mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
📨 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:
parent
7c071e244b
commit
2350ebb24a
26 changed files with 950 additions and 76 deletions
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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: {} };
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
109
packages/api/src/endpoints/anthropic/initialize.spec.ts
Normal file
109
packages/api/src/endpoints/anthropic/initialize.spec.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}}' });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
155
packages/api/src/utils/headers.spec.ts
Normal file
155
packages/api/src/utils/headers.spec.ts
Normal 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({});
|
||||
});
|
||||
});
|
||||
128
packages/api/src/utils/headers.ts
Normal file
128
packages/api/src/utils/headers.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue