From bbdc478e84d06feae86f4752ed1a1cfd20b9ddc6 Mon Sep 17 00:00:00 2001 From: Simon Gardling Date: Mon, 27 Apr 2026 01:36:08 -0400 Subject: [PATCH] omp: update patches --- home/progs/pi.nix | 8 +- patches/omp/0001-fix-reasoning_content.patch | 804 ++++++++++++++++++ ...ithout-strict-on-deepseek-openrouter.patch | 126 --- ...tub-reasoning-content-for-openrouter.patch | 233 ----- 4 files changed, 805 insertions(+), 366 deletions(-) create mode 100644 patches/omp/0001-fix-reasoning_content.patch delete mode 100644 patches/omp/0001-openai-completions-retry-without-strict-on-deepseek-openrouter.patch delete mode 100644 patches/omp/0002-openai-completions-stub-reasoning-content-for-openrouter.patch diff --git a/home/progs/pi.nix b/home/progs/pi.nix index 657e5b6..9b0f0ac 100644 --- a/home/progs/pi.nix +++ b/home/progs/pi.nix @@ -42,13 +42,7 @@ in # so they actually take effect. Tracking: nothing upstream yet. (inputs.llm-agents.packages.${pkgs.stdenv.hostPlatform.system}.omp.overrideAttrs (old: { prePatch = (old.prePatch or "") + '' - # 0001 — retry without strict tools when DeepSeek (via OpenRouter) rejects strict-mode - # `anyOf` nullable unions with `Invalid tool parameters schema : field \`anyOf\`: - # missing field \`type\``. - patch -p1 < ${../../patches/omp/0001-openai-completions-retry-without-strict-on-deepseek-openrouter.patch} - # 0002 — require `reasoning_content` for OpenRouter reasoning models so DeepSeek V4 Pro - # et al. accept follow-up requests in thinking mode. - patch -p1 < ${../../patches/omp/0002-openai-completions-stub-reasoning-content-for-openrouter.patch} + patch -p1 < ${../../patches/omp/0001-fix-reasoning_content.patch} ''; })) ]; diff --git a/patches/omp/0001-fix-reasoning_content.patch b/patches/omp/0001-fix-reasoning_content.patch new file mode 100644 index 0000000..12d1548 --- /dev/null +++ b/patches/omp/0001-fix-reasoning_content.patch @@ -0,0 +1,804 @@ +From e145b627cffb6907e6bde348f1318f48acba3801 Mon Sep 17 00:00:00 2001 +From: sonhyrd +Date: Mon, 27 Apr 2026 00:00:18 +0700 +Subject: [PATCH 1/5] fix(ai/providers): cover opencode-go reasoning tool-call + history + +--- + .../providers/openai-completions-compat.ts | 12 +++-- + .../ai/src/providers/openai-completions.ts | 4 +- + .../ai/test/openai-completions-compat.test.ts | 51 +++++++++++++++---- + 3 files changed, 49 insertions(+), 18 deletions(-) + +diff --git a/packages/ai/src/providers/openai-completions-compat.ts b/packages/ai/src/providers/openai-completions-compat.ts +index 69f4811c8..c777f312b 100644 +--- a/packages/ai/src/providers/openai-completions-compat.ts ++++ b/packages/ai/src/providers/openai-completions-compat.ts +@@ -107,12 +107,14 @@ export function detectOpenAICompat(model: Model<"openai-completions">, resolvedB + reasoningContentField: "reasoning_content", + // Backends that 400 follow-up requests when prior assistant tool-call turns lack `reasoning_content`: + // - Kimi: documented invariant on its native API and via OpenCode-Go. +- // - Any reasoning-capable model reached through OpenRouter: DeepSeek V4 Pro and similar enforce +- // this server-side whenever the request is in thinking mode. We can't translate Anthropic's +- // redacted/encrypted reasoning into DeepSeek's plaintext form, so cross-provider continuations +- // rely on a placeholder — see `convertMessages` for the placeholder injection. ++ // - Reasoning-capable models reached through OpenRouter or OpenCode-Go: DeepSeek V4 Pro and ++ // similar enforce this server-side whenever the request is in thinking mode. ++ // We can't translate Anthropic's redacted/encrypted reasoning into DeepSeek's plaintext form, so ++ // cross-provider continuations rely on a placeholder — see `convertMessages` for injection rules. + requiresReasoningContentForToolCalls: +- isKimiModel || ((provider === "openrouter" || baseUrl.includes("openrouter.ai")) && Boolean(model.reasoning)), ++ isKimiModel || ++ ((provider === "openrouter" || baseUrl.includes("openrouter.ai") || provider === "opencode-go" || ++ baseUrl.includes("opencode.ai/zen/go")) && Boolean(model.reasoning)), + requiresAssistantContentForToolCalls: isKimiModel, + openRouterRouting: undefined, + vercelGatewayRouting: undefined, +diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts +index 3785af106..70f2e3b63 100644 +--- a/packages/ai/src/providers/openai-completions.ts ++++ b/packages/ai/src/providers/openai-completions.ts +@@ -1213,8 +1213,8 @@ export function convertMessages( + // Inject a `reasoning_content` placeholder on assistant tool-call turns when the backend + // rejects history without it. The compat flag captures the rule: + // - Kimi (native or via OpenCode-Go): chat completion endpoint demands the field. +- // - Reasoning models reached through OpenRouter (e.g. DeepSeek V4 Pro): the underlying +- // provider's thinking-mode validator demands it on every prior assistant turn. omp ++ // - Reasoning models reached through OpenRouter or OpenCode-Go (e.g. DeepSeek V4 Pro): ++ // the upstream thinking-mode validator demands it on every prior assistant turn. omp + // cannot synthesize real reasoning when the conversation was warmed up by another + // provider whose reasoning is redacted/encrypted (Anthropic) or simply absent, so we + // emit a placeholder. Real captured reasoning, when present, is preserved earlier via +diff --git a/packages/ai/test/openai-completions-compat.test.ts b/packages/ai/test/openai-completions-compat.test.ts +index 6fc3ca9af..6d60ba5e4 100644 +--- a/packages/ai/test/openai-completions-compat.test.ts ++++ b/packages/ai/test/openai-completions-compat.test.ts +@@ -283,23 +283,59 @@ describe("openai-completions compatibility", () => { + }); + + describe("kimi model detection via detectCompat", () => { +- function kimiOpenCodeModel(id: string): Model<"openai-completions"> { ++ function openCodeGoModel(id: string, reasoning = true): Model<"openai-completions"> { + return { + ...getBundledModel("openai", "gpt-4o-mini"), + api: "openai-completions", + provider: "opencode-go", + baseUrl: "https://opencode.ai/zen/go/v1", + id, +- reasoning: true, ++ reasoning, + }; + } + ++ function kimiOpenCodeModel(id: string): Model<"openai-completions"> { ++ return openCodeGoModel(id, true); ++ } ++ + it("requires reasoning_content for tool calls on kimi-k2.5 (opencode-go)", () => { + const compat = detectCompat(kimiOpenCodeModel("kimi-k2.5")); + expect(compat.requiresReasoningContentForToolCalls).toBe(true); + expect(compat.requiresAssistantContentForToolCalls).toBe(true); + }); + ++ it("requires reasoning_content for tool calls on reasoning DeepSeek models via opencode-go", () => { ++ const compat = detectCompat(openCodeGoModel("deepseek-v4-pro", true)); ++ expect(compat.requiresReasoningContentForToolCalls).toBe(true); ++ expect(compat.requiresAssistantContentForToolCalls).toBe(false); ++ }); ++ ++ it("injects reasoning_content placeholder for reasoning DeepSeek tool-call turns via opencode-go", () => { ++ const model = openCodeGoModel("deepseek-v4-pro", true); ++ const compat = detectCompat(model); ++ const toolCallMessage: AssistantMessage = { ++ role: "assistant", ++ content: [{ type: "toolCall", id: "call_ds_go", name: "web_search", arguments: { query: "hi" } }], ++ api: model.api, ++ provider: model.provider, ++ model: model.id, ++ usage: { ++ input: 0, ++ output: 0, ++ cacheRead: 0, ++ cacheWrite: 0, ++ totalTokens: 0, ++ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, ++ }, ++ stopReason: "toolUse", ++ timestamp: Date.now(), ++ }; ++ const messages = convertMessages(model, { messages: [toolCallMessage] }, compat); ++ const assistant = messages.find(m => m.role === "assistant"); ++ expect(assistant).toBeDefined(); ++ expect(Reflect.get(assistant as object, "reasoning_content")).toBe("."); ++ }); ++ + it("injects reasoning_content placeholder when assistant with tool calls has no reasoning field", () => { + const model = kimiOpenCodeModel("kimi-k2.5"); + const compat = detectCompat(model); +@@ -338,15 +374,8 @@ describe("kimi model detection via detectCompat", () => { + expect((reasoningContent as string).length).toBeGreaterThan(0); + }); + +- it("does not inject reasoning_content when model is not kimi", () => { +- const model: Model<"openai-completions"> = { +- ...getBundledModel("openai", "gpt-4o-mini"), +- api: "openai-completions", +- provider: "opencode-go", +- baseUrl: "https://opencode.ai/zen/go/v1", +- id: "some-other-model", +- }; +- const compat = detectCompat(model); ++ it("does not require reasoning_content when opencode-go model is not reasoning-capable", () => { ++ const compat = detectCompat(openCodeGoModel("some-other-model", false)); + expect(compat.requiresReasoningContentForToolCalls).toBe(false); + }); + + +From 70eda0132d7ff48314cbf2dc9560339f0a765d9e Mon Sep 17 00:00:00 2001 +From: sonhyrd +Date: Mon, 27 Apr 2026 00:08:04 +0700 +Subject: [PATCH 2/5] fix(ai/providers): generalize opencode reasoning_content + gating + +--- + .../providers/openai-completions-compat.ts | 14 +- + .../ai/src/providers/openai-completions.ts | 4 +- + .../ai/test/openai-completions-compat.test.ts | 160 ++++++++---------- + 3 files changed, 82 insertions(+), 96 deletions(-) + +diff --git a/packages/ai/src/providers/openai-completions-compat.ts b/packages/ai/src/providers/openai-completions-compat.ts +index c777f312b..b4825a31c 100644 +--- a/packages/ai/src/providers/openai-completions-compat.ts ++++ b/packages/ai/src/providers/openai-completions-compat.ts +@@ -54,6 +54,8 @@ export function detectOpenAICompat(model: Model<"openai-completions">, resolvedB + const isKimiModel = model.id.includes("moonshotai/kimi") || /^kimi[-.]/i.test(model.id); + const isAlibaba = provider === "alibaba-coding-plan" || baseUrl.includes("dashscope"); + const isQwen = model.id.toLowerCase().includes("qwen"); ++ const isOpenRouter = provider === "openrouter" || baseUrl.includes("openrouter.ai"); ++ const isOpenCode = provider === "opencode-zen" || provider === "opencode-go" || baseUrl.includes("opencode.ai/zen"); + + const isNonStandard = + isCerebras || +@@ -99,22 +101,20 @@ export function detectOpenAICompat(model: Model<"openai-completions">, resolvedB + requiresMistralToolIds: isMistral, + thinkingFormat: isZai + ? "zai" +- : provider === "openrouter" || baseUrl.includes("openrouter.ai") ++ : isOpenRouter + ? "openrouter" + : isAlibaba || isQwen + ? "qwen" + : "openai", + reasoningContentField: "reasoning_content", + // Backends that 400 follow-up requests when prior assistant tool-call turns lack `reasoning_content`: +- // - Kimi: documented invariant on its native API and via OpenCode-Go. +- // - Reasoning-capable models reached through OpenRouter or OpenCode-Go: DeepSeek V4 Pro and +- // similar enforce this server-side whenever the request is in thinking mode. ++ // - Kimi: documented invariant on its native API and via OpenCode. ++ // - Reasoning-capable models reached through OpenRouter or OpenCode (Zen/Go): DeepSeek V4 Pro, ++ // Kimi, and similar models can enforce this server-side whenever the request is in thinking mode. + // We can't translate Anthropic's redacted/encrypted reasoning into DeepSeek's plaintext form, so + // cross-provider continuations rely on a placeholder — see `convertMessages` for injection rules. + requiresReasoningContentForToolCalls: +- isKimiModel || +- ((provider === "openrouter" || baseUrl.includes("openrouter.ai") || provider === "opencode-go" || +- baseUrl.includes("opencode.ai/zen/go")) && Boolean(model.reasoning)), ++ isKimiModel || ((isOpenRouter || isOpenCode) && Boolean(model.reasoning)), + requiresAssistantContentForToolCalls: isKimiModel, + openRouterRouting: undefined, + vercelGatewayRouting: undefined, +diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts +index 70f2e3b63..e25aeffb3 100644 +--- a/packages/ai/src/providers/openai-completions.ts ++++ b/packages/ai/src/providers/openai-completions.ts +@@ -1212,8 +1212,8 @@ export function convertMessages( + (assistantMsg as any).reasoning_text !== undefined; + // Inject a `reasoning_content` placeholder on assistant tool-call turns when the backend + // rejects history without it. The compat flag captures the rule: +- // - Kimi (native or via OpenCode-Go): chat completion endpoint demands the field. +- // - Reasoning models reached through OpenRouter or OpenCode-Go (e.g. DeepSeek V4 Pro): ++ // - Kimi (native or via OpenCode Zen/Go): chat completion endpoint demands the field. ++ // - Reasoning models reached through OpenRouter or OpenCode Zen/Go (e.g. DeepSeek V4 Pro): + // the upstream thinking-mode validator demands it on every prior assistant turn. omp + // cannot synthesize real reasoning when the conversation was warmed up by another + // provider whose reasoning is redacted/encrypted (Anthropic) or simply absent, so we +diff --git a/packages/ai/test/openai-completions-compat.test.ts b/packages/ai/test/openai-completions-compat.test.ts +index 6d60ba5e4..c743dd246 100644 +--- a/packages/ai/test/openai-completions-compat.test.ts ++++ b/packages/ai/test/openai-completions-compat.test.ts +@@ -282,105 +282,91 @@ describe("openai-completions compatibility", () => { + }); + }); + +-describe("kimi model detection via detectCompat", () => { +- function openCodeGoModel(id: string, reasoning = true): Model<"openai-completions"> { ++describe("opencode reasoning-content compatibility via detectCompat", () => { ++ type OpenCodeProvider = "opencode-go" | "opencode-zen"; ++ ++ function openCodeModel(provider: OpenCodeProvider, id: string, reasoning = true): Model<"openai-completions"> { ++ const baseUrl = provider === "opencode-go" ? "https://opencode.ai/zen/go/v1" : "https://opencode.ai/zen/v1"; + return { + ...getBundledModel("openai", "gpt-4o-mini"), + api: "openai-completions", +- provider: "opencode-go", +- baseUrl: "https://opencode.ai/zen/go/v1", ++ provider, ++ baseUrl, + id, + reasoning, + }; + } + +- function kimiOpenCodeModel(id: string): Model<"openai-completions"> { +- return openCodeGoModel(id, true); +- } +- +- it("requires reasoning_content for tool calls on kimi-k2.5 (opencode-go)", () => { +- const compat = detectCompat(kimiOpenCodeModel("kimi-k2.5")); +- expect(compat.requiresReasoningContentForToolCalls).toBe(true); +- expect(compat.requiresAssistantContentForToolCalls).toBe(true); +- }); +- +- it("requires reasoning_content for tool calls on reasoning DeepSeek models via opencode-go", () => { +- const compat = detectCompat(openCodeGoModel("deepseek-v4-pro", true)); +- expect(compat.requiresReasoningContentForToolCalls).toBe(true); +- expect(compat.requiresAssistantContentForToolCalls).toBe(false); +- }); +- +- it("injects reasoning_content placeholder for reasoning DeepSeek tool-call turns via opencode-go", () => { +- const model = openCodeGoModel("deepseek-v4-pro", true); +- const compat = detectCompat(model); +- const toolCallMessage: AssistantMessage = { +- role: "assistant", +- content: [{ type: "toolCall", id: "call_ds_go", name: "web_search", arguments: { query: "hi" } }], +- api: model.api, +- provider: model.provider, +- model: model.id, +- usage: { +- input: 0, +- output: 0, +- cacheRead: 0, +- cacheWrite: 0, +- totalTokens: 0, +- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, +- }, +- stopReason: "toolUse", +- timestamp: Date.now(), ++ it.each(["opencode-go", "opencode-zen"] as const)( ++ "requires reasoning_content for tool calls on kimi-k2.5 via %s", ++ provider => { ++ const compat = detectCompat(openCodeModel(provider, "kimi-k2.5", true)); ++ expect(compat.requiresReasoningContentForToolCalls).toBe(true); ++ expect(compat.requiresAssistantContentForToolCalls).toBe(true); ++ }, ++ ); ++ ++ it.each(["opencode-go", "opencode-zen"] as const)( ++ "requires reasoning_content for tool calls on reasoning DeepSeek models via %s", ++ provider => { ++ const compat = detectCompat(openCodeModel(provider, "deepseek-v4-pro", true)); ++ expect(compat.requiresReasoningContentForToolCalls).toBe(true); ++ expect(compat.requiresAssistantContentForToolCalls).toBe(false); ++ }, ++ ); ++ ++ it("requires reasoning_content when custom openai provider targets opencode zen baseUrl", () => { ++ const model: Model<"openai-completions"> = { ++ ...getBundledModel("openai", "gpt-4o-mini"), ++ api: "openai-completions", ++ provider: "openai", ++ baseUrl: "https://opencode.ai/zen/v1", ++ id: "deepseek-v4-pro", ++ reasoning: true, + }; +- const messages = convertMessages(model, { messages: [toolCallMessage] }, compat); +- const assistant = messages.find(m => m.role === "assistant"); +- expect(assistant).toBeDefined(); +- expect(Reflect.get(assistant as object, "reasoning_content")).toBe("."); +- }); +- +- it("injects reasoning_content placeholder when assistant with tool calls has no reasoning field", () => { +- const model = kimiOpenCodeModel("kimi-k2.5"); + const compat = detectCompat(model); +- const toolCallMessage: AssistantMessage = { +- role: "assistant", +- content: [ +- // Thinking returned as plain text (as kimi-k2.5 on opencode-go does) +- { type: "text", text: "Let me research this." }, +- { +- type: "toolCall", +- id: "call_abc123", +- name: "web_search", +- arguments: { query: "beads gastownhall" }, +- }, +- ], +- api: model.api, +- provider: model.provider, +- model: model.id, +- usage: { +- input: 0, +- output: 0, +- cacheRead: 0, +- cacheWrite: 0, +- totalTokens: 0, +- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, +- }, +- stopReason: "toolUse", +- timestamp: Date.now(), +- }; +- const messages = convertMessages(model, { messages: [toolCallMessage] }, compat); +- const assistant = messages.find(m => m.role === "assistant"); +- expect(assistant).toBeDefined(); +- const reasoningContent = Reflect.get(assistant as object, "reasoning_content"); +- expect(reasoningContent).toBeDefined(); +- expect(typeof reasoningContent).toBe("string"); +- expect((reasoningContent as string).length).toBeGreaterThan(0); +- }); +- +- it("does not require reasoning_content when opencode-go model is not reasoning-capable", () => { +- const compat = detectCompat(openCodeGoModel("some-other-model", false)); +- expect(compat.requiresReasoningContentForToolCalls).toBe(false); ++ expect(compat.requiresReasoningContentForToolCalls).toBe(true); + }); + +- it.each(["kimi-k2.5", "kimi-k1.5", "kimi-k2-5"])("matches kimi model id: %s", id => { +- const compat = detectCompat(kimiOpenCodeModel(id)); ++ it.each(["opencode-go", "opencode-zen"] as const)( ++ "injects reasoning_content placeholder for reasoning DeepSeek tool-call turns via %s", ++ provider => { ++ const model = openCodeModel(provider, "deepseek-v4-pro", true); ++ const compat = detectCompat(model); ++ const toolCallMessage: AssistantMessage = { ++ role: "assistant", ++ content: [{ type: "toolCall", id: `call_ds_${provider}`, name: "web_search", arguments: { query: "hi" } }], ++ api: model.api, ++ provider: model.provider, ++ model: model.id, ++ usage: { ++ input: 0, ++ output: 0, ++ cacheRead: 0, ++ cacheWrite: 0, ++ totalTokens: 0, ++ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, ++ }, ++ stopReason: "toolUse", ++ timestamp: Date.now(), ++ }; ++ const messages = convertMessages(model, { messages: [toolCallMessage] }, compat); ++ const assistant = messages.find(m => m.role === "assistant"); ++ expect(assistant).toBeDefined(); ++ expect(Reflect.get(assistant as object, "reasoning_content")).toBe("."); ++ }, ++ ); ++ ++ it.each(["opencode-go", "opencode-zen"] as const)( ++ "does not require reasoning_content when %s model is not reasoning-capable", ++ provider => { ++ const compat = detectCompat(openCodeModel(provider, "some-other-model", false)); ++ expect(compat.requiresReasoningContentForToolCalls).toBe(false); ++ }, ++ ); ++ ++ it.each(["kimi-k2.5", "kimi-k1.5", "kimi-k2-5"])("matches kimi model id pattern via opencode-zen: %s", id => { ++ const compat = detectCompat(openCodeModel("opencode-zen", id, true)); + expect(compat.requiresReasoningContentForToolCalls).toBe(true); + }); + + +From 76c1fe9ee083836ecca43900fefc458c8cf4c4fb Mon Sep 17 00:00:00 2001 +From: sonhyrd +Date: Mon, 27 Apr 2026 00:14:27 +0700 +Subject: [PATCH 3/5] test(ai): restore non-kimi coverage while adding + opencode-zen cases + +--- + .../ai/test/openai-completions-compat.test.ts | 215 +++++++++++++----- + 1 file changed, 154 insertions(+), 61 deletions(-) + +diff --git a/packages/ai/test/openai-completions-compat.test.ts b/packages/ai/test/openai-completions-compat.test.ts +index c743dd246..8b8cef393 100644 +--- a/packages/ai/test/openai-completions-compat.test.ts ++++ b/packages/ai/test/openai-completions-compat.test.ts +@@ -282,38 +282,56 @@ describe("openai-completions compatibility", () => { + }); + }); + +-describe("opencode reasoning-content compatibility via detectCompat", () => { +- type OpenCodeProvider = "opencode-go" | "opencode-zen"; ++describe("kimi model detection via detectCompat", () => { ++ function openCodeGoModel(id: string, reasoning = true): Model<"openai-completions"> { ++ return { ++ ...getBundledModel("openai", "gpt-4o-mini"), ++ api: "openai-completions", ++ provider: "opencode-go", ++ baseUrl: "https://opencode.ai/zen/go/v1", ++ id, ++ reasoning, ++ }; ++ } + +- function openCodeModel(provider: OpenCodeProvider, id: string, reasoning = true): Model<"openai-completions"> { +- const baseUrl = provider === "opencode-go" ? "https://opencode.ai/zen/go/v1" : "https://opencode.ai/zen/v1"; ++ function openCodeZenModel(id: string, reasoning = true): Model<"openai-completions"> { + return { + ...getBundledModel("openai", "gpt-4o-mini"), + api: "openai-completions", +- provider, +- baseUrl, ++ provider: "opencode-zen", ++ baseUrl: "https://opencode.ai/zen/v1", + id, + reasoning, + }; + } + +- it.each(["opencode-go", "opencode-zen"] as const)( +- "requires reasoning_content for tool calls on kimi-k2.5 via %s", +- provider => { +- const compat = detectCompat(openCodeModel(provider, "kimi-k2.5", true)); +- expect(compat.requiresReasoningContentForToolCalls).toBe(true); +- expect(compat.requiresAssistantContentForToolCalls).toBe(true); +- }, +- ); +- +- it.each(["opencode-go", "opencode-zen"] as const)( +- "requires reasoning_content for tool calls on reasoning DeepSeek models via %s", +- provider => { +- const compat = detectCompat(openCodeModel(provider, "deepseek-v4-pro", true)); +- expect(compat.requiresReasoningContentForToolCalls).toBe(true); +- expect(compat.requiresAssistantContentForToolCalls).toBe(false); +- }, +- ); ++ function kimiOpenCodeModel(id: string): Model<"openai-completions"> { ++ return openCodeGoModel(id, true); ++ } ++ ++ it("requires reasoning_content for tool calls on kimi-k2.5 (opencode-go)", () => { ++ const compat = detectCompat(kimiOpenCodeModel("kimi-k2.5")); ++ expect(compat.requiresReasoningContentForToolCalls).toBe(true); ++ expect(compat.requiresAssistantContentForToolCalls).toBe(true); ++ }); ++ ++ it("requires reasoning_content for tool calls on kimi-k2.5 (opencode-zen)", () => { ++ const compat = detectCompat(openCodeZenModel("kimi-k2.5", true)); ++ expect(compat.requiresReasoningContentForToolCalls).toBe(true); ++ expect(compat.requiresAssistantContentForToolCalls).toBe(true); ++ }); ++ ++ it("requires reasoning_content for tool calls on reasoning DeepSeek models via opencode-go", () => { ++ const compat = detectCompat(openCodeGoModel("deepseek-v4-pro", true)); ++ expect(compat.requiresReasoningContentForToolCalls).toBe(true); ++ expect(compat.requiresAssistantContentForToolCalls).toBe(false); ++ }); ++ ++ it("requires reasoning_content for tool calls on reasoning DeepSeek models via opencode-zen", () => { ++ const compat = detectCompat(openCodeZenModel("deepseek-v4-pro", true)); ++ expect(compat.requiresReasoningContentForToolCalls).toBe(true); ++ expect(compat.requiresAssistantContentForToolCalls).toBe(false); ++ }); + + it("requires reasoning_content when custom openai provider targets opencode zen baseUrl", () => { + const model: Model<"openai-completions"> = { +@@ -328,45 +346,120 @@ describe("opencode reasoning-content compatibility via detectCompat", () => { + expect(compat.requiresReasoningContentForToolCalls).toBe(true); + }); + +- it.each(["opencode-go", "opencode-zen"] as const)( +- "injects reasoning_content placeholder for reasoning DeepSeek tool-call turns via %s", +- provider => { +- const model = openCodeModel(provider, "deepseek-v4-pro", true); +- const compat = detectCompat(model); +- const toolCallMessage: AssistantMessage = { +- role: "assistant", +- content: [{ type: "toolCall", id: `call_ds_${provider}`, name: "web_search", arguments: { query: "hi" } }], +- api: model.api, +- provider: model.provider, +- model: model.id, +- usage: { +- input: 0, +- output: 0, +- cacheRead: 0, +- cacheWrite: 0, +- totalTokens: 0, +- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, ++ it("injects reasoning_content placeholder for reasoning DeepSeek tool-call turns via opencode-go", () => { ++ const model = openCodeGoModel("deepseek-v4-pro", true); ++ const compat = detectCompat(model); ++ const toolCallMessage: AssistantMessage = { ++ role: "assistant", ++ content: [{ type: "toolCall", id: "call_ds_go", name: "web_search", arguments: { query: "hi" } }], ++ api: model.api, ++ provider: model.provider, ++ model: model.id, ++ usage: { ++ input: 0, ++ output: 0, ++ cacheRead: 0, ++ cacheWrite: 0, ++ totalTokens: 0, ++ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, ++ }, ++ stopReason: "toolUse", ++ timestamp: Date.now(), ++ }; ++ const messages = convertMessages(model, { messages: [toolCallMessage] }, compat); ++ const assistant = messages.find(m => m.role === "assistant"); ++ expect(assistant).toBeDefined(); ++ expect(Reflect.get(assistant as object, "reasoning_content")).toBe("."); ++ }); ++ ++ it("injects reasoning_content placeholder for reasoning DeepSeek tool-call turns via opencode-zen", () => { ++ const model = openCodeZenModel("deepseek-v4-pro", true); ++ const compat = detectCompat(model); ++ const toolCallMessage: AssistantMessage = { ++ role: "assistant", ++ content: [{ type: "toolCall", id: "call_ds_zen", name: "web_search", arguments: { query: "hi" } }], ++ api: model.api, ++ provider: model.provider, ++ model: model.id, ++ usage: { ++ input: 0, ++ output: 0, ++ cacheRead: 0, ++ cacheWrite: 0, ++ totalTokens: 0, ++ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, ++ }, ++ stopReason: "toolUse", ++ timestamp: Date.now(), ++ }; ++ const messages = convertMessages(model, { messages: [toolCallMessage] }, compat); ++ const assistant = messages.find(m => m.role === "assistant"); ++ expect(assistant).toBeDefined(); ++ expect(Reflect.get(assistant as object, "reasoning_content")).toBe("."); ++ }); ++ ++ it("injects reasoning_content placeholder when assistant with tool calls has no reasoning field", () => { ++ const model = kimiOpenCodeModel("kimi-k2.5"); ++ const compat = detectCompat(model); ++ const toolCallMessage: AssistantMessage = { ++ role: "assistant", ++ content: [ ++ // Thinking returned as plain text (as kimi-k2.5 on opencode-go does) ++ { type: "text", text: "Let me research this." }, ++ { ++ type: "toolCall", ++ id: "call_abc123", ++ name: "web_search", ++ arguments: { query: "beads gastownhall" }, + }, +- stopReason: "toolUse", +- timestamp: Date.now(), +- }; +- const messages = convertMessages(model, { messages: [toolCallMessage] }, compat); +- const assistant = messages.find(m => m.role === "assistant"); +- expect(assistant).toBeDefined(); +- expect(Reflect.get(assistant as object, "reasoning_content")).toBe("."); +- }, +- ); +- +- it.each(["opencode-go", "opencode-zen"] as const)( +- "does not require reasoning_content when %s model is not reasoning-capable", +- provider => { +- const compat = detectCompat(openCodeModel(provider, "some-other-model", false)); +- expect(compat.requiresReasoningContentForToolCalls).toBe(false); +- }, +- ); +- +- it.each(["kimi-k2.5", "kimi-k1.5", "kimi-k2-5"])("matches kimi model id pattern via opencode-zen: %s", id => { +- const compat = detectCompat(openCodeModel("opencode-zen", id, true)); ++ ], ++ api: model.api, ++ provider: model.provider, ++ model: model.id, ++ usage: { ++ input: 0, ++ output: 0, ++ cacheRead: 0, ++ cacheWrite: 0, ++ totalTokens: 0, ++ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, ++ }, ++ stopReason: "toolUse", ++ timestamp: Date.now(), ++ }; ++ const messages = convertMessages(model, { messages: [toolCallMessage] }, compat); ++ const assistant = messages.find(m => m.role === "assistant"); ++ expect(assistant).toBeDefined(); ++ const reasoningContent = Reflect.get(assistant as object, "reasoning_content"); ++ expect(reasoningContent).toBeDefined(); ++ expect(typeof reasoningContent).toBe("string"); ++ expect((reasoningContent as string).length).toBeGreaterThan(0); ++ }); ++ ++ it("does not inject reasoning_content when model is not kimi", () => { ++ const model: Model<"openai-completions"> = { ++ ...getBundledModel("openai", "gpt-4o-mini"), ++ api: "openai-completions", ++ provider: "opencode-go", ++ baseUrl: "https://opencode.ai/zen/go/v1", ++ id: "some-other-model", ++ }; ++ const compat = detectCompat(model); ++ expect(compat.requiresReasoningContentForToolCalls).toBe(false); ++ }); ++ ++ it("does not require reasoning_content when opencode-go model is not reasoning-capable", () => { ++ const compat = detectCompat(openCodeGoModel("some-other-model", false)); ++ expect(compat.requiresReasoningContentForToolCalls).toBe(false); ++ }); ++ ++ it("does not require reasoning_content when opencode-zen model is not reasoning-capable", () => { ++ const compat = detectCompat(openCodeZenModel("some-other-model", false)); ++ expect(compat.requiresReasoningContentForToolCalls).toBe(false); ++ }); ++ ++ it.each(["kimi-k2.5", "kimi-k1.5", "kimi-k2-5"])("matches kimi model id: %s", id => { ++ const compat = detectCompat(kimiOpenCodeModel(id)); + expect(compat.requiresReasoningContentForToolCalls).toBe(true); + }); + + +From 9c7a8958c682b16990504500551827320508087d Mon Sep 17 00:00:00 2001 +From: sonhyrd +Date: Mon, 27 Apr 2026 00:29:48 +0700 +Subject: [PATCH 4/5] fix(ai/providers): gate reasoning_content stubs on + deepseek models + +--- + .../providers/openai-completions-compat.ts | 7 ++-- + .../ai/src/providers/openai-completions.ts | 4 +-- + .../ai/test/openai-completions-compat.test.ts | 36 +++++++++++++++++++ + 3 files changed, 42 insertions(+), 5 deletions(-) + +diff --git a/packages/ai/src/providers/openai-completions-compat.ts b/packages/ai/src/providers/openai-completions-compat.ts +index b4825a31c..bba1cef70 100644 +--- a/packages/ai/src/providers/openai-completions-compat.ts ++++ b/packages/ai/src/providers/openai-completions-compat.ts +@@ -54,6 +54,7 @@ export function detectOpenAICompat(model: Model<"openai-completions">, resolvedB + const isKimiModel = model.id.includes("moonshotai/kimi") || /^kimi[-.]/i.test(model.id); + const isAlibaba = provider === "alibaba-coding-plan" || baseUrl.includes("dashscope"); + const isQwen = model.id.toLowerCase().includes("qwen"); ++ const isDeepSeekModel = model.id.toLowerCase().includes("deepseek"); + const isOpenRouter = provider === "openrouter" || baseUrl.includes("openrouter.ai"); + const isOpenCode = provider === "opencode-zen" || provider === "opencode-go" || baseUrl.includes("opencode.ai/zen"); + +@@ -109,12 +110,12 @@ export function detectOpenAICompat(model: Model<"openai-completions">, resolvedB + reasoningContentField: "reasoning_content", + // Backends that 400 follow-up requests when prior assistant tool-call turns lack `reasoning_content`: + // - Kimi: documented invariant on its native API and via OpenCode. +- // - Reasoning-capable models reached through OpenRouter or OpenCode (Zen/Go): DeepSeek V4 Pro, +- // Kimi, and similar models can enforce this server-side whenever the request is in thinking mode. ++ // - DeepSeek reasoning models reached through OpenRouter or OpenCode (Zen/Go): enforced when ++ // thinking mode is enabled on those model families. + // We can't translate Anthropic's redacted/encrypted reasoning into DeepSeek's plaintext form, so + // cross-provider continuations rely on a placeholder — see `convertMessages` for injection rules. + requiresReasoningContentForToolCalls: +- isKimiModel || ((isOpenRouter || isOpenCode) && Boolean(model.reasoning)), ++ isKimiModel || (isDeepSeekModel && (isOpenRouter || isOpenCode) && Boolean(model.reasoning)), + requiresAssistantContentForToolCalls: isKimiModel, + openRouterRouting: undefined, + vercelGatewayRouting: undefined, +diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts +index e25aeffb3..89a997a0f 100644 +--- a/packages/ai/src/providers/openai-completions.ts ++++ b/packages/ai/src/providers/openai-completions.ts +@@ -1213,8 +1213,8 @@ export function convertMessages( + // Inject a `reasoning_content` placeholder on assistant tool-call turns when the backend + // rejects history without it. The compat flag captures the rule: + // - Kimi (native or via OpenCode Zen/Go): chat completion endpoint demands the field. +- // - Reasoning models reached through OpenRouter or OpenCode Zen/Go (e.g. DeepSeek V4 Pro): +- // the upstream thinking-mode validator demands it on every prior assistant turn. omp ++ // - DeepSeek reasoning models reached through OpenRouter or OpenCode Zen/Go: the upstream ++ // thinking-mode validator demands it on every prior assistant turn. omp + // cannot synthesize real reasoning when the conversation was warmed up by another + // provider whose reasoning is redacted/encrypted (Anthropic) or simply absent, so we + // emit a placeholder. Real captured reasoning, when present, is preserved earlier via +diff --git a/packages/ai/test/openai-completions-compat.test.ts b/packages/ai/test/openai-completions-compat.test.ts +index 8b8cef393..c083c2151 100644 +--- a/packages/ai/test/openai-completions-compat.test.ts ++++ b/packages/ai/test/openai-completions-compat.test.ts +@@ -333,6 +333,29 @@ describe("kimi model detection via detectCompat", () => { + expect(compat.requiresAssistantContentForToolCalls).toBe(false); + }); + ++ it("does not require reasoning_content for non-DeepSeek reasoning models via opencode-go", () => { ++ const compat = detectCompat(openCodeGoModel("glm-5", true)); ++ expect(compat.requiresReasoningContentForToolCalls).toBe(false); ++ }); ++ ++ it("does not require reasoning_content for non-DeepSeek reasoning models via opencode-zen", () => { ++ const compat = detectCompat(openCodeZenModel("glm-5", true)); ++ expect(compat.requiresReasoningContentForToolCalls).toBe(false); ++ }); ++ ++ it("does not require reasoning_content when custom openai provider targets opencode zen baseUrl with non-DeepSeek model", () => { ++ const model: Model<"openai-completions"> = { ++ ...getBundledModel("openai", "gpt-4o-mini"), ++ api: "openai-completions", ++ provider: "openai", ++ baseUrl: "https://opencode.ai/zen/v1", ++ id: "glm-5", ++ reasoning: true, ++ }; ++ const compat = detectCompat(model); ++ expect(compat.requiresReasoningContentForToolCalls).toBe(false); ++ }); ++ + it("requires reasoning_content when custom openai provider targets opencode zen baseUrl", () => { + const model: Model<"openai-completions"> = { + ...getBundledModel("openai", "gpt-4o-mini"), +@@ -453,6 +476,19 @@ describe("kimi model detection via detectCompat", () => { + expect(compat.requiresReasoningContentForToolCalls).toBe(false); + }); + ++ it("does not require reasoning_content for non-DeepSeek reasoning models via openrouter", () => { ++ const model: Model<"openai-completions"> = { ++ ...getBundledModel("openai", "gpt-4o-mini"), ++ api: "openai-completions", ++ provider: "openrouter", ++ baseUrl: "https://openrouter.ai/api/v1", ++ id: "openai/gpt-4.1-mini", ++ reasoning: true, ++ }; ++ const compat = detectCompat(model); ++ expect(compat.requiresReasoningContentForToolCalls).toBe(false); ++ }); ++ + it("does not require reasoning_content when opencode-zen model is not reasoning-capable", () => { + const compat = detectCompat(openCodeZenModel("some-other-model", false)); + expect(compat.requiresReasoningContentForToolCalls).toBe(false); + +From 53a03286cf658bb4aeab67dad3246b7ba80cf244 Mon Sep 17 00:00:00 2001 +From: sonhyrd +Date: Mon, 27 Apr 2026 00:52:22 +0700 +Subject: [PATCH 5/5] fix(ai/providers): set content when reasoning placeholder + is injected + +--- + packages/ai/src/providers/openai-completions.ts | 3 ++- + packages/ai/test/openai-completions-compat.test.ts | 2 ++ + 2 files changed, 4 insertions(+), 1 deletion(-) + +diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts +index 89a997a0f..b490e254e 100644 +--- a/packages/ai/src/providers/openai-completions.ts ++++ b/packages/ai/src/providers/openai-completions.ts +@@ -1206,7 +1206,7 @@ export function convertMessages( + } + + const toolCalls = msg.content.filter(b => b.type === "toolCall") as ToolCall[]; +- const hasReasoningField = ++ let hasReasoningField = + (assistantMsg as any).reasoning_content !== undefined || + (assistantMsg as any).reasoning !== undefined || + (assistantMsg as any).reasoning_text !== undefined; +@@ -1227,6 +1227,7 @@ export function convertMessages( + if (toolCalls.length > 0 && stubsReasoningContent && !hasReasoningField) { + const reasoningField = compat.reasoningContentField ?? "reasoning_content"; + (assistantMsg as any)[reasoningField] = "."; ++ hasReasoningField = true; + } + if (toolCalls.length > 0) { + assistantMsg.tool_calls = toolCalls.map((tc, toolCallIndex) => { +diff --git a/packages/ai/test/openai-completions-compat.test.ts b/packages/ai/test/openai-completions-compat.test.ts +index c083c2151..8efae899a 100644 +--- a/packages/ai/test/openai-completions-compat.test.ts ++++ b/packages/ai/test/openai-completions-compat.test.ts +@@ -393,6 +393,7 @@ describe("kimi model detection via detectCompat", () => { + const assistant = messages.find(m => m.role === "assistant"); + expect(assistant).toBeDefined(); + expect(Reflect.get(assistant as object, "reasoning_content")).toBe("."); ++ expect(Reflect.get(assistant as object, "content")).toBe(""); + }); + + it("injects reasoning_content placeholder for reasoning DeepSeek tool-call turns via opencode-zen", () => { +@@ -419,6 +420,7 @@ describe("kimi model detection via detectCompat", () => { + const assistant = messages.find(m => m.role === "assistant"); + expect(assistant).toBeDefined(); + expect(Reflect.get(assistant as object, "reasoning_content")).toBe("."); ++ expect(Reflect.get(assistant as object, "content")).toBe(""); + }); + + it("injects reasoning_content placeholder when assistant with tool calls has no reasoning field", () => { diff --git a/patches/omp/0001-openai-completions-retry-without-strict-on-deepseek-openrouter.patch b/patches/omp/0001-openai-completions-retry-without-strict-on-deepseek-openrouter.patch deleted file mode 100644 index ff9429e..0000000 --- a/patches/omp/0001-openai-completions-retry-without-strict-on-deepseek-openrouter.patch +++ /dev/null @@ -1,126 +0,0 @@ -Subject: [PATCH] fix(openai-completions): retry without strict tools for DeepSeek-via-OpenRouter anyOf rejections - -The retry-on-strict-tool-error path in openai-completions failed to recover when -DeepSeek (and similar backends fronted by OpenRouter) reject strict-mode tool -schemas with errors of the form: - - Invalid tool parameters schema : field `anyOf`: missing field `type` - -Two reasons: - -1. Retry only triggered in "all_strict" mode. OpenRouter defaults to "mixed" - (per-tool strict), so the early return prevented retry. -2. The error-message regex required "strict" near "tool". DeepSeek's message - never mentions "strict". - -Fix: -- Allow retry whenever any tool was sent with strict (i.e. mode != "none"). -- Recognize "Invalid tool parameters" in the regex. - -Includes a regression test reproducing the exact DeepSeek error body via -OpenRouter mixed-strict mode. - -Applies cleanly against v14.2.1. - ---- -diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts -index e58189607..3c20631c1 100644 ---- a/packages/ai/src/providers/openai-completions.ts -+++ b/packages/ai/src/providers/openai-completions.ts -@@ -1245,7 +1245,10 @@ function shouldRetryWithoutStrictTools( - toolStrictMode: AppliedToolStrictMode, - tools: Tool[] | undefined, - ): boolean { -- if (!tools || tools.length === 0 || toolStrictMode !== "all_strict") { -+ // Retry whenever any tool was sent with `strict: true`. OpenRouter routes to underlying -+ // providers (e.g. DeepSeek) whose schema validators reject the strict-mode `anyOf` shape -+ // even when omp emitted strict per-tool ("mixed"), not just provider-wide ("all_strict"). -+ if (!tools || tools.length === 0 || toolStrictMode === "none") { - return false; - } - const status = extractHttpStatusFromError(error) ?? capturedErrorResponse?.status; -@@ -1255,7 +1258,14 @@ function shouldRetryWithoutStrictTools( - const messageParts = [error instanceof Error ? error.message : undefined, capturedErrorResponse?.bodyText] - .filter((value): value is string => typeof value === "string" && value.trim().length > 0) - .join("\n"); -- return /wrong_api_format|mixed values for 'strict'|tool[s]?\b.*strict|\bstrict\b.*tool/i.test(messageParts); -+ // Patterns: -+ // - `wrong_api_format`, `mixed values for 'strict'`: OpenAI rejecting mixed strict flags. -+ // - `tool ... strict` / `strict ... tool`: generic strict-tool complaints. -+ // - `Invalid tool parameters schema`: DeepSeek (via OpenRouter) rejecting strict-mode -+ // nullable unions because their validator demands `type` alongside `anyOf`. -+ return /wrong_api_format|mixed values for 'strict'|tool[s]?\b.*strict|\bstrict\b.*tool|invalid tool parameters/i.test( -+ messageParts, -+ ); - } - - function mapStopReason(reason: ChatCompletionChunk.Choice["finish_reason"] | string): { -diff --git a/packages/ai/test/openai-tool-strict-mode.test.ts b/packages/ai/test/openai-tool-strict-mode.test.ts -index 2bf17e6d8..24d5a09d5 100644 ---- a/packages/ai/test/openai-tool-strict-mode.test.ts -+++ b/packages/ai/test/openai-tool-strict-mode.test.ts -@@ -231,6 +231,64 @@ describe("OpenAI tool strict mode", () => { - expect(result.content).toContainEqual({ type: "text", text: "Hello" }); - expect(strictFlags).toEqual([[true], [false]]); - }); -+ it("retries with non-strict tool schemas when OpenRouter backend rejects strict anyOf nullable unions", async () => { -+ // Reproduces deepseek/deepseek-v4-pro via OpenRouter rejecting the strict-mode schema with: -+ // 400 Provider returned error -+ // {"error":{"message":"Invalid tool parameters schema : field `anyOf`: missing field `type`",...}} -+ // OpenRouter is in mixed-strict mode by default (per-tool strict), so the original retry condition -+ // (only "all_strict") prevented recovery. The retry now triggers whenever any tool sent strict=true. -+ const model = getBundledModel("openrouter", "deepseek/deepseek-v3.2") as Model<"openai-completions">; -+ const strictFlags: boolean[][] = []; -+ global.fetch = Object.assign( -+ async (_input: string | URL | Request, init?: RequestInit): Promise => { -+ const bodyText = typeof init?.body === "string" ? init.body : ""; -+ const payload = JSON.parse(bodyText) as { -+ tools?: Array<{ function?: { strict?: boolean } }>; -+ }; -+ strictFlags.push((payload.tools ?? []).map(tool => tool.function?.strict === true)); -+ if (strictFlags.length === 1) { -+ return new Response( -+ JSON.stringify({ -+ error: { -+ message: "Invalid tool parameters schema : field `anyOf`: missing field `type`", -+ type: "invalid_request_error", -+ param: null, -+ code: "invalid_request_error", -+ }, -+ }), -+ { -+ status: 400, -+ headers: { "content-type": "application/json" }, -+ }, -+ ); -+ } -+ return createSseResponse([ -+ { -+ id: "chatcmpl-or", -+ object: "chat.completion.chunk", -+ created: 0, -+ model: model.id, -+ choices: [{ index: 0, delta: { content: "Hello" } }], -+ }, -+ { -+ id: "chatcmpl-or", -+ object: "chat.completion.chunk", -+ created: 0, -+ model: model.id, -+ choices: [{ index: 0, delta: {}, finish_reason: "stop" }], -+ }, -+ "[DONE]", -+ ]); -+ }, -+ { preconnect: originalFetch.preconnect }, -+ ); -+ -+ const result = await streamOpenAICompletions(model, testContext, { apiKey: "test-key" }).result(); -+ expect(result.stopReason).toBe("stop"); -+ expect(result.content).toContainEqual({ type: "text", text: "Hello" }); -+ expect(strictFlags).toEqual([[true], [false]]); -+ }); -+ - - it("sends strict=true for openai-responses tool schemas on OpenAI", async () => { - const model = getBundledModel("openai", "gpt-5-mini") as Model<"openai-responses">; diff --git a/patches/omp/0002-openai-completions-stub-reasoning-content-for-openrouter.patch b/patches/omp/0002-openai-completions-stub-reasoning-content-for-openrouter.patch deleted file mode 100644 index 4dc0eb3..0000000 --- a/patches/omp/0002-openai-completions-stub-reasoning-content-for-openrouter.patch +++ /dev/null @@ -1,233 +0,0 @@ -Subject: [PATCH] fix(openai-completions): require `reasoning_content` for OpenRouter reasoning models - -DeepSeek V4 Pro (and similar reasoning models reached via OpenRouter) reject -multi-turn requests in thinking mode with: - - 400 The `reasoning_content` in the thinking mode must be passed back to - the API. - -omp's existing kimi placeholder injection (`requiresReasoningContentForToolCalls`) -covered this requirement only for `thinkingFormat === "openai"`. OpenRouter -sets `thinkingFormat === "openrouter"`, so the gate never fired even though -the underlying providers behind OpenRouter (DeepSeek, Kimi, etc.) all enforce -the same invariant. - -This patch: - -1. Extends `requiresReasoningContentForToolCalls` detection: any - reasoning-capable model fronted by OpenRouter now sets the flag. -2. Extends the placeholder gate in `convertMessages` to accept - `thinkingFormat === "openrouter"` alongside `"openai"`. - -Cross-provider continuations are the dominant trigger: a conversation warmed -up by Anthropic Claude (whose reasoning is redacted/encrypted on the wire) -followed by a switch to DeepSeek V4 Pro via OpenRouter. omp cannot -synthesize plaintext `reasoning_content` from Anthropic's encrypted blocks, -so the placeholder satisfies DeepSeek's validator without fabricating a -reasoning trace. Real captured reasoning, when present, short-circuits the -placeholder via `hasReasoningField` and survives intact. - -Side benefit: also closes a latent gap where Kimi-via-OpenRouter -(`thinkingFormat === "openrouter"`) had the compat flag set but the -placeholder gate silently rejected it. - -Regression tests cover: - - compat flag detection on OpenRouter reasoning models - - opt-out for non-reasoning OpenRouter models - - cross-provider redacted-thinking placeholder - - Kimi-via-OpenRouter placeholder firing - - real reasoning preserved over the placeholder - -Applies cleanly on top of patch 0001. - ---- -diff --git a/packages/ai/src/providers/openai-completions-compat.ts b/packages/ai/src/providers/openai-completions-compat.ts ---- a/packages/ai/src/providers/openai-completions-compat.ts -+++ b/packages/ai/src/providers/openai-completions-compat.ts -@@ -105,7 +105,14 @@ - ? "qwen" - : "openai", - reasoningContentField: "reasoning_content", -- requiresReasoningContentForToolCalls: isKimiModel, -+ // Backends that 400 follow-up requests when prior assistant tool-call turns lack `reasoning_content`: -+ // - Kimi: documented invariant on its native API and via OpenCode-Go. -+ // - Any reasoning-capable model reached through OpenRouter: DeepSeek V4 Pro and similar enforce -+ // this server-side whenever the request is in thinking mode. We can't translate Anthropic's -+ // redacted/encrypted reasoning into DeepSeek's plaintext form, so cross-provider continuations -+ // rely on a placeholder — see `convertMessages` for the placeholder injection. -+ requiresReasoningContentForToolCalls: -+ isKimiModel || ((provider === "openrouter" || baseUrl.includes("openrouter.ai")) && Boolean(model.reasoning)), - requiresAssistantContentForToolCalls: isKimiModel, - openRouterRouting: undefined, - vercelGatewayRouting: undefined, -diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts ---- a/packages/ai/src/providers/openai-completions.ts -+++ b/packages/ai/src/providers/openai-completions.ts -@@ -1059,12 +1059,21 @@ - (assistantMsg as any).reasoning_content !== undefined || - (assistantMsg as any).reasoning !== undefined || - (assistantMsg as any).reasoning_text !== undefined; -- if ( -- toolCalls.length > 0 && -+ // Inject a `reasoning_content` placeholder on assistant tool-call turns when the backend -+ // rejects history without it. The compat flag captures the rule: -+ // - Kimi (native or via OpenCode-Go): chat completion endpoint demands the field. -+ // - Reasoning models reached through OpenRouter (e.g. DeepSeek V4 Pro): the underlying -+ // provider's thinking-mode validator demands it on every prior assistant turn. omp -+ // cannot synthesize real reasoning when the conversation was warmed up by another -+ // provider whose reasoning is redacted/encrypted (Anthropic) or simply absent, so we -+ // emit a placeholder. Real captured reasoning, when present, is preserved earlier via -+ // the `thinkingSignature` echo path and short-circuits via `hasReasoningField`. -+ // `thinkingFormat` is gated to formats that consume the field (openai/openrouter chat -+ // completions); formats with their own conventions (zai, qwen) are excluded. -+ const stubsReasoningContent = - compat.requiresReasoningContentForToolCalls && -- compat.thinkingFormat === "openai" && -- !hasReasoningField -- ) { -+ (compat.thinkingFormat === "openai" || compat.thinkingFormat === "openrouter"); -+ if (toolCalls.length > 0 && stubsReasoningContent && !hasReasoningField) { - const reasoningField = compat.reasoningContentField ?? "reasoning_content"; - (assistantMsg as any)[reasoningField] = "."; - } -diff --git a/packages/ai/test/openai-completions-compat.test.ts b/packages/ai/test/openai-completions-compat.test.ts ---- a/packages/ai/test/openai-completions-compat.test.ts -+++ b/packages/ai/test/openai-completions-compat.test.ts -@@ -367,4 +367,137 @@ - const compat = detectCompat(model); - expect(compat.requiresReasoningContentForToolCalls).toBe(true); - }); -+ -+ it("requires reasoning_content for tool calls on reasoning-capable models via OpenRouter", () => { -+ const model: Model<"openai-completions"> = { -+ ...(getBundledModel("openrouter", "deepseek/deepseek-v3.2") as Model<"openai-completions">), -+ reasoning: true, -+ }; -+ const compat = detectCompat(model); -+ expect(compat.thinkingFormat).toBe("openrouter"); -+ expect(compat.requiresReasoningContentForToolCalls).toBe(true); -+ }); -+ -+ it("does not require reasoning_content for non-reasoning OpenRouter models", () => { -+ const model: Model<"openai-completions"> = { -+ ...(getBundledModel("openrouter", "deepseek/deepseek-v3.2") as Model<"openai-completions">), -+ reasoning: false, -+ }; -+ const compat = detectCompat(model); -+ expect(compat.requiresReasoningContentForToolCalls).toBe(false); -+ }); -+ -+ it("injects reasoning_content placeholder for OpenRouter reasoning models lacking captured reasoning", () => { -+ // Reproduces the failing path from real usage: a conversation generated under Anthropic Claude (whose -+ // reasoning is redacted/encrypted) is continued with deepseek/deepseek-v4-pro via OpenRouter. The -+ // prior assistant turns persist as ThinkingContent blocks with empty `thinking` text plus an opaque -+ // Anthropic signature cookie. omp cannot translate that into DeepSeek's plain-text `reasoning_content`, -+ // so the empty thinking block is filtered out and the placeholder fires — satisfying DeepSeek's -+ // thinking-mode validator without fabricating a reasoning trace. -+ const model: Model<"openai-completions"> = { -+ ...(getBundledModel("openrouter", "deepseek/deepseek-v3.2") as Model<"openai-completions">), -+ reasoning: true, -+ }; -+ const compat = detectCompat(model); -+ const toolCallMessage: AssistantMessage = { -+ role: "assistant", -+ content: [ -+ // Anthropic-style redacted thinking block: empty text plus opaque signature. -+ // `thinking.trim().length === 0` filters this out before the signature echo can fire. -+ { type: "thinking", thinking: "", thinkingSignature: "Ep4CClkIDRgCKkDOpaqueAnthropicCookie" }, -+ { type: "toolCall", id: "call_anth_to_ds", name: "web_search", arguments: { query: "hi" } }, -+ ], -+ api: model.api, -+ provider: model.provider, -+ model: model.id, -+ usage: { -+ input: 0, -+ output: 0, -+ cacheRead: 0, -+ cacheWrite: 0, -+ totalTokens: 0, -+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, -+ }, -+ stopReason: "toolUse", -+ timestamp: Date.now(), -+ }; -+ const messages = convertMessages(model, { messages: [toolCallMessage] }, compat); -+ const assistant = messages.find(m => m.role === "assistant"); -+ expect(assistant).toBeDefined(); -+ expect(Reflect.get(assistant as object, "reasoning_content")).toBe("."); -+ }); -+ -+ it("injects reasoning_content placeholder for kimi-k2-5 via OpenRouter (closes the kimi-via-openrouter gap)", () => { -+ // Before this fix, `requiresReasoningContentForToolCalls` was true for Kimi via OpenRouter but the -+ // stub gate only fired when `thinkingFormat === "openai"`. OpenRouter sets thinkingFormat="openrouter", -+ // so the stub silently never fired and Kimi-via-OpenRouter conversations 400'd the same way. -+ const model: Model<"openai-completions"> = { -+ ...getBundledModel("openai", "gpt-4o-mini"), -+ api: "openai-completions", -+ provider: "openrouter", -+ baseUrl: "https://openrouter.ai/api/v1", -+ id: "moonshotai/kimi-k2-5", -+ reasoning: true, -+ }; -+ const compat = detectCompat(model); -+ const toolCallMessage: AssistantMessage = { -+ role: "assistant", -+ content: [ -+ { type: "toolCall", id: "call_kimi_or", name: "web_search", arguments: { query: "hi" } }, -+ ], -+ api: model.api, -+ provider: model.provider, -+ model: model.id, -+ usage: { -+ input: 0, -+ output: 0, -+ cacheRead: 0, -+ cacheWrite: 0, -+ totalTokens: 0, -+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, -+ }, -+ stopReason: "toolUse", -+ timestamp: Date.now(), -+ }; -+ const messages = convertMessages(model, { messages: [toolCallMessage] }, compat); -+ const assistant = messages.find(m => m.role === "assistant"); -+ expect(assistant).toBeDefined(); -+ expect(Reflect.get(assistant as object, "reasoning_content")).toBe("."); -+ }); -+ -+ it("preserves real captured reasoning over the placeholder when the assistant has non-empty thinking", () => { -+ // Sanity check: the placeholder must not overwrite real reasoning. When the prior assistant turn was -+ // generated by the same provider and surfaces plaintext reasoning, the existing thinkingSignature -+ // echo path sets `reasoning_content` first, and `hasReasoningField` short-circuits the stub. -+ const model: Model<"openai-completions"> = { -+ ...(getBundledModel("openrouter", "deepseek/deepseek-v3.2") as Model<"openai-completions">), -+ reasoning: true, -+ }; -+ const compat = detectCompat(model); -+ const toolCallMessage: AssistantMessage = { -+ role: "assistant", -+ content: [ -+ { type: "thinking", thinking: "Step 1: read the file. Step 2: search.", thinkingSignature: "reasoning_content" }, -+ { type: "toolCall", id: "call_real", name: "web_search", arguments: { query: "hi" } }, -+ ], -+ api: model.api, -+ provider: model.provider, -+ model: model.id, -+ usage: { -+ input: 0, -+ output: 0, -+ cacheRead: 0, -+ cacheWrite: 0, -+ totalTokens: 0, -+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, -+ }, -+ stopReason: "toolUse", -+ timestamp: Date.now(), -+ }; -+ const messages = convertMessages(model, { messages: [toolCallMessage] }, compat); -+ const assistant = messages.find(m => m.role === "assistant"); -+ expect(assistant).toBeDefined(); -+ expect(Reflect.get(assistant as object, "reasoning_content")).toBe("Step 1: read the file. Step 2: search."); -+ }); -+ - });