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] 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