fix(ai): revert kimi-coding switch to Chat Completions

Reverts b06c1f2. The pi.dev model catalog is served to every client
since 0.80.7, so released clients received kimi-coding models with
api openai-completions and baseUrl https://api.kimi.com/coding/v1.
Their kimi-coding provider only implements Anthropic Messages and
ignores model.api, so requests went to /coding/v1/v1/messages and
failed with 404. The change will re-land together with a bump of the
catalog's minimum pi version.

refs #10720, refs #10985
This commit is contained in:
David Brailovsky 2026-10-15 13:19:40 +02:00
parent 4a5d372915
commit 6a1f799b67
9 changed files with 99 additions and 113 deletions

View file

@ -10,10 +10,6 @@
- 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 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,21 +343,13 @@ const KIMI_K3_COST = {
cacheRead: 0.3,
cacheWrite: 0,
} as const;
// Kimi Chat Completions settings shared by Moonshot and Kimi Coding.
const KIMI_OPENAI_COMPAT: OpenAICompletionsCompat = {
supportsStore: false,
supportsDeveloperRole: false,
supportsReasoningEffort: false,
maxTokensField: "max_tokens",
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,
// 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 },
"kimi-k2-thinking": { input: 0.6, output: 2.5, cacheRead: 0.15, cacheWrite: 0 },
};
const OPENROUTER_KIMI_K3_MODEL_IDS = new Set(["moonshotai/kimi-k3", "~moonshotai/kimi-latest"]);
@ -942,7 +934,7 @@ function applyOpenAIToolSearchMetadata(model: Model<Api>): void {
// 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
// tool-bearing message. DeepSeek V4 Pro and OpenAI models behind OpenRouter also accept
// plain system text in place. Kimi Coding sets these flags in createKimiCodingModels().
// plain system text in place.
function applyOpenAICompletionsTranscriptMetadata(model: Model<Api>): void {
if (model.api !== "openai-completions") return;
const isKimiK3 =
@ -1777,58 +1769,6 @@ function processFireworksModels(provider: ModelsDevProvider | undefined): Model<
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>[]> {
try {
console.log("Fetching models from models.dev API...");
@ -2432,7 +2372,48 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
// Process Kimi For Coding models
if (data["kimi-code-plan-global"]?.models) {
models.push(...createKimiCodingModels(data["kimi-code-plan-global"].models as Record<string, ModelsDevModel>));
const kimiModels = 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
@ -2440,6 +2421,14 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
{ key: "moonshotai", provider: "moonshotai", baseUrl: "https://api.moonshot.ai/v1" },
{ key: "moonshotai-cn", provider: "moonshotai-cn", baseUrl: "https://api.moonshot.cn/v1" },
] 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 providerModels = data[key]?.models as Record<string, ModelsDevModel> | undefined;
return providerModels ? { ...providerModels } : {};
@ -2454,7 +2443,12 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
if (m.tool_call !== true) continue;
const isKimiK3 = modelId === "kimi-k3";
const compat = { ...(isKimiK3 ? KIMI_K3_OPENAI_COMPAT : KIMI_OPENAI_COMPAT) };
const compat = isKimiK3 ? { ...moonshotCompat } : moonshotCompat;
if (isKimiK3) {
compat.requiresReasoningContentOnAssistantMessages = true;
compat.thinkingFormat = "openai";
compat.supportsReasoningEffort = true;
}
models.push({
id: modelId,
name: m.name || modelId,

View file

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

View file

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

View file

@ -134,4 +134,13 @@ describe("Anthropic empty thinking signature compat", () => {
{ 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,6 +89,20 @@ describe("Anthropic forceAdaptiveThinking compat override", () => {
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 () => {
const model: Model<"anthropic-messages"> = {
...getModel("anthropic", "claude-opus-4-8"),

View file

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

View file

@ -209,12 +209,6 @@ describe("getSupportedThinkingLevels", () => {
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", () => {
const model = getModel("opencode", "grok-build-0.1");
expect(model).toBeDefined();

View file

@ -1,6 +1,6 @@
import { Type } from "typebox";
import { describe, expect, test } from "vitest";
import { getModel, streamSimple } from "../src/compat.ts";
import { streamSimple } from "../src/compat.ts";
import type { Api, Context, Model, Tool } from "../src/types.ts";
class PayloadCaptured extends Error {}
@ -339,32 +339,6 @@ 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 () => {
const model: Model<"openai-completions"> = {
...modelBase,