FN-7816: add Cursor usage card to the Usage activity dropdown

Adds a Cursor provider fetcher to the dashboard usage aggregator so operators with a Cursor Admin API key see spend-based usage alongside the other providers.

- usage.ts: add fetchCursorUsage() using the Cursor Admin API POST https://api.cursor.com/teams/spend with Basic auth (API key as username), resolving the key from CURSOR_ADMIN_API_KEY (preferred) or CURSOR_API_KEY, falling back to fusion-auth/pi-configured api keys; maps teamMemberSpend overallSpendCents/spendCents and hardLimitOverrideDollars/monthlyLimitDollars into a "Monthly spend" usage window with a reset derived from subscriptionCycleStart
- usage.ts: wire fetchCursorUsage into fetchAllProviderUsage's parallel provider fetch list (with withTimeout + no-auth demotion) and update the provider-list comment
- UsageIndicator.tsx: map the "Cursor" provider name to the existing cursor-cli icon token/SVG
- usage.test.ts: add CURSOR_ADMIN_API_KEY/CURSOR_API_KEY env stubbing and a full fetchCursorUsage regression suite (ok/zero-utilization/no-auth/error/expired-key/parse-failure cases)
- UsageIndicator.test.tsx: cover the Cursor icon mapping
- docs/settings-reference.md: document that the Usage dropdown Cursor card requires a Cursor Admin API key (session-only cursor-agent login is insufficient)
- add a minor changeset for @runfusion/fusion documenting the new Cursor usage card

Files changed:
 .changeset/fn-7816-cursor-usage.md                 |   7 +
 docs/settings-reference.md                         |   4 +
 packages/dashboard/app/components/UsageIndicator.tsx |   7 +
 packages/dashboard/app/components/__tests__/UsageIndicator.test.tsx |  26 +++
 packages/dashboard/src/__tests__/usage.test.ts     | 157 ++++++++++++++
 packages/dashboard/src/usage.ts                    | 240 ++++++++++++++++++++-
 6 files changed, 440 insertions(+), 1 deletion(-)

Fusion-Task-Id: FN-7816
Fusion-Task-Lineage: 4cec63d8-4ddc-40f3-8d16-5e4078da5eba
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-11 00:12:00 -07:00
parent c12653e22c
commit d40f24d20a
6 changed files with 440 additions and 1 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Show Cursor subscription usage in the Usage dropdown.
category: feature
dev: usage.ts adds fetchCursorUsage via Cursor Admin API POST https://api.cursor.com/teams/spend with Basic auth API_KEY:, resolving the Admin API key from documented env `CURSOR_ADMIN_API_KEY` (or `CURSOR_API_KEY` alias) before internal test/auth-storage fallbacks. It maps teamMemberSpend overallSpendCents/spendCents plus hardLimitOverrideDollars/monthlyLimitDollars and subscriptionCycleStart; fetchAllProviderUsage wraps it with withTimeout and no-auth demotion, while UsageIndicator maps "Cursor" to cursor-cli. No personal Cursor CLI usage endpoint confirmed; CLI session only supplies userEmail/subscriptionTier metadata.

View File

@@ -990,6 +990,10 @@ When the Hermes Runtime plugin (`fusion-plugin-hermes-runtime`) is installed and
When the Cursor Runtime plugin (`fusion-plugin-cursor-runtime`) is installed and the `useCursorCli` toggle is enabled (Settings → Authentication), Cursor CLI-discovered models (`cursor-agent models --json`, with text/`model list` fallbacks) are surfaced additively in `/api/models` under the `cursor-cli` provider — id/name derived from the discovered model id/label. This surfacing is fetched through a short-TTL, single-flight cache so the model picker never spawns `cursor-agent` on every request; a missing/failed/unavailable Cursor CLI binary simply yields zero `cursor-cli` rows without affecting other providers. Disabling `useCursorCli` hides all `cursor-cli` rows.
<!-- FNXC:UsageProviders 2026-07-10-00:00: FN-7816 — Cursor usage in the dashboard Usage dropdown is intentionally separate from Cursor CLI OAuth/session auth. Metered spend requires a Cursor Admin API key exported to the dashboard process as `CURSOR_ADMIN_API_KEY` (preferred) or `CURSOR_API_KEY`; session-only `cursor-agent` login can identify the user/plan but cannot call the Admin API spend endpoint. -->
The Usage dropdown can show a Cursor card when the dashboard process has a Cursor Admin API key in `CURSOR_ADMIN_API_KEY` (preferred) or `CURSOR_API_KEY` (compatibility alias). Fusion calls Cursor Admin API `POST https://api.cursor.com/teams/spend` with Basic auth (`API_KEY:`) and maps documented team spend fields into the generic usage-window UI. If only the `cursor-agent` session/OAuth login is present, Fusion omits the Cursor usage card because Cursor has not documented a personal/session usage endpoint; expired Admin API keys remain visible as an error card so operators can rotate the environment secret.
When the Grok Runtime plugin (`fusion-plugin-grok-runtime`) is installed and the `useGrokCli` toggle is enabled (Settings → Authentication), Grok CLI-discovered models (`grok models`) are surfaced additively in `/api/models` under the `grok-cli` provider — id/name derived from the discovered model id/label. This surfacing is fetched through a short-TTL, single-flight cache so the model picker never spawns `grok` on every request; a missing/failed/unavailable Grok CLI binary simply yields zero `grok-cli` rows without affecting other providers. Disabling `useGrokCli` hides all `grok-cli` rows. Unlike Cursor (OAuth/session auth), Grok direct-endpoint auth is API-key based, but CLI-routed execution lets the `grok` binary use any auth source it supports; the Settings card still surfaces Fusion-visible key detection only as an informational hint.
The three GPT-5.6 codenamed OpenAI Codex variants (`gpt-5.6-luna`, `gpt-5.6-sol`, `gpt-5.6-terra`) are additively surfaced under the `openai-codex` provider (FN-7745/FN-7754/FN-7759, mirroring the Anthropic/Z.ai supplemental-merge pattern above) so they appear both in dashboard `/api/models` and the engine/pi `createFnAgent` registry-seeding surface whenever `openai-codex` is configured — deduped against any pinned pi-ai catalog row that already carries one of the ids. FN-7759 specifically keeps the supplemental registration compatible with the real pi-coding-agent `ModelRegistry` by preserving the OpenAI Codex OAuth provider during dynamic full-provider replacement, so legacy catalogs without native 5.6 rows still survive `getAvailable()` auth filtering and remain executable.

View File

@@ -442,6 +442,13 @@ function getProviderIconKey(providerName: string): string {
if (normalized.includes('xai') || normalized.includes('grok')) {
return 'xai';
}
/*
FNXC:UsageIndicator 2026-07-10-00:00:
Cursor usage cards are rendered by the generic ProviderCard path, so provider-name mapping is the only frontend-specific requirement: route "Cursor" to the existing cursor-cli icon token and SVG.
*/
if (normalized.includes('cursor')) {
return 'cursor-cli';
}
if (normalized.includes('opencode')) {
return 'opencode';
}

View File

@@ -1536,6 +1536,32 @@ describe("UsageIndicator", () => {
expect(screen.getByText("Moonshot")).toBeInTheDocument();
});
it("maps Cursor provider to the cursor-cli icon and renders usage windows", () => {
mockUseUsageData.mockReturnValue(createUsageDataState({
providers: [
{
name: "Cursor",
icon: "🟣",
status: "ok",
windows: [
{ label: "Monthly spend", percentUsed: 25, percentLeft: 75, resetText: "resets in 15d" },
],
},
],
loading: false,
error: null,
lastUpdated: new Date(),
refresh: mockRefresh,
}));
render(<UsageIndicator isOpen={true} onClose={mockOnClose} projectId={TEST_PROJECT_ID} />);
expect(screen.getByTestId("cursor-cli-icon")).toBeInTheDocument();
expect(document.querySelector('[data-provider="cursor-cli"]')).toBeInTheDocument();
expect(screen.getByText("Monthly spend")).toBeInTheDocument();
expect(screen.getByText("25% used")).toBeInTheDocument();
});
// Pace indicator tests
it("renders pace marker for weekly windows with timing data", () => {
mockUseUsageData.mockReturnValue(createUsageDataState({

View File

@@ -92,6 +92,8 @@ describe("usage", () => {
vi.stubEnv("HOME", "/home/testuser");
vi.stubEnv("CODEX_HOME", "");
vi.stubEnv("GROK_API_KEY", "");
vi.stubEnv("CURSOR_ADMIN_API_KEY", "");
vi.stubEnv("CURSOR_API_KEY", "");
});
afterEach(() => {
@@ -3253,6 +3255,161 @@ describe("usage", () => {
});
});
describe("fetchCursorUsage (via fetchAllProviderUsage)", () => {
const cursorAuthStorage = (apiKey: string) => ({
reload: vi.fn(),
hasAuth: vi.fn((provider: string) => provider === "cursor"),
getApiKey: vi.fn((provider: string) => provider === "cursor" ? apiKey : null),
});
const mockCursorAccount = (account: { email?: string; plan?: string } = {}) => {
mockExecFileSync.mockImplementation((cmd: string) => {
if (cmd === "cursor-agent") {
return JSON.stringify({
userEmail: account.email ?? "developer@company.com",
subscriptionTier: account.plan ?? "Pro",
});
}
throw new Error("File not found");
});
};
const mockCursorSpendResponse = (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.cursor.com");
expect(options.path).toBe("/teams/spend");
expect(options.method).toBe("POST");
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;
});
return mockReq;
};
it("renders Cursor usage from the Admin API spend response", async () => {
const cycleStart = Date.now() - 15 * 24 * 60 * 60 * 1000;
mockReadFile.mockImplementation(async () => Promise.reject(new Error("File not found")));
mockCursorAccount({ email: "developer@company.com", plan: "Pro" });
const mockReq = mockCursorSpendResponse(200, {
subscriptionCycleStart: cycleStart,
teamMemberSpend: [
{
email: "developer@company.com",
overallSpendCents: 2450,
spendCents: 2450,
hardLimitOverrideDollars: null,
monthlyLimitDollars: 100,
},
],
});
vi.stubEnv("CURSOR_ADMIN_API_KEY", "cursor-admin-env-key");
const providers = await fetchAllProviderUsage();
const cursor = providers.find((p) => p.name === "Cursor")!;
expect(cursor.status).toBe("ok");
expect(cursor.plan).toBe("Pro");
expect(cursor.email).toBe("developer@company.com");
expect(cursor.windows).toHaveLength(1);
expect(cursor.windows[0].label).toBe("Monthly spend");
expect(cursor.windows[0].percentUsed).toBeCloseTo(24.5, 1);
expect(cursor.windows[0].percentLeft).toBeCloseTo(75.5, 1);
expect(cursor.windows[0].resetText).toContain("resets in");
expect(cursor.windows[0].resetAt).toBeDefined();
expect(cursor.windows[0].pace).toBeDefined();
expect(mockReq.write).toHaveBeenCalledWith(expect.stringContaining("developer@company.com"));
expect(mockRequest.mock.calls[0][0].headers.authorization).toBe(`Basic ${Buffer.from("cursor-admin-env-key:").toString("base64")}`);
});
it("keeps a zero-utilization Cursor window visible", async () => {
mockReadFile.mockImplementation(async () => Promise.reject(new Error("File not found")));
mockCursorAccount({ email: "developer@company.com" });
mockCursorSpendResponse(200, {
subscriptionCycleStart: Date.now() - 24 * 60 * 60 * 1000,
teamMemberSpend: [
{
email: "developer@company.com",
overallSpendCents: 0,
monthlyLimitDollars: 100,
},
],
});
const providers = await fetchAllProviderUsage(cursorAuthStorage("cursor-admin-key"));
const cursor = providers.find((p) => p.name === "Cursor")!;
expect(cursor.status).toBe("ok");
expect(cursor.windows).toHaveLength(1);
expect(cursor.windows[0].percentUsed).toBe(0);
expect(cursor.windows[0].percentLeft).toBe(100);
});
it("omits Cursor when no Cursor API key is configured", async () => {
mockReadFile.mockImplementation(async () => Promise.reject(new Error("File not found")));
mockExecFileSync.mockImplementation(() => {
throw new Error("File not found");
});
const providers = await fetchAllProviderUsage();
const cursor = providers.find((p) => p.name === "Cursor");
expect(cursor).toBeUndefined();
});
it("omits Cursor when the spend response has no meterable row", async () => {
mockReadFile.mockImplementation(async () => Promise.reject(new Error("File not found")));
mockCursorAccount({ email: "developer@company.com" });
mockCursorSpendResponse(200, { teamMemberSpend: [] });
const providers = await fetchAllProviderUsage(cursorAuthStorage("cursor-admin-key"));
const cursor = providers.find((p) => p.name === "Cursor");
expect(cursor).toBeUndefined();
});
it("keeps Cursor visible as error for expired API keys", async () => {
mockReadFile.mockImplementation(async () => Promise.reject(new Error("File not found")));
mockCursorAccount({ email: "developer@company.com" });
mockCursorSpendResponse(401, { error: "unauthorized" });
const providers = await fetchAllProviderUsage(cursorAuthStorage("expired-cursor-key"));
const cursor = providers.find((p) => p.name === "Cursor")!;
expect(cursor.status).toBe("error");
expect(cursor.error).toContain("Auth expired");
});
it("keeps Cursor visible as error for non-200 and parse failures", async () => {
mockReadFile.mockImplementation(async () => Promise.reject(new Error("File not found")));
mockCursorAccount({ email: "developer@company.com" });
mockCursorSpendResponse(500, { error: "server error" });
let providers = await fetchAllProviderUsage(cursorAuthStorage("cursor-admin-key"));
let cursor = providers.find((p) => p.name === "Cursor")!;
expect(cursor.status).toBe("error");
expect(cursor.error).toContain("HTTP 500");
clearUsageCache();
mockRequest.mockClear();
mockCursorSpendResponse(200, "not json");
providers = await fetchAllProviderUsage(cursorAuthStorage("cursor-admin-key"));
cursor = providers.find((p) => p.name === "Cursor")!;
expect(cursor.status).toBe("error");
expect(cursor.error).toMatch(/JSON|Unexpected/i);
});
});
describe("Grok provider", () => {
const mockGrokApiKeyResponse = (statusCode: number, body: unknown) => {
const mockReq = { on: vi.fn(), write: vi.fn(), end: vi.fn() };

View File

@@ -1810,6 +1810,243 @@ async function fetchZaiUsage(authStorage?: AuthStorageLike): Promise<ProviderUsa
return usage;
}
// ── Cursor fetcher ──────────────────────────────────────────────────────────
const CURSOR_ADMIN_SPEND_ENDPOINT = "https://api.cursor.com/teams/spend";
const CURSOR_ADMIN_API_KEY_ENV_VARS = ["CURSOR_ADMIN_API_KEY", "CURSOR_API_KEY"];
const CURSOR_API_KEY_PROVIDER_IDS = ["cursor", "cursor-cli", "cursor-agent"];
const CURSOR_MONTHLY_WINDOW_MS = 30 * 24 * 60 * 60 * 1000;
type CursorAccountInfo = {
email?: string;
plan?: string;
};
async function readCursorApiKey(authStorage?: AuthStorageLike): Promise<string | null> {
for (const envName of CURSOR_ADMIN_API_KEY_ENV_VARS) {
const envKey = process.env[envName];
if (typeof envKey === "string" && envKey.trim().length > 0) {
return envKey.trim();
}
}
try {
authStorage?.reload();
} catch {
// Reload may fail if no storage - ignore.
}
for (const providerId of CURSOR_API_KEY_PROVIDER_IDS) {
try {
const apiKey = await authStorage?.getApiKey?.(providerId);
if (apiKey) return apiKey;
} catch {
// Try the next provider id.
}
try {
const entry = authStorage?.get?.(providerId);
if (entry && (entry.type === "api_key" || entry.type === "key") && entry.key) {
return entry.key;
}
} catch {
// Try the next provider id.
}
}
return null;
}
async function readCursorAccountInfo(): Promise<CursorAccountInfo> {
for (const command of ["cursor-agent", "cursor"]) {
try {
const { stdout } = await execFileAsync(command, ["about", "--format", "json"], {
encoding: "utf-8",
timeout: 5000,
});
const data = JSON.parse(stdout.trim()) as Record<string, unknown>;
return {
email: typeof data.userEmail === "string" ? data.userEmail : undefined,
plan: typeof data.subscriptionTier === "string" ? data.subscriptionTier : undefined,
};
} catch {
// Try the next binary/status fallback.
}
try {
const { stdout } = await execFileAsync(command, ["status", "--format", "json"], {
encoding: "utf-8",
timeout: 5000,
});
const data = JSON.parse(stdout.trim()) as { userInfo?: { email?: unknown }; status?: unknown };
return {
email: typeof data.userInfo?.email === "string" ? data.userInfo.email : undefined,
plan: typeof data.status === "string" ? data.status : undefined,
};
} catch {
// Try the next binary.
}
}
return {};
}
function readNumber(value: unknown): number | undefined {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim().length > 0) {
const parsed = Number(value);
if (Number.isFinite(parsed)) return parsed;
}
return undefined;
}
function clampPercent(value: number): number {
return Math.min(100, Math.max(0, value));
}
function addOneMonth(value: Date): Date {
const next = new Date(value.getTime());
next.setUTCMonth(next.getUTCMonth() + 1);
return next;
}
function deriveCursorReset(cycleStart: unknown): { resetText: string | null; resetMs?: number; resetAt?: string; windowDurationMs: number } {
const startMs = readNumber(cycleStart);
if (!startMs) {
return { resetText: null, windowDurationMs: CURSOR_MONTHLY_WINDOW_MS };
}
let resetAtDate = addOneMonth(new Date(startMs >= 1e12 ? startMs : startMs * 1000));
const now = Date.now();
while (resetAtDate.getTime() <= now) {
resetAtDate = addOneMonth(resetAtDate);
}
const parsedReset = _parseResetTimestamp(resetAtDate.toISOString());
if (!parsedReset) {
return { resetText: null, windowDurationMs: CURSOR_MONTHLY_WINDOW_MS };
}
return {
resetText: `resets in ${formatDuration(parsedReset.msLeft)}`,
resetMs: parsedReset.msLeft,
resetAt: parsedReset.resetAt,
windowDurationMs: CURSOR_MONTHLY_WINDOW_MS,
};
}
function getCursorSpendRows(data: Record<string, unknown>): Record<string, unknown>[] {
const nested = typeof data.data === "object" && data.data !== null ? data.data as Record<string, unknown> : undefined;
const rows = data.teamMemberSpend ?? nested?.teamMemberSpend ?? data.members ?? nested?.members;
return Array.isArray(rows) ? rows.filter((row): row is Record<string, unknown> => typeof row === "object" && row !== null) : [];
}
function selectCursorSpendRow(rows: Record<string, unknown>[], email?: string): Record<string, unknown> | null {
if (rows.length === 0) return null;
if (email) {
const normalizedEmail = email.toLowerCase();
const match = rows.find((row) => typeof row.email === "string" && row.email.toLowerCase() === normalizedEmail);
if (match) return match;
}
return rows.length === 1 ? rows[0] : null;
}
async function fetchCursorUsage(authStorage?: AuthStorageLike): Promise<ProviderUsage> {
const usage: ProviderUsage = {
name: "Cursor",
icon: "🟣",
status: "no-auth",
windows: [],
};
const apiKey = await readCursorApiKey(authStorage);
if (!apiKey) {
usage.error = "No Cursor Admin API key — set CURSOR_ADMIN_API_KEY (or CURSOR_API_KEY) in the Fusion dashboard environment";
return usage;
}
const account = await readCursorAccountInfo();
if (account.email) usage.email = account.email;
if (account.plan) usage.plan = account.plan;
try {
/*
FNXC:UsageProviders 2026-07-10-00:00:
Cursor exposes meterable team spend through the documented Admin API `POST https://api.cursor.com/teams/spend`, authenticated with Basic auth using the API key as the username (`-u YOUR_API_KEY:`). Fusion resolves that key from `CURSOR_ADMIN_API_KEY` (preferred) or the documented `CURSOR_API_KEY` compatibility alias; Cursor CLI OAuth/session auth is not an Admin API credential and cannot reach this endpoint by itself. The response documents `teamMemberSpend[].overallSpendCents`, `spendCents`, `hardLimitOverrideDollars`, `monthlyLimitDollars`, `email`, and `subscriptionCycleStart`; no personal CLI usage endpoint or direct reset timestamp is documented, so personal/session-only Cursor logins stay `no-auth` and the reset is derived from the monthly cycle start.
*/
const body: Record<string, unknown> = { page: 1, pageSize: 500 };
if (account.email) body.searchTerm = account.email;
const res = await httpsRequest(CURSOR_ADMIN_SPEND_ENDPOINT, {
method: "POST",
headers: {
authorization: `Basic ${Buffer.from(`${apiKey}:`).toString("base64")}`,
"content-type": "application/json",
},
body: JSON.stringify(body),
});
if (res.status === 401 || res.status === 403) {
usage.status = "error";
usage.error = "Auth expired — check your Cursor Admin API key";
return usage;
}
if (res.status === 404) {
usage.status = "no-auth";
usage.error = "No Cursor usage entitlement found";
return usage;
}
if (res.status !== 200) {
usage.status = "error";
usage.error = `HTTP ${res.status}: ${res.body.slice(0, 200)}`;
return usage;
}
const data = JSON.parse(res.body) as Record<string, unknown>;
const rows = getCursorSpendRows(data);
const row = selectCursorSpendRow(rows, account.email);
if (!row) {
usage.status = "no-auth";
usage.error = rows.length > 1
? "Cursor usage response did not include a unique current user row"
: "Cursor usage response did not include meterable spend";
return usage;
}
const spentCents = readNumber(row.overallSpendCents) ?? readNumber(row.spendCents);
const hardLimitDollars = readNumber(row.hardLimitOverrideDollars);
const monthlyLimitDollars = readNumber(row.monthlyLimitDollars);
const limitDollars = hardLimitDollars && hardLimitDollars > 0 ? hardLimitDollars : monthlyLimitDollars;
if (spentCents === undefined || !limitDollars || limitDollars <= 0) {
usage.status = "no-auth";
usage.error = "Cursor usage response did not include a spend limit to meter";
return usage;
}
const percentUsed = clampPercent((spentCents / (limitDollars * 100)) * 100);
const reset = deriveCursorReset(data.subscriptionCycleStart ?? row.subscriptionCycleStart);
usage.status = "ok";
usage.email = typeof row.email === "string" ? row.email : usage.email;
usage.plan = usage.plan ?? (typeof row.plan === "string" ? row.plan : null);
usage.windows.push({
label: "Monthly spend",
percentUsed,
percentLeft: clampPercent(100 - percentUsed),
resetText: reset.resetText,
resetMs: reset.resetMs,
resetAt: reset.resetAt,
windowDurationMs: reset.windowDurationMs,
});
} catch (e: unknown) {
usage.status = "error";
usage.error = e instanceof Error ? e.message : "Failed to fetch";
}
return usage;
}
// ── GitHub Copilot fetcher ──────────────────────────────────────────────────
type CopilotCredential = {
@@ -2027,7 +2264,7 @@ export async function fetchAllProviderUsage(authStorage?: AuthStorageLike): Prom
}
// Fetch all providers in parallel with per-provider timeout
// Currently includes: Claude, Codex, Gemini, Minimax, Zai, Grok, GitHub Copilot
// Currently includes: Claude, Codex, Gemini, Minimax, Zai, Grok, Cursor, GitHub Copilot
const results = await Promise.allSettled([
withTimeout(fetchClaudeUsage(authStorage), "Claude", CLAUDE_FETCH_TIMEOUT_MS),
withTimeout(fetchCodexUsage(), "Codex"),
@@ -2035,6 +2272,7 @@ export async function fetchAllProviderUsage(authStorage?: AuthStorageLike): Prom
withTimeout(fetchMinimaxUsage(authStorage), "Minimax"),
withTimeout(fetchZaiUsage(authStorage), "Zai"),
withTimeout(fetchGrokUsage(authStorage), "Grok"),
withTimeout(fetchCursorUsage(authStorage), "Cursor"),
withTimeout(fetchGitHubCopilotUsage(), "GitHub Copilot"),
]);