🥣 fix: Apply Agent Shaping to Built-In Summarizers (#15599)

* 🧩 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
This commit is contained in:
Danny Avila 2026-09-04 10:42:38 -04:00 • committed by GitHub
parent ee0a51ac19
commit 22a83bd4df
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 285 additions and 14 deletions

View file

@ -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<string, unknown>,
model = 'gpt-6-astra',
): Promise<Record<string, unknown>> => {
const agents = await callAndCapture({
agents: [anthropicAgent()],
appConfig: makeAppConfig([]),
summarizationConfig: {
provider: 'openAI',
model,
parameters: parameters as SummarizationConfig['parameters'],
},
});
const config = agents[0].summarizationConfig as Record<string, unknown>;
return config.parameters as Record<string, unknown>;
};
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<string, unknown>;
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<string, unknown>;
expect(config.parameters).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// Suite 8: subagentConfigs
// ---------------------------------------------------------------------------

View file

@ -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<string, unknown>;
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<string, unknown>).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 }

View file

@ -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,