Compare commits

...

46 Commits

Author SHA1 Message Date
Danny Avila
62b4d29967 WIP: fix tests 2025-08-20 02:16:02 -04:00
Danny Avila
f7ea232760 refactor: decouple caching and DB operations from AppService, make part of consolidated getAppConfig 2025-08-20 02:03:38 -04:00
Danny Avila
28cd234a6c refactor: add FunctionTool interface and availableTools to AppConfig 2025-08-20 01:13:42 -04:00
Danny Avila
550bf56077 refactor: migrate checkEmailConfig to TypeScript and update imports 2025-08-20 01:07:31 -04:00
Danny Avila
ab4596206f refactor: move interface config logic to TS API 2025-08-20 01:00:25 -04:00
Danny Avila
84c78f5812 refactor: consolidate tool loading logic into a new tools module for startup logic 2025-08-20 00:57:42 -04:00
Danny Avila
1bdfb143eb refactor: separate manifest logic into its own module 2025-08-20 00:56:39 -04:00
Danny Avila
20a486a190 refactor: restructure toolkits to TS API 2025-08-20 00:56:12 -04:00
Danny Avila
6873b396ea chore: add missing FILE_CITATIONS permission to IRole interface 2025-08-20 00:54:35 -04:00
Danny Avila
1c7b3b53da refactor: move /services/Files/images/parse to TS API 2025-08-20 00:54:02 -04:00
Danny Avila
c160e7c7d5 refactor: add type annotation for loadedEndpoints in loadEndpoints function 2025-08-19 12:22:14 -04:00
Danny Avila
c0d6404385 refactor: replace getMCPAuthMap with getUserMCPAuthMap and remove unused getCustomConfig file 2025-08-19 12:22:14 -04:00
Danny Avila
71a14517cd refactor: streamline endpoint configuration and enhance appConfig usage across services 2025-08-19 12:22:11 -04:00
Danny Avila
647b1bbac6 refactor: update getAppConfig call to include user role parameter 2025-08-19 12:22:11 -04:00
Danny Avila
5eef6ea9e8 refactor: implement custom endpoints configuration and streamline endpoint loading logic 2025-08-19 12:22:11 -04:00
Danny Avila
240e3bd59e refactor: update appConfig access to use endpoints structure across various services 2025-08-19 12:22:10 -04:00
Danny Avila
89fb9c7e1c refactor: consolidate endpoint loading logic into loadEndpoints function 2025-08-19 12:22:10 -04:00
Danny Avila
8525c8df36 refactor: remove getCustomConfig usage and use app config in file citations 2025-08-19 12:22:10 -04:00
Danny Avila
46f9c90223 refactor: remove customConfig dependency for appConfig and streamline loadConfigModels logic 2025-08-19 12:22:09 -04:00
Danny Avila
1d2be247cf refactor: get balance config primarily from appConfig 2025-08-19 12:22:09 -04:00
Danny Avila
d853c10920 chore: remove deprecated unused RunManager 2025-08-19 12:22:09 -04:00
Danny Avila
5e70d518aa refactor: update imports to use normalizeEndpointName from @librechat/api and remove redundant definitions 2025-08-19 12:22:08 -04:00
Danny Avila
8df0ecd438 refactor: enhance AppConfig to include fileStrategies and update related file strategy logic 2025-08-19 12:22:08 -04:00
Danny Avila
f25e4253d0 fix: update appConfig usage to access allowedDomains from actions instead of registration 2025-08-19 12:22:07 -04:00
Danny Avila
493f60fa54 refactor: update getAppConfig call in getCustomConfigSpeech to include user role 2025-08-19 12:22:07 -04:00
Danny Avila
1168a7d82e ci: update related tests 2025-08-19 12:22:07 -04:00
Danny Avila
8f89fdc802 refactor: update getAppConfig calls in Conversation and Message models to include user role for temporary chat expiration 2025-08-19 12:22:06 -04:00
Danny Avila
57513f7ac9 refactor: replace getCustomConfig with getAppConfig in Conversation and Message models, update tempChatRetention functions to use AppConfig type 2025-08-19 12:22:06 -04:00
Danny Avila
c82c47ab6a refactor: replace getCustomConfig with getAppConfig in STTService, TTSService, and related files 2025-08-19 12:22:06 -04:00
Danny Avila
b0256510b5 refactor: remove getCustomConfig dependency and use getAppConfig in PluginController, multer, and MCP services 2025-08-19 12:22:05 -04:00
Danny Avila
e0980e796a refactor: update AppConfig interface to correct registration and turnstile configurations 2025-08-19 12:22:05 -04:00
Danny Avila
370e2d7ade chore: remove app parameter from AppService invocation 2025-08-19 12:22:05 -04:00
Danny Avila
0e0758edaf refactor: use appConfig registration property 2025-08-19 12:22:04 -04:00
Danny Avila
50bd6d3a02 refactor: update domain validation to use appConfig for allowed domains 2025-08-19 12:22:04 -04:00
Danny Avila
677481dde6 refactor: remove getCustomConfig dependency in config route 2025-08-19 12:22:01 -04:00
Danny Avila
2229672929 chore: correct parameter documentation for imageOutputType in ToolService.js 2025-08-19 12:21:31 -04:00
Danny Avila
68c6afd009 fix: streamline OpenAI image tools configuration by removing direct appConfig dependency and using function parameters 2025-08-19 12:21:30 -04:00
Danny Avila
0b5816d1be chore: rename Config/getAppConfig -> Config/app 2025-08-19 12:21:30 -04:00
Danny Avila
e7af3bdaed refactor: Update convertMCPToolsToPlugins to use mcpManager for server configuration and adjust related tests 2025-08-19 12:21:30 -04:00
Danny Avila
b2b2aee945 ci: Mock getAppConfig in various tests to provide default configurations 2025-08-19 12:21:29 -04:00
Danny Avila
2501d11fa0 refactor: Rename initializeAppConfig to setAppConfig and update related tests 2025-08-19 12:21:29 -04:00
Danny Avila
eeab69ff7f refactor: Update multer storage destination to use promise-based getAppConfig and improve error handling in tests 2025-08-19 12:21:29 -04:00
Danny Avila
5bb731764c ci: Integrate getAppConfig into remaining tests 2025-08-19 12:21:28 -04:00
Danny Avila
5b43bf6c95 ci: Update AppService tests to initialize app config instead of app.locals 2025-08-19 12:21:28 -04:00
Danny Avila
3ecb96149a 🏷️ refactor: Update tests to use getAppConfig for endpoint configurations 2025-08-19 12:21:28 -04:00
Danny Avila
b992fed16c 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
2025-08-19 12:21:27 -04:00
159 changed files with 3404 additions and 2862 deletions

View File

@@ -11,6 +11,7 @@ const {
Constants, Constants,
} = require('librechat-data-provider'); } = require('librechat-data-provider');
const { getMessages, saveMessage, updateMessage, saveConvo, getConvo } = require('~/models'); const { getMessages, saveMessage, updateMessage, saveConvo, getConvo } = require('~/models');
const { getAppConfig } = require('~/server/services/Config');
const { checkBalance } = require('~/models/balanceMethods'); const { checkBalance } = require('~/models/balanceMethods');
const { truncateToolCallOutputs } = require('./prompts'); const { truncateToolCallOutputs } = require('./prompts');
const { getFiles } = require('~/models/File'); const { getFiles } = require('~/models/File');
@@ -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,
}); });
@@ -567,6 +570,7 @@ class BaseClient {
} }
async sendMessage(message, opts = {}) { async sendMessage(message, opts = {}) {
const appConfig = await getAppConfig({ role: this.options.req?.user?.role });
/** @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 } =
@@ -653,7 +657,7 @@ class BaseClient {
} }
} }
const balance = this.options.req?.app?.locals?.balance; const balance = appConfig?.balance;
if ( if (
balance?.enabled && balance?.enabled &&
supportsBalanceCheck[this.options.endpointType ?? this.options.endpoint] supportsBalanceCheck[this.options.endpointType ?? this.options.endpoint]
@@ -752,6 +756,7 @@ class BaseClient {
completionTokens = responseMessage.tokenCount; completionTokens = responseMessage.tokenCount;
await this.recordTokenUsage({ await this.recordTokenUsage({
usage, usage,
balance,
promptTokens, promptTokens,
completionTokens, completionTokens,
model: responseMessage.model, model: responseMessage.model,

View File

@@ -34,13 +34,14 @@ const {
const { extractBaseURL, getModelMaxTokens, getModelMaxOutputTokens } = require('~/utils'); const { extractBaseURL, getModelMaxTokens, getModelMaxOutputTokens } = require('~/utils');
const { encodeAndFormat } = require('~/server/services/Files/images/encode'); const { encodeAndFormat } = require('~/server/services/Files/images/encode');
const { addSpaceIfNeeded, sleep } = require('~/server/utils'); const { addSpaceIfNeeded, sleep } = require('~/server/utils');
const { getAppConfig } = require('~/server/services/Config');
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 +619,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 +663,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 +689,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 = await getAppConfig({ role: this.options.req?.user?.role });
this.conversationId = conversationId; this.conversationId = conversationId;
if (this.options.attachments) { if (this.options.attachments) {
@@ -730,8 +718,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 +1107,7 @@ ${convo}
} }
async chatCompletion({ payload, onProgress, abortController = null }) { async chatCompletion({ payload, onProgress, abortController = null }) {
const appConfig = await getAppConfig({ role: this.options.req?.user?.role });
let error = null; let error = null;
let intermediateReply = []; let intermediateReply = [];
const errorCallback = (err) => (error = err); const errorCallback = (err) => (error = err);
@@ -1165,8 +1153,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 specific—break your idea into layers:
(1) main concept and subject,
(2) composition and position,
(3) lighting and mood,
(4) style, medium, or camera details,
(5) important features (age, expression, clothing, etc.),
(6) background.
Use positive, descriptive language and specify what should be included, not what to avoid.
List number and characteristics of people/objects, and mention style/technical requirements (e.g., "DSLR photo, 85mm lens, golden hour").
Do not reference any uploaded images—use for new image creation from text only.`;
const DEFAULT_IMAGE_EDIT_PROMPT_DESCRIPTION = `Describe the changes, enhancements, or new ideas to apply to the uploaded image(s).
Be highly specific—break your request into layers:
(1) main concept or transformation,
(2) specific edits/replacements or composition guidance,
(3) desired style, mood, or technique,
(4) features/items to keep, change, or add (such as objects, people, clothing, lighting, etc.).
Use positive, descriptive language and clarify what should be included or changed, not what to avoid.
Always base this prompt on the most recently uploaded reference images.`;
const displayMessage = 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

@@ -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,160 +42,94 @@ 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, type: 'video',
type: 'video', maxResults: maxResults || 5,
maxResults: maxResults || 5, });
}); const result = response.data.items.map((item) => ({
const result = response.data.items.map((item) => ({ title: item.snippet.title,
title: item.snippet.title, description: item.snippet.description,
description: item.snippet.description, 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'); }
}
const response = await youtubeClient.videos.list({ const response = await youtubeClient.videos.list({
part: 'snippet,statistics', part: 'snippet,statistics',
id: videoId, id: videoId,
}); });
if (!response.data.items?.length) { if (!response.data.items?.length) {
throw new Error('Video not found'); throw new Error('Video not found');
} }
const video = response.data.items[0]; const video = response.data.items[0];
const result = { const result = {
title: video.snippet.title, title: video.snippet.title,
description: video.snippet.description, description: video.snippet.description,
views: video.statistics.viewCount, views: video.statistics.viewCount,
likes: video.statistics.likeCount, likes: video.statistics.likeCount,
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'); }
}
const response = await youtubeClient.commentThreads.list({ const response = await youtubeClient.commentThreads.list({
part: 'snippet', part: 'snippet',
videoId, videoId,
maxResults: maxResults || 10, maxResults: maxResults || 10,
}); });
const result = response.data.items.map((item) => ({ const result = response.data.items.map((item) => ({
author: item.snippet.topLevelComment.snippet.authorDisplayName, author: item.snippet.topLevelComment.snippet.authorDisplayName,
text: item.snippet.topLevelComment.snippet.textDisplay, text: item.snippet.topLevelComment.snippet.textDisplay,
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'); }
try {
try {
const transcript = await YoutubeTranscript.fetchTranscript(videoId, { lang: 'en' });
return parseTranscript(transcript);
} catch (e) {
logger.error(e);
} }
try { try {
try { const transcript = await YoutubeTranscript.fetchTranscript(videoId, { lang: 'de' });
const transcript = await YoutubeTranscript.fetchTranscript(videoId, { lang: 'en' });
return parseTranscript(transcript);
} catch (e) {
logger.error(e);
}
try {
const transcript = await YoutubeTranscript.fetchTranscript(videoId, { lang: 'de' });
return parseTranscript(transcript);
} catch (e) {
logger.error(e);
}
const transcript = await YoutubeTranscript.fetchTranscript(videoId);
return parseTranscript(transcript); return parseTranscript(transcript);
} catch (error) { } catch (e) {
throw new Error(`Failed to fetch transcript: ${error.message}`); logger.error(e);
} }
},
{ const transcript = await YoutubeTranscript.fetchTranscript(videoId);
name: 'youtube_transcript', return parseTranscript(transcript);
description: `Fetch and parse the transcript/captions of a YouTube video. } catch (error) {
- Required: url (full YouTube URL or video ID) throw new Error(`Failed to fetch transcript: ${error.message}`);
- Returns: Full video transcript as plain text }
- Use for: Content analysis, summarization, translation reference }, ytToolkit.youtube_transcript);
- 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,16 +121,19 @@ const getAuthFields = (toolKey) => {
/** /**
* *
* @param {object} object * @param {object} params
* @param {string} object.user * @param {string} params.user
* @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 ({
@@ -142,6 +145,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,
@@ -200,6 +206,8 @@ const loadTools = async ({
...authValues, ...authValues,
isAgent: !!agent, isAgent: !!agent,
req: options.req, req: options.req,
imageOutputType,
fileStrategy,
imageFiles, imageFiles,
}); });
}, },
@@ -215,7 +223,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,
@@ -272,11 +280,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

@@ -1,6 +1,6 @@
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 { getAppConfig } = require('~/server/services/Config/app');
const { getMessages, deleteMessages } = require('./Message'); const { getMessages, deleteMessages } = require('./Message');
const { Conversation } = require('~/db/models'); const { Conversation } = require('~/db/models');
@@ -102,8 +102,10 @@ module.exports = {
if (req?.body?.isTemporary) { if (req?.body?.isTemporary) {
try { try {
const customConfig = await getCustomConfig(); const appConfig = await getAppConfig({
update.expiredAt = createTempChatExpirationDate(customConfig); role: req.user.role,
});
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,9 @@ 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 { getAppConfig } = require('~/server/services/Config/app');
const { getMessages, deleteMessages } = require('./Message'); const { getMessages, deleteMessages } = require('./Message');
const { Conversation } = require('~/db/models'); const { Conversation } = require('~/db/models');
@@ -118,9 +118,9 @@ 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({ getAppConfig.mockResolvedValue({
interface: { interfaceConfig: {
temporaryChatRetention: 24, temporaryChatRetention: 24,
}, },
}); });
@@ -167,9 +167,9 @@ 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({ getAppConfig.mockResolvedValue({
interface: { interfaceConfig: {
temporaryChatRetention: 48, temporaryChatRetention: 48,
}, },
}); });
@@ -194,9 +194,9 @@ 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({ getAppConfig.mockResolvedValue({
interface: { interfaceConfig: {
temporaryChatRetention: 0.5, // Half hour - should be clamped to 1 hour temporaryChatRetention: 0.5, // Half hour - should be clamped to 1 hour
}, },
}); });
@@ -221,9 +221,9 @@ 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({ getAppConfig.mockResolvedValue({
interface: { interfaceConfig: {
temporaryChatRetention: 10000, // Should be clamped to 8760 hours temporaryChatRetention: 10000, // Should be clamped to 8760 hours
}, },
}); });
@@ -247,9 +247,9 @@ describe('Conversation Operations', () => {
); );
}); });
it('should handle getCustomConfig errors gracefully', async () => { it('should handle getAppConfig errors gracefully', async () => {
// Mock getCustomConfig to throw an error // Mock getAppConfig to throw an error
getCustomConfig.mockRejectedValue(new Error('Config service unavailable')); getAppConfig.mockRejectedValue(new Error('Config service unavailable'));
mockReq.body = { isTemporary: true }; mockReq.body = { isTemporary: true };
@@ -261,8 +261,8 @@ describe('Conversation Operations', () => {
}); });
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({}); getAppConfig.mockResolvedValue({});
mockReq.body = { isTemporary: true }; mockReq.body = { isTemporary: true };
@@ -285,8 +285,8 @@ 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({ getAppConfig.mockResolvedValue({
interface: { interfaceConfig: {
temporaryChatRetention: 24, temporaryChatRetention: 24,
}, },
}); });

View File

@@ -1,7 +1,7 @@
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 { getAppConfig } = require('~/server/services/Config/app');
const { Message } = require('~/db/models'); const { Message } = require('~/db/models');
const idSchema = z.string().uuid(); const idSchema = z.string().uuid();
@@ -57,8 +57,10 @@ async function saveMessage(req, params, metadata) {
if (req?.body?.isTemporary) { if (req?.body?.isTemporary) {
try { try {
const customConfig = await getCustomConfig(); const appConfig = await getAppConfig({
update.expiredAt = createTempChatExpirationDate(customConfig); role: req.user.role,
});
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,8 @@ 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'); const { getAppConfig } = require('~/server/services/Config/app');
/** /**
* @type {import('mongoose').Model<import('@librechat/data-schemas').IMessage>} * @type {import('mongoose').Model<import('@librechat/data-schemas').IMessage>}
@@ -326,9 +326,9 @@ 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({ getAppConfig.mockResolvedValue({
interface: { interfaceConfig: {
temporaryChatRetention: 24, temporaryChatRetention: 24,
}, },
}); });
@@ -375,9 +375,9 @@ 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({ getAppConfig.mockResolvedValue({
interface: { interfaceConfig: {
temporaryChatRetention: 48, temporaryChatRetention: 48,
}, },
}); });
@@ -402,9 +402,9 @@ 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({ getAppConfig.mockResolvedValue({
interface: { interfaceConfig: {
temporaryChatRetention: 0.5, // Half hour - should be clamped to 1 hour temporaryChatRetention: 0.5, // Half hour - should be clamped to 1 hour
}, },
}); });
@@ -429,9 +429,9 @@ 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({ getAppConfig.mockResolvedValue({
interface: { interfaceConfig: {
temporaryChatRetention: 10000, // Should be clamped to 8760 hours temporaryChatRetention: 10000, // Should be clamped to 8760 hours
}, },
}); });
@@ -455,9 +455,9 @@ describe('Message Operations', () => {
); );
}); });
it('should handle getCustomConfig errors gracefully', async () => { it('should handle getAppConfig errors gracefully', async () => {
// Mock getCustomConfig to throw an error // Mock getAppConfig to throw an error
getCustomConfig.mockRejectedValue(new Error('Config service unavailable')); getAppConfig.mockRejectedValue(new Error('Config service unavailable'));
mockReq.body = { isTemporary: true }; mockReq.body = { isTemporary: true };
@@ -469,8 +469,8 @@ describe('Message Operations', () => {
}); });
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({}); getAppConfig.mockResolvedValue({});
mockReq.body = { isTemporary: true }; mockReq.body = { isTemporary: true };
@@ -493,8 +493,8 @@ 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({ getAppConfig.mockResolvedValue({
interface: { interfaceConfig: {
temporaryChatRetention: 24, temporaryChatRetention: 24,
}, },
}); });
@@ -520,8 +520,8 @@ 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({ getAppConfig.mockResolvedValue({
interface: { interfaceConfig: {
temporaryChatRetention: 24, temporaryChatRetention: 24,
}, },
}); });

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

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

@@ -6,7 +6,7 @@ const {
filterUniquePlugins, filterUniquePlugins,
convertMCPToolsToPlugins, convertMCPToolsToPlugins,
} = require('@librechat/api'); } = require('@librechat/api');
const { getCustomConfig, getCachedTools } = require('~/server/services/Config'); const { getCachedTools, getAppConfig } = require('~/server/services/Config');
const { availableTools, toolkits } = require('~/app/clients/tools'); const { availableTools, toolkits } = require('~/app/clients/tools');
const { getMCPManager, getFlowStateManager } = require('~/config'); const { getMCPManager, getFlowStateManager } = require('~/config');
const { getLogStores } = require('~/cache'); const { getLogStores } = require('~/cache');
@@ -20,8 +20,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;
const pluginManifest = availableTools; const pluginManifest = availableTools;
const uniquePlugins = filterUniquePlugins(pluginManifest); const uniquePlugins = filterUniquePlugins(pluginManifest);
@@ -101,11 +102,13 @@ function createGetServerTools() {
const getAvailableTools = async (req, res) => { const getAvailableTools = async (req, res) => {
try { try {
const userId = req.user?.id; const userId = req.user?.id;
const customConfig = await getCustomConfig(); const appConfig = await getAppConfig();
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 userPlugins = convertMCPToolsToPlugins({ functionTools: cachedUserTools, customConfig });
const mcpManager = getMCPManager();
const userPlugins = convertMCPToolsToPlugins({ functionTools: cachedUserTools, mcpManager });
if (cachedToolsArray != null && userPlugins != null) { if (cachedToolsArray != null && userPlugins != null) {
const dedupedTools = filterUniquePlugins([...userPlugins, ...cachedToolsArray]); const dedupedTools = filterUniquePlugins([...userPlugins, ...cachedToolsArray]);
@@ -114,9 +117,8 @@ const getAvailableTools = async (req, res) => {
} }
let pluginManifest = availableTools; let pluginManifest = availableTools;
if (customConfig?.mcpServers != null) { if (appConfig?.mcpConfig != null) {
try { try {
const mcpManager = getMCPManager();
const flowsCache = getLogStores(CacheKeys.FLOWS); const flowsCache = getLogStores(CacheKeys.FLOWS);
const flowManager = flowsCache ? getFlowStateManager(flowsCache) : null; const flowManager = flowsCache ? getFlowStateManager(flowsCache) : null;
const serverToolsCallback = createServerToolsCallback(); const serverToolsCallback = createServerToolsCallback();
@@ -170,7 +172,7 @@ const getAvailableTools = async (req, res) => {
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) {
toolsOutput.push(toolToAdd); toolsOutput.push(toolToAdd);

View File

@@ -1,8 +1,7 @@
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(),
@@ -11,8 +10,8 @@ jest.mock('@librechat/data-schemas', () => ({
})); }));
jest.mock('~/server/services/Config', () => ({ jest.mock('~/server/services/Config', () => ({
getCustomConfig: jest.fn(),
getCachedTools: jest.fn(), getCachedTools: jest.fn(),
getAppConfig: jest.fn(),
})); }));
jest.mock('~/server/services/ToolService', () => ({ jest.mock('~/server/services/ToolService', () => ({
@@ -22,6 +21,7 @@ jest.mock('~/server/services/ToolService', () => ({
jest.mock('~/config', () => ({ jest.mock('~/config', () => ({
getMCPManager: jest.fn(() => ({ getMCPManager: jest.fn(() => ({
loadManifestTools: jest.fn().mockResolvedValue([]), loadManifestTools: jest.fn().mockResolvedValue([]),
getRawConfig: jest.fn(),
})), })),
getFlowStateManager: jest.fn(), getFlowStateManager: jest.fn(),
})); }));
@@ -64,7 +64,10 @@ describe('PluginController', () => {
describe('getAvailablePluginsController', () => { describe('getAvailablePluginsController', () => {
beforeEach(() => { beforeEach(() => {
mockReq.app = { locals: { filteredTools: [], includedTools: [] } }; getAppConfig.mockResolvedValue({
filteredTools: [],
includedTools: [],
});
}); });
it('should use filterUniquePlugins to remove duplicate plugins', async () => { it('should use filterUniquePlugins to remove duplicate plugins', async () => {
@@ -122,7 +125,10 @@ describe('PluginController', () => {
{ name: 'Plugin2', pluginKey: 'key2', description: 'Second' }, { name: 'Plugin2', pluginKey: 'key2', description: 'Second' },
]; ];
mockReq.app.locals.includedTools = ['key1']; getAppConfig.mockResolvedValue({
filteredTools: [],
includedTools: ['key1'],
});
mockCache.get.mockResolvedValue(null); mockCache.get.mockResolvedValue(null);
filterUniquePlugins.mockReturnValue(mockPlugins); filterUniquePlugins.mockReturnValue(mockPlugins);
checkPluginAuth.mockReturnValue(false); checkPluginAuth.mockReturnValue(false);
@@ -154,13 +160,12 @@ describe('PluginController', () => {
getCachedTools.mockResolvedValueOnce(mockUserTools); getCachedTools.mockResolvedValueOnce(mockUserTools);
convertMCPToolsToPlugins.mockReturnValue(mockConvertedPlugins); convertMCPToolsToPlugins.mockReturnValue(mockConvertedPlugins);
filterUniquePlugins.mockImplementation((plugins) => plugins); filterUniquePlugins.mockImplementation((plugins) => plugins);
getCustomConfig.mockResolvedValue(null);
await getAvailableTools(mockReq, mockRes); await getAvailableTools(mockReq, mockRes);
expect(convertMCPToolsToPlugins).toHaveBeenCalledWith({ expect(convertMCPToolsToPlugins).toHaveBeenCalledWith({
functionTools: mockUserTools, functionTools: mockUserTools,
customConfig: null, mcpManager: expect.any(Object),
}); });
}); });
@@ -176,7 +181,6 @@ describe('PluginController', () => {
getCachedTools.mockResolvedValueOnce({}); getCachedTools.mockResolvedValueOnce({});
convertMCPToolsToPlugins.mockReturnValue(mockUserPlugins); convertMCPToolsToPlugins.mockReturnValue(mockUserPlugins);
filterUniquePlugins.mockReturnValue([...mockUserPlugins, ...mockManifestPlugins]); filterUniquePlugins.mockReturnValue([...mockUserPlugins, ...mockManifestPlugins]);
getCustomConfig.mockResolvedValue(null);
await getAvailableTools(mockReq, mockRes); await getAvailableTools(mockReq, mockRes);
@@ -195,7 +199,6 @@ describe('PluginController', () => {
convertMCPToolsToPlugins.mockReturnValue([]); convertMCPToolsToPlugins.mockReturnValue([]);
filterUniquePlugins.mockReturnValue([mockPlugin]); filterUniquePlugins.mockReturnValue([mockPlugin]);
checkPluginAuth.mockReturnValue(true); checkPluginAuth.mockReturnValue(true);
getCustomConfig.mockResolvedValue(null);
// Mock getCachedTools second call to return tool definitions // Mock getCachedTools second call to return tool definitions
getCachedTools.mockResolvedValueOnce({}).mockResolvedValueOnce({ tool1: true }); getCachedTools.mockResolvedValueOnce({}).mockResolvedValueOnce({ tool1: true });
@@ -219,7 +222,6 @@ describe('PluginController', () => {
filterUniquePlugins.mockReturnValue([mockToolkit]); filterUniquePlugins.mockReturnValue([mockToolkit]);
checkPluginAuth.mockReturnValue(false); checkPluginAuth.mockReturnValue(false);
getToolkitKey.mockReturnValue('toolkit1'); getToolkitKey.mockReturnValue('toolkit1');
getCustomConfig.mockResolvedValue(null);
// Mock getCachedTools second call to return tool definitions // Mock getCachedTools second call to return tool definitions
getCachedTools.mockResolvedValueOnce({}).mockResolvedValueOnce({ getCachedTools.mockResolvedValueOnce({}).mockResolvedValueOnce({
@@ -233,9 +235,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`]: {
@@ -247,11 +248,18 @@ describe('PluginController', () => {
name: 'test-tool', name: 'test-tool',
pluginKey: `test-tool${Constants.mcp_delimiter}test-server`, pluginKey: `test-tool${Constants.mcp_delimiter}test-server`,
description: 'A test tool', description: 'A test tool',
icon: mcpServers['test-server']?.iconPath, icon: serverConfig?.iconPath,
authenticated: true, authenticated: true,
authConfig: [], authConfig: [],
}; };
// Mock the MCP manager to return the server config
const mockMCPManager = {
loadManifestTools: jest.fn().mockResolvedValue([]),
getRawConfig: jest.fn().mockReturnValue(serverConfig),
};
require('~/config').getMCPManager.mockReturnValue(mockMCPManager);
getCachedTools.mockResolvedValueOnce(functionTools); getCachedTools.mockResolvedValueOnce(functionTools);
convertMCPToolsToPlugins.mockReturnValue([mockConvertedPlugin]); convertMCPToolsToPlugins.mockReturnValue([mockConvertedPlugin]);
filterUniquePlugins.mockImplementation((plugins) => plugins); filterUniquePlugins.mockImplementation((plugins) => plugins);
@@ -268,28 +276,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' },
@@ -311,11 +315,12 @@ describe('PluginController', () => {
// Mock the MCP manager to return tools // Mock the MCP manager to return tools
const mockMCPManager = { const mockMCPManager = {
loadManifestTools: jest.fn().mockResolvedValue(mcpManagerTools), loadManifestTools: jest.fn().mockResolvedValue(mcpManagerTools),
getRawConfig: jest.fn(),
}; };
require('~/config').getMCPManager.mockReturnValue(mockMCPManager); require('~/config').getMCPManager.mockReturnValue(mockMCPManager);
mockCache.get.mockResolvedValue(null); mockCache.get.mockResolvedValue(null);
getCustomConfig.mockResolvedValue(customConfig); getAppConfig.mockResolvedValue(appConfig);
// First call returns user tools (empty in this case) // First call returns user tools (empty in this case)
getCachedTools.mockResolvedValueOnce({}); getCachedTools.mockResolvedValueOnce({});
@@ -375,13 +380,12 @@ describe('PluginController', () => {
getCachedTools.mockResolvedValue(null); getCachedTools.mockResolvedValue(null);
convertMCPToolsToPlugins.mockReturnValue(undefined); convertMCPToolsToPlugins.mockReturnValue(undefined);
filterUniquePlugins.mockImplementation((plugins) => plugins || []); filterUniquePlugins.mockImplementation((plugins) => plugins || []);
getCustomConfig.mockResolvedValue(null);
await getAvailableTools(mockReq, mockRes); await getAvailableTools(mockReq, mockRes);
expect(convertMCPToolsToPlugins).toHaveBeenCalledWith({ expect(convertMCPToolsToPlugins).toHaveBeenCalledWith({
functionTools: null, functionTools: null,
customConfig: null, mcpManager: expect.any(Object),
}); });
}); });
@@ -390,7 +394,6 @@ describe('PluginController', () => {
getCachedTools.mockResolvedValue(undefined); getCachedTools.mockResolvedValue(undefined);
convertMCPToolsToPlugins.mockReturnValue(undefined); convertMCPToolsToPlugins.mockReturnValue(undefined);
filterUniquePlugins.mockImplementation((plugins) => plugins || []); filterUniquePlugins.mockImplementation((plugins) => plugins || []);
getCustomConfig.mockResolvedValue(null);
checkPluginAuth.mockReturnValue(false); checkPluginAuth.mockReturnValue(false);
// Mock getCachedTools to return undefined for both calls // Mock getCachedTools to return undefined for both calls
@@ -401,7 +404,7 @@ describe('PluginController', () => {
expect(convertMCPToolsToPlugins).toHaveBeenCalledWith({ expect(convertMCPToolsToPlugins).toHaveBeenCalledWith({
functionTools: undefined, functionTools: undefined,
customConfig: null, mcpManager: expect.any(Object),
}); });
}); });
@@ -428,7 +431,6 @@ describe('PluginController', () => {
getCachedTools.mockResolvedValueOnce({}).mockResolvedValueOnce({}); getCachedTools.mockResolvedValueOnce({}).mockResolvedValueOnce({});
convertMCPToolsToPlugins.mockReturnValue([]); convertMCPToolsToPlugins.mockReturnValue([]);
filterUniquePlugins.mockImplementation((plugins) => plugins || []); filterUniquePlugins.mockImplementation((plugins) => plugins || []);
getCustomConfig.mockResolvedValue(null);
checkPluginAuth.mockReturnValue(true); checkPluginAuth.mockReturnValue(true);
await getAvailableTools(mockReq, mockRes); await getAvailableTools(mockReq, mockRes);
@@ -452,8 +454,19 @@ describe('PluginController', () => {
}, },
}; };
// Mock the MCP manager to return server config without customUserVars
const mockMCPManager = {
loadManifestTools: jest.fn().mockResolvedValue([]),
getRawConfig: jest.fn().mockReturnValue(customConfig.mcpServers['test-server']),
};
require('~/config').getMCPManager.mockReturnValue(mockMCPManager);
mockCache.get.mockResolvedValue(null); mockCache.get.mockResolvedValue(null);
getCustomConfig.mockResolvedValue(customConfig); getAppConfig.mockResolvedValue({
mcpConfig: customConfig.mcpServers,
filteredTools: [],
includedTools: [],
});
getCachedTools.mockResolvedValueOnce(mockUserTools); getCachedTools.mockResolvedValueOnce(mockUserTools);
const mockPlugin = { const mockPlugin = {
@@ -480,8 +493,8 @@ describe('PluginController', () => {
expect(responseData[0].authConfig).toEqual([]); expect(responseData[0].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: {} }; getAppConfig.mockResolvedValue({});
mockCache.get.mockResolvedValue(null); mockCache.get.mockResolvedValue(null);
filterUniquePlugins.mockReturnValue([]); filterUniquePlugins.mockReturnValue([]);
checkPluginAuth.mockReturnValue(false); checkPluginAuth.mockReturnValue(false);
@@ -506,7 +519,6 @@ describe('PluginController', () => {
filterUniquePlugins.mockReturnValue([mockToolkit]); filterUniquePlugins.mockReturnValue([mockToolkit]);
checkPluginAuth.mockReturnValue(false); checkPluginAuth.mockReturnValue(false);
getToolkitKey.mockReturnValue(undefined); getToolkitKey.mockReturnValue(undefined);
getCustomConfig.mockResolvedValue(null);
// Mock getCachedTools second call to return null // Mock getCachedTools second call to return null
getCachedTools.mockResolvedValueOnce({}).mockResolvedValueOnce(null); getCachedTools.mockResolvedValueOnce({}).mockResolvedValueOnce(null);

View File

@@ -17,11 +17,13 @@ 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) => {
const appConfig = await getAppConfig({ role: req.user?.role });
/** @type {MongoUser} */ /** @type {MongoUser} */
const userData = req.user.toObject != null ? req.user.toObject() : { ...req.user }; const userData = req.user.toObject != null ? req.user.toObject() : { ...req.user };
/** /**
@@ -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

@@ -7,6 +7,8 @@ const {
createRun, createRun,
Tokenizer, Tokenizer,
checkAccess, checkAccess,
hasCustomUserVars,
getUserMCPAuthMap,
memoryInstructions, memoryInstructions,
formatContentStrings, formatContentStrings,
createMemoryProcessor, createMemoryProcessor,
@@ -39,10 +41,10 @@ const {
deleteMemory, deleteMemory,
setMemory, setMemory,
} = require('~/models'); } = require('~/models');
const { getMCPAuthMap, checkCapability, hasCustomUserVars } = require('~/server/services/Config');
const { addCacheControl, createContextHandlers } = require('~/app/clients/prompts'); const { addCacheControl, createContextHandlers } = require('~/app/clients/prompts');
const { initializeAgent } = require('~/server/services/Endpoints/agents/agent'); const { initializeAgent } = require('~/server/services/Endpoints/agents/agent');
const { spendTokens, spendStructuredTokens } = require('~/models/spendTokens'); const { spendTokens, spendStructuredTokens } = require('~/models/spendTokens');
const { checkCapability, getAppConfig } = require('~/server/services/Config');
const { encodeAndFormat } = require('~/server/services/Files/images/encode'); const { encodeAndFormat } = require('~/server/services/Files/images/encode');
const { getProviderConfig } = require('~/server/services/Endpoints'); const { getProviderConfig } = require('~/server/services/Endpoints');
const BaseClient = require('~/app/clients/BaseClient'); const BaseClient = require('~/app/clients/BaseClient');
@@ -451,8 +453,8 @@ class AgentClient extends BaseClient {
); );
return; return;
} }
/** @type {TCustomConfig['memory']} */ const appConfig = await getAppConfig({ role: user.role });
const memoryConfig = this.options.req?.app?.locals?.memory; const memoryConfig = appConfig.memory;
if (!memoryConfig || memoryConfig.disabled === true) { if (!memoryConfig || memoryConfig.disabled === true) {
return; return;
} }
@@ -460,7 +462,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) {
@@ -582,8 +584,8 @@ class AgentClient extends BaseClient {
if (this.processMemory == null) { if (this.processMemory == null) {
return; return;
} }
/** @type {TCustomConfig['memory']} */ const appConfig = await getAppConfig({ role: this.options.req.user?.role });
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];
@@ -624,9 +626,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;
} }
@@ -648,6 +656,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,
@@ -759,8 +768,9 @@ class AgentClient extends BaseClient {
abortController = new AbortController(); abortController = new AbortController();
} }
/** @type {TCustomConfig['endpoints']['agents']} */ const appConfig = await getAppConfig({ role: this.options.req.user?.role });
const agentsEConfig = this.options.req.app.locals[EModelEndpoint.agents]; /** @type {AppConfig['endpoints']['agents']} */
const agentsEConfig = appConfig.endpoints?.[EModelEndpoint.agents];
config = { config = {
configurable: { configurable: {
@@ -904,10 +914,10 @@ class AgentClient extends BaseClient {
} }
try { try {
if (await hasCustomUserVars()) { if (agent.tools?.length && hasCustomUserVars(appConfig)) {
config.configurable.userMCPAuthMap = await getMCPAuthMap({ config.configurable.userMCPAuthMap = await getUserMCPAuthMap({
tools: agent.tools,
userId: this.options.req.user.id, userId: this.options.req.user.id,
tools: agent.tools,
findPluginAuthsByKeys, findPluginAuthsByKeys,
}); });
} }
@@ -1040,7 +1050,7 @@ class AgentClient extends BaseClient {
this.artifactPromises.push(...attachments); this.artifactPromises.push(...attachments);
} }
await this.recordCollectedUsage({ context: 'message' }); await this.recordCollectedUsage({ context: 'message', balance: appConfig?.balance });
} 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',
@@ -1081,6 +1091,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 = await getAppConfig({ role: req.user?.role });
let endpoint = agent.endpoint; let endpoint = agent.endpoint;
/** @type {import('@librechat/agents').ClientOptions} */ /** @type {import('@librechat/agents').ClientOptions} */
@@ -1088,11 +1099,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',
@@ -1101,7 +1114,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(
@@ -1110,7 +1126,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 });
} }
} }
@@ -1214,9 +1230,10 @@ class AgentClient extends BaseClient {
}); });
await this.recordCollectedUsage({ await this.recordCollectedUsage({
model: clientOptions.model,
context: 'title',
collectedUsage, collectedUsage,
context: 'title',
model: clientOptions.model,
balance: appConfig?.balance,
}).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',
@@ -1235,17 +1252,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,
@@ -1262,6 +1288,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

@@ -1,5 +1,6 @@
const { Providers } = require('@librechat/agents'); const { Providers } = require('@librechat/agents');
const { Constants, EModelEndpoint } = require('librechat-data-provider'); const { Constants, EModelEndpoint } = require('librechat-data-provider');
const { getAppConfig } = require('~/server/services/Config');
const AgentClient = require('./client'); const AgentClient = require('./client');
jest.mock('@librechat/agents', () => ({ jest.mock('@librechat/agents', () => ({
@@ -10,6 +11,10 @@ jest.mock('@librechat/agents', () => ({
}), }),
})); }));
jest.mock('~/server/services/Config', () => ({
getAppConfig: jest.fn(),
}));
describe('AgentClient - titleConvo', () => { describe('AgentClient - titleConvo', () => {
let client; let client;
let mockRun; let mockRun;
@@ -39,19 +44,21 @@ describe('AgentClient - titleConvo', () => {
}, },
}; };
// Mock request and response // Mock getAppConfig to return endpoint configurations
mockReq = { getAppConfig.mockResolvedValue({
app: { endpoints: {
locals: { [EModelEndpoint.openAI]: {
[EModelEndpoint.openAI]: { // Match the agent endpoint
// Match the agent endpoint titleModel: 'gpt-3.5-turbo',
titleModel: 'gpt-3.5-turbo', titlePrompt: 'Custom title prompt',
titlePrompt: 'Custom title prompt', titleMethod: 'structured',
titleMethod: 'structured', titlePromptTemplate: 'Template: {{content}}',
titlePromptTemplate: 'Template: {{content}}',
},
}, },
}, },
});
// Mock request and response
mockReq = {
user: { user: {
id: 'user-123', id: 'user-123',
}, },
@@ -143,7 +150,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; getAppConfig.mockResolvedValue({ endpoints: {} });
const text = 'Test conversation text'; const text = 'Test conversation text';
const abortController = new AbortController(); const abortController = new AbortController();
@@ -161,7 +168,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; getAppConfig.mockResolvedValue({
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 +189,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; getAppConfig.mockResolvedValue({
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();
@@ -245,10 +270,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; getAppConfig.mockResolvedValue({
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,19 +306,17 @@ 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; getAppConfig.mockResolvedValue({
delete mockReq.app.locals[EModelEndpoint.openAI].titlePrompt; endpoints: {
delete mockReq.app.locals[EModelEndpoint.openAI].titleMethod; all: {
delete mockReq.app.locals[EModelEndpoint.openAI].titlePromptTemplate; titleModel: 'gpt-4o-mini',
titlePrompt: 'All config title prompt',
// Set 'all' config titleMethod: 'completion',
mockReq.app.locals.all = { titlePromptTemplate: 'All config template: {{content}}',
titleModel: 'gpt-4o-mini', },
titlePrompt: 'All config title prompt', },
titleMethod: 'completion', });
titlePromptTemplate: 'All config template: {{content}}',
};
const text = 'Test conversation text'; const text = 'Test conversation text';
const abortController = new AbortController(); const abortController = new AbortController();
@@ -309,18 +339,22 @@ 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'; getAppConfig.mockResolvedValue({
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
titleModel: 'gpt-4o-mini', },
titlePrompt: 'All config title prompt', all: {
titleMethod: 'completion', titleModel: 'gpt-4o-mini',
titlePromptTemplate: 'All config template', titlePrompt: 'All config title prompt',
}; titleMethod: 'completion',
titlePromptTemplate: 'All config template',
},
},
});
const text = 'Test conversation text'; const text = 'Test conversation text';
const abortController = new AbortController(); const abortController = new AbortController();
@@ -346,18 +380,19 @@ 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 = { getAppConfig.mockResolvedValue({
titleConvo: true, endpoints: {
titleModel: 'claude-3-haiku-20240307', all: {
titleMethod: 'completion', // Testing the new default method titleConvo: true,
titlePrompt: 'Generate a concise, descriptive title for this conversation', titleModel: 'claude-3-haiku-20240307',
titlePromptTemplate: 'Conversation summary: {{content}}', titleMethod: 'completion', // Testing the new default method
titleEndpoint: EModelEndpoint.anthropic, // Should switch provider to Anthropic titlePrompt: 'Generate a concise, descriptive title for this conversation',
}; titlePromptTemplate: 'Conversation summary: {{content}}',
titleEndpoint: EModelEndpoint.anthropic, // Should switch provider to Anthropic
},
},
});
const text = 'Test conversation about AI and machine learning'; const text = 'Test conversation about AI and machine learning';
const abortController = new AbortController(); const abortController = new AbortController();
@@ -402,16 +437,17 @@ 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 = { getAppConfig.mockResolvedValue({
titleModel: 'gpt-4o-mini', endpoints: {
titleMethod: method, all: {
titlePrompt: `Testing ${method} method`, titleModel: 'gpt-4o-mini',
titlePromptTemplate: `Template for ${method}: {{content}}`, titleMethod: method,
}; titlePrompt: `Testing ${method} method`,
titlePromptTemplate: `Template for ${method}: {{content}}`,
},
},
});
const text = `Test conversation for ${method} method`; const text = `Test conversation for ${method} method`;
const abortController = new AbortController(); const abortController = new AbortController();
@@ -455,32 +491,36 @@ 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] = { getAppConfig.mockResolvedValue({
titleConvo: true, endpoints: {
titleModel: 'grok-3', [EModelEndpoint.azureOpenAI]: {
titleMethod: 'completion', titleConvo: true,
titlePrompt: 'Azure serverless title prompt', titleModel: 'grok-3',
streamRate: 35, titleMethod: 'completion',
modelGroupMap: { titlePrompt: 'Azure serverless title prompt',
'grok-3': { streamRate: 35,
group: 'Azure AI Foundry', modelGroupMap: {
deploymentName: 'grok-3',
},
},
groupMap: {
'Azure AI Foundry': {
apiKey: '${AZURE_API_KEY}',
baseURL: 'https://test.services.ai.azure.com/models',
version: '2024-05-01-preview',
serverless: true,
models: {
'grok-3': { 'grok-3': {
group: 'Azure AI Foundry',
deploymentName: 'grok-3', deploymentName: 'grok-3',
}, },
}, },
groupMap: {
'Azure AI Foundry': {
apiKey: '${AZURE_API_KEY}',
baseURL: 'https://test.services.ai.azure.com/models',
version: '2024-05-01-preview',
serverless: true,
models: {
'grok-3': {
deploymentName: 'grok-3',
},
},
},
},
}, },
}, },
}; });
mockReq.body.endpoint = EModelEndpoint.azureOpenAI; mockReq.body.endpoint = EModelEndpoint.azureOpenAI;
mockReq.body.model = 'grok-3'; mockReq.body.model = 'grok-3';
@@ -503,31 +543,35 @@ 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] = { getAppConfig.mockResolvedValue({
titleConvo: true, endpoints: {
titleModel: 'gpt-4o', [EModelEndpoint.azureOpenAI]: {
titleMethod: 'structured', titleConvo: true,
titlePrompt: 'Azure instance title prompt', titleModel: 'gpt-4o',
streamRate: 35, titleMethod: 'structured',
modelGroupMap: { titlePrompt: 'Azure instance title prompt',
'gpt-4o': { streamRate: 35,
group: 'eastus', modelGroupMap: {
deploymentName: 'gpt-4o',
},
},
groupMap: {
eastus: {
apiKey: '${EASTUS_API_KEY}',
instanceName: 'region-instance',
version: '2024-02-15-preview',
models: {
'gpt-4o': { 'gpt-4o': {
group: 'eastus',
deploymentName: 'gpt-4o', deploymentName: 'gpt-4o',
}, },
}, },
groupMap: {
eastus: {
apiKey: '${EASTUS_API_KEY}',
instanceName: 'region-instance',
version: '2024-02-15-preview',
models: {
'gpt-4o': {
deploymentName: 'gpt-4o',
},
},
},
},
}, },
}, },
}; });
mockReq.body.endpoint = EModelEndpoint.azureOpenAI; mockReq.body.endpoint = EModelEndpoint.azureOpenAI;
mockReq.body.model = 'gpt-4o'; mockReq.body.model = 'gpt-4o';
@@ -551,32 +595,36 @@ 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] = { getAppConfig.mockResolvedValue({
titleConvo: true, endpoints: {
titleModel: Constants.CURRENT_MODEL, [EModelEndpoint.azureOpenAI]: {
titleMethod: 'functions', titleConvo: true,
streamRate: 35, titleModel: Constants.CURRENT_MODEL,
modelGroupMap: { titleMethod: 'functions',
'gpt-4o-latest': { streamRate: 35,
group: 'region-eastus', modelGroupMap: {
deploymentName: 'gpt-4o-mini',
version: '2024-02-15-preview',
},
},
groupMap: {
'region-eastus': {
apiKey: '${EASTUS2_API_KEY}',
instanceName: 'test-instance',
version: '2024-12-01-preview',
models: {
'gpt-4o-latest': { 'gpt-4o-latest': {
group: 'region-eastus',
deploymentName: 'gpt-4o-mini', deploymentName: 'gpt-4o-mini',
version: '2024-02-15-preview', version: '2024-02-15-preview',
}, },
}, },
groupMap: {
'region-eastus': {
apiKey: '${EASTUS2_API_KEY}',
instanceName: 'test-instance',
version: '2024-12-01-preview',
models: {
'gpt-4o-latest': {
deploymentName: 'gpt-4o-mini',
version: '2024-02-15-preview',
},
},
},
},
}, },
}, },
}; });
mockReq.body.endpoint = EModelEndpoint.azureOpenAI; mockReq.body.endpoint = EModelEndpoint.azureOpenAI;
mockReq.body.model = 'gpt-4o-latest'; mockReq.body.model = 'gpt-4o-latest';
@@ -598,59 +646,63 @@ 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] = { getAppConfig.mockResolvedValue({
titleConvo: true, endpoints: {
titleModel: 'o1-mini', [EModelEndpoint.azureOpenAI]: {
titleMethod: 'completion', titleConvo: true,
streamRate: 35, titleModel: 'o1-mini',
modelGroupMap: { titleMethod: 'completion',
'gpt-4o': { streamRate: 35,
group: 'eastus', modelGroupMap: {
deploymentName: 'gpt-4o',
},
'o1-mini': {
group: 'region-eastus',
deploymentName: 'o1-mini',
},
'codex-mini': {
group: 'codex-mini',
deploymentName: 'codex-mini',
},
},
groupMap: {
eastus: {
apiKey: '${EASTUS_API_KEY}',
instanceName: 'region-eastus',
version: '2024-02-15-preview',
models: {
'gpt-4o': { 'gpt-4o': {
group: 'eastus',
deploymentName: 'gpt-4o', deploymentName: 'gpt-4o',
}, },
},
},
'region-eastus': {
apiKey: '${EASTUS2_API_KEY}',
instanceName: 'region-eastus2',
version: '2024-12-01-preview',
models: {
'o1-mini': { 'o1-mini': {
group: 'region-eastus',
deploymentName: 'o1-mini', deploymentName: 'o1-mini',
}, },
},
},
'codex-mini': {
apiKey: '${AZURE_API_KEY}',
baseURL: 'https://example.cognitiveservices.azure.com/openai/',
version: '2025-04-01-preview',
serverless: true,
models: {
'codex-mini': { 'codex-mini': {
group: 'codex-mini',
deploymentName: 'codex-mini', deploymentName: 'codex-mini',
}, },
}, },
groupMap: {
eastus: {
apiKey: '${EASTUS_API_KEY}',
instanceName: 'region-eastus',
version: '2024-02-15-preview',
models: {
'gpt-4o': {
deploymentName: 'gpt-4o',
},
},
},
'region-eastus': {
apiKey: '${EASTUS2_API_KEY}',
instanceName: 'region-eastus2',
version: '2024-12-01-preview',
models: {
'o1-mini': {
deploymentName: 'o1-mini',
},
},
},
'codex-mini': {
apiKey: '${AZURE_API_KEY}',
baseURL: 'https://example.cognitiveservices.azure.com/openai/',
version: '2025-04-01-preview',
serverless: true,
models: {
'codex-mini': {
deploymentName: 'codex-mini',
},
},
},
},
}, },
}, },
}; });
mockReq.body.endpoint = EModelEndpoint.azureOpenAI; mockReq.body.endpoint = EModelEndpoint.azureOpenAI;
mockReq.body.model = 'o1-mini'; mockReq.body.model = 'o1-mini';
@@ -679,36 +731,37 @@ 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 = { getAppConfig.mockResolvedValue({
titleConvo: true, endpoints: {
titleModel: 'gpt-4', all: {
titleMethod: 'structured', titleConvo: true,
titlePrompt: 'Fallback title prompt from all config', titleModel: 'gpt-4',
titlePromptTemplate: 'Template: {{content}}', titleMethod: 'structured',
modelGroupMap: { titlePrompt: 'Fallback title prompt from all config',
'gpt-4': { titlePromptTemplate: 'Template: {{content}}',
group: 'default-group', modelGroupMap: {
deploymentName: 'gpt-4',
},
},
groupMap: {
'default-group': {
apiKey: '${AZURE_API_KEY}',
baseURL: 'https://default.openai.azure.com/',
version: '2024-02-15-preview',
serverless: true,
models: {
'gpt-4': { 'gpt-4': {
group: 'default-group',
deploymentName: 'gpt-4', deploymentName: 'gpt-4',
}, },
}, },
groupMap: {
'default-group': {
apiKey: '${AZURE_API_KEY}',
baseURL: 'https://default.openai.azure.com/',
version: '2024-02-15-preview',
serverless: true,
models: {
'gpt-4': {
deploymentName: 'gpt-4',
},
},
},
},
}, },
}, },
}; });
const text = 'Test Azure with all config fallback'; const text = 'Test Azure with all config fallback';
const abortController = new AbortController(); const abortController = new AbortController();
@@ -982,13 +1035,6 @@ describe('AgentClient - titleConvo', () => {
}; };
mockReq = { mockReq = {
app: {
locals: {
memory: {
messageWindowSize: 3,
},
},
},
user: { user: {
id: 'user-123', id: 'user-123',
personalization: { personalization: {
@@ -997,6 +1043,13 @@ describe('AgentClient - titleConvo', () => {
}, },
}; };
// Mock getAppConfig for memory tests
getAppConfig.mockResolvedValue({
memory: {
messageWindowSize: 3,
},
});
mockRes = {}; mockRes = {};
mockOptions = { mockOptions = {

View File

@@ -31,12 +31,12 @@ const {
grantPermission, grantPermission,
} = require('~/server/services/PermissionService'); } = require('~/server/services/PermissionService');
const { getStrategyFunctions } = require('~/server/services/Files/strategies'); const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { getCachedTools, getAppConfig } = require('~/server/services/Config');
const { resizeAvatar } = require('~/server/services/Files/images/avatar'); const { resizeAvatar } = require('~/server/services/Files/images/avatar');
const { getFileStrategy } = require('~/server/utils/getFileStrategy'); const { getFileStrategy } = require('~/server/utils/getFileStrategy');
const { refreshS3Url } = require('~/server/services/Files/S3/crud'); const { refreshS3Url } = require('~/server/services/Files/S3/crud');
const { filterFile } = require('~/server/services/Files/process'); const { filterFile } = require('~/server/services/Files/process');
const { updateAction, getActions } = require('~/models/Action'); const { updateAction, getActions } = require('~/models/Action');
const { getCachedTools } = require('~/server/services/Config');
const { deleteFileByFilter } = require('~/models/File'); const { deleteFileByFilter } = require('~/models/File');
const { getCategoriesWithCounts } = require('~/models'); const { getCategoriesWithCounts } = require('~/models');
@@ -487,6 +487,7 @@ const getListAgentsHandler = async (req, res) => {
*/ */
const uploadAgentAvatarHandler = async (req, res) => { const uploadAgentAvatarHandler = async (req, res) => {
try { try {
const appConfig = await getAppConfig({ role: req.user?.role });
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

@@ -29,6 +29,7 @@ const { createRun, StreamRunManager } = require('~/server/services/Runs');
const { addTitle } = require('~/server/services/Endpoints/assistants'); const { addTitle } = require('~/server/services/Endpoints/assistants');
const { createRunBody } = require('~/server/services/createRunBody'); const { createRunBody } = require('~/server/services/createRunBody');
const { sendResponse } = require('~/server/middleware/error'); const { sendResponse } = require('~/server/middleware/error');
const { getAppConfig } = require('~/server/services/Config');
const { getTransactions } = require('~/models/Transaction'); const { getTransactions } = require('~/models/Transaction');
const { checkBalance } = require('~/models/balanceMethods'); const { checkBalance } = require('~/models/balanceMethods');
const { getConvo } = require('~/models/Conversation'); const { getConvo } = require('~/models/Conversation');
@@ -47,6 +48,7 @@ const { getOpenAIClient } = require('./helpers');
* @returns {void} * @returns {void}
*/ */
const chatV1 = async (req, res) => { const chatV1 = async (req, res) => {
const appConfig = await getAppConfig({ role: req.user?.role });
logger.debug('[/assistants/chat/] req.body', req.body); logger.debug('[/assistants/chat/] req.body', req.body);
const { const {
@@ -251,7 +253,7 @@ const chatV1 = async (req, res) => {
} }
const checkBalanceBeforeRun = async () => { const checkBalanceBeforeRun = async () => {
const balance = req.app?.locals?.balance; const balance = appConfig?.balance;
if (!balance?.enabled) { if (!balance?.enabled) {
return; return;
} }

View File

@@ -26,6 +26,7 @@ const validateAuthor = require('~/server/middleware/assistants/validateAuthor');
const { createRun, StreamRunManager } = require('~/server/services/Runs'); const { createRun, StreamRunManager } = require('~/server/services/Runs');
const { addTitle } = require('~/server/services/Endpoints/assistants'); const { addTitle } = require('~/server/services/Endpoints/assistants');
const { createRunBody } = require('~/server/services/createRunBody'); const { createRunBody } = require('~/server/services/createRunBody');
const { getAppConfig } = require('~/server/services/Config');
const { getTransactions } = require('~/models/Transaction'); const { getTransactions } = require('~/models/Transaction');
const { checkBalance } = require('~/models/balanceMethods'); const { checkBalance } = require('~/models/balanceMethods');
const { getConvo } = require('~/models/Conversation'); const { getConvo } = require('~/models/Conversation');
@@ -44,6 +45,7 @@ const { getOpenAIClient } = require('./helpers');
*/ */
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 = await getAppConfig({ role: req.user?.role });
/** @type {{files: MongoFile[]}} */ /** @type {{files: MongoFile[]}} */
const { const {
@@ -126,7 +128,7 @@ const chatV2 = async (req, res) => {
} }
const checkBalanceBeforeRun = async () => { const checkBalanceBeforeRun = async () => {
const balance = req.app?.locals?.balance; const balance = appConfig?.balance;
if (!balance?.enabled) { if (!balance?.enabled) {
return; return;
} }
@@ -374,9 +376,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

@@ -7,8 +7,8 @@ const {
const { const {
initializeClient: initAzureClient, initializeClient: initAzureClient,
} = require('~/server/services/Endpoints/azureAssistants'); } = require('~/server/services/Endpoints/azureAssistants');
const { getEndpointsConfig, getAppConfig } = require('~/server/services/Config');
const { initializeClient } = require('~/server/services/Endpoints/assistants'); const { initializeClient } = require('~/server/services/Endpoints/assistants');
const { getEndpointsConfig } = require('~/server/services/Config');
/** /**
* @param {Express.Request} req * @param {Express.Request} req
@@ -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 = await getAppConfig({ role: req.user?.role });
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

@@ -5,9 +5,9 @@ const { uploadImageBuffer, filterFile } = require('~/server/services/Files/proce
const validateAuthor = require('~/server/middleware/assistants/validateAuthor'); const validateAuthor = require('~/server/middleware/assistants/validateAuthor');
const { getStrategyFunctions } = require('~/server/services/Files/strategies'); const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { deleteAssistantActions } = require('~/server/services/ActionService'); const { deleteAssistantActions } = require('~/server/services/ActionService');
const { getCachedTools, getAppConfig } = require('~/server/services/Config');
const { updateAssistantDoc, getAssistants } = require('~/models/Assistant'); const { updateAssistantDoc, getAssistants } = require('~/models/Assistant');
const { getOpenAIClient, fetchAssistants } = require('./helpers'); const { getOpenAIClient, fetchAssistants } = require('./helpers');
const { getCachedTools } = require('~/server/services/Config');
const { manifestToolMap } = require('~/app/clients/tools'); const { manifestToolMap } = require('~/app/clients/tools');
const { deleteFileByFilter } = require('~/models/File'); const { deleteFileByFilter } = require('~/models/File');
@@ -258,8 +258,9 @@ function filterAssistantDocs({ documents, userId, assistantsConfig = {} }) {
*/ */
const getAssistantDocuments = async (req, res) => { const getAssistantDocuments = async (req, res) => {
try { try {
const appConfig = await getAppConfig({ role: req.user?.role });
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 = await getAppConfig({ role: req.user?.role });
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

@@ -13,6 +13,7 @@ const { processFileURL, uploadImageBuffer } = require('~/server/services/Files/p
const { processCodeOutput } = require('~/server/services/Files/Code/process'); const { processCodeOutput } = require('~/server/services/Files/Code/process');
const { createToolCall, getToolCallsByConvo } = require('~/models/ToolCall'); const { createToolCall, getToolCallsByConvo } = require('~/models/ToolCall');
const { loadAuthValues } = require('~/server/services/Tools/credentials'); const { loadAuthValues } = require('~/server/services/Tools/credentials');
const { getAppConfig } = require('~/server/services/Config');
const { loadTools } = require('~/app/clients/tools/util'); const { loadTools } = require('~/app/clients/tools/util');
const { getRoleByName } = require('~/models/Role'); const { getRoleByName } = require('~/models/Role');
const { getMessage } = require('~/models/Message'); const { getMessage } = require('~/models/Message');
@@ -35,9 +36,10 @@ const toolAccessPermType = {
*/ */
const verifyWebSearchAuth = async (req, res) => { const verifyWebSearchAuth = async (req, res) => {
try { try {
const appConfig = await getAppConfig({ role: req.user?.role });
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 +112,7 @@ const verifyToolAuth = async (req, res) => {
*/ */
const callTool = async (req, res) => { const callTool = async (req, res) => {
try { try {
const appConfig = await getAppConfig({ role: req.user?.role });
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 +158,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,28 @@ 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({})),
}); setAppConfig: jest.fn(),
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 +50,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

@@ -1,5 +1,6 @@
const { v4 } = require('uuid'); const { v4 } = require('uuid');
const { handleAbortError } = require('~/server/middleware/abortMiddleware'); const { handleAbortError } = require('~/server/middleware/abortMiddleware');
const { getAppConfig } = require('~/server/services/Config/app');
/** /**
* Checks if the assistant is supported or excluded * Checks if the assistant is supported or excluded
@@ -12,8 +13,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 = await getAppConfig({ role: req.user?.role });
/** @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

@@ -1,4 +1,5 @@
const { SystemRoles } = require('librechat-data-provider'); const { SystemRoles } = require('librechat-data-provider');
const { getAppConfig } = require('~/server/services/Config/app');
const { getAssistant } = require('~/models/Assistant'); const { getAssistant } = require('~/models/Assistant');
/** /**
@@ -20,8 +21,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 = await getAppConfig({ role: req.user?.role });
/** @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

@@ -10,6 +10,7 @@ const azureAssistants = require('~/server/services/Endpoints/azureAssistants');
const assistants = require('~/server/services/Endpoints/assistants'); const assistants = require('~/server/services/Endpoints/assistants');
const { processFiles } = require('~/server/services/Files/process'); const { processFiles } = require('~/server/services/Files/process');
const anthropic = require('~/server/services/Endpoints/anthropic'); const anthropic = require('~/server/services/Endpoints/anthropic');
const { getAppConfig } = require('~/server/services/Config/app');
const bedrock = require('~/server/services/Endpoints/bedrock'); const bedrock = require('~/server/services/Endpoints/bedrock');
const openAI = require('~/server/services/Endpoints/openAI'); const openAI = require('~/server/services/Endpoints/openAI');
const agents = require('~/server/services/Endpoints/agents'); const agents = require('~/server/services/Endpoints/agents');
@@ -40,9 +41,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 = await getAppConfig({ role: req.user?.role });
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 = getAppConfig({
role: req?.user?.role,
});
if (email && !(await 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

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

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

@@ -16,6 +16,7 @@ const { getAgent, updateAgent, getListAgentsByAccess } = require('~/models/Agent
const { updateAction, getActions, deleteAction } = require('~/models/Action'); const { updateAction, getActions, deleteAction } = require('~/models/Action');
const { isActionDomainAllowed } = require('~/server/services/domains'); const { isActionDomainAllowed } = require('~/server/services/domains');
const { canAccessAgentResource } = require('~/server/middleware'); const { canAccessAgentResource } = require('~/server/middleware');
const { getAppConfig } = require('~/server/services/Config/app');
const { getRoleByName } = require('~/models/Role'); const { getRoleByName } = require('~/models/Role');
const router = express.Router(); const router = express.Router();
@@ -83,7 +84,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 = await getAppConfig({ role: req.user.role });
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

@@ -6,6 +6,7 @@ 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 { getAppConfig } = require('~/server/services/Config');
const { logger } = require('~/config'); const { logger } = require('~/config');
const router = express.Router(); const router = express.Router();
@@ -21,6 +22,7 @@ const router = express.Router();
*/ */
router.post('/:assistant_id', async (req, res) => { router.post('/:assistant_id', async (req, res) => {
try { try {
const appConfig = await getAppConfig({ role: req.user?.role });
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 +32,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 +130,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,9 +1,14 @@
const express = require('express'); const express = require('express');
const { isEnabled } = require('@librechat/api'); 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 {
const { getCustomConfig } = require('~/server/services/Config/getCustomConfig'); 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 &&
@@ -58,7 +65,7 @@ router.get('/', async function (req, res) {
/** @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 +98,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: appConfig?.balance,
sharedLinksEnabled, sharedLinksEnabled,
publicSharedLinksEnabled, publicSharedLinksEnabled,
analyticsGtmId: process.env.ANALYTICS_GTM_ID, analyticsGtmId: process.env.ANALYTICS_GTM_ID,
@@ -109,27 +116,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 (err) { } catch (error) {
logger.error('Error loading MCP servers', err); logger.error('Error loading MCP servers', error);
} }
} };
/** @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

@@ -2,14 +2,16 @@ const fs = require('fs').promises;
const express = require('express'); const express = require('express');
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 { filterFile } = require('~/server/services/Files/process');
const { getAppConfig } = require('~/server/services/Config');
const { logger } = require('~/config'); const { logger } = require('~/config');
const router = express.Router(); const router = express.Router();
router.post('/', async (req, res) => { router.post('/', async (req, res) => {
try { try {
const appConfig = await getAppConfig({ role: req.user?.role });
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 +21,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 +41,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

@@ -26,6 +26,7 @@ const { loadAuthValues } = require('~/server/services/Tools/credentials');
const { refreshS3FileUrls } = require('~/server/services/Files/S3/crud'); const { refreshS3FileUrls } = require('~/server/services/Files/S3/crud');
const { hasAccessToFilesViaAgent } = require('~/server/services/Files'); const { hasAccessToFilesViaAgent } = require('~/server/services/Files');
const { getFiles, batchUpdateFiles } = require('~/models/File'); const { getFiles, batchUpdateFiles } = require('~/models/File');
const { getAppConfig } = require('~/server/services/Config');
const { cleanFileName } = require('~/server/utils/files'); const { cleanFileName } = require('~/server/utils/files');
const { getAssistant } = require('~/models/Assistant'); const { getAssistant } = require('~/models/Assistant');
const { getAgent } = require('~/models/Agent'); const { getAgent } = require('~/models/Agent');
@@ -36,8 +37,9 @@ const router = express.Router();
router.get('/', async (req, res) => { router.get('/', async (req, res) => {
try { try {
const appConfig = await getAppConfig({ role: req.user?.role });
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 +116,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 = await getAppConfig({ role: req.user?.role });
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

@@ -7,11 +7,13 @@ const {
processImageFile, processImageFile,
processAgentFileUpload, processAgentFileUpload,
} = require('~/server/services/Files/process'); } = require('~/server/services/Files/process');
const { getAppConfig } = require('~/server/services/Config');
const { logger } = require('~/config'); const { logger } = require('~/config');
const router = express.Router(); const router = express.Router();
router.post('/', async (req, res) => { router.post('/', async (req, res) => {
const appConfig = await getAppConfig({ role: req.user?.role });
const metadata = req.body; const metadata = req.body;
try { try {
@@ -30,7 +32,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 +45,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

@@ -4,15 +4,21 @@ 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); getAppConfig({ role: req.user?.role })
if (!fs.existsSync(outputPath)) { .then((appConfig) => {
fs.mkdirSync(outputPath, { recursive: true }); const outputPath = path.join(appConfig.paths.uploads, 'temp', req.user.id);
} if (!fs.existsSync(outputPath)) {
cb(null, outputPath); fs.mkdirSync(outputPath, { recursive: true });
}
cb(null, outputPath);
})
.catch((error) => {
cb(error);
});
}, },
filename: function (req, file, cb) { filename: function (req, file, cb) {
req.file_id = crypto.randomUUID(); req.file_id = crypto.randomUUID();
@@ -68,8 +74,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,13 +22,6 @@ describe('Multer Configuration', () => {
mockReq = { mockReq = {
user: { id: 'test-user-123' }, user: { id: 'test-user-123' },
app: {
locals: {
paths: {
uploads: tempDir,
},
},
},
body: {}, body: {},
originalUrl: '/api/files/upload', originalUrl: '/api/files/upload',
}; };
@@ -53,6 +32,14 @@ describe('Multer Configuration', () => {
size: 1024, size: 1024,
}; };
// Mock getAppConfig to return paths
const { getAppConfig } = require('~/server/services/Config');
getAppConfig.mockResolvedValue({
paths: {
uploads: tempDir,
},
});
// Clear mocks // Clear mocks
jest.clearAllMocks(); jest.clearAllMocks();
}); });
@@ -79,7 +66,12 @@ 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; const { getAppConfig } = require('~/server/services/Config');
getAppConfig.mockResolvedValue({
paths: {
uploads: deepPath,
},
});
const cb = jest.fn((err, destination) => { const cb = jest.fn((err, destination) => {
expect(err).toBeNull(); expect(err).toBeNull();
@@ -331,11 +323,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();
}); });
@@ -465,23 +457,24 @@ describe('Multer Configuration', () => {
it('should handle file system errors when directory creation fails', (done) => { it('should handle file system errors when directory creation fails', (done) => {
// 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; const { getAppConfig } = require('~/server/services/Config');
getAppConfig.mockResolvedValue({
paths: {
uploads: invalidPath,
},
});
try { // Call getDestination which should fail due to permission/path issues
// Call getDestination which should fail due to permission/path issues storage.getDestination(mockReq, mockFile, (err, destination) => {
storage.getDestination(mockReq, mockFile, (err, destination) => { // Now we expect the error to be passed to the callback
// If callback is reached, we didn't get the expected error expect(err).toBeDefined();
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 // This is the expected behavior - mkdirSync throws synchronously for invalid paths
// On Linux, this typically returns EACCES (permission denied) // On Linux, this typically returns EACCES (permission denied)
// On macOS/Darwin, this returns ENOENT (no such file or directory) // On macOS/Darwin, this returns ENOENT (no such file or directory)
expect(['EACCES', 'ENOENT']).toContain(error.code); expect(['EACCES', 'ENOENT']).toContain(err.code);
expect(destination).toBeUndefined();
done(); done();
} });
}); });
it('should handle malformed filenames with real sanitization', (done) => { it('should handle malformed filenames with real sanitization', (done) => {
@@ -538,10 +531,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 +542,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,6 +8,7 @@ const {
deleteMemory, deleteMemory,
setMemory, setMemory,
} = require('~/models'); } = require('~/models');
const { getAppConfig } = require('~/server/services/Config');
const { requireJwtAuth } = require('~/server/middleware'); const { requireJwtAuth } = require('~/server/middleware');
const { getRoleByName } = require('~/models/Role'); const { getRoleByName } = require('~/models/Role');
@@ -60,7 +61,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 = await getAppConfig({ role: req.user?.role });
const memoryConfig = appConfig?.memory;
const tokenLimit = memoryConfig?.tokenLimit; const tokenLimit = memoryConfig?.tokenLimit;
const charLimit = memoryConfig?.charLimit || 10000; const charLimit = memoryConfig?.charLimit || 10000;
@@ -98,7 +100,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 = await getAppConfig({ role: req.user?.role });
const memoryConfig = appConfig?.memory;
const charLimit = memoryConfig?.charLimit || 10000; const charLimit = memoryConfig?.charLimit || 10000;
if (key.length > 1000) { if (key.length > 1000) {
@@ -117,6 +120,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 = await getAppConfig({ role: req.user?.role });
const memoryConfig = appConfig?.memory;
const tokenLimit = memoryConfig?.tokenLimit; const tokenLimit = memoryConfig?.tokenLimit;
if (tokenLimit) { if (tokenLimit) {
@@ -200,8 +206,8 @@ router.patch('/:key', memoryPayloadLimit, checkMemoryUpdate, async (req, res) =>
} }
const newKey = bodyKey || urlKey; const newKey = bodyKey || urlKey;
const appConfig = await getAppConfig({ role: req.user?.role });
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

@@ -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,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({
users: true, interfaceConfig: expect.objectContaining({
groups: true, peoplePicker: expect.objectContaining({
roles: true, users: true,
}); groups: 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,
[EModelEndpoint.agents]: agentsDefaults, endpoints: {
[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;

File diff suppressed because it is too large Load Diff

View File

@@ -16,6 +16,7 @@ const { retrieveAndProcessFile } = require('~/server/services/Files/process');
const { processRequiredActions } = require('~/server/services/ToolService'); const { processRequiredActions } = require('~/server/services/ToolService');
const { RunManager, waitForRun } = require('~/server/services/Runs'); const { RunManager, waitForRun } = require('~/server/services/Runs');
const { processMessages } = require('~/server/services/Threads'); const { processMessages } = require('~/server/services/Threads');
const { getAppConfig } = require('~/server/services/Config');
const { createOnProgress } = require('~/server/utils'); const { createOnProgress } = require('~/server/utils');
const { TextStream } = require('~/app/clients'); const { TextStream } = require('~/app/clients');
@@ -350,6 +351,7 @@ async function runAssistant({
accumulatedMessages = [], accumulatedMessages = [],
in_progress: inProgress, in_progress: inProgress,
}) { }) {
const appConfig = await getAppConfig({ role: openai.req.user?.role });
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 +398,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,
@@ -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 (!(await 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({

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,96 +0,0 @@
const { logger } = require('@librechat/data-schemas');
const { isEnabled, getUserMCPAuthMap } = 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,
);
};
/**
* @param {Object} params
* @param {string} params.userId
* @param {GenericTool[]} [params.tools]
* @param {import('@librechat/data-schemas').PluginAuthMethods['findPluginAuthsByKeys']} params.findPluginAuthsByKeys
* @returns {Promise<Record<string, Record<string, string>> | undefined>}
*/
async function getMCPAuthMap({ userId, tools, findPluginAuthsByKeys }) {
try {
if (!tools || tools.length === 0) {
return;
}
return await getUserMCPAuthMap({
tools,
userId,
findPluginAuthsByKeys,
});
} catch (err) {
logger.error(
`[api/server/controllers/agents/client.js #chatCompletion] Error getting custom user vars for agent`,
err,
);
}
}
/**
* @returns {Promise<boolean>}
*/
async function hasCustomUserVars() {
const customConfig = await getCustomConfig();
const mcpServers = customConfig?.mcpServers;
return Object.values(mcpServers ?? {}).some((server) => server.customUserVars);
}
module.exports = {
getMCPAuthMap,
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 = 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,6 +1,6 @@
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 loadCustomConfig = require('./loadCustomConfig'); const loadCustomConfig = require('./loadCustomConfig');
const loadConfigModels = require('./loadConfigModels'); const loadConfigModels = require('./loadConfigModels');
const loadDefaultModels = require('./loadDefaultModels'); const loadDefaultModels = require('./loadDefaultModels');
@@ -15,7 +15,7 @@ module.exports = {
loadDefaultModels, loadDefaultModels,
loadOverrideConfig, loadOverrideConfig,
loadAsyncEndpoints, loadAsyncEndpoints,
...appConfig,
...getCachedTools, ...getCachedTools,
...getCustomConfig,
...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,7 +1,7 @@
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
@@ -9,35 +9,31 @@ const { getCustomConfig } = require('./getCustomConfig');
* @param {Express.Request} req - The Express request object. * @param {Express.Request} 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

@@ -16,6 +16,7 @@ const generateArtifactsPrompt = require('~/app/clients/prompts/artifacts');
const { getProviderConfig } = require('~/server/services/Endpoints'); const { getProviderConfig } = require('~/server/services/Endpoints');
const { processFiles } = require('~/server/services/Files/process'); const { processFiles } = require('~/server/services/Files/process');
const { getFiles, getToolFilesByIds } = require('~/models/File'); const { getFiles, getToolFilesByIds } = require('~/models/File');
const { getAppConfig } = require('~/server/services/Config');
const { getConvoFiles } = require('~/models/Conversation'); const { getConvoFiles } = require('~/models/Conversation');
const { getModelMaxTokens } = require('~/utils'); const { getModelMaxTokens } = require('~/utils');
@@ -43,6 +44,7 @@ const initializeAgent = async ({
allowedProviders, allowedProviders,
isInitialAgent = false, isInitialAgent = false,
}) => { }) => {
const appConfig = await getAppConfig({ role: req.user?.role });
if ( if (
isAgentsEndpoint(endpointOption?.endpoint) && isAgentsEndpoint(endpointOption?.endpoint) &&
allowedProviders.size > 0 && allowedProviders.size > 0 &&
@@ -84,10 +86,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;
@@ -103,7 +106,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;
} }

View File

@@ -1,6 +1,6 @@
const { logger } = require('@librechat/data-schemas'); const { logger } = require('@librechat/data-schemas');
const { validateAgentModel } = require('@librechat/api');
const { createContentAggregator } = require('@librechat/agents'); const { createContentAggregator } = require('@librechat/agents');
const { validateAgentModel, getCustomEndpointConfig } = require('@librechat/api');
const { const {
Constants, Constants,
EModelEndpoint, EModelEndpoint,
@@ -13,9 +13,9 @@ const {
} = require('~/server/controllers/agents/callbacks'); } = require('~/server/controllers/agents/callbacks');
const { initializeAgent } = require('~/server/services/Endpoints/agents/agent'); const { initializeAgent } = require('~/server/services/Endpoints/agents/agent');
const { getModelsConfig } = require('~/server/controllers/ModelController'); const { getModelsConfig } = require('~/server/controllers/ModelController');
const { getCustomEndpointConfig } = require('~/server/services/Config');
const { loadAgentTools } = require('~/server/services/ToolService'); const { loadAgentTools } = require('~/server/services/ToolService');
const AgentClient = require('~/server/controllers/agents/client'); const AgentClient = require('~/server/controllers/agents/client');
const { getAppConfig } = require('~/server/services/Config');
const { getAgent } = require('~/models/Agent'); const { getAgent } = require('~/models/Agent');
const { logViolation } = require('~/cache'); const { logViolation } = require('~/cache');
@@ -50,6 +50,7 @@ const initializeClient = async ({ req, res, endpointOption }) => {
if (!endpointOption) { if (!endpointOption) {
throw new Error('Endpoint option not provided'); throw new Error('Endpoint option not provided');
} }
const appConfig = await getAppConfig({ role: req.user?.role });
// TODO: use endpointOption to determine options/modelOptions // TODO: use endpointOption to determine options/modelOptions
/** @type {Array<UsageMetadata>} */ /** @type {Array<UsageMetadata>} */
@@ -89,8 +90,7 @@ const initializeClient = async ({ req, res, endpointOption }) => {
} }
const agentConfigs = new Map(); const agentConfigs = new Map();
/** @type {Set<string>} */ const allowedProviders = new Set(appConfig?.endpoints?.[EModelEndpoint.agents]?.allowedProviders);
const allowedProviders = new Set(req?.app?.locals?.[EModelEndpoint.agents]?.allowedProviders);
const loadTools = createToolLoader(); const loadTools = createToolLoader();
/** @type {Array<MongoFile>} */ /** @type {Array<MongoFile>} */
@@ -144,10 +144,13 @@ const initializeClient = async ({ req, res, endpointOption }) => {
} }
} }
let endpointConfig = req.app.locals[primaryConfig.endpoint]; let endpointConfig = appConfig.endpoints?.[primaryConfig.endpoint];
if (!isAgentsEndpoint(primaryConfig.endpoint) && !endpointConfig) { if (!isAgentsEndpoint(primaryConfig.endpoint) && !endpointConfig) {
try { try {
endpointConfig = await getCustomEndpointConfig(primaryConfig.endpoint); endpointConfig = getCustomEndpointConfig({
endpoint: primaryConfig.endpoint,
appConfig,
});
} catch (err) { } catch (err) {
logger.error( logger.error(
'[api/server/controllers/agents/client.js #titleConvo] Error getting custom endpoint config', '[api/server/controllers/agents/client.js #titleConvo] Error getting custom endpoint config',

View File

@@ -2,8 +2,10 @@ const { EModelEndpoint } = require('librechat-data-provider');
const { getUserKey, checkUserKeyExpiry } = require('~/server/services/UserService'); const { getUserKey, checkUserKeyExpiry } = require('~/server/services/UserService');
const { getLLMConfig } = require('~/server/services/Endpoints/anthropic/llm'); const { getLLMConfig } = require('~/server/services/Endpoints/anthropic/llm');
const AnthropicClient = require('~/app/clients/AnthropicClient'); const AnthropicClient = require('~/app/clients/AnthropicClient');
const { getAppConfig } = require('~/server/services/Config');
const initializeClient = async ({ req, res, endpointOption, overrideModel, optionsOnly }) => { const initializeClient = async ({ req, res, endpointOption, overrideModel, optionsOnly }) => {
const appConfig = await getAppConfig({ role: req.user?.role });
const { ANTHROPIC_API_KEY, ANTHROPIC_REVERSE_PROXY, PROXY } = process.env; const { ANTHROPIC_API_KEY, ANTHROPIC_REVERSE_PROXY, PROXY } = process.env;
const expiresAt = req.body.key; const expiresAt = req.body.key;
const isUserProvided = ANTHROPIC_API_KEY === 'user_provided'; const isUserProvided = ANTHROPIC_API_KEY === 'user_provided';
@@ -23,15 +25,14 @@ const initializeClient = async ({ req, res, endpointOption, overrideModel, optio
let clientOptions = {}; let clientOptions = {};
/** @type {undefined | TBaseEndpoint} */ /** @type {undefined | TBaseEndpoint} */
const anthropicConfig = req.app.locals[EModelEndpoint.anthropic]; const anthropicConfig = appConfig.endpoints?.[EModelEndpoint.anthropic];
if (anthropicConfig) { if (anthropicConfig) {
clientOptions.streamRate = anthropicConfig.streamRate; clientOptions.streamRate = anthropicConfig.streamRate;
clientOptions.titleModel = anthropicConfig.titleModel; clientOptions.titleModel = anthropicConfig.titleModel;
} }
/** @type {undefined | TBaseEndpoint} */ const allConfig = appConfig.endpoints?.all;
const allConfig = req.app.locals.all;
if (allConfig) { if (allConfig) {
clientOptions.streamRate = allConfig.streamRate; clientOptions.streamRate = allConfig.streamRate;
} }

View File

@@ -7,6 +7,7 @@ const {
getUserKeyValues, getUserKeyValues,
getUserKeyExpiry, getUserKeyExpiry,
} = require('~/server/services/UserService'); } = require('~/server/services/UserService');
const { getAppConfig } = require('~/server/services/Config');
const OAIClient = require('~/app/clients/OpenAIClient'); const OAIClient = require('~/app/clients/OpenAIClient');
class Files { class Files {
@@ -48,6 +49,7 @@ class Files {
} }
const initializeClient = async ({ req, res, version, endpointOption, initAppClient = false }) => { const initializeClient = async ({ req, res, version, endpointOption, initAppClient = false }) => {
const appConfig = await getAppConfig({ role: req.user?.role });
const { PROXY, OPENAI_ORGANIZATION, AZURE_ASSISTANTS_API_KEY, AZURE_ASSISTANTS_BASE_URL } = const { PROXY, OPENAI_ORGANIZATION, AZURE_ASSISTANTS_API_KEY, AZURE_ASSISTANTS_BASE_URL } =
process.env; process.env;
@@ -81,7 +83,7 @@ const initializeClient = async ({ req, res, version, endpointOption, initAppClie
}; };
/** @type {TAzureConfig | undefined} */ /** @type {TAzureConfig | undefined} */
const azureConfig = req.app.locals[EModelEndpoint.azureOpenAI]; const azureConfig = appConfig.endpoints?.[EModelEndpoint.azureOpenAI];
/** @type {AzureOptions | undefined} */ /** @type {AzureOptions | undefined} */
let azureOptions; let azureOptions;

View File

@@ -12,6 +12,15 @@ jest.mock('~/server/services/UserService', () => ({
checkUserKeyExpiry: jest.requireActual('~/server/services/UserService').checkUserKeyExpiry, checkUserKeyExpiry: jest.requireActual('~/server/services/UserService').checkUserKeyExpiry,
})); }));
jest.mock('~/server/services/Config', () => ({
getAppConfig: jest.fn().mockResolvedValue({
azureAssistants: {
apiKey: 'test-key',
baseURL: 'https://test.url',
},
}),
}));
const today = new Date(); const today = new Date();
const tenDaysFromToday = new Date(today.setDate(today.getDate() + 10)); const tenDaysFromToday = new Date(today.setDate(today.getDate() + 10));
const isoString = tenDaysFromToday.toISOString(); const isoString = tenDaysFromToday.toISOString();

View File

@@ -9,8 +9,10 @@ const {
removeNullishValues, removeNullishValues,
} = require('librechat-data-provider'); } = require('librechat-data-provider');
const { getUserKey, checkUserKeyExpiry } = require('~/server/services/UserService'); const { getUserKey, checkUserKeyExpiry } = require('~/server/services/UserService');
const { getAppConfig } = require('~/server/services/Config');
const getOptions = async ({ req, overrideModel, endpointOption }) => { const getOptions = async ({ req, overrideModel, endpointOption }) => {
const appConfig = await getAppConfig({ role: req.user?.role });
const { const {
BEDROCK_AWS_SECRET_ACCESS_KEY, BEDROCK_AWS_SECRET_ACCESS_KEY,
BEDROCK_AWS_ACCESS_KEY_ID, BEDROCK_AWS_ACCESS_KEY_ID,
@@ -50,14 +52,13 @@ const getOptions = async ({ req, overrideModel, endpointOption }) => {
let streamRate = Constants.DEFAULT_STREAM_RATE; let streamRate = Constants.DEFAULT_STREAM_RATE;
/** @type {undefined | TBaseEndpoint} */ /** @type {undefined | TBaseEndpoint} */
const bedrockConfig = req.app.locals[EModelEndpoint.bedrock]; const bedrockConfig = appConfig.endpoints?.[EModelEndpoint.bedrock];
if (bedrockConfig && bedrockConfig.streamRate) { if (bedrockConfig && bedrockConfig.streamRate) {
streamRate = bedrockConfig.streamRate; streamRate = bedrockConfig.streamRate;
} }
/** @type {undefined | TBaseEndpoint} */ const allConfig = appConfig.endpoints?.all;
const allConfig = req.app.locals.all;
if (allConfig && allConfig.streamRate) { if (allConfig && allConfig.streamRate) {
streamRate = allConfig.streamRate; streamRate = allConfig.streamRate;
} }

View File

@@ -1,3 +1,6 @@
const { Providers } = require('@librechat/agents');
const { isUserProvided, getCustomEndpointConfig } = require('@librechat/api');
const { getOpenAIConfig, createHandleLLMNewToken, resolveHeaders } = require('@librechat/api');
const { const {
CacheKeys, CacheKeys,
ErrorTypes, ErrorTypes,
@@ -5,22 +8,23 @@ const {
FetchTokenConfig, FetchTokenConfig,
extractEnvVariable, extractEnvVariable,
} = require('librechat-data-provider'); } = require('librechat-data-provider');
const { Providers } = require('@librechat/agents');
const { getOpenAIConfig, createHandleLLMNewToken, resolveHeaders } = require('@librechat/api');
const { getUserKeyValues, checkUserKeyExpiry } = require('~/server/services/UserService'); const { getUserKeyValues, checkUserKeyExpiry } = require('~/server/services/UserService');
const { getCustomEndpointConfig } = require('~/server/services/Config');
const { fetchModels } = require('~/server/services/ModelService'); const { fetchModels } = require('~/server/services/ModelService');
const { getAppConfig } = require('~/server/services/Config');
const OpenAIClient = require('~/app/clients/OpenAIClient'); const OpenAIClient = require('~/app/clients/OpenAIClient');
const { isUserProvided } = require('~/server/utils');
const getLogStores = require('~/cache/getLogStores'); const getLogStores = require('~/cache/getLogStores');
const { PROXY } = process.env; const { PROXY } = process.env;
const initializeClient = async ({ req, res, endpointOption, optionsOnly, overrideEndpoint }) => { const initializeClient = async ({ req, res, endpointOption, optionsOnly, overrideEndpoint }) => {
const appConfig = await getAppConfig({ role: req.user?.role });
const { key: expiresAt } = req.body; const { key: expiresAt } = req.body;
const endpoint = overrideEndpoint ?? req.body.endpoint; const endpoint = overrideEndpoint ?? req.body.endpoint;
const endpointConfig = await getCustomEndpointConfig(endpoint); const endpointConfig = getCustomEndpointConfig({
endpoint,
appConfig,
});
if (!endpointConfig) { if (!endpointConfig) {
throw new Error(`Config not found for the ${endpoint} custom endpoint.`); throw new Error(`Config not found for the ${endpoint} custom endpoint.`);
} }
@@ -117,8 +121,7 @@ const initializeClient = async ({ req, res, endpointOption, optionsOnly, overrid
endpointTokenConfig, endpointTokenConfig,
}; };
/** @type {undefined | TBaseEndpoint} */ const allConfig = appConfig.endpoints?.all;
const allConfig = req.app.locals.all;
if (allConfig) { if (allConfig) {
customOptions.streamRate = allConfig.streamRate; customOptions.streamRate = allConfig.streamRate;
} }

View File

@@ -1,21 +1,16 @@
const initializeClient = require('./initialize'); const initializeClient = require('./initialize');
jest.mock('@librechat/api', () => ({ jest.mock('@librechat/api', () => ({
...jest.requireActual('@librechat/api'),
resolveHeaders: jest.fn(), resolveHeaders: jest.fn(),
getOpenAIConfig: jest.fn(), getOpenAIConfig: jest.fn(),
createHandleLLMNewToken: jest.fn(), createHandleLLMNewToken: jest.fn(),
})); getCustomEndpointConfig: jest.fn().mockReturnValue({
apiKey: 'test-key',
jest.mock('librechat-data-provider', () => ({ baseURL: 'https://test.com',
CacheKeys: { TOKEN_CONFIG: 'token_config' }, headers: { 'x-user': '{{LIBRECHAT_USER_ID}}', 'x-email': '{{LIBRECHAT_USER_EMAIL}}' },
ErrorTypes: { NO_USER_KEY: 'NO_USER_KEY', NO_BASE_URL: 'NO_BASE_URL' }, models: { default: ['test-model'] },
envVarRegex: /\$\{([^}]+)\}/, }),
FetchTokenConfig: {},
extractEnvVariable: jest.fn((value) => value),
}));
jest.mock('@librechat/agents', () => ({
Providers: { OLLAMA: 'ollama' },
})); }));
jest.mock('~/server/services/UserService', () => ({ jest.mock('~/server/services/UserService', () => ({
@@ -24,11 +19,11 @@ jest.mock('~/server/services/UserService', () => ({
})); }));
jest.mock('~/server/services/Config', () => ({ jest.mock('~/server/services/Config', () => ({
getCustomEndpointConfig: jest.fn().mockResolvedValue({ getAppConfig: jest.fn().mockResolvedValue({
apiKey: 'test-key', 'test-endpoint': {
baseURL: 'https://test.com', apiKey: 'test-key',
headers: { 'x-user': '{{LIBRECHAT_USER_ID}}', 'x-email': '{{LIBRECHAT_USER_EMAIL}}' }, baseURL: 'https://test.com',
models: { default: ['test-model'] }, },
}), }),
})); }));
@@ -42,10 +37,6 @@ jest.mock('~/app/clients/OpenAIClient', () => {
})); }));
}); });
jest.mock('~/server/utils', () => ({
isUserProvided: jest.fn().mockReturnValue(false),
}));
jest.mock('~/cache/getLogStores', () => jest.mock('~/cache/getLogStores', () =>
jest.fn().mockReturnValue({ jest.fn().mockReturnValue({
get: jest.fn(), get: jest.fn(),
@@ -55,13 +46,25 @@ jest.mock('~/cache/getLogStores', () =>
describe('custom/initializeClient', () => { describe('custom/initializeClient', () => {
const mockRequest = { const mockRequest = {
body: { endpoint: 'test-endpoint' }, body: { endpoint: 'test-endpoint' },
user: { id: 'user-123', email: 'test@example.com' }, user: { id: 'user-123', email: 'test@example.com', role: 'user' },
app: { locals: {} }, app: { locals: {} },
}; };
const mockResponse = {}; const mockResponse = {};
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
const { getCustomEndpointConfig, resolveHeaders, getOpenAIConfig } = require('@librechat/api');
getCustomEndpointConfig.mockReturnValue({
apiKey: 'test-key',
baseURL: 'https://test.com',
headers: { 'x-user': '{{LIBRECHAT_USER_ID}}', 'x-email': '{{LIBRECHAT_USER_EMAIL}}' },
models: { default: ['test-model'] },
});
resolveHeaders.mockReturnValue({ 'x-user': 'user-123', 'x-email': 'test@example.com' });
getOpenAIConfig.mockReturnValue({
useLegacyContent: true,
endpointTokenConfig: null,
});
}); });
it('calls resolveHeaders with headers, user, and body for body placeholder support', async () => { it('calls resolveHeaders with headers, user, and body for body placeholder support', async () => {
@@ -69,14 +72,14 @@ describe('custom/initializeClient', () => {
await initializeClient({ req: mockRequest, res: mockResponse, optionsOnly: true }); await initializeClient({ req: mockRequest, res: mockResponse, optionsOnly: true });
expect(resolveHeaders).toHaveBeenCalledWith({ expect(resolveHeaders).toHaveBeenCalledWith({
headers: { 'x-user': '{{LIBRECHAT_USER_ID}}', 'x-email': '{{LIBRECHAT_USER_EMAIL}}' }, headers: { 'x-user': '{{LIBRECHAT_USER_ID}}', 'x-email': '{{LIBRECHAT_USER_EMAIL}}' },
user: { id: 'user-123', email: 'test@example.com' }, user: { id: 'user-123', email: 'test@example.com', role: 'user' },
body: { endpoint: 'test-endpoint' }, // body - supports {{LIBRECHAT_BODY_*}} placeholders body: { endpoint: 'test-endpoint' }, // body - supports {{LIBRECHAT_BODY_*}} placeholders
}); });
}); });
it('throws if endpoint config is missing', async () => { it('throws if endpoint config is missing', async () => {
const { getCustomEndpointConfig } = require('~/server/services/Config'); const { getCustomEndpointConfig } = require('@librechat/api');
getCustomEndpointConfig.mockResolvedValueOnce(null); getCustomEndpointConfig.mockReturnValueOnce(null);
await expect( await expect(
initializeClient({ req: mockRequest, res: mockResponse, optionsOnly: true }), initializeClient({ req: mockRequest, res: mockResponse, optionsOnly: true }),
).rejects.toThrow('Config not found for the test-endpoint custom endpoint.'); ).rejects.toThrow('Config not found for the test-endpoint custom endpoint.');

View File

@@ -2,6 +2,7 @@ const path = require('path');
const { EModelEndpoint, AuthKeys } = require('librechat-data-provider'); const { EModelEndpoint, AuthKeys } = require('librechat-data-provider');
const { getGoogleConfig, isEnabled, loadServiceKey } = require('@librechat/api'); const { getGoogleConfig, isEnabled, loadServiceKey } = require('@librechat/api');
const { getUserKey, checkUserKeyExpiry } = require('~/server/services/UserService'); const { getUserKey, checkUserKeyExpiry } = require('~/server/services/UserService');
const { getAppConfig } = require('~/server/services/Config');
const { GoogleClient } = require('~/app'); const { GoogleClient } = require('~/app');
const initializeClient = async ({ req, res, endpointOption, overrideModel, optionsOnly }) => { const initializeClient = async ({ req, res, endpointOption, overrideModel, optionsOnly }) => {
@@ -46,10 +47,11 @@ const initializeClient = async ({ req, res, endpointOption, overrideModel, optio
let clientOptions = {}; let clientOptions = {};
const appConfig = await getAppConfig({ role: req.user?.role });
/** @type {undefined | TBaseEndpoint} */ /** @type {undefined | TBaseEndpoint} */
const allConfig = req.app.locals.all; const allConfig = appConfig.endpoints?.all;
/** @type {undefined | TBaseEndpoint} */ /** @type {undefined | TBaseEndpoint} */
const googleConfig = req.app.locals[EModelEndpoint.google]; const googleConfig = appConfig.endpoints?.[EModelEndpoint.google];
if (googleConfig) { if (googleConfig) {
clientOptions.streamRate = googleConfig.streamRate; clientOptions.streamRate = googleConfig.streamRate;

View File

@@ -8,6 +8,14 @@ jest.mock('~/server/services/UserService', () => ({
getUserKey: jest.fn().mockImplementation(() => ({})), getUserKey: jest.fn().mockImplementation(() => ({})),
})); }));
jest.mock('~/server/services/Config', () => ({
getAppConfig: jest.fn().mockResolvedValue({
google: {
apiKey: 'test-key',
},
}),
}));
const app = { locals: {} }; const app = { locals: {} };
describe('google/initializeClient', () => { describe('google/initializeClient', () => {

View File

@@ -1,7 +1,8 @@
const { isEnabled } = require('@librechat/api');
const { EModelEndpoint, CacheKeys, Constants, googleSettings } = require('librechat-data-provider'); const { EModelEndpoint, CacheKeys, Constants, googleSettings } = require('librechat-data-provider');
const { getAppConfig } = require('~/server/services/Config');
const getLogStores = require('~/cache/getLogStores'); const getLogStores = require('~/cache/getLogStores');
const initializeClient = require('./initialize'); const initializeClient = require('./initialize');
const { isEnabled } = require('~/server/utils');
const { saveConvo } = require('~/models'); const { saveConvo } = require('~/models');
const addTitle = async (req, { text, response, client }) => { const addTitle = async (req, { text, response, client }) => {
@@ -14,7 +15,8 @@ const addTitle = async (req, { text, response, client }) => {
return; return;
} }
const { GOOGLE_TITLE_MODEL } = process.env ?? {}; const { GOOGLE_TITLE_MODEL } = process.env ?? {};
const providerConfig = req.app.locals[EModelEndpoint.google]; const appConfig = await getAppConfig({ role: req.user?.role });
const providerConfig = appConfig.endpoints?.[EModelEndpoint.google];
let model = let model =
providerConfig?.titleModel ?? providerConfig?.titleModel ??
GOOGLE_TITLE_MODEL ?? GOOGLE_TITLE_MODEL ??

View File

@@ -1,11 +1,11 @@
const { Providers } = require('@librechat/agents'); const { Providers } = require('@librechat/agents');
const { EModelEndpoint } = require('librechat-data-provider'); const { EModelEndpoint } = require('librechat-data-provider');
const { getCustomEndpointConfig } = require('@librechat/api');
const initAnthropic = require('~/server/services/Endpoints/anthropic/initialize'); const initAnthropic = require('~/server/services/Endpoints/anthropic/initialize');
const getBedrockOptions = require('~/server/services/Endpoints/bedrock/options'); const getBedrockOptions = require('~/server/services/Endpoints/bedrock/options');
const initOpenAI = require('~/server/services/Endpoints/openAI/initialize'); const initOpenAI = require('~/server/services/Endpoints/openAI/initialize');
const initCustom = require('~/server/services/Endpoints/custom/initialize'); const initCustom = require('~/server/services/Endpoints/custom/initialize');
const initGoogle = require('~/server/services/Endpoints/google/initialize'); const initGoogle = require('~/server/services/Endpoints/google/initialize');
const { getCustomEndpointConfig } = require('~/server/services/Config');
/** Check if the provider is a known custom provider /** Check if the provider is a known custom provider
* @param {string | undefined} [provider] - The provider string * @param {string | undefined} [provider] - The provider string
@@ -31,14 +31,16 @@ const providerConfigMap = {
/** /**
* Get the provider configuration and override endpoint based on the provider string * Get the provider configuration and override endpoint based on the provider string
* @param {string} provider - The provider string * @param {Object} params
* @returns {Promise<{ * @param {string} params.provider - The provider string
* getOptions: Function, * @param {AppConfig} params.appConfig - The application configuration
* @returns {{
* getOptions: (typeof providerConfigMap)[keyof typeof providerConfigMap],
* overrideProvider: string, * overrideProvider: string,
* customEndpointConfig?: TEndpoint * customEndpointConfig?: TEndpoint
* }>} * }}
*/ */
async function getProviderConfig(provider) { function getProviderConfig({ provider, appConfig }) {
let getOptions = providerConfigMap[provider]; let getOptions = providerConfigMap[provider];
let overrideProvider = provider; let overrideProvider = provider;
/** @type {TEndpoint | undefined} */ /** @type {TEndpoint | undefined} */
@@ -48,7 +50,7 @@ async function getProviderConfig(provider) {
overrideProvider = provider.toLowerCase(); overrideProvider = provider.toLowerCase();
getOptions = providerConfigMap[overrideProvider]; getOptions = providerConfigMap[overrideProvider];
} else if (!getOptions) { } else if (!getOptions) {
customEndpointConfig = await getCustomEndpointConfig(provider); customEndpointConfig = getCustomEndpointConfig({ endpoint: provider, appConfig });
if (!customEndpointConfig) { if (!customEndpointConfig) {
throw new Error(`Provider ${provider} not supported`); throw new Error(`Provider ${provider} not supported`);
} }
@@ -57,7 +59,7 @@ async function getProviderConfig(provider) {
} }
if (isKnownCustomProvider(overrideProvider) && !customEndpointConfig) { if (isKnownCustomProvider(overrideProvider) && !customEndpointConfig) {
customEndpointConfig = await getCustomEndpointConfig(provider); customEndpointConfig = getCustomEndpointConfig({ endpoint: provider, appConfig });
if (!customEndpointConfig) { if (!customEndpointConfig) {
throw new Error(`Provider ${provider} not supported`); throw new Error(`Provider ${provider} not supported`);
} }

View File

@@ -8,6 +8,7 @@ const {
createHandleLLMNewToken, createHandleLLMNewToken,
} = require('@librechat/api'); } = require('@librechat/api');
const { getUserKeyValues, checkUserKeyExpiry } = require('~/server/services/UserService'); const { getUserKeyValues, checkUserKeyExpiry } = require('~/server/services/UserService');
const { getAppConfig } = require('~/server/services/Config');
const OpenAIClient = require('~/app/clients/OpenAIClient'); const OpenAIClient = require('~/app/clients/OpenAIClient');
const initializeClient = async ({ const initializeClient = async ({
@@ -18,6 +19,7 @@ const initializeClient = async ({
overrideEndpoint, overrideEndpoint,
overrideModel, overrideModel,
}) => { }) => {
const appConfig = await getAppConfig({ role: req.user?.role });
const { const {
PROXY, PROXY,
OPENAI_API_KEY, OPENAI_API_KEY,
@@ -64,7 +66,7 @@ const initializeClient = async ({
const isAzureOpenAI = endpoint === EModelEndpoint.azureOpenAI; const isAzureOpenAI = endpoint === EModelEndpoint.azureOpenAI;
/** @type {false | TAzureConfig} */ /** @type {false | TAzureConfig} */
const azureConfig = isAzureOpenAI && req.app.locals[EModelEndpoint.azureOpenAI]; const azureConfig = isAzureOpenAI && appConfig.endpoints?.[EModelEndpoint.azureOpenAI];
let serverless = false; let serverless = false;
if (isAzureOpenAI && azureConfig) { if (isAzureOpenAI && azureConfig) {
const { modelGroupMap, groupMap } = azureConfig; const { modelGroupMap, groupMap } = azureConfig;
@@ -113,15 +115,14 @@ const initializeClient = async ({
} }
/** @type {undefined | TBaseEndpoint} */ /** @type {undefined | TBaseEndpoint} */
const openAIConfig = req.app.locals[EModelEndpoint.openAI]; const openAIConfig = appConfig.endpoints?.[EModelEndpoint.openAI];
if (!isAzureOpenAI && openAIConfig) { if (!isAzureOpenAI && openAIConfig) {
clientOptions.streamRate = openAIConfig.streamRate; clientOptions.streamRate = openAIConfig.streamRate;
clientOptions.titleModel = openAIConfig.titleModel; clientOptions.titleModel = openAIConfig.titleModel;
} }
/** @type {undefined | TBaseEndpoint} */ const allConfig = appConfig.endpoints?.all;
const allConfig = req.app.locals.all;
if (allConfig) { if (allConfig) {
clientOptions.streamRate = allConfig.streamRate; clientOptions.streamRate = allConfig.streamRate;
} }

View File

@@ -1,4 +1,13 @@
jest.mock('~/cache/getLogStores'); jest.mock('~/cache/getLogStores', () => ({
getLogStores: jest.fn().mockReturnValue({
get: jest.fn().mockResolvedValue({
openAI: { apiKey: 'test-key' },
}),
set: jest.fn(),
delete: jest.fn(),
}),
}));
const { EModelEndpoint, ErrorTypes, validateAzureGroups } = require('librechat-data-provider'); const { EModelEndpoint, ErrorTypes, validateAzureGroups } = require('librechat-data-provider');
const { getUserKey, getUserKeyValues } = require('~/server/services/UserService'); const { getUserKey, getUserKeyValues } = require('~/server/services/UserService');
const initializeClient = require('./initialize'); const initializeClient = require('./initialize');
@@ -11,6 +20,40 @@ jest.mock('~/server/services/UserService', () => ({
checkUserKeyExpiry: jest.requireActual('~/server/services/UserService').checkUserKeyExpiry, checkUserKeyExpiry: jest.requireActual('~/server/services/UserService').checkUserKeyExpiry,
})); }));
jest.mock('~/server/services/Config', () => ({
getAppConfig: jest.fn().mockResolvedValue({
endpoints: {
openAI: {
apiKey: 'test-key',
},
azureOpenAI: {
apiKey: 'test-azure-key',
modelNames: ['gpt-4-vision-preview', 'gpt-3.5-turbo', 'gpt-4'],
modelGroupMap: {
'gpt-4-vision-preview': {
group: 'librechat-westus',
deploymentName: 'gpt-4-vision-preview',
version: '2024-02-15-preview',
},
},
groupMap: {
'librechat-westus': {
apiKey: 'WESTUS_API_KEY',
instanceName: 'librechat-westus',
version: '2023-12-01-preview',
models: {
'gpt-4-vision-preview': {
deploymentName: 'gpt-4-vision-preview',
version: '2024-02-15-preview',
},
},
},
},
},
},
}),
}));
describe('initializeClient', () => { describe('initializeClient', () => {
// Set up environment variables // Set up environment variables
const originalEnvironment = process.env; const originalEnvironment = process.env;
@@ -79,7 +122,7 @@ describe('initializeClient', () => {
}, },
]; ];
const { modelNames, modelGroupMap, groupMap } = validateAzureGroups(validAzureConfigs); const { modelNames } = validateAzureGroups(validAzureConfigs);
beforeEach(() => { beforeEach(() => {
jest.resetModules(); // Clears the cache jest.resetModules(); // Clears the cache
@@ -112,25 +155,29 @@ describe('initializeClient', () => {
test('should initialize client with Azure credentials when endpoint is azureOpenAI', async () => { test('should initialize client with Azure credentials when endpoint is azureOpenAI', async () => {
process.env.AZURE_API_KEY = 'test-azure-api-key'; process.env.AZURE_API_KEY = 'test-azure-api-key';
(process.env.AZURE_OPENAI_API_INSTANCE_NAME = 'some-value'), (process.env.AZURE_OPENAI_API_INSTANCE_NAME = 'some-value'),
(process.env.AZURE_OPENAI_API_DEPLOYMENT_NAME = 'some-value'), (process.env.AZURE_OPENAI_API_DEPLOYMENT_NAME = 'some-value'),
(process.env.AZURE_OPENAI_API_VERSION = 'some-value'), (process.env.AZURE_OPENAI_API_VERSION = 'some-value'),
(process.env.AZURE_OPENAI_API_COMPLETIONS_DEPLOYMENT_NAME = 'some-value'), (process.env.AZURE_OPENAI_API_COMPLETIONS_DEPLOYMENT_NAME = 'some-value'),
(process.env.AZURE_OPENAI_API_EMBEDDINGS_DEPLOYMENT_NAME = 'some-value'), (process.env.AZURE_OPENAI_API_EMBEDDINGS_DEPLOYMENT_NAME = 'some-value'),
(process.env.OPENAI_API_KEY = 'test-openai-api-key'); (process.env.OPENAI_API_KEY = 'test-openai-api-key');
process.env.DEBUG_OPENAI = 'false'; process.env.DEBUG_OPENAI = 'false';
process.env.OPENAI_SUMMARIZE = 'false'; process.env.OPENAI_SUMMARIZE = 'false';
const req = { const req = {
body: { key: null, endpoint: 'azureOpenAI' }, body: {
key: null,
endpoint: 'azureOpenAI',
model: 'gpt-4-vision-preview',
},
user: { id: '123' }, user: { id: '123' },
app, app,
}; };
const res = {}; const res = {};
const endpointOption = { modelOptions: { model: 'test-model' } }; const endpointOption = {};
const client = await initializeClient({ req, res, endpointOption }); const client = await initializeClient({ req, res, endpointOption });
expect(client.openAIApiKey).toBe('test-azure-api-key'); expect(client.openAIApiKey).toBe('WESTUS_API_KEY');
expect(client.client).toBeInstanceOf(OpenAIClient); expect(client.client).toBeInstanceOf(OpenAIClient);
}); });
@@ -291,7 +338,7 @@ describe('initializeClient', () => {
let userValues = getUserKey(); let userValues = getUserKey();
try { try {
userValues = JSON.parse(userValues); userValues = JSON.parse(userValues);
} catch (e) { } catch {
throw new Error( throw new Error(
JSON.stringify({ JSON.stringify({
type: ErrorTypes.INVALID_USER_KEY, type: ErrorTypes.INVALID_USER_KEY,
@@ -307,6 +354,9 @@ describe('initializeClient', () => {
}); });
test('should initialize client correctly for Azure OpenAI with valid configuration', async () => { test('should initialize client correctly for Azure OpenAI with valid configuration', async () => {
// Set up Azure environment variables
process.env.WESTUS_API_KEY = 'test-westus-key';
const req = { const req = {
body: { body: {
key: null, key: null,
@@ -314,15 +364,6 @@ describe('initializeClient', () => {
model: modelNames[0], model: modelNames[0],
}, },
user: { id: '123' }, user: { id: '123' },
app: {
locals: {
[EModelEndpoint.azureOpenAI]: {
modelNames,
modelGroupMap,
groupMap,
},
},
},
}; };
const res = {}; const res = {};
const endpointOption = {}; const endpointOption = {};

View File

@@ -2,10 +2,10 @@ const axios = require('axios');
const fs = require('fs').promises; const fs = require('fs').promises;
const FormData = require('form-data'); const FormData = require('form-data');
const { Readable } = require('stream'); const { Readable } = require('stream');
const { logger } = require('@librechat/data-schemas');
const { genAzureEndpoint } = require('@librechat/api'); const { genAzureEndpoint } = require('@librechat/api');
const { extractEnvVariable, STTProviders } = require('librechat-data-provider'); const { extractEnvVariable, STTProviders } = require('librechat-data-provider');
const { getCustomConfig } = require('~/server/services/Config'); const { getAppConfig } = require('~/server/services/Config');
const { logger } = require('~/config');
/** /**
* Maps MIME types to their corresponding file extensions for audio files. * Maps MIME types to their corresponding file extensions for audio files.
@@ -84,12 +84,7 @@ function getFileExtensionFromMime(mimeType) {
* @class * @class
*/ */
class STTService { class STTService {
/** constructor() {
* Creates an instance of STTService.
* @param {Object} customConfig - The custom configuration object.
*/
constructor(customConfig) {
this.customConfig = customConfig;
this.providerStrategies = { this.providerStrategies = {
[STTProviders.OPENAI]: this.openAIProvider, [STTProviders.OPENAI]: this.openAIProvider,
[STTProviders.AZURE_OPENAI]: this.azureOpenAIProvider, [STTProviders.AZURE_OPENAI]: this.azureOpenAIProvider,
@@ -104,21 +99,20 @@ class STTService {
* @throws {Error} If the custom config is not found. * @throws {Error} If the custom config is not found.
*/ */
static async getInstance() { static async getInstance() {
const customConfig = await getCustomConfig(); return new STTService();
if (!customConfig) {
throw new Error('Custom config not found');
}
return new STTService(customConfig);
} }
/** /**
* Retrieves the configured STT provider and its schema. * Retrieves the configured STT provider and its schema.
* @param {ServerRequest} req - The request object.
* @returns {Promise<[string, Object]>} A promise that resolves to an array containing the provider name and its schema. * @returns {Promise<[string, Object]>} A promise that resolves to an array containing the provider name and its schema.
* @throws {Error} If no STT schema is set, multiple providers are set, or no provider is set. * @throws {Error} If no STT schema is set, multiple providers are set, or no provider is set.
*/ */
async getProviderSchema() { async getProviderSchema(req) {
const sttSchema = this.customConfig.speech.stt; const appConfig = await getAppConfig({
role: req?.user?.role,
});
const sttSchema = appConfig?.speech?.stt;
if (!sttSchema) { if (!sttSchema) {
throw new Error( throw new Error(
'No STT schema is set. Did you configure STT in the custom config (librechat.yaml)?', 'No STT schema is set. Did you configure STT in the custom config (librechat.yaml)?',
@@ -274,7 +268,7 @@ class STTService {
* @param {Object} res - The response object. * @param {Object} res - The response object.
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
async processTextToSpeech(req, res) { async processSpeechToText(req, res) {
if (!req.file) { if (!req.file) {
return res.status(400).json({ message: 'No audio file provided in the FormData' }); return res.status(400).json({ message: 'No audio file provided in the FormData' });
} }
@@ -287,7 +281,7 @@ class STTService {
}; };
try { try {
const [provider, sttSchema] = await this.getProviderSchema(); const [provider, sttSchema] = await this.getProviderSchema(req);
const text = await this.sttRequest(provider, sttSchema, { audioBuffer, audioFile }); const text = await this.sttRequest(provider, sttSchema, { audioBuffer, audioFile });
res.json({ text }); res.json({ text });
} catch (error) { } catch (error) {
@@ -297,7 +291,7 @@ class STTService {
try { try {
await fs.unlink(req.file.path); await fs.unlink(req.file.path);
logger.debug('[/speech/stt] Temp. audio upload file deleted'); logger.debug('[/speech/stt] Temp. audio upload file deleted');
} catch (error) { } catch {
logger.debug('[/speech/stt] Temp. audio upload file already deleted'); logger.debug('[/speech/stt] Temp. audio upload file already deleted');
} }
} }
@@ -322,7 +316,7 @@ async function createSTTService() {
*/ */
async function speechToText(req, res) { async function speechToText(req, res) {
const sttService = await createSTTService(); const sttService = await createSTTService();
await sttService.processTextToSpeech(req, res); await sttService.processSpeechToText(req, res);
} }
module.exports = { speechToText }; module.exports = { speechToText };

View File

@@ -1,9 +1,9 @@
const axios = require('axios'); const axios = require('axios');
const { logger } = require('@librechat/data-schemas');
const { genAzureEndpoint } = require('@librechat/api'); const { genAzureEndpoint } = require('@librechat/api');
const { extractEnvVariable, TTSProviders } = require('librechat-data-provider'); const { extractEnvVariable, TTSProviders } = require('librechat-data-provider');
const { getRandomVoiceId, createChunkProcessor, splitTextIntoChunks } = require('./streamAudio'); const { getRandomVoiceId, createChunkProcessor, splitTextIntoChunks } = require('./streamAudio');
const { getCustomConfig } = require('~/server/services/Config'); const { getAppConfig } = require('~/server/services/Config');
const { logger } = require('~/config');
/** /**
* Service class for handling Text-to-Speech (TTS) operations. * Service class for handling Text-to-Speech (TTS) operations.
@@ -32,11 +32,7 @@ class TTSService {
* @throws {Error} If the custom config is not found. * @throws {Error} If the custom config is not found.
*/ */
static async getInstance() { static async getInstance() {
const customConfig = await getCustomConfig(); return new TTSService();
if (!customConfig) {
throw new Error('Custom config not found');
}
return new TTSService(customConfig);
} }
/** /**
@@ -293,10 +289,13 @@ class TTSService {
return res.status(400).send('Missing text in request body'); return res.status(400).send('Missing text in request body');
} }
const appConfig = await getAppConfig({
role: req.user?.role,
});
try { try {
res.setHeader('Content-Type', 'audio/mpeg'); res.setHeader('Content-Type', 'audio/mpeg');
const provider = this.getProvider(); const provider = this.getProvider();
const ttsSchema = this.customConfig.speech.tts[provider]; const ttsSchema = appConfig?.speech?.tts?.[provider];
const voice = await this.getVoice(ttsSchema, requestVoice); const voice = await this.getVoice(ttsSchema, requestVoice);
if (input.length < 4096) { if (input.length < 4096) {

View File

@@ -1,5 +1,5 @@
const { getCustomConfig } = require('~/server/services/Config'); const { logger } = require('@librechat/data-schemas');
const { logger } = require('~/config'); const { getAppConfig } = require('~/server/services/Config');
/** /**
* This function retrieves the speechTab settings from the custom configuration * This function retrieves the speechTab settings from the custom configuration
@@ -15,26 +15,28 @@ const { logger } = require('~/config');
*/ */
async function getCustomConfigSpeech(req, res) { async function getCustomConfigSpeech(req, res) {
try { try {
const customConfig = await getCustomConfig(); const appConfig = await getAppConfig({
role: req.user?.role,
});
if (!customConfig) { if (!appConfig) {
return res.status(200).send({ return res.status(200).send({
message: 'not_found', message: 'not_found',
}); });
} }
const sttExternal = !!customConfig.speech?.stt; const sttExternal = !!appConfig.speech?.stt;
const ttsExternal = !!customConfig.speech?.tts; const ttsExternal = !!appConfig.speech?.tts;
let settings = { let settings = {
sttExternal, sttExternal,
ttsExternal, ttsExternal,
}; };
if (!customConfig.speech?.speechTab) { if (!appConfig.speech?.speechTab) {
return res.status(200).send(settings); return res.status(200).send(settings);
} }
const speechTab = customConfig.speech.speechTab; const speechTab = appConfig.speech.speechTab;
if (speechTab.advancedMode !== undefined) { if (speechTab.advancedMode !== undefined) {
settings.advancedMode = speechTab.advancedMode; settings.advancedMode = speechTab.advancedMode;

View File

@@ -1,5 +1,5 @@
const { TTSProviders } = require('librechat-data-provider'); const { TTSProviders } = require('librechat-data-provider');
const { getCustomConfig } = require('~/server/services/Config'); const { getAppConfig } = require('~/server/services/Config');
const { getProvider } = require('./TTSService'); const { getProvider } = require('./TTSService');
/** /**
@@ -14,13 +14,15 @@ const { getProvider } = require('./TTSService');
*/ */
async function getVoices(req, res) { async function getVoices(req, res) {
try { try {
const customConfig = await getCustomConfig(); const appConfig = await getAppConfig({
role: req.user?.role,
});
if (!customConfig || !customConfig?.speech?.tts) { if (!appConfig || !appConfig?.speech?.tts) {
throw new Error('Configuration or TTS schema is missing'); throw new Error('Configuration or TTS schema is missing');
} }
const ttsSchema = customConfig?.speech?.tts; const ttsSchema = appConfig?.speech?.tts;
const provider = await getProvider(ttsSchema); const provider = await getProvider(ttsSchema);
let voices; let voices;

View File

@@ -3,6 +3,7 @@ const path = require('path');
const sharp = require('sharp'); const sharp = require('sharp');
const { logger } = require('@librechat/data-schemas'); const { logger } = require('@librechat/data-schemas');
const { resizeImageBuffer } = require('../images/resize'); const { resizeImageBuffer } = require('../images/resize');
const { getAppConfig } = require('~/server/services/Config');
const { updateUser, updateFile } = require('~/models'); const { updateUser, updateFile } = require('~/models');
const { saveBufferToAzure } = require('./crud'); const { saveBufferToAzure } = require('./crud');
@@ -30,6 +31,7 @@ async function uploadImageToAzure({
containerName, containerName,
}) { }) {
try { try {
const appConfig = await getAppConfig({ role: req.user?.role });
const inputFilePath = file.path; const inputFilePath = file.path;
const inputBuffer = await fs.promises.readFile(inputFilePath); const inputBuffer = await fs.promises.readFile(inputFilePath);
const { const {
@@ -41,12 +43,12 @@ async function uploadImageToAzure({
const userId = req.user.id; const userId = req.user.id;
let webPBuffer; let webPBuffer;
let fileName = `${file_id}__${path.basename(inputFilePath)}`; let fileName = `${file_id}__${path.basename(inputFilePath)}`;
const targetExtension = `.${req.app.locals.imageOutputType}`; const targetExtension = `.${appConfig.imageOutputType}`;
if (extension.toLowerCase() === targetExtension) { if (extension.toLowerCase() === targetExtension) {
webPBuffer = resizedBuffer; webPBuffer = resizedBuffer;
} else { } else {
webPBuffer = await sharp(resizedBuffer).toFormat(req.app.locals.imageOutputType).toBuffer(); webPBuffer = await sharp(resizedBuffer).toFormat(appConfig.imageOutputType).toBuffer();
const extRegExp = new RegExp(path.extname(fileName) + '$'); const extRegExp = new RegExp(path.extname(fileName) + '$');
fileName = fileName.replace(extRegExp, targetExtension); fileName = fileName.replace(extRegExp, targetExtension);
if (!path.extname(fileName)) { if (!path.extname(fileName)) {

View File

@@ -1,9 +1,15 @@
const { nanoid } = require('nanoid'); const { nanoid } = require('nanoid');
const { checkAccess } = require('@librechat/api'); const { checkAccess } = require('@librechat/api');
const { Tools, PermissionTypes, Permissions } = require('librechat-data-provider'); const { logger } = require('@librechat/data-schemas');
const { getCustomConfig } = require('~/server/services/Config/getCustomConfig'); const {
Tools,
Permissions,
FileSources,
EModelEndpoint,
PermissionTypes,
} = require('librechat-data-provider');
const { getAppConfig } = require('~/server/services/Config/app');
const { getRoleByName } = require('~/models/Role'); const { getRoleByName } = require('~/models/Role');
const { logger } = require('~/config');
const { Files } = require('~/models'); const { Files } = require('~/models');
/** /**
@@ -44,10 +50,12 @@ async function processFileCitations({ user, toolArtifact, toolCallId, metadata }
} }
} }
const customConfig = await getCustomConfig(); const appConfig = await getAppConfig({ role: user?.role });
const maxCitations = customConfig?.endpoints?.agents?.maxCitations ?? 30; const maxCitations = appConfig.endpoints?.[EModelEndpoint.agents]?.maxCitations ?? 30;
const maxCitationsPerFile = customConfig?.endpoints?.agents?.maxCitationsPerFile ?? 5; const maxCitationsPerFile =
const minRelevanceScore = customConfig?.endpoints?.agents?.minRelevanceScore ?? 0.45; appConfig.endpoints?.[EModelEndpoint.agents]?.maxCitationsPerFile ?? 5;
const minRelevanceScore =
appConfig.endpoints?.[EModelEndpoint.agents]?.minRelevanceScore ?? 0.45;
const sources = toolArtifact[Tools.file_search].sources || []; const sources = toolArtifact[Tools.file_search].sources || [];
const filteredSources = sources.filter((source) => source.relevance >= minRelevanceScore); const filteredSources = sources.filter((source) => source.relevance >= minRelevanceScore);
@@ -59,7 +67,7 @@ async function processFileCitations({ user, toolArtifact, toolCallId, metadata }
} }
const selectedSources = applyCitationLimits(filteredSources, maxCitations, maxCitationsPerFile); const selectedSources = applyCitationLimits(filteredSources, maxCitations, maxCitationsPerFile);
const enhancedSources = await enhanceSourcesWithMetadata(selectedSources, customConfig); const enhancedSources = await enhanceSourcesWithMetadata(selectedSources, appConfig);
if (enhancedSources.length > 0) { if (enhancedSources.length > 0) {
const fileSearchAttachment = { const fileSearchAttachment = {
@@ -110,10 +118,10 @@ function applyCitationLimits(sources, maxCitations, maxCitationsPerFile) {
/** /**
* Enhance sources with file metadata from database * Enhance sources with file metadata from database
* @param {Array} sources - Selected sources * @param {Array} sources - Selected sources
* @param {Object} customConfig - Custom configuration * @param {AppConfig} appConfig - Custom configuration
* @returns {Promise<Array>} Enhanced sources * @returns {Promise<Array>} Enhanced sources
*/ */
async function enhanceSourcesWithMetadata(sources, customConfig) { async function enhanceSourcesWithMetadata(sources, appConfig) {
const fileIds = [...new Set(sources.map((source) => source.fileId))]; const fileIds = [...new Set(sources.map((source) => source.fileId))];
let fileMetadataMap = {}; let fileMetadataMap = {};
@@ -129,7 +137,7 @@ async function enhanceSourcesWithMetadata(sources, customConfig) {
return sources.map((source) => { return sources.map((source) => {
const fileRecord = fileMetadataMap[source.fileId] || {}; const fileRecord = fileMetadataMap[source.fileId] || {};
const configuredStorageType = fileRecord.source || customConfig?.fileStrategy || 'local'; const configuredStorageType = fileRecord.source || appConfig?.fileStrategy || FileSources.local;
return { return {
...source, ...source,

View File

@@ -43,8 +43,7 @@ async function getCodeOutputDownloadStream(fileIdentifier, apiKey) {
/** /**
* Uploads a file to the Code Environment server. * Uploads a file to the Code Environment server.
* @param {Object} params - The params object. * @param {Object} params - The params object.
* @param {ServerRequest} params.req - The request object from Express. It should have a `user` property with an `id` * @param {ServerRequest} params.req - The request object from Express. It should have a `user` property with an `id` representing the user
* representing the user, and an `app.locals.paths` object with an `uploads` path.
* @param {import('fs').ReadStream | import('stream').Readable} params.stream - The read stream for the file. * @param {import('fs').ReadStream | import('stream').Readable} params.stream - The read stream for the file.
* @param {string} params.filename - The name of the file. * @param {string} params.filename - The name of the file.
* @param {string} params.apiKey - The API key for authentication. * @param {string} params.apiKey - The API key for authentication.

View File

@@ -15,6 +15,7 @@ const { filterFilesByAgentAccess } = require('~/server/services/Files/permission
const { getStrategyFunctions } = require('~/server/services/Files/strategies'); const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { convertImage } = require('~/server/services/Files/images/convert'); const { convertImage } = require('~/server/services/Files/images/convert');
const { createFile, getFiles, updateFile } = require('~/models/File'); const { createFile, getFiles, updateFile } = require('~/models/File');
const { getAppConfig } = require('~/server/services/Config');
/** /**
* Process OpenAI image files, convert to target format, save and return file metadata. * Process OpenAI image files, convert to target format, save and return file metadata.
@@ -38,6 +39,7 @@ const processCodeOutput = async ({
messageId, messageId,
session_id, session_id,
}) => { }) => {
const appConfig = await getAppConfig({ role: req.user?.role });
const currentDate = new Date(); const currentDate = new Date();
const baseURL = getCodeBaseURL(); const baseURL = getCodeBaseURL();
const fileExt = path.extname(name); const fileExt = path.extname(name);
@@ -77,10 +79,10 @@ const processCodeOutput = async ({
filename: name, filename: name,
conversationId, conversationId,
user: req.user.id, user: req.user.id,
type: `image/${req.app.locals.imageOutputType}`, type: `image/${appConfig.imageOutputType}`,
createdAt: formattedDate, createdAt: formattedDate,
updatedAt: formattedDate, updatedAt: formattedDate,
source: req.app.locals.fileStrategy, source: appConfig.fileStrategy,
context: FileContext.execute_code, context: FileContext.execute_code,
}; };
createFile(file, true); createFile(file, true);

View File

@@ -2,6 +2,7 @@ const fs = require('fs');
const path = require('path'); const path = require('path');
const sharp = require('sharp'); const sharp = require('sharp');
const { logger } = require('@librechat/data-schemas'); const { logger } = require('@librechat/data-schemas');
const { getAppConfig } = require('~/server/services/Config');
const { resizeImageBuffer } = require('../images/resize'); const { resizeImageBuffer } = require('../images/resize');
const { updateUser, updateFile } = require('~/models'); const { updateUser, updateFile } = require('~/models');
const { saveBufferToFirebase } = require('./crud'); const { saveBufferToFirebase } = require('./crud');
@@ -11,8 +12,7 @@ const { saveBufferToFirebase } = require('./crud');
* resolution. * resolution.
* *
* @param {Object} params - The params object. * @param {Object} params - The params object.
* @param {Express.Request} params.req - The request object from Express. It should have a `user` property with an `id` * @param {Express.Request} params.req - The request object from Express. It should have a `user` property with an `id` representing the user
* representing the user, and an `app.locals.paths` object with an `imageOutput` path.
* @param {Express.Multer.File} params.file - The file object, which is part of the request. The file object should * @param {Express.Multer.File} params.file - The file object, which is part of the request. The file object should
* have a `path` property that points to the location of the uploaded file. * have a `path` property that points to the location of the uploaded file.
* @param {EModelEndpoint} params.endpoint - The params object. * @param {EModelEndpoint} params.endpoint - The params object.
@@ -26,6 +26,7 @@ const { saveBufferToFirebase } = require('./crud');
* - height: The height of the converted image. * - height: The height of the converted image.
*/ */
async function uploadImageToFirebase({ req, file, file_id, endpoint, resolution = 'high' }) { async function uploadImageToFirebase({ req, file, file_id, endpoint, resolution = 'high' }) {
const appConfig = await getAppConfig({ role: req.user?.role });
const inputFilePath = file.path; const inputFilePath = file.path;
const inputBuffer = await fs.promises.readFile(inputFilePath); const inputBuffer = await fs.promises.readFile(inputFilePath);
const { const {
@@ -38,11 +39,11 @@ async function uploadImageToFirebase({ req, file, file_id, endpoint, resolution
let webPBuffer; let webPBuffer;
let fileName = `${file_id}__${path.basename(inputFilePath)}`; let fileName = `${file_id}__${path.basename(inputFilePath)}`;
const targetExtension = `.${req.app.locals.imageOutputType}`; const targetExtension = `.${appConfig.imageOutputType}`;
if (extension.toLowerCase() === targetExtension) { if (extension.toLowerCase() === targetExtension) {
webPBuffer = resizedBuffer; webPBuffer = resizedBuffer;
} else { } else {
webPBuffer = await sharp(resizedBuffer).toFormat(req.app.locals.imageOutputType).toBuffer(); webPBuffer = await sharp(resizedBuffer).toFormat(appConfig.imageOutputType).toBuffer();
// Replace or append the correct extension // Replace or append the correct extension
const extRegExp = new RegExp(path.extname(fileName) + '$'); const extRegExp = new RegExp(path.extname(fileName) + '$');
fileName = fileName.replace(extRegExp, targetExtension); fileName = fileName.replace(extRegExp, targetExtension);

View File

@@ -4,6 +4,7 @@ const axios = require('axios');
const { logger } = require('@librechat/data-schemas'); const { logger } = require('@librechat/data-schemas');
const { EModelEndpoint } = require('librechat-data-provider'); const { EModelEndpoint } = require('librechat-data-provider');
const { generateShortLivedToken } = require('~/server/services/AuthService'); const { generateShortLivedToken } = require('~/server/services/AuthService');
const { getAppConfig } = require('~/server/services/Config');
const { getBufferMetadata } = require('~/server/utils'); const { getBufferMetadata } = require('~/server/utils');
const paths = require('~/config/paths'); const paths = require('~/config/paths');
@@ -45,7 +46,8 @@ async function saveLocalFile(file, outputPath, outputFilename) {
* @throws Will throw an error if the image saving process fails. * @throws Will throw an error if the image saving process fails.
*/ */
const saveLocalImage = async (req, file, filename) => { const saveLocalImage = async (req, file, filename) => {
const imagePath = req.app.locals.paths.imageOutput; const appConfig = await getAppConfig({ role: req.user?.role });
const imagePath = appConfig.paths.imageOutput;
const outputPath = path.join(imagePath, req.user.id ?? ''); const outputPath = path.join(imagePath, req.user.id ?? '');
await saveLocalFile(file, outputPath, filename); await saveLocalFile(file, outputPath, filename);
}; };
@@ -191,8 +193,7 @@ const unlinkFile = async (filepath) => {
* Deletes a file from the filesystem. This function takes a file object, constructs the full path, and * Deletes a file from the filesystem. This function takes a file object, constructs the full path, and
* verifies the path's validity before deleting the file. If the path is invalid, an error is thrown. * verifies the path's validity before deleting the file. If the path is invalid, an error is thrown.
* *
* @param {Express.Request} req - The request object from Express. It should have an `app.locals.paths` object with * @param {Express.Request} req - The request object from Express.
* a `publicPath` property.
* @param {MongoFile} file - The file object to be deleted. It should have a `filepath` property that is * @param {MongoFile} file - The file object to be deleted. It should have a `filepath` property that is
* a string representing the path of the file relative to the publicPath. * a string representing the path of the file relative to the publicPath.
* *
@@ -201,7 +202,8 @@ const unlinkFile = async (filepath) => {
* file path is invalid or if there is an error in deletion. * file path is invalid or if there is an error in deletion.
*/ */
const deleteLocalFile = async (req, file) => { const deleteLocalFile = async (req, file) => {
const { publicPath, uploads } = req.app.locals.paths; const appConfig = await getAppConfig({ role: req.user?.role });
const { publicPath, uploads } = appConfig.paths;
/** Filepath stripped of query parameters (e.g., ?manual=true) */ /** Filepath stripped of query parameters (e.g., ?manual=true) */
const cleanFilepath = file.filepath.split('?')[0]; const cleanFilepath = file.filepath.split('?')[0];
@@ -256,8 +258,7 @@ const deleteLocalFile = async (req, file) => {
* Uploads a file to the specified upload directory. * Uploads a file to the specified upload directory.
* *
* @param {Object} params - The params object. * @param {Object} params - The params object.
* @param {ServerRequest} params.req - The request object from Express. It should have a `user` property with an `id` * @param {ServerRequest} params.req - The request object from Express. It should have a `user` property with an `id` representing the user
* representing the user, and an `app.locals.paths` object with an `uploads` path.
* @param {Express.Multer.File} params.file - The file object, which is part of the request. The file object should * @param {Express.Multer.File} params.file - The file object, which is part of the request. The file object should
* have a `path` property that points to the location of the uploaded file. * have a `path` property that points to the location of the uploaded file.
* @param {string} params.file_id - The file ID. * @param {string} params.file_id - The file ID.
@@ -268,11 +269,12 @@ const deleteLocalFile = async (req, file) => {
* - bytes: The size of the file in bytes. * - bytes: The size of the file in bytes.
*/ */
async function uploadLocalFile({ req, file, file_id }) { async function uploadLocalFile({ req, file, file_id }) {
const appConfig = await getAppConfig({ role: req.user?.role });
const inputFilePath = file.path; const inputFilePath = file.path;
const inputBuffer = await fs.promises.readFile(inputFilePath); const inputBuffer = await fs.promises.readFile(inputFilePath);
const bytes = Buffer.byteLength(inputBuffer); const bytes = Buffer.byteLength(inputBuffer);
const { uploads } = req.app.locals.paths; const { uploads } = appConfig.paths;
const userPath = path.join(uploads, req.user.id); const userPath = path.join(uploads, req.user.id);
if (!fs.existsSync(userPath)) { if (!fs.existsSync(userPath)) {
@@ -295,8 +297,9 @@ async function uploadLocalFile({ req, file, file_id }) {
* @param {string} filepath - The filepath. * @param {string} filepath - The filepath.
* @returns {ReadableStream} A readable stream of the file. * @returns {ReadableStream} A readable stream of the file.
*/ */
function getLocalFileStream(req, filepath) { async function getLocalFileStream(req, filepath) {
try { try {
const appConfig = await getAppConfig({ role: req.user?.role });
if (filepath.includes('/uploads/')) { if (filepath.includes('/uploads/')) {
const basePath = filepath.split('/uploads/')[1]; const basePath = filepath.split('/uploads/')[1];
@@ -305,8 +308,8 @@ function getLocalFileStream(req, filepath) {
throw new Error(`Invalid file path: ${filepath}`); throw new Error(`Invalid file path: ${filepath}`);
} }
const fullPath = path.join(req.app.locals.paths.uploads, basePath); const fullPath = path.join(appConfig.paths.uploads, basePath);
const uploadsDir = req.app.locals.paths.uploads; const uploadsDir = appConfig.paths.uploads;
const rel = path.relative(uploadsDir, fullPath); const rel = path.relative(uploadsDir, fullPath);
if (rel.startsWith('..') || path.isAbsolute(rel) || rel.includes(`..${path.sep}`)) { if (rel.startsWith('..') || path.isAbsolute(rel) || rel.includes(`..${path.sep}`)) {
@@ -323,8 +326,8 @@ function getLocalFileStream(req, filepath) {
throw new Error(`Invalid file path: ${filepath}`); throw new Error(`Invalid file path: ${filepath}`);
} }
const fullPath = path.join(req.app.locals.paths.imageOutput, basePath); const fullPath = path.join(appConfig.paths.imageOutput, basePath);
const publicDir = req.app.locals.paths.imageOutput; const publicDir = appConfig.paths.imageOutput;
const rel = path.relative(publicDir, fullPath); const rel = path.relative(publicDir, fullPath);
if (rel.startsWith('..') || path.isAbsolute(rel) || rel.includes(`..${path.sep}`)) { if (rel.startsWith('..') || path.isAbsolute(rel) || rel.includes(`..${path.sep}`)) {

View File

@@ -1,6 +1,7 @@
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const sharp = require('sharp'); const sharp = require('sharp');
const { getAppConfig } = require('~/server/services/Config');
const { resizeImageBuffer } = require('../images/resize'); const { resizeImageBuffer } = require('../images/resize');
const { updateUser, updateFile } = require('~/models'); const { updateUser, updateFile } = require('~/models');
@@ -13,8 +14,7 @@ const { updateUser, updateFile } = require('~/models');
* *
* The original image is deleted after conversion. * The original image is deleted after conversion.
* @param {Object} params - The params object. * @param {Object} params - The params object.
* @param {Object} params.req - The request object from Express. It should have a `user` property with an `id` * @param {Object} params.req - The request object from Express. It should have a `user` property with an `id` representing the user
* representing the user, and an `app.locals.paths` object with an `imageOutput` path.
* @param {Express.Multer.File} params.file - The file object, which is part of the request. The file object should * @param {Express.Multer.File} params.file - The file object, which is part of the request. The file object should
* have a `path` property that points to the location of the uploaded file. * have a `path` property that points to the location of the uploaded file.
* @param {string} params.file_id - The file ID. * @param {string} params.file_id - The file ID.
@@ -29,6 +29,7 @@ const { updateUser, updateFile } = require('~/models');
* - height: The height of the converted image. * - height: The height of the converted image.
*/ */
async function uploadLocalImage({ req, file, file_id, endpoint, resolution = 'high' }) { async function uploadLocalImage({ req, file, file_id, endpoint, resolution = 'high' }) {
const appConfig = await getAppConfig({ role: req.user?.role });
const inputFilePath = file.path; const inputFilePath = file.path;
const inputBuffer = await fs.promises.readFile(inputFilePath); const inputBuffer = await fs.promises.readFile(inputFilePath);
const { const {
@@ -38,7 +39,7 @@ async function uploadLocalImage({ req, file, file_id, endpoint, resolution = 'hi
} = await resizeImageBuffer(inputBuffer, resolution, endpoint); } = await resizeImageBuffer(inputBuffer, resolution, endpoint);
const extension = path.extname(inputFilePath); const extension = path.extname(inputFilePath);
const { imageOutput } = req.app.locals.paths; const { imageOutput } = appConfig.paths;
const userPath = path.join(imageOutput, req.user.id); const userPath = path.join(imageOutput, req.user.id);
if (!fs.existsSync(userPath)) { if (!fs.existsSync(userPath)) {
@@ -47,7 +48,7 @@ async function uploadLocalImage({ req, file, file_id, endpoint, resolution = 'hi
const fileName = `${file_id}__${path.basename(inputFilePath)}`; const fileName = `${file_id}__${path.basename(inputFilePath)}`;
const newPath = path.join(userPath, fileName); const newPath = path.join(userPath, fileName);
const targetExtension = `.${req.app.locals.imageOutputType}`; const targetExtension = `.${appConfig.imageOutputType}`;
if (extension.toLowerCase() === targetExtension) { if (extension.toLowerCase() === targetExtension) {
const bytes = Buffer.byteLength(resizedBuffer); const bytes = Buffer.byteLength(resizedBuffer);
@@ -57,7 +58,7 @@ async function uploadLocalImage({ req, file, file_id, endpoint, resolution = 'hi
} }
const outputFilePath = newPath.replace(extension, targetExtension); const outputFilePath = newPath.replace(extension, targetExtension);
const data = await sharp(resizedBuffer).toFormat(req.app.locals.imageOutputType).toBuffer(); const data = await sharp(resizedBuffer).toFormat(appConfig.imageOutputType).toBuffer();
await fs.promises.writeFile(outputFilePath, data); await fs.promises.writeFile(outputFilePath, data);
const bytes = Buffer.byteLength(data); const bytes = Buffer.byteLength(data);
const filepath = path.posix.join('/', 'images', req.user.id, path.basename(outputFilePath)); const filepath = path.posix.join('/', 'images', req.user.id, path.basename(outputFilePath));
@@ -90,7 +91,8 @@ function encodeImage(imagePath) {
* @returns {Promise<[MongoFile, string]>} - A promise that resolves to an array of results from updateFile and encodeImage. * @returns {Promise<[MongoFile, string]>} - A promise that resolves to an array of results from updateFile and encodeImage.
*/ */
async function prepareImagesLocal(req, file) { async function prepareImagesLocal(req, file) {
const { publicPath, imageOutput } = req.app.locals.paths; const appConfig = await getAppConfig({ role: req.user?.role });
const { publicPath, imageOutput } = appConfig.paths;
const userPath = path.join(imageOutput, req.user.id); const userPath = path.join(imageOutput, req.user.id);
if (!fs.existsSync(userPath)) { if (!fs.existsSync(userPath)) {

View File

@@ -7,8 +7,7 @@ const { logger } = require('~/config');
* Uploads a file that can be used across various OpenAI services. * Uploads a file that can be used across various OpenAI services.
* *
* @param {Object} params - The params object. * @param {Object} params - The params object.
* @param {ServerRequest} params.req - The request object from Express. It should have a `user` property with an `id` * @param {ServerRequest} params.req - The request object from Express. It should have a `user` property with an `id` representing the user
* representing the user, and an `app.locals.paths` object with an `imageOutput` path.
* @param {Express.Multer.File} params.file - The file uploaded to the server via multer. * @param {Express.Multer.File} params.file - The file uploaded to the server via multer.
* @param {OpenAIClient} params.openai - The initialized OpenAI client. * @param {OpenAIClient} params.openai - The initialized OpenAI client.
* @returns {Promise<OpenAIFile>} * @returns {Promise<OpenAIFile>}

View File

@@ -2,6 +2,7 @@ const fs = require('fs');
const path = require('path'); const path = require('path');
const sharp = require('sharp'); const sharp = require('sharp');
const { logger } = require('@librechat/data-schemas'); const { logger } = require('@librechat/data-schemas');
const { getAppConfig } = require('~/server/services/Config');
const { resizeImageBuffer } = require('../images/resize'); const { resizeImageBuffer } = require('../images/resize');
const { updateUser, updateFile } = require('~/models'); const { updateUser, updateFile } = require('~/models');
const { saveBufferToS3 } = require('./crud'); const { saveBufferToS3 } = require('./crud');
@@ -12,7 +13,7 @@ const defaultBasePath = 'images';
* Resizes, converts, and uploads an image file to S3. * Resizes, converts, and uploads an image file to S3.
* *
* @param {Object} params * @param {Object} params
* @param {import('express').Request} params.req - Express request (expects user and app.locals.imageOutputType). * @param {import('express').Request} params.req - Express request (expects `user` and `appConfig.imageOutputType`).
* @param {Express.Multer.File} params.file - File object from Multer. * @param {Express.Multer.File} params.file - File object from Multer.
* @param {string} params.file_id - Unique file identifier. * @param {string} params.file_id - Unique file identifier.
* @param {any} params.endpoint - Endpoint identifier used in image processing. * @param {any} params.endpoint - Endpoint identifier used in image processing.
@@ -29,6 +30,7 @@ async function uploadImageToS3({
basePath = defaultBasePath, basePath = defaultBasePath,
}) { }) {
try { try {
const appConfig = await getAppConfig({ role: req.user?.role });
const inputFilePath = file.path; const inputFilePath = file.path;
const inputBuffer = await fs.promises.readFile(inputFilePath); const inputBuffer = await fs.promises.readFile(inputFilePath);
const { const {
@@ -41,14 +43,12 @@ async function uploadImageToS3({
let processedBuffer; let processedBuffer;
let fileName = `${file_id}__${path.basename(inputFilePath)}`; let fileName = `${file_id}__${path.basename(inputFilePath)}`;
const targetExtension = `.${req.app.locals.imageOutputType}`; const targetExtension = `.${appConfig.imageOutputType}`;
if (extension.toLowerCase() === targetExtension) { if (extension.toLowerCase() === targetExtension) {
processedBuffer = resizedBuffer; processedBuffer = resizedBuffer;
} else { } else {
processedBuffer = await sharp(resizedBuffer) processedBuffer = await sharp(resizedBuffer).toFormat(appConfig.imageOutputType).toBuffer();
.toFormat(req.app.locals.imageOutputType)
.toBuffer();
fileName = fileName.replace(new RegExp(path.extname(fileName) + '$'), targetExtension); fileName = fileName.replace(new RegExp(path.extname(fileName) + '$'), targetExtension);
if (!path.extname(fileName)) { if (!path.extname(fileName)) {
fileName += targetExtension; fileName += targetExtension;

View File

@@ -10,8 +10,7 @@ const { generateShortLivedToken } = require('~/server/services/AuthService');
* Deletes a file from the vector database. This function takes a file object, constructs the full path, and * Deletes a file from the vector database. This function takes a file object, constructs the full path, and
* verifies the path's validity before deleting the file. If the path is invalid, an error is thrown. * verifies the path's validity before deleting the file. If the path is invalid, an error is thrown.
* *
* @param {ServerRequest} req - The request object from Express. It should have an `app.locals.paths` object with * @param {ServerRequest} req - The request object from Express.
* a `publicPath` property.
* @param {MongoFile} file - The file object to be deleted. It should have a `filepath` property that is * @param {MongoFile} file - The file object to be deleted. It should have a `filepath` property that is
* a string representing the path of the file relative to the publicPath. * a string representing the path of the file relative to the publicPath.
* *
@@ -54,8 +53,7 @@ const deleteVectors = async (req, file) => {
* Uploads a file to the configured Vector database * Uploads a file to the configured Vector database
* *
* @param {Object} params - The params object. * @param {Object} params - The params object.
* @param {Object} params.req - The request object from Express. It should have a `user` property with an `id` * @param {Object} params.req - The request object from Express. It should have a `user` property with an `id` representing the user
* representing the user, and an `app.locals.paths` object with an `uploads` path.
* @param {Express.Multer.File} params.file - The file object, which is part of the request. The file object should * @param {Express.Multer.File} params.file - The file object, which is part of the request. The file object should
* have a `path` property that points to the location of the uploaded file. * have a `path` property that points to the location of the uploaded file.
* @param {string} params.file_id - The file ID. * @param {string} params.file_id - The file ID.

View File

@@ -1,8 +1,9 @@
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const sharp = require('sharp'); const sharp = require('sharp');
const { resizeImageBuffer } = require('./resize'); const { getAppConfig } = require('~/server/services/Config');
const { getStrategyFunctions } = require('../strategies'); const { getStrategyFunctions } = require('../strategies');
const { resizeImageBuffer } = require('./resize');
const { logger } = require('~/config'); const { logger } = require('~/config');
/** /**
@@ -17,6 +18,7 @@ const { logger } = require('~/config');
*/ */
async function convertImage(req, file, resolution = 'high', basename = '') { async function convertImage(req, file, resolution = 'high', basename = '') {
try { try {
const appConfig = await getAppConfig({ role: req.user?.role });
let inputBuffer; let inputBuffer;
let outputBuffer; let outputBuffer;
let extension = path.extname(file.path ?? basename).toLowerCase(); let extension = path.extname(file.path ?? basename).toLowerCase();
@@ -39,11 +41,11 @@ async function convertImage(req, file, resolution = 'high', basename = '') {
} = await resizeImageBuffer(inputBuffer, resolution); } = await resizeImageBuffer(inputBuffer, resolution);
// Check if the file is already in target format; if it isn't, convert it: // Check if the file is already in target format; if it isn't, convert it:
const targetExtension = `.${req.app.locals.imageOutputType}`; const targetExtension = `.${appConfig.imageOutputType}`;
if (extension === targetExtension) { if (extension === targetExtension) {
outputBuffer = resizedBuffer; outputBuffer = resizedBuffer;
} else { } else {
outputBuffer = await sharp(resizedBuffer).toFormat(req.app.locals.imageOutputType).toBuffer(); outputBuffer = await sharp(resizedBuffer).toFormat(appConfig.imageOutputType).toBuffer();
extension = targetExtension; extension = targetExtension;
} }
@@ -51,7 +53,7 @@ async function convertImage(req, file, resolution = 'high', basename = '') {
const newFileName = const newFileName =
path.basename(file.path ?? basename, path.extname(file.path ?? basename)) + extension; path.basename(file.path ?? basename, path.extname(file.path ?? basename)) + extension;
const { saveBuffer } = getStrategyFunctions(req.app.locals.fileStrategy); const { saveBuffer } = getStrategyFunctions(appConfig.fileStrategy);
const savedFilePath = await saveBuffer({ const savedFilePath = await saveBuffer({
userId: req.user.id, userId: req.user.id,

View File

@@ -1,13 +1,11 @@
const avatar = require('./avatar'); const avatar = require('./avatar');
const convert = require('./convert'); const convert = require('./convert');
const encode = require('./encode'); const encode = require('./encode');
const parse = require('./parse');
const resize = require('./resize'); const resize = require('./resize');
module.exports = { module.exports = {
...convert, ...convert,
...encode, ...encode,
...parse,
...resize, ...resize,
avatar, avatar,
}; };

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