| const Keyv = require('keyv'); |
| const crypto = require('crypto'); |
| const { |
| EModelEndpoint, |
| resolveHeaders, |
| CohereConstants, |
| mapModelToAzureConfig, |
| } = require('librechat-data-provider'); |
| const { CohereClient } = require('cohere-ai'); |
| const { encoding_for_model: encodingForModel, get_encoding: getEncoding } = require('tiktoken'); |
| const { fetchEventSource } = require('@waylaidwanderer/fetch-event-source'); |
| const { createCoherePayload } = require('./llm'); |
| const { Agent, ProxyAgent } = require('undici'); |
| const BaseClient = require('./BaseClient'); |
| const { logger } = require('~/config'); |
| const { extractBaseURL, constructAzureURL, genAzureChatCompletion } = require('~/utils'); |
|
|
| 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.includes('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, onTokenProgress, abortController = null) { |
| if (!abortController) { |
| abortController = new AbortController(); |
| } |
|
|
| let modelOptions = { ...this.modelOptions }; |
| if (typeof onProgress === 'function') { |
| modelOptions.stream = true; |
| } |
| if (this.isChatGptModel) { |
| modelOptions.messages = input; |
| } else { |
| modelOptions.prompt = input; |
| } |
|
|
| if (this.useOpenRouter && modelOptions.prompt) { |
| delete modelOptions.stop; |
| } |
|
|
| const { debug } = this.options; |
| let baseURL = this.completionsUrl; |
| if (debug) { |
| console.debug(); |
| console.debug(baseURL); |
| console.debug(modelOptions); |
| console.debug(); |
| } |
|
|
| const opts = { |
| method: 'POST', |
| headers: { |
| 'Content-Type': 'application/json', |
| }, |
| dispatcher: new Agent({ |
| bodyTimeout: 0, |
| headersTimeout: 0, |
| }), |
| }; |
|
|
| if (this.isVisionModel) { |
| modelOptions.max_tokens = 4000; |
| } |
|
|
| |
| const azureConfig = this.options?.req?.app?.locals?.[EModelEndpoint.azureOpenAI]; |
|
|
| const isAzure = this.azure || this.options.azure; |
| if ( |
| (isAzure && this.isVisionModel && azureConfig) || |
| (azureConfig && this.isVisionModel && this.options.endpoint === EModelEndpoint.azureOpenAI) |
| ) { |
| const { modelGroupMap, groupMap } = azureConfig; |
| const { |
| azureOptions, |
| baseURL, |
| headers = {}, |
| serverless, |
| } = mapModelToAzureConfig({ |
| modelName: modelOptions.model, |
| modelGroupMap, |
| groupMap, |
| }); |
| opts.headers = resolveHeaders(headers); |
| this.langchainProxy = extractBaseURL(baseURL); |
| this.apiKey = azureOptions.azureOpenAIApiKey; |
|
|
| const groupName = modelGroupMap[modelOptions.model].group; |
| this.options.addParams = azureConfig.groupMap[groupName].addParams; |
| this.options.dropParams = azureConfig.groupMap[groupName].dropParams; |
| |
|
|
| this.azure = !serverless && azureOptions; |
| this.azureEndpoint = |
| !serverless && genAzureChatCompletion(this.azure, modelOptions.model, this); |
| } |
|
|
| if (this.options.headers) { |
| opts.headers = { ...opts.headers, ...this.options.headers }; |
| } |
|
|
| if (isAzure) { |
| |
| delete modelOptions.model; |
|
|
| baseURL = this.langchainProxy |
| ? constructAzureURL({ |
| baseURL: this.langchainProxy, |
| azureOptions: this.azure, |
| }) |
| : this.azureEndpoint.split(/(?<!\/)\/(chat|completion)\//)[0]; |
|
|
| if (this.options.forcePrompt) { |
| baseURL += '/completions'; |
| } else { |
| baseURL += '/chat/completions'; |
| } |
|
|
| opts.defaultQuery = { 'api-version': this.azure.azureOpenAIApiVersion }; |
| opts.headers = { ...opts.headers, 'api-key': this.apiKey }; |
| } else if (this.apiKey) { |
| opts.headers.Authorization = `Bearer ${this.apiKey}`; |
| } |
|
|
| if (process.env.OPENAI_ORGANIZATION) { |
| opts.headers['OpenAI-Organization'] = process.env.OPENAI_ORGANIZATION; |
| } |
|
|
| if (this.useOpenRouter) { |
| opts.headers['HTTP-Referer'] = 'https://librechat.ai'; |
| opts.headers['X-Title'] = 'LibreChat'; |
| } |
|
|
| if (this.options.proxy) { |
| opts.dispatcher = new ProxyAgent(this.options.proxy); |
| } |
|
|
| |
| |
| |
| |
| if (baseURL.includes('https://api.mistral.ai/v1') && modelOptions.messages) { |
| const { messages } = modelOptions; |
|
|
| const systemMessageIndex = messages.findIndex((msg) => msg.role === 'system'); |
|
|
| if (systemMessageIndex > 0) { |
| const [systemMessage] = messages.splice(systemMessageIndex, 1); |
| messages.unshift(systemMessage); |
| } |
|
|
| modelOptions.messages = messages; |
|
|
| if (messages.length === 1 && messages[0].role === 'system') { |
| modelOptions.messages[0].role = 'user'; |
| } |
| } |
|
|
| if (this.options.addParams && typeof this.options.addParams === 'object') { |
| modelOptions = { |
| ...modelOptions, |
| ...this.options.addParams, |
| }; |
| logger.debug('[ChatGPTClient] chatCompletion: added params', { |
| addParams: this.options.addParams, |
| modelOptions, |
| }); |
| } |
|
|
| if (this.options.dropParams && Array.isArray(this.options.dropParams)) { |
| this.options.dropParams.forEach((param) => { |
| delete modelOptions[param]; |
| }); |
| logger.debug('[ChatGPTClient] chatCompletion: dropped params', { |
| dropParams: this.options.dropParams, |
| modelOptions, |
| }); |
| } |
|
|
| if (baseURL.startsWith(CohereConstants.API_URL)) { |
| const payload = createCoherePayload({ modelOptions }); |
| return await this.cohereChatCompletion({ payload, onTokenProgress }); |
| } |
|
|
| if (baseURL.includes('v1') && !baseURL.includes('/completions') && !this.isChatCompletion) { |
| baseURL = baseURL.split('v1')[0] + 'v1/completions'; |
| } else if ( |
| baseURL.includes('v1') && |
| !baseURL.includes('/chat/completions') && |
| this.isChatCompletion |
| ) { |
| baseURL = baseURL.split('v1')[0] + 'v1/chat/completions'; |
| } |
|
|
| const BASE_URL = new URL(baseURL); |
| if (opts.defaultQuery) { |
| Object.entries(opts.defaultQuery).forEach(([key, value]) => { |
| BASE_URL.searchParams.append(key, value); |
| }); |
| delete opts.defaultQuery; |
| } |
|
|
| const completionsURL = BASE_URL.toString(); |
| opts.body = JSON.stringify(modelOptions); |
|
|
| if (modelOptions.stream) { |
| |
| return new Promise(async (resolve, reject) => { |
| try { |
| let done = false; |
| await fetchEventSource(completionsURL, { |
| ...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]'); |
| resolve(); |
| } |
| }, |
| onerror(err) { |
| if (debug) { |
| console.debug(err); |
| } |
| |
| throw err; |
| }, |
| onmessage(message) { |
| if (debug) { |
| console.debug(message); |
| } |
| if (!message.data || message.event === 'ping') { |
| return; |
| } |
| if (message.data === '[DONE]') { |
| onProgress('[DONE]'); |
| resolve(); |
| done = true; |
| return; |
| } |
| onProgress(JSON.parse(message.data)); |
| }, |
| }); |
| } catch (err) { |
| reject(err); |
| } |
| }); |
| } |
| const response = await fetch(completionsURL, { |
| ...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 cohereChatCompletion({ payload, onTokenProgress }) { |
| const cohere = new CohereClient({ |
| token: this.apiKey, |
| environment: this.completionsUrl, |
| }); |
|
|
| if (!payload.stream) { |
| const chatResponse = await cohere.chat(payload); |
| return chatResponse.text; |
| } |
|
|
| const chatStream = await cohere.chatStream(payload); |
| let reply = ''; |
| for await (const message of chatStream) { |
| if (!message) { |
| continue; |
| } |
|
|
| if (message.eventType === 'text-generation' && message.text) { |
| onTokenProgress(message.text); |
| reply += message.text; |
| } |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| } |
|
|
| return reply; |
| } |
|
|
| 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, { isChatGptModel = false, promptPrefix = null }) { |
| 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 && messages.length > 0) { |
| const message = messages.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 += 3; |
| } |
|
|
| |
| 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, promptTokens: currentTokenCount }; |
| } |
|
|
| getTokenCount(text) { |
| return this.gptEncoder.encode(text, 'all').length; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| getTokenCountForMessage(message) { |
| |
| let tokensPerMessage = 3; |
| let tokensPerName = 1; |
|
|
| if (this.modelOptions.model === 'gpt-3.5-turbo-0301') { |
| tokensPerMessage = 4; |
| tokensPerName = -1; |
| } |
|
|
| let numTokens = tokensPerMessage; |
| for (let [key, value] of Object.entries(message)) { |
| numTokens += this.getTokenCount(value); |
| if (key === 'name') { |
| numTokens += tokensPerName; |
| } |
| } |
|
|
| return numTokens; |
| } |
| } |
|
|
| module.exports = ChatGPTClient; |
|
|