SPB Git forge

spb/polyllm

Public
15commits 1branches 0releases
2.2 MBsize
maindefault branch
13 days agolast push
TypeScript 97.4% SQL 1% JavaScript 0.9% CSS 0.6%
7.4 KB · 147 lines typescript
Raw Blame History
1import type OpenAI from "openai";2import { type PolyModel, modelKey } from "@/lib/ai/core/types";3import { createOpenAICompatAdapter } from "../shared/openai-compat/factory";4import { KIMI_CATALOG } from "./catalog";56/**7 * Kimi (Moonshot AI, international platform) — OpenAI-compatible at https://api.moonshot.ai/v1.8 * `GET /v1/models/{id}` exposes `supports_image_in`, `supports_video_in`, `supports_reasoning`,9 * `think_efforts.valid_efforts`, `supports_thinking_type` ("only" = cannot be disabled) and10 * `context_length` — used live; pricing comes from the catalog (docs/provider-research/kimi.md).11 * Verified quirks: temperature must be 1 and top_p 0.95 (400 otherwise), thinking streams as12 * `delta.reasoning_content` and must be replayed on assistant turns, `json_schema` is not enforced13 * (json_object + prompt instead).14 */15interface KimiModel {16  id: string;17  created?: number;18  context_length?: number;19  supports_image_in?: boolean;20  supports_video_in?: boolean;21  supports_reasoning?: boolean;22  supports_dynamic_tools?: boolean;23  think_efforts?: { support?: boolean; valid_efforts?: string[]; default_effort?: string };24  reasoning_efforts?: { support?: boolean; valid_efforts?: string[]; default_effort?: string };25  supports_thinking_type?: string; // "only" | "optional" | …26}2728export function normalizeKimiModel(m: KimiModel): PolyModel | null {29  if (!m.id.startsWith("kimi") && !m.id.startsWith("moonshot")) return null;30  const cat = KIMI_CATALOG.get(m.id);31  const reasoning = m.supports_reasoning ?? true;32  const efforts = m.reasoning_efforts?.valid_efforts ?? m.think_efforts?.valid_efforts ?? ["low", "high", "max"];33  const alwaysThinking = m.supports_thinking_type === "only";34  return {35    key: modelKey("kimi", m.id),36    id: m.id,37    provider: "kimi",38    displayName: cat?.displayName ?? prettify(m.id),39    family: cat?.family ?? (m.id.startsWith("kimi-k3") ? "Kimi K3" : "Kimi K2"),40    capabilities: {41      text: true,42      vision: m.supports_image_in ?? true,43      audioInput: false,44      audioOutput: false,45      imageGeneration: false,46      video: m.supports_video_in ?? false,47      reasoning,48      tools: true,49      structuredOutput: true,50      streaming: true,51      files: false,52      webSearch: false, // `$web_search` builtin exists but its round trip is not stable enough to expose yet53      ...(cat?.capabilityOverrides ?? {}),54    },55    limits: { contextTokens: m.context_length ?? cat?.limits?.contextTokens, maxOutputTokens: cat?.limits?.maxOutputTokens ?? 32_768 },56    parameters: {57      temperature: false, // only 1 accepted58      topP: false, // only 0.95 accepted59      topK: false,60      maxTokens: true,61      stop: true,62      seed: false,63      frequencyPenalty: false, // only 0 accepted64      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      thinkingBudget: false,68      ...(cat?.parameters ?? {}),69    },70    status: cat?.status ?? "active",71    pricing: cat?.pricing ?? null,72    metadata: {73      jsonSchema: false,74      jsonWordRequired: true,75      ...(cat?.metadata ?? {}),76      alwaysThinking,77      defaultReasoningEffort: m.reasoning_efforts?.default_effort ?? m.think_efforts?.default_effort,78      dynamicTools: m.supports_dynamic_tools,79      createdAt: m.created ? new Date(m.created * 1000).toISOString() : undefined,80      sortWeight: cat?.sortWeight ?? (m.id.startsWith("kimi-k3") ? 100 : m.id.includes("2.7-code-highspeed") ? 88 : m.id.includes("2.7") ? 90 : 85),81    },82  };83}8485function prettify(id: string) {86  return id.replace(/^kimi-/, "Kimi ").replace(/-code/, " Code").replace(/-highspeed/, " High-speed").replace(/\bk(\d)/, "K$1");87}8889async function list(_client: OpenAI, apiKey: string, signal?: AbortSignal): Promise<PolyModel[]> {90  const headers = { Authorization: `Bearer ${apiKey}` };91  const res = await fetch("https://api.moonshot.ai/v1/models", { headers, signal });92  if (!res.ok) {93    const body = (await res.json().catch(() => ({}))) as { error?: { message?: string; type?: string } };94    throw Object.assign(new Error(body.error?.message ?? `HTTP ${res.status}`), { status: res.status, error: body, headers: res.headers });95  }96  const data = (await res.json()) as { data: { id: string }[] };97  const details = await Promise.all(98    data.data.map(async (m) => {99      const r = await fetch(`https://api.moonshot.ai/v1/models/${encodeURIComponent(m.id)}`, { headers, signal }).catch(() => null);100      return r && r.ok ? ((await r.json()) as KimiModel) : ({ id: m.id } as KimiModel);101    }),102  );103  return details.map(normalizeKimiModel).filter((m): m is PolyModel => Boolean(m));104}105106export const kimiAdapter = createOpenAICompatAdapter({107  id: "kimi",108  name: "Kimi (Moonshot AI)",109  baseURL: "https://api.moonshot.ai/v1",110  keyDocsUrl: "https://platform.moonshot.ai/console/api-keys",111  keyPrefixHint: "sk-",112  listModels: list,113  useMaxTokens: true,114  messageOptions: { inlineFiles: true, replayReasoningContent: true },115  tweakParams: (params, settings, req) => {116    const p = params as unknown as Record<string, unknown>;117    const meta = (req.modelInfo?.metadata ?? {}) as Record<string, unknown>;118    delete p.temperature; // only the default (1) is accepted119    delete p.top_p; // only 0.95 is accepted120    delete p.seed;121    delete p.frequency_penalty; // only 0 is accepted122    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" };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";136  },137  refineError: (status, code, message) => {138    if (status === 401 || code === "invalid_authentication_error") return "INVALID_API_KEY";139    if (status === 403 && /balance|quota|insufficient/i.test(message)) return "INSUFFICIENT_CREDITS";140    if (status === 404) return "MODEL_NOT_FOUND";141    if (code === "exceeded_current_quota_error" || code === "rate_limit_reached_error" || code === "engine_overloaded_error") return "RATE_LIMITED";142    if (code === "content_filter") return "CONTENT_REJECTED";143    if (/only 1 is allowed|only 0.95 is allowed/i.test(message)) return "INVALID_PARAMETER";144    return undefined;145  },146});147