diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index fd2a759b99b6..c67fca63019a 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -57,7 +57,6 @@ services: # ports: # - 7700:7700 # if exposing these ports, make sure your master key is not the default value environment: - - MEILI_HTTP_ADDR=meilisearch:7700 - MEILI_NO_ANALYTICS=true - MEILI_MASTER_KEY=5c71cf56d672d009e36070b5bc5e47b743535ae55c818ae3b735bb6ebfb4ba63 volumes: diff --git a/.dockerignore b/.dockerignore index 0f03be588591..396f0da3e572 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,5 +1,17 @@ +**/.circleci +**/.editorconfig +**/.dockerignore +**/.git +**/.DS_Store +**/.vscode **/node_modules -client/dist/images + +# Specific patterns to ignore data-node -.env -**/.env \ No newline at end of file +meili_data* +librechat* +Dockerfile* +docs + +# Ignore all hidden files +.* diff --git a/.env.example b/.env.example index 822bb2d669bc..4c3900d9c1fd 100644 --- a/.env.example +++ b/.env.example @@ -24,9 +24,12 @@ MONGO_URI=mongodb://127.0.0.1:27017/LibreChat DOMAIN_CLIENT=http://localhost:3080 DOMAIN_SERVER=http://localhost:3080 +NO_INDEX=true + #===============# # Debug Logging # #===============# + DEBUG_LOGGING=true DEBUG_CONSOLE=false @@ -174,7 +177,6 @@ ZAPIER_NLA_API_KEY= SEARCH=true MEILI_NO_ANALYTICS=true MEILI_HOST=http://0.0.0.0:7700 -MEILI_HTTP_ADDR=0.0.0.0:7700 MEILI_MASTER_KEY=DrhYf7zENyR6AlUCKmnz0eYASOQdl6zxH7s7MKFSfFCt #===================================================# @@ -185,6 +187,10 @@ MEILI_MASTER_KEY=DrhYf7zENyR6AlUCKmnz0eYASOQdl6zxH7s7MKFSfFCt # Moderation # #========================# +OPENAI_MODERATION=false +OPENAI_MODERATION_API_KEY= +# OPENAI_MODERATION_REVERSE_PROXY=not working with some reverse proxys + BAN_VIOLATIONS=true BAN_DURATION=1000 * 60 * 60 * 2 BAN_INTERVAL=20 @@ -278,6 +284,17 @@ EMAIL_PASSWORD= EMAIL_FROM_NAME= EMAIL_FROM=noreply@librechat.ai +#========================# +# Firebase CDN # +#========================# + +FIREBASE_API_KEY= +FIREBASE_AUTH_DOMAIN= +FIREBASE_PROJECT_ID= +FIREBASE_STORAGE_BUCKET= +FIREBASE_MESSAGING_SENDER_ID= +FIREBASE_APP_ID= + #==================================================# # Others # #==================================================# diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 54c44782af6b..06d2656bd649 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -15,7 +15,8 @@ Please delete any irrelevant options. - [ ] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) - [ ] This change requires a documentation update -- [ ] Documentation update +- [ ] Documentation update +- [ ] Translation update ## Testing diff --git a/.github/workflows/mkdocs.yaml b/.github/workflows/mkdocs.yaml index 25cfd2baa32f..3b2878fa2a78 100644 --- a/.github/workflows/mkdocs.yaml +++ b/.github/workflows/mkdocs.yaml @@ -22,4 +22,6 @@ jobs: mkdocs-material- - run: pip install mkdocs-material - run: pip install mkdocs-nav-weight + - run: pip install mkdocs-publisher + - run: pip install mkdocs-exclude - run: mkdocs gh-deploy --force diff --git a/.gitignore b/.gitignore index af92a1f2daf3..a09baeed0e03 100644 --- a/.gitignore +++ b/.gitignore @@ -48,6 +48,9 @@ bower_components/ .floo .flooignore +#config file +librechat.yaml + # Environment .npmrc .env* @@ -82,4 +85,6 @@ data.ms/* auth.json /packages/ux-shared/ -/images \ No newline at end of file +/images + +!client/src/components/Nav/SettingsTabs/Data/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 1dac186b17fa..edc79c2497ad 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,16 +1,14 @@ # Base node image -FROM node:19-alpine AS node +FROM node:18-alpine AS node COPY . /app WORKDIR /app +# Allow mounting of these files, which have no default +# values. +RUN touch .env # Install call deps - Install curl for health check RUN apk --no-cache add curl && \ - # We want to inherit env from the container, not the file - # This will preserve any existing env file if it's already in source - # otherwise it will create a new one - touch .env && \ - # Build deps in seperate npm ci # React client build diff --git a/LICENSE b/LICENSE index 752f1a458093..49a224977b14 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2023 LibreChat +Copyright (c) 2024 LibreChat Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/api/app/clients/BaseClient.js b/api/app/clients/BaseClient.js index b76883265aac..5b7ea103c2d6 100644 --- a/api/app/clients/BaseClient.js +++ b/api/app/clients/BaseClient.js @@ -516,10 +516,11 @@ class BaseClient { } async saveMessageToDatabase(message, endpointOptions, user = null) { - await saveMessage({ ...message, user, unfinished: false, cancelled: false }); + await saveMessage({ ...message, endpoint: this.options.endpoint, user, unfinished: false }); await saveConvo(user, { conversationId: message.conversationId, endpoint: this.options.endpoint, + endpointType: this.options.endpointType, ...endpointOptions, }); } diff --git a/api/app/clients/OpenAIClient.js b/api/app/clients/OpenAIClient.js index ce39311f3ce8..1c22d6f7d412 100644 --- a/api/app/clients/OpenAIClient.js +++ b/api/app/clients/OpenAIClient.js @@ -1,6 +1,6 @@ const OpenAI = require('openai'); const { HttpsProxyAgent } = require('https-proxy-agent'); -const { getResponseSender, EModelEndpoint } = require('librechat-data-provider'); +const { getResponseSender } = require('librechat-data-provider'); const { encoding_for_model: encodingForModel, get_encoding: getEncoding } = require('tiktoken'); const { encodeAndFormat, validateVisionModel } = require('~/server/services/Files/images'); const { getModelMaxTokens, genAzureChatCompletion, extractBaseURL } = require('~/utils'); @@ -94,10 +94,23 @@ class OpenAIClient extends BaseClient { } const { reverseProxyUrl: reverseProxy } = this.options; + + if ( + !this.useOpenRouter && + reverseProxy && + reverseProxy.includes('https://openrouter.ai/api/v1') + ) { + this.useOpenRouter = 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.modelOptions.model = process.env.AZURE_OPENAI_DEFAULT_MODEL; @@ -146,8 +159,10 @@ class OpenAIClient extends BaseClient { this.options.sender ?? getResponseSender({ model: this.modelOptions.model, - endpoint: EModelEndpoint.openAI, + endpoint: this.options.endpoint, + endpointType: this.options.endpointType, chatGptLabel: this.options.chatGptLabel, + modelDisplayLabel: this.options.modelDisplayLabel, }); this.userLabel = this.options.userLabel || 'User'; @@ -434,7 +449,7 @@ class OpenAIClient extends BaseClient { }, opts.abortController || new AbortController(), ); - } else if (typeof opts.onProgress === 'function') { + } else if (typeof opts.onProgress === 'function' || this.options.useChatCompletion) { reply = await this.chatCompletion({ payload, clientOptions: opts, @@ -530,6 +545,19 @@ class OpenAIClient extends BaseClient { return llm; } + /** + * Generates a concise title for a conversation based on the user's input text and response. + * Uses either specified method or starts with the OpenAI `functions` method (using LangChain). + * If the `functions` method fails, it falls back to the `completion` method, + * which involves sending a chat completion request with specific instructions for title generation. + * + * @param {Object} params - The parameters for the conversation title generation. + * @param {string} params.text - The user's input. + * @param {string} [params.responseText=''] - The AI's immediate response to the user. + * + * @returns {Promise} A promise that resolves to the generated conversation title. + * In case of failure, it will return the default title, "New Chat". + */ async titleConvo({ text, responseText = '' }) { let title = 'New Chat'; const convo = `||>User: @@ -539,32 +567,25 @@ class OpenAIClient extends BaseClient { const { OPENAI_TITLE_MODEL } = process.env ?? {}; + const model = this.options.titleModel ?? OPENAI_TITLE_MODEL ?? 'gpt-3.5-turbo'; + const modelOptions = { - model: OPENAI_TITLE_MODEL ?? 'gpt-3.5-turbo', + // TODO: remove the gpt fallback and make it specific to endpoint + model, temperature: 0.2, presence_penalty: 0, frequency_penalty: 0, max_tokens: 16, }; - try { - this.abortController = new AbortController(); - const llm = this.initializeLLM({ ...modelOptions, context: 'title', tokenBuffer: 150 }); - title = await runTitleChain({ llm, text, convo, signal: this.abortController.signal }); - } catch (e) { - if (e?.message?.toLowerCase()?.includes('abort')) { - logger.debug('[OpenAIClient] Aborted title generation'); - return; - } - logger.error( - '[OpenAIClient] There was an issue generating title with LangChain, trying the old method...', - e, - ); - modelOptions.model = OPENAI_TITLE_MODEL ?? 'gpt-3.5-turbo'; + const titleChatCompletion = async () => { + modelOptions.model = model; + if (this.azure) { modelOptions.model = process.env.AZURE_OPENAI_DEFAULT_MODEL ?? modelOptions.model; this.azureEndpoint = genAzureChatCompletion(this.azure, modelOptions.model); } + const instructionsPayload = [ { role: 'system', @@ -578,10 +599,38 @@ ${convo} ]; try { - title = (await this.sendPayload(instructionsPayload, { modelOptions })).replaceAll('"', ''); + title = ( + await this.sendPayload(instructionsPayload, { modelOptions, useChatCompletion: true }) + ).replaceAll('"', ''); } catch (e) { - logger.error('[OpenAIClient] There was another issue generating the title', e); + logger.error( + '[OpenAIClient] There was an issue generating the title with the completion method', + e, + ); } + }; + + if (this.options.titleMethod === 'completion') { + await titleChatCompletion(); + logger.debug('[OpenAIClient] Convo Title: ' + title); + return title; + } + + try { + this.abortController = new AbortController(); + const llm = this.initializeLLM({ ...modelOptions, context: 'title', tokenBuffer: 150 }); + title = await runTitleChain({ llm, text, convo, signal: this.abortController.signal }); + } catch (e) { + if (e?.message?.toLowerCase()?.includes('abort')) { + logger.debug('[OpenAIClient] Aborted title generation'); + return; + } + logger.error( + '[OpenAIClient] There was an issue generating title with LangChain, trying completion method...', + e, + ); + + await titleChatCompletion(); } logger.debug('[OpenAIClient] Convo Title: ' + title); @@ -593,8 +642,11 @@ ${convo} let context = messagesToRefine; let prompt; + // TODO: remove the gpt fallback and make it specific to endpoint const { OPENAI_SUMMARY_MODEL = 'gpt-3.5-turbo' } = process.env ?? {}; - const maxContextTokens = getModelMaxTokens(OPENAI_SUMMARY_MODEL) ?? 4095; + const model = this.options.summaryModel ?? OPENAI_SUMMARY_MODEL; + const maxContextTokens = getModelMaxTokens(model) ?? 4095; + // 3 tokens for the assistant label, and 98 for the summarizer prompt (101) let promptBuffer = 101; @@ -644,7 +696,7 @@ ${convo} logger.debug('[OpenAIClient] initialPromptTokens', initialPromptTokens); const llm = this.initializeLLM({ - model: OPENAI_SUMMARY_MODEL, + model, temperature: 0.2, context: 'summary', tokenBuffer: initialPromptTokens, @@ -719,7 +771,9 @@ ${convo} if (!abortController) { abortController = new AbortController(); } - const modelOptions = { ...this.modelOptions }; + + let modelOptions = { ...this.modelOptions }; + if (typeof onProgress === 'function') { modelOptions.stream = true; } @@ -779,6 +833,27 @@ ${convo} ...opts, }); + /* hacky fix for Mistral AI API not allowing a singular system message in payload */ + if (opts.baseURL.includes('https://api.mistral.ai/v1') && modelOptions.messages) { + const { messages } = modelOptions; + 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, + }; + } + + if (this.options.dropParams && Array.isArray(this.options.dropParams)) { + this.options.dropParams.forEach((param) => { + delete modelOptions[param]; + }); + } + let UnexpectedRoleError = false; if (modelOptions.stream) { const stream = await openai.beta.chat.completions @@ -847,7 +922,7 @@ ${convo} err?.message?.includes('abort') || (err instanceof OpenAI.APIError && err?.message?.includes('abort')) ) { - return ''; + return intermediateReply; } if ( err?.message?.includes( @@ -859,7 +934,6 @@ ${convo} (err instanceof OpenAI.OpenAIError && err?.message?.includes('missing finish_reason')) ) { logger.error('[OpenAIClient] Known OpenAI error:', err); - await abortController.abortCompletion(); return intermediateReply; } else if (err instanceof OpenAI.APIError) { if (intermediateReply) { diff --git a/api/app/clients/llm/createLLM.js b/api/app/clients/llm/createLLM.js index 020fba65034f..de5fa18e77dc 100644 --- a/api/app/clients/llm/createLLM.js +++ b/api/app/clients/llm/createLLM.js @@ -2,38 +2,6 @@ const { ChatOpenAI } = require('langchain/chat_models/openai'); const { sanitizeModelName } = require('../../../utils'); const { isEnabled } = require('../../../server/utils'); -/** - * @typedef {Object} ModelOptions - * @property {string} modelName - The name of the model. - * @property {number} [temperature] - The temperature setting for the model. - * @property {number} [presence_penalty] - The presence penalty setting. - * @property {number} [frequency_penalty] - The frequency penalty setting. - * @property {number} [max_tokens] - The maximum number of tokens to generate. - */ - -/** - * @typedef {Object} ConfigOptions - * @property {string} [basePath] - The base path for the API requests. - * @property {Object} [baseOptions] - Base options for the API requests, including headers. - * @property {Object} [httpAgent] - The HTTP agent for the request. - * @property {Object} [httpsAgent] - The HTTPS agent for the request. - */ - -/** - * @typedef {Object} Callbacks - * @property {Function} [handleChatModelStart] - A callback function for handleChatModelStart - * @property {Function} [handleLLMEnd] - A callback function for handleLLMEnd - * @property {Function} [handleLLMError] - A callback function for handleLLMError - */ - -/** - * @typedef {Object} AzureOptions - * @property {string} [azureOpenAIApiKey] - The Azure OpenAI API key. - * @property {string} [azureOpenAIApiInstanceName] - The Azure OpenAI API instance name. - * @property {string} [azureOpenAIApiDeploymentName] - The Azure OpenAI API deployment name. - * @property {string} [azureOpenAIApiVersion] - The Azure OpenAI API version. - */ - /** * Creates a new instance of a language model (LLM) for chat interactions. * @@ -96,6 +64,7 @@ function createLLM({ configuration, ...azureOptions, ...modelOptions, + ...credentials, callbacks, }, configOptions, diff --git a/api/app/clients/output_parsers/addImages.js b/api/app/clients/output_parsers/addImages.js index 38ceb9a68609..ec04bcac86cd 100644 --- a/api/app/clients/output_parsers/addImages.js +++ b/api/app/clients/output_parsers/addImages.js @@ -60,12 +60,10 @@ function addImages(intermediateSteps, responseMessage) { if (!observation || !observation.includes('![')) { return; } - const observedImagePath = observation.match(/\(\/images\/.*\.\w*\)/g); + const observedImagePath = observation.match(/!\[.*\]\([^)]*\)/g); if (observedImagePath && !responseMessage.text.includes(observedImagePath[0])) { responseMessage.text += '\n' + observation; - if (process.env.DEBUG_PLUGINS) { - logger.debug('[addImages] added image from intermediateSteps:', observation); - } + logger.debug('[addImages] added image from intermediateSteps:', observation); } }); } diff --git a/api/app/clients/prompts/formatMessages.spec.js b/api/app/clients/prompts/formatMessages.spec.js index 0497e4c47a0c..636cdb1c8e5d 100644 --- a/api/app/clients/prompts/formatMessages.spec.js +++ b/api/app/clients/prompts/formatMessages.spec.js @@ -54,7 +54,6 @@ describe('formatMessage', () => { _id: '6512cdfb92cbf69fea615331', messageId: 'b620bf73-c5c3-4a38-b724-76886aac24c4', __v: 0, - cancelled: false, conversationId: '5c23d24f-941f-4aab-85df-127b596c8aa5', createdAt: Date.now(), error: false, diff --git a/api/app/clients/tools/DALL-E.js b/api/app/clients/tools/DALL-E.js index 88a7cf850ac2..387294a1cbb6 100644 --- a/api/app/clients/tools/DALL-E.js +++ b/api/app/clients/tools/DALL-E.js @@ -4,8 +4,15 @@ const fs = require('fs'); const path = require('path'); const OpenAI = require('openai'); // const { genAzureEndpoint } = require('~/utils/genAzureEndpoints'); +const { v4: uuidv4 } = require('uuid'); const { Tool } = require('langchain/tools'); const { HttpsProxyAgent } = require('https-proxy-agent'); +const { + saveImageToFirebaseStorage, + getFirebaseStorageImageUrl, + getFirebaseStorage, +} = require('~/server/services/Files/Firebase'); +const { getImageBasename } = require('~/server/services/Files/images'); const extractBaseURL = require('~/utils/extractBaseURL'); const saveImageFromUrl = require('./saveImageFromUrl'); const { logger } = require('~/config'); @@ -15,7 +22,9 @@ class OpenAICreateImage extends Tool { constructor(fields = {}) { super(); + this.userId = fields.userId; let apiKey = fields.DALLE_API_KEY || this.getApiKey(); + const config = { apiKey }; if (DALLE_REVERSE_PROXY) { config.baseURL = extractBaseURL(DALLE_REVERSE_PROXY); @@ -24,7 +33,6 @@ class OpenAICreateImage extends Tool { if (PROXY) { config.httpAgent = new HttpsProxyAgent(PROXY); } - // let azureKey = fields.AZURE_API_KEY || process.env.AZURE_API_KEY; // if (azureKey) { @@ -97,12 +105,11 @@ Guidelines: throw new Error('No image URL returned from OpenAI API.'); } - const regex = /img-[\w\d]+.png/; - const match = theImageUrl.match(regex); - let imageName = '1.png'; + const imageBasename = getImageBasename(theImageUrl); + let imageName = `image_${uuidv4()}.png`; - if (match) { - imageName = match[0]; + if (imageBasename) { + imageName = imageBasename; logger.debug('[DALL-E]', { imageName }); // Output: img-lgCf7ppcbhqQrz6a5ear6FOb.png } else { logger.debug('[DALL-E] No image name found in the string.', { @@ -111,7 +118,18 @@ Guidelines: }); } - this.outputPath = path.resolve(__dirname, '..', '..', '..', '..', 'client', 'public', 'images'); + this.outputPath = path.resolve( + __dirname, + '..', + '..', + '..', + '..', + 'client', + 'public', + 'images', + this.userId, + ); + const appRoot = path.resolve(__dirname, '..', '..', '..', '..', 'client'); this.relativeImageUrl = path.relative(appRoot, this.outputPath); @@ -120,14 +138,25 @@ Guidelines: fs.mkdirSync(this.outputPath, { recursive: true }); } - try { - await saveImageFromUrl(theImageUrl, this.outputPath, imageName); - this.result = this.getMarkdownImageUrl(imageName); - } catch (error) { - logger.error('Error while saving the DALL-E image:', error); - this.result = theImageUrl; + const storage = getFirebaseStorage(); + if (storage) { + try { + await saveImageToFirebaseStorage(this.userId, theImageUrl, imageName); + this.result = await getFirebaseStorageImageUrl(`${this.userId}/${imageName}`); + logger.debug('[DALL-E] result: ' + this.result); + } catch (error) { + logger.error('Error while saving the image to Firebase Storage:', error); + this.result = `Failed to save the image to Firebase Storage. ${error.message}`; + } + } else { + try { + await saveImageFromUrl(theImageUrl, this.outputPath, imageName); + this.result = this.getMarkdownImageUrl(imageName); + } catch (error) { + logger.error('Error while saving the image locally:', error); + this.result = `Failed to save the image locally. ${error.message}`; + } } - return this.result; } } diff --git a/api/app/clients/tools/saveImageFromUrl.js b/api/app/clients/tools/saveImageFromUrl.js index d8b14ad4783e..b9fbcc895b0c 100644 --- a/api/app/clients/tools/saveImageFromUrl.js +++ b/api/app/clients/tools/saveImageFromUrl.js @@ -11,18 +11,24 @@ async function saveImageFromUrl(url, outputPath, outputFilename) { responseType: 'stream', }); + // Get the content type from the response headers + const contentType = response.headers['content-type']; + let extension = contentType.split('/').pop(); + // Check if the output directory exists, if not, create it if (!fs.existsSync(outputPath)) { fs.mkdirSync(outputPath, { recursive: true }); } - // Ensure the output filename has a '.png' extension - const filenameWithPngExt = outputFilename.endsWith('.png') - ? outputFilename - : `${outputFilename}.png`; + // Replace or append the correct extension + const extRegExp = new RegExp(path.extname(outputFilename) + '$'); + outputFilename = outputFilename.replace(extRegExp, `.${extension}`); + if (!path.extname(outputFilename)) { + outputFilename += `.${extension}`; + } // Create a writable stream for the output path - const outputFilePath = path.join(outputPath, filenameWithPngExt); + const outputFilePath = path.join(outputPath, outputFilename); const writer = fs.createWriteStream(outputFilePath); // Pipe the response data to the output file diff --git a/api/app/clients/tools/structured/DALLE3.js b/api/app/clients/tools/structured/DALLE3.js index dc5750a6892a..17d0368f395b 100644 --- a/api/app/clients/tools/structured/DALLE3.js +++ b/api/app/clients/tools/structured/DALLE3.js @@ -4,10 +4,17 @@ const fs = require('fs'); const path = require('path'); const { z } = require('zod'); const OpenAI = require('openai'); +const { v4: uuidv4 } = require('uuid'); const { Tool } = require('langchain/tools'); const { HttpsProxyAgent } = require('https-proxy-agent'); -const saveImageFromUrl = require('../saveImageFromUrl'); +const { + saveImageToFirebaseStorage, + getFirebaseStorageImageUrl, + getFirebaseStorage, +} = require('~/server/services/Files/Firebase'); +const { getImageBasename } = require('~/server/services/Files/images'); const extractBaseURL = require('~/utils/extractBaseURL'); +const saveImageFromUrl = require('../saveImageFromUrl'); const { logger } = require('~/config'); const { DALLE3_SYSTEM_PROMPT, DALLE_REVERSE_PROXY, PROXY } = process.env; @@ -15,6 +22,7 @@ class DALLE3 extends Tool { constructor(fields = {}) { super(); + this.userId = fields.userId; let apiKey = fields.DALLE_API_KEY || this.getApiKey(); const config = { apiKey }; if (DALLE_REVERSE_PROXY) { @@ -108,12 +116,12 @@ class DALLE3 extends Tool { n: 1, }); } catch (error) { - return `Something went wrong when trying to generate the image. The DALL-E API may unavailable: + return `Something went wrong when trying to generate the image. The DALL-E API may be unavailable: Error Message: ${error.message}`; } if (!resp) { - return 'Something went wrong when trying to generate the image. The DALL-E API may unavailable'; + return 'Something went wrong when trying to generate the image. The DALL-E API may be unavailable'; } const theImageUrl = resp.data[0].url; @@ -122,12 +130,11 @@ Error Message: ${error.message}`; return 'No image URL returned from OpenAI API. There may be a problem with the API or your configuration.'; } - const regex = /img-[\w\d]+.png/; - const match = theImageUrl.match(regex); - let imageName = '1.png'; + const imageBasename = getImageBasename(theImageUrl); + let imageName = `image_${uuidv4()}.png`; - if (match) { - imageName = match[0]; + if (imageBasename) { + imageName = imageBasename; logger.debug('[DALL-E-3]', { imageName }); // Output: img-lgCf7ppcbhqQrz6a5ear6FOb.png } else { logger.debug('[DALL-E-3] No image name found in the string.', { @@ -146,6 +153,7 @@ Error Message: ${error.message}`; 'client', 'public', 'images', + this.userId, ); const appRoot = path.resolve(__dirname, '..', '..', '..', '..', '..', 'client'); this.relativeImageUrl = path.relative(appRoot, this.outputPath); @@ -154,13 +162,24 @@ Error Message: ${error.message}`; if (!fs.existsSync(this.outputPath)) { fs.mkdirSync(this.outputPath, { recursive: true }); } - - try { - await saveImageFromUrl(theImageUrl, this.outputPath, imageName); - this.result = this.getMarkdownImageUrl(imageName); - } catch (error) { - logger.error('Error while saving the image:', error); - this.result = theImageUrl; + const storage = getFirebaseStorage(); + if (storage) { + try { + await saveImageToFirebaseStorage(this.userId, theImageUrl, imageName); + this.result = await getFirebaseStorageImageUrl(`${this.userId}/${imageName}`); + logger.debug('[DALL-E-3] result: ' + this.result); + } catch (error) { + logger.error('Error while saving the image to Firebase Storage:', error); + this.result = `Failed to save the image to Firebase Storage. ${error.message}`; + } + } else { + try { + await saveImageFromUrl(theImageUrl, this.outputPath, imageName); + this.result = this.getMarkdownImageUrl(imageName); + } catch (error) { + logger.error('Error while saving the image locally:', error); + this.result = `Failed to save the image locally. ${error.message}`; + } } return this.result; diff --git a/api/app/clients/tools/structured/specs/DALLE3.spec.js b/api/app/clients/tools/structured/specs/DALLE3.spec.js index c61e1d351302..34fa3ebf00a3 100644 --- a/api/app/clients/tools/structured/specs/DALLE3.spec.js +++ b/api/app/clients/tools/structured/specs/DALLE3.spec.js @@ -2,11 +2,40 @@ const fs = require('fs'); const path = require('path'); const OpenAI = require('openai'); const DALLE3 = require('../DALLE3'); +const { + getFirebaseStorage, + saveImageToFirebaseStorage, +} = require('~/server/services/Files/Firebase'); const saveImageFromUrl = require('../../saveImageFromUrl'); const { logger } = require('~/config'); jest.mock('openai'); +jest.mock('~/server/services/Files/Firebase', () => ({ + getFirebaseStorage: jest.fn(), + saveImageToFirebaseStorage: jest.fn(), + getFirebaseStorageImageUrl: jest.fn(), +})); + +jest.mock('~/server/services/Files/images', () => ({ + getImageBasename: jest.fn().mockImplementation((url) => { + // Split the URL by '/' + const parts = url.split('/'); + + // Get the last part of the URL + const lastPart = parts.pop(); + + // Check if the last part of the URL matches the image extension regex + const imageExtensionRegex = /\.(jpg|jpeg|png|gif|bmp|tiff|svg)$/i; + if (imageExtensionRegex.test(lastPart)) { + return lastPart; + } + + // If the regex test fails, return an empty string + return ''; + }), +})); + const generate = jest.fn(); OpenAI.mockImplementation(() => ({ images: { @@ -187,7 +216,48 @@ describe('DALLE3', () => { generate.mockResolvedValue(mockResponse); saveImageFromUrl.mockRejectedValue(error); const result = await dalle._call(mockData); - expect(logger.error).toHaveBeenCalledWith('Error while saving the image:', error); - expect(result).toBe(mockResponse.data[0].url); + expect(logger.error).toHaveBeenCalledWith('Error while saving the image locally:', error); + expect(result).toBe('Failed to save the image locally. Error while saving the image'); + }); + + it('should save image to Firebase Storage if Firebase is initialized', async () => { + const mockData = { + prompt: 'A test prompt', + }; + const mockImageUrl = 'http://example.com/img-test.png'; + const mockResponse = { data: [{ url: mockImageUrl }] }; + generate.mockResolvedValue(mockResponse); + getFirebaseStorage.mockReturnValue({}); // Simulate Firebase being initialized + + await dalle._call(mockData); + + expect(getFirebaseStorage).toHaveBeenCalled(); + expect(saveImageToFirebaseStorage).toHaveBeenCalledWith( + undefined, + mockImageUrl, + expect.any(String), + ); + }); + + it('should handle error when saving image to Firebase Storage fails', async () => { + const mockData = { + prompt: 'A test prompt', + }; + const mockImageUrl = 'http://example.com/img-test.png'; + const mockResponse = { data: [{ url: mockImageUrl }] }; + const error = new Error('Error while saving to Firebase'); + generate.mockResolvedValue(mockResponse); + getFirebaseStorage.mockReturnValue({}); // Simulate Firebase being initialized + saveImageToFirebaseStorage.mockRejectedValue(error); + + const result = await dalle._call(mockData); + + expect(logger.error).toHaveBeenCalledWith( + 'Error while saving the image to Firebase Storage:', + error, + ); + expect(result).toBe( + 'Failed to save the image to Firebase Storage. Error while saving to Firebase', + ); }); }); diff --git a/api/app/clients/tools/util/handleTools.js b/api/app/clients/tools/util/handleTools.js index 3afe2776729e..352dd5dec740 100644 --- a/api/app/clients/tools/util/handleTools.js +++ b/api/app/clients/tools/util/handleTools.js @@ -67,19 +67,19 @@ const validateTools = async (user, tools = []) => { } }; -const loadToolWithAuth = async (user, authFields, ToolConstructor, options = {}) => { +const loadToolWithAuth = async (userId, authFields, ToolConstructor, options = {}) => { return async function () { let authValues = {}; for (const authField of authFields) { let authValue = process.env[authField]; if (!authValue) { - authValue = await getUserPluginAuthValue(user, authField); + authValue = await getUserPluginAuthValue(userId, authField); } authValues[authField] = authValue; } - return new ToolConstructor({ ...options, ...authValues }); + return new ToolConstructor({ ...options, ...authValues, userId }); }; }; diff --git a/api/cache/getCustomConfig.js b/api/cache/getCustomConfig.js new file mode 100644 index 000000000000..62082c5cbae7 --- /dev/null +++ b/api/cache/getCustomConfig.js @@ -0,0 +1,23 @@ +const { CacheKeys } = require('librechat-data-provider'); +const loadCustomConfig = require('~/server/services/Config/loadCustomConfig'); +const getLogStores = require('./getLogStores'); + +/** + * Retrieves the configuration object + * @function getCustomConfig */ +async function getCustomConfig() { + const cache = getLogStores(CacheKeys.CONFIG_STORE); + let customConfig = await cache.get(CacheKeys.CUSTOM_CONFIG); + + if (!customConfig) { + customConfig = await loadCustomConfig(); + } + + if (!customConfig) { + return null; + } + + return customConfig; +} + +module.exports = getCustomConfig; diff --git a/api/cache/getLogStores.js b/api/cache/getLogStores.js index 77949dacd3c9..016c77000099 100644 --- a/api/cache/getLogStores.js +++ b/api/cache/getLogStores.js @@ -1,9 +1,10 @@ const Keyv = require('keyv'); -const keyvMongo = require('./keyvMongo'); -const keyvRedis = require('./keyvRedis'); -const { CacheKeys } = require('~/common/enums'); -const { math, isEnabled } = require('~/server/utils'); +const { CacheKeys } = require('librechat-data-provider'); const { logFile, violationFile } = require('./keyvFiles'); +const { math, isEnabled } = require('~/server/utils'); +const keyvRedis = require('./keyvRedis'); +const keyvMongo = require('./keyvMongo'); + const { BAN_DURATION, USE_REDIS } = process.env ?? {}; const duration = math(BAN_DURATION, 7200000); @@ -20,10 +21,10 @@ const pending_req = isEnabled(USE_REDIS) const config = isEnabled(USE_REDIS) ? new Keyv({ store: keyvRedis }) - : new Keyv({ namespace: CacheKeys.CONFIG }); + : new Keyv({ namespace: CacheKeys.CONFIG_STORE }); const namespaces = { - config, + [CacheKeys.CONFIG_STORE]: config, pending_req, ban: new Keyv({ store: keyvMongo, namespace: 'bans', ttl: duration }), general: new Keyv({ store: logFile, namespace: 'violations' }), @@ -39,19 +40,15 @@ const namespaces = { * Returns the keyv cache specified by type. * If an invalid type is passed, an error will be thrown. * - * @module getLogStores - * @requires keyv - a simple key-value storage that allows you to easily switch out storage adapters. - * @requires keyvFiles - a module that includes the logFile and violationFile. - * - * @param {string} type - The type of violation, which can be 'concurrent', 'message_limit', 'registrations' or 'logins'. - * @returns {Keyv} - If a valid type is passed, returns an object containing the logs for violations of the specified type. - * @throws Will throw an error if an invalid violation type is passed. + * @param {string} key - The key for the namespace to access + * @returns {Keyv} - If a valid key is passed, returns an object containing the cache store of the specified key. + * @throws Will throw an error if an invalid key is passed. */ -const getLogStores = (type) => { - if (!type || !namespaces[type]) { - throw new Error(`Invalid store type: ${type}`); +const getLogStores = (key) => { + if (!key || !namespaces[key]) { + throw new Error(`Invalid store key: ${key}`); } - return namespaces[type]; + return namespaces[key]; }; module.exports = getLogStores; diff --git a/api/common/enums.js b/api/common/enums.js deleted file mode 100644 index 849ae43f59c2..000000000000 --- a/api/common/enums.js +++ /dev/null @@ -1,17 +0,0 @@ -/** - * @typedef {Object} CacheKeys - * @property {'config'} CONFIG - Key for the config cache. - * @property {'plugins'} PLUGINS - Key for the plugins cache. - * @property {'modelsConfig'} MODELS_CONFIG - Key for the model config cache. - * @property {'defaultConfig'} DEFAULT_CONFIG - Key for the default config cache. - * @property {'overrideConfig'} OVERRIDE_CONFIG - Key for the override config cache. - */ -const CacheKeys = { - CONFIG: 'config', - PLUGINS: 'plugins', - MODELS_CONFIG: 'modelsConfig', - DEFAULT_CONFIG: 'defaultConfig', - OVERRIDE_CONFIG: 'overrideConfig', -}; - -module.exports = { CacheKeys }; diff --git a/api/config/parsers.js b/api/config/parsers.js index 4f94d6e4d767..59685eab0bf3 100644 --- a/api/config/parsers.js +++ b/api/config/parsers.js @@ -1,11 +1,16 @@ +const { klona } = require('klona'); const winston = require('winston'); const traverse = require('traverse'); -const { klona } = require('klona/full'); const SPLAT_SYMBOL = Symbol.for('splat'); const MESSAGE_SYMBOL = Symbol.for('message'); -const sensitiveKeys = [/^(sk-)[^\s]+/, /(Bearer )[^\s]+/, /(api-key:? )[^\s]+/, /(key=)[^\s]+/]; +const sensitiveKeys = [ + /^(sk-)[^\s]+/, // OpenAI API key pattern + /(Bearer )[^\s]+/, // Header: Bearer token pattern + /(api-key:? )[^\s]+/, // Header: API key pattern + /(key=)[^\s]+/, // URL query param: sensitive key pattern (Google) +]; /** * Determines if a given value string is sensitive and returns matching regex patterns. @@ -102,55 +107,72 @@ const condenseArray = (item) => { */ const debugTraverse = winston.format.printf(({ level, message, timestamp, ...metadata }) => { let msg = `${timestamp} ${level}: ${truncateLongStrings(message?.trim(), 150)}`; + try { + if (level !== 'debug') { + return msg; + } - if (level !== 'debug') { - return msg; - } + if (!metadata) { + return msg; + } - if (!metadata) { - return msg; - } + const debugValue = metadata[SPLAT_SYMBOL]?.[0]; - const debugValue = metadata[SPLAT_SYMBOL]?.[0]; + if (!debugValue) { + return msg; + } - if (!debugValue) { - return msg; - } + if (debugValue && Array.isArray(debugValue)) { + msg += `\n${JSON.stringify(debugValue.map(condenseArray))}`; + return msg; + } - if (debugValue && Array.isArray(debugValue)) { - msg += `\n${JSON.stringify(debugValue.map(condenseArray))}`; - return msg; - } + if (typeof debugValue !== 'object') { + return (msg += ` ${debugValue}`); + } - if (typeof debugValue !== 'object') { - return (msg += ` ${debugValue}`); - } + msg += '\n{'; - msg += '\n{'; - - const copy = klona(metadata); - traverse(copy).forEach(function (value) { - const parent = this.parent; - const parentKey = `${parent && parent.notRoot ? parent.key + '.' : ''}`; - const tabs = `${parent && parent.notRoot ? '\t\t' : '\t'}`; - if (this.isLeaf && typeof value === 'string') { - const truncatedText = truncateLongStrings(value); - msg += `\n${tabs}${parentKey}${this.key}: ${JSON.stringify(truncatedText)},`; - } else if (this.notLeaf && Array.isArray(value) && value.length > 0) { - const currentMessage = `\n${tabs}// ${value.length} ${this.key.replace(/s$/, '')}(s)`; - this.update(currentMessage, true); - msg += currentMessage; - const stringifiedArray = value.map(condenseArray); - msg += `\n${tabs}${parentKey}${this.key}: [${stringifiedArray}],`; - } else if (this.isLeaf && typeof value === 'function') { - msg += `\n${tabs}${parentKey}${this.key}: function,`; - } else if (this.isLeaf) { - msg += `\n${tabs}${parentKey}${this.key}: ${value},`; - } - }); + const copy = klona(metadata); + traverse(copy).forEach(function (value) { + if (typeof this?.key === 'symbol') { + return; + } + + let _parentKey = ''; + const parent = this.parent; + + if (typeof parent?.key !== 'symbol' && parent?.key) { + _parentKey = parent.key; + } - msg += '\n}'; - return msg; + const parentKey = `${parent && parent.notRoot ? _parentKey + '.' : ''}`; + + const tabs = `${parent && parent.notRoot ? ' ' : ' '}`; + + const currentKey = this?.key ?? 'unknown'; + + if (this.isLeaf && typeof value === 'string') { + const truncatedText = truncateLongStrings(value); + msg += `\n${tabs}${parentKey}${currentKey}: ${JSON.stringify(truncatedText)},`; + } else if (this.notLeaf && Array.isArray(value) && value.length > 0) { + const currentMessage = `\n${tabs}// ${value.length} ${currentKey.replace(/s$/, '')}(s)`; + this.update(currentMessage, true); + msg += currentMessage; + const stringifiedArray = value.map(condenseArray); + msg += `\n${tabs}${parentKey}${currentKey}: [${stringifiedArray}],`; + } else if (this.isLeaf && typeof value === 'function') { + msg += `\n${tabs}${parentKey}${currentKey}: function,`; + } else if (this.isLeaf) { + msg += `\n${tabs}${parentKey}${currentKey}: ${value},`; + } + }); + + msg += '\n}'; + return msg; + } catch (e) { + return (msg += `\n[LOGGER PARSING ERROR] ${e.message}`); + } }); module.exports = { diff --git a/api/models/Message.js b/api/models/Message.js index 270ff851f97f..fe615f3283f0 100644 --- a/api/models/Message.js +++ b/api/models/Message.js @@ -9,23 +9,23 @@ module.exports = { async saveMessage({ user, + endpoint, messageId, newMessageId, conversationId, parentMessageId, sender, text, - isCreatedByUser = false, + isCreatedByUser, error, unfinished, - cancelled, files, - isEdited = false, - finish_reason = null, - tokenCount = null, - plugin = null, - plugins = null, - model = null, + isEdited, + finish_reason, + tokenCount, + plugin, + plugins, + model, }) { try { const validConvoId = idSchema.safeParse(conversationId); @@ -35,6 +35,7 @@ module.exports = { const update = { user, + endpoint, messageId: newMessageId || messageId, conversationId, parentMessageId, @@ -45,7 +46,6 @@ module.exports = { finish_reason, error, unfinished, - cancelled, tokenCount, plugin, plugins, diff --git a/api/models/schema/convoSchema.js b/api/models/schema/convoSchema.js index 46555ba35342..a282287eccbb 100644 --- a/api/models/schema/convoSchema.js +++ b/api/models/schema/convoSchema.js @@ -18,36 +18,29 @@ const convoSchema = mongoose.Schema( user: { type: String, index: true, - // default: null, }, messages: [{ type: mongoose.Schema.Types.ObjectId, ref: 'Message' }], // google only - examples: [{ type: mongoose.Schema.Types.Mixed }], + examples: { type: [{ type: mongoose.Schema.Types.Mixed }], default: undefined }, agentOptions: { type: mongoose.Schema.Types.Mixed, - // default: null, }, ...conversationPreset, // for bingAI only bingConversationId: { type: String, - // default: null, }, jailbreakConversationId: { type: String, - // default: null, }, conversationSignature: { type: String, - // default: null, }, clientId: { type: String, - // default: null, }, invocationId: { type: Number, - // default: 1, }, }, { timestamps: true }, diff --git a/api/models/schema/defaults.js b/api/models/schema/defaults.js index 338ee1208919..feedf1019ae7 100644 --- a/api/models/schema/defaults.js +++ b/api/models/schema/defaults.js @@ -5,6 +5,9 @@ const conversationPreset = { default: null, required: true, }, + endpointType: { + type: String, + }, // for azureOpenAI, openAI, chatGPTBrowser only model: { type: String, @@ -95,7 +98,6 @@ const agentOptions = { // default: null, required: false, }, - // for google only modelLabel: { type: String, // default: null, diff --git a/api/models/schema/messageSchema.js b/api/models/schema/messageSchema.js index 4c0ff2521ebf..06da19e476de 100644 --- a/api/models/schema/messageSchema.js +++ b/api/models/schema/messageSchema.js @@ -21,10 +21,13 @@ const messageSchema = mongoose.Schema( }, model: { type: String, + default: null, + }, + endpoint: { + type: String, }, conversationSignature: { type: String, - // required: true }, clientId: { type: String, @@ -34,7 +37,6 @@ const messageSchema = mongoose.Schema( }, parentMessageId: { type: String, - // required: true }, tokenCount: { type: Number, @@ -68,10 +70,6 @@ const messageSchema = mongoose.Schema( type: Boolean, default: false, }, - cancelled: { - type: Boolean, - default: false, - }, error: { type: Boolean, default: false, @@ -85,22 +83,26 @@ const messageSchema = mongoose.Schema( select: false, default: false, }, - files: [{ type: mongoose.Schema.Types.Mixed }], + files: { type: [{ type: mongoose.Schema.Types.Mixed }], default: undefined }, plugin: { - latest: { - type: String, - required: false, - }, - inputs: { - type: [mongoose.Schema.Types.Mixed], - required: false, - }, - outputs: { - type: String, - required: false, + type: { + latest: { + type: String, + required: false, + }, + inputs: { + type: [mongoose.Schema.Types.Mixed], + required: false, + default: undefined, + }, + outputs: { + type: String, + required: false, + }, }, + default: undefined, }, - plugins: [{ type: mongoose.Schema.Types.Mixed }], + plugins: { type: [{ type: mongoose.Schema.Types.Mixed }], default: undefined }, }, { timestamps: true }, ); diff --git a/api/package.json b/api/package.json index 684d13978947..38e63ba5c9d4 100644 --- a/api/package.json +++ b/api/package.json @@ -31,7 +31,7 @@ "@azure/search-documents": "^12.0.0", "@keyv/mongo": "^2.1.8", "@keyv/redis": "^2.8.1", - "@langchain/google-genai": "^0.0.2", + "@langchain/google-genai": "^0.0.7", "axios": "^1.3.4", "bcryptjs": "^2.4.3", "cheerio": "^1.0.0-rc.12", @@ -44,6 +44,7 @@ "express-mongo-sanitize": "^2.2.0", "express-rate-limit": "^6.9.0", "express-session": "^1.17.3", + "firebase": "^10.6.0", "googleapis": "^126.0.1", "handlebars": "^4.7.7", "html": "^1.0.0", @@ -53,7 +54,7 @@ "keyv": "^4.5.4", "keyv-file": "^0.2.0", "klona": "^2.0.6", - "langchain": "^0.0.186", + "langchain": "^0.0.214", "librechat-data-provider": "*", "lodash": "^4.17.21", "meilisearch": "^0.33.0", diff --git a/api/server/controllers/AskController.js b/api/server/controllers/AskController.js index d1d9f8f7ad93..67d7c67e9f75 100644 --- a/api/server/controllers/AskController.js +++ b/api/server/controllers/AskController.js @@ -9,6 +9,7 @@ const AskController = async (req, res, next, initializeClient, addTitle) => { text, endpointOption, conversationId, + modelDisplayLabel, parentMessageId = null, overrideParentMessageId = null, } = req.body; @@ -22,7 +23,11 @@ const AskController = async (req, res, next, initializeClient, addTitle) => { let responseMessageId; let lastSavedTimestamp = 0; let saveDelay = 100; - const sender = getResponseSender({ ...endpointOption, model: endpointOption.modelOptions.model }); + const sender = getResponseSender({ + ...endpointOption, + model: endpointOption.modelOptions.model, + modelDisplayLabel, + }); const newConvo = !conversationId; const user = req.user.id; @@ -62,7 +67,6 @@ const AskController = async (req, res, next, initializeClient, addTitle) => { text: partialText, model: client.modelOptions.model, unfinished: true, - cancelled: false, error: false, user, }); @@ -114,21 +118,26 @@ const AskController = async (req, res, next, initializeClient, addTitle) => { response = { ...response, ...metadata }; } + response.endpoint = endpointOption.endpoint; + if (client.options.attachments) { userMessage.files = client.options.attachments; delete userMessage.image_urls; } - sendMessage(res, { - title: await getConvoTitle(user, conversationId), - final: true, - conversation: await getConvo(user, conversationId), - requestMessage: userMessage, - responseMessage: response, - }); - res.end(); + if (!abortController.signal.aborted) { + sendMessage(res, { + title: await getConvoTitle(user, conversationId), + final: true, + conversation: await getConvo(user, conversationId), + requestMessage: userMessage, + responseMessage: response, + }); + res.end(); + + await saveMessage({ ...response, user }); + } - await saveMessage({ ...response, user }); await saveMessage(userMessage); if (addTitle && parentMessageId === '00000000-0000-0000-0000-000000000000' && newConvo) { diff --git a/api/server/controllers/EditController.js b/api/server/controllers/EditController.js index 023dc35a832c..43b82e7193ff 100644 --- a/api/server/controllers/EditController.js +++ b/api/server/controllers/EditController.js @@ -10,6 +10,7 @@ const EditController = async (req, res, next, initializeClient) => { generation, endpointOption, conversationId, + modelDisplayLabel, responseMessageId, isContinued = false, parentMessageId = null, @@ -29,7 +30,11 @@ const EditController = async (req, res, next, initializeClient) => { let promptTokens; let lastSavedTimestamp = 0; let saveDelay = 100; - const sender = getResponseSender({ ...endpointOption, model: endpointOption.modelOptions.model }); + const sender = getResponseSender({ + ...endpointOption, + model: endpointOption.modelOptions.model, + modelDisplayLabel, + }); const userMessageId = parentMessageId; const user = req.user.id; @@ -61,7 +66,6 @@ const EditController = async (req, res, next, initializeClient) => { text: partialText, model: endpointOption.modelOptions.model, unfinished: true, - cancelled: false, isEdited: true, error: false, user, @@ -113,16 +117,18 @@ const EditController = async (req, res, next, initializeClient) => { response = { ...response, ...metadata }; } - await saveMessage({ ...response, user }); + if (!abortController.signal.aborted) { + sendMessage(res, { + title: await getConvoTitle(user, conversationId), + final: true, + conversation: await getConvo(user, conversationId), + requestMessage: userMessage, + responseMessage: response, + }); + res.end(); - sendMessage(res, { - title: await getConvoTitle(user, conversationId), - final: true, - conversation: await getConvo(user, conversationId), - requestMessage: userMessage, - responseMessage: response, - }); - res.end(); + await saveMessage({ ...response, user }); + } } catch (error) { const partialText = getPartialText(); handleAbortError(res, req, error, { diff --git a/api/server/controllers/EndpointController.js b/api/server/controllers/EndpointController.js index 0cc21f96ac3b..5069bb33e0b4 100644 --- a/api/server/controllers/EndpointController.js +++ b/api/server/controllers/EndpointController.js @@ -1,17 +1,22 @@ +const { CacheKeys } = require('librechat-data-provider'); +const { loadDefaultEndpointsConfig, loadConfigEndpoints } = require('~/server/services/Config'); const { getLogStores } = require('~/cache'); -const { CacheKeys } = require('~/common/enums'); -const { loadDefaultEndpointsConfig } = require('~/server/services/Config'); async function endpointController(req, res) { - const cache = getLogStores(CacheKeys.CONFIG); - const config = await cache.get(CacheKeys.DEFAULT_CONFIG); - if (config) { - res.send(config); + const cache = getLogStores(CacheKeys.CONFIG_STORE); + const cachedEndpointsConfig = await cache.get(CacheKeys.ENDPOINT_CONFIG); + if (cachedEndpointsConfig) { + res.send(cachedEndpointsConfig); return; } - const defaultConfig = await loadDefaultEndpointsConfig(); - await cache.set(CacheKeys.DEFAULT_CONFIG, defaultConfig); - res.send(JSON.stringify(defaultConfig)); + + const defaultEndpointsConfig = await loadDefaultEndpointsConfig(); + const customConfigEndpoints = await loadConfigEndpoints(); + + const endpointsConfig = { ...defaultEndpointsConfig, ...customConfigEndpoints }; + + await cache.set(CacheKeys.ENDPOINT_CONFIG, endpointsConfig); + res.send(JSON.stringify(endpointsConfig)); } module.exports = endpointController; diff --git a/api/server/controllers/ModelController.js b/api/server/controllers/ModelController.js index 61ca82ecf030..2d23961e1544 100644 --- a/api/server/controllers/ModelController.js +++ b/api/server/controllers/ModelController.js @@ -1,15 +1,19 @@ +const { CacheKeys } = require('librechat-data-provider'); +const { loadDefaultModels, loadConfigModels } = require('~/server/services/Config'); const { getLogStores } = require('~/cache'); -const { CacheKeys } = require('~/common/enums'); -const { loadDefaultModels } = require('~/server/services/Config'); async function modelController(req, res) { - const cache = getLogStores(CacheKeys.CONFIG); - let modelConfig = await cache.get(CacheKeys.MODELS_CONFIG); - if (modelConfig) { - res.send(modelConfig); + const cache = getLogStores(CacheKeys.CONFIG_STORE); + const cachedModelsConfig = await cache.get(CacheKeys.MODELS_CONFIG); + if (cachedModelsConfig) { + res.send(cachedModelsConfig); return; } - modelConfig = await loadDefaultModels(); + const defaultModelsConfig = await loadDefaultModels(); + const customModelsConfig = await loadConfigModels(); + + const modelConfig = { ...defaultModelsConfig, ...customModelsConfig }; + await cache.set(CacheKeys.MODELS_CONFIG, modelConfig); res.send(modelConfig); } diff --git a/api/server/controllers/OverrideController.js b/api/server/controllers/OverrideController.js index 0abd27a7a24b..677fb87bdcb5 100644 --- a/api/server/controllers/OverrideController.js +++ b/api/server/controllers/OverrideController.js @@ -1,9 +1,9 @@ -const { getLogStores } = require('~/cache'); -const { CacheKeys } = require('~/common/enums'); +const { CacheKeys } = require('librechat-data-provider'); const { loadOverrideConfig } = require('~/server/services/Config'); +const { getLogStores } = require('~/cache'); async function overrideController(req, res) { - const cache = getLogStores(CacheKeys.CONFIG); + const cache = getLogStores(CacheKeys.CONFIG_STORE); let overrideConfig = await cache.get(CacheKeys.OVERRIDE_CONFIG); if (overrideConfig) { res.send(overrideConfig); @@ -15,7 +15,7 @@ async function overrideController(req, res) { overrideConfig = await loadOverrideConfig(); const { endpointsConfig, modelsConfig } = overrideConfig; if (endpointsConfig) { - await cache.set(CacheKeys.DEFAULT_CONFIG, endpointsConfig); + await cache.set(CacheKeys.ENDPOINT_CONFIG, endpointsConfig); } if (modelsConfig) { await cache.set(CacheKeys.MODELS_CONFIG, modelsConfig); diff --git a/api/server/controllers/PluginController.js b/api/server/controllers/PluginController.js index 697a499796c7..c37b36974e08 100644 --- a/api/server/controllers/PluginController.js +++ b/api/server/controllers/PluginController.js @@ -1,7 +1,7 @@ const path = require('path'); const { promises: fs } = require('fs'); +const { CacheKeys } = require('librechat-data-provider'); const { addOpenAPISpecs } = require('~/app/clients/tools/util/addOpenAPISpecs'); -const { CacheKeys } = require('~/common/enums'); const { getLogStores } = require('~/cache'); const filterUniquePlugins = (plugins) => { @@ -29,7 +29,7 @@ const isPluginAuthenticated = (plugin) => { const getAvailablePluginsController = async (req, res) => { try { - const cache = getLogStores(CacheKeys.CONFIG); + const cache = getLogStores(CacheKeys.CONFIG_STORE); const cachedPlugins = await cache.get(CacheKeys.PLUGINS); if (cachedPlugins) { res.status(200).json(cachedPlugins); diff --git a/api/server/index.js b/api/server/index.js index afe9d1047a7b..3c6429e47d7e 100644 --- a/api/server/index.js +++ b/api/server/index.js @@ -1,16 +1,20 @@ +require('dotenv').config(); const path = require('path'); require('module-alias')({ base: path.resolve(__dirname, '..') }); const cors = require('cors'); const express = require('express'); const passport = require('passport'); const mongoSanitize = require('express-mongo-sanitize'); -const errorController = require('./controllers/ErrorController'); -const configureSocialLogins = require('./socialLogins'); +const { initializeFirebase } = require('~/server/services/Files/Firebase/initialize'); +const loadCustomConfig = require('~/server/services/Config/loadCustomConfig'); +const errorController = require('~/server/controllers/ErrorController'); +const configureSocialLogins = require('~/server/socialLogins'); +const noIndex = require('~/server/middleware/noIndex'); const { connectDb, indexSync } = require('~/lib/db'); const { logger } = require('~/config'); +const routes = require('~/server/routes'); const paths = require('~/config/paths'); -const routes = require('./routes'); const { PORT, HOST, ALLOW_SOCIAL_LOGIN } = process.env ?? {}; @@ -22,12 +26,15 @@ const { jwtLogin, passportLogin } = require('~/strategies'); const startServer = async () => { await connectDb(); logger.info('Connected to MongoDB'); + await loadCustomConfig(); + initializeFirebase(); await indexSync(); const app = express(); app.locals.config = paths; // Middleware + app.use(noIndex); app.use(errorController); app.use(express.json({ limit: '3mb' })); app.use(mongoSanitize()); diff --git a/api/server/middleware/abortMiddleware.js b/api/server/middleware/abortMiddleware.js index 4a109acf8f35..cc9b9fc0513d 100644 --- a/api/server/middleware/abortMiddleware.js +++ b/api/server/middleware/abortMiddleware.js @@ -7,17 +7,28 @@ const spendTokens = require('~/models/spendTokens'); const { logger } = require('~/config'); async function abortMessage(req, res) { - const { abortKey } = req.body; + let { abortKey, conversationId } = req.body; + + if (!abortKey && conversationId) { + abortKey = conversationId; + } if (!abortControllers.has(abortKey) && !res.headersSent) { - return res.status(404).send({ message: 'Request not found' }); + return res.status(204).send({ message: 'Request not found' }); } const { abortController } = abortControllers.get(abortKey); - const ret = await abortController.abortCompletion(); + const finalEvent = await abortController.abortCompletion(); logger.debug('[abortMessage] Aborted request', { abortKey }); abortControllers.delete(abortKey); - res.send(JSON.stringify(ret)); + + if (res.headersSent && finalEvent) { + return sendMessage(res, finalEvent); + } + + res.setHeader('Content-Type', 'application/json'); + + res.send(JSON.stringify(finalEvent)); } const handleAbort = () => { @@ -58,7 +69,6 @@ const createAbortController = (req, res, getAbortData) => { finish_reason: 'incomplete', model: endpointOption.modelOptions.model, unfinished: false, - cancelled: true, error: false, isCreatedByUser: false, tokenCount: completionTokens, @@ -84,10 +94,16 @@ const createAbortController = (req, res, getAbortData) => { }; const handleAbortError = async (res, req, error, data) => { - logger.error('[handleAbortError] response error and aborting request', error); + logger.error('[handleAbortError] AI response error; aborting request:', error); const { sender, conversationId, messageId, parentMessageId, partialText } = data; - const respondWithError = async () => { + if (error.stack && error.stack.includes('google')) { + logger.warn( + `AI Response error for conversation ${conversationId} likely caused by Google censor/filter`, + ); + } + + const respondWithError = async (partialText) => { const options = { sender, messageId, @@ -97,6 +113,15 @@ const handleAbortError = async (res, req, error, data) => { shouldSaveMessage: true, user: req.user.id, }; + + if (partialText) { + options.overrideProps = { + error: false, + unfinished: true, + text: partialText, + }; + } + const callback = async () => { if (abortControllers.has(conversationId)) { const { abortController } = abortControllers.get(conversationId); @@ -113,7 +138,7 @@ const handleAbortError = async (res, req, error, data) => { return await abortMessage(req, res); } catch (err) { logger.error('[handleAbortError] error while trying to abort message', err); - return respondWithError(); + return respondWithError(partialText); } } else { return respondWithError(); diff --git a/api/server/middleware/buildEndpointOption.js b/api/server/middleware/buildEndpointOption.js index d98fe92d2cea..543815e3676e 100644 --- a/api/server/middleware/buildEndpointOption.js +++ b/api/server/middleware/buildEndpointOption.js @@ -1,5 +1,6 @@ const { processFiles } = require('~/server/services/Files'); const openAI = require('~/server/services/Endpoints/openAI'); +const custom = require('~/server/services/Endpoints/custom'); const google = require('~/server/services/Endpoints/google'); const anthropic = require('~/server/services/Endpoints/anthropic'); const gptPlugins = require('~/server/services/Endpoints/gptPlugins'); @@ -8,15 +9,20 @@ const { parseConvo, EModelEndpoint } = require('librechat-data-provider'); const buildFunction = { [EModelEndpoint.openAI]: openAI.buildOptions, [EModelEndpoint.google]: google.buildOptions, + [EModelEndpoint.custom]: custom.buildOptions, [EModelEndpoint.azureOpenAI]: openAI.buildOptions, [EModelEndpoint.anthropic]: anthropic.buildOptions, [EModelEndpoint.gptPlugins]: gptPlugins.buildOptions, }; function buildEndpointOption(req, res, next) { - const { endpoint } = req.body; - const parsedBody = parseConvo(endpoint, req.body); - req.body.endpointOption = buildFunction[endpoint](endpoint, parsedBody); + const { endpoint, endpointType } = req.body; + const parsedBody = parseConvo({ endpoint, endpointType, conversation: req.body }); + req.body.endpointOption = buildFunction[endpointType ?? endpoint]( + endpoint, + parsedBody, + endpointType, + ); if (req.body.files) { // hold the promise req.body.endpointOption.attachments = processFiles(req.body.files); diff --git a/api/server/middleware/index.js b/api/server/middleware/index.js index 553f2c663abc..77afd9716509 100644 --- a/api/server/middleware/index.js +++ b/api/server/middleware/index.js @@ -12,6 +12,8 @@ const concurrentLimiter = require('./concurrentLimiter'); const validateMessageReq = require('./validateMessageReq'); const buildEndpointOption = require('./buildEndpointOption'); const validateRegistration = require('./validateRegistration'); +const moderateText = require('./moderateText'); +const noIndex = require('./noIndex'); module.exports = { ...abortMiddleware, @@ -28,4 +30,6 @@ module.exports = { validateMessageReq, buildEndpointOption, validateRegistration, + moderateText, + noIndex, }; diff --git a/api/server/middleware/moderateText.js b/api/server/middleware/moderateText.js new file mode 100644 index 000000000000..c4bfd8a13aee --- /dev/null +++ b/api/server/middleware/moderateText.js @@ -0,0 +1,39 @@ +const axios = require('axios'); +const denyRequest = require('./denyRequest'); + +async function moderateText(req, res, next) { + if (process.env.OPENAI_MODERATION === 'true') { + try { + const { text } = req.body; + + const response = await axios.post( + process.env.OPENAI_MODERATION_REVERSE_PROXY || 'https://api.openai.com/v1/moderations', + { + input: text, + }, + { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${process.env.OPENAI_MODERATION_API_KEY}`, + }, + }, + ); + + const results = response.data.results; + const flagged = results.some((result) => result.flagged); + + if (flagged) { + const type = 'moderation'; + const errorMessage = { type }; + return await denyRequest(req, res, errorMessage); + } + } catch (error) { + console.error('Error in moderateText:', error); + const errorMessage = 'error in moderation check'; + return await denyRequest(req, res, errorMessage); + } + } + next(); +} + +module.exports = moderateText; diff --git a/api/server/middleware/noIndex.js b/api/server/middleware/noIndex.js new file mode 100644 index 000000000000..c4d7b55f2ded --- /dev/null +++ b/api/server/middleware/noIndex.js @@ -0,0 +1,11 @@ +const noIndex = (req, res, next) => { + const shouldNoIndex = process.env.NO_INDEX ? process.env.NO_INDEX === 'true' : true; + + if (shouldNoIndex) { + res.setHeader('X-Robots-Tag', 'noindex'); + } + + next(); +}; + +module.exports = noIndex; diff --git a/api/server/middleware/validateEndpoint.js b/api/server/middleware/validateEndpoint.js index 6e9c914c8eb3..0eeaaeb97dcb 100644 --- a/api/server/middleware/validateEndpoint.js +++ b/api/server/middleware/validateEndpoint.js @@ -1,7 +1,8 @@ const { handleError } = require('../utils'); function validateEndpoint(req, res, next) { - const { endpoint } = req.body; + const { endpoint: _endpoint, endpointType } = req.body; + const endpoint = endpointType ?? _endpoint; if (!req.body.text || req.body.text.length === 0) { return handleError(res, { text: 'Prompt empty or too short' }); diff --git a/api/server/routes/ask/askChatGPTBrowser.js b/api/server/routes/ask/askChatGPTBrowser.js index 37065b3770d7..34f1096a8714 100644 --- a/api/server/routes/ask/askChatGPTBrowser.js +++ b/api/server/routes/ask/askChatGPTBrowser.js @@ -99,7 +99,6 @@ const ask = async ({ parentMessageId: overrideParentMessageId || userMessageId, text: text, unfinished: true, - cancelled: false, error: false, isCreatedByUser: false, user, @@ -155,7 +154,6 @@ const ask = async ({ text: await handleText(response), sender: endpointOption?.chatGptLabel || 'ChatGPT', unfinished: false, - cancelled: false, error: false, isCreatedByUser: false, }; @@ -226,7 +224,6 @@ const ask = async ({ conversationId, parentMessageId: overrideParentMessageId || userMessageId, unfinished: false, - cancelled: false, error: true, isCreatedByUser: false, text: `${getPartialMessage() ?? ''}\n\nError message: "${error.message}"`, diff --git a/api/server/routes/ask/bingAI.js b/api/server/routes/ask/bingAI.js index 7a7177a96e25..1281b56ae35d 100644 --- a/api/server/routes/ask/bingAI.js +++ b/api/server/routes/ask/bingAI.js @@ -125,7 +125,6 @@ const ask = async ({ model, text: text, unfinished: true, - cancelled: false, error: false, isCreatedByUser: false, user, @@ -193,7 +192,6 @@ const ask = async ({ response.details.suggestedResponses && response.details.suggestedResponses.map((s) => s.text), unfinished, - cancelled: false, error: false, isCreatedByUser: false, }; @@ -263,7 +261,6 @@ const ask = async ({ text: partialText, model, unfinished: true, - cancelled: false, error: false, isCreatedByUser: false, }; @@ -285,7 +282,6 @@ const ask = async ({ conversationId, parentMessageId: overrideParentMessageId || userMessageId, unfinished: false, - cancelled: false, error: true, text: error.message, model, diff --git a/api/server/routes/ask/custom.js b/api/server/routes/ask/custom.js new file mode 100644 index 000000000000..ef979bf0000c --- /dev/null +++ b/api/server/routes/ask/custom.js @@ -0,0 +1,20 @@ +const express = require('express'); +const AskController = require('~/server/controllers/AskController'); +const { initializeClient } = require('~/server/services/Endpoints/custom'); +const { addTitle } = require('~/server/services/Endpoints/openAI'); +const { + handleAbort, + setHeaders, + validateEndpoint, + buildEndpointOption, +} = require('~/server/middleware'); + +const router = express.Router(); + +router.post('/abort', handleAbort()); + +router.post('/', validateEndpoint, buildEndpointOption, setHeaders, async (req, res, next) => { + await AskController(req, res, next, initializeClient, addTitle); +}); + +module.exports = router; diff --git a/api/server/routes/ask/gptPlugins.js b/api/server/routes/ask/gptPlugins.js index b0aa1aa0f05c..85616cd1b319 100644 --- a/api/server/routes/ask/gptPlugins.js +++ b/api/server/routes/ask/gptPlugins.js @@ -13,9 +13,11 @@ const { setHeaders, validateEndpoint, buildEndpointOption, + moderateText, } = require('~/server/middleware'); const { logger } = require('~/config'); +router.use(moderateText); router.post('/abort', handleAbort()); router.post('/', validateEndpoint, buildEndpointOption, setHeaders, async (req, res) => { @@ -81,7 +83,6 @@ router.post('/', validateEndpoint, buildEndpointOption, setHeaders, async (req, text: partialText, model: endpointOption.modelOptions.model, unfinished: true, - cancelled: false, error: false, plugins, user, diff --git a/api/server/routes/ask/index.js b/api/server/routes/ask/index.js index 669fd87e6fbf..b5156ed8d106 100644 --- a/api/server/routes/ask/index.js +++ b/api/server/routes/ask/index.js @@ -1,5 +1,6 @@ const express = require('express'); const openAI = require('./openAI'); +const custom = require('./custom'); const google = require('./google'); const bingAI = require('./bingAI'); const anthropic = require('./anthropic'); @@ -42,5 +43,6 @@ router.use(`/${EModelEndpoint.gptPlugins}`, gptPlugins); router.use(`/${EModelEndpoint.anthropic}`, anthropic); router.use(`/${EModelEndpoint.google}`, google); router.use(`/${EModelEndpoint.bingAI}`, bingAI); +router.use(`/${EModelEndpoint.custom}`, custom); module.exports = router; diff --git a/api/server/routes/ask/openAI.js b/api/server/routes/ask/openAI.js index 180ee27f299b..31b3111077fa 100644 --- a/api/server/routes/ask/openAI.js +++ b/api/server/routes/ask/openAI.js @@ -6,10 +6,11 @@ const { setHeaders, validateEndpoint, buildEndpointOption, + moderateText, } = require('~/server/middleware'); const router = express.Router(); - +router.use(moderateText); router.post('/abort', handleAbort()); router.post('/', validateEndpoint, buildEndpointOption, setHeaders, async (req, res, next) => { diff --git a/api/server/routes/edit/custom.js b/api/server/routes/edit/custom.js new file mode 100644 index 000000000000..dd63c96c8f94 --- /dev/null +++ b/api/server/routes/edit/custom.js @@ -0,0 +1,20 @@ +const express = require('express'); +const EditController = require('~/server/controllers/EditController'); +const { initializeClient } = require('~/server/services/Endpoints/custom'); +const { addTitle } = require('~/server/services/Endpoints/openAI'); +const { + handleAbort, + setHeaders, + validateEndpoint, + buildEndpointOption, +} = require('~/server/middleware'); + +const router = express.Router(); + +router.post('/abort', handleAbort()); + +router.post('/', validateEndpoint, buildEndpointOption, setHeaders, async (req, res, next) => { + await EditController(req, res, next, initializeClient, addTitle); +}); + +module.exports = router; diff --git a/api/server/routes/edit/gptPlugins.js b/api/server/routes/edit/gptPlugins.js index b4f1f7ce85ce..8ddf92c25079 100644 --- a/api/server/routes/edit/gptPlugins.js +++ b/api/server/routes/edit/gptPlugins.js @@ -12,9 +12,11 @@ const { setHeaders, validateEndpoint, buildEndpointOption, + moderateText, } = require('~/server/middleware'); const { logger } = require('~/config'); +router.use(moderateText); router.post('/abort', handleAbort()); router.post('/', validateEndpoint, buildEndpointOption, setHeaders, async (req, res) => { @@ -88,7 +90,6 @@ router.post('/', validateEndpoint, buildEndpointOption, setHeaders, async (req, text: partialText, model: endpointOption.modelOptions.model, unfinished: true, - cancelled: false, isEdited: true, error: false, user, diff --git a/api/server/routes/edit/index.js b/api/server/routes/edit/index.js index 01dd06ced98f..fa19f9effdc6 100644 --- a/api/server/routes/edit/index.js +++ b/api/server/routes/edit/index.js @@ -1,5 +1,6 @@ const express = require('express'); const openAI = require('./openAI'); +const custom = require('./custom'); const google = require('./google'); const anthropic = require('./anthropic'); const gptPlugins = require('./gptPlugins'); @@ -38,5 +39,6 @@ router.use([`/${EModelEndpoint.azureOpenAI}`, `/${EModelEndpoint.openAI}`], open router.use(`/${EModelEndpoint.gptPlugins}`, gptPlugins); router.use(`/${EModelEndpoint.anthropic}`, anthropic); router.use(`/${EModelEndpoint.google}`, google); +router.use(`/${EModelEndpoint.custom}`, custom); module.exports = router; diff --git a/api/server/routes/edit/openAI.js b/api/server/routes/edit/openAI.js index 47f36d6cb4d9..e54881148dc6 100644 --- a/api/server/routes/edit/openAI.js +++ b/api/server/routes/edit/openAI.js @@ -6,10 +6,11 @@ const { setHeaders, validateEndpoint, buildEndpointOption, + moderateText, } = require('~/server/middleware'); const router = express.Router(); - +router.use(moderateText); router.post('/abort', handleAbort()); router.post('/', validateEndpoint, buildEndpointOption, setHeaders, async (req, res, next) => { diff --git a/api/server/routes/files/avatar.js b/api/server/routes/files/avatar.js new file mode 100644 index 000000000000..a7bb07c0f95f --- /dev/null +++ b/api/server/routes/files/avatar.js @@ -0,0 +1,34 @@ +const express = require('express'); +const multer = require('multer'); + +const uploadAvatar = require('~/server/services/Files/images/avatar/uploadAvatar'); +const { requireJwtAuth } = require('~/server/middleware/'); +const User = require('~/models/User'); + +const upload = multer(); +const router = express.Router(); + +router.post('/', requireJwtAuth, upload.single('input'), async (req, res) => { + try { + const userId = req.user.id; + const { manual } = req.body; + const input = req.file.buffer; + if (!userId) { + throw new Error('User ID is undefined'); + } + + // TODO: do not use Model directly, instead use a service method that uses the model + const user = await User.findById(userId).lean(); + + if (!user) { + throw new Error('User not found'); + } + const url = await uploadAvatar(userId, input, manual); + + res.json({ url }); + } catch (error) { + res.status(500).json({ message: 'An error occurred while uploading the profile picture' }); + } +}); + +module.exports = router; diff --git a/api/server/routes/files/index.js b/api/server/routes/files/index.js index 34c7dc62e3ad..74b200c80665 100644 --- a/api/server/routes/files/index.js +++ b/api/server/routes/files/index.js @@ -18,5 +18,6 @@ router.use(uaParser); router.use('/', files); router.use('/images', images); +router.use('/images/avatar', require('./avatar')); module.exports = router; diff --git a/api/server/services/Config/index.js b/api/server/services/Config/index.js index 13cbc09f3b36..57a00bf515e7 100644 --- a/api/server/services/Config/index.js +++ b/api/server/services/Config/index.js @@ -1,13 +1,19 @@ const { config } = require('./EndpointService'); +const loadCustomConfig = require('./loadCustomConfig'); +const loadConfigModels = require('./loadConfigModels'); const loadDefaultModels = require('./loadDefaultModels'); const loadOverrideConfig = require('./loadOverrideConfig'); const loadAsyncEndpoints = require('./loadAsyncEndpoints'); +const loadConfigEndpoints = require('./loadConfigEndpoints'); const loadDefaultEndpointsConfig = require('./loadDefaultEConfig'); module.exports = { config, + loadCustomConfig, + loadConfigModels, loadDefaultModels, loadOverrideConfig, loadAsyncEndpoints, + loadConfigEndpoints, loadDefaultEndpointsConfig, }; diff --git a/api/server/services/Config/loadConfigEndpoints.js b/api/server/services/Config/loadConfigEndpoints.js new file mode 100644 index 000000000000..1b435e144e99 --- /dev/null +++ b/api/server/services/Config/loadConfigEndpoints.js @@ -0,0 +1,54 @@ +const { CacheKeys, EModelEndpoint } = require('librechat-data-provider'); +const { isUserProvided, extractEnvVariable } = require('~/server/utils'); +const loadCustomConfig = require('./loadCustomConfig'); +const { getLogStores } = require('~/cache'); + +/** + * Load config endpoints from the cached configuration object + * @function loadConfigEndpoints */ +async function loadConfigEndpoints() { + const cache = getLogStores(CacheKeys.CONFIG_STORE); + let customConfig = await cache.get(CacheKeys.CUSTOM_CONFIG); + + if (!customConfig) { + customConfig = await loadCustomConfig(); + } + + if (!customConfig) { + return {}; + } + + const { endpoints = {} } = customConfig ?? {}; + const endpointsConfig = {}; + + if (Array.isArray(endpoints[EModelEndpoint.custom])) { + const customEndpoints = endpoints[EModelEndpoint.custom].filter( + (endpoint) => + endpoint.baseURL && + endpoint.apiKey && + endpoint.name && + endpoint.models && + (endpoint.models.fetch || endpoint.models.default), + ); + + for (let i = 0; i < customEndpoints.length; i++) { + const endpoint = customEndpoints[i]; + const { baseURL, apiKey, name, iconURL, modelDisplayLabel } = endpoint; + + const resolvedApiKey = extractEnvVariable(apiKey); + const resolvedBaseURL = extractEnvVariable(baseURL); + + endpointsConfig[name] = { + type: EModelEndpoint.custom, + userProvide: isUserProvided(resolvedApiKey), + userProvideURL: isUserProvided(resolvedBaseURL), + modelDisplayLabel, + iconURL, + }; + } + } + + return endpointsConfig; +} + +module.exports = loadConfigEndpoints; diff --git a/api/server/services/Config/loadConfigModels.js b/api/server/services/Config/loadConfigModels.js new file mode 100644 index 000000000000..0abe15a8a1f6 --- /dev/null +++ b/api/server/services/Config/loadConfigModels.js @@ -0,0 +1,79 @@ +const { CacheKeys, EModelEndpoint } = require('librechat-data-provider'); +const { isUserProvided, extractEnvVariable } = require('~/server/utils'); +const { fetchModels } = require('~/server/services/ModelService'); +const loadCustomConfig = require('./loadCustomConfig'); +const { getLogStores } = require('~/cache'); + +/** + * Load config endpoints from the cached configuration object + * @function loadConfigModels */ +async function loadConfigModels() { + const cache = getLogStores(CacheKeys.CONFIG_STORE); + let customConfig = await cache.get(CacheKeys.CUSTOM_CONFIG); + + if (!customConfig) { + customConfig = await loadCustomConfig(); + } + + if (!customConfig) { + return {}; + } + + const { endpoints = {} } = customConfig ?? {}; + const modelsConfig = {}; + + if (!Array.isArray(endpoints[EModelEndpoint.custom])) { + return modelsConfig; + } + + const customEndpoints = endpoints[EModelEndpoint.custom].filter( + (endpoint) => + endpoint.baseURL && + endpoint.apiKey && + endpoint.name && + endpoint.models && + (endpoint.models.fetch || endpoint.models.default), + ); + + const fetchPromisesMap = {}; // Map for promises keyed by baseURL + const baseUrlToNameMap = {}; // Map to associate baseURLs with names + + for (let i = 0; i < customEndpoints.length; i++) { + const endpoint = customEndpoints[i]; + const { models, name, baseURL, apiKey } = endpoint; + + const API_KEY = extractEnvVariable(apiKey); + const BASE_URL = extractEnvVariable(baseURL); + + modelsConfig[name] = []; + + if (models.fetch && !isUserProvided(API_KEY) && !isUserProvided(BASE_URL)) { + fetchPromisesMap[BASE_URL] = + fetchPromisesMap[BASE_URL] || fetchModels({ baseURL: BASE_URL, apiKey: API_KEY }); + baseUrlToNameMap[BASE_URL] = baseUrlToNameMap[BASE_URL] || []; + baseUrlToNameMap[BASE_URL].push(name); + continue; + } + + if (Array.isArray(models.default)) { + modelsConfig[name] = models.default; + } + } + + const fetchedData = await Promise.all(Object.values(fetchPromisesMap)); + const baseUrls = Object.keys(fetchPromisesMap); + + for (let i = 0; i < fetchedData.length; i++) { + const currentBaseUrl = baseUrls[i]; + const modelData = fetchedData[i]; + const associatedNames = baseUrlToNameMap[currentBaseUrl]; + + for (const name of associatedNames) { + modelsConfig[name] = modelData; + } + } + + return modelsConfig; +} + +module.exports = loadConfigModels; diff --git a/api/server/services/Config/loadCustomConfig.js b/api/server/services/Config/loadCustomConfig.js new file mode 100644 index 000000000000..c17d3283b47f --- /dev/null +++ b/api/server/services/Config/loadCustomConfig.js @@ -0,0 +1,41 @@ +const path = require('path'); +const { CacheKeys, configSchema } = require('librechat-data-provider'); +const loadYaml = require('~/utils/loadYaml'); +const { getLogStores } = require('~/cache'); +const { logger } = require('~/config'); + +const projectRoot = path.resolve(__dirname, '..', '..', '..', '..'); +const configPath = path.resolve(projectRoot, 'librechat.yaml'); + +/** + * Load custom configuration files and caches the object if the `cache` field at root is true. + * Validation via parsing the config file with the config schema. + * @function loadCustomConfig + * @returns {Promise} A promise that resolves to null or the custom config object. + * */ + +async function loadCustomConfig() { + const customConfig = loadYaml(configPath); + if (!customConfig) { + return null; + } + + const result = configSchema.strict().safeParse(customConfig); + if (!result.success) { + logger.error(`Invalid custom config file at ${configPath}`, result.error); + return null; + } else { + logger.info('Loaded custom config file'); + } + + if (customConfig.cache) { + const cache = getLogStores(CacheKeys.CONFIG_STORE); + await cache.set(CacheKeys.CUSTOM_CONFIG, customConfig); + } + + // TODO: handle remote config + + return customConfig; +} + +module.exports = loadCustomConfig; diff --git a/api/server/services/Endpoints/custom/buildOptions.js b/api/server/services/Endpoints/custom/buildOptions.js new file mode 100644 index 000000000000..63a2d1599246 --- /dev/null +++ b/api/server/services/Endpoints/custom/buildOptions.js @@ -0,0 +1,16 @@ +const buildOptions = (endpoint, parsedBody, endpointType) => { + const { chatGptLabel, promptPrefix, ...rest } = parsedBody; + const endpointOption = { + endpoint, + endpointType, + chatGptLabel, + promptPrefix, + modelOptions: { + ...rest, + }, + }; + + return endpointOption; +}; + +module.exports = buildOptions; diff --git a/api/server/services/Endpoints/custom/index.js b/api/server/services/Endpoints/custom/index.js new file mode 100644 index 000000000000..3cda8d5fece1 --- /dev/null +++ b/api/server/services/Endpoints/custom/index.js @@ -0,0 +1,7 @@ +const initializeClient = require('./initializeClient'); +const buildOptions = require('./buildOptions'); + +module.exports = { + initializeClient, + buildOptions, +}; diff --git a/api/server/services/Endpoints/custom/initializeClient.js b/api/server/services/Endpoints/custom/initializeClient.js new file mode 100644 index 000000000000..0c0ad9e7e217 --- /dev/null +++ b/api/server/services/Endpoints/custom/initializeClient.js @@ -0,0 +1,89 @@ +const { EModelEndpoint } = require('librechat-data-provider'); +const { getUserKey, checkUserKeyExpiry } = require('~/server/services/UserService'); +const { isUserProvided, extractEnvVariable } = require('~/server/utils'); +const getCustomConfig = require('~/cache/getCustomConfig'); +const { OpenAIClient } = require('~/app'); + +const envVarRegex = /^\${(.+)}$/; + +const { PROXY } = process.env; + +const initializeClient = async ({ req, res, endpointOption }) => { + const { key: expiresAt, endpoint } = req.body; + const customConfig = await getCustomConfig(); + if (!customConfig) { + throw new Error(`Config not found for the ${endpoint} custom endpoint.`); + } + + const { endpoints = {} } = customConfig; + const customEndpoints = endpoints[EModelEndpoint.custom] ?? []; + const endpointConfig = customEndpoints.find((endpointConfig) => endpointConfig.name === endpoint); + + const CUSTOM_API_KEY = extractEnvVariable(endpointConfig.apiKey); + const CUSTOM_BASE_URL = extractEnvVariable(endpointConfig.baseURL); + + if (CUSTOM_API_KEY.match(envVarRegex)) { + throw new Error(`Missing API Key for ${endpoint}.`); + } + + if (CUSTOM_BASE_URL.match(envVarRegex)) { + throw new Error(`Missing Base URL for ${endpoint}.`); + } + + const customOptions = { + addParams: endpointConfig.addParams, + dropParams: endpointConfig.dropParams, + titleConvo: endpointConfig.titleConvo, + titleModel: endpointConfig.titleModel, + forcePrompt: endpointConfig.forcePrompt, + summaryModel: endpointConfig.summaryModel, + modelDisplayLabel: endpointConfig.modelDisplayLabel, + titleMethod: endpointConfig.titleMethod ?? 'completion', + contextStrategy: endpointConfig.summarize ? 'summarize' : null, + }; + + const useUserKey = isUserProvided(CUSTOM_API_KEY); + const useUserURL = isUserProvided(CUSTOM_BASE_URL); + + let userValues = null; + if (expiresAt && (useUserKey || useUserURL)) { + checkUserKeyExpiry( + expiresAt, + `Your API values for ${endpoint} have expired. Please configure them again.`, + ); + userValues = await getUserKey({ userId: req.user.id, name: endpoint }); + try { + userValues = JSON.parse(userValues); + } catch (e) { + throw new Error(`Invalid JSON provided for ${endpoint} user values.`); + } + } + + let apiKey = useUserKey ? userValues.apiKey : CUSTOM_API_KEY; + let baseURL = useUserURL ? userValues.baseURL : CUSTOM_BASE_URL; + + if (!apiKey) { + throw new Error(`${endpoint} API key not provided.`); + } + + if (!baseURL) { + throw new Error(`${endpoint} Base URL not provided.`); + } + + const clientOptions = { + reverseProxyUrl: baseURL ?? null, + proxy: PROXY ?? null, + req, + res, + ...customOptions, + ...endpointOption, + }; + + const client = new OpenAIClient(apiKey, clientOptions); + return { + client, + openAIApiKey: apiKey, + }; +}; + +module.exports = initializeClient; diff --git a/api/server/services/Endpoints/openAI/addTitle.js b/api/server/services/Endpoints/openAI/addTitle.js index f630638643fb..ab15443f9428 100644 --- a/api/server/services/Endpoints/openAI/addTitle.js +++ b/api/server/services/Endpoints/openAI/addTitle.js @@ -7,6 +7,10 @@ const addTitle = async (req, { text, response, client }) => { return; } + if (client.options.titleConvo === false) { + return; + } + // If the request was aborted and is not azure, don't generate the title. if (!client.azure && client.abortController.signal.aborted) { return; diff --git a/api/server/services/Files/Firebase/images.js b/api/server/services/Files/Firebase/images.js new file mode 100644 index 000000000000..e04902c02fe0 --- /dev/null +++ b/api/server/services/Files/Firebase/images.js @@ -0,0 +1,45 @@ +const fetch = require('node-fetch'); +const { ref, uploadBytes, getDownloadURL } = require('firebase/storage'); +const { getFirebaseStorage } = require('./initialize'); + +async function saveImageToFirebaseStorage(userId, imageUrl, imageName) { + const storage = getFirebaseStorage(); + if (!storage) { + console.error('Firebase is not initialized. Cannot save image to Firebase Storage.'); + return null; + } + + const storageRef = ref(storage, `images/${userId.toString()}/${imageName}`); + + try { + // Upload image to Firebase Storage using the image URL + await uploadBytes(storageRef, await fetch(imageUrl).then((response) => response.buffer())); + return imageName; + } catch (error) { + console.error('Error uploading image to Firebase Storage:', error.message); + return null; + } +} + +async function getFirebaseStorageImageUrl(imageName) { + const storage = getFirebaseStorage(); + if (!storage) { + console.error('Firebase is not initialized. Cannot get image URL from Firebase Storage.'); + return null; + } + + const storageRef = ref(storage, `images/${imageName}`); + + try { + // Get the download URL for the image from Firebase Storage + return `![generated image](${await getDownloadURL(storageRef)})`; + } catch (error) { + console.error('Error fetching image URL from Firebase Storage:', error.message); + return null; + } +} + +module.exports = { + saveImageToFirebaseStorage, + getFirebaseStorageImageUrl, +}; diff --git a/api/server/services/Files/Firebase/index.js b/api/server/services/Files/Firebase/index.js new file mode 100644 index 000000000000..905bf660d4fe --- /dev/null +++ b/api/server/services/Files/Firebase/index.js @@ -0,0 +1,7 @@ +const images = require('./images'); +const initialize = require('./initialize'); + +module.exports = { + ...images, + ...initialize, +}; diff --git a/api/server/services/Files/Firebase/initialize.js b/api/server/services/Files/Firebase/initialize.js new file mode 100644 index 000000000000..67d923c44f88 --- /dev/null +++ b/api/server/services/Files/Firebase/initialize.js @@ -0,0 +1,39 @@ +const firebase = require('firebase/app'); +const { getStorage } = require('firebase/storage'); +const { logger } = require('~/config'); + +let i = 0; +let firebaseApp = null; + +const initializeFirebase = () => { + // Return existing instance if already initialized + if (firebaseApp) { + return firebaseApp; + } + + const firebaseConfig = { + apiKey: process.env.FIREBASE_API_KEY, + authDomain: process.env.FIREBASE_AUTH_DOMAIN, + projectId: process.env.FIREBASE_PROJECT_ID, + storageBucket: process.env.FIREBASE_STORAGE_BUCKET, + messagingSenderId: process.env.FIREBASE_MESSAGING_SENDER_ID, + appId: process.env.FIREBASE_APP_ID, + }; + + if (Object.values(firebaseConfig).some((value) => !value)) { + i === 0 && logger.info('[Optional] CDN not initialized.'); + i++; + return null; + } + + firebaseApp = firebase.initializeApp(firebaseConfig); + logger.info('Firebase CDN initialized'); + return firebaseApp; +}; + +const getFirebaseStorage = () => { + const app = initializeFirebase(); + return app ? getStorage(app) : null; +}; + +module.exports = { initializeFirebase, getFirebaseStorage }; diff --git a/api/server/services/Files/images/avatar/firebaseStrategy.js b/api/server/services/Files/images/avatar/firebaseStrategy.js new file mode 100644 index 000000000000..9c000b43ecc4 --- /dev/null +++ b/api/server/services/Files/images/avatar/firebaseStrategy.js @@ -0,0 +1,29 @@ +const { ref, uploadBytes, getDownloadURL } = require('firebase/storage'); +const { getFirebaseStorage } = require('~/server/services/Files/Firebase/initialize'); +const { logger } = require('~/config'); + +async function firebaseStrategy(userId, webPBuffer, oldUser, manual) { + try { + const storage = getFirebaseStorage(); + if (!storage) { + throw new Error('Firebase is not initialized.'); + } + const avatarRef = ref(storage, `images/${userId.toString()}/avatar`); + + await uploadBytes(avatarRef, webPBuffer); + const urlFirebase = await getDownloadURL(avatarRef); + const isManual = manual === 'true'; + + const url = `${urlFirebase}?manual=${isManual}`; + if (isManual) { + oldUser.avatar = url; + await oldUser.save(); + } + return url; + } catch (error) { + logger.error('Error uploading profile picture:', error); + throw error; + } +} + +module.exports = firebaseStrategy; diff --git a/api/server/services/Files/images/avatar/localStrategy.js b/api/server/services/Files/images/avatar/localStrategy.js new file mode 100644 index 000000000000..021beda7d13f --- /dev/null +++ b/api/server/services/Files/images/avatar/localStrategy.js @@ -0,0 +1,32 @@ +const fs = require('fs').promises; +const path = require('path'); + +async function localStrategy(userId, webPBuffer, oldUser, manual) { + const userDir = path.resolve( + __dirname, + '..', + '..', + '..', + '..', + '..', + '..', + 'client', + 'public', + 'images', + userId, + ); + let avatarPath = path.join(userDir, 'avatar.png'); + const urlRoute = `/images/${userId}/avatar.png`; + await fs.mkdir(userDir, { recursive: true }); + await fs.writeFile(avatarPath, webPBuffer); + const isManual = manual === 'true'; + let url = `${urlRoute}?manual=${isManual}×tamp=${new Date().getTime()}`; + if (isManual) { + oldUser.avatar = url; + await oldUser.save(); + } + + return url; +} + +module.exports = localStrategy; diff --git a/api/server/services/Files/images/avatar/uploadAvatar.js b/api/server/services/Files/images/avatar/uploadAvatar.js new file mode 100644 index 000000000000..0726df9a4dde --- /dev/null +++ b/api/server/services/Files/images/avatar/uploadAvatar.js @@ -0,0 +1,63 @@ +const sharp = require('sharp'); +const fetch = require('node-fetch'); +const fs = require('fs').promises; +const User = require('~/models/User'); +const { getFirebaseStorage } = require('~/server/services/Files/Firebase/initialize'); +const firebaseStrategy = require('./firebaseStrategy'); +const localStrategy = require('./localStrategy'); +const { logger } = require('~/config'); + +async function convertToWebP(inputBuffer) { + return sharp(inputBuffer).resize({ width: 150 }).toFormat('webp').toBuffer(); +} + +async function uploadAvatar(userId, input, manual) { + try { + if (userId === undefined) { + throw new Error('User ID is undefined'); + } + const _id = userId; + // TODO: remove direct use of Model, `User` + const oldUser = await User.findOne({ _id }); + let imageBuffer; + if (typeof input === 'string') { + const response = await fetch(input); + + if (!response.ok) { + throw new Error(`Failed to fetch image from URL. Status: ${response.status}`); + } + imageBuffer = await response.buffer(); + } else if (input instanceof Buffer) { + imageBuffer = input; + } else if (typeof input === 'object' && input instanceof File) { + const fileContent = await fs.readFile(input.path); + imageBuffer = Buffer.from(fileContent); + } else { + throw new Error('Invalid input type. Expected URL, Buffer, or File.'); + } + const { width, height } = await sharp(imageBuffer).metadata(); + const minSize = Math.min(width, height); + const squaredBuffer = await sharp(imageBuffer) + .extract({ + left: Math.floor((width - minSize) / 2), + top: Math.floor((height - minSize) / 2), + width: minSize, + height: minSize, + }) + .toBuffer(); + const webPBuffer = await convertToWebP(squaredBuffer); + const storage = getFirebaseStorage(); + if (storage) { + const url = await firebaseStrategy(userId, webPBuffer, oldUser, manual); + return url; + } + + const url = await localStrategy(userId, webPBuffer, oldUser, manual); + return url; + } catch (error) { + logger.error('Error uploading the avatar:', error); + throw error; + } +} + +module.exports = uploadAvatar; diff --git a/api/server/services/Files/images/index.js b/api/server/services/Files/images/index.js index d5b818e937d8..fa49eb953567 100644 --- a/api/server/services/Files/images/index.js +++ b/api/server/services/Files/images/index.js @@ -1,11 +1,15 @@ const convert = require('./convert'); const encode = require('./encode'); +const parse = require('./parse'); const resize = require('./resize'); const validate = require('./validate'); +const uploadAvatar = require('./avatar/uploadAvatar'); module.exports = { ...convert, ...encode, + ...parse, ...resize, ...validate, + uploadAvatar, }; diff --git a/api/server/services/Files/images/parse.js b/api/server/services/Files/images/parse.js new file mode 100644 index 000000000000..5a1113c97e41 --- /dev/null +++ b/api/server/services/Files/images/parse.js @@ -0,0 +1,27 @@ +const URL = require('url').URL; +const path = require('path'); + +const imageExtensionRegex = /\.(jpg|jpeg|png|gif|bmp|tiff|svg)$/i; + +/** + * Extracts the image basename from a given URL. + * + * @param {string} urlString - The URL string from which the image basename is to be extracted. + * @returns {string} The basename of the image file from the URL. + * Returns an empty string if the URL does not contain a valid image basename. + */ +function getImageBasename(urlString) { + try { + const url = new URL(urlString); + const basename = path.basename(url.pathname); + + return imageExtensionRegex.test(basename) ? basename : ''; + } catch (error) { + // If URL parsing fails, return an empty string + return ''; + } +} + +module.exports = { + getImageBasename, +}; diff --git a/api/server/services/ModelService.js b/api/server/services/ModelService.js index 08c9ae71d292..2e433dbd14ed 100644 --- a/api/server/services/ModelService.js +++ b/api/server/services/ModelService.js @@ -24,15 +24,53 @@ const { PROXY, } = process.env ?? {}; +/** + * Fetches OpenAI models from the specified base API path or Azure, based on the provided configuration. + * + * @param {Object} params - The parameters for fetching the models. + * @param {string} params.apiKey - The API key for authentication with the API. + * @param {string} params.baseURL - The base path URL for the API. + * @param {string} [params.name='OpenAI'] - The name of the API; defaults to 'OpenAI'. + * @param {boolean} [params.azure=false] - Whether to fetch models from Azure. + * @returns {Promise} A promise that resolves to an array of model identifiers. + * @async + */ +const fetchModels = async ({ apiKey, baseURL, name = 'OpenAI', azure = false }) => { + let models = []; + + if (!baseURL && !azure) { + return models; + } + + try { + const payload = { + headers: { + Authorization: `Bearer ${apiKey}`, + }, + }; + + if (PROXY) { + payload.httpsAgent = new HttpsProxyAgent(PROXY); + } + + const res = await axios.get(`${baseURL}${azure ? '' : '/models'}`, payload); + models = res.data.data.map((item) => item.id); + } catch (err) { + logger.error(`Failed to fetch models from ${azure ? 'Azure ' : ''}${name} API`, err); + } + + return models; +}; + const fetchOpenAIModels = async (opts = { azure: false, plugins: false }, _models = []) => { let models = _models.slice() ?? []; let apiKey = openAIApiKey; - let basePath = 'https://api.openai.com/v1'; + let baseURL = 'https://api.openai.com/v1'; let reverseProxyUrl = OPENAI_REVERSE_PROXY; if (opts.azure) { return models; // const azure = getAzureCredentials(); - // basePath = (genAzureChatCompletion(azure)) + // baseURL = (genAzureChatCompletion(azure)) // .split('/deployments')[0] // .concat(`/models?api-version=${azure.azureOpenAIApiVersion}`); // apiKey = azureOpenAIApiKey; @@ -42,32 +80,20 @@ const fetchOpenAIModels = async (opts = { azure: false, plugins: false }, _model } if (reverseProxyUrl) { - basePath = extractBaseURL(reverseProxyUrl); + baseURL = extractBaseURL(reverseProxyUrl); } - const cachedModels = await modelsCache.get(basePath); + const cachedModels = await modelsCache.get(baseURL); if (cachedModels) { return cachedModels; } - if (basePath || opts.azure) { - try { - const payload = { - headers: { - Authorization: `Bearer ${apiKey}`, - }, - }; - - if (PROXY) { - payload.httpsAgent = new HttpsProxyAgent(PROXY); - } - const res = await axios.get(`${basePath}${opts.azure ? '' : '/models'}`, payload); - - models = res.data.data.map((item) => item.id); - // logger.debug(`Fetched ${models.length} models from ${opts.azure ? 'Azure ' : ''}OpenAI API`); - } catch (err) { - logger.error(`Failed to fetch models from ${opts.azure ? 'Azure ' : ''}OpenAI API`, err); - } + if (baseURL || opts.azure) { + models = await fetchModels({ + apiKey, + baseURL, + azure: opts.azure, + }); } if (!reverseProxyUrl) { @@ -75,7 +101,7 @@ const fetchOpenAIModels = async (opts = { azure: false, plugins: false }, _model models = models.filter((model) => regex.test(model)); } - await modelsCache.set(basePath, models); + await modelsCache.set(baseURL, models); return models; }; @@ -142,6 +168,7 @@ const getGoogleModels = () => { }; module.exports = { + fetchModels, getOpenAIModels, getChatGPTBrowserModels, getAnthropicModels, diff --git a/api/server/services/PluginService.js b/api/server/services/PluginService.js index 1eaa6eedab5f..615823829147 100644 --- a/api/server/services/PluginService.js +++ b/api/server/services/PluginService.js @@ -2,18 +2,38 @@ const PluginAuth = require('~/models/schema/pluginAuthSchema'); const { encrypt, decrypt } = require('~/server/utils/'); const { logger } = require('~/config'); -const getUserPluginAuthValue = async (user, authField) => { +/** + * Asynchronously retrieves and decrypts the authentication value for a user's plugin, based on a specified authentication field. + * + * @param {string} userId - The unique identifier of the user for whom the plugin authentication value is to be retrieved. + * @param {string} authField - The specific authentication field (e.g., 'API_KEY', 'URL') whose value is to be retrieved and decrypted. + * @returns {Promise} A promise that resolves to the decrypted authentication value if found, or `null` if no such authentication value exists for the given user and field. + * + * The function throws an error if it encounters any issue during the retrieval or decryption process, or if the authentication value does not exist. + * + * @example + * // To get the decrypted value of the 'token' field for a user with userId '12345': + * getUserPluginAuthValue('12345', 'token').then(value => { + * console.log(value); + * }).catch(err => { + * console.error(err); + * }); + * + * @throws {Error} Throws an error if there's an issue during the retrieval or decryption process, or if the authentication value does not exist. + * @async + */ +const getUserPluginAuthValue = async (userId, authField) => { try { - const pluginAuth = await PluginAuth.findOne({ user, authField }).lean(); + const pluginAuth = await PluginAuth.findOne({ userId, authField }).lean(); if (!pluginAuth) { - return null; + throw new Error(`No plugin auth ${authField} found for user ${userId}`); } const decryptedValue = decrypt(pluginAuth.value); return decryptedValue; } catch (err) { logger.error('[getUserPluginAuthValue]', err); - return err; + throw err; } }; diff --git a/api/server/utils/countTokens.js b/api/server/utils/countTokens.js index 9c8c98e76a7d..34c070aa8c28 100644 --- a/api/server/utils/countTokens.js +++ b/api/server/utils/countTokens.js @@ -1,13 +1,12 @@ -const { load } = require('tiktoken/load'); const { Tiktoken } = require('tiktoken/lite'); -const registry = require('tiktoken/registry.json'); -const models = require('tiktoken/model_to_encoding.json'); +const p50k_base = require('tiktoken/encoders/p50k_base.json'); +const cl100k_base = require('tiktoken/encoders/cl100k_base.json'); const logger = require('~/config/winston'); const countTokens = async (text = '', modelName = 'gpt-3.5-turbo') => { let encoder = null; try { - const model = await load(registry[models[modelName]]); + const model = modelName.includes('text-davinci-003') ? p50k_base : cl100k_base; encoder = new Tiktoken(model.bpe_ranks, model.special_tokens, model.pat_str); const tokens = encoder.encode(text); encoder.free(); diff --git a/api/server/utils/handleText.js b/api/server/utils/handleText.js index 4cd1b7ce9946..b8d17106622d 100644 --- a/api/server/utils/handleText.js +++ b/api/server/utils/handleText.js @@ -165,6 +165,27 @@ function isEnabled(value) { return false; } +/** + * Checks if the provided value is 'user_provided'. + * + * @param {string} value - The value to check. + * @returns {boolean} - Returns true if the value is 'user_provided', otherwise false. + */ +const isUserProvided = (value) => value === 'user_provided'; + +/** + * Extracts the value of an environment variable from a string. + * @param {string} value - The value to be processed, possibly containing an env variable placeholder. + * @returns {string} - The actual value from the environment variable or the original value. + */ +function extractEnvVariable(value) { + const envVarMatch = value.match(/^\${(.+)}$/); + if (envVarMatch) { + return process.env[envVarMatch[1]] || value; + } + return value; +} + module.exports = { createOnProgress, isEnabled, @@ -172,4 +193,6 @@ module.exports = { formatSteps, formatAction, addSpaceIfNeeded, + isUserProvided, + extractEnvVariable, }; diff --git a/api/server/utils/handleText.spec.js b/api/server/utils/handleText.spec.js index ea440a89a57a..a5566fb1b2b7 100644 --- a/api/server/utils/handleText.spec.js +++ b/api/server/utils/handleText.spec.js @@ -1,4 +1,4 @@ -const { isEnabled } = require('./handleText'); +const { isEnabled, extractEnvVariable } = require('./handleText'); describe('isEnabled', () => { test('should return true when input is "true"', () => { @@ -48,4 +48,51 @@ describe('isEnabled', () => { test('should return false when input is an array', () => { expect(isEnabled([])).toBe(false); }); + + describe('extractEnvVariable', () => { + const originalEnv = process.env; + + beforeEach(() => { + jest.resetModules(); + process.env = { ...originalEnv }; + }); + + afterAll(() => { + process.env = originalEnv; + }); + + test('should return the value of the environment variable', () => { + process.env.TEST_VAR = 'test_value'; + expect(extractEnvVariable('${TEST_VAR}')).toBe('test_value'); + }); + + test('should return the original string if the envrionment variable is not defined correctly', () => { + process.env.TEST_VAR = 'test_value'; + expect(extractEnvVariable('${ TEST_VAR }')).toBe('${ TEST_VAR }'); + }); + + test('should return the original string if environment variable is not set', () => { + expect(extractEnvVariable('${NON_EXISTENT_VAR}')).toBe('${NON_EXISTENT_VAR}'); + }); + + test('should return the original string if it does not contain an environment variable', () => { + expect(extractEnvVariable('some_string')).toBe('some_string'); + }); + + test('should handle empty strings', () => { + expect(extractEnvVariable('')).toBe(''); + }); + + test('should handle strings without variable format', () => { + expect(extractEnvVariable('no_var_here')).toBe('no_var_here'); + }); + + test('should not process multiple variable formats', () => { + process.env.FIRST_VAR = 'first'; + process.env.SECOND_VAR = 'second'; + expect(extractEnvVariable('${FIRST_VAR} and ${SECOND_VAR}')).toBe( + '${FIRST_VAR} and ${SECOND_VAR}', + ); + }); + }); }); diff --git a/api/server/utils/streamResponse.js b/api/server/utils/streamResponse.js index 85d35f2c5533..3511f144cc7e 100644 --- a/api/server/utils/streamResponse.js +++ b/api/server/utils/streamResponse.js @@ -1,5 +1,8 @@ const crypto = require('crypto'); -const { saveMessage } = require('~/models/Message'); +const { parseConvo } = require('librechat-data-provider'); +const { saveMessage, getMessages } = require('~/models/Message'); +const { getConvo } = require('~/models/Conversation'); +const { logger } = require('~/config'); /** * Sends error data in Server Sent Events format and ends the response. @@ -15,7 +18,7 @@ const handleError = (res, message) => { * Sends message data in Server Sent Events format. * @param {object} res - - The server response. * @param {string} message - The message to be sent. - * @param {string} event - [Optional] The type of event. Default is 'message'. + * @param {'message' | 'error' | 'cancel'} event - [Optional] The type of event. Default is 'message'. */ const sendMessage = (res, message, event = 'message') => { if (message.length === 0) { @@ -32,19 +35,27 @@ const sendMessage = (res, message, event = 'message') => { * @param {function} callback - [Optional] The callback function to be executed. */ const sendError = async (res, options, callback) => { - const { user, sender, conversationId, messageId, parentMessageId, text, shouldSaveMessage } = - options; + const { + user, + sender, + conversationId, + messageId, + parentMessageId, + text, + shouldSaveMessage, + overrideProps = {}, + } = options; const errorMessage = { sender, messageId: messageId ?? crypto.randomUUID(), conversationId, parentMessageId, unfinished: false, - cancelled: false, error: true, final: true, text, isCreatedByUser: false, + ...overrideProps, }; if (callback && typeof callback === 'function') { await callback(); @@ -54,6 +65,26 @@ const sendError = async (res, options, callback) => { await saveMessage({ ...errorMessage, user }); } + if (!errorMessage.error) { + const requestMessage = { messageId: parentMessageId, conversationId }; + let query = [], + convo = {}; + try { + query = await getMessages(requestMessage); + convo = await getConvo(user, conversationId); + } catch (err) { + logger.error('[sendError] Error retrieving conversation data:', err); + convo = parseConvo(errorMessage); + } + + return sendMessage(res, { + final: true, + requestMessage: query?.[0] ? query[0] : requestMessage, + responseMessage: errorMessage, + conversation: convo, + }); + } + handleError(res, errorMessage); }; diff --git a/api/strategies/discordStrategy.js b/api/strategies/discordStrategy.js index c6fdde6d8c36..994554200cd4 100644 --- a/api/strategies/discordStrategy.js +++ b/api/strategies/discordStrategy.js @@ -1,51 +1,72 @@ const { Strategy: DiscordStrategy } = require('passport-discord'); const { logger } = require('~/config'); const User = require('~/models/User'); +const { useFirebase, uploadAvatar } = require('~/server/services/Files/images'); const discordLogin = async (accessToken, refreshToken, profile, cb) => { try { const email = profile.email; const discordId = profile.id; - const oldUser = await User.findOne({ - email, - }); + const oldUser = await User.findOne({ email }); const ALLOW_SOCIAL_REGISTRATION = process.env.ALLOW_SOCIAL_REGISTRATION?.toLowerCase() === 'true'; - let avatarURL; + let avatarUrl; + if (profile.avatar) { const format = profile.avatar.startsWith('a_') ? 'gif' : 'png'; - avatarURL = `https://cdn.discordapp.com/avatars/${profile.id}/${profile.avatar}.${format}`; + avatarUrl = `https://cdn.discordapp.com/avatars/${profile.id}/${profile.avatar}.${format}`; } else { const defaultAvatarNum = Number(profile.discriminator) % 5; - avatarURL = `https://cdn.discordapp.com/embed/avatars/${defaultAvatarNum}.png`; + avatarUrl = `https://cdn.discordapp.com/embed/avatars/${defaultAvatarNum}.png`; } if (oldUser) { - oldUser.avatar = avatarURL; - await oldUser.save(); + await handleExistingUser(oldUser, avatarUrl, useFirebase); return cb(null, oldUser); - } else if (ALLOW_SOCIAL_REGISTRATION) { - const newUser = await new User({ - provider: 'discord', - discordId, - username: profile.username, - email, - name: profile.global_name, - avatar: avatarURL, - }).save(); + } + if (ALLOW_SOCIAL_REGISTRATION) { + const newUser = await createNewUser(profile, discordId, email, avatarUrl, useFirebase); return cb(null, newUser); } - - return cb(null, false, { - message: 'User not found.', - }); } catch (err) { logger.error('[discordLogin]', err); return cb(err); } }; +const handleExistingUser = async (oldUser, avatarUrl, useFirebase) => { + if (!useFirebase && !oldUser.avatar.includes('?manual=true')) { + oldUser.avatar = avatarUrl; + await oldUser.save(); + } else if (useFirebase && !oldUser.avatar.includes('?manual=true')) { + const userId = oldUser._id; + const newavatarUrl = await uploadAvatar(userId, avatarUrl); + oldUser.avatar = newavatarUrl; + await oldUser.save(); + } +}; + +const createNewUser = async (profile, discordId, email, avatarUrl, useFirebase) => { + const newUser = await new User({ + provider: 'discord', + discordId, + username: profile.username, + email, + name: profile.global_name, + avatar: avatarUrl, + }).save(); + + if (useFirebase) { + const userId = newUser._id; + const newavatarUrl = await uploadAvatar(userId, avatarUrl); + newUser.avatar = newavatarUrl; + await newUser.save(); + } + + return newUser; +}; + module.exports = () => new DiscordStrategy( { diff --git a/api/strategies/facebookStrategy.js b/api/strategies/facebookStrategy.js index bb175a099cc2..b8915b2cc4bf 100644 --- a/api/strategies/facebookStrategy.js +++ b/api/strategies/facebookStrategy.js @@ -1,43 +1,64 @@ const FacebookStrategy = require('passport-facebook').Strategy; const { logger } = require('~/config'); const User = require('~/models/User'); +const { useFirebase, uploadAvatar } = require('~/server/services/Files/images'); const facebookLogin = async (accessToken, refreshToken, profile, cb) => { try { const email = profile.emails[0]?.value; const facebookId = profile.id; - const oldUser = await User.findOne({ - email, - }); + const oldUser = await User.findOne({ email }); const ALLOW_SOCIAL_REGISTRATION = process.env.ALLOW_SOCIAL_REGISTRATION?.toLowerCase() === 'true'; + const avatarUrl = profile.photos[0]?.value; if (oldUser) { - oldUser.avatar = profile.photo; - await oldUser.save(); + await handleExistingUser(oldUser, avatarUrl, useFirebase); return cb(null, oldUser); - } else if (ALLOW_SOCIAL_REGISTRATION) { - const newUser = await new User({ - provider: 'facebook', - facebookId, - username: profile.displayName, - email, - name: profile.name?.givenName + ' ' + profile.name?.familyName, - avatar: profile.photos[0]?.value, - }).save(); + } + if (ALLOW_SOCIAL_REGISTRATION) { + const newUser = await createNewUser(profile, facebookId, email, avatarUrl, useFirebase); return cb(null, newUser); } - - return cb(null, false, { - message: 'User not found.', - }); } catch (err) { logger.error('[facebookLogin]', err); return cb(err); } }; +const handleExistingUser = async (oldUser, avatarUrl, useFirebase) => { + if (!useFirebase && !oldUser.avatar.includes('?manual=true')) { + oldUser.avatar = avatarUrl; + await oldUser.save(); + } else if (useFirebase && !oldUser.avatar.includes('?manual=true')) { + const userId = oldUser._id; + const newavatarUrl = await uploadAvatar(userId, avatarUrl); + oldUser.avatar = newavatarUrl; + await oldUser.save(); + } +}; + +const createNewUser = async (profile, facebookId, email, avatarUrl, useFirebase) => { + const newUser = await new User({ + provider: 'facebook', + facebookId, + username: profile.displayName, + email, + name: profile.name?.givenName + ' ' + profile.name?.familyName, + avatar: avatarUrl, + }).save(); + + if (useFirebase) { + const userId = newUser._id; + const newavatarUrl = await uploadAvatar(userId, avatarUrl); + newUser.avatar = newavatarUrl; + await newUser.save(); + } + + return newUser; +}; + module.exports = () => new FacebookStrategy( { diff --git a/api/strategies/githubStrategy.js b/api/strategies/githubStrategy.js index 3962c58e50e7..c8480d50c135 100644 --- a/api/strategies/githubStrategy.js +++ b/api/strategies/githubStrategy.js @@ -1,6 +1,7 @@ const { Strategy: GitHubStrategy } = require('passport-github2'); const { logger } = require('~/config'); const User = require('~/models/User'); +const { useFirebase, uploadAvatar } = require('~/server/services/Files/images'); const githubLogin = async (accessToken, refreshToken, profile, cb) => { try { @@ -9,32 +10,56 @@ const githubLogin = async (accessToken, refreshToken, profile, cb) => { const oldUser = await User.findOne({ email }); const ALLOW_SOCIAL_REGISTRATION = process.env.ALLOW_SOCIAL_REGISTRATION?.toLowerCase() === 'true'; + const avatarUrl = profile.photos[0].value; if (oldUser) { - oldUser.avatar = profile.photos[0].value; - await oldUser.save(); + await handleExistingUser(oldUser, avatarUrl, useFirebase); return cb(null, oldUser); - } else if (ALLOW_SOCIAL_REGISTRATION) { - const newUser = await new User({ - provider: 'github', - githubId, - username: profile.username, - email, - emailVerified: profile.emails[0].verified, - name: profile.displayName, - avatar: profile.photos[0].value, - }).save(); + } + if (ALLOW_SOCIAL_REGISTRATION) { + const newUser = await createNewUser(profile, githubId, email, avatarUrl, useFirebase); return cb(null, newUser); } - - return cb(null, false, { message: 'User not found.' }); } catch (err) { logger.error('[githubLogin]', err); return cb(err); } }; +const handleExistingUser = async (oldUser, avatarUrl, useFirebase) => { + if (!useFirebase && !oldUser.avatar.includes('?manual=true')) { + oldUser.avatar = avatarUrl; + await oldUser.save(); + } else if (useFirebase && !oldUser.avatar.includes('?manual=true')) { + const userId = oldUser._id; + const avatarURL = await uploadAvatar(userId, avatarUrl); + oldUser.avatar = avatarURL; + await oldUser.save(); + } +}; + +const createNewUser = async (profile, githubId, email, avatarUrl, useFirebase) => { + const newUser = await new User({ + provider: 'github', + githubId, + username: profile.username, + email, + emailVerified: profile.emails[0].verified, + name: profile.displayName, + avatar: avatarUrl, + }).save(); + + if (useFirebase) { + const userId = newUser._id; + const avatarURL = await uploadAvatar(userId, avatarUrl); + newUser.avatar = avatarURL; + await newUser.save(); + } + + return newUser; +}; + module.exports = () => new GitHubStrategy( { diff --git a/api/strategies/googleStrategy.js b/api/strategies/googleStrategy.js index e65c5403f4bb..d013cc8e8fdb 100644 --- a/api/strategies/googleStrategy.js +++ b/api/strategies/googleStrategy.js @@ -1,6 +1,7 @@ const { Strategy: GoogleStrategy } = require('passport-google-oauth20'); const { logger } = require('~/config'); const User = require('~/models/User'); +const { useFirebase, uploadAvatar } = require('~/server/services/Files/images'); const googleLogin = async (accessToken, refreshToken, profile, cb) => { try { @@ -9,32 +10,56 @@ const googleLogin = async (accessToken, refreshToken, profile, cb) => { const oldUser = await User.findOne({ email }); const ALLOW_SOCIAL_REGISTRATION = process.env.ALLOW_SOCIAL_REGISTRATION?.toLowerCase() === 'true'; + const avatarUrl = profile.photos[0].value; if (oldUser) { - oldUser.avatar = profile.photos[0].value; - await oldUser.save(); + await handleExistingUser(oldUser, avatarUrl, useFirebase); return cb(null, oldUser); - } else if (ALLOW_SOCIAL_REGISTRATION) { - const newUser = await new User({ - provider: 'google', - googleId, - username: profile.name.givenName, - email, - emailVerified: profile.emails[0].verified, - name: `${profile.name.givenName} ${profile.name.familyName}`, - avatar: profile.photos[0].value, - }).save(); + } + if (ALLOW_SOCIAL_REGISTRATION) { + const newUser = await createNewUser(profile, googleId, email, avatarUrl, useFirebase); return cb(null, newUser); } - - return cb(null, false, { message: 'User not found.' }); } catch (err) { logger.error('[googleLogin]', err); return cb(err); } }; +const handleExistingUser = async (oldUser, avatarUrl, useFirebase) => { + if ((!useFirebase && !oldUser.avatar.includes('?manual=true')) || oldUser.avatar === null) { + oldUser.avatar = avatarUrl; + await oldUser.save(); + } else if (useFirebase && !oldUser.avatar.includes('?manual=true')) { + const userId = oldUser._id; + const avatarURL = await uploadAvatar(userId, avatarUrl); + oldUser.avatar = avatarURL; + await oldUser.save(); + } +}; + +const createNewUser = async (profile, googleId, email, avatarUrl, useFirebase) => { + const newUser = await new User({ + provider: 'google', + googleId, + username: profile.name.givenName, + email, + emailVerified: profile.emails[0].verified, + name: `${profile.name.givenName} ${profile.name.familyName}`, + avatar: avatarUrl, + }).save(); + + if (useFirebase) { + const userId = newUser._id; + const avatarURL = await uploadAvatar(userId, avatarUrl); + newUser.avatar = avatarURL; + await newUser.save(); + } + + return newUser; +}; + module.exports = () => new GoogleStrategy( { diff --git a/api/typedefs.js b/api/typedefs.js index 1ab9f6457186..e40a09763492 100644 --- a/api/typedefs.js +++ b/api/typedefs.js @@ -20,6 +20,12 @@ * @memberof typedefs */ +/** + * @exports TConfig + * @typedef {import('librechat-data-provider').TConfig} TConfig + * @memberof typedefs + */ + /** * @exports ImageMetadata * @typedef {Object} ImageMetadata @@ -280,8 +286,8 @@ * @property {boolean|{userProvide: boolean}} [chatGPTBrowser] - Flag to indicate if ChatGPT Browser endpoint is user provided, or its configuration. * @property {boolean|{userProvide: boolean}} [anthropic] - Flag to indicate if Anthropic endpoint is user provided, or its configuration. * @property {boolean|{userProvide: boolean}} [bingAI] - Flag to indicate if BingAI endpoint is user provided, or its configuration. - * @property {boolean|{userProvide: boolean}} [bingAI] - Flag to indicate if BingAI endpoint is user provided, or its configuration. - * @property {boolean|{userProvide: boolean}} [bingAI] - Flag to indicate if BingAI endpoint is user provided, or its configuration. + * @property {boolean|{userProvide: boolean}} [google] - Flag to indicate if BingAI endpoint is user provided, or its configuration. + * @property {boolean|{userProvide: boolean, userProvideURL: boolean, name: string}} [custom] - Custom Endpoint configuration. * @memberof typedefs */ @@ -313,13 +319,14 @@ * @property {boolean|{userProvide: boolean}} [anthropic] - Flag to indicate if Anthropic endpoint is user provided, or its configuration. * @property {boolean|{userProvide: boolean}} [bingAI] - Flag to indicate if BingAI endpoint is user provided, or its configuration. * @property {boolean|{userProvide: boolean}} [google] - Flag to indicate if Google endpoint is user provided, or its configuration. + * @property {boolean|{userProvide: boolean, userProvideURL: boolean, name: string}} [custom] - Custom Endpoint configuration. * @property {boolean|GptPlugins} [gptPlugins] - Configuration for GPT plugins. * @memberof typedefs */ /** * @exports EndpointConfig - * @typedef {boolean|{userProvide: boolean}|GptPlugins} EndpointConfig + * @typedef {boolean|TConfig} EndpointConfig * @memberof typedefs */ @@ -330,3 +337,39 @@ * @property {number} order - The order of the endpoint. * @memberof typedefs */ + +/** + * @typedef {Object} ModelOptions + * @property {string} modelName - The name of the model. + * @property {number} [temperature] - The temperature setting for the model. + * @property {number} [presence_penalty] - The presence penalty setting. + * @property {number} [frequency_penalty] - The frequency penalty setting. + * @property {number} [max_tokens] - The maximum number of tokens to generate. + * @memberof typedefs + */ + +/** + * @typedef {Object} ConfigOptions + * @property {string} [basePath] - The base path for the API requests. + * @property {Object} [baseOptions] - Base options for the API requests, including headers. + * @property {Object} [httpAgent] - The HTTP agent for the request. + * @property {Object} [httpsAgent] - The HTTPS agent for the request. + * @memberof typedefs + */ + +/** + * @typedef {Object} Callbacks + * @property {Function} [handleChatModelStart] - A callback function for handleChatModelStart + * @property {Function} [handleLLMEnd] - A callback function for handleLLMEnd + * @property {Function} [handleLLMError] - A callback function for handleLLMError + * @memberof typedefs + */ + +/** + * @typedef {Object} AzureOptions + * @property {string} [azureOpenAIApiKey] - The Azure OpenAI API key. + * @property {string} [azureOpenAIApiInstanceName] - The Azure OpenAI API instance name. + * @property {string} [azureOpenAIApiDeploymentName] - The Azure OpenAI API deployment name. + * @property {string} [azureOpenAIApiVersion] - The Azure OpenAI API version. + * @memberof typedefs + */ diff --git a/api/utils/index.js b/api/utils/index.js index f9194858e824..a40c53b6aba2 100644 --- a/api/utils/index.js +++ b/api/utils/index.js @@ -1,3 +1,4 @@ +const loadYaml = require('./loadYaml'); const tokenHelpers = require('./tokens'); const azureUtils = require('./azureUtils'); const extractBaseURL = require('./extractBaseURL'); @@ -8,4 +9,5 @@ module.exports = { ...tokenHelpers, extractBaseURL, findMessageContent, + loadYaml, }; diff --git a/api/utils/loadYaml.js b/api/utils/loadYaml.js new file mode 100644 index 000000000000..b7068e209f01 --- /dev/null +++ b/api/utils/loadYaml.js @@ -0,0 +1,13 @@ +const fs = require('fs'); +const yaml = require('js-yaml'); + +function loadYaml(filepath) { + try { + let fileContents = fs.readFileSync(filepath, 'utf8'); + return yaml.load(fileContents); + } catch (e) { + // console.error(e); + } +} + +module.exports = loadYaml; diff --git a/api/utils/tokens.js b/api/utils/tokens.js index cda4755717dd..b6aa7ba58886 100644 --- a/api/utils/tokens.js +++ b/api/utils/tokens.js @@ -39,22 +39,26 @@ const models = [ 'gpt-3.5-turbo-0301', ]; +const openAIModels = { + 'gpt-4': 8191, + 'gpt-4-0613': 8191, + 'gpt-4-32k': 32767, + 'gpt-4-32k-0314': 32767, + 'gpt-4-32k-0613': 32767, + 'gpt-3.5-turbo': 4095, + 'gpt-3.5-turbo-0613': 4095, + 'gpt-3.5-turbo-0301': 4095, + 'gpt-3.5-turbo-16k': 15999, + 'gpt-3.5-turbo-16k-0613': 15999, + 'gpt-3.5-turbo-1106': 16380, // -5 from max + 'gpt-4-1106': 127995, // -5 from max + 'mistral-': 31995, // -5 from max +}; + // Order is important here: by model series and context size (gpt-4 then gpt-3, ascending) const maxTokensMap = { - [EModelEndpoint.openAI]: { - 'gpt-4': 8191, - 'gpt-4-0613': 8191, - 'gpt-4-32k': 32767, - 'gpt-4-32k-0314': 32767, - 'gpt-4-32k-0613': 32767, - 'gpt-3.5-turbo': 4095, - 'gpt-3.5-turbo-0613': 4095, - 'gpt-3.5-turbo-0301': 4095, - 'gpt-3.5-turbo-16k': 15999, - 'gpt-3.5-turbo-16k-0613': 15999, - 'gpt-3.5-turbo-1106': 16380, // -5 from max - 'gpt-4-1106': 127995, // -5 from max - }, + [EModelEndpoint.openAI]: openAIModels, + [EModelEndpoint.custom]: openAIModels, [EModelEndpoint.google]: { /* Max I/O is combined so we subtract the amount from max response tokens for actual total */ gemini: 32750, // -10 from max diff --git a/client/index.html b/client/index.html index 6d6c1dbf5961..dae28cb7057e 100644 --- a/client/index.html +++ b/client/index.html @@ -3,6 +3,7 @@ + LibreChat { - + diff --git a/client/src/common/types.ts b/client/src/common/types.ts index 807e2dc9424f..17625731ff08 100644 --- a/client/src/common/types.ts +++ b/client/src/common/types.ts @@ -1,4 +1,11 @@ -import type { TConversation, TMessage, TPreset, TLoginUser, TUser } from 'librechat-data-provider'; +import type { + TConversation, + TMessage, + TPreset, + TLoginUser, + TUser, + EModelEndpoint, +} from 'librechat-data-provider'; import type { UseMutationResult } from '@tanstack/react-query'; export type TSetOption = (param: number | string) => (newValue: number | string | boolean) => void; @@ -27,6 +34,7 @@ export type TShowToast = { severity?: NotificationSeverity; showIcon?: boolean; duration?: number; + status?: 'error' | 'success' | 'warning' | 'info'; }; export type TBaseSettingsProps = { @@ -140,7 +148,7 @@ export type TDisplayProps = TText & export type TConfigProps = { userKey: string; setUserKey: React.Dispatch>; - endpoint: string; + endpoint: EModelEndpoint | string; }; export type TDangerButtonProps = { @@ -193,9 +201,11 @@ export type IconProps = Pick & Pick & { size?: number; button?: boolean; + iconURL?: string; message?: boolean; className?: string; - endpoint?: string | null; + endpoint?: EModelEndpoint | string | null; + endpointType?: EModelEndpoint | null; }; export type Option = Record & { diff --git a/client/src/components/Chat/ChatView.tsx b/client/src/components/Chat/ChatView.tsx index 5ce309513956..d582f4b3e9d8 100644 --- a/client/src/components/Chat/ChatView.tsx +++ b/client/src/components/Chat/ChatView.tsx @@ -6,10 +6,10 @@ import { useChatHelpers, useSSE } from '~/hooks'; // import GenerationButtons from './Input/GenerationButtons'; import MessagesView from './Messages/MessagesView'; // import OptionsBar from './Input/OptionsBar'; +import { Spinner } from '~/components/svg'; import { ChatContext } from '~/Providers'; import Presentation from './Presentation'; import ChatForm from './Input/ChatForm'; -import { Spinner } from '~/components'; import { buildTree } from '~/utils'; import Landing from './Landing'; import Header from './Header'; diff --git a/client/src/components/Chat/Input/ChatForm.tsx b/client/src/components/Chat/Input/ChatForm.tsx index 9fce650c83d2..f52ff943e1fe 100644 --- a/client/src/components/Chat/Input/ChatForm.tsx +++ b/client/src/components/Chat/Input/ChatForm.tsx @@ -30,6 +30,8 @@ export default function ChatForm({ index = 0 }) { }; const { requiresKey } = useRequiresKey(); + const { endpoint: _endpoint, endpointType } = conversation ?? { endpoint: null }; + const endpoint = endpointType ?? _endpoint; return (
-