mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-25 19:52:29 +00:00
🪪 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:
parent
d5b2a8580e
commit
0b96fb9f70
15 changed files with 389 additions and 30 deletions
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue