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({});
|
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 () => {
|
||||||
|
|||||||
@@ -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"),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user