From 6187c6d95e500e5e47b230cf66f09d9cee19d97b Mon Sep 17 00:00:00 2001 From: roboomp Date: Wed, 27 May 2026 16:25:27 +0000 Subject: [PATCH] fix(ai): replay openrouter deepseek reasoning content Normalized OpenRouter reasoning replay back to DeepSeek's required reasoning_content field for exact-replay providers. Added regression coverage for OpenRouter DeepSeek V4 tool-call continuations. Fixes #1445 --- packages/ai/CHANGELOG.md | 4 +++ .../ai/src/providers/openai-completions.ts | 18 +++++++------ .../test/deepseek-reasoning-content.test.ts | 25 +++++++++++++++++++ 3 files changed, 39 insertions(+), 8 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index b471e996c..0fdedd1a2 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -11,6 +11,10 @@ - `OpenAIResponsesOptions` gains four optional, provider-agnostic fields that adapter wrappers can use to compose provider-specific behavior on top of the generic transport: `includeEncryptedReasoning` (gates `include: ["reasoning.encrypted_content"]`; default `true`, preserves current behavior), `filterReasoningHistory` (strips replayed `type: "reasoning"` items from conversation history; default `false`), `headers` (merged onto the client's default headers), and `extraBody` (merged into the request payload). - The existing `XAI_API_KEY` path is unchanged — it continues to use the OpenAI-completions transport. +### Fixed + +- Fixed OpenRouter DeepSeek V4 tool-call follow-up requests replaying normalized `reasoning` as-is instead of DeepSeek's required `reasoning_content`, which caused HTTP 400 errors in thinking mode. ([#1445](https://github.com/can1357/oh-my-pi/issues/1445)) + ## [15.5.6] - 2026-05-27 ### Added diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index b8fa7a95f..372d343ed 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -1507,12 +1507,13 @@ export function convertMessages( } } - if (compat.thinkingFormat === "openai" && compat.requiresReasoningContentForToolCalls) { + if (compat.requiresReasoningContentForToolCalls) { const streamedReasoningField = nonEmptyThinkingBlocks[0]?.thinkingSignature; const reasoningField = - streamedReasoningField === "reasoning_content" || - streamedReasoningField === "reasoning" || - streamedReasoningField === "reasoning_text" + compat.allowsSyntheticReasoningContentForToolCalls && + (streamedReasoningField === "reasoning_content" || + streamedReasoningField === "reasoning" || + streamedReasoningField === "reasoning_text") ? streamedReasoningField : (compat.reasoningContentField ?? "reasoning_content"); const reasoningContent = (assistantMsg as any)[reasoningField]; @@ -1547,9 +1548,9 @@ export function convertMessages( (compat.thinkingFormat === "openai" || compat.thinkingFormat === "openrouter" || compat.thinkingFormat === "zai"); - // DeepSeek reasoning models require reasoning_content on ALL assistant turns, - // not just tool-call turns. Other providers (Kimi, OpenRouter) only require it - // on tool-call turns. + // DeepSeek-compatible reasoning models require reasoning_content on all + // assistant turns. Providers that allow placeholders only need it on + // tool-call turns. const needsReasoningOnAllTurns = compat.requiresReasoningContentForToolCalls && !compat.allowsSyntheticReasoningContentForToolCalls; const needsReasoningField = needsReasoningOnAllTurns || toolCalls.length > 0; @@ -1576,7 +1577,8 @@ export function convertMessages( const signature = allThinkingBlocks[0].thinkingSignature; const recognizedFields = ["reasoning_content", "reasoning", "reasoning_text"]; if (signature && recognizedFields.includes(signature)) { - (assistantMsg as any)[signature] = allThinkingBlocks.map(b => b.thinking).join("\n"); + const reasoningField = compat.reasoningContentField ?? "reasoning_content"; + (assistantMsg as any)[reasoningField] = allThinkingBlocks.map(b => b.thinking).join("\n"); hasReasoningField = true; } } diff --git a/packages/ai/test/deepseek-reasoning-content.test.ts b/packages/ai/test/deepseek-reasoning-content.test.ts index d70d69c91..180885e4d 100644 --- a/packages/ai/test/deepseek-reasoning-content.test.ts +++ b/packages/ai/test/deepseek-reasoning-content.test.ts @@ -242,6 +242,31 @@ describe("DeepSeek reasoning_content tool-call replay", () => { expect(assistant).toBeDefined(); expect(Reflect.get(assistant as object, "reasoning_content")).toBe("I need to read the file first."); }); + + it("normalizes OpenRouter reasoning deltas to DeepSeek reasoning_content on replay", () => { + const model = getBundledModel("openrouter", "deepseek/deepseek-v4-pro") as Model<"openai-completions">; + const compat = detectCompat(model); + expect(compat.requiresReasoningContentForToolCalls).toBe(true); + expect(compat.allowsSyntheticReasoningContentForToolCalls).toBe(false); + + const msg = assistantToolCall(model, [ + { + type: "thinking", + thinking: "I should inspect the requested file.", + thinkingSignature: "reasoning", + } as ThinkingContent, + { + type: "toolCall", + id: "call_openrouter_deepseek", + name: "read", + arguments: { path: "package.json" }, + } as ToolCall, + ]); + const messages = convertMessages(model, { messages: [msg] }, compat); + const assistant = messages.find(m => m.role === "assistant"); + expect(assistant).toBeDefined(); + expect(Reflect.get(assistant as object, "reasoning_content")).toBe("I should inspect the requested file."); + }); it("does not use opaque signature as property name but still sets reasoning_content from thinking text", () => { const model = deepseekModel({ provider: "opencode-go",