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:
7
.changeset/fix-claude-usage-login-timeout.md
Normal file
7
.changeset/fix-claude-usage-login-timeout.md
Normal 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.
|
||||
@@ -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: {
|
||||
|
||||
@@ -707,6 +707,31 @@ async function fetchClaudeUsageViaCli(): Promise<ProviderUsage> {
|
||||
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 &&
|
||||
|
||||
Reference in New Issue
Block a user