mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-22 10:02:41 +00:00
🥣 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:
parent
ee0a51ac19
commit
22a83bd4df
3 changed files with 285 additions and 14 deletions
|
|
@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue