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:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user