From f49f5eb39a3f7232f4f5a847644b28be52686b03 Mon Sep 17 00:00:00 2001 From: flexi767 <96955327+flexi767@users.noreply.github.com> Date: Wed, 22 Jul 2026 01:54:28 +0200 Subject: [PATCH] fix(dashboard): report Claude CLI login and API billing states (#2338) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What changed - Detect the Claude CLI `Not logged in · Run /login` screen and return an actionable error immediately. - Detect Claude 2.1.x `API Usage Billing` session-stat output, which has no subscription quota windows, instead of waiting for the PTY timeout. - Keep the existing quota-window parser unchanged for authenticated subscription sessions. ## Root cause and impact Fusion only recognized the older `/usage` quota view. Newer Claude CLI builds can render a login prompt or API-billing session statistics after accepting `/usage`; neither contains quota windows, so Fusion waited 60 seconds and reported a misleading timeout even though it had already received conclusive output. Operators now get an immediate instruction to run `claude /login` on the Fusion host. ## Validation - `vitest run src/__tests__/usage.test.ts`: 196 passed - Dashboard TypeScript typecheck passed - Changeset format check passed ## Summary by CodeRabbit * **Bug Fixes** * Claude usage detection now recognizes authentication-required states and stops immediately instead of waiting and reporting a misleading “usage timeout.” * Enhanced parsing/fallback behavior for Claude “usage” and billing/session statistics screens, improving reliability when subscription quota data isn’t available. * **Tests** * Added coverage for Claude CLI fallback scenarios after HTTP 429 responses, including validation of correct error messaging and process termination behavior. --------- Co-authored-by: v Co-authored-by: gsxdsm --- .changeset/fix-claude-usage-login-timeout.md | 7 ++ .../dashboard/src/__tests__/usage.test.ts | 115 ++++++++++++++++++ packages/dashboard/src/usage.ts | 25 ++++ 3 files changed, 147 insertions(+) create mode 100644 .changeset/fix-claude-usage-login-timeout.md diff --git a/.changeset/fix-claude-usage-login-timeout.md b/.changeset/fix-claude-usage-login-timeout.md new file mode 100644 index 0000000000..7964b62da9 --- /dev/null +++ b/.changeset/fix-claude-usage-login-timeout.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Report when the server Claude CLI needs login instead of waiting a minute and showing a false usage timeout. +category: fix +dev: Detects Claude Code 2.1.x unauthenticated and API-billing session-stat screens during the PTY quota fallback and exits immediately. diff --git a/packages/dashboard/src/__tests__/usage.test.ts b/packages/dashboard/src/__tests__/usage.test.ts index 247ecfb3a4..166180099a 100644 --- a/packages/dashboard/src/__tests__/usage.test.ts +++ b/packages/dashboard/src/__tests__/usage.test.ts @@ -1095,6 +1095,121 @@ describe("usage", () => { _resetSleepFn(); }); + it("reports the server login requirement immediately when the Claude CLI usage screen is unauthenticated", async () => { + setupClaudeMocks({ + credFileContent: { + accessToken: "test-token", + scopes: ["user:profile"], + }, + }); + + _setSleepFn(async () => {}); + nodePtyMocks.available = true; + const kill = vi.fn(); + nodePtyMocks.spawn.mockImplementation(() => ({ + write: vi.fn(), + kill, + onData: vi.fn((handler: (data: string) => void) => { + handler("Not logged in · Run /login\nSession\nTotal cost: $0.0000"); + }), + onExit: vi.fn((handler: () => void) => { + handler(); + }), + })); + + const mockReq = { on: vi.fn(), write: vi.fn(), end: vi.fn() }; + mockRequest.mockImplementation((_options: any, callback: any) => { + const mockRes = { + statusCode: 429, + headers: {}, + on: vi.fn((event: string, handler: any) => { + if (event === "data") handler(Buffer.from('{"error":"rate_limited"}')); + if (event === "end") handler(); + }), + }; + callback(mockRes); + return mockReq; + }); + + /* + FNXC:UsageTesting 2026-07-21-21:30: + Claude usage probes must restore injected sleep behavior even when an assertion fails so later provider tests cannot inherit the mock. + */ + try { + const providers = await fetchAllProviderUsage(); + const claude = providers.find((provider) => provider.name === "Claude")!; + + expect(claude.status).toBe("error"); + expect(claude.error).toBe( + "Claude CLI has no subscription quota session on the Fusion server. Run `claude /login` there, then refresh Usage.", + ); + expect(kill).toHaveBeenCalledOnce(); + } finally { + _resetSleepFn(); + } + }); + + it("reports the server login requirement when Claude 2.1.x shows API billing session statistics", async () => { + setupClaudeMocks({ + credFileContent: { + accessToken: "test-token", + scopes: ["user:profile"], + }, + }); + + _setSleepFn(async () => {}); + nodePtyMocks.available = true; + const kill = vi.fn(); + nodePtyMocks.spawn.mockImplementation(() => { + let dataHandler: ((data: string) => void) | undefined; + const process = { + write: vi.fn((input: string) => { + if (input === "/usage\r") { + dataHandler?.("Settings Status Config Usage Stats\nSession\nTotal cost: $0.0000\nUsage: 0 input, 0 output"); + } + }), + kill, + onData: vi.fn((handler: (data: string) => void) => { + dataHandler = handler; + handler("Claude Code v2.1.215\nSonnet 5 · API Usage Billing\n❯ ? for shortcuts"); + }), + onExit: vi.fn(), + }; + return process; + }); + + const mockReq = { on: vi.fn(), write: vi.fn(), end: vi.fn() }; + mockRequest.mockImplementation((_options: any, callback: any) => { + const mockRes = { + statusCode: 429, + headers: {}, + on: vi.fn((event: string, handler: any) => { + if (event === "data") handler(Buffer.from('{"error":"rate_limited"}')); + if (event === "end") handler(); + }), + }; + callback(mockRes); + return mockReq; + }); + + vi.useFakeTimers(); + try { + const providersPromise = fetchAllProviderUsage(); + await vi.advanceTimersByTimeAsync(1_500); + const providers = await providersPromise; + const claude = providers.find((provider) => provider.name === "Claude")!; + + expect(claude.status).toBe("error"); + expect(claude.error).toBe( + "Claude CLI has no subscription quota session on the Fusion server. Run `claude /login` there, then refresh Usage.", + ); + expect(kill).toHaveBeenCalledOnce(); + } finally { + vi.useRealTimers(); + _resetSleepFn(); + } + }); + it("falls back to CLI parsing on 429 rate limit", async () => { setupClaudeMocks({ credFileContent: { diff --git a/packages/dashboard/src/usage.ts b/packages/dashboard/src/usage.ts index bf8e171bff..d9c41b2e47 100644 --- a/packages/dashboard/src/usage.ts +++ b/packages/dashboard/src/usage.ts @@ -707,6 +707,31 @@ async function fetchClaudeUsageViaCli(): Promise { return; } + /* + * FNXC:ClaudeUsage 2026-07-19-18:00: + * Claude Code 2.1.x can render its settings Usage tab while the CLI is + * unauthenticated. That screen contains ordinary session statistics but + * no subscription quota percentages, so the old detector waited the + * full minute and falsely described the result as a timeout. Surface the + * TUI's explicit login state immediately and tell remote-dashboard users + * where the login must happen. + */ + const isClaudeLoginRequired = /not logged in\s*[·•-]?\s*run\s*\/login/i.test(clean); + const isApiBillingSessionStats = + sentCommand && + clean.includes("API Usage Billing") && + clean.includes("Total cost:") && + /Usage:\s*\d+\s+input/i.test(clean); + if (isClaudeLoginRequired || isApiBillingSessionStats) { + settled = true; + clearTimeout(timeout); + try { ptyProcess.kill(); } catch { + // Kill may fail if process already exited - ignore + } + reject(new Error("Claude CLI has no subscription quota session on the Fusion server. Run `claude /login` there, then refresh Usage.")); + return; + } + // Auto-approve trust prompt if ( !approvedTrust &&