type ProviderErrorPattern = { regex: RegExp; label: string }; // status codes are only treated as provider errors when they are adjacent to // a recognised status key. this rejects commit SHAs that happen to contain // "429", version strings, file hashes, etc. const statusKey = `\\b(?:status[_ ]?code|http[_ ]?status|status)["']?\\s*[:=]\\s*["']?`; const PROVIDER_ERROR_PATTERNS: ProviderErrorPattern[] = [ // auth patterns must come BEFORE rate-limit patterns. OpenRouter 401 error // payloads carry `x-ratelimit-*` response headers in the dump, and the // free-form rate-limit regex below would otherwise win on word-boundary // matches inside header names. canonical 401 messages: OpenRouter returns // `{"error":{"message":"User not found","code":401}}` for disabled or // invalid keys (https://openai.luzhipeng.com/docs/api/reference/errors-and-debugging). { regex: new RegExp(`${statusKey}401\\b`, "i"), label: "auth error (401)" }, { regex: new RegExp(`${statusKey}403\\b`, "i"), label: "auth error (403)" }, { regex: /\bUser not found\b/i, label: "auth error (invalid/disabled key)" }, { regex: /\bInvalid authentication\b/i, label: "auth error (invalid credentials)" }, { regex: /\bNo auth credentials found\b/i, label: "auth error (missing credentials)" }, { regex: new RegExp(`${statusKey}429\\b`, "i"), label: "rate limited (429)" }, { regex: new RegExp(`${statusKey}500\\b`, "i"), label: "provider 500 error" }, { regex: new RegExp(`${statusKey}503\\b`, "i"), label: "provider unavailable (503)" }, // matches `rate limit`, `rate limited`, `rate limits exceeded`, // `rate_limit_error`, `rate_limit_exceeded`. the leading `\b` + `[_ ]` // separator rejects `x-ratelimit-*` / `anthropic-ratelimit-*` response // headers (no separator between "rate" and "limit") which routinely // appear in dumped 401 / 4xx error JSON. { regex: /\brate[_ ]limit/i, label: "rate limited" }, { regex: /\bRESOURCE_EXHAUSTED\b/, label: "quota exhausted" }, // Google gRPC `INTERNAL` status. word-boundary anchors reject // `INTERNAL_SERVER_ERROR` (HTTP 500 message that may appear in unrelated // log lines) and identifiers like `INTERNALS`. { regex: /\bINTERNAL\b/, label: "provider internal error" }, { regex: /\bUNAVAILABLE\b/, label: "provider unavailable" }, // matches `quota`, `insufficient_quota`, `quota_exceeded`, `quotaExceeded`. // word-character lookarounds would reject `_quota` / `quotaX`; `quota` is // specific enough that a plain substring match is safe. { regex: /quota/i, label: "quota error" }, // explicit zero-quota response, e.g. `{"limit": 0}`. the `\b` anchor // around `limit` rejects keys like `time_limit` or `field_limit`. { regex: /["']?\blimit\b["']?\s*:\s*0\b/, label: "zero quota" }, ]; /** * Result of a provider-error scan: the classification label plus a * human-readable excerpt centered on the matched line. The excerpt is what * gets surfaced in `» provider error detected (...)` log lines — see * `extractExcerpt` for the windowing/byte-cap policy. */ export type ProviderErrorMatch = { label: string; excerpt: string; }; // roughly half a wide terminal line by 4–5 lines of context; large enough // to capture a structured error payload (request id, retry-after, model) // plus its immediate stack/headers, small enough to not flood the log. const EXCERPT_MAX_BYTES = 600; const LINES_BEFORE = 1; const LINES_AFTER = 2; export function findProviderErrorMatch(text: string): ProviderErrorMatch | null { for (const entry of PROVIDER_ERROR_PATTERNS) { const m = entry.regex.exec(text); if (!m) continue; return { label: entry.label, excerpt: extractExcerpt(text, m.index) }; } return null; } export function detectProviderError(text: string): string | null { return findProviderErrorMatch(text)?.label ?? null; } /** * Slice a context window around `matchIndex`: the matched line plus * `LINES_BEFORE`/`LINES_AFTER` neighbours. If the windowed slice exceeds * `EXCERPT_MAX_BYTES` (giant adjacent lines, e.g. JSON tool-schema dumps), * fall back to the matched line alone, head-truncated if still too long. * Replaces the old `chunk.substring(0, 500)` head-anchored excerpt which * surfaced whatever happened to be at the front of the stderr buffer * instead of the error itself. See issue #703. */ function extractExcerpt(text: string, matchIndex: number): string { const lineStart = text.lastIndexOf("\n", matchIndex - 1) + 1; const lineEndRaw = text.indexOf("\n", matchIndex); const lineEnd = lineEndRaw === -1 ? text.length : lineEndRaw; let start = lineStart; for (let i = 0; i < LINES_BEFORE && start > 0; i++) { const prev = text.lastIndexOf("\n", start - 2); start = prev < 0 ? 0 : prev + 1; } let end = lineEnd; for (let i = 0; i < LINES_AFTER && end < text.length; i++) { const next = text.indexOf("\n", end + 1); end = next < 0 ? text.length : next; } let excerpt = text.slice(start, end); if (excerpt.length > EXCERPT_MAX_BYTES) { excerpt = text.slice(lineStart, lineEnd); if (excerpt.length > EXCERPT_MAX_BYTES) excerpt = excerpt.slice(0, EXCERPT_MAX_BYTES); } return excerpt.trim(); } /** * OpenRouter's response when the per-run key's remaining budget can't cover * the agent's `max_tokens` reservation. Distinct from a generic provider error * because it's a Pullfrog billing concern, not an upstream outage — the user's * Router wallet ran out (or the key budget was undersized at mint time and the * agent ran out of headroom partway through). * * Match must be specific to this exact OpenRouter error class. Generic "credits" * or "limit" text shows up in unrelated errors and would mis-classify them. * * Sample: * `APIError: This request requires more credits, or fewer max_tokens. * You requested up to 32000 tokens, but can only afford 22800.` */ // `/s` (dotAll) lets `.*?` cross newlines so we still detect the error if any // upstream layer reformats the message onto multiple lines. Without it, a // single inserted `\n` would silently bypass the BillingError reclassification // and the user would see the generic `❌ Pullfrog failed` dump instead of the // actionable top-up CTA. const ROUTER_KEYLIMIT_EXHAUSTED_PATTERN = /requires more credits.*?fewer max_tokens|requested up to \d+ tokens.*?can only afford/is; export function isRouterKeylimitExhaustedError(text: string): boolean { return ROUTER_KEYLIMIT_EXHAUSTED_PATTERN.test(text); }