feat: show Claude Fable weekly window and Grok CLI credit usage in the Usage dropdown

Claude per-model weekly usage is parsed generically from the OAuth payload's
limits[] scoped entries (live probe disproved the seven_day_fable key guess).
Grok now prefers ~/.grok/auth.json OIDC credentials against
cli-chat-proxy.grok.com/v1/billing?format=credits for a real percent-used
weekly credits window, falling back to the xAI API-key validity card.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-11 19:33:26 -07:00
parent b7f82ee9fd
commit 4edd8cc293
3 changed files with 320 additions and 1 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Usage dropdown now shows the Claude Fable weekly window and Grok CLI subscription credit usage.
category: feature
dev: Claude per-model weekly usage is parsed generically from the OAuth payload's `limits[]` scoped entries (the `seven_day_fable` key guess was disproven by a live probe); Grok prefers `~/.grok/auth.json` OIDC credentials against `cli-chat-proxy.grok.com/v1/billing?format=credits`, falling back to the xAI API-key validity card.

View File

@@ -937,6 +937,110 @@ describe("usage", () => {
expect(claude.windows.some((w) => w.label === "Weekly (Fable)")).toBe(false);
});
it("parses scoped weekly model windows from the limits[] array (live Fable payload shape)", async () => {
setupClaudeMocks({
credFileContent: {
accessToken: "test-token",
scopes: ["user:profile"],
subscriptionType: "max",
},
});
const resetsAt = new Date(Date.now() + 4 * 24 * 60 * 60 * 1000).toISOString();
setupClaudeApiResponse({
five_hour: { utilization: 36.0, resets_at: resetsAt },
seven_day: { utilization: 41.0, resets_at: resetsAt },
seven_day_sonnet: null,
seven_day_opus: null,
limits: [
{ kind: "session", group: "session", percent: 36, resets_at: resetsAt, scope: null },
{ kind: "weekly_all", group: "weekly", percent: 41, resets_at: resetsAt, scope: null },
{
kind: "weekly_scoped",
group: "weekly",
percent: 46,
resets_at: resetsAt,
scope: { model: { id: null, display_name: "Fable" }, surface: null },
},
],
});
const providers = await fetchAllProviderUsage();
const claude = providers.find((p) => p.name === "Claude")!;
expect(claude.status).toBe("ok");
expect(claude.windows.map((w) => w.label)).toEqual([
"Session (5h)",
"Weekly",
"Weekly (Fable)",
]);
const fable = claude.windows.find((w) => w.label === "Weekly (Fable)")!;
expect(fable.percentUsed).toBe(46);
expect(fable.percentLeft).toBe(54);
expect(fable.resetText).toContain("resets in");
expect(fable.resetAt).toBeDefined();
});
it("does not duplicate a model window present as both seven_day_* key and limits[] entry", async () => {
setupClaudeMocks({
credFileContent: {
accessToken: "test-token",
scopes: ["user:profile"],
subscriptionType: "max",
},
});
const resetsAt = new Date(Date.now() + 4 * 24 * 60 * 60 * 1000).toISOString();
setupClaudeApiResponse({
seven_day_fable: { utilization: 46.0, resets_at: resetsAt },
limits: [
{
kind: "weekly_scoped",
group: "weekly",
percent: 46,
resets_at: resetsAt,
scope: { model: { id: null, display_name: "Fable" }, surface: null },
},
],
});
const providers = await fetchAllProviderUsage();
const claude = providers.find((p) => p.name === "Claude")!;
expect(claude.windows.filter((w) => w.label === "Weekly (Fable)")).toHaveLength(1);
});
it("ignores limits[] entries that are session-scoped or missing model scope", async () => {
setupClaudeMocks({
credFileContent: {
accessToken: "test-token",
scopes: ["user:profile"],
subscriptionType: "max",
},
});
const resetsAt = new Date(Date.now() + 4 * 24 * 60 * 60 * 1000).toISOString();
setupClaudeApiResponse({
five_hour: { utilization: 10.0, resets_at: resetsAt },
limits: [
{ kind: "session", group: "session", percent: 10, resets_at: resetsAt, scope: null },
{
kind: "session_scoped",
group: "session",
percent: 20,
resets_at: resetsAt,
scope: { model: { id: null, display_name: "Fable" }, surface: null },
},
{ kind: "weekly_scoped", group: "weekly", percent: 30, resets_at: resetsAt, scope: { model: null, surface: "code" } },
],
});
const providers = await fetchAllProviderUsage();
const claude = providers.find((p) => p.name === "Claude")!;
expect(claude.windows.map((w) => w.label)).toEqual(["Session (5h)"]);
});
it("parses Weekly (Fable) from CLI fallback output after a 429 rate limit", async () => {
setupClaudeMocks({
credFileContent: {
@@ -3479,6 +3583,112 @@ describe("usage", () => {
});
};
const GROK_CLI_AUTH_JSON = JSON.stringify({
"https://auth.x.ai::client-id": {
key: "grok-cli-oidc-token",
auth_mode: "oidc",
refresh_token: "refresh",
expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
},
});
const mockGrokBillingResponse = (statusCode: number, body: unknown, apiKeyStatus = 200) => {
const mockReq = { on: vi.fn(), write: vi.fn(), end: vi.fn() };
mockRequest.mockImplementation((options: any, callback: any) => {
let responseBody: string;
let responseStatus: number;
if (options.hostname === "cli-chat-proxy.grok.com") {
expect(options.path).toBe("/v1/billing?format=credits");
expect(options.headers.authorization).toBe("Bearer grok-cli-oidc-token");
responseBody = typeof body === "string" ? body : JSON.stringify(body);
responseStatus = statusCode;
} else {
expect(options.hostname).toBe("api.x.ai");
expect(options.path).toBe("/v1/api-key");
responseBody = JSON.stringify({ api_key_blocked: false });
responseStatus = apiKeyStatus;
}
const mockRes = {
statusCode: responseStatus,
headers: {},
on: vi.fn((event: string, handler: any) => {
if (event === "data") handler(Buffer.from(responseBody));
if (event === "end") handler();
}),
};
callback(mockRes);
return mockReq;
});
};
it("prefers grok CLI subscription billing and renders a weekly credits window", async () => {
mockReadFile.mockImplementation(async (filePath: string) => {
if (String(filePath).includes(".grok/auth.json")) return GROK_CLI_AUTH_JSON;
return Promise.reject(new Error("File not found"));
});
mockExecFileSync.mockImplementation(() => {
throw new Error("Keychain item not found");
});
const periodEnd = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString();
mockGrokBillingResponse(200, {
config: {
currentPeriod: { type: "USAGE_PERIOD_TYPE_WEEKLY", start: new Date().toISOString(), end: periodEnd },
creditUsagePercent: 6.0,
isUnifiedBillingUser: true,
billingPeriodEnd: periodEnd,
},
});
const providers = await fetchAllProviderUsage();
const grok = providers.find((p) => p.name === "Grok")!;
expect(grok.status).toBe("ok");
expect(grok.windows).toHaveLength(1);
expect(grok.windows[0]).toMatchObject({
label: "Weekly (credits)",
percentUsed: 6,
percentLeft: 94,
});
expect(grok.windows[0].resetText).toContain("resets in");
expect(mockRequest).toHaveBeenCalledTimes(1);
});
it("falls back to the xAI API-key validity card when CLI billing fails", async () => {
vi.stubEnv("GROK_API_KEY", "env-grok-key");
mockReadFile.mockImplementation(async (filePath: string) => {
if (String(filePath).includes(".grok/auth.json")) return GROK_CLI_AUTH_JSON;
return Promise.reject(new Error("File not found"));
});
mockExecFileSync.mockImplementation(() => {
throw new Error("Keychain item not found");
});
mockGrokBillingResponse(401, { error: "unauthorized" });
const providers = await fetchAllProviderUsage();
const grok = providers.find((p) => p.name === "Grok")!;
expect(grok.status).toBe("ok");
expect(grok.windows).toEqual([]);
expect(mockRequest).toHaveBeenCalledTimes(2);
});
it("shows an actionable error card when only an expired grok CLI login exists", async () => {
mockReadFile.mockImplementation(async (filePath: string) => {
if (String(filePath).includes(".grok/auth.json")) return GROK_CLI_AUTH_JSON;
return Promise.reject(new Error("File not found"));
});
mockExecFileSync.mockImplementation(() => {
throw new Error("Keychain item not found");
});
mockGrokBillingResponse(401, { error: "unauthorized" });
const providers = await fetchAllProviderUsage();
const grok = providers.find((p) => p.name === "Grok")!;
expect(grok.status).toBe("error");
expect(grok.error).toContain("grok login");
});
it("omits Grok when no env, user settings, or auth-file key exists", async () => {
mockReadFile.mockImplementation(async () => {
return Promise.reject(new Error("File not found"));

View File

@@ -1126,6 +1126,9 @@ async function fetchClaudeUsage(authStorage?: AuthStorageLike): Promise<Provider
/*
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.
FNXC:UsageIndicator 2026-07-11-19:40:
A live OAuth usage probe disproved the `seven_day_fable` guess: Anthropic ships per-model weekly usage in the top-level `limits[]` array as `{ kind: "weekly_scoped", group: "weekly", percent, resets_at, scope.model.display_name }` (observed live with display_name "Fable"), while `seven_day_opus`/`seven_day_sonnet` are now null. Parse `limits[]` generically so every scoped weekly model bucket (Fable today, future models automatically) appears in the Usage dropdown as "Weekly (<model>)". The legacy seven_day_* keys stay first for older payloads; label dedup prevents double windows when both shapes are present.
*/
const fable = parseWindow("seven_day_fable", "Weekly (Fable)", SEVEN_DAYS_MS, [
"seven_day_claude_fable",
@@ -1138,6 +1141,30 @@ async function fetchClaudeUsage(authStorage?: AuthStorageLike): Promise<Provider
if (sonnet) usage.windows.push(sonnet);
if (opus) usage.windows.push(opus);
if (fable) usage.windows.push(fable);
if (Array.isArray(data.limits)) {
for (const limit of data.limits) {
if (!limit || typeof limit !== "object") continue;
const modelName = limit?.scope?.model?.display_name;
if (typeof modelName !== "string" || modelName.trim().length === 0) continue;
if (typeof limit.percent !== "number" || !Number.isFinite(limit.percent)) continue;
const isWeekly = limit.group === "weekly" || (typeof limit.kind === "string" && limit.kind.startsWith("weekly"));
if (!isWeekly) continue;
const label = `Weekly (${modelName.trim()})`;
if (usage.windows.some((w) => w.label === label)) continue;
const parsedReset = _parseResetTimestamp(limit.resets_at ?? limit.reset_at ?? limit.resetsAt);
usage.windows.push({
label,
percentUsed: Math.min(100, Math.max(0, limit.percent)),
percentLeft: Math.min(100, Math.max(0, 100 - limit.percent)),
resetText: parsedReset ? `resets in ${formatDuration(parsedReset.msLeft)}` : null,
resetMs: parsedReset?.msLeft,
resetAt: parsedReset?.resetAt,
windowDurationMs: SEVEN_DAYS_MS,
});
}
}
} catch (e: unknown) {
usage.status = "error";
usage.error = e instanceof Error ? e.message : "Failed to fetch Claude usage";
@@ -1611,6 +1638,67 @@ async function readGrokUserSettingsApiKey(): Promise<string | null> {
}
}
/*
FNXC:UsageProviders 2026-07-11-19:45:
The grok CLI (`grok login`) stores OIDC subscription credentials in `~/.grok/auth.json` as a map keyed by `<issuer>::<client_id>` whose entries carry a Bearer `key`. Its `/usage` command fetches subscription credit usage from `GET https://cli-chat-proxy.grok.com/v1/billing?format=credits` (verified live: returns `config.creditUsagePercent`, weekly `currentPeriod`/`billingPeriodEnd`, and per-product `productUsage`). This gives the Usage dropdown a real percent-used weekly window for Grok subscription users, unlike the xAI inference API key which only supports an auth-validity card.
*/
async function readGrokCliOidcToken(): Promise<string | null> {
try {
const raw = await readFile(path.join(getHomeDir(), ".grok", "auth.json"), "utf-8");
const parsed = JSON.parse(raw) as Record<string, { key?: unknown } | null>;
for (const entry of Object.values(parsed)) {
if (entry && typeof entry.key === "string" && entry.key.trim().length > 0) {
return entry.key.trim();
}
}
} catch {
// File doesn't exist or invalid JSON — no grok CLI login
}
return null;
}
/**
* Fetch Grok subscription credit usage via the grok CLI's billing endpoint.
* Returns null when the request fails in any way so the caller can fall back
* to the xAI API-key auth-validity card.
*/
async function fetchGrokCliBillingUsage(token: string, usage: ProviderUsage): Promise<boolean> {
try {
const res = await httpsRequest("https://cli-chat-proxy.grok.com/v1/billing?format=credits", {
method: "GET",
headers: {
authorization: `Bearer ${token}`,
"content-type": "application/json",
},
});
if (res.status !== 200) return false;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- untyped API response
const data: any = res.body.trim().length > 0 ? JSON.parse(res.body) : {};
const config = data?.config;
if (!config || typeof config !== "object") return false;
const pctUsed = config.creditUsagePercent;
if (typeof pctUsed !== "number" || !Number.isFinite(pctUsed)) return false;
const parsedReset = _parseResetTimestamp(config.billingPeriodEnd ?? config.currentPeriod?.end);
const isWeekly = config.currentPeriod?.type === "USAGE_PERIOD_TYPE_WEEKLY";
usage.windows.push({
label: isWeekly ? "Weekly (credits)" : "Credits",
percentUsed: Math.min(100, Math.max(0, pctUsed)),
percentLeft: Math.min(100, Math.max(0, 100 - pctUsed)),
resetText: parsedReset ? `resets in ${formatDuration(parsedReset.msLeft)}` : null,
resetMs: parsedReset?.msLeft,
resetAt: parsedReset?.resetAt,
windowDurationMs: isWeekly ? 7 * 24 * 60 * 60 * 1000 : undefined,
});
usage.status = "ok";
return true;
} catch {
return false;
}
}
async function readGrokApiKey(authStorage?: AuthStorageLike): Promise<string | null> {
const envKey = process.env.GROK_API_KEY;
if (typeof envKey === "string" && envKey.trim().length > 0) {
@@ -1633,9 +1721,23 @@ async function fetchGrokUsage(authStorage?: AuthStorageLike): Promise<ProviderUs
windows: [],
};
// Prefer grok CLI subscription credentials — they yield a real percent-used
// weekly credits window instead of the API-key auth-validity card below.
const cliToken = await readGrokCliOidcToken();
if (cliToken && (await fetchGrokCliBillingUsage(cliToken, usage))) {
return usage;
}
const apiKey = await readGrokApiKey(authStorage);
if (!apiKey) {
usage.error = "No Grok credentials — set GROK_API_KEY or add a key";
if (cliToken) {
// A grok CLI login exists but its billing call failed — surface an
// actionable error card instead of hiding the provider as no-auth.
usage.status = "error";
usage.error = "Grok CLI auth expired — run 'grok login' (or set GROK_API_KEY)";
} else {
usage.error = "No Grok credentials — set GROK_API_KEY or add a key";
}
return usage;
}