diff --git a/api/app/clients/BaseClient.js b/api/app/clients/BaseClient.js index ec4fbd97d..2458dc0ab 100644 --- a/api/app/clients/BaseClient.js +++ b/api/app/clients/BaseClient.js @@ -1,5 +1,7 @@ const crypto = require('crypto'); const fetch = require('node-fetch'); +const { logger } = require('@librechat/data-schemas'); +const { getBalanceConfig } = require('@librechat/api'); const { supportsBalanceCheck, isAgentsEndpoint, @@ -15,7 +17,6 @@ const { checkBalance } = require('~/models/balanceMethods'); const { truncateToolCallOutputs } = require('./prompts'); const { getFiles } = require('~/models/File'); const TextStream = require('./TextStream'); -const { logger } = require('~/config'); class BaseClient { constructor(apiKey, options = {}) { @@ -112,13 +113,15 @@ class BaseClient { * If a correction to the token usage is needed, the method should return an object with the corrected token counts. * Should only be used if `recordCollectedUsage` was not used instead. * @param {string} [model] + * @param {AppConfig['balance']} [balance] * @param {number} promptTokens * @param {number} completionTokens * @returns {Promise} */ - async recordTokenUsage({ model, promptTokens, completionTokens }) { + async recordTokenUsage({ model, balance, promptTokens, completionTokens }) { logger.debug('[BaseClient] `recordTokenUsage` not implemented.', { model, + balance, promptTokens, completionTokens, }); @@ -571,6 +574,7 @@ class BaseClient { } async sendMessage(message, opts = {}) { + const appConfig = this.options.req?.config; /** @type {Promise} */ let userMessagePromise; const { user, head, isEdited, conversationId, responseMessageId, saveOptions, userMessage } = @@ -657,9 +661,9 @@ class BaseClient { } } - const balance = this.options.req?.app?.locals?.balance; + const balanceConfig = getBalanceConfig(appConfig); if ( - balance?.enabled && + balanceConfig?.enabled && supportsBalanceCheck[this.options.endpointType ?? this.options.endpoint] ) { await checkBalance({ @@ -758,6 +762,7 @@ class BaseClient { usage, promptTokens, completionTokens, + balance: balanceConfig, model: responseMessage.model, }); } diff --git a/api/app/clients/OpenAIClient.js b/api/app/clients/OpenAIClient.js index 3553e40dd..d6fd018ae 100644 --- a/api/app/clients/OpenAIClient.js +++ b/api/app/clients/OpenAIClient.js @@ -36,11 +36,11 @@ const { encodeAndFormat } = require('~/server/services/Files/images/encode'); const { addSpaceIfNeeded, sleep } = require('~/server/utils'); const { spendTokens } = require('~/models/spendTokens'); const { handleOpenAIErrors } = require('./tools/util'); -const { createLLM, RunManager } = require('./llm'); const { summaryBuffer } = require('./memory'); const { runTitleChain } = require('./chains'); const { tokenSplit } = require('./document'); const BaseClient = require('./BaseClient'); +const { createLLM } = require('./llm'); const { logger } = require('~/config'); class OpenAIClient extends BaseClient { @@ -618,10 +618,6 @@ class OpenAIClient extends BaseClient { temperature = 0.2, max_tokens, streaming, - context, - tokenBuffer, - initialMessageCount, - conversationId, }) { const modelOptions = { modelName: modelName ?? model, @@ -666,22 +662,12 @@ class OpenAIClient extends BaseClient { configOptions.httpsAgent = new HttpsProxyAgent(this.options.proxy); } - const { req, res, debug } = this.options; - const runManager = new RunManager({ req, res, debug, abortController: this.abortController }); - this.runManager = runManager; - const llm = createLLM({ modelOptions, configOptions, openAIApiKey: this.apiKey, azure: this.azure, streaming, - callbacks: runManager.createCallbacks({ - context, - tokenBuffer, - conversationId: this.conversationId ?? conversationId, - initialMessageCount, - }), }); return llm; @@ -702,6 +688,7 @@ class OpenAIClient extends BaseClient { * In case of failure, it will return the default title, "New Chat". */ async titleConvo({ text, conversationId, responseText = '' }) { + const appConfig = this.options.req?.config; this.conversationId = conversationId; if (this.options.attachments) { @@ -730,8 +717,7 @@ class OpenAIClient extends BaseClient { max_tokens: 16, }; - /** @type {TAzureConfig | undefined} */ - const azureConfig = this.options?.req?.app?.locals?.[EModelEndpoint.azureOpenAI]; + const azureConfig = appConfig?.endpoints?.[EModelEndpoint.azureOpenAI]; const resetTitleOptions = !!( (this.azure && azureConfig) || @@ -1120,6 +1106,7 @@ ${convo} } async chatCompletion({ payload, onProgress, abortController = null }) { + const appConfig = this.options.req?.config; let error = null; let intermediateReply = []; const errorCallback = (err) => (error = err); @@ -1165,8 +1152,7 @@ ${convo} opts.fetchOptions.agent = new HttpsProxyAgent(this.options.proxy); } - /** @type {TAzureConfig | undefined} */ - const azureConfig = this.options?.req?.app?.locals?.[EModelEndpoint.azureOpenAI]; + const azureConfig = appConfig?.endpoints?.[EModelEndpoint.azureOpenAI]; if ( (this.azure && this.isVisionModel && azureConfig) || diff --git a/api/app/clients/callbacks/createStartHandler.js b/api/app/clients/callbacks/createStartHandler.js deleted file mode 100644 index b7292aaf1..000000000 --- a/api/app/clients/callbacks/createStartHandler.js +++ /dev/null @@ -1,95 +0,0 @@ -const { promptTokensEstimate } = require('openai-chat-tokens'); -const { EModelEndpoint, supportsBalanceCheck } = require('librechat-data-provider'); -const { formatFromLangChain } = require('~/app/clients/prompts'); -const { getBalanceConfig } = require('~/server/services/Config'); -const { checkBalance } = require('~/models/balanceMethods'); -const { logger } = require('~/config'); - -const createStartHandler = ({ - context, - conversationId, - tokenBuffer = 0, - initialMessageCount, - manager, -}) => { - return async (_llm, _messages, runId, parentRunId, extraParams) => { - const { invocation_params } = extraParams; - const { model, functions, function_call } = invocation_params; - const messages = _messages[0].map(formatFromLangChain); - - logger.debug(`[createStartHandler] handleChatModelStart: ${context}`, { - model, - function_call, - }); - - if (context !== 'title') { - logger.debug(`[createStartHandler] handleChatModelStart: ${context}`, { - functions, - }); - } - - const payload = { messages }; - let prelimPromptTokens = 1; - - if (functions) { - payload.functions = functions; - prelimPromptTokens += 2; - } - - if (function_call) { - payload.function_call = function_call; - prelimPromptTokens -= 5; - } - - prelimPromptTokens += promptTokensEstimate(payload); - logger.debug('[createStartHandler]', { - prelimPromptTokens, - tokenBuffer, - }); - prelimPromptTokens += tokenBuffer; - - try { - const balance = await getBalanceConfig(); - if (balance?.enabled && supportsBalanceCheck[EModelEndpoint.openAI]) { - const generations = - initialMessageCount && messages.length > initialMessageCount - ? messages.slice(initialMessageCount) - : null; - await checkBalance({ - req: manager.req, - res: manager.res, - txData: { - user: manager.user, - tokenType: 'prompt', - amount: prelimPromptTokens, - debug: manager.debug, - generations, - model, - endpoint: EModelEndpoint.openAI, - }, - }); - } - } catch (err) { - logger.error(`[createStartHandler][${context}] checkBalance error`, err); - manager.abortController.abort(); - if (context === 'summary' || context === 'plugins') { - manager.addRun(runId, { conversationId, error: err.message }); - throw new Error(err); - } - return; - } - - manager.addRun(runId, { - model, - messages, - functions, - function_call, - runId, - parentRunId, - conversationId, - prelimPromptTokens, - }); - }; -}; - -module.exports = createStartHandler; diff --git a/api/app/clients/callbacks/index.js b/api/app/clients/callbacks/index.js deleted file mode 100644 index 33f736552..000000000 --- a/api/app/clients/callbacks/index.js +++ /dev/null @@ -1,5 +0,0 @@ -const createStartHandler = require('./createStartHandler'); - -module.exports = { - createStartHandler, -}; diff --git a/api/app/clients/llm/RunManager.js b/api/app/clients/llm/RunManager.js deleted file mode 100644 index 51abe480a..000000000 --- a/api/app/clients/llm/RunManager.js +++ /dev/null @@ -1,105 +0,0 @@ -const { createStartHandler } = require('~/app/clients/callbacks'); -const { spendTokens } = require('~/models/spendTokens'); -const { logger } = require('~/config'); - -class RunManager { - constructor(fields) { - const { req, res, abortController, debug } = fields; - this.abortController = abortController; - this.user = req.user.id; - this.req = req; - this.res = res; - this.debug = debug; - this.runs = new Map(); - this.convos = new Map(); - } - - addRun(runId, runData) { - if (!this.runs.has(runId)) { - this.runs.set(runId, runData); - if (runData.conversationId) { - this.convos.set(runData.conversationId, runId); - } - return runData; - } else { - const existingData = this.runs.get(runId); - const update = { ...existingData, ...runData }; - this.runs.set(runId, update); - if (update.conversationId) { - this.convos.set(update.conversationId, runId); - } - return update; - } - } - - removeRun(runId) { - if (this.runs.has(runId)) { - this.runs.delete(runId); - } else { - logger.error(`[api/app/clients/llm/RunManager] Run with ID ${runId} does not exist.`); - } - } - - getAllRuns() { - return Array.from(this.runs.values()); - } - - getRunById(runId) { - return this.runs.get(runId); - } - - getRunByConversationId(conversationId) { - const runId = this.convos.get(conversationId); - return { run: this.runs.get(runId), runId }; - } - - createCallbacks(metadata) { - return [ - { - handleChatModelStart: createStartHandler({ ...metadata, manager: this }), - handleLLMEnd: async (output, runId, _parentRunId) => { - const { llmOutput, ..._output } = output; - logger.debug(`[RunManager] handleLLMEnd: ${JSON.stringify(metadata)}`, { - runId, - _parentRunId, - llmOutput, - }); - - if (metadata.context !== 'title') { - logger.debug('[RunManager] handleLLMEnd:', { - output: _output, - }); - } - - const { tokenUsage } = output.llmOutput; - const run = this.getRunById(runId); - this.removeRun(runId); - - const txData = { - user: this.user, - model: run?.model ?? 'gpt-3.5-turbo', - ...metadata, - }; - - await spendTokens(txData, tokenUsage); - }, - handleLLMError: async (err) => { - logger.error(`[RunManager] handleLLMError: ${JSON.stringify(metadata)}`, err); - if (metadata.context === 'title') { - return; - } else if (metadata.context === 'plugins') { - throw new Error(err); - } - const { conversationId } = metadata; - const { run } = this.getRunByConversationId(conversationId); - if (run && run.error) { - const { error } = run; - throw new Error(error); - } - }, - }, - ]; - } -} - -module.exports = RunManager; diff --git a/api/app/clients/llm/index.js b/api/app/clients/llm/index.js index 2e09bbb84..d03e1cda4 100644 --- a/api/app/clients/llm/index.js +++ b/api/app/clients/llm/index.js @@ -1,9 +1,7 @@ const createLLM = require('./createLLM'); -const RunManager = require('./RunManager'); const createCoherePayload = require('./createCoherePayload'); module.exports = { createLLM, - RunManager, createCoherePayload, }; diff --git a/api/app/clients/specs/BaseClient.test.js b/api/app/clients/specs/BaseClient.test.js index 4708ed9b2..3cc082ab6 100644 --- a/api/app/clients/specs/BaseClient.test.js +++ b/api/app/clients/specs/BaseClient.test.js @@ -2,6 +2,14 @@ const { Constants } = require('librechat-data-provider'); const { initializeFakeClient } = require('./FakeClient'); jest.mock('~/db/connect'); +jest.mock('~/server/services/Config', () => ({ + getAppConfig: jest.fn().mockResolvedValue({ + // Default app config for tests + paths: { uploads: '/tmp' }, + fileStrategy: 'local', + memory: { disabled: false }, + }), +})); jest.mock('~/models', () => ({ User: jest.fn(), Key: jest.fn(), diff --git a/api/app/clients/tools/index.js b/api/app/clients/tools/index.js index 87b1884e8..90d1545a5 100644 --- a/api/app/clients/tools/index.js +++ b/api/app/clients/tools/index.js @@ -1,4 +1,4 @@ -const availableTools = require('./manifest.json'); +const manifest = require('./manifest'); // Structured Tools const DALLE3 = require('./structured/DALLE3'); @@ -13,23 +13,8 @@ const TraversaalSearch = require('./structured/TraversaalSearch'); const createOpenAIImageTools = require('./structured/OpenAIImageTools'); const TavilySearchResults = require('./structured/TavilySearchResults'); -/** @type {Record} */ -const manifestToolMap = {}; - -/** @type {Array} */ -const toolkits = []; - -availableTools.forEach((tool) => { - manifestToolMap[tool.pluginKey] = tool; - if (tool.toolkit === true) { - toolkits.push(tool); - } -}); - module.exports = { - toolkits, - availableTools, - manifestToolMap, + ...manifest, // Structured Tools DALLE3, FluxAPI, diff --git a/api/app/clients/tools/manifest.js b/api/app/clients/tools/manifest.js new file mode 100644 index 000000000..302d9c3df --- /dev/null +++ b/api/app/clients/tools/manifest.js @@ -0,0 +1,20 @@ +const availableTools = require('./manifest.json'); + +/** @type {Record} */ +const manifestToolMap = {}; + +/** @type {Array} */ +const toolkits = []; + +availableTools.forEach((tool) => { + manifestToolMap[tool.pluginKey] = tool; + if (tool.toolkit === true) { + toolkits.push(tool); + } +}); + +module.exports = { + toolkits, + availableTools, + manifestToolMap, +}; diff --git a/api/app/clients/tools/structured/DALLE3.js b/api/app/clients/tools/structured/DALLE3.js index 5f6e335a1..db6b4e63e 100644 --- a/api/app/clients/tools/structured/DALLE3.js +++ b/api/app/clients/tools/structured/DALLE3.js @@ -5,10 +5,10 @@ const fetch = require('node-fetch'); const { v4: uuidv4 } = require('uuid'); const { ProxyAgent } = require('undici'); const { Tool } = require('@langchain/core/tools'); +const { logger } = require('@librechat/data-schemas'); +const { getImageBasename } = require('@librechat/api'); const { FileContext, ContentTypes } = require('librechat-data-provider'); -const { getImageBasename } = require('~/server/services/Files/images'); const extractBaseURL = require('~/utils/extractBaseURL'); -const logger = require('~/config/winston'); const displayMessage = "DALL-E displayed an image. All generated images are already plainly visible, so don't repeat the descriptions in detail. Do not list download links as they are available in the UI already. The user may download the images by clicking on them, but do not mention anything about downloading to the user."; diff --git a/api/app/clients/tools/structured/OpenAIImageTools.js b/api/app/clients/tools/structured/OpenAIImageTools.js index 920555da3..9a2a047bb 100644 --- a/api/app/clients/tools/structured/OpenAIImageTools.js +++ b/api/app/clients/tools/structured/OpenAIImageTools.js @@ -1,69 +1,16 @@ -const { z } = require('zod'); const axios = require('axios'); const { v4 } = require('uuid'); const OpenAI = require('openai'); const FormData = require('form-data'); const { ProxyAgent } = require('undici'); const { tool } = require('@langchain/core/tools'); -const { logAxiosError } = require('@librechat/api'); const { logger } = require('@librechat/data-schemas'); +const { logAxiosError, oaiToolkit } = require('@librechat/api'); const { ContentTypes, EImageOutputType } = require('librechat-data-provider'); const { getStrategyFunctions } = require('~/server/services/Files/strategies'); -const { extractBaseURL } = require('~/utils'); +const extractBaseURL = require('~/utils/extractBaseURL'); const { getFiles } = require('~/models/File'); -/** Default descriptions for image generation tool */ -const DEFAULT_IMAGE_GEN_DESCRIPTION = ` -Generates high-quality, original images based solely on text, not using any uploaded reference images. - -When to use \`image_gen_oai\`: -- To create entirely new images from detailed text descriptions that do NOT reference any image files. - -When NOT to use \`image_gen_oai\`: -- If the user has uploaded any images and requests modifications, enhancements, or remixing based on those uploads → use \`image_edit_oai\` instead. - -Generated image IDs will be returned in the response, so you can refer to them in future requests made to \`image_edit_oai\`. -`.trim(); - -/** Default description for image editing tool */ -const DEFAULT_IMAGE_EDIT_DESCRIPTION = - `Generates high-quality, original images based on text and one or more uploaded/referenced images. - -When to use \`image_edit_oai\`: -- The user wants to modify, extend, or remix one **or more** uploaded images, either: -- Previously generated, or in the current request (both to be included in the \`image_ids\` array). -- Always when the user refers to uploaded images for editing, enhancement, remixing, style transfer, or combining elements. -- Any current or existing images are to be used as visual guides. -- If there are any files in the current request, they are more likely than not expected as references for image edit requests. - -When NOT to use \`image_edit_oai\`: -- Brand-new generations that do not rely on an existing image → use \`image_gen_oai\` instead. - -Both generated and referenced image IDs will be returned in the response, so you can refer to them in future requests made to \`image_edit_oai\`. -`.trim(); - -/** Default prompt descriptions */ -const DEFAULT_IMAGE_GEN_PROMPT_DESCRIPTION = `Describe the image you want in detail. - Be highly specific—break your idea into layers: - (1) main concept and subject, - (2) composition and position, - (3) lighting and mood, - (4) style, medium, or camera details, - (5) important features (age, expression, clothing, etc.), - (6) background. - Use positive, descriptive language and specify what should be included, not what to avoid. - List number and characteristics of people/objects, and mention style/technical requirements (e.g., "DSLR photo, 85mm lens, golden hour"). - Do not reference any uploaded images—use for new image creation from text only.`; - -const DEFAULT_IMAGE_EDIT_PROMPT_DESCRIPTION = `Describe the changes, enhancements, or new ideas to apply to the uploaded image(s). - Be highly specific—break your request into layers: - (1) main concept or transformation, - (2) specific edits/replacements or composition guidance, - (3) desired style, mood, or technique, - (4) features/items to keep, change, or add (such as objects, people, clothing, lighting, etc.). - Use positive, descriptive language and clarify what should be included or changed, not what to avoid. - Always base this prompt on the most recently uploaded reference images.`; - const displayMessage = "The tool displayed an image. All generated images are already plainly visible, so don't repeat the descriptions in detail. Do not list download links as they are available in the UI already. The user may download the images by clicking on them, but do not mention anything about downloading to the user."; @@ -91,22 +38,6 @@ function returnValue(value) { return value; } -const getImageGenDescription = () => { - return process.env.IMAGE_GEN_OAI_DESCRIPTION || DEFAULT_IMAGE_GEN_DESCRIPTION; -}; - -const getImageEditDescription = () => { - return process.env.IMAGE_EDIT_OAI_DESCRIPTION || DEFAULT_IMAGE_EDIT_DESCRIPTION; -}; - -const getImageGenPromptDescription = () => { - return process.env.IMAGE_GEN_OAI_PROMPT_DESCRIPTION || DEFAULT_IMAGE_GEN_PROMPT_DESCRIPTION; -}; - -const getImageEditPromptDescription = () => { - return process.env.IMAGE_EDIT_OAI_PROMPT_DESCRIPTION || DEFAULT_IMAGE_EDIT_PROMPT_DESCRIPTION; -}; - function createAbortHandler() { return function () { logger.debug('[ImageGenOAI] Image generation aborted'); @@ -121,7 +52,9 @@ function createAbortHandler() { * @param {string} fields.IMAGE_GEN_OAI_API_KEY - The OpenAI API key * @param {boolean} [fields.override] - Whether to override the API key check, necessary for app initialization * @param {MongoFile[]} [fields.imageFiles] - The images to be used for editing - * @returns {Array} - Array of image tools + * @param {string} [fields.imageOutputType] - The image output type configuration + * @param {string} [fields.fileStrategy] - The file storage strategy + * @returns {Array>} - Array of image tools */ function createOpenAIImageTools(fields = {}) { /** @type {boolean} Used to initialize the Tool without necessary variables. */ @@ -131,8 +64,8 @@ function createOpenAIImageTools(fields = {}) { throw new Error('This tool is only available for agents.'); } const { req } = fields; - const imageOutputType = req?.app.locals.imageOutputType || EImageOutputType.PNG; - const appFileStrategy = req?.app.locals.fileStrategy; + const imageOutputType = fields.imageOutputType || EImageOutputType.PNG; + const appFileStrategy = fields.fileStrategy; const getApiKey = () => { const apiKey = process.env.IMAGE_GEN_OAI_API_KEY ?? ''; @@ -285,46 +218,7 @@ Error Message: ${error.message}`); ]; return [response, { content, file_ids }]; }, - { - name: 'image_gen_oai', - description: getImageGenDescription(), - schema: z.object({ - prompt: z.string().max(32000).describe(getImageGenPromptDescription()), - background: z - .enum(['transparent', 'opaque', 'auto']) - .optional() - .describe( - 'Sets transparency for the background. Must be one of transparent, opaque or auto (default). When transparent, the output format should be png or webp.', - ), - /* - n: z - .number() - .int() - .min(1) - .max(10) - .optional() - .describe('The number of images to generate. Must be between 1 and 10.'), - output_compression: z - .number() - .int() - .min(0) - .max(100) - .optional() - .describe('The compression level (0-100%) for webp or jpeg formats. Defaults to 100.'), - */ - quality: z - .enum(['auto', 'high', 'medium', 'low']) - .optional() - .describe('The quality of the image. One of auto (default), high, medium, or low.'), - size: z - .enum(['auto', '1024x1024', '1536x1024', '1024x1536']) - .optional() - .describe( - 'The size of the generated image. One of 1024x1024, 1536x1024 (landscape), 1024x1536 (portrait), or auto (default).', - ), - }), - responseFormat: 'content_and_artifact', - }, + oaiToolkit.image_gen_oai, ); /** @@ -517,48 +411,7 @@ Error Message: ${error.message || 'Unknown error'}`); } } }, - { - name: 'image_edit_oai', - description: getImageEditDescription(), - schema: z.object({ - image_ids: z - .array(z.string()) - .min(1) - .describe( - ` -IDs (image ID strings) of previously generated or uploaded images that should guide the edit. - -Guidelines: -- If the user's request depends on any prior image(s), copy their image IDs into the \`image_ids\` array (in the same order the user refers to them). -- Never invent or hallucinate IDs; only use IDs that are still visible in the conversation context. -- If no earlier image is relevant, omit the field entirely. -`.trim(), - ), - prompt: z.string().max(32000).describe(getImageEditPromptDescription()), - /* - n: z - .number() - .int() - .min(1) - .max(10) - .optional() - .describe('The number of images to generate. Must be between 1 and 10. Defaults to 1.'), - */ - quality: z - .enum(['auto', 'high', 'medium', 'low']) - .optional() - .describe( - 'The quality of the image. One of auto (default), high, medium, or low. High/medium/low only supported for gpt-image-1.', - ), - size: z - .enum(['auto', '1024x1024', '1536x1024', '1024x1536', '256x256', '512x512']) - .optional() - .describe( - 'The size of the generated images. For gpt-image-1: auto (default), 1024x1024, 1536x1024, 1024x1536. For dall-e-2: 256x256, 512x512, 1024x1024.', - ), - }), - responseFormat: 'content_and_artifact', - }, + oaiToolkit.image_edit_oai, ); return [imageGenTool, imageEditTool]; diff --git a/api/app/clients/tools/structured/StableDiffusion.js b/api/app/clients/tools/structured/StableDiffusion.js index 25a9e0abd..b08e42e7c 100644 --- a/api/app/clients/tools/structured/StableDiffusion.js +++ b/api/app/clients/tools/structured/StableDiffusion.js @@ -11,14 +11,14 @@ const paths = require('~/config/paths'); const { logger } = require('~/config'); const displayMessage = - 'Stable Diffusion displayed an image. All generated images are already plainly visible, so don\'t repeat the descriptions in detail. Do not list download links as they are available in the UI already. The user may download the images by clicking on them, but do not mention anything about downloading to the user.'; + "Stable Diffusion displayed an image. All generated images are already plainly visible, so don't repeat the descriptions in detail. Do not list download links as they are available in the UI already. The user may download the images by clicking on them, but do not mention anything about downloading to the user."; class StableDiffusionAPI extends Tool { constructor(fields) { super(); /** @type {string} User ID */ this.userId = fields.userId; - /** @type {Express.Request | undefined} Express Request object, only provided by ToolService */ + /** @type {ServerRequest | undefined} Express Request object, only provided by ToolService */ this.req = fields.req; /** @type {boolean} Used to initialize the Tool without necessary variables. */ this.override = fields.override ?? false; @@ -44,7 +44,7 @@ class StableDiffusionAPI extends Tool { // "negative_prompt":"semi-realistic, cgi, 3d, render, sketch, cartoon, drawing, anime, out of frame, low quality, ugly, mutation, deformed" // - Generate images only once per human query unless explicitly requested by the user`; this.description = - 'You can generate images using text with \'stable-diffusion\'. This tool is exclusively for visual content.'; + "You can generate images using text with 'stable-diffusion'. This tool is exclusively for visual content."; this.schema = z.object({ prompt: z .string() diff --git a/api/app/clients/tools/structured/YouTube.js b/api/app/clients/tools/structured/YouTube.js index aa19fc211..8d1c7b9ff 100644 --- a/api/app/clients/tools/structured/YouTube.js +++ b/api/app/clients/tools/structured/YouTube.js @@ -1,9 +1,9 @@ -const { z } = require('zod'); +const { ytToolkit } = require('@librechat/api'); const { tool } = require('@langchain/core/tools'); const { youtube } = require('@googleapis/youtube'); +const { logger } = require('@librechat/data-schemas'); const { YoutubeTranscript } = require('youtube-transcript'); const { getApiKey } = require('./credentials'); -const { logger } = require('~/config'); function extractVideoId(url) { const rawIdRegex = /^[a-zA-Z0-9_-]{11}$/; @@ -29,7 +29,7 @@ function parseTranscript(transcriptResponse) { .map((entry) => entry.text.trim()) .filter((text) => text) .join(' ') - .replaceAll('&#39;', '\''); + .replaceAll('&#39;', "'"); } function createYouTubeTools(fields = {}) { @@ -42,160 +42,94 @@ function createYouTubeTools(fields = {}) { auth: apiKey, }); - const searchTool = tool( - async ({ query, maxResults = 5 }) => { - const response = await youtubeClient.search.list({ - part: 'snippet', - q: query, - type: 'video', - maxResults: maxResults || 5, - }); - const result = response.data.items.map((item) => ({ - title: item.snippet.title, - description: item.snippet.description, - url: `https://www.youtube.com/watch?v=${item.id.videoId}`, - })); - return JSON.stringify(result, null, 2); - }, - { - name: 'youtube_search', - description: `Search for YouTube videos by keyword or phrase. -- Required: query (search terms to find videos) -- Optional: maxResults (number of videos to return, 1-50, default: 5) -- Returns: List of videos with titles, descriptions, and URLs -- Use for: Finding specific videos, exploring content, research -Example: query="cooking pasta tutorials" maxResults=3`, - schema: z.object({ - query: z.string().describe('Search query terms'), - maxResults: z.number().int().min(1).max(50).optional().describe('Number of results (1-50)'), - }), - }, - ); + const searchTool = tool(async ({ query, maxResults = 5 }) => { + const response = await youtubeClient.search.list({ + part: 'snippet', + q: query, + type: 'video', + maxResults: maxResults || 5, + }); + const result = response.data.items.map((item) => ({ + title: item.snippet.title, + description: item.snippet.description, + url: `https://www.youtube.com/watch?v=${item.id.videoId}`, + })); + return JSON.stringify(result, null, 2); + }, ytToolkit.youtube_search); - const infoTool = tool( - async ({ url }) => { - const videoId = extractVideoId(url); - if (!videoId) { - throw new Error('Invalid YouTube URL or video ID'); - } + const infoTool = tool(async ({ url }) => { + const videoId = extractVideoId(url); + if (!videoId) { + throw new Error('Invalid YouTube URL or video ID'); + } - const response = await youtubeClient.videos.list({ - part: 'snippet,statistics', - id: videoId, - }); + const response = await youtubeClient.videos.list({ + part: 'snippet,statistics', + id: videoId, + }); - if (!response.data.items?.length) { - throw new Error('Video not found'); - } - const video = response.data.items[0]; + if (!response.data.items?.length) { + throw new Error('Video not found'); + } + const video = response.data.items[0]; - const result = { - title: video.snippet.title, - description: video.snippet.description, - views: video.statistics.viewCount, - likes: video.statistics.likeCount, - comments: video.statistics.commentCount, - }; - return JSON.stringify(result, null, 2); - }, - { - name: 'youtube_info', - description: `Get detailed metadata and statistics for a specific YouTube video. -- Required: url (full YouTube URL or video ID) -- Returns: Video title, description, view count, like count, comment count -- Use for: Getting video metrics and basic metadata -- DO NOT USE FOR VIDEO SUMMARIES, USE TRANSCRIPTS FOR COMPREHENSIVE ANALYSIS -- Accepts both full URLs and video IDs -Example: url="https://youtube.com/watch?v=abc123" or url="abc123"`, - schema: z.object({ - url: z.string().describe('YouTube video URL or ID'), - }), - }, - ); + const result = { + title: video.snippet.title, + description: video.snippet.description, + views: video.statistics.viewCount, + likes: video.statistics.likeCount, + comments: video.statistics.commentCount, + }; + return JSON.stringify(result, null, 2); + }, ytToolkit.youtube_info); - const commentsTool = tool( - async ({ url, maxResults = 10 }) => { - const videoId = extractVideoId(url); - if (!videoId) { - throw new Error('Invalid YouTube URL or video ID'); - } + const commentsTool = tool(async ({ url, maxResults = 10 }) => { + const videoId = extractVideoId(url); + if (!videoId) { + throw new Error('Invalid YouTube URL or video ID'); + } - const response = await youtubeClient.commentThreads.list({ - part: 'snippet', - videoId, - maxResults: maxResults || 10, - }); + const response = await youtubeClient.commentThreads.list({ + part: 'snippet', + videoId, + maxResults: maxResults || 10, + }); - const result = response.data.items.map((item) => ({ - author: item.snippet.topLevelComment.snippet.authorDisplayName, - text: item.snippet.topLevelComment.snippet.textDisplay, - likes: item.snippet.topLevelComment.snippet.likeCount, - })); - return JSON.stringify(result, null, 2); - }, - { - name: 'youtube_comments', - description: `Retrieve top-level comments from a YouTube video. -- Required: url (full YouTube URL or video ID) -- Optional: maxResults (number of comments, 1-50, default: 10) -- Returns: Comment text, author names, like counts -- Use for: Sentiment analysis, audience feedback, engagement review -Example: url="abc123" maxResults=20`, - schema: z.object({ - url: z.string().describe('YouTube video URL or ID'), - maxResults: z - .number() - .int() - .min(1) - .max(50) - .optional() - .describe('Number of comments to retrieve'), - }), - }, - ); + const result = response.data.items.map((item) => ({ + author: item.snippet.topLevelComment.snippet.authorDisplayName, + text: item.snippet.topLevelComment.snippet.textDisplay, + likes: item.snippet.topLevelComment.snippet.likeCount, + })); + return JSON.stringify(result, null, 2); + }, ytToolkit.youtube_comments); - const transcriptTool = tool( - async ({ url }) => { - const videoId = extractVideoId(url); - if (!videoId) { - throw new Error('Invalid YouTube URL or video ID'); + const transcriptTool = tool(async ({ url }) => { + const videoId = extractVideoId(url); + if (!videoId) { + throw new Error('Invalid YouTube URL or video ID'); + } + + try { + try { + const transcript = await YoutubeTranscript.fetchTranscript(videoId, { lang: 'en' }); + return parseTranscript(transcript); + } catch (e) { + logger.error(e); } try { - try { - const transcript = await YoutubeTranscript.fetchTranscript(videoId, { lang: 'en' }); - return parseTranscript(transcript); - } catch (e) { - logger.error(e); - } - - try { - const transcript = await YoutubeTranscript.fetchTranscript(videoId, { lang: 'de' }); - return parseTranscript(transcript); - } catch (e) { - logger.error(e); - } - - const transcript = await YoutubeTranscript.fetchTranscript(videoId); + const transcript = await YoutubeTranscript.fetchTranscript(videoId, { lang: 'de' }); return parseTranscript(transcript); - } catch (error) { - throw new Error(`Failed to fetch transcript: ${error.message}`); + } catch (e) { + logger.error(e); } - }, - { - name: 'youtube_transcript', - description: `Fetch and parse the transcript/captions of a YouTube video. -- Required: url (full YouTube URL or video ID) -- Returns: Full video transcript as plain text -- Use for: Content analysis, summarization, translation reference -- This is the "Go-to" tool for analyzing actual video content -- Attempts to fetch English first, then German, then any available language -Example: url="https://youtube.com/watch?v=abc123"`, - schema: z.object({ - url: z.string().describe('YouTube video URL or ID'), - }), - }, - ); + + const transcript = await YoutubeTranscript.fetchTranscript(videoId); + return parseTranscript(transcript); + } catch (error) { + throw new Error(`Failed to fetch transcript: ${error.message}`); + } + }, ytToolkit.youtube_transcript); return [searchTool, infoTool, commentsTool, transcriptTool]; } diff --git a/api/app/clients/tools/structured/specs/DALLE3-proxy.spec.js b/api/app/clients/tools/structured/specs/DALLE3-proxy.spec.js index 768d81e88..4481a7d70 100644 --- a/api/app/clients/tools/structured/specs/DALLE3-proxy.spec.js +++ b/api/app/clients/tools/structured/specs/DALLE3-proxy.spec.js @@ -1,43 +1,9 @@ const DALLE3 = require('../DALLE3'); const { ProxyAgent } = require('undici'); +jest.mock('tiktoken'); const processFileURL = jest.fn(); -jest.mock('~/server/services/Files/images', () => ({ - getImageBasename: jest.fn().mockImplementation((url) => { - const parts = url.split('/'); - const lastPart = parts.pop(); - const imageExtensionRegex = /\.(jpg|jpeg|png|gif|bmp|tiff|svg)$/i; - if (imageExtensionRegex.test(lastPart)) { - return lastPart; - } - return ''; - }), -})); - -jest.mock('fs', () => { - return { - existsSync: jest.fn(), - mkdirSync: jest.fn(), - promises: { - writeFile: jest.fn(), - readFile: jest.fn(), - unlink: jest.fn(), - }, - }; -}); - -jest.mock('path', () => { - return { - resolve: jest.fn(), - join: jest.fn(), - relative: jest.fn(), - extname: jest.fn().mockImplementation((filename) => { - return filename.slice(filename.lastIndexOf('.')); - }), - }; -}); - describe('DALLE3 Proxy Configuration', () => { let originalEnv; diff --git a/api/app/clients/tools/structured/specs/DALLE3.spec.js b/api/app/clients/tools/structured/specs/DALLE3.spec.js index 2def575fb..d2040989f 100644 --- a/api/app/clients/tools/structured/specs/DALLE3.spec.js +++ b/api/app/clients/tools/structured/specs/DALLE3.spec.js @@ -1,9 +1,8 @@ const OpenAI = require('openai'); +const { logger } = require('@librechat/data-schemas'); const DALLE3 = require('../DALLE3'); -const logger = require('~/config/winston'); jest.mock('openai'); - jest.mock('@librechat/data-schemas', () => { return { logger: { @@ -26,25 +25,6 @@ jest.mock('tiktoken', () => { const processFileURL = 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: { diff --git a/api/app/clients/tools/util/handleTools.js b/api/app/clients/tools/util/handleTools.js index ea127e4ab..522972fe6 100644 --- a/api/app/clients/tools/util/handleTools.js +++ b/api/app/clients/tools/util/handleTools.js @@ -121,18 +121,21 @@ const getAuthFields = (toolKey) => { /** * - * @param {object} object - * @param {string} object.user + * @param {object} params + * @param {string} params.user * @param {Record>} [object.userMCPAuthMap] * @param {AbortSignal} [object.signal] - * @param {Pick} [object.agent] - * @param {string} [object.model] - * @param {EModelEndpoint} [object.endpoint] - * @param {LoadToolOptions} [object.options] - * @param {boolean} [object.useSpecs] - * @param {Array} object.tools - * @param {boolean} [object.functions] - * @param {boolean} [object.returnMap] + * @param {Pick} [params.agent] + * @param {string} [params.model] + * @param {EModelEndpoint} [params.endpoint] + * @param {LoadToolOptions} [params.options] + * @param {boolean} [params.useSpecs] + * @param {Array} params.tools + * @param {boolean} [params.functions] + * @param {boolean} [params.returnMap] + * @param {AppConfig['webSearch']} [params.webSearch] + * @param {AppConfig['fileStrategy']} [params.fileStrategy] + * @param {AppConfig['imageOutputType']} [params.imageOutputType] * @returns {Promise<{ loadedTools: Tool[], toolContextMap: Object } | Record>} */ const loadTools = async ({ @@ -146,6 +149,9 @@ const loadTools = async ({ options = {}, functions = true, returnMap = false, + webSearch, + fileStrategy, + imageOutputType, }) => { const toolConstructors = { flux: FluxAPI, @@ -204,6 +210,8 @@ const loadTools = async ({ ...authValues, isAgent: !!agent, req: options.req, + imageOutputType, + fileStrategy, imageFiles, }); }, @@ -219,7 +227,7 @@ const loadTools = async ({ const imageGenOptions = { isAgent: !!agent, req: options.req, - fileStrategy: options.fileStrategy, + fileStrategy, processFileURL: options.processFileURL, returnMetadata: options.returnMetadata, uploadImageBuffer: options.uploadImageBuffer, @@ -277,11 +285,10 @@ const loadTools = async ({ }; continue; } else if (tool === Tools.web_search) { - const webSearchConfig = options?.req?.app?.locals?.webSearch; const result = await loadWebSearchAuth({ userId: user, loadAuthValues, - webSearchConfig, + webSearchConfig: webSearch, }); const { onSearchResults, onGetHighlights } = options?.[Tools.web_search] ?? {}; requestedTools[tool] = async () => { diff --git a/api/app/clients/tools/util/handleTools.test.js b/api/app/clients/tools/util/handleTools.test.js index 1cacda815..b01aa2f7e 100644 --- a/api/app/clients/tools/util/handleTools.test.js +++ b/api/app/clients/tools/util/handleTools.test.js @@ -9,6 +9,27 @@ const mockPluginService = { jest.mock('~/server/services/PluginService', () => mockPluginService); +jest.mock('~/server/services/Config', () => ({ + getAppConfig: jest.fn().mockResolvedValue({ + // Default app config for tool tests + paths: { uploads: '/tmp' }, + fileStrategy: 'local', + filteredTools: [], + includedTools: [], + }), + getCachedTools: jest.fn().mockResolvedValue({ + // Default cached tools for tests + dalle: { + type: 'function', + function: { + name: 'dalle', + description: 'DALL-E image generation', + parameters: {}, + }, + }, + }), +})); + const { BaseLLM } = require('@langchain/openai'); const { Calculator } = require('@langchain/community/tools/calculator'); diff --git a/api/models/Agent.js b/api/models/Agent.js index 13fc1e472..7bcffe669 100644 --- a/api/models/Agent.js +++ b/api/models/Agent.js @@ -681,7 +681,7 @@ const getListAgents = async (searchParameter) => { * This function also updates the corresponding projects to include or exclude the agent ID. * * @param {Object} params - Parameters for updating the agent's projects. - * @param {MongoUser} params.user - Parameters for updating the agent's projects. + * @param {IUser} params.user - Parameters for updating the agent's projects. * @param {string} params.agentId - The ID of the agent to update. * @param {string[]} [params.projectIds] - Array of project IDs to add to the agent. * @param {string[]} [params.removeProjectIds] - Array of project IDs to remove from the agent. diff --git a/api/models/Conversation.js b/api/models/Conversation.js index 8a529dd10..9f7aa9001 100644 --- a/api/models/Conversation.js +++ b/api/models/Conversation.js @@ -1,6 +1,5 @@ const { logger } = require('@librechat/data-schemas'); const { createTempChatExpirationDate } = require('@librechat/api'); -const { getCustomConfig } = require('~/server/services/Config/getCustomConfig'); const { getMessages, deleteMessages } = require('./Message'); const { Conversation } = require('~/db/models'); @@ -102,8 +101,8 @@ module.exports = { if (req?.body?.isTemporary) { try { - const customConfig = await getCustomConfig(); - update.expiredAt = createTempChatExpirationDate(customConfig); + const appConfig = req.config; + update.expiredAt = createTempChatExpirationDate(appConfig?.interfaceConfig); } catch (err) { logger.error('Error creating temporary chat expiration date:', err); logger.info(`---\`saveConvo\` context: ${metadata?.context}`); diff --git a/api/models/Conversation.spec.js b/api/models/Conversation.spec.js index 1acdb7750..c5030aed3 100644 --- a/api/models/Conversation.spec.js +++ b/api/models/Conversation.spec.js @@ -13,9 +13,8 @@ const { saveConvo, getConvo, } = require('./Conversation'); -jest.mock('~/server/services/Config/getCustomConfig'); +jest.mock('~/server/services/Config/app'); jest.mock('./Message'); -const { getCustomConfig } = require('~/server/services/Config/getCustomConfig'); const { getMessages, deleteMessages } = require('./Message'); const { Conversation } = require('~/db/models'); @@ -50,6 +49,11 @@ describe('Conversation Operations', () => { mockReq = { user: { id: 'user123' }, body: {}, + config: { + interfaceConfig: { + temporaryChatRetention: 24, // Default 24 hours + }, + }, }; mockConversationData = { @@ -118,12 +122,8 @@ describe('Conversation Operations', () => { describe('isTemporary conversation handling', () => { it('should save a conversation with expiredAt when isTemporary is true', async () => { - // Mock custom config with 24 hour retention - getCustomConfig.mockResolvedValue({ - interface: { - temporaryChatRetention: 24, - }, - }); + // Mock app config with 24 hour retention + mockReq.config.interfaceConfig.temporaryChatRetention = 24; mockReq.body = { isTemporary: true }; @@ -167,12 +167,8 @@ describe('Conversation Operations', () => { }); it('should use custom retention period from config', async () => { - // Mock custom config with 48 hour retention - getCustomConfig.mockResolvedValue({ - interface: { - temporaryChatRetention: 48, - }, - }); + // Mock app config with 48 hour retention + mockReq.config.interfaceConfig.temporaryChatRetention = 48; mockReq.body = { isTemporary: true }; @@ -194,12 +190,8 @@ describe('Conversation Operations', () => { }); it('should handle minimum retention period (1 hour)', async () => { - // Mock custom config with less than minimum retention - getCustomConfig.mockResolvedValue({ - interface: { - temporaryChatRetention: 0.5, // Half hour - should be clamped to 1 hour - }, - }); + // Mock app config with less than minimum retention + mockReq.config.interfaceConfig.temporaryChatRetention = 0.5; // Half hour - should be clamped to 1 hour mockReq.body = { isTemporary: true }; @@ -221,12 +213,8 @@ describe('Conversation Operations', () => { }); it('should handle maximum retention period (8760 hours)', async () => { - // Mock custom config with more than maximum retention - getCustomConfig.mockResolvedValue({ - interface: { - temporaryChatRetention: 10000, // Should be clamped to 8760 hours - }, - }); + // Mock app config with more than maximum retention + mockReq.config.interfaceConfig.temporaryChatRetention = 10000; // Should be clamped to 8760 hours mockReq.body = { isTemporary: true }; @@ -247,22 +235,36 @@ describe('Conversation Operations', () => { ); }); - it('should handle getCustomConfig errors gracefully', async () => { - // Mock getCustomConfig to throw an error - getCustomConfig.mockRejectedValue(new Error('Config service unavailable')); + it('should handle missing config gracefully', async () => { + // Simulate missing config - should use default retention period + delete mockReq.config; mockReq.body = { isTemporary: true }; + const beforeSave = new Date(); const result = await saveConvo(mockReq, mockConversationData); + const afterSave = new Date(); - // Should still save the conversation but with expiredAt as null + // Should still save the conversation with default retention period (30 days) expect(result.conversationId).toBe(mockConversationData.conversationId); - expect(result.expiredAt).toBeNull(); + expect(result.expiredAt).toBeDefined(); + expect(result.expiredAt).toBeInstanceOf(Date); + + // Verify expiredAt is approximately 30 days in the future (720 hours) + const expectedExpirationTime = new Date(beforeSave.getTime() + 720 * 60 * 60 * 1000); + const actualExpirationTime = new Date(result.expiredAt); + + expect(actualExpirationTime.getTime()).toBeGreaterThanOrEqual( + expectedExpirationTime.getTime() - 1000, + ); + expect(actualExpirationTime.getTime()).toBeLessThanOrEqual( + new Date(afterSave.getTime() + 720 * 60 * 60 * 1000 + 1000).getTime(), + ); }); it('should use default retention when config is not provided', async () => { - // Mock getCustomConfig to return empty config - getCustomConfig.mockResolvedValue({}); + // Mock getAppConfig to return empty config + mockReq.config = {}; // Empty config mockReq.body = { isTemporary: true }; @@ -285,11 +287,7 @@ describe('Conversation Operations', () => { it('should update expiredAt when saving existing temporary conversation', async () => { // First save a temporary conversation - getCustomConfig.mockResolvedValue({ - interface: { - temporaryChatRetention: 24, - }, - }); + mockReq.config.interfaceConfig.temporaryChatRetention = 24; mockReq.body = { isTemporary: true }; const firstSave = await saveConvo(mockReq, mockConversationData); diff --git a/api/models/Message.js b/api/models/Message.js index 5a3f84a8e..02b74ec71 100644 --- a/api/models/Message.js +++ b/api/models/Message.js @@ -1,7 +1,6 @@ const { z } = require('zod'); const { logger } = require('@librechat/data-schemas'); const { createTempChatExpirationDate } = require('@librechat/api'); -const { getCustomConfig } = require('~/server/services/Config/getCustomConfig'); const { Message } = require('~/db/models'); const idSchema = z.string().uuid(); @@ -11,7 +10,7 @@ const idSchema = z.string().uuid(); * * @async * @function saveMessage - * @param {Express.Request} req - The request object containing user information. + * @param {ServerRequest} req - The request object containing user information. * @param {Object} params - The message data object. * @param {string} params.endpoint - The endpoint where the message originated. * @param {string} params.iconURL - The URL of the sender's icon. @@ -57,8 +56,8 @@ async function saveMessage(req, params, metadata) { if (req?.body?.isTemporary) { try { - const customConfig = await getCustomConfig(); - update.expiredAt = createTempChatExpirationDate(customConfig); + const appConfig = req.config; + update.expiredAt = createTempChatExpirationDate(appConfig?.interfaceConfig); } catch (err) { logger.error('Error creating temporary chat expiration date:', err); logger.info(`---\`saveMessage\` context: ${metadata?.context}`); diff --git a/api/models/Message.spec.js b/api/models/Message.spec.js index 8e954a12b..2dab6b286 100644 --- a/api/models/Message.spec.js +++ b/api/models/Message.spec.js @@ -13,8 +13,7 @@ const { deleteMessagesSince, } = require('./Message'); -jest.mock('~/server/services/Config/getCustomConfig'); -const { getCustomConfig } = require('~/server/services/Config/getCustomConfig'); +jest.mock('~/server/services/Config/app'); /** * @type {import('mongoose').Model} @@ -44,6 +43,11 @@ describe('Message Operations', () => { mockReq = { user: { id: 'user123' }, + config: { + interfaceConfig: { + temporaryChatRetention: 24, // Default 24 hours + }, + }, }; mockMessageData = { @@ -326,12 +330,8 @@ describe('Message Operations', () => { }); it('should save a message with expiredAt when isTemporary is true', async () => { - // Mock custom config with 24 hour retention - getCustomConfig.mockResolvedValue({ - interface: { - temporaryChatRetention: 24, - }, - }); + // Mock app config with 24 hour retention + mockReq.config.interfaceConfig.temporaryChatRetention = 24; mockReq.body = { isTemporary: true }; @@ -375,12 +375,8 @@ describe('Message Operations', () => { }); it('should use custom retention period from config', async () => { - // Mock custom config with 48 hour retention - getCustomConfig.mockResolvedValue({ - interface: { - temporaryChatRetention: 48, - }, - }); + // Mock app config with 48 hour retention + mockReq.config.interfaceConfig.temporaryChatRetention = 48; mockReq.body = { isTemporary: true }; @@ -402,12 +398,8 @@ describe('Message Operations', () => { }); it('should handle minimum retention period (1 hour)', async () => { - // Mock custom config with less than minimum retention - getCustomConfig.mockResolvedValue({ - interface: { - temporaryChatRetention: 0.5, // Half hour - should be clamped to 1 hour - }, - }); + // Mock app config with less than minimum retention + mockReq.config.interfaceConfig.temporaryChatRetention = 0.5; // Half hour - should be clamped to 1 hour mockReq.body = { isTemporary: true }; @@ -429,12 +421,8 @@ describe('Message Operations', () => { }); it('should handle maximum retention period (8760 hours)', async () => { - // Mock custom config with more than maximum retention - getCustomConfig.mockResolvedValue({ - interface: { - temporaryChatRetention: 10000, // Should be clamped to 8760 hours - }, - }); + // Mock app config with more than maximum retention + mockReq.config.interfaceConfig.temporaryChatRetention = 10000; // Should be clamped to 8760 hours mockReq.body = { isTemporary: true }; @@ -455,22 +443,36 @@ describe('Message Operations', () => { ); }); - it('should handle getCustomConfig errors gracefully', async () => { - // Mock getCustomConfig to throw an error - getCustomConfig.mockRejectedValue(new Error('Config service unavailable')); + it('should handle missing config gracefully', async () => { + // Simulate missing config - should use default retention period + delete mockReq.config; mockReq.body = { isTemporary: true }; + const beforeSave = new Date(); const result = await saveMessage(mockReq, mockMessageData); + const afterSave = new Date(); - // Should still save the message but with expiredAt as null + // Should still save the message with default retention period (30 days) expect(result.messageId).toBe('msg123'); - expect(result.expiredAt).toBeNull(); + expect(result.expiredAt).toBeDefined(); + expect(result.expiredAt).toBeInstanceOf(Date); + + // Verify expiredAt is approximately 30 days in the future (720 hours) + const expectedExpirationTime = new Date(beforeSave.getTime() + 720 * 60 * 60 * 1000); + const actualExpirationTime = new Date(result.expiredAt); + + expect(actualExpirationTime.getTime()).toBeGreaterThanOrEqual( + expectedExpirationTime.getTime() - 1000, + ); + expect(actualExpirationTime.getTime()).toBeLessThanOrEqual( + new Date(afterSave.getTime() + 720 * 60 * 60 * 1000 + 1000).getTime(), + ); }); it('should use default retention when config is not provided', async () => { - // Mock getCustomConfig to return empty config - getCustomConfig.mockResolvedValue({}); + // Mock getAppConfig to return empty config + mockReq.config = {}; // Empty config mockReq.body = { isTemporary: true }; @@ -493,11 +495,7 @@ describe('Message Operations', () => { it('should not update expiredAt on message update', async () => { // First save a temporary message - getCustomConfig.mockResolvedValue({ - interface: { - temporaryChatRetention: 24, - }, - }); + mockReq.config.interfaceConfig.temporaryChatRetention = 24; mockReq.body = { isTemporary: true }; const savedMessage = await saveMessage(mockReq, mockMessageData); @@ -520,11 +518,7 @@ describe('Message Operations', () => { it('should preserve expiredAt when saving existing temporary message', async () => { // First save a temporary message - getCustomConfig.mockResolvedValue({ - interface: { - temporaryChatRetention: 24, - }, - }); + mockReq.config.interfaceConfig.temporaryChatRetention = 24; mockReq.body = { isTemporary: true }; const firstSave = await saveMessage(mockReq, mockMessageData); diff --git a/api/models/Transaction.js b/api/models/Transaction.js index 0e0e32785..e1cff15c3 100644 --- a/api/models/Transaction.js +++ b/api/models/Transaction.js @@ -1,5 +1,4 @@ const { logger } = require('@librechat/data-schemas'); -const { getBalanceConfig } = require('~/server/services/Config'); const { getMultiplier, getCacheMultiplier } = require('./tx'); const { Transaction, Balance } = require('~/db/models'); @@ -187,9 +186,10 @@ async function createAutoRefillTransaction(txData) { /** * Static method to create a transaction and update the balance - * @param {txData} txData - Transaction data. + * @param {txData} _txData - Transaction data. */ -async function createTransaction(txData) { +async function createTransaction(_txData) { + const { balance, ...txData } = _txData; if (txData.rawAmount != null && isNaN(txData.rawAmount)) { return; } @@ -199,8 +199,6 @@ async function createTransaction(txData) { calculateTokenValue(transaction); await transaction.save(); - - const balance = await getBalanceConfig(); if (!balance?.enabled) { return; } @@ -221,9 +219,10 @@ async function createTransaction(txData) { /** * Static method to create a structured transaction and update the balance - * @param {txData} txData - Transaction data. + * @param {txData} _txData - Transaction data. */ -async function createStructuredTransaction(txData) { +async function createStructuredTransaction(_txData) { + const { balance, ...txData } = _txData; const transaction = new Transaction({ ...txData, endpointTokenConfig: txData.endpointTokenConfig, @@ -233,7 +232,6 @@ async function createStructuredTransaction(txData) { await transaction.save(); - const balance = await getBalanceConfig(); if (!balance?.enabled) { return; } diff --git a/api/models/Transaction.spec.js b/api/models/Transaction.spec.js index 3a1303ede..891d9ca7d 100644 --- a/api/models/Transaction.spec.js +++ b/api/models/Transaction.spec.js @@ -1,14 +1,11 @@ const mongoose = require('mongoose'); const { MongoMemoryServer } = require('mongodb-memory-server'); const { spendTokens, spendStructuredTokens } = require('./spendTokens'); -const { getBalanceConfig } = require('~/server/services/Config'); + const { getMultiplier, getCacheMultiplier } = require('./tx'); const { createTransaction } = require('./Transaction'); const { Balance } = require('~/db/models'); -// Mock the custom config module so we can control the balance flag. -jest.mock('~/server/services/Config'); - let mongoServer; beforeAll(async () => { mongoServer = await MongoMemoryServer.create(); @@ -23,8 +20,6 @@ afterAll(async () => { beforeEach(async () => { await mongoose.connection.dropDatabase(); - // Default: enable balance updates in tests. - getBalanceConfig.mockResolvedValue({ enabled: true }); }); describe('Regular Token Spending Tests', () => { @@ -41,6 +36,7 @@ describe('Regular Token Spending Tests', () => { model, context: 'test', endpointTokenConfig: null, + balance: { enabled: true }, }; const tokenUsage = { @@ -74,6 +70,7 @@ describe('Regular Token Spending Tests', () => { model, context: 'test', endpointTokenConfig: null, + balance: { enabled: true }, }; const tokenUsage = { @@ -104,6 +101,7 @@ describe('Regular Token Spending Tests', () => { model, context: 'test', endpointTokenConfig: null, + balance: { enabled: true }, }; const tokenUsage = {}; @@ -128,6 +126,7 @@ describe('Regular Token Spending Tests', () => { model, context: 'test', endpointTokenConfig: null, + balance: { enabled: true }, }; const tokenUsage = { promptTokens: 100 }; @@ -143,8 +142,7 @@ describe('Regular Token Spending Tests', () => { }); test('spendTokens should not update balance when balance feature is disabled', async () => { - // Arrange: Override the config to disable balance updates. - getBalanceConfig.mockResolvedValue({ balance: { enabled: false } }); + // Arrange: Balance config is now passed directly in txData const userId = new mongoose.Types.ObjectId(); const initialBalance = 10000000; await Balance.create({ user: userId, tokenCredits: initialBalance }); @@ -156,6 +154,7 @@ describe('Regular Token Spending Tests', () => { model, context: 'test', endpointTokenConfig: null, + balance: { enabled: false }, }; const tokenUsage = { @@ -186,6 +185,7 @@ describe('Structured Token Spending Tests', () => { model, context: 'message', endpointTokenConfig: null, + balance: { enabled: true }, }; const tokenUsage = { @@ -239,6 +239,7 @@ describe('Structured Token Spending Tests', () => { conversationId: 'test-convo', model, context: 'message', + balance: { enabled: true }, }; const tokenUsage = { @@ -271,6 +272,7 @@ describe('Structured Token Spending Tests', () => { conversationId: 'test-convo', model, context: 'message', + balance: { enabled: true }, }; const tokenUsage = { @@ -302,6 +304,7 @@ describe('Structured Token Spending Tests', () => { conversationId: 'test-convo', model, context: 'message', + balance: { enabled: true }, }; const tokenUsage = {}; @@ -328,6 +331,7 @@ describe('Structured Token Spending Tests', () => { conversationId: 'test-convo', model, context: 'incomplete', + balance: { enabled: true }, }; const tokenUsage = { @@ -364,6 +368,7 @@ describe('NaN Handling Tests', () => { endpointTokenConfig: null, rawAmount: NaN, tokenType: 'prompt', + balance: { enabled: true }, }; // Act diff --git a/api/models/balanceMethods.js b/api/models/balanceMethods.js index 7e6321ab2..e614872ea 100644 --- a/api/models/balanceMethods.js +++ b/api/models/balanceMethods.js @@ -118,7 +118,7 @@ const addIntervalToDate = (date, value, unit) => { * @async * @function * @param {Object} params - The function parameters. - * @param {Express.Request} params.req - The Express request object. + * @param {ServerRequest} params.req - The Express request object. * @param {Express.Response} params.res - The Express response object. * @param {Object} params.txData - The transaction data. * @param {string} params.txData.user - The user ID or identifier. diff --git a/api/models/index.js b/api/models/index.js index 048e270e1..7f2c65194 100644 --- a/api/models/index.js +++ b/api/models/index.js @@ -24,8 +24,15 @@ const { getConvoTitle, getConvo, saveConvo, deleteConvos } = require('./Conversa const { getPreset, getPresets, savePreset, deletePresets } = require('./Preset'); const { File } = require('~/db/models'); +const seedDatabase = async () => { + await methods.initializeRoles(); + await methods.seedDefaultRoles(); + await methods.ensureDefaultCategories(); +}; + module.exports = { ...methods, + seedDatabase, comparePassword, findFileById, createFile, diff --git a/api/models/interface.js b/api/models/interface.js new file mode 100644 index 000000000..a79a8e747 --- /dev/null +++ b/api/models/interface.js @@ -0,0 +1,24 @@ +const { logger } = require('@librechat/data-schemas'); +const { updateInterfacePermissions: updateInterfacePerms } = require('@librechat/api'); +const { getRoleByName, updateAccessPermissions } = require('./Role'); + +/** + * Update interface permissions based on app configuration. + * Must be done independently from loading the app config. + * @param {AppConfig} appConfig + */ +async function updateInterfacePermissions(appConfig) { + try { + await updateInterfacePerms({ + appConfig, + getRoleByName, + updateAccessPermissions, + }); + } catch (error) { + logger.error('Error updating interface permissions:', error); + } +} + +module.exports = { + updateInterfacePermissions, +}; diff --git a/api/models/spendTokens.js b/api/models/spendTokens.js index 834f74092..65fadd789 100644 --- a/api/models/spendTokens.js +++ b/api/models/spendTokens.js @@ -5,13 +5,7 @@ const { createTransaction, createStructuredTransaction } = require('./Transactio * * @function * @async - * @param {Object} txData - Transaction data. - * @param {mongoose.Schema.Types.ObjectId} txData.user - The user ID. - * @param {String} txData.conversationId - The ID of the conversation. - * @param {String} txData.model - The model name. - * @param {String} txData.context - The context in which the transaction is made. - * @param {EndpointTokenConfig} [txData.endpointTokenConfig] - The current endpoint token config. - * @param {String} [txData.valueKey] - The value key (optional). + * @param {txData} txData - Transaction data. * @param {Object} tokenUsage - The number of tokens used. * @param {Number} tokenUsage.promptTokens - The number of prompt tokens used. * @param {Number} tokenUsage.completionTokens - The number of completion tokens used. @@ -69,13 +63,7 @@ const spendTokens = async (txData, tokenUsage) => { * * @function * @async - * @param {Object} txData - Transaction data. - * @param {mongoose.Schema.Types.ObjectId} txData.user - The user ID. - * @param {String} txData.conversationId - The ID of the conversation. - * @param {String} txData.model - The model name. - * @param {String} txData.context - The context in which the transaction is made. - * @param {EndpointTokenConfig} [txData.endpointTokenConfig] - The current endpoint token config. - * @param {String} [txData.valueKey] - The value key (optional). + * @param {txData} txData - Transaction data. * @param {Object} tokenUsage - The number of tokens used. * @param {Object} tokenUsage.promptTokens - The number of prompt tokens used. * @param {Number} tokenUsage.promptTokens.input - The number of input tokens. diff --git a/api/models/spendTokens.spec.js b/api/models/spendTokens.spec.js index 7ee067e58..eee657273 100644 --- a/api/models/spendTokens.spec.js +++ b/api/models/spendTokens.spec.js @@ -5,7 +5,6 @@ const { createTransaction, createAutoRefillTransaction } = require('./Transactio require('~/db/models'); -// Mock the logger to prevent console output during tests jest.mock('~/config', () => ({ logger: { debug: jest.fn(), @@ -13,10 +12,6 @@ jest.mock('~/config', () => ({ }, })); -// Mock the Config service -const { getBalanceConfig } = require('~/server/services/Config'); -jest.mock('~/server/services/Config'); - describe('spendTokens', () => { let mongoServer; let userId; @@ -44,8 +39,7 @@ describe('spendTokens', () => { // Create a new user ID for each test userId = new mongoose.Types.ObjectId(); - // Mock the balance config to be enabled by default - getBalanceConfig.mockResolvedValue({ enabled: true }); + // Balance config is now passed directly in txData }); it('should create transactions for both prompt and completion tokens', async () => { @@ -60,6 +54,7 @@ describe('spendTokens', () => { conversationId: 'test-convo', model: 'gpt-3.5-turbo', context: 'test', + balance: { enabled: true }, }; const tokenUsage = { promptTokens: 100, @@ -98,6 +93,7 @@ describe('spendTokens', () => { conversationId: 'test-convo', model: 'gpt-3.5-turbo', context: 'test', + balance: { enabled: true }, }; const tokenUsage = { promptTokens: 100, @@ -127,6 +123,7 @@ describe('spendTokens', () => { conversationId: 'test-convo', model: 'gpt-3.5-turbo', context: 'test', + balance: { enabled: true }, }; const tokenUsage = {}; @@ -138,8 +135,7 @@ describe('spendTokens', () => { }); it('should not update balance when the balance feature is disabled', async () => { - // Override configuration: disable balance updates - getBalanceConfig.mockResolvedValue({ enabled: false }); + // Balance is now passed directly in txData // Create a balance for the user await Balance.create({ user: userId, @@ -151,6 +147,7 @@ describe('spendTokens', () => { conversationId: 'test-convo', model: 'gpt-3.5-turbo', context: 'test', + balance: { enabled: false }, }; const tokenUsage = { promptTokens: 100, @@ -180,6 +177,7 @@ describe('spendTokens', () => { conversationId: 'test-convo', model: 'gpt-4', // Using a more expensive model context: 'test', + balance: { enabled: true }, }; // Spending more tokens than the user has balance for @@ -233,6 +231,7 @@ describe('spendTokens', () => { conversationId: 'test-convo-1', model: 'gpt-4', context: 'test', + balance: { enabled: true }, }; const tokenUsage1 = { @@ -252,6 +251,7 @@ describe('spendTokens', () => { conversationId: 'test-convo-2', model: 'gpt-4', context: 'test', + balance: { enabled: true }, }; const tokenUsage2 = { @@ -292,6 +292,7 @@ describe('spendTokens', () => { tokenType: 'completion', rawAmount: -100, context: 'test', + balance: { enabled: true }, }); console.log('Direct Transaction.create result:', directResult); @@ -316,6 +317,7 @@ describe('spendTokens', () => { conversationId: `test-convo-${model}`, model, context: 'test', + balance: { enabled: true }, }; const tokenUsage = { @@ -352,6 +354,7 @@ describe('spendTokens', () => { conversationId: 'test-convo-1', model: 'claude-3-5-sonnet', context: 'test', + balance: { enabled: true }, }; const tokenUsage1 = { @@ -375,6 +378,7 @@ describe('spendTokens', () => { conversationId: 'test-convo-2', model: 'claude-3-5-sonnet', context: 'test', + balance: { enabled: true }, }; const tokenUsage2 = { @@ -426,6 +430,7 @@ describe('spendTokens', () => { conversationId: 'test-convo', model: 'claude-3-5-sonnet', // Using a model that supports structured tokens context: 'test', + balance: { enabled: true }, }; // Spending more tokens than the user has balance for @@ -505,6 +510,7 @@ describe('spendTokens', () => { conversationId, user: userId, model: usage.model, + balance: { enabled: true }, }; // Calculate expected spend for this transaction @@ -617,6 +623,7 @@ describe('spendTokens', () => { tokenType: 'credits', context: 'concurrent-refill-test', rawAmount: refillAmount, + balance: { enabled: true }, }), ); } @@ -683,6 +690,7 @@ describe('spendTokens', () => { conversationId: 'test-convo', model: 'claude-3-5-sonnet', context: 'test', + balance: { enabled: true }, }; const tokenUsage = { promptTokens: { diff --git a/api/models/userMethods.js b/api/models/userMethods.js index a36409ebc..b57b24e64 100644 --- a/api/models/userMethods.js +++ b/api/models/userMethods.js @@ -3,7 +3,7 @@ const bcrypt = require('bcryptjs'); /** * Compares the provided password with the user's password. * - * @param {MongoUser} user - The user to compare the password for. + * @param {IUser} user - The user to compare the password for. * @param {string} candidatePassword - The password to test against the user's password. * @returns {Promise} A promise that resolves to a boolean indicating if the password matches. */ diff --git a/api/package.json b/api/package.json index 9c672edec..ffe3ac1e8 100644 --- a/api/package.json +++ b/api/package.json @@ -97,7 +97,6 @@ "nodemailer": "^6.9.15", "ollama": "^0.5.0", "openai": "^5.10.1", - "openai-chat-tokens": "^0.2.8", "openid-client": "^6.5.0", "passport": "^0.6.0", "passport-apple": "^2.0.2", diff --git a/api/server/controllers/OverrideController.js b/api/server/controllers/OverrideController.js deleted file mode 100644 index 677fb87bd..000000000 --- a/api/server/controllers/OverrideController.js +++ /dev/null @@ -1,27 +0,0 @@ -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_STORE); - let overrideConfig = await cache.get(CacheKeys.OVERRIDE_CONFIG); - if (overrideConfig) { - res.send(overrideConfig); - return; - } else if (overrideConfig === false) { - res.send(false); - return; - } - overrideConfig = await loadOverrideConfig(); - const { endpointsConfig, modelsConfig } = overrideConfig; - if (endpointsConfig) { - await cache.set(CacheKeys.ENDPOINT_CONFIG, endpointsConfig); - } - if (modelsConfig) { - await cache.set(CacheKeys.MODELS_CONFIG, modelsConfig); - } - await cache.set(CacheKeys.OVERRIDE_CONFIG, overrideConfig); - res.send(JSON.stringify(overrideConfig)); -} - -module.exports = overrideController; diff --git a/api/server/controllers/PluginController.js b/api/server/controllers/PluginController.js index 451868b75..070d53812 100644 --- a/api/server/controllers/PluginController.js +++ b/api/server/controllers/PluginController.js @@ -7,14 +7,9 @@ const { convertMCPToolToPlugin, convertMCPToolsToPlugins, } = require('@librechat/api'); -const { - getCachedTools, - setCachedTools, - mergeUserTools, - getCustomConfig, -} = require('~/server/services/Config'); -const { loadAndFormatTools } = require('~/server/services/ToolService'); +const { getCachedTools, setCachedTools, mergeUserTools } = require('~/server/services/Config'); const { availableTools, toolkits } = require('~/app/clients/tools'); +const { getAppConfig } = require('~/server/services/Config'); const { getMCPManager } = require('~/config'); const { getLogStores } = require('~/cache'); @@ -27,8 +22,9 @@ const getAvailablePluginsController = async (req, res) => { return; } + const appConfig = await getAppConfig({ role: req.user?.role }); /** @type {{ filteredTools: string[], includedTools: string[] }} */ - const { filteredTools = [], includedTools = [] } = req.app.locals; + const { filteredTools = [], includedTools = [] } = appConfig; /** @type {import('@librechat/api').LCManifestTool[]} */ const pluginManifest = availableTools; @@ -74,13 +70,14 @@ const getAvailableTools = async (req, res) => { logger.warn('[getAvailableTools] User ID not found in request'); return res.status(401).json({ message: 'Unauthorized' }); } - const customConfig = await getCustomConfig(); const cache = getLogStores(CacheKeys.CONFIG_STORE); const cachedToolsArray = await cache.get(CacheKeys.TOOLS); const cachedUserTools = await getCachedTools({ userId }); + + const mcpManager = getMCPManager(); const userPlugins = cachedUserTools != null - ? convertMCPToolsToPlugins({ functionTools: cachedUserTools, customConfig }) + ? convertMCPToolsToPlugins({ functionTools: cachedUserTools, mcpManager }) : undefined; if (cachedToolsArray != null && userPlugins != null) { @@ -93,28 +90,19 @@ const getAvailableTools = async (req, res) => { let toolDefinitions = await getCachedTools({ includeGlobal: true }); let prelimCachedTools; - // TODO: this is a temp fix until app config is refactored - if (!toolDefinitions) { - toolDefinitions = loadAndFormatTools({ - adminFilter: req.app.locals?.filteredTools, - adminIncluded: req.app.locals?.includedTools, - directory: req.app.locals?.paths.structuredTools, - }); - prelimCachedTools = toolDefinitions; - } - /** @type {import('@librechat/api').LCManifestTool[]} */ let pluginManifest = availableTools; - if (customConfig?.mcpServers != null) { + + const appConfig = req.config ?? (await getAppConfig({ role: req.user?.role })); + if (appConfig?.mcpConfig != null) { try { - const mcpManager = getMCPManager(); const mcpTools = await mcpManager.getAllToolFunctions(userId); prelimCachedTools = prelimCachedTools ?? {}; for (const [toolKey, toolData] of Object.entries(mcpTools)) { const plugin = convertMCPToolToPlugin({ toolKey, toolData, - customConfig, + mcpManager, }); if (plugin) { pluginManifest.push(plugin); @@ -161,7 +149,7 @@ const getAvailableTools = async (req, res) => { if (plugin.pluginKey.includes(Constants.mcp_delimiter)) { const parts = plugin.pluginKey.split(Constants.mcp_delimiter); const serverName = parts[parts.length - 1]; - const serverConfig = customConfig?.mcpServers?.[serverName]; + const serverConfig = appConfig?.mcpConfig?.[serverName]; if (serverConfig?.customUserVars) { const customVarKeys = Object.keys(serverConfig.customUserVars); diff --git a/api/server/controllers/PluginController.spec.js b/api/server/controllers/PluginController.spec.js index 69ef99605..4ed9cccf0 100644 --- a/api/server/controllers/PluginController.spec.js +++ b/api/server/controllers/PluginController.spec.js @@ -1,30 +1,31 @@ const { Constants } = require('librechat-data-provider'); -const { getCustomConfig, getCachedTools } = require('~/server/services/Config'); +const { getCachedTools, getAppConfig } = require('~/server/services/Config'); const { getLogStores } = require('~/cache'); -// Mock the dependencies jest.mock('@librechat/data-schemas', () => ({ logger: { debug: jest.fn(), error: jest.fn(), + warn: jest.fn(), }, })); jest.mock('~/server/services/Config', () => ({ - getCustomConfig: jest.fn(), getCachedTools: jest.fn(), + getAppConfig: jest.fn().mockResolvedValue({ + filteredTools: [], + includedTools: [], + }), setCachedTools: jest.fn(), mergeUserTools: jest.fn(), })); -jest.mock('~/server/services/ToolService', () => ({ - getToolkitKey: jest.fn(), - loadAndFormatTools: jest.fn(), -})); +// loadAndFormatTools mock removed - no longer used in PluginController jest.mock('~/config', () => ({ getMCPManager: jest.fn(() => ({ - loadAllManifestTools: jest.fn().mockResolvedValue([]), + getAllToolFunctions: jest.fn().mockResolvedValue({}), + getRawConfig: jest.fn().mockReturnValue({}), })), getFlowStateManager: jest.fn(), })); @@ -39,7 +40,6 @@ jest.mock('~/cache', () => ({ })); const { getAvailableTools, getAvailablePluginsController } = require('./PluginController'); -const { loadAndFormatTools } = require('~/server/services/ToolService'); describe('PluginController', () => { let mockReq, mockRes, mockCache; @@ -48,12 +48,9 @@ describe('PluginController', () => { jest.clearAllMocks(); mockReq = { user: { id: 'test-user-id' }, - app: { - locals: { - paths: { structuredTools: '/mock/path' }, - filteredTools: null, - includedTools: null, - }, + config: { + filteredTools: [], + includedTools: [], }, }; mockRes = { status: jest.fn().mockReturnThis(), json: jest.fn() }; @@ -63,13 +60,19 @@ describe('PluginController', () => { // Clear availableTools and toolkits arrays before each test require('~/app/clients/tools').availableTools.length = 0; require('~/app/clients/tools').toolkits.length = 0; + + // Reset getCachedTools mock to ensure clean state + getCachedTools.mockReset(); + + // Reset getAppConfig mock to ensure clean state with default values + getAppConfig.mockReset(); + getAppConfig.mockResolvedValue({ + filteredTools: [], + includedTools: [], + }); }); describe('getAvailablePluginsController', () => { - beforeEach(() => { - mockReq.app = { locals: { filteredTools: [], includedTools: [] } }; - }); - it('should use filterUniquePlugins to remove duplicate plugins', async () => { // Add plugins with duplicates to availableTools const mockPlugins = [ @@ -82,10 +85,17 @@ describe('PluginController', () => { mockCache.get.mockResolvedValue(null); + // Configure getAppConfig to return the expected config + getAppConfig.mockResolvedValueOnce({ + filteredTools: [], + includedTools: [], + }); + await getAvailablePluginsController(mockReq, mockRes); expect(mockRes.status).toHaveBeenCalledWith(200); const responseData = mockRes.json.mock.calls[0][0]; + // The real filterUniquePlugins should have removed the duplicate expect(responseData).toHaveLength(2); expect(responseData[0].pluginKey).toBe('key1'); expect(responseData[1].pluginKey).toBe('key2'); @@ -99,10 +109,16 @@ describe('PluginController', () => { require('~/app/clients/tools').availableTools.push(mockPlugin); mockCache.get.mockResolvedValue(null); + // Configure getAppConfig to return the expected config + getAppConfig.mockResolvedValueOnce({ + filteredTools: [], + includedTools: [], + }); + await getAvailablePluginsController(mockReq, mockRes); const responseData = mockRes.json.mock.calls[0][0]; - // checkPluginAuth returns false, so authenticated property is not added + // The real checkPluginAuth returns false for plugins without authConfig, so authenticated property is not added expect(responseData[0].authenticated).toBeUndefined(); }); @@ -126,9 +142,14 @@ describe('PluginController', () => { ]; require('~/app/clients/tools').availableTools.push(...mockPlugins); - mockReq.app.locals.includedTools = ['key1']; mockCache.get.mockResolvedValue(null); + // Configure getAppConfig to return config with includedTools + getAppConfig.mockResolvedValueOnce({ + filteredTools: [], + includedTools: ['key1'], + }); + await getAvailablePluginsController(mockReq, mockRes); const responseData = mockRes.json.mock.calls[0][0]; @@ -152,20 +173,26 @@ describe('PluginController', () => { mockCache.get.mockResolvedValue(null); getCachedTools.mockResolvedValueOnce(mockUserTools); - getCustomConfig.mockResolvedValue(null); + mockReq.config = { + mcpConfig: null, + paths: { structuredTools: '/mock/path' }, + }; - // Mock second call to return tool definitions + // Mock second call to return tool definitions (includeGlobal: true) getCachedTools.mockResolvedValueOnce(mockUserTools); await getAvailableTools(mockReq, mockRes); + expect(mockRes.status).toHaveBeenCalledWith(200); const responseData = mockRes.json.mock.calls[0][0]; - // convertMCPToolsToPlugins should have converted the tool + expect(responseData).toBeDefined(); + expect(Array.isArray(responseData)).toBe(true); expect(responseData.length).toBeGreaterThan(0); const convertedTool = responseData.find( (tool) => tool.pluginKey === `tool1${Constants.mcp_delimiter}server1`, ); expect(convertedTool).toBeDefined(); + // The real convertMCPToolsToPlugins extracts the name from the delimiter expect(convertedTool.name).toBe('tool1'); }); @@ -188,15 +215,20 @@ describe('PluginController', () => { mockCache.get.mockResolvedValue(mockCachedPlugins); getCachedTools.mockResolvedValueOnce(mockUserTools); - getCustomConfig.mockResolvedValue(null); + mockReq.config = { + mcpConfig: null, + paths: { structuredTools: '/mock/path' }, + }; // Mock second call to return tool definitions getCachedTools.mockResolvedValueOnce(mockUserTools); await getAvailableTools(mockReq, mockRes); + expect(mockRes.status).toHaveBeenCalledWith(200); const responseData = mockRes.json.mock.calls[0][0]; - // Should have deduplicated tools with same pluginKey + expect(Array.isArray(responseData)).toBe(true); + // The real filterUniquePlugins should have deduplicated tools with same pluginKey const userToolCount = responseData.filter((tool) => tool.pluginKey === 'user-tool').length; expect(userToolCount).toBe(1); }); @@ -213,11 +245,15 @@ describe('PluginController', () => { require('~/app/clients/tools').availableTools.push(mockPlugin); mockCache.get.mockResolvedValue(null); - getCachedTools.mockResolvedValue(null); - getCustomConfig.mockResolvedValue(null); + // First call returns null for user tools + getCachedTools.mockResolvedValueOnce(null); + mockReq.config = { + mcpConfig: null, + paths: { structuredTools: '/mock/path' }, + }; - // Mock loadAndFormatTools to return tool definitions including our tool - loadAndFormatTools.mockReturnValue({ + // Second call (with includeGlobal: true) returns the tool definitions + getCachedTools.mockResolvedValueOnce({ tool1: { type: 'function', function: { @@ -235,7 +271,7 @@ describe('PluginController', () => { expect(Array.isArray(responseData)).toBe(true); const tool = responseData.find((t) => t.pluginKey === 'tool1'); expect(tool).toBeDefined(); - // checkPluginAuth returns false, so authenticated property is not added + // The real checkPluginAuth returns false for plugins without authConfig, so authenticated property is not added expect(tool.authenticated).toBeUndefined(); }); @@ -257,11 +293,15 @@ describe('PluginController', () => { }); mockCache.get.mockResolvedValue(null); - getCachedTools.mockResolvedValue(null); - getCustomConfig.mockResolvedValue(null); + // First call returns null for user tools + getCachedTools.mockResolvedValueOnce(null); + mockReq.config = { + mcpConfig: null, + paths: { structuredTools: '/mock/path' }, + }; - // Mock loadAndFormatTools to return tool definitions - loadAndFormatTools.mockReturnValue({ + // Second call (with includeGlobal: true) returns the tool definitions + getCachedTools.mockResolvedValueOnce({ toolkit1_function: { type: 'function', function: { @@ -283,9 +323,8 @@ describe('PluginController', () => { }); describe('plugin.icon behavior', () => { - const callGetAvailableToolsWithMCPServer = async (mcpServers) => { + const callGetAvailableToolsWithMCPServer = async (serverConfig) => { mockCache.get.mockResolvedValue(null); - getCustomConfig.mockResolvedValue({ mcpServers }); const functionTools = { [`test-tool${Constants.mcp_delimiter}test-server`]: { @@ -298,17 +337,24 @@ describe('PluginController', () => { }, }; - // Mock the MCP manager to return tools + // Mock the MCP manager to return tools and server config const mockMCPManager = { getAllToolFunctions: jest.fn().mockResolvedValue(functionTools), + getRawConfig: jest.fn().mockReturnValue(serverConfig), }; require('~/config').getMCPManager.mockReturnValue(mockMCPManager); + // First call returns empty user tools getCachedTools.mockResolvedValueOnce({}); - // Mock loadAndFormatTools to return empty object since these are MCP tools - loadAndFormatTools.mockReturnValue({}); + // Mock getAppConfig to return the mcpConfig + mockReq.config = { + mcpConfig: { + 'test-server': serverConfig, + }, + }; + // Second call (with includeGlobal: true) returns the tool definitions getCachedTools.mockResolvedValueOnce(functionTools); await getAvailableTools(mockReq, mockRes); @@ -319,28 +365,24 @@ describe('PluginController', () => { }; it('should set plugin.icon when iconPath is defined', async () => { - const mcpServers = { - 'test-server': { - iconPath: '/path/to/icon.png', - }, + const serverConfig = { + iconPath: '/path/to/icon.png', }; - const testTool = await callGetAvailableToolsWithMCPServer(mcpServers); + const testTool = await callGetAvailableToolsWithMCPServer(serverConfig); expect(testTool.icon).toBe('/path/to/icon.png'); }); it('should set plugin.icon to undefined when iconPath is not defined', async () => { - const mcpServers = { - 'test-server': {}, - }; - const testTool = await callGetAvailableToolsWithMCPServer(mcpServers); + const serverConfig = {}; + const testTool = await callGetAvailableToolsWithMCPServer(serverConfig); expect(testTool.icon).toBeUndefined(); }); }); describe('helper function integration', () => { it('should properly handle MCP tools with custom user variables', async () => { - const customConfig = { - mcpServers: { + const appConfig = { + mcpConfig: { 'test-server': { customUserVars: { API_KEY: { title: 'API Key', description: 'Your API key' }, @@ -364,24 +406,28 @@ describe('PluginController', () => { // Mock the MCP manager to return tools const mockMCPManager = { getAllToolFunctions: jest.fn().mockResolvedValue(mcpToolFunctions), + getRawConfig: jest.fn().mockReturnValue({ + customUserVars: { + API_KEY: { title: 'API Key', description: 'Your API key' }, + }, + }), }; require('~/config').getMCPManager.mockReturnValue(mockMCPManager); mockCache.get.mockResolvedValue(null); - getCustomConfig.mockResolvedValue(customConfig); + mockReq.config = appConfig; // First call returns user tools (empty in this case) getCachedTools.mockResolvedValueOnce({}); - // Mock loadAndFormatTools to return empty object for MCP tools - loadAndFormatTools.mockReturnValue({}); - - // Second call returns tool definitions including our MCP tool + // Second call (with includeGlobal: true) returns tool definitions including our MCP tool getCachedTools.mockResolvedValueOnce(mcpToolFunctions); await getAvailableTools(mockReq, mockRes); + expect(mockRes.status).toHaveBeenCalledWith(200); const responseData = mockRes.json.mock.calls[0][0]; + expect(Array.isArray(responseData)).toBe(true); // Find the MCP tool in the response const mcpTool = responseData.find( @@ -417,24 +463,36 @@ describe('PluginController', () => { it('should handle null cachedTools and cachedUserTools', async () => { mockCache.get.mockResolvedValue(null); - getCachedTools.mockResolvedValue(null); - getCustomConfig.mockResolvedValue(null); + // First call returns null for user tools + getCachedTools.mockResolvedValueOnce(null); + mockReq.config = { + mcpConfig: null, + paths: { structuredTools: '/mock/path' }, + }; - // Mock loadAndFormatTools to return empty object when getCachedTools returns null - loadAndFormatTools.mockReturnValue({}); + // Mock MCP manager to return no tools + const mockMCPManager = { + getAllToolFunctions: jest.fn().mockResolvedValue({}), + getRawConfig: jest.fn().mockReturnValue({}), + }; + require('~/config').getMCPManager.mockReturnValue(mockMCPManager); + + // Second call (with includeGlobal: true) returns empty object instead of null + getCachedTools.mockResolvedValueOnce({}); await getAvailableTools(mockReq, mockRes); // Should handle null values gracefully expect(mockRes.status).toHaveBeenCalledWith(200); + expect(mockRes.json).toHaveBeenCalledWith([]); }); it('should handle when getCachedTools returns undefined', async () => { mockCache.get.mockResolvedValue(null); - getCustomConfig.mockResolvedValue(null); - - // Mock loadAndFormatTools to return empty object when getCachedTools returns undefined - loadAndFormatTools.mockReturnValue({}); + mockReq.config = { + mcpConfig: null, + paths: { structuredTools: '/mock/path' }, + }; // Mock getCachedTools to return undefined for both calls getCachedTools.mockReset(); @@ -444,6 +502,7 @@ describe('PluginController', () => { // Should handle undefined values gracefully expect(mockRes.status).toHaveBeenCalledWith(200); + expect(mockRes.json).toHaveBeenCalledWith([]); }); it('should handle cachedToolsArray and userPlugins both being defined', async () => { @@ -461,8 +520,18 @@ describe('PluginController', () => { }; mockCache.get.mockResolvedValue(cachedTools); - getCachedTools.mockResolvedValue(userTools); - getCustomConfig.mockResolvedValue(null); + getCachedTools.mockResolvedValueOnce(userTools); + mockReq.config = { + mcpConfig: null, + paths: { structuredTools: '/mock/path' }, + }; + + // The controller expects a second call to getCachedTools + getCachedTools.mockResolvedValueOnce({ + 'cached-tool': { type: 'function', function: { name: 'cached-tool' } }, + [`user-tool${Constants.mcp_delimiter}server1`]: + userTools[`user-tool${Constants.mcp_delimiter}server1`], + }); await getAvailableTools(mockReq, mockRes); @@ -474,8 +543,20 @@ describe('PluginController', () => { it('should handle empty toolDefinitions object', async () => { mockCache.get.mockResolvedValue(null); - getCachedTools.mockResolvedValueOnce({}).mockResolvedValueOnce({}); - getCustomConfig.mockResolvedValue(null); + // Reset getCachedTools to ensure clean state + getCachedTools.mockReset(); + getCachedTools.mockResolvedValue({}); + mockReq.config = {}; // No mcpConfig at all + + // Ensure no plugins are available + require('~/app/clients/tools').availableTools.length = 0; + + // Reset MCP manager to default state + const mockMCPManager = { + getAllToolFunctions: jest.fn().mockResolvedValue({}), + getRawConfig: jest.fn().mockReturnValue({}), + }; + require('~/config').getMCPManager.mockReturnValue(mockMCPManager); await getAvailableTools(mockReq, mockRes); @@ -484,8 +565,8 @@ describe('PluginController', () => { }); it('should handle MCP tools without customUserVars', async () => { - const customConfig = { - mcpServers: { + const appConfig = { + mcpConfig: { 'test-server': { // No customUserVars defined }, @@ -494,30 +575,60 @@ describe('PluginController', () => { const mockUserTools = { [`tool1${Constants.mcp_delimiter}test-server`]: { - function: { name: 'tool1', description: 'Tool 1' }, + type: 'function', + function: { + name: `tool1${Constants.mcp_delimiter}test-server`, + description: 'Tool 1', + parameters: { type: 'object', properties: {} }, + }, }, }; + // Mock the MCP manager to return the tools + const mockMCPManager = { + getAllToolFunctions: jest.fn().mockResolvedValue(mockUserTools), + getRawConfig: jest.fn().mockReturnValue({ + // No customUserVars defined + }), + }; + require('~/config').getMCPManager.mockReturnValue(mockMCPManager); + mockCache.get.mockResolvedValue(null); - getCustomConfig.mockResolvedValue(customConfig); + mockReq.config = appConfig; + // First call returns empty user tools + getCachedTools.mockResolvedValueOnce({}); + + // Second call (with includeGlobal: true) returns the tool definitions getCachedTools.mockResolvedValueOnce(mockUserTools); - getCachedTools.mockResolvedValueOnce({ - [`tool1${Constants.mcp_delimiter}test-server`]: true, - }); + // Ensure no plugins in availableTools for clean test + require('~/app/clients/tools').availableTools.length = 0; await getAvailableTools(mockReq, mockRes); + expect(mockRes.status).toHaveBeenCalledWith(200); const responseData = mockRes.json.mock.calls[0][0]; - expect(responseData[0].authenticated).toBe(true); - // The actual implementation doesn't set authConfig on tools without customUserVars - expect(responseData[0].authConfig).toEqual([]); + expect(Array.isArray(responseData)).toBe(true); + expect(responseData.length).toBeGreaterThan(0); + + const mcpTool = responseData.find( + (tool) => tool.pluginKey === `tool1${Constants.mcp_delimiter}test-server`, + ); + + expect(mcpTool).toBeDefined(); + expect(mcpTool.authenticated).toBe(true); + // The actual implementation sets authConfig to empty array when no customUserVars + expect(mcpTool.authConfig).toEqual([]); }); - it('should handle req.app.locals with undefined filteredTools and includedTools', async () => { - mockReq.app = { locals: {} }; + it('should handle undefined filteredTools and includedTools', async () => { + mockReq.config = {}; mockCache.get.mockResolvedValue(null); + // Configure getAppConfig to return config with undefined properties + // The controller will use default values [] for filteredTools and includedTools + getAppConfig.mockResolvedValueOnce({}); + await getAvailablePluginsController(mockReq, mockRes); expect(mockRes.status).toHaveBeenCalledWith(200); @@ -532,27 +643,21 @@ describe('PluginController', () => { toolkit: true, }; - // Ensure req.app.locals is properly mocked - mockReq.app = { - locals: { - filteredTools: [], - includedTools: [], - paths: { structuredTools: '/mock/path' }, - }, - }; + // No need to mock app.locals anymore as it's not used // Add the toolkit to availableTools require('~/app/clients/tools').availableTools.push(mockToolkit); mockCache.get.mockResolvedValue(null); - getCachedTools.mockResolvedValue({}); - getCustomConfig.mockResolvedValue(null); + // First call returns empty object + getCachedTools.mockResolvedValueOnce({}); + mockReq.config = { + mcpConfig: null, + paths: { structuredTools: '/mock/path' }, + }; - // Mock loadAndFormatTools to return an empty object when toolDefinitions is null - loadAndFormatTools.mockReturnValue({}); - - // Mock getCachedTools second call to return null - getCachedTools.mockResolvedValueOnce({}).mockResolvedValueOnce(null); + // Second call (with includeGlobal: true) returns empty object to avoid null reference error + getCachedTools.mockResolvedValueOnce({}); await getAvailableTools(mockReq, mockRes); diff --git a/api/server/controllers/UserController.js b/api/server/controllers/UserController.js index cd88cb2de..58818e8d3 100644 --- a/api/server/controllers/UserController.js +++ b/api/server/controllers/UserController.js @@ -17,12 +17,14 @@ const { needsRefresh, getNewS3URL } = require('~/server/services/Files/S3/crud') const { Tools, Constants, FileSources } = require('librechat-data-provider'); const { processDeleteRequest } = require('~/server/services/Files/process'); const { Transaction, Balance, User } = require('~/db/models'); +const { getAppConfig } = require('~/server/services/Config'); const { deleteToolCalls } = require('~/models/ToolCall'); const { deleteAllSharedLinks } = require('~/models'); const { getMCPManager } = require('~/config'); const getUserController = async (req, res) => { - /** @type {MongoUser} */ + const appConfig = await getAppConfig({ role: req.user?.role }); + /** @type {IUser} */ const userData = req.user.toObject != null ? req.user.toObject() : { ...req.user }; /** * These fields should not exist due to secure field selection, but deletion @@ -31,7 +33,7 @@ const getUserController = async (req, res) => { delete userData.password; delete userData.totpSecret; delete userData.backupCodes; - if (req.app.locals.fileStrategy === FileSources.s3 && userData.avatar) { + if (appConfig.fileStrategy === FileSources.s3 && userData.avatar) { const avatarNeedsRefresh = needsRefresh(userData.avatar, 3600); if (!avatarNeedsRefresh) { return res.status(200).send(userData); @@ -87,6 +89,7 @@ const deleteUserFiles = async (req) => { }; const updateUserPluginsController = async (req, res) => { + const appConfig = await getAppConfig({ role: req.user?.role }); const { user } = req; const { pluginKey, action, auth, isEntityTool } = req.body; try { @@ -131,7 +134,7 @@ const updateUserPluginsController = async (req, res) => { if (pluginKey === Tools.web_search) { /** @type {TCustomConfig['webSearch']} */ - const webSearchConfig = req.app.locals?.webSearch; + const webSearchConfig = appConfig?.webSearch; keys = extractWebSearchEnvVars({ keys: action === 'install' ? keys : webSearchKeys, config: webSearchConfig, diff --git a/api/server/controllers/agents/callbacks.js b/api/server/controllers/agents/callbacks.js index a9291aa7f..6138964ba 100644 --- a/api/server/controllers/agents/callbacks.js +++ b/api/server/controllers/agents/callbacks.js @@ -246,6 +246,7 @@ function createToolEndCallback({ req, res, artifactPromises }) { const attachment = await processFileCitations({ user, metadata, + appConfig: req.config, toolArtifact: output.artifact, toolCallId: output.tool_call_id, }); diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index 897e8f84f..804fe34fa 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -7,6 +7,7 @@ const { createRun, Tokenizer, checkAccess, + getBalanceConfig, memoryInstructions, formatContentStrings, createMemoryProcessor, @@ -446,8 +447,8 @@ class AgentClient extends BaseClient { ); return; } - /** @type {TCustomConfig['memory']} */ - const memoryConfig = this.options.req?.app?.locals?.memory; + const appConfig = this.options.req.config; + const memoryConfig = appConfig.memory; if (!memoryConfig || memoryConfig.disabled === true) { return; } @@ -455,7 +456,7 @@ class AgentClient extends BaseClient { /** @type {Agent} */ let prelimAgent; const allowedProviders = new Set( - this.options.req?.app?.locals?.[EModelEndpoint.agents]?.allowedProviders, + appConfig?.endpoints?.[EModelEndpoint.agents]?.allowedProviders, ); try { if (memoryConfig.agent?.id != null && memoryConfig.agent.id !== this.options.agent.id) { @@ -577,8 +578,8 @@ class AgentClient extends BaseClient { if (this.processMemory == null) { return; } - /** @type {TCustomConfig['memory']} */ - const memoryConfig = this.options.req?.app?.locals?.memory; + const appConfig = this.options.req.config; + const memoryConfig = appConfig.memory; const messageWindowSize = memoryConfig?.messageWindowSize ?? 5; let messagesToProcess = [...messages]; @@ -620,9 +621,15 @@ class AgentClient extends BaseClient { * @param {Object} params * @param {string} [params.model] * @param {string} [params.context='message'] + * @param {AppConfig['balance']} [params.balance] * @param {UsageMetadata[]} [params.collectedUsage=this.collectedUsage] */ - async recordCollectedUsage({ model, context = 'message', collectedUsage = this.collectedUsage }) { + async recordCollectedUsage({ + model, + balance, + context = 'message', + collectedUsage = this.collectedUsage, + }) { if (!collectedUsage || !collectedUsage.length) { return; } @@ -644,6 +651,7 @@ class AgentClient extends BaseClient { const txMetadata = { context, + balance, conversationId: this.conversationId, user: this.user ?? this.options.req.user?.id, endpointTokenConfig: this.options.endpointTokenConfig, @@ -761,8 +769,9 @@ class AgentClient extends BaseClient { abortController = new AbortController(); } - /** @type {TCustomConfig['endpoints']['agents']} */ - const agentsEConfig = this.options.req.app.locals[EModelEndpoint.agents]; + const appConfig = this.options.req.config; + /** @type {AppConfig['endpoints']['agents']} */ + const agentsEConfig = appConfig.endpoints?.[EModelEndpoint.agents]; config = { configurable: { @@ -1030,7 +1039,8 @@ class AgentClient extends BaseClient { this.artifactPromises.push(...attachments); } - await this.recordCollectedUsage({ context: 'message' }); + const balanceConfig = getBalanceConfig(appConfig); + await this.recordCollectedUsage({ context: 'message', balance: balanceConfig }); } catch (err) { logger.error( '[api/server/controllers/agents/client.js #chatCompletion] Error recording collected usage', @@ -1071,6 +1081,7 @@ class AgentClient extends BaseClient { } const { handleLLMEnd, collected: collectedMetadata } = createMetadataAggregator(); const { req, res, agent } = this.options; + const appConfig = req.config; let endpoint = agent.endpoint; /** @type {import('@librechat/agents').ClientOptions} */ @@ -1078,11 +1089,13 @@ class AgentClient extends BaseClient { model: agent.model || agent.model_parameters.model, }; - let titleProviderConfig = await getProviderConfig(endpoint); + let titleProviderConfig = getProviderConfig({ provider: endpoint, appConfig }); /** @type {TEndpoint | undefined} */ const endpointConfig = - req.app.locals.all ?? req.app.locals[endpoint] ?? titleProviderConfig.customEndpointConfig; + appConfig.endpoints?.all ?? + appConfig.endpoints?.[endpoint] ?? + titleProviderConfig.customEndpointConfig; if (!endpointConfig) { logger.warn( '[api/server/controllers/agents/client.js #titleConvo] Error getting endpoint config', @@ -1091,7 +1104,10 @@ class AgentClient extends BaseClient { if (endpointConfig?.titleEndpoint && endpointConfig.titleEndpoint !== endpoint) { try { - titleProviderConfig = await getProviderConfig(endpointConfig.titleEndpoint); + titleProviderConfig = getProviderConfig({ + provider: endpointConfig.titleEndpoint, + appConfig, + }); endpoint = endpointConfig.titleEndpoint; } catch (error) { logger.warn( @@ -1100,7 +1116,7 @@ class AgentClient extends BaseClient { ); // Fall back to original provider config endpoint = agent.endpoint; - titleProviderConfig = await getProviderConfig(endpoint); + titleProviderConfig = getProviderConfig({ provider: endpoint, appConfig }); } } @@ -1203,10 +1219,12 @@ class AgentClient extends BaseClient { }; }); + const balanceConfig = getBalanceConfig(appConfig); await this.recordCollectedUsage({ - model: clientOptions.model, - context: 'title', collectedUsage, + context: 'title', + model: clientOptions.model, + balance: balanceConfig, }).catch((err) => { logger.error( '[api/server/controllers/agents/client.js #titleConvo] Error recording collected usage', @@ -1225,17 +1243,26 @@ class AgentClient extends BaseClient { * @param {object} params * @param {number} params.promptTokens * @param {number} params.completionTokens - * @param {OpenAIUsageMetadata} [params.usage] * @param {string} [params.model] + * @param {OpenAIUsageMetadata} [params.usage] + * @param {AppConfig['balance']} [params.balance] * @param {string} [params.context='message'] * @returns {Promise} */ - async recordTokenUsage({ model, promptTokens, completionTokens, usage, context = 'message' }) { + async recordTokenUsage({ + model, + usage, + balance, + promptTokens, + completionTokens, + context = 'message', + }) { try { await spendTokens( { model, context, + balance, conversationId: this.conversationId, user: this.user ?? this.options.req.user?.id, endpointTokenConfig: this.options.endpointTokenConfig, @@ -1252,6 +1279,7 @@ class AgentClient extends BaseClient { await spendTokens( { model, + balance, context: 'reasoning', conversationId: this.conversationId, user: this.user ?? this.options.req.user?.id, diff --git a/api/server/controllers/agents/client.test.js b/api/server/controllers/agents/client.test.js index 27a54c987..5a42b6c3c 100644 --- a/api/server/controllers/agents/client.test.js +++ b/api/server/controllers/agents/client.test.js @@ -41,8 +41,16 @@ describe('AgentClient - titleConvo', () => { // Mock request and response mockReq = { - app: { - locals: { + user: { + id: 'user-123', + }, + body: { + model: 'gpt-4', + endpoint: EModelEndpoint.openAI, + key: null, + }, + config: { + endpoints: { [EModelEndpoint.openAI]: { // Match the agent endpoint titleModel: 'gpt-3.5-turbo', @@ -52,14 +60,6 @@ describe('AgentClient - titleConvo', () => { }, }, }, - user: { - id: 'user-123', - }, - body: { - model: 'gpt-4', - endpoint: EModelEndpoint.openAI, - key: null, - }, }; mockRes = {}; @@ -143,7 +143,7 @@ describe('AgentClient - titleConvo', () => { it('should handle missing endpoint config gracefully', async () => { // Remove endpoint config - mockReq.app.locals[EModelEndpoint.openAI] = undefined; + mockReq.config = { endpoints: {} }; const text = 'Test conversation text'; const abortController = new AbortController(); @@ -161,7 +161,16 @@ describe('AgentClient - titleConvo', () => { it('should use agent model when titleModel is not provided', async () => { // Remove titleModel from config - delete mockReq.app.locals[EModelEndpoint.openAI].titleModel; + mockReq.config = { + endpoints: { + [EModelEndpoint.openAI]: { + titlePrompt: 'Custom title prompt', + titleMethod: 'structured', + titlePromptTemplate: 'Template: {{content}}', + // titleModel is omitted + }, + }, + }; const text = 'Test conversation text'; const abortController = new AbortController(); @@ -173,7 +182,16 @@ describe('AgentClient - titleConvo', () => { }); it('should not use titleModel when it equals CURRENT_MODEL constant', async () => { - mockReq.app.locals[EModelEndpoint.openAI].titleModel = Constants.CURRENT_MODEL; + mockReq.config = { + endpoints: { + [EModelEndpoint.openAI]: { + titleModel: Constants.CURRENT_MODEL, + titlePrompt: 'Custom title prompt', + titleMethod: 'structured', + titlePromptTemplate: 'Template: {{content}}', + }, + }, + }; const text = 'Test conversation text'; const abortController = new AbortController(); @@ -216,6 +234,9 @@ describe('AgentClient - titleConvo', () => { model: 'gpt-3.5-turbo', context: 'title', collectedUsage: expect.any(Array), + balance: { + enabled: false, + }, }); }); @@ -245,10 +266,17 @@ describe('AgentClient - titleConvo', () => { process.env.ANTHROPIC_API_KEY = 'test-api-key'; // Add titleEndpoint to the config - mockReq.app.locals[EModelEndpoint.openAI].titleEndpoint = EModelEndpoint.anthropic; - mockReq.app.locals[EModelEndpoint.openAI].titleMethod = 'structured'; - mockReq.app.locals[EModelEndpoint.openAI].titlePrompt = 'Custom title prompt'; - mockReq.app.locals[EModelEndpoint.openAI].titlePromptTemplate = 'Custom template'; + mockReq.config = { + endpoints: { + [EModelEndpoint.openAI]: { + titleModel: 'gpt-3.5-turbo', + titleEndpoint: EModelEndpoint.anthropic, + titleMethod: 'structured', + titlePrompt: 'Custom title prompt', + titlePromptTemplate: 'Custom template', + }, + }, + }; const text = 'Test conversation text'; const abortController = new AbortController(); @@ -274,18 +302,16 @@ describe('AgentClient - titleConvo', () => { }); it('should use all config when endpoint config is missing', async () => { - // Remove endpoint-specific config - delete mockReq.app.locals[EModelEndpoint.openAI].titleModel; - delete mockReq.app.locals[EModelEndpoint.openAI].titlePrompt; - delete mockReq.app.locals[EModelEndpoint.openAI].titleMethod; - delete mockReq.app.locals[EModelEndpoint.openAI].titlePromptTemplate; - - // Set 'all' config - mockReq.app.locals.all = { - titleModel: 'gpt-4o-mini', - titlePrompt: 'All config title prompt', - titleMethod: 'completion', - titlePromptTemplate: 'All config template: {{content}}', + // Set 'all' config without endpoint-specific config + mockReq.config = { + endpoints: { + all: { + titleModel: 'gpt-4o-mini', + titlePrompt: 'All config title prompt', + titleMethod: 'completion', + titlePromptTemplate: 'All config template: {{content}}', + }, + }, }; const text = 'Test conversation text'; @@ -309,17 +335,21 @@ describe('AgentClient - titleConvo', () => { it('should prioritize all config over endpoint config for title settings', async () => { // Set both endpoint and 'all' config - mockReq.app.locals[EModelEndpoint.openAI].titleModel = 'gpt-3.5-turbo'; - mockReq.app.locals[EModelEndpoint.openAI].titlePrompt = 'Endpoint title prompt'; - mockReq.app.locals[EModelEndpoint.openAI].titleMethod = 'structured'; - // Remove titlePromptTemplate from endpoint config to test fallback - delete mockReq.app.locals[EModelEndpoint.openAI].titlePromptTemplate; - - mockReq.app.locals.all = { - titleModel: 'gpt-4o-mini', - titlePrompt: 'All config title prompt', - titleMethod: 'completion', - titlePromptTemplate: 'All config template', + mockReq.config = { + endpoints: { + [EModelEndpoint.openAI]: { + titleModel: 'gpt-3.5-turbo', + titlePrompt: 'Endpoint title prompt', + titleMethod: 'structured', + // titlePromptTemplate is omitted to test fallback + }, + all: { + titleModel: 'gpt-4o-mini', + titlePrompt: 'All config title prompt', + titleMethod: 'completion', + titlePromptTemplate: 'All config template', + }, + }, }; const text = 'Test conversation text'; @@ -346,17 +376,18 @@ describe('AgentClient - titleConvo', () => { const originalApiKey = process.env.ANTHROPIC_API_KEY; process.env.ANTHROPIC_API_KEY = 'test-anthropic-key'; - // Remove endpoint-specific config to test 'all' config - delete mockReq.app.locals[EModelEndpoint.openAI]; - // Set comprehensive 'all' config with all new title options - mockReq.app.locals.all = { - titleConvo: true, - titleModel: 'claude-3-haiku-20240307', - titleMethod: 'completion', // Testing the new default method - titlePrompt: 'Generate a concise, descriptive title for this conversation', - titlePromptTemplate: 'Conversation summary: {{content}}', - titleEndpoint: EModelEndpoint.anthropic, // Should switch provider to Anthropic + mockReq.config = { + endpoints: { + all: { + titleConvo: true, + titleModel: 'claude-3-haiku-20240307', + titleMethod: 'completion', // Testing the new default method + titlePrompt: 'Generate a concise, descriptive title for this conversation', + titlePromptTemplate: 'Conversation summary: {{content}}', + titleEndpoint: EModelEndpoint.anthropic, // Should switch provider to Anthropic + }, + }, }; const text = 'Test conversation about AI and machine learning'; @@ -402,15 +433,16 @@ describe('AgentClient - titleConvo', () => { // Clear previous calls mockRun.generateTitle.mockClear(); - // Remove endpoint config - delete mockReq.app.locals[EModelEndpoint.openAI]; - // Set 'all' config with specific titleMethod - mockReq.app.locals.all = { - titleModel: 'gpt-4o-mini', - titleMethod: method, - titlePrompt: `Testing ${method} method`, - titlePromptTemplate: `Template for ${method}: {{content}}`, + mockReq.config = { + endpoints: { + all: { + titleModel: 'gpt-4o-mini', + titleMethod: method, + titlePrompt: `Testing ${method} method`, + titlePromptTemplate: `Template for ${method}: {{content}}`, + }, + }, }; const text = `Test conversation for ${method} method`; @@ -455,29 +487,33 @@ describe('AgentClient - titleConvo', () => { // Set up Azure endpoint with serverless config mockAgent.endpoint = EModelEndpoint.azureOpenAI; mockAgent.provider = EModelEndpoint.azureOpenAI; - mockReq.app.locals[EModelEndpoint.azureOpenAI] = { - titleConvo: true, - titleModel: 'grok-3', - titleMethod: 'completion', - titlePrompt: 'Azure serverless title prompt', - streamRate: 35, - modelGroupMap: { - 'grok-3': { - group: 'Azure AI Foundry', - deploymentName: 'grok-3', - }, - }, - groupMap: { - 'Azure AI Foundry': { - apiKey: '${AZURE_API_KEY}', - baseURL: 'https://test.services.ai.azure.com/models', - version: '2024-05-01-preview', - serverless: true, - models: { + mockReq.config = { + endpoints: { + [EModelEndpoint.azureOpenAI]: { + titleConvo: true, + titleModel: 'grok-3', + titleMethod: 'completion', + titlePrompt: 'Azure serverless title prompt', + streamRate: 35, + modelGroupMap: { 'grok-3': { + group: 'Azure AI Foundry', deploymentName: 'grok-3', }, }, + groupMap: { + 'Azure AI Foundry': { + apiKey: '${AZURE_API_KEY}', + baseURL: 'https://test.services.ai.azure.com/models', + version: '2024-05-01-preview', + serverless: true, + models: { + 'grok-3': { + deploymentName: 'grok-3', + }, + }, + }, + }, }, }, }; @@ -503,28 +539,32 @@ describe('AgentClient - titleConvo', () => { // Set up Azure endpoint mockAgent.endpoint = EModelEndpoint.azureOpenAI; mockAgent.provider = EModelEndpoint.azureOpenAI; - mockReq.app.locals[EModelEndpoint.azureOpenAI] = { - titleConvo: true, - titleModel: 'gpt-4o', - titleMethod: 'structured', - titlePrompt: 'Azure instance title prompt', - streamRate: 35, - modelGroupMap: { - 'gpt-4o': { - group: 'eastus', - deploymentName: 'gpt-4o', - }, - }, - groupMap: { - eastus: { - apiKey: '${EASTUS_API_KEY}', - instanceName: 'region-instance', - version: '2024-02-15-preview', - models: { + mockReq.config = { + endpoints: { + [EModelEndpoint.azureOpenAI]: { + titleConvo: true, + titleModel: 'gpt-4o', + titleMethod: 'structured', + titlePrompt: 'Azure instance title prompt', + streamRate: 35, + modelGroupMap: { 'gpt-4o': { + group: 'eastus', deploymentName: 'gpt-4o', }, }, + groupMap: { + eastus: { + apiKey: '${EASTUS_API_KEY}', + instanceName: 'region-instance', + version: '2024-02-15-preview', + models: { + 'gpt-4o': { + deploymentName: 'gpt-4o', + }, + }, + }, + }, }, }, }; @@ -551,29 +591,33 @@ describe('AgentClient - titleConvo', () => { mockAgent.endpoint = EModelEndpoint.azureOpenAI; mockAgent.provider = EModelEndpoint.azureOpenAI; mockAgent.model_parameters.model = 'gpt-4o-latest'; - mockReq.app.locals[EModelEndpoint.azureOpenAI] = { - titleConvo: true, - titleModel: Constants.CURRENT_MODEL, - titleMethod: 'functions', - streamRate: 35, - modelGroupMap: { - 'gpt-4o-latest': { - group: 'region-eastus', - deploymentName: 'gpt-4o-mini', - version: '2024-02-15-preview', - }, - }, - groupMap: { - 'region-eastus': { - apiKey: '${EASTUS2_API_KEY}', - instanceName: 'test-instance', - version: '2024-12-01-preview', - models: { + mockReq.config = { + endpoints: { + [EModelEndpoint.azureOpenAI]: { + titleConvo: true, + titleModel: Constants.CURRENT_MODEL, + titleMethod: 'functions', + streamRate: 35, + modelGroupMap: { 'gpt-4o-latest': { + group: 'region-eastus', deploymentName: 'gpt-4o-mini', version: '2024-02-15-preview', }, }, + groupMap: { + 'region-eastus': { + apiKey: '${EASTUS2_API_KEY}', + instanceName: 'test-instance', + version: '2024-12-01-preview', + models: { + 'gpt-4o-latest': { + deploymentName: 'gpt-4o-mini', + version: '2024-02-15-preview', + }, + }, + }, + }, }, }, }; @@ -598,56 +642,60 @@ describe('AgentClient - titleConvo', () => { // Set up Azure endpoint mockAgent.endpoint = EModelEndpoint.azureOpenAI; mockAgent.provider = EModelEndpoint.azureOpenAI; - mockReq.app.locals[EModelEndpoint.azureOpenAI] = { - titleConvo: true, - titleModel: 'o1-mini', - titleMethod: 'completion', - streamRate: 35, - modelGroupMap: { - 'gpt-4o': { - group: 'eastus', - deploymentName: 'gpt-4o', - }, - 'o1-mini': { - group: 'region-eastus', - deploymentName: 'o1-mini', - }, - 'codex-mini': { - group: 'codex-mini', - deploymentName: 'codex-mini', - }, - }, - groupMap: { - eastus: { - apiKey: '${EASTUS_API_KEY}', - instanceName: 'region-eastus', - version: '2024-02-15-preview', - models: { + mockReq.config = { + endpoints: { + [EModelEndpoint.azureOpenAI]: { + titleConvo: true, + titleModel: 'o1-mini', + titleMethod: 'completion', + streamRate: 35, + modelGroupMap: { 'gpt-4o': { + group: 'eastus', deploymentName: 'gpt-4o', }, - }, - }, - 'region-eastus': { - apiKey: '${EASTUS2_API_KEY}', - instanceName: 'region-eastus2', - version: '2024-12-01-preview', - models: { 'o1-mini': { + group: 'region-eastus', deploymentName: 'o1-mini', }, - }, - }, - 'codex-mini': { - apiKey: '${AZURE_API_KEY}', - baseURL: 'https://example.cognitiveservices.azure.com/openai/', - version: '2025-04-01-preview', - serverless: true, - models: { 'codex-mini': { + group: 'codex-mini', deploymentName: 'codex-mini', }, }, + groupMap: { + eastus: { + apiKey: '${EASTUS_API_KEY}', + instanceName: 'region-eastus', + version: '2024-02-15-preview', + models: { + 'gpt-4o': { + deploymentName: 'gpt-4o', + }, + }, + }, + 'region-eastus': { + apiKey: '${EASTUS2_API_KEY}', + instanceName: 'region-eastus2', + version: '2024-12-01-preview', + models: { + 'o1-mini': { + deploymentName: 'o1-mini', + }, + }, + }, + 'codex-mini': { + apiKey: '${AZURE_API_KEY}', + baseURL: 'https://example.cognitiveservices.azure.com/openai/', + version: '2025-04-01-preview', + serverless: true, + models: { + 'codex-mini': { + deploymentName: 'codex-mini', + }, + }, + }, + }, }, }, }; @@ -679,33 +727,34 @@ describe('AgentClient - titleConvo', () => { mockReq.body.endpoint = EModelEndpoint.azureOpenAI; mockReq.body.model = 'gpt-4'; - // Remove Azure-specific config - delete mockReq.app.locals[EModelEndpoint.azureOpenAI]; - // Set 'all' config as fallback with a serverless Azure config - mockReq.app.locals.all = { - titleConvo: true, - titleModel: 'gpt-4', - titleMethod: 'structured', - titlePrompt: 'Fallback title prompt from all config', - titlePromptTemplate: 'Template: {{content}}', - modelGroupMap: { - 'gpt-4': { - group: 'default-group', - deploymentName: 'gpt-4', - }, - }, - groupMap: { - 'default-group': { - apiKey: '${AZURE_API_KEY}', - baseURL: 'https://default.openai.azure.com/', - version: '2024-02-15-preview', - serverless: true, - models: { + mockReq.config = { + endpoints: { + all: { + titleConvo: true, + titleModel: 'gpt-4', + titleMethod: 'structured', + titlePrompt: 'Fallback title prompt from all config', + titlePromptTemplate: 'Template: {{content}}', + modelGroupMap: { 'gpt-4': { + group: 'default-group', deploymentName: 'gpt-4', }, }, + groupMap: { + 'default-group': { + apiKey: '${AZURE_API_KEY}', + baseURL: 'https://default.openai.azure.com/', + version: '2024-02-15-preview', + serverless: true, + models: { + 'gpt-4': { + deploymentName: 'gpt-4', + }, + }, + }, + }, }, }, }; @@ -982,13 +1031,6 @@ describe('AgentClient - titleConvo', () => { }; mockReq = { - app: { - locals: { - memory: { - messageWindowSize: 3, - }, - }, - }, user: { id: 'user-123', personalization: { @@ -997,6 +1039,13 @@ describe('AgentClient - titleConvo', () => { }, }; + // Mock getAppConfig for memory tests + mockReq.config = { + memory: { + messageWindowSize: 3, + }, + }; + mockRes = {}; mockOptions = { diff --git a/api/server/controllers/agents/errors.js b/api/server/controllers/agents/errors.js index 8c8418248..54b296a5d 100644 --- a/api/server/controllers/agents/errors.js +++ b/api/server/controllers/agents/errors.js @@ -21,7 +21,7 @@ const getLogStores = require('~/cache/getLogStores'); /** * @typedef {Object} ErrorHandlerDependencies - * @property {Express.Request} req - The Express request object + * @property {ServerRequest} req - The Express request object * @property {Express.Response} res - The Express response object * @property {() => ErrorHandlerContext} getContext - Function to get the current context * @property {string} [originPath] - The origin path for the error handler diff --git a/api/server/controllers/agents/v1.js b/api/server/controllers/agents/v1.js index df009c0b0..33443cd8a 100644 --- a/api/server/controllers/agents/v1.js +++ b/api/server/controllers/agents/v1.js @@ -487,6 +487,7 @@ const getListAgentsHandler = async (req, res) => { */ const uploadAgentAvatarHandler = async (req, res) => { try { + const appConfig = req.config; filterFile({ req, file: req.file, image: true, isAvatar: true }); const { agent_id } = req.params; if (!agent_id) { @@ -510,9 +511,7 @@ const uploadAgentAvatarHandler = async (req, res) => { } const buffer = await fs.readFile(req.file.path); - - const fileStrategy = getFileStrategy(req.app.locals, { isAvatar: true }); - + const fileStrategy = getFileStrategy(appConfig, { isAvatar: true }); const resizedBuffer = await resizeAvatar({ userId: req.user.id, input: buffer, diff --git a/api/server/controllers/assistants/chatV1.js b/api/server/controllers/assistants/chatV1.js index 09770b56d..b170b916a 100644 --- a/api/server/controllers/assistants/chatV1.js +++ b/api/server/controllers/assistants/chatV1.js @@ -1,7 +1,7 @@ const { v4 } = require('uuid'); const { sleep } = require('@librechat/agents'); -const { sendEvent } = require('@librechat/api'); const { logger } = require('@librechat/data-schemas'); +const { sendEvent, getBalanceConfig } = require('@librechat/api'); const { Time, Constants, @@ -47,6 +47,7 @@ const { getOpenAIClient } = require('./helpers'); * @returns {void} */ const chatV1 = async (req, res) => { + const appConfig = req.config; logger.debug('[/assistants/chat/] req.body', req.body); const { @@ -251,8 +252,8 @@ const chatV1 = async (req, res) => { } const checkBalanceBeforeRun = async () => { - const balance = req.app?.locals?.balance; - if (!balance?.enabled) { + const balanceConfig = getBalanceConfig(appConfig); + if (!balanceConfig?.enabled) { return; } const transactions = diff --git a/api/server/controllers/assistants/chatV2.js b/api/server/controllers/assistants/chatV2.js index c569dc837..cfcaf2ee3 100644 --- a/api/server/controllers/assistants/chatV2.js +++ b/api/server/controllers/assistants/chatV2.js @@ -1,7 +1,7 @@ const { v4 } = require('uuid'); const { sleep } = require('@librechat/agents'); -const { sendEvent } = require('@librechat/api'); const { logger } = require('@librechat/data-schemas'); +const { sendEvent, getBalanceConfig } = require('@librechat/api'); const { Time, Constants, @@ -38,12 +38,13 @@ const { getOpenAIClient } = require('./helpers'); * @route POST / * @desc Chat with an assistant * @access Public - * @param {Express.Request} req - The request object, containing the request data. + * @param {ServerRequest} req - The request object, containing the request data. * @param {Express.Response} res - The response object, used to send back a response. * @returns {void} */ const chatV2 = async (req, res) => { logger.debug('[/assistants/chat/] req.body', req.body); + const appConfig = req.config; /** @type {{files: MongoFile[]}} */ const { @@ -126,8 +127,8 @@ const chatV2 = async (req, res) => { } const checkBalanceBeforeRun = async () => { - const balance = req.app?.locals?.balance; - if (!balance?.enabled) { + const balanceConfig = getBalanceConfig(appConfig); + if (!balanceConfig?.enabled) { return; } const transactions = @@ -374,9 +375,9 @@ const chatV2 = async (req, res) => { }; /** @type {undefined | TAssistantEndpoint} */ - const config = req.app.locals[endpoint] ?? {}; + const config = appConfig.endpoints?.[endpoint] ?? {}; /** @type {undefined | TBaseEndpoint} */ - const allConfig = req.app.locals.all; + const allConfig = appConfig.endpoints?.all; const streamRunManager = new StreamRunManager({ req, diff --git a/api/server/controllers/assistants/errors.js b/api/server/controllers/assistants/errors.js index 1c76b138c..1ae12ea3d 100644 --- a/api/server/controllers/assistants/errors.js +++ b/api/server/controllers/assistants/errors.js @@ -22,7 +22,7 @@ const getLogStores = require('~/cache/getLogStores'); /** * @typedef {Object} ErrorHandlerDependencies - * @property {Express.Request} req - The Express request object + * @property {ServerRequest} req - The Express request object * @property {Express.Response} res - The Express response object * @property {() => ErrorHandlerContext} getContext - Function to get the current context * @property {string} [originPath] - The origin path for the error handler diff --git a/api/server/controllers/assistants/helpers.js b/api/server/controllers/assistants/helpers.js index 1bbc0915b..418fd4580 100644 --- a/api/server/controllers/assistants/helpers.js +++ b/api/server/controllers/assistants/helpers.js @@ -11,7 +11,7 @@ const { initializeClient } = require('~/server/services/Endpoints/assistants'); const { getEndpointsConfig } = require('~/server/services/Config'); /** - * @param {Express.Request} req + * @param {ServerRequest} req * @param {string} [endpoint] * @returns {Promise} */ @@ -210,6 +210,7 @@ async function getOpenAIClient({ req, res, endpointOption, initAppClient, overri * @returns {Promise} 200 - success response - application/json */ const fetchAssistants = async ({ req, res, overrideEndpoint }) => { + const appConfig = req.config; const { limit = 100, order = 'desc', @@ -230,20 +231,20 @@ const fetchAssistants = async ({ req, res, overrideEndpoint }) => { if (endpoint === EModelEndpoint.assistants) { ({ body } = await listAllAssistants({ req, res, version, query })); } else if (endpoint === EModelEndpoint.azureAssistants) { - const azureConfig = req.app.locals[EModelEndpoint.azureOpenAI]; + const azureConfig = appConfig.endpoints?.[EModelEndpoint.azureOpenAI]; body = await listAssistantsForAzure({ req, res, version, azureConfig, query }); } if (req.user.role === SystemRoles.ADMIN) { return body; - } else if (!req.app.locals[endpoint]) { + } else if (!appConfig.endpoints?.[endpoint]) { return body; } body.data = filterAssistants({ userId: req.user.id, assistants: body.data, - assistantsConfig: req.app.locals[endpoint], + assistantsConfig: appConfig.endpoints?.[endpoint], }); return body; }; diff --git a/api/server/controllers/assistants/v1.js b/api/server/controllers/assistants/v1.js index 10c59d913..1fb872f71 100644 --- a/api/server/controllers/assistants/v1.js +++ b/api/server/controllers/assistants/v1.js @@ -258,8 +258,9 @@ function filterAssistantDocs({ documents, userId, assistantsConfig = {} }) { */ const getAssistantDocuments = async (req, res) => { try { + const appConfig = req.config; const endpoint = req.query; - const assistantsConfig = req.app.locals[endpoint]; + const assistantsConfig = appConfig.endpoints?.[endpoint]; const documents = await getAssistants( {}, { @@ -296,6 +297,7 @@ const getAssistantDocuments = async (req, res) => { */ const uploadAssistantAvatar = async (req, res) => { try { + const appConfig = req.config; filterFile({ req, file: req.file, image: true, isAvatar: true }); const { assistant_id } = req.params; if (!assistant_id) { @@ -337,7 +339,7 @@ const uploadAssistantAvatar = async (req, res) => { const metadata = { ..._metadata, avatar: image.filepath, - avatar_source: req.app.locals.fileStrategy, + avatar_source: appConfig.fileStrategy, }; const promises = []; @@ -347,7 +349,7 @@ const uploadAssistantAvatar = async (req, res) => { { avatar: { filepath: image.filepath, - source: req.app.locals.fileStrategy, + source: appConfig.fileStrategy, }, user: req.user.id, }, diff --git a/api/server/controllers/assistants/v2.js b/api/server/controllers/assistants/v2.js index 98441ba70..824f58d26 100644 --- a/api/server/controllers/assistants/v2.js +++ b/api/server/controllers/assistants/v2.js @@ -94,7 +94,7 @@ const createAssistant = async (req, res) => { /** * Modifies an assistant. * @param {object} params - * @param {Express.Request} params.req + * @param {ServerRequest} params.req * @param {OpenAIClient} params.openai * @param {string} params.assistant_id * @param {AssistantUpdateParams} params.updateData @@ -199,7 +199,7 @@ const updateAssistant = async ({ req, openai, assistant_id, updateData }) => { /** * Modifies an assistant with the resource file id. * @param {object} params - * @param {Express.Request} params.req + * @param {ServerRequest} params.req * @param {OpenAIClient} params.openai * @param {string} params.assistant_id * @param {string} params.tool_resource @@ -227,7 +227,7 @@ const addResourceFileId = async ({ req, openai, assistant_id, tool_resource, fil /** * Deletes a file ID from an assistant's resource. * @param {object} params - * @param {Express.Request} params.req + * @param {ServerRequest} params.req * @param {OpenAIClient} params.openai * @param {string} params.assistant_id * @param {string} [params.tool_resource] diff --git a/api/server/controllers/tools.js b/api/server/controllers/tools.js index 079b4abc8..14a757e2b 100644 --- a/api/server/controllers/tools.js +++ b/api/server/controllers/tools.js @@ -35,9 +35,10 @@ const toolAccessPermType = { */ const verifyWebSearchAuth = async (req, res) => { try { + const appConfig = req.config; const userId = req.user.id; /** @type {TCustomConfig['webSearch']} */ - const webSearchConfig = req.app.locals?.webSearch || {}; + const webSearchConfig = appConfig?.webSearch || {}; const result = await loadWebSearchAuth({ userId, loadAuthValues, @@ -110,6 +111,7 @@ const verifyToolAuth = async (req, res) => { */ const callTool = async (req, res) => { try { + const appConfig = req.config; const { toolId = '' } = req.params; if (!fieldsMap[toolId]) { logger.warn(`[${toolId}/call] User ${req.user.id} attempted call to invalid tool`); @@ -155,8 +157,10 @@ const callTool = async (req, res) => { returnMetadata: true, processFileURL, uploadImageBuffer, - fileStrategy: req.app.locals.fileStrategy, }, + webSearch: appConfig.webSearch, + fileStrategy: appConfig.fileStrategy, + imageOutputType: appConfig.imageOutputType, }); const tool = loadedTools[0]; diff --git a/api/server/index.js b/api/server/index.js index f83fcbb44..c28418b86 100644 --- a/api/server/index.js +++ b/api/server/index.js @@ -14,12 +14,14 @@ const { isEnabled, ErrorController } = require('@librechat/api'); const { connectDb, indexSync } = require('~/db'); const validateImageRequest = require('./middleware/validateImageRequest'); const { jwtLogin, ldapLogin, passportLogin } = require('~/strategies'); +const { updateInterfacePermissions } = require('~/models/interface'); const { checkMigrations } = require('./services/start/migration'); const initializeMCPs = require('./services/initializeMCPs'); const configureSocialLogins = require('./socialLogins'); -const AppService = require('./services/AppService'); +const { getAppConfig } = require('./services/Config'); const staticCache = require('./utils/staticCache'); const noIndex = require('./middleware/noIndex'); +const { seedDatabase } = require('~/models'); const routes = require('./routes'); const { PORT, HOST, ALLOW_SOCIAL_LOGIN, DISABLE_COMPRESSION, TRUST_PROXY } = process.env ?? {}; @@ -45,9 +47,11 @@ const startServer = async () => { app.disable('x-powered-by'); app.set('trust proxy', trusted_proxy); - await AppService(app); + await seedDatabase(); - const indexPath = path.join(app.locals.paths.dist, 'index.html'); + const appConfig = await getAppConfig(); + await updateInterfacePermissions(appConfig); + const indexPath = path.join(appConfig.paths.dist, 'index.html'); const indexHTML = fs.readFileSync(indexPath, 'utf8'); app.get('/health', (_req, res) => res.status(200).send('OK')); @@ -66,10 +70,9 @@ const startServer = async () => { console.warn('Response compression has been disabled via DISABLE_COMPRESSION.'); } - // Serve static assets with aggressive caching - app.use(staticCache(app.locals.paths.dist)); - app.use(staticCache(app.locals.paths.fonts)); - app.use(staticCache(app.locals.paths.assets)); + app.use(staticCache(appConfig.paths.dist)); + app.use(staticCache(appConfig.paths.fonts)); + app.use(staticCache(appConfig.paths.assets)); if (!ALLOW_SOCIAL_LOGIN) { console.warn('Social logins are disabled. Set ALLOW_SOCIAL_LOGIN=true to enable them.'); @@ -146,7 +149,7 @@ const startServer = async () => { logger.info(`Server listening at http://${host == '0.0.0.0' ? 'localhost' : host}:${port}`); } - initializeMCPs(app).then(() => checkMigrations()); + initializeMCPs().then(() => checkMigrations()); }); }; diff --git a/api/server/index.spec.js b/api/server/index.spec.js index 79ca1621a..4dcd34687 100644 --- a/api/server/index.spec.js +++ b/api/server/index.spec.js @@ -3,9 +3,27 @@ const request = require('supertest'); const { MongoMemoryServer } = require('mongodb-memory-server'); const mongoose = require('mongoose'); -jest.mock('~/server/services/Config/loadCustomConfig', () => { - return jest.fn(() => Promise.resolve({})); -}); +jest.mock('~/server/services/Config', () => ({ + loadCustomConfig: jest.fn(() => Promise.resolve({})), + getAppConfig: jest.fn().mockResolvedValue({ + paths: { + uploads: '/tmp', + dist: '/tmp/dist', + fonts: '/tmp/fonts', + assets: '/tmp/assets', + }, + fileStrategy: 'local', + imageOutputType: 'PNG', + }), + setCachedTools: jest.fn(), +})); + +jest.mock('~/app/clients/tools', () => ({ + createOpenAIImageTools: jest.fn(() => []), + createYouTubeTools: jest.fn(() => []), + manifestToolMap: {}, + toolkits: [], +})); describe('Server Configuration', () => { // Increase the default timeout to allow for Mongo cleanup @@ -31,6 +49,22 @@ describe('Server Configuration', () => { }); beforeAll(async () => { + // Create the required directories and files for the test + const fs = require('fs'); + const path = require('path'); + + const dirs = ['/tmp/dist', '/tmp/fonts', '/tmp/assets']; + dirs.forEach((dir) => { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + }); + + fs.writeFileSync( + path.join('/tmp/dist', 'index.html'), + 'LibreChat
', + ); + mongoServer = await MongoMemoryServer.create(); process.env.MONGO_URI = mongoServer.getUri(); process.env.PORT = '0'; // Use a random available port diff --git a/api/server/middleware/assistants/validate.js b/api/server/middleware/assistants/validate.js index a98e8e227..a09f1ee00 100644 --- a/api/server/middleware/assistants/validate.js +++ b/api/server/middleware/assistants/validate.js @@ -12,8 +12,9 @@ const { handleAbortError } = require('~/server/middleware/abortMiddleware'); const validateAssistant = async (req, res, next) => { const { endpoint, conversationId, assistant_id, messageId } = req.body; + const appConfig = req.config; /** @type {Partial} */ - const assistantsConfig = req.app.locals?.[endpoint]; + const assistantsConfig = appConfig.endpoints?.[endpoint]; if (!assistantsConfig) { return next(); } diff --git a/api/server/middleware/assistants/validateAuthor.js b/api/server/middleware/assistants/validateAuthor.js index a17448211..03936444e 100644 --- a/api/server/middleware/assistants/validateAuthor.js +++ b/api/server/middleware/assistants/validateAuthor.js @@ -20,8 +20,9 @@ const validateAuthor = async ({ req, openai, overrideEndpoint, overrideAssistant const assistant_id = overrideAssistantId ?? req.params.id ?? req.body.assistant_id ?? req.query.assistant_id; + const appConfig = req.config; /** @type {Partial} */ - const assistantsConfig = req.app.locals?.[endpoint]; + const assistantsConfig = appConfig.endpoints?.[endpoint]; if (!assistantsConfig) { return; } diff --git a/api/server/middleware/buildEndpointOption.js b/api/server/middleware/buildEndpointOption.js index a44fd4b75..6f554d95d 100644 --- a/api/server/middleware/buildEndpointOption.js +++ b/api/server/middleware/buildEndpointOption.js @@ -40,9 +40,10 @@ async function buildEndpointOption(req, res, next) { return handleError(res, { text: 'Error parsing conversation' }); } - if (req.app.locals.modelSpecs?.list && req.app.locals.modelSpecs?.enforce) { + const appConfig = req.config; + if (appConfig.modelSpecs?.list && appConfig.modelSpecs?.enforce) { /** @type {{ list: TModelSpec[] }}*/ - const { list } = req.app.locals.modelSpecs; + const { list } = appConfig.modelSpecs; const { spec } = parsedBody; if (!spec) { diff --git a/api/server/middleware/checkDomainAllowed.js b/api/server/middleware/checkDomainAllowed.js index f9af7558c..be3f737ad 100644 --- a/api/server/middleware/checkDomainAllowed.js +++ b/api/server/middleware/checkDomainAllowed.js @@ -1,5 +1,6 @@ +const { logger } = require('@librechat/data-schemas'); const { isEmailDomainAllowed } = require('~/server/services/domains'); -const { logger } = require('~/config'); +const { getAppConfig } = require('~/server/services/Config'); /** * Checks the domain's social login is allowed @@ -14,7 +15,10 @@ const { logger } = require('~/config'); */ const checkDomainAllowed = async (req, res, next = () => {}) => { const email = req?.user?.email; - if (email && !(await isEmailDomainAllowed(email))) { + const appConfig = await getAppConfig({ + role: req?.user?.role, + }); + if (email && !isEmailDomainAllowed(email, appConfig?.registration?.allowedDomains)) { logger.error(`[Social Login] [Social Login not allowed] [Email: ${email}]`); return res.redirect('/login'); } else { diff --git a/api/server/middleware/config/app.js b/api/server/middleware/config/app.js new file mode 100644 index 000000000..bca3c8f71 --- /dev/null +++ b/api/server/middleware/config/app.js @@ -0,0 +1,27 @@ +const { logger } = require('@librechat/data-schemas'); +const { getAppConfig } = require('~/server/services/Config'); + +const configMiddleware = async (req, res, next) => { + try { + const userRole = req.user?.role; + req.config = await getAppConfig({ role: userRole }); + + next(); + } catch (error) { + logger.error('Config middleware error:', { + error: error.message, + userRole: req.user?.role, + path: req.path, + }); + + try { + req.config = await getAppConfig(); + next(); + } catch (fallbackError) { + logger.error('Fallback config middleware error:', fallbackError); + next(fallbackError); + } + } +}; + +module.exports = configMiddleware; diff --git a/api/server/middleware/error.js b/api/server/middleware/error.js index db445c1d4..270663a46 100644 --- a/api/server/middleware/error.js +++ b/api/server/middleware/error.js @@ -82,7 +82,7 @@ const sendError = async (req, res, options, callback) => { /** * Sends the response based on whether headers have been sent or not. - * @param {Express.Request} req - The server response. + * @param {ServerRequest} req - The server response. * @param {Express.Response} res - The server response. * @param {Object} data - The data to be sent. * @param {string} [errorMessage] - The error message, if any. diff --git a/api/server/middleware/index.js b/api/server/middleware/index.js index 16594b4d0..9a54f2df9 100644 --- a/api/server/middleware/index.js +++ b/api/server/middleware/index.js @@ -13,6 +13,7 @@ const requireLdapAuth = require('./requireLdapAuth'); const abortMiddleware = require('./abortMiddleware'); const checkInviteUser = require('./checkInviteUser'); const requireJwtAuth = require('./requireJwtAuth'); +const configMiddleware = require('./config/app'); const validateModel = require('./validateModel'); const moderateText = require('./moderateText'); const logHeaders = require('./logHeaders'); @@ -43,6 +44,7 @@ module.exports = { requireLocalAuth, canDeleteAccount, validateEndpoint, + configMiddleware, concurrentLimiter, checkDomainAllowed, validateMessageReq, diff --git a/api/server/middleware/spec/validateImages.spec.js b/api/server/middleware/spec/validateImages.spec.js index 8b04ac931..a2321534f 100644 --- a/api/server/middleware/spec/validateImages.spec.js +++ b/api/server/middleware/spec/validateImages.spec.js @@ -1,13 +1,18 @@ const jwt = require('jsonwebtoken'); const validateImageRequest = require('~/server/middleware/validateImageRequest'); +jest.mock('~/server/services/Config/app', () => ({ + getAppConfig: jest.fn(), +})); + describe('validateImageRequest middleware', () => { let req, res, next; const validObjectId = '65cfb246f7ecadb8b1e8036b'; + const { getAppConfig } = require('~/server/services/Config/app'); beforeEach(() => { + jest.clearAllMocks(); req = { - app: { locals: { secureImageLinks: true } }, headers: {}, originalUrl: '', }; @@ -17,79 +22,86 @@ describe('validateImageRequest middleware', () => { }; next = jest.fn(); process.env.JWT_REFRESH_SECRET = 'test-secret'; + + // Mock getAppConfig to return secureImageLinks: true by default + getAppConfig.mockResolvedValue({ + secureImageLinks: true, + }); }); afterEach(() => { jest.clearAllMocks(); }); - test('should call next() if secureImageLinks is false', () => { - req.app.locals.secureImageLinks = false; - validateImageRequest(req, res, next); + test('should call next() if secureImageLinks is false', async () => { + getAppConfig.mockResolvedValue({ + secureImageLinks: false, + }); + await validateImageRequest(req, res, next); expect(next).toHaveBeenCalled(); }); - test('should return 401 if refresh token is not provided', () => { - validateImageRequest(req, res, next); + test('should return 401 if refresh token is not provided', async () => { + await validateImageRequest(req, res, next); expect(res.status).toHaveBeenCalledWith(401); expect(res.send).toHaveBeenCalledWith('Unauthorized'); }); - test('should return 403 if refresh token is invalid', () => { + test('should return 403 if refresh token is invalid', async () => { req.headers.cookie = 'refreshToken=invalid-token'; - validateImageRequest(req, res, next); + await validateImageRequest(req, res, next); expect(res.status).toHaveBeenCalledWith(403); expect(res.send).toHaveBeenCalledWith('Access Denied'); }); - test('should return 403 if refresh token is expired', () => { + test('should return 403 if refresh token is expired', async () => { const expiredToken = jwt.sign( { id: validObjectId, exp: Math.floor(Date.now() / 1000) - 3600 }, process.env.JWT_REFRESH_SECRET, ); req.headers.cookie = `refreshToken=${expiredToken}`; - validateImageRequest(req, res, next); + await validateImageRequest(req, res, next); expect(res.status).toHaveBeenCalledWith(403); expect(res.send).toHaveBeenCalledWith('Access Denied'); }); - test('should call next() for valid image path', () => { + test('should call next() for valid image path', async () => { const validToken = jwt.sign( { id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 }, process.env.JWT_REFRESH_SECRET, ); req.headers.cookie = `refreshToken=${validToken}`; req.originalUrl = `/images/${validObjectId}/example.jpg`; - validateImageRequest(req, res, next); + await validateImageRequest(req, res, next); expect(next).toHaveBeenCalled(); }); - test('should return 403 for invalid image path', () => { + test('should return 403 for invalid image path', async () => { const validToken = jwt.sign( { id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 }, process.env.JWT_REFRESH_SECRET, ); req.headers.cookie = `refreshToken=${validToken}`; req.originalUrl = '/images/65cfb246f7ecadb8b1e8036c/example.jpg'; // Different ObjectId - validateImageRequest(req, res, next); + await validateImageRequest(req, res, next); expect(res.status).toHaveBeenCalledWith(403); expect(res.send).toHaveBeenCalledWith('Access Denied'); }); - test('should return 403 for invalid ObjectId format', () => { + test('should return 403 for invalid ObjectId format', async () => { const validToken = jwt.sign( { id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 }, process.env.JWT_REFRESH_SECRET, ); req.headers.cookie = `refreshToken=${validToken}`; req.originalUrl = '/images/123/example.jpg'; // Invalid ObjectId - validateImageRequest(req, res, next); + await validateImageRequest(req, res, next); expect(res.status).toHaveBeenCalledWith(403); expect(res.send).toHaveBeenCalledWith('Access Denied'); }); // File traversal tests - test('should prevent file traversal attempts', () => { + test('should prevent file traversal attempts', async () => { const validToken = jwt.sign( { id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 }, process.env.JWT_REFRESH_SECRET, @@ -103,23 +115,23 @@ describe('validateImageRequest middleware', () => { `/images/${validObjectId}/%2e%2e%2f%2e%2e%2f%2e%2e%2fetc%2fpasswd`, ]; - traversalAttempts.forEach((attempt) => { + for (const attempt of traversalAttempts) { req.originalUrl = attempt; - validateImageRequest(req, res, next); + await validateImageRequest(req, res, next); expect(res.status).toHaveBeenCalledWith(403); expect(res.send).toHaveBeenCalledWith('Access Denied'); jest.clearAllMocks(); - }); + } }); - test('should handle URL encoded characters in valid paths', () => { + test('should handle URL encoded characters in valid paths', async () => { const validToken = jwt.sign( { id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 }, process.env.JWT_REFRESH_SECRET, ); req.headers.cookie = `refreshToken=${validToken}`; req.originalUrl = `/images/${validObjectId}/image%20with%20spaces.jpg`; - validateImageRequest(req, res, next); + await validateImageRequest(req, res, next); expect(next).toHaveBeenCalled(); }); }); diff --git a/api/server/middleware/validate/convoAccess.js b/api/server/middleware/validate/convoAccess.js index afd2aeace..ffee70ae6 100644 --- a/api/server/middleware/validate/convoAccess.js +++ b/api/server/middleware/validate/convoAccess.js @@ -15,7 +15,7 @@ const { USE_REDIS, CONVO_ACCESS_VIOLATION_SCORE: score = 0 } = process.env ?? {} * If the `cache` store is not available, the middleware will skip its logic. * * @function - * @param {Express.Request} req - Express request object containing user information. + * @param {ServerRequest} req - Express request object containing user information. * @param {Express.Response} res - Express response object. * @param {function} next - Express next middleware function. * @throws {Error} Throws an error if the user doesn't have access to the conversation. diff --git a/api/server/middleware/validateImageRequest.js b/api/server/middleware/validateImageRequest.js index eb37b9dbb..41c258c46 100644 --- a/api/server/middleware/validateImageRequest.js +++ b/api/server/middleware/validateImageRequest.js @@ -1,6 +1,7 @@ const cookies = require('cookie'); const jwt = require('jsonwebtoken'); -const { logger } = require('~/config'); +const { logger } = require('@librechat/data-schemas'); +const { getAppConfig } = require('~/server/services/Config/app'); const OBJECT_ID_LENGTH = 24; const OBJECT_ID_PATTERN = /^[0-9a-f]{24}$/i; @@ -24,8 +25,9 @@ function isValidObjectId(id) { * Middleware to validate image request. * Must be set by `secureImageLinks` via custom config file. */ -function validateImageRequest(req, res, next) { - if (!req.app.locals.secureImageLinks) { +async function validateImageRequest(req, res, next) { + const appConfig = await getAppConfig({ role: req.user?.role }); + if (!appConfig.secureImageLinks) { return next(); } diff --git a/api/server/middleware/validateModel.js b/api/server/middleware/validateModel.js index 09d083fa6..40f6e67bf 100644 --- a/api/server/middleware/validateModel.js +++ b/api/server/middleware/validateModel.js @@ -6,7 +6,7 @@ const { logViolation } = require('~/cache'); * Validates the model of the request. * * @async - * @param {Express.Request} req - The Express request object. + * @param {ServerRequest} req - The Express request object. * @param {Express.Response} res - The Express response object. * @param {Function} next - The Express next function. */ diff --git a/api/server/routes/agents/actions.js b/api/server/routes/agents/actions.js index c3a41bc77..2a0629235 100644 --- a/api/server/routes/agents/actions.js +++ b/api/server/routes/agents/actions.js @@ -83,7 +83,11 @@ router.post( } let metadata = await encryptMetadata(removeNullishValues(_metadata, true)); - const isDomainAllowed = await isActionDomainAllowed(metadata.domain); + const appConfig = req.config; + const isDomainAllowed = await isActionDomainAllowed( + metadata.domain, + appConfig?.actions?.allowedDomains, + ); if (!isDomainAllowed) { return res.status(400).json({ message: 'Domain not allowed' }); } diff --git a/api/server/routes/agents/index.js b/api/server/routes/agents/index.js index 1c4f69d9a..a1cf5f751 100644 --- a/api/server/routes/agents/index.js +++ b/api/server/routes/agents/index.js @@ -4,6 +4,7 @@ const { checkBan, requireJwtAuth, messageIpLimiter, + configMiddleware, concurrentLimiter, messageUserLimiter, } = require('~/server/middleware'); @@ -22,6 +23,8 @@ router.use(uaParser); router.use('/', v1); const chatRouter = express.Router(); +chatRouter.use(configMiddleware); + if (isEnabled(LIMIT_CONCURRENT_MESSAGES)) { chatRouter.use(concurrentLimiter); } @@ -37,6 +40,4 @@ if (isEnabled(LIMIT_MESSAGE_USER)) { chatRouter.use('/', chat); router.use('/chat', chatRouter); -// Add marketplace routes - module.exports = router; diff --git a/api/server/routes/agents/tools.js b/api/server/routes/agents/tools.js index 8e498b1db..ca512e98c 100644 --- a/api/server/routes/agents/tools.js +++ b/api/server/routes/agents/tools.js @@ -1,7 +1,7 @@ const express = require('express'); const { callTool, verifyToolAuth, getToolCalls } = require('~/server/controllers/tools'); const { getAvailableTools } = require('~/server/controllers/PluginController'); -const { toolCallLimiter } = require('~/server/middleware/limiters'); +const { toolCallLimiter } = require('~/server/middleware'); const router = express.Router(); diff --git a/api/server/routes/agents/v1.js b/api/server/routes/agents/v1.js index ad197b756..ef0535c4d 100644 --- a/api/server/routes/agents/v1.js +++ b/api/server/routes/agents/v1.js @@ -1,7 +1,7 @@ const express = require('express'); const { generateCheckAccess } = require('@librechat/api'); const { PermissionTypes, Permissions, PermissionBits } = require('librechat-data-provider'); -const { requireJwtAuth, canAccessAgentResource } = require('~/server/middleware'); +const { requireJwtAuth, configMiddleware, canAccessAgentResource } = require('~/server/middleware'); const v1 = require('~/server/controllers/agents/v1'); const { getRoleByName } = require('~/models/Role'); const actions = require('./actions'); @@ -36,13 +36,13 @@ router.use(requireJwtAuth); * Agent actions route. * @route GET|POST /agents/actions */ -router.use('/actions', actions); +router.use('/actions', configMiddleware, actions); /** * Get a list of available tools for agents. * @route GET /agents/tools */ -router.use('/tools', tools); +router.use('/tools', configMiddleware, tools); /** * Get all agent categories with counts diff --git a/api/server/routes/assistants/actions.js b/api/server/routes/assistants/actions.js index 3dc392350..1853b5c10 100644 --- a/api/server/routes/assistants/actions.js +++ b/api/server/routes/assistants/actions.js @@ -1,12 +1,12 @@ const express = require('express'); const { nanoid } = require('nanoid'); +const { logger } = require('@librechat/data-schemas'); const { actionDelimiter, EModelEndpoint, removeNullishValues } = require('librechat-data-provider'); const { encryptMetadata, domainParser } = require('~/server/services/ActionService'); const { getOpenAIClient } = require('~/server/controllers/assistants/helpers'); const { updateAction, getActions, deleteAction } = require('~/models/Action'); const { updateAssistantDoc, getAssistant } = require('~/models/Assistant'); const { isActionDomainAllowed } = require('~/server/services/domains'); -const { logger } = require('~/config'); const router = express.Router(); @@ -21,6 +21,7 @@ const router = express.Router(); */ router.post('/:assistant_id', async (req, res) => { try { + const appConfig = req.config; const { assistant_id } = req.params; /** @type {{ functions: FunctionTool[], action_id: string, metadata: ActionMetadata }} */ @@ -30,7 +31,10 @@ router.post('/:assistant_id', async (req, res) => { } let metadata = await encryptMetadata(removeNullishValues(_metadata, true)); - const isDomainAllowed = await isActionDomainAllowed(metadata.domain); + const isDomainAllowed = await isActionDomainAllowed( + metadata.domain, + appConfig?.actions?.allowedDomains, + ); if (!isDomainAllowed) { return res.status(400).json({ message: 'Domain not allowed' }); } @@ -125,7 +129,7 @@ router.post('/:assistant_id', async (req, res) => { } /* Map Azure OpenAI model to the assistant as defined by config */ - if (req.app.locals[EModelEndpoint.azureOpenAI]?.assistants) { + if (appConfig.endpoints?.[EModelEndpoint.azureOpenAI]?.assistants) { updatedAssistant = { ...updatedAssistant, model: req.body.model, diff --git a/api/server/routes/assistants/index.js b/api/server/routes/assistants/index.js index e4408b2fe..6251f394f 100644 --- a/api/server/routes/assistants/index.js +++ b/api/server/routes/assistants/index.js @@ -1,6 +1,6 @@ const express = require('express'); +const { uaParser, checkBan, requireJwtAuth, configMiddleware } = require('~/server/middleware'); const router = express.Router(); -const { uaParser, checkBan, requireJwtAuth } = require('~/server/middleware'); const { v1 } = require('./v1'); const chatV1 = require('./chatV1'); @@ -10,6 +10,7 @@ const chatV2 = require('./chatV2'); router.use(requireJwtAuth); router.use(checkBan); router.use(uaParser); +router.use(configMiddleware); router.use('/v1/', v1); router.use('/v1/chat', chatV1); router.use('/v2/', v2); diff --git a/api/server/routes/assistants/v2.js b/api/server/routes/assistants/v2.js index e7c0d8476..303725607 100644 --- a/api/server/routes/assistants/v2.js +++ b/api/server/routes/assistants/v2.js @@ -1,4 +1,5 @@ const express = require('express'); +const { configMiddleware } = require('~/server/middleware'); const v1 = require('~/server/controllers/assistants/v1'); const v2 = require('~/server/controllers/assistants/v2'); const documents = require('./documents'); @@ -6,6 +7,7 @@ const actions = require('./actions'); const tools = require('./tools'); const router = express.Router(); +router.use(configMiddleware); /** * Assistant actions route. diff --git a/api/server/routes/auth.js b/api/server/routes/auth.js index 62bbcd7f2..e84442f65 100644 --- a/api/server/routes/auth.js +++ b/api/server/routes/auth.js @@ -17,12 +17,12 @@ const { const { verify2FAWithTempToken } = require('~/server/controllers/auth/TwoFactorAuthController'); const { logoutController } = require('~/server/controllers/auth/LogoutController'); const { loginController } = require('~/server/controllers/auth/LoginController'); -const { getBalanceConfig } = require('~/server/services/Config'); +const { getAppConfig } = require('~/server/services/Config'); const middleware = require('~/server/middleware'); const { Balance } = require('~/db/models'); const setBalanceConfig = createSetBalanceConfig({ - getBalanceConfig, + getAppConfig, Balance, }); diff --git a/api/server/routes/config.js b/api/server/routes/config.js index 2abc4b550..9b1c3eee5 100644 --- a/api/server/routes/config.js +++ b/api/server/routes/config.js @@ -1,9 +1,14 @@ const express = require('express'); -const { isEnabled } = require('@librechat/api'); const { logger } = require('@librechat/data-schemas'); -const { CacheKeys, defaultSocialLogins, Constants } = require('librechat-data-provider'); -const { getCustomConfig } = require('~/server/services/Config/getCustomConfig'); +const { isEnabled, getBalanceConfig } = require('@librechat/api'); +const { + Constants, + CacheKeys, + removeNullishValues, + defaultSocialLogins, +} = require('librechat-data-provider'); const { getLdapConfig } = require('~/server/services/Config/ldap'); +const { getAppConfig } = require('~/server/services/Config/app'); const { getProjectByName } = require('~/models/Project'); const { getMCPManager } = require('~/config'); const { getLogStores } = require('~/cache'); @@ -43,6 +48,8 @@ router.get('/', async function (req, res) { const ldap = getLdapConfig(); try { + const appConfig = await getAppConfig({ role: req.user?.role }); + const isOpenIdEnabled = !!process.env.OPENID_CLIENT_ID && !!process.env.OPENID_CLIENT_SECRET && @@ -55,10 +62,12 @@ router.get('/', async function (req, res) { !!process.env.SAML_CERT && !!process.env.SAML_SESSION_SECRET; + const balanceConfig = getBalanceConfig(appConfig); + /** @type {TStartupConfig} */ const payload = { appTitle: process.env.APP_TITLE || 'LibreChat', - socialLogins: req.app.locals.socialLogins ?? defaultSocialLogins, + socialLogins: appConfig?.registration?.socialLogins ?? defaultSocialLogins, discordLoginEnabled: !!process.env.DISCORD_CLIENT_ID && !!process.env.DISCORD_CLIENT_SECRET, facebookLoginEnabled: !!process.env.FACEBOOK_CLIENT_ID && !!process.env.FACEBOOK_CLIENT_SECRET, @@ -91,10 +100,10 @@ router.get('/', async function (req, res) { isEnabled(process.env.SHOW_BIRTHDAY_ICON) || process.env.SHOW_BIRTHDAY_ICON === '', helpAndFaqURL: process.env.HELP_AND_FAQ_URL || 'https://librechat.ai', - interface: req.app.locals.interfaceConfig, - turnstile: req.app.locals.turnstileConfig, - modelSpecs: req.app.locals.modelSpecs, - balance: req.app.locals.balance, + interface: appConfig?.interfaceConfig, + turnstile: appConfig?.turnstileConfig, + modelSpecs: appConfig?.modelSpecs, + balance: balanceConfig, sharedLinksEnabled, publicSharedLinksEnabled, analyticsGtmId: process.env.ANALYTICS_GTM_ID, @@ -109,27 +118,31 @@ router.get('/', async function (req, res) { }; payload.mcpServers = {}; - const config = await getCustomConfig(); - if (config?.mcpServers != null) { + const getMCPServers = () => { try { const mcpManager = getMCPManager(); + if (!mcpManager) { + return; + } + const mcpServers = mcpManager.getAllServers(); + if (!mcpServers) return; const oauthServers = mcpManager.getOAuthServers(); - for (const serverName in config.mcpServers) { - const serverConfig = config.mcpServers[serverName]; - payload.mcpServers[serverName] = { + for (const serverName in mcpServers) { + const serverConfig = mcpServers[serverName]; + payload.mcpServers[serverName] = removeNullishValues({ startup: serverConfig?.startup, chatMenu: serverConfig?.chatMenu, isOAuth: oauthServers?.has(serverName), - customUserVars: serverConfig?.customUserVars || {}, - }; + customUserVars: serverConfig?.customUserVars, + }); } - } catch (err) { - logger.error('Error loading MCP servers', err); + } catch (error) { + logger.error('Error loading MCP servers', error); } - } + }; - /** @type {TCustomConfig['webSearch']} */ - const webSearchConfig = req.app.locals.webSearch; + getMCPServers(); + const webSearchConfig = appConfig?.webSearch; if ( webSearchConfig != null && (webSearchConfig.searchProvider || diff --git a/api/server/routes/endpoints.js b/api/server/routes/endpoints.js index 5e4405faa..794abde0c 100644 --- a/api/server/routes/endpoints.js +++ b/api/server/routes/endpoints.js @@ -1,9 +1,7 @@ const express = require('express'); -const router = express.Router(); const endpointController = require('~/server/controllers/EndpointController'); -const overrideController = require('~/server/controllers/OverrideController'); +const router = express.Router(); router.get('/', endpointController); -router.get('/config/override', overrideController); module.exports = router; diff --git a/api/server/routes/files/avatar.js b/api/server/routes/files/avatar.js index 23d90a4f3..f5c937917 100644 --- a/api/server/routes/files/avatar.js +++ b/api/server/routes/files/avatar.js @@ -1,15 +1,16 @@ const fs = require('fs').promises; const express = require('express'); +const { logger } = require('@librechat/data-schemas'); const { getStrategyFunctions } = require('~/server/services/Files/strategies'); const { resizeAvatar } = require('~/server/services/Files/images/avatar'); -const { filterFile } = require('~/server/services/Files/process'); const { getFileStrategy } = require('~/server/utils/getFileStrategy'); -const { logger } = require('~/config'); +const { filterFile } = require('~/server/services/Files/process'); const router = express.Router(); router.post('/', async (req, res) => { try { + const appConfig = req.config; filterFile({ req, file: req.file, image: true, isAvatar: true }); const userId = req.user.id; const { manual } = req.body; @@ -19,8 +20,8 @@ router.post('/', async (req, res) => { throw new Error('User ID is undefined'); } - const fileStrategy = getFileStrategy(req.app.locals, { isAvatar: true }); - const desiredFormat = req.app.locals.imageOutputType; + const fileStrategy = getFileStrategy(appConfig, { isAvatar: true }); + const desiredFormat = appConfig.imageOutputType; const resizedBuffer = await resizeAvatar({ userId, input, @@ -39,7 +40,7 @@ router.post('/', async (req, res) => { try { await fs.unlink(req.file.path); logger.debug('[/files/images/avatar] Temp. image upload file deleted'); - } catch (error) { + } catch { logger.debug('[/files/images/avatar] Temp. image upload file already deleted'); } } diff --git a/api/server/routes/files/files.js b/api/server/routes/files/files.js index 1708d9953..611abf9ba 100644 --- a/api/server/routes/files/files.js +++ b/api/server/routes/files/files.js @@ -36,8 +36,9 @@ const router = express.Router(); router.get('/', async (req, res) => { try { + const appConfig = req.config; const files = await getFiles({ user: req.user.id }); - if (req.app.locals.fileStrategy === FileSources.s3) { + if (appConfig.fileStrategy === FileSources.s3) { try { const cache = getLogStores(CacheKeys.S3_EXPIRY_INTERVAL); const alreadyChecked = await cache.get(req.user.id); @@ -114,7 +115,8 @@ router.get('/agent/:agent_id', async (req, res) => { router.get('/config', async (req, res) => { try { - res.status(200).json(req.app.locals.fileConfig); + const appConfig = req.config; + res.status(200).json(appConfig.fileConfig); } catch (error) { logger.error('[/files] Error getting fileConfig', error); res.status(400).json({ message: 'Error in request', error: error.message }); diff --git a/api/server/routes/files/images.js b/api/server/routes/files/images.js index d6d04446f..a6a5369bc 100644 --- a/api/server/routes/files/images.js +++ b/api/server/routes/files/images.js @@ -1,18 +1,19 @@ const path = require('path'); const fs = require('fs').promises; const express = require('express'); +const { logger } = require('@librechat/data-schemas'); const { isAgentsEndpoint } = require('librechat-data-provider'); const { filterFile, processImageFile, processAgentFileUpload, } = require('~/server/services/Files/process'); -const { logger } = require('~/config'); const router = express.Router(); router.post('/', async (req, res) => { const metadata = req.body; + const appConfig = req.config; try { filterFile({ req, image: true }); @@ -30,7 +31,7 @@ router.post('/', async (req, res) => { logger.error('[/files/images] Error processing file:', error); try { const filepath = path.join( - req.app.locals.paths.imageOutput, + appConfig.paths.imageOutput, req.user.id, path.basename(req.file.filename), ); @@ -43,7 +44,7 @@ router.post('/', async (req, res) => { try { await fs.unlink(req.file.path); logger.debug('[/files/images] Temp. image upload file deleted'); - } catch (error) { + } catch { logger.debug('[/files/images] Temp. image upload file already deleted'); } } diff --git a/api/server/routes/files/index.js b/api/server/routes/files/index.js index 2004b97e4..d2f6126fe 100644 --- a/api/server/routes/files/index.js +++ b/api/server/routes/files/index.js @@ -1,5 +1,11 @@ const express = require('express'); -const { uaParser, checkBan, requireJwtAuth, createFileLimiters } = require('~/server/middleware'); +const { + createFileLimiters, + configMiddleware, + requireJwtAuth, + uaParser, + checkBan, +} = require('~/server/middleware'); const { avatar: asstAvatarRouter } = require('~/server/routes/assistants/v1'); const { avatar: agentAvatarRouter } = require('~/server/routes/agents/v1'); const { createMulterInstance } = require('./multer'); @@ -12,6 +18,7 @@ const speech = require('./speech'); const initialize = async () => { const router = express.Router(); router.use(requireJwtAuth); + router.use(configMiddleware); router.use(checkBan); router.use(uaParser); diff --git a/api/server/routes/files/multer.js b/api/server/routes/files/multer.js index 257c309fa..3632f34a2 100644 --- a/api/server/routes/files/multer.js +++ b/api/server/routes/files/multer.js @@ -4,11 +4,12 @@ const crypto = require('crypto'); const multer = require('multer'); const { sanitizeFilename } = require('@librechat/api'); const { fileConfig: defaultFileConfig, mergeFileConfig } = require('librechat-data-provider'); -const { getCustomConfig } = require('~/server/services/Config'); +const { getAppConfig } = require('~/server/services/Config'); const storage = multer.diskStorage({ destination: function (req, file, cb) { - const outputPath = path.join(req.app.locals.paths.uploads, 'temp', req.user.id); + const appConfig = req.config; + const outputPath = path.join(appConfig.paths.uploads, 'temp', req.user.id); if (!fs.existsSync(outputPath)) { fs.mkdirSync(outputPath, { recursive: true }); } @@ -68,8 +69,8 @@ const createFileFilter = (customFileConfig) => { }; const createMulterInstance = async () => { - const customConfig = await getCustomConfig(); - const fileConfig = mergeFileConfig(customConfig?.fileConfig); + const appConfig = await getAppConfig(); + const fileConfig = mergeFileConfig(appConfig?.fileConfig); const fileFilter = createFileFilter(fileConfig); return multer({ storage, diff --git a/api/server/routes/files/multer.spec.js b/api/server/routes/files/multer.spec.js index 2fb9147ae..84b97fe78 100644 --- a/api/server/routes/files/multer.spec.js +++ b/api/server/routes/files/multer.spec.js @@ -8,21 +8,7 @@ const { createMulterInstance, storage, importFileFilter } = require('./multer'); // Mock only the config service that requires external dependencies jest.mock('~/server/services/Config', () => ({ - getCustomConfig: jest.fn(() => - Promise.resolve({ - fileConfig: { - endpoints: { - openAI: { - supportedMimeTypes: ['image/jpeg', 'image/png', 'application/pdf'], - }, - default: { - supportedMimeTypes: ['image/jpeg', 'image/png', 'text/plain'], - }, - }, - serverFileSizeLimit: 10000000, // 10MB - }, - }), - ), + getAppConfig: jest.fn(), })); describe('Multer Configuration', () => { @@ -36,15 +22,13 @@ describe('Multer Configuration', () => { mockReq = { user: { id: 'test-user-123' }, - app: { - locals: { - paths: { - uploads: tempDir, - }, - }, - }, body: {}, originalUrl: '/api/files/upload', + config: { + paths: { + uploads: tempDir, + }, + }, }; mockFile = { @@ -79,7 +63,7 @@ describe('Multer Configuration', () => { it("should create directory recursively if it doesn't exist", (done) => { const deepPath = path.join(tempDir, 'deep', 'nested', 'path'); - mockReq.app.locals.paths.uploads = deepPath; + mockReq.config.paths.uploads = deepPath; const cb = jest.fn((err, destination) => { expect(err).toBeNull(); @@ -331,11 +315,11 @@ describe('Multer Configuration', () => { }); it('should use real config merging', async () => { - const { getCustomConfig } = require('~/server/services/Config'); + const { getAppConfig } = require('~/server/services/Config'); const multerInstance = await createMulterInstance(); - expect(getCustomConfig).toHaveBeenCalled(); + expect(getAppConfig).toHaveBeenCalled(); expect(multerInstance).toBeDefined(); }); @@ -462,26 +446,15 @@ describe('Multer Configuration', () => { }).not.toThrow(); }); - it('should handle file system errors when directory creation fails', (done) => { + it('should handle file system errors when directory creation fails', () => { // Test with a non-existent parent directory to simulate fs issues const invalidPath = '/nonexistent/path/that/should/not/exist'; - mockReq.app.locals.paths.uploads = invalidPath; + mockReq.config.paths.uploads = invalidPath; - try { - // Call getDestination which should fail due to permission/path issues - storage.getDestination(mockReq, mockFile, (err, destination) => { - // If callback is reached, we didn't get the expected error - done(new Error('Expected mkdirSync to throw an error but callback was called')); - }); - // If we get here without throwing, something unexpected happened - done(new Error('Expected mkdirSync to throw an error but no error was thrown')); - } catch (error) { - // This is the expected behavior - mkdirSync throws synchronously for invalid paths - // On Linux, this typically returns EACCES (permission denied) - // On macOS/Darwin, this returns ENOENT (no such file or directory) - expect(['EACCES', 'ENOENT']).toContain(error.code); - done(); - } + // The current implementation doesn't catch errors, so they're thrown synchronously + expect(() => { + storage.getDestination(mockReq, mockFile, jest.fn()); + }).toThrow(); }); it('should handle malformed filenames with real sanitization', (done) => { @@ -538,10 +511,10 @@ describe('Multer Configuration', () => { describe('Real Configuration Testing', () => { it('should handle missing custom config gracefully with real mergeFileConfig', async () => { - const { getCustomConfig } = require('~/server/services/Config'); + const { getAppConfig } = require('~/server/services/Config'); - // Mock getCustomConfig to return undefined - getCustomConfig.mockResolvedValueOnce(undefined); + // Mock getAppConfig to return undefined + getAppConfig.mockResolvedValueOnce(undefined); const multerInstance = await createMulterInstance(); expect(multerInstance).toBeDefined(); @@ -549,25 +522,28 @@ describe('Multer Configuration', () => { }); it('should properly integrate real fileConfig with custom endpoints', async () => { - const { getCustomConfig } = require('~/server/services/Config'); + const { getAppConfig } = require('~/server/services/Config'); - // Mock a custom config with additional endpoints - getCustomConfig.mockResolvedValueOnce({ + // Mock appConfig with fileConfig + getAppConfig.mockResolvedValueOnce({ + paths: { + uploads: tempDir, + }, fileConfig: { endpoints: { anthropic: { supportedMimeTypes: ['text/plain', 'image/png'], }, }, - serverFileSizeLimit: 20, // 20 MB + serverFileSizeLimit: 20971520, // 20 MB in bytes (mergeFileConfig converts) }, }); const multerInstance = await createMulterInstance(); expect(multerInstance).toBeDefined(); - // Verify that getCustomConfig was called (we can't spy on the actual merge function easily) - expect(getCustomConfig).toHaveBeenCalled(); + // Verify that getAppConfig was called + expect(getAppConfig).toHaveBeenCalled(); }); }); }); diff --git a/api/server/routes/memories.js b/api/server/routes/memories.js index b596c89e8..58955d8ec 100644 --- a/api/server/routes/memories.js +++ b/api/server/routes/memories.js @@ -8,7 +8,7 @@ const { deleteMemory, setMemory, } = require('~/models'); -const { requireJwtAuth } = require('~/server/middleware'); +const { requireJwtAuth, configMiddleware } = require('~/server/middleware'); const { getRoleByName } = require('~/models/Role'); const router = express.Router(); @@ -48,7 +48,7 @@ router.use(requireJwtAuth); * Returns all memories for the authenticated user, sorted by updated_at (newest first). * Also includes memory usage percentage based on token limit. */ -router.get('/', checkMemoryRead, async (req, res) => { +router.get('/', checkMemoryRead, configMiddleware, async (req, res) => { try { const memories = await getAllUserMemories(req.user.id); @@ -60,7 +60,8 @@ router.get('/', checkMemoryRead, async (req, res) => { return sum + (memory.tokenCount || 0); }, 0); - const memoryConfig = req.app.locals?.memory; + const appConfig = req.config; + const memoryConfig = appConfig?.memory; const tokenLimit = memoryConfig?.tokenLimit; const charLimit = memoryConfig?.charLimit || 10000; @@ -87,7 +88,7 @@ router.get('/', checkMemoryRead, async (req, res) => { * Body: { key: string, value: string } * Returns 201 and { created: true, memory: } when successful. */ -router.post('/', memoryPayloadLimit, checkMemoryCreate, async (req, res) => { +router.post('/', memoryPayloadLimit, checkMemoryCreate, configMiddleware, async (req, res) => { const { key, value } = req.body; if (typeof key !== 'string' || key.trim() === '') { @@ -98,7 +99,8 @@ router.post('/', memoryPayloadLimit, checkMemoryCreate, async (req, res) => { return res.status(400).json({ error: 'Value is required and must be a non-empty string.' }); } - const memoryConfig = req.app.locals?.memory; + const appConfig = req.config; + const memoryConfig = appConfig?.memory; const charLimit = memoryConfig?.charLimit || 10000; if (key.length > 1000) { @@ -117,6 +119,9 @@ router.post('/', memoryPayloadLimit, checkMemoryCreate, async (req, res) => { const tokenCount = Tokenizer.getTokenCount(value, 'o200k_base'); const memories = await getAllUserMemories(req.user.id); + + const appConfig = req.config; + const memoryConfig = appConfig?.memory; const tokenLimit = memoryConfig?.tokenLimit; if (tokenLimit) { @@ -191,7 +196,7 @@ router.patch('/preferences', checkMemoryOptOut, async (req, res) => { * Body: { key?: string, value: string } * Returns 200 and { updated: true, memory: } when successful. */ -router.patch('/:key', memoryPayloadLimit, checkMemoryUpdate, async (req, res) => { +router.patch('/:key', memoryPayloadLimit, checkMemoryUpdate, configMiddleware, async (req, res) => { const { key: urlKey } = req.params; const { key: bodyKey, value } = req.body || {}; @@ -200,8 +205,8 @@ router.patch('/:key', memoryPayloadLimit, checkMemoryUpdate, async (req, res) => } const newKey = bodyKey || urlKey; - - const memoryConfig = req.app.locals?.memory; + const appConfig = req.config; + const memoryConfig = appConfig?.memory; const charLimit = memoryConfig?.charLimit || 10000; if (newKey.length > 1000) { diff --git a/api/server/routes/oauth.js b/api/server/routes/oauth.js index 666e5d2b5..ced8a0f54 100644 --- a/api/server/routes/oauth.js +++ b/api/server/routes/oauth.js @@ -8,11 +8,11 @@ const { isEnabled, createSetBalanceConfig } = require('@librechat/api'); const { checkDomainAllowed, loginLimiter, logHeaders, checkBan } = require('~/server/middleware'); const { syncUserEntraGroupMemberships } = require('~/server/services/PermissionService'); const { setAuthTokens, setOpenIDAuthTokens } = require('~/server/services/AuthService'); -const { getBalanceConfig } = require('~/server/services/Config'); +const { getAppConfig } = require('~/server/services/Config'); const { Balance } = require('~/db/models'); const setBalanceConfig = createSetBalanceConfig({ - getBalanceConfig, + getAppConfig, Balance, }); diff --git a/api/server/routes/plugins.js b/api/server/routes/plugins.js index 4a7715a61..00f3fca75 100644 --- a/api/server/routes/plugins.js +++ b/api/server/routes/plugins.js @@ -1,6 +1,6 @@ const express = require('express'); -const { getAvailablePluginsController } = require('../controllers/PluginController'); -const requireJwtAuth = require('../middleware/requireJwtAuth'); +const { getAvailablePluginsController } = require('~/server/controllers/PluginController'); +const { requireJwtAuth } = require('~/server/middleware'); const router = express.Router(); diff --git a/api/server/routes/prompts.js b/api/server/routes/prompts.js index 48d4819d8..46bdf697e 100644 --- a/api/server/routes/prompts.js +++ b/api/server/routes/prompts.js @@ -424,7 +424,7 @@ router.get('/', async (req, res) => { /** * Deletes a prompt * - * @param {Express.Request} req - The request object. + * @param {ServerRequest} req - The request object. * @param {TDeletePromptVariables} req.params - The request parameters * @param {import('mongoose').ObjectId} req.params.promptId - The prompt ID * @param {Express.Response} res - The response object. diff --git a/api/server/routes/prompts.test.js b/api/server/routes/prompts.test.js index 530d5d67f..29b806f62 100644 --- a/api/server/routes/prompts.test.js +++ b/api/server/routes/prompts.test.js @@ -14,7 +14,6 @@ const { // Mock modules before importing jest.mock('~/server/services/Config', () => ({ getCachedTools: jest.fn().mockResolvedValue({}), - getCustomConfig: jest.fn(), })); jest.mock('~/models/Role', () => ({ diff --git a/api/server/routes/user.js b/api/server/routes/user.js index 34d28fd93..05d4e850c 100644 --- a/api/server/routes/user.js +++ b/api/server/routes/user.js @@ -1,14 +1,14 @@ const express = require('express'); -const { requireJwtAuth, canDeleteAccount, verifyEmailLimiter } = require('~/server/middleware'); const { - getUserController, - deleteUserController, - verifyEmailController, updateUserPluginsController, resendVerificationController, getTermsStatusController, acceptTermsController, + verifyEmailController, + deleteUserController, + getUserController, } = require('~/server/controllers/UserController'); +const { requireJwtAuth, canDeleteAccount, verifyEmailLimiter } = require('~/server/middleware'); const router = express.Router(); diff --git a/api/server/services/ActionService.spec.js b/api/server/services/ActionService.spec.js index f3b442319..c60aef7ad 100644 --- a/api/server/services/ActionService.spec.js +++ b/api/server/services/ActionService.spec.js @@ -1,10 +1,7 @@ -const { Constants, EModelEndpoint, actionDomainSeparator } = require('librechat-data-provider'); +const { Constants, actionDomainSeparator } = require('librechat-data-provider'); const { domainParser } = require('./ActionService'); jest.mock('keyv'); -jest.mock('~/server/services/Config', () => ({ - getCustomConfig: jest.fn(), -})); const globalCache = {}; jest.mock('~/cache/getLogStores', () => { @@ -53,26 +50,6 @@ jest.mock('~/cache/getLogStores', () => { }); describe('domainParser', () => { - const req = { - app: { - locals: { - [EModelEndpoint.azureOpenAI]: { - assistants: true, - }, - }, - }, - }; - - const reqNoAzure = { - app: { - locals: { - [EModelEndpoint.azureOpenAI]: { - assistants: false, - }, - }, - }, - }; - const TLD = '.com'; // Non-azure request diff --git a/api/server/services/AppService.interface.spec.js b/api/server/services/AppService.interface.spec.js index 31c8d70f3..f4b1c67c3 100644 --- a/api/server/services/AppService.interface.spec.js +++ b/api/server/services/AppService.interface.spec.js @@ -1,15 +1,4 @@ -jest.mock('~/models', () => ({ - initializeRoles: jest.fn(), - seedDefaultRoles: jest.fn(), - ensureDefaultCategories: jest.fn(), -})); -jest.mock('~/models/Role', () => ({ - updateAccessPermissions: jest.fn(), - getRoleByName: jest.fn().mockResolvedValue(null), - updateRoleByName: jest.fn(), -})); - -jest.mock('~/config', () => ({ +jest.mock('@librechat/data-schemas', () => ({ logger: { info: jest.fn(), warn: jest.fn(), @@ -17,11 +6,11 @@ jest.mock('~/config', () => ({ }, })); -jest.mock('./Config/loadCustomConfig', () => jest.fn()); -jest.mock('./start/interface', () => ({ +jest.mock('@librechat/api', () => ({ + ...jest.requireActual('@librechat/api'), loadDefaultInterface: jest.fn(), })); -jest.mock('./ToolService', () => ({ +jest.mock('./start/tools', () => ({ loadAndFormatTools: jest.fn().mockReturnValue({}), })); jest.mock('./start/checks', () => ({ @@ -32,15 +21,15 @@ jest.mock('./start/checks', () => ({ checkWebSearchConfig: jest.fn(), })); +jest.mock('./Config/loadCustomConfig', () => jest.fn()); + const AppService = require('./AppService'); -const { loadDefaultInterface } = require('./start/interface'); +const { loadDefaultInterface } = require('@librechat/api'); describe('AppService interface configuration', () => { - let app; let mockLoadCustomConfig; beforeEach(() => { - app = { locals: {} }; jest.resetModules(); jest.clearAllMocks(); mockLoadCustomConfig = require('./Config/loadCustomConfig'); @@ -50,10 +39,16 @@ describe('AppService interface configuration', () => { mockLoadCustomConfig.mockResolvedValue({}); loadDefaultInterface.mockResolvedValue({ prompts: true, bookmarks: true }); - await AppService(app); + const result = await AppService(); - expect(app.locals.interfaceConfig.prompts).toBe(true); - expect(app.locals.interfaceConfig.bookmarks).toBe(true); + expect(result).toEqual( + expect.objectContaining({ + interfaceConfig: expect.objectContaining({ + prompts: true, + bookmarks: true, + }), + }), + ); expect(loadDefaultInterface).toHaveBeenCalled(); }); @@ -61,10 +56,16 @@ describe('AppService interface configuration', () => { mockLoadCustomConfig.mockResolvedValue({ interface: { prompts: false, bookmarks: false } }); loadDefaultInterface.mockResolvedValue({ prompts: false, bookmarks: false }); - await AppService(app); + const result = await AppService(); - expect(app.locals.interfaceConfig.prompts).toBe(false); - expect(app.locals.interfaceConfig.bookmarks).toBe(false); + expect(result).toEqual( + expect.objectContaining({ + interfaceConfig: expect.objectContaining({ + prompts: false, + bookmarks: false, + }), + }), + ); expect(loadDefaultInterface).toHaveBeenCalled(); }); @@ -72,10 +73,17 @@ describe('AppService interface configuration', () => { mockLoadCustomConfig.mockResolvedValue({}); loadDefaultInterface.mockResolvedValue({}); - await AppService(app); + const result = await AppService(); - expect(app.locals.interfaceConfig.prompts).toBeUndefined(); - expect(app.locals.interfaceConfig.bookmarks).toBeUndefined(); + expect(result).toEqual( + expect.objectContaining({ + interfaceConfig: expect.anything(), + }), + ); + + // Verify that prompts and bookmarks are undefined when not provided + expect(result.interfaceConfig.prompts).toBeUndefined(); + expect(result.interfaceConfig.bookmarks).toBeUndefined(); expect(loadDefaultInterface).toHaveBeenCalled(); }); @@ -83,10 +91,16 @@ describe('AppService interface configuration', () => { mockLoadCustomConfig.mockResolvedValue({ interface: { prompts: true, bookmarks: false } }); loadDefaultInterface.mockResolvedValue({ prompts: true, bookmarks: false }); - await AppService(app); + const result = await AppService(); - expect(app.locals.interfaceConfig.prompts).toBe(true); - expect(app.locals.interfaceConfig.bookmarks).toBe(false); + expect(result).toEqual( + expect.objectContaining({ + interfaceConfig: expect.objectContaining({ + prompts: true, + bookmarks: false, + }), + }), + ); expect(loadDefaultInterface).toHaveBeenCalled(); }); @@ -108,14 +122,19 @@ describe('AppService interface configuration', () => { }, }); - await AppService(app); + const result = await AppService(); - expect(app.locals.interfaceConfig.peoplePicker).toBeDefined(); - expect(app.locals.interfaceConfig.peoplePicker).toMatchObject({ - users: true, - groups: true, - roles: true, - }); + expect(result).toEqual( + expect.objectContaining({ + interfaceConfig: expect.objectContaining({ + peoplePicker: expect.objectContaining({ + users: true, + groups: true, + roles: true, + }), + }), + }), + ); expect(loadDefaultInterface).toHaveBeenCalled(); }); @@ -137,11 +156,19 @@ describe('AppService interface configuration', () => { }, }); - await AppService(app); + const result = await AppService(); - expect(app.locals.interfaceConfig.peoplePicker.users).toBe(true); - expect(app.locals.interfaceConfig.peoplePicker.groups).toBe(false); - expect(app.locals.interfaceConfig.peoplePicker.roles).toBe(true); + expect(result).toEqual( + expect.objectContaining({ + interfaceConfig: expect.objectContaining({ + peoplePicker: expect.objectContaining({ + users: true, + groups: false, + roles: true, + }), + }), + }), + ); }); it('should set default peoplePicker permissions when not provided', async () => { @@ -154,11 +181,18 @@ describe('AppService interface configuration', () => { }, }); - await AppService(app); + const result = await AppService(); - expect(app.locals.interfaceConfig.peoplePicker).toBeDefined(); - expect(app.locals.interfaceConfig.peoplePicker.users).toBe(true); - expect(app.locals.interfaceConfig.peoplePicker.groups).toBe(true); - expect(app.locals.interfaceConfig.peoplePicker.roles).toBe(true); + expect(result).toEqual( + expect.objectContaining({ + interfaceConfig: expect.objectContaining({ + peoplePicker: expect.objectContaining({ + users: true, + groups: true, + roles: true, + }), + }), + }), + ); }); }); diff --git a/api/server/services/AppService.js b/api/server/services/AppService.js index 1cb429b69..eaaf47894 100644 --- a/api/server/services/AppService.js +++ b/api/server/services/AppService.js @@ -3,6 +3,7 @@ const { loadMemoryConfig, agentsConfigSetup, loadWebSearchConfig, + loadDefaultInterface, } = require('@librechat/api'); const { FileSources, @@ -12,35 +13,26 @@ const { } = require('librechat-data-provider'); const { checkWebSearchConfig, - checkAzureVariables, checkVariables, checkHealth, checkConfig, } = require('./start/checks'); -const { ensureDefaultCategories, seedDefaultRoles, initializeRoles } = require('~/models'); -const { azureAssistantsDefaults, assistantsConfigSetup } = require('./start/assistants'); const { initializeAzureBlobService } = require('./Files/Azure/initialize'); const { initializeFirebase } = require('./Files/Firebase/initialize'); -const loadCustomConfig = require('./Config/loadCustomConfig'); const handleRateLimits = require('./Config/handleRateLimits'); -const { loadDefaultInterface } = require('./start/interface'); +const loadCustomConfig = require('./Config/loadCustomConfig'); const { loadTurnstileConfig } = require('./start/turnstile'); -const { azureConfigSetup } = require('./start/azureOpenAI'); const { processModelSpecs } = require('./start/modelSpecs'); const { initializeS3 } = require('./Files/S3/initialize'); -const { loadAndFormatTools } = require('./ToolService'); -const { setCachedTools } = require('./Config'); +const { loadAndFormatTools } = require('./start/tools'); +const { loadEndpoints } = require('./start/endpoints'); const paths = require('~/config/paths'); /** * Loads custom config and initializes app-wide variables. * @function AppService - * @param {Express.Application} app - The Express application object. */ -const AppService = async (app) => { - await initializeRoles(); - await seedDefaultRoles(); - await ensureDefaultCategories(); +const AppService = async () => { /** @type {TCustomConfig} */ const config = (await loadCustomConfig()) ?? {}; const configDefaults = getConfigDefaults(); @@ -79,101 +71,57 @@ const AppService = async (app) => { directory: paths.structuredTools, }); - await setCachedTools(availableTools, { isGlobal: true }); - - // Store MCP config for later initialization const mcpConfig = config.mcpServers || null; - - const socialLogins = - config?.registration?.socialLogins ?? configDefaults?.registration?.socialLogins; - const interfaceConfig = await loadDefaultInterface(config, configDefaults); + const registration = config.registration ?? configDefaults.registration; + const interfaceConfig = await loadDefaultInterface({ config, configDefaults }); const turnstileConfig = loadTurnstileConfig(config, configDefaults); + const speech = config.speech; - const defaultLocals = { - config, + const defaultConfig = { ocr, paths, + config, memory, + speech, + balance, + mcpConfig, webSearch, fileStrategy, - socialLogins, + registration, filteredTools, includedTools, + availableTools, imageOutputType, interfaceConfig, turnstileConfig, - balance, - mcpConfig, + fileStrategies: config.fileStrategies, }; const agentsDefaults = agentsConfigSetup(config); if (!Object.keys(config).length) { - app.locals = { - ...defaultLocals, - [EModelEndpoint.agents]: agentsDefaults, + const appConfig = { + ...defaultConfig, + endpoints: { + [EModelEndpoint.agents]: agentsDefaults, + }, }; - return; + return appConfig; } checkConfig(config); handleRateLimits(config?.rateLimits); + const loadedEndpoints = loadEndpoints(config, agentsDefaults); - const endpointLocals = {}; - const endpoints = config?.endpoints; - - if (endpoints?.[EModelEndpoint.azureOpenAI]) { - endpointLocals[EModelEndpoint.azureOpenAI] = azureConfigSetup(config); - checkAzureVariables(); - } - - if (endpoints?.[EModelEndpoint.azureOpenAI]?.assistants) { - endpointLocals[EModelEndpoint.azureAssistants] = azureAssistantsDefaults(); - } - - if (endpoints?.[EModelEndpoint.azureAssistants]) { - endpointLocals[EModelEndpoint.azureAssistants] = assistantsConfigSetup( - config, - EModelEndpoint.azureAssistants, - endpointLocals[EModelEndpoint.azureAssistants], - ); - } - - if (endpoints?.[EModelEndpoint.assistants]) { - endpointLocals[EModelEndpoint.assistants] = assistantsConfigSetup( - config, - EModelEndpoint.assistants, - endpointLocals[EModelEndpoint.assistants], - ); - } - - endpointLocals[EModelEndpoint.agents] = agentsConfigSetup(config, agentsDefaults); - - const endpointKeys = [ - EModelEndpoint.openAI, - EModelEndpoint.google, - EModelEndpoint.bedrock, - EModelEndpoint.anthropic, - EModelEndpoint.gptPlugins, - ]; - - endpointKeys.forEach((key) => { - if (endpoints?.[key]) { - endpointLocals[key] = endpoints[key]; - } - }); - - if (endpoints?.all) { - endpointLocals.all = endpoints.all; - } - - app.locals = { - ...defaultLocals, + const appConfig = { + ...defaultConfig, fileConfig: config?.fileConfig, secureImageLinks: config?.secureImageLinks, - modelSpecs: processModelSpecs(endpoints, config.modelSpecs, interfaceConfig), - ...endpointLocals, + modelSpecs: processModelSpecs(config?.endpoints, config.modelSpecs, interfaceConfig), + endpoints: loadedEndpoints, }; + + return appConfig; }; module.exports = AppService; diff --git a/api/server/services/AppService.spec.js b/api/server/services/AppService.spec.js index f3a32993f..972c685bf 100644 --- a/api/server/services/AppService.spec.js +++ b/api/server/services/AppService.spec.js @@ -10,10 +10,24 @@ const { conflictingAzureVariables, } = require('librechat-data-provider'); +jest.mock('@librechat/data-schemas', () => ({ + ...jest.requireActual('@librechat/data-schemas'), + logger: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }, +})); + const AppService = require('./AppService'); -jest.mock('./Config/loadCustomConfig', () => { - return jest.fn(() => +jest.mock('./Files/Firebase/initialize', () => ({ + initializeFirebase: jest.fn(), +})); + +jest.mock('./Config/loadCustomConfig', () => + jest.fn(() => Promise.resolve({ registration: { socialLogins: ['testLogin'] }, fileStrategy: 'testStrategy', @@ -21,40 +35,10 @@ jest.mock('./Config/loadCustomConfig', () => { enabled: true, }, }), - ); -}); -jest.mock('./Files/Firebase/initialize', () => ({ - initializeFirebase: jest.fn(), -})); -jest.mock('~/models', () => ({ - initializeRoles: jest.fn(), - seedDefaultRoles: jest.fn(), - ensureDefaultCategories: jest.fn(), -})); -jest.mock('~/models/Role', () => ({ - updateAccessPermissions: jest.fn(), - getRoleByName: jest.fn().mockResolvedValue(null), -})); -jest.mock('./Config', () => ({ - setCachedTools: jest.fn(), - getCachedTools: jest.fn().mockResolvedValue({ - ExampleTool: { - type: 'function', - function: { - description: 'Example tool function', - name: 'exampleFunction', - parameters: { - type: 'object', - properties: { - param1: { type: 'string', description: 'An example parameter' }, - }, - required: ['param1'], - }, - }, - }, - }), -})); -jest.mock('./ToolService', () => ({ + ), +); + +jest.mock('./start/tools', () => ({ loadAndFormatTools: jest.fn().mockReturnValue({ ExampleTool: { type: 'function', @@ -116,70 +100,81 @@ const azureGroups = [ }, ]; +jest.mock('./start/checks', () => ({ + ...jest.requireActual('./start/checks'), + checkHealth: jest.fn(), +})); + describe('AppService', () => { - let app; const mockedTurnstileConfig = { siteKey: 'default-site-key', options: {}, }; + const loadCustomConfig = require('./Config/loadCustomConfig'); beforeEach(() => { - app = { locals: {} }; process.env.CDN_PROVIDER = undefined; + jest.clearAllMocks(); }); - it('should correctly assign process.env and app.locals based on custom config', async () => { - await AppService(app); + it('should correctly assign process.env and initialize app config based on custom config', async () => { + const result = await AppService(); expect(process.env.CDN_PROVIDER).toEqual('testStrategy'); - expect(app.locals).toEqual({ - config: expect.objectContaining({ + expect(result).toEqual( + expect.objectContaining({ + config: expect.objectContaining({ + fileStrategy: 'testStrategy', + }), + registration: expect.objectContaining({ + socialLogins: ['testLogin'], + }), fileStrategy: 'testStrategy', + interfaceConfig: expect.objectContaining({ + endpointsMenu: true, + modelSelect: true, + parameters: true, + sidePanel: true, + presets: true, + }), + mcpConfig: null, + turnstileConfig: mockedTurnstileConfig, + modelSpecs: undefined, + paths: expect.anything(), + ocr: expect.anything(), + imageOutputType: expect.any(String), + fileConfig: undefined, + secureImageLinks: undefined, + balance: { enabled: true }, + filteredTools: undefined, + includedTools: undefined, + webSearch: expect.objectContaining({ + safeSearch: 1, + jinaApiKey: '${JINA_API_KEY}', + cohereApiKey: '${COHERE_API_KEY}', + serperApiKey: '${SERPER_API_KEY}', + searxngApiKey: '${SEARXNG_API_KEY}', + firecrawlApiKey: '${FIRECRAWL_API_KEY}', + firecrawlApiUrl: '${FIRECRAWL_API_URL}', + searxngInstanceUrl: '${SEARXNG_INSTANCE_URL}', + }), + memory: undefined, + endpoints: expect.objectContaining({ + agents: expect.objectContaining({ + disableBuilder: false, + capabilities: expect.arrayContaining([...defaultAgentCapabilities]), + maxCitations: 30, + maxCitationsPerFile: 7, + minRelevanceScore: 0.45, + }), + }), }), - socialLogins: ['testLogin'], - fileStrategy: 'testStrategy', - interfaceConfig: expect.objectContaining({ - endpointsMenu: true, - modelSelect: true, - parameters: true, - sidePanel: true, - presets: true, - }), - mcpConfig: null, - turnstileConfig: mockedTurnstileConfig, - modelSpecs: undefined, - paths: expect.anything(), - ocr: expect.anything(), - imageOutputType: expect.any(String), - fileConfig: undefined, - secureImageLinks: undefined, - balance: { enabled: true }, - filteredTools: undefined, - includedTools: undefined, - webSearch: { - safeSearch: 1, - jinaApiKey: '${JINA_API_KEY}', - cohereApiKey: '${COHERE_API_KEY}', - serperApiKey: '${SERPER_API_KEY}', - searxngApiKey: '${SEARXNG_API_KEY}', - firecrawlApiKey: '${FIRECRAWL_API_KEY}', - firecrawlApiUrl: '${FIRECRAWL_API_URL}', - searxngInstanceUrl: '${SEARXNG_INSTANCE_URL}', - }, - memory: undefined, - agents: { - disableBuilder: false, - capabilities: expect.arrayContaining([...defaultAgentCapabilities]), - maxCitations: 30, - maxCitationsPerFile: 7, - minRelevanceScore: 0.45, - }, - }); + ); }); it('should log a warning if the config version is outdated', async () => { - require('./Config/loadCustomConfig').mockImplementationOnce(() => + loadCustomConfig.mockImplementationOnce(() => Promise.resolve({ version: '0.9.0', // An outdated version for this test registration: { socialLogins: ['testLogin'] }, @@ -187,50 +182,62 @@ describe('AppService', () => { }), ); - await AppService(app); + await AppService(); - const { logger } = require('~/config'); + const { logger } = require('@librechat/data-schemas'); expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('Outdated Config version')); }); it('should change the `imageOutputType` based on config value', async () => { - require('./Config/loadCustomConfig').mockImplementationOnce(() => + loadCustomConfig.mockImplementationOnce(() => Promise.resolve({ version: '0.10.0', imageOutputType: EImageOutputType.WEBP, }), ); - await AppService(app); - expect(app.locals.imageOutputType).toEqual(EImageOutputType.WEBP); + const result = await AppService(); + expect(result).toEqual( + expect.objectContaining({ + imageOutputType: EImageOutputType.WEBP, + }), + ); }); it('should default to `PNG` `imageOutputType` with no provided type', async () => { - require('./Config/loadCustomConfig').mockImplementationOnce(() => + loadCustomConfig.mockImplementationOnce(() => Promise.resolve({ version: '0.10.0', }), ); - await AppService(app); - expect(app.locals.imageOutputType).toEqual(EImageOutputType.PNG); + const result = await AppService(); + expect(result).toEqual( + expect.objectContaining({ + imageOutputType: EImageOutputType.PNG, + }), + ); }); it('should default to `PNG` `imageOutputType` with no provided config', async () => { - require('./Config/loadCustomConfig').mockImplementationOnce(() => Promise.resolve(undefined)); + loadCustomConfig.mockImplementationOnce(() => Promise.resolve(undefined)); - await AppService(app); - expect(app.locals.imageOutputType).toEqual(EImageOutputType.PNG); + const result = await AppService(); + expect(result).toEqual( + expect.objectContaining({ + imageOutputType: EImageOutputType.PNG, + }), + ); }); it('should initialize Firebase when fileStrategy is firebase', async () => { - require('./Config/loadCustomConfig').mockImplementationOnce(() => + loadCustomConfig.mockImplementationOnce(() => Promise.resolve({ fileStrategy: FileSources.firebase, }), ); - await AppService(app); + await AppService(); const { initializeFirebase } = require('./Files/Firebase/initialize'); expect(initializeFirebase).toHaveBeenCalled(); @@ -239,10 +246,9 @@ describe('AppService', () => { }); it('should load and format tools accurately with defined structure', async () => { - const { loadAndFormatTools } = require('./ToolService'); - const { setCachedTools, getCachedTools } = require('./Config'); + const { loadAndFormatTools } = require('./start/tools'); - await AppService(app); + const result = await AppService(); expect(loadAndFormatTools).toHaveBeenCalledWith({ adminFilter: undefined, @@ -250,31 +256,9 @@ describe('AppService', () => { directory: expect.anything(), }); - // Verify setCachedTools was called with the tools - expect(setCachedTools).toHaveBeenCalledWith( - { - ExampleTool: { - type: 'function', - function: { - description: 'Example tool function', - name: 'exampleFunction', - parameters: { - type: 'object', - properties: { - param1: { type: 'string', description: 'An example parameter' }, - }, - required: ['param1'], - }, - }, - }, - }, - { isGlobal: true }, - ); - - // Verify we can retrieve the tools from cache - const cachedTools = await getCachedTools({ includeGlobal: true }); - expect(cachedTools.ExampleTool).toBeDefined(); - expect(cachedTools.ExampleTool).toEqual({ + // Verify tools are included in the returned config + expect(result.availableTools).toBeDefined(); + expect(result.availableTools.ExampleTool).toEqual({ type: 'function', function: { description: 'Example tool function', @@ -291,7 +275,7 @@ describe('AppService', () => { }); it('should correctly configure Assistants endpoint based on custom config', async () => { - require('./Config/loadCustomConfig').mockImplementationOnce(() => + loadCustomConfig.mockImplementationOnce(() => Promise.resolve({ endpoints: { [EModelEndpoint.assistants]: { @@ -305,22 +289,25 @@ describe('AppService', () => { }), ); - await AppService(app); + const result = await AppService(); - expect(app.locals).toHaveProperty(EModelEndpoint.assistants); - expect(app.locals[EModelEndpoint.assistants]).toEqual( + expect(result).toEqual( expect.objectContaining({ - disableBuilder: true, - pollIntervalMs: 5000, - timeoutMs: 30000, - supportedIds: expect.arrayContaining(['id1', 'id2']), - privateAssistants: false, + endpoints: expect.objectContaining({ + [EModelEndpoint.assistants]: expect.objectContaining({ + disableBuilder: true, + pollIntervalMs: 5000, + timeoutMs: 30000, + supportedIds: expect.arrayContaining(['id1', 'id2']), + privateAssistants: false, + }), + }), }), ); }); it('should correctly configure Agents endpoint based on custom config', async () => { - require('./Config/loadCustomConfig').mockImplementationOnce(() => + loadCustomConfig.mockImplementationOnce(() => Promise.resolve({ endpoints: { [EModelEndpoint.agents]: { @@ -334,36 +321,45 @@ describe('AppService', () => { }), ); - await AppService(app); + const result = await AppService(); - expect(app.locals).toHaveProperty(EModelEndpoint.agents); - expect(app.locals[EModelEndpoint.agents]).toEqual( + expect(result).toEqual( expect.objectContaining({ - disableBuilder: true, - recursionLimit: 10, - maxRecursionLimit: 20, - allowedProviders: expect.arrayContaining(['openai', 'anthropic']), - capabilities: expect.arrayContaining([AgentCapabilities.tools, AgentCapabilities.actions]), + endpoints: expect.objectContaining({ + [EModelEndpoint.agents]: expect.objectContaining({ + disableBuilder: true, + recursionLimit: 10, + maxRecursionLimit: 20, + allowedProviders: expect.arrayContaining(['openai', 'anthropic']), + capabilities: expect.arrayContaining([ + AgentCapabilities.tools, + AgentCapabilities.actions, + ]), + }), + }), }), ); }); it('should configure Agents endpoint with defaults when no config is provided', async () => { - require('./Config/loadCustomConfig').mockImplementationOnce(() => Promise.resolve({})); + loadCustomConfig.mockImplementationOnce(() => Promise.resolve({})); - await AppService(app); + const result = await AppService(); - expect(app.locals).toHaveProperty(EModelEndpoint.agents); - expect(app.locals[EModelEndpoint.agents]).toEqual( + expect(result).toEqual( expect.objectContaining({ - disableBuilder: false, - capabilities: expect.arrayContaining([...defaultAgentCapabilities]), + endpoints: expect.objectContaining({ + [EModelEndpoint.agents]: expect.objectContaining({ + disableBuilder: false, + capabilities: expect.arrayContaining([...defaultAgentCapabilities]), + }), + }), }), ); }); it('should configure Agents endpoint with defaults when endpoints exist but agents is not defined', async () => { - require('./Config/loadCustomConfig').mockImplementationOnce(() => + loadCustomConfig.mockImplementationOnce(() => Promise.resolve({ endpoints: { [EModelEndpoint.openAI]: { @@ -373,20 +369,26 @@ describe('AppService', () => { }), ); - await AppService(app); + const result = await AppService(); - expect(app.locals).toHaveProperty(EModelEndpoint.agents); - expect(app.locals[EModelEndpoint.agents]).toEqual( + expect(result).toEqual( expect.objectContaining({ - disableBuilder: false, - capabilities: expect.arrayContaining([...defaultAgentCapabilities]), + endpoints: expect.objectContaining({ + [EModelEndpoint.agents]: expect.objectContaining({ + disableBuilder: false, + capabilities: expect.arrayContaining([...defaultAgentCapabilities]), + }), + [EModelEndpoint.openAI]: expect.objectContaining({ + titleConvo: true, + }), + }), }), ); }); it('should correctly configure minimum Azure OpenAI Assistant values', async () => { const assistantGroups = [azureGroups[0], { ...azureGroups[1], assistants: true }]; - require('./Config/loadCustomConfig').mockImplementationOnce(() => + loadCustomConfig.mockImplementationOnce(() => Promise.resolve({ endpoints: { [EModelEndpoint.azureOpenAI]: { @@ -400,13 +402,24 @@ describe('AppService', () => { process.env.WESTUS_API_KEY = 'westus-key'; process.env.EASTUS_API_KEY = 'eastus-key'; - await AppService(app); - expect(app.locals).toHaveProperty(EModelEndpoint.azureAssistants); - expect(app.locals[EModelEndpoint.azureAssistants].capabilities.length).toEqual(3); + const result = await AppService(); + expect(result).toEqual( + expect.objectContaining({ + endpoints: expect.objectContaining({ + [EModelEndpoint.azureAssistants]: expect.objectContaining({ + capabilities: expect.arrayContaining([ + expect.any(String), + expect.any(String), + expect.any(String), + ]), + }), + }), + }), + ); }); it('should correctly configure Azure OpenAI endpoint based on custom config', async () => { - require('./Config/loadCustomConfig').mockImplementationOnce(() => + loadCustomConfig.mockImplementationOnce(() => Promise.resolve({ endpoints: { [EModelEndpoint.azureOpenAI]: { @@ -419,18 +432,20 @@ describe('AppService', () => { process.env.WESTUS_API_KEY = 'westus-key'; process.env.EASTUS_API_KEY = 'eastus-key'; - await AppService(app); - - expect(app.locals).toHaveProperty(EModelEndpoint.azureOpenAI); - const azureConfig = app.locals[EModelEndpoint.azureOpenAI]; - expect(azureConfig).toHaveProperty('modelNames'); - expect(azureConfig).toHaveProperty('modelGroupMap'); - expect(azureConfig).toHaveProperty('groupMap'); + const result = await AppService(); const { modelNames, modelGroupMap, groupMap } = validateAzureGroups(azureGroups); - expect(azureConfig.modelNames).toEqual(modelNames); - expect(azureConfig.modelGroupMap).toEqual(modelGroupMap); - expect(azureConfig.groupMap).toEqual(groupMap); + expect(result).toEqual( + expect.objectContaining({ + endpoints: expect.objectContaining({ + [EModelEndpoint.azureOpenAI]: expect.objectContaining({ + modelNames, + modelGroupMap, + groupMap, + }), + }), + }), + ); }); it('should not modify FILE_UPLOAD environment variables without rate limits', async () => { @@ -442,7 +457,7 @@ describe('AppService', () => { const initialEnv = { ...process.env }; - await AppService(app); + await AppService(); // Expect environment variables to remain unchanged expect(process.env.FILE_UPLOAD_IP_MAX).toEqual(initialEnv.FILE_UPLOAD_IP_MAX); @@ -464,11 +479,9 @@ describe('AppService', () => { }, }; - require('./Config/loadCustomConfig').mockImplementationOnce(() => - Promise.resolve(rateLimitsConfig), - ); + loadCustomConfig.mockImplementationOnce(() => Promise.resolve(rateLimitsConfig)); - await AppService(app); + await AppService(); // Verify that process.env has been updated according to the rate limits config expect(process.env.FILE_UPLOAD_IP_MAX).toEqual('100'); @@ -484,7 +497,7 @@ describe('AppService', () => { process.env.FILE_UPLOAD_USER_MAX = 'initialUserMax'; process.env.FILE_UPLOAD_USER_WINDOW = 'initialUserWindow'; - await AppService(app); + await AppService(); // Verify that process.env falls back to the initial values expect(process.env.FILE_UPLOAD_IP_MAX).toEqual('initialMax'); @@ -502,7 +515,7 @@ describe('AppService', () => { const initialEnv = { ...process.env }; - await AppService(app); + await AppService(); // Expect environment variables to remain unchanged expect(process.env.IMPORT_IP_MAX).toEqual(initialEnv.IMPORT_IP_MAX); @@ -524,11 +537,9 @@ describe('AppService', () => { }, }; - require('./Config/loadCustomConfig').mockImplementationOnce(() => - Promise.resolve(importLimitsConfig), - ); + loadCustomConfig.mockImplementationOnce(() => Promise.resolve(importLimitsConfig)); - await AppService(app); + await AppService(); // Verify that process.env has been updated according to the rate limits config expect(process.env.IMPORT_IP_MAX).toEqual('150'); @@ -544,7 +555,7 @@ describe('AppService', () => { process.env.IMPORT_USER_MAX = 'initialUserMax'; process.env.IMPORT_USER_WINDOW = 'initialUserWindow'; - await AppService(app); + await AppService(); // Verify that process.env falls back to the initial values expect(process.env.IMPORT_IP_MAX).toEqual('initialMax'); @@ -554,7 +565,7 @@ describe('AppService', () => { }); it('should correctly configure endpoint with titlePrompt, titleMethod, and titlePromptTemplate', async () => { - require('./Config/loadCustomConfig').mockImplementationOnce(() => + loadCustomConfig.mockImplementationOnce(() => Promise.resolve({ endpoints: { [EModelEndpoint.openAI]: { @@ -581,43 +592,40 @@ describe('AppService', () => { }), ); - await AppService(app); + const result = await AppService(); - // Check OpenAI endpoint configuration - expect(app.locals).toHaveProperty(EModelEndpoint.openAI); - expect(app.locals[EModelEndpoint.openAI]).toEqual( + expect(result).toEqual( expect.objectContaining({ - titleConvo: true, - titleModel: 'gpt-3.5-turbo', - titleMethod: 'structured', - titlePrompt: 'Custom title prompt for conversation', - titlePromptTemplate: 'Summarize this conversation: {{conversation}}', - }), - ); - - // Check Assistants endpoint configuration - expect(app.locals).toHaveProperty(EModelEndpoint.assistants); - expect(app.locals[EModelEndpoint.assistants]).toMatchObject({ - titleMethod: 'functions', - titlePrompt: 'Generate a title for this assistant conversation', - titlePromptTemplate: 'Assistant conversation template: {{messages}}', - }); - - // Check Azure OpenAI endpoint configuration - expect(app.locals).toHaveProperty(EModelEndpoint.azureOpenAI); - expect(app.locals[EModelEndpoint.azureOpenAI]).toEqual( - expect.objectContaining({ - titleConvo: true, - titleMethod: 'completion', - titleModel: 'gpt-4', - titlePrompt: 'Azure title prompt', - titlePromptTemplate: 'Azure conversation: {{context}}', + endpoints: expect.objectContaining({ + // Check OpenAI endpoint configuration + [EModelEndpoint.openAI]: expect.objectContaining({ + titleConvo: true, + titleModel: 'gpt-3.5-turbo', + titleMethod: 'structured', + titlePrompt: 'Custom title prompt for conversation', + titlePromptTemplate: 'Summarize this conversation: {{conversation}}', + }), + // Check Assistants endpoint configuration + [EModelEndpoint.assistants]: expect.objectContaining({ + titleMethod: 'functions', + titlePrompt: 'Generate a title for this assistant conversation', + titlePromptTemplate: 'Assistant conversation template: {{messages}}', + }), + // Check Azure OpenAI endpoint configuration + [EModelEndpoint.azureOpenAI]: expect.objectContaining({ + titleConvo: true, + titleMethod: 'completion', + titleModel: 'gpt-4', + titlePrompt: 'Azure title prompt', + titlePromptTemplate: 'Azure conversation: {{context}}', + }), + }), }), ); }); it('should configure Agent endpoint with title generation settings', async () => { - require('./Config/loadCustomConfig').mockImplementationOnce(() => + loadCustomConfig.mockImplementationOnce(() => Promise.resolve({ endpoints: { [EModelEndpoint.agents]: { @@ -634,23 +642,31 @@ describe('AppService', () => { }), ); - await AppService(app); + const result = await AppService(); - expect(app.locals).toHaveProperty(EModelEndpoint.agents); - expect(app.locals[EModelEndpoint.agents]).toMatchObject({ - disableBuilder: false, - titleConvo: true, - titleModel: 'gpt-4', - titleMethod: 'structured', - titlePrompt: 'Generate a descriptive title for this agent conversation', - titlePromptTemplate: 'Agent conversation summary: {{content}}', - recursionLimit: 15, - capabilities: expect.arrayContaining([AgentCapabilities.tools, AgentCapabilities.actions]), - }); + expect(result).toEqual( + expect.objectContaining({ + endpoints: expect.objectContaining({ + [EModelEndpoint.agents]: expect.objectContaining({ + disableBuilder: false, + titleConvo: true, + titleModel: 'gpt-4', + titleMethod: 'structured', + titlePrompt: 'Generate a descriptive title for this agent conversation', + titlePromptTemplate: 'Agent conversation summary: {{content}}', + recursionLimit: 15, + capabilities: expect.arrayContaining([ + AgentCapabilities.tools, + AgentCapabilities.actions, + ]), + }), + }), + }), + ); }); it('should handle missing title configuration options with defaults', async () => { - require('./Config/loadCustomConfig').mockImplementationOnce(() => + loadCustomConfig.mockImplementationOnce(() => Promise.resolve({ endpoints: { [EModelEndpoint.openAI]: { @@ -661,20 +677,26 @@ describe('AppService', () => { }), ); - await AppService(app); + const result = await AppService(); - expect(app.locals).toHaveProperty(EModelEndpoint.openAI); - expect(app.locals[EModelEndpoint.openAI]).toMatchObject({ - titleConvo: true, - }); - // Check that the optional fields are undefined when not provided - expect(app.locals[EModelEndpoint.openAI].titlePrompt).toBeUndefined(); - expect(app.locals[EModelEndpoint.openAI].titlePromptTemplate).toBeUndefined(); - expect(app.locals[EModelEndpoint.openAI].titleMethod).toBeUndefined(); + expect(result).toEqual( + expect.objectContaining({ + endpoints: expect.objectContaining({ + [EModelEndpoint.openAI]: expect.objectContaining({ + titleConvo: true, + }), + }), + }), + ); + + // Verify that optional fields are not set when not provided + expect(result.endpoints[EModelEndpoint.openAI].titlePrompt).toBeUndefined(); + expect(result.endpoints[EModelEndpoint.openAI].titlePromptTemplate).toBeUndefined(); + expect(result.endpoints[EModelEndpoint.openAI].titleMethod).toBeUndefined(); }); it('should correctly configure titleEndpoint when specified', async () => { - require('./Config/loadCustomConfig').mockImplementationOnce(() => + loadCustomConfig.mockImplementationOnce(() => Promise.resolve({ endpoints: { [EModelEndpoint.openAI]: { @@ -691,27 +713,30 @@ describe('AppService', () => { }), ); - await AppService(app); + const result = await AppService(); - // Check OpenAI endpoint has titleEndpoint - expect(app.locals).toHaveProperty(EModelEndpoint.openAI); - expect(app.locals[EModelEndpoint.openAI]).toMatchObject({ - titleConvo: true, - titleModel: 'gpt-3.5-turbo', - titleEndpoint: EModelEndpoint.anthropic, - titlePrompt: 'Generate a concise title', - }); - - // Check Agents endpoint has titleEndpoint - expect(app.locals).toHaveProperty(EModelEndpoint.agents); - expect(app.locals[EModelEndpoint.agents]).toMatchObject({ - titleEndpoint: 'custom-provider', - titleMethod: 'structured', - }); + expect(result).toEqual( + expect.objectContaining({ + endpoints: expect.objectContaining({ + // Check OpenAI endpoint has titleEndpoint + [EModelEndpoint.openAI]: expect.objectContaining({ + titleConvo: true, + titleModel: 'gpt-3.5-turbo', + titleEndpoint: EModelEndpoint.anthropic, + titlePrompt: 'Generate a concise title', + }), + // Check Agents endpoint has titleEndpoint + [EModelEndpoint.agents]: expect.objectContaining({ + titleEndpoint: 'custom-provider', + titleMethod: 'structured', + }), + }), + }), + ); }); it('should correctly configure all endpoint when specified', async () => { - require('./Config/loadCustomConfig').mockImplementationOnce(() => + loadCustomConfig.mockImplementationOnce(() => Promise.resolve({ endpoints: { all: { @@ -731,39 +756,42 @@ describe('AppService', () => { }), ); - await AppService(app); + const result = await AppService(); - // Check that 'all' endpoint config is loaded - expect(app.locals).toHaveProperty('all'); - expect(app.locals.all).toMatchObject({ - titleConvo: true, - titleModel: 'gpt-4o-mini', - titleMethod: 'structured', - titlePrompt: 'Default title prompt for all endpoints', - titlePromptTemplate: 'Default template: {{conversation}}', - titleEndpoint: EModelEndpoint.anthropic, - streamRate: 50, - }); - - // Check that OpenAI endpoint has its own config - expect(app.locals).toHaveProperty(EModelEndpoint.openAI); - expect(app.locals[EModelEndpoint.openAI]).toMatchObject({ - titleConvo: true, - titleModel: 'gpt-3.5-turbo', - }); + expect(result).toEqual( + expect.objectContaining({ + // Check that 'all' endpoint config is loaded + endpoints: expect.objectContaining({ + all: expect.objectContaining({ + titleConvo: true, + titleModel: 'gpt-4o-mini', + titleMethod: 'structured', + titlePrompt: 'Default title prompt for all endpoints', + titlePromptTemplate: 'Default template: {{conversation}}', + titleEndpoint: EModelEndpoint.anthropic, + streamRate: 50, + }), + // Check that OpenAI endpoint has its own config + [EModelEndpoint.openAI]: expect.objectContaining({ + titleConvo: true, + titleModel: 'gpt-3.5-turbo', + }), + }), + }), + ); }); }); -describe('AppService updating app.locals and issuing warnings', () => { - let app; +describe('AppService updating app config and issuing warnings', () => { let initialEnv; + const loadCustomConfig = require('./Config/loadCustomConfig'); beforeEach(() => { // Store initial environment variables to restore them after each test initialEnv = { ...process.env }; - app = { locals: {} }; process.env.CDN_PROVIDER = undefined; + jest.clearAllMocks(); }); afterEach(() => { @@ -771,26 +799,29 @@ describe('AppService updating app.locals and issuing warnings', () => { process.env = { ...initialEnv }; }); - it('should update app.locals with default values if loadCustomConfig returns undefined', async () => { + it('should initialize app config with default values if loadCustomConfig returns undefined', async () => { // Mock loadCustomConfig to return undefined - require('./Config/loadCustomConfig').mockImplementationOnce(() => Promise.resolve(undefined)); + loadCustomConfig.mockImplementationOnce(() => Promise.resolve(undefined)); - await AppService(app); + const result = await AppService(); - expect(app.locals).toBeDefined(); - expect(app.locals.paths).toBeDefined(); - expect(app.locals.config).toEqual({}); - expect(app.locals.fileStrategy).toEqual(FileSources.local); - expect(app.locals.socialLogins).toEqual(defaultSocialLogins); - expect(app.locals.balance).toEqual( + expect(result).toEqual( expect.objectContaining({ - enabled: false, - startBalance: undefined, + paths: expect.anything(), + config: {}, + fileStrategy: FileSources.local, + registration: expect.objectContaining({ + socialLogins: defaultSocialLogins, + }), + balance: expect.objectContaining({ + enabled: false, + startBalance: undefined, + }), }), ); }); - it('should update app.locals with values from loadCustomConfig', async () => { + it('should initialize app config with values from loadCustomConfig', async () => { // Mock loadCustomConfig to return a specific config object with a complete balance config const customConfig = { fileStrategy: 'firebase', @@ -804,21 +835,24 @@ describe('AppService updating app.locals and issuing warnings', () => { refillAmount: 5000, }, }; - require('./Config/loadCustomConfig').mockImplementationOnce(() => - Promise.resolve(customConfig), + loadCustomConfig.mockImplementationOnce(() => Promise.resolve(customConfig)); + + const result = await AppService(); + + expect(result).toEqual( + expect.objectContaining({ + paths: expect.anything(), + config: customConfig, + fileStrategy: customConfig.fileStrategy, + registration: expect.objectContaining({ + socialLogins: customConfig.registration.socialLogins, + }), + balance: customConfig.balance, + }), ); - - await AppService(app); - - expect(app.locals).toBeDefined(); - expect(app.locals.paths).toBeDefined(); - expect(app.locals.config).toEqual(customConfig); - expect(app.locals.fileStrategy).toEqual(customConfig.fileStrategy); - expect(app.locals.socialLogins).toEqual(customConfig.registration.socialLogins); - expect(app.locals.balance).toEqual(customConfig.balance); }); - it('should apply the assistants endpoint configuration correctly to app.locals', async () => { + it('should apply the assistants endpoint configuration correctly to app config', async () => { const mockConfig = { endpoints: { assistants: { @@ -829,18 +863,25 @@ describe('AppService updating app.locals and issuing warnings', () => { }, }, }; - require('./Config/loadCustomConfig').mockImplementationOnce(() => Promise.resolve(mockConfig)); + loadCustomConfig.mockImplementationOnce(() => Promise.resolve(mockConfig)); - const app = { locals: {} }; - await AppService(app); + const result = await AppService(); - expect(app.locals).toHaveProperty('assistants'); - const { assistants } = app.locals; - expect(assistants.disableBuilder).toBe(true); - expect(assistants.pollIntervalMs).toBe(5000); - expect(assistants.timeoutMs).toBe(30000); - expect(assistants.supportedIds).toEqual(['id1', 'id2']); - expect(assistants.excludedIds).toBeUndefined(); + expect(result).toEqual( + expect.objectContaining({ + endpoints: expect.objectContaining({ + assistants: expect.objectContaining({ + disableBuilder: true, + pollIntervalMs: 5000, + timeoutMs: 30000, + supportedIds: ['id1', 'id2'], + }), + }), + }), + ); + + // Verify excludedIds is undefined when not provided + expect(result.endpoints.assistants.excludedIds).toBeUndefined(); }); it('should log a warning when both supportedIds and excludedIds are provided', async () => { @@ -855,12 +896,11 @@ describe('AppService updating app.locals and issuing warnings', () => { }, }, }; - require('./Config/loadCustomConfig').mockImplementationOnce(() => Promise.resolve(mockConfig)); + loadCustomConfig.mockImplementationOnce(() => Promise.resolve(mockConfig)); - const app = { locals: {} }; - await require('./AppService')(app); + await AppService(); - const { logger } = require('~/config'); + const { logger } = require('@librechat/data-schemas'); expect(logger.warn).toHaveBeenCalledWith( expect.stringContaining( "The 'assistants' endpoint has both 'supportedIds' and 'excludedIds' defined.", @@ -877,12 +917,11 @@ describe('AppService updating app.locals and issuing warnings', () => { }, }, }; - require('./Config/loadCustomConfig').mockImplementationOnce(() => Promise.resolve(mockConfig)); + loadCustomConfig.mockImplementationOnce(() => Promise.resolve(mockConfig)); - const app = { locals: {} }; - await require('./AppService')(app); + await AppService(); - const { logger } = require('~/config'); + const { logger } = require('@librechat/data-schemas'); expect(logger.warn).toHaveBeenCalledWith( expect.stringContaining( "The 'assistants' endpoint has both 'privateAssistants' and 'supportedIds' or 'excludedIds' defined.", @@ -891,7 +930,7 @@ describe('AppService updating app.locals and issuing warnings', () => { }); it('should issue expected warnings when loading Azure Groups with deprecated Environment Variables', async () => { - require('./Config/loadCustomConfig').mockImplementationOnce(() => + loadCustomConfig.mockImplementationOnce(() => Promise.resolve({ endpoints: { [EModelEndpoint.azureOpenAI]: { @@ -905,10 +944,9 @@ describe('AppService updating app.locals and issuing warnings', () => { process.env[varInfo.key] = 'test'; }); - const app = { locals: {} }; - await require('./AppService')(app); + await AppService(); - const { logger } = require('~/config'); + const { logger } = require('@librechat/data-schemas'); deprecatedAzureVariables.forEach(({ key, description }) => { expect(logger.warn).toHaveBeenCalledWith( `The \`${key}\` environment variable (related to ${description}) should not be used in combination with the \`azureOpenAI\` endpoint configuration, as you will experience conflicts and errors.`, @@ -917,7 +955,7 @@ describe('AppService updating app.locals and issuing warnings', () => { }); it('should issue expected warnings when loading conflicting Azure Envrionment Variables', async () => { - require('./Config/loadCustomConfig').mockImplementationOnce(() => + loadCustomConfig.mockImplementationOnce(() => Promise.resolve({ endpoints: { [EModelEndpoint.azureOpenAI]: { @@ -931,10 +969,9 @@ describe('AppService updating app.locals and issuing warnings', () => { process.env[varInfo.key] = 'test'; }); - const app = { locals: {} }; - await require('./AppService')(app); + await AppService(); - const { logger } = require('~/config'); + const { logger } = require('@librechat/data-schemas'); conflictingAzureVariables.forEach(({ key }) => { expect(logger.warn).toHaveBeenCalledWith( `The \`${key}\` environment variable should not be used in combination with the \`azureOpenAI\` endpoint configuration, as you may experience with the defined placeholders for mapping to the current model grouping using the same name.`, @@ -953,22 +990,25 @@ describe('AppService updating app.locals and issuing warnings', () => { }, }; - require('./Config/loadCustomConfig').mockImplementationOnce(() => Promise.resolve(mockConfig)); + loadCustomConfig.mockImplementationOnce(() => Promise.resolve(mockConfig)); // Set actual environment variables with different values process.env.OCR_API_KEY_CUSTOM_VAR_NAME = 'actual-api-key'; process.env.OCR_BASEURL_CUSTOM_VAR_NAME = 'https://actual-ocr-url.com'; - // Initialize app - const app = { locals: {} }; - await AppService(app); + const result = await AppService(); // Verify that the raw string references were preserved and not interpolated - expect(app.locals.ocr).toBeDefined(); - expect(app.locals.ocr.apiKey).toEqual('${OCR_API_KEY_CUSTOM_VAR_NAME}'); - expect(app.locals.ocr.baseURL).toEqual('${OCR_BASEURL_CUSTOM_VAR_NAME}'); - expect(app.locals.ocr.strategy).toEqual('mistral_ocr'); - expect(app.locals.ocr.mistralModel).toEqual('mistral-medium'); + expect(result).toEqual( + expect.objectContaining({ + ocr: expect.objectContaining({ + apiKey: '${OCR_API_KEY_CUSTOM_VAR_NAME}', + baseURL: '${OCR_BASEURL_CUSTOM_VAR_NAME}', + strategy: 'mistral_ocr', + mistralModel: 'mistral-medium', + }), + }), + ); }); it('should correctly configure peoplePicker permissions when specified', async () => { @@ -982,17 +1022,21 @@ describe('AppService updating app.locals and issuing warnings', () => { }, }; - require('./Config/loadCustomConfig').mockImplementationOnce(() => Promise.resolve(mockConfig)); + loadCustomConfig.mockImplementationOnce(() => Promise.resolve(mockConfig)); - const app = { locals: {} }; - await AppService(app); + const result = await AppService(); // Check that interface config includes the permissions - expect(app.locals.interfaceConfig.peoplePicker).toBeDefined(); - expect(app.locals.interfaceConfig.peoplePicker).toMatchObject({ - users: true, - groups: true, - roles: true, - }); + expect(result).toEqual( + expect.objectContaining({ + interfaceConfig: expect.objectContaining({ + peoplePicker: expect.objectContaining({ + users: true, + groups: true, + roles: true, + }), + }), + }), + ); }); }); diff --git a/api/server/services/AssistantService.js b/api/server/services/AssistantService.js index a9ac26e47..892afb700 100644 --- a/api/server/services/AssistantService.js +++ b/api/server/services/AssistantService.js @@ -350,6 +350,7 @@ async function runAssistant({ accumulatedMessages = [], in_progress: inProgress, }) { + const appConfig = openai.req.config; let steps = accumulatedSteps; let messages = accumulatedMessages; const in_progress = inProgress ?? createInProgressHandler(openai, thread_id, messages); @@ -396,8 +397,8 @@ async function runAssistant({ }); const { endpoint = EModelEndpoint.azureAssistants } = openai.req.body; - /** @type {TCustomConfig.endpoints.assistants} */ - const assistantsEndpointConfig = openai.req.app.locals?.[endpoint] ?? {}; + /** @type {AppConfig['endpoints']['assistants']} */ + const assistantsEndpointConfig = appConfig.endpoints?.[endpoint] ?? {}; const { pollIntervalMs, timeoutMs } = assistantsEndpointConfig; const run = await waitForRun({ diff --git a/api/server/services/AuthService.js b/api/server/services/AuthService.js index 8c7cbf7d9..5f9474847 100644 --- a/api/server/services/AuthService.js +++ b/api/server/services/AuthService.js @@ -1,8 +1,8 @@ const bcrypt = require('bcryptjs'); const jwt = require('jsonwebtoken'); const { webcrypto } = require('node:crypto'); -const { isEnabled } = require('@librechat/api'); const { logger } = require('@librechat/data-schemas'); +const { isEnabled, checkEmailConfig } = require('@librechat/api'); const { SystemRoles, errorsToString } = require('librechat-data-provider'); const { findUser, @@ -21,9 +21,9 @@ const { generateRefreshToken, } = require('~/models'); const { isEmailDomainAllowed } = require('~/server/services/domains'); -const { checkEmailConfig, sendEmail } = require('~/server/utils'); -const { getBalanceConfig } = require('~/server/services/Config'); const { registerSchema } = require('~/strategies/validators'); +const { getAppConfig } = require('~/server/services/Config'); +const { sendEmail } = require('~/server/utils'); const domains = { client: process.env.DOMAIN_CLIENT, @@ -78,7 +78,7 @@ const createTokenHash = () => { /** * Send Verification Email - * @param {Partial & { _id: ObjectId, email: string, name: string}} user + * @param {Partial} user * @returns {Promise} */ const sendVerificationEmail = async (user) => { @@ -112,7 +112,7 @@ const sendVerificationEmail = async (user) => { /** * Verify Email - * @param {Express.Request} req + * @param {ServerRequest} req */ const verifyEmail = async (req) => { const { email, token } = req.body; @@ -160,9 +160,9 @@ const verifyEmail = async (req) => { /** * Register a new user. - * @param {MongoUser} user - * @param {Partial} [additionalData={}] - * @returns {Promise<{status: number, message: string, user?: MongoUser}>} + * @param {IUser} user + * @param {Partial} [additionalData={}] + * @returns {Promise<{status: number, message: string, user?: IUser}>} */ const registerUser = async (user, additionalData = {}) => { const { error } = registerSchema.safeParse(user); @@ -195,7 +195,8 @@ const registerUser = async (user, additionalData = {}) => { return { status: 200, message: genericVerificationMessage }; } - if (!(await isEmailDomainAllowed(email))) { + const appConfig = await getAppConfig({ role: user.role }); + if (!isEmailDomainAllowed(email, appConfig?.registration?.allowedDomains)) { const errorMessage = 'The email address provided cannot be used. Please use a different email address.'; logger.error(`[registerUser] [Registration not allowed] [Email: ${user.email}]`); @@ -219,9 +220,8 @@ const registerUser = async (user, additionalData = {}) => { const emailEnabled = checkEmailConfig(); const disableTTL = isEnabled(process.env.ALLOW_UNVERIFIED_EMAIL_LOGIN); - const balanceConfig = await getBalanceConfig(); - const newUser = await createUser(newUserData, balanceConfig, disableTTL, true); + const newUser = await createUser(newUserData, appConfig.balance, disableTTL, true); newUserId = newUser._id; if (emailEnabled && !newUser.emailVerified) { await sendVerificationEmail({ @@ -248,7 +248,7 @@ const registerUser = async (user, additionalData = {}) => { /** * Request password reset - * @param {Express.Request} req + * @param {ServerRequest} req */ const requestPasswordReset = async (req) => { const { email } = req.body; diff --git a/api/server/services/Config/app.js b/api/server/services/Config/app.js new file mode 100644 index 000000000..e357b55d9 --- /dev/null +++ b/api/server/services/Config/app.js @@ -0,0 +1,68 @@ +const { logger } = require('@librechat/data-schemas'); +const { CacheKeys } = require('librechat-data-provider'); +const AppService = require('~/server/services/AppService'); +const { setCachedTools } = require('./getCachedTools'); +const getLogStores = require('~/cache/getLogStores'); + +/** + * Get the app configuration based on user context + * @param {Object} [options] + * @param {string} [options.role] - User role for role-based config + * @param {boolean} [options.refresh] - Force refresh the cache + * @returns {Promise} + */ +async function getAppConfig(options = {}) { + const { role, refresh } = options; + + const cache = getLogStores(CacheKeys.CONFIG_STORE); + const cacheKey = role ? `${CacheKeys.APP_CONFIG}:${role}` : CacheKeys.APP_CONFIG; + + if (!refresh) { + const cached = await cache.get(cacheKey); + if (cached) { + return cached; + } + } + + let baseConfig = await cache.get(CacheKeys.APP_CONFIG); + if (!baseConfig) { + logger.info('[getAppConfig] App configuration not initialized. Initializing AppService...'); + baseConfig = await AppService(); + + if (!baseConfig) { + throw new Error('Failed to initialize app configuration through AppService.'); + } + + if (baseConfig.availableTools) { + await setCachedTools(baseConfig.availableTools, { isGlobal: true }); + } + + await cache.set(CacheKeys.APP_CONFIG, baseConfig); + } + + // For now, return the base config + // In the future, this is where we'll apply role-based modifications + if (role) { + // TODO: Apply role-based config modifications + // const roleConfig = await applyRoleBasedConfig(baseConfig, role); + // await cache.set(cacheKey, roleConfig); + // return roleConfig; + } + + return baseConfig; +} + +/** + * Clear the app configuration cache + * @returns {Promise} + */ +async function clearAppConfigCache() { + const cache = getLogStores(CacheKeys.CONFIG_STORE); + const cacheKey = CacheKeys.APP_CONFIG; + return await cache.delete(cacheKey); +} + +module.exports = { + getAppConfig, + clearAppConfigCache, +}; diff --git a/api/server/services/Config/getCustomConfig.js b/api/server/services/Config/getCustomConfig.js deleted file mode 100644 index ced319050..000000000 --- a/api/server/services/Config/getCustomConfig.js +++ /dev/null @@ -1,69 +0,0 @@ -const { isEnabled } = require('@librechat/api'); -const { CacheKeys, EModelEndpoint } = require('librechat-data-provider'); -const { normalizeEndpointName } = require('~/server/utils'); -const loadCustomConfig = require('./loadCustomConfig'); -const getLogStores = require('~/cache/getLogStores'); - -/** - * Retrieves the configuration object - * @function getCustomConfig - * @returns {Promise} - * */ -async function getCustomConfig() { - const cache = getLogStores(CacheKeys.STATIC_CONFIG); - return (await cache.get(CacheKeys.LIBRECHAT_YAML_CONFIG)) || (await loadCustomConfig()); -} - -/** - * Retrieves the configuration object - * @function getBalanceConfig - * @returns {Promise} - * */ -async function getBalanceConfig() { - const isLegacyEnabled = isEnabled(process.env.CHECK_BALANCE); - const startBalance = process.env.START_BALANCE; - /** @type {TCustomConfig['balance']} */ - const config = { - enabled: isLegacyEnabled, - startBalance: startBalance != null && startBalance ? parseInt(startBalance, 10) : undefined, - }; - const customConfig = await getCustomConfig(); - if (!customConfig) { - return config; - } - return { ...config, ...(customConfig?.['balance'] ?? {}) }; -} - -/** - * - * @param {string | EModelEndpoint} endpoint - * @returns {Promise} - */ -const getCustomEndpointConfig = async (endpoint) => { - 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] ?? []; - return customEndpoints.find( - (endpointConfig) => normalizeEndpointName(endpointConfig.name) === endpoint, - ); -}; - -/** - * @returns {Promise} - */ -async function hasCustomUserVars() { - const customConfig = await getCustomConfig(); - const mcpServers = customConfig?.mcpServers; - return Object.values(mcpServers ?? {}).some((server) => server.customUserVars); -} - -module.exports = { - getCustomConfig, - getBalanceConfig, - hasCustomUserVars, - getCustomEndpointConfig, -}; diff --git a/api/server/services/Config/getEndpointsConfig.js b/api/server/services/Config/getEndpointsConfig.js index 670bc22d1..081f63d1d 100644 --- a/api/server/services/Config/getEndpointsConfig.js +++ b/api/server/services/Config/getEndpointsConfig.js @@ -1,3 +1,4 @@ +const { loadCustomEndpointsConfig } = require('@librechat/api'); const { CacheKeys, EModelEndpoint, @@ -6,8 +7,8 @@ const { defaultAgentCapabilities, } = require('librechat-data-provider'); const loadDefaultEndpointsConfig = require('./loadDefaultEConfig'); -const loadConfigEndpoints = require('./loadConfigEndpoints'); const getLogStores = require('~/cache/getLogStores'); +const { getAppConfig } = require('./app'); /** * @@ -21,14 +22,36 @@ async function getEndpointsConfig(req) { return cachedEndpointsConfig; } - const defaultEndpointsConfig = await loadDefaultEndpointsConfig(req); - const customConfigEndpoints = await loadConfigEndpoints(req); + const appConfig = req.config ?? (await getAppConfig({ role: req.user?.role })); + const defaultEndpointsConfig = await loadDefaultEndpointsConfig(appConfig); + const customEndpointsConfig = loadCustomEndpointsConfig(appConfig?.endpoints?.custom); /** @type {TEndpointsConfig} */ - const mergedConfig = { ...defaultEndpointsConfig, ...customConfigEndpoints }; - if (mergedConfig[EModelEndpoint.assistants] && req.app.locals?.[EModelEndpoint.assistants]) { + const mergedConfig = { + ...defaultEndpointsConfig, + ...customEndpointsConfig, + }; + + if (appConfig.endpoints?.[EModelEndpoint.azureOpenAI]) { + /** @type {Omit} */ + mergedConfig[EModelEndpoint.azureOpenAI] = { + userProvide: false, + }; + } + + if (appConfig.endpoints?.[EModelEndpoint.azureOpenAI]?.assistants) { + /** @type {Omit} */ + mergedConfig[EModelEndpoint.azureAssistants] = { + userProvide: false, + }; + } + + if ( + mergedConfig[EModelEndpoint.assistants] && + appConfig?.endpoints?.[EModelEndpoint.assistants] + ) { const { disableBuilder, retrievalModels, capabilities, version, ..._rest } = - req.app.locals[EModelEndpoint.assistants]; + appConfig.endpoints[EModelEndpoint.assistants]; mergedConfig[EModelEndpoint.assistants] = { ...mergedConfig[EModelEndpoint.assistants], @@ -38,9 +61,9 @@ async function getEndpointsConfig(req) { capabilities, }; } - if (mergedConfig[EModelEndpoint.agents] && req.app.locals?.[EModelEndpoint.agents]) { + if (mergedConfig[EModelEndpoint.agents] && appConfig?.endpoints?.[EModelEndpoint.agents]) { const { disableBuilder, capabilities, allowedProviders, ..._rest } = - req.app.locals[EModelEndpoint.agents]; + appConfig.endpoints[EModelEndpoint.agents]; mergedConfig[EModelEndpoint.agents] = { ...mergedConfig[EModelEndpoint.agents], @@ -52,10 +75,10 @@ async function getEndpointsConfig(req) { if ( mergedConfig[EModelEndpoint.azureAssistants] && - req.app.locals?.[EModelEndpoint.azureAssistants] + appConfig?.endpoints?.[EModelEndpoint.azureAssistants] ) { const { disableBuilder, retrievalModels, capabilities, version, ..._rest } = - req.app.locals[EModelEndpoint.azureAssistants]; + appConfig.endpoints[EModelEndpoint.azureAssistants]; mergedConfig[EModelEndpoint.azureAssistants] = { ...mergedConfig[EModelEndpoint.azureAssistants], @@ -66,8 +89,8 @@ async function getEndpointsConfig(req) { }; } - if (mergedConfig[EModelEndpoint.bedrock] && req.app.locals?.[EModelEndpoint.bedrock]) { - const { availableRegions } = req.app.locals[EModelEndpoint.bedrock]; + if (mergedConfig[EModelEndpoint.bedrock] && appConfig?.endpoints?.[EModelEndpoint.bedrock]) { + const { availableRegions } = appConfig.endpoints[EModelEndpoint.bedrock]; mergedConfig[EModelEndpoint.bedrock] = { ...mergedConfig[EModelEndpoint.bedrock], availableRegions, diff --git a/api/server/services/Config/index.js b/api/server/services/Config/index.js index aafb2dcb4..f1767ddec 100644 --- a/api/server/services/Config/index.js +++ b/api/server/services/Config/index.js @@ -1,12 +1,11 @@ +const appConfig = require('./app'); const { config } = require('./EndpointService'); const getCachedTools = require('./getCachedTools'); -const getCustomConfig = require('./getCustomConfig'); const mcpToolsCache = require('./mcpToolsCache'); const loadCustomConfig = require('./loadCustomConfig'); const loadConfigModels = require('./loadConfigModels'); const loadDefaultModels = require('./loadDefaultModels'); const getEndpointsConfig = require('./getEndpointsConfig'); -const loadOverrideConfig = require('./loadOverrideConfig'); const loadAsyncEndpoints = require('./loadAsyncEndpoints'); module.exports = { @@ -14,10 +13,9 @@ module.exports = { loadCustomConfig, loadConfigModels, loadDefaultModels, - loadOverrideConfig, loadAsyncEndpoints, + ...appConfig, ...getCachedTools, - ...getCustomConfig, ...mcpToolsCache, ...getEndpointsConfig, }; diff --git a/api/server/services/Config/loadAsyncEndpoints.js b/api/server/services/Config/loadAsyncEndpoints.js index b88744e9a..48b42131e 100644 --- a/api/server/services/Config/loadAsyncEndpoints.js +++ b/api/server/services/Config/loadAsyncEndpoints.js @@ -1,16 +1,16 @@ const path = require('path'); const { logger } = require('@librechat/data-schemas'); -const { loadServiceKey, isUserProvided } = require('@librechat/api'); const { EModelEndpoint } = require('librechat-data-provider'); +const { loadServiceKey, isUserProvided } = require('@librechat/api'); const { config } = require('./EndpointService'); const { openAIApiKey, azureOpenAIApiKey, useAzurePlugins, userProvidedOpenAI, googleKey } = config; /** * Load async endpoints and return a configuration object - * @param {Express.Request} req - The request object + * @param {AppConfig} [appConfig] - The app configuration object */ -async function loadAsyncEndpoints(req) { +async function loadAsyncEndpoints(appConfig) { let serviceKey, googleUserProvides; /** Check if GOOGLE_KEY is provided at all(including 'user_provided') */ @@ -34,7 +34,7 @@ async function loadAsyncEndpoints(req) { const google = serviceKey || isGoogleKeyProvided ? { userProvide: googleUserProvides } : false; - const useAzure = req.app.locals[EModelEndpoint.azureOpenAI]?.plugins; + const useAzure = !!appConfig?.endpoints?.[EModelEndpoint.azureOpenAI]?.plugins; const gptPlugins = useAzure || openAIApiKey || azureOpenAIApiKey ? { diff --git a/api/server/services/Config/loadConfigEndpoints.js b/api/server/services/Config/loadConfigEndpoints.js deleted file mode 100644 index 2e80fb42b..000000000 --- a/api/server/services/Config/loadConfigEndpoints.js +++ /dev/null @@ -1,73 +0,0 @@ -const { EModelEndpoint, extractEnvVariable } = require('librechat-data-provider'); -const { isUserProvided, normalizeEndpointName } = require('~/server/utils'); -const { getCustomConfig } = require('./getCustomConfig'); - -/** - * Load config endpoints from the cached configuration object - * @param {Express.Request} req - The request object - * @returns {Promise} A promise that resolves to an object containing the endpoints configuration - */ -async function loadConfigEndpoints(req) { - const customConfig = await getCustomConfig(); - - 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: configName, - iconURL, - modelDisplayLabel, - customParams, - } = endpoint; - const name = normalizeEndpointName(configName); - - const resolvedApiKey = extractEnvVariable(apiKey); - const resolvedBaseURL = extractEnvVariable(baseURL); - - endpointsConfig[name] = { - type: EModelEndpoint.custom, - userProvide: isUserProvided(resolvedApiKey), - userProvideURL: isUserProvided(resolvedBaseURL), - modelDisplayLabel, - iconURL, - customParams, - }; - } - } - - if (req.app.locals[EModelEndpoint.azureOpenAI]) { - /** @type {Omit} */ - endpointsConfig[EModelEndpoint.azureOpenAI] = { - userProvide: false, - }; - } - - if (req.app.locals[EModelEndpoint.azureOpenAI]?.assistants) { - /** @type {Omit} */ - endpointsConfig[EModelEndpoint.azureAssistants] = { - userProvide: false, - }; - } - - return endpointsConfig; -} - -module.exports = loadConfigEndpoints; diff --git a/api/server/services/Config/loadConfigModels.js b/api/server/services/Config/loadConfigModels.js index 2943317e3..9ef899424 100644 --- a/api/server/services/Config/loadConfigModels.js +++ b/api/server/services/Config/loadConfigModels.js @@ -1,43 +1,39 @@ +const { isUserProvided, normalizeEndpointName } = require('@librechat/api'); const { EModelEndpoint, extractEnvVariable } = require('librechat-data-provider'); -const { isUserProvided, normalizeEndpointName } = require('~/server/utils'); const { fetchModels } = require('~/server/services/ModelService'); -const { getCustomConfig } = require('./getCustomConfig'); +const { getAppConfig } = require('./app'); /** * Load config endpoints from the cached configuration object * @function loadConfigModels - * @param {Express.Request} req - The Express request object. + * @param {ServerRequest} req - The Express request object. */ async function loadConfigModels(req) { - const customConfig = await getCustomConfig(); - - if (!customConfig) { + const appConfig = await getAppConfig({ role: req.user?.role }); + if (!appConfig) { return {}; } - - const { endpoints = {} } = customConfig ?? {}; const modelsConfig = {}; - const azureEndpoint = endpoints[EModelEndpoint.azureOpenAI]; - const azureConfig = req.app.locals[EModelEndpoint.azureOpenAI]; + const azureConfig = appConfig.endpoints?.[EModelEndpoint.azureOpenAI]; const { modelNames } = azureConfig ?? {}; - if (modelNames && azureEndpoint) { + if (modelNames && azureConfig) { modelsConfig[EModelEndpoint.azureOpenAI] = modelNames; } - if (modelNames && azureEndpoint && azureEndpoint.plugins) { + if (modelNames && azureConfig && azureConfig.plugins) { modelsConfig[EModelEndpoint.gptPlugins] = modelNames; } - if (azureEndpoint?.assistants && azureConfig.assistantModels) { + if (azureConfig?.assistants && azureConfig.assistantModels) { modelsConfig[EModelEndpoint.azureAssistants] = azureConfig.assistantModels; } - if (!Array.isArray(endpoints[EModelEndpoint.custom])) { + if (!Array.isArray(appConfig.endpoints?.[EModelEndpoint.custom])) { return modelsConfig; } - const customEndpoints = endpoints[EModelEndpoint.custom].filter( + const customEndpoints = appConfig.endpoints[EModelEndpoint.custom].filter( (endpoint) => endpoint.baseURL && endpoint.apiKey && diff --git a/api/server/services/Config/loadConfigModels.spec.js b/api/server/services/Config/loadConfigModels.spec.js index e7199c59d..b8d577667 100644 --- a/api/server/services/Config/loadConfigModels.spec.js +++ b/api/server/services/Config/loadConfigModels.spec.js @@ -1,9 +1,9 @@ const { fetchModels } = require('~/server/services/ModelService'); -const { getCustomConfig } = require('./getCustomConfig'); const loadConfigModels = require('./loadConfigModels'); +const { getAppConfig } = require('./app'); jest.mock('~/server/services/ModelService'); -jest.mock('./getCustomConfig'); +jest.mock('./app'); const exampleConfig = { endpoints: { @@ -60,7 +60,7 @@ const exampleConfig = { }; describe('loadConfigModels', () => { - const mockRequest = { app: { locals: {} }, user: { id: 'testUserId' } }; + const mockRequest = { user: { id: 'testUserId' } }; const originalEnv = process.env; @@ -68,6 +68,9 @@ describe('loadConfigModels', () => { jest.resetAllMocks(); jest.resetModules(); process.env = { ...originalEnv }; + + // Default mock for getAppConfig + getAppConfig.mockResolvedValue({}); }); afterEach(() => { @@ -75,18 +78,15 @@ describe('loadConfigModels', () => { }); it('should return an empty object if customConfig is null', async () => { - getCustomConfig.mockResolvedValue(null); + getAppConfig.mockResolvedValue(null); const result = await loadConfigModels(mockRequest); expect(result).toEqual({}); }); it('handles azure models and endpoint correctly', async () => { - mockRequest.app.locals.azureOpenAI = { modelNames: ['model1', 'model2'] }; - getCustomConfig.mockResolvedValue({ + getAppConfig.mockResolvedValue({ endpoints: { - azureOpenAI: { - models: ['model1', 'model2'], - }, + azureOpenAI: { modelNames: ['model1', 'model2'] }, }, }); @@ -97,18 +97,16 @@ describe('loadConfigModels', () => { it('fetches custom models based on the unique key', async () => { process.env.BASE_URL = 'http://example.com'; process.env.API_KEY = 'some-api-key'; - const customEndpoints = { - custom: [ - { - baseURL: '${BASE_URL}', - apiKey: '${API_KEY}', - name: 'CustomModel', - models: { fetch: true }, - }, - ], - }; + const customEndpoints = [ + { + baseURL: '${BASE_URL}', + apiKey: '${API_KEY}', + name: 'CustomModel', + models: { fetch: true }, + }, + ]; - getCustomConfig.mockResolvedValue({ endpoints: customEndpoints }); + getAppConfig.mockResolvedValue({ endpoints: { custom: customEndpoints } }); fetchModels.mockResolvedValue(['customModel1', 'customModel2']); const result = await loadConfigModels(mockRequest); @@ -117,7 +115,7 @@ describe('loadConfigModels', () => { }); it('correctly associates models to names using unique keys', async () => { - getCustomConfig.mockResolvedValue({ + getAppConfig.mockResolvedValue({ endpoints: { custom: [ { @@ -146,7 +144,7 @@ describe('loadConfigModels', () => { it('correctly handles multiple endpoints with the same baseURL but different apiKeys', async () => { // Mock the custom configuration to simulate the user's scenario - getCustomConfig.mockResolvedValue({ + getAppConfig.mockResolvedValue({ endpoints: { custom: [ { @@ -210,7 +208,7 @@ describe('loadConfigModels', () => { process.env.MY_OPENROUTER_API_KEY = 'actual_openrouter_api_key'; // Setup custom configuration with specific API keys for Mistral and OpenRouter // and "user_provided" for groq and Ollama, indicating no fetch for the latter two - getCustomConfig.mockResolvedValue(exampleConfig); + getAppConfig.mockResolvedValue(exampleConfig); // Assuming fetchModels would be called only for Mistral and OpenRouter fetchModels.mockImplementation(({ name }) => { @@ -273,7 +271,7 @@ describe('loadConfigModels', () => { }); it('falls back to default models if fetching returns an empty array', async () => { - getCustomConfig.mockResolvedValue({ + getAppConfig.mockResolvedValue({ endpoints: { custom: [ { @@ -306,7 +304,7 @@ describe('loadConfigModels', () => { }); it('falls back to default models if fetching returns a falsy value', async () => { - getCustomConfig.mockResolvedValue({ + getAppConfig.mockResolvedValue({ endpoints: { custom: [ { @@ -367,7 +365,7 @@ describe('loadConfigModels', () => { }, ]; - getCustomConfig.mockResolvedValue({ + getAppConfig.mockResolvedValue({ endpoints: { custom: testCases, }, diff --git a/api/server/services/Config/loadDefaultEConfig.js b/api/server/services/Config/loadDefaultEConfig.js index a9602bac2..f3c12a493 100644 --- a/api/server/services/Config/loadDefaultEConfig.js +++ b/api/server/services/Config/loadDefaultEConfig.js @@ -4,11 +4,11 @@ const { config } = require('./EndpointService'); /** * Load async endpoints and return a configuration object - * @param {Express.Request} req - The request object + * @param {AppConfig} appConfig - The app configuration object * @returns {Promise>} An object whose keys are endpoint names and values are objects that contain the endpoint configuration and an order. */ -async function loadDefaultEndpointsConfig(req) { - const { google, gptPlugins } = await loadAsyncEndpoints(req); +async function loadDefaultEndpointsConfig(appConfig) { + const { google, gptPlugins } = await loadAsyncEndpoints(appConfig); const { assistants, azureAssistants, azureOpenAI, chatGPTBrowser } = config; const enabledEndpoints = getEnabledEndpoints(); diff --git a/api/server/services/Config/loadDefaultModels.js b/api/server/services/Config/loadDefaultModels.js index 84f40fccc..a70fa5849 100644 --- a/api/server/services/Config/loadDefaultModels.js +++ b/api/server/services/Config/loadDefaultModels.js @@ -11,7 +11,7 @@ const { * Loads the default models for the application. * @async * @function - * @param {Express.Request} req - The Express request object. + * @param {ServerRequest} req - The Express request object. */ async function loadDefaultModels(req) { try { diff --git a/api/server/services/Config/loadOverrideConfig.js b/api/server/services/Config/loadOverrideConfig.js deleted file mode 100644 index 1a90e814f..000000000 --- a/api/server/services/Config/loadOverrideConfig.js +++ /dev/null @@ -1,6 +0,0 @@ -// fetch some remote config -async function loadOverrideConfig() { - return false; -} - -module.exports = loadOverrideConfig; diff --git a/api/server/services/Endpoints/agents/agent.js b/api/server/services/Endpoints/agents/agent.js index 40dbe1700..56b4bf058 100644 --- a/api/server/services/Endpoints/agents/agent.js +++ b/api/server/services/Endpoints/agents/agent.js @@ -49,6 +49,7 @@ const initializeAgent = async ({ allowedProviders, isInitialAgent = false, }) => { + const appConfig = req.config; if ( isAgentsEndpoint(endpointOption?.endpoint) && allowedProviders.size > 0 && @@ -90,10 +91,11 @@ const initializeAgent = async ({ const { attachments, tool_resources } = await primeResources({ req, getFiles, + appConfig, + agentId: agent.id, attachments: currentFiles, tool_resources: agent.tool_resources, requestFileSet: new Set(requestFiles?.map((file) => file.file_id)), - agentId: agent.id, }); const provider = agent.provider; @@ -112,7 +114,7 @@ const initializeAgent = async ({ })) ?? {}; agent.endpoint = provider; - const { getOptions, overrideProvider } = await getProviderConfig(provider); + const { getOptions, overrideProvider } = getProviderConfig({ provider, appConfig }); if (overrideProvider !== agent.provider) { agent.provider = overrideProvider; } diff --git a/api/server/services/Endpoints/agents/initialize.js b/api/server/services/Endpoints/agents/initialize.js index f9b49abbc..7cc0a39fb 100644 --- a/api/server/services/Endpoints/agents/initialize.js +++ b/api/server/services/Endpoints/agents/initialize.js @@ -1,6 +1,6 @@ const { logger } = require('@librechat/data-schemas'); -const { validateAgentModel } = require('@librechat/api'); const { createContentAggregator } = require('@librechat/agents'); +const { validateAgentModel, getCustomEndpointConfig } = require('@librechat/api'); const { Constants, EModelEndpoint, @@ -13,7 +13,6 @@ const { } = require('~/server/controllers/agents/callbacks'); const { initializeAgent } = require('~/server/services/Endpoints/agents/agent'); const { getModelsConfig } = require('~/server/controllers/ModelController'); -const { getCustomEndpointConfig } = require('~/server/services/Config'); const { loadAgentTools } = require('~/server/services/ToolService'); const AgentClient = require('~/server/controllers/agents/client'); const { getAgent } = require('~/models/Agent'); @@ -58,6 +57,7 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { if (!endpointOption) { throw new Error('Endpoint option not provided'); } + const appConfig = req.config; // TODO: use endpointOption to determine options/modelOptions /** @type {Array} */ @@ -97,8 +97,7 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { } const agentConfigs = new Map(); - /** @type {Set} */ - const allowedProviders = new Set(req?.app?.locals?.[EModelEndpoint.agents]?.allowedProviders); + const allowedProviders = new Set(appConfig?.endpoints?.[EModelEndpoint.agents]?.allowedProviders); const loadTools = createToolLoader(signal); /** @type {Array} */ @@ -158,10 +157,13 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { } } - let endpointConfig = req.app.locals[primaryConfig.endpoint]; + let endpointConfig = appConfig.endpoints?.[primaryConfig.endpoint]; if (!isAgentsEndpoint(primaryConfig.endpoint) && !endpointConfig) { try { - endpointConfig = await getCustomEndpointConfig(primaryConfig.endpoint); + endpointConfig = getCustomEndpointConfig({ + endpoint: primaryConfig.endpoint, + appConfig, + }); } catch (err) { logger.error( '[api/server/controllers/agents/client.js #titleConvo] Error getting custom endpoint config', diff --git a/api/server/services/Endpoints/anthropic/initialize.js b/api/server/services/Endpoints/anthropic/initialize.js index 7c98d8a63..48b452672 100644 --- a/api/server/services/Endpoints/anthropic/initialize.js +++ b/api/server/services/Endpoints/anthropic/initialize.js @@ -4,6 +4,7 @@ const { getLLMConfig } = require('~/server/services/Endpoints/anthropic/llm'); const AnthropicClient = require('~/app/clients/AnthropicClient'); const initializeClient = async ({ req, res, endpointOption, overrideModel, optionsOnly }) => { + const appConfig = req.config; const { ANTHROPIC_API_KEY, ANTHROPIC_REVERSE_PROXY, PROXY } = process.env; const expiresAt = req.body.key; const isUserProvided = ANTHROPIC_API_KEY === 'user_provided'; @@ -23,15 +24,14 @@ const initializeClient = async ({ req, res, endpointOption, overrideModel, optio let clientOptions = {}; /** @type {undefined | TBaseEndpoint} */ - const anthropicConfig = req.app.locals[EModelEndpoint.anthropic]; + const anthropicConfig = appConfig.endpoints?.[EModelEndpoint.anthropic]; if (anthropicConfig) { clientOptions.streamRate = anthropicConfig.streamRate; clientOptions.titleModel = anthropicConfig.titleModel; } - /** @type {undefined | TBaseEndpoint} */ - const allConfig = req.app.locals.all; + const allConfig = appConfig.endpoints?.all; if (allConfig) { clientOptions.streamRate = allConfig.streamRate; } diff --git a/api/server/services/Endpoints/azureAssistants/initialize.js b/api/server/services/Endpoints/azureAssistants/initialize.js index e8aaf89e0..a6fb3e85f 100644 --- a/api/server/services/Endpoints/azureAssistants/initialize.js +++ b/api/server/services/Endpoints/azureAssistants/initialize.js @@ -48,6 +48,7 @@ class Files { } const initializeClient = async ({ req, res, version, endpointOption, initAppClient = false }) => { + const appConfig = req.config; const { PROXY, OPENAI_ORGANIZATION, AZURE_ASSISTANTS_API_KEY, AZURE_ASSISTANTS_BASE_URL } = process.env; @@ -81,7 +82,7 @@ const initializeClient = async ({ req, res, version, endpointOption, initAppClie }; /** @type {TAzureConfig | undefined} */ - const azureConfig = req.app.locals[EModelEndpoint.azureOpenAI]; + const azureConfig = appConfig.endpoints?.[EModelEndpoint.azureOpenAI]; /** @type {AzureOptions | undefined} */ let azureOptions; diff --git a/api/server/services/Endpoints/azureAssistants/initialize.spec.js b/api/server/services/Endpoints/azureAssistants/initialize.spec.js index 28e1004c9..d74373ae1 100644 --- a/api/server/services/Endpoints/azureAssistants/initialize.spec.js +++ b/api/server/services/Endpoints/azureAssistants/initialize.spec.js @@ -1,6 +1,6 @@ // const OpenAI = require('openai'); const { ProxyAgent } = require('undici'); -const { ErrorTypes } = require('librechat-data-provider'); +const { ErrorTypes, EModelEndpoint } = require('librechat-data-provider'); const { getUserKey, getUserKeyExpiry, getUserKeyValues } = require('~/server/services/UserService'); const initializeClient = require('./initialize'); // const { OpenAIClient } = require('~/app'); @@ -12,6 +12,8 @@ jest.mock('~/server/services/UserService', () => ({ checkUserKeyExpiry: jest.requireActual('~/server/services/UserService').checkUserKeyExpiry, })); +// Config is now passed via req.config, not getAppConfig + const today = new Date(); const tenDaysFromToday = new Date(today.setDate(today.getDate() + 10)); const isoString = tenDaysFromToday.toISOString(); @@ -41,7 +43,11 @@ describe('initializeClient', () => { isUserProvided: jest.fn().mockReturnValueOnce(false), })); - const req = { user: { id: 'user123' }, app }; + const req = { + user: { id: 'user123' }, + app, + config: { endpoints: { [EModelEndpoint.azureOpenAI]: {} } }, + }; const res = {}; const { openai, openAIApiKey } = await initializeClient({ req, res }); @@ -57,7 +63,11 @@ describe('initializeClient', () => { getUserKeyValues.mockResolvedValue({ apiKey: 'user-api-key', baseURL: 'https://user.api.url' }); getUserKeyExpiry.mockResolvedValue(isoString); - const req = { user: { id: 'user123' }, app }; + const req = { + user: { id: 'user123' }, + app, + config: { endpoints: { [EModelEndpoint.azureOpenAI]: {} } }, + }; const res = {}; const { openai, openAIApiKey } = await initializeClient({ req, res }); @@ -74,7 +84,7 @@ describe('initializeClient', () => { let userValues = getUserKey(); try { userValues = JSON.parse(userValues); - } catch (e) { + } catch { throw new Error( JSON.stringify({ type: ErrorTypes.INVALID_USER_KEY, @@ -84,7 +94,10 @@ describe('initializeClient', () => { return userValues; }); - const req = { user: { id: 'user123' } }; + const req = { + user: { id: 'user123' }, + config: { endpoints: { [EModelEndpoint.azureOpenAI]: {} } }, + }; const res = {}; await expect(initializeClient({ req, res })).rejects.toThrow(/invalid_user_key/); @@ -93,7 +106,11 @@ describe('initializeClient', () => { test('throws error if API key is not provided', async () => { delete process.env.AZURE_ASSISTANTS_API_KEY; // Simulate missing API key - const req = { user: { id: 'user123' }, app }; + const req = { + user: { id: 'user123' }, + app, + config: { endpoints: { [EModelEndpoint.azureOpenAI]: {} } }, + }; const res = {}; await expect(initializeClient({ req, res })).rejects.toThrow(/Assistants API key not/); @@ -103,7 +120,11 @@ describe('initializeClient', () => { process.env.AZURE_ASSISTANTS_API_KEY = 'test-key'; process.env.PROXY = 'http://proxy.server'; - const req = { user: { id: 'user123' }, app }; + const req = { + user: { id: 'user123' }, + app, + config: { endpoints: { [EModelEndpoint.azureOpenAI]: {} } }, + }; const res = {}; const { openai } = await initializeClient({ req, res }); diff --git a/api/server/services/Endpoints/bedrock/options.js b/api/server/services/Endpoints/bedrock/options.js index a31d6e10c..2bc18f9a7 100644 --- a/api/server/services/Endpoints/bedrock/options.js +++ b/api/server/services/Endpoints/bedrock/options.js @@ -11,6 +11,7 @@ const { const { getUserKey, checkUserKeyExpiry } = require('~/server/services/UserService'); const getOptions = async ({ req, overrideModel, endpointOption }) => { + const appConfig = req.config; const { BEDROCK_AWS_SECRET_ACCESS_KEY, BEDROCK_AWS_ACCESS_KEY_ID, @@ -50,14 +51,13 @@ const getOptions = async ({ req, overrideModel, endpointOption }) => { let streamRate = Constants.DEFAULT_STREAM_RATE; /** @type {undefined | TBaseEndpoint} */ - const bedrockConfig = req.app.locals[EModelEndpoint.bedrock]; + const bedrockConfig = appConfig.endpoints?.[EModelEndpoint.bedrock]; if (bedrockConfig && bedrockConfig.streamRate) { streamRate = bedrockConfig.streamRate; } - /** @type {undefined | TBaseEndpoint} */ - const allConfig = req.app.locals.all; + const allConfig = appConfig.endpoints?.all; if (allConfig && allConfig.streamRate) { streamRate = allConfig.streamRate; } diff --git a/api/server/services/Endpoints/custom/initialize.js b/api/server/services/Endpoints/custom/initialize.js index 184cb612d..4a1026636 100644 --- a/api/server/services/Endpoints/custom/initialize.js +++ b/api/server/services/Endpoints/custom/initialize.js @@ -1,3 +1,11 @@ +const { Providers } = require('@librechat/agents'); +const { + resolveHeaders, + isUserProvided, + getOpenAIConfig, + getCustomEndpointConfig, + createHandleLLMNewToken, +} = require('@librechat/api'); const { CacheKeys, ErrorTypes, @@ -5,22 +13,22 @@ const { FetchTokenConfig, extractEnvVariable, } = require('librechat-data-provider'); -const { Providers } = require('@librechat/agents'); -const { getOpenAIConfig, createHandleLLMNewToken, resolveHeaders } = require('@librechat/api'); const { getUserKeyValues, checkUserKeyExpiry } = require('~/server/services/UserService'); -const { getCustomEndpointConfig } = require('~/server/services/Config'); const { fetchModels } = require('~/server/services/ModelService'); const OpenAIClient = require('~/app/clients/OpenAIClient'); -const { isUserProvided } = require('~/server/utils'); const getLogStores = require('~/cache/getLogStores'); const { PROXY } = process.env; const initializeClient = async ({ req, res, endpointOption, optionsOnly, overrideEndpoint }) => { + const appConfig = req.config; const { key: expiresAt } = req.body; const endpoint = overrideEndpoint ?? req.body.endpoint; - const endpointConfig = await getCustomEndpointConfig(endpoint); + const endpointConfig = getCustomEndpointConfig({ + endpoint, + appConfig, + }); if (!endpointConfig) { throw new Error(`Config not found for the ${endpoint} custom endpoint.`); } @@ -117,8 +125,7 @@ const initializeClient = async ({ req, res, endpointOption, optionsOnly, overrid endpointTokenConfig, }; - /** @type {undefined | TBaseEndpoint} */ - const allConfig = req.app.locals.all; + const allConfig = appConfig.endpoints?.all; if (allConfig) { customOptions.streamRate = allConfig.streamRate; } diff --git a/api/server/services/Endpoints/custom/initialize.spec.js b/api/server/services/Endpoints/custom/initialize.spec.js index 373620d42..38d65a158 100644 --- a/api/server/services/Endpoints/custom/initialize.spec.js +++ b/api/server/services/Endpoints/custom/initialize.spec.js @@ -1,21 +1,16 @@ const initializeClient = require('./initialize'); jest.mock('@librechat/api', () => ({ + ...jest.requireActual('@librechat/api'), resolveHeaders: jest.fn(), getOpenAIConfig: jest.fn(), createHandleLLMNewToken: jest.fn(), -})); - -jest.mock('librechat-data-provider', () => ({ - CacheKeys: { TOKEN_CONFIG: 'token_config' }, - ErrorTypes: { NO_USER_KEY: 'NO_USER_KEY', NO_BASE_URL: 'NO_BASE_URL' }, - envVarRegex: /\$\{([^}]+)\}/, - FetchTokenConfig: {}, - extractEnvVariable: jest.fn((value) => value), -})); - -jest.mock('@librechat/agents', () => ({ - Providers: { OLLAMA: 'ollama' }, + getCustomEndpointConfig: jest.fn().mockReturnValue({ + apiKey: 'test-key', + baseURL: 'https://test.com', + headers: { 'x-user': '{{LIBRECHAT_USER_ID}}', 'x-email': '{{LIBRECHAT_USER_EMAIL}}' }, + models: { default: ['test-model'] }, + }), })); jest.mock('~/server/services/UserService', () => ({ @@ -23,14 +18,7 @@ jest.mock('~/server/services/UserService', () => ({ checkUserKeyExpiry: jest.fn(), })); -jest.mock('~/server/services/Config', () => ({ - getCustomEndpointConfig: jest.fn().mockResolvedValue({ - apiKey: 'test-key', - baseURL: 'https://test.com', - headers: { 'x-user': '{{LIBRECHAT_USER_ID}}', 'x-email': '{{LIBRECHAT_USER_EMAIL}}' }, - models: { default: ['test-model'] }, - }), -})); +// Config is now passed via req.config, not getAppConfig jest.mock('~/server/services/ModelService', () => ({ fetchModels: jest.fn(), @@ -42,10 +30,6 @@ jest.mock('~/app/clients/OpenAIClient', () => { })); }); -jest.mock('~/server/utils', () => ({ - isUserProvided: jest.fn().mockReturnValue(false), -})); - jest.mock('~/cache/getLogStores', () => jest.fn().mockReturnValue({ get: jest.fn(), @@ -55,13 +39,35 @@ jest.mock('~/cache/getLogStores', () => describe('custom/initializeClient', () => { const mockRequest = { body: { endpoint: 'test-endpoint' }, - user: { id: 'user-123', email: 'test@example.com' }, + user: { id: 'user-123', email: 'test@example.com', role: 'user' }, app: { locals: {} }, + config: { + endpoints: { + all: { + streamRate: 25, + }, + }, + }, }; const mockResponse = {}; beforeEach(() => { jest.clearAllMocks(); + const { getCustomEndpointConfig, resolveHeaders, getOpenAIConfig } = require('@librechat/api'); + getCustomEndpointConfig.mockReturnValue({ + apiKey: 'test-key', + baseURL: 'https://test.com', + headers: { 'x-user': '{{LIBRECHAT_USER_ID}}', 'x-email': '{{LIBRECHAT_USER_EMAIL}}' }, + models: { default: ['test-model'] }, + }); + resolveHeaders.mockReturnValue({ 'x-user': 'user-123', 'x-email': 'test@example.com' }); + getOpenAIConfig.mockReturnValue({ + useLegacyContent: true, + endpointTokenConfig: null, + llmConfig: { + callbacks: [], + }, + }); }); it('calls resolveHeaders with headers, user, and body for body placeholder support', async () => { @@ -69,14 +75,14 @@ describe('custom/initializeClient', () => { await initializeClient({ req: mockRequest, res: mockResponse, optionsOnly: true }); expect(resolveHeaders).toHaveBeenCalledWith({ headers: { 'x-user': '{{LIBRECHAT_USER_ID}}', 'x-email': '{{LIBRECHAT_USER_EMAIL}}' }, - user: { id: 'user-123', email: 'test@example.com' }, + user: { id: 'user-123', email: 'test@example.com', role: 'user' }, body: { endpoint: 'test-endpoint' }, // body - supports {{LIBRECHAT_BODY_*}} placeholders }); }); it('throws if endpoint config is missing', async () => { - const { getCustomEndpointConfig } = require('~/server/services/Config'); - getCustomEndpointConfig.mockResolvedValueOnce(null); + const { getCustomEndpointConfig } = require('@librechat/api'); + getCustomEndpointConfig.mockReturnValueOnce(null); await expect( initializeClient({ req: mockRequest, res: mockResponse, optionsOnly: true }), ).rejects.toThrow('Config not found for the test-endpoint custom endpoint.'); diff --git a/api/server/services/Endpoints/google/initialize.js b/api/server/services/Endpoints/google/initialize.js index 75a31a8c0..9a685d679 100644 --- a/api/server/services/Endpoints/google/initialize.js +++ b/api/server/services/Endpoints/google/initialize.js @@ -46,10 +46,11 @@ const initializeClient = async ({ req, res, endpointOption, overrideModel, optio let clientOptions = {}; + const appConfig = req.config; /** @type {undefined | TBaseEndpoint} */ - const allConfig = req.app.locals.all; + const allConfig = appConfig.endpoints?.all; /** @type {undefined | TBaseEndpoint} */ - const googleConfig = req.app.locals[EModelEndpoint.google]; + const googleConfig = appConfig.endpoints?.[EModelEndpoint.google]; if (googleConfig) { clientOptions.streamRate = googleConfig.streamRate; diff --git a/api/server/services/Endpoints/google/initialize.spec.js b/api/server/services/Endpoints/google/initialize.spec.js index e5391107b..aa8a61e9c 100644 --- a/api/server/services/Endpoints/google/initialize.spec.js +++ b/api/server/services/Endpoints/google/initialize.spec.js @@ -8,6 +8,8 @@ jest.mock('~/server/services/UserService', () => ({ getUserKey: jest.fn().mockImplementation(() => ({})), })); +// Config is now passed via req.config, not getAppConfig + const app = { locals: {} }; describe('google/initializeClient', () => { @@ -26,6 +28,12 @@ describe('google/initializeClient', () => { body: { key: expiresAt }, user: { id: '123' }, app, + config: { + endpoints: { + all: {}, + google: {}, + }, + }, }; const res = {}; const endpointOption = { modelOptions: { model: 'default-model' } }; @@ -48,6 +56,12 @@ describe('google/initializeClient', () => { body: { key: null }, user: { id: '123' }, app, + config: { + endpoints: { + all: {}, + google: {}, + }, + }, }; const res = {}; const endpointOption = { modelOptions: { model: 'default-model' } }; @@ -71,6 +85,12 @@ describe('google/initializeClient', () => { body: { key: expiresAt }, user: { id: '123' }, app, + config: { + endpoints: { + all: {}, + google: {}, + }, + }, }; const res = {}; const endpointOption = { modelOptions: { model: 'default-model' } }; diff --git a/api/server/services/Endpoints/google/title.js b/api/server/services/Endpoints/google/title.js index dd8aa7a22..63ed8aae5 100644 --- a/api/server/services/Endpoints/google/title.js +++ b/api/server/services/Endpoints/google/title.js @@ -1,7 +1,7 @@ +const { isEnabled } = require('@librechat/api'); const { EModelEndpoint, CacheKeys, Constants, googleSettings } = require('librechat-data-provider'); const getLogStores = require('~/cache/getLogStores'); const initializeClient = require('./initialize'); -const { isEnabled } = require('~/server/utils'); const { saveConvo } = require('~/models'); const addTitle = async (req, { text, response, client }) => { @@ -14,7 +14,8 @@ const addTitle = async (req, { text, response, client }) => { return; } const { GOOGLE_TITLE_MODEL } = process.env ?? {}; - const providerConfig = req.app.locals[EModelEndpoint.google]; + const appConfig = req.config; + const providerConfig = appConfig.endpoints?.[EModelEndpoint.google]; let model = providerConfig?.titleModel ?? GOOGLE_TITLE_MODEL ?? diff --git a/api/server/services/Endpoints/index.js b/api/server/services/Endpoints/index.js index 1847f0ca9..b18dfb797 100644 --- a/api/server/services/Endpoints/index.js +++ b/api/server/services/Endpoints/index.js @@ -1,11 +1,11 @@ const { Providers } = require('@librechat/agents'); const { EModelEndpoint } = require('librechat-data-provider'); +const { getCustomEndpointConfig } = require('@librechat/api'); const initAnthropic = require('~/server/services/Endpoints/anthropic/initialize'); const getBedrockOptions = require('~/server/services/Endpoints/bedrock/options'); const initOpenAI = require('~/server/services/Endpoints/openAI/initialize'); const initCustom = require('~/server/services/Endpoints/custom/initialize'); const initGoogle = require('~/server/services/Endpoints/google/initialize'); -const { getCustomEndpointConfig } = require('~/server/services/Config'); /** Check if the provider is a known custom provider * @param {string | undefined} [provider] - The provider string @@ -31,14 +31,16 @@ const providerConfigMap = { /** * Get the provider configuration and override endpoint based on the provider string - * @param {string} provider - The provider string - * @returns {Promise<{ - * getOptions: Function, + * @param {Object} params + * @param {string} params.provider - The provider string + * @param {AppConfig} params.appConfig - The application configuration + * @returns {{ + * getOptions: (typeof providerConfigMap)[keyof typeof providerConfigMap], * overrideProvider: string, * customEndpointConfig?: TEndpoint - * }>} + * }} */ -async function getProviderConfig(provider) { +function getProviderConfig({ provider, appConfig }) { let getOptions = providerConfigMap[provider]; let overrideProvider = provider; /** @type {TEndpoint | undefined} */ @@ -48,7 +50,7 @@ async function getProviderConfig(provider) { overrideProvider = provider.toLowerCase(); getOptions = providerConfigMap[overrideProvider]; } else if (!getOptions) { - customEndpointConfig = await getCustomEndpointConfig(provider); + customEndpointConfig = getCustomEndpointConfig({ endpoint: provider, appConfig }); if (!customEndpointConfig) { throw new Error(`Provider ${provider} not supported`); } @@ -57,7 +59,7 @@ async function getProviderConfig(provider) { } if (isKnownCustomProvider(overrideProvider) && !customEndpointConfig) { - customEndpointConfig = await getCustomEndpointConfig(provider); + customEndpointConfig = getCustomEndpointConfig({ endpoint: provider, appConfig }); if (!customEndpointConfig) { throw new Error(`Provider ${provider} not supported`); } diff --git a/api/server/services/Endpoints/openAI/initialize.js b/api/server/services/Endpoints/openAI/initialize.js index 30673ecb5..391b194ab 100644 --- a/api/server/services/Endpoints/openAI/initialize.js +++ b/api/server/services/Endpoints/openAI/initialize.js @@ -18,6 +18,7 @@ const initializeClient = async ({ overrideEndpoint, overrideModel, }) => { + const appConfig = req.config; const { PROXY, OPENAI_API_KEY, @@ -64,7 +65,7 @@ const initializeClient = async ({ const isAzureOpenAI = endpoint === EModelEndpoint.azureOpenAI; /** @type {false | TAzureConfig} */ - const azureConfig = isAzureOpenAI && req.app.locals[EModelEndpoint.azureOpenAI]; + const azureConfig = isAzureOpenAI && appConfig.endpoints?.[EModelEndpoint.azureOpenAI]; let serverless = false; if (isAzureOpenAI && azureConfig) { const { modelGroupMap, groupMap } = azureConfig; @@ -113,15 +114,14 @@ const initializeClient = async ({ } /** @type {undefined | TBaseEndpoint} */ - const openAIConfig = req.app.locals[EModelEndpoint.openAI]; + const openAIConfig = appConfig.endpoints?.[EModelEndpoint.openAI]; if (!isAzureOpenAI && openAIConfig) { clientOptions.streamRate = openAIConfig.streamRate; clientOptions.titleModel = openAIConfig.titleModel; } - /** @type {undefined | TBaseEndpoint} */ - const allConfig = req.app.locals.all; + const allConfig = appConfig.endpoints?.all; if (allConfig) { clientOptions.streamRate = allConfig.streamRate; } diff --git a/api/server/services/Endpoints/openAI/initialize.spec.js b/api/server/services/Endpoints/openAI/initialize.spec.js index 16563e4b2..d51300aaf 100644 --- a/api/server/services/Endpoints/openAI/initialize.spec.js +++ b/api/server/services/Endpoints/openAI/initialize.spec.js @@ -1,4 +1,13 @@ -jest.mock('~/cache/getLogStores'); +jest.mock('~/cache/getLogStores', () => ({ + getLogStores: jest.fn().mockReturnValue({ + get: jest.fn().mockResolvedValue({ + openAI: { apiKey: 'test-key' }, + }), + set: jest.fn(), + delete: jest.fn(), + }), +})); + const { EModelEndpoint, ErrorTypes, validateAzureGroups } = require('librechat-data-provider'); const { getUserKey, getUserKeyValues } = require('~/server/services/UserService'); const initializeClient = require('./initialize'); @@ -11,6 +20,38 @@ jest.mock('~/server/services/UserService', () => ({ checkUserKeyExpiry: jest.requireActual('~/server/services/UserService').checkUserKeyExpiry, })); +const mockAppConfig = { + endpoints: { + openAI: { + apiKey: 'test-key', + }, + azureOpenAI: { + apiKey: 'test-azure-key', + modelNames: ['gpt-4-vision-preview', 'gpt-3.5-turbo', 'gpt-4'], + modelGroupMap: { + 'gpt-4-vision-preview': { + group: 'librechat-westus', + deploymentName: 'gpt-4-vision-preview', + version: '2024-02-15-preview', + }, + }, + groupMap: { + 'librechat-westus': { + apiKey: 'WESTUS_API_KEY', + instanceName: 'librechat-westus', + version: '2023-12-01-preview', + models: { + 'gpt-4-vision-preview': { + deploymentName: 'gpt-4-vision-preview', + version: '2024-02-15-preview', + }, + }, + }, + }, + }, + }, +}; + describe('initializeClient', () => { // Set up environment variables const originalEnvironment = process.env; @@ -79,7 +120,7 @@ describe('initializeClient', () => { }, ]; - const { modelNames, modelGroupMap, groupMap } = validateAzureGroups(validAzureConfigs); + const { modelNames } = validateAzureGroups(validAzureConfigs); beforeEach(() => { jest.resetModules(); // Clears the cache @@ -99,6 +140,7 @@ describe('initializeClient', () => { body: { key: null, endpoint: EModelEndpoint.openAI }, user: { id: '123' }, app, + config: mockAppConfig, }; const res = {}; const endpointOption = {}; @@ -112,25 +154,30 @@ describe('initializeClient', () => { test('should initialize client with Azure credentials when endpoint is azureOpenAI', async () => { process.env.AZURE_API_KEY = 'test-azure-api-key'; (process.env.AZURE_OPENAI_API_INSTANCE_NAME = 'some-value'), - (process.env.AZURE_OPENAI_API_DEPLOYMENT_NAME = 'some-value'), - (process.env.AZURE_OPENAI_API_VERSION = 'some-value'), - (process.env.AZURE_OPENAI_API_COMPLETIONS_DEPLOYMENT_NAME = 'some-value'), - (process.env.AZURE_OPENAI_API_EMBEDDINGS_DEPLOYMENT_NAME = 'some-value'), - (process.env.OPENAI_API_KEY = 'test-openai-api-key'); + (process.env.AZURE_OPENAI_API_DEPLOYMENT_NAME = 'some-value'), + (process.env.AZURE_OPENAI_API_VERSION = 'some-value'), + (process.env.AZURE_OPENAI_API_COMPLETIONS_DEPLOYMENT_NAME = 'some-value'), + (process.env.AZURE_OPENAI_API_EMBEDDINGS_DEPLOYMENT_NAME = 'some-value'), + (process.env.OPENAI_API_KEY = 'test-openai-api-key'); process.env.DEBUG_OPENAI = 'false'; process.env.OPENAI_SUMMARIZE = 'false'; const req = { - body: { key: null, endpoint: 'azureOpenAI' }, + body: { + key: null, + endpoint: 'azureOpenAI', + model: 'gpt-4-vision-preview', + }, user: { id: '123' }, app, + config: mockAppConfig, }; const res = {}; - const endpointOption = { modelOptions: { model: 'test-model' } }; + const endpointOption = {}; const client = await initializeClient({ req, res, endpointOption }); - expect(client.openAIApiKey).toBe('test-azure-api-key'); + expect(client.openAIApiKey).toBe('WESTUS_API_KEY'); expect(client.client).toBeInstanceOf(OpenAIClient); }); @@ -142,6 +189,7 @@ describe('initializeClient', () => { body: { key: null, endpoint: EModelEndpoint.openAI }, user: { id: '123' }, app, + config: mockAppConfig, }; const res = {}; const endpointOption = {}; @@ -159,6 +207,7 @@ describe('initializeClient', () => { body: { key: null, endpoint: EModelEndpoint.openAI }, user: { id: '123' }, app, + config: mockAppConfig, }; const res = {}; const endpointOption = {}; @@ -177,6 +226,7 @@ describe('initializeClient', () => { body: { key: null, endpoint: EModelEndpoint.openAI }, user: { id: '123' }, app, + config: mockAppConfig, }; const res = {}; const endpointOption = {}; @@ -198,6 +248,7 @@ describe('initializeClient', () => { body: { key: expiresAt, endpoint: EModelEndpoint.openAI }, user: { id: '123' }, app, + config: mockAppConfig, }; const res = {}; const endpointOption = {}; @@ -216,6 +267,7 @@ describe('initializeClient', () => { body: { key: null, endpoint: EModelEndpoint.openAI }, user: { id: '123' }, app, + config: mockAppConfig, }; const res = {}; const endpointOption = {}; @@ -236,6 +288,7 @@ describe('initializeClient', () => { id: '123', }, app, + config: mockAppConfig, }; const res = {}; @@ -260,6 +313,7 @@ describe('initializeClient', () => { body: { key: invalidKey, endpoint: EModelEndpoint.openAI }, user: { id: '123' }, app, + config: mockAppConfig, }; const res = {}; const endpointOption = {}; @@ -281,6 +335,7 @@ describe('initializeClient', () => { body: { key: new Date(Date.now() + 10000).toISOString(), endpoint: EModelEndpoint.openAI }, user: { id: '123' }, app, + config: mockAppConfig, }; const res = {}; const endpointOption = {}; @@ -291,7 +346,7 @@ describe('initializeClient', () => { let userValues = getUserKey(); try { userValues = JSON.parse(userValues); - } catch (e) { + } catch { throw new Error( JSON.stringify({ type: ErrorTypes.INVALID_USER_KEY, @@ -307,6 +362,9 @@ describe('initializeClient', () => { }); test('should initialize client correctly for Azure OpenAI with valid configuration', async () => { + // Set up Azure environment variables + process.env.WESTUS_API_KEY = 'test-westus-key'; + const req = { body: { key: null, @@ -314,15 +372,7 @@ describe('initializeClient', () => { model: modelNames[0], }, user: { id: '123' }, - app: { - locals: { - [EModelEndpoint.azureOpenAI]: { - modelNames, - modelGroupMap, - groupMap, - }, - }, - }, + config: mockAppConfig, }; const res = {}; const endpointOption = {}; @@ -340,6 +390,7 @@ describe('initializeClient', () => { body: { key: null, endpoint: EModelEndpoint.openAI }, user: { id: '123' }, app, + config: mockAppConfig, }; const res = {}; const endpointOption = {}; @@ -362,6 +413,7 @@ describe('initializeClient', () => { id: '123', }, app, + config: mockAppConfig, }; const res = {}; const endpointOption = {}; diff --git a/api/server/services/Files/Audio/STTService.js b/api/server/services/Files/Audio/STTService.js index 49a800336..2b6fa1a39 100644 --- a/api/server/services/Files/Audio/STTService.js +++ b/api/server/services/Files/Audio/STTService.js @@ -2,10 +2,10 @@ const axios = require('axios'); const fs = require('fs').promises; const FormData = require('form-data'); const { Readable } = require('stream'); +const { logger } = require('@librechat/data-schemas'); const { genAzureEndpoint } = require('@librechat/api'); const { extractEnvVariable, STTProviders } = require('librechat-data-provider'); -const { getCustomConfig } = require('~/server/services/Config'); -const { logger } = require('~/config'); +const { getAppConfig } = require('~/server/services/Config'); /** * Maps MIME types to their corresponding file extensions for audio files. @@ -84,12 +84,7 @@ function getFileExtensionFromMime(mimeType) { * @class */ class STTService { - /** - * Creates an instance of STTService. - * @param {Object} customConfig - The custom configuration object. - */ - constructor(customConfig) { - this.customConfig = customConfig; + constructor() { this.providerStrategies = { [STTProviders.OPENAI]: this.openAIProvider, [STTProviders.AZURE_OPENAI]: this.azureOpenAIProvider, @@ -104,21 +99,20 @@ class STTService { * @throws {Error} If the custom config is not found. */ static async getInstance() { - const customConfig = await getCustomConfig(); - if (!customConfig) { - throw new Error('Custom config not found'); - } - return new STTService(customConfig); + return new STTService(); } /** * Retrieves the configured STT provider and its schema. + * @param {ServerRequest} req - The request object. * @returns {Promise<[string, Object]>} A promise that resolves to an array containing the provider name and its schema. * @throws {Error} If no STT schema is set, multiple providers are set, or no provider is set. */ - async getProviderSchema() { - const sttSchema = this.customConfig.speech.stt; - + async getProviderSchema(req) { + const appConfig = await getAppConfig({ + role: req?.user?.role, + }); + const sttSchema = appConfig?.speech?.stt; if (!sttSchema) { throw new Error( 'No STT schema is set. Did you configure STT in the custom config (librechat.yaml)?', @@ -274,7 +268,7 @@ class STTService { * @param {Object} res - The response object. * @returns {Promise} */ - async processTextToSpeech(req, res) { + async processSpeechToText(req, res) { if (!req.file) { return res.status(400).json({ message: 'No audio file provided in the FormData' }); } @@ -287,7 +281,7 @@ class STTService { }; try { - const [provider, sttSchema] = await this.getProviderSchema(); + const [provider, sttSchema] = await this.getProviderSchema(req); const text = await this.sttRequest(provider, sttSchema, { audioBuffer, audioFile }); res.json({ text }); } catch (error) { @@ -297,7 +291,7 @@ class STTService { try { await fs.unlink(req.file.path); logger.debug('[/speech/stt] Temp. audio upload file deleted'); - } catch (error) { + } catch { logger.debug('[/speech/stt] Temp. audio upload file already deleted'); } } @@ -322,7 +316,7 @@ async function createSTTService() { */ async function speechToText(req, res) { const sttService = await createSTTService(); - await sttService.processTextToSpeech(req, res); + await sttService.processSpeechToText(req, res); } module.exports = { speechToText }; diff --git a/api/server/services/Files/Audio/TTSService.js b/api/server/services/Files/Audio/TTSService.js index 34d820215..83c91b2f1 100644 --- a/api/server/services/Files/Audio/TTSService.js +++ b/api/server/services/Files/Audio/TTSService.js @@ -1,9 +1,9 @@ const axios = require('axios'); +const { logger } = require('@librechat/data-schemas'); const { genAzureEndpoint } = require('@librechat/api'); const { extractEnvVariable, TTSProviders } = require('librechat-data-provider'); const { getRandomVoiceId, createChunkProcessor, splitTextIntoChunks } = require('./streamAudio'); -const { getCustomConfig } = require('~/server/services/Config'); -const { logger } = require('~/config'); +const { getAppConfig } = require('~/server/services/Config'); /** * Service class for handling Text-to-Speech (TTS) operations. @@ -12,10 +12,8 @@ const { logger } = require('~/config'); class TTSService { /** * Creates an instance of TTSService. - * @param {Object} customConfig - The custom configuration object. */ - constructor(customConfig) { - this.customConfig = customConfig; + constructor() { this.providerStrategies = { [TTSProviders.OPENAI]: this.openAIProvider.bind(this), [TTSProviders.AZURE_OPENAI]: this.azureOpenAIProvider.bind(this), @@ -32,11 +30,7 @@ class TTSService { * @throws {Error} If the custom config is not found. */ static async getInstance() { - const customConfig = await getCustomConfig(); - if (!customConfig) { - throw new Error('Custom config not found'); - } - return new TTSService(customConfig); + return new TTSService(); } /** @@ -293,10 +287,13 @@ class TTSService { return res.status(400).send('Missing text in request body'); } + const appConfig = await getAppConfig({ + role: req.user?.role, + }); try { res.setHeader('Content-Type', 'audio/mpeg'); const provider = this.getProvider(); - const ttsSchema = this.customConfig.speech.tts[provider]; + const ttsSchema = appConfig?.speech?.tts?.[provider]; const voice = await this.getVoice(ttsSchema, requestVoice); if (input.length < 4096) { diff --git a/api/server/services/Files/Audio/getCustomConfigSpeech.js b/api/server/services/Files/Audio/getCustomConfigSpeech.js index 36f97bc49..b4bc8f704 100644 --- a/api/server/services/Files/Audio/getCustomConfigSpeech.js +++ b/api/server/services/Files/Audio/getCustomConfigSpeech.js @@ -1,5 +1,5 @@ -const { getCustomConfig } = require('~/server/services/Config'); -const { logger } = require('~/config'); +const { logger } = require('@librechat/data-schemas'); +const { getAppConfig } = require('~/server/services/Config'); /** * This function retrieves the speechTab settings from the custom configuration @@ -15,26 +15,28 @@ const { logger } = require('~/config'); */ async function getCustomConfigSpeech(req, res) { try { - const customConfig = await getCustomConfig(); + const appConfig = await getAppConfig({ + role: req.user?.role, + }); - if (!customConfig) { + if (!appConfig) { return res.status(200).send({ message: 'not_found', }); } - const sttExternal = !!customConfig.speech?.stt; - const ttsExternal = !!customConfig.speech?.tts; + const sttExternal = !!appConfig.speech?.stt; + const ttsExternal = !!appConfig.speech?.tts; let settings = { sttExternal, ttsExternal, }; - if (!customConfig.speech?.speechTab) { + if (!appConfig.speech?.speechTab) { return res.status(200).send(settings); } - const speechTab = customConfig.speech.speechTab; + const speechTab = appConfig.speech.speechTab; if (speechTab.advancedMode !== undefined) { settings.advancedMode = speechTab.advancedMode; diff --git a/api/server/services/Files/Audio/getVoices.js b/api/server/services/Files/Audio/getVoices.js index 24612d85e..be13abdc8 100644 --- a/api/server/services/Files/Audio/getVoices.js +++ b/api/server/services/Files/Audio/getVoices.js @@ -1,5 +1,5 @@ const { TTSProviders } = require('librechat-data-provider'); -const { getCustomConfig } = require('~/server/services/Config'); +const { getAppConfig } = require('~/server/services/Config'); const { getProvider } = require('./TTSService'); /** @@ -14,13 +14,15 @@ const { getProvider } = require('./TTSService'); */ async function getVoices(req, res) { try { - const customConfig = await getCustomConfig(); + const appConfig = await getAppConfig({ + role: req.user?.role, + }); - if (!customConfig || !customConfig?.speech?.tts) { + if (!appConfig || !appConfig?.speech?.tts) { throw new Error('Configuration or TTS schema is missing'); } - const ttsSchema = customConfig?.speech?.tts; + const ttsSchema = appConfig?.speech?.tts; const provider = await getProvider(ttsSchema); let voices; diff --git a/api/server/services/Files/Azure/images.js b/api/server/services/Files/Azure/images.js index 80d5e7629..4d857d0d1 100644 --- a/api/server/services/Files/Azure/images.js +++ b/api/server/services/Files/Azure/images.js @@ -30,6 +30,7 @@ async function uploadImageToAzure({ containerName, }) { try { + const appConfig = req.config; const inputFilePath = file.path; const inputBuffer = await fs.promises.readFile(inputFilePath); const { @@ -41,12 +42,12 @@ async function uploadImageToAzure({ const userId = req.user.id; let webPBuffer; let fileName = `${file_id}__${path.basename(inputFilePath)}`; - const targetExtension = `.${req.app.locals.imageOutputType}`; + const targetExtension = `.${appConfig.imageOutputType}`; if (extension.toLowerCase() === targetExtension) { webPBuffer = resizedBuffer; } else { - webPBuffer = await sharp(resizedBuffer).toFormat(req.app.locals.imageOutputType).toBuffer(); + webPBuffer = await sharp(resizedBuffer).toFormat(appConfig.imageOutputType).toBuffer(); const extRegExp = new RegExp(path.extname(fileName) + '$'); fileName = fileName.replace(extRegExp, targetExtension); if (!path.extname(fileName)) { diff --git a/api/server/services/Files/Citations/index.js b/api/server/services/Files/Citations/index.js index 6ee7258d9..08ebe448b 100644 --- a/api/server/services/Files/Citations/index.js +++ b/api/server/services/Files/Citations/index.js @@ -1,21 +1,27 @@ const { nanoid } = require('nanoid'); const { checkAccess } = require('@librechat/api'); -const { Tools, PermissionTypes, Permissions } = require('librechat-data-provider'); -const { getCustomConfig } = require('~/server/services/Config/getCustomConfig'); +const { logger } = require('@librechat/data-schemas'); +const { + Tools, + Permissions, + FileSources, + EModelEndpoint, + PermissionTypes, +} = require('librechat-data-provider'); const { getRoleByName } = require('~/models/Role'); -const { logger } = require('~/config'); const { Files } = require('~/models'); /** * Process file search results from tool calls * @param {Object} options * @param {IUser} options.user - The user object + * @param {AppConfig} options.appConfig - The app configuration object * @param {GraphRunnableConfig['configurable']} options.metadata - The metadata * @param {any} options.toolArtifact - The tool artifact containing structured data * @param {string} options.toolCallId - The tool call ID * @returns {Promise} The file search attachment or null */ -async function processFileCitations({ user, toolArtifact, toolCallId, metadata }) { +async function processFileCitations({ user, appConfig, toolArtifact, toolCallId, metadata }) { try { if (!toolArtifact?.[Tools.file_search]?.sources) { return null; @@ -44,10 +50,11 @@ async function processFileCitations({ user, toolArtifact, toolCallId, metadata } } } - const customConfig = await getCustomConfig(); - const maxCitations = customConfig?.endpoints?.agents?.maxCitations ?? 30; - const maxCitationsPerFile = customConfig?.endpoints?.agents?.maxCitationsPerFile ?? 5; - const minRelevanceScore = customConfig?.endpoints?.agents?.minRelevanceScore ?? 0.45; + const maxCitations = appConfig.endpoints?.[EModelEndpoint.agents]?.maxCitations ?? 30; + const maxCitationsPerFile = + appConfig.endpoints?.[EModelEndpoint.agents]?.maxCitationsPerFile ?? 5; + const minRelevanceScore = + appConfig.endpoints?.[EModelEndpoint.agents]?.minRelevanceScore ?? 0.45; const sources = toolArtifact[Tools.file_search].sources || []; const filteredSources = sources.filter((source) => source.relevance >= minRelevanceScore); @@ -59,7 +66,7 @@ async function processFileCitations({ user, toolArtifact, toolCallId, metadata } } const selectedSources = applyCitationLimits(filteredSources, maxCitations, maxCitationsPerFile); - const enhancedSources = await enhanceSourcesWithMetadata(selectedSources, customConfig); + const enhancedSources = await enhanceSourcesWithMetadata(selectedSources, appConfig); if (enhancedSources.length > 0) { const fileSearchAttachment = { @@ -110,10 +117,10 @@ function applyCitationLimits(sources, maxCitations, maxCitationsPerFile) { /** * Enhance sources with file metadata from database * @param {Array} sources - Selected sources - * @param {Object} customConfig - Custom configuration + * @param {AppConfig} appConfig - Custom configuration * @returns {Promise} Enhanced sources */ -async function enhanceSourcesWithMetadata(sources, customConfig) { +async function enhanceSourcesWithMetadata(sources, appConfig) { const fileIds = [...new Set(sources.map((source) => source.fileId))]; let fileMetadataMap = {}; @@ -129,7 +136,7 @@ async function enhanceSourcesWithMetadata(sources, customConfig) { return sources.map((source) => { const fileRecord = fileMetadataMap[source.fileId] || {}; - const configuredStorageType = fileRecord.source || customConfig?.fileStrategy || 'local'; + const configuredStorageType = fileRecord.source || appConfig?.fileStrategy || FileSources.local; return { ...source, diff --git a/api/server/services/Files/Code/crud.js b/api/server/services/Files/Code/crud.js index c696eae0c..4781219fc 100644 --- a/api/server/services/Files/Code/crud.js +++ b/api/server/services/Files/Code/crud.js @@ -43,8 +43,7 @@ async function getCodeOutputDownloadStream(fileIdentifier, apiKey) { /** * Uploads a file to the Code Environment server. * @param {Object} params - The params object. - * @param {ServerRequest} params.req - The request object from Express. It should have a `user` property with an `id` - * representing the user, and an `app.locals.paths` object with an `uploads` path. + * @param {ServerRequest} params.req - The request object from Express. It should have a `user` property with an `id` representing the user * @param {import('fs').ReadStream | import('stream').Readable} params.stream - The read stream for the file. * @param {string} params.filename - The name of the file. * @param {string} params.apiKey - The API key for authentication. diff --git a/api/server/services/Files/Code/process.js b/api/server/services/Files/Code/process.js index 420451ab6..39b47a7d6 100644 --- a/api/server/services/Files/Code/process.js +++ b/api/server/services/Files/Code/process.js @@ -38,6 +38,7 @@ const processCodeOutput = async ({ messageId, session_id, }) => { + const appConfig = req.config; const currentDate = new Date(); const baseURL = getCodeBaseURL(); const fileExt = path.extname(name); @@ -77,10 +78,10 @@ const processCodeOutput = async ({ filename: name, conversationId, user: req.user.id, - type: `image/${req.app.locals.imageOutputType}`, + type: `image/${appConfig.imageOutputType}`, createdAt: formattedDate, updatedAt: formattedDate, - source: req.app.locals.fileStrategy, + source: appConfig.fileStrategy, context: FileContext.execute_code, }; createFile(file, true); diff --git a/api/server/services/Files/Firebase/images.js b/api/server/services/Files/Firebase/images.js index 8b0866b5d..cffafa890 100644 --- a/api/server/services/Files/Firebase/images.js +++ b/api/server/services/Files/Firebase/images.js @@ -11,8 +11,7 @@ const { saveBufferToFirebase } = require('./crud'); * resolution. * * @param {Object} params - The params object. - * @param {Express.Request} params.req - The request object from Express. It should have a `user` property with an `id` - * representing the user, and an `app.locals.paths` object with an `imageOutput` path. + * @param {ServerRequest} params.req - The request object from Express. It should have a `user` property with an `id` representing the user * @param {Express.Multer.File} params.file - The file object, which is part of the request. The file object should * have a `path` property that points to the location of the uploaded file. * @param {EModelEndpoint} params.endpoint - The params object. @@ -26,6 +25,7 @@ const { saveBufferToFirebase } = require('./crud'); * - height: The height of the converted image. */ async function uploadImageToFirebase({ req, file, file_id, endpoint, resolution = 'high' }) { + const appConfig = req.config; const inputFilePath = file.path; const inputBuffer = await fs.promises.readFile(inputFilePath); const { @@ -38,11 +38,11 @@ async function uploadImageToFirebase({ req, file, file_id, endpoint, resolution let webPBuffer; let fileName = `${file_id}__${path.basename(inputFilePath)}`; - const targetExtension = `.${req.app.locals.imageOutputType}`; + const targetExtension = `.${appConfig.imageOutputType}`; if (extension.toLowerCase() === targetExtension) { webPBuffer = resizedBuffer; } else { - webPBuffer = await sharp(resizedBuffer).toFormat(req.app.locals.imageOutputType).toBuffer(); + webPBuffer = await sharp(resizedBuffer).toFormat(appConfig.imageOutputType).toBuffer(); // Replace or append the correct extension const extRegExp = new RegExp(path.extname(fileName) + '$'); fileName = fileName.replace(extRegExp, targetExtension); diff --git a/api/server/services/Files/Local/crud.js b/api/server/services/Files/Local/crud.js index 455d4e0c4..f6c9ddcf3 100644 --- a/api/server/services/Files/Local/crud.js +++ b/api/server/services/Files/Local/crud.js @@ -38,14 +38,15 @@ async function saveLocalFile(file, outputPath, outputFilename) { /** * Saves an uploaded image file to a specified directory based on the user's ID and a filename. * - * @param {Express.Request} req - The Express request object, containing the user's information and app configuration. + * @param {ServerRequest} req - The Express request object, containing the user's information and app configuration. * @param {Express.Multer.File} file - The uploaded file object. * @param {string} filename - The new filename to assign to the saved image (without extension). * @returns {Promise} * @throws Will throw an error if the image saving process fails. */ const saveLocalImage = async (req, file, filename) => { - const imagePath = req.app.locals.paths.imageOutput; + const appConfig = req.config; + const imagePath = appConfig.paths.imageOutput; const outputPath = path.join(imagePath, req.user.id ?? ''); await saveLocalFile(file, outputPath, filename); }; @@ -162,7 +163,7 @@ async function getLocalFileURL({ fileName, basePath = 'images' }) { * the expected base path using the base, subfolder, and user id from the request, and then checks if the * provided filepath starts with this constructed base path. * - * @param {Express.Request} req - The request object from Express. It should contain a `user` property with an `id`. + * @param {ServerRequest} req - The request object from Express. It should contain a `user` property with an `id`. * @param {string} base - The base directory path. * @param {string} subfolder - The subdirectory under the base path. * @param {string} filepath - The complete file path to be validated. @@ -191,8 +192,7 @@ const unlinkFile = async (filepath) => { * Deletes a file from the filesystem. This function takes a file object, constructs the full path, and * verifies the path's validity before deleting the file. If the path is invalid, an error is thrown. * - * @param {Express.Request} req - The request object from Express. It should have an `app.locals.paths` object with - * a `publicPath` property. + * @param {ServerRequest} req - The request object from Express. * @param {MongoFile} file - The file object to be deleted. It should have a `filepath` property that is * a string representing the path of the file relative to the publicPath. * @@ -201,7 +201,8 @@ const unlinkFile = async (filepath) => { * file path is invalid or if there is an error in deletion. */ const deleteLocalFile = async (req, file) => { - const { publicPath, uploads } = req.app.locals.paths; + const appConfig = req.config; + const { publicPath, uploads } = appConfig.paths; /** Filepath stripped of query parameters (e.g., ?manual=true) */ const cleanFilepath = file.filepath.split('?')[0]; @@ -256,8 +257,7 @@ const deleteLocalFile = async (req, file) => { * Uploads a file to the specified upload directory. * * @param {Object} params - The params object. - * @param {ServerRequest} params.req - The request object from Express. It should have a `user` property with an `id` - * representing the user, and an `app.locals.paths` object with an `uploads` path. + * @param {ServerRequest} params.req - The request object from Express. It should have a `user` property with an `id` representing the user * @param {Express.Multer.File} params.file - The file object, which is part of the request. The file object should * have a `path` property that points to the location of the uploaded file. * @param {string} params.file_id - The file ID. @@ -268,11 +268,12 @@ const deleteLocalFile = async (req, file) => { * - bytes: The size of the file in bytes. */ async function uploadLocalFile({ req, file, file_id }) { + const appConfig = req.config; const inputFilePath = file.path; const inputBuffer = await fs.promises.readFile(inputFilePath); const bytes = Buffer.byteLength(inputBuffer); - const { uploads } = req.app.locals.paths; + const { uploads } = appConfig.paths; const userPath = path.join(uploads, req.user.id); if (!fs.existsSync(userPath)) { @@ -295,8 +296,9 @@ async function uploadLocalFile({ req, file, file_id }) { * @param {string} filepath - The filepath. * @returns {ReadableStream} A readable stream of the file. */ -function getLocalFileStream(req, filepath) { +async function getLocalFileStream(req, filepath) { try { + const appConfig = req.config; if (filepath.includes('/uploads/')) { const basePath = filepath.split('/uploads/')[1]; @@ -305,8 +307,8 @@ function getLocalFileStream(req, filepath) { throw new Error(`Invalid file path: ${filepath}`); } - const fullPath = path.join(req.app.locals.paths.uploads, basePath); - const uploadsDir = req.app.locals.paths.uploads; + const fullPath = path.join(appConfig.paths.uploads, basePath); + const uploadsDir = appConfig.paths.uploads; const rel = path.relative(uploadsDir, fullPath); if (rel.startsWith('..') || path.isAbsolute(rel) || rel.includes(`..${path.sep}`)) { @@ -323,8 +325,8 @@ function getLocalFileStream(req, filepath) { throw new Error(`Invalid file path: ${filepath}`); } - const fullPath = path.join(req.app.locals.paths.imageOutput, basePath); - const publicDir = req.app.locals.paths.imageOutput; + const fullPath = path.join(appConfig.paths.imageOutput, basePath); + const publicDir = appConfig.paths.imageOutput; const rel = path.relative(publicDir, fullPath); if (rel.startsWith('..') || path.isAbsolute(rel) || rel.includes(`..${path.sep}`)) { diff --git a/api/server/services/Files/Local/images.js b/api/server/services/Files/Local/images.js index ea3af87c7..d46f4779a 100644 --- a/api/server/services/Files/Local/images.js +++ b/api/server/services/Files/Local/images.js @@ -13,8 +13,7 @@ const { updateUser, updateFile } = require('~/models'); * * The original image is deleted after conversion. * @param {Object} params - The params object. - * @param {Object} params.req - The request object from Express. It should have a `user` property with an `id` - * representing the user, and an `app.locals.paths` object with an `imageOutput` path. + * @param {Object} params.req - The request object from Express. It should have a `user` property with an `id` representing the user * @param {Express.Multer.File} params.file - The file object, which is part of the request. The file object should * have a `path` property that points to the location of the uploaded file. * @param {string} params.file_id - The file ID. @@ -29,6 +28,7 @@ const { updateUser, updateFile } = require('~/models'); * - height: The height of the converted image. */ async function uploadLocalImage({ req, file, file_id, endpoint, resolution = 'high' }) { + const appConfig = req.config; const inputFilePath = file.path; const inputBuffer = await fs.promises.readFile(inputFilePath); const { @@ -38,7 +38,7 @@ async function uploadLocalImage({ req, file, file_id, endpoint, resolution = 'hi } = await resizeImageBuffer(inputBuffer, resolution, endpoint); const extension = path.extname(inputFilePath); - const { imageOutput } = req.app.locals.paths; + const { imageOutput } = appConfig.paths; const userPath = path.join(imageOutput, req.user.id); if (!fs.existsSync(userPath)) { @@ -47,7 +47,7 @@ async function uploadLocalImage({ req, file, file_id, endpoint, resolution = 'hi const fileName = `${file_id}__${path.basename(inputFilePath)}`; const newPath = path.join(userPath, fileName); - const targetExtension = `.${req.app.locals.imageOutputType}`; + const targetExtension = `.${appConfig.imageOutputType}`; if (extension.toLowerCase() === targetExtension) { const bytes = Buffer.byteLength(resizedBuffer); @@ -57,7 +57,7 @@ async function uploadLocalImage({ req, file, file_id, endpoint, resolution = 'hi } const outputFilePath = newPath.replace(extension, targetExtension); - const data = await sharp(resizedBuffer).toFormat(req.app.locals.imageOutputType).toBuffer(); + const data = await sharp(resizedBuffer).toFormat(appConfig.imageOutputType).toBuffer(); await fs.promises.writeFile(outputFilePath, data); const bytes = Buffer.byteLength(data); const filepath = path.posix.join('/', 'images', req.user.id, path.basename(outputFilePath)); @@ -90,7 +90,8 @@ function encodeImage(imagePath) { * @returns {Promise<[MongoFile, string]>} - A promise that resolves to an array of results from updateFile and encodeImage. */ async function prepareImagesLocal(req, file) { - const { publicPath, imageOutput } = req.app.locals.paths; + const appConfig = req.config; + const { publicPath, imageOutput } = appConfig.paths; const userPath = path.join(imageOutput, req.user.id); if (!fs.existsSync(userPath)) { diff --git a/api/server/services/Files/OpenAI/crud.js b/api/server/services/Files/OpenAI/crud.js index a55485fe4..9afe217f6 100644 --- a/api/server/services/Files/OpenAI/crud.js +++ b/api/server/services/Files/OpenAI/crud.js @@ -7,8 +7,7 @@ const { logger } = require('~/config'); * Uploads a file that can be used across various OpenAI services. * * @param {Object} params - The params object. - * @param {ServerRequest} params.req - The request object from Express. It should have a `user` property with an `id` - * representing the user, and an `app.locals.paths` object with an `imageOutput` path. + * @param {ServerRequest} params.req - The request object from Express. It should have a `user` property with an `id` representing the user * @param {Express.Multer.File} params.file - The file uploaded to the server via multer. * @param {OpenAIClient} params.openai - The initialized OpenAI client. * @returns {Promise} diff --git a/api/server/services/Files/S3/images.js b/api/server/services/Files/S3/images.js index 688d5eb68..9bdae940c 100644 --- a/api/server/services/Files/S3/images.js +++ b/api/server/services/Files/S3/images.js @@ -12,7 +12,7 @@ const defaultBasePath = 'images'; * Resizes, converts, and uploads an image file to S3. * * @param {Object} params - * @param {import('express').Request} params.req - Express request (expects user and app.locals.imageOutputType). + * @param {import('express').Request} params.req - Express request (expects `user` and `appConfig.imageOutputType`). * @param {Express.Multer.File} params.file - File object from Multer. * @param {string} params.file_id - Unique file identifier. * @param {any} params.endpoint - Endpoint identifier used in image processing. @@ -29,6 +29,7 @@ async function uploadImageToS3({ basePath = defaultBasePath, }) { try { + const appConfig = req.config; const inputFilePath = file.path; const inputBuffer = await fs.promises.readFile(inputFilePath); const { @@ -41,14 +42,12 @@ async function uploadImageToS3({ let processedBuffer; let fileName = `${file_id}__${path.basename(inputFilePath)}`; - const targetExtension = `.${req.app.locals.imageOutputType}`; + const targetExtension = `.${appConfig.imageOutputType}`; if (extension.toLowerCase() === targetExtension) { processedBuffer = resizedBuffer; } else { - processedBuffer = await sharp(resizedBuffer) - .toFormat(req.app.locals.imageOutputType) - .toBuffer(); + processedBuffer = await sharp(resizedBuffer).toFormat(appConfig.imageOutputType).toBuffer(); fileName = fileName.replace(new RegExp(path.extname(fileName) + '$'), targetExtension); if (!path.extname(fileName)) { fileName += targetExtension; diff --git a/api/server/services/Files/VectorDB/crud.js b/api/server/services/Files/VectorDB/crud.js index 18327d7df..5e00e71b5 100644 --- a/api/server/services/Files/VectorDB/crud.js +++ b/api/server/services/Files/VectorDB/crud.js @@ -10,8 +10,7 @@ const { generateShortLivedToken } = require('~/server/services/AuthService'); * Deletes a file from the vector database. This function takes a file object, constructs the full path, and * verifies the path's validity before deleting the file. If the path is invalid, an error is thrown. * - * @param {ServerRequest} req - The request object from Express. It should have an `app.locals.paths` object with - * a `publicPath` property. + * @param {ServerRequest} req - The request object from Express. * @param {MongoFile} file - The file object to be deleted. It should have a `filepath` property that is * a string representing the path of the file relative to the publicPath. * @@ -54,8 +53,7 @@ const deleteVectors = async (req, file) => { * Uploads a file to the configured Vector database * * @param {Object} params - The params object. - * @param {Object} params.req - The request object from Express. It should have a `user` property with an `id` - * representing the user, and an `app.locals.paths` object with an `uploads` path. + * @param {Object} params.req - The request object from Express. It should have a `user` property with an `id` representing the user * @param {Express.Multer.File} params.file - The file object, which is part of the request. The file object should * have a `path` property that points to the location of the uploaded file. * @param {string} params.file_id - The file ID. diff --git a/api/server/services/Files/images/convert.js b/api/server/services/Files/images/convert.js index 4e7ab75d4..446de5ba1 100644 --- a/api/server/services/Files/images/convert.js +++ b/api/server/services/Files/images/convert.js @@ -1,14 +1,14 @@ const fs = require('fs'); const path = require('path'); const sharp = require('sharp'); -const { resizeImageBuffer } = require('./resize'); const { getStrategyFunctions } = require('../strategies'); +const { resizeImageBuffer } = require('./resize'); const { logger } = require('~/config'); /** * Converts an image file or buffer to target output type with specified resolution. * - * @param {Express.Request} req - The request object, containing user and app configuration data. + * @param {ServerRequest} req - The request object, containing user and app configuration data. * @param {Buffer | Express.Multer.File} file - The file object, containing either a path or a buffer. * @param {'low' | 'high'} [resolution='high'] - The desired resolution for the output image. * @param {string} [basename=''] - The basename of the input file, if it is a buffer. @@ -17,6 +17,7 @@ const { logger } = require('~/config'); */ async function convertImage(req, file, resolution = 'high', basename = '') { try { + const appConfig = req.config; let inputBuffer; let outputBuffer; let extension = path.extname(file.path ?? basename).toLowerCase(); @@ -39,11 +40,11 @@ async function convertImage(req, file, resolution = 'high', basename = '') { } = await resizeImageBuffer(inputBuffer, resolution); // Check if the file is already in target format; if it isn't, convert it: - const targetExtension = `.${req.app.locals.imageOutputType}`; + const targetExtension = `.${appConfig.imageOutputType}`; if (extension === targetExtension) { outputBuffer = resizedBuffer; } else { - outputBuffer = await sharp(resizedBuffer).toFormat(req.app.locals.imageOutputType).toBuffer(); + outputBuffer = await sharp(resizedBuffer).toFormat(appConfig.imageOutputType).toBuffer(); extension = targetExtension; } @@ -51,7 +52,7 @@ async function convertImage(req, file, resolution = 'high', basename = '') { const newFileName = path.basename(file.path ?? basename, path.extname(file.path ?? basename)) + extension; - const { saveBuffer } = getStrategyFunctions(req.app.locals.fileStrategy); + const { saveBuffer } = getStrategyFunctions(appConfig.fileStrategy); const savedFilePath = await saveBuffer({ userId: req.user.id, diff --git a/api/server/services/Files/images/encode.js b/api/server/services/Files/images/encode.js index e87654b37..b7f6325d3 100644 --- a/api/server/services/Files/images/encode.js +++ b/api/server/services/Files/images/encode.js @@ -81,7 +81,7 @@ const blobStorageSources = new Set([FileSources.azure_blob, FileSources.s3]); /** * Encodes and formats the given files. - * @param {Express.Request} req - The request object. + * @param {ServerRequest} req - The request object. * @param {Array} files - The array of files to encode and format. * @param {EModelEndpoint} [endpoint] - Optional: The endpoint for the image. * @param {string} [mode] - Optional: The endpoint mode for the image. diff --git a/api/server/services/Files/images/index.js b/api/server/services/Files/images/index.js index 889b19f20..d72a80690 100644 --- a/api/server/services/Files/images/index.js +++ b/api/server/services/Files/images/index.js @@ -1,13 +1,11 @@ const avatar = require('./avatar'); const convert = require('./convert'); const encode = require('./encode'); -const parse = require('./parse'); const resize = require('./resize'); module.exports = { ...convert, ...encode, - ...parse, ...resize, avatar, }; diff --git a/api/server/services/Files/process.js b/api/server/services/Files/process.js index 819e3b655..73fffe6ac 100644 --- a/api/server/services/Files/process.js +++ b/api/server/services/Files/process.js @@ -16,8 +16,8 @@ const { removeNullishValues, isAssistantsEndpoint, } = require('librechat-data-provider'); -const { sanitizeFilename } = require('@librechat/api'); const { EnvVar } = require('@librechat/agents'); +const { sanitizeFilename } = require('@librechat/api'); const { convertImage, resizeAndConvert, @@ -28,10 +28,10 @@ const { addAgentResourceFile, removeAgentResourceFiles } = require('~/models/Age const { getOpenAIClient } = require('~/server/controllers/assistants/helpers'); const { createFile, updateFileUsage, deleteFiles } = require('~/models/File'); const { loadAuthValues } = require('~/server/services/Tools/credentials'); +const { getFileStrategy } = require('~/server/utils/getFileStrategy'); const { checkCapability } = require('~/server/services/Config'); const { LB_QueueAsyncCall } = require('~/server/utils/queue'); const { getStrategyFunctions } = require('./strategies'); -const { getFileStrategy } = require('~/server/utils/getFileStrategy'); const { determineFileType } = require('~/server/utils'); const { logger } = require('~/config'); @@ -157,6 +157,7 @@ function enqueueDeleteOperation({ req, file, deleteFile, promises, resolvedFileI * @returns {Promise} */ const processDeleteRequest = async ({ req, files }) => { + const appConfig = req.config; const resolvedFileIds = []; const deletionMethods = {}; const promises = []; @@ -164,7 +165,7 @@ const processDeleteRequest = async ({ req, files }) => { /** @type {Record} */ const client = { [FileSources.openai]: undefined, [FileSources.azure]: undefined }; const initializeClients = async () => { - if (req.app.locals[EModelEndpoint.assistants]) { + if (appConfig.endpoints?.[EModelEndpoint.assistants]) { const openAIClient = await getOpenAIClient({ req, overrideEndpoint: EModelEndpoint.assistants, @@ -172,7 +173,7 @@ const processDeleteRequest = async ({ req, files }) => { client[FileSources.openai] = openAIClient.openai; } - if (!req.app.locals[EModelEndpoint.azureOpenAI]?.assistants) { + if (!appConfig.endpoints?.[EModelEndpoint.azureOpenAI]?.assistants) { return; } @@ -320,7 +321,8 @@ const processFileURL = async ({ fileStrategy, userId, URL, fileName, basePath, c */ const processImageFile = async ({ req, res, metadata, returnFile = false }) => { const { file } = req; - const source = getFileStrategy(req.app.locals, { isImage: true }); + const appConfig = req.config; + const source = getFileStrategy(appConfig, { isImage: true }); const { handleImageUpload } = getStrategyFunctions(source); const { file_id, temp_file_id, endpoint } = metadata; @@ -341,7 +343,7 @@ const processImageFile = async ({ req, res, metadata, returnFile = false }) => { filename: file.originalname, context: FileContext.message_attachment, source, - type: `image/${req.app.locals.imageOutputType}`, + type: `image/${appConfig.imageOutputType}`, width, height, }, @@ -366,18 +368,19 @@ const processImageFile = async ({ req, res, metadata, returnFile = false }) => { * @returns {Promise<{ filepath: string, filename: string, source: string, type: string}>} */ const uploadImageBuffer = async ({ req, context, metadata = {}, resize = true }) => { - const source = getFileStrategy(req.app.locals, { isImage: true }); + const appConfig = req.config; + const source = getFileStrategy(appConfig, { isImage: true }); const { saveBuffer } = getStrategyFunctions(source); let { buffer, width, height, bytes, filename, file_id, type } = metadata; if (resize) { file_id = v4(); - type = `image/${req.app.locals.imageOutputType}`; + type = `image/${appConfig.imageOutputType}`; ({ buffer, width, height, bytes } = await resizeAndConvert({ inputBuffer: buffer, - desiredFormat: req.app.locals.imageOutputType, + desiredFormat: appConfig.imageOutputType, })); filename = `${path.basename(req.file.originalname, path.extname(req.file.originalname))}.${ - req.app.locals.imageOutputType + appConfig.imageOutputType }`; } const fileName = `${file_id}-${filename}`; @@ -411,11 +414,12 @@ const uploadImageBuffer = async ({ req, context, metadata = {}, resize = true }) * @returns {Promise} */ const processFileUpload = async ({ req, res, metadata }) => { + const appConfig = req.config; const isAssistantUpload = isAssistantsEndpoint(metadata.endpoint); const assistantSource = metadata.endpoint === EModelEndpoint.azureAssistants ? FileSources.azure : FileSources.openai; // Use the configured file strategy for regular file uploads (not vectordb) - const source = isAssistantUpload ? assistantSource : req.app.locals.fileStrategy; + const source = isAssistantUpload ? assistantSource : appConfig.fileStrategy; const { handleFileUpload } = getStrategyFunctions(source); const { file_id, temp_file_id = null } = metadata; @@ -501,6 +505,7 @@ const processFileUpload = async ({ req, res, metadata }) => { */ const processAgentFileUpload = async ({ req, res, metadata }) => { const { file } = req; + const appConfig = req.config; const { agent_id, tool_resource, file_id, temp_file_id = null } = metadata; if (agent_id && !tool_resource) { throw new Error('No tool resource provided for agent file upload'); @@ -553,7 +558,7 @@ const processAgentFileUpload = async ({ req, res, metadata }) => { } const { handleFileUpload: uploadOCR } = getStrategyFunctions( - req.app.locals?.ocr?.strategy ?? FileSources.mistral_ocr, + appConfig?.ocr?.strategy ?? FileSources.mistral_ocr, ); const { file_id, temp_file_id = null } = metadata; @@ -564,7 +569,7 @@ const processAgentFileUpload = async ({ req, res, metadata }) => { images: _i, filename, filepath: ocrFileURL, - } = await uploadOCR({ req, file, loadAuthValues }); + } = await uploadOCR({ req, appConfig, file, loadAuthValues }); const fileInfo = removeNullishValues({ text, @@ -597,7 +602,7 @@ const processAgentFileUpload = async ({ req, res, metadata }) => { // Dual storage pattern for RAG files: Storage + Vector DB let storageResult, embeddingResult; const isImageFile = file.mimetype.startsWith('image'); - const source = getFileStrategy(req.app.locals, { isImage: isImageFile }); + const source = getFileStrategy(appConfig, { isImage: isImageFile }); if (tool_resource === EToolResources.file_search) { // FIRST: Upload to Storage for permanent backup (S3/local/etc.) @@ -752,6 +757,7 @@ const processOpenAIFile = async ({ const processOpenAIImageOutput = async ({ req, buffer, file_id, filename, fileExt }) => { const currentDate = new Date(); const formattedDate = currentDate.toISOString(); + const appConfig = req.config; const _file = await convertImage(req, buffer, undefined, `${file_id}${fileExt}`); // Create only one file record with the correct information @@ -762,7 +768,7 @@ const processOpenAIImageOutput = async ({ req, buffer, file_id, filename, fileEx type: mime.getType(fileExt), createdAt: formattedDate, updatedAt: formattedDate, - source: getFileStrategy(req.app.locals, { isImage: true }), + source: getFileStrategy(appConfig, { isImage: true }), context: FileContext.assistants_output, file_id, filename, @@ -889,7 +895,7 @@ async function saveBase64Image( url, { req, file_id: _file_id, filename: _filename, endpoint, context, resolution }, ) { - const effectiveResolution = resolution ?? req.app.locals.fileConfig?.imageGeneration ?? 'high'; + const effectiveResolution = resolution ?? appConfig.fileConfig?.imageGeneration ?? 'high'; const file_id = _file_id ?? v4(); let filename = `${file_id}-${_filename}`; const { buffer: inputBuffer, type } = base64ToBuffer(url); @@ -903,7 +909,8 @@ async function saveBase64Image( } const image = await resizeImageBuffer(inputBuffer, effectiveResolution, endpoint); - const source = getFileStrategy(req.app.locals, { isImage: true }); + const appConfig = req.config; + const source = getFileStrategy(appConfig, { isImage: true }); const { saveBuffer } = getStrategyFunctions(source); const filepath = await saveBuffer({ userId: req.user.id, @@ -964,7 +971,8 @@ function filterFile({ req, image, isAvatar }) { throw new Error('No endpoint provided'); } - const fileConfig = mergeFileConfig(req.app.locals.fileConfig); + const appConfig = req.config; + const fileConfig = mergeFileConfig(appConfig.fileConfig); const { fileSizeLimit: sizeLimit, supportedMimeTypes } = fileConfig.endpoints[endpoint] ?? fileConfig.endpoints.default; diff --git a/api/server/services/MCP.js b/api/server/services/MCP.js index 2fc03f629..59492f00c 100644 --- a/api/server/services/MCP.js +++ b/api/server/services/MCP.js @@ -20,9 +20,9 @@ const { ContentTypes, isAssistantsEndpoint, } = require('librechat-data-provider'); -const { getCachedTools, loadCustomConfig } = require('./Config'); const { findToken, createToken, updateToken } = require('~/models'); const { getMCPManager, getFlowStateManager } = require('~/config'); +const { getCachedTools, getAppConfig } = require('./Config'); const { reinitMCPServer } = require('./Tools/mcp'); const { getLogStores } = require('~/cache'); @@ -428,9 +428,8 @@ function createToolInstance({ res, toolName, serverName, toolDefinition, provide * @returns {Object} Object containing mcpConfig, appConnections, userConnections, and oauthServers */ async function getMCPSetupData(userId) { - const printConfig = false; - const config = await loadCustomConfig(printConfig); - const mcpConfig = config?.mcpServers; + const config = await getAppConfig(); + const mcpConfig = config?.mcpConfig; if (!mcpConfig) { throw new Error('MCP config not found'); diff --git a/api/server/services/MCP.spec.js b/api/server/services/MCP.spec.js index 8c81abd68..3751c8a88 100644 --- a/api/server/services/MCP.spec.js +++ b/api/server/services/MCP.spec.js @@ -25,6 +25,7 @@ jest.mock('librechat-data-provider', () => ({ jest.mock('./Config', () => ({ loadCustomConfig: jest.fn(), + getAppConfig: jest.fn(), })); jest.mock('~/config', () => ({ @@ -65,8 +66,10 @@ describe('tests for the new helper functions used by the MCP connection status e server2: { type: 'http' }, }, }; + let mockGetAppConfig; beforeEach(() => { + mockGetAppConfig = require('./Config').getAppConfig; mockGetMCPManager.mockReturnValue({ getAllConnections: jest.fn(() => new Map()), getUserConnections: jest.fn(() => new Map()), @@ -75,7 +78,7 @@ describe('tests for the new helper functions used by the MCP connection status e }); it('should successfully return MCP setup data', async () => { - mockLoadCustomConfig.mockResolvedValue(mockConfig); + mockGetAppConfig.mockResolvedValue({ mcpConfig: mockConfig.mcpServers }); const mockAppConnections = new Map([['server1', { status: 'connected' }]]); const mockUserConnections = new Map([['server2', { status: 'disconnected' }]]); @@ -90,7 +93,7 @@ describe('tests for the new helper functions used by the MCP connection status e const result = await getMCPSetupData(mockUserId); - expect(mockLoadCustomConfig).toHaveBeenCalledWith(false); + expect(mockGetAppConfig).toHaveBeenCalled(); expect(mockGetMCPManager).toHaveBeenCalledWith(mockUserId); expect(mockMCPManager.getAllConnections).toHaveBeenCalled(); expect(mockMCPManager.getUserConnections).toHaveBeenCalledWith(mockUserId); @@ -105,12 +108,12 @@ describe('tests for the new helper functions used by the MCP connection status e }); it('should throw error when MCP config not found', async () => { - mockLoadCustomConfig.mockResolvedValue({}); + mockGetAppConfig.mockResolvedValue({}); await expect(getMCPSetupData(mockUserId)).rejects.toThrow('MCP config not found'); }); it('should handle null values from MCP manager gracefully', async () => { - mockLoadCustomConfig.mockResolvedValue(mockConfig); + mockGetAppConfig.mockResolvedValue({ mcpConfig: mockConfig.mcpServers }); const mockMCPManager = { getAllConnections: jest.fn(() => null), diff --git a/api/server/services/Runs/StreamRunManager.js b/api/server/services/Runs/StreamRunManager.js index b8cc57e8c..a0b52c349 100644 --- a/api/server/services/Runs/StreamRunManager.js +++ b/api/server/services/Runs/StreamRunManager.js @@ -36,7 +36,7 @@ class StreamRunManager { /** @type {Run | null} */ this.run = null; - /** @type {Express.Request} */ + /** @type {ServerRequest} */ this.req = fields.req; /** @type {Express.Response} */ this.res = fields.res; diff --git a/api/server/services/Runs/methods.js b/api/server/services/Runs/methods.js index 167b9cc2b..8607c5c6f 100644 --- a/api/server/services/Runs/methods.js +++ b/api/server/services/Runs/methods.js @@ -18,6 +18,7 @@ const { EModelEndpoint } = require('librechat-data-provider'); * @returns {Promise} The data retrieved from the API. */ async function retrieveRun({ thread_id, run_id, timeout, openai }) { + const appConfig = openai.req.config; const { apiKey, baseURL, httpAgent, organization } = openai; let url = `${baseURL}/threads/${thread_id}/runs/${run_id}`; @@ -31,7 +32,7 @@ async function retrieveRun({ thread_id, run_id, timeout, openai }) { } /** @type {TAzureConfig | undefined} */ - const azureConfig = openai.req.app.locals[EModelEndpoint.azureOpenAI]; + const azureConfig = appConfig.endpoints?.[EModelEndpoint.azureOpenAI]; if (azureConfig && azureConfig.assistants) { delete headers.Authorization; diff --git a/api/server/services/ToolService.js b/api/server/services/ToolService.js index 4f6c1ed3e..87005e64d 100644 --- a/api/server/services/ToolService.js +++ b/api/server/services/ToolService.js @@ -1,11 +1,7 @@ -const fs = require('fs'); -const path = require('path'); const { sleep } = require('@librechat/agents'); const { logger } = require('@librechat/data-schemas'); -const { zodToJsonSchema } = require('zod-to-json-schema'); -const { getToolkitKey, getUserMCPAuthMap } = require('@librechat/api'); -const { Calculator } = require('@langchain/community/tools/calculator'); -const { tool: toolFn, Tool, DynamicStructuredTool } = require('@langchain/core/tools'); +const { tool: toolFn, DynamicStructuredTool } = require('@langchain/core/tools'); +const { getToolkitKey, hasCustomUserVars, getUserMCPAuthMap } = require('@librechat/api'); const { Tools, Constants, @@ -26,145 +22,15 @@ const { loadActionSets, domainParser, } = require('./ActionService'); -const { - createOpenAIImageTools, - createYouTubeTools, - manifestToolMap, - toolkits, -} = require('~/app/clients/tools'); const { processFileURL, uploadImageBuffer } = require('~/server/services/Files/process'); -const { - getEndpointsConfig, - hasCustomUserVars, - getCachedTools, -} = require('~/server/services/Config'); +const { getEndpointsConfig, getCachedTools } = require('~/server/services/Config'); +const { manifestToolMap, toolkits } = require('~/app/clients/tools/manifest'); const { createOnSearchResults } = require('~/server/services/Tools/search'); const { isActionDomainAllowed } = require('~/server/services/domains'); const { recordUsage } = require('~/server/services/Threads'); const { loadTools } = require('~/app/clients/tools/util'); const { redactMessage } = require('~/config/parsers'); const { findPluginAuthsByKeys } = require('~/models'); - -/** - * Loads and formats tools from the specified tool directory. - * - * The directory is scanned for JavaScript files, excluding any files in the filter set. - * For each file, it attempts to load the file as a module and instantiate a class, if it's a subclass of `StructuredTool`. - * Each tool instance is then formatted to be compatible with the OpenAI Assistant. - * Additionally, instances of LangChain Tools are included in the result. - * - * @param {object} params - The parameters for the function. - * @param {string} params.directory - The directory path where the tools are located. - * @param {Array} [params.adminFilter=[]] - Array of admin-defined tool keys to exclude from loading. - * @param {Array} [params.adminIncluded=[]] - Array of admin-defined tool keys to include from loading. - * @returns {Record} An object mapping each tool's plugin key to its instance. - */ -function loadAndFormatTools({ directory, adminFilter = [], adminIncluded = [] }) { - const filter = new Set([...adminFilter]); - const included = new Set(adminIncluded); - const tools = []; - /* Structured Tools Directory */ - const files = fs.readdirSync(directory); - - if (included.size > 0 && adminFilter.length > 0) { - logger.warn( - 'Both `includedTools` and `filteredTools` are defined; `filteredTools` will be ignored.', - ); - } - - for (const file of files) { - const filePath = path.join(directory, file); - if (!file.endsWith('.js') || (filter.has(file) && included.size === 0)) { - continue; - } - - let ToolClass = null; - try { - ToolClass = require(filePath); - } catch (error) { - logger.error(`[loadAndFormatTools] Error loading tool from ${filePath}:`, error); - continue; - } - - if (!ToolClass || !(ToolClass.prototype instanceof Tool)) { - continue; - } - - let toolInstance = null; - try { - toolInstance = new ToolClass({ override: true }); - } catch (error) { - logger.error( - `[loadAndFormatTools] Error initializing \`${file}\` tool; if it requires authentication, is the \`override\` field configured?`, - error, - ); - continue; - } - - if (!toolInstance) { - continue; - } - - if (filter.has(toolInstance.name) && included.size === 0) { - continue; - } - - if (included.size > 0 && !included.has(file) && !included.has(toolInstance.name)) { - continue; - } - - const formattedTool = formatToOpenAIAssistantTool(toolInstance); - tools.push(formattedTool); - } - - /** Basic Tools & Toolkits; schema: { input: string } */ - const basicToolInstances = [ - new Calculator(), - ...createOpenAIImageTools({ override: true }), - ...createYouTubeTools({ override: true }), - ]; - for (const toolInstance of basicToolInstances) { - const formattedTool = formatToOpenAIAssistantTool(toolInstance); - let toolName = formattedTool[Tools.function].name; - toolName = getToolkitKey({ toolkits, toolName }) ?? toolName; - if (filter.has(toolName) && included.size === 0) { - continue; - } - - if (included.size > 0 && !included.has(toolName)) { - continue; - } - tools.push(formattedTool); - } - - tools.push(ImageVisionTool); - - return tools.reduce((map, tool) => { - map[tool.function.name] = tool; - return map; - }, {}); -} - -/** - * Formats a `StructuredTool` instance into a format that is compatible - * with OpenAI's ChatCompletionFunctions. It uses the `zodToJsonSchema` - * function to convert the schema of the `StructuredTool` into a JSON - * schema, which is then used as the parameters for the OpenAI function. - * - * @param {StructuredTool} tool - The StructuredTool to format. - * @returns {FunctionTool} The OpenAI Assistant Tool. - */ -function formatToOpenAIAssistantTool(tool) { - return { - type: Tools.function, - [Tools.function]: { - name: tool.name, - description: tool.description, - parameters: zodToJsonSchema(tool.schema), - }, - }; -} - /** * Processes the required actions by calling the appropriate tools and returning the outputs. * @param {OpenAIClient} client - OpenAI or StreamRunManager Client. @@ -207,6 +73,7 @@ async function processRequiredActions(client, requiredActions) { `[required actions] user: ${client.req.user.id} | thread_id: ${requiredActions[0].thread_id} | run_id: ${requiredActions[0].run_id}`, requiredActions, ); + const appConfig = client.req.config; const toolDefinitions = await getCachedTools({ userId: client.req.user.id, includeGlobal: true }); const seenToolkits = new Set(); const tools = requiredActions @@ -238,9 +105,11 @@ async function processRequiredActions(client, requiredActions) { req: client.req, uploadImageBuffer, openAIApiKey: client.apiKey, - fileStrategy: client.req.app.locals.fileStrategy, returnMetadata: true, }, + webSearch: appConfig.webSearch, + fileStrategy: appConfig.fileStrategy, + imageOutputType: appConfig.imageOutputType, }); const ToolMap = loadedTools.reduce((map, tool) => { @@ -353,8 +222,10 @@ async function processRequiredActions(client, requiredActions) { const domain = await domainParser(action.metadata.domain, true); domainMap.set(domain, action); - // Check if domain is allowed - const isDomainAllowed = await isActionDomainAllowed(action.metadata.domain); + const isDomainAllowed = await isActionDomainAllowed( + action.metadata.domain, + appConfig?.actions?.allowedDomains, + ); if (!isDomainAllowed) { continue; } @@ -486,12 +357,13 @@ async function loadAgentTools({ req, res, agent, signal, tool_resources, openAIA return {}; } + const appConfig = req.config; const endpointsConfig = await getEndpointsConfig(req); let enabledCapabilities = new Set(endpointsConfig?.[EModelEndpoint.agents]?.capabilities ?? []); /** Edge case: use defined/fallback capabilities when the "agents" endpoint is not enabled */ if (enabledCapabilities.size === 0 && agent.id === Constants.EPHEMERAL_AGENT_ID) { enabledCapabilities = new Set( - req.app?.locals?.[EModelEndpoint.agents]?.capabilities ?? defaultAgentCapabilities, + appConfig.endpoints?.[EModelEndpoint.agents]?.capabilities ?? defaultAgentCapabilities, ); } const checkCapability = (capability) => { @@ -523,7 +395,7 @@ async function loadAgentTools({ req, res, agent, signal, tool_resources, openAIA if (!_agentTools || _agentTools.length === 0) { return {}; } - /** @type {ReturnType} */ + /** @type {ReturnType} */ let webSearchCallbacks; if (includesWebSearch) { webSearchCallbacks = createOnSearchResults(res); @@ -531,7 +403,7 @@ async function loadAgentTools({ req, res, agent, signal, tool_resources, openAIA /** @type {Record>} */ let userMCPAuthMap; - if (await hasCustomUserVars()) { + if (hasCustomUserVars(req.config)) { userMCPAuthMap = await getUserMCPAuthMap({ tools: agent.tools, userId: req.user.id, @@ -554,9 +426,11 @@ async function loadAgentTools({ req, res, agent, signal, tool_resources, openAIA processFileURL, uploadImageBuffer, returnMetadata: true, - fileStrategy: req.app.locals.fileStrategy, [Tools.web_search]: webSearchCallbacks, }, + webSearch: appConfig.webSearch, + fileStrategy: appConfig.fileStrategy, + imageOutputType: appConfig.imageOutputType, }); const agentTools = []; @@ -632,7 +506,10 @@ async function loadAgentTools({ req, res, agent, signal, tool_resources, openAIA domainMap.set(domain, action); // Check if domain is allowed (do this once per action set) - const isDomainAllowed = await isActionDomainAllowed(action.metadata.domain); + const isDomainAllowed = await isActionDomainAllowed( + action.metadata.domain, + appConfig?.actions?.allowedDomains, + ); if (!isDomainAllowed) { continue; } @@ -734,7 +611,5 @@ async function loadAgentTools({ req, res, agent, signal, tool_resources, openAIA module.exports = { getToolkitKey, loadAgentTools, - loadAndFormatTools, processRequiredActions, - formatToOpenAIAssistantTool, }; diff --git a/api/server/services/Tools/search.js b/api/server/services/Tools/search.js index a5c9947b5..c10c54314 100644 --- a/api/server/services/Tools/search.js +++ b/api/server/services/Tools/search.js @@ -1,6 +1,6 @@ const { nanoid } = require('nanoid'); const { Tools } = require('librechat-data-provider'); -const { logger } = require('~/config'); +const { logger } = require('@librechat/data-schemas'); /** * Creates a function to handle search results and stream them as attachments diff --git a/api/server/services/domains.js b/api/server/services/domains.js index 50e625c3d..67966eeaa 100644 --- a/api/server/services/domains.js +++ b/api/server/services/domains.js @@ -1,10 +1,9 @@ -const { getCustomConfig } = require('~/server/services/Config'); - /** * @param {string} email - * @returns {Promise} + * @param {string[]} [allowedDomains] + * @returns {boolean} */ -async function isEmailDomainAllowed(email) { +function isEmailDomainAllowed(email, allowedDomains) { if (!email) { return false; } @@ -15,14 +14,13 @@ async function isEmailDomainAllowed(email) { return false; } - const customConfig = await getCustomConfig(); - if (!customConfig) { + if (!allowedDomains) { return true; - } else if (!customConfig?.registration?.allowedDomains) { + } else if (!Array.isArray(allowedDomains) || !allowedDomains.length) { return true; } - return customConfig.registration.allowedDomains.includes(domain); + return allowedDomains.includes(domain); } /** @@ -65,16 +63,14 @@ function normalizeDomain(domain) { /** * Checks if the given domain is allowed. If no restrictions are set, allows all domains. * @param {string} [domain] + * @param {string[]} [allowedDomains] * @returns {Promise} */ -async function isActionDomainAllowed(domain) { +async function isActionDomainAllowed(domain, allowedDomains) { if (!domain || typeof domain !== 'string') { return false; } - const customConfig = await getCustomConfig(); - const allowedDomains = customConfig?.actions?.allowedDomains; - if (!Array.isArray(allowedDomains) || !allowedDomains.length) { return true; } diff --git a/api/server/services/domains.spec.js b/api/server/services/domains.spec.js index b4537dd37..a64062846 100644 --- a/api/server/services/domains.spec.js +++ b/api/server/services/domains.spec.js @@ -1,8 +1,8 @@ const { isEmailDomainAllowed, isActionDomainAllowed } = require('~/server/services/domains'); -const { getCustomConfig } = require('~/server/services/Config'); +const { getAppConfig } = require('~/server/services/Config'); jest.mock('~/server/services/Config', () => ({ - getCustomConfig: jest.fn(), + getAppConfig: jest.fn(), })); describe('isEmailDomainAllowed', () => { @@ -12,49 +12,49 @@ describe('isEmailDomainAllowed', () => { it('should return false if email is falsy', async () => { const email = ''; - const result = await isEmailDomainAllowed(email); + const result = isEmailDomainAllowed(email); expect(result).toBe(false); }); it('should return false if domain is not present in the email', async () => { const email = 'test'; - const result = await isEmailDomainAllowed(email); + const result = isEmailDomainAllowed(email); expect(result).toBe(false); }); it('should return true if customConfig is not available', async () => { const email = 'test@domain1.com'; - getCustomConfig.mockResolvedValue(null); - const result = await isEmailDomainAllowed(email); + getAppConfig.mockResolvedValue(null); + const result = isEmailDomainAllowed(email, null); expect(result).toBe(true); }); it('should return true if allowedDomains is not defined in customConfig', async () => { const email = 'test@domain1.com'; - getCustomConfig.mockResolvedValue({}); - const result = await isEmailDomainAllowed(email); + getAppConfig.mockResolvedValue({}); + const result = isEmailDomainAllowed(email, undefined); expect(result).toBe(true); }); it('should return true if domain is included in the allowedDomains', async () => { const email = 'user@domain1.com'; - getCustomConfig.mockResolvedValue({ + getAppConfig.mockResolvedValue({ registration: { allowedDomains: ['domain1.com', 'domain2.com'], }, }); - const result = await isEmailDomainAllowed(email); + const result = isEmailDomainAllowed(email, ['domain1.com', 'domain2.com']); expect(result).toBe(true); }); it('should return false if domain is not included in the allowedDomains', async () => { const email = 'user@domain3.com'; - getCustomConfig.mockResolvedValue({ + getAppConfig.mockResolvedValue({ registration: { allowedDomains: ['domain1.com', 'domain2.com'], }, }); - const result = await isEmailDomainAllowed(email); + const result = isEmailDomainAllowed(email, ['domain1.com', 'domain2.com']); expect(result).toBe(false); }); }); @@ -80,114 +80,119 @@ describe('isActionDomainAllowed', () => { }); it('should return false for invalid domain formats', async () => { - getCustomConfig.mockResolvedValue({ + getAppConfig.mockResolvedValue({ actions: { allowedDomains: ['http://', 'https://'] }, }); - expect(await isActionDomainAllowed('http://')).toBe(false); - expect(await isActionDomainAllowed('https://')).toBe(false); + expect(await isActionDomainAllowed('http://', ['http://', 'https://'])).toBe(false); + expect(await isActionDomainAllowed('https://', ['http://', 'https://'])).toBe(false); }); }); // Configuration Tests describe('configuration handling', () => { it('should return true if customConfig is null', async () => { - getCustomConfig.mockResolvedValue(null); - expect(await isActionDomainAllowed('example.com')).toBe(true); + getAppConfig.mockResolvedValue(null); + expect(await isActionDomainAllowed('example.com', null)).toBe(true); }); it('should return true if actions.allowedDomains is not defined', async () => { - getCustomConfig.mockResolvedValue({}); - expect(await isActionDomainAllowed('example.com')).toBe(true); + getAppConfig.mockResolvedValue({}); + expect(await isActionDomainAllowed('example.com', undefined)).toBe(true); }); it('should return true if allowedDomains is empty array', async () => { - getCustomConfig.mockResolvedValue({ + getAppConfig.mockResolvedValue({ actions: { allowedDomains: [] }, }); - expect(await isActionDomainAllowed('example.com')).toBe(true); + expect(await isActionDomainAllowed('example.com', [])).toBe(true); }); }); // Domain Matching Tests describe('domain matching', () => { + const allowedDomains = [ + 'example.com', + '*.subdomain.com', + 'specific.domain.com', + 'www.withprefix.com', + 'swapi.dev', + ]; + beforeEach(() => { - getCustomConfig.mockResolvedValue({ + getAppConfig.mockResolvedValue({ actions: { - allowedDomains: [ - 'example.com', - '*.subdomain.com', - 'specific.domain.com', - 'www.withprefix.com', - 'swapi.dev', - ], + allowedDomains, }, }); }); it('should match exact domains', async () => { - expect(await isActionDomainAllowed('example.com')).toBe(true); - expect(await isActionDomainAllowed('other.com')).toBe(false); - expect(await isActionDomainAllowed('swapi.dev')).toBe(true); + expect(await isActionDomainAllowed('example.com', allowedDomains)).toBe(true); + expect(await isActionDomainAllowed('other.com', allowedDomains)).toBe(false); + expect(await isActionDomainAllowed('swapi.dev', allowedDomains)).toBe(true); }); it('should handle domains with www prefix', async () => { - expect(await isActionDomainAllowed('www.example.com')).toBe(true); - expect(await isActionDomainAllowed('www.withprefix.com')).toBe(true); + expect(await isActionDomainAllowed('www.example.com', allowedDomains)).toBe(true); + expect(await isActionDomainAllowed('www.withprefix.com', allowedDomains)).toBe(true); }); it('should handle full URLs', async () => { - expect(await isActionDomainAllowed('https://example.com')).toBe(true); - expect(await isActionDomainAllowed('http://example.com')).toBe(true); - expect(await isActionDomainAllowed('https://example.com/path')).toBe(true); + expect(await isActionDomainAllowed('https://example.com', allowedDomains)).toBe(true); + expect(await isActionDomainAllowed('http://example.com', allowedDomains)).toBe(true); + expect(await isActionDomainAllowed('https://example.com/path', allowedDomains)).toBe(true); }); it('should handle wildcard subdomains', async () => { - expect(await isActionDomainAllowed('test.subdomain.com')).toBe(true); - expect(await isActionDomainAllowed('any.subdomain.com')).toBe(true); - expect(await isActionDomainAllowed('subdomain.com')).toBe(true); + expect(await isActionDomainAllowed('test.subdomain.com', allowedDomains)).toBe(true); + expect(await isActionDomainAllowed('any.subdomain.com', allowedDomains)).toBe(true); + expect(await isActionDomainAllowed('subdomain.com', allowedDomains)).toBe(true); }); it('should handle specific subdomains', async () => { - expect(await isActionDomainAllowed('specific.domain.com')).toBe(true); - expect(await isActionDomainAllowed('other.domain.com')).toBe(false); + expect(await isActionDomainAllowed('specific.domain.com', allowedDomains)).toBe(true); + expect(await isActionDomainAllowed('other.domain.com', allowedDomains)).toBe(false); }); }); // Edge Cases describe('edge cases', () => { + const edgeAllowedDomains = ['example.com', '*.test.com']; + beforeEach(() => { - getCustomConfig.mockResolvedValue({ + getAppConfig.mockResolvedValue({ actions: { - allowedDomains: ['example.com', '*.test.com'], + allowedDomains: edgeAllowedDomains, }, }); }); it('should handle domains with query parameters', async () => { - expect(await isActionDomainAllowed('example.com?param=value')).toBe(true); + expect(await isActionDomainAllowed('example.com?param=value', edgeAllowedDomains)).toBe(true); }); it('should handle domains with ports', async () => { - expect(await isActionDomainAllowed('example.com:8080')).toBe(true); + expect(await isActionDomainAllowed('example.com:8080', edgeAllowedDomains)).toBe(true); }); it('should handle domains with trailing slashes', async () => { - expect(await isActionDomainAllowed('example.com/')).toBe(true); + expect(await isActionDomainAllowed('example.com/', edgeAllowedDomains)).toBe(true); }); it('should handle case insensitivity', async () => { - expect(await isActionDomainAllowed('EXAMPLE.COM')).toBe(true); - expect(await isActionDomainAllowed('Example.Com')).toBe(true); + expect(await isActionDomainAllowed('EXAMPLE.COM', edgeAllowedDomains)).toBe(true); + expect(await isActionDomainAllowed('Example.Com', edgeAllowedDomains)).toBe(true); }); it('should handle invalid entries in allowedDomains', async () => { - getCustomConfig.mockResolvedValue({ + const invalidAllowedDomains = ['example.com', null, undefined, '', 'test.com']; + getAppConfig.mockResolvedValue({ actions: { - allowedDomains: ['example.com', null, undefined, '', 'test.com'], + allowedDomains: invalidAllowedDomains, }, }); - expect(await isActionDomainAllowed('example.com')).toBe(true); - expect(await isActionDomainAllowed('test.com')).toBe(true); + expect(await isActionDomainAllowed('example.com', invalidAllowedDomains)).toBe(true); + expect(await isActionDomainAllowed('test.com', invalidAllowedDomains)).toBe(true); }); }); }); diff --git a/api/server/services/initializeMCPs.js b/api/server/services/initializeMCPs.js index 696373384..397fc8520 100644 --- a/api/server/services/initializeMCPs.js +++ b/api/server/services/initializeMCPs.js @@ -1,13 +1,13 @@ const { logger } = require('@librechat/data-schemas'); +const { mergeAppTools, getAppConfig } = require('./Config'); const { createMCPManager } = require('~/config'); -const { mergeAppTools } = require('./Config'); /** * Initialize MCP servers - * @param {import('express').Application} app - Express app instance */ -async function initializeMCPs(app) { - const mcpServers = app.locals.mcpConfig; +async function initializeMCPs() { + const appConfig = await getAppConfig(); + const mcpServers = appConfig.mcpConfig; if (!mcpServers) { return; } @@ -15,7 +15,6 @@ async function initializeMCPs(app) { const mcpManager = await createMCPManager(mcpServers); try { - delete app.locals.mcpConfig; const mcpTools = mcpManager.getAppToolFunctions() || {}; await mergeAppTools(mcpTools); diff --git a/api/server/services/start/assistants.js b/api/server/services/start/assistants.js index 0513c63bc..febc170a9 100644 --- a/api/server/services/start/assistants.js +++ b/api/server/services/start/assistants.js @@ -1,9 +1,9 @@ +const { logger } = require('@librechat/data-schemas'); const { Capabilities, assistantEndpointSchema, defaultAssistantsVersion, } = require('librechat-data-provider'); -const { logger } = require('~/config'); /** * Sets up the minimum, default Assistants configuration if Azure OpenAI Assistants option is enabled. diff --git a/api/server/services/start/azureOpenAI.js b/api/server/services/start/azureOpenAI.js index 565c8f691..1598b28ba 100644 --- a/api/server/services/start/azureOpenAI.js +++ b/api/server/services/start/azureOpenAI.js @@ -1,9 +1,9 @@ +const { logger } = require('@librechat/data-schemas'); const { EModelEndpoint, validateAzureGroups, mapModelToAzureConfig, } = require('librechat-data-provider'); -const { logger } = require('~/config'); /** * Sets up the Azure OpenAI configuration from the config (`librechat.yaml`) file. diff --git a/api/server/services/start/checks.js b/api/server/services/start/checks.js index d6560c3f3..5b13d41d5 100644 --- a/api/server/services/start/checks.js +++ b/api/server/services/start/checks.js @@ -1,12 +1,11 @@ -const { webSearchKeys } = require('@librechat/api'); +const { logger } = require('@librechat/data-schemas'); +const { isEnabled, webSearchKeys, checkEmailConfig } = require('@librechat/api'); const { Constants, + extractVariableName, deprecatedAzureVariables, conflictingAzureVariables, - extractVariableName, } = require('librechat-data-provider'); -const { isEnabled, checkEmailConfig } = require('~/server/utils'); -const { logger } = require('~/config'); const secretDefaults = { CREDS_KEY: 'f34be427ebb29de8d88c107a71546019685ed8b241d8f2ed00c3df97ad2566f0', @@ -76,7 +75,7 @@ async function checkHealth() { if (response?.ok && response?.status === 200) { logger.info(`RAG API is running and reachable at ${process.env.RAG_API_URL}.`); } - } catch (error) { + } catch { logger.warn( `RAG API is either not running or not reachable at ${process.env.RAG_API_URL}, you may experience errors with file uploads.`, ); diff --git a/api/server/services/start/checks.spec.js b/api/server/services/start/checks.spec.js index d6b95006d..128133126 100644 --- a/api/server/services/start/checks.spec.js +++ b/api/server/services/start/checks.spec.js @@ -1,11 +1,10 @@ -// Mock librechat-data-provider jest.mock('librechat-data-provider', () => ({ ...jest.requireActual('librechat-data-provider'), extractVariableName: jest.fn(), })); -// Mock the config logger -jest.mock('~/config', () => ({ +jest.mock('@librechat/data-schemas', () => ({ + ...jest.requireActual('@librechat/data-schemas'), logger: { debug: jest.fn(), warn: jest.fn(), @@ -13,7 +12,7 @@ jest.mock('~/config', () => ({ })); const { checkWebSearchConfig } = require('./checks'); -const { logger } = require('~/config'); +const { logger } = require('@librechat/data-schemas'); const { extractVariableName } = require('librechat-data-provider'); describe('checkWebSearchConfig', () => { diff --git a/api/server/services/start/endpoints.js b/api/server/services/start/endpoints.js new file mode 100644 index 000000000..3e9bd0df8 --- /dev/null +++ b/api/server/services/start/endpoints.js @@ -0,0 +1,67 @@ +const { agentsConfigSetup } = require('@librechat/api'); +const { EModelEndpoint } = require('librechat-data-provider'); +const { azureAssistantsDefaults, assistantsConfigSetup } = require('./assistants'); +const { azureConfigSetup } = require('./azureOpenAI'); +const { checkAzureVariables } = require('./checks'); + +/** + * Loads custom config endpoints + * @param {TCustomConfig} [config] + * @param {TCustomConfig['endpoints']['agents']} [agentsDefaults] + */ +const loadEndpoints = (config, agentsDefaults) => { + /** @type {AppConfig['endpoints']} */ + const loadedEndpoints = {}; + const endpoints = config?.endpoints; + + if (endpoints?.[EModelEndpoint.azureOpenAI]) { + loadedEndpoints[EModelEndpoint.azureOpenAI] = azureConfigSetup(config); + checkAzureVariables(); + } + + if (endpoints?.[EModelEndpoint.azureOpenAI]?.assistants) { + loadedEndpoints[EModelEndpoint.azureAssistants] = azureAssistantsDefaults(); + } + + if (endpoints?.[EModelEndpoint.azureAssistants]) { + loadedEndpoints[EModelEndpoint.azureAssistants] = assistantsConfigSetup( + config, + EModelEndpoint.azureAssistants, + loadedEndpoints[EModelEndpoint.azureAssistants], + ); + } + + if (endpoints?.[EModelEndpoint.assistants]) { + loadedEndpoints[EModelEndpoint.assistants] = assistantsConfigSetup( + config, + EModelEndpoint.assistants, + loadedEndpoints[EModelEndpoint.assistants], + ); + } + + loadedEndpoints[EModelEndpoint.agents] = agentsConfigSetup(config, agentsDefaults); + + const endpointKeys = [ + EModelEndpoint.openAI, + EModelEndpoint.google, + EModelEndpoint.custom, + EModelEndpoint.bedrock, + EModelEndpoint.anthropic, + ]; + + endpointKeys.forEach((key) => { + if (endpoints?.[key]) { + loadedEndpoints[key] = endpoints[key]; + } + }); + + if (endpoints?.all) { + loadedEndpoints.all = endpoints.all; + } + + return loadedEndpoints; +}; + +module.exports = { + loadEndpoints, +}; diff --git a/api/server/services/start/interface.js b/api/server/services/start/interface.js deleted file mode 100644 index 570881e5c..000000000 --- a/api/server/services/start/interface.js +++ /dev/null @@ -1,292 +0,0 @@ -const { - SystemRoles, - Permissions, - roleDefaults, - PermissionTypes, - removeNullishValues, -} = require('librechat-data-provider'); -const { logger } = require('@librechat/data-schemas'); -const { isMemoryEnabled } = require('@librechat/api'); -const { updateAccessPermissions, getRoleByName } = require('~/models/Role'); - -/** - * Checks if a permission type has explicit configuration - */ -function hasExplicitConfig(interfaceConfig, permissionType) { - switch (permissionType) { - case PermissionTypes.PROMPTS: - return interfaceConfig.prompts !== undefined; - case PermissionTypes.BOOKMARKS: - return interfaceConfig.bookmarks !== undefined; - case PermissionTypes.MEMORIES: - return interfaceConfig.memories !== undefined; - case PermissionTypes.MULTI_CONVO: - return interfaceConfig.multiConvo !== undefined; - case PermissionTypes.AGENTS: - return interfaceConfig.agents !== undefined; - case PermissionTypes.TEMPORARY_CHAT: - return interfaceConfig.temporaryChat !== undefined; - case PermissionTypes.RUN_CODE: - return interfaceConfig.runCode !== undefined; - case PermissionTypes.WEB_SEARCH: - return interfaceConfig.webSearch !== undefined; - case PermissionTypes.PEOPLE_PICKER: - return interfaceConfig.peoplePicker !== undefined; - case PermissionTypes.MARKETPLACE: - return interfaceConfig.marketplace !== undefined; - case PermissionTypes.FILE_SEARCH: - return interfaceConfig.fileSearch !== undefined; - case PermissionTypes.FILE_CITATIONS: - return interfaceConfig.fileCitations !== undefined; - default: - return false; - } -} - -/** - * Loads the default interface object. - * @param {TCustomConfig | undefined} config - The loaded custom configuration. - * @param {TConfigDefaults} configDefaults - The custom configuration default values. - * @returns {Promise} The default interface object. - */ -async function loadDefaultInterface(config, configDefaults) { - const { interface: interfaceConfig } = config ?? {}; - const { interface: defaults } = configDefaults; - const hasModelSpecs = config?.modelSpecs?.list?.length > 0; - const includesAddedEndpoints = config?.modelSpecs?.addedEndpoints?.length > 0; - - const memoryConfig = config?.memory; - const memoryEnabled = isMemoryEnabled(memoryConfig); - /** Only disable memories if memory config is present but disabled/invalid */ - const shouldDisableMemories = memoryConfig && !memoryEnabled; - /** Check if personalization is enabled (defaults to true if memory is configured and enabled) */ - const isPersonalizationEnabled = - memoryConfig && memoryEnabled && memoryConfig.personalize !== false; - - /** @type {TCustomConfig['interface']} */ - const loadedInterface = removeNullishValues({ - // UI elements - use schema defaults - endpointsMenu: - interfaceConfig?.endpointsMenu ?? (hasModelSpecs ? false : defaults.endpointsMenu), - modelSelect: - interfaceConfig?.modelSelect ?? - (hasModelSpecs ? includesAddedEndpoints : defaults.modelSelect), - parameters: interfaceConfig?.parameters ?? (hasModelSpecs ? false : defaults.parameters), - presets: interfaceConfig?.presets ?? (hasModelSpecs ? false : defaults.presets), - sidePanel: interfaceConfig?.sidePanel ?? defaults.sidePanel, - privacyPolicy: interfaceConfig?.privacyPolicy ?? defaults.privacyPolicy, - termsOfService: interfaceConfig?.termsOfService ?? defaults.termsOfService, - mcpServers: interfaceConfig?.mcpServers ?? defaults.mcpServers, - customWelcome: interfaceConfig?.customWelcome ?? defaults.customWelcome, - - // Permissions - only include if explicitly configured - bookmarks: interfaceConfig?.bookmarks, - memories: shouldDisableMemories ? false : interfaceConfig?.memories, - prompts: interfaceConfig?.prompts, - multiConvo: interfaceConfig?.multiConvo, - agents: interfaceConfig?.agents, - temporaryChat: interfaceConfig?.temporaryChat, - runCode: interfaceConfig?.runCode, - webSearch: interfaceConfig?.webSearch, - fileSearch: interfaceConfig?.fileSearch, - fileCitations: interfaceConfig?.fileCitations, - peoplePicker: interfaceConfig?.peoplePicker, - marketplace: interfaceConfig?.marketplace, - }); - - // Helper to get permission value with proper precedence - const getPermissionValue = (configValue, roleDefault, schemaDefault) => { - if (configValue !== undefined) return configValue; - if (roleDefault !== undefined) return roleDefault; - return schemaDefault; - }; - - // Permission precedence order: - // 1. Explicit user configuration (from librechat.yaml) - // 2. Role-specific defaults (from roleDefaults) - // 3. Interface schema defaults (from interfaceSchema.default()) - for (const roleName of [SystemRoles.USER, SystemRoles.ADMIN]) { - const defaultPerms = roleDefaults[roleName].permissions; - const existingRole = await getRoleByName(roleName); - const existingPermissions = existingRole?.permissions || {}; - const permissionsToUpdate = {}; - - // Helper to add permission if it should be updated - const addPermissionIfNeeded = (permType, permissions) => { - const permTypeExists = existingPermissions[permType]; - const isExplicitlyConfigured = - interfaceConfig && hasExplicitConfig(interfaceConfig, permType); - - // Only update if: doesn't exist OR explicitly configured - if (!permTypeExists || isExplicitlyConfigured) { - permissionsToUpdate[permType] = permissions; - if (!permTypeExists) { - logger.debug(`Role '${roleName}': Setting up default permissions for '${permType}'`); - } else if (isExplicitlyConfigured) { - logger.debug(`Role '${roleName}': Applying explicit config for '${permType}'`); - } - } else { - logger.debug(`Role '${roleName}': Preserving existing permissions for '${permType}'`); - } - }; - - // Build permissions for each type - const allPermissions = { - [PermissionTypes.PROMPTS]: { - [Permissions.USE]: getPermissionValue( - loadedInterface.prompts, - defaultPerms[PermissionTypes.PROMPTS]?.[Permissions.USE], - defaults.prompts, - ), - }, - [PermissionTypes.BOOKMARKS]: { - [Permissions.USE]: getPermissionValue( - loadedInterface.bookmarks, - defaultPerms[PermissionTypes.BOOKMARKS]?.[Permissions.USE], - defaults.bookmarks, - ), - }, - [PermissionTypes.MEMORIES]: { - [Permissions.USE]: getPermissionValue( - loadedInterface.memories, - defaultPerms[PermissionTypes.MEMORIES]?.[Permissions.USE], - defaults.memories, - ), - [Permissions.OPT_OUT]: isPersonalizationEnabled, - }, - [PermissionTypes.MULTI_CONVO]: { - [Permissions.USE]: getPermissionValue( - loadedInterface.multiConvo, - defaultPerms[PermissionTypes.MULTI_CONVO]?.[Permissions.USE], - defaults.multiConvo, - ), - }, - [PermissionTypes.AGENTS]: { - [Permissions.USE]: getPermissionValue( - loadedInterface.agents, - defaultPerms[PermissionTypes.AGENTS]?.[Permissions.USE], - defaults.agents, - ), - }, - [PermissionTypes.TEMPORARY_CHAT]: { - [Permissions.USE]: getPermissionValue( - loadedInterface.temporaryChat, - defaultPerms[PermissionTypes.TEMPORARY_CHAT]?.[Permissions.USE], - defaults.temporaryChat, - ), - }, - [PermissionTypes.RUN_CODE]: { - [Permissions.USE]: getPermissionValue( - loadedInterface.runCode, - defaultPerms[PermissionTypes.RUN_CODE]?.[Permissions.USE], - defaults.runCode, - ), - }, - [PermissionTypes.WEB_SEARCH]: { - [Permissions.USE]: getPermissionValue( - loadedInterface.webSearch, - defaultPerms[PermissionTypes.WEB_SEARCH]?.[Permissions.USE], - defaults.webSearch, - ), - }, - [PermissionTypes.PEOPLE_PICKER]: { - [Permissions.VIEW_USERS]: getPermissionValue( - loadedInterface.peoplePicker?.users, - defaultPerms[PermissionTypes.PEOPLE_PICKER]?.[Permissions.VIEW_USERS], - defaults.peoplePicker?.users, - ), - [Permissions.VIEW_GROUPS]: getPermissionValue( - loadedInterface.peoplePicker?.groups, - defaultPerms[PermissionTypes.PEOPLE_PICKER]?.[Permissions.VIEW_GROUPS], - defaults.peoplePicker?.groups, - ), - [Permissions.VIEW_ROLES]: getPermissionValue( - loadedInterface.peoplePicker?.roles, - defaultPerms[PermissionTypes.PEOPLE_PICKER]?.[Permissions.VIEW_ROLES], - defaults.peoplePicker?.roles, - ), - }, - [PermissionTypes.MARKETPLACE]: { - [Permissions.USE]: getPermissionValue( - loadedInterface.marketplace?.use, - defaultPerms[PermissionTypes.MARKETPLACE]?.[Permissions.USE], - defaults.marketplace?.use, - ), - }, - [PermissionTypes.FILE_SEARCH]: { - [Permissions.USE]: getPermissionValue( - loadedInterface.fileSearch, - defaultPerms[PermissionTypes.FILE_SEARCH]?.[Permissions.USE], - defaults.fileSearch, - ), - }, - [PermissionTypes.FILE_CITATIONS]: { - [Permissions.USE]: getPermissionValue( - loadedInterface.fileCitations, - defaultPerms[PermissionTypes.FILE_CITATIONS]?.[Permissions.USE], - defaults.fileCitations, - ), - }, - }; - - // Check and add each permission type if needed - for (const [permType, permissions] of Object.entries(allPermissions)) { - addPermissionIfNeeded(permType, permissions); - } - - // Update permissions if any need updating - if (Object.keys(permissionsToUpdate).length > 0) { - await updateAccessPermissions(roleName, permissionsToUpdate, existingRole); - } - } - - let i = 0; - const logSettings = () => { - // log interface object and model specs object (without list) for reference - logger.warn(`\`interface\` settings:\n${JSON.stringify(loadedInterface, null, 2)}`); - logger.warn( - `\`modelSpecs\` settings:\n${JSON.stringify( - { ...(config?.modelSpecs ?? {}), list: undefined }, - null, - 2, - )}`, - ); - }; - - // warn about config.modelSpecs.prioritize if true and presets are enabled, that default presets will conflict with prioritizing model specs. - if (config?.modelSpecs?.prioritize && loadedInterface.presets) { - logger.warn( - "Note: Prioritizing model specs can conflict with default presets if a default preset is set. It's recommended to disable presets from the interface or disable use of a default preset.", - ); - i === 0 && i++; - } - - // warn about config.modelSpecs.enforce if true and if any of these, endpointsMenu, modelSelect, presets, or parameters are enabled, that enforcing model specs can conflict with these options. - if ( - config?.modelSpecs?.enforce && - (loadedInterface.endpointsMenu || - loadedInterface.modelSelect || - loadedInterface.presets || - loadedInterface.parameters) - ) { - logger.warn( - "Note: Enforcing model specs can conflict with the interface options: endpointsMenu, modelSelect, presets, and parameters. It's recommended to disable these options from the interface or disable enforcing model specs.", - ); - i === 0 && i++; - } - // warn if enforce is true and prioritize is not, that enforcing model specs without prioritizing them can lead to unexpected behavior. - if (config?.modelSpecs?.enforce && !config?.modelSpecs?.prioritize) { - logger.warn( - "Note: Enforcing model specs without prioritizing them can lead to unexpected behavior. It's recommended to enable prioritizing model specs if enforcing them.", - ); - i === 0 && i++; - } - - if (i > 0) { - logSettings(); - } - - return loadedInterface; -} - -module.exports = { loadDefaultInterface }; diff --git a/api/server/services/start/modelSpecs.js b/api/server/services/start/modelSpecs.js index 4adc89cc3..057255010 100644 --- a/api/server/services/start/modelSpecs.js +++ b/api/server/services/start/modelSpecs.js @@ -1,6 +1,6 @@ +const { logger } = require('@librechat/data-schemas'); +const { normalizeEndpointName } = require('@librechat/api'); const { EModelEndpoint } = require('librechat-data-provider'); -const { normalizeEndpointName } = require('~/server/utils'); -const { logger } = require('~/config'); /** * Sets up Model Specs from the config (`librechat.yaml`) file. diff --git a/api/server/services/start/tools.js b/api/server/services/start/tools.js new file mode 100644 index 000000000..684430db5 --- /dev/null +++ b/api/server/services/start/tools.js @@ -0,0 +1,132 @@ +const fs = require('fs'); +const path = require('path'); +const { Tool } = require('@langchain/core/tools'); +const { logger } = require('@librechat/data-schemas'); +const { zodToJsonSchema } = require('zod-to-json-schema'); +const { Tools, ImageVisionTool } = require('librechat-data-provider'); +const { Calculator } = require('@langchain/community/tools/calculator'); +const { getToolkitKey, oaiToolkit, ytToolkit } = require('@librechat/api'); +const { toolkits } = require('~/app/clients/tools/manifest'); + +/** + * Loads and formats tools from the specified tool directory. + * + * The directory is scanned for JavaScript files, excluding any files in the filter set. + * For each file, it attempts to load the file as a module and instantiate a class, if it's a subclass of `StructuredTool`. + * Each tool instance is then formatted to be compatible with the OpenAI Assistant. + * Additionally, instances of LangChain Tools are included in the result. + * + * @param {object} params - The parameters for the function. + * @param {string} params.directory - The directory path where the tools are located. + * @param {Array} [params.adminFilter=[]] - Array of admin-defined tool keys to exclude from loading. + * @param {Array} [params.adminIncluded=[]] - Array of admin-defined tool keys to include from loading. + * @returns {Record} An object mapping each tool's plugin key to its instance. + */ +function loadAndFormatTools({ directory, adminFilter = [], adminIncluded = [] }) { + const filter = new Set([...adminFilter]); + const included = new Set(adminIncluded); + const tools = []; + /* Structured Tools Directory */ + const files = fs.readdirSync(directory); + + if (included.size > 0 && adminFilter.length > 0) { + logger.warn( + 'Both `includedTools` and `filteredTools` are defined; `filteredTools` will be ignored.', + ); + } + + for (const file of files) { + const filePath = path.join(directory, file); + if (!file.endsWith('.js') || (filter.has(file) && included.size === 0)) { + continue; + } + + let ToolClass = null; + try { + ToolClass = require(filePath); + } catch (error) { + logger.error(`[loadAndFormatTools] Error loading tool from ${filePath}:`, error); + continue; + } + + if (!ToolClass || !(ToolClass.prototype instanceof Tool)) { + continue; + } + + let toolInstance = null; + try { + toolInstance = new ToolClass({ override: true }); + } catch (error) { + logger.error( + `[loadAndFormatTools] Error initializing \`${file}\` tool; if it requires authentication, is the \`override\` field configured?`, + error, + ); + continue; + } + + if (!toolInstance) { + continue; + } + + if (filter.has(toolInstance.name) && included.size === 0) { + continue; + } + + if (included.size > 0 && !included.has(file) && !included.has(toolInstance.name)) { + continue; + } + + const formattedTool = formatToOpenAIAssistantTool(toolInstance); + tools.push(formattedTool); + } + + const basicToolInstances = [ + new Calculator(), + ...Object.values(oaiToolkit), + ...Object.values(ytToolkit), + ]; + for (const toolInstance of basicToolInstances) { + const formattedTool = formatToOpenAIAssistantTool(toolInstance); + let toolName = formattedTool[Tools.function].name; + toolName = getToolkitKey({ toolkits, toolName }) ?? toolName; + if (filter.has(toolName) && included.size === 0) { + continue; + } + + if (included.size > 0 && !included.has(toolName)) { + continue; + } + tools.push(formattedTool); + } + + tools.push(ImageVisionTool); + + return tools.reduce((map, tool) => { + map[tool.function.name] = tool; + return map; + }, {}); +} + +/** + * Formats a `StructuredTool` instance into a format that is compatible + * with OpenAI's ChatCompletionFunctions. It uses the `zodToJsonSchema` + * function to convert the schema of the `StructuredTool` into a JSON + * schema, which is then used as the parameters for the OpenAI function. + * + * @param {StructuredTool} tool - The StructuredTool to format. + * @returns {FunctionTool} The OpenAI Assistant Tool. + */ +function formatToOpenAIAssistantTool(tool) { + return { + type: Tools.function, + [Tools.function]: { + name: tool.name, + description: tool.description, + parameters: zodToJsonSchema(tool.schema), + }, + }; +} + +module.exports = { + loadAndFormatTools, +}; diff --git a/api/server/services/start/turnstile.js b/api/server/services/start/turnstile.js index be9e5f83c..9309e680c 100644 --- a/api/server/services/start/turnstile.js +++ b/api/server/services/start/turnstile.js @@ -1,5 +1,5 @@ +const { logger } = require('@librechat/data-schemas'); const { removeNullishValues } = require('librechat-data-provider'); -const { logger } = require('~/config'); /** * Loads and maps the Cloudflare Turnstile configuration. diff --git a/api/server/utils/getFileStrategy.js b/api/server/utils/getFileStrategy.js index 6c408e510..2e3dfdd79 100644 --- a/api/server/utils/getFileStrategy.js +++ b/api/server/utils/getFileStrategy.js @@ -1,14 +1,14 @@ -const { FileContext } = require('librechat-data-provider'); +const { FileSources, FileContext } = require('librechat-data-provider'); /** * Determines the appropriate file storage strategy based on file type and configuration. * - * @param {Object} config - App configuration object containing fileStrategy and fileStrategies + * @param {AppConfig} appConfig - App configuration object containing fileStrategy and fileStrategies * @param {Object} options - File context options * @param {boolean} options.isAvatar - Whether this is an avatar upload * @param {boolean} options.isImage - Whether this is an image upload * @param {string} options.context - File context from FileContext enum - * @returns {string} Storage strategy to use (e.g., 'local', 's3', 'azure') + * @returns {string} Storage strategy to use (e.g., FileSources.local, 's3', 'azure') * * @example * // Legacy single strategy @@ -19,30 +19,25 @@ const { FileContext } = require('librechat-data-provider'); * getFileStrategy( * { * fileStrategy: 's3', - * fileStrategies: { avatar: 'local', document: 's3' } + * fileStrategies: { avatar: FileSources.local, document: 's3' } * }, * { isAvatar: true } - * ) // Returns 'local' + * ) // Returns FileSources.local */ -function getFileStrategy(appLocals, { isAvatar = false, isImage = false, context = null } = {}) { - // Handle both old (config object) and new (app.locals object) calling patterns - const isAppLocals = appLocals.fileStrategy !== undefined; - const config = isAppLocals ? appLocals.config : appLocals; - const fileStrategy = isAppLocals ? appLocals.fileStrategy : appLocals.fileStrategy; - +function getFileStrategy(appConfig, { isAvatar = false, isImage = false, context = null } = {}) { // Fallback to legacy single strategy if no granular config - if (!config?.fileStrategies) { - return fileStrategy || 'local'; // Default to 'local' if undefined + if (!appConfig?.fileStrategies) { + return appConfig.fileStrategy || FileSources.local; // Default to FileSources.local if undefined } - const strategies = config.fileStrategies; - const defaultStrategy = strategies.default || fileStrategy || 'local'; + const strategies = appConfig.fileStrategies; + const defaultStrategy = strategies.default || appConfig.fileStrategy || FileSources.local; // Priority order for strategy selection: // 1. Specific file type strategy // 2. Default strategy from fileStrategies // 3. Legacy fileStrategy - // 4. 'local' as final fallback + // 4. FileSources.local as final fallback let selectedStrategy; @@ -55,7 +50,7 @@ function getFileStrategy(appLocals, { isAvatar = false, isImage = false, context selectedStrategy = strategies.document || defaultStrategy; } - return selectedStrategy || 'local'; // Final fallback to 'local' + return selectedStrategy || FileSources.local; // Final fallback to FileSources.local } module.exports = { getFileStrategy }; diff --git a/api/server/utils/handleText.js b/api/server/utils/handleText.js index 36671c44f..91c2ff25e 100644 --- a/api/server/utils/handleText.js +++ b/api/server/utils/handleText.js @@ -8,7 +8,6 @@ const { defaultAgentCapabilities, } = require('librechat-data-provider'); const { sendEvent } = require('@librechat/api'); -const { Providers } = require('@librechat/agents'); const partialRight = require('lodash/partialRight'); /** Helper function to escape special characters in regex @@ -207,15 +206,6 @@ function generateConfig(key, baseURL, endpoint) { return config; } -/** - * Normalize the endpoint name to system-expected value. - * @param {string} name - * @returns {string} - */ -function normalizeEndpointName(name = '') { - return name.toLowerCase() === Providers.OLLAMA ? Providers.OLLAMA : name; -} - module.exports = { isEnabled, handleText, @@ -226,5 +216,4 @@ module.exports = { generateConfig, addSpaceIfNeeded, createOnProgress, - normalizeEndpointName, }; diff --git a/api/server/utils/index.js b/api/server/utils/index.js index 2672f4f2e..7e29b9f51 100644 --- a/api/server/utils/index.js +++ b/api/server/utils/index.js @@ -5,28 +5,7 @@ const sendEmail = require('./sendEmail'); const queue = require('./queue'); const files = require('./files'); -/** - * Check if email configuration is set - * @returns {Boolean} - */ -function checkEmailConfig() { - // Check if Mailgun is configured - const hasMailgunConfig = - !!process.env.MAILGUN_API_KEY && !!process.env.MAILGUN_DOMAIN && !!process.env.EMAIL_FROM; - - // Check if SMTP is configured - const hasSMTPConfig = - (!!process.env.EMAIL_SERVICE || !!process.env.EMAIL_HOST) && - !!process.env.EMAIL_USERNAME && - !!process.env.EMAIL_PASSWORD && - !!process.env.EMAIL_FROM; - - // Return true if either Mailgun or SMTP is properly configured - return hasMailgunConfig || hasSMTPConfig; -} - module.exports = { - checkEmailConfig, ...handleText, countTokens, removePorts, diff --git a/api/server/utils/sendEmail.js b/api/server/utils/sendEmail.js index c0afd0eeb..ee64b209f 100644 --- a/api/server/utils/sendEmail.js +++ b/api/server/utils/sendEmail.js @@ -4,9 +4,8 @@ const axios = require('axios'); const FormData = require('form-data'); const nodemailer = require('nodemailer'); const handlebars = require('handlebars'); -const { logAxiosError } = require('@librechat/api'); const { logger } = require('@librechat/data-schemas'); -const { isEnabled } = require('~/server/utils/handleText'); +const { logAxiosError, isEnabled } = require('@librechat/api'); /** * Sends an email using Mailgun API. diff --git a/api/strategies/ldapStrategy.js b/api/strategies/ldapStrategy.js index a9c00fa59..c5796f62e 100644 --- a/api/strategies/ldapStrategy.js +++ b/api/strategies/ldapStrategy.js @@ -1,10 +1,10 @@ const fs = require('fs'); -const { isEnabled } = require('@librechat/api'); const LdapStrategy = require('passport-ldapauth'); const { logger } = require('@librechat/data-schemas'); +const { isEnabled, getBalanceConfig } = require('@librechat/api'); const { SystemRoles, ErrorTypes } = require('librechat-data-provider'); const { createUser, findUser, updateUser, countUsers } = require('~/models'); -const { getBalanceConfig } = require('~/server/services/Config'); +const { getAppConfig } = require('~/server/services/Config'); const { LDAP_URL, @@ -123,6 +123,7 @@ const ldapLogin = new LdapStrategy(ldapOptions, async (userinfo, done) => { if (!user) { const isFirstRegisteredUser = (await countUsers()) === 0; + const role = isFirstRegisteredUser ? SystemRoles.ADMIN : SystemRoles.USER; user = { provider: 'ldap', ldapId, @@ -130,9 +131,10 @@ const ldapLogin = new LdapStrategy(ldapOptions, async (userinfo, done) => { email: mail, emailVerified: true, // The ldap server administrator should verify the email name: fullName, - role: isFirstRegisteredUser ? SystemRoles.ADMIN : SystemRoles.USER, + role, }; - const balanceConfig = await getBalanceConfig(); + const appConfig = await getAppConfig({ role: user?.role }); + const balanceConfig = getBalanceConfig(appConfig); const userId = await createUser(user, balanceConfig); user._id = userId; } else { diff --git a/api/strategies/localStrategy.js b/api/strategies/localStrategy.js index 26b3b8197..0d220ead2 100644 --- a/api/strategies/localStrategy.js +++ b/api/strategies/localStrategy.js @@ -1,7 +1,7 @@ const { logger } = require('@librechat/data-schemas'); const { errorsToString } = require('librechat-data-provider'); +const { isEnabled, checkEmailConfig } = require('@librechat/api'); const { Strategy: PassportLocalStrategy } = require('passport-local'); -const { isEnabled, checkEmailConfig } = require('~/server/utils'); const { findUser, comparePassword, updateUser } = require('~/models'); const { loginSchema } = require('./validators'); diff --git a/api/strategies/openidStrategy.js b/api/strategies/openidStrategy.js index ec22b5174..4cb749328 100644 --- a/api/strategies/openidStrategy.js +++ b/api/strategies/openidStrategy.js @@ -7,10 +7,10 @@ const { HttpsProxyAgent } = require('https-proxy-agent'); const { hashToken, logger } = require('@librechat/data-schemas'); const { CacheKeys, ErrorTypes } = require('librechat-data-provider'); const { Strategy: OpenIDStrategy } = require('openid-client/passport'); -const { isEnabled, safeStringify, logHeaders } = require('@librechat/api'); +const { isEnabled, logHeaders, safeStringify, getBalanceConfig } = require('@librechat/api'); const { getStrategyFunctions } = require('~/server/services/Files/strategies'); const { findUser, createUser, updateUser } = require('~/models'); -const { getBalanceConfig } = require('~/server/services/Config'); +const { getAppConfig } = require('~/server/services/Config'); const getLogStores = require('~/cache/getLogStores'); /** @@ -410,8 +410,8 @@ async function setupOpenId() { idOnTheSource: userinfo.oid, }; - const balanceConfig = await getBalanceConfig(); - + const appConfig = await getAppConfig(); + const balanceConfig = getBalanceConfig(appConfig); user = await createUser(user, balanceConfig, true, true); } else { user.provider = 'openid'; diff --git a/api/strategies/openidStrategy.spec.js b/api/strategies/openidStrategy.spec.js index 550ef1814..5bbf194f4 100644 --- a/api/strategies/openidStrategy.spec.js +++ b/api/strategies/openidStrategy.spec.js @@ -13,6 +13,11 @@ jest.mock('~/server/services/Files/strategies', () => ({ })), })); jest.mock('~/server/services/Config', () => ({ + getAppConfig: jest.fn().mockResolvedValue({}), +})); +jest.mock('@librechat/api', () => ({ + ...jest.requireActual('@librechat/api'), + isEnabled: jest.fn(() => false), getBalanceConfig: jest.fn(() => ({ enabled: false, })), @@ -22,10 +27,6 @@ jest.mock('~/models', () => ({ createUser: jest.fn(), updateUser: jest.fn(), })); -jest.mock('@librechat/api', () => ({ - ...jest.requireActual('@librechat/api'), - isEnabled: jest.fn(() => false), -})); jest.mock('@librechat/data-schemas', () => ({ ...jest.requireActual('@librechat/api'), logger: { diff --git a/api/strategies/process.js b/api/strategies/process.js index 49f99c1f4..f8b01f054 100644 --- a/api/strategies/process.js +++ b/api/strategies/process.js @@ -1,15 +1,16 @@ +const { getBalanceConfig } = require('@librechat/api'); const { FileSources } = require('librechat-data-provider'); const { getStrategyFunctions } = require('~/server/services/Files/strategies'); const { resizeAvatar } = require('~/server/services/Files/images/avatar'); const { updateUser, createUser, getUserById } = require('~/models'); -const { getBalanceConfig } = require('~/server/services/Config'); +const { getAppConfig } = require('~/server/services/Config'); /** * Updates the avatar URL of an existing user. If the user's avatar URL does not include the query parameter * '?manual=true', it updates the user's avatar with the provided URL. For local file storage, it directly updates * the avatar URL, while for other storage types, it processes the avatar URL using the specified file strategy. * - * @param {MongoUser} oldUser - The existing user object that needs to be updated. + * @param {IUser} oldUser - The existing user object that needs to be updated. * @param {string} avatarUrl - The new avatar URL to be set for the user. * * @returns {Promise} @@ -82,7 +83,8 @@ const createSocialUser = async ({ emailVerified, }; - const balanceConfig = await getBalanceConfig(); + const appConfig = await getAppConfig(); + const balanceConfig = getBalanceConfig(appConfig); const newUserId = await createUser(update, balanceConfig); const fileStrategy = process.env.CDN_PROVIDER; const isLocal = fileStrategy === FileSources.local; diff --git a/api/strategies/process.test.js b/api/strategies/process.test.js index 729552c86..ceb7d21a6 100644 --- a/api/strategies/process.test.js +++ b/api/strategies/process.test.js @@ -16,7 +16,13 @@ jest.mock('~/models', () => ({ })); jest.mock('~/server/services/Config', () => ({ - getBalanceConfig: jest.fn(), + getAppConfig: jest.fn().mockResolvedValue({}), +})); + +jest.mock('@librechat/api', () => ({ + getBalanceConfig: jest.fn(() => ({ + enabled: false, + })), })); const { getStrategyFunctions } = require('~/server/services/Files/strategies'); diff --git a/api/strategies/samlStrategy.js b/api/strategies/samlStrategy.js index e00918ff4..e09d64bce 100644 --- a/api/strategies/samlStrategy.js +++ b/api/strategies/samlStrategy.js @@ -2,12 +2,13 @@ const fs = require('fs'); const path = require('path'); const fetch = require('node-fetch'); const passport = require('passport'); +const { getBalanceConfig } = require('@librechat/api'); const { ErrorTypes } = require('librechat-data-provider'); const { hashToken, logger } = require('@librechat/data-schemas'); const { Strategy: SamlStrategy } = require('@node-saml/passport-saml'); const { getStrategyFunctions } = require('~/server/services/Files/strategies'); const { findUser, createUser, updateUser } = require('~/models'); -const { getBalanceConfig } = require('~/server/services/Config'); +const { getAppConfig } = require('~/server/services/Config'); const paths = require('~/config/paths'); let crypto; @@ -228,7 +229,8 @@ async function setupSaml() { emailVerified: true, name: fullName, }; - const balanceConfig = await getBalanceConfig(); + const appConfig = await getAppConfig(); + const balanceConfig = getBalanceConfig(appConfig); user = await createUser(user, balanceConfig, true, true); } else { user.provider = 'saml'; diff --git a/api/strategies/samlStrategy.spec.js b/api/strategies/samlStrategy.spec.js index d6880655d..812c24f26 100644 --- a/api/strategies/samlStrategy.spec.js +++ b/api/strategies/samlStrategy.spec.js @@ -23,10 +23,13 @@ jest.mock('~/server/services/Config', () => ({ socialLogins: ['saml'], }, }, - getBalanceConfig: jest.fn().mockResolvedValue({ + getAppConfig: jest.fn().mockResolvedValue({}), +})); +jest.mock('@librechat/api', () => ({ + getBalanceConfig: jest.fn(() => ({ tokenCredits: 1000, - startingBalance: 1000, - }), + startBalance: 1000, + })), })); jest.mock('~/server/services/Config/EndpointService', () => ({ config: {}, diff --git a/api/test/services/Files/processFileCitations.test.js b/api/test/services/Files/processFileCitations.test.js index 1370dc287..e9fe850eb 100644 --- a/api/test/services/Files/processFileCitations.test.js +++ b/api/test/services/Files/processFileCitations.test.js @@ -20,17 +20,17 @@ jest.mock('@librechat/api', () => ({ checkAccess: jest.fn().mockResolvedValue(true), })); -jest.mock('~/server/services/Config/getCustomConfig', () => ({ - getCustomConfig: jest.fn().mockResolvedValue({ - endpoints: { - agents: { - maxCitations: 30, - maxCitationsPerFile: 5, - minRelevanceScore: 0.45, - }, +jest.mock('~/cache/getLogStores', () => () => ({ + get: jest.fn().mockResolvedValue({ + agents: { + maxCitations: 30, + maxCitationsPerFile: 5, + minRelevanceScore: 0.45, }, fileStrategy: 'local', }), + set: jest.fn(), + delete: jest.fn(), })); jest.mock('~/config', () => ({ @@ -48,6 +48,17 @@ describe('processFileCitations', () => { }, }; + const mockAppConfig = { + endpoints: { + agents: { + maxCitations: 30, + maxCitationsPerFile: 5, + minRelevanceScore: 0.45, + }, + }, + fileStrategy: 'local', + }; + const mockMetadata = { run_id: 'run123', thread_id: 'conv123', @@ -85,6 +96,7 @@ describe('processFileCitations', () => { toolCallId: 'call_123', metadata: mockMetadata, user: mockReq.user, + appConfig: mockAppConfig, }); expect(result).toBeTruthy(); @@ -100,6 +112,7 @@ describe('processFileCitations', () => { toolCallId: 'call_123', metadata: mockMetadata, user: mockReq.user, + appConfig: mockAppConfig, }); expect(result).toBeNull(); @@ -127,6 +140,7 @@ describe('processFileCitations', () => { toolCallId: 'call_123', metadata: mockMetadata, user: mockReq.user, + appConfig: mockAppConfig, }); expect(result).toBeNull(); @@ -138,6 +152,7 @@ describe('processFileCitations', () => { toolCallId: 'call_123', metadata: mockMetadata, user: mockReq.user, + appConfig: mockAppConfig, }); expect(result).toBeNull(); diff --git a/api/typedefs.js b/api/typedefs.js index 2703c41d0..d26617356 100644 --- a/api/typedefs.js +++ b/api/typedefs.js @@ -15,7 +15,10 @@ /** * @exports ServerRequest - * @typedef {import('express').Request} ServerRequest + * @typedef {import('express').Request & { + * user?: IUser; + * config?: AppConfig; + * }} ServerRequest * @memberof typedefs */ @@ -877,8 +880,8 @@ */ /** - * @exports MongoUser - * @typedef {import('@librechat/data-schemas').IUser} MongoUser + * @exports IUser + * @typedef {import('@librechat/data-schemas').IUser} IUser * @memberof typedefs */ @@ -917,7 +920,6 @@ * @typedef {Object} ImageGenOptions * @property {ServerRequest} req - The request object. * @property {boolean} isAgent - Whether the request is from an agent. - * @property {FileSources} fileStrategy - The file strategy to use. * @property {processFileURL} processFileURL - The function to process a file URL. * @property {boolean} returnMetadata - Whether to return metadata. * @property {uploadImageBuffer} uploadImageBuffer - The function to upload an image buffer. @@ -930,6 +932,7 @@ * signal?: AbortSignal, * memory?: ConversationSummaryBufferMemory, * tool_resources?: AgentToolResources, + * web_search?: ReturnType, * }} LoadToolOptions * @memberof typedefs */ @@ -1091,6 +1094,12 @@ * @memberof typedefs */ +/** + * @exports AppConfig + * @typedef {import('@librechat/api').AppConfig} AppConfig + * @memberof typedefs + */ + /** * @exports JsonSchemaType * @typedef {import('@librechat/api').JsonSchemaType} JsonSchemaType @@ -1669,26 +1678,10 @@ * @memberof typedefs */ -// /** -// * @typedef {OpenAI & { -// * req: Express.Request, -// * res: Express.Response -// * getPartialText: () => string, -// * processedFileIds: Set, -// * mappedOrder: Map, -// * completeToolCallSteps: Set, -// * seenCompletedMessages: Set, -// * seenToolCalls: Map, -// * progressCallback: (options: Object) => void, -// * addContentData: (data: TContentData) => void, -// * responseMessage: ResponseMessage, -// * }} OpenAIClient - for reference only -// */ - /** * @typedef {Object} RunClient * - * @property {Express.Request} req - The Express request object. + * @property {ServerRequest} req - The Express request object. * @property {Express.Response} res - The Express response object. * @property {?import('https-proxy-agent').HttpsProxyAgent} httpAgent - An optional HTTP proxy agent for the request. @@ -1789,8 +1782,8 @@ * @property {String} conversationId - The ID of the conversation. * @property {String} model - The model name. * @property {String} context - The context in which the transaction is made. + * @property {AppConfig['balance']} [balance] - The balance config * @property {EndpointTokenConfig} [endpointTokenConfig] - The current endpoint token config. - * @property {object} [cacheUsage] - Cache usage, if any. * @property {String} [valueKey] - The value key (optional). * @memberof typedefs */ @@ -1835,6 +1828,7 @@ * @callback sendCompletion * @param {Array | string} payload - The messages or prompt to send to the model * @param {object} opts - Options for the completion + * @param {AppConfig} opts.appConfig - Callback function to handle token progress * @param {onTokenProgress} opts.onProgress - Callback function to handle token progress * @param {AbortController} opts.abortController - AbortController instance * @param {Record>} [opts.userMCPAuthMap] diff --git a/client/src/data-provider/queries.ts b/client/src/data-provider/queries.ts index f4fd4d37f..6cc29ea05 100644 --- a/client/src/data-provider/queries.ts +++ b/client/src/data-provider/queries.ts @@ -45,21 +45,6 @@ export const useGetPresetsQuery = ( }); }; -export const useGetEndpointsConfigOverride = ( - config?: UseQueryOptions, -): QueryObserverResult => { - return useQuery( - [QueryKeys.endpointsConfigOverride], - () => dataService.getEndpointsConfigOverride(), - { - refetchOnWindowFocus: false, - refetchOnReconnect: false, - refetchOnMount: false, - ...config, - }, - ); -}; - export const useGetConvoIdQuery = ( id: string, config?: UseQueryOptions, diff --git a/client/src/hooks/Config/useConfigOverride.ts b/client/src/hooks/Config/useConfigOverride.ts deleted file mode 100644 index 977cb8661..000000000 --- a/client/src/hooks/Config/useConfigOverride.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { useSetRecoilState } from 'recoil'; -import { useEffect, useCallback } from 'react'; -import { useQueryClient } from '@tanstack/react-query'; -import type { TEndpointsConfig, TModelsConfig } from 'librechat-data-provider'; -import { useGetEndpointsConfigOverride } from '~/data-provider'; -import { QueryKeys } from 'librechat-data-provider'; -import store from '~/store'; - -type TempOverrideType = Record & { - endpointsConfig: TEndpointsConfig; - modelsConfig?: TModelsConfig; - combinedOptions: unknown[]; - combined: boolean; -}; - -export default function useConfigOverride() { - const setEndpointsQueryEnabled = useSetRecoilState(store.endpointsQueryEnabled); - const overrideQuery = useGetEndpointsConfigOverride({ - staleTime: Infinity, - }); - - const queryClient = useQueryClient(); - - const handleOverride = useCallback( - async (data: unknown | boolean) => { - const { endpointsConfig, modelsConfig } = data as TempOverrideType; - if (endpointsConfig) { - setEndpointsQueryEnabled(false); - await queryClient.cancelQueries([QueryKeys.endpoints]); - queryClient.setQueryData([QueryKeys.endpoints], endpointsConfig); - } - if (modelsConfig) { - await queryClient.cancelQueries([QueryKeys.models]); - queryClient.setQueryData([QueryKeys.models], modelsConfig); - } - }, - [queryClient, setEndpointsQueryEnabled], - ); - - useEffect(() => { - if (overrideQuery.data != null) { - handleOverride(overrideQuery.data); - } - }, [overrideQuery.data, handleOverride]); -} diff --git a/client/src/hooks/Conversations/useExportConversation.ts b/client/src/hooks/Conversations/useExportConversation.ts index 68c2061c2..e97f321e1 100644 --- a/client/src/hooks/Conversations/useExportConversation.ts +++ b/client/src/hooks/Conversations/useExportConversation.ts @@ -1,5 +1,6 @@ import download from 'downloadjs'; import { useCallback } from 'react'; +import { useParams } from 'react-router-dom'; import exportFromJSON from 'export-from-json'; import { useQueryClient } from '@tanstack/react-query'; import { @@ -10,15 +11,14 @@ import { isImageVisionTool, } from 'librechat-data-provider'; import type { + TMessageContentParts, + TConversation, TMessage, TPreset, - TConversation, - TMessageContentParts, } from 'librechat-data-provider'; import useBuildMessageTree from '~/hooks/Messages/useBuildMessageTree'; import { useScreenshot } from '~/hooks/ScreenshotContext'; import { cleanupPreset, buildTree } from '~/utils'; -import { useParams } from 'react-router-dom'; type ExportValues = { fieldName: string; @@ -48,13 +48,14 @@ export default function useExportConversation({ const { conversationId: paramId } = useParams(); const getMessageTree = useCallback(() => { - const queryParam = paramId === 'new' ? paramId : conversation?.conversationId ?? paramId ?? ''; + const queryParam = + paramId === 'new' ? paramId : (conversation?.conversationId ?? paramId ?? ''); const messages = queryClient.getQueryData([QueryKeys.messages, queryParam]) ?? []; const dataTree = buildTree({ messages }); - return dataTree?.length === 0 ? null : dataTree ?? null; + return dataTree?.length === 0 ? null : (dataTree ?? null); }, [paramId, conversation?.conversationId, queryClient]); - const getMessageText = (message: TMessage | undefined, format = 'text') => { + const getMessageText = (message: Partial | undefined, format = 'text') => { if (!message) { return ''; } @@ -67,7 +68,7 @@ export default function useExportConversation({ }; if (!message.content) { - return formatText(message.sender || '', message.text); + return formatText(message.sender || '', message.text || ''); } return message.content @@ -90,7 +91,12 @@ export default function useExportConversation({ if (content.type === ContentTypes.ERROR) { // ERROR - return [sender, content[ContentTypes.TEXT].value]; + return [ + sender, + typeof content[ContentTypes.TEXT] === 'object' + ? (content[ContentTypes.TEXT].value ?? '') + : (content[ContentTypes.TEXT] ?? ''), + ]; } if (content.type === ContentTypes.TEXT) { @@ -156,7 +162,7 @@ export default function useExportConversation({ }; const exportCSV = async () => { - const data: TMessage[] = []; + const data: Partial[] = []; const messages = await buildMessageTree({ messageId: conversation?.conversationId, @@ -168,6 +174,9 @@ export default function useExportConversation({ if (Array.isArray(messages)) { for (const message of messages) { + if (!message) { + continue; + } data.push(message); } } else { @@ -245,10 +254,10 @@ export default function useExportConversation({ if (Array.isArray(messages)) { for (const message of messages) { data += `${getMessageText(message, 'md')}\n`; - if (message.error) { + if (message?.error) { data += '*(This is an error message)*\n'; } - if (message.unfinished === true) { + if (message?.unfinished === true) { data += '*(This is an unfinished message)*\n'; } data += '\n\n'; @@ -301,10 +310,10 @@ export default function useExportConversation({ if (Array.isArray(messages)) { for (const message of messages) { data += `${getMessageText(message)}\n`; - if (message.error) { + if (message?.error) { data += '(This is an error message)\n'; } - if (message.unfinished === true) { + if (message?.unfinished === true) { data += '(This is an unfinished message)\n'; } data += '\n\n'; diff --git a/config/add-balance.js b/config/add-balance.js index 48176f992..ebf49df44 100644 --- a/config/add-balance.js +++ b/config/add-balance.js @@ -1,10 +1,11 @@ const path = require('path'); const mongoose = require('mongoose'); +const { isEnabled, getBalanceConfig } = require('@librechat/api'); const { User } = require('@librechat/data-schemas').createModels(mongoose); require('module-alias')({ base: path.resolve(__dirname, '..', 'api') }); -const { askQuestion, silentExit } = require('./helpers'); -const { isEnabled } = require('~/server/utils/handleText'); const { createTransaction } = require('~/models/Transaction'); +const { getAppConfig } = require('~/server/services/Config'); +const { askQuestion, silentExit } = require('./helpers'); const connect = require('./connect'); (async () => { @@ -79,11 +80,14 @@ const connect = require('./connect'); */ let result; try { + const appConfig = await getAppConfig(); + const balanceConfig = getBalanceConfig(appConfig); result = await createTransaction({ user: user._id, tokenType: 'credits', context: 'admin', rawAmount: +amount, + balance: balanceConfig, }); } catch (error) { console.red('Error: ' + error.message); diff --git a/config/invite-user.js b/config/invite-user.js index b311837c3..80fe0ab23 100644 --- a/config/invite-user.js +++ b/config/invite-user.js @@ -1,10 +1,11 @@ const path = require('path'); const mongoose = require('mongoose'); +const { checkEmailConfig } = require('@librechat/api'); const { User } = require('@librechat/data-schemas').createModels(mongoose); require('module-alias')({ base: path.resolve(__dirname, '..', 'api') }); -const { sendEmail, checkEmailConfig } = require('~/server/utils'); const { askQuestion, silentExit } = require('./helpers'); const { createInvite } = require('~/models/inviteUser'); +const { sendEmail } = require('~/server/utils'); const connect = require('./connect'); (async () => { diff --git a/config/set-balance.js b/config/set-balance.js index 90c815c9a..faa0c1dd1 100644 --- a/config/set-balance.js +++ b/config/set-balance.js @@ -1,9 +1,9 @@ const path = require('path'); const mongoose = require('mongoose'); +const { isEnabled } = require('@librechat/api'); const { User, Balance } = require('@librechat/data-schemas').createModels(mongoose); require('module-alias')({ base: path.resolve(__dirname, '..', 'api') }); const { askQuestion, silentExit } = require('./helpers'); -const { isEnabled } = require('~/server/utils/handleText'); const connect = require('./connect'); (async () => { diff --git a/package-lock.json b/package-lock.json index 6aeeb0026..c3a250f85 100644 --- a/package-lock.json +++ b/package-lock.json @@ -112,7 +112,6 @@ "nodemailer": "^6.9.15", "ollama": "^0.5.0", "openai": "^5.10.1", - "openai-chat-tokens": "^0.2.8", "openid-client": "^6.5.0", "passport": "^0.6.0", "passport-apple": "^2.0.2", @@ -42553,14 +42552,6 @@ } } }, - "node_modules/openai-chat-tokens": { - "version": "0.2.8", - "resolved": "https://registry.npmjs.org/openai-chat-tokens/-/openai-chat-tokens-0.2.8.tgz", - "integrity": "sha512-nW7QdFDIZlAYe6jsCT/VPJ/Lam3/w2DX9oxf/5wHpebBT49KI3TN43PPhYlq1klq2ajzXWKNOLY6U4FNZM7AoA==", - "dependencies": { - "js-tiktoken": "^1.0.7" - } - }, "node_modules/openai/node_modules/@types/node": { "version": "18.19.14", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.14.tgz", diff --git a/packages/api/src/agents/resources.test.ts b/packages/api/src/agents/resources.test.ts index 3735726b8..df6e0a121 100644 --- a/packages/api/src/agents/resources.test.ts +++ b/packages/api/src/agents/resources.test.ts @@ -1,9 +1,11 @@ import { primeResources } from './resources'; import { logger } from '@librechat/data-schemas'; import { EModelEndpoint, EToolResources, AgentCapabilities } from 'librechat-data-provider'; +import type { TAgentsEndpoint, TFile } from 'librechat-data-provider'; import type { Request as ServerRequest } from 'express'; -import type { TFile } from 'librechat-data-provider'; +import type { IUser } from '@librechat/data-schemas'; import type { TGetFiles } from './resources'; +import type { AppConfig } from '~/types'; // Mock logger jest.mock('@librechat/data-schemas', () => ({ @@ -13,7 +15,8 @@ jest.mock('@librechat/data-schemas', () => ({ })); describe('primeResources', () => { - let mockReq: ServerRequest; + let mockReq: ServerRequest & { user?: IUser }; + let mockAppConfig: AppConfig; let mockGetFiles: jest.MockedFunction; let requestFileSet: Set; @@ -22,15 +25,16 @@ describe('primeResources', () => { jest.clearAllMocks(); // Setup mock request - mockReq = { - app: { - locals: { - [EModelEndpoint.agents]: { - capabilities: [AgentCapabilities.ocr], - }, - }, + mockReq = {} as unknown as ServerRequest & { user?: IUser }; + + // Setup mock appConfig + mockAppConfig = { + endpoints: { + [EModelEndpoint.agents]: { + capabilities: [AgentCapabilities.ocr], + } as TAgentsEndpoint, }, - } as unknown as ServerRequest; + } as AppConfig; // Setup mock getFiles function mockGetFiles = jest.fn(); @@ -65,6 +69,7 @@ describe('primeResources', () => { const result = await primeResources({ req: mockReq, + appConfig: mockAppConfig, getFiles: mockGetFiles, requestFileSet, attachments: undefined, @@ -84,7 +89,7 @@ describe('primeResources', () => { describe('when OCR is disabled', () => { it('should not fetch OCR files even if tool_resources has OCR file_ids', async () => { - (mockReq.app as ServerRequest['app']).locals[EModelEndpoint.agents].capabilities = []; + (mockAppConfig.endpoints![EModelEndpoint.agents] as TAgentsEndpoint).capabilities = []; const tool_resources = { [EToolResources.ocr]: { @@ -94,6 +99,7 @@ describe('primeResources', () => { const result = await primeResources({ req: mockReq, + appConfig: mockAppConfig, getFiles: mockGetFiles, requestFileSet, attachments: undefined, @@ -129,6 +135,7 @@ describe('primeResources', () => { const result = await primeResources({ req: mockReq, + appConfig: mockAppConfig, getFiles: mockGetFiles, requestFileSet, attachments, @@ -158,6 +165,7 @@ describe('primeResources', () => { const result = await primeResources({ req: mockReq, + appConfig: mockAppConfig, getFiles: mockGetFiles, requestFileSet, attachments, @@ -189,6 +197,7 @@ describe('primeResources', () => { const result = await primeResources({ req: mockReq, + appConfig: mockAppConfig, getFiles: mockGetFiles, requestFileSet, attachments, @@ -220,6 +229,7 @@ describe('primeResources', () => { const result = await primeResources({ req: mockReq, + appConfig: mockAppConfig, getFiles: mockGetFiles, requestFileSet, attachments, @@ -250,6 +260,7 @@ describe('primeResources', () => { const result = await primeResources({ req: mockReq, + appConfig: mockAppConfig, getFiles: mockGetFiles, requestFileSet, attachments, @@ -291,6 +302,7 @@ describe('primeResources', () => { const result = await primeResources({ req: mockReq, + appConfig: mockAppConfig, getFiles: mockGetFiles, requestFileSet, attachments, @@ -342,6 +354,7 @@ describe('primeResources', () => { const result = await primeResources({ req: mockReq, + appConfig: mockAppConfig, getFiles: mockGetFiles, requestFileSet, attachments, @@ -399,6 +412,7 @@ describe('primeResources', () => { const result = await primeResources({ req: mockReq, + appConfig: mockAppConfig, getFiles: mockGetFiles, requestFileSet, attachments, @@ -450,6 +464,7 @@ describe('primeResources', () => { const result = await primeResources({ req: mockReq, + appConfig: mockAppConfig, getFiles: mockGetFiles, requestFileSet, attachments, @@ -492,6 +507,7 @@ describe('primeResources', () => { const result = await primeResources({ req: mockReq, + appConfig: mockAppConfig, getFiles: mockGetFiles, requestFileSet, attachments, @@ -560,6 +576,7 @@ describe('primeResources', () => { const result = await primeResources({ req: mockReq, + appConfig: mockAppConfig, getFiles: mockGetFiles, requestFileSet, attachments, @@ -618,6 +635,7 @@ describe('primeResources', () => { const result = await primeResources({ req: mockReq, + appConfig: mockAppConfig, getFiles: mockGetFiles, requestFileSet, attachments, @@ -671,6 +689,7 @@ describe('primeResources', () => { const result = await primeResources({ req: mockReq, + appConfig: mockAppConfig, getFiles: mockGetFiles, requestFileSet, attachments, @@ -724,6 +743,7 @@ describe('primeResources', () => { const result = await primeResources({ req: mockReq, + appConfig: mockAppConfig, getFiles: mockGetFiles, requestFileSet, attachments, @@ -764,6 +784,7 @@ describe('primeResources', () => { const result = await primeResources({ req: mockReq, + appConfig: mockAppConfig, getFiles: mockGetFiles, requestFileSet, attachments, @@ -838,6 +859,7 @@ describe('primeResources', () => { const result = await primeResources({ req: mockReq, + appConfig: mockAppConfig, getFiles: mockGetFiles, requestFileSet, attachments, @@ -888,6 +910,7 @@ describe('primeResources', () => { const result = await primeResources({ req: mockReq, + appConfig: mockAppConfig, getFiles: mockGetFiles, requestFileSet, attachments, @@ -906,6 +929,7 @@ describe('primeResources', () => { // The function should now handle rejected attachment promises gracefully const result = await primeResources({ req: mockReq, + appConfig: mockAppConfig, getFiles: mockGetFiles, requestFileSet, attachments, @@ -926,11 +950,13 @@ describe('primeResources', () => { }); describe('edge cases', () => { - it('should handle missing app.locals gracefully', async () => { - const reqWithoutLocals = {} as ServerRequest; + it('should handle missing appConfig agents endpoint gracefully', async () => { + const reqWithoutLocals = {} as ServerRequest & { user?: IUser }; + const emptyAppConfig = {} as AppConfig; const result = await primeResources({ req: reqWithoutLocals, + appConfig: emptyAppConfig, getFiles: mockGetFiles, requestFileSet, attachments: undefined, @@ -942,14 +968,15 @@ describe('primeResources', () => { }); expect(mockGetFiles).not.toHaveBeenCalled(); - // When app.locals is missing and there's an error accessing properties, - // the function falls back to the catch block which returns an empty array - expect(result.attachments).toEqual([]); + // When appConfig agents endpoint is missing, OCR is disabled + // and no attachments are provided, the function returns undefined + expect(result.attachments).toBeUndefined(); }); it('should handle undefined tool_resources', async () => { const result = await primeResources({ req: mockReq, + appConfig: mockAppConfig, getFiles: mockGetFiles, requestFileSet, attachments: undefined, @@ -982,6 +1009,7 @@ describe('primeResources', () => { const result = await primeResources({ req: mockReq, + appConfig: mockAppConfig, getFiles: mockGetFiles, requestFileSet: emptyRequestFileSet, attachments, diff --git a/packages/api/src/agents/resources.ts b/packages/api/src/agents/resources.ts index ac33291d2..e0ad1443b 100644 --- a/packages/api/src/agents/resources.ts +++ b/packages/api/src/agents/resources.ts @@ -4,6 +4,7 @@ import type { AgentToolResources, TFile, AgentBaseResource } from 'librechat-dat import type { FilterQuery, QueryOptions, ProjectionType } from 'mongoose'; import type { IMongoFile, IUser } from '@librechat/data-schemas'; import type { Request as ServerRequest } from 'express'; +import type { AppConfig } from '~/types/'; /** * Function type for retrieving files from the database @@ -134,7 +135,8 @@ const categorizeFileForToolResources = ({ * 4. Prevents duplicate files across all sources * * @param params - Parameters object - * @param params.req - Express request object containing app configuration + * @param params.req - Express request object + * @param params.appConfig - Application configuration object * @param params.getFiles - Function to retrieve files from database * @param params.requestFileSet - Set of file IDs from the current request * @param params.attachments - Promise resolving to array of attachment files @@ -143,6 +145,7 @@ const categorizeFileForToolResources = ({ */ export const primeResources = async ({ req, + appConfig, getFiles, requestFileSet, attachments: _attachments, @@ -150,6 +153,7 @@ export const primeResources = async ({ agentId, }: { req: ServerRequest & { user?: IUser }; + appConfig: AppConfig; requestFileSet: Set; attachments: Promise> | undefined; tool_resources: AgentToolResources | undefined; @@ -198,9 +202,9 @@ export const primeResources = async ({ } } - const isOCREnabled = (req.app.locals?.[EModelEndpoint.agents]?.capabilities ?? []).includes( - AgentCapabilities.ocr, - ); + const isOCREnabled = ( + appConfig?.endpoints?.[EModelEndpoint.agents]?.capabilities ?? [] + ).includes(AgentCapabilities.ocr); if (tool_resources[EToolResources.ocr]?.file_ids && isOCREnabled) { const context = await getFiles( diff --git a/packages/api/src/app/config.ts b/packages/api/src/app/config.ts new file mode 100644 index 000000000..d7307a1b3 --- /dev/null +++ b/packages/api/src/app/config.ts @@ -0,0 +1,43 @@ +import { EModelEndpoint, removeNullishValues } from 'librechat-data-provider'; +import type { TCustomConfig, TEndpoint } from 'librechat-data-provider'; +import type { AppConfig } from '~/types'; +import { isEnabled, normalizeEndpointName } from '~/utils'; + +/** + * Retrieves the balance configuration object + * */ +export function getBalanceConfig(appConfig?: AppConfig): Partial | null { + const isLegacyEnabled = isEnabled(process.env.CHECK_BALANCE); + const startBalance = process.env.START_BALANCE; + /** @type {} */ + const config: Partial = removeNullishValues({ + enabled: isLegacyEnabled, + startBalance: startBalance != null && startBalance ? parseInt(startBalance, 10) : undefined, + }); + if (!appConfig) { + return config; + } + return { ...config, ...(appConfig?.['balance'] ?? {}) }; +} + +export const getCustomEndpointConfig = ({ + endpoint, + appConfig, +}: { + endpoint: string | EModelEndpoint; + appConfig?: AppConfig; +}): Partial | undefined => { + if (!appConfig) { + throw new Error(`Config not found for the ${endpoint} custom endpoint.`); + } + + const customEndpoints = appConfig.endpoints?.[EModelEndpoint.custom] ?? []; + return customEndpoints.find( + (endpointConfig) => normalizeEndpointName(endpointConfig.name) === endpoint, + ); +}; + +export function hasCustomUserVars(appConfig?: AppConfig): boolean { + const mcpServers = appConfig?.mcpConfig; + return Object.values(mcpServers ?? {}).some((server) => server.customUserVars); +} diff --git a/packages/api/src/app/index.ts b/packages/api/src/app/index.ts new file mode 100644 index 000000000..74ae27abf --- /dev/null +++ b/packages/api/src/app/index.ts @@ -0,0 +1,3 @@ +export * from './config'; +export * from './interface'; +export * from './permissions'; diff --git a/packages/api/src/app/interface.ts b/packages/api/src/app/interface.ts new file mode 100644 index 000000000..3a03d0943 --- /dev/null +++ b/packages/api/src/app/interface.ts @@ -0,0 +1,108 @@ +import { logger } from '@librechat/data-schemas'; +import { removeNullishValues } from 'librechat-data-provider'; +import type { TCustomConfig, TConfigDefaults } from 'librechat-data-provider'; +import type { AppConfig } from '~/types/config'; +import { isMemoryEnabled } from '~/memory/config'; + +/** + * Loads the default interface object. + * @param params - The loaded custom configuration. + * @param params.config - The loaded custom configuration. + * @param params.configDefaults - The custom configuration default values. + * @returns default interface object. + */ +export async function loadDefaultInterface({ + config, + configDefaults, +}: { + config?: Partial; + configDefaults: TConfigDefaults; +}): Promise { + const { interface: interfaceConfig } = config ?? {}; + const { interface: defaults } = configDefaults; + const hasModelSpecs = (config?.modelSpecs?.list?.length ?? 0) > 0; + const includesAddedEndpoints = (config?.modelSpecs?.addedEndpoints?.length ?? 0) > 0; + + const memoryConfig = config?.memory; + const memoryEnabled = isMemoryEnabled(memoryConfig); + /** Only disable memories if memory config is present but disabled/invalid */ + const shouldDisableMemories = memoryConfig && !memoryEnabled; + + const loadedInterface: AppConfig['interfaceConfig'] = removeNullishValues({ + // UI elements - use schema defaults + endpointsMenu: + interfaceConfig?.endpointsMenu ?? (hasModelSpecs ? false : defaults.endpointsMenu), + modelSelect: + interfaceConfig?.modelSelect ?? + (hasModelSpecs ? includesAddedEndpoints : defaults.modelSelect), + parameters: interfaceConfig?.parameters ?? (hasModelSpecs ? false : defaults.parameters), + presets: interfaceConfig?.presets ?? (hasModelSpecs ? false : defaults.presets), + sidePanel: interfaceConfig?.sidePanel ?? defaults.sidePanel, + privacyPolicy: interfaceConfig?.privacyPolicy ?? defaults.privacyPolicy, + termsOfService: interfaceConfig?.termsOfService ?? defaults.termsOfService, + mcpServers: interfaceConfig?.mcpServers ?? defaults.mcpServers, + customWelcome: interfaceConfig?.customWelcome ?? defaults.customWelcome, + + // Permissions - only include if explicitly configured + bookmarks: interfaceConfig?.bookmarks, + memories: shouldDisableMemories ? false : interfaceConfig?.memories, + prompts: interfaceConfig?.prompts, + multiConvo: interfaceConfig?.multiConvo, + agents: interfaceConfig?.agents, + temporaryChat: interfaceConfig?.temporaryChat, + runCode: interfaceConfig?.runCode, + webSearch: interfaceConfig?.webSearch, + fileSearch: interfaceConfig?.fileSearch, + fileCitations: interfaceConfig?.fileCitations, + peoplePicker: interfaceConfig?.peoplePicker, + marketplace: interfaceConfig?.marketplace, + }); + + let i = 0; + const logSettings = () => { + // log interface object and model specs object (without list) for reference + logger.warn(`\`interface\` settings:\n${JSON.stringify(loadedInterface, null, 2)}`); + logger.warn( + `\`modelSpecs\` settings:\n${JSON.stringify( + { ...(config?.modelSpecs ?? {}), list: undefined }, + null, + 2, + )}`, + ); + }; + + // warn about config.modelSpecs.prioritize if true and presets are enabled, that default presets will conflict with prioritizing model specs. + if (config?.modelSpecs?.prioritize && loadedInterface.presets) { + logger.warn( + "Note: Prioritizing model specs can conflict with default presets if a default preset is set. It's recommended to disable presets from the interface or disable use of a default preset.", + ); + if (i === 0) i++; + } + + // warn about config.modelSpecs.enforce if true and if any of these, endpointsMenu, modelSelect, presets, or parameters are enabled, that enforcing model specs can conflict with these options. + if ( + config?.modelSpecs?.enforce && + (loadedInterface.endpointsMenu || + loadedInterface.modelSelect || + loadedInterface.presets || + loadedInterface.parameters) + ) { + logger.warn( + "Note: Enforcing model specs can conflict with the interface options: endpointsMenu, modelSelect, presets, and parameters. It's recommended to disable these options from the interface or disable enforcing model specs.", + ); + if (i === 0) i++; + } + // warn if enforce is true and prioritize is not, that enforcing model specs without prioritizing them can lead to unexpected behavior. + if (config?.modelSpecs?.enforce && !config?.modelSpecs?.prioritize) { + logger.warn( + "Note: Enforcing model specs without prioritizing them can lead to unexpected behavior. It's recommended to enable prioritizing model specs if enforcing them.", + ); + if (i === 0) i++; + } + + if (i > 0) { + logSettings(); + } + + return loadedInterface; +} diff --git a/api/server/services/start/interface.spec.js b/packages/api/src/app/permissions.spec.ts similarity index 79% rename from api/server/services/start/interface.spec.js rename to packages/api/src/app/permissions.spec.ts index 601e955c3..12b121d40 100644 --- a/api/server/services/start/interface.spec.js +++ b/packages/api/src/app/permissions.spec.ts @@ -1,27 +1,21 @@ -const { - SystemRoles, - Permissions, - PermissionTypes, - roleDefaults, -} = require('librechat-data-provider'); -const { updateAccessPermissions, getRoleByName } = require('~/models/Role'); -const { loadDefaultInterface } = require('./interface'); +import { SystemRoles, Permissions, PermissionTypes, roleDefaults } from 'librechat-data-provider'; +import type { TConfigDefaults, TCustomConfig } from 'librechat-data-provider'; +import type { AppConfig } from '~/types/config'; +import { updateInterfacePermissions } from './permissions'; +import { loadDefaultInterface } from './interface'; -jest.mock('~/models/Role', () => ({ - updateAccessPermissions: jest.fn(), - getRoleByName: jest.fn(), -})); +const mockUpdateAccessPermissions = jest.fn(); +const mockGetRoleByName = jest.fn(); -jest.mock('@librechat/api', () => ({ - ...jest.requireActual('@librechat/api'), +jest.mock('~/memory', () => ({ isMemoryEnabled: jest.fn((config) => config?.enable === true), })); -describe('loadDefaultInterface', () => { +describe('updateInterfacePermissions - permissions', () => { beforeEach(() => { jest.clearAllMocks(); // Mock getRoleByName to return null (no existing permissions) - getRoleByName.mockResolvedValue(null); + mockGetRoleByName.mockResolvedValue(null); }); it('should call updateAccessPermissions with the correct parameters when permission types are true', async () => { @@ -47,9 +41,15 @@ describe('loadDefaultInterface', () => { }, }, }; - const configDefaults = { interface: {} }; + const configDefaults = { interface: {} } as TConfigDefaults; + const interfaceConfig = await loadDefaultInterface({ config, configDefaults }); + const appConfig = { config, interfaceConfig } as unknown as AppConfig; - await loadDefaultInterface(config, configDefaults); + await updateInterfacePermissions({ + appConfig, + getRoleByName: mockGetRoleByName, + updateAccessPermissions: mockUpdateAccessPermissions, + }); const expectedPermissionsForUser = { [PermissionTypes.PROMPTS]: { @@ -103,17 +103,17 @@ describe('loadDefaultInterface', () => { [PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: true }, }; - expect(updateAccessPermissions).toHaveBeenCalledTimes(2); + expect(mockUpdateAccessPermissions).toHaveBeenCalledTimes(2); // Check USER role call - expect(updateAccessPermissions).toHaveBeenCalledWith( + expect(mockUpdateAccessPermissions).toHaveBeenCalledWith( SystemRoles.USER, expectedPermissionsForUser, null, ); // Check ADMIN role call - expect(updateAccessPermissions).toHaveBeenCalledWith( + expect(mockUpdateAccessPermissions).toHaveBeenCalledWith( SystemRoles.ADMIN, expectedPermissionsForAdmin, null, @@ -143,9 +143,15 @@ describe('loadDefaultInterface', () => { }, }, }; - const configDefaults = { interface: {} }; + const configDefaults = { interface: {} } as TConfigDefaults; + const interfaceConfig = await loadDefaultInterface({ config, configDefaults }); + const appConfig = { config, interfaceConfig } as unknown as AppConfig; - await loadDefaultInterface(config, configDefaults); + await updateInterfacePermissions({ + appConfig, + getRoleByName: mockGetRoleByName, + updateAccessPermissions: mockUpdateAccessPermissions, + }); const expectedPermissionsForUser = { [PermissionTypes.PROMPTS]: { @@ -199,17 +205,17 @@ describe('loadDefaultInterface', () => { [PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: false }, }; - expect(updateAccessPermissions).toHaveBeenCalledTimes(2); + expect(mockUpdateAccessPermissions).toHaveBeenCalledTimes(2); // Check USER role call - expect(updateAccessPermissions).toHaveBeenCalledWith( + expect(mockUpdateAccessPermissions).toHaveBeenCalledWith( SystemRoles.USER, expectedPermissionsForUser, null, ); // Check ADMIN role call - expect(updateAccessPermissions).toHaveBeenCalledWith( + expect(mockUpdateAccessPermissions).toHaveBeenCalledWith( SystemRoles.ADMIN, expectedPermissionsForAdmin, null, @@ -239,9 +245,15 @@ describe('loadDefaultInterface', () => { use: false, }, }, - }; + } as TConfigDefaults; + const interfaceConfig = await loadDefaultInterface({ config, configDefaults }); + const appConfig = { config, interfaceConfig } as unknown as AppConfig; - await loadDefaultInterface(config, configDefaults); + await updateInterfacePermissions({ + appConfig, + getRoleByName: mockGetRoleByName, + updateAccessPermissions: mockUpdateAccessPermissions, + }); const expectedPermissionsForUser = { [PermissionTypes.PROMPTS]: { @@ -295,17 +307,17 @@ describe('loadDefaultInterface', () => { [PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: true }, }; - expect(updateAccessPermissions).toHaveBeenCalledTimes(2); + expect(mockUpdateAccessPermissions).toHaveBeenCalledTimes(2); // Check USER role call - expect(updateAccessPermissions).toHaveBeenCalledWith( + expect(mockUpdateAccessPermissions).toHaveBeenCalledWith( SystemRoles.USER, expectedPermissionsForUser, null, ); // Check ADMIN role call - expect(updateAccessPermissions).toHaveBeenCalledWith( + expect(mockUpdateAccessPermissions).toHaveBeenCalledWith( SystemRoles.ADMIN, expectedPermissionsForAdmin, null, @@ -348,9 +360,15 @@ describe('loadDefaultInterface', () => { use: false, }, }, - }; + } as TConfigDefaults; + const interfaceConfig = await loadDefaultInterface({ config, configDefaults }); + const appConfig = { config, interfaceConfig } as unknown as AppConfig; - await loadDefaultInterface(config, configDefaults); + await updateInterfacePermissions({ + appConfig, + getRoleByName: mockGetRoleByName, + updateAccessPermissions: mockUpdateAccessPermissions, + }); const expectedPermissionsForUser = { [PermissionTypes.PROMPTS]: { @@ -404,17 +422,17 @@ describe('loadDefaultInterface', () => { [PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: true }, }; - expect(updateAccessPermissions).toHaveBeenCalledTimes(2); + expect(mockUpdateAccessPermissions).toHaveBeenCalledTimes(2); // Check USER role call - expect(updateAccessPermissions).toHaveBeenCalledWith( + expect(mockUpdateAccessPermissions).toHaveBeenCalledWith( SystemRoles.USER, expectedPermissionsForUser, null, ); // Check ADMIN role call - expect(updateAccessPermissions).toHaveBeenCalledWith( + expect(mockUpdateAccessPermissions).toHaveBeenCalledWith( SystemRoles.ADMIN, expectedPermissionsForAdmin, null, @@ -444,9 +462,15 @@ describe('loadDefaultInterface', () => { use: false, }, }, - }; + } as TConfigDefaults; + const interfaceConfig = await loadDefaultInterface({ config, configDefaults }); + const appConfig = { interfaceConfig } as unknown as AppConfig; - await loadDefaultInterface(config, configDefaults); + await updateInterfacePermissions({ + appConfig, + getRoleByName: mockGetRoleByName, + updateAccessPermissions: mockUpdateAccessPermissions, + }); const expectedPermissionsForUser = { [PermissionTypes.PROMPTS]: { @@ -500,17 +524,17 @@ describe('loadDefaultInterface', () => { [PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: true }, }; - expect(updateAccessPermissions).toHaveBeenCalledTimes(2); + expect(mockUpdateAccessPermissions).toHaveBeenCalledTimes(2); // Check USER role call - expect(updateAccessPermissions).toHaveBeenCalledWith( + expect(mockUpdateAccessPermissions).toHaveBeenCalledWith( SystemRoles.USER, expectedPermissionsForUser, null, ); // Check ADMIN role call - expect(updateAccessPermissions).toHaveBeenCalledWith( + expect(mockUpdateAccessPermissions).toHaveBeenCalledWith( SystemRoles.ADMIN, expectedPermissionsForAdmin, null, @@ -519,7 +543,7 @@ describe('loadDefaultInterface', () => { it('should only update permissions that do not exist when no config provided', async () => { // Mock that some permissions already exist - getRoleByName.mockResolvedValue({ + mockGetRoleByName.mockResolvedValue({ permissions: { [PermissionTypes.PROMPTS]: { [Permissions.USE]: false }, [PermissionTypes.AGENTS]: { [Permissions.USE]: true }, @@ -548,9 +572,15 @@ describe('loadDefaultInterface', () => { use: false, }, }, - }; + } as TConfigDefaults; + const interfaceConfig = await loadDefaultInterface({ config, configDefaults }); + const appConfig = { interfaceConfig } as unknown as AppConfig; - await loadDefaultInterface(config, configDefaults); + await updateInterfacePermissions({ + appConfig, + getRoleByName: mockGetRoleByName, + updateAccessPermissions: mockUpdateAccessPermissions, + }); // Should be called with all permissions EXCEPT prompts and agents (which already exist) const expectedPermissionsForUser = { @@ -593,8 +623,8 @@ describe('loadDefaultInterface', () => { [PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: true }, }; - expect(updateAccessPermissions).toHaveBeenCalledTimes(2); - expect(updateAccessPermissions).toHaveBeenCalledWith( + expect(mockUpdateAccessPermissions).toHaveBeenCalledTimes(2); + expect(mockUpdateAccessPermissions).toHaveBeenCalledWith( SystemRoles.USER, expectedPermissionsForUser, expect.objectContaining({ @@ -604,7 +634,7 @@ describe('loadDefaultInterface', () => { }, }), ); - expect(updateAccessPermissions).toHaveBeenCalledWith( + expect(mockUpdateAccessPermissions).toHaveBeenCalledWith( SystemRoles.ADMIN, expectedPermissionsForAdmin, expect.objectContaining({ @@ -618,7 +648,7 @@ describe('loadDefaultInterface', () => { it('should override existing permissions when explicitly configured', async () => { // Mock that some permissions already exist - getRoleByName.mockResolvedValue({ + mockGetRoleByName.mockResolvedValue({ permissions: { [PermissionTypes.PROMPTS]: { [Permissions.USE]: false }, [PermissionTypes.AGENTS]: { [Permissions.USE]: false }, @@ -654,9 +684,15 @@ describe('loadDefaultInterface', () => { use: false, }, }, - }; + } as TConfigDefaults; + const interfaceConfig = await loadDefaultInterface({ config, configDefaults }); + const appConfig = { config, interfaceConfig } as unknown as AppConfig; - await loadDefaultInterface(config, configDefaults); + await updateInterfacePermissions({ + appConfig, + getRoleByName: mockGetRoleByName, + updateAccessPermissions: mockUpdateAccessPermissions, + }); // Should update prompts (explicitly configured) and all other permissions that don't exist const expectedPermissionsForUser = { @@ -705,15 +741,15 @@ describe('loadDefaultInterface', () => { [PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: true }, }; - expect(updateAccessPermissions).toHaveBeenCalledTimes(2); - expect(updateAccessPermissions).toHaveBeenCalledWith( + expect(mockUpdateAccessPermissions).toHaveBeenCalledTimes(2); + expect(mockUpdateAccessPermissions).toHaveBeenCalledWith( SystemRoles.USER, expectedPermissionsForUser, expect.objectContaining({ permissions: expect.any(Object), }), ); - expect(updateAccessPermissions).toHaveBeenCalledWith( + expect(mockUpdateAccessPermissions).toHaveBeenCalledWith( SystemRoles.ADMIN, expectedPermissionsForAdmin, expect.objectContaining({ @@ -722,6 +758,41 @@ describe('loadDefaultInterface', () => { ); }); + it('should handle memories OPT_OUT based on personalization when memories are enabled', async () => { + const config = { + interface: { + memories: true, + }, + memory: { + // Memory enabled with personalization + agent: { + id: 'test-agent-id', + }, + personalize: true, + } as unknown as TCustomConfig['memory'], + }; + const configDefaults = { interface: {} } as TConfigDefaults; + const interfaceConfig = await loadDefaultInterface({ config, configDefaults }); + const appConfig = { config, interfaceConfig } as unknown as AppConfig; + + await updateInterfacePermissions({ + appConfig, + getRoleByName: mockGetRoleByName, + updateAccessPermissions: mockUpdateAccessPermissions, + }); + + const userCall = mockUpdateAccessPermissions.mock.calls.find( + (call) => call[0] === SystemRoles.USER, + ); + const adminCall = mockUpdateAccessPermissions.mock.calls.find( + (call) => call[0] === SystemRoles.ADMIN, + ); + + // Both roles should have OPT_OUT set to true when personalization is enabled + expect(userCall[1][PermissionTypes.MEMORIES][Permissions.OPT_OUT]).toBe(true); + expect(adminCall[1][PermissionTypes.MEMORIES][Permissions.OPT_OUT]).toBe(true); + }); + it('should use role-specific defaults for PEOPLE_PICKER when peoplePicker config is undefined', async () => { const config = { interface: { @@ -730,17 +801,23 @@ describe('loadDefaultInterface', () => { bookmarks: true, }, }; - const configDefaults = { interface: {} }; + const configDefaults = { interface: {} } as TConfigDefaults; + const interfaceConfig = await loadDefaultInterface({ config, configDefaults }); + const appConfig = { config, interfaceConfig } as unknown as AppConfig; - await loadDefaultInterface(config, configDefaults); + await updateInterfacePermissions({ + appConfig, + getRoleByName: mockGetRoleByName, + updateAccessPermissions: mockUpdateAccessPermissions, + }); - expect(updateAccessPermissions).toHaveBeenCalledTimes(2); + expect(mockUpdateAccessPermissions).toHaveBeenCalledTimes(2); // Get the calls to updateAccessPermissions - const userCall = updateAccessPermissions.mock.calls.find( + const userCall = mockUpdateAccessPermissions.mock.calls.find( (call) => call[0] === SystemRoles.USER, ); - const adminCall = updateAccessPermissions.mock.calls.find( + const adminCall = mockUpdateAccessPermissions.mock.calls.find( (call) => call[0] === SystemRoles.ADMIN, ); @@ -759,6 +836,29 @@ describe('loadDefaultInterface', () => { }); }); + it('should only call getRoleByName once per role for efficiency', async () => { + const config = { + interface: { + prompts: true, + bookmarks: true, + }, + }; + const configDefaults = { interface: {} } as TConfigDefaults; + const interfaceConfig = await loadDefaultInterface({ config, configDefaults }); + const appConfig = { config, interfaceConfig } as unknown as AppConfig; + + await updateInterfacePermissions({ + appConfig, + getRoleByName: mockGetRoleByName, + updateAccessPermissions: mockUpdateAccessPermissions, + }); + + // Should call getRoleByName exactly twice (once for USER, once for ADMIN) + expect(mockGetRoleByName).toHaveBeenCalledTimes(2); + expect(mockGetRoleByName).toHaveBeenCalledWith(SystemRoles.USER); + expect(mockGetRoleByName).toHaveBeenCalledWith(SystemRoles.ADMIN); + }); + it('should use role-specific defaults for complex permissions when not configured', async () => { const config = { interface: { @@ -788,14 +888,20 @@ describe('loadDefaultInterface', () => { use: false, }, }, - }; + } as TConfigDefaults; + const interfaceConfig = await loadDefaultInterface({ config, configDefaults }); + const appConfig = { config, interfaceConfig } as unknown as AppConfig; - await loadDefaultInterface(config, configDefaults); + await updateInterfacePermissions({ + appConfig, + getRoleByName: mockGetRoleByName, + updateAccessPermissions: mockUpdateAccessPermissions, + }); - const userCall = updateAccessPermissions.mock.calls.find( + const userCall = mockUpdateAccessPermissions.mock.calls.find( (call) => call[0] === SystemRoles.USER, ); - const adminCall = updateAccessPermissions.mock.calls.find( + const adminCall = mockUpdateAccessPermissions.mock.calls.find( (call) => call[0] === SystemRoles.ADMIN, ); @@ -829,36 +935,9 @@ describe('loadDefaultInterface', () => { }); }); - it('should handle memories OPT_OUT based on personalization when memories are enabled', async () => { - const config = { - interface: { - memories: true, - }, - memory: { - // Memory enabled with personalization - enable: true, - personalize: true, - }, - }; - const configDefaults = { interface: {} }; - - await loadDefaultInterface(config, configDefaults); - - const userCall = updateAccessPermissions.mock.calls.find( - (call) => call[0] === SystemRoles.USER, - ); - const adminCall = updateAccessPermissions.mock.calls.find( - (call) => call[0] === SystemRoles.ADMIN, - ); - - // Both roles should have OPT_OUT set to true when personalization is enabled - expect(userCall[1][PermissionTypes.MEMORIES][Permissions.OPT_OUT]).toBe(true); - expect(adminCall[1][PermissionTypes.MEMORIES][Permissions.OPT_OUT]).toBe(true); - }); - it('should populate missing PEOPLE_PICKER and MARKETPLACE permissions with role-specific defaults', async () => { // Mock that PEOPLE_PICKER and MARKETPLACE permissions don't exist yet - getRoleByName.mockResolvedValue({ + mockGetRoleByName.mockResolvedValue({ permissions: { [PermissionTypes.PROMPTS]: { [Permissions.USE]: true }, [PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true }, @@ -885,14 +964,20 @@ describe('loadDefaultInterface', () => { use: false, }, }, - }; + } as TConfigDefaults; + const interfaceConfig = await loadDefaultInterface({ config, configDefaults }); + const appConfig = { config, interfaceConfig } as unknown as AppConfig; - await loadDefaultInterface(config, configDefaults); + await updateInterfacePermissions({ + appConfig, + getRoleByName: mockGetRoleByName, + updateAccessPermissions: mockUpdateAccessPermissions, + }); - const userCall = updateAccessPermissions.mock.calls.find( + const userCall = mockUpdateAccessPermissions.mock.calls.find( (call) => call[0] === SystemRoles.USER, ); - const adminCall = updateAccessPermissions.mock.calls.find( + const adminCall = mockUpdateAccessPermissions.mock.calls.find( (call) => call[0] === SystemRoles.ADMIN, ); @@ -953,7 +1038,7 @@ describe('loadDefaultInterface', () => { [PermissionTypes.MARKETPLACE]: { [Permissions.USE]: true }, }; - getRoleByName.mockResolvedValue({ + mockGetRoleByName.mockResolvedValue({ permissions: existingUserPermissions, }); @@ -973,12 +1058,18 @@ describe('loadDefaultInterface', () => { use: false, }, }, - }; + } as TConfigDefaults; + const interfaceConfig = await loadDefaultInterface({ config, configDefaults }); + const appConfig = { interfaceConfig } as unknown as AppConfig; - await loadDefaultInterface(config, configDefaults); + await updateInterfacePermissions({ + appConfig, + getRoleByName: mockGetRoleByName, + updateAccessPermissions: mockUpdateAccessPermissions, + }); // Should only update permissions that don't exist - const userCall = updateAccessPermissions.mock.calls.find( + const userCall = mockUpdateAccessPermissions.mock.calls.find( (call) => call[0] === SystemRoles.USER, ); @@ -994,26 +1085,9 @@ describe('loadDefaultInterface', () => { expect(userCall[1]).toHaveProperty(PermissionTypes.AGENTS); }); - it('should only call getRoleByName once per role for efficiency', async () => { - const config = { - interface: { - prompts: true, - bookmarks: true, - }, - }; - const configDefaults = { interface: {} }; - - await loadDefaultInterface(config, configDefaults); - - // Should call getRoleByName exactly twice (once for USER, once for ADMIN) - expect(getRoleByName).toHaveBeenCalledTimes(2); - expect(getRoleByName).toHaveBeenCalledWith(SystemRoles.USER); - expect(getRoleByName).toHaveBeenCalledWith(SystemRoles.ADMIN); - }); - it('should only update explicitly configured permissions and leave others unchanged', async () => { // Mock existing permissions - getRoleByName.mockResolvedValue({ + mockGetRoleByName.mockResolvedValue({ permissions: { [PermissionTypes.PROMPTS]: { [Permissions.USE]: false }, [PermissionTypes.BOOKMARKS]: { [Permissions.USE]: false }, @@ -1053,11 +1127,17 @@ describe('loadDefaultInterface', () => { use: false, }, }, - }; + } as TConfigDefaults; + const interfaceConfig = await loadDefaultInterface({ config, configDefaults }); + const appConfig = { config, interfaceConfig } as unknown as AppConfig; - await loadDefaultInterface(config, configDefaults); + await updateInterfacePermissions({ + appConfig, + getRoleByName: mockGetRoleByName, + updateAccessPermissions: mockUpdateAccessPermissions, + }); - const userCall = updateAccessPermissions.mock.calls.find( + const userCall = mockUpdateAccessPermissions.mock.calls.find( (call) => call[0] === SystemRoles.USER, ); diff --git a/packages/api/src/app/permissions.ts b/packages/api/src/app/permissions.ts new file mode 100644 index 000000000..29ae81ba9 --- /dev/null +++ b/packages/api/src/app/permissions.ts @@ -0,0 +1,234 @@ +import { logger } from '@librechat/data-schemas'; +import { + SystemRoles, + Permissions, + roleDefaults, + PermissionTypes, + getConfigDefaults, +} from 'librechat-data-provider'; +import type { IRole } from '@librechat/data-schemas'; +import type { AppConfig } from '~/types/config'; +import { isMemoryEnabled } from '~/memory/config'; + +/** + * Checks if a permission type has explicit configuration + */ +function hasExplicitConfig( + interfaceConfig: AppConfig['interfaceConfig'], + permissionType: PermissionTypes, +) { + switch (permissionType) { + case PermissionTypes.PROMPTS: + return interfaceConfig?.prompts !== undefined; + case PermissionTypes.BOOKMARKS: + return interfaceConfig?.bookmarks !== undefined; + case PermissionTypes.MEMORIES: + return interfaceConfig?.memories !== undefined; + case PermissionTypes.MULTI_CONVO: + return interfaceConfig?.multiConvo !== undefined; + case PermissionTypes.AGENTS: + return interfaceConfig?.agents !== undefined; + case PermissionTypes.TEMPORARY_CHAT: + return interfaceConfig?.temporaryChat !== undefined; + case PermissionTypes.RUN_CODE: + return interfaceConfig?.runCode !== undefined; + case PermissionTypes.WEB_SEARCH: + return interfaceConfig?.webSearch !== undefined; + case PermissionTypes.PEOPLE_PICKER: + return interfaceConfig?.peoplePicker !== undefined; + case PermissionTypes.MARKETPLACE: + return interfaceConfig?.marketplace !== undefined; + case PermissionTypes.FILE_SEARCH: + return interfaceConfig?.fileSearch !== undefined; + case PermissionTypes.FILE_CITATIONS: + return interfaceConfig?.fileCitations !== undefined; + default: + return false; + } +} + +export async function updateInterfacePermissions({ + appConfig, + getRoleByName, + updateAccessPermissions, +}: { + appConfig: AppConfig; + getRoleByName: (roleName: string, fieldsToSelect?: string | string[]) => Promise; + updateAccessPermissions: ( + roleName: string, + permissionsUpdate: Partial>>, + + roleData?: IRole | null, + ) => Promise; +}) { + const loadedInterface = appConfig?.interfaceConfig; + if (!loadedInterface) { + return; + } + /** Configured values for interface object structure */ + const interfaceConfig = appConfig?.config?.interface; + const memoryConfig = appConfig?.config?.memory; + const memoryEnabled = isMemoryEnabled(memoryConfig); + /** Check if personalization is enabled (defaults to true if memory is configured and enabled) */ + const isPersonalizationEnabled = + memoryConfig && memoryEnabled && memoryConfig.personalize !== false; + + /** Helper to get permission value with proper precedence */ + const getPermissionValue = ( + configValue?: boolean, + roleDefault?: boolean, + schemaDefault?: boolean, + ) => { + if (configValue !== undefined) return configValue; + if (roleDefault !== undefined) return roleDefault; + return schemaDefault; + }; + + const defaults = getConfigDefaults().interface; + + // Permission precedence order: + // 1. Explicit user configuration (from librechat.yaml) + // 2. Role-specific defaults (from roleDefaults) + // 3. Interface schema defaults (from interfaceSchema.default()) + for (const roleName of [SystemRoles.USER, SystemRoles.ADMIN]) { + const defaultPerms = roleDefaults[roleName]?.permissions; + + const existingRole = await getRoleByName(roleName); + const existingPermissions = existingRole?.permissions; + const permissionsToUpdate: Partial< + Record> + > = {}; + + /** + * Helper to add permission if it should be updated + */ + const addPermissionIfNeeded = ( + permType: PermissionTypes, + permissions: Record, + ) => { + const permTypeExists = existingPermissions?.[permType]; + const isExplicitlyConfigured = + interfaceConfig && hasExplicitConfig(interfaceConfig, permType); + + // Only update if: doesn't exist OR explicitly configured + if (!permTypeExists || isExplicitlyConfigured) { + permissionsToUpdate[permType] = permissions; + if (!permTypeExists) { + logger.debug(`Role '${roleName}': Setting up default permissions for '${permType}'`); + } else if (isExplicitlyConfigured) { + logger.debug(`Role '${roleName}': Applying explicit config for '${permType}'`); + } + } else { + logger.debug(`Role '${roleName}': Preserving existing permissions for '${permType}'`); + } + }; + + const allPermissions: Partial>> = { + [PermissionTypes.PROMPTS]: { + [Permissions.USE]: getPermissionValue( + loadedInterface.prompts, + defaultPerms[PermissionTypes.PROMPTS]?.[Permissions.USE], + defaults.prompts, + ), + }, + [PermissionTypes.BOOKMARKS]: { + [Permissions.USE]: getPermissionValue( + loadedInterface.bookmarks, + defaultPerms[PermissionTypes.BOOKMARKS]?.[Permissions.USE], + defaults.bookmarks, + ), + }, + [PermissionTypes.MEMORIES]: { + [Permissions.USE]: getPermissionValue( + loadedInterface.memories, + defaultPerms[PermissionTypes.MEMORIES]?.[Permissions.USE], + defaults.memories, + ), + [Permissions.OPT_OUT]: isPersonalizationEnabled, + }, + [PermissionTypes.MULTI_CONVO]: { + [Permissions.USE]: getPermissionValue( + loadedInterface.multiConvo, + defaultPerms[PermissionTypes.MULTI_CONVO]?.[Permissions.USE], + defaults.multiConvo, + ), + }, + [PermissionTypes.AGENTS]: { + [Permissions.USE]: getPermissionValue( + loadedInterface.agents, + defaultPerms[PermissionTypes.AGENTS]?.[Permissions.USE], + defaults.agents, + ), + }, + [PermissionTypes.TEMPORARY_CHAT]: { + [Permissions.USE]: getPermissionValue( + loadedInterface.temporaryChat, + defaultPerms[PermissionTypes.TEMPORARY_CHAT]?.[Permissions.USE], + defaults.temporaryChat, + ), + }, + [PermissionTypes.RUN_CODE]: { + [Permissions.USE]: getPermissionValue( + loadedInterface.runCode, + defaultPerms[PermissionTypes.RUN_CODE]?.[Permissions.USE], + defaults.runCode, + ), + }, + [PermissionTypes.WEB_SEARCH]: { + [Permissions.USE]: getPermissionValue( + loadedInterface.webSearch, + defaultPerms[PermissionTypes.WEB_SEARCH]?.[Permissions.USE], + defaults.webSearch, + ), + }, + [PermissionTypes.PEOPLE_PICKER]: { + [Permissions.VIEW_USERS]: getPermissionValue( + loadedInterface.peoplePicker?.users, + defaultPerms[PermissionTypes.PEOPLE_PICKER]?.[Permissions.VIEW_USERS], + defaults.peoplePicker?.users, + ), + [Permissions.VIEW_GROUPS]: getPermissionValue( + loadedInterface.peoplePicker?.groups, + defaultPerms[PermissionTypes.PEOPLE_PICKER]?.[Permissions.VIEW_GROUPS], + defaults.peoplePicker?.groups, + ), + [Permissions.VIEW_ROLES]: getPermissionValue( + loadedInterface.peoplePicker?.roles, + defaultPerms[PermissionTypes.PEOPLE_PICKER]?.[Permissions.VIEW_ROLES], + defaults.peoplePicker?.roles, + ), + }, + [PermissionTypes.MARKETPLACE]: { + [Permissions.USE]: getPermissionValue( + loadedInterface.marketplace?.use, + defaultPerms[PermissionTypes.MARKETPLACE]?.[Permissions.USE], + defaults.marketplace?.use, + ), + }, + [PermissionTypes.FILE_SEARCH]: { + [Permissions.USE]: getPermissionValue( + loadedInterface.fileSearch, + defaultPerms[PermissionTypes.FILE_SEARCH]?.[Permissions.USE], + defaults.fileSearch, + ), + }, + [PermissionTypes.FILE_CITATIONS]: { + [Permissions.USE]: getPermissionValue( + loadedInterface.fileCitations, + defaultPerms[PermissionTypes.FILE_CITATIONS]?.[Permissions.USE], + defaults.fileCitations, + ), + }, + }; + + // Check and add each permission type if needed + for (const [permType, permissions] of Object.entries(allPermissions)) { + addPermissionIfNeeded(permType as PermissionTypes, permissions); + } + + // Update permissions if any need updating + if (Object.keys(permissionsToUpdate).length > 0) { + await updateAccessPermissions(roleName, permissionsToUpdate, existingRole); + } + } +} diff --git a/packages/api/src/endpoints/custom/config.ts b/packages/api/src/endpoints/custom/config.ts new file mode 100644 index 000000000..220eb4350 --- /dev/null +++ b/packages/api/src/endpoints/custom/config.ts @@ -0,0 +1,56 @@ +import { EModelEndpoint, extractEnvVariable } from 'librechat-data-provider'; +import type { TCustomEndpoints, TEndpoint, TConfig } from 'librechat-data-provider'; +import type { TCustomEndpointsConfig } from '~/types/endpoints'; +import { isUserProvided, normalizeEndpointName } from '~/utils'; + +/** + * Load config endpoints from the cached configuration object + * @param customEndpointsConfig - The configuration object + */ +export function loadCustomEndpointsConfig( + customEndpoints?: TCustomEndpoints, +): TCustomEndpointsConfig | undefined { + if (!customEndpoints) { + return; + } + + const customEndpointsConfig: TCustomEndpointsConfig = {}; + + if (Array.isArray(customEndpoints)) { + const filteredEndpoints = customEndpoints.filter( + (endpoint) => + endpoint.baseURL && + endpoint.apiKey && + endpoint.name && + endpoint.models && + (endpoint.models.fetch || endpoint.models.default), + ); + + for (let i = 0; i < filteredEndpoints.length; i++) { + const endpoint = filteredEndpoints[i] as TEndpoint; + const { + baseURL, + apiKey, + name: configName, + iconURL, + modelDisplayLabel, + customParams, + } = endpoint; + const name = normalizeEndpointName(configName); + + const resolvedApiKey = extractEnvVariable(apiKey ?? ''); + const resolvedBaseURL = extractEnvVariable(baseURL ?? ''); + + customEndpointsConfig[name] = { + type: EModelEndpoint.custom, + userProvide: isUserProvided(resolvedApiKey), + userProvideURL: isUserProvided(resolvedBaseURL), + customParams: customParams as TConfig['customParams'], + modelDisplayLabel, + iconURL, + }; + } + } + + return customEndpointsConfig; +} diff --git a/packages/api/src/endpoints/custom/index.ts b/packages/api/src/endpoints/custom/index.ts new file mode 100644 index 000000000..f03c2281a --- /dev/null +++ b/packages/api/src/endpoints/custom/index.ts @@ -0,0 +1 @@ +export * from './config'; diff --git a/packages/api/src/endpoints/index.ts b/packages/api/src/endpoints/index.ts index e12780d87..7b98ffcb6 100644 --- a/packages/api/src/endpoints/index.ts +++ b/packages/api/src/endpoints/index.ts @@ -1,2 +1,3 @@ +export * from './custom'; export * from './google'; export * from './openai'; diff --git a/packages/api/src/endpoints/openai/initialize.ts b/packages/api/src/endpoints/openai/initialize.ts index babfed0be..425aa3d55 100644 --- a/packages/api/src/endpoints/openai/initialize.ts +++ b/packages/api/src/endpoints/openai/initialize.ts @@ -1,9 +1,9 @@ import { ErrorTypes, EModelEndpoint, mapModelToAzureConfig } from 'librechat-data-provider'; import type { - UserKeyValues, + InitializeOpenAIOptionsParams, OpenAIOptionsResult, OpenAIConfigOptions, - InitializeOpenAIOptionsParams, + UserKeyValues, } from '~/types'; import { createHandleLLMNewToken } from '~/utils/generators'; import { getAzureCredentials } from '~/utils/azure'; @@ -21,6 +21,7 @@ import { getOpenAIConfig } from './llm'; */ export const initializeOpenAI = async ({ req, + appConfig, overrideModel, endpointOption, overrideEndpoint, @@ -71,7 +72,7 @@ export const initializeOpenAI = async ({ }; const isAzureOpenAI = endpoint === EModelEndpoint.azureOpenAI; - const azureConfig = isAzureOpenAI && req.app.locals[EModelEndpoint.azureOpenAI]; + const azureConfig = isAzureOpenAI && appConfig.endpoints?.[EModelEndpoint.azureOpenAI]; if (isAzureOpenAI && azureConfig) { const { modelGroupMap, groupMap } = azureConfig; @@ -142,8 +143,8 @@ export const initializeOpenAI = async ({ const options = getOpenAIConfig(apiKey, finalClientOptions, endpoint); - const openAIConfig = req.app.locals[EModelEndpoint.openAI]; - const allConfig = req.app.locals.all; + const openAIConfig = appConfig.endpoints?.[EModelEndpoint.openAI]; + const allConfig = appConfig.endpoints?.all; const azureRate = modelName?.includes('gpt-4') ? 30 : 17; let streamRate: number | undefined; diff --git a/packages/api/src/files/index.ts b/packages/api/src/files/index.ts index 77feb9f15..1c43619fe 100644 --- a/packages/api/src/files/index.ts +++ b/packages/api/src/files/index.ts @@ -1 +1,2 @@ export * from './mistral/crud'; +export * from './parse'; diff --git a/packages/api/src/files/mistral/crud.spec.ts b/packages/api/src/files/mistral/crud.spec.ts index a7db60180..e80e18358 100644 --- a/packages/api/src/files/mistral/crud.spec.ts +++ b/packages/api/src/files/mistral/crud.spec.ts @@ -46,7 +46,12 @@ import * as fs from 'fs'; import axios from 'axios'; import type { Request as ExpressRequest } from 'express'; import type { Readable } from 'stream'; -import type { MistralFileUploadResponse, MistralSignedUrlResponse, OCRResult } from '~/types'; +import type { + MistralFileUploadResponse, + MistralSignedUrlResponse, + OCRResult, + AppConfig, +} from '~/types'; import { logger as mockLogger } from '@librechat/data-schemas'; import { uploadDocumentToMistral, @@ -497,18 +502,17 @@ describe('MistralOCR Service', () => { const req = { user: { id: 'user123' }, - app: { - locals: { - ocr: { - // Use environment variable syntax to ensure loadAuthValues is called - apiKey: '${OCR_API_KEY}', - baseURL: '${OCR_BASEURL}', - mistralModel: 'mistral-medium', - }, - }, - }, } as unknown as ExpressRequest; + const appConfig = { + ocr: { + // Use environment variable syntax to ensure loadAuthValues is called + apiKey: '${OCR_API_KEY}', + baseURL: '${OCR_BASEURL}', + mistralModel: 'mistral-medium', + }, + } as AppConfig; + const file = { path: '/tmp/upload/file.pdf', originalname: 'document.pdf', @@ -517,6 +521,7 @@ describe('MistralOCR Service', () => { const result = await uploadMistralOCR({ req, + appConfig, file, loadAuthValues: mockLoadAuthValues, }); @@ -599,17 +604,16 @@ describe('MistralOCR Service', () => { const req = { user: { id: 'user456' }, - app: { - locals: { - ocr: { - apiKey: '${OCR_API_KEY}', - baseURL: '${OCR_BASEURL}', - mistralModel: 'mistral-medium', - }, - }, - }, } as unknown as ExpressRequest; + const appConfig = { + ocr: { + apiKey: '${OCR_API_KEY}', + baseURL: '${OCR_BASEURL}', + mistralModel: 'mistral-medium', + }, + } as AppConfig; + const file = { path: '/tmp/upload/image.png', originalname: 'image.png', @@ -618,6 +622,7 @@ describe('MistralOCR Service', () => { const result = await uploadMistralOCR({ req, + appConfig, file, loadAuthValues: mockLoadAuthValues, }); @@ -698,17 +703,16 @@ describe('MistralOCR Service', () => { const req = { user: { id: 'user123' }, - app: { - locals: { - ocr: { - apiKey: '${CUSTOM_API_KEY}', - baseURL: '${CUSTOM_BASEURL}', - mistralModel: '${CUSTOM_MODEL}', - }, - }, - }, } as unknown as ExpressRequest; + const appConfig = { + ocr: { + apiKey: '${CUSTOM_API_KEY}', + baseURL: '${CUSTOM_BASEURL}', + mistralModel: '${CUSTOM_MODEL}', + }, + } as AppConfig; + // Set environment variable for model process.env.CUSTOM_MODEL = 'mistral-large'; @@ -720,6 +724,7 @@ describe('MistralOCR Service', () => { const result = await uploadMistralOCR({ req, + appConfig, file, loadAuthValues: mockLoadAuthValues, }); @@ -790,18 +795,17 @@ describe('MistralOCR Service', () => { const req = { user: { id: 'user123' }, - app: { - locals: { - ocr: { - // Use environment variable syntax to ensure loadAuthValues is called - apiKey: '${INVALID_FORMAT}', // Using valid env var format but with an invalid name - baseURL: '${OCR_BASEURL}', // Using valid env var format - mistralModel: 'mistral-ocr-latest', // Plain string value - }, - }, - }, } as unknown as ExpressRequest; + const appConfig = { + ocr: { + // Use environment variable syntax to ensure loadAuthValues is called + apiKey: '${INVALID_FORMAT}', // Using valid env var format but with an invalid name + baseURL: '${OCR_BASEURL}', // Using valid env var format + mistralModel: 'mistral-ocr-latest', // Plain string value + }, + } as AppConfig; + const file = { path: '/tmp/upload/file.pdf', originalname: 'document.pdf', @@ -810,6 +814,7 @@ describe('MistralOCR Service', () => { await uploadMistralOCR({ req, + appConfig, file, loadAuthValues: mockLoadAuthValues, }); @@ -845,16 +850,15 @@ describe('MistralOCR Service', () => { const req = { user: { id: 'user123' }, - app: { - locals: { - ocr: { - apiKey: 'OCR_API_KEY', - baseURL: 'OCR_BASEURL', - }, - }, - }, } as unknown as ExpressRequest; + const appConfig = { + ocr: { + apiKey: 'OCR_API_KEY', + baseURL: 'OCR_BASEURL', + }, + } as AppConfig; + const file = { path: '/tmp/upload/file.pdf', originalname: 'document.pdf', @@ -864,6 +868,7 @@ describe('MistralOCR Service', () => { await expect( uploadMistralOCR({ req, + appConfig, file, loadAuthValues: mockLoadAuthValues, }), @@ -931,17 +936,16 @@ describe('MistralOCR Service', () => { const req = { user: { id: 'user123' }, - app: { - locals: { - ocr: { - apiKey: 'OCR_API_KEY', - baseURL: 'OCR_BASEURL', - mistralModel: 'mistral-ocr-latest', - }, - }, - }, } as unknown as ExpressRequest; + const appConfig = { + ocr: { + apiKey: 'OCR_API_KEY', + baseURL: 'OCR_BASEURL', + mistralModel: 'mistral-ocr-latest', + }, + } as AppConfig; + const file = { path: '/tmp/upload/file.pdf', originalname: 'single-page.pdf', @@ -950,6 +954,7 @@ describe('MistralOCR Service', () => { const result = await uploadMistralOCR({ req, + appConfig, file, loadAuthValues: mockLoadAuthValues, }); @@ -1019,18 +1024,17 @@ describe('MistralOCR Service', () => { const req = { user: { id: 'user123' }, - app: { - locals: { - ocr: { - // Direct values that should be used as-is, without variable substitution - apiKey: 'actual-api-key-value', - baseURL: 'https://direct-api-url.mistral.ai/v1', - mistralModel: 'mistral-direct-model', - }, - }, - }, } as unknown as ExpressRequest; + const appConfig = { + ocr: { + // Direct values that should be used as-is, without variable substitution + apiKey: 'actual-api-key-value', + baseURL: 'https://direct-api-url.mistral.ai/v1', + mistralModel: 'mistral-direct-model', + }, + } as AppConfig; + const file = { path: '/tmp/upload/file.pdf', originalname: 'direct-values.pdf', @@ -1039,6 +1043,7 @@ describe('MistralOCR Service', () => { const result = await uploadMistralOCR({ req, + appConfig, file, loadAuthValues: mockLoadAuthValues, }); @@ -1133,18 +1138,17 @@ describe('MistralOCR Service', () => { const req = { user: { id: 'user123' }, - app: { - locals: { - ocr: { - // Empty string values - should fall back to defaults - apiKey: '', - baseURL: '', - mistralModel: '', - }, - }, - }, } as unknown as ExpressRequest; + const appConfig = { + ocr: { + // Empty string values - should fall back to defaults + apiKey: '', + baseURL: '', + mistralModel: '', + }, + } as AppConfig; + const file = { path: '/tmp/upload/file.pdf', originalname: 'empty-config.pdf', @@ -1153,6 +1157,7 @@ describe('MistralOCR Service', () => { const result = await uploadMistralOCR({ req, + appConfig, file, loadAuthValues: mockLoadAuthValues, }); @@ -1276,17 +1281,16 @@ describe('MistralOCR Service', () => { const req = { user: { id: 'user123' }, - app: { - locals: { - ocr: { - apiKey: '${AZURE_MISTRAL_OCR_API_KEY}', - baseURL: 'https://endpoint.models.ai.azure.com/v1', - mistralModel: 'mistral-ocr-2503', - }, - }, - }, } as unknown as ExpressRequest; + const appConfig = { + ocr: { + apiKey: '${AZURE_MISTRAL_OCR_API_KEY}', + baseURL: 'https://endpoint.models.ai.azure.com/v1', + mistralModel: 'mistral-ocr-2503', + }, + } as AppConfig; + const file = { path: '/tmp/upload/file.pdf', originalname: 'document.pdf', @@ -1295,6 +1299,7 @@ describe('MistralOCR Service', () => { await uploadMistralOCR({ req, + appConfig, file, loadAuthValues: mockLoadAuthValues, }); @@ -1360,17 +1365,16 @@ describe('MistralOCR Service', () => { const req = { user: { id: 'user456' }, - app: { - locals: { - ocr: { - apiKey: 'hardcoded-api-key-12345', - baseURL: '${CUSTOM_OCR_BASEURL}', - mistralModel: 'mistral-ocr-latest', - }, - }, - }, } as unknown as ExpressRequest; + const appConfig = { + ocr: { + apiKey: 'hardcoded-api-key-12345', + baseURL: '${CUSTOM_OCR_BASEURL}', + mistralModel: 'mistral-ocr-latest', + }, + } as AppConfig; + const file = { path: '/tmp/upload/file.pdf', originalname: 'document.pdf', @@ -1379,6 +1383,7 @@ describe('MistralOCR Service', () => { await uploadMistralOCR({ req, + appConfig, file, loadAuthValues: mockLoadAuthValues, }); @@ -1484,17 +1489,16 @@ describe('MistralOCR Service', () => { const req = { user: { id: 'user123' }, - app: { - locals: { - ocr: { - apiKey: '${OCR_API_KEY}', - baseURL: '${OCR_BASEURL}', - mistralModel: 'mistral-ocr-latest', - }, - }, - }, } as unknown as ExpressRequest; + const appConfig = { + ocr: { + apiKey: '${OCR_API_KEY}', + baseURL: '${OCR_BASEURL}', + mistralModel: 'mistral-ocr-latest', + }, + } as AppConfig; + const file = { path: '/tmp/upload/file.pdf', originalname: 'document.pdf', @@ -1503,6 +1507,7 @@ describe('MistralOCR Service', () => { await uploadMistralOCR({ req, + appConfig, file, loadAuthValues: mockLoadAuthValues, }); @@ -1553,17 +1558,16 @@ describe('MistralOCR Service', () => { const req = { user: { id: 'user123' }, - app: { - locals: { - ocr: { - apiKey: '${OCR_API_KEY}', - baseURL: '${OCR_BASEURL}', - mistralModel: 'mistral-ocr-latest', - }, - }, - }, } as unknown as ExpressRequest; + const appConfig = { + ocr: { + apiKey: '${OCR_API_KEY}', + baseURL: '${OCR_BASEURL}', + mistralModel: 'mistral-ocr-latest', + }, + } as AppConfig; + const file = { path: '/tmp/upload/file.pdf', originalname: 'document.pdf', @@ -1573,6 +1577,7 @@ describe('MistralOCR Service', () => { await expect( uploadMistralOCR({ req, + appConfig, file, loadAuthValues: mockLoadAuthValues, }), @@ -1641,17 +1646,16 @@ describe('MistralOCR Service', () => { const req = { user: { id: 'user123' }, - app: { - locals: { - ocr: { - apiKey: '${OCR_API_KEY}', - baseURL: '${OCR_BASEURL}', - mistralModel: 'mistral-ocr-latest', - }, - }, - }, } as unknown as ExpressRequest; + const appConfig = { + ocr: { + apiKey: '${OCR_API_KEY}', + baseURL: '${OCR_BASEURL}', + mistralModel: 'mistral-ocr-latest', + }, + } as AppConfig; + const file = { path: '/tmp/upload/file.pdf', originalname: 'document.pdf', @@ -1661,6 +1665,7 @@ describe('MistralOCR Service', () => { // Should not throw even if delete fails const result = await uploadMistralOCR({ req, + appConfig, file, loadAuthValues: mockLoadAuthValues, }); @@ -1701,17 +1706,16 @@ describe('MistralOCR Service', () => { const req = { user: { id: 'user123' }, - app: { - locals: { - ocr: { - apiKey: '${OCR_API_KEY}', - baseURL: '${OCR_BASEURL}', - mistralModel: 'mistral-ocr-latest', - }, - }, - }, } as unknown as ExpressRequest; + const appConfig = { + ocr: { + apiKey: '${OCR_API_KEY}', + baseURL: '${OCR_BASEURL}', + mistralModel: 'mistral-ocr-latest', + }, + } as AppConfig; + const file = { path: '/tmp/upload/file.pdf', originalname: 'document.pdf', @@ -1721,6 +1725,7 @@ describe('MistralOCR Service', () => { await expect( uploadMistralOCR({ req, + appConfig, file, loadAuthValues: mockLoadAuthValues, }), @@ -1775,17 +1780,16 @@ describe('MistralOCR Service', () => { const req = { user: { id: 'user123' }, - app: { - locals: { - ocr: { - apiKey: '${OCR_API_KEY}', - baseURL: '${OCR_BASEURL}', - mistralModel: 'mistral-ocr-latest', - }, - }, - }, } as unknown as ExpressRequest; + const appConfig = { + ocr: { + apiKey: '${OCR_API_KEY}', + baseURL: '${OCR_BASEURL}', + mistralModel: 'mistral-ocr-latest', + }, + } as AppConfig; + const file = { path: '/tmp/upload/azure-file.pdf', originalname: 'azure-document.pdf', @@ -1794,6 +1798,7 @@ describe('MistralOCR Service', () => { const result = await uploadAzureMistralOCR({ req, + appConfig, file, loadAuthValues: mockLoadAuthValues, }); @@ -1851,17 +1856,16 @@ describe('MistralOCR Service', () => { const req = { user: { id: 'user123' }, - app: { - locals: { - ocr: { - apiKey: '${AZURE_MISTRAL_OCR_API_KEY}', - baseURL: 'https://endpoint.models.ai.azure.com/v1', - mistralModel: 'mistral-ocr-2503', - }, - }, - }, } as unknown as ExpressRequest; + const appConfig = { + ocr: { + apiKey: '${AZURE_MISTRAL_OCR_API_KEY}', + baseURL: 'https://endpoint.models.ai.azure.com/v1', + mistralModel: 'mistral-ocr-2503', + }, + } as AppConfig; + const file = { path: '/tmp/upload/file.pdf', originalname: 'document.pdf', @@ -1870,6 +1874,7 @@ describe('MistralOCR Service', () => { await uploadAzureMistralOCR({ req, + appConfig, file, loadAuthValues: mockLoadAuthValues, }); @@ -1915,17 +1920,16 @@ describe('MistralOCR Service', () => { const req = { user: { id: 'user456' }, - app: { - locals: { - ocr: { - apiKey: 'hardcoded-api-key-12345', - baseURL: '${CUSTOM_OCR_BASEURL}', - mistralModel: 'mistral-ocr-latest', - }, - }, - }, } as unknown as ExpressRequest; + const appConfig = { + ocr: { + apiKey: 'hardcoded-api-key-12345', + baseURL: '${CUSTOM_OCR_BASEURL}', + mistralModel: 'mistral-ocr-latest', + }, + } as AppConfig; + const file = { path: '/tmp/upload/file.pdf', originalname: 'document.pdf', @@ -1934,6 +1938,7 @@ describe('MistralOCR Service', () => { await uploadAzureMistralOCR({ req, + appConfig, file, loadAuthValues: mockLoadAuthValues, }); diff --git a/packages/api/src/files/mistral/crud.ts b/packages/api/src/files/mistral/crud.ts index 077351a7e..5f9389547 100644 --- a/packages/api/src/files/mistral/crud.ts +++ b/packages/api/src/files/mistral/crud.ts @@ -17,6 +17,7 @@ import type { MistralOCRUploadResult, MistralOCRError, OCRResultPage, + AppConfig, OCRResult, OCRImage, } from '~/types'; @@ -42,14 +43,10 @@ interface GoogleServiceAccount { /** Helper type for OCR request context */ interface OCRContext { - req: Pick & { + req: Pick & { user?: { id: string }; - app: { - locals?: { - ocr?: TCustomConfig['ocr']; - }; - }; }; + appConfig: AppConfig; file: Express.Multer.File; loadAuthValues: (params: { userId: string; @@ -241,7 +238,7 @@ async function resolveConfigValue( * Loads authentication configuration from OCR config */ async function loadAuthConfig(context: OCRContext): Promise { - const ocrConfig = context.req.app.locals?.ocr; + const ocrConfig = context.appConfig?.ocr; const apiKeyConfig = ocrConfig?.apiKey || ''; const baseURLConfig = ocrConfig?.baseURL || ''; @@ -357,6 +354,7 @@ function createOCRError(error: unknown, baseMessage: string): Error { * @param params - The params object. * @param params.req - The request object from Express. It should have a `user` property with an `id` * representing the user + * @param params.appConfig - Application configuration object * @param params.file - The file object, which is part of the request. The file object should * have a `mimetype` property that tells us the file type * @param params.loadAuthValues - Function to load authentication values @@ -372,7 +370,7 @@ export const uploadMistralOCR = async (context: OCRContext): Promise => { try { const { apiKey, baseURL } = await loadAuthConfig(context); - const model = getModelConfig(context.req.app.locals?.ocr); + const model = getModelConfig(context.appConfig?.ocr); const buffer = fs.readFileSync(context.file.path); const base64 = buffer.toString('base64'); @@ -644,6 +643,7 @@ async function performGoogleVertexOCR({ * @param params - The params object. * @param params.req - The request object from Express. It should have a `user` property with an `id` * representing the user + * @param params.appConfig - Application configuration object * @param params.file - The file object, which is part of the request. The file object should * have a `mimetype` property that tells us the file type * @param params.loadAuthValues - Function to load authentication values @@ -655,7 +655,7 @@ export const uploadGoogleVertexMistralOCR = async ( ): Promise => { try { const { serviceAccount, accessToken } = await loadGoogleAuthConfig(); - const model = getModelConfig(context.req.app.locals?.ocr); + const model = getModelConfig(context.appConfig?.ocr); const buffer = fs.readFileSync(context.file.path); const base64 = buffer.toString('base64'); diff --git a/api/server/services/Files/images/parse.js b/packages/api/src/files/parse.ts similarity index 56% rename from api/server/services/Files/images/parse.js rename to packages/api/src/files/parse.ts index 1b0f7e473..f782f7e5e 100644 --- a/api/server/services/Files/images/parse.js +++ b/packages/api/src/files/parse.ts @@ -1,22 +1,22 @@ -const URL = require('url').URL; -const path = require('path'); +import path from 'path'; +import { URL } from 'url'; const imageExtensionRegex = /\.(jpg|jpeg|png|gif|bmp|tiff|svg|webp)$/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. + * @param urlString - The URL string from which the image basename is to be extracted. + * @returns 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) { +export function getImageBasename(urlString: string) { try { const url = new URL(urlString); const basename = path.basename(url.pathname); return imageExtensionRegex.test(basename) ? basename : ''; - } catch (error) { + } catch { // If URL parsing fails, return an empty string return ''; } @@ -25,21 +25,16 @@ function getImageBasename(urlString) { /** * Extracts the basename of a file from a given URL. * - * @param {string} urlString - The URL string from which the file basename is to be extracted. - * @returns {string} The basename of the file from the URL. + * @param urlString - The URL string from which the file basename is to be extracted. + * @returns The basename of the file from the URL. * Returns an empty string if the URL parsing fails. */ -function getFileBasename(urlString) { +export function getFileBasename(urlString: string) { try { const url = new URL(urlString); return path.basename(url.pathname); - } catch (error) { + } catch { // If URL parsing fails, return an empty string return ''; } } - -module.exports = { - getImageBasename, - getFileBasename, -}; diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 4947b6bc6..3fd81fa53 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -1,3 +1,4 @@ +export * from './app'; /* MCP */ export * from './mcp/MCPManager'; export * from './mcp/connection'; @@ -33,3 +34,4 @@ export * from './web'; /* types */ export type * from './mcp/types'; export type * from './flow/types'; +export type * from './types'; diff --git a/packages/api/src/mcp/MCPManager.ts b/packages/api/src/mcp/MCPManager.ts index 4d5848ae7..485e6882e 100644 --- a/packages/api/src/mcp/MCPManager.ts +++ b/packages/api/src/mcp/MCPManager.ts @@ -54,6 +54,11 @@ export class MCPManager extends UserConnectionManager { return this.serversRegistry.oauthServers!; } + /** Get all servers */ + public getAllServers(): t.MCPServers | null { + return this.serversRegistry.rawConfigs!; + } + /** Returns all available tool functions from app-level connections */ public getAppToolFunctions(): t.LCAvailableTools | null { return this.serversRegistry.toolFunctions!; diff --git a/packages/api/src/middleware/balance.spec.ts b/packages/api/src/middleware/balance.spec.ts index 7e340e441..076ec6d51 100644 --- a/packages/api/src/middleware/balance.spec.ts +++ b/packages/api/src/middleware/balance.spec.ts @@ -2,7 +2,7 @@ import mongoose from 'mongoose'; import { MongoMemoryServer } from 'mongodb-memory-server'; import { logger, balanceSchema } from '@librechat/data-schemas'; import type { NextFunction, Request as ServerRequest, Response as ServerResponse } from 'express'; -import type { IBalance, BalanceConfig } from '@librechat/data-schemas'; +import type { IBalance } from '@librechat/data-schemas'; import { createSetBalanceConfig } from './balance'; jest.mock('@librechat/data-schemas', () => ({ @@ -48,23 +48,22 @@ describe('createSetBalanceConfig', () => { }); const mockNext: NextFunction = jest.fn(); - - const defaultBalanceConfig: BalanceConfig = { - enabled: true, - startBalance: 1000, - autoRefillEnabled: true, - refillIntervalValue: 30, - refillIntervalUnit: 'days', - refillAmount: 500, - }; - describe('Basic Functionality', () => { test('should create balance record for new user with start balance', async () => { const userId = new mongoose.Types.ObjectId(); - const getBalanceConfig = jest.fn().mockResolvedValue(defaultBalanceConfig); + const getAppConfig = jest.fn().mockResolvedValue({ + balance: { + enabled: true, + startBalance: 1000, + autoRefillEnabled: true, + refillIntervalValue: 30, + refillIntervalUnit: 'days', + refillAmount: 500, + }, + }); const middleware = createSetBalanceConfig({ - getBalanceConfig, + getAppConfig, Balance, }); @@ -73,7 +72,7 @@ describe('createSetBalanceConfig', () => { await middleware(req as ServerRequest, res as ServerResponse, mockNext); - expect(getBalanceConfig).toHaveBeenCalled(); + expect(getAppConfig).toHaveBeenCalled(); expect(mockNext).toHaveBeenCalled(); const balanceRecord = await Balance.findOne({ user: userId }); @@ -88,10 +87,14 @@ describe('createSetBalanceConfig', () => { test('should skip if balance config is not enabled', async () => { const userId = new mongoose.Types.ObjectId(); - const getBalanceConfig = jest.fn().mockResolvedValue({ enabled: false }); + const getAppConfig = jest.fn().mockResolvedValue({ + balance: { + enabled: false, + }, + }); const middleware = createSetBalanceConfig({ - getBalanceConfig, + getAppConfig, Balance, }); @@ -108,13 +111,15 @@ describe('createSetBalanceConfig', () => { test('should skip if startBalance is null', async () => { const userId = new mongoose.Types.ObjectId(); - const getBalanceConfig = jest.fn().mockResolvedValue({ - enabled: true, - startBalance: null, + const getAppConfig = jest.fn().mockResolvedValue({ + balance: { + enabled: true, + startBalance: null, + }, }); const middleware = createSetBalanceConfig({ - getBalanceConfig, + getAppConfig, Balance, }); @@ -131,10 +136,19 @@ describe('createSetBalanceConfig', () => { test('should handle user._id as string', async () => { const userId = new mongoose.Types.ObjectId().toString(); - const getBalanceConfig = jest.fn().mockResolvedValue(defaultBalanceConfig); + const getAppConfig = jest.fn().mockResolvedValue({ + balance: { + enabled: true, + startBalance: 1000, + autoRefillEnabled: true, + refillIntervalValue: 30, + refillIntervalUnit: 'days', + refillAmount: 500, + }, + }); const middleware = createSetBalanceConfig({ - getBalanceConfig, + getAppConfig, Balance, }); @@ -151,10 +165,19 @@ describe('createSetBalanceConfig', () => { }); test('should skip if user is not present in request', async () => { - const getBalanceConfig = jest.fn().mockResolvedValue(defaultBalanceConfig); + const getAppConfig = jest.fn().mockResolvedValue({ + balance: { + enabled: true, + startBalance: 1000, + autoRefillEnabled: true, + refillIntervalValue: 30, + refillIntervalUnit: 'days', + refillAmount: 500, + }, + }); const middleware = createSetBalanceConfig({ - getBalanceConfig, + getAppConfig, Balance, }); @@ -164,7 +187,7 @@ describe('createSetBalanceConfig', () => { await middleware(req, res as ServerResponse, mockNext); expect(mockNext).toHaveBeenCalled(); - expect(getBalanceConfig).toHaveBeenCalled(); + expect(getAppConfig).toHaveBeenCalled(); }); }); @@ -183,17 +206,19 @@ describe('createSetBalanceConfig', () => { // Remove lastRefill to simulate existing user without it await Balance.updateOne({ _id: doc._id }, { $unset: { lastRefill: 1 } }); - const getBalanceConfig = jest.fn().mockResolvedValue({ - enabled: true, - startBalance: 1000, - autoRefillEnabled: true, - refillIntervalValue: 30, - refillIntervalUnit: 'days', - refillAmount: 500, + const getAppConfig = jest.fn().mockResolvedValue({ + balance: { + enabled: true, + startBalance: 1000, + autoRefillEnabled: true, + refillIntervalValue: 30, + refillIntervalUnit: 'days', + refillAmount: 500, + }, }); const middleware = createSetBalanceConfig({ - getBalanceConfig, + getAppConfig, Balance, }); @@ -233,17 +258,19 @@ describe('createSetBalanceConfig', () => { lastRefill: existingLastRefill, }); - const getBalanceConfig = jest.fn().mockResolvedValue({ - enabled: true, - startBalance: 1000, - autoRefillEnabled: true, - refillIntervalValue: 30, - refillIntervalUnit: 'days', - refillAmount: 500, + const getAppConfig = jest.fn().mockResolvedValue({ + balance: { + enabled: true, + startBalance: 1000, + autoRefillEnabled: true, + refillIntervalValue: 30, + refillIntervalUnit: 'days', + refillAmount: 500, + }, }); const middleware = createSetBalanceConfig({ - getBalanceConfig, + getAppConfig, Balance, }); @@ -275,17 +302,19 @@ describe('createSetBalanceConfig', () => { // Remove lastRefill to simulate the edge case await Balance.updateOne({ _id: doc._id }, { $unset: { lastRefill: 1 } }); - const getBalanceConfig = jest.fn().mockResolvedValue({ - enabled: true, - startBalance: 1000, - autoRefillEnabled: true, - refillIntervalValue: 30, - refillIntervalUnit: 'days', - refillAmount: 500, + const getAppConfig = jest.fn().mockResolvedValue({ + balance: { + enabled: true, + startBalance: 1000, + autoRefillEnabled: true, + refillIntervalValue: 30, + refillIntervalUnit: 'days', + refillAmount: 500, + }, }); const middleware = createSetBalanceConfig({ - getBalanceConfig, + getAppConfig, Balance, }); @@ -306,14 +335,17 @@ describe('createSetBalanceConfig', () => { test('should not set lastRefill when auto-refill is disabled', async () => { const userId = new mongoose.Types.ObjectId(); - const getBalanceConfig = jest.fn().mockResolvedValue({ - enabled: true, - startBalance: 1000, - autoRefillEnabled: false, + const getAppConfig = jest.fn().mockResolvedValue({ + balance: { + enabled: true, + + startBalance: 1000, + autoRefillEnabled: false, + }, }); const middleware = createSetBalanceConfig({ - getBalanceConfig, + getAppConfig, Balance, }); @@ -347,17 +379,19 @@ describe('createSetBalanceConfig', () => { refillAmount: 100, }); - const getBalanceConfig = jest.fn().mockResolvedValue({ - enabled: true, - startBalance: 1000, - autoRefillEnabled: true, - refillIntervalValue: 30, - refillIntervalUnit: 'days', - refillAmount: 500, + const getAppConfig = jest.fn().mockResolvedValue({ + balance: { + enabled: true, + startBalance: 1000, + autoRefillEnabled: true, + refillIntervalValue: 30, + refillIntervalUnit: 'days', + refillAmount: 500, + }, }); const middleware = createSetBalanceConfig({ - getBalanceConfig, + getAppConfig, Balance, }); @@ -389,10 +423,19 @@ describe('createSetBalanceConfig', () => { lastRefill: lastRefillTime, }); - const getBalanceConfig = jest.fn().mockResolvedValue(defaultBalanceConfig); + const getAppConfig = jest.fn().mockResolvedValue({ + balance: { + enabled: true, + startBalance: 1000, + autoRefillEnabled: true, + refillIntervalValue: 30, + refillIntervalUnit: 'days', + refillAmount: 500, + }, + }); const middleware = createSetBalanceConfig({ - getBalanceConfig, + getAppConfig, Balance, }); @@ -417,13 +460,16 @@ describe('createSetBalanceConfig', () => { tokenCredits: null, }); - const getBalanceConfig = jest.fn().mockResolvedValue({ - enabled: true, - startBalance: 2000, + const getAppConfig = jest.fn().mockResolvedValue({ + balance: { + enabled: true, + + startBalance: 2000, + }, }); const middleware = createSetBalanceConfig({ - getBalanceConfig, + getAppConfig, Balance, }); @@ -440,7 +486,16 @@ describe('createSetBalanceConfig', () => { describe('Error Handling', () => { test('should handle database errors gracefully', async () => { const userId = new mongoose.Types.ObjectId(); - const getBalanceConfig = jest.fn().mockResolvedValue(defaultBalanceConfig); + const getAppConfig = jest.fn().mockResolvedValue({ + balance: { + enabled: true, + startBalance: 1000, + autoRefillEnabled: true, + refillIntervalValue: 30, + refillIntervalUnit: 'days', + refillAmount: 500, + }, + }); const dbError = new Error('Database error'); // Mock Balance.findOne to throw an error @@ -451,7 +506,7 @@ describe('createSetBalanceConfig', () => { }) as unknown as mongoose.Model['findOne']); const middleware = createSetBalanceConfig({ - getBalanceConfig, + getAppConfig, Balance, }); @@ -464,13 +519,13 @@ describe('createSetBalanceConfig', () => { expect(mockNext).toHaveBeenCalledWith(dbError); }); - test('should handle getBalanceConfig errors', async () => { + test('should handle getAppConfig errors', async () => { const userId = new mongoose.Types.ObjectId(); const configError = new Error('Config error'); - const getBalanceConfig = jest.fn().mockRejectedValue(configError); + const getAppConfig = jest.fn().mockRejectedValue(configError); const middleware = createSetBalanceConfig({ - getBalanceConfig, + getAppConfig, Balance, }); @@ -487,17 +542,20 @@ describe('createSetBalanceConfig', () => { const userId = new mongoose.Types.ObjectId(); // Missing required auto-refill fields - const getBalanceConfig = jest.fn().mockResolvedValue({ - enabled: true, - startBalance: 1000, - autoRefillEnabled: true, - refillIntervalValue: null, // Invalid - refillIntervalUnit: 'days', - refillAmount: 500, + const getAppConfig = jest.fn().mockResolvedValue({ + balance: { + enabled: true, + + startBalance: 1000, + autoRefillEnabled: true, + refillIntervalValue: null, // Invalid + refillIntervalUnit: 'days', + refillAmount: 500, + }, }); const middleware = createSetBalanceConfig({ - getBalanceConfig, + getAppConfig, Balance, }); @@ -519,10 +577,19 @@ describe('createSetBalanceConfig', () => { describe('Concurrent Updates', () => { test('should handle concurrent middleware calls for same user', async () => { const userId = new mongoose.Types.ObjectId(); - const getBalanceConfig = jest.fn().mockResolvedValue(defaultBalanceConfig); + const getAppConfig = jest.fn().mockResolvedValue({ + balance: { + enabled: true, + startBalance: 1000, + autoRefillEnabled: true, + refillIntervalValue: 30, + refillIntervalUnit: 'days', + refillAmount: 500, + }, + }); const middleware = createSetBalanceConfig({ - getBalanceConfig, + getAppConfig, Balance, }); @@ -554,17 +621,20 @@ describe('createSetBalanceConfig', () => { async (unit) => { const userId = new mongoose.Types.ObjectId(); - const getBalanceConfig = jest.fn().mockResolvedValue({ - enabled: true, - startBalance: 1000, - autoRefillEnabled: true, - refillIntervalValue: 10, - refillIntervalUnit: unit, - refillAmount: 100, + const getAppConfig = jest.fn().mockResolvedValue({ + balance: { + enabled: true, + + startBalance: 1000, + autoRefillEnabled: true, + refillIntervalValue: 10, + refillIntervalUnit: unit, + refillAmount: 100, + }, }); const middleware = createSetBalanceConfig({ - getBalanceConfig, + getAppConfig, Balance, }); diff --git a/packages/api/src/middleware/balance.ts b/packages/api/src/middleware/balance.ts index 3aaa20da6..f51666a15 100644 --- a/packages/api/src/middleware/balance.ts +++ b/packages/api/src/middleware/balance.ts @@ -2,10 +2,11 @@ import { logger } from '@librechat/data-schemas'; import type { NextFunction, Request as ServerRequest, Response as ServerResponse } from 'express'; import type { IBalance, IUser, BalanceConfig, ObjectId } from '@librechat/data-schemas'; import type { Model } from 'mongoose'; -import type { BalanceUpdateFields } from '~/types'; +import type { AppConfig, BalanceUpdateFields } from '~/types'; +import { getBalanceConfig } from '~/app/config'; export interface BalanceMiddlewareOptions { - getBalanceConfig: () => Promise; + getAppConfig: (options?: { role?: string; refresh?: boolean }) => Promise; Balance: Model; } @@ -73,7 +74,7 @@ function buildUpdateFields( * @returns Express middleware function */ export function createSetBalanceConfig({ - getBalanceConfig, + getAppConfig, Balance, }: BalanceMiddlewareOptions): ( req: ServerRequest, @@ -82,7 +83,9 @@ export function createSetBalanceConfig({ ) => Promise { return async (req: ServerRequest, res: ServerResponse, next: NextFunction): Promise => { try { - const balanceConfig = await getBalanceConfig(); + const user = req.user as IUser & { _id: string | ObjectId }; + const appConfig = await getAppConfig({ role: user?.role }); + const balanceConfig = getBalanceConfig(appConfig); if (!balanceConfig?.enabled) { return next(); } @@ -90,7 +93,6 @@ export function createSetBalanceConfig({ return next(); } - const user = req.user as IUser & { _id: string | ObjectId }; if (!user || !user._id) { return next(); } diff --git a/packages/api/src/tools/format.spec.ts b/packages/api/src/tools/format.spec.ts index 3226da41f..3a02fd7c6 100644 --- a/packages/api/src/tools/format.spec.ts +++ b/packages/api/src/tools/format.spec.ts @@ -1,5 +1,6 @@ import { AuthType, Constants, EToolResources } from 'librechat-data-provider'; -import type { TPlugin, FunctionTool, TCustomConfig } from 'librechat-data-provider'; +import type { TPlugin, FunctionTool } from 'librechat-data-provider'; +import type { MCPManager } from '~/mcp/MCPManager'; import { convertMCPToolsToPlugins, filterUniquePlugins, @@ -277,19 +278,18 @@ describe('format.ts helper functions', () => { } as FunctionTool, }; - const customConfig: Partial = { - mcpServers: { - server1: { - command: 'test', - args: [], - iconPath: '/path/to/icon.png', - }, - }, - }; + const mockMcpManager = { + getRawConfig: jest.fn().mockReturnValue({ + command: 'test', + args: [], + iconPath: '/path/to/icon.png', + }), + } as unknown as MCPManager; - const result = convertMCPToolsToPlugins({ functionTools, customConfig }); + const result = convertMCPToolsToPlugins({ functionTools, mcpManager: mockMcpManager }); expect(result).toHaveLength(1); expect(result![0].icon).toBe('/path/to/icon.png'); + expect(mockMcpManager.getRawConfig).toHaveBeenCalledWith('server1'); }); it('should handle customUserVars in server config', () => { @@ -300,26 +300,25 @@ describe('format.ts helper functions', () => { } as FunctionTool, }; - const customConfig: Partial = { - mcpServers: { - server1: { - command: 'test', - args: [], - customUserVars: { - API_KEY: { title: 'API Key', description: 'Your API key' }, - SECRET: { title: 'Secret', description: 'Your secret' }, - }, + const mockMcpManager = { + getRawConfig: jest.fn().mockReturnValue({ + command: 'test', + args: [], + customUserVars: { + API_KEY: { title: 'API Key', description: 'Your API key' }, + SECRET: { title: 'Secret', description: 'Your secret' }, }, - }, - }; + }), + } as unknown as MCPManager; - const result = convertMCPToolsToPlugins({ functionTools, customConfig }); + const result = convertMCPToolsToPlugins({ functionTools, mcpManager: mockMcpManager }); expect(result).toHaveLength(1); expect(result![0].authConfig).toHaveLength(2); expect(result![0].authConfig).toEqual([ { authField: 'API_KEY', label: 'API Key', description: 'Your API key' }, { authField: 'SECRET', label: 'Secret', description: 'Your secret' }, ]); + expect(mockMcpManager.getRawConfig).toHaveBeenCalledWith('server1'); }); it('should use key as label when title is missing in customUserVars', () => { @@ -330,23 +329,22 @@ describe('format.ts helper functions', () => { } as FunctionTool, }; - const customConfig: Partial = { - mcpServers: { - server1: { - command: 'test', - args: [], - customUserVars: { - API_KEY: { title: 'API Key', description: 'Your API key' }, - }, + const mockMcpManager = { + getRawConfig: jest.fn().mockReturnValue({ + command: 'test', + args: [], + customUserVars: { + API_KEY: { title: 'API Key', description: 'Your API key' }, }, - }, - }; + }), + } as unknown as MCPManager; - const result = convertMCPToolsToPlugins({ functionTools, customConfig }); + const result = convertMCPToolsToPlugins({ functionTools, mcpManager: mockMcpManager }); expect(result).toHaveLength(1); expect(result![0].authConfig).toEqual([ { authField: 'API_KEY', label: 'API Key', description: 'Your API key' }, ]); + expect(mockMcpManager.getRawConfig).toHaveBeenCalledWith('server1'); }); it('should handle empty customUserVars', () => { @@ -357,19 +355,51 @@ describe('format.ts helper functions', () => { } as FunctionTool, }; - const customConfig: Partial = { - mcpServers: { - server1: { - command: 'test', - args: [], - customUserVars: {}, - }, - }, - }; + const mockMcpManager = { + getRawConfig: jest.fn().mockReturnValue({ + command: 'test', + args: [], + customUserVars: {}, + }), + } as unknown as MCPManager; - const result = convertMCPToolsToPlugins({ functionTools, customConfig }); + const result = convertMCPToolsToPlugins({ functionTools, mcpManager: mockMcpManager }); expect(result).toHaveLength(1); expect(result![0].authConfig).toEqual([]); + expect(mockMcpManager.getRawConfig).toHaveBeenCalledWith('server1'); + }); + + it('should handle missing mcpManager', () => { + const functionTools: Record = { + [`tool1${Constants.mcp_delimiter}server1`]: { + type: 'function', + function: { name: 'tool1', description: 'Tool 1' }, + } as FunctionTool, + }; + + const result = convertMCPToolsToPlugins({ functionTools }); + expect(result).toHaveLength(1); + expect(result![0].icon).toBeUndefined(); + expect(result![0].authConfig).toEqual([]); + }); + + it('should handle when getRawConfig returns undefined', () => { + const functionTools: Record = { + [`tool1${Constants.mcp_delimiter}server1`]: { + type: 'function', + function: { name: 'tool1', description: 'Tool 1' }, + } as FunctionTool, + }; + + const mockMcpManager = { + getRawConfig: jest.fn().mockReturnValue(undefined), + } as unknown as MCPManager; + + const result = convertMCPToolsToPlugins({ functionTools, mcpManager: mockMcpManager }); + expect(result).toHaveLength(1); + expect(result![0].icon).toBeUndefined(); + expect(result![0].authConfig).toEqual([]); + expect(mockMcpManager.getRawConfig).toHaveBeenCalledWith('server1'); }); }); diff --git a/packages/api/src/tools/format.ts b/packages/api/src/tools/format.ts index dce8b9d16..1ff1d7f38 100644 --- a/packages/api/src/tools/format.ts +++ b/packages/api/src/tools/format.ts @@ -1,5 +1,6 @@ import { AuthType, Constants, EToolResources } from 'librechat-data-provider'; -import type { TCustomConfig, TPlugin } from 'librechat-data-provider'; +import type { TPlugin } from 'librechat-data-provider'; +import type { MCPManager } from '~/mcp/MCPManager'; import { LCAvailableTools, LCFunctionTool } from '~/mcp/types'; /** @@ -58,11 +59,11 @@ export const checkPluginAuth = (plugin?: TPlugin): boolean => { export function convertMCPToolToPlugin({ toolKey, toolData, - customConfig, + mcpManager, }: { toolKey: string; toolData: LCFunctionTool; - customConfig?: Partial | null; + mcpManager?: MCPManager; }): TPlugin | undefined { if (!toolData.function || !toolKey.includes(Constants.mcp_delimiter)) { return; @@ -72,7 +73,7 @@ export function convertMCPToolToPlugin({ const parts = toolKey.split(Constants.mcp_delimiter); const serverName = parts[parts.length - 1]; - const serverConfig = customConfig?.mcpServers?.[serverName]; + const serverConfig = mcpManager?.getRawConfig(serverName); const plugin: TPlugin = { /** Tool name without server suffix */ @@ -111,10 +112,10 @@ export function convertMCPToolToPlugin({ */ export function convertMCPToolsToPlugins({ functionTools, - customConfig, + mcpManager, }: { functionTools?: LCAvailableTools; - customConfig?: Partial | null; + mcpManager?: MCPManager; }): TPlugin[] | undefined { if (!functionTools || typeof functionTools !== 'object') { return; @@ -122,7 +123,7 @@ export function convertMCPToolsToPlugins({ const plugins: TPlugin[] = []; for (const [toolKey, toolData] of Object.entries(functionTools)) { - const plugin = convertMCPToolToPlugin({ toolKey, toolData, customConfig }); + const plugin = convertMCPToolToPlugin({ toolKey, toolData, mcpManager }); if (plugin) { plugins.push(plugin); } diff --git a/packages/api/src/tools/index.ts b/packages/api/src/tools/index.ts index 16c5b2b50..eb375902f 100644 --- a/packages/api/src/tools/index.ts +++ b/packages/api/src/tools/index.ts @@ -1 +1,2 @@ export * from './format'; +export * from './toolkits'; diff --git a/packages/api/src/tools/toolkits/index.ts b/packages/api/src/tools/toolkits/index.ts new file mode 100644 index 000000000..33807c673 --- /dev/null +++ b/packages/api/src/tools/toolkits/index.ts @@ -0,0 +1,2 @@ +export * from './oai'; +export * from './yt'; diff --git a/packages/api/src/tools/toolkits/oai.ts b/packages/api/src/tools/toolkits/oai.ts new file mode 100644 index 000000000..0881a0148 --- /dev/null +++ b/packages/api/src/tools/toolkits/oai.ts @@ -0,0 +1,153 @@ +import { z } from 'zod'; + +/** Default descriptions for image generation tool */ +const DEFAULT_IMAGE_GEN_DESCRIPTION = + `Generates high-quality, original images based solely on text, not using any uploaded reference images. + +When to use \`image_gen_oai\`: +- To create entirely new images from detailed text descriptions that do NOT reference any image files. + +When NOT to use \`image_gen_oai\`: +- If the user has uploaded any images and requests modifications, enhancements, or remixing based on those uploads → use \`image_edit_oai\` instead. + +Generated image IDs will be returned in the response, so you can refer to them in future requests made to \`image_edit_oai\`.` as const; + +const getImageGenDescription = () => { + return process.env.IMAGE_GEN_OAI_DESCRIPTION || DEFAULT_IMAGE_GEN_DESCRIPTION; +}; + +/** Default prompt descriptions */ +const DEFAULT_IMAGE_GEN_PROMPT_DESCRIPTION = `Describe the image you want in detail. + Be highly specific—break your idea into layers: + (1) main concept and subject, + (2) composition and position, + (3) lighting and mood, + (4) style, medium, or camera details, + (5) important features (age, expression, clothing, etc.), + (6) background. + Use positive, descriptive language and specify what should be included, not what to avoid. + List number and characteristics of people/objects, and mention style/technical requirements (e.g., "DSLR photo, 85mm lens, golden hour"). + Do not reference any uploaded images—use for new image creation from text only.` as const; + +const getImageGenPromptDescription = () => { + return process.env.IMAGE_GEN_OAI_PROMPT_DESCRIPTION || DEFAULT_IMAGE_GEN_PROMPT_DESCRIPTION; +}; + +/** Default description for image editing tool */ +const DEFAULT_IMAGE_EDIT_DESCRIPTION = + `Generates high-quality, original images based on text and one or more uploaded/referenced images. + +When to use \`image_edit_oai\`: +- The user wants to modify, extend, or remix one **or more** uploaded images, either: +- Previously generated, or in the current request (both to be included in the \`image_ids\` array). +- Always when the user refers to uploaded images for editing, enhancement, remixing, style transfer, or combining elements. +- Any current or existing images are to be used as visual guides. +- If there are any files in the current request, they are more likely than not expected as references for image edit requests. + +When NOT to use \`image_edit_oai\`: +- Brand-new generations that do not rely on an existing image → use \`image_gen_oai\` instead. + +Both generated and referenced image IDs will be returned in the response, so you can refer to them in future requests made to \`image_edit_oai\`. +`.trim(); + +const getImageEditDescription = () => { + return process.env.IMAGE_EDIT_OAI_DESCRIPTION || DEFAULT_IMAGE_EDIT_DESCRIPTION; +}; + +const DEFAULT_IMAGE_EDIT_PROMPT_DESCRIPTION = `Describe the changes, enhancements, or new ideas to apply to the uploaded image(s). + Be highly specific—break your request into layers: + (1) main concept or transformation, + (2) specific edits/replacements or composition guidance, + (3) desired style, mood, or technique, + (4) features/items to keep, change, or add (such as objects, people, clothing, lighting, etc.). + Use positive, descriptive language and clarify what should be included or changed, not what to avoid. + Always base this prompt on the most recently uploaded reference images.`; + +const getImageEditPromptDescription = () => { + return process.env.IMAGE_EDIT_OAI_PROMPT_DESCRIPTION || DEFAULT_IMAGE_EDIT_PROMPT_DESCRIPTION; +}; + +export const oaiToolkit = { + image_gen_oai: { + name: 'image_gen_oai' as const, + description: getImageGenDescription(), + schema: z.object({ + prompt: z.string().max(32000).describe(getImageGenPromptDescription()), + background: z + .enum(['transparent', 'opaque', 'auto']) + .optional() + .describe( + 'Sets transparency for the background. Must be one of transparent, opaque or auto (default). When transparent, the output format should be png or webp.', + ), + /* + n: z + .number() + .int() + .min(1) + .max(10) + .optional() + .describe('The number of images to generate. Must be between 1 and 10.'), + output_compression: z + .number() + .int() + .min(0) + .max(100) + .optional() + .describe('The compression level (0-100%) for webp or jpeg formats. Defaults to 100.'), + */ + quality: z + .enum(['auto', 'high', 'medium', 'low']) + .optional() + .describe('The quality of the image. One of auto (default), high, medium, or low.'), + size: z + .enum(['auto', '1024x1024', '1536x1024', '1024x1536']) + .optional() + .describe( + 'The size of the generated image. One of 1024x1024, 1536x1024 (landscape), 1024x1536 (portrait), or auto (default).', + ), + }), + responseFormat: 'content_and_artifact' as const, + } as const, + image_edit_oai: { + name: 'image_edit_oai' as const, + description: getImageEditDescription(), + schema: z.object({ + image_ids: z + .array(z.string()) + .min(1) + .describe( + ` +IDs (image ID strings) of previously generated or uploaded images that should guide the edit. + +Guidelines: +- If the user's request depends on any prior image(s), copy their image IDs into the \`image_ids\` array (in the same order the user refers to them). +- Never invent or hallucinate IDs; only use IDs that are still visible in the conversation context. +- If no earlier image is relevant, omit the field entirely. +`.trim(), + ), + prompt: z.string().max(32000).describe(getImageEditPromptDescription()), + /* + n: z + .number() + .int() + .min(1) + .max(10) + .optional() + .describe('The number of images to generate. Must be between 1 and 10. Defaults to 1.'), + */ + quality: z + .enum(['auto', 'high', 'medium', 'low']) + .optional() + .describe( + 'The quality of the image. One of auto (default), high, medium, or low. High/medium/low only supported for gpt-image-1.', + ), + size: z + .enum(['auto', '1024x1024', '1536x1024', '1024x1536', '256x256', '512x512']) + .optional() + .describe( + 'The size of the generated images. For gpt-image-1: auto (default), 1024x1024, 1536x1024, 1024x1536. For dall-e-2: 256x256, 512x512, 1024x1024.', + ), + }), + responseFormat: 'content_and_artifact' as const, + }, +} as const; diff --git a/packages/api/src/tools/toolkits/yt.ts b/packages/api/src/tools/toolkits/yt.ts new file mode 100644 index 000000000..7185a260d --- /dev/null +++ b/packages/api/src/tools/toolkits/yt.ts @@ -0,0 +1,61 @@ +import { z } from 'zod'; +export const ytToolkit = { + youtube_search: { + name: 'youtube_search' as const, + description: `Search for YouTube videos by keyword or phrase. +- Required: query (search terms to find videos) +- Optional: maxResults (number of videos to return, 1-50, default: 5) +- Returns: List of videos with titles, descriptions, and URLs +- Use for: Finding specific videos, exploring content, research +Example: query="cooking pasta tutorials" maxResults=3` as const, + schema: z.object({ + query: z.string().describe('Search query terms'), + maxResults: z.number().int().min(1).max(50).optional().describe('Number of results (1-50)'), + }), + }, + youtube_info: { + name: 'youtube_info' as const, + description: `Get detailed metadata and statistics for a specific YouTube video. +- Required: url (full YouTube URL or video ID) +- Returns: Video title, description, view count, like count, comment count +- Use for: Getting video metrics and basic metadata +- DO NOT USE FOR VIDEO SUMMARIES, USE TRANSCRIPTS FOR COMPREHENSIVE ANALYSIS +- Accepts both full URLs and video IDs +Example: url="https://youtube.com/watch?v=abc123" or url="abc123"` as const, + schema: z.object({ + url: z.string().describe('YouTube video URL or ID'), + }), + } as const, + youtube_comments: { + name: 'youtube_comments', + description: `Retrieve top-level comments from a YouTube video. +- Required: url (full YouTube URL or video ID) +- Optional: maxResults (number of comments, 1-50, default: 10) +- Returns: Comment text, author names, like counts +- Use for: Sentiment analysis, audience feedback, engagement review +Example: url="abc123" maxResults=20`, + schema: z.object({ + url: z.string().describe('YouTube video URL or ID'), + maxResults: z + .number() + .int() + .min(1) + .max(50) + .optional() + .describe('Number of comments to retrieve'), + }), + } as const, + youtube_transcript: { + name: 'youtube_transcript', + description: `Fetch and parse the transcript/captions of a YouTube video. +- Required: url (full YouTube URL or video ID) +- Returns: Full video transcript as plain text +- Use for: Content analysis, summarization, translation reference +- This is the "Go-to" tool for analyzing actual video content +- Attempts to fetch English first, then German, then any available language +Example: url="https://youtube.com/watch?v=abc123"`, + schema: z.object({ + url: z.string().describe('YouTube video URL or ID'), + }), + } as const, +} as const; diff --git a/packages/api/src/types/config.ts b/packages/api/src/types/config.ts new file mode 100644 index 000000000..7a9ef2611 --- /dev/null +++ b/packages/api/src/types/config.ts @@ -0,0 +1,90 @@ +import type { + TEndpoint, + FileSources, + TAzureConfig, + TCustomConfig, + TMemoryConfig, + EModelEndpoint, + TAgentsEndpoint, + TCustomEndpoints, + TAssistantEndpoint, +} from 'librechat-data-provider'; +import type { FunctionTool } from './tools'; + +/** + * Application configuration object + * Based on the configuration defined in api/server/services/Config/getAppConfig.js + */ +export interface AppConfig { + /** The main custom configuration */ + config: TCustomConfig; + /** OCR configuration */ + ocr?: TCustomConfig['ocr']; + /** File paths configuration */ + paths: { + uploads: string; + imageOutput: string; + publicPath: string; + [key: string]: string; + }; + /** Memory configuration */ + memory?: TMemoryConfig; + /** Web search configuration */ + webSearch?: TCustomConfig['webSearch']; + /** File storage strategy ('local', 's3', 'firebase', 'azure_blob') */ + fileStrategy: FileSources.local | FileSources.s3 | FileSources.firebase | FileSources.azure_blob; + /** File strategies configuration */ + fileStrategies: TCustomConfig['fileStrategies']; + /** Registration configurations */ + registration?: TCustomConfig['registration']; + /** Actions configurations */ + actions?: TCustomConfig['actions']; + /** Admin-filtered tools */ + filteredTools?: string[]; + /** Admin-included tools */ + includedTools?: string[]; + /** Image output type configuration */ + imageOutputType: string; + /** Interface configuration */ + interfaceConfig?: TCustomConfig['interface']; + /** Turnstile configuration */ + turnstileConfig?: TCustomConfig['turnstile']; + /** Balance configuration */ + balance?: TCustomConfig['balance']; + /** Speech configuration */ + speech?: TCustomConfig['speech']; + /** MCP server configuration */ + mcpConfig?: TCustomConfig['mcpServers'] | null; + /** File configuration */ + fileConfig?: TCustomConfig['fileConfig']; + /** Secure image links configuration */ + secureImageLinks?: TCustomConfig['secureImageLinks']; + /** Processed model specifications */ + modelSpecs?: TCustomConfig['modelSpecs']; + /** Available tools */ + availableTools?: Record; + endpoints?: { + /** OpenAI endpoint configuration */ + openAI?: TEndpoint; + /** Google endpoint configuration */ + google?: TEndpoint; + /** Bedrock endpoint configuration */ + bedrock?: TEndpoint; + /** Anthropic endpoint configuration */ + anthropic?: TEndpoint; + /** GPT plugins endpoint configuration */ + gptPlugins?: TEndpoint; + /** Azure OpenAI endpoint configuration */ + azureOpenAI?: TAzureConfig; + /** Assistants endpoint configuration */ + assistants?: TAssistantEndpoint; + /** Azure assistants endpoint configuration */ + azureAssistants?: TAssistantEndpoint; + /** Agents endpoint configuration */ + [EModelEndpoint.agents]?: TAgentsEndpoint; + /** Custom endpoints configuration */ + [EModelEndpoint.custom]?: TCustomEndpoints; + /** Global endpoint configuration */ + all?: TEndpoint; + }; +} diff --git a/packages/api/src/types/endpoints.ts b/packages/api/src/types/endpoints.ts new file mode 100644 index 000000000..c16691518 --- /dev/null +++ b/packages/api/src/types/endpoints.ts @@ -0,0 +1,3 @@ +import type { TConfig } from 'librechat-data-provider'; + +export type TCustomEndpointsConfig = Partial<{ [key: string]: Omit }>; diff --git a/packages/api/src/types/index.ts b/packages/api/src/types/index.ts index b38f16e70..ff2d6cf69 100644 --- a/packages/api/src/types/index.ts +++ b/packages/api/src/types/index.ts @@ -1,5 +1,7 @@ +export * from './config'; export * from './azure'; export * from './balance'; +export * from './endpoints'; export * from './events'; export * from './error'; export * from './google'; @@ -8,4 +10,5 @@ export * from './mistral'; export * from './openai'; export * from './prompts'; export * from './run'; +export * from './tools'; export * from './zod'; diff --git a/packages/api/src/types/openai.ts b/packages/api/src/types/openai.ts index b90618051..8953283a3 100644 --- a/packages/api/src/types/openai.ts +++ b/packages/api/src/types/openai.ts @@ -4,6 +4,7 @@ import type { TEndpointOption, TAzureConfig, TEndpoint } from 'librechat-data-pr import type { BindToolsInput } from '@langchain/core/language_models/chat_models'; import type { OpenAIClientOptions, Providers } from '@librechat/agents'; import type { AzureOptions } from './azure'; +import type { AppConfig } from './config'; export type OpenAIParameters = z.infer; @@ -86,6 +87,7 @@ export type CheckUserKeyExpiryFunction = (expiresAt: string, endpoint: string) = */ export interface InitializeOpenAIOptionsParams { req: RequestData; + appConfig: AppConfig; overrideModel?: string; overrideEndpoint?: string; endpointOption: Partial; diff --git a/packages/api/src/types/tools.ts b/packages/api/src/types/tools.ts new file mode 100644 index 000000000..591c10da8 --- /dev/null +++ b/packages/api/src/types/tools.ts @@ -0,0 +1,10 @@ +import type { JsonSchemaType } from './zod'; + +export interface FunctionTool { + type: 'function'; + function: { + description: string; + name: string; + parameters: JsonSchemaType; + }; +} diff --git a/packages/api/src/utils/common.ts b/packages/api/src/utils/common.ts index 693c00d48..f82e8dd21 100644 --- a/packages/api/src/utils/common.ts +++ b/packages/api/src/utils/common.ts @@ -1,3 +1,6 @@ +import { Providers } from '@librechat/agents'; +import { AuthType } from 'librechat-data-provider'; + /** * Checks if the given value is truthy by being either the boolean `true` or a string * that case-insensitively matches 'true'. @@ -31,7 +34,7 @@ export function isEnabled(value?: string | boolean | null | undefined): boolean * @param value - The value to check. * @returns - Returns true if the value is 'user_provided', otherwise false. */ -export const isUserProvided = (value?: string): boolean => value === 'user_provided'; +export const isUserProvided = (value?: string): boolean => value === AuthType.USER_PROVIDED; /** * @param values @@ -46,3 +49,11 @@ export function optionalChainWithEmptyCheck( } return values[values.length - 1]; } + +/** + * Normalize the endpoint name to system-expected value. + * @param name + */ +export function normalizeEndpointName(name = ''): string { + return name.toLowerCase() === Providers.OLLAMA ? Providers.OLLAMA : name; +} diff --git a/packages/api/src/utils/email.ts b/packages/api/src/utils/email.ts new file mode 100644 index 000000000..f98e7c51b --- /dev/null +++ b/packages/api/src/utils/email.ts @@ -0,0 +1,16 @@ +/** + * Check if email configuration is set + * @returns Returns `true` if either Mailgun or SMTP is properly configured + */ +export function checkEmailConfig(): boolean { + const hasMailgunConfig = + !!process.env.MAILGUN_API_KEY && !!process.env.MAILGUN_DOMAIN && !!process.env.EMAIL_FROM; + + const hasSMTPConfig = + (!!process.env.EMAIL_SERVICE || !!process.env.EMAIL_HOST) && + !!process.env.EMAIL_USERNAME && + !!process.env.EMAIL_PASSWORD && + !!process.env.EMAIL_FROM; + + return hasMailgunConfig || hasSMTPConfig; +} diff --git a/packages/api/src/utils/index.ts b/packages/api/src/utils/index.ts index a513425c7..2ce0381af 100644 --- a/packages/api/src/utils/index.ts +++ b/packages/api/src/utils/index.ts @@ -1,6 +1,7 @@ export * from './axios'; export * from './azure'; export * from './common'; +export * from './email'; export * from './env'; export * from './events'; export * from './files'; diff --git a/packages/api/src/utils/tempChatRetention.spec.ts b/packages/api/src/utils/tempChatRetention.spec.ts index b0166e952..8e924183c 100644 --- a/packages/api/src/utils/tempChatRetention.spec.ts +++ b/packages/api/src/utils/tempChatRetention.spec.ts @@ -1,11 +1,11 @@ +import type { AppConfig } from '~/types'; import { + createTempChatExpirationDate, + getTempChatRetentionHours, + DEFAULT_RETENTION_HOURS, MIN_RETENTION_HOURS, MAX_RETENTION_HOURS, - DEFAULT_RETENTION_HOURS, - getTempChatRetentionHours, - createTempChatExpirationDate, } from './tempChatRetention'; -import type { TCustomConfig } from 'librechat-data-provider'; describe('tempChatRetention', () => { const originalEnv = process.env; @@ -33,43 +33,43 @@ describe('tempChatRetention', () => { }); it('should use config value when set', () => { - const config: Partial = { - interface: { + const config: Partial = { + interfaceConfig: { temporaryChatRetention: 12, }, }; - const result = getTempChatRetentionHours(config); + const result = getTempChatRetentionHours(config?.interfaceConfig); expect(result).toBe(12); }); it('should prioritize config over environment variable', () => { process.env.TEMP_CHAT_RETENTION_HOURS = '48'; - const config: Partial = { - interface: { + const config: Partial = { + interfaceConfig: { temporaryChatRetention: 12, }, }; - const result = getTempChatRetentionHours(config); + const result = getTempChatRetentionHours(config?.interfaceConfig); expect(result).toBe(12); }); it('should enforce minimum retention period', () => { - const config: Partial = { - interface: { + const config: Partial = { + interfaceConfig: { temporaryChatRetention: 0, }, }; - const result = getTempChatRetentionHours(config); + const result = getTempChatRetentionHours(config?.interfaceConfig); expect(result).toBe(MIN_RETENTION_HOURS); }); it('should enforce maximum retention period', () => { - const config: Partial = { - interface: { + const config: Partial = { + interfaceConfig: { temporaryChatRetention: 10000, }, }; - const result = getTempChatRetentionHours(config); + const result = getTempChatRetentionHours(config?.interfaceConfig); expect(result).toBe(MAX_RETENTION_HOURS); }); @@ -80,12 +80,12 @@ describe('tempChatRetention', () => { }); it('should handle invalid config value', () => { - const config: Partial = { - interface: { + const config: Partial = { + interfaceConfig: { temporaryChatRetention: 'invalid' as unknown as number, }, }; - const result = getTempChatRetentionHours(config); + const result = getTempChatRetentionHours(config?.interfaceConfig); expect(result).toBe(DEFAULT_RETENTION_HOURS); }); }); @@ -103,13 +103,13 @@ describe('tempChatRetention', () => { }); it('should create expiration date with custom retention period', () => { - const config: Partial = { - interface: { + const config: Partial = { + interfaceConfig: { temporaryChatRetention: 12, }, }; - const result = createTempChatExpirationDate(config); + const result = createTempChatExpirationDate(config?.interfaceConfig); const expectedDate = new Date(); expectedDate.setHours(expectedDate.getHours() + 12); diff --git a/packages/api/src/utils/tempChatRetention.ts b/packages/api/src/utils/tempChatRetention.ts index 6683b4c6a..6b7868189 100644 --- a/packages/api/src/utils/tempChatRetention.ts +++ b/packages/api/src/utils/tempChatRetention.ts @@ -1,5 +1,5 @@ import { logger } from '@librechat/data-schemas'; -import type { TCustomConfig } from 'librechat-data-provider'; +import type { AppConfig } from '~/types'; /** * Default retention period for temporary chats in hours @@ -18,10 +18,12 @@ export const MAX_RETENTION_HOURS = 8760; /** * Gets the temporary chat retention period from environment variables or config - * @param config - The custom configuration object + * @param interfaceConfig - The custom configuration object * @returns The retention period in hours */ -export function getTempChatRetentionHours(config?: Partial | null): number { +export function getTempChatRetentionHours( + interfaceConfig?: AppConfig['interfaceConfig'] | null, +): number { let retentionHours = DEFAULT_RETENTION_HOURS; // Check environment variable first @@ -37,8 +39,8 @@ export function getTempChatRetentionHours(config?: Partial | null } // Check config file (takes precedence over environment variable) - if (config?.interface?.temporaryChatRetention !== undefined) { - const configValue = config.interface.temporaryChatRetention; + if (interfaceConfig?.temporaryChatRetention !== undefined) { + const configValue = interfaceConfig.temporaryChatRetention; if (typeof configValue === 'number' && !isNaN(configValue)) { retentionHours = configValue; } else { @@ -66,11 +68,11 @@ export function getTempChatRetentionHours(config?: Partial | null /** * Creates an expiration date for temporary chats - * @param config - The custom configuration object + * @param interfaceConfig - The custom configuration object * @returns The expiration date */ -export function createTempChatExpirationDate(config?: Partial): Date { - const retentionHours = getTempChatRetentionHours(config); +export function createTempChatExpirationDate(interfaceConfig?: AppConfig['interfaceConfig']): Date { + const retentionHours = getTempChatRetentionHours(interfaceConfig); const expiredAt = new Date(); expiredAt.setHours(expiredAt.getHours() + retentionHours); return expiredAt; diff --git a/packages/data-provider/src/api-endpoints.ts b/packages/data-provider/src/api-endpoints.ts index 6c3c2b4f9..3b3608f63 100644 --- a/packages/data-provider/src/api-endpoints.ts +++ b/packages/data-provider/src/api-endpoints.ts @@ -104,8 +104,6 @@ export const deletePreset = () => '/api/presets/delete'; export const aiEndpoints = () => '/api/endpoints'; -export const endpointsConfigOverride = () => '/api/endpoints/config/override'; - export const models = () => '/api/models'; export const tokenizer = () => '/api/tokenizer'; diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 963f983fa..533081479 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -300,6 +300,7 @@ export const endpointSchema = baseEndpointSchema.merge( }), summarize: z.boolean().optional(), summaryModel: z.string().optional(), + iconURL: z.string().optional(), forcePrompt: z.boolean().optional(), modelDisplayLabel: z.string().optional(), headers: z.record(z.any()).optional(), @@ -789,6 +790,8 @@ export const memorySchema = z.object({ export type TMemoryConfig = z.infer; +const customEndpointsSchema = z.array(endpointSchema.partial()).optional(); + export const configSchema = z.object({ version: z.string(), cache: z.boolean().default(true), @@ -837,7 +840,7 @@ export const configSchema = z.object({ [EModelEndpoint.azureAssistants]: assistantEndpointSchema.optional(), [EModelEndpoint.assistants]: assistantEndpointSchema.optional(), [EModelEndpoint.agents]: agentsEndpointSchema.optional(), - [EModelEndpoint.custom]: z.array(endpointSchema.partial()).optional(), + [EModelEndpoint.custom]: customEndpointsSchema.optional(), [EModelEndpoint.bedrock]: baseEndpointSchema.optional(), }) .strict() @@ -850,6 +853,7 @@ export const configSchema = z.object({ export const getConfigDefaults = () => getSchemaDefaults(configSchema); export type TCustomConfig = z.infer; +export type TCustomEndpoints = z.infer; export type TProviderSchema = | z.infer @@ -1213,14 +1217,14 @@ export enum CacheKeys { * Key for the static config namespace. */ STATIC_CONFIG = 'STATIC_CONFIG', + /** + * Key for the app config namespace. + */ + APP_CONFIG = 'APP_CONFIG', /** * Key for accessing Abort Keys */ ABORT_KEYS = 'ABORT_KEYS', - /** - * Key for the override config cache. - */ - OVERRIDE_CONFIG = 'OVERRIDE_CONFIG', /** * Key for the bans cache. */ diff --git a/packages/data-provider/src/data-service.ts b/packages/data-provider/src/data-service.ts index 817074856..bdcd93311 100644 --- a/packages/data-provider/src/data-service.ts +++ b/packages/data-provider/src/data-service.ts @@ -182,10 +182,6 @@ export const getModels = async (): Promise => { return request.get(endpoints.models()); }; -export const getEndpointsConfigOverride = (): Promise => { - return request.get(endpoints.endpointsConfigOverride()); -}; - /* Assistants */ export const createAssistant = ({ diff --git a/packages/data-provider/src/keys.ts b/packages/data-provider/src/keys.ts index fda75a389..79d1fa38d 100644 --- a/packages/data-provider/src/keys.ts +++ b/packages/data-provider/src/keys.ts @@ -21,7 +21,6 @@ export enum QueryKeys { assistant = 'assistant', agents = 'agents', agent = 'agent', - endpointsConfigOverride = 'endpointsConfigOverride', files = 'files', fileConfig = 'fileConfig', tools = 'tools', diff --git a/packages/data-schemas/src/types/role.ts b/packages/data-schemas/src/types/role.ts index 5441847b9..b1cb4b87c 100644 --- a/packages/data-schemas/src/types/role.ts +++ b/packages/data-schemas/src/types/role.ts @@ -47,6 +47,9 @@ export interface IRole extends Document { [PermissionTypes.FILE_SEARCH]?: { [Permissions.USE]?: boolean; }; + [PermissionTypes.FILE_CITATIONS]?: { + [Permissions.USE]?: boolean; + }; }; }