🛜 refactor: Streamline App Config Usage (#9234)

* WIP: app.locals refactoring

WIP: appConfig

fix: update memory configuration retrieval to use getAppConfig based on user role

fix: update comment for AppConfig interface to clarify purpose

🏷️ refactor: Update tests to use getAppConfig for endpoint configurations

ci: Update AppService tests to initialize app config instead of app.locals

ci: Integrate getAppConfig into remaining tests

refactor: Update multer storage destination to use promise-based getAppConfig and improve error handling in tests

refactor: Rename initializeAppConfig to setAppConfig and update related tests

ci: Mock getAppConfig in various tests to provide default configurations

refactor: Update convertMCPToolsToPlugins to use mcpManager for server configuration and adjust related tests

chore: rename `Config/getAppConfig` -> `Config/app`

fix: streamline OpenAI image tools configuration by removing direct appConfig dependency and using function parameters

chore: correct parameter documentation for imageOutputType in ToolService.js

refactor: remove `getCustomConfig` dependency in config route

refactor: update domain validation to use appConfig for allowed domains

refactor: use appConfig registration property

chore: remove app parameter from AppService invocation

refactor: update AppConfig interface to correct registration and turnstile configurations

refactor: remove getCustomConfig dependency and use getAppConfig in PluginController, multer, and MCP services

refactor: replace getCustomConfig with getAppConfig in STTService, TTSService, and related files

refactor: replace getCustomConfig with getAppConfig in Conversation and Message models, update tempChatRetention functions to use AppConfig type

refactor: update getAppConfig calls in Conversation and Message models to include user role for temporary chat expiration

ci: update related tests

refactor: update getAppConfig call in getCustomConfigSpeech to include user role

fix: update appConfig usage to access allowedDomains from actions instead of registration

refactor: enhance AppConfig to include fileStrategies and update related file strategy logic

refactor: update imports to use normalizeEndpointName from @librechat/api and remove redundant definitions

chore: remove deprecated unused RunManager

refactor: get balance config primarily from appConfig

refactor: remove customConfig dependency for appConfig and streamline loadConfigModels logic

refactor: remove getCustomConfig usage and use app config in file citations

refactor: consolidate endpoint loading logic into loadEndpoints function

refactor: update appConfig access to use endpoints structure across various services

refactor: implement custom endpoints configuration and streamline endpoint loading logic

refactor: update getAppConfig call to include user role parameter

refactor: streamline endpoint configuration and enhance appConfig usage across services

refactor: replace getMCPAuthMap with getUserMCPAuthMap and remove unused getCustomConfig file

refactor: add type annotation for loadedEndpoints in loadEndpoints function

refactor: move /services/Files/images/parse to TS API

chore: add missing FILE_CITATIONS permission to IRole interface

refactor: restructure toolkits to TS API

refactor: separate manifest logic into its own module

refactor: consolidate tool loading logic into a new tools module for startup logic

refactor: move interface config logic to TS API

refactor: migrate checkEmailConfig to TypeScript and update imports

refactor: add FunctionTool interface and availableTools to AppConfig

refactor: decouple caching and DB operations from AppService, make part of consolidated `getAppConfig`

WIP: fix tests

* fix: rebase conflicts

* refactor: remove app.locals references

* refactor: replace getBalanceConfig with getAppConfig in various strategies and middleware

* refactor: replace appConfig?.balance with getBalanceConfig in various controllers and clients

* test: add balance configuration to titleConvo method in AgentClient tests

* chore: remove unused `openai-chat-tokens` package

* chore: remove unused imports in initializeMCPs.js

* refactor: update balance configuration to use getAppConfig instead of getBalanceConfig

* refactor: integrate configMiddleware for centralized configuration handling

* refactor: optimize email domain validation by removing unnecessary async calls

* refactor: simplify multer storage configuration by removing async calls

* refactor: reorder imports for better readability in user.js

* refactor: replace getAppConfig calls with req.config for improved performance

* chore: replace getAppConfig calls with req.config in tests for centralized configuration handling

* chore: remove unused override config

* refactor: add configMiddleware to endpoint route and replace getAppConfig with req.config

* chore: remove customConfig parameter from TTSService constructor

* refactor: pass appConfig from request to processFileCitations for improved configuration handling

* refactor: remove configMiddleware from endpoint route and retrieve appConfig directly in getEndpointsConfig if not in `req.config`

* test: add mockAppConfig to processFileCitations tests for improved configuration handling

* fix: pass req.config to hasCustomUserVars and call without await after synchronous refactor

* fix: type safety in useExportConversation

* refactor: retrieve appConfig using getAppConfig in PluginController and remove configMiddleware from plugins route, to avoid always retrieving when plugins are cached

* chore: change `MongoUser` typedef to `IUser`

* fix: Add `user` and `config` fields to ServerRequest and update JSDoc type annotations from Express.Request to ServerRequest

* fix: remove unused setAppConfig mock from Server configuration tests
This commit is contained in:
Danny Avila 2025-08-26 12:10:18 -04:00 committed by GitHub
parent e1ad235f17
commit 9a210971f5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
210 changed files with 4102 additions and 3465 deletions

View file

@ -1,5 +1,7 @@
const crypto = require('crypto'); const crypto = require('crypto');
const fetch = require('node-fetch'); const fetch = require('node-fetch');
const { logger } = require('@librechat/data-schemas');
const { getBalanceConfig } = require('@librechat/api');
const { const {
supportsBalanceCheck, supportsBalanceCheck,
isAgentsEndpoint, isAgentsEndpoint,
@ -15,7 +17,6 @@ const { checkBalance } = require('~/models/balanceMethods');
const { truncateToolCallOutputs } = require('./prompts'); const { truncateToolCallOutputs } = require('./prompts');
const { getFiles } = require('~/models/File'); const { getFiles } = require('~/models/File');
const TextStream = require('./TextStream'); const TextStream = require('./TextStream');
const { logger } = require('~/config');
class BaseClient { class BaseClient {
constructor(apiKey, options = {}) { 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. * 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. * Should only be used if `recordCollectedUsage` was not used instead.
* @param {string} [model] * @param {string} [model]
* @param {AppConfig['balance']} [balance]
* @param {number} promptTokens * @param {number} promptTokens
* @param {number} completionTokens * @param {number} completionTokens
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
async recordTokenUsage({ model, promptTokens, completionTokens }) { async recordTokenUsage({ model, balance, promptTokens, completionTokens }) {
logger.debug('[BaseClient] `recordTokenUsage` not implemented.', { logger.debug('[BaseClient] `recordTokenUsage` not implemented.', {
model, model,
balance,
promptTokens, promptTokens,
completionTokens, completionTokens,
}); });
@ -571,6 +574,7 @@ class BaseClient {
} }
async sendMessage(message, opts = {}) { async sendMessage(message, opts = {}) {
const appConfig = this.options.req?.config;
/** @type {Promise<TMessage>} */ /** @type {Promise<TMessage>} */
let userMessagePromise; let userMessagePromise;
const { user, head, isEdited, conversationId, responseMessageId, saveOptions, userMessage } = 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 ( if (
balance?.enabled && balanceConfig?.enabled &&
supportsBalanceCheck[this.options.endpointType ?? this.options.endpoint] supportsBalanceCheck[this.options.endpointType ?? this.options.endpoint]
) { ) {
await checkBalance({ await checkBalance({
@ -758,6 +762,7 @@ class BaseClient {
usage, usage,
promptTokens, promptTokens,
completionTokens, completionTokens,
balance: balanceConfig,
model: responseMessage.model, model: responseMessage.model,
}); });
} }

View file

@ -36,11 +36,11 @@ const { encodeAndFormat } = require('~/server/services/Files/images/encode');
const { addSpaceIfNeeded, sleep } = require('~/server/utils'); const { addSpaceIfNeeded, sleep } = require('~/server/utils');
const { spendTokens } = require('~/models/spendTokens'); const { spendTokens } = require('~/models/spendTokens');
const { handleOpenAIErrors } = require('./tools/util'); const { handleOpenAIErrors } = require('./tools/util');
const { createLLM, RunManager } = require('./llm');
const { summaryBuffer } = require('./memory'); const { summaryBuffer } = require('./memory');
const { runTitleChain } = require('./chains'); const { runTitleChain } = require('./chains');
const { tokenSplit } = require('./document'); const { tokenSplit } = require('./document');
const BaseClient = require('./BaseClient'); const BaseClient = require('./BaseClient');
const { createLLM } = require('./llm');
const { logger } = require('~/config'); const { logger } = require('~/config');
class OpenAIClient extends BaseClient { class OpenAIClient extends BaseClient {
@ -618,10 +618,6 @@ class OpenAIClient extends BaseClient {
temperature = 0.2, temperature = 0.2,
max_tokens, max_tokens,
streaming, streaming,
context,
tokenBuffer,
initialMessageCount,
conversationId,
}) { }) {
const modelOptions = { const modelOptions = {
modelName: modelName ?? model, modelName: modelName ?? model,
@ -666,22 +662,12 @@ class OpenAIClient extends BaseClient {
configOptions.httpsAgent = new HttpsProxyAgent(this.options.proxy); 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({ const llm = createLLM({
modelOptions, modelOptions,
configOptions, configOptions,
openAIApiKey: this.apiKey, openAIApiKey: this.apiKey,
azure: this.azure, azure: this.azure,
streaming, streaming,
callbacks: runManager.createCallbacks({
context,
tokenBuffer,
conversationId: this.conversationId ?? conversationId,
initialMessageCount,
}),
}); });
return llm; return llm;
@ -702,6 +688,7 @@ class OpenAIClient extends BaseClient {
* In case of failure, it will return the default title, "New Chat". * In case of failure, it will return the default title, "New Chat".
*/ */
async titleConvo({ text, conversationId, responseText = '' }) { async titleConvo({ text, conversationId, responseText = '' }) {
const appConfig = this.options.req?.config;
this.conversationId = conversationId; this.conversationId = conversationId;
if (this.options.attachments) { if (this.options.attachments) {
@ -730,8 +717,7 @@ class OpenAIClient extends BaseClient {
max_tokens: 16, max_tokens: 16,
}; };
/** @type {TAzureConfig | undefined} */ const azureConfig = appConfig?.endpoints?.[EModelEndpoint.azureOpenAI];
const azureConfig = this.options?.req?.app?.locals?.[EModelEndpoint.azureOpenAI];
const resetTitleOptions = !!( const resetTitleOptions = !!(
(this.azure && azureConfig) || (this.azure && azureConfig) ||
@ -1120,6 +1106,7 @@ ${convo}
} }
async chatCompletion({ payload, onProgress, abortController = null }) { async chatCompletion({ payload, onProgress, abortController = null }) {
const appConfig = this.options.req?.config;
let error = null; let error = null;
let intermediateReply = []; let intermediateReply = [];
const errorCallback = (err) => (error = err); const errorCallback = (err) => (error = err);
@ -1165,8 +1152,7 @@ ${convo}
opts.fetchOptions.agent = new HttpsProxyAgent(this.options.proxy); opts.fetchOptions.agent = new HttpsProxyAgent(this.options.proxy);
} }
/** @type {TAzureConfig | undefined} */ const azureConfig = appConfig?.endpoints?.[EModelEndpoint.azureOpenAI];
const azureConfig = this.options?.req?.app?.locals?.[EModelEndpoint.azureOpenAI];
if ( if (
(this.azure && this.isVisionModel && azureConfig) || (this.azure && this.isVisionModel && azureConfig) ||

View file

@ -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;

View file

@ -1,5 +0,0 @@
const createStartHandler = require('./createStartHandler');
module.exports = {
createStartHandler,
};

View file

@ -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;

View file

@ -1,9 +1,7 @@
const createLLM = require('./createLLM'); const createLLM = require('./createLLM');
const RunManager = require('./RunManager');
const createCoherePayload = require('./createCoherePayload'); const createCoherePayload = require('./createCoherePayload');
module.exports = { module.exports = {
createLLM, createLLM,
RunManager,
createCoherePayload, createCoherePayload,
}; };

View file

@ -2,6 +2,14 @@ const { Constants } = require('librechat-data-provider');
const { initializeFakeClient } = require('./FakeClient'); const { initializeFakeClient } = require('./FakeClient');
jest.mock('~/db/connect'); 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', () => ({ jest.mock('~/models', () => ({
User: jest.fn(), User: jest.fn(),
Key: jest.fn(), Key: jest.fn(),

View file

@ -1,4 +1,4 @@
const availableTools = require('./manifest.json'); const manifest = require('./manifest');
// Structured Tools // Structured Tools
const DALLE3 = require('./structured/DALLE3'); const DALLE3 = require('./structured/DALLE3');
@ -13,23 +13,8 @@ const TraversaalSearch = require('./structured/TraversaalSearch');
const createOpenAIImageTools = require('./structured/OpenAIImageTools'); const createOpenAIImageTools = require('./structured/OpenAIImageTools');
const TavilySearchResults = require('./structured/TavilySearchResults'); const TavilySearchResults = require('./structured/TavilySearchResults');
/** @type {Record<string, TPlugin | undefined>} */
const manifestToolMap = {};
/** @type {Array<TPlugin>} */
const toolkits = [];
availableTools.forEach((tool) => {
manifestToolMap[tool.pluginKey] = tool;
if (tool.toolkit === true) {
toolkits.push(tool);
}
});
module.exports = { module.exports = {
toolkits, ...manifest,
availableTools,
manifestToolMap,
// Structured Tools // Structured Tools
DALLE3, DALLE3,
FluxAPI, FluxAPI,

View file

@ -0,0 +1,20 @@
const availableTools = require('./manifest.json');
/** @type {Record<string, TPlugin | undefined>} */
const manifestToolMap = {};
/** @type {Array<TPlugin>} */
const toolkits = [];
availableTools.forEach((tool) => {
manifestToolMap[tool.pluginKey] = tool;
if (tool.toolkit === true) {
toolkits.push(tool);
}
});
module.exports = {
toolkits,
availableTools,
manifestToolMap,
};

View file

@ -5,10 +5,10 @@ const fetch = require('node-fetch');
const { v4: uuidv4 } = require('uuid'); const { v4: uuidv4 } = require('uuid');
const { ProxyAgent } = require('undici'); const { ProxyAgent } = require('undici');
const { Tool } = require('@langchain/core/tools'); 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 { FileContext, ContentTypes } = require('librechat-data-provider');
const { getImageBasename } = require('~/server/services/Files/images');
const extractBaseURL = require('~/utils/extractBaseURL'); const extractBaseURL = require('~/utils/extractBaseURL');
const logger = require('~/config/winston');
const displayMessage = 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."; "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.";

View file

@ -1,69 +1,16 @@
const { z } = require('zod');
const axios = require('axios'); const axios = require('axios');
const { v4 } = require('uuid'); const { v4 } = require('uuid');
const OpenAI = require('openai'); const OpenAI = require('openai');
const FormData = require('form-data'); const FormData = require('form-data');
const { ProxyAgent } = require('undici'); const { ProxyAgent } = require('undici');
const { tool } = require('@langchain/core/tools'); const { tool } = require('@langchain/core/tools');
const { logAxiosError } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas'); const { logger } = require('@librechat/data-schemas');
const { logAxiosError, oaiToolkit } = require('@librechat/api');
const { ContentTypes, EImageOutputType } = require('librechat-data-provider'); const { ContentTypes, EImageOutputType } = require('librechat-data-provider');
const { getStrategyFunctions } = require('~/server/services/Files/strategies'); const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { extractBaseURL } = require('~/utils'); const extractBaseURL = require('~/utils/extractBaseURL');
const { getFiles } = require('~/models/File'); 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 specificbreak 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 imagesuse 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 specificbreak 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 = 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."; "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; 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() { function createAbortHandler() {
return function () { return function () {
logger.debug('[ImageGenOAI] Image generation aborted'); 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 {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 {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 * @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<ReturnType<tool>>} - Array of image tools
*/ */
function createOpenAIImageTools(fields = {}) { function createOpenAIImageTools(fields = {}) {
/** @type {boolean} Used to initialize the Tool without necessary variables. */ /** @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.'); throw new Error('This tool is only available for agents.');
} }
const { req } = fields; const { req } = fields;
const imageOutputType = req?.app.locals.imageOutputType || EImageOutputType.PNG; const imageOutputType = fields.imageOutputType || EImageOutputType.PNG;
const appFileStrategy = req?.app.locals.fileStrategy; const appFileStrategy = fields.fileStrategy;
const getApiKey = () => { const getApiKey = () => {
const apiKey = process.env.IMAGE_GEN_OAI_API_KEY ?? ''; const apiKey = process.env.IMAGE_GEN_OAI_API_KEY ?? '';
@ -285,46 +218,7 @@ Error Message: ${error.message}`);
]; ];
return [response, { content, file_ids }]; return [response, { content, file_ids }];
}, },
{ oaiToolkit.image_gen_oai,
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',
},
); );
/** /**
@ -517,48 +411,7 @@ Error Message: ${error.message || 'Unknown error'}`);
} }
} }
}, },
{ oaiToolkit.image_edit_oai,
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',
},
); );
return [imageGenTool, imageEditTool]; return [imageGenTool, imageEditTool];

View file

@ -11,14 +11,14 @@ const paths = require('~/config/paths');
const { logger } = require('~/config'); const { logger } = require('~/config');
const displayMessage = 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 { class StableDiffusionAPI extends Tool {
constructor(fields) { constructor(fields) {
super(); super();
/** @type {string} User ID */ /** @type {string} User ID */
this.userId = fields.userId; 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; this.req = fields.req;
/** @type {boolean} Used to initialize the Tool without necessary variables. */ /** @type {boolean} Used to initialize the Tool without necessary variables. */
this.override = fields.override ?? false; 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" // "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`; // - Generate images only once per human query unless explicitly requested by the user`;
this.description = 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({ this.schema = z.object({
prompt: z prompt: z
.string() .string()

View file

@ -1,9 +1,9 @@
const { z } = require('zod'); const { ytToolkit } = require('@librechat/api');
const { tool } = require('@langchain/core/tools'); const { tool } = require('@langchain/core/tools');
const { youtube } = require('@googleapis/youtube'); const { youtube } = require('@googleapis/youtube');
const { logger } = require('@librechat/data-schemas');
const { YoutubeTranscript } = require('youtube-transcript'); const { YoutubeTranscript } = require('youtube-transcript');
const { getApiKey } = require('./credentials'); const { getApiKey } = require('./credentials');
const { logger } = require('~/config');
function extractVideoId(url) { function extractVideoId(url) {
const rawIdRegex = /^[a-zA-Z0-9_-]{11}$/; const rawIdRegex = /^[a-zA-Z0-9_-]{11}$/;
@ -29,7 +29,7 @@ function parseTranscript(transcriptResponse) {
.map((entry) => entry.text.trim()) .map((entry) => entry.text.trim())
.filter((text) => text) .filter((text) => text)
.join(' ') .join(' ')
.replaceAll('&amp;#39;', '\''); .replaceAll('&amp;#39;', "'");
} }
function createYouTubeTools(fields = {}) { function createYouTubeTools(fields = {}) {
@ -42,8 +42,7 @@ function createYouTubeTools(fields = {}) {
auth: apiKey, auth: apiKey,
}); });
const searchTool = tool( const searchTool = tool(async ({ query, maxResults = 5 }) => {
async ({ query, maxResults = 5 }) => {
const response = await youtubeClient.search.list({ const response = await youtubeClient.search.list({
part: 'snippet', part: 'snippet',
q: query, q: query,
@ -56,24 +55,9 @@ function createYouTubeTools(fields = {}) {
url: `https://www.youtube.com/watch?v=${item.id.videoId}`, url: `https://www.youtube.com/watch?v=${item.id.videoId}`,
})); }));
return JSON.stringify(result, null, 2); return JSON.stringify(result, null, 2);
}, }, ytToolkit.youtube_search);
{
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 infoTool = tool( const infoTool = tool(async ({ url }) => {
async ({ url }) => {
const videoId = extractVideoId(url); const videoId = extractVideoId(url);
if (!videoId) { if (!videoId) {
throw new Error('Invalid YouTube URL or video ID'); throw new Error('Invalid YouTube URL or video ID');
@ -97,24 +81,9 @@ Example: query="cooking pasta tutorials" maxResults=3`,
comments: video.statistics.commentCount, comments: video.statistics.commentCount,
}; };
return JSON.stringify(result, null, 2); return JSON.stringify(result, null, 2);
}, }, ytToolkit.youtube_info);
{
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 commentsTool = tool( const commentsTool = tool(async ({ url, maxResults = 10 }) => {
async ({ url, maxResults = 10 }) => {
const videoId = extractVideoId(url); const videoId = extractVideoId(url);
if (!videoId) { if (!videoId) {
throw new Error('Invalid YouTube URL or video ID'); throw new Error('Invalid YouTube URL or video ID');
@ -132,30 +101,9 @@ Example: url="https://youtube.com/watch?v=abc123" or url="abc123"`,
likes: item.snippet.topLevelComment.snippet.likeCount, likes: item.snippet.topLevelComment.snippet.likeCount,
})); }));
return JSON.stringify(result, null, 2); return JSON.stringify(result, null, 2);
}, }, ytToolkit.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'),
}),
},
);
const transcriptTool = tool( const transcriptTool = tool(async ({ url }) => {
async ({ url }) => {
const videoId = extractVideoId(url); const videoId = extractVideoId(url);
if (!videoId) { if (!videoId) {
throw new Error('Invalid YouTube URL or video ID'); throw new Error('Invalid YouTube URL or video ID');
@ -181,21 +129,7 @@ Example: url="abc123" maxResults=20`,
} catch (error) { } catch (error) {
throw new Error(`Failed to fetch transcript: ${error.message}`); throw new Error(`Failed to fetch transcript: ${error.message}`);
} }
}, }, ytToolkit.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'),
}),
},
);
return [searchTool, infoTool, commentsTool, transcriptTool]; return [searchTool, infoTool, commentsTool, transcriptTool];
} }

View file

@ -1,43 +1,9 @@
const DALLE3 = require('../DALLE3'); const DALLE3 = require('../DALLE3');
const { ProxyAgent } = require('undici'); const { ProxyAgent } = require('undici');
jest.mock('tiktoken');
const processFileURL = jest.fn(); 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', () => { describe('DALLE3 Proxy Configuration', () => {
let originalEnv; let originalEnv;

View file

@ -1,9 +1,8 @@
const OpenAI = require('openai'); const OpenAI = require('openai');
const { logger } = require('@librechat/data-schemas');
const DALLE3 = require('../DALLE3'); const DALLE3 = require('../DALLE3');
const logger = require('~/config/winston');
jest.mock('openai'); jest.mock('openai');
jest.mock('@librechat/data-schemas', () => { jest.mock('@librechat/data-schemas', () => {
return { return {
logger: { logger: {
@ -26,25 +25,6 @@ jest.mock('tiktoken', () => {
const processFileURL = jest.fn(); 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(); const generate = jest.fn();
OpenAI.mockImplementation(() => ({ OpenAI.mockImplementation(() => ({
images: { images: {

View file

@ -121,18 +121,21 @@ const getAuthFields = (toolKey) => {
/** /**
* *
* @param {object} object * @param {object} params
* @param {string} object.user * @param {string} params.user
* @param {Record<string, Record<string, string>>} [object.userMCPAuthMap] * @param {Record<string, Record<string, string>>} [object.userMCPAuthMap]
* @param {AbortSignal} [object.signal] * @param {AbortSignal} [object.signal]
* @param {Pick<Agent, 'id' | 'provider' | 'model'>} [object.agent] * @param {Pick<Agent, 'id' | 'provider' | 'model'>} [params.agent]
* @param {string} [object.model] * @param {string} [params.model]
* @param {EModelEndpoint} [object.endpoint] * @param {EModelEndpoint} [params.endpoint]
* @param {LoadToolOptions} [object.options] * @param {LoadToolOptions} [params.options]
* @param {boolean} [object.useSpecs] * @param {boolean} [params.useSpecs]
* @param {Array<string>} object.tools * @param {Array<string>} params.tools
* @param {boolean} [object.functions] * @param {boolean} [params.functions]
* @param {boolean} [object.returnMap] * @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<string, any> } | Record<string,Tool>>} * @returns {Promise<{ loadedTools: Tool[], toolContextMap: Object<string, any> } | Record<string,Tool>>}
*/ */
const loadTools = async ({ const loadTools = async ({
@ -146,6 +149,9 @@ const loadTools = async ({
options = {}, options = {},
functions = true, functions = true,
returnMap = false, returnMap = false,
webSearch,
fileStrategy,
imageOutputType,
}) => { }) => {
const toolConstructors = { const toolConstructors = {
flux: FluxAPI, flux: FluxAPI,
@ -204,6 +210,8 @@ const loadTools = async ({
...authValues, ...authValues,
isAgent: !!agent, isAgent: !!agent,
req: options.req, req: options.req,
imageOutputType,
fileStrategy,
imageFiles, imageFiles,
}); });
}, },
@ -219,7 +227,7 @@ const loadTools = async ({
const imageGenOptions = { const imageGenOptions = {
isAgent: !!agent, isAgent: !!agent,
req: options.req, req: options.req,
fileStrategy: options.fileStrategy, fileStrategy,
processFileURL: options.processFileURL, processFileURL: options.processFileURL,
returnMetadata: options.returnMetadata, returnMetadata: options.returnMetadata,
uploadImageBuffer: options.uploadImageBuffer, uploadImageBuffer: options.uploadImageBuffer,
@ -277,11 +285,10 @@ const loadTools = async ({
}; };
continue; continue;
} else if (tool === Tools.web_search) { } else if (tool === Tools.web_search) {
const webSearchConfig = options?.req?.app?.locals?.webSearch;
const result = await loadWebSearchAuth({ const result = await loadWebSearchAuth({
userId: user, userId: user,
loadAuthValues, loadAuthValues,
webSearchConfig, webSearchConfig: webSearch,
}); });
const { onSearchResults, onGetHighlights } = options?.[Tools.web_search] ?? {}; const { onSearchResults, onGetHighlights } = options?.[Tools.web_search] ?? {};
requestedTools[tool] = async () => { requestedTools[tool] = async () => {

View file

@ -9,6 +9,27 @@ const mockPluginService = {
jest.mock('~/server/services/PluginService', () => 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 { BaseLLM } = require('@langchain/openai');
const { Calculator } = require('@langchain/community/tools/calculator'); const { Calculator } = require('@langchain/community/tools/calculator');

View file

@ -681,7 +681,7 @@ const getListAgents = async (searchParameter) => {
* This function also updates the corresponding projects to include or exclude the agent ID. * 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 {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.agentId - The ID of the agent to update.
* @param {string[]} [params.projectIds] - Array of project IDs to add to the agent. * @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. * @param {string[]} [params.removeProjectIds] - Array of project IDs to remove from the agent.

View file

@ -1,6 +1,5 @@
const { logger } = require('@librechat/data-schemas'); const { logger } = require('@librechat/data-schemas');
const { createTempChatExpirationDate } = require('@librechat/api'); const { createTempChatExpirationDate } = require('@librechat/api');
const { getCustomConfig } = require('~/server/services/Config/getCustomConfig');
const { getMessages, deleteMessages } = require('./Message'); const { getMessages, deleteMessages } = require('./Message');
const { Conversation } = require('~/db/models'); const { Conversation } = require('~/db/models');
@ -102,8 +101,8 @@ module.exports = {
if (req?.body?.isTemporary) { if (req?.body?.isTemporary) {
try { try {
const customConfig = await getCustomConfig(); const appConfig = req.config;
update.expiredAt = createTempChatExpirationDate(customConfig); update.expiredAt = createTempChatExpirationDate(appConfig?.interfaceConfig);
} catch (err) { } catch (err) {
logger.error('Error creating temporary chat expiration date:', err); logger.error('Error creating temporary chat expiration date:', err);
logger.info(`---\`saveConvo\` context: ${metadata?.context}`); logger.info(`---\`saveConvo\` context: ${metadata?.context}`);

View file

@ -13,9 +13,8 @@ const {
saveConvo, saveConvo,
getConvo, getConvo,
} = require('./Conversation'); } = require('./Conversation');
jest.mock('~/server/services/Config/getCustomConfig'); jest.mock('~/server/services/Config/app');
jest.mock('./Message'); jest.mock('./Message');
const { getCustomConfig } = require('~/server/services/Config/getCustomConfig');
const { getMessages, deleteMessages } = require('./Message'); const { getMessages, deleteMessages } = require('./Message');
const { Conversation } = require('~/db/models'); const { Conversation } = require('~/db/models');
@ -50,6 +49,11 @@ describe('Conversation Operations', () => {
mockReq = { mockReq = {
user: { id: 'user123' }, user: { id: 'user123' },
body: {}, body: {},
config: {
interfaceConfig: {
temporaryChatRetention: 24, // Default 24 hours
},
},
}; };
mockConversationData = { mockConversationData = {
@ -118,12 +122,8 @@ describe('Conversation Operations', () => {
describe('isTemporary conversation handling', () => { describe('isTemporary conversation handling', () => {
it('should save a conversation with expiredAt when isTemporary is true', async () => { it('should save a conversation with expiredAt when isTemporary is true', async () => {
// Mock custom config with 24 hour retention // Mock app config with 24 hour retention
getCustomConfig.mockResolvedValue({ mockReq.config.interfaceConfig.temporaryChatRetention = 24;
interface: {
temporaryChatRetention: 24,
},
});
mockReq.body = { isTemporary: true }; mockReq.body = { isTemporary: true };
@ -167,12 +167,8 @@ describe('Conversation Operations', () => {
}); });
it('should use custom retention period from config', async () => { it('should use custom retention period from config', async () => {
// Mock custom config with 48 hour retention // Mock app config with 48 hour retention
getCustomConfig.mockResolvedValue({ mockReq.config.interfaceConfig.temporaryChatRetention = 48;
interface: {
temporaryChatRetention: 48,
},
});
mockReq.body = { isTemporary: true }; mockReq.body = { isTemporary: true };
@ -194,12 +190,8 @@ describe('Conversation Operations', () => {
}); });
it('should handle minimum retention period (1 hour)', async () => { it('should handle minimum retention period (1 hour)', async () => {
// Mock custom config with less than minimum retention // Mock app config with less than minimum retention
getCustomConfig.mockResolvedValue({ mockReq.config.interfaceConfig.temporaryChatRetention = 0.5; // Half hour - should be clamped to 1 hour
interface: {
temporaryChatRetention: 0.5, // Half hour - should be clamped to 1 hour
},
});
mockReq.body = { isTemporary: true }; mockReq.body = { isTemporary: true };
@ -221,12 +213,8 @@ describe('Conversation Operations', () => {
}); });
it('should handle maximum retention period (8760 hours)', async () => { it('should handle maximum retention period (8760 hours)', async () => {
// Mock custom config with more than maximum retention // Mock app config with more than maximum retention
getCustomConfig.mockResolvedValue({ mockReq.config.interfaceConfig.temporaryChatRetention = 10000; // Should be clamped to 8760 hours
interface: {
temporaryChatRetention: 10000, // Should be clamped to 8760 hours
},
});
mockReq.body = { isTemporary: true }; mockReq.body = { isTemporary: true };
@ -247,22 +235,36 @@ describe('Conversation Operations', () => {
); );
}); });
it('should handle getCustomConfig errors gracefully', async () => { it('should handle missing config gracefully', async () => {
// Mock getCustomConfig to throw an error // Simulate missing config - should use default retention period
getCustomConfig.mockRejectedValue(new Error('Config service unavailable')); delete mockReq.config;
mockReq.body = { isTemporary: true }; mockReq.body = { isTemporary: true };
const beforeSave = new Date();
const result = await saveConvo(mockReq, mockConversationData); 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.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 () => { it('should use default retention when config is not provided', async () => {
// Mock getCustomConfig to return empty config // Mock getAppConfig to return empty config
getCustomConfig.mockResolvedValue({}); mockReq.config = {}; // Empty config
mockReq.body = { isTemporary: true }; mockReq.body = { isTemporary: true };
@ -285,11 +287,7 @@ describe('Conversation Operations', () => {
it('should update expiredAt when saving existing temporary conversation', async () => { it('should update expiredAt when saving existing temporary conversation', async () => {
// First save a temporary conversation // First save a temporary conversation
getCustomConfig.mockResolvedValue({ mockReq.config.interfaceConfig.temporaryChatRetention = 24;
interface: {
temporaryChatRetention: 24,
},
});
mockReq.body = { isTemporary: true }; mockReq.body = { isTemporary: true };
const firstSave = await saveConvo(mockReq, mockConversationData); const firstSave = await saveConvo(mockReq, mockConversationData);

View file

@ -1,7 +1,6 @@
const { z } = require('zod'); const { z } = require('zod');
const { logger } = require('@librechat/data-schemas'); const { logger } = require('@librechat/data-schemas');
const { createTempChatExpirationDate } = require('@librechat/api'); const { createTempChatExpirationDate } = require('@librechat/api');
const { getCustomConfig } = require('~/server/services/Config/getCustomConfig');
const { Message } = require('~/db/models'); const { Message } = require('~/db/models');
const idSchema = z.string().uuid(); const idSchema = z.string().uuid();
@ -11,7 +10,7 @@ const idSchema = z.string().uuid();
* *
* @async * @async
* @function saveMessage * @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 {Object} params - The message data object.
* @param {string} params.endpoint - The endpoint where the message originated. * @param {string} params.endpoint - The endpoint where the message originated.
* @param {string} params.iconURL - The URL of the sender's icon. * @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) { if (req?.body?.isTemporary) {
try { try {
const customConfig = await getCustomConfig(); const appConfig = req.config;
update.expiredAt = createTempChatExpirationDate(customConfig); update.expiredAt = createTempChatExpirationDate(appConfig?.interfaceConfig);
} catch (err) { } catch (err) {
logger.error('Error creating temporary chat expiration date:', err); logger.error('Error creating temporary chat expiration date:', err);
logger.info(`---\`saveMessage\` context: ${metadata?.context}`); logger.info(`---\`saveMessage\` context: ${metadata?.context}`);

View file

@ -13,8 +13,7 @@ const {
deleteMessagesSince, deleteMessagesSince,
} = require('./Message'); } = require('./Message');
jest.mock('~/server/services/Config/getCustomConfig'); jest.mock('~/server/services/Config/app');
const { getCustomConfig } = require('~/server/services/Config/getCustomConfig');
/** /**
* @type {import('mongoose').Model<import('@librechat/data-schemas').IMessage>} * @type {import('mongoose').Model<import('@librechat/data-schemas').IMessage>}
@ -44,6 +43,11 @@ describe('Message Operations', () => {
mockReq = { mockReq = {
user: { id: 'user123' }, user: { id: 'user123' },
config: {
interfaceConfig: {
temporaryChatRetention: 24, // Default 24 hours
},
},
}; };
mockMessageData = { mockMessageData = {
@ -326,12 +330,8 @@ describe('Message Operations', () => {
}); });
it('should save a message with expiredAt when isTemporary is true', async () => { it('should save a message with expiredAt when isTemporary is true', async () => {
// Mock custom config with 24 hour retention // Mock app config with 24 hour retention
getCustomConfig.mockResolvedValue({ mockReq.config.interfaceConfig.temporaryChatRetention = 24;
interface: {
temporaryChatRetention: 24,
},
});
mockReq.body = { isTemporary: true }; mockReq.body = { isTemporary: true };
@ -375,12 +375,8 @@ describe('Message Operations', () => {
}); });
it('should use custom retention period from config', async () => { it('should use custom retention period from config', async () => {
// Mock custom config with 48 hour retention // Mock app config with 48 hour retention
getCustomConfig.mockResolvedValue({ mockReq.config.interfaceConfig.temporaryChatRetention = 48;
interface: {
temporaryChatRetention: 48,
},
});
mockReq.body = { isTemporary: true }; mockReq.body = { isTemporary: true };
@ -402,12 +398,8 @@ describe('Message Operations', () => {
}); });
it('should handle minimum retention period (1 hour)', async () => { it('should handle minimum retention period (1 hour)', async () => {
// Mock custom config with less than minimum retention // Mock app config with less than minimum retention
getCustomConfig.mockResolvedValue({ mockReq.config.interfaceConfig.temporaryChatRetention = 0.5; // Half hour - should be clamped to 1 hour
interface: {
temporaryChatRetention: 0.5, // Half hour - should be clamped to 1 hour
},
});
mockReq.body = { isTemporary: true }; mockReq.body = { isTemporary: true };
@ -429,12 +421,8 @@ describe('Message Operations', () => {
}); });
it('should handle maximum retention period (8760 hours)', async () => { it('should handle maximum retention period (8760 hours)', async () => {
// Mock custom config with more than maximum retention // Mock app config with more than maximum retention
getCustomConfig.mockResolvedValue({ mockReq.config.interfaceConfig.temporaryChatRetention = 10000; // Should be clamped to 8760 hours
interface: {
temporaryChatRetention: 10000, // Should be clamped to 8760 hours
},
});
mockReq.body = { isTemporary: true }; mockReq.body = { isTemporary: true };
@ -455,22 +443,36 @@ describe('Message Operations', () => {
); );
}); });
it('should handle getCustomConfig errors gracefully', async () => { it('should handle missing config gracefully', async () => {
// Mock getCustomConfig to throw an error // Simulate missing config - should use default retention period
getCustomConfig.mockRejectedValue(new Error('Config service unavailable')); delete mockReq.config;
mockReq.body = { isTemporary: true }; mockReq.body = { isTemporary: true };
const beforeSave = new Date();
const result = await saveMessage(mockReq, mockMessageData); 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.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 () => { it('should use default retention when config is not provided', async () => {
// Mock getCustomConfig to return empty config // Mock getAppConfig to return empty config
getCustomConfig.mockResolvedValue({}); mockReq.config = {}; // Empty config
mockReq.body = { isTemporary: true }; mockReq.body = { isTemporary: true };
@ -493,11 +495,7 @@ describe('Message Operations', () => {
it('should not update expiredAt on message update', async () => { it('should not update expiredAt on message update', async () => {
// First save a temporary message // First save a temporary message
getCustomConfig.mockResolvedValue({ mockReq.config.interfaceConfig.temporaryChatRetention = 24;
interface: {
temporaryChatRetention: 24,
},
});
mockReq.body = { isTemporary: true }; mockReq.body = { isTemporary: true };
const savedMessage = await saveMessage(mockReq, mockMessageData); const savedMessage = await saveMessage(mockReq, mockMessageData);
@ -520,11 +518,7 @@ describe('Message Operations', () => {
it('should preserve expiredAt when saving existing temporary message', async () => { it('should preserve expiredAt when saving existing temporary message', async () => {
// First save a temporary message // First save a temporary message
getCustomConfig.mockResolvedValue({ mockReq.config.interfaceConfig.temporaryChatRetention = 24;
interface: {
temporaryChatRetention: 24,
},
});
mockReq.body = { isTemporary: true }; mockReq.body = { isTemporary: true };
const firstSave = await saveMessage(mockReq, mockMessageData); const firstSave = await saveMessage(mockReq, mockMessageData);

View file

@ -1,5 +1,4 @@
const { logger } = require('@librechat/data-schemas'); const { logger } = require('@librechat/data-schemas');
const { getBalanceConfig } = require('~/server/services/Config');
const { getMultiplier, getCacheMultiplier } = require('./tx'); const { getMultiplier, getCacheMultiplier } = require('./tx');
const { Transaction, Balance } = require('~/db/models'); const { Transaction, Balance } = require('~/db/models');
@ -187,9 +186,10 @@ async function createAutoRefillTransaction(txData) {
/** /**
* Static method to create a transaction and update the balance * 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)) { if (txData.rawAmount != null && isNaN(txData.rawAmount)) {
return; return;
} }
@ -199,8 +199,6 @@ async function createTransaction(txData) {
calculateTokenValue(transaction); calculateTokenValue(transaction);
await transaction.save(); await transaction.save();
const balance = await getBalanceConfig();
if (!balance?.enabled) { if (!balance?.enabled) {
return; return;
} }
@ -221,9 +219,10 @@ async function createTransaction(txData) {
/** /**
* Static method to create a structured transaction and update the balance * 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({ const transaction = new Transaction({
...txData, ...txData,
endpointTokenConfig: txData.endpointTokenConfig, endpointTokenConfig: txData.endpointTokenConfig,
@ -233,7 +232,6 @@ async function createStructuredTransaction(txData) {
await transaction.save(); await transaction.save();
const balance = await getBalanceConfig();
if (!balance?.enabled) { if (!balance?.enabled) {
return; return;
} }

View file

@ -1,14 +1,11 @@
const mongoose = require('mongoose'); const mongoose = require('mongoose');
const { MongoMemoryServer } = require('mongodb-memory-server'); const { MongoMemoryServer } = require('mongodb-memory-server');
const { spendTokens, spendStructuredTokens } = require('./spendTokens'); const { spendTokens, spendStructuredTokens } = require('./spendTokens');
const { getBalanceConfig } = require('~/server/services/Config');
const { getMultiplier, getCacheMultiplier } = require('./tx'); const { getMultiplier, getCacheMultiplier } = require('./tx');
const { createTransaction } = require('./Transaction'); const { createTransaction } = require('./Transaction');
const { Balance } = require('~/db/models'); const { Balance } = require('~/db/models');
// Mock the custom config module so we can control the balance flag.
jest.mock('~/server/services/Config');
let mongoServer; let mongoServer;
beforeAll(async () => { beforeAll(async () => {
mongoServer = await MongoMemoryServer.create(); mongoServer = await MongoMemoryServer.create();
@ -23,8 +20,6 @@ afterAll(async () => {
beforeEach(async () => { beforeEach(async () => {
await mongoose.connection.dropDatabase(); await mongoose.connection.dropDatabase();
// Default: enable balance updates in tests.
getBalanceConfig.mockResolvedValue({ enabled: true });
}); });
describe('Regular Token Spending Tests', () => { describe('Regular Token Spending Tests', () => {
@ -41,6 +36,7 @@ describe('Regular Token Spending Tests', () => {
model, model,
context: 'test', context: 'test',
endpointTokenConfig: null, endpointTokenConfig: null,
balance: { enabled: true },
}; };
const tokenUsage = { const tokenUsage = {
@ -74,6 +70,7 @@ describe('Regular Token Spending Tests', () => {
model, model,
context: 'test', context: 'test',
endpointTokenConfig: null, endpointTokenConfig: null,
balance: { enabled: true },
}; };
const tokenUsage = { const tokenUsage = {
@ -104,6 +101,7 @@ describe('Regular Token Spending Tests', () => {
model, model,
context: 'test', context: 'test',
endpointTokenConfig: null, endpointTokenConfig: null,
balance: { enabled: true },
}; };
const tokenUsage = {}; const tokenUsage = {};
@ -128,6 +126,7 @@ describe('Regular Token Spending Tests', () => {
model, model,
context: 'test', context: 'test',
endpointTokenConfig: null, endpointTokenConfig: null,
balance: { enabled: true },
}; };
const tokenUsage = { promptTokens: 100 }; 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 () => { test('spendTokens should not update balance when balance feature is disabled', async () => {
// Arrange: Override the config to disable balance updates. // Arrange: Balance config is now passed directly in txData
getBalanceConfig.mockResolvedValue({ balance: { enabled: false } });
const userId = new mongoose.Types.ObjectId(); const userId = new mongoose.Types.ObjectId();
const initialBalance = 10000000; const initialBalance = 10000000;
await Balance.create({ user: userId, tokenCredits: initialBalance }); await Balance.create({ user: userId, tokenCredits: initialBalance });
@ -156,6 +154,7 @@ describe('Regular Token Spending Tests', () => {
model, model,
context: 'test', context: 'test',
endpointTokenConfig: null, endpointTokenConfig: null,
balance: { enabled: false },
}; };
const tokenUsage = { const tokenUsage = {
@ -186,6 +185,7 @@ describe('Structured Token Spending Tests', () => {
model, model,
context: 'message', context: 'message',
endpointTokenConfig: null, endpointTokenConfig: null,
balance: { enabled: true },
}; };
const tokenUsage = { const tokenUsage = {
@ -239,6 +239,7 @@ describe('Structured Token Spending Tests', () => {
conversationId: 'test-convo', conversationId: 'test-convo',
model, model,
context: 'message', context: 'message',
balance: { enabled: true },
}; };
const tokenUsage = { const tokenUsage = {
@ -271,6 +272,7 @@ describe('Structured Token Spending Tests', () => {
conversationId: 'test-convo', conversationId: 'test-convo',
model, model,
context: 'message', context: 'message',
balance: { enabled: true },
}; };
const tokenUsage = { const tokenUsage = {
@ -302,6 +304,7 @@ describe('Structured Token Spending Tests', () => {
conversationId: 'test-convo', conversationId: 'test-convo',
model, model,
context: 'message', context: 'message',
balance: { enabled: true },
}; };
const tokenUsage = {}; const tokenUsage = {};
@ -328,6 +331,7 @@ describe('Structured Token Spending Tests', () => {
conversationId: 'test-convo', conversationId: 'test-convo',
model, model,
context: 'incomplete', context: 'incomplete',
balance: { enabled: true },
}; };
const tokenUsage = { const tokenUsage = {
@ -364,6 +368,7 @@ describe('NaN Handling Tests', () => {
endpointTokenConfig: null, endpointTokenConfig: null,
rawAmount: NaN, rawAmount: NaN,
tokenType: 'prompt', tokenType: 'prompt',
balance: { enabled: true },
}; };
// Act // Act

View file

@ -118,7 +118,7 @@ const addIntervalToDate = (date, value, unit) => {
* @async * @async
* @function * @function
* @param {Object} params - The function parameters. * @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 {Express.Response} params.res - The Express response object.
* @param {Object} params.txData - The transaction data. * @param {Object} params.txData - The transaction data.
* @param {string} params.txData.user - The user ID or identifier. * @param {string} params.txData.user - The user ID or identifier.

View file

@ -24,8 +24,15 @@ const { getConvoTitle, getConvo, saveConvo, deleteConvos } = require('./Conversa
const { getPreset, getPresets, savePreset, deletePresets } = require('./Preset'); const { getPreset, getPresets, savePreset, deletePresets } = require('./Preset');
const { File } = require('~/db/models'); const { File } = require('~/db/models');
const seedDatabase = async () => {
await methods.initializeRoles();
await methods.seedDefaultRoles();
await methods.ensureDefaultCategories();
};
module.exports = { module.exports = {
...methods, ...methods,
seedDatabase,
comparePassword, comparePassword,
findFileById, findFileById,
createFile, createFile,

24
api/models/interface.js Normal file
View file

@ -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,
};

View file

@ -5,13 +5,7 @@ const { createTransaction, createStructuredTransaction } = require('./Transactio
* *
* @function * @function
* @async * @async
* @param {Object} txData - Transaction data. * @param {txData} 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 {Object} tokenUsage - The number of tokens used. * @param {Object} tokenUsage - The number of tokens used.
* @param {Number} tokenUsage.promptTokens - The number of prompt tokens used. * @param {Number} tokenUsage.promptTokens - The number of prompt tokens used.
* @param {Number} tokenUsage.completionTokens - The number of completion tokens used. * @param {Number} tokenUsage.completionTokens - The number of completion tokens used.
@ -69,13 +63,7 @@ const spendTokens = async (txData, tokenUsage) => {
* *
* @function * @function
* @async * @async
* @param {Object} txData - Transaction data. * @param {txData} 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 {Object} tokenUsage - The number of tokens used. * @param {Object} tokenUsage - The number of tokens used.
* @param {Object} tokenUsage.promptTokens - The number of prompt tokens used. * @param {Object} tokenUsage.promptTokens - The number of prompt tokens used.
* @param {Number} tokenUsage.promptTokens.input - The number of input tokens. * @param {Number} tokenUsage.promptTokens.input - The number of input tokens.

View file

@ -5,7 +5,6 @@ const { createTransaction, createAutoRefillTransaction } = require('./Transactio
require('~/db/models'); require('~/db/models');
// Mock the logger to prevent console output during tests
jest.mock('~/config', () => ({ jest.mock('~/config', () => ({
logger: { logger: {
debug: jest.fn(), 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', () => { describe('spendTokens', () => {
let mongoServer; let mongoServer;
let userId; let userId;
@ -44,8 +39,7 @@ describe('spendTokens', () => {
// Create a new user ID for each test // Create a new user ID for each test
userId = new mongoose.Types.ObjectId(); userId = new mongoose.Types.ObjectId();
// Mock the balance config to be enabled by default // Balance config is now passed directly in txData
getBalanceConfig.mockResolvedValue({ enabled: true });
}); });
it('should create transactions for both prompt and completion tokens', async () => { it('should create transactions for both prompt and completion tokens', async () => {
@ -60,6 +54,7 @@ describe('spendTokens', () => {
conversationId: 'test-convo', conversationId: 'test-convo',
model: 'gpt-3.5-turbo', model: 'gpt-3.5-turbo',
context: 'test', context: 'test',
balance: { enabled: true },
}; };
const tokenUsage = { const tokenUsage = {
promptTokens: 100, promptTokens: 100,
@ -98,6 +93,7 @@ describe('spendTokens', () => {
conversationId: 'test-convo', conversationId: 'test-convo',
model: 'gpt-3.5-turbo', model: 'gpt-3.5-turbo',
context: 'test', context: 'test',
balance: { enabled: true },
}; };
const tokenUsage = { const tokenUsage = {
promptTokens: 100, promptTokens: 100,
@ -127,6 +123,7 @@ describe('spendTokens', () => {
conversationId: 'test-convo', conversationId: 'test-convo',
model: 'gpt-3.5-turbo', model: 'gpt-3.5-turbo',
context: 'test', context: 'test',
balance: { enabled: true },
}; };
const tokenUsage = {}; const tokenUsage = {};
@ -138,8 +135,7 @@ describe('spendTokens', () => {
}); });
it('should not update balance when the balance feature is disabled', async () => { it('should not update balance when the balance feature is disabled', async () => {
// Override configuration: disable balance updates // Balance is now passed directly in txData
getBalanceConfig.mockResolvedValue({ enabled: false });
// Create a balance for the user // Create a balance for the user
await Balance.create({ await Balance.create({
user: userId, user: userId,
@ -151,6 +147,7 @@ describe('spendTokens', () => {
conversationId: 'test-convo', conversationId: 'test-convo',
model: 'gpt-3.5-turbo', model: 'gpt-3.5-turbo',
context: 'test', context: 'test',
balance: { enabled: false },
}; };
const tokenUsage = { const tokenUsage = {
promptTokens: 100, promptTokens: 100,
@ -180,6 +177,7 @@ describe('spendTokens', () => {
conversationId: 'test-convo', conversationId: 'test-convo',
model: 'gpt-4', // Using a more expensive model model: 'gpt-4', // Using a more expensive model
context: 'test', context: 'test',
balance: { enabled: true },
}; };
// Spending more tokens than the user has balance for // Spending more tokens than the user has balance for
@ -233,6 +231,7 @@ describe('spendTokens', () => {
conversationId: 'test-convo-1', conversationId: 'test-convo-1',
model: 'gpt-4', model: 'gpt-4',
context: 'test', context: 'test',
balance: { enabled: true },
}; };
const tokenUsage1 = { const tokenUsage1 = {
@ -252,6 +251,7 @@ describe('spendTokens', () => {
conversationId: 'test-convo-2', conversationId: 'test-convo-2',
model: 'gpt-4', model: 'gpt-4',
context: 'test', context: 'test',
balance: { enabled: true },
}; };
const tokenUsage2 = { const tokenUsage2 = {
@ -292,6 +292,7 @@ describe('spendTokens', () => {
tokenType: 'completion', tokenType: 'completion',
rawAmount: -100, rawAmount: -100,
context: 'test', context: 'test',
balance: { enabled: true },
}); });
console.log('Direct Transaction.create result:', directResult); console.log('Direct Transaction.create result:', directResult);
@ -316,6 +317,7 @@ describe('spendTokens', () => {
conversationId: `test-convo-${model}`, conversationId: `test-convo-${model}`,
model, model,
context: 'test', context: 'test',
balance: { enabled: true },
}; };
const tokenUsage = { const tokenUsage = {
@ -352,6 +354,7 @@ describe('spendTokens', () => {
conversationId: 'test-convo-1', conversationId: 'test-convo-1',
model: 'claude-3-5-sonnet', model: 'claude-3-5-sonnet',
context: 'test', context: 'test',
balance: { enabled: true },
}; };
const tokenUsage1 = { const tokenUsage1 = {
@ -375,6 +378,7 @@ describe('spendTokens', () => {
conversationId: 'test-convo-2', conversationId: 'test-convo-2',
model: 'claude-3-5-sonnet', model: 'claude-3-5-sonnet',
context: 'test', context: 'test',
balance: { enabled: true },
}; };
const tokenUsage2 = { const tokenUsage2 = {
@ -426,6 +430,7 @@ describe('spendTokens', () => {
conversationId: 'test-convo', conversationId: 'test-convo',
model: 'claude-3-5-sonnet', // Using a model that supports structured tokens model: 'claude-3-5-sonnet', // Using a model that supports structured tokens
context: 'test', context: 'test',
balance: { enabled: true },
}; };
// Spending more tokens than the user has balance for // Spending more tokens than the user has balance for
@ -505,6 +510,7 @@ describe('spendTokens', () => {
conversationId, conversationId,
user: userId, user: userId,
model: usage.model, model: usage.model,
balance: { enabled: true },
}; };
// Calculate expected spend for this transaction // Calculate expected spend for this transaction
@ -617,6 +623,7 @@ describe('spendTokens', () => {
tokenType: 'credits', tokenType: 'credits',
context: 'concurrent-refill-test', context: 'concurrent-refill-test',
rawAmount: refillAmount, rawAmount: refillAmount,
balance: { enabled: true },
}), }),
); );
} }
@ -683,6 +690,7 @@ describe('spendTokens', () => {
conversationId: 'test-convo', conversationId: 'test-convo',
model: 'claude-3-5-sonnet', model: 'claude-3-5-sonnet',
context: 'test', context: 'test',
balance: { enabled: true },
}; };
const tokenUsage = { const tokenUsage = {
promptTokens: { promptTokens: {

View file

@ -3,7 +3,7 @@ const bcrypt = require('bcryptjs');
/** /**
* Compares the provided password with the user's password. * 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. * @param {string} candidatePassword - The password to test against the user's password.
* @returns {Promise<boolean>} A promise that resolves to a boolean indicating if the password matches. * @returns {Promise<boolean>} A promise that resolves to a boolean indicating if the password matches.
*/ */

View file

@ -97,7 +97,6 @@
"nodemailer": "^6.9.15", "nodemailer": "^6.9.15",
"ollama": "^0.5.0", "ollama": "^0.5.0",
"openai": "^5.10.1", "openai": "^5.10.1",
"openai-chat-tokens": "^0.2.8",
"openid-client": "^6.5.0", "openid-client": "^6.5.0",
"passport": "^0.6.0", "passport": "^0.6.0",
"passport-apple": "^2.0.2", "passport-apple": "^2.0.2",

View file

@ -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;

View file

@ -7,14 +7,9 @@ const {
convertMCPToolToPlugin, convertMCPToolToPlugin,
convertMCPToolsToPlugins, convertMCPToolsToPlugins,
} = require('@librechat/api'); } = require('@librechat/api');
const { const { getCachedTools, setCachedTools, mergeUserTools } = require('~/server/services/Config');
getCachedTools,
setCachedTools,
mergeUserTools,
getCustomConfig,
} = require('~/server/services/Config');
const { loadAndFormatTools } = require('~/server/services/ToolService');
const { availableTools, toolkits } = require('~/app/clients/tools'); const { availableTools, toolkits } = require('~/app/clients/tools');
const { getAppConfig } = require('~/server/services/Config');
const { getMCPManager } = require('~/config'); const { getMCPManager } = require('~/config');
const { getLogStores } = require('~/cache'); const { getLogStores } = require('~/cache');
@ -27,8 +22,9 @@ const getAvailablePluginsController = async (req, res) => {
return; return;
} }
const appConfig = await getAppConfig({ role: req.user?.role });
/** @type {{ filteredTools: string[], includedTools: string[] }} */ /** @type {{ filteredTools: string[], includedTools: string[] }} */
const { filteredTools = [], includedTools = [] } = req.app.locals; const { filteredTools = [], includedTools = [] } = appConfig;
/** @type {import('@librechat/api').LCManifestTool[]} */ /** @type {import('@librechat/api').LCManifestTool[]} */
const pluginManifest = availableTools; const pluginManifest = availableTools;
@ -74,13 +70,14 @@ const getAvailableTools = async (req, res) => {
logger.warn('[getAvailableTools] User ID not found in request'); logger.warn('[getAvailableTools] User ID not found in request');
return res.status(401).json({ message: 'Unauthorized' }); return res.status(401).json({ message: 'Unauthorized' });
} }
const customConfig = await getCustomConfig();
const cache = getLogStores(CacheKeys.CONFIG_STORE); const cache = getLogStores(CacheKeys.CONFIG_STORE);
const cachedToolsArray = await cache.get(CacheKeys.TOOLS); const cachedToolsArray = await cache.get(CacheKeys.TOOLS);
const cachedUserTools = await getCachedTools({ userId }); const cachedUserTools = await getCachedTools({ userId });
const mcpManager = getMCPManager();
const userPlugins = const userPlugins =
cachedUserTools != null cachedUserTools != null
? convertMCPToolsToPlugins({ functionTools: cachedUserTools, customConfig }) ? convertMCPToolsToPlugins({ functionTools: cachedUserTools, mcpManager })
: undefined; : undefined;
if (cachedToolsArray != null && userPlugins != null) { if (cachedToolsArray != null && userPlugins != null) {
@ -93,28 +90,19 @@ const getAvailableTools = async (req, res) => {
let toolDefinitions = await getCachedTools({ includeGlobal: true }); let toolDefinitions = await getCachedTools({ includeGlobal: true });
let prelimCachedTools; 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[]} */ /** @type {import('@librechat/api').LCManifestTool[]} */
let pluginManifest = availableTools; let pluginManifest = availableTools;
if (customConfig?.mcpServers != null) {
const appConfig = req.config ?? (await getAppConfig({ role: req.user?.role }));
if (appConfig?.mcpConfig != null) {
try { try {
const mcpManager = getMCPManager();
const mcpTools = await mcpManager.getAllToolFunctions(userId); const mcpTools = await mcpManager.getAllToolFunctions(userId);
prelimCachedTools = prelimCachedTools ?? {}; prelimCachedTools = prelimCachedTools ?? {};
for (const [toolKey, toolData] of Object.entries(mcpTools)) { for (const [toolKey, toolData] of Object.entries(mcpTools)) {
const plugin = convertMCPToolToPlugin({ const plugin = convertMCPToolToPlugin({
toolKey, toolKey,
toolData, toolData,
customConfig, mcpManager,
}); });
if (plugin) { if (plugin) {
pluginManifest.push(plugin); pluginManifest.push(plugin);
@ -161,7 +149,7 @@ const getAvailableTools = async (req, res) => {
if (plugin.pluginKey.includes(Constants.mcp_delimiter)) { if (plugin.pluginKey.includes(Constants.mcp_delimiter)) {
const parts = plugin.pluginKey.split(Constants.mcp_delimiter); const parts = plugin.pluginKey.split(Constants.mcp_delimiter);
const serverName = parts[parts.length - 1]; const serverName = parts[parts.length - 1];
const serverConfig = customConfig?.mcpServers?.[serverName]; const serverConfig = appConfig?.mcpConfig?.[serverName];
if (serverConfig?.customUserVars) { if (serverConfig?.customUserVars) {
const customVarKeys = Object.keys(serverConfig.customUserVars); const customVarKeys = Object.keys(serverConfig.customUserVars);

View file

@ -1,30 +1,31 @@
const { Constants } = require('librechat-data-provider'); const { Constants } = require('librechat-data-provider');
const { getCustomConfig, getCachedTools } = require('~/server/services/Config'); const { getCachedTools, getAppConfig } = require('~/server/services/Config');
const { getLogStores } = require('~/cache'); const { getLogStores } = require('~/cache');
// Mock the dependencies
jest.mock('@librechat/data-schemas', () => ({ jest.mock('@librechat/data-schemas', () => ({
logger: { logger: {
debug: jest.fn(), debug: jest.fn(),
error: jest.fn(), error: jest.fn(),
warn: jest.fn(),
}, },
})); }));
jest.mock('~/server/services/Config', () => ({ jest.mock('~/server/services/Config', () => ({
getCustomConfig: jest.fn(),
getCachedTools: jest.fn(), getCachedTools: jest.fn(),
getAppConfig: jest.fn().mockResolvedValue({
filteredTools: [],
includedTools: [],
}),
setCachedTools: jest.fn(), setCachedTools: jest.fn(),
mergeUserTools: jest.fn(), mergeUserTools: jest.fn(),
})); }));
jest.mock('~/server/services/ToolService', () => ({ // loadAndFormatTools mock removed - no longer used in PluginController
getToolkitKey: jest.fn(),
loadAndFormatTools: jest.fn(),
}));
jest.mock('~/config', () => ({ jest.mock('~/config', () => ({
getMCPManager: jest.fn(() => ({ getMCPManager: jest.fn(() => ({
loadAllManifestTools: jest.fn().mockResolvedValue([]), getAllToolFunctions: jest.fn().mockResolvedValue({}),
getRawConfig: jest.fn().mockReturnValue({}),
})), })),
getFlowStateManager: jest.fn(), getFlowStateManager: jest.fn(),
})); }));
@ -39,7 +40,6 @@ jest.mock('~/cache', () => ({
})); }));
const { getAvailableTools, getAvailablePluginsController } = require('./PluginController'); const { getAvailableTools, getAvailablePluginsController } = require('./PluginController');
const { loadAndFormatTools } = require('~/server/services/ToolService');
describe('PluginController', () => { describe('PluginController', () => {
let mockReq, mockRes, mockCache; let mockReq, mockRes, mockCache;
@ -48,12 +48,9 @@ describe('PluginController', () => {
jest.clearAllMocks(); jest.clearAllMocks();
mockReq = { mockReq = {
user: { id: 'test-user-id' }, user: { id: 'test-user-id' },
app: { config: {
locals: { filteredTools: [],
paths: { structuredTools: '/mock/path' }, includedTools: [],
filteredTools: null,
includedTools: null,
},
}, },
}; };
mockRes = { status: jest.fn().mockReturnThis(), json: jest.fn() }; mockRes = { status: jest.fn().mockReturnThis(), json: jest.fn() };
@ -63,13 +60,19 @@ describe('PluginController', () => {
// Clear availableTools and toolkits arrays before each test // Clear availableTools and toolkits arrays before each test
require('~/app/clients/tools').availableTools.length = 0; require('~/app/clients/tools').availableTools.length = 0;
require('~/app/clients/tools').toolkits.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', () => { describe('getAvailablePluginsController', () => {
beforeEach(() => {
mockReq.app = { locals: { filteredTools: [], includedTools: [] } };
});
it('should use filterUniquePlugins to remove duplicate plugins', async () => { it('should use filterUniquePlugins to remove duplicate plugins', async () => {
// Add plugins with duplicates to availableTools // Add plugins with duplicates to availableTools
const mockPlugins = [ const mockPlugins = [
@ -82,10 +85,17 @@ describe('PluginController', () => {
mockCache.get.mockResolvedValue(null); mockCache.get.mockResolvedValue(null);
// Configure getAppConfig to return the expected config
getAppConfig.mockResolvedValueOnce({
filteredTools: [],
includedTools: [],
});
await getAvailablePluginsController(mockReq, mockRes); await getAvailablePluginsController(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(200); expect(mockRes.status).toHaveBeenCalledWith(200);
const responseData = mockRes.json.mock.calls[0][0]; const responseData = mockRes.json.mock.calls[0][0];
// The real filterUniquePlugins should have removed the duplicate
expect(responseData).toHaveLength(2); expect(responseData).toHaveLength(2);
expect(responseData[0].pluginKey).toBe('key1'); expect(responseData[0].pluginKey).toBe('key1');
expect(responseData[1].pluginKey).toBe('key2'); expect(responseData[1].pluginKey).toBe('key2');
@ -99,10 +109,16 @@ describe('PluginController', () => {
require('~/app/clients/tools').availableTools.push(mockPlugin); require('~/app/clients/tools').availableTools.push(mockPlugin);
mockCache.get.mockResolvedValue(null); mockCache.get.mockResolvedValue(null);
// Configure getAppConfig to return the expected config
getAppConfig.mockResolvedValueOnce({
filteredTools: [],
includedTools: [],
});
await getAvailablePluginsController(mockReq, mockRes); await getAvailablePluginsController(mockReq, mockRes);
const responseData = mockRes.json.mock.calls[0][0]; 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(); expect(responseData[0].authenticated).toBeUndefined();
}); });
@ -126,9 +142,14 @@ describe('PluginController', () => {
]; ];
require('~/app/clients/tools').availableTools.push(...mockPlugins); require('~/app/clients/tools').availableTools.push(...mockPlugins);
mockReq.app.locals.includedTools = ['key1'];
mockCache.get.mockResolvedValue(null); mockCache.get.mockResolvedValue(null);
// Configure getAppConfig to return config with includedTools
getAppConfig.mockResolvedValueOnce({
filteredTools: [],
includedTools: ['key1'],
});
await getAvailablePluginsController(mockReq, mockRes); await getAvailablePluginsController(mockReq, mockRes);
const responseData = mockRes.json.mock.calls[0][0]; const responseData = mockRes.json.mock.calls[0][0];
@ -152,20 +173,26 @@ describe('PluginController', () => {
mockCache.get.mockResolvedValue(null); mockCache.get.mockResolvedValue(null);
getCachedTools.mockResolvedValueOnce(mockUserTools); 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); getCachedTools.mockResolvedValueOnce(mockUserTools);
await getAvailableTools(mockReq, mockRes); await getAvailableTools(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(200);
const responseData = mockRes.json.mock.calls[0][0]; 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); expect(responseData.length).toBeGreaterThan(0);
const convertedTool = responseData.find( const convertedTool = responseData.find(
(tool) => tool.pluginKey === `tool1${Constants.mcp_delimiter}server1`, (tool) => tool.pluginKey === `tool1${Constants.mcp_delimiter}server1`,
); );
expect(convertedTool).toBeDefined(); expect(convertedTool).toBeDefined();
// The real convertMCPToolsToPlugins extracts the name from the delimiter
expect(convertedTool.name).toBe('tool1'); expect(convertedTool.name).toBe('tool1');
}); });
@ -188,15 +215,20 @@ describe('PluginController', () => {
mockCache.get.mockResolvedValue(mockCachedPlugins); mockCache.get.mockResolvedValue(mockCachedPlugins);
getCachedTools.mockResolvedValueOnce(mockUserTools); 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
getCachedTools.mockResolvedValueOnce(mockUserTools); getCachedTools.mockResolvedValueOnce(mockUserTools);
await getAvailableTools(mockReq, mockRes); await getAvailableTools(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(200);
const responseData = mockRes.json.mock.calls[0][0]; 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; const userToolCount = responseData.filter((tool) => tool.pluginKey === 'user-tool').length;
expect(userToolCount).toBe(1); expect(userToolCount).toBe(1);
}); });
@ -213,11 +245,15 @@ describe('PluginController', () => {
require('~/app/clients/tools').availableTools.push(mockPlugin); require('~/app/clients/tools').availableTools.push(mockPlugin);
mockCache.get.mockResolvedValue(null); mockCache.get.mockResolvedValue(null);
getCachedTools.mockResolvedValue(null); // First call returns null for user tools
getCustomConfig.mockResolvedValue(null); getCachedTools.mockResolvedValueOnce(null);
mockReq.config = {
mcpConfig: null,
paths: { structuredTools: '/mock/path' },
};
// Mock loadAndFormatTools to return tool definitions including our tool // Second call (with includeGlobal: true) returns the tool definitions
loadAndFormatTools.mockReturnValue({ getCachedTools.mockResolvedValueOnce({
tool1: { tool1: {
type: 'function', type: 'function',
function: { function: {
@ -235,7 +271,7 @@ describe('PluginController', () => {
expect(Array.isArray(responseData)).toBe(true); expect(Array.isArray(responseData)).toBe(true);
const tool = responseData.find((t) => t.pluginKey === 'tool1'); const tool = responseData.find((t) => t.pluginKey === 'tool1');
expect(tool).toBeDefined(); 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(); expect(tool.authenticated).toBeUndefined();
}); });
@ -257,11 +293,15 @@ describe('PluginController', () => {
}); });
mockCache.get.mockResolvedValue(null); mockCache.get.mockResolvedValue(null);
getCachedTools.mockResolvedValue(null); // First call returns null for user tools
getCustomConfig.mockResolvedValue(null); getCachedTools.mockResolvedValueOnce(null);
mockReq.config = {
mcpConfig: null,
paths: { structuredTools: '/mock/path' },
};
// Mock loadAndFormatTools to return tool definitions // Second call (with includeGlobal: true) returns the tool definitions
loadAndFormatTools.mockReturnValue({ getCachedTools.mockResolvedValueOnce({
toolkit1_function: { toolkit1_function: {
type: 'function', type: 'function',
function: { function: {
@ -283,9 +323,8 @@ describe('PluginController', () => {
}); });
describe('plugin.icon behavior', () => { describe('plugin.icon behavior', () => {
const callGetAvailableToolsWithMCPServer = async (mcpServers) => { const callGetAvailableToolsWithMCPServer = async (serverConfig) => {
mockCache.get.mockResolvedValue(null); mockCache.get.mockResolvedValue(null);
getCustomConfig.mockResolvedValue({ mcpServers });
const functionTools = { const functionTools = {
[`test-tool${Constants.mcp_delimiter}test-server`]: { [`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 = { const mockMCPManager = {
getAllToolFunctions: jest.fn().mockResolvedValue(functionTools), getAllToolFunctions: jest.fn().mockResolvedValue(functionTools),
getRawConfig: jest.fn().mockReturnValue(serverConfig),
}; };
require('~/config').getMCPManager.mockReturnValue(mockMCPManager); require('~/config').getMCPManager.mockReturnValue(mockMCPManager);
// First call returns empty user tools
getCachedTools.mockResolvedValueOnce({}); getCachedTools.mockResolvedValueOnce({});
// Mock loadAndFormatTools to return empty object since these are MCP tools // Mock getAppConfig to return the mcpConfig
loadAndFormatTools.mockReturnValue({}); mockReq.config = {
mcpConfig: {
'test-server': serverConfig,
},
};
// Second call (with includeGlobal: true) returns the tool definitions
getCachedTools.mockResolvedValueOnce(functionTools); getCachedTools.mockResolvedValueOnce(functionTools);
await getAvailableTools(mockReq, mockRes); await getAvailableTools(mockReq, mockRes);
@ -319,28 +365,24 @@ describe('PluginController', () => {
}; };
it('should set plugin.icon when iconPath is defined', async () => { it('should set plugin.icon when iconPath is defined', async () => {
const mcpServers = { const serverConfig = {
'test-server': {
iconPath: '/path/to/icon.png', iconPath: '/path/to/icon.png',
},
}; };
const testTool = await callGetAvailableToolsWithMCPServer(mcpServers); const testTool = await callGetAvailableToolsWithMCPServer(serverConfig);
expect(testTool.icon).toBe('/path/to/icon.png'); expect(testTool.icon).toBe('/path/to/icon.png');
}); });
it('should set plugin.icon to undefined when iconPath is not defined', async () => { it('should set plugin.icon to undefined when iconPath is not defined', async () => {
const mcpServers = { const serverConfig = {};
'test-server': {}, const testTool = await callGetAvailableToolsWithMCPServer(serverConfig);
};
const testTool = await callGetAvailableToolsWithMCPServer(mcpServers);
expect(testTool.icon).toBeUndefined(); expect(testTool.icon).toBeUndefined();
}); });
}); });
describe('helper function integration', () => { describe('helper function integration', () => {
it('should properly handle MCP tools with custom user variables', async () => { it('should properly handle MCP tools with custom user variables', async () => {
const customConfig = { const appConfig = {
mcpServers: { mcpConfig: {
'test-server': { 'test-server': {
customUserVars: { customUserVars: {
API_KEY: { title: 'API Key', description: 'Your API key' }, API_KEY: { title: 'API Key', description: 'Your API key' },
@ -364,24 +406,28 @@ describe('PluginController', () => {
// Mock the MCP manager to return tools // Mock the MCP manager to return tools
const mockMCPManager = { const mockMCPManager = {
getAllToolFunctions: jest.fn().mockResolvedValue(mcpToolFunctions), getAllToolFunctions: jest.fn().mockResolvedValue(mcpToolFunctions),
getRawConfig: jest.fn().mockReturnValue({
customUserVars: {
API_KEY: { title: 'API Key', description: 'Your API key' },
},
}),
}; };
require('~/config').getMCPManager.mockReturnValue(mockMCPManager); require('~/config').getMCPManager.mockReturnValue(mockMCPManager);
mockCache.get.mockResolvedValue(null); mockCache.get.mockResolvedValue(null);
getCustomConfig.mockResolvedValue(customConfig); mockReq.config = appConfig;
// First call returns user tools (empty in this case) // First call returns user tools (empty in this case)
getCachedTools.mockResolvedValueOnce({}); getCachedTools.mockResolvedValueOnce({});
// Mock loadAndFormatTools to return empty object for MCP tools // Second call (with includeGlobal: true) returns tool definitions including our MCP tool
loadAndFormatTools.mockReturnValue({});
// Second call returns tool definitions including our MCP tool
getCachedTools.mockResolvedValueOnce(mcpToolFunctions); getCachedTools.mockResolvedValueOnce(mcpToolFunctions);
await getAvailableTools(mockReq, mockRes); await getAvailableTools(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(200);
const responseData = mockRes.json.mock.calls[0][0]; const responseData = mockRes.json.mock.calls[0][0];
expect(Array.isArray(responseData)).toBe(true);
// Find the MCP tool in the response // Find the MCP tool in the response
const mcpTool = responseData.find( const mcpTool = responseData.find(
@ -417,24 +463,36 @@ describe('PluginController', () => {
it('should handle null cachedTools and cachedUserTools', async () => { it('should handle null cachedTools and cachedUserTools', async () => {
mockCache.get.mockResolvedValue(null); mockCache.get.mockResolvedValue(null);
getCachedTools.mockResolvedValue(null); // First call returns null for user tools
getCustomConfig.mockResolvedValue(null); getCachedTools.mockResolvedValueOnce(null);
mockReq.config = {
mcpConfig: null,
paths: { structuredTools: '/mock/path' },
};
// Mock loadAndFormatTools to return empty object when getCachedTools returns null // Mock MCP manager to return no tools
loadAndFormatTools.mockReturnValue({}); 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); await getAvailableTools(mockReq, mockRes);
// Should handle null values gracefully // Should handle null values gracefully
expect(mockRes.status).toHaveBeenCalledWith(200); expect(mockRes.status).toHaveBeenCalledWith(200);
expect(mockRes.json).toHaveBeenCalledWith([]);
}); });
it('should handle when getCachedTools returns undefined', async () => { it('should handle when getCachedTools returns undefined', async () => {
mockCache.get.mockResolvedValue(null); mockCache.get.mockResolvedValue(null);
getCustomConfig.mockResolvedValue(null); mockReq.config = {
mcpConfig: null,
// Mock loadAndFormatTools to return empty object when getCachedTools returns undefined paths: { structuredTools: '/mock/path' },
loadAndFormatTools.mockReturnValue({}); };
// Mock getCachedTools to return undefined for both calls // Mock getCachedTools to return undefined for both calls
getCachedTools.mockReset(); getCachedTools.mockReset();
@ -444,6 +502,7 @@ describe('PluginController', () => {
// Should handle undefined values gracefully // Should handle undefined values gracefully
expect(mockRes.status).toHaveBeenCalledWith(200); expect(mockRes.status).toHaveBeenCalledWith(200);
expect(mockRes.json).toHaveBeenCalledWith([]);
}); });
it('should handle cachedToolsArray and userPlugins both being defined', async () => { it('should handle cachedToolsArray and userPlugins both being defined', async () => {
@ -461,8 +520,18 @@ describe('PluginController', () => {
}; };
mockCache.get.mockResolvedValue(cachedTools); mockCache.get.mockResolvedValue(cachedTools);
getCachedTools.mockResolvedValue(userTools); getCachedTools.mockResolvedValueOnce(userTools);
getCustomConfig.mockResolvedValue(null); 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); await getAvailableTools(mockReq, mockRes);
@ -474,8 +543,20 @@ describe('PluginController', () => {
it('should handle empty toolDefinitions object', async () => { it('should handle empty toolDefinitions object', async () => {
mockCache.get.mockResolvedValue(null); mockCache.get.mockResolvedValue(null);
getCachedTools.mockResolvedValueOnce({}).mockResolvedValueOnce({}); // Reset getCachedTools to ensure clean state
getCustomConfig.mockResolvedValue(null); 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); await getAvailableTools(mockReq, mockRes);
@ -484,8 +565,8 @@ describe('PluginController', () => {
}); });
it('should handle MCP tools without customUserVars', async () => { it('should handle MCP tools without customUserVars', async () => {
const customConfig = { const appConfig = {
mcpServers: { mcpConfig: {
'test-server': { 'test-server': {
// No customUserVars defined // No customUserVars defined
}, },
@ -494,30 +575,60 @@ describe('PluginController', () => {
const mockUserTools = { const mockUserTools = {
[`tool1${Constants.mcp_delimiter}test-server`]: { [`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); 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(mockUserTools);
getCachedTools.mockResolvedValueOnce({ // Ensure no plugins in availableTools for clean test
[`tool1${Constants.mcp_delimiter}test-server`]: true, require('~/app/clients/tools').availableTools.length = 0;
});
await getAvailableTools(mockReq, mockRes); await getAvailableTools(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(200);
const responseData = mockRes.json.mock.calls[0][0]; const responseData = mockRes.json.mock.calls[0][0];
expect(responseData[0].authenticated).toBe(true); expect(Array.isArray(responseData)).toBe(true);
// The actual implementation doesn't set authConfig on tools without customUserVars expect(responseData.length).toBeGreaterThan(0);
expect(responseData[0].authConfig).toEqual([]);
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 () => { it('should handle undefined filteredTools and includedTools', async () => {
mockReq.app = { locals: {} }; mockReq.config = {};
mockCache.get.mockResolvedValue(null); 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); await getAvailablePluginsController(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(200); expect(mockRes.status).toHaveBeenCalledWith(200);
@ -532,27 +643,21 @@ describe('PluginController', () => {
toolkit: true, toolkit: true,
}; };
// Ensure req.app.locals is properly mocked // No need to mock app.locals anymore as it's not used
mockReq.app = {
locals: {
filteredTools: [],
includedTools: [],
paths: { structuredTools: '/mock/path' },
},
};
// Add the toolkit to availableTools // Add the toolkit to availableTools
require('~/app/clients/tools').availableTools.push(mockToolkit); require('~/app/clients/tools').availableTools.push(mockToolkit);
mockCache.get.mockResolvedValue(null); mockCache.get.mockResolvedValue(null);
getCachedTools.mockResolvedValue({}); // First call returns empty object
getCustomConfig.mockResolvedValue(null); getCachedTools.mockResolvedValueOnce({});
mockReq.config = {
mcpConfig: null,
paths: { structuredTools: '/mock/path' },
};
// Mock loadAndFormatTools to return an empty object when toolDefinitions is null // Second call (with includeGlobal: true) returns empty object to avoid null reference error
loadAndFormatTools.mockReturnValue({}); getCachedTools.mockResolvedValueOnce({});
// Mock getCachedTools second call to return null
getCachedTools.mockResolvedValueOnce({}).mockResolvedValueOnce(null);
await getAvailableTools(mockReq, mockRes); await getAvailableTools(mockReq, mockRes);

View file

@ -17,12 +17,14 @@ const { needsRefresh, getNewS3URL } = require('~/server/services/Files/S3/crud')
const { Tools, Constants, FileSources } = require('librechat-data-provider'); const { Tools, Constants, FileSources } = require('librechat-data-provider');
const { processDeleteRequest } = require('~/server/services/Files/process'); const { processDeleteRequest } = require('~/server/services/Files/process');
const { Transaction, Balance, User } = require('~/db/models'); const { Transaction, Balance, User } = require('~/db/models');
const { getAppConfig } = require('~/server/services/Config');
const { deleteToolCalls } = require('~/models/ToolCall'); const { deleteToolCalls } = require('~/models/ToolCall');
const { deleteAllSharedLinks } = require('~/models'); const { deleteAllSharedLinks } = require('~/models');
const { getMCPManager } = require('~/config'); const { getMCPManager } = require('~/config');
const getUserController = async (req, res) => { 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 }; const userData = req.user.toObject != null ? req.user.toObject() : { ...req.user };
/** /**
* These fields should not exist due to secure field selection, but deletion * 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.password;
delete userData.totpSecret; delete userData.totpSecret;
delete userData.backupCodes; 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); const avatarNeedsRefresh = needsRefresh(userData.avatar, 3600);
if (!avatarNeedsRefresh) { if (!avatarNeedsRefresh) {
return res.status(200).send(userData); return res.status(200).send(userData);
@ -87,6 +89,7 @@ const deleteUserFiles = async (req) => {
}; };
const updateUserPluginsController = async (req, res) => { const updateUserPluginsController = async (req, res) => {
const appConfig = await getAppConfig({ role: req.user?.role });
const { user } = req; const { user } = req;
const { pluginKey, action, auth, isEntityTool } = req.body; const { pluginKey, action, auth, isEntityTool } = req.body;
try { try {
@ -131,7 +134,7 @@ const updateUserPluginsController = async (req, res) => {
if (pluginKey === Tools.web_search) { if (pluginKey === Tools.web_search) {
/** @type {TCustomConfig['webSearch']} */ /** @type {TCustomConfig['webSearch']} */
const webSearchConfig = req.app.locals?.webSearch; const webSearchConfig = appConfig?.webSearch;
keys = extractWebSearchEnvVars({ keys = extractWebSearchEnvVars({
keys: action === 'install' ? keys : webSearchKeys, keys: action === 'install' ? keys : webSearchKeys,
config: webSearchConfig, config: webSearchConfig,

View file

@ -246,6 +246,7 @@ function createToolEndCallback({ req, res, artifactPromises }) {
const attachment = await processFileCitations({ const attachment = await processFileCitations({
user, user,
metadata, metadata,
appConfig: req.config,
toolArtifact: output.artifact, toolArtifact: output.artifact,
toolCallId: output.tool_call_id, toolCallId: output.tool_call_id,
}); });

View file

@ -7,6 +7,7 @@ const {
createRun, createRun,
Tokenizer, Tokenizer,
checkAccess, checkAccess,
getBalanceConfig,
memoryInstructions, memoryInstructions,
formatContentStrings, formatContentStrings,
createMemoryProcessor, createMemoryProcessor,
@ -446,8 +447,8 @@ class AgentClient extends BaseClient {
); );
return; return;
} }
/** @type {TCustomConfig['memory']} */ const appConfig = this.options.req.config;
const memoryConfig = this.options.req?.app?.locals?.memory; const memoryConfig = appConfig.memory;
if (!memoryConfig || memoryConfig.disabled === true) { if (!memoryConfig || memoryConfig.disabled === true) {
return; return;
} }
@ -455,7 +456,7 @@ class AgentClient extends BaseClient {
/** @type {Agent} */ /** @type {Agent} */
let prelimAgent; let prelimAgent;
const allowedProviders = new Set( const allowedProviders = new Set(
this.options.req?.app?.locals?.[EModelEndpoint.agents]?.allowedProviders, appConfig?.endpoints?.[EModelEndpoint.agents]?.allowedProviders,
); );
try { try {
if (memoryConfig.agent?.id != null && memoryConfig.agent.id !== this.options.agent.id) { if (memoryConfig.agent?.id != null && memoryConfig.agent.id !== this.options.agent.id) {
@ -577,8 +578,8 @@ class AgentClient extends BaseClient {
if (this.processMemory == null) { if (this.processMemory == null) {
return; return;
} }
/** @type {TCustomConfig['memory']} */ const appConfig = this.options.req.config;
const memoryConfig = this.options.req?.app?.locals?.memory; const memoryConfig = appConfig.memory;
const messageWindowSize = memoryConfig?.messageWindowSize ?? 5; const messageWindowSize = memoryConfig?.messageWindowSize ?? 5;
let messagesToProcess = [...messages]; let messagesToProcess = [...messages];
@ -620,9 +621,15 @@ class AgentClient extends BaseClient {
* @param {Object} params * @param {Object} params
* @param {string} [params.model] * @param {string} [params.model]
* @param {string} [params.context='message'] * @param {string} [params.context='message']
* @param {AppConfig['balance']} [params.balance]
* @param {UsageMetadata[]} [params.collectedUsage=this.collectedUsage] * @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) { if (!collectedUsage || !collectedUsage.length) {
return; return;
} }
@ -644,6 +651,7 @@ class AgentClient extends BaseClient {
const txMetadata = { const txMetadata = {
context, context,
balance,
conversationId: this.conversationId, conversationId: this.conversationId,
user: this.user ?? this.options.req.user?.id, user: this.user ?? this.options.req.user?.id,
endpointTokenConfig: this.options.endpointTokenConfig, endpointTokenConfig: this.options.endpointTokenConfig,
@ -761,8 +769,9 @@ class AgentClient extends BaseClient {
abortController = new AbortController(); abortController = new AbortController();
} }
/** @type {TCustomConfig['endpoints']['agents']} */ const appConfig = this.options.req.config;
const agentsEConfig = this.options.req.app.locals[EModelEndpoint.agents]; /** @type {AppConfig['endpoints']['agents']} */
const agentsEConfig = appConfig.endpoints?.[EModelEndpoint.agents];
config = { config = {
configurable: { configurable: {
@ -1030,7 +1039,8 @@ class AgentClient extends BaseClient {
this.artifactPromises.push(...attachments); this.artifactPromises.push(...attachments);
} }
await this.recordCollectedUsage({ context: 'message' }); const balanceConfig = getBalanceConfig(appConfig);
await this.recordCollectedUsage({ context: 'message', balance: balanceConfig });
} catch (err) { } catch (err) {
logger.error( logger.error(
'[api/server/controllers/agents/client.js #chatCompletion] Error recording collected usage', '[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 { handleLLMEnd, collected: collectedMetadata } = createMetadataAggregator();
const { req, res, agent } = this.options; const { req, res, agent } = this.options;
const appConfig = req.config;
let endpoint = agent.endpoint; let endpoint = agent.endpoint;
/** @type {import('@librechat/agents').ClientOptions} */ /** @type {import('@librechat/agents').ClientOptions} */
@ -1078,11 +1089,13 @@ class AgentClient extends BaseClient {
model: agent.model || agent.model_parameters.model, model: agent.model || agent.model_parameters.model,
}; };
let titleProviderConfig = await getProviderConfig(endpoint); let titleProviderConfig = getProviderConfig({ provider: endpoint, appConfig });
/** @type {TEndpoint | undefined} */ /** @type {TEndpoint | undefined} */
const endpointConfig = const endpointConfig =
req.app.locals.all ?? req.app.locals[endpoint] ?? titleProviderConfig.customEndpointConfig; appConfig.endpoints?.all ??
appConfig.endpoints?.[endpoint] ??
titleProviderConfig.customEndpointConfig;
if (!endpointConfig) { if (!endpointConfig) {
logger.warn( logger.warn(
'[api/server/controllers/agents/client.js #titleConvo] Error getting endpoint config', '[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) { if (endpointConfig?.titleEndpoint && endpointConfig.titleEndpoint !== endpoint) {
try { try {
titleProviderConfig = await getProviderConfig(endpointConfig.titleEndpoint); titleProviderConfig = getProviderConfig({
provider: endpointConfig.titleEndpoint,
appConfig,
});
endpoint = endpointConfig.titleEndpoint; endpoint = endpointConfig.titleEndpoint;
} catch (error) { } catch (error) {
logger.warn( logger.warn(
@ -1100,7 +1116,7 @@ class AgentClient extends BaseClient {
); );
// Fall back to original provider config // Fall back to original provider config
endpoint = agent.endpoint; 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({ await this.recordCollectedUsage({
model: clientOptions.model,
context: 'title',
collectedUsage, collectedUsage,
context: 'title',
model: clientOptions.model,
balance: balanceConfig,
}).catch((err) => { }).catch((err) => {
logger.error( logger.error(
'[api/server/controllers/agents/client.js #titleConvo] Error recording collected usage', '[api/server/controllers/agents/client.js #titleConvo] Error recording collected usage',
@ -1225,17 +1243,26 @@ class AgentClient extends BaseClient {
* @param {object} params * @param {object} params
* @param {number} params.promptTokens * @param {number} params.promptTokens
* @param {number} params.completionTokens * @param {number} params.completionTokens
* @param {OpenAIUsageMetadata} [params.usage]
* @param {string} [params.model] * @param {string} [params.model]
* @param {OpenAIUsageMetadata} [params.usage]
* @param {AppConfig['balance']} [params.balance]
* @param {string} [params.context='message'] * @param {string} [params.context='message']
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
async recordTokenUsage({ model, promptTokens, completionTokens, usage, context = 'message' }) { async recordTokenUsage({
model,
usage,
balance,
promptTokens,
completionTokens,
context = 'message',
}) {
try { try {
await spendTokens( await spendTokens(
{ {
model, model,
context, context,
balance,
conversationId: this.conversationId, conversationId: this.conversationId,
user: this.user ?? this.options.req.user?.id, user: this.user ?? this.options.req.user?.id,
endpointTokenConfig: this.options.endpointTokenConfig, endpointTokenConfig: this.options.endpointTokenConfig,
@ -1252,6 +1279,7 @@ class AgentClient extends BaseClient {
await spendTokens( await spendTokens(
{ {
model, model,
balance,
context: 'reasoning', context: 'reasoning',
conversationId: this.conversationId, conversationId: this.conversationId,
user: this.user ?? this.options.req.user?.id, user: this.user ?? this.options.req.user?.id,

View file

@ -41,8 +41,16 @@ describe('AgentClient - titleConvo', () => {
// Mock request and response // Mock request and response
mockReq = { mockReq = {
app: { user: {
locals: { id: 'user-123',
},
body: {
model: 'gpt-4',
endpoint: EModelEndpoint.openAI,
key: null,
},
config: {
endpoints: {
[EModelEndpoint.openAI]: { [EModelEndpoint.openAI]: {
// Match the agent endpoint // Match the agent endpoint
titleModel: 'gpt-3.5-turbo', 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 = {}; mockRes = {};
@ -143,7 +143,7 @@ describe('AgentClient - titleConvo', () => {
it('should handle missing endpoint config gracefully', async () => { it('should handle missing endpoint config gracefully', async () => {
// Remove endpoint config // Remove endpoint config
mockReq.app.locals[EModelEndpoint.openAI] = undefined; mockReq.config = { endpoints: {} };
const text = 'Test conversation text'; const text = 'Test conversation text';
const abortController = new AbortController(); const abortController = new AbortController();
@ -161,7 +161,16 @@ describe('AgentClient - titleConvo', () => {
it('should use agent model when titleModel is not provided', async () => { it('should use agent model when titleModel is not provided', async () => {
// Remove titleModel from config // 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 text = 'Test conversation text';
const abortController = new AbortController(); const abortController = new AbortController();
@ -173,7 +182,16 @@ describe('AgentClient - titleConvo', () => {
}); });
it('should not use titleModel when it equals CURRENT_MODEL constant', async () => { 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 text = 'Test conversation text';
const abortController = new AbortController(); const abortController = new AbortController();
@ -216,6 +234,9 @@ describe('AgentClient - titleConvo', () => {
model: 'gpt-3.5-turbo', model: 'gpt-3.5-turbo',
context: 'title', context: 'title',
collectedUsage: expect.any(Array), collectedUsage: expect.any(Array),
balance: {
enabled: false,
},
}); });
}); });
@ -245,10 +266,17 @@ describe('AgentClient - titleConvo', () => {
process.env.ANTHROPIC_API_KEY = 'test-api-key'; process.env.ANTHROPIC_API_KEY = 'test-api-key';
// Add titleEndpoint to the config // Add titleEndpoint to the config
mockReq.app.locals[EModelEndpoint.openAI].titleEndpoint = EModelEndpoint.anthropic; mockReq.config = {
mockReq.app.locals[EModelEndpoint.openAI].titleMethod = 'structured'; endpoints: {
mockReq.app.locals[EModelEndpoint.openAI].titlePrompt = 'Custom title prompt'; [EModelEndpoint.openAI]: {
mockReq.app.locals[EModelEndpoint.openAI].titlePromptTemplate = 'Custom template'; titleModel: 'gpt-3.5-turbo',
titleEndpoint: EModelEndpoint.anthropic,
titleMethod: 'structured',
titlePrompt: 'Custom title prompt',
titlePromptTemplate: 'Custom template',
},
},
};
const text = 'Test conversation text'; const text = 'Test conversation text';
const abortController = new AbortController(); const abortController = new AbortController();
@ -274,18 +302,16 @@ describe('AgentClient - titleConvo', () => {
}); });
it('should use all config when endpoint config is missing', async () => { it('should use all config when endpoint config is missing', async () => {
// Remove endpoint-specific config // Set 'all' config without endpoint-specific config
delete mockReq.app.locals[EModelEndpoint.openAI].titleModel; mockReq.config = {
delete mockReq.app.locals[EModelEndpoint.openAI].titlePrompt; endpoints: {
delete mockReq.app.locals[EModelEndpoint.openAI].titleMethod; all: {
delete mockReq.app.locals[EModelEndpoint.openAI].titlePromptTemplate;
// Set 'all' config
mockReq.app.locals.all = {
titleModel: 'gpt-4o-mini', titleModel: 'gpt-4o-mini',
titlePrompt: 'All config title prompt', titlePrompt: 'All config title prompt',
titleMethod: 'completion', titleMethod: 'completion',
titlePromptTemplate: 'All config template: {{content}}', titlePromptTemplate: 'All config template: {{content}}',
},
},
}; };
const text = 'Test conversation text'; const text = 'Test conversation text';
@ -309,17 +335,21 @@ describe('AgentClient - titleConvo', () => {
it('should prioritize all config over endpoint config for title settings', async () => { it('should prioritize all config over endpoint config for title settings', async () => {
// Set both endpoint and 'all' config // Set both endpoint and 'all' config
mockReq.app.locals[EModelEndpoint.openAI].titleModel = 'gpt-3.5-turbo'; mockReq.config = {
mockReq.app.locals[EModelEndpoint.openAI].titlePrompt = 'Endpoint title prompt'; endpoints: {
mockReq.app.locals[EModelEndpoint.openAI].titleMethod = 'structured'; [EModelEndpoint.openAI]: {
// Remove titlePromptTemplate from endpoint config to test fallback titleModel: 'gpt-3.5-turbo',
delete mockReq.app.locals[EModelEndpoint.openAI].titlePromptTemplate; titlePrompt: 'Endpoint title prompt',
titleMethod: 'structured',
mockReq.app.locals.all = { // titlePromptTemplate is omitted to test fallback
},
all: {
titleModel: 'gpt-4o-mini', titleModel: 'gpt-4o-mini',
titlePrompt: 'All config title prompt', titlePrompt: 'All config title prompt',
titleMethod: 'completion', titleMethod: 'completion',
titlePromptTemplate: 'All config template', titlePromptTemplate: 'All config template',
},
},
}; };
const text = 'Test conversation text'; const text = 'Test conversation text';
@ -346,17 +376,18 @@ describe('AgentClient - titleConvo', () => {
const originalApiKey = process.env.ANTHROPIC_API_KEY; const originalApiKey = process.env.ANTHROPIC_API_KEY;
process.env.ANTHROPIC_API_KEY = 'test-anthropic-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 // Set comprehensive 'all' config with all new title options
mockReq.app.locals.all = { mockReq.config = {
endpoints: {
all: {
titleConvo: true, titleConvo: true,
titleModel: 'claude-3-haiku-20240307', titleModel: 'claude-3-haiku-20240307',
titleMethod: 'completion', // Testing the new default method titleMethod: 'completion', // Testing the new default method
titlePrompt: 'Generate a concise, descriptive title for this conversation', titlePrompt: 'Generate a concise, descriptive title for this conversation',
titlePromptTemplate: 'Conversation summary: {{content}}', titlePromptTemplate: 'Conversation summary: {{content}}',
titleEndpoint: EModelEndpoint.anthropic, // Should switch provider to Anthropic titleEndpoint: EModelEndpoint.anthropic, // Should switch provider to Anthropic
},
},
}; };
const text = 'Test conversation about AI and machine learning'; const text = 'Test conversation about AI and machine learning';
@ -402,15 +433,16 @@ describe('AgentClient - titleConvo', () => {
// Clear previous calls // Clear previous calls
mockRun.generateTitle.mockClear(); mockRun.generateTitle.mockClear();
// Remove endpoint config
delete mockReq.app.locals[EModelEndpoint.openAI];
// Set 'all' config with specific titleMethod // Set 'all' config with specific titleMethod
mockReq.app.locals.all = { mockReq.config = {
endpoints: {
all: {
titleModel: 'gpt-4o-mini', titleModel: 'gpt-4o-mini',
titleMethod: method, titleMethod: method,
titlePrompt: `Testing ${method} method`, titlePrompt: `Testing ${method} method`,
titlePromptTemplate: `Template for ${method}: {{content}}`, titlePromptTemplate: `Template for ${method}: {{content}}`,
},
},
}; };
const text = `Test conversation for ${method} method`; const text = `Test conversation for ${method} method`;
@ -455,7 +487,9 @@ describe('AgentClient - titleConvo', () => {
// Set up Azure endpoint with serverless config // Set up Azure endpoint with serverless config
mockAgent.endpoint = EModelEndpoint.azureOpenAI; mockAgent.endpoint = EModelEndpoint.azureOpenAI;
mockAgent.provider = EModelEndpoint.azureOpenAI; mockAgent.provider = EModelEndpoint.azureOpenAI;
mockReq.app.locals[EModelEndpoint.azureOpenAI] = { mockReq.config = {
endpoints: {
[EModelEndpoint.azureOpenAI]: {
titleConvo: true, titleConvo: true,
titleModel: 'grok-3', titleModel: 'grok-3',
titleMethod: 'completion', titleMethod: 'completion',
@ -480,6 +514,8 @@ describe('AgentClient - titleConvo', () => {
}, },
}, },
}, },
},
},
}; };
mockReq.body.endpoint = EModelEndpoint.azureOpenAI; mockReq.body.endpoint = EModelEndpoint.azureOpenAI;
mockReq.body.model = 'grok-3'; mockReq.body.model = 'grok-3';
@ -503,7 +539,9 @@ describe('AgentClient - titleConvo', () => {
// Set up Azure endpoint // Set up Azure endpoint
mockAgent.endpoint = EModelEndpoint.azureOpenAI; mockAgent.endpoint = EModelEndpoint.azureOpenAI;
mockAgent.provider = EModelEndpoint.azureOpenAI; mockAgent.provider = EModelEndpoint.azureOpenAI;
mockReq.app.locals[EModelEndpoint.azureOpenAI] = { mockReq.config = {
endpoints: {
[EModelEndpoint.azureOpenAI]: {
titleConvo: true, titleConvo: true,
titleModel: 'gpt-4o', titleModel: 'gpt-4o',
titleMethod: 'structured', titleMethod: 'structured',
@ -527,6 +565,8 @@ describe('AgentClient - titleConvo', () => {
}, },
}, },
}, },
},
},
}; };
mockReq.body.endpoint = EModelEndpoint.azureOpenAI; mockReq.body.endpoint = EModelEndpoint.azureOpenAI;
mockReq.body.model = 'gpt-4o'; mockReq.body.model = 'gpt-4o';
@ -551,7 +591,9 @@ describe('AgentClient - titleConvo', () => {
mockAgent.endpoint = EModelEndpoint.azureOpenAI; mockAgent.endpoint = EModelEndpoint.azureOpenAI;
mockAgent.provider = EModelEndpoint.azureOpenAI; mockAgent.provider = EModelEndpoint.azureOpenAI;
mockAgent.model_parameters.model = 'gpt-4o-latest'; mockAgent.model_parameters.model = 'gpt-4o-latest';
mockReq.app.locals[EModelEndpoint.azureOpenAI] = { mockReq.config = {
endpoints: {
[EModelEndpoint.azureOpenAI]: {
titleConvo: true, titleConvo: true,
titleModel: Constants.CURRENT_MODEL, titleModel: Constants.CURRENT_MODEL,
titleMethod: 'functions', titleMethod: 'functions',
@ -576,6 +618,8 @@ describe('AgentClient - titleConvo', () => {
}, },
}, },
}, },
},
},
}; };
mockReq.body.endpoint = EModelEndpoint.azureOpenAI; mockReq.body.endpoint = EModelEndpoint.azureOpenAI;
mockReq.body.model = 'gpt-4o-latest'; mockReq.body.model = 'gpt-4o-latest';
@ -598,7 +642,9 @@ describe('AgentClient - titleConvo', () => {
// Set up Azure endpoint // Set up Azure endpoint
mockAgent.endpoint = EModelEndpoint.azureOpenAI; mockAgent.endpoint = EModelEndpoint.azureOpenAI;
mockAgent.provider = EModelEndpoint.azureOpenAI; mockAgent.provider = EModelEndpoint.azureOpenAI;
mockReq.app.locals[EModelEndpoint.azureOpenAI] = { mockReq.config = {
endpoints: {
[EModelEndpoint.azureOpenAI]: {
titleConvo: true, titleConvo: true,
titleModel: 'o1-mini', titleModel: 'o1-mini',
titleMethod: 'completion', titleMethod: 'completion',
@ -650,6 +696,8 @@ describe('AgentClient - titleConvo', () => {
}, },
}, },
}, },
},
},
}; };
mockReq.body.endpoint = EModelEndpoint.azureOpenAI; mockReq.body.endpoint = EModelEndpoint.azureOpenAI;
mockReq.body.model = 'o1-mini'; mockReq.body.model = 'o1-mini';
@ -679,11 +727,10 @@ describe('AgentClient - titleConvo', () => {
mockReq.body.endpoint = EModelEndpoint.azureOpenAI; mockReq.body.endpoint = EModelEndpoint.azureOpenAI;
mockReq.body.model = 'gpt-4'; 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 // Set 'all' config as fallback with a serverless Azure config
mockReq.app.locals.all = { mockReq.config = {
endpoints: {
all: {
titleConvo: true, titleConvo: true,
titleModel: 'gpt-4', titleModel: 'gpt-4',
titleMethod: 'structured', titleMethod: 'structured',
@ -708,6 +755,8 @@ describe('AgentClient - titleConvo', () => {
}, },
}, },
}, },
},
},
}; };
const text = 'Test Azure with all config fallback'; const text = 'Test Azure with all config fallback';
@ -982,13 +1031,6 @@ describe('AgentClient - titleConvo', () => {
}; };
mockReq = { mockReq = {
app: {
locals: {
memory: {
messageWindowSize: 3,
},
},
},
user: { user: {
id: 'user-123', id: 'user-123',
personalization: { personalization: {
@ -997,6 +1039,13 @@ describe('AgentClient - titleConvo', () => {
}, },
}; };
// Mock getAppConfig for memory tests
mockReq.config = {
memory: {
messageWindowSize: 3,
},
};
mockRes = {}; mockRes = {};
mockOptions = { mockOptions = {

View file

@ -21,7 +21,7 @@ const getLogStores = require('~/cache/getLogStores');
/** /**
* @typedef {Object} ErrorHandlerDependencies * @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 {Express.Response} res - The Express response object
* @property {() => ErrorHandlerContext} getContext - Function to get the current context * @property {() => ErrorHandlerContext} getContext - Function to get the current context
* @property {string} [originPath] - The origin path for the error handler * @property {string} [originPath] - The origin path for the error handler

View file

@ -487,6 +487,7 @@ const getListAgentsHandler = async (req, res) => {
*/ */
const uploadAgentAvatarHandler = async (req, res) => { const uploadAgentAvatarHandler = async (req, res) => {
try { try {
const appConfig = req.config;
filterFile({ req, file: req.file, image: true, isAvatar: true }); filterFile({ req, file: req.file, image: true, isAvatar: true });
const { agent_id } = req.params; const { agent_id } = req.params;
if (!agent_id) { if (!agent_id) {
@ -510,9 +511,7 @@ const uploadAgentAvatarHandler = async (req, res) => {
} }
const buffer = await fs.readFile(req.file.path); const buffer = await fs.readFile(req.file.path);
const fileStrategy = getFileStrategy(appConfig, { isAvatar: true });
const fileStrategy = getFileStrategy(req.app.locals, { isAvatar: true });
const resizedBuffer = await resizeAvatar({ const resizedBuffer = await resizeAvatar({
userId: req.user.id, userId: req.user.id,
input: buffer, input: buffer,

View file

@ -1,7 +1,7 @@
const { v4 } = require('uuid'); const { v4 } = require('uuid');
const { sleep } = require('@librechat/agents'); const { sleep } = require('@librechat/agents');
const { sendEvent } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas'); const { logger } = require('@librechat/data-schemas');
const { sendEvent, getBalanceConfig } = require('@librechat/api');
const { const {
Time, Time,
Constants, Constants,
@ -47,6 +47,7 @@ const { getOpenAIClient } = require('./helpers');
* @returns {void} * @returns {void}
*/ */
const chatV1 = async (req, res) => { const chatV1 = async (req, res) => {
const appConfig = req.config;
logger.debug('[/assistants/chat/] req.body', req.body); logger.debug('[/assistants/chat/] req.body', req.body);
const { const {
@ -251,8 +252,8 @@ const chatV1 = async (req, res) => {
} }
const checkBalanceBeforeRun = async () => { const checkBalanceBeforeRun = async () => {
const balance = req.app?.locals?.balance; const balanceConfig = getBalanceConfig(appConfig);
if (!balance?.enabled) { if (!balanceConfig?.enabled) {
return; return;
} }
const transactions = const transactions =

View file

@ -1,7 +1,7 @@
const { v4 } = require('uuid'); const { v4 } = require('uuid');
const { sleep } = require('@librechat/agents'); const { sleep } = require('@librechat/agents');
const { sendEvent } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas'); const { logger } = require('@librechat/data-schemas');
const { sendEvent, getBalanceConfig } = require('@librechat/api');
const { const {
Time, Time,
Constants, Constants,
@ -38,12 +38,13 @@ const { getOpenAIClient } = require('./helpers');
* @route POST / * @route POST /
* @desc Chat with an assistant * @desc Chat with an assistant
* @access Public * @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. * @param {Express.Response} res - The response object, used to send back a response.
* @returns {void} * @returns {void}
*/ */
const chatV2 = async (req, res) => { const chatV2 = async (req, res) => {
logger.debug('[/assistants/chat/] req.body', req.body); logger.debug('[/assistants/chat/] req.body', req.body);
const appConfig = req.config;
/** @type {{files: MongoFile[]}} */ /** @type {{files: MongoFile[]}} */
const { const {
@ -126,8 +127,8 @@ const chatV2 = async (req, res) => {
} }
const checkBalanceBeforeRun = async () => { const checkBalanceBeforeRun = async () => {
const balance = req.app?.locals?.balance; const balanceConfig = getBalanceConfig(appConfig);
if (!balance?.enabled) { if (!balanceConfig?.enabled) {
return; return;
} }
const transactions = const transactions =
@ -374,9 +375,9 @@ const chatV2 = async (req, res) => {
}; };
/** @type {undefined | TAssistantEndpoint} */ /** @type {undefined | TAssistantEndpoint} */
const config = req.app.locals[endpoint] ?? {}; const config = appConfig.endpoints?.[endpoint] ?? {};
/** @type {undefined | TBaseEndpoint} */ /** @type {undefined | TBaseEndpoint} */
const allConfig = req.app.locals.all; const allConfig = appConfig.endpoints?.all;
const streamRunManager = new StreamRunManager({ const streamRunManager = new StreamRunManager({
req, req,

View file

@ -22,7 +22,7 @@ const getLogStores = require('~/cache/getLogStores');
/** /**
* @typedef {Object} ErrorHandlerDependencies * @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 {Express.Response} res - The Express response object
* @property {() => ErrorHandlerContext} getContext - Function to get the current context * @property {() => ErrorHandlerContext} getContext - Function to get the current context
* @property {string} [originPath] - The origin path for the error handler * @property {string} [originPath] - The origin path for the error handler

View file

@ -11,7 +11,7 @@ const { initializeClient } = require('~/server/services/Endpoints/assistants');
const { getEndpointsConfig } = require('~/server/services/Config'); const { getEndpointsConfig } = require('~/server/services/Config');
/** /**
* @param {Express.Request} req * @param {ServerRequest} req
* @param {string} [endpoint] * @param {string} [endpoint]
* @returns {Promise<string>} * @returns {Promise<string>}
*/ */
@ -210,6 +210,7 @@ async function getOpenAIClient({ req, res, endpointOption, initAppClient, overri
* @returns {Promise<AssistantListResponse>} 200 - success response - application/json * @returns {Promise<AssistantListResponse>} 200 - success response - application/json
*/ */
const fetchAssistants = async ({ req, res, overrideEndpoint }) => { const fetchAssistants = async ({ req, res, overrideEndpoint }) => {
const appConfig = req.config;
const { const {
limit = 100, limit = 100,
order = 'desc', order = 'desc',
@ -230,20 +231,20 @@ const fetchAssistants = async ({ req, res, overrideEndpoint }) => {
if (endpoint === EModelEndpoint.assistants) { if (endpoint === EModelEndpoint.assistants) {
({ body } = await listAllAssistants({ req, res, version, query })); ({ body } = await listAllAssistants({ req, res, version, query }));
} else if (endpoint === EModelEndpoint.azureAssistants) { } 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 }); body = await listAssistantsForAzure({ req, res, version, azureConfig, query });
} }
if (req.user.role === SystemRoles.ADMIN) { if (req.user.role === SystemRoles.ADMIN) {
return body; return body;
} else if (!req.app.locals[endpoint]) { } else if (!appConfig.endpoints?.[endpoint]) {
return body; return body;
} }
body.data = filterAssistants({ body.data = filterAssistants({
userId: req.user.id, userId: req.user.id,
assistants: body.data, assistants: body.data,
assistantsConfig: req.app.locals[endpoint], assistantsConfig: appConfig.endpoints?.[endpoint],
}); });
return body; return body;
}; };

View file

@ -258,8 +258,9 @@ function filterAssistantDocs({ documents, userId, assistantsConfig = {} }) {
*/ */
const getAssistantDocuments = async (req, res) => { const getAssistantDocuments = async (req, res) => {
try { try {
const appConfig = req.config;
const endpoint = req.query; const endpoint = req.query;
const assistantsConfig = req.app.locals[endpoint]; const assistantsConfig = appConfig.endpoints?.[endpoint];
const documents = await getAssistants( const documents = await getAssistants(
{}, {},
{ {
@ -296,6 +297,7 @@ const getAssistantDocuments = async (req, res) => {
*/ */
const uploadAssistantAvatar = async (req, res) => { const uploadAssistantAvatar = async (req, res) => {
try { try {
const appConfig = req.config;
filterFile({ req, file: req.file, image: true, isAvatar: true }); filterFile({ req, file: req.file, image: true, isAvatar: true });
const { assistant_id } = req.params; const { assistant_id } = req.params;
if (!assistant_id) { if (!assistant_id) {
@ -337,7 +339,7 @@ const uploadAssistantAvatar = async (req, res) => {
const metadata = { const metadata = {
..._metadata, ..._metadata,
avatar: image.filepath, avatar: image.filepath,
avatar_source: req.app.locals.fileStrategy, avatar_source: appConfig.fileStrategy,
}; };
const promises = []; const promises = [];
@ -347,7 +349,7 @@ const uploadAssistantAvatar = async (req, res) => {
{ {
avatar: { avatar: {
filepath: image.filepath, filepath: image.filepath,
source: req.app.locals.fileStrategy, source: appConfig.fileStrategy,
}, },
user: req.user.id, user: req.user.id,
}, },

View file

@ -94,7 +94,7 @@ const createAssistant = async (req, res) => {
/** /**
* Modifies an assistant. * Modifies an assistant.
* @param {object} params * @param {object} params
* @param {Express.Request} params.req * @param {ServerRequest} params.req
* @param {OpenAIClient} params.openai * @param {OpenAIClient} params.openai
* @param {string} params.assistant_id * @param {string} params.assistant_id
* @param {AssistantUpdateParams} params.updateData * @param {AssistantUpdateParams} params.updateData
@ -199,7 +199,7 @@ const updateAssistant = async ({ req, openai, assistant_id, updateData }) => {
/** /**
* Modifies an assistant with the resource file id. * Modifies an assistant with the resource file id.
* @param {object} params * @param {object} params
* @param {Express.Request} params.req * @param {ServerRequest} params.req
* @param {OpenAIClient} params.openai * @param {OpenAIClient} params.openai
* @param {string} params.assistant_id * @param {string} params.assistant_id
* @param {string} params.tool_resource * @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. * Deletes a file ID from an assistant's resource.
* @param {object} params * @param {object} params
* @param {Express.Request} params.req * @param {ServerRequest} params.req
* @param {OpenAIClient} params.openai * @param {OpenAIClient} params.openai
* @param {string} params.assistant_id * @param {string} params.assistant_id
* @param {string} [params.tool_resource] * @param {string} [params.tool_resource]

View file

@ -35,9 +35,10 @@ const toolAccessPermType = {
*/ */
const verifyWebSearchAuth = async (req, res) => { const verifyWebSearchAuth = async (req, res) => {
try { try {
const appConfig = req.config;
const userId = req.user.id; const userId = req.user.id;
/** @type {TCustomConfig['webSearch']} */ /** @type {TCustomConfig['webSearch']} */
const webSearchConfig = req.app.locals?.webSearch || {}; const webSearchConfig = appConfig?.webSearch || {};
const result = await loadWebSearchAuth({ const result = await loadWebSearchAuth({
userId, userId,
loadAuthValues, loadAuthValues,
@ -110,6 +111,7 @@ const verifyToolAuth = async (req, res) => {
*/ */
const callTool = async (req, res) => { const callTool = async (req, res) => {
try { try {
const appConfig = req.config;
const { toolId = '' } = req.params; const { toolId = '' } = req.params;
if (!fieldsMap[toolId]) { if (!fieldsMap[toolId]) {
logger.warn(`[${toolId}/call] User ${req.user.id} attempted call to invalid tool`); logger.warn(`[${toolId}/call] User ${req.user.id} attempted call to invalid tool`);
@ -155,8 +157,10 @@ const callTool = async (req, res) => {
returnMetadata: true, returnMetadata: true,
processFileURL, processFileURL,
uploadImageBuffer, uploadImageBuffer,
fileStrategy: req.app.locals.fileStrategy,
}, },
webSearch: appConfig.webSearch,
fileStrategy: appConfig.fileStrategy,
imageOutputType: appConfig.imageOutputType,
}); });
const tool = loadedTools[0]; const tool = loadedTools[0];

View file

@ -14,12 +14,14 @@ const { isEnabled, ErrorController } = require('@librechat/api');
const { connectDb, indexSync } = require('~/db'); const { connectDb, indexSync } = require('~/db');
const validateImageRequest = require('./middleware/validateImageRequest'); const validateImageRequest = require('./middleware/validateImageRequest');
const { jwtLogin, ldapLogin, passportLogin } = require('~/strategies'); const { jwtLogin, ldapLogin, passportLogin } = require('~/strategies');
const { updateInterfacePermissions } = require('~/models/interface');
const { checkMigrations } = require('./services/start/migration'); const { checkMigrations } = require('./services/start/migration');
const initializeMCPs = require('./services/initializeMCPs'); const initializeMCPs = require('./services/initializeMCPs');
const configureSocialLogins = require('./socialLogins'); const configureSocialLogins = require('./socialLogins');
const AppService = require('./services/AppService'); const { getAppConfig } = require('./services/Config');
const staticCache = require('./utils/staticCache'); const staticCache = require('./utils/staticCache');
const noIndex = require('./middleware/noIndex'); const noIndex = require('./middleware/noIndex');
const { seedDatabase } = require('~/models');
const routes = require('./routes'); const routes = require('./routes');
const { PORT, HOST, ALLOW_SOCIAL_LOGIN, DISABLE_COMPRESSION, TRUST_PROXY } = process.env ?? {}; 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.disable('x-powered-by');
app.set('trust proxy', trusted_proxy); 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'); const indexHTML = fs.readFileSync(indexPath, 'utf8');
app.get('/health', (_req, res) => res.status(200).send('OK')); 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.'); console.warn('Response compression has been disabled via DISABLE_COMPRESSION.');
} }
// Serve static assets with aggressive caching app.use(staticCache(appConfig.paths.dist));
app.use(staticCache(app.locals.paths.dist)); app.use(staticCache(appConfig.paths.fonts));
app.use(staticCache(app.locals.paths.fonts)); app.use(staticCache(appConfig.paths.assets));
app.use(staticCache(app.locals.paths.assets));
if (!ALLOW_SOCIAL_LOGIN) { if (!ALLOW_SOCIAL_LOGIN) {
console.warn('Social logins are disabled. Set ALLOW_SOCIAL_LOGIN=true to enable them.'); 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}`); logger.info(`Server listening at http://${host == '0.0.0.0' ? 'localhost' : host}:${port}`);
} }
initializeMCPs(app).then(() => checkMigrations()); initializeMCPs().then(() => checkMigrations());
}); });
}; };

View file

@ -3,9 +3,27 @@ const request = require('supertest');
const { MongoMemoryServer } = require('mongodb-memory-server'); const { MongoMemoryServer } = require('mongodb-memory-server');
const mongoose = require('mongoose'); const mongoose = require('mongoose');
jest.mock('~/server/services/Config/loadCustomConfig', () => { jest.mock('~/server/services/Config', () => ({
return jest.fn(() => Promise.resolve({})); 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', () => { describe('Server Configuration', () => {
// Increase the default timeout to allow for Mongo cleanup // Increase the default timeout to allow for Mongo cleanup
@ -31,6 +49,22 @@ describe('Server Configuration', () => {
}); });
beforeAll(async () => { 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'),
'<!DOCTYPE html><html><head><title>LibreChat</title></head><body><div id="root"></div></body></html>',
);
mongoServer = await MongoMemoryServer.create(); mongoServer = await MongoMemoryServer.create();
process.env.MONGO_URI = mongoServer.getUri(); process.env.MONGO_URI = mongoServer.getUri();
process.env.PORT = '0'; // Use a random available port process.env.PORT = '0'; // Use a random available port

View file

@ -12,8 +12,9 @@ const { handleAbortError } = require('~/server/middleware/abortMiddleware');
const validateAssistant = async (req, res, next) => { const validateAssistant = async (req, res, next) => {
const { endpoint, conversationId, assistant_id, messageId } = req.body; const { endpoint, conversationId, assistant_id, messageId } = req.body;
const appConfig = req.config;
/** @type {Partial<TAssistantEndpoint>} */ /** @type {Partial<TAssistantEndpoint>} */
const assistantsConfig = req.app.locals?.[endpoint]; const assistantsConfig = appConfig.endpoints?.[endpoint];
if (!assistantsConfig) { if (!assistantsConfig) {
return next(); return next();
} }

View file

@ -20,8 +20,9 @@ const validateAuthor = async ({ req, openai, overrideEndpoint, overrideAssistant
const assistant_id = const assistant_id =
overrideAssistantId ?? req.params.id ?? req.body.assistant_id ?? req.query.assistant_id; overrideAssistantId ?? req.params.id ?? req.body.assistant_id ?? req.query.assistant_id;
const appConfig = req.config;
/** @type {Partial<TAssistantEndpoint>} */ /** @type {Partial<TAssistantEndpoint>} */
const assistantsConfig = req.app.locals?.[endpoint]; const assistantsConfig = appConfig.endpoints?.[endpoint];
if (!assistantsConfig) { if (!assistantsConfig) {
return; return;
} }

View file

@ -40,9 +40,10 @@ async function buildEndpointOption(req, res, next) {
return handleError(res, { text: 'Error parsing conversation' }); 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[] }}*/ /** @type {{ list: TModelSpec[] }}*/
const { list } = req.app.locals.modelSpecs; const { list } = appConfig.modelSpecs;
const { spec } = parsedBody; const { spec } = parsedBody;
if (!spec) { if (!spec) {

View file

@ -1,5 +1,6 @@
const { logger } = require('@librechat/data-schemas');
const { isEmailDomainAllowed } = require('~/server/services/domains'); const { isEmailDomainAllowed } = require('~/server/services/domains');
const { logger } = require('~/config'); const { getAppConfig } = require('~/server/services/Config');
/** /**
* Checks the domain's social login is allowed * Checks the domain's social login is allowed
@ -14,7 +15,10 @@ const { logger } = require('~/config');
*/ */
const checkDomainAllowed = async (req, res, next = () => {}) => { const checkDomainAllowed = async (req, res, next = () => {}) => {
const email = req?.user?.email; 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}]`); logger.error(`[Social Login] [Social Login not allowed] [Email: ${email}]`);
return res.redirect('/login'); return res.redirect('/login');
} else { } else {

View file

@ -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;

View file

@ -82,7 +82,7 @@ const sendError = async (req, res, options, callback) => {
/** /**
* Sends the response based on whether headers have been sent or not. * 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 {Express.Response} res - The server response.
* @param {Object} data - The data to be sent. * @param {Object} data - The data to be sent.
* @param {string} [errorMessage] - The error message, if any. * @param {string} [errorMessage] - The error message, if any.

View file

@ -13,6 +13,7 @@ const requireLdapAuth = require('./requireLdapAuth');
const abortMiddleware = require('./abortMiddleware'); const abortMiddleware = require('./abortMiddleware');
const checkInviteUser = require('./checkInviteUser'); const checkInviteUser = require('./checkInviteUser');
const requireJwtAuth = require('./requireJwtAuth'); const requireJwtAuth = require('./requireJwtAuth');
const configMiddleware = require('./config/app');
const validateModel = require('./validateModel'); const validateModel = require('./validateModel');
const moderateText = require('./moderateText'); const moderateText = require('./moderateText');
const logHeaders = require('./logHeaders'); const logHeaders = require('./logHeaders');
@ -43,6 +44,7 @@ module.exports = {
requireLocalAuth, requireLocalAuth,
canDeleteAccount, canDeleteAccount,
validateEndpoint, validateEndpoint,
configMiddleware,
concurrentLimiter, concurrentLimiter,
checkDomainAllowed, checkDomainAllowed,
validateMessageReq, validateMessageReq,

View file

@ -1,13 +1,18 @@
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const validateImageRequest = require('~/server/middleware/validateImageRequest'); const validateImageRequest = require('~/server/middleware/validateImageRequest');
jest.mock('~/server/services/Config/app', () => ({
getAppConfig: jest.fn(),
}));
describe('validateImageRequest middleware', () => { describe('validateImageRequest middleware', () => {
let req, res, next; let req, res, next;
const validObjectId = '65cfb246f7ecadb8b1e8036b'; const validObjectId = '65cfb246f7ecadb8b1e8036b';
const { getAppConfig } = require('~/server/services/Config/app');
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks();
req = { req = {
app: { locals: { secureImageLinks: true } },
headers: {}, headers: {},
originalUrl: '', originalUrl: '',
}; };
@ -17,79 +22,86 @@ describe('validateImageRequest middleware', () => {
}; };
next = jest.fn(); next = jest.fn();
process.env.JWT_REFRESH_SECRET = 'test-secret'; process.env.JWT_REFRESH_SECRET = 'test-secret';
// Mock getAppConfig to return secureImageLinks: true by default
getAppConfig.mockResolvedValue({
secureImageLinks: true,
});
}); });
afterEach(() => { afterEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
}); });
test('should call next() if secureImageLinks is false', () => { test('should call next() if secureImageLinks is false', async () => {
req.app.locals.secureImageLinks = false; getAppConfig.mockResolvedValue({
validateImageRequest(req, res, next); secureImageLinks: false,
});
await validateImageRequest(req, res, next);
expect(next).toHaveBeenCalled(); expect(next).toHaveBeenCalled();
}); });
test('should return 401 if refresh token is not provided', () => { test('should return 401 if refresh token is not provided', async () => {
validateImageRequest(req, res, next); await validateImageRequest(req, res, next);
expect(res.status).toHaveBeenCalledWith(401); expect(res.status).toHaveBeenCalledWith(401);
expect(res.send).toHaveBeenCalledWith('Unauthorized'); 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'; req.headers.cookie = 'refreshToken=invalid-token';
validateImageRequest(req, res, next); await validateImageRequest(req, res, next);
expect(res.status).toHaveBeenCalledWith(403); expect(res.status).toHaveBeenCalledWith(403);
expect(res.send).toHaveBeenCalledWith('Access Denied'); 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( const expiredToken = jwt.sign(
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) - 3600 }, { id: validObjectId, exp: Math.floor(Date.now() / 1000) - 3600 },
process.env.JWT_REFRESH_SECRET, process.env.JWT_REFRESH_SECRET,
); );
req.headers.cookie = `refreshToken=${expiredToken}`; req.headers.cookie = `refreshToken=${expiredToken}`;
validateImageRequest(req, res, next); await validateImageRequest(req, res, next);
expect(res.status).toHaveBeenCalledWith(403); expect(res.status).toHaveBeenCalledWith(403);
expect(res.send).toHaveBeenCalledWith('Access Denied'); 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( const validToken = jwt.sign(
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 }, { id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET, process.env.JWT_REFRESH_SECRET,
); );
req.headers.cookie = `refreshToken=${validToken}`; req.headers.cookie = `refreshToken=${validToken}`;
req.originalUrl = `/images/${validObjectId}/example.jpg`; req.originalUrl = `/images/${validObjectId}/example.jpg`;
validateImageRequest(req, res, next); await validateImageRequest(req, res, next);
expect(next).toHaveBeenCalled(); expect(next).toHaveBeenCalled();
}); });
test('should return 403 for invalid image path', () => { test('should return 403 for invalid image path', async () => {
const validToken = jwt.sign( const validToken = jwt.sign(
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 }, { id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET, process.env.JWT_REFRESH_SECRET,
); );
req.headers.cookie = `refreshToken=${validToken}`; req.headers.cookie = `refreshToken=${validToken}`;
req.originalUrl = '/images/65cfb246f7ecadb8b1e8036c/example.jpg'; // Different ObjectId req.originalUrl = '/images/65cfb246f7ecadb8b1e8036c/example.jpg'; // Different ObjectId
validateImageRequest(req, res, next); await validateImageRequest(req, res, next);
expect(res.status).toHaveBeenCalledWith(403); expect(res.status).toHaveBeenCalledWith(403);
expect(res.send).toHaveBeenCalledWith('Access Denied'); 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( const validToken = jwt.sign(
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 }, { id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET, process.env.JWT_REFRESH_SECRET,
); );
req.headers.cookie = `refreshToken=${validToken}`; req.headers.cookie = `refreshToken=${validToken}`;
req.originalUrl = '/images/123/example.jpg'; // Invalid ObjectId req.originalUrl = '/images/123/example.jpg'; // Invalid ObjectId
validateImageRequest(req, res, next); await validateImageRequest(req, res, next);
expect(res.status).toHaveBeenCalledWith(403); expect(res.status).toHaveBeenCalledWith(403);
expect(res.send).toHaveBeenCalledWith('Access Denied'); expect(res.send).toHaveBeenCalledWith('Access Denied');
}); });
// File traversal tests // File traversal tests
test('should prevent file traversal attempts', () => { test('should prevent file traversal attempts', async () => {
const validToken = jwt.sign( const validToken = jwt.sign(
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 }, { id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET, process.env.JWT_REFRESH_SECRET,
@ -103,23 +115,23 @@ describe('validateImageRequest middleware', () => {
`/images/${validObjectId}/%2e%2e%2f%2e%2e%2f%2e%2e%2fetc%2fpasswd`, `/images/${validObjectId}/%2e%2e%2f%2e%2e%2f%2e%2e%2fetc%2fpasswd`,
]; ];
traversalAttempts.forEach((attempt) => { for (const attempt of traversalAttempts) {
req.originalUrl = attempt; req.originalUrl = attempt;
validateImageRequest(req, res, next); await validateImageRequest(req, res, next);
expect(res.status).toHaveBeenCalledWith(403); expect(res.status).toHaveBeenCalledWith(403);
expect(res.send).toHaveBeenCalledWith('Access Denied'); expect(res.send).toHaveBeenCalledWith('Access Denied');
jest.clearAllMocks(); 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( const validToken = jwt.sign(
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 }, { id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET, process.env.JWT_REFRESH_SECRET,
); );
req.headers.cookie = `refreshToken=${validToken}`; req.headers.cookie = `refreshToken=${validToken}`;
req.originalUrl = `/images/${validObjectId}/image%20with%20spaces.jpg`; req.originalUrl = `/images/${validObjectId}/image%20with%20spaces.jpg`;
validateImageRequest(req, res, next); await validateImageRequest(req, res, next);
expect(next).toHaveBeenCalled(); expect(next).toHaveBeenCalled();
}); });
}); });

View file

@ -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. * If the `cache` store is not available, the middleware will skip its logic.
* *
* @function * @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 {Express.Response} res - Express response object.
* @param {function} next - Express next middleware function. * @param {function} next - Express next middleware function.
* @throws {Error} Throws an error if the user doesn't have access to the conversation. * @throws {Error} Throws an error if the user doesn't have access to the conversation.

View file

@ -1,6 +1,7 @@
const cookies = require('cookie'); const cookies = require('cookie');
const jwt = require('jsonwebtoken'); 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_LENGTH = 24;
const OBJECT_ID_PATTERN = /^[0-9a-f]{24}$/i; const OBJECT_ID_PATTERN = /^[0-9a-f]{24}$/i;
@ -24,8 +25,9 @@ function isValidObjectId(id) {
* Middleware to validate image request. * Middleware to validate image request.
* Must be set by `secureImageLinks` via custom config file. * Must be set by `secureImageLinks` via custom config file.
*/ */
function validateImageRequest(req, res, next) { async function validateImageRequest(req, res, next) {
if (!req.app.locals.secureImageLinks) { const appConfig = await getAppConfig({ role: req.user?.role });
if (!appConfig.secureImageLinks) {
return next(); return next();
} }

View file

@ -6,7 +6,7 @@ const { logViolation } = require('~/cache');
* Validates the model of the request. * Validates the model of the request.
* *
* @async * @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 {Express.Response} res - The Express response object.
* @param {Function} next - The Express next function. * @param {Function} next - The Express next function.
*/ */

View file

@ -83,7 +83,11 @@ router.post(
} }
let metadata = await encryptMetadata(removeNullishValues(_metadata, true)); 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) { if (!isDomainAllowed) {
return res.status(400).json({ message: 'Domain not allowed' }); return res.status(400).json({ message: 'Domain not allowed' });
} }

View file

@ -4,6 +4,7 @@ const {
checkBan, checkBan,
requireJwtAuth, requireJwtAuth,
messageIpLimiter, messageIpLimiter,
configMiddleware,
concurrentLimiter, concurrentLimiter,
messageUserLimiter, messageUserLimiter,
} = require('~/server/middleware'); } = require('~/server/middleware');
@ -22,6 +23,8 @@ router.use(uaParser);
router.use('/', v1); router.use('/', v1);
const chatRouter = express.Router(); const chatRouter = express.Router();
chatRouter.use(configMiddleware);
if (isEnabled(LIMIT_CONCURRENT_MESSAGES)) { if (isEnabled(LIMIT_CONCURRENT_MESSAGES)) {
chatRouter.use(concurrentLimiter); chatRouter.use(concurrentLimiter);
} }
@ -37,6 +40,4 @@ if (isEnabled(LIMIT_MESSAGE_USER)) {
chatRouter.use('/', chat); chatRouter.use('/', chat);
router.use('/chat', chatRouter); router.use('/chat', chatRouter);
// Add marketplace routes
module.exports = router; module.exports = router;

View file

@ -1,7 +1,7 @@
const express = require('express'); const express = require('express');
const { callTool, verifyToolAuth, getToolCalls } = require('~/server/controllers/tools'); const { callTool, verifyToolAuth, getToolCalls } = require('~/server/controllers/tools');
const { getAvailableTools } = require('~/server/controllers/PluginController'); const { getAvailableTools } = require('~/server/controllers/PluginController');
const { toolCallLimiter } = require('~/server/middleware/limiters'); const { toolCallLimiter } = require('~/server/middleware');
const router = express.Router(); const router = express.Router();

View file

@ -1,7 +1,7 @@
const express = require('express'); const express = require('express');
const { generateCheckAccess } = require('@librechat/api'); const { generateCheckAccess } = require('@librechat/api');
const { PermissionTypes, Permissions, PermissionBits } = require('librechat-data-provider'); 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 v1 = require('~/server/controllers/agents/v1');
const { getRoleByName } = require('~/models/Role'); const { getRoleByName } = require('~/models/Role');
const actions = require('./actions'); const actions = require('./actions');
@ -36,13 +36,13 @@ router.use(requireJwtAuth);
* Agent actions route. * Agent actions route.
* @route GET|POST /agents/actions * @route GET|POST /agents/actions
*/ */
router.use('/actions', actions); router.use('/actions', configMiddleware, actions);
/** /**
* Get a list of available tools for agents. * Get a list of available tools for agents.
* @route GET /agents/tools * @route GET /agents/tools
*/ */
router.use('/tools', tools); router.use('/tools', configMiddleware, tools);
/** /**
* Get all agent categories with counts * Get all agent categories with counts

View file

@ -1,12 +1,12 @@
const express = require('express'); const express = require('express');
const { nanoid } = require('nanoid'); const { nanoid } = require('nanoid');
const { logger } = require('@librechat/data-schemas');
const { actionDelimiter, EModelEndpoint, removeNullishValues } = require('librechat-data-provider'); const { actionDelimiter, EModelEndpoint, removeNullishValues } = require('librechat-data-provider');
const { encryptMetadata, domainParser } = require('~/server/services/ActionService'); const { encryptMetadata, domainParser } = require('~/server/services/ActionService');
const { getOpenAIClient } = require('~/server/controllers/assistants/helpers'); const { getOpenAIClient } = require('~/server/controllers/assistants/helpers');
const { updateAction, getActions, deleteAction } = require('~/models/Action'); const { updateAction, getActions, deleteAction } = require('~/models/Action');
const { updateAssistantDoc, getAssistant } = require('~/models/Assistant'); const { updateAssistantDoc, getAssistant } = require('~/models/Assistant');
const { isActionDomainAllowed } = require('~/server/services/domains'); const { isActionDomainAllowed } = require('~/server/services/domains');
const { logger } = require('~/config');
const router = express.Router(); const router = express.Router();
@ -21,6 +21,7 @@ const router = express.Router();
*/ */
router.post('/:assistant_id', async (req, res) => { router.post('/:assistant_id', async (req, res) => {
try { try {
const appConfig = req.config;
const { assistant_id } = req.params; const { assistant_id } = req.params;
/** @type {{ functions: FunctionTool[], action_id: string, metadata: ActionMetadata }} */ /** @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)); let metadata = await encryptMetadata(removeNullishValues(_metadata, true));
const isDomainAllowed = await isActionDomainAllowed(metadata.domain); const isDomainAllowed = await isActionDomainAllowed(
metadata.domain,
appConfig?.actions?.allowedDomains,
);
if (!isDomainAllowed) { if (!isDomainAllowed) {
return res.status(400).json({ message: 'Domain not allowed' }); 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 */ /* 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 = {
...updatedAssistant, ...updatedAssistant,
model: req.body.model, model: req.body.model,

View file

@ -1,6 +1,6 @@
const express = require('express'); const express = require('express');
const { uaParser, checkBan, requireJwtAuth, configMiddleware } = require('~/server/middleware');
const router = express.Router(); const router = express.Router();
const { uaParser, checkBan, requireJwtAuth } = require('~/server/middleware');
const { v1 } = require('./v1'); const { v1 } = require('./v1');
const chatV1 = require('./chatV1'); const chatV1 = require('./chatV1');
@ -10,6 +10,7 @@ const chatV2 = require('./chatV2');
router.use(requireJwtAuth); router.use(requireJwtAuth);
router.use(checkBan); router.use(checkBan);
router.use(uaParser); router.use(uaParser);
router.use(configMiddleware);
router.use('/v1/', v1); router.use('/v1/', v1);
router.use('/v1/chat', chatV1); router.use('/v1/chat', chatV1);
router.use('/v2/', v2); router.use('/v2/', v2);

View file

@ -1,4 +1,5 @@
const express = require('express'); const express = require('express');
const { configMiddleware } = require('~/server/middleware');
const v1 = require('~/server/controllers/assistants/v1'); const v1 = require('~/server/controllers/assistants/v1');
const v2 = require('~/server/controllers/assistants/v2'); const v2 = require('~/server/controllers/assistants/v2');
const documents = require('./documents'); const documents = require('./documents');
@ -6,6 +7,7 @@ const actions = require('./actions');
const tools = require('./tools'); const tools = require('./tools');
const router = express.Router(); const router = express.Router();
router.use(configMiddleware);
/** /**
* Assistant actions route. * Assistant actions route.

View file

@ -17,12 +17,12 @@ const {
const { verify2FAWithTempToken } = require('~/server/controllers/auth/TwoFactorAuthController'); const { verify2FAWithTempToken } = require('~/server/controllers/auth/TwoFactorAuthController');
const { logoutController } = require('~/server/controllers/auth/LogoutController'); const { logoutController } = require('~/server/controllers/auth/LogoutController');
const { loginController } = require('~/server/controllers/auth/LoginController'); 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 middleware = require('~/server/middleware');
const { Balance } = require('~/db/models'); const { Balance } = require('~/db/models');
const setBalanceConfig = createSetBalanceConfig({ const setBalanceConfig = createSetBalanceConfig({
getBalanceConfig, getAppConfig,
Balance, Balance,
}); });

View file

@ -1,9 +1,14 @@
const express = require('express'); const express = require('express');
const { isEnabled } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas'); const { logger } = require('@librechat/data-schemas');
const { CacheKeys, defaultSocialLogins, Constants } = require('librechat-data-provider'); const { isEnabled, getBalanceConfig } = require('@librechat/api');
const { getCustomConfig } = require('~/server/services/Config/getCustomConfig'); const {
Constants,
CacheKeys,
removeNullishValues,
defaultSocialLogins,
} = require('librechat-data-provider');
const { getLdapConfig } = require('~/server/services/Config/ldap'); const { getLdapConfig } = require('~/server/services/Config/ldap');
const { getAppConfig } = require('~/server/services/Config/app');
const { getProjectByName } = require('~/models/Project'); const { getProjectByName } = require('~/models/Project');
const { getMCPManager } = require('~/config'); const { getMCPManager } = require('~/config');
const { getLogStores } = require('~/cache'); const { getLogStores } = require('~/cache');
@ -43,6 +48,8 @@ router.get('/', async function (req, res) {
const ldap = getLdapConfig(); const ldap = getLdapConfig();
try { try {
const appConfig = await getAppConfig({ role: req.user?.role });
const isOpenIdEnabled = const isOpenIdEnabled =
!!process.env.OPENID_CLIENT_ID && !!process.env.OPENID_CLIENT_ID &&
!!process.env.OPENID_CLIENT_SECRET && !!process.env.OPENID_CLIENT_SECRET &&
@ -55,10 +62,12 @@ router.get('/', async function (req, res) {
!!process.env.SAML_CERT && !!process.env.SAML_CERT &&
!!process.env.SAML_SESSION_SECRET; !!process.env.SAML_SESSION_SECRET;
const balanceConfig = getBalanceConfig(appConfig);
/** @type {TStartupConfig} */ /** @type {TStartupConfig} */
const payload = { const payload = {
appTitle: process.env.APP_TITLE || 'LibreChat', 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, discordLoginEnabled: !!process.env.DISCORD_CLIENT_ID && !!process.env.DISCORD_CLIENT_SECRET,
facebookLoginEnabled: facebookLoginEnabled:
!!process.env.FACEBOOK_CLIENT_ID && !!process.env.FACEBOOK_CLIENT_SECRET, !!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) || isEnabled(process.env.SHOW_BIRTHDAY_ICON) ||
process.env.SHOW_BIRTHDAY_ICON === '', process.env.SHOW_BIRTHDAY_ICON === '',
helpAndFaqURL: process.env.HELP_AND_FAQ_URL || 'https://librechat.ai', helpAndFaqURL: process.env.HELP_AND_FAQ_URL || 'https://librechat.ai',
interface: req.app.locals.interfaceConfig, interface: appConfig?.interfaceConfig,
turnstile: req.app.locals.turnstileConfig, turnstile: appConfig?.turnstileConfig,
modelSpecs: req.app.locals.modelSpecs, modelSpecs: appConfig?.modelSpecs,
balance: req.app.locals.balance, balance: balanceConfig,
sharedLinksEnabled, sharedLinksEnabled,
publicSharedLinksEnabled, publicSharedLinksEnabled,
analyticsGtmId: process.env.ANALYTICS_GTM_ID, analyticsGtmId: process.env.ANALYTICS_GTM_ID,
@ -109,27 +118,31 @@ router.get('/', async function (req, res) {
}; };
payload.mcpServers = {}; payload.mcpServers = {};
const config = await getCustomConfig(); const getMCPServers = () => {
if (config?.mcpServers != null) {
try { try {
const mcpManager = getMCPManager(); const mcpManager = getMCPManager();
if (!mcpManager) {
return;
}
const mcpServers = mcpManager.getAllServers();
if (!mcpServers) return;
const oauthServers = mcpManager.getOAuthServers(); const oauthServers = mcpManager.getOAuthServers();
for (const serverName in config.mcpServers) { for (const serverName in mcpServers) {
const serverConfig = config.mcpServers[serverName]; const serverConfig = mcpServers[serverName];
payload.mcpServers[serverName] = { payload.mcpServers[serverName] = removeNullishValues({
startup: serverConfig?.startup, startup: serverConfig?.startup,
chatMenu: serverConfig?.chatMenu, chatMenu: serverConfig?.chatMenu,
isOAuth: oauthServers?.has(serverName), isOAuth: oauthServers?.has(serverName),
customUserVars: serverConfig?.customUserVars || {}, customUserVars: serverConfig?.customUserVars,
});
}
} catch (error) {
logger.error('Error loading MCP servers', error);
}
}; };
}
} catch (err) {
logger.error('Error loading MCP servers', err);
}
}
/** @type {TCustomConfig['webSearch']} */ getMCPServers();
const webSearchConfig = req.app.locals.webSearch; const webSearchConfig = appConfig?.webSearch;
if ( if (
webSearchConfig != null && webSearchConfig != null &&
(webSearchConfig.searchProvider || (webSearchConfig.searchProvider ||

View file

@ -1,9 +1,7 @@
const express = require('express'); const express = require('express');
const router = express.Router();
const endpointController = require('~/server/controllers/EndpointController'); const endpointController = require('~/server/controllers/EndpointController');
const overrideController = require('~/server/controllers/OverrideController');
const router = express.Router();
router.get('/', endpointController); router.get('/', endpointController);
router.get('/config/override', overrideController);
module.exports = router; module.exports = router;

View file

@ -1,15 +1,16 @@
const fs = require('fs').promises; const fs = require('fs').promises;
const express = require('express'); const express = require('express');
const { logger } = require('@librechat/data-schemas');
const { getStrategyFunctions } = require('~/server/services/Files/strategies'); const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { resizeAvatar } = require('~/server/services/Files/images/avatar'); const { resizeAvatar } = require('~/server/services/Files/images/avatar');
const { filterFile } = require('~/server/services/Files/process');
const { getFileStrategy } = require('~/server/utils/getFileStrategy'); const { getFileStrategy } = require('~/server/utils/getFileStrategy');
const { logger } = require('~/config'); const { filterFile } = require('~/server/services/Files/process');
const router = express.Router(); const router = express.Router();
router.post('/', async (req, res) => { router.post('/', async (req, res) => {
try { try {
const appConfig = req.config;
filterFile({ req, file: req.file, image: true, isAvatar: true }); filterFile({ req, file: req.file, image: true, isAvatar: true });
const userId = req.user.id; const userId = req.user.id;
const { manual } = req.body; const { manual } = req.body;
@ -19,8 +20,8 @@ router.post('/', async (req, res) => {
throw new Error('User ID is undefined'); throw new Error('User ID is undefined');
} }
const fileStrategy = getFileStrategy(req.app.locals, { isAvatar: true }); const fileStrategy = getFileStrategy(appConfig, { isAvatar: true });
const desiredFormat = req.app.locals.imageOutputType; const desiredFormat = appConfig.imageOutputType;
const resizedBuffer = await resizeAvatar({ const resizedBuffer = await resizeAvatar({
userId, userId,
input, input,
@ -39,7 +40,7 @@ router.post('/', async (req, res) => {
try { try {
await fs.unlink(req.file.path); await fs.unlink(req.file.path);
logger.debug('[/files/images/avatar] Temp. image upload file deleted'); logger.debug('[/files/images/avatar] Temp. image upload file deleted');
} catch (error) { } catch {
logger.debug('[/files/images/avatar] Temp. image upload file already deleted'); logger.debug('[/files/images/avatar] Temp. image upload file already deleted');
} }
} }

View file

@ -36,8 +36,9 @@ const router = express.Router();
router.get('/', async (req, res) => { router.get('/', async (req, res) => {
try { try {
const appConfig = req.config;
const files = await getFiles({ user: req.user.id }); const files = await getFiles({ user: req.user.id });
if (req.app.locals.fileStrategy === FileSources.s3) { if (appConfig.fileStrategy === FileSources.s3) {
try { try {
const cache = getLogStores(CacheKeys.S3_EXPIRY_INTERVAL); const cache = getLogStores(CacheKeys.S3_EXPIRY_INTERVAL);
const alreadyChecked = await cache.get(req.user.id); 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) => { router.get('/config', async (req, res) => {
try { try {
res.status(200).json(req.app.locals.fileConfig); const appConfig = req.config;
res.status(200).json(appConfig.fileConfig);
} catch (error) { } catch (error) {
logger.error('[/files] Error getting fileConfig', error); logger.error('[/files] Error getting fileConfig', error);
res.status(400).json({ message: 'Error in request', error: error.message }); res.status(400).json({ message: 'Error in request', error: error.message });

View file

@ -1,18 +1,19 @@
const path = require('path'); const path = require('path');
const fs = require('fs').promises; const fs = require('fs').promises;
const express = require('express'); const express = require('express');
const { logger } = require('@librechat/data-schemas');
const { isAgentsEndpoint } = require('librechat-data-provider'); const { isAgentsEndpoint } = require('librechat-data-provider');
const { const {
filterFile, filterFile,
processImageFile, processImageFile,
processAgentFileUpload, processAgentFileUpload,
} = require('~/server/services/Files/process'); } = require('~/server/services/Files/process');
const { logger } = require('~/config');
const router = express.Router(); const router = express.Router();
router.post('/', async (req, res) => { router.post('/', async (req, res) => {
const metadata = req.body; const metadata = req.body;
const appConfig = req.config;
try { try {
filterFile({ req, image: true }); filterFile({ req, image: true });
@ -30,7 +31,7 @@ router.post('/', async (req, res) => {
logger.error('[/files/images] Error processing file:', error); logger.error('[/files/images] Error processing file:', error);
try { try {
const filepath = path.join( const filepath = path.join(
req.app.locals.paths.imageOutput, appConfig.paths.imageOutput,
req.user.id, req.user.id,
path.basename(req.file.filename), path.basename(req.file.filename),
); );
@ -43,7 +44,7 @@ router.post('/', async (req, res) => {
try { try {
await fs.unlink(req.file.path); await fs.unlink(req.file.path);
logger.debug('[/files/images] Temp. image upload file deleted'); logger.debug('[/files/images] Temp. image upload file deleted');
} catch (error) { } catch {
logger.debug('[/files/images] Temp. image upload file already deleted'); logger.debug('[/files/images] Temp. image upload file already deleted');
} }
} }

View file

@ -1,5 +1,11 @@
const express = require('express'); 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: asstAvatarRouter } = require('~/server/routes/assistants/v1');
const { avatar: agentAvatarRouter } = require('~/server/routes/agents/v1'); const { avatar: agentAvatarRouter } = require('~/server/routes/agents/v1');
const { createMulterInstance } = require('./multer'); const { createMulterInstance } = require('./multer');
@ -12,6 +18,7 @@ const speech = require('./speech');
const initialize = async () => { const initialize = async () => {
const router = express.Router(); const router = express.Router();
router.use(requireJwtAuth); router.use(requireJwtAuth);
router.use(configMiddleware);
router.use(checkBan); router.use(checkBan);
router.use(uaParser); router.use(uaParser);

View file

@ -4,11 +4,12 @@ const crypto = require('crypto');
const multer = require('multer'); const multer = require('multer');
const { sanitizeFilename } = require('@librechat/api'); const { sanitizeFilename } = require('@librechat/api');
const { fileConfig: defaultFileConfig, mergeFileConfig } = require('librechat-data-provider'); const { fileConfig: defaultFileConfig, mergeFileConfig } = require('librechat-data-provider');
const { getCustomConfig } = require('~/server/services/Config'); const { getAppConfig } = require('~/server/services/Config');
const storage = multer.diskStorage({ const storage = multer.diskStorage({
destination: function (req, file, cb) { 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)) { if (!fs.existsSync(outputPath)) {
fs.mkdirSync(outputPath, { recursive: true }); fs.mkdirSync(outputPath, { recursive: true });
} }
@ -68,8 +69,8 @@ const createFileFilter = (customFileConfig) => {
}; };
const createMulterInstance = async () => { const createMulterInstance = async () => {
const customConfig = await getCustomConfig(); const appConfig = await getAppConfig();
const fileConfig = mergeFileConfig(customConfig?.fileConfig); const fileConfig = mergeFileConfig(appConfig?.fileConfig);
const fileFilter = createFileFilter(fileConfig); const fileFilter = createFileFilter(fileConfig);
return multer({ return multer({
storage, storage,

View file

@ -8,21 +8,7 @@ const { createMulterInstance, storage, importFileFilter } = require('./multer');
// Mock only the config service that requires external dependencies // Mock only the config service that requires external dependencies
jest.mock('~/server/services/Config', () => ({ jest.mock('~/server/services/Config', () => ({
getCustomConfig: jest.fn(() => getAppConfig: 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
},
}),
),
})); }));
describe('Multer Configuration', () => { describe('Multer Configuration', () => {
@ -36,15 +22,13 @@ describe('Multer Configuration', () => {
mockReq = { mockReq = {
user: { id: 'test-user-123' }, user: { id: 'test-user-123' },
app: { body: {},
locals: { originalUrl: '/api/files/upload',
config: {
paths: { paths: {
uploads: tempDir, uploads: tempDir,
}, },
}, },
},
body: {},
originalUrl: '/api/files/upload',
}; };
mockFile = { mockFile = {
@ -79,7 +63,7 @@ describe('Multer Configuration', () => {
it("should create directory recursively if it doesn't exist", (done) => { it("should create directory recursively if it doesn't exist", (done) => {
const deepPath = path.join(tempDir, 'deep', 'nested', 'path'); 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) => { const cb = jest.fn((err, destination) => {
expect(err).toBeNull(); expect(err).toBeNull();
@ -331,11 +315,11 @@ describe('Multer Configuration', () => {
}); });
it('should use real config merging', async () => { it('should use real config merging', async () => {
const { getCustomConfig } = require('~/server/services/Config'); const { getAppConfig } = require('~/server/services/Config');
const multerInstance = await createMulterInstance(); const multerInstance = await createMulterInstance();
expect(getCustomConfig).toHaveBeenCalled(); expect(getAppConfig).toHaveBeenCalled();
expect(multerInstance).toBeDefined(); expect(multerInstance).toBeDefined();
}); });
@ -462,26 +446,15 @@ describe('Multer Configuration', () => {
}).not.toThrow(); }).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 // Test with a non-existent parent directory to simulate fs issues
const invalidPath = '/nonexistent/path/that/should/not/exist'; const invalidPath = '/nonexistent/path/that/should/not/exist';
mockReq.app.locals.paths.uploads = invalidPath; mockReq.config.paths.uploads = invalidPath;
try { // The current implementation doesn't catch errors, so they're thrown synchronously
// Call getDestination which should fail due to permission/path issues expect(() => {
storage.getDestination(mockReq, mockFile, (err, destination) => { storage.getDestination(mockReq, mockFile, jest.fn());
// If callback is reached, we didn't get the expected error }).toThrow();
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();
}
}); });
it('should handle malformed filenames with real sanitization', (done) => { it('should handle malformed filenames with real sanitization', (done) => {
@ -538,10 +511,10 @@ describe('Multer Configuration', () => {
describe('Real Configuration Testing', () => { describe('Real Configuration Testing', () => {
it('should handle missing custom config gracefully with real mergeFileConfig', async () => { 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 // Mock getAppConfig to return undefined
getCustomConfig.mockResolvedValueOnce(undefined); getAppConfig.mockResolvedValueOnce(undefined);
const multerInstance = await createMulterInstance(); const multerInstance = await createMulterInstance();
expect(multerInstance).toBeDefined(); expect(multerInstance).toBeDefined();
@ -549,25 +522,28 @@ describe('Multer Configuration', () => {
}); });
it('should properly integrate real fileConfig with custom endpoints', async () => { 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 // Mock appConfig with fileConfig
getCustomConfig.mockResolvedValueOnce({ getAppConfig.mockResolvedValueOnce({
paths: {
uploads: tempDir,
},
fileConfig: { fileConfig: {
endpoints: { endpoints: {
anthropic: { anthropic: {
supportedMimeTypes: ['text/plain', 'image/png'], supportedMimeTypes: ['text/plain', 'image/png'],
}, },
}, },
serverFileSizeLimit: 20, // 20 MB serverFileSizeLimit: 20971520, // 20 MB in bytes (mergeFileConfig converts)
}, },
}); });
const multerInstance = await createMulterInstance(); const multerInstance = await createMulterInstance();
expect(multerInstance).toBeDefined(); expect(multerInstance).toBeDefined();
// Verify that getCustomConfig was called (we can't spy on the actual merge function easily) // Verify that getAppConfig was called
expect(getCustomConfig).toHaveBeenCalled(); expect(getAppConfig).toHaveBeenCalled();
}); });
}); });
}); });

View file

@ -8,7 +8,7 @@ const {
deleteMemory, deleteMemory,
setMemory, setMemory,
} = require('~/models'); } = require('~/models');
const { requireJwtAuth } = require('~/server/middleware'); const { requireJwtAuth, configMiddleware } = require('~/server/middleware');
const { getRoleByName } = require('~/models/Role'); const { getRoleByName } = require('~/models/Role');
const router = express.Router(); const router = express.Router();
@ -48,7 +48,7 @@ router.use(requireJwtAuth);
* Returns all memories for the authenticated user, sorted by updated_at (newest first). * Returns all memories for the authenticated user, sorted by updated_at (newest first).
* Also includes memory usage percentage based on token limit. * Also includes memory usage percentage based on token limit.
*/ */
router.get('/', checkMemoryRead, async (req, res) => { router.get('/', checkMemoryRead, configMiddleware, async (req, res) => {
try { try {
const memories = await getAllUserMemories(req.user.id); const memories = await getAllUserMemories(req.user.id);
@ -60,7 +60,8 @@ router.get('/', checkMemoryRead, async (req, res) => {
return sum + (memory.tokenCount || 0); return sum + (memory.tokenCount || 0);
}, 0); }, 0);
const memoryConfig = req.app.locals?.memory; const appConfig = req.config;
const memoryConfig = appConfig?.memory;
const tokenLimit = memoryConfig?.tokenLimit; const tokenLimit = memoryConfig?.tokenLimit;
const charLimit = memoryConfig?.charLimit || 10000; const charLimit = memoryConfig?.charLimit || 10000;
@ -87,7 +88,7 @@ router.get('/', checkMemoryRead, async (req, res) => {
* Body: { key: string, value: string } * Body: { key: string, value: string }
* Returns 201 and { created: true, memory: <createdDoc> } when successful. * Returns 201 and { created: true, memory: <createdDoc> } when successful.
*/ */
router.post('/', memoryPayloadLimit, checkMemoryCreate, async (req, res) => { router.post('/', memoryPayloadLimit, checkMemoryCreate, configMiddleware, async (req, res) => {
const { key, value } = req.body; const { key, value } = req.body;
if (typeof key !== 'string' || key.trim() === '') { 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.' }); 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; const charLimit = memoryConfig?.charLimit || 10000;
if (key.length > 1000) { if (key.length > 1000) {
@ -117,6 +119,9 @@ router.post('/', memoryPayloadLimit, checkMemoryCreate, async (req, res) => {
const tokenCount = Tokenizer.getTokenCount(value, 'o200k_base'); const tokenCount = Tokenizer.getTokenCount(value, 'o200k_base');
const memories = await getAllUserMemories(req.user.id); const memories = await getAllUserMemories(req.user.id);
const appConfig = req.config;
const memoryConfig = appConfig?.memory;
const tokenLimit = memoryConfig?.tokenLimit; const tokenLimit = memoryConfig?.tokenLimit;
if (tokenLimit) { if (tokenLimit) {
@ -191,7 +196,7 @@ router.patch('/preferences', checkMemoryOptOut, async (req, res) => {
* Body: { key?: string, value: string } * Body: { key?: string, value: string }
* Returns 200 and { updated: true, memory: <updatedDoc> } when successful. * Returns 200 and { updated: true, memory: <updatedDoc> } 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: urlKey } = req.params;
const { key: bodyKey, value } = req.body || {}; const { key: bodyKey, value } = req.body || {};
@ -200,8 +205,8 @@ router.patch('/:key', memoryPayloadLimit, checkMemoryUpdate, async (req, res) =>
} }
const newKey = bodyKey || urlKey; const newKey = bodyKey || urlKey;
const appConfig = req.config;
const memoryConfig = req.app.locals?.memory; const memoryConfig = appConfig?.memory;
const charLimit = memoryConfig?.charLimit || 10000; const charLimit = memoryConfig?.charLimit || 10000;
if (newKey.length > 1000) { if (newKey.length > 1000) {

View file

@ -8,11 +8,11 @@ const { isEnabled, createSetBalanceConfig } = require('@librechat/api');
const { checkDomainAllowed, loginLimiter, logHeaders, checkBan } = require('~/server/middleware'); const { checkDomainAllowed, loginLimiter, logHeaders, checkBan } = require('~/server/middleware');
const { syncUserEntraGroupMemberships } = require('~/server/services/PermissionService'); const { syncUserEntraGroupMemberships } = require('~/server/services/PermissionService');
const { setAuthTokens, setOpenIDAuthTokens } = require('~/server/services/AuthService'); 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 { Balance } = require('~/db/models');
const setBalanceConfig = createSetBalanceConfig({ const setBalanceConfig = createSetBalanceConfig({
getBalanceConfig, getAppConfig,
Balance, Balance,
}); });

View file

@ -1,6 +1,6 @@
const express = require('express'); const express = require('express');
const { getAvailablePluginsController } = require('../controllers/PluginController'); const { getAvailablePluginsController } = require('~/server/controllers/PluginController');
const requireJwtAuth = require('../middleware/requireJwtAuth'); const { requireJwtAuth } = require('~/server/middleware');
const router = express.Router(); const router = express.Router();

View file

@ -424,7 +424,7 @@ router.get('/', async (req, res) => {
/** /**
* Deletes a prompt * Deletes a prompt
* *
* @param {Express.Request} req - The request object. * @param {ServerRequest} req - The request object.
* @param {TDeletePromptVariables} req.params - The request parameters * @param {TDeletePromptVariables} req.params - The request parameters
* @param {import('mongoose').ObjectId} req.params.promptId - The prompt ID * @param {import('mongoose').ObjectId} req.params.promptId - The prompt ID
* @param {Express.Response} res - The response object. * @param {Express.Response} res - The response object.

View file

@ -14,7 +14,6 @@ const {
// Mock modules before importing // Mock modules before importing
jest.mock('~/server/services/Config', () => ({ jest.mock('~/server/services/Config', () => ({
getCachedTools: jest.fn().mockResolvedValue({}), getCachedTools: jest.fn().mockResolvedValue({}),
getCustomConfig: jest.fn(),
})); }));
jest.mock('~/models/Role', () => ({ jest.mock('~/models/Role', () => ({

View file

@ -1,14 +1,14 @@
const express = require('express'); const express = require('express');
const { requireJwtAuth, canDeleteAccount, verifyEmailLimiter } = require('~/server/middleware');
const { const {
getUserController,
deleteUserController,
verifyEmailController,
updateUserPluginsController, updateUserPluginsController,
resendVerificationController, resendVerificationController,
getTermsStatusController, getTermsStatusController,
acceptTermsController, acceptTermsController,
verifyEmailController,
deleteUserController,
getUserController,
} = require('~/server/controllers/UserController'); } = require('~/server/controllers/UserController');
const { requireJwtAuth, canDeleteAccount, verifyEmailLimiter } = require('~/server/middleware');
const router = express.Router(); const router = express.Router();

View file

@ -1,10 +1,7 @@
const { Constants, EModelEndpoint, actionDomainSeparator } = require('librechat-data-provider'); const { Constants, actionDomainSeparator } = require('librechat-data-provider');
const { domainParser } = require('./ActionService'); const { domainParser } = require('./ActionService');
jest.mock('keyv'); jest.mock('keyv');
jest.mock('~/server/services/Config', () => ({
getCustomConfig: jest.fn(),
}));
const globalCache = {}; const globalCache = {};
jest.mock('~/cache/getLogStores', () => { jest.mock('~/cache/getLogStores', () => {
@ -53,26 +50,6 @@ jest.mock('~/cache/getLogStores', () => {
}); });
describe('domainParser', () => { describe('domainParser', () => {
const req = {
app: {
locals: {
[EModelEndpoint.azureOpenAI]: {
assistants: true,
},
},
},
};
const reqNoAzure = {
app: {
locals: {
[EModelEndpoint.azureOpenAI]: {
assistants: false,
},
},
},
};
const TLD = '.com'; const TLD = '.com';
// Non-azure request // Non-azure request

View file

@ -1,15 +1,4 @@
jest.mock('~/models', () => ({ jest.mock('@librechat/data-schemas', () => ({
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', () => ({
logger: { logger: {
info: jest.fn(), info: jest.fn(),
warn: jest.fn(), warn: jest.fn(),
@ -17,11 +6,11 @@ jest.mock('~/config', () => ({
}, },
})); }));
jest.mock('./Config/loadCustomConfig', () => jest.fn()); jest.mock('@librechat/api', () => ({
jest.mock('./start/interface', () => ({ ...jest.requireActual('@librechat/api'),
loadDefaultInterface: jest.fn(), loadDefaultInterface: jest.fn(),
})); }));
jest.mock('./ToolService', () => ({ jest.mock('./start/tools', () => ({
loadAndFormatTools: jest.fn().mockReturnValue({}), loadAndFormatTools: jest.fn().mockReturnValue({}),
})); }));
jest.mock('./start/checks', () => ({ jest.mock('./start/checks', () => ({
@ -32,15 +21,15 @@ jest.mock('./start/checks', () => ({
checkWebSearchConfig: jest.fn(), checkWebSearchConfig: jest.fn(),
})); }));
jest.mock('./Config/loadCustomConfig', () => jest.fn());
const AppService = require('./AppService'); const AppService = require('./AppService');
const { loadDefaultInterface } = require('./start/interface'); const { loadDefaultInterface } = require('@librechat/api');
describe('AppService interface configuration', () => { describe('AppService interface configuration', () => {
let app;
let mockLoadCustomConfig; let mockLoadCustomConfig;
beforeEach(() => { beforeEach(() => {
app = { locals: {} };
jest.resetModules(); jest.resetModules();
jest.clearAllMocks(); jest.clearAllMocks();
mockLoadCustomConfig = require('./Config/loadCustomConfig'); mockLoadCustomConfig = require('./Config/loadCustomConfig');
@ -50,10 +39,16 @@ describe('AppService interface configuration', () => {
mockLoadCustomConfig.mockResolvedValue({}); mockLoadCustomConfig.mockResolvedValue({});
loadDefaultInterface.mockResolvedValue({ prompts: true, bookmarks: true }); loadDefaultInterface.mockResolvedValue({ prompts: true, bookmarks: true });
await AppService(app); const result = await AppService();
expect(app.locals.interfaceConfig.prompts).toBe(true); expect(result).toEqual(
expect(app.locals.interfaceConfig.bookmarks).toBe(true); expect.objectContaining({
interfaceConfig: expect.objectContaining({
prompts: true,
bookmarks: true,
}),
}),
);
expect(loadDefaultInterface).toHaveBeenCalled(); expect(loadDefaultInterface).toHaveBeenCalled();
}); });
@ -61,10 +56,16 @@ describe('AppService interface configuration', () => {
mockLoadCustomConfig.mockResolvedValue({ interface: { prompts: false, bookmarks: false } }); mockLoadCustomConfig.mockResolvedValue({ interface: { prompts: false, bookmarks: false } });
loadDefaultInterface.mockResolvedValue({ 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(result).toEqual(
expect(app.locals.interfaceConfig.bookmarks).toBe(false); expect.objectContaining({
interfaceConfig: expect.objectContaining({
prompts: false,
bookmarks: false,
}),
}),
);
expect(loadDefaultInterface).toHaveBeenCalled(); expect(loadDefaultInterface).toHaveBeenCalled();
}); });
@ -72,10 +73,17 @@ describe('AppService interface configuration', () => {
mockLoadCustomConfig.mockResolvedValue({}); mockLoadCustomConfig.mockResolvedValue({});
loadDefaultInterface.mockResolvedValue({}); loadDefaultInterface.mockResolvedValue({});
await AppService(app); const result = await AppService();
expect(app.locals.interfaceConfig.prompts).toBeUndefined(); expect(result).toEqual(
expect(app.locals.interfaceConfig.bookmarks).toBeUndefined(); 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(); expect(loadDefaultInterface).toHaveBeenCalled();
}); });
@ -83,10 +91,16 @@ describe('AppService interface configuration', () => {
mockLoadCustomConfig.mockResolvedValue({ interface: { prompts: true, bookmarks: false } }); mockLoadCustomConfig.mockResolvedValue({ interface: { prompts: true, bookmarks: false } });
loadDefaultInterface.mockResolvedValue({ 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(result).toEqual(
expect(app.locals.interfaceConfig.bookmarks).toBe(false); expect.objectContaining({
interfaceConfig: expect.objectContaining({
prompts: true,
bookmarks: false,
}),
}),
);
expect(loadDefaultInterface).toHaveBeenCalled(); 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(result).toEqual(
expect(app.locals.interfaceConfig.peoplePicker).toMatchObject({ expect.objectContaining({
interfaceConfig: expect.objectContaining({
peoplePicker: expect.objectContaining({
users: true, users: true,
groups: true, groups: true,
roles: true, roles: true,
}); }),
}),
}),
);
expect(loadDefaultInterface).toHaveBeenCalled(); 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(result).toEqual(
expect(app.locals.interfaceConfig.peoplePicker.groups).toBe(false); expect.objectContaining({
expect(app.locals.interfaceConfig.peoplePicker.roles).toBe(true); interfaceConfig: expect.objectContaining({
peoplePicker: expect.objectContaining({
users: true,
groups: false,
roles: true,
}),
}),
}),
);
}); });
it('should set default peoplePicker permissions when not provided', async () => { 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(result).toEqual(
expect(app.locals.interfaceConfig.peoplePicker.users).toBe(true); expect.objectContaining({
expect(app.locals.interfaceConfig.peoplePicker.groups).toBe(true); interfaceConfig: expect.objectContaining({
expect(app.locals.interfaceConfig.peoplePicker.roles).toBe(true); peoplePicker: expect.objectContaining({
users: true,
groups: true,
roles: true,
}),
}),
}),
);
}); });
}); });

View file

@ -3,6 +3,7 @@ const {
loadMemoryConfig, loadMemoryConfig,
agentsConfigSetup, agentsConfigSetup,
loadWebSearchConfig, loadWebSearchConfig,
loadDefaultInterface,
} = require('@librechat/api'); } = require('@librechat/api');
const { const {
FileSources, FileSources,
@ -12,35 +13,26 @@ const {
} = require('librechat-data-provider'); } = require('librechat-data-provider');
const { const {
checkWebSearchConfig, checkWebSearchConfig,
checkAzureVariables,
checkVariables, checkVariables,
checkHealth, checkHealth,
checkConfig, checkConfig,
} = require('./start/checks'); } = require('./start/checks');
const { ensureDefaultCategories, seedDefaultRoles, initializeRoles } = require('~/models');
const { azureAssistantsDefaults, assistantsConfigSetup } = require('./start/assistants');
const { initializeAzureBlobService } = require('./Files/Azure/initialize'); const { initializeAzureBlobService } = require('./Files/Azure/initialize');
const { initializeFirebase } = require('./Files/Firebase/initialize'); const { initializeFirebase } = require('./Files/Firebase/initialize');
const loadCustomConfig = require('./Config/loadCustomConfig');
const handleRateLimits = require('./Config/handleRateLimits'); const handleRateLimits = require('./Config/handleRateLimits');
const { loadDefaultInterface } = require('./start/interface'); const loadCustomConfig = require('./Config/loadCustomConfig');
const { loadTurnstileConfig } = require('./start/turnstile'); const { loadTurnstileConfig } = require('./start/turnstile');
const { azureConfigSetup } = require('./start/azureOpenAI');
const { processModelSpecs } = require('./start/modelSpecs'); const { processModelSpecs } = require('./start/modelSpecs');
const { initializeS3 } = require('./Files/S3/initialize'); const { initializeS3 } = require('./Files/S3/initialize');
const { loadAndFormatTools } = require('./ToolService'); const { loadAndFormatTools } = require('./start/tools');
const { setCachedTools } = require('./Config'); const { loadEndpoints } = require('./start/endpoints');
const paths = require('~/config/paths'); const paths = require('~/config/paths');
/** /**
* Loads custom config and initializes app-wide variables. * Loads custom config and initializes app-wide variables.
* @function AppService * @function AppService
* @param {Express.Application} app - The Express application object.
*/ */
const AppService = async (app) => { const AppService = async () => {
await initializeRoles();
await seedDefaultRoles();
await ensureDefaultCategories();
/** @type {TCustomConfig} */ /** @type {TCustomConfig} */
const config = (await loadCustomConfig()) ?? {}; const config = (await loadCustomConfig()) ?? {};
const configDefaults = getConfigDefaults(); const configDefaults = getConfigDefaults();
@ -79,101 +71,57 @@ const AppService = async (app) => {
directory: paths.structuredTools, directory: paths.structuredTools,
}); });
await setCachedTools(availableTools, { isGlobal: true });
// Store MCP config for later initialization
const mcpConfig = config.mcpServers || null; const mcpConfig = config.mcpServers || null;
const registration = config.registration ?? configDefaults.registration;
const socialLogins = const interfaceConfig = await loadDefaultInterface({ config, configDefaults });
config?.registration?.socialLogins ?? configDefaults?.registration?.socialLogins;
const interfaceConfig = await loadDefaultInterface(config, configDefaults);
const turnstileConfig = loadTurnstileConfig(config, configDefaults); const turnstileConfig = loadTurnstileConfig(config, configDefaults);
const speech = config.speech;
const defaultLocals = { const defaultConfig = {
config,
ocr, ocr,
paths, paths,
config,
memory, memory,
speech,
balance,
mcpConfig,
webSearch, webSearch,
fileStrategy, fileStrategy,
socialLogins, registration,
filteredTools, filteredTools,
includedTools, includedTools,
availableTools,
imageOutputType, imageOutputType,
interfaceConfig, interfaceConfig,
turnstileConfig, turnstileConfig,
balance, fileStrategies: config.fileStrategies,
mcpConfig,
}; };
const agentsDefaults = agentsConfigSetup(config); const agentsDefaults = agentsConfigSetup(config);
if (!Object.keys(config).length) { if (!Object.keys(config).length) {
app.locals = { const appConfig = {
...defaultLocals, ...defaultConfig,
endpoints: {
[EModelEndpoint.agents]: agentsDefaults, [EModelEndpoint.agents]: agentsDefaults,
},
}; };
return; return appConfig;
} }
checkConfig(config); checkConfig(config);
handleRateLimits(config?.rateLimits); handleRateLimits(config?.rateLimits);
const loadedEndpoints = loadEndpoints(config, agentsDefaults);
const endpointLocals = {}; const appConfig = {
const endpoints = config?.endpoints; ...defaultConfig,
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,
fileConfig: config?.fileConfig, fileConfig: config?.fileConfig,
secureImageLinks: config?.secureImageLinks, secureImageLinks: config?.secureImageLinks,
modelSpecs: processModelSpecs(endpoints, config.modelSpecs, interfaceConfig), modelSpecs: processModelSpecs(config?.endpoints, config.modelSpecs, interfaceConfig),
...endpointLocals, endpoints: loadedEndpoints,
}; };
return appConfig;
}; };
module.exports = AppService; module.exports = AppService;

View file

@ -10,10 +10,24 @@ const {
conflictingAzureVariables, conflictingAzureVariables,
} = require('librechat-data-provider'); } = 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'); const AppService = require('./AppService');
jest.mock('./Config/loadCustomConfig', () => { jest.mock('./Files/Firebase/initialize', () => ({
return jest.fn(() => initializeFirebase: jest.fn(),
}));
jest.mock('./Config/loadCustomConfig', () =>
jest.fn(() =>
Promise.resolve({ Promise.resolve({
registration: { socialLogins: ['testLogin'] }, registration: { socialLogins: ['testLogin'] },
fileStrategy: 'testStrategy', fileStrategy: 'testStrategy',
@ -21,40 +35,10 @@ jest.mock('./Config/loadCustomConfig', () => {
enabled: true, enabled: true,
}, },
}), }),
),
); );
});
jest.mock('./Files/Firebase/initialize', () => ({ jest.mock('./start/tools', () => ({
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', () => ({
loadAndFormatTools: jest.fn().mockReturnValue({ loadAndFormatTools: jest.fn().mockReturnValue({
ExampleTool: { ExampleTool: {
type: 'function', type: 'function',
@ -116,28 +100,36 @@ const azureGroups = [
}, },
]; ];
jest.mock('./start/checks', () => ({
...jest.requireActual('./start/checks'),
checkHealth: jest.fn(),
}));
describe('AppService', () => { describe('AppService', () => {
let app;
const mockedTurnstileConfig = { const mockedTurnstileConfig = {
siteKey: 'default-site-key', siteKey: 'default-site-key',
options: {}, options: {},
}; };
const loadCustomConfig = require('./Config/loadCustomConfig');
beforeEach(() => { beforeEach(() => {
app = { locals: {} };
process.env.CDN_PROVIDER = undefined; process.env.CDN_PROVIDER = undefined;
jest.clearAllMocks();
}); });
it('should correctly assign process.env and app.locals based on custom config', async () => { it('should correctly assign process.env and initialize app config based on custom config', async () => {
await AppService(app); const result = await AppService();
expect(process.env.CDN_PROVIDER).toEqual('testStrategy'); expect(process.env.CDN_PROVIDER).toEqual('testStrategy');
expect(app.locals).toEqual({ expect(result).toEqual(
expect.objectContaining({
config: expect.objectContaining({ config: expect.objectContaining({
fileStrategy: 'testStrategy', fileStrategy: 'testStrategy',
}), }),
registration: expect.objectContaining({
socialLogins: ['testLogin'], socialLogins: ['testLogin'],
}),
fileStrategy: 'testStrategy', fileStrategy: 'testStrategy',
interfaceConfig: expect.objectContaining({ interfaceConfig: expect.objectContaining({
endpointsMenu: true, endpointsMenu: true,
@ -157,7 +149,7 @@ describe('AppService', () => {
balance: { enabled: true }, balance: { enabled: true },
filteredTools: undefined, filteredTools: undefined,
includedTools: undefined, includedTools: undefined,
webSearch: { webSearch: expect.objectContaining({
safeSearch: 1, safeSearch: 1,
jinaApiKey: '${JINA_API_KEY}', jinaApiKey: '${JINA_API_KEY}',
cohereApiKey: '${COHERE_API_KEY}', cohereApiKey: '${COHERE_API_KEY}',
@ -166,20 +158,23 @@ describe('AppService', () => {
firecrawlApiKey: '${FIRECRAWL_API_KEY}', firecrawlApiKey: '${FIRECRAWL_API_KEY}',
firecrawlApiUrl: '${FIRECRAWL_API_URL}', firecrawlApiUrl: '${FIRECRAWL_API_URL}',
searxngInstanceUrl: '${SEARXNG_INSTANCE_URL}', searxngInstanceUrl: '${SEARXNG_INSTANCE_URL}',
}, }),
memory: undefined, memory: undefined,
agents: { endpoints: expect.objectContaining({
agents: expect.objectContaining({
disableBuilder: false, disableBuilder: false,
capabilities: expect.arrayContaining([...defaultAgentCapabilities]), capabilities: expect.arrayContaining([...defaultAgentCapabilities]),
maxCitations: 30, maxCitations: 30,
maxCitationsPerFile: 7, maxCitationsPerFile: 7,
minRelevanceScore: 0.45, minRelevanceScore: 0.45,
}, }),
}); }),
}),
);
}); });
it('should log a warning if the config version is outdated', async () => { it('should log a warning if the config version is outdated', async () => {
require('./Config/loadCustomConfig').mockImplementationOnce(() => loadCustomConfig.mockImplementationOnce(() =>
Promise.resolve({ Promise.resolve({
version: '0.9.0', // An outdated version for this test version: '0.9.0', // An outdated version for this test
registration: { socialLogins: ['testLogin'] }, 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')); expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('Outdated Config version'));
}); });
it('should change the `imageOutputType` based on config value', async () => { it('should change the `imageOutputType` based on config value', async () => {
require('./Config/loadCustomConfig').mockImplementationOnce(() => loadCustomConfig.mockImplementationOnce(() =>
Promise.resolve({ Promise.resolve({
version: '0.10.0', version: '0.10.0',
imageOutputType: EImageOutputType.WEBP, imageOutputType: EImageOutputType.WEBP,
}), }),
); );
await AppService(app); const result = await AppService();
expect(app.locals.imageOutputType).toEqual(EImageOutputType.WEBP); expect(result).toEqual(
expect.objectContaining({
imageOutputType: EImageOutputType.WEBP,
}),
);
}); });
it('should default to `PNG` `imageOutputType` with no provided type', async () => { it('should default to `PNG` `imageOutputType` with no provided type', async () => {
require('./Config/loadCustomConfig').mockImplementationOnce(() => loadCustomConfig.mockImplementationOnce(() =>
Promise.resolve({ Promise.resolve({
version: '0.10.0', version: '0.10.0',
}), }),
); );
await AppService(app); const result = await AppService();
expect(app.locals.imageOutputType).toEqual(EImageOutputType.PNG); expect(result).toEqual(
expect.objectContaining({
imageOutputType: EImageOutputType.PNG,
}),
);
}); });
it('should default to `PNG` `imageOutputType` with no provided config', async () => { 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); const result = await AppService();
expect(app.locals.imageOutputType).toEqual(EImageOutputType.PNG); expect(result).toEqual(
expect.objectContaining({
imageOutputType: EImageOutputType.PNG,
}),
);
}); });
it('should initialize Firebase when fileStrategy is firebase', async () => { it('should initialize Firebase when fileStrategy is firebase', async () => {
require('./Config/loadCustomConfig').mockImplementationOnce(() => loadCustomConfig.mockImplementationOnce(() =>
Promise.resolve({ Promise.resolve({
fileStrategy: FileSources.firebase, fileStrategy: FileSources.firebase,
}), }),
); );
await AppService(app); await AppService();
const { initializeFirebase } = require('./Files/Firebase/initialize'); const { initializeFirebase } = require('./Files/Firebase/initialize');
expect(initializeFirebase).toHaveBeenCalled(); expect(initializeFirebase).toHaveBeenCalled();
@ -239,10 +246,9 @@ describe('AppService', () => {
}); });
it('should load and format tools accurately with defined structure', async () => { it('should load and format tools accurately with defined structure', async () => {
const { loadAndFormatTools } = require('./ToolService'); const { loadAndFormatTools } = require('./start/tools');
const { setCachedTools, getCachedTools } = require('./Config');
await AppService(app); const result = await AppService();
expect(loadAndFormatTools).toHaveBeenCalledWith({ expect(loadAndFormatTools).toHaveBeenCalledWith({
adminFilter: undefined, adminFilter: undefined,
@ -250,31 +256,9 @@ describe('AppService', () => {
directory: expect.anything(), directory: expect.anything(),
}); });
// Verify setCachedTools was called with the tools // Verify tools are included in the returned config
expect(setCachedTools).toHaveBeenCalledWith( expect(result.availableTools).toBeDefined();
{ expect(result.availableTools.ExampleTool).toEqual({
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({
type: 'function', type: 'function',
function: { function: {
description: 'Example tool function', description: 'Example tool function',
@ -291,7 +275,7 @@ describe('AppService', () => {
}); });
it('should correctly configure Assistants endpoint based on custom config', async () => { it('should correctly configure Assistants endpoint based on custom config', async () => {
require('./Config/loadCustomConfig').mockImplementationOnce(() => loadCustomConfig.mockImplementationOnce(() =>
Promise.resolve({ Promise.resolve({
endpoints: { endpoints: {
[EModelEndpoint.assistants]: { [EModelEndpoint.assistants]: {
@ -305,22 +289,25 @@ describe('AppService', () => {
}), }),
); );
await AppService(app); const result = await AppService();
expect(app.locals).toHaveProperty(EModelEndpoint.assistants); expect(result).toEqual(
expect(app.locals[EModelEndpoint.assistants]).toEqual(
expect.objectContaining({ expect.objectContaining({
endpoints: expect.objectContaining({
[EModelEndpoint.assistants]: expect.objectContaining({
disableBuilder: true, disableBuilder: true,
pollIntervalMs: 5000, pollIntervalMs: 5000,
timeoutMs: 30000, timeoutMs: 30000,
supportedIds: expect.arrayContaining(['id1', 'id2']), supportedIds: expect.arrayContaining(['id1', 'id2']),
privateAssistants: false, privateAssistants: false,
}), }),
}),
}),
); );
}); });
it('should correctly configure Agents endpoint based on custom config', async () => { it('should correctly configure Agents endpoint based on custom config', async () => {
require('./Config/loadCustomConfig').mockImplementationOnce(() => loadCustomConfig.mockImplementationOnce(() =>
Promise.resolve({ Promise.resolve({
endpoints: { endpoints: {
[EModelEndpoint.agents]: { [EModelEndpoint.agents]: {
@ -334,36 +321,45 @@ describe('AppService', () => {
}), }),
); );
await AppService(app); const result = await AppService();
expect(app.locals).toHaveProperty(EModelEndpoint.agents); expect(result).toEqual(
expect(app.locals[EModelEndpoint.agents]).toEqual(
expect.objectContaining({ expect.objectContaining({
endpoints: expect.objectContaining({
[EModelEndpoint.agents]: expect.objectContaining({
disableBuilder: true, disableBuilder: true,
recursionLimit: 10, recursionLimit: 10,
maxRecursionLimit: 20, maxRecursionLimit: 20,
allowedProviders: expect.arrayContaining(['openai', 'anthropic']), allowedProviders: expect.arrayContaining(['openai', 'anthropic']),
capabilities: expect.arrayContaining([AgentCapabilities.tools, AgentCapabilities.actions]), capabilities: expect.arrayContaining([
AgentCapabilities.tools,
AgentCapabilities.actions,
]),
}),
}),
}), }),
); );
}); });
it('should configure Agents endpoint with defaults when no config is provided', async () => { 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(result).toEqual(
expect(app.locals[EModelEndpoint.agents]).toEqual(
expect.objectContaining({ expect.objectContaining({
endpoints: expect.objectContaining({
[EModelEndpoint.agents]: expect.objectContaining({
disableBuilder: false, disableBuilder: false,
capabilities: expect.arrayContaining([...defaultAgentCapabilities]), capabilities: expect.arrayContaining([...defaultAgentCapabilities]),
}), }),
}),
}),
); );
}); });
it('should configure Agents endpoint with defaults when endpoints exist but agents is not defined', async () => { it('should configure Agents endpoint with defaults when endpoints exist but agents is not defined', async () => {
require('./Config/loadCustomConfig').mockImplementationOnce(() => loadCustomConfig.mockImplementationOnce(() =>
Promise.resolve({ Promise.resolve({
endpoints: { endpoints: {
[EModelEndpoint.openAI]: { [EModelEndpoint.openAI]: {
@ -373,20 +369,26 @@ describe('AppService', () => {
}), }),
); );
await AppService(app); const result = await AppService();
expect(app.locals).toHaveProperty(EModelEndpoint.agents); expect(result).toEqual(
expect(app.locals[EModelEndpoint.agents]).toEqual(
expect.objectContaining({ expect.objectContaining({
endpoints: expect.objectContaining({
[EModelEndpoint.agents]: expect.objectContaining({
disableBuilder: false, disableBuilder: false,
capabilities: expect.arrayContaining([...defaultAgentCapabilities]), capabilities: expect.arrayContaining([...defaultAgentCapabilities]),
}), }),
[EModelEndpoint.openAI]: expect.objectContaining({
titleConvo: true,
}),
}),
}),
); );
}); });
it('should correctly configure minimum Azure OpenAI Assistant values', async () => { it('should correctly configure minimum Azure OpenAI Assistant values', async () => {
const assistantGroups = [azureGroups[0], { ...azureGroups[1], assistants: true }]; const assistantGroups = [azureGroups[0], { ...azureGroups[1], assistants: true }];
require('./Config/loadCustomConfig').mockImplementationOnce(() => loadCustomConfig.mockImplementationOnce(() =>
Promise.resolve({ Promise.resolve({
endpoints: { endpoints: {
[EModelEndpoint.azureOpenAI]: { [EModelEndpoint.azureOpenAI]: {
@ -400,13 +402,24 @@ describe('AppService', () => {
process.env.WESTUS_API_KEY = 'westus-key'; process.env.WESTUS_API_KEY = 'westus-key';
process.env.EASTUS_API_KEY = 'eastus-key'; process.env.EASTUS_API_KEY = 'eastus-key';
await AppService(app); const result = await AppService();
expect(app.locals).toHaveProperty(EModelEndpoint.azureAssistants); expect(result).toEqual(
expect(app.locals[EModelEndpoint.azureAssistants].capabilities.length).toEqual(3); 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 () => { it('should correctly configure Azure OpenAI endpoint based on custom config', async () => {
require('./Config/loadCustomConfig').mockImplementationOnce(() => loadCustomConfig.mockImplementationOnce(() =>
Promise.resolve({ Promise.resolve({
endpoints: { endpoints: {
[EModelEndpoint.azureOpenAI]: { [EModelEndpoint.azureOpenAI]: {
@ -419,18 +432,20 @@ describe('AppService', () => {
process.env.WESTUS_API_KEY = 'westus-key'; process.env.WESTUS_API_KEY = 'westus-key';
process.env.EASTUS_API_KEY = 'eastus-key'; process.env.EASTUS_API_KEY = 'eastus-key';
await AppService(app); const result = await AppService();
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 { modelNames, modelGroupMap, groupMap } = validateAzureGroups(azureGroups); const { modelNames, modelGroupMap, groupMap } = validateAzureGroups(azureGroups);
expect(azureConfig.modelNames).toEqual(modelNames); expect(result).toEqual(
expect(azureConfig.modelGroupMap).toEqual(modelGroupMap); expect.objectContaining({
expect(azureConfig.groupMap).toEqual(groupMap); endpoints: expect.objectContaining({
[EModelEndpoint.azureOpenAI]: expect.objectContaining({
modelNames,
modelGroupMap,
groupMap,
}),
}),
}),
);
}); });
it('should not modify FILE_UPLOAD environment variables without rate limits', async () => { it('should not modify FILE_UPLOAD environment variables without rate limits', async () => {
@ -442,7 +457,7 @@ describe('AppService', () => {
const initialEnv = { ...process.env }; const initialEnv = { ...process.env };
await AppService(app); await AppService();
// Expect environment variables to remain unchanged // Expect environment variables to remain unchanged
expect(process.env.FILE_UPLOAD_IP_MAX).toEqual(initialEnv.FILE_UPLOAD_IP_MAX); expect(process.env.FILE_UPLOAD_IP_MAX).toEqual(initialEnv.FILE_UPLOAD_IP_MAX);
@ -464,11 +479,9 @@ describe('AppService', () => {
}, },
}; };
require('./Config/loadCustomConfig').mockImplementationOnce(() => loadCustomConfig.mockImplementationOnce(() => Promise.resolve(rateLimitsConfig));
Promise.resolve(rateLimitsConfig),
);
await AppService(app); await AppService();
// Verify that process.env has been updated according to the rate limits config // Verify that process.env has been updated according to the rate limits config
expect(process.env.FILE_UPLOAD_IP_MAX).toEqual('100'); 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_MAX = 'initialUserMax';
process.env.FILE_UPLOAD_USER_WINDOW = 'initialUserWindow'; process.env.FILE_UPLOAD_USER_WINDOW = 'initialUserWindow';
await AppService(app); await AppService();
// Verify that process.env falls back to the initial values // Verify that process.env falls back to the initial values
expect(process.env.FILE_UPLOAD_IP_MAX).toEqual('initialMax'); expect(process.env.FILE_UPLOAD_IP_MAX).toEqual('initialMax');
@ -502,7 +515,7 @@ describe('AppService', () => {
const initialEnv = { ...process.env }; const initialEnv = { ...process.env };
await AppService(app); await AppService();
// Expect environment variables to remain unchanged // Expect environment variables to remain unchanged
expect(process.env.IMPORT_IP_MAX).toEqual(initialEnv.IMPORT_IP_MAX); expect(process.env.IMPORT_IP_MAX).toEqual(initialEnv.IMPORT_IP_MAX);
@ -524,11 +537,9 @@ describe('AppService', () => {
}, },
}; };
require('./Config/loadCustomConfig').mockImplementationOnce(() => loadCustomConfig.mockImplementationOnce(() => Promise.resolve(importLimitsConfig));
Promise.resolve(importLimitsConfig),
);
await AppService(app); await AppService();
// Verify that process.env has been updated according to the rate limits config // Verify that process.env has been updated according to the rate limits config
expect(process.env.IMPORT_IP_MAX).toEqual('150'); expect(process.env.IMPORT_IP_MAX).toEqual('150');
@ -544,7 +555,7 @@ describe('AppService', () => {
process.env.IMPORT_USER_MAX = 'initialUserMax'; process.env.IMPORT_USER_MAX = 'initialUserMax';
process.env.IMPORT_USER_WINDOW = 'initialUserWindow'; process.env.IMPORT_USER_WINDOW = 'initialUserWindow';
await AppService(app); await AppService();
// Verify that process.env falls back to the initial values // Verify that process.env falls back to the initial values
expect(process.env.IMPORT_IP_MAX).toEqual('initialMax'); 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 () => { it('should correctly configure endpoint with titlePrompt, titleMethod, and titlePromptTemplate', async () => {
require('./Config/loadCustomConfig').mockImplementationOnce(() => loadCustomConfig.mockImplementationOnce(() =>
Promise.resolve({ Promise.resolve({
endpoints: { endpoints: {
[EModelEndpoint.openAI]: { [EModelEndpoint.openAI]: {
@ -581,43 +592,40 @@ describe('AppService', () => {
}), }),
); );
await AppService(app); const result = await AppService();
// Check OpenAI endpoint configuration expect(result).toEqual(
expect(app.locals).toHaveProperty(EModelEndpoint.openAI);
expect(app.locals[EModelEndpoint.openAI]).toEqual(
expect.objectContaining({ expect.objectContaining({
endpoints: expect.objectContaining({
// Check OpenAI endpoint configuration
[EModelEndpoint.openAI]: expect.objectContaining({
titleConvo: true, titleConvo: true,
titleModel: 'gpt-3.5-turbo', titleModel: 'gpt-3.5-turbo',
titleMethod: 'structured', titleMethod: 'structured',
titlePrompt: 'Custom title prompt for conversation', titlePrompt: 'Custom title prompt for conversation',
titlePromptTemplate: 'Summarize this conversation: {{conversation}}', titlePromptTemplate: 'Summarize this conversation: {{conversation}}',
}), }),
);
// Check Assistants endpoint configuration // Check Assistants endpoint configuration
expect(app.locals).toHaveProperty(EModelEndpoint.assistants); [EModelEndpoint.assistants]: expect.objectContaining({
expect(app.locals[EModelEndpoint.assistants]).toMatchObject({
titleMethod: 'functions', titleMethod: 'functions',
titlePrompt: 'Generate a title for this assistant conversation', titlePrompt: 'Generate a title for this assistant conversation',
titlePromptTemplate: 'Assistant conversation template: {{messages}}', titlePromptTemplate: 'Assistant conversation template: {{messages}}',
}); }),
// Check Azure OpenAI endpoint configuration // Check Azure OpenAI endpoint configuration
expect(app.locals).toHaveProperty(EModelEndpoint.azureOpenAI); [EModelEndpoint.azureOpenAI]: expect.objectContaining({
expect(app.locals[EModelEndpoint.azureOpenAI]).toEqual(
expect.objectContaining({
titleConvo: true, titleConvo: true,
titleMethod: 'completion', titleMethod: 'completion',
titleModel: 'gpt-4', titleModel: 'gpt-4',
titlePrompt: 'Azure title prompt', titlePrompt: 'Azure title prompt',
titlePromptTemplate: 'Azure conversation: {{context}}', titlePromptTemplate: 'Azure conversation: {{context}}',
}), }),
}),
}),
); );
}); });
it('should configure Agent endpoint with title generation settings', async () => { it('should configure Agent endpoint with title generation settings', async () => {
require('./Config/loadCustomConfig').mockImplementationOnce(() => loadCustomConfig.mockImplementationOnce(() =>
Promise.resolve({ Promise.resolve({
endpoints: { endpoints: {
[EModelEndpoint.agents]: { [EModelEndpoint.agents]: {
@ -634,10 +642,12 @@ describe('AppService', () => {
}), }),
); );
await AppService(app); const result = await AppService();
expect(app.locals).toHaveProperty(EModelEndpoint.agents); expect(result).toEqual(
expect(app.locals[EModelEndpoint.agents]).toMatchObject({ expect.objectContaining({
endpoints: expect.objectContaining({
[EModelEndpoint.agents]: expect.objectContaining({
disableBuilder: false, disableBuilder: false,
titleConvo: true, titleConvo: true,
titleModel: 'gpt-4', titleModel: 'gpt-4',
@ -645,12 +655,18 @@ describe('AppService', () => {
titlePrompt: 'Generate a descriptive title for this agent conversation', titlePrompt: 'Generate a descriptive title for this agent conversation',
titlePromptTemplate: 'Agent conversation summary: {{content}}', titlePromptTemplate: 'Agent conversation summary: {{content}}',
recursionLimit: 15, recursionLimit: 15,
capabilities: expect.arrayContaining([AgentCapabilities.tools, AgentCapabilities.actions]), capabilities: expect.arrayContaining([
}); AgentCapabilities.tools,
AgentCapabilities.actions,
]),
}),
}),
}),
);
}); });
it('should handle missing title configuration options with defaults', async () => { it('should handle missing title configuration options with defaults', async () => {
require('./Config/loadCustomConfig').mockImplementationOnce(() => loadCustomConfig.mockImplementationOnce(() =>
Promise.resolve({ Promise.resolve({
endpoints: { endpoints: {
[EModelEndpoint.openAI]: { [EModelEndpoint.openAI]: {
@ -661,20 +677,26 @@ describe('AppService', () => {
}), }),
); );
await AppService(app); const result = await AppService();
expect(app.locals).toHaveProperty(EModelEndpoint.openAI); expect(result).toEqual(
expect(app.locals[EModelEndpoint.openAI]).toMatchObject({ expect.objectContaining({
endpoints: expect.objectContaining({
[EModelEndpoint.openAI]: expect.objectContaining({
titleConvo: true, 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();
// 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 () => { it('should correctly configure titleEndpoint when specified', async () => {
require('./Config/loadCustomConfig').mockImplementationOnce(() => loadCustomConfig.mockImplementationOnce(() =>
Promise.resolve({ Promise.resolve({
endpoints: { endpoints: {
[EModelEndpoint.openAI]: { [EModelEndpoint.openAI]: {
@ -691,27 +713,30 @@ describe('AppService', () => {
}), }),
); );
await AppService(app); const result = await AppService();
expect(result).toEqual(
expect.objectContaining({
endpoints: expect.objectContaining({
// Check OpenAI endpoint has titleEndpoint // Check OpenAI endpoint has titleEndpoint
expect(app.locals).toHaveProperty(EModelEndpoint.openAI); [EModelEndpoint.openAI]: expect.objectContaining({
expect(app.locals[EModelEndpoint.openAI]).toMatchObject({
titleConvo: true, titleConvo: true,
titleModel: 'gpt-3.5-turbo', titleModel: 'gpt-3.5-turbo',
titleEndpoint: EModelEndpoint.anthropic, titleEndpoint: EModelEndpoint.anthropic,
titlePrompt: 'Generate a concise title', titlePrompt: 'Generate a concise title',
}); }),
// Check Agents endpoint has titleEndpoint // Check Agents endpoint has titleEndpoint
expect(app.locals).toHaveProperty(EModelEndpoint.agents); [EModelEndpoint.agents]: expect.objectContaining({
expect(app.locals[EModelEndpoint.agents]).toMatchObject({
titleEndpoint: 'custom-provider', titleEndpoint: 'custom-provider',
titleMethod: 'structured', titleMethod: 'structured',
}); }),
}),
}),
);
}); });
it('should correctly configure all endpoint when specified', async () => { it('should correctly configure all endpoint when specified', async () => {
require('./Config/loadCustomConfig').mockImplementationOnce(() => loadCustomConfig.mockImplementationOnce(() =>
Promise.resolve({ Promise.resolve({
endpoints: { endpoints: {
all: { all: {
@ -731,11 +756,13 @@ describe('AppService', () => {
}), }),
); );
await AppService(app); const result = await AppService();
expect(result).toEqual(
expect.objectContaining({
// Check that 'all' endpoint config is loaded // Check that 'all' endpoint config is loaded
expect(app.locals).toHaveProperty('all'); endpoints: expect.objectContaining({
expect(app.locals.all).toMatchObject({ all: expect.objectContaining({
titleConvo: true, titleConvo: true,
titleModel: 'gpt-4o-mini', titleModel: 'gpt-4o-mini',
titleMethod: 'structured', titleMethod: 'structured',
@ -743,27 +770,28 @@ describe('AppService', () => {
titlePromptTemplate: 'Default template: {{conversation}}', titlePromptTemplate: 'Default template: {{conversation}}',
titleEndpoint: EModelEndpoint.anthropic, titleEndpoint: EModelEndpoint.anthropic,
streamRate: 50, streamRate: 50,
}); }),
// Check that OpenAI endpoint has its own config // Check that OpenAI endpoint has its own config
expect(app.locals).toHaveProperty(EModelEndpoint.openAI); [EModelEndpoint.openAI]: expect.objectContaining({
expect(app.locals[EModelEndpoint.openAI]).toMatchObject({
titleConvo: true, titleConvo: true,
titleModel: 'gpt-3.5-turbo', titleModel: 'gpt-3.5-turbo',
}); }),
}),
}),
);
}); });
}); });
describe('AppService updating app.locals and issuing warnings', () => { describe('AppService updating app config and issuing warnings', () => {
let app;
let initialEnv; let initialEnv;
const loadCustomConfig = require('./Config/loadCustomConfig');
beforeEach(() => { beforeEach(() => {
// Store initial environment variables to restore them after each test // Store initial environment variables to restore them after each test
initialEnv = { ...process.env }; initialEnv = { ...process.env };
app = { locals: {} };
process.env.CDN_PROVIDER = undefined; process.env.CDN_PROVIDER = undefined;
jest.clearAllMocks();
}); });
afterEach(() => { afterEach(() => {
@ -771,26 +799,29 @@ describe('AppService updating app.locals and issuing warnings', () => {
process.env = { ...initialEnv }; 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 // 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(result).toEqual(
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.objectContaining({ expect.objectContaining({
paths: expect.anything(),
config: {},
fileStrategy: FileSources.local,
registration: expect.objectContaining({
socialLogins: defaultSocialLogins,
}),
balance: expect.objectContaining({
enabled: false, enabled: false,
startBalance: undefined, 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 // Mock loadCustomConfig to return a specific config object with a complete balance config
const customConfig = { const customConfig = {
fileStrategy: 'firebase', fileStrategy: 'firebase',
@ -804,21 +835,24 @@ describe('AppService updating app.locals and issuing warnings', () => {
refillAmount: 5000, refillAmount: 5000,
}, },
}; };
require('./Config/loadCustomConfig').mockImplementationOnce(() => loadCustomConfig.mockImplementationOnce(() => Promise.resolve(customConfig));
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 = { const mockConfig = {
endpoints: { endpoints: {
assistants: { 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: {} }; const result = await AppService();
await AppService(app);
expect(app.locals).toHaveProperty('assistants'); expect(result).toEqual(
const { assistants } = app.locals; expect.objectContaining({
expect(assistants.disableBuilder).toBe(true); endpoints: expect.objectContaining({
expect(assistants.pollIntervalMs).toBe(5000); assistants: expect.objectContaining({
expect(assistants.timeoutMs).toBe(30000); disableBuilder: true,
expect(assistants.supportedIds).toEqual(['id1', 'id2']); pollIntervalMs: 5000,
expect(assistants.excludedIds).toBeUndefined(); 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 () => { 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 AppService();
await require('./AppService')(app);
const { logger } = require('~/config'); const { logger } = require('@librechat/data-schemas');
expect(logger.warn).toHaveBeenCalledWith( expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining( expect.stringContaining(
"The 'assistants' endpoint has both 'supportedIds' and 'excludedIds' defined.", "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 AppService();
await require('./AppService')(app);
const { logger } = require('~/config'); const { logger } = require('@librechat/data-schemas');
expect(logger.warn).toHaveBeenCalledWith( expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining( expect.stringContaining(
"The 'assistants' endpoint has both 'privateAssistants' and 'supportedIds' or 'excludedIds' defined.", "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 () => { it('should issue expected warnings when loading Azure Groups with deprecated Environment Variables', async () => {
require('./Config/loadCustomConfig').mockImplementationOnce(() => loadCustomConfig.mockImplementationOnce(() =>
Promise.resolve({ Promise.resolve({
endpoints: { endpoints: {
[EModelEndpoint.azureOpenAI]: { [EModelEndpoint.azureOpenAI]: {
@ -905,10 +944,9 @@ describe('AppService updating app.locals and issuing warnings', () => {
process.env[varInfo.key] = 'test'; process.env[varInfo.key] = 'test';
}); });
const app = { locals: {} }; await AppService();
await require('./AppService')(app);
const { logger } = require('~/config'); const { logger } = require('@librechat/data-schemas');
deprecatedAzureVariables.forEach(({ key, description }) => { deprecatedAzureVariables.forEach(({ key, description }) => {
expect(logger.warn).toHaveBeenCalledWith( 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.`, `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 () => { it('should issue expected warnings when loading conflicting Azure Envrionment Variables', async () => {
require('./Config/loadCustomConfig').mockImplementationOnce(() => loadCustomConfig.mockImplementationOnce(() =>
Promise.resolve({ Promise.resolve({
endpoints: { endpoints: {
[EModelEndpoint.azureOpenAI]: { [EModelEndpoint.azureOpenAI]: {
@ -931,10 +969,9 @@ describe('AppService updating app.locals and issuing warnings', () => {
process.env[varInfo.key] = 'test'; process.env[varInfo.key] = 'test';
}); });
const app = { locals: {} }; await AppService();
await require('./AppService')(app);
const { logger } = require('~/config'); const { logger } = require('@librechat/data-schemas');
conflictingAzureVariables.forEach(({ key }) => { conflictingAzureVariables.forEach(({ key }) => {
expect(logger.warn).toHaveBeenCalledWith( 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.`, `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 // Set actual environment variables with different values
process.env.OCR_API_KEY_CUSTOM_VAR_NAME = 'actual-api-key'; process.env.OCR_API_KEY_CUSTOM_VAR_NAME = 'actual-api-key';
process.env.OCR_BASEURL_CUSTOM_VAR_NAME = 'https://actual-ocr-url.com'; process.env.OCR_BASEURL_CUSTOM_VAR_NAME = 'https://actual-ocr-url.com';
// Initialize app const result = await AppService();
const app = { locals: {} };
await AppService(app);
// Verify that the raw string references were preserved and not interpolated // Verify that the raw string references were preserved and not interpolated
expect(app.locals.ocr).toBeDefined(); expect(result).toEqual(
expect(app.locals.ocr.apiKey).toEqual('${OCR_API_KEY_CUSTOM_VAR_NAME}'); expect.objectContaining({
expect(app.locals.ocr.baseURL).toEqual('${OCR_BASEURL_CUSTOM_VAR_NAME}'); ocr: expect.objectContaining({
expect(app.locals.ocr.strategy).toEqual('mistral_ocr'); apiKey: '${OCR_API_KEY_CUSTOM_VAR_NAME}',
expect(app.locals.ocr.mistralModel).toEqual('mistral-medium'); baseURL: '${OCR_BASEURL_CUSTOM_VAR_NAME}',
strategy: 'mistral_ocr',
mistralModel: 'mistral-medium',
}),
}),
);
}); });
it('should correctly configure peoplePicker permissions when specified', async () => { 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: {} }; const result = await AppService();
await AppService(app);
// Check that interface config includes the permissions // Check that interface config includes the permissions
expect(app.locals.interfaceConfig.peoplePicker).toBeDefined(); expect(result).toEqual(
expect(app.locals.interfaceConfig.peoplePicker).toMatchObject({ expect.objectContaining({
interfaceConfig: expect.objectContaining({
peoplePicker: expect.objectContaining({
users: true, users: true,
groups: true, groups: true,
roles: true, roles: true,
}); }),
}),
}),
);
}); });
}); });

View file

@ -350,6 +350,7 @@ async function runAssistant({
accumulatedMessages = [], accumulatedMessages = [],
in_progress: inProgress, in_progress: inProgress,
}) { }) {
const appConfig = openai.req.config;
let steps = accumulatedSteps; let steps = accumulatedSteps;
let messages = accumulatedMessages; let messages = accumulatedMessages;
const in_progress = inProgress ?? createInProgressHandler(openai, thread_id, messages); const in_progress = inProgress ?? createInProgressHandler(openai, thread_id, messages);
@ -396,8 +397,8 @@ async function runAssistant({
}); });
const { endpoint = EModelEndpoint.azureAssistants } = openai.req.body; const { endpoint = EModelEndpoint.azureAssistants } = openai.req.body;
/** @type {TCustomConfig.endpoints.assistants} */ /** @type {AppConfig['endpoints']['assistants']} */
const assistantsEndpointConfig = openai.req.app.locals?.[endpoint] ?? {}; const assistantsEndpointConfig = appConfig.endpoints?.[endpoint] ?? {};
const { pollIntervalMs, timeoutMs } = assistantsEndpointConfig; const { pollIntervalMs, timeoutMs } = assistantsEndpointConfig;
const run = await waitForRun({ const run = await waitForRun({

View file

@ -1,8 +1,8 @@
const bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const { webcrypto } = require('node:crypto'); const { webcrypto } = require('node:crypto');
const { isEnabled } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas'); const { logger } = require('@librechat/data-schemas');
const { isEnabled, checkEmailConfig } = require('@librechat/api');
const { SystemRoles, errorsToString } = require('librechat-data-provider'); const { SystemRoles, errorsToString } = require('librechat-data-provider');
const { const {
findUser, findUser,
@ -21,9 +21,9 @@ const {
generateRefreshToken, generateRefreshToken,
} = require('~/models'); } = require('~/models');
const { isEmailDomainAllowed } = require('~/server/services/domains'); const { isEmailDomainAllowed } = require('~/server/services/domains');
const { checkEmailConfig, sendEmail } = require('~/server/utils');
const { getBalanceConfig } = require('~/server/services/Config');
const { registerSchema } = require('~/strategies/validators'); const { registerSchema } = require('~/strategies/validators');
const { getAppConfig } = require('~/server/services/Config');
const { sendEmail } = require('~/server/utils');
const domains = { const domains = {
client: process.env.DOMAIN_CLIENT, client: process.env.DOMAIN_CLIENT,
@ -78,7 +78,7 @@ const createTokenHash = () => {
/** /**
* Send Verification Email * Send Verification Email
* @param {Partial<MongoUser> & { _id: ObjectId, email: string, name: string}} user * @param {Partial<IUser>} user
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
const sendVerificationEmail = async (user) => { const sendVerificationEmail = async (user) => {
@ -112,7 +112,7 @@ const sendVerificationEmail = async (user) => {
/** /**
* Verify Email * Verify Email
* @param {Express.Request} req * @param {ServerRequest} req
*/ */
const verifyEmail = async (req) => { const verifyEmail = async (req) => {
const { email, token } = req.body; const { email, token } = req.body;
@ -160,9 +160,9 @@ const verifyEmail = async (req) => {
/** /**
* Register a new user. * Register a new user.
* @param {MongoUser} user <email, password, name, username> * @param {IUser} user <email, password, name, username>
* @param {Partial<MongoUser>} [additionalData={}] * @param {Partial<IUser>} [additionalData={}]
* @returns {Promise<{status: number, message: string, user?: MongoUser}>} * @returns {Promise<{status: number, message: string, user?: IUser}>}
*/ */
const registerUser = async (user, additionalData = {}) => { const registerUser = async (user, additionalData = {}) => {
const { error } = registerSchema.safeParse(user); const { error } = registerSchema.safeParse(user);
@ -195,7 +195,8 @@ const registerUser = async (user, additionalData = {}) => {
return { status: 200, message: genericVerificationMessage }; return { status: 200, message: genericVerificationMessage };
} }
if (!(await isEmailDomainAllowed(email))) { const appConfig = await getAppConfig({ role: user.role });
if (!isEmailDomainAllowed(email, appConfig?.registration?.allowedDomains)) {
const errorMessage = const errorMessage =
'The email address provided cannot be used. Please use a different email address.'; 'The email address provided cannot be used. Please use a different email address.';
logger.error(`[registerUser] [Registration not allowed] [Email: ${user.email}]`); logger.error(`[registerUser] [Registration not allowed] [Email: ${user.email}]`);
@ -219,9 +220,8 @@ const registerUser = async (user, additionalData = {}) => {
const emailEnabled = checkEmailConfig(); const emailEnabled = checkEmailConfig();
const disableTTL = isEnabled(process.env.ALLOW_UNVERIFIED_EMAIL_LOGIN); 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; newUserId = newUser._id;
if (emailEnabled && !newUser.emailVerified) { if (emailEnabled && !newUser.emailVerified) {
await sendVerificationEmail({ await sendVerificationEmail({
@ -248,7 +248,7 @@ const registerUser = async (user, additionalData = {}) => {
/** /**
* Request password reset * Request password reset
* @param {Express.Request} req * @param {ServerRequest} req
*/ */
const requestPasswordReset = async (req) => { const requestPasswordReset = async (req) => {
const { email } = req.body; const { email } = req.body;

View file

@ -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<AppConfig>}
*/
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<boolean>}
*/
async function clearAppConfigCache() {
const cache = getLogStores(CacheKeys.CONFIG_STORE);
const cacheKey = CacheKeys.APP_CONFIG;
return await cache.delete(cacheKey);
}
module.exports = {
getAppConfig,
clearAppConfigCache,
};

View file

@ -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<TCustomConfig | null>}
* */
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<TCustomConfig['balance'] | null>}
* */
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<TEndpoint | undefined>}
*/
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<boolean>}
*/
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,
};

View file

@ -1,3 +1,4 @@
const { loadCustomEndpointsConfig } = require('@librechat/api');
const { const {
CacheKeys, CacheKeys,
EModelEndpoint, EModelEndpoint,
@ -6,8 +7,8 @@ const {
defaultAgentCapabilities, defaultAgentCapabilities,
} = require('librechat-data-provider'); } = require('librechat-data-provider');
const loadDefaultEndpointsConfig = require('./loadDefaultEConfig'); const loadDefaultEndpointsConfig = require('./loadDefaultEConfig');
const loadConfigEndpoints = require('./loadConfigEndpoints');
const getLogStores = require('~/cache/getLogStores'); const getLogStores = require('~/cache/getLogStores');
const { getAppConfig } = require('./app');
/** /**
* *
@ -21,14 +22,36 @@ async function getEndpointsConfig(req) {
return cachedEndpointsConfig; return cachedEndpointsConfig;
} }
const defaultEndpointsConfig = await loadDefaultEndpointsConfig(req); const appConfig = req.config ?? (await getAppConfig({ role: req.user?.role }));
const customConfigEndpoints = await loadConfigEndpoints(req); const defaultEndpointsConfig = await loadDefaultEndpointsConfig(appConfig);
const customEndpointsConfig = loadCustomEndpointsConfig(appConfig?.endpoints?.custom);
/** @type {TEndpointsConfig} */ /** @type {TEndpointsConfig} */
const mergedConfig = { ...defaultEndpointsConfig, ...customConfigEndpoints }; const mergedConfig = {
if (mergedConfig[EModelEndpoint.assistants] && req.app.locals?.[EModelEndpoint.assistants]) { ...defaultEndpointsConfig,
...customEndpointsConfig,
};
if (appConfig.endpoints?.[EModelEndpoint.azureOpenAI]) {
/** @type {Omit<TConfig, 'order'>} */
mergedConfig[EModelEndpoint.azureOpenAI] = {
userProvide: false,
};
}
if (appConfig.endpoints?.[EModelEndpoint.azureOpenAI]?.assistants) {
/** @type {Omit<TConfig, 'order'>} */
mergedConfig[EModelEndpoint.azureAssistants] = {
userProvide: false,
};
}
if (
mergedConfig[EModelEndpoint.assistants] &&
appConfig?.endpoints?.[EModelEndpoint.assistants]
) {
const { disableBuilder, retrievalModels, capabilities, version, ..._rest } = const { disableBuilder, retrievalModels, capabilities, version, ..._rest } =
req.app.locals[EModelEndpoint.assistants]; appConfig.endpoints[EModelEndpoint.assistants];
mergedConfig[EModelEndpoint.assistants] = { mergedConfig[EModelEndpoint.assistants] = {
...mergedConfig[EModelEndpoint.assistants], ...mergedConfig[EModelEndpoint.assistants],
@ -38,9 +61,9 @@ async function getEndpointsConfig(req) {
capabilities, capabilities,
}; };
} }
if (mergedConfig[EModelEndpoint.agents] && req.app.locals?.[EModelEndpoint.agents]) { if (mergedConfig[EModelEndpoint.agents] && appConfig?.endpoints?.[EModelEndpoint.agents]) {
const { disableBuilder, capabilities, allowedProviders, ..._rest } = const { disableBuilder, capabilities, allowedProviders, ..._rest } =
req.app.locals[EModelEndpoint.agents]; appConfig.endpoints[EModelEndpoint.agents];
mergedConfig[EModelEndpoint.agents] = { mergedConfig[EModelEndpoint.agents] = {
...mergedConfig[EModelEndpoint.agents], ...mergedConfig[EModelEndpoint.agents],
@ -52,10 +75,10 @@ async function getEndpointsConfig(req) {
if ( if (
mergedConfig[EModelEndpoint.azureAssistants] && mergedConfig[EModelEndpoint.azureAssistants] &&
req.app.locals?.[EModelEndpoint.azureAssistants] appConfig?.endpoints?.[EModelEndpoint.azureAssistants]
) { ) {
const { disableBuilder, retrievalModels, capabilities, version, ..._rest } = const { disableBuilder, retrievalModels, capabilities, version, ..._rest } =
req.app.locals[EModelEndpoint.azureAssistants]; appConfig.endpoints[EModelEndpoint.azureAssistants];
mergedConfig[EModelEndpoint.azureAssistants] = { mergedConfig[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]) { if (mergedConfig[EModelEndpoint.bedrock] && appConfig?.endpoints?.[EModelEndpoint.bedrock]) {
const { availableRegions } = req.app.locals[EModelEndpoint.bedrock]; const { availableRegions } = appConfig.endpoints[EModelEndpoint.bedrock];
mergedConfig[EModelEndpoint.bedrock] = { mergedConfig[EModelEndpoint.bedrock] = {
...mergedConfig[EModelEndpoint.bedrock], ...mergedConfig[EModelEndpoint.bedrock],
availableRegions, availableRegions,

View file

@ -1,12 +1,11 @@
const appConfig = require('./app');
const { config } = require('./EndpointService'); const { config } = require('./EndpointService');
const getCachedTools = require('./getCachedTools'); const getCachedTools = require('./getCachedTools');
const getCustomConfig = require('./getCustomConfig');
const mcpToolsCache = require('./mcpToolsCache'); const mcpToolsCache = require('./mcpToolsCache');
const loadCustomConfig = require('./loadCustomConfig'); const loadCustomConfig = require('./loadCustomConfig');
const loadConfigModels = require('./loadConfigModels'); const loadConfigModels = require('./loadConfigModels');
const loadDefaultModels = require('./loadDefaultModels'); const loadDefaultModels = require('./loadDefaultModels');
const getEndpointsConfig = require('./getEndpointsConfig'); const getEndpointsConfig = require('./getEndpointsConfig');
const loadOverrideConfig = require('./loadOverrideConfig');
const loadAsyncEndpoints = require('./loadAsyncEndpoints'); const loadAsyncEndpoints = require('./loadAsyncEndpoints');
module.exports = { module.exports = {
@ -14,10 +13,9 @@ module.exports = {
loadCustomConfig, loadCustomConfig,
loadConfigModels, loadConfigModels,
loadDefaultModels, loadDefaultModels,
loadOverrideConfig,
loadAsyncEndpoints, loadAsyncEndpoints,
...appConfig,
...getCachedTools, ...getCachedTools,
...getCustomConfig,
...mcpToolsCache, ...mcpToolsCache,
...getEndpointsConfig, ...getEndpointsConfig,
}; };

View file

@ -1,16 +1,16 @@
const path = require('path'); const path = require('path');
const { logger } = require('@librechat/data-schemas'); const { logger } = require('@librechat/data-schemas');
const { loadServiceKey, isUserProvided } = require('@librechat/api');
const { EModelEndpoint } = require('librechat-data-provider'); const { EModelEndpoint } = require('librechat-data-provider');
const { loadServiceKey, isUserProvided } = require('@librechat/api');
const { config } = require('./EndpointService'); const { config } = require('./EndpointService');
const { openAIApiKey, azureOpenAIApiKey, useAzurePlugins, userProvidedOpenAI, googleKey } = config; const { openAIApiKey, azureOpenAIApiKey, useAzurePlugins, userProvidedOpenAI, googleKey } = config;
/** /**
* Load async endpoints and return a configuration object * 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; let serviceKey, googleUserProvides;
/** Check if GOOGLE_KEY is provided at all(including 'user_provided') */ /** 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 google = serviceKey || isGoogleKeyProvided ? { userProvide: googleUserProvides } : false;
const useAzure = req.app.locals[EModelEndpoint.azureOpenAI]?.plugins; const useAzure = !!appConfig?.endpoints?.[EModelEndpoint.azureOpenAI]?.plugins;
const gptPlugins = const gptPlugins =
useAzure || openAIApiKey || azureOpenAIApiKey useAzure || openAIApiKey || azureOpenAIApiKey
? { ? {

View file

@ -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<TEndpointsConfig>} 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<TConfig, 'order'>} */
endpointsConfig[EModelEndpoint.azureOpenAI] = {
userProvide: false,
};
}
if (req.app.locals[EModelEndpoint.azureOpenAI]?.assistants) {
/** @type {Omit<TConfig, 'order'>} */
endpointsConfig[EModelEndpoint.azureAssistants] = {
userProvide: false,
};
}
return endpointsConfig;
}
module.exports = loadConfigEndpoints;

View file

@ -1,43 +1,39 @@
const { isUserProvided, normalizeEndpointName } = require('@librechat/api');
const { EModelEndpoint, extractEnvVariable } = require('librechat-data-provider'); const { EModelEndpoint, extractEnvVariable } = require('librechat-data-provider');
const { isUserProvided, normalizeEndpointName } = require('~/server/utils');
const { fetchModels } = require('~/server/services/ModelService'); const { fetchModels } = require('~/server/services/ModelService');
const { getCustomConfig } = require('./getCustomConfig'); const { getAppConfig } = require('./app');
/** /**
* Load config endpoints from the cached configuration object * Load config endpoints from the cached configuration object
* @function loadConfigModels * @function loadConfigModels
* @param {Express.Request} req - The Express request object. * @param {ServerRequest} req - The Express request object.
*/ */
async function loadConfigModels(req) { async function loadConfigModels(req) {
const customConfig = await getCustomConfig(); const appConfig = await getAppConfig({ role: req.user?.role });
if (!appConfig) {
if (!customConfig) {
return {}; return {};
} }
const { endpoints = {} } = customConfig ?? {};
const modelsConfig = {}; const modelsConfig = {};
const azureEndpoint = endpoints[EModelEndpoint.azureOpenAI]; const azureConfig = appConfig.endpoints?.[EModelEndpoint.azureOpenAI];
const azureConfig = req.app.locals[EModelEndpoint.azureOpenAI];
const { modelNames } = azureConfig ?? {}; const { modelNames } = azureConfig ?? {};
if (modelNames && azureEndpoint) { if (modelNames && azureConfig) {
modelsConfig[EModelEndpoint.azureOpenAI] = modelNames; modelsConfig[EModelEndpoint.azureOpenAI] = modelNames;
} }
if (modelNames && azureEndpoint && azureEndpoint.plugins) { if (modelNames && azureConfig && azureConfig.plugins) {
modelsConfig[EModelEndpoint.gptPlugins] = modelNames; modelsConfig[EModelEndpoint.gptPlugins] = modelNames;
} }
if (azureEndpoint?.assistants && azureConfig.assistantModels) { if (azureConfig?.assistants && azureConfig.assistantModels) {
modelsConfig[EModelEndpoint.azureAssistants] = azureConfig.assistantModels; modelsConfig[EModelEndpoint.azureAssistants] = azureConfig.assistantModels;
} }
if (!Array.isArray(endpoints[EModelEndpoint.custom])) { if (!Array.isArray(appConfig.endpoints?.[EModelEndpoint.custom])) {
return modelsConfig; return modelsConfig;
} }
const customEndpoints = endpoints[EModelEndpoint.custom].filter( const customEndpoints = appConfig.endpoints[EModelEndpoint.custom].filter(
(endpoint) => (endpoint) =>
endpoint.baseURL && endpoint.baseURL &&
endpoint.apiKey && endpoint.apiKey &&

View file

@ -1,9 +1,9 @@
const { fetchModels } = require('~/server/services/ModelService'); const { fetchModels } = require('~/server/services/ModelService');
const { getCustomConfig } = require('./getCustomConfig');
const loadConfigModels = require('./loadConfigModels'); const loadConfigModels = require('./loadConfigModels');
const { getAppConfig } = require('./app');
jest.mock('~/server/services/ModelService'); jest.mock('~/server/services/ModelService');
jest.mock('./getCustomConfig'); jest.mock('./app');
const exampleConfig = { const exampleConfig = {
endpoints: { endpoints: {
@ -60,7 +60,7 @@ const exampleConfig = {
}; };
describe('loadConfigModels', () => { describe('loadConfigModels', () => {
const mockRequest = { app: { locals: {} }, user: { id: 'testUserId' } }; const mockRequest = { user: { id: 'testUserId' } };
const originalEnv = process.env; const originalEnv = process.env;
@ -68,6 +68,9 @@ describe('loadConfigModels', () => {
jest.resetAllMocks(); jest.resetAllMocks();
jest.resetModules(); jest.resetModules();
process.env = { ...originalEnv }; process.env = { ...originalEnv };
// Default mock for getAppConfig
getAppConfig.mockResolvedValue({});
}); });
afterEach(() => { afterEach(() => {
@ -75,18 +78,15 @@ describe('loadConfigModels', () => {
}); });
it('should return an empty object if customConfig is null', async () => { it('should return an empty object if customConfig is null', async () => {
getCustomConfig.mockResolvedValue(null); getAppConfig.mockResolvedValue(null);
const result = await loadConfigModels(mockRequest); const result = await loadConfigModels(mockRequest);
expect(result).toEqual({}); expect(result).toEqual({});
}); });
it('handles azure models and endpoint correctly', async () => { it('handles azure models and endpoint correctly', async () => {
mockRequest.app.locals.azureOpenAI = { modelNames: ['model1', 'model2'] }; getAppConfig.mockResolvedValue({
getCustomConfig.mockResolvedValue({
endpoints: { endpoints: {
azureOpenAI: { azureOpenAI: { modelNames: ['model1', 'model2'] },
models: ['model1', 'model2'],
},
}, },
}); });
@ -97,18 +97,16 @@ describe('loadConfigModels', () => {
it('fetches custom models based on the unique key', async () => { it('fetches custom models based on the unique key', async () => {
process.env.BASE_URL = 'http://example.com'; process.env.BASE_URL = 'http://example.com';
process.env.API_KEY = 'some-api-key'; process.env.API_KEY = 'some-api-key';
const customEndpoints = { const customEndpoints = [
custom: [
{ {
baseURL: '${BASE_URL}', baseURL: '${BASE_URL}',
apiKey: '${API_KEY}', apiKey: '${API_KEY}',
name: 'CustomModel', name: 'CustomModel',
models: { fetch: true }, models: { fetch: true },
}, },
], ];
};
getCustomConfig.mockResolvedValue({ endpoints: customEndpoints }); getAppConfig.mockResolvedValue({ endpoints: { custom: customEndpoints } });
fetchModels.mockResolvedValue(['customModel1', 'customModel2']); fetchModels.mockResolvedValue(['customModel1', 'customModel2']);
const result = await loadConfigModels(mockRequest); const result = await loadConfigModels(mockRequest);
@ -117,7 +115,7 @@ describe('loadConfigModels', () => {
}); });
it('correctly associates models to names using unique keys', async () => { it('correctly associates models to names using unique keys', async () => {
getCustomConfig.mockResolvedValue({ getAppConfig.mockResolvedValue({
endpoints: { endpoints: {
custom: [ custom: [
{ {
@ -146,7 +144,7 @@ describe('loadConfigModels', () => {
it('correctly handles multiple endpoints with the same baseURL but different apiKeys', async () => { it('correctly handles multiple endpoints with the same baseURL but different apiKeys', async () => {
// Mock the custom configuration to simulate the user's scenario // Mock the custom configuration to simulate the user's scenario
getCustomConfig.mockResolvedValue({ getAppConfig.mockResolvedValue({
endpoints: { endpoints: {
custom: [ custom: [
{ {
@ -210,7 +208,7 @@ describe('loadConfigModels', () => {
process.env.MY_OPENROUTER_API_KEY = 'actual_openrouter_api_key'; process.env.MY_OPENROUTER_API_KEY = 'actual_openrouter_api_key';
// Setup custom configuration with specific API keys for Mistral and OpenRouter // 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 // 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 // Assuming fetchModels would be called only for Mistral and OpenRouter
fetchModels.mockImplementation(({ name }) => { fetchModels.mockImplementation(({ name }) => {
@ -273,7 +271,7 @@ describe('loadConfigModels', () => {
}); });
it('falls back to default models if fetching returns an empty array', async () => { it('falls back to default models if fetching returns an empty array', async () => {
getCustomConfig.mockResolvedValue({ getAppConfig.mockResolvedValue({
endpoints: { endpoints: {
custom: [ custom: [
{ {
@ -306,7 +304,7 @@ describe('loadConfigModels', () => {
}); });
it('falls back to default models if fetching returns a falsy value', async () => { it('falls back to default models if fetching returns a falsy value', async () => {
getCustomConfig.mockResolvedValue({ getAppConfig.mockResolvedValue({
endpoints: { endpoints: {
custom: [ custom: [
{ {
@ -367,7 +365,7 @@ describe('loadConfigModels', () => {
}, },
]; ];
getCustomConfig.mockResolvedValue({ getAppConfig.mockResolvedValue({
endpoints: { endpoints: {
custom: testCases, custom: testCases,
}, },

View file

@ -4,11 +4,11 @@ const { config } = require('./EndpointService');
/** /**
* Load async endpoints and return a configuration object * Load async endpoints and return a configuration object
* @param {Express.Request} req - The request object * @param {AppConfig} appConfig - The app configuration object
* @returns {Promise<Object.<string, EndpointWithOrder>>} An object whose keys are endpoint names and values are objects that contain the endpoint configuration and an order. * @returns {Promise<Object.<string, EndpointWithOrder>>} An object whose keys are endpoint names and values are objects that contain the endpoint configuration and an order.
*/ */
async function loadDefaultEndpointsConfig(req) { async function loadDefaultEndpointsConfig(appConfig) {
const { google, gptPlugins } = await loadAsyncEndpoints(req); const { google, gptPlugins } = await loadAsyncEndpoints(appConfig);
const { assistants, azureAssistants, azureOpenAI, chatGPTBrowser } = config; const { assistants, azureAssistants, azureOpenAI, chatGPTBrowser } = config;
const enabledEndpoints = getEnabledEndpoints(); const enabledEndpoints = getEnabledEndpoints();

View file

@ -11,7 +11,7 @@ const {
* Loads the default models for the application. * Loads the default models for the application.
* @async * @async
* @function * @function
* @param {Express.Request} req - The Express request object. * @param {ServerRequest} req - The Express request object.
*/ */
async function loadDefaultModels(req) { async function loadDefaultModels(req) {
try { try {

View file

@ -1,6 +0,0 @@
// fetch some remote config
async function loadOverrideConfig() {
return false;
}
module.exports = loadOverrideConfig;

View file

@ -49,6 +49,7 @@ const initializeAgent = async ({
allowedProviders, allowedProviders,
isInitialAgent = false, isInitialAgent = false,
}) => { }) => {
const appConfig = req.config;
if ( if (
isAgentsEndpoint(endpointOption?.endpoint) && isAgentsEndpoint(endpointOption?.endpoint) &&
allowedProviders.size > 0 && allowedProviders.size > 0 &&
@ -90,10 +91,11 @@ const initializeAgent = async ({
const { attachments, tool_resources } = await primeResources({ const { attachments, tool_resources } = await primeResources({
req, req,
getFiles, getFiles,
appConfig,
agentId: agent.id,
attachments: currentFiles, attachments: currentFiles,
tool_resources: agent.tool_resources, tool_resources: agent.tool_resources,
requestFileSet: new Set(requestFiles?.map((file) => file.file_id)), requestFileSet: new Set(requestFiles?.map((file) => file.file_id)),
agentId: agent.id,
}); });
const provider = agent.provider; const provider = agent.provider;
@ -112,7 +114,7 @@ const initializeAgent = async ({
})) ?? {}; })) ?? {};
agent.endpoint = provider; agent.endpoint = provider;
const { getOptions, overrideProvider } = await getProviderConfig(provider); const { getOptions, overrideProvider } = getProviderConfig({ provider, appConfig });
if (overrideProvider !== agent.provider) { if (overrideProvider !== agent.provider) {
agent.provider = overrideProvider; agent.provider = overrideProvider;
} }

Some files were not shown because too many files have changed in this diff Show more