mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-01-30 06:15:18 +01:00
Merge branch 'main' into feature/entra-id-azure-integration
This commit is contained in:
commit
a7cf1ae27b
241 changed files with 25653 additions and 3303 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
602
packages/api/src/endpoints/openai/llm.spec.ts
Normal file
602
packages/api/src/endpoints/openai/llm.spec.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ export async function parseText({
|
|||
accept: 'application/json',
|
||||
...formHeaders,
|
||||
},
|
||||
timeout: 30000,
|
||||
timeout: 300000,
|
||||
});
|
||||
|
||||
const responseData = response.data;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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...`,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
267
packages/api/src/mcp/oauth/detectOAuth.test.ts
Normal file
267
packages/api/src/mcp/oauth/detectOAuth.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
export * from './access';
|
||||
export * from './error';
|
||||
export * from './balance';
|
||||
export * from './json';
|
||||
|
|
|
|||
158
packages/api/src/middleware/json.spec.ts
Normal file
158
packages/api/src/middleware/json.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
40
packages/api/src/middleware/json.ts
Normal file
40
packages/api/src/middleware/json.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,2 +1,3 @@
|
|||
export * from './format';
|
||||
export * from './migration';
|
||||
export * from './schemas';
|
||||
|
|
|
|||
222
packages/api/src/prompts/schemas.spec.ts
Normal file
222
packages/api/src/prompts/schemas.spec.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
53
packages/api/src/prompts/schemas.ts
Normal file
53
packages/api/src/prompts/schemas.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -61,7 +61,7 @@ export interface AnthropicDocumentBlock {
|
|||
|
||||
/** Google document block format */
|
||||
export interface GoogleDocumentBlock {
|
||||
type: 'document';
|
||||
type: 'media';
|
||||
mimeType: string;
|
||||
data: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, '\\$&');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
122
packages/api/src/utils/message.spec.ts
Normal file
122
packages/api/src/utils/message.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
68
packages/api/src/utils/message.ts
Normal file
68
packages/api/src/utils/message.ts
Normal 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;
|
||||
}
|
||||
482
packages/api/src/utils/oidc.spec.ts
Normal file
482
packages/api/src/utils/oidc.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
176
packages/api/src/utils/oidc.ts
Normal file
176
packages/api/src/utils/oidc.ts
Normal 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);
|
||||
}
|
||||
97
packages/api/src/utils/path.spec.ts
Normal file
97
packages/api/src/utils/path.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
25
packages/api/src/utils/path.ts
Normal file
25
packages/api/src/utils/path.ts
Normal 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 '';
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
851
packages/api/src/utils/text.spec.ts
Normal file
851
packages/api/src/utils/text.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue