FN-7817: Simplify Cursor usage credential to a single documented CURSOR_API_KEY path

Consolidates Cursor Admin API key resolution onto one documented env var so the usage/admin credential path is reachable and unambiguous, replacing the prior dual CURSOR_ADMIN_API_KEY/CURSOR_API_KEY and multi-provider-id lookup.

- Replace CURSOR_ADMIN_API_KEY (preferred) + CURSOR_API_KEY alias with a single CURSOR_API_KEY env var, mirroring the GROK_API_KEY precedent
- Simplify readCursorApiKey to check CURSOR_API_KEY then fall back to the single "cursor" authStorage entry via readConfiguredApiKey (drop the cursor/cursor-cli/cursor-agent provider-id loop)
- Export readCursorApiKey and fetchCursorUsage for direct test coverage
- Update the no-auth error message and settings-reference.md docs to reference only CURSOR_API_KEY, clarifying cursor-cli OAuth/session auth vs the separate Admin API usage-metering credential
- Add changeset (@runfusion/fusion: minor) documenting the credential-path change
- Add/adjust usage.test.ts coverage for readCursorApiKey precedence (env over authStorage) and the updated credential-absent error message

Files changed:
 .changeset/fn-7817-cursor-api-key.md           |  7 ++++
 docs/settings-reference.md                     |  8 ++--
 packages/dashboard/src/__tests__/usage.test.ts | 53 +++++++++++++++++++++++++-
 packages/dashboard/src/usage.ts                | 52 ++++++++-----------------
 4 files changed, 77 insertions(+), 43 deletions(-)

Fusion-Task-Id: FN-7817

Fusion-Task-Lineage: 86ac3d47-8e80-4159-abee-6c41aae56407

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-11 00:28:00 -07:00
parent d40f24d20a
commit a2c9b0fd52
4 changed files with 77 additions and 43 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Add a documented CURSOR_API_KEY credential path for Cursor usage metering.
category: feature
dev: usage.ts adds readCursorApiKey (CURSOR_API_KEY env var → cursor authStorage entry, mirroring readGrokApiKey); settings-reference.md documents it and clarifies cursor-cli runtime OAuth vs the usage/admin API key. Unblocks FN-7816. Cursor usage-API specifics confirmed via Cursor Admin API docs: POST /teams/spend with Basic auth using an admin:* API key as the username.

View File

@@ -106,7 +106,7 @@ Fusion automatically falls back to ntfy's JSON publish format when a notificatio
| `gitlabAuthTokenType` | `"personal" \| "project" \| "group"` | `undefined` (effective `"personal"` when a token exists) | Global fallback GitLab token family label for operator clarity. Project tokens and group tokens remain limited to their associated project/group and role membership; this label does not expand authorization. Unsupported values are rejected by the GitLab auth resolver. |
| `autoReloadOnVersionChange` | `boolean` | `true` | When enabled (default), the dashboard automatically reloads when a new build version is detected via `/version.json` polling or service worker activation. Set to `false` to suppress automatic reloads — the user must manually refresh to pick up updates. |
| `modelOnboardingComplete` | `boolean` | `undefined` | Whether AI onboarding has been completed or dismissed. |
| `useCursorCli` | `boolean` | `undefined` | Enables the `cursor-cli` provider in model pickers after Cursor CLI status validation. Toggle from Settings → Authentication. |
| `useCursorCli` | `boolean` | `undefined` | Enables the `cursor-cli` provider in model pickers after Cursor CLI status validation. Toggle from Settings → Authentication. This runtime auth is OAuth/session-based; Cursor usage metering is separate and reads a Cursor Admin API key from the dashboard process `CURSOR_API_KEY` env var. |
| `cursorCliBinaryPath` | `string` | `undefined` | Optional global, machine-local Cursor CLI executable override used by Settings → Authentication, status/enable validation, probes, and model discovery. Leave unset/blank to auto-detect `cursor-agent` then `cursor` on PATH. Use this when PATH points at the wrong Cursor install or Windows exposes a specific `.cmd`/`.bat` shim; invalid non-empty saves are rejected with bounded diagnostics. |
| `useGrokCli` | `boolean` | `undefined` | Enables the `grok-cli` provider in model pickers after Grok CLI status validation. Toggle from Settings → Authentication. Grok's direct xAI endpoint uses API-key auth (`GROK_API_KEY` env var or `~/.grok/user-settings.json` `apiKey`); when a `grok-cli/*` execution model has no Fusion-visible key, Fusion falls back to the `grok` CLI runtime if registered so the CLI can use its own auth store. |
| `grokCliBinaryPath` | `string` | `undefined` | Optional global, machine-local Grok CLI executable override used by Settings → Authentication, status/enable validation, probes, and model discovery. Leave unset/blank to auto-detect `grok` on PATH. Invalid non-empty saves are rejected with bounded diagnostics. |
@@ -990,11 +990,11 @@ 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. -->
<!-- FNXC:UsageProviders 2026-07-11-00:00: FN-7817 — 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 Fusion's documented `CURSOR_API_KEY` env var; 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.
The Usage dropdown can show a Cursor card when the dashboard process has a Cursor Admin API key in `CURSOR_API_KEY`. 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. Cursor documents API key creation at `cursor.com/dashboard` → **API Keys** with `admin:*` scope for Admin API access, but does not document a local Admin API key file or an env-var name; `CURSOR_API_KEY` is Fusion's operator-facing convention. 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.
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. Grok direct-endpoint auth is API-key based (`GROK_API_KEY` or the Grok CLI settings-file fallback), while Cursor has two distinct paths: `cursor-cli` model execution uses OAuth/session auth and Cursor usage metering uses Fusion's `CURSOR_API_KEY` Admin API key env var. CLI-routed Grok 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

@@ -35,6 +35,8 @@ import {
withTimeout,
CLAUDE_FETCH_TIMEOUT_MS,
_clearRefreshedToken,
readCursorApiKey,
fetchCursorUsage,
} from "../usage.js";
// Mock the https module
@@ -92,7 +94,6 @@ 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", "");
});
@@ -3255,6 +3256,45 @@ describe("usage", () => {
});
});
describe("readCursorApiKey", () => {
const cursorAuthStorage = (apiKey: string) => ({
reload: vi.fn(),
hasAuth: vi.fn((provider: string) => provider === "cursor"),
getApiKey: vi.fn((provider: string) => provider === "cursor" ? apiKey : null),
get: vi.fn(),
});
it("resolves a trimmed CURSOR_API_KEY without injected authStorage", async () => {
vi.stubEnv("CURSOR_API_KEY", " cursor-env-key ");
await expect(readCursorApiKey()).resolves.toBe("cursor-env-key");
});
it("returns null when CURSOR_API_KEY is unset or blank and no authStorage key exists", async () => {
mockReadFile.mockImplementation(async () => Promise.reject(new Error("File not found")));
vi.stubEnv("CURSOR_API_KEY", " ");
await expect(readCursorApiKey()).resolves.toBeNull();
});
it("resolves the documented cursor authStorage api-key entry", async () => {
mockReadFile.mockImplementation(async () => Promise.reject(new Error("File not found")));
const authStorage = cursorAuthStorage("cursor-storage-key");
await expect(readCursorApiKey(authStorage)).resolves.toBe("cursor-storage-key");
expect(authStorage.reload).toHaveBeenCalled();
expect(authStorage.getApiKey).toHaveBeenCalledWith("cursor");
});
it("prefers CURSOR_API_KEY over authStorage", async () => {
vi.stubEnv("CURSOR_API_KEY", "cursor-env-key");
const authStorage = cursorAuthStorage("cursor-storage-key");
await expect(readCursorApiKey(authStorage)).resolves.toBe("cursor-env-key");
expect(authStorage.getApiKey).not.toHaveBeenCalled();
});
});
describe("fetchCursorUsage (via fetchAllProviderUsage)", () => {
const cursorAuthStorage = (apiKey: string) => ({
reload: vi.fn(),
@@ -3312,7 +3352,7 @@ describe("usage", () => {
],
});
vi.stubEnv("CURSOR_ADMIN_API_KEY", "cursor-admin-env-key");
vi.stubEnv("CURSOR_API_KEY", "cursor-admin-env-key");
const providers = await fetchAllProviderUsage();
const cursor = providers.find((p) => p.name === "Cursor")!;
@@ -3366,6 +3406,15 @@ describe("usage", () => {
expect(cursor).toBeUndefined();
});
it("names CURSOR_API_KEY in the credential-absent message", async () => {
mockReadFile.mockImplementation(async () => Promise.reject(new Error("File not found")));
const cursor = await fetchCursorUsage();
expect(cursor.status).toBe("no-auth");
expect(cursor.error).toBe("No Cursor Admin API key — set CURSOR_API_KEY in the Fusion dashboard environment");
});
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" });

View File

@@ -1813,8 +1813,8 @@ async function fetchZaiUsage(authStorage?: AuthStorageLike): Promise<ProviderUsa
// ── 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_API_KEY_ENV_VAR = "CURSOR_API_KEY";
const CURSOR_API_KEY_PROVIDER_ID = "cursor";
const CURSOR_MONTHLY_WINDOW_MS = 30 * 24 * 60 * 60 * 1000;
type CursorAccountInfo = {
@@ -1822,39 +1822,17 @@ type CursorAccountInfo = {
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();
}
/*
FNXC:UsageProviders 2026-07-11-00:00:
Cursor Admin API usage metering needs a real operator-reachable credential path, but Cursor CLI runtime auth remains OAuth/session-based through `cursor-agent status` and is not an Admin API key. Use Fusion's documented `CURSOR_API_KEY` environment variable first, mirroring the `GROK_API_KEY` precedent, then the single `cursor` authStorage fallback for tests/imported credentials. Cursor documents Basic Auth with an API key for the Admin API and `POST /teams/spend`, but does not document a local Admin API key file, so no file fallback is invented here.
*/
export async function readCursorApiKey(authStorage?: AuthStorageLike): Promise<string | null> {
const envKey = process.env[CURSOR_API_KEY_ENV_VAR];
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;
return readConfiguredApiKey(CURSOR_API_KEY_PROVIDER_ID, authStorage);
}
async function readCursorAccountInfo(): Promise<CursorAccountInfo> {
@@ -1951,7 +1929,7 @@ function selectCursorSpendRow(rows: Record<string, unknown>[], email?: string):
return rows.length === 1 ? rows[0] : null;
}
async function fetchCursorUsage(authStorage?: AuthStorageLike): Promise<ProviderUsage> {
export async function fetchCursorUsage(authStorage?: AuthStorageLike): Promise<ProviderUsage> {
const usage: ProviderUsage = {
name: "Cursor",
icon: "🟣",
@@ -1961,7 +1939,7 @@ async function fetchCursorUsage(authStorage?: AuthStorageLike): Promise<Provider
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";
usage.error = "No Cursor Admin API key — set CURSOR_API_KEY in the Fusion dashboard environment";
return usage;
}
@@ -1971,8 +1949,8 @@ async function fetchCursorUsage(authStorage?: AuthStorageLike): Promise<Provider
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.
FNXC:UsageProviders 2026-07-11-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 the documented `CURSOR_API_KEY` environment variable; 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;