FN-8650: preserve Grok CLI authentication status
Keep unavailable Grok CLI billing data distinct from authentication expiry. - Preserve billing outcomes for usage windows, missing data, HTTP responses, and transport failures. - Mark CLI authentication expired only after observed 401 or 403 responses. - Cover unmeterable authenticated accounts in usage and indicator tests. - Add a patch changeset for the corrected indicator behavior. Files changed: .changeset/fn-8650-grok-cli-auth-expired.md | 7 ++ .../components/__tests__/UsageIndicator.test.tsx | 16 +++++ packages/dashboard/src/__tests__/usage.test.ts | 79 ++++++++++++++++++++-- packages/dashboard/src/usage.ts | 57 ++++++++++++---- 4 files changed, 139 insertions(+), 20 deletions(-) Fusion-Task-Id: FN-8650 Fusion-Task-Lineage: f18cceb6-1092-4073-b540-9c7a5434fbcd Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8650-grok-cli-auth-expired.md
Normal file
7
.changeset/fn-8650-grok-cli-auth-expired.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Stop reporting a working Grok CLI login as expired auth in the Usage indicator.
|
||||
category: fix
|
||||
dev: Grok billing fetches now preserve auth, no-data, HTTP, and transport outcomes.
|
||||
@@ -185,6 +185,22 @@ describe("UsageIndicator", () => {
|
||||
expect(screen.getByText("Hourly")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders an authenticated provider with no windows without an error badge or meter", () => {
|
||||
mockUseUsageData.mockReturnValue(createUsageDataState({
|
||||
providers: [{ name: "Grok", icon: "✖️", status: "ok", windows: [] }],
|
||||
lastUpdated: new Date(),
|
||||
refresh: mockRefresh,
|
||||
}));
|
||||
|
||||
render(<UsageIndicator isOpen={true} onClose={mockOnClose} projectId={TEST_PROJECT_ID} />);
|
||||
|
||||
const card = document.querySelector('[data-provider="Grok"]')!;
|
||||
expect(card).toHaveAttribute("data-status", "ok");
|
||||
expect(card.querySelector(".usage-status-badge--error")).toBeNull();
|
||||
expect(card.querySelector(".usage-provider-windows")).toBeNull();
|
||||
expect(screen.getByText("No usage data available")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders drag handle in the right-side actions cluster for each provider card", () => {
|
||||
mockUseUsageData.mockReturnValue(createUsageDataState({
|
||||
providers: mockProviders,
|
||||
|
||||
@@ -3819,8 +3819,7 @@ describe("usage", () => {
|
||||
expect(mockRequest).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not fabricate a weekly credit window when the CLI billing percentage is omitted", async () => {
|
||||
vi.stubEnv("GROK_API_KEY", "env-grok-key");
|
||||
it("keeps a healthy unmeterable CLI login authenticated when the billing percentage is omitted", 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"));
|
||||
@@ -3846,7 +3845,8 @@ describe("usage", () => {
|
||||
expect(grok.status).toBe("ok");
|
||||
expect(grok.windows).toEqual([]);
|
||||
expect(grok.windows.some((window) => window.percentUsed === 100)).toBe(false);
|
||||
expect(mockRequest).toHaveBeenCalledTimes(2);
|
||||
expect(grok.error ?? "").not.toMatch(/auth expired/i);
|
||||
expect(mockRequest).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders a zero-percent weekly CLI credit window", async () => {
|
||||
@@ -3886,9 +3886,28 @@ describe("usage", () => {
|
||||
const providers = await fetchAllProviderUsage();
|
||||
const grok = providers.find((provider) => provider.name === "Grok")!;
|
||||
|
||||
expect(grok.status).toBe("error");
|
||||
expect(grok.status).toBe("ok");
|
||||
expect(grok.windows).toEqual([]);
|
||||
expect(grok.error).toContain("grok login");
|
||||
expect(grok.error ?? "").not.toMatch(/auth expired/i);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["missing config", {}],
|
||||
["invalid config", { config: null }],
|
||||
["empty body", ""],
|
||||
])("keeps a CLI login authenticated for %s billing data", async (_description, body) => {
|
||||
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"));
|
||||
});
|
||||
mockGrokBillingResponse(200, body);
|
||||
|
||||
const providers = await fetchAllProviderUsage();
|
||||
const grok = providers.find((provider) => provider.name === "Grok")!;
|
||||
|
||||
expect(grok.status).toBe("ok");
|
||||
expect(grok.windows).toEqual([]);
|
||||
expect(grok.error ?? "").not.toMatch(/auth expired/i);
|
||||
});
|
||||
|
||||
it("retains the Credits label for a non-weekly numeric CLI percentage", async () => {
|
||||
@@ -3914,6 +3933,56 @@ describe("usage", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each([401, 403])("shows CLI auth expiry only for observed billing HTTP %s without an API key", async (statusCode) => {
|
||||
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"));
|
||||
});
|
||||
mockGrokBillingResponse(statusCode, { error: "unauthorized" });
|
||||
|
||||
const providers = await fetchAllProviderUsage();
|
||||
const grok = providers.find((provider) => provider.name === "Grok")!;
|
||||
|
||||
expect(grok.status).toBe("error");
|
||||
expect(grok.error).toMatch(/auth expired/i);
|
||||
});
|
||||
|
||||
it.each([429, 500])("reports billing HTTP %s without claiming CLI auth expired", async (statusCode) => {
|
||||
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"));
|
||||
});
|
||||
mockGrokBillingResponse(statusCode, { error: "unavailable" });
|
||||
|
||||
const providers = await fetchAllProviderUsage();
|
||||
const grok = providers.find((provider) => provider.name === "Grok")!;
|
||||
|
||||
expect(grok.status).toBe("error");
|
||||
expect(grok.error).toContain(`HTTP ${statusCode}`);
|
||||
expect(grok.error ?? "").not.toMatch(/auth expired/i);
|
||||
});
|
||||
|
||||
it("reports a CLI billing transport failure without claiming auth expired", 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"));
|
||||
});
|
||||
mockRequest.mockImplementation(() => ({
|
||||
on: vi.fn((event: string, handler: (error: Error) => void) => {
|
||||
if (event === "error") handler(new Error("network unavailable"));
|
||||
}),
|
||||
write: vi.fn(),
|
||||
end: vi.fn(),
|
||||
}));
|
||||
|
||||
const providers = await fetchAllProviderUsage();
|
||||
const grok = providers.find((provider) => provider.name === "Grok")!;
|
||||
|
||||
expect(grok.status).toBe("error");
|
||||
expect(grok.error).toContain("network unavailable");
|
||||
expect(grok.error ?? "").not.toMatch(/auth expired/i);
|
||||
});
|
||||
|
||||
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) => {
|
||||
|
||||
@@ -1694,12 +1694,22 @@ async function readGrokCliOidcToken(): Promise<string | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
type GrokCliBillingUsageOutcome =
|
||||
| { outcome: "window" }
|
||||
| { outcome: "unauthorized" }
|
||||
| { outcome: "no-data" }
|
||||
| { outcome: "http-error"; status: number }
|
||||
| { outcome: "transport-error"; message: string };
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Preserves the observed reason no usage window was emitted so callers never
|
||||
* infer authentication failure from unavailable meter data.
|
||||
*/
|
||||
async function fetchGrokCliBillingUsage(token: string, usage: ProviderUsage): Promise<boolean> {
|
||||
async function fetchGrokCliBillingUsage(
|
||||
token: string,
|
||||
usage: ProviderUsage,
|
||||
): Promise<GrokCliBillingUsageOutcome> {
|
||||
try {
|
||||
const res = await httpsRequest("https://cli-chat-proxy.grok.com/v1/billing?format=credits", {
|
||||
method: "GET",
|
||||
@@ -1708,21 +1718,22 @@ async function fetchGrokCliBillingUsage(token: string, usage: ProviderUsage): Pr
|
||||
"content-type": "application/json",
|
||||
},
|
||||
});
|
||||
if (res.status !== 200) return false;
|
||||
if (res.status === 401 || res.status === 403) return { outcome: "unauthorized" };
|
||||
if (res.status !== 200) return { outcome: "http-error", status: res.status };
|
||||
|
||||
// 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;
|
||||
if (!config || typeof config !== "object") return { outcome: "no-data" };
|
||||
|
||||
const parsedReset = _parseResetTimestamp(config.billingPeriodEnd ?? config.currentPeriod?.end);
|
||||
const isWeekly = config.currentPeriod?.type === "USAGE_PERIOD_TYPE_WEEKLY";
|
||||
/*
|
||||
FNXC:UsageProviders 2026-07-31-20:31:
|
||||
A real account reported zero Grok credit usage while its billing response omitted `creditUsagePercent`, disproving the former omitted-field-to-100% inference. Emit a credits window only for a finite API-supplied percentage; field absence must fall through to the API-key validity or CLI-auth error card rather than fabricate consumption.
|
||||
A real account reported zero Grok credit usage while its billing response omitted `creditUsagePercent`, disproving the former omitted-field-to-100% inference. Emit a credits window only for a finite API-supplied percentage; field absence must remain an authenticated but unmeterable state rather than fabricate consumption or infer expired CLI auth.
|
||||
*/
|
||||
const pctUsed = config.creditUsagePercent;
|
||||
if (typeof pctUsed !== "number" || !Number.isFinite(pctUsed)) return false;
|
||||
if (typeof pctUsed !== "number" || !Number.isFinite(pctUsed)) return { outcome: "no-data" };
|
||||
|
||||
usage.windows.push({
|
||||
label: isWeekly ? "Weekly (credits)" : "Credits",
|
||||
@@ -1734,9 +1745,12 @@ async function fetchGrokCliBillingUsage(token: string, usage: ProviderUsage): Pr
|
||||
windowDurationMs: isWeekly ? 7 * 24 * 60 * 60 * 1000 : undefined,
|
||||
});
|
||||
usage.status = "ok";
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
return { outcome: "window" };
|
||||
} catch (error: unknown) {
|
||||
return {
|
||||
outcome: "transport-error",
|
||||
message: error instanceof Error ? error.message : "Failed to fetch",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1765,17 +1779,30 @@ async function fetchGrokUsage(authStorage?: AuthStorageLike): Promise<ProviderUs
|
||||
// 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))) {
|
||||
const billingOutcome = cliToken ? await fetchGrokCliBillingUsage(cliToken, usage) : null;
|
||||
if (billingOutcome?.outcome === "window") {
|
||||
return usage;
|
||||
}
|
||||
|
||||
const apiKey = await readGrokApiKey(authStorage);
|
||||
if (!apiKey) {
|
||||
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)";
|
||||
/*
|
||||
FNXC:UsageProviders 2026-08-01-02:05:
|
||||
A successful Grok billing response can be authenticated yet contain no meterable percentage. The Usage indicator must claim expired CLI auth only after the endpoint explicitly rejects the OIDC token with HTTP 401 or 403; unavailable meter data and transport failures require their own non-fabricated states.
|
||||
*/
|
||||
if (billingOutcome?.outcome === "unauthorized") {
|
||||
usage.status = "error";
|
||||
usage.error = "Grok CLI auth expired — run 'grok login' (or set GROK_API_KEY)";
|
||||
} else if (billingOutcome?.outcome === "no-data") {
|
||||
usage.status = "ok";
|
||||
} else if (billingOutcome?.outcome === "http-error") {
|
||||
usage.status = "error";
|
||||
usage.error = `Grok CLI billing request failed: HTTP ${billingOutcome.status}`;
|
||||
} else if (billingOutcome?.outcome === "transport-error") {
|
||||
usage.status = "error";
|
||||
usage.error = `Grok CLI billing request failed: ${billingOutcome.message}`;
|
||||
}
|
||||
} else {
|
||||
usage.error = "No Grok credentials — set GROK_API_KEY or add a key";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user