mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-27 12:13:30 +00:00
🧠 fix: Apply the Configured Reasoning Effort to Summarization (#15232)
A summarizer that reuses the main agent's client options silently inherits the agent's reasoning effort: `summarization.parameters.reasoning_effort` is a scalar, and every OpenAI-compatible LangChain client reads only `reasoning` from constructor fields — `reasoning_effort` is a call-time option, so it is dropped outright. A hidden summary configured for `low` runs at whatever the agent resolved, and the yaml schema (scalars only) gives users no way to express the nested shape themselves. Translate the scalar the way the main flow's `getOpenAIConfig` does, into the top-level `reasoning` object the client honors. Top-level rather than nested, because the SDK spreads `parameters` onto the agent's client options: a `modelKwargs` fragment would replace the agent's `modelKwargs` wholesale, while `reasoning` merges over an inherited `modelKwargs.reasoning` in `ChatOpenRouter` and is re-emitted as `reasoning_effort` by Chat Completions. OpenRouter's adaptive Anthropic models keep the main flow's mapping, where effort is expressed as `verbosity` rather than `reasoning.effort`. Also prefer the provider `getOpenAIConfig` detects over the one `getProviderConfig` reports, so a cross-endpoint summarizer pointed at an OpenRouter endpoint whose config name isn't `openrouter` builds the same client the main agent flow builds for it. Supersedes #15088; thanks to @flamerged for the diagnosis and the OpenRouter repro.
This commit is contained in:
parent
227a99ede8
commit
cdd4c09076
3 changed files with 314 additions and 5 deletions
|
|
@ -86,7 +86,9 @@ jest.mock('~/agents/checkpointer', () => ({
|
|||
getAgentCheckpointer: jest.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
||||
import { Run, buildChildInputs, InMemorySubagentTaskStore } from '@librechat/agents';
|
||||
import { ChatOpenAI } from '@librechat/agents/llm/openai';
|
||||
import { ChatOpenRouter } from '@librechat/agents/llm/openrouter';
|
||||
import { Run, Providers, buildChildInputs, InMemorySubagentTaskStore } from '@librechat/agents';
|
||||
|
||||
/** Minimal RunAgent factory */
|
||||
function makeAgent(
|
||||
|
|
@ -618,6 +620,223 @@ describe('summarizationConfig field passthrough', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Suite: reasoning effort translation
|
||||
// ---------------------------------------------------------------------------
|
||||
const OPENROUTER_MODEL = 'openai/gpt-5.6';
|
||||
const ADAPTIVE_CLAUDE_MODEL = 'anthropic/claude-sonnet-4.6';
|
||||
|
||||
/** Agent whose resolved client options already carry a reasoning configuration. */
|
||||
function makeReasoningAgent(overrides: {
|
||||
provider: string;
|
||||
endpoint: string;
|
||||
model: string;
|
||||
model_parameters: Record<string, unknown>;
|
||||
}) {
|
||||
return makeAgent({
|
||||
provider: overrides.provider as never,
|
||||
endpoint: overrides.endpoint,
|
||||
model: overrides.model,
|
||||
model_parameters: overrides.model_parameters as never,
|
||||
});
|
||||
}
|
||||
|
||||
describe('summarization reasoning effort', () => {
|
||||
it.each(['medium', 'low'])(
|
||||
'overrides an inherited OpenRouter reasoning object with %s, leaving the agent untouched',
|
||||
async (reasoningEffort) => {
|
||||
const agents = await callAndCapture({
|
||||
agents: [
|
||||
makeReasoningAgent({
|
||||
provider: Providers.OPENROUTER,
|
||||
endpoint: 'OpenRouter',
|
||||
model: OPENROUTER_MODEL,
|
||||
model_parameters: {
|
||||
model: OPENROUTER_MODEL,
|
||||
modelKwargs: { reasoning: { effort: 'max' } },
|
||||
},
|
||||
}),
|
||||
],
|
||||
summarizationConfig: {
|
||||
provider: 'OpenRouter',
|
||||
model: OPENROUTER_MODEL,
|
||||
parameters: { reasoning_effort: reasoningEffort },
|
||||
},
|
||||
});
|
||||
|
||||
const mainClientOptions = agents[0].clientOptions as Record<string, unknown>;
|
||||
const summaryConfig = agents[0].summarizationConfig as Record<string, unknown>;
|
||||
|
||||
expect(mainClientOptions.modelKwargs).toEqual({ reasoning: { effort: 'max' } });
|
||||
expect(summaryConfig.parameters).toEqual({ reasoning: { effort: reasoningEffort } });
|
||||
|
||||
/** The SDK spreads `parameters` onto the agent's own client options. */
|
||||
const summaryModel = new ChatOpenRouter({
|
||||
...mainClientOptions,
|
||||
...(summaryConfig.parameters as Record<string, unknown>),
|
||||
apiKey: 'test-key',
|
||||
model: summaryConfig.model as string,
|
||||
});
|
||||
const request = summaryModel.invocationParams();
|
||||
|
||||
expect(request.reasoning).toEqual({ effort: reasoningEffort });
|
||||
expect(request.reasoning_effort).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
it('overrides an inherited OpenAI reasoning object', async () => {
|
||||
const agents = await callAndCapture({
|
||||
agents: [
|
||||
makeReasoningAgent({
|
||||
provider: EModelEndpoint.openAI,
|
||||
endpoint: EModelEndpoint.openAI,
|
||||
model: 'gpt-5.6',
|
||||
model_parameters: { model: 'gpt-5.6', reasoning: { effort: 'high' } },
|
||||
}),
|
||||
],
|
||||
summarizationConfig: {
|
||||
provider: EModelEndpoint.openAI,
|
||||
model: 'gpt-5.6',
|
||||
parameters: { reasoning_effort: 'low' },
|
||||
},
|
||||
});
|
||||
|
||||
const mainClientOptions = agents[0].clientOptions as Record<string, unknown>;
|
||||
const summaryConfig = agents[0].summarizationConfig as Record<string, unknown>;
|
||||
|
||||
expect(mainClientOptions.reasoning).toEqual({ effort: 'high' });
|
||||
expect(summaryConfig.parameters).toEqual({ reasoning: { effort: 'low' } });
|
||||
|
||||
const summaryModel = new ChatOpenAI({
|
||||
...mainClientOptions,
|
||||
...(summaryConfig.parameters as Record<string, unknown>),
|
||||
apiKey: 'test-key',
|
||||
model: summaryConfig.model as string,
|
||||
} as never);
|
||||
const request = summaryModel.invocationParams() as Record<string, unknown>;
|
||||
|
||||
/** Chat Completions re-emits the object as the scalar the API expects. */
|
||||
expect(request.reasoning_effort).toBe('low');
|
||||
});
|
||||
|
||||
it('maps effort to verbosity for OpenRouter adaptive Anthropic models', async () => {
|
||||
const agents = await callAndCapture({
|
||||
agents: [
|
||||
makeReasoningAgent({
|
||||
provider: Providers.OPENROUTER,
|
||||
endpoint: 'OpenRouter',
|
||||
model: ADAPTIVE_CLAUDE_MODEL,
|
||||
model_parameters: {
|
||||
model: ADAPTIVE_CLAUDE_MODEL,
|
||||
verbosity: 'max',
|
||||
modelKwargs: { reasoning: { enabled: true } },
|
||||
},
|
||||
}),
|
||||
],
|
||||
summarizationConfig: {
|
||||
provider: 'OpenRouter',
|
||||
model: ADAPTIVE_CLAUDE_MODEL,
|
||||
parameters: { reasoning_effort: 'low' },
|
||||
},
|
||||
});
|
||||
|
||||
const summaryConfig = agents[0].summarizationConfig as Record<string, unknown>;
|
||||
expect(summaryConfig.parameters).toEqual({
|
||||
verbosity: 'low',
|
||||
reasoning: { enabled: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('turns adaptive thinking off for reasoning_effort "none"', async () => {
|
||||
const agents = await callAndCapture({
|
||||
agents: [
|
||||
makeReasoningAgent({
|
||||
provider: Providers.OPENROUTER,
|
||||
endpoint: 'OpenRouter',
|
||||
model: ADAPTIVE_CLAUDE_MODEL,
|
||||
model_parameters: {
|
||||
model: ADAPTIVE_CLAUDE_MODEL,
|
||||
modelKwargs: { reasoning: { enabled: true } },
|
||||
},
|
||||
}),
|
||||
],
|
||||
summarizationConfig: {
|
||||
provider: 'OpenRouter',
|
||||
model: ADAPTIVE_CLAUDE_MODEL,
|
||||
parameters: { reasoning_effort: 'none' },
|
||||
},
|
||||
});
|
||||
|
||||
const summaryConfig = agents[0].summarizationConfig as Record<string, unknown>;
|
||||
expect(summaryConfig.parameters).toEqual({ reasoning: { enabled: false } });
|
||||
|
||||
const summaryModel = new ChatOpenRouter({
|
||||
...(agents[0].clientOptions as Record<string, unknown>),
|
||||
...(summaryConfig.parameters as Record<string, unknown>),
|
||||
apiKey: 'test-key',
|
||||
model: ADAPTIVE_CLAUDE_MODEL,
|
||||
});
|
||||
expect(summaryModel.invocationParams().reasoning).toEqual({ enabled: false });
|
||||
});
|
||||
|
||||
it('translates for a custom endpoint that resolves to OpenRouter by baseURL', async () => {
|
||||
const appConfig = makeAppConfig([
|
||||
{ name: 'Router', baseURL: 'https://openrouter.ai/api/v1', apiKey: 'router-key' },
|
||||
]);
|
||||
const agents = await callAndCapture({
|
||||
summarizationConfig: {
|
||||
provider: 'Router',
|
||||
model: OPENROUTER_MODEL,
|
||||
parameters: { reasoning_effort: 'low' },
|
||||
},
|
||||
appConfig,
|
||||
});
|
||||
|
||||
const summaryConfig = agents[0].summarizationConfig as Record<string, unknown>;
|
||||
expect(summaryConfig.provider).toBe(Providers.OPENROUTER);
|
||||
expect(summaryConfig.parameters).toMatchObject({ reasoning: { effort: 'low' } });
|
||||
expect(summaryConfig.parameters).not.toHaveProperty('reasoning_effort');
|
||||
});
|
||||
|
||||
it('leaves parameters untouched for providers with no reasoning_effort concept', async () => {
|
||||
const agents = await callAndCapture({
|
||||
summarizationConfig: {
|
||||
provider: EModelEndpoint.anthropic,
|
||||
model: 'claude-3-haiku',
|
||||
parameters: { reasoning_effort: 'low' },
|
||||
},
|
||||
});
|
||||
|
||||
const summaryConfig = agents[0].summarizationConfig as Record<string, unknown>;
|
||||
expect(summaryConfig.parameters).toEqual({ reasoning_effort: 'low' });
|
||||
});
|
||||
|
||||
it('leaves unrelated parameters and an unset effort untouched', async () => {
|
||||
const agents = await callAndCapture({
|
||||
agents: [
|
||||
makeReasoningAgent({
|
||||
provider: Providers.OPENROUTER,
|
||||
endpoint: 'OpenRouter',
|
||||
model: OPENROUTER_MODEL,
|
||||
model_parameters: { model: OPENROUTER_MODEL },
|
||||
}),
|
||||
],
|
||||
summarizationConfig: {
|
||||
provider: 'OpenRouter',
|
||||
model: OPENROUTER_MODEL,
|
||||
parameters: { temperature: 0.2, streaming: false, reasoning_effort: '' },
|
||||
},
|
||||
});
|
||||
|
||||
const summaryConfig = agents[0].summarizationConfig as Record<string, unknown>;
|
||||
expect(summaryConfig.parameters).toEqual({
|
||||
temperature: 0.2,
|
||||
streaming: false,
|
||||
reasoning_effort: '',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Suite 5: Multi-agent + per-agent overrides
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -72,12 +72,12 @@ import {
|
|||
import { applyCustomHandoffPromptKeyCompatibility } from '~/agents/handoffPromptKeyCompatibility';
|
||||
import { stripIntentFromToolRegistry, stripIntentFromToolDefinitions } from '~/agents/intent';
|
||||
import { isSteeringSupported, isSteerPreemptSupported } from '~/agents/steering/runtime';
|
||||
import { extractDefaultParams, resolveReasoningParams } from '~/endpoints/openai/llm';
|
||||
import { getLLMConfig as getAnthropicLLMConfig } from '~/endpoints/anthropic/llm';
|
||||
import { resolveStreamLimits, resolveSubagentMaxTurns } from '~/agents/config';
|
||||
import { CREATE_FILE_TOOL_NAME, EDIT_FILE_TOOL_NAME } from '~/agents/tools';
|
||||
import { buildAgentInitialToolSessions } from '~/agents/codeFilesSession';
|
||||
import { getProviderConfig } from '~/endpoints/config/providers';
|
||||
import { extractDefaultParams } from '~/endpoints/openai/llm';
|
||||
import { resolveHeaders, createSafeUser } from '~/utils/env';
|
||||
import { getAgentCheckpointer } from '~/agents/checkpointer';
|
||||
import { getPluginHookSource } from '~/agents/hooks/source';
|
||||
|
|
@ -666,7 +666,11 @@ function resolveSummarizationProvider(
|
|||
* that the main agent relied on. `proxy` is forwarded so outbound proxy
|
||||
* dispatchers (`PROXY` env var) apply to cross-endpoint summarization.
|
||||
*/
|
||||
const { llmConfig, configOptions } = getOpenAIConfig(
|
||||
const {
|
||||
llmConfig,
|
||||
configOptions,
|
||||
provider: detectedProvider,
|
||||
} = getOpenAIConfig(
|
||||
apiKey,
|
||||
{
|
||||
reverseProxyUrl: baseURL,
|
||||
|
|
@ -694,8 +698,16 @@ function resolveSummarizationProvider(
|
|||
*/
|
||||
delete clientOverrides.model;
|
||||
delete clientOverrides.modelName;
|
||||
/**
|
||||
* `getOpenAIConfig` detects OpenRouter from the resolved `baseURL`, which
|
||||
* `getProviderConfig` cannot do for an endpoint whose config name isn't
|
||||
* `openrouter` — it reports `openAI` for those. Prefer the detected
|
||||
* provider so a cross-endpoint summarizer builds the same client the main
|
||||
* agent flow builds for that endpoint (`initializeAgent` applies the same
|
||||
* precedence).
|
||||
*/
|
||||
return {
|
||||
provider: overrideProvider,
|
||||
provider: detectedProvider ?? overrideProvider,
|
||||
clientOverrides,
|
||||
};
|
||||
} catch (error) {
|
||||
|
|
@ -753,10 +765,17 @@ function shapeSummarizationConfig(
|
|||
* adding e.g. `configuration.defaultQuery` keeps the resolved `baseURL`
|
||||
* and `defaultHeaders` rather than replacing the whole object.
|
||||
*/
|
||||
const parameters =
|
||||
const mergedParameters =
|
||||
clientOverrides != null
|
||||
? mergeParameters(clientOverrides, config?.parameters)
|
||||
: config?.parameters;
|
||||
/**
|
||||
* A scalar `reasoning_effort` — the only reasoning shape the yaml schema
|
||||
* accepts — is inert as a client option and leaves the summarizer running at
|
||||
* whatever effort the main agent resolved. Translate it the way the main
|
||||
* flow's `getOpenAIConfig` would for the summarization target.
|
||||
*/
|
||||
const parameters = resolveReasoningParams({ provider, model, parameters: mergedParameters });
|
||||
|
||||
return {
|
||||
enabled: config?.enabled !== false && isNonEmptyString(provider) && isNonEmptyString(model),
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { Providers, isOpenAILike } from '@librechat/agents';
|
||||
import {
|
||||
EModelEndpoint,
|
||||
ReasoningEffort,
|
||||
|
|
@ -349,6 +350,76 @@ function applyOpenRouterReasoningConfig({
|
|||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Translates a scalar `reasoning_effort` parameter into the reasoning fields an
|
||||
* already-resolved OpenAI-compatible client honors, for callers that layer
|
||||
* their own parameters on top of a client configuration built elsewhere
|
||||
* (summarization reusing the agent's client options).
|
||||
*
|
||||
* The override has to land in *top-level* fields. A nested `modelKwargs`
|
||||
* fragment would replace the inherited `modelKwargs` wholesale, and a scalar
|
||||
* `reasoning_effort` is dropped outright: LangChain reads only `reasoning` from
|
||||
* constructor fields — `reasoning_effort` is a call-time option. `reasoning` is
|
||||
* the one shape every OpenAI-compatible client honors, since Chat Completions
|
||||
* re-emits it as `reasoning_effort`, the Responses API sends it as-is, and
|
||||
* `ChatOpenRouter` merges it over an inherited `modelKwargs.reasoning`.
|
||||
*
|
||||
* Mirrors {@link applyOpenRouterReasoningConfig} for OpenRouter's adaptive
|
||||
* Anthropic models, where effort is expressed as `verbosity` rather than
|
||||
* `reasoning.effort`. Non-OpenAI-compatible providers are left untouched:
|
||||
* they have no `reasoning_effort` concept to translate into.
|
||||
*/
|
||||
export function resolveReasoningParams({
|
||||
provider,
|
||||
model,
|
||||
parameters,
|
||||
}: {
|
||||
provider?: string | null;
|
||||
model?: string | null;
|
||||
parameters?: Record<string, unknown>;
|
||||
}): Record<string, unknown> | undefined {
|
||||
if (parameters == null || provider == null) {
|
||||
return parameters;
|
||||
}
|
||||
|
||||
const reasoningEffort = parameters.reasoning_effort;
|
||||
if (typeof reasoningEffort !== 'string' || reasoningEffort === ReasoningEffort.unset) {
|
||||
return parameters;
|
||||
}
|
||||
|
||||
const isOpenRouter = provider.toLowerCase() === Providers.OPENROUTER;
|
||||
if (!isOpenRouter && !isOpenAILike(provider as Providers)) {
|
||||
return parameters;
|
||||
}
|
||||
|
||||
const resolved = { ...parameters };
|
||||
delete resolved.reasoning_effort;
|
||||
|
||||
if (isOpenRouter && isOpenRouterAnthropicAdaptiveModel(model)) {
|
||||
/** Adaptive thinking is disabled through the object itself: the inherited
|
||||
* `modelKwargs.reasoning` would otherwise keep it enabled, which the main
|
||||
* flow's `include_reasoning: false` cannot undo. */
|
||||
if (reasoningEffort === ReasoningEffort.none) {
|
||||
resolved.reasoning = { enabled: false };
|
||||
return resolved;
|
||||
}
|
||||
const adaptiveVerbosity = getOpenRouterAnthropicVerbosity(reasoningEffort, model);
|
||||
if (adaptiveVerbosity != null && resolved.verbosity == null) {
|
||||
resolved.verbosity = adaptiveVerbosity;
|
||||
}
|
||||
resolved.reasoning = { enabled: true };
|
||||
return resolved;
|
||||
}
|
||||
|
||||
const inherited = resolved.reasoning;
|
||||
const base =
|
||||
inherited != null && typeof inherited === 'object' && !Array.isArray(inherited)
|
||||
? (inherited as Record<string, unknown>)
|
||||
: undefined;
|
||||
resolved.reasoning = { ...base, effort: reasoningEffort };
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function applyReasoningConfig({
|
||||
endpoint,
|
||||
llmConfig,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue