|
const crypto = require('crypto'); |
|
const Keyv = require('keyv'); |
|
const { |
|
encoding_for_model: encodingForModel, |
|
get_encoding: getEncoding, |
|
} = require('@dqbd/tiktoken'); |
|
const { fetchEventSource } = require('@waylaidwanderer/fetch-event-source'); |
|
const { Agent, ProxyAgent } = require('undici'); |
|
const BaseClient = require('./BaseClient'); |
|
|
|
const CHATGPT_MODEL = 'gpt-3.5-turbo'; |
|
const tokenizersCache = {}; |
|
|
|
class ChatGPTClient extends BaseClient { |
|
constructor(apiKey, options = {}, cacheOptions = {}) { |
|
super(apiKey, options, cacheOptions); |
|
|
|
cacheOptions.namespace = cacheOptions.namespace || 'chatgpt'; |
|
this.conversationsCache = new Keyv(cacheOptions); |
|
this.setOptions(options); |
|
} |
|
|
|
setOptions(options) { |
|
if (this.options && !this.options.replaceOptions) { |
|
|
|
this.options.modelOptions = { |
|
...this.options.modelOptions, |
|
...options.modelOptions, |
|
}; |
|
delete options.modelOptions; |
|
|
|
this.options = { |
|
...this.options, |
|
...options, |
|
}; |
|
} else { |
|
this.options = options; |
|
} |
|
|
|
if (this.options.openaiApiKey) { |
|
this.apiKey = this.options.openaiApiKey; |
|
} |
|
|
|
const modelOptions = this.options.modelOptions || {}; |
|
this.modelOptions = { |
|
...modelOptions, |
|
|
|
model: modelOptions.model || CHATGPT_MODEL, |
|
temperature: typeof modelOptions.temperature === 'undefined' ? 0.8 : modelOptions.temperature, |
|
top_p: typeof modelOptions.top_p === 'undefined' ? 1 : modelOptions.top_p, |
|
presence_penalty: |
|
typeof modelOptions.presence_penalty === 'undefined' ? 1 : modelOptions.presence_penalty, |
|
stop: modelOptions.stop, |
|
}; |
|
|
|
this.isChatGptModel = this.modelOptions.model.startsWith('gpt-'); |
|
const { isChatGptModel } = this; |
|
this.isUnofficialChatGptModel = |
|
this.modelOptions.model.startsWith('text-chat') || |
|
this.modelOptions.model.startsWith('text-davinci-002-render'); |
|
const { isUnofficialChatGptModel } = this; |
|
|
|
|
|
this.maxContextTokens = this.options.maxContextTokens || (isChatGptModel ? 4095 : 4097); |
|
|
|
|
|
|
|
this.maxResponseTokens = this.modelOptions.max_tokens || 1024; |
|
this.maxPromptTokens = |
|
this.options.maxPromptTokens || this.maxContextTokens - this.maxResponseTokens; |
|
|
|
if (this.maxPromptTokens + this.maxResponseTokens > this.maxContextTokens) { |
|
throw new Error( |
|
`maxPromptTokens + max_tokens (${this.maxPromptTokens} + ${this.maxResponseTokens} = ${ |
|
this.maxPromptTokens + this.maxResponseTokens |
|
}) must be less than or equal to maxContextTokens (${this.maxContextTokens})`, |
|
); |
|
} |
|
|
|
this.userLabel = this.options.userLabel || 'User'; |
|
this.chatGptLabel = this.options.chatGptLabel || 'ChatGPT'; |
|
|
|
if (isChatGptModel) { |
|
|
|
|
|
|
|
this.startToken = '||>'; |
|
this.endToken = ''; |
|
this.gptEncoder = this.constructor.getTokenizer('cl100k_base'); |
|
} else if (isUnofficialChatGptModel) { |
|
this.startToken = '<|im_start|>'; |
|
this.endToken = '<|im_end|>'; |
|
this.gptEncoder = this.constructor.getTokenizer('text-davinci-003', true, { |
|
'<|im_start|>': 100264, |
|
'<|im_end|>': 100265, |
|
}); |
|
} else { |
|
|
|
|
|
|
|
this.startToken = '||>'; |
|
this.endToken = ''; |
|
try { |
|
this.gptEncoder = this.constructor.getTokenizer(this.modelOptions.model, true); |
|
} catch { |
|
this.gptEncoder = this.constructor.getTokenizer('text-davinci-003', true); |
|
} |
|
} |
|
|
|
if (!this.modelOptions.stop) { |
|
const stopTokens = [this.startToken]; |
|
if (this.endToken && this.endToken !== this.startToken) { |
|
stopTokens.push(this.endToken); |
|
} |
|
stopTokens.push(`\n${this.userLabel}:`); |
|
stopTokens.push('<|diff_marker|>'); |
|
|
|
this.modelOptions.stop = stopTokens; |
|
} |
|
|
|
if (this.options.reverseProxyUrl) { |
|
this.completionsUrl = this.options.reverseProxyUrl; |
|
} else if (isChatGptModel) { |
|
this.completionsUrl = 'https://api.openai.com/v1/chat/completions'; |
|
} else { |
|
this.completionsUrl = 'https://api.openai.com/v1/completions'; |
|
} |
|
|
|
return this; |
|
} |
|
|
|
static getTokenizer(encoding, isModelName = false, extendSpecialTokens = {}) { |
|
if (tokenizersCache[encoding]) { |
|
return tokenizersCache[encoding]; |
|
} |
|
let tokenizer; |
|
if (isModelName) { |
|
tokenizer = encodingForModel(encoding, extendSpecialTokens); |
|
} else { |
|
tokenizer = getEncoding(encoding, extendSpecialTokens); |
|
} |
|
tokenizersCache[encoding] = tokenizer; |
|
return tokenizer; |
|
} |
|
|
|
async getCompletion(input, onProgress, abortController = null) { |
|
if (!abortController) { |
|
abortController = new AbortController(); |
|
} |
|
const modelOptions = { ...this.modelOptions }; |
|
if (typeof onProgress === 'function') { |
|
modelOptions.stream = true; |
|
} |
|
if (this.isChatGptModel) { |
|
modelOptions.messages = input; |
|
} else { |
|
modelOptions.prompt = input; |
|
} |
|
const { debug } = this.options; |
|
const url = this.completionsUrl; |
|
if (debug) { |
|
console.debug(); |
|
console.debug(url); |
|
console.debug(modelOptions); |
|
console.debug(); |
|
} |
|
const opts = { |
|
method: 'POST', |
|
headers: { |
|
'Content-Type': 'application/json', |
|
}, |
|
body: JSON.stringify(modelOptions), |
|
dispatcher: new Agent({ |
|
bodyTimeout: 0, |
|
headersTimeout: 0, |
|
}), |
|
}; |
|
|
|
if (this.apiKey && this.options.azure) { |
|
opts.headers['api-key'] = this.apiKey; |
|
} else if (this.apiKey) { |
|
opts.headers.Authorization = `Bearer ${this.apiKey}`; |
|
} |
|
|
|
if (this.options.headers) { |
|
opts.headers = { ...opts.headers, ...this.options.headers }; |
|
} |
|
|
|
if (this.options.proxy) { |
|
opts.dispatcher = new ProxyAgent(this.options.proxy); |
|
} |
|
|
|
if (modelOptions.stream) { |
|
|
|
return new Promise(async (resolve, reject) => { |
|
try { |
|
let done = false; |
|
await fetchEventSource(url, { |
|
...opts, |
|
signal: abortController.signal, |
|
async onopen(response) { |
|
if (response.status === 200) { |
|
return; |
|
} |
|
if (debug) { |
|
console.debug(response); |
|
} |
|
let error; |
|
try { |
|
const body = await response.text(); |
|
error = new Error(`Failed to send message. HTTP ${response.status} - ${body}`); |
|
error.status = response.status; |
|
error.json = JSON.parse(body); |
|
} catch { |
|
error = error || new Error(`Failed to send message. HTTP ${response.status}`); |
|
} |
|
throw error; |
|
}, |
|
onclose() { |
|
if (debug) { |
|
console.debug('Server closed the connection unexpectedly, returning...'); |
|
} |
|
|
|
if (!done) { |
|
onProgress('[DONE]'); |
|
abortController.abort(); |
|
resolve(); |
|
} |
|
}, |
|
onerror(err) { |
|
if (debug) { |
|
console.debug(err); |
|
} |
|
|
|
throw err; |
|
}, |
|
onmessage(message) { |
|
if (debug) { |
|
|
|
} |
|
if (!message.data || message.event === 'ping') { |
|
return; |
|
} |
|
if (message.data === '[DONE]') { |
|
onProgress('[DONE]'); |
|
abortController.abort(); |
|
resolve(); |
|
done = true; |
|
return; |
|
} |
|
onProgress(JSON.parse(message.data)); |
|
}, |
|
}); |
|
} catch (err) { |
|
reject(err); |
|
} |
|
}); |
|
} |
|
const response = await fetch(url, { |
|
...opts, |
|
signal: abortController.signal, |
|
}); |
|
if (response.status !== 200) { |
|
const body = await response.text(); |
|
const error = new Error(`Failed to send message. HTTP ${response.status} - ${body}`); |
|
error.status = response.status; |
|
try { |
|
error.json = JSON.parse(body); |
|
} catch { |
|
error.body = body; |
|
} |
|
throw error; |
|
} |
|
return response.json(); |
|
} |
|
|
|
async generateTitle(userMessage, botMessage) { |
|
const instructionsPayload = { |
|
role: 'system', |
|
content: `Write an extremely concise subtitle for this conversation with no more than a few words. All words should be capitalized. Exclude punctuation. |
|
|
|
||>Message: |
|
${userMessage.message} |
|
||>Response: |
|
${botMessage.message} |
|
|
|
||>Title:`, |
|
}; |
|
|
|
const titleGenClientOptions = JSON.parse(JSON.stringify(this.options)); |
|
titleGenClientOptions.modelOptions = { |
|
model: 'gpt-3.5-turbo', |
|
temperature: 0, |
|
presence_penalty: 0, |
|
frequency_penalty: 0, |
|
}; |
|
const titleGenClient = new ChatGPTClient(this.apiKey, titleGenClientOptions); |
|
const result = await titleGenClient.getCompletion([instructionsPayload], null); |
|
|
|
return result.choices[0].message.content |
|
.replace(/[^a-zA-Z0-9' ]/g, '') |
|
.replace(/\s+/g, ' ') |
|
.trim(); |
|
} |
|
|
|
async sendMessage(message, opts = {}) { |
|
if (opts.clientOptions && typeof opts.clientOptions === 'object') { |
|
this.setOptions(opts.clientOptions); |
|
} |
|
|
|
const conversationId = opts.conversationId || crypto.randomUUID(); |
|
const parentMessageId = opts.parentMessageId || crypto.randomUUID(); |
|
|
|
let conversation = |
|
typeof opts.conversation === 'object' |
|
? opts.conversation |
|
: await this.conversationsCache.get(conversationId); |
|
|
|
let isNewConversation = false; |
|
if (!conversation) { |
|
conversation = { |
|
messages: [], |
|
createdAt: Date.now(), |
|
}; |
|
isNewConversation = true; |
|
} |
|
|
|
const shouldGenerateTitle = opts.shouldGenerateTitle && isNewConversation; |
|
|
|
const userMessage = { |
|
id: crypto.randomUUID(), |
|
parentMessageId, |
|
role: 'User', |
|
message, |
|
}; |
|
conversation.messages.push(userMessage); |
|
|
|
|
|
|
|
const { prompt: payload, context } = await this.buildPrompt( |
|
conversation.messages, |
|
userMessage.id, |
|
{ |
|
isChatGptModel: this.isChatGptModel, |
|
promptPrefix: opts.promptPrefix, |
|
}, |
|
); |
|
|
|
if (this.options.keepNecessaryMessagesOnly) { |
|
conversation.messages = context; |
|
} |
|
|
|
let reply = ''; |
|
let result = null; |
|
if (typeof opts.onProgress === 'function') { |
|
await this.getCompletion( |
|
payload, |
|
(progressMessage) => { |
|
if (progressMessage === '[DONE]') { |
|
return; |
|
} |
|
const token = this.isChatGptModel |
|
? progressMessage.choices[0].delta.content |
|
: progressMessage.choices[0].text; |
|
|
|
if (!token) { |
|
return; |
|
} |
|
if (this.options.debug) { |
|
console.debug(token); |
|
} |
|
if (token === this.endToken) { |
|
return; |
|
} |
|
opts.onProgress(token); |
|
reply += token; |
|
}, |
|
opts.abortController || new AbortController(), |
|
); |
|
} else { |
|
result = await this.getCompletion( |
|
payload, |
|
null, |
|
opts.abortController || new AbortController(), |
|
); |
|
if (this.options.debug) { |
|
console.debug(JSON.stringify(result)); |
|
} |
|
if (this.isChatGptModel) { |
|
reply = result.choices[0].message.content; |
|
} else { |
|
reply = result.choices[0].text.replace(this.endToken, ''); |
|
} |
|
} |
|
|
|
|
|
if (this.options.debug) { |
|
console.debug(); |
|
} |
|
|
|
reply = reply.trim(); |
|
|
|
const replyMessage = { |
|
id: crypto.randomUUID(), |
|
parentMessageId: userMessage.id, |
|
role: 'ChatGPT', |
|
message: reply, |
|
}; |
|
conversation.messages.push(replyMessage); |
|
|
|
const returnData = { |
|
response: replyMessage.message, |
|
conversationId, |
|
parentMessageId: replyMessage.parentMessageId, |
|
messageId: replyMessage.id, |
|
details: result || {}, |
|
}; |
|
|
|
if (shouldGenerateTitle) { |
|
conversation.title = await this.generateTitle(userMessage, replyMessage); |
|
returnData.title = conversation.title; |
|
} |
|
|
|
await this.conversationsCache.set(conversationId, conversation); |
|
|
|
if (this.options.returnConversation) { |
|
returnData.conversation = conversation; |
|
} |
|
|
|
return returnData; |
|
} |
|
|
|
async buildPrompt(messages, parentMessageId, { isChatGptModel = false, promptPrefix = null }) { |
|
const orderedMessages = this.constructor.getMessagesForConversation(messages, parentMessageId); |
|
|
|
promptPrefix = (promptPrefix || this.options.promptPrefix || '').trim(); |
|
if (promptPrefix) { |
|
|
|
if (!promptPrefix.endsWith(`${this.endToken}`)) { |
|
promptPrefix = `${promptPrefix.trim()}${this.endToken}\n\n`; |
|
} |
|
promptPrefix = `${this.startToken}Instructions:\n${promptPrefix}`; |
|
} else { |
|
const currentDateString = new Date().toLocaleDateString('en-us', { |
|
year: 'numeric', |
|
month: 'long', |
|
day: 'numeric', |
|
}); |
|
promptPrefix = `${this.startToken}Instructions:\nYou are ChatGPT, a large language model trained by OpenAI. Respond conversationally.\nCurrent date: ${currentDateString}${this.endToken}\n\n`; |
|
} |
|
|
|
const promptSuffix = `${this.startToken}${this.chatGptLabel}:\n`; |
|
|
|
const instructionsPayload = { |
|
role: 'system', |
|
name: 'instructions', |
|
content: promptPrefix, |
|
}; |
|
|
|
const messagePayload = { |
|
role: 'system', |
|
content: promptSuffix, |
|
}; |
|
|
|
let currentTokenCount; |
|
if (isChatGptModel) { |
|
currentTokenCount = |
|
this.getTokenCountForMessage(instructionsPayload) + |
|
this.getTokenCountForMessage(messagePayload); |
|
} else { |
|
currentTokenCount = this.getTokenCount(`${promptPrefix}${promptSuffix}`); |
|
} |
|
let promptBody = ''; |
|
const maxTokenCount = this.maxPromptTokens; |
|
|
|
const context = []; |
|
|
|
|
|
|
|
const buildPromptBody = async () => { |
|
if (currentTokenCount < maxTokenCount && orderedMessages.length > 0) { |
|
const message = orderedMessages.pop(); |
|
const roleLabel = |
|
message?.isCreatedByUser || message?.role?.toLowerCase() === 'user' |
|
? this.userLabel |
|
: this.chatGptLabel; |
|
const messageString = `${this.startToken}${roleLabel}:\n${ |
|
message?.text ?? message?.message |
|
}${this.endToken}\n`; |
|
let newPromptBody; |
|
if (promptBody || isChatGptModel) { |
|
newPromptBody = `${messageString}${promptBody}`; |
|
} else { |
|
|
|
|
|
|
|
|
|
newPromptBody = `${promptPrefix}${messageString}${promptBody}`; |
|
} |
|
|
|
context.unshift(message); |
|
|
|
const tokenCountForMessage = this.getTokenCount(messageString); |
|
const newTokenCount = currentTokenCount + tokenCountForMessage; |
|
if (newTokenCount > maxTokenCount) { |
|
if (promptBody) { |
|
|
|
return false; |
|
} |
|
|
|
throw new Error( |
|
`Prompt is too long. Max token count is ${maxTokenCount}, but prompt is ${newTokenCount} tokens long.`, |
|
); |
|
} |
|
promptBody = newPromptBody; |
|
currentTokenCount = newTokenCount; |
|
|
|
await new Promise((resolve) => setImmediate(resolve)); |
|
return buildPromptBody(); |
|
} |
|
return true; |
|
}; |
|
|
|
await buildPromptBody(); |
|
|
|
const prompt = `${promptBody}${promptSuffix}`; |
|
if (isChatGptModel) { |
|
messagePayload.content = prompt; |
|
|
|
currentTokenCount += 2; |
|
} |
|
|
|
|
|
this.modelOptions.max_tokens = Math.min( |
|
this.maxContextTokens - currentTokenCount, |
|
this.maxResponseTokens, |
|
); |
|
|
|
if (this.options.debug) { |
|
console.debug(`Prompt : ${prompt}`); |
|
} |
|
|
|
if (isChatGptModel) { |
|
return { prompt: [instructionsPayload, messagePayload], context }; |
|
} |
|
return { prompt, context }; |
|
} |
|
|
|
getTokenCount(text) { |
|
return this.gptEncoder.encode(text, 'all').length; |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
getTokenCountForMessage(message) { |
|
let tokensPerMessage; |
|
let nameAdjustment; |
|
if (this.modelOptions.model.startsWith('gpt-4')) { |
|
tokensPerMessage = 3; |
|
nameAdjustment = 1; |
|
} else { |
|
tokensPerMessage = 4; |
|
nameAdjustment = -1; |
|
} |
|
|
|
|
|
const propertyTokenCounts = Object.entries(message).map(([key, value]) => { |
|
|
|
const numTokens = this.getTokenCount(value); |
|
|
|
|
|
const adjustment = key === 'name' ? nameAdjustment : 0; |
|
return numTokens + adjustment; |
|
}); |
|
|
|
|
|
return propertyTokenCounts.reduce((a, b) => a + b, tokensPerMessage); |
|
} |
|
} |
|
|
|
module.exports = ChatGPTClient; |
|
|