* Repro for #827 — `opencode-go/kimi-k2.6` returns 400 with
* `tool_choice 'specified' is incompatible with thinking enabled`
* whenever the agent forces a tool call while reasoning is on.
*
* The fix follows the Anthropic pattern (`disableThinkingIfToolChoiceForced`)
* — when a forced tool_choice is sent to a Kimi reasoning model, we strip
* reasoning for that single turn rather than dropping `tool_choice` outright.
*/
import { afterEach, describe, expect, it } from "bun:test";
import { getBundledModel } from "@oh-my-pi/pi-ai/models";
import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions";
import type { Context, Model, Tool } from "@oh-my-pi/pi-ai/types";
import * as z from "zod/v4";
const originalFetch = global.fetch;
afterEach(() => {
global.fetch = originalFetch;
});
const echoTool: Tool = {
name: "echo",
description: "Echo input",
parameters: z.object({ text: z.string() }),
};
const ctx: Context = {
messages: [{ role: "user", content: "do it", timestamp: Date.now() }],
tools: [echoTool],
};
function abortedSignal(): AbortSignal {
const controller = new AbortController();
controller.abort();
return controller.signal;
}
function kimiOpencodeGoModel(): Model<"openai-completions"> {
return {
...getBundledModel("openai", "gpt-4o-mini"),
api: "openai-completions",
provider: "opencode-go",
baseUrl: "https://opencode.ai/zen/v1",
id: "kimi-k2.6",
name: "Kimi K2.6",
reasoning: true,
};
}
function kimiOpenRouterModel(): Model<"openai-completions"> {
return {
...getBundledModel("openai", "gpt-4o-mini"),
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
id: "moonshotai/kimi-k2",
name: "Kimi K2 (OpenRouter)",
reasoning: true,
};
}
function captureBody(
model: Model<"openai-completions">,
opts: Parameters<typeof streamOpenAICompletions>[2],
): Promise<unknown> {
const { promise, resolve } = Promise.withResolvers<unknown>();
streamOpenAICompletions(model, ctx, {
...opts,
apiKey: "test-key",
signal: abortedSignal(),
onPayload: payload => resolve(payload),
});
return promise;
}
interface CompletionsBody {
tool_choice?: unknown;
tools?: unknown[];
reasoning_effort?: unknown;
reasoning?: unknown;
thinking?: unknown;
}
describe("issue #827 — kimi reasoning models drop reasoning under forced tool_choice", () => {
it("strips reasoning_effort when toolChoice is forced on direct Kimi (Moonshot-style id)", async () => {
const body = (await captureBody(kimiOpencodeGoModel(), {
reasoning: "high",
toolChoice: "any",
})) as CompletionsBody;
expect(body.tool_choice).toBe("required");
expect(body.reasoning_effort).toBeUndefined();
});
it("preserves reasoning_effort when toolChoice is auto", async () => {
const body = (await captureBody(kimiOpencodeGoModel(), {
reasoning: "high",
toolChoice: "auto",
})) as CompletionsBody;
expect(body.tool_choice).toBe("auto");
expect(body.reasoning_effort).toBe("high");
});
it("strips OpenRouter-shaped reasoning object on forced toolChoice for Kimi via OpenRouter", async () => {
const body = (await captureBody(kimiOpenRouterModel(), {
reasoning: "high",
toolChoice: { type: "tool", name: "echo" },
})) as CompletionsBody;
expect(body.tool_choice).toMatchObject({ type: "function", function: { name: "echo" } });
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 () => {
const model: Model<"openai-completions"> = {
...getBundledModel("openai", "gpt-4o-mini"),
api: "openai-completions",
provider: "litellm",
baseUrl: "http://localhost:4000/v1",
id: "claude-sonnet-4-6",
name: "Claude Sonnet 4.6 (LiteLLM)",
reasoning: true,
};
const body = (await captureBody(model, {
reasoning: "high",
toolChoice: "any",
})) as CompletionsBody;
expect(body.tool_choice).toBe("required");
expect(body.reasoning_effort).toBeUndefined();
});
it("does not strip reasoning on non-Kimi models even with forced tool_choice", async () => {
const model: Model<"openai-completions"> = {
...getBundledModel("openai", "gpt-4o-mini"),
api: "openai-completions",
id: "gpt-5-mini",
reasoning: true,
};
const body = (await captureBody(model, {
reasoning: "high",
toolChoice: "any",
})) as CompletionsBody;
expect(body.tool_choice).toBe("required");
expect(body.reasoning_effort).toBe("high");
});
});