From df704d9016cd55ee11435a39c9ca465e4bf704ad Mon Sep 17 00:00:00 2001 From: Mateo Wang <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 4 Jun 2026 22:46:08 -0700 Subject: [PATCH] fix(proxy/hooks): populate llm_provider on internal rate-limit errors (#27707) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(proxy/hooks): add ProxyHTTPRateLimitError + provider resolver Introduces a small helper layer used by every proxy-side rate-limit hook so that the 429 they raise carries a populated llm_provider / model — instead of an empty exception.llm_provider that downstream loggers (Prometheus failure metric, observability callbacks) read as 'no provider attribution'. ProxyHTTPRateLimitError inherits from both fastapi.HTTPException (so the proxy server still renders it as a 429) and litellm.exceptions.RateLimitError (so isinstance checks and PrometheusLogger._get_exception_class_name pick up llm_provider). We deliberately don't call RateLimitError.__init__ — it constructs an httpx.Response we don't need and would just add failure surface; attribute parity is what downstream consumers care about. resolve_llm_provider_for_rate_limit() wraps litellm.get_llm_provider defensively. Internal limiter hooks fire from async_pre_call_hook — well before get_llm_provider runs anywhere else in the request lifecycle — so we have to call it ourselves at raise time. If the model is missing or unparseable (alias, router-only model) we fall back to llm_provider='litellm_proxy' rather than letting a second exception leak out and break the request path. Co-authored-by: Mateo Wang * fix(proxy/hooks): populate llm_provider on parallel-request 429s Both v1 and v3 parallel-request limiters fired bare HTTPException(429) from inside async_pre_call_hook. The downstream Prometheus failure metric reads exception.llm_provider via _get_exception_class_name — the empty value showed up as exception_class='HTTPException' and left model_id='None' on the time series. Threads requested_model through every raise site in: * parallel_request_limiter.py: - check_key_in_limits (the per-key/per-model/per-user/per-team/ per-customer over-limit path) - raise_rate_limit_error (zero-limit + global_max_parallel_requests paths) — now takes an optional requested_model kwarg * parallel_request_limiter_v3.py: - _handle_rate_limit_error (the OVER_LIMIT translator), called from both the should_rate_limit pre-check and the TPM reservation path Resolved via resolve_llm_provider_for_rate_limit so unknown / missing models silently fall back to llm_provider='litellm_proxy' instead of breaking the request path with a second exception. Co-authored-by: Mateo Wang * fix(proxy/hooks): populate llm_provider on dynamic-rate-limit 429s Same plumbing change as the parallel limiters, applied to both dynamic_rate_limiter (v1) and dynamic_rate_limiter_v3: * v1: TPM-zero and RPM-zero paths in async_pre_call_hook now resolve data['model'] -> (model, llm_provider) once and pass it into both raises. * v3: All three raise sites in _check_rate_limits — the model_saturation_check enforced raise, the priority_model enforced raise, and the fail-closed unknown-descriptor branch — now attribute the 429 to the actual provider. Falls back to llm_provider='litellm_proxy' when the model can't be resolved. Co-authored-by: Mateo Wang * fix(proxy/hooks): populate llm_provider on batch-rate-limit 429s batch_rate_limiter._raise_rate_limit_error now takes a requested_model kwarg threaded from data['model'] in _check_and_increment_batch_counters. The batch-creation 429 is what gets raised when the input file's tokens/requests count would push the per-key TPM/RPM window over its limit. Co-authored-by: Mateo Wang * fix(proxy/hooks): populate llm_provider on budget/iterations 429s Final batch of internal raise sites — the user/session-budget and max-iterations hooks. Same pattern: resolve data['model'] once at raise time, attach to ProxyHTTPRateLimitError so Prometheus and observability callbacks can attribute the 429. Hooks updated: * max_budget_limiter (per-user max_budget exceeded) * max_iterations_limiter (per-session agent iteration cap) * max_budget_per_session_limiter (per-session dollar cap) All three fall back to llm_provider='litellm_proxy' when data['model'] is missing or unparseable. Drops the now-unused HTTPException import from each module. Co-authored-by: Mateo Wang * test(proxy/hooks): pin provider field on internal rate-limit 429s Regression coverage for the 'provider field missing' bug across every proxy-side rate-limit hook + the helper layer: * ProxyHTTPRateLimitError class shape (HTTPException + RateLimitError, dict-detail stringification, None-provider normalization). * resolve_llm_provider_for_rate_limit happy paths (gpt-4o-mini, anthropic/..., bedrock/...) plus all three fallback branches (None, '', unknown name) plus a 'get_llm_provider raises' case that asserts we swallow the secondary exception. * For each limiter (parallel v1/v3, dynamic v1/v3, batch, max_budget, max_iterations, max_budget_per_session): assert the raised exception is a RateLimitError carrying the resolved model + llm_provider, and a sibling test that asserts the fallback path returns 'litellm_proxy' without leaking a second exception. * Two PrometheusLogger._get_exception_class_name pins so the Prometheus failure metric label flips from 'HTTPException' to 'Openai.ProxyHTTPRateLimitError' (or 'Litellm_proxy.*' on fallback) — that's what dashboards consume. Co-authored-by: Mateo Wang * perf(proxy/hooks): defer provider resolution to over-limit branches * fix: use error_message in raise_rate_limit_error to avoid literal 'None' in detail * Consolidate rate_limiter_utils imports in dynamic_rate_limiter * fix(proxy): set num_retries/max_retries on ProxyHTTPRateLimitError ProxyHTTPRateLimitError inherits from RateLimitError but did not call RateLimitError.__init__, so num_retries/max_retries were never set. When Starlette's HTTPException lacks __str__, MRO falls through to RateLimitError.__str__, which unconditionally reads these attributes and raises AttributeError during logging/traceback formatting. Initialize them to None defensively. * fix(mypy): silence base-class status_code conflict on ProxyHTTPRateLimitError HTTPException declares 'status_code: int' while openai.RateLimitError (via APIStatusError) declares 'status_code: Literal[429] = 429'. Mypy flags the multi-base override as [misc] in CI lint. The runtime semantics are fine (we set self.status_code in __init__), so silence the class-level annotation conflict with a targeted ignore. Co-authored-by: Mateo Wang --------- Co-authored-by: Cursor Agent Co-authored-by: Mateo Wang --- litellm/proxy/hooks/batch_rate_limiter.py | 14 +- litellm/proxy/hooks/dynamic_rate_limiter.py | 23 +- .../proxy/hooks/dynamic_rate_limiter_v3.py | 19 +- litellm/proxy/hooks/max_budget_limiter.py | 14 +- .../hooks/max_budget_per_session_limiter.py | 13 +- litellm/proxy/hooks/max_iterations_limiter.py | 13 +- .../proxy/hooks/parallel_request_limiter.py | 39 +- .../hooks/parallel_request_limiter_v3.py | 16 +- litellm/proxy/hooks/rate_limiter_utils.py | 96 +- .../test_proxy_rate_limit_provider_field.py | 968 ++++++++++++++++++ 10 files changed, 1186 insertions(+), 29 deletions(-) create mode 100644 tests/test_litellm/proxy/hooks/test_proxy_rate_limit_provider_field.py diff --git a/litellm/proxy/hooks/batch_rate_limiter.py b/litellm/proxy/hooks/batch_rate_limiter.py index 435b6eea45..df485411d7 100644 --- a/litellm/proxy/hooks/batch_rate_limiter.py +++ b/litellm/proxy/hooks/batch_rate_limiter.py @@ -32,6 +32,10 @@ from litellm.batches.batch_utils import ( ) from litellm.integrations.custom_logger import CustomLogger from litellm.proxy._types import SpecialModelNames, UserAPIKeyAuth +from litellm.proxy.hooks.rate_limiter_utils import ( + ProxyHTTPRateLimitError, + resolve_llm_provider_for_rate_limit, +) if TYPE_CHECKING: from opentelemetry.trace import Span as _Span @@ -375,6 +379,7 @@ class _PROXY_BatchRateLimiter(CustomLogger): descriptors: List["RateLimitDescriptor"], batch_usage: BatchFileUsage, limit_type: str, + requested_model: Optional[str] = None, ) -> None: """Raise HTTPException for rate limit exceeded.""" from datetime import datetime @@ -419,7 +424,10 @@ class _PROXY_BatchRateLimiter(CustomLogger): f"Limit resets at: {reset_time_formatted}" ) - raise HTTPException( + resolved_model, llm_provider = resolve_llm_provider_for_rate_limit( + requested_model + ) + raise ProxyHTTPRateLimitError( status_code=429, detail=detail, headers={ @@ -427,6 +435,8 @@ class _PROXY_BatchRateLimiter(CustomLogger): "rate_limit_type": limit_type, "reset_at": reset_time_formatted, }, + model=resolved_model, + llm_provider=llm_provider, ) async def _check_and_increment_batch_counters( @@ -470,6 +480,7 @@ class _PROXY_BatchRateLimiter(CustomLogger): ) if rate_limit_response["overall_code"] == "OVER_LIMIT": + requested_model = data.get("model") if data else None for status in rate_limit_response["statuses"]: if status["code"] == "OVER_LIMIT": self._raise_rate_limit_error( @@ -477,6 +488,7 @@ class _PROXY_BatchRateLimiter(CustomLogger): descriptors, batch_usage, status["rate_limit_type"], + requested_model=requested_model, ) async def count_input_file_usage( diff --git a/litellm/proxy/hooks/dynamic_rate_limiter.py b/litellm/proxy/hooks/dynamic_rate_limiter.py index f1c1d487cc..57cd538507 100644 --- a/litellm/proxy/hooks/dynamic_rate_limiter.py +++ b/litellm/proxy/hooks/dynamic_rate_limiter.py @@ -6,20 +6,21 @@ import asyncio import os from typing import List, Optional, Tuple, Union -from fastapi import HTTPException - import litellm from litellm import ModelResponse, Router from litellm._logging import verbose_proxy_logger from litellm.caching.caching import DualCache from litellm.integrations.custom_logger import CustomLogger from litellm.proxy._types import UserAPIKeyAuth +from litellm.proxy.hooks.rate_limiter_utils import ( + ProxyHTTPRateLimitError, + convert_priority_to_percent, + resolve_llm_provider_for_rate_limit, +) from litellm.types.router import ModelGroupInfo from litellm.types.utils import CallTypesLiteral from litellm.utils import get_utc_datetime -from .rate_limiter_utils import convert_priority_to_percent - class DynamicRateLimiterCache: """ @@ -218,7 +219,10 @@ class _PROXY_DynamicRateLimitHandler(CustomLogger): ) ### CHECK TPM ### if available_tpm is not None and available_tpm == 0: - raise HTTPException( + resolved_model, llm_provider = resolve_llm_provider_for_rate_limit( + data.get("model") + ) + raise ProxyHTTPRateLimitError( status_code=429, detail={ "error": "Key={} over available TPM={}. Model TPM={}, Active keys={}".format( @@ -228,10 +232,15 @@ class _PROXY_DynamicRateLimitHandler(CustomLogger): active_projects, ) }, + model=resolved_model, + llm_provider=llm_provider, ) ### CHECK RPM ### elif available_rpm is not None and available_rpm == 0: - raise HTTPException( + resolved_model, llm_provider = resolve_llm_provider_for_rate_limit( + data.get("model") + ) + raise ProxyHTTPRateLimitError( status_code=429, detail={ "error": "Key={} over available RPM={}. Model RPM={}, Active keys={}".format( @@ -241,6 +250,8 @@ class _PROXY_DynamicRateLimitHandler(CustomLogger): active_projects, ) }, + model=resolved_model, + llm_provider=llm_provider, ) elif available_rpm is not None or available_tpm is not None: ## UPDATE CACHE WITH ACTIVE PROJECT diff --git a/litellm/proxy/hooks/dynamic_rate_limiter_v3.py b/litellm/proxy/hooks/dynamic_rate_limiter_v3.py index 861083e7df..bfc6e2c2f7 100644 --- a/litellm/proxy/hooks/dynamic_rate_limiter_v3.py +++ b/litellm/proxy/hooks/dynamic_rate_limiter_v3.py @@ -19,7 +19,11 @@ from litellm.proxy.hooks.parallel_request_limiter_v3 import ( RateLimitDescriptorRateLimitObject, _PROXY_MaxParallelRequestsHandler_v3, ) -from litellm.proxy.hooks.rate_limiter_utils import convert_priority_to_percent +from litellm.proxy.hooks.rate_limiter_utils import ( + ProxyHTTPRateLimitError, + convert_priority_to_percent, + resolve_llm_provider_for_rate_limit, +) from litellm.proxy.utils import InternalUsageCache from litellm.types.router import ModelGroupInfo from litellm.types.utils import CallTypesLiteral @@ -487,12 +491,13 @@ class _PROXY_DynamicRateLimitHandlerV3(CustomLogger): ) if atomic_response["overall_code"] == "OVER_LIMIT": + resolved_model, llm_provider = resolve_llm_provider_for_rate_limit(model) for status in atomic_response["statuses"]: if status["code"] != "OVER_LIMIT": continue descriptor_key = status["descriptor_key"] if descriptor_key == "model_saturation_check": - raise HTTPException( + raise ProxyHTTPRateLimitError( status_code=429, detail={ "error": f"Model capacity reached for {model}. " @@ -507,13 +512,15 @@ class _PROXY_DynamicRateLimitHandlerV3(CustomLogger): "rate_limit_type": str(status["rate_limit_type"]), "x-litellm-priority": priority or "default", }, + model=resolved_model, + llm_provider=llm_provider, ) if descriptor_key == "priority_model": verbose_proxy_logger.debug( f"Enforcing priority limits for {model}, saturation: {saturation:.1%}, " f"priority: {priority}" ) - raise HTTPException( + raise ProxyHTTPRateLimitError( status_code=429, detail={ "error": f"Priority-based rate limit exceeded. " @@ -531,6 +538,8 @@ class _PROXY_DynamicRateLimitHandlerV3(CustomLogger): "x-litellm-priority": priority or "default", "x-litellm-saturation": f"{saturation:.2%}", }, + model=resolved_model, + llm_provider=llm_provider, ) # Fail-closed guard: overall_code says OVER_LIMIT but no status @@ -547,7 +556,7 @@ class _PROXY_DynamicRateLimitHandlerV3(CustomLogger): f"Dynamic rate limiter: OVER_LIMIT response with unknown " f"descriptor_key(s) — refusing request. response={atomic_response}" ) - raise HTTPException( + raise ProxyHTTPRateLimitError( status_code=429, detail={ "error": "Rate limit exceeded", @@ -562,6 +571,8 @@ class _PROXY_DynamicRateLimitHandlerV3(CustomLogger): "retry-after": str(self.v3_limiter.window_size), "x-litellm-priority": priority or "default", }, + model=resolved_model, + llm_provider=llm_provider, ) # If priority is NOT enforced (saturation below threshold) but diff --git a/litellm/proxy/hooks/max_budget_limiter.py b/litellm/proxy/hooks/max_budget_limiter.py index 9a7e511794..658d799563 100644 --- a/litellm/proxy/hooks/max_budget_limiter.py +++ b/litellm/proxy/hooks/max_budget_limiter.py @@ -5,6 +5,10 @@ from litellm._logging import verbose_proxy_logger from litellm.caching.caching import DualCache from litellm.integrations.custom_logger import CustomLogger from litellm.proxy._types import UserAPIKeyAuth +from litellm.proxy.hooks.rate_limiter_utils import ( + ProxyHTTPRateLimitError, + resolve_llm_provider_for_rate_limit, +) class _PROXY_MaxBudgetLimiter(CustomLogger): @@ -63,7 +67,15 @@ class _PROXY_MaxBudgetLimiter(CustomLogger): # CHECK IF REQUEST ALLOWED if curr_spend >= max_budget: - raise HTTPException(status_code=429, detail="Max budget limit reached.") + resolved_model, llm_provider = resolve_llm_provider_for_rate_limit( + data.get("model") if data else None + ) + raise ProxyHTTPRateLimitError( + status_code=429, + detail="Max budget limit reached.", + model=resolved_model, + llm_provider=llm_provider, + ) except HTTPException as e: raise e except Exception as e: diff --git a/litellm/proxy/hooks/max_budget_per_session_limiter.py b/litellm/proxy/hooks/max_budget_per_session_limiter.py index 59fb101f55..0b63465c4a 100644 --- a/litellm/proxy/hooks/max_budget_per_session_limiter.py +++ b/litellm/proxy/hooks/max_budget_per_session_limiter.py @@ -17,12 +17,14 @@ Follows the same pattern as max_iterations_limiter.py. import os from typing import TYPE_CHECKING, Any, Optional, Union -from fastapi import HTTPException - from litellm import DualCache from litellm._logging import verbose_proxy_logger from litellm.integrations.custom_logger import CustomLogger from litellm.proxy._types import UserAPIKeyAuth +from litellm.proxy.hooks.rate_limiter_utils import ( + ProxyHTTPRateLimitError, + resolve_llm_provider_for_rate_limit, +) if TYPE_CHECKING: from litellm.proxy.utils import InternalUsageCache as _InternalUsageCache @@ -112,13 +114,18 @@ class _PROXY_MaxBudgetPerSessionHandler(CustomLogger): ) if current_spend >= max_budget: - raise HTTPException( + resolved_model, llm_provider = resolve_llm_provider_for_rate_limit( + data.get("model") if data else None + ) + raise ProxyHTTPRateLimitError( status_code=429, detail=( f"Session budget exceeded for session {session_id}. " f"Current spend: ${current_spend:.4f}, " f"max_budget_per_session: ${max_budget:.2f}." ), + model=resolved_model, + llm_provider=llm_provider, ) return None diff --git a/litellm/proxy/hooks/max_iterations_limiter.py b/litellm/proxy/hooks/max_iterations_limiter.py index df9a298ca0..d5bc669c92 100644 --- a/litellm/proxy/hooks/max_iterations_limiter.py +++ b/litellm/proxy/hooks/max_iterations_limiter.py @@ -13,12 +13,14 @@ Follows the same pattern as parallel_request_limiter_v3.py. import os from typing import TYPE_CHECKING, Any, Optional, Union -from fastapi import HTTPException - from litellm import DualCache from litellm._logging import verbose_proxy_logger from litellm.integrations.custom_logger import CustomLogger from litellm.proxy._types import UserAPIKeyAuth +from litellm.proxy.hooks.rate_limiter_utils import ( + ProxyHTTPRateLimitError, + resolve_llm_provider_for_rate_limit, +) if TYPE_CHECKING: from litellm.proxy.utils import InternalUsageCache as _InternalUsageCache @@ -116,12 +118,17 @@ class _PROXY_MaxIterationsHandler(CustomLogger): current_count = await self._increment_and_get(cache_key) if current_count > max_iterations: - raise HTTPException( + resolved_model, llm_provider = resolve_llm_provider_for_rate_limit( + data.get("model") if data else None + ) + raise ProxyHTTPRateLimitError( status_code=429, detail=( f"Max iterations exceeded for session {session_id}. " f"Current count: {current_count}, max_iterations: {max_iterations}." ), + model=resolved_model, + llm_provider=llm_provider, ) verbose_proxy_logger.debug( diff --git a/litellm/proxy/hooks/parallel_request_limiter.py b/litellm/proxy/hooks/parallel_request_limiter.py index 43c5fc6872..c6324c3e3a 100644 --- a/litellm/proxy/hooks/parallel_request_limiter.py +++ b/litellm/proxy/hooks/parallel_request_limiter.py @@ -17,6 +17,10 @@ from litellm.proxy.auth.auth_utils import ( get_key_model_rpm_limit, get_key_model_tpm_limit, ) +from litellm.proxy.hooks.rate_limiter_utils import ( + ProxyHTTPRateLimitError, + resolve_llm_provider_for_rate_limit, +) if TYPE_CHECKING: from opentelemetry.trace import Span as _Span @@ -73,7 +77,8 @@ class _PROXY_MaxParallelRequestsHandler(CustomLogger): if max_parallel_requests == 0 or tpm_limit == 0 or rpm_limit == 0: # base case raise self.raise_rate_limit_error( - additional_details=f"{CommonProxyErrors.max_parallel_request_limit_reached.value}. Hit limit for {rate_limit_type}. Current limits: max_parallel_requests: {max_parallel_requests}, tpm_limit: {tpm_limit}, rpm_limit: {rpm_limit}" + additional_details=f"{CommonProxyErrors.max_parallel_request_limit_reached.value}. Hit limit for {rate_limit_type}. Current limits: max_parallel_requests: {max_parallel_requests}, tpm_limit: {tpm_limit}, rpm_limit: {rpm_limit}", + requested_model=data.get("model") if data else None, ) new_val = { "current_requests": 1, @@ -95,10 +100,16 @@ class _PROXY_MaxParallelRequestsHandler(CustomLogger): values_to_update_in_cache.append((request_count_api_key, new_val)) else: - raise HTTPException( + requested_model = data.get("model") if data else None + resolved_model, llm_provider = resolve_llm_provider_for_rate_limit( + requested_model + ) + raise ProxyHTTPRateLimitError( status_code=429, detail=f"LiteLLM Rate Limit Handler for rate limit type = {rate_limit_type}. {CommonProxyErrors.max_parallel_request_limit_reached.value}. current rpm: {current['current_rpm']}, rpm limit: {rpm_limit}, current tpm: {current['current_tpm']}, tpm limit: {tpm_limit}, current max_parallel_requests: {current['current_requests']}, max_parallel_requests: {max_parallel_requests}", headers={"retry-after": str(self.time_to_next_minute())}, + model=resolved_model, + llm_provider=llm_provider, ) await self.internal_usage_cache.async_batch_set_cache( @@ -122,18 +133,31 @@ class _PROXY_MaxParallelRequestsHandler(CustomLogger): return seconds_to_next_minute def raise_rate_limit_error( - self, additional_details: Optional[str] = None + self, + additional_details: Optional[str] = None, + requested_model: Optional[str] = None, ) -> HTTPException: """ - Raise an HTTPException with a 429 status code and a retry-after header + Raise an HTTPException with a 429 status code and a retry-after header. + + ``requested_model`` is resolved via :func:`get_llm_provider` so the + raised exception carries ``llm_provider`` for downstream loggers + (Prometheus failure metric, observability callbacks). Falls back to + ``llm_provider="litellm_proxy"`` when the model is missing or + unparseable — see ``resolve_llm_provider_for_rate_limit``. """ error_message = "Max parallel request limit reached" if additional_details is not None: error_message = error_message + " " + additional_details - raise HTTPException( + resolved_model, llm_provider = resolve_llm_provider_for_rate_limit( + requested_model + ) + raise ProxyHTTPRateLimitError( status_code=429, - detail=f"Max parallel request limit reached {additional_details}", + detail=error_message, headers={"retry-after": str(self.time_to_next_minute())}, + model=resolved_model, + llm_provider=llm_provider, ) async def get_all_cache_objects( @@ -225,7 +249,8 @@ class _PROXY_MaxParallelRequestsHandler(CustomLogger): # if above -> raise error if current_global_requests >= global_max_parallel_requests: return self.raise_rate_limit_error( - additional_details=f"Hit Global Limit: Limit={global_max_parallel_requests}, current: {current_global_requests}" + additional_details=f"Hit Global Limit: Limit={global_max_parallel_requests}, current: {current_global_requests}", + requested_model=data.get("model") if data else None, ) # if below -> increment else: diff --git a/litellm/proxy/hooks/parallel_request_limiter_v3.py b/litellm/proxy/hooks/parallel_request_limiter_v3.py index 4343747d10..9fdb146b19 100644 --- a/litellm/proxy/hooks/parallel_request_limiter_v3.py +++ b/litellm/proxy/hooks/parallel_request_limiter_v3.py @@ -23,8 +23,6 @@ from typing import ( cast, ) -from fastapi import HTTPException - from litellm import DualCache from litellm._logging import verbose_proxy_logger from litellm.constants import DYNAMIC_RATE_LIMIT_ERROR_THRESHOLD_PER_MINUTE @@ -34,6 +32,10 @@ from litellm.litellm_core_utils.prompt_templates.common_utils import ( ) from litellm.proxy._types import UserAPIKeyAuth from litellm.proxy.auth.auth_utils import get_model_rate_limit_from_metadata +from litellm.proxy.hooks.rate_limiter_utils import ( + ProxyHTTPRateLimitError, + resolve_llm_provider_for_rate_limit, +) from litellm.types.caching import RedisPipelineIncrementOperation from litellm.types.llms.openai import BaseLiteLLMOpenAIResponseObject from litellm.types.utils import CallTypes, ModelResponse, Usage @@ -1967,6 +1969,7 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger): self, response: RateLimitResponse, descriptors: List[RateLimitDescriptor], + requested_model: Optional[str] = None, ) -> None: """Handle rate limit exceeded error by raising HTTPException.""" for status in response["statuses"]: @@ -1999,7 +2002,10 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger): f"Limit resets at: {reset_time_formatted}" ) - raise HTTPException( + resolved_model, llm_provider = resolve_llm_provider_for_rate_limit( + requested_model + ) + raise ProxyHTTPRateLimitError( status_code=429, detail=detail, headers={ @@ -2007,6 +2013,8 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger): "rate_limit_type": str(status["rate_limit_type"]), "reset_at": reset_time_formatted, }, + model=resolved_model, + llm_provider=llm_provider, ) async def async_pre_call_hook( @@ -2115,6 +2123,7 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger): self._handle_rate_limit_error( response=response, descriptors=descriptors, + requested_model=requested_model, ) else: # add descriptors to request headers @@ -2188,6 +2197,7 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger): self._handle_rate_limit_error( response=tpm_response, descriptors=descriptors, + requested_model=requested_model, ) else: self._stash_value_in_metadata_channels( diff --git a/litellm/proxy/hooks/rate_limiter_utils.py b/litellm/proxy/hooks/rate_limiter_utils.py index 927bac0de5..0ba3df448e 100644 --- a/litellm/proxy/hooks/rate_limiter_utils.py +++ b/litellm/proxy/hooks/rate_limiter_utils.py @@ -2,11 +2,105 @@ Shared utility functions for rate limiter hooks. """ -from typing import Optional, Union +from typing import Any, Optional, Tuple, Union +from fastapi import HTTPException + +import litellm +from litellm._logging import verbose_proxy_logger +from litellm.exceptions import RateLimitError from litellm.types.router import ModelGroupInfo from litellm.types.utils import PriorityReservationDict +PROXY_LLM_PROVIDER_FALLBACK = "litellm_proxy" + + +def resolve_llm_provider_for_rate_limit( + model: Optional[str], +) -> Tuple[str, str]: + """ + Resolve ``(model, llm_provider)`` for a request being rejected by an + internal proxy-side rate-limit hook. + + These hooks fire from ``async_pre_call_hook`` — well before + :func:`litellm.get_llm_provider` is invoked anywhere else in the request + lifecycle — so the raised 429 would otherwise have an empty + ``llm_provider`` field, making the resulting Prometheus + ``litellm_proxy_failed_requests_metric`` show up with + ``exception_class="RateLimitError"`` and no provider attribution. + + Wrapped defensively: if ``model`` is missing, malformed, or + ``get_llm_provider`` raises (unknown alias, router-only model, etc.) we + fall back to ``("", "litellm_proxy")`` so we never break the request path + by piling a second exception on top of the rate-limit one we're trying to + raise. + """ + if not model: + return "", PROXY_LLM_PROVIDER_FALLBACK + try: + resolved_model, custom_llm_provider, _, _ = litellm.get_llm_provider( + model=model, + ) + return ( + resolved_model or model, + custom_llm_provider or PROXY_LLM_PROVIDER_FALLBACK, + ) + except Exception as e: + verbose_proxy_logger.debug( + "rate_limiter_utils.resolve_llm_provider_for_rate_limit: " + "could not resolve provider for model=%s, falling back to %s. err=%s", + model, + PROXY_LLM_PROVIDER_FALLBACK, + str(e), + ) + return model, PROXY_LLM_PROVIDER_FALLBACK + + +class ProxyHTTPRateLimitError(HTTPException, RateLimitError): # type: ignore[misc] + """ + HTTPException raised by proxy-side rate-limit hooks that *also* exposes + ``model`` and ``llm_provider`` attributes. + + Why both base classes: + + - The proxy server's exception handler keys off ``HTTPException`` to render + a 429 response, so we must remain an ``HTTPException``. + - Downstream loggers (Prometheus ``async_post_call_failure_hook``, + structured logging, observability callbacks) read ``exception.llm_provider`` + via :meth:`litellm.integrations.prometheus.PrometheusLogger._get_exception_class_name` + and ``isinstance(exc, RateLimitError)`` for category routing. Inheriting + from :class:`litellm.exceptions.RateLimitError` keeps that wiring intact. + + We intentionally do not call ``RateLimitError.__init__`` (which constructs + an httpx.Response) — it isn't needed here and just adds failure surface. + Attribute parity is what downstream consumers rely on. + """ + + def __init__( + self, + status_code: int, + detail: Any = None, + headers: Optional[dict] = None, + *, + model: str = "", + llm_provider: str = PROXY_LLM_PROVIDER_FALLBACK, + ) -> None: + HTTPException.__init__( + self, status_code=status_code, detail=detail, headers=headers + ) + self.status_code = status_code + self.model = model or "" + self.llm_provider = llm_provider or PROXY_LLM_PROVIDER_FALLBACK + # `message` is what RateLimitError.__str__ would print and what some + # observability callbacks log. Keep it human-readable. + self.message = detail if isinstance(detail, str) else str(detail) + # `RateLimitError.__str__` (resolved via MRO since Starlette's + # HTTPException doesn't define `__str__`) unconditionally reads + # these attributes. Set them so `str(exc)` doesn't raise + # AttributeError from logging/traceback paths. + self.num_retries: Optional[int] = None + self.max_retries: Optional[int] = None + def convert_priority_to_percent( value: Union[float, PriorityReservationDict], model_info: Optional[ModelGroupInfo] diff --git a/tests/test_litellm/proxy/hooks/test_proxy_rate_limit_provider_field.py b/tests/test_litellm/proxy/hooks/test_proxy_rate_limit_provider_field.py new file mode 100644 index 0000000000..8c74919df1 --- /dev/null +++ b/tests/test_litellm/proxy/hooks/test_proxy_rate_limit_provider_field.py @@ -0,0 +1,968 @@ +""" +Regression tests for the "provider field missing" bug on proxy-side +rate-limit errors. + +Background +---------- +The proxy's internal rate-limit hooks (parallel_request_limiter, +parallel_request_limiter_v3, dynamic_rate_limiter, dynamic_rate_limiter_v3, +batch_rate_limiter, max_budget_limiter, max_iterations_limiter, +max_budget_per_session_limiter) all fire from ``async_pre_call_hook`` — +*before* :func:`litellm.get_llm_provider` runs anywhere else in the request +lifecycle. + +Until now, those hooks raised a bare ``HTTPException(429, ...)`` which carries +no ``llm_provider`` / ``model`` attribute. Downstream: + +- The Prometheus ``litellm_proxy_failed_requests_metric`` reads + ``exception.llm_provider`` via ``_get_exception_class_name`` — it came back + empty, so dashboards showed ``exception_class="HTTPException"`` with no + provider attribution. +- Observability callbacks that ``isinstance(e, RateLimitError)`` for + category routing missed these entirely. + +The fix wraps every internal raise site in +:class:`ProxyHTTPRateLimitError` (an ``HTTPException`` *and* a +``litellm.RateLimitError``), and resolves ``model`` / ``llm_provider`` from +``data["model"]`` via :func:`get_llm_provider`. When the model is missing or +unparseable we fall back to ``llm_provider="litellm_proxy"`` so we never break +the request path with a second exception. + +These tests pin both the happy path (provider correctly resolved) and the +fallback path (unknown model, missing model) for every limiter. +""" + +import sys +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from fastapi import HTTPException + +import litellm +from litellm.caching.caching import DualCache +from litellm.exceptions import RateLimitError +from litellm.proxy._types import UserAPIKeyAuth +from litellm.proxy.hooks.batch_rate_limiter import ( + BatchFileUsage, + _PROXY_BatchRateLimiter, +) +from litellm.proxy.hooks.dynamic_rate_limiter import _PROXY_DynamicRateLimitHandler +from litellm.proxy.hooks.dynamic_rate_limiter_v3 import ( + _PROXY_DynamicRateLimitHandlerV3, +) +from litellm.proxy.hooks.max_budget_limiter import _PROXY_MaxBudgetLimiter +from litellm.proxy.hooks.max_budget_per_session_limiter import ( + _PROXY_MaxBudgetPerSessionHandler, +) +from litellm.proxy.hooks.max_iterations_limiter import _PROXY_MaxIterationsHandler +from litellm.proxy.hooks.parallel_request_limiter import ( + _PROXY_MaxParallelRequestsHandler, +) +from litellm.proxy.hooks.parallel_request_limiter_v3 import ( + _PROXY_MaxParallelRequestsHandler_v3, +) +from litellm.proxy.hooks.rate_limiter_utils import ( + PROXY_LLM_PROVIDER_FALLBACK, + ProxyHTTPRateLimitError, + resolve_llm_provider_for_rate_limit, +) +from litellm.proxy.utils import InternalUsageCache +from litellm.types.agents import AgentResponse + + +# --------------------------------------------------------------------------- +# Helper class itself +# --------------------------------------------------------------------------- + + +class TestProxyHTTPRateLimitErrorClass: + """Pin the dual ``HTTPException`` + ``RateLimitError`` shape.""" + + def test_is_both_http_exception_and_rate_limit_error(self): + e = ProxyHTTPRateLimitError( + status_code=429, + detail="boom", + model="gpt-4o-mini", + llm_provider="openai", + ) + # FastAPI handler keys off HTTPException to render the 429. + assert isinstance(e, HTTPException) + # Prometheus / observability key off RateLimitError + .llm_provider. + assert isinstance(e, RateLimitError) + assert e.status_code == 429 + assert e.model == "gpt-4o-mini" + assert e.llm_provider == "openai" + assert e.message == "boom" + assert e.detail == "boom" + + def test_dict_detail_is_stringified_for_message(self): + # Some hooks pass a dict detail (e.g. dynamic_rate_limiter v1) — the + # `message` attr (read by RateLimitError.__str__ and observability + # callbacks) must still be a string. + e = ProxyHTTPRateLimitError( + status_code=429, + detail={"error": "over rpm"}, + model="claude-3-5-sonnet", + llm_provider="anthropic", + ) + assert isinstance(e.message, str) + assert "over rpm" in e.message + + def test_defaults_to_litellm_proxy_provider(self): + e = ProxyHTTPRateLimitError(status_code=429, detail="x") + assert e.llm_provider == PROXY_LLM_PROVIDER_FALLBACK + assert e.model == "" + + def test_none_provider_normalized_to_fallback(self): + e = ProxyHTTPRateLimitError( + status_code=429, + detail="x", + model=None, # type: ignore[arg-type] + llm_provider=None, # type: ignore[arg-type] + ) + assert e.llm_provider == PROXY_LLM_PROVIDER_FALLBACK + assert e.model == "" + + +class TestResolveLLMProviderForRateLimit: + @pytest.mark.parametrize( + "model, expected_provider", + [ + ("gpt-4o-mini", "openai"), + ("anthropic/claude-3-5-sonnet", "anthropic"), + ("bedrock/meta.llama3-1-70b-instruct-v1:0", "bedrock"), + ], + ) + def test_known_models_resolve_provider(self, model, expected_provider): + resolved_model, provider = resolve_llm_provider_for_rate_limit(model) + assert provider == expected_provider + assert resolved_model # non-empty + + @pytest.mark.parametrize("model", [None, "", "totally-not-a-real-model-name"]) + def test_missing_or_unknown_model_falls_back(self, model): + # Must never raise — the resolver wraps `get_llm_provider` defensively + # because raising here would mask the rate-limit error we're trying + # to surface to the user. + resolved_model, provider = resolve_llm_provider_for_rate_limit(model) + assert provider == PROXY_LLM_PROVIDER_FALLBACK + # Resolver returns the input model verbatim on the unknown branch so + # the `.model` attribute is never silently swapped to a different one. + if not model: + assert resolved_model == "" + else: + assert resolved_model == model + + def test_get_llm_provider_raising_is_swallowed(self): + # If get_llm_provider itself blows up (unexpected error), we still + # fall back rather than letting the secondary exception escape. + with patch.object( + litellm, + "get_llm_provider", + side_effect=RuntimeError("boom"), + ): + resolved_model, provider = resolve_llm_provider_for_rate_limit("anything") + assert provider == PROXY_LLM_PROVIDER_FALLBACK + assert resolved_model == "anything" + + +# --------------------------------------------------------------------------- +# parallel_request_limiter v1 +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_parallel_request_limiter_v1_populates_provider_when_at_rpm_limit(): + """ + Trip the per-key RPM cap and assert the raised exception carries + ``model`` / ``llm_provider`` resolved from ``data["model"]``. + """ + handler = _PROXY_MaxParallelRequestsHandler( + internal_usage_cache=InternalUsageCache(DualCache()) + ) + user_api_key_dict = UserAPIKeyAuth( + api_key="sk-rl-test", + max_parallel_requests=10, + rpm_limit=1, + tpm_limit=10, + ) + data = {"model": "gpt-4o-mini"} + + # First request consumes the budget. + await handler.async_pre_call_hook( + user_api_key_dict=user_api_key_dict, + cache=DualCache(), + data=data, + call_type="completion", + ) + + with pytest.raises(HTTPException) as exc_info: + await handler.async_pre_call_hook( + user_api_key_dict=user_api_key_dict, + cache=DualCache(), + data=data, + call_type="completion", + ) + + exc = exc_info.value + assert exc.status_code == 429 + assert isinstance(exc, RateLimitError) + assert exc.llm_provider == "openai" + assert exc.model == "gpt-4o-mini" + + +@pytest.mark.asyncio +async def test_parallel_request_limiter_v1_zero_limit_path_populates_provider(): + """ + When tpm_limit / rpm_limit is 0 the limiter takes the + ``raise_rate_limit_error`` path. That path receives ``requested_model`` + via the call-site change and must pass it through. + """ + handler = _PROXY_MaxParallelRequestsHandler( + internal_usage_cache=InternalUsageCache(DualCache()) + ) + user_api_key_dict = UserAPIKeyAuth( + api_key="sk-rl-zero", + max_parallel_requests=0, + rpm_limit=10, + tpm_limit=10, + ) + + with pytest.raises(HTTPException) as exc_info: + await handler.async_pre_call_hook( + user_api_key_dict=user_api_key_dict, + cache=DualCache(), + data={"model": "anthropic/claude-3-5-sonnet"}, + call_type="completion", + ) + + exc = exc_info.value + assert exc.status_code == 429 + assert isinstance(exc, RateLimitError) + assert exc.llm_provider == "anthropic" + assert exc.model == "claude-3-5-sonnet" + + +@pytest.mark.asyncio +async def test_parallel_request_limiter_v1_global_limit_populates_provider(): + """global_max_parallel_requests path also threads the model through.""" + handler = _PROXY_MaxParallelRequestsHandler( + internal_usage_cache=InternalUsageCache(DualCache()) + ) + user_api_key_dict = UserAPIKeyAuth(api_key="sk-global") + + # Pre-fill the global counter so the next call exceeds it. + await handler.internal_usage_cache.async_set_cache( + key="global_max_parallel_requests", + value=5, + local_only=True, + litellm_parent_otel_span=None, + ) + + with pytest.raises(HTTPException) as exc_info: + await handler.async_pre_call_hook( + user_api_key_dict=user_api_key_dict, + cache=DualCache(), + data={ + "model": "bedrock/meta.llama3-1-70b-instruct-v1:0", + "metadata": {"global_max_parallel_requests": 1}, + }, + call_type="completion", + ) + + exc = exc_info.value + assert exc.status_code == 429 + assert exc.llm_provider == "bedrock" + assert exc.model == "meta.llama3-1-70b-instruct-v1:0" + + +@pytest.mark.asyncio +async def test_parallel_request_limiter_v1_unknown_model_falls_back(): + """ + When ``data["model"]`` is unparseable, the resolver falls back to + ``litellm_proxy`` — and crucially does *not* leak a secondary exception. + """ + handler = _PROXY_MaxParallelRequestsHandler( + internal_usage_cache=InternalUsageCache(DualCache()) + ) + user_api_key_dict = UserAPIKeyAuth( + api_key="sk-rl-unknown", + max_parallel_requests=10, + rpm_limit=1, + tpm_limit=10, + ) + data = {"model": "totally-not-a-real-model"} + + await handler.async_pre_call_hook( + user_api_key_dict=user_api_key_dict, + cache=DualCache(), + data=data, + call_type="completion", + ) + + with pytest.raises(HTTPException) as exc_info: + await handler.async_pre_call_hook( + user_api_key_dict=user_api_key_dict, + cache=DualCache(), + data=data, + call_type="completion", + ) + + exc = exc_info.value + assert exc.status_code == 429 + assert exc.llm_provider == PROXY_LLM_PROVIDER_FALLBACK + # Resolver returns the input verbatim so we don't silently relabel the + # model in the user-facing 429 detail. + assert exc.model == "totally-not-a-real-model" + + +@pytest.mark.asyncio +async def test_parallel_request_limiter_v1_missing_model_falls_back(): + handler = _PROXY_MaxParallelRequestsHandler( + internal_usage_cache=InternalUsageCache(DualCache()) + ) + user_api_key_dict = UserAPIKeyAuth( + api_key="sk-rl-no-model", + max_parallel_requests=10, + rpm_limit=1, + tpm_limit=10, + ) + + await handler.async_pre_call_hook( + user_api_key_dict=user_api_key_dict, + cache=DualCache(), + data={}, + call_type="completion", + ) + + with pytest.raises(HTTPException) as exc_info: + await handler.async_pre_call_hook( + user_api_key_dict=user_api_key_dict, + cache=DualCache(), + data={}, + call_type="completion", + ) + + exc = exc_info.value + assert exc.llm_provider == PROXY_LLM_PROVIDER_FALLBACK + assert exc.model == "" + + +# --------------------------------------------------------------------------- +# parallel_request_limiter v3 +# --------------------------------------------------------------------------- + + +def _v3_over_limit_response(rate_limit_type: str = "rpm") -> dict: + return { + "overall_code": "OVER_LIMIT", + "statuses": [ + { + "code": "OVER_LIMIT", + "descriptor_key": "key", + "current_limit": 1, + "limit_remaining": -1, + "rate_limit_type": rate_limit_type, + } + ], + } + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "model, expected_provider", + [ + ("gpt-4o-mini", "openai"), + ("anthropic/claude-3-5-sonnet", "anthropic"), + ], +) +async def test_parallel_request_limiter_v3_populates_provider(model, expected_provider): + handler = _PROXY_MaxParallelRequestsHandler_v3( + internal_usage_cache=InternalUsageCache(DualCache()) + ) + + descriptors = [{"key": "key", "value": "v", "rate_limit": {"requests_per_unit": 1}}] + over = _v3_over_limit_response() + + with pytest.raises(HTTPException) as exc_info: + handler._handle_rate_limit_error( + response=over, + descriptors=descriptors, + requested_model=model, + ) + + exc = exc_info.value + assert exc.status_code == 429 + assert isinstance(exc, RateLimitError) + assert exc.llm_provider == expected_provider + # v3 may strip the "anthropic/" prefix in the resolved model — accept + # either; we only care that the provider field is correct and the model + # is non-empty. + assert exc.model + + +@pytest.mark.asyncio +async def test_parallel_request_limiter_v3_unknown_model_falls_back(): + handler = _PROXY_MaxParallelRequestsHandler_v3( + internal_usage_cache=InternalUsageCache(DualCache()) + ) + descriptors = [{"key": "key", "value": "v", "rate_limit": {"requests_per_unit": 1}}] + + with pytest.raises(HTTPException) as exc_info: + handler._handle_rate_limit_error( + response=_v3_over_limit_response(), + descriptors=descriptors, + requested_model="totally-bogus", + ) + + assert exc_info.value.llm_provider == PROXY_LLM_PROVIDER_FALLBACK + assert exc_info.value.model == "totally-bogus" + + +@pytest.mark.asyncio +async def test_parallel_request_limiter_v3_missing_model_falls_back(): + handler = _PROXY_MaxParallelRequestsHandler_v3( + internal_usage_cache=InternalUsageCache(DualCache()) + ) + descriptors = [{"key": "key", "value": "v", "rate_limit": {"requests_per_unit": 1}}] + + with pytest.raises(HTTPException) as exc_info: + handler._handle_rate_limit_error( + response=_v3_over_limit_response(), + descriptors=descriptors, + requested_model=None, + ) + + assert exc_info.value.llm_provider == PROXY_LLM_PROVIDER_FALLBACK + assert exc_info.value.model == "" + + +# --------------------------------------------------------------------------- +# dynamic_rate_limiter v1 +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_dynamic_rate_limiter_v1_tpm_zero_populates_provider(): + handler = _PROXY_DynamicRateLimitHandler(internal_usage_cache=DualCache()) + handler.check_available_usage = AsyncMock(return_value=(0, 5, 100, 5, 1)) + + user_api_key_dict = UserAPIKeyAuth(api_key="sk-dyn") + user_api_key_dict.metadata = {} + + with pytest.raises(HTTPException) as exc_info: + await handler.async_pre_call_hook( + user_api_key_dict=user_api_key_dict, + cache=DualCache(), + data={"model": "gpt-4o-mini"}, + call_type="completion", + ) + + exc = exc_info.value + assert exc.status_code == 429 + assert isinstance(exc, RateLimitError) + assert exc.llm_provider == "openai" + assert exc.model == "gpt-4o-mini" + + +@pytest.mark.asyncio +async def test_dynamic_rate_limiter_v1_rpm_zero_populates_provider(): + handler = _PROXY_DynamicRateLimitHandler(internal_usage_cache=DualCache()) + handler.check_available_usage = AsyncMock(return_value=(5, 0, 5, 100, 1)) + + user_api_key_dict = UserAPIKeyAuth(api_key="sk-dyn") + user_api_key_dict.metadata = {} + + with pytest.raises(HTTPException) as exc_info: + await handler.async_pre_call_hook( + user_api_key_dict=user_api_key_dict, + cache=DualCache(), + data={"model": "anthropic/claude-3-5-sonnet"}, + call_type="completion", + ) + + exc = exc_info.value + assert exc.llm_provider == "anthropic" + assert exc.model == "claude-3-5-sonnet" + + +@pytest.mark.asyncio +async def test_dynamic_rate_limiter_v1_unknown_model_falls_back(): + handler = _PROXY_DynamicRateLimitHandler(internal_usage_cache=DualCache()) + handler.check_available_usage = AsyncMock(return_value=(0, 5, 100, 5, 1)) + + user_api_key_dict = UserAPIKeyAuth(api_key="sk-dyn") + user_api_key_dict.metadata = {} + + with pytest.raises(HTTPException) as exc_info: + await handler.async_pre_call_hook( + user_api_key_dict=user_api_key_dict, + cache=DualCache(), + data={"model": "no-such-model"}, + call_type="completion", + ) + + assert exc_info.value.llm_provider == PROXY_LLM_PROVIDER_FALLBACK + assert exc_info.value.model == "no-such-model" + + +# --------------------------------------------------------------------------- +# dynamic_rate_limiter v3 — exercise just the raise path via the helper, not +# the full Redis/Lua stack. +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_dynamic_rate_limiter_v3_model_capacity_path_populates_provider(): + """ + The v3 dynamic limiter has three raise sites: model_saturation_check, + priority_model, and the fail-closed unknown-descriptor branch. We patch + the atomic increment to short-circuit straight into the model_saturation + path — that's the most common production trip — and confirm the + raised exception carries provider info. + """ + from litellm.types.router import ModelGroupInfo + + handler = _PROXY_DynamicRateLimitHandlerV3(internal_usage_cache=DualCache()) + handler.v3_limiter.atomic_check_and_increment_by_n = AsyncMock( + return_value={ + "overall_code": "OVER_LIMIT", + "statuses": [ + { + "code": "OVER_LIMIT", + "descriptor_key": "model_saturation_check", + "current_limit": 100, + "limit_remaining": 0, + "rate_limit_type": "rpm", + } + ], + } + ) + handler._create_priority_based_descriptors = MagicMock(return_value=[]) + handler._create_model_tracking_descriptor = MagicMock( + return_value={ + "key": "model_saturation_check", + "value": "gpt-4o-mini", + "rate_limit": {"requests_per_unit": 100}, + } + ) + + user_api_key_dict = UserAPIKeyAuth(api_key="sk-dyn-v3") + user_api_key_dict.metadata = {} + model_info = ModelGroupInfo(model_group="gpt-4o-mini", providers=["openai"]) + + with pytest.raises(HTTPException) as exc_info: + await handler._check_rate_limits( + model="gpt-4o-mini", + model_group_info=model_info, + user_api_key_dict=user_api_key_dict, + priority="default", + saturation=1.0, + data={"model": "gpt-4o-mini"}, + ) + + exc = exc_info.value + assert exc.status_code == 429 + assert isinstance(exc, RateLimitError) + assert exc.llm_provider == "openai" + assert exc.model == "gpt-4o-mini" + + +@pytest.mark.asyncio +async def test_dynamic_rate_limiter_v3_unknown_descriptor_path_populates_provider(): + """Fail-closed unknown-descriptor branch must still attribute provider.""" + from litellm.types.router import ModelGroupInfo + + handler = _PROXY_DynamicRateLimitHandlerV3(internal_usage_cache=DualCache()) + handler.v3_limiter.atomic_check_and_increment_by_n = AsyncMock( + return_value={ + "overall_code": "OVER_LIMIT", + "statuses": [ + { + "code": "OVER_LIMIT", + "descriptor_key": "something_we_dont_handle", + "current_limit": 1, + "limit_remaining": 0, + "rate_limit_type": "rpm", + } + ], + } + ) + handler._create_priority_based_descriptors = MagicMock(return_value=[]) + handler._create_model_tracking_descriptor = MagicMock( + return_value={ + "key": "model_saturation_check", + "value": "gpt-4o-mini", + "rate_limit": {"requests_per_unit": 1}, + } + ) + + user_api_key_dict = UserAPIKeyAuth(api_key="sk-dyn-v3-unknown") + user_api_key_dict.metadata = {} + model_info = ModelGroupInfo(model_group="gpt-4o-mini", providers=["openai"]) + + with pytest.raises(HTTPException) as exc_info: + await handler._check_rate_limits( + model="gpt-4o-mini", + model_group_info=model_info, + user_api_key_dict=user_api_key_dict, + priority="default", + saturation=1.0, + data={"model": "gpt-4o-mini"}, + ) + + assert exc_info.value.llm_provider == "openai" + + +# --------------------------------------------------------------------------- +# batch_rate_limiter +# --------------------------------------------------------------------------- + + +def _batch_over_limit_response() -> dict: + return { + "overall_code": "OVER_LIMIT", + "statuses": [ + { + "code": "OVER_LIMIT", + "descriptor_key": "key", + "current_limit": 10, + "limit_remaining": -5, + "rate_limit_type": "requests", + } + ], + } + + +@pytest.mark.asyncio +async def test_batch_rate_limiter_populates_provider(): + """ + batch_rate_limiter trips when the file's request/token count exceeds the + remaining window. The raise must thread `data["model"]` through the + helper. + """ + parallel_limiter = MagicMock() + parallel_limiter.window_size = 60 + parallel_limiter._create_rate_limit_descriptors = MagicMock( + return_value=[ + {"key": "key", "value": "v", "rate_limit": {"requests_per_unit": 10}} + ] + ) + parallel_limiter.atomic_check_and_increment_by_n = AsyncMock( + return_value=_batch_over_limit_response() + ) + + handler = _PROXY_BatchRateLimiter( + internal_usage_cache=InternalUsageCache(DualCache()), + parallel_request_limiter=parallel_limiter, + ) + + with pytest.raises(HTTPException) as exc_info: + await handler._check_and_increment_batch_counters( + user_api_key_dict=UserAPIKeyAuth(api_key="sk-batch"), + data={"model": "gpt-4o-mini"}, + batch_usage=BatchFileUsage(total_tokens=100, request_count=15), + ) + + exc = exc_info.value + assert exc.status_code == 429 + assert isinstance(exc, RateLimitError) + assert exc.llm_provider == "openai" + assert exc.model == "gpt-4o-mini" + + +@pytest.mark.asyncio +async def test_batch_rate_limiter_unknown_model_falls_back(): + parallel_limiter = MagicMock() + parallel_limiter.window_size = 60 + parallel_limiter._create_rate_limit_descriptors = MagicMock( + return_value=[ + {"key": "key", "value": "v", "rate_limit": {"requests_per_unit": 10}} + ] + ) + parallel_limiter.atomic_check_and_increment_by_n = AsyncMock( + return_value=_batch_over_limit_response() + ) + + handler = _PROXY_BatchRateLimiter( + internal_usage_cache=InternalUsageCache(DualCache()), + parallel_request_limiter=parallel_limiter, + ) + + with pytest.raises(HTTPException) as exc_info: + await handler._check_and_increment_batch_counters( + user_api_key_dict=UserAPIKeyAuth(api_key="sk-batch"), + data={"model": "fake-model-xyz"}, + batch_usage=BatchFileUsage(total_tokens=100, request_count=15), + ) + + assert exc_info.value.llm_provider == PROXY_LLM_PROVIDER_FALLBACK + + +# --------------------------------------------------------------------------- +# max_budget_limiter +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_max_budget_limiter_populates_provider(): + handler = _PROXY_MaxBudgetLimiter() + user_api_key_dict = UserAPIKeyAuth( + api_key="sk-budget", + user_id="user-1", + user_max_budget=10.0, + ) + + with patch( + "litellm.proxy.proxy_server.get_current_spend", + new=AsyncMock(return_value=10.0), + ): + with pytest.raises(HTTPException) as exc_info: + await handler.async_pre_call_hook( + user_api_key_dict=user_api_key_dict, + cache=DualCache(), + data={"model": "gpt-4o-mini"}, + call_type="completion", + ) + + exc = exc_info.value + assert exc.status_code == 429 + assert isinstance(exc, RateLimitError) + assert exc.llm_provider == "openai" + assert exc.model == "gpt-4o-mini" + + +@pytest.mark.asyncio +async def test_max_budget_limiter_no_model_falls_back(): + handler = _PROXY_MaxBudgetLimiter() + user_api_key_dict = UserAPIKeyAuth( + api_key="sk-budget", + user_id="user-1", + user_max_budget=10.0, + ) + + with patch( + "litellm.proxy.proxy_server.get_current_spend", + new=AsyncMock(return_value=10.0), + ): + with pytest.raises(HTTPException) as exc_info: + await handler.async_pre_call_hook( + user_api_key_dict=user_api_key_dict, + cache=DualCache(), + data={}, + call_type="completion", + ) + + assert exc_info.value.llm_provider == PROXY_LLM_PROVIDER_FALLBACK + assert exc_info.value.model == "" + + +# --------------------------------------------------------------------------- +# max_iterations_limiter +# --------------------------------------------------------------------------- + + +def _make_iter_agent(max_iterations: int) -> AgentResponse: + return AgentResponse( + agent_id="agent-iter", + agent_name="iter-agent", + litellm_params={"max_iterations": max_iterations}, + agent_card_params={"name": "iter-agent", "version": "1.0.0"}, + ) + + +@pytest.mark.asyncio +async def test_max_iterations_limiter_populates_provider(): + local_cache = DualCache() + handler = _PROXY_MaxIterationsHandler( + internal_usage_cache=InternalUsageCache(local_cache) + ) + user_api_key_dict = UserAPIKeyAuth(api_key="sk-iter", agent_id="agent-iter") + + with patch( + "litellm.proxy.agent_endpoints.agent_registry.global_agent_registry" + ) as mock_registry: + mock_registry.get_agent_by_id.return_value = _make_iter_agent(max_iterations=1) + + await handler.async_pre_call_hook( + user_api_key_dict=user_api_key_dict, + cache=local_cache, + data={ + "model": "gpt-4o-mini", + "metadata": {"session_id": "session-iter-1"}, + }, + call_type="completion", + ) + + with pytest.raises(HTTPException) as exc_info: + await handler.async_pre_call_hook( + user_api_key_dict=user_api_key_dict, + cache=local_cache, + data={ + "model": "gpt-4o-mini", + "metadata": {"session_id": "session-iter-1"}, + }, + call_type="completion", + ) + + exc = exc_info.value + assert exc.status_code == 429 + assert isinstance(exc, RateLimitError) + assert exc.llm_provider == "openai" + assert exc.model == "gpt-4o-mini" + + +@pytest.mark.asyncio +async def test_max_iterations_limiter_unknown_model_falls_back(): + local_cache = DualCache() + handler = _PROXY_MaxIterationsHandler( + internal_usage_cache=InternalUsageCache(local_cache) + ) + user_api_key_dict = UserAPIKeyAuth(api_key="sk-iter", agent_id="agent-iter") + + with patch( + "litellm.proxy.agent_endpoints.agent_registry.global_agent_registry" + ) as mock_registry: + mock_registry.get_agent_by_id.return_value = _make_iter_agent(max_iterations=1) + + await handler.async_pre_call_hook( + user_api_key_dict=user_api_key_dict, + cache=local_cache, + data={ + "model": "no-such-model", + "metadata": {"session_id": "session-iter-2"}, + }, + call_type="completion", + ) + + with pytest.raises(HTTPException) as exc_info: + await handler.async_pre_call_hook( + user_api_key_dict=user_api_key_dict, + cache=local_cache, + data={ + "model": "no-such-model", + "metadata": {"session_id": "session-iter-2"}, + }, + call_type="completion", + ) + + assert exc_info.value.llm_provider == PROXY_LLM_PROVIDER_FALLBACK + + +# --------------------------------------------------------------------------- +# max_budget_per_session_limiter +# --------------------------------------------------------------------------- + + +def _make_session_budget_agent(max_budget: float) -> AgentResponse: + return AgentResponse( + agent_id="agent-session-budget", + agent_name="session-budget-agent", + litellm_params={"max_budget_per_session": max_budget}, + agent_card_params={"name": "session-budget-agent", "version": "1.0.0"}, + ) + + +@pytest.mark.asyncio +async def test_max_budget_per_session_limiter_populates_provider(): + handler = _PROXY_MaxBudgetPerSessionHandler( + internal_usage_cache=InternalUsageCache(DualCache()) + ) + user_api_key_dict = UserAPIKeyAuth( + api_key="sk-session-budget", agent_id="agent-session-budget" + ) + + with patch( + "litellm.proxy.agent_endpoints.agent_registry.global_agent_registry" + ) as mock_registry: + mock_registry.get_agent_by_id.return_value = _make_session_budget_agent( + max_budget=1.0 + ) + with patch.object( + handler, "_get_current_spend", new=AsyncMock(return_value=5.0) + ): + with pytest.raises(HTTPException) as exc_info: + await handler.async_pre_call_hook( + user_api_key_dict=user_api_key_dict, + cache=DualCache(), + data={ + "model": "anthropic/claude-3-5-sonnet", + "metadata": {"session_id": "session-budget-1"}, + }, + call_type="completion", + ) + + exc = exc_info.value + assert exc.status_code == 429 + assert isinstance(exc, RateLimitError) + assert exc.llm_provider == "anthropic" + + +@pytest.mark.asyncio +async def test_max_budget_per_session_limiter_unknown_model_falls_back(): + handler = _PROXY_MaxBudgetPerSessionHandler( + internal_usage_cache=InternalUsageCache(DualCache()) + ) + user_api_key_dict = UserAPIKeyAuth( + api_key="sk-session-budget", agent_id="agent-session-budget" + ) + + with patch( + "litellm.proxy.agent_endpoints.agent_registry.global_agent_registry" + ) as mock_registry: + mock_registry.get_agent_by_id.return_value = _make_session_budget_agent( + max_budget=1.0 + ) + with patch.object( + handler, "_get_current_spend", new=AsyncMock(return_value=5.0) + ): + with pytest.raises(HTTPException) as exc_info: + await handler.async_pre_call_hook( + user_api_key_dict=user_api_key_dict, + cache=DualCache(), + data={ + "model": "no-such-model", + "metadata": {"session_id": "session-budget-2"}, + }, + call_type="completion", + ) + + assert exc_info.value.llm_provider == PROXY_LLM_PROVIDER_FALLBACK + + +# --------------------------------------------------------------------------- +# Prometheus integration: failure metric reads exception.llm_provider +# via _get_exception_class_name. With the fix, this returns +# "Openai.RateLimitError" instead of plain "HTTPException" for proxy-side +# 429s on a known model. Pin that contract — that's what dashboards see. +# --------------------------------------------------------------------------- + + +def test_prometheus_exception_class_name_includes_provider(): + from litellm.integrations.prometheus import PrometheusLogger + + exc = ProxyHTTPRateLimitError( + status_code=429, + detail="over limit", + model="gpt-4o-mini", + llm_provider="openai", + ) + + name = PrometheusLogger._get_exception_class_name(exc) + # Format is "{Provider.}{ClassName}" per `_get_exception_class_name`. + assert name.startswith("Openai.") + # And specifically: it ends in our exception class. (We don't pin the + # full string to avoid coupling the test to PR #27687's parallel rename.) + assert name.endswith("ProxyHTTPRateLimitError") + + +def test_prometheus_exception_class_name_falls_back_when_no_model(): + from litellm.integrations.prometheus import PrometheusLogger + + exc = ProxyHTTPRateLimitError(status_code=429, detail="over limit") + name = PrometheusLogger._get_exception_class_name(exc) + # `litellm_proxy` -> `Litellm_proxy.` (capitalize first char only). + assert name.startswith("Litellm_proxy.") + + +if __name__ == "__main__": + sys.exit(pytest.main([__file__, "-vv", "-x"]))