feat(ai): report Cursor account usage
Expose Cursor OAuth and access-token quota reporting through the shared usage APIs and CLI views, with parser and presentation coverage.
This commit is contained in:
@@ -6,6 +6,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
|
||||
|
||||
|
||||
@@ -42,6 +42,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";
|
||||
@@ -566,6 +567,7 @@ const DEFAULT_USAGE_PROVIDERS: UsageProvider[] = [
|
||||
zaiUsageProvider,
|
||||
opencodeGoUsageProvider,
|
||||
githubCopilotUsageProvider,
|
||||
cursorUsageProvider,
|
||||
];
|
||||
|
||||
const DEFAULT_USAGE_PROVIDER_MAP = new Map<Provider, UsageProvider>(
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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 = {
|
||||
|
||||
@@ -279,6 +279,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[] = [
|
||||
|
||||
Reference in New Issue
Block a user