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:
7
.changeset/fn-7816-cursor-usage.md
Normal file
7
.changeset/fn-7816-cursor-usage.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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() };
|
||||
|
||||
@@ -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"),
|
||||
]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user