diff --git a/api/server/services/Endpoints/bedrock/options.js b/api/server/services/Endpoints/bedrock/options.js index 4da13a9ffa..0d02d09b07 100644 --- a/api/server/services/Endpoints/bedrock/options.js +++ b/api/server/services/Endpoints/bedrock/options.js @@ -1,4 +1,3 @@ -const { resolveHeaders } = require('@librechat/api'); const { HttpsProxyAgent } = require('https-proxy-agent'); const { AuthType, @@ -89,14 +88,6 @@ const getOptions = async ({ req, overrideModel, endpointOption }) => { llmConfig.endpointHost = BEDROCK_REVERSE_PROXY; } - if (llmConfig.additionalModelRequestFields) { - llmConfig.additionalModelRequestFields = resolveHeaders({ - headers: llmConfig.additionalModelRequestFields, - user: req.user, - body: req.body, - }); - } - return { /** @type {BedrockClientOptions} */ llmConfig, diff --git a/packages/api/src/utils/env.spec.ts b/packages/api/src/utils/env.spec.ts index 7ef42e893e..e7515de2c3 100644 --- a/packages/api/src/utils/env.spec.ts +++ b/packages/api/src/utils/env.spec.ts @@ -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 { return !options.type || options.type === 'stdio'; @@ -13,19 +15,21 @@ function isStreamableHTTPOptions( } /** Helper function to create test user objects */ -function createTestUser(overrides: Partial = {}): TUser { +function createTestUser(overrides: Partial = {}): 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'); + } + }); + }); }); diff --git a/packages/api/src/utils/env.ts b/packages/api/src/utils/env.ts index 86e60cfce9..3a22d897e6 100644 --- a/packages/api/src/utils/env.ts +++ b/packages/api/src/utils/env.ts @@ -78,7 +78,8 @@ function processUserPlaceholders(value: string, user?: IUser): string { 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; } @@ -111,6 +112,11 @@ function processUserPlaceholders(value: string, user?: IUser): 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)) { @@ -144,6 +150,11 @@ function processSingleValue({ user?: IUser; body?: RequestBody; }): string { + // Type guard: ensure we're working with a string + if (typeof originalValue !== 'string') { + return String(originalValue); + } + let value = originalValue; if (customUserVars) { @@ -243,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; + 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 = {}; + 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(options?: { + obj: T | undefined; + user?: Partial | { id: string }; + body?: RequestBody; + customUserVars?: Record; +}): 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. * diff --git a/packages/api/src/utils/oidc.ts b/packages/api/src/utils/oidc.ts index 9c0ac478fd..b8d4fdac42 100644 --- a/packages/api/src/utils/oidc.ts +++ b/packages/api/src/utils/oidc.ts @@ -77,10 +77,6 @@ export function extractOpenIDTokenInfo(user: IUser | null | undefined): OpenIDTo tokenInfo.accessToken = tokens.access_token; tokenInfo.idToken = tokens.id_token; tokenInfo.expiresAt = tokens.expires_at; - } else { - logger.warn( - '[extractOpenIDTokenInfo] No federatedTokens or openidTokens found in user object', - ); } tokenInfo.userId = user.openidId || user.id;