fix(dashboard): report Claude CLI login and API billing states (#2338)

## 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


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## 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.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: v <v@v.speedport.ip>
Co-authored-by: gsxdsm <gsxdsm@users.noreply.github.com>
This commit is contained in:
flexi767
2026-07-22 01:54:28 +02:00
committed by GitHub
parent eef5eb751e
commit f49f5eb39a
3 changed files with 147 additions and 0 deletions

View File

@@ -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.

View File

@@ -1095,6 +1095,121 @@ describe("usage", () => {
_resetSleepFn(); _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 () => { it("falls back to CLI parsing on 429 rate limit", async () => {
setupClaudeMocks({ setupClaudeMocks({
credFileContent: { credFileContent: {

View File

@@ -707,6 +707,31 @@ async function fetchClaudeUsageViaCli(): Promise<ProviderUsage> {
return; 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 // Auto-approve trust prompt
if ( if (
!approvedTrust && !approvedTrust &&