Merge remote-tracking branch 'origin/litellm_internal_staging' into litellm_/lucid-heyrovsky-991ad8

This commit is contained in:
Yuneng Jiang 2026-06-06 18:03:53 -07:00
commit b1b131659b
No known key found for this signature in database
35 changed files with 3572 additions and 514 deletions

View file

@ -442,6 +442,13 @@ custom_prometheus_metadata_labels: List[str] = []
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
@ -1303,6 +1310,8 @@ from .exceptions import (
NotFoundError,
PermissionDeniedError,
RateLimitError,
RateLimitErrorCategory,
RateLimitType,
ServiceUnavailableError,
BadGatewayError,
OpenAIError,

View file

@ -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
@ -321,6 +417,18 @@ class PermissionDeniedError(openai.PermissionDeniedError): # type: ignore
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,
@ -330,6 +438,12 @@ class RateLimitError(openai.RateLimitError): # type: ignore
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)
@ -338,9 +452,39 @@ class RateLimitError(openai.RateLimitError): # type: ignore
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,
@ -843,11 +987,24 @@ LITELLM_EXCEPTION_TYPES = [
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 ""
# 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}"

View file

@ -24,6 +24,10 @@ from typing import (
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,
@ -78,6 +82,20 @@ class PrometheusLogger(CustomLogger):
# 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)
@ -1033,13 +1051,27 @@ class PrometheusLogger(CustomLogger):
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
@ -1050,6 +1082,7 @@ class PrometheusLogger(CustomLogger):
label for label in default_labels if label in configured_labels
]
self._cached_metric_labels[metric_name] = filtered_labels
return filtered_labels
def _track_end_user_metric_series(
@ -2029,14 +2062,8 @@ class PrometheusLogger(CustomLogger):
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,
@ -2059,6 +2086,9 @@ class PrometheusLogger(CustomLogger):
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,
@ -2073,6 +2103,8 @@ class PrometheusLogger(CustomLogger):
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"),
@ -2843,6 +2875,33 @@ class PrometheusLogger(CustomLogger):
@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 ""
@ -2857,6 +2916,27 @@ class PrometheusLogger(CustomLogger):
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
):

View file

@ -37,6 +37,10 @@ from litellm import (
turn_off_message_logging,
)
from litellm._logging import _is_debugging_on, _redact_string, verbose_logger
from litellm.exceptions import (
validate_rate_limit_category,
validate_rate_limit_type,
)
from litellm._uuid import uuid
from litellm.batches.batch_utils import _handle_completed_batch
from litellm.caching.caching import DualCache, InMemoryCache
@ -5318,12 +5322,27 @@ class StandardLoggingPayloadSetup:
else str(original_exception)
)
# Duck-typed read so bare-Exception subclasses like
# `litellm.BudgetExceededError` can participate without joining the
# RateLimitError hierarchy (which would break `except BudgetExceededError`).
# Validated against the enum value sets so a third-party exception that
# happens to declare a `.category` or `.rate_limit_type` string attribute
# can't leak garbage into the payload or Prometheus label cardinality.
rate_limit_category = validate_rate_limit_category(
getattr(original_exception, "category", None)
)
rate_limit_type = validate_rate_limit_type(
getattr(original_exception, "rate_limit_type", None)
)
return StandardLoggingPayloadErrorInformation(
error_code=error_status,
error_class=error_class,
llm_provider=_llm_provider_in_exception,
traceback=traceback_info,
error_message=error_message if original_exception else "",
error_rate_limit_category=rate_limit_category,
error_rate_limit_type=rate_limit_type,
)
@staticmethod

View file

@ -4125,6 +4125,10 @@ class TeamMemberUpdateRequest(TeamMemberDeleteRequest):
rpm_limit: Optional[int] = Field(
default=None, description="Requests per minute limit for this team member"
)
budget_duration: Optional[str] = Field(
default=None,
description="Duration after which this team member's budget resets (e.g. '1h', '24h', '7d', '30d'). If not set, the budget never resets.",
)
allowed_models: Optional[List[str]] = Field(
default=None,
description="List of models this team member can access. Pass an empty list to remove per-member model restrictions.",
@ -4136,6 +4140,7 @@ class TeamMemberUpdateResponse(MemberUpdateResponse):
max_budget_in_team: Optional[float] = None
tpm_limit: Optional[int] = None
rpm_limit: Optional[int] = None
budget_duration: Optional[str] = None
allowed_models: Optional[List[str]] = None

View file

@ -126,6 +126,20 @@ class UserAPIKeyAuthExceptionHandler:
model=request_data.get("model"),
)
# Budget checks live in tenant-scoped helpers (key / team / org / tag)
# that don't see the request model, so the BudgetExceededError they
# raise carries `llm_provider=""`. Resolve it here off `request_data`
# so custom-callback consumers reading StandardLoggingPayload get
# the same `llm_provider` attribution as for RPM/TPM 429s.
if isinstance(e, litellm.BudgetExceededError) and not e.llm_provider:
from litellm.proxy.hooks.rate_limiter_utils import (
resolve_llm_provider_for_rate_limit,
)
_, e.llm_provider = resolve_llm_provider_for_rate_limit(
request_data.get("model")
)
# Allow callbacks to transform the error response
transformed_exception = await proxy_logging_obj.post_call_failure_hook(
request_data=request_data,

View file

@ -0,0 +1,196 @@
"""
ProxyRateLimitError — a unified rate-limit exception used by litellm's
proxy-side hooks.
Background
----------
LiteLLM previously surfaced rate-limit conditions through *several* unrelated
exception types:
* :class:`litellm.exceptions.RateLimitError` — raised by exception mapping when
an upstream LLM provider returns 429.
* :class:`fastapi.HTTPException` (status 429) — raised directly by proxy hooks
such as ``parallel_request_limiter``, ``dynamic_rate_limiter``,
``batch_rate_limiter``, ``max_budget_limiter``, ``max_iterations_limiter``,
etc.
* :class:`litellm.llms.base_llm.chat.transformation.BaseLLMException` (status
429) — raised by some provider transports.
This made it impossible for downstream code (and end users) to express
"is this a rate limit?" with a single ``except`` clause, and impossible to
distinguish *where* the rate limit originated (vendor vs. litellm, batch vs.
chat) without ad-hoc string-matching on the message.
This module provides a single proxy-side error class that:
1. Is a subclass of :class:`litellm.exceptions.RateLimitError`, so user code
that catches ``RateLimitError`` works for *every* rate-limit source.
2. Is also a subclass of :class:`fastapi.HTTPException`, so existing proxy
plumbing (``isinstance(e, HTTPException)`` branches in route handlers and
FastAPI's own dispatcher) continues to behave the same way and the
``retry-after`` / ``rate_limit_type`` / ``reset_at`` headers are preserved
on the wire.
3. Carries a :attr:`category` field (one of
:class:`litellm.exceptions.RateLimitErrorCategory`) so callers can switch on
the rate limit source.
"""
import json
from typing import Any, Dict, Mapping, Optional, Union
from fastapi import HTTPException
from litellm.exceptions import RateLimitError, RateLimitErrorCategory, RateLimitType
def map_v3_rate_limit_type(
v3_value: Optional[str],
) -> Optional[RateLimitType]:
"""
Map the v3 rate limiter's internal `status["rate_limit_type"]` strings
onto the public :class:`RateLimitType` enum.
The v3 limiter uses the literal values ``"requests"``, ``"tokens"``, and
``"max_parallel_requests"``. We collapse the last one onto
:attr:`RateLimitType.CONCURRENT_REQUESTS` because that's the public name
documented for users and dashboards. Unrecognized values return ``None``
so the field stays absent rather than carrying garbage downstream.
"""
if v3_value == "tokens":
return RateLimitType.TOKENS
if v3_value == "max_parallel_requests":
return RateLimitType.CONCURRENT_REQUESTS
if v3_value == "requests":
return RateLimitType.REQUESTS
return None
def _coerce_message(detail: Any) -> str:
"""Best-effort, JSON-friendly stringification of an HTTPException-style detail."""
if detail is None:
return ""
if isinstance(detail, str):
return detail
if isinstance(detail, Mapping):
for key in ("error", "message"):
if isinstance(detail.get(key), str):
return detail[key]
inner = detail.get(key)
if isinstance(inner, Mapping) and isinstance(inner.get("message"), str):
return inner["message"]
try:
return json.dumps(detail)
except (TypeError, ValueError):
return str(detail)
return str(detail)
# NOTE: mypy emits two `[misc]` errors on the class line below because the
# bases declare overlapping attributes with related-but-not-identical
# annotations:
# * `status_code` is `int` on starlette HTTPException but `Literal[429]` on
# openai.RateLimitError (every openai status-error subclass narrows it
# this way and silences pyright with the same convention).
# * `headers` is `Mapping[str, str] | None` on HTTPException; we narrow it
# to `Optional[Dict[str, str]]` on RateLimitError because we always carry
# a stringified dict.
# Both narrowings are intentional and handled at construction time — every
# instance always has status_code == 429 and a Dict-typed headers — so we
# silence the ATTR-overlap check rather than relax the annotations.
class ProxyRateLimitError(HTTPException, RateLimitError): # type: ignore[misc]
"""
A 429 raised by litellm's proxy-side rate limiting hooks.
This class deliberately inherits from BOTH
:class:`litellm.exceptions.RateLimitError` and :class:`fastapi.HTTPException`
so the same instance can flow through:
* ``except RateLimitError`` (user / SDK code that wants a category-aware
handler), and
* ``isinstance(e, HTTPException)`` (FastAPI / proxy_server.py route
handlers that need to forward ``status_code``, ``detail`` and
``headers`` back to the client).
Downstream code should prefer this class over
``raise HTTPException(status_code=429, ...)`` for litellm-internal rate
limits.
Parameters
----------
detail:
The structured error payload. Forwarded as ``HTTPException.detail`` so
FastAPI's default exception handler will serialize it verbatim.
headers:
Optional response headers (e.g. ``retry-after``). Values are stringified
to satisfy FastAPI's typing.
category:
One of :class:`RateLimitErrorCategory`. Defaults to
``LITELLM_RATE_LIMIT`` since this class is only used by litellm's own
proxy-side limiters; pass ``LITELLM_BATCH_RATE_LIMIT`` for the batch
limiter, etc.
model / llm_provider:
Optional context, propagated to the inherited ``RateLimitError`` for
compatibility with logging / standard payload extraction.
"""
# Prometheus' ``exception_class`` label is pinned to "HTTPException" for
# this type: before the unified class existed, proxy-side 429s surfaced as
# ``fastapi.HTTPException`` and existing dashboards/alerts key off that exact
# value. Distinguishing vendor vs. litellm 429s is now the job of the
# ``rate_limit_category`` / ``rate_limit_type`` labels.
prometheus_exception_class_name = "HTTPException"
def __init__(
self,
detail: Any,
headers: Optional[Mapping[str, Any]] = None,
category: Union[
str, RateLimitErrorCategory
] = RateLimitErrorCategory.LITELLM_RATE_LIMIT,
rate_limit_type: Optional[Union[str, RateLimitType]] = None,
model: Optional[str] = None,
llm_provider: Optional[str] = "litellm_proxy",
):
# Normalize None → safe defaults so callers (and the resolver helper
# in `rate_limiter_utils`) can pass `None` without producing an
# instance whose `.llm_provider` attribute is `None` — that would
# break Prometheus' `_get_exception_class_name` (it calls
# `.capitalize()` on the provider string).
model = model or ""
llm_provider = llm_provider or "litellm_proxy"
message = _coerce_message(detail)
stringified_headers: Optional[Dict[str, str]] = (
{k: str(v) for k, v in headers.items()} if headers else None
)
# Initialize the FastAPI HTTPException portion first so its attributes
# (status_code, detail, headers) are already on the instance before
# RateLimitError.__init__ runs and possibly overrides them.
HTTPException.__init__(
self,
status_code=429,
detail=detail,
headers=stringified_headers,
)
# Now initialize the litellm RateLimitError portion. We deliberately
# pass the structured detail through so RateLimitError preserves it as
# its `.detail` attribute too — keeping both sides of the MRO
# consistent.
RateLimitError.__init__(
self,
message=message,
llm_provider=llm_provider,
model=model,
category=category,
rate_limit_type=rate_limit_type,
headers=stringified_headers,
detail=detail,
)
# RateLimitError.__init__ overwrites self.headers with its own copy and
# leaves self.status_code at 429 — restore the HTTPException-style
# headers value so downstream code that pulls headers off the
# instance gets back exactly what the limiter passed in.
self.headers = stringified_headers
self.detail = detail
self.status_code = 429

View file

@ -17,7 +17,17 @@ Quick summary:
- async_log_success_event() fires on GET /v1/batches/{id} (batch completion)
"""
from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Tuple, Union
from typing import (
TYPE_CHECKING,
Any,
Dict,
List,
Literal,
NoReturn,
Optional,
Tuple,
Union,
)
from fastapi import HTTPException
from pydantic import BaseModel
@ -30,6 +40,7 @@ from litellm.batches.batch_utils import (
_get_file_content_as_dictionary,
_get_models_from_batch_input_file_content,
)
from litellm.exceptions import RateLimitErrorCategory
from litellm.integrations.custom_logger import CustomLogger
from litellm.proxy._types import (
ProxyErrorTypes,
@ -37,10 +48,11 @@ from litellm.proxy._types import (
SpecialModelNames,
UserAPIKeyAuth,
)
from litellm.proxy.hooks.rate_limiter_utils import (
ProxyHTTPRateLimitError,
resolve_llm_provider_for_rate_limit,
from litellm.proxy.common_utils.proxy_rate_limit_error import (
ProxyRateLimitError,
map_v3_rate_limit_type,
)
from litellm.proxy.hooks.rate_limiter_utils import resolve_llm_provider_for_rate_limit
if TYPE_CHECKING:
from opentelemetry.trace import Span as _Span
@ -385,8 +397,8 @@ class _PROXY_BatchRateLimiter(CustomLogger):
batch_usage: BatchFileUsage,
limit_type: str,
requested_model: Optional[str] = None,
) -> None:
"""Raise HTTPException for rate limit exceeded."""
) -> NoReturn:
"""Raise :class:`ProxyRateLimitError` (a 429) for batch rate limit exceeded."""
from datetime import datetime
# Find the descriptor for this status
@ -432,14 +444,15 @@ class _PROXY_BatchRateLimiter(CustomLogger):
resolved_model, llm_provider = resolve_llm_provider_for_rate_limit(
requested_model
)
raise ProxyHTTPRateLimitError(
status_code=429,
raise ProxyRateLimitError(
detail=detail,
headers={
"retry-after": str(window_size),
"rate_limit_type": limit_type,
"reset_at": reset_time_formatted,
},
category=RateLimitErrorCategory.LITELLM_BATCH_RATE_LIMIT,
rate_limit_type=map_v3_rate_limit_type(limit_type),
model=resolved_model,
llm_provider=llm_provider,
)

View file

@ -11,9 +11,10 @@ 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.exceptions import RateLimitType
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.common_utils.proxy_rate_limit_error import ProxyRateLimitError
from litellm.proxy.hooks.rate_limiter_utils import (
ProxyHTTPRateLimitError,
convert_priority_to_percent,
resolve_llm_provider_for_rate_limit,
)
@ -222,8 +223,7 @@ class _PROXY_DynamicRateLimitHandler(CustomLogger):
resolved_model, llm_provider = resolve_llm_provider_for_rate_limit(
data.get("model")
)
raise ProxyHTTPRateLimitError(
status_code=429,
raise ProxyRateLimitError(
detail={
"error": "Key={} over available TPM={}. Model TPM={}, Active keys={}".format(
user_api_key_dict.api_key,
@ -232,6 +232,7 @@ class _PROXY_DynamicRateLimitHandler(CustomLogger):
active_projects,
)
},
rate_limit_type=RateLimitType.TOKENS,
model=resolved_model,
llm_provider=llm_provider,
)
@ -240,8 +241,7 @@ class _PROXY_DynamicRateLimitHandler(CustomLogger):
resolved_model, llm_provider = resolve_llm_provider_for_rate_limit(
data.get("model")
)
raise ProxyHTTPRateLimitError(
status_code=429,
raise ProxyRateLimitError(
detail={
"error": "Key={} over available RPM={}. Model RPM={}, Active keys={}".format(
user_api_key_dict.api_key,
@ -250,6 +250,7 @@ class _PROXY_DynamicRateLimitHandler(CustomLogger):
active_projects,
)
},
rate_limit_type=RateLimitType.REQUESTS,
model=resolved_model,
llm_provider=llm_provider,
)

View file

@ -14,13 +14,16 @@ 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.common_utils.proxy_rate_limit_error import (
ProxyRateLimitError,
map_v3_rate_limit_type,
)
from litellm.proxy.hooks.parallel_request_limiter_v3 import (
RateLimitDescriptor,
RateLimitDescriptorRateLimitObject,
_PROXY_MaxParallelRequestsHandler_v3,
)
from litellm.proxy.hooks.rate_limiter_utils import (
ProxyHTTPRateLimitError,
convert_priority_to_percent,
resolve_llm_provider_for_rate_limit,
)
@ -497,8 +500,7 @@ class _PROXY_DynamicRateLimitHandlerV3(CustomLogger):
continue
descriptor_key = status["descriptor_key"]
if descriptor_key == "model_saturation_check":
raise ProxyHTTPRateLimitError(
status_code=429,
raise ProxyRateLimitError(
detail={
"error": f"Model capacity reached for {model}. "
f"Priority: {priority}, "
@ -512,6 +514,9 @@ class _PROXY_DynamicRateLimitHandlerV3(CustomLogger):
"rate_limit_type": str(status["rate_limit_type"]),
"x-litellm-priority": priority or "default",
},
rate_limit_type=map_v3_rate_limit_type(
status["rate_limit_type"]
),
model=resolved_model,
llm_provider=llm_provider,
)
@ -520,8 +525,7 @@ class _PROXY_DynamicRateLimitHandlerV3(CustomLogger):
f"Enforcing priority limits for {model}, saturation: {saturation:.1%}, "
f"priority: {priority}"
)
raise ProxyHTTPRateLimitError(
status_code=429,
raise ProxyRateLimitError(
detail={
"error": f"Priority-based rate limit exceeded. "
f"Model: {model}, "
@ -538,6 +542,9 @@ class _PROXY_DynamicRateLimitHandlerV3(CustomLogger):
"x-litellm-priority": priority or "default",
"x-litellm-saturation": f"{saturation:.2%}",
},
rate_limit_type=map_v3_rate_limit_type(
status["rate_limit_type"]
),
model=resolved_model,
llm_provider=llm_provider,
)
@ -556,8 +563,7 @@ class _PROXY_DynamicRateLimitHandlerV3(CustomLogger):
f"Dynamic rate limiter: OVER_LIMIT response with unknown "
f"descriptor_key(s) — refusing request. response={atomic_response}"
)
raise ProxyHTTPRateLimitError(
status_code=429,
raise ProxyRateLimitError(
detail={
"error": "Rate limit exceeded",
"descriptor_key": (
@ -567,6 +573,9 @@ class _PROXY_DynamicRateLimitHandlerV3(CustomLogger):
str(offending["rate_limit_type"]) if offending else "unknown"
),
},
rate_limit_type=map_v3_rate_limit_type(
offending["rate_limit_type"] if offending else None
),
headers={
"retry-after": str(self.v3_limiter.window_size),
"x-litellm-priority": priority or "default",

View file

@ -4,11 +4,10 @@ from litellm import verbose_logger
from litellm._logging import verbose_proxy_logger
from litellm.caching.caching import DualCache
from litellm.integrations.custom_logger import CustomLogger
from litellm.exceptions import RateLimitType
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.hooks.rate_limiter_utils import (
ProxyHTTPRateLimitError,
resolve_llm_provider_for_rate_limit,
)
from litellm.proxy.common_utils.proxy_rate_limit_error import ProxyRateLimitError
from litellm.proxy.hooks.rate_limiter_utils import resolve_llm_provider_for_rate_limit
class _PROXY_MaxBudgetLimiter(CustomLogger):
@ -70,9 +69,9 @@ class _PROXY_MaxBudgetLimiter(CustomLogger):
resolved_model, llm_provider = resolve_llm_provider_for_rate_limit(
data.get("model") if data else None
)
raise ProxyHTTPRateLimitError(
status_code=429,
raise ProxyRateLimitError(
detail="Max budget limit reached.",
rate_limit_type=RateLimitType.BUDGET,
model=resolved_model,
llm_provider=llm_provider,
)

View file

@ -20,11 +20,10 @@ from typing import TYPE_CHECKING, Any, Optional, Union
from litellm import DualCache
from litellm._logging import verbose_proxy_logger
from litellm.integrations.custom_logger import CustomLogger
from litellm.exceptions import RateLimitType
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.hooks.rate_limiter_utils import (
ProxyHTTPRateLimitError,
resolve_llm_provider_for_rate_limit,
)
from litellm.proxy.common_utils.proxy_rate_limit_error import ProxyRateLimitError
from litellm.proxy.hooks.rate_limiter_utils import resolve_llm_provider_for_rate_limit
if TYPE_CHECKING:
from litellm.proxy.utils import InternalUsageCache as _InternalUsageCache
@ -117,13 +116,13 @@ class _PROXY_MaxBudgetPerSessionHandler(CustomLogger):
resolved_model, llm_provider = resolve_llm_provider_for_rate_limit(
data.get("model") if data else None
)
raise ProxyHTTPRateLimitError(
status_code=429,
raise ProxyRateLimitError(
detail=(
f"Session budget exceeded for session {session_id}. "
f"Current spend: ${current_spend:.4f}, "
f"max_budget_per_session: ${max_budget:.2f}."
),
rate_limit_type=RateLimitType.BUDGET,
model=resolved_model,
llm_provider=llm_provider,
)

View file

@ -16,11 +16,10 @@ from typing import TYPE_CHECKING, Any, Optional, Union
from litellm import DualCache
from litellm._logging import verbose_proxy_logger
from litellm.integrations.custom_logger import CustomLogger
from litellm.exceptions import RateLimitType
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.hooks.rate_limiter_utils import (
ProxyHTTPRateLimitError,
resolve_llm_provider_for_rate_limit,
)
from litellm.proxy.common_utils.proxy_rate_limit_error import ProxyRateLimitError
from litellm.proxy.hooks.rate_limiter_utils import resolve_llm_provider_for_rate_limit
if TYPE_CHECKING:
from litellm.proxy.utils import InternalUsageCache as _InternalUsageCache
@ -121,12 +120,12 @@ class _PROXY_MaxIterationsHandler(CustomLogger):
resolved_model, llm_provider = resolve_llm_provider_for_rate_limit(
data.get("model") if data else None
)
raise ProxyHTTPRateLimitError(
status_code=429,
raise ProxyRateLimitError(
detail=(
f"Max iterations exceeded for session {session_id}. "
f"Current count: {current_count}, max_iterations: {max_iterations}."
),
rate_limit_type=RateLimitType.MAX_ITERATIONS,
model=resolved_model,
llm_provider=llm_provider,
)

View file

@ -1,9 +1,8 @@
import asyncio
import sys
from datetime import datetime, timedelta
from typing import TYPE_CHECKING, Any, List, Literal, Optional, Tuple, Union
from typing import TYPE_CHECKING, Any, List, Literal, NoReturn, Optional, Tuple, Union
from fastapi import HTTPException
from pydantic import BaseModel
from typing_extensions import TypedDict
@ -13,14 +12,13 @@ from litellm._logging import verbose_proxy_logger
from litellm.integrations.custom_logger import CustomLogger
from litellm.litellm_core_utils.core_helpers import _get_parent_otel_span_from_kwargs
from litellm.proxy._types import CommonProxyErrors, CurrentItemRateLimit, UserAPIKeyAuth
from litellm.exceptions import RateLimitType
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,
)
from litellm.proxy.common_utils.proxy_rate_limit_error import ProxyRateLimitError
from litellm.proxy.hooks.rate_limiter_utils import resolve_llm_provider_for_rate_limit
if TYPE_CHECKING:
from opentelemetry.trace import Span as _Span
@ -75,9 +73,21 @@ class _PROXY_MaxParallelRequestsHandler(CustomLogger):
)
if current is None:
if max_parallel_requests == 0 or tpm_limit == 0 or rpm_limit == 0:
# base case
raise self.raise_rate_limit_error(
# base case — at least one dimension is set to 0 (effectively
# disabled). Pick the most specific dimension as the
# rate_limit_type so dashboards can attribute the failure to
# the right cap. Order matters: max_parallel_requests is
# listed first because it's the rarest 0 in practice and the
# most actionable signal.
if max_parallel_requests == 0:
triggered_type = RateLimitType.CONCURRENT_REQUESTS
elif tpm_limit == 0:
triggered_type = RateLimitType.TOKENS
else:
triggered_type = RateLimitType.REQUESTS
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}",
rate_limit_type=triggered_type,
requested_model=data.get("model") if data else None,
)
new_val = {
@ -100,14 +110,23 @@ class _PROXY_MaxParallelRequestsHandler(CustomLogger):
values_to_update_in_cache.append((request_count_api_key, new_val))
else:
# Detect which dimension actually tripped the limit so we can
# surface the right rate_limit_type. Order matches the boolean
# condition above (concurrent → tpm → rpm) — first match wins.
if int(current["current_requests"]) >= max_parallel_requests:
triggered_type = RateLimitType.CONCURRENT_REQUESTS
elif current["current_tpm"] >= tpm_limit:
triggered_type = RateLimitType.TOKENS
else:
triggered_type = RateLimitType.REQUESTS
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,
raise ProxyRateLimitError(
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())},
rate_limit_type=triggered_type,
model=resolved_model,
llm_provider=llm_provider,
)
@ -135,27 +154,45 @@ class _PROXY_MaxParallelRequestsHandler(CustomLogger):
def raise_rate_limit_error(
self,
additional_details: Optional[str] = None,
rate_limit_type: Optional[RateLimitType] = None,
requested_model: Optional[str] = None,
) -> HTTPException:
) -> NoReturn:
"""
Raise an HTTPException with a 429 status code and a retry-after header.
Raise a 429 with a retry-after header for litellm-proxy parallel-request limits.
Always raises :class:`ProxyRateLimitError` — never returns. Annotated
``NoReturn`` so type-checkers know callers after this invocation are
unreachable. The raised exception is both a
:class:`litellm.RateLimitError` (so callers can catch by category) and a
:class:`fastapi.HTTPException` (so the FastAPI dispatcher serializes it
correctly with status 429 and the supplied headers).
``rate_limit_type`` defaults to ``CONCURRENT_REQUESTS`` because every
existing internal caller of this helper hits the parallel-request cap
(the global-limit branch in ``async_pre_call_hook`` and the
all-zeros base case in ``check_key_in_limits``). Callers that know
the dimension exactly should pass it explicitly.
``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``.
raised exception carries ``llm_provider`` (and a stripped ``model``)
for downstream loggers (Prometheus failure metric, observability
callbacks). Falls back to ``llm_provider="litellm_proxy"`` when the
model is missing or unparseable — see
:func:`resolve_llm_provider_for_rate_limit`.
"""
# additional_details is optional; build the detail with a None-guard
# so callers that pass nothing don't get the literal string "None"
# interpolated into the error message.
error_message = "Max parallel request limit reached"
if additional_details is not None:
error_message = error_message + " " + additional_details
resolved_model, llm_provider = resolve_llm_provider_for_rate_limit(
requested_model
)
raise ProxyHTTPRateLimitError(
status_code=429,
raise ProxyRateLimitError(
detail=error_message,
headers={"retry-after": str(self.time_to_next_minute())},
rate_limit_type=rate_limit_type or RateLimitType.CONCURRENT_REQUESTS,
model=resolved_model,
llm_provider=llm_provider,
)
@ -248,7 +285,7 @@ class _PROXY_MaxParallelRequestsHandler(CustomLogger):
current_global_requests = 1
# if above -> raise error
if current_global_requests >= global_max_parallel_requests:
return self.raise_rate_limit_error(
self.raise_rate_limit_error(
additional_details=f"Hit Global Limit: Limit={global_max_parallel_requests}, current: {current_global_requests}",
requested_model=data.get("model") if data else None,
)

View file

@ -32,10 +32,11 @@ 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.proxy.common_utils.proxy_rate_limit_error import (
ProxyRateLimitError,
map_v3_rate_limit_type,
)
from litellm.proxy.hooks.rate_limiter_utils import 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
@ -1971,7 +1972,7 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
descriptors: List[RateLimitDescriptor],
requested_model: Optional[str] = None,
) -> None:
"""Handle rate limit exceeded error by raising HTTPException."""
"""Handle rate limit exceeded by raising :class:`ProxyRateLimitError` (a 429)."""
for status in response["statuses"]:
if status["code"] == "OVER_LIMIT":
descriptor_key = status["descriptor_key"]
@ -2005,14 +2006,14 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
resolved_model, llm_provider = resolve_llm_provider_for_rate_limit(
requested_model
)
raise ProxyHTTPRateLimitError(
status_code=429,
raise ProxyRateLimitError(
detail=detail,
headers={
"retry-after": str(self.window_size),
"rate_limit_type": str(status["rate_limit_type"]),
"reset_at": reset_time_formatted,
},
rate_limit_type=map_v3_rate_limit_type(status["rate_limit_type"]),
model=resolved_model,
llm_provider=llm_provider,
)

View file

@ -2,13 +2,10 @@
Shared utility functions for rate limiter hooks.
"""
from typing import Any, Optional, Tuple, Union
from fastapi import HTTPException
from typing import Optional, Tuple, Union
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
@ -29,11 +26,21 @@ def resolve_llm_provider_for_rate_limit(
``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.
Resolution order:
1. ``litellm.get_llm_provider(model)`` — covers raw provider/model
strings the SDK already understands (``"gpt-4o-mini"``,
``"anthropic/claude-3-5-sonnet"``, ``"bedrock/..."`` etc.).
2. **Router alias fallback** — nearly every real proxy deployment
routes through a router ``model_name`` alias (e.g.
``"tpm-locked"`` → ``litellm_params.model: openai/gpt-4o-mini``).
``get_llm_provider`` doesn't know router aliases, so without this
step every alias call ended up labeled ``"litellm_proxy"``,
defeating the field's purpose for the most common case.
3. Defensive fallback to ``("", "litellm_proxy")`` — used only when
``model`` is missing, malformed, or both lookups fail. We never let
a secondary exception escape and mask the rate-limit error we're
trying to surface.
"""
if not model:
return "", PROXY_LLM_PROVIDER_FALLBACK
@ -46,6 +53,9 @@ def resolve_llm_provider_for_rate_limit(
custom_llm_provider or PROXY_LLM_PROVIDER_FALLBACK,
)
except Exception as e:
alias_resolution = _resolve_provider_from_router_alias(model)
if alias_resolution is not None:
return alias_resolution
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",
@ -56,50 +66,58 @@ def resolve_llm_provider_for_rate_limit(
return model, PROXY_LLM_PROVIDER_FALLBACK
class ProxyHTTPRateLimitError(HTTPException, RateLimitError): # type: ignore[misc]
def _resolve_provider_from_router_alias(
model: str,
) -> Optional[Tuple[str, str]]:
"""
HTTPException raised by proxy-side rate-limit hooks that *also* exposes
``model`` and ``llm_provider`` attributes.
Resolve a router ``model_name`` alias to ``(underlying_model, provider)``
by scanning the active router's ``model_list``.
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.
Returns ``None`` if the router isn't initialized, the alias isn't
registered, the deployment has no usable ``litellm_params.model``, or
any underlying lookup raises. Callers fall through to the defensive
``litellm_proxy`` fallback in that case — never raising secondary
exceptions out of the rate-limit raise path.
"""
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
try:
from litellm.proxy.proxy_server import llm_router
except Exception:
return None
if llm_router is None:
return None
try:
model_list = getattr(llm_router, "model_list", None)
if not model_list:
return None
for deployment in model_list:
if not isinstance(deployment, dict):
continue
if deployment.get("model_name") != model:
continue
params = deployment.get("litellm_params")
if not isinstance(params, dict):
continue
underlying_model = params.get("model")
if not isinstance(underlying_model, str) or not underlying_model:
continue
try:
resolved_model, custom_llm_provider, _, _ = litellm.get_llm_provider(
model=underlying_model,
)
except Exception:
continue
if not custom_llm_provider:
continue
# Prefer the underlying provider-qualified model so the failure
# callback / Prometheus label points at the actual deployment, not
# the alias.
return (
resolved_model or underlying_model,
custom_llm_provider,
)
return None
except Exception:
return None
def convert_priority_to_percent(

View file

@ -1,4 +1,4 @@
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union
from typing import TYPE_CHECKING, Any, Dict, Optional, Union
from fastapi import HTTPException, status
from pydantic import BaseModel
@ -19,6 +19,7 @@ from litellm.proxy._types import (
UserAPIKeyAuth,
user_api_key_has_admin_view as _user_has_admin_view, # noqa: F401 re-exported
)
from litellm.proxy.common_utils.timezone_utils import get_budget_reset_time
from litellm.proxy.utils import _premium_user_check
if TYPE_CHECKING:
@ -400,121 +401,127 @@ def _set_object_metadata_field(
object_data.metadata[field_name] = value
_TEAM_MEMBER_BUDGET_LIMIT_FIELDS = (
"max_budget",
"soft_budget",
"max_parallel_requests",
"tpm_limit",
"rpm_limit",
"model_max_budget",
"budget_duration",
"allowed_models",
)
def _is_set_budget_value(value: Any) -> bool:
if value is None:
return False
if isinstance(value, list) and len(value) == 0:
return False
return True
def _has_meaningful_budget_limit(budget_values: Dict[str, Any]) -> bool:
"""A budget is meaningful if at least one limit is actually set; an empty
list (no model restriction) and None both count as unset."""
return any(
_is_set_budget_value(budget_values.get(field))
for field in _TEAM_MEMBER_BUDGET_LIMIT_FIELDS
)
async def _upsert_budget_and_membership(
tx,
*,
team_id: str,
user_id: str,
max_budget: Optional[float],
existing_budget_id: Optional[str],
user_api_key_dict: UserAPIKeyAuth,
tpm_limit: Optional[int] = None,
rpm_limit: Optional[int] = None,
allowed_models: Optional[List[str]] = None,
budget_patch: Dict[str, Any],
team_default_budget_id: Optional[str] = None,
):
"""
Helper function to Create/Update or Delete the budget within the team membership
Args:
tx: The transaction object
team_id: The ID of the team
user_id: The ID of the user
max_budget: The maximum budget for the team
existing_budget_id: The ID of the existing budget, if any
user_api_key_dict: User API Key dictionary containing user information
tpm_limit: Tokens per minute limit for the team member
rpm_limit: Requests per minute limit for the team member
allowed_models: Per-member model scope. None = don't change. [] = remove restrictions. Non-empty list = enforce.
team_default_budget_id: The team's shared default member budget id (from
team metadata.team_member_budget_id), if any. When the membership's
existing_budget_id matches this, we clone-on-write so editing one
member's budget does not mutate the shared default (and therefore
every other member who still points at it).
Apply a merge-patch of per-member budget fields to a team membership.
If max_budget, tpm_limit, rpm_limit, and allowed_models are all None, the user's budget is removed from the team membership.
If any of these values exist, a budget is updated or created and linked to the team membership.
``budget_patch`` holds only the budget columns the caller explicitly sent
(RFC 7396 semantics): a value sets the column, ``None`` clears it, and a
column that is absent from the dict is left untouched. Once the patch is
applied, if the budget has no meaningful limit left the member's private
budget is disconnected so they fall back to the team default.
``team_default_budget_id`` is the team's shared default member budget id
(from team metadata.team_member_budget_id). When the membership still
points at it, we clone-on-write so editing one member's budget does not
mutate the shared default that every other member points at.
"""
if (
max_budget is None
and tpm_limit is None
and rpm_limit is None
and allowed_models is None
):
# disconnect the budget since all limits are None
await tx.litellm_teammembership.update(
where={"user_id_team_id": {"user_id": user_id, "team_id": team_id}},
data={"litellm_budget_table": {"disconnect": True}},
)
if not budget_patch:
return
write_data = dict(budget_patch)
if "budget_duration" in write_data:
duration = write_data["budget_duration"]
write_data["budget_reset_at"] = (
get_budget_reset_time(budget_duration=duration)
if duration is not None
else None
)
is_shared_default = (
existing_budget_id is not None
and team_default_budget_id is not None
and existing_budget_id == team_default_budget_id
)
async def _disconnect():
await tx.litellm_teammembership.update(
where={"user_id_team_id": {"user_id": user_id, "team_id": team_id}},
data={"litellm_budget_table": {"disconnect": True}},
)
if existing_budget_id is not None and not is_shared_default:
# Update the existing budget in-place to preserve fields not being changed.
# Only write fields that the caller explicitly provided (non-None).
update_data: Dict[str, Any] = {
"updated_by": user_api_key_dict.user_id or "",
}
if max_budget is not None:
update_data["max_budget"] = max_budget
if tpm_limit is not None:
update_data["tpm_limit"] = tpm_limit
if rpm_limit is not None:
update_data["rpm_limit"] = rpm_limit
if allowed_models is not None:
update_data["allowed_models"] = allowed_models
existing_budget = await tx.litellm_budgettable.find_unique(
where={"budget_id": existing_budget_id}
)
merged = existing_budget.model_dump() if existing_budget is not None else {}
merged.update(write_data)
if not _has_meaningful_budget_limit(merged):
await _disconnect()
return
await tx.litellm_budgettable.update(
where={"budget_id": existing_budget_id},
data=update_data,
data={"updated_by": user_api_key_dict.user_id or "", **write_data},
)
return
# Either there is no existing budget, OR the membership is still pointing
# at the team's shared default member budget. In both cases we create a
# NEW private budget for this user and (re)link the membership to it.
create_data: Dict[str, Any] = {
"created_by": user_api_key_dict.user_id or "",
"updated_by": user_api_key_dict.user_id or "",
}
# If we're forking off the shared default, seed the new row with the
# default's values so fields the caller did not change carry over.
if is_shared_default:
default_budget_row = await tx.litellm_budgettable.find_unique(
where={"budget_id": existing_budget_id}
)
if default_budget_row is not None:
default_budget_dict = default_budget_row.model_dump()
for field in (
"max_budget",
"soft_budget",
"max_parallel_requests",
"tpm_limit",
"rpm_limit",
"model_max_budget",
"budget_duration",
"allowed_models",
):
for field in _TEAM_MEMBER_BUDGET_LIMIT_FIELDS:
value = default_budget_dict.get(field)
if value is None:
continue
if isinstance(value, list) and len(value) == 0:
continue
create_data[field] = value
if _is_set_budget_value(value):
create_data[field] = value
# Caller-provided values take precedence over the cloned defaults.
if max_budget is not None:
create_data["max_budget"] = max_budget
if tpm_limit is not None:
create_data["tpm_limit"] = tpm_limit
if rpm_limit is not None:
create_data["rpm_limit"] = rpm_limit
if allowed_models is not None:
create_data["allowed_models"] = allowed_models
create_data.update(write_data)
if create_data.get("budget_duration") is not None:
create_data["budget_reset_at"] = get_budget_reset_time(
budget_duration=create_data["budget_duration"]
)
else:
create_data.pop("budget_reset_at", None)
if not _has_meaningful_budget_limit(create_data):
if existing_budget_id is not None:
await _disconnect()
return
new_budget = await tx.litellm_budgettable.create(
data=create_data,

View file

@ -2733,6 +2733,52 @@ async def team_member_delete(
return existing_team_row
_MEMBER_BUDGET_PATCH_FIELDS = {
"max_budget_in_team": "max_budget",
"tpm_limit": "tpm_limit",
"rpm_limit": "rpm_limit",
"budget_duration": "budget_duration",
"allowed_models": "allowed_models",
}
def _build_member_budget_patch(data: TeamMemberUpdateRequest) -> Dict[str, Any]:
"""Map the budget fields the request actually set (merge-patch: a sent
value updates, an explicit null clears, an absent field is left untouched)
to their budget-table columns."""
provided = data.model_dump(exclude_unset=True)
return {
column: provided[request_field]
for request_field, column in _MEMBER_BUDGET_PATCH_FIELDS.items()
if request_field in provided
}
def _validate_budget_duration(budget_duration: Optional[str]) -> None:
"""Reject budget durations that can't be parsed, are non-positive, or
overflow date math, so a bad value can't be persisted and later crash the
budget reset job."""
if budget_duration is None:
return
from litellm.litellm_core_utils.duration_parser import duration_in_seconds
from litellm.proxy.common_utils.timezone_utils import get_budget_reset_time
try:
if duration_in_seconds(budget_duration) <= 0:
raise ValueError("budget_duration must be positive")
get_budget_reset_time(budget_duration=budget_duration)
except (ValueError, OverflowError):
raise HTTPException(
status_code=400,
detail={
"error": "Invalid budget_duration '{}'. Use a format like '1h', '24h', '7d', or '30d'.".format(
budget_duration
)
},
)
@router.post(
"/team/member_update",
tags=["team management"],
@ -2770,6 +2816,8 @@ async def team_member_update(
detail={"error": "Either user_id or user_email needs to be passed in"},
)
_validate_budget_duration(data.budget_duration)
_existing_team_row = await prisma_client.db.litellm_teamtable.find_unique(
where={"team_id": data.team_id}
)
@ -2843,17 +2891,15 @@ async def team_member_update(
team_default_budget_id = raw_default_budget_id
### upsert new budget
budget_patch = _build_member_budget_patch(data)
async with prisma_client.db.tx() as tx:
await _upsert_budget_and_membership(
tx=tx,
team_id=data.team_id,
user_id=received_user_id,
max_budget=data.max_budget_in_team,
existing_budget_id=identified_budget_id,
user_api_key_dict=user_api_key_dict,
tpm_limit=data.tpm_limit,
rpm_limit=data.rpm_limit,
allowed_models=data.allowed_models,
budget_patch=budget_patch,
team_default_budget_id=team_default_budget_id,
)
@ -2887,6 +2933,7 @@ async def team_member_update(
max_budget_in_team=data.max_budget_in_team,
tpm_limit=data.tpm_limit,
rpm_limit=data.rpm_limit,
budget_duration=data.budget_duration,
allowed_models=data.allowed_models,
)

View file

@ -115,6 +115,8 @@ class ValidationResults:
REQUESTED_MODEL = "requested_model"
EXCEPTION_STATUS = "exception_status"
EXCEPTION_CLASS = "exception_class"
RATE_LIMIT_CATEGORY = "rate_limit_category"
RATE_LIMIT_TYPE = "rate_limit_type"
STATUS_CODE = "status_code"
EXCEPTION_LABELS = [EXCEPTION_STATUS, EXCEPTION_CLASS]
LATENCY_BUCKETS = (
@ -174,6 +176,8 @@ class UserAPIKeyLabelNames(Enum):
API_PROVIDER = "api_provider"
EXCEPTION_STATUS = EXCEPTION_STATUS
EXCEPTION_CLASS = EXCEPTION_CLASS
RATE_LIMIT_CATEGORY = RATE_LIMIT_CATEGORY
RATE_LIMIT_TYPE = RATE_LIMIT_TYPE
STATUS_CODE = "status_code"
FALLBACK_MODEL = "fallback_model"
ROUTE = "route"
@ -343,6 +347,10 @@ class PrometheusMetricLabels:
UserAPIKeyLabelNames.USER_EMAIL.value,
UserAPIKeyLabelNames.EXCEPTION_STATUS.value,
UserAPIKeyLabelNames.EXCEPTION_CLASS.value,
# ``rate_limit_category`` / ``rate_limit_type`` are appended in
# ``get_labels()`` when ``litellm.prometheus_emit_rate_limit_labels``
# is True. Kept opt-in so existing dashboards keyed on this metric's
# historical label set keep matching after upgrade.
UserAPIKeyLabelNames.ROUTE.value,
UserAPIKeyLabelNames.CLIENT_IP.value,
UserAPIKeyLabelNames.USER_AGENT.value,
@ -745,6 +753,25 @@ class PrometheusMetricLabels:
):
custom_labels.append(UserAPIKeyLabelNames.STREAM.value)
# Conditionally add unified rate-limit labels to
# litellm_proxy_failed_requests_metric. Off by default so the metric's
# historical label set is preserved across upgrade; enable via
# ``litellm.prometheus_emit_rate_limit_labels`` once downstream
# dashboards include the new labels in their matchers / aggregations.
if (
label_name == "litellm_proxy_failed_requests_metric"
and litellm.prometheus_emit_rate_limit_labels is True
):
for _rate_limit_label in (
UserAPIKeyLabelNames.RATE_LIMIT_CATEGORY.value,
UserAPIKeyLabelNames.RATE_LIMIT_TYPE.value,
):
if (
_rate_limit_label not in default_labels
and _rate_limit_label not in custom_labels
):
custom_labels.append(_rate_limit_label)
_user_budget_metrics = {
"litellm_remaining_user_budget_metric",
"litellm_user_max_budget_metric",
@ -807,6 +834,8 @@ class UserAPIKeyLabelValues:
api_provider: Optional[str] = None
exception_status: Optional[str] = None
exception_class: Optional[str] = None
rate_limit_category: Optional[str] = None
rate_limit_type: Optional[str] = None
status_code: Optional[str] = None
fallback_model: Optional[str] = None
route: Optional[str] = None

View file

@ -2720,6 +2720,23 @@ class StandardLoggingPayloadErrorInformation(TypedDict, total=False):
llm_provider: Optional[str]
traceback: Optional[str]
error_message: Optional[str]
# error_rate_limit_category:
# For 429 / rate-limit errors, the source of the rate limit. One of the
# string values defined by `litellm.exceptions.RateLimitErrorCategory`
# (vendor_rate_limit, vendor_batch_rate_limit, litellm_rate_limit,
# litellm_batch_rate_limit). None for non-rate-limit exceptions.
# Surfaced here so custom callbacks / metrics consumers can switch on
# the rate-limit source without reaching for the raw exception.
error_rate_limit_category: Optional[str]
# error_rate_limit_type:
# For 429 / rate-limit errors, the dimension that was exceeded. One of
# the string values defined by `litellm.exceptions.RateLimitType`
# (requests, tokens, concurrent_requests, budget, max_iterations).
# None for non-rate-limit exceptions and for rate-limit exceptions that
# did not classify the failure (e.g. legacy vendor 429 with no header
# hints). Lets dashboards split rate-limit failures by cause without
# parsing free-text error messages.
error_rate_limit_type: Optional[str]
class GuardrailMode(TypedDict, total=False):

View file

@ -783,6 +783,16 @@ async def test_async_post_call_failure_hook(prometheus_logger):
it should increment the litellm_proxy_failed_requests_metric and litellm_proxy_total_requests_metric
"""
# Opt into the unified rate-limit labels so this test exercises the
# full label set surfaced when `prometheus_emit_rate_limit_labels` is on.
# The logger caches each metric's label set at construction time (so the
# labels passed to ``counter.labels(...)`` stay in lock step with the
# labels used to register the metric), so we must invalidate the cache
# after flipping the toggle for the cache to pick up the new label set.
original_emit = litellm.prometheus_emit_rate_limit_labels
litellm.prometheus_emit_rate_limit_labels = True
prometheus_logger._cached_metric_labels.clear()
# Mock the prometheus metrics
prometheus_logger.litellm_proxy_failed_requests_metric = MagicMock()
prometheus_logger.litellm_proxy_total_requests_metric = MagicMock()
@ -804,32 +814,38 @@ async def test_async_post_call_failure_hook(prometheus_logger):
request_route="/chat/completions",
)
# Call the function
await prometheus_logger.async_post_call_failure_hook(
request_data=request_data,
original_exception=original_exception,
user_api_key_dict=user_api_key_dict,
)
try:
# Call the function
await prometheus_logger.async_post_call_failure_hook(
request_data=request_data,
original_exception=original_exception,
user_api_key_dict=user_api_key_dict,
)
# Assert failed requests metric was incremented with correct labels
prometheus_logger.litellm_proxy_failed_requests_metric.labels.assert_called_once_with(
end_user=None,
user="test_user",
user_email=None,
hashed_api_key="test_key",
api_key_alias="test_alias",
team="test_team",
team_alias="test_team_alias",
org_id=None,
org_alias=None,
requested_model="gpt-5-mini",
exception_status="429",
exception_class="Openai.RateLimitError",
route=user_api_key_dict.request_route,
model_id=None,
client_ip=None,
user_agent=None,
)
# Assert failed requests metric was incremented with correct labels
prometheus_logger.litellm_proxy_failed_requests_metric.labels.assert_called_once_with(
end_user=None,
user="test_user",
user_email=None,
hashed_api_key="test_key",
api_key_alias="test_alias",
team="test_team",
team_alias="test_team_alias",
org_id=None,
org_alias=None,
requested_model="gpt-5-mini",
exception_status="429",
exception_class="Openai.RateLimitError",
rate_limit_category="vendor_rate_limit",
rate_limit_type=None,
route=user_api_key_dict.request_route,
model_id=None,
client_ip=None,
user_agent=None,
)
finally:
litellm.prometheus_emit_rate_limit_labels = original_emit
prometheus_logger._cached_metric_labels.clear()
prometheus_logger.litellm_proxy_failed_requests_metric.labels().inc.assert_called_once()
# Assert total requests metric was incremented with correct labels
@ -1962,6 +1978,10 @@ def test_set_team_budget_metrics_with_custom_labels(prometheus_logger, monkeypat
# Set custom prometheus labels
custom_labels = ["metadata.organization", "metadata.environment"]
monkeypatch.setattr("litellm.custom_prometheus_metadata_labels", custom_labels)
# Logger caches each metric's label set at construction time (fixture
# runs before this monkeypatch), so invalidate so the cached label set
# picks up the freshly-configured custom metadata labels.
prometheus_logger._cached_metric_labels.clear()
# Create test team with custom metadata
team = MagicMock(

View file

@ -284,6 +284,12 @@ def test_prometheus_metrics_use_normalized_routes():
# Create a mock PrometheusLogger
prometheus_logger = MagicMock()
# ``get_labels_for_metric`` reads ``_cached_metric_labels`` and
# ``label_filters`` off ``self``; default MagicMock attribute access
# returns Mocks that masquerade as a populated cache, so seed real
# containers before binding the real method.
prometheus_logger._cached_metric_labels = {}
prometheus_logger.label_filters = {}
prometheus_logger.get_labels_for_metric = (
PrometheusLogger.get_labels_for_metric.__get__(prometheus_logger)
)
@ -327,6 +333,8 @@ def test_prometheus_label_value_sanitization():
from unittest.mock import MagicMock
prometheus_logger = MagicMock()
prometheus_logger._cached_metric_labels = {}
prometheus_logger.label_filters = {}
prometheus_logger.get_labels_for_metric = (
PrometheusLogger.get_labels_for_metric.__get__(prometheus_logger)
)

View file

@ -0,0 +1,328 @@
"""
Tests for the Prometheus rate-limit labels added on top of PR #27687.
Covers two follow-up gaps to the unified rate-limit error work:
1. ``litellm_proxy_failed_requests_metric`` now carries
``rate_limit_category`` and ``rate_limit_type`` labels populated from
:class:`litellm.RateLimitError` (vendor + ``ProxyRateLimitError``
subclass). Closes the Prometheus side of LIT-2718.
2. ``_get_exception_class_name`` keeps emitting the literal string
``"HTTPException"`` for ``ProxyRateLimitError`` so existing dashboards
that key off ``exception_class="HTTPException"`` for litellm-internal
429s don't silently break when the new class lands.
"""
from unittest.mock import MagicMock, patch
import pytest
from litellm.exceptions import (
RateLimitError,
RateLimitErrorCategory,
RateLimitType,
)
from litellm.integrations.prometheus import PrometheusLogger
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.common_utils.proxy_rate_limit_error import ProxyRateLimitError
from litellm.types.integrations.prometheus import (
PrometheusMetricLabels,
UserAPIKeyLabelNames,
UserAPIKeyLabelValues,
)
# ---------------------------------------------------------------------------
# Label / enum wiring
# ---------------------------------------------------------------------------
def test_should_register_rate_limit_label_names_on_enum():
assert UserAPIKeyLabelNames.RATE_LIMIT_CATEGORY.value == "rate_limit_category"
assert UserAPIKeyLabelNames.RATE_LIMIT_TYPE.value == "rate_limit_type"
def test_should_include_rate_limit_labels_on_failed_requests_metric():
import litellm
original = litellm.prometheus_emit_rate_limit_labels
try:
litellm.prometheus_emit_rate_limit_labels = True
labels = PrometheusMetricLabels.get_labels(
"litellm_proxy_failed_requests_metric"
)
assert "rate_limit_category" in labels
assert "rate_limit_type" in labels
# These must coexist with the legacy exception labels (back-compat).
assert "exception_class" in labels
assert "exception_status" in labels
finally:
litellm.prometheus_emit_rate_limit_labels = original
def test_should_omit_rate_limit_labels_by_default_for_back_compat():
"""Default-off preserves the metric's historical label set so existing
dashboards / recording rules keyed on `litellm_proxy_failed_requests_metric`
keep matching after upgrade."""
import litellm
assert litellm.prometheus_emit_rate_limit_labels is False
labels = PrometheusMetricLabels.get_labels("litellm_proxy_failed_requests_metric")
assert "rate_limit_category" not in labels
assert "rate_limit_type" not in labels
# Pre-PR labels must still be present.
assert "exception_class" in labels
assert "exception_status" in labels
def test_should_accept_rate_limit_fields_on_user_api_key_label_values():
enum_values = UserAPIKeyLabelValues(
rate_limit_category="litellm_rate_limit",
rate_limit_type="requests",
)
assert enum_values.rate_limit_category == "litellm_rate_limit"
assert enum_values.rate_limit_type == "requests"
# ---------------------------------------------------------------------------
# _extract_rate_limit_labels helper
# ---------------------------------------------------------------------------
def test_should_extract_vendor_category_for_vanilla_rate_limit_error():
err = RateLimitError(message="vendor 429", llm_provider="openai", model="gpt-4o")
category, rate_limit_type = PrometheusLogger._extract_rate_limit_labels(err)
assert category == "vendor_rate_limit"
assert rate_limit_type is None
def test_should_extract_litellm_category_and_type_for_proxy_rate_limit_error():
err = ProxyRateLimitError(
detail={"error": "tpm exceeded"},
category=RateLimitErrorCategory.LITELLM_RATE_LIMIT,
rate_limit_type=RateLimitType.TOKENS,
)
category, rate_limit_type = PrometheusLogger._extract_rate_limit_labels(err)
assert category == "litellm_rate_limit"
assert rate_limit_type == "tokens"
def test_should_return_none_for_non_rate_limit_exception():
assert PrometheusLogger._extract_rate_limit_labels(ValueError("nope")) == (
None,
None,
)
def test_should_return_none_for_none_exception():
assert PrometheusLogger._extract_rate_limit_labels(None) == (None, None)
def test_should_extract_budget_dimension_for_budget_exceeded_error():
# Virtual-key / team / org / end-user budget caps raise
# `litellm.BudgetExceededError` (a bare Exception subclass), which sets
# the same `.category` / `.rate_limit_type` attributes as the unified
# RateLimitError path so Prometheus can split budget 429s from other
# 429s without the customer parsing free-text error messages.
import litellm
err = litellm.BudgetExceededError(current_cost=0.5, max_budget=0.1)
category, rate_limit_type = PrometheusLogger._extract_rate_limit_labels(err)
assert category == "litellm_rate_limit"
assert rate_limit_type == "budget"
@pytest.mark.parametrize(
"category_enum,rate_limit_enum,expected_category,expected_type",
[
(
RateLimitErrorCategory.LITELLM_RATE_LIMIT,
RateLimitType.REQUESTS,
"litellm_rate_limit",
"requests",
),
(
RateLimitErrorCategory.LITELLM_RATE_LIMIT,
RateLimitType.TOKENS,
"litellm_rate_limit",
"tokens",
),
(
RateLimitErrorCategory.LITELLM_RATE_LIMIT,
RateLimitType.CONCURRENT_REQUESTS,
"litellm_rate_limit",
"concurrent_requests",
),
(
RateLimitErrorCategory.LITELLM_RATE_LIMIT,
RateLimitType.BUDGET,
"litellm_rate_limit",
"budget",
),
(
RateLimitErrorCategory.LITELLM_RATE_LIMIT,
RateLimitType.MAX_ITERATIONS,
"litellm_rate_limit",
"max_iterations",
),
(
RateLimitErrorCategory.LITELLM_BATCH_RATE_LIMIT,
RateLimitType.REQUESTS,
"litellm_batch_rate_limit",
"requests",
),
],
)
def test_should_serialize_rate_limit_enums_as_underlying_string_values(
category_enum, rate_limit_enum, expected_category, expected_type
):
err = ProxyRateLimitError(
detail="boom", category=category_enum, rate_limit_type=rate_limit_enum
)
category, rate_limit_type = PrometheusLogger._extract_rate_limit_labels(err)
assert category == expected_category
assert rate_limit_type == expected_type
# ---------------------------------------------------------------------------
# _get_exception_class_name back-compat
# ---------------------------------------------------------------------------
def test_should_emit_legacy_http_exception_label_for_proxy_rate_limit_error():
"""
``ProxyRateLimitError`` multi-inherits from ``HTTPException`` +
``RateLimitError``. The ``exception_class`` label MUST keep emitting
"HTTPException" for back-compat with existing dashboards (see Slack
thread + PR #27687 review). Distinguishing vendor vs. litellm 429s
is now the job of the new ``rate_limit_category`` label.
"""
err = ProxyRateLimitError(detail={"error": "boom"})
assert PrometheusLogger._get_exception_class_name(err) == "HTTPException"
def test_should_keep_provider_prefixed_exception_class_for_vendor_rate_limit_errors():
err = RateLimitError(message="vendor 429", llm_provider="openai", model="gpt-4o")
# Vendor-side errors keep the historical "Provider.ClassName" formatting.
assert PrometheusLogger._get_exception_class_name(err) == "Openai.RateLimitError"
def test_should_preserve_exception_class_name_for_unrelated_exceptions():
assert PrometheusLogger._get_exception_class_name(ValueError("nope")) == (
"ValueError"
)
# ---------------------------------------------------------------------------
# End-to-end wiring through async_post_call_failure_hook
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_should_populate_rate_limit_labels_for_proxy_rate_limit_error_on_failure_hook():
"""
When a proxy hook raises ``ProxyRateLimitError`` and the failure flows
through ``async_post_call_failure_hook``, the resulting
``UserAPIKeyLabelValues`` must carry both new labels AND keep
``exception_class="HTTPException"`` for back-compat.
"""
with patch(
"litellm.integrations.prometheus.PrometheusLogger.__init__", return_value=None
):
logger = PrometheusLogger()
logger.litellm_proxy_failed_requests_metric = MagicMock()
logger.litellm_proxy_total_requests_metric = MagicMock()
logger.get_labels_for_metric = MagicMock(
return_value=PrometheusMetricLabels.get_labels(
"litellm_proxy_failed_requests_metric"
)
)
err = ProxyRateLimitError(
detail={"error": "rpm exceeded"},
category=RateLimitErrorCategory.LITELLM_RATE_LIMIT,
rate_limit_type=RateLimitType.REQUESTS,
)
with patch(
"litellm.integrations.prometheus.prometheus_label_factory"
) as mock_label_factory:
mock_label_factory.return_value = {}
await logger.async_post_call_failure_hook(
request_data={"model": "gpt-4o-mini", "metadata": {}},
original_exception=err,
user_api_key_dict=UserAPIKeyAuth(token="t"),
)
enum_values = mock_label_factory.call_args_list[0].kwargs["enum_values"]
assert isinstance(enum_values, UserAPIKeyLabelValues)
assert enum_values.rate_limit_category == "litellm_rate_limit"
assert enum_values.rate_limit_type == "requests"
# Back-compat: exception_class on a ProxyRateLimitError stays "HTTPException".
assert enum_values.exception_class == "HTTPException"
assert enum_values.exception_status == "429"
@pytest.mark.asyncio
async def test_should_populate_rate_limit_labels_for_vendor_rate_limit_error_on_failure_hook():
with patch(
"litellm.integrations.prometheus.PrometheusLogger.__init__", return_value=None
):
logger = PrometheusLogger()
logger.litellm_proxy_failed_requests_metric = MagicMock()
logger.litellm_proxy_total_requests_metric = MagicMock()
logger.get_labels_for_metric = MagicMock(
return_value=PrometheusMetricLabels.get_labels(
"litellm_proxy_failed_requests_metric"
)
)
err = RateLimitError(message="upstream 429", llm_provider="openai", model="gpt-4o")
with patch(
"litellm.integrations.prometheus.prometheus_label_factory"
) as mock_label_factory:
mock_label_factory.return_value = {}
await logger.async_post_call_failure_hook(
request_data={"model": "gpt-4o", "metadata": {}},
original_exception=err,
user_api_key_dict=UserAPIKeyAuth(token="t"),
)
enum_values = mock_label_factory.call_args_list[0].kwargs["enum_values"]
assert isinstance(enum_values, UserAPIKeyLabelValues)
assert enum_values.rate_limit_category == "vendor_rate_limit"
assert enum_values.rate_limit_type is None
# Vendor errors keep the historical Provider.ClassName label.
assert enum_values.exception_class == "Openai.RateLimitError"
assert enum_values.exception_status == "429"
@pytest.mark.asyncio
async def test_should_leave_rate_limit_labels_blank_for_non_rate_limit_failure():
with patch(
"litellm.integrations.prometheus.PrometheusLogger.__init__", return_value=None
):
logger = PrometheusLogger()
logger.litellm_proxy_failed_requests_metric = MagicMock()
logger.litellm_proxy_total_requests_metric = MagicMock()
logger.get_labels_for_metric = MagicMock(
return_value=PrometheusMetricLabels.get_labels(
"litellm_proxy_failed_requests_metric"
)
)
with patch(
"litellm.integrations.prometheus.prometheus_label_factory"
) as mock_label_factory:
mock_label_factory.return_value = {}
await logger.async_post_call_failure_hook(
request_data={"model": "gpt-4o", "metadata": {}},
original_exception=RuntimeError("boom"),
user_api_key_dict=UserAPIKeyAuth(token="t"),
)
enum_values = mock_label_factory.call_args_list[0].kwargs["enum_values"]
assert isinstance(enum_values, UserAPIKeyLabelValues)
assert enum_values.rate_limit_category is None
assert enum_values.rate_limit_type is None

View file

@ -511,29 +511,34 @@ def test_set_user_budget_metrics_default_no_email_alias_labels(
)
def test_set_user_budget_metrics_includes_user_email_and_alias_labels_when_opted_in(
prometheus_logger,
):
"""When prometheus_user_budget_label_include_email_alias=True, email+alias labels appear."""
def test_set_user_budget_metrics_includes_user_email_and_alias_labels_when_opted_in():
"""When prometheus_user_budget_label_include_email_alias=True, email+alias labels appear.
The flag is read once per metric at logger construction time and snapshotted,
so it must be enabled before the PrometheusLogger is built (mirroring how the
proxy applies config at startup before instantiating callbacks).
"""
import litellm
from litellm.proxy._types import LiteLLM_UserTable
litellm.prometheus_user_budget_label_include_email_alias = True
user = LiteLLM_UserTable(
user_id="user-abc-123",
user_email="alice@example.com",
user_alias="Alice",
spend=25.0,
max_budget=100.0,
budget_reset_at=datetime(2026, 3, 1, tzinfo=timezone.utc),
)
prometheus_logger.litellm_remaining_user_budget_metric = MagicMock()
prometheus_logger.litellm_user_max_budget_metric = MagicMock()
prometheus_logger.litellm_user_budget_remaining_hours_metric = MagicMock()
try:
prometheus_logger = PrometheusLogger()
user = LiteLLM_UserTable(
user_id="user-abc-123",
user_email="alice@example.com",
user_alias="Alice",
spend=25.0,
max_budget=100.0,
budget_reset_at=datetime(2026, 3, 1, tzinfo=timezone.utc),
)
prometheus_logger.litellm_remaining_user_budget_metric = MagicMock()
prometheus_logger.litellm_user_max_budget_metric = MagicMock()
prometheus_logger.litellm_user_budget_remaining_hours_metric = MagicMock()
prometheus_logger._set_user_budget_metrics(user)
prometheus_logger.litellm_remaining_user_budget_metric.labels.assert_called_once_with(

View file

@ -1,5 +1,6 @@
# tests/litellm/proxy/common_utils/test_upsert_budget_membership.py
import types
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock
import pytest
@ -19,15 +20,13 @@ def mock_tx():
Builds an object that looks just enough like the Prisma tx you use
inside _upsert_budget_and_membership.
"""
# membership “table”
membership = MagicMock()
membership.update = AsyncMock()
membership.upsert = AsyncMock()
# budget “table”
budget = MagicMock()
budget.update = AsyncMock()
# budget.create returns a fake row that has .budget_id
budget.find_unique = AsyncMock(return_value=None)
budget.create = AsyncMock(
return_value=types.SimpleNamespace(budget_id="new-budget-123")
)
@ -44,16 +43,57 @@ def fake_user():
return types.SimpleNamespace(user_id="tester@example.com")
# TEST: max_budget is None, disconnect only
def budget_row(**fields):
"""A fake litellm_budgettable row whose model_dump returns the given fields."""
row = MagicMock()
row.model_dump.return_value = fields
return row
def assert_future_reset_time(value):
"""A budget_reset_at must be a timezone-aware datetime in the future, so the
member's budget actually rolls over and the UI shows a reset date instead of
waiting for the reset cron to backfill it."""
assert isinstance(value, datetime)
assert value.tzinfo is not None
assert value > datetime.now(timezone.utc)
# TEST: an empty patch (caller sent no budget fields) leaves everything alone.
# This is the merge-patch contract: absent != clear. Updating only a member's
# role must not silently wipe their budget.
@pytest.mark.asyncio
async def test_upsert_disconnect(mock_tx, fake_user):
async def test_empty_patch_is_noop(mock_tx, fake_user):
await _upsert_budget_and_membership(
mock_tx,
team_id="team-1",
user_id="user-1",
max_budget=None,
existing_budget_id=None,
existing_budget_id="bud-1",
user_api_key_dict=fake_user,
budget_patch={},
)
mock_tx.litellm_teammembership.update.assert_not_called()
mock_tx.litellm_teammembership.upsert.assert_not_called()
mock_tx.litellm_budgettable.update.assert_not_called()
mock_tx.litellm_budgettable.create.assert_not_called()
# TEST: clearing every limit on a member's private budget disconnects it, so the
# member falls back to the team default instead of keeping an empty private row.
@pytest.mark.asyncio
async def test_clearing_all_limits_disconnects(mock_tx, fake_user):
mock_tx.litellm_budgettable.find_unique = AsyncMock(
return_value=budget_row(max_budget=100.0)
)
await _upsert_budget_and_membership(
mock_tx,
team_id="team-1",
user_id="user-1",
existing_budget_id="bud-1",
user_api_key_dict=fake_user,
budget_patch={"max_budget": None},
)
mock_tx.litellm_teammembership.update.assert_awaited_once_with(
@ -62,205 +102,114 @@ async def test_upsert_disconnect(mock_tx, fake_user):
)
mock_tx.litellm_budgettable.update.assert_not_called()
mock_tx.litellm_budgettable.create.assert_not_called()
mock_tx.litellm_teammembership.upsert.assert_not_called()
# TEST: existing budget id → updates budget in-place (current behavior)
# TEST: clearing one field on a budget that still has another limit updates in
# place (clears just that column + its reset time) and does NOT disconnect.
@pytest.mark.asyncio
async def test_upsert_with_existing_budget_id_creates_new(mock_tx, fake_user):
"""
Test that when existing_budget_id is provided, the function updates the budget in-place.
"""
await _upsert_budget_and_membership(
mock_tx,
team_id="team-2",
user_id="user-2",
max_budget=42.0,
existing_budget_id="bud-999",
user_api_key_dict=fake_user,
async def test_clear_one_field_keeps_others(mock_tx, fake_user):
mock_tx.litellm_budgettable.find_unique = AsyncMock(
return_value=budget_row(max_budget=100.0, budget_duration="24h")
)
# Should update the existing budget, not create a new one
await _upsert_budget_and_membership(
mock_tx,
team_id="team-1",
user_id="user-1",
existing_budget_id="bud-1",
user_api_key_dict=fake_user,
budget_patch={"budget_duration": None},
)
mock_tx.litellm_teammembership.update.assert_not_called()
mock_tx.litellm_budgettable.update.assert_awaited_once_with(
where={"budget_id": "bud-999"},
where={"budget_id": "bud-1"},
data={
"max_budget": 42.0,
"updated_by": fake_user.user_id,
"budget_duration": None,
"budget_reset_at": None,
},
)
# Should NOT create a new budget or touch membership
# TEST: setting budget_duration in place writes the duration AND a future
# budget_reset_at, so the budget rolls over without waiting for the reset cron.
@pytest.mark.asyncio
async def test_update_in_place_seeds_reset_at(mock_tx, fake_user):
mock_tx.litellm_budgettable.find_unique = AsyncMock(
return_value=budget_row(max_budget=20.0)
)
await _upsert_budget_and_membership(
mock_tx,
team_id="team-dur",
user_id="user-dur",
existing_budget_id="bud-dur",
user_api_key_dict=fake_user,
budget_patch={"budget_duration": "30d"},
)
mock_tx.litellm_budgettable.update.assert_awaited_once()
call = mock_tx.litellm_budgettable.update.await_args
assert call.kwargs["where"] == {"budget_id": "bud-dur"}
data = call.kwargs["data"]
assert data["budget_duration"] == "30d"
assert data["updated_by"] == fake_user.user_id
assert_future_reset_time(data["budget_reset_at"])
mock_tx.litellm_budgettable.create.assert_not_called()
mock_tx.litellm_teammembership.upsert.assert_not_called()
mock_tx.litellm_teammembership.update.assert_not_called()
# TEST: create new budget and link membership
# TEST: updating a single limit in place only writes that field; an untouched
# budget_duration must not get a (re)computed reset time.
@pytest.mark.asyncio
async def test_upsert_create_and_link(mock_tx, fake_user):
async def test_update_in_place_single_field_leaves_reset_at_alone(mock_tx, fake_user):
mock_tx.litellm_budgettable.find_unique = AsyncMock(
return_value=budget_row(max_budget=50.0)
)
await _upsert_budget_and_membership(
mock_tx,
team_id="team-3",
user_id="user-3",
max_budget=99.9,
existing_budget_id=None,
team_id="team-rpm",
user_id="user-rpm",
existing_budget_id="bud-rpm",
user_api_key_dict=fake_user,
budget_patch={"rpm_limit": 100},
)
mock_tx.litellm_budgettable.create.assert_awaited_once_with(
data={
"max_budget": 99.9,
"created_by": fake_user.user_id,
"updated_by": fake_user.user_id,
},
include={"team_membership": True},
mock_tx.litellm_budgettable.update.assert_awaited_once_with(
where={"budget_id": "bud-rpm"},
data={"updated_by": fake_user.user_id, "rpm_limit": 100},
)
# Budget ID returned by the mocked create()
bid = mock_tx.litellm_budgettable.create.return_value.budget_id
mock_tx.litellm_teammembership.upsert.assert_awaited_once_with(
where={"user_id_team_id": {"user_id": "user-3", "team_id": "team-3"}},
data={
"create": {
"user_id": "user-3",
"team_id": "team-3",
"litellm_budget_table": {"connect": {"budget_id": bid}},
},
"update": {
"litellm_budget_table": {"connect": {"budget_id": bid}},
},
},
)
mock_tx.litellm_teammembership.update.assert_not_called()
mock_tx.litellm_budgettable.update.assert_not_called()
mock_tx.litellm_budgettable.create.assert_not_called()
# TEST: create new budget and link membership, then create another new budget
# TEST: with no existing budget, a duration-only patch creates a budget carrying
# the duration and a future reset time, then links the membership.
@pytest.mark.asyncio
async def test_upsert_create_then_create_another(mock_tx, fake_user):
"""
Test that multiple calls to _upsert_budget_and_membership create separate budgets,
reflecting the current implementation behavior.
"""
# FIRST CALL – create new budget and link membership
async def test_create_seeds_reset_at_and_links(mock_tx, fake_user):
await _upsert_budget_and_membership(
mock_tx,
team_id="team-42",
user_id="user-42",
max_budget=10.0,
team_id="team-new",
user_id="user-new",
existing_budget_id=None,
user_api_key_dict=fake_user,
budget_patch={"budget_duration": "7d"},
)
# capture the budget id that create() returned
created_bid = mock_tx.litellm_budgettable.create.return_value.budget_id
# sanity: we really did the create + upsert path
mock_tx.litellm_budgettable.create.assert_awaited_once()
mock_tx.litellm_teammembership.upsert.assert_awaited_once()
data = mock_tx.litellm_budgettable.create.await_args.kwargs["data"]
assert data["budget_duration"] == "7d"
assert data["created_by"] == fake_user.user_id
assert data["updated_by"] == fake_user.user_id
assert_future_reset_time(data["budget_reset_at"])
# SECOND CALL – reset call history; this time we supply the existing budget_id
mock_tx.litellm_budgettable.create.reset_mock()
mock_tx.litellm_teammembership.upsert.reset_mock()
mock_tx.litellm_budgettable.update.reset_mock()
await _upsert_budget_and_membership(
mock_tx,
team_id="team-42",
user_id="user-42",
max_budget=25.0,
existing_budget_id=created_bid, # now used: triggers in-place update
user_api_key_dict=fake_user,
)
# Should update the existing budget in-place, not create a new one
mock_tx.litellm_budgettable.update.assert_awaited_once_with(
where={"budget_id": created_bid},
data={
"max_budget": 25.0,
"updated_by": fake_user.user_id,
},
)
# Should NOT create a new budget or touch membership
mock_tx.litellm_budgettable.create.assert_not_called()
mock_tx.litellm_teammembership.upsert.assert_not_called()
# TEST: update rpm_limit for member with existing budget_id → updates in-place
@pytest.mark.asyncio
async def test_upsert_rpm_limit_update_creates_new_budget(mock_tx, fake_user):
"""
Test that updating rpm_limit for a member with an existing budget_id
updates the existing budget in-place (not creates a new one).
"""
existing_budget_id = "existing-budget-456"
await _upsert_budget_and_membership(
mock_tx,
team_id="team-rpm-test",
user_id="user-rpm-test",
max_budget=50.0,
existing_budget_id=existing_budget_id,
user_api_key_dict=fake_user,
tpm_limit=1000,
rpm_limit=100,
)
# Should update the existing budget with all specified limits
mock_tx.litellm_budgettable.update.assert_awaited_once_with(
where={"budget_id": existing_budget_id},
data={
"max_budget": 50.0,
"tpm_limit": 1000,
"rpm_limit": 100,
"updated_by": fake_user.user_id,
},
)
# Should NOT create a new budget or touch membership
mock_tx.litellm_budgettable.create.assert_not_called()
mock_tx.litellm_teammembership.upsert.assert_not_called()
# TEST: create new budget with only rpm_limit (no max_budget)
@pytest.mark.asyncio
async def test_upsert_rpm_only_creates_new_budget(mock_tx, fake_user):
"""
Test that setting only rpm_limit creates a new budget with just the rpm_limit.
"""
await _upsert_budget_and_membership(
mock_tx,
team_id="team-rpm-only",
user_id="user-rpm-only",
max_budget=None,
existing_budget_id=None,
user_api_key_dict=fake_user,
rpm_limit=50,
)
# Should create a new budget with only rpm_limit
mock_tx.litellm_budgettable.create.assert_awaited_once_with(
data={
"rpm_limit": 50,
"created_by": fake_user.user_id,
"updated_by": fake_user.user_id,
},
include={"team_membership": True},
)
# Should upsert team membership with the new budget ID
new_budget_id = mock_tx.litellm_budgettable.create.return_value.budget_id
mock_tx.litellm_teammembership.upsert.assert_awaited_once_with(
where={
"user_id_team_id": {"user_id": "user-rpm-only", "team_id": "team-rpm-only"}
},
where={"user_id_team_id": {"user_id": "user-new", "team_id": "team-new"}},
data={
"create": {
"user_id": "user-rpm-only",
"team_id": "team-rpm-only",
"user_id": "user-new",
"team_id": "team-new",
"litellm_budget_table": {"connect": {"budget_id": new_budget_id}},
},
"update": {
@ -270,60 +219,48 @@ async def test_upsert_rpm_only_creates_new_budget(mock_tx, fake_user):
)
# TEST: clone-on-write when membership still points at the team's shared default budget
# TEST: clone-on-write when the membership still points at the team's shared
# default budget. Editing this member must fork a private budget instead of
# mutating the shared row, and cloning a duration must seed a fresh reset time.
@pytest.mark.asyncio
async def test_upsert_clones_when_pointing_at_shared_default(mock_tx, fake_user):
"""
When a member's existing budget_id is the same row as the team's shared
default member budget, updating that member's budget must NOT mutate the
shared row. Instead we should create a new private budget for this member
(seeded with the default's values) and re-link the membership to it.
"""
async def test_clone_on_write_from_shared_default(mock_tx, fake_user):
shared_default_id = "team-default-budget-1"
mock_tx.litellm_budgettable.find_unique = AsyncMock(
return_value=budget_row(
budget_id=shared_default_id,
max_budget=200.0,
soft_budget=None,
max_parallel_requests=None,
tpm_limit=500,
rpm_limit=None,
model_max_budget=None,
budget_duration="1d",
allowed_models=[],
)
)
# Default budget row in the DB: $200 cap, daily reset, 500 tpm.
default_row = MagicMock()
default_row.model_dump.return_value = {
"budget_id": shared_default_id,
"max_budget": 200.0,
"soft_budget": None,
"max_parallel_requests": None,
"tpm_limit": 500,
"rpm_limit": None,
"model_max_budget": None,
"budget_duration": "1d",
"allowed_models": [],
}
mock_tx.litellm_budgettable.find_unique = AsyncMock(return_value=default_row)
# Caller is changing only this member's max_budget.
await _upsert_budget_and_membership(
mock_tx,
team_id="team-shared",
user_id="user-shared",
max_budget=50.0,
existing_budget_id=shared_default_id,
user_api_key_dict=fake_user,
budget_patch={"max_budget": 50.0},
team_default_budget_id=shared_default_id,
)
# Must NOT touch the shared default row in place.
mock_tx.litellm_budgettable.update.assert_not_called()
mock_tx.litellm_budgettable.create.assert_awaited_once()
create_data = mock_tx.litellm_budgettable.create.await_args.kwargs["data"]
assert_future_reset_time(create_data.pop("budget_reset_at"))
assert create_data == {
"created_by": fake_user.user_id,
"updated_by": fake_user.user_id,
"max_budget": 50.0, # caller wins
"tpm_limit": 500, # cloned from default
"budget_duration": "1d", # cloned from default
}
# Must create a new private budget seeded with the default's values,
# with the caller's max_budget overriding the cloned default.
mock_tx.litellm_budgettable.create.assert_awaited_once_with(
data={
"created_by": fake_user.user_id,
"updated_by": fake_user.user_id,
"max_budget": 50.0, # caller wins
"tpm_limit": 500, # cloned from default
"budget_duration": "1d", # cloned from default
},
include={"team_membership": True},
)
# Membership must be re-linked to the new private budget.
new_budget_id = mock_tx.litellm_budgettable.create.return_value.budget_id
mock_tx.litellm_teammembership.upsert.assert_awaited_once_with(
where={"user_id_team_id": {"user_id": "user-shared", "team_id": "team-shared"}},
@ -340,32 +277,64 @@ async def test_upsert_clones_when_pointing_at_shared_default(mock_tx, fake_user)
)
# TEST: when team default exists but member already has their own budget, in-place update
# TEST: forking the shared default while clearing its duration must drop the
# duration (and not carry a reset time) on the new private budget.
@pytest.mark.asyncio
async def test_upsert_updates_in_place_when_member_has_private_budget(
mock_tx, fake_user
):
"""
If the member's budget_id is different from the team's shared default
(i.e. they already have a private budget), we should keep the current
in-place behavior and not allocate a new row.
"""
async def test_clone_on_write_clears_duration(mock_tx, fake_user):
shared_default_id = "team-default-budget-1"
mock_tx.litellm_budgettable.find_unique = AsyncMock(
return_value=budget_row(
budget_id=shared_default_id,
max_budget=200.0,
tpm_limit=500,
budget_duration="1d",
allowed_models=[],
)
)
await _upsert_budget_and_membership(
mock_tx,
team_id="team-shared",
user_id="user-shared",
existing_budget_id=shared_default_id,
user_api_key_dict=fake_user,
budget_patch={"budget_duration": None},
team_default_budget_id=shared_default_id,
)
mock_tx.litellm_budgettable.update.assert_not_called()
create_data = mock_tx.litellm_budgettable.create.await_args.kwargs["data"]
assert create_data == {
"created_by": fake_user.user_id,
"updated_by": fake_user.user_id,
"max_budget": 200.0,
"tpm_limit": 500,
"budget_duration": None,
}
assert "budget_reset_at" not in create_data
# TEST: when the member already has their own private budget (different from the
# team default), we update it in place rather than forking another row.
@pytest.mark.asyncio
async def test_private_budget_updates_in_place(mock_tx, fake_user):
mock_tx.litellm_budgettable.find_unique = AsyncMock(
return_value=budget_row(max_budget=10.0)
)
await _upsert_budget_and_membership(
mock_tx,
team_id="team-mixed",
user_id="user-private",
max_budget=75.0,
existing_budget_id="private-budget-xyz",
user_api_key_dict=fake_user,
budget_patch={"max_budget": 75.0},
team_default_budget_id="team-default-budget-1",
)
mock_tx.litellm_budgettable.update.assert_awaited_once_with(
where={"budget_id": "private-budget-xyz"},
data={
"max_budget": 75.0,
"updated_by": fake_user.user_id,
},
data={"max_budget": 75.0, "updated_by": fake_user.user_id},
)
mock_tx.litellm_budgettable.create.assert_not_called()
mock_tx.litellm_teammembership.upsert.assert_not_called()

View file

@ -22,7 +22,7 @@ no ``llm_provider`` / ``model`` attribute. Downstream:
category routing missed these entirely.
The fix wraps every internal raise site in
:class:`ProxyHTTPRateLimitError` (an ``HTTPException`` *and* a
:class:`ProxyRateLimitError` (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
@ -61,9 +61,9 @@ from litellm.proxy.hooks.parallel_request_limiter import (
from litellm.proxy.hooks.parallel_request_limiter_v3 import (
_PROXY_MaxParallelRequestsHandler_v3,
)
from litellm.proxy.common_utils.proxy_rate_limit_error import ProxyRateLimitError
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
@ -75,12 +75,11 @@ from litellm.types.agents import AgentResponse
# ---------------------------------------------------------------------------
class TestProxyHTTPRateLimitErrorClass:
class TestProxyRateLimitErrorClass:
"""Pin the dual ``HTTPException`` + ``RateLimitError`` shape."""
def test_is_both_http_exception_and_rate_limit_error(self):
e = ProxyHTTPRateLimitError(
status_code=429,
e = ProxyRateLimitError(
detail="boom",
model="gpt-4o-mini",
llm_provider="openai",
@ -92,15 +91,15 @@ class TestProxyHTTPRateLimitErrorClass:
assert e.status_code == 429
assert e.model == "gpt-4o-mini"
assert e.llm_provider == "openai"
assert e.message == "boom"
# ProxyRateLimitError prefixes message via RateLimitError.__init__.
assert "boom" in e.message
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,
e = ProxyRateLimitError(
detail={"error": "over rpm"},
model="claude-3-5-sonnet",
llm_provider="anthropic",
@ -109,16 +108,15 @@ class TestProxyHTTPRateLimitErrorClass:
assert "over rpm" in e.message
def test_defaults_to_litellm_proxy_provider(self):
e = ProxyHTTPRateLimitError(status_code=429, detail="x")
e = ProxyRateLimitError(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,
e = ProxyRateLimitError(
detail="x",
model=None, # type: ignore[arg-type]
llm_provider=None, # type: ignore[arg-type]
model=None,
llm_provider=None,
)
assert e.llm_provider == PROXY_LLM_PROVIDER_FALLBACK
assert e.model == ""
@ -143,7 +141,10 @@ class TestResolveLLMProviderForRateLimit:
# 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)
# Pin llm_router to None so the alias-fallback path doesn't pick up
# a router left behind by another test in the session.
with patch("litellm.proxy.proxy_server.llm_router", None):
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.
@ -155,15 +156,148 @@ class TestResolveLLMProviderForRateLimit:
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.
# No router is registered in this test, so the alias-fallback path
# also yields None and we land at PROXY_LLM_PROVIDER_FALLBACK.
with patch.object(
litellm,
"get_llm_provider",
side_effect=RuntimeError("boom"),
):
resolved_model, provider = resolve_llm_provider_for_rate_limit("anything")
with patch(
"litellm.proxy.proxy_server.llm_router",
None,
):
resolved_model, provider = resolve_llm_provider_for_rate_limit(
"anything"
)
assert provider == PROXY_LLM_PROVIDER_FALLBACK
assert resolved_model == "anything"
def test_router_alias_resolves_to_underlying_provider(self):
"""
Nearly every real LiteLLM proxy deployment uses router aliases:
model_list:
- model_name: tpm-locked
litellm_params:
model: openai/gpt-4o-mini
...
``litellm.get_llm_provider("tpm-locked")`` doesn't know about
router aliases and raises. Before this fix the resolver fell
through to ``"litellm_proxy"``, defeating the whole point of the
``llm_provider`` field on the rate-limit error. The alias path
must look the deployment up in the router's ``model_list`` and
resolve from its ``litellm_params.model``.
"""
class _FakeRouter:
model_list = [
{
"model_name": "tpm-locked",
"litellm_params": {
"model": "openai/gpt-4o-mini",
"api_key": "fake",
},
}
]
with patch(
"litellm.proxy.proxy_server.llm_router",
_FakeRouter(),
):
resolved_model, provider = resolve_llm_provider_for_rate_limit("tpm-locked")
assert provider == "openai", (
f"Router-alias path must resolve through litellm_params.model, "
f"not fall through to {PROXY_LLM_PROVIDER_FALLBACK!r}. Got "
f"provider={provider!r}, model={resolved_model!r}."
)
# The resolved model should point at the underlying deployment so
# downstream Prometheus labels / failure callbacks attribute the
# 429 to the real upstream, not the alias.
assert resolved_model == "gpt-4o-mini"
def test_router_alias_with_multiple_deployments_uses_first(self):
"""
When an alias maps to multiple deployments (the load-balancing
case), the rate-limit error fired at the *alias* level is
deployment-agnostic — we have no way of knowing which one would
have been picked. Use the first deployment's underlying provider:
every deployment under one alias should agree on provider in any
sensible config, and 'first' is deterministic so the Prometheus
label is stable.
"""
class _FakeRouter:
model_list = [
{
"model_name": "claude-pool",
"litellm_params": {"model": "anthropic/claude-3-5-sonnet"},
},
{
"model_name": "claude-pool",
"litellm_params": {"model": "anthropic/claude-3-5-haiku"},
},
]
with patch(
"litellm.proxy.proxy_server.llm_router",
_FakeRouter(),
):
_, provider = resolve_llm_provider_for_rate_limit("claude-pool")
assert provider == "anthropic"
def test_router_alias_unknown_falls_back(self):
"""
Alias not in the router model_list — both lookups fail, so we
land at the defensive ``litellm_proxy`` fallback rather than
raising.
"""
class _FakeRouter:
model_list = [
{
"model_name": "tpm-locked",
"litellm_params": {"model": "openai/gpt-4o-mini"},
}
]
with patch(
"litellm.proxy.proxy_server.llm_router",
_FakeRouter(),
):
resolved_model, provider = resolve_llm_provider_for_rate_limit(
"not-an-alias"
)
assert provider == PROXY_LLM_PROVIDER_FALLBACK
assert resolved_model == "not-an-alias"
def test_router_alias_with_malformed_deployment_falls_back(self):
"""
A deployment in the router model_list with no usable
``litellm_params.model`` (or where ``get_llm_provider`` on the
underlying string also raises) must not crash the resolver —
fall through to the defensive fallback.
"""
class _FakeRouter:
model_list = [
{"model_name": "broken", "litellm_params": {}},
{"model_name": "broken", "litellm_params": {"model": ""}},
{
"model_name": "broken",
"litellm_params": {"model": "nonsense-no-provider"},
},
]
with patch(
"litellm.proxy.proxy_server.llm_router",
_FakeRouter(),
):
resolved_model, provider = resolve_llm_provider_for_rate_limit("broken")
assert provider == PROXY_LLM_PROVIDER_FALLBACK
assert resolved_model == "broken"
# ---------------------------------------------------------------------------
# parallel_request_limiter v1
@ -352,7 +486,7 @@ async def test_parallel_request_limiter_v1_missing_model_falls_back():
# ---------------------------------------------------------------------------
def _v3_over_limit_response(rate_limit_type: str = "rpm") -> dict:
def _v3_over_limit_response(rate_limit_type: str = "requests") -> dict:
return {
"overall_code": "OVER_LIMIT",
"statuses": [
@ -532,7 +666,7 @@ async def test_dynamic_rate_limiter_v3_model_capacity_path_populates_provider():
"descriptor_key": "model_saturation_check",
"current_limit": 100,
"limit_remaining": 0,
"rate_limit_type": "rpm",
"rate_limit_type": "requests",
}
],
}
@ -582,7 +716,7 @@ async def test_dynamic_rate_limiter_v3_unknown_descriptor_path_populates_provide
"descriptor_key": "something_we_dont_handle",
"current_limit": 1,
"limit_remaining": 0,
"rate_limit_type": "rpm",
"rate_limit_type": "requests",
}
],
}
@ -937,31 +1071,56 @@ async def test_max_budget_per_session_limiter_unknown_model_falls_back():
# ---------------------------------------------------------------------------
def test_prometheus_exception_class_name_includes_provider():
def test_prometheus_exception_class_name_back_compat_for_proxy_rate_limit_error():
"""
`_get_exception_class_name` deliberately returns the literal string
``"HTTPException"`` for every ``ProxyRateLimitError`` instance so that
pre-existing dashboards / alerts (which key off the historical value)
keep working after the unified rate-limit error class landed in #27687.
Provider attribution is now surfaced separately via the
``rate_limit_category`` / ``rate_limit_type`` labels — this test pins
the back-compat shim itself.
"""
from litellm.integrations.prometheus import PrometheusLogger
exc = ProxyHTTPRateLimitError(
status_code=429,
exc = ProxyRateLimitError(
detail="over limit",
model="gpt-4o-mini",
llm_provider="openai",
)
assert PrometheusLogger._get_exception_class_name(exc) == "HTTPException"
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")
# Same back-compat path even when the resolver fell back to litellm_proxy.
exc_no_model = ProxyRateLimitError(detail="over limit")
assert PrometheusLogger._get_exception_class_name(exc_no_model) == "HTTPException"
def test_prometheus_exception_class_name_falls_back_when_no_model():
def test_prometheus_exception_class_name_back_compat_for_budget_exceeded_error():
"""
The unified rate-limit work also attached ``.llm_provider`` to
``BudgetExceededError`` so callbacks get provider attribution from
``StandardLoggingPayload``. Without a back-compat short-circuit the
provider-prefix step in ``_get_exception_class_name`` would silently
flip the label from ``"BudgetExceededError"`` to e.g.
``"Openai.BudgetExceededError"`` and break dashboards keyed on the
historical value. Pin the literal label here.
"""
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.")
err = litellm.BudgetExceededError(
current_cost=1.0,
max_budget=0.5,
llm_provider="openai",
)
assert PrometheusLogger._get_exception_class_name(err) == "BudgetExceededError"
# Default (empty llm_provider) path — same literal label.
err_no_provider = litellm.BudgetExceededError(current_cost=1.0, max_budget=0.5)
assert (
PrometheusLogger._get_exception_class_name(err_no_provider)
== "BudgetExceededError"
)
if __name__ == "__main__":

View file

@ -1,9 +1,19 @@
import types
from unittest.mock import AsyncMock, MagicMock
import pytest
from fastapi import HTTPException
from starlette.requests import Request
import litellm.proxy.proxy_server as proxy_server
from litellm.proxy._types import TeamMemberUpdateRequest
import litellm.proxy.management_endpoints.team_endpoints as team_endpoints
from litellm.proxy._types import (
LiteLLM_TeamTable,
LitellmUserRoles,
Member,
TeamMemberUpdateRequest,
UserAPIKeyAuth,
)
from litellm.proxy.management_endpoints.team_endpoints import team_member_update
@ -38,3 +48,133 @@ async def test_ateam_member_update_admin_requires_premium(monkeypatch):
"Pricing: https://www.litellm.ai/#pricing"
)
assert exc_info.value.detail == expected_msg
@pytest.fixture
def happy_path_upsert(monkeypatch):
"""Stub out the DB and the budget upsert so a team_member_update call reaches
_upsert_budget_and_membership, and hand back that mock to inspect the patch."""
team_row = LiteLLM_TeamTable(
team_id="team-1234",
members_with_roles=[Member(user_id="user-1", role="user")],
metadata={},
)
prisma_client = MagicMock()
prisma_client.db.litellm_teamtable.find_unique = AsyncMock(return_value=team_row)
prisma_client.db.litellm_teamtable.update = AsyncMock()
class _FakeTx:
async def __aenter__(self):
return self
async def __aexit__(self, *args):
return False
prisma_client.db.tx = MagicMock(return_value=_FakeTx())
monkeypatch.setattr(proxy_server, "prisma_client", prisma_client)
monkeypatch.setattr(proxy_server, "premium_user", False)
monkeypatch.setattr(
team_endpoints,
"team_info",
AsyncMock(
return_value={
"team_info": team_row,
"team_memberships": [
types.SimpleNamespace(user_id="user-1", budget_id="bud-1")
],
}
),
)
upsert_mock = AsyncMock()
monkeypatch.setattr(team_endpoints, "_upsert_budget_and_membership", upsert_mock)
return upsert_mock
def _member_update_request(**overrides):
data = TeamMemberUpdateRequest(
team_id="team-1234", user_id="user-1", role="user", **overrides
)
request = Request({"type": "http", "method": "POST", "path": "/team/member_update"})
auth = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN.value, user_id="admin")
return data, request, auth
@pytest.mark.asyncio
async def test_team_member_update_sends_provided_fields_as_patch(happy_path_upsert):
"""Fields the request sets must reach _upsert_budget_and_membership as a
budget patch, otherwise the member budget is never written/reset."""
data, request, auth = _member_update_request(
max_budget_in_team=10.0, budget_duration="30d"
)
response = await team_member_update(data, request, auth)
happy_path_upsert.assert_awaited_once()
assert happy_path_upsert.await_args.kwargs["budget_patch"] == {
"max_budget": 10.0,
"budget_duration": "30d",
}
assert response.budget_duration == "30d"
@pytest.mark.asyncio
async def test_team_member_update_explicit_null_clears_field(happy_path_upsert):
"""An explicitly-null field must be forwarded as None so the column is
cleared, rather than silently dropped."""
data, request, auth = _member_update_request(budget_duration=None)
await team_member_update(data, request, auth)
assert happy_path_upsert.await_args.kwargs["budget_patch"] == {
"budget_duration": None
}
@pytest.mark.asyncio
async def test_team_member_update_omits_unset_fields_from_patch(happy_path_upsert):
"""A request that touches no budget fields must produce an empty patch so the
member's existing budget is left untouched."""
data, request, auth = _member_update_request()
await team_member_update(data, request, auth)
assert happy_path_upsert.await_args.kwargs["budget_patch"] == {}
@pytest.mark.parametrize(
"bad_duration",
[
"not-a-duration", # unparseable garbage
"10x", # unsupported unit
"0d", # zero-length window
"999999999999999999999999d", # overflows datetime math
],
)
@pytest.mark.asyncio
async def test_team_member_update_rejects_invalid_budget_duration(
monkeypatch, bad_duration
):
"""An invalid budget_duration must be rejected with a 400 before any DB
write, so it can never be persisted and later break the budget reset job."""
monkeypatch.setattr(proxy_server, "prisma_client", object())
monkeypatch.setattr(proxy_server, "premium_user", False)
upsert_mock = AsyncMock()
monkeypatch.setattr(team_endpoints, "_upsert_budget_and_membership", upsert_mock)
data = TeamMemberUpdateRequest(
team_id="team-1234",
user_id="user-1",
role="user",
budget_duration=bad_duration,
)
request = Request({"type": "http", "method": "POST", "path": "/team/member_update"})
auth = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN.value, user_id="admin")
with pytest.raises(HTTPException) as exc_info:
await team_member_update(data, request, auth)
assert exc_info.value.status_code == 400
assert "budget_duration" in str(exc_info.value.detail)
upsert_mock.assert_not_called()

File diff suppressed because it is too large Load diff

View file

@ -2822,6 +2822,7 @@ export interface Member {
max_budget_in_team?: number | null;
tpm_limit?: number | null;
rpm_limit?: number | null;
budget_duration?: string | null;
allowed_models?: string[] | null;
}
@ -2949,18 +2950,21 @@ export const teamMemberUpdateCall = async (
user_id: formValues.user_id,
};
// Add optional budget and rate limit fields
const orNull = (value: unknown) => (value === undefined || value === null || value === "" ? null : value);
if (formValues.user_email !== undefined) {
requestBody.user_email = formValues.user_email;
}
if (formValues.max_budget_in_team !== undefined && formValues.max_budget_in_team !== null) {
requestBody.max_budget_in_team = formValues.max_budget_in_team;
if ("max_budget_in_team" in formValues) {
requestBody.max_budget_in_team = orNull(formValues.max_budget_in_team);
}
if (formValues.tpm_limit !== undefined && formValues.tpm_limit !== null) {
requestBody.tpm_limit = formValues.tpm_limit;
if ("tpm_limit" in formValues) {
requestBody.tpm_limit = orNull(formValues.tpm_limit);
}
if (formValues.rpm_limit !== undefined && formValues.rpm_limit !== null) {
requestBody.rpm_limit = formValues.rpm_limit;
if ("rpm_limit" in formValues) {
requestBody.rpm_limit = orNull(formValues.rpm_limit);
}
if ("budget_duration" in formValues) {
requestBody.budget_duration = orNull(formValues.budget_duration);
}
if (formValues.allowed_models !== undefined) {
requestBody.allowed_models = formValues.allowed_models;

View file

@ -2,6 +2,7 @@ import { Text, TextInput } from "@tremor/react";
import { Button as AntButton, Form, Modal, Select } from "antd";
import React, { useEffect, useState } from "react";
import NumericalInput from "../shared/numerical_input";
import BudgetDurationDropdown from "../common_components/budget_duration_dropdown";
interface BaseMember {
user_email?: string;
@ -21,7 +22,7 @@ interface ModalConfig {
additionalFields?: Array<{
name: string;
label: string | React.ReactNode;
type: "input" | "select" | "numerical" | "multi-select";
type: "input" | "select" | "numerical" | "multi-select" | "budget-duration";
options?: Array<{ label: string; value: string }>;
rules?: any[];
step?: number;
@ -65,6 +66,7 @@ const MemberModal = <T extends BaseMember>({
max_budget_in_team: (initialData as any).max_budget_in_team || null,
tpm_limit: (initialData as any).tpm_limit || null,
rpm_limit: (initialData as any).rpm_limit || null,
budget_duration: (initialData as any).budget_duration || null,
// Keep array values for multi-select fields
allowed_models: (initialData as any).allowed_models || [],
};
@ -117,7 +119,7 @@ const MemberModal = <T extends BaseMember>({
const renderField = (field: {
name: string;
label: string | React.ReactNode;
type: "input" | "select" | "numerical" | "multi-select";
type: "input" | "select" | "numerical" | "multi-select" | "budget-duration";
options?: Array<{ label: string; value: string }>;
rules?: any[];
step?: number;
@ -155,6 +157,8 @@ const MemberModal = <T extends BaseMember>({
allowClear
/>
);
case "budget-duration":
return <BudgetDurationDropdown />;
default:
return null;
}

View file

@ -388,6 +388,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
max_budget_in_team: values.max_budget_in_team,
tpm_limit: values.tpm_limit,
rpm_limit: values.rpm_limit,
budget_duration: values.budget_duration,
allowed_models: values.allowed_models,
};
MessageManager.destroy(); // Remove all existing toasts
@ -1689,6 +1690,18 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
min: 0,
placeholder: "Budget limit for this member within this team",
},
{
name: "budget_duration",
label: (
<span>
Budget Reset Period{" "}
<Tooltip title="How often this member's budget resets within the team. Leave unset and the budget never resets.">
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
</Tooltip>
</span>
),
type: "budget-duration" as const,
},
{
name: "tpm_limit",
label: (

View file

@ -210,6 +210,7 @@ export default function TeamMemberTab({
max_budget_in_team: membership?.litellm_budget_table?.max_budget || null,
tpm_limit: membership?.litellm_budget_table?.tpm_limit || null,
rpm_limit: membership?.litellm_budget_table?.rpm_limit || null,
budget_duration: membership?.litellm_budget_table?.budget_duration || null,
allowed_models: membership?.litellm_budget_table?.allowed_models || [],
};
setSelectedEditMember(enhancedMember);

View file

@ -1,8 +1,11 @@
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import moment from "moment";
import { beforeEach, describe, expect, it, vi } from "vitest";
import SpendLogsTable from "./index";
import { renderWithProviders } from "../../../tests/test-utils";
import { uiSpendLogsCall } from "../networking";
import { useLogFilterLogic } from "./log_filter_logic";
const mockHandleFilterResetFromHook = vi.fn();
vi.mock("./log_filter_logic", async (importOriginal) => {
@ -115,4 +118,63 @@ describe("SpendLogsTable", () => {
expect(screen.getByRole("button", { name: "Reset Filters" })).toBeInTheDocument();
});
});
describe("Quick Select time range", () => {
// uiSpendLogsCall fires from the real useLogFilterLogic query, so restore it here.
beforeEach(async () => {
const actual = await vi.importActual<typeof import("./log_filter_logic")>("./log_filter_logic");
vi.mocked(useLogFilterLogic).mockImplementation(actual.useLogFilterLogic);
});
const waitForWindowSeconds = async (minMinutes: number) => {
let diff = -1;
await waitFor(() => {
const lastCall = vi.mocked(uiSpendLogsCall).mock.calls.at(-1)?.[0];
if (!lastCall) throw new Error("uiSpendLogsCall was not called");
diff = moment
.utc(lastCall.end_date, "YYYY-MM-DD HH:mm:ss")
.diff(moment.utc(lastCall.start_date, "YYYY-MM-DD HH:mm:ss"), "seconds");
// start_date is rounded down to the minute boundary, end_date is the
// current wall-clock at queryFn time. The dropped sub-minute fraction
// on start_date can push the diff up to (minMinutes+1)*60 seconds
// exactly (e.g. click at HH:MM:59.9 → start floors to HH:MM:00 and
// queryFn fires just past HH:(MM+1):00), so allow equality on the
// upper bound.
expect(diff).toBeGreaterThanOrEqual(minMinutes * 60);
expect(diff).toBeLessThanOrEqual((minMinutes + 1) * 60);
});
return diff;
};
it("should pass a ~1-minute window to uiSpendLogsCall when 'Last Minute' is selected", async () => {
const user = userEvent.setup();
renderWithProviders(<SpendLogsTable {...defaultProps} />);
await user.click(screen.getByRole("button", { name: /Last 24 Hours/i }));
await user.click(await screen.findByRole("button", { name: "Last Minute" }));
await waitForWindowSeconds(1);
});
it("should pass a ~15-minute window to uiSpendLogsCall when 'Last 15 Minutes' is selected", async () => {
const user = userEvent.setup();
renderWithProviders(<SpendLogsTable {...defaultProps} />);
await user.click(screen.getByRole("button", { name: /Last 24 Hours/i }));
await user.click(await screen.findByRole("button", { name: "Last 15 Minutes" }));
await waitForWindowSeconds(15);
});
it("should update the time-range button label to 'Last Minute' after selecting it", async () => {
const user = userEvent.setup();
renderWithProviders(<SpendLogsTable {...defaultProps} />);
await user.click(screen.getByRole("button", { name: /Last 24 Hours/i }));
await user.click(await screen.findByRole("button", { name: "Last Minute" }));
expect(screen.getByRole("button", { name: "Last Minute" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /Last 24 Hours/i })).not.toBeInTheDocument();
});
});
});

View file

@ -587,6 +587,7 @@ const WorkflowRuns: React.FC<WorkflowRunsProps> = ({ accessToken }) => {
return (
<div
style={{
width: "100%",
padding: "24px 32px",
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
minHeight: "calc(100vh - 64px)",

View file

@ -27574,6 +27574,11 @@ export interface components {
* @description List of models this team member can access. Pass an empty list to remove per-member model restrictions.
*/
allowed_models?: string[] | null;
/**
* Budget Duration
* @description Duration after which this team member's budget resets (e.g. '1h', '24h', '7d', '30d'). If not set, the budget never resets.
*/
budget_duration?: string | null;
/** Max Budget In Team */
max_budget_in_team?: number | null;
/** Role */
@ -27599,6 +27604,8 @@ export interface components {
TeamMemberUpdateResponse: {
/** Allowed Models */
allowed_models?: string[] | null;
/** Budget Duration */
budget_duration?: string | null;
/** Max Budget In Team */
max_budget_in_team?: number | null;
/** Rpm Limit */
@ -38537,7 +38544,17 @@ export interface operations {
"application/json": components["schemas"]["ErrorResponse"];
};
};
/** @description RateLimitError */
/**
* @description 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.
*/
429: {
headers: {
[name: string]: unknown;