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 },
};
/**

View file

@ -1,7 +1,11 @@
{
"name": "@librechat/client",
"version": "0.3.2",
"version": "0.4.1",
"description": "React components for LibreChat",
"repository": {
"type": "git",
"url": "https://github.com/danny-avila/LibreChat"
},
"main": "dist/index.js",
"module": "dist/index.es.js",
"types": "dist/types/index.d.ts",
@ -17,7 +21,9 @@
],
"scripts": {
"clean": "rimraf dist",
"b:clean": "bun run rimraf dist",
"build": "npm run clean && rollup -c --bundleConfigAsCjs",
"b:build": "bun run b:clean && bun run rollup -c --silent --bundleConfigAsCjs",
"build:watch": "rollup -c -w --bundleConfigAsCjs",
"dev": "rollup -c -w --bundleConfigAsCjs"
},
@ -51,7 +57,7 @@
"@tanstack/react-virtual": "^3.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dompurify": "^3.2.6",
"dompurify": "^3.3.0",
"framer-motion": "^12.23.6",
"i18next": "^24.2.2 || ^25.3.2",
"i18next-browser-languagedetector": "^8.2.0",
@ -70,7 +76,7 @@
},
"devDependencies": {
"@rollup/plugin-alias": "^5.1.0",
"@rollup/plugin-commonjs": "^25.0.2",
"@rollup/plugin-commonjs": "^29.0.0",
"@rollup/plugin-node-resolve": "^15.0.0",
"@rollup/plugin-replace": "^5.0.5",
"@rollup/plugin-terser": "^0.4.4",
@ -85,7 +91,7 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-i18next": "^15.4.0",
"rimraf": "^5.0.1",
"rimraf": "^6.1.2",
"rollup": "^4.0.0",
"rollup-plugin-peer-deps-external": "^2.2.4",
"rollup-plugin-postcss": "^4.0.2",

View file

@ -93,7 +93,7 @@ const Menu: React.FC<MenuProps> = ({
.map((item, index) => {
const { subItems } = item;
if (item.separate === true) {
return <Ariakit.MenuSeparator key={index} className="my-1 h-px bg-white/10" />;
return <Ariakit.MenuSeparator key={index} className="my-1 h-px border-border-medium" />;
}
if (subItems && subItems.length > 0) {
return (

View file

@ -21,7 +21,6 @@ export const TooltipAnchor = forwardRef<HTMLDivElement, TooltipAnchorProps>(func
const mounted = Ariakit.useStoreState(tooltip, (state) => state.mounted);
const placement = Ariakit.useStoreState(tooltip, (state) => state.placement);
const id = useId();
const sanitizer = useMemo(() => {
const instance = DOMPurify();
instance.addHook('afterSanitizeAttributes', (node) => {
@ -79,7 +78,6 @@ export const TooltipAnchor = forwardRef<HTMLDivElement, TooltipAnchorProps>(func
{...props}
ref={ref}
role={role}
aria-describedby={id}
onKeyDown={handleKeyDown}
className={cn('cursor-pointer', className)}
/>
@ -89,7 +87,6 @@ export const TooltipAnchor = forwardRef<HTMLDivElement, TooltipAnchorProps>(func
gutter={4}
alwaysVisible
className="tooltip"
id={id}
render={
<motion.div
initial={{ opacity: 0, x, y }}

View file

@ -1,6 +1,6 @@
{
"name": "librechat-data-provider",
"version": "0.8.020",
"version": "0.8.200",
"description": "data services for librechat apps",
"main": "dist/index.js",
"module": "dist/index.es.js",
@ -30,7 +30,7 @@
},
"repository": {
"type": "git",
"url": "git+https://github.com/danny-avila/LibreChat.git"
"url": "https://github.com/danny-avila/LibreChat"
},
"author": "",
"license": "ISC",
@ -50,7 +50,7 @@
"@babel/preset-typescript": "^7.21.0",
"@langchain/core": "^0.3.62",
"@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",
@ -63,7 +63,7 @@
"jest": "^30.2.0",
"jest-junit": "^16.0.0",
"openapi-types": "^12.1.3",
"rimraf": "^5.0.1",
"rimraf": "^6.1.2",
"rollup": "^4.22.4",
"rollup-plugin-peer-deps-external": "^2.2.4",
"rollup-plugin-typescript2": "^0.35.0",

View file

@ -1539,6 +1539,60 @@ describe('SSRF Protection', () => {
'http://169.254.169.254',
);
});
it('handles IPv6 URLs with brackets correctly', () => {
expect(extractDomainFromUrl('http://[::1]/')).toBe('http://[::1]');
expect(extractDomainFromUrl('http://[::1]:8080')).toBe('http://[::1]');
expect(extractDomainFromUrl('https://[2001:db8::1]/api')).toBe('https://[2001:db8::1]');
expect(extractDomainFromUrl('http://[fe80::1]/path')).toBe('http://[fe80::1]');
});
it('handles complex IPv6 addresses', () => {
expect(extractDomainFromUrl('http://[2001:db8:85a3::8a2e:370:7334]/api')).toBe(
'http://[2001:db8:85a3::8a2e:370:7334]',
);
// Node.js normalizes IPv4-mapped IPv6 to hex form
expect(extractDomainFromUrl('https://[::ffff:192.168.1.1]:8080')).toBe(
'https://[::ffff:c0a8:101]',
);
});
it('handles URLs with authentication credentials', () => {
expect(extractDomainFromUrl('https://user:pass@example.com/api')).toBe('https://example.com');
expect(extractDomainFromUrl('http://admin@192.168.1.1:8080')).toBe('http://192.168.1.1');
});
it('handles URLs with special characters in path', () => {
expect(extractDomainFromUrl('https://example.com/path%20with%20spaces')).toBe(
'https://example.com',
);
expect(extractDomainFromUrl('https://example.com/path#fragment')).toBe('https://example.com');
expect(extractDomainFromUrl('https://example.com/?query=value&other=123')).toBe(
'https://example.com',
);
});
it('handles localhost variations', () => {
expect(extractDomainFromUrl('http://localhost/')).toBe('http://localhost');
expect(extractDomainFromUrl('https://localhost:3000')).toBe('https://localhost');
expect(extractDomainFromUrl('http://localhost.localdomain')).toBe(
'http://localhost.localdomain',
);
});
it('handles internationalized domain names', () => {
expect(extractDomainFromUrl('https://xn--e1afmkfd.xn--p1ai/api')).toBe(
'https://xn--e1afmkfd.xn--p1ai',
);
// Node.js URL parser converts IDN to punycode
expect(extractDomainFromUrl('https://münchen.de')).toBe('https://xn--mnchen-3ya.de');
});
it('throws error for non-HTTP/HTTPS protocols in extractDomainFromUrl', () => {
expect(() => extractDomainFromUrl('ftp://example.com')).not.toThrow();
expect(extractDomainFromUrl('ftp://example.com')).toBe('ftp://example.com');
// Note: The function doesn't validate protocol, just extracts domain
});
});
describe('validateAndParseOpenAPISpec - SSRF Prevention', () => {
@ -1738,7 +1792,7 @@ describe('SSRF Protection', () => {
expect(result.isValid).toBe(false);
expect(result.message).toContain('Domain mismatch');
expect(result.message).toContain('example.com');
expect(result.message).toContain('https://malicious.com');
expect(result.message).toContain('malicious.com');
});
it('detects SSRF attempt with internal IP', () => {
@ -1837,5 +1891,567 @@ describe('SSRF Protection', () => {
expect(result.isValid).toBe(true);
expect(result.normalizedSpecDomain).toBe('https://api.openai.com');
});
// Tests for IP address validation (fix for the reported issue)
it('validates matching IP addresses when client provides just IP (no protocol)', () => {
const result = validateActionDomain('10.225.26.25', 'http://10.225.26.25:7894/api');
expect(result.isValid).toBe(true);
expect(result.normalizedSpecDomain).toBe('http://10.225.26.25');
expect(result.normalizedClientDomain).toBe('http://10.225.26.25');
});
it('validates matching localhost IP when client provides just IP', () => {
const result = validateActionDomain('127.0.0.1', 'http://127.0.0.1:8080/api');
expect(result.isValid).toBe(true);
expect(result.normalizedSpecDomain).toBe('http://127.0.0.1');
expect(result.normalizedClientDomain).toBe('http://127.0.0.1');
});
it('validates matching private network IP when client provides just IP', () => {
const result = validateActionDomain('192.168.1.100', 'https://192.168.1.100:443/api');
expect(result.isValid).toBe(true);
expect(result.normalizedSpecDomain).toBe('https://192.168.1.100');
expect(result.normalizedClientDomain).toBe('https://192.168.1.100');
});
it('validates matching IP when client provides full URL with IP', () => {
const result = validateActionDomain('http://10.225.26.25', 'http://10.225.26.25:7894');
expect(result.isValid).toBe(true);
expect(result.normalizedSpecDomain).toBe('http://10.225.26.25');
expect(result.normalizedClientDomain).toBe('http://10.225.26.25');
});
it('rejects mismatched IP addresses', () => {
const result = validateActionDomain('10.225.26.25', 'http://10.225.26.26:7894/api');
expect(result.isValid).toBe(false);
expect(result.message).toContain('Domain mismatch');
expect(result.message).toContain('10.225.26.25');
expect(result.message).toContain('10.225.26.26');
});
it('rejects IP when domain expected', () => {
const result = validateActionDomain('example.com', 'http://192.168.1.1/api');
expect(result.isValid).toBe(false);
expect(result.message).toContain('Domain mismatch');
expect(result.normalizedSpecDomain).toBe('http://192.168.1.1');
});
it('rejects domain when IP expected', () => {
const result = validateActionDomain('192.168.1.1', 'http://malicious.com/api');
expect(result.isValid).toBe(false);
expect(result.message).toContain('Domain mismatch');
expect(result.message).toContain('192.168.1.1');
expect(result.message).toContain('malicious.com');
});
it('handles IPv6 addresses when client provides just IP', () => {
const result = validateActionDomain('[::1]', 'http://[::1]:8080/api');
expect(result.isValid).toBe(true);
expect(result.normalizedSpecDomain).toBe('http://[::1]');
expect(result.normalizedClientDomain).toBe('http://[::1]');
});
// Additional IP-based SSRF tests for comprehensive security coverage
it('prevents using whitelisted IP to access different IP', () => {
const result = validateActionDomain('192.168.1.100', 'http://192.168.1.101/api');
expect(result.isValid).toBe(false);
expect(result.message).toContain('Domain mismatch');
expect(result.message).toContain('192.168.1.100');
expect(result.message).toContain('192.168.1.101');
});
it('prevents using external IP to access localhost', () => {
const result = validateActionDomain('8.8.8.8', 'http://127.0.0.1/admin');
expect(result.isValid).toBe(false);
expect(result.message).toContain('Domain mismatch');
});
it('prevents using localhost to access private network', () => {
const result = validateActionDomain('127.0.0.1', 'http://192.168.1.1/admin');
expect(result.isValid).toBe(false);
expect(result.message).toContain('Domain mismatch');
});
it('detects SSRF with 0.0.0.0 binding address', () => {
const result = validateActionDomain('example.com', 'http://0.0.0.0:8080');
expect(result.isValid).toBe(false);
expect(result.message).toContain('Domain mismatch');
expect(result.normalizedSpecDomain).toBe('http://0.0.0.0');
});
it('validates matching 0.0.0.0 when legitimately used', () => {
const result = validateActionDomain('0.0.0.0', 'http://0.0.0.0:8080');
expect(result.isValid).toBe(true);
expect(result.normalizedSpecDomain).toBe('http://0.0.0.0');
});
it('prevents link-local address SSRF (169.254.x.x)', () => {
const result = validateActionDomain('api.example.com', 'http://169.254.10.10/');
expect(result.isValid).toBe(false);
expect(result.message).toContain('Domain mismatch');
expect(result.normalizedSpecDomain).toBe('http://169.254.10.10');
});
it('validates matching link-local when explicitly allowed', () => {
const result = validateActionDomain('169.254.10.10', 'http://169.254.10.10/api');
expect(result.isValid).toBe(true);
expect(result.normalizedSpecDomain).toBe('http://169.254.10.10');
});
it('prevents Docker internal network access via SSRF', () => {
const result = validateActionDomain('public-api.com', 'http://172.17.0.1/');
expect(result.isValid).toBe(false);
expect(result.message).toContain('Domain mismatch');
expect(result.normalizedSpecDomain).toBe('http://172.17.0.1');
});
it('prevents Kubernetes service network SSRF', () => {
const result = validateActionDomain('api.company.com', 'http://10.96.0.1/');
expect(result.isValid).toBe(false);
expect(result.message).toContain('Domain mismatch');
});
it('detects protocol mismatch for IP addresses', () => {
const result = validateActionDomain('https://192.168.1.1', 'http://192.168.1.1/api');
expect(result.isValid).toBe(false);
expect(result.message).toContain('Domain mismatch');
expect(result.normalizedSpecDomain).toBe('http://192.168.1.1');
expect(result.normalizedClientDomain).toBe('https://192.168.1.1');
});
it('prevents IPv6 localhost bypass attempts', () => {
const result = validateActionDomain('example.com', 'http://[::1]/admin');
expect(result.isValid).toBe(false);
expect(result.message).toContain('Domain mismatch');
expect(result.normalizedSpecDomain).toBe('http://[::1]');
});
it('prevents IPv6 link-local SSRF (fe80::)', () => {
const result = validateActionDomain('api.example.com', 'http://[fe80::1]/');
expect(result.isValid).toBe(false);
expect(result.message).toContain('Domain mismatch');
});
it('validates matching IPv6 link-local when explicitly allowed', () => {
const result = validateActionDomain('[fe80::1]', 'http://[fe80::1]/api');
expect(result.isValid).toBe(true);
expect(result.normalizedSpecDomain).toBe('http://[fe80::1]');
});
it('prevents multicast address SSRF', () => {
const result = validateActionDomain('api.example.com', 'http://224.0.0.1/');
expect(result.isValid).toBe(false);
expect(result.message).toContain('Domain mismatch');
});
it('prevents broadcast address SSRF', () => {
const result = validateActionDomain('api.example.com', 'http://255.255.255.255/');
expect(result.isValid).toBe(false);
expect(result.message).toContain('Domain mismatch');
});
// Cloud Provider Metadata Service Tests
it('prevents AWS IMDSv1 metadata access', () => {
const result = validateActionDomain(
'trusted-api.com',
'http://169.254.169.254/latest/meta-data/',
);
expect(result.isValid).toBe(false);
expect(result.message).toContain('Domain mismatch');
});
it('prevents AWS IMDSv2 token endpoint access', () => {
const result = validateActionDomain(
'api.example.com',
'http://169.254.169.254/latest/api/token',
);
expect(result.isValid).toBe(false);
expect(result.message).toContain('Domain mismatch');
});
it('prevents GCP metadata access via metadata.google.internal', () => {
const result = validateActionDomain(
'api.example.com',
'http://metadata.google.internal/computeMetadata/v1/',
);
expect(result.isValid).toBe(false);
expect(result.message).toContain('Domain mismatch');
});
it('prevents Azure IMDS access', () => {
const result = validateActionDomain(
'api.example.com',
'http://169.254.169.254/metadata/instance?api-version=2021-02-01',
);
expect(result.isValid).toBe(false);
expect(result.message).toContain('Domain mismatch');
});
it('prevents DigitalOcean metadata access', () => {
const result = validateActionDomain('api.example.com', 'http://169.254.169.254/metadata/v1/');
expect(result.isValid).toBe(false);
expect(result.message).toContain('Domain mismatch');
});
it('prevents Oracle Cloud metadata access', () => {
const result = validateActionDomain(
'api.example.com',
'http://169.254.169.254/opc/v1/instance/',
);
expect(result.isValid).toBe(false);
expect(result.message).toContain('Domain mismatch');
});
it('prevents Alibaba Cloud metadata access', () => {
const result = validateActionDomain(
'api.example.com',
'http://100.100.100.200/latest/meta-data/',
);
expect(result.isValid).toBe(false);
expect(result.message).toContain('Domain mismatch');
});
// Container & Orchestration Internal Services
it('prevents Kubernetes API server access', () => {
const result = validateActionDomain(
'api.example.com',
'https://kubernetes.default.svc.cluster.local/',
);
expect(result.isValid).toBe(false);
expect(result.message).toContain('Domain mismatch');
});
it('prevents Docker host access from container', () => {
const result = validateActionDomain('api.example.com', 'http://host.docker.internal/');
expect(result.isValid).toBe(false);
expect(result.message).toContain('Domain mismatch');
});
it('prevents Rancher metadata service access', () => {
const result = validateActionDomain('api.example.com', 'http://rancher-metadata/');
expect(result.isValid).toBe(false);
expect(result.message).toContain('Domain mismatch');
});
// Common Internal Service Ports
it('prevents Redis default port access', () => {
const result = validateActionDomain('api.example.com', 'http://10.0.0.5:6379/');
expect(result.isValid).toBe(false);
expect(result.message).toContain('Domain mismatch');
});
it('prevents Elasticsearch default port access', () => {
const result = validateActionDomain('api.example.com', 'http://10.0.0.5:9200/');
expect(result.isValid).toBe(false);
expect(result.message).toContain('Domain mismatch');
});
it('prevents MongoDB default port access', () => {
const result = validateActionDomain('api.example.com', 'http://10.0.0.5:27017/');
expect(result.isValid).toBe(false);
expect(result.message).toContain('Domain mismatch');
});
it('prevents PostgreSQL default port access', () => {
const result = validateActionDomain('api.example.com', 'http://10.0.0.5:5432/');
expect(result.isValid).toBe(false);
expect(result.message).toContain('Domain mismatch');
});
it('prevents MySQL default port access', () => {
const result = validateActionDomain('api.example.com', 'http://10.0.0.5:3306/');
expect(result.isValid).toBe(false);
expect(result.message).toContain('Domain mismatch');
});
// Alternative localhost representations
it('prevents localhost.localdomain SSRF', () => {
const result = validateActionDomain('api.example.com', 'http://localhost.localdomain/admin');
expect(result.isValid).toBe(false);
expect(result.message).toContain('Domain mismatch');
});
it('validates matching localhost.localdomain when explicitly allowed', () => {
const result = validateActionDomain(
'localhost.localdomain',
'https://localhost.localdomain/api',
);
expect(result.isValid).toBe(true);
});
// Edge cases with special IPs
it('prevents class E reserved IP range access', () => {
const result = validateActionDomain('api.example.com', 'http://240.0.0.1/');
expect(result.isValid).toBe(false);
expect(result.message).toContain('Domain mismatch');
});
it('prevents TEST-NET-1 range access when not matching', () => {
const result = validateActionDomain('api.example.com', 'http://192.0.2.1/');
expect(result.isValid).toBe(false);
expect(result.message).toContain('Domain mismatch');
});
it('validates TEST-NET-1 when explicitly matching', () => {
const result = validateActionDomain('192.0.2.1', 'http://192.0.2.1/api');
expect(result.isValid).toBe(true);
});
// Mixed protocol and IP scenarios (unsupported protocols)
it('rejects unsupported WebSocket protocol', () => {
const result = validateActionDomain('api.example.com', 'ws://api.example.com:8080/');
expect(result.isValid).toBe(false);
expect(result.message).toContain('Invalid protocol');
expect(result.message).toContain('ws:');
});
it('rejects unsupported FTP protocol', () => {
const result = validateActionDomain('ftp.example.com', 'ftp://ftp.example.com/');
expect(result.isValid).toBe(false);
expect(result.message).toContain('Invalid protocol');
expect(result.message).toContain('ftp:');
});
it('rejects WSS (secure WebSocket) protocol', () => {
const result = validateActionDomain('api.example.com', 'wss://api.example.com:8080/');
expect(result.isValid).toBe(false);
expect(result.message).toContain('Invalid protocol');
expect(result.message).toContain('wss:');
});
it('rejects file:// protocol for local file access', () => {
const result = validateActionDomain('localhost', 'file:///etc/passwd');
expect(result.isValid).toBe(false);
expect(result.message).toContain('Invalid protocol');
expect(result.message).toContain('file:');
});
it('rejects gopher:// protocol', () => {
const result = validateActionDomain('example.com', 'gopher://example.com/');
expect(result.isValid).toBe(false);
expect(result.message).toContain('Invalid protocol');
expect(result.message).toContain('gopher:');
});
it('rejects data: URL protocol', () => {
const result = validateActionDomain('example.com', 'data:text/plain,Hello');
expect(result.isValid).toBe(false);
expect(result.message).toContain('Invalid protocol');
expect(result.message).toContain('data:');
});
// Tests for Copilot second review catches
it('rejects unsupported protocol in client domain', () => {
const result = validateActionDomain('ftp://evil.com', 'https://trusted.com/api');
expect(result.isValid).toBe(false);
expect(result.message).toContain('Invalid protocol');
expect(result.message).toContain('client domain');
});
it('rejects WebSocket protocol in client domain', () => {
const result = validateActionDomain('ws://evil.com', 'https://trusted.com/api');
expect(result.isValid).toBe(false);
expect(result.message).toContain('Invalid protocol');
expect(result.message).toContain('client domain');
});
it('rejects file protocol in client domain', () => {
const result = validateActionDomain('file:///etc/passwd', 'https://trusted.com/api');
expect(result.isValid).toBe(false);
expect(result.message).toContain('Invalid protocol');
expect(result.message).toContain('client domain');
});
it('handles IPv6 address without brackets from client', () => {
const result = validateActionDomain('2001:db8::1', 'http://[2001:db8::1]/api');
expect(result.isValid).toBe(true);
expect(result.normalizedClientDomain).toBe('http://[2001:db8::1]');
expect(result.normalizedSpecDomain).toBe('http://[2001:db8::1]');
});
it('handles IPv6 address with brackets from client', () => {
const result = validateActionDomain('[2001:db8::1]', 'http://[2001:db8::1]/api');
expect(result.isValid).toBe(true);
expect(result.normalizedClientDomain).toBe('http://[2001:db8::1]');
expect(result.normalizedSpecDomain).toBe('http://[2001:db8::1]');
});
// Ensure legitimate internal use cases still work
it('allows legitimate internal API with matching IP', () => {
const result = validateActionDomain('10.0.0.5', 'http://10.0.0.5:8080/api');
expect(result.isValid).toBe(true);
});
it('allows legitimate Docker internal when explicitly specified', () => {
const result = validateActionDomain(
'host.docker.internal',
'https://host.docker.internal:3000/api',
);
expect(result.isValid).toBe(true);
});
it('allows legitimate Kubernetes service when explicitly specified', () => {
const result = validateActionDomain(
'myservice.default.svc.cluster.local',
'https://myservice.default.svc.cluster.local/api',
);
expect(result.isValid).toBe(true);
});
// Additional coverage tests for error paths and edge cases
it('handles malformed URL in client domain gracefully', () => {
const result = validateActionDomain('http://[invalid', 'https://example.com/api');
expect(result.isValid).toBe(false);
});
it('handles error in spec URL parsing', () => {
const result = validateActionDomain('example.com', 'not-a-valid-url');
expect(result.isValid).toBe(false);
expect(result.message).toContain('Failed to validate domain');
});
it('validates when client provides HTTP and spec uses HTTP', () => {
const result = validateActionDomain('http://example.com', 'http://example.com/api');
expect(result.isValid).toBe(true);
expect(result.normalizedClientDomain).toBe('http://example.com');
expect(result.normalizedSpecDomain).toBe('http://example.com');
});
it('validates when client provides HTTPS and spec uses HTTPS', () => {
const result = validateActionDomain('https://example.com', 'https://example.com/api');
expect(result.isValid).toBe(true);
expect(result.normalizedClientDomain).toBe('https://example.com');
expect(result.normalizedSpecDomain).toBe('https://example.com');
});
it('handles IPv4 with explicit protocol from client', () => {
const result = validateActionDomain('http://192.168.1.1', 'http://192.168.1.1:8080');
expect(result.isValid).toBe(true);
expect(result.normalizedClientDomain).toBe('http://192.168.1.1');
});
it('handles localhost as a domain', () => {
const result = validateActionDomain('localhost', 'https://localhost:3000/api');
expect(result.isValid).toBe(true);
expect(result.normalizedClientDomain).toBe('https://localhost');
expect(result.normalizedSpecDomain).toBe('https://localhost');
});
it('rejects javascript: protocol in client domain', () => {
const result = validateActionDomain('javascript:alert(1)', 'https://example.com/api');
expect(result.isValid).toBe(false);
// javascript: doesn't have :// so it's treated as a hostname mismatch
expect(result.message).toContain('Domain mismatch');
});
it('handles empty string as client domain', () => {
const result = validateActionDomain('', 'https://example.com/api');
expect(result.isValid).toBe(false);
});
it('handles spec URL without path', () => {
const result = validateActionDomain('example.com', 'https://example.com');
expect(result.isValid).toBe(true);
});
it('handles spec URL with query parameters', () => {
const result = validateActionDomain(
'api.example.com',
'https://api.example.com/v1?key=value',
);
expect(result.isValid).toBe(true);
expect(result.normalizedSpecDomain).toBe('https://api.example.com');
});
it('handles subdomain matching correctly', () => {
const result = validateActionDomain(
'api.v2.example.com',
'https://api.v2.example.com/endpoint',
);
expect(result.isValid).toBe(true);
});
it('rejects SSH protocol in client domain', () => {
const result = validateActionDomain('ssh://git@github.com', 'https://github.com/api');
expect(result.isValid).toBe(false);
expect(result.message).toContain('Invalid protocol');
});
it('handles punycode/internationalized domains', () => {
const result = validateActionDomain(
'xn--e1afmkfd.xn--p1ai',
'https://xn--e1afmkfd.xn--p1ai/api',
);
expect(result.isValid).toBe(true);
});
it('validates IPv6 localhost variations', () => {
const result = validateActionDomain('::1', 'http://[::1]:8080');
expect(result.isValid).toBe(true);
expect(result.normalizedClientDomain).toBe('http://[::1]');
});
it('handles spec URL with username in URL', () => {
const result = validateActionDomain('example.com', 'https://user@example.com/api');
expect(result.isValid).toBe(true);
expect(result.normalizedSpecDomain).toBe('https://example.com');
});
it('handles spec URL with username and password', () => {
const result = validateActionDomain('example.com', 'https://user:pass@example.com/api');
expect(result.isValid).toBe(true);
expect(result.normalizedSpecDomain).toBe('https://example.com');
});
it('handles complex IPv6 addresses', () => {
const result = validateActionDomain(
'2001:db8:85a3::8a2e:370:7334',
'http://[2001:db8:85a3::8a2e:370:7334]/api',
);
expect(result.isValid).toBe(true);
expect(result.normalizedClientDomain).toBe('http://[2001:db8:85a3::8a2e:370:7334]');
});
it('handles IPv4-mapped IPv6 addresses', () => {
// Node.js normalizes IPv4-mapped IPv6 differently in URL parsing
const result = validateActionDomain('::ffff:c0a8:101', 'http://[::ffff:c0a8:101]/api');
expect(result.isValid).toBe(true);
expect(result.normalizedClientDomain).toBe('http://[::ffff:c0a8:101]');
});
it('rejects telnet protocol in client domain', () => {
const result = validateActionDomain('telnet://example.com', 'https://example.com/api');
expect(result.isValid).toBe(false);
expect(result.message).toContain('Invalid protocol');
});
it('handles client domain with port and no protocol', () => {
const result = validateActionDomain('example.com:443', 'https://example.com:443/api');
// Port is included in hostname comparison, causing mismatch
expect(result.isValid).toBe(false);
expect(result.normalizedClientDomain).toBe('https://example.com:443');
expect(result.normalizedSpecDomain).toBe('https://example.com');
});
it('handles TLD-only domains', () => {
const result = validateActionDomain('localhost', 'http://localhost/api');
expect(result.isValid).toBe(false); // HTTP vs HTTPS mismatch
expect(result.normalizedClientDomain).toBe('https://localhost');
expect(result.normalizedSpecDomain).toBe('http://localhost');
});
it('validates when both URLs have ports', () => {
const result = validateActionDomain(
'https://api.example.com:8443',
'https://api.example.com:8443/v1',
);
expect(result.isValid).toBe(true);
});
it('handles client domain that looks like URL but missing protocol separator', () => {
const result = validateActionDomain('httpexample.com', 'https://httpexample.com/api');
expect(result.isValid).toBe(true);
expect(result.normalizedClientDomain).toBe('https://httpexample.com');
});
});
});

View file

@ -1,6 +1,7 @@
import { replaceSpecialVars } from '../src/parsers';
import { replaceSpecialVars, parseCompactConvo } from '../src/parsers';
import { specialVariables } from '../src/config';
import type { TUser } from '../src/types';
import { EModelEndpoint } from '../src/schemas';
import type { TUser, TConversation } from '../src/types';
// Mock dayjs module with consistent date/time values regardless of environment
jest.mock('dayjs', () => {
@ -123,3 +124,138 @@ describe('replaceSpecialVars', () => {
expect(result).toContain('Test User'); // current_user
});
});
describe('parseCompactConvo', () => {
describe('iconURL security sanitization', () => {
test('should strip iconURL from OpenAI endpoint conversation input', () => {
const maliciousIconURL = 'https://evil-tracker.example.com/pixel.png?user=victim';
const conversation: Partial<TConversation> = {
model: 'gpt-4',
iconURL: maliciousIconURL,
endpoint: EModelEndpoint.openAI,
};
const result = parseCompactConvo({
endpoint: EModelEndpoint.openAI,
conversation,
});
expect(result).not.toBeNull();
expect(result?.iconURL).toBeUndefined();
expect(result?.model).toBe('gpt-4');
});
test('should strip iconURL from agents endpoint conversation input', () => {
const maliciousIconURL = 'https://evil-tracker.example.com/pixel.png';
const conversation: Partial<TConversation> = {
agent_id: 'agent_123',
iconURL: maliciousIconURL,
endpoint: EModelEndpoint.agents,
};
const result = parseCompactConvo({
endpoint: EModelEndpoint.agents,
conversation,
});
expect(result).not.toBeNull();
expect(result?.iconURL).toBeUndefined();
expect(result?.agent_id).toBe('agent_123');
});
test('should strip iconURL from anthropic endpoint conversation input', () => {
const maliciousIconURL = 'https://tracker.malicious.com/beacon.gif';
const conversation: Partial<TConversation> = {
model: 'claude-3-opus',
iconURL: maliciousIconURL,
endpoint: EModelEndpoint.anthropic,
};
const result = parseCompactConvo({
endpoint: EModelEndpoint.anthropic,
conversation,
});
expect(result).not.toBeNull();
expect(result?.iconURL).toBeUndefined();
expect(result?.model).toBe('claude-3-opus');
});
test('should strip iconURL from google endpoint conversation input', () => {
const maliciousIconURL = 'https://tracking.example.com/spy.png';
const conversation: Partial<TConversation> = {
model: 'gemini-pro',
iconURL: maliciousIconURL,
endpoint: EModelEndpoint.google,
};
const result = parseCompactConvo({
endpoint: EModelEndpoint.google,
conversation,
});
expect(result).not.toBeNull();
expect(result?.iconURL).toBeUndefined();
expect(result?.model).toBe('gemini-pro');
});
test('should strip iconURL from assistants endpoint conversation input', () => {
const maliciousIconURL = 'https://evil.com/track.png';
const conversation: Partial<TConversation> = {
assistant_id: 'asst_123',
iconURL: maliciousIconURL,
endpoint: EModelEndpoint.assistants,
};
const result = parseCompactConvo({
endpoint: EModelEndpoint.assistants,
conversation,
});
expect(result).not.toBeNull();
expect(result?.iconURL).toBeUndefined();
expect(result?.assistant_id).toBe('asst_123');
});
test('should preserve other conversation properties while stripping iconURL', () => {
const conversation: Partial<TConversation> = {
model: 'gpt-4',
iconURL: 'https://malicious.com/track.png',
endpoint: EModelEndpoint.openAI,
temperature: 0.7,
top_p: 0.9,
promptPrefix: 'You are a helpful assistant.',
maxContextTokens: 4000,
};
const result = parseCompactConvo({
endpoint: EModelEndpoint.openAI,
conversation,
});
expect(result).not.toBeNull();
expect(result?.iconURL).toBeUndefined();
expect(result?.model).toBe('gpt-4');
expect(result?.temperature).toBe(0.7);
expect(result?.top_p).toBe(0.9);
expect(result?.promptPrefix).toBe('You are a helpful assistant.');
expect(result?.maxContextTokens).toBe(4000);
});
test('should handle conversation without iconURL (no error)', () => {
const conversation: Partial<TConversation> = {
model: 'gpt-4',
endpoint: EModelEndpoint.openAI,
};
const result = parseCompactConvo({
endpoint: EModelEndpoint.openAI,
conversation,
});
expect(result).not.toBeNull();
expect(result?.iconURL).toBeUndefined();
expect(result?.model).toBe('gpt-4');
});
});
});

View file

@ -1,6 +1,6 @@
import { z } from 'zod';
import _axios from 'axios';
import { URL } from 'url';
import _axios from 'axios';
import crypto from 'crypto';
import { load } from 'js-yaml';
import type { ActionMetadata, ActionMetadataRuntime } from './types/agents';
@ -567,16 +567,44 @@ export type ValidationResult = {
};
/**
* Extracts the domain from a URL string.
* @param {string} url - The URL to extract the domain from.
* @returns {string} The extracted domain (hostname with protocol).
* Cross-platform IP validation (works in Node.js and browser).
* @param input - String to check if it's an IP address
* @returns 0 if not IP, 4 for IPv4, 6 for IPv6
*/
function isIP(input: string): number {
// IPv4 regex - matches 0.0.0.0 to 255.255.255.255
const ipv4Regex =
/^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
if (ipv4Regex.test(input)) {
return 4;
}
// IPv6 regex - simplified but covers most cases
// Handles compressed (::), full, and mixed notations
const ipv6Regex =
/^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$/;
if (ipv6Regex.test(input)) {
return 6;
}
return 0;
}
/**
* Extracts domain from URL (protocol + hostname).
* @param url - URL to extract from
* @returns Protocol and hostname (e.g., "https://example.com")
*/
export function extractDomainFromUrl(url: string): string {
try {
/** Parsed URL object */
const parsedUrl = new URL(url);
// Return protocol + hostname (e.g., "https://example.com")
// This preserves the protocol which is important for SSRF prevention
return `${parsedUrl.protocol}//${parsedUrl.hostname}`;
// Preserve brackets for IPv6 addresses using isIP
const ipVersion = isIP(parsedUrl.hostname);
const hostname = ipVersion === 6 ? `[${parsedUrl.hostname}]` : parsedUrl.hostname;
return `${parsedUrl.protocol}//${hostname}`;
} catch {
throw new Error(`Invalid URL format: ${url}`);
}
@ -590,45 +618,100 @@ export type DomainValidationResult = {
};
/**
* Validates that a client-provided domain matches the domain from an OpenAPI spec server URL.
* This is critical for preventing SSRF attacks where an attacker provides a whitelisted domain
* but uses a different (potentially internal) URL in the raw OpenAPI spec.
*
* @param {string} clientProvidedDomain - The domain provided by the client (may or may not include protocol)
* @param {string} specServerUrl - The server URL from the OpenAPI spec
* @returns {DomainValidationResult} Validation result with normalized domains
* Validates client domain matches OpenAPI spec server URL domain (SSRF prevention).
* @param clientProvidedDomain - Domain from client (with/without protocol)
* @param specServerUrl - Server URL from OpenAPI spec
* @returns Validation result with normalized domains
*/
export function validateActionDomain(
clientProvidedDomain: string,
specServerUrl: string,
): DomainValidationResult {
try {
// Extract domain from the spec's server URL
const specDomain = extractDomainFromUrl(specServerUrl);
const normalizedSpecDomain = extractDomainFromUrl(specDomain);
/** Parsed spec URL */
const specUrl = new URL(specServerUrl);
// Normalize client-provided domain (add https:// if no protocol)
const normalizedClientDomain = clientProvidedDomain.startsWith('http')
? clientProvidedDomain
: `https://${clientProvidedDomain}`;
// Compare normalized domains
// We check both the normalized client domain and the raw client domain
// to handle cases where the client might provide "example.com" vs "https://example.com"
if (
normalizedSpecDomain !== normalizedClientDomain &&
normalizedSpecDomain !== clientProvidedDomain
) {
if (specUrl.protocol !== 'http:' && specUrl.protocol !== 'https:') {
return {
isValid: false,
message: `Domain mismatch: Client provided '${clientProvidedDomain}', but spec uses '${normalizedSpecDomain}'`,
message: `Invalid protocol: Only HTTP and HTTPS are allowed, got ${specUrl.protocol}`,
};
}
/** Spec hostname only */
const specHostname = specUrl.hostname;
/** Spec domain with protocol (handle IPv6 brackets) */
const specIpVersion = isIP(specHostname);
const normalizedSpecDomain =
specIpVersion === 6
? `${specUrl.protocol}//[${specHostname}]`
: `${specUrl.protocol}//${specHostname}`;
/** Extract hostname from client domain if it's a full URL */
let clientHostname = clientProvidedDomain;
let clientHasProtocol = false;
// Check for any protocol in the client domain
if (clientProvidedDomain.includes('://')) {
if (
!clientProvidedDomain.startsWith('http://') &&
!clientProvidedDomain.startsWith('https://')
) {
return {
isValid: false,
message: `Invalid protocol: Only HTTP and HTTPS are allowed in client domain`,
};
}
try {
const clientUrl = new URL(clientProvidedDomain);
clientHostname = clientUrl.hostname;
clientHasProtocol = true;
} catch {
// If parsing fails, treat as hostname
clientHasProtocol = false;
}
}
/** Normalize IPv6 addresses by removing brackets for comparison */
const normalizedClientHostname = clientHostname.replace(/^\[(.+)\]$/, '$1');
const normalizedSpecHostname = specHostname.replace(/^\[(.+)\]$/, '$1');
/** Check if hostname is valid IP using cross-platform isIP */
const isIPAddress = isIP(normalizedClientHostname) !== 0;
/** Normalized client domain */
let normalizedClientDomain: string;
if (clientHasProtocol) {
normalizedClientDomain = extractDomainFromUrl(clientProvidedDomain);
} else {
// IP addresses inherit protocol from spec, domains default to https
if (isIPAddress) {
// IPv6 addresses need brackets in URLs
const ipVersion = isIP(normalizedClientHostname);
const hostname =
ipVersion === 6 && !clientHostname.startsWith('[')
? `[${normalizedClientHostname}]`
: clientHostname;
normalizedClientDomain = `${specUrl.protocol}//${hostname}`;
} else {
normalizedClientDomain = `https://${clientHostname}`;
}
}
if (
normalizedSpecDomain === normalizedClientDomain ||
(!clientHasProtocol && isIPAddress && normalizedClientHostname === normalizedSpecHostname)
) {
return {
isValid: true,
normalizedSpecDomain,
normalizedClientDomain,
};
}
return {
isValid: true,
isValid: false,
message: `Domain mismatch: Client provided '${clientProvidedDomain}', but spec uses '${specHostname}'`,
normalizedSpecDomain,
normalizedClientDomain,
};

View file

@ -149,6 +149,10 @@ export const resetPassword = () => `${BASE_URL}/api/auth/resetPassword`;
export const verifyEmail = () => `${BASE_URL}/api/user/verify`;
// Auth page URLs (for client-side navigation and redirects)
export const loginPage = () => `${BASE_URL}/login`;
export const registerPage = () => `${BASE_URL}/register`;
export const resendVerificationEmail = () => `${BASE_URL}/api/user/verify/resend`;
export const plugins = () => `${BASE_URL}/api/plugins`;

View file

@ -1003,6 +1003,7 @@ const sharedAnthropicModels = [
'claude-haiku-4-5-20251001',
'claude-opus-4-1',
'claude-opus-4-1-20250805',
'claude-opus-4-5',
'claude-sonnet-4-20250514',
'claude-sonnet-4-0',
'claude-opus-4-20250514',
@ -1132,6 +1133,7 @@ export const supportsBalanceCheck = {
[EModelEndpoint.azureAssistants]: true,
[EModelEndpoint.azureOpenAI]: true,
[EModelEndpoint.bedrock]: true,
[EModelEndpoint.google]: true,
};
export const visionModels = [
@ -1584,7 +1586,7 @@ export enum TTSProviders {
/** Enum for app-wide constants */
export enum Constants {
/** Key for the app's version. */
VERSION = 'v0.8.1-rc1',
VERSION = 'v0.8.1',
/** Key for the Custom Config's version (librechat.yaml). */
CONFIG_VERSION = '1.3.1',
/** Standard value for the first message's `parentMessageId` value, to indicate no parent exists. */

View file

@ -200,6 +200,27 @@ export const codeTypeMapping: { [key: string]: string } = {
tsv: 'text/tab-separated-values',
};
/** Maps image extensions to MIME types for formats browsers may not recognize */
export const imageTypeMapping: { [key: string]: string } = {
heic: 'image/heic',
heif: 'image/heif',
};
/**
* Infers the MIME type from a file's extension when the browser doesn't recognize it
* @param fileName - The name of the file including extension
* @param currentType - The current MIME type reported by the browser (may be empty)
* @returns The inferred MIME type if browser didn't provide one, otherwise the original type
*/
export function inferMimeType(fileName: string, currentType: string): string {
if (currentType) {
return currentType;
}
const extension = fileName.split('.').pop()?.toLowerCase() ?? '';
return codeTypeMapping[extension] || imageTypeMapping[extension] || currentType;
}
export const retrievalMimeTypes = [
/^(text\/(x-c|x-c\+\+|x-h|html|x-java|markdown|x-php|x-python|x-script\.python|x-ruby|x-tex|plain|vtt|xml))$/,
/^(application\/(json|pdf|vnd\.openxmlformats-officedocument\.(wordprocessingml\.document|presentationml\.presentation)))$/,

View file

@ -33,6 +33,7 @@ export * from './accessPermissions';
export * from './keys';
/* api call helpers */
export * from './headers-helpers';
export { loginPage, registerPage, apiBaseUrl } from './api-endpoints';
export { default as request } from './request';
export { dataService };
import * as dataService from './data-service';

View file

@ -56,6 +56,8 @@ const BaseOptionsSchema = z.object({
response_types_supported: z.array(z.string()).optional(),
/** Supported code challenge methods (defaults to ['S256', 'plain']) */
code_challenge_methods_supported: z.array(z.string()).optional(),
/** Skip code challenge validation and force S256 (useful for providers like AWS Cognito that support S256 but don't advertise it) */
skip_code_challenge_check: z.boolean().optional(),
/** OAuth revocation endpoint (optional - can be auto-discovered) */
revocation_endpoint: z.string().url().optional(),
/** OAuth revocation endpoint authentication methods supported (optional - can be auto-discovered) */

View file

@ -326,7 +326,7 @@ export const parseCompactConvo = ({
possibleValues?: TPossibleValues;
// TODO: POC for default schema
// defaultSchema?: Partial<EndpointSchema>,
}) => {
}): Omit<s.TConversation, 'iconURL'> | null => {
if (!endpoint) {
throw new Error(`undefined endpoint: ${endpoint}`);
}
@ -343,7 +343,11 @@ export const parseCompactConvo = ({
throw new Error(`Unknown endpointType: ${endpointType}`);
}
const convo = schema.parse(conversation) as s.TConversation | null;
// Strip iconURL from input before parsing - it should only be derived server-side
// from model spec configuration, not accepted from client requests
const { iconURL: _clientIconURL, ...conversationWithoutIconURL } = conversation;
const convo = schema.parse(conversationWithoutIconURL) as s.TConversation | null;
// const { models, secondaryModels } = possibleValues ?? {};
const { models } = possibleValues ?? {};

View file

@ -135,7 +135,7 @@ if (typeof window !== 'undefined') {
`Refresh token failed from shared link, attempting request to ${originalRequest.url}`,
);
} else {
window.location.href = '/login';
window.location.href = endpoints.loginPage();
}
} catch (err) {
processQueue(err as AxiosError, null);

View file

@ -0,0 +1,341 @@
import { anthropicSettings } from './schemas';
describe('anthropicSettings', () => {
describe('maxOutputTokens.reset()', () => {
const { reset } = anthropicSettings.maxOutputTokens;
describe('Claude Sonnet models', () => {
it('should return 64K for claude-sonnet-4', () => {
expect(reset('claude-sonnet-4')).toBe(64000);
});
it('should return 64K for claude-sonnet-4-5', () => {
expect(reset('claude-sonnet-4-5')).toBe(64000);
});
it('should return 64K for claude-sonnet-5', () => {
expect(reset('claude-sonnet-5')).toBe(64000);
});
it('should return 64K for future versions like claude-sonnet-9', () => {
expect(reset('claude-sonnet-9')).toBe(64000);
});
});
describe('Claude Haiku models', () => {
it('should return 64K for claude-haiku-4-5', () => {
expect(reset('claude-haiku-4-5')).toBe(64000);
});
it('should return 64K for claude-haiku-4', () => {
expect(reset('claude-haiku-4')).toBe(64000);
});
it('should return 64K for claude-haiku-5', () => {
expect(reset('claude-haiku-5')).toBe(64000);
});
it('should return 64K for future versions like claude-haiku-9', () => {
expect(reset('claude-haiku-9')).toBe(64000);
});
});
describe('Claude Opus 4.0-4.4 models (32K limit)', () => {
it('should return 32K for claude-opus-4', () => {
expect(reset('claude-opus-4')).toBe(32000);
});
it('should return 32K for claude-opus-4-0', () => {
expect(reset('claude-opus-4-0')).toBe(32000);
});
it('should return 32K for claude-opus-4-1', () => {
expect(reset('claude-opus-4-1')).toBe(32000);
});
it('should return 32K for claude-opus-4-2', () => {
expect(reset('claude-opus-4-2')).toBe(32000);
});
it('should return 32K for claude-opus-4-3', () => {
expect(reset('claude-opus-4-3')).toBe(32000);
});
it('should return 32K for claude-opus-4-4', () => {
expect(reset('claude-opus-4-4')).toBe(32000);
});
it('should return 32K for claude-opus-4.0', () => {
expect(reset('claude-opus-4.0')).toBe(32000);
});
it('should return 32K for claude-opus-4.1', () => {
expect(reset('claude-opus-4.1')).toBe(32000);
});
});
describe('Claude Opus 4.5+ models (64K limit - future-proof)', () => {
it('should return 64K for claude-opus-4-5', () => {
expect(reset('claude-opus-4-5')).toBe(64000);
});
it('should return 64K for claude-opus-4-6', () => {
expect(reset('claude-opus-4-6')).toBe(64000);
});
it('should return 64K for claude-opus-4-7', () => {
expect(reset('claude-opus-4-7')).toBe(64000);
});
it('should return 64K for claude-opus-4-8', () => {
expect(reset('claude-opus-4-8')).toBe(64000);
});
it('should return 64K for claude-opus-4-9', () => {
expect(reset('claude-opus-4-9')).toBe(64000);
});
it('should return 64K for claude-opus-4.5', () => {
expect(reset('claude-opus-4.5')).toBe(64000);
});
it('should return 64K for claude-opus-4.6', () => {
expect(reset('claude-opus-4.6')).toBe(64000);
});
});
describe('Claude Opus 4.10+ models (double-digit minor versions)', () => {
it('should return 64K for claude-opus-4-10', () => {
expect(reset('claude-opus-4-10')).toBe(64000);
});
it('should return 64K for claude-opus-4-11', () => {
expect(reset('claude-opus-4-11')).toBe(64000);
});
it('should return 64K for claude-opus-4-15', () => {
expect(reset('claude-opus-4-15')).toBe(64000);
});
it('should return 64K for claude-opus-4-20', () => {
expect(reset('claude-opus-4-20')).toBe(64000);
});
it('should return 64K for claude-opus-4.10', () => {
expect(reset('claude-opus-4.10')).toBe(64000);
});
});
describe('Claude Opus 5+ models (future major versions)', () => {
it('should return 64K for claude-opus-5', () => {
expect(reset('claude-opus-5')).toBe(64000);
});
it('should return 64K for claude-opus-6', () => {
expect(reset('claude-opus-6')).toBe(64000);
});
it('should return 64K for claude-opus-7', () => {
expect(reset('claude-opus-7')).toBe(64000);
});
it('should return 64K for claude-opus-9', () => {
expect(reset('claude-opus-9')).toBe(64000);
});
it('should return 64K for claude-opus-5-0', () => {
expect(reset('claude-opus-5-0')).toBe(64000);
});
it('should return 64K for claude-opus-5.0', () => {
expect(reset('claude-opus-5.0')).toBe(64000);
});
});
describe('Model name variations with dates and suffixes', () => {
it('should return 64K for claude-opus-4-5-20250420', () => {
expect(reset('claude-opus-4-5-20250420')).toBe(64000);
});
it('should return 64K for claude-opus-4-6-20260101', () => {
expect(reset('claude-opus-4-6-20260101')).toBe(64000);
});
it('should return 32K for claude-opus-4-1-20250805', () => {
expect(reset('claude-opus-4-1-20250805')).toBe(32000);
});
it('should return 32K for claude-opus-4-0-20240229', () => {
expect(reset('claude-opus-4-0-20240229')).toBe(32000);
});
});
describe('Legacy Claude models', () => {
it('should return 8192 for claude-3-opus', () => {
expect(reset('claude-3-opus')).toBe(8192);
});
it('should return 8192 for claude-3-5-sonnet', () => {
expect(reset('claude-3-5-sonnet')).toBe(8192);
});
it('should return 8192 for claude-3-5-haiku', () => {
expect(reset('claude-3-5-haiku')).toBe(8192);
});
it('should return 8192 for claude-3-7-sonnet', () => {
expect(reset('claude-3-7-sonnet')).toBe(8192);
});
it('should return 8192 for claude-2', () => {
expect(reset('claude-2')).toBe(8192);
});
it('should return 8192 for claude-2.1', () => {
expect(reset('claude-2.1')).toBe(8192);
});
it('should return 8192 for claude-instant', () => {
expect(reset('claude-instant')).toBe(8192);
});
});
describe('Non-Claude models and edge cases', () => {
it('should return 8192 for unknown model', () => {
expect(reset('unknown-model')).toBe(8192);
});
it('should return 8192 for empty string', () => {
expect(reset('')).toBe(8192);
});
it('should return 8192 for gpt-4', () => {
expect(reset('gpt-4')).toBe(8192);
});
it('should return 8192 for gemini-pro', () => {
expect(reset('gemini-pro')).toBe(8192);
});
});
describe('Regex pattern edge cases', () => {
it('should not match claude-opus-3', () => {
expect(reset('claude-opus-3')).toBe(8192);
});
it('should not match opus-4-5 without claude prefix', () => {
expect(reset('opus-4-5')).toBe(8192);
});
it('should NOT match claude.opus.4.5 (incorrect separator pattern)', () => {
// Model names use hyphens after "claude", not dots
expect(reset('claude.opus.4.5')).toBe(8192);
});
it('should match claude-opus45 (no separator after opus)', () => {
// The regex allows optional separators, so "45" can follow directly
// In practice, Anthropic uses separators, but regex is permissive
expect(reset('claude-opus45')).toBe(64000);
});
});
});
describe('maxOutputTokens.set()', () => {
const { set } = anthropicSettings.maxOutputTokens;
describe('Claude Sonnet and Haiku 4+ models (64K cap)', () => {
it('should cap at 64K for claude-sonnet-4 when value exceeds', () => {
expect(set(100000, 'claude-sonnet-4')).toBe(64000);
});
it('should allow 50K for claude-sonnet-4', () => {
expect(set(50000, 'claude-sonnet-4')).toBe(50000);
});
it('should cap at 64K for claude-haiku-4-5 when value exceeds', () => {
expect(set(80000, 'claude-haiku-4-5')).toBe(64000);
});
});
describe('Claude Opus 4.5+ models (64K cap)', () => {
it('should cap at 64K for claude-opus-4-5 when value exceeds', () => {
expect(set(100000, 'claude-opus-4-5')).toBe(64000);
});
it('should cap at model-specific 64K limit, not global 128K limit', () => {
// Values between 64K and 128K should be capped at 64K (model limit)
// This verifies the fix for the unreachable code issue
expect(set(70000, 'claude-opus-4-5')).toBe(64000);
expect(set(80000, 'claude-opus-4-5')).toBe(64000);
expect(set(100000, 'claude-opus-4-5')).toBe(64000);
expect(set(128000, 'claude-opus-4-5')).toBe(64000);
// Values above 128K should also be capped at 64K (not 128K)
expect(set(150000, 'claude-opus-4-5')).toBe(64000);
});
it('should allow 50K for claude-opus-4-5', () => {
expect(set(50000, 'claude-opus-4-5')).toBe(50000);
});
it('should cap at 64K for claude-opus-4-6', () => {
expect(set(80000, 'claude-opus-4-6')).toBe(64000);
});
it('should cap at 64K for claude-opus-5', () => {
expect(set(100000, 'claude-opus-5')).toBe(64000);
});
it('should cap at 64K for claude-opus-4-10', () => {
expect(set(100000, 'claude-opus-4-10')).toBe(64000);
});
});
describe('Claude Opus 4.0-4.4 models (32K cap)', () => {
it('should cap at 32K for claude-opus-4', () => {
expect(set(50000, 'claude-opus-4')).toBe(32000);
});
it('should allow 20K for claude-opus-4', () => {
expect(set(20000, 'claude-opus-4')).toBe(20000);
});
it('should cap at 32K for claude-opus-4-1', () => {
expect(set(50000, 'claude-opus-4-1')).toBe(32000);
});
it('should cap at 32K for claude-opus-4-4', () => {
expect(set(40000, 'claude-opus-4-4')).toBe(32000);
});
});
describe('Global 128K cap for all models', () => {
it('should cap at model-specific limit first, then global', () => {
// claude-sonnet-4 has 64K limit, so caps at 64K not 128K
expect(set(150000, 'claude-sonnet-4')).toBe(64000);
});
it('should cap at 128K for claude-3 models', () => {
expect(set(150000, 'claude-3-opus')).toBe(128000);
});
it('should cap at 128K for unknown models', () => {
expect(set(200000, 'unknown-model')).toBe(128000);
});
});
describe('Valid values within limits', () => {
it('should allow valid values for legacy models', () => {
expect(set(8000, 'claude-3-opus')).toBe(8000);
});
it('should allow 1 token minimum', () => {
expect(set(1, 'claude-opus-4-5')).toBe(1);
});
it('should allow 128K exactly', () => {
expect(set(128000, 'claude-3-opus')).toBe(128000);
});
});
});
});

View file

@ -41,7 +41,6 @@ export enum Providers {
BEDROCK = 'bedrock',
MISTRALAI = 'mistralai',
MISTRAL = 'mistral',
OLLAMA = 'ollama',
DEEPSEEK = 'deepseek',
OPENROUTER = 'openrouter',
XAI = 'xai',
@ -59,7 +58,6 @@ export const documentSupportedProviders = new Set<string>([
Providers.VERTEXAI,
Providers.MISTRALAI,
Providers.MISTRAL,
Providers.OLLAMA,
Providers.DEEPSEEK,
Providers.OPENROUTER,
Providers.XAI,
@ -71,7 +69,6 @@ const openAILikeProviders = new Set<string>([
EModelEndpoint.custom,
Providers.MISTRALAI,
Providers.MISTRAL,
Providers.OLLAMA,
Providers.DEEPSEEK,
Providers.OPENROUTER,
Providers.XAI,
@ -386,6 +383,10 @@ export const anthropicSettings = {
return CLAUDE_4_64K_MAX_OUTPUT;
}
if (/claude-opus[-.]?(?:[5-9]|4[-.]?([5-9]|\d{2,}))/.test(modelName)) {
return CLAUDE_4_64K_MAX_OUTPUT;
}
if (/claude-opus[-.]?[4-9]/.test(modelName)) {
return CLAUDE_32K_MAX_OUTPUT;
}
@ -397,7 +398,14 @@ export const anthropicSettings = {
return CLAUDE_4_64K_MAX_OUTPUT;
}
if (/claude-(?:opus|haiku)[-.]?[4-9]/.test(modelName) && value > CLAUDE_32K_MAX_OUTPUT) {
if (/claude-opus[-.]?(?:[5-9]|4[-.]?([5-9]|\d{2,}))/.test(modelName)) {
if (value > CLAUDE_4_64K_MAX_OUTPUT) {
return CLAUDE_4_64K_MAX_OUTPUT;
}
return value;
}
if (/claude-opus[-.]?[4-9]/.test(modelName) && value > CLAUDE_32K_MAX_OUTPUT) {
return CLAUDE_32K_MAX_OUTPUT;
}
@ -610,6 +618,8 @@ export const tMessageSchema = z.object({
/* frontend components */
iconURL: z.string().nullable().optional(),
feedback: feedbackSchema.optional(),
/** metadata */
metadata: z.record(z.unknown()).optional(),
});
export type MemoryArtifact = {

View file

@ -185,8 +185,8 @@ export interface MCPConnectionStatusResponse {
export interface MCPServerConnectionStatusResponse {
success: boolean;
serverName: string;
connectionStatus: string;
requiresOAuth: boolean;
connectionStatus: 'disconnected' | 'connecting' | 'connected' | 'error';
}
export interface MCPAuthValuesResponse {

View file

@ -1,6 +1,6 @@
{
"name": "@librechat/data-schemas",
"version": "0.0.23",
"version": "0.0.31",
"description": "Mongoose schemas and models for LibreChat",
"type": "module",
"main": "dist/index.cjs",
@ -28,7 +28,7 @@
},
"repository": {
"type": "git",
"url": "git+https://github.com/danny-avila/LibreChat.git"
"url": "https://github.com/danny-avila/LibreChat"
},
"author": "",
"license": "MIT",
@ -38,7 +38,7 @@
"homepage": "https://librechat.ai",
"devDependencies": {
"@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",
@ -51,7 +51,7 @@
"jest": "^30.2.0",
"jest-junit": "^16.0.0",
"mongodb-memory-server": "^10.1.4",
"rimraf": "^5.0.1",
"rimraf": "^6.1.2",
"rollup": "^4.22.4",
"rollup-plugin-peer-deps-external": "^2.2.4",
"rollup-plugin-typescript2": "^0.35.0",

View file

@ -418,6 +418,41 @@ describe('Token Methods - Detailed Tests', () => {
expect(updated).toBeNull();
});
test('should update expiresAt when expiresIn is provided', async () => {
const beforeUpdate = Date.now();
const newExpiresIn = 7200;
const updated = await methods.updateToken(
{ token: 'update-token' },
{ expiresIn: newExpiresIn },
);
const afterUpdate = Date.now();
expect(updated).toBeDefined();
expect(updated?.expiresAt).toBeDefined();
const expectedMinExpiry = beforeUpdate + newExpiresIn * 1000;
const expectedMaxExpiry = afterUpdate + newExpiresIn * 1000;
expect(updated!.expiresAt.getTime()).toBeGreaterThanOrEqual(expectedMinExpiry);
expect(updated!.expiresAt.getTime()).toBeLessThanOrEqual(expectedMaxExpiry);
});
test('should not modify expiresAt when expiresIn is not provided', async () => {
const original = await Token.findOne({ token: 'update-token' });
const originalExpiresAt = original!.expiresAt.getTime();
const updated = await methods.updateToken(
{ token: 'update-token' },
{ email: 'changed@example.com' },
);
expect(updated).toBeDefined();
expect(updated?.email).toBe('changed@example.com');
expect(updated!.expiresAt.getTime()).toBe(originalExpiresAt);
});
});
describe('deleteTokens', () => {
@ -617,4 +652,171 @@ describe('Token Methods - Detailed Tests', () => {
expect(remainingTokens.find((t) => t.token === 'email-verify-token-2')).toBeUndefined();
});
});
describe('Email Normalization', () => {
let normUserId: mongoose.Types.ObjectId;
beforeEach(async () => {
normUserId = new mongoose.Types.ObjectId();
// Create token with lowercase email (as stored in DB)
await Token.create({
token: 'norm-token-1',
userId: normUserId,
email: 'john.doe@example.com',
createdAt: new Date(),
expiresAt: new Date(Date.now() + 3600000),
});
});
describe('findToken email normalization', () => {
test('should find token by email with different case (case-insensitive)', async () => {
const foundUpper = await methods.findToken({ email: 'JOHN.DOE@EXAMPLE.COM' });
const foundMixed = await methods.findToken({ email: 'John.Doe@Example.COM' });
const foundLower = await methods.findToken({ email: 'john.doe@example.com' });
expect(foundUpper).toBeDefined();
expect(foundUpper?.token).toBe('norm-token-1');
expect(foundMixed).toBeDefined();
expect(foundMixed?.token).toBe('norm-token-1');
expect(foundLower).toBeDefined();
expect(foundLower?.token).toBe('norm-token-1');
});
test('should find token by email with leading/trailing whitespace', async () => {
const foundWithSpaces = await methods.findToken({ email: ' john.doe@example.com ' });
const foundWithTabs = await methods.findToken({ email: '\tjohn.doe@example.com\t' });
expect(foundWithSpaces).toBeDefined();
expect(foundWithSpaces?.token).toBe('norm-token-1');
expect(foundWithTabs).toBeDefined();
expect(foundWithTabs?.token).toBe('norm-token-1');
});
test('should find token by email with both case difference and whitespace', async () => {
const found = await methods.findToken({ email: ' JOHN.DOE@EXAMPLE.COM ' });
expect(found).toBeDefined();
expect(found?.token).toBe('norm-token-1');
});
test('should find token with combined email and other criteria', async () => {
const found = await methods.findToken({
userId: normUserId.toString(),
email: 'John.Doe@Example.COM',
});
expect(found).toBeDefined();
expect(found?.token).toBe('norm-token-1');
});
});
describe('deleteTokens email normalization', () => {
test('should delete token by email with different case', async () => {
const result = await methods.deleteTokens({ email: 'JOHN.DOE@EXAMPLE.COM' });
expect(result.deletedCount).toBe(1);
const remaining = await Token.find({});
expect(remaining).toHaveLength(0);
});
test('should delete token by email with whitespace', async () => {
const result = await methods.deleteTokens({ email: ' john.doe@example.com ' });
expect(result.deletedCount).toBe(1);
const remaining = await Token.find({});
expect(remaining).toHaveLength(0);
});
test('should delete token by email with case and whitespace combined', async () => {
const result = await methods.deleteTokens({ email: ' John.Doe@EXAMPLE.COM ' });
expect(result.deletedCount).toBe(1);
const remaining = await Token.find({});
expect(remaining).toHaveLength(0);
});
test('should only delete matching token when using normalized email', async () => {
// Create additional token with different email
await Token.create({
token: 'norm-token-2',
userId: new mongoose.Types.ObjectId(),
email: 'jane.doe@example.com',
createdAt: new Date(),
expiresAt: new Date(Date.now() + 3600000),
});
const result = await methods.deleteTokens({ email: 'JOHN.DOE@EXAMPLE.COM' });
expect(result.deletedCount).toBe(1);
const remaining = await Token.find({});
expect(remaining).toHaveLength(1);
expect(remaining[0].email).toBe('jane.doe@example.com');
});
});
describe('Email verification flow with normalization', () => {
test('should handle OpenID provider email case mismatch scenario', async () => {
/**
* Simulate the exact bug scenario:
* 1. User registers with email stored as lowercase
* 2. OpenID provider returns email with different casing
* 3. System should still find and delete the correct token
*/
const userId = new mongoose.Types.ObjectId();
// Token created during registration (email stored lowercase)
await Token.create({
token: 'verification-token',
userId: userId,
email: 'user@company.com',
createdAt: new Date(),
expiresAt: new Date(Date.now() + 86400000),
});
// OpenID provider returns email with different case
const emailFromProvider = 'User@Company.COM';
// Should find the token despite case mismatch
const found = await methods.findToken({ email: emailFromProvider });
expect(found).toBeDefined();
expect(found?.token).toBe('verification-token');
// Should delete the token despite case mismatch
const deleted = await methods.deleteTokens({ email: emailFromProvider });
expect(deleted.deletedCount).toBe(1);
});
test('should handle resend verification email with case mismatch', async () => {
const userId = new mongoose.Types.ObjectId();
// Old verification token
await Token.create({
token: 'old-verification',
userId: userId,
email: 'john.smith@enterprise.com',
createdAt: new Date(Date.now() - 3600000),
expiresAt: new Date(Date.now() + 82800000),
});
// User requests resend with different email casing
const userInputEmail = ' John.Smith@ENTERPRISE.COM ';
// Delete old tokens for this email
const deleted = await methods.deleteTokens({ email: userInputEmail });
expect(deleted.deletedCount).toBe(1);
// Verify token was actually deleted
const remaining = await Token.find({ userId });
expect(remaining).toHaveLength(0);
});
});
});
});

View file

@ -35,7 +35,13 @@ export function createTokenMethods(mongoose: typeof import('mongoose')) {
): Promise<IToken | null> {
try {
const Token = mongoose.models.Token;
return await Token.findOneAndUpdate(query, updateData, { new: true });
const dataToUpdate = { ...updateData };
if (updateData?.expiresIn !== undefined) {
dataToUpdate.expiresAt = new Date(Date.now() + updateData.expiresIn * 1000);
}
return await Token.findOneAndUpdate(query, dataToUpdate, { new: true });
} catch (error) {
logger.debug('An error occurred while updating token:', error);
throw error;
@ -44,6 +50,7 @@ export function createTokenMethods(mongoose: typeof import('mongoose')) {
/**
* Deletes all Token documents that match the provided token, user ID, or email.
* Email is automatically normalized to lowercase for case-insensitive matching.
*/
async function deleteTokens(query: TokenQuery): Promise<TokenDeleteResult> {
try {
@ -57,7 +64,7 @@ export function createTokenMethods(mongoose: typeof import('mongoose')) {
conditions.push({ token: query.token });
}
if (query.email !== undefined) {
conditions.push({ email: query.email });
conditions.push({ email: query.email.trim().toLowerCase() });
}
if (query.identifier !== undefined) {
conditions.push({ identifier: query.identifier });
@ -81,6 +88,7 @@ export function createTokenMethods(mongoose: typeof import('mongoose')) {
/**
* Finds a Token document that matches the provided query.
* Email is automatically normalized to lowercase for case-insensitive matching.
*/
async function findToken(query: TokenQuery, options?: QueryOptions): Promise<IToken | null> {
try {
@ -94,7 +102,7 @@ export function createTokenMethods(mongoose: typeof import('mongoose')) {
conditions.push({ token: query.token });
}
if (query.email) {
conditions.push({ email: query.email });
conditions.push({ email: query.email.trim().toLowerCase() });
}
if (query.identifier) {
conditions.push({ identifier: query.identifier });

View file

@ -0,0 +1,623 @@
import mongoose from 'mongoose';
import { MongoMemoryServer } from 'mongodb-memory-server';
import type * as t from '~/types';
import { createUserMethods } from './user';
import userSchema from '~/schema/user';
import balanceSchema from '~/schema/balance';
/** Mocking crypto for generateToken */
jest.mock('~/crypto', () => ({
signPayload: jest.fn().mockResolvedValue('mocked-token'),
}));
let mongoServer: MongoMemoryServer;
let User: mongoose.Model<t.IUser>;
let Balance: mongoose.Model<t.IBalance>;
let methods: ReturnType<typeof createUserMethods>;
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
const mongoUri = mongoServer.getUri();
await mongoose.connect(mongoUri);
/** Register models */
User = mongoose.models.User || mongoose.model<t.IUser>('User', userSchema);
Balance = mongoose.models.Balance || mongoose.model<t.IBalance>('Balance', balanceSchema);
/** Initialize methods */
methods = createUserMethods(mongoose);
});
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
beforeEach(async () => {
await mongoose.connection.dropDatabase();
});
describe('User Methods - Database Tests', () => {
describe('findUser', () => {
test('should find user by exact email', async () => {
await User.create({
name: 'Test User',
email: 'test@example.com',
provider: 'local',
});
const found = await methods.findUser({ email: 'test@example.com' });
expect(found).toBeDefined();
expect(found?.email).toBe('test@example.com');
});
test('should find user by email with different case (case-insensitive)', async () => {
await User.create({
name: 'Test User',
email: 'test@example.com', // stored lowercase by schema
provider: 'local',
});
/** Test various case combinations - all should find the same user */
const foundUpper = await methods.findUser({ email: 'TEST@EXAMPLE.COM' });
const foundMixed = await methods.findUser({ email: 'Test@Example.COM' });
const foundLower = await methods.findUser({ email: 'test@example.com' });
expect(foundUpper).toBeDefined();
expect(foundUpper?.email).toBe('test@example.com');
expect(foundMixed).toBeDefined();
expect(foundMixed?.email).toBe('test@example.com');
expect(foundLower).toBeDefined();
expect(foundLower?.email).toBe('test@example.com');
});
test('should find user by email with leading/trailing whitespace (trimmed)', async () => {
await User.create({
name: 'Test User',
email: 'test@example.com',
provider: 'local',
});
const foundWithSpaces = await methods.findUser({ email: ' test@example.com ' });
const foundWithTabs = await methods.findUser({ email: '\ttest@example.com\t' });
expect(foundWithSpaces).toBeDefined();
expect(foundWithSpaces?.email).toBe('test@example.com');
expect(foundWithTabs).toBeDefined();
expect(foundWithTabs?.email).toBe('test@example.com');
});
test('should find user by email with both case difference and whitespace', async () => {
await User.create({
name: 'Test User',
email: 'john.doe@example.com',
provider: 'local',
});
const found = await methods.findUser({ email: ' John.Doe@EXAMPLE.COM ' });
expect(found).toBeDefined();
expect(found?.email).toBe('john.doe@example.com');
});
test('should normalize email in $or conditions', async () => {
await User.create({
name: 'Test User',
email: 'test@example.com',
provider: 'openid',
openidId: 'openid-123',
});
const found = await methods.findUser({
$or: [{ openidId: 'different-id' }, { email: 'TEST@EXAMPLE.COM' }],
});
expect(found).toBeDefined();
expect(found?.email).toBe('test@example.com');
});
test('should find user by non-email criteria without affecting them', async () => {
await User.create({
name: 'Test User',
email: 'test@example.com',
provider: 'openid',
openidId: 'openid-123',
});
const found = await methods.findUser({ openidId: 'openid-123' });
expect(found).toBeDefined();
expect(found?.openidId).toBe('openid-123');
});
test('should apply field selection correctly', async () => {
await User.create({
name: 'Test User',
email: 'test@example.com',
provider: 'local',
username: 'testuser',
});
const found = await methods.findUser({ email: 'test@example.com' }, 'email name');
expect(found).toBeDefined();
expect(found?.email).toBe('test@example.com');
expect(found?.name).toBe('Test User');
expect(found?.username).toBeUndefined();
expect(found?.provider).toBeUndefined();
});
test('should return null for non-existent user', async () => {
const found = await methods.findUser({ email: 'nonexistent@example.com' });
expect(found).toBeNull();
});
});
describe('createUser', () => {
test('should create a user and return ObjectId by default', async () => {
const result = await methods.createUser({
name: 'New User',
email: 'new@example.com',
provider: 'local',
});
expect(result).toBeInstanceOf(mongoose.Types.ObjectId);
const user = await User.findById(result);
expect(user).toBeDefined();
expect(user?.name).toBe('New User');
expect(user?.email).toBe('new@example.com');
});
test('should create a user and return user object when returnUser is true', async () => {
const result = await methods.createUser(
{
name: 'New User',
email: 'new@example.com',
provider: 'local',
},
undefined,
true,
true,
);
expect(result).toHaveProperty('_id');
expect(result).toHaveProperty('name', 'New User');
expect(result).toHaveProperty('email', 'new@example.com');
});
test('should store email as lowercase regardless of input case', async () => {
await methods.createUser({
name: 'New User',
email: 'NEW@EXAMPLE.COM',
provider: 'local',
});
const user = await User.findOne({ email: 'new@example.com' });
expect(user).toBeDefined();
expect(user?.email).toBe('new@example.com');
});
test('should create user with TTL when disableTTL is false', async () => {
const result = await methods.createUser(
{
name: 'TTL User',
email: 'ttl@example.com',
provider: 'local',
},
undefined,
false,
true,
);
expect(result).toHaveProperty('expiresAt');
const expiresAt = (result as t.IUser).expiresAt;
expect(expiresAt).toBeInstanceOf(Date);
/** Should expire in approximately 1 week */
const oneWeekMs = 604800 * 1000;
const expectedExpiry = Date.now() + oneWeekMs;
expect(expiresAt!.getTime()).toBeGreaterThan(expectedExpiry - 10000);
expect(expiresAt!.getTime()).toBeLessThan(expectedExpiry + 10000);
});
test('should create balance record when balanceConfig is provided', async () => {
const userId = await methods.createUser(
{
name: 'Balance User',
email: 'balance@example.com',
provider: 'local',
},
{
enabled: true,
startBalance: 1000,
},
);
const balance = await Balance.findOne({ user: userId });
expect(balance).toBeDefined();
expect(balance?.tokenCredits).toBe(1000);
});
});
describe('updateUser', () => {
test('should update user fields', async () => {
const user = await User.create({
name: 'Original Name',
email: 'test@example.com',
provider: 'local',
});
const updated = await methods.updateUser(user._id?.toString() ?? '', {
name: 'Updated Name',
});
expect(updated).toBeDefined();
expect(updated?.name).toBe('Updated Name');
expect(updated?.email).toBe('test@example.com');
});
test('should remove expiresAt field on update', async () => {
const user = await User.create({
name: 'TTL User',
email: 'ttl@example.com',
provider: 'local',
expiresAt: new Date(Date.now() + 604800 * 1000),
});
const updated = await methods.updateUser(user._id?.toString() || '', {
name: 'No longer TTL',
});
expect(updated).toBeDefined();
expect(updated?.expiresAt).toBeUndefined();
});
test('should return null for non-existent user', async () => {
const fakeId = new mongoose.Types.ObjectId();
const result = await methods.updateUser(fakeId.toString(), { name: 'Test' });
expect(result).toBeNull();
});
});
describe('getUserById', () => {
test('should get user by ID', async () => {
const user = await User.create({
name: 'Test User',
email: 'test@example.com',
provider: 'local',
});
const found = await methods.getUserById(user._id?.toString() || '');
expect(found).toBeDefined();
expect(found?.name).toBe('Test User');
});
test('should apply field selection', async () => {
const user = await User.create({
name: 'Test User',
email: 'test@example.com',
provider: 'local',
username: 'testuser',
});
const found = await methods.getUserById(user._id?.toString() || '', 'name email');
expect(found).toBeDefined();
expect(found?.name).toBe('Test User');
expect(found?.email).toBe('test@example.com');
expect(found?.username).toBeUndefined();
});
test('should return null for non-existent ID', async () => {
const fakeId = new mongoose.Types.ObjectId();
const found = await methods.getUserById(fakeId.toString());
expect(found).toBeNull();
});
});
describe('deleteUserById', () => {
test('should delete user by ID', async () => {
const user = await User.create({
name: 'To Delete',
email: 'delete@example.com',
provider: 'local',
});
const result = await methods.deleteUserById(user._id?.toString() || '');
expect(result.deletedCount).toBe(1);
expect(result.message).toBe('User was deleted successfully.');
const found = await User.findById(user._id);
expect(found).toBeNull();
});
test('should return zero count for non-existent user', async () => {
const fakeId = new mongoose.Types.ObjectId();
const result = await methods.deleteUserById(fakeId.toString());
expect(result.deletedCount).toBe(0);
expect(result.message).toBe('No user found with that ID.');
});
});
describe('countUsers', () => {
test('should count all users', async () => {
await User.create([
{ name: 'User 1', email: 'user1@example.com', provider: 'local' },
{ name: 'User 2', email: 'user2@example.com', provider: 'local' },
{ name: 'User 3', email: 'user3@example.com', provider: 'openid' },
]);
const count = await methods.countUsers();
expect(count).toBe(3);
});
test('should count users with filter', async () => {
await User.create([
{ name: 'User 1', email: 'user1@example.com', provider: 'local' },
{ name: 'User 2', email: 'user2@example.com', provider: 'local' },
{ name: 'User 3', email: 'user3@example.com', provider: 'openid' },
]);
const count = await methods.countUsers({ provider: 'local' });
expect(count).toBe(2);
});
test('should return zero for empty collection', async () => {
const count = await methods.countUsers();
expect(count).toBe(0);
});
});
describe('searchUsers', () => {
beforeEach(async () => {
await User.create([
{ name: 'John Doe', email: 'john@example.com', username: 'johnd', provider: 'local' },
{ name: 'Jane Smith', email: 'jane@example.com', username: 'janes', provider: 'local' },
{
name: 'Bob Johnson',
email: 'bob@example.com',
username: 'bobbyj',
provider: 'local',
},
{
name: 'Alice Wonder',
email: 'alice@test.com',
username: 'alice',
provider: 'openid',
},
]);
});
test('should search by name', async () => {
const results = await methods.searchUsers({ searchPattern: 'John' });
expect(results).toHaveLength(2); // John Doe and Bob Johnson
});
test('should search by email', async () => {
const results = await methods.searchUsers({ searchPattern: 'example.com' });
expect(results).toHaveLength(3);
});
test('should search by username', async () => {
const results = await methods.searchUsers({ searchPattern: 'alice' });
expect(results).toHaveLength(1);
expect((results[0] as unknown as t.IUser)?.username).toBe('alice');
});
test('should be case-insensitive', async () => {
const results = await methods.searchUsers({ searchPattern: 'JOHN' });
expect(results.length).toBeGreaterThan(0);
});
test('should respect limit', async () => {
const results = await methods.searchUsers({ searchPattern: 'example', limit: 2 });
expect(results).toHaveLength(2);
});
test('should return empty array for empty search pattern', async () => {
const results = await methods.searchUsers({ searchPattern: '' });
expect(results).toEqual([]);
});
test('should return empty array for whitespace-only pattern', async () => {
const results = await methods.searchUsers({ searchPattern: ' ' });
expect(results).toEqual([]);
});
test('should apply field selection', async () => {
const results = await methods.searchUsers({
searchPattern: 'john',
fieldsToSelect: 'name email',
});
expect(results.length).toBeGreaterThan(0);
expect(results[0]).toHaveProperty('name');
expect(results[0]).toHaveProperty('email');
expect(results[0]).not.toHaveProperty('username');
});
test('should sort by relevance (exact match first)', async () => {
const results = await methods.searchUsers({ searchPattern: 'alice' });
/** 'alice' username should score highest due to exact match */
expect((results[0] as unknown as t.IUser).username).toBe('alice');
});
});
describe('toggleUserMemories', () => {
test('should enable memories for user', async () => {
const user = await User.create({
name: 'Test User',
email: 'test@example.com',
provider: 'local',
});
const updated = await methods.toggleUserMemories(user._id?.toString() || '', true);
expect(updated).toBeDefined();
expect(updated?.personalization?.memories).toBe(true);
});
test('should disable memories for user', async () => {
const user = await User.create({
name: 'Test User',
email: 'test@example.com',
provider: 'local',
personalization: { memories: true },
});
const updated = await methods.toggleUserMemories(user._id?.toString() || '', false);
expect(updated).toBeDefined();
expect(updated?.personalization?.memories).toBe(false);
});
test('should update personalization.memories field', async () => {
const user = await User.create({
name: 'Test User',
email: 'test@example.com',
provider: 'local',
});
/** Toggle memories to true */
const updated = await methods.toggleUserMemories(user._id?.toString() || '', true);
expect(updated?.personalization).toBeDefined();
expect(updated?.personalization?.memories).toBe(true);
/** Toggle back to false */
const updatedAgain = await methods.toggleUserMemories(user._id?.toString() || '', false);
expect(updatedAgain?.personalization?.memories).toBe(false);
});
test('should return null for non-existent user', async () => {
const fakeId = new mongoose.Types.ObjectId();
const result = await methods.toggleUserMemories(fakeId.toString(), true);
expect(result).toBeNull();
});
});
describe('Email Normalization Edge Cases', () => {
test('should handle email with multiple spaces', async () => {
await User.create({
name: 'Test User',
email: 'test@example.com',
provider: 'local',
});
const found = await methods.findUser({ email: ' test@example.com ' });
expect(found).toBeDefined();
expect(found?.email).toBe('test@example.com');
});
test('should handle mixed case with international characters', async () => {
await User.create({
name: 'Test User',
email: 'user@example.com',
provider: 'local',
});
const found = await methods.findUser({ email: 'USER@EXAMPLE.COM' });
expect(found).toBeDefined();
});
test('should handle email normalization in complex $or queries', async () => {
const user1 = await User.create({
name: 'User One',
email: 'user1@example.com',
provider: 'openid',
openidId: 'openid-1',
});
await User.create({
name: 'User Two',
email: 'user2@example.com',
provider: 'openid',
openidId: 'openid-2',
});
/** Search with mixed case email in $or */
const found = await methods.findUser({
$or: [{ openidId: 'nonexistent' }, { email: 'USER1@EXAMPLE.COM' }],
});
expect(found).toBeDefined();
expect(found?._id?.toString()).toBe(user1._id?.toString());
});
test('should not normalize non-string email values', async () => {
await User.create({
name: 'Test User',
email: 'test@example.com',
provider: 'local',
});
/** Using regex for email (should not be normalized) */
const found = await methods.findUser({ email: /test@example\.com/i });
expect(found).toBeDefined();
expect(found?.email).toBe('test@example.com');
});
test('should handle OpenID provider migration scenario', async () => {
/** Simulate user stored with lowercase email */
await User.create({
name: 'John Doe',
email: 'john.doe@company.com',
provider: 'openid',
openidId: 'old-provider-id',
});
/**
* New OpenID provider returns email with different casing
* This simulates the exact bug reported in the GitHub issue
*/
const emailFromNewProvider = 'John.Doe@Company.COM';
const found = await methods.findUser({ email: emailFromNewProvider });
expect(found).toBeDefined();
expect(found?.email).toBe('john.doe@company.com');
expect(found?.name).toBe('John Doe');
});
test('should handle SAML provider email normalization', async () => {
await User.create({
name: 'SAML User',
email: 'saml.user@enterprise.com',
provider: 'saml',
samlId: 'saml-123',
});
/** SAML providers sometimes return emails in different formats */
const found = await methods.findUser({ email: ' SAML.USER@ENTERPRISE.COM ' });
expect(found).toBeDefined();
expect(found?.provider).toBe('saml');
});
});
});

View file

@ -4,15 +4,37 @@ import { signPayload } from '~/crypto';
/** Factory function that takes mongoose instance and returns the methods */
export function createUserMethods(mongoose: typeof import('mongoose')) {
/**
* Normalizes email fields in search criteria to lowercase and trimmed.
* Handles both direct email fields and $or arrays containing email conditions.
*/
function normalizeEmailInCriteria<T extends FilterQuery<IUser>>(criteria: T): T {
const normalized = { ...criteria };
if (typeof normalized.email === 'string') {
normalized.email = normalized.email.trim().toLowerCase();
}
if (Array.isArray(normalized.$or)) {
normalized.$or = normalized.$or.map((condition) => {
if (typeof condition.email === 'string') {
return { ...condition, email: condition.email.trim().toLowerCase() };
}
return condition;
});
}
return normalized;
}
/**
* Search for a single user based on partial data and return matching user document as plain object.
* Email fields in searchCriteria are automatically normalized to lowercase for case-insensitive matching.
*/
async function findUser(
searchCriteria: FilterQuery<IUser>,
fieldsToSelect?: string | string[] | null,
): Promise<IUser | null> {
const User = mongoose.models.User;
const query = User.findOne(searchCriteria);
const normalizedCriteria = normalizeEmailInCriteria(searchCriteria);
const query = User.findOne(normalizedCriteria);
if (fieldsToSelect) {
query.select(fieldsToSelect);
}

View file

@ -132,6 +132,7 @@ const messageSchema: Schema<IMessage> = new Schema(
iconURL: {
type: String,
},
metadata: { type: mongoose.Schema.Types.Mixed },
attachments: { type: [{ type: mongoose.Schema.Types.Mixed }], default: undefined },
/*
attachments: {

View file

@ -37,6 +37,7 @@ export interface IMessage extends Document {
content?: unknown[];
thread_id?: string;
iconURL?: string;
metadata?: Record<string, unknown>;
attachments?: unknown[];
expiredAt?: Date;
createdAt?: Date;

View file

@ -34,6 +34,7 @@ export interface TokenUpdateData {
identifier?: string;
token?: string;
expiresAt?: Date;
expiresIn?: number;
metadata?: Map<string, unknown>;
}