From 4edd8cc2938432eb8eff4c63364c8d1859e8b489 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 11 Jul 2026 19:33:26 -0700 Subject: [PATCH] feat: show Claude Fable weekly window and Grok CLI credit usage in the Usage dropdown Claude per-model weekly usage is parsed generically from the OAuth payload's limits[] scoped entries (live probe disproved the seven_day_fable key guess). Grok now prefers ~/.grok/auth.json OIDC credentials against cli-chat-proxy.grok.com/v1/billing?format=credits for a real percent-used weekly credits window, falling back to the xAI API-key validity card. Co-Authored-By: Claude Fable 5 --- .changeset/usage-dropdown-fable-grok-cli.md | 7 + .../dashboard/src/__tests__/usage.test.ts | 210 ++++++++++++++++++ packages/dashboard/src/usage.ts | 104 ++++++++- 3 files changed, 320 insertions(+), 1 deletion(-) create mode 100644 .changeset/usage-dropdown-fable-grok-cli.md diff --git a/.changeset/usage-dropdown-fable-grok-cli.md b/.changeset/usage-dropdown-fable-grok-cli.md new file mode 100644 index 0000000000..b4d6bb9688 --- /dev/null +++ b/.changeset/usage-dropdown-fable-grok-cli.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Usage dropdown now shows the Claude Fable weekly window and Grok CLI subscription credit usage. +category: feature +dev: Claude per-model weekly usage is parsed generically from the OAuth payload's `limits[]` scoped entries (the `seven_day_fable` key guess was disproven by a live probe); Grok prefers `~/.grok/auth.json` OIDC credentials against `cli-chat-proxy.grok.com/v1/billing?format=credits`, falling back to the xAI API-key validity card. diff --git a/packages/dashboard/src/__tests__/usage.test.ts b/packages/dashboard/src/__tests__/usage.test.ts index a254439228..c89d3aac28 100644 --- a/packages/dashboard/src/__tests__/usage.test.ts +++ b/packages/dashboard/src/__tests__/usage.test.ts @@ -937,6 +937,110 @@ describe("usage", () => { expect(claude.windows.some((w) => w.label === "Weekly (Fable)")).toBe(false); }); + it("parses scoped weekly model windows from the limits[] array (live Fable payload shape)", async () => { + setupClaudeMocks({ + credFileContent: { + accessToken: "test-token", + scopes: ["user:profile"], + subscriptionType: "max", + }, + }); + + const resetsAt = new Date(Date.now() + 4 * 24 * 60 * 60 * 1000).toISOString(); + setupClaudeApiResponse({ + five_hour: { utilization: 36.0, resets_at: resetsAt }, + seven_day: { utilization: 41.0, resets_at: resetsAt }, + seven_day_sonnet: null, + seven_day_opus: null, + limits: [ + { kind: "session", group: "session", percent: 36, resets_at: resetsAt, scope: null }, + { kind: "weekly_all", group: "weekly", percent: 41, resets_at: resetsAt, scope: null }, + { + kind: "weekly_scoped", + group: "weekly", + percent: 46, + resets_at: resetsAt, + scope: { model: { id: null, display_name: "Fable" }, surface: null }, + }, + ], + }); + + const providers = await fetchAllProviderUsage(); + const claude = providers.find((p) => p.name === "Claude")!; + + expect(claude.status).toBe("ok"); + expect(claude.windows.map((w) => w.label)).toEqual([ + "Session (5h)", + "Weekly", + "Weekly (Fable)", + ]); + const fable = claude.windows.find((w) => w.label === "Weekly (Fable)")!; + expect(fable.percentUsed).toBe(46); + expect(fable.percentLeft).toBe(54); + expect(fable.resetText).toContain("resets in"); + expect(fable.resetAt).toBeDefined(); + }); + + it("does not duplicate a model window present as both seven_day_* key and limits[] entry", async () => { + setupClaudeMocks({ + credFileContent: { + accessToken: "test-token", + scopes: ["user:profile"], + subscriptionType: "max", + }, + }); + + const resetsAt = new Date(Date.now() + 4 * 24 * 60 * 60 * 1000).toISOString(); + setupClaudeApiResponse({ + seven_day_fable: { utilization: 46.0, resets_at: resetsAt }, + limits: [ + { + kind: "weekly_scoped", + group: "weekly", + percent: 46, + resets_at: resetsAt, + scope: { model: { id: null, display_name: "Fable" }, surface: null }, + }, + ], + }); + + const providers = await fetchAllProviderUsage(); + const claude = providers.find((p) => p.name === "Claude")!; + + expect(claude.windows.filter((w) => w.label === "Weekly (Fable)")).toHaveLength(1); + }); + + it("ignores limits[] entries that are session-scoped or missing model scope", async () => { + setupClaudeMocks({ + credFileContent: { + accessToken: "test-token", + scopes: ["user:profile"], + subscriptionType: "max", + }, + }); + + const resetsAt = new Date(Date.now() + 4 * 24 * 60 * 60 * 1000).toISOString(); + setupClaudeApiResponse({ + five_hour: { utilization: 10.0, resets_at: resetsAt }, + limits: [ + { kind: "session", group: "session", percent: 10, resets_at: resetsAt, scope: null }, + { + kind: "session_scoped", + group: "session", + percent: 20, + resets_at: resetsAt, + scope: { model: { id: null, display_name: "Fable" }, surface: null }, + }, + { kind: "weekly_scoped", group: "weekly", percent: 30, resets_at: resetsAt, scope: { model: null, surface: "code" } }, + ], + }); + + const providers = await fetchAllProviderUsage(); + const claude = providers.find((p) => p.name === "Claude")!; + + expect(claude.windows.map((w) => w.label)).toEqual(["Session (5h)"]); + }); + it("parses Weekly (Fable) from CLI fallback output after a 429 rate limit", async () => { setupClaudeMocks({ credFileContent: { @@ -3479,6 +3583,112 @@ describe("usage", () => { }); }; + const GROK_CLI_AUTH_JSON = JSON.stringify({ + "https://auth.x.ai::client-id": { + key: "grok-cli-oidc-token", + auth_mode: "oidc", + refresh_token: "refresh", + expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + }, + }); + + const mockGrokBillingResponse = (statusCode: number, body: unknown, apiKeyStatus = 200) => { + const mockReq = { on: vi.fn(), write: vi.fn(), end: vi.fn() }; + mockRequest.mockImplementation((options: any, callback: any) => { + let responseBody: string; + let responseStatus: number; + if (options.hostname === "cli-chat-proxy.grok.com") { + expect(options.path).toBe("/v1/billing?format=credits"); + expect(options.headers.authorization).toBe("Bearer grok-cli-oidc-token"); + responseBody = typeof body === "string" ? body : JSON.stringify(body); + responseStatus = statusCode; + } else { + expect(options.hostname).toBe("api.x.ai"); + expect(options.path).toBe("/v1/api-key"); + responseBody = JSON.stringify({ api_key_blocked: false }); + responseStatus = apiKeyStatus; + } + const mockRes = { + statusCode: responseStatus, + headers: {}, + on: vi.fn((event: string, handler: any) => { + if (event === "data") handler(Buffer.from(responseBody)); + if (event === "end") handler(); + }), + }; + callback(mockRes); + return mockReq; + }); + }; + + it("prefers grok CLI subscription billing and renders a weekly credits window", async () => { + mockReadFile.mockImplementation(async (filePath: string) => { + if (String(filePath).includes(".grok/auth.json")) return GROK_CLI_AUTH_JSON; + return Promise.reject(new Error("File not found")); + }); + mockExecFileSync.mockImplementation(() => { + throw new Error("Keychain item not found"); + }); + const periodEnd = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString(); + mockGrokBillingResponse(200, { + config: { + currentPeriod: { type: "USAGE_PERIOD_TYPE_WEEKLY", start: new Date().toISOString(), end: periodEnd }, + creditUsagePercent: 6.0, + isUnifiedBillingUser: true, + billingPeriodEnd: periodEnd, + }, + }); + + const providers = await fetchAllProviderUsage(); + const grok = providers.find((p) => p.name === "Grok")!; + + expect(grok.status).toBe("ok"); + expect(grok.windows).toHaveLength(1); + expect(grok.windows[0]).toMatchObject({ + label: "Weekly (credits)", + percentUsed: 6, + percentLeft: 94, + }); + expect(grok.windows[0].resetText).toContain("resets in"); + expect(mockRequest).toHaveBeenCalledTimes(1); + }); + + it("falls back to the xAI API-key validity card when CLI billing fails", async () => { + vi.stubEnv("GROK_API_KEY", "env-grok-key"); + mockReadFile.mockImplementation(async (filePath: string) => { + if (String(filePath).includes(".grok/auth.json")) return GROK_CLI_AUTH_JSON; + return Promise.reject(new Error("File not found")); + }); + mockExecFileSync.mockImplementation(() => { + throw new Error("Keychain item not found"); + }); + mockGrokBillingResponse(401, { error: "unauthorized" }); + + const providers = await fetchAllProviderUsage(); + const grok = providers.find((p) => p.name === "Grok")!; + + expect(grok.status).toBe("ok"); + expect(grok.windows).toEqual([]); + expect(mockRequest).toHaveBeenCalledTimes(2); + }); + + it("shows an actionable error card when only an expired grok CLI login exists", async () => { + mockReadFile.mockImplementation(async (filePath: string) => { + if (String(filePath).includes(".grok/auth.json")) return GROK_CLI_AUTH_JSON; + return Promise.reject(new Error("File not found")); + }); + mockExecFileSync.mockImplementation(() => { + throw new Error("Keychain item not found"); + }); + mockGrokBillingResponse(401, { error: "unauthorized" }); + + const providers = await fetchAllProviderUsage(); + const grok = providers.find((p) => p.name === "Grok")!; + + expect(grok.status).toBe("error"); + expect(grok.error).toContain("grok login"); + }); + it("omits Grok when no env, user settings, or auth-file key exists", async () => { mockReadFile.mockImplementation(async () => { return Promise.reject(new Error("File not found")); diff --git a/packages/dashboard/src/usage.ts b/packages/dashboard/src/usage.ts index 45d5518338..b55fb98acb 100644 --- a/packages/dashboard/src/usage.ts +++ b/packages/dashboard/src/usage.ts @@ -1126,6 +1126,9 @@ async function fetchClaudeUsage(authStorage?: AuthStorageLike): Promise)". The legacy seven_day_* keys stay first for older payloads; label dedup prevents double windows when both shapes are present. */ const fable = parseWindow("seven_day_fable", "Weekly (Fable)", SEVEN_DAYS_MS, [ "seven_day_claude_fable", @@ -1138,6 +1141,30 @@ async function fetchClaudeUsage(authStorage?: AuthStorageLike): Promise w.label === label)) continue; + + const parsedReset = _parseResetTimestamp(limit.resets_at ?? limit.reset_at ?? limit.resetsAt); + usage.windows.push({ + label, + percentUsed: Math.min(100, Math.max(0, limit.percent)), + percentLeft: Math.min(100, Math.max(0, 100 - limit.percent)), + resetText: parsedReset ? `resets in ${formatDuration(parsedReset.msLeft)}` : null, + resetMs: parsedReset?.msLeft, + resetAt: parsedReset?.resetAt, + windowDurationMs: SEVEN_DAYS_MS, + }); + } + } } catch (e: unknown) { usage.status = "error"; usage.error = e instanceof Error ? e.message : "Failed to fetch Claude usage"; @@ -1611,6 +1638,67 @@ async function readGrokUserSettingsApiKey(): Promise { } } +/* +FNXC:UsageProviders 2026-07-11-19:45: +The grok CLI (`grok login`) stores OIDC subscription credentials in `~/.grok/auth.json` as a map keyed by `::` whose entries carry a Bearer `key`. Its `/usage` command fetches subscription credit usage from `GET https://cli-chat-proxy.grok.com/v1/billing?format=credits` (verified live: returns `config.creditUsagePercent`, weekly `currentPeriod`/`billingPeriodEnd`, and per-product `productUsage`). This gives the Usage dropdown a real percent-used weekly window for Grok subscription users, unlike the xAI inference API key which only supports an auth-validity card. +*/ +async function readGrokCliOidcToken(): Promise { + try { + const raw = await readFile(path.join(getHomeDir(), ".grok", "auth.json"), "utf-8"); + const parsed = JSON.parse(raw) as Record; + for (const entry of Object.values(parsed)) { + if (entry && typeof entry.key === "string" && entry.key.trim().length > 0) { + return entry.key.trim(); + } + } + } catch { + // File doesn't exist or invalid JSON — no grok CLI login + } + return null; +} + +/** + * Fetch Grok subscription credit usage via the grok CLI's billing endpoint. + * Returns null when the request fails in any way so the caller can fall back + * to the xAI API-key auth-validity card. + */ +async function fetchGrokCliBillingUsage(token: string, usage: ProviderUsage): Promise { + try { + const res = await httpsRequest("https://cli-chat-proxy.grok.com/v1/billing?format=credits", { + method: "GET", + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json", + }, + }); + if (res.status !== 200) return false; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- untyped API response + const data: any = res.body.trim().length > 0 ? JSON.parse(res.body) : {}; + const config = data?.config; + if (!config || typeof config !== "object") return false; + + const pctUsed = config.creditUsagePercent; + if (typeof pctUsed !== "number" || !Number.isFinite(pctUsed)) return false; + + const parsedReset = _parseResetTimestamp(config.billingPeriodEnd ?? config.currentPeriod?.end); + const isWeekly = config.currentPeriod?.type === "USAGE_PERIOD_TYPE_WEEKLY"; + usage.windows.push({ + label: isWeekly ? "Weekly (credits)" : "Credits", + percentUsed: Math.min(100, Math.max(0, pctUsed)), + percentLeft: Math.min(100, Math.max(0, 100 - pctUsed)), + resetText: parsedReset ? `resets in ${formatDuration(parsedReset.msLeft)}` : null, + resetMs: parsedReset?.msLeft, + resetAt: parsedReset?.resetAt, + windowDurationMs: isWeekly ? 7 * 24 * 60 * 60 * 1000 : undefined, + }); + usage.status = "ok"; + return true; + } catch { + return false; + } +} + async function readGrokApiKey(authStorage?: AuthStorageLike): Promise { const envKey = process.env.GROK_API_KEY; if (typeof envKey === "string" && envKey.trim().length > 0) { @@ -1633,9 +1721,23 @@ async function fetchGrokUsage(authStorage?: AuthStorageLike): Promise