OpenSpace/openspace/llm/errors.py
2026-07-17 11:43:42 +08:00

949 lines
30 KiB
Python

"""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"<title[^>]*>\s*(.*?)\s*</title>", re.IGNORECASE | re.DOTALL)
_HTML_TAG_RE = re.compile(r"<[^>]+>")
def sanitize_error_html(message: str) -> str:
"""Collapse HTML error payloads to a short human-readable message."""
import html as _html
raw = str(message)
title_match = _HTML_TITLE_RE.search(raw)
if title_match:
title = _HTML_TAG_RE.sub("", title_match.group(1))
return _html.unescape(re.sub(r"\s+", " ", title)).strip()
if "<html" not in raw.lower() and "<!doctype" not in raw.lower():
return raw
text = _HTML_TAG_RE.sub(" ", raw)
text = _html.unescape(re.sub(r"\s+", " ", text)).strip()
return text or raw
def format_api_error(error: Exception) -> str:
"""Format an API error into a single-line human-readable description.
Implementation: ``formatAPIError`` in ``services/api/errors.ts``.
Used by the agent loop to build user-facing error messages.
Extracts HTTP status, provider name, and the most informative part
of the error message, removing JSON noise and internal stack info.
"""
status = _get_error_status(error)
provider = _get_error_provider(error)
raw = str(error)
# LiteLLM often wraps the real message in JSON — try to extract it
inner = _extract_inner_error_message(raw) or sanitize_error_html(raw)
parts: list[str] = []
if status:
parts.append(str(status))
if provider:
parts.append(provider)
parts.append(inner or raw[:500])
return " · ".join(parts)
def _extract_inner_error_message(raw: str) -> str | None:
"""Try to pull the most informative piece from a LiteLLM error string.
LiteLLM wraps provider errors in verbose multi-line strings.
We try several heuristics to extract the real message.
"""
# Pattern: "litellm.XXXError: ... message" → take after last colon
# but only if it's informative
# Try JSON extraction (LiteLLM sometimes JSON-stringifies the body)
try:
# Look for {"message": "..."} or {"error": {"message": "..."}}
for pattern in (
re.compile(r'"message"\s*:\s*"([^"]{5,})"'),
re.compile(r'"error"\s*:\s*\{[^}]*"message"\s*:\s*"([^"]{5,})"'),
):
m = pattern.search(raw)
if m:
return m.group(1)
except Exception:
pass
# Trim the verbose LiteLLM prefix
if "litellm." in raw.lower():
idx = raw.rfind(": ")
if idx > 0 and len(raw) - idx > 10:
candidate = raw[idx + 2:].strip()
if len(candidate) > 10:
return candidate[:500]
return None
def get_error_message_for_user(
error: Exception,
model: str = "unknown",
) -> str:
"""Build a user-friendly error message from an API exception.
Implementation: ``getAssistantMessageFromError`` (errors.ts L425-934).
Maps 12 error categories to clear, actionable messages.
The returned string is suitable for ``build_assistant_api_error_message``.
Categories handled (mapped from OpenSpace, adapted for LiteLLM/OpenRouter):
1. timeout
2. prompt_too_long
3. image/PDF size exceeded
4. tool_use/tool_result pairing error
5. duplicate_tool_use_id
6. invalid_model (404 / bad model name)
7. credit_balance_low
8. auth_error (401/403)
9. connection_error
10. request_too_large (413)
11. rate_limit (429)
12. unknown / generic
"""
import asyncio
msg = str(error)
msg_lower = msg.lower()
status = _get_error_status(error)
# 0. Abort — not really an error
if is_abort_error(error):
return "Request was cancelled."
# 1. Timeout
if isinstance(error, (TimeoutError, asyncio.TimeoutError)):
return API_TIMEOUT_ERROR_MESSAGE
try:
import litellm.exceptions as le
if isinstance(error, le.Timeout):
return API_TIMEOUT_ERROR_MESSAGE
except ImportError:
pass
if isinstance(error, OSError) and "timeout" in msg_lower:
return API_TIMEOUT_ERROR_MESSAGE
# 2. Prompt too long (already handled by PromptTooLongError in call_model,
# but this catches edge cases)
if isinstance(error, PromptTooLongError):
return PROMPT_TOO_LONG_ERROR_MESSAGE
# 3. Image / PDF size errors
if is_media_size_error(error):
if "pdf" in msg_lower:
return (
f"{API_ERROR_MESSAGE_PREFIX}: A PDF in the conversation exceeds "
"the size limit. Try using a smaller file or /compact to free context."
)
return (
f"{API_ERROR_MESSAGE_PREFIX}: An image in the conversation exceeds "
"the size limit. Try /compact to remove old images from context."
)
# 4. tool_use / tool_result pairing error
if (
status == 400
and "tool_use" in msg_lower
and "tool_result" in msg_lower
and ("without" in msg_lower or "immediately after" in msg_lower)
):
return (
f"{API_ERROR_MESSAGE_PREFIX}: 400 — tool_use/tool_result pairing "
"error in conversation history. This is a known edge case. "
"Try /compact to clean up the conversation."
)
# 5. Duplicate tool_use IDs
if status == 400 and "tool_use" in msg_lower and "must be unique" in msg_lower:
return (
f"{API_ERROR_MESSAGE_PREFIX}: 400 — duplicate tool_use ID in "
"conversation history. Try /compact to recover."
)
# 6. Invalid model name / model not found
if status == 404 or (status == 400 and "invalid model" in msg_lower):
return (
f"{API_ERROR_MESSAGE_PREFIX}: The model '{model}' is not available. "
"It may not exist or you may not have access to it. "
"Check your API provider configuration."
)
if "does not exist" in msg_lower and "model" in msg_lower:
return (
f"{API_ERROR_MESSAGE_PREFIX}: The model '{model}' does not exist. "
"Check your configuration."
)
# 7. Credit balance / billing
if "credit balance" in msg_lower and "too low" in msg_lower:
return (
f"{API_ERROR_MESSAGE_PREFIX}: Your API credit balance is too low. "
"Please add credits to your account."
)
if "billing" in msg_lower or "payment" in msg_lower:
return (
f"{API_ERROR_MESSAGE_PREFIX}: Billing issue — {format_api_error(error)}"
)
# 8. Auth errors (401 / 403)
if status in (401, 403):
return (
f"{API_ERROR_MESSAGE_PREFIX}: Authentication failed (HTTP {status}). "
"Check that your API key is valid and has the required permissions."
)
try:
import litellm.exceptions as le
if isinstance(error, le.AuthenticationError):
return (
f"{API_ERROR_MESSAGE_PREFIX}: Authentication failed. "
"Check your API key configuration."
)
except ImportError:
pass
# 9. Connection errors
try:
import litellm.exceptions as le
if isinstance(error, le.APIConnectionError):
return (
f"{API_ERROR_MESSAGE_PREFIX}: Connection error — "
f"{format_api_error(error)}"
)
except ImportError:
pass
if any(kw in msg_lower for kw in (
"connection refused", "connection reset", "name resolution",
"network unreachable", "connectionerror",
)):
return (
f"{API_ERROR_MESSAGE_PREFIX}: Network connection failed. "
"Check your internet connection and API endpoint."
)
# 10. Request too large (413)
if status == 413:
return (
f"{API_ERROR_MESSAGE_PREFIX}: Request too large (413). "
"The conversation + attachments exceed the API size limit. "
"Try /compact to reduce context size."
)
# 11. Rate limit (429)
if status == 429:
return (
f"{API_ERROR_MESSAGE_PREFIX}: Rate limit reached (429). "
"Please wait a moment before retrying."
)
try:
import litellm.exceptions as le
if isinstance(error, le.RateLimitError):
return (
f"{API_ERROR_MESSAGE_PREFIX}: Rate limit reached. "
"Please wait a moment before retrying."
)
except ImportError:
pass
# 11b. Overloaded / 529
if isinstance(error, OverloadedError) or is_overloaded_error(error):
return (
f"{API_ERROR_MESSAGE_PREFIX}: {REPEATED_OVERLOADED_ERROR_MESSAGE}. "
"The API server is overloaded. Please try again later."
)
# 12. Generic / unknown
formatted = format_api_error(error)
return f"{API_ERROR_MESSAGE_PREFIX}: {formatted}"