🪪 feat: Propagate Tenant to Agent Model Headers (#15521)

* fix: propagate tenant to agent model headers

* fix: propagate tenant across agent model calls
This commit is contained in:
Dustin Healy 2026-09-02 10:25:38 -07:00 • committed by GitHub
parent d5b2a8580e
commit 0b96fb9f70
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 389 additions and 30 deletions

View file

@ -1332,9 +1332,10 @@ describe('OpenAIChatCompletionController', () => {
req.user = {
id: 'user-123',
role: 'USER',
tenantId: 'tenant-123',
tenantId: 'stale-user-tenant',
federatedTokens: { access_token: 'secret' },
};
req.tenantId = 'request-tenant';
const requestBody = {
...req.body,
ephemeralAgent: { skills: true },
@ -1350,7 +1351,10 @@ describe('OpenAIChatCompletionController', () => {
expect(mockCreateAgentRunEnvelope).toHaveBeenCalledWith(
expect.objectContaining({
protocol: 'chat.completions',
principal: req.user,
principal: {
...req.user,
tenantId: 'request-tenant',
},
payload: requestBody,
requestId: expect.any(String),
receivedAt: expect.any(Number),

View file

@ -815,9 +815,10 @@ describe('createResponse controller', () => {
req.user = {
id: 'user-123',
role: 'USER',
tenantId: 'tenant-123',
tenantId: 'stale-user-tenant',
federatedTokens: { access_token: 'secret' },
};
req.tenantId = 'request-tenant';
const requestBody = {
...req.body,
ephemeralAgent: { skills: true },
@ -834,7 +835,10 @@ describe('createResponse controller', () => {
expect(mockCreateAgentRunEnvelope).toHaveBeenCalledWith(
expect.objectContaining({
protocol: 'responses',
principal: req.user,
principal: {
...req.user,
tenantId: 'request-tenant',
},
payload: requestBody,
requestId: expect.any(String),
receivedAt: expect.any(Number),

View file

@ -16,6 +16,7 @@ const {
createSafeUser,
initializeAgent,
resolveConfigHeaders,
resolveRequestTenantId,
countTokens,
getBalanceConfig,
omitTitleOptions,
@ -2797,6 +2798,7 @@ class AgentClient extends BaseClient {
},
res: this.options.res,
user: createSafeUser(this.options.req.user),
tenantId: resolveRequestTenantId(this.options.req),
});
this.processMemory = processMemory;
@ -3803,7 +3805,7 @@ class AgentClient extends BaseClient {
? buildToolApprovalHooks({
userId: this.options.req?.user?.id,
conversationId: this.conversationId,
tenantId: this.options.req?.user?.tenantId,
tenantId: resolveRequestTenantId(this.options.req ?? {}),
appConfig,
})
: undefined;
@ -4265,7 +4267,7 @@ class AgentClient extends BaseClient {
customHandlers: reasoningLabel?.handlers(activityHandlers) ?? activityHandlers,
requestBody: config.configurable.requestBody,
user: createSafeUser(this.options.req?.user),
tenantId: this.options.req?.user?.tenantId,
tenantId: resolveRequestTenantId(this.options.req ?? {}),
summarizationConfig: appConfig?.summarization,
appConfig,
tokenCounter,
@ -4607,7 +4609,7 @@ class AgentClient extends BaseClient {
? buildToolApprovalHooks({
userId: this.options.req?.user?.id,
conversationId: this.conversationId,
tenantId: this.options.req?.user?.tenantId,
tenantId: resolveRequestTenantId(this.options.req ?? {}),
appConfig,
})
: undefined;
@ -4800,7 +4802,7 @@ class AgentClient extends BaseClient {
customHandlers: reasoningLabel?.handlers(activityHandlers) ?? activityHandlers,
requestBody: config.configurable.requestBody,
user: createSafeUser(this.options.req?.user),
tenantId: this.options.req?.user?.tenantId,
tenantId: resolveRequestTenantId(this.options.req ?? {}),
summarizationConfig: appConfig?.summarization,
appConfig,
tokenCounter,
@ -5161,6 +5163,7 @@ class AgentClient extends BaseClient {
resolveConfigHeaders({
llmConfig: clientOptions,
user: createSafeUser(req?.user),
tenantId: resolveRequestTenantId(req ?? {}),
body: {
messageId: this.responseMessageId,
conversationId: this.conversationId,

View file

@ -1677,7 +1677,8 @@ describe('AgentClient - startup telemetry', () => {
const client = new AgentClient({
req: {
user: { id: 'user-123' },
tenantId: 'request-tenant',
user: { id: 'user-123', tenantId: 'stale-user-tenant' },
body: {},
config: {
endpoints: { [EModelEndpoint.agents]: { toolApproval: { enabled: true } } },
@ -1712,6 +1713,7 @@ describe('AgentClient - startup telemetry', () => {
expect(mockCreateRun).toHaveBeenCalledTimes(1);
expect(mockCreateRun.mock.calls[0][0]).toEqual(
expect.objectContaining({
tenantId: 'request-tenant',
modelCallbacks: [
expect.objectContaining({
name: 'librechat-model-bound-content-filter',
@ -6961,6 +6963,7 @@ describe('AgentClient - resumeCompletion content protection', () => {
const makeContext = (filters) => ({
options: {
req: {
tenantId: 'request-tenant',
user: { id: 'user-123' },
body: { files: [] },
config: {
@ -7230,6 +7233,7 @@ describe('AgentClient - resumeCompletion content protection', () => {
expect(mockCreateRun).toHaveBeenCalledTimes(1);
expect(mockCreateRun.mock.calls[0][0]).toEqual(
expect.objectContaining({
tenantId: 'request-tenant',
modelCallbacks: [expect.objectContaining({ name: 'librechat-model-bound-content-filter' })],
compactionSemanticIndex: compactionSemanticIndex.entries,
}),

View file

@ -1209,7 +1209,7 @@ const OpenAIChatCompletionController = async (req, res) => {
protocol: 'chat.completions',
requestId: req.requestId ?? req.id ?? `agent-run-${nanoid()}`,
receivedAt,
principal: req.user,
principal: req.tenantId == null ? req.user : { ...req.user, tenantId: req.tenantId },
payload: validation.request,
});
} catch (error) {

View file

@ -1536,7 +1536,7 @@ const createResponse = async (req, res) => {
protocol: 'responses',
requestId: req.requestId ?? req.id ?? `agent-run-${nanoid()}`,
receivedAt,
principal: req.user,
principal: req.tenantId == null ? req.user : { ...req.user, tenantId: req.tenantId },
payload: validation.request,
});
} catch (error) {

View file

@ -0,0 +1,190 @@
import { formatAgentMessages, Providers } from '@librechat/agents';
import { createRun } from '~/agents/run';
type CapturedRequest = {
tenantId?: string;
body: Record<string, unknown>;
};
const ROOT_ID = 'agent_root';
const CHILD_ID = 'agent_child';
const TENANT_HEADER = 'x-tenant-id';
function anthropicStream(kind: 'delegate' | 'text', text = ''): string {
const events: Array<[string, Record<string, unknown>]> = [
[
'message_start',
{
type: 'message_start',
message: {
id: `msg_${kind}`,
type: 'message',
role: 'assistant',
model: 'claude-test',
content: [],
stop_reason: null,
stop_sequence: null,
usage: { input_tokens: 1, output_tokens: 0 },
},
},
],
];
if (kind === 'delegate') {
events.push(
[
'content_block_start',
{
type: 'content_block_start',
index: 0,
content_block: { type: 'tool_use', id: 'toolu_delegate', name: 'subagent', input: {} },
},
],
[
'content_block_delta',
{
type: 'content_block_delta',
index: 0,
delta: {
type: 'input_json_delta',
partial_json: JSON.stringify({
description: 'Answer the user request.',
subagent_type: CHILD_ID,
}),
},
},
],
);
} else {
events.push(
[
'content_block_start',
{
type: 'content_block_start',
index: 0,
content_block: { type: 'text', text: '' },
},
],
[
'content_block_delta',
{
type: 'content_block_delta',
index: 0,
delta: { type: 'text_delta', text },
},
],
);
}
events.push(
['content_block_stop', { type: 'content_block_stop', index: 0 }],
[
'message_delta',
{
type: 'message_delta',
delta: {
stop_reason: kind === 'delegate' ? 'tool_use' : 'end_turn',
stop_sequence: null,
},
usage: { output_tokens: 1 },
},
],
['message_stop', { type: 'message_stop' }],
);
return events
.map(([event, data]) => `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
.join('');
}
function makeAgent(id: string, baseURL: string) {
return {
id,
name: id,
provider: Providers.ANTHROPIC,
endpoint: 'Tenant Gateway',
instructions:
id === ROOT_ID
? 'Delegate the request to agent_child, then return its answer.'
: 'Return a short answer.',
tools: [],
maxContextTokens: 4096,
recursion_limit: 9,
model_parameters: {
model: 'claude-test',
maxTokens: 64,
streaming: false,
apiKey: 'test-key',
clientOptions: {
baseURL,
defaultHeaders: { 'X-Tenant-ID': '{{LIBRECHAT_USER_TENANT_ID}}' },
},
},
};
}
describe('custom endpoint tenant headers E2E', () => {
it('sends the authoritative tenant on root and subagent HTTP requests', async () => {
const requests: CapturedRequest[] = [];
const fetchMock = jest.spyOn(global, 'fetch').mockImplementation(async (input, init) => {
const request = input instanceof Request ? input : undefined;
const headers = new Headers(request?.headers ?? init?.headers);
const rawBody = request ? await request.clone().text() : String(init?.body ?? '{}');
requests.push({
tenantId: headers.get(TENANT_HEADER) ?? undefined,
body: JSON.parse(rawBody) as Record<string, unknown>,
});
const stream =
requests.length === 1
? anthropicStream('delegate')
: anthropicStream('text', requests.length === 3 ? 'Final answer.' : 'Child answer.');
return new Response(stream, {
status: 200,
headers: { 'content-type': 'text/event-stream' },
});
});
try {
const baseURL = 'https://fake-custom-endpoint.invalid';
const child = makeAgent(CHILD_ID, baseURL);
const root = {
...makeAgent(ROOT_ID, baseURL),
subagents: { enabled: true, allowSelf: false, agent_ids: [CHILD_ID] },
subagentAgentConfigs: [child],
};
const { messages } = formatAgentMessages(
[{ role: 'user', content: 'Ask the child for an answer.' }] as never,
{},
);
const runId = `custom-endpoint-tenant-${Date.now()}`;
const run = await createRun({
agents: [root] as never,
messages,
runId,
signal: new AbortController().signal,
streaming: false,
streamUsage: true,
user: { id: 'user-1', tenantId: 'stale-user-tenant' } as never,
tenantId: 'request-tenant',
});
await run.processStream(
{ messages },
{
configurable: { thread_id: runId },
recursionLimit: 20,
version: 'v2',
},
);
expect(requests).toHaveLength(3);
expect(requests.every(({ tenantId }) => tenantId === 'request-tenant')).toBe(true);
expect(requests.every(({ body }) => body.stream === true)).toBe(true);
expect(requests.every(({ body }) => body.model === 'claude-test')).toBe(true);
} finally {
fetchMock.mockRestore();
}
});
});

View file

@ -8,8 +8,8 @@ import {
} from 'librechat-data-provider';
import type { CompactionSemanticIndex, SubagentTaskConfig } from '@librechat/agents';
import type { SummarizationConfig, TEndpoint } from 'librechat-data-provider';
import type { AppConfig, IUser } from '@librechat/data-schemas';
import type { BaseMessage } from '@langchain/core/messages';
import type { AppConfig } from '@librechat/data-schemas';
import type { ModelBoundChatModelCallback } from '~/middleware/modelBoundContent';
import { createRun, isAskUserQuestionAdminDisabled } from '~/agents/run';
@ -187,6 +187,8 @@ async function callAndCapture(
compactionSemanticIndex?: CompactionSemanticIndex;
subagentTasks?: SubagentTaskConfig;
modelCallbacks?: readonly ModelBoundChatModelCallback[];
user?: IUser;
tenantId?: string;
} = {},
) {
const agents = opts.agents ?? [makeAgent()];
@ -203,6 +205,8 @@ async function callAndCapture(
compactionSemanticIndex: opts.compactionSemanticIndex,
subagentTasks: opts.subagentTasks,
modelCallbacks: opts.modelCallbacks,
user: opts.user,
tenantId: opts.tenantId,
streaming: true,
streamUsage: true,
});
@ -1244,6 +1248,29 @@ describe('custom-endpoint provider resolution', () => {
expect(call).toBeDefined();
});
it('uses the authoritative run tenant in custom-endpoint summarization headers', async () => {
const appConfig = makeAppConfig([
{
name: 'Tenant Gateway',
baseURL: 'https://gateway.example.com/v1',
apiKey: 'gateway-key',
headers: { 'X-Tenant-ID': '{{LIBRECHAT_USER_TENANT_ID}}' },
},
]);
const agents = await callAndCapture({
summarizationConfig: { provider: 'Tenant Gateway', model: 'summary-model' },
appConfig,
user: { id: 'user-1', tenantId: 'stale-user-tenant' } as IUser,
tenantId: 'request-tenant',
});
const config = agents[0].summarizationConfig as Record<string, unknown>;
const parameters = config.parameters as Record<string, unknown>;
const configuration = parameters.configuration as Record<string, unknown>;
expect(configuration.defaultHeaders).toEqual({ 'X-Tenant-ID': 'request-tenant' });
});
it('forwards PROXY env var into summarization client configuration', async () => {
const originalProxy = process.env.PROXY;
process.env.PROXY = 'http://proxy.internal:3128';

View file

@ -11,13 +11,16 @@ import {
const mockGetOptions = jest.fn(async (_params: unknown) => ({
llmConfig: { model: 'resolved' },
}));
const mockResolveConfigHeaders = jest.fn();
jest.mock('~/endpoints/config/providers', () => ({
getProviderConfig: jest.fn(() => ({
getOptions: (params: unknown) => mockGetOptions(params),
customEndpointConfig: undefined,
})),
}));
jest.mock('~/utils/headers', () => ({ resolveConfigHeaders: jest.fn() }));
jest.mock('~/utils/headers', () => ({
resolveConfigHeaders: (...args: unknown[]) => mockResolveConfigHeaders(...args),
}));
const appConfig = (endpoints: Record<string, unknown>): AppConfig =>
({ endpoints }) as unknown as AppConfig;
@ -233,6 +236,24 @@ describe('resolveActivityLabelModel model precedence', () => {
beforeEach(() => {
mockGetOptions.mockClear();
mockResolveConfigHeaders.mockClear();
});
it('uses the request tenant when resolving activity model headers', async () => {
await resolveActivityLabelModel({
req: {
tenantId: 'request-tenant',
user: { tenantId: 'stale-user-tenant' },
config: appConfig({ openAI: { activityLabel: true } }),
} as unknown as ServerRequest,
agent: { endpoint: 'openAI', model_parameters: { model: 'run-model' } },
ids: { conversationId: 'conversation-1' },
db,
});
expect(mockResolveConfigHeaders).toHaveBeenCalledWith(
expect.objectContaining({ tenantId: 'request-tenant' }),
);
});
/** An EXPLICIT `activityModel: current_model` names the run model — a

View file

@ -7,6 +7,7 @@ import type { ClientOptions } from '@librechat/agents';
import type { EndpointDbMethods, OpenAIConfiguration, ServerRequest } from '~/types';
import type { ActivityLabelLLM } from './runtime';
import { getProviderConfig } from '~/endpoints/config/providers';
import { resolveRequestTenantId } from '~/middleware/tenant';
import { resolveConfigHeaders } from '~/utils/headers';
import { omitTitleOptions } from '~/agents/client';
import { createSafeUser } from '~/utils/env';
@ -482,6 +483,7 @@ export async function resolveActivityLabelModel({
resolveConfigHeaders({
llmConfig: clientOptions,
user: createSafeUser(req.user as IUser | undefined),
tenantId: resolveRequestTenantId(req),
body: ids,
});
return {

View file

@ -282,6 +282,7 @@ describe('Memory Agent Header Resolution', () => {
instructions: 'test instructions',
llmConfig,
user: testUser,
tenantId: 'request-tenant',
});
expect(Run.create as jest.Mock).toHaveBeenCalled();
@ -290,6 +291,9 @@ describe('Memory Agent Header Resolution', () => {
'X-User-Identifier': 'test@example.com',
'X-User-ID': 'user-123',
});
expect(mockResolveConfigHeaders).toHaveBeenCalledWith(
expect.objectContaining({ tenantId: 'request-tenant' }),
);
});
it('should handle mixed environment variables and user placeholders', async () => {

View file

@ -764,6 +764,7 @@ export async function processMemory({
streamId = null,
jobCreatedAt,
user,
tenantId,
}: {
res: ServerResponse;
setMemory: MemoryMethods['setMemory'];
@ -792,6 +793,7 @@ export async function processMemory({
streamId?: string | null;
jobCreatedAt?: number;
user?: IUser;
tenantId?: string;
}): Promise<(TAttachment | null)[] | undefined> {
try {
const submittedMessages = (inspectionMessages ?? messages).filter(
@ -915,6 +917,7 @@ ${memory ?? 'No existing memories'}`;
resolveConfigHeaders({
llmConfig: finalLLMConfig as unknown as RunLLMConfig,
user: user ? createSafeUser(user) : undefined,
tenantId,
body: { conversationId, messageId },
});
@ -1027,6 +1030,7 @@ export async function createMemoryProcessor({
streamId = null,
jobCreatedAt,
user,
tenantId,
}: {
res: ServerResponse;
messageId: string;
@ -1040,6 +1044,7 @@ export async function createMemoryProcessor({
streamId?: string | null;
jobCreatedAt?: number;
user?: IUser;
tenantId?: string;
}): Promise<
[
string,
@ -1092,6 +1097,7 @@ export async function createMemoryProcessor({
setMemory: memoryMethods.setMemory,
deleteMemory: memoryMethods.deleteMemory,
user,
tenantId,
});
} catch (error) {
logger.error('Memory Agent failed to process memory', getSafeErrorMetadata(error));

View file

@ -95,19 +95,19 @@ import { stripIntentFromToolRegistry, stripIntentFromToolDefinitions } from '~/a
import { extractDefaultParams, resolveReasoningParams } from '~/endpoints/openai/llm';
import { getLLMConfig as getAnthropicLLMConfig } from '~/endpoints/anthropic/llm';
import { CREATE_FILE_TOOL_NAME, EDIT_FILE_TOOL_NAME } from '~/agents/tools';
import { resolveConfigHeaders, resolveModelHeaders } from '~/utils/headers';
import { buildAgentInitialToolSessions } from '~/agents/codeFilesSession';
import { getProviderConfig } from '~/endpoints/config/providers';
import { buildToolApprovalHooks } from '~/agents/hitl/hooks';
import { resolveHeaders, createSafeUser } from '~/utils/env';
import { getAgentCheckpointer } from '~/agents/checkpointer';
import { getPluginHookSource } from '~/agents/hooks/source';
import { getOpenAIConfig } from '~/endpoints/openai/config';
import { createStepBudgetHook } from '~/agents/stepBudget';
import { buildHITLRunWiring } from '~/agents/hitl/runtime';
import { buildLangfuseConfig } from '~/langfuse/config';
import { resolveConfigHeaders } from '~/utils/headers';
import { applyTestRunHook } from '~/agents/testHook';
import { isUserProvided } from '~/utils/common';
import { createSafeUser } from '~/utils/env';
/** Expected shape of JSON tool search results */
interface ToolSearchJsonResult {
@ -599,7 +599,7 @@ interface SummarizationClientOverrides {
function resolveSummarizationProvider(
rawProvider: string,
appConfig: AppConfig | undefined,
headerContext: { user?: IUser; requestBody?: t.RequestBody },
headerContext: { user?: IUser; tenantId?: string; requestBody?: t.RequestBody },
): {
provider: string;
clientOverrides?: SummarizationClientOverrides;
@ -652,11 +652,11 @@ function resolveSummarizationProvider(
*/
const resolvedHeaders =
customEndpointConfig.headers != null
? resolveHeaders({
? resolveModelHeaders({
headers: customEndpointConfig.headers as Record<string, string>,
user: createSafeUser(headerContext.user),
tenantId: headerContext.tenantId,
body: headerContext.requestBody,
stripUnresolved: true,
})
: undefined;
/**
@ -759,7 +759,7 @@ function shapeSummarizationConfig(
fallbackModel: string | undefined,
appConfig: AppConfig | undefined,
agentEndpoint: string | undefined,
headerContext: { user?: IUser; requestBody?: t.RequestBody },
headerContext: { user?: IUser; tenantId?: string; requestBody?: t.RequestBody },
) {
const rawProvider = config?.provider ?? fallbackProvider;
/**
@ -1587,7 +1587,7 @@ export async function createRun({
selfModel,
appConfig,
agent.endpoint ?? undefined,
{ user, requestBody },
{ user, tenantId, requestBody },
);
const summarization = modelCallbacks?.length
? {
@ -1644,6 +1644,7 @@ export async function createRun({
resolveConfigHeaders({
llmConfig,
user: createSafeUser(user),
tenantId,
body: requestBody,
});

View file

@ -128,22 +128,24 @@ describe('resolveConfigHeaders', () => {
});
});
it('leaves Google customHeaders untouched (resolved at init, not request time)', () => {
it('resolves only tenant placeholders in Google customHeaders', () => {
const llmConfig = {
customHeaders: {
'X-Conversation-Id': '{{LIBRECHAT_BODY_CONVERSATIONID}}',
'X-Tenant-Id': '{{LIBRECHAT_USER_TENANT_ID}}',
Authorization: 'Bearer ${SOME_KEY}',
},
} as unknown as RunLLMConfig;
resolveConfigHeaders({ llmConfig, user, body });
resolveConfigHeaders({ llmConfig, user, tenantId: 'request-tenant', body });
// Native Google headers are resolved in initializeGoogle; resolveConfigHeaders
// must not re-process them (keeps the key-derived auth out of env expansion).
// Native Google headers are otherwise resolved in initializeGoogle. In
// particular, provider auth must never pass through environment expansion.
expect(
(llmConfig as unknown as { customHeaders: Record<string, string> }).customHeaders,
).toEqual({
'X-Conversation-Id': '{{LIBRECHAT_BODY_CONVERSATIONID}}',
'X-Tenant-Id': 'request-tenant',
Authorization: 'Bearer ${SOME_KEY}',
});
});
@ -179,6 +181,36 @@ describe('resolveConfigHeaders', () => {
delete process.env.HEADERS_SPEC_GATEWAY_KEY;
});
it('resolves model tenant aliases without exposing tenantId through the safe user', () => {
const llmConfig = {
configuration: {
defaultHeaders: {
'X-Tenant-ID': '{{LIBRECHAT_USER_TENANT_ID}}',
'X-Canonical-Tenant-ID': '{{LIBRECHAT_USER_TENANTID}}',
},
},
} as unknown as RunLLMConfig;
resolveConfigHeaders({ llmConfig, user, tenantId: 'request-tenant', body });
expect(llmConfig.configuration?.defaultHeaders).toEqual({
'X-Tenant-ID': 'request-tenant',
'X-Canonical-Tenant-ID': 'request-tenant',
});
});
it('blanks model tenant placeholders when the run has no tenant', () => {
const llmConfig = {
configuration: {
defaultHeaders: { 'X-Tenant-ID': '{{LIBRECHAT_USER_TENANT_ID}}' },
},
} as unknown as RunLLMConfig;
resolveConfigHeaders({ llmConfig, user, body });
expect(llmConfig.configuration?.defaultHeaders).toEqual({ 'X-Tenant-ID': '' });
});
it('leaves configs without header maps untouched', () => {
const llmConfig = { model: 'gpt-4o', configuration: {} } as unknown as RunLLMConfig;
expect(() => resolveConfigHeaders({ llmConfig, user, body })).not.toThrow();

View file

@ -3,6 +3,11 @@ import type { IUser } from '@librechat/data-schemas';
import type { RequestBody, RunLLMConfig } from '~/types';
import { resolveHeaders } from './env';
const TENANT_ID_HEADER_PLACEHOLDERS = [
'{{LIBRECHAT_USER_TENANTID}}',
'{{LIBRECHAT_USER_TENANT_ID}}',
] as const;
/**
* The media type of a `Content-Type` header — the lowercased `type/subtype` pair with any
* parameters stripped, or `''` when the header is absent or empty.
@ -79,6 +84,51 @@ export function mergeHeaders(
}
type DefaultHeadersContainer = { defaultHeaders?: Record<string, string> };
type CustomHeadersContainer = { customHeaders?: Record<string, string> };
function resolveTenantPlaceholders(
headers: Record<string, string>,
tenantId?: string,
): Record<string, string> {
return Object.fromEntries(
Object.entries(headers).map(([name, value]) => [
name,
TENANT_ID_HEADER_PLACEHOLDERS.reduce(
(current, placeholder) => current.replaceAll(placeholder, tenantId ?? ''),
value,
),
]),
);
}
/**
* Resolves model-header templates with the request-scoped tenant supplied by
* the run host. Tenant substitution is intentionally scoped to model headers;
* it does not widen the user fields exposed to other template consumers.
*/
export function resolveModelHeaders({
headers,
user,
tenantId,
body,
customUserVars,
}: {
headers: Record<string, string> | undefined;
user?: Partial<IUser> | { id: string };
tenantId?: string;
body?: RequestBody;
customUserVars?: Record<string, string>;
}): Record<string, string> {
const resolved = resolveHeaders({
headers,
user,
body,
customUserVars,
stripUnresolved: true,
});
return resolveTenantPlaceholders(resolved, tenantId);
}
/**
* Header maps already resolved by `resolveConfigHeaders`. `resolveConfigHeaders`
@ -96,11 +146,10 @@ 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.
* Anthropic `clientOptions.defaultHeaders` (including Vertex) carriers, plus
* tenant-only substitution in native Google `customHeaders`. Google's map is
* otherwise resolved at initialization so provider-managed authorization never
* passes through environment or user-template expansion.
*
* Resolution runs at request time so request-body placeholders (e.g.
* `{{LIBRECHAT_BODY_CONVERSATIONID}}`) resolve against the live request. It is a
@ -114,6 +163,7 @@ const resolvedHeaderMaps = new WeakSet<object>();
export function resolveConfigHeaders({
llmConfig,
user,
tenantId,
body,
customUserVars,
}: {
@ -123,6 +173,8 @@ export function resolveConfigHeaders({
* full run config. */
llmConfig?: Partial<RunLLMConfig> | null;
user?: Partial<IUser> | { id: string };
/** Authoritative request tenant used only for model-header templates. */
tenantId?: string;
body?: RequestBody;
customUserVars?: Record<string, string>;
}): void {
@ -134,7 +186,7 @@ export function resolveConfigHeaders({
if (resolvedHeaderMaps.has(headers)) {
return headers;
}
const resolved = resolveHeaders({ headers, user, body, customUserVars, stripUnresolved: true });
const resolved = resolveModelHeaders({ headers, user, tenantId, body, customUserVars });
resolvedHeaderMaps.add(resolved);
return resolved;
};
@ -150,4 +202,13 @@ export function resolveConfigHeaders({
if (clientOptions?.defaultHeaders != null) {
clientOptions.defaultHeaders = resolveOnce(clientOptions.defaultHeaders);
}
const customHeadersContainer = llmConfig as CustomHeadersContainer;
if (customHeadersContainer.customHeaders != null) {
const headers = customHeadersContainer.customHeaders;
if (!resolvedHeaderMaps.has(headers)) {
customHeadersContainer.customHeaders = resolveTenantPlaceholders(headers, tenantId);
resolvedHeaderMaps.add(customHeadersContainer.customHeaders);
}
}
}