diff --git a/packages/server/src/server/agent/provider-registry.test.ts b/packages/server/src/server/agent/provider-registry.test.ts index 2b08f5343..656349e10 100644 --- a/packages/server/src/server/agent/provider-registry.test.ts +++ b/packages/server/src/server/agent/provider-registry.test.ts @@ -43,6 +43,11 @@ const mockState = vi.hoisted(() => { env?: Record; providerParams?: unknown; }>, + kimi: [] as Array<{ + command: string[]; + env?: Record; + providerParams?: unknown; + }>, pi: [] as ConstructorEntry[], genericAcp: [] as Array<{ command: string[]; @@ -61,6 +66,7 @@ const mockState = vi.hoisted(() => { this.constructorArgs.copilot = []; this.constructorArgs.cursor = []; this.constructorArgs.trae = []; + this.constructorArgs.kimi = []; this.constructorArgs.pi = []; this.constructorArgs.genericAcp = []; this.isCommandAvailable.mockReset(); @@ -469,6 +475,59 @@ vi.mock("./providers/trae-acp-agent.js", () => ({ }, })); +vi.mock("./providers/kimi-acp-agent.js", () => ({ + KimiACPAgentClient: class KimiACPAgentClient { + readonly capabilities = { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, + }; + readonly provider = "acp"; + readonly runtimeSettings?: unknown; + + constructor(options: { + command: string[]; + env?: Record; + providerParams?: unknown; + }) { + this.runtimeSettings = { + command: { + mode: "replace", + argv: options.command, + }, + env: options.env, + }; + mockState.constructorArgs.kimi.push({ + command: options.command, + env: options.env, + providerParams: options.providerParams, + }); + } + + async createSession(): Promise { + throw new Error("not implemented"); + } + + async resumeSession(): Promise { + throw new Error("not implemented"); + } + + async fetchCatalog(): Promise { + return { + models: mockState.runtimeModels.get(this.provider) ?? [], + modes: [], + }; + } + + async isAvailable(): Promise { + return true; + } + }, +})); + import { AGENT_PROVIDER_DEFINITIONS, buildProviderRegistry, @@ -862,6 +921,33 @@ test("traecli provider extending acp uses TraeACPAgentClient", () => { expect(mockState.constructorArgs.genericAcp).toEqual([]); }); +test("kimi provider extending acp uses KimiACPAgentClient", () => { + const registry = buildProviderRegistry(logger, { + providerOverrides: { + kimi: { + extends: "acp", + label: "Kimi Code CLI", + command: ["kimi", "acp"], + }, + }, + }); + + expect(registry.kimi.createClient(logger).provider).toBe("kimi"); + expect(mockState.constructorArgs.kimi).toEqual([ + { + command: ["kimi", "acp"], + env: undefined, + providerParams: undefined, + }, + { + command: ["kimi", "acp"], + env: undefined, + providerParams: undefined, + }, + ]); + expect(mockState.constructorArgs.genericAcp).toEqual([]); +}); + test('extends: "acp" without command throws', () => { expect(() => buildProviderRegistry(logger, { diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts index db563cbff..b673e2bd7 100644 --- a/packages/server/src/server/agent/provider-registry.ts +++ b/packages/server/src/server/agent/provider-registry.ts @@ -37,6 +37,7 @@ import { CodexAppServerAgentClient } from "./providers/codex-app-server-agent.js import { CopilotACPAgentClient } from "./providers/copilot-acp-agent.js"; import { CursorACPAgentClient } from "./providers/cursor-acp-agent.js"; import { GenericACPAgentClient } from "./providers/generic-acp-agent.js"; +import { KimiACPAgentClient } from "./providers/kimi-acp-agent.js"; import { KiroACPAgentClient } from "./providers/kiro-acp-agent.js"; import { OpenCodeAgentClient } from "./providers/opencode-agent.js"; import { OmpAgentClient } from "./providers/omp/agent.js"; @@ -775,6 +776,9 @@ function addDerivedProviders( if (providerId === "cursor") { return new CursorACPAgentClient(acpOptions); } + if (providerId === "kimi") { + return new KimiACPAgentClient(acpOptions); + } if (providerId === "kiro") { return new KiroACPAgentClient(acpOptions); } diff --git a/packages/server/src/server/agent/providers/acp-agent.test.ts b/packages/server/src/server/agent/providers/acp-agent.test.ts index b82e8e215..74cd504c2 100644 --- a/packages/server/src/server/agent/providers/acp-agent.test.ts +++ b/packages/server/src/server/agent/providers/acp-agent.test.ts @@ -1788,6 +1788,73 @@ describe("ACPAgentClient modelTransformer", () => { }); }); +describe("ACPAgentClient catalog discovery without a model resolver", () => { + test("never switches models during catalog discovery even with multiple models and a thinking picker", async () => { + // The per-model probing that switches models lives on KimiACPAgentClient + // (see kimi-acp-agent.test.ts). The base client ships no catalog model resolver, so a + // slow or nonconforming ACP can't stall its catalog probe on extra setSessionConfigOption + // round trips. + const setSessionConfigOption = vi.fn(); + + class TestACPAgentClient extends ACPAgentClient { + protected override async spawnProcess(): Promise { + return { + child: { kill: vi.fn(), exitCode: 0, signalCode: null, once: vi.fn() }, + connection: { + newSession: vi.fn().mockResolvedValue({ + sessionId: "session-1", + configOptions: [ + { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: "model-a", + options: [ + { value: "model-a", name: "Model A" }, + { value: "model-b", name: "Model B" }, + ], + }, + { + id: "thinking", + name: "Thinking", + category: "thought_level", + type: "select", + currentValue: "off", + options: [ + { value: "off", name: "Off" }, + { value: "on", name: "On" }, + ], + }, + ], + }), + setSessionConfigOption, + }, + initialize: { agentCapabilities: {} }, + } as unknown as SpawnedACPProcess; + } + + protected override async closeProbe(): Promise {} + } + + const client = new TestACPAgentClient({ + provider: "acp", + logger: createTestLogger(), + defaultCommand: ["acp-agent"], + defaultModes: [], + }); + + const catalog = await client.fetchCatalog({ + scope: "workspace", + cwd: "/tmp/acp-generic-catalog", + force: false, + }); + + expect(setSessionConfigOption).not.toHaveBeenCalled(); + expect(catalog.models.map((model) => model.id)).toEqual(["model-a", "model-b"]); + }); +}); + describe("ACPAgentClient config features", () => { test("enables Auto Accept for unattended ACP creation", () => { const client = new ACPAgentClient({ diff --git a/packages/server/src/server/agent/providers/acp-agent.ts b/packages/server/src/server/agent/providers/acp-agent.ts index 189218dac..2296aa396 100644 --- a/packages/server/src/server/agent/providers/acp-agent.ts +++ b/packages/server/src/server/agent/providers/acp-agent.ts @@ -375,12 +375,40 @@ export type ACPExtensionCommandsParser = ( params: Record, ) => AgentSlashCommand[] | null; +/** + * Context handed to an {@link ACPCatalogModelResolver} during `fetchCatalog`. It exposes + * the already-derived models plus the live probe session so a resolver can refine them + * (e.g. switch through each model to read back per-model options) without re-implementing + * the catalog plumbing. + */ +export interface ACPCatalogModelResolverContext { + connection: ClientSideConnection; + sessionId: string; + models: AgentModelDefinition[]; + configOptions: SessionConfigOption[] | null | undefined; + runRequest: (request: () => Promise) => Promise; + transformConfigOptions: (configOptions: SessionConfigOption[]) => SessionConfigOption[]; + logger: Logger; + provider: string; +} + +/** + * Optional hook that refines the catalog's model list using the live probe session. + * The base client ships no resolver — catalog discovery derives models from the initial + * session response and never mutates the probe. Providers that need per-model data (Kimi) + * inject a resolver so the extra round trips stay off every other ACP. + */ +export type ACPCatalogModelResolver = ( + context: ACPCatalogModelResolverContext, +) => Promise; + interface ACPAgentClientOptions { provider: string; logger: Logger; runtimeSettings?: ProviderRuntimeSettings; defaultCommand: [string, ...string[]]; defaultModes?: AgentMode[]; + catalogModelResolver?: ACPCatalogModelResolver; modelTransformer?: (models: AgentModelDefinition[]) => AgentModelDefinition[]; sessionResponseTransformer?: (response: SessionStateResponse) => SessionStateResponse; configOptionsTransformer?: (configOptions: SessionConfigOption[]) => SessionConfigOption[]; @@ -500,7 +528,7 @@ interface TerminalEntry { rejectExit: (error: Error) => void; } -interface ConfigOptionSelector { +export interface ConfigOptionSelector { id: string; label: string; description?: string; @@ -519,7 +547,7 @@ export interface ACPConfigFeatureOption { emptyOptionLabel?: string; } -type SelectConfigOption = Extract; +export type SelectConfigOption = Extract; interface SelectConfigChoice { value: string; name: string; @@ -763,6 +791,7 @@ export class ACPAgentClient implements AgentClient { protected readonly runtimeSettings?: ProviderRuntimeSettings; protected readonly defaultCommand: [string, ...string[]]; protected readonly defaultModes: AgentMode[]; + private readonly catalogModelResolver?: ACPCatalogModelResolver; private readonly modelTransformer?: (models: AgentModelDefinition[]) => AgentModelDefinition[]; private readonly sessionResponseTransformer?: ( response: SessionStateResponse, @@ -802,6 +831,7 @@ export class ACPAgentClient implements AgentClient { this.runtimeSettings = options.runtimeSettings; this.defaultCommand = options.defaultCommand; this.defaultModes = options.defaultModes ?? []; + this.catalogModelResolver = options.catalogModelResolver; this.modelTransformer = options.modelTransformer; this.sessionResponseTransformer = options.sessionResponseTransformer; this.configOptionsTransformer = options.configOptionsTransformer; @@ -924,11 +954,26 @@ export class ACPAgentClient implements AgentClient { }), ); const transformed = this.transformSessionResponse(response); - const models = deriveModelDefinitionsFromACP( + const derivedModels = deriveModelDefinitionsFromACP( this.provider, transformed.models, transformed.configOptions, ); + const models = this.catalogModelResolver + ? await this.catalogModelResolver({ + connection: initializedProbe.connection, + sessionId: response.sessionId, + models: derivedModels, + configOptions: transformed.configOptions, + runRequest: (request) => this.runACPRequest(request), + transformConfigOptions: (configOptions) => + this.configOptionsTransformer + ? this.configOptionsTransformer(configOptions) + : configOptions, + logger: this.logger, + provider: this.provider, + }) + : derivedModels; const modeInfo = deriveModesFromACP( this.defaultModes, transformed.modes, @@ -2930,7 +2975,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { } } -function findSelectConfigOption({ +export function findSelectConfigOption({ configOptions, category, id, @@ -3036,7 +3081,7 @@ function normalizeConfigFeatureValue(value: unknown): string { throw new Error(`ACP feature value must be a string`); } -function deriveSelectorOptions( +export function deriveSelectorOptions( configOptions: SessionConfigOption[] | null | undefined, category: string, ): ConfigOptionSelector[] { diff --git a/packages/server/src/server/agent/providers/generic-acp-agent.ts b/packages/server/src/server/agent/providers/generic-acp-agent.ts index 72f29faa1..095d4e10a 100644 --- a/packages/server/src/server/agent/providers/generic-acp-agent.ts +++ b/packages/server/src/server/agent/providers/generic-acp-agent.ts @@ -5,6 +5,7 @@ import type { AgentCapabilityFlags } from "../agent-sdk-types.js"; import { checkProviderLaunchAvailable, resolveProviderLaunch } from "../provider-launch-config.js"; import { ACPAgentClient, + type ACPCatalogModelResolver, type ACPClientCapabilityMeta, type ACPConfigFeatureOption, DEFAULT_ACP_CAPABILITIES, @@ -49,6 +50,7 @@ interface GenericACPAgentClientOptions { clientCapabilityMeta?: ACPClientCapabilityMeta; configFeatureOptions?: ACPConfigFeatureOption[]; extensionCommandsParser?: ACPExtensionCommandsParser; + catalogModelResolver?: ACPCatalogModelResolver; } export class GenericACPAgentClient extends ACPAgentClient { @@ -73,6 +75,7 @@ export class GenericACPAgentClient extends ACPAgentClient { clientCapabilityMeta: options.clientCapabilityMeta, configFeatureOptions: options.configFeatureOptions, extensionCommandsParser: options.extensionCommandsParser, + catalogModelResolver: options.catalogModelResolver, }); this.command = options.command; diff --git a/packages/server/src/server/agent/providers/kimi-acp-agent.test.ts b/packages/server/src/server/agent/providers/kimi-acp-agent.test.ts new file mode 100644 index 000000000..ce296726a --- /dev/null +++ b/packages/server/src/server/agent/providers/kimi-acp-agent.test.ts @@ -0,0 +1,221 @@ +import { SessionConfigOption } from "@agentclientprotocol/sdk"; +import { describe, expect, test, vi } from "vitest"; + +import { createTestLogger } from "../../../test-utils/test-logger.js"; +import { type SpawnedACPProcess } from "./acp-agent.js"; +import { KimiACPAgentClient } from "./kimi-acp-agent.js"; + +function modelConfigOption(currentValue: string): SessionConfigOption { + return { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue, + options: [ + { value: "kimi-for-coding", name: "K2.7 Coding Fast" }, + { value: "kimi-k3", name: "K3" }, + ], + }; +} + +function booleanThinkingConfigOption(): SessionConfigOption { + return { + id: "thinking", + name: "Thinking", + category: "thought_level", + type: "select", + currentValue: "off", + options: [ + { value: "off", name: "Off" }, + { value: "on", name: "On" }, + ], + }; +} + +function effortThinkingConfigOption(): SessionConfigOption { + return { + id: "thinking", + name: "Thinking", + category: "thought_level", + type: "select", + currentValue: "medium", + options: [ + { value: "off", name: "Off" }, + { value: "low", name: "Low" }, + { value: "medium", name: "Medium" }, + { value: "high", name: "High" }, + ], + }; +} + +function createKimiClient(spawnProcess: () => Promise): KimiACPAgentClient { + class TestKimiACPAgentClient extends KimiACPAgentClient { + protected override async spawnProcess(): Promise { + return spawnProcess(); + } + + protected override async closeProbe(): Promise {} + } + + return new TestKimiACPAgentClient({ + logger: createTestLogger(), + command: ["kimi", "acp"], + providerId: "kimi", + label: "Kimi Code CLI", + }); +} + +describe("KimiACPAgentClient per-model thinking options", () => { + test("probes each model so a boolean model and an effort-level model keep distinct thinking options", async () => { + const setSessionConfigOption = vi.fn(async ({ value }: { value: string }) => ({ + configOptions: + value === "kimi-k3" + ? [modelConfigOption(value), effortThinkingConfigOption()] + : [modelConfigOption(value), booleanThinkingConfigOption()], + })); + + const client = createKimiClient( + async () => + ({ + child: { kill: vi.fn(), exitCode: 0, signalCode: null, once: vi.fn() }, + connection: { + newSession: vi.fn().mockResolvedValue({ + sessionId: "session-1", + configOptions: [modelConfigOption("kimi-for-coding"), booleanThinkingConfigOption()], + }), + setSessionConfigOption, + }, + initialize: { agentCapabilities: {} }, + }) as unknown as SpawnedACPProcess, + ); + + const catalog = await client.fetchCatalog({ + scope: "workspace", + cwd: "/tmp/acp-kimi-thinking", + force: false, + }); + + expect(setSessionConfigOption).toHaveBeenCalledTimes(2); + expect(setSessionConfigOption).toHaveBeenNthCalledWith(1, { + sessionId: "session-1", + configId: "model", + value: "kimi-for-coding", + }); + expect(setSessionConfigOption).toHaveBeenNthCalledWith(2, { + sessionId: "session-1", + configId: "model", + value: "kimi-k3", + }); + + const kimiForCoding = catalog.models.find((model) => model.id === "kimi-for-coding"); + const kimiK3 = catalog.models.find((model) => model.id === "kimi-k3"); + + expect(kimiForCoding?.thinkingOptions).toEqual([ + expect.objectContaining({ id: "off", isDefault: true }), + expect.objectContaining({ id: "on", isDefault: false }), + ]); + expect(kimiK3?.thinkingOptions).toEqual([ + expect.objectContaining({ id: "off", isDefault: false }), + expect.objectContaining({ id: "low", isDefault: false }), + expect.objectContaining({ id: "medium", isDefault: true }), + expect.objectContaining({ id: "high", isDefault: false }), + ]); + }); + + test("skips per-model probing when the provider reports a single model", async () => { + const setSessionConfigOption = vi.fn(); + + const client = createKimiClient( + async () => + ({ + child: { kill: vi.fn(), exitCode: 0, signalCode: null, once: vi.fn() }, + connection: { + newSession: vi.fn().mockResolvedValue({ + sessionId: "session-1", + configOptions: [ + { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: "kimi-for-coding", + options: [{ value: "kimi-for-coding", name: "K2.7 Coding Fast" }], + }, + booleanThinkingConfigOption(), + ], + }), + setSessionConfigOption, + }, + initialize: { agentCapabilities: {} }, + }) as unknown as SpawnedACPProcess, + ); + + await client.fetchCatalog({ scope: "workspace", cwd: "/tmp/acp-kimi-single", force: false }); + + expect(setSessionConfigOption).not.toHaveBeenCalled(); + }); + + test("skips per-model probing when the provider has no thinking picker", async () => { + const setSessionConfigOption = vi.fn(); + + const client = createKimiClient( + async () => + ({ + child: { kill: vi.fn(), exitCode: 0, signalCode: null, once: vi.fn() }, + connection: { + newSession: vi.fn().mockResolvedValue({ + sessionId: "session-1", + configOptions: [modelConfigOption("kimi-for-coding")], + }), + setSessionConfigOption, + }, + initialize: { agentCapabilities: {} }, + }) as unknown as SpawnedACPProcess, + ); + + await client.fetchCatalog({ + scope: "workspace", + cwd: "/tmp/acp-kimi-no-thinking", + force: false, + }); + + expect(setSessionConfigOption).not.toHaveBeenCalled(); + }); + + test("keeps a model's default thinking options when its probe fails", async () => { + const setSessionConfigOption = vi.fn(async ({ value }: { value: string }) => { + if (value === "kimi-k3") { + throw new Error("probe rejected model switch"); + } + return { configOptions: [modelConfigOption(value), booleanThinkingConfigOption()] }; + }); + + const client = createKimiClient( + async () => + ({ + child: { kill: vi.fn(), exitCode: 0, signalCode: null, once: vi.fn() }, + connection: { + newSession: vi.fn().mockResolvedValue({ + sessionId: "session-1", + configOptions: [modelConfigOption("kimi-for-coding"), booleanThinkingConfigOption()], + }), + setSessionConfigOption, + }, + initialize: { agentCapabilities: {} }, + }) as unknown as SpawnedACPProcess, + ); + + const catalog = await client.fetchCatalog({ + scope: "workspace", + cwd: "/tmp/acp-kimi-probe-error", + force: false, + }); + + const kimiK3 = catalog.models.find((model) => model.id === "kimi-k3"); + expect(kimiK3?.thinkingOptions).toEqual([ + expect.objectContaining({ id: "off", isDefault: true }), + expect.objectContaining({ id: "on", isDefault: false }), + ]); + }); +}); diff --git a/packages/server/src/server/agent/providers/kimi-acp-agent.ts b/packages/server/src/server/agent/providers/kimi-acp-agent.ts new file mode 100644 index 000000000..e8ff7ce25 --- /dev/null +++ b/packages/server/src/server/agent/providers/kimi-acp-agent.ts @@ -0,0 +1,97 @@ +import type { Logger } from "pino"; + +import type { AgentModelDefinition } from "../agent-sdk-types.js"; +import { + type ACPCatalogModelResolverContext, + deriveSelectorOptions, + findSelectConfigOption, +} from "./acp-agent.js"; +import { toDiagnosticErrorMessage } from "./diagnostic-utils.js"; +import { GenericACPAgentClient } from "./generic-acp-agent.js"; + +interface KimiACPAgentClientOptions { + logger: Logger; + command: [string, ...string[]]; + env?: Record; + providerId?: string; + label?: string; + providerParams?: unknown; +} + +/** + * Kimi reports different thinking-effort levels per model (a boolean on/off toggle for one + * model, a multi-level select for another), but only exposes the *currently selected* + * model's levels through `configOptions` — the model list itself carries no per-model + * effort metadata. `deriveModelDefinitionsFromACP` can only see whichever model the probe + * session defaulted to, so every other model would otherwise inherit that one model's + * thinking options. + * + * Reuse the single catalog probe session to switch through each candidate model in turn and + * read back its real thinking options, rather than spawning a probe per model. Skipped + * entirely when the provider reports one model or no thinking picker, so a misbehaving ACP + * that only advertises a single model pays no extra round trips. + * + * This lives on the Kimi client, not the base ACP adapter: only Kimi needs the extra + * `setSessionConfigOption` round trips, so no other ACP provider risks a slow or + * nonconforming agent stalling its catalog probe on model switching. + */ +export async function resolveKimiCatalogModels({ + connection, + sessionId, + models, + configOptions, + runRequest, + transformConfigOptions, + logger, + provider, +}: ACPCatalogModelResolverContext): Promise { + if (models.length <= 1) { + return models; + } + const modelOption = findSelectConfigOption({ configOptions, category: "model" }); + if (!modelOption || !findSelectConfigOption({ configOptions, category: "thought_level" })) { + return models; + } + + const resolved: AgentModelDefinition[] = []; + for (const model of models) { + try { + const response = await runRequest(() => + connection.setSessionConfigOption({ + sessionId, + configId: modelOption.id, + value: model.id, + }), + ); + const modelConfigOptions = transformConfigOptions(response.configOptions ?? []); + const thinkingOptions = deriveSelectorOptions(modelConfigOptions, "thought_level"); + resolved.push({ + ...model, + thinkingOptions: thinkingOptions.length > 0 ? thinkingOptions : undefined, + defaultThinkingOptionId: + thinkingOptions.find((option) => option.isDefault)?.id ?? undefined, + }); + } catch (error) { + logger.warn( + { modelId: model.id, error: toDiagnosticErrorMessage(error) }, + `${provider} catalog probe could not resolve thinking options for model "${model.id}"; keeping its default options`, + ); + resolved.push(model); + } + } + return resolved; +} + +export class KimiACPAgentClient extends GenericACPAgentClient { + constructor(options: KimiACPAgentClientOptions) { + super({ + logger: options.logger, + command: options.command, + env: options.env, + providerId: options.providerId, + label: options.label, + providerParams: options.providerParams, + catalogModelResolver: resolveKimiCatalogModels, + }); + } +} diff --git a/packages/server/src/services/quota-fetcher/providers/kimi.ts b/packages/server/src/services/quota-fetcher/providers/kimi.ts index af3e6b560..84157315d 100644 --- a/packages/server/src/services/quota-fetcher/providers/kimi.ts +++ b/packages/server/src/services/quota-fetcher/providers/kimi.ts @@ -1,31 +1,276 @@ +import { randomUUID } from "node:crypto"; import { existsSync, promises as fs } from "node:fs"; import { homedir } from "node:os"; -import { join } from "node:path"; +import { basename, dirname, join } from "node:path"; import type { Logger } from "pino"; import { z } from "zod"; -import type { ProviderUsage } from "../../../server/messages.js"; +import type { ProviderUsage, ProviderUsageWindow } from "../../../server/messages.js"; import type { ProviderApiFetch, ProviderUsageFetcher } from "../provider.js"; import { + ApiNumberSchema, ApiOptionalStringSchema, fetchProviderApi, toneFromUsedPct, unavailableUsage, + windowFromUsedPct, } from "../usage.js"; -const KimiUsageResponseSchema = z.object({ - usage: z - .object({ - limit: ApiOptionalStringSchema, - remaining: ApiOptionalStringSchema, - resetTime: ApiOptionalStringSchema, - }) - .nullish(), +const KIMI_CLIENT_ID = "17e5f671-d194-4dfb-9706-5516cb48c098"; +const KIMI_USAGE_URL = "https://api.kimi.com/coding/v1/usages"; +const KIMI_TOKEN_URL = "https://auth.kimi.com/api/oauth/token"; + +const KimiUsageFieldsSchema = z.object({ + limit: ApiOptionalStringSchema, + used: ApiOptionalStringSchema, + remaining: ApiOptionalStringSchema, + resetTime: ApiOptionalStringSchema, + resetAt: ApiOptionalStringSchema, + reset_time: ApiOptionalStringSchema, + reset_at: ApiOptionalStringSchema, + name: ApiOptionalStringSchema, + title: ApiOptionalStringSchema, + scope: ApiOptionalStringSchema, + duration: z.unknown().optional(), + timeUnit: ApiOptionalStringSchema, }); -const KimiAuthSchema = z.object({ - access_token: z.string().optional(), +const KimiUsageLimitSchema = z + .object({ + window: z.unknown().optional(), + detail: z.unknown().optional(), + }) + .passthrough(); + +const KimiUsageResponseSchema = z.object({ + usage: z.unknown().nullish(), + limits: z.unknown().nullish(), }); +type KimiUsageFields = z.infer; +type KimiUsageLimit = z.infer; + +function parseUsageFields(value: unknown): KimiUsageFields | null { + const parsed = KimiUsageFieldsSchema.safeParse(value); + return parsed.success ? parsed.data : null; +} + +function usedPctFromFields(fields: KimiUsageFields): number | null { + const limit = fields.limit === undefined ? null : Number(fields.limit); + const used = fields.used === undefined ? null : Number(fields.used); + const remaining = fields.remaining === undefined ? null : Number(fields.remaining); + + if (limit === null || !Number.isFinite(limit) || limit <= 0) return null; + + let usedValue: number | null = null; + if (used !== null && Number.isFinite(used)) { + usedValue = used; + } else if (remaining !== null && Number.isFinite(remaining)) { + usedValue = limit - remaining; + } + + return usedValue === null ? null : Math.max(0, Math.min(100, (usedValue / limit) * 100)); +} + +function resetTimeFromFields(fields: KimiUsageFields): string | null { + return fields.resetTime ?? fields.resetAt ?? fields.reset_time ?? fields.reset_at ?? null; +} + +function explicitUsageLabel(...fields: Array): string | null { + for (const field of fields) { + if (!field) continue; + const label = field.name ?? field.title ?? field.scope; + if (label?.trim()) return label.trim(); + } + return null; +} + +function durationFrom(value: unknown): number | null { + const duration = Number(value); + return Number.isFinite(duration) && duration > 0 ? duration : null; +} + +function durationLabel(duration: number, timeUnit: string | undefined): string | null { + if (!timeUnit) return null; + + const normalizedUnit = timeUnit.replace(/^TIME_UNIT_/i, "").toUpperCase(); + if (normalizedUnit.includes("MINUTE")) { + if (duration % 60 === 0) return `${duration / 60}-hour limit`; + return `${duration}-minute limit`; + } + if (normalizedUnit.includes("HOUR")) return `${duration}-hour limit`; + if (normalizedUnit.includes("DAY")) return `${duration}-day limit`; + if (normalizedUnit.includes("WEEK")) return `${duration}-week limit`; + if (normalizedUnit.includes("SECOND")) return `${duration}-second limit`; + return null; +} + +function idPart(value: string | number): string { + return String(value) + .toLowerCase() + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, ""); +} + +function hasUsageData(fields: KimiUsageFields | null): fields is KimiUsageFields { + return ( + fields !== null && + (fields.limit !== undefined || fields.used !== undefined || fields.remaining !== undefined) + ); +} + +function limitFields(limit: KimiUsageLimit): { + fields: KimiUsageFields | null; + metadata: KimiUsageFields | null; +} { + const metadata = parseUsageFields(limit); + const detail = parseUsageFields(limit.detail); + return { + fields: hasUsageData(detail) ? detail : metadata, + metadata, + }; +} + +function windowMetadata(limit: KimiUsageLimit): KimiUsageFields | null { + return parseUsageFields(limit.window); +} + +function limitIdentity(input: { + metadata: KimiUsageFields | null; + fields: KimiUsageFields; + window: KimiUsageFields | null; + index: number; +}): { id: string; label: string } { + const label = explicitUsageLabel(input.metadata, input.fields); + const duration = durationFrom( + input.window?.duration ?? input.metadata?.duration ?? input.fields.duration, + ); + const timeUnit = input.window?.timeUnit ?? input.metadata?.timeUnit ?? input.fields.timeUnit; + const generatedLabel = duration === null ? null : durationLabel(duration, timeUnit); + + if (duration !== null && timeUnit) { + return { + id: `coding_limit_${idPart(duration)}_${idPart(timeUnit)}`, + label: label ?? generatedLabel ?? `Limit ${input.index + 1}`, + }; + } + + if (label) { + return { id: `coding_limit_${idPart(label)}`, label }; + } + + return { + id: `coding_limit_${input.index + 1}`, + label: generatedLabel ?? `Limit ${input.index + 1}`, + }; +} + +function windowFromFields(input: { + id: string; + label: string; + fields: KimiUsageFields; +}): ProviderUsageWindow { + const usedPct = usedPctFromFields(input.fields); + return windowFromUsedPct({ + id: input.id, + label: input.label, + utilizationPct: usedPct, + resetsAt: resetTimeFromFields(input.fields), + tone: toneFromUsedPct(usedPct), + }); +} + +function uniqueWindowId(baseId: string, seenIds: Set): string { + let id = baseId; + let suffix = 2; + while (seenIds.has(id)) { + id = `${baseId}_${suffix}`; + suffix += 1; + } + seenIds.add(id); + return id; +} + +function kimiUsageWindowsFromPayload( + payload: unknown, + logger: Pick, +): ProviderUsageWindow[] { + const response = KimiUsageResponseSchema.parse(payload); + const windows: ProviderUsageWindow[] = []; + const seenWindowIds = new Set(); + const usage = parseUsageFields(response.usage); + + if (hasUsageData(usage)) { + windows.push( + windowFromFields({ + id: uniqueWindowId("coding_usage", seenWindowIds), + label: explicitUsageLabel(usage) ?? "Weekly limit", + fields: usage, + }), + ); + } + + const limits = Array.isArray(response.limits) ? response.limits : []; + for (const [index, rawLimit] of limits.entries()) { + const parsedLimit = KimiUsageLimitSchema.safeParse(rawLimit); + if (!parsedLimit.success) { + logger.debug({ index }, "Ignoring malformed Kimi usage limit window"); + continue; + } + + const limit = parsedLimit.data; + const { fields, metadata } = limitFields(limit); + if (!hasUsageData(fields)) { + logger.debug({ index }, "Ignoring malformed Kimi usage limit window"); + continue; + } + + const window = windowMetadata(limit); + const identity = limitIdentity({ metadata, fields, window, index }); + windows.push( + windowFromFields({ + id: uniqueWindowId(identity.id, seenWindowIds), + label: identity.label, + fields, + }), + ); + } + + return windows; +} + +const KimiAuthSchema = z + .object({ + access_token: z.string().nullish(), + refresh_token: z.string().nullish(), + expires_at: ApiNumberSchema.nullish(), + expires_in: ApiNumberSchema.nullish(), + scope: z.string().nullish(), + token_type: z.string().nullish(), + }) + .passthrough(); + +const KimiTokenRefreshSchema = z.object({ + access_token: z.string(), + refresh_token: z.string().nullish(), + expires_in: ApiNumberSchema.nullish(), + scope: z.string().nullish(), + token_type: z.string().nullish(), +}); + +type KimiAuth = z.infer; +type KimiTokenRefresh = z.infer; + +interface KimiCredentialRecord { + credentials: KimiAuth & { access_token: string }; + filePath: string | null; +} + +interface SaveRefreshedCredentialsOptions { + filePath: string; + refreshTokenUsed: string; + refreshed: KimiTokenRefresh; +} + interface KimiQuotaProviderOptions { logger: Logger; fetch?: ProviderApiFetch; @@ -41,63 +286,163 @@ export class KimiQuotaProvider implements ProviderUsageFetcher { private readonly homeDir?: string; constructor(options: KimiQuotaProviderOptions) { - this.logger = options.logger; + this.logger = options.logger.child({ module: "kimi-quota-provider" }); this.fetchApi = options.fetch ?? fetch; this.homeDir = options.homeDir; } async fetchUsage(): Promise { - const token = - process.env["KIMI_TOKEN"] || process.env["KIMI_API_KEY"] || (await this.readKimiToken()); + const credentials = await this.readCredentials(); + if (!credentials) return unavailableUsage(this); - if (!token) return unavailableUsage(this); - - const res = await fetchProviderApi(this.fetchApi, "https://api.kimi.com/coding/v1/usages", { - headers: { - Authorization: `Bearer ${token}`, - Accept: "application/json", - }, - }); + const res = await this.fetchUsageResponse(credentials); if (!res.ok) { this.logger.debug({ status: res.status }, "Kimi usage fetch failed"); return unavailableUsage(this); } - const resp = KimiUsageResponseSchema.parse(await res.json()); - const limit = resp.usage?.limit === undefined ? null : Number(resp.usage.limit); - const remaining = resp.usage?.remaining === undefined ? null : Number(resp.usage.remaining); - const hasFiniteLimit = typeof limit === "number" && Number.isFinite(limit) && limit > 0; - const hasFiniteRemaining = typeof remaining === "number" && Number.isFinite(remaining); - const usedPct = - hasFiniteLimit && hasFiniteRemaining - ? Math.max(0, Math.min(100, ((limit - remaining) / limit) * 100)) - : null; + const windows = kimiUsageWindowsFromPayload(await res.json(), this.logger); return { providerId: this.providerId, displayName: this.displayName, status: "available", planLabel: null, - windows: [ - { - id: "coding_usage", - label: "Coding usage", - usedPct, - remainingPct: usedPct === null ? null : Math.max(0, 100 - usedPct), - resetsAt: resp.usage?.resetTime ?? null, - tone: toneFromUsedPct(usedPct), - }, - ], + windows, balances: [], details: [], error: null, }; } - private async readKimiToken(): Promise { + private async fetchUsageResponse(record: KimiCredentialRecord): Promise { + const { credentials, filePath } = record; + const res = await this.callUsageApi(credentials.access_token); + + if (res.status !== 401 || !filePath || !credentials.refresh_token) return res; + + const latest = await this.readCredentialFile(filePath); + const retried = + latest?.access_token && latest.access_token !== credentials.access_token + ? await this.callUsageApi(latest.access_token) + : res; + if (retried.status !== 401) return retried; + + const refreshTokenUsed = latest?.refresh_token ?? credentials.refresh_token; + const refreshed = await this.refreshToken(refreshTokenUsed); + if (!refreshed) return retried; + + await this.saveRefreshedCredentials({ filePath, refreshTokenUsed, refreshed }); + return this.callUsageApi(refreshed.access_token); + } + + private async callUsageApi(token: string): Promise { + return fetchProviderApi(this.fetchApi, KIMI_USAGE_URL, { + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/json", + }, + }); + } + + private async refreshToken(refreshToken: string): Promise { + const body = new URLSearchParams({ + client_id: KIMI_CLIENT_ID, + grant_type: "refresh_token", + refresh_token: refreshToken, + }); + const res = await fetchProviderApi(this.fetchApi, KIMI_TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body, + }); + if (!res.ok) { + this.logger.debug({ status: res.status }, "Kimi token refresh failed"); + return null; + } + const parsed = KimiTokenRefreshSchema.safeParse(await res.json()); + return parsed.success ? parsed.data : null; + } + + private async saveRefreshedCredentials(options: SaveRefreshedCredentialsOptions): Promise { + const { filePath, refreshTokenUsed, refreshed } = options; + const existing = await this.readCredentialFile(filePath); + if (!existing) return; + + // The Kimi CLI owns this file too. If its refresh token no longer matches the one we + // just spent, the CLI rotated the credentials while our refresh was in flight and its + // copy is newer than ours — writing our merge would strand the CLI on dead tokens. + if (existing.refresh_token !== refreshTokenUsed) { + this.logger.debug("Kimi credentials rotated during refresh; keeping the file on disk"); + return; + } + + const expiresIn = refreshed.expires_in ?? existing.expires_in; + const merged: KimiAuth = { + ...existing, + access_token: refreshed.access_token, + refresh_token: refreshed.refresh_token ?? existing.refresh_token, + expires_in: expiresIn, + expires_at: + expiresIn == null ? existing.expires_at : Math.floor(Date.now() / 1000) + expiresIn, + scope: refreshed.scope ?? existing.scope, + token_type: refreshed.token_type ?? existing.token_type, + }; + + const tempPath = join( + dirname(filePath), + `.${basename(filePath)}.${process.pid}.${randomUUID()}.tmp`, + ); + try { + await fs.writeFile(tempPath, JSON.stringify(merged, null, 2), { mode: 0o600 }); + + // writeFile above is async I/O that gives the CLI a window to rotate the file on + // its own. Re-read right before the rename and confirm nothing changed since the + // guard above — the check-then-write gap this closes is what the file-level guard + // couldn't cover, since it ran before the write, not right before the replace. + const beforeRename = await this.readCredentialFile(filePath); + if (!this.credentialsUnchangedSince(existing, beforeRename)) { + this.logger.debug("Kimi credentials rotated during refresh; discarding stale merge"); + await fs.rm(tempPath, { force: true }).catch(() => undefined); + return; + } + + await fs.rename(tempPath, filePath); + } catch (error) { + this.logger.warn({ err: error }, "Failed to persist refreshed Kimi credentials"); + await fs.rm(tempPath, { force: true }).catch(() => undefined); + } + } + + private credentialsUnchangedSince(baseline: KimiAuth, candidate: KimiAuth | null): boolean { + return ( + candidate?.access_token === baseline.access_token && + candidate?.refresh_token === baseline.refresh_token + ); + } + + private async readCredentials(): Promise { + const environmentToken = process.env["KIMI_TOKEN"] || process.env["KIMI_API_KEY"]; + if (environmentToken) { + return { credentials: { access_token: environmentToken }, filePath: null }; + } + + for (const path of this.credentialPaths()) { + const credentials = await this.readCredentialFile(path); + if (credentials?.access_token) { + return { + credentials: { ...credentials, access_token: credentials.access_token }, + filePath: path, + }; + } + } + return null; + } + + private credentialPaths(): string[] { const homeDir = this.homeDir ?? homedir(); - const paths = [ + return [ join( process.env["KIMI_CODE_HOME"] || join(homeDir, ".kimi-code"), "credentials", @@ -105,16 +450,14 @@ export class KimiQuotaProvider implements ProviderUsageFetcher { ), join(homeDir, ".kimi", "credentials", "kimi-code.json"), ]; + } - for (const path of paths) { - if (!existsSync(path)) continue; - try { - const credentials = KimiAuthSchema.parse(JSON.parse(await fs.readFile(path, "utf8"))); - if (credentials.access_token) return credentials.access_token; - } catch { - continue; - } + private async readCredentialFile(path: string): Promise { + if (!existsSync(path)) return null; + try { + return KimiAuthSchema.parse(JSON.parse(await fs.readFile(path, "utf8"))); + } catch { + return null; } - return null; } } diff --git a/packages/server/src/services/quota-fetcher/service.test.ts b/packages/server/src/services/quota-fetcher/service.test.ts index 5550be407..087f7fcf7 100644 --- a/packages/server/src/services/quota-fetcher/service.test.ts +++ b/packages/server/src/services/quota-fetcher/service.test.ts @@ -1,5 +1,6 @@ -import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { mkdtempSync, rmSync } from "node:fs"; +import { promises as fsPromises } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -37,16 +38,22 @@ function writeCodexAuth(dir: string, accessToken: string, refreshToken = "rt_cod ); } -function writeKimiCredentials(dir: string, accessToken: string): void { +function kimiCredentialPath(dir: string): string { + return join(dir, "credentials", "kimi-code.json"); +} + +function writeKimiCredentials(dir: string, accessToken: string, overrides: object = {}): void { mkdirSync(join(dir, "credentials"), { recursive: true }); writeFileSync( - join(dir, "credentials", "kimi-code.json"), + kimiCredentialPath(dir), JSON.stringify({ access_token: accessToken, refresh_token: "rt_kimi", expires_at: 1_798_812_800, + expires_in: 900, scope: "kimi-code", token_type: "Bearer", + ...overrides, }), ); } @@ -397,7 +404,9 @@ describe("real provider usage fetchers", () => { new KimiQuotaProvider({ logger, fetch: fetchThroughTestDouble, - homeDir: options.kimiHomeDir, + // Never leave this undefined: the provider would fall back to os.homedir() and + // read — and now write — the developer's real Kimi credentials. + homeDir: options.kimiHomeDir ?? homeDir, }), new MiniMaxQuotaProvider({ logger, @@ -921,6 +930,226 @@ describe("real provider usage fetchers", () => { }); }); + it("reads Kimi credentials whose optional fields are null", async () => { + writeKimiCredentials(join(homeDir, ".kimi-code"), "kimi_cli_token", { + expires_at: null, + expires_in: null, + scope: null, + token_type: null, + }); + fetchApi = mockFetch( + new Map([ + [ + "https://api.kimi.com/coding/v1/usages", + () => jsonResponse({ usage: { limit: "100", remaining: "60" } }), + ], + ]), + ); + + const kimi = findProvider(await service({ kimiHomeDir: homeDir }).listUsage(), "kimi"); + + expect(kimi.status).toBe("available"); + }); + + it("persists refreshed Kimi tokens to the credential file that was read", async () => { + writeKimiCredentials(join(homeDir, ".kimi-code"), "at_kimi_expired", { + preserved_field: "keep-me", + }); + const authorization: Array = []; + let usageCalls = 0; + fetchApi = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => { + const endpoint = url.toString(); + if (endpoint === "https://api.kimi.com/coding/v1/usages") { + usageCalls += 1; + authorization.push( + (init?.headers as Record | undefined)?.Authorization ?? null, + ); + if (usageCalls === 1) return new Response(null, { status: 401 }); + return jsonResponse({ usage: { limit: "100", remaining: "74" } }); + } + if (endpoint === "https://auth.kimi.com/api/oauth/token") { + expect(init?.method).toBe("POST"); + expect(init?.body?.toString()).toContain("grant_type=refresh_token"); + expect(init?.body?.toString()).toContain("refresh_token=rt_kimi"); + return jsonResponse({ + access_token: "at_kimi_fresh", + refresh_token: "rt_kimi_rotated", + expires_in: 900, + }); + } + throw new Error(`Unmocked fetch: ${endpoint}`); + }) as never; + + const result = await service({ kimiHomeDir: homeDir }).listUsage(); + const persisted = JSON.parse( + readFileSync(kimiCredentialPath(join(homeDir, ".kimi-code")), "utf8"), + ); + + expect(findProvider(result, "kimi").status).toBe("available"); + expect(authorization).toEqual(["Bearer at_kimi_expired", "Bearer at_kimi_fresh"]); + expect(persisted).toMatchObject({ + access_token: "at_kimi_fresh", + refresh_token: "rt_kimi_rotated", + expires_in: 900, + scope: "kimi-code", + preserved_field: "keep-me", + }); + expect(persisted.expires_at).toBeGreaterThan(Date.now() / 1000); + }); + + it("prefers a Kimi token rewritten on disk over refreshing again", async () => { + const kimiHome = join(homeDir, ".kimi-code"); + writeKimiCredentials(kimiHome, "at_kimi_old"); + let usageCalls = 0; + fetchApi = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => { + const endpoint = url.toString(); + if (endpoint === "https://api.kimi.com/coding/v1/usages") { + usageCalls += 1; + if (usageCalls === 1) { + writeKimiCredentials(kimiHome, "at_kimi_rewritten"); + return new Response(null, { status: 401 }); + } + expect((init?.headers as Record | undefined)?.Authorization).toBe( + "Bearer at_kimi_rewritten", + ); + return jsonResponse({ usage: { limit: "100", remaining: "50" } }); + } + throw new Error(`Unmocked fetch: ${endpoint}`); + }) as never; + + const result = await service({ kimiHomeDir: homeDir }).listUsage(); + + expect(findProvider(result, "kimi").status).toBe("available"); + expect(usageCalls).toBe(2); + }); + + it("keeps Kimi credentials rotated by the CLI during the refresh", async () => { + const kimiHome = join(homeDir, ".kimi-code"); + writeKimiCredentials(kimiHome, "at_kimi_expired"); + fetchApi = mockFetch( + new Map([ + ["https://api.kimi.com/coding/v1/usages", () => new Response(null, { status: 401 })], + [ + "https://auth.kimi.com/api/oauth/token", + () => { + writeKimiCredentials(kimiHome, "at_kimi_cli", { refresh_token: "rt_kimi_cli" }); + return jsonResponse({ + access_token: "at_kimi_fresh", + refresh_token: "rt_kimi_rotated", + expires_in: 900, + }); + }, + ], + ]), + ); + + await service({ kimiHomeDir: homeDir }).listUsage(); + const persisted = JSON.parse(readFileSync(kimiCredentialPath(kimiHome), "utf8")); + + expect(persisted).toMatchObject({ + access_token: "at_kimi_cli", + refresh_token: "rt_kimi_cli", + }); + }); + + it("discards a refreshed Kimi merge if the CLI rotates credentials after the guard but before the rename", async () => { + const kimiHome = join(homeDir, ".kimi-code"); + writeKimiCredentials(kimiHome, "at_kimi_expired"); + fetchApi = mockFetch( + new Map([ + ["https://api.kimi.com/coding/v1/usages", () => new Response(null, { status: 401 })], + [ + "https://auth.kimi.com/api/oauth/token", + () => + jsonResponse({ + access_token: "at_kimi_fresh", + refresh_token: "rt_kimi_fresh", + expires_in: 900, + }), + ], + ]), + ); + + // The credential-file guard runs, passes, and only then does the CLI rotate the file + // — simulated by mutating the file as a side effect of the temp-file write, which is + // the async I/O step that sits between the guard read and the final rename. + const realWriteFile = fsPromises.writeFile; + const writeFileSpy = vi + .spyOn(fsPromises, "writeFile") + .mockImplementationOnce(async (...args: Parameters) => { + writeKimiCredentials(kimiHome, "at_kimi_cli", { refresh_token: "rt_kimi_cli" }); + return realWriteFile(...args); + }); + + await service({ kimiHomeDir: homeDir }).listUsage(); + const persisted = JSON.parse(readFileSync(kimiCredentialPath(kimiHome), "utf8")); + + expect(persisted).toMatchObject({ + access_token: "at_kimi_cli", + refresh_token: "rt_kimi_cli", + }); + + writeFileSpy.mockRestore(); + }); + + it("does not recreate a Kimi credential file deleted during the refresh", async () => { + const credentialPath = kimiCredentialPath(join(homeDir, ".kimi-code")); + writeKimiCredentials(join(homeDir, ".kimi-code"), "at_kimi_expired"); + fetchApi = mockFetch( + new Map([ + ["https://api.kimi.com/coding/v1/usages", () => new Response(null, { status: 401 })], + [ + "https://auth.kimi.com/api/oauth/token", + () => { + rmSync(credentialPath, { force: true }); + return jsonResponse({ access_token: "at_kimi_fresh", expires_in: 900 }); + }, + ], + ]), + ); + + const result = await service({ kimiHomeDir: homeDir }).listUsage(); + + expect(findProvider(result, "kimi").status).toBe("unavailable"); + expect(existsSync(credentialPath)).toBe(false); + }); + + it("returns unavailable Kimi usage when the token refresh is rejected", async () => { + writeKimiCredentials(join(homeDir, ".kimi-code"), "at_kimi_expired"); + fetchApi = mockFetch( + new Map([ + ["https://api.kimi.com/coding/v1/usages", () => new Response(null, { status: 401 })], + ["https://auth.kimi.com/api/oauth/token", () => new Response(null, { status: 400 })], + ]), + ); + + const result = await service({ kimiHomeDir: homeDir }).listUsage(); + + expect(findProvider(result, "kimi").status).toBe("unavailable"); + }); + + it("does not refresh Kimi tokens read from the environment", async () => { + process.env["KIMI_TOKEN"] = "kimi_test_token"; + const usageFetch = vi.fn(async () => new Response(null, { status: 401 })); + fetchApi = usageFetch as never; + + const result = await service().listUsage(); + + expect(findProvider(result, "kimi").status).toBe("unavailable"); + expect(usageFetch).toHaveBeenCalledTimes(1); + }); + + it("does not refresh Kimi tokens on a 403", async () => { + writeKimiCredentials(join(homeDir, ".kimi-code"), "at_kimi_forbidden"); + const usageFetch = vi.fn(async () => new Response(null, { status: 403 })); + fetchApi = usageFetch as never; + + const result = await service({ kimiHomeDir: homeDir }).listUsage(); + + expect(findProvider(result, "kimi").status).toBe("unavailable"); + expect(usageFetch).toHaveBeenCalledTimes(1); + }); + it("fetches MiniMax usage from MINIMAX_API_KEY against the global endpoint", async () => { process.env["MINIMAX_API_KEY"] = "minimax_test_token"; let requestedUrl: string | null = null; @@ -1488,3 +1717,145 @@ describe("ClaudeQuotaProvider scoped limit reconciliation", () => { ]); }); }); + +describe("KimiQuotaProvider usage windows", () => { + afterEach(() => { + delete process.env["KIMI_TOKEN"]; + vi.restoreAllMocks(); + }); + + it("normalizes weekly and enforced rolling usage windows", async () => { + process.env["KIMI_TOKEN"] = "kimi_test_token"; + const fetchApi = vi.fn(async () => + jsonResponse({ + limited: true, + usage: { + limit: "100", + used: "61", + remaining: "39", + resetTime: "2026-08-05T00:01:45Z", + }, + limits: [ + { + window: { + duration: 300, + timeUnit: "TIME_UNIT_MINUTE", + }, + detail: { + limit: "100", + used: "100", + resetTime: "2026-07-31T17:01:45Z", + }, + }, + ], + }), + ); + const provider = new KimiQuotaProvider({ logger: createLogger(), fetch: fetchApi }); + + const usage = await provider.fetchUsage(); + + expect(usage).toMatchObject({ + status: "available", + windows: [ + { + id: "coding_usage", + label: "Weekly limit", + usedPct: 61, + remainingPct: 39, + resetsAt: "2026-08-05T00:01:45Z", + tone: "ok", + }, + { + id: "coding_limit_300_time_unit_minute", + label: "5-hour limit", + usedPct: 100, + remainingPct: 0, + resetsAt: "2026-07-31T17:01:45Z", + tone: "danger", + }, + ], + }); + }); + + it("keeps valid windows when another limits entry is malformed", async () => { + process.env["KIMI_TOKEN"] = "kimi_test_token"; + const logger = createLogger() as unknown as { debug: ReturnType }; + const fetchApi = vi.fn(async () => + jsonResponse({ + usage: { + limit: "100", + remaining: "75", + resetTime: "2026-08-05T00:01:45Z", + }, + limits: [ + { window: { duration: "invalid" }, detail: {} }, + { + window: { duration: 300, timeUnit: "TIME_UNIT_MINUTE" }, + detail: { limit: "100", remaining: "50" }, + }, + ], + }), + ); + const provider = new KimiQuotaProvider({ logger: logger as never, fetch: fetchApi }); + + const usage = await provider.fetchUsage(); + + expect(usage.windows).toHaveLength(2); + expect(usage.windows[1]).toMatchObject({ + label: "5-hour limit", + usedPct: 50, + remainingPct: 50, + }); + expect(logger.debug).toHaveBeenCalledWith( + { index: 0 }, + "Ignoring malformed Kimi usage limit window", + ); + }); + + it("accepts direct limit fields, alternate reset keys, and provider labels", async () => { + process.env["KIMI_TOKEN"] = "kimi_test_token"; + const fetchApi = vi.fn(async () => + jsonResponse({ + usage: null, + limits: [ + { + name: "Burst quota", + limit: "80", + remaining: "20", + reset_at: "2026-08-01T00:00:00Z", + }, + ], + }), + ); + const provider = new KimiQuotaProvider({ logger: createLogger(), fetch: fetchApi }); + + const usage = await provider.fetchUsage(); + + expect(usage.windows).toEqual([ + expect.objectContaining({ + id: "coding_limit_burst_quota", + label: "Burst quota", + usedPct: 75, + remainingPct: 25, + resetsAt: "2026-08-01T00:00:00Z", + }), + ]); + }); + + it("keeps window ids unique when Kimi returns duplicate limit descriptors", async () => { + process.env["KIMI_TOKEN"] = "kimi_test_token"; + const duplicate = { + window: { duration: 300, timeUnit: "TIME_UNIT_MINUTE" }, + detail: { limit: "100", used: "10" }, + }; + const fetchApi = vi.fn(async () => jsonResponse({ limits: [duplicate, duplicate] })); + const provider = new KimiQuotaProvider({ logger: createLogger(), fetch: fetchApi }); + + const usage = await provider.fetchUsage(); + + expect(usage.windows.map((window) => window.id)).toEqual([ + "coding_limit_300_time_unit_minute", + "coding_limit_300_time_unit_minute_2", + ]); + }); +});