Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions gateway/routes/allowlist.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,5 +118,9 @@
"/docs/oauth2-redirect",
"/redoc",
"/test",
# Claude Code telemetry stub (introduced upstream by #20504); hit
# by Claude Code clients as part of the `/v1/messages` data path,
# so it belongs on the gateway component.
"/api/event_logging/batch",
}
)
142 changes: 129 additions & 13 deletions litellm/anthropic_interface/exceptions/exception_mapping_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,76 @@
Similar to litellm/litellm_core_utils/exception_mapping_utils.py but for Anthropic response format.
"""

from litellm.litellm_core_utils.safe_json_loads import safe_json_loads
import json
import re
from typing import Dict, Optional

from litellm.litellm_core_utils.safe_json_loads import safe_json_loads

from .exceptions import AnthropicErrorResponse, AnthropicErrorType

# Leading `litellm.SomethingError: ` / `litellm.SomethingException: ` prefix that
# LiteLLM exception classes prepend to their `.message` (often stacked, e.g.
# `litellm.ContextWindowExceededError: litellm.BadRequestError: ...`).
_LITELLM_CLASS_PREFIX = re.compile(r"^\s*litellm\.\w+(?:Error|Exception):\s*")

# Provider exception prefix, e.g. `AnthropicException - {json}` /
# `VertexAIException - ...`. Appears once, right before the raw upstream body.
# Anchored to known provider names rather than `\w+Exception` so a generic
# `TimeoutException - <real error>` / `ConnectionException - <real error>`
# / `RequestException - <real error>` does NOT swallow the front of a
# legitimate runtime error string. Maintained from `litellm/llms/**`
# `<Name>Exception` classes plus common aliases LiteLLM emits.
_PROVIDER_EXCEPTION_NAMES = (
"Anthropic",
"AzureOpenAI",
"Azure",
"AWSBedrock",
"Bedrock",
"Cerebras",
"ClarifAI",
"Cohere",
"CometAPI",
"Databricks",
"DeepInfra",
"Deepgram",
"DeepSeek",
"Deepseek",
"ElevenLabs",
"FireworksAI",
"Fireworks",
"Gemini",
"Groq",
"HuggingFace",
"Huggingface",
"Hyperbolic",
"Minimax",
"MistralAudioTranscription",
"Mistral",
"NLPCloud",
"NvidiaRiva",
"OllamaChat",
"Ollama",
"OpenAI",
"OpenRouter",
"OVHCloud",
"Perplexity",
"Predibase",
"Replicate",
"Sambanova",
"ScalewayAudioTranscription",
"Snowflake",
"TogetherAI",
"Together",
"Topaz",
"VercelAIGateway",
"VertexAI",
"Watsonx",
"XAI",
)
_PROVIDER_EXCEPTION_PREFIX = re.compile(r"^\s*(?:" + "|".join(_PROVIDER_EXCEPTION_NAMES) + r")Exception\s*-\s*")


# HTTP status code -> Anthropic error type
# Source: https://docs.anthropic.com/en/api/errors
ANTHROPIC_ERROR_TYPE_MAP: Dict[int, AnthropicErrorType] = {
Expand All @@ -35,6 +100,32 @@ def get_error_type(status_code: int) -> AnthropicErrorType:
"""Map HTTP status code to Anthropic error type."""
return ANTHROPIC_ERROR_TYPE_MAP.get(status_code, "api_error")

@staticmethod
def _strip_litellm_wrapper_prefixes(raw_message: str) -> str:
"""
Strip LiteLLM/provider wrapper prefixes off an exception message so the
embedded upstream body (often a JSON string) is exposed.

LiteLLM exception classes prepend `litellm.<Class>: ` to `.message`,
sometimes stacked, and providers prepend `<Provider>Exception - `.
For example:

"litellm.RateLimitError: AnthropicException - {\"type\":\"error\",...}"
-> "{\"type\":\"error\",...}"

Idempotent: returns the input unchanged when no prefix is present.
"""
message = raw_message
# Strip stacked `litellm.XxxError: ` prefixes until none remain.
while True:
stripped = _LITELLM_CLASS_PREFIX.sub("", message, count=1)
if stripped == message:
break
message = stripped
# Strip a single `<Provider>Exception - ` prefix.
message = _PROVIDER_EXCEPTION_PREFIX.sub("", message, count=1)
return message

@staticmethod
def create_error_response(
status_code: int,
Expand Down Expand Up @@ -72,18 +163,15 @@ def extract_error_message(raw_message: str) -> str:
Extract error message from various provider response formats.

Handles:
- Bedrock: {"detail": {"message": "..."}}
- AWS: {"Message": "..."}
- Generic: {"message": "..."}
- Bedrock: {"detail": {"message": "..."}}
- AWS: {"Message": "..."}
- OpenAI / new-api: {"error": {"message": "...", ...}}
- Generic: {"message": "..."}
- Plain strings
"""
parsed = safe_json_loads(raw_message)
if isinstance(parsed, dict):
# Bedrock format
if "detail" in parsed and isinstance(parsed["detail"], dict):
return parsed["detail"].get("message", raw_message)
# AWS/generic format
return parsed.get("Message") or parsed.get("message") or raw_message
return AnthropicExceptionMapping._extract_message_from_dict(parsed, raw_message)
return raw_message

@staticmethod
Expand All @@ -110,13 +198,24 @@ def _extract_message_from_dict(parsed: dict, raw_message: str) -> str:
Extract error message from a parsed provider-specific dict.

Handles:
- Bedrock: {"detail": {"message": "..."}}
- AWS: {"Message": "..."}
- Generic: {"message": "..."}
- Bedrock: {"detail": {"message": "..."}}
- AWS: {"Message": "..."}
- OpenAI / new-api: {"error": {"message": "...", ...}}
- Generic: {"message": "..."}

Falls back to ``raw_message`` only when no recognized message field
is present, so an upstream JSON body's clean message is preferred
over a raw string that may carry post-decode debug suffixes.
"""
# Bedrock format
if "detail" in parsed and isinstance(parsed["detail"], dict):
return parsed["detail"].get("message", raw_message)
# OpenAI / new-api / OpenAI-compatible nested error
err = parsed.get("error")
if isinstance(err, dict):
nested = err.get("message")
if isinstance(nested, str) and nested:
return nested
# AWS/generic format
return parsed.get("Message") or parsed.get("message") or raw_message

Expand All @@ -142,11 +241,28 @@ def transform_to_anthropic_error(
Returns:
AnthropicErrorResponse dict
"""
# Try to parse as JSON once
# Strip LiteLLM/provider wrapper prefixes so an embedded upstream
# Anthropic error body can be detected and passed through unchanged.
raw_message = AnthropicExceptionMapping._strip_litellm_wrapper_prefixes(raw_message)

# Try to parse as JSON once.
parsed: Optional[dict] = safe_json_loads(raw_message)
if not isinstance(parsed, dict):
parsed = None

# Fallback for messages where an Anthropic-shaped JSON body is
# followed by appended debug text (e.g. the Router's
# ". Received Model Group=...\nAvailable Model Group Fallbacks=..."
# suffix). `safe_json_loads` rejects trailing garbage; `raw_decode`
# parses the leading JSON value and ignores anything after it.
if parsed is None:
try:
obj, _ = json.JSONDecoder().raw_decode(raw_message.lstrip())
if isinstance(obj, dict):
parsed = obj
except json.JSONDecodeError:
pass

# If parsed and already in Anthropic format - passthrough
if parsed is not None and AnthropicExceptionMapping._is_anthropic_error_dict(parsed):
# Optionally add request_id if provided and not present
Expand Down
64 changes: 45 additions & 19 deletions litellm/proxy/anthropic_endpoints/endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,14 @@ def _strip_total_tokens_from_anthropic_response(response: Any) -> None:
usage.pop("total_tokens", None)


def _anthropic_error_response(status_code: int, raw_message: str) -> JSONResponse:
body = AnthropicExceptionMapping.transform_to_anthropic_error(
status_code=status_code,
raw_message=raw_message,
)
return JSONResponse(status_code=status_code, content=body)


@router.post(
"/v1/messages",
tags=["[beta] Anthropic `/v1/messages`"],
Expand Down Expand Up @@ -204,12 +212,32 @@ async def _passthrough_stream_generator():
litellm_logging_obj=None,
)

error_msg = f"{str(e)}"
raise ProxyException(
message=getattr(e, "message", error_msg),
type=getattr(e, "type", "None"),
param=getattr(e, "param", "None"),
code=getattr(e, "status_code", 500),
# Return an Anthropic-shaped error body (not the OpenAI-shaped
# ProxyException envelope) so Anthropic SDK clients can switch on
# error.error.type. Use JSONResponse directly: HTTPException(detail=...)
# would wrap the dict in a spurious {"detail": ...} envelope.
# ProxyException stores its HTTP code on `.code` (string), litellm
# provider exceptions store it on `.status_code` (int). Read both,
# matching the `count_tokens` handler below.
proxy_code = getattr(e, "code", None)
if isinstance(proxy_code, str) and proxy_code.isdigit():
status_code = int(proxy_code)
else:
status_code = int(getattr(e, "status_code", 500) or 500)
# `getattr(e, "message", str(e))` returns None when .message is
# explicitly None (e.g. `litellm.BadRequestError(message=None)`).
# That None would crash `re.sub(..., None)` in
# `_strip_litellm_wrapper_prefixes`.
raw_message = getattr(e, "message", None)
if raw_message is None:
raw_message = str(e)
anthropic_error = AnthropicExceptionMapping.transform_to_anthropic_error(
status_code=status_code,
raw_message=raw_message,
)
return JSONResponse(
status_code=status_code,
content=anthropic_error,
headers=headers,
)

Expand Down Expand Up @@ -254,10 +282,10 @@ async def count_tokens(
messages = data.get("messages", [])

if not model_name:
raise HTTPException(status_code=400, detail={"error": "model parameter is required"})
return _anthropic_error_response(400, "model parameter is required")

if not messages:
raise HTTPException(status_code=400, detail={"error": "messages parameter is required"})
return _anthropic_error_response(400, "messages parameter is required")

# Create TokenCountRequest for the internal endpoint
from litellm.proxy._types import TokenCountRequest
Expand All @@ -283,23 +311,21 @@ async def count_tokens(
# Convert the internal response to Anthropic API format
return {"input_tokens": _token_response_dict.get("total_tokens", 0)}

except HTTPException:
raise
except HTTPException as e:
if isinstance(e.detail, dict):
raw_message = str(e.detail.get("error") or e.detail.get("message") or e.detail)
else:
raw_message = str(e.detail)
return _anthropic_error_response(e.status_code, raw_message)
except ProxyException as e:
status_code = int(e.code) if e.code and e.code.isdigit() else 500
detail = AnthropicExceptionMapping.transform_to_anthropic_error(
status_code=status_code,
raw_message=e.message,
)
raise HTTPException(
status_code=status_code,
detail=detail,
)
raw_message = e.message if e.message is not None else str(e)
return _anthropic_error_response(status_code, raw_message)
except Exception as e:
verbose_proxy_logger.exception(
"litellm.proxy.anthropic_endpoints.count_tokens(): Exception occurred - {}".format(str(e))
)
raise HTTPException(status_code=500, detail={"error": f"Internal server error: {str(e)}"})
return _anthropic_error_response(500, str(e))


@router.post(
Expand Down
Loading
Loading