diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 1338f346f..9b066ead8 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -50,6 +50,7 @@ - Added asynchronous `invalidateUsageCache` method to clear cached usage reports - Added support for cross-service usage cache invalidation between AuthStorage and AuthBroker +- Added Cursor OAuth and access-token usage reporting to `omp usage` via Cursor's account usage endpoint. ### Fixed diff --git a/packages/ai/src/auth-storage.ts b/packages/ai/src/auth-storage.ts index eec406470..56022676c 100644 --- a/packages/ai/src/auth-storage.ts +++ b/packages/ai/src/auth-storage.ts @@ -43,6 +43,7 @@ import type { } from "./usage"; import { resolveUsedFraction } from "./usage"; import { claudeRankingStrategy, claudeUsageProvider } from "./usage/claude"; +import { cursorUsageProvider } from "./usage/cursor"; import { googleGeminiCliUsageProvider } from "./usage/gemini"; import { githubCopilotUsageProvider } from "./usage/github-copilot"; import { antigravityRankingStrategy, antigravityUsageProvider } from "./usage/google-antigravity"; @@ -579,6 +580,7 @@ const DEFAULT_USAGE_PROVIDERS: UsageProvider[] = [ zaiUsageProvider, opencodeGoUsageProvider, githubCopilotUsageProvider, + cursorUsageProvider, ]; const DEFAULT_USAGE_PROVIDER_MAP = new Map( diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index 967735359..e5599d876 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -29,6 +29,7 @@ export * from "./stream"; export * from "./types"; export * from "./usage"; export * from "./usage/claude"; +export * from "./usage/cursor"; export * from "./usage/gemini"; export * from "./usage/github-copilot"; export * from "./usage/google-antigravity"; diff --git a/packages/ai/src/usage/cursor.ts b/packages/ai/src/usage/cursor.ts new file mode 100644 index 000000000..c3f621126 --- /dev/null +++ b/packages/ai/src/usage/cursor.ts @@ -0,0 +1,192 @@ +import type { + UsageAmount, + UsageFetchContext, + UsageFetchParams, + UsageLimit, + UsageProvider, + UsageReport, + UsageStatus, + UsageWindow, +} from "../usage"; +import { toNumber } from "./shared"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parseTimestamp(value: unknown): number | undefined { + const numeric = toNumber(value); + if (numeric !== undefined) return numeric < 1_000_000_000_000 ? numeric * 1000 : numeric; + if (typeof value !== "string" || !value.trim()) return undefined; + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function normalizeCursorBaseUrl(baseUrl?: string): string { + if (!baseUrl) return "https://api2.cursor.sh"; + return baseUrl.replace(/\/+$/, ""); +} + +function deriveResetsAt(payload: Record): number | undefined { + const endKeys = ["billingCycleEnd", "endOfMonth", "resetsAt", "nextReset"]; + for (const key of endKeys) { + const parsed = parseTimestamp(payload[key]); + if (parsed !== undefined) return parsed; + } + + const startKeys = ["startOfMonth", "billingCycleStart", "startOfBillingCycle"]; + for (const key of startKeys) { + const parsed = parseTimestamp(payload[key]); + if (parsed !== undefined) { + const date = new Date(parsed); + date.setUTCMonth(date.getUTCMonth() + 1); + return date.getTime(); + } + } + return undefined; +} + +export function parseCursorUsage(payload: unknown, fetchedAt = Date.now()): UsageReport | null { + if (!isRecord(payload)) return null; + const limits: UsageLimit[] = []; + const resetsAt = deriveResetsAt(payload); + + const window: UsageWindow = { + id: "monthly", + label: "Monthly", + ...(resetsAt !== undefined ? { resetsAt } : {}), + }; + + for (const [key, value] of Object.entries(payload)) { + if (!isRecord(value)) continue; + + // used can be: numRequests, used, amountUsed, usdUsed + const usedVal = + toNumber(value.numRequests) ?? toNumber(value.used) ?? toNumber(value.amountUsed) ?? toNumber(value.usdUsed); + + // limit can be: maxRequestUsage, limit, amountLimit, usdLimit + const limitVal = + toNumber(value.maxRequestUsage) ?? + toNumber(value.limit) ?? + toNumber(value.amountLimit) ?? + toNumber(value.usdLimit); + + if (usedVal !== undefined && limitVal !== undefined) { + const isUsd = + key === "planUsage" || + key.toLowerCase().includes("usd") || + key.toLowerCase().includes("billing") || + key.toLowerCase().includes("stripe"); + + const unit = isUsd ? "usd" : "requests"; + const cleanBucket = key.toLowerCase().trim(); + const limitId = isUsd ? `cursor:usd:${cleanBucket}` : `cursor:requests:${cleanBucket}`; + + const label = isUsd ? `${key} spend` : `${key} requests`; + + const amount: UsageAmount = { + used: usedVal, + limit: limitVal, + remaining: Math.max(0, limitVal - usedVal), + usedFraction: limitVal > 0 ? usedVal / limitVal : 0, + remainingFraction: limitVal > 0 ? Math.max(0, limitVal - usedVal) / limitVal : 0, + unit, + }; + + const usedFraction = amount.usedFraction; + let status: UsageStatus = "unknown"; + if (usedFraction !== undefined) { + if (usedFraction >= 1) { + status = "exhausted"; + } else if (usedFraction >= 0.9) { + status = "warning"; + } else { + status = "ok"; + } + } + + limits.push({ + id: limitId, + label, + scope: { + provider: "cursor", + ...(window ? { windowId: window.id } : {}), + }, + ...(window ? { window } : {}), + amount, + status, + }); + } + } + + if (limits.length === 0) { + return null; + } + + return { + provider: "cursor", + fetchedAt, + limits, + raw: payload, + }; +} + +export const cursorUsageProvider: UsageProvider = { + id: "cursor", + supports(params: UsageFetchParams): boolean { + if (params.provider !== "cursor") return false; + const { credential } = params; + if (credential.type === "oauth") { + return Boolean(credential.accessToken); + } + if (credential.type === "api_key") { + return Boolean(credential.apiKey); + } + return false; + }, + async fetchUsage(params: UsageFetchParams, ctx: UsageFetchContext): Promise { + if (params.provider !== "cursor") return null; + const { credential } = params; + const token = credential.type === "oauth" ? credential.accessToken : credential.apiKey; + if (!token) return null; + + const baseUrl = normalizeCursorBaseUrl(params.baseUrl ?? credential.apiEndpoint); + const url = `${baseUrl}/auth/usage`; + + const headers: Record = { + Accept: "application/json", + Authorization: `Bearer ${token}`, + }; + + try { + const response = await ctx.fetch(url, { + headers, + signal: params.signal, + }); + if (!response.ok) { + ctx.logger?.warn("Cursor usage request failed", { + status: response.status, + provider: params.provider, + }); + return null; + } + const payload = await response.json(); + const report = parseCursorUsage(payload); + if (report) { + const metadata = { + ...(credential.email ? { email: credential.email } : {}), + ...(credential.accountId ? { accountId: credential.accountId } : {}), + ...(credential.projectId ? { projectId: credential.projectId } : {}), + }; + if (Object.keys(metadata).length > 0) report.metadata = metadata; + } + return report; + } catch (error) { + ctx.logger?.warn("Cursor usage request error", { + provider: params.provider, + error: String(error), + }); + return null; + } + }, +}; diff --git a/packages/ai/test/cursor-usage.test.ts b/packages/ai/test/cursor-usage.test.ts new file mode 100644 index 000000000..ff0a81d28 --- /dev/null +++ b/packages/ai/test/cursor-usage.test.ts @@ -0,0 +1,284 @@ +import { describe, expect, it } from "bun:test"; +import type { UsageFetchContext, UsageFetchParams } from "../src/usage"; +import { cursorUsageProvider, parseCursorUsage } from "../src/usage/cursor"; + +describe("cursor usage provider", () => { + describe("parseCursorUsage", () => { + it("returns null for non-record payloads", () => { + expect(parseCursorUsage(null)).toBeNull(); + expect(parseCursorUsage(undefined)).toBeNull(); + expect(parseCursorUsage("invalid")).toBeNull(); + expect(parseCursorUsage([])).toBeNull(); + }); + + it("returns null when no recognized quotas are present", () => { + const payload = { + someOtherField: "hello", + startOfMonth: "2026-07-01T00:00:00.000Z", + }; + expect(parseCursorUsage(payload)).toBeNull(); + }); + + it("parses request-count buckets with stable IDs and labels", () => { + const payload = { + "gpt-4": { + numRequests: 150, + maxRequestUsage: 500, + }, + "claude-3-5-sonnet": { + used: 80, + limit: 100, + }, + startOfMonth: "2026-07-01T00:00:00.000Z", + }; + + const report = parseCursorUsage(payload); + expect(report).not.toBeNull(); + if (!report) return; + + expect(report.provider).toBe("cursor"); + expect(report.limits).toHaveLength(2); + + const gpt4Limit = report.limits.find(l => l.id === "cursor:requests:gpt-4"); + expect(gpt4Limit).toBeDefined(); + if (gpt4Limit) { + expect(gpt4Limit.label).toBe("gpt-4 requests"); + expect(gpt4Limit.amount.used).toBe(150); + expect(gpt4Limit.amount.limit).toBe(500); + expect(gpt4Limit.amount.remaining).toBe(350); + expect(gpt4Limit.amount.usedFraction).toBe(0.3); + expect(gpt4Limit.amount.unit).toBe("requests"); + expect(gpt4Limit.status).toBe("ok"); + expect(gpt4Limit.window).toBeDefined(); + expect(gpt4Limit.window?.id).toBe("monthly"); + expect(gpt4Limit.window?.label).toBe("Monthly"); + // 2026-07-01 + 1 month = 2026-08-01 + expect(gpt4Limit.window?.resetsAt).toBe(Date.parse("2026-08-01T00:00:00.000Z")); + } + + const sonnetLimit = report.limits.find(l => l.id === "cursor:requests:claude-3-5-sonnet"); + expect(sonnetLimit).toBeDefined(); + if (sonnetLimit) { + expect(sonnetLimit.label).toBe("claude-3-5-sonnet requests"); + expect(sonnetLimit.amount.used).toBe(80); + expect(sonnetLimit.amount.limit).toBe(100); + expect(sonnetLimit.amount.usedFraction).toBe(0.8); + expect(sonnetLimit.status).toBe("ok"); + } + }); + + it("parses USD/billing plan buckets with stable IDs and labels", () => { + const payload = { + planUsage: { + used: 15.5, + limit: 20.0, + }, + "usd-custom": { + amountUsed: 45, + amountLimit: 50, + }, + }; + + const report = parseCursorUsage(payload); + expect(report).not.toBeNull(); + if (!report) return; + + expect(report.limits).toHaveLength(2); + + const planLimit = report.limits.find(l => l.id === "cursor:usd:planusage"); + expect(planLimit).toBeDefined(); + if (planLimit) { + expect(planLimit.label).toBe("planUsage spend"); + expect(planLimit.amount.used).toBe(15.5); + expect(planLimit.amount.limit).toBe(20.0); + expect(planLimit.amount.unit).toBe("usd"); + expect(planLimit.status).toBe("ok"); + } + + const customLimit = report.limits.find(l => l.id === "cursor:usd:usd-custom"); + expect(customLimit).toBeDefined(); + if (customLimit) { + expect(customLimit.label).toBe("usd-custom spend"); + expect(customLimit.amount.used).toBe(45); + expect(customLimit.amount.limit).toBe(50); + expect(customLimit.amount.unit).toBe("usd"); + // 45 / 50 = 0.9 -> warning status + expect(customLimit.status).toBe("warning"); + } + }); + + it("derives resetsAt from startOfMonth", () => { + const payload = { + "gpt-4": { + numRequests: 10, + maxRequestUsage: 10, + }, + startOfMonth: "2026-07-11T12:00:00.000Z", + }; + const report = parseCursorUsage(payload); + expect(report).not.toBeNull(); + const limit = report?.limits[0]; + expect(limit?.window?.resetsAt).toBe(Date.parse("2026-08-11T12:00:00.000Z")); + }); + + it("derives resetsAt directly from billingCycleEnd", () => { + const payload = { + "gpt-4": { + numRequests: 10, + maxRequestUsage: 10, + }, + billingCycleEnd: "2026-07-20T00:00:00.000Z", + }; + const report = parseCursorUsage(payload); + expect(report).not.toBeNull(); + const limit = report?.limits[0]; + expect(limit?.window?.resetsAt).toBe(Date.parse("2026-07-20T00:00:00.000Z")); + }); + }); + + describe("cursorUsageProvider", () => { + it("supports oauth credentials", () => { + const params: UsageFetchParams = { + provider: "cursor", + credential: { + type: "oauth", + accessToken: "valid-token", + }, + }; + expect(cursorUsageProvider.supports?.(params)).toBe(true); + }); + + it("supports api_key credentials", () => { + const params: UsageFetchParams = { + provider: "cursor", + credential: { + type: "api_key", + apiKey: "valid-api-key", + }, + }; + expect(cursorUsageProvider.supports?.(params)).toBe(true); + }); + + it("does not support missing token/key", () => { + const params1: UsageFetchParams = { + provider: "cursor", + credential: { + type: "oauth", + }, + }; + const params2: UsageFetchParams = { + provider: "cursor", + credential: { + type: "api_key", + }, + }; + expect(cursorUsageProvider.supports?.(params1)).toBe(false); + expect(cursorUsageProvider.supports?.(params2)).toBe(false); + }); + + it("does not support other providers", () => { + const params: UsageFetchParams = { + provider: "openai-codex", + credential: { + type: "oauth", + accessToken: "token", + }, + }; + expect(cursorUsageProvider.supports?.(params)).toBe(false); + }); + + it("fetches and parses usage successfully", async () => { + const payload = { + "gpt-4": { + numRequests: 10, + maxRequestUsage: 100, + }, + startOfMonth: "2026-07-01T00:00:00.000Z", + }; + + const mockFetch = (async (input: string | URL, init?: RequestInit): Promise => { + const urlStr = typeof input === "string" ? input : input.toString(); + expect(urlStr).toBe("https://api2.cursor.sh/auth/usage"); + expect(init?.headers).toBeDefined(); + const headers = init?.headers as Record; + expect(headers.Accept).toBe("application/json"); + expect(headers.Authorization).toBe("Bearer test-token"); + + return new Response(JSON.stringify(payload), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }) as unknown as typeof fetch; + + const ctx: UsageFetchContext = { + fetch: mockFetch, + }; + + const report = await cursorUsageProvider.fetchUsage( + { + provider: "cursor", + credential: { + type: "oauth", + accessToken: "test-token", + email: "user@example.com", + accountId: "acc_123", + }, + }, + ctx, + ); + + expect(report).not.toBeNull(); + if (!report) return; + + expect(report.provider).toBe("cursor"); + expect(report.limits).toHaveLength(1); + expect(report.limits[0].id).toBe("cursor:requests:gpt-4"); + expect(report.metadata).toEqual({ + email: "user@example.com", + accountId: "acc_123", + }); + }); + + it("returns null on non-2xx response", async () => { + const mockFetch = (async () => new Response("Error", { status: 403 })) as unknown as typeof fetch; + const ctx: UsageFetchContext = { + fetch: mockFetch, + }; + + const report = await cursorUsageProvider.fetchUsage( + { + provider: "cursor", + credential: { + type: "oauth", + accessToken: "test-token", + }, + }, + ctx, + ); + + expect(report).toBeNull(); + }); + + it("returns null on fetch error", async () => { + const mockFetch = (async () => { + throw new Error("Network error"); + }) as unknown as typeof fetch; + const ctx: UsageFetchContext = { + fetch: mockFetch, + }; + + const report = await cursorUsageProvider.fetchUsage( + { + provider: "cursor", + credential: { + type: "oauth", + accessToken: "test-token", + }, + }, + ctx, + ); + + expect(report).toBeNull(); + }); + }); +}); diff --git a/packages/coding-agent/test/modes/controllers/usage-command.test.ts b/packages/coding-agent/test/modes/controllers/usage-command.test.ts index c6af0c95f..d44e9e180 100644 --- a/packages/coding-agent/test/modes/controllers/usage-command.test.ts +++ b/packages/coding-agent/test/modes/controllers/usage-command.test.ts @@ -66,6 +66,54 @@ describe("CommandController /usage", () => { expect(output).not.toContain("··········"); }); + it("renders Cursor request quotas in the /usage view", async () => { + const present = vi.fn(); + const ctx = { + session: {}, + ui: { terminal: { columns: 100 } }, + present, + showWarning: vi.fn(), + showError: vi.fn(), + } as unknown as InteractiveModeContext; + const controller = new CommandController(ctx); + const now = Date.now(); + const reports: UsageReport[] = [ + { + provider: "cursor", + fetchedAt: now, + limits: [ + { + id: "cursor:requests:gpt-4", + label: "gpt-4 requests", + scope: { provider: "cursor", windowId: "monthly" }, + window: { id: "monthly", label: "Monthly", resetsAt: now + 86_400_000 }, + amount: { + unit: "requests", + used: 150, + limit: 500, + remaining: 350, + usedFraction: 0.3, + remainingFraction: 0.7, + }, + status: "ok", + }, + ], + metadata: { email: "cursor@example.test" }, + }, + ]; + + await controller.handleUsageCommand(reports); + + expect(present).toHaveBeenCalledTimes(1); + const firstCall = present.mock.calls[0]; + expect(firstCall).toBeDefined(); + const output = renderPresentedBlocks(firstCall?.[0]); + expect(output).toContain("Cursor"); + expect(output).toContain("gpt-4 requests"); + expect(output).toContain("70% free"); + expect(output).toContain("resets in 1d"); + }); + it("renders saved reset expiry lines for future and expired credits", async () => { const present = vi.fn(); const ctx = { diff --git a/packages/coding-agent/test/usage-cli.test.ts b/packages/coding-agent/test/usage-cli.test.ts index 3d45710de..b65adcc91 100644 --- a/packages/coding-agent/test/usage-cli.test.ts +++ b/packages/coding-agent/test/usage-cli.test.ts @@ -331,6 +331,44 @@ describe("formatUsageBreakdown", () => { expect(text).toContain("0.40× quota left"); }); + it("renders Cursor request quotas in the usage breakdown", () => { + const now = Date.parse("2026-01-01T00:00:00.000Z"); + const reports: UsageReport[] = [ + { + provider: "cursor", + fetchedAt: now, + metadata: { email: "cursor@example.test" }, + limits: [ + { + id: "cursor:requests:gpt-4", + label: "gpt-4 requests", + scope: { provider: "cursor", windowId: "monthly" }, + window: { + id: "monthly", + label: "Monthly", + resetsAt: Date.parse("2026-02-01T00:00:00.000Z"), + }, + amount: { + unit: "requests", + used: 150, + limit: 500, + remaining: 350, + usedFraction: 0.3, + remainingFraction: 0.7, + }, + status: "ok", + }, + ], + }, + ]; + + const text = stripVTControlCharacters(formatUsageBreakdown(reports, [], now)); + expect(text).toContain("Cursor"); + expect(text).toContain("gpt-4 requests"); + expect(text).toContain("150 / 500 requests"); + expect(text).toContain("30.0% used"); + expect(text).toContain("resets in 31d"); + }); it("renders saved reset expiry state for future and expired credits", () => { const now = Date.parse("2026-01-01T00:00:00.000Z"); const reports: UsageReport[] = [