From d40f24d20a10e9c9bd9f007bb0e92e5ed18dc03b Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 11 Jul 2026 00:12:00 -0700 Subject: [PATCH] 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) --- .changeset/fn-7816-cursor-usage.md | 7 + docs/settings-reference.md | 4 + .../app/components/UsageIndicator.tsx | 7 + .../__tests__/UsageIndicator.test.tsx | 26 ++ .../dashboard/src/__tests__/usage.test.ts | 157 ++++++++++++ packages/dashboard/src/usage.ts | 240 +++++++++++++++++- 6 files changed, 440 insertions(+), 1 deletion(-) create mode 100644 .changeset/fn-7816-cursor-usage.md diff --git a/.changeset/fn-7816-cursor-usage.md b/.changeset/fn-7816-cursor-usage.md new file mode 100644 index 0000000000..e8635853a1 --- /dev/null +++ b/.changeset/fn-7816-cursor-usage.md @@ -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. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index d42b1dfae2..93f6efbc96 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -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. + + +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. diff --git a/packages/dashboard/app/components/UsageIndicator.tsx b/packages/dashboard/app/components/UsageIndicator.tsx index 39e1a75e7a..492789c501 100644 --- a/packages/dashboard/app/components/UsageIndicator.tsx +++ b/packages/dashboard/app/components/UsageIndicator.tsx @@ -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'; } diff --git a/packages/dashboard/app/components/__tests__/UsageIndicator.test.tsx b/packages/dashboard/app/components/__tests__/UsageIndicator.test.tsx index 15080ecf13..93789230aa 100644 --- a/packages/dashboard/app/components/__tests__/UsageIndicator.test.tsx +++ b/packages/dashboard/app/components/__tests__/UsageIndicator.test.tsx @@ -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(); + + 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({ diff --git a/packages/dashboard/src/__tests__/usage.test.ts b/packages/dashboard/src/__tests__/usage.test.ts index 6faca807bb..3c2a6a1ad8 100644 --- a/packages/dashboard/src/__tests__/usage.test.ts +++ b/packages/dashboard/src/__tests__/usage.test.ts @@ -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() }; diff --git a/packages/dashboard/src/usage.ts b/packages/dashboard/src/usage.ts index ca3e2e080b..092907e818 100644 --- a/packages/dashboard/src/usage.ts +++ b/packages/dashboard/src/usage.ts @@ -1810,6 +1810,243 @@ async function fetchZaiUsage(authStorage?: AuthStorageLike): Promise { + 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 { + 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; + 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): Record[] { + const nested = typeof data.data === "object" && data.data !== null ? data.data as Record : undefined; + const rows = data.teamMemberSpend ?? nested?.teamMemberSpend ?? data.members ?? nested?.members; + return Array.isArray(rows) ? rows.filter((row): row is Record => typeof row === "object" && row !== null) : []; +} + +function selectCursorSpendRow(rows: Record[], email?: string): Record | 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 { + 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 = { 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; + 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"), ]);