From 68f5cbd52296baae115e20f7893fe5428ecf9ade Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Tue, 25 Aug 2026 20:09:31 -0700 Subject: [PATCH 1/4] fix(logging): keep tracebacks for provider-originated 4xx errors is_expected_client_error treated every HTTP 4xx as a rejection the proxy issued itself, so a 401 or 429 the provider returned lost its traceback in the standard logging payload and the OTel error span dropped litellm.provider.error.stack_trace. An exception carrying llm_provider is an upstream or deployment problem and keeps its traceback; the proxy's own pre-call rejections still skip it --- litellm/litellm_core_utils/core_helpers.py | 8 ++++++- .../integrations/otel/test_otel_v2_logger.py | 24 +++++++++++++++++++ .../litellm_core_utils/test_core_helpers.py | 22 +++++++++++++++++ .../test_litellm_logging.py | 18 ++++++++++++++ 4 files changed, 71 insertions(+), 1 deletion(-) diff --git a/litellm/litellm_core_utils/core_helpers.py b/litellm/litellm_core_utils/core_helpers.py index b71ef4c8e06..b669ab94350 100644 --- a/litellm/litellm_core_utils/core_helpers.py +++ b/litellm/litellm_core_utils/core_helpers.py @@ -60,13 +60,19 @@ def safe_divide( 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 (the exception carries ``llm_provider``) is an upstream or + deployment problem, so it is never an expected client error and keeps its + traceback. ProxyException stores the status on .code (as a str), HTTPException and litellm exceptions on .status_code. """ if exception is None: return False + if getattr(exception, "llm_provider", None): + 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..1bc02bab89a 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,30 @@ 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_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..a3c9aadd4d9 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,25 @@ 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 + + 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 + + class RouterRejection(Exception): + def __init__(self): + self.status_code = 429 + self.llm_provider = "" + + assert is_expected_client_error(RouterRejection()) is True 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..812c91290dd 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,24 @@ 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_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 From ac6deec5292a288110a688a921ac6efa18d8c7b2 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Tue, 25 Aug 2026 20:25:38 -0700 Subject: [PATCH 2/4] fix(logging): keep tracebacks for unmapped provider exceptions too The /v1/messages route logs the provider's raw BaseLLMException, which carries no llm_provider, so its 4xx still counted as an expected client error and lost its traceback. Treat BaseLLMException as provider-originated as well. --- litellm/litellm_core_utils/core_helpers.py | 17 ++++++++++++---- .../integrations/otel/test_otel_v2_logger.py | 20 +++++++++++++++++++ .../litellm_core_utils/test_core_helpers.py | 4 ++++ .../test_litellm_logging.py | 14 +++++++++++++ 4 files changed, 51 insertions(+), 4 deletions(-) diff --git a/litellm/litellm_core_utils/core_helpers.py b/litellm/litellm_core_utils/core_helpers.py index b669ab94350..1089499a64f 100644 --- a/litellm/litellm_core_utils/core_helpers.py +++ b/litellm/litellm_core_utils/core_helpers.py @@ -58,20 +58,29 @@ def safe_divide( return numerator / denominator +def _is_provider_originated(exception: BaseException) -> bool: + 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 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 (the exception carries ``llm_provider``) is an upstream or - deployment problem, so it is never an expected client error and keeps its - traceback. + 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. ProxyException stores the status on .code (as a str), HTTPException and litellm exceptions on .status_code. """ if exception is None: return False - if getattr(exception, "llm_provider", None): + 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) 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 1bc02bab89a..455d84c764f 100644 --- a/tests/test_litellm/integrations/otel/test_otel_v2_logger.py +++ b/tests/test_litellm/integrations/otel/test_otel_v2_logger.py @@ -354,6 +354,26 @@ def test_provider_auth_failure_span_carries_stack_trace(): 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 a3c9aadd4d9..efbf7192a0e 100644 --- a/tests/test_litellm/litellm_core_utils/test_core_helpers.py +++ b/tests/test_litellm/litellm_core_utils/test_core_helpers.py @@ -285,6 +285,7 @@ class TestIsExpectedClientError: 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 provider_auth_failure = AuthenticationError( message="AnthropicException - API key is invalid.", llm_provider="anthropic", model="claude-haiku-4-5" @@ -294,6 +295,9 @@ class TestIsExpectedClientError: 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 + class RouterRejection(Exception): def __init__(self): self.status_code = 429 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 812c91290dd..5feaeff4408 100644 --- a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py +++ b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py @@ -5732,6 +5732,20 @@ def test_get_error_information_keeps_traceback_for_provider_4xx(): 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_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 From a6e1708e6c243a5e3434b135abb08132575cec68 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Tue, 25 Aug 2026 20:30:44 -0700 Subject: [PATCH 3/4] fix(logging): keep proxy-side rate limits as expected client errors ProxyRateLimitError derives from HTTPException but carries an llm_provider, so it read as provider-originated and regained its traceback. Any HTTPException is a proxy rejection regardless of llm_provider. --- litellm/litellm_core_utils/core_helpers.py | 12 ++++++++++++ .../litellm_core_utils/test_core_helpers.py | 7 +++++++ 2 files changed, 19 insertions(+) diff --git a/litellm/litellm_core_utils/core_helpers.py b/litellm/litellm_core_utils/core_helpers.py index 1089499a64f..9d5b0c86da6 100644 --- a/litellm/litellm_core_utils/core_helpers.py +++ b/litellm/litellm_core_utils/core_helpers.py @@ -58,7 +58,17 @@ def safe_divide( return numerator / denominator +def _is_proxy_rejection(exception: BaseException) -> bool: + 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 @@ -74,6 +84,8 @@ def is_expected_client_error(exception: BaseException | None) -> bool: 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. ProxyException stores the status on .code (as a str), HTTPException and litellm exceptions on .status_code. 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 efbf7192a0e..a1f3f5eac70 100644 --- a/tests/test_litellm/litellm_core_utils/test_core_helpers.py +++ b/tests/test_litellm/litellm_core_utils/test_core_helpers.py @@ -286,6 +286,7 @@ class TestIsExpectedClientError: 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" @@ -298,6 +299,12 @@ class TestIsExpectedClientError: 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 From 514cae1b3dc1ff72fdf5b10aa440e75b523c207d Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Tue, 25 Aug 2026 20:51:55 -0700 Subject: [PATCH 4/4] fix(logging): keep the proxy's own budget rejection an expected client error The auth handler stamps the requested model's provider onto BudgetExceededError before logging it, which made a key-over-budget 429 look provider-originated and regain its traceback (and an OTel stack_trace) after the provider 4xx carve-out. Any exception whose unified rate-limit category names litellm's own limiter is now a proxy rejection, matching the HTTPException rule. --- litellm/litellm_core_utils/core_helpers.py | 16 ++++++++++++- .../litellm_core_utils/test_core_helpers.py | 23 +++++++++++++++++++ .../test_litellm_logging.py | 13 +++++++++++ 3 files changed, 51 insertions(+), 1 deletion(-) diff --git a/litellm/litellm_core_utils/core_helpers.py b/litellm/litellm_core_utils/core_helpers.py index 9d5b0c86da6..33eef9d3ac3 100644 --- a/litellm/litellm_core_utils/core_helpers.py +++ b/litellm/litellm_core_utils/core_helpers.py @@ -58,7 +58,18 @@ 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: @@ -85,7 +96,10 @@ def is_expected_client_error(exception: BaseException | None) -> bool: ``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. + 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. 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 a1f3f5eac70..93cb01e1969 100644 --- a/tests/test_litellm/litellm_core_utils/test_core_helpers.py +++ b/tests/test_litellm/litellm_core_utils/test_core_helpers.py @@ -311,3 +311,26 @@ class TestIsExpectedClientError: 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 5feaeff4408..29b283ec009 100644 --- a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py +++ b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py @@ -5746,6 +5746,19 @@ def test_get_error_information_keeps_traceback_for_unmapped_provider_4xx(): 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