diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index d73adb246f..0b18529e38 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -9,9 +9,9 @@ const { logToolError, sanitizeTitle, payloadParser, - resolveHeaders, createSafeUser, initializeAgent, + resolveConfigHeaders, countTokens, getBalanceConfig, omitTitleOptions, @@ -1624,12 +1624,25 @@ class AgentClient extends BaseClient { delete clientOptions.modelKwargs.max_output_tokens; } + /** `omitTitleOptions` drops the Anthropic `clientOptions` carrier (thinking, + * streaming, etc.), which would also drop its `defaultHeaders` — preserve the + * original `clientOptions` object so gateway/reverse-proxy metadata still + * reaches title requests (the proxy may require it for auth/routing). Restore + * the SAME object reference, not a copy: the Vertex `createClient` closure from + * `getLLMConfig` closes over this object, so `resolveConfigHeaders` must mutate + * the very object the client is built from. */ + const anthropicClientOptions = clientOptions?.clientOptions; + clientOptions = Object.assign( Object.fromEntries( Object.entries(clientOptions).filter(([key]) => !omitTitleOptions.has(key)), ), ); + if (anthropicClientOptions?.defaultHeaders != null && clientOptions.clientOptions == null) { + clientOptions.clientOptions = anthropicClientOptions; + } + if ( provider === Providers.GOOGLE && (endpointConfig?.titleMethod === TitleMethod.FUNCTIONS || @@ -1638,20 +1651,19 @@ class AgentClient extends BaseClient { clientOptions.json = true; } - /** Resolve request-based headers for Custom Endpoints. Note: if this is added to - * non-custom endpoints, needs consideration of varying provider header configs. + /** Resolve request-based headers across provider-specific header locations: + * OpenAI `configuration.defaultHeaders`, Anthropic `clientOptions.defaultHeaders` + * (preserved above), and Google `customHeaders`. */ - if (clientOptions?.configuration?.defaultHeaders != null) { - clientOptions.configuration.defaultHeaders = resolveHeaders({ - headers: clientOptions.configuration.defaultHeaders, - user: createSafeUser(this.options.req?.user), - body: { - messageId: this.responseMessageId, - conversationId: this.conversationId, - parentMessageId: this.parentMessageId, - }, - }); - } + resolveConfigHeaders({ + llmConfig: clientOptions, + user: createSafeUser(this.options.req?.user), + body: { + messageId: this.responseMessageId, + conversationId: this.conversationId, + parentMessageId: this.parentMessageId, + }, + }); try { const titleResult = await this.run.generateTitle({ diff --git a/api/server/controllers/agents/client.test.js b/api/server/controllers/agents/client.test.js index 9fe4623c9c..c71ede7b23 100644 --- a/api/server/controllers/agents/client.test.js +++ b/api/server/controllers/agents/client.test.js @@ -225,6 +225,51 @@ describe('AgentClient - titleConvo', () => { expect(generateTitleCall.clientOptions.model).toBe('gpt-3.5-turbo'); }); + it('preserves Anthropic custom headers on title requests despite omitTitleOptions', async () => { + const prevKey = process.env.ANTHROPIC_API_KEY; + process.env.ANTHROPIC_API_KEY = 'sk-ant-test'; + try { + const req = { + user: { id: 'user-123' }, + body: { model: 'claude-sonnet-4-5', endpoint: EModelEndpoint.anthropic, key: null }, + config: { + endpoints: { + [EModelEndpoint.anthropic]: { + headers: { 'X-Conversation-Id': '{{LIBRECHAT_BODY_CONVERSATIONID}}' }, + }, + }, + }, + }; + const agent = { + id: 'agent-anthropic', + endpoint: EModelEndpoint.anthropic, + provider: EModelEndpoint.anthropic, + model_parameters: { model: 'claude-sonnet-4-5' }, + }; + const anthropicClient = new AgentClient({ req, res: {}, agent, endpointTokenConfig: {} }); + anthropicClient.run = mockRun; + anthropicClient.responseMessageId = 'response-123'; + anthropicClient.conversationId = 'convo-123'; + anthropicClient.contentParts = [{ type: 'text', text: 'Test content' }]; + anthropicClient.recordCollectedUsage = jest.fn().mockResolvedValue(); + + await anthropicClient.titleConvo({ text: 'Hello', abortController: new AbortController() }); + + const defaultHeaders = + mockRun.generateTitle.mock.calls[0][0].clientOptions?.clientOptions?.defaultHeaders; + // Custom header survives the `omitTitleOptions` strip and resolves the conversationId + expect(defaultHeaders?.['X-Conversation-Id']).toBe('convo-123'); + // Provider-managed beta header is preserved alongside it + expect(defaultHeaders?.['anthropic-beta']).toBeDefined(); + } finally { + if (prevKey === undefined) { + delete process.env.ANTHROPIC_API_KEY; + } else { + process.env.ANTHROPIC_API_KEY = prevKey; + } + } + }); + it('should handle missing endpoint config gracefully', async () => { // Remove endpoint config mockReq.config = { endpoints: {} }; diff --git a/api/server/services/Config/loadDefaultModels.js b/api/server/services/Config/loadDefaultModels.js index cc8da0bbc0..f7ea0daf71 100644 --- a/api/server/services/Config/loadDefaultModels.js +++ b/api/server/services/Config/loadDefaultModels.js @@ -1,6 +1,7 @@ const { logger } = require('@librechat/data-schemas'); const { EModelEndpoint } = require('librechat-data-provider'); const { + mergeHeaders, getAnthropicModels, getBedrockModels, getOpenAIModels, @@ -25,18 +26,35 @@ async function loadDefaultModels(req) { })); const vertexConfig = appConfig?.endpoints?.[EModelEndpoint.anthropic]?.vertexConfig; + /** Forward configured custom headers (endpoint over global `all`) so model + * fetches reach a gateway-fronted provider the same as chat requests. */ + const allHeaders = appConfig?.endpoints?.all?.headers; + const openAIHeaders = mergeHeaders( + allHeaders, + appConfig?.endpoints?.[EModelEndpoint.openAI]?.headers, + ); + const anthropicHeaders = mergeHeaders( + allHeaders, + appConfig?.endpoints?.[EModelEndpoint.anthropic]?.headers, + ); + const [openAI, anthropic, azureOpenAI, assistants, azureAssistants, google, bedrock] = await Promise.all([ - getOpenAIModels({ user: req.user.id }).catch((error) => { - logger.error('Error fetching OpenAI models:', error); - return []; - }), - getAnthropicModels({ user: req.user.id, vertexModels: vertexConfig?.modelNames }).catch( + getOpenAIModels({ user: req.user.id, headers: openAIHeaders, userObject: req.user }).catch( (error) => { - logger.error('Error fetching Anthropic models:', error); + logger.error('Error fetching OpenAI models:', error); return []; }, ), + getAnthropicModels({ + user: req.user.id, + vertexModels: vertexConfig?.modelNames, + headers: anthropicHeaders, + userObject: req.user, + }).catch((error) => { + logger.error('Error fetching Anthropic models:', error); + return []; + }), getOpenAIModels({ user: req.user.id, azure: true }).catch((error) => { logger.error('Error fetching Azure OpenAI models:', error); return []; diff --git a/librechat.example.yaml b/librechat.example.yaml index b6fba26da7..45997869f8 100644 --- a/librechat.example.yaml +++ b/librechat.example.yaml @@ -419,6 +419,21 @@ endpoints: # # (optional) Agent Capabilities available to all users. Omit the ones you wish to exclude. Defaults to list below. # capabilities: ["deferred_tools", "execute_code", "file_search", "actions", "tools"] + # (optional) Custom request headers for the built-in OpenAI / Google endpoints. + # Forwarded on every request to the provider (or an AI gateway / reverse proxy + # in front of it) while keeping provider-native request shaping intact. Values + # support env vars (${VAR}), user fields ({{LIBRECHAT_USER_*}}), and request-body + # fields ({{LIBRECHAT_BODY_CONVERSATIONID}}). Set the same `headers:` block under + # `endpoints.all` to apply globally across endpoints (endpoint values win on key + # collisions). NOTE: send metadata headers like these only behind a gateway that + # consumes them — native provider APIs ignore unknown headers. + # openAI: + # headers: + # cf-aig-metadata: '{"user_email":"{{LIBRECHAT_USER_EMAIL}}","app":"librechat"}' + # google: + # headers: + # cf-aig-metadata: '{"user_email":"{{LIBRECHAT_USER_EMAIL}}","app":"librechat"}' + # Anthropic endpoint configuration with Vertex AI support # Use this to run Anthropic Claude models through Google Cloud Vertex AI # anthropic: @@ -426,6 +441,12 @@ endpoints: # streamRate: 20 # # (optional) Title model for conversation titles # titleModel: claude-3.5-haiku # Use the visible model name (key from models config) + # # (optional) Custom request headers, same placeholder resolution as above. + # # Useful for correlating reverse-proxied requests by conversation, since an + # # unknown header is simply ignored by the native Anthropic API. + # headers: + # cf-aig-metadata: '{"user_email":"{{LIBRECHAT_USER_EMAIL}}","app":"librechat"}' + # X-Conversation-Id: '{{LIBRECHAT_BODY_CONVERSATIONID}}' # # # Vertex AI Configuration - enables running Claude models via Google Cloud # # This is similar to Azure OpenAI but for Anthropic models on Google Cloud diff --git a/packages/api/src/agents/memory.spec.ts b/packages/api/src/agents/memory.spec.ts index 622bc970d1..699d01017a 100644 --- a/packages/api/src/agents/memory.spec.ts +++ b/packages/api/src/agents/memory.spec.ts @@ -27,12 +27,34 @@ const mockResolveHeaders = jest.fn((opts) => { return result; }); +type HeaderCarrier = { defaultHeaders?: Record }; +const mockResolveConfigHeaders = jest.fn( + (opts: { + llmConfig?: { configuration?: HeaderCarrier; clientOptions?: HeaderCarrier }; + user?: { id?: string; email?: string }; + }) => { + const cfg = opts?.llmConfig; + if (cfg?.configuration?.defaultHeaders != null) { + cfg.configuration.defaultHeaders = mockResolveHeaders({ + headers: cfg.configuration.defaultHeaders, + user: opts.user, + }); + } + if (cfg?.clientOptions?.defaultHeaders != null) { + cfg.clientOptions.defaultHeaders = mockResolveHeaders({ + headers: cfg.clientOptions.defaultHeaders, + user: opts.user, + }); + } + }, +); + jest.mock('~/utils', () => ({ Tokenizer: { getTokenCount: jest.fn(() => 10), }, createSafeUser: (user: unknown) => mockCreateSafeUser(user), - resolveHeaders: (opts: unknown) => mockResolveHeaders(opts), + resolveConfigHeaders: (opts: unknown) => mockResolveConfigHeaders(opts as never), })); const { createSafeUser } = jest.requireMock('~/utils'); diff --git a/packages/api/src/agents/memory.ts b/packages/api/src/agents/memory.ts index 4006ea1184..feecb6ab4f 100644 --- a/packages/api/src/agents/memory.ts +++ b/packages/api/src/agents/memory.ts @@ -18,8 +18,9 @@ import type { DynamicStructuredTool } from '@librechat/agents/langchain/tools'; import type { ObjectId, MemoryMethods, IUser } from '@librechat/data-schemas'; import type { TAttachment, MemoryArtifact } from 'librechat-data-provider'; import type { Response as ServerResponse } from 'express'; +import type { RunLLMConfig } from '~/types'; import { GenerationJobManager } from '~/stream/GenerationJobManager'; -import { resolveHeaders, createSafeUser } from '~/utils'; +import { resolveConfigHeaders, createSafeUser } from '~/utils'; import Tokenizer from '~/utils/tokenizer'; type RequiredMemoryMethods = Pick< @@ -405,13 +406,17 @@ ${memory ?? 'No existing memories'}`; delete (finalLLMConfig as Record).temperature; } - const llmConfigWithHeaders = finalLLMConfig as OpenAIClientOptions; - if (llmConfigWithHeaders?.configuration?.defaultHeaders != null) { - llmConfigWithHeaders.configuration.defaultHeaders = resolveHeaders({ - headers: llmConfigWithHeaders.configuration.defaultHeaders as Record, - user: user ? createSafeUser(user) : undefined, - }); - } + /** + * Resolve request-based headers across provider-specific carriers (OpenAI + * `configuration.defaultHeaders`, native Anthropic `clientOptions.defaultHeaders`) + * so gateway-fronted built-in providers receive resolved metadata/auth headers + * on memory extraction too. Native Google headers are resolved at init. + */ + resolveConfigHeaders({ + llmConfig: finalLLMConfig as unknown as RunLLMConfig, + user: user ? createSafeUser(user) : undefined, + body: { conversationId, messageId }, + }); const artifactPromises: Promise[] = []; const memoryCallback = createMemoryCallback({ res, artifactPromises, streamId }); diff --git a/packages/api/src/agents/run.ts b/packages/api/src/agents/run.ts index fc148c94f9..ebdf04e385 100644 --- a/packages/api/src/agents/run.ts +++ b/packages/api/src/agents/run.ts @@ -36,6 +36,7 @@ import type * as t from '~/types'; import { getProviderConfig } from '~/endpoints/config/providers'; import { resolveHeaders, createSafeUser } from '~/utils/env'; import { getOpenAIConfig } from '~/endpoints/openai/config'; +import { resolveConfigHeaders } from '~/utils/headers'; import { applyTestRunHook } from '~/agents/testHook'; import { isUserProvided } from '~/utils/common'; @@ -887,18 +888,17 @@ export async function createRun({ .trim(); /** - * Resolve request-based headers for Custom Endpoints. Note: if this is added to - * non-custom endpoints, needs consideration of varying provider header configs. - * This is done at this step because the request body may contain dynamic values - * that need to be resolved after agent initialization. + * Resolve request-based headers across provider-specific header locations + * (OpenAI `configuration.defaultHeaders`, Anthropic `clientOptions.defaultHeaders`, + * Google `customHeaders`). Done at this step because the request body may + * contain dynamic values (e.g. conversationId) that are only known after + * agent initialization. */ - if (llmConfig?.configuration?.defaultHeaders != null) { - llmConfig.configuration.defaultHeaders = resolveHeaders({ - headers: llmConfig.configuration.defaultHeaders as Record, - user: createSafeUser(user), - body: requestBody, - }); - } + resolveConfigHeaders({ + llmConfig, + user: createSafeUser(user), + body: requestBody, + }); /** Resolves issues with new OpenAI usage field */ if ( diff --git a/packages/api/src/endpoints/anthropic/initialize.spec.ts b/packages/api/src/endpoints/anthropic/initialize.spec.ts new file mode 100644 index 0000000000..32e4e0d940 --- /dev/null +++ b/packages/api/src/endpoints/anthropic/initialize.spec.ts @@ -0,0 +1,109 @@ +import { EModelEndpoint } from 'librechat-data-provider'; +import type { AnthropicClientOptions } from '@librechat/agents'; +import type { BaseInitializeParams, ServerRequest } from '~/types'; +import { FINE_GRAINED_TOOL_STREAMING_BETA } from './helpers'; +import { initializeAnthropic } from './initialize'; + +const getDefaultHeaders = (llmConfig: unknown): Record => + ((llmConfig as AnthropicClientOptions).clientOptions?.defaultHeaders ?? {}) as Record< + string, + string + >; + +function createParams( + endpointsConfig: Record, + env: Record = {}, +): { params: BaseInitializeParams; restore: () => void } { + const savedEnv: Record = {}; + for (const key of Object.keys(env)) { + savedEnv[key] = process.env[key]; + } + Object.assign(process.env, env); + + const params: BaseInitializeParams = { + req: { + user: { id: 'user-42' }, + body: { conversationId: 'convo-xyz' }, + config: { endpoints: endpointsConfig }, + } as unknown as ServerRequest, + endpoint: EModelEndpoint.anthropic, + model_parameters: { model: 'claude-sonnet-4-5' }, + db: { + getUserKey: jest.fn(), + getUserKeyValues: jest.fn(), + }, + }; + + const restore = () => { + for (const key of Object.keys(env)) { + if (savedEnv[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = savedEnv[key]; + } + } + }; + + return { params, restore }; +} + +describe('initializeAnthropic – custom headers', () => { + it('threads configured headers into clientOptions.defaultHeaders without resolving placeholders', async () => { + const { params, restore } = createParams( + { + [EModelEndpoint.anthropic]: { + headers: { 'X-Conversation-Id': '{{LIBRECHAT_BODY_CONVERSATIONID}}' }, + }, + }, + { ANTHROPIC_API_KEY: 'sk-ant-test', ANTHROPIC_REVERSE_PROXY: 'https://gateway.example.com' }, + ); + + try { + const result = await initializeAnthropic(params); + const defaultHeaders = getDefaultHeaders(result.llmConfig); + /** Placeholder kept intact — resolved at request time, not init time */ + expect(defaultHeaders['X-Conversation-Id']).toBe('{{LIBRECHAT_BODY_CONVERSATIONID}}'); + /** Provider-managed beta header preserved alongside the custom header */ + expect(defaultHeaders['anthropic-beta']).toBe(FINE_GRAINED_TOOL_STREAMING_BETA); + /** Reverse proxy still wired through native Anthropic config */ + expect(result.llmConfig).toHaveProperty('anthropicApiUrl', 'https://gateway.example.com'); + } finally { + restore(); + } + }); + + it('merges endpoints.all headers beneath endpoint-specific headers', async () => { + const { params, restore } = createParams( + { + all: { headers: { 'X-Common': 'all', 'X-Override': 'all' } }, + [EModelEndpoint.anthropic]: { headers: { 'X-Override': 'anthropic' } }, + }, + { ANTHROPIC_API_KEY: 'sk-ant-test' }, + ); + + try { + const result = await initializeAnthropic(params); + const defaultHeaders = getDefaultHeaders(result.llmConfig); + expect(defaultHeaders['X-Common']).toBe('all'); + expect(defaultHeaders['X-Override']).toBe('anthropic'); + } finally { + restore(); + } + }); + + it('leaves defaultHeaders provider-managed when no custom headers are configured', async () => { + const { params, restore } = createParams( + { [EModelEndpoint.anthropic]: {} }, + { ANTHROPIC_API_KEY: 'sk-ant-test' }, + ); + + try { + const result = await initializeAnthropic(params); + expect(getDefaultHeaders(result.llmConfig)).toEqual({ + 'anthropic-beta': FINE_GRAINED_TOOL_STREAMING_BETA, + }); + } finally { + restore(); + } + }); +}); diff --git a/packages/api/src/endpoints/anthropic/initialize.ts b/packages/api/src/endpoints/anthropic/initialize.ts index 8bebd8467b..94a86294a2 100644 --- a/packages/api/src/endpoints/anthropic/initialize.ts +++ b/packages/api/src/endpoints/anthropic/initialize.ts @@ -1,7 +1,7 @@ import { EModelEndpoint, AuthKeys } from 'librechat-data-provider'; import type { BaseInitializeParams, InitializeResultBase, AnthropicConfigOptions } from '~/types'; -import { checkUserKeyExpiry, isEnabled } from '~/utils'; import { loadAnthropicVertexCredentials, getVertexCredentialOptions } from './vertex'; +import { checkUserKeyExpiry, isEnabled, mergeHeaders } from '~/utils'; import { getLLMConfig } from './llm'; /** @@ -64,6 +64,11 @@ export async function initializeAnthropic({ credentials[AuthKeys.ANTHROPIC_API_KEY] = anthropicApiKey; } + const anthropicConfig = appConfig?.endpoints?.[EModelEndpoint.anthropic]; + const allConfig = appConfig?.endpoints?.all; + + const headers = mergeHeaders(allConfig?.headers, anthropicConfig?.headers); + const clientOptions: AnthropicConfigOptions = { proxy: PROXY ?? undefined, reverseProxyUrl: ANTHROPIC_REVERSE_PROXY ?? undefined, @@ -71,15 +76,13 @@ export async function initializeAnthropic({ ...(model_parameters ?? {}), user: req.user?.id, }, + ...(headers && { headers }), // Pass Vertex AI options if configured ...(vertexOptions && { vertexOptions }), // Pass full Vertex AI config including model mappings ...(vertexConfig && { vertexConfig }), }; - const anthropicConfig = appConfig?.endpoints?.[EModelEndpoint.anthropic]; - const allConfig = appConfig?.endpoints?.all; - const result = getLLMConfig(credentials, clientOptions); if (anthropicConfig?.streamRate) { diff --git a/packages/api/src/endpoints/anthropic/llm.spec.ts b/packages/api/src/endpoints/anthropic/llm.spec.ts index 844d4ef2c0..c48e641971 100644 --- a/packages/api/src/endpoints/anthropic/llm.spec.ts +++ b/packages/api/src/endpoints/anthropic/llm.spec.ts @@ -1861,4 +1861,55 @@ describe('getLLMConfig', () => { }); }); }); + + describe('custom headers', () => { + it('attaches admin-configured headers while preserving native Anthropic formatting', () => { + const result = getLLMConfig('test-key', { + modelOptions: { model: 'claude-sonnet-4-5' }, + reverseProxyUrl: 'https://gateway.example.com', + headers: { + 'cf-aig-metadata': '{"user_email":"{{LIBRECHAT_USER_EMAIL}}","app":"librechat"}', + }, + }); + + const clientOptions = result.llmConfig.clientOptions; + /** Provider-managed beta header is preserved */ + expect((clientOptions?.defaultHeaders as Record)['anthropic-beta']).toBe( + FINE_GRAINED_TOOL_STREAMING_BETA, + ); + /** Custom header is attached, placeholders kept intact for request-time resolution */ + expect((clientOptions?.defaultHeaders as Record)['cf-aig-metadata']).toBe( + '{"user_email":"{{LIBRECHAT_USER_EMAIL}}","app":"librechat"}', + ); + /** Native request shaping is untouched */ + expect(result.llmConfig).toHaveProperty('model', 'claude-sonnet-4-5'); + expect(result.llmConfig).toHaveProperty('stream', true); + expect(result.llmConfig.invocationKwargs?.metadata).toEqual({ user_id: undefined }); + expect(result.llmConfig).toHaveProperty('anthropicApiUrl', 'https://gateway.example.com'); + }); + + it('does not let custom headers override the provider-managed anthropic-beta header', () => { + const result = getLLMConfig('test-key', { + modelOptions: { model: 'claude-3-5-sonnet' }, + headers: { 'anthropic-beta': 'custom-beta' }, + }); + + const beta = (result.llmConfig.clientOptions?.defaultHeaders as Record)[ + 'anthropic-beta' + ]; + /** Custom beta is unioned with managed betas rather than clobbering them */ + expect(beta).toContain(FINE_GRAINED_TOOL_STREAMING_BETA); + expect(beta).toContain('custom-beta'); + }); + + it('does not attach custom headers when clientOptions are dropped', () => { + const result = getLLMConfig('test-key', { + modelOptions: { model: 'claude-3-opus' }, + dropParams: ['clientOptions'], + headers: { 'cf-aig-metadata': 'x' }, + }); + + expect(result.llmConfig).not.toHaveProperty('clientOptions'); + }); + }); }); diff --git a/packages/api/src/endpoints/anthropic/llm.ts b/packages/api/src/endpoints/anthropic/llm.ts index a12119e5b9..9b1063c49b 100644 --- a/packages/api/src/endpoints/anthropic/llm.ts +++ b/packages/api/src/endpoints/anthropic/llm.ts @@ -27,6 +27,7 @@ import { getVertexDeploymentName, } from './vertex'; import { getProxyDispatcher } from '~/utils/proxy'; +import { mergeHeaders } from '~/utils/headers'; const WEB_SEARCH_BETA = 'web-search-2025-03-05'; @@ -334,6 +335,21 @@ function getLLMConfig( ); } + /** + * Attach admin-configured custom headers (e.g. AI-gateway metadata) beneath + * the provider-managed headers above, so beta/protocol headers always win. + * Placeholders are kept intact here and resolved at request time. + */ + if (options.headers && Object.keys(options.headers).length > 0 && !shouldDropClientOptions) { + if (!requestOptions.clientOptions) { + requestOptions.clientOptions = {}; + } + requestOptions.clientOptions.defaultHeaders = mergeHeaders( + options.headers, + requestOptions.clientOptions.defaultHeaders as Record | undefined, + ); + } + return { tools, llmConfig: removeNullishValues( diff --git a/packages/api/src/endpoints/google/initialize.spec.ts b/packages/api/src/endpoints/google/initialize.spec.ts index 50de1cac26..a6239805ef 100644 --- a/packages/api/src/endpoints/google/initialize.spec.ts +++ b/packages/api/src/endpoints/google/initialize.spec.ts @@ -18,6 +18,7 @@ jest.mock('./llm', () => ({ })); jest.mock('~/utils', () => ({ + ...jest.requireActual('~/utils'), isEnabled: (value: unknown) => mockIsEnabled(value), loadServiceKey: (keyPath: unknown) => mockLoadServiceKey(keyPath), checkUserKeyExpiry: (expiresAt: unknown, endpoint: unknown) => @@ -127,4 +128,40 @@ describe('initializeGoogle', () => { }), ); }); + + it('resolves configured headers at init (merged over endpoints.all) before getGoogleConfig', async () => { + process.env.GOOGLE_KEY = 'test-api-key'; + + const req = { + body: { conversationId: 'convo-9' }, + user: { id: 'user-1' }, + config: { + endpoints: { + all: { headers: { 'X-Common': 'all', 'X-Override': 'all' } }, + [EModelEndpoint.google]: { + headers: { + 'X-Override': 'google', + 'X-Conversation-Id': '{{LIBRECHAT_BODY_CONVERSATIONID}}', + 'X-User-Id': '{{LIBRECHAT_USER_ID}}', + }, + }, + }, + }, + } as unknown as ServerRequest; + + await initializeGoogle({ + req, + endpoint: EModelEndpoint.google, + model_parameters: { model: 'gemini-2.5-flash' }, + db: createDb(), + }); + + const [, options] = getGoogleConfigCall(); + expect(options.headers).toEqual({ + 'X-Common': 'all', + 'X-Override': 'google', + 'X-Conversation-Id': 'convo-9', + 'X-User-Id': 'user-1', + }); + }); }); diff --git a/packages/api/src/endpoints/google/initialize.ts b/packages/api/src/endpoints/google/initialize.ts index 1050a974eb..41c2f65298 100644 --- a/packages/api/src/endpoints/google/initialize.ts +++ b/packages/api/src/endpoints/google/initialize.ts @@ -7,7 +7,13 @@ import type { GoogleConfigOptions, GoogleCredentials, } from '~/types'; -import { isEnabled, loadServiceKey, checkUserKeyExpiry } from '~/utils'; +import { + isEnabled, + loadServiceKey, + checkUserKeyExpiry, + mergeHeaders, + resolveHeaders, +} from '~/utils'; import { getGoogleConfig } from './llm'; /** @@ -82,10 +88,26 @@ export async function initializeGoogle({ clientOptions.streamRate = allConfig.streamRate; } + /** + * Resolve configured Google headers at init (not at request time): the native + * Google auth header (`GOOGLE_AUTH_HEADER`) is built from the API key in + * `getGoogleConfig` and lives in the same `customHeaders` map. Resolving the + * admin templates here — before that key-derived header is added — keeps the + * key out of placeholder/env expansion (a user-provided `${ENV}` key can't leak + * server env) while still resolving admin headers (env, user, conversationId). + * `req.body` lacks the assistant message id at init, so `{{LIBRECHAT_BODY_MESSAGEID}}` + * is the one body placeholder unavailable here. + */ + const mergedHeaders = mergeHeaders(allConfig?.headers, googleConfig?.headers); + const headers = mergedHeaders + ? resolveHeaders({ headers: mergedHeaders, user: req.user, body: req.body }) + : undefined; + clientOptions = { reverseProxyUrl: GOOGLE_REVERSE_PROXY ?? undefined, authHeader: isEnabled(GOOGLE_AUTH_HEADER) ?? undefined, proxy: PROXY ?? undefined, + ...(headers && { headers }), modelOptions: model_parameters ?? {}, forceVertex: isVertexEndpoint, projectId: isVertexEndpoint diff --git a/packages/api/src/endpoints/google/llm.spec.ts b/packages/api/src/endpoints/google/llm.spec.ts index c8f6cffd0e..66ef6f84e7 100644 --- a/packages/api/src/endpoints/google/llm.spec.ts +++ b/packages/api/src/endpoints/google/llm.spec.ts @@ -1,5 +1,6 @@ import { Providers } from '@librechat/agents'; import { AuthKeys, ThinkingLevel } from 'librechat-data-provider'; +import type { GoogleClientOptions } from '@librechat/agents'; import type * as t from '~/types'; import { getGoogleConfig, getSafetySettings, knownGoogleParams } from './llm'; @@ -1506,4 +1507,34 @@ describe('knownGoogleParams', () => { expect(knownGoogleParams.has('frequency_penalty')).toBe(false); expect(knownGoogleParams.has('presence_penalty')).toBe(false); }); + + describe('custom headers', () => { + const credentials = { [AuthKeys.GOOGLE_API_KEY]: 'test-api-key' }; + + it('attaches admin-configured headers to customHeaders, keeping placeholders intact', () => { + const result = getGoogleConfig(credentials, { + modelOptions: { model: 'gemini-1.5-flash' }, + headers: { + 'cf-aig-metadata': '{"user_email":"{{LIBRECHAT_USER_EMAIL}}"}', + }, + }); + + expect((result.llmConfig as GoogleClientOptions).customHeaders).toEqual({ + 'cf-aig-metadata': '{"user_email":"{{LIBRECHAT_USER_EMAIL}}"}', + }); + }); + + it('does not let custom headers override the provider-managed Authorization header', () => { + const result = getGoogleConfig(credentials, { + modelOptions: { model: 'gemini-1.5-flash' }, + authHeader: true, + headers: { Authorization: 'Bearer attacker', 'X-Conversation-Id': 'cid' }, + }); + + expect((result.llmConfig as GoogleClientOptions).customHeaders).toEqual({ + Authorization: 'Bearer test-api-key', + 'X-Conversation-Id': 'cid', + }); + }); + }); }); diff --git a/packages/api/src/endpoints/google/llm.ts b/packages/api/src/endpoints/google/llm.ts index af04414543..b0d3f8cea5 100644 --- a/packages/api/src/endpoints/google/llm.ts +++ b/packages/api/src/endpoints/google/llm.ts @@ -3,6 +3,7 @@ import { googleSettings, AuthKeys, removeNullishValues } from 'librechat-data-pr import type { GoogleClientOptions, VertexAIClientOptions } from '@librechat/agents'; import type { GoogleAIToolType } from '@librechat/agents/langchain/google-common'; import type * as t from '~/types'; +import { mergeHeaders } from '~/utils/headers'; import { isEnabled } from '~/utils'; type GoogleThinkingLevel = 'THINKING_LEVEL_UNSPECIFIED' | 'MINIMAL' | 'LOW' | 'MEDIUM' | 'HIGH'; @@ -489,6 +490,19 @@ export function getGoogleConfig( }; } + /** + * Attach admin-configured custom headers (e.g. AI-gateway metadata) beneath + * the provider-managed `Authorization` header above, so auth always wins. + * `options.headers` are already resolved by `initializeGoogle`, keeping the + * key-derived `Authorization` out of placeholder/env expansion. + */ + if (options.headers && Object.keys(options.headers).length > 0) { + (llmConfig as GoogleClientOptions).customHeaders = mergeHeaders( + options.headers, + (llmConfig as GoogleClientOptions).customHeaders as Record | undefined, + ); + } + /** Handle defaultParams first - only process Google-native params if undefined */ if (options.defaultParams && typeof options.defaultParams === 'object') { for (const [key, value] of Object.entries(options.defaultParams)) { diff --git a/packages/api/src/endpoints/models.spec.ts b/packages/api/src/endpoints/models.spec.ts index 4f2c4efe78..13d3d82241 100644 --- a/packages/api/src/endpoints/models.spec.ts +++ b/packages/api/src/endpoints/models.spec.ts @@ -343,6 +343,24 @@ describe('getOpenAIModels', () => { expect(models).toEqual(['gpt-env-key']); }); + it('forwards configured custom headers to the OpenAI model fetch', async () => { + mockedAxios.get.mockResolvedValue({ data: { data: [{ id: 'gpt-x' }] } }); + process.env.OPENAI_API_KEY = 'sk-env'; + + await getOpenAIModels({ + user: 'user456', + headers: { 'cf-aig-token': 'tok' }, + userObject: { id: 'user456' }, + }); + + expect(mockedAxios.get).toHaveBeenCalledWith( + expect.stringContaining('https://api.openai.com/v1/models'), + expect.objectContaining({ + headers: expect.objectContaining({ 'cf-aig-token': 'tok' }), + }), + ); + }); + it('returns `AZURE_OPENAI_MODELS` with `azure` flag (and fetch fails)', async () => { process.env.AZURE_OPENAI_MODELS = 'azure-model,azure-model-2'; const models = await getOpenAIModels({ azure: true }); @@ -737,7 +755,7 @@ describe('getAnthropicModels', () => { ); }); - it('should pass custom headers for Anthropic endpoint', async () => { + it('forwards custom headers for the Anthropic endpoint alongside managed auth', async () => { const customHeaders = { 'X-Custom-Header': 'custom-value', }; @@ -760,12 +778,32 @@ describe('getAnthropicModels', () => { expect.any(String), expect.objectContaining({ headers: { + 'X-Custom-Header': 'custom-value', 'x-api-key': 'test-anthropic-key', 'anthropic-version': expect.any(String), }, }), ); }); + + it('threads configured headers through getAnthropicModels to the fetch', async () => { + delete process.env.ANTHROPIC_MODELS; + process.env.ANTHROPIC_API_KEY = 'test-anthropic-key'; + mockedAxios.get.mockResolvedValue({ data: { data: [{ id: 'claude-3' }] } }); + + await getAnthropicModels({ + user: 'user123', + headers: { 'cf-aig-token': 'tok' }, + userObject: { id: 'user123' }, + }); + + expect(mockedAxios.get).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.objectContaining({ 'cf-aig-token': 'tok' }), + }), + ); + }); }); describe('getGoogleModels', () => { diff --git a/packages/api/src/endpoints/models.ts b/packages/api/src/endpoints/models.ts index 822dea7b1e..866e10e539 100644 --- a/packages/api/src/endpoints/models.ts +++ b/packages/api/src/endpoints/models.ts @@ -185,7 +185,10 @@ export async function fetchModels({ }; if (name === EModelEndpoint.anthropic) { + // Keep configured custom headers (e.g. gateway metadata) while the + // provider-managed auth/version headers stay authoritative. options.headers = { + ...resolvedHeaders, 'x-api-key': apiKey, 'anthropic-version': process.env.ANTHROPIC_VERSION || '2023-06-01', }; @@ -249,6 +252,10 @@ export interface GetOpenAIModelsOptions { openAIApiKey?: string; /** Skip MODEL_QUERIES cache (e.g., for user-provided keys) */ skipCache?: boolean; + /** Configured custom headers forwarded to the (gateway-fronted) provider */ + headers?: Record | null; + /** User object for resolving header placeholders */ + userObject?: Partial; } function resolveOpenAIApiKey(opts: GetOpenAIModelsOptions): string | undefined { @@ -289,6 +296,8 @@ export async function fetchOpenAIModels( user: opts.user, name: EModelEndpoint.openAI, skipCache: opts.skipCache, + headers: opts.headers, + userObject: opts.userObject, }); } @@ -349,7 +358,12 @@ export async function getOpenAIModels(opts: GetOpenAIModelsOptions = {}): Promis * @returns Promise resolving to array of model IDs */ export async function fetchAnthropicModels( - opts: { user?: string; skipCache?: boolean } = {}, + opts: { + user?: string; + skipCache?: boolean; + headers?: Record | null; + userObject?: Partial; + } = {}, _models: string[] = [], ): Promise { let models = _models.slice() ?? []; @@ -374,6 +388,8 @@ export async function fetchAnthropicModels( name: EModelEndpoint.anthropic, tokenKey: EModelEndpoint.anthropic, skipCache: opts.skipCache, + headers: opts.headers, + userObject: opts.userObject, }); } @@ -390,7 +406,12 @@ export async function fetchAnthropicModels( * @returns Promise resolving to array of model IDs */ export async function getAnthropicModels( - opts: { user?: string; vertexModels?: string[] } = {}, + opts: { + user?: string; + vertexModels?: string[]; + headers?: Record | null; + userObject?: Partial; + } = {}, ): Promise { const models = defaultModels[EModelEndpoint.anthropic]; diff --git a/packages/api/src/endpoints/openai/config.ts b/packages/api/src/endpoints/openai/config.ts index c059d2beba..521590c782 100644 --- a/packages/api/src/endpoints/openai/config.ts +++ b/packages/api/src/endpoints/openai/config.ts @@ -8,6 +8,7 @@ import { transformToOpenAIConfig } from './transform'; import { getProxyDispatcher } from '~/utils/proxy'; import { constructAzureURL } from '~/utils/azure'; import { createFetch } from '~/utils/generators'; +import { mergeHeaders } from '~/utils/headers'; type Fetch = (input: string | URL | Request, init?: RequestInit) => Promise; @@ -50,26 +51,6 @@ function getReasoningFormat({ return undefined; } -function mergeHeadersPreservingAnthropicBeta( - headers: Record | undefined, - defaultHeaders: Record, -): Record { - const mergedHeaders = Object.assign({}, headers ?? {}, defaultHeaders); - const existingBetaHeader = headers?.['anthropic-beta']; - const defaultBetaHeader = defaultHeaders['anthropic-beta']; - - if (existingBetaHeader && defaultBetaHeader) { - const betaValues = [existingBetaHeader, defaultBetaHeader] - .flatMap((value) => value.split(',')) - .map((value) => value.trim()) - .filter(Boolean); - - mergedHeaders['anthropic-beta'] = Array.from(new Set(betaValues)).join(','); - } - - return mergedHeaders; -} - /** * Generates configuration options for creating a language model (LLM) instance. * @param apiKey - The API key for authentication. @@ -134,7 +115,7 @@ export function getOpenAIConfig( llmConfig = transformed.llmConfig; tools = anthropicResult.tools; if (transformed.configOptions?.defaultHeaders) { - headers = mergeHeadersPreservingAnthropicBeta( + headers = mergeHeaders( headers, transformed.configOptions.defaultHeaders as Record, ); diff --git a/packages/api/src/endpoints/openai/initialize.spec.ts b/packages/api/src/endpoints/openai/initialize.spec.ts index 85c8c1e896..93111ae52f 100644 --- a/packages/api/src/endpoints/openai/initialize.spec.ts +++ b/packages/api/src/endpoints/openai/initialize.spec.ts @@ -15,12 +15,14 @@ jest.mock('./config', () => ({ })); jest.mock('~/utils', () => ({ + ...jest.requireActual('~/utils'), getAzureCredentials: jest.fn(), resolveHeaders: jest.fn(() => ({})), isUserProvided: (val: string) => val === 'user_provided', checkUserKeyExpiry: jest.fn(), })); +import { getAzureCredentials } from '~/utils'; import { initializeOpenAI } from './initialize'; function createParams(env: Record): BaseInitializeParams { @@ -134,3 +136,83 @@ describe('initializeOpenAI – SSRF guard wiring', () => { expect(mockGetOpenAIConfig).not.toHaveBeenCalled(); }); }); + +describe('initializeOpenAI – custom headers', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('forwards configured endpoint headers (merged over endpoints.all) to getOpenAIConfig', async () => { + const params = createParams({ OPENAI_API_KEY: 'sk-test' }); + (params.req.config as { endpoints: Record }).endpoints = { + all: { headers: { 'X-Common': 'all', 'X-Override': 'all' } }, + [EModelEndpoint.openAI]: { + headers: { 'X-Override': 'openai', 'cf-aig-metadata': '{{LIBRECHAT_BODY_CONVERSATIONID}}' }, + }, + }; + + try { + await initializeOpenAI(params); + } finally { + (params as unknown as { _restore: () => void })._restore(); + } + + const options = mockGetOpenAIConfig.mock.calls[0][1] as { headers?: Record }; + expect(options.headers).toEqual({ + 'X-Common': 'all', + 'X-Override': 'openai', + 'cf-aig-metadata': '{{LIBRECHAT_BODY_CONVERSATIONID}}', + }); + }); + + it('does not set headers when none are configured', async () => { + const params = createParams({ OPENAI_API_KEY: 'sk-test' }); + + try { + await initializeOpenAI(params); + } finally { + (params as unknown as { _restore: () => void })._restore(); + } + + const options = mockGetOpenAIConfig.mock.calls[0][1] as { headers?: Record }; + expect(options.headers).toBeUndefined(); + }); + + it('withholds configured headers when the user supplies the base URL', async () => { + const params = createParams({ + OPENAI_API_KEY: 'sk-test', + OPENAI_REVERSE_PROXY: AuthType.USER_PROVIDED, + }); + (params.req.config as { endpoints: Record }).endpoints = { + [EModelEndpoint.openAI]: { headers: { 'X-Secret': '${GATEWAY_SECRET}' } }, + }; + + try { + await initializeOpenAI(params); + } finally { + (params as unknown as { _restore: () => void })._restore(); + } + + const options = mockGetOpenAIConfig.mock.calls[0][1] as { headers?: Record }; + expect(options.headers).toBeUndefined(); + }); + + it('applies endpoints.all headers to the env-based Azure path, unresolved at init', async () => { + (getAzureCredentials as jest.Mock).mockReturnValueOnce({ azureOpenAIApiKey: 'az-key' }); + const params = createParams({ AZURE_API_KEY: 'az-key' }); + params.endpoint = EModelEndpoint.azureOpenAI; + (params.req.config as { endpoints: Record }).endpoints = { + all: { headers: { 'X-Global': '{{LIBRECHAT_USER_ID}}' } }, + }; + + try { + await initializeOpenAI(params); + } finally { + (params as unknown as { _restore: () => void })._restore(); + } + + const options = mockGetOpenAIConfig.mock.calls[0][1] as { headers?: Record }; + // Left unresolved here; request-time resolveConfigHeaders resolves it once + expect(options.headers).toEqual({ 'X-Global': '{{LIBRECHAT_USER_ID}}' }); + }); +}); diff --git a/packages/api/src/endpoints/openai/initialize.ts b/packages/api/src/endpoints/openai/initialize.ts index 1b852deb1f..b0c76b2aad 100644 --- a/packages/api/src/endpoints/openai/initialize.ts +++ b/packages/api/src/endpoints/openai/initialize.ts @@ -5,7 +5,13 @@ import type { OpenAIConfigOptions, UserKeyValues, } from '~/types'; -import { getAzureCredentials, resolveHeaders, isUserProvided, checkUserKeyExpiry } from '~/utils'; +import { + mergeHeaders, + resolveHeaders, + isUserProvided, + checkUserKeyExpiry, + getAzureCredentials, +} from '~/utils'; import { validateEndpointURL } from '~/auth'; import { getOpenAIConfig } from './config'; @@ -24,6 +30,8 @@ export async function initializeOpenAI({ db, }: BaseInitializeParams): Promise { const appConfig = req.config; + const openAIConfig = appConfig?.endpoints?.[EModelEndpoint.openAI]; + const allConfig = appConfig?.endpoints?.all; const { PROXY, OPENAI_API_KEY, AZURE_API_KEY, OPENAI_REVERSE_PROXY, AZURE_OPENAI_BASEURL } = process.env; @@ -66,6 +74,18 @@ export async function initializeOpenAI({ streaming: true, }; + /** + * Custom headers are forwarded only when the destination URL is admin-trusted. + * When the user supplies the base URL, withhold them — they may carry + * `${SECRET}` gateway values or user/OpenID token placeholders resolved later + * by `resolveConfigHeaders`, which must not reach a user-controlled endpoint. + */ + const trustedURL = !userProvidesURL; + const globalHeaders = trustedURL ? allConfig?.headers : undefined; + const openAIHeaders = trustedURL + ? mergeHeaders(allConfig?.headers, openAIConfig?.headers) + : undefined; + const isAzureOpenAI = endpoint === EModelEndpoint.azureOpenAI; const azureConfig = isAzureOpenAI && appConfig?.endpoints?.[EModelEndpoint.azureOpenAI]; let isServerless = false; @@ -89,6 +109,13 @@ export async function initializeOpenAI({ headers: { ...headers, ...(clientOptions.headers ?? {}) }, user: req.user, }); + /** `endpoints.all` headers apply globally, but stay unresolved here — they are + * resolved once at request time by `resolveConfigHeaders`. Resolving them now + * (in addition) would re-expand already-substituted user values, violating the + * env-before-user invariant. Azure-managed headers stay authoritative. */ + if (globalHeaders) { + clientOptions.headers = mergeHeaders(globalHeaders, clientOptions.headers); + } const groupName = modelGroupMap[modelName || '']?.group; if (groupName && groupMap[groupName]) { @@ -113,6 +140,19 @@ export async function initializeOpenAI({ clientOptions.azure = userProvidesKey && userValues?.apiKey ? JSON.parse(userValues.apiKey) : getAzureCredentials(); apiKey = clientOptions.azure ? clientOptions.azure.azureOpenAIApiKey : undefined; + /** Env-var Azure path has no per-model headers; still honor global `all` headers. */ + if (globalHeaders) { + clientOptions.headers = { ...globalHeaders }; + } + } else { + /** + * Attach admin-configured custom headers for the built-in OpenAI endpoint + * (endpoint over global `all`). Kept unresolved here so request-body + * placeholders resolve at request time via `resolveConfigHeaders`. + */ + if (openAIHeaders) { + clientOptions.headers = openAIHeaders; + } } if (userProvidesKey && !apiKey) { @@ -145,8 +185,6 @@ export async function initializeOpenAI({ (options as InitializeResultBase).useLegacyContent = true; } - const openAIConfig = appConfig?.endpoints?.[EModelEndpoint.openAI]; - const allConfig = appConfig?.endpoints?.all; const azureRate = modelName?.includes('gpt-4') ? 30 : 17; let streamRate: number | undefined; diff --git a/packages/api/src/types/anthropic.ts b/packages/api/src/types/anthropic.ts index 7f62a14974..ca19482f3b 100644 --- a/packages/api/src/types/anthropic.ts +++ b/packages/api/src/types/anthropic.ts @@ -1,10 +1,10 @@ import { z } from 'zod'; import { Dispatcher } from 'undici'; import { AuthKeys, anthropicSchema, TVertexAISchema } from 'librechat-data-provider'; -import type { AnthropicClientOptions } from '@librechat/agents'; import type { ThinkingDisplayWireValue } from 'librechat-data-provider'; -import type { LLMConfigResult } from './openai'; +import type { AnthropicClientOptions } from '@librechat/agents'; import type { GoogleServiceKey } from '../utils/key'; +import type { LLMConfigResult } from './openai'; export type AnthropicParameters = z.infer; @@ -86,6 +86,11 @@ export interface AnthropicConfigOptions { addParams?: Record; /** Parameters to drop/exclude from the configuration */ dropParams?: string[]; + /** + * Admin-configured custom request headers (with unresolved placeholders). + * Merged beneath provider-managed headers and resolved at request time. + */ + headers?: Record; /** Vertex AI specific options for Google Cloud configuration */ vertexOptions?: VertexAIClientOptions; /** Full Vertex AI configuration including model mappings from YAML config */ diff --git a/packages/api/src/utils/headers.spec.ts b/packages/api/src/utils/headers.spec.ts new file mode 100644 index 0000000000..ca5cdd8c10 --- /dev/null +++ b/packages/api/src/utils/headers.spec.ts @@ -0,0 +1,155 @@ +import type { RunLLMConfig } from '~/types'; +import { mergeHeaders, resolveConfigHeaders } from './headers'; + +describe('mergeHeaders', () => { + it('returns undefined when neither side has headers', () => { + expect(mergeHeaders(undefined, undefined)).toBeUndefined(); + }); + + it('returns a copy of the side that is present', () => { + expect(mergeHeaders({ a: '1' }, undefined)).toEqual({ a: '1' }); + expect(mergeHeaders(undefined, { b: '2' })).toEqual({ b: '2' }); + }); + + it('lets override win on key collisions', () => { + expect(mergeHeaders({ a: 'base', b: 'base' }, { b: 'override' })).toEqual({ + a: 'base', + b: 'override', + }); + }); + + it('comma-unions anthropic-beta values from both sides (deduped)', () => { + const merged = mergeHeaders( + { 'anthropic-beta': 'custom-beta, shared' }, + { 'anthropic-beta': 'shared,managed-beta' }, + ); + expect(merged?.['anthropic-beta']).toBe('custom-beta,shared,managed-beta'); + }); + + it('does not mutate the input objects', () => { + const base = { a: '1' }; + const override = { b: '2' }; + mergeHeaders(base, override); + expect(base).toEqual({ a: '1' }); + expect(override).toEqual({ b: '2' }); + }); + + it('replaces a case-variant base key with the override (no duplicate header names)', () => { + const merged = mergeHeaders({ authorization: 'custom' }, { Authorization: 'Bearer managed' }); + expect(merged).toEqual({ Authorization: 'Bearer managed' }); + }); + + it('unions anthropic-beta case-insensitively, keeping the override casing', () => { + const merged = mergeHeaders( + { 'anthropic-beta': 'custom-beta' }, + { 'Anthropic-Beta': 'managed-beta' }, + ); + expect(merged).toEqual({ 'Anthropic-Beta': 'custom-beta,managed-beta' }); + }); +}); + +describe('resolveConfigHeaders', () => { + const user = { id: 'user-123', email: 'person@example.com' }; + const body = { conversationId: 'convo-abc' }; + + it('is a no-op when llmConfig is null/undefined', () => { + expect(() => resolveConfigHeaders({ llmConfig: null, user, body })).not.toThrow(); + expect(() => resolveConfigHeaders({ llmConfig: undefined, user, body })).not.toThrow(); + }); + + it('resolves OpenAI-style configuration.defaultHeaders', () => { + const llmConfig = { + configuration: { + defaultHeaders: { + 'X-Conversation-Id': '{{LIBRECHAT_BODY_CONVERSATIONID}}', + 'X-User-Id': '{{LIBRECHAT_USER_ID}}', + }, + }, + } as unknown as RunLLMConfig; + + resolveConfigHeaders({ llmConfig, user, body }); + + expect(llmConfig.configuration?.defaultHeaders).toEqual({ + 'X-Conversation-Id': 'convo-abc', + 'X-User-Id': 'user-123', + }); + }); + + it('resolves Anthropic-style clientOptions.defaultHeaders while preserving non-placeholder values', () => { + const llmConfig = { + clientOptions: { + defaultHeaders: { + 'anthropic-beta': 'fine-grained-tool-streaming-2025-05-14', + 'cf-aig-metadata': '{"conversation_id":"{{LIBRECHAT_BODY_CONVERSATIONID}}"}', + }, + }, + } as unknown as RunLLMConfig; + + resolveConfigHeaders({ llmConfig, user, body }); + + const clientOptions = ( + llmConfig as unknown as { clientOptions: { defaultHeaders: Record } } + ).clientOptions; + expect(clientOptions.defaultHeaders).toEqual({ + 'anthropic-beta': 'fine-grained-tool-streaming-2025-05-14', + 'cf-aig-metadata': '{"conversation_id":"convo-abc"}', + }); + }); + + it('leaves Google customHeaders untouched (resolved at init, not request time)', () => { + const llmConfig = { + customHeaders: { + 'X-Conversation-Id': '{{LIBRECHAT_BODY_CONVERSATIONID}}', + Authorization: 'Bearer ${SOME_KEY}', + }, + } as unknown as RunLLMConfig; + + resolveConfigHeaders({ llmConfig, user, body }); + + // Native Google headers are resolved in initializeGoogle; resolveConfigHeaders + // must not re-process them (keeps the key-derived auth out of env expansion). + expect( + (llmConfig as unknown as { customHeaders: Record }).customHeaders, + ).toEqual({ + 'X-Conversation-Id': '{{LIBRECHAT_BODY_CONVERSATIONID}}', + Authorization: 'Bearer ${SOME_KEY}', + }); + }); + + it('resolves each header map only once across repeated calls (idempotent under reuse)', () => { + process.env.HEADERS_SPEC_IDEMPOTENT = 'env-value'; + const reusedUser = { id: 'u', name: '${HEADERS_SPEC_IDEMPOTENT}' }; + const llmConfig = { + configuration: { defaultHeaders: { 'X-Name': '{{LIBRECHAT_USER_NAME}}' } }, + } as unknown as RunLLMConfig; + + resolveConfigHeaders({ llmConfig, user: reusedUser, body }); + // Second pass must NOT re-expand the now-substituted ${...} from the user name + resolveConfigHeaders({ llmConfig, user: reusedUser, body }); + + expect(llmConfig.configuration?.defaultHeaders).toEqual({ + 'X-Name': '${HEADERS_SPEC_IDEMPOTENT}', + }); + delete process.env.HEADERS_SPEC_IDEMPOTENT; + }); + + it('resolves env-var placeholders in header values', () => { + process.env.HEADERS_SPEC_GATEWAY_KEY = 'secret-key'; + const llmConfig = { + configuration: { + defaultHeaders: { 'X-Gateway-Key': '${HEADERS_SPEC_GATEWAY_KEY}' }, + }, + } as unknown as RunLLMConfig; + + resolveConfigHeaders({ llmConfig, user, body }); + + expect(llmConfig.configuration?.defaultHeaders).toEqual({ 'X-Gateway-Key': 'secret-key' }); + delete process.env.HEADERS_SPEC_GATEWAY_KEY; + }); + + it('leaves configs without header maps untouched', () => { + const llmConfig = { model: 'gpt-4o', configuration: {} } as unknown as RunLLMConfig; + expect(() => resolveConfigHeaders({ llmConfig, user, body })).not.toThrow(); + expect(llmConfig.configuration).toEqual({}); + }); +}); diff --git a/packages/api/src/utils/headers.ts b/packages/api/src/utils/headers.ts new file mode 100644 index 0000000000..37ddd571ce --- /dev/null +++ b/packages/api/src/utils/headers.ts @@ -0,0 +1,128 @@ +import type { AnthropicClientOptions } from '@librechat/agents'; +import type { IUser } from '@librechat/data-schemas'; +import type { RequestBody, RunLLMConfig } from '~/types'; +import { resolveHeaders } from './env'; + +/** Comma-unions two header values (deduped, trimmed), e.g. `anthropic-beta`. */ +function unionCsv(a: string, b: string): string { + const values = [a, b] + .flatMap((value) => value.split(',')) + .map((value) => value.trim()) + .filter(Boolean); + return Array.from(new Set(values)).join(','); +} + +/** + * Merges two header maps, with `override` winning on key collisions. Matching is + * case-insensitive (HTTP header names are), so an `override` key replaces any + * case variant from `base` rather than leaving both names in the output (which + * clients may collapse, breaking auth or protocol headers); the `override` + * casing is kept. The `anthropic-beta` header is special-cased: values from both + * sides are comma-unioned (deduped) so a custom beta coexists with + * provider-managed betas instead of clobbering them. + * + * Used both to layer endpoint headers over global (`endpoints.all`) headers and + * to attach admin-configured custom headers beneath provider-managed headers + * (auth/version/beta), so the provider integration's own headers always win. + * + * @returns the merged map, or `undefined` when neither side has any headers. + */ +export function mergeHeaders( + base?: Record, + override?: Record, +): Record | undefined { + if (!base && !override) { + return undefined; + } + + const merged: Record = { ...(base ?? {}) }; + if (!override) { + return merged; + } + + const baseKeyByLower = new Map( + Object.keys(merged).map((key) => [key.toLowerCase(), key]), + ); + + for (const [key, value] of Object.entries(override)) { + const lower = key.toLowerCase(); + const existingKey = baseKeyByLower.get(lower); + const nextValue = + lower === 'anthropic-beta' && existingKey != null + ? unionCsv(merged[existingKey], value) + : value; + + if (existingKey != null && existingKey !== key) { + delete merged[existingKey]; + } + merged[key] = nextValue; + baseKeyByLower.set(lower, key); + } + + return merged; +} + +type DefaultHeadersContainer = { defaultHeaders?: Record }; + +/** + * Header maps already resolved by `resolveConfigHeaders`. `resolveConfigHeaders` + * mutates config objects in place, and the same initialized agent (hence the same + * nested header objects) can flow through `buildAgentInput` more than once (root + + * subagent, multiple parents). Resolving twice would run env expansion over values + * already substituted with user/body data, violating the env-before-user invariant + * documented in `resolveHeaders`. Tracking resolved maps makes resolution + * idempotent across reuse. Keyed by object identity (per-request fresh objects), so + * nothing carries across requests. + */ +const resolvedHeaderMaps = new WeakSet(); + +/** + * Resolves placeholder templates in the outbound request headers of a built LLM + * config, mutating it in place. Handles the OpenAI-compatible + * `configuration.defaultHeaders` (OpenAI / Azure / custom) and the native + * Anthropic `clientOptions.defaultHeaders` (including Vertex) carriers. Native + * Google `customHeaders` are intentionally NOT handled here — they are resolved + * once at init in `initializeGoogle`, so the provider-managed `Authorization` + * header (built from a possibly user-provided key) never passes through env + * expansion. + * + * Resolution runs at request time so request-body placeholders (e.g. + * `{{LIBRECHAT_BODY_CONVERSATIONID}}`) resolve against the live request. It is a + * no-op for header values without placeholders, and idempotent under config reuse. + */ +export function resolveConfigHeaders({ + llmConfig, + user, + body, + customUserVars, +}: { + llmConfig?: RunLLMConfig | null; + user?: Partial | { id: string }; + body?: RequestBody; + customUserVars?: Record; +}): void { + if (llmConfig == null) { + return; + } + + const resolveOnce = (headers: Record): Record => { + if (resolvedHeaderMaps.has(headers)) { + return headers; + } + const resolved = resolveHeaders({ headers, user, body, customUserVars }); + resolvedHeaderMaps.add(resolved); + return resolved; + }; + + const configuration = llmConfig.configuration as DefaultHeadersContainer | undefined; + if (configuration?.defaultHeaders != null) { + configuration.defaultHeaders = resolveOnce(configuration.defaultHeaders); + } + + const clientOptions = (llmConfig as AnthropicClientOptions).clientOptions as + | DefaultHeadersContainer + | undefined; + if (clientOptions?.defaultHeaders != null) { + clientOptions.defaultHeaders = resolveOnce(clientOptions.defaultHeaders); + } +} diff --git a/packages/api/src/utils/index.ts b/packages/api/src/utils/index.ts index ff40512a5d..f801ec236d 100644 --- a/packages/api/src/utils/index.ts +++ b/packages/api/src/utils/index.ts @@ -10,6 +10,7 @@ export * from './files'; export * from './import'; export * from './generators'; export * from './graph'; +export * from './headers'; export * from './path'; export * from './key'; export * from './latex'; diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index bd92072fa2..50da11b7b2 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -585,6 +585,15 @@ export const defaultAssistantsVersion = { export const baseEndpointSchema = z.object({ streamRate: z.number().optional(), baseURL: z.string().optional(), + /** + * Custom request headers forwarded to the provider on every request. Values + * support the same placeholder resolution as custom endpoints — env vars + * (`${VAR}`), user fields (`{{LIBRECHAT_USER_*}}`), and request-body fields + * (`{{LIBRECHAT_BODY_CONVERSATIONID}}`). Primarily for routing built-in + * providers through an AI gateway / reverse proxy that consumes metadata + * headers (provider-native request shaping is preserved). + */ + headers: z.record(z.string()).optional(), titlePrompt: z.string().optional(), titleModel: z.string().optional(), titleConvo: z.boolean().optional(), diff --git a/packages/data-schemas/src/app/endpoints.ts b/packages/data-schemas/src/app/endpoints.ts index 8db3a3e9f6..70a5376528 100644 --- a/packages/data-schemas/src/app/endpoints.ts +++ b/packages/data-schemas/src/app/endpoints.ts @@ -1,5 +1,10 @@ import { EModelEndpoint } from 'librechat-data-provider'; -import type { TCustomConfig, TAgentsEndpoint, TAnthropicEndpoint } from 'librechat-data-provider'; +import type { + TEndpoint, + TCustomConfig, + TAgentsEndpoint, + TAnthropicEndpoint, +} from 'librechat-data-provider'; import type { AppConfig } from '~/types'; import { azureAssistantsDefaults, assistantsConfigSetup } from './assistants'; import { agentsConfigSetup } from './agents'; @@ -88,7 +93,12 @@ export const loadEndpoints = ( }); if (endpoints?.all) { - loadedEndpoints.all = endpoints.all; + /** + * `DeepPartial` widens record values to `string | undefined` + * (e.g. `headers`), so cast to the concrete endpoint type — mirrors the + * `anthropicConfig as TAnthropicEndpoint` cast above. + */ + loadedEndpoints.all = endpoints.all as Partial; } if (endpoints?.allowedAddresses) {