Kimi: apply audit (pricing, output caps, per-model thinking knobs, json_schema per model, tool_choice fallback)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
4 changed files +47 −28
modified
docs/provider-test-matrix.md
+10 −10
@@ -22,13 +22,13 @@ Test models: openai: gpt-5.4-mini / reasoning gpt-5.4-mini; anthropic: claude-ha | ||
| 22 | 22 | |
| 23 | 23 | ## Notes |
| 24 | 24 | |
| 25 | −- openrouter: models: 323 (openai/gpt-6-astra, openai/gpt-6-astra-pro, inclusionai/ling-3.0-flash-sante:free, qwen/qwen3.8-max-0902, meta/muse-spark-1.3…) | |
| 26 | −- openrouter: text(openai/gpt-5.4-nano): "OK" deltas=1 usage={"inputTokens":11,"outputTokens":5,"cachedInputTokens":0,"reasoningTokens":0,"totalTokens":16} finish=stop | |
| 27 | −- openrouter: system: "Arr! Hello there, matey, cheers!" | |
| 28 | −- openrouter: vision: "Red" | |
| 29 | −- openrouter: tools: [{"n":"calculator","a":{"expression":"1234*5678"}}] finish=tool-calls | |
| 30 | −- openrouter: structured: {"city":"Ottawa","country":"Canada"} | |
| 31 | −- openrouter: reasoning(openai/gpt-5.4-nano): text="16" reasoningChars=838 reasoningTokens=565 | |
| 32 | −- openrouter: invalid key → INVALID_API_KEY (401) | |
| 33 | −- openrouter: unknown model → INVALID_PARAMETER: definitely-not-a-model-xyz is not a valid model ID | |
| 34 | −- openrouter: long: 468 words, 541 deltas | |
| 25 | +- kimi: models: 4 (kimi-k3, kimi-k2.7-code, kimi-k2.7-code-highspeed, kimi-k2.6…) | |
| 26 | +- kimi: text(kimi-k2.6): "OK" deltas=68 usage={"inputTokens":12,"outputTokens":71,"cachedInputTokens":12,"reasoningTokens":68,"totalTokens":83} finish=stop | |
| 27 | +- kimi: system: "Arr hello to ye matey." | |
| 28 | +- kimi: vision: "Red" | |
| 29 | +- kimi: tools: [{"n":"calculator","a":{"expression":"1234 * 5678"}}] finish=tool-calls | |
| 30 | +- kimi: structured: {"city":"Ottawa","country":"Canada"} | |
| 31 | +- kimi: reasoning(kimi-k3): text="16" reasoningChars=709 reasoningTokens=289 | |
| 32 | +- kimi: invalid key → INVALID_API_KEY (401) | |
| 33 | +- kimi: unknown model → MODEL_NOT_FOUND: Not found the model definitely-not-a-model-xyz or Permission denied | |
| 34 | +- kimi: long: 410 words, 1641 deltas | |
modified
src/lib/ai/providers/kimi/catalog.ts
+16 −2
@@ -1,4 +1,18 @@ | ||
| 1 | 1 | import type { CatalogEntry } from "@/lib/ai/registry/catalog"; |
| 2 | 2 | |
| 3 | −/** Kimi documented augmentation — filled from docs/provider-research/kimi.md (2026-09-08). */ | |
| 4 | −export const KIMI_CATALOG = new Map<string, CatalogEntry>(); | |
| 3 | +/** | |
| 4 | + * Kimi documented augmentation — docs/provider-research/kimi.md (audited 2026-09-08). | |
| 5 | + * Pricing USD/M (input / cache-hit / output) from the pricing page; max output per docs | |
| 6 | + * (K3 131 072 default, K2.x 32 768 — the API does not validate it, so we clamp client-side). | |
| 7 | + * Thinking controls: K2.6 `thinking:{type}`; K2.7-code(-highspeed) fixed (disable → 400); K3 `reasoning_effort` low|high|max. | |
| 8 | + * `json_schema` is enforced on K3 / K2.7 but not on K2.6 (prose came back twice). | |
| 9 | + */ | |
| 10 | +const price = (i: number, c: number, o: number) => ({ inputPerMillion: i, cachedInputPerMillion: c, outputPerMillion: o, source: "kimi-pricing-page", asOf: "2026-09-08" }); | |
| 11 | +const caps = { text: true, vision: true, audioInput: false, audioOutput: false, imageGeneration: false, video: true, reasoning: true, tools: true, structuredOutput: true, streaming: true, files: false, webSearch: false }; | |
| 12 | + | |
| 13 | +export const KIMI_CATALOG = new Map<string, CatalogEntry>([ | |
| 14 | + ["kimi-k3", { displayName: "Kimi K3", family: "Kimi K3", capabilities: caps, limits: { maxOutputTokens: 131_072 }, pricing: price(3, 0.3, 15), status: "active", sortWeight: 100, metadata: { thinkingKnob: "reasoning_effort", jsonSchema: true, minOutputForReasoning: 8192 } }], | |
| 15 | + ["kimi-k2.7-code", { displayName: "Kimi K2.7 Code", family: "Kimi K2", capabilities: caps, limits: { maxOutputTokens: 32_768 }, pricing: price(0.95, 0.19, 4), status: "active", sortWeight: 90, metadata: { thinkingKnob: "fixed", jsonSchema: true, minOutputForReasoning: 8192, toolChoiceRequired: false } }], | |
| 16 | + ["kimi-k2.7-code-highspeed", { displayName: "Kimi K2.7 Code High-speed", family: "Kimi K2", capabilities: caps, limits: { maxOutputTokens: 32_768 }, pricing: price(1.9, 0.38, 8), status: "active", sortWeight: 88, metadata: { thinkingKnob: "fixed", jsonSchema: true, minOutputForReasoning: 8192, toolChoiceRequired: false } }], | |
| 17 | + ["kimi-k2.6", { displayName: "Kimi K2.6", family: "Kimi K2", capabilities: caps, limits: { maxOutputTokens: 32_768 }, pricing: price(0.95, 0.16, 4), status: "active", sortWeight: 85, metadata: { thinkingKnob: "thinking", jsonSchema: false, minOutputForReasoning: 8192, toolChoiceRequired: false } }], | |
| 18 | +]); | |
modified
src/lib/ai/providers/kimi/index.ts
+20 −15
@@ -52,7 +52,7 @@ export function normalizeKimiModel(m: KimiModel): PolyModel | null { | ||
| 52 | 52 | webSearch: false, // `$web_search` builtin exists but its round trip is not stable enough to expose yet |
| 53 | 53 | ...(cat?.capabilityOverrides ?? {}), |
| 54 | 54 | }, |
| 55 | − limits: { contextTokens: m.context_length ?? cat?.limits?.contextTokens, maxOutputTokens: cat?.limits?.maxOutputTokens }, | |
| 55 | + limits: { contextTokens: m.context_length ?? cat?.limits?.contextTokens, maxOutputTokens: cat?.limits?.maxOutputTokens ?? 32_768 }, | |
| 56 | 56 | parameters: { |
| 57 | 57 | temperature: false, // only 1 accepted |
| 58 | 58 | topP: false, // only 0.95 accepted |
@@ -60,19 +60,19 @@ export function normalizeKimiModel(m: KimiModel): PolyModel | null { | ||
| 60 | 60 | maxTokens: true, |
| 61 | 61 | stop: true, |
| 62 | 62 | seed: false, |
| 63 | − frequencyPenalty: true, | |
| 64 | − presencePenalty: true, | |
| 65 | − reasoningEffort: reasoning, | |
| 66 | − reasoningEffortLevels: reasoning ? [...(alwaysThinking ? [] : ["none"]), ...efforts] : undefined, | |
| 63 | + frequencyPenalty: false, // only 0 accepted | |
| 64 | + presencePenalty: false, | |
| 65 | + reasoningEffort: reasoning && (cat?.metadata?.thinkingKnob ?? (m.reasoning_efforts?.support ? "reasoning_effort" : "thinking")) !== "fixed", | |
| 66 | + reasoningEffortLevels: reasoning ? ((cat?.metadata?.thinkingKnob ?? "thinking") === "fixed" ? undefined : m.reasoning_efforts?.support ? efforts : ["none", "high"]) : undefined, | |
| 67 | 67 | thinkingBudget: false, |
| 68 | 68 | ...(cat?.parameters ?? {}), |
| 69 | 69 | }, |
| 70 | 70 | status: cat?.status ?? "active", |
| 71 | 71 | pricing: cat?.pricing ?? null, |
| 72 | 72 | metadata: { |
| 73 | − ...(cat?.metadata ?? {}), | |
| 74 | 73 | jsonSchema: false, |
| 75 | 74 | jsonWordRequired: true, |
| 75 | + ...(cat?.metadata ?? {}), | |
| 76 | 76 | alwaysThinking, |
| 77 | 77 | defaultReasoningEffort: m.reasoning_efforts?.default_effort ?? m.think_efforts?.default_effort, |
| 78 | 78 | dynamicTools: m.supports_dynamic_tools, |
@@ -118,16 +118,21 @@ export const kimiAdapter = createOpenAICompatAdapter({ | ||
| 118 | 118 | delete p.temperature; // only the default (1) is accepted |
| 119 | 119 | delete p.top_p; // only 0.95 is accepted |
| 120 | 120 | delete p.seed; |
| 121 | − if (req.modelInfo?.capabilities.reasoning && settings.reasoningEffort) { | |
| 122 | − const e = settings.reasoningEffort; | |
| 123 | − if (e === "none" && meta.alwaysThinking !== true) p.thinking = { type: "disabled" }; | |
| 124 | − else { | |
| 125 | − const levels = (req.modelInfo.parameters.reasoningEffortLevels ?? ["low", "high", "max"]).filter((l) => l !== "none"); | |
| 126 | − const wanted = e === "minimal" ? "low" : e === "medium" ? (levels.includes("medium") ? "medium" : "low") : e === "xhigh" ? (levels.includes("xhigh") ? "xhigh" : "high") : e === "none" ? levels[0] : e; | |
| 127 | − p.thinking = { type: "enabled" }; | |
| 128 | − p.reasoning_effort = levels.includes(wanted) ? wanted : levels[levels.length - 1]; | |
| 129 | − } | |
| 121 | + delete p.frequency_penalty; // only 0 is accepted | |
| 122 | + delete p.presence_penalty; | |
| 123 | + const knob = (meta.thinkingKnob as string | undefined) ?? "thinking"; | |
| 124 | + const effort = settings.reasoningEffort; | |
| 125 | + if (req.modelInfo?.capabilities.reasoning && effort && knob !== "fixed") { | |
| 126 | + if (knob === "reasoning_effort") { | |
| 127 | + // K3: low | high | max (medium accepted); "none" is undocumented → map to low. | |
| 128 | + p.reasoning_effort = effort === "none" || effort === "minimal" || effort === "low" ? "low" : effort === "max" ? "max" : effort === "medium" ? "medium" : "high"; | |
| 129 | + } else if (effort === "none") p.thinking = { type: "disabled" }; | |
| 130 | + else p.thinking = { type: "enabled" }; | |
| 130 | 131 | } |
| 132 | + // Reasoning consumes max_tokens; the API does not validate the cap, so clamp and raise tiny caps. | |
| 133 | + if (req.modelInfo?.capabilities.reasoning && effort !== "none" && typeof params.max_tokens === "number" && params.max_tokens < Number(meta.minOutputForReasoning ?? 8192)) params.max_tokens = Number(meta.minOutputForReasoning ?? 8192); | |
| 134 | + // Forced/required tool choice → 400 with thinking on K2.x and forced functions everywhere → fall back to auto. | |
| 135 | + if (params.tool_choice && params.tool_choice !== "none" && (typeof params.tool_choice === "object" || meta.toolChoiceRequired === false)) params.tool_choice = "auto"; | |
| 131 | 136 | }, |
| 132 | 137 | refineError: (status, code, message) => { |
| 133 | 138 | if (status === 401 || code === "invalid_authentication_error") return "INVALID_API_KEY"; |
modified
tests/unit/compat-providers.test.ts
+1 −1
@@ -59,7 +59,7 @@ describe("id-only listings", () => { | ||
| 59 | 59 | expect(normalizeDeepSeekModel({ id: "text-embedding" })).toBeNull(); |
| 60 | 60 | expect(normalizeKimiModel({ id: "kimi-k3" })?.family).toBe("Kimi K3"); |
| 61 | 61 | expect(normalizeKimiModel({ id: "kimi-k2.6" })?.parameters.temperature).toBe(false); // only temperature=1 is accepted |
| 62 | − expect(normalizeKimiModel({ id: "kimi-k3", supports_thinking_type: "only", reasoning_efforts: { valid_efforts: ["low", "high", "max"] } })?.parameters.reasoningEffortLevels).toEqual(["low", "high", "max"]); | |
| 62 | + expect(normalizeKimiModel({ id: "kimi-k3", supports_thinking_type: "only", reasoning_efforts: { support: true, valid_efforts: ["low", "high", "max"] } })?.parameters.reasoningEffortLevels).toEqual(["low", "high", "max"]); | |
| 63 | 63 | expect(normalizeCerebrasModel({ id: "gpt-oss-120b" })?.parameters.reasoningEffortLevels).toEqual(["low", "medium", "high"]); |
| 64 | 64 | }); |
| 65 | 65 | }); |
| 66 | 66 | |