Files
nixos/patches/omp/0001-fix-reasoning_content.patch
Simon Gardling bbdc478e84
All checks were successful
Build and Deploy / mreow (push) Successful in 13m8s
Build and Deploy / yarn (push) Successful in 1m11s
Build and Deploy / muffin (push) Successful in 7m15s
omp: update patches
2026-04-27 01:36:08 -04:00

805 lines
37 KiB
Diff

From e145b627cffb6907e6bde348f1318f48acba3801 Mon Sep 17 00:00:00 2001
From: sonhyrd <son.hong.do@hyrd.ai>
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 <son.hong.do@hyrd.ai>
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 <son.hong.do@hyrd.ai>
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 <son.hong.do@hyrd.ai>
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 <son.hong.do@hyrd.ai>
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", () => {