From d9a76fca906823a13c2e53b436b7913b4c384a81 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Tue, 23 Jun 2026 21:08:47 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=A7=A0=20feat:=20Configurable=20Reasoning?= =?UTF-8?q?=20Replay=20for=20Custom=20Endpoints=20(#13921)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🧠 feat: Configurable Reasoning Replay for Custom Endpoints Adds customParams.includeReasoningContent so OpenAI-compatible custom endpoints (e.g. Xiaomi MiMo, Kimi) can replay reasoning_content on tool-call turns natively, without impersonating the moonshot provider. * 🔁 feat: Replay reasoning_content across turns for opted-in custom endpoints Extends the DeepSeek reasoning-content format spoof to honor customParams.includeReasoningContent, so custom OpenAI-compatible endpoints (Xiaomi MiMo, Kimi) reconstruct reasoning_content from persisted history on later turns, matching DeepSeek thinking-mode parity. Adds shouldReplayReasoningContent predicate (tested) and surfaces the flag on the initialized agent. * 🪢 refactor: Split within-run vs cross-turn reasoning replay flags moonshot only replays reasoning_content within a run's tool calls, not across turns. Decouples the two: includeReasoningContent = within-run replay (exact moonshot parity), new includeReasoningHistory = cross-turn reconstruction from persisted history (implies includeReasoningContent, since reconstruction is a no-op without the within-run replay flag). * 🩹 fix: Apply reasoning replay across all param-format branches Move the within-run includeReasoningContent application out of the OpenAI-only branch in getOpenAIConfig to after the branch dispatch, so custom endpoints using anthropic/google defaultParamsEndpoint gateway modes also honor includeReasoningContent/includeReasoningHistory. Addresses Codex finding. * chore: Update @librechat/agents to v3.2.46 * 🧽 refactor: De-spoof reasoning replay via explicit preserveReasoningContent Now that @librechat/agents 3.2.46 exposes an explicit preserveReasoningContent option on formatAgentMessages, pass it directly instead of impersonating provider: deepseek. Behavior is unchanged (shouldReplayReasoningContent still gates DeepSeek + the custom includeReasoningHistory flag); also corrects the comment to reference includeReasoningHistory. * 🌳 fix: Walk subagents in the reasoning-history replay gate The gate only checked the primary agent and top-level handoff/parallel configs, so an opted-in custom endpoint used solely as a nested subagent had its persisted reasoning dropped on later turns. New exported anyAgentReplaysReasoningContent walks subagentAgentConfigs (cycle-safe, mirrors anyAgentHasCodeEnv); client.js uses it. Addresses Codex finding. --- api/package.json | 2 +- api/server/controllers/agents/client.js | 25 ++--- package-lock.json | 10 +- packages/api/package.json | 2 +- packages/api/src/agents/initialize.ts | 3 + packages/api/src/agents/run.spec.ts | 98 ++++++++++++++++++- packages/api/src/agents/run.ts | 55 +++++++++++ .../api/src/endpoints/openai/config.spec.ts | 79 +++++++++++++++ packages/api/src/endpoints/openai/config.ts | 13 +++ packages/api/src/endpoints/openai/llm.ts | 6 +- packages/data-provider/src/config.ts | 4 + packages/data-provider/src/types.ts | 2 + 12 files changed, 276 insertions(+), 23 deletions(-) diff --git a/api/package.json b/api/package.json index 7ae08cb56b..442baae85a 100644 --- a/api/package.json +++ b/api/package.json @@ -46,7 +46,7 @@ "@azure/storage-blob": "^12.30.0", "@google/genai": "^2.8.0", "@keyv/redis": "^4.3.3", - "@librechat/agents": "^3.2.44", + "@librechat/agents": "^3.2.46", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index ce89addce8..b95d4de26c 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -29,7 +29,7 @@ const { computeSummaryUsedTokens, priorRunOutputTokens, createSubagentUsageSink, - isDeepSeekReasoningProvider, + anyAgentReplaysReasoningContent, GenerationJobManager, getTransactionsConfig, resolveRecursionLimit, @@ -1160,14 +1160,17 @@ class AgentClient extends BaseClient { agents: [this.options.agent, ...(this.agentConfigs ? this.agentConfigs.values() : [])], }); - /** Spoof `Providers.DEEPSEEK` so the SDK preserves `reasoning_content` on tool turns (#13366). */ - const hasDeepSeekAgent = (agent) => - agent != null && - isDeepSeekReasoningProvider(agent.provider, agent.model_parameters?.model ?? agent.model); - const needsDeepSeekFormat = - hasDeepSeekAgent(this.options.agent) || - (this.agentConfigs != null && - Array.from(this.agentConfigs.values()).some(hasDeepSeekAgent)); + /** + * Reconstruct `reasoning_content` on prior tool-call turns: DeepSeek + * thinking-mode (#13366) or custom endpoints opting in via + * `customParams.includeReasoningHistory` (e.g. Xiaomi MiMo, Kimi). + * Walks subagents too — the opted-in endpoint may appear only as a + * nested subagent, not the primary or a top-level handoff agent. + */ + const needsReasoningContentFormat = anyAgentReplaysReasoningContent([ + this.options.agent, + ...(this.agentConfigs ? Array.from(this.agentConfigs.values()) : []), + ]); /** * Skills primed fresh this turn — manual ($ popover) and always-apply * (frontmatter). `injectSkillPrimes` (below) splices their SKILL.md @@ -1184,9 +1187,9 @@ class AgentClient extends BaseClient { alwaysApplySkillPrimes, }); const formatOptions = - needsDeepSeekFormat || freshSkillPrimeNames.size > 0 + needsReasoningContentFormat || freshSkillPrimeNames.size > 0 ? { - ...(needsDeepSeekFormat ? { provider: Providers.DEEPSEEK } : {}), + ...(needsReasoningContentFormat ? { preserveReasoningContent: true } : {}), ...(freshSkillPrimeNames.size > 0 ? { skipSkillBodyNames: freshSkillPrimeNames } : {}), diff --git a/package-lock.json b/package-lock.json index 04c7470efb..6b4adbf166 100644 --- a/package-lock.json +++ b/package-lock.json @@ -61,7 +61,7 @@ "@azure/storage-blob": "^12.30.0", "@google/genai": "^2.8.0", "@keyv/redis": "^4.3.3", - "@librechat/agents": "^3.2.44", + "@librechat/agents": "^3.2.46", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", @@ -9907,9 +9907,9 @@ } }, "node_modules/@librechat/agents": { - "version": "3.2.44", - "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.2.44.tgz", - "integrity": "sha512-kBfF22rUye1D32FR55BaVqukphsYHfNpNxqvst2i/7iyFig81SVfCQaJQb715sEAkLTI8tYMOuoc1ui/l21ucA==", + "version": "3.2.46", + "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.2.46.tgz", + "integrity": "sha512-ppylwuFF3BOlhrnzDdD6J7h9mkUgAi9aF7LBt5pPpk6/Hab/h5BecxA6a7PyRo5x4xNfgPvAlnTn6MSDeAqJBg==", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "^0.92.0", @@ -42463,7 +42463,7 @@ "@azure/storage-blob": "^12.30.0", "@google/genai": "^2.8.0", "@keyv/redis": "^4.3.3", - "@librechat/agents": "^3.2.44", + "@librechat/agents": "^3.2.46", "@librechat/data-schemas": "*", "@modelcontextprotocol/sdk": "^1.29.0", "@opentelemetry/api": "^1.9.0", diff --git a/packages/api/package.json b/packages/api/package.json index e5ac33d258..3054518a77 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -113,7 +113,7 @@ "@azure/storage-blob": "^12.30.0", "@google/genai": "^2.8.0", "@keyv/redis": "^4.3.3", - "@librechat/agents": "^3.2.44", + "@librechat/agents": "^3.2.46", "@librechat/data-schemas": "*", "@modelcontextprotocol/sdk": "^1.29.0", "@opentelemetry/api": "^1.9.0", diff --git a/packages/api/src/agents/initialize.ts b/packages/api/src/agents/initialize.ts index b5ec1df2da..f7f9991951 100644 --- a/packages/api/src/agents/initialize.ts +++ b/packages/api/src/agents/initialize.ts @@ -261,6 +261,8 @@ export type InitializedAgent = Agent & { maxToolResultChars?: number; /** Response field to read model reasoning from for custom OpenAI-compatible endpoints. */ reasoningKey?: ReasoningResponseKey; + /** Whether to reconstruct `reasoning_content` from persisted history across turns (custom endpoint opt-in). */ + includeReasoningHistory?: boolean; /** * Whether the code-execution environment is available *for this agent*. * Narrower than the incoming `params.codeEnvAvailable` admin flag — this @@ -1257,6 +1259,7 @@ export async function initializeAgent( baseContextTokens, codeEnvAvailable: effectiveCodeEnvAvailable, reasoningKey: customEndpointConfig?.customParams?.reasoningKey, + includeReasoningHistory: customEndpointConfig?.customParams?.includeReasoningHistory, skillAuthoringAvailable, fileAuthoringToolNames: fileAuthoringToolNames.size > 0 ? fileAuthoringToolNames : undefined, skillCount, diff --git a/packages/api/src/agents/run.spec.ts b/packages/api/src/agents/run.spec.ts index cf909546e1..0900e44518 100644 --- a/packages/api/src/agents/run.spec.ts +++ b/packages/api/src/agents/run.spec.ts @@ -1,11 +1,12 @@ import { Providers } from '@librechat/agents'; -import { ToolMessage, AIMessage, HumanMessage } from '@librechat/agents/langchain/messages'; import { ReasoningResponseKey } from 'librechat-data-provider'; - +import { ToolMessage, AIMessage, HumanMessage } from '@librechat/agents/langchain/messages'; import { extractDiscoveredToolsFromHistory, getReasoningKey, isDeepSeekReasoningProvider, + shouldReplayReasoningContent, + anyAgentReplaysReasoningContent, } from './run'; describe('extractDiscoveredToolsFromHistory', () => { @@ -274,3 +275,96 @@ describe('isDeepSeekReasoningProvider', () => { expect(isDeepSeekReasoningProvider(undefined, 'community/deepseek-r1')).toBe(false); }); }); + +describe('shouldReplayReasoningContent', () => { + it('returns true when a custom endpoint opts in via includeReasoningHistory', () => { + expect( + shouldReplayReasoningContent({ + provider: Providers.OPENAI, + model_parameters: { model: 'MiMo-V2.5' }, + includeReasoningHistory: true, + }), + ).toBe(true); + }); + + it('returns true for DeepSeek reasoning agents without the flag', () => { + expect( + shouldReplayReasoningContent({ + provider: Providers.DEEPSEEK, + model_parameters: { model: 'deepseek-chat' }, + }), + ).toBe(true); + expect( + shouldReplayReasoningContent({ + provider: Providers.OPENROUTER, + model_parameters: { model: 'deepseek/deepseek-v4-pro' }, + }), + ).toBe(true); + }); + + it('returns false for a non-DeepSeek agent that has not opted in', () => { + expect( + shouldReplayReasoningContent({ + provider: Providers.OPENAI, + model_parameters: { model: 'MiMo-V2.5' }, + }), + ).toBe(false); + expect( + shouldReplayReasoningContent({ + provider: Providers.OPENAI, + model_parameters: { model: 'MiMo-V2.5' }, + includeReasoningHistory: false, + }), + ).toBe(false); + }); + + it('returns false for nullish agents', () => { + expect(shouldReplayReasoningContent(null)).toBe(false); + expect(shouldReplayReasoningContent(undefined)).toBe(false); + }); +}); + +describe('anyAgentReplaysReasoningContent', () => { + const plainAgent = (id: string, extra = {}) => + ({ + id, + provider: Providers.OPENAI, + model_parameters: { model: 'MiMo-V2.5' }, + ...extra, + }) as unknown as Parameters[0][number]; + + it('returns true when the primary agent opts in', () => { + expect( + anyAgentReplaysReasoningContent([plainAgent('a', { includeReasoningHistory: true })]), + ).toBe(true); + }); + + it('returns true when only a nested subagent opts in', () => { + const subagent = plainAgent('child', { includeReasoningHistory: true }); + const primary = plainAgent('root', { + subagentAgentConfigs: [plainAgent('mid', { subagentAgentConfigs: [subagent] })], + }); + expect(anyAgentReplaysReasoningContent([primary])).toBe(true); + }); + + it('returns false when no reachable agent opts in', () => { + const primary = plainAgent('root', { + subagentAgentConfigs: [plainAgent('child')], + }); + expect(anyAgentReplaysReasoningContent([primary, null, undefined])).toBe(false); + }); + + it('is cycle-safe across subagent references', () => { + const a = plainAgent('a'); + const b = plainAgent('b', { includeReasoningHistory: true }); + (a as { subagentAgentConfigs?: unknown[] }).subagentAgentConfigs = [b]; + (b as { subagentAgentConfigs?: unknown[] }).subagentAgentConfigs = [a]; + expect(anyAgentReplaysReasoningContent([a])).toBe(true); + + const x = plainAgent('x'); + const y = plainAgent('y'); + (x as { subagentAgentConfigs?: unknown[] }).subagentAgentConfigs = [y]; + (y as { subagentAgentConfigs?: unknown[] }).subagentAgentConfigs = [x]; + expect(anyAgentReplaysReasoningContent([x])).toBe(false); + }); +}); diff --git a/packages/api/src/agents/run.ts b/packages/api/src/agents/run.ts index 0ecfe2d50a..a0d0f548e8 100644 --- a/packages/api/src/agents/run.ts +++ b/packages/api/src/agents/run.ts @@ -284,6 +284,29 @@ export function isDeepSeekReasoningProvider( return matchesDeepSeekModel(model); } +/** + * Whether prior assistant tool-call messages should have `reasoning_content` + * reconstructed when reformatting persisted history (cross-turn replay): either + * DeepSeek thinking-mode (#13366) or a custom OpenAI-compatible endpoint that + * opted in via `customParams.includeReasoningHistory` (e.g. Xiaomi MiMo, Kimi). + */ +export function shouldReplayReasoningContent( + agent?: { + provider?: string | Providers | null; + model?: string | null; + model_parameters?: { model?: string | null } | null; + includeReasoningHistory?: boolean | null; + } | null, +): boolean { + if (agent == null) { + return false; + } + if (agent.includeReasoningHistory === true) { + return true; + } + return isDeepSeekReasoningProvider(agent.provider, agent.model_parameters?.model ?? agent.model); +} + type RunAgent = Omit & { tools?: GenericTool[]; maxContextTokens?: number; @@ -309,6 +332,8 @@ type RunAgent = Omit & { summarization?: SummarizationConfig; /** Response field to read model reasoning from for custom OpenAI-compatible endpoints. */ reasoningKey?: ReasoningResponseKey; + /** Whether to reconstruct `reasoning_content` from persisted history across turns. */ + includeReasoningHistory?: boolean; /** * Maximum characters allowed in a single tool result before truncation. * Overrides the default computed from maxContextTokens. @@ -699,6 +724,36 @@ function anyAgentHasCodeEnv(agents: RunAgent[]): boolean { return false; } +/** + * Whether any agent reachable in the run — primary, handoff/parallel, or a + * nested subagent — opts into cross-turn `reasoning_content` reconstruction. + * Walks `subagentAgentConfigs` like {@link anyAgentHasCodeEnv}, since an + * opted-in custom endpoint may appear only as a (possibly pruned) subagent. + */ +export function anyAgentReplaysReasoningContent( + agents: Array, +): boolean { + const visited = new Set(); + const pending = [...agents]; + + for (let index = 0; index < pending.length; index++) { + const agent = pending[index]; + if (agent == null || visited.has(agent.id)) { + continue; + } + visited.add(agent.id); + if (shouldReplayReasoningContent(agent)) { + return true; + } + for (const child of agent.subagentAgentConfigs ?? []) { + if (!visited.has(child.id)) { + pending.push(child); + } + } + } + return false; +} + /** * Builds SubagentConfig entries for an agent: optional self-spawn plus any * explicit child agents loaded in `agent.subagentAgentConfigs`. Returns an empty diff --git a/packages/api/src/endpoints/openai/config.spec.ts b/packages/api/src/endpoints/openai/config.spec.ts index f27c20e050..15f81ef034 100644 --- a/packages/api/src/endpoints/openai/config.spec.ts +++ b/packages/api/src/endpoints/openai/config.spec.ts @@ -3,6 +3,7 @@ import { EModelEndpoint, ReasoningEffort, ReasoningSummary, + ReasoningResponseKey, ReasoningParameterFormat, } from 'librechat-data-provider'; import type { RequestInit } from 'undici'; @@ -216,6 +217,84 @@ describe('getOpenAIConfig', () => { expect((result.llmConfig as Record).reasoning_effort).toBeUndefined(); }); + it('should replay reasoning content when custom endpoint enables includeReasoningContent', () => { + const result = getOpenAIConfig( + mockApiKey, + { + customParams: { + reasoningKey: ReasoningResponseKey.reasoningContent, + includeReasoningContent: true, + }, + modelOptions: { + model: 'MiMo-VL-7B-RL', + }, + }, + 'custom-endpoint', + ); + + expect(result.llmConfig).toHaveProperty('includeReasoningContent', true); + }); + + it('should enable within-run replay when custom endpoint enables includeReasoningHistory', () => { + const result = getOpenAIConfig( + mockApiKey, + { + customParams: { + reasoningKey: ReasoningResponseKey.reasoningContent, + includeReasoningHistory: true, + }, + modelOptions: { + model: 'MiMo-VL-7B-RL', + }, + }, + 'custom-endpoint', + ); + + expect(result.llmConfig).toHaveProperty('includeReasoningContent', true); + }); + + it('should enable within-run replay for non-OpenAI param-format gateways (anthropic/google)', () => { + const anthropic = getOpenAIConfig( + mockApiKey, + { + customParams: { + defaultParamsEndpoint: EModelEndpoint.anthropic, + includeReasoningContent: true, + }, + modelOptions: { model: 'claude-3-7-sonnet' }, + }, + 'custom-endpoint', + ); + expect(anthropic.llmConfig).toHaveProperty('includeReasoningContent', true); + + const google = getOpenAIConfig( + mockApiKey, + { + customParams: { + defaultParamsEndpoint: EModelEndpoint.google, + includeReasoningHistory: true, + }, + modelOptions: { model: 'gemini-2.5-pro' }, + }, + 'custom-endpoint', + ); + expect(google.llmConfig).toHaveProperty('includeReasoningContent', true); + }); + + it('should not replay reasoning content for custom endpoints by default', () => { + const result = getOpenAIConfig( + mockApiKey, + { + modelOptions: { + model: 'MiMo-VL-7B-RL', + }, + }, + 'custom-endpoint', + ); + + expect(result.llmConfig).not.toHaveProperty('includeReasoningContent'); + }); + it('should default Vercel custom endpoints to reasoning object format', () => { const result = getOpenAIConfig( mockApiKey, diff --git a/packages/api/src/endpoints/openai/config.ts b/packages/api/src/endpoints/openai/config.ts index bc425e1a1f..13e324d7ff 100644 --- a/packages/api/src/endpoints/openai/config.ts +++ b/packages/api/src/endpoints/openai/config.ts @@ -202,6 +202,19 @@ export function getOpenAIConfig( tools = openaiResult.tools; } + /** + * Within-run `reasoning_content` replay applies across every param-format + * branch above (OpenAI / Anthropic / Google gateway modes all resolve to the + * OpenAI client). `includeReasoningHistory` implies it, since reconstructed + * history reasoning is only sent when the within-run flag is set. + */ + if ( + options.customParams?.includeReasoningContent === true || + options.customParams?.includeReasoningHistory === true + ) { + llmConfig.includeReasoningContent = true; + } + const configOptions: t.OpenAIConfiguration = {}; if (baseURL) { configOptions.baseURL = baseURL; diff --git a/packages/api/src/endpoints/openai/llm.ts b/packages/api/src/endpoints/openai/llm.ts index 448c9a653d..ddbadc0853 100644 --- a/packages/api/src/endpoints/openai/llm.ts +++ b/packages/api/src/endpoints/openai/llm.ts @@ -667,10 +667,10 @@ export function getOpenAILLMConfig({ } /** DeepSeek thinking-mode requires `reasoning_content` replay on tool turns (#13366). */ - if ( + const isDeepSeekModel = typeof modelOptions.model === 'string' && - /^deepseek(?:[-/]|$)/i.test(modelOptions.model.replace(/^~/, '')) - ) { + /^deepseek(?:[-/]|$)/i.test(modelOptions.model.replace(/^~/, '')); + if (isDeepSeekModel) { llmConfig.includeReasoningContent = true; } diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 2e0439cc0e..c0034d8a33 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -857,6 +857,10 @@ export const endpointSchema = baseEndpointSchema.merge( defaultParamsEndpoint: z.string().default('custom'), reasoningFormat: eReasoningParameterFormatSchema.optional(), reasoningKey: eReasoningResponseKeySchema.optional(), + /** Replays `reasoning_content` within a run's tool-call turns (e.g. Xiaomi MiMo, Kimi). */ + includeReasoningContent: z.boolean().optional(), + /** Also reconstructs `reasoning_content` from persisted history across turns (implies `includeReasoningContent`). */ + includeReasoningHistory: z.boolean().optional(), paramDefinitions: z.array(paramDefinitionSchema).optional(), }) .strict() diff --git a/packages/data-provider/src/types.ts b/packages/data-provider/src/types.ts index 020f6c637d..f3d40b139b 100644 --- a/packages/data-provider/src/types.ts +++ b/packages/data-provider/src/types.ts @@ -456,6 +456,8 @@ export type TConfig = { defaultParamsEndpoint?: string; reasoningFormat?: ReasoningParameterFormat; reasoningKey?: ReasoningResponseKey; + includeReasoningContent?: boolean; + includeReasoningHistory?: boolean; paramDefinitions?: Partial[]; }; };