From 0bf280d0791b050e18aa1a8a2cec5603f46a1a7e Mon Sep 17 00:00:00 2001 From: shoucandanghehe Date: Fri, 15 May 2026 02:29:14 +0800 Subject: [PATCH 1/2] fix(ai): disable Kimi thinking for forced tools --- packages/ai/CHANGELOG.md | 3 +++ .../providers/openai-completions-compat.ts | 21 ++++++++++++------- .../ai/src/providers/openai-completions.ts | 10 +++++---- packages/ai/src/types.ts | 2 +- packages/ai/test/issue-827-repro.test.ts | 20 ++++++++++++++++++ 5 files changed, 44 insertions(+), 12 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index e8fbc0668..fac5e69aa 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,9 @@ ## [Unreleased] +### Fixed +- Fixed Moonshot Kimi K2.6 forced tool calls to send `thinking: { type: "disabled" }`, avoiding `tool_choice 'specified' is incompatible with thinking enabled` 400s while preserving the requested named tool ([#1077](https://github.com/can1357/oh-my-pi/issues/1077)). + ## [15.0.1] - 2026-05-14 ### Breaking Changes diff --git a/packages/ai/src/providers/openai-completions-compat.ts b/packages/ai/src/providers/openai-completions-compat.ts index 8b4b706d6..0e145247d 100644 --- a/packages/ai/src/providers/openai-completions-compat.ts +++ b/packages/ai/src/providers/openai-completions-compat.ts @@ -53,6 +53,12 @@ export function detectOpenAICompat(model: Model<"openai-completions">, resolvedB const isZai = provider === "zai" || baseUrl.includes("api.z.ai"); const isKilo = provider === "kilo" || baseUrl.includes("api.kilo.ai"); const isKimiModel = model.id.includes("moonshotai/kimi") || /^kimi[-.]/i.test(model.id); + const isMoonshotKimi = + isKimiModel && + (provider === "moonshot" || + provider === "kimi-code" || + baseUrl.includes("api.moonshot.ai") || + baseUrl.includes("api.kimi.com")); const isAnthropicModel = provider === "anthropic" || baseUrl.includes("api.anthropic.com") || @@ -173,13 +179,14 @@ export function detectOpenAICompat(model: Model<"openai-completions">, resolvedB requiresAssistantAfterToolResult: false, requiresThinkingAsText: isMistral, requiresMistralToolIds: isMistral, - thinkingFormat: isZai - ? "zai" - : provider === "openrouter" || baseUrl.includes("openrouter.ai") - ? "openrouter" - : isAlibaba || isQwen - ? "qwen" - : "openai", + thinkingFormat: + isZai || isMoonshotKimi + ? "zai" + : provider === "openrouter" || baseUrl.includes("openrouter.ai") + ? "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. diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index 86f4c798a..ceedac35e 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -1019,12 +1019,14 @@ function buildParams( } if (compat.disableReasoningOnForcedToolChoice && isForcedToolChoice(params.tool_choice)) { - // Mirrors anthropic.ts:disableThinkingIfToolChoiceForced — backends like - // Kimi 400 with `tool_choice 'specified' is incompatible with thinking - // enabled`. Drop reasoning for this turn instead of dropping tool_choice; - // the agent still gets the forced tool call, just without thinking. + // Backends like Kimi 400 with `tool_choice 'specified' is incompatible + // with thinking enabled`. Suppress thinking for this single forced-tool + // turn while keeping the tool-selection contract intact. delete params.reasoning_effort; delete params.reasoning; + if (compat.thinkingFormat === "zai") { + params.thinking = { type: "disabled" }; + } } // OpenRouter provider routing preferences diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 07b6ddee9..b2ef0871c 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -613,7 +613,7 @@ export interface OpenAICompat { requiresThinkingAsText?: boolean; /** Whether tool call IDs must be normalized to Mistral format (exactly 9 alphanumeric chars). Default: auto-detected from URL. */ requiresMistralToolIds?: boolean; - /** Format for reasoning/thinking parameter. "openai" uses reasoning_effort, "openrouter" uses reasoning: { effort }, "zai" uses thinking: { type: "enabled" }, "qwen" uses top-level enable_thinking, and "qwen-chat-template" uses chat_template_kwargs.enable_thinking. Default: "openai". */ + /** Format for reasoning/thinking parameter. "openai" uses reasoning_effort, "openrouter" uses reasoning: { effort }, "zai" uses thinking: { type: "enabled" | "disabled" } (also used by Moonshot Kimi), "qwen" uses top-level enable_thinking, and "qwen-chat-template" uses chat_template_kwargs.enable_thinking. Default: "openai". */ thinkingFormat?: "openai" | "openrouter" | "zai" | "qwen" | "qwen-chat-template"; /** Which reasoning content field to emit on assistant messages. Default: auto-detected. */ reasoningContentField?: "reasoning_content" | "reasoning" | "reasoning_text"; diff --git a/packages/ai/test/issue-827-repro.test.ts b/packages/ai/test/issue-827-repro.test.ts index 7f9004b93..ace904a8d 100644 --- a/packages/ai/test/issue-827-repro.test.ts +++ b/packages/ai/test/issue-827-repro.test.ts @@ -79,6 +79,7 @@ interface CompletionsBody { tools?: unknown[]; reasoning_effort?: unknown; reasoning?: unknown; + thinking?: unknown; } describe("issue #827 — kimi reasoning models drop reasoning under forced tool_choice", () => { @@ -114,6 +115,25 @@ describe("issue #827 — kimi reasoning models drop reasoning under forced tool_ expect(body.reasoning).toBeUndefined(); expect(body.reasoning_effort).toBeUndefined(); }); + it("sends explicit thinking disabled for Moonshot Kimi K2.6 when a named tool is forced", async () => { + const model: Model<"openai-completions"> = { + ...getBundledModel("openai", "gpt-4o-mini"), + api: "openai-completions", + provider: "moonshot", + baseUrl: "https://api.moonshot.ai/v1", + id: "kimi-k2.6", + name: "Kimi K2.6", + reasoning: false, + }; + const body = (await captureBody(model, { + toolChoice: { type: "tool", name: "echo" }, + })) as CompletionsBody; + + expect(body.tool_choice).toMatchObject({ type: "function", function: { name: "echo" } }); + expect(body.thinking).toEqual({ type: "disabled" }); + expect(body.reasoning).toBeUndefined(); + expect(body.reasoning_effort).toBeUndefined(); + }); it("strips reasoning_effort for Anthropic Claude models served via openai-completions (e.g. LiteLLM/OpenRouter proxies)", async () => { // LiteLLM / Vertex proxies often expose Claude through chat-completions; Anthropic From 8b4b73e3c408df361d8ea729ff89a0f1fa5a9e3e Mon Sep 17 00:00:00 2001 From: shoucandanghehe Date: Fri, 15 May 2026 02:46:44 +0800 Subject: [PATCH 2/2] fix(ai): replay Kimi reasoning placeholder --- .../ai/src/providers/openai-completions.ts | 4 +- .../ai/test/openai-completions-compat.test.ts | 43 +++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index ceedac35e..e878e4f1f 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -1364,7 +1364,9 @@ export function convertMessages( const canUseSyntheticReasoningContent = compat.requiresReasoningContentForToolCalls && compat.allowsSyntheticReasoningContentForToolCalls && - (compat.thinkingFormat === "openai" || compat.thinkingFormat === "openrouter"); + (compat.thinkingFormat === "openai" || + compat.thinkingFormat === "openrouter" || + compat.thinkingFormat === "zai"); // DeepSeek reasoning models require reasoning_content on ALL assistant turns, // not just tool-call turns. Other providers (Kimi, OpenRouter) only require it // on tool-call turns. diff --git a/packages/ai/test/openai-completions-compat.test.ts b/packages/ai/test/openai-completions-compat.test.ts index 96018b8cc..55bd3cb97 100644 --- a/packages/ai/test/openai-completions-compat.test.ts +++ b/packages/ai/test/openai-completions-compat.test.ts @@ -526,6 +526,49 @@ describe("kimi model detection via detectCompat", () => { expect((reasoningContent as string).length).toBeGreaterThan(0); }); + it("injects reasoning_content placeholder for direct Moonshot Kimi after thinking-disabled forced tool calls", () => { + const model: Model<"openai-completions"> = { + ...getBundledModel("openai", "gpt-4o-mini"), + api: "openai-completions", + provider: "moonshot", + baseUrl: "https://api.moonshot.ai/v1", + id: "kimi-k2.6", + reasoning: false, + }; + const compat = detectCompat(model); + const toolCallMessage: AssistantMessage = { + role: "assistant", + content: [ + { + type: "toolCall", + id: "call_abc123", + name: "resolve", + arguments: { action: "apply", reason: "approved" }, + }, + ], + 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(), + }; + + expect(compat.thinkingFormat).toBe("zai"); + expect(compat.requiresReasoningContentForToolCalls).toBe(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("does not inject reasoning_content when model is not kimi", () => { const model: Model<"openai-completions"> = { ...getBundledModel("openai", "gpt-4o-mini"),