feat(ai): use Chat Completions for the kimi-coding provider

Switch kimi-coding to OpenAI Chat Completions at api.kimi.com/coding/v1,
the protocol the official Kimi Code client uses. K3, K3-256K, and Kimi For
Coding accept tools declared in later system messages, so tools loaded
mid-conversation (e.g. by tool_search) keep the prompt cache. Highspeed
rejects tool-bearing system messages and keeps added tools top-level.

closes #10720
This commit is contained in:
David Brailovsky 2026-10-14 22:23:30 +02:00
parent 87f9a36419
commit b06c1f2cf1
9 changed files with 113 additions and 99 deletions

View file

@ -10,6 +10,10 @@
- Added cache-preserving thinking level changes for GPT-6 models on the openai provider: with an API key, the level is sent as configuration_update input items instead of changing reasoning.effort, controlled by compat.supportsMidConvoEffort ([#10853](https://github.com/earendil-works/pi/issues/10853)) - Added cache-preserving thinking level changes for GPT-6 models on the openai provider: with an API key, the level is sent as configuration_update input items instead of changing reasoning.effort, controlled by compat.supportsMidConvoEffort ([#10853](https://github.com/earendil-works/pi/issues/10853))
### Changed
- Changed the `kimi-coding` provider to use OpenAI Chat Completions at `https://api.kimi.com/coding/v1`, the protocol used by the official Kimi Code client. K3, K3-256K, and Kimi For Coding now accept tools added mid-conversation without invalidating the prompt cache. `models.json` overrides of the `kimi-coding` base URL must now include `/v1` ([#10720](https://github.com/earendil-works/pi/issues/10720))
### Fixed ### Fixed
- Fixed tool-call argument parsing dropping fields when a string contains an invalid `\u` escape such as `\usepackage` ([#10868](https://github.com/earendil-works/pi/issues/10868)) - Fixed tool-call argument parsing dropping fields when a string contains an invalid `\u` escape such as `\usepackage` ([#10868](https://github.com/earendil-works/pi/issues/10868))

View file

@ -343,13 +343,21 @@ const KIMI_K3_COST = {
cacheRead: 0.3, cacheRead: 0.3,
cacheWrite: 0, cacheWrite: 0,
} as const; } as const;
// Kimi Coding is subscription-backed, so models.dev reports zero cost. Use the // Kimi Chat Completions settings shared by Moonshot and Kimi Coding.
// equivalent Moonshot API rates to estimate the value of subscription usage. const KIMI_OPENAI_COMPAT: OpenAICompletionsCompat = {
const KIMI_CODING_IMPLIED_COSTS: Record<string, Model<Api>["cost"]> = { supportsStore: false,
k3: KIMI_K3_COST, supportsDeveloperRole: false,
"kimi-for-coding": { input: 0.95, output: 4, cacheRead: 0.19, cacheWrite: 0 }, supportsReasoningEffort: false,
"kimi-for-coding-highspeed": { input: 1.9, output: 8, cacheRead: 0.38, cacheWrite: 0 }, maxTokensField: "max_tokens",
"kimi-k2-thinking": { input: 0.6, output: 2.5, cacheRead: 0.15, cacheWrite: 0 }, supportsStrictMode: false,
thinkingFormat: "deepseek",
};
// Kimi K3-style models take reasoning_effort and need earlier reasoning sent back.
const KIMI_K3_OPENAI_COMPAT: OpenAICompletionsCompat = {
...KIMI_OPENAI_COMPAT,
requiresReasoningContentOnAssistantMessages: true,
thinkingFormat: "openai",
supportsReasoningEffort: true,
}; };
const OPENROUTER_KIMI_K3_MODEL_IDS = new Set(["moonshotai/kimi-k3", "~moonshotai/kimi-latest"]); const OPENROUTER_KIMI_K3_MODEL_IDS = new Set(["moonshotai/kimi-k3", "~moonshotai/kimi-latest"]);
@ -918,7 +926,7 @@ function applyOpenAIToolSearchMetadata(model: Model<Api>): void {
// tool-bearing system messages. Kimi K3 accepts both forms; Fireworks and OpenCode pass // tool-bearing system messages. Kimi K3 accepts both forms; Fireworks and OpenCode pass
// its tool-bearing form through. GitHub Copilot forwards K3 text but silently drops its // its tool-bearing form through. GitHub Copilot forwards K3 text but silently drops its
// tool-bearing message. DeepSeek V4 Pro and OpenAI models behind OpenRouter also accept // tool-bearing message. DeepSeek V4 Pro and OpenAI models behind OpenRouter also accept
// plain system text in place. // plain system text in place. Kimi Coding sets these flags in createKimiCodingModels().
function applyOpenAICompletionsTranscriptMetadata(model: Model<Api>): void { function applyOpenAICompletionsTranscriptMetadata(model: Model<Api>): void {
if (model.api !== "openai-completions") return; if (model.api !== "openai-completions") return;
const isKimiK3 = const isKimiK3 =
@ -1753,6 +1761,58 @@ function processFireworksModels(provider: ModelsDevProvider | undefined): Model<
return models; return models;
} }
// Kimi Coding is subscription-backed, so models.dev reports zero cost. Use the
// equivalent Moonshot API rates to estimate the value of subscription usage.
const KIMI_CODING_IMPLIED_COSTS: Record<string, Model<Api>["cost"]> = {
k3: KIMI_K3_COST,
"kimi-for-coding": { input: 0.95, output: 4, cacheRead: 0.19, cacheWrite: 0 },
"kimi-for-coding-highspeed": { input: 1.9, output: 8, cacheRead: 0.38, cacheWrite: 0 },
};
// Models whose https://api.kimi.com/coding/v1/models entry reports effort levels and
// dynamic tool loading (tools declared in later system messages). Highspeed reports
// neither and rejects tool-bearing system messages with a 400.
const KIMI_CODING_K3_STYLE_MODEL_IDS = new Set(["k3", "k3-256k", "kimi-for-coding"]);
// Kimi Coding uses Chat Completions, the same protocol as the official Kimi Code client.
function createKimiCodingModels(kimiModels: Record<string, ModelsDevModel>): Model<"openai-completions">[] {
const models: Model<"openai-completions">[] = [];
for (const [modelId, m] of Object.entries(kimiModels)) {
if (m.tool_call !== true) continue;
const isK3Style = KIMI_CODING_K3_STYLE_MODEL_IDS.has(modelId);
const impliedCost = KIMI_CODING_IMPLIED_COSTS[modelId];
// K3-style models send reasoning_effort, so expose the effort values models.dev lists.
const effortLevels = isK3Style && m.reasoning_options ? getEffortThinkingLevelMap(m.reasoning_options) : undefined;
models.push({
id: modelId,
name: m.name || modelId,
api: "openai-completions",
provider: "kimi-coding",
baseUrl: "https://api.kimi.com/coding/v1",
compat: {
...(isK3Style ? KIMI_K3_OPENAI_COMPAT : KIMI_OPENAI_COMPAT),
// All models accept system text after the conversation starts.
supportsMidConvoSystemMessages: true,
...(isK3Style ? { supportsMidConvoToolAdditions: true } : {}),
},
reasoning: m.reasoning === true,
input: m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"],
cost: {
input: m.cost?.input || impliedCost?.input || 0,
output: m.cost?.output || impliedCost?.output || 0,
cacheRead: m.cost?.cache_read || impliedCost?.cacheRead || 0,
cacheWrite: m.cost?.cache_write || impliedCost?.cacheWrite || 0,
},
contextWindow: m.limit?.context || 4096,
maxTokens: m.limit?.output || 4096,
// Kimi Code reports every model as always-thinking.
thinkingLevelMap: { ...effortLevels, off: null },
});
}
return models;
}
async function loadModelsDevData(): Promise<Model<any>[]> { async function loadModelsDevData(): Promise<Model<any>[]> {
try { try {
console.log("Fetching models from models.dev API..."); console.log("Fetching models from models.dev API...");
@ -2356,48 +2416,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
// Process Kimi For Coding models // Process Kimi For Coding models
if (data["kimi-code-plan-global"]?.models) { if (data["kimi-code-plan-global"]?.models) {
const kimiModels = data["kimi-code-plan-global"].models as Record<string, ModelsDevModel>; models.push(...createKimiCodingModels(data["kimi-code-plan-global"].models as Record<string, ModelsDevModel>));
const hasCanonicalModel = Object.prototype.hasOwnProperty.call(kimiModels, "kimi-for-coding");
const kimiAliases = new Set(["k2p5", "k2p6", "k2p7"]);
for (const [modelId, model] of Object.entries(kimiModels)) {
const m = model as ModelsDevModel;
if (m.tool_call !== true) continue;
// models.dev may expose versioned aliases (e.g. k2p5/k2p6/k2p7).
// Normalize aliases to the canonical model id and drop duplicates when canonical exists.
if (kimiAliases.has(modelId) && hasCanonicalModel) continue;
const normalizedId = kimiAliases.has(modelId) ? "kimi-for-coding" : modelId;
const normalizedName = kimiAliases.has(modelId) ? "Kimi For Coding" : m.name || normalizedId;
const isKimiK3 = normalizedId === "k3";
const allowEmptySignature = isKimiK3 || normalizedId === "kimi-for-coding";
const impliedCost = KIMI_CODING_IMPLIED_COSTS[normalizedId];
models.push({
id: normalizedId,
name: normalizedName,
api: "anthropic-messages",
provider: "kimi-coding",
// Kimi For Coding's Anthropic-compatible API - SDK appends /v1/messages
baseUrl: "https://api.kimi.com/coding",
compat: {
...(allowEmptySignature ? { allowEmptySignature: true } : {}),
forceAdaptiveThinking: true,
},
reasoning: isKimiK3 || m.reasoning === true,
input: m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"],
cost: {
input: m.cost?.input || impliedCost?.input || 0,
output: m.cost?.output || impliedCost?.output || 0,
cacheRead: m.cost?.cache_read || impliedCost?.cacheRead || 0,
cacheWrite: m.cost?.cache_write || impliedCost?.cacheWrite || 0,
},
contextWindow: m.limit?.context || 4096,
maxTokens: m.limit?.output || 4096,
});
recordModelsDevReasoningOptions("kimi-coding", normalizedId, m);
}
} }
// Process Moonshot AI models // Process Moonshot AI models
@ -2405,14 +2424,6 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
{ key: "moonshotai", provider: "moonshotai", baseUrl: "https://api.moonshot.ai/v1" }, { key: "moonshotai", provider: "moonshotai", baseUrl: "https://api.moonshot.ai/v1" },
{ key: "moonshotai-cn", provider: "moonshotai-cn", baseUrl: "https://api.moonshot.cn/v1" }, { key: "moonshotai-cn", provider: "moonshotai-cn", baseUrl: "https://api.moonshot.cn/v1" },
] as const; ] as const;
const moonshotCompat: OpenAICompletionsCompat = {
supportsStore: false,
supportsDeveloperRole: false,
supportsReasoningEffort: false,
maxTokensField: "max_tokens",
supportsStrictMode: false,
thinkingFormat: "deepseek",
};
const getMoonshotProviderModels = (key: "moonshotai" | "moonshotai-cn"): Record<string, ModelsDevModel> => { const getMoonshotProviderModels = (key: "moonshotai" | "moonshotai-cn"): Record<string, ModelsDevModel> => {
const providerModels = data[key]?.models as Record<string, ModelsDevModel> | undefined; const providerModels = data[key]?.models as Record<string, ModelsDevModel> | undefined;
return providerModels ? { ...providerModels } : {}; return providerModels ? { ...providerModels } : {};
@ -2427,12 +2438,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
if (m.tool_call !== true) continue; if (m.tool_call !== true) continue;
const isKimiK3 = modelId === "kimi-k3"; const isKimiK3 = modelId === "kimi-k3";
const compat = isKimiK3 ? { ...moonshotCompat } : moonshotCompat; const compat = { ...(isKimiK3 ? KIMI_K3_OPENAI_COMPAT : KIMI_OPENAI_COMPAT) };
if (isKimiK3) {
compat.requiresReasoningContentOnAssistantMessages = true;
compat.thinkingFormat = "openai";
compat.supportsReasoningEffort = true;
}
models.push({ models.push({
id: modelId, id: modelId,
name: m.name || modelId, name: m.name || modelId,

View file

@ -1,14 +1,14 @@
import { anthropicMessagesApi } from "../api/anthropic-messages.lazy.ts"; import { openAICompletionsApi } from "../api/openai-completions.lazy.ts";
import { envApiKeyAuth, lazyOAuth } from "../auth/helpers.ts"; import { envApiKeyAuth, lazyOAuth } from "../auth/helpers.ts";
import { loadKimiCodingOAuth } from "../auth/oauth/load.ts"; import { loadKimiCodingOAuth } from "../auth/oauth/load.ts";
import { createProvider, type Provider } from "../models.ts"; import { createProvider, type Provider } from "../models.ts";
import { KIMI_CODING_MODELS } from "./kimi-coding.models.ts"; import { KIMI_CODING_MODELS } from "./kimi-coding.models.ts";
export function kimiCodingProvider(): Provider<"anthropic-messages"> { export function kimiCodingProvider(): Provider<"openai-completions"> {
return createProvider({ return createProvider({
id: "kimi-coding", id: "kimi-coding",
name: "Kimi For Coding", name: "Kimi For Coding",
baseUrl: "https://api.kimi.com/coding", baseUrl: "https://api.kimi.com/coding/v1",
auth: { auth: {
apiKey: envApiKeyAuth("Kimi API key", ["KIMI_API_KEY"]), apiKey: envApiKeyAuth("Kimi API key", ["KIMI_API_KEY"]),
oauth: lazyOAuth({ oauth: lazyOAuth({
@ -19,6 +19,6 @@ export function kimiCodingProvider(): Provider<"anthropic-messages"> {
}), }),
}, },
models: Object.values(KIMI_CODING_MODELS), models: Object.values(KIMI_CODING_MODELS),
api: anthropicMessagesApi(), api: openAICompletionsApi(),
}); });
} }

View file

@ -12,9 +12,6 @@ const EXPECTED_CURRENT_ADAPTIVE_THINKING_MODELS = [
"fireworks/accounts/fireworks/models/deepseek-v4p1-flash", "fireworks/accounts/fireworks/models/deepseek-v4p1-flash",
"fireworks/accounts/fireworks/models/gpt-oss-120b", "fireworks/accounts/fireworks/models/gpt-oss-120b",
"fireworks/accounts/fireworks/models/qwen3p8-max", "fireworks/accounts/fireworks/models/qwen3p8-max",
"kimi-coding/kimi-for-coding",
"kimi-coding/k3",
"kimi-coding/kimi-for-coding-highspeed",
"opencode/claude-opus-4-8", "opencode/claude-opus-4-8",
"opencode/claude-opus-5", "opencode/claude-opus-5",
"vercel-ai-gateway/anthropic/claude-opus-4.8", "vercel-ai-gateway/anthropic/claude-opus-4.8",
@ -41,9 +38,7 @@ describe("Anthropic adaptive thinking model metadata", () => {
// Regression for #9323: Fireworks uses catalog effort metadata and // Regression for #9323: Fireworks uses catalog effort metadata and
// verified fallbacks, not a fixed set of adaptive model names. // verified fallbacks, not a fixed set of adaptive model names.
modelId.startsWith("fireworks/") || modelId.startsWith("fireworks/") ||
/(opus[-.](4[-.][678]|5)|sonnet[-.]4[-.]6|sonnet[-.]5|haiku[-.]5[-.]5|fable[-.]5|kimi-coding\/)/.test( /(opus[-.](4[-.][678]|5)|sonnet[-.]4[-.]6|sonnet[-.]5|haiku[-.]5[-.]5|fable[-.]5)/.test(modelId),
modelId,
),
), ),
); );
}); });

View file

@ -134,13 +134,4 @@ describe("Anthropic empty thinking signature compat", () => {
{ type: "text", text: "internal reasoning" }, { type: "text", text: "internal reasoning" },
]); ]);
}); });
it.each(["k3"] as const)("allows empty signatures for Kimi Coding %s", async (modelId) => {
const model = getModel("kimi-coding", modelId);
expect(model.compat?.allowEmptySignature).toBe(true);
const payload = await capturePayload(model, makeContext(" ", "internal reasoning", "kimi-coding", modelId));
const assistant = payload.messages?.find((message) => message.role === "assistant");
expect(assistant?.content).toEqual([{ type: "thinking", thinking: "internal reasoning", signature: "" }]);
});
}); });

View file

@ -89,20 +89,6 @@ describe("Anthropic forceAdaptiveThinking compat override", () => {
expect(payload.output_config).toEqual({ effort: "xhigh" }); expect(payload.output_config).toEqual({ effort: "xhigh" });
}); });
it.each([
["kimi-for-coding", "medium", "medium"],
["k3", "max", "max"],
["kimi-for-coding-highspeed", "medium", "medium"],
] as const)(
"uses adaptive thinking effort without a token budget for Kimi Coding %s",
async (modelId, reasoning, effort) => {
const payload = await capturePayload(getModel("kimi-coding", modelId), { reasoning });
expect(payload.thinking).toEqual({ type: "adaptive", display: "summarized" });
expect(payload.output_config).toEqual({ effort });
},
);
it("allows built-in adaptive models to opt out with compat.forceAdaptiveThinking false", async () => { it("allows built-in adaptive models to opt out with compat.forceAdaptiveThinking false", async () => {
const model: Model<"anthropic-messages"> = { const model: Model<"anthropic-messages"> = {
...getModel("anthropic", "claude-opus-4-8"), ...getModel("anthropic", "claude-opus-4-8"),

View file

@ -1041,7 +1041,7 @@ describe("Generate E2E Tests", () => {
}); });
describe.skipIf(!process.env.KIMI_API_KEY)( describe.skipIf(!process.env.KIMI_API_KEY)(
"Kimi For Coding Provider (kimi-for-coding via Anthropic Messages)", "Kimi For Coding Provider (kimi-for-coding via OpenAI Completions)",
() => { () => {
const llm = getModel("kimi-coding", "kimi-for-coding"); const llm = getModel("kimi-coding", "kimi-for-coding");

View file

@ -209,6 +209,12 @@ describe("getSupportedThinkingLevels", () => {
expect(getSupportedThinkingLevels(model!)).toEqual(["low", "high", "max"]); expect(getSupportedThinkingLevels(model!)).toEqual(["low", "high", "max"]);
}); });
it("excludes off for always-thinking Kimi Coding Highspeed", () => {
const model = getModel("kimi-coding", "kimi-for-coding-highspeed");
expect(model).toBeDefined();
expect(getSupportedThinkingLevels(model!)).not.toContain("off");
});
it("includes only high for OpenCode Grok Build", () => { it("includes only high for OpenCode Grok Build", () => {
const model = getModel("opencode", "grok-build-0.1"); const model = getModel("opencode", "grok-build-0.1");
expect(model).toBeDefined(); expect(model).toBeDefined();

View file

@ -1,6 +1,6 @@
import { Type } from "typebox"; import { Type } from "typebox";
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import { streamSimple } from "../src/compat.ts"; import { getModel, streamSimple } from "../src/compat.ts";
import type { Api, Context, Model, Tool } from "../src/types.ts"; import type { Api, Context, Model, Tool } from "../src/types.ts";
class PayloadCaptured extends Error {} class PayloadCaptured extends Error {}
@ -339,6 +339,32 @@ describe("transcript system messages", () => {
); );
}); });
type KimiPayload = {
tools?: Array<{ function?: { name: string } }>;
messages: Array<{ role: string; tools?: Array<{ function?: { name: string } }> }>;
};
// #10720: Kimi Coding uses Chat Completions so K3-style models keep added tools out of the top-level list.
test.each(["k3", "k3-256k", "kimi-for-coding"] as const)("anchors additions for Kimi Coding %s", async (modelId) => {
const payload = await capturePayload<KimiPayload>(getModel("kimi-coding", modelId), additionContext);
expect(payload.tools?.map((value) => value.function?.name)).toEqual(["base_tool"]);
expect(payload.messages.find((message) => message.tools)?.tools?.map((value) => value.function?.name)).toEqual([
"late_tool",
]);
});
// #10720: Kimi Code Highspeed rejects tool-bearing system messages.
test("keeps Kimi Coding Highspeed additions in top-level tools", async () => {
const payload = await capturePayload<KimiPayload>(
getModel("kimi-coding", "kimi-for-coding-highspeed"),
additionContext,
);
expect(payload.tools?.map((value) => value.function?.name)).toEqual(["base_tool", "late_tool"]);
expect(payload.messages.some((message) => message.tools !== undefined)).toBe(false);
});
test("keeps Kimi K2 system text inline without dynamic tool messages", async () => { test("keeps Kimi K2 system text inline without dynamic tool messages", async () => {
const model: Model<"openai-completions"> = { const model: Model<"openai-completions"> = {
...modelBase, ...modelBase,