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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-10 23:31:29 -07:00
parent 56b20a76af
commit bd0e99b31f
3 changed files with 257 additions and 2 deletions

View File

@@ -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.

View File

@@ -91,6 +91,7 @@ describe("usage", () => {
coreInteropMocks.readStoredCredentialsFromAuthFile.mockReturnValue({}); coreInteropMocks.readStoredCredentialsFromAuthFile.mockReturnValue({});
vi.stubEnv("HOME", "/home/testuser"); vi.stubEnv("HOME", "/home/testuser");
vi.stubEnv("CODEX_HOME", ""); vi.stubEnv("CODEX_HOME", "");
vi.stubEnv("GROK_API_KEY", "");
}); });
afterEach(() => { 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", () => { describe("Zai provider", () => {
it("detects no auth when pi auth.json doesn't exist", async () => { it("detects no auth when pi auth.json doesn't exist", async () => {
mockReadFile.mockImplementation(async () => { mockReadFile.mockImplementation(async () => {

View File

@@ -3,7 +3,12 @@ import * as path from "node:path";
import { readFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
import * as https from "node:https"; import * as https from "node:https";
import * as child_process from "node:child_process"; 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"; import { getAuthFileCandidates } from "./auth-paths.js";
function getHomeDir(): string { function getHomeDir(): string {
@@ -1590,6 +1595,91 @@ async function fetchMinimaxUsage(authStorage?: AuthStorageLike): Promise<Provide
return usage; return usage;
} }
// ── Grok (xAI) fetcher ─────────────────────────────────────────────────────
async function readGrokUserSettingsApiKey(): Promise<string | null> {
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<string | null> {
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<ProviderUsage> {
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 ────────────────────────────────────────────────── // ── Zai (Zhipu AI) fetcher ──────────────────────────────────────────────────
async function fetchZaiUsage(authStorage?: AuthStorageLike): Promise<ProviderUsage> { async function fetchZaiUsage(authStorage?: AuthStorageLike): Promise<ProviderUsage> {
@@ -1937,13 +2027,14 @@ export async function fetchAllProviderUsage(authStorage?: AuthStorageLike): Prom
} }
// Fetch all providers in parallel with per-provider timeout // 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([ const results = await Promise.allSettled([
withTimeout(fetchClaudeUsage(authStorage), "Claude", CLAUDE_FETCH_TIMEOUT_MS), withTimeout(fetchClaudeUsage(authStorage), "Claude", CLAUDE_FETCH_TIMEOUT_MS),
withTimeout(fetchCodexUsage(), "Codex"), withTimeout(fetchCodexUsage(), "Codex"),
withTimeout(fetchGeminiUsage(), "Gemini"), withTimeout(fetchGeminiUsage(), "Gemini"),
withTimeout(fetchMinimaxUsage(authStorage), "Minimax"), withTimeout(fetchMinimaxUsage(authStorage), "Minimax"),
withTimeout(fetchZaiUsage(authStorage), "Zai"), withTimeout(fetchZaiUsage(authStorage), "Zai"),
withTimeout(fetchGrokUsage(authStorage), "Grok"),
withTimeout(fetchGitHubCopilotUsage(), "GitHub Copilot"), withTimeout(fetchGitHubCopilotUsage(), "GitHub Copilot"),
]); ]);