mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-22 23:22:09 +03:00
⭐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.
359 lines
12 KiB
TypeScript
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);
|
|
}
|