Files
oh-my-pi/packages/ai/src/usage/cursor.ts
T
dnth 69f790fa6e fix(ai): map Cursor plan rails to dashboard percents
Cursor Pro/Pro+ usage-summary still uses individualUsage.plan, but the
web dashboard percent is autoPercentUsed / apiPercentUsed — not
plan.used/limit cents. Prefer those rails for Cursor Models / Other
Models, keep overall+cents fallbacks, floor monthly status-line %, and
repaint after async usage fetch so mo N% does not stay blank.
2026-08-08 17:26:40 +08:00

455 lines
13 KiB
TypeScript

import { toNumber } from "@oh-my-pi/pi-catalog/utils";
import { extractCursorAccessTokenUserId } from "../registry/oauth/cursor";
import type {
UsageAmount,
UsageFetchContext,
UsageFetchParams,
UsageLimit,
UsageProvider,
UsageReport,
UsageWindow,
} from "../usage";
import { isRecord } from "../utils";
import { parseIsoTimestamp, usageStatus } from "./shared";
function parseTimestamp(value: unknown): number | undefined {
const numeric = toNumber(value);
if (numeric !== undefined) return numeric < 1_000_000_000_000 ? numeric * 1000 : numeric;
return parseIsoTimestamp(value);
}
const DEFAULT_CURSOR_BASE_URL = "https://api2.cursor.sh";
function normalizeCursorBaseUrl(baseUrl?: string): string {
if (!baseUrl) return DEFAULT_CURSOR_BASE_URL;
return baseUrl.replace(/\/+$/, "");
}
type CursorUsageSource = "auth-usage" | "usage-summary" | "auth-me";
async function fetchCursorJson(
ctx: UsageFetchContext,
url: string,
init: RequestInit,
source: CursorUsageSource,
): Promise<unknown | undefined> {
try {
const response = await ctx.fetch(url, init);
if (!response.ok) {
ctx.logger?.warn("Cursor usage request failed", {
status: response.status,
provider: "cursor",
source,
});
return undefined;
}
return await response.json();
} catch (error) {
ctx.logger?.warn("Cursor usage request error", {
provider: "cursor",
source,
error: String(error),
});
return undefined;
}
}
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;
}
/**
* Cursor's `/api/usage-summary` has shipped two personal-bucket shapes:
* - Enterprise/team dashboards historically exposed `individualUsage.overall`
* - Current Pro / Pro+ / Ultra dashboards expose `individualUsage.plan`
* (plus optional `onDemand`)
*
* Prefer `overall` when present so older fixtures keep working, then fall
* back to `plan`. Optionally emit a second on-demand meter when Cursor
* reports one with a positive limit.
*/
function pickCursorIndividualBucket(
individualUsage: Record<string, unknown>,
): { key: "overall" | "plan"; bucket: Record<string, unknown> } | null {
if (isRecord(individualUsage.overall)) {
return { key: "overall", bucket: individualUsage.overall };
}
if (isRecord(individualUsage.plan)) {
return { key: "plan", bucket: individualUsage.plan };
}
return null;
}
function parseCursorCentsBucket(bucket: Record<string, unknown>): UsageAmount | null {
if (bucket.enabled === false) return null;
const reportedUsed = toNumber(bucket.used);
const reportedRemaining = toNumber(bucket.remaining);
const hasValidUsed = reportedUsed !== undefined && reportedUsed >= 0;
const hasValidRemaining = reportedRemaining !== undefined && reportedRemaining >= 0;
const limit = toNumber(bucket.limit);
if (bucket.limit === null || bucket.limit === undefined) {
if (!hasValidUsed) return null;
return { used: reportedUsed / 100, unit: "usd" };
}
if (limit === undefined || limit <= 0) return null;
let used: number;
if (reportedUsed !== undefined && reportedUsed > 0) {
used = reportedUsed;
} else if (hasValidRemaining && reportedRemaining < limit) {
used = Math.max(0, limit - reportedRemaining);
} else if (hasValidUsed) {
used = reportedUsed;
} else {
return null;
}
const remaining = Math.max(0, limit - used);
return {
used: used / 100,
limit: limit / 100,
remaining: remaining / 100,
usedFraction: used / limit,
remainingFraction: remaining / limit,
unit: "usd",
};
}
/**
* Cursor's dashboard does not treat plan.used / plan.limit as the visible %.
* Pro+ shows separate rails:
* - Cursor Models ← autoPercentUsed
* - Other Models ← apiPercentUsed (against the included $ pool)
* Prefer those fractions when present; fall back to cents only for older overall buckets.
*/
function parseCursorPlanDashboardAmounts(bucket: Record<string, unknown>): {
auto?: UsageAmount;
api?: UsageAmount;
fallback?: UsageAmount;
} {
const limitCents = toNumber(bucket.limit);
const limitUsd = limitCents !== undefined && limitCents > 0 ? limitCents / 100 : undefined;
const autoPct = toNumber(bucket.autoPercentUsed);
const apiPct = toNumber(bucket.apiPercentUsed);
const totalPct = toNumber(bucket.totalPercentUsed);
const fromPercent = (pct: number, withLimit: boolean): UsageAmount => {
const usedFraction = Math.max(0, pct) / 100;
if (withLimit && limitUsd !== undefined) {
const used = limitUsd * usedFraction;
return {
used,
limit: limitUsd,
remaining: Math.max(0, limitUsd - used),
usedFraction,
remainingFraction: Math.max(0, 1 - usedFraction),
unit: "usd",
};
}
return { used: usedFraction * 100, usedFraction, unit: "percent" };
};
const result: { auto?: UsageAmount; api?: UsageAmount; fallback?: UsageAmount } = {};
if (autoPct !== undefined) result.auto = fromPercent(autoPct, false);
if (apiPct !== undefined) result.api = fromPercent(apiPct, true);
if (!result.auto && !result.api) {
if (totalPct !== undefined) {
result.fallback = fromPercent(totalPct, true);
} else {
const cents = parseCursorCentsBucket(bucket);
if (cents) result.fallback = cents;
}
}
return result;
}
export function parseCursorIndividualUsage(payload: unknown, fetchedAt = Date.now()): UsageReport | null {
if (!isRecord(payload) || !isRecord(payload.individualUsage)) {
return null;
}
const picked = pickCursorIndividualBucket(payload.individualUsage);
if (!picked) return null;
const resetsAt = deriveResetsAt(payload);
const window: UsageWindow = {
id: "monthly",
label: "Monthly",
...(resetsAt !== undefined ? { resetsAt } : {}),
};
const limits: UsageLimit[] = [];
if (picked.key === "plan") {
const rails = parseCursorPlanDashboardAmounts(picked.bucket);
if (rails.auto) {
limits.push({
id: "cursor:usd:individual-auto",
label: "Cursor Models",
scope: { provider: "cursor", windowId: window.id },
window,
amount: rails.auto,
...(rails.auto.usedFraction !== undefined
? { status: usageStatus(rails.auto.usedFraction) }
: {}),
});
}
if (rails.api) {
limits.push({
id: "cursor:usd:individual-api",
label: "Other Models",
scope: { provider: "cursor", windowId: window.id },
window,
amount: rails.api,
...(rails.api.usedFraction !== undefined ? { status: usageStatus(rails.api.usedFraction) } : {}),
});
}
if (rails.fallback) {
limits.push({
id: "cursor:usd:individual-plan",
label: "Personal Usage",
scope: { provider: "cursor", windowId: window.id },
window,
amount: rails.fallback,
...(rails.fallback.usedFraction !== undefined
? { status: usageStatus(rails.fallback.usedFraction) }
: {}),
});
}
} else {
const amount = parseCursorCentsBucket(picked.bucket);
if (!amount) return null;
limits.push({
id: `cursor:usd:individual-${picked.key}`,
label: "Personal Usage",
scope: { provider: "cursor", windowId: window.id },
window,
amount,
...(amount.usedFraction !== undefined ? { status: usageStatus(amount.usedFraction) } : {}),
});
}
if (limits.length === 0) return null;
if (isRecord(payload.individualUsage.onDemand)) {
const onDemandAmount = parseCursorCentsBucket(payload.individualUsage.onDemand);
if (onDemandAmount && onDemandAmount.limit !== undefined && onDemandAmount.limit > 0) {
limits.push({
id: "cursor:usd:individual-ondemand",
label: "On-Demand Usage",
scope: { provider: "cursor", windowId: window.id },
window,
amount: onDemandAmount,
...(onDemandAmount.usedFraction !== undefined
? { status: usageStatus(onDemandAmount.usedFraction) }
: {}),
});
}
}
return {
provider: "cursor",
fetchedAt,
limits,
raw: payload,
};
}
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 status = usageStatus(amount.usedFraction);
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}`,
};
const fetchedAt = Date.now();
const legacyReportPromise = fetchCursorJson(
ctx,
url,
{
headers,
signal: params.signal,
},
"auth-usage",
).then(payload => parseCursorUsage(payload, fetchedAt));
let summaryReportPromise = Promise.resolve<UsageReport | null>(null);
let profileEmailPromise = Promise.resolve<string | undefined>(undefined);
if (credential.type === "oauth" && baseUrl === DEFAULT_CURSOR_BASE_URL) {
const userId = extractCursorAccessTokenUserId(token);
if (userId) {
const sessionHeaders: Record<string, string> = {
Accept: "application/json",
Cookie: `WorkosCursorSessionToken=${encodeURIComponent(`${userId}::${token}`)}`,
};
summaryReportPromise = fetchCursorJson(
ctx,
"https://cursor.com/api/usage-summary",
{
headers: sessionHeaders,
signal: params.signal,
},
"usage-summary",
).then(payload => parseCursorIndividualUsage(payload, fetchedAt));
profileEmailPromise = fetchCursorJson(
ctx,
"https://cursor.com/api/auth/me",
{
headers: sessionHeaders,
signal: params.signal,
},
"auth-me",
).then(payload => {
if (
!isRecord(payload) ||
payload.sub !== userId ||
typeof payload.email !== "string" ||
!payload.email.trim()
) {
return undefined;
}
return payload.email.trim();
});
}
}
const [legacyReport, summaryReport, profileEmail] = await Promise.all([
legacyReportPromise,
summaryReportPromise,
profileEmailPromise,
]);
let report: UsageReport | null;
if (legacyReport && summaryReport) {
report = {
provider: "cursor",
fetchedAt,
limits: [...legacyReport.limits, ...summaryReport.limits],
raw: {
authUsage: legacyReport.raw,
usageSummary: summaryReport.raw,
},
};
} else {
report = legacyReport ?? summaryReport;
}
if (!report) return null;
const email = profileEmail ?? credential.email?.trim();
const metadata = {
...(email ? { email } : {}),
...(credential.accountId ? { accountId: credential.accountId } : {}),
...(credential.projectId ? { projectId: credential.projectId } : {}),
};
if (Object.keys(metadata).length > 0) report.metadata = metadata;
return report;
},
};