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
This commit is contained in:
roboomp
2026-05-27 16:25:27 +00:00
parent 5be5053696
commit 6187c6d95e
3 changed files with 39 additions and 8 deletions
+4
View File
@@ -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
@@ -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;
}
}
@@ -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",