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:
7
.changeset/fn-7817-cursor-api-key.md
Normal file
7
.changeset/fn-7817-cursor-api-key.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user