From 91e5a3caee9a8d49bc537db65cae741e5e20c302 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benedikt=20=C3=93skarsson?= Date: Wed, 18 Feb 2026 14:00:31 +0000 Subject: [PATCH] fix: remove model config cache and resolve per-user headers for model fetching The models config was cached globally (MODELS_CONFIG key) which meant all users saw the same model list regardless of their role or permissions. This is incorrect when the upstream provider (e.g. LiteLLM) returns different models per user based on JWT/OIDC tokens forwarded via custom headers. Changes: - Remove MODELS_CONFIG cache from ModelController so models are fetched fresh on each request, supporting per-user model lists - Resolve custom headers through resolveHeaders() before merging into the request options in fetchModels(), enabling template placeholders like {{LIBRECHAT_OPENID_ID_TOKEN}} to be expanded per-user - Merge resolved custom headers after default auth headers so config headers (e.g. authorization) take precedence over the default Bearer token - Update tests to verify header resolution and override behavior --- api/server/controllers/ModelController.js | 20 ++--------- packages/api/src/endpoints/models.spec.ts | 43 +++++++++++++++++++++-- packages/api/src/endpoints/models.ts | 13 +++++-- 3 files changed, 53 insertions(+), 23 deletions(-) diff --git a/api/server/controllers/ModelController.js b/api/server/controllers/ModelController.js index 805d9eef27ba..426b27eac5f7 100644 --- a/api/server/controllers/ModelController.js +++ b/api/server/controllers/ModelController.js @@ -1,20 +1,12 @@ const { logger } = require('@librechat/data-schemas'); -const { CacheKeys } = require('librechat-data-provider'); 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); - let modelsConfig = await cache.get(CacheKeys.MODELS_CONFIG); - if (!modelsConfig) { - modelsConfig = await loadModels(req); - } - - return modelsConfig; + return await loadModels(req); }; /** @@ -23,18 +15,10 @@ const getModelsConfig = async (req) => { * @returns {Promise} The models config. */ async function loadModels(req) { - const cache = getLogStores(CacheKeys.CONFIG_STORE); - const cachedModelsConfig = await cache.get(CacheKeys.MODELS_CONFIG); - if (cachedModelsConfig) { - return cachedModelsConfig; - } const defaultModelsConfig = await loadDefaultModels(req); const customModelsConfig = await loadConfigModels(req); - const modelConfig = { ...defaultModelsConfig, ...customModelsConfig }; - - await cache.set(CacheKeys.MODELS_CONFIG, modelConfig); - return modelConfig; + return { ...defaultModelsConfig, ...customModelsConfig }; } async function modelController(req, res) { diff --git a/packages/api/src/endpoints/models.spec.ts b/packages/api/src/endpoints/models.spec.ts index 575cc5fef894..34dbaaead945 100644 --- a/packages/api/src/endpoints/models.spec.ts +++ b/packages/api/src/endpoints/models.spec.ts @@ -78,12 +78,14 @@ describe('fetchModels', () => { ); }); - it('should pass custom headers to the API request', async () => { + it('should resolve and merge custom headers to the API request', async () => { const customHeaders = { 'X-Custom-Header': 'custom-value', 'X-API-Version': 'v2', }; + (resolveHeaders as jest.Mock).mockReturnValueOnce(customHeaders); + await fetchModels({ user: 'user123', apiKey: 'testApiKey', @@ -92,6 +94,10 @@ describe('fetchModels', () => { headers: customHeaders, }); + expect(resolveHeaders).toHaveBeenCalledWith({ + headers: customHeaders, + user: undefined, + }); expect(mockedAxios.get).toHaveBeenCalledWith( expect.stringContaining('https://api.test.com/models'), expect.objectContaining({ @@ -104,6 +110,36 @@ describe('fetchModels', () => { ); }); + it('should allow custom authorization header to override default Bearer token', async () => { + const customHeaders = { + Authorization: 'Bearer user-specific-token', + }; + + (resolveHeaders as jest.Mock).mockReturnValueOnce(customHeaders); + + await fetchModels({ + user: 'user123', + apiKey: 'testApiKey', + baseURL: 'https://api.test.com', + name: 'TestAPI', + headers: { authorization: 'Bearer {{LIBRECHAT_OPENID_ID_TOKEN}}' }, + userObject: { id: 'user123', email: 'test@example.com' }, + }); + + expect(resolveHeaders).toHaveBeenCalledWith({ + headers: { authorization: 'Bearer {{LIBRECHAT_OPENID_ID_TOKEN}}' }, + user: { id: 'user123', email: 'test@example.com' }, + }); + expect(mockedAxios.get).toHaveBeenCalledWith( + expect.stringContaining('https://api.test.com/models'), + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer user-specific-token', + }), + }), + ); + }); + it('should handle null headers gracefully', async () => { await fetchModels({ user: 'user123', @@ -548,11 +584,13 @@ describe('getAnthropicModels', () => { ); }); - it('should pass custom headers for Anthropic endpoint', async () => { + it('should merge custom headers on top of Anthropic defaults', async () => { const customHeaders = { 'X-Custom-Header': 'custom-value', }; + (resolveHeaders as jest.Mock).mockReturnValueOnce(customHeaders); + mockedAxios.get.mockResolvedValue({ data: { data: [{ id: 'claude-3' }], @@ -573,6 +611,7 @@ describe('getAnthropicModels', () => { headers: { 'x-api-key': 'test-anthropic-key', 'anthropic-version': expect.any(String), + 'X-Custom-Header': 'custom-value', }, }), ); diff --git a/packages/api/src/endpoints/models.ts b/packages/api/src/endpoints/models.ts index 715b80656597..9e241294a47d 100644 --- a/packages/api/src/endpoints/models.ts +++ b/packages/api/src/endpoints/models.ts @@ -127,14 +127,17 @@ export async function fetchModels({ } try { + const resolvedCustomHeaders = resolveHeaders({ + headers: headers ?? undefined, + user: userObject, + }); + const options: { headers: Record; timeout: number; httpsAgent?: HttpsProxyAgent; } = { - headers: { - ...(headers ?? {}), - }, + headers: {}, timeout: 5000, }; @@ -147,6 +150,10 @@ export async function fetchModels({ options.headers.Authorization = `Bearer ${apiKey}`; } + // Merge resolved custom headers after defaults so config headers + // (e.g. authorization with {{LIBRECHAT_OPENID_ACCESS_TOKEN}}) take precedence + Object.assign(options.headers, resolvedCustomHeaders); + if (process.env.PROXY) { options.httpsAgent = new HttpsProxyAgent(process.env.PROXY); }