Files
OmniRoute/open-sse/services/usage/cursor.ts
SB Yoon c130f2aa1c feat(providers): Cursor PKCE login with Bearer quota, auto router, and empty-turn errors (#9909)
5 — Cursor PKCE login com Bearer quota, auto router e empty-turn errors. Feature completa e testada (11 arquivos de teste, 133 testes focados, todos verdes).

**Validação (worktree combinado `.claude/worktrees/fix-9909`, board sobre `origin/release/v3.8.50`):**
- 3 conflitos reais resolvidos: `config/quality/eslint-suppressions.json` (aditivo), `open-sse/config/providers/registry/cursor/index.ts` (dedup de 208 entradas de catálogo, 0 IDs duplicados verificado), `open-sse/executors/cursor.ts` (imports aditivos).
- `npm run typecheck:core`: limpo.
- `check-changelog-integrity`, `check-file-size`, `check-complexity` (2615/2774), `check-cognitive-complexity` (1175/1223), `check-dead-code` (410/416): todos OK.
- `check-public-creds`: 1 entrada obsoleta pré-existente na allowlist (`copilot-m365-web.ts:330`), já presente no tip da release — não é desta PR.
- `npm run lint`: 0 errors (5 warnings pré-existentes).
- Testes focados (`cursor-agent-cli-version`, `cursor-available-models`, `cursor-catalog-combo-compat`, `cursor-errors-classify`, `cursor-login-pkce`, `cursor-model-effort-suffix-7289`, `cursor-streaming`, `cursor-token-extractor`, `cursor-token-refresh-wiring`, `cursor-usage-fetcher`, `empty-stream-no-content-8649`): 133/133 verdes.
- Corrigido durante a validação: 1 teste novo da própria PR (`cursor-model-effort-suffix-7289.test.ts`, "splits effort off legacy grok- ids") colidia com `CURSOR_MODEL_ALIASES` já mesclado na release (mapeia `grok-4.5-high` → `cursor-grok-4.5-high` antes do fallback legado rodar); ajustado para usar um id não-aliasado (`grok-3-high`) que de fato exercita o fallback — commit `68b58ed`.

Obrigado pela contribuição, @yansigit — feature robusta com boa cobertura de testes.
2026-08-21 10:28:01 -03:00

359 lines
12 KiB
TypeScript

/**
* usage/cursor.ts — Cursor (Pro) usage fetcher + JWT/config helpers.
*
* Prefer Bearer APIs on api2.cursor.sh (works with deep-control PKCE JWTs).
* Fall back to the cookie-based cursor.com dashboard endpoint for IDE-imported
* WorkOS sessions. OpenCodex-compatible chain:
* GetCurrentPeriodUsage → /api/usage/summary → /auth/usage → cookie dashboard.
*/
import { toRecord, toNumber, clampPercentage } from "./scalars.ts";
import { type UsageQuota, parseResetTime } from "./quota.ts";
const REQUEST_TIMEOUT_MS = 12_000;
const CURSOR_API2 = "https://api2.cursor.sh";
const CURSOR_PERIOD_USAGE_URL = `${CURSOR_API2}/aiserver.v1.DashboardService/GetCurrentPeriodUsage`;
const CURSOR_USAGE_SUMMARY_URL = `${CURSOR_API2}/api/usage/summary`;
const CURSOR_AUTH_USAGE_URL = `${CURSOR_API2}/auth/usage`;
/** Legacy IDE/session cookie path (last resort). */
const CURSOR_COOKIE_USAGE_CONFIG = {
usageUrl: "https://cursor.com/api/dashboard/get-current-period-usage",
origin: "https://cursor.com",
referer: "https://cursor.com/dashboard/spending",
userAgent:
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36",
};
const REAUTH_HINT = "Use Cursor Login (PKCE) or re-import the connection from Cursor IDE.";
export type CursorUsageResult = {
plan?: string;
quotas?: Record<string, UsageQuota>;
message?: string;
};
/**
* Decode the `sub` claim of a Cursor JWT (the WorkOS user id).
* Returns null if the token is not a parseable JWT.
*/
export function decodeCursorJwtSub(token: string): string | null {
if (!token || typeof token !== "string") return null;
const parts = token.split(".");
if (parts.length !== 3) return null;
try {
let payload = parts[1].replace(/-/g, "+").replace(/_/g, "/");
while (payload.length % 4 !== 0) payload += "=";
const decoded = JSON.parse(Buffer.from(payload, "base64").toString("utf8"));
const sub = decoded?.sub;
return typeof sub === "string" && sub.length > 0 ? sub : null;
} catch {
return null;
}
}
function bearerHeaders(accessToken: string): Record<string, string> {
return {
Accept: "application/json",
Authorization: `Bearer ${accessToken}`,
"User-Agent": "omniroute-cursor-quota",
};
}
function toDollars(cents: number): number {
return Math.round(cents) / 100;
}
function buildPlanUsageQuotas(
planUsage: Record<string, unknown>,
billingCycleEnd: unknown
): Record<string, UsageQuota> | null {
const limitCents = Math.max(
0,
toNumber(planUsage.limit ?? planUsage.limitCents ?? planUsage.totalLimitCents, 0)
);
const includedSpendRaw = toNumber(
planUsage.includedSpend ?? planUsage.usedCents ?? planUsage.used,
NaN
);
const totalSpendCents = Number.isFinite(includedSpendRaw)
? Math.max(0, includedSpendRaw)
: Math.max(0, toNumber(planUsage.totalSpend, 0));
const rawTotalPct = toNumber(planUsage.totalPercentUsed ?? planUsage.percentUsed, NaN);
let totalPercentUsed: number;
if (Number.isFinite(rawTotalPct)) {
totalPercentUsed = clampPercentage(rawTotalPct);
} else if (limitCents > 0) {
totalPercentUsed = clampPercentage((totalSpendCents / limitCents) * 100);
} else {
return null;
}
const autoPercentUsed = clampPercentage(toNumber(planUsage.autoPercentUsed, 0));
const apiPercentUsed = clampPercentage(toNumber(planUsage.apiPercentUsed, 0));
const effectiveLimitCents = limitCents > 0 ? limitCents : 100;
const billingCycleEndMs = toNumber(billingCycleEnd, 0);
const resetAt = billingCycleEndMs > 0 ? parseResetTime(billingCycleEndMs) : null;
const limitDollars = toDollars(effectiveLimitCents);
const buildWindow = (percentUsed: number, usedCentsOverride?: number): UsageQuota => {
const usedCents =
typeof usedCentsOverride === "number"
? usedCentsOverride
: Math.round((effectiveLimitCents * percentUsed) / 100);
const clampedUsed = Math.min(usedCents, effectiveLimitCents);
return {
used: toDollars(clampedUsed),
total: limitDollars,
remaining: toDollars(Math.max(effectiveLimitCents - clampedUsed, 0)),
remainingPercentage: clampPercentage(100 - percentUsed),
resetAt,
unlimited: false,
};
};
return {
Total: buildWindow(totalPercentUsed, limitCents > 0 ? totalSpendCents : undefined),
"Auto + Composer": buildWindow(autoPercentUsed),
API: buildWindow(apiPercentUsed),
};
}
async function fetchJson(
url: string,
init: RequestInit
): Promise<{ ok: true; data: Record<string, unknown> } | { ok: false }> {
try {
const response = await fetch(url, {
...init,
signal: init.signal ?? AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
if (!response.ok) return { ok: false };
const data = toRecord(await response.json().catch(() => null));
if (Object.keys(data).length === 0) return { ok: false };
return { ok: true, data };
} catch {
return { ok: false };
}
}
function tryPeriodUsage(data: Record<string, unknown>): CursorUsageResult | null {
const planUsage = toRecord(data.planUsage);
if (Object.keys(planUsage).length === 0) return null;
const quotas = buildPlanUsageQuotas(planUsage, data.billingCycleEnd ?? planUsage.billingCycleEnd);
if (!quotas) return null;
return { plan: "Cursor Pro", quotas };
}
function tryUsageSummary(data: Record<string, unknown>): CursorUsageResult | null {
const individual = toRecord(data.individualUsage);
const plan = toRecord(individual.plan);
if (Object.keys(plan).length === 0) return null;
const used = toNumber(plan.used, NaN);
const limit = toNumber(plan.limit, NaN);
const percent = clampPercentage(
toNumber(
plan.totalPercentUsed,
Number.isFinite(used) && Number.isFinite(limit) && limit > 0 ? (used / limit) * 100 : NaN
)
);
if (
!Number.isFinite(toNumber(plan.totalPercentUsed, NaN)) &&
!(Number.isFinite(used) && limit > 0)
) {
return null;
}
const quotas = buildPlanUsageQuotas(
{
limit: Number.isFinite(limit) ? limit : 100,
totalSpend: Number.isFinite(used) ? used : Math.round(percent),
totalPercentUsed: percent,
autoPercentUsed: percent,
apiPercentUsed: 0,
},
data.billingCycleEnd
);
if (!quotas) return null;
return { plan: "Cursor Pro", quotas };
}
function tryAuthUsage(data: Record<string, unknown>): CursorUsageResult | null {
let used: number | undefined;
let limit: number | undefined;
const gpt4 = toRecord(data["gpt-4"]);
if (Object.keys(gpt4).length > 0) {
used = toNumber(gpt4.numRequests ?? gpt4.used, NaN);
limit = toNumber(gpt4.maxRequestUsage ?? gpt4.limit ?? gpt4.maxRequests, NaN);
}
if (!Number.isFinite(used) || !Number.isFinite(limit) || (limit as number) <= 0) {
for (const [key, value] of Object.entries(data)) {
if (key === "startOfMonth" || key === "billingCycleStart") continue;
const bucket = toRecord(value);
if (Object.keys(bucket).length === 0) continue;
const bucketUsed = toNumber(bucket.numRequests ?? bucket.used, NaN);
const bucketLimit = toNumber(
bucket.maxRequestUsage ?? bucket.limit ?? bucket.maxRequests,
NaN
);
if (Number.isFinite(bucketUsed) && Number.isFinite(bucketLimit) && bucketLimit > 0) {
used = bucketUsed;
limit = bucketLimit;
break;
}
}
}
if (!Number.isFinite(used) || !Number.isFinite(limit) || (limit as number) <= 0) return null;
const percent = clampPercentage(((used as number) / (limit as number)) * 100);
const startOfMonth = parseResetTime(data.startOfMonth ?? data.billingCycleStart);
let monthlyResetAt: string | null = null;
if (startOfMonth) {
const start = new Date(startOfMonth);
monthlyResetAt = new Date(
Date.UTC(start.getUTCFullYear(), start.getUTCMonth() + 1, start.getUTCDate())
).toISOString();
}
const limitNum = limit as number;
const usedNum = used as number;
return {
plan: "Cursor Pro",
quotas: {
Total: {
used: usedNum,
total: limitNum,
remaining: Math.max(limitNum - usedNum, 0),
remainingPercentage: clampPercentage(100 - percent),
resetAt: monthlyResetAt,
unlimited: false,
},
},
};
}
async function fetchCookieDashboardUsage(
accessToken: string,
userId: string
): Promise<CursorUsageResult> {
try {
const response = await fetch(CURSOR_COOKIE_USAGE_CONFIG.usageUrl, {
method: "POST",
redirect: "manual",
headers: {
Cookie: `WorkosCursorSessionToken=${userId}::${accessToken}`,
Origin: CURSOR_COOKIE_USAGE_CONFIG.origin,
Referer: CURSOR_COOKIE_USAGE_CONFIG.referer,
"Content-Type": "application/json",
Accept: "application/json",
"User-Agent": CURSOR_COOKIE_USAGE_CONFIG.userAgent,
},
body: "{}",
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
if (response.status >= 300 && response.status < 400) {
return {
plan: "Cursor",
message: `Cursor session expired. ${REAUTH_HINT}`,
};
}
if (!response.ok) {
if (response.status === 401 || response.status === 403) {
return {
plan: "Cursor",
message: `Cursor session unauthorized. ${REAUTH_HINT}`,
};
}
return {
plan: "Cursor",
message: `Cursor usage endpoint error (${response.status}). ${REAUTH_HINT}`,
};
}
const data = toRecord(await response.json());
const planUsage = toRecord(data.planUsage);
if (Object.keys(planUsage).length === 0) {
return {
plan: "Cursor",
message: "Cursor connected. No active plan usage returned.",
};
}
const quotas = buildPlanUsageQuotas(planUsage, data.billingCycleEnd);
if (!quotas) {
return {
plan: "Cursor",
message: "Cursor connected. No active plan usage returned.",
};
}
return { plan: "Cursor Pro", quotas };
} catch (error) {
return {
plan: "Cursor",
message: `Cursor connected. Unable to fetch usage: ${(error as Error).message}`,
};
}
}
/**
* Cursor Pro Plan Usage — Bearer APIs first (PKCE), cookie dashboard last (IDE import).
*/
export async function getCursorUsage(
accessToken: string,
providerSpecificData?: unknown
): Promise<CursorUsageResult> {
if (!accessToken) {
return { message: `Cursor access token missing. ${REAUTH_HINT}` };
}
const auth = bearerHeaders(accessToken);
const period = await fetchJson(CURSOR_PERIOD_USAGE_URL, {
method: "POST",
headers: {
...auth,
"Content-Type": "application/json",
"Connect-Protocol-Version": "1",
},
body: "{}",
});
if (period.ok) {
const mapped = tryPeriodUsage(period.data);
if (mapped) return mapped;
}
const summary = await fetchJson(CURSOR_USAGE_SUMMARY_URL, {
method: "GET",
headers: auth,
});
if (summary.ok) {
const mapped = tryUsageSummary(summary.data);
if (mapped) return mapped;
}
const authUsage = await fetchJson(CURSOR_AUTH_USAGE_URL, {
method: "GET",
headers: auth,
});
if (authUsage.ok) {
const mapped = tryAuthUsage(authUsage.data);
if (mapped) return mapped;
}
const storedUserId = (() => {
const raw = toRecord(providerSpecificData).userId;
return typeof raw === "string" && raw.length > 0 ? raw : null;
})();
const userId = storedUserId || decodeCursorJwtSub(accessToken);
if (!userId) {
return {
plan: "Cursor",
message: `Cursor usage unavailable via API and token has no user id for cookie fallback. ${REAUTH_HINT}`,
};
}
return fetchCookieDashboardUsage(accessToken, userId);
}