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", () => {