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:
7
.changeset/fn-7783-fable-usage.md
Normal file
7
.changeset/fn-7783-fable-usage.md
Normal 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.
|
||||
@@ -9,6 +9,11 @@ const coreInteropMocks = vi.hoisted(() => ({
|
||||
readStoredCredentialsFromAuthFile: vi.fn(),
|
||||
}));
|
||||
|
||||
const nodePtyMocks = vi.hoisted(() => ({
|
||||
available: false,
|
||||
spawn: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@fusion/core", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@fusion/core")>()),
|
||||
choosePreferredStoredCredential: coreInteropMocks.choosePreferredStoredCredential,
|
||||
@@ -62,7 +67,10 @@ vi.mock("node:child_process", () => ({
|
||||
|
||||
// Mock node-pty for CLI fallback — default: not available (simulates test env)
|
||||
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", () => {
|
||||
@@ -72,6 +80,8 @@ describe("usage", () => {
|
||||
mockRequest.mockClear();
|
||||
mockReadFile.mockClear();
|
||||
mockExecFileSync.mockClear();
|
||||
nodePtyMocks.available = false;
|
||||
nodePtyMocks.spawn.mockReset();
|
||||
mockExecFileSync.mockImplementation(() => {
|
||||
throw new Error("File not found");
|
||||
});
|
||||
@@ -772,7 +782,54 @@ describe("usage", () => {
|
||||
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({
|
||||
credFileContent: {
|
||||
accessToken: "test-token",
|
||||
@@ -804,13 +861,67 @@ describe("usage", () => {
|
||||
const claude = providers.find((p) => p.name === "Claude")!;
|
||||
|
||||
expect(claude.status).toBe("ok");
|
||||
expect(claude.windows).toHaveLength(4);
|
||||
expect(claude.windows.map((w) => w.label)).toEqual([
|
||||
"Session (5h)",
|
||||
"Weekly",
|
||||
"Weekly (Sonnet)",
|
||||
"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 () => {
|
||||
|
||||
@@ -772,11 +772,12 @@ async function fetchClaudeUsageViaCli(): Promise<ProviderUsage> {
|
||||
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)
|
||||
const sections: { label: string; windowMs: number }[] = [
|
||||
const sections: { label: string; windowMs: number; displayLabel?: string }[] = [
|
||||
{ label: "Current session", windowMs: 5 * 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 (Opus", windowMs: 7 * 24 * 60 * 60 * 1000 },
|
||||
{ label: "Current week (Fable", windowMs: 7 * 24 * 60 * 60 * 1000, displayLabel: "Weekly (Fable)" },
|
||||
];
|
||||
|
||||
usage.status = "ok";
|
||||
@@ -806,7 +807,7 @@ async function fetchClaudeUsageViaCli(): Promise<ProviderUsage> {
|
||||
|
||||
if (percentUsed !== null) {
|
||||
const window: UsageWindow = {
|
||||
label: section.label,
|
||||
label: section.displayLabel ?? section.label,
|
||||
percentUsed: Math.min(100, Math.max(0, percentUsed)),
|
||||
percentLeft: Math.min(100, Math.max(0, 100 - percentUsed)),
|
||||
resetText,
|
||||
@@ -1117,11 +1118,21 @@ async function fetchClaudeUsage(authStorage?: AuthStorageLike): Promise<Provider
|
||||
const sevenDay = parseWindow("seven_day", "Weekly", 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);
|
||||
/*
|
||||
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 (sevenDay) usage.windows.push(sevenDay);
|
||||
if (sonnet) usage.windows.push(sonnet);
|
||||
if (opus) usage.windows.push(opus);
|
||||
if (fable) usage.windows.push(fable);
|
||||
} catch (e: unknown) {
|
||||
usage.status = "error";
|
||||
usage.error = e instanceof Error ? e.message : "Failed to fetch Claude usage";
|
||||
|
||||
Reference in New Issue
Block a user