{"repo":"NousResearch/hermes-agent","public_id":"5435","display_label":"Structured provider/API error classification for failover","summary":"All three members share a theme: adding structured classification of provider or API errors, with one title mentioning use for smart failover. Titles alone do not indicate implementation details, status, or whether these overlap or duplicate one another.","subsystem":"agent","counts":{"members":3,"open_issues":1,"open_issues_distinct":1,"open_prs":0,"closed_issues":0,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":6514,"merged_at":"2026-04-09T11:10:12+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"stale","last_verified_at":"2026-04-09T11:10:12+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":5435,"pr_number":6514,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-04-09T11:10:12+00:00"}],"members":[{"kind":"issue","number":5435,"title":"Structured provider error classification","url":null,"state":"open"},{"kind":"pr","number":5441,"title":"feat: structured provider error classification","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#6514","number":6514}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":6514,"title":"feat: structured API error classification for smart failover","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/error_classifier.py","run_agent.py","tests/agent/test_error_classifier.py"],"fix_hunks":[{"file":"agent/error_classifier.py","hunks":["@@ -0,0 +1,789 @@\n+\"\"\"API error classification for smart failover and recovery.\n+\n+Provides a structured taxonomy of API errors and a priority-ordered\n+classification pipeline that determines the correct recovery action\n+(retry, rotate credential, fallback to another provider, compress\n+context, or abort).\n+\n+Replaces scattered inline string-matching with a centralized classifier\n+that the main retry loop in run_agent.py consults for every API failure.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import enum\n+import logging\n+import re\n+from dataclasses import dataclass, field\n+from typing import Any, Dict, Optional\n+\n+logger = logging.getLogger(__name__)\n+\n+\n+# ── Error taxonomy ──────────────────────────────────────────────────────\n+\n+class FailoverReason(enum.Enum):\n+    \"\"\"Why an API call failed — determines recovery strategy.\"\"\"\n+\n+    # Authentication / authorization\n+    auth = \"auth\"                        # Transient auth (401/403) — refresh/rotate\n+    auth_permanent = \"auth_permanent\"    # Auth failed after refresh — abort\n+\n+    # Billing / quota\n+    billing = \"billing\"                  # 402 or confirmed credit exhaustion — rotate immediately\n+    rate_limit = \"rate_limit\"            # 429 or quota-based throttling — backoff then rotate\n+\n+    # Server-side\n+    overloaded = \"overloaded\"            # 503/529 — provider overloaded, backoff\n+    server_error = \"server_error\"        # 500/502 — internal server error, retry\n+\n+    # Transport\n+    timeout = \"timeout\"                  # Connection/read timeout — rebuild client + retry\n+\n+    # Context / payload\n+    context_overflow = \"context_overflow\"  # Context too large — compress, not failover\n+    payload_too_large = \"payload_too_large\"  # 413 — compress payload\n+\n+    # Model\n+    model_not_found = \"model_not_found\"  # 404 or invalid model — fallback to different model\n+\n+    # Request format\n+    format_error = \"format_error\"        # 400 bad request — abort or strip + retry\n+\n+    # Provider-specific\n+    thinking_signature = \"thinking_signature\"  # Anthropic thinking block sig invalid\n+    long_context_tier = \"long_context_tier\"    # Anthropic \"extra usage\" tier gate\n+\n+    # Catch-all\n+    unknown = \"unknown\"                  # Unclassifiable — retry with backoff\n+\n+\n+# ── Classification result ───────────────────────────────────────────────\n+\n+@dataclass\n+class ClassifiedError:\n+    \"\"\"Structured classification of an API error with recovery hints.\"\"\"\n+\n+    reason: FailoverReason\n+    status_code: Optional[int] = None\n+    provider: Optional[str] = None\n+    model: Optional[str] = None\n+    message: str = \"\"\n+    error_context: Dict[str, Any] = field(default_factory=dict)\n+\n+    # Recovery action hints — the retry loop checks these instead of\n+    # re-classifying the error itself.\n+    retryable: bool = True\n+    should_compress: bool = False\n+    should_rotate_credential: bool = False\n+    should_fallback: bool = False\n+\n+    @property\n+    def is_auth(self) -> bool:\n+        return self.reason in (FailoverReason.auth, FailoverReason.auth_permanent)\n+\n+    @property\n+    def is_transient(self) -> bool:\n+        \"\"\"Error is expected to resolve on retry (with or without backoff).\"\"\"\n+        return self.reason in (\n+            FailoverReason.rate_limit,\n+            FailoverReason.overloaded,\n+            FailoverReason.server_error,\n+            FailoverReason.timeout,\n+            FailoverReason.unknown,\n+        )\n+\n+\n+# ── Provider-specific patterns ──────────────────────────────────────────\n+\n+# Patterns that indicate billing exhaustion (not transient rate limit)\n+_BILLING_PATTERNS = [\n+    \"insufficient credits\",\n+    \"insufficient_quota\",\n+    \"credit balance\",\n+    \"credits have been exhausted\",\n+    \"top up your credits\",\n+    \"payment required\",\n+    \"billing hard limit\",\n+    \"exceeded your current quota\",\n+    \"account is deactivated\",\n+    \"plan does not include\",\n+]\n+\n+# Patterns that indicate rate limiting (transient, will resolve)\n+_RATE_LIMIT_PATTERNS = [\n+    \"rate limit\",\n+    \"rate_limit\",\n+    \"too many requests\",\n+    \"throttled\",\n+    \"requests per minute\",\n+    \"tokens per minute\",\n+    \"requests per day\",\n+    \"try again in\",\n+    \"please retry after\",\n+    \"resource_exhausted\",\n+]\n+\n+# Usage-limit patterns that need disambiguation (could be billing OR rate_limit)\n+_USAGE_LIMIT_PATTERNS = [\n+    \"usage limit\",\n+    \"quota\",\n+    \"limit exceeded\",\n+    \"key limit exceeded\",\n+]\n+\n+# Patterns confirming usage limit is transient (not billing)\n+_USAGE_LIMIT_TRANSIENT_SIGNALS = [\n+    \"try again\",\n+    \"retry\",\n+    \"resets at\",\n+    \"reset in\",\n+    \"wait\",\n+    \"requests remaining\",\n+    \"periodic\",\n+    \"window\",\n+]\n+\n+# Payload-too-large patterns detected from message text (no status_code attr).\n+# Proxies and some backends embed the HTTP status in the error message.\n+_PAYLOAD_TOO_LARGE_PATTERNS = [\n+    \"request entity too large\",\n+    \"payload too large\",\n+    \"error code: 413\",\n+]\n+\n+# Context overflow patterns\n+_CONTEXT_OVERFLOW_PATTERNS = [\n+    \"context length\",\n+    \"context size\",\n+    \"maximum context\",\n+    \"token limit\",\n+    \"too many tokens\",\n+    \"reduce the length\",\n+    \"exceeds the limit\",\n+    \"context window\",\n+    \"prompt is too long\",\n+    \"prompt exceeds max length\",\n+    \"max_tokens\",\n+    \"maximum number of tokens\",\n+    # Chinese error messages (some providers return these)\n+    \"超过最大长度\",\n+    \"上下文长度\",\n+]\n+\n+# Model not found patterns\n+_MODEL_NOT_FOUND_PATTERNS = [\n+    \"is not a valid model\",\n+    \"invalid model\",\n+    \"model not found\",\n+    \"model_not_found\",\n+    \"does not exist\",\n+    \"no such model\",\n+    \"unknown model\",\n+    \"unsupported model\",\n+]\n+\n+# Auth patterns (non-status-code signals)\n+_AUTH_PATTERNS = [\n+    \"invalid api key\",\n+    \"invalid_api_key\",\n+    \"authentication\",\n+    \"unauthorized\",\n+    \"forbidden\",\n+    \"invalid token\",\n+    \"token expired\",\n+    \"token revoked\",\n+    \"access denied\",\n+]\n+\n+# Anthropic thinking block signature patterns\n+_THINKING_SIG_PATTERNS = [\n+    \"signature\",  # Combined with \"thinking\" check\n+]\n+\n+# Transport error type names\n+_TRANSPORT_ERROR_TYPES = frozenset({\n+    \"ReadTimeout\", \"ConnectTimeout\", \"PoolTimeout\",\n+    \"ConnectError\", \"RemoteProtocolError\",\n+    \"ConnectionError\", \"ConnectionResetError\",\n+    \"ConnectionAbortedError\", \"BrokenPipeError\",\n+    \"TimeoutError\", \"ReadError\",\n+    \"ServerDisconnectedError\",\n+    # OpenAI SDK errors (not subclasses of Python builtins)\n+    \"APIConnectionError\",\n+    \"APITimeoutError\",\n+})\n+\n+# Server disconnect patterns (no status code, but transport-level)\n+_SERVER_DISCONNECT_PATTERNS = [\n+    \"server disconnected\",\n+    \"peer closed connection\",\n+    \"connection reset by peer\",\n+    \"connection was closed\",\n+    \"network connection lost\",\n+    \"unexpected eof\",\n+    \"incomplete chunked read\",\n+]\n+\n+\n+# ── Classification pipeline ─────────────────────────────────────────────\n+\n+def classify_api_error(\n+    error: Exception,\n+    *,\n+    provider: str = \"\",\n+    model: str = \"\",\n+    approx_tokens: int = 0,\n+    context_length: int = 200000,\n+    num_messages: int = 0,\n+) -> ClassifiedError:\n+    \"\"\"Classify an API error into a structured recovery recommendation.\n+\n+    Priority-ordered pipeline:\n+      1. Special-case provider-specific patterns (thinking sigs, tier gates)\n+      2. HTTP status code + message-aware refinement\n+      3. Error code classification (from body)\n+      4. Message pattern matching (billing vs rate_limit vs context vs auth)\n+      5. Transport error heuristics\n+      6. Server disconnect + large session → context overflow\n+      7. Fallback: unknown (retryable with backoff)\n+\n+    Args:\n+        error: The exception from the API call.\n+        provider: Current provider name (e.g. \"openrouter\", \"anthropic\").\n+        model: Current model slug.\n+        approx_tokens: Approximate token count of the current context.\n+        context_length: Maximum context length for the current model.\n+\n+    Returns:\n+        ClassifiedError with reason and recovery action hints.\n+    \"\"\"\n+    status_code = _extract_status_code(error)\n+    error_type = type(error).__name__\n+    body = _extract_error_body(error)\n+    error_code = _extract_error_code(body)\n+\n+    # Build a comprehensive error message string for pattern matching.\n+    # str(error) alone may not include the body message (e.g. OpenAI SDK's\n+    # APIStatusError.__str__ returns the first arg, not the body).  Append\n+    # the body message so patterns like \"try again\" in 402 disambiguation\n+    # are detected even when only present in the structured body.\n+    #\n+    # Also extract metadata.raw — OpenRouter wraps upstream provider errors\n+    # inside {\"error\": {\"message\": \"Provider returned error\", \"metadata\":\n+    # {\"raw\": \"<actual error JSON>\"}}} and the real error message (e.g.\n+    # \"context length exceeded\") is only in the inner JSON.\n+    _raw_msg = str(error).lower()\n+    _body_msg = \"\"\n+    _metadata_msg = \"\"\n+    if isinstance(body, dict):\n+        _err_obj = body.get(\"error\", {})\n+        if isinstance(_err_obj, dict):\n+            _body_msg = (_err_obj.get(\"message\") or \"\").lower()\n+            # Parse metadata.raw for wrapped provider errors\n+            _metadata = _err_obj.get(\"metadata\", {})\n+            if isinstance(_metadata, dict):\n+                _raw_json = _metadata.get(\"raw\") or \"\"\n+                if isinstance(_raw_json, str) and _raw_json.strip():\n+                    try:\n+                        import json\n+                        _inner = json.loads(_raw_json)\n+                        if isinstance(_inner, dict):\n+                            _inner_err = _inner.get(\"error\", {})\n+                            if isinstance(_inner_err, dict):\n+                                _metadata_msg = (_inner_err.get(\"message\") or \"\").lower()\n+                    except (json.JSONDecodeError, TypeError):\n+                        pass\n+        if not _body_msg:\n+            _body_msg = (body.get(\"message\") or \"\").lower()\n+    # Combine all message sources for pattern matching\n+    parts = [_raw_msg]\n+    if _body_msg and _body_msg not in _raw_msg:\n+        parts.append(_body_msg)\n+    if _metadata_msg and _metadata_msg not in _raw_msg and _metadata_msg not in _body_msg:\n+        parts.append(_metadata_msg)\n+    error_msg = \" \".join(parts)\n+    provider_lower = (provider or \"\").strip().lower()\n+    model_lower = (model or \"\").strip().lower()\n+\n+    def _result(reason: FailoverReason, **overrides) -> ClassifiedError:\n+        defaults = {\n+            \"reason\": reason,\n+            \"status_code\": status_code,\n+            \"provider\": provider,\n+            \"model\": model,\n+            \"message\": _extract_message(error, body),\n+        }\n+        defaults.update(overrides)\n+        return ClassifiedError(**defaults)\n+\n+    # ── 1. Provider-specific patterns (highest priority) ────────────\n+\n+    # Anthropic thinking block signature invalid (400).\n+    # Don't gate on provider — OpenRouter proxies Anthropic errors, so the\n+    # provider may be \"openrouter\" even though the error is Anthropic-specific.\n+    # The message pattern (\"signature\" + \"thinking\") is unique enough.\n+    if (\n+        status_code == 400\n+        and \"signature\" in error_msg\n+        and \"thinking\" in error_msg\n+    ):\n+        return _result(\n+            FailoverReason.thinking_signature,\n+            retryable=True,\n+            should_compress=False,\n+        )\n+\n+    # Anthropic long-context tier gate (429 \"extra usage\" + \"long context\")\n+    if (\n+        status_code == 429\n+        and \"extra usage\" in error_msg\n+        and \"long context\" in error_msg\n+    ):\n+        return _result(\n+            FailoverReason.long_context_tier,\n+            retryable=True,\n+            should_compress=True,\n+        )\n+\n+    # ── 2. HTTP status code classification ──────────────────────────\n+\n+    if status_code is not None:\n+        classified = _classify_by_status(\n+            status_code, error_msg, error_code, body,\n+            provider=provider_lower, model=model_lower,\n+            approx_tokens=[redacted:secret-assignment] context_length=context_length,\n+            num_messages=num_messages,\n+            result_fn=_result,\n+        )\n+        if classified is not None:\n+            return classified\n+\n+    # ── 3. Error code classification ────────────────────────────────\n+\n+    if error_code:\n+        classified = _classify_by_error_code(error_code, error_msg, _result)\n+        if classified is not None:\n+            return classified\n+\n+    # ── 4. Message pattern matching (no status code) ────────────────\n+\n+    classified = _classify_by_message(\n+        error_msg, error_type,\n+        approx_tokens=[redacted:secret-assignment]\n+        context_length=context_length,\n+        result_fn=_result,\n+    )\n+    if classified is not None:\n+        return classified\n+\n+    # ── 5. Server disconnect + large session → context overflow ─────\n+    # Must come BEFORE generic transport error catch — a disconnect on\n+    # a large session is more likely context overflow than a transient\n+    # transport hiccup.  Without this ordering, RemoteProtocolError\n+    # always maps to timeout regardless of session size.\n+\n+    is_disconnect = any(p in error_msg for p in _SERVER_DISCONNECT_PATTERNS)\n+    if is_disconnect and not status_code:\n+        is_large = approx_tokens > context_length * 0.6 or approx_tokens > 120000 or num_messages > 200\n+        if is_large:\n+            return _result(\n+                FailoverReason.context_overflow,\n+                retryable=True,\n+                should_compress=True,\n+            )\n+        return _result(FailoverReason.timeout, retryable=True)\n+\n+    # ── 6. Transport / timeout heuristics ───────────────────────────\n+\n+    if error_type in _TRANSPORT_ERROR_TYPES or isinstance(error, (TimeoutError, ConnectionError, OSError)):\n+        return _result(FailoverReason.timeout, retryable=True)\n+\n+    # ── 7. Fallback: unknown ────────────────────────────────────────\n+\n+    return _result(FailoverReason.unknown, retryable=True)\n+\n+\n+# ── Status code classification ──────────────────────────────────────────\n+\n+def _classify_by_status(\n+    status_code: int,\n+    error_msg: str,\n+    error_code: str,\n+    body: dict,\n+    *,\n+    provider: str,\n+    model: str,\n+    approx_tokens: int,\n+    context_length: int,\n+    num_messages: int = 0,\n+    result_fn,\n+) -> Optional[ClassifiedError]:\n+    \"\"\"Classify based on HTTP status code with message-aware refinement.\"\"\"\n+\n+    if status_code == 401:\n+        # Not retryable on its own — credential pool rotation and\n+        # provider-specific refresh (Codex, Anthropic, Nous) run before\n+        # the retryability check in run_agent.py.  If those succeed, the\n+        # loop `continue`s.  If they fail, retryable=False ensures we\n+        # hit the client-error abort path (which tries fallback first).\n+        return result_fn(\n+            FailoverReason.auth,\n+            retryable=False,\n+            should_rotate_credential=True,\n+            should_fallback=True,\n+        )\n+\n+    if status_code == 403:\n+        # OpenRouter 403 \"key limit exceeded\" is actually billing\n+        if \"key limit exceeded\" in error_msg or \"spending limit\" in error_msg:\n+            return result_fn(\n+                FailoverReason.billing,\n+                retryable=False,\n+                should_rotate_credential=True,\n+                should_fallback=True,\n+            )\n+        return result_fn(\n+            FailoverReason.auth,\n+            retryable=False,\n+            should_fallback=True,\n+        )\n+\n+    if status_code == 402:\n+        return _classify_402(error_msg, result_fn)\n+\n+    if status_code == 404:\n+        if any(p in error_msg for p in _MODEL_NOT_FOUND_PATTERNS):\n+            return result_fn(\n+                FailoverReason.model_not_found,\n+                retryable=False,\n+                should_fallback=True,\n+            )\n+        # Generic 404 — could be model or endpoint\n+        return result_fn(\n+            FailoverReason.model_not_found,\n+            retryable=False,\n+            should_fallback=True,\n+        )\n+\n+    if status_code == 413:\n+        return result_fn(\n+            FailoverReason.payload_too_large,\n+            retryable=True,\n+            should_compress=True,\n+        )\n+\n+    if status_code == 429:\n+        # Already checked long_context_tier above; this is a normal rate limit\n+        return result_fn(\n+            FailoverReason.rate_limit,\n+            retryable=True,\n+            should_rotate_credential=True,\n+            should_fallback=True,\n+        )\n+\n+    if status_code == 400:\n+        return _classify_400(\n+            error_msg, error_code, body,\n+            provider=provider, model=model,\n+            approx_tokens=[redacted:secret-assignment]\n+            context_length=context_length,\n+            num_messages=num_messages,\n+            result_fn=result_fn,\n+        )\n+\n+    if status_code in (500, 502):\n+        return result_fn(FailoverReason.server_error, retryable=True)\n+\n+    if status_code in (503, 529):\n+        return result_fn(FailoverReason.overloaded, retryable=True)\n+\n+    # Other 4xx — non-retryable\n+    if 400 <= status_code < 500:\n+        return result_fn(\n+            FailoverReason.format_error,\n+            retryable=False,\n+            should_fallback=True,\n+        )\n+\n+    # Other 5xx — retryable\n+    if 500 <= status_code < 600:\n+        return result_fn(FailoverReason.server_error, retryable=True)\n+\n+    return None\n+\n+\n+def _classify_402(error_msg: str, result_fn) -> ClassifiedError:\n+    \"\"\"Disambiguate 402: billing exhaustion vs transient usage limit.\n+\n+    The key insight from OpenClaw: some 402s are transient rate limits\n+    disguised as payment errors.  \"Usage limit, try again in 5 minutes\"\n+    is NOT a billing problem — it's a periodic quota that resets.\n+    \"\"\"\n+    # Check for transient usage-limit signals first\n+    has_usage_limit = any(p in error_msg for p in _USAGE_LIMIT_PATTERNS)\n+    has_transient_signal = any(p in error_msg for p in _USAGE_LIMIT_TRANSIENT_SIGNALS)\n+\n+    if has_usage_limit and has_transient_signal:\n+        # Transient quota — treat as rate limit, not billing\n+        return result_fn(\n+            FailoverReason.rate_limit,\n+            retryable=True,\n+            should_rotate_credential=True,\n+            should_fallback=True,\n+        )\n+\n+    # Confirmed billing exhaustion\n+    return result_fn(\n+        FailoverReason.billing,\n+        retryable=False,\n+        should_rotate_credential=True,\n+        should_fallback=True,\n+    )\n+\n+\n+def _classify_400(\n+    error_msg: str,\n+    error_code: str,\n+    body: dict,\n+    *,\n+    provider: str,\n+    model: str,\n+    approx_tokens: int,\n+    context_length: int,\n+    num_messages: int = 0,\n+    result_fn,\n+) -> ClassifiedError:\n+    \"\"\"Classify 400 Bad Request — context overflow, format error, or generic.\"\"\"\n+\n+    # Context overflow from 400\n+    if any(p in error_msg for p in _CONTEXT_OVERFLOW_PATTERNS):\n+        return result_fn(\n+            FailoverReason.context_overflow,\n+            retryable=True,\n+            should_compress=True,\n+        )\n+\n+    # Some providers return model-not-found as 400 instead of 404 (e.g. OpenRouter).\n+    if any(p in error_msg for p in _MODEL_NOT_FOUND_PATTERNS):\n+        return result_fn(\n+            FailoverReason.model_not_found,\n+            retryable=False,\n+            should_fallback=True,\n+        )\n+\n+    # Some providers return rate limit / billing errors as 400 instead of 429/402.\n+    # Check these patterns before falling through to format_error.\n+    if any(p in error_msg for p in _RATE_LIMIT_PATTERNS):\n+        return result_fn(\n+            FailoverReason.rate_limit,\n+            retryable=True,\n+            should_rotate_credential=True,\n+            should_fallback=True,\n+        )\n+    if any(p in error_msg for p in _BILLING_PATTERNS):\n+        return result_fn(\n+            FailoverReason.billing,\n+            retryable=False,\n+            should_rotate_credential=True,\n+            should_fallback=True,\n+        )\n+\n+    # Generic 400 + large session → probable context overflow\n+    # Anthropic sometimes returns a bare \"Error\" message when context is too large\n+    err_body_msg = \"\"\n+    if isinstance(body, dict):\n+        err_obj = body.get(\"error\", {})\n+        if isinstance(err_obj, dict):\n+            err_body_msg = (err_obj.get(\"message\") or \"\").strip().lower()\n+    is_generic = len(err_body_msg) < 30 or err_body_msg in (\"error\", \"\")\n+    is_large = approx_tokens > context_length * 0.4 or approx_tokens > 80000 or num_messages > 80\n+\n+    if is_generic and is_large:\n+        return result_fn(\n+            FailoverReason.context_overflow,\n+            retryable=True,\n+            should_compress=True,\n+        )\n+\n+    # Non-retryable format error\n+    return result_fn(\n+        FailoverReason.format_error,\n+        retryable=False,\n+        should_fallback=True,\n+    )\n+\n+\n+# ── Error code classification ───────────────────────────────────────────\n+\n+def _classify_by_error_code(\n+    error_code: str, error_msg: str, result_fn,\n+) -> Optional[ClassifiedError]:\n+    \"\"\"Classify by structured error codes from the response body.\"\"\"\n+    code_lower = error_code.lower()\n+\n+    if code_lower in (\"resource_exhausted\", \"throttled\", \"rate_limit_exceeded\"):\n+        return result_fn(\n+            FailoverReason.rate_limit,\n+            retryable=True,\n+            should_rotate_credential=True,\n+        )\n+\n+    if code_lower in (\"insufficient_quota\", \"billing_not_active\", \"payment_required\"):\n+        return result_fn(\n+            FailoverReason.billing,\n+            retryable=False,\n+            should_rotate_credential=True,\n+            should_fallback=True,\n+        )\n+\n+    if code_lower in (\"model_not_found\", \"model_not_available\", \"invalid_model\"):\n+        return result_fn(\n+            FailoverReason.model_not_found,\n+            retryable=False,\n+            should_fallback=True,\n+        )\n+\n+    if code_lower in (\"context_length_exceeded\", \"max_tokens_exceeded\"):\n+        return result_fn(\n+            FailoverReason.context_overflow,\n+            retryable=True,\n+            should_compress=True,\n+        )\n+\n+    return None\n+\n+\n+# ── Message pattern classification ──────────────────────────────────────\n+\n+def _classify_by_message(\n+    error_msg: str,\n+    error_type: str,\n+    *,\n+    approx_tokens: int,\n+    context_length: int,\n+    result_fn,\n+) -> Optional[ClassifiedError]:\n+    \"\"\"Classify based on error message patterns when no status code is available.\"\"\"\n+\n+    # Payload-too-large patterns (from message text when no status_code)\n+    if any(p in error_msg for p in _PAYLOAD_TOO_LARGE_PATTERNS):\n+        return result_fn(\n+            FailoverReason.payload_too_large,\n+            retryable=True,\n+            should_compress=True,\n+        )\n+\n+    # Billing patterns\n+    if any(p in error_msg for p in _BILLING_PATTERNS):\n+        return result_fn(\n+            FailoverReason.billing,\n+            retryable=False,\n+            should_rotate_credential=True,\n+            should_fallback=True,\n+        )\n+\n+    # Rate limit patterns\n+    if any(p in error_msg for p in _RATE_LIMIT_PATTERNS):\n+        return result_fn(\n+            FailoverReason.rate_limit,\n+            retryable=True,\n+            should_rotate_credential=True,\n+            should_fallback=True,\n+        )\n+\n+    # Context overflow patterns\n+    if any(p in error_msg for p in _CONTEXT_OVERFLOW_PATTERNS):\n+        return result_fn(\n+            FailoverReason.context_overflow,\n+            retryable=True,\n+            should_compress=True,\n+        )\n+\n+    # Auth patterns\n+    if any(p in error_msg for p in _AUTH_PATTERNS):\n+        return result_fn(\n+            FailoverReason.auth,\n+            retryable=True,\n+            should_rotate_credential=True,\n+        )\n+\n+    # Model not found patterns\n+    if any(p in error_msg for p in _MODEL_NOT_FOUND_PATTERNS):\n+        return result_fn(\n+            FailoverReason.model_not_found,\n+            retryable=False,\n+            should_fallback=True,\n+        )\n+\n+    return None\n+\n+\n+# ── Helpers ─────────────────────────────────────────────────────────────\n+\n+def _extract_status_code(error: Exception) -> Optional[int]:\n+    \"\"\"Walk the error and its cause chain to find an HTTP status code.\"\"\"\n+    current = error\n+    for _ in range(5):  # Max depth to prevent infinite loops\n+        code = getattr(current, \"status_code\", None)\n+        if isinstance(code, int):\n+            return code\n+        # Some SDKs use .status instead of .status_code\n+        code = getattr(current, \"status\", None)\n+        if isinstance(code, int) and 100 <= code < 600:\n+            return code\n+        # Walk cause chain\n+        cause = getattr(current, \"__cause__\", None) or getattr(current, \"__context__\", None)\n+        if cause is None or cause is current:\n+            break\n+        current = cause\n+    return None\n+\n+\n+def _extract_error_body(error: Exception) -> dict:\n+    \"\"\"Extract the structured error body from an SDK exception.\"\"\"\n+    body = getattr(error, \"body\", None)\n+    if isinstance(body, dict):\n+        return body\n+    # Some errors have .response.json()\n+    response = getattr(error, \"response\", None)\n+    if response is not None:\n+        try:\n+            json_body = response.json()\n+            if isinstance(json_body, dict):\n+                return json_body\n+        except Exception:\n+            pass\n+    return {}\n+\n+\n+def _extract_error_code(body: dict) -> str:\n+    \"\"\"Extract an error code string from the response body.\"\"\"\n+    if not body:\n+        return \"\"\n+    error_obj = body.get(\"error\", {})\n+    if isinstance(error_obj, dict):\n+        code = error_obj.get(\"code\") or error_obj.get(\"type\") or \"\"\n+        if isinstance(code, str) and code.strip():\n+            return code.strip()\n+    # Top-level code\n+    code = body.get(\"code\") or body.get(\"error_code\") or \"\"\n+    if isinstance(code, (str, int)):\n+        return str(code).strip()\n+    return \"\"\n+\n+\n+def _extract_message(error: Exception, body: dict) -> str:\n+    \"\"\"Extract the most informative error message.\"\"\"\n+    # Try structured body first\n+    if body:\n+        error_obj = body.get(\"error\", {})\n+        if isinstance(error_obj, dict):\n+            msg = error_obj.get(\"message\", \"\")\n+            if isinstance(msg, str) and msg.strip():\n+                return msg.strip()[:500]\n+        msg = body.get(\"message\", \"\")\n+        if isinstance(msg, str) and msg.strip():\n+            return msg.strip()[:500]\n+    # Fallback to str(error)\n+    return str(error)[:500]"]},{"file":"run_agent.py","hunks":["@@ -77,6 +77,7 @@\n # Agent internals extracted to agent/ package for modularity\n from agent.memory_manager import build_memory_context_block\n from agent.retry_utils import jittered_backoff\n+from agent.error_classifier import classify_api_error, FailoverReason\n from agent.prompt_builder import (\n     DEFAULT_AGENT_IDENTITY, PLATFORM_HINTS,\n     MEMORY_GUIDANCE, SESSION_SEARCH_GUIDANCE, SKILLS_GUIDANCE,","@@ -7985,6 +7986,25 @@ def _stop_spinner():\n \n                     status_code = getattr(api_error, \"status_code\", None)\n                     error_context = self._extract_api_error_context(api_error)\n+\n+                    # ── Classify the error for structured recovery decisions ──\n+                    _compressor = getattr(self, \"context_compressor\", None)\n+                    _ctx_len = getattr(_compressor, \"context_length\", 200000) if _compressor else 200000\n+                    classified = classify_api_error(\n+                        api_error,\n+                        provider=getattr(self, \"provider\", \"\") or \"\",\n+                        model=getattr(self, \"model\", \"\") or \"\",\n+                        approx_tokens=[redacted:secret-assignment]\n+                        context_length=_ctx_len,\n+                        num_messages=len(api_messages) if api_messages else 0,\n+                    )\n+                    logger.debug(\n+                        \"Error classified: reason=%s status=%s retryable=%s compress=%s rotate=%s fallback=%s\",\n+                        classified.reason.value, classified.status_code,\n+                        classified.retryable, classified.should_compress,\n+                        classified.should_rotate_credential, classified.should_fallback,\n+                    )\n+\n                     recovered_with_pool, has_retried_429 = self._recover_with_credential_pool(\n                         status_code=status_code,\n                         has_retried_429=has_retried_429,","@@ -8047,27 +8067,24 @@ def _stop_spinner():\n                     # from all messages so the next retry sends no thinking\n                     # blocks at all.  One-shot — don't retry infinitely.\n                     if (\n-                        self.api_mode == \"anthropic_messages\"\n-                        and status_code == 400\n+                        classified.reason == FailoverReason.thinking_signature\n                         and not thinking_sig_retry_attempted\n                     ):\n-                        _err_msg_lower = str(api_error).lower()\n-                        if \"signature\" in _err_msg_lower and \"thinking\" in _err_msg_lower:\n-                            thinking_sig_retry_attempted = True\n-                            for _m in messages:\n-                                if isinstance(_m, dict):\n-                                    _m.pop(\"reasoning_details\", None)\n-                            self._vprint(\n-                                f\"{self.log_prefix}⚠️  Thinking block signature invalid — \"\n-                                f\"stripped all thinking blocks, retrying...\",\n-                                force=True,\n-                            )\n-                            logging.warning(\n-                                \"%sThinking block signature recovery: stripped \"\n-                                \"reasoning_details from %d messages\",\n-                                self.log_prefix, len(messages),\n-                            )\n-                            continue\n+                        thinking_sig_retry_attempted = True\n+                        for _m in messages:\n+                            if isinstance(_m, dict):\n+                                _m.pop(\"reasoning_details\", None)\n+                        self._vprint(\n+                            f\"{self.log_prefix}⚠️  Thinking block signature invalid — \"\n+                            f\"stripped all thinking blocks, retrying...\",\n+                            force=True,\n+                        )\n+                        logging.warning(\n+                            \"%sThinking block signature recovery: stripped \"\n+                            \"reasoning_details from %d messages\",\n+                            self.log_prefix, len(messages),\n+                        )\n+                        continue\n \n                     retry_count += 1\n                     elapsed_time = time.time() - api_start_time","@@ -8124,14 +8141,7 @@ def _stop_spinner():\n                     # is NOT a transient rate limit — retrying or switching\n                     # credentials won't help.  Reduce context to 200k (the\n                     # standard tier) and compress.\n-                    # Only applies to Sonnet — Opus 1M is general access.\n-                    _is_long_context_tier_error = (\n-                        status_code == 429\n-                        and \"extra usage\" in error_msg\n-                        and \"long context\" in error_msg\n-                        and \"sonnet\" in self.model.lower()\n-                    )\n-                    if _is_long_context_tier_error:\n+                    if classified.reason == FailoverReason.long_context_tier:\n                         _reduced_ctx = 200000\n                         compressor = self.context_compressor\n                         old_ctx = compressor.context_length","@@ -8176,13 +8186,9 @@ def _stop_spinner():\n                     # When a fallback model is configured, switch immediately instead\n                     # of burning through retries with exponential backoff -- the\n                     # primary provider won't recover within the retry window.\n-                    is_rate_limited = (\n-                        status_code == 429\n-                        or \"rate limit\" in error_msg\n-                        or \"too many requests\" in error_msg\n-                        or \"rate_limit\" in error_msg\n-                        or \"usage limit\" in error_msg\n-                        or \"quota\" in error_msg\n+                    is_rate_limited = classified.reason in (\n+                        FailoverReason.rate_limit,\n+                        FailoverReason.billing,\n                     )\n                     if is_rate_limited and self._fallback_index < len(self._fallback_chain):\n                         # Don't eagerly fallback if credential pool rotation may","@@ -8198,10 +8204,7 @@ def _stop_spinner():\n                                 continue\n \n                     is_payload_too_large = (\n-                        status_code == 413\n-                        or 'request entity too large' in error_msg\n-                        or 'payload too large' in error_msg\n-                        or 'error code: 413' in error_msg\n+                        classified.reason == FailoverReason.payload_too_large\n                     )\n \n                     if is_payload_too_large:","@@ -8245,64 +8248,12 @@ def _stop_spinner():\n                             }\n \n                     # Check for context-length errors BEFORE generic 4xx handler.\n-                    # Local backends (LM Studio, Ollama, llama.cpp) often return\n-                    # HTTP 400 with messages like \"Context size has been exceeded\"\n-                    # which must trigger compression, not an immediate abort.\n-                    is_context_length_error = any(phrase in error_msg for phrase in [\n-                        'context length', 'context size', 'maximum context',\n-                        'token limit', 'too many tokens', 'reduce the length',\n-                        'exceeds the limit', 'context window',\n-                        'request entity too large',  # OpenRouter/Nous 413 safety net\n-                        'prompt is too long',  # Anthropic: \"prompt is too long: N tokens > M maximum\"\n-                        'prompt exceeds max length',  # Z.AI / GLM: generic 400 overflow wording\n-                    ])\n-\n-                    # Fallback heuristic: Anthropic sometimes returns a generic\n-                    # 400 invalid_request_error with just \"Error\" as the message\n-                    # when the context is too large.  If the error message is very\n-                    # short/generic AND the session is large, treat it as a\n-                    # probable context-length error and attempt compression rather\n-                    # than aborting.  This prevents an infinite failure loop where\n-                    # each failed message gets persisted, making the session even\n-                    # larger. (#1630)\n-                    if not is_context_length_error and status_code == 400:\n-                        ctx_len = getattr(getattr(self, 'context_compressor', None), 'context_length', 200000)\n-                        is_large_session = approx_tokens > ctx_len * 0.4 or len(api_messages) > 80\n-                        is_generic_error = len(error_msg.strip()) < 30  # e.g. just \"error\"\n-                        if is_large_session and is_generic_error:\n-                            is_context_length_error = True\n-                            self._vprint(\n-                                f\"{self.log_prefix}⚠️  Generic 400 with large session \"\n-                                f\"(~{approx_tokens:,} tokens, {len(api_messages)} msgs) — \"\n-                                f\"treating as probable context overflow.\",\n-                                force=True,\n-                            )\n-\n-                    # Server disconnects on large sessions are often caused by\n-                    # the request exceeding the provider's context/payload limit\n-                    # without a proper HTTP error response.  Treat these as\n-                    # context-length errors to trigger compression rather than\n-                    # burning through retries that will all fail the same way.\n-                    # This breaks the death spiral: disconnect → no token data\n-                    # → no compression → bigger session → more disconnects.\n-                    # (#2153)\n-                    if not is_context_length_error and not status_code:\n-                        _is_server_disconnect = (\n-                            'server disconnected' in error_msg\n-                            or 'peer closed connection' in error_msg\n-                            or error_type in ('ReadError', 'RemoteProtocolError', 'ServerDisconnectedError')\n-                        )\n-                        if _is_server_disconnect:\n-                            ctx_len = getattr(getattr(self, 'context_compressor', None), 'context_length', 200000)\n-                            _is_large = approx_tokens > ctx_len * 0.6 or len(api_messages) > 200\n-                            if _is_large:\n-                                is_context_length_error = True\n-                                self._vprint(\n-                                    f\"{self.log_prefix}⚠️  Server disconnected with large session \"\n-                                    f\"(~{approx_tokens:,} tokens, {len(api_messages)} msgs) — \"\n-                                    f\"treating as context-length error, attempting compression.\",\n-                                    force=True,\n-                                )\n+                    # The classifier detects context overflow from: explicit error\n+                    # messages, generic 400 + large session heuristic (#1630), and\n+                    # server disconnect + large session pattern (#2153).\n+                    is_context_length_error = (\n+                        classified.reason == FailoverReason.context_overflow\n+                    )\n \n                     if is_context_length_error:\n                         compressor = self.context_compressor","@@ -8374,35 +8325,30 @@ def _stop_spinner():\n                                 \"partial\": True\n                             }\n \n-                    # Check for non-retryable client errors (4xx HTTP status codes).\n-                    # These indicate a problem with the request itself (bad model ID,\n-                    # invalid API key, forbidden, etc.) and will never succeed on retry.\n-                    # Note: 413 and context-length errors are excluded — handled above.\n-                    # 429 (rate limit) is transient and MUST be retried with backoff.\n-                    # 529 (Anthropic overloaded) is also transient.\n-                    # Also catch local validation errors (ValueError, TypeError) — these\n-                    # are programming bugs, not transient failures.\n-                    # Exclude UnicodeEncodeError — it's a ValueError subclass but is\n-                    # handled separately by the surrogate sanitization path above.\n-                    _RETRYABLE_STATUS_CODES = {413, 429, 529}\n+                    # Check for non-retryable client errors.  The classifier\n+                    # already accounts for 413, 429, 529 (transient), context\n+                    # overflow, and generic-400 heuristics.  Local validation\n+                    # errors (ValueError, TypeError) are programming bugs.\n                     is_local_validation_error = (\n                         isinstance(api_error, (ValueError, TypeError))\n                         and not isinstance(api_error, UnicodeEncodeError)\n                     )\n-                    # Detect generic 400s from Anthropic OAuth (transient server-side failures).\n-                    # Real invalid_request_error responses include a descriptive message;\n-                    # transient ones contain only \"Error\" or are empty. (ref: issue #1608)\n-                    _err_body = getattr(api_error, \"body\", None) or {}\n-                    _err_message = (_err_body.get(\"error\", {}).get(\"message\", \"\") if isinstance(_err_body, dict) else \"\")\n-                    _is_generic_400 = (status_code == 400 and _err_message.strip().lower() in (\"error\", \"\"))\n-                    is_client_status_error = isinstance(status_code, int) and 400 <= status_code < 500 and status_code not in _RETRYABLE_STATUS_CODES and not _is_generic_400\n-                    is_client_error = (is_local_validation_error or is_client_status_error or any(phrase in error_msg for phrase in [\n-                        'error code: 401', 'error code: 403',\n-                        'error code: 404', 'error code: 422',\n-                        'is not a valid model', 'invalid model', 'model not found',\n-                        'invalid api key', 'invalid_api_key', 'authentication',\n-                        'unauthorized', 'forbidden', 'not found',\n-                    ])) and not is_context_length_error\n+                    is_client_error = (\n+                        is_local_validation_error\n+                        or (\n+                            not classified.retryable\n+                            and not classified.should_compress\n+                            and classified.reason not in (\n+                                FailoverReason.rate_limit,\n+                                FailoverReason.billing,\n+                                FailoverReason.overloaded,\n+                                FailoverReason.context_overflow,\n+                                FailoverReason.payload_too_large,\n+                                FailoverReason.long_context_tier,\n+                                FailoverReason.thinking_signature,\n+                            )\n+                        )\n+                    ) and not is_context_length_error\n \n                     if is_client_error:\n                         # Try fallback before aborting — a different provider","@@ -8422,7 +8368,7 @@ def _stop_spinner():\n                         self._vprint(f\"{self.log_prefix}   🔌 Provider: {_provider}  Model: {_model}\", force=True)\n                         self._vprint(f\"{self.log_prefix}   🌐 Endpoint: {_base}\", force=True)\n                         # Actionable guidance for common auth errors\n-                        if status_code in (401, 403) or \"unauthorized\" in error_msg or \"forbidden\" in error_msg or \"permission\" in error_msg:\n+                        if classified.is_auth or classified.reason == FailoverReason.billing:\n                             if _provider == \"openai-codex\" and status_code == 401:\n                                 self._vprint(f\"{self.log_prefix}   💡 Codex OAuth token was rejected (HTTP 401). Your token may have been\", force=True)\n                                 self._vprint(f\"{self.log_prefix}      refreshed by another client (Codex CLI, VS Code). To fix:\", force=True)"]},{"file":"tests/agent/test_error_classifier.py","hunks":["@@ -0,0 +1,750 @@\n+\"\"\"Tests for agent.error_classifier — structured API error classification.\"\"\"\n+\n+import pytest\n+from agent.error_classifier import (\n+    ClassifiedError,\n+    FailoverReason,\n+    classify_api_error,\n+    _extract_status_code,\n+    _extract_error_body,\n+    _extract_error_code,\n+    _classify_402,\n+)\n+\n+\n+# ── Helper: mock API errors ────────────────────────────────────────────\n+\n+class MockAPIError(Exception):\n+    \"\"\"Simulates an OpenAI SDK APIStatusError.\"\"\"\n+    def __init__(self, message, status_code=None, body=None):\n+        super().__init__(message)\n+        self.status_code = status_code\n+        self.body = body or {}\n+\n+\n+class MockTransportError(Exception):\n+    \"\"\"Simulates a transport-level error with a specific type name.\"\"\"\n+    pass\n+\n+\n+class ReadTimeout(MockTransportError):\n+    pass\n+\n+\n+class ConnectError(MockTransportError):\n+    pass\n+\n+\n+class RemoteProtocolError(MockTransportError):\n+    pass\n+\n+\n+class ServerDisconnectedError(MockTransportError):\n+    pass\n+\n+\n+# ── Test: FailoverReason enum ──────────────────────────────────────────\n+\n+class TestFailoverReason:\n+    def test_all_reasons_have_string_values(self):\n+        for reason in FailoverReason:\n+            assert isinstance(reason.value, str)\n+\n+    def test_enum_members_exist(self):\n+        expected = {\n+            \"auth\", \"auth_permanent\", \"billing\", \"rate_limit\",\n+            \"overloaded\", \"server_error\", \"timeout\",\n+            \"context_overflow\", \"payload_too_large\",\n+            \"model_not_found\", \"format_error\",\n+            \"thinking_signature\", \"long_context_tier\", \"unknown\",\n+        }\n+        actual = {r.value for r in FailoverReason}\n+        assert expected == actual\n+\n+\n+# ── Test: ClassifiedError ──────────────────────────────────────────────\n+\n+class TestClassifiedError:\n+    def test_is_auth_property(self):\n+        e1 = ClassifiedError(reason=FailoverReason.auth)\n+        assert e1.is_auth is True\n+\n+        e2 = ClassifiedError(reason=FailoverReason.auth_permanent)\n+        assert e2.is_auth is True\n+\n+        e3 = ClassifiedError(reason=FailoverReason.billing)\n+        assert e3.is_auth is False\n+\n+    def test_is_transient_property(self):\n+        transient_reasons = [\n+            FailoverReason.rate_limit,\n+            FailoverReason.overloaded,\n+            FailoverReason.server_error,\n+            FailoverReason.timeout,\n+            FailoverReason.unknown,\n+        ]\n+        for reason in transient_reasons:\n+            e = ClassifiedError(reason=reason)\n+            assert e.is_transient is True, f\"{reason} should be transient\"\n+\n+        non_transient = [\n+            FailoverReason.auth,\n+            FailoverReason.billing,\n+            FailoverReason.model_not_found,\n+            FailoverReason.format_error,\n+        ]\n+        for reason in non_transient:\n+            e = ClassifiedError(reason=reason)\n+            assert e.is_transient is False, f\"{reason} should NOT be transient\"\n+\n+    def test_defaults(self):\n+        e = ClassifiedError(reason=FailoverReason.unknown)\n+        assert e.retryable is True\n+        assert e.should_compress is False\n+        assert e.should_rotate_credential is False\n+        assert e.should_fallback is False\n+        assert e.status_code is None\n+        assert e.message == \"\"\n+\n+\n+# ── Test: Status code extraction ───────────────────────────────────────\n+\n+class TestExtractStatusCode:\n+    def test_from_status_code_attr(self):\n+        e = MockAPIError(\"fail\", status_code=429)\n+        assert _extract_status_code(e) == 429\n+\n+    def test_from_status_attr(self):\n+        class ErrWithStatus(Exception):\n+            status = 503\n+        assert _extract_status_code(ErrWithStatus()) == 503\n+\n+    def test_from_cause_chain(self):\n+        inner = MockAPIError(\"inner\", status_code=401)\n+        outer = Exception(\"outer\")\n+        outer.__cause__ = inner\n+        assert _extract_status_code(outer) == 401\n+\n+    def test_none_when_missing(self):\n+        assert _extract_status_code(Exception(\"generic\")) is None\n+\n+    def test_rejects_non_http_status(self):\n+        \"\"\"Integers outside 100-599 on .status should be ignored.\"\"\"\n+        class ErrWeirdStatus(Exception):\n+            status = 42\n+        assert _extract_status_code(ErrWeirdStatus()) is None\n+\n+\n+# ── Test: Error body extraction ────────────────────────────────────────\n+\n+class TestExtractErrorBody:\n+    def test_from_body_attr(self):\n+        e = MockAPIError(\"fail\", body={\"error\": {\"message\": \"bad\"}})\n+        assert _extract_error_body(e) == {\"error\": {\"message\": \"bad\"}}\n+\n+    def test_empty_when_no_body(self):\n+        assert _extract_error_body(Exception(\"generic\")) == {}\n+\n+\n+# ── Test: Error code extraction ────────────────────────────────────────\n+\n+class TestExtractErrorCode:\n+    def test_from_nested_error_code(self):\n+        body = {\"error\": {\"code\": \"rate_limit_exceeded\"}}\n+        assert _extract_error_code(body) == \"rate_limit_exceeded\"\n+\n+    def test_from_nested_error_type(self):\n+        body = {\"error\": {\"type\": \"invalid_request_error\"}}\n+        assert _extract_error_code(body) == \"invalid_request_error\"\n+\n+    def test_from_top_level_code(self):\n+        body = {\"code\": \"model_not_found\"}\n+        assert _extract_error_code(body) == \"model_not_found\"\n+\n+    def test_empty_when_no_code(self):\n+        assert _extract_error_code({}) == \"\"\n+        assert _extract_error_code({\"error\": {\"message\": \"oops\"}}) == \"\"\n+\n+\n+# ── Test: 402 disambiguation ───────────────────────────────────────────\n+\n+class TestClassify402:\n+    \"\"\"The critical 402 billing vs rate_limit disambiguation.\"\"\"\n+\n+    def test_billing_exhaustion(self):\n+        \"\"\"Plain 402 = billing.\"\"\"\n+        result = _classify_402(\n+            \"payment required\",\n+            lambda reason, **kw: ClassifiedError(reason=reason, **kw),\n+        )\n+        assert result.reason == FailoverReason.billing\n+        assert result.should_rotate_credential is True\n+\n+    def test_transient_usage_limit(self):\n+        \"\"\"402 with 'usage limit' + 'try again' = rate limit, not billing.\"\"\"\n+        result = _classify_402(\n+            \"usage limit exceeded. try again in 5 minutes\",\n+            lambda reason, **kw: ClassifiedError(reason=reason, **kw),\n+        )\n+        assert result.reason == FailoverReason.rate_limit\n+        assert result.should_rotate_credential is True\n+\n+    def test_quota_with_retry(self):\n+        \"\"\"402 with 'quota' + 'retry' = rate limit.\"\"\"\n+        result = _classify_402(\n+            \"quota exceeded, please retry after the window resets\",\n+            lambda reason, **kw: ClassifiedError(reason=reason, **kw),\n+        )\n+        assert result.reason == FailoverReason.rate_limit\n+\n+    def test_quota_without_retry(self):\n+        \"\"\"402 with just 'quota' but no transient signal = billing.\"\"\"\n+        result = _classify_402(\n+            \"quota exceeded\",\n+            lambda reason, **kw: ClassifiedError(reason=reason, **kw),\n+        )\n+        assert result.reason == FailoverReason.billing\n+\n+    def test_insufficient_credits(self):\n+        result = _classify_402(\n+            \"insufficient credits to complete request\",\n+            lambda reason, **kw: ClassifiedError(reason=reason, **kw),\n+        )\n+        assert result.reason == FailoverReason.billing\n+\n+\n+# ── Test: Full classification pipeline ─────────────────────────────────\n+\n+class TestClassifyApiError:\n+    \"\"\"End-to-end classification tests.\"\"\"\n+\n+    # ── Auth errors ──\n+\n+    def test_401_classified_as_auth(self):\n+        e = MockAPIError(\"Unauthorized\", status_code=401)\n+        result = classify_api_error(e, provider=\"openrouter\")\n+        assert result.reason == FailoverReason.auth\n+        assert result.should_rotate_credential is True\n+        # 401 is non-retryable on its own — credential rotation runs\n+        # before the retryability check in the agent loop.\n+        assert result.retryable is False\n+        assert result.should_fallback is True\n+\n+    def test_403_classified_as_auth(self):\n+        e = MockAPIError(\"Forbidden\", status_code=403)\n+        result = classify_api_error(e, provider=\"anthropic\")\n+        assert result.reason == FailoverReason.auth\n+        assert result.should_fallback is True\n+\n+    def test_403_key_limit_classified_as_billing(self):\n+        \"\"\"OpenRouter 403 'key limit exceeded' is billing, not auth.\"\"\"\n+        e = MockAPIError(\"Key limit exceeded for this key\", status_code=403)\n+        result = classify_api_error(e, provider=\"openrouter\")\n+        assert result.reason == FailoverReason.billing\n+        assert result.should_rotate_credential is True\n+        assert result.should_fallback is True\n+\n+    def test_403_spending_limit_classified_as_billing(self):\n+        e = MockAPIError(\"spending limit reached\", status_code=403)\n+        result = classify_api_error(e, provider=\"openrouter\")\n+        assert result.reason == FailoverReason.billing\n+\n+    # ── Billing ──\n+\n+    def test_402_plain_billing(self):\n+        e = MockAPIError(\"Payment Required\", status_code=402)\n+        result = classify_api_error(e)\n+        assert result.reason == FailoverReason.billing\n+        assert result.retryable is False\n+\n+    def test_402_transient_usage_limit(self):\n+        e = MockAPIError(\"usage limit exceeded, try again later\", status_code=402)\n+        result = classify_api_error(e)\n+        assert result.reason == FailoverReason.rate_limit\n+        assert result.retryable is True\n+\n+    # ── Rate limit ──\n+\n+    def test_429_rate_limit(self):\n+        e = MockAPIError(\"Too Many Requests\", status_code=429)\n+        result = classify_api_error(e)\n+        assert result.reason == FailoverReason.rate_limit\n+        assert result.should_fallback is True\n+\n+    # ── Server errors ──\n+\n+    def test_500_server_error(self):\n+        e = MockAPIError(\"Internal Server Error\", status_code=500)\n+        result = classify_api_error(e)\n+        assert result.reason == FailoverReason.server_error\n+        assert result.retryable is True\n+\n+    def test_502_server_error(self):\n+        e = MockAPIError(\"Bad Gateway\", status_code=502)\n+        result = classify_api_error(e)\n+        assert result.reason == FailoverReason.server_error\n+\n+    def test_503_overloaded(self):\n+        e = MockAPIError(\"Service Unavailable\", status_code=503)\n+        result = classify_api_error(e)\n+        assert result.reason == FailoverReason.overloaded\n+\n+    def test_529_anthropic_overloaded(self):\n+        e = MockAPIError(\"Overloaded\", status_code=529)\n+        result = classify_api_error(e)\n+        assert result.reason == FailoverReason.overloaded\n+\n+    # ── Model not found ──\n+\n+    def test_404_model_not_found(self):\n+        e = MockAPIError(\"model not found\", status_code=404)\n+        result = classify_api_error(e)\n+        assert result.reason == FailoverReason.model_not_found\n+        assert result.should_fallback is True\n+        assert result.retryable is False\n+\n+    def test_404_generic(self):\n+        e = MockAPIError(\"Not Found\", status_code=404)\n+        result = classify_api_error(e)\n+        assert result.reason == FailoverReason.model_not_found\n+\n+    # ── Payload too large ──\n+\n+    def test_413_payload_too_large(self):\n+        e = MockAPIError(\"Request Entity Too Large\", status_code=413)\n+        result = classify_api_error(e)\n+        assert result.reason == FailoverReason.payload_too_large\n+        assert result.should_compress is True\n+\n+    # ── Context overflow ──\n+\n+    def test_400_context_length(self):\n+        e = MockAPIError(\"context length exceeded: 250000 > 200000\", status_code=400)\n+        result = classify_api_error(e)\n+        assert result.reason == FailoverReason.context_overflow\n+        assert result.should_compress is True\n+\n+    def test_400_too_many_tokens(self):\n+        e = MockAPIError(\"This model's maximum context is 128000 tokens, too many tokens\", status_code=400)\n+        result = classify_api_error(e)\n+        assert result.reason == FailoverReason.context_overflow\n+\n+    def test_400_prompt_too_long(self):\n+        e = MockAPIError(\"prompt is too long: 300000 tokens > 200000 maximum\", status_code=400)\n+        result = classify_api_error(e)\n+        assert result.reason == FailoverReason.context_overflow\n+\n+    def test_400_generic_large_session(self):\n+        \"\"\"Generic 400 with large session → context overflow heuristic.\"\"\"\n+        e = MockAPIError(\n+            \"Error\",\n+            status_code=400,\n+            body={\"error\": {\"message\": \"Error\"}},\n+        )\n+        result = classify_api_error(e, approx_tokens=[redacted:secret-assignment] context_length=200000)\n+        assert result.reason == FailoverReason.context_overflow\n+\n+    def test_400_generic_small_session_is_format_error(self):\n+        \"\"\"Generic 400 with small session → format error, not context overflow.\"\"\"\n+        e = MockAPIError(\n+            \"Error\",\n+            status_code=400,\n+            body={\"error\": {\"message\": \"Error\"}},\n+        )\n+        result = classify_api_error(e, approx_tokens=1000, context_length=200000)\n+        assert result.reason == FailoverReason.format_error\n+\n+    # ── Server disconnect + large session ──\n+\n+    def test_disconnect_large_session_context_overflow(self):\n+        \"\"\"Server disconnect with large session → context overflow.\"\"\"\n+        e = Exception(\"server disconnected without sending complete message\")\n+        result = classify_api_error(e, approx_tokens=[redacted:secret-assignment] context_length=200000)\n+        assert result.reason == FailoverReason.context_overflow\n+        assert result.should_compress is True\n+\n+    def test_disconnect_small_session_timeout(self):\n+        \"\"\"Server disconnect with small session → timeout.\"\"\"\n+        e = Exception(\"server disconnected without sending complete message\")\n+        result = classify_api_error(e, approx_tokens=5000, context_length=200000)\n+        assert result.reason == FailoverReason.timeout\n+\n+    # ── Provider-specific: Anthropic thinking signature ──\n+\n+    def test_anthropic_thinking_signature(self):\n+        e = MockAPIError(\n+            \"thinking block has invalid signature\",\n+            status_code=400,\n+        )\n+        result = classify_api_error(e, provider=\"anthropic\")\n+        assert result.reason == FailoverReason.thinking_signature\n+        assert result.retryable is True\n+\n+    def test_non_anthropic_400_with_signature_not_classified_as_thinking(self):\n+        \"\"\"400 with 'signature' but from non-Anthropic → format error.\"\"\"\n+        e = MockAPIError(\"invalid signature\", status_code=400)\n+        result = classify_api_error(e, provider=\"openrouter\", approx_tokens=0)\n+        # Without \"thinking\" in the message, it shouldn't be thinking_signature\n+        assert result.reason != FailoverReason.thinking_signature\n+\n+    # ── Provider-specific: Anthropic long-context tier ──\n+\n+    def test_anthropic_long_context_tier(self):\n+        e = MockAPIError(\n+            \"Extra usage is required for long context requests over 200k tokens\",\n+            status_code=429,\n+        )\n+        result = classify_api_error(e, provider=\"anthropic\", model=\"claude-sonnet-4\")\n+        assert result.reason == FailoverReason.long_context_tier\n+        assert result.should_compress is True\n+\n+    def test_normal_429_not_long_context(self):\n+        \"\"\"Normal 429 without 'extra usage' + 'long context' → rate_limit.\"\"\"\n+        e = MockAPIError(\"Too Many Requests\", status_code=429)\n+        result = classify_api_error(e, provider=\"anthropic\")\n+        assert result.reason == FailoverReason.rate_limit\n+\n+    # ── Transport errors ──\n+\n+    def test_read_timeout(self):\n+        e = ReadTimeout(\"Read timed out\")\n+        result = classify_api_error(e)\n+        assert result.reason == FailoverReason.timeout\n+        assert result.retryable is True\n+\n+    def test_connect_error(self):\n+        e = ConnectError(\"Connection refused\")\n+        result = classify_api_error(e)\n+        assert result.reason == FailoverReason.timeout\n+\n+    def test_connection_error_builtin(self):\n+        e = ConnectionError(\"Connection reset by peer\")\n+        result = classify_api_error(e)\n+        assert result.reason == FailoverReason.timeout\n+\n+    def test_timeout_error_builtin(self):\n+        e = TimeoutError(\"timed out\")\n+        result = classify_api_error(e)\n+        assert result.reason == FailoverReason.timeout\n+\n+    # ── Error code classification ──\n+\n+    def test_error_code_resource_exhausted(self):\n+        e = MockAPIError(\n+            \"Resource exhausted\",\n+            body={\"error\": {\"code\": \"resource_exhausted\", \"message\": \"Too many requests\"}},\n+        )\n+        result = classify_api_error(e)\n+        assert result.reason == FailoverReason.rate_limit\n+\n+    def test_error_code_model_not_found(self):\n+        e = MockAPIError(\n+            \"Model not available\",\n+            body={\"error\": {\"code\": \"model_not_found\"}},\n+        )\n+        result = classify_api_error(e)\n+        assert result.reason == FailoverReason.model_not_found\n+\n+    def test_error_code_context_length_exceeded(self):\n+        e = MockAPIError(\n+            \"Context too large\",\n+            body={\"error\": {\"code\": \"context_length_exceeded\"}},\n+        )\n+        result = classify_api_error(e)\n+        assert result.reason == FailoverReason.context_overflow\n+\n+    # ── Message-only patterns (no status code) ──\n+\n+    def test_message_billing_pattern(self):\n+        e = Exception(\"insufficient credits to complete this request\")\n+        result = classify_api_error(e)\n+        assert result.reason == FailoverReason.billing\n+\n+    def test_message_rate_limit_pattern(self):\n+        e = Exception(\"rate limit reached for this model\")\n+        result = classify_api_error(e)\n+        assert result.reason == FailoverReason.rate_limit\n+\n+    def test_message_auth_pattern(self):\n+        e = Exception(\"invalid api key provided\")\n+        result = classify_api_error(e)\n+        assert result.reason == FailoverReason.auth\n+\n+    def test_message_model_not_found_pattern(self):\n+        e = Exception(\"gpt-99 is not a valid model\")\n+        result = classify_api_error(e)\n+        assert result.reason == FailoverReason.model_not_found\n+\n+    def test_message_context_overflow_pattern(self):\n+        e = Exception(\"maximum context length exceeded\")\n+        result = classify_api_error(e)\n+        assert result.reason == FailoverReason.context_overflow\n+\n+    # ── Unknown / fallback ──\n+\n+    def test_generic_exception_is_unknown(self):\n+        e = Exception(\"something weird happened\")\n+        result = classify_api_error(e)\n+        assert result.reason == FailoverReason.unknown\n+        assert result.retryable is True\n+\n+    # ── Format error ──\n+\n+    def test_400_descriptive_format_error(self):\n+        \"\"\"400 with descriptive message (not context overflow) → format error.\"\"\"\n+        e = MockAPIError(\n+            \"Invalid value for parameter 'temperature': must be between 0 and 2\",\n+            status_code=400,\n+            body={\"error\": {\"message\": \"Invalid value for parameter 'temperature': must be between 0 and 2\"}},\n+        )\n+        result = classify_api_error(e, approx_tokens=1000)\n+        assert result.reason == FailoverReason.format_error\n+        assert result.retryable is False\n+\n+    def test_422_format_error(self):\n+        e = MockAPIError(\"Unprocessable Entity\", status_code=422)\n+        result = classify_api_error(e)\n+        assert result.reason == FailoverReason.format_error\n+        assert result.retryable is False\n+\n+    # ── Peer closed + large session ──\n+\n+    def test_peer_closed_large_session(self):\n+        e = Exception(\"peer closed connection without sending complete message\")\n+        result = classify_api_error(e, approx_tokens=[redacted:secret-assignment] context_length=200000)\n+        assert result.reason == FailoverReason.context_overflow\n+\n+    # ── Chinese error messages ──\n+\n+    def test_chinese_context_overflow(self):\n+        e = MockAPIError(\"超过最大长度限制\", status_code=400)\n+        result = classify_api_error(e)\n+        assert result.reason == FailoverReason.context_overflow\n+\n+    # ── Result metadata ──\n+\n+    def test_provider_and_model_in_result(self):\n+        e = MockAPIError(\"fail\", status_code=500)\n+        result = classify_api_error(e, provider=\"openrouter\", model=\"gpt-5\")\n+        assert result.provider == \"openrouter\"\n+        assert result.model == \"gpt-5\"\n+        assert result.status_code == 500\n+\n+    def test_message_extracted(self):\n+        e = MockAPIError(\n+            \"outer\",\n+            status_code=500,\n+            body={\"error\": {\"message\": \"Internal server error occurred\"}},\n+        )\n+        result = classify_api_error(e)\n+        assert result.message == \"Internal server error occurred\"\n+\n+\n+# ── Test: Adversarial / edge cases (from live testing) ─────────────────\n+\n+class TestAdversarialEdgeCases:\n+    \"\"\"Edge cases discovered during live testing with real SDK objects.\"\"\"\n+\n+    def test_empty_exception_message(self):\n+        result = classify_api_error(Exception(\"\"))\n+        assert result.reason == FailoverReason.unknown\n+        assert result.retryable is True\n+\n+    def test_500_with_none_body(self):\n+        e = MockAPIError(\"fail\", status_code=500, body=None)\n+        result = classify_api_error(e)\n+        assert result.reason == FailoverReason.server_error\n+\n+    def test_non_dict_body(self):\n+        \"\"\"Some providers return strings instead of JSON.\"\"\"\n+        class StringBodyError(Exception):\n+            status_code = 400\n+            body = \"just a string\"\n+        result = classify_api_error(StringBodyError(\"bad\"))\n+        assert result.reason == FailoverReason.format_error\n+\n+    def test_list_body(self):\n+        class ListBodyError(Exception):\n+            status_code = 500\n+            body = [{\"error\": \"something\"}]\n+        result = classify_api_error(ListBodyError(\"server error\"))\n+        assert result.reason == FailoverReason.server_error\n+\n+    def test_circular_cause_chain(self):\n+        \"\"\"Must not infinite-loop on circular __cause__.\"\"\"\n+        e = Exception(\"circular\")\n+        e.__cause__ = e\n+        result = classify_api_error(e)\n+        assert result.reason == FailoverReason.unknown\n+\n+    def test_three_level_cause_chain(self):\n+        inner = MockAPIError(\"inner\", status_code=429)\n+        middle = Exception(\"middle\")\n+        middle.__cause__ = inner\n+        outer = RuntimeError(\"outer\")\n+        outer.__cause__ = middle\n+        result = classify_api_error(outer)\n+        assert result.status_code == 429\n+        assert result.reason == FailoverReason.rate_limit\n+\n+    def test_400_with_rate_limit_text(self):\n+        \"\"\"Some providers send rate limits as 400 instead of 429.\"\"\"\n+        e = MockAPIError(\n+            \"rate limit policy\",\n+            status_code=400,\n+            body={\"error\": {\"message\": \"rate limit exceeded on this model\"}},\n+        )\n+        result = classify_api_error(e, provider=\"openrouter\")\n+        assert result.reason == FailoverReason.rate_limit\n+\n+    def test_400_with_billing_text(self):\n+        \"\"\"Some providers send billing errors as 400.\"\"\"\n+        e = MockAPIError(\n+            \"billing\",\n+            status_code=400,\n+            body={\"error\": {\"message\": \"insufficient credits for this request\"}},\n+        )\n+        result = classify_api_error(e)\n+        assert result.reason == FailoverReason.billing\n+\n+    def test_200_with_error_body(self):\n+        \"\"\"200 status with error in body — should be unknown, not crash.\"\"\"\n+        class WeirdSuccess(Exception):\n+            status_code = 200\n+            body = {\"error\": {\"message\": \"loading\"}}\n+        result = classify_api_error(WeirdSuccess(\"model loading\"))\n+        assert result.reason == FailoverReason.unknown\n+\n+    def test_ollama_context_size_exceeded(self):\n+        e = MockAPIError(\n+            \"Error\",\n+            status_code=400,\n+            body={\"error\": {\"message\": \"context size has been exceeded\"}},\n+        )\n+        result = classify_api_error(e, provider=\"ollama\")\n+        assert result.reason == FailoverReason.context_overflow\n+\n+    def test_connection_refused_error(self):\n+        e = ConnectionRefusedError(\"Connection refused: localhost:11434\")\n+        result = classify_api_error(e, provider=\"ollama\")\n+        assert result.reason == FailoverReason.timeout\n+\n+    def test_body_message_enrichment(self):\n+        \"\"\"Body message must be included in pattern matching even when\n+        str(error) doesn't contain it (OpenAI SDK APIStatusError).\"\"\"\n+        e = MockAPIError(\n+            \"Usage limit\",  # str(e) = \"usage limit\"\n+            status_code=402,\n+            body={\"error\": {\"message\": \"Usage limit reached, try again in 5 minutes\"}},\n+        )\n+        result = classify_api_error(e)\n+        # \"try again\" is only in body, not in str(e)\n+        assert result.reason == FailoverReason.rate_limit\n+\n+    def test_disconnect_pattern_ordering(self):\n+        \"\"\"Disconnect + large session must beat generic transport catch.\"\"\"\n+        class FakeRemoteProtocol(Exception):\n+            pass\n+        # Type name isn't in _TRANSPORT_ERROR_TYPES but message has disconnect pattern\n+        e = Exception(\"peer closed connection without sending complete message\")\n+        result = classify_api_error(e, approx_tokens=[redacted:secret-assignment] context_length=200000)\n+        assert result.reason == FailoverReason.context_overflow\n+        assert result.should_compress is True\n+\n+    def test_credit_balance_too_low(self):\n+        e = MockAPIError(\n+            \"Credits low\",\n+            status_code=402,\n+            body={\"error\": {\"message\": \"Your credit balance is too low\"}},\n+        )\n+        result = classify_api_error(e, provider=\"anthropic\")\n+        assert result.reason == FailoverReason.billing\n+\n+    def test_deepseek_402_chinese(self):\n+        \"\"\"Chinese billing message should still match billing patterns.\"\"\"\n+        # \"余额不足\" doesn't match English billing patterns, but 402 defaults to billing\n+        e = MockAPIError(\"余额不足\", status_code=402)\n+        result = classify_api_error(e, provider=\"deepseek\")\n+        assert result.reason == FailoverReason.billing\n+\n+    def test_openrouter_wrapped_context_overflow_in_metadata_raw(self):\n+        \"\"\"OpenRouter wraps provider errors in metadata.raw JSON string.\"\"\"\n+        e = MockAPIError(\n+            \"Provider returned error\",\n+            status_code=400,\n+            body={\n+                \"error\": {\n+                    \"message\": \"Provider returned error\",\n+                    \"code\": 400,\n+                    \"metadata\": {\n+                        \"raw\": '{\"error\":{\"message\":\"context length exceeded: 50000 > 32768\"}}'\n+                    }\n+                }\n+            },\n+        )\n+        result = classify_api_error(e, provider=\"openrouter\", approx_tokens=[redacted:secret-assignment]\n+        assert result.reason == FailoverReason.context_overflow\n+        assert result.should_compress is True\n+\n+    def test_openrouter_wrapped_rate_limit_in_metadata_raw(self):\n+        e = MockAPIError(\n+            \"Provider returned error\",\n+            status_code=400,\n+            body={\n+                \"error\": {\n+                    \"message\": \"Provider returned error\",\n+                    \"metadata\": {\n+                        \"raw\": '{\"error\":{\"message\":\"Rate limit exceeded. Please retry after 30s.\"}}'\n+                    }\n+                }\n+            },\n+        )\n+        result = classify_api_error(e, provider=\"openrouter\")\n+        assert result.reason == FailoverReason.rate_limit\n+\n+    def test_thinking_signature_via_openrouter(self):\n+        \"\"\"Thinking signature errors proxied through OpenRouter must be caught.\"\"\"\n+        e = MockAPIError(\n+            \"thinking block has invalid signature\",\n+            status_code=400,\n+        )\n+        # provider is openrouter, not anthropic — old code missed this\n+        result = classify_api_error(e, provider=\"openrouter\", model=\"anthropic/claude-sonnet-4\")\n+        assert result.reason == FailoverReason.thinking_signature\n+\n+    def test_generic_400_large_by_message_count(self):\n+        \"\"\"Many small messages (>80) should trigger context overflow heuristic.\"\"\"\n+        e = MockAPIError(\n+            \"Error\",\n+            status_code=400,\n+            body={\"error\": {\"message\": \"Error\"}},\n+        )\n+        # Low token count but high message count\n+        result = classify_api_error(\n+            e, approx_tokens=5000, context_length=200000, num_messages=100,\n+        )\n+        assert result.reason == FailoverReason.context_overflow\n+\n+    def test_disconnect_large_by_message_count(self):\n+        \"\"\"Server disconnect with 200+ messages should trigger context overflow.\"\"\"\n+        e = Exception(\"server disconnected without sending complete message\")\n+        result = classify_api_error(\n+            e, approx_tokens=5000, context_length=200000, num_messages=250,\n+        )\n+        assert result.reason == FailoverReason.context_overflow\n+\n+    def test_openrouter_wrapped_model_not_found_in_metadata_raw(self):\n+        e = MockAPIError(\n+            \"Provider returned error\",\n+            status_code=400,\n+            body={\n+                \"error\": {\n+                    \"message\": \"Provider returned error\",\n+                    \"metadata\": {\n+                        \"raw\": '{\"error\":{\"message\":\"The model gpt-99 does not exist\"}}'\n+                    }\n+                }\n+            },\n+        )\n+        result = classify_api_error(e, provider=\"openrouter\")\n+        assert result.reason == FailoverReason.model_not_found\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}