From d4fd7af298d4be7b58b1752b579d5c93f76f9369 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Mon, 5 Oct 2026 15:20:21 -0700 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20fix:=20Pin=20Agent?= =?UTF-8?q?=20Runs=20to=20the=20Validated=20Model?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent model_parameters are free-form. A saved top-level fallbacks list, model routing keys in modelKwargs, or a Google apiVersion could change which model, endpoint or credentials an agent run uses, outside what validateAgentModel and the server configuration allow. initializeAgent now pins provider options to agent.model: it drops a top-level fallbacks list and the modelKwargs keys that select another model (model, models, route, fallbacks); other options pass through. The Google config builder also drops apiVersion alongside the other transport-level keys it already blocks. --- .../src/agents/__tests__/initialize.test.ts | 95 +++++++++++++++++++ packages/api/src/agents/initialize.ts | 6 +- packages/api/src/agents/pin.ts | 30 ++++++ packages/api/src/endpoints/google/llm.spec.ts | 15 +++ packages/api/src/endpoints/google/llm.ts | 1 + 5 files changed, 143 insertions(+), 4 deletions(-) create mode 100644 packages/api/src/agents/pin.ts diff --git a/packages/api/src/agents/__tests__/initialize.test.ts b/packages/api/src/agents/__tests__/initialize.test.ts index eb0042ebc87..22e30e39f36 100644 --- a/packages/api/src/agents/__tests__/initialize.test.ts +++ b/packages/api/src/agents/__tests__/initialize.test.ts @@ -6208,3 +6208,98 @@ describe('initializeAgent turn delivery routing', () => { expect(filesOrder).toBeLessThan(toolsOrder); }); }); + +/** `model_parameters` is free-form and OpenAI-compatible clients send `modelKwargs` verbatim in + * the request body, so a nested `model` would replace the model `validateAgentModel` checked + * against the user's role/group-filtered list. */ +describe('initializeAgent — validated model pinning', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + /** `fallbacks` makes the agents runtime build a second client from user-chosen provider, + * model and baseURL (with the server's env credentials) when the primary call fails, and + * OpenRouter/LiteLLM-style `modelKwargs` routing keys pick other models server-side. */ + it('drops user-supplied fallback and routing keys that select other models', async () => { + const { agent, req, res, loadTools, db } = createMocks({ + provider: Providers.OPENAI, + model: 'gpt-4o-mini', + }); + mockExtractLibreChatParams.mockReturnValue({ + resendFiles: false, + maxContextTokens: undefined, + modelOptions: { + model: 'gpt-4o-mini', + temperature: 0.2, + fallbacks: [{ provider: 'openAI', clientOptions: { model: 'gpt-4o' } }], + modelKwargs: { + models: ['gpt-4o'], + route: 'fallback', + fallbacks: ['gpt-4o'], + service_tier: 'flex', + }, + }, + }); + + await initializeAgent( + { + req, + res, + agent, + loadTools, + endpointOption: { endpoint: EModelEndpoint.agents }, + allowedProviders: new Set([Providers.OPENAI]), + isInitialAgent: true, + }, + db, + ); + + const { results } = mockGetProviderConfig.mock; + const { getOptions } = results[results.length - 1].value; + expect(getOptions).toHaveBeenCalledWith( + expect.objectContaining({ + model_parameters: { + model: 'gpt-4o-mini', + temperature: 0.2, + modelKwargs: { service_tier: 'flex' }, + }, + }), + ); + }); + + it('drops a nested modelKwargs.model so the provider request uses the validated model', async () => { + const { agent, req, res, loadTools, db } = createMocks({ + provider: Providers.OPENAI, + model: 'gpt-4o-mini', + }); + mockExtractLibreChatParams.mockReturnValue({ + resendFiles: false, + maxContextTokens: undefined, + modelOptions: { + model: 'gpt-4o-mini', + modelKwargs: { model: 'gpt-4o', service_tier: 'flex' }, + }, + }); + + await initializeAgent( + { + req, + res, + agent, + loadTools, + endpointOption: { endpoint: EModelEndpoint.agents }, + allowedProviders: new Set([Providers.OPENAI]), + isInitialAgent: true, + }, + db, + ); + + const { results } = mockGetProviderConfig.mock; + const { getOptions } = results[results.length - 1].value; + expect(getOptions).toHaveBeenCalledWith( + expect.objectContaining({ + model_parameters: { model: 'gpt-4o-mini', modelKwargs: { service_tier: 'flex' } }, + }), + ); + }); +}); diff --git a/packages/api/src/agents/initialize.ts b/packages/api/src/agents/initialize.ts index 9d648632b44..2070685aa15 100644 --- a/packages/api/src/agents/initialize.ts +++ b/packages/api/src/agents/initialize.ts @@ -147,6 +147,7 @@ import { applyTurnDelivery } from './files/delivery'; import { generateArtifactsPrompt } from '~/prompts'; import { getProviderConfig } from '~/endpoints'; import { primeResources } from './resources'; +import { pinAgentModel } from './pin'; /** * Fraction of context budget reserved as headroom when no explicit maxContextTokens is set. @@ -1544,10 +1545,7 @@ export async function initializeAgent( agent.provider = overrideProvider; } - const finalModelOptions = { - ...modelOptions, - model: agent.model, - }; + const finalModelOptions = pinAgentModel(modelOptions, agent.model); const options: InitializeResultBase = await getOptions({ runtime: { diff --git a/packages/api/src/agents/pin.ts b/packages/api/src/agents/pin.ts new file mode 100644 index 00000000000..1b228223565 --- /dev/null +++ b/packages/api/src/agents/pin.ts @@ -0,0 +1,30 @@ +/** + * Keys in free-form `modelKwargs` that OpenAI-compatible clients send verbatim in the request body + * and that select a different model: the model itself, plus OpenRouter (`models`, `route`) and + * LiteLLM (`fallbacks`) server-side fallback routing. + */ +const MODEL_ROUTING_KWARGS = new Set(['model', 'models', 'route', 'fallbacks']); + +type PinnedModelOptions = Omit & { model: M }; + +/** + * Pins an agent's provider options to its validated model. `model_parameters` is free-form, so + * a saved agent could otherwise pick models that `validateAgentModel` never checked against the + * user's allowed models: a top-level `fallbacks` list makes the agents runtime build a second + * client from user-chosen provider, model and baseURL (with the server's credentials) when the + * primary call fails, and `modelKwargs` routing keys redirect the provider request itself. + */ +export function pinAgentModel, M>( + modelOptions: T, + model: M, +): PinnedModelOptions { + const { fallbacks: _fallbacks, ...options } = modelOptions; + const { modelKwargs } = options; + if (modelKwargs == null || typeof modelKwargs !== 'object' || Array.isArray(modelKwargs)) { + return { ...options, model }; + } + const forwardedKwargs = Object.fromEntries( + Object.entries(modelKwargs).filter(([key]) => !MODEL_ROUTING_KWARGS.has(key)), + ); + return { ...options, model, modelKwargs: forwardedKwargs }; +} diff --git a/packages/api/src/endpoints/google/llm.spec.ts b/packages/api/src/endpoints/google/llm.spec.ts index 8b323b824f8..ec407e129b3 100644 --- a/packages/api/src/endpoints/google/llm.spec.ts +++ b/packages/api/src/endpoints/google/llm.spec.ts @@ -575,6 +575,21 @@ describe('getGoogleConfig', () => { }); }); + /** The SDK builds the request URL from `apiVersion`; only server config may choose it. */ + it('should ignore a model option apiVersion override', () => { + const credentials = { [AuthKeys.GOOGLE_API_KEY]: 'server-api-key' }; + + const result = getGoogleConfig(credentials, { + modelOptions: { + model: 'gemini-2.5-flash', + apiVersion: 'v1alpha', + } as t.GoogleParameters, + }); + + expect(result.llmConfig).not.toHaveProperty('apiVersion'); + expect(result.llmConfig).toHaveProperty('model', 'gemini-2.5-flash'); + }); + it('should ignore non-Google Vertex AI endpoint overrides from additional params', () => { process.env.GOOGLE_LOC = 'us'; diff --git a/packages/api/src/endpoints/google/llm.ts b/packages/api/src/endpoints/google/llm.ts index 0c3c6daa59a..5d844892df7 100644 --- a/packages/api/src/endpoints/google/llm.ts +++ b/packages/api/src/endpoints/google/llm.ts @@ -77,6 +77,7 @@ const vertexMultiRegionEndpoints = new Map([ const blockedModelOptionParams = [ 'apiKey', + 'apiVersion', 'baseUrl', 'baseURL', 'endpoint', From 01aed014ec3658ad59faa4de42a69c3984ee056a Mon Sep 17 00:00:00 2001 From: Lia Date: Mon, 12 Oct 2026 13:10:13 +0000 Subject: [PATCH 2/2] fix: Reject Unvalidated Agent Model Name Aliases --- .../src/agents/__tests__/initialize.test.ts | 88 ++++---- packages/api/src/agents/pin.spec.ts | 205 ++++++++++++++++++ packages/api/src/agents/pin.ts | 4 +- 3 files changed, 253 insertions(+), 44 deletions(-) create mode 100644 packages/api/src/agents/pin.spec.ts diff --git a/packages/api/src/agents/__tests__/initialize.test.ts b/packages/api/src/agents/__tests__/initialize.test.ts index 22e30e39f36..cf189ad1b2f 100644 --- a/packages/api/src/agents/__tests__/initialize.test.ts +++ b/packages/api/src/agents/__tests__/initialize.test.ts @@ -6220,52 +6220,56 @@ describe('initializeAgent — validated model pinning', () => { /** `fallbacks` makes the agents runtime build a second client from user-chosen provider, * model and baseURL (with the server's env credentials) when the primary call fails, and * OpenRouter/LiteLLM-style `modelKwargs` routing keys pick other models server-side. */ - it('drops user-supplied fallback and routing keys that select other models', async () => { - const { agent, req, res, loadTools, db } = createMocks({ - provider: Providers.OPENAI, - model: 'gpt-4o-mini', - }); - mockExtractLibreChatParams.mockReturnValue({ - resendFiles: false, - maxContextTokens: undefined, - modelOptions: { + it.each([true, false])( + 'drops model aliases and routing keys (initial agent: %s)', + async (isInitialAgent) => { + const { agent, req, res, loadTools, db } = createMocks({ + provider: Providers.OPENAI, model: 'gpt-4o-mini', - temperature: 0.2, - fallbacks: [{ provider: 'openAI', clientOptions: { model: 'gpt-4o' } }], - modelKwargs: { - models: ['gpt-4o'], - route: 'fallback', - fallbacks: ['gpt-4o'], - service_tier: 'flex', - }, - }, - }); - - await initializeAgent( - { - req, - res, - agent, - loadTools, - endpointOption: { endpoint: EModelEndpoint.agents }, - allowedProviders: new Set([Providers.OPENAI]), - isInitialAgent: true, - }, - db, - ); - - const { results } = mockGetProviderConfig.mock; - const { getOptions } = results[results.length - 1].value; - expect(getOptions).toHaveBeenCalledWith( - expect.objectContaining({ - model_parameters: { + }); + mockExtractLibreChatParams.mockReturnValue({ + resendFiles: false, + maxContextTokens: undefined, + modelOptions: { model: 'gpt-4o-mini', + modelName: 'gpt-4o', temperature: 0.2, - modelKwargs: { service_tier: 'flex' }, + fallbacks: [{ provider: 'openAI', clientOptions: { model: 'gpt-4o' } }], + modelKwargs: { + models: ['gpt-4o'], + route: 'fallback', + fallbacks: ['gpt-4o'], + service_tier: 'flex', + }, }, - }), - ); - }); + }); + + await initializeAgent( + { + req, + res, + agent, + loadTools, + endpointOption: { endpoint: EModelEndpoint.agents }, + allowedProviders: new Set([Providers.OPENAI]), + isInitialAgent, + }, + db, + ); + + const { results } = mockGetProviderConfig.mock; + const { getOptions } = results[results.length - 1].value; + expect(getOptions).toHaveBeenCalledWith( + expect.objectContaining({ + model_parameters: { + model: 'gpt-4o-mini', + temperature: 0.2, + modelKwargs: { service_tier: 'flex' }, + }, + }), + ); + }, + ); it('drops a nested modelKwargs.model so the provider request uses the validated model', async () => { const { agent, req, res, loadTools, db } = createMocks({ diff --git a/packages/api/src/agents/pin.spec.ts b/packages/api/src/agents/pin.spec.ts new file mode 100644 index 00000000000..d6300819d62 --- /dev/null +++ b/packages/api/src/agents/pin.spec.ts @@ -0,0 +1,205 @@ +import { Providers } from '@librechat/agents'; +import { AuthKeys, EModelEndpoint } from 'librechat-data-provider'; +import type { Agent } from 'librechat-data-provider'; +import type { Request, Response } from 'express'; +import { getOpenAILLMConfig } from '~/endpoints/openai/llm'; +import { getGoogleConfig } from '~/endpoints/google/llm'; +import { validateAgentModel } from './validation'; +import { pinAgentModel } from './pin'; + +describe('pinAgentModel', () => { + it.each([undefined, null, []])('removes modelName without object kwargs (%p)', (modelKwargs) => { + expect( + pinAgentModel( + { + modelName: 'gpt-4o', + temperature: 0.2, + fallbacks: [{ provider: Providers.OPENAI, clientOptions: { model: 'gpt-4o' } }], + modelKwargs, + }, + 'gpt-4o-mini', + ), + ).toEqual({ model: 'gpt-4o-mini', temperature: 0.2, modelKwargs }); + }); + + it('copies routing kwargs without mutating saved parameters', () => { + const modelOptions = Object.freeze({ + model: 'gpt-4o', + modelName: 'gpt-4o', + temperature: 0.2, + fallbacks: [{ provider: Providers.OPENAI, clientOptions: { model: 'gpt-4o' } }], + modelKwargs: Object.freeze({ + model: 'gpt-4o', + models: ['gpt-4o'], + route: 'fallback', + fallbacks: ['gpt-4o'], + service_tier: 'flex', + }), + }); + + const pinned = pinAgentModel(modelOptions, 'gpt-4o-mini'); + + expect(pinned).toEqual({ + model: 'gpt-4o-mini', + temperature: 0.2, + modelKwargs: { service_tier: 'flex' }, + }); + expect(pinned.modelKwargs).not.toBe(modelOptions.modelKwargs); + expect(modelOptions).toHaveProperty('modelName', 'gpt-4o'); + expect(modelOptions).toHaveProperty('fallbacks', [ + { provider: Providers.OPENAI, clientOptions: { model: 'gpt-4o' } }, + ]); + expect(modelOptions.modelKwargs).toEqual({ + model: 'gpt-4o', + models: ['gpt-4o'], + route: 'fallback', + fallbacks: ['gpt-4o'], + service_tier: 'flex', + }); + }); +}); + +describe('pinned provider configuration', () => { + it.each([false, true])('pins OpenAI-compatible options (OpenRouter: %s)', (useOpenRouter) => { + const modelOptions = pinAgentModel( + { + model: 'gpt-4o', + modelName: 'gpt-4o', + temperature: 0.2, + fallbacks: [ + { + provider: Providers.OPENAI, + clientOptions: { + model: 'gpt-4o', + configuration: { baseURL: 'https://unchecked.invalid' }, + }, + }, + ], + modelKwargs: { + model: 'gpt-4o', + models: ['gpt-4o'], + route: 'fallback', + fallbacks: ['gpt-4o'], + service_tier: 'flex', + }, + }, + 'gpt-4o-mini', + ); + + const { llmConfig } = getOpenAILLMConfig({ + apiKey: 'test-api-key', + streaming: true, + endpoint: EModelEndpoint.openAI, + useOpenRouter, + modelOptions, + }); + + expect(llmConfig).toMatchObject({ model: 'gpt-4o-mini', temperature: 0.2 }); + expect(llmConfig).not.toHaveProperty('modelName'); + expect(llmConfig).not.toHaveProperty('fallbacks'); + expect(llmConfig.modelKwargs).toEqual({ service_tier: 'flex' }); + }); + + it('pins Google options while preserving server-configured transport', () => { + const modelOptions = pinAgentModel( + { + model: 'gemini-2.5-pro', + modelName: 'gemini-2.5-pro', + temperature: 0.2, + maxOutputTokens: 1024, + apiKey: 'untrusted-api-key', + apiVersion: 'v1alpha', + baseUrl: 'https://unchecked.invalid', + customHeaders: { Authorization: 'untrusted-authorization' }, + fallbacks: [{ provider: Providers.OPENAI, clientOptions: { model: 'gpt-4o' } }], + }, + 'gemini-2.5-flash', + ); + + const { provider, llmConfig } = getGoogleConfig( + { [AuthKeys.GOOGLE_API_KEY]: 'server-api-key' }, + { + modelOptions, + reverseProxyUrl: 'https://configured.invalid/google', + authHeader: true, + headers: { 'X-Gateway': 'configured' }, + }, + ); + + expect(provider).toBe(Providers.GOOGLE); + expect(llmConfig).toMatchObject({ + model: 'gemini-2.5-flash', + temperature: 0.2, + maxOutputTokens: 1024, + apiKey: 'server-api-key', + baseUrl: 'https://configured.invalid/google', + customHeaders: { 'X-Gateway': 'configured', Authorization: 'Bearer server-api-key' }, + }); + expect(llmConfig).not.toHaveProperty('modelName'); + expect(llmConfig).not.toHaveProperty('fallbacks'); + expect(llmConfig).not.toHaveProperty('apiVersion'); + }); + + it('pins Vertex options without replacing the configured project', () => { + const modelOptions = pinAgentModel( + { + modelName: 'gemini-2.5-pro', + temperature: 0.2, + apiVersion: 'v1alpha', + authOptions: { projectId: 'unchecked-project' }, + }, + 'gemini-2.5-flash', + ); + + const { provider, llmConfig } = getGoogleConfig( + { [AuthKeys.GOOGLE_SERVICE_KEY]: { project_id: 'configured-project' } }, + { modelOptions, forceVertex: true, projectId: 'configured-project' }, + ); + + expect(provider).toBe(Providers.VERTEXAI); + expect(llmConfig).toMatchObject({ + model: 'gemini-2.5-flash', + temperature: 0.2, + authOptions: { projectId: 'configured-project' }, + }); + expect(llmConfig).not.toHaveProperty('modelName'); + expect(llmConfig).not.toHaveProperty('apiVersion'); + }); + + it('still rejects a disallowed declared model when aliases name an allowed model', async () => { + const logViolation = jest.fn().mockResolvedValue(undefined); + const modelParameters = { + temperature: 0.2, + maxContextTokens: null, + max_context_tokens: null, + max_output_tokens: null, + top_p: null, + frequency_penalty: null, + presence_penalty: null, + modelName: 'gpt-4o-mini', + modelKwargs: { model: 'gpt-4o-mini' }, + }; + const agent: Agent = { + id: 'restricted-agent', + name: 'Restricted agent', + description: null, + created_at: 0, + avatar: null, + provider: Providers.OPENAI, + model: 'gpt-4o', + model_parameters: modelParameters, + }; + + const validation = await validateAgentModel({ + req: {} as Request, + res: {} as Response, + agent, + modelsConfig: { [EModelEndpoint.openAI]: ['gpt-4o-mini'] }, + logViolation, + }); + + expect(validation.isValid).toBe(false); + expect(validation.error?.message).toContain('illegal_model_request'); + expect(logViolation).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/api/src/agents/pin.ts b/packages/api/src/agents/pin.ts index 1b228223565..a919b770a72 100644 --- a/packages/api/src/agents/pin.ts +++ b/packages/api/src/agents/pin.ts @@ -5,7 +5,7 @@ */ const MODEL_ROUTING_KWARGS = new Set(['model', 'models', 'route', 'fallbacks']); -type PinnedModelOptions = Omit & { model: M }; +type PinnedModelOptions = Omit & { model: M }; /** * Pins an agent's provider options to its validated model. `model_parameters` is free-form, so @@ -18,7 +18,7 @@ export function pinAgentModel, M>( modelOptions: T, model: M, ): PinnedModelOptions { - const { fallbacks: _fallbacks, ...options } = modelOptions; + const { fallbacks: _fallbacks, modelName: _modelName, ...options } = modelOptions; const { modelKwargs } = options; if (modelKwargs == null || typeof modelKwargs !== 'object' || Array.isArray(modelKwargs)) { return { ...options, model };