Merge branch 'main' into feature/entra-id-azure-integration

This commit is contained in:
victorbjor 2025-12-15 15:40:47 +01:00 committed by GitHub
commit a7cf1ae27b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
241 changed files with 25653 additions and 3303 deletions

View file

@ -1,6 +1,6 @@
{
"name": "@librechat/api",
"version": "1.5.0",
"version": "1.7.0",
"type": "commonjs",
"description": "MCP services for LibreChat",
"main": "dist/index.js",
@ -20,9 +20,9 @@
"build:watch:prod": "rollup -c -w --bundleConfigAsCjs",
"test": "jest --coverage --watch --testPathIgnorePatterns=\"\\.*integration\\.|\\.*helper\\.\"",
"test:ci": "jest --coverage --ci --testPathIgnorePatterns=\"\\.*integration\\.|\\.*helper\\.\"",
"test:cache-integration:core": "jest --testPathPattern=\"src/cache/.*\\.cache_integration\\.spec\\.ts$\" --coverage=false",
"test:cache-integration:cluster": "jest --testPathPattern=\"src/cluster/.*\\.cache_integration\\.spec\\.ts$\" --coverage=false --runInBand",
"test:cache-integration:mcp": "jest --testPathPattern=\"src/mcp/.*\\.cache_integration\\.spec\\.ts$\" --coverage=false",
"test:cache-integration:core": "jest --testPathPatterns=\"src/cache/.*\\.cache_integration\\.spec\\.ts$\" --coverage=false",
"test:cache-integration:cluster": "jest --testPathPatterns=\"src/cluster/.*\\.cache_integration\\.spec\\.ts$\" --coverage=false --runInBand",
"test:cache-integration:mcp": "jest --testPathPatterns=\"src/mcp/.*\\.cache_integration\\.spec\\.ts$\" --coverage=false",
"test:cache-integration": "npm run test:cache-integration:core && npm run test:cache-integration:cluster && npm run test:cache-integration:mcp",
"verify": "npm run test:ci",
"b:clean": "bun run rimraf dist",
@ -48,7 +48,7 @@
"@babel/preset-react": "^7.18.6",
"@babel/preset-typescript": "^7.21.0",
"@rollup/plugin-alias": "^5.1.0",
"@rollup/plugin-commonjs": "^25.0.2",
"@rollup/plugin-commonjs": "^29.0.0",
"@rollup/plugin-json": "^6.1.0",
"@rollup/plugin-node-resolve": "^15.1.0",
"@rollup/plugin-replace": "^5.0.5",
@ -68,7 +68,7 @@
"jest-junit": "^16.0.0",
"librechat-data-provider": "*",
"mongodb": "^6.14.2",
"rimraf": "^5.0.1",
"rimraf": "^6.1.2",
"rollup": "^4.22.4",
"rollup-plugin-peer-deps-external": "^2.2.4",
"ts-node": "^10.9.2",
@ -84,7 +84,7 @@
"@azure/storage-blob": "^12.27.0",
"@keyv/redis": "^4.3.3",
"@langchain/core": "^0.3.79",
"@librechat/agents": "^3.0.17",
"@librechat/agents": "^3.0.50",
"@librechat/data-schemas": "*",
"@modelcontextprotocol/sdk": "^1.21.0",
"axios": "^1.12.1",

View file

@ -9,15 +9,16 @@ import type {
RunConfig,
IState,
} from '@librechat/agents';
import type { IUser } from '@librechat/data-schemas';
import type { Agent } from 'librechat-data-provider';
import type * as t from '~/types';
import { resolveHeaders } from '~/utils/env';
import { resolveHeaders, createSafeUser } from '~/utils/env';
const customProviders = new Set([
Providers.XAI,
Providers.OLLAMA,
Providers.DEEPSEEK,
Providers.OPENROUTER,
KnownEndpoints.ollama,
]);
export function getReasoningKey(
@ -66,6 +67,7 @@ export async function createRun({
signal,
agents,
requestBody,
user,
tokenCounter,
customHandlers,
indexTokenCountMap,
@ -78,6 +80,7 @@ export async function createRun({
streaming?: boolean;
streamUsage?: boolean;
requestBody?: t.RequestBody;
user?: IUser;
} & Pick<RunConfig, 'tokenCounter' | 'customHandlers' | 'indexTokenCountMap'>): Promise<
Run<IState>
> {
@ -118,6 +121,7 @@ export async function createRun({
if (llmConfig?.configuration?.defaultHeaders != null) {
llmConfig.configuration.defaultHeaders = resolveHeaders({
headers: llmConfig.configuration.defaultHeaders as Record<string, string>,
user: createSafeUser(user),
body: requestBody,
});
}

View file

@ -81,6 +81,7 @@ export const agentCreateSchema = agentBaseSchema.extend({
/** Update schema extends base with all fields optional and additional update-only fields */
export const agentUpdateSchema = agentBaseSchema.extend({
avatar: z.union([agentAvatarSchema, z.null()]).optional(),
provider: z.string().optional(),
model: z.string().nullable().optional(),
projectIds: z.array(z.string()).optional(),

View file

@ -394,6 +394,34 @@ describe('findOpenIDUser', () => {
expect(mockFindUser).toHaveBeenCalledWith({ email: 'user@example.com' });
});
it('should pass email to findUser for case-insensitive lookup (findUser handles normalization)', async () => {
const mockUser: IUser = {
_id: 'user123',
provider: 'openid',
openidId: 'openid_456',
email: 'user@example.com',
username: 'testuser',
} as IUser;
mockFindUser
.mockResolvedValueOnce(null) // Primary condition fails
.mockResolvedValueOnce(mockUser); // Email search succeeds
const result = await findOpenIDUser({
openidId: 'openid_123',
findUser: mockFindUser,
email: 'User@Example.COM',
});
/** Email is passed as-is; findUser implementation handles normalization */
expect(mockFindUser).toHaveBeenNthCalledWith(2, { email: 'User@Example.COM' });
expect(result).toEqual({
user: mockUser,
error: null,
migration: false,
});
});
it('should handle findUser throwing an error', async () => {
mockFindUser.mockRejectedValueOnce(new Error('Database error'));

View file

@ -122,6 +122,38 @@ describe('getLLMConfig', () => {
});
});
it('should add "prompt-caching" beta header for claude-opus-4-5 model', () => {
const modelOptions = {
model: 'claude-opus-4-5',
promptCache: true,
};
const result = getLLMConfig('test-key', { modelOptions });
const clientOptions = result.llmConfig.clientOptions;
expect(clientOptions?.defaultHeaders).toBeDefined();
expect(clientOptions?.defaultHeaders).toHaveProperty('anthropic-beta');
const defaultHeaders = clientOptions?.defaultHeaders as Record<string, string>;
expect(defaultHeaders['anthropic-beta']).toBe('prompt-caching-2024-07-31');
});
it('should add "prompt-caching" beta header for claude-opus-4-5 model formats', () => {
const modelVariations = [
'claude-opus-4-5',
'claude-opus-4-5-20250420',
'claude-opus-4.5',
'anthropic/claude-opus-4-5',
];
modelVariations.forEach((model) => {
const modelOptions = { model, promptCache: true };
const result = getLLMConfig('test-key', { modelOptions });
const clientOptions = result.llmConfig.clientOptions;
expect(clientOptions?.defaultHeaders).toBeDefined();
expect(clientOptions?.defaultHeaders).toHaveProperty('anthropic-beta');
const defaultHeaders = clientOptions?.defaultHeaders as Record<string, string>;
expect(defaultHeaders['anthropic-beta']).toBe('prompt-caching-2024-07-31');
});
});
it('should NOT include topK and topP for Claude-3.7 models with thinking enabled (decimal notation)', () => {
const result = getLLMConfig('test-api-key', {
modelOptions: {
@ -707,6 +739,7 @@ describe('getLLMConfig', () => {
{ model: 'claude-haiku-4-5-20251001', expectedMaxTokens: 64000 },
{ model: 'claude-opus-4-1', expectedMaxTokens: 32000 },
{ model: 'claude-opus-4-1-20250805', expectedMaxTokens: 32000 },
{ model: 'claude-opus-4-5', expectedMaxTokens: 64000 },
{ model: 'claude-sonnet-4-20250514', expectedMaxTokens: 64000 },
{ model: 'claude-opus-4-0', expectedMaxTokens: 32000 },
];
@ -771,6 +804,17 @@ describe('getLLMConfig', () => {
});
});
it('should default Claude Opus 4.5 model to 64K tokens', () => {
const testCases = ['claude-opus-4-5', 'claude-opus-4-5-20250420', 'claude-opus-4.5'];
testCases.forEach((model) => {
const result = getLLMConfig('test-key', {
modelOptions: { model },
});
expect(result.llmConfig.maxTokens).toBe(64000);
});
});
it('should default future Claude 4.x Sonnet/Haiku models to 64K (future-proofing)', () => {
const testCases = ['claude-sonnet-4-20250514', 'claude-sonnet-4-9', 'claude-haiku-4-8'];
@ -782,15 +826,24 @@ describe('getLLMConfig', () => {
});
});
it('should default future Claude 4.x Opus models to 32K (future-proofing)', () => {
const testCases = ['claude-opus-4-0', 'claude-opus-4-7'];
testCases.forEach((model) => {
it('should default future Claude 4.x Opus models (future-proofing)', () => {
// opus-4-0 through opus-4-4 get 32K
const opus32kModels = ['claude-opus-4-0', 'claude-opus-4-1', 'claude-opus-4-4'];
opus32kModels.forEach((model) => {
const result = getLLMConfig('test-key', {
modelOptions: { model },
});
expect(result.llmConfig.maxTokens).toBe(32000);
});
// opus-4-5+ get 64K
const opus64kModels = ['claude-opus-4-5', 'claude-opus-4-7', 'claude-opus-4-10'];
opus64kModels.forEach((model) => {
const result = getLLMConfig('test-key', {
modelOptions: { model },
});
expect(result.llmConfig.maxTokens).toBe(64000);
});
});
it('should handle explicit maxOutputTokens override for Claude 4.x models', () => {
@ -908,7 +961,7 @@ describe('getLLMConfig', () => {
});
});
it('should future-proof Claude 5.x Opus models with 32K default', () => {
it('should future-proof Claude 5.x Opus models with 64K default', () => {
const testCases = [
'claude-opus-5',
'claude-opus-5-0',
@ -920,28 +973,28 @@ describe('getLLMConfig', () => {
const result = getLLMConfig('test-key', {
modelOptions: { model },
});
expect(result.llmConfig.maxTokens).toBe(32000);
expect(result.llmConfig.maxTokens).toBe(64000);
});
});
it('should future-proof Claude 6-9.x models with correct defaults', () => {
const testCases = [
// Claude 6.x
// Claude 6.x - All get 64K since they're version 5+
{ model: 'claude-sonnet-6', expected: 64000 },
{ model: 'claude-haiku-6-0', expected: 64000 },
{ model: 'claude-opus-6-1', expected: 32000 },
{ model: 'claude-opus-6-1', expected: 64000 }, // opus 6+ gets 64K
// Claude 7.x
{ model: 'claude-sonnet-7-20270101', expected: 64000 },
{ model: 'claude-haiku-7.5', expected: 64000 },
{ model: 'claude-opus-7', expected: 32000 },
{ model: 'claude-opus-7', expected: 64000 }, // opus 7+ gets 64K
// Claude 8.x
{ model: 'claude-sonnet-8', expected: 64000 },
{ model: 'claude-haiku-8-2', expected: 64000 },
{ model: 'claude-opus-8-latest', expected: 32000 },
{ model: 'claude-opus-8-latest', expected: 64000 }, // opus 8+ gets 64K
// Claude 9.x
{ model: 'claude-sonnet-9', expected: 64000 },
{ model: 'claude-haiku-9', expected: 64000 },
{ model: 'claude-opus-9', expected: 32000 },
{ model: 'claude-opus-9', expected: 64000 }, // opus 9+ gets 64K
];
testCases.forEach(({ model, expected }) => {

View file

@ -121,9 +121,12 @@ export function getSafetySettings(
export function getGoogleConfig(
credentials: string | t.GoogleCredentials | undefined,
options: t.GoogleConfigOptions = {},
acceptRawApiKey = false,
) {
let creds: t.GoogleCredentials = {};
if (typeof credentials === 'string') {
if (acceptRawApiKey && typeof credentials === 'string') {
creds[AuthKeys.GOOGLE_API_KEY] = credentials;
} else if (typeof credentials === 'string') {
try {
creds = JSON.parse(credentials);
} catch (err: unknown) {

View file

@ -69,6 +69,26 @@ describe('getOpenAIConfig - Google Compatibility', () => {
expect(result.tools).toEqual([]);
});
it('should filter out googleSearch when web_search is only in modelOptions (not explicitly in addParams/defaultParams)', () => {
const apiKey = JSON.stringify({ GOOGLE_API_KEY: 'test-google-key' });
const endpoint = 'Gemini (Custom)';
const options = {
modelOptions: {
model: 'gemini-2.0-flash-exp',
web_search: true,
},
customParams: {
defaultParamsEndpoint: 'google',
},
reverseProxyUrl: 'https://generativelanguage.googleapis.com/v1beta/openai',
};
const result = getOpenAIConfig(apiKey, options, endpoint);
/** googleSearch should be filtered out since web_search was not explicitly added via addParams or defaultParams */
expect(result.tools).toEqual([]);
});
it('should handle web_search with mixed Google and OpenAI params in addParams', () => {
const apiKey = JSON.stringify({ GOOGLE_API_KEY: 'test-google-key' });
const endpoint = 'Gemini (Custom)';

View file

@ -26,7 +26,7 @@ describe('getOpenAIConfig', () => {
it('should apply model options', () => {
const modelOptions = {
model: 'gpt-5',
model: 'gpt-4',
temperature: 0.7,
max_tokens: 1000,
};
@ -34,14 +34,11 @@ describe('getOpenAIConfig', () => {
const result = getOpenAIConfig(mockApiKey, { modelOptions });
expect(result.llmConfig).toMatchObject({
model: 'gpt-5',
model: 'gpt-4',
temperature: 0.7,
modelKwargs: {
max_completion_tokens: 1000,
},
maxTokens: 1000,
});
expect((result.llmConfig as Record<string, unknown>).max_tokens).toBeUndefined();
expect((result.llmConfig as Record<string, unknown>).maxTokens).toBeUndefined();
});
it('should separate known and unknown params from addParams', () => {
@ -286,7 +283,7 @@ describe('getOpenAIConfig', () => {
it('should ignore non-boolean web_search values in addParams', () => {
const modelOptions = {
model: 'gpt-5',
model: 'gpt-4',
web_search: true,
};
@ -399,7 +396,7 @@ describe('getOpenAIConfig', () => {
it('should handle verbosity parameter in modelKwargs', () => {
const modelOptions = {
model: 'gpt-5',
model: 'gpt-4',
temperature: 0.7,
verbosity: Verbosity.high,
};
@ -407,7 +404,7 @@ describe('getOpenAIConfig', () => {
const result = getOpenAIConfig(mockApiKey, { modelOptions });
expect(result.llmConfig).toMatchObject({
model: 'gpt-5',
model: 'gpt-4',
temperature: 0.7,
});
expect(result.llmConfig.modelKwargs).toEqual({
@ -417,7 +414,7 @@ describe('getOpenAIConfig', () => {
it('should allow addParams to override verbosity in modelKwargs', () => {
const modelOptions = {
model: 'gpt-5',
model: 'gpt-4',
verbosity: Verbosity.low,
};
@ -451,7 +448,7 @@ describe('getOpenAIConfig', () => {
it('should nest verbosity under text when useResponsesApi is enabled', () => {
const modelOptions = {
model: 'gpt-5',
model: 'gpt-4',
temperature: 0.7,
verbosity: Verbosity.low,
useResponsesApi: true,
@ -460,7 +457,7 @@ describe('getOpenAIConfig', () => {
const result = getOpenAIConfig(mockApiKey, { modelOptions });
expect(result.llmConfig).toMatchObject({
model: 'gpt-5',
model: 'gpt-4',
temperature: 0.7,
useResponsesApi: true,
});
@ -496,7 +493,6 @@ describe('getOpenAIConfig', () => {
it('should move maxTokens to modelKwargs.max_completion_tokens for GPT-5+ models', () => {
const modelOptions = {
model: 'gpt-5',
temperature: 0.7,
max_tokens: 2048,
};
@ -504,7 +500,6 @@ describe('getOpenAIConfig', () => {
expect(result.llmConfig).toMatchObject({
model: 'gpt-5',
temperature: 0.7,
});
expect(result.llmConfig.maxTokens).toBeUndefined();
expect(result.llmConfig.modelKwargs).toEqual({
@ -1684,7 +1679,7 @@ describe('getOpenAIConfig', () => {
it('should not override existing modelOptions with defaultParams', () => {
const result = getOpenAIConfig(mockApiKey, {
modelOptions: {
model: 'gpt-5',
model: 'gpt-4',
temperature: 0.9,
},
customParams: {
@ -1697,7 +1692,7 @@ describe('getOpenAIConfig', () => {
});
expect(result.llmConfig.temperature).toBe(0.9);
expect(result.llmConfig.modelKwargs?.max_completion_tokens).toBe(1000);
expect(result.llmConfig.maxTokens).toBe(1000);
});
it('should allow addParams to override defaultParams', () => {
@ -1845,7 +1840,7 @@ describe('getOpenAIConfig', () => {
it('should preserve order: defaultParams < addParams < modelOptions', () => {
const result = getOpenAIConfig(mockApiKey, {
modelOptions: {
model: 'gpt-5',
model: 'gpt-4',
temperature: 0.9,
},
customParams: {
@ -1863,7 +1858,7 @@ describe('getOpenAIConfig', () => {
expect(result.llmConfig.temperature).toBe(0.9);
expect(result.llmConfig.topP).toBe(0.8);
expect(result.llmConfig.modelKwargs?.max_completion_tokens).toBe(500);
expect(result.llmConfig.maxTokens).toBe(500);
});
});
});

View file

@ -77,23 +77,29 @@ export function getOpenAIConfig(
headers = Object.assign(headers ?? {}, transformed.configOptions?.defaultHeaders);
}
} else if (isGoogle) {
const googleResult = getGoogleConfig(apiKey, {
modelOptions,
reverseProxyUrl: baseURL ?? undefined,
authHeader: true,
addParams,
dropParams,
defaultParams,
});
const googleResult = getGoogleConfig(
apiKey,
{
modelOptions,
reverseProxyUrl: baseURL ?? undefined,
authHeader: true,
addParams,
dropParams,
defaultParams,
},
true,
);
/** Transform handles addParams/dropParams - it knows about OpenAI params */
const transformed = transformToOpenAIConfig({
addParams,
dropParams,
defaultParams,
tools: googleResult.tools,
llmConfig: googleResult.llmConfig,
fromEndpoint: EModelEndpoint.google,
});
llmConfig = transformed.llmConfig;
tools = googleResult.tools;
tools = transformed.tools;
} else {
const openaiResult = getOpenAILLMConfig({
azure,

View file

@ -0,0 +1,602 @@
import {
Verbosity,
EModelEndpoint,
ReasoningEffort,
ReasoningSummary,
} from 'librechat-data-provider';
import { getOpenAILLMConfig, extractDefaultParams, applyDefaultParams } from './llm';
import type * as t from '~/types';
describe('getOpenAILLMConfig', () => {
describe('Basic Configuration', () => {
it('should create a basic configuration with required fields', () => {
const result = getOpenAILLMConfig({
apiKey: 'test-api-key',
streaming: true,
modelOptions: {
model: 'gpt-4',
},
});
expect(result.llmConfig).toHaveProperty('apiKey', 'test-api-key');
expect(result.llmConfig).toHaveProperty('model', 'gpt-4');
expect(result.llmConfig).toHaveProperty('streaming', true);
expect(result.tools).toEqual([]);
});
it('should handle model options including temperature and penalties', () => {
const result = getOpenAILLMConfig({
apiKey: 'test-api-key',
streaming: true,
modelOptions: {
model: 'gpt-4',
temperature: 0.7,
frequency_penalty: 0.5,
presence_penalty: 0.3,
},
});
expect(result.llmConfig).toHaveProperty('temperature', 0.7);
expect(result.llmConfig).toHaveProperty('frequencyPenalty', 0.5);
expect(result.llmConfig).toHaveProperty('presencePenalty', 0.3);
});
it('should handle max_tokens conversion to maxTokens', () => {
const result = getOpenAILLMConfig({
apiKey: 'test-api-key',
streaming: true,
modelOptions: {
model: 'gpt-4',
max_tokens: 4096,
},
});
expect(result.llmConfig).toHaveProperty('maxTokens', 4096);
expect(result.llmConfig).not.toHaveProperty('max_tokens');
});
});
describe('OpenAI Reasoning Models (o1/o3/gpt-5)', () => {
const reasoningModels = [
'o1',
'o1-mini',
'o1-preview',
'o1-pro',
'o3',
'o3-mini',
'gpt-5',
'gpt-5-pro',
'gpt-5-turbo',
];
const excludedParams = [
'frequencyPenalty',
'presencePenalty',
'temperature',
'topP',
'logitBias',
'n',
'logprobs',
];
it.each(reasoningModels)(
'should exclude unsupported parameters for reasoning model: %s',
(model) => {
const result = getOpenAILLMConfig({
apiKey: 'test-api-key',
streaming: true,
modelOptions: {
model,
temperature: 0.7,
frequency_penalty: 0.5,
presence_penalty: 0.3,
topP: 0.9,
logitBias: { '50256': -100 },
n: 2,
logprobs: true,
} as Partial<t.OpenAIParameters>,
});
excludedParams.forEach((param) => {
expect(result.llmConfig).not.toHaveProperty(param);
});
expect(result.llmConfig).toHaveProperty('model', model);
expect(result.llmConfig).toHaveProperty('streaming', true);
},
);
it('should preserve maxTokens for reasoning models', () => {
const result = getOpenAILLMConfig({
apiKey: 'test-api-key',
streaming: true,
modelOptions: {
model: 'o1',
max_tokens: 4096,
temperature: 0.7,
},
});
expect(result.llmConfig).toHaveProperty('maxTokens', 4096);
expect(result.llmConfig).not.toHaveProperty('temperature');
});
it('should preserve other valid parameters for reasoning models', () => {
const result = getOpenAILLMConfig({
apiKey: 'test-api-key',
streaming: true,
modelOptions: {
model: 'o1',
max_tokens: 8192,
stop: ['END'],
},
});
expect(result.llmConfig).toHaveProperty('maxTokens', 8192);
expect(result.llmConfig).toHaveProperty('stop', ['END']);
});
it('should handle GPT-5 max_tokens conversion to max_completion_tokens', () => {
const result = getOpenAILLMConfig({
apiKey: 'test-api-key',
streaming: true,
modelOptions: {
model: 'gpt-5',
max_tokens: 8192,
stop: ['END'],
},
});
expect(result.llmConfig.modelKwargs).toHaveProperty('max_completion_tokens', 8192);
expect(result.llmConfig).not.toHaveProperty('maxTokens');
expect(result.llmConfig).toHaveProperty('stop', ['END']);
});
it('should combine user dropParams with reasoning exclusion params', () => {
const result = getOpenAILLMConfig({
apiKey: 'test-api-key',
streaming: true,
modelOptions: {
model: 'o3-mini',
temperature: 0.7,
stop: ['END'],
},
dropParams: ['stop'],
});
expect(result.llmConfig).not.toHaveProperty('temperature');
expect(result.llmConfig).not.toHaveProperty('stop');
});
it('should NOT exclude parameters for non-reasoning models', () => {
const result = getOpenAILLMConfig({
apiKey: 'test-api-key',
streaming: true,
modelOptions: {
model: 'gpt-4-turbo',
temperature: 0.7,
frequency_penalty: 0.5,
presence_penalty: 0.3,
topP: 0.9,
},
});
expect(result.llmConfig).toHaveProperty('temperature', 0.7);
expect(result.llmConfig).toHaveProperty('frequencyPenalty', 0.5);
expect(result.llmConfig).toHaveProperty('presencePenalty', 0.3);
expect(result.llmConfig).toHaveProperty('topP', 0.9);
});
it('should NOT exclude parameters for gpt-5.x versioned models (they support sampling params)', () => {
const versionedModels = ['gpt-5.1', 'gpt-5.1-turbo', 'gpt-5.2', 'gpt-5.5-preview'];
versionedModels.forEach((model) => {
const result = getOpenAILLMConfig({
apiKey: 'test-api-key',
streaming: true,
modelOptions: {
model,
temperature: 0.7,
frequency_penalty: 0.5,
presence_penalty: 0.3,
topP: 0.9,
},
});
expect(result.llmConfig).toHaveProperty('temperature', 0.7);
expect(result.llmConfig).toHaveProperty('frequencyPenalty', 0.5);
expect(result.llmConfig).toHaveProperty('presencePenalty', 0.3);
expect(result.llmConfig).toHaveProperty('topP', 0.9);
});
});
it('should NOT exclude parameters for gpt-5-chat (it supports sampling params)', () => {
const result = getOpenAILLMConfig({
apiKey: 'test-api-key',
streaming: true,
modelOptions: {
model: 'gpt-5-chat',
temperature: 0.7,
frequency_penalty: 0.5,
presence_penalty: 0.3,
topP: 0.9,
},
});
expect(result.llmConfig).toHaveProperty('temperature', 0.7);
expect(result.llmConfig).toHaveProperty('frequencyPenalty', 0.5);
expect(result.llmConfig).toHaveProperty('presencePenalty', 0.3);
expect(result.llmConfig).toHaveProperty('topP', 0.9);
});
it('should handle reasoning models with reasoning_effort parameter', () => {
const result = getOpenAILLMConfig({
apiKey: 'test-api-key',
streaming: true,
endpoint: EModelEndpoint.openAI,
modelOptions: {
model: 'o1',
reasoning_effort: ReasoningEffort.high,
temperature: 0.7,
},
});
expect(result.llmConfig).toHaveProperty('reasoning_effort', ReasoningEffort.high);
expect(result.llmConfig).not.toHaveProperty('temperature');
});
});
describe('OpenAI Web Search Models', () => {
it('should exclude parameters for gpt-4o search models', () => {
const result = getOpenAILLMConfig({
apiKey: 'test-api-key',
streaming: true,
modelOptions: {
model: 'gpt-4o-search-preview',
temperature: 0.7,
top_p: 0.9,
seed: 42,
} as Partial<t.OpenAIParameters>,
});
expect(result.llmConfig).not.toHaveProperty('temperature');
expect(result.llmConfig).not.toHaveProperty('top_p');
expect(result.llmConfig).not.toHaveProperty('seed');
});
it('should preserve max_tokens for search models', () => {
const result = getOpenAILLMConfig({
apiKey: 'test-api-key',
streaming: true,
modelOptions: {
model: 'gpt-4o-search',
max_tokens: 4096,
temperature: 0.7,
},
});
expect(result.llmConfig).toHaveProperty('maxTokens', 4096);
expect(result.llmConfig).not.toHaveProperty('temperature');
});
});
describe('Web Search Functionality', () => {
it('should enable web search with Responses API', () => {
const result = getOpenAILLMConfig({
apiKey: 'test-api-key',
streaming: true,
modelOptions: {
model: 'gpt-4',
web_search: true,
},
});
expect(result.llmConfig).toHaveProperty('useResponsesApi', true);
expect(result.tools).toContainEqual({ type: 'web_search' });
});
it('should handle web search with OpenRouter', () => {
const result = getOpenAILLMConfig({
apiKey: 'test-api-key',
streaming: true,
useOpenRouter: true,
modelOptions: {
model: 'gpt-4',
web_search: true,
},
});
expect(result.llmConfig.modelKwargs).toHaveProperty('plugins', [{ id: 'web' }]);
expect(result.llmConfig).toHaveProperty('include_reasoning', true);
});
it('should disable web search via dropParams', () => {
const result = getOpenAILLMConfig({
apiKey: 'test-api-key',
streaming: true,
modelOptions: {
model: 'gpt-4',
web_search: true,
},
dropParams: ['web_search'],
});
expect(result.tools).not.toContainEqual({ type: 'web_search' });
});
});
describe('GPT-5 max_tokens Handling', () => {
it('should convert maxTokens to max_completion_tokens for GPT-5 models', () => {
const result = getOpenAILLMConfig({
apiKey: 'test-api-key',
streaming: true,
modelOptions: {
model: 'gpt-5',
max_tokens: 8192,
},
});
expect(result.llmConfig.modelKwargs).toHaveProperty('max_completion_tokens', 8192);
expect(result.llmConfig).not.toHaveProperty('maxTokens');
});
it('should convert maxTokens to max_output_tokens for GPT-5 with Responses API', () => {
const result = getOpenAILLMConfig({
apiKey: 'test-api-key',
streaming: true,
modelOptions: {
model: 'gpt-5',
max_tokens: 8192,
},
addParams: {
useResponsesApi: true,
},
});
expect(result.llmConfig.modelKwargs).toHaveProperty('max_output_tokens', 8192);
expect(result.llmConfig).not.toHaveProperty('maxTokens');
});
});
describe('Reasoning Parameters', () => {
it('should handle reasoning_effort for OpenAI endpoint', () => {
const result = getOpenAILLMConfig({
apiKey: 'test-api-key',
streaming: true,
endpoint: EModelEndpoint.openAI,
modelOptions: {
model: 'o1',
reasoning_effort: ReasoningEffort.high,
},
});
expect(result.llmConfig).toHaveProperty('reasoning_effort', ReasoningEffort.high);
});
it('should use reasoning object for non-OpenAI endpoints', () => {
const result = getOpenAILLMConfig({
apiKey: 'test-api-key',
streaming: true,
endpoint: 'custom',
modelOptions: {
model: 'o1',
reasoning_effort: ReasoningEffort.high,
reasoning_summary: ReasoningSummary.concise,
},
});
expect(result.llmConfig).toHaveProperty('reasoning');
expect(result.llmConfig.reasoning).toEqual({
effort: ReasoningEffort.high,
summary: ReasoningSummary.concise,
});
});
it('should use reasoning object when useResponsesApi is true', () => {
const result = getOpenAILLMConfig({
apiKey: 'test-api-key',
streaming: true,
endpoint: EModelEndpoint.openAI,
modelOptions: {
model: 'o1',
reasoning_effort: ReasoningEffort.medium,
reasoning_summary: ReasoningSummary.detailed,
},
addParams: {
useResponsesApi: true,
},
});
expect(result.llmConfig).toHaveProperty('reasoning');
expect(result.llmConfig.reasoning).toEqual({
effort: ReasoningEffort.medium,
summary: ReasoningSummary.detailed,
});
});
});
describe('Default and Add Parameters', () => {
it('should apply default parameters when fields are undefined', () => {
const result = getOpenAILLMConfig({
apiKey: 'test-api-key',
streaming: true,
modelOptions: {
model: 'gpt-4',
},
defaultParams: {
temperature: 0.5,
topP: 0.9,
},
});
expect(result.llmConfig).toHaveProperty('temperature', 0.5);
expect(result.llmConfig).toHaveProperty('topP', 0.9);
});
it('should NOT override existing values with default parameters', () => {
const result = getOpenAILLMConfig({
apiKey: 'test-api-key',
streaming: true,
modelOptions: {
model: 'gpt-4',
temperature: 0.8,
},
defaultParams: {
temperature: 0.5,
},
});
expect(result.llmConfig).toHaveProperty('temperature', 0.8);
});
it('should apply addParams and override defaults', () => {
const result = getOpenAILLMConfig({
apiKey: 'test-api-key',
streaming: true,
modelOptions: {
model: 'gpt-4',
},
defaultParams: {
temperature: 0.5,
},
addParams: {
temperature: 0.9,
seed: 42,
},
});
expect(result.llmConfig).toHaveProperty('temperature', 0.9);
expect(result.llmConfig).toHaveProperty('seed', 42);
});
it('should handle unknown params via modelKwargs', () => {
const result = getOpenAILLMConfig({
apiKey: 'test-api-key',
streaming: true,
modelOptions: {
model: 'gpt-4',
},
addParams: {
custom_param: 'custom_value',
},
});
expect(result.llmConfig.modelKwargs).toHaveProperty('custom_param', 'custom_value');
});
});
describe('Drop Parameters', () => {
it('should drop specified parameters', () => {
const result = getOpenAILLMConfig({
apiKey: 'test-api-key',
streaming: true,
modelOptions: {
model: 'gpt-4',
temperature: 0.7,
topP: 0.9,
},
dropParams: ['temperature'],
});
expect(result.llmConfig).not.toHaveProperty('temperature');
expect(result.llmConfig).toHaveProperty('topP', 0.9);
});
});
describe('OpenRouter Configuration', () => {
it('should include include_reasoning for OpenRouter', () => {
const result = getOpenAILLMConfig({
apiKey: 'test-api-key',
streaming: true,
useOpenRouter: true,
modelOptions: {
model: 'gpt-4',
},
});
expect(result.llmConfig).toHaveProperty('include_reasoning', true);
});
});
describe('Verbosity Handling', () => {
it('should add verbosity to modelKwargs', () => {
const result = getOpenAILLMConfig({
apiKey: 'test-api-key',
streaming: true,
modelOptions: {
model: 'gpt-4',
verbosity: Verbosity.high,
},
});
expect(result.llmConfig.modelKwargs).toHaveProperty('verbosity', Verbosity.high);
});
it('should convert verbosity to text object with Responses API', () => {
const result = getOpenAILLMConfig({
apiKey: 'test-api-key',
streaming: true,
modelOptions: {
model: 'gpt-4',
verbosity: Verbosity.low,
},
addParams: {
useResponsesApi: true,
},
});
expect(result.llmConfig.modelKwargs).toHaveProperty('text', { verbosity: Verbosity.low });
expect(result.llmConfig.modelKwargs).not.toHaveProperty('verbosity');
});
});
});
describe('extractDefaultParams', () => {
it('should extract default values from param definitions', () => {
const paramDefinitions = [
{ key: 'temperature', default: 0.7 },
{ key: 'maxTokens', default: 4096 },
{ key: 'noDefault' },
];
const result = extractDefaultParams(paramDefinitions);
expect(result).toEqual({
temperature: 0.7,
maxTokens: 4096,
});
});
it('should return undefined for undefined or non-array input', () => {
expect(extractDefaultParams(undefined)).toBeUndefined();
expect(extractDefaultParams(null as unknown as undefined)).toBeUndefined();
});
it('should handle empty array', () => {
const result = extractDefaultParams([]);
expect(result).toEqual({});
});
});
describe('applyDefaultParams', () => {
it('should apply defaults only when field is undefined', () => {
const target: Record<string, unknown> = {
temperature: 0.8,
maxTokens: undefined,
};
const defaults = {
temperature: 0.5,
maxTokens: 4096,
topP: 0.9,
};
applyDefaultParams(target, defaults);
expect(target).toEqual({
temperature: 0.8,
maxTokens: 4096,
topP: 0.9,
});
});
});

View file

@ -259,9 +259,35 @@ export function getOpenAILLMConfig({
}
/**
* Note: OpenAI Web Search models do not support any known parameters besides `max_tokens`
* Note: OpenAI reasoning models (o1/o3/gpt-5) do not support temperature and other sampling parameters
* Exception: gpt-5-chat and versioned models like gpt-5.1 DO support these parameters
*/
if (modelOptions.model && /gpt-4o.*search/.test(modelOptions.model as string)) {
if (
modelOptions.model &&
/\b(o[13]|gpt-5)(?!\.|-chat)(?:-|$)/.test(modelOptions.model as string)
) {
const reasoningExcludeParams = [
'frequencyPenalty',
'presencePenalty',
'temperature',
'topP',
'logitBias',
'n',
'logprobs',
];
const updatedDropParams = dropParams || [];
const combinedDropParams = [...new Set([...updatedDropParams, ...reasoningExcludeParams])];
combinedDropParams.forEach((param) => {
if (param in llmConfig) {
delete llmConfig[param as keyof t.OAIClientOptions];
}
});
} else if (modelOptions.model && /gpt-4o.*search/.test(modelOptions.model as string)) {
/**
* Note: OpenAI Web Search models do not support any known parameters besides `max_tokens`
*/
const searchExcludeParams = [
'frequency_penalty',
'presence_penalty',

View file

@ -1,28 +1,48 @@
import { EModelEndpoint } from 'librechat-data-provider';
import type { GoogleAIToolType } from '@langchain/google-common';
import type { ClientOptions } from '@librechat/agents';
import type * as t from '~/types';
import { knownOpenAIParams } from './llm';
const anthropicExcludeParams = new Set(['anthropicApiUrl']);
const googleExcludeParams = new Set(['safetySettings', 'location', 'baseUrl', 'customHeaders']);
const googleExcludeParams = new Set([
'safetySettings',
'location',
'baseUrl',
'customHeaders',
'thinkingConfig',
'thinkingBudget',
'includeThoughts',
]);
/** Google-specific tool types that have no OpenAI-compatible equivalent */
const googleToolsToFilter = new Set(['googleSearch']);
export type ConfigTools = Array<Record<string, unknown>> | Array<GoogleAIToolType>;
/**
* Transforms a Non-OpenAI LLM config to an OpenAI-conformant config.
* Non-OpenAI parameters are moved to modelKwargs.
* Also extracts configuration options that belong in configOptions.
* Handles addParams and dropParams for parameter customization.
* Filters out provider-specific tools that have no OpenAI equivalent.
*/
export function transformToOpenAIConfig({
tools,
addParams,
dropParams,
defaultParams,
llmConfig,
fromEndpoint,
}: {
tools?: ConfigTools;
addParams?: Record<string, unknown>;
dropParams?: string[];
defaultParams?: Record<string, unknown>;
llmConfig: ClientOptions;
fromEndpoint: string;
}): {
tools: ConfigTools;
llmConfig: t.OAIClientOptions;
configOptions: Partial<t.OpenAIConfiguration>;
} {
@ -58,18 +78,9 @@ export function transformToOpenAIConfig({
hasModelKwargs = true;
continue;
} else if (isGoogle && key === 'authOptions') {
// Handle Google authOptions
modelKwargs = Object.assign({}, modelKwargs, value as Record<string, unknown>);
hasModelKwargs = true;
continue;
} else if (
isGoogle &&
(key === 'thinkingConfig' || key === 'thinkingBudget' || key === 'includeThoughts')
) {
// Handle Google thinking configuration
modelKwargs = Object.assign({}, modelKwargs, { [key]: value });
hasModelKwargs = true;
continue;
}
if (knownOpenAIParams.has(key)) {
@ -121,7 +132,34 @@ export function transformToOpenAIConfig({
}
}
/**
* Filter out provider-specific tools that have no OpenAI equivalent.
* Exception: If web_search was explicitly enabled via addParams or defaultParams,
* preserve googleSearch tools (pass through in Google-native format).
*/
const webSearchExplicitlyEnabled =
addParams?.web_search === true || defaultParams?.web_search === true;
const filterGoogleTool = (tool: unknown): boolean => {
if (!isGoogle) {
return true;
}
if (typeof tool !== 'object' || tool === null) {
return false;
}
const toolKeys = Object.keys(tool as Record<string, unknown>);
const isGoogleSpecificTool = toolKeys.some((key) => googleToolsToFilter.has(key));
/** Preserve googleSearch if web_search was explicitly enabled */
if (isGoogleSpecificTool && webSearchExplicitlyEnabled) {
return true;
}
return !isGoogleSpecificTool;
};
const filteredTools = Array.isArray(tools) ? tools.filter(filterGoogleTool) : [];
return {
tools: filteredTools,
llmConfig: openAIConfig as t.OAIClientOptions,
configOptions,
};

View file

@ -75,7 +75,7 @@ export async function encodeAndFormatAudios(
if (provider === Providers.GOOGLE || provider === Providers.VERTEXAI) {
result.audios.push({
type: 'audio',
type: 'media',
mimeType: file.type,
data: content,
});

View file

@ -112,7 +112,7 @@ export async function encodeAndFormatDocuments(
});
} else if (provider === Providers.GOOGLE || provider === Providers.VERTEXAI) {
result.documents.push({
type: 'document',
type: 'media',
mimeType: 'application/pdf',
data: content,
});

View file

@ -75,7 +75,7 @@ export async function encodeAndFormatVideos(
if (provider === Providers.GOOGLE || provider === Providers.VERTEXAI) {
result.videos.push({
type: 'video',
type: 'media',
mimeType: file.type,
data: content,
});

View file

@ -50,8 +50,9 @@ import { parseTextNative, parseText } from './text';
import fs, { ReadStream } from 'fs';
import axios from 'axios';
import FormData from 'form-data';
import { generateShortLivedToken } from '../crypto/jwt';
import { readFileAsString } from '../utils';
import type { ServerRequest } from '~/types';
import { generateShortLivedToken } from '~/crypto/jwt';
import { readFileAsString } from '~/utils';
const mockedFs = fs as jest.Mocked<typeof fs>;
const mockedAxios = axios as jest.Mocked<typeof axios>;
@ -77,7 +78,7 @@ describe('text', () => {
const mockReq = {
user: { id: 'user123' },
};
} as ServerRequest;
const mockFileId = 'file123';
@ -228,6 +229,13 @@ describe('text', () => {
file_id: mockFileId,
});
expect(mockedAxios.post).toHaveBeenCalledWith(
'http://rag-api.test/text',
expect.any(Object),
expect.objectContaining({
timeout: 300000,
}),
);
expect(result).toEqual({
text: '',
bytes: 0,
@ -278,7 +286,7 @@ describe('text', () => {
});
const result = await parseText({
req: { user: undefined },
req: { user: undefined } as ServerRequest,
file: mockFile,
file_id: mockFileId,
});

View file

@ -65,7 +65,7 @@ export async function parseText({
accept: 'application/json',
...formHeaders,
},
timeout: 30000,
timeout: 300000,
});
const responseData = response.data;

View file

@ -2,9 +2,8 @@ import pick from 'lodash/pick';
import { logger } from '@librechat/data-schemas';
import { CallToolResultSchema, ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
import type { RequestOptions } from '@modelcontextprotocol/sdk/shared/protocol.js';
import type { TokenMethods } from '@librechat/data-schemas';
import type { TokenMethods, IUser } from '@librechat/data-schemas';
import type { FlowStateManager } from '~/flow/manager';
import type { TUser } from 'librechat-data-provider';
import type { MCPOAuthTokens } from './oauth';
import type { RequestBody } from '~/types';
import type * as t from './types';
@ -49,7 +48,7 @@ export class MCPManager extends UserConnectionManager {
public async getConnection(
args: {
serverName: string;
user?: TUser;
user?: IUser;
forceNew?: boolean;
flowManager?: FlowStateManager<MCPOAuthTokens | null>;
} & Omit<t.OAuthConnectionOptions, 'useOAuth' | 'user' | 'flowManager'>,
@ -176,7 +175,7 @@ Please follow these instructions when using tools from the respective MCP server
oauthEnd,
customUserVars,
}: {
user?: TUser;
user?: IUser;
serverName: string;
toolName: string;
provider: t.Provider;

View file

@ -5,6 +5,7 @@ import { mcpServersRegistry as serversRegistry } from '~/mcp/registry/MCPServers
import { MCPConnection } from './connection';
import type * as t from './types';
import { ConnectionsRepository } from '~/mcp/ConnectionsRepository';
import { mcpConfig } from './mcpConfig';
/**
* Abstract base class for managing user-specific MCP connections with lifecycle management.
@ -20,7 +21,6 @@ export abstract class UserConnectionManager {
protected userConnections: Map<string, Map<string, MCPConnection>> = new Map();
/** Last activity timestamp for users (not per server) */
protected userLastActivity: Map<string, number> = new Map();
protected readonly USER_CONNECTION_IDLE_TIMEOUT = 15 * 60 * 1000; // 15 minutes (TODO: make configurable)
/** Updates the last activity timestamp for a user */
protected updateUserLastActivity(userId: string): void {
@ -67,7 +67,7 @@ export abstract class UserConnectionManager {
// Check if user is idle
const lastActivity = this.userLastActivity.get(userId);
if (lastActivity && now - lastActivity > this.USER_CONNECTION_IDLE_TIMEOUT) {
if (lastActivity && now - lastActivity > mcpConfig.USER_CONNECTION_IDLE_TIMEOUT) {
logger.info(`[MCP][User: ${userId}] User idle for too long. Disconnecting all connections.`);
// Disconnect all user connections
try {
@ -217,7 +217,7 @@ export abstract class UserConnectionManager {
if (currentUserId && currentUserId === userId) {
continue;
}
if (now - lastActivity > this.USER_CONNECTION_IDLE_TIMEOUT) {
if (now - lastActivity > mcpConfig.USER_CONNECTION_IDLE_TIMEOUT) {
logger.info(
`[MCP][User: ${userId}] User idle for too long. Disconnecting all connections...`,
);

View file

@ -25,7 +25,7 @@ describe('OAuth Detection Integration Tests', () => {
name: 'GitHub Copilot MCP Server',
url: 'https://api.githubcopilot.com/mcp',
expectedOAuth: true,
expectedMethod: '401-challenge-metadata',
expectedMethod: 'protected-resource-metadata',
withMeta: true,
},
{
@ -42,6 +42,13 @@ describe('OAuth Detection Integration Tests', () => {
expectedMethod: 'protected-resource-metadata',
withMeta: true,
},
{
name: 'StackOverflow MCP (HEAD=405, POST=401+Bearer)',
url: 'https://mcp.stackoverflow.com',
expectedOAuth: true,
expectedMethod: '401-challenge-metadata',
withMeta: false,
},
{
name: 'HTTPBin (Non-OAuth)',
url: 'https://httpbin.org',

View file

@ -992,4 +992,147 @@ describe('MCPOAuthHandler - Configurable OAuth Metadata', () => {
expect(headers.get('foo')).toBe('bar');
});
});
describe('Fallback OAuth Metadata (Legacy Server Support)', () => {
const originalFetch = global.fetch;
const mockFetch = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
global.fetch = mockFetch as unknown as typeof fetch;
});
afterAll(() => {
global.fetch = originalFetch;
});
it('should use fallback metadata when discoverAuthorizationServerMetadata returns undefined', async () => {
// Mock resource metadata discovery to fail
mockDiscoverOAuthProtectedResourceMetadata.mockRejectedValueOnce(
new Error('No resource metadata'),
);
// Mock authorization server metadata discovery to return undefined (no .well-known)
mockDiscoverAuthorizationServerMetadata.mockResolvedValueOnce(undefined);
// Mock client registration to succeed
mockRegisterClient.mockResolvedValueOnce({
client_id: 'dynamic-client-id',
client_secret: 'dynamic-client-secret',
redirect_uris: ['http://localhost:3080/api/mcp/test-server/oauth/callback'],
});
// Mock startAuthorization to return a successful response
mockStartAuthorization.mockResolvedValueOnce({
authorizationUrl: new URL('https://mcp.example.com/authorize?client_id=dynamic-client-id'),
codeVerifier: 'test-code-verifier',
});
await MCPOAuthHandler.initiateOAuthFlow(
'test-server',
'https://mcp.example.com',
'user-123',
{},
undefined,
);
// Verify registerClient was called with fallback metadata
expect(mockRegisterClient).toHaveBeenCalledWith(
'https://mcp.example.com/',
expect.objectContaining({
metadata: expect.objectContaining({
issuer: 'https://mcp.example.com/',
authorization_endpoint: 'https://mcp.example.com/authorize',
token_endpoint: 'https://mcp.example.com/token',
registration_endpoint: 'https://mcp.example.com/register',
response_types_supported: ['code'],
grant_types_supported: ['authorization_code', 'refresh_token'],
code_challenge_methods_supported: ['S256', 'plain'],
token_endpoint_auth_methods_supported: [
'client_secret_basic',
'client_secret_post',
'none',
],
}),
}),
);
});
it('should use fallback /token endpoint for refresh when metadata discovery fails', async () => {
const metadata = {
serverName: 'test-server',
serverUrl: 'https://mcp.example.com',
clientInfo: {
client_id: 'test-client-id',
client_secret: 'test-client-secret',
},
};
// Mock metadata discovery to return undefined (no .well-known)
mockDiscoverAuthorizationServerMetadata.mockResolvedValueOnce(undefined);
// Mock successful token refresh
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
access_token: 'new-access-token',
refresh_token: 'new-refresh-token',
expires_in: 3600,
}),
} as Response);
const result = await MCPOAuthHandler.refreshOAuthTokens(
'test-refresh-token',
metadata,
{},
{},
);
// Verify fetch was called with fallback /token endpoint
expect(mockFetch).toHaveBeenCalledWith(
'https://mcp.example.com/token',
expect.objectContaining({
method: 'POST',
}),
);
expect(result.access_token).toBe('new-access-token');
});
it('should use fallback auth methods when metadata discovery fails during refresh', async () => {
const metadata = {
serverName: 'test-server',
serverUrl: 'https://mcp.example.com',
clientInfo: {
client_id: 'test-client-id',
client_secret: 'test-client-secret',
},
};
// Mock metadata discovery to return undefined
mockDiscoverAuthorizationServerMetadata.mockResolvedValueOnce(undefined);
// Mock successful token refresh
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
access_token: 'new-access-token',
expires_in: 3600,
}),
} as Response);
await MCPOAuthHandler.refreshOAuthTokens('test-refresh-token', metadata, {}, {});
// Verify it uses client_secret_basic (first in fallback auth methods)
const expectedAuth = `Basic ${Buffer.from('test-client-id:test-client-secret').toString('base64')}`;
expect(mockFetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
headers: expect.objectContaining({
Authorization: expectedAuth,
}),
}),
);
});
});
});

View file

@ -336,7 +336,7 @@ export class MCPConnection extends EventEmitter {
}
}
} catch (error) {
this.emitError(error, 'Failed to construct transport:');
this.emitError(error, 'Failed to construct transport');
throw error;
}
}
@ -595,17 +595,27 @@ export class MCPConnection extends EventEmitter {
private setupTransportErrorHandlers(transport: Transport): void {
transport.onerror = (error) => {
logger.error(`${this.getLogPrefix()} Transport error:`, error);
// Check if it's an OAuth authentication error
if (error && typeof error === 'object' && 'code' in error) {
const errorCode = (error as unknown as { code?: number }).code;
// Ignore SSE 404 errors for servers that don't support SSE
if (
errorCode === 404 &&
String(error?.message).toLowerCase().includes('failed to open sse stream')
) {
logger.warn(`${this.getLogPrefix()} SSE stream not available (404). Ignoring.`);
return;
}
// Check if it's an OAuth authentication error
if (errorCode === 401 || errorCode === 403) {
logger.warn(`${this.getLogPrefix()} OAuth authentication error detected`);
this.emit('oauthError', error);
}
}
logger.error(`${this.getLogPrefix()} Transport error:`, error);
this.emit('connectionChange', 'error');
};
}
@ -631,7 +641,7 @@ export class MCPConnection extends EventEmitter {
const { resources } = await this.client.listResources();
return resources;
} catch (error) {
this.emitError(error, 'Failed to fetch resources:');
this.emitError(error, 'Failed to fetch resources');
return [];
}
}
@ -641,7 +651,7 @@ export class MCPConnection extends EventEmitter {
const { tools } = await this.client.listTools();
return tools;
} catch (error) {
this.emitError(error, 'Failed to fetch tools:');
this.emitError(error, 'Failed to fetch tools');
return [];
}
}
@ -651,7 +661,7 @@ export class MCPConnection extends EventEmitter {
const { prompts } = await this.client.listPrompts();
return prompts;
} catch (error) {
this.emitError(error, 'Failed to fetch prompts:');
this.emitError(error, 'Failed to fetch prompts');
return [];
}
}
@ -678,7 +688,9 @@ export class MCPConnection extends EventEmitter {
const pingUnsupported =
error instanceof Error &&
((error as Error)?.message.includes('-32601') ||
(error as Error)?.message.includes('-32602') ||
(error as Error)?.message.includes('invalid method ping') ||
(error as Error)?.message.includes('Unsupported method: ping') ||
(error as Error)?.message.includes('method not found'));
if (!pingUnsupported) {

View file

@ -8,4 +8,6 @@ export const mcpConfig = {
OAUTH_ON_AUTH_ERROR: isEnabled(process.env.MCP_OAUTH_ON_AUTH_ERROR ?? true),
OAUTH_DETECTION_TIMEOUT: math(process.env.MCP_OAUTH_DETECTION_TIMEOUT ?? 5000),
CONNECTION_CHECK_TTL: math(process.env.MCP_CONNECTION_CHECK_TTL ?? 60000),
/** Idle timeout (ms) after which user connections are disconnected. Default: 15 minutes */
USER_CONNECTION_IDLE_TIMEOUT: math(process.env.MCP_USER_CONNECTION_IDLE_TIMEOUT ?? 15 * 60 * 1000),
};

View file

@ -1,6 +1,5 @@
import { logger } from '@librechat/data-schemas';
import type { TokenMethods } from '@librechat/data-schemas';
import type { TUser } from 'librechat-data-provider';
import type { TokenMethods, IUser } from '@librechat/data-schemas';
import type { MCPOAuthTokens } from './types';
import { OAuthReconnectionTracker } from './OAuthReconnectionTracker';
import { FlowStateManager } from '~/flow/manager';
@ -117,7 +116,7 @@ export class OAuthReconnectionManager {
// attempt to get connection (this will use existing tokens and refresh if needed)
const connection = await this.mcpManager.getUserConnection({
serverName,
user: { id: userId } as TUser,
user: { id: userId } as IUser,
flowManager: this.flowManager,
tokenMethods: this.tokenMethods,
// don't force new connection, let it reuse existing or create new as needed

View file

@ -0,0 +1,267 @@
import { detectOAuthRequirement } from './detectOAuth';
jest.mock('@modelcontextprotocol/sdk/client/auth.js', () => ({
discoverOAuthProtectedResourceMetadata: jest.fn(),
}));
import { discoverOAuthProtectedResourceMetadata } from '@modelcontextprotocol/sdk/client/auth.js';
const mockDiscoverOAuthProtectedResourceMetadata =
discoverOAuthProtectedResourceMetadata as jest.MockedFunction<
typeof discoverOAuthProtectedResourceMetadata
>;
describe('detectOAuthRequirement', () => {
const originalFetch = global.fetch;
const mockFetch = jest.fn() as unknown as jest.MockedFunction<typeof fetch>;
beforeEach(() => {
jest.clearAllMocks();
global.fetch = mockFetch;
mockDiscoverOAuthProtectedResourceMetadata.mockRejectedValue(
new Error('No protected resource metadata'),
);
});
afterAll(() => {
global.fetch = originalFetch;
});
describe('POST fallback when HEAD fails', () => {
it('should try POST when HEAD returns 405 Method Not Allowed', async () => {
// HEAD returns 405 (Method Not Allowed)
mockFetch.mockResolvedValueOnce({
status: 405,
headers: new Headers(),
} as Response);
// POST returns 401 with Bearer
mockFetch.mockResolvedValueOnce({
status: 401,
headers: new Headers({ 'www-authenticate': 'Bearer' }),
} as Response);
const result = await detectOAuthRequirement('https://mcp.example.com');
expect(result.requiresOAuth).toBe(true);
expect(result.method).toBe('401-challenge-metadata');
expect(mockFetch).toHaveBeenCalledTimes(2);
// Verify HEAD was called first
expect(mockFetch.mock.calls[0][1]).toEqual(expect.objectContaining({ method: 'HEAD' }));
// Verify POST was called second with proper headers and body
expect(mockFetch.mock.calls[1][1]).toEqual(
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
}),
);
});
it('should try POST when HEAD returns non-401 status', async () => {
// HEAD returns 200 OK (no auth required for HEAD)
mockFetch.mockResolvedValueOnce({
status: 200,
headers: new Headers(),
} as Response);
// POST returns 401 with Bearer
mockFetch.mockResolvedValueOnce({
status: 401,
headers: new Headers({ 'www-authenticate': 'Bearer' }),
} as Response);
const result = await detectOAuthRequirement('https://mcp.example.com');
expect(result.requiresOAuth).toBe(true);
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it('should not try POST if HEAD returns 401', async () => {
// HEAD returns 401 with Bearer
mockFetch.mockResolvedValueOnce({
status: 401,
headers: new Headers({ 'www-authenticate': 'Bearer' }),
} as Response);
const result = await detectOAuthRequirement('https://mcp.example.com');
expect(result.requiresOAuth).toBe(true);
// Only HEAD should be called since it returned 401
expect(mockFetch).toHaveBeenCalledTimes(1);
});
});
describe('Bearer detection without resource_metadata URL', () => {
it('should detect OAuth when 401 has WWW-Authenticate: Bearer (case insensitive)', async () => {
mockFetch.mockResolvedValueOnce({
status: 401,
headers: new Headers({ 'www-authenticate': 'bearer' }),
} as Response);
const result = await detectOAuthRequirement('https://mcp.example.com');
expect(result.requiresOAuth).toBe(true);
expect(result.method).toBe('401-challenge-metadata');
expect(result.metadata).toBeNull();
});
it('should detect OAuth when 401 has WWW-Authenticate: BEARER (uppercase)', async () => {
mockFetch.mockResolvedValueOnce({
status: 401,
headers: new Headers({ 'www-authenticate': 'BEARER' }),
} as Response);
const result = await detectOAuthRequirement('https://mcp.example.com');
expect(result.requiresOAuth).toBe(true);
expect(result.method).toBe('401-challenge-metadata');
});
it('should detect OAuth when Bearer is part of a larger header value', async () => {
mockFetch.mockResolvedValueOnce({
status: 401,
headers: new Headers({ 'www-authenticate': 'Bearer realm="api"' }),
} as Response);
const result = await detectOAuthRequirement('https://mcp.example.com');
expect(result.requiresOAuth).toBe(true);
});
it('should not detect OAuth when 401 has no WWW-Authenticate header', async () => {
mockFetch.mockResolvedValueOnce({
status: 401,
headers: new Headers(),
} as Response);
// POST also returns 401 without header
mockFetch.mockResolvedValueOnce({
status: 401,
headers: new Headers(),
} as Response);
const result = await detectOAuthRequirement('https://mcp.example.com');
expect(result.requiresOAuth).toBe(false);
expect(result.method).toBe('no-metadata-found');
});
it('should not detect OAuth when 401 has non-Bearer auth scheme', async () => {
mockFetch.mockResolvedValueOnce({
status: 401,
headers: new Headers({ 'www-authenticate': 'Basic realm="api"' }),
} as Response);
// POST also returns 401 with Basic
mockFetch.mockResolvedValueOnce({
status: 401,
headers: new Headers({ 'www-authenticate': 'Basic realm="api"' }),
} as Response);
const result = await detectOAuthRequirement('https://mcp.example.com');
expect(result.requiresOAuth).toBe(false);
});
});
describe('resource_metadata URL in WWW-Authenticate', () => {
it('should prefer resource_metadata URL when provided with Bearer', async () => {
const metadataUrl = 'https://auth.example.com/.well-known/oauth-protected-resource';
mockFetch
// HEAD request - 401 with resource_metadata URL
.mockResolvedValueOnce({
status: 401,
headers: new Headers({
'www-authenticate': `Bearer resource_metadata="${metadataUrl}"`,
}),
} as Response)
// Metadata fetch
.mockResolvedValueOnce({
ok: true,
json: async () => ({
authorization_servers: ['https://auth.example.com'],
}),
} as Response);
const result = await detectOAuthRequirement('https://mcp.example.com');
expect(result.requiresOAuth).toBe(true);
expect(result.method).toBe('401-challenge-metadata');
expect(result.metadata).toEqual({
authorization_servers: ['https://auth.example.com'],
});
});
it('should fall back to Bearer detection if metadata fetch fails', async () => {
const metadataUrl = 'https://auth.example.com/.well-known/oauth-protected-resource';
mockFetch
// HEAD request - 401 with resource_metadata URL
.mockResolvedValueOnce({
status: 401,
headers: new Headers({
'www-authenticate': `Bearer resource_metadata="${metadataUrl}"`,
}),
} as Response)
// Metadata fetch fails
.mockRejectedValueOnce(new Error('Network error'));
const result = await detectOAuthRequirement('https://mcp.example.com');
// Should still detect OAuth via Bearer
expect(result.requiresOAuth).toBe(true);
expect(result.metadata).toBeNull();
});
});
describe('StackOverflow-like server behavior', () => {
it('should detect OAuth for servers that return 405 for HEAD and 401+Bearer for POST', async () => {
// This mimics StackOverflow's actual behavior:
// HEAD -> 405 Method Not Allowed
// POST -> 401 with WWW-Authenticate: Bearer
mockFetch
// HEAD returns 405
.mockResolvedValueOnce({
status: 405,
headers: new Headers(),
} as Response)
// POST returns 401 with Bearer
.mockResolvedValueOnce({
status: 401,
headers: new Headers({ 'www-authenticate': 'Bearer' }),
} as Response);
const result = await detectOAuthRequirement('https://mcp.stackoverflow.com');
expect(result.requiresOAuth).toBe(true);
expect(result.method).toBe('401-challenge-metadata');
expect(result.metadata).toBeNull();
});
});
describe('error handling', () => {
it('should return no OAuth required when all checks fail', async () => {
mockFetch.mockRejectedValue(new Error('Network error'));
const result = await detectOAuthRequirement('https://unreachable.example.com');
expect(result.requiresOAuth).toBe(false);
expect(result.method).toBe('no-metadata-found');
});
it('should handle timeout gracefully', async () => {
mockFetch.mockImplementation(
() => new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), 100)),
);
const result = await detectOAuthRequirement('https://slow.example.com');
expect(result.requiresOAuth).toBe(false);
});
});
});

View file

@ -66,32 +66,81 @@ async function checkProtectedResourceMetadata(
}
}
// Checks for OAuth using 401 challenge with resource metadata URL
/**
* Checks for OAuth using 401 challenge with resource metadata URL or Bearer token.
* Tries HEAD first, then falls back to POST if HEAD doesn't return 401.
* Some servers (like StackOverflow) only return 401 for POST requests.
*/
async function check401ChallengeMetadata(serverUrl: string): Promise<OAuthDetectionResult | null> {
// Try HEAD first (lighter weight)
const headResult = await check401WithMethod(serverUrl, 'HEAD');
if (headResult) return headResult;
// Fall back to POST if HEAD didn't return 401 (some servers don't support HEAD)
const postResult = await check401WithMethod(serverUrl, 'POST');
if (postResult) return postResult;
return null;
}
async function check401WithMethod(
serverUrl: string,
method: 'HEAD' | 'POST',
): Promise<OAuthDetectionResult | null> {
try {
const response = await fetch(serverUrl, {
method: 'HEAD',
const fetchOptions: RequestInit = {
method,
signal: AbortSignal.timeout(mcpConfig.OAUTH_DETECTION_TIMEOUT),
});
};
// POST requests need headers and body for MCP servers
if (method === 'POST') {
fetchOptions.headers = { 'Content-Type': 'application/json' };
fetchOptions.body = JSON.stringify({});
}
const response = await fetch(serverUrl, fetchOptions);
if (response.status !== 401) return null;
const wwwAuth = response.headers.get('www-authenticate');
const metadataUrl = wwwAuth?.match(/resource_metadata="([^"]+)"/)?.[1];
if (!metadataUrl) return null;
const metadataResponse = await fetch(metadataUrl, {
signal: AbortSignal.timeout(mcpConfig.OAUTH_DETECTION_TIMEOUT),
});
const metadata = await metadataResponse.json();
if (metadataUrl) {
try {
// Try to fetch resource metadata from the provided URL
const metadataResponse = await fetch(metadataUrl, {
signal: AbortSignal.timeout(mcpConfig.OAUTH_DETECTION_TIMEOUT),
});
const metadata = await metadataResponse.json();
if (!metadata?.authorization_servers?.length) return null;
if (metadata?.authorization_servers?.length) {
return {
requiresOAuth: true,
method: '401-challenge-metadata',
metadata,
};
}
} catch {
// Metadata fetch failed, continue to Bearer check below
}
}
return {
requiresOAuth: true,
method: '401-challenge-metadata',
metadata,
};
/**
* If we got a 401 with WWW-Authenticate containing "Bearer" (case-insensitive),
* the server requires OAuth authentication even without discovery metadata.
* This handles "legacy" OAuth servers (like StackOverflow's MCP) that use standard
* OAuth endpoints (/authorize, /token, /register) without .well-known metadata.
*/
if (wwwAuth && /bearer/i.test(wwwAuth)) {
return {
requiresOAuth: true,
method: '401-challenge-metadata',
metadata: null,
};
}
return null;
} catch {
return null;
}

View file

@ -93,10 +93,37 @@ export class MCPOAuthHandler {
});
if (!rawMetadata) {
logger.error(
`[MCPOAuth] Failed to discover OAuth metadata from ${sanitizeUrlForLogging(authServerUrl)}`,
/**
* No metadata discovered - create fallback metadata using default OAuth endpoint paths.
* This mirrors the MCP SDK's behavior where it falls back to /authorize, /token, /register
* when metadata discovery fails (e.g., servers without .well-known endpoints).
* See: https://github.com/modelcontextprotocol/sdk/blob/main/src/client/auth.ts
*/
logger.warn(
`[MCPOAuth] No OAuth metadata discovered from ${sanitizeUrlForLogging(authServerUrl)}, using legacy fallback endpoints`,
);
throw new Error('Failed to discover OAuth metadata');
const fallbackMetadata: OAuthMetadata = {
issuer: authServerUrl.toString(),
authorization_endpoint: new URL('/authorize', authServerUrl).toString(),
token_endpoint: new URL('/token', authServerUrl).toString(),
registration_endpoint: new URL('/register', authServerUrl).toString(),
response_types_supported: ['code'],
grant_types_supported: ['authorization_code', 'refresh_token'],
code_challenge_methods_supported: ['S256', 'plain'],
token_endpoint_auth_methods_supported: [
'client_secret_basic',
'client_secret_post',
'none',
],
};
logger.debug(`[MCPOAuth] Using fallback metadata:`, fallbackMetadata);
return {
metadata: fallbackMetadata,
resourceMetadata,
authServerUrl,
};
}
logger.debug(`[MCPOAuth] OAuth metadata discovered successfully`);
@ -223,6 +250,23 @@ export class MCPOAuthHandler {
// Check if we have pre-configured OAuth settings
if (config?.authorization_url && config?.token_url && config?.client_id) {
logger.debug(`[MCPOAuth] Using pre-configured OAuth settings for ${serverName}`);
const skipCodeChallengeCheck =
config?.skip_code_challenge_check === true ||
process.env.MCP_SKIP_CODE_CHALLENGE_CHECK === 'true';
let codeChallengeMethodsSupported: string[];
if (config?.code_challenge_methods_supported !== undefined) {
codeChallengeMethodsSupported = config.code_challenge_methods_supported;
} else if (skipCodeChallengeCheck) {
codeChallengeMethodsSupported = ['S256', 'plain'];
logger.debug(
`[MCPOAuth] Code challenge check skip enabled, forcing S256 support for ${serverName}`,
);
} else {
codeChallengeMethodsSupported = ['S256', 'plain'];
}
/** Metadata based on pre-configured settings */
const metadata: OAuthMetadata = {
authorization_endpoint: config.authorization_url,
@ -238,10 +282,7 @@ export class MCPOAuthHandler {
'client_secret_post',
],
response_types_supported: config?.response_types_supported ?? ['code'],
code_challenge_methods_supported: config?.code_challenge_methods_supported ?? [
'S256',
'plain',
],
code_challenge_methods_supported: codeChallengeMethodsSupported,
};
logger.debug(`[MCPOAuth] metadata for "${serverName}": ${JSON.stringify(metadata)}`);
const clientInfo: OAuthClientInformation = {
@ -548,13 +589,21 @@ export class MCPOAuthHandler {
fetchFn: this.createOAuthFetch(oauthHeaders),
});
if (!oauthMetadata) {
throw new Error('Failed to discover OAuth metadata for token refresh');
}
if (!oauthMetadata.token_endpoint) {
/**
* No metadata discovered - use fallback /token endpoint.
* This mirrors the MCP SDK's behavior for legacy servers without .well-known endpoints.
*/
logger.warn(
`[MCPOAuth] No OAuth metadata discovered for token refresh, using fallback /token endpoint`,
);
tokenUrl = new URL('/token', metadata.serverUrl).toString();
authMethods = ['client_secret_basic', 'client_secret_post', 'none'];
} else if (!oauthMetadata.token_endpoint) {
throw new Error('No token endpoint found in OAuth metadata');
} else {
tokenUrl = oauthMetadata.token_endpoint;
authMethods = oauthMetadata.token_endpoint_auth_methods_supported;
}
tokenUrl = oauthMetadata.token_endpoint;
authMethods = oauthMetadata.token_endpoint_auth_methods_supported;
}
const body = new URLSearchParams({
@ -727,12 +776,20 @@ export class MCPOAuthHandler {
fetchFn: this.createOAuthFetch(oauthHeaders),
});
let tokenUrl: URL;
if (!oauthMetadata?.token_endpoint) {
throw new Error('No token endpoint found in OAuth metadata');
/**
* No metadata or token_endpoint discovered - use fallback /token endpoint.
* This mirrors the MCP SDK's behavior for legacy servers without .well-known endpoints.
*/
logger.warn(
`[MCPOAuth] No OAuth metadata or token endpoint found, using fallback /token endpoint`,
);
tokenUrl = new URL('/token', metadata.serverUrl);
} else {
tokenUrl = new URL(oauthMetadata.token_endpoint);
}
const tokenUrl = new URL(oauthMetadata.token_endpoint);
const body = new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,

View file

@ -18,6 +18,8 @@ export interface OAuthMetadata {
token_endpoint_auth_methods_supported?: string[];
/** Code challenge methods supported */
code_challenge_methods_supported?: string[];
/** Dynamic client registration endpoint (RFC 7591) */
registration_endpoint?: string;
/** Revocation endpoint */
revocation_endpoint?: string;
/** Revocation endpoint auth methods supported */

View file

@ -34,6 +34,9 @@ export class MCPServersInitializer {
public static async initialize(rawConfigs: t.MCPServers): Promise<void> {
if (await statusCache.isInitialized()) return;
/** Store raw configs immediately so they're available even if initialization fails/is slow */
registry.setRawConfigs(rawConfigs);
if (await isLeader()) {
// Leader performs initialization
await statusCache.reset();

View file

@ -13,12 +13,22 @@ import {
*
* Provides a unified interface for retrieving server configs with proper fallback hierarchy:
* checks shared app servers first, then shared user servers, then private user servers.
* Falls back to raw config when servers haven't been initialized yet or failed to initialize.
* Handles server lifecycle operations including adding, removing, and querying configurations.
*/
class MCPServersRegistry {
public readonly sharedAppServers = ServerConfigsCacheFactory.create('App', true);
public readonly sharedUserServers = ServerConfigsCacheFactory.create('User', true);
public readonly sharedAppServers = ServerConfigsCacheFactory.create('App', false);
public readonly sharedUserServers = ServerConfigsCacheFactory.create('User', false);
private readonly privateUserServers: Map<string | undefined, ServerConfigsCache> = new Map();
private rawConfigs: t.MCPServers = {};
/**
* Stores the raw MCP configuration as a fallback when servers haven't been initialized yet.
* Should be called during initialization before inspecting servers.
*/
public setRawConfigs(configs: t.MCPServers): void {
this.rawConfigs = configs;
}
public async addPrivateUserServer(
userId: string,
@ -59,15 +69,32 @@ class MCPServersRegistry {
const privateUserServer = await this.privateUserServers.get(userId)?.get(serverName);
if (privateUserServer) return privateUserServer;
/** Fallback to raw config if server hasn't been initialized yet */
const rawConfig = this.rawConfigs[serverName];
if (rawConfig) return rawConfig as t.ParsedServerConfig;
return undefined;
}
public async getAllServerConfigs(userId?: string): Promise<Record<string, t.ParsedServerConfig>> {
return {
const registryConfigs = {
...(await this.sharedAppServers.getAll()),
...(await this.sharedUserServers.getAll()),
...((await this.privateUserServers.get(userId)?.getAll()) ?? {}),
};
/** Include all raw configs, but registry configs take precedence (they have inspection data) */
const allConfigs: Record<string, t.ParsedServerConfig> = {};
for (const serverName in this.rawConfigs) {
allConfigs[serverName] = this.rawConfigs[serverName] as t.ParsedServerConfig;
}
/** Override with registry configs where available (they have richer data) */
for (const serverName in registryConfigs) {
allConfigs[serverName] = registryConfigs[serverName];
}
return allConfigs;
}
// TODO: This is currently used to determine if a server requires OAuth. However, this info can

View file

@ -1,16 +1,16 @@
import { z } from 'zod';
import {
Tools,
SSEOptionsSchema,
MCPOptionsSchema,
MCPServersSchema,
StdioOptionsSchema,
WebSocketOptionsSchema,
StreamableHTTPOptionsSchema,
Tools,
} from 'librechat-data-provider';
import type { SearchResultData, UIResource, TPlugin, TUser } from 'librechat-data-provider';
import type { SearchResultData, UIResource, TPlugin } from 'librechat-data-provider';
import type { TokenMethods, JsonSchemaType, IUser } from '@librechat/data-schemas';
import type * as t from '@modelcontextprotocol/sdk/types.js';
import type { TokenMethods, JsonSchemaType } from '@librechat/data-schemas';
import type { FlowStateManager } from '~/flow/manager';
import type { RequestBody } from '~/types/http';
import type * as o from '~/mcp/oauth/types';
@ -161,7 +161,7 @@ export interface BasicConnectionOptions {
}
export interface OAuthConnectionOptions {
user: TUser;
user: IUser;
useOAuth: true;
requestBody?: RequestBody;
customUserVars?: Record<string, string>;

View file

@ -1,3 +1,4 @@
export * from './access';
export * from './error';
export * from './balance';
export * from './json';

View file

@ -0,0 +1,158 @@
import { handleJsonParseError } from './json';
import type { Request, Response, NextFunction } from 'express';
describe('handleJsonParseError', () => {
let req: Partial<Request>;
let res: Partial<Response>;
let next: NextFunction;
let jsonSpy: jest.Mock;
let statusSpy: jest.Mock;
beforeEach(() => {
req = {
path: '/api/test',
method: 'POST',
ip: '127.0.0.1',
};
jsonSpy = jest.fn();
statusSpy = jest.fn().mockReturnValue({ json: jsonSpy });
res = {
status: statusSpy,
json: jsonSpy,
};
next = jest.fn();
});
describe('JSON parse errors', () => {
it('should handle JSON SyntaxError with 400 status', () => {
const err = new SyntaxError('Unexpected token < in JSON at position 0') as SyntaxError & {
status?: number;
body?: unknown;
};
err.status = 400;
err.body = {};
handleJsonParseError(err, req as Request, res as Response, next);
expect(statusSpy).toHaveBeenCalledWith(400);
expect(jsonSpy).toHaveBeenCalledWith({
error: 'Invalid JSON format',
message: 'The request body contains malformed JSON',
});
expect(next).not.toHaveBeenCalled();
});
it('should not reflect user input in error message', () => {
const maliciousInput = '<script>alert("xss")</script>';
const err = new SyntaxError(
`Unexpected token < in JSON at position 0: ${maliciousInput}`,
) as SyntaxError & {
status?: number;
body?: unknown;
};
err.status = 400;
err.body = maliciousInput;
handleJsonParseError(err, req as Request, res as Response, next);
expect(statusSpy).toHaveBeenCalledWith(400);
const errorResponse = jsonSpy.mock.calls[0][0];
expect(errorResponse.message).not.toContain(maliciousInput);
expect(errorResponse.message).toBe('The request body contains malformed JSON');
expect(next).not.toHaveBeenCalled();
});
it('should handle JSON parse error with HTML tags in body', () => {
const err = new SyntaxError('Invalid JSON') as SyntaxError & {
status?: number;
body?: unknown;
};
err.status = 400;
err.body = '<html><body><h1>XSS</h1></body></html>';
handleJsonParseError(err, req as Request, res as Response, next);
expect(statusSpy).toHaveBeenCalledWith(400);
const errorResponse = jsonSpy.mock.calls[0][0];
expect(errorResponse.message).not.toContain('<html>');
expect(errorResponse.message).not.toContain('<script>');
expect(next).not.toHaveBeenCalled();
});
});
describe('non-JSON errors', () => {
it('should pass through non-SyntaxError errors', () => {
const err = new Error('Some other error');
handleJsonParseError(err, req as Request, res as Response, next);
expect(next).toHaveBeenCalledWith(err);
expect(statusSpy).not.toHaveBeenCalled();
expect(jsonSpy).not.toHaveBeenCalled();
});
it('should pass through SyntaxError without status 400', () => {
const err = new SyntaxError('Some syntax error') as SyntaxError & { status?: number };
err.status = 500;
handleJsonParseError(err, req as Request, res as Response, next);
expect(next).toHaveBeenCalledWith(err);
expect(statusSpy).not.toHaveBeenCalled();
});
it('should pass through SyntaxError without body property', () => {
const err = new SyntaxError('Some syntax error') as SyntaxError & { status?: number };
err.status = 400;
handleJsonParseError(err, req as Request, res as Response, next);
expect(next).toHaveBeenCalledWith(err);
expect(statusSpy).not.toHaveBeenCalled();
});
it('should pass through TypeError', () => {
const err = new TypeError('Type error');
handleJsonParseError(err, req as Request, res as Response, next);
expect(next).toHaveBeenCalledWith(err);
expect(statusSpy).not.toHaveBeenCalled();
});
});
describe('security verification', () => {
it('should return generic error message for all JSON parse errors', () => {
const testCases = [
'Unexpected token < in JSON',
'Unexpected end of JSON input',
'Invalid or unexpected token',
'<script>alert(1)</script>',
'"><img src=x onerror=alert(1)>',
];
testCases.forEach((errorMsg) => {
const err = new SyntaxError(errorMsg) as SyntaxError & {
status?: number;
body?: unknown;
};
err.status = 400;
err.body = errorMsg;
jsonSpy.mockClear();
statusSpy.mockClear();
(next as jest.Mock).mockClear();
handleJsonParseError(err, req as Request, res as Response, next);
const errorResponse = jsonSpy.mock.calls[0][0];
// Verify the generic message is always returned, not the user input
expect(errorResponse.message).toBe('The request body contains malformed JSON');
expect(errorResponse.error).toBe('Invalid JSON format');
});
});
});
});

View file

@ -0,0 +1,40 @@
import { logger } from '@librechat/data-schemas';
import type { Request, Response, NextFunction } from 'express';
/**
* Middleware to handle JSON parsing errors from express.json()
* Prevents user input from being reflected in error messages (XSS prevention)
*
* This middleware should be placed immediately after express.json() middleware.
*
* @param err - Error object from express.json()
* @param req - Express request object
* @param res - Express response object
* @param next - Express next function
*
* @example
* app.use(express.json({ limit: '3mb' }));
* app.use(handleJsonParseError);
*/
export function handleJsonParseError(
err: Error & { status?: number; body?: unknown },
req: Request,
res: Response,
next: NextFunction,
): void {
if (err instanceof SyntaxError && err.status === 400 && 'body' in err) {
logger.warn('[JSON Parse Error] Invalid JSON received', {
path: req.path,
method: req.method,
ip: req.ip,
});
res.status(400).json({
error: 'Invalid JSON format',
message: 'The request body contains malformed JSON',
});
return;
}
next(err);
}

View file

@ -2,6 +2,7 @@ import { SystemCategories } from 'librechat-data-provider';
import type { IPromptGroupDocument as IPromptGroup } from '@librechat/data-schemas';
import type { Types } from 'mongoose';
import type { PromptGroupsListResponse } from '~/types';
import { escapeRegExp } from '~/utils/common';
/**
* Formats prompt groups for the paginated /groups endpoint response
@ -101,7 +102,6 @@ export function buildPromptGroupFilter({
// Handle name filter - convert to regex for case-insensitive search
if (name) {
const escapeRegExp = (str: string) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
filter.name = new RegExp(escapeRegExp(name), 'i');
}

View file

@ -1,2 +1,3 @@
export * from './format';
export * from './migration';
export * from './schemas';

View file

@ -0,0 +1,222 @@
import {
updatePromptGroupSchema,
validatePromptGroupUpdate,
safeValidatePromptGroupUpdate,
} from './schemas';
describe('updatePromptGroupSchema', () => {
describe('allowed fields', () => {
it('should accept valid name field', () => {
const result = updatePromptGroupSchema.safeParse({ name: 'Test Group' });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.name).toBe('Test Group');
}
});
it('should accept valid oneliner field', () => {
const result = updatePromptGroupSchema.safeParse({ oneliner: 'A short description' });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.oneliner).toBe('A short description');
}
});
it('should accept valid category field', () => {
const result = updatePromptGroupSchema.safeParse({ category: 'testing' });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.category).toBe('testing');
}
});
it('should accept valid projectIds array', () => {
const result = updatePromptGroupSchema.safeParse({
projectIds: ['proj1', 'proj2'],
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.projectIds).toEqual(['proj1', 'proj2']);
}
});
it('should accept valid removeProjectIds array', () => {
const result = updatePromptGroupSchema.safeParse({
removeProjectIds: ['proj1'],
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.removeProjectIds).toEqual(['proj1']);
}
});
it('should accept valid command field', () => {
const result = updatePromptGroupSchema.safeParse({ command: 'my-command-123' });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.command).toBe('my-command-123');
}
});
it('should accept null command field', () => {
const result = updatePromptGroupSchema.safeParse({ command: null });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.command).toBeNull();
}
});
it('should accept multiple valid fields', () => {
const input = {
name: 'Updated Name',
category: 'new-category',
oneliner: 'New description',
};
const result = updatePromptGroupSchema.safeParse(input);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toEqual(input);
}
});
it('should accept empty object', () => {
const result = updatePromptGroupSchema.safeParse({});
expect(result.success).toBe(true);
});
});
describe('security - strips sensitive fields', () => {
it('should reject author field', () => {
const result = updatePromptGroupSchema.safeParse({
name: 'Test',
author: '507f1f77bcf86cd799439011',
});
expect(result.success).toBe(false);
});
it('should reject authorName field', () => {
const result = updatePromptGroupSchema.safeParse({
name: 'Test',
authorName: 'Malicious Author',
});
expect(result.success).toBe(false);
});
it('should reject _id field', () => {
const result = updatePromptGroupSchema.safeParse({
name: 'Test',
_id: '507f1f77bcf86cd799439011',
});
expect(result.success).toBe(false);
});
it('should reject productionId field', () => {
const result = updatePromptGroupSchema.safeParse({
name: 'Test',
productionId: '507f1f77bcf86cd799439011',
});
expect(result.success).toBe(false);
});
it('should reject createdAt field', () => {
const result = updatePromptGroupSchema.safeParse({
name: 'Test',
createdAt: new Date().toISOString(),
});
expect(result.success).toBe(false);
});
it('should reject updatedAt field', () => {
const result = updatePromptGroupSchema.safeParse({
name: 'Test',
updatedAt: new Date().toISOString(),
});
expect(result.success).toBe(false);
});
it('should reject __v field', () => {
const result = updatePromptGroupSchema.safeParse({
name: 'Test',
__v: 999,
});
expect(result.success).toBe(false);
});
it('should reject multiple sensitive fields in a single request', () => {
const result = updatePromptGroupSchema.safeParse({
name: 'Legit Name',
author: '507f1f77bcf86cd799439011',
authorName: 'Hacker',
_id: 'newid123',
productionId: 'prodid456',
createdAt: '2020-01-01T00:00:00.000Z',
__v: 999,
});
expect(result.success).toBe(false);
});
});
describe('validation rules', () => {
it('should reject empty name', () => {
const result = updatePromptGroupSchema.safeParse({ name: '' });
expect(result.success).toBe(false);
});
it('should reject name exceeding max length', () => {
const result = updatePromptGroupSchema.safeParse({ name: 'a'.repeat(256) });
expect(result.success).toBe(false);
});
it('should reject oneliner exceeding max length', () => {
const result = updatePromptGroupSchema.safeParse({ oneliner: 'a'.repeat(501) });
expect(result.success).toBe(false);
});
it('should reject category exceeding max length', () => {
const result = updatePromptGroupSchema.safeParse({ category: 'a'.repeat(101) });
expect(result.success).toBe(false);
});
it('should reject command with invalid characters (uppercase)', () => {
const result = updatePromptGroupSchema.safeParse({ command: 'MyCommand' });
expect(result.success).toBe(false);
});
it('should reject command with invalid characters (spaces)', () => {
const result = updatePromptGroupSchema.safeParse({ command: 'my command' });
expect(result.success).toBe(false);
});
it('should reject command with invalid characters (special)', () => {
const result = updatePromptGroupSchema.safeParse({ command: 'my_command!' });
expect(result.success).toBe(false);
});
});
});
describe('validatePromptGroupUpdate', () => {
it('should return validated data for valid input', () => {
const input = { name: 'Test', category: 'testing' };
const result = validatePromptGroupUpdate(input);
expect(result).toEqual(input);
});
it('should throw ZodError for invalid input', () => {
expect(() => validatePromptGroupUpdate({ author: 'malicious-id' })).toThrow();
});
});
describe('safeValidatePromptGroupUpdate', () => {
it('should return success true for valid input', () => {
const result = safeValidatePromptGroupUpdate({ name: 'Test' });
expect(result.success).toBe(true);
});
it('should return success false for invalid input with errors', () => {
const result = safeValidatePromptGroupUpdate({ author: 'malicious-id' });
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.errors.length).toBeGreaterThan(0);
}
});
});

View file

@ -0,0 +1,53 @@
import { z } from 'zod';
import { Constants } from 'librechat-data-provider';
/**
* Schema for validating prompt group update payloads.
* Only allows fields that users should be able to modify.
* Sensitive fields like author, authorName, _id, productionId, etc. are excluded.
*/
export const updatePromptGroupSchema = z
.object({
/** The name of the prompt group */
name: z.string().min(1).max(255).optional(),
/** Short description/oneliner for the prompt group */
oneliner: z.string().max(500).optional(),
/** Category for organizing prompt groups */
category: z.string().max(100).optional(),
/** Project IDs to add for sharing */
projectIds: z.array(z.string()).optional(),
/** Project IDs to remove from sharing */
removeProjectIds: z.array(z.string()).optional(),
/** Command shortcut for the prompt group */
command: z
.string()
.max(Constants.COMMANDS_MAX_LENGTH as number)
.regex(/^[a-z0-9-]*$/, {
message: 'Command must only contain lowercase alphanumeric characters and hyphens',
})
.optional()
.nullable(),
})
.strict();
export type TUpdatePromptGroupSchema = z.infer<typeof updatePromptGroupSchema>;
/**
* Validates and sanitizes a prompt group update payload.
* Returns only the allowed fields, stripping any sensitive fields.
* @param data - The raw request body to validate
* @returns The validated and sanitized payload
* @throws ZodError if validation fails
*/
export function validatePromptGroupUpdate(data: unknown): TUpdatePromptGroupSchema {
return updatePromptGroupSchema.parse(data);
}
/**
* Safely validates a prompt group update payload without throwing.
* @param data - The raw request body to validate
* @returns A SafeParseResult with either the validated data or validation errors
*/
export function safeValidatePromptGroupUpdate(data: unknown) {
return updatePromptGroupSchema.safeParse(data);
}

View file

@ -61,7 +61,7 @@ export interface AnthropicDocumentBlock {
/** Google document block format */
export interface GoogleDocumentBlock {
type: 'document';
type: 'media';
mimeType: string;
data: string;
}

View file

@ -48,3 +48,12 @@ export function optionalChainWithEmptyCheck(
}
return values[values.length - 1];
}
/**
* Escapes special characters in a string for use in a regular expression.
* @param str - The string to escape.
* @returns The escaped string safe for use in RegExp.
*/
export function escapeRegExp(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

View file

@ -1,6 +1,8 @@
import { resolveHeaders, processMCPEnv } from './env';
import { TokenExchangeMethodEnum } from 'librechat-data-provider';
import type { TUser, MCPOptions } from 'librechat-data-provider';
import { resolveHeaders, resolveNestedObject, processMCPEnv } from './env';
import type { MCPOptions } from 'librechat-data-provider';
import type { IUser } from '@librechat/data-schemas';
import { Types } from 'mongoose';
function isStdioOptions(options: MCPOptions): options is Extract<MCPOptions, { type?: 'stdio' }> {
return !options.type || options.type === 'stdio';
@ -13,19 +15,21 @@ function isStreamableHTTPOptions(
}
/** Helper function to create test user objects */
function createTestUser(overrides: Partial<TUser> = {}): TUser {
function createTestUser(overrides: Partial<IUser> = {}): IUser {
return {
id: 'test-user-id',
_id: new Types.ObjectId(),
id: new Types.ObjectId().toString(),
username: 'testuser',
email: 'test@example.com',
name: 'Test User',
avatar: 'https://example.com/avatar.png',
provider: 'email',
role: 'user',
createdAt: new Date('2021-01-01').toISOString(),
updatedAt: new Date('2021-01-01').toISOString(),
createdAt: new Date('2021-01-01'),
updatedAt: new Date('2021-01-01'),
emailVerified: true,
...overrides,
};
} as IUser;
}
describe('resolveHeaders', () => {
@ -445,6 +449,428 @@ describe('resolveHeaders', () => {
const result = resolveHeaders({ headers, body });
expect(result['X-Conversation']).toBe('conv-123');
});
describe('non-string header values (type guard tests)', () => {
it('should handle numeric header values without crashing', () => {
const headers = {
'X-Number': 12345 as unknown as string,
'X-String': 'normal-string',
};
const result = resolveHeaders({ headers });
expect(result['X-Number']).toBe('12345');
expect(result['X-String']).toBe('normal-string');
});
it('should handle boolean header values without crashing', () => {
const headers = {
'X-Boolean-True': true as unknown as string,
'X-Boolean-False': false as unknown as string,
'X-String': 'normal-string',
};
const result = resolveHeaders({ headers });
expect(result['X-Boolean-True']).toBe('true');
expect(result['X-Boolean-False']).toBe('false');
expect(result['X-String']).toBe('normal-string');
});
it('should handle null and undefined header values', () => {
const headers = {
'X-Null': null as unknown as string,
'X-Undefined': undefined as unknown as string,
'X-String': 'normal-string',
};
const result = resolveHeaders({ headers });
expect(result['X-Null']).toBe('null');
expect(result['X-Undefined']).toBe('undefined');
expect(result['X-String']).toBe('normal-string');
});
it('should handle numeric values with placeholders', () => {
const user = { id: 'user-123' };
const headers = {
'X-Number': 42 as unknown as string,
'X-String-With-Placeholder': '{{LIBRECHAT_USER_ID}}',
};
const result = resolveHeaders({ headers, user });
expect(result['X-Number']).toBe('42');
expect(result['X-String-With-Placeholder']).toBe('user-123');
});
it('should handle objects in header values', () => {
const headers = {
'X-Object': { nested: 'value' } as unknown as string,
'X-String': 'normal-string',
};
const result = resolveHeaders({ headers });
expect(result['X-Object']).toBe('[object Object]');
expect(result['X-String']).toBe('normal-string');
});
it('should handle arrays in header values', () => {
const headers = {
'X-Array': ['value1', 'value2'] as unknown as string,
'X-String': 'normal-string',
};
const result = resolveHeaders({ headers });
expect(result['X-Array']).toBe('value1,value2');
expect(result['X-String']).toBe('normal-string');
});
it('should handle numeric values with env variables', () => {
process.env.TEST_API_KEY = 'test-api-key-value';
const headers = {
'X-Number': 12345 as unknown as string,
'X-Env': '${TEST_API_KEY}',
};
const result = resolveHeaders({ headers });
expect(result['X-Number']).toBe('12345');
expect(result['X-Env']).toBe('test-api-key-value');
delete process.env.TEST_API_KEY;
});
it('should handle numeric values with body placeholders', () => {
const body = {
conversationId: 'conv-123',
parentMessageId: 'parent-456',
messageId: 'msg-789',
};
const headers = {
'X-Number': 999 as unknown as string,
'X-Conv': '{{LIBRECHAT_BODY_CONVERSATIONID}}',
};
const result = resolveHeaders({ headers, body });
expect(result['X-Number']).toBe('999');
expect(result['X-Conv']).toBe('conv-123');
});
it('should handle mixed type headers with user and custom vars', () => {
const user = { id: 'user-123', email: 'test@example.com' };
const customUserVars = { CUSTOM_TOKEN: 'secret-token' };
const headers = {
'X-Number': 42 as unknown as string,
'X-Boolean': true as unknown as string,
'X-User-Id': '{{LIBRECHAT_USER_ID}}',
'X-Custom': '{{CUSTOM_TOKEN}}',
'X-String': 'normal',
};
const result = resolveHeaders({ headers, user, customUserVars });
expect(result['X-Number']).toBe('42');
expect(result['X-Boolean']).toBe('true');
expect(result['X-User-Id']).toBe('user-123');
expect(result['X-Custom']).toBe('secret-token');
expect(result['X-String']).toBe('normal');
});
it('should not crash when calling includes on non-string body field values', () => {
const body = {
conversationId: 12345 as unknown as string,
parentMessageId: 'parent-456',
messageId: 'msg-789',
};
const headers = {
'X-Conv-Id': '{{LIBRECHAT_BODY_CONVERSATIONID}}',
'X-Number': 999 as unknown as string,
};
expect(() => resolveHeaders({ headers, body })).not.toThrow();
const result = resolveHeaders({ headers, body });
expect(result['X-Number']).toBe('999');
});
});
});
describe('resolveNestedObject', () => {
beforeEach(() => {
process.env.TEST_API_KEY = 'test-api-key-value';
process.env.ANOTHER_SECRET = 'another-secret-value';
});
afterEach(() => {
delete process.env.TEST_API_KEY;
delete process.env.ANOTHER_SECRET;
});
it('should preserve nested object structure', () => {
const obj = {
thinking: {
type: 'enabled',
budget_tokens: 2000,
},
anthropic_beta: ['output-128k-2025-02-19'],
max_tokens: 4096,
temperature: 0.7,
};
const result = resolveNestedObject({ obj });
expect(result).toEqual({
thinking: {
type: 'enabled',
budget_tokens: 2000,
},
anthropic_beta: ['output-128k-2025-02-19'],
max_tokens: 4096,
temperature: 0.7,
});
});
it('should process placeholders in string values while preserving structure', () => {
const user = { id: 'user-123', email: 'test@example.com' };
const obj = {
thinking: {
type: 'enabled',
budget_tokens: 2000,
user_context: '{{LIBRECHAT_USER_ID}}',
},
anthropic_beta: ['output-128k-2025-02-19'],
api_key: '${TEST_API_KEY}',
max_tokens: 4096,
};
const result = resolveNestedObject({ obj, user });
expect(result).toEqual({
thinking: {
type: 'enabled',
budget_tokens: 2000,
user_context: 'user-123',
},
anthropic_beta: ['output-128k-2025-02-19'],
api_key: 'test-api-key-value',
max_tokens: 4096,
});
});
it('should process strings in arrays', () => {
const user = { id: 'user-123' };
const obj = {
headers: ['Authorization: Bearer ${TEST_API_KEY}', 'X-User-Id: {{LIBRECHAT_USER_ID}}'],
values: [1, 2, 3],
mixed: ['string', 42, true, '{{LIBRECHAT_USER_ID}}'],
};
const result = resolveNestedObject({ obj, user });
expect(result).toEqual({
headers: ['Authorization: Bearer test-api-key-value', 'X-User-Id: user-123'],
values: [1, 2, 3],
mixed: ['string', 42, true, 'user-123'],
});
});
it('should handle deeply nested structures', () => {
const user = { id: 'user-123' };
const obj = {
level1: {
level2: {
level3: {
user_id: '{{LIBRECHAT_USER_ID}}',
settings: {
api_key: '${TEST_API_KEY}',
enabled: true,
},
},
},
},
};
const result = resolveNestedObject({ obj, user });
expect(result).toEqual({
level1: {
level2: {
level3: {
user_id: 'user-123',
settings: {
api_key: 'test-api-key-value',
enabled: true,
},
},
},
},
});
});
it('should preserve all primitive types', () => {
const obj = {
string: 'text',
number: 42,
float: 3.14,
boolean_true: true,
boolean_false: false,
null_value: null,
undefined_value: undefined,
};
const result = resolveNestedObject({ obj });
expect(result).toEqual(obj);
});
it('should handle empty objects and arrays', () => {
const obj = {
empty_object: {},
empty_array: [],
nested: {
also_empty: {},
},
};
const result = resolveNestedObject({ obj });
expect(result).toEqual(obj);
});
it('should handle body placeholders in nested objects', () => {
const body = {
conversationId: 'conv-123',
parentMessageId: 'parent-456',
messageId: 'msg-789',
};
const obj = {
metadata: {
conversation: '{{LIBRECHAT_BODY_CONVERSATIONID}}',
parent: '{{LIBRECHAT_BODY_PARENTMESSAGEID}}',
count: 5,
},
};
const result = resolveNestedObject({ obj, body });
expect(result).toEqual({
metadata: {
conversation: 'conv-123',
parent: 'parent-456',
count: 5,
},
});
});
it('should handle custom user variables in nested objects', () => {
const customUserVars = {
CUSTOM_TOKEN: 'secret-token',
REGION: 'us-west-1',
};
const obj = {
auth: {
token: '{{CUSTOM_TOKEN}}',
region: '{{REGION}}',
timeout: 3000,
},
};
const result = resolveNestedObject({ obj, customUserVars });
expect(result).toEqual({
auth: {
token: 'secret-token',
region: 'us-west-1',
timeout: 3000,
},
});
});
it('should handle mixed placeholders in nested objects', () => {
const user = { id: 'user-123', email: 'test@example.com' };
const customUserVars = { CUSTOM_VAR: 'custom-value' };
const body = { conversationId: 'conv-456' };
const obj = {
config: {
user_id: '{{LIBRECHAT_USER_ID}}',
custom: '{{CUSTOM_VAR}}',
api_key: '${TEST_API_KEY}',
conversation: '{{LIBRECHAT_BODY_CONVERSATIONID}}',
nested: {
email: '{{LIBRECHAT_USER_EMAIL}}',
port: 8080,
},
},
};
const result = resolveNestedObject({ obj, user, customUserVars, body });
expect(result).toEqual({
config: {
user_id: 'user-123',
custom: 'custom-value',
api_key: 'test-api-key-value',
conversation: 'conv-456',
nested: {
email: 'test@example.com',
port: 8080,
},
},
});
});
it('should handle Bedrock additionalModelRequestFields example', () => {
const obj = {
thinking: {
type: 'enabled',
budget_tokens: 2000,
},
anthropic_beta: ['output-128k-2025-02-19'],
};
const result = resolveNestedObject({ obj });
expect(result).toEqual({
thinking: {
type: 'enabled',
budget_tokens: 2000,
},
anthropic_beta: ['output-128k-2025-02-19'],
});
expect(typeof result.thinking).toBe('object');
expect(Array.isArray(result.anthropic_beta)).toBe(true);
expect(result.thinking).not.toBe('[object Object]');
});
it('should return undefined when obj is undefined', () => {
const result = resolveNestedObject({ obj: undefined });
expect(result).toBeUndefined();
});
it('should return null when obj is null', () => {
const result = resolveNestedObject({ obj: null });
expect(result).toBeNull();
});
it('should handle arrays of objects', () => {
const user = { id: 'user-123' };
const obj = {
items: [
{ name: 'item1', user: '{{LIBRECHAT_USER_ID}}', count: 1 },
{ name: 'item2', user: '{{LIBRECHAT_USER_ID}}', count: 2 },
],
};
const result = resolveNestedObject({ obj, user });
expect(result).toEqual({
items: [
{ name: 'item1', user: 'user-123', count: 1 },
{ name: 'item2', user: 'user-123', count: 2 },
],
});
});
it('should not modify the original object', () => {
const user = { id: 'user-123' };
const originalObj = {
thinking: {
type: 'enabled',
budget_tokens: 2000,
user_id: '{{LIBRECHAT_USER_ID}}',
},
};
const result = resolveNestedObject({ obj: originalObj, user });
expect(result.thinking.user_id).toBe('user-123');
expect(originalObj.thinking.user_id).toBe('{{LIBRECHAT_USER_ID}}');
});
});
describe('processMCPEnv', () => {
@ -774,4 +1200,181 @@ describe('processMCPEnv', () => {
throw new Error('Expected stdio options');
}
});
describe('non-string values (type guard tests)', () => {
it('should handle numeric values in env without crashing', () => {
const options: MCPOptions = {
type: 'stdio',
command: 'mcp-server',
args: [],
env: {
PORT: 8080 as unknown as string,
TIMEOUT: 30000 as unknown as string,
API_KEY: '${TEST_API_KEY}',
},
};
const result = processMCPEnv({ options });
if (isStdioOptions(result)) {
expect(result.env?.PORT).toBe('8080');
expect(result.env?.TIMEOUT).toBe('30000');
expect(result.env?.API_KEY).toBe('test-api-key-value');
}
});
it('should handle boolean values in env without crashing', () => {
const options: MCPOptions = {
type: 'stdio',
command: 'mcp-server',
args: [],
env: {
DEBUG: true as unknown as string,
PRODUCTION: false as unknown as string,
API_KEY: '${TEST_API_KEY}',
},
};
const result = processMCPEnv({ options });
if (isStdioOptions(result)) {
expect(result.env?.DEBUG).toBe('true');
expect(result.env?.PRODUCTION).toBe('false');
expect(result.env?.API_KEY).toBe('test-api-key-value');
}
});
it('should handle numeric values in args without crashing', () => {
const options: MCPOptions = {
type: 'stdio',
command: 'mcp-server',
args: ['--port', 8080 as unknown as string, '--timeout', 30000 as unknown as string],
};
const result = processMCPEnv({ options });
if (isStdioOptions(result)) {
expect(result.args).toEqual(['--port', '8080', '--timeout', '30000']);
}
});
it('should handle null and undefined values in env', () => {
const options: MCPOptions = {
type: 'stdio',
command: 'mcp-server',
args: [],
env: {
NULL_VALUE: null as unknown as string,
UNDEFINED_VALUE: undefined as unknown as string,
NORMAL_VALUE: 'normal',
},
};
const result = processMCPEnv({ options });
if (isStdioOptions(result)) {
expect(result.env?.NULL_VALUE).toBe('null');
expect(result.env?.UNDEFINED_VALUE).toBe('undefined');
expect(result.env?.NORMAL_VALUE).toBe('normal');
}
});
it('should handle numeric values in headers without crashing', () => {
const options: MCPOptions = {
type: 'streamable-http',
url: 'https://api.example.com',
headers: {
'X-Timeout': 5000 as unknown as string,
'X-Retry-Count': 3 as unknown as string,
'Content-Type': 'application/json',
},
};
const result = processMCPEnv({ options });
if (isStreamableHTTPOptions(result)) {
expect(result.headers?.['X-Timeout']).toBe('5000');
expect(result.headers?.['X-Retry-Count']).toBe('3');
expect(result.headers?.['Content-Type']).toBe('application/json');
}
});
it('should handle numeric URL values', () => {
const options: MCPOptions = {
type: 'websocket',
url: 12345 as unknown as string,
};
const result = processMCPEnv({ options });
expect((result as unknown as { url?: string }).url).toBe('12345');
});
it('should handle mixed numeric and placeholder values', () => {
const user = createTestUser({ id: 'user-123' });
const options: MCPOptions = {
type: 'stdio',
command: 'mcp-server',
args: [],
env: {
PORT: 8080 as unknown as string,
USER_ID: '{{LIBRECHAT_USER_ID}}',
API_KEY: '${TEST_API_KEY}',
},
};
const result = processMCPEnv({ options, user });
if (isStdioOptions(result)) {
expect(result.env?.PORT).toBe('8080');
expect(result.env?.USER_ID).toBe('user-123');
expect(result.env?.API_KEY).toBe('test-api-key-value');
}
});
it('should handle objects and arrays in env values', () => {
const options: MCPOptions = {
type: 'stdio',
command: 'mcp-server',
args: [],
env: {
OBJECT_VALUE: { nested: 'value' } as unknown as string,
ARRAY_VALUE: ['item1', 'item2'] as unknown as string,
STRING_VALUE: 'normal',
},
};
const result = processMCPEnv({ options });
if (isStdioOptions(result)) {
expect(result.env?.OBJECT_VALUE).toBe('[object Object]');
expect(result.env?.ARRAY_VALUE).toBe('item1,item2');
expect(result.env?.STRING_VALUE).toBe('normal');
}
});
it('should not crash with numeric body field values', () => {
const body = {
conversationId: 12345 as unknown as string,
parentMessageId: 'parent-456',
messageId: 'msg-789',
};
const options: MCPOptions = {
type: 'stdio',
command: 'mcp-server',
args: [],
env: {
CONV_ID: '{{LIBRECHAT_BODY_CONVERSATIONID}}',
PORT: 8080 as unknown as string,
},
};
expect(() => processMCPEnv({ options, body })).not.toThrow();
const result = processMCPEnv({ options, body });
if (isStdioOptions(result)) {
expect(result.env?.PORT).toBe('8080');
}
});
});
});

View file

@ -1,7 +1,8 @@
import { extractEnvVariable } from 'librechat-data-provider';
import type { TUser, MCPOptions } from 'librechat-data-provider';
import type { MCPOptions } from 'librechat-data-provider';
import type { IUser } from '@librechat/data-schemas';
import type { RequestBody } from '~/types';
import { extractOpenIDTokenInfo, processOpenIDPlaceholders, isOpenIDTokenValid } from './oidc';
/**
* List of allowed user fields that can be used in MCP environment variables.
@ -32,23 +33,29 @@ type SafeUser = Pick<IUser, AllowedUserField>;
/**
* Creates a safe user object containing only allowed fields.
* Optimized for performance while maintaining type safety.
* Preserves federatedTokens for OpenID token template variable resolution.
*
* @param user - The user object to extract safe fields from
* @returns A new object containing only allowed fields
* @returns A new object containing only allowed fields plus federatedTokens if present
*/
export function createSafeUser(user: IUser | null | undefined): Partial<SafeUser> {
export function createSafeUser(
user: IUser | null | undefined,
): Partial<SafeUser> & { federatedTokens?: unknown } {
if (!user) {
return {};
}
const safeUser: Partial<SafeUser> = {};
const safeUser: Partial<SafeUser> & { federatedTokens?: unknown } = {};
for (const field of ALLOWED_USER_FIELDS) {
if (field in user) {
safeUser[field] = user[field];
}
}
if ('federatedTokens' in user) {
safeUser.federatedTokens = user.federatedTokens;
}
return safeUser;
}
@ -64,18 +71,19 @@ const ALLOWED_BODY_FIELDS = ['conversationId', 'parentMessageId', 'messageId'] a
* @param user - The user object
* @returns The processed string with placeholders replaced
*/
function processUserPlaceholders(value: string, user?: TUser): string {
function processUserPlaceholders(value: string, user?: IUser): string {
if (!user || typeof value !== 'string') {
return value;
}
for (const field of ALLOWED_USER_FIELDS) {
const placeholder = `{{LIBRECHAT_USER_${field.toUpperCase()}}}`;
if (!value.includes(placeholder)) {
if (typeof value !== 'string' || !value.includes(placeholder)) {
continue;
}
const fieldValue = user[field as keyof TUser];
const fieldValue = user[field as keyof IUser];
// Skip replacement if field doesn't exist in user object
if (!(field in user)) {
@ -104,6 +112,11 @@ function processUserPlaceholders(value: string, user?: TUser): string {
* @returns The processed string with placeholders replaced
*/
function processBodyPlaceholders(value: string, body: RequestBody): string {
// Type guard: ensure value is a string
if (typeof value !== 'string') {
return value;
}
for (const field of ALLOWED_BODY_FIELDS) {
const placeholder = `{{LIBRECHAT_BODY_${field.toUpperCase()}}}`;
if (!value.includes(placeholder)) {
@ -134,12 +147,16 @@ function processSingleValue({
}: {
originalValue: string;
customUserVars?: Record<string, string>;
user?: TUser;
user?: IUser;
body?: RequestBody;
}): string {
// Type guard: ensure we're working with a string
if (typeof originalValue !== 'string') {
return String(originalValue);
}
let value = originalValue;
// 1. Replace custom user variables
if (customUserVars) {
for (const [varName, varVal] of Object.entries(customUserVars)) {
/** Escaped varName for use in regex to avoid issues with special characters */
@ -149,15 +166,17 @@ function processSingleValue({
}
}
// 2. Replace user field placeholders (e.g., {{LIBRECHAT_USER_EMAIL}}, {{LIBRECHAT_USER_ID}})
value = processUserPlaceholders(value, user);
// 3. Replace body field placeholders (e.g., {{LIBRECHAT_BODY_CONVERSATIONID}}, {{LIBRECHAT_BODY_PARENTMESSAGEID}})
const openidTokenInfo = extractOpenIDTokenInfo(user);
if (openidTokenInfo && isOpenIDTokenValid(openidTokenInfo)) {
value = processOpenIDPlaceholders(value, openidTokenInfo);
}
if (body) {
value = processBodyPlaceholders(value, body);
}
// 4. Replace system environment variables
value = extractEnvVariable(value);
return value;
@ -174,7 +193,7 @@ function processSingleValue({
*/
export function processMCPEnv(params: {
options: Readonly<MCPOptions>;
user?: TUser;
user?: IUser;
customUserVars?: Record<string, string>;
body?: RequestBody;
}): MCPOptions {
@ -219,7 +238,7 @@ export function processMCPEnv(params: {
// Process OAuth configuration if it exists (for all transport types)
if ('oauth' in newObj && newObj.oauth) {
const processedOAuth: Record<string, string | string[] | undefined> = {};
const processedOAuth: Record<string, boolean | string | string[] | undefined> = {};
for (const [key, originalValue] of Object.entries(newObj.oauth)) {
// Only process string values for environment variables
// token_exchange_method is an enum and shouldn't be processed
@ -235,6 +254,74 @@ export function processMCPEnv(params: {
return newObj;
}
/**
* Recursively processes a value, replacing placeholders in strings while preserving structure
* @param value - The value to process (can be string, number, boolean, array, object, etc.)
* @param options - Processing options
* @returns The processed value with the same structure
*/
function processValue(
value: unknown,
options: {
customUserVars?: Record<string, string>;
user?: IUser;
body?: RequestBody;
},
): unknown {
if (typeof value === 'string') {
return processSingleValue({
originalValue: value,
customUserVars: options.customUserVars,
user: options.user,
body: options.body,
});
}
if (Array.isArray(value)) {
return value.map((item) => processValue(item, options));
}
if (value !== null && typeof value === 'object') {
const processed: Record<string, unknown> = {};
for (const [key, val] of Object.entries(value)) {
processed[key] = processValue(val, options);
}
return processed;
}
return value;
}
/**
* Recursively resolves placeholders in a nested object structure while preserving types.
* Only processes string values - leaves numbers, booleans, arrays, and nested objects intact.
*
* @param options - Configuration object
* @param options.obj - The object to process
* @param options.user - Optional user object for replacing user field placeholders
* @param options.body - Optional request body object for replacing body field placeholders
* @param options.customUserVars - Optional custom user variables to replace placeholders
* @returns The processed object with placeholders replaced in string values
*/
export function resolveNestedObject<T = unknown>(options?: {
obj: T | undefined;
user?: Partial<IUser> | { id: string };
body?: RequestBody;
customUserVars?: Record<string, string>;
}): T {
const { obj, user, body, customUserVars } = options ?? {};
if (!obj) {
return obj as T;
}
return processValue(obj, {
customUserVars,
user: user as IUser,
body,
}) as T;
}
/**
* Resolves header values by replacing user placeholders, body variables, custom variables, and environment variables.
*
@ -247,7 +334,7 @@ export function processMCPEnv(params: {
*/
export function resolveHeaders(options?: {
headers: Record<string, string> | undefined;
user?: Partial<TUser> | { id: string };
user?: Partial<IUser> | { id: string };
body?: RequestBody;
customUserVars?: Record<string, string>;
}) {
@ -261,7 +348,7 @@ export function resolveHeaders(options?: {
resolvedHeaders[key] = processSingleValue({
originalValue: inputHeaders[key],
customUserVars,
user: user as TUser,
user: user as IUser,
body,
});
});

View file

@ -7,6 +7,7 @@ export * from './env';
export * from './events';
export * from './files';
export * from './generators';
export * from './path';
export * from './key';
export * from './latex';
export * from './llm';
@ -16,7 +17,8 @@ export * from './promise';
export * from './sanitizeTitle';
export * from './tempChatRetention';
export * from './text';
export { default as Tokenizer } from './tokenizer';
export { default as Tokenizer, countTokens } from './tokenizer';
export * from './yaml';
export * from './http';
export * from './tokens';
export * from './message';

View file

@ -0,0 +1,122 @@
import { sanitizeFileForTransmit, sanitizeMessageForTransmit } from './message';
describe('sanitizeFileForTransmit', () => {
it('should remove text field from file', () => {
const file = {
file_id: 'test-123',
filename: 'test.txt',
text: 'This is a very long text content that should be stripped',
bytes: 1000,
};
const result = sanitizeFileForTransmit(file);
expect(result.file_id).toBe('test-123');
expect(result.filename).toBe('test.txt');
expect(result.bytes).toBe(1000);
expect(result).not.toHaveProperty('text');
});
it('should remove _id and __v fields', () => {
const file = {
file_id: 'test-123',
_id: 'mongo-id',
__v: 0,
filename: 'test.txt',
};
const result = sanitizeFileForTransmit(file);
expect(result.file_id).toBe('test-123');
expect(result).not.toHaveProperty('_id');
expect(result).not.toHaveProperty('__v');
});
it('should not modify original file object', () => {
const file = {
file_id: 'test-123',
text: 'original text',
};
sanitizeFileForTransmit(file);
expect(file.text).toBe('original text');
});
});
describe('sanitizeMessageForTransmit', () => {
it('should remove fileContext from message', () => {
const message = {
messageId: 'msg-123',
text: 'Hello world',
fileContext: 'This is a very long context that should be stripped',
};
const result = sanitizeMessageForTransmit(message);
expect(result.messageId).toBe('msg-123');
expect(result.text).toBe('Hello world');
expect(result).not.toHaveProperty('fileContext');
});
it('should sanitize files array', () => {
const message = {
messageId: 'msg-123',
files: [
{ file_id: 'file-1', text: 'long text 1', filename: 'a.txt' },
{ file_id: 'file-2', text: 'long text 2', filename: 'b.txt' },
],
};
const result = sanitizeMessageForTransmit(message);
expect(result.files).toHaveLength(2);
expect(result.files?.[0].file_id).toBe('file-1');
expect(result.files?.[0].filename).toBe('a.txt');
expect(result.files?.[0]).not.toHaveProperty('text');
expect(result.files?.[1]).not.toHaveProperty('text');
});
it('should handle null/undefined message', () => {
expect(sanitizeMessageForTransmit(null as unknown as object)).toBeNull();
expect(sanitizeMessageForTransmit(undefined as unknown as object)).toBeUndefined();
});
it('should handle message without files', () => {
const message = {
messageId: 'msg-123',
text: 'Hello',
};
const result = sanitizeMessageForTransmit(message);
expect(result.messageId).toBe('msg-123');
expect(result.text).toBe('Hello');
});
it('should create new array reference for empty files array (immutability)', () => {
const message = {
messageId: 'msg-123',
files: [] as { file_id: string }[],
};
const result = sanitizeMessageForTransmit(message);
expect(result.files).toEqual([]);
// New array reference ensures full immutability even for empty arrays
expect(result.files).not.toBe(message.files);
});
it('should not modify original message object', () => {
const message = {
messageId: 'msg-123',
fileContext: 'original context',
files: [{ file_id: 'file-1', text: 'original text' }],
};
sanitizeMessageForTransmit(message);
expect(message.fileContext).toBe('original context');
expect(message.files[0].text).toBe('original text');
});
});

View file

@ -0,0 +1,68 @@
import type { TFile, TMessage } from 'librechat-data-provider';
/** Fields to strip from files before client transmission */
const FILE_STRIP_FIELDS = ['text', '_id', '__v'] as const;
/** Fields to strip from messages before client transmission */
const MESSAGE_STRIP_FIELDS = ['fileContext'] as const;
/**
* Strips large/unnecessary fields from a file object before transmitting to client.
* Use this within existing loops when building file arrays to avoid extra iterations.
*
* @param file - The file object to sanitize
* @returns A new file object without the stripped fields
*
* @example
* // Use in existing file processing loop:
* for (const attachment of client.options.attachments) {
* if (messageFiles.has(attachment.file_id)) {
* userMessage.files.push(sanitizeFileForTransmit(attachment));
* }
* }
*/
export function sanitizeFileForTransmit<T extends Partial<TFile>>(
file: T,
): Omit<T, (typeof FILE_STRIP_FIELDS)[number]> {
const sanitized = { ...file };
for (const field of FILE_STRIP_FIELDS) {
delete sanitized[field as keyof typeof sanitized];
}
return sanitized;
}
/**
* Sanitizes a message object before transmitting to client.
* Removes large fields like `fileContext` and strips `text` from embedded files.
*
* @param message - The message object to sanitize
* @returns A new message object safe for client transmission
*
* @example
* sendEvent(res, {
* final: true,
* requestMessage: sanitizeMessageForTransmit(userMessage),
* responseMessage: response,
* });
*/
export function sanitizeMessageForTransmit<T extends Partial<TMessage>>(
message: T,
): Omit<T, (typeof MESSAGE_STRIP_FIELDS)[number]> {
if (!message) {
return message as Omit<T, (typeof MESSAGE_STRIP_FIELDS)[number]>;
}
const sanitized = { ...message };
// Remove message-level fields
for (const field of MESSAGE_STRIP_FIELDS) {
delete sanitized[field as keyof typeof sanitized];
}
// Always create a new array when files exist to maintain full immutability
if (Array.isArray(sanitized.files)) {
sanitized.files = sanitized.files.map((file) => sanitizeFileForTransmit(file));
}
return sanitized;
}

View file

@ -0,0 +1,482 @@
import { extractOpenIDTokenInfo, isOpenIDTokenValid, processOpenIDPlaceholders } from './oidc';
import type { TUser } from 'librechat-data-provider';
describe('OpenID Token Utilities', () => {
describe('extractOpenIDTokenInfo', () => {
it('should extract token info from user with federatedTokens', () => {
const user: Partial<TUser> = {
id: 'user-123',
provider: 'openid',
openidId: 'oidc-sub-456',
email: 'test@example.com',
name: 'Test User',
federatedTokens: {
access_token: 'access-token-value',
id_token: 'id-token-value',
refresh_token: 'refresh-token-value',
expires_at: Math.floor(Date.now() / 1000) + 3600,
},
};
const result = extractOpenIDTokenInfo(user);
expect(result).toMatchObject({
accessToken: 'access-token-value',
idToken: 'id-token-value',
userId: expect.any(String),
userEmail: 'test@example.com',
userName: 'Test User',
});
expect(result?.expiresAt).toBeDefined();
});
it('should return null when user is undefined', () => {
const result = extractOpenIDTokenInfo(undefined);
expect(result).toBeNull();
});
it('should return null when user is not OpenID provider', () => {
const user: Partial<TUser> = {
id: 'user-123',
provider: 'email',
};
const result = extractOpenIDTokenInfo(user);
expect(result).toBeNull();
});
it('should return token info when user has no federatedTokens but is OpenID provider', () => {
const user: Partial<TUser> = {
id: 'user-123',
provider: 'openid',
openidId: 'oidc-sub-456',
email: 'test@example.com',
name: 'Test User',
};
const result = extractOpenIDTokenInfo(user);
expect(result).toMatchObject({
userId: 'oidc-sub-456',
userEmail: 'test@example.com',
userName: 'Test User',
});
expect(result?.accessToken).toBeUndefined();
expect(result?.idToken).toBeUndefined();
});
it('should extract partial token info when some tokens are missing', () => {
const user: Partial<TUser> = {
id: 'user-123',
provider: 'openid',
openidId: 'oidc-sub-456',
email: 'test@example.com',
federatedTokens: {
access_token: 'access-token-value',
id_token: undefined,
refresh_token: undefined,
expires_at: undefined,
},
};
const result = extractOpenIDTokenInfo(user);
expect(result).toMatchObject({
accessToken: 'access-token-value',
userId: 'oidc-sub-456',
userEmail: 'test@example.com',
});
});
it('should prioritize openidId over regular id', () => {
const user: Partial<TUser> = {
id: 'user-123',
provider: 'openid',
openidId: 'oidc-sub-456',
federatedTokens: {
access_token: 'access-token-value',
},
};
const result = extractOpenIDTokenInfo(user);
expect(result?.userId).toBe('oidc-sub-456');
});
it('should fall back to regular id when openidId is not available', () => {
const user: Partial<TUser> = {
id: 'user-123',
provider: 'openid',
federatedTokens: {
access_token: 'access-token-value',
},
};
const result = extractOpenIDTokenInfo(user);
expect(result?.userId).toBe('user-123');
});
});
describe('isOpenIDTokenValid', () => {
it('should return false when tokenInfo is null', () => {
expect(isOpenIDTokenValid(null)).toBe(false);
});
it('should return false when tokenInfo has no accessToken', () => {
const tokenInfo = {
userId: 'oidc-sub-456',
};
expect(isOpenIDTokenValid(tokenInfo)).toBe(false);
});
it('should return true when token has access token and no expiresAt', () => {
const tokenInfo = {
accessToken: 'access-token-value',
userId: 'oidc-sub-456',
};
expect(isOpenIDTokenValid(tokenInfo)).toBe(true);
});
it('should return true when token has not expired', () => {
const futureTimestamp = Math.floor(Date.now() / 1000) + 3600; // 1 hour from now
const tokenInfo = {
accessToken: 'access-token-value',
expiresAt: futureTimestamp,
userId: 'oidc-sub-456',
};
expect(isOpenIDTokenValid(tokenInfo)).toBe(true);
});
it('should return false when token has expired', () => {
const pastTimestamp = Math.floor(Date.now() / 1000) - 3600; // 1 hour ago
const tokenInfo = {
accessToken: 'access-token-value',
expiresAt: pastTimestamp,
userId: 'oidc-sub-456',
};
expect(isOpenIDTokenValid(tokenInfo)).toBe(false);
});
it('should return false when token expires exactly now', () => {
const nowTimestamp = Math.floor(Date.now() / 1000);
const tokenInfo = {
accessToken: 'access-token-value',
expiresAt: nowTimestamp,
userId: 'oidc-sub-456',
};
expect(isOpenIDTokenValid(tokenInfo)).toBe(false);
});
it('should return true when token is just about to expire (within 1 second)', () => {
const almostExpiredTimestamp = Math.floor(Date.now() / 1000) + 1;
const tokenInfo = {
accessToken: 'access-token-value',
expiresAt: almostExpiredTimestamp,
userId: 'oidc-sub-456',
};
expect(isOpenIDTokenValid(tokenInfo)).toBe(true);
});
});
describe('processOpenIDPlaceholders', () => {
it('should replace LIBRECHAT_OPENID_TOKEN with access token', () => {
const tokenInfo = {
accessToken: 'access-token-value',
idToken: 'id-token-value',
userId: 'oidc-sub-456',
};
const input = 'Authorization: Bearer {{LIBRECHAT_OPENID_TOKEN}}';
const result = processOpenIDPlaceholders(input, tokenInfo);
expect(result).toBe('Authorization: Bearer access-token-value');
});
it('should replace LIBRECHAT_OPENID_ACCESS_TOKEN with access token', () => {
const tokenInfo = {
accessToken: 'access-token-value',
userId: 'oidc-sub-456',
};
const input = 'Token: {{LIBRECHAT_OPENID_ACCESS_TOKEN}}';
const result = processOpenIDPlaceholders(input, tokenInfo);
expect(result).toBe('Token: access-token-value');
});
it('should replace LIBRECHAT_OPENID_ID_TOKEN with id token', () => {
const tokenInfo = {
idToken: 'id-token-value',
userId: 'oidc-sub-456',
};
const input = 'ID Token: {{LIBRECHAT_OPENID_ID_TOKEN}}';
const result = processOpenIDPlaceholders(input, tokenInfo);
expect(result).toBe('ID Token: id-token-value');
});
it('should replace LIBRECHAT_OPENID_USER_ID with user id', () => {
const tokenInfo = {
userId: 'oidc-sub-456',
};
const input = 'User: {{LIBRECHAT_OPENID_USER_ID}}';
const result = processOpenIDPlaceholders(input, tokenInfo);
expect(result).toBe('User: oidc-sub-456');
});
it('should replace LIBRECHAT_OPENID_USER_EMAIL with user email', () => {
const tokenInfo = {
userEmail: 'test@example.com',
userId: 'oidc-sub-456',
};
const input = 'Email: {{LIBRECHAT_OPENID_USER_EMAIL}}';
const result = processOpenIDPlaceholders(input, tokenInfo);
expect(result).toBe('Email: test@example.com');
});
it('should replace LIBRECHAT_OPENID_USER_NAME with user name', () => {
const tokenInfo = {
userName: 'Test User',
userId: 'oidc-sub-456',
};
const input = 'Name: {{LIBRECHAT_OPENID_USER_NAME}}';
const result = processOpenIDPlaceholders(input, tokenInfo);
expect(result).toBe('Name: Test User');
});
it('should replace multiple placeholders in a single string', () => {
const tokenInfo = {
accessToken: 'access-token-value',
idToken: 'id-token-value',
userId: 'oidc-sub-456',
userEmail: 'test@example.com',
};
const input =
'Authorization: Bearer {{LIBRECHAT_OPENID_TOKEN}}, ID: {{LIBRECHAT_OPENID_ID_TOKEN}}, User: {{LIBRECHAT_OPENID_USER_ID}}';
const result = processOpenIDPlaceholders(input, tokenInfo);
expect(result).toBe(
'Authorization: Bearer access-token-value, ID: id-token-value, User: oidc-sub-456',
);
});
it('should replace empty string when token field is undefined', () => {
const tokenInfo = {
accessToken: undefined,
idToken: undefined,
userId: 'oidc-sub-456',
};
const input =
'Access: {{LIBRECHAT_OPENID_TOKEN}}, ID: {{LIBRECHAT_OPENID_ID_TOKEN}}, User: {{LIBRECHAT_OPENID_USER_ID}}';
const result = processOpenIDPlaceholders(input, tokenInfo);
expect(result).toBe('Access: , ID: , User: oidc-sub-456');
});
it('should handle all placeholder types in one value', () => {
const tokenInfo = {
accessToken: 'access-token-value',
idToken: 'id-token-value',
userId: 'oidc-sub-456',
userEmail: 'test@example.com',
userName: 'Test User',
expiresAt: 1234567890,
};
const input = `
Authorization: Bearer {{LIBRECHAT_OPENID_TOKEN}}
ID Token: {{LIBRECHAT_OPENID_ID_TOKEN}}
Access Token (alt): {{LIBRECHAT_OPENID_ACCESS_TOKEN}}
User ID: {{LIBRECHAT_OPENID_USER_ID}}
User Email: {{LIBRECHAT_OPENID_USER_EMAIL}}
User Name: {{LIBRECHAT_OPENID_USER_NAME}}
Expires: {{LIBRECHAT_OPENID_EXPIRES_AT}}
`;
const result = processOpenIDPlaceholders(input, tokenInfo);
expect(result).toContain('Bearer access-token-value');
expect(result).toContain('ID Token: id-token-value');
expect(result).toContain('Access Token (alt): access-token-value');
expect(result).toContain('User ID: oidc-sub-456');
expect(result).toContain('User Email: test@example.com');
expect(result).toContain('User Name: Test User');
expect(result).toContain('Expires: 1234567890');
});
it('should not modify string when no placeholders are present', () => {
const tokenInfo = {
accessToken: 'access-token-value',
userId: 'oidc-sub-456',
};
const input = 'Authorization: Bearer static-token';
const result = processOpenIDPlaceholders(input, tokenInfo);
expect(result).toBe('Authorization: Bearer static-token');
});
it('should handle case-sensitive placeholders', () => {
const tokenInfo = {
accessToken: 'access-token-value',
userId: 'oidc-sub-456',
};
// Wrong case should NOT be replaced
const input = 'Token: {{librechat_openid_token}}';
const result = processOpenIDPlaceholders(input, tokenInfo);
expect(result).toBe('Token: {{librechat_openid_token}}');
});
it('should handle multiple occurrences of the same placeholder', () => {
const tokenInfo = {
accessToken: 'access-token-value',
userId: 'oidc-sub-456',
};
const input =
'Primary: {{LIBRECHAT_OPENID_TOKEN}}, Secondary: {{LIBRECHAT_OPENID_TOKEN}}, Backup: {{LIBRECHAT_OPENID_TOKEN}}';
const result = processOpenIDPlaceholders(input, tokenInfo);
expect(result).toBe(
'Primary: access-token-value, Secondary: access-token-value, Backup: access-token-value',
);
});
it('should handle token info with all fields undefined except userId', () => {
const tokenInfo = {
accessToken: undefined,
idToken: undefined,
userId: 'oidc-sub-456',
userEmail: undefined,
userName: undefined,
};
const input =
'Access: {{LIBRECHAT_OPENID_TOKEN}}, ID: {{LIBRECHAT_OPENID_ID_TOKEN}}, User: {{LIBRECHAT_OPENID_USER_ID}}';
const result = processOpenIDPlaceholders(input, tokenInfo);
expect(result).toBe('Access: , ID: , User: oidc-sub-456');
});
it('should return original value when tokenInfo is null', () => {
const input = 'Authorization: Bearer {{LIBRECHAT_OPENID_TOKEN}}';
const result = processOpenIDPlaceholders(input, null);
expect(result).toBe('Authorization: Bearer {{LIBRECHAT_OPENID_TOKEN}}');
});
it('should return original value when value is not a string', () => {
const tokenInfo = {
accessToken: 'access-token-value',
userId: 'oidc-sub-456',
};
const result = processOpenIDPlaceholders(123 as unknown as string, tokenInfo);
expect(result).toBe(123);
});
});
describe('Integration: Full OpenID Token Flow', () => {
it('should extract, validate, and process tokens correctly', () => {
const user: Partial<TUser> = {
id: 'user-123',
provider: 'openid',
openidId: 'oidc-sub-456',
email: 'test@example.com',
name: 'Test User',
federatedTokens: {
access_token: 'access-token-value',
id_token: 'id-token-value',
refresh_token: 'refresh-token-value',
expires_at: Math.floor(Date.now() / 1000) + 3600,
},
};
// Step 1: Extract token info
const tokenInfo = extractOpenIDTokenInfo(user);
expect(tokenInfo).not.toBeNull();
// Step 2: Validate token
const isValid = isOpenIDTokenValid(tokenInfo!);
expect(isValid).toBe(true);
// Step 3: Process placeholders
const input =
'Authorization: Bearer {{LIBRECHAT_OPENID_TOKEN}}, User: {{LIBRECHAT_OPENID_USER_ID}}';
const result = processOpenIDPlaceholders(input, tokenInfo!);
expect(result).toContain('Authorization: Bearer access-token-value');
expect(result).toContain('User:');
});
it('should handle expired tokens correctly', () => {
const user: Partial<TUser> = {
id: 'user-123',
provider: 'openid',
openidId: 'oidc-sub-456',
federatedTokens: {
access_token: 'access-token-value',
expires_at: Math.floor(Date.now() / 1000) - 3600, // Expired 1 hour ago
},
};
const tokenInfo = extractOpenIDTokenInfo(user);
expect(tokenInfo).not.toBeNull();
const isValid = isOpenIDTokenValid(tokenInfo!);
expect(isValid).toBe(false); // Token is expired
// Even if expired, processOpenIDPlaceholders should still work
// (validation is checked separately by the caller)
const input = 'Authorization: Bearer {{LIBRECHAT_OPENID_TOKEN}}';
const result = processOpenIDPlaceholders(input, tokenInfo!);
expect(result).toBe('Authorization: Bearer access-token-value');
});
it('should handle user with no federatedTokens but still has OpenID provider', () => {
const user: Partial<TUser> = {
id: 'user-123',
provider: 'openid',
openidId: 'oidc-sub-456',
};
const tokenInfo = extractOpenIDTokenInfo(user);
expect(tokenInfo).not.toBeNull();
expect(tokenInfo?.userId).toBe('oidc-sub-456');
expect(tokenInfo?.accessToken).toBeUndefined();
});
it('should handle missing user', () => {
const tokenInfo = extractOpenIDTokenInfo(undefined);
expect(tokenInfo).toBeNull();
});
it('should handle non-OpenID users', () => {
const user: Partial<TUser> = {
id: 'user-123',
provider: 'email',
};
const tokenInfo = extractOpenIDTokenInfo(user);
expect(tokenInfo).toBeNull();
});
});
});

View file

@ -0,0 +1,176 @@
import { logger } from '@librechat/data-schemas';
import type { IUser } from '@librechat/data-schemas';
export interface OpenIDTokenInfo {
accessToken?: string;
idToken?: string;
expiresAt?: number;
userId?: string;
userEmail?: string;
userName?: string;
claims?: Record<string, unknown>;
}
interface FederatedTokens {
access_token?: string;
id_token?: string;
refresh_token?: string;
expires_at?: number;
}
function isFederatedTokens(obj: unknown): obj is FederatedTokens {
if (!obj || typeof obj !== 'object') {
return false;
}
return 'access_token' in obj || 'id_token' in obj || 'expires_at' in obj;
}
const OPENID_TOKEN_FIELDS = [
'ACCESS_TOKEN',
'ID_TOKEN',
'USER_ID',
'USER_EMAIL',
'USER_NAME',
'EXPIRES_AT',
] as const;
export function extractOpenIDTokenInfo(user: IUser | null | undefined): OpenIDTokenInfo | null {
if (!user) {
return null;
}
try {
if (user.provider !== 'openid' && !user.openidId) {
return null;
}
const tokenInfo: OpenIDTokenInfo = {};
if ('federatedTokens' in user && isFederatedTokens(user.federatedTokens)) {
const tokens = user.federatedTokens;
logger.debug('[extractOpenIDTokenInfo] Found federatedTokens:', {
has_access_token: !!tokens.access_token,
has_id_token: !!tokens.id_token,
has_refresh_token: !!tokens.refresh_token,
expires_at: tokens.expires_at,
});
tokenInfo.accessToken = tokens.access_token;
tokenInfo.idToken = tokens.id_token;
tokenInfo.expiresAt = tokens.expires_at;
} else if ('openidTokens' in user && isFederatedTokens(user.openidTokens)) {
const tokens = user.openidTokens;
logger.debug('[extractOpenIDTokenInfo] Found openidTokens');
tokenInfo.accessToken = tokens.access_token;
tokenInfo.idToken = tokens.id_token;
tokenInfo.expiresAt = tokens.expires_at;
}
tokenInfo.userId = user.openidId || user.id;
tokenInfo.userEmail = user.email;
tokenInfo.userName = user.name || user.username;
if (tokenInfo.idToken) {
try {
const payload = JSON.parse(
Buffer.from(tokenInfo.idToken.split('.')[1], 'base64').toString(),
);
tokenInfo.claims = payload;
if (payload.sub) tokenInfo.userId = payload.sub;
if (payload.email) tokenInfo.userEmail = payload.email;
if (payload.name) tokenInfo.userName = payload.name;
if (payload.exp) tokenInfo.expiresAt = payload.exp;
} catch (jwtError) {
logger.warn('Could not parse ID token claims:', jwtError);
}
}
return tokenInfo;
} catch (error) {
logger.error('Error extracting OpenID token info:', error);
return null;
}
}
export function isOpenIDTokenValid(tokenInfo: OpenIDTokenInfo | null): boolean {
if (!tokenInfo || !tokenInfo.accessToken) {
return false;
}
if (tokenInfo.expiresAt) {
const now = Math.floor(Date.now() / 1000);
if (now >= tokenInfo.expiresAt) {
logger.warn('OpenID token has expired');
return false;
}
}
return true;
}
export function processOpenIDPlaceholders(
value: string,
tokenInfo: OpenIDTokenInfo | null,
): string {
if (!tokenInfo || typeof value !== 'string') {
return value;
}
let processedValue = value;
for (const field of OPENID_TOKEN_FIELDS) {
const placeholder = `{{LIBRECHAT_OPENID_${field}}}`;
if (!processedValue.includes(placeholder)) {
continue;
}
let replacementValue = '';
switch (field) {
case 'ACCESS_TOKEN':
replacementValue = tokenInfo.accessToken || '';
break;
case 'ID_TOKEN':
replacementValue = tokenInfo.idToken || '';
break;
case 'USER_ID':
replacementValue = tokenInfo.userId || '';
break;
case 'USER_EMAIL':
replacementValue = tokenInfo.userEmail || '';
break;
case 'USER_NAME':
replacementValue = tokenInfo.userName || '';
break;
case 'EXPIRES_AT':
replacementValue = tokenInfo.expiresAt ? String(tokenInfo.expiresAt) : '';
break;
}
processedValue = processedValue.replace(new RegExp(placeholder, 'g'), replacementValue);
}
const genericPlaceholder = '{{LIBRECHAT_OPENID_TOKEN}}';
if (processedValue.includes(genericPlaceholder)) {
const replacementValue = tokenInfo.accessToken || '';
processedValue = processedValue.replace(new RegExp(genericPlaceholder, 'g'), replacementValue);
}
return processedValue;
}
export function createBearerAuthHeader(tokenInfo: OpenIDTokenInfo | null): string {
if (!tokenInfo || !tokenInfo.accessToken) {
return '';
}
return `Bearer ${tokenInfo.accessToken}`;
}
export function isOpenIDAvailable(): boolean {
const openidClientId = process.env.OPENID_CLIENT_ID;
const openidClientSecret = process.env.OPENID_CLIENT_SECRET;
const openidIssuer = process.env.OPENID_ISSUER;
return !!(openidClientId && openidClientSecret && openidIssuer);
}

View file

@ -0,0 +1,97 @@
import { logger } from '@librechat/data-schemas';
import type { Logger } from '@librechat/agents';
import { getBasePath } from './path';
describe('getBasePath', () => {
let originalDomainClient: string | undefined;
beforeEach(() => {
originalDomainClient = process.env.DOMAIN_CLIENT;
});
afterEach(() => {
process.env.DOMAIN_CLIENT = originalDomainClient;
});
it('should return empty string when DOMAIN_CLIENT is not set', () => {
delete process.env.DOMAIN_CLIENT;
expect(getBasePath()).toBe('');
});
it('should return empty string when DOMAIN_CLIENT is root path', () => {
process.env.DOMAIN_CLIENT = 'http://localhost:3080/';
expect(getBasePath()).toBe('');
});
it('should return base path for subdirectory deployment', () => {
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
expect(getBasePath()).toBe('/librechat');
});
it('should return base path without trailing slash', () => {
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat/';
expect(getBasePath()).toBe('/librechat');
});
it('should handle nested subdirectories', () => {
process.env.DOMAIN_CLIENT = 'http://localhost:3080/apps/librechat';
expect(getBasePath()).toBe('/apps/librechat');
});
it('should handle HTTPS URLs', () => {
process.env.DOMAIN_CLIENT = 'https://example.com/librechat';
expect(getBasePath()).toBe('/librechat');
});
it('should handle URLs with query parameters', () => {
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat?param=value';
expect(getBasePath()).toBe('/librechat');
});
it('should handle URLs with fragments', () => {
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat#section';
expect(getBasePath()).toBe('/librechat');
});
it('should return empty string for invalid URL', () => {
process.env.DOMAIN_CLIENT = 'not-a-valid-url';
// Accepts (infoObject: object), return value is not used
const loggerSpy = jest.spyOn(logger, 'warn').mockImplementation(() => {
return logger as unknown as Logger;
});
expect(getBasePath()).toBe('');
expect(loggerSpy).toHaveBeenCalledWith(
'Error parsing DOMAIN_CLIENT for base path:',
expect.objectContaining({
message: 'Invalid URL',
}),
);
loggerSpy.mockRestore();
});
it('should handle empty string DOMAIN_CLIENT', () => {
process.env.DOMAIN_CLIENT = '';
expect(getBasePath()).toBe('');
});
it('should handle undefined DOMAIN_CLIENT', () => {
process.env.DOMAIN_CLIENT = undefined;
expect(getBasePath()).toBe('');
});
it('should handle null DOMAIN_CLIENT', () => {
// @ts-expect-error Testing null case
process.env.DOMAIN_CLIENT = null;
expect(getBasePath()).toBe('');
});
it('should handle URLs with ports', () => {
process.env.DOMAIN_CLIENT = 'http://localhost:8080/librechat';
expect(getBasePath()).toBe('/librechat');
});
it('should handle URLs with subdomains', () => {
process.env.DOMAIN_CLIENT = 'https://app.example.com/librechat';
expect(getBasePath()).toBe('/librechat');
});
});

View file

@ -0,0 +1,25 @@
import { logger } from '@librechat/data-schemas';
/**
* Gets the base path from the DOMAIN_CLIENT environment variable.
* This is useful for constructing URLs when LibreChat is served from a subdirectory.
* @returns {string} The base path (e.g., '/librechat' or '')
*/
export function getBasePath(): string {
if (!process.env.DOMAIN_CLIENT) {
return '';
}
try {
const clientUrl = new URL(process.env.DOMAIN_CLIENT);
// Keep consistent with the logic in api/server/index.js
const baseHref = clientUrl.pathname.endsWith('/')
? clientUrl.pathname.slice(0, -1) // Remove trailing slash for path construction
: clientUrl.pathname;
return baseHref === '/' ? '' : baseHref;
} catch (error) {
logger.warn('Error parsing DOMAIN_CLIENT for base path:', error);
return '';
}
}

View file

@ -60,8 +60,7 @@ describe('sanitizeTitle', () => {
});
it('should handle multiple attributes', () => {
const input =
'<think reason="test" type="deep" id="1">reasoning</think> Title';
const input = '<think reason="test" type="deep" id="1">reasoning</think> Title';
expect(sanitizeTitle(input)).toBe('Title');
});
@ -170,8 +169,7 @@ describe('sanitizeTitle', () => {
});
it('should handle real-world with attributes', () => {
const input =
'<think reasoning="multi-step">\nStep 1\nStep 2\n</think> Project Status';
const input = '<think reasoning="multi-step">\nStep 1\nStep 2\n</think> Project Status';
expect(sanitizeTitle(input)).toBe('Project Status');
});
});

View file

@ -0,0 +1,851 @@
import { processTextWithTokenLimit, TokenCountFn } from './text';
import Tokenizer, { countTokens } from './tokenizer';
jest.mock('@librechat/data-schemas', () => ({
logger: {
debug: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
},
}));
/**
* OLD IMPLEMENTATION (Binary Search) - kept for comparison testing
* This is the original algorithm that caused CPU spikes
*/
async function processTextWithTokenLimitOLD({
text,
tokenLimit,
tokenCountFn,
}: {
text: string;
tokenLimit: number;
tokenCountFn: TokenCountFn;
}): Promise<{ text: string; tokenCount: number; wasTruncated: boolean }> {
const originalTokenCount = await tokenCountFn(text);
if (originalTokenCount <= tokenLimit) {
return {
text,
tokenCount: originalTokenCount,
wasTruncated: false,
};
}
let low = 0;
let high = text.length;
let bestText = '';
while (low <= high) {
const mid = Math.floor((low + high) / 2);
const truncatedText = text.substring(0, mid);
const tokenCount = await tokenCountFn(truncatedText);
if (tokenCount <= tokenLimit) {
bestText = truncatedText;
low = mid + 1;
} else {
high = mid - 1;
}
}
const finalTokenCount = await tokenCountFn(bestText);
return {
text: bestText,
tokenCount: finalTokenCount,
wasTruncated: true,
};
}
/**
* Creates a wrapper around Tokenizer.getTokenCount that tracks call count
*/
const createRealTokenCounter = () => {
let callCount = 0;
const tokenCountFn = (text: string): number => {
callCount++;
return Tokenizer.getTokenCount(text, 'cl100k_base');
};
return {
tokenCountFn,
getCallCount: () => callCount,
resetCallCount: () => {
callCount = 0;
},
};
};
/**
* Creates a wrapper around the async countTokens function that tracks call count
*/
const createCountTokensCounter = () => {
let callCount = 0;
const tokenCountFn = async (text: string): Promise<number> => {
callCount++;
return countTokens(text);
};
return {
tokenCountFn,
getCallCount: () => callCount,
resetCallCount: () => {
callCount = 0;
},
};
};
describe('processTextWithTokenLimit', () => {
/**
* Creates a mock token count function that simulates realistic token counting.
* Roughly 4 characters per token (common for English text).
* Tracks call count to verify efficiency.
*/
const createMockTokenCounter = () => {
let callCount = 0;
const tokenCountFn = (text: string): number => {
callCount++;
return Math.ceil(text.length / 4);
};
return {
tokenCountFn,
getCallCount: () => callCount,
resetCallCount: () => {
callCount = 0;
},
};
};
/** Creates a string of specified character length */
const createTextOfLength = (charLength: number): string => {
return 'a'.repeat(charLength);
};
/** Creates realistic text content with varied token density */
const createRealisticText = (approximateTokens: number): string => {
const words = [
'the',
'quick',
'brown',
'fox',
'jumps',
'over',
'lazy',
'dog',
'lorem',
'ipsum',
'dolor',
'sit',
'amet',
'consectetur',
'adipiscing',
'elit',
'sed',
'do',
'eiusmod',
'tempor',
'incididunt',
'ut',
'labore',
'et',
'dolore',
'magna',
'aliqua',
'enim',
'ad',
'minim',
'veniam',
'authentication',
'implementation',
'configuration',
'documentation',
];
const result: string[] = [];
for (let i = 0; i < approximateTokens; i++) {
result.push(words[i % words.length]);
}
return result.join(' ');
};
describe('tokenCountFn flexibility (sync and async)', () => {
it('should work with synchronous tokenCountFn', async () => {
const syncTokenCountFn = (text: string): number => Math.ceil(text.length / 4);
const text = 'Hello, world! This is a test message.';
const tokenLimit = 5;
const result = await processTextWithTokenLimit({
text,
tokenLimit,
tokenCountFn: syncTokenCountFn,
});
expect(result.wasTruncated).toBe(true);
expect(result.tokenCount).toBeLessThanOrEqual(tokenLimit);
});
it('should work with asynchronous tokenCountFn', async () => {
const asyncTokenCountFn = async (text: string): Promise<number> => {
await new Promise((resolve) => setTimeout(resolve, 1));
return Math.ceil(text.length / 4);
};
const text = 'Hello, world! This is a test message.';
const tokenLimit = 5;
const result = await processTextWithTokenLimit({
text,
tokenLimit,
tokenCountFn: asyncTokenCountFn,
});
expect(result.wasTruncated).toBe(true);
expect(result.tokenCount).toBeLessThanOrEqual(tokenLimit);
});
it('should produce equivalent results with sync and async tokenCountFn', async () => {
const syncTokenCountFn = (text: string): number => Math.ceil(text.length / 4);
const asyncTokenCountFn = async (text: string): Promise<number> => Math.ceil(text.length / 4);
const text = 'a'.repeat(8000);
const tokenLimit = 1000;
const syncResult = await processTextWithTokenLimit({
text,
tokenLimit,
tokenCountFn: syncTokenCountFn,
});
const asyncResult = await processTextWithTokenLimit({
text,
tokenLimit,
tokenCountFn: asyncTokenCountFn,
});
expect(syncResult.tokenCount).toBe(asyncResult.tokenCount);
expect(syncResult.wasTruncated).toBe(asyncResult.wasTruncated);
expect(syncResult.text.length).toBe(asyncResult.text.length);
});
});
describe('when text is under the token limit', () => {
it('should return original text unchanged', async () => {
const { tokenCountFn } = createMockTokenCounter();
const text = 'Hello, world!';
const tokenLimit = 100;
const result = await processTextWithTokenLimit({
text,
tokenLimit,
tokenCountFn,
});
expect(result.text).toBe(text);
expect(result.wasTruncated).toBe(false);
});
it('should return correct token count', async () => {
const { tokenCountFn } = createMockTokenCounter();
const text = 'Hello, world!';
const tokenLimit = 100;
const result = await processTextWithTokenLimit({
text,
tokenLimit,
tokenCountFn,
});
expect(result.tokenCount).toBe(Math.ceil(text.length / 4));
});
it('should only call tokenCountFn once when under limit', async () => {
const { tokenCountFn, getCallCount } = createMockTokenCounter();
const text = 'Hello, world!';
const tokenLimit = 100;
await processTextWithTokenLimit({
text,
tokenLimit,
tokenCountFn,
});
expect(getCallCount()).toBe(1);
});
});
describe('when text is exactly at the token limit', () => {
it('should return original text unchanged', async () => {
const { tokenCountFn } = createMockTokenCounter();
const text = createTextOfLength(400);
const tokenLimit = 100;
const result = await processTextWithTokenLimit({
text,
tokenLimit,
tokenCountFn,
});
expect(result.text).toBe(text);
expect(result.wasTruncated).toBe(false);
expect(result.tokenCount).toBe(tokenLimit);
});
});
describe('when text exceeds the token limit', () => {
it('should truncate text to fit within limit', async () => {
const { tokenCountFn } = createMockTokenCounter();
const text = createTextOfLength(8000);
const tokenLimit = 1000;
const result = await processTextWithTokenLimit({
text,
tokenLimit,
tokenCountFn,
});
expect(result.wasTruncated).toBe(true);
expect(result.tokenCount).toBeLessThanOrEqual(tokenLimit);
expect(result.text.length).toBeLessThan(text.length);
});
it('should truncate text to be close to but not exceed the limit', async () => {
const { tokenCountFn } = createMockTokenCounter();
const text = createTextOfLength(8000);
const tokenLimit = 1000;
const result = await processTextWithTokenLimit({
text,
tokenLimit,
tokenCountFn,
});
expect(result.tokenCount).toBeLessThanOrEqual(tokenLimit);
expect(result.tokenCount).toBeGreaterThan(tokenLimit * 0.9);
});
});
describe('efficiency - tokenCountFn call count', () => {
it('should call tokenCountFn at most 7 times for large text (vs ~17 for binary search)', async () => {
const { tokenCountFn, getCallCount } = createMockTokenCounter();
const text = createTextOfLength(400000);
const tokenLimit = 50000;
await processTextWithTokenLimit({
text,
tokenLimit,
tokenCountFn,
});
expect(getCallCount()).toBeLessThanOrEqual(7);
});
it('should typically call tokenCountFn only 2-3 times for standard truncation', async () => {
const { tokenCountFn, getCallCount } = createMockTokenCounter();
const text = createTextOfLength(40000);
const tokenLimit = 5000;
await processTextWithTokenLimit({
text,
tokenLimit,
tokenCountFn,
});
expect(getCallCount()).toBeLessThanOrEqual(3);
});
it('should call tokenCountFn only once when text is under limit', async () => {
const { tokenCountFn, getCallCount } = createMockTokenCounter();
const text = createTextOfLength(1000);
const tokenLimit = 10000;
await processTextWithTokenLimit({
text,
tokenLimit,
tokenCountFn,
});
expect(getCallCount()).toBe(1);
});
it('should handle very large text (100k+ tokens) efficiently', async () => {
const { tokenCountFn, getCallCount } = createMockTokenCounter();
const text = createTextOfLength(500000);
const tokenLimit = 100000;
await processTextWithTokenLimit({
text,
tokenLimit,
tokenCountFn,
});
expect(getCallCount()).toBeLessThanOrEqual(7);
});
});
describe('edge cases', () => {
it('should handle empty text', async () => {
const { tokenCountFn } = createMockTokenCounter();
const text = '';
const tokenLimit = 100;
const result = await processTextWithTokenLimit({
text,
tokenLimit,
tokenCountFn,
});
expect(result.text).toBe('');
expect(result.tokenCount).toBe(0);
expect(result.wasTruncated).toBe(false);
});
it('should handle token limit of 1', async () => {
const { tokenCountFn } = createMockTokenCounter();
const text = createTextOfLength(1000);
const tokenLimit = 1;
const result = await processTextWithTokenLimit({
text,
tokenLimit,
tokenCountFn,
});
expect(result.wasTruncated).toBe(true);
expect(result.tokenCount).toBeLessThanOrEqual(tokenLimit);
});
it('should handle text that is just slightly over the limit', async () => {
const { tokenCountFn } = createMockTokenCounter();
const text = createTextOfLength(404);
const tokenLimit = 100;
const result = await processTextWithTokenLimit({
text,
tokenLimit,
tokenCountFn,
});
expect(result.wasTruncated).toBe(true);
expect(result.tokenCount).toBeLessThanOrEqual(tokenLimit);
});
});
describe('correctness with variable token density', () => {
it('should handle text with varying token density', async () => {
const variableDensityTokenCounter = (text: string): number => {
const shortWords = (text.match(/\s+/g) || []).length;
return Math.ceil(text.length / 4) + shortWords;
};
const text = 'This is a test with many short words and some longer concatenated words too';
const tokenLimit = 10;
const result = await processTextWithTokenLimit({
text,
tokenLimit,
tokenCountFn: variableDensityTokenCounter,
});
expect(result.wasTruncated).toBe(true);
expect(result.tokenCount).toBeLessThanOrEqual(tokenLimit);
});
});
describe('direct comparison with OLD binary search implementation', () => {
it('should produce equivalent results to the old implementation', async () => {
const oldCounter = createMockTokenCounter();
const newCounter = createMockTokenCounter();
const text = createTextOfLength(8000);
const tokenLimit = 1000;
const oldResult = await processTextWithTokenLimitOLD({
text,
tokenLimit,
tokenCountFn: oldCounter.tokenCountFn,
});
const newResult = await processTextWithTokenLimit({
text,
tokenLimit,
tokenCountFn: newCounter.tokenCountFn,
});
expect(newResult.wasTruncated).toBe(oldResult.wasTruncated);
expect(newResult.tokenCount).toBeLessThanOrEqual(tokenLimit);
expect(oldResult.tokenCount).toBeLessThanOrEqual(tokenLimit);
});
it('should use significantly fewer tokenCountFn calls than old implementation (400k chars)', async () => {
const oldCounter = createMockTokenCounter();
const newCounter = createMockTokenCounter();
const text = createTextOfLength(400000);
const tokenLimit = 50000;
await processTextWithTokenLimitOLD({
text,
tokenLimit,
tokenCountFn: oldCounter.tokenCountFn,
});
await processTextWithTokenLimit({
text,
tokenLimit,
tokenCountFn: newCounter.tokenCountFn,
});
const oldCalls = oldCounter.getCallCount();
const newCalls = newCounter.getCallCount();
console.log(
`[400k chars] OLD implementation: ${oldCalls} calls, NEW implementation: ${newCalls} calls`,
);
console.log(`[400k chars] Reduction: ${((1 - newCalls / oldCalls) * 100).toFixed(1)}%`);
expect(newCalls).toBeLessThan(oldCalls);
expect(newCalls).toBeLessThanOrEqual(7);
});
it('should use significantly fewer tokenCountFn calls than old implementation (500k chars, 100k token limit)', async () => {
const oldCounter = createMockTokenCounter();
const newCounter = createMockTokenCounter();
const text = createTextOfLength(500000);
const tokenLimit = 100000;
await processTextWithTokenLimitOLD({
text,
tokenLimit,
tokenCountFn: oldCounter.tokenCountFn,
});
await processTextWithTokenLimit({
text,
tokenLimit,
tokenCountFn: newCounter.tokenCountFn,
});
const oldCalls = oldCounter.getCallCount();
const newCalls = newCounter.getCallCount();
console.log(
`[500k chars] OLD implementation: ${oldCalls} calls, NEW implementation: ${newCalls} calls`,
);
console.log(`[500k chars] Reduction: ${((1 - newCalls / oldCalls) * 100).toFixed(1)}%`);
expect(newCalls).toBeLessThan(oldCalls);
});
it('should achieve at least 70% reduction in tokenCountFn calls', async () => {
const oldCounter = createMockTokenCounter();
const newCounter = createMockTokenCounter();
const text = createTextOfLength(500000);
const tokenLimit = 100000;
await processTextWithTokenLimitOLD({
text,
tokenLimit,
tokenCountFn: oldCounter.tokenCountFn,
});
await processTextWithTokenLimit({
text,
tokenLimit,
tokenCountFn: newCounter.tokenCountFn,
});
const oldCalls = oldCounter.getCallCount();
const newCalls = newCounter.getCallCount();
const reduction = 1 - newCalls / oldCalls;
console.log(
`Efficiency improvement: ${(reduction * 100).toFixed(1)}% fewer tokenCountFn calls`,
);
expect(reduction).toBeGreaterThanOrEqual(0.7);
});
it('should simulate the reported scenario (122k tokens, 100k limit)', async () => {
const oldCounter = createMockTokenCounter();
const newCounter = createMockTokenCounter();
const text = createTextOfLength(489564);
const tokenLimit = 100000;
await processTextWithTokenLimitOLD({
text,
tokenLimit,
tokenCountFn: oldCounter.tokenCountFn,
});
await processTextWithTokenLimit({
text,
tokenLimit,
tokenCountFn: newCounter.tokenCountFn,
});
const oldCalls = oldCounter.getCallCount();
const newCalls = newCounter.getCallCount();
console.log(`[User reported scenario: ~122k tokens]`);
console.log(`OLD implementation: ${oldCalls} tokenCountFn calls`);
console.log(`NEW implementation: ${newCalls} tokenCountFn calls`);
console.log(`Reduction: ${((1 - newCalls / oldCalls) * 100).toFixed(1)}%`);
expect(newCalls).toBeLessThan(oldCalls);
expect(newCalls).toBeLessThanOrEqual(7);
});
});
describe('direct comparison with REAL tiktoken tokenizer', () => {
beforeEach(() => {
Tokenizer.freeAndResetAllEncoders();
});
it('should produce valid truncation with real tokenizer', async () => {
const counter = createRealTokenCounter();
const text = createRealisticText(5000);
const tokenLimit = 1000;
const result = await processTextWithTokenLimit({
text,
tokenLimit,
tokenCountFn: counter.tokenCountFn,
});
expect(result.wasTruncated).toBe(true);
expect(result.tokenCount).toBeLessThanOrEqual(tokenLimit);
expect(result.text.length).toBeLessThan(text.length);
});
it('should use fewer tiktoken calls than old implementation (realistic text)', async () => {
const oldCounter = createRealTokenCounter();
const newCounter = createRealTokenCounter();
const text = createRealisticText(15000);
const tokenLimit = 5000;
await processTextWithTokenLimitOLD({
text,
tokenLimit,
tokenCountFn: oldCounter.tokenCountFn,
});
Tokenizer.freeAndResetAllEncoders();
await processTextWithTokenLimit({
text,
tokenLimit,
tokenCountFn: newCounter.tokenCountFn,
});
const oldCalls = oldCounter.getCallCount();
const newCalls = newCounter.getCallCount();
console.log(`[Real tiktoken ~15k tokens] OLD: ${oldCalls} calls, NEW: ${newCalls} calls`);
console.log(`[Real tiktoken] Reduction: ${((1 - newCalls / oldCalls) * 100).toFixed(1)}%`);
expect(newCalls).toBeLessThan(oldCalls);
});
it('should handle the reported user scenario with real tokenizer (~120k tokens)', async () => {
const oldCounter = createRealTokenCounter();
const newCounter = createRealTokenCounter();
const text = createRealisticText(120000);
const tokenLimit = 100000;
const startOld = performance.now();
await processTextWithTokenLimitOLD({
text,
tokenLimit,
tokenCountFn: oldCounter.tokenCountFn,
});
const timeOld = performance.now() - startOld;
Tokenizer.freeAndResetAllEncoders();
const startNew = performance.now();
const result = await processTextWithTokenLimit({
text,
tokenLimit,
tokenCountFn: newCounter.tokenCountFn,
});
const timeNew = performance.now() - startNew;
const oldCalls = oldCounter.getCallCount();
const newCalls = newCounter.getCallCount();
console.log(`\n[REAL TIKTOKEN - User reported scenario: ~120k tokens]`);
console.log(`OLD implementation: ${oldCalls} tiktoken calls, ${timeOld.toFixed(0)}ms`);
console.log(`NEW implementation: ${newCalls} tiktoken calls, ${timeNew.toFixed(0)}ms`);
console.log(`Call reduction: ${((1 - newCalls / oldCalls) * 100).toFixed(1)}%`);
console.log(`Time reduction: ${((1 - timeNew / timeOld) * 100).toFixed(1)}%`);
console.log(
`Result: truncated=${result.wasTruncated}, tokens=${result.tokenCount}/${tokenLimit}\n`,
);
expect(newCalls).toBeLessThan(oldCalls);
expect(result.tokenCount).toBeLessThanOrEqual(tokenLimit);
expect(newCalls).toBeLessThanOrEqual(7);
});
it('should achieve at least 70% reduction with real tokenizer', async () => {
const oldCounter = createRealTokenCounter();
const newCounter = createRealTokenCounter();
const text = createRealisticText(50000);
const tokenLimit = 10000;
await processTextWithTokenLimitOLD({
text,
tokenLimit,
tokenCountFn: oldCounter.tokenCountFn,
});
Tokenizer.freeAndResetAllEncoders();
await processTextWithTokenLimit({
text,
tokenLimit,
tokenCountFn: newCounter.tokenCountFn,
});
const oldCalls = oldCounter.getCallCount();
const newCalls = newCounter.getCallCount();
const reduction = 1 - newCalls / oldCalls;
console.log(
`[Real tiktoken 50k tokens] OLD: ${oldCalls}, NEW: ${newCalls}, Reduction: ${(reduction * 100).toFixed(1)}%`,
);
expect(reduction).toBeGreaterThanOrEqual(0.7);
});
});
describe('using countTokens async function from @librechat/api', () => {
beforeEach(() => {
Tokenizer.freeAndResetAllEncoders();
});
it('countTokens should return correct token count', async () => {
const text = 'Hello, world!';
const count = await countTokens(text);
expect(count).toBeGreaterThan(0);
expect(typeof count).toBe('number');
});
it('countTokens should handle empty string', async () => {
const count = await countTokens('');
expect(count).toBe(0);
});
it('should work with processTextWithTokenLimit using countTokens', async () => {
const counter = createCountTokensCounter();
const text = createRealisticText(5000);
const tokenLimit = 1000;
const result = await processTextWithTokenLimit({
text,
tokenLimit,
tokenCountFn: counter.tokenCountFn,
});
expect(result.wasTruncated).toBe(true);
expect(result.tokenCount).toBeLessThanOrEqual(tokenLimit);
expect(result.text.length).toBeLessThan(text.length);
});
it('should use fewer countTokens calls than old implementation', async () => {
const oldCounter = createCountTokensCounter();
const newCounter = createCountTokensCounter();
const text = createRealisticText(15000);
const tokenLimit = 5000;
await processTextWithTokenLimitOLD({
text,
tokenLimit,
tokenCountFn: oldCounter.tokenCountFn,
});
Tokenizer.freeAndResetAllEncoders();
await processTextWithTokenLimit({
text,
tokenLimit,
tokenCountFn: newCounter.tokenCountFn,
});
const oldCalls = oldCounter.getCallCount();
const newCalls = newCounter.getCallCount();
console.log(`[countTokens ~15k tokens] OLD: ${oldCalls} calls, NEW: ${newCalls} calls`);
console.log(`[countTokens] Reduction: ${((1 - newCalls / oldCalls) * 100).toFixed(1)}%`);
expect(newCalls).toBeLessThan(oldCalls);
});
it('should handle user reported scenario with countTokens (~120k tokens)', async () => {
const oldCounter = createCountTokensCounter();
const newCounter = createCountTokensCounter();
const text = createRealisticText(120000);
const tokenLimit = 100000;
const startOld = performance.now();
await processTextWithTokenLimitOLD({
text,
tokenLimit,
tokenCountFn: oldCounter.tokenCountFn,
});
const timeOld = performance.now() - startOld;
Tokenizer.freeAndResetAllEncoders();
const startNew = performance.now();
const result = await processTextWithTokenLimit({
text,
tokenLimit,
tokenCountFn: newCounter.tokenCountFn,
});
const timeNew = performance.now() - startNew;
const oldCalls = oldCounter.getCallCount();
const newCalls = newCounter.getCallCount();
console.log(`\n[countTokens - User reported scenario: ~120k tokens]`);
console.log(`OLD implementation: ${oldCalls} countTokens calls, ${timeOld.toFixed(0)}ms`);
console.log(`NEW implementation: ${newCalls} countTokens calls, ${timeNew.toFixed(0)}ms`);
console.log(`Call reduction: ${((1 - newCalls / oldCalls) * 100).toFixed(1)}%`);
console.log(`Time reduction: ${((1 - timeNew / timeOld) * 100).toFixed(1)}%`);
console.log(
`Result: truncated=${result.wasTruncated}, tokens=${result.tokenCount}/${tokenLimit}\n`,
);
expect(newCalls).toBeLessThan(oldCalls);
expect(result.tokenCount).toBeLessThanOrEqual(tokenLimit);
expect(newCalls).toBeLessThanOrEqual(7);
});
it('should achieve at least 70% reduction with countTokens', async () => {
const oldCounter = createCountTokensCounter();
const newCounter = createCountTokensCounter();
const text = createRealisticText(50000);
const tokenLimit = 10000;
await processTextWithTokenLimitOLD({
text,
tokenLimit,
tokenCountFn: oldCounter.tokenCountFn,
});
Tokenizer.freeAndResetAllEncoders();
await processTextWithTokenLimit({
text,
tokenLimit,
tokenCountFn: newCounter.tokenCountFn,
});
const oldCalls = oldCounter.getCallCount();
const newCalls = newCounter.getCallCount();
const reduction = 1 - newCalls / oldCalls;
console.log(
`[countTokens 50k tokens] OLD: ${oldCalls}, NEW: ${newCalls}, Reduction: ${(reduction * 100).toFixed(1)}%`,
);
expect(reduction).toBeGreaterThanOrEqual(0.7);
});
});
});

View file

@ -1,11 +1,39 @@
import { logger } from '@librechat/data-schemas';
/** Token count function that can be sync or async */
export type TokenCountFn = (text: string) => number | Promise<number>;
/**
* Safety buffer multiplier applied to character position estimates during truncation.
*
* We use 98% (0.98) rather than 100% to intentionally undershoot the target on the first attempt.
* This is necessary because:
* - Token density varies across text (some regions may have more tokens per character than the average)
* - The ratio-based estimate assumes uniform token distribution, which is rarely true
* - Undershooting is safer than overshooting: exceeding the limit requires another iteration,
* while being slightly under is acceptable
* - In practice, this buffer reduces refinement iterations from 2-3 down to 0-1 in most cases
*
* @example
* // If text has 1000 chars and 250 tokens (4 chars/token average), targeting 100 tokens:
* // Without buffer: estimate = 1000 * (100/250) = 400 chars → might yield 105 tokens (over!)
* // With 0.98 buffer: estimate = 400 * 0.98 = 392 chars → likely yields 97-99 tokens (safe)
*/
const TRUNCATION_SAFETY_BUFFER = 0.98;
/**
* Processes text content by counting tokens and truncating if it exceeds the specified limit.
* Uses ratio-based estimation to minimize expensive tokenCountFn calls.
*
* @param text - The text content to process
* @param tokenLimit - The maximum number of tokens allowed
* @param tokenCountFn - Function to count tokens
* @param tokenCountFn - Function to count tokens (can be sync or async)
* @returns Promise resolving to object with processed text, token count, and truncation status
*
* @remarks
* This function uses a ratio-based estimation algorithm instead of binary search.
* Binary search would require O(log n) tokenCountFn calls (~17 for 100k chars),
* while this approach typically requires only 2-3 calls for a 90%+ reduction in CPU usage.
*/
export async function processTextWithTokenLimit({
text,
@ -14,7 +42,7 @@ export async function processTextWithTokenLimit({
}: {
text: string;
tokenLimit: number;
tokenCountFn: (text: string) => number;
tokenCountFn: TokenCountFn;
}): Promise<{ text: string; tokenCount: number; wasTruncated: boolean }> {
const originalTokenCount = await tokenCountFn(text);
@ -26,40 +54,34 @@ export async function processTextWithTokenLimit({
};
}
/**
* Doing binary search here to find the truncation point efficiently
* (May be a better way to go about this)
*/
let low = 0;
let high = text.length;
let bestText = '';
logger.debug(
`[textTokenLimiter] Text content exceeds token limit: ${originalTokenCount} > ${tokenLimit}, truncating...`,
);
while (low <= high) {
const mid = Math.floor((low + high) / 2);
const truncatedText = text.substring(0, mid);
const tokenCount = await tokenCountFn(truncatedText);
const ratio = tokenLimit / originalTokenCount;
let charPosition = Math.floor(text.length * ratio * TRUNCATION_SAFETY_BUFFER);
if (tokenCount <= tokenLimit) {
bestText = truncatedText;
low = mid + 1;
} else {
high = mid - 1;
}
let truncatedText = text.substring(0, charPosition);
let tokenCount = await tokenCountFn(truncatedText);
const maxIterations = 5;
let iterations = 0;
while (tokenCount > tokenLimit && iterations < maxIterations && charPosition > 0) {
const overageRatio = tokenLimit / tokenCount;
charPosition = Math.floor(charPosition * overageRatio * TRUNCATION_SAFETY_BUFFER);
truncatedText = text.substring(0, charPosition);
tokenCount = await tokenCountFn(truncatedText);
iterations++;
}
const finalTokenCount = await tokenCountFn(bestText);
logger.warn(
`[textTokenLimiter] Text truncated from ${originalTokenCount} to ${finalTokenCount} tokens (limit: ${tokenLimit})`,
`[textTokenLimiter] Text truncated from ${originalTokenCount} to ${tokenCount} tokens (limit: ${tokenLimit})`,
);
return {
text: bestText,
tokenCount: finalTokenCount,
text: truncatedText,
tokenCount,
wasTruncated: true,
};
}

View file

@ -75,4 +75,14 @@ class Tokenizer {
const TokenizerSingleton = new Tokenizer();
/**
* Counts the number of tokens in a given text using tiktoken.
* This is an async wrapper around Tokenizer.getTokenCount for compatibility.
* @param text - The text to be tokenized. Defaults to an empty string if not provided.
* @returns The number of tokens in the provided text.
*/
export async function countTokens(text = ''): Promise<number> {
return TokenizerSingleton.getTokenCount(text, 'cl100k_base');
}
export default TokenizerSingleton;

View file

@ -91,6 +91,7 @@ const googleModels = {
gemini: 30720, // -2048 from max
'gemini-pro-vision': 12288,
'gemini-exp': 2000000,
'gemini-3': 1000000, // 1M input tokens, 64k output tokens
'gemini-2.5': 1000000, // 1M input tokens, 64k output tokens
'gemini-2.5-pro': 1000000,
'gemini-2.5-flash': 1000000,
@ -132,12 +133,14 @@ const anthropicModels = {
'claude-3.5-sonnet-latest': 200000,
'claude-haiku-4-5': 200000,
'claude-sonnet-4': 1000000,
'claude-opus-4': 200000,
'claude-4': 200000,
'claude-opus-4': 200000,
'claude-opus-4-5': 200000,
};
const deepseekModels = {
deepseek: 128000,
'deepseek-chat': 128000,
'deepseek-reasoner': 128000,
'deepseek-r1': 128000,
'deepseek-v3': 128000,
@ -278,6 +281,9 @@ const xAIModels = {
'grok-3-mini': 131072,
'grok-3-mini-fast': 131072,
'grok-4': 256000, // 256K context
'grok-4-fast': 2000000, // 2M context
'grok-4-1-fast': 2000000, // 2M context (covers reasoning & non-reasoning variants)
'grok-code-fast': 256000, // 256K context
};
const aggregateModels = {
@ -333,19 +339,30 @@ const anthropicMaxOutputs = {
'claude-3-sonnet': 4096,
'claude-3-opus': 4096,
'claude-haiku-4-5': 64000,
'claude-opus-4': 32000,
'claude-sonnet-4': 64000,
'claude-opus-4': 32000,
'claude-opus-4-5': 64000,
'claude-3.5-sonnet': 8192,
'claude-3-5-sonnet': 8192,
'claude-3.7-sonnet': 128000,
'claude-3-7-sonnet': 128000,
};
/** Outputs from https://api-docs.deepseek.com/quick_start/pricing */
const deepseekMaxOutputs = {
deepseek: 8000, // deepseek-chat default: 4K, max: 8K
'deepseek-chat': 8000,
'deepseek-reasoner': 64000, // default: 32K, max: 64K
'deepseek-r1': 64000,
'deepseek-v3': 8000,
'deepseek.r1': 64000,
};
export const maxOutputTokensMap = {
[EModelEndpoint.anthropic]: anthropicMaxOutputs,
[EModelEndpoint.azureOpenAI]: modelMaxOutputs,
[EModelEndpoint.openAI]: modelMaxOutputs,
[EModelEndpoint.custom]: modelMaxOutputs,
[EModelEndpoint.openAI]: { ...modelMaxOutputs, ...deepseekMaxOutputs },
[EModelEndpoint.custom]: { ...modelMaxOutputs, ...deepseekMaxOutputs },
};
/**