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:
7
.changeset/usage-dropdown-fable-grok-cli.md
Normal file
7
.changeset/usage-dropdown-fable-grok-cli.md
Normal 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.
|
||||
@@ -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"));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user