Files
OmniRoute/open-sse/executors/cursor/cursorErrors.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

270 lines
7.8 KiB
TypeScript

/**
* Classify Cursor transport / Connect / gRPC error text into actionable categories.
* Modeled on OpenCodex `adapters/cursor/cursor-errors.ts` (safe messages + quota vs size).
*/
const ABSOLUTE_PATH_PATTERN =
/(?:\/Users\/[^ "';,]+|\/home\/[^ "';,]+|[A-Za-z]:\\Users\\[^ "';,]+)/g;
const CURSOR_CREDENTIAL_PATTERN =
/\b(authorization|auth[_-]?token|cursor[_-]?token|bearer)=([^&\s"',;]+)/gi;
const QUOTA_RATE_CUES = [
"too many requests",
"quota",
"rate limit",
"rate-limit",
"throttl",
"out of usage",
"increase limits",
"actionrequired",
];
const REQUEST_TOO_LARGE_PATTERNS: (string | RegExp)[] = [
"tool catalog too large",
"tool registration too large",
"too many tools",
"message too large",
"payload too large",
"request too large",
/request exceeds .*size/,
/request (?:body|size) exceeds .*(?:size|limit)/,
"maximum allowed size",
];
export type CursorErrorKind =
"rate_limit" | "auth" | "invalid" | "overload" | "timeout" | "connection" | "upstream";
export type ClassifiedCursorError = {
kind: CursorErrorKind;
/** HTTP status to surface to OmniRoute clients. */
status: number;
/** OpenAI-style error.type */
type: string;
/** Secret-safe user-facing message with category prefix. */
message: string;
};
function sanitize(value: string): string {
return value
.replace(CURSOR_CREDENTIAL_PATTERN, "$1=[REDACTED]")
.replace(ABSOLUTE_PATH_PATTERN, "[REDACTED_PATH]")
.replace(/eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, "[REDACTED_JWT]");
}
export function isCursorRequestTooLargeDetail(lowerMessage: string): boolean {
if (QUOTA_RATE_CUES.some((cue) => lowerMessage.includes(cue))) return false;
return REQUEST_TOO_LARGE_PATTERNS.some((pattern) =>
typeof pattern === "string" ? lowerMessage.includes(pattern) : pattern.test(lowerMessage)
);
}
function errorMessage(value: unknown): string {
if (value instanceof Error) return value.message;
if (typeof value === "string") return value;
return String(value ?? "");
}
function errorCode(value: unknown): string {
if (typeof value !== "object" || !value || !("code" in value)) return "";
const code = (value as { code?: unknown }).code;
return code === undefined || code === null ? "" : String(code);
}
/**
* True when Cursor intentionally cancelled the HTTP/2 stream after a client-tool
* suspend (OpenCodex `isCursorBenignCancelError`). Not an upstream failure.
*/
export function isCursorBenignCancelError(value: unknown): boolean {
const message = errorMessage(value).toLowerCase();
const code = errorCode(value).toUpperCase();
if (code === "NGHTTP2_CANCEL") return true;
if (message.includes("nghttp2_cancel")) return true;
if (message.includes("cursor stream suspended")) return true;
return false;
}
export function classifyCursorErrorKind(rawMessage: string): CursorErrorKind {
const lower = rawMessage.toLowerCase();
if (lower.includes("resource_exhausted") || lower.includes("resource exhausted")) {
return isCursorRequestTooLargeDetail(lower) ? "invalid" : "rate_limit";
}
if (QUOTA_RATE_CUES.some((cue) => lower.includes(cue))) return "rate_limit";
// Live Cursor out-of-usage for premium models often surfaces as:
// not_found: AI Model Not Found (reset after 109h …)
// OmniRoute may also append "(reset after …)" after classification; treat the
// Cursor-specific "AI Model Not Found" cue as rate/quota either way.
if (
lower.includes("ai model not found") ||
(lower.includes("reset after") && lower.includes("model not found"))
) {
return "rate_limit";
}
if (
lower.includes("unauthenticated") ||
lower.includes("unauthorized") ||
lower.includes("permission_denied") ||
lower.includes("permission denied") ||
lower.includes("forbidden") ||
lower.includes("invalid token") ||
lower.includes("expired token") ||
lower.includes("authentication") ||
lower.includes("access denied")
) {
return "auth";
}
if (
lower.includes("unavailable") ||
lower.includes("overloaded") ||
lower.includes("temporarily") ||
lower.includes("server is busy")
) {
return "overload";
}
if (
lower.includes("invalid") ||
lower.includes("not found") ||
lower.includes("unsupported") ||
lower.includes("malformed") ||
lower.includes("unimplemented")
) {
return "invalid";
}
if (
lower.includes("timed out") ||
lower.includes("timeout") ||
lower.includes("etimedout") ||
lower.includes("deadline")
) {
return "timeout";
}
if (
lower.includes("econnreset") ||
lower.includes("econnrefused") ||
lower.includes("goaway") ||
lower.includes("nghttp2") ||
lower.includes("socket hang up") ||
lower.includes("connection reset")
) {
return "connection";
}
return "upstream";
}
function kindToStatus(kind: CursorErrorKind): number {
switch (kind) {
case "rate_limit":
return 429;
case "auth":
return 401;
case "invalid":
return 400;
case "overload":
case "timeout":
case "connection":
case "upstream":
default:
return 502;
}
}
function kindToType(kind: CursorErrorKind): string {
switch (kind) {
case "rate_limit":
return "rate_limit_error";
case "auth":
return "authentication_error";
case "invalid":
return "invalid_request_error";
default:
return "api_error";
}
}
function kindPrefix(kind: CursorErrorKind): string {
switch (kind) {
case "rate_limit":
return "Cursor rate limit / usage exceeded";
case "auth":
return "Cursor authentication failed";
case "invalid":
return "Cursor invalid request";
case "overload":
return "Cursor server overloaded";
case "timeout":
return "Cursor request timed out";
case "connection":
return "Cursor connection failed";
default:
return "Cursor upstream error";
}
}
/** Produce a classified, secret-safe Cursor error for HTTP / SSE responses. */
export function classifyCursorError(rawMessage: string): ClassifiedCursorError {
const kind = classifyCursorErrorKind(rawMessage);
const detail = sanitize(rawMessage)
.replace(/resource[_ ]exhausted/gi, "resource limit exceeded")
.slice(0, 500);
const prefix = kindPrefix(kind);
const message = detail.startsWith(prefix) ? detail : detail ? `${prefix}: ${detail}` : prefix;
return {
kind,
status: kindToStatus(kind),
type: kindToType(kind),
message,
};
}
export const CURSOR_EMPTY_TURN_MESSAGE =
'Cursor returned an empty turn (often usage/quota exhausted). Try model "auto", or check Usage → Provider Limits / raise Cursor limits.';
/**
* Resolve the error to emit when a Cursor turn ends with no assistant text/tool_calls.
* Prefer classifying an upstream JSON/error message; otherwise use the empty-turn hint.
* When `quotaExhaustedHint` is true (fresh Provider Limits cache), force 429.
*/
export function resolveCursorEmptyTurnError(options: {
upstreamMessage?: string | null;
quotaExhaustedHint?: boolean;
}): ClassifiedCursorError {
const upstream = options.upstreamMessage?.trim();
if (upstream) {
const classified = classifyCursorError(upstream);
if (options.quotaExhaustedHint && classified.kind !== "auth") {
return {
...classified,
kind: "rate_limit",
status: 429,
type: "rate_limit_error",
message: classified.message.includes("usage")
? classified.message
: `${classified.message} (${CURSOR_EMPTY_TURN_MESSAGE})`,
};
}
return classified;
}
if (options.quotaExhaustedHint) {
return {
kind: "rate_limit",
status: 429,
type: "rate_limit_error",
message: CURSOR_EMPTY_TURN_MESSAGE,
};
}
return {
kind: "upstream",
status: 502,
type: "api_error",
message: CURSOR_EMPTY_TURN_MESSAGE,
};
}