diff --git a/api/server/controllers/ModelController.js b/api/server/controllers/ModelController.js index 1741d3f6b1..4738d45111 100644 --- a/api/server/controllers/ModelController.js +++ b/api/server/controllers/ModelController.js @@ -1,42 +1,12 @@ -const { CacheKeys } = require('librechat-data-provider'); -const { logger, scopedCacheKey } = require('@librechat/data-schemas'); +const { logger } = require('@librechat/data-schemas'); const { loadDefaultModels, loadConfigModels } = require('~/server/services/Config'); -const { getLogStores } = require('~/cache'); -/** - * @param {ServerRequest} req - * @returns {Promise} The models config. - */ -const getModelsConfig = async (req) => { - const cache = getLogStores(CacheKeys.CONFIG_STORE); - const cacheKey = scopedCacheKey(CacheKeys.MODELS_CONFIG); - let modelsConfig = await cache.get(cacheKey); - if (!modelsConfig) { - modelsConfig = await loadModels(req); - } +const getModelsConfig = (req) => loadModels(req); - return modelsConfig; -}; - -/** - * Loads the models from the config. - * @param {ServerRequest} req - The Express request object. - * @returns {Promise} The models config. - */ async function loadModels(req) { - const cache = getLogStores(CacheKeys.CONFIG_STORE); - const cacheKey = scopedCacheKey(CacheKeys.MODELS_CONFIG); - const cachedModelsConfig = await cache.get(cacheKey); - if (cachedModelsConfig) { - return cachedModelsConfig; - } const defaultModelsConfig = await loadDefaultModels(req); const customModelsConfig = await loadConfigModels(req); - - const modelConfig = { ...defaultModelsConfig, ...customModelsConfig }; - - await cache.set(cacheKey, modelConfig); - return modelConfig; + return { ...defaultModelsConfig, ...customModelsConfig }; } async function modelController(req, res) { diff --git a/api/server/controllers/PluginController.js b/api/server/controllers/PluginController.js index 7c47fe4d57..c5d5c5b888 100644 --- a/api/server/controllers/PluginController.js +++ b/api/server/controllers/PluginController.js @@ -1,62 +1,37 @@ -const { CacheKeys } = require('librechat-data-provider'); -const { logger, scopedCacheKey } = require('@librechat/data-schemas'); +const { logger } = require('@librechat/data-schemas'); const { getToolkitKey, checkPluginAuth, filterUniquePlugins } = require('@librechat/api'); const { getCachedTools, setCachedTools } = require('~/server/services/Config'); const { availableTools, toolkits } = require('~/app/clients/tools'); const { getAppConfig } = require('~/server/services/Config'); -const { getLogStores } = require('~/cache'); const getAvailablePluginsController = async (req, res) => { try { - const cache = getLogStores(CacheKeys.TOOL_CACHE); - const pluginsCacheKey = scopedCacheKey(CacheKeys.PLUGINS); - const cachedPlugins = await cache.get(pluginsCacheKey); - if (cachedPlugins) { - res.status(200).json(cachedPlugins); - return; - } - const appConfig = await getAppConfig({ role: req.user?.role, tenantId: req.user?.tenantId }); - /** @type {{ filteredTools: string[], includedTools: string[] }} */ const { filteredTools = [], includedTools = [] } = appConfig; - /** @type {import('@librechat/api').LCManifestTool[]} */ - const pluginManifest = availableTools; - const uniquePlugins = filterUniquePlugins(pluginManifest); - let authenticatedPlugins = []; + const uniquePlugins = filterUniquePlugins(availableTools); + const includeSet = new Set(includedTools); + const filterSet = new Set(filteredTools); + + /** includedTools takes precedence — filteredTools ignored when both are set. */ + const plugins = []; for (const plugin of uniquePlugins) { - authenticatedPlugins.push( - checkPluginAuth(plugin) ? { ...plugin, authenticated: true } : plugin, - ); + if (includeSet.size > 0) { + if (!includeSet.has(plugin.pluginKey)) { + continue; + } + } else if (filterSet.has(plugin.pluginKey)) { + continue; + } + plugins.push(checkPluginAuth(plugin) ? { ...plugin, authenticated: true } : plugin); } - let plugins = authenticatedPlugins; - - if (includedTools.length > 0) { - plugins = plugins.filter((plugin) => includedTools.includes(plugin.pluginKey)); - } else { - plugins = plugins.filter((plugin) => !filteredTools.includes(plugin.pluginKey)); - } - - await cache.set(pluginsCacheKey, plugins); res.status(200).json(plugins); } catch (error) { res.status(500).json({ message: error.message }); } }; -/** - * Retrieves and returns a list of available tools, either from a cache or by reading a plugin manifest file. - * - * This function first attempts to retrieve the list of tools from a cache. If the tools are not found in the cache, - * it reads a plugin manifest file, filters for unique plugins, and determines if each plugin is authenticated. - * Only plugins that are marked as available in the application's local state are included in the final list. - * The resulting list of tools is then cached and sent to the client. - * - * @param {object} req - The request object, containing information about the HTTP request. - * @param {object} res - The response object, used to send back the desired HTTP response. - * @returns {Promise} A promise that resolves when the function has completed. - */ const getAvailableTools = async (req, res) => { try { const userId = req.user?.id; @@ -64,20 +39,10 @@ const getAvailableTools = async (req, res) => { logger.warn('[getAvailableTools] User ID not found in request'); return res.status(401).json({ message: 'Unauthorized' }); } - const cache = getLogStores(CacheKeys.TOOL_CACHE); - const toolsCacheKey = scopedCacheKey(CacheKeys.TOOLS); - const cachedToolsArray = await cache.get(toolsCacheKey); const appConfig = req.config ?? (await getAppConfig({ role: req.user?.role, tenantId: req.user?.tenantId })); - // Return early if we have cached tools - if (cachedToolsArray != null) { - res.status(200).json(cachedToolsArray); - return; - } - - /** @type {Record | null} Get tool definitions to filter which tools are actually available */ let toolDefinitions = await getCachedTools(); if (toolDefinitions == null && appConfig?.availableTools != null) { @@ -86,26 +51,17 @@ const getAvailableTools = async (req, res) => { toolDefinitions = appConfig.availableTools; } - /** @type {import('@librechat/api').LCManifestTool[]} */ - let pluginManifest = availableTools; + const uniquePlugins = filterUniquePlugins(availableTools); + const toolDefKeysList = toolDefinitions ? Object.keys(toolDefinitions) : null; + const toolDefKeys = toolDefKeysList ? new Set(toolDefKeysList) : null; - /** @type {TPlugin[]} Deduplicate and authenticate plugins */ - const uniquePlugins = filterUniquePlugins(pluginManifest); - const authenticatedPlugins = uniquePlugins.map((plugin) => { - if (checkPluginAuth(plugin)) { - return { ...plugin, authenticated: true }; - } else { - return plugin; - } - }); - - /** Filter plugins based on availability */ const toolsOutput = []; - for (const plugin of authenticatedPlugins) { - const isToolDefined = toolDefinitions?.[plugin.pluginKey] !== undefined; + for (const plugin of uniquePlugins) { + const isToolDefined = toolDefKeys?.has(plugin.pluginKey) === true; const isToolkit = plugin.toolkit === true && - Object.keys(toolDefinitions ?? {}).some( + toolDefKeysList != null && + toolDefKeysList.some( (key) => getToolkitKey({ toolkits, toolName: key }) === plugin.pluginKey, ); @@ -113,13 +69,10 @@ const getAvailableTools = async (req, res) => { continue; } - toolsOutput.push(plugin); + toolsOutput.push(checkPluginAuth(plugin) ? { ...plugin, authenticated: true } : plugin); } - const finalTools = filterUniquePlugins(toolsOutput); - await cache.set(toolsCacheKey, finalTools); - - res.status(200).json(finalTools); + res.status(200).json(toolsOutput); } catch (error) { logger.error('[getAvailableTools]', error); res.status(500).json({ message: error.message }); diff --git a/api/server/controllers/PluginController.spec.js b/api/server/controllers/PluginController.spec.js index fdbc2401ce..9288680567 100644 --- a/api/server/controllers/PluginController.spec.js +++ b/api/server/controllers/PluginController.spec.js @@ -1,6 +1,4 @@ -const { CacheKeys } = require('librechat-data-provider'); const { getCachedTools, getAppConfig } = require('~/server/services/Config'); -const { getLogStores } = require('~/cache'); jest.mock('@librechat/data-schemas', () => ({ logger: { @@ -8,7 +6,6 @@ jest.mock('@librechat/data-schemas', () => ({ error: jest.fn(), warn: jest.fn(), }, - scopedCacheKey: jest.fn((key) => key), })); jest.mock('~/server/services/Config', () => ({ @@ -20,22 +17,15 @@ jest.mock('~/server/services/Config', () => ({ setCachedTools: jest.fn(), })); -// loadAndFormatTools mock removed - no longer used in PluginController -// getMCPManager mock removed - no longer used in PluginController - jest.mock('~/app/clients/tools', () => ({ availableTools: [], toolkits: [], })); -jest.mock('~/cache', () => ({ - getLogStores: jest.fn(), -})); - const { getAvailableTools, getAvailablePluginsController } = require('./PluginController'); describe('PluginController', () => { - let mockReq, mockRes, mockCache; + let mockReq, mockRes; beforeEach(() => { jest.clearAllMocks(); @@ -47,17 +37,12 @@ describe('PluginController', () => { }, }; mockRes = { status: jest.fn().mockReturnThis(), json: jest.fn() }; - mockCache = { get: jest.fn(), set: jest.fn() }; - getLogStores.mockReturnValue(mockCache); - // Clear availableTools and toolkits arrays before each test require('~/app/clients/tools').availableTools.length = 0; require('~/app/clients/tools').toolkits.length = 0; - // Reset getCachedTools mock to ensure clean state getCachedTools.mockReset(); - // Reset getAppConfig mock to ensure clean state with default values getAppConfig.mockReset(); getAppConfig.mockResolvedValue({ filteredTools: [], @@ -65,31 +50,8 @@ describe('PluginController', () => { }); }); - describe('cache namespace', () => { - it('getAvailablePluginsController should use TOOL_CACHE namespace', async () => { - mockCache.get.mockResolvedValue([]); - await getAvailablePluginsController(mockReq, mockRes); - expect(getLogStores).toHaveBeenCalledWith(CacheKeys.TOOL_CACHE); - }); - - it('getAvailableTools should use TOOL_CACHE namespace', async () => { - mockCache.get.mockResolvedValue([]); - await getAvailableTools(mockReq, mockRes); - expect(getLogStores).toHaveBeenCalledWith(CacheKeys.TOOL_CACHE); - }); - - it('should NOT use CONFIG_STORE namespace for tool/plugin operations', async () => { - mockCache.get.mockResolvedValue([]); - await getAvailablePluginsController(mockReq, mockRes); - await getAvailableTools(mockReq, mockRes); - const allCalls = getLogStores.mock.calls.flat(); - expect(allCalls).not.toContain(CacheKeys.CONFIG_STORE); - }); - }); - describe('getAvailablePluginsController', () => { it('should use filterUniquePlugins to remove duplicate plugins', async () => { - // Add plugins with duplicates to availableTools const mockPlugins = [ { name: 'Plugin1', pluginKey: 'key1', description: 'First' }, { name: 'Plugin1', pluginKey: 'key1', description: 'First duplicate' }, @@ -98,9 +60,6 @@ describe('PluginController', () => { require('~/app/clients/tools').availableTools.push(...mockPlugins); - mockCache.get.mockResolvedValue(null); - - // Configure getAppConfig to return the expected config getAppConfig.mockResolvedValueOnce({ filteredTools: [], includedTools: [], @@ -110,21 +69,16 @@ describe('PluginController', () => { expect(mockRes.status).toHaveBeenCalledWith(200); const responseData = mockRes.json.mock.calls[0][0]; - // The real filterUniquePlugins should have removed the duplicate expect(responseData).toHaveLength(2); expect(responseData[0].pluginKey).toBe('key1'); expect(responseData[1].pluginKey).toBe('key2'); }); it('should use checkPluginAuth to verify plugin authentication', async () => { - // checkPluginAuth returns false for plugins without authConfig - // so authenticated property won't be added const mockPlugin = { name: 'Plugin1', pluginKey: 'key1', description: 'First' }; require('~/app/clients/tools').availableTools.push(mockPlugin); - mockCache.get.mockResolvedValue(null); - // Configure getAppConfig to return the expected config getAppConfig.mockResolvedValueOnce({ filteredTools: [], includedTools: [], @@ -133,23 +87,9 @@ describe('PluginController', () => { await getAvailablePluginsController(mockReq, mockRes); const responseData = mockRes.json.mock.calls[0][0]; - // The real checkPluginAuth returns false for plugins without authConfig, so authenticated property is not added expect(responseData[0].authenticated).toBeUndefined(); }); - it('should return cached plugins when available', async () => { - const cachedPlugins = [ - { name: 'CachedPlugin', pluginKey: 'cached', description: 'Cached plugin' }, - ]; - - mockCache.get.mockResolvedValue(cachedPlugins); - - await getAvailablePluginsController(mockReq, mockRes); - - // When cache is hit, we return immediately without processing - expect(mockRes.json).toHaveBeenCalledWith(cachedPlugins); - }); - it('should filter plugins based on includedTools', async () => { const mockPlugins = [ { name: 'Plugin1', pluginKey: 'key1', description: 'First' }, @@ -157,9 +97,7 @@ describe('PluginController', () => { ]; require('~/app/clients/tools').availableTools.push(...mockPlugins); - mockCache.get.mockResolvedValue(null); - // Configure getAppConfig to return config with includedTools getAppConfig.mockResolvedValueOnce({ filteredTools: [], includedTools: ['key1'], @@ -171,6 +109,47 @@ describe('PluginController', () => { expect(responseData).toHaveLength(1); expect(responseData[0].pluginKey).toBe('key1'); }); + + it('should exclude plugins in filteredTools', async () => { + const mockPlugins = [ + { name: 'Plugin1', pluginKey: 'key1', description: 'First' }, + { name: 'Plugin2', pluginKey: 'key2', description: 'Second' }, + ]; + + require('~/app/clients/tools').availableTools.push(...mockPlugins); + + getAppConfig.mockResolvedValueOnce({ + filteredTools: ['key2'], + includedTools: [], + }); + + await getAvailablePluginsController(mockReq, mockRes); + + const responseData = mockRes.json.mock.calls[0][0]; + expect(responseData).toHaveLength(1); + expect(responseData[0].pluginKey).toBe('key1'); + }); + + it('should ignore filteredTools when includedTools is set', async () => { + const mockPlugins = [ + { name: 'Plugin1', pluginKey: 'key1', description: 'First' }, + { name: 'Plugin2', pluginKey: 'key2', description: 'Second' }, + { name: 'Plugin3', pluginKey: 'key3', description: 'Third' }, + ]; + + require('~/app/clients/tools').availableTools.push(...mockPlugins); + + getAppConfig.mockResolvedValueOnce({ + includedTools: ['key1', 'key2'], + filteredTools: ['key2'], + }); + + await getAvailablePluginsController(mockReq, mockRes); + + const responseData = mockRes.json.mock.calls[0][0]; + expect(responseData).toHaveLength(2); + expect(responseData.map((p) => p.pluginKey)).toEqual(['key1', 'key2']); + }); }); describe('getAvailableTools', () => { @@ -186,12 +165,11 @@ describe('PluginController', () => { }, }; - const mockCachedPlugins = [ + require('~/app/clients/tools').availableTools.push( { name: 'user-tool', pluginKey: 'user-tool', description: 'Duplicate user tool' }, { name: 'ManifestTool', pluginKey: 'manifest-tool', description: 'Manifest tool' }, - ]; + ); - mockCache.get.mockResolvedValue(mockCachedPlugins); getCachedTools.mockResolvedValueOnce(mockUserTools); mockReq.config = { mcpConfig: null, @@ -203,24 +181,19 @@ describe('PluginController', () => { expect(mockRes.status).toHaveBeenCalledWith(200); const responseData = mockRes.json.mock.calls[0][0]; expect(Array.isArray(responseData)).toBe(true); - // The real filterUniquePlugins should have deduplicated tools with same pluginKey const userToolCount = responseData.filter((tool) => tool.pluginKey === 'user-tool').length; expect(userToolCount).toBe(1); }); it('should use checkPluginAuth to verify authentication status', async () => { - // Add a plugin to availableTools that will be checked const mockPlugin = { name: 'Tool1', pluginKey: 'tool1', description: 'Tool 1', - // No authConfig means checkPluginAuth returns false }; require('~/app/clients/tools').availableTools.push(mockPlugin); - mockCache.get.mockResolvedValue(null); - // getCachedTools returns the tool definitions getCachedTools.mockResolvedValueOnce({ tool1: { type: 'function', @@ -243,7 +216,6 @@ describe('PluginController', () => { expect(Array.isArray(responseData)).toBe(true); const tool = responseData.find((t) => t.pluginKey === 'tool1'); expect(tool).toBeDefined(); - // The real checkPluginAuth returns false for plugins without authConfig, so authenticated property is not added expect(tool.authenticated).toBeUndefined(); }); @@ -257,15 +229,12 @@ describe('PluginController', () => { require('~/app/clients/tools').availableTools.push(mockToolkit); - // Mock toolkits to have a mapping require('~/app/clients/tools').toolkits.push({ name: 'Toolkit1', pluginKey: 'toolkit1', tools: ['toolkit1_function'], }); - mockCache.get.mockResolvedValue(null); - // getCachedTools returns the tool definitions getCachedTools.mockResolvedValueOnce({ toolkit1_function: { type: 'function', @@ -293,7 +262,7 @@ describe('PluginController', () => { describe('helper function integration', () => { it('should handle error cases gracefully', async () => { - mockCache.get.mockRejectedValue(new Error('Cache error')); + getCachedTools.mockRejectedValue(new Error('Cache error')); await getAvailableTools(mockReq, mockRes); @@ -303,17 +272,7 @@ describe('PluginController', () => { }); describe('edge cases with undefined/null values', () => { - it('should handle undefined cache gracefully', async () => { - getLogStores.mockReturnValue(undefined); - - await getAvailableTools(mockReq, mockRes); - - expect(mockRes.status).toHaveBeenCalledWith(500); - }); - - it('should handle null cachedTools and cachedUserTools', async () => { - mockCache.get.mockResolvedValue(null); - // getCachedTools returns empty object instead of null + it('should handle null cachedTools', async () => { getCachedTools.mockResolvedValueOnce({}); mockReq.config = { mcpConfig: null, @@ -322,51 +281,40 @@ describe('PluginController', () => { await getAvailableTools(mockReq, mockRes); - // Should handle null values gracefully expect(mockRes.status).toHaveBeenCalledWith(200); expect(mockRes.json).toHaveBeenCalledWith([]); }); it('should handle when getCachedTools returns undefined', async () => { - mockCache.get.mockResolvedValue(null); mockReq.config = { mcpConfig: null, paths: { structuredTools: '/mock/path' }, }; - // Mock getCachedTools to return undefined getCachedTools.mockReset(); getCachedTools.mockResolvedValueOnce(undefined); await getAvailableTools(mockReq, mockRes); - // Should handle undefined values gracefully expect(mockRes.status).toHaveBeenCalledWith(200); expect(mockRes.json).toHaveBeenCalledWith([]); }); it('should handle empty toolDefinitions object', async () => { - mockCache.get.mockResolvedValue(null); - // Reset getCachedTools to ensure clean state getCachedTools.mockReset(); getCachedTools.mockResolvedValue({}); - mockReq.config = {}; // No mcpConfig at all + mockReq.config = {}; - // Ensure no plugins are available require('~/app/clients/tools').availableTools.length = 0; await getAvailableTools(mockReq, mockRes); - // With empty tool definitions, no tools should be in the final output expect(mockRes.json).toHaveBeenCalledWith([]); }); it('should handle undefined filteredTools and includedTools', async () => { mockReq.config = {}; - mockCache.get.mockResolvedValue(null); - // Configure getAppConfig to return config with undefined properties - // The controller will use default values [] for filteredTools and includedTools getAppConfig.mockResolvedValueOnce({}); await getAvailablePluginsController(mockReq, mockRes); @@ -383,13 +331,8 @@ describe('PluginController', () => { toolkit: true, }; - // No need to mock app.locals anymore as it's not used - - // Add the toolkit to availableTools require('~/app/clients/tools').availableTools.push(mockToolkit); - mockCache.get.mockResolvedValue(null); - // getCachedTools returns empty object to avoid null reference error getCachedTools.mockResolvedValueOnce({}); mockReq.config = { mcpConfig: null, @@ -398,43 +341,32 @@ describe('PluginController', () => { await getAvailableTools(mockReq, mockRes); - // Should handle null toolDefinitions gracefully expect(mockRes.status).toHaveBeenCalledWith(200); }); - it('should handle undefined toolDefinitions when checking isToolDefined (traversaal_search bug)', async () => { - // This test reproduces the bug where toolDefinitions is undefined - // and accessing toolDefinitions[plugin.pluginKey] causes a TypeError + it('should handle undefined toolDefinitions when checking isToolDefined', async () => { const mockPlugin = { name: 'Traversaal Search', pluginKey: 'traversaal_search', description: 'Search plugin', }; - // Add the plugin to availableTools require('~/app/clients/tools').availableTools.push(mockPlugin); - mockCache.get.mockResolvedValue(null); - mockReq.config = { mcpConfig: null, paths: { structuredTools: '/mock/path' }, }; - // CRITICAL: getCachedTools returns undefined - // This is what causes the bug when trying to access toolDefinitions[plugin.pluginKey] getCachedTools.mockResolvedValueOnce(undefined); - // This should not throw an error with the optional chaining fix await getAvailableTools(mockReq, mockRes); - // Should handle undefined toolDefinitions gracefully and return empty array expect(mockRes.status).toHaveBeenCalledWith(200); expect(mockRes.json).toHaveBeenCalledWith([]); }); it('should re-initialize tools from appConfig when cache returns null', async () => { - // Setup: Initial state with tools in appConfig const mockAppTools = { tool1: { type: 'function', @@ -454,15 +386,12 @@ describe('PluginController', () => { }, }; - // Add matching plugins to availableTools require('~/app/clients/tools').availableTools.push( { name: 'Tool 1', pluginKey: 'tool1', description: 'Tool 1' }, { name: 'Tool 2', pluginKey: 'tool2', description: 'Tool 2' }, ); - // Simulate cache cleared state (returns null) - mockCache.get.mockResolvedValue(null); - getCachedTools.mockResolvedValueOnce(null); // Global tools (cache cleared) + getCachedTools.mockResolvedValueOnce(null); mockReq.config = { filteredTools: [], @@ -470,15 +399,12 @@ describe('PluginController', () => { availableTools: mockAppTools, }; - // Mock setCachedTools to verify it's called to re-initialize const { setCachedTools } = require('~/server/services/Config'); await getAvailableTools(mockReq, mockRes); - // Should have re-initialized the cache with tools from appConfig expect(setCachedTools).toHaveBeenCalledWith(mockAppTools); - // Should still return tools successfully expect(mockRes.status).toHaveBeenCalledWith(200); const responseData = mockRes.json.mock.calls[0][0]; expect(responseData).toHaveLength(2); @@ -487,29 +413,22 @@ describe('PluginController', () => { }); it('should handle cache clear without appConfig.availableTools gracefully', async () => { - // Setup: appConfig without availableTools getAppConfig.mockResolvedValue({ filteredTools: [], includedTools: [], - // No availableTools property }); - // Clear availableTools array require('~/app/clients/tools').availableTools.length = 0; - // Cache returns null (cleared state) - mockCache.get.mockResolvedValue(null); - getCachedTools.mockResolvedValueOnce(null); // Global tools (cache cleared) + getCachedTools.mockResolvedValueOnce(null); mockReq.config = { filteredTools: [], includedTools: [], - // No availableTools }; await getAvailableTools(mockReq, mockRes); - // Should handle gracefully without crashing expect(mockRes.status).toHaveBeenCalledWith(200); expect(mockRes.json).toHaveBeenCalledWith([]); }); diff --git a/api/server/controllers/agents/filterAuthorizedTools.spec.js b/api/server/controllers/agents/filterAuthorizedTools.spec.js index e215fdc1fc..e6b41aef16 100644 --- a/api/server/controllers/agents/filterAuthorizedTools.spec.js +++ b/api/server/controllers/agents/filterAuthorizedTools.spec.js @@ -22,6 +22,10 @@ jest.mock('~/config', () => ({ })), })); +jest.mock('~/server/services/MCP', () => ({ + resolveConfigServers: jest.fn().mockResolvedValue({}), +})); + jest.mock('~/server/services/Files/strategies', () => ({ getStrategyFunctions: jest.fn(), })); @@ -223,7 +227,27 @@ describe('MCP Tool Authorization', () => { availableTools, }); - expect(mockGetAllServerConfigs).toHaveBeenCalledWith('specific-user-id'); + expect(mockGetAllServerConfigs).toHaveBeenCalledWith('specific-user-id', undefined); + }); + + test('should pass configServers to getAllServerConfigs and allow config-override servers', async () => { + const configServers = { + 'config-override-server': { type: 'sse', url: 'https://override.example.com' }, + }; + mockGetAllServerConfigs.mockResolvedValue({ + 'config-override-server': configServers['config-override-server'], + }); + + const result = await filterAuthorizedTools({ + tools: [`tool${d}config-override-server`, `tool${d}unauthorizedServer`], + userId, + availableTools, + configServers, + }); + + expect(mockGetAllServerConfigs).toHaveBeenCalledWith(userId, configServers); + expect(result).toContain(`tool${d}config-override-server`); + expect(result).not.toContain(`tool${d}unauthorizedServer`); }); test('should only call getAllServerConfigs once even with multiple MCP tools', async () => { diff --git a/api/server/controllers/agents/v1.js b/api/server/controllers/agents/v1.js index 17985f97ce..e365b232e4 100644 --- a/api/server/controllers/agents/v1.js +++ b/api/server/controllers/agents/v1.js @@ -38,6 +38,7 @@ const { resizeAvatar } = require('~/server/services/Files/images/avatar'); const { getFileStrategy } = require('~/server/utils/getFileStrategy'); const { filterFile } = require('~/server/services/Files/process'); const { getCachedTools } = require('~/server/services/Config'); +const { resolveConfigServers } = require('~/server/services/MCP'); const { getMCPServersRegistry } = require('~/config'); const { getLogStores } = require('~/cache'); const db = require('~/models'); @@ -101,9 +102,16 @@ const validateEdgeAgentAccess = async (edges, userId, userRole) => { * @param {string} params.userId - Requesting user ID for MCP server access check * @param {Record} params.availableTools - Global non-MCP tool cache * @param {string[]} [params.existingTools] - Tools already persisted on the agent document + * @param {Record} [params.configServers] - Config-source MCP servers resolved from appConfig overrides * @returns {Promise} Only the authorized subset of tools */ -const filterAuthorizedTools = async ({ tools, userId, availableTools, existingTools }) => { +const filterAuthorizedTools = async ({ + tools, + userId, + availableTools, + existingTools, + configServers, +}) => { const filteredTools = []; let mcpServerConfigs; let registryUnavailable = false; @@ -121,7 +129,8 @@ const filterAuthorizedTools = async ({ tools, userId, availableTools, existingTo if (mcpServerConfigs === undefined) { try { - mcpServerConfigs = (await getMCPServersRegistry().getAllServerConfigs(userId)) ?? {}; + mcpServerConfigs = + (await getMCPServersRegistry().getAllServerConfigs(userId, configServers)) ?? {}; } catch (e) { logger.warn( '[filterAuthorizedTools] MCP registry unavailable, filtering all MCP tools', @@ -192,8 +201,17 @@ const createAgentHandler = async (req, res) => { agentData.author = userId; agentData.tools = []; - const availableTools = (await getCachedTools()) ?? {}; - agentData.tools = await filterAuthorizedTools({ tools, userId, availableTools }); + const hasMCPTools = tools.some((t) => t?.includes(Constants.mcp_delimiter)); + const [availableTools, configServers] = await Promise.all([ + getCachedTools().then((t) => t ?? {}), + hasMCPTools ? resolveConfigServers(req) : Promise.resolve(undefined), + ]); + agentData.tools = await filterAuthorizedTools({ + tools, + userId, + availableTools, + configServers, + }); const agent = await db.createAgent(agentData); @@ -376,11 +394,15 @@ const updateAgentHandler = async (req, res) => { ); if (newMCPTools.length > 0) { - const availableTools = (await getCachedTools()) ?? {}; + const [availableTools, configServers] = await Promise.all([ + getCachedTools().then((t) => t ?? {}), + resolveConfigServers(req), + ]); const approvedNew = await filterAuthorizedTools({ tools: newMCPTools, userId: req.user.id, availableTools, + configServers, }); const rejectedSet = new Set(newMCPTools.filter((t) => !approvedNew.includes(t))); if (rejectedSet.size > 0) { @@ -533,12 +555,16 @@ const duplicateAgentHandler = async (req, res) => { newAgentData.actions = agentActions; if (newAgentData.tools?.length) { - const availableTools = (await getCachedTools()) ?? {}; + const [availableTools, configServers] = await Promise.all([ + getCachedTools().then((t) => t ?? {}), + resolveConfigServers(req), + ]); newAgentData.tools = await filterAuthorizedTools({ tools: newAgentData.tools, userId, availableTools, existingTools: newAgentData.tools, + configServers, }); } @@ -873,12 +899,16 @@ const revertAgentVersionHandler = async (req, res) => { let updatedAgent = await db.revertAgentVersion({ id }, version_index); if (updatedAgent.tools?.length) { - const availableTools = (await getCachedTools()) ?? {}; + const [availableTools, configServers] = await Promise.all([ + getCachedTools().then((t) => t ?? {}), + resolveConfigServers(req), + ]); const filteredTools = await filterAuthorizedTools({ tools: updatedAgent.tools, userId: req.user.id, availableTools, existingTools: updatedAgent.tools, + configServers, }); if (filteredTools.length !== updatedAgent.tools.length) { updatedAgent = await db.updateAgent( diff --git a/api/server/middleware/__tests__/validateModel.spec.js b/api/server/middleware/__tests__/validateModel.spec.js new file mode 100644 index 0000000000..634baeed11 --- /dev/null +++ b/api/server/middleware/__tests__/validateModel.spec.js @@ -0,0 +1,178 @@ +const { ViolationTypes } = require('librechat-data-provider'); + +jest.mock('@librechat/api', () => ({ + handleError: jest.fn(), +})); + +jest.mock('~/server/controllers/ModelController', () => ({ + getModelsConfig: jest.fn(), +})); + +jest.mock('~/server/services/Config', () => ({ + getEndpointsConfig: jest.fn(), +})); + +jest.mock('~/cache', () => ({ + logViolation: jest.fn(), +})); + +const { handleError } = require('@librechat/api'); +const { getModelsConfig } = require('~/server/controllers/ModelController'); +const { getEndpointsConfig } = require('~/server/services/Config'); +const { logViolation } = require('~/cache'); +const validateModel = require('../validateModel'); + +describe('validateModel', () => { + let req, res, next; + + beforeEach(() => { + jest.clearAllMocks(); + req = { body: { model: 'gpt-4o', endpoint: 'openAI' } }; + res = {}; + next = jest.fn(); + getEndpointsConfig.mockResolvedValue({ + openAI: { userProvide: false }, + }); + getModelsConfig.mockResolvedValue({ + openAI: ['gpt-4o', 'gpt-4o-mini'], + }); + }); + + describe('format validation', () => { + it('rejects missing model', async () => { + req.body.model = undefined; + await validateModel(req, res, next); + expect(handleError).toHaveBeenCalledWith(res, { text: 'Model not provided' }); + expect(next).not.toHaveBeenCalled(); + }); + + it('rejects non-string model', async () => { + req.body.model = 12345; + await validateModel(req, res, next); + expect(handleError).toHaveBeenCalledWith(res, { text: 'Model not provided' }); + expect(next).not.toHaveBeenCalled(); + }); + + it('rejects model exceeding 256 chars', async () => { + req.body.model = 'a'.repeat(257); + await validateModel(req, res, next); + expect(handleError).toHaveBeenCalledWith(res, { text: 'Invalid model identifier' }); + }); + + it('rejects model with leading special character', async () => { + req.body.model = '.bad-model'; + await validateModel(req, res, next); + expect(handleError).toHaveBeenCalledWith(res, { text: 'Invalid model identifier' }); + }); + + it('rejects model with script injection', async () => { + req.body.model = ''; + await validateModel(req, res, next); + expect(handleError).toHaveBeenCalledWith(res, { text: 'Invalid model identifier' }); + }); + + it('trims whitespace before validation', async () => { + req.body.model = ' gpt-4o '; + getModelsConfig.mockResolvedValue({ openAI: ['gpt-4o'] }); + await validateModel(req, res, next); + expect(next).toHaveBeenCalled(); + expect(handleError).not.toHaveBeenCalled(); + }); + + it('rejects model with spaces in the middle', async () => { + req.body.model = 'gpt 4o'; + await validateModel(req, res, next); + expect(handleError).toHaveBeenCalledWith(res, { text: 'Invalid model identifier' }); + }); + + it('accepts standard model IDs', async () => { + const validModels = [ + 'gpt-4o', + 'claude-3-5-sonnet-20241022', + 'us.amazon.nova-pro-v1:0', + 'qwen/qwen3.6-plus-preview:free', + 'Meta-Llama-3-8B-Instruct-4bit', + ]; + for (const model of validModels) { + jest.clearAllMocks(); + req.body.model = model; + getEndpointsConfig.mockResolvedValue({ openAI: { userProvide: false } }); + getModelsConfig.mockResolvedValue({ openAI: [model] }); + next.mockClear(); + + await validateModel(req, res, next); + expect(next).toHaveBeenCalled(); + expect(handleError).not.toHaveBeenCalled(); + } + }); + }); + + describe('userProvide early-return', () => { + it('calls next() immediately for userProvide endpoints without checking model list', async () => { + getEndpointsConfig.mockResolvedValue({ + openAI: { userProvide: true }, + }); + req.body.model = 'any-model-from-user-key'; + + await validateModel(req, res, next); + + expect(next).toHaveBeenCalled(); + expect(getModelsConfig).not.toHaveBeenCalled(); + }); + + it('does not call getModelsConfig for userProvide endpoints', async () => { + getEndpointsConfig.mockResolvedValue({ + CustomEndpoint: { userProvide: true }, + }); + req.body = { model: 'custom-model', endpoint: 'CustomEndpoint' }; + + await validateModel(req, res, next); + + expect(getModelsConfig).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalled(); + }); + }); + + describe('system endpoint list validation', () => { + it('rejects a model not in the available list', async () => { + req.body.model = 'not-in-list'; + + await validateModel(req, res, next); + + expect(logViolation).toHaveBeenCalledWith( + req, + res, + ViolationTypes.ILLEGAL_MODEL_REQUEST, + expect.any(Object), + expect.anything(), + ); + expect(handleError).toHaveBeenCalledWith(res, { text: 'Illegal model request' }); + expect(next).not.toHaveBeenCalled(); + }); + + it('accepts a model in the available list', async () => { + req.body.model = 'gpt-4o'; + + await validateModel(req, res, next); + + expect(next).toHaveBeenCalled(); + expect(handleError).not.toHaveBeenCalled(); + }); + + it('rejects when endpoint has no models loaded', async () => { + getModelsConfig.mockResolvedValue({ openAI: undefined }); + + await validateModel(req, res, next); + + expect(handleError).toHaveBeenCalledWith(res, { text: 'Endpoint models not loaded' }); + }); + + it('rejects when modelsConfig is null', async () => { + getModelsConfig.mockResolvedValue(null); + + await validateModel(req, res, next); + + expect(handleError).toHaveBeenCalledWith(res, { text: 'Models not loaded' }); + }); + }); +}); diff --git a/api/server/middleware/validateModel.js b/api/server/middleware/validateModel.js index 40f6e67bfb..71a931f0d1 100644 --- a/api/server/middleware/validateModel.js +++ b/api/server/middleware/validateModel.js @@ -1,7 +1,12 @@ const { handleError } = require('@librechat/api'); const { ViolationTypes } = require('librechat-data-provider'); const { getModelsConfig } = require('~/server/controllers/ModelController'); +const { getEndpointsConfig } = require('~/server/services/Config'); const { logViolation } = require('~/cache'); + +const MAX_MODEL_STRING_LENGTH = 256; +const MODEL_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_.:/@+-]*$/; + /** * Validates the model of the request. * @@ -11,11 +16,27 @@ const { logViolation } = require('~/cache'); * @param {Function} next - The Express next function. */ const validateModel = async (req, res, next) => { - const { model, endpoint } = req.body; - if (!model) { + const { endpoint } = req.body; + const rawModel = req.body.model; + + if (!rawModel || typeof rawModel !== 'string') { return handleError(res, { text: 'Model not provided' }); } + const model = rawModel.trim(); + if (!model || model.length > MAX_MODEL_STRING_LENGTH || !MODEL_PATTERN.test(model)) { + return handleError(res, { text: 'Invalid model identifier' }); + } + + req.body.model = model; + + const endpointsConfig = await getEndpointsConfig(req); + const endpointConfig = endpointsConfig?.[endpoint]; + + if (endpointConfig?.userProvide) { + return next(); + } + const modelsConfig = await getModelsConfig(req); if (!modelsConfig) { diff --git a/api/server/routes/config.js b/api/server/routes/config.js index 8caa180854..ec3612e384 100644 --- a/api/server/routes/config.js +++ b/api/server/routes/config.js @@ -1,10 +1,9 @@ const express = require('express'); const { isEnabled, getBalanceConfig } = require('@librechat/api'); -const { CacheKeys, defaultSocialLogins } = require('librechat-data-provider'); -const { logger, getTenantId, scopedCacheKey } = require('@librechat/data-schemas'); +const { defaultSocialLogins } = require('librechat-data-provider'); +const { logger, getTenantId } = require('@librechat/data-schemas'); const { getLdapConfig } = require('~/server/services/Config/ldap'); const { getAppConfig } = require('~/server/services/Config/app'); -const { getLogStores } = require('~/cache'); const router = express.Router(); const emailLoginEnabled = @@ -21,15 +20,6 @@ const sharePointFilePickerEnabled = isEnabled(process.env.ENABLE_SHAREPOINT_FILE const openidReuseTokens = isEnabled(process.env.OPENID_REUSE_TOKENS); router.get('/', async function (req, res) { - const cache = getLogStores(CacheKeys.CONFIG_STORE); - - const cacheKey = scopedCacheKey(CacheKeys.STARTUP_CONFIG); - const cachedStartupConfig = await cache.get(cacheKey); - if (cachedStartupConfig) { - res.send(cachedStartupConfig); - return; - } - const isBirthday = () => { const today = new Date(); return today.getMonth() === 1 && today.getDate() === 11; @@ -145,7 +135,6 @@ router.get('/', async function (req, res) { payload.customFooter = process.env.CUSTOM_FOOTER; } - await cache.set(cacheKey, payload); return res.status(200).send(payload); } catch (err) { logger.error('Error in startup config', err); diff --git a/api/server/routes/convos.js b/api/server/routes/convos.js index 1964075ed3..ded7d835d7 100644 --- a/api/server/routes/convos.js +++ b/api/server/routes/convos.js @@ -267,7 +267,11 @@ router.post( async (req, res) => { try { /* TODO: optimize to return imported conversations and add manually */ - await importConversations({ filepath: req.file.path, requestUserId: req.user.id }); + await importConversations({ + filepath: req.file.path, + requestUserId: req.user.id, + userRole: req.user.role, + }); res.status(201).json({ message: 'Conversation(s) imported successfully' }); } catch (error) { logger.error('Error processing file', error); diff --git a/api/server/routes/endpoints.js b/api/server/routes/endpoints.js index 794abde0c2..e7ff1c7000 100644 --- a/api/server/routes/endpoints.js +++ b/api/server/routes/endpoints.js @@ -1,7 +1,9 @@ const express = require('express'); +const requireJwtAuth = require('~/server/middleware/requireJwtAuth'); const endpointController = require('~/server/controllers/EndpointController'); const router = express.Router(); -router.get('/', endpointController); +/** Auth required for role/tenant-scoped endpoint config resolution. */ +router.get('/', requireJwtAuth, endpointController); module.exports = router; diff --git a/api/server/services/Config/__tests__/invalidateConfigCaches.spec.js b/api/server/services/Config/__tests__/invalidateConfigCaches.spec.js index 49e94bc081..ddc97042b9 100644 --- a/api/server/services/Config/__tests__/invalidateConfigCaches.spec.js +++ b/api/server/services/Config/__tests__/invalidateConfigCaches.spec.js @@ -1,13 +1,10 @@ // ── Mocks ────────────────────────────────────────────────────────────── -const mockConfigStoreDelete = jest.fn().mockResolvedValue(true); const mockClearAppConfigCache = jest.fn().mockResolvedValue(undefined); const mockClearOverrideCache = jest.fn().mockResolvedValue(undefined); jest.mock('~/cache/getLogStores', () => { - return jest.fn(() => ({ - delete: mockConfigStoreDelete, - })); + return jest.fn(() => ({})); }); jest.mock('~/server/services/start/tools', () => ({ @@ -44,7 +41,6 @@ jest.mock('@librechat/api', () => ({ // ── Tests ────────────────────────────────────────────────────────────── -const { CacheKeys } = require('librechat-data-provider'); const { invalidateConfigCaches } = require('../app'); describe('invalidateConfigCaches', () => { @@ -52,13 +48,13 @@ describe('invalidateConfigCaches', () => { jest.clearAllMocks(); }); - it('clears all four caches', async () => { + it('clears all caches', async () => { await invalidateConfigCaches(); expect(mockClearAppConfigCache).toHaveBeenCalledTimes(1); expect(mockClearOverrideCache).toHaveBeenCalledTimes(1); expect(mockInvalidateCachedTools).toHaveBeenCalledWith({ invalidateGlobal: true }); - expect(mockConfigStoreDelete).toHaveBeenCalledWith(CacheKeys.ENDPOINT_CONFIG); + expect(mockClearMcpConfigCache).toHaveBeenCalledTimes(1); }); it('passes tenantId through to clearOverrideCache', async () => { @@ -69,17 +65,6 @@ describe('invalidateConfigCaches', () => { expect(mockInvalidateCachedTools).toHaveBeenCalledWith({ invalidateGlobal: true }); }); - it('does not throw when CONFIG_STORE.delete fails', async () => { - mockConfigStoreDelete.mockRejectedValueOnce(new Error('store not found')); - - await expect(invalidateConfigCaches()).resolves.not.toThrow(); - - // Other caches should still have been invalidated - expect(mockClearAppConfigCache).toHaveBeenCalledTimes(1); - expect(mockClearOverrideCache).toHaveBeenCalledTimes(1); - expect(mockInvalidateCachedTools).toHaveBeenCalledWith({ invalidateGlobal: true }); - }); - it('all operations run in parallel (not sequentially)', async () => { const order = []; @@ -110,11 +95,11 @@ describe('invalidateConfigCaches', () => { }, 10), ), ); - mockConfigStoreDelete.mockImplementation( + mockClearMcpConfigCache.mockImplementation( () => new Promise((r) => setTimeout(() => { - order.push('endpoint'); + order.push('mcp'); r(); }, 10), ), @@ -122,9 +107,8 @@ describe('invalidateConfigCaches', () => { await invalidateConfigCaches(); - // All four should have been called (parallel execution via Promise.allSettled) expect(order).toHaveLength(4); - expect(new Set(order)).toEqual(new Set(['base', 'override', 'tools', 'endpoint'])); + expect(new Set(order)).toEqual(new Set(['base', 'override', 'tools', 'mcp'])); }); it('resolves even when clearAppConfigCache throws (partial failure)', async () => { @@ -132,7 +116,6 @@ describe('invalidateConfigCaches', () => { await expect(invalidateConfigCaches()).resolves.not.toThrow(); - // Other caches should still have been invalidated despite the failure expect(mockClearOverrideCache).toHaveBeenCalledTimes(1); expect(mockInvalidateCachedTools).toHaveBeenCalledWith({ invalidateGlobal: true }); }); diff --git a/api/server/services/Config/app.js b/api/server/services/Config/app.js index 3256732ec2..7aa913e636 100644 --- a/api/server/services/Config/app.js +++ b/api/server/services/Config/app.js @@ -1,5 +1,5 @@ const { CacheKeys } = require('librechat-data-provider'); -const { AppService, logger, scopedCacheKey } = require('@librechat/data-schemas'); +const { AppService, logger } = require('@librechat/data-schemas'); const { createAppConfigService, clearMcpConfigCache } = require('@librechat/api'); const { setCachedTools, invalidateCachedTools } = require('./getCachedTools'); const { loadAndFormatTools } = require('~/server/services/start/tools'); @@ -29,32 +29,10 @@ const { getAppConfig, clearAppConfigCache, clearOverrideCache } = createAppConfi getUserPrincipals: db.getUserPrincipals, }); -/** - * Deletes ENDPOINT_CONFIG entries from CONFIG_STORE. - * Clears both the tenant-scoped key (if in tenant context) and the - * unscoped base key (populated by unauthenticated /api/endpoints calls). - * Other tenants' scoped keys are NOT actively cleared — they expire - * via TTL. Config mutations in one tenant do not propagate immediately - * to other tenants' endpoint config caches. - */ -async function clearEndpointConfigCache() { - try { - const configStore = getLogStores(CacheKeys.CONFIG_STORE); - const scoped = scopedCacheKey(CacheKeys.ENDPOINT_CONFIG); - const keys = [scoped]; - if (scoped !== CacheKeys.ENDPOINT_CONFIG) { - keys.push(CacheKeys.ENDPOINT_CONFIG); - } - await Promise.all(keys.map((k) => configStore.delete(k))); - } catch { - // CONFIG_STORE or ENDPOINT_CONFIG may not exist — not critical - } -} - /** * Invalidate all config-related caches after an admin config mutation. * Clears the base config, per-principal override caches, tool caches, - * the endpoints config cache, and the MCP config-source server cache. + * and the MCP config-source server cache. * @param {string} [tenantId] - Optional tenant ID to scope override cache clearing. */ async function invalidateConfigCaches(tenantId) { @@ -62,14 +40,12 @@ async function invalidateConfigCaches(tenantId) { clearAppConfigCache(), clearOverrideCache(tenantId), invalidateCachedTools({ invalidateGlobal: true }), - clearEndpointConfigCache(), clearMcpConfigCache(), ]); const labels = [ 'clearAppConfigCache', 'clearOverrideCache', 'invalidateCachedTools', - 'clearEndpointConfigCache', 'clearMcpConfigCache', ]; for (let i = 0; i < results.length; i++) { diff --git a/api/server/services/Config/getEndpointsConfig.js b/api/server/services/Config/getEndpointsConfig.js index cd0230ad4a..d09b45626c 100644 --- a/api/server/services/Config/getEndpointsConfig.js +++ b/api/server/services/Config/getEndpointsConfig.js @@ -1,136 +1,10 @@ -const { scopedCacheKey } = require('@librechat/data-schemas'); -const { loadCustomEndpointsConfig } = require('@librechat/api'); -const { - CacheKeys, - EModelEndpoint, - isAgentsEndpoint, - orderEndpointsConfig, - defaultAgentCapabilities, -} = require('librechat-data-provider'); +const { createEndpointsConfigService } = require('@librechat/api'); const loadDefaultEndpointsConfig = require('./loadDefaultEConfig'); -const getLogStores = require('~/cache/getLogStores'); const { getAppConfig } = require('./app'); -/** - * - * @param {ServerRequest} req - * @returns {Promise} - */ -async function getEndpointsConfig(req) { - const cache = getLogStores(CacheKeys.CONFIG_STORE); - const cacheKey = scopedCacheKey(CacheKeys.ENDPOINT_CONFIG); - const cachedEndpointsConfig = await cache.get(cacheKey); - if (cachedEndpointsConfig) { - if (cachedEndpointsConfig.gptPlugins) { - await cache.delete(cacheKey); - } else { - return cachedEndpointsConfig; - } - } - - const appConfig = - req.config ?? (await getAppConfig({ role: req.user?.role, tenantId: req.user?.tenantId })); - const defaultEndpointsConfig = await loadDefaultEndpointsConfig(appConfig); - const customEndpointsConfig = loadCustomEndpointsConfig(appConfig?.endpoints?.custom); - - /** @type {TEndpointsConfig} */ - const mergedConfig = { - ...defaultEndpointsConfig, - ...customEndpointsConfig, - }; - - if (appConfig.endpoints?.[EModelEndpoint.azureOpenAI]) { - /** @type {Omit} */ - mergedConfig[EModelEndpoint.azureOpenAI] = { - userProvide: false, - }; - } - - // Enable Anthropic endpoint when Vertex AI is configured in YAML - if (appConfig.endpoints?.[EModelEndpoint.anthropic]?.vertexConfig?.enabled) { - /** @type {Omit} */ - mergedConfig[EModelEndpoint.anthropic] = { - userProvide: false, - }; - } - - if (appConfig.endpoints?.[EModelEndpoint.azureOpenAI]?.assistants) { - /** @type {Omit} */ - mergedConfig[EModelEndpoint.azureAssistants] = { - userProvide: false, - }; - } - - if ( - mergedConfig[EModelEndpoint.assistants] && - appConfig?.endpoints?.[EModelEndpoint.assistants] - ) { - const { disableBuilder, retrievalModels, capabilities, version, ..._rest } = - appConfig.endpoints[EModelEndpoint.assistants]; - - mergedConfig[EModelEndpoint.assistants] = { - ...mergedConfig[EModelEndpoint.assistants], - version, - retrievalModels, - disableBuilder, - capabilities, - }; - } - if (mergedConfig[EModelEndpoint.agents] && appConfig?.endpoints?.[EModelEndpoint.agents]) { - const { disableBuilder, capabilities, allowedProviders, ..._rest } = - appConfig.endpoints[EModelEndpoint.agents]; - - mergedConfig[EModelEndpoint.agents] = { - ...mergedConfig[EModelEndpoint.agents], - allowedProviders, - disableBuilder, - capabilities, - }; - } - - if ( - mergedConfig[EModelEndpoint.azureAssistants] && - appConfig?.endpoints?.[EModelEndpoint.azureAssistants] - ) { - const { disableBuilder, retrievalModels, capabilities, version, ..._rest } = - appConfig.endpoints[EModelEndpoint.azureAssistants]; - - mergedConfig[EModelEndpoint.azureAssistants] = { - ...mergedConfig[EModelEndpoint.azureAssistants], - version, - retrievalModels, - disableBuilder, - capabilities, - }; - } - - if (mergedConfig[EModelEndpoint.bedrock] && appConfig?.endpoints?.[EModelEndpoint.bedrock]) { - const { availableRegions } = appConfig.endpoints[EModelEndpoint.bedrock]; - mergedConfig[EModelEndpoint.bedrock] = { - ...mergedConfig[EModelEndpoint.bedrock], - availableRegions, - }; - } - - const endpointsConfig = orderEndpointsConfig(mergedConfig); - - await cache.set(cacheKey, endpointsConfig); - return endpointsConfig; -} - -/** - * @param {ServerRequest} req - * @param {import('librechat-data-provider').AgentCapabilities} capability - * @returns {Promise} - */ -const checkCapability = async (req, capability) => { - const isAgents = isAgentsEndpoint(req.body?.endpointType || req.body?.endpoint); - const endpointsConfig = await getEndpointsConfig(req); - const capabilities = - isAgents || endpointsConfig?.[EModelEndpoint.agents]?.capabilities != null - ? (endpointsConfig?.[EModelEndpoint.agents]?.capabilities ?? []) - : defaultAgentCapabilities; - return capabilities.includes(capability); -}; +const { getEndpointsConfig, checkCapability } = createEndpointsConfigService({ + getAppConfig, + loadDefaultEndpointsConfig, +}); module.exports = { getEndpointsConfig, checkCapability }; diff --git a/api/server/services/Config/loadConfigModels.js b/api/server/services/Config/loadConfigModels.js index b94a719909..93212cd030 100644 --- a/api/server/services/Config/loadConfigModels.js +++ b/api/server/services/Config/loadConfigModels.js @@ -1,117 +1,11 @@ -const { isUserProvided, fetchModels } = require('@librechat/api'); -const { - EModelEndpoint, - extractEnvVariable, - normalizeEndpointName, -} = require('librechat-data-provider'); +const { createLoadConfigModels, fetchModels } = require('@librechat/api'); const { getAppConfig } = require('./app'); +const db = require('~/models'); -/** - * Load config endpoints from the cached configuration object - * @function loadConfigModels - * @param {ServerRequest} req - The Express request object. - */ -async function loadConfigModels(req) { - const appConfig = await getAppConfig({ role: req.user?.role, tenantId: req.user?.tenantId }); - if (!appConfig) { - return {}; - } - const modelsConfig = {}; - const azureConfig = appConfig.endpoints?.[EModelEndpoint.azureOpenAI]; - const { modelNames } = azureConfig ?? {}; - - if (modelNames && azureConfig) { - modelsConfig[EModelEndpoint.azureOpenAI] = modelNames; - } - - if (azureConfig?.assistants && azureConfig.assistantModels) { - modelsConfig[EModelEndpoint.azureAssistants] = azureConfig.assistantModels; - } - - const bedrockConfig = appConfig.endpoints?.[EModelEndpoint.bedrock]; - if (bedrockConfig?.models && Array.isArray(bedrockConfig.models)) { - modelsConfig[EModelEndpoint.bedrock] = bedrockConfig.models; - } - - if (!Array.isArray(appConfig.endpoints?.[EModelEndpoint.custom])) { - return modelsConfig; - } - - const customEndpoints = appConfig.endpoints[EModelEndpoint.custom].filter( - (endpoint) => - endpoint.baseURL && - endpoint.apiKey && - endpoint.name && - endpoint.models && - (endpoint.models.fetch || endpoint.models.default), - ); - - /** - * @type {Record>} - * Map for promises keyed by unique combination of baseURL and apiKey */ - const fetchPromisesMap = {}; - /** - * @type {Record} - * Map to associate unique keys with endpoint names; note: one key may can correspond to multiple endpoints */ - const uniqueKeyToEndpointsMap = {}; - /** - * @type {Record>} - * Map to associate endpoint names to their configurations */ - const endpointsMap = {}; - - for (let i = 0; i < customEndpoints.length; i++) { - const endpoint = customEndpoints[i]; - const { models, name: configName, baseURL, apiKey, headers: endpointHeaders } = endpoint; - const name = normalizeEndpointName(configName); - endpointsMap[name] = endpoint; - - const API_KEY = extractEnvVariable(apiKey); - const BASE_URL = extractEnvVariable(baseURL); - - const uniqueKey = `${BASE_URL}__${API_KEY}`; - - modelsConfig[name] = []; - - if (models.fetch && !isUserProvided(API_KEY) && !isUserProvided(BASE_URL)) { - fetchPromisesMap[uniqueKey] = - fetchPromisesMap[uniqueKey] || - fetchModels({ - name, - apiKey: API_KEY, - baseURL: BASE_URL, - user: req.user.id, - userObject: req.user, - headers: endpointHeaders, - direct: endpoint.directEndpoint, - userIdQuery: models.userIdQuery, - }); - uniqueKeyToEndpointsMap[uniqueKey] = uniqueKeyToEndpointsMap[uniqueKey] || []; - uniqueKeyToEndpointsMap[uniqueKey].push(name); - continue; - } - - if (Array.isArray(models.default)) { - modelsConfig[name] = models.default.map((model) => - typeof model === 'string' ? model : model.name, - ); - } - } - - const fetchedData = await Promise.all(Object.values(fetchPromisesMap)); - const uniqueKeys = Object.keys(fetchPromisesMap); - - for (let i = 0; i < fetchedData.length; i++) { - const currentKey = uniqueKeys[i]; - const modelData = fetchedData[i]; - const associatedNames = uniqueKeyToEndpointsMap[currentKey]; - - for (const name of associatedNames) { - const endpoint = endpointsMap[name]; - modelsConfig[name] = !modelData?.length ? (endpoint.models.default ?? []) : modelData; - } - } - - return modelsConfig; -} +const loadConfigModels = createLoadConfigModels({ + getAppConfig, + getUserKeyValues: db.getUserKeyValues, + fetchModels, +}); module.exports = loadConfigModels; diff --git a/api/server/services/Config/loadConfigModels.spec.js b/api/server/services/Config/loadConfigModels.spec.js index 6ffb8ba522..d3ec0309ae 100644 --- a/api/server/services/Config/loadConfigModels.spec.js +++ b/api/server/services/Config/loadConfigModels.spec.js @@ -7,6 +7,13 @@ jest.mock('@librechat/api', () => ({ fetchModels: jest.fn(), })); jest.mock('./app'); +jest.mock('@librechat/data-schemas', () => ({ + ...jest.requireActual('@librechat/data-schemas'), + logger: { debug: jest.fn(), error: jest.fn(), warn: jest.fn() }, +})); +jest.mock('~/models', () => ({ + getUserKeyValues: jest.fn(), +})); const exampleConfig = { endpoints: { @@ -68,11 +75,11 @@ describe('loadConfigModels', () => { const originalEnv = process.env; beforeEach(() => { - jest.resetAllMocks(); - jest.resetModules(); + jest.clearAllMocks(); + fetchModels.mockReset(); + require('~/models').getUserKeyValues.mockReset(); process.env = { ...originalEnv }; - // Default mock for getAppConfig getAppConfig.mockResolvedValue({}); }); @@ -337,6 +344,168 @@ describe('loadConfigModels', () => { expect(result.FalsyFetchModel).toEqual(['defaultModel1', 'defaultModel2']); }); + describe('user-provided API key model fetching', () => { + it('fetches models using user-provided API key when key is stored', async () => { + const { getUserKeyValues } = require('~/models'); + getUserKeyValues.mockResolvedValueOnce({ + apiKey: 'sk-user-key', + baseURL: 'https://api.x.com/v1', + }); + getAppConfig.mockResolvedValue({ + endpoints: { + custom: [ + { + name: 'UserEndpoint', + apiKey: 'user_provided', + baseURL: 'user_provided', + models: { fetch: true, default: ['fallback-model'] }, + }, + ], + }, + }); + fetchModels.mockResolvedValue(['fetched-model-a', 'fetched-model-b']); + + const result = await loadConfigModels(mockRequest); + + expect(getUserKeyValues).toHaveBeenCalledWith({ userId: 'testUserId', name: 'UserEndpoint' }); + expect(fetchModels).toHaveBeenCalledWith( + expect.objectContaining({ + apiKey: 'sk-user-key', + baseURL: 'https://api.x.com/v1', + skipCache: true, + }), + ); + expect(result.UserEndpoint).toEqual(['fetched-model-a', 'fetched-model-b']); + }); + + it('falls back to defaults when getUserKeyValues returns no apiKey', async () => { + const { getUserKeyValues } = require('~/models'); + getUserKeyValues.mockResolvedValueOnce({ baseURL: 'https://api.x.com/v1' }); + getAppConfig.mockResolvedValue({ + endpoints: { + custom: [ + { + name: 'NoKeyEndpoint', + apiKey: 'user_provided', + baseURL: 'https://api.x.com/v1', + models: { fetch: true, default: ['default-model'] }, + }, + ], + }, + }); + + const result = await loadConfigModels(mockRequest); + + expect(fetchModels).not.toHaveBeenCalled(); + expect(result.NoKeyEndpoint).toEqual(['default-model']); + }); + + it('falls back to defaults and logs warn when getUserKeyValues throws infra error', async () => { + const { getUserKeyValues } = require('~/models'); + const { logger } = require('@librechat/data-schemas'); + getUserKeyValues.mockRejectedValueOnce(new Error('DB connection timeout')); + getAppConfig.mockResolvedValue({ + endpoints: { + custom: [ + { + name: 'ErrorEndpoint', + apiKey: 'user_provided', + baseURL: 'https://api.example.com/v1', + models: { fetch: true, default: ['fallback'] }, + }, + ], + }, + }); + + const result = await loadConfigModels(mockRequest); + + expect(fetchModels).not.toHaveBeenCalled(); + expect(result.ErrorEndpoint).toEqual(['fallback']); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining( + 'Failed to retrieve user key for "ErrorEndpoint": DB connection timeout', + ), + ); + expect(logger.debug).not.toHaveBeenCalledWith(expect.stringContaining('No user key stored')); + }); + + it('logs debug (not warn) for NO_USER_KEY errors', async () => { + const { getUserKeyValues } = require('~/models'); + const { logger } = require('@librechat/data-schemas'); + getUserKeyValues.mockRejectedValueOnce(new Error(JSON.stringify({ type: 'no_user_key' }))); + getAppConfig.mockResolvedValue({ + endpoints: { + custom: [ + { + name: 'MissingKeyEndpoint', + apiKey: 'user_provided', + baseURL: 'https://api.example.com/v1', + models: { fetch: true, default: ['default-model'] }, + }, + ], + }, + }); + + const result = await loadConfigModels(mockRequest); + + expect(result.MissingKeyEndpoint).toEqual(['default-model']); + expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining('No user key stored')); + expect(logger.warn).not.toHaveBeenCalledWith( + expect.stringContaining('Failed to retrieve user key'), + ); + }); + + it('skips user key lookup when req.user.id is undefined', async () => { + const { getUserKeyValues } = require('~/models'); + getAppConfig.mockResolvedValue({ + endpoints: { + custom: [ + { + name: 'NoUserEndpoint', + apiKey: 'user_provided', + baseURL: 'https://api.x.com/v1', + models: { fetch: true, default: ['anon-model'] }, + }, + ], + }, + }); + + const result = await loadConfigModels({ user: {} }); + + expect(getUserKeyValues).not.toHaveBeenCalled(); + expect(result.NoUserEndpoint).toEqual(['anon-model']); + }); + + it('uses stored baseURL only when baseURL is user_provided', async () => { + const { getUserKeyValues } = require('~/models'); + getUserKeyValues.mockResolvedValueOnce({ apiKey: 'sk-key' }); + getAppConfig.mockResolvedValue({ + endpoints: { + custom: [ + { + name: 'KeyOnly', + apiKey: 'user_provided', + baseURL: 'https://fixed-base.com/v1', + models: { fetch: true, default: ['default'] }, + }, + ], + }, + }); + fetchModels.mockResolvedValue(['model-from-fixed-base']); + + const result = await loadConfigModels(mockRequest); + + expect(fetchModels).toHaveBeenCalledWith( + expect.objectContaining({ + apiKey: 'sk-key', + baseURL: 'https://fixed-base.com/v1', + skipCache: true, + }), + ); + expect(result.KeyOnly).toEqual(['model-from-fixed-base']); + }); + }); + it('normalizes Ollama endpoint name to lowercase', async () => { const testCases = [ { diff --git a/api/server/services/Config/mcp.js b/api/server/services/Config/mcp.js index 869c9e66da..fa37e223f5 100644 --- a/api/server/services/Config/mcp.js +++ b/api/server/services/Config/mcp.js @@ -1,100 +1,10 @@ -const { CacheKeys, Constants } = require('librechat-data-provider'); -const { logger, scopedCacheKey } = require('@librechat/data-schemas'); +const { createMCPToolCacheService } = require('@librechat/api'); const { getCachedTools, setCachedTools } = require('./getCachedTools'); -const { getLogStores } = require('~/cache'); -/** - * Updates MCP tools in the cache for a specific server - * @param {Object} params - Parameters for updating MCP tools - * @param {string} params.userId - User ID for user-specific caching - * @param {string} params.serverName - MCP server name - * @param {Array} params.tools - Array of tool objects from MCP server - * @returns {Promise} - */ -async function updateMCPServerTools({ userId, serverName, tools }) { - try { - const serverTools = {}; - const mcpDelimiter = Constants.mcp_delimiter; - - if (tools == null || tools.length === 0) { - logger.debug(`[MCP Cache] No tools to update for server ${serverName} (user: ${userId})`); - return serverTools; - } - - for (const tool of tools) { - const name = `${tool.name}${mcpDelimiter}${serverName}`; - serverTools[name] = { - type: 'function', - ['function']: { - name, - description: tool.description, - parameters: tool.inputSchema, - }, - }; - } - - await setCachedTools(serverTools, { userId, serverName }); - - const cache = getLogStores(CacheKeys.TOOL_CACHE); - await cache.delete(scopedCacheKey(CacheKeys.TOOLS)); - logger.debug( - `[MCP Cache] Updated ${tools.length} tools for server ${serverName} (user: ${userId})`, - ); - return serverTools; - } catch (error) { - logger.error(`[MCP Cache] Failed to update tools for ${serverName} (user: ${userId}):`, error); - throw error; - } -} - -/** - * Merges app-level tools with global tools. - * Only the current ALS-scoped key (base key in system/startup context) is cleared. - * Tenant-scoped TOOLS:tenantId keys are NOT actively invalidated — they expire - * via TTL on the next tenant request. This matches clearEndpointConfigCache behavior. - * @param {import('@librechat/api').LCAvailableTools} appTools - * @returns {Promise} - */ -async function mergeAppTools(appTools) { - try { - const count = Object.keys(appTools).length; - if (!count) { - return; - } - const cachedTools = await getCachedTools(); - const mergedTools = { ...cachedTools, ...appTools }; - await setCachedTools(mergedTools); - const cache = getLogStores(CacheKeys.TOOL_CACHE); - await cache.delete(scopedCacheKey(CacheKeys.TOOLS)); - logger.debug(`Merged ${count} app-level tools`); - } catch (error) { - logger.error('Failed to merge app-level tools:', error); - throw error; - } -} - -/** - * Caches MCP server tools (no longer merges with global) - * @param {object} params - * @param {string} params.userId - User ID for user-specific caching - * @param {string} params.serverName - * @param {import('@librechat/api').LCAvailableTools} params.serverTools - * @returns {Promise} - */ -async function cacheMCPServerTools({ userId, serverName, serverTools }) { - try { - const count = Object.keys(serverTools).length; - if (!count) { - return; - } - // Only cache server-specific tools, no merging with global - await setCachedTools(serverTools, { userId, serverName }); - logger.debug(`Cached ${count} MCP server tools for ${serverName} (user: ${userId})`); - } catch (error) { - logger.error(`Failed to cache MCP server tools for ${serverName} (user: ${userId}):`, error); - throw error; - } -} +const { mergeAppTools, cacheMCPServerTools, updateMCPServerTools } = createMCPToolCacheService({ + getCachedTools, + setCachedTools, +}); module.exports = { mergeAppTools, diff --git a/api/server/utils/import/importConversations.js b/api/server/utils/import/importConversations.js index e56176c609..ad2d743f01 100644 --- a/api/server/utils/import/importConversations.js +++ b/api/server/utils/import/importConversations.js @@ -7,10 +7,10 @@ const maxFileSize = resolveImportMaxFileSize(); /** * Job definition for importing a conversation. - * @param {{ filepath, requestUserId }} job - The job object. + * @param {{ filepath: string, requestUserId: string, userRole?: string }} job */ const importConversations = async (job) => { - const { filepath, requestUserId } = job; + const { filepath, requestUserId, userRole } = job; try { logger.debug(`user: ${requestUserId} | Importing conversation(s) from file...`); @@ -24,7 +24,7 @@ const importConversations = async (job) => { const fileData = await fs.readFile(filepath, 'utf8'); const jsonData = JSON.parse(fileData); const importer = getImporter(jsonData); - await importer(jsonData, requestUserId); + await importer(jsonData, requestUserId, undefined, userRole); logger.debug(`user: ${requestUserId} | Finished importing conversations`); } catch (error) { logger.error(`user: ${requestUserId} | Failed to import conversation: `, error); diff --git a/api/server/utils/import/importers-timestamp.spec.js b/api/server/utils/import/importers-timestamp.spec.js index 09021a9ccd..e12c099abb 100644 --- a/api/server/utils/import/importers-timestamp.spec.js +++ b/api/server/utils/import/importers-timestamp.spec.js @@ -8,17 +8,16 @@ jest.mock('~/models', () => ({ bulkSaveConvos: jest.fn(), bulkSaveMessages: jest.fn(), })); -jest.mock('~/cache/getLogStores'); -const getLogStores = require('~/cache/getLogStores'); -const mockedCacheGet = jest.fn(); -getLogStores.mockImplementation(() => ({ - get: mockedCacheGet, + +const mockGetEndpointsConfig = jest.fn().mockResolvedValue(null); +jest.mock('~/server/services/Config', () => ({ + getEndpointsConfig: (...args) => mockGetEndpointsConfig(...args), })); describe('Import Timestamp Ordering', () => { beforeEach(() => { jest.clearAllMocks(); - mockedCacheGet.mockResolvedValue(null); + mockGetEndpointsConfig.mockResolvedValue(null); }); describe('LibreChat Import - Timestamp Issues', () => { diff --git a/api/server/utils/import/importers.js b/api/server/utils/import/importers.js index f8b3be4dab..7bcca41e04 100644 --- a/api/server/utils/import/importers.js +++ b/api/server/utils/import/importers.js @@ -1,9 +1,9 @@ const { v4: uuidv4 } = require('uuid'); -const { logger, scopedCacheKey } = require('@librechat/data-schemas'); -const { EModelEndpoint, Constants, openAISettings, CacheKeys } = require('librechat-data-provider'); +const { logger, getTenantId } = require('@librechat/data-schemas'); +const { EModelEndpoint, Constants, openAISettings } = require('librechat-data-provider'); +const { getEndpointsConfig } = require('~/server/services/Config'); const { createImportBatchBuilder } = require('./importBatchBuilder'); const { cloneMessagesWithTimestamps } = require('./fork'); -const getLogStores = require('~/cache/getLogStores'); /** * Returns the appropriate importer function based on the provided JSON data. @@ -194,6 +194,7 @@ async function importLibreChatConvo( jsonData, requestUserId, builderFactory = createImportBatchBuilder, + userRole, ) { try { /** @type {ImportBatchBuilder} */ @@ -202,8 +203,9 @@ async function importLibreChatConvo( /* Endpoint configuration */ let endpoint = jsonData.endpoint ?? options.endpoint ?? EModelEndpoint.openAI; - const cache = getLogStores(CacheKeys.CONFIG_STORE); - const endpointsConfig = await cache.get(scopedCacheKey(CacheKeys.ENDPOINT_CONFIG)); + const endpointsConfig = await getEndpointsConfig({ + user: { id: requestUserId, role: userRole, tenantId: getTenantId() }, + }); const endpointConfig = endpointsConfig?.[endpoint]; if (!endpointConfig && endpointsConfig) { endpoint = Object.keys(endpointsConfig)[0]; diff --git a/api/server/utils/import/importers.spec.js b/api/server/utils/import/importers.spec.js index 7984144cbc..6e712881fc 100644 --- a/api/server/utils/import/importers.spec.js +++ b/api/server/utils/import/importers.spec.js @@ -4,12 +4,13 @@ const { EModelEndpoint, Constants, openAISettings } = require('librechat-data-pr const { getImporter, processAssistantMessage } = require('./importers'); const { ImportBatchBuilder } = require('./importBatchBuilder'); const { bulkSaveMessages, bulkSaveConvos: _bulkSaveConvos } = require('~/models'); -const getLogStores = require('~/cache/getLogStores'); -jest.mock('~/cache/getLogStores'); -const mockedCacheGet = jest.fn(); -getLogStores.mockImplementation(() => ({ - get: mockedCacheGet, +const mockGetEndpointsConfig = jest.fn().mockResolvedValue({ + [EModelEndpoint.openAI]: { userProvide: false }, +}); + +jest.mock('~/server/services/Config', () => ({ + getEndpointsConfig: (...args) => mockGetEndpointsConfig(...args), })); // Mock the database methods @@ -758,7 +759,7 @@ describe('importLibreChatConvo', () => { ); it('should import conversation correctly', async () => { - mockedCacheGet.mockResolvedValue({ + mockGetEndpointsConfig.mockResolvedValue({ [EModelEndpoint.openAI]: {}, }); const expectedNumberOfMessages = 6; @@ -784,7 +785,7 @@ describe('importLibreChatConvo', () => { }); it('should import linear, non-recursive thread correctly with correct endpoint', async () => { - mockedCacheGet.mockResolvedValue({ + mockGetEndpointsConfig.mockResolvedValue({ [EModelEndpoint.azureOpenAI]: {}, }); @@ -924,7 +925,7 @@ describe('importLibreChatConvo', () => { }); it('should retain properties from the original conversation as well as new settings', async () => { - mockedCacheGet.mockResolvedValue({ + mockGetEndpointsConfig.mockResolvedValue({ [EModelEndpoint.azureOpenAI]: {}, }); const requestUserId = 'user-123'; diff --git a/packages/api/src/app/service.spec.ts b/packages/api/src/app/service.spec.ts index c410783793..8d168095c7 100644 --- a/packages/api/src/app/service.spec.ts +++ b/packages/api/src/app/service.spec.ts @@ -5,7 +5,6 @@ import { createAppConfigService } from './service'; interface TestConfig extends AppConfig { restricted?: boolean; x?: string; - interface?: { endpointsMenu?: boolean; [key: string]: boolean | undefined }; } /** @@ -35,7 +34,7 @@ function createMockCache(namespace = 'app_config') { function createDeps(overrides = {}) { const cache = createMockCache(); - const baseConfig = { interface: { endpointsMenu: true }, endpoints: ['openAI'] }; + const baseConfig = { interfaceConfig: { endpointsMenu: true }, endpoints: ['openAI'] }; return { loadBaseConfig: jest.fn().mockResolvedValue(baseConfig), @@ -133,9 +132,8 @@ describe('createAppConfigService', () => { const config = await getAppConfig({ role: 'ADMIN' }); - // Test data uses mock fields that don't exist on AppConfig to verify merge behavior const merged = config as TestConfig; - expect(merged.interface?.endpointsMenu).toBe(false); + expect(merged.interfaceConfig?.endpointsMenu).toBe(false); expect(merged.endpoints).toEqual(['openAI']); }); diff --git a/packages/api/src/endpoints/config/endpoints.spec.ts b/packages/api/src/endpoints/config/endpoints.spec.ts new file mode 100644 index 0000000000..504ea6e730 --- /dev/null +++ b/packages/api/src/endpoints/config/endpoints.spec.ts @@ -0,0 +1,240 @@ +import { + AgentCapabilities, + EModelEndpoint, + defaultAgentCapabilities, +} from 'librechat-data-provider'; +import { createEndpointsConfigService } from './endpoints'; +import type { AppConfig } from '@librechat/data-schemas'; +import type { EndpointsConfigDeps } from './endpoints'; +import type { ServerRequest } from '~/types'; + +function appConfig(partial: Record): AppConfig { + return partial as unknown as AppConfig; +} + +function createMockDeps(overrides: Partial = {}): EndpointsConfigDeps { + return { + getAppConfig: jest.fn().mockResolvedValue(appConfig({ endpoints: {} })), + loadDefaultEndpointsConfig: jest.fn().mockResolvedValue({ + [EModelEndpoint.openAI]: { userProvide: false, order: 0 }, + }), + loadCustomEndpointsConfig: jest.fn().mockReturnValue(undefined), + ...overrides, + }; +} + +function fakeReq(overrides: Partial = {}): ServerRequest { + return { user: { id: 'u1', role: 'USER' }, ...overrides } as ServerRequest; +} + +describe('createEndpointsConfigService', () => { + describe('getEndpointsConfig', () => { + it('merges default and custom endpoints', async () => { + const deps = createMockDeps({ + loadDefaultEndpointsConfig: jest.fn().mockResolvedValue({ + [EModelEndpoint.openAI]: { userProvide: false, order: 0 }, + }), + loadCustomEndpointsConfig: jest.fn().mockReturnValue({ + myCustom: { userProvide: true }, + }), + }); + const { getEndpointsConfig } = createEndpointsConfigService(deps); + const result = await getEndpointsConfig(fakeReq()); + + expect(result?.[EModelEndpoint.openAI]).toBeDefined(); + expect(result?.myCustom).toBeDefined(); + }); + + it('adds azureOpenAI when configured', async () => { + const deps = createMockDeps({ + getAppConfig: jest.fn().mockResolvedValue( + appConfig({ + endpoints: { [EModelEndpoint.azureOpenAI]: { modelNames: ['gpt-4'] } }, + }), + ), + }); + const { getEndpointsConfig } = createEndpointsConfigService(deps); + const result = await getEndpointsConfig(fakeReq()); + + expect(result?.[EModelEndpoint.azureOpenAI]).toEqual( + expect.objectContaining({ userProvide: false }), + ); + }); + + it('adds azureAssistants when azure has assistants config', async () => { + const deps = createMockDeps({ + getAppConfig: jest.fn().mockResolvedValue( + appConfig({ + endpoints: { [EModelEndpoint.azureOpenAI]: { assistants: true } }, + }), + ), + }); + const { getEndpointsConfig } = createEndpointsConfigService(deps); + const result = await getEndpointsConfig(fakeReq()); + + expect(result?.[EModelEndpoint.azureAssistants]).toEqual( + expect.objectContaining({ userProvide: false }), + ); + }); + + it('enables anthropic when vertex AI is configured', async () => { + const deps = createMockDeps({ + getAppConfig: jest.fn().mockResolvedValue( + appConfig({ + endpoints: { [EModelEndpoint.anthropic]: { vertexConfig: { enabled: true } } }, + }), + ), + }); + const { getEndpointsConfig } = createEndpointsConfigService(deps); + const result = await getEndpointsConfig(fakeReq()); + + expect(result?.[EModelEndpoint.anthropic]).toEqual( + expect.objectContaining({ userProvide: false }), + ); + }); + + it('merges assistants config with version coercion', async () => { + const deps = createMockDeps({ + loadDefaultEndpointsConfig: jest.fn().mockResolvedValue({ + [EModelEndpoint.assistants]: { userProvide: false, order: 0 }, + }), + getAppConfig: jest.fn().mockResolvedValue( + appConfig({ + endpoints: { + [EModelEndpoint.assistants]: { + disableBuilder: true, + capabilities: [AgentCapabilities.execute_code], + version: 2, + }, + }, + }), + ), + }); + const { getEndpointsConfig } = createEndpointsConfigService(deps); + const result = await getEndpointsConfig(fakeReq()); + const assistants = result?.[EModelEndpoint.assistants]; + + expect(assistants?.version).toBe('2'); + expect(assistants?.disableBuilder).toBe(true); + expect(assistants?.capabilities).toEqual([AgentCapabilities.execute_code]); + }); + + it('merges agents config with allowedProviders', async () => { + const deps = createMockDeps({ + loadDefaultEndpointsConfig: jest.fn().mockResolvedValue({ + [EModelEndpoint.agents]: { userProvide: false, order: 0 }, + }), + getAppConfig: jest.fn().mockResolvedValue( + appConfig({ + endpoints: { + [EModelEndpoint.agents]: { + allowedProviders: ['openAI', 'anthropic'], + capabilities: [AgentCapabilities.execute_code], + }, + }, + }), + ), + }); + const { getEndpointsConfig } = createEndpointsConfigService(deps); + const result = await getEndpointsConfig(fakeReq()); + + expect(result?.[EModelEndpoint.agents]?.allowedProviders).toEqual(['openAI', 'anthropic']); + }); + + it('merges bedrock availableRegions', async () => { + const deps = createMockDeps({ + loadDefaultEndpointsConfig: jest.fn().mockResolvedValue({ + [EModelEndpoint.bedrock]: { userProvide: false, order: 0 }, + }), + getAppConfig: jest.fn().mockResolvedValue( + appConfig({ + endpoints: { + [EModelEndpoint.bedrock]: { availableRegions: ['us-east-1', 'eu-west-1'] }, + }, + }), + ), + }); + const { getEndpointsConfig } = createEndpointsConfigService(deps); + const result = await getEndpointsConfig(fakeReq()); + + expect(result?.[EModelEndpoint.bedrock]?.availableRegions).toEqual([ + 'us-east-1', + 'eu-west-1', + ]); + }); + + it('uses req.config when available instead of calling getAppConfig', async () => { + const mockGetAppConfig = jest.fn(); + const deps = createMockDeps({ getAppConfig: mockGetAppConfig }); + const { getEndpointsConfig } = createEndpointsConfigService(deps); + + await getEndpointsConfig(fakeReq({ config: appConfig({ endpoints: {} }) })); + + expect(mockGetAppConfig).not.toHaveBeenCalled(); + }); + }); + + describe('checkCapability', () => { + it('returns true when agents endpoint has the requested capability', async () => { + const deps = createMockDeps({ + loadDefaultEndpointsConfig: jest.fn().mockResolvedValue({ + [EModelEndpoint.agents]: { userProvide: false, order: 0 }, + }), + getAppConfig: jest.fn().mockResolvedValue( + appConfig({ + endpoints: { + [EModelEndpoint.agents]: { + capabilities: [AgentCapabilities.execute_code], + }, + }, + }), + ), + }); + const { checkCapability } = createEndpointsConfigService(deps); + + const result = await checkCapability( + fakeReq({ body: { endpoint: EModelEndpoint.agents } }), + AgentCapabilities.execute_code, + ); + + expect(result).toBe(true); + }); + + it('returns false when agents endpoint lacks the requested capability', async () => { + const deps = createMockDeps({ + loadDefaultEndpointsConfig: jest.fn().mockResolvedValue({ + [EModelEndpoint.agents]: { userProvide: false, order: 0 }, + }), + getAppConfig: jest.fn().mockResolvedValue( + appConfig({ + endpoints: { + [EModelEndpoint.agents]: { + capabilities: [AgentCapabilities.execute_code], + }, + }, + }), + ), + }); + const { checkCapability } = createEndpointsConfigService(deps); + + const result = await checkCapability( + fakeReq({ body: { endpoint: EModelEndpoint.agents } }), + AgentCapabilities.file_search, + ); + + expect(result).toBe(false); + }); + + it('falls back to defaultAgentCapabilities for non-agents endpoints', async () => { + const deps = createMockDeps(); + const { checkCapability } = createEndpointsConfigService(deps); + + const result = await checkCapability( + fakeReq({ body: { endpoint: EModelEndpoint.openAI } }), + defaultAgentCapabilities[0], + ); + + expect(result).toBe(true); + }); + }); +}); diff --git a/packages/api/src/endpoints/config/endpoints.ts b/packages/api/src/endpoints/config/endpoints.ts new file mode 100644 index 0000000000..1a6b98d195 --- /dev/null +++ b/packages/api/src/endpoints/config/endpoints.ts @@ -0,0 +1,120 @@ +import { + EModelEndpoint, + isAgentsEndpoint, + orderEndpointsConfig, + defaultAgentCapabilities, +} from 'librechat-data-provider'; +import type { AppConfig } from '@librechat/data-schemas'; +import type { AgentCapabilities, TEndpointsConfig, TConfig } from 'librechat-data-provider'; +import type { ServerRequest, TCustomEndpointsConfig } from '~/types'; +import { loadCustomEndpointsConfig as defaultLoadCustomEndpoints } from '~/endpoints/custom'; + +type PartialEndpointEntry = Partial; +type DefaultEndpointsResult = Record; +type MutableEndpointsConfig = Record; + +export interface EndpointsConfigDeps { + getAppConfig: (params: { role?: string | null; tenantId?: string }) => Promise; + loadDefaultEndpointsConfig: (appConfig: AppConfig) => Promise; + loadCustomEndpointsConfig?: (custom: unknown) => TCustomEndpointsConfig | undefined; +} + +export function createEndpointsConfigService(deps: EndpointsConfigDeps) { + const { + getAppConfig, + loadDefaultEndpointsConfig, + loadCustomEndpointsConfig = defaultLoadCustomEndpoints, + } = deps; + + async function getEndpointsConfig(req: ServerRequest): Promise { + const appConfig = + req.config ?? (await getAppConfig({ role: req.user?.role, tenantId: req.user?.tenantId })); + const defaultEndpointsConfig = await loadDefaultEndpointsConfig(appConfig); + const customEndpointsConfig = loadCustomEndpointsConfig(appConfig?.endpoints?.custom); + + const mergedConfig: MutableEndpointsConfig = { + ...defaultEndpointsConfig, + ...customEndpointsConfig, + }; + + if (appConfig.endpoints?.[EModelEndpoint.azureOpenAI]) { + mergedConfig[EModelEndpoint.azureOpenAI] = { userProvide: false }; + } + + if (appConfig.endpoints?.[EModelEndpoint.anthropic]?.vertexConfig?.enabled) { + mergedConfig[EModelEndpoint.anthropic] = { userProvide: false }; + } + + if (appConfig.endpoints?.[EModelEndpoint.azureOpenAI]?.assistants) { + mergedConfig[EModelEndpoint.azureAssistants] = { userProvide: false }; + } + + if ( + mergedConfig[EModelEndpoint.assistants] && + appConfig?.endpoints?.[EModelEndpoint.assistants] + ) { + const { disableBuilder, retrievalModels, capabilities, version } = + appConfig.endpoints[EModelEndpoint.assistants]; + mergedConfig[EModelEndpoint.assistants] = { + ...mergedConfig[EModelEndpoint.assistants], + version: version != null ? String(version) : undefined, + retrievalModels, + disableBuilder, + capabilities, + }; + } + + if (mergedConfig[EModelEndpoint.agents] && appConfig?.endpoints?.[EModelEndpoint.agents]) { + const { disableBuilder, capabilities, allowedProviders } = + appConfig.endpoints[EModelEndpoint.agents]; + mergedConfig[EModelEndpoint.agents] = { + ...mergedConfig[EModelEndpoint.agents], + allowedProviders, + disableBuilder, + capabilities, + }; + } + + if ( + mergedConfig[EModelEndpoint.azureAssistants] && + appConfig?.endpoints?.[EModelEndpoint.azureAssistants] + ) { + const { disableBuilder, retrievalModels, capabilities, version } = + appConfig.endpoints[EModelEndpoint.azureAssistants]; + mergedConfig[EModelEndpoint.azureAssistants] = { + ...mergedConfig[EModelEndpoint.azureAssistants], + version: version != null ? String(version) : undefined, + retrievalModels, + disableBuilder, + capabilities, + }; + } + + if (mergedConfig[EModelEndpoint.bedrock] && appConfig?.endpoints?.[EModelEndpoint.bedrock]) { + const { availableRegions } = appConfig.endpoints[EModelEndpoint.bedrock] as { + availableRegions?: string[]; + }; + mergedConfig[EModelEndpoint.bedrock] = { + ...mergedConfig[EModelEndpoint.bedrock], + availableRegions, + }; + } + + return orderEndpointsConfig(mergedConfig as TEndpointsConfig); + } + + async function checkCapability( + req: ServerRequest, + capability: AgentCapabilities, + ): Promise { + const isAgents = isAgentsEndpoint(req.body?.endpointType || req.body?.endpoint); + const endpointsConfig = await getEndpointsConfig(req); + const capabilities = + isAgents || endpointsConfig?.[EModelEndpoint.agents]?.capabilities != null + ? (endpointsConfig?.[EModelEndpoint.agents]?.capabilities ?? []) + : defaultAgentCapabilities; + return capabilities.includes(capability); + } + + return { getEndpointsConfig, checkCapability }; +} diff --git a/packages/api/src/endpoints/config/index.ts b/packages/api/src/endpoints/config/index.ts new file mode 100644 index 0000000000..4f5afaf927 --- /dev/null +++ b/packages/api/src/endpoints/config/index.ts @@ -0,0 +1,5 @@ +export { createEndpointsConfigService } from './endpoints'; +export { createLoadConfigModels } from './models'; +export * from './providers'; +export type { EndpointsConfigDeps } from './endpoints'; +export type { LoadConfigModelsDeps } from './models'; diff --git a/packages/api/src/endpoints/config/models.ts b/packages/api/src/endpoints/config/models.ts new file mode 100644 index 0000000000..2d87d5d242 --- /dev/null +++ b/packages/api/src/endpoints/config/models.ts @@ -0,0 +1,221 @@ +import { logger } from '@librechat/data-schemas'; +import { + ErrorTypes, + EModelEndpoint, + extractEnvVariable, + normalizeEndpointName, +} from 'librechat-data-provider'; +import type { TModelsConfig, TEndpoint } from 'librechat-data-provider'; +import type { AppConfig } from '@librechat/data-schemas'; +import type { ServerRequest, GetUserKeyValuesFunction, UserKeyValues } from '~/types'; +import type { FetchModelsParams } from '~/endpoints/models'; +import { fetchModels as defaultFetchModels } from '~/endpoints/models'; +import { isUserProvided } from '~/utils'; + +interface ResolvedEndpoint { + name: string; + endpoint: TEndpoint; + apiKey: string; + baseURL: string; + apiKeyIsUserProvided: boolean; + baseURLIsUserProvided: boolean; +} + +export interface LoadConfigModelsDeps { + getAppConfig: (params: { role?: string | null; tenantId?: string }) => Promise; + getUserKeyValues: GetUserKeyValuesFunction; + fetchModels?: (params: FetchModelsParams) => Promise; +} + +export function createLoadConfigModels(deps: LoadConfigModelsDeps) { + const { getAppConfig, getUserKeyValues, fetchModels = defaultFetchModels } = deps; + + return async function loadConfigModels(req: ServerRequest): Promise { + const appConfig = await getAppConfig({ + role: req.user?.role, + tenantId: req.user?.tenantId, + }); + if (!appConfig) { + return {}; + } + + const modelsConfig: TModelsConfig = {}; + const azureConfig = appConfig.endpoints?.[EModelEndpoint.azureOpenAI]; + const { modelNames } = azureConfig ?? {}; + + if (modelNames && azureConfig) { + modelsConfig[EModelEndpoint.azureOpenAI] = modelNames; + } + + if (azureConfig?.assistants && azureConfig.assistantModels) { + modelsConfig[EModelEndpoint.azureAssistants] = azureConfig.assistantModels; + } + + const bedrockConfig = appConfig.endpoints?.[EModelEndpoint.bedrock]; + if (bedrockConfig?.models && Array.isArray(bedrockConfig.models)) { + modelsConfig[EModelEndpoint.bedrock] = bedrockConfig.models; + } + + if (!Array.isArray(appConfig.endpoints?.[EModelEndpoint.custom])) { + return modelsConfig; + } + + const customEndpoints = (appConfig.endpoints[EModelEndpoint.custom] as TEndpoint[]).filter( + (endpoint) => + endpoint.baseURL && + endpoint.apiKey && + endpoint.name && + endpoint.models && + (endpoint.models.fetch || endpoint.models.default), + ); + + const fetchPromisesMap: Record> = {}; + const uniqueKeyToEndpointsMap: Record = {}; + const endpointsMap: Record = {}; + + const resolved: ResolvedEndpoint[] = []; + const userKeyEndpoints: ResolvedEndpoint[] = []; + + for (let i = 0; i < customEndpoints.length; i++) { + const endpoint = customEndpoints[i]; + const { name: configName, baseURL, apiKey } = endpoint; + const name = normalizeEndpointName(configName); + endpointsMap[name] = endpoint; + modelsConfig[name] = []; + + const resolvedApiKey = extractEnvVariable(apiKey); + const resolvedBaseURL = extractEnvVariable(baseURL); + const entry: ResolvedEndpoint = { + name, + endpoint, + apiKey: resolvedApiKey, + baseURL: resolvedBaseURL, + apiKeyIsUserProvided: isUserProvided(resolvedApiKey), + baseURLIsUserProvided: isUserProvided(resolvedBaseURL), + }; + resolved.push(entry); + + if ( + endpoint.models?.fetch && + (entry.apiKeyIsUserProvided || entry.baseURLIsUserProvided) && + req.user?.id + ) { + userKeyEndpoints.push(entry); + } + } + + const userKeyMap = new Map(); + if (userKeyEndpoints.length > 0 && req.user?.id) { + const userId = req.user.id; + const results = await Promise.allSettled( + userKeyEndpoints.map((e) => getUserKeyValues({ userId, name: e.name })), + ); + for (let i = 0; i < userKeyEndpoints.length; i++) { + const settled = results[i]; + if (settled.status === 'fulfilled') { + userKeyMap.set(userKeyEndpoints[i].name, settled.value); + } else { + const msg = + settled.reason instanceof Error ? settled.reason.message : String(settled.reason); + const isKeyNotFound = + msg.includes(ErrorTypes.NO_USER_KEY) || msg.includes(ErrorTypes.INVALID_USER_KEY); + if (isKeyNotFound) { + logger.debug( + `[loadConfigModels] No user key stored for endpoint "${userKeyEndpoints[i].name}"`, + ); + } else { + logger.warn( + `[loadConfigModels] Failed to retrieve user key for "${userKeyEndpoints[i].name}": ${msg}`, + ); + } + userKeyMap.set(userKeyEndpoints[i].name, null); + } + } + } + + for (const { + name, + endpoint, + apiKey: API_KEY, + baseURL: BASE_URL, + apiKeyIsUserProvided, + baseURLIsUserProvided, + } of resolved) { + const { models, headers: endpointHeaders } = endpoint; + const uniqueKey = `${BASE_URL}__${API_KEY}`; + + if (models?.fetch && !apiKeyIsUserProvided && !baseURLIsUserProvided) { + fetchPromisesMap[uniqueKey] = + fetchPromisesMap[uniqueKey] || + fetchModels({ + name, + apiKey: API_KEY, + baseURL: BASE_URL, + user: req.user?.id, + userObject: req.user, + headers: endpointHeaders, + direct: endpoint.directEndpoint, + userIdQuery: models.userIdQuery, + }); + uniqueKeyToEndpointsMap[uniqueKey] = uniqueKeyToEndpointsMap[uniqueKey] || []; + uniqueKeyToEndpointsMap[uniqueKey].push(name); + continue; + } + + if (models?.fetch && userKeyMap.has(name)) { + const userKeyValues = userKeyMap.get(name); + const resolvedApiKey = apiKeyIsUserProvided ? userKeyValues?.apiKey : API_KEY; + const resolvedBaseURL = baseURLIsUserProvided ? userKeyValues?.baseURL : BASE_URL; + + if (resolvedApiKey && resolvedBaseURL) { + const userFetchKey = `user:${req.user?.id}:${name}`; + fetchPromisesMap[userFetchKey] = + fetchPromisesMap[userFetchKey] || + fetchModels({ + name, + apiKey: resolvedApiKey, + baseURL: resolvedBaseURL, + user: req.user?.id, + userObject: req.user, + headers: endpointHeaders, + direct: endpoint.directEndpoint, + userIdQuery: models.userIdQuery, + skipCache: true, + }); + uniqueKeyToEndpointsMap[userFetchKey] = uniqueKeyToEndpointsMap[userFetchKey] || []; + uniqueKeyToEndpointsMap[userFetchKey].push(name); + continue; + } + } + + if (Array.isArray(models?.default)) { + modelsConfig[name] = models.default.map((model) => + typeof model === 'string' ? model : model.name, + ); + } + } + + const settledResults = await Promise.allSettled(Object.values(fetchPromisesMap)); + const uniqueKeys = Object.keys(fetchPromisesMap); + + for (let i = 0; i < settledResults.length; i++) { + const currentKey = uniqueKeys[i]; + const settled = settledResults[i]; + if (settled.status === 'rejected') { + logger.warn(`[loadConfigModels] Model fetch failed for "${currentKey}":`, settled.reason); + } + const modelData = settled.status === 'fulfilled' ? settled.value : []; + const associatedNames = uniqueKeyToEndpointsMap[currentKey]; + + for (const name of associatedNames) { + const endpoint = endpointsMap[name]; + const defaults = (endpoint.models?.default ?? []).map((m) => + typeof m === 'string' ? m : m.name, + ); + modelsConfig[name] = !modelData?.length ? defaults : modelData; + } + } + + return modelsConfig; + }; +} diff --git a/packages/api/src/endpoints/config.ts b/packages/api/src/endpoints/config/providers.ts similarity index 91% rename from packages/api/src/endpoints/config.ts rename to packages/api/src/endpoints/config/providers.ts index 97246fa336..5e5151b548 100644 --- a/packages/api/src/endpoints/config.ts +++ b/packages/api/src/endpoints/config/providers.ts @@ -3,11 +3,11 @@ import { EModelEndpoint } from 'librechat-data-provider'; import type { TEndpoint } from 'librechat-data-provider'; import type { AppConfig } from '@librechat/data-schemas'; import type { BaseInitializeParams, InitializeResultBase } from '~/types'; -import { initializeAnthropic } from './anthropic/initialize'; -import { initializeBedrock } from './bedrock/initialize'; -import { initializeCustom } from './custom/initialize'; -import { initializeGoogle } from './google/initialize'; -import { initializeOpenAI } from './openai/initialize'; +import { initializeAnthropic } from '../anthropic/initialize'; +import { initializeBedrock } from '../bedrock/initialize'; +import { initializeCustom } from '../custom/initialize'; +import { initializeGoogle } from '../google/initialize'; +import { initializeOpenAI } from '../openai/initialize'; import { getCustomEndpointConfig } from '~/app/config'; /** diff --git a/packages/api/src/endpoints/models.spec.ts b/packages/api/src/endpoints/models.spec.ts index 575cc5fef8..2838bee293 100644 --- a/packages/api/src/endpoints/models.spec.ts +++ b/packages/api/src/endpoints/models.spec.ts @@ -1,5 +1,5 @@ import axios from 'axios'; -import { EModelEndpoint, defaultModels } from 'librechat-data-provider'; +import { Time, EModelEndpoint, defaultModels } from 'librechat-data-provider'; import { fetchModels, splitAndTrim, @@ -11,10 +11,12 @@ import { jest.mock('axios'); +const mockCacheGet = jest.fn().mockResolvedValue(undefined); +const mockCacheSet = jest.fn().mockResolvedValue(true); jest.mock('~/cache', () => ({ standardCache: jest.fn().mockImplementation(() => ({ - get: jest.fn().mockResolvedValue(undefined), - set: jest.fn().mockResolvedValue(true), + get: mockCacheGet, + set: mockCacheSet, })), })); @@ -46,6 +48,11 @@ mockedAxios.get.mockResolvedValue({ }, }); +beforeEach(() => { + mockCacheGet.mockReset().mockResolvedValue(undefined); + mockCacheSet.mockReset().mockResolvedValue(true); +}); + describe('fetchModels', () => { it('fetches models successfully from the API', async () => { const models = await fetchModels({ @@ -397,6 +404,37 @@ describe('fetchModels with Ollama specific logic', () => { expect(models).toEqual(['model-1', 'model-2']); expect(mockedAxios.get).toHaveBeenCalledWith('https://api.test.com/models', expect.any(Object)); }); + + it('writes Ollama models to cache with TTL', async () => { + mockCacheGet.mockReset().mockResolvedValue(undefined); + mockCacheSet.mockReset().mockResolvedValue(true); + + await fetchModels({ + apiKey: 'testApiKey', + baseURL: 'https://api.ollama.test.com', + name: 'OllamaAPI', + }); + + expect(mockCacheSet).toHaveBeenCalledWith( + expect.any(String), + ['Ollama-Base', 'Ollama-Advanced'], + Time.TWO_MINUTES, + ); + }); + + it('returns Ollama models from cache without hitting server', async () => { + mockCacheGet.mockReset().mockResolvedValue(['cached-ollama-model']); + mockCacheSet.mockReset().mockResolvedValue(true); + + const models = await fetchModels({ + apiKey: 'testApiKey', + baseURL: 'https://api.ollama.test.com', + name: 'OllamaAPI', + }); + + expect(models).toEqual(['cached-ollama-model']); + expect(mockedAxios.get).not.toHaveBeenCalled(); + }); }); describe('fetchModels URL construction with trailing slashes', () => { @@ -626,3 +664,73 @@ describe('getBedrockModels', () => { expect(models).toEqual(['anthropic.claude-v2', 'ai21.j2-ultra']); }); }); + +describe('fetchModels caching behavior', () => { + beforeEach(() => { + mockCacheGet.mockReset().mockResolvedValue(undefined); + mockCacheSet.mockReset().mockResolvedValue(true); + mockedAxios.get.mockResolvedValue({ + data: { data: [{ id: 'cached-model-1' }, { id: 'cached-model-2' }] }, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('writes fetched models to cache with TTL', async () => { + await fetchModels({ + apiKey: 'key', + baseURL: 'https://api.test.com', + name: 'TestAPI', + }); + + expect(mockCacheSet).toHaveBeenCalledWith( + expect.any(String), + ['cached-model-1', 'cached-model-2'], + Time.TWO_MINUTES, + ); + }); + + it('returns cached result without making HTTP request', async () => { + mockCacheGet.mockResolvedValue(['from-cache']); + + const models = await fetchModels({ + apiKey: 'key', + baseURL: 'https://api.test.com', + name: 'TestAPI', + }); + + expect(models).toEqual(['from-cache']); + expect(mockedAxios.get).not.toHaveBeenCalled(); + expect(mockCacheSet).not.toHaveBeenCalled(); + }); + + it('does not read or write cache when skipCache is true', async () => { + await fetchModels({ + apiKey: 'key', + baseURL: 'https://api.test.com', + name: 'TestAPI', + skipCache: true, + }); + + expect(mockCacheGet).not.toHaveBeenCalled(); + expect(mockCacheSet).not.toHaveBeenCalled(); + }); + + it('does not write to cache when fetch returns empty models', async () => { + mockedAxios.get.mockResolvedValue({ data: { data: [] } }); + + await fetchModels({ + apiKey: 'key', + baseURL: 'https://api.test.com', + name: 'TestAPI', + }); + + expect(mockCacheSet).not.toHaveBeenCalledWith( + expect.any(String), + expect.any(Array), + expect.any(Number), + ); + }); +}); diff --git a/packages/api/src/endpoints/models.ts b/packages/api/src/endpoints/models.ts index 715b806565..cc91c1a4fa 100644 --- a/packages/api/src/endpoints/models.ts +++ b/packages/api/src/endpoints/models.ts @@ -1,7 +1,14 @@ +import crypto from 'crypto'; import axios from 'axios'; import { logger } from '@librechat/data-schemas'; import { HttpsProxyAgent } from 'https-proxy-agent'; -import { CacheKeys, KnownEndpoints, EModelEndpoint, defaultModels } from 'librechat-data-provider'; +import { + Time, + CacheKeys, + KnownEndpoints, + EModelEndpoint, + defaultModels, +} from 'librechat-data-provider'; import type { IUser } from '@librechat/data-schemas'; import { processModelData, @@ -37,6 +44,8 @@ export interface FetchModelsParams { headers?: Record | null; /** Optional user object for header resolution */ userObject?: Partial; + /** Skip MODEL_QUERIES cache (e.g., for user-provided keys) */ + skipCache?: boolean; } /** @@ -104,6 +113,7 @@ export async function fetchModels({ tokenKey, headers, userObject, + skipCache = false, }: FetchModelsParams): Promise { let models: string[] = []; const baseURL = direct ? extractBaseURL(_baseURL ?? '') : _baseURL; @@ -116,13 +126,32 @@ export async function fetchModels({ return models; } + const shouldCache = !skipCache && !(userIdQuery && user); + const cacheKey = shouldCache ? modelsCacheKey(baseURL ?? '', apiKey) : ''; + const modelsCache = shouldCache ? standardCache(CacheKeys.MODEL_QUERIES) : null; + if (modelsCache && cacheKey) { + const cachedModels = await modelsCache.get(cacheKey); + if (cachedModels) { + return cachedModels as string[]; + } + } + if (name && name.toLowerCase().startsWith(KnownEndpoints.ollama)) { + let ollamaModels: string[] | null = null; try { - return await fetchOllamaModels(baseURL ?? '', { headers, user: userObject }); + ollamaModels = await fetchOllamaModels(baseURL ?? '', { headers, user: userObject }); } catch (ollamaError) { - const logMessage = - 'Failed to fetch models from Ollama API. Attempting to fetch via OpenAI-compatible endpoint.'; - logAxiosError({ message: logMessage, error: ollamaError as Error }); + logAxiosError({ + message: + 'Failed to fetch models from Ollama API. Attempting to fetch via OpenAI-compatible endpoint.', + error: ollamaError as Error, + }); + } + if (ollamaModels !== null) { + if (modelsCache && cacheKey && ollamaModels.length > 0) { + await modelsCache.set(cacheKey, ollamaModels, Time.TWO_MINUTES); + } + return ollamaModels; } } @@ -175,9 +204,17 @@ export async function fetchModels({ logAxiosError({ message: logMessage, error: error as Error }); } + if (modelsCache && cacheKey && models.length > 0) { + await modelsCache.set(cacheKey, models, Time.TWO_MINUTES); + } + return models; } +function modelsCacheKey(baseURL: string, apiKey: string): string { + return crypto.createHash('sha256').update(`${baseURL}:${apiKey}`).digest('hex').slice(0, 32); +} + /** Options for fetching OpenAI models */ export interface GetOpenAIModelsOptions { /** User ID for API requests */ @@ -190,6 +227,8 @@ export interface GetOpenAIModelsOptions { openAIApiKey?: string; /** Whether user provides their own API key */ userProvidedOpenAI?: boolean; + /** Skip MODEL_QUERIES cache (e.g., for user-provided keys) */ + skipCache?: boolean; } /** @@ -218,13 +257,6 @@ export async function fetchOpenAIModels( baseURL = extractBaseURL(reverseProxyUrl) ?? openaiBaseURL; } - const modelsCache = standardCache(CacheKeys.MODEL_QUERIES); - - const cachedModels = await modelsCache.get(baseURL); - if (cachedModels) { - return cachedModels as string[]; - } - if (baseURL || opts.azure) { models = await fetchModels({ apiKey: apiKey ?? '', @@ -232,6 +264,7 @@ export async function fetchOpenAIModels( azure: opts.azure, user: opts.user, name: EModelEndpoint.openAI, + skipCache: opts.skipCache, }); } @@ -248,7 +281,6 @@ export async function fetchOpenAIModels( models = otherModels.concat(instructModels); } - await modelsCache.set(baseURL, models); return models; } @@ -293,7 +325,7 @@ export async function getOpenAIModels(opts: GetOpenAIModelsOptions = {}): Promis * @returns Promise resolving to array of model IDs */ export async function fetchAnthropicModels( - opts: { user?: string } = {}, + opts: { user?: string; skipCache?: boolean } = {}, _models: string[] = [], ): Promise { let models = _models.slice() ?? []; @@ -310,13 +342,6 @@ export async function fetchAnthropicModels( return models; } - const modelsCache = standardCache(CacheKeys.MODEL_QUERIES); - - const cachedModels = await modelsCache.get(baseURL); - if (cachedModels) { - return cachedModels as string[]; - } - if (baseURL) { models = await fetchModels({ apiKey, @@ -324,6 +349,7 @@ export async function fetchAnthropicModels( user: opts.user, name: EModelEndpoint.anthropic, tokenKey: EModelEndpoint.anthropic, + skipCache: opts.skipCache, }); } @@ -331,7 +357,6 @@ export async function fetchAnthropicModels( return _models; } - await modelsCache.set(baseURL, models); return models; } diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 7a04b8e74a..d4b6ac9542 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -15,6 +15,7 @@ export * from './mcp/auth'; export * from './mcp/zod'; export * from './mcp/errors'; export * from './mcp/cache'; +export * from './mcp/tools'; /* Utilities */ export * from './mcp/utils'; export * from './utils'; diff --git a/packages/api/src/mcp/tools.spec.ts b/packages/api/src/mcp/tools.spec.ts new file mode 100644 index 0000000000..2a5d201df8 --- /dev/null +++ b/packages/api/src/mcp/tools.spec.ts @@ -0,0 +1,213 @@ +import { Constants } from 'librechat-data-provider'; +import { createMCPToolCacheService } from './tools'; +import type { LCAvailableTools } from './types'; +import type { MCPToolInput, MCPToolCacheDeps } from './tools'; + +function createMockDeps(overrides: Partial = {}): MCPToolCacheDeps { + return { + getCachedTools: jest.fn().mockResolvedValue(null), + setCachedTools: jest.fn().mockResolvedValue(true), + ...overrides, + }; +} + +describe('createMCPToolCacheService', () => { + describe('updateMCPServerTools', () => { + it('returns empty object for null tools', async () => { + const deps = createMockDeps(); + const { updateMCPServerTools } = createMCPToolCacheService(deps); + + const result = await updateMCPServerTools({ userId: 'u1', serverName: 'srv', tools: null }); + + expect(result).toEqual({}); + expect(deps.setCachedTools).not.toHaveBeenCalled(); + }); + + it('returns empty object for empty tools array', async () => { + const deps = createMockDeps(); + const { updateMCPServerTools } = createMCPToolCacheService(deps); + + const result = await updateMCPServerTools({ userId: 'u1', serverName: 'srv', tools: [] }); + + expect(result).toEqual({}); + expect(deps.setCachedTools).not.toHaveBeenCalled(); + }); + + it('constructs tool names with mcp_delimiter and caches them', async () => { + const deps = createMockDeps(); + const { updateMCPServerTools } = createMCPToolCacheService(deps); + const tools: MCPToolInput[] = [ + { + name: 'search', + description: 'Search docs', + inputSchema: { type: 'object', properties: {} }, + }, + ]; + + const result = await updateMCPServerTools({ userId: 'u1', serverName: 'brave', tools }); + + const expectedKey = `search${Constants.mcp_delimiter}brave`; + expect(result[expectedKey]).toBeDefined(); + expect(result[expectedKey].type).toBe('function'); + expect(result[expectedKey]['function'].name).toBe(expectedKey); + expect(result[expectedKey]['function'].description).toBe('Search docs'); + expect(deps.setCachedTools).toHaveBeenCalledWith(result, { + userId: 'u1', + serverName: 'brave', + }); + }); + + it('propagates setCachedTools errors', async () => { + const deps = createMockDeps({ + setCachedTools: jest.fn().mockRejectedValue(new Error('Redis down')), + }); + const { updateMCPServerTools } = createMCPToolCacheService(deps); + const tools: MCPToolInput[] = [{ name: 'tool1' }]; + + await expect( + updateMCPServerTools({ userId: 'u1', serverName: 'srv', tools }), + ).rejects.toThrow('Redis down'); + }); + }); + + describe('mergeAppTools', () => { + it('no-ops when appTools is empty', async () => { + const deps = createMockDeps(); + const { mergeAppTools } = createMCPToolCacheService(deps); + + await mergeAppTools({}); + + expect(deps.getCachedTools).not.toHaveBeenCalled(); + expect(deps.setCachedTools).not.toHaveBeenCalled(); + }); + + it('merges app tools with existing cached tools', async () => { + const existing: LCAvailableTools = { + old: { + type: 'function', + ['function']: { + name: 'old', + description: '', + parameters: { type: 'object', properties: {} }, + }, + }, + }; + const deps = createMockDeps({ getCachedTools: jest.fn().mockResolvedValue(existing) }); + const { mergeAppTools } = createMCPToolCacheService(deps); + const appTools: LCAvailableTools = { + new: { + type: 'function', + ['function']: { + name: 'new', + description: '', + parameters: { type: 'object', properties: {} }, + }, + }, + }; + + await mergeAppTools(appTools); + + expect(deps.setCachedTools).toHaveBeenCalledWith( + expect.objectContaining({ old: existing.old, new: appTools.new }), + ); + }); + + it('handles null cache (cold start) by defaulting to empty', async () => { + const deps = createMockDeps({ getCachedTools: jest.fn().mockResolvedValue(null) }); + const { mergeAppTools } = createMCPToolCacheService(deps); + const appTools: LCAvailableTools = { + tool: { + type: 'function', + ['function']: { + name: 'tool', + description: '', + parameters: { type: 'object', properties: {} }, + }, + }, + }; + + await mergeAppTools(appTools); + + expect(deps.setCachedTools).toHaveBeenCalledWith( + expect.objectContaining({ tool: appTools.tool }), + ); + }); + + it('propagates getCachedTools errors', async () => { + const deps = createMockDeps({ + getCachedTools: jest.fn().mockRejectedValue(new Error('cache read failed')), + }); + const { mergeAppTools } = createMCPToolCacheService(deps); + + await expect( + mergeAppTools({ + t: { + type: 'function', + ['function']: { + name: 't', + description: '', + parameters: { type: 'object', properties: {} }, + }, + }, + }), + ).rejects.toThrow('cache read failed'); + }); + }); + + describe('cacheMCPServerTools', () => { + it('no-ops when serverTools is empty', async () => { + const deps = createMockDeps(); + const { cacheMCPServerTools } = createMCPToolCacheService(deps); + + await cacheMCPServerTools({ userId: 'u1', serverName: 'srv', serverTools: {} }); + + expect(deps.setCachedTools).not.toHaveBeenCalled(); + }); + + it('caches server tools with userId and serverName', async () => { + const deps = createMockDeps(); + const { cacheMCPServerTools } = createMCPToolCacheService(deps); + const serverTools: LCAvailableTools = { + tool: { + type: 'function', + ['function']: { + name: 'tool', + description: '', + parameters: { type: 'object', properties: {} }, + }, + }, + }; + + await cacheMCPServerTools({ userId: 'u1', serverName: 'brave', serverTools }); + + expect(deps.setCachedTools).toHaveBeenCalledWith(serverTools, { + userId: 'u1', + serverName: 'brave', + }); + }); + + it('propagates setCachedTools errors', async () => { + const deps = createMockDeps({ + setCachedTools: jest.fn().mockRejectedValue(new Error('write failed')), + }); + const { cacheMCPServerTools } = createMCPToolCacheService(deps); + + await expect( + cacheMCPServerTools({ + userId: 'u1', + serverName: 'srv', + serverTools: { + t: { + type: 'function', + ['function']: { + name: 't', + description: '', + parameters: { type: 'object', properties: {} }, + }, + }, + }, + }), + ).rejects.toThrow('write failed'); + }); + }); +}); diff --git a/packages/api/src/mcp/tools.ts b/packages/api/src/mcp/tools.ts new file mode 100644 index 0000000000..d539cc5bd0 --- /dev/null +++ b/packages/api/src/mcp/tools.ts @@ -0,0 +1,104 @@ +import { logger } from '@librechat/data-schemas'; +import { Constants } from 'librechat-data-provider'; +import type { JsonSchemaType } from '@librechat/agents'; +import type { LCAvailableTools, LCFunctionTool } from './types'; + +export interface MCPToolInput { + name: string; + description?: string; + inputSchema?: JsonSchemaType; +} + +export interface MCPToolCacheDeps { + getCachedTools: (options?: { + userId?: string; + serverName?: string; + }) => Promise; + setCachedTools: ( + tools: LCAvailableTools, + options?: { userId?: string; serverName?: string }, + ) => Promise; +} + +export function createMCPToolCacheService(deps: MCPToolCacheDeps) { + const { getCachedTools, setCachedTools } = deps; + + async function updateMCPServerTools(params: { + userId: string; + serverName: string; + tools: MCPToolInput[] | null; + }): Promise { + const { userId, serverName, tools } = params; + try { + const serverTools: LCAvailableTools = {}; + const mcpDelimiter = Constants.mcp_delimiter; + + if (tools == null || tools.length === 0) { + logger.debug(`[MCP Cache] No tools to update for server ${serverName} (user: ${userId})`); + return serverTools; + } + + for (const tool of tools) { + const name = `${tool.name}${mcpDelimiter}${serverName}`; + const entry: LCFunctionTool = { + type: 'function', + ['function']: { + name, + description: tool.description ?? '', + parameters: tool.inputSchema ?? ({ type: 'object', properties: {} } as JsonSchemaType), + }, + }; + serverTools[name] = entry; + } + + await setCachedTools(serverTools, { userId, serverName }); + logger.debug( + `[MCP Cache] Updated ${tools.length} tools for server ${serverName} (user: ${userId})`, + ); + return serverTools; + } catch (error) { + logger.error( + `[MCP Cache] Failed to update tools for ${serverName} (user: ${userId}):`, + error, + ); + throw error; + } + } + + async function mergeAppTools(appTools: LCAvailableTools): Promise { + try { + const count = Object.keys(appTools).length; + if (!count) { + return; + } + const cachedTools = (await getCachedTools()) ?? {}; + const mergedTools: LCAvailableTools = { ...cachedTools, ...appTools }; + await setCachedTools(mergedTools); + logger.debug(`Merged ${count} app-level tools`); + } catch (error) { + logger.error('Failed to merge app-level tools:', error); + throw error; + } + } + + async function cacheMCPServerTools(params: { + userId: string; + serverName: string; + serverTools: LCAvailableTools; + }): Promise { + const { userId, serverName, serverTools } = params; + try { + const count = Object.keys(serverTools).length; + if (!count) { + return; + } + await setCachedTools(serverTools, { userId, serverName }); + logger.debug(`Cached ${count} MCP server tools for ${serverName} (user: ${userId})`); + } catch (error) { + logger.error(`Failed to cache MCP server tools for ${serverName} (user: ${userId}):`, error); + throw error; + } + } + + return { updateMCPServerTools, mergeAppTools, cacheMCPServerTools }; +} diff --git a/packages/data-provider/src/react-query/react-query-service.ts b/packages/data-provider/src/react-query/react-query-service.ts index 9d08d9d56a..571dce5830 100644 --- a/packages/data-provider/src/react-query/react-query-service.ts +++ b/packages/data-provider/src/react-query/react-query-service.ts @@ -124,6 +124,7 @@ export const useUpdateUserKeysMutation = (): UseMutationResult< return useMutation((payload: t.TUpdateUserKeyRequest) => dataService.updateUserKey(payload), { onSuccess: (data, variables) => { queryClient.invalidateQueries([QueryKeys.name, variables.name]); + queryClient.invalidateQueries([QueryKeys.models]); }, }); }; @@ -142,6 +143,7 @@ export const useRevokeUserKeyMutation = (name: string): UseMutationResult dataService.revokeUserKey(name), { onSuccess: () => { queryClient.invalidateQueries([QueryKeys.name, name]); + queryClient.invalidateQueries([QueryKeys.models]); if (s.isAssistantsEndpoint(name)) { queryClient.invalidateQueries([QueryKeys.assistants, name, defaultOrderQuery]); queryClient.invalidateQueries([QueryKeys.assistantDocs]); @@ -176,6 +178,7 @@ export const useRevokeAllUserKeysMutation = (): UseMutationResult => { queryClient.invalidateQueries([QueryKeys.mcpTools]); queryClient.invalidateQueries([QueryKeys.actions]); queryClient.invalidateQueries([QueryKeys.tools]); + queryClient.invalidateQueries([QueryKeys.models]); }, }); }; diff --git a/packages/data-provider/src/types.ts b/packages/data-provider/src/types.ts index 3716f67b05..d0792c1fdf 100644 --- a/packages/data-provider/src/types.ts +++ b/packages/data-provider/src/types.ts @@ -366,6 +366,7 @@ export type TConfig = { azure?: boolean; availableTools?: []; availableRegions?: string[]; + allowedProviders?: (string | EModelEndpoint)[]; plugins?: Record; name?: string; iconURL?: string; diff --git a/packages/data-schemas/src/app/resolution.spec.ts b/packages/data-schemas/src/app/resolution.spec.ts index 12f8985a48..b37ff25e3a 100644 --- a/packages/data-schemas/src/app/resolution.spec.ts +++ b/packages/data-schemas/src/app/resolution.spec.ts @@ -15,7 +15,7 @@ function fakeConfig(overrides: Record, priority: number): IConf } const baseConfig = { - interface: { endpointsMenu: true, sidePanel: true }, + interfaceConfig: { endpointsMenu: true, sidePanel: true }, registration: { enabled: true }, endpoints: ['openAI'], } as unknown as AppConfig; @@ -33,7 +33,7 @@ describe('mergeConfigOverrides', () => { it('deep merges a single override into base', () => { const configs = [fakeConfig({ interface: { endpointsMenu: false } }, 10)]; const result = mergeConfigOverrides(baseConfig, configs) as unknown as Record; - const iface = result.interface as Record; + const iface = result.interfaceConfig as Record; expect(iface.endpointsMenu).toBe(false); expect(iface.sidePanel).toBe(true); }); @@ -65,7 +65,7 @@ describe('mergeConfigOverrides', () => { it('handles null override values', () => { const configs = [fakeConfig({ interface: { endpointsMenu: null } }, 10)]; const result = mergeConfigOverrides(baseConfig, configs) as unknown as Record; - const iface = result.interface as Record; + const iface = result.interfaceConfig as Record; expect(iface.endpointsMenu).toBeNull(); }); @@ -101,8 +101,55 @@ describe('mergeConfigOverrides', () => { fakeConfig({ interface: { sidePanel: true } }, 100), ]; const result = mergeConfigOverrides(baseConfig, configs) as unknown as Record; - const iface = result.interface as Record; + const iface = result.interfaceConfig as Record; expect(iface.endpointsMenu).toBe(true); expect(iface.sidePanel).toBe(true); }); + + it('remaps all renamed YAML keys (exhaustiveness check)', () => { + const base = { + mcpConfig: null, + interfaceConfig: { endpointsMenu: true }, + turnstileConfig: {}, + } as unknown as AppConfig; + + const configs = [ + fakeConfig( + { + mcpServers: { srv: { url: 'http://mcp' } }, + interface: { endpointsMenu: false }, + turnstile: { siteKey: 'key-123' }, + }, + 10, + ), + ]; + const result = mergeConfigOverrides(base, configs) as unknown as Record; + + expect(result.mcpConfig).toEqual({ srv: { url: 'http://mcp' } }); + expect((result.interfaceConfig as Record).endpointsMenu).toBe(false); + expect((result.turnstileConfig as Record).siteKey).toBe('key-123'); + + expect(result.mcpServers).toBeUndefined(); + expect(result.interface).toBeUndefined(); + expect(result.turnstile).toBeUndefined(); + }); + + it('remaps YAML-level keys to AppConfig equivalents', () => { + const configs = [ + fakeConfig( + { + mcpServers: { 'test-server': { type: 'streamable-http', url: 'https://example.com' } }, + }, + 10, + ), + ]; + const result = mergeConfigOverrides(baseConfig, configs) as unknown as Record; + const mcpConfig = result.mcpConfig as Record; + expect(mcpConfig).toBeDefined(); + expect(mcpConfig['test-server']).toEqual({ + type: 'streamable-http', + url: 'https://example.com', + }); + expect(result.mcpServers).toBeUndefined(); + }); }); diff --git a/packages/data-schemas/src/app/resolution.ts b/packages/data-schemas/src/app/resolution.ts index ad1c1fbff0..be35a1d706 100644 --- a/packages/data-schemas/src/app/resolution.ts +++ b/packages/data-schemas/src/app/resolution.ts @@ -1,3 +1,4 @@ +import type { TCustomConfig } from 'librechat-data-provider'; import type { AppConfig, IConfig } from '~/types'; type AnyObject = { [key: string]: unknown }; @@ -5,6 +6,22 @@ type AnyObject = { [key: string]: unknown }; const MAX_MERGE_DEPTH = 10; const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']); +/** + * Maps YAML-level override keys (TCustomConfig) to their AppConfig equivalents. + * Overrides are stored with YAML keys but merged into the already-processed AppConfig + * where some fields have been renamed by AppService. + * + * When AppService renames a field, add the mapping here. Map entries are + * type-checked: keys must be valid TCustomConfig fields, values must be + * valid AppConfig fields. The runtime lookup casts string keys to satisfy + * strict indexing — unknown keys safely fall through via the ?? fallback. + */ +const OVERRIDE_KEY_MAP: Partial> = { + mcpServers: 'mcpConfig', + interface: 'interfaceConfig', + turnstile: 'turnstileConfig', +}; + function deepMerge(target: T, source: AnyObject, depth = 0): T { const result = { ...target } as AnyObject; for (const key of Object.keys(source)) { @@ -46,7 +63,11 @@ export function mergeConfigOverrides(baseConfig: AppConfig, configs: IConfig[]): let merged = { ...baseConfig }; for (const config of sorted) { if (config.overrides && typeof config.overrides === 'object') { - merged = deepMerge(merged, config.overrides as AnyObject); + const remapped: AnyObject = {}; + for (const [key, value] of Object.entries(config.overrides)) { + remapped[OVERRIDE_KEY_MAP[key as keyof typeof OVERRIDE_KEY_MAP] ?? key] = value; + } + merged = deepMerge(merged, remapped); } }