🧠 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:
Danny Avila 2026-08-25 23:54:36 -04:00 committed by GitHub
parent 227a99ede8
commit cdd4c09076
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 314 additions and 5 deletions

View file

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

View file

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

View file

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