From cdd4c09076aa43ae9d94bba8f43c55ee35c181d5 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Tue, 25 Aug 2026 23:54:36 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=A7=A0=20fix:=20Apply=20the=20Configured?= =?UTF-8?q?=20Reasoning=20Effort=20to=20Summarization=20(#15232)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../__tests__/run-summarization.test.ts | 221 +++++++++++++++++- packages/api/src/agents/run.ts | 27 ++- packages/api/src/endpoints/openai/llm.ts | 71 ++++++ 3 files changed, 314 insertions(+), 5 deletions(-) diff --git a/packages/api/src/agents/__tests__/run-summarization.test.ts b/packages/api/src/agents/__tests__/run-summarization.test.ts index 595688c55e..43d7c810ab 100644 --- a/packages/api/src/agents/__tests__/run-summarization.test.ts +++ b/packages/api/src/agents/__tests__/run-summarization.test.ts @@ -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; +}) { + 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; + const summaryConfig = agents[0].summarizationConfig as Record; + + 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), + 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; + const summaryConfig = agents[0].summarizationConfig as Record; + + expect(mainClientOptions.reasoning).toEqual({ effort: 'high' }); + expect(summaryConfig.parameters).toEqual({ reasoning: { effort: 'low' } }); + + const summaryModel = new ChatOpenAI({ + ...mainClientOptions, + ...(summaryConfig.parameters as Record), + apiKey: 'test-key', + model: summaryConfig.model as string, + } as never); + const request = summaryModel.invocationParams() as Record; + + /** 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; + 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; + expect(summaryConfig.parameters).toEqual({ reasoning: { enabled: false } }); + + const summaryModel = new ChatOpenRouter({ + ...(agents[0].clientOptions as Record), + ...(summaryConfig.parameters as Record), + 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; + 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; + 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; + expect(summaryConfig.parameters).toEqual({ + temperature: 0.2, + streaming: false, + reasoning_effort: '', + }); + }); +}); + // --------------------------------------------------------------------------- // Suite 5: Multi-agent + per-agent overrides // --------------------------------------------------------------------------- diff --git a/packages/api/src/agents/run.ts b/packages/api/src/agents/run.ts index 029807ebba..e85100d28f 100644 --- a/packages/api/src/agents/run.ts +++ b/packages/api/src/agents/run.ts @@ -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), diff --git a/packages/api/src/endpoints/openai/llm.ts b/packages/api/src/endpoints/openai/llm.ts index 733ba91cc5..282aca2a17 100644 --- a/packages/api/src/endpoints/openai/llm.ts +++ b/packages/api/src/endpoints/openai/llm.ts @@ -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; +}): Record | 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) + : undefined; + resolved.reasoning = { ...base, effort: reasoningEffort }; + return resolved; +} + function applyReasoningConfig({ endpoint, llmConfig,