import type OpenAI from "openai"; import { type PolyModel, modelKey } from "@/lib/ai/core/types"; import { createOpenAICompatAdapter } from "../shared/openai-compat/factory"; import { KIMI_CATALOG } from "./catalog"; /** * Kimi (Moonshot AI, international platform) — OpenAI-compatible at https://api.moonshot.ai/v1. * `GET /v1/models/{id}` exposes `supports_image_in`, `supports_video_in`, `supports_reasoning`, * `think_efforts.valid_efforts`, `supports_thinking_type` ("only" = cannot be disabled) and * `context_length` — used live; pricing comes from the catalog (docs/provider-research/kimi.md). * Verified quirks: temperature must be 1 and top_p 0.95 (400 otherwise), thinking streams as * `delta.reasoning_content` and must be replayed on assistant turns, `json_schema` is not enforced * (json_object + prompt instead). */ interface KimiModel { id: string; created?: number; context_length?: number; supports_image_in?: boolean; supports_video_in?: boolean; supports_reasoning?: boolean; supports_dynamic_tools?: boolean; think_efforts?: { support?: boolean; valid_efforts?: string[]; default_effort?: string }; reasoning_efforts?: { support?: boolean; valid_efforts?: string[]; default_effort?: string }; supports_thinking_type?: string; // "only" | "optional" | … } export function normalizeKimiModel(m: KimiModel): PolyModel | null { if (!m.id.startsWith("kimi") && !m.id.startsWith("moonshot")) return null; const cat = KIMI_CATALOG.get(m.id); const reasoning = m.supports_reasoning ?? true; const efforts = m.reasoning_efforts?.valid_efforts ?? m.think_efforts?.valid_efforts ?? ["low", "high", "max"]; const alwaysThinking = m.supports_thinking_type === "only"; return { key: modelKey("kimi", m.id), id: m.id, provider: "kimi", displayName: cat?.displayName ?? prettify(m.id), family: cat?.family ?? (m.id.startsWith("kimi-k3") ? "Kimi K3" : "Kimi K2"), capabilities: { text: true, vision: m.supports_image_in ?? true, audioInput: false, audioOutput: false, imageGeneration: false, video: m.supports_video_in ?? false, reasoning, tools: true, structuredOutput: true, streaming: true, files: false, webSearch: false, // `$web_search` builtin exists but its round trip is not stable enough to expose yet ...(cat?.capabilityOverrides ?? {}), }, limits: { contextTokens: m.context_length ?? cat?.limits?.contextTokens, maxOutputTokens: cat?.limits?.maxOutputTokens ?? 32_768 }, parameters: { temperature: false, // only 1 accepted topP: false, // only 0.95 accepted topK: false, maxTokens: true, stop: true, seed: false, frequencyPenalty: false, // only 0 accepted presencePenalty: false, reasoningEffort: reasoning && (cat?.metadata?.thinkingKnob ?? (m.reasoning_efforts?.support ? "reasoning_effort" : "thinking")) !== "fixed", reasoningEffortLevels: reasoning ? ((cat?.metadata?.thinkingKnob ?? "thinking") === "fixed" ? undefined : m.reasoning_efforts?.support ? efforts : ["none", "high"]) : undefined, thinkingBudget: false, ...(cat?.parameters ?? {}), }, status: cat?.status ?? "active", pricing: cat?.pricing ?? null, metadata: { jsonSchema: false, jsonWordRequired: true, ...(cat?.metadata ?? {}), alwaysThinking, defaultReasoningEffort: m.reasoning_efforts?.default_effort ?? m.think_efforts?.default_effort, dynamicTools: m.supports_dynamic_tools, createdAt: m.created ? new Date(m.created * 1000).toISOString() : undefined, sortWeight: cat?.sortWeight ?? (m.id.startsWith("kimi-k3") ? 100 : m.id.includes("2.7-code-highspeed") ? 88 : m.id.includes("2.7") ? 90 : 85), }, }; } function prettify(id: string) { return id.replace(/^kimi-/, "Kimi ").replace(/-code/, " Code").replace(/-highspeed/, " High-speed").replace(/\bk(\d)/, "K$1"); } async function list(_client: OpenAI, apiKey: string, signal?: AbortSignal): Promise { const headers = { Authorization: `Bearer ${apiKey}` }; const res = await fetch("https://api.moonshot.ai/v1/models", { headers, signal }); if (!res.ok) { const body = (await res.json().catch(() => ({}))) as { error?: { message?: string; type?: string } }; throw Object.assign(new Error(body.error?.message ?? `HTTP ${res.status}`), { status: res.status, error: body, headers: res.headers }); } const data = (await res.json()) as { data: { id: string }[] }; const details = await Promise.all( data.data.map(async (m) => { const r = await fetch(`https://api.moonshot.ai/v1/models/${encodeURIComponent(m.id)}`, { headers, signal }).catch(() => null); return r && r.ok ? ((await r.json()) as KimiModel) : ({ id: m.id } as KimiModel); }), ); return details.map(normalizeKimiModel).filter((m): m is PolyModel => Boolean(m)); } export const kimiAdapter = createOpenAICompatAdapter({ id: "kimi", name: "Kimi (Moonshot AI)", baseURL: "https://api.moonshot.ai/v1", keyDocsUrl: "https://platform.moonshot.ai/console/api-keys", keyPrefixHint: "sk-", listModels: list, useMaxTokens: true, messageOptions: { inlineFiles: true, replayReasoningContent: true }, tweakParams: (params, settings, req) => { const p = params as unknown as Record; const meta = (req.modelInfo?.metadata ?? {}) as Record; delete p.temperature; // only the default (1) is accepted delete p.top_p; // only 0.95 is accepted delete p.seed; delete p.frequency_penalty; // only 0 is accepted delete p.presence_penalty; const knob = (meta.thinkingKnob as string | undefined) ?? "thinking"; const effort = settings.reasoningEffort; if (req.modelInfo?.capabilities.reasoning && effort && knob !== "fixed") { if (knob === "reasoning_effort") { // K3: low | high | max (medium accepted); "none" is undocumented → map to low. p.reasoning_effort = effort === "none" || effort === "minimal" || effort === "low" ? "low" : effort === "max" ? "max" : effort === "medium" ? "medium" : "high"; } else if (effort === "none") p.thinking = { type: "disabled" }; else p.thinking = { type: "enabled" }; } // Reasoning consumes max_tokens; the API does not validate the cap, so clamp and raise tiny caps. 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); // Forced/required tool choice → 400 with thinking on K2.x and forced functions everywhere → fall back to auto. if (params.tool_choice && params.tool_choice !== "none" && (typeof params.tool_choice === "object" || meta.toolChoiceRequired === false)) params.tool_choice = "auto"; }, refineError: (status, code, message) => { if (status === 401 || code === "invalid_authentication_error") return "INVALID_API_KEY"; if (status === 403 && /balance|quota|insufficient/i.test(message)) return "INSUFFICIENT_CREDITS"; if (status === 404) return "MODEL_NOT_FOUND"; if (code === "exceeded_current_quota_error" || code === "rate_limit_reached_error" || code === "engine_overloaded_error") return "RATE_LIMITED"; if (code === "content_filter") return "CONTENT_REJECTED"; if (/only 1 is allowed|only 0.95 is allowed/i.test(message)) return "INVALID_PARAMETER"; return undefined; }, });