FN-7783: add Fable weekly usage window to Usage dropdown

Adds parsing and display support for a Claude "Weekly (Fable)" usage window alongside existing Sonnet/Opus per-model windows.
- fetchClaudeUsage parses seven_day_fable (with tolerant fallback keys: seven_day_claude_fable, fable, seven_day_fable_5) and pushes a Fable usage window
- fetchClaudeUsageViaCli recognizes a "Current week (Fable" CLI section and renders it as "Weekly (Fable)"
- Adds regression tests covering both API and CLI parsing paths
- Adds changeset documenting the new minor feature

Files changed:
 .changeset/fn-7783-fable-usage.md              |   7 ++
 packages/dashboard/src/__tests__/usage.test.ts | 117 ++++++++++++++++++++++++-
 packages/dashboard/src/usage.ts                |  15 +++-
 3 files changed, 134 insertions(+), 5 deletions(-)

Fusion-Task-Id: FN-7783

Fusion-Task-Lineage: 0ed79ed5-15f3-4b6a-a443-d99653e72448

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-10 08:09:12 -07:00
parent f5fd8b84f8
commit 03073afa85
3 changed files with 134 additions and 5 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Show a Claude "Weekly (Fable)" usage window in the Usage dropdown.
category: feature
dev: usage.ts fetchClaudeUsage parses seven_day_fable (with tolerant fallback keys) and fetchClaudeUsageViaCli adds a "Current week (Fable" section; frontend renders it generically. API field name assumed seven_day_fable.

View File

@@ -9,6 +9,11 @@ const coreInteropMocks = vi.hoisted(() => ({
readStoredCredentialsFromAuthFile: vi.fn(), readStoredCredentialsFromAuthFile: vi.fn(),
})); }));
const nodePtyMocks = vi.hoisted(() => ({
available: false,
spawn: vi.fn(),
}));
vi.mock("@fusion/core", async (importOriginal) => ({ vi.mock("@fusion/core", async (importOriginal) => ({
...(await importOriginal<typeof import("@fusion/core")>()), ...(await importOriginal<typeof import("@fusion/core")>()),
choosePreferredStoredCredential: coreInteropMocks.choosePreferredStoredCredential, choosePreferredStoredCredential: coreInteropMocks.choosePreferredStoredCredential,
@@ -62,7 +67,10 @@ vi.mock("node:child_process", () => ({
// Mock node-pty for CLI fallback — default: not available (simulates test env) // Mock node-pty for CLI fallback — default: not available (simulates test env)
vi.mock("node-pty", () => { vi.mock("node-pty", () => {
throw new Error("node-pty not available in test environment"); if (!nodePtyMocks.available) {
throw new Error("node-pty not available in test environment");
}
return { spawn: nodePtyMocks.spawn };
}); });
describe("usage", () => { describe("usage", () => {
@@ -72,6 +80,8 @@ describe("usage", () => {
mockRequest.mockClear(); mockRequest.mockClear();
mockReadFile.mockClear(); mockReadFile.mockClear();
mockExecFileSync.mockClear(); mockExecFileSync.mockClear();
nodePtyMocks.available = false;
nodePtyMocks.spawn.mockReset();
mockExecFileSync.mockImplementation(() => { mockExecFileSync.mockImplementation(() => {
throw new Error("File not found"); throw new Error("File not found");
}); });
@@ -772,7 +782,54 @@ describe("usage", () => {
expect(sessionWindow!.resetText).toContain("resets in"); expect(sessionWindow!.resetText).toContain("resets in");
}); });
it("parses all four usage windows from API response", async () => { it("parses all five usage windows from API response", async () => {
setupClaudeMocks({
credFileContent: {
accessToken: "test-token",
scopes: ["user:profile"],
subscriptionType: "max",
},
});
setupClaudeApiResponse({
five_hour: {
utilization: 40.0,
resets_at: new Date(Date.now() + 2 * 60 * 60 * 1000).toISOString(),
},
seven_day: {
utilization: 20.0,
resets_at: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000).toISOString(),
},
seven_day_sonnet: {
utilization: 15.0,
resets_at: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000).toISOString(),
},
seven_day_opus: {
utilization: 5.0,
resets_at: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000).toISOString(),
},
seven_day_fable: {
utilization: 0,
resets_at: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000).toISOString(),
},
});
const providers = await fetchAllProviderUsage();
const claude = providers.find((p) => p.name === "Claude")!;
expect(claude.status).toBe("ok");
expect(claude.windows).toHaveLength(5);
expect(claude.windows.map((w) => w.label)).toEqual([
"Session (5h)",
"Weekly",
"Weekly (Sonnet)",
"Weekly (Opus)",
"Weekly (Fable)",
]);
expect(claude.windows.find((w) => w.label === "Weekly (Fable)")?.percentUsed).toBe(0);
});
it("omits Weekly (Fable) when API response has no Fable window", async () => {
setupClaudeMocks({ setupClaudeMocks({
credFileContent: { credFileContent: {
accessToken: "test-token", accessToken: "test-token",
@@ -804,13 +861,67 @@ describe("usage", () => {
const claude = providers.find((p) => p.name === "Claude")!; const claude = providers.find((p) => p.name === "Claude")!;
expect(claude.status).toBe("ok"); expect(claude.status).toBe("ok");
expect(claude.windows).toHaveLength(4);
expect(claude.windows.map((w) => w.label)).toEqual([ expect(claude.windows.map((w) => w.label)).toEqual([
"Session (5h)", "Session (5h)",
"Weekly", "Weekly",
"Weekly (Sonnet)", "Weekly (Sonnet)",
"Weekly (Opus)", "Weekly (Opus)",
]); ]);
expect(claude.windows.some((w) => w.label === "Weekly (Fable)")).toBe(false);
});
it("parses Weekly (Fable) from CLI fallback output after a 429 rate limit", async () => {
setupClaudeMocks({
credFileContent: {
accessToken: "test-token",
scopes: ["user:profile"],
},
});
_setSleepFn(async () => {});
nodePtyMocks.available = true;
nodePtyMocks.spawn.mockImplementation(() => ({
write: vi.fn(),
kill: vi.fn(),
onData: vi.fn((handler: (data: string) => void) => {
handler([
"Current week (Fable)",
"████ 12% used",
"Resets in 2d 4h",
].join("\n"));
}),
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;
});
const providers = await fetchAllProviderUsage();
const claude = providers.find((p) => p.name === "Claude")!;
expect(claude.status).toBe("ok");
expect(claude.windows).toHaveLength(1);
expect(claude.windows[0]).toMatchObject({
label: "Weekly (Fable)",
percentUsed: 12,
percentLeft: 88,
});
expect(mockRequest).toHaveBeenCalledTimes(3);
_resetSleepFn();
}); });
it("falls back to CLI parsing on 429 rate limit", async () => { it("falls back to CLI parsing on 429 rate limit", async () => {

View File

@@ -772,11 +772,12 @@ async function fetchClaudeUsageViaCli(): Promise<ProviderUsage> {
const lines = cleanOutput.split("\n").map((l) => l.trim()).filter(Boolean); const lines = cleanOutput.split("\n").map((l) => l.trim()).filter(Boolean);
// Find sections by looking for known headers (use LAST occurrence since PTY output has redraws) // Find sections by looking for known headers (use LAST occurrence since PTY output has redraws)
const sections: { label: string; windowMs: number }[] = [ const sections: { label: string; windowMs: number; displayLabel?: string }[] = [
{ label: "Current session", windowMs: 5 * 60 * 60 * 1000 }, { label: "Current session", windowMs: 5 * 60 * 60 * 1000 },
{ label: "Current week (all models)", windowMs: 7 * 24 * 60 * 60 * 1000 }, { label: "Current week (all models)", windowMs: 7 * 24 * 60 * 60 * 1000 },
{ label: "Current week (Sonnet", windowMs: 7 * 24 * 60 * 60 * 1000 }, { label: "Current week (Sonnet", windowMs: 7 * 24 * 60 * 60 * 1000 },
{ label: "Current week (Opus", windowMs: 7 * 24 * 60 * 60 * 1000 }, { label: "Current week (Opus", windowMs: 7 * 24 * 60 * 60 * 1000 },
{ label: "Current week (Fable", windowMs: 7 * 24 * 60 * 60 * 1000, displayLabel: "Weekly (Fable)" },
]; ];
usage.status = "ok"; usage.status = "ok";
@@ -806,7 +807,7 @@ async function fetchClaudeUsageViaCli(): Promise<ProviderUsage> {
if (percentUsed !== null) { if (percentUsed !== null) {
const window: UsageWindow = { const window: UsageWindow = {
label: section.label, label: section.displayLabel ?? section.label,
percentUsed: Math.min(100, Math.max(0, percentUsed)), percentUsed: Math.min(100, Math.max(0, percentUsed)),
percentLeft: Math.min(100, Math.max(0, 100 - percentUsed)), percentLeft: Math.min(100, Math.max(0, 100 - percentUsed)),
resetText, resetText,
@@ -1117,11 +1118,21 @@ async function fetchClaudeUsage(authStorage?: AuthStorageLike): Promise<Provider
const sevenDay = parseWindow("seven_day", "Weekly", SEVEN_DAYS_MS); const sevenDay = parseWindow("seven_day", "Weekly", SEVEN_DAYS_MS);
const sonnet = parseWindow("seven_day_sonnet", "Weekly (Sonnet)", SEVEN_DAYS_MS); const sonnet = parseWindow("seven_day_sonnet", "Weekly (Sonnet)", SEVEN_DAYS_MS);
const opus = parseWindow("seven_day_opus", "Weekly (Opus)", SEVEN_DAYS_MS); const opus = parseWindow("seven_day_opus", "Weekly (Opus)", SEVEN_DAYS_MS);
/*
FNXC:UsageIndicator 2026-07-10-00:00:
Claude Fable 5 is a first-class Anthropic model, so the Usage dropdown must mirror the Sonnet/Opus per-model weekly windows when Anthropic returns a Fable usage bucket. `seven_day_fable` follows the existing API naming convention but remains an assumed primary key until a live OAuth usage payload confirms it; tolerant fallbacks keep the operator-visible window working if Anthropic ships a nearby field name.
*/
const fable = parseWindow("seven_day_fable", "Weekly (Fable)", SEVEN_DAYS_MS, [
"seven_day_claude_fable",
"fable",
"seven_day_fable_5",
]);
if (fiveHour) usage.windows.push(fiveHour); if (fiveHour) usage.windows.push(fiveHour);
if (sevenDay) usage.windows.push(sevenDay); if (sevenDay) usage.windows.push(sevenDay);
if (sonnet) usage.windows.push(sonnet); if (sonnet) usage.windows.push(sonnet);
if (opus) usage.windows.push(opus); if (opus) usage.windows.push(opus);
if (fable) usage.windows.push(fable);
} catch (e: unknown) { } catch (e: unknown) {
usage.status = "error"; usage.status = "error";
usage.error = e instanceof Error ? e.message : "Failed to fetch Claude usage"; usage.error = e instanceof Error ? e.message : "Failed to fetch Claude usage";