"""Provider API error summarising for ``AIAgent``. Entitlement-failure detection, xAI subscription decoration, structured-detail coercion, and log-safe redaction of provider error payloads. Extracted from ``run_agent.py``; every method resolves through ``AIAgent``'s MRO unchanged. """ import json import re from typing import Any, Dict, Optional from agent.redact import redact_sensitive_text # Substrings of the plain ``ValueError`` jiter (the openai/anthropic SDKs' SSE JSON parser) # raises for a truncated/corrupted event-stream frame — wire trouble, not local validation # (#65147). Serde-style vocabulary anchored on the "at line" suffix; classify through # ``is_provider_stream_parse_error`` rather than scanning this tuple directly. PROVIDER_STREAM_PARSE_MARKERS = ( "expected ident at line", "expected value at line", "eof while parsing a value at line", "eof while parsing a string at line", "eof while parsing a list at line", "eof while parsing an object at line", "key must be a string at line", "trailing characters at line", "trailing comma at line", "expected `,` or `}` at line", "expected `,` or `]` at line", "expected `:` at line", "invalid escape at line", "invalid number at line", "found while parsing a string at line", # "control character (\u0000-\u001F) found while ..." ) def is_provider_stream_parse_error(error: BaseException) -> bool: """True for a provider stream-parse ``ValueError`` (see ``PROVIDER_STREAM_PARSE_MARKERS``).""" return (isinstance(error, ValueError) and not isinstance(error, (UnicodeEncodeError, json.JSONDecodeError)) and any(marker in str(error).lower() for marker in PROVIDER_STREAM_PARSE_MARKERS)) # Offline DNS failures are wrapped in a generic "Connection error" by SDKs — inspect the chain. _NETWORK_RESOLUTION_MARKERS = ( "temporary failure in name resolution", "name or service not known", "nodename nor servname provided, or not known", "getaddrinfo failed", "no address associated with hostname", "network is unreachable", ) _XAI_ENTITLEMENT_HINT = ( " — xAI rejected this OAuth account. NOTE: X Premium+ does NOT " "include xAI API access — only standalone SuperGrok subscribers " "can use this provider. Other possible causes: no Grok " "subscription, your tier doesn't include this model, or your " "quota is exhausted. Check https://grok.com/?_s=usage to see " "which, or run `/model` to switch providers." ) _ERROR_DETAIL_KEYS = ("message", "detail", "error", "code", "type") def _is_xai_entitlement_text(lower: str) -> bool: """xAI's permission-denied body text for an unsubscribed / under-tiered / exhausted account.""" return ( "do not have an active grok subscription" in lower or ("out of available resources" in lower and "grok" in lower) or ("does not have permission" in lower and "grok" in lower) ) def _http_prefix(error: Exception) -> str: status_code = getattr(error, "status_code", None) return f"HTTP {status_code}: " if status_code else "" class ApiErrorSummaryMixin: """Provider error -> user/log-safe summary (see module docstring).""" @staticmethod def _is_entitlement_failure( error_context: Optional[Dict[str, Any]], status_code: Optional[int] ) -> bool: """Detect subscription/entitlement 401/403s that masquerade as auth failures. Refreshing a token cannot fix an unsubscribed account, so callers surface the error instead of looping the pool. xAI returns the same permission-denied text for BOTH cases; a ``[WKE=unauthenticated:...]`` suffix (or "access token could not be validated") means stale token → return False so the refresh path runs. Disambiguator for xAI (#29344): the same ``code`` text ("The caller does not have permission to execute the specified operation") is returned for BOTH an unsubscribed account AND a stale OAuth access token. xAI ships an explicit signal in the ``error`` field that tells the two apart: a ``[WKE=unauthenticated:...]`` suffix (and/or the ``OAuth2 access token could not be validated`` phrasing) means the credentials failed validation — that's recoverable by refreshing the token, NOT by surfacing an entitlement message. When either signal is present we return False eagerly so the credential-pool refresh path runs, letting long-running TUI sessions recover from stale tokens without an exit/reopen cycle. """ if status_code not in {401, 403, None}: return False if not isinstance(error_context, dict): return False # Single lowercase haystack over every field shape (message/reason and raw code/error). haystack = " ".join( str(error_context.get(k) or "").lower() for k in ("message", "reason", "code", "error") ) if not haystack.strip(): return False if "[wke=unauthenticated:" in haystack or "oauth2 access token could not be validated" in haystack: return False return _is_xai_entitlement_text(haystack) @staticmethod def _decorate_xai_entitlement_error(detail: str) -> str: """Append a neutral hint when xAI's OAuth surface returns the permission-denied 403. xAI's ``/v1/responses`` uses one body for several causes (no subscription, tier lacks the model, quota exhausted). The least obvious: X Premium+ does NOT include API access — only SuperGrok does. Lead with that, keep the raw text, point at https://grok.com/?_s=usage. Idempotent: a substring unique to the hint marks prior decoration. """ if not detail or not _is_xai_entitlement_text(detail.lower()): return detail if "X Premium+ does NOT include" in detail: return detail return f"{detail}{_XAI_ENTITLEMENT_HINT}" @staticmethod def _coerce_api_error_detail(value: Any) -> str: """Return a display-safe string for structured provider error fields.""" if isinstance(value, str): return value if isinstance(value, dict): for key in _ERROR_DETAIL_KEYS: nested = value.get(key) if isinstance(nested, str) and nested.strip(): return nested for key in _ERROR_DETAIL_KEYS: if key in value: nested_detail = ApiErrorSummaryMixin._coerce_api_error_detail(value[key]) if nested_detail: return nested_detail try: return json.dumps(value, ensure_ascii=False, sort_keys=True) except TypeError: return str(value) if isinstance(value, (list, tuple)): parts = [ApiErrorSummaryMixin._coerce_api_error_detail(item) for item in value] return "; ".join(part for part in parts if part) if value is None: return "" return str(value) @staticmethod def _summarize_api_error(error: Exception) -> str: """Extract a human-readable one-liner from an API error. Cloudflare HTML pages → ``