Merge 4127908b4d676be13606c2e81a82aaf292b4f874 into 6b4c24c2e55b5b4013277bd799525086f6a0c40f

This commit is contained in:
Chris Kimpton 2026-03-20 21:45:00 -07:00 committed by GitHub
commit 4510c3dfae
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 180 additions and 0 deletions

View File

@ -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;
}

View File

@ -0,0 +1,105 @@
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("shows depleted label in plan 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 (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);
});
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("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) => {
capturedInit = init;
return makeResponse(200, { balance: 5.0, isDepleted: false });
});
await fetchKilocodeUsage("test-api-key", 5000, mockFetch);
expect((capturedInit?.headers as Record<string, string>)?.["Authorization"]).toBe(
"Bearer test-api-key",
);
});
});

View File

@ -0,0 +1,60 @@
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";
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<ProviderUsageSnapshot> {
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().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. 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",
displayName: PROVIDER_LABELS.kilocode,
windows: [],
plan,
};
}

View File

@ -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";

View File

@ -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":

View File

@ -11,6 +11,7 @@ export const PROVIDER_LABELS: Record<UsageProviderId, string> = {
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",

View File

@ -21,6 +21,7 @@ export type UsageProviderId =
| "anthropic"
| "github-copilot"
| "google-gemini-cli"
| "kilocode"
| "minimax"
| "openai-codex"
| "xiaomi"