Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions packages/api/src/agents/__tests__/initialize.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6208,3 +6208,102 @@ 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.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',
});
mockExtractLibreChatParams.mockReturnValue({
resendFiles: false,
maxContextTokens: undefined,
modelOptions: {
model: 'gpt-4o-mini',
modelName: 'gpt-4o',
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,
},
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' } },
}),
);
});
});
6 changes: 2 additions & 4 deletions packages/api/src/agents/initialize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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: {
Expand Down
205 changes: 205 additions & 0 deletions packages/api/src/agents/pin.spec.ts
Original file line number Diff line number Diff line change
@@ -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<unknown, unknown, unknown>,
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);
});
});
30 changes: 30 additions & 0 deletions packages/api/src/agents/pin.ts
Original file line number Diff line number Diff line change
@@ -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<T, M> = Omit<T, 'fallbacks' | 'modelName'> & { 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<T extends Record<string, unknown>, M>(
modelOptions: T,
model: M,
): PinnedModelOptions<T, M> {
const { fallbacks: _fallbacks, modelName: _modelName, ...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 };
}
15 changes: 15 additions & 0 deletions packages/api/src/endpoints/google/llm.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
1 change: 1 addition & 0 deletions packages/api/src/endpoints/google/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ const vertexMultiRegionEndpoints = new Map([

const blockedModelOptionParams = [
'apiKey',
'apiVersion',
'baseUrl',
'baseURL',
'endpoint',
Expand Down
Loading