🧠 feat: Configurable Reasoning Replay for Custom Endpoints (#13921)

* 🧠 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.
This commit is contained in:
Danny Avila 2026-06-23 21:08:47 -04:00 • committed by GitHub
parent f09a1ad7fc
commit d9a76fca90
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 276 additions and 23 deletions

View file

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

View file

@ -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 }
: {}),

10
package-lock.json generated
View file

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

View file

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

View file

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

View file

@ -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<typeof anyAgentReplaysReasoningContent>[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);
});
});

View file

@ -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<Agent, 'tools'> & {
tools?: GenericTool[];
maxContextTokens?: number;
@ -309,6 +332,8 @@ type RunAgent = Omit<Agent, 'tools'> & {
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<RunAgent | null | undefined>,
): boolean {
const visited = new Set<string>();
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

View file

@ -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<string, unknown>).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,

View file

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

View file

@ -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;
}

View file

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

View file

@ -456,6 +456,8 @@ export type TConfig = {
defaultParamsEndpoint?: string;
reasoningFormat?: ReasoningParameterFormat;
reasoningKey?: ReasoningResponseKey;
includeReasoningContent?: boolean;
includeReasoningHistory?: boolean;
paramDefinitions?: Partial<SettingDefinition>[];
};
};