2023-02-01 10:48:36 +00:00
import Keyv from 'keyv'
2022-12-10 22:19:35 +00:00
import pTimeout from 'p-timeout'
2023-02-01 09:14:10 +00:00
import QuickLRU from 'quick-lru'
2022-12-05 05:13:36 +00:00
import { v4 as uuidv4 } from 'uuid'
2023-02-19 09:48:06 +00:00
import * as tokenizer from './tokenizer'
2022-12-05 05:13:36 +00:00
import * as types from './types'
2023-02-14 06:30:06 +00:00
import { fetch as globalFetch } from './fetch'
2022-12-05 23:09:31 +00:00
import { fetchSSE } from './fetch-sse'
2022-12-05 05:13:36 +00:00
2023-03-02 02:49:20 +00:00
const CHATGPT_MODEL = 'gpt-3.5-turbo-0301'
2023-02-01 10:48:36 +00:00
2023-02-02 00:01:34 +00:00
const USER_LABEL_DEFAULT = 'User'
const ASSISTANT_LABEL_DEFAULT = 'ChatGPT'
2022-12-02 23:43:59 +00:00
2023-02-01 09:14:10 +00:00
export class ChatGPTAPI {
protected _apiKey : string
2022-12-05 05:13:36 +00:00
protected _apiBaseUrl : string
2023-02-01 09:14:10 +00:00
protected _debug : boolean
2023-02-01 10:48:36 +00:00
2023-03-02 02:49:20 +00:00
protected _systemMessage : string
protected _completionParams : Omit <
types . openai . CreateChatCompletionRequest ,
'messages' | 'n'
>
2023-02-02 00:01:34 +00:00
protected _maxModelTokens : number
protected _maxResponseTokens : number
2023-02-14 06:30:06 +00:00
protected _fetch : types.FetchFn
2023-02-02 00:01:34 +00:00
2023-02-01 09:14:10 +00:00
protected _getMessageById : types.GetMessageByIdFunction
2023-02-01 10:48:36 +00:00
protected _upsertMessage : types.UpsertMessageFunction
protected _messageStore : Keyv < types.ChatMessage >
2022-12-02 23:43:59 +00:00
/ * *
2023-03-02 02:49:20 +00:00
* Creates a new client wrapper around OpenAI 's chat completion API, mimicing the official ChatGPT webapp' s functionality as closely as possible .
2022-12-05 05:13:36 +00:00
*
2023-02-01 10:48:36 +00:00
* @param apiKey - OpenAI API key ( required ) .
2023-02-02 00:01:34 +00:00
* @param apiBaseUrl - Optional override for the OpenAI API base URL .
2023-02-01 10:48:36 +00:00
* @param debug - Optional enables logging debugging info to stdout .
2023-03-02 02:49:20 +00:00
* @param completionParams - Param overrides to send to the [ OpenAI chat completion API ] ( https : //platform.openai.com/docs/api-reference/chat/create). Options like `temperature` and `presence_penalty` can be tweaked to change the personality of the assistant.
* @param maxModelTokens - Optional override for the maximum number of tokens allowed by the model ' s context . Defaults to 4096 .
* @param maxResponseTokens - Optional override for the minimum number of tokens allowed for the model ' s response . Defaults to 1000 .
2023-02-02 00:01:34 +00:00
* @param messageStore - Optional [ Keyv ] ( https : //github.com/jaredwray/keyv) store to persist chat messages to. If not provided, messages will be lost when the process exits.
* @param getMessageById - Optional function to retrieve a message by its ID . If not provided , the default implementation will be used ( using an in - memory ` messageStore ` ) .
* @param upsertMessage - Optional function to insert or update a message . If not provided , the default implementation will be used ( using an in - memory ` messageStore ` ) .
2023-02-14 06:30:06 +00:00
* @param fetch - Optional override for the ` fetch ` implementation to use . Defaults to the global ` fetch ` function .
2022-12-02 23:43:59 +00:00
* /
2022-12-05 05:13:36 +00:00
constructor ( opts : {
2023-02-01 09:14:10 +00:00
apiKey : string
2022-12-02 23:43:59 +00:00
2023-02-01 09:14:10 +00:00
/** @defaultValue `'https://api.openai.com'` **/
2022-12-05 05:13:36 +00:00
apiBaseUrl? : string
2022-12-02 23:43:59 +00:00
2022-12-12 16:37:44 +00:00
/** @defaultValue `false` **/
debug? : boolean
2022-12-17 04:48:42 +00:00
2023-03-02 02:49:20 +00:00
completionParams? : Partial <
Omit < types.openai.CreateChatCompletionRequest , ' messages ' | ' n ' >
>
systemMessage? : string
2023-02-01 10:48:36 +00:00
2023-02-02 00:01:34 +00:00
/** @defaultValue `4096` **/
maxModelTokens? : number
/** @defaultValue `1000` **/
maxResponseTokens? : number
messageStore? : Keyv
2023-02-01 09:14:10 +00:00
getMessageById? : types.GetMessageByIdFunction
2023-02-01 10:48:36 +00:00
upsertMessage? : types.UpsertMessageFunction
2023-02-14 06:30:06 +00:00
fetch? : types.FetchFn
2023-02-01 09:14:10 +00:00
} ) {
2022-12-02 23:43:59 +00:00
const {
2023-02-01 09:14:10 +00:00
apiKey ,
apiBaseUrl = 'https://api.openai.com' ,
debug = false ,
2023-02-01 10:48:36 +00:00
messageStore ,
2023-02-02 00:01:34 +00:00
completionParams ,
2023-03-02 02:49:20 +00:00
systemMessage ,
2023-02-02 00:01:34 +00:00
maxModelTokens = 4096 ,
maxResponseTokens = 1000 ,
2023-02-01 10:48:36 +00:00
getMessageById = this . _defaultGetMessageById ,
2023-02-14 06:30:06 +00:00
upsertMessage = this . _defaultUpsertMessage ,
fetch = globalFetch
2022-12-02 23:43:59 +00:00
} = opts
2023-02-01 09:14:10 +00:00
this . _apiKey = apiKey
2022-12-05 05:13:36 +00:00
this . _apiBaseUrl = apiBaseUrl
2023-02-01 09:14:10 +00:00
this . _debug = ! ! debug
2023-02-14 06:30:06 +00:00
this . _fetch = fetch
2023-02-01 10:48:36 +00:00
this . _completionParams = {
model : CHATGPT_MODEL ,
2023-02-13 05:36:42 +00:00
temperature : 0.8 ,
top_p : 1.0 ,
presence_penalty : 1.0 ,
2023-02-01 10:48:36 +00:00
. . . completionParams
}
2023-02-13 05:36:42 +00:00
2023-03-02 02:49:20 +00:00
this . _systemMessage = systemMessage
2023-02-13 05:36:42 +00:00
2023-03-02 02:49:20 +00:00
if ( this . _systemMessage === undefined ) {
const currentDate = new Date ( ) . toISOString ( ) . split ( 'T' ) [ 0 ]
this . _systemMessage = ` You are ChatGPT, a large language model trained by OpenAI. Answer as concisely as possiboe. \ nCurrent date: ${ currentDate } \ n `
2023-02-13 05:36:42 +00:00
}
2023-02-02 00:01:34 +00:00
this . _maxModelTokens = maxModelTokens
this . _maxResponseTokens = maxResponseTokens
2023-02-01 10:48:36 +00:00
2023-02-01 09:14:10 +00:00
this . _getMessageById = getMessageById
2023-02-01 10:48:36 +00:00
this . _upsertMessage = upsertMessage
2022-12-07 04:07:14 +00:00
2023-02-01 10:48:36 +00:00
if ( messageStore ) {
this . _messageStore = messageStore
} else {
this . _messageStore = new Keyv < types.ChatMessage , any > ( {
store : new QuickLRU < string , types.ChatMessage > ( { maxSize : 10000 } )
} )
}
2022-12-12 11:28:53 +00:00
2023-02-01 09:14:10 +00:00
if ( ! this . _apiKey ) {
2023-03-02 02:49:20 +00:00
throw new Error ( 'OpenAI missing required apiKey' )
2022-12-12 11:28:53 +00:00
}
2023-02-14 06:30:06 +00:00
if ( ! this . _fetch ) {
throw new Error ( 'Invalid environment; fetch is not defined' )
}
if ( typeof this . _fetch !== 'function' ) {
throw new Error ( 'Invalid "fetch" is not a function' )
}
2022-12-02 23:43:59 +00:00
}
2022-12-05 05:13:36 +00:00
/ * *
2023-03-02 02:49:20 +00:00
* Sends a message to the OpenAI chat completions endpoint , waits for the response
* to resolve , and returns the response .
2022-12-05 05:13:36 +00:00
*
2023-02-02 00:01:34 +00:00
* If you want your response to have historical context , you must provide a valid ` parentMessageId ` .
*
2022-12-07 04:07:14 +00:00
* If you want to receive a stream of partial responses , use ` opts.onProgress ` .
*
2023-03-02 02:49:20 +00:00
* Set ` debug: true ` in the ` ChatGPTAPI ` constructor to log more info on the full prompt sent to the OpenAI chat completions API . You can override the ` systemMessage ` in ` opts ` to customize the assistant ' s instructions .
2023-02-02 00:01:34 +00:00
*
2022-12-06 22:13:11 +00:00
* @param message - The prompt message to send
2023-02-02 00:01:34 +00:00
* @param opts . parentMessageId - Optional ID of the previous message in the conversation ( defaults to ` undefined ` )
2022-12-12 16:37:44 +00:00
* @param opts . messageId - Optional ID of the message to send ( defaults to a random UUID )
2023-03-02 02:49:20 +00:00
* @param opts . systemMessage - Optional override for the chat "system message" which acts as instructions to the model ( defaults to the ChatGPT system message )
2022-12-07 04:07:14 +00:00
* @param opts . timeoutMs - Optional timeout in milliseconds ( defaults to no timeout )
2022-12-07 00:19:30 +00:00
* @param opts . onProgress - Optional callback which will be invoked every time the partial response is updated
* @param opts . abortSignal - Optional callback used to abort the underlying ` fetch ` call using an [ AbortController ] ( https : //developer.mozilla.org/en-US/docs/Web/API/AbortController)
*
2022-12-06 22:13:11 +00:00
* @returns The response from ChatGPT
2022-12-05 05:13:36 +00:00
* /
2023-02-01 09:14:10 +00:00
async sendMessage (
text : string ,
2022-12-07 00:19:30 +00:00
opts : types.SendMessageOptions = { }
2023-02-01 09:14:10 +00:00
) : Promise < types.ChatMessage > {
2022-12-06 07:38:32 +00:00
const {
2023-02-01 09:14:10 +00:00
parentMessageId ,
2022-12-12 16:37:44 +00:00
messageId = uuidv4 ( ) ,
2022-12-07 04:07:14 +00:00
timeoutMs ,
2023-02-01 09:14:10 +00:00
onProgress ,
stream = onProgress ? true : false
2022-12-06 07:38:32 +00:00
} = opts
2022-12-05 05:13:36 +00:00
2022-12-07 04:07:14 +00:00
let { abortSignal } = opts
let abortController : AbortController = null
if ( timeoutMs && ! abortSignal ) {
abortController = new AbortController ( )
abortSignal = abortController . signal
}
2023-02-01 10:48:36 +00:00
const message : types.ChatMessage = {
2023-02-01 09:14:10 +00:00
role : 'user' ,
id : messageId ,
parentMessageId ,
text
2022-12-06 07:38:32 +00:00
}
2023-02-01 10:48:36 +00:00
await this . _upsertMessage ( message )
2022-12-06 07:38:32 +00:00
2023-03-02 02:49:20 +00:00
const { messages , maxTokens , numTokens } = await this . _buildMessages (
text ,
opts
)
2022-12-02 23:43:59 +00:00
2023-02-01 09:14:10 +00:00
const result : types.ChatMessage = {
role : 'assistant' ,
id : uuidv4 ( ) ,
parentMessageId : messageId ,
text : ''
2022-12-17 04:48:42 +00:00
}
2022-12-05 05:13:36 +00:00
2023-02-01 09:14:10 +00:00
const responseP = new Promise < types.ChatMessage > (
async ( resolve , reject ) = > {
2023-03-02 02:49:20 +00:00
const url = ` ${ this . _apiBaseUrl } /v1/chat/completions `
2023-02-01 09:14:10 +00:00
const headers = {
'Content-Type' : 'application/json' ,
Authorization : ` Bearer ${ this . _apiKey } `
}
const body = {
2023-02-01 10:48:36 +00:00
max_tokens : maxTokens ,
. . . this . _completionParams ,
2023-03-02 02:49:20 +00:00
messages ,
2023-02-01 10:48:36 +00:00
stream
2023-02-01 09:14:10 +00:00
}
2022-12-12 16:37:44 +00:00
2023-02-01 09:14:10 +00:00
if ( this . _debug ) {
console . log ( ` sendMessage ( ${ numTokens } tokens) ` , body )
}
2022-12-12 16:37:44 +00:00
2023-02-01 09:14:10 +00:00
if ( stream ) {
2023-02-14 06:30:06 +00:00
fetchSSE (
url ,
{
method : 'POST' ,
headers ,
body : JSON.stringify ( body ) ,
signal : abortSignal ,
onMessage : ( data : string ) = > {
if ( data === '[DONE]' ) {
result . text = result . text . trim ( )
return resolve ( result )
}
2022-12-05 05:13:36 +00:00
2023-02-14 06:30:06 +00:00
try {
2023-03-02 02:49:20 +00:00
const response : types.CreateChatCompletionDeltaResponse =
2023-02-14 06:30:06 +00:00
JSON . parse ( data )
2023-02-01 09:14:10 +00:00
2023-02-14 06:30:06 +00:00
if ( response . id ) {
result . id = response . id
}
2023-02-13 05:36:42 +00:00
2023-02-14 06:30:06 +00:00
if ( response ? . choices ? . length ) {
2023-03-02 02:49:20 +00:00
const delta = response . choices [ 0 ] . delta
if ( delta ? . content ) {
result . delta = delta . content
result . text += delta . content
result . detail = response
if ( delta . role ) {
result . role = delta . role
}
onProgress ? . ( result )
}
2023-02-14 06:30:06 +00:00
}
} catch ( err ) {
2023-03-02 02:49:20 +00:00
console . warn ( 'OpenAI stream SEE event unexpected error' , err )
2023-02-14 06:30:06 +00:00
return reject ( err )
2023-02-01 09:14:10 +00:00
}
}
2023-02-14 06:30:06 +00:00
} ,
this . _fetch
) . catch ( reject )
2023-02-01 09:14:10 +00:00
} else {
2022-12-05 05:13:36 +00:00
try {
2023-02-14 06:30:06 +00:00
const res = await this . _fetch ( url , {
2023-02-01 09:14:10 +00:00
method : 'POST' ,
headers ,
body : JSON.stringify ( body ) ,
signal : abortSignal
} )
if ( ! res . ok ) {
const reason = await res . text ( )
2023-03-02 02:49:20 +00:00
const msg = ` OpenAI error ${
2023-02-01 09:14:10 +00:00
res . status || res . statusText
} : $ { reason } `
const error = new types . ChatGPTError ( msg , { cause : res } )
error . statusCode = res . status
error . statusText = res . statusText
return reject ( error )
2022-12-06 07:38:32 +00:00
}
2022-12-07 00:19:30 +00:00
2023-03-02 02:49:20 +00:00
const response : types.openai.CreateChatCompletionResponse =
await res . json ( )
2023-02-01 09:14:10 +00:00
if ( this . _debug ) {
console . log ( response )
2022-12-17 04:48:42 +00:00
}
2023-02-13 06:42:05 +00:00
if ( response ? . id ) {
2023-02-13 05:36:42 +00:00
result . id = response . id
}
2023-02-13 07:43:47 +00:00
2023-02-13 05:36:42 +00:00
if ( response ? . choices ? . length ) {
2023-03-02 02:49:20 +00:00
const message = response . choices [ 0 ] . message
result . text = message . content
if ( message . role ) {
result . role = message . role
}
2023-02-13 05:36:42 +00:00
} else {
2023-02-13 06:42:05 +00:00
const res = response as any
2023-02-13 05:36:42 +00:00
return reject (
new Error (
2023-03-02 02:49:20 +00:00
` OpenAI error: ${
2023-02-13 06:42:05 +00:00
res ? . detail ? . message || res ? . detail || 'unknown'
2023-02-13 05:36:42 +00:00
} `
)
)
}
result . detail = response
2022-12-05 05:13:36 +00:00
2023-02-01 09:14:10 +00:00
return resolve ( result )
2022-12-05 05:13:36 +00:00
} catch ( err ) {
2023-02-01 09:14:10 +00:00
return reject ( err )
2022-12-05 05:13:36 +00:00
}
}
2023-02-01 09:14:10 +00:00
}
2023-02-01 10:48:36 +00:00
) . then ( ( message ) = > {
return this . _upsertMessage ( message ) . then ( ( ) = > message )
2022-12-05 05:13:36 +00:00
} )
2022-12-07 04:07:14 +00:00
if ( timeoutMs ) {
if ( abortController ) {
// This will be called when a timeout occurs in order for us to forcibly
// ensure that the underlying HTTP request is aborted.
; ( responseP as any ) . cancel = ( ) = > {
abortController . abort ( )
}
}
return pTimeout ( responseP , {
milliseconds : timeoutMs ,
2023-03-02 02:49:20 +00:00
message : 'OpenAI timed out waiting for response'
2022-12-07 04:07:14 +00:00
} )
} else {
return responseP
}
2022-12-02 23:43:59 +00:00
}
2023-02-13 07:43:47 +00:00
get apiKey ( ) : string {
return this . _apiKey
}
set apiKey ( apiKey : string ) {
this . _apiKey = apiKey
}
2023-03-02 02:49:20 +00:00
protected async _buildMessages ( text : string , opts : types.SendMessageOptions ) {
const { systemMessage = this . _systemMessage } = opts
let { parentMessageId } = opts
const userLabel = USER_LABEL_DEFAULT
const assistantLabel = ASSISTANT_LABEL_DEFAULT
2023-02-01 09:14:10 +00:00
2023-02-02 00:01:34 +00:00
const maxNumTokens = this . _maxModelTokens - this . _maxResponseTokens
2023-03-02 02:49:20 +00:00
let messages : types.openai.ChatCompletionRequestMessage [ ] = [ ]
if ( systemMessage ) {
messages . push ( {
role : 'system' ,
content : systemMessage
} )
}
const systemMessageOffset = messages . length
let nextMessages = messages . concat ( [
{
. . . {
role : 'user' ,
content : text ,
name : opts.name
}
}
] )
let numTokens = 0
2023-02-01 09:14:10 +00:00
do {
2023-03-02 02:49:20 +00:00
const prompt = nextMessages
. reduce ( ( prompt , message ) = > {
switch ( message . role ) {
case 'system' :
return [ prompt , ` Instructions: \ n ${ message . content } ` ]
case 'user' :
return [ prompt , ` ${ userLabel } : \ n ${ message . content } ` ]
default :
return [ prompt , ` ${ assistantLabel } : \ n ${ message . content } ` ]
}
} , [ ] )
. join ( '\n\n' )
const nextNumTokensEstimate = await this . _getTokenCount ( prompt )
const isValidPrompt = nextNumTokensEstimate <= maxNumTokens
2023-02-01 09:14:10 +00:00
if ( prompt && ! isValidPrompt ) {
break
2022-12-15 04:41:43 +00:00
}
2023-03-02 02:49:20 +00:00
messages = nextMessages
numTokens = nextNumTokensEstimate
2022-12-15 04:41:43 +00:00
2023-02-01 09:14:10 +00:00
if ( ! isValidPrompt ) {
break
2022-12-12 00:42:44 +00:00
}
2023-02-01 09:14:10 +00:00
if ( ! parentMessageId ) {
break
2022-12-12 16:37:44 +00:00
}
2023-02-01 09:14:10 +00:00
const parentMessage = await this . _getMessageById ( parentMessageId )
if ( ! parentMessage ) {
break
2022-12-02 23:43:59 +00:00
}
2023-02-01 09:14:10 +00:00
const parentMessageRole = parentMessage . role || 'user'
2022-12-05 23:09:31 +00:00
2023-03-02 02:49:20 +00:00
nextMessages = nextMessages . slice ( 0 , systemMessageOffset ) . concat ( [
{
. . . {
role : parentMessageRole ,
content : parentMessage.text ,
name : parentMessage.name
}
} ,
. . . nextMessages . slice ( systemMessageOffset )
] )
2023-02-01 09:14:10 +00:00
parentMessageId = parentMessage . parentMessageId
} while ( true )
2022-12-11 11:43:39 +00:00
2023-02-02 00:01:34 +00:00
// Use up to 4096 tokens (prompt + response), but try to leave 1000 tokens
2023-02-01 09:14:10 +00:00
// for the response.
2023-02-02 00:01:34 +00:00
const maxTokens = Math . max (
1 ,
Math . min ( this . _maxModelTokens - numTokens , this . _maxResponseTokens )
)
2022-12-12 16:37:44 +00:00
2023-03-02 02:49:20 +00:00
return { messages , maxTokens , numTokens }
2023-02-01 09:14:10 +00:00
}
protected async _getTokenCount ( text : string ) {
2023-03-02 02:49:20 +00:00
// TODO: use a better fix in the tokenizer
2023-03-01 02:44:48 +00:00
text = text . replace ( /<\|endoftext\|>/g , '' )
2023-03-01 02:45:57 +00:00
2023-02-19 09:48:06 +00:00
return tokenizer . encode ( text ) . length
2022-12-05 05:14:23 +00:00
}
2022-12-06 07:38:32 +00:00
2023-02-01 09:14:10 +00:00
protected async _defaultGetMessageById (
id : string
) : Promise < types.ChatMessage > {
2023-02-13 05:36:42 +00:00
const res = await this . _messageStore . get ( id )
return res
2023-02-01 10:48:36 +00:00
}
protected async _defaultUpsertMessage (
message : types.ChatMessage
) : Promise < void > {
2023-02-13 05:36:42 +00:00
await this . _messageStore . set ( message . id , message )
2022-12-06 07:38:32 +00:00
}
2022-12-02 23:43:59 +00:00
}