/** * Provider error classification + humanization for the pi harness. * * Every non-OK HTTP response and network failure from the three stream * providers funnels through here so the user sees an actionable message * ("update your key in the dashboard") instead of a raw JSON wall, and so the * session/harness can react structurally: retry `retryable` rounds, tear the * conversation down on `auth` / `context-overflow` (a poisoned history would * otherwise re-fail forever). * * Mirrors the codex harness's codexErrorInfo mapping (house standard M4). */ import type { PiErrorKind } from './types.js'; export interface ClassifiedPiError { /** User-facing message — friendly, with a one-line raw detail for debugging. */ message: string; kind: PiErrorKind; /** True when re-sending the identical request can plausibly succeed. */ retryable: boolean; status?: number; } /** Pull the provider's human-readable message out of a JSON error body. */ function extractDetail(body: string): string { const trimmed = (body || '').trim(); if (!trimmed) return ''; try { const j = JSON.parse(trimmed); // Google/OpenAI/Anthropic all nest it under error.message; some // OpenAI-compat vendors use a top-level message. const msg = j?.error?.message || j?.message || (typeof j?.error === 'string' ? j.error : ''); if (typeof msg === 'string' && msg.trim()) return msg.trim().slice(0, 300); } catch {} return trimmed.slice(0, 300); } const CONTEXT_OVERFLOW_RE = /context.length|context_length_exceeded|maximum context length|prompt is too long|too many tokens|input token count.*exceed|token count exceeds|exceeds the maximum number of tokens|request exceeds the.*token|exceeds? (the )?context limit|input length and .{0,3}max_tokens/i; const AUTH_RE = /api key not valid|invalid api key|invalid x-api-key|incorrect api key|invalid_api_key|authentication[_ ]error|permission_error|invalid bearer token|no auth credentials/i; // Deliberately narrow: only unambiguous out-of-credit markers. Gemini's // routine per-minute 429 says "check your plan and billing details" — that is // a RATE LIMIT (retryable), not billing; OpenAI's true quota exhaustion is // distinguished by the insufficient_quota code (absent from Gemini bodies). const BILLING_RE = /insufficient_quota|credit balance is too low|payment required|purchase more credits/i; // A text-only model rejecting an attached image. Vendors phrase it many ways: // OpenAI "Invalid content type. image_url is only supported by certain models", // OpenRouter "No endpoints found that support image input", others mention // "image input" / "does not support images" / "unsupported content type". // Only EXPLICIT image-naming phrases — the bare tokens "vision"/"multimodal"/ // "modality" were removed because the provider body routinely echoes the model id // (e.g. "gpt-4-vision-preview", "llama-3.2-90b-vision-instruct"), which would // mis-classify an unrelated 400 from a vision-capable model and wrongly disable // vision for the rest of the session. Paired with a 400/415/422 status below. const IMAGE_UNSUPPORTED_RE = /image[_ ]?url|image input|images?(?: are| is)? not supported|does not support images?|no endpoints? .*support image|unsupported content type/i; export function classifyPiError( providerLabel: string, status: number | undefined, statusText: string, body: string, ): ClassifiedPiError { const detail = extractDetail(body); const suffix = detail ? ` (${detail})` : ''; // Order matters: overflow and billing hide behind generic 400/429 statuses. if ((status === 400 || status === 413) && CONTEXT_OVERFLOW_RE.test(body)) { return { kind: 'context-overflow', retryable: false, status, message: `The conversation has outgrown ${providerLabel}'s context window.${suffix}`, }; } if (BILLING_RE.test(body) || status === 402) { return { kind: 'billing', retryable: false, status, message: `${providerLabel} reports a quota/billing problem — check your plan or credits on the provider's console.${suffix}`, }; } // 401 is always auth; 403 only when the body says so — vendors also use 403 // for per-message moderation/guardrail blocks (e.g. OpenRouter), which must // NOT be classified auth (auth is a fatal kind that recycles the session). if (status === 401 || AUTH_RE.test(body)) { return { kind: 'auth', retryable: false, status, message: `${providerLabel} rejected your API key. Update it from the dashboard (Bloby provider settings).${suffix}`, }; } // A text-only model that the catalog couldn't flag up front (dynamic/unknown // sub-providers) 400/415/422s on the attached image. The session reacts by // disabling vision for the rest of the session and re-running the round with // images downgraded — self-healing so a single screenshot can't permanently // 400-poison the conversation (it rides every stateless resend otherwise). if ((status === 400 || status === 415 || status === 422) && IMAGE_UNSUPPORTED_RE.test(body)) { return { kind: 'image-unsupported', retryable: false, status, message: `${providerLabel} rejected the attached image — this model appears to be text-only. Retrying without the image; switch to a vision-capable model to send images.${suffix}`, }; } if (status === 429) { return { kind: 'rate-limit', retryable: true, status, message: `${providerLabel} rate limit reached — give it a moment and try again.${suffix}`, }; } if (status === 408 || (status !== undefined && status >= 500)) { return { kind: 'transient', retryable: true, status, message: `${providerLabel} is having trouble right now (HTTP ${status}) — try again in a moment.${suffix}`, }; } return { kind: 'other', retryable: false, status, message: `${providerLabel} ${status ?? ''} ${statusText || ''}`.trim() + `${detail ? `: ${detail}` : ''}`, }; } /** Network-level failures (DNS, refused, reset, undici timeouts) — always transient. */ export function classifyPiNetworkError(providerLabel: string, err: any): ClassifiedPiError { const raw = err?.message || String(err); // undici's body/headers timeouts surface as the famously cryptic 'terminated' // and 'Headers Timeout Error'; our own SSE idle guard says 'stalled'. const stalled = /terminated|timeout|stalled/i.test(raw); return { kind: 'transient', retryable: true, message: stalled ? `${providerLabel} stream stalled (no data from the provider). Try again in a moment. (${raw})` : `Could not reach ${providerLabel}: ${raw}`, }; }