From 0b96fb9f7071af39d9a6a092a003c68a3a65235b Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Wed, 2 Sep 2026 10:25:38 -0700 Subject: [PATCH] =?UTF-8?q?=F0=9F=AA=AA=20feat:=20Propagate=20Tenant=20to?= =?UTF-8?q?=20Agent=20Model=20Headers=20(#15521)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: propagate tenant to agent model headers * fix: propagate tenant across agent model calls --- .../agents/__tests__/openai.spec.js | 8 +- .../agents/__tests__/responses.unit.spec.js | 8 +- api/server/controllers/agents/client.js | 11 +- api/server/controllers/agents/client.test.js | 6 +- api/server/controllers/agents/openai.js | 2 +- api/server/controllers/agents/responses.js | 2 +- .../custom-endpoint-tenant.e2e.test.ts | 190 ++++++++++++++++++ .../__tests__/run-summarization.test.ts | 29 ++- .../activityLabels/__tests__/host.spec.ts | 23 ++- .../api/src/agents/activityLabels/host.ts | 2 + packages/api/src/agents/memory.spec.ts | 4 + packages/api/src/agents/memory.ts | 6 + packages/api/src/agents/run.ts | 15 +- packages/api/src/utils/headers.spec.ts | 40 +++- packages/api/src/utils/headers.ts | 73 ++++++- 15 files changed, 389 insertions(+), 30 deletions(-) create mode 100644 packages/api/src/agents/__tests__/custom-endpoint-tenant.e2e.test.ts diff --git a/api/server/controllers/agents/__tests__/openai.spec.js b/api/server/controllers/agents/__tests__/openai.spec.js index feca57e144..90fdabe516 100644 --- a/api/server/controllers/agents/__tests__/openai.spec.js +++ b/api/server/controllers/agents/__tests__/openai.spec.js @@ -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), diff --git a/api/server/controllers/agents/__tests__/responses.unit.spec.js b/api/server/controllers/agents/__tests__/responses.unit.spec.js index 2570a070b0..584b4c8e56 100644 --- a/api/server/controllers/agents/__tests__/responses.unit.spec.js +++ b/api/server/controllers/agents/__tests__/responses.unit.spec.js @@ -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), diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index 4d3674299e..bfe23974fb 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -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, diff --git a/api/server/controllers/agents/client.test.js b/api/server/controllers/agents/client.test.js index 39a1648729..0eb7d3d1e7 100644 --- a/api/server/controllers/agents/client.test.js +++ b/api/server/controllers/agents/client.test.js @@ -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, }), diff --git a/api/server/controllers/agents/openai.js b/api/server/controllers/agents/openai.js index 3e2bdb1b8d..b57cce7493 100644 --- a/api/server/controllers/agents/openai.js +++ b/api/server/controllers/agents/openai.js @@ -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) { diff --git a/api/server/controllers/agents/responses.js b/api/server/controllers/agents/responses.js index 79ce4ea3e2..606f4733be 100644 --- a/api/server/controllers/agents/responses.js +++ b/api/server/controllers/agents/responses.js @@ -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) { diff --git a/packages/api/src/agents/__tests__/custom-endpoint-tenant.e2e.test.ts b/packages/api/src/agents/__tests__/custom-endpoint-tenant.e2e.test.ts new file mode 100644 index 0000000000..a87b0b96a8 --- /dev/null +++ b/packages/api/src/agents/__tests__/custom-endpoint-tenant.e2e.test.ts @@ -0,0 +1,190 @@ +import { formatAgentMessages, Providers } from '@librechat/agents'; +import { createRun } from '~/agents/run'; + +type CapturedRequest = { + tenantId?: string; + body: Record; +}; + +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]> = [ + [ + '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, + }); + + 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(); + } + }); +}); diff --git a/packages/api/src/agents/__tests__/run-summarization.test.ts b/packages/api/src/agents/__tests__/run-summarization.test.ts index ae60bb2d99..800a1f387a 100644 --- a/packages/api/src/agents/__tests__/run-summarization.test.ts +++ b/packages/api/src/agents/__tests__/run-summarization.test.ts @@ -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; + const parameters = config.parameters as Record; + const configuration = parameters.configuration as Record; + + 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'; diff --git a/packages/api/src/agents/activityLabels/__tests__/host.spec.ts b/packages/api/src/agents/activityLabels/__tests__/host.spec.ts index 33d3784b08..ac7e049991 100644 --- a/packages/api/src/agents/activityLabels/__tests__/host.spec.ts +++ b/packages/api/src/agents/activityLabels/__tests__/host.spec.ts @@ -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): 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 diff --git a/packages/api/src/agents/activityLabels/host.ts b/packages/api/src/agents/activityLabels/host.ts index 7318b8b350..3fdd3e42e6 100644 --- a/packages/api/src/agents/activityLabels/host.ts +++ b/packages/api/src/agents/activityLabels/host.ts @@ -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 { diff --git a/packages/api/src/agents/memory.spec.ts b/packages/api/src/agents/memory.spec.ts index e74851ccf0..918f979782 100644 --- a/packages/api/src/agents/memory.spec.ts +++ b/packages/api/src/agents/memory.spec.ts @@ -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 () => { diff --git a/packages/api/src/agents/memory.ts b/packages/api/src/agents/memory.ts index b453dc83fa..9e7627d9aa 100644 --- a/packages/api/src/agents/memory.ts +++ b/packages/api/src/agents/memory.ts @@ -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)); diff --git a/packages/api/src/agents/run.ts b/packages/api/src/agents/run.ts index 1297763ad9..40fa88ed18 100644 --- a/packages/api/src/agents/run.ts +++ b/packages/api/src/agents/run.ts @@ -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, 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, }); diff --git a/packages/api/src/utils/headers.spec.ts b/packages/api/src/utils/headers.spec.ts index e87c6f20ab..d534b3ebc3 100644 --- a/packages/api/src/utils/headers.spec.ts +++ b/packages/api/src/utils/headers.spec.ts @@ -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 }).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(); diff --git a/packages/api/src/utils/headers.ts b/packages/api/src/utils/headers.ts index e2644f0be5..70806d3bb7 100644 --- a/packages/api/src/utils/headers.ts +++ b/packages/api/src/utils/headers.ts @@ -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 }; +type CustomHeadersContainer = { customHeaders?: Record }; + +function resolveTenantPlaceholders( + headers: Record, + tenantId?: string, +): Record { + 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 | undefined; + user?: Partial | { id: string }; + tenantId?: string; + body?: RequestBody; + customUserVars?: Record; +}): Record { + 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(); * 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(); export function resolveConfigHeaders({ llmConfig, user, + tenantId, body, customUserVars, }: { @@ -123,6 +173,8 @@ export function resolveConfigHeaders({ * full run config. */ llmConfig?: Partial | null; user?: Partial | { id: string }; + /** Authoritative request tenant used only for model-header templates. */ + tenantId?: string; body?: RequestBody; customUserVars?: Record; }): 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); + } + } }