From bd0e99b31f7901ee34266d6bf186e47f65edc17b Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 10 Jul 2026 23:31:29 -0700 Subject: [PATCH] FN-7814: add xAI/Grok usage card to the Usage dropdown Adds a Grok (xAI) provider fetcher to the dashboard's usage aggregation so a Grok card now appears in the Usage dropdown when credentials are configured. - Add fetchGrokUsage() in usage.ts: resolves the API key from GROK_API_KEY env, then ~/.grok/user-settings.json, then grok-cli auth storage, and validates it against GET https://api.x.ai/v1/api-key - Since xAI exposes no subscription usage meter for inference keys, the card reports auth-validity status (ok/no-auth/error) with an empty usage-window list rather than fabricating quota data - Surfaces clear error messages for expired/blocked keys and non-200 responses; omits the card entirely when no credentials are found - Register fetchGrokUsage in fetchAllProviderUsage's parallel provider fetch list alongside Claude, Codex, Gemini, Minimax, Zai, and GitHub Copilot - Add extensive test coverage in usage.test.ts for key-source precedence, ok/error/no-auth states, and blocked/expired key handling - Add changeset (.changeset/fn-7814-grok-usage.md) documenting the new minor feature Files changed: .changeset/fn-7814-grok-usage.md | 7 ++ packages/dashboard/src/__tests__/usage.test.ts | 157 +++++++++++++++++++++++++ packages/dashboard/src/usage.ts | 95 ++++++++++++++- 3 files changed, 257 insertions(+), 2 deletions(-) Fusion-Task-Id: FN-7814 Fusion-Task-Lineage: cac497a9-5a57-4ba5-a7ea-8a01b89a0cbd Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-7814-grok-usage.md | 7 + .../dashboard/src/__tests__/usage.test.ts | 157 ++++++++++++++++++ packages/dashboard/src/usage.ts | 95 ++++++++++- 3 files changed, 257 insertions(+), 2 deletions(-) create mode 100644 .changeset/fn-7814-grok-usage.md diff --git a/.changeset/fn-7814-grok-usage.md b/.changeset/fn-7814-grok-usage.md new file mode 100644 index 0000000000..6f87dfc55c --- /dev/null +++ b/.changeset/fn-7814-grok-usage.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Show a Grok (xAI) card in the Usage dropdown for configured Grok API keys. +category: feature +dev: usage.ts adds fetchGrokUsage (env GROK_API_KEY -> ~/.grok/user-settings.json -> grok-cli auth key) validating GET https://api.x.ai/v1/api-key and registered in fetchAllProviderUsage. xAI exposes no subscription usage meter to the inference key, so the card is auth-validity (ok/no-auth/error) with a real usage window only when confirmed data exists; no fabricated windows. Real usage field found: no — validity-only. diff --git a/packages/dashboard/src/__tests__/usage.test.ts b/packages/dashboard/src/__tests__/usage.test.ts index 928848c0e0..6faca807bb 100644 --- a/packages/dashboard/src/__tests__/usage.test.ts +++ b/packages/dashboard/src/__tests__/usage.test.ts @@ -91,6 +91,7 @@ describe("usage", () => { coreInteropMocks.readStoredCredentialsFromAuthFile.mockReturnValue({}); vi.stubEnv("HOME", "/home/testuser"); vi.stubEnv("CODEX_HOME", ""); + vi.stubEnv("GROK_API_KEY", ""); }); afterEach(() => { @@ -3252,6 +3253,162 @@ describe("usage", () => { }); }); + describe("Grok provider", () => { + const mockGrokApiKeyResponse = (statusCode: number, body: unknown) => { + const mockReq = { on: vi.fn(), write: vi.fn(), end: vi.fn() }; + mockRequest.mockImplementation((options: any, callback: any) => { + expect(options.hostname).toBe("api.x.ai"); + expect(options.path).toBe("/v1/api-key"); + const responseBody = typeof body === "string" ? body : JSON.stringify(body); + const mockRes = { + statusCode, + headers: {}, + on: vi.fn((event: string, handler: any) => { + if (event === "data") handler(Buffer.from(responseBody)); + if (event === "end") handler(); + }), + }; + callback(mockRes); + return mockReq; + }); + }; + + 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")); + }); + mockExecFileSync.mockImplementation(() => { + throw new Error("Keychain item not found"); + }); + + const providers = await fetchAllProviderUsage(); + const grok = providers.find((p) => p.name === "Grok"); + + expect(grok).toBeUndefined(); + }); + + it("uses GROK_API_KEY env first and renders a validity-only ok card", async () => { + vi.stubEnv("GROK_API_KEY", "env-grok-key"); + mockReadFile.mockImplementation(async () => { + return Promise.reject(new Error("File not found")); + }); + mockExecFileSync.mockImplementation(() => { + throw new Error("Keychain item not found"); + }); + mockGrokApiKeyResponse(200, { + api_key_blocked: false, + api_key_disabled: false, + team_blocked: false, + }); + + const providers = await fetchAllProviderUsage(); + const grok = providers.find((p) => p.name === "Grok")!; + + expect(grok.status).toBe("ok"); + expect(grok.icon).toBe("✖️"); + expect(grok.windows).toEqual([]); + expect(mockRequest).toHaveBeenCalledTimes(1); + expect(mockRequest.mock.calls[0][0].headers.authorization).toBe("Bearer env-grok-key"); + expect(mockReadFile.mock.calls.every(([filePath]) => !String(filePath).includes(".grok/user-settings.json"))).toBe(true); + }); + + it("reads Grok API key from ~/.grok/user-settings.json before auth files", async () => { + mockReadFile.mockImplementation((filePath: string) => { + if (filePath.includes(".grok/user-settings.json")) { + return JSON.stringify({ apiKey: "user-settings-grok-key" }); + } + if (filePath.includes(".pi/agent/auth.json")) { + return JSON.stringify({ "grok-cli": { type: "api_key", key: "auth-file-grok-key" } }); + } + return Promise.reject(new Error("File not found")); + }); + mockExecFileSync.mockImplementation(() => { + throw new Error("Keychain item not found"); + }); + mockGrokApiKeyResponse(200, { api_key_blocked: false }); + + const providers = await fetchAllProviderUsage(); + const grok = providers.find((p) => p.name === "Grok")!; + + expect(grok.status).toBe("ok"); + expect(mockRequest.mock.calls[0][0].headers.authorization).toBe("Bearer user-settings-grok-key"); + }); + + it("falls back to grok-cli auth storage when env and user settings are absent", async () => { + mockReadFile.mockImplementation(async () => { + return Promise.reject(new Error("File not found")); + }); + mockExecFileSync.mockImplementation(() => { + throw new Error("Keychain item not found"); + }); + mockGrokApiKeyResponse(200, { api_key_blocked: false }); + + const providers = await fetchAllProviderUsage({ + reload: vi.fn(), + hasAuth: vi.fn(() => true), + getApiKey: vi.fn((provider: string) => + provider === "grok-cli" ? "auth-storage-grok-key" : null + ), + }); + const grok = providers.find((p) => p.name === "Grok")!; + + expect(grok.status).toBe("ok"); + expect(mockRequest.mock.calls[0][0].headers.authorization).toBe("Bearer auth-storage-grok-key"); + }); + + it("keeps Grok visible as error for 401/403 auth failures", async () => { + vi.stubEnv("GROK_API_KEY", "expired-grok-key"); + mockReadFile.mockImplementation(async () => { + return Promise.reject(new Error("File not found")); + }); + mockExecFileSync.mockImplementation(() => { + throw new Error("Keychain item not found"); + }); + mockGrokApiKeyResponse(401, { error: "unauthorized" }); + + const providers = await fetchAllProviderUsage(); + const grok = providers.find((p) => p.name === "Grok")!; + + expect(grok).toBeDefined(); + expect(grok.status).toBe("error"); + expect(grok.error).toContain("Auth expired"); + }); + + it("keeps Grok visible as error when xAI reports the key is blocked", async () => { + vi.stubEnv("GROK_API_KEY", "blocked-grok-key"); + mockReadFile.mockImplementation(async () => { + return Promise.reject(new Error("File not found")); + }); + mockExecFileSync.mockImplementation(() => { + throw new Error("Keychain item not found"); + }); + mockGrokApiKeyResponse(200, { api_key_blocked: true }); + + const providers = await fetchAllProviderUsage(); + const grok = providers.find((p) => p.name === "Grok")!; + + expect(grok.status).toBe("error"); + expect(grok.error).toContain("blocked or disabled"); + }); + + it("keeps Grok visible as error for non-200 responses", async () => { + vi.stubEnv("GROK_API_KEY", "server-error-grok-key"); + mockReadFile.mockImplementation(async () => { + return Promise.reject(new Error("File not found")); + }); + mockExecFileSync.mockImplementation(() => { + throw new Error("Keychain item not found"); + }); + mockGrokApiKeyResponse(500, { error: "server error" }); + + const providers = await fetchAllProviderUsage(); + const grok = providers.find((p) => p.name === "Grok")!; + + expect(grok.status).toBe("error"); + expect(grok.error).toContain("HTTP 500"); + }); + }); + describe("Zai provider", () => { it("detects no auth when pi auth.json doesn't exist", async () => { mockReadFile.mockImplementation(async () => { diff --git a/packages/dashboard/src/usage.ts b/packages/dashboard/src/usage.ts index 33460500d7..ca3e2e080b 100644 --- a/packages/dashboard/src/usage.ts +++ b/packages/dashboard/src/usage.ts @@ -3,7 +3,12 @@ import * as path from "node:path"; import { readFile } from "node:fs/promises"; import * as https from "node:https"; import * as child_process from "node:child_process"; -import { choosePreferredStoredCredential, readStoredCredentialsFromAuthFile } from "@fusion/core"; +import { + choosePreferredStoredCredential, + GROK_CLI_PROVIDER_ID, + GROK_PROVIDER_REGISTRATION, + readStoredCredentialsFromAuthFile, +} from "@fusion/core"; import { getAuthFileCandidates } from "./auth-paths.js"; function getHomeDir(): string { @@ -1590,6 +1595,91 @@ async function fetchMinimaxUsage(authStorage?: AuthStorageLike): Promise { + try { + const settingsPath = path.join(getHomeDir(), ".grok", "user-settings.json"); + const raw = await readFile(settingsPath, "utf-8"); + const parsed = JSON.parse(raw) as { apiKey?: unknown }; + return typeof parsed?.apiKey === "string" && parsed.apiKey.trim().length > 0 + ? parsed.apiKey.trim() + : null; + } catch { + return null; + } +} + +async function readGrokApiKey(authStorage?: AuthStorageLike): Promise { + const envKey = process.env.GROK_API_KEY; + if (typeof envKey === "string" && envKey.trim().length > 0) { + return envKey.trim(); + } + + const userSettingsKey = await readGrokUserSettingsApiKey(); + if (userSettingsKey) { + return userSettingsKey; + } + + return readConfiguredApiKey(GROK_CLI_PROVIDER_ID, authStorage); +} + +async function fetchGrokUsage(authStorage?: AuthStorageLike): Promise { + const usage: ProviderUsage = { + name: "Grok", + icon: "✖️", + status: "no-auth", + windows: [], + }; + + const apiKey = await readGrokApiKey(authStorage); + if (!apiKey) { + usage.error = "No Grok credentials — set GROK_API_KEY or add a key"; + return usage; + } + + try { + /* + FNXC:UsageProviders 2026-07-10-00:00: + xAI's inference key exposes GET /api-key as the verified auth-validity endpoint on the same direct provider base URL Fusion already uses for `grok-cli`. The public xAI API does not document a subscription reset-window or remaining-quota meter for inference keys, so this provider must remain an auth-validity card unless xAI returns confirmed consumption data in this response or standard rate-limit headers. Do not fabricate percent-used, reset timestamps, or UsageWindow entries from key metadata alone. + */ + const res = await httpsRequest(`${GROK_PROVIDER_REGISTRATION.baseUrl}/api-key`, { + method: "GET", + headers: { + authorization: `Bearer ${apiKey}`, + "content-type": "application/json", + }, + }); + + if (res.status === 401 || res.status === 403) { + usage.status = "error"; + usage.error = "Auth expired — check your Grok/xAI API key"; + return usage; + } + + if (res.status !== 200) { + usage.status = "error"; + usage.error = `HTTP ${res.status}: ${res.body.slice(0, 200)}`; + return usage; + } + + const data = res.body.trim().length > 0 ? JSON.parse(res.body) : {}; + if (data?.api_key_blocked === true || data?.api_key_disabled === true || data?.team_blocked === true) { + usage.status = "error"; + usage.error = "Grok/xAI API key is blocked or disabled"; + return usage; + } + + usage.status = "ok"; + } catch (e: unknown) { + usage.status = "error"; + usage.error = e instanceof Error ? e.message : "Failed to fetch"; + } + + return usage; +} + // ── Zai (Zhipu AI) fetcher ────────────────────────────────────────────────── async function fetchZaiUsage(authStorage?: AuthStorageLike): Promise { @@ -1937,13 +2027,14 @@ export async function fetchAllProviderUsage(authStorage?: AuthStorageLike): Prom } // Fetch all providers in parallel with per-provider timeout - // Currently includes: Claude, Codex, Gemini, Minimax, Zai, GitHub Copilot + // Currently includes: Claude, Codex, Gemini, Minimax, Zai, Grok, GitHub Copilot const results = await Promise.allSettled([ withTimeout(fetchClaudeUsage(authStorage), "Claude", CLAUDE_FETCH_TIMEOUT_MS), withTimeout(fetchCodexUsage(), "Codex"), withTimeout(fetchGeminiUsage(), "Gemini"), withTimeout(fetchMinimaxUsage(authStorage), "Minimax"), withTimeout(fetchZaiUsage(authStorage), "Zai"), + withTimeout(fetchGrokUsage(authStorage), "Grok"), withTimeout(fetchGitHubCopilotUsage(), "GitHub Copilot"), ]);