From 9b5de7989814c966c498b00c0b4d2d92c03d674d Mon Sep 17 00:00:00 2001 From: Serhii Vecherenko Date: Fri, 17 Jul 2026 03:10:14 -0700 Subject: [PATCH] feat(usage): add Kimi Code usage tracking and credentials --- .../agents-usage/src/collectors/kimi.test.ts | 217 ++++++++++++++ packages/agents-usage/src/collectors/kimi.ts | 267 ++++++++++++++++++ packages/agents-usage/src/index.ts | 8 + packages/agents-usage/src/providers.ts | 7 + packages/agents-usage/src/registry.test.ts | 6 +- packages/agents-usage/src/registry.ts | 7 + src/main/usageLogin/UsageLoginManager.test.ts | 6 + src/main/usageLogin/UsageLoginManager.ts | 17 +- .../providers/usageProviders.test.ts | 17 ++ .../components/providers/usageProviders.ts | 20 +- .../runtime/kimiCredentials.test.ts | 71 +++++ src/supervisor/runtime/kimiCredentials.ts | 111 ++++++++ src/supervisor/runtime/usageCredentials.ts | 2 + src/supervisor/runtime/usageService.test.ts | 1 + 14 files changed, 743 insertions(+), 14 deletions(-) create mode 100644 packages/agents-usage/src/collectors/kimi.test.ts create mode 100644 packages/agents-usage/src/collectors/kimi.ts create mode 100644 src/supervisor/runtime/kimiCredentials.test.ts create mode 100644 src/supervisor/runtime/kimiCredentials.ts diff --git a/packages/agents-usage/src/collectors/kimi.test.ts b/packages/agents-usage/src/collectors/kimi.test.ts new file mode 100644 index 000000000..0bbe23f77 --- /dev/null +++ b/packages/agents-usage/src/collectors/kimi.test.ts @@ -0,0 +1,217 @@ +import { describe, expect, it } from "vitest"; +import type { HttpRequest } from "../host"; +import { createFakeHost, FAKE_NOW_MS } from "../testHost"; +import { collectKimi, KIMI_USAGES_ENDPOINT, parseKimiUsage, resolveKimiUsagesUrl } from "./kimi"; + +const WEEKLY_RESET = "2026-01-09T15:23:13.716839300Z"; +const SESSION_RESET = "2026-01-06T13:33:02.717479433Z"; + +/** Verbatim shape from CodexBar's docs: weekly membership quota + 5h rate limit. */ +const USAGES_BODY = JSON.stringify({ + usage: { limit: "2048", used: "214", remaining: "1834", resetTime: WEEKLY_RESET }, + limits: [ + { + window: { duration: 300, timeUnit: "TIME_UNIT_MINUTE" }, + detail: { limit: "200", used: "139", remaining: "61", resetTime: SESSION_RESET }, + }, + ], +}); + +describe("parseKimiUsage", () => { + it("maps the membership quota to weekly and the 300-minute limit to session-5h", () => { + const snap = parseKimiUsage(JSON.parse(USAGES_BODY), FAKE_NOW_MS); + expect(snap.providerId).toBe("kimi"); + expect(snap.status).toBe("ok"); + // Fast window leads, matching the Claude/Codex card order. + expect(snap.windows.map((w) => w.id)).toEqual(["session-5h", "weekly"]); + + const weekly = snap.windows.find((w) => w.id === "weekly")!; + expect(weekly.usedPercent).toBeCloseTo((214 / 2048) * 100); + expect(weekly.resetsAt).toBe(Date.parse(WEEKLY_RESET)); + // Percent only — raw request counts would duplicate the percentage on the card. + expect(weekly.used).toBeUndefined(); + expect(weekly.limit).toBeUndefined(); + expect(weekly.unit).toBeUndefined(); + + const session = snap.windows.find((w) => w.id === "session-5h")!; + expect(session.usedPercent).toBeCloseTo((139 / 200) * 100); + expect(session.resetsAt).toBe(Date.parse(SESSION_RESET)); + }); + + it("derives used from limit-remaining when `used` is absent", () => { + const snap = parseKimiUsage( + { usage: { limit: "100", remaining: "25", resetTime: WEEKLY_RESET } }, + FAKE_NOW_MS, + ); + const weekly = snap.windows.find((w) => w.id === "weekly")!; + expect(weekly.usedPercent).toBe(75); + }); + + it("tolerates numeric counters and snake_case reset keys", () => { + const snap = parseKimiUsage( + { usage: { limit: 2048, used: 512, reset_time: WEEKLY_RESET } }, + FAKE_NOW_MS, + ); + const weekly = snap.windows.find((w) => w.id === "weekly")!; + expect(weekly.usedPercent).toBe(25); + expect(weekly.resetsAt).toBe(Date.parse(WEEKLY_RESET)); + }); + + it("picks the shortest rate limit and ignores windows longer than 6 hours", () => { + const snap = parseKimiUsage( + { + limits: [ + { + window: { duration: 7, timeUnit: "TIME_UNIT_DAY" }, + detail: { limit: "1000", used: "10" }, + }, + { + window: { duration: 5, timeUnit: "TIME_UNIT_HOUR" }, + detail: { limit: "200", used: "100" }, + }, + ], + }, + FAKE_NOW_MS, + ); + expect(snap.windows).toHaveLength(1); + expect(snap.windows[0]!.id).toBe("session-5h"); + expect(snap.windows[0]!.usedPercent).toBe(50); + }); + + it("clamps overshoot and tolerates an empty body", () => { + const over = parseKimiUsage({ usage: { limit: "100", used: "150" } }, FAKE_NOW_MS); + expect(over.windows[0]!.usedPercent).toBe(100); + + const empty = parseKimiUsage(undefined, FAKE_NOW_MS); + expect(empty.status).toBe("ok"); + expect(empty.windows).toEqual([]); + }); +}); + +describe("resolveKimiUsagesUrl", () => { + it("defaults to the public Kimi Code API endpoint", () => { + expect(resolveKimiUsagesUrl(undefined)).toBe(KIMI_USAGES_ENDPOINT); + expect(resolveKimiUsagesUrl({ accessToken: "k" })).toBe(KIMI_USAGES_ENDPOINT); + }); + + it("suffixes a bare base URL with /coding/v1/usages", () => { + expect(resolveKimiUsagesUrl({ accessToken: "k", raw: { baseUrl: "https://proxy.test" } })).toBe( + "https://proxy.test/coding/v1/usages", + ); + }); + + it("does not double-suffix a base already ending in /coding or /coding/v1", () => { + expect( + resolveKimiUsagesUrl({ accessToken: "k", raw: { baseUrl: "https://proxy.test/coding" } }), + ).toBe("https://proxy.test/coding/v1/usages"); + expect( + resolveKimiUsagesUrl({ accessToken: "k", raw: { baseUrl: "https://proxy.test/coding/v1/" } }), + ).toBe("https://proxy.test/coding/v1/usages"); + }); +}); + +describe("collectKimi", () => { + it("returns auth-missing when neither a pasted key nor a native credential exists", async () => { + const snap = await collectKimi(createFakeHost()); + expect(snap.status).toBe("auth-missing"); + expect(snap.windows).toEqual([]); + }); + + it("collects via a pasted API key and sends a Bearer header", async () => { + let seen: HttpRequest | undefined; + const host = createFakeHost({ + secrets: { kimi: { apiKey: "kimi-pasted" } }, + routes: { [KIMI_USAGES_ENDPOINT]: { body: USAGES_BODY } }, + onRequest: (req) => { + seen = req; + }, + }); + const snap = await collectKimi(host); + expect(snap.status).toBe("ok"); + expect(snap.windows.map((w) => w.id).sort()).toEqual(["session-5h", "weekly"]); + expect(seen?.headers?.Authorization).toBe("Bearer kimi-pasted"); + }); + + it("collects via the native (CLI/env) token and forwards its identity headers", async () => { + let seen: HttpRequest | undefined; + const host = createFakeHost({ + tokens: { + kimi: { + accessToken: "cli-token", + raw: { identityHeaders: { "X-Msh-Platform": "kimi_code_cli", "X-Msh-Device-Id": "d1" } }, + }, + }, + routes: { [KIMI_USAGES_ENDPOINT]: { body: USAGES_BODY } }, + onRequest: (req) => { + seen = req; + }, + }); + const snap = await collectKimi(host); + expect(snap.status).toBe("ok"); + expect(seen?.headers?.Authorization).toBe("Bearer cli-token"); + expect(seen?.headers?.["X-Msh-Platform"]).toBe("kimi_code_cli"); + expect(seen?.headers?.["X-Msh-Device-Id"]).toBe("d1"); + }); + + it("prefers the pasted key and drops the CLI identity headers with it", async () => { + let seen: HttpRequest | undefined; + const host = createFakeHost({ + secrets: { kimi: { apiKey: "pasted-wins" } }, + tokens: { + kimi: { + accessToken: "cli-loses", + raw: { identityHeaders: { "X-Msh-Device-Id": "d1" } }, + }, + }, + routes: { [KIMI_USAGES_ENDPOINT]: { body: USAGES_BODY } }, + onRequest: (req) => { + seen = req; + }, + }); + await collectKimi(host); + expect(seen?.headers?.Authorization).toBe("Bearer pasted-wins"); + expect(seen?.headers?.["X-Msh-Device-Id"]).toBeUndefined(); + }); + + it("maps 401/403 to auth-missing", async () => { + const host = createFakeHost({ + secrets: { kimi: { apiKey: "bad" } }, + routes: { [KIMI_USAGES_ENDPOINT]: { status: 401, body: "" } }, + }); + expect((await collectKimi(host)).status).toBe("auth-missing"); + }); + + it("maps a 429 to rate-limited and honors Retry-After", async () => { + const host = createFakeHost({ + secrets: { kimi: { apiKey: "k" } }, + routes: { + [KIMI_USAGES_ENDPOINT]: { status: 429, body: "", headers: { "retry-after": "120" } }, + }, + }); + const snap = await collectKimi(host); + expect(snap.status).toBe("rate-limited"); + expect(snap.rateLimitedUntil).toBe(FAKE_NOW_MS + 120_000); + }); + + it("maps other failures to error snapshots", async () => { + const host = createFakeHost({ + secrets: { kimi: { apiKey: "k" } }, + routes: { [KIMI_USAGES_ENDPOINT]: { status: 500, body: "boom" } }, + }); + expect((await collectKimi(host)).status).toBe("error"); + + const badJson = createFakeHost({ + secrets: { kimi: { apiKey: "k" } }, + routes: { [KIMI_USAGES_ENDPOINT]: { body: "not json" } }, + }); + expect((await collectKimi(badJson)).status).toBe("error"); + + const noUsage = createFakeHost({ + secrets: { kimi: { apiKey: "k" } }, + routes: { [KIMI_USAGES_ENDPOINT]: { body: "{}" } }, + }); + const snap = await collectKimi(noUsage); + expect(snap.status).toBe("error"); + expect(snap.error).toBe("missing usage data"); + }); +}); diff --git a/packages/agents-usage/src/collectors/kimi.ts b/packages/agents-usage/src/collectors/kimi.ts new file mode 100644 index 000000000..9cb73e25c --- /dev/null +++ b/packages/agents-usage/src/collectors/kimi.ts @@ -0,0 +1,267 @@ +import { parseRetryAfter, toEpochMs } from "../formatters"; +import type { CollectOptions, HostPort, HttpClient, HttpResponse, OAuthToken } from "../host"; +import type { UsageSnapshot, UsageWindow } from "../types"; + +/** + * Kimi For Coding (kimi.com/code). Usage lives behind the Kimi Code API the + * official CLI calls; it authenticates with a Bearer credential sourced two + * ways (mirroring CodexBar, github.com/steipete/codexbar): a Kimi Code API key + * from the console (pasted in-app or `KIMI_CODE_API_KEY`), or the Kimi Code + * CLI's access token resolved host-side from `~/.kimi-code/credentials/` + * (`getOAuthToken`). The pasted key wins. + * + * GET {base}/coding/v1/usages + * headers: Authorization: Bearer , Accept: application/json + * → { usage: { limit, used, remaining, resetTime }, limits: [...] } + * + * `usage` is the weekly request quota from the membership tier (Andante 1,024 / + * Moderato 2,048 / Allegretto 7,168 requests per week); `limits[]` carries the + * rolling rate limits, in practice one 300-minute (5-hour) request window. All + * counters arrive as decimal strings, `resetTime` as an ISO timestamp. The + * endpoint is private and may rotate without notice; responses are normalized + * into the shared `UsageSnapshot` shape. + */ + +export const KIMI_PROVIDER_ID = "kimi" as const; + +export const KIMI_USAGES_ENDPOINT = "https://api.kimi.com/coding/v1/usages"; + +interface KimiUsageDetailRaw { + /** Total cap for the window, a decimal string (e.g. "2048"). */ + limit?: string | number; + used?: string | number; + remaining?: string | number; + /** ISO-8601 timestamp; snake_case / `resetAt` variants appear in the wild. */ + resetTime?: string | number; + resetAt?: string | number; + reset_time?: string | number; + reset_at?: string | number; +} + +interface KimiRateLimitRaw { + window?: { duration?: number; timeUnit?: string }; + detail?: KimiUsageDetailRaw; +} + +export interface KimiUsagesResponse { + usage?: KimiUsageDetailRaw; + limits?: KimiRateLimitRaw[]; +} + +/** Counters arrive as decimal strings ("2048"); tolerate numbers too. */ +function toCount(value: string | number | undefined): number | undefined { + if (value === undefined || value === null) return undefined; + const n = typeof value === "number" ? value : Number(String(value).trim()); + return Number.isFinite(n) && n >= 0 ? n : undefined; +} + +function resetEpochMs(detail: KimiUsageDetailRaw): number | undefined { + return toEpochMs( + detail.resetTime ?? detail.resetAt ?? detail.reset_time ?? detail.reset_at ?? undefined, + ); +} + +/** Used percent from a detail block: prefer `used`, fall back to limit-remaining. */ +function usedPercentFor(detail: KimiUsageDetailRaw): number | undefined { + const limit = toCount(detail.limit); + if (limit === undefined || limit <= 0) return undefined; + const used = toCount(detail.used); + const remaining = toCount(detail.remaining); + const usedRaw = used ?? (remaining !== undefined ? limit - remaining : undefined); + if (usedRaw === undefined) return undefined; + return (Math.max(0, Math.min(limit, usedRaw)) / limit) * 100; +} + +function toWindow( + detail: KimiUsageDetailRaw, + id: UsageWindow["id"], + label: string, +): UsageWindow | undefined { + const usedPercent = usedPercentFor(detail); + if (usedPercent === undefined) return undefined; + // Percent only: the raw request counts would just repeat the percentage on + // the card (the caps are round numbers like 100), so they stay off the window. + const window: UsageWindow = { id, label, usedPercent }; + const resetsAt = resetEpochMs(detail); + if (resetsAt !== undefined) window.resetsAt = resetsAt; + return window; +} + +/** Rate-limit window length in minutes from `window.duration` + `timeUnit`. */ +function rateLimitMinutes(raw: KimiRateLimitRaw): number | undefined { + const duration = raw.window?.duration; + if (typeof duration !== "number" || !Number.isFinite(duration) || duration <= 0) return undefined; + switch (raw.window?.timeUnit) { + case "TIME_UNIT_MINUTE": + return duration; + case "TIME_UNIT_HOUR": + return duration * 60; + case "TIME_UNIT_DAY": + return duration * 24 * 60; + default: + return undefined; + } +} + +/** + * Pure: map a parsed `/coding/v1/usages` body to a `UsageSnapshot`. The top-level + * `usage` block is the weekly membership quota; the shortest `limits[]` entry is + * the rolling 5-hour rate limit. Longer rate limits (none observed today) are + * ignored rather than guessed into a window id. + */ +export function parseKimiUsage(data: unknown, nowMs: number): UsageSnapshot { + const block = (data ?? {}) as KimiUsagesResponse; + // Fast window first: the 5h rate limit leads, the weekly quota follows, + // matching the Claude/Codex card order. + const windows: UsageWindow[] = []; + + const limits = Array.isArray(block.limits) ? block.limits : []; + const rated = limits + .filter((raw): raw is KimiRateLimitRaw => !!raw && typeof raw === "object" && !!raw.detail) + .map((raw) => ({ raw, minutes: rateLimitMinutes(raw) })) + .filter((entry) => entry.minutes === undefined || entry.minutes <= 6 * 60) + .sort( + (a, b) => (a.minutes ?? Number.POSITIVE_INFINITY) - (b.minutes ?? Number.POSITIVE_INFINITY), + ); + const session = rated[0] + ? toWindow(rated[0].raw.detail!, "session-5h", "Session (5h)") + : undefined; + if (session) windows.push(session); + + if (block.usage && typeof block.usage === "object") { + const weekly = toWindow(block.usage, "weekly", "Weekly"); + if (weekly) windows.push(weekly); + } + + return { + providerId: KIMI_PROVIDER_ID, + status: "ok", + windows, + fetchedAt: nowMs, + }; +} + +/** + * Resolve the usages endpoint. A host-side resolver may attach `baseUrl` (the + * `KIMI_CODE_BASE_URL` override) to the token's `raw` bag; absent that, the + * public Kimi Code API host is used. Mirrors CodexBar's endpoint builder: a + * base already ending in `/coding` or `/coding/v1` is not double-suffixed. + */ +export function resolveKimiUsagesUrl(token: OAuthToken | undefined): string { + const raw = token?.raw as { baseUrl?: unknown } | undefined; + const base = typeof raw?.baseUrl === "string" ? raw.baseUrl.trim() : ""; + if (!base) return KIMI_USAGES_ENDPOINT; + const withScheme = /^https?:\/\//i.test(base) ? base : `https://${base}`; + let url: URL; + try { + url = new URL(withScheme); + } catch { + return KIMI_USAGES_ENDPOINT; + } + const path = url.pathname.replace(/\/+$/, ""); + if (path.endsWith("/coding/v1")) url.pathname = `${path}/usages`; + else if (path.endsWith("/coding")) url.pathname = `${path}/v1/usages`; + else url.pathname = `${path}/coding/v1/usages`; + return url.toString(); +} + +/** + * Identity headers a host-side resolver attaches for CLI-sourced tokens (the + * official client sends `X-Msh-*` device identity with them; a plain API key + * needs none). + */ +function identityHeaders(token: OAuthToken | undefined): Record { + const raw = token?.raw as { identityHeaders?: unknown } | undefined; + const bag = raw?.identityHeaders; + if (!bag || typeof bag !== "object") return {}; + const headers: Record = {}; + for (const [name, value] of Object.entries(bag as Record)) { + if (typeof value === "string" && value.trim()) headers[name] = value; + } + return headers; +} + +function kimiRequest( + http: HttpClient, + url: string, + bearer: string, + extraHeaders: Record, +): Promise { + return http.request({ + method: "GET", + url, + headers: { + ...extraHeaders, + Authorization: `Bearer ${bearer}`, + Accept: "application/json", + }, + timeoutMs: 15_000, + }); +} + +function authMissing(now: number, error?: string): UsageSnapshot { + return { + providerId: KIMI_PROVIDER_ID, + status: "auth-missing", + windows: [], + fetchedAt: now, + ...(error ? { error } : {}), + }; +} + +function errorSnapshot(now: number, error: string): UsageSnapshot { + return { providerId: KIMI_PROVIDER_ID, status: "error", windows: [], fetchedAt: now, error }; +} + +/** + * Collect Kimi For Coding usage. Reads the pasted API key first (an explicit + * user action), then the host-resolved credential (env key or the Kimi Code + * CLI's access token); returns `auth-missing` when neither is present so the + * card can prompt for sign-in. + */ +export async function collectKimi(host: HostPort, _opts?: CollectOptions): Promise { + const now = host.now(); + const [pastedValue, token] = await Promise.all([ + host.credentials.getSecret(KIMI_PROVIDER_ID, "apiKey"), + host.credentials.getOAuthToken(KIMI_PROVIDER_ID), + ]); + const pasted = pastedValue?.trim(); + const bearer = pasted || token?.accessToken?.trim(); + if (!bearer) return authMissing(now); + + // Identity headers belong to the CLI token; never send them with a pasted key. + const extraHeaders = pasted ? {} : identityHeaders(token); + const res = await kimiRequest(host.http, resolveKimiUsagesUrl(token), bearer, extraHeaders); + if (res.status === 401 || res.status === 403) { + return authMissing(now, `token rejected (${res.status})`); + } + if (res.status === 429) { + const snapshot: UsageSnapshot = { + providerId: KIMI_PROVIDER_ID, + status: "rate-limited", + windows: [], + fetchedAt: now, + }; + const retryAt = parseRetryAfter(res.headers["retry-after"], now); + if (retryAt !== undefined) snapshot.rateLimitedUntil = retryAt; + return snapshot; + } + if (res.status < 200 || res.status >= 300) { + return errorSnapshot(now, `HTTP ${res.status}`); + } + + const body = res.body?.trim(); + if (!body) return errorSnapshot(now, "empty response"); + + let parsed: KimiUsagesResponse; + try { + parsed = JSON.parse(body) as KimiUsagesResponse; + } catch { + return errorSnapshot(now, "invalid JSON response"); + } + if (!parsed.usage && !Array.isArray(parsed.limits)) { + return errorSnapshot(now, "missing usage data"); + } + + return parseKimiUsage(parsed, now); +} diff --git a/packages/agents-usage/src/index.ts b/packages/agents-usage/src/index.ts index 455232b23..61f9e46e1 100644 --- a/packages/agents-usage/src/index.ts +++ b/packages/agents-usage/src/index.ts @@ -95,6 +95,14 @@ export { ZAI_BIGMODEL_QUOTA_ENDPOINT, } from "./collectors/zai"; export type { ZaiQuotaResponse } from "./collectors/zai"; +export { + collectKimi, + parseKimiUsage, + resolveKimiUsagesUrl, + KIMI_PROVIDER_ID, + KIMI_USAGES_ENDPOINT, +} from "./collectors/kimi"; +export type { KimiUsagesResponse } from "./collectors/kimi"; export { antigravityPool, antigravityPoolWindows, diff --git a/packages/agents-usage/src/providers.ts b/packages/agents-usage/src/providers.ts index b85178436..1302c998e 100644 --- a/packages/agents-usage/src/providers.ts +++ b/packages/agents-usage/src/providers.ts @@ -66,6 +66,13 @@ export const BUILT_IN_USAGE_PROVIDER_DESCRIPTORS = { needsLogin: true, windowIds: ["session-5h", "weekly", "monthly"], }, + kimi: { + id: "kimi", + label: "Kimi Code", + mechanism: "api-key", + needsLogin: true, + windowIds: ["session-5h", "weekly"], + }, } satisfies Record; /** Descriptors for the built-in HTTP collectors, in registration order. */ diff --git a/packages/agents-usage/src/registry.test.ts b/packages/agents-usage/src/registry.test.ts index 4afeafad9..1e95cbd7b 100644 --- a/packages/agents-usage/src/registry.test.ts +++ b/packages/agents-usage/src/registry.test.ts @@ -19,6 +19,7 @@ describe("createUsageCollectorRegistry", () => { "factory", "gemini", "grok", + "kimi", "zai", ]); expect(reg.has("claude")).toBe(true); @@ -26,8 +27,9 @@ describe("createUsageCollectorRegistry", () => { }); it("collectAll returns one snapshot per provider, auth-missing without tokens", async () => { - const snaps = await createUsageCollectorRegistry().collectAll(undefined, createFakeHost()); - expect(snaps).toHaveLength(9); + const reg = createUsageCollectorRegistry(); + const snaps = await reg.collectAll(undefined, createFakeHost()); + expect(snaps).toHaveLength(reg.descriptors().length); expect(snaps.every((s) => s.status === "auth-missing")).toBe(true); }); diff --git a/packages/agents-usage/src/registry.ts b/packages/agents-usage/src/registry.ts index d878ec696..a2b905fe4 100644 --- a/packages/agents-usage/src/registry.ts +++ b/packages/agents-usage/src/registry.ts @@ -6,6 +6,7 @@ import { collectCursor } from "./collectors/cursor"; import { collectFactory } from "./collectors/factory"; import { collectGemini } from "./collectors/gemini"; import { collectGrok } from "./collectors/grok"; +import { collectKimi } from "./collectors/kimi"; import { collectZai } from "./collectors/zai"; import type { CollectOptions, HostPort } from "./host"; import { BUILT_IN_USAGE_PROVIDER_DESCRIPTORS } from "./providers"; @@ -66,6 +67,11 @@ const ZAI_COLLECTOR: UsageCollector = { collect: collectZai, }; +const KIMI_COLLECTOR: UsageCollector = { + descriptor: BUILT_IN_USAGE_PROVIDER_DESCRIPTORS.kimi, + collect: collectKimi, +}; + // Antigravity is collected supervisor-side from its local language server // (LS-only), not here; see src/supervisor/runtime/antigravityUsageScanner.ts. @@ -79,6 +85,7 @@ const BUILT_IN: UsageCollector[] = [ COMMANDCODE_COLLECTOR, FACTORY_COLLECTOR, ZAI_COLLECTOR, + KIMI_COLLECTOR, ]; export interface UsageCollectorRegistry { diff --git a/src/main/usageLogin/UsageLoginManager.test.ts b/src/main/usageLogin/UsageLoginManager.test.ts index e67e94631..368c2f149 100644 --- a/src/main/usageLogin/UsageLoginManager.test.ts +++ b/src/main/usageLogin/UsageLoginManager.test.ts @@ -165,6 +165,12 @@ describe("UsageLoginManager API-key flow", () => { expect(hasUsageSecret(cacheDir, "zai")).toBe(true); }); + it("seals a pasted Kimi Code key and reports it stored", async () => { + const manager = newManager(makePanel()); + await expect(manager.submitApiKey("kimi", "kimi-secret")).resolves.toEqual({ ok: true }); + expect(hasUsageSecret(cacheDir, "kimi")).toBe(true); + }); + it("rejects an empty key without storing anything", async () => { const manager = newManager(makePanel()); await expect(manager.submitApiKey("zai", " ")).resolves.toMatchObject({ ok: false }); diff --git a/src/main/usageLogin/UsageLoginManager.ts b/src/main/usageLogin/UsageLoginManager.ts index e5554d2fb..77469f26f 100644 --- a/src/main/usageLogin/UsageLoginManager.ts +++ b/src/main/usageLogin/UsageLoginManager.ts @@ -58,9 +58,10 @@ interface LocalStorageLoginConfig { /** * The provider authenticates its usage API with a long-lived key the user pastes - * in (z.ai). There is no browser/OAuth step — the key is sealed via - * {@link submitApiKey} and read back by the collector — but the provider still - * lives in `PROVIDER_CONFIGS` so its stored-secret state surfaces like any login. + * in (for example, z.ai or Kimi). There is no browser/OAuth step — the key is + * sealed via {@link submitApiKey} and read back by the collector — but the + * provider still lives in `PROVIDER_CONFIGS` so its stored-secret state surfaces + * like any login. */ interface ApiKeyLoginConfig { kind: "api-key"; @@ -131,12 +132,14 @@ const PROVIDER_CONFIGS: Record = { // actually authenticates before prompting. validateSession: isOpenCodeLoginCookieLive, }, - // z.ai's GLM Coding Plan quota API takes a Bearer API key, not a web cookie. - // The user pastes it in (see `submitApiKey`); the env/config key is resolved - // host-side instead and needs no entry here. - zai: { kind: "api-key" }, }; +for (const descriptor of USAGE_PROVIDER_BY_ID.values()) { + if (descriptor.needsLogin && descriptor.mechanism === "api-key") { + PROVIDER_CONFIGS[descriptor.id] = { kind: "api-key" }; + } +} + for (const providerId of Object.keys(PROVIDER_CONFIGS)) { if (!USAGE_PROVIDER_BY_ID.has(providerId)) { throw new Error(`Usage login config has no provider descriptor: ${providerId}`); diff --git a/src/renderer/components/providers/usageProviders.test.ts b/src/renderer/components/providers/usageProviders.test.ts index d07ca301b..c8c65aaa1 100644 --- a/src/renderer/components/providers/usageProviders.test.ts +++ b/src/renderer/components/providers/usageProviders.test.ts @@ -5,6 +5,7 @@ import { isClaudeUsageProvider, pickUsageRings, resolveDisplayedProviders, + supportsApiKeyLogin, usageProvidersForAgentInstances, usageRingGroups, } from "./usageProviders"; @@ -38,6 +39,12 @@ describe("usageProviders", () => { expect(isClaudeUsageProvider("codex")).toBe(false); }); + it("derives API-key login support from provider descriptors", () => { + expect(supportsApiKeyLogin("zai")).toBe(true); + expect(supportsApiKeyLogin("kimi")).toBe(true); + expect(supportsApiKeyLogin("grok")).toBe(false); + }); + it("adds Claude profile providers after the base Claude provider", () => { const providers = usageProvidersForAgentInstances(agentInstances); const claudeIndex = providers.findIndex((provider) => provider.id === "claude"); @@ -101,6 +108,16 @@ describe("usageProviders", () => { expect(rings.inner?.id).toBe("weekly"); }); + it("rings Kimi with the 5h rate limit outside and the weekly quota inside", () => { + const windows: UsageWindow[] = [ + { id: "weekly", label: "Weekly", usedPercent: 10 }, + { id: "session-5h", label: "Session (5h)", usedPercent: 70 }, + ]; + const rings = pickUsageRings("kimi", windows); + expect(rings.outer?.id).toBe("session-5h"); + expect(rings.inner?.id).toBe("weekly"); + }); + describe("Antigravity ring groups", () => { const windows: UsageWindow[] = [ { id: "antigravity:gemini:session-5h", label: "Gemini · 5h", usedPercent: 60 }, diff --git a/src/renderer/components/providers/usageProviders.ts b/src/renderer/components/providers/usageProviders.ts index 7886b3367..bdc4e154b 100644 --- a/src/renderer/components/providers/usageProviders.ts +++ b/src/renderer/components/providers/usageProviders.ts @@ -34,8 +34,6 @@ export type UsageProvider = { label: string; /** Offers an in-app browser login (web-session cookie or OAuth device flow). */ supportsBrowserLogin?: boolean; - /** Offers an in-app API-key paste sign-in (no browser step, e.g. z.ai). */ - supportsApiKeyLogin?: boolean; /** * All windows reset on one shared clock, so the UI shows a single reset * countdown in the header instead of one per window (e.g. Cursor). @@ -56,6 +54,11 @@ export type UsageProvider = { ringGroups?: readonly UsageRingGroup[]; }; +const USAGE_PROVIDER_DESCRIPTORS = allUsageProviderDescriptors(); +const USAGE_PROVIDER_BY_ID = new Map( + USAGE_PROVIDER_DESCRIPTORS.map((descriptor) => [descriptor.id, descriptor]), +); + /** Renderer-only presentation, keyed by provider id; merged onto the catalog. */ const RENDERER_META: Record> = { // Antigravity reports two quota groups (Gemini, Claude+GPT), each with a 5h @@ -108,12 +111,18 @@ const RENDERER_META: Record> = { // when the plan returns one. The monthly MCP-tools quota is a different kind of // limit, so it's deliberately omitted from the ring (it still shows as a card bar). zai: { - supportsApiKeyLogin: true, + rings: { outer: ["session-5h"], inner: ["weekly"] }, + }, + // Kimi For Coding reads the Kimi Code CLI credential automatically; the + // API-key paste is the fallback for users without a CLI sign-in. The 5h + // request rate limit is the fast outer ring, the weekly membership quota the + // slower inner one. + kimi: { rings: { outer: ["session-5h"], inner: ["weekly"] }, }, }; -const STATIC_USAGE_PROVIDERS: ReadonlyArray = allUsageProviderDescriptors().map( +const STATIC_USAGE_PROVIDERS: ReadonlyArray = USAGE_PROVIDER_DESCRIPTORS.map( (d) => ({ id: d.id, label: d.label, ...RENDERER_META[d.id] }), ); @@ -172,7 +181,8 @@ export function supportsBrowserLogin(providerId: string): boolean { /** Providers that sign in by pasting an API key (no browser step, e.g. z.ai). */ export function supportsApiKeyLogin(providerId: string): boolean { - return rendererMeta(providerId)?.supportsApiKeyLogin === true; + const descriptor = USAGE_PROVIDER_BY_ID.get(baseAgentKind(providerId)); + return descriptor?.needsLogin === true && descriptor.mechanism === "api-key"; } /** Providers whose windows share one reset clock (one header countdown, no per-window resets). */ diff --git a/src/supervisor/runtime/kimiCredentials.test.ts b/src/supervisor/runtime/kimiCredentials.test.ts new file mode 100644 index 000000000..7687426b7 --- /dev/null +++ b/src/supervisor/runtime/kimiCredentials.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; +import { parseKimiCliCredential, parseKimiEnv } from "./kimiCredentials"; + +const NOW = 1_700_000_000_000; + +describe("parseKimiEnv", () => { + it("returns undefined when no API key is set", () => { + expect(parseKimiEnv({})).toBeUndefined(); + expect(parseKimiEnv({ KIMI_CODE_BASE_URL: "https://proxy.test" })).toBeUndefined(); + }); + + it("reads the API key and strips wrapping quotes/whitespace", () => { + expect(parseKimiEnv({ KIMI_CODE_API_KEY: ' "kimi-key" ' })).toEqual({ + accessToken: "kimi-key", + }); + }); + + it("carries the base-URL override on raw without polluting it when absent", () => { + expect(parseKimiEnv({ KIMI_CODE_API_KEY: "k" })).toEqual({ accessToken: "k" }); + expect( + parseKimiEnv({ KIMI_CODE_API_KEY: "k", KIMI_CODE_BASE_URL: "https://proxy.test" }), + ).toEqual({ accessToken: "k", raw: { baseUrl: "https://proxy.test" } }); + }); +}); + +describe("parseKimiCliCredential", () => { + const freshExpiry = Math.floor(NOW / 1000) + 3600; + + it("returns a fresh access token with its expiry in epoch ms", () => { + const content = JSON.stringify({ + access_token: "cli-token", + refresh_token: "never-used", + expires_at: freshExpiry, + }); + expect(parseKimiCliCredential(content, NOW)).toEqual({ + accessToken: "cli-token", + expiresAt: freshExpiry * 1000, + }); + }); + + it("accepts camelCase keys and millisecond expiries", () => { + const content = JSON.stringify({ accessToken: "t", expiresAt: NOW + 3_600_000 }); + expect(parseKimiCliCredential(content, NOW)).toEqual({ + accessToken: "t", + expiresAt: NOW + 3_600_000, + }); + }); + + it("rejects expired, near-expiry, and expiry-less tokens (read-only, no refresh)", () => { + const expired = JSON.stringify({ + access_token: "t", + expires_at: Math.floor(NOW / 1000) - 10, + }); + expect(parseKimiCliCredential(expired, NOW)).toBeUndefined(); + + const nearExpiry = JSON.stringify({ + access_token: "t", + expires_at: Math.floor((NOW + 30_000) / 1000), + }); + expect(parseKimiCliCredential(nearExpiry, NOW)).toBeUndefined(); + + const noExpiry = JSON.stringify({ access_token: "t" }); + expect(parseKimiCliCredential(noExpiry, NOW)).toBeUndefined(); + }); + + it("rejects malformed content", () => { + expect(parseKimiCliCredential("not json", NOW)).toBeUndefined(); + expect(parseKimiCliCredential("[]", NOW)).toBeUndefined(); + expect(parseKimiCliCredential(JSON.stringify({ expires_at: 1 }), NOW)).toBeUndefined(); + }); +}); diff --git a/src/supervisor/runtime/kimiCredentials.ts b/src/supervisor/runtime/kimiCredentials.ts new file mode 100644 index 000000000..6e96da591 --- /dev/null +++ b/src/supervisor/runtime/kimiCredentials.ts @@ -0,0 +1,111 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import type { OAuthToken } from "@poracode/agents-usage"; +import { nativeKimiHomePath, nativeKimiOAuthCredentialPath } from "../agents/kimi/detection"; + +/** + * Kimi For Coding credential resolution, mirroring CodexBar: an explicit API + * key from `KIMI_CODE_API_KEY` wins (with an optional `KIMI_CODE_BASE_URL` + * endpoint override carried on the token's `raw` bag), else the Kimi Code + * CLI's access token is reused read-only from + * `~/.kimi-code/credentials/kimi-code.json` (honoring `KIMI_CODE_HOME`). A + * CLI credential is never combined with an endpoint override — a custom base + * URL means a test proxy, and forwarding the CLI token there would leak it. + * The refresh token is never used and the credential file never rewritten. + * Users who have neither paste a key into the in-app sign-in. Secrets never log. + */ + +export const KIMI_API_KEY_ENV = "KIMI_CODE_API_KEY"; +export const KIMI_BASE_URL_ENV = "KIMI_CODE_BASE_URL"; + +/** Sent alongside a CLI-sourced token, matching the official client. */ +const KIMI_CLI_PLATFORM = "kimi_code_cli"; + +/** Trim surrounding whitespace and a single layer of wrapping quotes. */ +function cleaned(raw: string | undefined): string | undefined { + let value = raw?.trim(); + if (!value) return undefined; + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1).trim(); + } + return value || undefined; +} + +/** Pure: build the Kimi usage token from an explicit API key in the env. */ +export function parseKimiEnv(env: Record): OAuthToken | undefined { + const accessToken = cleaned(env[KIMI_API_KEY_ENV]); + if (!accessToken) return undefined; + const baseUrl = cleaned(env[KIMI_BASE_URL_ENV]); + return baseUrl ? { accessToken, raw: { baseUrl } } : { accessToken }; +} + +/** `expires_at` arrives as epoch seconds (or ms); normalize to epoch ms. */ +function expiryEpochMs(value: unknown): number | undefined { + const n = + typeof value === "number" ? value : typeof value === "string" ? Number(value.trim()) : NaN; + if (!Number.isFinite(n) || n <= 0) return undefined; + return n < 1e12 ? n * 1000 : n; +} + +/** + * Pure: parse the CLI credential file. Returns a token only while the access + * token is fresh (CodexBar's rule: at least 60s of validity left) — this + * resolver never refreshes, so a stale token must read as signed-out rather + * than produce confusing 401s. + */ +export function parseKimiCliCredential(content: string, nowMs: number): OAuthToken | undefined { + let parsed: unknown; + try { + parsed = JSON.parse(content); + } catch { + return undefined; + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return undefined; + const record = parsed as Record; + const rawToken = record["access_token"] ?? record["accessToken"]; + const accessToken = typeof rawToken === "string" ? rawToken.trim() : ""; + if (!accessToken) return undefined; + const expiresAt = expiryEpochMs(record["expires_at"] ?? record["expiresAt"]); + if (expiresAt === undefined || expiresAt <= nowMs + 60_000) return undefined; + return { accessToken, expiresAt }; +} + +/** The CLI's stable device id, when it exists. Read-only: never created here. */ +async function readDeviceId(): Promise { + try { + const content = await readFile(join(nativeKimiHomePath(), "device_id"), "utf8"); + return cleaned(content); + } catch { + return undefined; + } +} + +/** + * Resolve the Kimi usage credential: explicit API key first, then the Kimi + * Code CLI's fresh access token (with the device identity headers the official + * client sends). Returns undefined when neither exists so the collector reports + * `auth-missing` and the card can offer the API-key sign-in. + */ +export async function resolveKimiToken(): Promise { + const fromEnv = parseKimiEnv(process.env); + if (fromEnv) return fromEnv; + // An endpoint override without an explicit key disables CLI credential reuse. + if (cleaned(process.env[KIMI_BASE_URL_ENV])) return undefined; + + let content: string; + try { + content = await readFile(nativeKimiOAuthCredentialPath(), "utf8"); + } catch { + return undefined; + } + const token = parseKimiCliCredential(content, Date.now()); + if (!token) return undefined; + + const identityHeaders: Record = { "X-Msh-Platform": KIMI_CLI_PLATFORM }; + const deviceId = await readDeviceId(); + if (deviceId) identityHeaders["X-Msh-Device-Id"] = deviceId; + return { ...token, raw: { identityHeaders } }; +} diff --git a/src/supervisor/runtime/usageCredentials.ts b/src/supervisor/runtime/usageCredentials.ts index e528acba1..1c189603b 100644 --- a/src/supervisor/runtime/usageCredentials.ts +++ b/src/supervisor/runtime/usageCredentials.ts @@ -7,6 +7,7 @@ import { resolveCursorToken } from "./cursorCredentials"; import { resolveFactoryCliToken } from "./factoryCredentials"; import { resolveGeminiToken } from "./geminiCredentials"; import { resolveGrokToken } from "./grokCredentials"; +import { resolveKimiToken } from "./kimiCredentials"; import { resolveZaiToken } from "./zaiCredentials"; /** @@ -37,6 +38,7 @@ const tokenResolvers: Record Promise> = { // wrap it so every entry shares the () => Promise shape. factory: async () => resolveFactoryCliToken(), zai: resolveZaiToken, + kimi: resolveKimiToken, }; /** Per-provider refreshers (currently only Claude rejects/expired tokens). */ diff --git a/src/supervisor/runtime/usageService.test.ts b/src/supervisor/runtime/usageService.test.ts index e2cd11819..f4efd6a64 100644 --- a/src/supervisor/runtime/usageService.test.ts +++ b/src/supervisor/runtime/usageService.test.ts @@ -107,6 +107,7 @@ describe("UsageService", () => { "factory", "gemini", "grok", + "kimi", "opencode", "zai", ]);