From d5a0712242680f35d0399a51574c1ecf411850eb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Mar 2026 07:51:38 +0000 Subject: [PATCH 1/4] feat(usage): add Kilo balance endpoint to provider usage tracking --- .../provider-usage.fetch.kilocode.test.ts | 83 +++++++++++++++++++ src/infra/provider-usage.fetch.kilocode.ts | 56 +++++++++++++ src/infra/provider-usage.fetch.ts | 1 + src/infra/provider-usage.load.ts | 3 + src/infra/provider-usage.shared.ts | 2 + src/infra/provider-usage.types.ts | 1 + 6 files changed, 146 insertions(+) create mode 100644 src/infra/provider-usage.fetch.kilocode.test.ts create mode 100644 src/infra/provider-usage.fetch.kilocode.ts diff --git a/src/infra/provider-usage.fetch.kilocode.test.ts b/src/infra/provider-usage.fetch.kilocode.test.ts new file mode 100644 index 00000000000..44b2cea6d15 --- /dev/null +++ b/src/infra/provider-usage.fetch.kilocode.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; +import { createProviderUsageFetch, makeResponse } from "../test-utils/provider-usage-fetch.js"; +import { fetchKilocodeUsage } from "./provider-usage.fetch.kilocode.js"; + +describe("fetchKilocodeUsage", () => { + it("returns HTTP error snapshot for failed requests", async () => { + const mockFetch = createProviderUsageFetch(async () => makeResponse(401, "unauthorized")); + const result = await fetchKilocodeUsage("key", 5000, mockFetch); + + expect(result.error).toBe("HTTP 401"); + expect(result.windows).toHaveLength(0); + }); + + it("returns HTTP error snapshot for server errors", async () => { + const mockFetch = createProviderUsageFetch(async () => makeResponse(503, "unavailable")); + const result = await fetchKilocodeUsage("key", 5000, mockFetch); + + expect(result.error).toBe("HTTP 503"); + expect(result.windows).toHaveLength(0); + }); + + it("returns balance as plan label when account is active", async () => { + const mockFetch = createProviderUsageFetch(async () => + makeResponse(200, { balance: 19.604203, isDepleted: false }), + ); + + const result = await fetchKilocodeUsage("key", 5000, mockFetch); + + expect(result.provider).toBe("kilocode"); + expect(result.displayName).toBe("Kilo"); + expect(result.plan).toBe("$19.60"); + expect(result.windows).toHaveLength(0); + expect(result.error).toBeUndefined(); + }); + + it("returns depleted error when isDepleted is true", async () => { + const mockFetch = createProviderUsageFetch(async () => + makeResponse(200, { balance: 0, isDepleted: true }), + ); + + const result = await fetchKilocodeUsage("key", 5000, mockFetch); + + expect(result.provider).toBe("kilocode"); + expect(result.plan).toBe("$0.00"); + expect(result.error).toBe("Depleted"); + expect(result.windows).toHaveLength(0); + }); + + it("handles missing balance field gracefully", async () => { + const mockFetch = createProviderUsageFetch(async () => + makeResponse(200, { isDepleted: false }), + ); + + const result = await fetchKilocodeUsage("key", 5000, mockFetch); + + expect(result.plan).toBeUndefined(); + expect(result.windows).toHaveLength(0); + expect(result.error).toBeUndefined(); + }); + + it("rounds balance to two decimal places", async () => { + const mockFetch = createProviderUsageFetch(async () => + makeResponse(200, { balance: 100, isDepleted: false }), + ); + + const result = await fetchKilocodeUsage("key", 5000, mockFetch); + expect(result.plan).toBe("$100.00"); + }); + + it("sends Authorization header with Bearer token", async () => { + let capturedInit: RequestInit | undefined; + const mockFetch = createProviderUsageFetch(async (_url, init) => { + capturedInit = init; + return makeResponse(200, { balance: 5.0, isDepleted: false }); + }); + + await fetchKilocodeUsage("test-api-key", 5000, mockFetch); + + expect((capturedInit?.headers as Record)?.["Authorization"]).toBe( + "Bearer test-api-key", + ); + }); +}); diff --git a/src/infra/provider-usage.fetch.kilocode.ts b/src/infra/provider-usage.fetch.kilocode.ts new file mode 100644 index 00000000000..bdb16b1b578 --- /dev/null +++ b/src/infra/provider-usage.fetch.kilocode.ts @@ -0,0 +1,56 @@ +import { buildUsageHttpErrorSnapshot, fetchJson } from "./provider-usage.fetch.shared.js"; +import { PROVIDER_LABELS } from "./provider-usage.shared.js"; +import type { ProviderUsageSnapshot } from "./provider-usage.types.js"; + +const KILOCODE_BALANCE_URL = "https://api.kilo.ai/api/profile/balance"; + +type KilocodeBalanceResponse = { + balance?: number; + isDepleted?: boolean; +}; + +export async function fetchKilocodeUsage( + apiKey: string, + timeoutMs: number, + fetchFn: typeof fetch, +): Promise { + const res = await fetchJson( + KILOCODE_BALANCE_URL, + { + method: "GET", + headers: { + Authorization: `Bearer ${apiKey}`, + Accept: "application/json", + }, + }, + timeoutMs, + fetchFn, + ); + + if (!res.ok) { + return buildUsageHttpErrorSnapshot({ provider: "kilocode", status: res.status }); + } + + const data = (await res.json()) as KilocodeBalanceResponse; + const balance = typeof data.balance === "number" ? data.balance : null; + // Show credit balance as a plan label since Kilo's endpoint returns a dollar + // amount rather than quota windows with usage percentages. + const plan = balance !== null ? `$${balance.toFixed(2)}` : undefined; + + if (data.isDepleted) { + return { + provider: "kilocode", + displayName: PROVIDER_LABELS.kilocode, + windows: [], + plan, + error: "Depleted", + }; + } + + return { + provider: "kilocode", + displayName: PROVIDER_LABELS.kilocode, + windows: [], + plan, + }; +} diff --git a/src/infra/provider-usage.fetch.ts b/src/infra/provider-usage.fetch.ts index 87f216eef24..e5c781aafc0 100644 --- a/src/infra/provider-usage.fetch.ts +++ b/src/infra/provider-usage.fetch.ts @@ -1,5 +1,6 @@ export { fetchClaudeUsage } from "./provider-usage.fetch.claude.js"; export { fetchCodexUsage } from "./provider-usage.fetch.codex.js"; export { fetchGeminiUsage } from "./provider-usage.fetch.gemini.js"; +export { fetchKilocodeUsage } from "./provider-usage.fetch.kilocode.js"; export { fetchMinimaxUsage } from "./provider-usage.fetch.minimax.js"; export { fetchZaiUsage } from "./provider-usage.fetch.zai.js"; diff --git a/src/infra/provider-usage.load.ts b/src/infra/provider-usage.load.ts index ec870aa27ee..ea6463bb816 100644 --- a/src/infra/provider-usage.load.ts +++ b/src/infra/provider-usage.load.ts @@ -6,6 +6,7 @@ import { fetchClaudeUsage, fetchCodexUsage, fetchGeminiUsage, + fetchKilocodeUsage, fetchMinimaxUsage, fetchZaiUsage, } from "./provider-usage.fetch.js"; @@ -95,6 +96,8 @@ async function fetchProviderUsageSnapshotFallback(params: { params.timeoutMs, params.fetchFn, ); + case "kilocode": + return await fetchKilocodeUsage(params.auth.token, params.timeoutMs, params.fetchFn); case "zai": return await fetchZaiUsage(params.auth.token, params.timeoutMs, params.fetchFn); case "minimax": diff --git a/src/infra/provider-usage.shared.ts b/src/infra/provider-usage.shared.ts index b801da4824c..690c5971c85 100644 --- a/src/infra/provider-usage.shared.ts +++ b/src/infra/provider-usage.shared.ts @@ -11,6 +11,7 @@ export const PROVIDER_LABELS: Record = { anthropic: "Claude", "github-copilot": "Copilot", "google-gemini-cli": "Gemini", + kilocode: "Kilo", minimax: "MiniMax", "openai-codex": "Codex", xiaomi: "Xiaomi", @@ -21,6 +22,7 @@ export const usageProviders: UsageProviderId[] = [ "anthropic", "github-copilot", "google-gemini-cli", + "kilocode", "minimax", "openai-codex", "xiaomi", diff --git a/src/infra/provider-usage.types.ts b/src/infra/provider-usage.types.ts index af5e2e93c8b..506f56c8ce0 100644 --- a/src/infra/provider-usage.types.ts +++ b/src/infra/provider-usage.types.ts @@ -21,6 +21,7 @@ export type UsageProviderId = | "anthropic" | "github-copilot" | "google-gemini-cli" + | "kilocode" | "minimax" | "openai-codex" | "xiaomi" From aa5a6dec1a62196f453fdd775ed8b513b5e175ef Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Mar 2026 08:09:42 +0000 Subject: [PATCH 2/4] fix(usage): guard res.json() against malformed responses in kilocode fetcher --- src/infra/provider-usage.fetch.kilocode.test.ts | 12 ++++++++++++ src/infra/provider-usage.fetch.kilocode.ts | 11 +++++++++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/infra/provider-usage.fetch.kilocode.test.ts b/src/infra/provider-usage.fetch.kilocode.test.ts index 44b2cea6d15..2db090b1854 100644 --- a/src/infra/provider-usage.fetch.kilocode.test.ts +++ b/src/infra/provider-usage.fetch.kilocode.test.ts @@ -67,6 +67,18 @@ describe("fetchKilocodeUsage", () => { expect(result.plan).toBe("$100.00"); }); + it("returns error snapshot for invalid JSON response", async () => { + const mockFetch = createProviderUsageFetch(async () => { + const res = new Response("not-json", { status: 200 }); + return res; + }); + + const result = await fetchKilocodeUsage("key", 5000, mockFetch); + + expect(result.error).toBe("Invalid JSON"); + expect(result.windows).toHaveLength(0); + }); + it("sends Authorization header with Bearer token", async () => { let capturedInit: RequestInit | undefined; const mockFetch = createProviderUsageFetch(async (_url, init) => { diff --git a/src/infra/provider-usage.fetch.kilocode.ts b/src/infra/provider-usage.fetch.kilocode.ts index bdb16b1b578..3fd196831bb 100644 --- a/src/infra/provider-usage.fetch.kilocode.ts +++ b/src/infra/provider-usage.fetch.kilocode.ts @@ -1,4 +1,8 @@ -import { buildUsageHttpErrorSnapshot, fetchJson } from "./provider-usage.fetch.shared.js"; +import { + buildUsageErrorSnapshot, + buildUsageHttpErrorSnapshot, + fetchJson, +} from "./provider-usage.fetch.shared.js"; import { PROVIDER_LABELS } from "./provider-usage.shared.js"; import type { ProviderUsageSnapshot } from "./provider-usage.types.js"; @@ -31,7 +35,10 @@ export async function fetchKilocodeUsage( return buildUsageHttpErrorSnapshot({ provider: "kilocode", status: res.status }); } - const data = (await res.json()) as KilocodeBalanceResponse; + const data = (await res.json().catch(() => null)) as KilocodeBalanceResponse | null; + if (!data) { + return buildUsageErrorSnapshot("kilocode", "Invalid JSON"); + } const balance = typeof data.balance === "number" ? data.balance : null; // Show credit balance as a plan label since Kilo's endpoint returns a dollar // amount rather than quota windows with usage percentages. From 6f745b86ec186244b7f811f92f3953be30c96650 Mon Sep 17 00:00:00 2001 From: Chris Kimpton Date: Wed, 18 Mar 2026 21:17:49 +0000 Subject: [PATCH 3/4] fix(usage): fold isDepleted into plan label instead of error field Depleted Kilo snapshots were returned with error: "Depleted", which caused formatUsageWindowSummary to silently drop the snapshot. Users with an exhausted Kilo balance saw no status line at all. Now the depletion is encoded in the plan label ("$0.00 (depleted)" or "depleted" if no balance present) so the snapshot is rendered normally. Co-Authored-By: Claude Sonnet 4.6 --- .../provider-usage.fetch.kilocode.test.ts | 16 +++++++++++--- src/infra/provider-usage.fetch.kilocode.ts | 21 ++++++++----------- 2 files changed, 22 insertions(+), 15 deletions(-) diff --git a/src/infra/provider-usage.fetch.kilocode.test.ts b/src/infra/provider-usage.fetch.kilocode.test.ts index 2db090b1854..bd1264fba89 100644 --- a/src/infra/provider-usage.fetch.kilocode.test.ts +++ b/src/infra/provider-usage.fetch.kilocode.test.ts @@ -33,7 +33,7 @@ describe("fetchKilocodeUsage", () => { expect(result.error).toBeUndefined(); }); - it("returns depleted error when isDepleted is true", async () => { + it("shows depleted label in plan when isDepleted is true", async () => { const mockFetch = createProviderUsageFetch(async () => makeResponse(200, { balance: 0, isDepleted: true }), ); @@ -41,8 +41,18 @@ describe("fetchKilocodeUsage", () => { const result = await fetchKilocodeUsage("key", 5000, mockFetch); expect(result.provider).toBe("kilocode"); - expect(result.plan).toBe("$0.00"); - expect(result.error).toBe("Depleted"); + expect(result.plan).toBe("$0.00 (depleted)"); + expect(result.error).toBeUndefined(); + expect(result.windows).toHaveLength(0); + }); + + it("shows depleted label without balance when isDepleted and no balance", async () => { + const mockFetch = createProviderUsageFetch(async () => makeResponse(200, { isDepleted: true })); + + const result = await fetchKilocodeUsage("key", 5000, mockFetch); + + expect(result.plan).toBe("depleted"); + expect(result.error).toBeUndefined(); expect(result.windows).toHaveLength(0); }); diff --git a/src/infra/provider-usage.fetch.kilocode.ts b/src/infra/provider-usage.fetch.kilocode.ts index 3fd196831bb..6fb541a1916 100644 --- a/src/infra/provider-usage.fetch.kilocode.ts +++ b/src/infra/provider-usage.fetch.kilocode.ts @@ -41,18 +41,15 @@ export async function fetchKilocodeUsage( } const balance = typeof data.balance === "number" ? data.balance : null; // Show credit balance as a plan label since Kilo's endpoint returns a dollar - // amount rather than quota windows with usage percentages. - const plan = balance !== null ? `$${balance.toFixed(2)}` : undefined; - - if (data.isDepleted) { - return { - provider: "kilocode", - displayName: PROVIDER_LABELS.kilocode, - windows: [], - plan, - error: "Depleted", - }; - } + // amount rather than quota windows with usage percentages. Fold the depleted + // flag into the label so status renderers display it rather than dropping the + // snapshot (formatUsageWindowSummary silently drops snapshots with error set). + const balanceLabel = balance !== null ? `$${balance.toFixed(2)}` : undefined; + const plan = data.isDepleted + ? balanceLabel + ? `${balanceLabel} (depleted)` + : "depleted" + : balanceLabel; return { provider: "kilocode", From 4127908b4d676be13606c2e81a82aaf292b4f874 Mon Sep 17 00:00:00 2001 From: Chris Kimpton Date: Thu, 19 Mar 2026 09:12:45 +0000 Subject: [PATCH 4/4] fix(usage): add kilocode fallback to resolveProviderUsageAuthFallback --- src/infra/provider-usage.auth.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/infra/provider-usage.auth.ts b/src/infra/provider-usage.auth.ts index c503779b6f5..3d9e30fa15f 100644 --- a/src/infra/provider-usage.auth.ts +++ b/src/infra/provider-usage.auth.ts @@ -220,6 +220,14 @@ async function resolveProviderUsageAuthFallback(params: { }); return apiKey ? { provider: "xiaomi", token: apiKey } : null; } + case "kilocode": { + const apiKey = resolveProviderApiKeyFromConfigAndStore({ + state: params.state, + providerIds: ["kilocode"], + envDirect: [params.state.env.KILOCODE_API_KEY], + }); + return apiKey ? { provider: "kilocode", token: apiKey } : null; + } default: return null; }