From 4faac05419befa45e051c539e8f712b13a76758a Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Fri, 29 May 2026 12:21:37 +0200 Subject: [PATCH] fix(ai): handle OpenCode reasoning params --- packages/ai/CHANGELOG.md | 4 +++ packages/ai/README.md | 2 +- packages/ai/scripts/generate-models.ts | 15 ++++++-- .../ai/src/providers/openai-completions.ts | 2 +- packages/ai/src/types.ts | 2 +- .../openai-completions-tool-choice.test.ts | 34 +++++++++++++++---- packages/ai/test/supports-xhigh.test.ts | 12 +++++++ 7 files changed, 60 insertions(+), 11 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 7266c79b..6b7b6542 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed OpenCode Go Kimi K2.6 thinking requests to send `thinking` objects instead of invalid string values, and fixed OpenCode Zen Grok Build thinking requests to omit unsupported `reasoning_effort` ([#5169](https://github.com/earendil-works/pi/issues/5169)). + ## [0.77.0] - 2026-05-28 ### Added diff --git a/packages/ai/README.md b/packages/ai/README.md index 1790a64c..e6624d3b 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -938,7 +938,7 @@ interface OpenAICompletionsCompat { requiresAssistantAfterToolResult?: boolean; // Whether tool results must be followed by an assistant message (default: false) requiresThinkingAsText?: boolean; // Whether thinking blocks must be converted to text (default: false) requiresReasoningContentOnAssistantMessages?: boolean; // Whether all replayed assistant messages must include empty reasoning_content when reasoning is enabled (default: auto-detected for DeepSeek) - thinkingFormat?: 'openai' | 'openrouter' | 'deepseek' | 'together' | 'zai' | 'qwen' | 'qwen-chat-template'; // Format for reasoning param: 'openai' uses reasoning_effort, 'openrouter' uses reasoning: { effort }, 'deepseek' uses thinking: { type } plus reasoning_effort, 'together' uses reasoning: { enabled } plus reasoning_effort when supported, 'zai' uses enable_thinking, 'qwen' uses enable_thinking, 'qwen-chat-template' uses chat_template_kwargs.enable_thinking (default: openai) + thinkingFormat?: 'openai' | 'openrouter' | 'deepseek' | 'together' | 'zai' | 'qwen' | 'qwen-chat-template'; // Format for reasoning param: 'openai' uses reasoning_effort, 'openrouter' uses reasoning: { effort }, 'deepseek' uses thinking: { type } plus reasoning_effort when supported, 'together' uses reasoning: { enabled } plus reasoning_effort when supported, 'zai' uses enable_thinking, 'qwen' uses enable_thinking, 'qwen-chat-template' uses chat_template_kwargs.enable_thinking (default: openai) cacheControlFormat?: 'anthropic'; // Anthropic-style cache_control on system prompt, last tool, and last user/assistant text content openRouterRouting?: OpenRouterRouting; // OpenRouter routing preferences (default: {}) vercelGatewayRouting?: VercelGatewayRouting; // Vercel AI Gateway routing preferences (default: {}) diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index a8853ac5..b95089ce 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -275,7 +275,12 @@ function applyThinkingLevelMetadata(model: Model): void { mergeThinkingLevelMap(model, { off: null }); } if (model.provider === "opencode-go" && model.id === "kimi-k2.6") { - mergeThinkingLevelMap(model, { off: "none" }); + // OpenCode Go exposes Kimi K2.6 thinking as on/off, not distinct effort tiers. + mergeThinkingLevelMap(model, { minimal: null, low: null, medium: null }); + } + if (model.provider === "opencode" && model.id === "grok-build-0.1") { + // OpenCode Zen Grok Build reasons by default but rejects explicit reasoningEffort. + mergeThinkingLevelMap(model, { off: null, minimal: null, low: null, medium: null }); } } @@ -896,6 +901,10 @@ async function loadModelsDevData(): Promise[]> { baseUrl = `${variant.basePath}/v1`; } + if (variant.provider === "opencode" && modelId === "grok-build-0.1") { + compat = { ...(compat ?? {}), supportsReasoningEffort: false }; + } + // Fix known mismatches between models.dev npm data and actual // OpenCode Go endpoint behaviour. models.dev reports these models // as @ai-sdk/anthropic, but the OpenCode Go endpoints either don't @@ -909,7 +918,9 @@ async function loadModelsDevData(): Promise[]> { baseUrl = `${variant.basePath}/v1`; } if (modelId === "kimi-k2.6") { - compat = { ...(compat ?? {}), thinkingFormat: "string-thinking" }; + // OpenCode Go Kimi K2.6 accepts Anthropic-style thinking objects + // and rejects string thinking values or combined reasoning_effort. + compat = { ...(compat ?? {}), thinkingFormat: "deepseek", supportsReasoningEffort: false }; } if (modelId === "qwen3.5-plus" || modelId === "qwen3.6-plus") { api = "openai-completions"; diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index 2e243fb7..6834d1e7 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -571,7 +571,7 @@ function buildParams( }; } else if (compat.thinkingFormat === "deepseek" && model.reasoning) { (params as any).thinking = { type: options?.reasoningEffort ? "enabled" : "disabled" }; - if (options?.reasoningEffort) { + if (options?.reasoningEffort && compat.supportsReasoningEffort) { (params as any).reasoning_effort = model.thinkingLevelMap?.[options.reasoningEffort] ?? options.reasoningEffort; } diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index bf997317..9475d26c 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -387,7 +387,7 @@ export interface OpenAICompletionsCompat { requiresThinkingAsText?: boolean; /** Whether all replayed assistant messages must include an empty reasoning_content field when reasoning is enabled. Default: auto-detected from URL. */ requiresReasoningContentOnAssistantMessages?: boolean; - /** Format for reasoning/thinking parameter. "openai" uses reasoning_effort, "openrouter" uses reasoning: { effort }, "deepseek" uses thinking: { type } plus reasoning_effort, "together" uses reasoning: { enabled } plus reasoning_effort when supported, "zai" uses top-level enable_thinking: boolean, "qwen" uses top-level enable_thinking: boolean, "qwen-chat-template" uses chat_template_kwargs.enable_thinking, and "string-thinking" uses top-level thinking: string. Default: "openai". */ + /** Format for reasoning/thinking parameter. "openai" uses reasoning_effort, "openrouter" uses reasoning: { effort }, "deepseek" uses thinking: { type } plus reasoning_effort when supported, "together" uses reasoning: { enabled } plus reasoning_effort when supported, "zai" uses top-level enable_thinking: boolean, "qwen" uses top-level enable_thinking: boolean, "qwen-chat-template" uses chat_template_kwargs.enable_thinking, and "string-thinking" uses top-level thinking: string. Default: "openai". */ thinkingFormat?: | "openai" | "openrouter" diff --git a/packages/ai/test/openai-completions-tool-choice.test.ts b/packages/ai/test/openai-completions-tool-choice.test.ts index 66cfb562..81dfb1d4 100644 --- a/packages/ai/test/openai-completions-tool-choice.test.ts +++ b/packages/ai/test/openai-completions-tool-choice.test.ts @@ -995,7 +995,7 @@ describe("openai-completions tool_choice", () => { expect(messages[0]).not.toHaveProperty("reasoning"); }); - it("sends thinking none for OpenCode Go Kimi K2.6 when thinking is off", async () => { + it("sends thinking disabled for OpenCode Go Kimi K2.6 when thinking is off", async () => { const model = getModel("opencode-go", "kimi-k2.6")!; let payload: unknown; @@ -1012,12 +1012,12 @@ describe("openai-completions tool_choice", () => { }, ).result(); - const params = (payload ?? mockState.lastParams) as { thinking?: string; reasoning_effort?: string }; - expect(params.thinking).toBe("none"); + const params = (payload ?? mockState.lastParams) as { thinking?: unknown; reasoning_effort?: string }; + expect(params.thinking).toEqual({ type: "disabled" }); expect(params.reasoning_effort).toBeUndefined(); }); - it("sends thinking effort for OpenCode Go Kimi K2.6 when thinking is enabled", async () => { + it("sends thinking enabled for OpenCode Go Kimi K2.6 when thinking is enabled", async () => { const model = getModel("opencode-go", "kimi-k2.6")!; let payload: unknown; @@ -1035,8 +1035,30 @@ describe("openai-completions tool_choice", () => { }, ).result(); - const params = (payload ?? mockState.lastParams) as { thinking?: string; reasoning_effort?: string }; - expect(params.thinking).toBe("high"); + const params = (payload ?? mockState.lastParams) as { thinking?: unknown; reasoning_effort?: string }; + expect(params.thinking).toEqual({ type: "enabled" }); + expect(params.reasoning_effort).toBeUndefined(); + }); + + it("omits reasoning effort for OpenCode Grok Build", async () => { + const model = getModel("opencode", "grok-build-0.1")!; + let payload: unknown; + + await streamSimple( + model, + { + messages: [{ role: "user", content: "Hi", timestamp: Date.now() }], + }, + { + apiKey: "test", + reasoning: "high", + onPayload: (params: unknown) => { + payload = params; + }, + }, + ).result(); + + const params = (payload ?? mockState.lastParams) as { reasoning_effort?: string }; expect(params.reasoning_effort).toBeUndefined(); }); diff --git a/packages/ai/test/supports-xhigh.test.ts b/packages/ai/test/supports-xhigh.test.ts index 5d9bdd9e..d5ec3b5b 100644 --- a/packages/ai/test/supports-xhigh.test.ts +++ b/packages/ai/test/supports-xhigh.test.ts @@ -56,6 +56,18 @@ describe("getSupportedThinkingLevels", () => { expect(getSupportedThinkingLevels(model!)).toEqual(["off", "high", "xhigh"]); }); + it("includes only high plus off for OpenCode Go Kimi K2.6", () => { + const model = getModel("opencode-go", "kimi-k2.6"); + expect(model).toBeDefined(); + expect(getSupportedThinkingLevels(model!)).toEqual(["off", "high"]); + }); + + it("includes only high for OpenCode Grok Build", () => { + const model = getModel("opencode", "grok-build-0.1"); + expect(model).toBeDefined(); + expect(getSupportedThinkingLevels(model!)).toEqual(["high"]); + }); + it("includes only high/xhigh plus off for DeepSeek V4 Flash on OpenRouter", () => { const model = getModel("openrouter", "deepseek/deepseek-v4-flash"); expect(model).toBeDefined();