| const { logger } = require('@librechat/data-schemas'); |
| const { HttpsProxyAgent } = require('https-proxy-agent'); |
| const { sleep, SplitStreamHandler, CustomOpenAIClient: OpenAI } = require('@librechat/agents'); |
| const { |
| isEnabled, |
| Tokenizer, |
| createFetch, |
| resolveHeaders, |
| constructAzureURL, |
| getModelMaxTokens, |
| genAzureChatCompletion, |
| getModelMaxOutputTokens, |
| createStreamEventHandlers, |
| } = require('@librechat/api'); |
| const { |
| Constants, |
| ImageDetail, |
| ContentTypes, |
| parseTextParts, |
| EModelEndpoint, |
| KnownEndpoints, |
| openAISettings, |
| ImageDetailCost, |
| getResponseSender, |
| validateVisionModel, |
| mapModelToAzureConfig, |
| } = require('librechat-data-provider'); |
| const { encodeAndFormat } = require('~/server/services/Files/images/encode'); |
| const { formatMessage, createContextHandlers } = require('./prompts'); |
| const { spendTokens } = require('~/models/spendTokens'); |
| const { addSpaceIfNeeded } = require('~/server/utils'); |
| const { handleOpenAIErrors } = require('./tools/util'); |
| const { OllamaClient } = require('./OllamaClient'); |
| const { extractBaseURL } = require('~/utils'); |
| const BaseClient = require('./BaseClient'); |
|
|
| class OpenAIClient extends BaseClient { |
| constructor(apiKey, options = {}) { |
| super(apiKey, options); |
| this.contextStrategy = options.contextStrategy |
| ? options.contextStrategy.toLowerCase() |
| : 'discard'; |
| this.shouldSummarize = this.contextStrategy === 'summarize'; |
| |
| this.azure = options.azure || false; |
| this.setOptions(options); |
| this.metadata = {}; |
|
|
| |
| this.completionsUrl; |
|
|
| |
| this.usage; |
| |
| this.isOmni; |
| |
| this.streamHandler; |
| } |
|
|
| |
| 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; |
| } |
|
|
| this.modelOptions = Object.assign( |
| { |
| model: openAISettings.model.default, |
| }, |
| this.modelOptions, |
| this.options.modelOptions, |
| ); |
|
|
| this.defaultVisionModel = this.options.visionModel ?? 'gpt-4-vision-preview'; |
| if (typeof this.options.attachments?.then === 'function') { |
| this.options.attachments.then((attachments) => this.checkVisionRequest(attachments)); |
| } else { |
| this.checkVisionRequest(this.options.attachments); |
| } |
|
|
| const omniPattern = /\b(o\d)\b/i; |
| this.isOmni = omniPattern.test(this.modelOptions.model); |
|
|
| const { OPENAI_FORCE_PROMPT } = process.env ?? {}; |
| const { reverseProxyUrl: reverseProxy } = this.options; |
|
|
| if ( |
| !this.useOpenRouter && |
| ((reverseProxy && reverseProxy.includes(KnownEndpoints.openrouter)) || |
| (this.options.endpoint && |
| this.options.endpoint.toLowerCase().includes(KnownEndpoints.openrouter))) |
| ) { |
| this.useOpenRouter = true; |
| } |
|
|
| if (this.options.endpoint?.toLowerCase() === 'ollama') { |
| this.isOllama = true; |
| } |
|
|
| this.FORCE_PROMPT = |
| isEnabled(OPENAI_FORCE_PROMPT) || |
| (reverseProxy && reverseProxy.includes('completions') && !reverseProxy.includes('chat')); |
|
|
| if (typeof this.options.forcePrompt === 'boolean') { |
| this.FORCE_PROMPT = this.options.forcePrompt; |
| } |
|
|
| if (this.azure && process.env.AZURE_OPENAI_DEFAULT_MODEL) { |
| this.azureEndpoint = genAzureChatCompletion(this.azure, this.modelOptions.model, this); |
| this.modelOptions.model = process.env.AZURE_OPENAI_DEFAULT_MODEL; |
| } else if (this.azure) { |
| this.azureEndpoint = genAzureChatCompletion(this.azure, this.modelOptions.model, this); |
| } |
|
|
| const { model } = this.modelOptions; |
|
|
| this.isChatCompletion = |
| omniPattern.test(model) || model.includes('gpt') || this.useOpenRouter || !!reverseProxy; |
| this.isChatGptModel = this.isChatCompletion; |
| if ( |
| model.includes('text-davinci') || |
| model.includes('gpt-3.5-turbo-instruct') || |
| this.FORCE_PROMPT |
| ) { |
| this.isChatCompletion = false; |
| this.isChatGptModel = false; |
| } |
| const { isChatGptModel } = this; |
| this.isUnofficialChatGptModel = |
| model.startsWith('text-chat') || model.startsWith('text-davinci-002-render'); |
|
|
| this.maxContextTokens = |
| this.options.maxContextTokens ?? |
| getModelMaxTokens( |
| model, |
| this.options.endpointType ?? this.options.endpoint, |
| this.options.endpointTokenConfig, |
| ) ?? |
| 4095; |
|
|
| if (this.shouldSummarize) { |
| this.maxContextTokens = Math.floor(this.maxContextTokens / 2); |
| } |
|
|
| if (this.options.debug) { |
| logger.debug('[OpenAIClient] maxContextTokens', this.maxContextTokens); |
| } |
|
|
| this.maxResponseTokens = |
| this.modelOptions.max_tokens ?? |
| getModelMaxOutputTokens( |
| model, |
| this.options.endpointType ?? this.options.endpoint, |
| this.options.endpointTokenConfig, |
| ) ?? |
| 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.sender = |
| this.options.sender ?? |
| getResponseSender({ |
| model: this.modelOptions.model, |
| endpoint: this.options.endpoint, |
| endpointType: this.options.endpointType, |
| modelDisplayLabel: this.options.modelDisplayLabel, |
| chatGptLabel: this.options.chatGptLabel || this.options.modelLabel, |
| }); |
|
|
| this.userLabel = this.options.userLabel || 'User'; |
| this.chatGptLabel = this.options.chatGptLabel || 'Assistant'; |
|
|
| this.setupTokens(); |
|
|
| if (reverseProxy) { |
| this.completionsUrl = reverseProxy; |
| this.langchainProxy = extractBaseURL(reverseProxy); |
| } else if (isChatGptModel) { |
| this.completionsUrl = 'https://api.openai.com/v1/chat/completions'; |
| } else { |
| this.completionsUrl = 'https://api.openai.com/v1/completions'; |
| } |
|
|
| if (this.azureEndpoint) { |
| this.completionsUrl = this.azureEndpoint; |
| } |
|
|
| if (this.azureEndpoint && this.options.debug) { |
| logger.debug('Using Azure endpoint'); |
| } |
|
|
| return this; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| checkVisionRequest(attachments) { |
| if (!attachments) { |
| return; |
| } |
|
|
| const availableModels = this.options.modelsConfig?.[this.options.endpoint]; |
| if (!availableModels) { |
| return; |
| } |
|
|
| let visionRequestDetected = false; |
| for (const file of attachments) { |
| if (file?.type?.includes('image')) { |
| visionRequestDetected = true; |
| break; |
| } |
| } |
| if (!visionRequestDetected) { |
| return; |
| } |
|
|
| this.isVisionModel = validateVisionModel({ model: this.modelOptions.model, availableModels }); |
| if (this.isVisionModel) { |
| delete this.modelOptions.stop; |
| return; |
| } |
|
|
| for (const model of availableModels) { |
| if (!validateVisionModel({ model, availableModels })) { |
| continue; |
| } |
| this.modelOptions.model = model; |
| this.isVisionModel = true; |
| delete this.modelOptions.stop; |
| return; |
| } |
|
|
| if (!availableModels.includes(this.defaultVisionModel)) { |
| return; |
| } |
| if (!validateVisionModel({ model: this.defaultVisionModel, availableModels })) { |
| return; |
| } |
|
|
| this.modelOptions.model = this.defaultVisionModel; |
| this.isVisionModel = true; |
| delete this.modelOptions.stop; |
| } |
|
|
| setupTokens() { |
| if (this.isChatCompletion) { |
| this.startToken = '||>'; |
| this.endToken = ''; |
| } else if (this.isUnofficialChatGptModel) { |
| this.startToken = '<|im_start|>'; |
| this.endToken = '<|im_end|>'; |
| } else { |
| this.startToken = '||>'; |
| this.endToken = ''; |
| } |
| } |
|
|
| getEncoding() { |
| return this.modelOptions?.model && /gpt-4[^-\s]/.test(this.modelOptions.model) |
| ? 'o200k_base' |
| : 'cl100k_base'; |
| } |
|
|
| |
| |
| |
| |
| |
| getTokenCount(text) { |
| const encoding = this.getEncoding(); |
| return Tokenizer.getTokenCount(text, encoding); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| calculateImageTokenCost({ width, height, detail }) { |
| if (detail === 'low') { |
| return ImageDetailCost.LOW; |
| } |
|
|
| |
| const numSquares = Math.ceil(width / 512) * Math.ceil(height / 512); |
|
|
| |
| return numSquares * ImageDetailCost.HIGH + ImageDetailCost.ADDITIONAL; |
| } |
|
|
| getSaveOptions() { |
| return { |
| artifacts: this.options.artifacts, |
| maxContextTokens: this.options.maxContextTokens, |
| chatGptLabel: this.options.chatGptLabel, |
| promptPrefix: this.options.promptPrefix, |
| resendFiles: this.options.resendFiles, |
| imageDetail: this.options.imageDetail, |
| modelLabel: this.options.modelLabel, |
| iconURL: this.options.iconURL, |
| greeting: this.options.greeting, |
| spec: this.options.spec, |
| ...this.modelOptions, |
| }; |
| } |
|
|
| getBuildMessagesOptions(opts) { |
| return { |
| isChatCompletion: this.isChatCompletion, |
| promptPrefix: opts.promptPrefix, |
| abortController: opts.abortController, |
| }; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| async addImageURLs(message, attachments) { |
| const { files, image_urls } = await encodeAndFormat(this.options.req, attachments, { |
| endpoint: this.options.endpoint, |
| }); |
| message.image_urls = image_urls.length ? image_urls : undefined; |
| return files; |
| } |
|
|
| async buildMessages(messages, parentMessageId, { promptPrefix = null }, opts) { |
| let orderedMessages = this.constructor.getMessagesForConversation({ |
| messages, |
| parentMessageId, |
| summary: this.shouldSummarize, |
| }); |
|
|
| let payload; |
| let instructions; |
| let tokenCountMap; |
| let promptTokens; |
|
|
| promptPrefix = (promptPrefix || this.options.promptPrefix || '').trim(); |
| if (typeof this.options.artifactsPrompt === 'string' && this.options.artifactsPrompt) { |
| promptPrefix = `${promptPrefix ?? ''}\n${this.options.artifactsPrompt}`.trim(); |
| } |
|
|
| if (this.options.attachments) { |
| const attachments = await this.options.attachments; |
|
|
| if (this.message_file_map) { |
| this.message_file_map[orderedMessages[orderedMessages.length - 1].messageId] = attachments; |
| } else { |
| this.message_file_map = { |
| [orderedMessages[orderedMessages.length - 1].messageId]: attachments, |
| }; |
| } |
|
|
| const files = await this.addImageURLs( |
| orderedMessages[orderedMessages.length - 1], |
| attachments, |
| ); |
|
|
| this.options.attachments = files; |
| } |
|
|
| if (this.message_file_map) { |
| this.contextHandlers = createContextHandlers( |
| this.options.req, |
| orderedMessages[orderedMessages.length - 1].text, |
| ); |
| } |
|
|
| const formattedMessages = orderedMessages.map((message, i) => { |
| const formattedMessage = formatMessage({ |
| message, |
| userName: this.options?.name, |
| assistantName: this.options?.chatGptLabel, |
| }); |
|
|
| const needsTokenCount = this.contextStrategy && !orderedMessages[i].tokenCount; |
|
|
| |
| if (needsTokenCount || (this.isVisionModel && (message.image_urls || message.files))) { |
| orderedMessages[i].tokenCount = this.getTokenCountForMessage(formattedMessage); |
| } |
|
|
| |
| if (this.message_file_map && this.message_file_map[message.messageId]) { |
| const attachments = this.message_file_map[message.messageId]; |
| for (const file of attachments) { |
| if (file.embedded) { |
| this.contextHandlers?.processFile(file); |
| continue; |
| } |
| if (file.metadata?.fileIdentifier) { |
| continue; |
| } |
|
|
| orderedMessages[i].tokenCount += this.calculateImageTokenCost({ |
| width: file.width, |
| height: file.height, |
| detail: this.options.imageDetail ?? ImageDetail.auto, |
| }); |
| } |
| } |
|
|
| return formattedMessage; |
| }); |
|
|
| if (this.contextHandlers) { |
| this.augmentedPrompt = await this.contextHandlers.createContext(); |
| promptPrefix = this.augmentedPrompt + promptPrefix; |
| } |
|
|
| const noSystemModelRegex = /\b(o1-preview|o1-mini)\b/i.test(this.modelOptions.model); |
|
|
| if (promptPrefix && !noSystemModelRegex) { |
| promptPrefix = `Instructions:\n${promptPrefix.trim()}`; |
| instructions = { |
| role: 'system', |
| content: promptPrefix, |
| }; |
|
|
| if (this.contextStrategy) { |
| instructions.tokenCount = this.getTokenCountForMessage(instructions); |
| } |
| } |
|
|
| |
| if (this.contextStrategy) { |
| ({ payload, tokenCountMap, promptTokens, messages } = await this.handleContextStrategy({ |
| instructions, |
| orderedMessages, |
| formattedMessages, |
| })); |
| } |
|
|
| const result = { |
| prompt: payload, |
| promptTokens, |
| messages, |
| }; |
|
|
| |
| if (promptPrefix && noSystemModelRegex) { |
| const lastUserMessageIndex = payload.findLastIndex((message) => message.role === 'user'); |
| if (lastUserMessageIndex !== -1) { |
| if (Array.isArray(payload[lastUserMessageIndex].content)) { |
| const firstTextPartIndex = payload[lastUserMessageIndex].content.findIndex( |
| (part) => part.type === ContentTypes.TEXT, |
| ); |
| if (firstTextPartIndex !== -1) { |
| const firstTextPart = payload[lastUserMessageIndex].content[firstTextPartIndex]; |
| payload[lastUserMessageIndex].content[firstTextPartIndex].text = |
| `${promptPrefix}\n${firstTextPart.text}`; |
| } else { |
| payload[lastUserMessageIndex].content.unshift({ |
| type: ContentTypes.TEXT, |
| text: promptPrefix, |
| }); |
| } |
| } else { |
| payload[lastUserMessageIndex].content = |
| `${promptPrefix}\n${payload[lastUserMessageIndex].content}`; |
| } |
| } |
| } |
|
|
| if (tokenCountMap) { |
| tokenCountMap.instructions = instructions?.tokenCount; |
| result.tokenCountMap = tokenCountMap; |
| } |
|
|
| if (promptTokens >= 0 && typeof opts?.getReqData === 'function') { |
| opts.getReqData({ promptTokens }); |
| } |
|
|
| return result; |
| } |
|
|
| |
| async sendCompletion(payload, opts = {}) { |
| let reply = ''; |
| let result = null; |
| let streamResult = null; |
| this.modelOptions.user = this.user; |
| const invalidBaseUrl = this.completionsUrl && extractBaseURL(this.completionsUrl) === null; |
| const useOldMethod = !!(invalidBaseUrl || !this.isChatCompletion); |
| if (typeof opts.onProgress === 'function' && useOldMethod) { |
| const completionResult = await this.getCompletion( |
| payload, |
| (progressMessage) => { |
| if (progressMessage === '[DONE]') { |
| return; |
| } |
|
|
| if (progressMessage.choices) { |
| streamResult = progressMessage; |
| } |
|
|
| let token = null; |
| if (this.isChatCompletion) { |
| token = |
| progressMessage.choices?.[0]?.delta?.content ?? progressMessage.choices?.[0]?.text; |
| } else { |
| token = progressMessage.choices?.[0]?.text; |
| } |
|
|
| if (!token && this.useOpenRouter) { |
| token = progressMessage.choices?.[0]?.message?.content; |
| } |
| |
| if (!token) { |
| return; |
| } |
|
|
| if (token === this.endToken) { |
| return; |
| } |
| opts.onProgress(token); |
| reply += token; |
| }, |
| opts.onProgress, |
| opts.abortController || new AbortController(), |
| ); |
|
|
| if (completionResult && typeof completionResult === 'string') { |
| reply = completionResult; |
| } else if ( |
| completionResult && |
| typeof completionResult === 'object' && |
| Array.isArray(completionResult.choices) |
| ) { |
| reply = completionResult.choices[0]?.text?.replace(this.endToken, ''); |
| } |
| } else if (typeof opts.onProgress === 'function' || this.options.useChatCompletion) { |
| reply = await this.chatCompletion({ |
| payload, |
| onProgress: opts.onProgress, |
| abortController: opts.abortController, |
| }); |
| } else { |
| result = await this.getCompletion( |
| payload, |
| null, |
| opts.onProgress, |
| opts.abortController || new AbortController(), |
| ); |
|
|
| if (result && typeof result === 'string') { |
| return result.trim(); |
| } |
|
|
| logger.debug('[OpenAIClient] sendCompletion: result', { ...result }); |
|
|
| if (this.isChatCompletion) { |
| reply = result.choices[0].message.content; |
| } else { |
| reply = result.choices[0].text.replace(this.endToken, ''); |
| } |
| } |
|
|
| if (streamResult) { |
| const { finish_reason } = streamResult.choices[0]; |
| this.metadata = { finish_reason }; |
| } |
| return (reply ?? '').trim(); |
| } |
|
|
| initializeLLM() { |
| throw new Error('Deprecated'); |
| } |
|
|
| |
| |
| |
| |
| getStreamUsage() { |
| if ( |
| this.usage && |
| typeof this.usage === 'object' && |
| 'completion_tokens_details' in this.usage && |
| this.usage.completion_tokens_details && |
| typeof this.usage.completion_tokens_details === 'object' && |
| 'reasoning_tokens' in this.usage.completion_tokens_details |
| ) { |
| const outputTokens = Math.abs( |
| this.usage.completion_tokens_details.reasoning_tokens - this.usage[this.outputTokensKey], |
| ); |
| return { |
| ...this.usage.completion_tokens_details, |
| [this.inputTokensKey]: this.usage[this.inputTokensKey], |
| [this.outputTokensKey]: outputTokens, |
| }; |
| } |
| return this.usage; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| calculateCurrentTokenCount({ tokenCountMap, currentMessageId, usage }) { |
| const originalEstimate = tokenCountMap[currentMessageId] || 0; |
|
|
| if (!usage || typeof usage[this.inputTokensKey] !== 'number') { |
| return originalEstimate; |
| } |
|
|
| tokenCountMap[currentMessageId] = 0; |
| const totalTokensFromMap = Object.values(tokenCountMap).reduce((sum, count) => { |
| const numCount = Number(count); |
| return sum + (isNaN(numCount) ? 0 : numCount); |
| }, 0); |
| const totalInputTokens = usage[this.inputTokensKey] ?? 0; |
|
|
| const currentMessageTokens = totalInputTokens - totalTokensFromMap; |
| return currentMessageTokens > 0 ? currentMessageTokens : originalEstimate; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| async recordTokenUsage({ promptTokens, completionTokens, usage, context = 'message' }) { |
| await spendTokens( |
| { |
| context, |
| model: this.modelOptions.model, |
| conversationId: this.conversationId, |
| user: this.user ?? this.options.req.user?.id, |
| endpointTokenConfig: this.options.endpointTokenConfig, |
| }, |
| { promptTokens, completionTokens }, |
| ); |
|
|
| if ( |
| usage && |
| typeof usage === 'object' && |
| 'reasoning_tokens' in usage && |
| typeof usage.reasoning_tokens === 'number' |
| ) { |
| await spendTokens( |
| { |
| context: 'reasoning', |
| model: this.modelOptions.model, |
| conversationId: this.conversationId, |
| user: this.user ?? this.options.req.user?.id, |
| endpointTokenConfig: this.options.endpointTokenConfig, |
| }, |
| { completionTokens: usage.reasoning_tokens }, |
| ); |
| } |
| } |
|
|
| getTokenCountForResponse(response) { |
| return this.getTokenCountForMessage({ |
| role: 'assistant', |
| content: response.text, |
| }); |
| } |
|
|
| |
| |
| |
| |
| |
| getStreamText(intermediateReply) { |
| if (!this.streamHandler) { |
| return intermediateReply?.join('') ?? ''; |
| } |
|
|
| let thinkMatch; |
| let remainingText; |
| let reasoningText = ''; |
|
|
| if (this.streamHandler.reasoningTokens.length > 0) { |
| reasoningText = this.streamHandler.reasoningTokens.join(''); |
| thinkMatch = reasoningText.match(/<think>([\s\S]*?)<\/think>/)?.[1]?.trim(); |
| if (thinkMatch != null && thinkMatch) { |
| const reasoningTokens = `:::thinking\n${thinkMatch}\n:::\n`; |
| remainingText = reasoningText.split(/<\/think>/)?.[1]?.trim() || ''; |
| return `${reasoningTokens}${remainingText}${this.streamHandler.tokens.join('')}`; |
| } else if (thinkMatch === '') { |
| remainingText = reasoningText.split(/<\/think>/)?.[1]?.trim() || ''; |
| return `${remainingText}${this.streamHandler.tokens.join('')}`; |
| } |
| } |
|
|
| const reasoningTokens = |
| reasoningText.length > 0 |
| ? `:::thinking\n${reasoningText.replace('<think>', '').replace('</think>', '').trim()}\n:::\n` |
| : ''; |
|
|
| return `${reasoningTokens}${this.streamHandler.tokens.join('')}`; |
| } |
|
|
| getMessageMapMethod() { |
| |
| |
| |
| return (msg) => { |
| if (msg.text != null && msg.text && msg.text.startsWith(':::thinking')) { |
| msg.text = msg.text.replace(/:::thinking.*?:::/gs, '').trim(); |
| } else if (msg.content != null) { |
| msg.text = parseTextParts(msg.content, true); |
| delete msg.content; |
| } |
|
|
| return msg; |
| }; |
| } |
|
|
| async chatCompletion({ payload, onProgress, abortController = null }) { |
| const appConfig = this.options.req?.config; |
| let error = null; |
| let intermediateReply = []; |
| const errorCallback = (err) => (error = err); |
| try { |
| if (!abortController) { |
| abortController = new AbortController(); |
| } |
|
|
| let modelOptions = { ...this.modelOptions }; |
|
|
| if (typeof onProgress === 'function') { |
| modelOptions.stream = true; |
| } |
| if (this.isChatCompletion) { |
| modelOptions.messages = payload; |
| } else { |
| modelOptions.prompt = payload; |
| } |
|
|
| const baseURL = extractBaseURL(this.completionsUrl); |
| logger.debug('[OpenAIClient] chatCompletion', { baseURL, modelOptions }); |
| const opts = { |
| baseURL, |
| fetchOptions: {}, |
| }; |
|
|
| if (this.useOpenRouter) { |
| opts.defaultHeaders = { |
| 'HTTP-Referer': 'https://librechat.ai', |
| 'X-Title': 'LibreChat', |
| }; |
| } |
|
|
| if (this.options.headers) { |
| opts.defaultHeaders = { ...opts.defaultHeaders, ...this.options.headers }; |
| } |
|
|
| if (this.options.defaultQuery) { |
| opts.defaultQuery = this.options.defaultQuery; |
| } |
|
|
| if (this.options.proxy) { |
| opts.fetchOptions.agent = new HttpsProxyAgent(this.options.proxy); |
| } |
|
|
| const azureConfig = appConfig?.endpoints?.[EModelEndpoint.azureOpenAI]; |
|
|
| if ( |
| (this.azure && 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.defaultHeaders = 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 (serverless === true) { |
| this.options.defaultQuery = azureOptions.azureOpenAIApiVersion |
| ? { 'api-version': azureOptions.azureOpenAIApiVersion } |
| : undefined; |
| this.options.headers['api-key'] = this.apiKey; |
| } |
| } |
|
|
| if (this.azure || this.options.azure) { |
| |
| if (!modelOptions.max_tokens && modelOptions.model === 'gpt-4-vision-preview') { |
| modelOptions.max_tokens = 4000; |
| } |
|
|
| |
| delete modelOptions.model; |
|
|
| opts.baseURL = this.langchainProxy |
| ? constructAzureURL({ |
| baseURL: this.langchainProxy, |
| azureOptions: this.azure, |
| }) |
| : this.azureEndpoint.split(/(?<!\/)\/(chat|completion)\//)[0]; |
|
|
| opts.defaultQuery = { 'api-version': this.azure.azureOpenAIApiVersion }; |
| opts.defaultHeaders = { ...opts.defaultHeaders, 'api-key': this.apiKey }; |
| } |
|
|
| if (this.isOmni === true && modelOptions.max_tokens != null) { |
| const paramName = |
| modelOptions.useResponsesApi === true ? 'max_output_tokens' : 'max_completion_tokens'; |
| modelOptions[paramName] = modelOptions.max_tokens; |
| delete modelOptions.max_tokens; |
| } |
| if (this.isOmni === true && modelOptions.temperature != null) { |
| delete modelOptions.temperature; |
| } |
|
|
| if (process.env.OPENAI_ORGANIZATION) { |
| opts.organization = process.env.OPENAI_ORGANIZATION; |
| } |
|
|
| let chatCompletion; |
| |
| const openai = new OpenAI({ |
| fetch: createFetch({ |
| directEndpoint: this.options.directEndpoint, |
| reverseProxyUrl: this.options.reverseProxyUrl, |
| }), |
| apiKey: this.apiKey, |
| ...opts, |
| }); |
|
|
| |
| if (modelOptions.messages && (opts.baseURL.includes('api.mistral.ai') || this.isOllama)) { |
| 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 ( |
| (opts.baseURL.includes('api.mistral.ai') || opts.baseURL.includes('api.perplexity.ai')) && |
| modelOptions.messages && |
| modelOptions.messages.length === 1 && |
| modelOptions.messages[0]?.role === 'system' |
| ) { |
| modelOptions.messages[0].role = 'user'; |
| } |
|
|
| if ( |
| (this.options.endpoint === EModelEndpoint.openAI || |
| this.options.endpoint === EModelEndpoint.azureOpenAI) && |
| modelOptions.stream === true |
| ) { |
| modelOptions.stream_options = { include_usage: true }; |
| } |
|
|
| if (this.options.addParams && typeof this.options.addParams === 'object') { |
| const addParams = { ...this.options.addParams }; |
| modelOptions = { |
| ...modelOptions, |
| ...addParams, |
| }; |
| logger.debug('[OpenAIClient] chatCompletion: added params', { |
| addParams: addParams, |
| modelOptions, |
| }); |
| } |
|
|
| |
| if (modelOptions.model && /gpt-4o.*search/.test(modelOptions.model)) { |
| const searchExcludeParams = [ |
| 'frequency_penalty', |
| 'presence_penalty', |
| 'temperature', |
| 'top_p', |
| 'top_k', |
| 'stop', |
| 'logit_bias', |
| 'seed', |
| 'response_format', |
| 'n', |
| 'logprobs', |
| 'user', |
| ]; |
|
|
| this.options.dropParams = this.options.dropParams || []; |
| this.options.dropParams = [ |
| ...new Set([...this.options.dropParams, ...searchExcludeParams]), |
| ]; |
| } |
|
|
| if (this.options.dropParams && Array.isArray(this.options.dropParams)) { |
| const dropParams = [...this.options.dropParams]; |
| dropParams.forEach((param) => { |
| delete modelOptions[param]; |
| }); |
| logger.debug('[OpenAIClient] chatCompletion: dropped params', { |
| dropParams: dropParams, |
| modelOptions, |
| }); |
| } |
|
|
| const streamRate = this.options.streamRate ?? Constants.DEFAULT_STREAM_RATE; |
|
|
| if (this.message_file_map && this.isOllama) { |
| const ollamaClient = new OllamaClient({ baseURL, streamRate }); |
| return await ollamaClient.chatCompletion({ |
| payload: modelOptions, |
| onProgress, |
| abortController, |
| }); |
| } |
|
|
| let UnexpectedRoleError = false; |
| |
| let streamPromise; |
| |
| let streamResolve; |
|
|
| if ( |
| (!this.isOmni || /^o1-(mini|preview)/i.test(modelOptions.model)) && |
| modelOptions.reasoning_effort != null |
| ) { |
| delete modelOptions.reasoning_effort; |
| delete modelOptions.temperature; |
| } |
|
|
| let reasoningKey = 'reasoning_content'; |
| if (this.useOpenRouter) { |
| modelOptions.include_reasoning = true; |
| reasoningKey = 'reasoning'; |
| } |
| if (this.useOpenRouter && modelOptions.reasoning_effort != null) { |
| modelOptions.reasoning = { |
| effort: modelOptions.reasoning_effort, |
| }; |
| delete modelOptions.reasoning_effort; |
| } |
|
|
| const handlers = createStreamEventHandlers(this.options.res); |
| this.streamHandler = new SplitStreamHandler({ |
| reasoningKey, |
| accumulate: true, |
| runId: this.responseMessageId, |
| handlers, |
| }); |
|
|
| intermediateReply = this.streamHandler.tokens; |
|
|
| if (modelOptions.stream) { |
| streamPromise = new Promise((resolve) => { |
| streamResolve = resolve; |
| }); |
| |
| const params = { |
| ...modelOptions, |
| stream: true, |
| }; |
| const stream = await openai.chat.completions |
| .stream(params) |
| .on('abort', () => { |
| |
| }) |
| .on('error', (err) => { |
| handleOpenAIErrors(err, errorCallback, 'stream'); |
| }) |
| .on('finalChatCompletion', async (finalChatCompletion) => { |
| const finalMessage = finalChatCompletion?.choices?.[0]?.message; |
| if (!finalMessage) { |
| return; |
| } |
| await streamPromise; |
| if (finalMessage?.role !== 'assistant') { |
| finalChatCompletion.choices[0].message.role = 'assistant'; |
| } |
|
|
| if (typeof finalMessage.content !== 'string' || finalMessage.content.trim() === '') { |
| finalChatCompletion.choices[0].message.content = this.streamHandler.tokens.join(''); |
| } |
| }) |
| .on('finalMessage', (message) => { |
| if (message?.role !== 'assistant') { |
| stream.messages.push({ |
| role: 'assistant', |
| content: this.streamHandler.tokens.join(''), |
| }); |
| UnexpectedRoleError = true; |
| } |
| }); |
|
|
| if (this.continued === true) { |
| const latestText = addSpaceIfNeeded( |
| this.currentMessages[this.currentMessages.length - 1]?.text ?? '', |
| ); |
| this.streamHandler.handle({ |
| choices: [ |
| { |
| delta: { |
| content: latestText, |
| }, |
| }, |
| ], |
| }); |
| } |
|
|
| for await (const chunk of stream) { |
| |
| if (chunk.choices) { |
| chunk.choices.forEach((choice) => { |
| if (!('finish_reason' in choice)) { |
| choice.finish_reason = null; |
| } |
| }); |
| } |
| this.streamHandler.handle(chunk); |
| if (abortController.signal.aborted) { |
| stream.controller.abort(); |
| break; |
| } |
|
|
| await sleep(streamRate); |
| } |
|
|
| streamResolve(); |
|
|
| if (!UnexpectedRoleError) { |
| chatCompletion = await stream.finalChatCompletion().catch((err) => { |
| handleOpenAIErrors(err, errorCallback, 'finalChatCompletion'); |
| }); |
| } |
| } |
| |
| else { |
| chatCompletion = await openai.chat.completions |
| .create({ |
| ...modelOptions, |
| }) |
| .catch((err) => { |
| handleOpenAIErrors(err, errorCallback, 'create'); |
| }); |
| } |
|
|
| if (openai.abortHandler && abortController.signal) { |
| abortController.signal.removeEventListener('abort', openai.abortHandler); |
| openai.abortHandler = undefined; |
| } |
|
|
| if (!chatCompletion && UnexpectedRoleError) { |
| throw new Error( |
| 'OpenAI error: Invalid final message: OpenAI expects final message to include role=assistant', |
| ); |
| } else if (!chatCompletion && error) { |
| throw new Error(error); |
| } else if (!chatCompletion) { |
| throw new Error('Chat completion failed'); |
| } |
|
|
| const { choices } = chatCompletion; |
| this.usage = chatCompletion.usage; |
|
|
| if (!Array.isArray(choices) || choices.length === 0) { |
| logger.warn('[OpenAIClient] Chat completion response has no choices'); |
| return this.streamHandler.tokens.join(''); |
| } |
|
|
| const { message, finish_reason } = choices[0] ?? {}; |
| this.metadata = { finish_reason }; |
|
|
| logger.debug('[OpenAIClient] chatCompletion response', chatCompletion); |
|
|
| if (!message) { |
| logger.warn('[OpenAIClient] Message is undefined in chatCompletion response'); |
| return this.streamHandler.tokens.join(''); |
| } |
|
|
| if (typeof message.content !== 'string' || message.content.trim() === '') { |
| const reply = this.streamHandler.tokens.join(''); |
| logger.debug( |
| '[OpenAIClient] chatCompletion: using intermediateReply due to empty message.content', |
| { intermediateReply: reply }, |
| ); |
| return reply; |
| } |
|
|
| if ( |
| this.streamHandler.reasoningTokens.length > 0 && |
| this.options.context !== 'title' && |
| !message.content.startsWith('<think>') |
| ) { |
| return this.getStreamText(); |
| } else if ( |
| this.streamHandler.reasoningTokens.length > 0 && |
| this.options.context !== 'title' && |
| message.content.startsWith('<think>') |
| ) { |
| return this.getStreamText(); |
| } |
|
|
| return message.content; |
| } catch (err) { |
| if ( |
| err?.message?.includes('abort') || |
| (err instanceof OpenAI.APIError && err?.message?.includes('abort')) |
| ) { |
| return this.getStreamText(intermediateReply); |
| } |
| if ( |
| err?.message?.includes( |
| 'OpenAI error: Invalid final message: OpenAI expects final message to include role=assistant', |
| ) || |
| err?.message?.includes( |
| 'stream ended without producing a ChatCompletionMessage with role=assistant', |
| ) || |
| err?.message?.includes('The server had an error processing your request') || |
| err?.message?.includes('missing finish_reason') || |
| err?.message?.includes('missing role') || |
| (err instanceof OpenAI.OpenAIError && err?.message?.includes('missing finish_reason')) |
| ) { |
| logger.error('[OpenAIClient] Known OpenAI error:', err); |
| if (this.streamHandler && this.streamHandler.reasoningTokens.length) { |
| return this.getStreamText(); |
| } else if (intermediateReply.length > 0) { |
| return this.getStreamText(intermediateReply); |
| } else { |
| throw err; |
| } |
| } else if (err instanceof OpenAI.APIError) { |
| if (this.streamHandler && this.streamHandler.reasoningTokens.length) { |
| return this.getStreamText(); |
| } else if (intermediateReply.length > 0) { |
| return this.getStreamText(intermediateReply); |
| } else { |
| throw err; |
| } |
| } else { |
| logger.error('[OpenAIClient.chatCompletion] Unhandled error type', err); |
| throw err; |
| } |
| } |
| } |
| } |
|
|
| module.exports = OpenAIClient; |
|
|