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:
7
.changeset/fn-7814-grok-usage.md
Normal file
7
.changeset/fn-7814-grok-usage.md
Normal 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.
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<Provide
|
||||
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 ──────────────────────────────────────────────────
|
||||
|
||||
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
|
||||
// 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"),
|
||||
]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user