diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 7b166d778..79de1c868 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -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)) +### 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)) diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 01bd65143..29480825e 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -343,13 +343,21 @@ const KIMI_K3_COST = { cacheRead: 0.3, cacheWrite: 0, } as const; -// 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["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 }, +// 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, }; const OPENROUTER_KIMI_K3_MODEL_IDS = new Set(["moonshotai/kimi-k3", "~moonshotai/kimi-latest"]); @@ -934,7 +942,7 @@ function applyOpenAIToolSearchMetadata(model: Model): 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. +// plain system text in place. Kimi Coding sets these flags in createKimiCodingModels(). function applyOpenAICompletionsTranscriptMetadata(model: Model): void { if (model.api !== "openai-completions") return; const isKimiK3 = @@ -1769,6 +1777,58 @@ 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["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): 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[]> { try { console.log("Fetching models from models.dev API..."); @@ -2372,48 +2432,7 @@ async function loadModelsDevData(): Promise[]> { // Process Kimi For Coding models if (data["kimi-code-plan-global"]?.models) { - const kimiModels = data["kimi-code-plan-global"].models as Record; - 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); - } + models.push(...createKimiCodingModels(data["kimi-code-plan-global"].models as Record)); } // Process Moonshot AI models @@ -2421,14 +2440,6 @@ async function loadModelsDevData(): Promise[]> { { 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 => { const providerModels = data[key]?.models as Record | undefined; return providerModels ? { ...providerModels } : {}; @@ -2443,12 +2454,7 @@ async function loadModelsDevData(): Promise[]> { if (m.tool_call !== true) continue; const isKimiK3 = modelId === "kimi-k3"; - const compat = isKimiK3 ? { ...moonshotCompat } : moonshotCompat; - if (isKimiK3) { - compat.requiresReasoningContentOnAssistantMessages = true; - compat.thinkingFormat = "openai"; - compat.supportsReasoningEffort = true; - } + const compat = { ...(isKimiK3 ? KIMI_K3_OPENAI_COMPAT : KIMI_OPENAI_COMPAT) }; models.push({ id: modelId, name: m.name || modelId, diff --git a/packages/ai/src/providers/kimi-coding.ts b/packages/ai/src/providers/kimi-coding.ts index 81ae4af2e..f84b7b5e7 100644 --- a/packages/ai/src/providers/kimi-coding.ts +++ b/packages/ai/src/providers/kimi-coding.ts @@ -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 { 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<"anthropic-messages"> { +export function kimiCodingProvider(): Provider<"openai-completions"> { return createProvider({ id: "kimi-coding", name: "Kimi For Coding", - baseUrl: "https://api.kimi.com/coding", + baseUrl: "https://api.kimi.com/coding/v1", auth: { apiKey: envApiKeyAuth("Kimi API key", ["KIMI_API_KEY"]), oauth: lazyOAuth({ @@ -19,6 +19,6 @@ export function kimiCodingProvider(): Provider<"anthropic-messages"> { }), }, models: Object.values(KIMI_CODING_MODELS), - api: anthropicMessagesApi(), + api: openAICompletionsApi(), }); } diff --git a/packages/ai/test/anthropic-adaptive-thinking-models.test.ts b/packages/ai/test/anthropic-adaptive-thinking-models.test.ts index 9efa7de35..69615cb07 100644 --- a/packages/ai/test/anthropic-adaptive-thinking-models.test.ts +++ b/packages/ai/test/anthropic-adaptive-thinking-models.test.ts @@ -12,9 +12,6 @@ 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", @@ -41,9 +38,7 @@ 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|kimi-coding\/)/.test( - modelId, - ), + /(opus[-.](4[-.][678]|5)|sonnet[-.]4[-.]6|sonnet[-.]5|haiku[-.]5[-.]5|fable[-.]5)/.test(modelId), ), ); }); diff --git a/packages/ai/test/anthropic-empty-thinking-signature-compat.test.ts b/packages/ai/test/anthropic-empty-thinking-signature-compat.test.ts index b15cdf4bc..1c4036a0f 100644 --- a/packages/ai/test/anthropic-empty-thinking-signature-compat.test.ts +++ b/packages/ai/test/anthropic-empty-thinking-signature-compat.test.ts @@ -134,13 +134,4 @@ 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: "" }]); - }); }); diff --git a/packages/ai/test/anthropic-force-adaptive-thinking.test.ts b/packages/ai/test/anthropic-force-adaptive-thinking.test.ts index ffaf48dbe..6629782f2 100644 --- a/packages/ai/test/anthropic-force-adaptive-thinking.test.ts +++ b/packages/ai/test/anthropic-force-adaptive-thinking.test.ts @@ -89,20 +89,6 @@ 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"), diff --git a/packages/ai/test/stream.test.ts b/packages/ai/test/stream.test.ts index 6becd2253..ac02be9ad 100644 --- a/packages/ai/test/stream.test.ts +++ b/packages/ai/test/stream.test.ts @@ -1041,7 +1041,7 @@ describe("Generate E2E Tests", () => { }); 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"); diff --git a/packages/ai/test/supports-xhigh.test.ts b/packages/ai/test/supports-xhigh.test.ts index f3f968d46..1454cdab5 100644 --- a/packages/ai/test/supports-xhigh.test.ts +++ b/packages/ai/test/supports-xhigh.test.ts @@ -209,6 +209,12 @@ 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(); diff --git a/packages/ai/test/transcript-tool-changes.test.ts b/packages/ai/test/transcript-tool-changes.test.ts index 86a8cf69f..1a1a99b39 100644 --- a/packages/ai/test/transcript-tool-changes.test.ts +++ b/packages/ai/test/transcript-tool-changes.test.ts @@ -1,6 +1,6 @@ import { Type } from "typebox"; 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"; 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(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( + 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, diff --git a/scripts/publish-model-catalog.mjs b/scripts/publish-model-catalog.mjs index 984a7ea47..a8d6b5201 100644 --- a/scripts/publish-model-catalog.mjs +++ b/scripts/publish-model-catalog.mjs @@ -25,7 +25,10 @@ import { // The storage layout, index format, and version ordering are defined in // model-catalog-protocol.ts, which pi.dev shares to serve these artifacts. // Bump this only when generated model metadata requires behavior unavailable in older pi clients. -const MINIMUM_PI_VERSION = "0.80.7"; +// Clients older than this keep the last catalog published under the previous minimum. +// 1.2.1: kimi-coding models use openai-completions at /coding/v1; older clients only +// implement anthropic-messages for kimi-coding and ignore a model's api. +const MINIMUM_PI_VERSION = "1.2.1"; const JSON_CONTENT_TYPE = "application/json; charset=utf-8"; const IMMUTABLE_CACHE_CONTROL = "public, max-age=31536000, immutable"; const INDEX_CACHE_CONTROL = "no-store";