📨 feat: Custom Headers on Built-in Provider Endpoints (#13742)

* 📨 feat: Custom Headers on Built-in Provider Endpoints

Add a `headers` config option to the built-in `openAI`, `anthropic`, and
`google` endpoints (incl. Anthropic/Google Vertex), mirroring the custom
endpoint header mechanism. Values support the same placeholder resolution
(env vars, `{{LIBRECHAT_USER_*}}`, `{{LIBRECHAT_BODY_CONVERSATIONID}}`) and
are resolved at request time so dynamic values like conversationId resolve
against the live request — without losing provider-native request shaping.

Closes #13082. Covers #13713: forwarding conversationId to a reverse proxy
is now `X-Conversation-Id: '{{LIBRECHAT_BODY_CONVERSATIONID}}'` — an unknown
header is ignored by the native Anthropic API, so no 400 and no metadata
gating needed.

- Schema: `headers` on `baseEndpointSchema` (openAI/google/anthropic/all).
- New `mergeHeaders`/`resolveConfigHeaders` utils centralize the per-provider
  header locations (`configuration.defaultHeaders`, Anthropic
  `clientOptions.defaultHeaders`, Google `customHeaders`); provider-managed
  headers (auth, `anthropic-beta`) always win on collision.
- Each initializer threads configured headers (endpoint over `all`) into the
  right place; request-time resolution runs across all locations in the main
  and title flows.

* 🩹 fix: Cast endpoints.all to TEndpoint for headers DeepPartial widening

Adding `headers` (a Record) to `baseEndpointSchema` makes `DeepPartial<TCustomConfig>`
widen its value type to `string | undefined`, which is not assignable to the
concrete `TEndpoint['headers']: Record<string, string>` at the `loadedEndpoints.all`
assignment. Cast at the assignment site, mirroring the existing
`anthropicConfig as TAnthropicEndpoint` cast in the same function.

* 🛡️ fix: Harden built-in endpoint custom headers (Codex review)

Address Codex P2 findings on the custom-headers feature:

- Anthropic title requests: `omitTitleOptions` strips the `clientOptions`
  carrier, which dropped its `defaultHeaders`. Preserve just the header carrier
  so gateway/reverse-proxy metadata still reaches title generation.
- mergeHeaders: match header names case-insensitively so an override (e.g. a
  provider-managed `Authorization`/`anthropic-beta`) replaces/uniones a
  case-variant from the base instead of emitting two names a client may collapse.
- OpenAI: withhold admin-configured headers when the user supplies the base URL
  (`user_provided`), since values may carry `${SECRET}`/token placeholders that
  must not reach a user-controlled endpoint — mirrors the custom-endpoint guard.
- Azure: honor global `endpoints.all` headers (same OpenAI carrier) while keeping
  Azure-managed `api-key`/version headers authoritative.

Adds tests for each.

* 🔐 fix: Resolve-once + provider-managed header safety (Codex review round 2)

Address Codex P2 findings:

- Azure: keep global `endpoints.all` headers unresolved at init and let
  request-time `resolveConfigHeaders` resolve them once, avoiding a
  second-order env expansion of already-substituted user values.
- Google: `resolveConfigHeaders` no longer template-resolves the
  provider-managed `Authorization` header (built from a possibly user-provided
  key), so a user key like `${ENV}` can't leak server environment values.
- Model fetches: thread configured headers (endpoint over `all`) + user object
  through `getOpenAIModels`/`getAnthropicModels` → `fetchModels`, so a
  gateway-fronted built-in provider receives the header on `/models` too. Fixed
  `fetchModels` to merge custom headers for Anthropic instead of overwriting
  them (managed `x-api-key`/version still win).

Adds/updates tests for each.

* 🧯 fix: Header provenance, memory/title coverage, idempotency (Codex round 3)

Address Codex P2 findings, including two regressions from the prior round:

- Google auth (findings 6 & 8): move native Google header resolution to init
  (`initializeGoogle`), resolving admin templates BEFORE the key-derived auth
  header is built. resolveConfigHeaders no longer touches Google `customHeaders`,
  so admin `Authorization` templates resolve again (fixes the round-2 regression)
  while the SDK auth header (possibly a user-provided key) is never env-expanded.
- Memory runs: memory extraction now calls `resolveConfigHeaders`, so native
  Anthropic (and OpenAI) headers resolve for memory requests too.
- Vertex titles: restore the ORIGINAL `clientOptions` object reference (not a
  copy) when preserving headers across `omitTitleOptions`, so the Vertex
  `createClient` closure and the resolved headers stay on the same object.
- Reuse: `resolveConfigHeaders` is now idempotent (resolve-once per header map),
  preventing a second pass from env-expanding values already substituted with
  user/body data when an agent object flows through buildAgentInput twice.

Adds/updates tests for each.
This commit is contained in:
Danny Avila 2026-06-14 17:02:04 -04:00 committed by GitHub
parent 7c071e244b
commit 2350ebb24a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 950 additions and 76 deletions

View file

@ -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({

View file

@ -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: {} };

View file

@ -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 [];

View file

@ -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

View file

@ -27,12 +27,34 @@ const mockResolveHeaders = jest.fn((opts) => {
return result;
});
type HeaderCarrier = { defaultHeaders?: Record<string, string> };
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');

View file

@ -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<string, unknown>).temperature;
}
const llmConfigWithHeaders = finalLLMConfig as OpenAIClientOptions;
if (llmConfigWithHeaders?.configuration?.defaultHeaders != null) {
llmConfigWithHeaders.configuration.defaultHeaders = resolveHeaders({
headers: llmConfigWithHeaders.configuration.defaultHeaders as Record<string, string>,
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<TAttachment | null>[] = [];
const memoryCallback = createMemoryCallback({ res, artifactPromises, streamId });

View file

@ -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<string, string>,
user: createSafeUser(user),
body: requestBody,
});
}
resolveConfigHeaders({
llmConfig,
user: createSafeUser(user),
body: requestBody,
});
/** Resolves issues with new OpenAI usage field */
if (

View file

@ -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<string, string> =>
((llmConfig as AnthropicClientOptions).clientOptions?.defaultHeaders ?? {}) as Record<
string,
string
>;
function createParams(
endpointsConfig: Record<string, unknown>,
env: Record<string, string | undefined> = {},
): { params: BaseInitializeParams; restore: () => void } {
const savedEnv: Record<string, string | undefined> = {};
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();
}
});
});

View file

@ -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) {

View file

@ -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<string, string>)['anthropic-beta']).toBe(
FINE_GRAINED_TOOL_STREAMING_BETA,
);
/** Custom header is attached, placeholders kept intact for request-time resolution */
expect((clientOptions?.defaultHeaders as Record<string, string>)['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<string, string>)[
'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');
});
});
});

View file

@ -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<string, string> | undefined,
);
}
return {
tools,
llmConfig: removeNullishValues(

View file

@ -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',
});
});
});

View file

@ -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

View file

@ -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',
});
});
});
});

View file

@ -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<string, string> | 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)) {

View file

@ -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', () => {

View file

@ -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<string, string> | null;
/** User object for resolving header placeholders */
userObject?: Partial<IUser>;
}
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<string, string> | null;
userObject?: Partial<IUser>;
} = {},
_models: string[] = [],
): Promise<string[]> {
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<string, string> | null;
userObject?: Partial<IUser>;
} = {},
): Promise<string[]> {
const models = defaultModels[EModelEndpoint.anthropic];

View file

@ -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<Response>;
@ -50,26 +51,6 @@ function getReasoningFormat({
return undefined;
}
function mergeHeadersPreservingAnthropicBeta(
headers: Record<string, string> | undefined,
defaultHeaders: Record<string, string>,
): Record<string, string> {
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<string, string>,
);

View file

@ -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<string, string | undefined>): 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<string, unknown> }).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<string, string> };
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<string, string> };
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<string, unknown> }).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<string, string> };
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<string, unknown> }).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<string, string> };
// Left unresolved here; request-time resolveConfigHeaders resolves it once
expect(options.headers).toEqual({ 'X-Global': '{{LIBRECHAT_USER_ID}}' });
});
});

View file

@ -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<InitializeResultBase> {
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;

View file

@ -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<typeof anthropicSchema>;
@ -86,6 +86,11 @@ export interface AnthropicConfigOptions {
addParams?: Record<string, unknown>;
/** 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<string, string>;
/** Vertex AI specific options for Google Cloud configuration */
vertexOptions?: VertexAIClientOptions;
/** Full Vertex AI configuration including model mappings from YAML config */

View file

@ -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<string, string> } }
).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<string, string> }).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({});
});
});

View file

@ -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<string, string>,
override?: Record<string, string>,
): Record<string, string> | undefined {
if (!base && !override) {
return undefined;
}
const merged: Record<string, string> = { ...(base ?? {}) };
if (!override) {
return merged;
}
const baseKeyByLower = new Map<string, string>(
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<string, string> };
/**
* 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<object>();
/**
* 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<IUser> | { id: string };
body?: RequestBody;
customUserVars?: Record<string, string>;
}): void {
if (llmConfig == null) {
return;
}
const resolveOnce = (headers: Record<string, string>): Record<string, string> => {
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);
}
}

View file

@ -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';

View file

@ -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(),

View file

@ -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<TCustomConfig>` 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<TEndpoint>;
}
if (endpoints?.allowedAddresses) {