diff --git a/packages/plugin/src/server/usage.ts b/packages/plugin/src/server/usage.ts index afc87f312e..26dd22763a 100644 --- a/packages/plugin/src/server/usage.ts +++ b/packages/plugin/src/server/usage.ts @@ -1,4 +1,4 @@ -import { z, type ZodType } from "zod"; +import type { ZodType } from "zod"; import type { JsonValue } from "@getpaseo/protocol/agent-types"; export interface UsageWindow { @@ -108,29 +108,6 @@ export function usedPctOf( return (used / limit) * 100; } -export const ApiNumberSchema = z.coerce.number().finite(); -export const ApiNullableNumberSchema = z.preprocess( - (value) => (value == null ? null : value), - ApiNumberSchema.nullable(), -); -export const ApiOptionalStringSchema = z.preprocess( - (value) => (value == null ? undefined : value), - z.coerce.string().optional(), -); - -export type UsageApiFetch = typeof fetch; - -export function fetchProviderApi( - fetchApi: UsageApiFetch, - input: RequestInfo | URL, - init: RequestInit = {}, -): Promise { - return fetchApi(input, { - ...init, - signal: init.signal ?? AbortSignal.timeout(15_000), - }); -} - export function unavailableUsage(accountKey = "default"): UsageReport { return { account: { key: accountKey }, @@ -140,8 +117,3 @@ export function unavailableUsage(accountKey = "default"): UsageReport { details: [], }; } - -export function toIsoStringOrNull(timestampMs: number): string | null { - const date = new Date(timestampMs); - return Number.isFinite(date.getTime()) ? date.toISOString() : null; -} diff --git a/packages/server/src/plugins/claude-usage-source/index.server.ts b/packages/server/src/plugins/claude-usage-source/index.server.ts index b989c30260..636f9d286b 100644 --- a/packages/server/src/plugins/claude-usage-source/index.server.ts +++ b/packages/server/src/plugins/claude-usage-source/index.server.ts @@ -1,6 +1,6 @@ import type { PluginServerContext } from "@getpaseo/plugin/server"; import { inputSchema } from "./shared/input.js"; -import { ClaudeQuotaProvider } from "./server/usage.js"; +import { fetchUsage } from "./server/usage.js"; export default function contribute(server: PluginServerContext) { server.registerUsageSource({ @@ -9,14 +9,7 @@ export default function contribute(server: PluginServerContext) { icon: "icon.svg", input: inputSchema, discover: async () => [{}], - fetch: async (input) => { - const parsed = inputSchema.parse(input); - return new ClaudeQuotaProvider({ - logger: console, - ...("configDir" in parsed ? { configDir: parsed.configDir } : {}), - ...("accessToken" in parsed ? { accessToken: parsed.accessToken } : {}), - }).fetchUsage(); - }, + fetch: fetchUsage, }); return () => {}; } diff --git a/packages/server/src/plugins/claude-usage-source/paseo-plugin.json b/packages/server/src/plugins/claude-usage-source/paseo-plugin.json index f4e639a515..1e2c89dca0 100644 --- a/packages/server/src/plugins/claude-usage-source/paseo-plugin.json +++ b/packages/server/src/plugins/claude-usage-source/paseo-plugin.json @@ -1 +1 @@ -{ "id": "claude-usage-source", "requirements": { "paseo": ">=0.9.2" } } +{ "id": "claude-usage-source" } diff --git a/packages/server/src/plugins/claude-usage-source/server/keychain.test.ts b/packages/server/src/plugins/claude-usage-source/server/keychain.test.ts new file mode 100644 index 0000000000..b4d5295185 --- /dev/null +++ b/packages/server/src/plugins/claude-usage-source/server/keychain.test.ts @@ -0,0 +1,164 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { claudeKeychainAccount, readClaudeKeychainCredentials } from "./usage.js"; + +const SERVICE = "Claude Code-credentials"; + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("claudeKeychainAccount", () => { + it("uses a supported username verbatim", () => { + expect(claudeKeychainAccount("thomas.benoit")).toBe("thomas.benoit"); + }); + + it("falls back to claude-code-user when the username carries a rejected character", () => { + expect(claudeKeychainAccount("first.last@example.com")).toBe("claude-code-user"); + }); + + it("reads the username from $USER when no account is given", () => { + vi.stubEnv("USER", "ci-runner"); + expect(claudeKeychainAccount()).toBe("ci-runner"); + + vi.stubEnv("USER", "first.last@example.com"); + expect(claudeKeychainAccount()).toBe("claude-code-user"); + }); +}); + +describe("readClaudeKeychainCredentials", () => { + it("looks the item up by account and stops there when it exists", async () => { + const run = vi.fn(async () => JSON.stringify({ claudeAiOauth: { accessToken: "at_fresh" } })); + + const credentials = await readClaudeKeychainCredentials(run, "claude-code-user"); + + expect(credentials).toEqual({ claudeAiOauth: { accessToken: "at_fresh" } }); + expect(run).toHaveBeenCalledTimes(1); + expect(run).toHaveBeenCalledWith([ + "find-generic-password", + "-a", + "claude-code-user", + "-w", + "-s", + SERVICE, + ]); + }); + + it("falls back to the account-less lookup when no item matches the account", async () => { + const run = vi.fn(async (args: string[]) => + args.includes("-a") ? null : JSON.stringify({ claudeAiOauth: { accessToken: "at_legacy" } }), + ); + + const credentials = await readClaudeKeychainCredentials(run, "claude-code-user"); + + expect(credentials).toEqual({ claudeAiOauth: { accessToken: "at_legacy" } }); + expect(run).toHaveBeenNthCalledWith(2, ["find-generic-password", "-w", "-s", SERVICE]); + }); + + it("falls through when the account item carries no access token", async () => { + const run = vi.fn(async (args: string[]) => + args.includes("-a") + ? JSON.stringify({ claudeAiOauth: { subscriptionType: "team" } }) + : JSON.stringify({ claudeAiOauth: { accessToken: "at_legacy" } }), + ); + + const credentials = await readClaudeKeychainCredentials(run, "claude-code-user"); + + expect(credentials).toEqual({ claudeAiOauth: { accessToken: "at_legacy" } }); + expect(run).toHaveBeenCalledTimes(2); + }); + + it("is null when the Keychain holds no item at all", async () => { + const run = vi.fn(async () => null); + + expect(await readClaudeKeychainCredentials(run, "claude-code-user")).toBeNull(); + }); + + it("tries the next lookup when the item does not parse", async () => { + const run = vi.fn(async (args: string[]) => + args.includes("-a") + ? "not-json" + : JSON.stringify({ claudeAiOauth: { accessToken: "at_legacy" } }), + ); + + expect(await readClaudeKeychainCredentials(run, "claude-code-user")).toEqual({ + claudeAiOauth: { accessToken: "at_legacy" }, + }); + }); +}); + +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fetchUsage } from "./usage.js"; + +function fixtureFetch(expectedToken: string): typeof fetch { + return vi.fn(async (_url: RequestInfo | URL, init?: RequestInit) => { + expect(init?.headers).toMatchObject({ Authorization: `Bearer ${expectedToken}` }); + return new Response(JSON.stringify({ five_hour: { utilization: 12 } }), { status: 200 }); + }) as unknown as typeof fetch; +} + +describe("Claude usage input forms", () => { + it("reads only the selected configDir file", async () => { + const dir = mkdtempSync(join(tmpdir(), "claude-usage-input-")); + try { + writeFileSync( + join(dir, ".credentials.json"), + JSON.stringify({ claudeAiOauth: { accessToken: "fixture-file" } }), + ); + expect((await fetchUsage({ configDir: dir }, fixtureFetch("fixture-file"))).status).toBe( + "available", + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it("uses an accessToken without reading a file", async () => { + expect( + (await fetchUsage({ accessToken: "fixture-direct" }, fixtureFetch("fixture-direct"))).status, + ).toBe("available"); + }); + it("default discovery reads the credential file", async () => { + const dir = mkdtempSync(join(tmpdir(), "claude-usage-default-")); + try { + writeFileSync( + join(dir, ".credentials.json"), + JSON.stringify({ claudeAiOauth: { accessToken: "fixture-default" } }), + ); + vi.stubEnv("CLAUDE_HOME", dir); + expect((await fetchUsage({}, fixtureFetch("fixture-default"))).status).toBe("available"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it("stays unavailable when non-default configDir has no credentials", async () => { + const dir = mkdtempSync(join(tmpdir(), "claude-empty-config-")); + try { + const fetchApi = vi.fn() as unknown as typeof fetch; + expect((await fetchUsage({ configDir: dir }, fetchApi)).status).toBe("unavailable"); + expect(fetchApi).not.toHaveBeenCalled(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +describe("Claude Keychain token usage", () => { + it("returns unavailable on 401 without attempting refresh", async () => { + const run = vi.fn(async () => + JSON.stringify({ + claudeAiOauth: { accessToken: "fixture-expired", refreshToken: "fixture-refresh" }, + }), + ); + const credentials = await readClaudeKeychainCredentials(run, "fixture-account"); + const token = (credentials as { claudeAiOauth: { accessToken: string } }).claudeAiOauth + .accessToken; + const fetchApi = vi.fn( + async () => new Response(null, { status: 401 }), + ) as unknown as typeof fetch; + const report = await fetchUsage({ accessToken: token }, fetchApi); + expect(report.status).toBe("unavailable"); + expect(fetchApi).toHaveBeenCalledTimes(1); + expect(run).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/server/src/plugins/claude-usage-source/server/usage.test.ts b/packages/server/src/plugins/claude-usage-source/server/usage.test.ts index 6b74a7fe88..34b561ff92 100644 --- a/packages/server/src/plugins/claude-usage-source/server/usage.test.ts +++ b/packages/server/src/plugins/claude-usage-source/server/usage.test.ts @@ -1,194 +1,601 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { claudeKeychainAccount, readClaudeKeychainCredentials } from "./usage.js"; +import { readFileSync, writeFileSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { fetchUsage } from "./usage.js"; +import type { UsageReport } from "@getpaseo/plugin/server/usage"; -const SERVICE = "Claude Code-credentials"; +function writeClaudeCredentials( + dir: string, + accessToken: string, + refreshToken = "rt_test", + subscriptionType = "pro", + rateLimitTier = "default_1x", +): void { + writeFileSync( + join(dir, ".credentials.json"), + JSON.stringify({ + claudeAiOauth: { accessToken, refreshToken, subscriptionType, rateLimitTier }, + }), + ); +} -afterEach(() => { - vi.unstubAllEnvs(); -}); +function makeClaudeResponse( + overrides: Partial<{ + five_hour: { utilization: number | string; resets_at: string }; + seven_day: { utilization: number | string; resets_at: string }; + seven_day_opus: { utilization: number | string; resets_at: string }; + }> = {}, +) { + return { + five_hour: { utilization: 11, resets_at: "2026-06-01T21:00:00Z" }, + seven_day: { utilization: 1, resets_at: "2026-06-04T00:00:00Z" }, + seven_day_opus: { utilization: 0.5, resets_at: "2026-06-04T00:00:00Z" }, + ...overrides, + }; +} -describe("claudeKeychainAccount", () => { - it("uses a supported username verbatim", () => { - expect(claudeKeychainAccount("thomas.benoit")).toBe("thomas.benoit"); +function mockFetch(handlers: Map Response>): typeof fetch { + return vi.fn(async (url: RequestInfo | URL) => { + const key = url.toString(); + const handler = handlers.get(key); + if (!handler) throw new Error(`Unmocked fetch: ${key}`); + return handler(); + }) as unknown as typeof fetch; +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +describe("claude usage source", () => { + let claudeHome: string; + let homeDir: string; + let fetchApi: typeof fetch; + let originalEnv: Record; + beforeEach(() => { + claudeHome = mkdtempSync(join(tmpdir(), "usage-claude-")); + homeDir = mkdtempSync(join(tmpdir(), "usage-home-")); + originalEnv = { ...process.env }; + process.env["HOME"] = homeDir; + process.env["USERPROFILE"] = homeDir; + process.env["CLAUDE_HOME"] = claudeHome; + for (const key of [ + "APPDATA", + "COPILOT_TOKEN", + "GITHUB_TOKEN", + "GITHUB_PAT", + "CURSOR_ACCESS_TOKEN", + "CURSOR_TOKEN", + "ZAI_API_KEY", + "GLM_API_KEY", + "GROK_API_KEY", + "GROK_TOKEN", + "KIMI_TOKEN", + "KIMI_API_KEY", + "KIMI_CODE_HOME", + "MINIMAX_API_KEY", + "MINIMAX_BASE_URL", + ]) + delete process.env[key]; + fetchApi = mockFetch(new Map()); + }); + afterEach(() => { + rmSync(claudeHome, { recursive: true, force: true }); + rmSync(homeDir, { recursive: true, force: true }); + for (const key of Object.keys(process.env)) if (!(key in originalEnv)) delete process.env[key]; + for (const key in originalEnv) process.env[key] = originalEnv[key]; + }); + function service( + _options: { + platform?: typeof process.platform; + keychain?: () => Promise; + cursorHomeDir?: string; + kimiHomeDir?: string; + } = {}, + ) { + return { + listUsage: async () => { + const report = await fetchUsage({}, (url, init) => fetchApi(url, init)); + return { + providers: [ + { + providerId: "claude", + ...report, + error: report.error ?? null, + planLabel: report.planLabel ?? null, + }, + ], + }; + }, + }; + } + function findProvider( + result: { providers: Array<{ providerId: string } & UsageReport> }, + id: string, + ) { + const report = result.providers.find((item) => item.providerId === id); + if (!report) throw new Error(`Missing usage source ${id}`); + return report; + } + it("fetches Claude usage, coerces API numbers, and attaches HTTP timeout signals", async () => { + writeClaudeCredentials(claudeHome, "at_valid"); + fetchApi = mockFetch( + new Map([ + [ + "https://api.anthropic.com/api/oauth/usage", + () => + jsonResponse( + makeClaudeResponse({ + five_hour: { utilization: "11", resets_at: "2026-06-01T21:00:00Z" }, + }), + ), + ], + ]), + ); + + const result = await service().listUsage(); + const claude = findProvider(result, "claude"); + + expect(claude).toMatchObject({ + status: "available", + planLabel: "Pro 1x", + windows: expect.arrayContaining([ + expect.objectContaining({ id: "five_hour", usedPct: 11 }), + expect.objectContaining({ id: "weekly", usedPct: 1 }), + expect.objectContaining({ id: "weekly_model_opus", usedPct: 0.5 }), + ]), + }); + expect(fetchApi).toHaveBeenCalledWith( + "https://api.anthropic.com/api/oauth/usage", + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); }); - it("falls back to claude-code-user when the username carries a rejected character", () => { - expect(claudeKeychainAccount("first.last@example.com")).toBe("claude-code-user"); + it("accepts a null Claude resets_at when a window has no scheduled reset", async () => { + writeClaudeCredentials(claudeHome, "at_valid"); + fetchApi = mockFetch( + new Map([ + [ + "https://api.anthropic.com/api/oauth/usage", + () => + jsonResponse({ + five_hour: { utilization: 0, resets_at: null }, + seven_day: { utilization: 1, resets_at: "2026-06-04T00:00:00Z" }, + }), + ], + ]), + ); + + const result = await service().listUsage(); + const claude = findProvider(result, "claude"); + + expect(claude).toMatchObject({ + status: "available", + windows: expect.arrayContaining([ + expect.objectContaining({ id: "five_hour", usedPct: 0, resetsAt: null }), + expect.objectContaining({ id: "weekly", usedPct: 1 }), + ]), + }); }); - it("reads the username from $USER when no account is given", () => { - vi.stubEnv("USER", "ci-runner"); - expect(claudeKeychainAccount()).toBe("ci-runner"); + it("returns unavailable Claude usage when credentials are missing", async () => { + fetchApi = vi.fn() as never; - vi.stubEnv("USER", "first.last@example.com"); - expect(claudeKeychainAccount()).toBe("claude-code-user"); + const result = await service().listUsage(); + const claude = findProvider(result, "claude"); + + expect(claude.status).toBe("unavailable"); + expect(fetchApi).not.toHaveBeenCalled(); + }); + + it("returns unavailable on 401 without refreshing or rewriting credentials", async () => { + writeClaudeCredentials(claudeHome, "at_expired", "rt_valid"); + const credPath = join(claudeHome, ".credentials.json"); + const before = readFileSync(credPath, "utf8"); + let usageCalls = 0; + fetchApi = vi.fn(async (url: RequestInfo | URL) => { + const endpoint = url.toString(); + if (endpoint === "https://api.anthropic.com/api/oauth/usage") { + usageCalls += 1; + return new Response(null, { status: 401 }); + } + // The read-only fetcher must never hit the OAuth token endpoint. + throw new Error(`Unmocked: ${endpoint}`); + }) as never; + + const result = await service().listUsage(); + + expect(findProvider(result, "claude").status).toBe("unavailable"); + expect(usageCalls).toBe(1); + // The credentials file must be left untouched for the Claude CLI to own. + expect(readFileSync(credPath, "utf8")).toBe(before); }); }); -describe("readClaudeKeychainCredentials", () => { - it("looks the item up by account and stops there when it exists", async () => { - const run = vi.fn(async () => JSON.stringify({ claudeAiOauth: { accessToken: "at_fresh" } })); +describe("usage bars escalate as they fill", () => { + let claudeHome: string; - const credentials = await readClaudeKeychainCredentials(run, "claude-code-user"); + beforeEach(() => { + claudeHome = mkdtempSync(join(tmpdir(), "paseo-tone-claude-")); + }); - expect(credentials).toEqual({ claudeAiOauth: { accessToken: "at_fresh" } }); - expect(run).toHaveBeenCalledTimes(1); - expect(run).toHaveBeenCalledWith([ - "find-generic-password", - "-a", - "claude-code-user", - "-w", - "-s", - SERVICE, + afterEach(() => { + rmSync(claudeHome, { recursive: true, force: true }); + }); + + function claudeAt(utilization: number) { + writeClaudeCredentials(claudeHome, "at_valid"); + process.env["CLAUDE_HOME"] = claudeHome; + return fetchUsage( + {}, + mockFetch( + new Map([ + [ + "https://api.anthropic.com/api/oauth/usage", + () => jsonResponse({ seven_day: { utilization, resets_at: "2026-06-04T00:00:00Z" } }), + ], + ]), + ), + ); + } + + it.each([ + [10, "ok"], + [75, "warning"], + [99, "danger"], + ])("a Claude window at %s%% is %s", async (utilization, tone) => { + const usage = await claudeAt(utilization); + expect(usage.windows).toEqual([expect.objectContaining({ id: "weekly", tone })]); + }); +}); + +describe("Claude usage source scoped weekly limits", () => { + beforeEach(() => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + }); + afterEach(() => { + vi.restoreAllMocks(); + }); + let claudeHome: string; + + beforeEach(() => { + claudeHome = mkdtempSync(join(tmpdir(), "paseo-claude-limits-")); + }); + + afterEach(() => { + rmSync(claudeHome, { recursive: true, force: true }); + }); + + function fableLimit(overrides: Record = {}) { + return { + kind: "weekly_scoped", + group: "weekly", + percent: 0, + severity: "normal", + resets_at: "2026-06-04T00:00:00Z", + is_active: false, + scope: { model: { id: null, display_name: "Fable" }, surface: null }, + ...overrides, + }; + } + + function claudeProvider(body: unknown) { + writeClaudeCredentials(claudeHome, "at_valid"); + process.env["CLAUDE_HOME"] = claudeHome; + const fetchApi = mockFetch( + new Map([["https://api.anthropic.com/api/oauth/usage", () => jsonResponse(body)]]), + ); + return { provider: { fetchUsage: () => fetchUsage({}, fetchApi) } }; + } + + it("renders a scoped weekly limit as its own window", async () => { + const { provider } = claudeProvider({ + five_hour: { utilization: 6, resets_at: "2026-06-01T21:00:00Z" }, + seven_day: { utilization: 23, resets_at: "2026-06-04T00:00:00Z" }, + limits: [fableLimit()], + }); + + const usage = await provider.fetchUsage(); + + expect(usage.windows).toContainEqual( + expect.objectContaining({ id: "weekly_model_fable", label: "Weekly · Fable" }), + ); + }); + + it("renders a scoped window that is at zero and inactive", async () => { + const { provider } = claudeProvider({ + seven_day: { utilization: 23, resets_at: "2026-06-04T00:00:00Z" }, + limits: [fableLimit({ percent: 0, is_active: false })], + }); + + const usage = await provider.fetchUsage(); + + expect(usage.windows).toContainEqual( + expect.objectContaining({ id: "weekly_model_fable", usedPct: 0, remainingPct: 100 }), + ); + }); + + it("ignores session and all-models entries so they do not duplicate the top-level windows", async () => { + const { provider } = claudeProvider({ + five_hour: { utilization: 6, resets_at: "2026-06-01T21:00:00Z" }, + seven_day: { utilization: 23, resets_at: "2026-06-04T00:00:00Z" }, + limits: [ + { kind: "session", percent: 6, resets_at: "2026-06-01T21:00:00Z", scope: null }, + { kind: "weekly_all", percent: 23, resets_at: "2026-06-04T00:00:00Z", scope: null }, + fableLimit(), + ], + }); + + const usage = await provider.fetchUsage(); + + expect(usage.windows.map((window) => window.id)).toEqual([ + "five_hour", + "weekly", + "weekly_model_fable", ]); }); - it("falls back to the account-less lookup when no item matches the account", async () => { - const run = vi.fn(async (args: string[]) => - args.includes("-a") ? null : JSON.stringify({ claudeAiOauth: { accessToken: "at_legacy" } }), - ); - - const credentials = await readClaudeKeychainCredentials(run, "claude-code-user"); - - expect(credentials).toEqual({ claudeAiOauth: { accessToken: "at_legacy" } }); - expect(run).toHaveBeenNthCalledWith(2, ["find-generic-password", "-w", "-s", SERVICE]); - }); - - it("falls through when the account item carries no access token", async () => { - const run = vi.fn(async (args: string[]) => - args.includes("-a") - ? JSON.stringify({ claudeAiOauth: { subscriptionType: "team" } }) - : JSON.stringify({ claudeAiOauth: { accessToken: "at_legacy" } }), - ); - - const credentials = await readClaudeKeychainCredentials(run, "claude-code-user"); - - expect(credentials).toEqual({ claudeAiOauth: { accessToken: "at_legacy" } }); - expect(run).toHaveBeenCalledTimes(2); - }); - - it("is null when the Keychain holds no item at all", async () => { - const run = vi.fn(async () => null); - - expect(await readClaudeKeychainCredentials(run, "claude-code-user")).toBeNull(); - }); - - it("tries the next lookup when the item does not parse", async () => { - const run = vi.fn(async (args: string[]) => - args.includes("-a") - ? "not-json" - : JSON.stringify({ claudeAiOauth: { accessToken: "at_legacy" } }), - ); - - expect(await readClaudeKeychainCredentials(run, "claude-code-user")).toEqual({ - claudeAiOauth: { accessToken: "at_legacy" }, + it("labels a surface-scoped limit from its surface name", async () => { + const { provider } = claudeProvider({ + seven_day: { utilization: 23, resets_at: "2026-06-04T00:00:00Z" }, + limits: [ + fableLimit({ scope: { model: null, surface: { id: "code", display_name: "Code" } } }), + ], }); - }); -}); -describe("Claude usage input forms", () => { - it("reads only the selected configDir file", async () => { - const { mkdtempSync, writeFileSync, rmSync } = await import("node:fs"); - const { tmpdir } = await import("node:os"); - const { join } = await import("node:path"); - const { ClaudeQuotaProvider } = await import("./usage.js"); - const dir = mkdtempSync(join(tmpdir(), "claude-usage-input-")); - try { - writeFileSync( - join(dir, ".credentials.json"), - JSON.stringify({ claudeAiOauth: { accessToken: "fixture-file" } }), - ); - const keychain = vi.fn(async () => ({ claudeAiOauth: { accessToken: "fixture-keychain" } })); - const fetchApi = vi.fn(async (_url: unknown, init: RequestInit) => { - expect(init.headers).toMatchObject({ Authorization: "Bearer fixture-file" }); - return new Response(JSON.stringify({ five_hour: { utilization: 12 } }), { status: 200 }); - }); - const report = await new ClaudeQuotaProvider({ - logger: console, - configDir: dir, - platform: "darwin", - claudeKeychainReader: keychain, - fetch: fetchApi as never, - }).fetchUsage(); - expect(report.status).toBe("available"); - expect(keychain).not.toHaveBeenCalled(); - } finally { - rmSync(dir, { recursive: true, force: true }); - } + const usage = await provider.fetchUsage(); + + expect(usage.windows).toContainEqual( + expect.objectContaining({ id: "weekly_surface_code", label: "Weekly · Code" }), + ); }); - it("uses an accessToken without reading a file or keychain", async () => { - const { ClaudeQuotaProvider } = await import("./usage.js"); - const keychain = vi.fn(async () => null); - const fetchApi = vi.fn(async (_url: unknown, init: RequestInit) => { - expect(init.headers).toMatchObject({ Authorization: "Bearer fixture-direct" }); - return new Response(JSON.stringify({ seven_day: { utilization: 25 } }), { status: 200 }); + it("skips a scoped limit with no resolvable label rather than rendering an unlabelled bar", async () => { + const { provider } = claudeProvider({ + seven_day: { utilization: 23, resets_at: "2026-06-04T00:00:00Z" }, + limits: [fableLimit({ scope: { model: { id: null, display_name: null }, surface: null } })], }); - const report = await new ClaudeQuotaProvider({ - logger: console, - accessToken: "fixture-direct", - configDir: "/nonexistent", - platform: "darwin", - claudeKeychainReader: keychain, - fetch: fetchApi as never, - }).fetchUsage(); - expect(report.status).toBe("available"); - expect(keychain).not.toHaveBeenCalled(); + + const usage = await provider.fetchUsage(); + + expect(usage.windows.map((window) => window.id)).toEqual(["weekly"]); + expect(console.warn).toHaveBeenCalled(); }); - it("default discovery reads the credential file before keychain", async () => { - const { mkdtempSync, writeFileSync, rmSync } = await import("node:fs"); - const { tmpdir } = await import("node:os"); - const { join } = await import("node:path"); - const { ClaudeQuotaProvider } = await import("./usage.js"); - const dir = mkdtempSync(join(tmpdir(), "claude-usage-default-")); - try { - writeFileSync( - join(dir, ".credentials.json"), - JSON.stringify({ claudeAiOauth: { accessToken: "fixture-default" } }), - ); - const keychain = vi.fn(async () => ({ claudeAiOauth: { accessToken: "fixture-keychain" } })); - const fetchApi = vi.fn(async (_url: unknown, init: RequestInit) => { - expect(init.headers).toMatchObject({ Authorization: "Bearer fixture-default" }); - return new Response(JSON.stringify({ five_hour: { utilization: 7 } }), { status: 200 }); - }); - const report = await new ClaudeQuotaProvider({ - logger: console, - claudeHome: dir, - platform: "darwin", - claudeKeychainReader: keychain, - fetch: fetchApi as never, - }).fetchUsage(); - expect(report.status).toBe("available"); - expect(keychain).not.toHaveBeenCalled(); - } finally { - rmSync(dir, { recursive: true, force: true }); - } - }); -}); + // Regression: an additive section must never take down data that already parsed. + it("keeps the top-level windows when a limits entry is malformed", async () => { + const { provider } = claudeProvider({ + five_hour: { utilization: 6, resets_at: "2026-06-01T21:00:00Z" }, + seven_day: { utilization: 23, resets_at: "2026-06-04T00:00:00Z" }, + limits: [{ percent: "not-a-kind" }, fableLimit()], + }); -describe("Claude non-default config directory", () => { - it("stays unavailable when its credential file is missing without probing keychain", async () => { - const { mkdtempSync, rmSync } = await import("node:fs"); - const { tmpdir } = await import("node:os"); - const { join } = await import("node:path"); - const { ClaudeQuotaProvider } = await import("./usage.js"); - const dir = mkdtempSync(join(tmpdir(), "claude-empty-config-")); - try { - const keychain = vi.fn(async () => ({ claudeAiOauth: { accessToken: "fixture-keychain" } })); - const fetchApi = vi.fn(); - const report = await new ClaudeQuotaProvider({ - logger: console, - configDir: dir, - platform: "darwin", - claudeKeychainReader: keychain, - fetch: fetchApi as never, - }).fetchUsage(); - expect(report.status).toBe("unavailable"); - expect(keychain).not.toHaveBeenCalled(); - expect(fetchApi).not.toHaveBeenCalled(); - } finally { - rmSync(dir, { recursive: true, force: true }); - } + const usage = await provider.fetchUsage(); + + expect(usage.status).toBe("available"); + expect(usage.windows.map((window) => window.id)).toEqual([ + "five_hour", + "weekly", + "weekly_model_fable", + ]); + expect(console.warn).toHaveBeenCalled(); + }); + + it("warns when a successful response describes no windows at all", async () => { + const { provider } = claudeProvider({ limits: [] }); + + const usage = await provider.fetchUsage(); + + expect(usage.windows).toEqual([]); + expect(console.warn).toHaveBeenCalledWith( + "Claude usage response parsed but produced no windows", + ); + }); +}); +/** + * The reconciliation matrix. + * + * Four review rounds on PR #2303 each found a different hole in how the two + * representations of one scoped limit get combined, because each fix was tested against + * the case that was reported rather than the space of cases. This walks the space: + * every combination of which representation carries the limit, whether the `limits[]` + * entry supplies values, and whether the two descriptions denote the same limit at all. + */ + +describe("Claude usage source scoped limit reconciliation", () => { + let claudeHome: string; + + beforeEach(() => { + claudeHome = mkdtempSync(join(tmpdir(), "paseo-claude-matrix-")); + }); + + afterEach(() => { + rmSync(claudeHome, { recursive: true, force: true }); + }); + + const RESETS = "2026-06-04T00:00:00Z"; + + function scoped(scope: unknown, percent: number | null = 30, resetsAt: string | null = RESETS) { + return { kind: "weekly_scoped", percent, resets_at: resetsAt, scope }; + } + + const model = (name: string | null, id: string | null = null) => ({ + model: { id, display_name: name }, + surface: null, + }); + const surface = (name: string | null, id: string | null = null) => ({ + model: null, + surface: { id, display_name: name }, + }); + + async function windowsFor(body: Record) { + writeClaudeCredentials(claudeHome, "at_valid"); + process.env["CLAUDE_HOME"] = claudeHome; + const usage = await fetchUsage( + {}, + mockFetch(new Map([["https://api.anthropic.com/api/oauth/usage", () => jsonResponse(body)]])), + ); + return usage.windows; + } + + it("which representation carries the limit: legacy only", async () => { + const windows = await windowsFor({ + seven_day_omelette: { utilization: 12, resets_at: RESETS }, + }); + expect(windows).toEqual([ + expect.objectContaining({ + id: "weekly_model_omelette", + label: "Weekly · Omelette", + usedPct: 12, + }), + ]); + }); + + it("which representation carries the limit: limits[] only", async () => { + const windows = await windowsFor({ limits: [scoped(model("Fable"), 2)] }); + expect(windows).toEqual([ + expect.objectContaining({ + id: "weekly_model_fable", + label: "Weekly · Fable", + usedPct: 2, + }), + ]); + }); + + it("which representation carries the limit: both, same limit — one bar, scoped identity", async () => { + const windows = await windowsFor({ + seven_day_omelette: { utilization: 12, resets_at: RESETS }, + limits: [scoped(model("Omelette"), 30)], + }); + expect(windows).toEqual([ + expect.objectContaining({ id: "weekly_model_omelette", usedPct: 30 }), + ]); + }); + + it("which representation carries the limit: both, different limits — two bars", async () => { + const windows = await windowsFor({ + seven_day_opus: { utilization: 8, resets_at: RESETS }, + limits: [scoped(model("Fable"), 2)], + }); + expect(windows.map((w) => w.id)).toEqual(["weekly_model_opus", "weekly_model_fable"]); + }); + + it("which representation carries the limit: neither", async () => { + const windows = await windowsFor({ seven_day: { utilization: 23, resets_at: RESETS } }); + expect(windows.map((w) => w.id)).toEqual(["weekly"]); + }); + + const legacy = { seven_day_omelette: { utilization: 12, resets_at: RESETS } }; + + it("value fallback when the scoped entry is sparse: scoped values win when present", async () => { + const windows = await windowsFor({ + ...legacy, + limits: [scoped(model("Omelette"), 30, "2026-06-09T00:00:00Z")], + }); + expect(windows[0]).toMatchObject({ usedPct: 30, resetsAt: "2026-06-09T00:00:00Z" }); + }); + + it("value fallback when the scoped entry is sparse: percentage falls back per field", async () => { + const windows = await windowsFor({ + ...legacy, + limits: [scoped(model("Omelette"), null, "2026-06-09T00:00:00Z")], + }); + expect(windows[0]).toMatchObject({ usedPct: 12, resetsAt: "2026-06-09T00:00:00Z" }); + }); + + it("value fallback when the scoped entry is sparse: reset time falls back per field", async () => { + const windows = await windowsFor({ + ...legacy, + limits: [scoped(model("Omelette"), 30, null)], + }); + expect(windows[0]).toMatchObject({ usedPct: 30, resetsAt: RESETS }); + }); + + it("value fallback when the scoped entry is sparse: both fall back when the scoped entry only names the limit", async () => { + const windows = await windowsFor({ + ...legacy, + limits: [scoped(model("Omelette"), null, null)], + }); + expect(windows[0]).toMatchObject({ usedPct: 12, resetsAt: RESETS }); + }); + + it("value fallback when the scoped entry is sparse: stays empty when neither side has a value", async () => { + const windows = await windowsFor({ limits: [scoped(model("Fable"), null, null)] }); + expect(windows[0]).toMatchObject({ id: "weekly_model_fable", usedPct: null }); + }); + + it("identity: a surface never matches a legacy model window of the same name", async () => { + const windows = await windowsFor({ + seven_day_omelette: { utilization: 12, resets_at: RESETS }, + limits: [scoped(surface("Omelette"), 30)], + }); + expect(windows.map((w) => w.id)).toEqual(["weekly_model_omelette", "weekly_surface_omelette"]); + expect(windows[0]).toMatchObject({ usedPct: 12 }); + expect(windows[1]).toMatchObject({ usedPct: 30 }); + }); + + it("identity: a model and a surface of the same name stay apart", async () => { + const windows = await windowsFor({ + limits: [scoped(model("Code"), 4), scoped(surface("Code"), 9)], + }); + expect(windows.map((w) => w.id)).toEqual(["weekly_model_code", "weekly_surface_code"]); + }); + + it("identity: ids decide when both sides have one", async () => { + const windows = await windowsFor({ + limits: [ + scoped(model("Fable-Pro", "fable-pro"), 4), + scoped(model("Fable_Pro", "fable_pro"), 9), + ], + }); + expect(windows.map((w) => w.id)).toEqual(["weekly_model_fable-pro", "weekly_model_fable_pro"]); + }); + + it("identity: names decide when ids are absent, so indistinguishable entries merge", async () => { + const windows = await windowsFor({ + limits: [scoped(model("Fable Pro"), 4), scoped(model("Fable-Pro"), 9)], + }); + expect(windows).toEqual([ + expect.objectContaining({ id: "weekly_model_fable_pro", usedPct: 9 }), + ]); + }); + + it("identity: a renamed scope keeps its id when the API supplies one", async () => { + const before = await windowsFor({ limits: [scoped(model("Fable", "fable"), 2)] }); + const after = await windowsFor({ limits: [scoped(model("Fable 5", "fable"), 2)] }); + expect(before[0]?.id).toBe("weekly_model_fable"); + expect(after[0]?.id).toBe("weekly_model_fable"); + expect(after[0]?.label).toBe("Weekly · Fable 5"); + }); + + it("identity: a limit keeps one id whichever representation carries it", async () => { + const viaLegacy = await windowsFor({ + seven_day_omelette: { utilization: 12, resets_at: RESETS }, + }); + const viaLimits = await windowsFor({ limits: [scoped(model("Omelette"), 12)] }); + expect(viaLegacy[0]?.id).toBe(viaLimits[0]?.id); + }); + + it("ordering and unscoped windows: puts session and weekly ahead of the scoped bars", async () => { + const windows = await windowsFor({ + five_hour: { utilization: 6, resets_at: RESETS }, + seven_day: { utilization: 23, resets_at: RESETS }, + seven_day_opus: { utilization: 8, resets_at: RESETS }, + limits: [ + { kind: "session", percent: 6, resets_at: RESETS, scope: null }, + { kind: "weekly_all", percent: 23, resets_at: RESETS, scope: null }, + scoped(model("Fable"), 2), + ], + }); + expect(windows.map((w) => w.id)).toEqual([ + "five_hour", + "weekly", + "weekly_model_opus", + "weekly_model_fable", + ]); }); }); diff --git a/packages/server/src/plugins/claude-usage-source/server/usage.ts b/packages/server/src/plugins/claude-usage-source/server/usage.ts index 9ce3459ea8..6a7e8d25b3 100644 --- a/packages/server/src/plugins/claude-usage-source/server/usage.ts +++ b/packages/server/src/plugins/claude-usage-source/server/usage.ts @@ -1,3 +1,4 @@ +import type { UsageInput } from "../shared/input.js"; import { execFile } from "node:child_process"; import { createHash } from "node:crypto"; import { existsSync, promises as fs } from "node:fs"; @@ -6,17 +7,16 @@ import { join } from "node:path"; import { promisify } from "node:util"; import { z } from "zod"; import { - ApiNumberSchema, - fetchProviderApi, toneFromUsedPct, unavailableUsage, windowFromUsedPct, type UsageReport, type UsageWindow, type UsageDetail, - type UsageApiFetch, } from "@getpaseo/plugin/server/usage"; +const ApiNumberSchema = z.coerce.number().finite(); + const execFileAsync = promisify(execFile); const CLAUDE_KEYCHAIN_TIMEOUT_MS = 2_000; const CLAUDE_OAUTH_BETA = "oauth-2025-04-20"; @@ -78,16 +78,6 @@ interface ClaudeCredentialRecord { oauth: { accessToken: string } & NonNullable; } -interface ClaudeQuotaProviderOptions { - logger: Console; - claudeHome?: string; - configDir?: string; - accessToken?: string; - claudeKeychainReader?: () => Promise; - platform?: typeof process.platform; - fetch?: UsageApiFetch; -} - function buildClaudePlan( subscriptionType: string | undefined, rateLimitTier: string | undefined, @@ -337,75 +327,15 @@ export async function readClaudeKeychainCredentials( return null; } -export class ClaudeQuotaProvider { - private readonly logger: Console; - private readonly claudeHome: string; - private readonly configDir: string | undefined; - private readonly accessToken: string | undefined; - private readonly readKeychainCredentials: () => Promise; - private readonly platform: typeof process.platform; - private readonly fetchApi: UsageApiFetch; - - constructor(options: ClaudeQuotaProviderOptions) { - this.logger = options.logger; - this.claudeHome = - options.claudeHome || process.env["CLAUDE_HOME"] || join(homedir(), ".claude"); - this.configDir = options.configDir; - this.accessToken = options.accessToken; - this.readKeychainCredentials = options.claudeKeychainReader ?? readClaudeKeychainCredentials; - this.platform = options.platform ?? process.platform; - this.fetchApi = options.fetch ?? fetch; - } - - async fetchUsage(): Promise { - const credentials = await this.readCredentials(); - if (!credentials) { - return unavailableUsage(); - } - - const { oauth } = credentials; - const accountKey = createHash("sha256").update(oauth.accessToken).digest("hex"); - const plan = buildClaudePlan(oauth.subscriptionType, oauth.rateLimitTier); - const resp = await this.callClaudeApi(oauth.accessToken); - - if (resp === "NEEDS_AUTH") { - // Read-only on credentials; the Claude CLI owns refresh. See docs/providers.md. - return unavailableUsage(accountKey); - } - - const scoped = reconcileScopedLimits( - legacyScopedLimits(resp), - this.scopedLimitsFromResponse(resp.limits), - ); - const windows = [...unscopedWindows(resp), ...scopedWindows(scoped)]; - if (windows[0]) windows[0].headline = true; - - if (windows.length === 0) { - // The response parsed but described nothing. That silence is how the previous - // shape change went unnoticed, so make it greppable. `warn` and not `debug` - // because file logging defaults to `info`. - this.logger.warn("Claude usage response parsed but produced no windows"); - } - - const details: UsageDetail[] = []; - const extraUsageEnabled = resp.extra_usage?.is_enabled; - if (extraUsageEnabled !== undefined) { - details.push({ - id: "extra_usage", - label: "Extra usage", - value: extraUsageEnabled ? "Enabled" : "Disabled", - }); - } - - return { - account: { key: accountKey }, - status: "available", - planLabel: plan ?? undefined, - windows, - balances: [], - details, - }; - } +export async function fetchUsage( + input: UsageInput, + fetchApi: typeof fetch = fetch, +): Promise { + const claudeHome = process.env["CLAUDE_HOME"] || join(homedir(), ".claude"); + const configDir = "configDir" in input ? input.configDir : undefined; + const accessToken = "accessToken" in input ? input.accessToken : undefined; + const readKeychainCredentials = readClaudeKeychainCredentials; + const platform = process.platform; /** * Scoped limits carried by `limits[]`. @@ -413,21 +343,21 @@ export class ClaudeQuotaProvider { * Entries are validated one at a time so a single malformed or newly-shaped entry * cannot fail the whole response and take the windows that already parsed with it. */ - private scopedLimitsFromResponse(limits: ClaudeUsageResponse["limits"]): ScopedLimit[] { + function scopedLimitsFromResponse(limits: ClaudeUsageResponse["limits"]): ScopedLimit[] { if (!limits) return []; const parsed: ScopedLimit[] = []; for (const entry of limits) { const result = ClaudeLimitSchema.safeParse(entry); if (!result.success) { - this.logger.warn({ err: result.error }, "Skipping unparseable Claude usage limit entry"); + console.warn({ err: result.error }, "Skipping unparseable Claude usage limit entry"); continue; } if (result.data.kind !== SCOPED_WEEKLY_KIND) continue; const limit = scopedLimitFromEntry(result.data); if (!limit) { - this.logger.warn("Skipping scoped Claude usage limit with no resolvable scope name"); + console.warn("Skipping scoped Claude usage limit with no resolvable scope name"); continue; } parsed.push(limit); @@ -435,20 +365,18 @@ export class ClaudeQuotaProvider { return parsed; } - private async readCredentials(): Promise { - if (this.accessToken) return { oauth: { accessToken: this.accessToken } }; - if (this.configDir) return this.readCredentialFile(join(this.configDir, ".credentials.json")); - const credPath = join(this.claudeHome, ".credentials.json"); - const fileCredentials = await this.readCredentialFile(credPath); - return ( - fileCredentials ?? (this.platform === "darwin" ? await this.readKeychainCredential() : null) - ); + async function readCredentials(): Promise { + if (accessToken) return { oauth: { accessToken: accessToken } }; + if (configDir) return readCredentialFile(join(configDir, ".credentials.json")); + const credPath = join(claudeHome, ".credentials.json"); + const fileCredentials = await readCredentialFile(credPath); + return fileCredentials ?? (platform === "darwin" ? await readKeychainCredential() : null); } - private async readCredentialFile(path: string): Promise { + async function readCredentialFile(path: string): Promise { if (!existsSync(path)) return null; try { - return this.toCredentialRecord( + return toCredentialRecord( ClaudeCredentialsSchema.parse(JSON.parse(await fs.readFile(path, "utf8"))), ); } catch { @@ -456,20 +384,21 @@ export class ClaudeQuotaProvider { } } - private async readKeychainCredential(): Promise { - const parsed = ClaudeCredentialsSchema.safeParse(await this.readKeychainCredentials()); - return parsed.success ? this.toCredentialRecord(parsed.data) : null; + async function readKeychainCredential(): Promise { + const parsed = ClaudeCredentialsSchema.safeParse(await readKeychainCredentials()); + return parsed.success ? toCredentialRecord(parsed.data) : null; } - private toCredentialRecord( + function toCredentialRecord( credentials: z.infer, ): ClaudeCredentialRecord | null { const oauth = credentials.claudeAiOauth; return oauth?.accessToken ? { oauth: { ...oauth, accessToken: oauth.accessToken } } : null; } - private async callClaudeApi(token: string): Promise { - const res = await fetchProviderApi(this.fetchApi, "https://api.anthropic.com/api/oauth/usage", { + async function callClaudeApi(token: string): Promise { + const res = await fetchApi("https://api.anthropic.com/api/oauth/usage", { + signal: AbortSignal.timeout(15_000), headers: { Authorization: `Bearer ${token}`, Accept: "application/json", @@ -480,4 +409,52 @@ export class ClaudeQuotaProvider { if (!res.ok) throw new Error(`Claude usage API returned ${res.status}`); return ClaudeUsageResponseSchema.parse(await res.json()); } + + const credentials = await readCredentials(); + if (!credentials) { + return unavailableUsage(); + } + + const { oauth } = credentials; + const accountKey = createHash("sha256").update(oauth.accessToken).digest("hex"); + const plan = buildClaudePlan(oauth.subscriptionType, oauth.rateLimitTier); + const resp = await callClaudeApi(oauth.accessToken); + + if (resp === "NEEDS_AUTH") { + // Read-only on credentials; the Claude CLI owns refresh. See docs/providers.md. + return unavailableUsage(accountKey); + } + + const scoped = reconcileScopedLimits( + legacyScopedLimits(resp), + scopedLimitsFromResponse(resp.limits), + ); + const windows = [...unscopedWindows(resp), ...scopedWindows(scoped)]; + if (windows[0]) windows[0].headline = true; + + if (windows.length === 0) { + // The response parsed but described nothing. That silence is how the previous + // shape change went unnoticed, so make it greppable. `warn` and not `debug` + // because file logging defaults to `info`. + console.warn("Claude usage response parsed but produced no windows"); + } + + const details: UsageDetail[] = []; + const extraUsageEnabled = resp.extra_usage?.is_enabled; + if (extraUsageEnabled !== undefined) { + details.push({ + id: "extra_usage", + label: "Extra usage", + value: extraUsageEnabled ? "Enabled" : "Disabled", + }); + } + + return { + account: { key: accountKey }, + status: "available", + planLabel: plan ?? undefined, + windows, + balances: [], + details, + }; } diff --git a/packages/server/src/plugins/copilot-usage-source/index.server.ts b/packages/server/src/plugins/copilot-usage-source/index.server.ts index 7a9f05bc98..2227f972de 100644 --- a/packages/server/src/plugins/copilot-usage-source/index.server.ts +++ b/packages/server/src/plugins/copilot-usage-source/index.server.ts @@ -1,6 +1,6 @@ import type { PluginServerContext } from "@getpaseo/plugin/server"; import { inputSchema } from "./shared/input.js"; -import { CopilotQuotaProvider } from "./server/usage.js"; +import { fetchUsage } from "./server/usage.js"; export default function contribute(server: PluginServerContext) { server.registerUsageSource({ @@ -9,7 +9,7 @@ export default function contribute(server: PluginServerContext) { icon: "icon.svg", input: inputSchema, discover: async () => [{}], - fetch: async (_input) => new CopilotQuotaProvider({ logger: console }).fetchUsage(), + fetch: fetchUsage, }); return () => {}; } diff --git a/packages/server/src/plugins/copilot-usage-source/paseo-plugin.json b/packages/server/src/plugins/copilot-usage-source/paseo-plugin.json index ab6addf629..306f1a467a 100644 --- a/packages/server/src/plugins/copilot-usage-source/paseo-plugin.json +++ b/packages/server/src/plugins/copilot-usage-source/paseo-plugin.json @@ -1 +1 @@ -{ "id": "copilot-usage-source", "requirements": { "paseo": ">=0.9.2" } } +{ "id": "copilot-usage-source" } diff --git a/packages/server/src/plugins/copilot-usage-source/server/usage.test.ts b/packages/server/src/plugins/copilot-usage-source/server/usage.test.ts new file mode 100644 index 0000000000..ed3eb6b0ac --- /dev/null +++ b/packages/server/src/plugins/copilot-usage-source/server/usage.test.ts @@ -0,0 +1,113 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { fetchUsage } from "./usage.js"; +import type { UsageReport } from "@getpaseo/plugin/server/usage"; + +function mockFetch(handlers: Map Response>): typeof fetch { + return vi.fn(async (url: RequestInfo | URL) => { + const key = url.toString(); + const handler = handlers.get(key); + if (!handler) throw new Error(`Unmocked fetch: ${key}`); + return handler(); + }) as unknown as typeof fetch; +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +describe("copilot usage source", () => { + let homeDir: string; + let fetchApi: typeof fetch; + let originalEnv: Record; + beforeEach(() => { + homeDir = mkdtempSync(join(tmpdir(), "usage-home-")); + originalEnv = { ...process.env }; + process.env["HOME"] = homeDir; + process.env["USERPROFILE"] = homeDir; + for (const key of [ + "APPDATA", + "COPILOT_TOKEN", + "GITHUB_TOKEN", + "GITHUB_PAT", + "CURSOR_ACCESS_TOKEN", + "CURSOR_TOKEN", + "ZAI_API_KEY", + "GLM_API_KEY", + "GROK_API_KEY", + "GROK_TOKEN", + "KIMI_TOKEN", + "KIMI_API_KEY", + "KIMI_CODE_HOME", + "MINIMAX_API_KEY", + "MINIMAX_BASE_URL", + ]) + delete process.env[key]; + fetchApi = mockFetch(new Map()); + }); + afterEach(() => { + rmSync(homeDir, { recursive: true, force: true }); + for (const key of Object.keys(process.env)) if (!(key in originalEnv)) delete process.env[key]; + for (const key in originalEnv) process.env[key] = originalEnv[key]; + }); + function service( + _options: { + platform?: typeof process.platform; + keychain?: () => Promise; + cursorHomeDir?: string; + kimiHomeDir?: string; + } = {}, + ) { + return { + listUsage: async () => { + const report = await fetchUsage({}, (url, init) => fetchApi(url, init)); + return { + providers: [ + { + providerId: "copilot", + ...report, + error: report.error ?? null, + planLabel: report.planLabel ?? null, + }, + ], + }; + }, + }; + } + function findProvider( + result: { providers: Array<{ providerId: string } & UsageReport> }, + id: string, + ) { + const report = result.providers.find((item) => item.providerId === id); + if (!report) throw new Error(`Missing usage source ${id}`); + return report; + } + it("fetches Copilot usage from COPILOT_TOKEN", async () => { + process.env["COPILOT_TOKEN"] = "copilot_test_token"; + fetchApi = mockFetch( + new Map([ + [ + "https://api.github.com/copilot_internal/user", + () => + jsonResponse({ + copilot_plan: "business", + quota_reset_date: "2026-07-01T00:00:00Z", + }), + ], + ]), + ); + + const copilot = findProvider(await service().listUsage(), "copilot"); + + expect(copilot).toMatchObject({ + status: "available", + planLabel: "business", + details: [{ id: "reset", label: "Quota reset", value: "2026-07-01T00:00:00Z" }], + }); + }); +}); diff --git a/packages/server/src/plugins/copilot-usage-source/server/usage.ts b/packages/server/src/plugins/copilot-usage-source/server/usage.ts index 3368351189..06eaa35719 100644 --- a/packages/server/src/plugins/copilot-usage-source/server/usage.ts +++ b/packages/server/src/plugins/copilot-usage-source/server/usage.ts @@ -1,26 +1,24 @@ +import type { UsageInput } from "../shared/input.js"; import { existsSync, promises as fs } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import { z } from "zod"; import { - ApiOptionalStringSchema, - fetchProviderApi, unavailableUsage, type UsageReport, type UsageDetail, - type UsageApiFetch, } from "@getpaseo/plugin/server/usage"; +const ApiOptionalStringSchema = z.preprocess( + (value) => (value == null ? undefined : value), + z.coerce.string().optional(), +); + const CopilotUsageResponseSchema = z.object({ copilot_plan: ApiOptionalStringSchema, quota_reset_date: ApiOptionalStringSchema, }); -interface CopilotQuotaProviderOptions { - logger: Console; - fetch?: UsageApiFetch; -} - async function readGithubCliToken(): Promise { const candidates: string[] = []; if (process.env["APPDATA"]) { @@ -41,56 +39,47 @@ async function readGithubCliToken(): Promise { return null; } -export class CopilotQuotaProvider { - private readonly logger: Console; - private readonly fetchApi: UsageApiFetch; +export async function fetchUsage( + input: UsageInput, + fetchApi: typeof fetch = fetch, +): Promise { + void input; - constructor(options: CopilotQuotaProviderOptions) { - this.logger = options.logger; - this.fetchApi = options.fetch ?? fetch; + const token = + process.env["COPILOT_TOKEN"] || + process.env["GITHUB_TOKEN"] || + process.env["GITHUB_PAT"] || + (await readGithubCliToken()); + + if (!token) return unavailableUsage(); + + const res = await fetchApi("https://api.github.com/copilot_internal/user", { + signal: AbortSignal.timeout(15_000), + headers: { + Authorization: `token ${token}`, + Accept: "application/json", + "Editor-Version": "vscode/1.96.2", + "Editor-Plugin-Version": "copilot-chat/0.26.7", + "User-Agent": "GitHubCopilotChat/0.26.7", + "X-Github-Api-Version": "2025-04-01", + }, + }); + + if (!res.ok) { + return unavailableUsage(); } - async fetchUsage(): Promise { - const token = - process.env["COPILOT_TOKEN"] || - process.env["GITHUB_TOKEN"] || - process.env["GITHUB_PAT"] || - (await readGithubCliToken()); + const resp = CopilotUsageResponseSchema.parse(await res.json()); + const details: UsageDetail[] = resp.quota_reset_date + ? [{ id: "reset", label: "Quota reset", value: resp.quota_reset_date }] + : []; - if (!token) return unavailableUsage(); - - const res = await fetchProviderApi( - this.fetchApi, - "https://api.github.com/copilot_internal/user", - { - headers: { - Authorization: `token ${token}`, - Accept: "application/json", - "Editor-Version": "vscode/1.96.2", - "Editor-Plugin-Version": "copilot-chat/0.26.7", - "User-Agent": "GitHubCopilotChat/0.26.7", - "X-Github-Api-Version": "2025-04-01", - }, - }, - ); - - if (!res.ok) { - this.logger.debug({ status: res.status }, "Copilot usage fetch failed"); - return unavailableUsage(); - } - - const resp = CopilotUsageResponseSchema.parse(await res.json()); - const details: UsageDetail[] = resp.quota_reset_date - ? [{ id: "reset", label: "Quota reset", value: resp.quota_reset_date }] - : []; - - return { - account: { key: "default" }, - status: "available", - planLabel: resp.copilot_plan || undefined, - windows: [], - balances: [], - details, - }; - } + return { + account: { key: "default" }, + status: "available", + planLabel: resp.copilot_plan || undefined, + windows: [], + balances: [], + details, + }; } diff --git a/packages/server/src/plugins/copilot-usage-source/shared/input.ts b/packages/server/src/plugins/copilot-usage-source/shared/input.ts index 79498e9cd5..9ee4593341 100644 --- a/packages/server/src/plugins/copilot-usage-source/shared/input.ts +++ b/packages/server/src/plugins/copilot-usage-source/shared/input.ts @@ -1,2 +1,4 @@ import { z } from "zod"; export const inputSchema = z.object({}).strict(); + +export type UsageInput = z.infer; diff --git a/packages/server/src/plugins/cursor-usage-source/index.server.ts b/packages/server/src/plugins/cursor-usage-source/index.server.ts index aaf27dfefd..9397dd07c4 100644 --- a/packages/server/src/plugins/cursor-usage-source/index.server.ts +++ b/packages/server/src/plugins/cursor-usage-source/index.server.ts @@ -1,6 +1,6 @@ import type { PluginServerContext } from "@getpaseo/plugin/server"; import { inputSchema } from "./shared/input.js"; -import { CursorQuotaProvider } from "./server/usage.js"; +import { fetchUsage } from "./server/usage.js"; export default function contribute(server: PluginServerContext) { server.registerUsageSource({ @@ -9,7 +9,7 @@ export default function contribute(server: PluginServerContext) { icon: "icon.svg", input: inputSchema, discover: async () => [{}], - fetch: async (_input) => new CursorQuotaProvider({ logger: console }).fetchUsage(), + fetch: fetchUsage, }); return () => {}; } diff --git a/packages/server/src/plugins/cursor-usage-source/paseo-plugin.json b/packages/server/src/plugins/cursor-usage-source/paseo-plugin.json index 311fd28ee4..5265bb7985 100644 --- a/packages/server/src/plugins/cursor-usage-source/paseo-plugin.json +++ b/packages/server/src/plugins/cursor-usage-source/paseo-plugin.json @@ -1 +1 @@ -{ "id": "cursor-usage-source", "requirements": { "paseo": ">=0.9.2" } } +{ "id": "cursor-usage-source" } diff --git a/packages/server/src/plugins/cursor-usage-source/server/usage.test.ts b/packages/server/src/plugins/cursor-usage-source/server/usage.test.ts new file mode 100644 index 0000000000..df9cf4bac5 --- /dev/null +++ b/packages/server/src/plugins/cursor-usage-source/server/usage.test.ts @@ -0,0 +1,260 @@ +import { mkdirSync, writeFileSync, mkdtempSync, rmSync } from "node:fs"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { fetchUsage } from "./usage.js"; +import type { UsageReport } from "@getpaseo/plugin/server/usage"; + +// node:sqlite has no @types/node@20 typings; require it with a narrow local type. +const testRequire = createRequire(import.meta.url); +interface TestSqliteDb { + exec(sql: string): void; + prepare(sql: string): { run(...params: unknown[]): void }; + close(): void; +} + +// Cursor builds have stored ItemTable values as both TEXT and BLOB. Keys map to the +// real layouts: a plain modern token or the legacy JSON object. +function writeCursorStateDb(homeDir: string, rows: Record): void { + const dir = join(homeDir, ".config", "Cursor", "User", "globalStorage"); + mkdirSync(dir, { recursive: true }); + const { DatabaseSync } = testRequire("node:sqlite") as { + DatabaseSync: new (path: string) => TestSqliteDb; + }; + const db = new DatabaseSync(join(dir, "state.vscdb")); + db.exec("CREATE TABLE IF NOT EXISTS ItemTable (key TEXT UNIQUE ON CONFLICT REPLACE, value BLOB)"); + const insert = db.prepare("INSERT INTO ItemTable (key, value) VALUES (?, ?)"); + for (const [key, value] of Object.entries(rows)) { + insert.run(key, value); + } + db.close(); +} + +function writeCursorAuthJson(homeDir: string, accessToken: string): void { + const dir = join(homeDir, ".config", "cursor"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "auth.json"), JSON.stringify({ accessToken })); +} + +function mockFetch(handlers: Map Response>): typeof fetch { + return vi.fn(async (url: RequestInfo | URL) => { + const key = url.toString(); + const handler = handlers.get(key); + if (!handler) throw new Error(`Unmocked fetch: ${key}`); + return handler(); + }) as unknown as typeof fetch; +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +describe("cursor usage source", () => { + let homeDir: string; + let fetchApi: typeof fetch; + let originalEnv: Record; + beforeEach(() => { + homeDir = mkdtempSync(join(tmpdir(), "usage-home-")); + originalEnv = { ...process.env }; + process.env["HOME"] = homeDir; + process.env["USERPROFILE"] = homeDir; + for (const key of [ + "APPDATA", + "COPILOT_TOKEN", + "GITHUB_TOKEN", + "GITHUB_PAT", + "CURSOR_ACCESS_TOKEN", + "CURSOR_TOKEN", + "ZAI_API_KEY", + "GLM_API_KEY", + "GROK_API_KEY", + "GROK_TOKEN", + "KIMI_TOKEN", + "KIMI_API_KEY", + "KIMI_CODE_HOME", + "MINIMAX_API_KEY", + "MINIMAX_BASE_URL", + ]) + delete process.env[key]; + fetchApi = mockFetch(new Map()); + }); + afterEach(() => { + rmSync(homeDir, { recursive: true, force: true }); + for (const key of Object.keys(process.env)) if (!(key in originalEnv)) delete process.env[key]; + for (const key in originalEnv) process.env[key] = originalEnv[key]; + }); + function service( + _options: { + platform?: typeof process.platform; + keychain?: () => Promise; + cursorHomeDir?: string; + kimiHomeDir?: string; + } = {}, + ) { + return { + listUsage: async () => { + const report = await fetchUsage({}, (url, init) => fetchApi(url, init)); + return { + providers: [ + { + providerId: "cursor", + ...report, + error: report.error ?? null, + planLabel: report.planLabel ?? null, + }, + ], + }; + }, + }; + } + function findProvider( + result: { providers: Array<{ providerId: string } & UsageReport> }, + id: string, + ) { + const report = result.providers.find((item) => item.providerId === id); + if (!report) throw new Error(`Missing usage source ${id}`); + return report; + } + it("fetches Cursor usage and normalizes malformed billing dates to null", async () => { + process.env["CURSOR_ACCESS_TOKEN"] = "cursor_test_token"; + fetchApi = mockFetch( + new Map([ + [ + "https://api2.cursor.sh/aiserver.v1.DashboardService/GetCurrentPeriodUsage", + () => + jsonResponse({ + planUsage: { + totalSpend: "1500", + includedSpend: "1000", + bonusSpend: "500", + remaining: "2500", + limit: "4000", + }, + billingCycleStart: "2026-01-14T12:42:14.000Z", + billingCycleEnd: "not-a-date", + }), + ], + ]), + ); + + const cursor = findProvider(await service().listUsage(), "cursor"); + + expect(cursor).toMatchObject({ + status: "available", + balances: [ + expect.objectContaining({ + id: "plan_usage", + used: 15, + remaining: 25, + limit: 40, + resetsAt: null, + }), + ], + }); + }); + + it("reads the Cursor token from the modern cursorAuth/accessToken key in state.vscdb", async () => { + writeCursorStateDb(homeDir, { "cursorAuth/accessToken": "cursor_state_jwt" }); + let authorization: string | null = null; + fetchApi = (async (url: RequestInfo | URL, init?: RequestInit) => { + authorization = (init?.headers as Record | undefined)?.Authorization ?? null; + return jsonResponse({ + planUsage: { + totalSpend: "1500", + includedSpend: "1000", + bonusSpend: "500", + remaining: "2500", + limit: "4000", + }, + billingCycleStart: "2026-01-14T12:42:14.000Z", + billingCycleEnd: "2026-02-14T12:42:14.000Z", + }); + }) as unknown as typeof fetch; + + const cursor = findProvider(await service({ cursorHomeDir: homeDir }).listUsage(), "cursor"); + + expect(authorization).toBe("Bearer cursor_state_jwt"); + expect(cursor).toMatchObject({ + status: "available", + balances: [expect.objectContaining({ id: "plan_usage", used: 15, remaining: 25, limit: 40 })], + }); + }); + + it("falls back to the legacy cursorAuthStatus JSON blob when the modern key is absent", async () => { + writeCursorStateDb(homeDir, { + cursorAuthStatus: Buffer.from(JSON.stringify({ accessToken: "cursor_legacy_jwt" }), "utf8"), + }); + let authorization: string | null = null; + fetchApi = (async (url: RequestInfo | URL, init?: RequestInit) => { + authorization = (init?.headers as Record | undefined)?.Authorization ?? null; + return jsonResponse({ + planUsage: { totalSpend: "0", remaining: "100", limit: "100" }, + billingCycleStart: null, + billingCycleEnd: null, + }); + }) as unknown as typeof fetch; + + const cursor = findProvider(await service({ cursorHomeDir: homeDir }).listUsage(), "cursor"); + + expect(authorization).toBe("Bearer cursor_legacy_jwt"); + expect(cursor.status).toBe("available"); + }); + + it("reads the Cursor token from cursor-agent ~/.config/cursor/auth.json when desktop state is absent", async () => { + writeCursorAuthJson(homeDir, "cursor_cli_jwt"); + let authorization: string | null = null; + fetchApi = (async (_url: RequestInfo | URL, init?: RequestInit) => { + authorization = (init?.headers as Record | undefined)?.Authorization ?? null; + return jsonResponse({ + planUsage: { + totalSpend: "1500", + includedSpend: "1000", + bonusSpend: "500", + remaining: "2500", + limit: "4000", + }, + billingCycleStart: "2026-01-14T12:42:14.000Z", + billingCycleEnd: "2026-02-14T12:42:14.000Z", + }); + }) as unknown as typeof fetch; + + const cursor = findProvider(await service({ cursorHomeDir: homeDir }).listUsage(), "cursor"); + + expect(authorization).toBe("Bearer cursor_cli_jwt"); + expect(cursor).toMatchObject({ + status: "available", + balances: [expect.objectContaining({ id: "plan_usage", used: 15, remaining: 25, limit: 40 })], + }); + }); + + it("prefers the desktop state.vscdb token over cursor-agent auth.json", async () => { + writeCursorStateDb(homeDir, { "cursorAuth/accessToken": "cursor_desktop_jwt" }); + writeCursorAuthJson(homeDir, "cursor_cli_jwt"); + let authorization: string | null = null; + fetchApi = (async (_url: RequestInfo | URL, init?: RequestInit) => { + authorization = (init?.headers as Record | undefined)?.Authorization ?? null; + return jsonResponse({ + planUsage: { totalSpend: "0", remaining: "100", limit: "100" }, + billingCycleStart: null, + billingCycleEnd: null, + }); + }) as unknown as typeof fetch; + + const cursor = findProvider(await service({ cursorHomeDir: homeDir }).listUsage(), "cursor"); + + expect(authorization).toBe("Bearer cursor_desktop_jwt"); + expect(cursor.status).toBe("available"); + }); + + it("stays unavailable when state.vscdb is unreadable", async () => { + const path = join(homeDir, ".config", "Cursor", "User", "globalStorage"); + mkdirSync(path, { recursive: true }); + writeFileSync(join(path, "state.vscdb"), "not a database"); + const cursor = findProvider(await service().listUsage(), "cursor"); + expect(cursor.status).toBe("unavailable"); + }); +}); diff --git a/packages/server/src/plugins/cursor-usage-source/server/usage.ts b/packages/server/src/plugins/cursor-usage-source/server/usage.ts index d1aac7e614..b1fbf9830e 100644 --- a/packages/server/src/plugins/cursor-usage-source/server/usage.ts +++ b/packages/server/src/plugins/cursor-usage-source/server/usage.ts @@ -1,20 +1,27 @@ +import type { UsageInput } from "../shared/input.js"; import { existsSync } from "node:fs"; import { readFile } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; import { z } from "zod"; import { - ApiNullableNumberSchema, toneFromUsedPct, usedPctOf, - fetchProviderApi, - toIsoStringOrNull, unavailableUsage, type UsageReport, type UsageBalance, - type UsageApiFetch, } from "@getpaseo/plugin/server/usage"; +const ApiNumberSchema = z.coerce.number().finite(); +const ApiNullableNumberSchema = z.preprocess( + (value) => (value == null ? null : value), + ApiNumberSchema.nullable(), +); +function toIsoStringOrNull(timestampMs: number): string | null { + const date = new Date(timestampMs); + return Number.isFinite(date.getTime()) ? date.toISOString() : null; +} + // Cursor desktop stores auth in VS Code's ItemTable (state.vscdb). Modern builds keep // the access token as a plain JWT string under `cursorAuth/accessToken`; older builds // kept a JSON blob under `cursorAuthStatus`. Read it with node:sqlite so we don't @@ -62,12 +69,6 @@ const CursorAuthStatusSchema = z.object({ type CursorUsageResponse = z.infer; -interface CursorQuotaProviderOptions { - logger: Console; - fetch?: UsageApiFetch; - homeDir?: string; -} - function parseCursorBillingCycleTimestamp( value: CursorUsageResponse["billingCycleStart"], ): string | null { @@ -113,7 +114,7 @@ function cursorTokenFromDb(db: CursorStateDatabase): string | null { return null; } -async function readCursorTokenFromSqlite(homeDir: string, logger: Console): Promise { +async function readCursorTokenFromSqlite(homeDir: string): Promise { const dbPaths: string[] = []; if (process.env["APPDATA"]) { dbPaths.push(join(process.env["APPDATA"], "Cursor", "User", "globalStorage", "state.vscdb")); @@ -137,8 +138,7 @@ async function readCursorTokenFromSqlite(homeDir: string, logger: Console): Prom let sqlite: NodeSqliteModule; try { sqlite = (await import(sqliteSpecifier)) as unknown as NodeSqliteModule; - } catch (err) { - logger.debug({ err }, "node:sqlite unavailable; cannot read Cursor state.vscdb"); + } catch { return null; // runtime without node:sqlite } @@ -149,10 +149,8 @@ async function readCursorTokenFromSqlite(homeDir: string, logger: Console): Prom db = new sqlite.DatabaseSync(path, { readOnly: true }); const token = cursorTokenFromDb(db); if (token) return token; - } catch (err) { - // Locked/permission/corrupt/schema failures all land here; log so an - // unavailable Cursor card is diagnosable, then try the next candidate. - logger.debug({ err, path }, "Failed to read Cursor token from state.vscdb"); + } catch { + // Locked, permission, corrupt, or schema failures try the next candidate. } finally { db?.close(); } @@ -160,86 +158,75 @@ async function readCursorTokenFromSqlite(homeDir: string, logger: Console): Prom return null; } -async function readCursorTokenFromAuthJson( - homeDir: string, - logger: Console, -): Promise { +async function readCursorTokenFromAuthJson(homeDir: string): Promise { const path = join(homeDir, ".config", "cursor", "auth.json"); if (!existsSync(path)) return null; try { const parsed = CursorAuthStatusSchema.parse(JSON.parse(await readFile(path, "utf8"))); return parsed.accessToken?.trim() || null; - } catch (err) { - logger.debug({ err, path }, "Failed to read Cursor token from auth.json"); + } catch { return null; } } -export class CursorQuotaProvider { - private readonly logger: Console; - private readonly fetchApi: UsageApiFetch; - private readonly homeDir: string; +export async function fetchUsage( + input: UsageInput, + fetchApi: typeof fetch = fetch, +): Promise { + void input; + const homeDir = homedir(); - constructor(options: CursorQuotaProviderOptions) { - this.logger = options.logger; - this.fetchApi = options.fetch ?? fetch; - this.homeDir = options.homeDir ?? homedir(); - } + const token = + process.env["CURSOR_ACCESS_TOKEN"] || + process.env["CURSOR_TOKEN"] || + (await readCursorTokenFromSqlite(homeDir)) || + (await readCursorTokenFromAuthJson(homeDir)); - async fetchUsage(): Promise { - const token = - process.env["CURSOR_ACCESS_TOKEN"] || - process.env["CURSOR_TOKEN"] || - (await readCursorTokenFromSqlite(this.homeDir, this.logger)) || - (await readCursorTokenFromAuthJson(this.homeDir, this.logger)); + if (!token) return unavailableUsage(); - if (!token) return unavailableUsage(); - - const res = await fetchProviderApi( - this.fetchApi, - "https://api2.cursor.sh/aiserver.v1.DashboardService/GetCurrentPeriodUsage", - { - method: "POST", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - "Connect-Protocol-Version": "1", - }, - body: JSON.stringify({}), + const res = await fetchApi( + "https://api2.cursor.sh/aiserver.v1.DashboardService/GetCurrentPeriodUsage", + { + signal: AbortSignal.timeout(15_000), + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + "Connect-Protocol-Version": "1", }, - ); + body: JSON.stringify({}), + }, + ); - if (!res.ok) { - this.logger.debug({ status: res.status }, "Cursor usage fetch failed"); - return unavailableUsage(); - } - - const resp = CursorUsageResponseSchema.parse(await res.json()); - const billingCycleEnd = parseCursorBillingCycleTimestamp(resp.billingCycleEnd); - const balances: UsageBalance[] = []; - if (resp.planUsage) { - const totalSpend = centsToDollars(resp.planUsage.totalSpend); - const remaining = centsToDollars(resp.planUsage.remaining); - const limit = centsToDollars(resp.planUsage.limit); - balances.push({ - id: "plan_usage", - label: "Plan usage", - used: totalSpend, - remaining, - limit, - unit: "usd", - resetsAt: billingCycleEnd, - tone: toneFromUsedPct(usedPctOf(totalSpend, limit)), - }); - } - - return { - account: { key: "default" }, - status: "available", - planLabel: undefined, - windows: [], - balances, - details: [], - }; + if (!res.ok) { + return unavailableUsage(); } + + const resp = CursorUsageResponseSchema.parse(await res.json()); + const billingCycleEnd = parseCursorBillingCycleTimestamp(resp.billingCycleEnd); + const balances: UsageBalance[] = []; + if (resp.planUsage) { + const totalSpend = centsToDollars(resp.planUsage.totalSpend); + const remaining = centsToDollars(resp.planUsage.remaining); + const limit = centsToDollars(resp.planUsage.limit); + balances.push({ + id: "plan_usage", + label: "Plan usage", + used: totalSpend, + remaining, + limit, + unit: "usd", + resetsAt: billingCycleEnd, + tone: toneFromUsedPct(usedPctOf(totalSpend, limit)), + }); + } + + return { + account: { key: "default" }, + status: "available", + planLabel: undefined, + windows: [], + balances, + details: [], + }; } diff --git a/packages/server/src/plugins/cursor-usage-source/shared/input.ts b/packages/server/src/plugins/cursor-usage-source/shared/input.ts index 79498e9cd5..9ee4593341 100644 --- a/packages/server/src/plugins/cursor-usage-source/shared/input.ts +++ b/packages/server/src/plugins/cursor-usage-source/shared/input.ts @@ -1,2 +1,4 @@ import { z } from "zod"; export const inputSchema = z.object({}).strict(); + +export type UsageInput = z.infer; diff --git a/packages/server/src/plugins/grok-usage-source/index.server.ts b/packages/server/src/plugins/grok-usage-source/index.server.ts index e3576fe5de..c7f148fdb5 100644 --- a/packages/server/src/plugins/grok-usage-source/index.server.ts +++ b/packages/server/src/plugins/grok-usage-source/index.server.ts @@ -1,6 +1,6 @@ import type { PluginServerContext } from "@getpaseo/plugin/server"; import { inputSchema } from "./shared/input.js"; -import { GrokQuotaProvider } from "./server/usage.js"; +import { fetchUsage } from "./server/usage.js"; export default function contribute(server: PluginServerContext) { server.registerUsageSource({ @@ -9,7 +9,7 @@ export default function contribute(server: PluginServerContext) { icon: "icon.svg", input: inputSchema, discover: async () => [{}], - fetch: async (_input) => new GrokQuotaProvider({ logger: console }).fetchUsage(), + fetch: fetchUsage, }); return () => {}; } diff --git a/packages/server/src/plugins/grok-usage-source/paseo-plugin.json b/packages/server/src/plugins/grok-usage-source/paseo-plugin.json index 2fbdcc5f14..0954ded8d4 100644 --- a/packages/server/src/plugins/grok-usage-source/paseo-plugin.json +++ b/packages/server/src/plugins/grok-usage-source/paseo-plugin.json @@ -1 +1 @@ -{ "id": "grok-usage-source", "requirements": { "paseo": ">=0.9.2" } } +{ "id": "grok-usage-source" } diff --git a/packages/server/src/plugins/grok-usage-source/server/usage.test.ts b/packages/server/src/plugins/grok-usage-source/server/usage.test.ts new file mode 100644 index 0000000000..eb03b8f7e6 --- /dev/null +++ b/packages/server/src/plugins/grok-usage-source/server/usage.test.ts @@ -0,0 +1,268 @@ +import { mkdirSync, writeFileSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { fetchUsage } from "./usage.js"; +import type { UsageReport } from "@getpaseo/plugin/server/usage"; + +function writeGrokAuth(home: string, auth: Record): void { + mkdirSync(join(home, ".grok"), { recursive: true }); + writeFileSync(join(home, ".grok", "auth.json"), JSON.stringify(auth)); +} + +function mockFetch(handlers: Map Response>): typeof fetch { + return vi.fn(async (url: RequestInfo | URL) => { + const key = url.toString(); + const handler = handlers.get(key); + if (!handler) throw new Error(`Unmocked fetch: ${key}`); + return handler(); + }) as unknown as typeof fetch; +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +describe("grok usage source", () => { + let homeDir: string; + let fetchApi: typeof fetch; + let originalEnv: Record; + beforeEach(() => { + homeDir = mkdtempSync(join(tmpdir(), "usage-home-")); + originalEnv = { ...process.env }; + process.env["HOME"] = homeDir; + process.env["USERPROFILE"] = homeDir; + for (const key of [ + "APPDATA", + "COPILOT_TOKEN", + "GITHUB_TOKEN", + "GITHUB_PAT", + "CURSOR_ACCESS_TOKEN", + "CURSOR_TOKEN", + "ZAI_API_KEY", + "GLM_API_KEY", + "GROK_API_KEY", + "GROK_TOKEN", + "KIMI_TOKEN", + "KIMI_API_KEY", + "KIMI_CODE_HOME", + "MINIMAX_API_KEY", + "MINIMAX_BASE_URL", + ]) + delete process.env[key]; + fetchApi = mockFetch(new Map()); + }); + afterEach(() => { + rmSync(homeDir, { recursive: true, force: true }); + for (const key of Object.keys(process.env)) if (!(key in originalEnv)) delete process.env[key]; + for (const key in originalEnv) process.env[key] = originalEnv[key]; + }); + function service( + _options: { + platform?: typeof process.platform; + keychain?: () => Promise; + cursorHomeDir?: string; + kimiHomeDir?: string; + } = {}, + ) { + return { + listUsage: async () => { + const report = await fetchUsage({}, (url, init) => fetchApi(url, init)); + return { + providers: [ + { + providerId: "grok", + ...report, + error: report.error ?? null, + planLabel: report.planLabel ?? null, + }, + ], + }; + }, + }; + } + function findProvider( + result: { providers: Array<{ providerId: string } & UsageReport> }, + id: string, + ) { + const report = result.providers.find((item) => item.providerId === id); + if (!report) throw new Error(`Missing usage source ${id}`); + return report; + } + it("fetches Grok usage and preserves zero values", async () => { + process.env["GROK_API_KEY"] = "grok_test_token"; + fetchApi = mockFetch( + new Map([ + [ + "https://cli-chat-proxy.grok.com/v1/billing?format=credits", + () => + jsonResponse({ + config: { monthlyLimit: { val: 0 }, used: { val: 0 } }, + }), + ], + ]), + ); + + const grok = findProvider(await service().listUsage(), "grok"); + + expect(grok).toMatchObject({ + status: "available", + balances: [ + expect.objectContaining({ + id: "monthly_credits", + used: 0, + remaining: 0, + limit: 0, + }), + ], + }); + }); + + it("fetches Grok usage from live billing shape (config.used.val)", async () => { + process.env["GROK_API_KEY"] = "grok_test_token"; + fetchApi = mockFetch( + new Map([ + [ + "https://cli-chat-proxy.grok.com/v1/billing?format=credits", + () => + jsonResponse({ + config: { + monthlyLimit: { val: 150000 }, + used: { val: 37886 }, + billingPeriodStart: "2026-07-01T00:00:00+00:00", + billingPeriodEnd: "2026-08-01T00:00:00+00:00", + }, + }), + ], + ]), + ); + + const grok = findProvider(await service().listUsage(), "grok"); + + expect(grok).toMatchObject({ + status: "available", + balances: [ + expect.objectContaining({ + id: "monthly_credits", + used: 37886, + remaining: 112114, + limit: 150000, + unit: "credits", + }), + ], + }); + }); + + it("fetches Grok usage with nested ~/.grok/auth.json key token", async () => { + writeGrokAuth(homeDir, { + "https://auth.x.ai::test-user-id": { + key: "nested_jwt_token", + refresh_token: "rt_nested", + expires_at: "2026-08-01T00:00:00Z", + user_id: "test-user-id", + email: "user@example.com", + }, + }); + + let authorization: string | null = null; + fetchApi = (async (_url: RequestInfo | URL, init?: RequestInit) => { + authorization = (init?.headers as Record | undefined)?.Authorization ?? null; + return jsonResponse({ + config: { + monthlyLimit: { val: 100 }, + used: { val: 25 }, + }, + }); + }) as typeof fetch; + + const grok = findProvider(await service().listUsage(), "grok"); + + expect(authorization).toBe("Bearer nested_jwt_token"); + expect(grok).toMatchObject({ + status: "available", + balances: [ + expect.objectContaining({ + id: "monthly_credits", + used: 25, + remaining: 75, + limit: 100, + }), + ], + }); + }); + + it("still accepts legacy Grok usage.creditUsage when config.used is absent", async () => { + process.env["GROK_API_KEY"] = "grok_test_token"; + fetchApi = mockFetch( + new Map([ + [ + "https://cli-chat-proxy.grok.com/v1/billing?format=credits", + () => + jsonResponse({ + config: { monthlyLimit: { val: 50 } }, + usage: { creditUsage: 10 }, + }), + ], + ]), + ); + + const grok = findProvider(await service().listUsage(), "grok"); + + expect(grok).toMatchObject({ + status: "available", + balances: [ + expect.objectContaining({ + id: "monthly_credits", + used: 10, + remaining: 40, + limit: 50, + }), + ], + }); + }); + + it("fetches Grok unified-billing usage as a weekly window", async () => { + process.env["GROK_API_KEY"] = "grok_test_token"; + fetchApi = mockFetch( + new Map([ + [ + "https://cli-chat-proxy.grok.com/v1/billing?format=credits", + () => + jsonResponse({ + config: { + currentPeriod: { + type: "USAGE_PERIOD_TYPE_WEEKLY", + start: "2026-08-24T09:41:40.001370+00:00", + end: "2026-08-31T09:41:40.001370+00:00", + }, + creditUsagePercent: 76.0, + isUnifiedBillingUser: true, + billingPeriodStart: "2026-08-24T09:41:40.001370+00:00", + billingPeriodEnd: "2026-08-31T09:41:40.001370+00:00", + }, + }), + ], + ]), + ); + + const grok = findProvider(await service().listUsage(), "grok"); + + expect(grok).toMatchObject({ + status: "available", + windows: [ + { + id: "weekly", + label: "Weekly", + usedPct: 76, + remainingPct: 24, + resetsAt: "2026-08-31T09:41:40.001370+00:00", + tone: "warning", + }, + ], + balances: [], + }); + }); +}); diff --git a/packages/server/src/plugins/grok-usage-source/server/usage.ts b/packages/server/src/plugins/grok-usage-source/server/usage.ts index 4e43d7c627..9cd86cefd2 100644 --- a/packages/server/src/plugins/grok-usage-source/server/usage.ts +++ b/packages/server/src/plugins/grok-usage-source/server/usage.ts @@ -1,21 +1,24 @@ +import type { UsageInput } from "../shared/input.js"; import { existsSync, promises as fs } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import { z } from "zod"; import { - ApiNumberSchema, - ApiOptionalStringSchema, toneFromUsedPct, usedPctOf, - fetchProviderApi, unavailableUsage, windowFromUsedPct, type UsageReport, type UsageWindow, type UsageBalance, - type UsageApiFetch, } from "@getpaseo/plugin/server/usage"; +const ApiNumberSchema = z.coerce.number().finite(); +const ApiOptionalStringSchema = z.preprocess( + (value) => (value == null ? undefined : value), + z.coerce.string().optional(), +); + const GrokUsageResponseSchema = z.object({ config: z .object({ @@ -45,13 +48,6 @@ const GrokUsageResponseSchema = z.object({ .nullish(), }); -interface GrokQuotaProviderOptions { - logger: Console; - fetch?: UsageApiFetch; - /** Override home directory (tests). Production uses os.homedir(). */ - homeDir?: string; -} - /** Resolve a Grok CLI token from ~/.grok/auth.json (legacy or current nested shape). */ export function extractGrokTokenFromAuth(auth: unknown): string | null { if (auth == null || typeof auth !== "object" || Array.isArray(auth)) return null; @@ -108,60 +104,16 @@ function grokUsageWindow(response: z.infer): Usa }); } -export class GrokQuotaProvider { - private readonly logger: Console; - private readonly fetchApi: UsageApiFetch; - private readonly homeDir: string | undefined; +export async function fetchUsage( + input: UsageInput, + fetchApi: typeof fetch = fetch, +): Promise { + void input; + const homeDir = homedir(); - constructor(options: GrokQuotaProviderOptions) { - this.logger = options.logger; - this.fetchApi = options.fetch ?? fetch; - this.homeDir = options.homeDir; - } - - async fetchUsage(): Promise { - const token = - process.env["GROK_API_KEY"] || process.env["GROK_TOKEN"] || (await this.readGrokToken()); - - if (!token) return unavailableUsage(); - - // The Grok CLI's /usage uses ?format=credits; without it, unified-billing accounts - // get a zeroed legacy monthly shape (monthlyLimit.val 0) instead of real usage. - const res = await fetchProviderApi( - this.fetchApi, - "https://cli-chat-proxy.grok.com/v1/billing?format=credits", - { - headers: { - Authorization: `Bearer ${token}`, - "X-XAI-Token-Auth": "xai-grok-cli", - Accept: "application/json", - }, - }, - ); - - if (!res.ok) { - this.logger.debug({ status: res.status }, "Grok usage fetch failed"); - return unavailableUsage(); - } - - const resp = GrokUsageResponseSchema.parse(await res.json()); - const balance = grokMonthlyCreditBalance(resp); - const window = grokUsageWindow(resp); - if (window) window.headline = true; - - return { - account: { key: "default" }, - status: "available", - planLabel: undefined, - windows: window ? [window] : [], - balances: balance ? [balance] : [], - details: [], - }; - } - - private async readGrokToken(): Promise { + async function readGrokToken(): Promise { // homeDir override is for tests: Windows os.homedir() ignores $HOME (uses USERPROFILE). - const path = join(this.homeDir ?? homedir(), ".grok", "auth.json"); + const path = join(homeDir ?? homedir(), ".grok", "auth.json"); if (!existsSync(path)) return null; try { return extractGrokTokenFromAuth(JSON.parse(await fs.readFile(path, "utf8"))); @@ -169,4 +121,37 @@ export class GrokQuotaProvider { return null; } } + + const token = process.env["GROK_API_KEY"] || process.env["GROK_TOKEN"] || (await readGrokToken()); + + if (!token) return unavailableUsage(); + + // The Grok CLI's /usage uses ?format=credits; without it, unified-billing accounts + // get a zeroed legacy monthly shape (monthlyLimit.val 0) instead of real usage. + const res = await fetchApi("https://cli-chat-proxy.grok.com/v1/billing?format=credits", { + signal: AbortSignal.timeout(15_000), + headers: { + Authorization: `Bearer ${token}`, + "X-XAI-Token-Auth": "xai-grok-cli", + Accept: "application/json", + }, + }); + + if (!res.ok) { + return unavailableUsage(); + } + + const resp = GrokUsageResponseSchema.parse(await res.json()); + const balance = grokMonthlyCreditBalance(resp); + const window = grokUsageWindow(resp); + if (window) window.headline = true; + + return { + account: { key: "default" }, + status: "available", + planLabel: undefined, + windows: window ? [window] : [], + balances: balance ? [balance] : [], + details: [], + }; } diff --git a/packages/server/src/plugins/grok-usage-source/shared/input.ts b/packages/server/src/plugins/grok-usage-source/shared/input.ts index 79498e9cd5..9ee4593341 100644 --- a/packages/server/src/plugins/grok-usage-source/shared/input.ts +++ b/packages/server/src/plugins/grok-usage-source/shared/input.ts @@ -1,2 +1,4 @@ import { z } from "zod"; export const inputSchema = z.object({}).strict(); + +export type UsageInput = z.infer; diff --git a/packages/server/src/plugins/kimi-usage-source/index.server.ts b/packages/server/src/plugins/kimi-usage-source/index.server.ts index eeb938a255..45e7486c7b 100644 --- a/packages/server/src/plugins/kimi-usage-source/index.server.ts +++ b/packages/server/src/plugins/kimi-usage-source/index.server.ts @@ -1,6 +1,6 @@ import type { PluginServerContext } from "@getpaseo/plugin/server"; import { inputSchema } from "./shared/input.js"; -import { KimiQuotaProvider } from "./server/usage.js"; +import { fetchUsage } from "./server/usage.js"; export default function contribute(server: PluginServerContext) { server.registerUsageSource({ @@ -9,7 +9,7 @@ export default function contribute(server: PluginServerContext) { icon: "icon.svg", input: inputSchema, discover: async () => [{}], - fetch: async (_input) => new KimiQuotaProvider({ logger: console }).fetchUsage(), + fetch: fetchUsage, }); return () => {}; } diff --git a/packages/server/src/plugins/kimi-usage-source/paseo-plugin.json b/packages/server/src/plugins/kimi-usage-source/paseo-plugin.json index c81434ce3b..1944cc1841 100644 --- a/packages/server/src/plugins/kimi-usage-source/paseo-plugin.json +++ b/packages/server/src/plugins/kimi-usage-source/paseo-plugin.json @@ -1 +1 @@ -{ "id": "kimi-usage-source", "requirements": { "paseo": ">=0.9.2" } } +{ "id": "kimi-usage-source" } diff --git a/packages/server/src/plugins/kimi-usage-source/server/usage.test.ts b/packages/server/src/plugins/kimi-usage-source/server/usage.test.ts new file mode 100644 index 0000000000..d772517034 --- /dev/null +++ b/packages/server/src/plugins/kimi-usage-source/server/usage.test.ts @@ -0,0 +1,370 @@ +import { mkdirSync, readFileSync, writeFileSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { fetchUsage } from "./usage.js"; +import type { UsageReport } from "@getpaseo/plugin/server/usage"; + +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( + 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, + }), + ); +} + +function mockFetch(handlers: Map Response>): typeof fetch { + return vi.fn(async (url: RequestInfo | URL) => { + const key = url.toString(); + const handler = handlers.get(key); + if (!handler) throw new Error(`Unmocked fetch: ${key}`); + return handler(); + }) as unknown as typeof fetch; +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +describe("kimi usage source", () => { + let homeDir: string; + let fetchApi: typeof fetch; + let originalEnv: Record; + beforeEach(() => { + homeDir = mkdtempSync(join(tmpdir(), "usage-home-")); + originalEnv = { ...process.env }; + process.env["HOME"] = homeDir; + process.env["USERPROFILE"] = homeDir; + for (const key of [ + "APPDATA", + "COPILOT_TOKEN", + "GITHUB_TOKEN", + "GITHUB_PAT", + "CURSOR_ACCESS_TOKEN", + "CURSOR_TOKEN", + "ZAI_API_KEY", + "GLM_API_KEY", + "GROK_API_KEY", + "GROK_TOKEN", + "KIMI_TOKEN", + "KIMI_API_KEY", + "KIMI_CODE_HOME", + "MINIMAX_API_KEY", + "MINIMAX_BASE_URL", + ]) + delete process.env[key]; + fetchApi = mockFetch(new Map()); + }); + afterEach(() => { + rmSync(homeDir, { recursive: true, force: true }); + for (const key of Object.keys(process.env)) if (!(key in originalEnv)) delete process.env[key]; + for (const key in originalEnv) process.env[key] = originalEnv[key]; + }); + function service( + _options: { + platform?: typeof process.platform; + keychain?: () => Promise; + cursorHomeDir?: string; + kimiHomeDir?: string; + } = {}, + ) { + return { + listUsage: async () => { + const report = await fetchUsage({}, (url, init) => fetchApi(url, init)); + return { + providers: [ + { + providerId: "kimi", + ...report, + error: report.error ?? null, + planLabel: report.planLabel ?? null, + }, + ], + }; + }, + }; + } + function findProvider( + result: { providers: Array<{ providerId: string } & UsageReport> }, + id: string, + ) { + const report = result.providers.find((item) => item.providerId === id); + if (!report) throw new Error(`Missing usage source ${id}`); + return report; + } + it("fetches Kimi usage from KIMI_TOKEN", async () => { + process.env["KIMI_TOKEN"] = "kimi_test_token"; + fetchApi = mockFetch( + new Map([ + [ + "https://api.kimi.com/coding/v1/usages", + () => + jsonResponse({ + usage: { + limit: "100", + remaining: "74", + resetTime: "2026-02-11T17:32:50Z", + }, + }), + ], + ]), + ); + + const kimi = findProvider(await service().listUsage(), "kimi"); + + expect(kimi).toMatchObject({ + status: "available", + windows: [ + expect.objectContaining({ + id: "coding_usage", + usedPct: 26, + remainingPct: 74, + resetsAt: "2026-02-11T17:32:50Z", + }), + ], + }); + }); + + it("fetches Kimi usage from the CLI credential home", async () => { + writeKimiCredentials(join(homeDir, ".kimi-code"), "kimi_cli_token"); + let requestedUrl: string | null = null; + let authorization: string | null = null; + fetchApi = (async (url: RequestInfo | URL, init?: RequestInit) => { + requestedUrl = url.toString(); + authorization = (init?.headers as Record | undefined)?.Authorization ?? null; + return jsonResponse({ + usage: { + limit: "200", + remaining: "150", + resetTime: "2026-06-23T05:12:17Z", + }, + }); + }) as unknown as typeof fetch; + + const kimi = findProvider(await service({ kimiHomeDir: homeDir }).listUsage(), "kimi"); + + expect(requestedUrl).toBe("https://api.kimi.com/coding/v1/usages"); + expect(authorization).toBe("Bearer kimi_cli_token"); + expect(kimi).toMatchObject({ + status: "available", + windows: [ + expect.objectContaining({ + id: "coding_usage", + usedPct: 25, + remainingPct: 75, + resetsAt: "2026-06-23T05:12:17Z", + }), + ], + }); + }); + + 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("returns unavailable on 401 without refreshing or rewriting the credential file", async () => { + writeKimiCredentials(join(homeDir, ".kimi-code"), "at_kimi_expired"); + const credPath = kimiCredentialPath(join(homeDir, ".kimi-code")); + const before = readFileSync(credPath, "utf8"); + let usageCalls = 0; + fetchApi = vi.fn(async (url: RequestInfo | URL) => { + const endpoint = url.toString(); + if (endpoint === "https://api.kimi.com/coding/v1/usages") { + usageCalls += 1; + return new Response(null, { status: 401 }); + } + // The read-only fetcher must never hit the OAuth token endpoint. + throw new Error(`Unmocked: ${endpoint}`); + }) as never; + + const result = await service({ kimiHomeDir: homeDir }).listUsage(); + + expect(findProvider(result, "kimi").status).toBe("unavailable"); + expect(usageCalls).toBe(1); + // The credentials file must be left untouched for the Kimi CLI to own. + expect(readFileSync(credPath, "utf8")).toBe(before); + }); + + 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); + }); +}); + +describe("Kimi usage source 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 usage = await fetchUsage({}, fetchApi); + + 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 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 usage = await fetchUsage({}, fetchApi); + + expect(usage.windows).toHaveLength(2); + expect(usage.windows[1]).toMatchObject({ + label: "5-hour limit", + usedPct: 50, + remainingPct: 50, + }); + }); + + 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 usage = await fetchUsage({}, fetchApi); + + 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 usage = await fetchUsage({}, fetchApi); + + expect(usage.windows.map((window) => window.id)).toEqual([ + "coding_limit_300_time_unit_minute", + "coding_limit_300_time_unit_minute_2", + ]); + }); +}); diff --git a/packages/server/src/plugins/kimi-usage-source/server/usage.ts b/packages/server/src/plugins/kimi-usage-source/server/usage.ts index 2d27f6293e..1cf4e32653 100644 --- a/packages/server/src/plugins/kimi-usage-source/server/usage.ts +++ b/packages/server/src/plugins/kimi-usage-source/server/usage.ts @@ -1,19 +1,22 @@ +import type { UsageInput } from "../shared/input.js"; import { existsSync, promises as fs } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import { z } from "zod"; import { - ApiNumberSchema, - ApiOptionalStringSchema, - fetchProviderApi, toneFromUsedPct, unavailableUsage, windowFromUsedPct, type UsageReport, type UsageWindow, - type UsageApiFetch, } from "@getpaseo/plugin/server/usage"; +const ApiNumberSchema = z.coerce.number().finite(); +const ApiOptionalStringSchema = z.preprocess( + (value) => (value == null ? undefined : value), + z.coerce.string().optional(), +); + const KIMI_USAGE_URL = "https://api.kimi.com/coding/v1/usages"; const KimiUsageFieldsSchema = z.object({ @@ -187,10 +190,7 @@ function uniqueWindowId(baseId: string, seenIds: Set): string { return id; } -function kimiUsageWindowsFromPayload( - payload: unknown, - logger: Pick, -): UsageWindow[] { +function kimiUsageWindowsFromPayload(payload: unknown): UsageWindow[] { const response = KimiUsageResponseSchema.parse(payload); const windows: UsageWindow[] = []; const seenWindowIds = new Set(); @@ -210,14 +210,12 @@ function kimiUsageWindowsFromPayload( 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; } @@ -249,50 +247,16 @@ const KimiAuthSchema = z type KimiAuth = z.infer; type KimiCredentials = KimiAuth & { access_token: string }; -interface KimiQuotaProviderOptions { - logger: Console; - fetch?: UsageApiFetch; - homeDir?: string; -} +export async function fetchUsage( + input: UsageInput, + fetchApi: typeof fetch = fetch, +): Promise { + void input; + const homeDir = homedir(); -export class KimiQuotaProvider { - private readonly logger: Console; - private readonly fetchApi: UsageApiFetch; - private readonly homeDir?: string; - - constructor(options: KimiQuotaProviderOptions) { - this.logger = options.logger; - this.fetchApi = options.fetch ?? fetch; - this.homeDir = options.homeDir; - } - - async fetchUsage(): Promise { - const credentials = await this.readCredentials(); - if (!credentials) return unavailableUsage(); - - const res = await this.callUsageApi(credentials.access_token); - - if (!res.ok) { - // Read-only on credentials; the Kimi CLI owns refresh. See docs/providers.md. - this.logger.debug({ status: res.status }, "Kimi usage fetch failed"); - return unavailableUsage(); - } - - const windows = kimiUsageWindowsFromPayload(await res.json(), this.logger); - if (windows[0]) windows[0].headline = true; - - return { - account: { key: "default" }, - status: "available", - planLabel: undefined, - windows, - balances: [], - details: [], - }; - } - - private async callUsageApi(token: string): Promise { - return fetchProviderApi(this.fetchApi, KIMI_USAGE_URL, { + async function callUsageApi(token: string): Promise { + return fetchApi(KIMI_USAGE_URL, { + signal: AbortSignal.timeout(15_000), headers: { Authorization: `Bearer ${token}`, Accept: "application/json", @@ -300,14 +264,14 @@ export class KimiQuotaProvider { }); } - private async readCredentials(): Promise { + async function readCredentials(): Promise { const environmentToken = process.env["KIMI_TOKEN"] || process.env["KIMI_API_KEY"]; if (environmentToken) { return { access_token: environmentToken }; } - for (const path of this.credentialPaths()) { - const credentials = await this.readCredentialFile(path); + for (const path of credentialPaths()) { + const credentials = await readCredentialFile(path); if (credentials?.access_token) { return { ...credentials, access_token: credentials.access_token }; } @@ -315,19 +279,19 @@ export class KimiQuotaProvider { return null; } - private credentialPaths(): string[] { - const homeDir = this.homeDir ?? homedir(); + function credentialPaths(): string[] { + const home = homeDir; return [ join( - process.env["KIMI_CODE_HOME"] || join(homeDir, ".kimi-code"), + process.env["KIMI_CODE_HOME"] || join(home, ".kimi-code"), "credentials", "kimi-code.json", ), - join(homeDir, ".kimi", "credentials", "kimi-code.json"), + join(home, ".kimi", "credentials", "kimi-code.json"), ]; } - private async readCredentialFile(path: string): Promise { + async function readCredentialFile(path: string): Promise { if (!existsSync(path)) return null; try { return KimiAuthSchema.parse(JSON.parse(await fs.readFile(path, "utf8"))); @@ -335,4 +299,27 @@ export class KimiQuotaProvider { return null; } } + + const credentials = await readCredentials(); + if (!credentials) return unavailableUsage(); + + const res = await callUsageApi(credentials.access_token); + + if (!res.ok) { + // Read-only on credentials; the Kimi CLI owns refresh. See docs/providers.md. + + return unavailableUsage(); + } + + const windows = kimiUsageWindowsFromPayload(await res.json()); + if (windows[0]) windows[0].headline = true; + + return { + account: { key: "default" }, + status: "available", + planLabel: undefined, + windows, + balances: [], + details: [], + }; } diff --git a/packages/server/src/plugins/kimi-usage-source/shared/input.ts b/packages/server/src/plugins/kimi-usage-source/shared/input.ts index 79498e9cd5..9ee4593341 100644 --- a/packages/server/src/plugins/kimi-usage-source/shared/input.ts +++ b/packages/server/src/plugins/kimi-usage-source/shared/input.ts @@ -1,2 +1,4 @@ import { z } from "zod"; export const inputSchema = z.object({}).strict(); + +export type UsageInput = z.infer; diff --git a/packages/server/src/plugins/minimax-usage-source/index.server.ts b/packages/server/src/plugins/minimax-usage-source/index.server.ts index 8989230452..f4b11cbc47 100644 --- a/packages/server/src/plugins/minimax-usage-source/index.server.ts +++ b/packages/server/src/plugins/minimax-usage-source/index.server.ts @@ -1,6 +1,6 @@ import type { PluginServerContext } from "@getpaseo/plugin/server"; import { inputSchema } from "./shared/input.js"; -import { MiniMaxQuotaProvider } from "./server/usage.js"; +import { fetchUsage } from "./server/usage.js"; export default function contribute(server: PluginServerContext) { server.registerUsageSource({ @@ -9,7 +9,7 @@ export default function contribute(server: PluginServerContext) { icon: "icon.svg", input: inputSchema, discover: async () => [{}], - fetch: async (_input) => new MiniMaxQuotaProvider({ logger: console }).fetchUsage(), + fetch: fetchUsage, }); return () => {}; } diff --git a/packages/server/src/plugins/minimax-usage-source/paseo-plugin.json b/packages/server/src/plugins/minimax-usage-source/paseo-plugin.json index 61ecc81c8c..0037a04482 100644 --- a/packages/server/src/plugins/minimax-usage-source/paseo-plugin.json +++ b/packages/server/src/plugins/minimax-usage-source/paseo-plugin.json @@ -1 +1 @@ -{ "id": "minimax-usage-source", "requirements": { "paseo": ">=0.9.2" } } +{ "id": "minimax-usage-source" } diff --git a/packages/server/src/plugins/minimax-usage-source/server/usage.test.ts b/packages/server/src/plugins/minimax-usage-source/server/usage.test.ts new file mode 100644 index 0000000000..620326f0be --- /dev/null +++ b/packages/server/src/plugins/minimax-usage-source/server/usage.test.ts @@ -0,0 +1,315 @@ +import { mkdirSync, writeFileSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { fetchUsage } from "./usage.js"; +import type { UsageReport } from "@getpaseo/plugin/server/usage"; + +function writeMiniMaxConfig(dir: string, payload: Record): void { + mkdirSync(join(dir, ".mmx"), { recursive: true }); + writeFileSync(join(dir, ".mmx", "config.json"), JSON.stringify(payload)); +} + +function writeMiniMaxCredentials( + dir: string, + accessToken: string, + expiresAt?: string, + resourceUrl?: string, +): void { + mkdirSync(join(dir, ".mmx"), { recursive: true }); + const payload: Record = { access_token: accessToken }; + if (expiresAt !== undefined) payload["expires_at"] = expiresAt; + if (resourceUrl !== undefined) payload["resource_url"] = resourceUrl; + writeFileSync(join(dir, ".mmx", "credentials.json"), JSON.stringify(payload)); +} + +function mockFetch(handlers: Map Response>): typeof fetch { + return vi.fn(async (url: RequestInfo | URL) => { + const key = url.toString(); + const handler = handlers.get(key); + if (!handler) throw new Error(`Unmocked fetch: ${key}`); + return handler(); + }) as unknown as typeof fetch; +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +describe("minimax usage source", () => { + let homeDir: string; + let fetchApi: typeof fetch; + let originalEnv: Record; + beforeEach(() => { + homeDir = mkdtempSync(join(tmpdir(), "usage-home-")); + originalEnv = { ...process.env }; + process.env["HOME"] = homeDir; + process.env["USERPROFILE"] = homeDir; + for (const key of [ + "APPDATA", + "COPILOT_TOKEN", + "GITHUB_TOKEN", + "GITHUB_PAT", + "CURSOR_ACCESS_TOKEN", + "CURSOR_TOKEN", + "ZAI_API_KEY", + "GLM_API_KEY", + "GROK_API_KEY", + "GROK_TOKEN", + "KIMI_TOKEN", + "KIMI_API_KEY", + "KIMI_CODE_HOME", + "MINIMAX_API_KEY", + "MINIMAX_BASE_URL", + ]) + delete process.env[key]; + fetchApi = mockFetch(new Map()); + }); + afterEach(() => { + rmSync(homeDir, { recursive: true, force: true }); + for (const key of Object.keys(process.env)) if (!(key in originalEnv)) delete process.env[key]; + for (const key in originalEnv) process.env[key] = originalEnv[key]; + }); + function service( + _options: { + platform?: typeof process.platform; + keychain?: () => Promise; + cursorHomeDir?: string; + kimiHomeDir?: string; + } = {}, + ) { + return { + listUsage: async () => { + const report = await fetchUsage({}, (url, init) => fetchApi(url, init)); + return { + providers: [ + { + providerId: "minimax", + ...report, + error: report.error ?? null, + planLabel: report.planLabel ?? null, + }, + ], + }; + }, + }; + } + function findProvider( + result: { providers: Array<{ providerId: string } & UsageReport> }, + id: string, + ) { + const report = result.providers.find((item) => item.providerId === id); + if (!report) throw new Error(`Missing usage source ${id}`); + return report; + } + 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; + let authorization: string | null = null; + fetchApi = (async (url: RequestInfo | URL, init?: RequestInit) => { + requestedUrl = url.toString(); + authorization = (init?.headers as Record | undefined)?.Authorization ?? null; + return jsonResponse({ + model_remains: [ + { + model_name: "MiniMax-M2.7", + end_time: Date.parse("2026-06-19T05:00:00.000Z"), + weekly_end_time: Date.parse("2026-06-26T00:00:00.000Z"), + current_interval_total_count: 1000, + current_interval_usage_count: 250, + current_interval_remaining_percent: 75, + current_weekly_total_count: 5000, + current_weekly_usage_count: 1200, + current_weekly_remaining_percent: 76, + }, + ], + }); + }) as unknown as typeof fetch; + + const miniMax = findProvider(await service().listUsage(), "minimax"); + + expect(requestedUrl).toBe("https://api.minimax.io/v1/token_plan/remains"); + expect(authorization).toBe("Bearer minimax_test_token"); + expect(miniMax).toMatchObject({ + status: "available", + windows: expect.arrayContaining([ + expect.objectContaining({ + id: "interval_MiniMax-M2.7", + label: "MiniMax-M2.7 · Interval", + usedPct: 25, + remainingPct: 75, + resetsAt: "2026-06-19T05:00:00.000Z", + }), + expect.objectContaining({ + id: "weekly_MiniMax-M2.7", + label: "MiniMax-M2.7 · Weekly", + usedPct: 24, + remainingPct: 76, + resetsAt: "2026-06-26T00:00:00.000Z", + }), + ]), + }); + }); + + it("returns unavailable MiniMax usage when no credentials are configured", async () => { + fetchApi = vi.fn() as never; + + const miniMax = findProvider(await service().listUsage(), "minimax"); + + expect(miniMax.status).toBe("unavailable"); + expect(fetchApi).not.toHaveBeenCalled(); + }); + + it("reads MiniMax OAuth credentials from the CLI credentials file", async () => { + writeMiniMaxCredentials( + homeDir, + "minimax_oauth_token", + "2030-01-01T00:00:00.000Z", + "https://account.example.com", + ); + let requestedUrl: string | null = null; + fetchApi = (async (url: RequestInfo | URL) => { + requestedUrl = url.toString(); + return jsonResponse({ model_remains: [] }); + }) as unknown as typeof fetch; + + await service().listUsage(); + + expect(requestedUrl).toBe("https://account.example.com/v1/token_plan/remains"); + }); + + it("falls back to MiniMax api_key in the CLI config file", async () => { + writeMiniMaxConfig(homeDir, { + api_key: "minimax_config_key", + region: "cn", + }); + let requestedUrl: string | null = null; + fetchApi = (async (url: RequestInfo | URL) => { + requestedUrl = url.toString(); + return jsonResponse({ model_remains: [] }); + }) as unknown as typeof fetch; + + const miniMax = findProvider(await service().listUsage(), "minimax"); + + expect(requestedUrl).toBe("https://api.minimaxi.com/v1/token_plan/remains"); + expect(miniMax.status).toBe("unavailable"); + }); + + it("reports MiniMax accounts with no active token plan as unavailable", async () => { + writeMiniMaxConfig(homeDir, { api_key: "minimax_config_key", region: "cn" }); + fetchApi = mockFetch( + new Map([ + [ + "https://api.minimaxi.com/v1/token_plan/remains", + () => + jsonResponse({ + model_remains: null, + base_resp: { status_code: 2062, status_msg: "no active token plan subscription" }, + }), + ], + ]), + ); + + const miniMax = findProvider(await service().listUsage(), "minimax"); + + expect(miniMax).toMatchObject({ status: "unavailable", windows: [], error: null }); + }); + + it("reports a null MiniMax token plan as unavailable even without base_resp", async () => { + process.env["MINIMAX_API_KEY"] = "minimax_test_token"; + fetchApi = mockFetch( + new Map([ + [ + "https://api.minimax.io/v1/token_plan/remains", + () => jsonResponse({ model_remains: null }), + ], + ]), + ); + + const miniMax = findProvider(await service().listUsage(), "minimax"); + + expect(miniMax).toMatchObject({ status: "unavailable", windows: [], error: null }); + }); + + it("still reads MiniMax windows when base_resp reports success", async () => { + process.env["MINIMAX_API_KEY"] = "minimax_test_token"; + fetchApi = mockFetch( + new Map([ + [ + "https://api.minimax.io/v1/token_plan/remains", + () => + jsonResponse({ + base_resp: { status_code: 0, status_msg: "success" }, + model_remains: [ + { + model_name: "MiniMax-M2.7", + end_time: Date.parse("2026-06-19T05:00:00.000Z"), + current_interval_total_count: 100, + current_interval_usage_count: 25, + current_interval_remaining_percent: 75, + }, + ], + }), + ], + ]), + ); + + const miniMax = findProvider(await service().listUsage(), "minimax"); + + expect(miniMax).toMatchObject({ + status: "available", + windows: expect.arrayContaining([ + expect.objectContaining({ id: "interval_MiniMax-M2.7", usedPct: 25 }), + ]), + }); + }); + + it("marks exhausted MiniMax interval windows with a danger tone", async () => { + process.env["MINIMAX_API_KEY"] = "minimax_test_token"; + fetchApi = mockFetch( + new Map([ + [ + "https://api.minimax.io/v1/token_plan/remains", + () => + jsonResponse({ + model_remains: [ + { + model_name: "MiniMax-M2.7", + end_time: Date.parse("2026-06-19T05:00:00.000Z"), + weekly_end_time: Date.parse("2026-06-26T00:00:00.000Z"), + current_interval_total_count: 100, + current_interval_usage_count: 100, + current_interval_remaining_percent: 0, + current_interval_status: 2, + current_weekly_total_count: 100, + current_weekly_usage_count: 10, + current_weekly_remaining_percent: 90, + current_weekly_status: 1, + }, + ], + }), + ], + ]), + ); + + const miniMax = findProvider(await service().listUsage(), "minimax"); + + expect(miniMax).toMatchObject({ + status: "available", + windows: expect.arrayContaining([ + expect.objectContaining({ + id: "interval_MiniMax-M2.7", + usedPct: 100, + tone: "danger", + }), + expect.objectContaining({ + id: "weekly_MiniMax-M2.7", + tone: "ok", + }), + ]), + }); + }); +}); diff --git a/packages/server/src/plugins/minimax-usage-source/server/usage.ts b/packages/server/src/plugins/minimax-usage-source/server/usage.ts index ef2236739a..f87110e79e 100644 --- a/packages/server/src/plugins/minimax-usage-source/server/usage.ts +++ b/packages/server/src/plugins/minimax-usage-source/server/usage.ts @@ -1,22 +1,23 @@ +import type { UsageInput } from "../shared/input.js"; import { existsSync, promises as fs } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import { z } from "zod"; import { - ApiNumberSchema, - ApiOptionalStringSchema, - fetchProviderApi, unavailableUsage, windowFromUsedPct, type UsageReport, type UsageWindow, - type UsageApiFetch, } from "@getpaseo/plugin/server/usage"; +const ApiNumberSchema = z.coerce.number().finite(); +const ApiOptionalStringSchema = z.preprocess( + (value) => (value == null ? undefined : value), + z.coerce.string().optional(), +); + const MINIMAX_GLOBAL_BASE_URL = "https://api.minimax.io"; const MINIMAX_CN_BASE_URL = "https://api.minimaxi.com"; -const MINIMAX_CREDENTIALS_PATH = join(homedir(), ".mmx", "credentials.json"); -const MINIMAX_CONFIG_PATH = join(homedir(), ".mmx", "config.json"); const MiniMaxModelRemainSchema = z.object({ model_name: ApiOptionalStringSchema, @@ -73,15 +74,6 @@ interface MiniMaxResolvedAuth { baseUrl: string; } -interface MiniMaxQuotaProviderOptions { - logger: Console; - fetch?: UsageApiFetch; - configPath?: string; - credentialsPath?: string; - env?: NodeJS.ProcessEnv; - now?: () => number; -} - function resolveBaseUrl(input: { baseUrl?: string; region?: string }): string { const explicit = input.baseUrl; if (explicit && explicit.startsWith("http")) return explicit; @@ -152,91 +144,35 @@ function toWeeklyWindow(modelName: string, model: MiniMaxModelRemain): UsageWind }); } -export class MiniMaxQuotaProvider { - private readonly logger: Console; - private readonly fetchApi: UsageApiFetch; - private readonly configPath: string; - private readonly credentialsPath: string; - private readonly env: NodeJS.ProcessEnv; - private readonly now: () => number; +export async function fetchUsage( + input: UsageInput, + fetchApi: typeof fetch = fetch, +): Promise { + void input; + const configPath = join(homedir(), ".mmx", "config.json"); + const credentialsPath = join(homedir(), ".mmx", "credentials.json"); + const env = process.env; + const now = Date.now; - constructor(options: MiniMaxQuotaProviderOptions) { - this.logger = options.logger; - this.fetchApi = options.fetch ?? fetch; - this.configPath = options.configPath ?? MINIMAX_CONFIG_PATH; - this.credentialsPath = options.credentialsPath ?? MINIMAX_CREDENTIALS_PATH; - this.env = options.env ?? process.env; - this.now = options.now ?? Date.now; - } - - async fetchUsage(): Promise { - const auth = await this.resolveAuth(); - if (!auth) return unavailableUsage(); - - const res = await fetchProviderApi(this.fetchApi, `${auth.baseUrl}/v1/token_plan/remains`, { - headers: { - Authorization: `Bearer ${auth.token}`, - Accept: "application/json", - }, - }); - - if (!res.ok) { - this.logger.debug({ status: res.status }, "MiniMax usage fetch failed"); - return unavailableUsage(); - } - - const resp = MiniMaxQuotaResponseSchema.parse(await res.json()); - - const statusCode = resp.base_resp?.status_code; - if (typeof statusCode === "number" && statusCode !== 0) { - this.logger.debug( - { statusCode, statusMsg: resp.base_resp?.status_msg }, - "MiniMax usage unavailable", - ); - return unavailableUsage(); - } - - const models = resp.model_remains ?? []; - - const windows: UsageWindow[] = []; - for (const model of models) { - const name = model.model_name ?? "token-plan"; - const intervalWindow = toIntervalWindow(name, model); - if (intervalWindow) windows.push(intervalWindow); - const weeklyWindow = toWeeklyWindow(name, model); - if (weeklyWindow) windows.push(weeklyWindow); - } - if (windows[0]) windows[0].headline = true; - - return { - account: { key: "default" }, - status: windows.length > 0 ? "available" : "unavailable", - planLabel: undefined, - windows, - balances: [], - details: [], - }; - } - - private async resolveAuth(): Promise { - const envToken = this.env["MINIMAX_API_KEY"]; + async function resolveAuth(): Promise { + const envToken = env["MINIMAX_API_KEY"]; if (envToken) { - const envBase = this.env["MINIMAX_BASE_URL"]; + const envBase = env["MINIMAX_BASE_URL"]; return { token: envToken, baseUrl: resolveBaseUrl({ baseUrl: envBase }), }; } - const credentials = await this.readCredentials(); - if (credentials?.access_token && !this.isExpired(credentials.expires_at)) { + const credentials = await readCredentials(); + if (credentials?.access_token && !isExpired(credentials.expires_at)) { return { token: credentials.access_token, baseUrl: resolveBaseUrl({ baseUrl: credentials.resource_url }), }; } - const config = await this.readConfig(); + const config = await readConfig(); if (config?.api_key) { return { token: config.api_key, @@ -247,7 +183,7 @@ export class MiniMaxQuotaProvider { }; } - if (config?.oauth?.access_token && !this.isExpired(config.oauth.expires_at)) { + if (config?.oauth?.access_token && !isExpired(config.oauth.expires_at)) { return { token: config.oauth.access_token, baseUrl: resolveBaseUrl({ @@ -260,30 +196,73 @@ export class MiniMaxQuotaProvider { return null; } - private isExpired(expiresAt: string | null | undefined): boolean { + function isExpired(expiresAt: string | null | undefined): boolean { if (!expiresAt) return false; const parsed = Date.parse(expiresAt); if (!Number.isFinite(parsed)) return false; - return parsed <= this.now(); + return parsed <= now(); } - private async readCredentials(): Promise | null> { - if (!existsSync(this.credentialsPath)) return null; + async function readCredentials(): Promise | null> { + if (!existsSync(credentialsPath)) return null; try { - const raw = JSON.parse(await fs.readFile(this.credentialsPath, "utf8")); + const raw = JSON.parse(await fs.readFile(credentialsPath, "utf8")); return MiniMaxCredentialsSchema.parse(raw); } catch { return null; } } - private async readConfig(): Promise | null> { - if (!existsSync(this.configPath)) return null; + async function readConfig(): Promise | null> { + if (!existsSync(configPath)) return null; try { - const raw = JSON.parse(await fs.readFile(this.configPath, "utf8")); + const raw = JSON.parse(await fs.readFile(configPath, "utf8")); return MiniMaxConfigSchema.parse(raw); } catch { return null; } } + + const auth = await resolveAuth(); + if (!auth) return unavailableUsage(); + + const res = await fetchApi(`${auth.baseUrl}/v1/token_plan/remains`, { + signal: AbortSignal.timeout(15_000), + headers: { + Authorization: `Bearer ${auth.token}`, + Accept: "application/json", + }, + }); + + if (!res.ok) { + return unavailableUsage(); + } + + const resp = MiniMaxQuotaResponseSchema.parse(await res.json()); + + const statusCode = resp.base_resp?.status_code; + if (typeof statusCode === "number" && statusCode !== 0) { + return unavailableUsage(); + } + + const models = resp.model_remains ?? []; + + const windows: UsageWindow[] = []; + for (const model of models) { + const name = model.model_name ?? "token-plan"; + const intervalWindow = toIntervalWindow(name, model); + if (intervalWindow) windows.push(intervalWindow); + const weeklyWindow = toWeeklyWindow(name, model); + if (weeklyWindow) windows.push(weeklyWindow); + } + if (windows[0]) windows[0].headline = true; + + return { + account: { key: "default" }, + status: windows.length > 0 ? "available" : "unavailable", + planLabel: undefined, + windows, + balances: [], + details: [], + }; } diff --git a/packages/server/src/plugins/minimax-usage-source/shared/input.ts b/packages/server/src/plugins/minimax-usage-source/shared/input.ts index 79498e9cd5..9ee4593341 100644 --- a/packages/server/src/plugins/minimax-usage-source/shared/input.ts +++ b/packages/server/src/plugins/minimax-usage-source/shared/input.ts @@ -1,2 +1,4 @@ import { z } from "zod"; export const inputSchema = z.object({}).strict(); + +export type UsageInput = z.infer; diff --git a/packages/server/src/plugins/usage-sources.test.ts b/packages/server/src/plugins/usage-sources.test.ts deleted file mode 100644 index 8faca659d2..0000000000 --- a/packages/server/src/plugins/usage-sources.test.ts +++ /dev/null @@ -1,1654 +0,0 @@ -import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { mkdtempSync, rmSync } from "node:fs"; -import { createRequire } from "node:module"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { ProviderUsage } from "../../server/messages.js"; -import { ClaudeQuotaProvider } from "./claude-usage-source/server/usage.js"; -import { CopilotQuotaProvider } from "./copilot-usage-source/server/usage.js"; -import { CursorQuotaProvider } from "./cursor-usage-source/server/usage.js"; -import { GrokQuotaProvider } from "./grok-usage-source/server/usage.js"; -import { KimiQuotaProvider } from "./kimi-usage-source/server/usage.js"; -import { MiniMaxQuotaProvider } from "./minimax-usage-source/server/usage.js"; -import { ZaiQuotaProvider } from "./zai-usage-source/server/usage.js"; - -function writeClaudeCredentials( - dir: string, - accessToken: string, - refreshToken = "rt_test", - subscriptionType = "pro", - rateLimitTier = "default_1x", -): void { - writeFileSync( - join(dir, ".credentials.json"), - JSON.stringify({ - claudeAiOauth: { accessToken, refreshToken, subscriptionType, rateLimitTier }, - }), - ); -} - -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( - 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, - }), - ); -} - -// node:sqlite has no @types/node@20 typings; require it with a narrow local type. -const testRequire = createRequire(import.meta.url); -interface TestSqliteDb { - exec(sql: string): void; - prepare(sql: string): { run(...params: unknown[]): void }; - close(): void; -} - -// Cursor builds have stored ItemTable values as both TEXT and BLOB. Keys map to the -// real layouts: a plain modern token or the legacy JSON object. -function writeCursorStateDb(homeDir: string, rows: Record): void { - const dir = join(homeDir, ".config", "Cursor", "User", "globalStorage"); - mkdirSync(dir, { recursive: true }); - const { DatabaseSync } = testRequire("node:sqlite") as { - DatabaseSync: new (path: string) => TestSqliteDb; - }; - const db = new DatabaseSync(join(dir, "state.vscdb")); - db.exec("CREATE TABLE IF NOT EXISTS ItemTable (key TEXT UNIQUE ON CONFLICT REPLACE, value BLOB)"); - const insert = db.prepare("INSERT INTO ItemTable (key, value) VALUES (?, ?)"); - for (const [key, value] of Object.entries(rows)) { - insert.run(key, value); - } - db.close(); -} - -function writeCursorAuthJson(homeDir: string, accessToken: string): void { - const dir = join(homeDir, ".config", "cursor"); - mkdirSync(dir, { recursive: true }); - writeFileSync(join(dir, "auth.json"), JSON.stringify({ accessToken })); -} - -function writeGrokAuth(home: string, auth: Record): void { - mkdirSync(join(home, ".grok"), { recursive: true }); - writeFileSync(join(home, ".grok", "auth.json"), JSON.stringify(auth)); -} - -function writeMiniMaxConfig(dir: string, payload: Record): void { - mkdirSync(join(dir, ".mmx"), { recursive: true }); - writeFileSync(join(dir, ".mmx", "config.json"), JSON.stringify(payload)); -} - -function writeMiniMaxCredentials( - dir: string, - accessToken: string, - expiresAt?: string, - resourceUrl?: string, -): void { - mkdirSync(join(dir, ".mmx"), { recursive: true }); - const payload: Record = { access_token: accessToken }; - if (expiresAt !== undefined) payload["expires_at"] = expiresAt; - if (resourceUrl !== undefined) payload["resource_url"] = resourceUrl; - writeFileSync(join(dir, ".mmx", "credentials.json"), JSON.stringify(payload)); -} - -function makeClaudeResponse( - overrides: Partial<{ - five_hour: { utilization: number | string; resets_at: string }; - seven_day: { utilization: number | string; resets_at: string }; - seven_day_opus: { utilization: number | string; resets_at: string }; - }> = {}, -) { - return { - five_hour: { utilization: 11, resets_at: "2026-06-01T21:00:00Z" }, - seven_day: { utilization: 1, resets_at: "2026-06-04T00:00:00Z" }, - seven_day_opus: { utilization: 0.5, resets_at: "2026-06-04T00:00:00Z" }, - ...overrides, - }; -} - -function mockFetch(handlers: Map Response>): typeof fetch { - return vi.fn(async (url: RequestInfo | URL) => { - const key = url.toString(); - const handler = handlers.get(key); - if (!handler) throw new Error(`Unmocked fetch: ${key}`); - return handler(); - }) as unknown as typeof fetch; -} - -function jsonResponse(body: unknown, status = 200): Response { - return new Response(JSON.stringify(body), { - status, - headers: { "Content-Type": "application/json" }, - }); -} - -function createLogger() { - const logger = { - debug: vi.fn(), - warn: vi.fn(), - info: vi.fn(), - error: vi.fn(), - child: () => logger, - }; - return logger as never; -} - -function findProvider(result: { providers: ProviderUsage[] }, providerId: string): ProviderUsage { - const provider = result.providers.find((candidate) => candidate.providerId === providerId); - if (!provider) throw new Error(`Missing provider ${providerId}`); - return provider; -} - -describe("real provider usage fetchers", () => { - let claudeHome: string; - let codexHome: string; - let homeDir: string; - let fetchApi: typeof fetch; - let originalEnv: Record; - - beforeEach(() => { - claudeHome = mkdtempSync(join(tmpdir(), "usage-test-claude-")); - codexHome = mkdtempSync(join(tmpdir(), "usage-test-codex-")); - homeDir = mkdtempSync(join(tmpdir(), "usage-test-home-")); - fetchApi = mockFetch(new Map()); - originalEnv = { ...process.env }; - process.env["HOME"] = homeDir; - - for (const key of [ - "APPDATA", - "COPILOT_TOKEN", - "GITHUB_TOKEN", - "GITHUB_PAT", - "CURSOR_ACCESS_TOKEN", - "CURSOR_TOKEN", - "ZAI_API_KEY", - "GLM_API_KEY", - "GROK_API_KEY", - "GROK_TOKEN", - "KIMI_TOKEN", - "KIMI_API_KEY", - "KIMI_CODE_HOME", - "CODEX_HOME", - "MINIMAX_API_KEY", - "MINIMAX_BASE_URL", - ]) { - delete process.env[key]; - } - }); - - afterEach(() => { - rmSync(claudeHome, { recursive: true, force: true }); - rmSync(codexHome, { recursive: true, force: true }); - rmSync(homeDir, { recursive: true, force: true }); - for (const key in originalEnv) { - process.env[key] = originalEnv[key]; - } - for (const key of Object.keys(process.env)) { - if (!(key in originalEnv)) { - delete process.env[key]; - } - } - }); - - function service( - options: { - platform?: typeof process.platform; - keychain?: () => Promise; - kimiHomeDir?: string; - cursorHomeDir?: string; - miniMaxConfigPath?: string; - miniMaxCredentialsPath?: string; - } = {}, - ) { - const logger = createLogger(); - const fetchThroughTestDouble = ((url: RequestInfo | URL, init?: RequestInit) => - fetchApi(url, init)) as typeof fetch; - const fetchers = [ - new ClaudeQuotaProvider({ - logger, - claudeHome, - claudeKeychainReader: options.keychain ?? (async () => null), - platform: options.platform, - fetch: fetchThroughTestDouble, - }), - new CopilotQuotaProvider({ logger, fetch: fetchThroughTestDouble }), - new CursorQuotaProvider({ - logger, - fetch: fetchThroughTestDouble, - homeDir: options.cursorHomeDir, - }), - new ZaiQuotaProvider({ logger, fetch: fetchThroughTestDouble }), - new GrokQuotaProvider({ - logger, - fetch: fetchThroughTestDouble, - // Match Kimi: inject temp HOME so nested auth-file tests work on Windows - // (os.homedir() uses USERPROFILE there and ignores process.env.HOME). - homeDir, - }), - new KimiQuotaProvider({ - logger, - fetch: fetchThroughTestDouble, - // 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, - fetch: fetchThroughTestDouble, - configPath: options.miniMaxConfigPath ?? join(homeDir, ".mmx", "config.json"), - credentialsPath: - options.miniMaxCredentialsPath ?? join(homeDir, ".mmx", "credentials.json"), - }), - ]; - return { - listUsage: async () => ({ - fetchedAt: "2026-06-19T00:00:00.000Z", - providers: await Promise.all( - fetchers.map(async (fetcher) => { - const report = await fetcher.fetchUsage(); - const id = fetcher.constructor.name.replace("QuotaProvider", "").toLowerCase(); - return { - providerId: id === "minimax" ? "minimax" : id, - displayName: id, - status: report.status, - planLabel: report.planLabel ?? null, - windows: report.windows, - balances: report.balances ?? [], - details: report.details ?? [], - error: report.error ?? null, - } satisfies ProviderUsage; - }), - ), - }), - }; - } - - it("fetches Claude usage, coerces API numbers, and attaches HTTP timeout signals", async () => { - writeClaudeCredentials(claudeHome, "at_valid"); - fetchApi = mockFetch( - new Map([ - [ - "https://api.anthropic.com/api/oauth/usage", - () => - jsonResponse( - makeClaudeResponse({ - five_hour: { utilization: "11", resets_at: "2026-06-01T21:00:00Z" }, - }), - ), - ], - ]), - ); - - const result = await service().listUsage(); - const claude = findProvider(result, "claude"); - - expect(claude).toMatchObject({ - status: "available", - planLabel: "Pro 1x", - windows: expect.arrayContaining([ - expect.objectContaining({ id: "five_hour", usedPct: 11 }), - expect.objectContaining({ id: "weekly", usedPct: 1 }), - expect.objectContaining({ id: "weekly_model_opus", usedPct: 0.5 }), - ]), - }); - expect(fetchApi).toHaveBeenCalledWith( - "https://api.anthropic.com/api/oauth/usage", - expect.objectContaining({ signal: expect.any(AbortSignal) }), - ); - }); - - it("accepts a null Claude resets_at when a window has no scheduled reset", async () => { - writeClaudeCredentials(claudeHome, "at_valid"); - fetchApi = mockFetch( - new Map([ - [ - "https://api.anthropic.com/api/oauth/usage", - () => - jsonResponse({ - five_hour: { utilization: 0, resets_at: null }, - seven_day: { utilization: 1, resets_at: "2026-06-04T00:00:00Z" }, - }), - ], - ]), - ); - - const result = await service().listUsage(); - const claude = findProvider(result, "claude"); - - expect(claude).toMatchObject({ - status: "available", - windows: expect.arrayContaining([ - expect.objectContaining({ id: "five_hour", usedPct: 0, resetsAt: null }), - expect.objectContaining({ id: "weekly", usedPct: 1 }), - ]), - }); - }); - - it("returns unavailable Claude usage when credentials are missing", async () => { - fetchApi = vi.fn() as never; - - const result = await service().listUsage(); - const claude = findProvider(result, "claude"); - - expect(claude.status).toBe("unavailable"); - expect(fetchApi).not.toHaveBeenCalled(); - }); - - it("returns unavailable on 401 without refreshing or rewriting credentials", async () => { - writeClaudeCredentials(claudeHome, "at_expired", "rt_valid"); - const credPath = join(claudeHome, ".credentials.json"); - const before = readFileSync(credPath, "utf8"); - let usageCalls = 0; - fetchApi = vi.fn(async (url: RequestInfo | URL) => { - const endpoint = url.toString(); - if (endpoint === "https://api.anthropic.com/api/oauth/usage") { - usageCalls += 1; - return new Response(null, { status: 401 }); - } - // The read-only fetcher must never hit the OAuth token endpoint. - throw new Error(`Unmocked: ${endpoint}`); - }) as never; - - const result = await service().listUsage(); - - expect(findProvider(result, "claude").status).toBe("unavailable"); - expect(usageCalls).toBe(1); - // The credentials file must be left untouched for the Claude CLI to own. - expect(readFileSync(credPath, "utf8")).toBe(before); - }); - - it("does not refresh Claude tokens read from the macOS Keychain", async () => { - const usageFetch = vi.fn(async () => new Response(null, { status: 401 })); - fetchApi = usageFetch as never; - - const result = await service({ - platform: "darwin", - keychain: async () => ({ - claudeAiOauth: { - accessToken: "at_expired", - refreshToken: "rt_valid", - }, - }), - }).listUsage(); - - expect(findProvider(result, "claude").status).toBe("unavailable"); - expect(usageFetch).toHaveBeenCalledTimes(1); - expect(usageFetch).toHaveBeenCalledWith( - "https://api.anthropic.com/api/oauth/usage", - expect.objectContaining({ - headers: expect.objectContaining({ Authorization: "Bearer at_expired" }), - }), - ); - }); - - it("fetches Copilot usage from COPILOT_TOKEN", async () => { - process.env["COPILOT_TOKEN"] = "copilot_test_token"; - fetchApi = mockFetch( - new Map([ - [ - "https://api.github.com/copilot_internal/user", - () => - jsonResponse({ - copilot_plan: "business", - quota_reset_date: "2026-07-01T00:00:00Z", - }), - ], - ]), - ); - - const copilot = findProvider(await service().listUsage(), "copilot"); - - expect(copilot).toMatchObject({ - status: "available", - planLabel: "business", - details: [{ id: "reset", label: "Quota reset", value: "2026-07-01T00:00:00Z" }], - }); - }); - - it("fetches Cursor usage and normalizes malformed billing dates to null", async () => { - process.env["CURSOR_ACCESS_TOKEN"] = "cursor_test_token"; - fetchApi = mockFetch( - new Map([ - [ - "https://api2.cursor.sh/aiserver.v1.DashboardService/GetCurrentPeriodUsage", - () => - jsonResponse({ - planUsage: { - totalSpend: "1500", - includedSpend: "1000", - bonusSpend: "500", - remaining: "2500", - limit: "4000", - }, - billingCycleStart: "2026-01-14T12:42:14.000Z", - billingCycleEnd: "not-a-date", - }), - ], - ]), - ); - - const cursor = findProvider(await service().listUsage(), "cursor"); - - expect(cursor).toMatchObject({ - status: "available", - balances: [ - expect.objectContaining({ - id: "plan_usage", - used: 15, - remaining: 25, - limit: 40, - resetsAt: null, - }), - ], - }); - }); - - it("reads the Cursor token from the modern cursorAuth/accessToken key in state.vscdb", async () => { - writeCursorStateDb(homeDir, { "cursorAuth/accessToken": "cursor_state_jwt" }); - let authorization: string | null = null; - fetchApi = (async (url: RequestInfo | URL, init?: RequestInit) => { - authorization = (init?.headers as Record | undefined)?.Authorization ?? null; - return jsonResponse({ - planUsage: { - totalSpend: "1500", - includedSpend: "1000", - bonusSpend: "500", - remaining: "2500", - limit: "4000", - }, - billingCycleStart: "2026-01-14T12:42:14.000Z", - billingCycleEnd: "2026-02-14T12:42:14.000Z", - }); - }) as unknown as typeof fetch; - - const cursor = findProvider(await service({ cursorHomeDir: homeDir }).listUsage(), "cursor"); - - expect(authorization).toBe("Bearer cursor_state_jwt"); - expect(cursor).toMatchObject({ - status: "available", - balances: [expect.objectContaining({ id: "plan_usage", used: 15, remaining: 25, limit: 40 })], - }); - }); - - it("falls back to the legacy cursorAuthStatus JSON blob when the modern key is absent", async () => { - writeCursorStateDb(homeDir, { - cursorAuthStatus: Buffer.from(JSON.stringify({ accessToken: "cursor_legacy_jwt" }), "utf8"), - }); - let authorization: string | null = null; - fetchApi = (async (url: RequestInfo | URL, init?: RequestInit) => { - authorization = (init?.headers as Record | undefined)?.Authorization ?? null; - return jsonResponse({ - planUsage: { totalSpend: "0", remaining: "100", limit: "100" }, - billingCycleStart: null, - billingCycleEnd: null, - }); - }) as unknown as typeof fetch; - - const cursor = findProvider(await service({ cursorHomeDir: homeDir }).listUsage(), "cursor"); - - expect(authorization).toBe("Bearer cursor_legacy_jwt"); - expect(cursor.status).toBe("available"); - }); - - it("reads the Cursor token from cursor-agent ~/.config/cursor/auth.json when desktop state is absent", async () => { - writeCursorAuthJson(homeDir, "cursor_cli_jwt"); - let authorization: string | null = null; - fetchApi = (async (_url: RequestInfo | URL, init?: RequestInit) => { - authorization = (init?.headers as Record | undefined)?.Authorization ?? null; - return jsonResponse({ - planUsage: { - totalSpend: "1500", - includedSpend: "1000", - bonusSpend: "500", - remaining: "2500", - limit: "4000", - }, - billingCycleStart: "2026-01-14T12:42:14.000Z", - billingCycleEnd: "2026-02-14T12:42:14.000Z", - }); - }) as unknown as typeof fetch; - - const cursor = findProvider(await service({ cursorHomeDir: homeDir }).listUsage(), "cursor"); - - expect(authorization).toBe("Bearer cursor_cli_jwt"); - expect(cursor).toMatchObject({ - status: "available", - balances: [expect.objectContaining({ id: "plan_usage", used: 15, remaining: 25, limit: 40 })], - }); - }); - - it("prefers the desktop state.vscdb token over cursor-agent auth.json", async () => { - writeCursorStateDb(homeDir, { "cursorAuth/accessToken": "cursor_desktop_jwt" }); - writeCursorAuthJson(homeDir, "cursor_cli_jwt"); - let authorization: string | null = null; - fetchApi = (async (_url: RequestInfo | URL, init?: RequestInit) => { - authorization = (init?.headers as Record | undefined)?.Authorization ?? null; - return jsonResponse({ - planUsage: { totalSpend: "0", remaining: "100", limit: "100" }, - billingCycleStart: null, - billingCycleEnd: null, - }); - }) as unknown as typeof fetch; - - const cursor = findProvider(await service({ cursorHomeDir: homeDir }).listUsage(), "cursor"); - - expect(authorization).toBe("Bearer cursor_desktop_jwt"); - expect(cursor.status).toBe("available"); - }); - - it("logs a debug diagnostic and stays unavailable when state.vscdb is unreadable", async () => { - const dir = join(homeDir, ".config", "Cursor", "User", "globalStorage"); - mkdirSync(dir, { recursive: true }); - writeFileSync(join(dir, "state.vscdb"), "not a sqlite database"); - const logger = createLogger(); - const provider = new CursorQuotaProvider({ - logger, - fetch: (() => { - throw new Error("usage API should not be called without a token"); - }) as unknown as typeof fetch, - homeDir, - }); - - const usage = await provider.fetchUsage(); - - expect(usage.status).toBe("unavailable"); - expect((logger as unknown as { debug: ReturnType }).debug).toHaveBeenCalledWith( - expect.objectContaining({ path: expect.stringContaining("state.vscdb") }), - expect.stringContaining("Failed to read Cursor token"), - ); - }); - - it("fetches Z.ai usage from ZAI_API_KEY", async () => { - process.env["ZAI_API_KEY"] = "zai_test_token"; - fetchApi = mockFetch( - new Map([ - [ - "https://api.z.ai/api/biz/subscription/list", - () => - jsonResponse({ - data: [ - { - productName: "GLM Coding Max", - status: "VALID", - purchaseTime: "2026-01-12 16:55:13", - valid: "2026-02-12 16:55:13-2026-03-12 16:55:13", - }, - ], - }), - ], - ]), - ); - - const zai = findProvider(await service().listUsage(), "zai"); - - expect(zai).toMatchObject({ - status: "available", - planLabel: "GLM Coding Max", - details: expect.arrayContaining([{ id: "status", label: "Status", value: "VALID" }]), - }); - }); - - it("fetches Grok usage and preserves zero values", async () => { - process.env["GROK_API_KEY"] = "grok_test_token"; - fetchApi = mockFetch( - new Map([ - [ - "https://cli-chat-proxy.grok.com/v1/billing?format=credits", - () => - jsonResponse({ - config: { monthlyLimit: { val: 0 }, used: { val: 0 } }, - }), - ], - ]), - ); - - const grok = findProvider(await service().listUsage(), "grok"); - - expect(grok).toMatchObject({ - status: "available", - balances: [ - expect.objectContaining({ - id: "monthly_credits", - used: 0, - remaining: 0, - limit: 0, - }), - ], - }); - }); - - it("fetches Grok usage from live billing shape (config.used.val)", async () => { - process.env["GROK_API_KEY"] = "grok_test_token"; - fetchApi = mockFetch( - new Map([ - [ - "https://cli-chat-proxy.grok.com/v1/billing?format=credits", - () => - jsonResponse({ - config: { - monthlyLimit: { val: 150000 }, - used: { val: 37886 }, - billingPeriodStart: "2026-07-01T00:00:00+00:00", - billingPeriodEnd: "2026-08-01T00:00:00+00:00", - }, - }), - ], - ]), - ); - - const grok = findProvider(await service().listUsage(), "grok"); - - expect(grok).toMatchObject({ - status: "available", - balances: [ - expect.objectContaining({ - id: "monthly_credits", - used: 37886, - remaining: 112114, - limit: 150000, - unit: "credits", - }), - ], - }); - }); - - it("fetches Grok usage with nested ~/.grok/auth.json key token", async () => { - writeGrokAuth(homeDir, { - "https://auth.x.ai::test-user-id": { - key: "nested_jwt_token", - refresh_token: "rt_nested", - expires_at: "2026-08-01T00:00:00Z", - user_id: "test-user-id", - email: "user@example.com", - }, - }); - - let authorization: string | null = null; - fetchApi = (async (_url: RequestInfo | URL, init?: RequestInit) => { - authorization = (init?.headers as Record | undefined)?.Authorization ?? null; - return jsonResponse({ - config: { - monthlyLimit: { val: 100 }, - used: { val: 25 }, - }, - }); - }) as typeof fetch; - - const grok = findProvider(await service().listUsage(), "grok"); - - expect(authorization).toBe("Bearer nested_jwt_token"); - expect(grok).toMatchObject({ - status: "available", - balances: [ - expect.objectContaining({ - id: "monthly_credits", - used: 25, - remaining: 75, - limit: 100, - }), - ], - }); - }); - - it("still accepts legacy Grok usage.creditUsage when config.used is absent", async () => { - process.env["GROK_API_KEY"] = "grok_test_token"; - fetchApi = mockFetch( - new Map([ - [ - "https://cli-chat-proxy.grok.com/v1/billing?format=credits", - () => - jsonResponse({ - config: { monthlyLimit: { val: 50 } }, - usage: { creditUsage: 10 }, - }), - ], - ]), - ); - - const grok = findProvider(await service().listUsage(), "grok"); - - expect(grok).toMatchObject({ - status: "available", - balances: [ - expect.objectContaining({ - id: "monthly_credits", - used: 10, - remaining: 40, - limit: 50, - }), - ], - }); - }); - - it("fetches Grok unified-billing usage as a weekly window", async () => { - process.env["GROK_API_KEY"] = "grok_test_token"; - fetchApi = mockFetch( - new Map([ - [ - "https://cli-chat-proxy.grok.com/v1/billing?format=credits", - () => - jsonResponse({ - config: { - currentPeriod: { - type: "USAGE_PERIOD_TYPE_WEEKLY", - start: "2026-08-24T09:41:40.001370+00:00", - end: "2026-08-31T09:41:40.001370+00:00", - }, - creditUsagePercent: 76.0, - isUnifiedBillingUser: true, - billingPeriodStart: "2026-08-24T09:41:40.001370+00:00", - billingPeriodEnd: "2026-08-31T09:41:40.001370+00:00", - }, - }), - ], - ]), - ); - - const grok = findProvider(await service().listUsage(), "grok"); - - expect(grok).toMatchObject({ - status: "available", - windows: [ - { - id: "weekly", - label: "Weekly", - usedPct: 76, - remainingPct: 24, - resetsAt: "2026-08-31T09:41:40.001370+00:00", - tone: "warning", - }, - ], - balances: [], - }); - }); - - it("fetches Kimi usage from KIMI_TOKEN", async () => { - process.env["KIMI_TOKEN"] = "kimi_test_token"; - fetchApi = mockFetch( - new Map([ - [ - "https://api.kimi.com/coding/v1/usages", - () => - jsonResponse({ - usage: { - limit: "100", - remaining: "74", - resetTime: "2026-02-11T17:32:50Z", - }, - }), - ], - ]), - ); - - const kimi = findProvider(await service().listUsage(), "kimi"); - - expect(kimi).toMatchObject({ - status: "available", - windows: [ - expect.objectContaining({ - id: "coding_usage", - usedPct: 26, - remainingPct: 74, - resetsAt: "2026-02-11T17:32:50Z", - }), - ], - }); - }); - - it("fetches Kimi usage from the CLI credential home", async () => { - writeKimiCredentials(join(homeDir, ".kimi-code"), "kimi_cli_token"); - let requestedUrl: string | null = null; - let authorization: string | null = null; - fetchApi = (async (url: RequestInfo | URL, init?: RequestInit) => { - requestedUrl = url.toString(); - authorization = (init?.headers as Record | undefined)?.Authorization ?? null; - return jsonResponse({ - usage: { - limit: "200", - remaining: "150", - resetTime: "2026-06-23T05:12:17Z", - }, - }); - }) as unknown as typeof fetch; - - const kimi = findProvider(await service({ kimiHomeDir: homeDir }).listUsage(), "kimi"); - - expect(requestedUrl).toBe("https://api.kimi.com/coding/v1/usages"); - expect(authorization).toBe("Bearer kimi_cli_token"); - expect(kimi).toMatchObject({ - status: "available", - windows: [ - expect.objectContaining({ - id: "coding_usage", - usedPct: 25, - remainingPct: 75, - resetsAt: "2026-06-23T05:12:17Z", - }), - ], - }); - }); - - 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("returns unavailable on 401 without refreshing or rewriting the credential file", async () => { - writeKimiCredentials(join(homeDir, ".kimi-code"), "at_kimi_expired"); - const credPath = kimiCredentialPath(join(homeDir, ".kimi-code")); - const before = readFileSync(credPath, "utf8"); - let usageCalls = 0; - fetchApi = vi.fn(async (url: RequestInfo | URL) => { - const endpoint = url.toString(); - if (endpoint === "https://api.kimi.com/coding/v1/usages") { - usageCalls += 1; - return new Response(null, { status: 401 }); - } - // The read-only fetcher must never hit the OAuth token endpoint. - throw new Error(`Unmocked: ${endpoint}`); - }) as never; - - const result = await service({ kimiHomeDir: homeDir }).listUsage(); - - expect(findProvider(result, "kimi").status).toBe("unavailable"); - expect(usageCalls).toBe(1); - // The credentials file must be left untouched for the Kimi CLI to own. - expect(readFileSync(credPath, "utf8")).toBe(before); - }); - - 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; - let authorization: string | null = null; - fetchApi = (async (url: RequestInfo | URL, init?: RequestInit) => { - requestedUrl = url.toString(); - authorization = (init?.headers as Record | undefined)?.Authorization ?? null; - return jsonResponse({ - model_remains: [ - { - model_name: "MiniMax-M2.7", - end_time: Date.parse("2026-06-19T05:00:00.000Z"), - weekly_end_time: Date.parse("2026-06-26T00:00:00.000Z"), - current_interval_total_count: 1000, - current_interval_usage_count: 250, - current_interval_remaining_percent: 75, - current_weekly_total_count: 5000, - current_weekly_usage_count: 1200, - current_weekly_remaining_percent: 76, - }, - ], - }); - }) as unknown as typeof fetch; - - const miniMax = findProvider(await service().listUsage(), "minimax"); - - expect(requestedUrl).toBe("https://api.minimax.io/v1/token_plan/remains"); - expect(authorization).toBe("Bearer minimax_test_token"); - expect(miniMax).toMatchObject({ - status: "available", - windows: expect.arrayContaining([ - expect.objectContaining({ - id: "interval_MiniMax-M2.7", - label: "MiniMax-M2.7 · Interval", - usedPct: 25, - remainingPct: 75, - resetsAt: "2026-06-19T05:00:00.000Z", - }), - expect.objectContaining({ - id: "weekly_MiniMax-M2.7", - label: "MiniMax-M2.7 · Weekly", - usedPct: 24, - remainingPct: 76, - resetsAt: "2026-06-26T00:00:00.000Z", - }), - ]), - }); - }); - - it("returns unavailable MiniMax usage when no credentials are configured", async () => { - fetchApi = vi.fn() as never; - - const miniMax = findProvider(await service().listUsage(), "minimax"); - - expect(miniMax.status).toBe("unavailable"); - expect(fetchApi).not.toHaveBeenCalled(); - }); - - it("reads MiniMax OAuth credentials from the CLI credentials file", async () => { - writeMiniMaxCredentials( - homeDir, - "minimax_oauth_token", - "2030-01-01T00:00:00.000Z", - "https://account.example.com", - ); - let requestedUrl: string | null = null; - fetchApi = (async (url: RequestInfo | URL) => { - requestedUrl = url.toString(); - return jsonResponse({ model_remains: [] }); - }) as unknown as typeof fetch; - - await service().listUsage(); - - expect(requestedUrl).toBe("https://account.example.com/v1/token_plan/remains"); - }); - - it("falls back to MiniMax api_key in the CLI config file", async () => { - writeMiniMaxConfig(homeDir, { - api_key: "minimax_config_key", - region: "cn", - }); - let requestedUrl: string | null = null; - fetchApi = (async (url: RequestInfo | URL) => { - requestedUrl = url.toString(); - return jsonResponse({ model_remains: [] }); - }) as unknown as typeof fetch; - - const miniMax = findProvider(await service().listUsage(), "minimax"); - - expect(requestedUrl).toBe("https://api.minimaxi.com/v1/token_plan/remains"); - expect(miniMax.status).toBe("unavailable"); - }); - - it("reports MiniMax accounts with no active token plan as unavailable", async () => { - writeMiniMaxConfig(homeDir, { api_key: "minimax_config_key", region: "cn" }); - fetchApi = mockFetch( - new Map([ - [ - "https://api.minimaxi.com/v1/token_plan/remains", - () => - jsonResponse({ - model_remains: null, - base_resp: { status_code: 2062, status_msg: "no active token plan subscription" }, - }), - ], - ]), - ); - - const miniMax = findProvider(await service().listUsage(), "minimax"); - - expect(miniMax).toMatchObject({ status: "unavailable", windows: [], error: null }); - }); - - it("reports a null MiniMax token plan as unavailable even without base_resp", async () => { - process.env["MINIMAX_API_KEY"] = "minimax_test_token"; - fetchApi = mockFetch( - new Map([ - [ - "https://api.minimax.io/v1/token_plan/remains", - () => jsonResponse({ model_remains: null }), - ], - ]), - ); - - const miniMax = findProvider(await service().listUsage(), "minimax"); - - expect(miniMax).toMatchObject({ status: "unavailable", windows: [], error: null }); - }); - - it("still reads MiniMax windows when base_resp reports success", async () => { - process.env["MINIMAX_API_KEY"] = "minimax_test_token"; - fetchApi = mockFetch( - new Map([ - [ - "https://api.minimax.io/v1/token_plan/remains", - () => - jsonResponse({ - base_resp: { status_code: 0, status_msg: "success" }, - model_remains: [ - { - model_name: "MiniMax-M2.7", - end_time: Date.parse("2026-06-19T05:00:00.000Z"), - current_interval_total_count: 100, - current_interval_usage_count: 25, - current_interval_remaining_percent: 75, - }, - ], - }), - ], - ]), - ); - - const miniMax = findProvider(await service().listUsage(), "minimax"); - - expect(miniMax).toMatchObject({ - status: "available", - windows: expect.arrayContaining([ - expect.objectContaining({ id: "interval_MiniMax-M2.7", usedPct: 25 }), - ]), - }); - }); - - it("marks exhausted MiniMax interval windows with a danger tone", async () => { - process.env["MINIMAX_API_KEY"] = "minimax_test_token"; - fetchApi = mockFetch( - new Map([ - [ - "https://api.minimax.io/v1/token_plan/remains", - () => - jsonResponse({ - model_remains: [ - { - model_name: "MiniMax-M2.7", - end_time: Date.parse("2026-06-19T05:00:00.000Z"), - weekly_end_time: Date.parse("2026-06-26T00:00:00.000Z"), - current_interval_total_count: 100, - current_interval_usage_count: 100, - current_interval_remaining_percent: 0, - current_interval_status: 2, - current_weekly_total_count: 100, - current_weekly_usage_count: 10, - current_weekly_remaining_percent: 90, - current_weekly_status: 1, - }, - ], - }), - ], - ]), - ); - - const miniMax = findProvider(await service().listUsage(), "minimax"); - - expect(miniMax).toMatchObject({ - status: "available", - windows: expect.arrayContaining([ - expect.objectContaining({ - id: "interval_MiniMax-M2.7", - usedPct: 100, - tone: "danger", - }), - expect.objectContaining({ - id: "weekly_MiniMax-M2.7", - tone: "ok", - }), - ]), - }); - }); -}); - -// Regression for #2320: providers hardcoded `tone: "ok"`, which suppressed the client's -// own thresholds (window-bar.tsx reads `window.tone ?? deriveTone(usedPct)`), so a bar -// stayed green at 99%. Codex escalated to "warning" but could never reach "danger". -describe("usage bars escalate as they fill", () => { - let claudeHome: string; - let codexHome: string; - - beforeEach(() => { - claudeHome = mkdtempSync(join(tmpdir(), "paseo-tone-claude-")); - codexHome = mkdtempSync(join(tmpdir(), "paseo-tone-codex-")); - }); - - afterEach(() => { - rmSync(claudeHome, { recursive: true, force: true }); - rmSync(codexHome, { recursive: true, force: true }); - }); - - function claudeAt(utilization: number) { - writeClaudeCredentials(claudeHome, "at_valid"); - return new ClaudeQuotaProvider({ - logger: createLogger(), - claudeHome, - claudeKeychainReader: async () => null, - fetch: mockFetch( - new Map([ - [ - "https://api.anthropic.com/api/oauth/usage", - () => - jsonResponse({ - seven_day: { utilization, resets_at: "2026-06-04T00:00:00Z" }, - }), - ], - ]), - ), - }).fetchUsage(); - } - - it.each([ - [10, "ok"], - [75, "warning"], - [99, "danger"], - ])("a Claude window at %s%% is %s", async (utilization, tone) => { - const usage = await claudeAt(utilization); - expect(usage.windows).toEqual([expect.objectContaining({ id: "weekly", tone })]); - }); -}); - -// Model- and surface-scoped weekly limits arrive in a `limits[]` array rather than the -// top-level `seven_day_*` keys, which now return null on most accounts. -describe("ClaudeQuotaProvider scoped weekly limits", () => { - let claudeHome: string; - - beforeEach(() => { - claudeHome = mkdtempSync(join(tmpdir(), "paseo-claude-limits-")); - }); - - afterEach(() => { - rmSync(claudeHome, { recursive: true, force: true }); - }); - - function fableLimit(overrides: Record = {}) { - return { - kind: "weekly_scoped", - group: "weekly", - percent: 0, - severity: "normal", - resets_at: "2026-06-04T00:00:00Z", - is_active: false, - scope: { model: { id: null, display_name: "Fable" }, surface: null }, - ...overrides, - }; - } - - function claudeProvider(body: unknown) { - writeClaudeCredentials(claudeHome, "at_valid"); - const logger = createLogger() as unknown as { warn: ReturnType }; - const provider = new ClaudeQuotaProvider({ - logger: logger as never, - claudeHome, - claudeKeychainReader: async () => null, - fetch: mockFetch( - new Map([["https://api.anthropic.com/api/oauth/usage", () => jsonResponse(body)]]), - ), - }); - return { provider, logger }; - } - - it("renders a scoped weekly limit as its own window", async () => { - const { provider } = claudeProvider({ - five_hour: { utilization: 6, resets_at: "2026-06-01T21:00:00Z" }, - seven_day: { utilization: 23, resets_at: "2026-06-04T00:00:00Z" }, - limits: [fableLimit()], - }); - - const usage = await provider.fetchUsage(); - - expect(usage.windows).toContainEqual( - expect.objectContaining({ id: "weekly_model_fable", label: "Weekly · Fable" }), - ); - }); - - it("renders a scoped window that is at zero and inactive", async () => { - const { provider } = claudeProvider({ - seven_day: { utilization: 23, resets_at: "2026-06-04T00:00:00Z" }, - limits: [fableLimit({ percent: 0, is_active: false })], - }); - - const usage = await provider.fetchUsage(); - - expect(usage.windows).toContainEqual( - expect.objectContaining({ id: "weekly_model_fable", usedPct: 0, remainingPct: 100 }), - ); - }); - - it("ignores session and all-models entries so they do not duplicate the top-level windows", async () => { - const { provider } = claudeProvider({ - five_hour: { utilization: 6, resets_at: "2026-06-01T21:00:00Z" }, - seven_day: { utilization: 23, resets_at: "2026-06-04T00:00:00Z" }, - limits: [ - { kind: "session", percent: 6, resets_at: "2026-06-01T21:00:00Z", scope: null }, - { kind: "weekly_all", percent: 23, resets_at: "2026-06-04T00:00:00Z", scope: null }, - fableLimit(), - ], - }); - - const usage = await provider.fetchUsage(); - - expect(usage.windows.map((window) => window.id)).toEqual([ - "five_hour", - "weekly", - "weekly_model_fable", - ]); - }); - - it("labels a surface-scoped limit from its surface name", async () => { - const { provider } = claudeProvider({ - seven_day: { utilization: 23, resets_at: "2026-06-04T00:00:00Z" }, - limits: [ - fableLimit({ scope: { model: null, surface: { id: "code", display_name: "Code" } } }), - ], - }); - - const usage = await provider.fetchUsage(); - - expect(usage.windows).toContainEqual( - expect.objectContaining({ id: "weekly_surface_code", label: "Weekly · Code" }), - ); - }); - - it("skips a scoped limit with no resolvable label rather than rendering an unlabelled bar", async () => { - const { provider, logger } = claudeProvider({ - seven_day: { utilization: 23, resets_at: "2026-06-04T00:00:00Z" }, - limits: [fableLimit({ scope: { model: { id: null, display_name: null }, surface: null } })], - }); - - const usage = await provider.fetchUsage(); - - expect(usage.windows.map((window) => window.id)).toEqual(["weekly"]); - expect(logger.warn).toHaveBeenCalled(); - }); - - // Regression: an additive section must never take down data that already parsed. - it("keeps the top-level windows when a limits entry is malformed", async () => { - const { provider, logger } = claudeProvider({ - five_hour: { utilization: 6, resets_at: "2026-06-01T21:00:00Z" }, - seven_day: { utilization: 23, resets_at: "2026-06-04T00:00:00Z" }, - limits: [{ percent: "not-a-kind" }, fableLimit()], - }); - - const usage = await provider.fetchUsage(); - - expect(usage.status).toBe("available"); - expect(usage.windows.map((window) => window.id)).toEqual([ - "five_hour", - "weekly", - "weekly_model_fable", - ]); - expect(logger.warn).toHaveBeenCalled(); - }); - - it("warns when a successful response describes no windows at all", async () => { - const { provider, logger } = claudeProvider({ limits: [] }); - - const usage = await provider.fetchUsage(); - - expect(usage.windows).toEqual([]); - expect(logger.warn).toHaveBeenCalledWith( - "Claude usage response parsed but produced no windows", - ); - }); -}); -/** - * The reconciliation matrix. - * - * Four review rounds on PR #2303 each found a different hole in how the two - * representations of one scoped limit get combined, because each fix was tested against - * the case that was reported rather than the space of cases. This walks the space: - * every combination of which representation carries the limit, whether the `limits[]` - * entry supplies values, and whether the two descriptions denote the same limit at all. - */ -describe("ClaudeQuotaProvider scoped limit reconciliation", () => { - let claudeHome: string; - - beforeEach(() => { - claudeHome = mkdtempSync(join(tmpdir(), "paseo-claude-matrix-")); - }); - - afterEach(() => { - rmSync(claudeHome, { recursive: true, force: true }); - }); - - const RESETS = "2026-06-04T00:00:00Z"; - - function scoped(scope: unknown, percent: number | null = 30, resetsAt: string | null = RESETS) { - return { kind: "weekly_scoped", percent, resets_at: resetsAt, scope }; - } - - const model = (name: string | null, id: string | null = null) => ({ - model: { id, display_name: name }, - surface: null, - }); - const surface = (name: string | null, id: string | null = null) => ({ - model: null, - surface: { id, display_name: name }, - }); - - async function windowsFor(body: Record) { - writeClaudeCredentials(claudeHome, "at_valid"); - const usage = await new ClaudeQuotaProvider({ - logger: createLogger(), - claudeHome, - claudeKeychainReader: async () => null, - fetch: mockFetch( - new Map([["https://api.anthropic.com/api/oauth/usage", () => jsonResponse(body)]]), - ), - }).fetchUsage(); - return usage.windows; - } - - it("which representation carries the limit: legacy only", async () => { - const windows = await windowsFor({ - seven_day_omelette: { utilization: 12, resets_at: RESETS }, - }); - expect(windows).toEqual([ - expect.objectContaining({ - id: "weekly_model_omelette", - label: "Weekly · Omelette", - usedPct: 12, - }), - ]); - }); - - it("which representation carries the limit: limits[] only", async () => { - const windows = await windowsFor({ limits: [scoped(model("Fable"), 2)] }); - expect(windows).toEqual([ - expect.objectContaining({ - id: "weekly_model_fable", - label: "Weekly · Fable", - usedPct: 2, - }), - ]); - }); - - it("which representation carries the limit: both, same limit — one bar, scoped identity", async () => { - const windows = await windowsFor({ - seven_day_omelette: { utilization: 12, resets_at: RESETS }, - limits: [scoped(model("Omelette"), 30)], - }); - expect(windows).toEqual([ - expect.objectContaining({ id: "weekly_model_omelette", usedPct: 30 }), - ]); - }); - - it("which representation carries the limit: both, different limits — two bars", async () => { - const windows = await windowsFor({ - seven_day_opus: { utilization: 8, resets_at: RESETS }, - limits: [scoped(model("Fable"), 2)], - }); - expect(windows.map((w) => w.id)).toEqual(["weekly_model_opus", "weekly_model_fable"]); - }); - - it("which representation carries the limit: neither", async () => { - const windows = await windowsFor({ seven_day: { utilization: 23, resets_at: RESETS } }); - expect(windows.map((w) => w.id)).toEqual(["weekly"]); - }); - - const legacy = { seven_day_omelette: { utilization: 12, resets_at: RESETS } }; - - it("value fallback when the scoped entry is sparse: scoped values win when present", async () => { - const windows = await windowsFor({ - ...legacy, - limits: [scoped(model("Omelette"), 30, "2026-06-09T00:00:00Z")], - }); - expect(windows[0]).toMatchObject({ usedPct: 30, resetsAt: "2026-06-09T00:00:00Z" }); - }); - - it("value fallback when the scoped entry is sparse: percentage falls back per field", async () => { - const windows = await windowsFor({ - ...legacy, - limits: [scoped(model("Omelette"), null, "2026-06-09T00:00:00Z")], - }); - expect(windows[0]).toMatchObject({ usedPct: 12, resetsAt: "2026-06-09T00:00:00Z" }); - }); - - it("value fallback when the scoped entry is sparse: reset time falls back per field", async () => { - const windows = await windowsFor({ - ...legacy, - limits: [scoped(model("Omelette"), 30, null)], - }); - expect(windows[0]).toMatchObject({ usedPct: 30, resetsAt: RESETS }); - }); - - it("value fallback when the scoped entry is sparse: both fall back when the scoped entry only names the limit", async () => { - const windows = await windowsFor({ - ...legacy, - limits: [scoped(model("Omelette"), null, null)], - }); - expect(windows[0]).toMatchObject({ usedPct: 12, resetsAt: RESETS }); - }); - - it("value fallback when the scoped entry is sparse: stays empty when neither side has a value", async () => { - const windows = await windowsFor({ limits: [scoped(model("Fable"), null, null)] }); - expect(windows[0]).toMatchObject({ id: "weekly_model_fable", usedPct: null }); - }); - - it("identity: a surface never matches a legacy model window of the same name", async () => { - const windows = await windowsFor({ - seven_day_omelette: { utilization: 12, resets_at: RESETS }, - limits: [scoped(surface("Omelette"), 30)], - }); - expect(windows.map((w) => w.id)).toEqual(["weekly_model_omelette", "weekly_surface_omelette"]); - expect(windows[0]).toMatchObject({ usedPct: 12 }); - expect(windows[1]).toMatchObject({ usedPct: 30 }); - }); - - it("identity: a model and a surface of the same name stay apart", async () => { - const windows = await windowsFor({ - limits: [scoped(model("Code"), 4), scoped(surface("Code"), 9)], - }); - expect(windows.map((w) => w.id)).toEqual(["weekly_model_code", "weekly_surface_code"]); - }); - - it("identity: ids decide when both sides have one", async () => { - const windows = await windowsFor({ - limits: [ - scoped(model("Fable-Pro", "fable-pro"), 4), - scoped(model("Fable_Pro", "fable_pro"), 9), - ], - }); - expect(windows.map((w) => w.id)).toEqual(["weekly_model_fable-pro", "weekly_model_fable_pro"]); - }); - - it("identity: names decide when ids are absent, so indistinguishable entries merge", async () => { - const windows = await windowsFor({ - limits: [scoped(model("Fable Pro"), 4), scoped(model("Fable-Pro"), 9)], - }); - expect(windows).toEqual([ - expect.objectContaining({ id: "weekly_model_fable_pro", usedPct: 9 }), - ]); - }); - - it("identity: a renamed scope keeps its id when the API supplies one", async () => { - const before = await windowsFor({ limits: [scoped(model("Fable", "fable"), 2)] }); - const after = await windowsFor({ limits: [scoped(model("Fable 5", "fable"), 2)] }); - expect(before[0]?.id).toBe("weekly_model_fable"); - expect(after[0]?.id).toBe("weekly_model_fable"); - expect(after[0]?.label).toBe("Weekly · Fable 5"); - }); - - it("identity: a limit keeps one id whichever representation carries it", async () => { - const viaLegacy = await windowsFor({ - seven_day_omelette: { utilization: 12, resets_at: RESETS }, - }); - const viaLimits = await windowsFor({ limits: [scoped(model("Omelette"), 12)] }); - expect(viaLegacy[0]?.id).toBe(viaLimits[0]?.id); - }); - - it("ordering and unscoped windows: puts session and weekly ahead of the scoped bars", async () => { - const windows = await windowsFor({ - five_hour: { utilization: 6, resets_at: RESETS }, - seven_day: { utilization: 23, resets_at: RESETS }, - seven_day_opus: { utilization: 8, resets_at: RESETS }, - limits: [ - { kind: "session", percent: 6, resets_at: RESETS, scope: null }, - { kind: "weekly_all", percent: 23, resets_at: RESETS, scope: null }, - scoped(model("Fable"), 2), - ], - }); - expect(windows.map((w) => w.id)).toEqual([ - "five_hour", - "weekly", - "weekly_model_opus", - "weekly_model_fable", - ]); - }); -}); - -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", - ]); - }); -}); diff --git a/packages/server/src/plugins/zai-usage-source/index.server.ts b/packages/server/src/plugins/zai-usage-source/index.server.ts index 3d064cf873..95b86aefd7 100644 --- a/packages/server/src/plugins/zai-usage-source/index.server.ts +++ b/packages/server/src/plugins/zai-usage-source/index.server.ts @@ -1,6 +1,6 @@ import type { PluginServerContext } from "@getpaseo/plugin/server"; import { inputSchema } from "./shared/input.js"; -import { ZaiQuotaProvider } from "./server/usage.js"; +import { fetchUsage } from "./server/usage.js"; export default function contribute(server: PluginServerContext) { server.registerUsageSource({ @@ -9,7 +9,7 @@ export default function contribute(server: PluginServerContext) { icon: "icon.svg", input: inputSchema, discover: async () => [{}], - fetch: async (_input) => new ZaiQuotaProvider({ logger: console }).fetchUsage(), + fetch: fetchUsage, }); return () => {}; } diff --git a/packages/server/src/plugins/zai-usage-source/paseo-plugin.json b/packages/server/src/plugins/zai-usage-source/paseo-plugin.json index 4b65f4a4e3..2d98c4cced 100644 --- a/packages/server/src/plugins/zai-usage-source/paseo-plugin.json +++ b/packages/server/src/plugins/zai-usage-source/paseo-plugin.json @@ -1 +1 @@ -{ "id": "zai-usage-source", "requirements": { "paseo": ">=0.9.2" } } +{ "id": "zai-usage-source" } diff --git a/packages/server/src/plugins/zai-usage-source/server/usage.test.ts b/packages/server/src/plugins/zai-usage-source/server/usage.test.ts new file mode 100644 index 0000000000..3f8fd0082d --- /dev/null +++ b/packages/server/src/plugins/zai-usage-source/server/usage.test.ts @@ -0,0 +1,119 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { fetchUsage } from "./usage.js"; +import type { UsageReport } from "@getpaseo/plugin/server/usage"; + +function mockFetch(handlers: Map Response>): typeof fetch { + return vi.fn(async (url: RequestInfo | URL) => { + const key = url.toString(); + const handler = handlers.get(key); + if (!handler) throw new Error(`Unmocked fetch: ${key}`); + return handler(); + }) as unknown as typeof fetch; +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +describe("zai usage source", () => { + let homeDir: string; + let fetchApi: typeof fetch; + let originalEnv: Record; + beforeEach(() => { + homeDir = mkdtempSync(join(tmpdir(), "usage-home-")); + originalEnv = { ...process.env }; + process.env["HOME"] = homeDir; + process.env["USERPROFILE"] = homeDir; + for (const key of [ + "APPDATA", + "COPILOT_TOKEN", + "GITHUB_TOKEN", + "GITHUB_PAT", + "CURSOR_ACCESS_TOKEN", + "CURSOR_TOKEN", + "ZAI_API_KEY", + "GLM_API_KEY", + "GROK_API_KEY", + "GROK_TOKEN", + "KIMI_TOKEN", + "KIMI_API_KEY", + "KIMI_CODE_HOME", + "MINIMAX_API_KEY", + "MINIMAX_BASE_URL", + ]) + delete process.env[key]; + fetchApi = mockFetch(new Map()); + }); + afterEach(() => { + rmSync(homeDir, { recursive: true, force: true }); + for (const key of Object.keys(process.env)) if (!(key in originalEnv)) delete process.env[key]; + for (const key in originalEnv) process.env[key] = originalEnv[key]; + }); + function service( + _options: { + platform?: typeof process.platform; + keychain?: () => Promise; + cursorHomeDir?: string; + kimiHomeDir?: string; + } = {}, + ) { + return { + listUsage: async () => { + const report = await fetchUsage({}, (url, init) => fetchApi(url, init)); + return { + providers: [ + { + providerId: "zai", + ...report, + error: report.error ?? null, + planLabel: report.planLabel ?? null, + }, + ], + }; + }, + }; + } + function findProvider( + result: { providers: Array<{ providerId: string } & UsageReport> }, + id: string, + ) { + const report = result.providers.find((item) => item.providerId === id); + if (!report) throw new Error(`Missing usage source ${id}`); + return report; + } + it("fetches Z.ai usage from ZAI_API_KEY", async () => { + process.env["ZAI_API_KEY"] = "zai_test_token"; + fetchApi = mockFetch( + new Map([ + [ + "https://api.z.ai/api/biz/subscription/list", + () => + jsonResponse({ + data: [ + { + productName: "GLM Coding Max", + status: "VALID", + purchaseTime: "2026-01-12 16:55:13", + valid: "2026-02-12 16:55:13-2026-03-12 16:55:13", + }, + ], + }), + ], + ]), + ); + + const zai = findProvider(await service().listUsage(), "zai"); + + expect(zai).toMatchObject({ + status: "available", + planLabel: "GLM Coding Max", + details: expect.arrayContaining([{ id: "status", label: "Status", value: "VALID" }]), + }); + }); +}); diff --git a/packages/server/src/plugins/zai-usage-source/server/usage.ts b/packages/server/src/plugins/zai-usage-source/server/usage.ts index 0eae65d239..8d23f88f1a 100644 --- a/packages/server/src/plugins/zai-usage-source/server/usage.ts +++ b/packages/server/src/plugins/zai-usage-source/server/usage.ts @@ -1,13 +1,16 @@ +import type { UsageInput } from "../shared/input.js"; import { z } from "zod"; import { - ApiOptionalStringSchema, - fetchProviderApi, unavailableUsage, type UsageReport, type UsageDetail, - type UsageApiFetch, } from "@getpaseo/plugin/server/usage"; +const ApiOptionalStringSchema = z.preprocess( + (value) => (value == null ? undefined : value), + z.coerce.string().optional(), +); + const ZaiUsageResponseSchema = z.object({ data: z .array( @@ -21,58 +24,44 @@ const ZaiUsageResponseSchema = z.object({ .optional(), }); -interface ZaiQuotaProviderOptions { - logger: Console; - fetch?: UsageApiFetch; -} +export async function fetchUsage( + input: UsageInput, + fetchApi: typeof fetch = fetch, +): Promise { + void input; -export class ZaiQuotaProvider { - private readonly logger: Console; - private readonly fetchApi: UsageApiFetch; + const token = process.env["ZAI_API_KEY"] || process.env["GLM_API_KEY"]; + if (!token) return unavailableUsage(); - constructor(options: ZaiQuotaProviderOptions) { - this.logger = options.logger; - this.fetchApi = options.fetch ?? fetch; + const res = await fetchApi("https://api.z.ai/api/biz/subscription/list", { + signal: AbortSignal.timeout(15_000), + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/json", + }, + }); + + if (!res.ok) { + return unavailableUsage(); } - async fetchUsage(): Promise { - const token = process.env["ZAI_API_KEY"] || process.env["GLM_API_KEY"]; - if (!token) return unavailableUsage(); + const resp = ZaiUsageResponseSchema.parse(await res.json()); + const sub = resp.data?.[0]; + if (!sub) return unavailableUsage(); - const res = await fetchProviderApi( - this.fetchApi, - "https://api.z.ai/api/biz/subscription/list", - { - headers: { - Authorization: `Bearer ${token}`, - Accept: "application/json", - }, - }, - ); - - if (!res.ok) { - this.logger.debug({ status: res.status }, "Z.ai usage fetch failed"); - return unavailableUsage(); - } - - const resp = ZaiUsageResponseSchema.parse(await res.json()); - const sub = resp.data?.[0]; - if (!sub) return unavailableUsage(); - - const details: UsageDetail[] = []; - if (sub.status) details.push({ id: "status", label: "Status", value: sub.status }); - if (sub.valid) details.push({ id: "valid", label: "Valid", value: sub.valid }); - if (sub.purchaseTime) { - details.push({ id: "purchase_time", label: "Purchased", value: sub.purchaseTime }); - } - - return { - account: { key: "default" }, - status: "available", - planLabel: sub.productName || undefined, - windows: [], - balances: [], - details, - }; + const details: UsageDetail[] = []; + if (sub.status) details.push({ id: "status", label: "Status", value: sub.status }); + if (sub.valid) details.push({ id: "valid", label: "Valid", value: sub.valid }); + if (sub.purchaseTime) { + details.push({ id: "purchase_time", label: "Purchased", value: sub.purchaseTime }); } + + return { + account: { key: "default" }, + status: "available", + planLabel: sub.productName || undefined, + windows: [], + balances: [], + details, + }; } diff --git a/packages/server/src/plugins/zai-usage-source/shared/input.ts b/packages/server/src/plugins/zai-usage-source/shared/input.ts index 79498e9cd5..9ee4593341 100644 --- a/packages/server/src/plugins/zai-usage-source/shared/input.ts +++ b/packages/server/src/plugins/zai-usage-source/shared/input.ts @@ -1,2 +1,4 @@ import { z } from "zod"; export const inputSchema = z.object({}).strict(); + +export type UsageInput = z.infer; diff --git a/packages/server/src/server/plugins/usage-sources/index.ts b/packages/server/src/server/plugins/usage-sources/index.ts index bba9e33989..a3beeecd25 100644 --- a/packages/server/src/server/plugins/usage-sources/index.ts +++ b/packages/server/src/server/plugins/usage-sources/index.ts @@ -52,7 +52,7 @@ export class UsageSourceRegistry { return [...unique.values()]; } - // COMPAT(providerUsageList): added in v0.9.2, remove after 2027-03-26. + // COMPAT(providerUsageList): added in v0.9.3, remove after 2027-03-26. async listLegacyUsage(): Promise<{ fetchedAt: string; providers: ProviderUsage[] }> { const reports = await this.listReports(); return { diff --git a/packages/server/src/utils/directory-suggestions.test.ts b/packages/server/src/utils/directory-suggestions.test.ts index 2611fee842..3604279b74 100644 --- a/packages/server/src/utils/directory-suggestions.test.ts +++ b/packages/server/src/utils/directory-suggestions.test.ts @@ -782,8 +782,8 @@ describe("relative typed-entry configuration", () => { "packages", "server", "src", - "services", - "quota-fetcher", + "plugins", + "usage-sources", "providers", "local.ts", ); @@ -792,7 +792,7 @@ describe("relative typed-entry configuration", () => { const results = await searchRelativeDirectoryEntries({ cwd: workspaceDir, - query: "packages/server/src/services/quota-fetcher/providers/local.ts", + query: "packages/server/src/plugins/usage-sources/providers/local.ts", limit: 20, includeFiles: true, includeDirectories: false, @@ -802,7 +802,7 @@ describe("relative typed-entry configuration", () => { expect(results).toEqual([ { - path: "packages/server/src/services/quota-fetcher/providers/local.ts", + path: "packages/server/src/plugins/usage-sources/providers/local.ts", kind: "file", }, ]); diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index abeb85a413..d9c9d87b33 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -306,7 +306,7 @@ SVG or URL. ### Usage sources -**Requires Paseo 0.9.2 or newer.** Server plugins can register a usage source with `server.registerUsageSource()` and import its types and helpers from `@getpaseo/plugin/server/usage`. +**Requires Paseo 0.9.3 or newer.** Server plugins can register a usage source with `server.registerUsageSource()` and import its types and helpers from `@getpaseo/plugin/server/usage`. ```ts import { z } from "zod";