"""LLM-layer error types, classification, and retry utilities AuthenticationError (401) — bad key / forbidden NotFoundError (404) — model does not exist BadRequestError (400) — catch-all (context overflow via OpenRouter, bad tool schema, etc.) ContextWindowExceededError (400) — context overflow (OpenAI direct) RateLimitError (429) — rate limit ServiceUnavailableError (529) — server overload Timeout (408) — request timeout APIConnectionError — network-level failure """ from __future__ import annotations import random import re from dataclasses import dataclass from typing import Any __all__ = [ # Exceptions "PromptTooLongError", "ModelNotAvailableError", "OverloadedError", "CannotRetryError", "FallbackTriggeredError", # Dataclass "RetryContext", # Predicates "is_overloaded_error", "is_prompt_too_long_error", "is_model_not_available_error", "is_abort_error", "is_media_size_error", "should_retry", # Token / overflow parsing "parse_prompt_too_long_token_counts", "get_prompt_too_long_token_gap", "parse_max_tokens_context_overflow_error", # Retry helpers "get_retry_delay", "get_retry_after_ms", "get_default_max_retries", # Classification (for logging / analytics) "classify_api_error", "categorize_retryable_api_error", # Error formatting for user-facing messages (consumed by 7.1) "format_api_error", "get_error_message_for_user", "sanitize_error_html", # Wrap helpers "to_prompt_too_long_error", "to_model_not_available_error", "to_overloaded_error", # Constants "API_ERROR_MESSAGE_PREFIX", "PROMPT_TOO_LONG_ERROR_MESSAGE", "REPEATED_OVERLOADED_ERROR_MESSAGE", "API_TIMEOUT_ERROR_MESSAGE", "DEFAULT_MAX_RETRIES", "BASE_DELAY_MS", "MAX_OVERLOADED_RETRIES", "FLOOR_OUTPUT_TOKENS", ] API_ERROR_MESSAGE_PREFIX = "API Error" PROMPT_TOO_LONG_ERROR_MESSAGE = "Prompt is too long" REPEATED_OVERLOADED_ERROR_MESSAGE = "Repeated overloaded errors" API_TIMEOUT_ERROR_MESSAGE = "Request timed out" DEFAULT_MAX_RETRIES = 10 BASE_DELAY_MS = 500 MAX_OVERLOADED_RETRIES = 3 FLOOR_OUTPUT_TOKENS = 3000 def get_default_max_retries() -> int: """Return the default max retries. Override with ``OPENSPACE_MAX_RETRIES``.""" import os as _os env_val = _os.environ.get("OPENSPACE_MAX_RETRIES") if env_val is not None: try: return int(env_val) except (ValueError, TypeError): pass return DEFAULT_MAX_RETRIES @dataclass class RetryContext: """Mutable context threaded through the retry loop. The agent loop reads ``max_tokens_override`` after a ``CannotRetryError`` to decide whether to retry with a reduced output-token budget. """ model: str max_tokens_override: int | None = None thinking_budget_tokens: int = 0 class PromptTooLongError(Exception): """Prompt exceeded the model context window. The agent lop catches this to trigger context compression instead of retrying the same payload. ``error_details`` stores the raw API message so ``get_prompt_too_long_token_gap`` can parse token counts. """ def __init__( self, message: str, *, model: str | None = None, provider: str | None = None, original_error: Exception | None = None, error_details: str | None = None, ) -> None: super().__init__(message) self.model = model self.provider = provider self.original_error = original_error self.error_details = error_details or message class ModelNotAvailableError(Exception): """Model or credentials are unavailable — should NOT be retried. Covers: bad API key (401), forbidden (403), model not found (404), invalid model name (400). """ def __init__( self, message: str, *, model: str | None = None, provider: str | None = None, original_error: Exception | None = None, ) -> None: super().__init__(message) self.model = model self.provider = provider self.original_error = original_error class OverloadedError(Exception): """Server returned 529 / overloaded. LiteLLM surfaces this as ``ServiceUnavailableError`` or status 529. """ def __init__( self, message: str, *, model: str | None = None, provider: str | None = None, original_error: Exception | None = None, ) -> None: super().__init__(message) self.model = model self.provider = provider self.original_error = original_error class CannotRetryError(Exception): """All retries exhausted — wraps the last error with retry context. The agent loop inspects ``retry_context.max_tokens_override`` to decide whether an output-budget reduction is viable before surfacing the error. """ def __init__( self, original_error: BaseException | None, retry_context: RetryContext, ) -> None: msg = str(original_error) if original_error else "Retries exhausted" super().__init__(msg) self.original_error = original_error self.retry_context = retry_context if isinstance(original_error, BaseException) and original_error.__traceback__: self.__traceback__ = original_error.__traceback__ class FallbackTriggeredError(Exception): """Model fallback triggered after repeated overloaded errors. The caller should re-issue the request with ``fallback_model``. """ def __init__(self, original_model: str, fallback_model: str) -> None: super().__init__( f"Model fallback triggered: {original_model} -> {fallback_model}" ) self.original_model = original_model self.fallback_model = fallback_model _PTL_PATTERN = re.compile( r"prompt is too long[^0-9]*(\d+)\s*tokens?\s*>\s*(\d+)", re.IGNORECASE, ) def parse_prompt_too_long_token_counts( raw_message: str, ) -> tuple[int | None, int | None]: """Parse actual/limit token counts from a prompt-too-long error. Handles: - Anthropic: ``"prompt is too long: 137500 tokens > 135000"`` - OpenAI: ``"maximum context length is 128000 … resulted in 200008"`` - Gemini/Vertex: ``"input token count (1100000) exceeds the maximum number of tokens allowed (1048576)"`` Returns ``(actual_tokens, limit_tokens)`` — either may be ``None``. """ m = _PTL_PATTERN.search(raw_message) if m: return (int(m.group(1)), int(m.group(2))) m2 = _CONTEXT_LENGTH_RE.search(raw_message) if m2: return (int(m2.group(2)), int(m2.group(1))) m3 = _GEMINI_PTL_RE.search(raw_message) if m3: return (int(m3.group(1)), int(m3.group(2))) return (None, None) _CONTEXT_LENGTH_RE = re.compile( r"maximum context length is (\d+) tokens.*?(\d+) tokens", re.IGNORECASE, ) # Gemini / Vertex AI: "input token count (1100000) exceeds the maximum # number of tokens allowed (1048576)" — parenthesised numbers. _GEMINI_PTL_RE = re.compile( r"input token count\s*\(?(\d+)\)?\s*exceeds.*?" r"tokens?\s*allowed\s*\(?(\d+)\)?", re.IGNORECASE | re.DOTALL, ) def get_prompt_too_long_token_gap(error: PromptTooLongError) -> int | None: """How many tokens over the limit a prompt-too-long error reports. Context compression uses this gap to skip past multiple message groups instead of peeling one-at-a-time. Returns positive gap, or ``None`` if not parseable. """ if not error.error_details: return None actual, limit = parse_prompt_too_long_token_counts(error.error_details) if actual is None or limit is None: return None gap = actual - limit return gap if gap > 0 else None _MAX_TOKENS_OVERFLOW_RE = re.compile( r"(?:" r"input length and `?max_tokens`? exceed context limit:\s*(\d+)\s*\+\s*(\d+)\s*>\s*(\d+)" r"|" r"max_tokens is too large:\s*(\d+).*?supports at most (\d+)" r")" ) def parse_max_tokens_context_overflow_error( error: Exception, ) -> dict[str, int] | None: """Parse token counts from a max_tokens overflow error. Handles two formats observed from real API responses: 1. Anthropic-style: ``"input length and max_tokens exceed context limit: X + Y > Z"`` 2. OpenAI-style: ``"max_tokens is too large: 200000. This model supports at most 16384"`` Returns ``{"input_tokens": …, "max_tokens": …, "context_limit": …}`` or ``None`` when the error is not a max-tokens overflow. """ status = _get_error_status(error) if status is not None and status != 400: return None msg = str(error) m = _MAX_TOKENS_OVERFLOW_RE.search(msg) if not m: return None if m.group(1) is not None: return { "input_tokens": int(m.group(1)), "max_tokens": int(m.group(2)), "context_limit": int(m.group(3)), } requested = int(m.group(4)) supported = int(m.group(5)) return { "input_tokens": 0, "max_tokens": requested, "context_limit": supported, } def _get_error_status(error: Exception) -> int | None: """Best-effort HTTP status extraction across LiteLLM/OpenAI/httpx errors.""" for attr in ("status_code", "status"): val = getattr(error, attr, None) if isinstance(val, int): return val resp = getattr(error, "response", None) if resp is not None: sc = getattr(resp, "status_code", None) if isinstance(sc, int): return sc return None def _get_error_provider(error: Exception) -> str | None: """Best-effort provider name extraction from a LiteLLM exception.""" provider = getattr(error, "llm_provider", None) return provider if isinstance(provider, str) and provider else None def is_overloaded_error(error: Exception) -> bool: """True if the error represents server overload (529 / ServiceUnavailable). Verified: LiteLLM raises ``ServiceUnavailableError`` or sets status 529. """ try: import litellm.exceptions as le if isinstance(error, le.ServiceUnavailableError): return True except ImportError: pass status = _get_error_status(error) if status == 529: return True if '"type":"overloaded_error"' in str(error): return True return False def is_prompt_too_long_error(error: Exception) -> bool: """True if the error indicates prompt exceeds the context window. Verified against real responses: - OpenAI direct: ``ContextWindowExceededError`` (400) with ``"maximum context length is X tokens … resulted in Y tokens"`` - OpenRouter: ``BadRequestError`` (400) with ``"maximum context length is X tokens"`` nested in JSON - Anthropic (native): ``"prompt is too long: X tokens > Y"`` - Gemini (google.genai): ``"input token count (X) exceeds the maximum number of tokens allowed (Y)"`` - Vertex AI: ``"Request payload size exceeds the limit"`` (byte-level, treated as PTL so the compaction/error path can handle it) """ try: import litellm.exceptions as le if isinstance(error, le.ContextWindowExceededError): return True except ImportError: pass msg_lower = str(error).lower() if "prompt is too long" in msg_lower: return True if "maximum context length" in msg_lower: return True if "context length exceeded" in msg_lower: return True if "input token count" in msg_lower and "exceeds" in msg_lower: return True if "exceeds the maximum number of tokens" in msg_lower: return True if "request payload size exceeds the limit" in msg_lower: return True return False def is_model_not_available_error(error: Exception) -> bool: """True when the error indicates model or credentials are unavailable. Verified: LiteLLM raises ``AuthenticationError`` (401), ``NotFoundError`` (404), or ``BadRequestError`` (400) with "not a valid model". """ try: import litellm.exceptions as le if isinstance(error, le.AuthenticationError): return True if isinstance(error, le.NotFoundError): return True except ImportError: pass status = _get_error_status(error) if status in (401, 403): return True if status == 404: return True msg_lower = str(error).lower() if ( "not a valid model" in msg_lower or "invalid model" in msg_lower or "does not exist" in msg_lower ): return True return False def classify_api_error(error: Exception) -> str: """Classify an API error into a tag for log messages. Only includes categories that are reachable through LiteLLM. Returns one of: ``aborted``, ``api_timeout``, ``repeated_overloaded``, ``rate_limit``, ``server_overload``, ``prompt_too_long``, ``invalid_model``, ``auth_error``, ``connection_error``, ``server_error``, ``client_error``, ``unknown``. """ import asyncio if isinstance(error, asyncio.CancelledError): return "aborted" # Timeout if isinstance(error, (TimeoutError, asyncio.TimeoutError)): return "api_timeout" try: import litellm.exceptions as le if isinstance(error, le.Timeout): return "api_timeout" except ImportError: pass msg = str(error) if REPEATED_OVERLOADED_ERROR_MESSAGE in msg: return "repeated_overloaded" # Rate limit (429) try: import litellm.exceptions as le if isinstance(error, le.RateLimitError): return "rate_limit" except ImportError: pass status = _get_error_status(error) if status == 429: return "rate_limit" if is_overloaded_error(error): return "server_overload" if is_prompt_too_long_error(error): return "prompt_too_long" if is_model_not_available_error(error): if ( status == 404 or "does not exist" in msg.lower() or "not a valid model" in msg.lower() or "invalid model" in msg.lower() ): return "invalid_model" return "auth_error" # Connection errors try: import litellm.exceptions as le if isinstance(error, le.APIConnectionError): return "connection_error" except ImportError: pass msg_lower = msg.lower() if any( kw in msg_lower for kw in ("connection refused", "connection reset", "connectionerror", "name resolution", "network unreachable") ): return "connection_error" if status is not None: if status >= 500: return "server_error" if status >= 400: return "client_error" return "unknown" def categorize_retryable_api_error(error: Exception) -> str: """Backward-compatible retry category helper. Older callers and tests used this name for the small retry analytics surface. Keep it as a thin adapter over the canonical classifier. """ tag = classify_api_error(error) if tag == "auth_error": return "authentication_failed" return tag def should_retry(error: Exception) -> bool: """Decide whether *error* is worth retrying. Non-retryable errors (prompt too long, auth failure, model not found) are handled before this function is called — they raise specific exception types immediately. This function handles the remaining transient / recoverable errors. """ if is_overloaded_error(error): return True if parse_max_tokens_context_overflow_error(error) is not None: return True try: import litellm.exceptions as le if isinstance(error, le.APIConnectionError): return True except ImportError: pass status = _get_error_status(error) if status is None: msg_lower = str(error).lower() return any( kw in msg_lower for kw in ("connection refused", "connection reset", "connectionerror", "cannot connect", "name resolution", "temporary failure", "network unreachable") ) if status == 408: # Timeout return True if status == 409: # Lock conflict return True if status == 429: # Rate limit return True if status in (401, 403): # LiteLLM may refresh credentials return True if status >= 500: # Server errors return True return False def get_retry_delay( attempt: int, retry_after_header: str | None = None, max_delay_ms: int = 32_000, ) -> int: """Compute retry delay in ms with exponential backoff + jitter. ``base = min(BASE_DELAY_MS * 2^(attempt-1), max_delay_ms)`` ``jitter = random(0, 0.25 * base)`` If *retry_after_header* is present and parseable, it is used verbatim. """ if retry_after_header: try: seconds = int(retry_after_header) if seconds > 0: return seconds * 1000 except (ValueError, TypeError): pass base_delay = min(BASE_DELAY_MS * (2 ** (attempt - 1)), max_delay_ms) jitter = random.random() * 0.25 * base_delay return int(base_delay + jitter) def get_retry_after_ms(error: Exception) -> int | None: """Extract retry-after delay in ms from error headers. LiteLLM exceptions may expose headers via ``.response`` or ``.headers``. """ headers = getattr(error, "headers", None) if headers is not None: val = _extract_retry_after_value(headers) if val is not None: return val resp = getattr(error, "response", None) if resp is not None: resp_headers = getattr(resp, "headers", None) if resp_headers is not None: val = _extract_retry_after_value(resp_headers) if val is not None: return val return None def _extract_retry_after_value(headers: Any) -> int | None: """Parse retry-after seconds → ms from a header-like object.""" raw = None if hasattr(headers, "get"): raw = headers.get("retry-after") or headers.get("Retry-After") elif isinstance(headers, dict): raw = headers.get("retry-after") or headers.get("Retry-After") if raw is not None: try: seconds = int(raw) if seconds > 0: return seconds * 1000 except (ValueError, TypeError): pass return None def to_prompt_too_long_error( error: Exception, *, model: str | None = None, ) -> PromptTooLongError: """Wrap a raw API error as ``PromptTooLongError``.""" return PromptTooLongError( str(error), model=model, provider=_get_error_provider(error), original_error=error, error_details=str(error), ) def to_model_not_available_error( error: Exception, *, model: str | None = None, ) -> ModelNotAvailableError: """Wrap a raw API error as ``ModelNotAvailableError``.""" return ModelNotAvailableError( str(error), model=model, provider=_get_error_provider(error), original_error=error, ) def to_overloaded_error( error: Exception, *, model: str | None = None, ) -> OverloadedError: """Wrap a raw API error as ``OverloadedError``.""" return OverloadedError( str(error), model=model, provider=_get_error_provider(error), original_error=error, ) # ═══════════════════════════════════════════════════════════════════════ # Functions consumed by step 7.1 — Agent Loop error handling # Implementation: utils/errors.ts isAbortError, services/api/errors.ts # getAssistantMessageFromError + classifyAPIError + formatAPIError # ═══════════════════════════════════════════════════════════════════════ def is_abort_error(error: Exception) -> bool: """True if the error represents an intentional abort / cancellation. Implementation: ``utils/errors.ts`` ``isAbortError`` (L27-33). Checks for ``asyncio.CancelledError`` and common abort patterns. """ import asyncio if isinstance(error, (asyncio.CancelledError, KeyboardInterrupt)): return True name = getattr(error, "name", "") or type(error).__name__ if name == "AbortError": return True msg = str(error).lower() if "request was aborted" in msg or "aborted" == msg.strip(): return True return False def is_media_size_error(error: Exception) -> bool: """True if the error is about an image/PDF exceeding size limits. Implementation: ``ImageSizeError``, ``ImageResizeError``, PDF page limit, etc. LiteLLM/OpenRouter surface these as 400 BadRequestError with specific message patterns. """ msg = str(error).lower() if "image exceeds" in msg and "maximum" in msg: return True if "maximum of" in msg and "pdf pages" in msg: return True if "image dimensions exceed" in msg: return True if "pdf" in msg and ("password protected" in msg or "not valid" in msg): return True return False _HTML_TITLE_RE = re.compile(r"