From a91330bae03022c2b7632776a019c40885a2d5d3 Mon Sep 17 00:00:00 2001 From: can1357 Date: Wed, 28 Jan 2026 13:21:32 +0100 Subject: [PATCH] feat(ai): added Kimi Code provider integration with OAuth and token management - Added Kimi Code provider integration with OAuth device authorization flow and token management. - Added four new Kimi Code models (kimi-for-coding, kimi-k2, kimi-k2-turbo-preview, kimi-k2.5) with reasoning support and 262K context window. - Added kimiUsageProvider for fetching and caching Kimi Code API usage quota information. - Added kimi-code login command to CLI for OAuth authentication with Kimi Code. - Updated openai-completions provider to support Kimi-specific headers and cached token formats. - Updated MiniMax-M2 model pricing: input 1.2->0.6, output 1.2->3, cacheRead 0.6->0.1. --- biome.json | 2 +- package.json | 2 +- packages/ai/scripts/generate-models.ts | 163 ++++++++- packages/ai/src/cli.ts | 13 + packages/ai/src/index.ts | 1 + packages/ai/src/models.generated.ts | 102 ++++-- .../ai/src/providers/openai-completions.ts | 16 +- packages/ai/src/types.ts | 1 + packages/ai/src/usage/kimi.ts | 319 ++++++++++++++++++ packages/ai/src/utils/oauth/index.ts | 12 + packages/ai/src/utils/oauth/kimi.ts | 256 ++++++++++++++ packages/ai/src/utils/oauth/types.ts | 1 + .../coding-agent/src/config/model-resolver.ts | 1 + .../coding-agent/src/session/auth-storage.ts | 6 + 14 files changed, 867 insertions(+), 28 deletions(-) create mode 100644 packages/ai/src/usage/kimi.ts create mode 100644 packages/ai/src/utils/oauth/kimi.ts diff --git a/biome.json b/biome.json index c5715eac2..edcb5b88c 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.3.12/schema.json", + "$schema": "https://biomejs.dev/schemas/2.3.13/schema.json", "linter": { "enabled": true, "rules": { diff --git a/package.json b/package.json index ce16c9d57..643edca34 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "release": "bun scripts/release.ts" }, "devDependencies": { - "@biomejs/biome": "2.3.12", + "@biomejs/biome": "2.3.13", "@bufbuild/protoc-gen-es": "^2.10.2", "@types/bun": "^1.2.18", "@types/node": "^25.0.10", diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 3aa5a21d6..49159c9c3 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -172,6 +172,165 @@ async function fetchAiGatewayModels(): Promise[]> { } } +const KIMI_CODE_BASE_URL = "https://api.kimi.com/coding/v1"; +const KIMI_CODE_DEFAULT_MAX_TOKENS = 32000; +const KIMI_CODE_HEADERS = { + "User-Agent": "KimiCLI/1.0", + "X-Msh-Platform": "kimi_cli", +} as const; + +interface KimiModelInfo { + id: string; + display_name?: string; + context_length?: number; + supports_reasoning?: boolean; + supports_image_in?: boolean; + supports_video_in?: boolean; +} + +async function fetchKimiCodeModels(): Promise[]> { + // Kimi Code /models endpoint requires authentication + // Use KIMI_API_KEY env var if available, otherwise return fallback models + const apiKey = process.env.KIMI_API_KEY; + + if (apiKey) { + try { + console.log("Fetching models from Kimi Code API..."); + const response = await fetch(`${KIMI_CODE_BASE_URL}/models`, { + headers: { Authorization: `Bearer ${apiKey}` }, + }); + + if (!response.ok) { + console.warn(`Kimi Code API returned ${response.status}, using fallback models`); + return getKimiCodeFallbackModels(); + } + + const data = await response.json(); + const items = Array.isArray(data.data) ? (data.data as KimiModelInfo[]) : []; + const models: Model<"openai-completions">[] = []; + + for (const model of items) { + if (!model.id) continue; + + // Derive capabilities from model info + const hasThinking = model.supports_reasoning || model.id.toLowerCase().includes("thinking"); + const hasImage = model.supports_image_in || model.id.toLowerCase().includes("k2.5"); + + const input: ("text" | "image")[] = ["text"]; + if (hasImage) input.push("image"); + + // Use display_name if available, otherwise format from model ID + const name = + model.display_name || + model.id + .split("-") + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); + + models.push({ + id: model.id, + name, + api: "openai-completions", + provider: "kimi-code", + baseUrl: KIMI_CODE_BASE_URL, + headers: { ...KIMI_CODE_HEADERS }, + reasoning: hasThinking, + input, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: model.context_length || 262144, + maxTokens: KIMI_CODE_DEFAULT_MAX_TOKENS, + compat: { + thinkingFormat: "zai", + reasoningContentField: "reasoning_content", + supportsDeveloperRole: false, + }, + }); + } + + console.log(`Fetched ${models.length} models from Kimi Code API`); + return models; + } catch (error) { + console.error("Failed to fetch Kimi Code models:", error); + return getKimiCodeFallbackModels(); + } + } + + console.log("KIMI_API_KEY not set, using fallback Kimi Code models"); + return getKimiCodeFallbackModels(); +} + +function getKimiCodeFallbackModels(): Model<"openai-completions">[] { + // Kimi Code models - the /models endpoint returns "kimi-for-coding" but the API + // accepts various model IDs. "kimi-for-coding" is an alias powered by kimi-k2.5. + const CONTEXT = 262144; + const MAX_TOKENS = KIMI_CODE_DEFAULT_MAX_TOKENS; + const compat = { + thinkingFormat: "zai" as const, + reasoningContentField: "reasoning_content" as const, + supportsDeveloperRole: false, + }; + const headers = { ...KIMI_CODE_HEADERS }; + + return [ + { + id: "kimi-for-coding", + name: "Kimi For Coding", + api: "openai-completions", + provider: "kimi-code", + baseUrl: KIMI_CODE_BASE_URL, + headers, + reasoning: true, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: CONTEXT, + maxTokens: MAX_TOKENS, + compat, + }, + { + id: "kimi-k2.5", + name: "Kimi K2.5", + api: "openai-completions", + provider: "kimi-code", + baseUrl: KIMI_CODE_BASE_URL, + headers, + reasoning: true, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: CONTEXT, + maxTokens: MAX_TOKENS, + compat, + }, + { + id: "kimi-k2-turbo-preview", + name: "Kimi K2 Turbo Preview", + api: "openai-completions", + provider: "kimi-code", + baseUrl: KIMI_CODE_BASE_URL, + headers, + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: CONTEXT, + maxTokens: MAX_TOKENS, + compat, + }, + { + id: "kimi-k2", + name: "Kimi K2", + api: "openai-completions", + provider: "kimi-code", + baseUrl: KIMI_CODE_BASE_URL, + headers, + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: CONTEXT, + maxTokens: MAX_TOKENS, + compat, + }, + ]; +} + async function loadModelsDevData(): Promise[]> { try { console.log("Fetching models from models.dev API..."); @@ -624,9 +783,10 @@ async function generateModels() { const modelsDevModels = await loadModelsDevData(); const openRouterModels = await fetchOpenRouterModels(); const aiGatewayModels = await fetchAiGatewayModels(); + const kimiCodeModels = await fetchKimiCodeModels(); // Combine models (models.dev has priority) - const allModels = [...modelsDevModels, ...openRouterModels, ...aiGatewayModels]; + const allModels = [...modelsDevModels, ...openRouterModels, ...aiGatewayModels, ...kimiCodeModels]; // Fix incorrect cache pricing for Claude Opus 4.5 from models.dev // models.dev has 3x the correct pricing (1.5/18.75 instead of 0.5/6.25) @@ -767,6 +927,7 @@ async function generateModels() { ]; allModels.push(...codexModels); + // Add missing Grok models if (!allModels.some((m) => m.provider === "xai" && m.id === "grok-code-fast-1")) { allModels.push({ diff --git a/packages/ai/src/cli.ts b/packages/ai/src/cli.ts index 9ee2a3839..4e4762bb8 100755 --- a/packages/ai/src/cli.ts +++ b/packages/ai/src/cli.ts @@ -8,6 +8,7 @@ import { loginCursor } from "./utils/oauth/cursor"; import { loginGitHubCopilot } from "./utils/oauth/github-copilot"; import { loginAntigravity } from "./utils/oauth/google-antigravity"; import { loginGeminiCli } from "./utils/oauth/google-gemini-cli"; +import { loginKimi } from "./utils/oauth/kimi"; import { loginOpenAICodex } from "./utils/oauth/openai-codex"; import type { OAuthCredentials, OAuthProvider } from "./utils/oauth/types"; @@ -89,6 +90,17 @@ async function login(provider: OAuthProvider): Promise { }); break; + case "kimi-code": + credentials = await loginKimi({ + onAuth(info) { + const { url, instructions } = info; + console.log(`\nOpen this URL in your browser:\n${url}`); + if (instructions) console.log(instructions); + console.log(); + }, + }); + break; + case "cursor": credentials = await loginCursor( url => { @@ -132,6 +144,7 @@ Providers: google-gemini-cli Google Gemini CLI google-antigravity Antigravity (Gemini 3, Claude, GPT-OSS) openai-codex OpenAI Codex (ChatGPT Plus/Pro) + kimi-code Kimi Code cursor Cursor (Claude, GPT, etc.) Examples: diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index 431c0b3f0..d7fc4a079 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -16,6 +16,7 @@ export * from "./usage"; export * from "./usage/claude"; export * from "./usage/github-copilot"; export * from "./usage/google-antigravity"; +export * from "./usage/kimi"; export * from "./usage/openai-codex"; export * from "./usage/zai"; export * from "./utils/event-stream"; diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index ab94dae13..3d4bbe199 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -2925,6 +2925,84 @@ export const MODELS = { maxTokens: 16384, } satisfies Model<"openai-completions">, }, + "kimi-code": { + "kimi-for-coding": { + id: "kimi-for-coding", + name: "Kimi For Coding", + api: "openai-completions", + provider: "kimi-code", + baseUrl: "https://api.kimi.com/coding/v1", + headers: {"User-Agent":"KimiCLI/1.0","X-Msh-Platform":"kimi_cli"}, + compat: {"thinkingFormat":"zai","reasoningContentField":"reasoning_content","supportsDeveloperRole":false}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 32000, + } satisfies Model<"openai-completions">, + "kimi-k2": { + id: "kimi-k2", + name: "Kimi K2", + api: "openai-completions", + provider: "kimi-code", + baseUrl: "https://api.kimi.com/coding/v1", + headers: {"User-Agent":"KimiCLI/1.0","X-Msh-Platform":"kimi_cli"}, + compat: {"thinkingFormat":"zai","reasoningContentField":"reasoning_content","supportsDeveloperRole":false}, + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 32000, + } satisfies Model<"openai-completions">, + "kimi-k2-turbo-preview": { + id: "kimi-k2-turbo-preview", + name: "Kimi K2 Turbo Preview", + api: "openai-completions", + provider: "kimi-code", + baseUrl: "https://api.kimi.com/coding/v1", + headers: {"User-Agent":"KimiCLI/1.0","X-Msh-Platform":"kimi_cli"}, + compat: {"thinkingFormat":"zai","reasoningContentField":"reasoning_content","supportsDeveloperRole":false}, + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 32000, + } satisfies Model<"openai-completions">, + "kimi-k2.5": { + id: "kimi-k2.5", + name: "Kimi K2.5", + api: "openai-completions", + provider: "kimi-code", + baseUrl: "https://api.kimi.com/coding/v1", + headers: {"User-Agent":"KimiCLI/1.0","X-Msh-Platform":"kimi_cli"}, + compat: {"thinkingFormat":"zai","reasoningContentField":"reasoning_content","supportsDeveloperRole":false}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 32000, + } satisfies Model<"openai-completions">, + }, "minimax": { "MiniMax-M2": { id: "MiniMax-M2", @@ -4475,9 +4553,9 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 1.2, - output: 1.2, - cacheRead: 0.6, + input: 0.6, + output: 3, + cacheRead: 0.1, cacheWrite: 0, }, contextWindow: 262144, @@ -11065,23 +11143,5 @@ export const MODELS = { contextWindow: 204800, maxTokens: 131072, } satisfies Model<"openai-completions">, - "glm-4.7-flash": { - id: "glm-4.7-flash", - name: "GLM-4.7-Flash", - api: "openai-completions", - provider: "zai", - baseUrl: "https://api.z.ai/api/coding/paas/v4", - compat: {"supportsDeveloperRole":false,"thinkingFormat":"zai"}, - reasoning: true, - input: ["text"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 200000, - maxTokens: 131072, - } satisfies Model<"openai-completions">, }, } as const; diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index 24ca17e44..8bdd79a35 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -28,6 +28,7 @@ import type { } from "../types"; import { AssistantMessageEventStream } from "../utils/event-stream"; import { parseStreamingJson } from "../utils/json-parse"; +import { getKimiCommonHeaders } from "../utils/oauth/kimi"; import { formatErrorMessageWithRetryAfter } from "../utils/retry-after"; import { sanitizeSurrogates } from "../utils/sanitize-unicode"; import { mapToOpenAICompletionsToolChoice } from "../utils/tool-choice"; @@ -111,7 +112,7 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = ( try { const apiKey = options?.apiKey || getEnvApiKey(model.provider) || ""; - const client = createClient(model, context, apiKey, options?.headers); + const client = await createClient(model, context, apiKey, options?.headers); const params = buildParams(model, context, options); options?.onPayload?.(params); const openaiStream = await client.chat.completions.create(params, { signal: options?.signal }); @@ -151,7 +152,11 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = ( for await (const chunk of openaiStream) { if (chunk.usage) { - const cachedTokens = chunk.usage.prompt_tokens_details?.cached_tokens || 0; + // Check for cached_tokens at root level (Kimi) or in prompt_tokens_details (OpenAI) + const cachedTokens = + (chunk.usage as { cached_tokens?: number }).cached_tokens ?? + chunk.usage.prompt_tokens_details?.cached_tokens ?? + 0; const reasoningTokens = chunk.usage.completion_tokens_details?.reasoning_tokens || 0; const input = (chunk.usage.prompt_tokens || 0) - cachedTokens; const outputTokens = (chunk.usage.completion_tokens || 0) + reasoningTokens; @@ -335,7 +340,7 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = ( return stream; }; -function createClient( +async function createClient( model: Model<"openai-completions">, context: Context, apiKey?: string, @@ -350,7 +355,10 @@ function createClient( apiKey = process.env.OPENAI_API_KEY; } - const headers = { ...(model.headers ?? {}), ...(extraHeaders ?? {}) }; + let headers = { ...(model.headers ?? {}), ...(extraHeaders ?? {}) }; + if (model.provider === "kimi-code") { + headers = { ...(await getKimiCommonHeaders()), ...headers }; + } if (model.provider === "github-copilot") { // Copilot expects X-Initiator to indicate whether the request is user-initiated // or agent-initiated (e.g. follow-up after assistant/tool messages). If there is diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index abe4cac96..6a05f7a57 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -76,6 +76,7 @@ export type KnownProvider = | "google-vertex" | "openai" | "openai-codex" + | "kimi-code" | "github-copilot" | "cursor" | "xai" diff --git a/packages/ai/src/usage/kimi.ts b/packages/ai/src/usage/kimi.ts new file mode 100644 index 000000000..caaef10da --- /dev/null +++ b/packages/ai/src/usage/kimi.ts @@ -0,0 +1,319 @@ +import type { + UsageAmount, + UsageCache, + UsageFetchContext, + UsageFetchParams, + UsageLimit, + UsageProvider, + UsageReport, + UsageStatus, + UsageWindow, +} from "../usage"; +import { getKimiCommonHeaders } from "../utils/oauth/kimi"; + +const DEFAULT_BASE_URL = "https://api.kimi.com/coding/v1"; +const USAGE_PATH = "usages"; +const DEFAULT_CACHE_TTL_MS = 60_000; + +interface KimiUsagePayload { + usage?: unknown; + limits?: unknown; +} + +type KimiUsageRow = { + label: string; + used?: number; + limit?: number; + remaining?: number; + resetsAt?: number; + resetInMs?: number; + window?: UsageWindow; +}; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +const toNumber = (value: unknown): number | undefined => { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string") { + const trimmed = value.trim(); + if (!trimmed) return undefined; + const parsed = Number(trimmed); + if (Number.isFinite(parsed)) return parsed; + } + return undefined; +}; + +function normalizeBaseUrl(baseUrl?: string): string { + const envBase = process.env.KIMI_CODE_BASE_URL?.trim(); + const candidate = baseUrl?.trim() || envBase || DEFAULT_BASE_URL; + return candidate.replace(/\/+$/, ""); +} + +function buildUsageUrl(baseUrl: string): string { + const normalized = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`; + return `${normalized}${USAGE_PATH}`; +} + +function parseResetTimes(data: Record, nowMs: number): Pick { + const timeKeys = ["reset_at", "resetAt", "reset_time", "resetTime"] as const; + for (const key of timeKeys) { + const value = data[key]; + if (typeof value === "string" && value.trim()) { + const parsed = Date.parse(value); + if (Number.isFinite(parsed)) { + return { resetsAt: parsed, resetInMs: parsed - nowMs }; + } + } + if (typeof value === "number" && Number.isFinite(value)) { + const parsed = value > 1_000_000_000_000 ? value : value * 1000; + return { resetsAt: parsed, resetInMs: parsed - nowMs }; + } + } + + const secondsKeys = ["reset_in", "resetIn", "ttl", "window"] as const; + for (const key of secondsKeys) { + const seconds = toNumber(data[key]); + if (seconds !== undefined) { + return { resetsAt: nowMs + seconds * 1000, resetInMs: seconds * 1000 }; + } + } + + return {}; +} + +function formatDurationLabel(duration: number, timeUnit: string): string | undefined { + const upper = timeUnit.toUpperCase(); + if (upper.includes("MINUTE")) { + if (duration >= 60 && duration % 60 === 0) return `${duration / 60}h limit`; + return `${duration}m limit`; + } + if (upper.includes("HOUR")) return `${duration}h limit`; + if (upper.includes("DAY")) return `${duration}d limit`; + if (upper.includes("SECOND")) return `${duration}s limit`; + return undefined; +} + +function buildWindow(windowData: Record, nowMs: number): UsageWindow | undefined { + const duration = toNumber(windowData.duration); + const timeUnit = typeof windowData.timeUnit === "string" ? windowData.timeUnit : ""; + const label = duration !== undefined && timeUnit ? formatDurationLabel(duration, timeUnit) : undefined; + const resets = parseResetTimes(windowData, nowMs); + + if (duration === undefined && !label && !resets.resetsAt && !resets.resetInMs) return undefined; + let durationMs: number | undefined; + if (duration !== undefined) { + if (timeUnit.toUpperCase().includes("MINUTE")) durationMs = duration * 60_000; + else if (timeUnit.toUpperCase().includes("HOUR")) durationMs = duration * 3_600_000; + else if (timeUnit.toUpperCase().includes("DAY")) durationMs = duration * 86_400_000; + else if (timeUnit.toUpperCase().includes("SECOND")) durationMs = duration * 1000; + } + + return { + id: duration !== undefined && timeUnit ? `${duration}${timeUnit.toLowerCase()}` : "default", + label: label ?? "Usage window", + durationMs, + ...resets, + }; +} + +function buildUsageRow(data: Record, defaultLabel: string, nowMs: number): KimiUsageRow | null { + const limit = toNumber(data.limit); + let used = toNumber(data.used); + const remaining = toNumber(data.remaining); + if (used === undefined && remaining !== undefined && limit !== undefined) { + used = limit - remaining; + } + + if (used === undefined && limit === undefined) return null; + const resets = parseResetTimes(data, nowMs); + return { + label: + typeof data.name === "string" && data.name + ? data.name + : typeof data.title === "string" && data.title + ? data.title + : defaultLabel, + used, + limit, + remaining, + resetsAt: resets.resetsAt, + resetInMs: resets.resetInMs, + }; +} + +function buildUsageAmount(row: KimiUsageRow): UsageAmount { + const amount: UsageAmount = { unit: "unknown" }; + if (row.limit !== undefined) amount.limit = row.limit; + if (row.used !== undefined) amount.used = row.used; + if (row.remaining !== undefined) amount.remaining = row.remaining; + if (row.limit !== undefined && row.used !== undefined && row.limit > 0) { + amount.usedFraction = Math.min(Math.max(row.used / row.limit, 0), 1); + amount.remainingFraction = Math.min(Math.max((row.limit - row.used) / row.limit, 0), 1); + amount.remaining = amount.remaining ?? row.limit - row.used; + } + return amount; +} + +function buildUsageStatus(amount: UsageAmount): UsageStatus { + if (amount.usedFraction === undefined) return "unknown"; + if (amount.usedFraction >= 1) return "exhausted"; + if (amount.usedFraction >= 0.9) return "warning"; + return "ok"; +} + +function toUsageLimit(row: KimiUsageRow, provider: string, index: number, accountId?: string): UsageLimit { + const window: UsageWindow | undefined = + row.window ?? + (row.resetsAt || row.resetInMs + ? { + id: "default", + label: "Usage window", + resetsAt: row.resetsAt, + resetInMs: row.resetInMs, + } + : undefined); + + const amount = buildUsageAmount(row); + return { + id: `${provider}:${index}`, + label: row.label, + scope: { + provider, + accountId, + windowId: window?.id, + shared: true, + }, + window, + amount, + status: buildUsageStatus(amount), + }; +} + +function parseUsagePayload(payload: unknown, nowMs: number): { rows: KimiUsageRow[]; raw: KimiUsagePayload } | null { + if (!isRecord(payload)) return null; + const data = payload as KimiUsagePayload; + const rows: KimiUsageRow[] = []; + + if (isRecord(data.usage)) { + const summary = buildUsageRow(data.usage, "Total quota", nowMs); + if (summary) rows.push(summary); + } + + if (Array.isArray(data.limits)) { + data.limits.forEach((item, idx) => { + if (!isRecord(item)) return; + const detail = isRecord(item.detail) ? item.detail : item; + const windowData = isRecord(item.window) ? item.window : {}; + const label = + (typeof item.name === "string" && item.name) || + (typeof item.title === "string" && item.title) || + (typeof item.scope === "string" && item.scope) || + (typeof detail.name === "string" && detail.name) || + (typeof detail.title === "string" && detail.title) || + formatDurationLabel(toNumber(windowData.duration) ?? 0, String(windowData.timeUnit || "")) || + `Limit #${idx + 1}`; + const row = buildUsageRow(detail, label, nowMs); + if (row) { + row.window = buildWindow(windowData, nowMs); + rows.push(row); + } + }); + } + + return { rows, raw: data }; +} + +function resolveCacheExpiry(report: UsageReport | null, nowMs: number): number { + if (!report) return nowMs + DEFAULT_CACHE_TTL_MS; + const resetCandidates = report.limits + .map(limit => limit.window?.resetsAt) + .filter((value): value is number => typeof value === "number" && Number.isFinite(value)); + if (resetCandidates.length === 0) return nowMs + DEFAULT_CACHE_TTL_MS; + return Math.min(nowMs + DEFAULT_CACHE_TTL_MS, Math.min(...resetCandidates)); +} + +async function getCachedReport(cache: UsageCache, key: string, nowMs: number): Promise { + const cached = await cache.get(key); + if (!cached) return undefined; + if (cached.expiresAt <= nowMs) return undefined; + return cached.value; +} + +async function setCachedReport( + cache: UsageCache, + key: string, + report: UsageReport | null, + expiresAt: number, +): Promise { + await cache.set(key, { value: report, expiresAt }); +} + +export const kimiUsageProvider: UsageProvider = { + id: "kimi-code", + supports(params: UsageFetchParams): boolean { + return params.provider === "kimi-code" && params.credential.type === "oauth"; + }, + async fetchUsage(params: UsageFetchParams, ctx: UsageFetchContext): Promise { + if (params.provider !== "kimi-code") return null; + const { credential } = params; + if (credential.type !== "oauth") return null; + + const accessToken = credential.accessToken; + if (!accessToken) return null; + + const nowMs = ctx.now(); + if (credential.expiresAt !== undefined && credential.expiresAt <= nowMs) { + ctx.logger?.warn("Kimi usage token expired", { provider: params.provider }); + return null; + } + + const baseUrl = normalizeBaseUrl(params.baseUrl); + const accountKey = credential.accountId ?? credential.email ?? "unknown"; + const cacheKey = `usage:kimi-code:${accountKey}:${baseUrl}`; + const cached = await getCachedReport(ctx.cache, cacheKey, nowMs); + if (cached !== undefined) return cached; + + const url = buildUsageUrl(baseUrl); + let payload: unknown; + try { + const response = await ctx.fetch(url, { + headers: { + ...(await getKimiCommonHeaders()), + Authorization: `Bearer ${accessToken}`, + }, + signal: params.signal, + }); + if (!response.ok) { + ctx.logger?.warn("Kimi usage request failed", { status: response.status, provider: params.provider }); + return null; + } + payload = await response.json(); + } catch (error) { + ctx.logger?.warn("Kimi usage request error", { provider: params.provider, error: String(error) }); + return null; + } + + const parsed = parseUsagePayload(payload, nowMs); + if (!parsed || parsed.rows.length === 0) { + ctx.logger?.warn("Kimi usage response invalid", { provider: params.provider }); + return null; + } + + const limits = parsed.rows.map((row, index) => toUsageLimit(row, params.provider, index, credential.accountId)); + + const report: UsageReport = { + provider: params.provider, + fetchedAt: nowMs, + limits, + metadata: { + endpoint: url, + }, + raw: parsed.raw, + }; + + const expiresAt = resolveCacheExpiry(report, nowMs); + await setCachedReport(ctx.cache, cacheKey, report, expiresAt); + return report; + }, +}; diff --git a/packages/ai/src/utils/oauth/index.ts b/packages/ai/src/utils/oauth/index.ts index ee0023c7f..98c64f774 100644 --- a/packages/ai/src/utils/oauth/index.ts +++ b/packages/ai/src/utils/oauth/index.ts @@ -6,6 +6,7 @@ import { refreshCursorToken } from "./cursor"; import { refreshGitHubCopilotToken } from "./github-copilot"; import { refreshAntigravityToken } from "./google-antigravity"; import { refreshGoogleCloudToken } from "./google-gemini-cli"; +import { refreshKimiToken } from "./kimi"; import { refreshOpenAICodexToken } from "./openai-codex"; import type { OAuthCredentials, OAuthProvider, OAuthProviderInfo } from "./types"; @@ -18,6 +19,7 @@ import type { OAuthCredentials, OAuthProvider, OAuthProviderInfo } from "./types * - GitHub Copilot * - Google Cloud Code Assist (Gemini CLI) * - Antigravity (Gemini 3, Claude, GPT-OSS via Google Cloud) + * - Kimi Code */ // Anthropic @@ -41,6 +43,8 @@ export { export { loginAntigravity, refreshAntigravityToken } from "./google-antigravity"; // Google Gemini CLI export { loginGeminiCli, refreshGoogleCloudToken } from "./google-gemini-cli"; +// Kimi Code +export { loginKimi, refreshKimiToken } from "./kimi"; export type { OpenAICodexLoginOptions } from "./openai-codex"; // OpenAI Codex (ChatGPT OAuth) export { loginOpenAICodex, refreshOpenAICodexToken } from "./openai-codex"; @@ -83,6 +87,9 @@ export async function refreshOAuthToken( case "openai-codex": newCredentials = await refreshOpenAICodexToken(credentials.refresh); break; + case "kimi-code": + newCredentials = await refreshKimiToken(credentials.refresh); + break; case "cursor": newCredentials = await refreshCursorToken(credentials.refresh); break; @@ -141,6 +148,11 @@ export function getOAuthProviders(): OAuthProviderInfo[] { name: "ChatGPT Plus/Pro (Codex Subscription)", available: true, }, + { + id: "kimi-code", + name: "Kimi Code", + available: true, + }, { id: "github-copilot", name: "GitHub Copilot", diff --git a/packages/ai/src/utils/oauth/kimi.ts b/packages/ai/src/utils/oauth/kimi.ts new file mode 100644 index 000000000..1881dbca0 --- /dev/null +++ b/packages/ai/src/utils/oauth/kimi.ts @@ -0,0 +1,256 @@ +/** + * Kimi Code OAuth flow (device authorization grant) + */ + +import * as crypto from "node:crypto"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { abortableSleep, isEnoent } from "@oh-my-pi/pi-utils"; +import packageJson from "../../../package.json" with { type: "json" }; +import type { OAuthController, OAuthCredentials } from "./types"; + +const CLIENT_ID = "17e5f671-d194-4dfb-9706-5516cb48c098"; +const DEFAULT_OAUTH_HOST = "https://auth.kimi.com"; +const DEVICE_ID_FILENAME = "kimi-device-id"; +const DEFAULT_POLL_INTERVAL_MS = 5000; +const DEFAULT_DEVICE_FLOW_TTL_MS = 15 * 60 * 1000; + +interface DeviceAuthorizationResponse { + user_code?: string; + device_code?: string; + verification_uri?: string; + verification_uri_complete?: string; + expires_in?: number; + interval?: number; +} + +interface TokenResponse { + access_token?: string; + refresh_token?: string; + expires_in?: number; + scope?: string; + token_type?: string; + error?: string; + error_description?: string; + interval?: number; +} + +function getAgentDir(): string { + const configDir = process.env.OMP_CODING_AGENT_DIR || path.join(os.homedir(), ".omp", "agent"); + return configDir; +} + +function resolveOAuthHost(): string { + return process.env.KIMI_CODE_OAUTH_HOST || process.env.KIMI_OAUTH_HOST || DEFAULT_OAUTH_HOST; +} + +function formatDeviceModel(system: string, release: string, arch: string): string { + return [system, release, arch].filter(Boolean).join(" ").trim(); +} + +function getDeviceModel(): string { + const platform = os.platform(); + const release = os.release(); + const arch = os.arch(); + if (platform === "darwin") return formatDeviceModel("macOS", release, arch); + if (platform === "win32") return formatDeviceModel("Windows", release, arch); + const label = platform === "linux" ? "Linux" : platform; + return formatDeviceModel(label, release, arch); +} + +async function getDeviceId(): Promise { + const deviceIdPath = path.join(getAgentDir(), DEVICE_ID_FILENAME); + try { + const existing = await Bun.file(deviceIdPath).text(); + const trimmed = existing.trim(); + if (trimmed) return trimmed; + } catch (error) { + if (!isEnoent(error)) throw error; + } + + const deviceId = crypto.randomUUID().replace(/-/g, ""); + await Bun.write(deviceIdPath, `${deviceId}\n`); + await fs.chmod(deviceIdPath, 0o600).catch(() => undefined); + return deviceId; +} + +async function buildCommonHeaders(): Promise> { + return { + "User-Agent": `KimiCLI/${packageJson.version}`, + "X-Msh-Platform": "kimi_cli", + "X-Msh-Version": packageJson.version, + "X-Msh-Device-Name": os.hostname(), + "X-Msh-Device-Model": getDeviceModel(), + "X-Msh-Os-Version": os.version(), + "X-Msh-Device-Id": await getDeviceId(), + }; +} + +export async function getKimiCommonHeaders(): Promise> { + return buildCommonHeaders(); +} + +async function requestDeviceAuthorization(): Promise<{ + userCode: string; + deviceCode: string; + verificationUri: string; + verificationUriComplete: string; + expiresInMs: number; + intervalMs: number; +}> { + const response = await fetch(`${resolveOAuthHost()}/api/oauth/device_authorization`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + ...(await buildCommonHeaders()), + }, + body: new URLSearchParams({ client_id: CLIENT_ID }), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Kimi device authorization failed: ${response.status} ${text}`); + } + + const payload = (await response.json()) as DeviceAuthorizationResponse; + const userCode = payload.user_code; + const deviceCode = payload.device_code; + const verificationUri = payload.verification_uri; + const verificationUriComplete = payload.verification_uri_complete; + + if (!userCode || !deviceCode || !verificationUri) { + throw new Error("Kimi device authorization response missing required fields"); + } + + const expiresInMs = typeof payload.expires_in === "number" ? payload.expires_in * 1000 : DEFAULT_DEVICE_FLOW_TTL_MS; + const intervalMs = + typeof payload.interval === "number" && payload.interval > 0 ? payload.interval * 1000 : DEFAULT_POLL_INTERVAL_MS; + + return { + userCode, + deviceCode, + verificationUri, + verificationUriComplete: verificationUriComplete || verificationUri, + expiresInMs, + intervalMs, + }; +} + +function parseTokenPayload(payload: TokenResponse, refreshTokenFallback?: string): OAuthCredentials { + if (!payload.access_token || typeof payload.expires_in !== "number") { + throw new Error("Kimi token response missing required fields"); + } + + const refresh = payload.refresh_token ?? refreshTokenFallback; + if (!refresh) { + throw new Error("Kimi token response missing refresh token"); + } + + return { + access: payload.access_token, + refresh, + expires: Date.now() + payload.expires_in * 1000, + }; +} + +async function pollForToken( + deviceCode: string, + intervalMs: number, + expiresInMs: number, + signal?: AbortSignal, +): Promise { + const deadline = Date.now() + expiresInMs; + let waitMs = Math.max(1000, intervalMs); + + while (Date.now() < deadline) { + if (signal?.aborted) { + throw new Error("Login cancelled"); + } + + const response = await fetch(`${resolveOAuthHost()}/api/oauth/token`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + ...(await buildCommonHeaders()), + }, + body: new URLSearchParams({ + client_id: CLIENT_ID, + device_code: deviceCode, + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + }), + }); + + const payload = (await response.json()) as TokenResponse; + if (response.ok && payload.access_token) { + return parseTokenPayload(payload); + } + + const error = payload.error; + if (error === "authorization_pending") { + await abortableSleep(waitMs, signal); + continue; + } + + if (error === "slow_down") { + waitMs += 5000; + const retryAfter = typeof payload.interval === "number" ? payload.interval * 1000 : undefined; + if (retryAfter && retryAfter > waitMs) waitMs = retryAfter; + await abortableSleep(waitMs, signal); + continue; + } + + if (error === "expired_token") { + throw new Error("Kimi device authorization expired"); + } + + if (error === "access_denied") { + throw new Error("Kimi device authorization denied"); + } + + const description = payload.error_description ? `: ${payload.error_description}` : ""; + throw new Error(`Kimi device flow failed: ${error ?? response.status}${description}`); + } + + throw new Error("Kimi device flow timed out"); +} + +/** + * Login with Kimi Code OAuth (device code flow). + */ +export async function loginKimi(options: OAuthController): Promise { + const device = await requestDeviceAuthorization(); + options.onAuth?.({ + url: device.verificationUriComplete, + instructions: `Enter code: ${device.userCode}`, + }); + + return pollForToken(device.deviceCode, device.intervalMs, device.expiresInMs, options.signal); +} + +/** + * Refresh Kimi OAuth token. + */ +export async function refreshKimiToken(refreshToken: string): Promise { + const response = await fetch(`${resolveOAuthHost()}/api/oauth/token`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + ...(await buildCommonHeaders()), + }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: CLIENT_ID, + }), + }); + + if (!response.ok) { + const payload = (await response.json().catch(() => undefined)) as TokenResponse | undefined; + const description = payload?.error_description ? `: ${payload.error_description}` : ""; + throw new Error(`Kimi token refresh failed: ${response.status}${description}`); + } + + const payload = (await response.json()) as TokenResponse; + return parseTokenPayload(payload, refreshToken); +} diff --git a/packages/ai/src/utils/oauth/types.ts b/packages/ai/src/utils/oauth/types.ts index 1157aa3d0..dcd00ca18 100644 --- a/packages/ai/src/utils/oauth/types.ts +++ b/packages/ai/src/utils/oauth/types.ts @@ -13,6 +13,7 @@ export type OAuthProvider = | "github-copilot" | "google-gemini-cli" | "google-antigravity" + | "kimi-code" | "openai-codex" | "cursor"; diff --git a/packages/coding-agent/src/config/model-resolver.ts b/packages/coding-agent/src/config/model-resolver.ts index 3febd737d..54540a932 100644 --- a/packages/coding-agent/src/config/model-resolver.ts +++ b/packages/coding-agent/src/config/model-resolver.ts @@ -28,6 +28,7 @@ export const defaultModelPerProvider: Record = { mistral: "devstral-medium-latest", minimax: "MiniMax-M2", opencode: "claude-opus-4-5", + "kimi-code": "kimi-k2.5", }; export interface ScopedModel { diff --git a/packages/coding-agent/src/session/auth-storage.ts b/packages/coding-agent/src/session/auth-storage.ts index d82615c1a..8272af4cf 100644 --- a/packages/coding-agent/src/session/auth-storage.ts +++ b/packages/coding-agent/src/session/auth-storage.ts @@ -11,11 +11,13 @@ import { getOAuthApiKey, githubCopilotUsageProvider, googleGeminiCliUsageProvider, + kimiUsageProvider, loginAnthropic, loginAntigravity, loginCursor, loginGeminiCli, loginGitHubCopilot, + loginKimi, loginOpenAICodex, type OAuthController, type OAuthCredentials, @@ -85,6 +87,7 @@ export type AuthStorageOptions = { const DEFAULT_USAGE_PROVIDERS: UsageProvider[] = [ openaiCodexUsageProvider, + kimiUsageProvider, antigravityUsageProvider, googleGeminiCliUsageProvider, claudeUsageProvider, @@ -770,6 +773,9 @@ export class AuthStorage { case "openai-codex": credentials = await loginOpenAICodex(ctrl); break; + case "kimi-code": + credentials = await loginKimi(ctrl); + break; case "cursor": credentials = await loginCursor( url => ctrl.onAuth({ url }),