Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
150616c
feat(exceptions): add RateLimitErrorCategory + headers/detail fields …
cursoragent May 11, 2026
8360519
feat(proxy): add ProxyRateLimitError unifying RateLimitError + HTTPEx…
cursoragent May 11, 2026
39a9968
refactor(proxy/hooks): raise ProxyRateLimitError from budget + iterat…
cursoragent May 11, 2026
96af712
refactor(proxy/hooks): raise ProxyRateLimitError from parallel-reques…
cursoragent May 11, 2026
be5b496
refactor(proxy/hooks): raise ProxyRateLimitError from dynamic rate li…
cursoragent May 11, 2026
f74c9f1
refactor(proxy/hooks): raise ProxyRateLimitError from batch rate limiter
cursoragent May 11, 2026
8f7bdf5
test(rate-limit): pin down unified rate-limit error contract
cursoragent May 11, 2026
5f9ab59
feat(logging): expose rate-limit category via StandardLoggingPayload
cursoragent May 11, 2026
82f535d
test(rate-limit): assert StandardLoggingPayload carries the category
cursoragent May 11, 2026
0e42744
fix(types): silence mypy [misc] on intentional dual-base attr overlap
cursoragent May 11, 2026
5a10a75
test(rate-limit): add direct hook-invocation tests to lift patch cove…
cursoragent May 11, 2026
113783e
fix: guard rate_limit_category extraction with isinstance check
cursoragent May 11, 2026
997f24b
test(rate-limit): cover remaining hook raise sites for codecov
cursoragent May 11, 2026
d0202b8
fix: use computed error_message in ProxyRateLimitError detail
cursoragent May 11, 2026
4e5abe1
fix(parallel-request-limiter): drop None from detail; annotate raise_…
cursoragent May 11, 2026
2074848
fix(proxy/hooks): drop literal 'None' from raise_rate_limit_error detail
cursoragent May 11, 2026
a136c59
fix(types): demote TypedDict floating string to a # comment
cursoragent May 11, 2026
4b3d31c
security(exceptions): do not auto-copy vendor response headers to e.h…
cursoragent May 11, 2026
bcf1989
test(rate-limit): regression guards for review-pass fixes
cursoragent May 11, 2026
9778f94
feat(rate-limit): add orthogonal RateLimitType (requests/tokens/concu…
cursoragent May 12, 2026
48dcd10
feat(proxy/hooks): wire rate_limit_type onto every limiter raise site
cursoragent May 12, 2026
f926f0a
test(rate-limit): cover RateLimitType enum, hook wiring, and Standard…
cursoragent May 12, 2026
1947ea9
test(rate-limit): cover _coerce_message branches and v1 dimension det…
cursoragent May 12, 2026
921cd06
feat(proxy/hooks): add ProxyHTTPRateLimitError + provider resolver
cursoragent May 12, 2026
66976f7
fix(proxy/hooks): populate llm_provider on parallel-request 429s
cursoragent May 12, 2026
e5d5515
fix(proxy/hooks): populate llm_provider on dynamic-rate-limit 429s
cursoragent May 12, 2026
127d854
fix(proxy/hooks): populate llm_provider on batch-rate-limit 429s
cursoragent May 12, 2026
c2af427
fix(proxy/hooks): populate llm_provider on budget/iterations 429s
cursoragent May 12, 2026
e8ebd2e
test(proxy/hooks): pin provider field on internal rate-limit 429s
cursoragent May 12, 2026
24e80ba
perf(proxy/hooks): defer provider resolution to over-limit branches
cursoragent May 12, 2026
06e53d3
fix: use error_message in raise_rate_limit_error to avoid literal 'No…
cursoragent May 12, 2026
21ccf56
Consolidate rate_limiter_utils imports in dynamic_rate_limiter
cursoragent May 12, 2026
af811ec
fix(proxy): set num_retries/max_retries on ProxyHTTPRateLimitError
cursoragent May 12, 2026
d00ad1d
fix(mypy): silence base-class status_code conflict on ProxyHTTPRateLi…
cursoragent May 12, 2026
af49dbe
fix: annotate batch limiter _raise_rate_limit_error as NoReturn
cursoragent May 12, 2026
26abed5
feat(prometheus): rate-limit category/type labels + exception_class b…
mateo-berri May 13, 2026
e2370b2
merge: fold #27707 (provider/llm_provider on internal rate-limit erro…
cursoragent May 14, 2026
a748209
Merge remote-tracking branch 'origin/litellm_internal_staging' into l…
mateo-berri May 14, 2026
7e8a077
fix(bugbot): simplify rate-limit label coercion + guard None detail
mateo-berri May 14, 2026
807d1b2
fix(rate-limit): surface unified category/type fields on BudgetExceed…
mateo-berri May 14, 2026
f2d3243
fix(rate-limit): validate enum membership at duck-typed read sites + …
mateo-berri May 14, 2026
7747ad0
fix(rate-limit): resolve router model_name aliases to real provider (…
mateo-berri May 14, 2026
2621f36
fix(bugbot): preserve "BudgetExceededError" Prometheus label
mateo-berri May 14, 2026
f3907ea
test: fix invalid 'rpm' rate_limit_type in v3 limiter test mocks
cursoragent May 14, 2026
57682ee
fix(bugbot): hoist provider resolver + opt-in prom rate-limit labels
mateo-berri May 14, 2026
984f75d
fix: stabilize prometheus label sets and drop redundant model normali…
cursoragent May 14, 2026
9c5ff9d
perf(dynamic_rate_limiter): only resolve provider when rate limit hit
cursoragent May 14, 2026
5452c2f
test(prometheus): clear cached metric labels after toggling rate-limi…
cursoragent May 14, 2026
f2d0827
test: fix CI failures from prom label cache + flaky time-window asser…
mateo-berri May 14, 2026
11aa696
Merge origin/litellm_internal_staging into litellm_standardize_rate_l…
cursoragent Jun 3, 2026
276714b
feat(otel-v2): surface rate_limit_category + rate_limit_type on faile…
cursoragent Jun 3, 2026
11f78d8
test: align prometheus user-budget + logs quick-select tests with mer…
mateo-berri Jun 3, 2026
ff2c03b
Merge branch 'litellm_internal_staging' into litellm_standardize_rate…
mateo-berri Jun 3, 2026
e04e4c2
refactor(otel/v2): drop rate-limit decomposition from the LLM-call span
mateo-berri Jun 3, 2026
9c92921
fix(batch_rate_limiter): map max_parallel_requests to concurrent_requ…
cursoragent Jun 3, 2026
443563c
refactor(prometheus): drop transitive fastapi import from _get_except…
mateo-berri Jun 3, 2026
a10610f
Merge remote-tracking branch 'origin/litellm_internal_staging' into l…
mateo-berri Jun 6, 2026
2bb03bf
Merge remote-tracking branch 'origin/litellm_internal_staging' into l…
mateo-berri Jun 6, 2026
1ebf785
chore(ui): sync schema.d.ts with unified rate-limit error spec
mateo-berri Jun 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions litellm/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,13 @@ def _dev_env_hot_reload_enabled() -> bool:
custom_prometheus_tags: List[str] = []
prometheus_metrics_config: Optional[List] = None
prometheus_emit_stream_label: bool = False
# Opt-in: emit `rate_limit_category` and `rate_limit_type` labels on
# `litellm_proxy_failed_requests_metric`. Off by default to preserve the
# pre-unification label set so existing dashboards / recording rules keyed on
# that metric keep matching after upgrade. Enable when downstream consumers
# are ready to split 429s by source (vendor vs. litellm) and dimension
# (RPM/TPM/concurrent/budget).
prometheus_emit_rate_limit_labels: bool = False
prometheus_user_budget_label_include_email_alias: bool = False
prometheus_end_user_metrics_max_series_per_metric: Optional[int] = 10000
prometheus_end_user_metrics_ttl_seconds: Optional[float] = 3600.0
Expand Down Expand Up @@ -1303,6 +1310,8 @@ def add_known_models(model_cost_map: Optional[Dict] = None):
NotFoundError,
PermissionDeniedError,
RateLimitError,
RateLimitErrorCategory,
RateLimitType,
ServiceUnavailableError,
BadGatewayError,
OpenAIError,
Expand Down
161 changes: 159 additions & 2 deletions litellm/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,109 @@

## LiteLLM versions of the OpenAI Exception Types

from typing import Any, Dict, Optional
import enum
from typing import Any, Dict, Optional, Union

import httpx
import openai

from litellm.types.utils import LiteLLMCommonStrings


class RateLimitErrorCategory(str, enum.Enum):
"""
Category of a rate limit error, allowing callers to distinguish where the rate
limit originated. Exposed on every :class:`RateLimitError` instance via the
``category`` attribute.

Use these values to switch on the rate limit source, e.g.::

try:
...
except litellm.RateLimitError as e:
if e.category == RateLimitErrorCategory.LITELLM_RATE_LIMIT:
... # litellm's own limiter (key/team/user/model RPM/TPM/budget)
elif e.category == RateLimitErrorCategory.VENDOR_RATE_LIMIT:
... # the upstream LLM provider returned 429
"""

VENDOR_RATE_LIMIT = "vendor_rate_limit"
"""The upstream LLM provider returned a rate-limit response (e.g. OpenAI 429)."""

VENDOR_BATCH_RATE_LIMIT = "vendor_batch_rate_limit"
"""The upstream LLM provider returned a rate-limit response on a batch endpoint."""

LITELLM_RATE_LIMIT = "litellm_rate_limit"
"""LiteLLM's own rate limiter (key/team/user/model RPM/TPM, budget, parallel-requests, etc.) blocked the request."""

LITELLM_BATCH_RATE_LIMIT = "litellm_batch_rate_limit"
"""LiteLLM's own batch rate limiter (token/request budget across a batch input file) blocked the request."""


class RateLimitType(str, enum.Enum):
"""
The dimension that was exceeded when a rate-limit error fired.

This is orthogonal to :class:`RateLimitErrorCategory` — *category* tells
callers **who** rate-limited the request (the upstream vendor vs. one of
litellm's own limiters), while *type* tells them **which limit dimension**
was exceeded (an RPM ceiling, a TPM ceiling, a max-parallel-requests
ceiling, a budget cap, or a max-iterations cap).

Surfaced both on every :class:`RateLimitError` instance via the
``rate_limit_type`` attribute and on the structured
``StandardLoggingPayload.error_information.error_rate_limit_type`` field
so custom callbacks / metrics consumers can split rate-limit failures by
cause without parsing free-text error messages.
"""

REQUESTS = "requests"
"""Requests-per-minute (RPM) or requests-per-window ceiling exceeded."""

TOKENS = "tokens"
"""Tokens-per-minute (TPM) or tokens-per-window ceiling exceeded."""

CONCURRENT_REQUESTS = "concurrent_requests"
"""``max_parallel_requests`` — too many in-flight requests at once."""

BUDGET = "budget"
"""Spend budget cap reached (key, team, user, or per-session)."""

MAX_ITERATIONS = "max_iterations"
"""Per-session max-iterations cap reached (agent-style flows)."""


_RATE_LIMIT_CATEGORY_VALUES = frozenset(c.value for c in RateLimitErrorCategory)
_RATE_LIMIT_TYPE_VALUES = frozenset(t.value for t in RateLimitType)


def validate_rate_limit_category(value: Any) -> Optional[str]:
"""Return ``value`` only if it matches a known :class:`RateLimitErrorCategory`.

Used at duck-typed read sites (StandardLoggingPayload extraction, Prometheus
labels) to reject `.category` strings set by unrelated third-party exceptions
— otherwise those would leak into custom-callback payloads and Prometheus
label cardinality.
"""
if isinstance(value, RateLimitErrorCategory):
return value.value
if isinstance(value, str) and value in _RATE_LIMIT_CATEGORY_VALUES:
return value
return None


def validate_rate_limit_type(value: Any) -> Optional[str]:
"""Return ``value`` only if it matches a known :class:`RateLimitType`.

See :func:`validate_rate_limit_category` for the rationale.
"""
if isinstance(value, RateLimitType):
return value.value
if isinstance(value, str) and value in _RATE_LIMIT_TYPE_VALUES:
return value
return None


_MINIMAL_ERROR_RESPONSE: Optional[httpx.Response] = None


Expand Down Expand Up @@ -321,6 +417,18 @@ def __repr__(self):


class RateLimitError(openai.RateLimitError): # type: ignore
"""
Unified rate-limit error.

Every rate-limit condition surfaced by litellm — whether it originated from
an upstream LLM provider, a vendor batch endpoint, or one of litellm's own
proxy-side limiters (parallel-requests, dynamic-rate, batch-rate, budget,
max-iterations, etc.) — is raised as an instance of this class.

The :attr:`category` attribute lets callers distinguish the source. See
:class:`RateLimitErrorCategory` for the available values.
"""

def __init__(
self,
message,
Expand All @@ -330,6 +438,12 @@ def __init__(
litellm_debug_info: Optional[str] = None,
max_retries: Optional[int] = None,
num_retries: Optional[int] = None,
category: Union[str, RateLimitErrorCategory] = (
RateLimitErrorCategory.VENDOR_RATE_LIMIT
),
rate_limit_type: Optional[Union[str, RateLimitType]] = None,
headers: Optional[Dict[str, str]] = None,
detail: Any = None,
):
self.status_code = 429
self.message = "litellm.RateLimitError: {}".format(message)
Expand All @@ -338,9 +452,39 @@ def __init__(
self.litellm_debug_info = litellm_debug_info
self.max_retries = max_retries
self.num_retries = num_retries
self.category = (
category.value if isinstance(category, RateLimitErrorCategory) else category
)
# Which dimension was exceeded — request count, token count, parallel
# requests, budget, max iterations. None when the source didn't
# classify the failure (e.g. legacy vendor 429 with no header hints).
self.rate_limit_type: Optional[str] = (
rate_limit_type.value
if isinstance(rate_limit_type, RateLimitType)
else rate_limit_type
)
# Headers explicitly attached to the error (e.g. retry-after,
# rate_limit_type, reset_at). Preserved across the proxy boundary so
# clients can react appropriately.
#
# IMPORTANT: we deliberately do NOT auto-populate self.headers from
# response.headers when only `response` is provided. A vendor 429 can
# set arbitrary response headers (Set-Cookie, CORS overrides, …); if
# those leaked into e.headers and a downstream proxy serializer
# forwarded them to the client, a malicious upstream could inject
# browser-interpreted headers for the proxy origin. Vendor response
# headers stay reachable on `e.response.headers` for callers that
# explicitly want them; only the proxy-supplied `headers=` kwarg
# makes it onto `self.headers`.
_response_headers = (
getattr(response, "headers", None) if response is not None else None
)
self.headers: Optional[Dict[str, str]] = (
{k: str(v) for k, v in headers.items()} if headers else None
)
# Mirrors FastAPI HTTPException.detail so the same instance can be
# serialized through both the ProxyException and HTTPException paths.
self.detail = detail if detail is not None else self.message
self.response = httpx.Response(
status_code=429,
headers=_response_headers,
Expand Down Expand Up @@ -843,11 +987,24 @@ def __init__(

class BudgetExceededError(Exception):
def __init__(
self, current_cost: float, max_budget: float, message: Optional[str] = None
self,
current_cost: float,
max_budget: float,
message: Optional[str] = None,
llm_provider: Optional[str] = None,
):
self.current_cost = current_cost
self.max_budget = max_budget
self.status_code = 429
self.llm_provider = llm_provider or ""
Comment thread
cursor[bot] marked this conversation as resolved.
# Surface unified rate-limit fields without joining the RateLimitError
# hierarchy so existing `except BudgetExceededError:` handlers keep
# working; custom callbacks reading StandardLoggingPayload pick these
# up via the same `category` / `rate_limit_type` attributes the rest
# of the unified rate-limit error path uses. Stored as plain strings
# to match the normalization RateLimitError.__init__ performs.
self.category: str = RateLimitErrorCategory.LITELLM_RATE_LIMIT.value
self.rate_limit_type: str = RateLimitType.BUDGET.value
message = (
message
or f"Budget has been exceeded! Current cost: {current_cost}, Max budget: {max_budget}"
Expand Down
98 changes: 89 additions & 9 deletions litellm/integrations/prometheus.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@

import litellm
from litellm._logging import print_verbose, verbose_logger
from litellm.exceptions import (
validate_rate_limit_category,
validate_rate_limit_type,
)
from litellm.integrations.custom_logger import CustomLogger
from litellm.integrations.prometheus_helpers.bounded_prometheus_series_tracker import (
BoundedPrometheusSeriesTracker,
Expand Down Expand Up @@ -78,6 +82,20 @@ def __init__( # noqa: PLR0915
# Always initialize label_filters, even for non-premium users
self.label_filters = self._parse_prometheus_config()

# Cache resolved label sets per metric. Several entries in
# ``PrometheusMetricLabels.get_labels`` read module-level toggles
# (e.g. ``litellm.prometheus_emit_stream_label``,
# ``litellm.prometheus_emit_rate_limit_labels``) that can be
# changed at runtime. Prometheus counters/gauges/histograms are
# created with a *fixed* ``labelnames`` set; if a runtime call
# to ``get_labels_for_metric`` returned a different set, the
# subsequent ``counter.labels(**_labels)`` would raise a
# ``ValueError`` from the prometheus client. Snapshotting at
# logger init time pins the label set for the lifetime of the
# logger so toggling these flags only takes effect after a
# restart, keeping init-time and runtime label sets in sync.
self._cached_metric_labels: Dict[str, List[str]] = {}

_custom_buckets = litellm.prometheus_latency_buckets
self.latency_buckets = (
tuple(_custom_buckets)
Expand Down Expand Up @@ -1033,13 +1051,27 @@ def get_labels_for_metric(
self, metric_name: DEFINED_PROMETHEUS_METRICS
) -> List[str]:
"""
Get the labels for a metric, filtered if configured
Get the labels for a metric, filtered if configured.

The result is cached on the instance so the label set used to
construct each Prometheus metric at ``__init__`` time stays in lock
step with the label set passed to ``counter.labels(...)`` at
runtime, even if the underlying module-level toggles consulted by
:meth:`PrometheusMetricLabels.get_labels` (e.g.
``litellm.prometheus_emit_rate_limit_labels``,
``litellm.prometheus_emit_stream_label``) are flipped after the
logger has been created.
"""
cached = self._cached_metric_labels.get(metric_name)
if cached is not None:
return cached

# Get default labels for this metric from PrometheusMetricLabels
default_labels = PrometheusMetricLabels.get_labels(metric_name)

# If no label filtering is configured for this metric, use default labels
if metric_name not in self.label_filters:
self._cached_metric_labels[metric_name] = default_labels
return default_labels

# Get configured labels for this metric
Expand All @@ -1050,6 +1082,7 @@ def get_labels_for_metric(
label for label in default_labels if label in configured_labels
]

self._cached_metric_labels[metric_name] = filtered_labels
Comment thread
cursor[bot] marked this conversation as resolved.
return filtered_labels

def _track_end_user_metric_series(
Expand Down Expand Up @@ -2029,14 +2062,8 @@ async def async_post_call_failure_hook(

Proxy level tracking - failed client side requests

labelnames=[
"end_user",
"hashed_api_key",
"api_key_alias",
REQUESTED_MODEL,
"team",
"team_alias",
] + EXCEPTION_LABELS,
See :attr:`PrometheusMetricLabels.litellm_proxy_failed_requests_metric`
for the authoritative list of labels emitted on this metric.
"""
from litellm.litellm_core_utils.litellm_logging import (
StandardLoggingPayloadSetup,
Expand All @@ -2059,6 +2086,9 @@ async def async_post_call_failure_hook(
model_id = _metadata.get("model_info", {}).get("id") or request_data.get(
"model_info", {}
).get("id")
rate_limit_category, rate_limit_type = self._extract_rate_limit_labels(
original_exception
)
enum_values = UserAPIKeyLabelValues(
end_user=user_api_key_dict.end_user_id,
user=user_api_key_dict.user_id,
Expand All @@ -2073,6 +2103,8 @@ async def async_post_call_failure_hook(
status_code=str(status_code),
exception_status=str(status_code),
exception_class=self._get_exception_class_name(original_exception),
rate_limit_category=rate_limit_category,
rate_limit_type=rate_limit_type,
tags=_tags,
route=user_api_key_dict.request_route,
client_ip=_metadata.get("requester_ip_address"),
Expand Down Expand Up @@ -2843,6 +2875,33 @@ def record_check_batch_cost_error(self, error_type: str):

@staticmethod
def _get_exception_class_name(exception: Exception) -> str:
# Some exception types pin the ``exception_class`` label to a legacy
# value for back-compat with existing dashboards (e.g. proxy-side 429s
# keep reporting as "HTTPException"). Honor that opt-in marker before
# deriving the label from the runtime class name. Reading it via
# ``getattr`` keeps this core integrations module free of a transitive
# ``fastapi`` dependency.
legacy_class_name = getattr(exception, "prometheus_exception_class_name", None)
if isinstance(legacy_class_name, str) and legacy_class_name:
return legacy_class_name

# Same back-compat reasoning for ``BudgetExceededError``: the unified
# rate-limit error work attached ``.llm_provider`` to budget errors
# too (so callbacks reading ``StandardLoggingPayload`` get provider
# attribution). Without this short-circuit, the provider prefix below
# would silently flip the label from "BudgetExceededError" to e.g.
# "Openai.BudgetExceededError" and break dashboards keyed on the
# original value.
try:
from litellm.exceptions import BudgetExceededError
except ImportError:
BudgetExceededError = None # type: ignore[assignment,misc]

if BudgetExceededError is not None and isinstance(
exception, BudgetExceededError
):
return "BudgetExceededError"

exception_class_name = ""
if hasattr(exception, "llm_provider"):
exception_class_name = getattr(exception, "llm_provider") or ""
Expand All @@ -2857,6 +2916,27 @@ def _get_exception_class_name(exception: Exception) -> str:
exception_class_name += exception.__class__.__name__
return exception_class_name

@staticmethod
def _extract_rate_limit_labels(
exception: Optional[Exception],
) -> Tuple[Optional[str], Optional[str]]:
"""
Pull the unified ``category`` / ``rate_limit_type`` fields off any
exception that declares them (``litellm.RateLimitError`` and bare-
Exception subclasses like ``BudgetExceededError``).

Values are validated against the :class:`RateLimitErrorCategory` /
:class:`RateLimitType` enums so unrelated third-party exceptions that
happen to declare ``.category`` / ``.rate_limit_type`` string attributes
can't leak garbage into Prometheus label cardinality.
"""
if exception is None:
return None, None
return (
validate_rate_limit_category(getattr(exception, "category", None)),
validate_rate_limit_type(getattr(exception, "rate_limit_type", None)),
)

async def log_success_fallback_event(
self, original_model_group: str, kwargs: dict, original_exception: Exception
):
Expand Down
Loading
Loading