diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 79de1c868..7b166d778 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -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)) diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 29480825e..01bd65143 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -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["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): 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): 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["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..."); @@ -2432,7 +2372,48 @@ async function loadModelsDevData(): Promise[]> { // Process Kimi For Coding models if (data["kimi-code-plan-global"]?.models) { - models.push(...createKimiCodingModels(data["kimi-code-plan-global"].models as Record)); + 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); + } } // Process Moonshot AI models @@ -2440,6 +2421,14 @@ 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 } : {}; @@ -2454,7 +2443,12 @@ async function loadModelsDevData(): Promise[]> { 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, diff --git a/packages/ai/src/providers/kimi-coding.ts b/packages/ai/src/providers/kimi-coding.ts index f84b7b5e7..81ae4af2e 100644 --- a/packages/ai/src/providers/kimi-coding.ts +++ b/packages/ai/src/providers/kimi-coding.ts @@ -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(), }); } diff --git a/packages/ai/test/anthropic-adaptive-thinking-models.test.ts b/packages/ai/test/anthropic-adaptive-thinking-models.test.ts index 69615cb07..9efa7de35 100644 --- a/packages/ai/test/anthropic-adaptive-thinking-models.test.ts +++ b/packages/ai/test/anthropic-adaptive-thinking-models.test.ts @@ -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, + ), ), ); }); 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 1c4036a0f..b15cdf4bc 100644 --- a/packages/ai/test/anthropic-empty-thinking-signature-compat.test.ts +++ b/packages/ai/test/anthropic-empty-thinking-signature-compat.test.ts @@ -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: "" }]); + }); }); diff --git a/packages/ai/test/anthropic-force-adaptive-thinking.test.ts b/packages/ai/test/anthropic-force-adaptive-thinking.test.ts index 6629782f2..ffaf48dbe 100644 --- a/packages/ai/test/anthropic-force-adaptive-thinking.test.ts +++ b/packages/ai/test/anthropic-force-adaptive-thinking.test.ts @@ -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"), diff --git a/packages/ai/test/stream.test.ts b/packages/ai/test/stream.test.ts index ac02be9ad..6becd2253 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 OpenAI Completions)", + "Kimi For Coding Provider (kimi-for-coding via Anthropic Messages)", () => { 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 1454cdab5..f3f968d46 100644 --- a/packages/ai/test/supports-xhigh.test.ts +++ b/packages/ai/test/supports-xhigh.test.ts @@ -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(); diff --git a/packages/ai/test/transcript-tool-changes.test.ts b/packages/ai/test/transcript-tool-changes.test.ts index 1a1a99b39..86a8cf69f 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 { 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(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,