Merge PR #5248: feat(ai): report Cursor account usage (@riverpilot)

This commit is contained in:
can1357
2026-07-14 19:16:09 +02:00
7 changed files with 566 additions and 0 deletions
+1
View File
@@ -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
+2
View File
@@ -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<Provider, UsageProvider>(
+1
View File
@@ -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";
+192
View File
@@ -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<string, unknown> {
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<string, unknown>): 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<UsageReport | null> {
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<string, string> = {
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;
}
},
};
+284
View File
@@ -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<Response> => {
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<string, string>;
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();
});
});
});
@@ -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 = {
@@ -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[] = [