From 22a83bd4df8de300d228bf570aa0a17e4f6ed640 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Fri, 4 Sep 2026 10:42:38 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=A5=A3=20fix:=20Apply=20Agent=20Shaping?= =?UTF-8?q?=20to=20Built-In=20Summarizers=20(#15599)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ๐Ÿงฉ fix: Shape Built-In Summarization Requests Like the Agent Flow Cross-provider summarization against a built-in provider never ran through `getOpenAIConfig`, so the summarization client learned neither which API its model takes nor whether its endpoint is first-party. The agents SDK defaults its model-specific request constraints off without that declaration, so configured parameters reached the model unshaped. Credentials and transport are deliberately untouched: a built-in provider has no configured key or base URL on this path, and emitting an empty `apiKey` would break how the client resolves them today. A user-supplied base URL in `summarization.parameters` suppresses the declaration entirely, so a gateway is never claimed as first-party. Fixes #15598 * ๐Ÿ›ก๏ธ fix: Scope Built-In Summarization Shaping to the Endpoint It Reaches Three ways the first pass claimed a contract it could not verify: - `OPENAI_REVERSE_PROXY` was omitted from the shaping call, so a gateway-backed built-in endpoint was treated as canonical. `initializeOpenAI` passes it as `reverseProxyUrl` for exactly this reason; that mapping is now a shared `getBuiltInBaseURL` rather than a second copy. A user-provided base URL withholds the declaration outright, since resolving it needs a database read this path avoids. - A custom-endpoint agent is normalized to the `openAI` provider while keeping its own endpoint name, so an omitted `summarization.provider` resolved to `openAI` and injected built-in shaping into parameters the SDK layers over the reused gateway client. Mirroring the SDK's own reuse condition skips it. - `getOpenAIConfig` reads `reasoning_effort` from `modelOptions`, which the merged parameters reach too late, so a GPT-5.6 summarizer never got the Responses routing the agent flow gives it. * ๐Ÿงน refactor: Destructure the Typed llmConfig Directly --- .../__tests__/run-summarization.test.ts | 146 ++++++++++++++++++ packages/api/src/agents/run.ts | 120 +++++++++++++- .../api/src/endpoints/openai/initialize.ts | 33 ++-- 3 files changed, 285 insertions(+), 14 deletions(-) diff --git a/packages/api/src/agents/__tests__/run-summarization.test.ts b/packages/api/src/agents/__tests__/run-summarization.test.ts index b29e85f476..bfb9ab13cd 100644 --- a/packages/api/src/agents/__tests__/run-summarization.test.ts +++ b/packages/api/src/agents/__tests__/run-summarization.test.ts @@ -1489,6 +1489,152 @@ describe('custom-endpoint provider resolution', () => { }); }); +// --------------------------------------------------------------------------- +// Suite: built-in provider request shaping (#15598) +// --------------------------------------------------------------------------- +/** + * A built-in provider produces no custom-endpoint config, so `getOpenAIConfig` + * was skipped entirely and the summarizer learned neither which API its model + * takes nor whether its endpoint is first-party. The agents SDK defaults its + * model-specific constraints off without that declaration, so configured + * parameters reached the model unshaped. + * + * These assert the declaration LibreChat emits, which is the half it owns; the + * SDK's honoring of it is covered by its own tests. + */ +describe('built-in provider request shaping', () => { + const anthropicAgent = () => + makeAgent({ + provider: 'anthropic', + endpoint: 'anthropic', + model: 'claude-sonnet-4.6', + model_parameters: { model: 'claude-sonnet-4.6' }, + }); + + const summarizeWith = async ( + parameters?: Record, + model = 'gpt-6-astra', + ): Promise> => { + const agents = await callAndCapture({ + agents: [anthropicAgent()], + appConfig: makeAppConfig([]), + summarizationConfig: { + provider: 'openAI', + model, + parameters: parameters as SummarizationConfig['parameters'], + }, + }); + const config = agents[0].summarizationConfig as Record; + return config.parameters as Record; + }; + + it('declares the first-party endpoint for a cross-provider built-in summarizer', async () => { + expect(await summarizeWith(undefined, 'gpt-4o')).toMatchObject({ firstPartyEndpoint: true }); + }); + + it('routes a Responses-only model to the Responses API', async () => { + expect(await summarizeWith()).toMatchObject({ + firstPartyEndpoint: true, + useResponsesApi: true, + }); + }); + + it('keeps the declaration alongside a translated reasoning effort', async () => { + /** + * The reachable failure from #15598: `resolveReasoningParams` translates the + * scalar effort for the summarizer, and without the declaration an effort + * the model rejects reached it verbatim. + */ + expect(await summarizeWith({ reasoning_effort: 'minimal' })).toMatchObject({ + firstPartyEndpoint: true, + reasoning: { effort: 'minimal' }, + }); + }); + + it('does not claim a first-party endpoint behind a user configuration.baseURL', async () => { + expect( + await summarizeWith({ configuration: { baseURL: 'https://gateway.internal/v1' } }), + ).toEqual({ configuration: { baseURL: 'https://gateway.internal/v1' } }); + }); + + it('does not claim a first-party endpoint behind a user baseURL', async () => { + expect(await summarizeWith({ baseURL: 'https://gateway.internal/v1' })).toEqual({ + baseURL: 'https://gateway.internal/v1', + }); + }); + + it('leaves credentials and transport to the client', async () => { + const parameters = await summarizeWith(); + expect(parameters.apiKey).toBeUndefined(); + expect(parameters.model).toBeUndefined(); + expect(parameters.modelName).toBeUndefined(); + expect(parameters.streaming).toBeUndefined(); + expect(parameters.configuration).toBeUndefined(); + }); + + it('leaves a same-endpoint summarizer on the agent client options', async () => { + const agents = await callAndCapture({ + appConfig: makeAppConfig([]), + summarizationConfig: { provider: 'openAI', model: 'gpt-6-astra' }, + }); + const config = agents[0].summarizationConfig as Record; + expect(config.parameters).toBeUndefined(); + }); + + it('routes a reasoning model the way the agent flow would', async () => { + /** `getOpenAIConfig` reads the effort from modelOptions, not from the merged + * parameters, so it has to be handed the summarizer's own effort. */ + expect(await summarizeWith({ reasoning_effort: 'medium' }, 'gpt-5.6')).toMatchObject({ + firstPartyEndpoint: true, + useResponsesApi: true, + reasoning: { effort: 'medium' }, + }); + }); + + it('withholds the declaration when a reverse proxy serves the built-in endpoint', async () => { + process.env.OPENAI_REVERSE_PROXY = 'https://gateway.internal/v1'; + try { + expect(await summarizeWith()).toBeUndefined(); + } finally { + delete process.env.OPENAI_REVERSE_PROXY; + } + }); + + it('withholds the declaration when the base URL is user-provided', async () => { + process.env.OPENAI_REVERSE_PROXY = 'user_provided'; + try { + expect(await summarizeWith()).toBeUndefined(); + } finally { + delete process.env.OPENAI_REVERSE_PROXY; + } + }); + + it('declares nothing for an agent whose custom endpoint normalized to openAI', async () => { + /** + * `initializeAgent` rewrites a custom-endpoint agent's provider to `openAI` + * while its endpoint keeps the custom name. With summarization omitted, the + * summarizer reuses that agent's client โ€” which points at the gateway, not + * at OpenAI. + */ + const agents = await callAndCapture({ + agents: [ + makeAgent({ + provider: 'openAI', + endpoint: 'MyGateway', + model: 'gpt-6-astra', + model_parameters: { model: 'gpt-6-astra' }, + }), + ], + appConfig: makeAppConfig([ + { name: 'MyGateway', baseURL: 'https://gateway.internal/v1', apiKey: 'gw-key' }, + ]), + summarizationConfig: { model: 'gpt-6-astra' }, + }); + const config = agents[0].summarizationConfig as Record; + expect(config.parameters).toBeUndefined(); + }); +}); + // --------------------------------------------------------------------------- // Suite 8: subagentConfigs // --------------------------------------------------------------------------- diff --git a/packages/api/src/agents/run.ts b/packages/api/src/agents/run.ts index 95a5278983..77cff7a4e7 100644 --- a/packages/api/src/agents/run.ts +++ b/packages/api/src/agents/run.ts @@ -5,6 +5,7 @@ import { Run, Providers, Constants, HookRegistry } from '@librechat/agents'; import { KnownEndpoints, EModelEndpoint, + ReasoningEffort, MAX_SUBAGENT_DEPTH, MAX_SUBAGENT_RUN_CONFIGS, extractEnvVariable, @@ -98,6 +99,7 @@ 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 { getBuiltInBaseURL } from '~/endpoints/openai/initialize'; import { getProviderConfig } from '~/endpoints/config/providers'; import { buildToolApprovalHooks } from '~/agents/hitl/hooks'; import { getAgentCheckpointer } from '~/agents/checkpointer'; @@ -588,6 +590,104 @@ interface SummarizationClientOverrides { [key: string]: unknown; } +/** + * A user-supplied base URL in `summarization.parameters` points the summarizer + * at a gateway whose contract is not the built-in provider's, so no built-in + * request shaping may be claimed for it. + */ +function hasBaseURLOverride(parameters: SummarizationConfig['parameters']): boolean { + if (!isPlainObject(parameters)) { + return false; + } + const params = parameters as Record; + if (isNonEmptyString(params.baseURL)) { + return true; + } + return isPlainObject(params.configuration) && 'baseURL' in params.configuration; +} + +/** + * The scalar `reasoning_effort` the yaml schema accepts, when it names a known + * effort. `getOpenAIConfig` reads the effort from `modelOptions` โ€” the merged + * `parameters` reach it too late โ€” so a summarizer that configures one has to + * hand it over for the same API routing the agent flow performs. + */ +function summarizationReasoningEffort( + parameters: SummarizationConfig['parameters'], +): ReasoningEffort | undefined { + if (!isPlainObject(parameters)) { + return undefined; + } + const effort = (parameters as Record).reasoning_effort; + const known = Object.values(ReasoningEffort) as string[]; + return typeof effort === 'string' && known.includes(effort) + ? (effort as ReasoningEffort) + : undefined; +} + +/** + * Builds the model-specific request shaping a built-in provider's client needs, + * for the cross-provider case where the SDK builds that client from these + * parameters alone. + * + * The custom-endpoint path below already runs `getOpenAIConfig`; built-in + * providers skipped it entirely, so a summarizer never learned which API its + * model takes or whether its endpoint is first-party โ€” and the agents SDK + * defaults its model-specific constraints off without that declaration + * (LibreChat#15598). + * + * Credentials and transport are deliberately not returned. A built-in provider + * has no configured key here, so the client resolves one the way it does today; + * emitting an empty `apiKey` would break that. The admin-configured base URL is + * still passed *in*, because whether the endpoint is first-party is exactly what + * `OPENAI_REVERSE_PROXY` decides. + */ +function resolveBuiltInClientOverrides( + provider: string, + target: { + model?: string; + parameters?: SummarizationConfig['parameters']; + agentProvider?: string; + }, +): SummarizationClientOverrides | undefined { + const { model, parameters } = target; + if (!isNonEmptyString(model) || hasBaseURLOverride(parameters)) { + return undefined; + } + /** + * Mirrors the SDK's own condition: when the summarization provider matches the + * agent's, `buildSummarizationClientConfig` spreads the agent's resolved client + * options and these parameters layer on top. A custom-endpoint agent is + * normalized to the `openAI` provider while keeping its own endpoint name, so + * declaring built-in constraints here would claim OpenAI's contract for that + * gateway. + */ + if (provider === target.agentProvider) { + return undefined; + } + const baseURL = getBuiltInBaseURL(provider); + /** Resolving a user-provided base URL needs a database read this path avoids. */ + if (isUserProvided(baseURL)) { + return undefined; + } + const { llmConfig } = getOpenAIConfig( + '', + { + modelOptions: { model, reasoning_effort: summarizationReasoningEffort(parameters) }, + reverseProxyUrl: baseURL, + }, + provider, + ); + const { + apiKey: _apiKey, + model: _model, + modelName: _modelName, + streaming: _streaming, + ...shaping + } = llmConfig; + return Object.keys(shaping).length > 0 ? shaping : undefined; +} + /** * Resolves a summarization provider string (which may be a custom-endpoint name * like "Ollama") into the SDK-recognized provider and any client-option @@ -601,6 +701,11 @@ function resolveSummarizationProvider( rawProvider: string, appConfig: AppConfig | undefined, headerContext: { user?: IUser; tenantId?: string; requestBody?: t.RequestBody }, + target: { + model?: string; + parameters?: SummarizationConfig['parameters']; + agentProvider?: string; + } = {}, ): { provider: string; clientOverrides?: SummarizationClientOverrides; @@ -614,7 +719,10 @@ function resolveSummarizationProvider( appConfig, }); if (!customEndpointConfig) { - return { provider: overrideProvider }; + return { + provider: overrideProvider, + clientOverrides: resolveBuiltInClientOverrides(overrideProvider, target), + }; } const rawApiKey = customEndpointConfig.apiKey ?? ''; const rawBaseURL = customEndpointConfig.baseURL ?? ''; @@ -775,11 +883,15 @@ function shapeSummarizationConfig( isNonEmptyString(rawProvider) && normalizeEndpointName(rawProvider) === normalizeEndpointName(agentEndpoint); + const model = config?.model ?? fallbackModel; + const { provider, clientOverrides } = isSameEndpointAsAgent ? { provider: fallbackProvider, clientOverrides: undefined } - : resolveSummarizationProvider(rawProvider, appConfig, headerContext); - - const model = config?.model ?? fallbackModel; + : resolveSummarizationProvider(rawProvider, appConfig, headerContext, { + model, + parameters: config?.parameters, + agentProvider: fallbackProvider, + }); const trigger = config?.trigger?.type && typeof config?.trigger?.value === 'number' ? { type: config.trigger.type, value: config.trigger.value } diff --git a/packages/api/src/endpoints/openai/initialize.ts b/packages/api/src/endpoints/openai/initialize.ts index e662b20810..b86cf43f35 100644 --- a/packages/api/src/endpoints/openai/initialize.ts +++ b/packages/api/src/endpoints/openai/initialize.ts @@ -16,6 +16,25 @@ import { resolveEndpointRuntime } from '~/types'; import { validateEndpointURL } from '~/auth'; import { getOpenAIConfig } from './config'; +/** + * Admin-configured base URL for a built-in OpenAI-family endpoint, or `undefined` + * when the endpoint is served by OpenAI/Azure directly. + * + * Read at call time rather than module load so environment changes apply, and + * shared with callers that must know the endpoint a built-in provider actually + * talks to before claiming its contract. May be the `user_provided` sentinel, + * which resolves to a per-user value only after a database read. + */ +export function getBuiltInBaseURL(endpoint?: string | null): string | undefined { + if (endpoint === EModelEndpoint.openAI) { + return process.env.OPENAI_REVERSE_PROXY; + } + if (endpoint === EModelEndpoint.azureOpenAI) { + return process.env.AZURE_OPENAI_BASEURL; + } + return undefined; +} + /** * Initializes OpenAI options for agent usage. This function always returns configuration * options and never creates a client instance (equivalent to optionsOnly=true behavior). @@ -31,8 +50,7 @@ export async function initializeOpenAI( const { appConfig, user, requestBody } = resolveEndpointRuntime(params); 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; + const { PROXY, OPENAI_API_KEY, AZURE_API_KEY } = process.env; const { key: expiresAt } = requestBody; const modelName = model_parameters?.model as string | undefined; @@ -42,13 +60,10 @@ export async function initializeOpenAI( [EModelEndpoint.azureOpenAI]: AZURE_API_KEY, }; - const baseURLOptions = { - [EModelEndpoint.openAI]: OPENAI_REVERSE_PROXY, - [EModelEndpoint.azureOpenAI]: AZURE_OPENAI_BASEURL, - }; + const configuredBaseURL = getBuiltInBaseURL(endpoint); const userProvidesKey = isUserProvided(credentials[endpoint as keyof typeof credentials]); - const userProvidesURL = isUserProvided(baseURLOptions[endpoint as keyof typeof baseURLOptions]); + const userProvidesURL = isUserProvided(configuredBaseURL); let userValues: UserKeyValues | null = null; if (expiresAt && (userProvidesKey || userProvidesURL)) { @@ -59,9 +74,7 @@ export async function initializeOpenAI( let apiKey = userProvidesKey ? userValues?.apiKey : credentials[endpoint as keyof typeof credentials]; - const baseURL = userProvidesURL - ? userValues?.baseURL - : baseURLOptions[endpoint as keyof typeof baseURLOptions]; + const baseURL = userProvidesURL ? userValues?.baseURL : configuredBaseURL; const clientOptions: OpenAIConfigOptions = { proxy: PROXY ?? undefined,