diff --git a/litellm/litellm_core_utils/core_helpers.py b/litellm/litellm_core_utils/core_helpers.py index b71ef4c8e06..33eef9d3ac3 100644 --- a/litellm/litellm_core_utils/core_helpers.py +++ b/litellm/litellm_core_utils/core_helpers.py @@ -58,15 +58,56 @@ def safe_divide( return numerator / denominator +def _is_litellm_limit_rejection(exception: BaseException) -> bool: + from litellm.exceptions import RateLimitErrorCategory + + litellm_limit_categories: Final = frozenset( + (RateLimitErrorCategory.LITELLM_RATE_LIMIT.value, RateLimitErrorCategory.LITELLM_BATCH_RATE_LIMIT.value) + ) + return getattr(exception, "category", None) in litellm_limit_categories + + +def _is_proxy_rejection(exception: BaseException) -> bool: + if _is_litellm_limit_rejection(exception): + return True + try: + from starlette.exceptions import HTTPException + except ImportError: + return False + return isinstance(exception, HTTPException) + + +def _is_provider_originated(exception: BaseException) -> bool: + if _is_proxy_rejection(exception): + return False + if getattr(exception, "llm_provider", None): + return True + from litellm.llms.base_llm.chat.transformation import BaseLLMException + + return isinstance(exception, BaseLLMException) + + def is_expected_client_error(exception: BaseException | None) -> bool: """ - True when the exception maps to an HTTP 4xx status. + True when the proxy itself rejected the request with an HTTP 4xx before any + provider call (bad key, budget, unknown model, guardrail). A 4xx returned by + a provider is an upstream or deployment problem, so it is never an expected + client error and keeps its traceback: a mapped litellm exception carries + ``llm_provider``, and the raw ``BaseLLMException`` that provider handlers + raise before mapping (the /v1/messages route surfaces it as-is) is one too. + The proxy's own limiters raise ``HTTPException`` subclasses that also carry + an ``llm_provider``, so any ``HTTPException`` stays a proxy rejection, and + so does any exception whose unified rate-limit ``category`` names litellm's + own limiter (``BudgetExceededError`` is a plain ``Exception`` that the auth + handler decorates with the requested model's provider). ProxyException stores the status on .code (as a str), HTTPException and litellm exceptions on .status_code. """ if exception is None: return False + if _is_provider_originated(exception): + return False code: Final[object] = getattr(exception, "code", None) status_code: Final[object] = code if code is not None else getattr(exception, "status_code", None) if status_code is None or isinstance(status_code, bool): diff --git a/tests/test_litellm/integrations/otel/test_otel_v2_logger.py b/tests/test_litellm/integrations/otel/test_otel_v2_logger.py index 704a1d3a7bb..455d84c764f 100644 --- a/tests/test_litellm/integrations/otel/test_otel_v2_logger.py +++ b/tests/test_litellm/integrations/otel/test_otel_v2_logger.py @@ -330,6 +330,50 @@ def test_real_llm_failure_still_emitted(): assert span.status.status_code is StatusCode.ERROR +def test_provider_auth_failure_span_carries_stack_trace(): + """Regression for LIT-6163: a 401 the provider returned is not an expected + client error, so the error span built from the real failure payload keeps + ``litellm.provider.error.stack_trace`` alongside code and llm_provider.""" + from litellm.exceptions import AuthenticationError + from litellm.litellm_core_utils.litellm_logging import StandardLoggingPayloadSetup + + try: + raise AuthenticationError( + message="AnthropicException - API key is invalid.", llm_provider="anthropic", model="claude-haiku-4-5" + ) + except AuthenticationError as caught: + error_information = StandardLoggingPayloadSetup.get_error_information(caught) + logger, exporter = _logger() + payload = _payload(status="failure", custom_llm_provider="anthropic", error_information=error_information) + _emit_llm(logger, _kwargs(payload=payload), fail=True) + (span,) = exporter.get_finished_spans() + assert span.status.status_code is StatusCode.ERROR + assert span.attributes["error.type"] == "AuthenticationError" + assert span.attributes["litellm.provider.error.code"] == "401" + assert span.attributes["litellm.provider.error.llm_provider"] == "anthropic" + assert "test_otel_v2_logger" in span.attributes["litellm.provider.error.stack_trace"] + + +def test_unmapped_provider_auth_failure_span_carries_stack_trace(): + """Regression for LIT-6163 on /v1/messages: that route logs the provider's + raw exception (no llm_provider), and its error span keeps the stack trace.""" + from litellm.litellm_core_utils.litellm_logging import StandardLoggingPayloadSetup + from litellm.llms.anthropic.common_utils import AnthropicError + + try: + raise AnthropicError(status_code=401, message='{"type":"authentication_error","message":"API key is invalid."}') + except AnthropicError as caught: + error_information = StandardLoggingPayloadSetup.get_error_information(caught) + logger, exporter = _logger() + payload = _payload(status="failure", custom_llm_provider="anthropic", error_information=error_information) + _emit_llm(logger, _kwargs(payload=payload), fail=True) + (span,) = exporter.get_finished_spans() + assert span.status.status_code is StatusCode.ERROR + assert span.attributes["error.type"] == "AnthropicError" + assert span.attributes["litellm.provider.error.code"] == "401" + assert "test_otel_v2_logger" in span.attributes["litellm.provider.error.stack_trace"] + + def test_idempotent_on_repeat_callback(): """The carrier is the dedup: once the async callback closes the span and clears the carrier, a second callback firing emits nothing.""" diff --git a/tests/test_litellm/litellm_core_utils/test_core_helpers.py b/tests/test_litellm/litellm_core_utils/test_core_helpers.py index 33a33711698..93cb01e1969 100644 --- a/tests/test_litellm/litellm_core_utils/test_core_helpers.py +++ b/tests/test_litellm/litellm_core_utils/test_core_helpers.py @@ -278,3 +278,59 @@ class TestIsExpectedClientError: assert is_expected_client_error(WithCode("invalid_request_error")) is False assert is_expected_client_error(Exception("no status")) is False assert is_expected_client_error(None) is False + + def test_provider_originated_4xx_is_not_expected(self): + """Regression for LIT-6163: a 4xx the provider returned is an upstream or + deployment problem, so it keeps its traceback; only the proxy's own + pre-call rejections (no llm_provider) are expected client errors.""" + from litellm.exceptions import AuthenticationError, RateLimitError + from litellm.litellm_core_utils.core_helpers import is_expected_client_error + from litellm.llms.anthropic.common_utils import AnthropicError + from litellm.proxy.common_utils.proxy_rate_limit_error import ProxyRateLimitError + + provider_auth_failure = AuthenticationError( + message="AnthropicException - API key is invalid.", llm_provider="anthropic", model="claude-haiku-4-5" + ) + assert is_expected_client_error(provider_auth_failure) is False + + provider_rate_limit = RateLimitError(message="rate limited upstream", llm_provider="openai", model="gpt-4o") + assert is_expected_client_error(provider_rate_limit) is False + + unmapped_provider_failure = AnthropicError(status_code=401, message='{"type":"authentication_error"}') + assert is_expected_client_error(unmapped_provider_failure) is False + + proxy_rate_limit = ProxyRateLimitError( + detail={"error": "Max parallel requests reached"}, model="claude-haiku-4-5", llm_provider="anthropic" + ) + assert proxy_rate_limit.llm_provider == "anthropic" + assert is_expected_client_error(proxy_rate_limit) is True + + class RouterRejection(Exception): + def __init__(self): + self.status_code = 429 + self.llm_provider = "" + + assert is_expected_client_error(RouterRejection()) is True + + def test_budget_rejection_decorated_with_provider_is_expected(self): + """The auth handler stamps the requested model's provider onto the proxy's + own BudgetExceededError before logging it, which must not turn a key-over-budget + 429 into a provider error that keeps its traceback.""" + from litellm.exceptions import BudgetExceededError, RateLimitError, RateLimitErrorCategory + from litellm.litellm_core_utils.core_helpers import is_expected_client_error + + over_budget = BudgetExceededError(current_cost=0.01, max_budget=0.0, llm_provider="anthropic") + assert over_budget.llm_provider == "anthropic" + assert is_expected_client_error(over_budget) is True + + litellm_limit = RateLimitError( + message="key over rpm", llm_provider="anthropic", model="claude-haiku-4-5", + category=RateLimitErrorCategory.LITELLM_RATE_LIMIT, + ) + assert is_expected_client_error(litellm_limit) is True + + vendor_limit = RateLimitError( + message="rate limited upstream", llm_provider="anthropic", model="claude-haiku-4-5", + category=RateLimitErrorCategory.VENDOR_RATE_LIMIT, + ) + assert is_expected_client_error(vendor_limit) is False diff --git a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py index f93daa61570..29b283ec009 100644 --- a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py +++ b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py @@ -5714,6 +5714,51 @@ def test_get_error_information_skips_traceback_for_expected_4xx(monkeypatch): assert "test_litellm_logging" in result["traceback"] +def test_get_error_information_keeps_traceback_for_provider_4xx(): + """Regression for LIT-6163: a 4xx the provider returned (invalid deployment + key, upstream validation) is an operator problem, so its traceback must + survive the expected-client-error gate and reach every payload consumer.""" + from litellm.litellm_core_utils.litellm_logging import StandardLoggingPayloadSetup + + assert litellm.log_client_error_tracebacks is False + provider_exc = _raise_and_catch( + litellm.AuthenticationError( + message="AnthropicException - API key is invalid.", llm_provider="anthropic", model="claude-haiku-4-5" + ) + ) + result = StandardLoggingPayloadSetup.get_error_information(provider_exc) + assert result["error_code"] == "401" + assert result["llm_provider"] == "anthropic" + assert "test_litellm_logging" in result["traceback"] + + +def test_get_error_information_keeps_traceback_for_unmapped_provider_4xx(): + """Regression for LIT-6163 on /v1/messages: that route logs the provider's + raw BaseLLMException (no llm_provider), which still keeps its traceback.""" + from litellm.litellm_core_utils.litellm_logging import StandardLoggingPayloadSetup + from litellm.llms.anthropic.common_utils import AnthropicError + + assert litellm.log_client_error_tracebacks is False + raw_provider_exc = _raise_and_catch(AnthropicError(status_code=401, message='{"type":"authentication_error"}')) + result = StandardLoggingPayloadSetup.get_error_information(raw_provider_exc) + assert result["error_code"] == "401" + assert result["error_class"] == "AnthropicError" + assert "test_litellm_logging" in result["traceback"] + + +def test_get_error_information_skips_traceback_for_budget_rejection_with_provider(): + """A key-over-budget 429 is the proxy's own rejection even after the auth + handler stamps the requested model's provider onto it, so it stays cheap.""" + from litellm.litellm_core_utils.litellm_logging import StandardLoggingPayloadSetup + + assert litellm.log_client_error_tracebacks is False + over_budget = _raise_and_catch(litellm.BudgetExceededError(current_cost=0.01, max_budget=0.0, llm_provider="anthropic")) + result = StandardLoggingPayloadSetup.get_error_information(over_budget) + assert result["error_code"] == "429" + assert result["llm_provider"] == "anthropic" + assert result["traceback"] == "" + + def test_failure_handler_helper_fn_builds_payload_once_per_exception(): """Regression for LIT-6043: async and sync failure handlers both call _failure_handler_helper_fn for the same failed request; the standardized