feat(rate-limit): team_model_limit_borrows_idle_capacity_from_other_teams lets a team's model TPM and RPM limits borrow idle capacity in the dynamic rate limiter (#51328)

* feat(rate-limit): opt-in team_model_limit_is_floor turns a team's model_tpm_limit into a guaranteed share that borrows idle capacity

With priority_reservation_settings.team_model_limit_is_floor: true, a team's inherited model_tpm_limit on a model the dynamic rate limiter covers stops being a hard cap in the regular limiter. The dynamic limiter tracks it as a team_model_floor descriptor, lets the team past it while the model is below saturation_threshold, and holds the team to it once the model saturates, with rate_limited_by: team_model_floor on the 429. Default false keeps today's behavior. Key-owned model_tpm_limit, model_rpm_limit, the model-wide tpm and customer limits stay hard.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat(rate-limit): name the setting team_model_limit_mode with hard and soft values

soft replaces the team_model_limit_is_floor flag. The 429 header reads rate_limited_by: team_model_soft_limit and the message says the team soft limit was reached.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat(rate-limit): team_model_limit_borrows_idle_capacity_from_other_teams covers team model TPM and RPM

Replaces team_model_limit_mode. A team's inherited model_tpm_limit and model_rpm_limit on a model the dynamic
rate limiter covers are enforced by that limiter once the model saturates, so the team borrows idle capacity below
the threshold. The 429 names the limit and reads rate_limited_by: model_per_team_saturated.

Folds in the bot findings: key ownership is checked per requested model, team limits resolve through the model
alias like the regular limiter, the team counter is charged only for requests the pre-call hook tracked, every
response type that carries usage is settled, and tracking below saturation no longer adds a token per request.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(rate-limit): keep the type gate flat in the dynamic limiter's team limit paths

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(rate-limit): settle responses usage and null auth metadata, resolve aliases for the borrowing team limit

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(rate-limit): key the borrowing team counter by resolved model group and read usage through get_usage_object

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(rate-limit): hold Responses WebSocket turns to the borrowing team limit once the model saturates

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Kerry Lu 2026-11-11 20:58:34 -08:00 • committed by GitHub
parent 6a98d09261
commit 19ffe3a5f2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 831 additions and 130 deletions

View file

@ -3,17 +3,19 @@ Dynamic rate limiter v3 - Saturation-aware priority-based rate limiting
"""
import os
from collections.abc import Callable
from collections.abc import Callable, Mapping
from datetime import datetime
from typing import TYPE_CHECKING, Final, Literal
from fastapi import HTTPException
from pydantic import BaseModel, ConfigDict
import litellm
from litellm import ModelResponse, Router
from litellm import Router
from litellm._internal_context import with_service_target
from litellm._logging import verbose_proxy_logger
from litellm.caching.caching import DualCache
from litellm.cost_calculator import get_usage_object
from litellm.integrations.custom_logger import CustomLogger
from litellm.litellm_core_utils.add_retry_fallback_headers import (
ensure_response_additional_headers,
@ -28,8 +30,12 @@ from litellm.proxy.hooks.parallel_request_limiter_v3 import ( # noqa: F401 # l
PROXY_MaxParallelRequestsHandler_v3,
RateLimitDescriptor,
RateLimitDescriptorRateLimitObject,
RateLimitedModel,
RateLimitResponse,
RateLimitStatus,
_PROXY_MaxParallelRequestsHandler_v3, # pyright: ignore[reportPrivateUsage,reportUnusedImport] # backwards-compatible package export
_socket_turn_tpm_windows, # pyright: ignore[reportPrivateUsage] # same per-turn TPM window the regular limiter checks
borrowing_team_model_limits,
claim_request_stash_for_data,
get_or_create_request_stash,
)
@ -40,12 +46,56 @@ from litellm.proxy.hooks.rate_limiter_utils import (
from litellm.proxy.utils import InternalUsageCache
from litellm.redis.stores.rate_limits import RATE_LIMITS_TARGET, CounterIncrement, RateLimitCounter, RateLimitScope
from litellm.types.router import ModelGroupInfo
from litellm.types.utils import CallTypesLiteral, LLMResponseTypes
from litellm.types.utils import CallTypesLiteral, LLMResponseTypes, Usage
if TYPE_CHECKING:
from litellm.types.utils import PriorityReservationSettings
class _SettlementMetadata(BaseModel):
"""The two fields of a request's standard logging metadata that token settlement reads."""
model_config = ConfigDict(frozen=True)
user_api_key_auth_metadata: Mapping[str, object] | None = None
user_api_key_team_id: str | None = None
@property
def priority(self) -> str | None:
value: Final = (self.user_api_key_auth_metadata or {}).get("priority")
return value if isinstance(value, str) else None
def _usage_of(response_obj: object) -> Usage | None:
usage: Final = getattr(response_obj, "usage", None)
return usage if isinstance(usage, Usage) else get_usage_object(response_obj)
TEAM_MODEL_LIMIT_AT_SATURATION: Final = "model_per_team_saturated"
def _team_limit_name(rate_limit_type: str) -> str:
return "model_rpm_limit" if rate_limit_type == "requests" else "model_tpm_limit"
def _team_model_token_increment(
descriptor: RateLimitDescriptor | None, total_tokens: int, ttl: int
) -> tuple[CounterIncrement, ...]:
"""Charge the team counter the pre-call hook tracked, and only when that team limit has a TPM share."""
if descriptor is None:
return ()
rate_limit: Final = descriptor["rate_limit"]
if rate_limit is None or rate_limit.get("tokens_per_unit") is None:
return ()
return (
CounterIncrement(
counter=RateLimitScope(descriptor["key"], descriptor["value"]).counter("tokens"),
amount=total_tokens,
ttl=ttl,
),
)
def _get_priority_settings() -> "PriorityReservationSettings":
"""
Get the priority reservation settings, guaranteed to be non-None.
@ -342,6 +392,87 @@ class _PROXY_DynamicRateLimitHandlerV3(CustomLogger):
return descriptors
def _create_borrowing_team_model_limit_descriptor(
self, model: str, user_api_key_dict: UserAPIKeyAuth
) -> RateLimitDescriptor | None:
"""One counter per team and resolved model group, so an alias and its target share the team's share."""
rate_limited: Final = self.v3_limiter.rate_limited_model(model) or RateLimitedModel(
requested=model, group=model
)
limits: Final = borrowing_team_model_limits(user_api_key_dict, rate_limited)
if limits.tpm is None and limits.rpm is None:
return None
return RateLimitDescriptor(
key=TEAM_MODEL_LIMIT_AT_SATURATION,
value=f"{user_api_key_dict.team_id}:{rate_limited.group}",
rate_limit={
"tokens_per_unit": limits.tpm,
"requests_per_unit": limits.rpm,
"window_size": self.v3_limiter.window_size,
},
)
def _team_limit_error(
self,
model: str,
status: RateLimitStatus,
saturation: float,
priority: str | None,
user_api_key_dict: UserAPIKeyAuth,
) -> ProxyRateLimitError:
resolved_model, llm_provider = resolve_llm_provider_for_rate_limit(model)
return ProxyRateLimitError(
detail={
"error": f"Team {_team_limit_name(status['rate_limit_type'])} reached for {model} "
"and the model is saturated, so there is no idle capacity to borrow. "
f"Team: {user_api_key_dict.team_id}, "
f"Rate limit type: {status['rate_limit_type']}, "
f"Team {_team_limit_name(status['rate_limit_type'])}: {status['current_limit']}, "
f"Remaining: {status['limit_remaining']}, "
f"Model saturation: {saturation:.1%}"
},
headers={
"retry-after": str(self.v3_limiter.window_size),
"rate_limit_type": str(status["rate_limit_type"]),
"rate_limited_by": TEAM_MODEL_LIMIT_AT_SATURATION,
"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,
)
async def enforce_token_limits_for_frame(
self, user_api_key_dict: UserAPIKeyAuth, requested_model: str | None
) -> None:
"""Hold a Responses WebSocket turn to the team's share once the model saturates, like the regular limiter does."""
if requested_model is None:
return
team_limit_descriptor: Final = self._create_borrowing_team_model_limit_descriptor(
model=requested_model, user_api_key_dict=user_api_key_dict
)
model_group_info: Final = self.llm_router.registry().group(requested_model)
if team_limit_descriptor is None or model_group_info is None:
return
saturation: Final = await self._check_model_saturation(requested_model, model_group_info)
if saturation < _get_priority_settings().saturation_threshold:
return
tpm_windows: Final = _socket_turn_tpm_windows((team_limit_descriptor,))
response: Final = await self.v3_limiter.should_rate_limit(
tpm_windows, parent_otel_span=user_api_key_dict.parent_otel_span, read_only=True, skip_parallel_check=True
)
over_limit: Final = next((s for s in response["statuses"] if s["code"] == "OVER_LIMIT"), None)
if over_limit is None:
return
raise self._team_limit_error(
requested_model,
over_limit,
saturation,
self._get_priority_from_user_api_key_dict(user_api_key_dict),
user_api_key_dict,
)
def _create_model_tracking_descriptor(
self,
model: str,
@ -438,9 +569,18 @@ class _PROXY_DynamicRateLimitHandlerV3(CustomLogger):
# asyncio.Lock + in-memory fallback for single-process deployments.
# All-or-nothing: if any enforced descriptor would exceed its limit,
# no counter is modified and the response carries "OVER_LIMIT".
team_limit_descriptor: Final = self._create_borrowing_team_model_limit_descriptor(
model=model, user_api_key_dict=user_api_key_dict
)
get_or_create_request_stash().borrowing_team_model_limit = team_limit_descriptor
if team_limit_descriptor is not None:
descriptors_to_check.append(team_limit_descriptor)
enforced_descriptors: Final[list[RateLimitDescriptor]] = [model_wide_descriptor]
if priority_descriptors and should_enforce_priority:
enforced_descriptors.extend(priority_descriptors)
if team_limit_descriptor is not None and should_enforce_priority:
enforced_descriptors.append(team_limit_descriptor)
per_request_increment: Final[dict[Literal["requests", "tokens"], int]] = {
"requests": 1,
@ -505,6 +645,9 @@ class _PROXY_DynamicRateLimitHandlerV3(CustomLogger):
llm_provider=llm_provider,
)
if descriptor_key == TEAM_MODEL_LIMIT_AT_SATURATION:
raise self._team_limit_error(model, status, saturation, priority, user_api_key_dict)
# Fail-closed guard: overall_code says OVER_LIMIT but no status
# matched a descriptor key we know how to translate into a 429.
# Refuse the request rather than silently fall through and let an
@ -544,6 +687,12 @@ class _PROXY_DynamicRateLimitHandlerV3(CustomLogger):
# priority allocation has accurate usage when it later becomes
# enforced. The increment-then-check semantics of should_rate_limit
# are fine because we ignore the OVER_LIMIT response.
if team_limit_descriptor is not None and not should_enforce_priority:
await self.v3_limiter.atomic_check_and_increment_by_n(
descriptors=[team_limit_descriptor],
increments=[per_request_increment],
parent_otel_span=user_api_key_dict.parent_otel_span,
)
if priority_descriptors and not should_enforce_priority:
priority_tracking_response: Final = await self.v3_limiter.should_rate_limit(
descriptors=priority_descriptors,
@ -678,7 +827,7 @@ class _PROXY_DynamicRateLimitHandlerV3(CustomLogger):
return response
@with_service_target(RATE_LIMITS_TARGET)
async def async_log_success_event(self, kwargs, response_obj, start_time, end_time) -> None:
async def async_log_success_event(self, kwargs, response_obj: object, start_time, end_time) -> None:
"""
Update token usage for priority-based rate limiting after successful API calls.
@ -692,7 +841,6 @@ class _PROXY_DynamicRateLimitHandlerV3(CustomLogger):
from litellm.proxy.common_utils.callback_utils import (
get_model_group_from_litellm_kwargs,
)
from litellm.types.utils import Usage
try:
verbose_proxy_logger.debug("INSIDE dynamic rate limiter ASYNC SUCCESS LOGGING")
@ -701,7 +849,9 @@ class _PROXY_DynamicRateLimitHandlerV3(CustomLogger):
# Get metadata from standard_logging_object
standard_logging_object: Final = kwargs.get("standard_logging_object") or {}
standard_logging_metadata: Final = standard_logging_object.get("metadata") or {}
settlement_metadata: Final = _SettlementMetadata.model_validate(
standard_logging_object.get("metadata") or {}
)
# Get model and priority
model_group: Final = get_model_group_from_litellm_kwargs(kwargs)
@ -710,22 +860,20 @@ class _PROXY_DynamicRateLimitHandlerV3(CustomLogger):
# Get priority from user_api_key_auth_metadata in standard_logging_metadata
# This is where user_api_key_dict.metadata is stored during pre-call
user_api_key_auth_metadata: Final = standard_logging_metadata.get("user_api_key_auth_metadata") or {}
key_priority: Final[str | None] = user_api_key_auth_metadata.get("priority")
key_priority: Final = settlement_metadata.priority
# Get total tokens from response
total_tokens = 0
rate_limit_type: Final = self.v3_limiter.get_rate_limit_type()
if isinstance(response_obj, ModelResponse):
_usage: Final = getattr(response_obj, "usage", None)
if _usage and isinstance(_usage, Usage):
if rate_limit_type == "output":
total_tokens = _usage.completion_tokens
elif rate_limit_type == "input":
total_tokens = _usage.prompt_tokens
elif rate_limit_type == "total":
total_tokens = _usage.total_tokens
_usage: Final = _usage_of(response_obj)
if _usage is not None:
if rate_limit_type == "output":
total_tokens = _usage.completion_tokens
elif rate_limit_type == "input":
total_tokens = _usage.prompt_tokens
elif rate_limit_type == "total":
total_tokens = _usage.total_tokens
if total_tokens == 0:
return
@ -750,6 +898,9 @@ class _PROXY_DynamicRateLimitHandlerV3(CustomLogger):
else:
priority_key = f"{model_group}:default_pool"
team_model_tokens: Final = _team_model_token_increment(
get_or_create_request_stash().borrowing_team_model_limit, total_tokens, self.v3_limiter.window_size
)
pipeline_operations: Final = (
model_tokens,
CounterIncrement(
@ -757,6 +908,7 @@ class _PROXY_DynamicRateLimitHandlerV3(CustomLogger):
amount=total_tokens,
ttl=self.v3_limiter.window_size,
),
*team_model_tokens,
)
# Execute token increments with TTL preservation

View file

@ -31,6 +31,7 @@ from pydantic import TypeAdapter, ValidationError
from starlette.status import HTTP_400_BAD_REQUEST, HTTP_503_SERVICE_UNAVAILABLE
from typing_extensions import NotRequired, ReadOnly
import litellm
from litellm import DualCache
from litellm._internal_context import with_service_target
from litellm._logging import verbose_proxy_logger
@ -166,6 +167,66 @@ class RateLimitedModel:
return requested_limit if requested_limit is not None else limits.get(self.group)
def dynamic_rate_limiter_covers(model_group: str) -> bool:
from litellm.proxy.hooks.dynamic_rate_limiter_v3 import PROXY_DynamicRateLimitHandlerV3
from litellm.proxy.proxy_server import llm_router
if llm_router is None:
return False
group: Final = llm_router.registry().group(model_group)
if group is None or (group.tpm is None and group.rpm is None):
return False
return any(
callback == "dynamic_rate_limiter_v3" or isinstance(callback, PROXY_DynamicRateLimitHandlerV3)
for callback in litellm.callbacks
)
def inherited_team_model_limit(
user_api_key_dict: UserAPIKeyAuth,
model: RateLimitedModel,
rate_limit_key: Literal["model_rpm_limit", "model_tpm_limit"],
) -> int | None:
"""The team's per-model limit a key inherits, or None when the key sets its own limit for that model."""
team_limit: Final = model.limit_in(
get_model_rate_limit_from_metadata(user_api_key_dict, "team_metadata", rate_limit_key)
)
if (
team_limit is None
or model.limit_in(get_key_own_model_rate_limit(user_api_key_dict, rate_limit_key)) is not None
):
return None
return team_limit
@dataclass(frozen=True, slots=True)
class BorrowingTeamModelLimits:
"""Inherited team per-model limits the dynamic rate limiter enforces at saturation instead of the regular limiter."""
tpm: int | None
rpm: int | None
NO_BORROWING: Final = BorrowingTeamModelLimits(tpm=None, rpm=None)
def borrowing_team_model_limits(user_api_key_dict: UserAPIKeyAuth, model: RateLimitedModel) -> BorrowingTeamModelLimits:
settings: Final = litellm.priority_reservation_settings
if (
settings is None
or not settings.team_model_limit_borrows_idle_capacity_from_other_teams
or user_api_key_dict.team_id is None
):
return NO_BORROWING
limits: Final = BorrowingTeamModelLimits(
tpm=inherited_team_model_limit(user_api_key_dict, model, "model_tpm_limit"),
rpm=inherited_team_model_limit(user_api_key_dict, model, "model_rpm_limit"),
)
if limits == NO_BORROWING or not dynamic_rate_limiter_covers(model.group):
return NO_BORROWING
return limits
def _resolve_media_models(router: "Router | None", model: str, team_id: str | None) -> tuple[str, ...]:
if router is None:
return (model,)
@ -512,6 +573,7 @@ class RequestRateLimiterStash:
otpm_reserved_window_identities: frozenset[ReservationWindowIdentity] = field(default_factory=frozenset)
batch_enqueued_reservation: BatchEnqueuedTokenReservation | None = None
batch_tpd_refund_ops: tuple[ReservationOperation, ...] = ()
borrowing_team_model_limit: RateLimitDescriptor | None = None
reservation_released: bool = False
io_reservation_released: bool = False
tpm_limited_tags: frozenset[str] = field(default_factory=frozenset)
@ -2440,6 +2502,10 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
if response["overall_code"] == "OVER_LIMIT":
self._handle_rate_limit_error(response, tpm_windows, requested_model)
def rate_limited_model(self, requested_model: str | None) -> RateLimitedModel | None:
"""The requested name and the model group it resolves to, so limits keyed by either name are found."""
return self._rate_limited_model(requested_model)
def _rate_limited_model(self, requested_model: str | None) -> RateLimitedModel | None:
if not requested_model:
return None
@ -2551,11 +2617,16 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
model: Final = self._rate_limited_model(requested_model)
if model is None:
return
model_specific_tpm_limit: Final = model.limit_in(
get_key_model_tpm_limit(user_api_key_dict, model_name=model.group)
borrowing: Final = borrowing_team_model_limits(user_api_key_dict, model)
model_specific_tpm_limit: Final = (
None
if borrowing.tpm is not None
else model.limit_in(get_key_model_tpm_limit(user_api_key_dict, model_name=model.group))
)
model_specific_rpm_limit: Final = model.limit_in(
get_key_model_rpm_limit(user_api_key_dict, model_name=model.group)
model_specific_rpm_limit: Final = (
None
if borrowing.rpm is not None
else model.limit_in(get_key_model_rpm_limit(user_api_key_dict, model_name=model.group))
)
if model_specific_tpm_limit is None and model_specific_rpm_limit is None:
return
@ -3134,12 +3205,7 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
model: RateLimitedModel,
rate_limit_key: Literal["model_rpm_limit", "model_tpm_limit"],
) -> int | None:
team_limit: Final = model.limit_in(
get_model_rate_limit_from_metadata(user_api_key_dict, "team_metadata", rate_limit_key)
)
if team_limit is None or self._key_owns_model_limit(user_api_key_dict, model, rate_limit_key):
return None
return team_limit
return inherited_team_model_limit(user_api_key_dict, model, rate_limit_key)
def _key_owns_model_tpm_limit_from_request_metadata(
self,
@ -3165,8 +3231,17 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
model: Final = self._rate_limited_model(requested_model)
if model is None:
return
team_rpm_limit: Final = self._inherited_team_model_limit(user_api_key_dict, model, "model_rpm_limit")
team_tpm_limit: Final = self._inherited_team_model_limit(user_api_key_dict, model, "model_tpm_limit")
borrowing: Final = borrowing_team_model_limits(user_api_key_dict, model)
team_rpm_limit: Final = (
None
if borrowing.rpm is not None
else inherited_team_model_limit(user_api_key_dict, model, "model_rpm_limit")
)
team_tpm_limit: Final = (
None
if borrowing.tpm is not None
else inherited_team_model_limit(user_api_key_dict, model, "model_tpm_limit")
)
if team_rpm_limit is None and team_tpm_limit is None:
return
descriptors.append(

View file

@ -5389,6 +5389,10 @@ class PriorityReservationSettings(LiteLLMBaseModel):
default=0.50,
description="Saturation threshold (0.0-1.0) at which strict priority enforcement begins. Below this threshold, generous mode allows priority borrowing. Above this threshold, strict mode enforces normalized priority limits.",
)
team_model_limit_borrows_idle_capacity_from_other_teams: bool = Field(
default=False,
description="When true, a team's model_tpm_limit and model_rpm_limit on a model this limiter covers stop being ceilings. The team may run past them on capacity other teams are not using while the model is below saturation_threshold, and is held to them once the model saturates.",
)
saturation_check_cache_ttl: int = Field(
default=60,

View file

@ -0,0 +1,117 @@
import json
import uuid
from collections.abc import Iterator
from pathlib import Path
from typing import Final
import httpx
import pytest
import yaml
from integration._support.client import Gateway, gateway_from_environment
from integration._support.process import owned_proxy
from integration._support.wire import Reply, Request, wire_server
OPENAI_MODEL: Final = "gpt-4.1-mini"
MODEL_TPM: Final = 1000
MODEL_RPM: Final = 1000
TEAM_TPM: Final = 100
TEAM_RPM: Final = 3
UPSTREAM_REPLY: Final = json.dumps(
{
"id": "chatcmpl_team_model_limit_borrowing",
"object": "chat.completion",
"created": 1700000000,
"model": OPENAI_MODEL,
"choices": [{"index": 0, "message": {"role": "assistant", "content": "borrowed"}, "finish_reason": "stop"}],
"usage": {"prompt_tokens": 20, "completion_tokens": 10, "total_tokens": 30},
}
).encode()
def _respond(request: Request) -> Reply:
assert request.method == "POST" and request.target == "/v1/chat/completions", request.target
assert request.headers["authorization"] == "Bearer synthetic-openai-key"
return Reply(body=UPSTREAM_REPLY)
def _proxy(tmp_path_factory: pytest.TempPathFactory, saturation_threshold: float) -> Iterator[tuple[Gateway, str]]:
directory: Final = tmp_path_factory.mktemp(f"team-borrowing-{saturation_threshold}")
configuration: Final = yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text())
configuration["litellm_settings"] = {
**configuration["litellm_settings"],
"callbacks": ["dynamic_rate_limiter_v3"],
"priority_reservation": {"interactive": 0.6, "batch": 0.4},
"priority_reservation_settings": {
"default_priority": 1.0,
"saturation_threshold": saturation_threshold,
"team_model_limit_borrows_idle_capacity_from_other_teams": True,
},
}
path: Final = directory / "team_model_limit_borrowing.yaml"
path.write_text(yaml.safe_dump(configuration))
with (
gateway_from_environment() as rig,
wire_server(_respond) as wire,
owned_proxy(rig, directory, {}, config=path) as owned,
):
yield owned, wire.url
@pytest.fixture(scope="module")
def idle_model(tmp_path_factory: pytest.TempPathFactory) -> Iterator[tuple[Gateway, str]]:
yield from _proxy(tmp_path_factory, saturation_threshold=0.5)
@pytest.fixture(scope="module")
def saturated_model(tmp_path_factory: pytest.TempPathFactory) -> Iterator[tuple[Gateway, str]]:
yield from _proxy(tmp_path_factory, saturation_threshold=0.0)
def _team_calls(
owned: Gateway, upstream: str, calls: int, limit: str = "model_tpm_limit"
) -> tuple[httpx.Response, ...]:
with owned.scenario() as scenario:
model: Final = scenario.model(
model=f"openai/{OPENAI_MODEL}",
api_base=f"{upstream}/v1",
api_key="synthetic-openai-key",
tpm=MODEL_TPM,
rpm=MODEL_RPM,
)
key: Final = scenario.key(
team_id=scenario.team(metadata={limit: {model: TEAM_TPM if limit == "model_tpm_limit" else TEAM_RPM}})
)
return tuple(
owned.request(
"POST",
"/v1/chat/completions",
{"model": model, "messages": [{"role": "user", "content": f"team limit borrowing {uuid.uuid4().hex}"}]},
key=key,
)
for _ in range(calls)
)
@pytest.mark.parametrize("limit", ["model_tpm_limit", "model_rpm_limit"])
def test_team_borrows_idle_model_capacity_past_its_model_limit(idle_model: tuple[Gateway, str], limit: str) -> None:
owned, upstream = idle_model
responses: Final = _team_calls(owned, upstream, calls=8, limit=limit)
assert [response.status_code for response in responses] == [200] * 8, [r.text for r in responses]
@pytest.mark.parametrize(
"limit, rate_limit_type", [("model_tpm_limit", "tokens"), ("model_rpm_limit", "requests")], ids=["tpm", "rpm"]
)
def test_team_is_held_to_its_model_limit_once_the_model_saturates(
saturated_model: tuple[Gateway, str], limit: str, rate_limit_type: str
) -> None:
owned, upstream = saturated_model
responses: Final = _team_calls(owned, upstream, calls=8, limit=limit)
blocked: Final = tuple(response for response in responses if response.status_code == 429)
assert blocked, [r.text for r in responses]
assert responses[0].status_code == 200, responses[0].text
first: Final = blocked[0]
assert first.headers.get("rate_limited_by") == "model_per_team_saturated", dict(first.headers)
assert first.headers.get("rate_limit_type") == rate_limit_type, dict(first.headers)
assert f"Team {limit} reached" in first.json()["error"]["message"], first.text
assert "no idle capacity to borrow" in first.json()["error"]["message"], first.text

View file

@ -5,17 +5,20 @@ Core tests to validate that priority weights are respected (0.9/0.1) instead of
"""
import asyncio
import os
import time
from datetime import datetime, timedelta
from typing import Final
from unittest.mock import AsyncMock, patch
import pytest
import litellm
from litellm import DualCache, Router
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.common_utils.proxy_rate_limit_error import ProxyRateLimitError
from litellm.proxy.hooks.dynamic_rate_limiter_v3 import (
PROXY_DynamicRateLimitHandlerV3,
)
from litellm.proxy.hooks.dynamic_rate_limiter_v3 import (
PROXY_DynamicRateLimitHandlerV3 as DynamicRateLimitHandler,
)
@ -23,6 +26,7 @@ from litellm.proxy.hooks.parallel_request_limiter_v3 import (
PROXY_MaxParallelRequestsHandler_v3,
)
from litellm.redis.stores.rate_limits import RateLimitScope
from litellm.types.llms.openai import ResponseAPIUsage, ResponsesAPIResponse
from tests.unit.redis.stores.fake_rate_limits import FakeRateLimits
@ -353,9 +357,9 @@ async def test_100_concurrent_priority_requests(time_controller, monkeypatch):
assert result["rpm"] == 50, f"Low priority user {result['user_id']} got {result['rpm']} RPM, expected 50"
print(f"✅ Successfully processed 100 concurrent requests in {total_duration:.2f}s")
print(f" - 70 high priority users: 900 TPM, 450 RPM each")
print(f" - 30 low priority users: 100 TPM, 50 RPM each")
print(f" - Priority ratio maintained: 9:1 (TPM) and 9:1 (RPM)")
print(" - 70 high priority users: 900 TPM, 450 RPM each")
print(" - 30 low priority users: 100 TPM, 50 RPM each")
print(" - Priority ratio maintained: 9:1 (TPM) and 9:1 (RPM)")
@pytest.mark.asyncio
@ -572,7 +576,7 @@ async def test_concurrent_pre_call_hooks_stress(monkeypatch):
print(f" - Premium users: {premium_success}/{len(premium_results)} success ({premium_success_rate:.1%})")
print(f" - Standard users: {standard_success}/{len(standard_results)} success ({standard_success_rate:.1%})")
print(f" - Total successful: {successful_count}/50 ({successful_count / 50:.1%})")
print(f" - Priority system working: Premium > Standard success rates")
print(" - Priority system working: Premium > Standard success rates")
# These tests make actual async_pre_call_hook calls to simulate real traffic
@ -676,7 +680,7 @@ async def test_fake_calls_case_1_no_rate_limiting_at_capacity(monkeypatch):
total_successful = successful_requests["key_a"] + successful_requests["key_b"]
total_rate_limited = rate_limited_requests["key_a"] + rate_limited_requests["key_b"]
print(f"Test Case 1 - Saturation-Aware Rate Limiting:")
print("Test Case 1 - Saturation-Aware Rate Limiting:")
print(f" - Duration: {end_time - start_time:.2f}s")
print(f" - Key A: {successful_requests['key_a']}/1 successful (reserved 75 RPM)")
print(f" - Key B: {successful_requests['key_b']}/100 successful (reserved 25 RPM)")
@ -796,7 +800,7 @@ async def test_fake_calls_case_2_priority_queue_during_saturation(monkeypatch):
key_a_success_rate = successful_requests["key_a"] / 200
key_b_success_rate = successful_requests["key_b"] / 200
print(f"Test Case 2 - Priority Queue Behavior During Saturation:")
print("Test Case 2 - Priority Queue Behavior During Saturation:")
print(f" - Duration: {end_time - start_time:.2f}s")
print(f" - Key A: {successful_requests['key_a']}/200 successful ({key_a_success_rate:.1%})")
print(f" - Key B: {successful_requests['key_b']}/200 successful ({key_b_success_rate:.1%})")
@ -929,7 +933,7 @@ async def test_fake_calls_case_3_spillover_capacity_default_keys(monkeypatch):
# Analyze results
total_successful = sum(successful_requests.values())
print(f"Test Case 3 - Spillover Capacity for Default Keys:")
print("Test Case 3 - Spillover Capacity for Default Keys:")
print(f" - Duration: {end_time - start_time:.2f}s")
print(f" - Key A: {successful_requests['key_a']}/150 successful")
print(f" - Key B: {successful_requests['key_b']}/150 successful (default)")
@ -1047,7 +1051,7 @@ async def test_fake_calls_case_4_over_allocated_with_normalization(monkeypatch):
key_a_success_rate = successful_requests["key_a"] / 200
key_b_success_rate = successful_requests["key_b"] / 200
print(f"Test Case 4 - Over-Allocated Priority Reservations with Normalization:")
print("Test Case 4 - Over-Allocated Priority Reservations with Normalization:")
print(f" - Duration: {end_time - start_time:.2f}s")
print(f" - Key A (0.60): {successful_requests['key_a']}/200 successful ({key_a_success_rate:.1%})")
print(f" - Key B (0.80): {successful_requests['key_b']}/200 successful ({key_b_success_rate:.1%})")
@ -1184,7 +1188,7 @@ async def test_fake_calls_case_5_default_value_priority_reservation(monkeypatch)
# Analyze results
total_successful = sum(successful_requests.values())
print(f"Test Case 5 - Default value for priority reservation:")
print("Test Case 5 - Default value for priority reservation:")
print(f" - Duration: {end_time - start_time:.2f}s")
print(f" - Key A (0.50): {successful_requests['key_a']}/150 successful")
print(f" - Key B (0.20): {successful_requests['key_b']}/150 successful")
@ -1430,7 +1434,6 @@ async def test_saturation_check_cache_ttl_configuration(monkeypatch):
class _RecordingRateLimits(FakeRateLimits):
async def get(self, entry, *, backfill_ttl_seconds=None):
get_cache_calls.append({"entry": entry, "ttl": backfill_ttl_seconds})
return None # Simulate cache miss
handler.v3_limiter = PROXY_MaxParallelRequestsHandler_v3(
handler.internal_usage_cache,
@ -1819,3 +1822,261 @@ async def test_post_call_success_hook_priority_header_is_always_http_encodable(t
http_response = Response(headers={key: str(value) for key, value in additional_headers.items()})
assert http_response.headers.get("x-litellm-priority") == expected_priority_header
assert http_response.headers["x-litellm-rate-limiter-version"] == "v3"
def _borrowing_handler(
monkeypatch: pytest.MonkeyPatch,
borrows: bool,
saturation_threshold: float = 0.5,
model_group_alias: dict[str, str] | None = None,
) -> PROXY_DynamicRateLimitHandlerV3:
from litellm.types.utils import PriorityReservationSettings
monkeypatch.setenv("LITELLM_LICENSE", "test-license-key")
monkeypatch.setattr(litellm, "priority_reservation", {"interactive": 0.6, "batch": 0.4})
monkeypatch.setattr(
litellm,
"priority_reservation_settings",
PriorityReservationSettings(
saturation_threshold=saturation_threshold, team_model_limit_borrows_idle_capacity_from_other_teams=borrows
),
)
monkeypatch.setattr(litellm, "callbacks", ["dynamic_rate_limiter_v3"])
router: Final = Router(
model_list=[
{
"model_name": "shared-model",
"litellm_params": {"model": "gpt-3.5-turbo", "api_key": "k", "api_base": "b", "tpm": 1000, "rpm": 100},
}
],
model_group_alias=model_group_alias or {},
)
monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", router)
handler = PROXY_DynamicRateLimitHandlerV3(internal_usage_cache=DualCache())
handler.update_variables(llm_router=router)
return handler
async def _admit_then_settle(handler: PROXY_DynamicRateLimitHandlerV3, auth: UserAPIKeyAuth, tokens: int) -> None:
await _check(handler, auth, saturation=0.0)
await _settle_tokens(handler, tokens)
async def _settle_tokens(handler: PROXY_DynamicRateLimitHandlerV3, tokens: int, response: object | None = None) -> None:
await handler.async_log_success_event(
kwargs={
"litellm_params": {"metadata": {"model_group": "shared-model"}},
"standard_logging_object": {
"metadata": {"user_api_key_team_id": "team-a", "user_api_key_auth_metadata": {}}
},
},
response_obj=response
if response is not None
else litellm.ModelResponse(usage=litellm.Usage(prompt_tokens=tokens, completion_tokens=0, total_tokens=tokens)),
start_time=datetime.now(),
end_time=datetime.now(),
)
def _team_key(
team_id: str = "team-a",
team_limits: dict[str, dict[str, int]] | None = None,
**extra: dict[str, dict[str, int]],
) -> UserAPIKeyAuth:
return UserAPIKeyAuth(
api_key="sk-borrow",
team_id=team_id,
team_metadata=team_limits if team_limits is not None else {"model_tpm_limit": {"shared-model": 100}},
**extra,
)
async def _check(
handler: PROXY_DynamicRateLimitHandlerV3, auth: UserAPIKeyAuth, saturation: float, model: str = "shared-model"
) -> None:
group: Final = handler.llm_router.registry().group(model)
assert group is not None
await handler._check_rate_limits(
model=model, model_group_info=group, user_api_key_dict=auth, priority=None, saturation=saturation
)
def _held_to_team_limit(exc: ProxyRateLimitError, rate_limit_type: str, limit_sentence: str) -> None:
assert exc.status_code == 429
assert exc.headers is not None
assert exc.headers["rate_limited_by"] == "model_per_team_saturated"
assert exc.headers["rate_limit_type"] == rate_limit_type
assert str(exc.detail).startswith(
"{'error': 'Team " + limit_sentence + " reached for shared-model and the model is saturated, "
"so there is no idle capacity to borrow. Team: team-a, Rate limit type: " + rate_limit_type + ", "
), exc.detail
@pytest.mark.asyncio
async def test_team_past_its_model_tpm_limit_borrows_while_the_model_is_below_the_saturation_threshold(
monkeypatch: pytest.MonkeyPatch,
):
handler = _borrowing_handler(monkeypatch, borrows=True)
await _admit_then_settle(handler, _team_key(), 150)
await _check(handler, _team_key(), saturation=0.49)
@pytest.mark.asyncio
async def test_team_past_its_model_tpm_limit_is_held_to_it_once_the_model_saturates(monkeypatch: pytest.MonkeyPatch):
handler = _borrowing_handler(monkeypatch, borrows=True)
await _admit_then_settle(handler, _team_key(), 150)
with pytest.raises(ProxyRateLimitError) as exc:
await _check(handler, _team_key(), saturation=0.5)
_held_to_team_limit(exc.value, "tokens", "model_tpm_limit")
assert "Team model_tpm_limit: 100, Remaining: 0, Model saturation: 50.0%" in str(exc.value.detail)
@pytest.mark.asyncio
async def test_team_past_its_model_rpm_limit_is_held_to_it_once_the_model_saturates(monkeypatch: pytest.MonkeyPatch):
handler = _borrowing_handler(monkeypatch, borrows=True)
auth: Final = _team_key(team_limits={"model_rpm_limit": {"shared-model": 2}})
await _check(handler, auth, saturation=0.0)
await _check(handler, auth, saturation=0.0)
await _check(handler, auth, saturation=0.49)
with pytest.raises(ProxyRateLimitError) as exc:
await _check(handler, auth, saturation=0.5)
_held_to_team_limit(exc.value, "requests", "model_rpm_limit")
assert "Team model_rpm_limit: 2, Remaining: 0, Model saturation: 50.0%" in str(exc.value.detail)
@pytest.mark.asyncio
async def test_requests_admitted_below_saturation_count_only_their_settled_tokens(monkeypatch: pytest.MonkeyPatch):
handler = _borrowing_handler(monkeypatch, borrows=True)
for _ in range(20):
await _admit_then_settle(handler, _team_key(), 4)
await _check(handler, _team_key(), saturation=0.9)
@pytest.mark.asyncio
async def test_an_embedding_responses_tokens_count_against_the_team_limit(monkeypatch: pytest.MonkeyPatch):
handler = _borrowing_handler(monkeypatch, borrows=True)
await _check(handler, _team_key(), saturation=0.0)
await _settle_tokens(
handler,
150,
response=litellm.EmbeddingResponse(
usage=litellm.Usage(prompt_tokens=150, completion_tokens=0, total_tokens=150)
),
)
with pytest.raises(ProxyRateLimitError) as exc:
await _check(handler, _team_key(), saturation=0.5)
_held_to_team_limit(exc.value, "tokens", "model_tpm_limit")
@pytest.mark.asyncio
async def test_a_responses_api_calls_tokens_count_against_the_team_limit(monkeypatch: pytest.MonkeyPatch):
handler = _borrowing_handler(monkeypatch, borrows=True)
await _check(handler, _team_key(), saturation=0.0)
await _settle_tokens(
handler,
150,
response=ResponsesAPIResponse(
id="resp",
created_at=0,
model="m",
object="response",
output=[],
parallel_tool_calls=False,
tool_choice="auto",
tools=[],
usage=ResponseAPIUsage(input_tokens=100, output_tokens=50, total_tokens=150),
),
)
with pytest.raises(ProxyRateLimitError) as exc:
await _check(handler, _team_key(), saturation=0.5)
_held_to_team_limit(exc.value, "tokens", "model_tpm_limit")
@pytest.mark.asyncio
async def test_a_null_auth_metadata_still_settles_the_teams_tokens(monkeypatch: pytest.MonkeyPatch):
handler = _borrowing_handler(monkeypatch, borrows=True)
await _check(handler, _team_key(), saturation=0.0)
await handler.async_log_success_event(
kwargs={
"litellm_params": {"metadata": {"model_group": "shared-model"}},
"standard_logging_object": {
"metadata": {"user_api_key_team_id": "team-a", "user_api_key_auth_metadata": None}
},
},
response_obj=litellm.ModelResponse(
usage=litellm.Usage(prompt_tokens=150, completion_tokens=0, total_tokens=150)
),
start_time=None,
end_time=None,
)
with pytest.raises(ProxyRateLimitError) as exc:
await _check(handler, _team_key(), saturation=0.5)
_held_to_team_limit(exc.value, "tokens", "model_tpm_limit")
@pytest.mark.asyncio
async def test_another_teams_usage_does_not_count_against_a_teams_limit(monkeypatch: pytest.MonkeyPatch):
handler = _borrowing_handler(monkeypatch, borrows=True)
await _admit_then_settle(handler, _team_key("team-b"), 150)
await _check(handler, _team_key("team-a"), saturation=0.9)
@pytest.mark.asyncio
async def test_borrowing_is_off_by_default_so_the_dynamic_limiter_adds_no_team_limit(monkeypatch: pytest.MonkeyPatch):
handler = _borrowing_handler(monkeypatch, borrows=False)
await _admit_then_settle(handler, _team_key(), 150)
await _check(handler, _team_key(), saturation=0.9)
@pytest.mark.asyncio
async def test_a_key_that_owns_its_own_model_tpm_limit_is_not_held_to_the_team_limit(monkeypatch: pytest.MonkeyPatch):
handler = _borrowing_handler(monkeypatch, borrows=True)
owner: Final = _team_key(metadata={"model_tpm_limit": {"shared-model": 5000}})
await _admit_then_settle(handler, owner, 150)
await _check(handler, owner, saturation=0.9)
@pytest.mark.asyncio
async def test_a_key_that_owns_a_limit_on_another_model_is_still_held_to_the_team_limit(
monkeypatch: pytest.MonkeyPatch,
):
handler = _borrowing_handler(monkeypatch, borrows=True)
other_owner: Final = _team_key(metadata={"model_tpm_limit": {"other-model": 5000}})
await _admit_then_settle(handler, other_owner, 150)
with pytest.raises(ProxyRateLimitError) as exc:
await _check(handler, other_owner, saturation=0.5)
_held_to_team_limit(exc.value, "tokens", "model_tpm_limit")
@pytest.mark.asyncio
async def test_an_exempt_keys_tokens_are_not_charged_to_its_teammates(monkeypatch: pytest.MonkeyPatch):
handler = _borrowing_handler(monkeypatch, borrows=True)
owner: Final = _team_key(metadata={"model_tpm_limit": {"shared-model": 5000}})
await _admit_then_settle(handler, owner, 150)
await _check(handler, _team_key(), saturation=0.9)
@pytest.mark.asyncio
async def test_an_alias_request_counts_against_the_same_team_share_as_its_target(monkeypatch: pytest.MonkeyPatch):
handler = _borrowing_handler(monkeypatch, borrows=True, model_group_alias={"alias-model": "shared-model"})
await _check(handler, _team_key(), saturation=0.0, model="alias-model")
await _settle_tokens(handler, 150)
with pytest.raises(ProxyRateLimitError) as exc:
await _check(handler, _team_key(), saturation=0.5)
_held_to_team_limit(exc.value, "tokens", "model_tpm_limit")
@pytest.mark.asyncio
async def test_a_websocket_turn_is_held_to_the_team_limit_once_the_model_saturates(monkeypatch: pytest.MonkeyPatch):
handler = _borrowing_handler(monkeypatch, borrows=True, saturation_threshold=0.1)
await _admit_then_settle(handler, _team_key(), 150)
with pytest.raises(ProxyRateLimitError) as exc:
await handler.enforce_token_limits_for_frame(user_api_key_dict=_team_key(), requested_model="shared-model")
_held_to_team_limit(exc.value, "tokens", "model_tpm_limit")
@pytest.mark.asyncio
async def test_a_websocket_turn_still_borrows_while_the_model_is_idle(monkeypatch: pytest.MonkeyPatch):
handler = _borrowing_handler(monkeypatch, borrows=True, saturation_threshold=0.5)
await _admit_then_settle(handler, _team_key(), 150)
await handler.enforce_token_limits_for_frame(user_api_key_dict=_team_key(), requested_model="shared-model")

View file

@ -5,13 +5,12 @@ Unit Tests for the max parallel request limiter v3 for the proxy
import asyncio
import contextvars
import logging
import sys
import time
from collections.abc import AsyncGenerator, Iterator, Sequence
from contextlib import contextmanager
from datetime import datetime, timedelta, timezone
from functools import partial
from typing import Any, Dict, Final, List, Optional
from typing import Any, Final
from unittest.mock import patch
import httpx
@ -29,8 +28,8 @@ from litellm.integrations.custom_secret_manager import CustomSecretManager
from litellm.litellm_core_utils import ptu_pricing
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.litellm_core_utils.model_latest_aliases import get_model_latest_aliases
from litellm.litellm_core_utils.routing_common_utils import resolve_model_group_alias
from litellm.litellm_core_utils.ptu_shares import PTUTeamCeiling
from litellm.litellm_core_utils.routing_common_utils import resolve_model_group_alias
from litellm.litellm_core_utils.total_deadline import TotalTimeoutExpired, total_deadline
from litellm.llms.azure.ptu_capacity import AZURE_PTU_CAPACITY
from litellm.proxy._types import UserAPIKeyAuth
@ -67,8 +66,6 @@ from litellm.redis.stores.rate_limits import (
RateLimitStore,
)
from litellm.responses.streaming_iterator import _record_usage_for_failure
from tests._support.clock_loops import AutojumpClockLoop
from tests.unit.redis.stores.fake_rate_limits import FakeRateLimits, scope_key
from litellm.responses.utils import ResponseAPILoggingUtils
from litellm.types.extraction import ExtractionResponse
from litellm.types.llms.openai import ResponseAPIUsage, ResponsesAPIResponse
@ -83,6 +80,8 @@ from litellm.types.utils import (
TextCompletionResponse,
Usage,
)
from tests._support.clock_loops import AutojumpClockLoop
from tests.unit.redis.stores.fake_rate_limits import FakeRateLimits, scope_key
class TimeController:
@ -144,7 +143,8 @@ def reservation() -> bool:
@pytest.fixture
def collision_limiter(
reservation: bool, monkeypatch: pytest.MonkeyPatch,
reservation: bool,
monkeypatch: pytest.MonkeyPatch,
) -> tuple[DualCache, PROXY_MaxParallelRequestsHandler_v3, str, str]:
monkeypatch.setenv("LITELLM_TPM_TOKEN_RESERVATION_ENABLED", str(reservation).lower())
alias: Final = "anthropic/claude-opus-latest"
@ -169,23 +169,31 @@ def collision_limiter(
(scope, metric)
for scope in ("key", "team", "project", "organization")
for metric in ("model_rpm_limit", "model_tpm_limit")
) + (("project", "model_itpm_limit"), ("project", "model_otpm_limit")),
)
+ (("project", "model_itpm_limit"), ("project", "model_otpm_limit")),
)
@pytest.mark.parametrize("limit_alias", (False, True))
async def test_latest_alias_collision_keeps_the_stricter_limit(
collision_limiter: tuple[DualCache, PROXY_MaxParallelRequestsHandler_v3, str, str],
scope: str, metric: str, limit_alias: bool,
scope: str,
metric: str,
limit_alias: bool,
) -> None:
cache, handler, alias, target = collision_limiter
limits: Final = {metric: {alias: 0 if limit_alias else 100, target: 100 if limit_alias else 0}}
auth: Final = UserAPIKeyAuth(
api_key="collision-key", team_id="collision-team", project_id="collision-project", org_id="collision-org",
api_key="collision-key",
team_id="collision-team",
project_id="collision-project",
org_id="collision-org",
**{("metadata" if scope == "key" else f"{scope}_metadata"): limits},
)
with pytest.raises(ProxyRateLimitError) as exc:
await handler.async_pre_call_hook(
auth, cache,
{"model": alias, "messages": [{"role": "user", "content": "hi"}], "max_tokens": 1}, "acompletion",
auth,
cache,
{"model": alias, "messages": [{"role": "user", "content": "hi"}], "max_tokens": 1},
"acompletion",
)
assert exc.value.status_code == 429
@ -196,19 +204,27 @@ async def test_latest_alias_collision_keeps_the_stricter_limit(
@pytest.mark.parametrize("event", ("success", "failure", "partial"))
async def test_latest_alias_collision_charges_and_settles_one_counter_per_metric(
collision_limiter: tuple[DualCache, PROXY_MaxParallelRequestsHandler_v3, str, str],
scope: str, reservation: bool, event: str,
scope: str,
reservation: bool,
event: str,
) -> None:
cache, handler, alias, target = collision_limiter
limits: Final = {"model_rpm_limit": {alias: 2}, "model_tpm_limit": {target: 10000}}
auth: Final = UserAPIKeyAuth(
api_key="collision-key", team_id="collision-team", project_id="collision-project", org_id="collision-org",
api_key="collision-key",
team_id="collision-team",
project_id="collision-project",
org_id="collision-org",
**{("metadata" if scope == "key" else f"{scope}_metadata"): limits},
)
await handler.async_pre_call_hook(
auth, cache, {"model": alias, "messages": [{"role": "user", "content": "hi"}], "max_tokens": 64}, "acompletion"
)
entity: Final = {
"key": auth.api_key, "team": auth.team_id, "project": auth.project_id, "organization": auth.org_id
"key": auth.api_key,
"team": auth.team_id,
"project": auth.project_id,
"organization": auth.org_id,
}[scope]
rpm_key: Final = _counter(f"model_per_{scope}", f"{entity}:{target}", "requests")
tpm_key: Final = _counter(f"model_per_{scope}", f"{entity}:{target}", "tokens")
@ -221,8 +237,10 @@ async def test_latest_alias_collision_charges_and_settles_one_counter_per_metric
"standard_logging_object": {
"model_group": alias,
"metadata": {
"user_api_key_hash": auth.api_key, "user_api_key_team_id": auth.team_id,
"user_api_key_project_id": auth.project_id, "user_api_key_org_id": auth.org_id,
"user_api_key_hash": auth.api_key,
"user_api_key_team_id": auth.team_id,
"user_api_key_project_id": auth.project_id,
"user_api_key_org_id": auth.org_id,
},
},
"litellm_params": {"metadata": {"model_group": alias, "user_api_key_metadata": auth.metadata}},
@ -245,11 +263,13 @@ async def test_latest_alias_collision_charges_and_settles_one_counter_per_metric
@pytest.mark.asyncio
@pytest.mark.parametrize("event", ("success", "failure", "partial"))
async def test_latest_alias_collision_project_io_reservations_settle_once(
collision_limiter: tuple[DualCache, PROXY_MaxParallelRequestsHandler_v3, str, str], event: str,
collision_limiter: tuple[DualCache, PROXY_MaxParallelRequestsHandler_v3, str, str],
event: str,
) -> None:
cache, handler, alias, target = collision_limiter
auth: Final = UserAPIKeyAuth(
api_key="collision-io-key", project_id="collision-project",
api_key="collision-io-key",
project_id="collision-project",
project_metadata={metric: {alias: 10000, target: 10000} for metric in ("model_itpm_limit", "model_otpm_limit")},
)
await handler.async_pre_call_hook(
@ -258,7 +278,8 @@ async def test_latest_alias_collision_project_io_reservations_settle_once(
stash: Final = get_request_stash()
assert stash is not None
for scope, reserved in (
("model_per_project_itpm", stash.itpm_reserved_tokens), ("model_per_project_otpm", stash.otpm_reserved_tokens),
("model_per_project_itpm", stash.itpm_reserved_tokens),
("model_per_project_otpm", stash.otpm_reserved_tokens),
):
assert reserved > 0
counter: Final = _counter(scope, f"{auth.project_id}:{target}", "tokens")
@ -288,12 +309,15 @@ async def test_latest_alias_collision_project_io_reservations_settle_once(
@pytest.mark.parametrize("event", ("success", "partial"))
async def test_latest_alias_collision_settles_the_target_team_limit_beside_the_key_alias_limit(
collision_limiter: tuple[DualCache, PROXY_MaxParallelRequestsHandler_v3, str, str],
reservation: bool, event: str,
reservation: bool,
event: str,
) -> None:
cache, handler, alias, target = collision_limiter
auth: Final = UserAPIKeyAuth(
api_key="collision-inherited-key", team_id="collision-team",
metadata={"model_tpm_limit": {alias: 10000}}, team_metadata={"model_tpm_limit": {target: 10000}},
api_key="collision-inherited-key",
team_id="collision-team",
metadata={"model_tpm_limit": {alias: 10000}},
team_metadata={"model_tpm_limit": {target: 10000}},
)
await handler.async_pre_call_hook(
auth, cache, {"model": alias, "messages": [{"role": "user", "content": "hi"}], "max_tokens": 64}, "acompletion"
@ -323,7 +347,8 @@ async def test_latest_alias_collision_project_frames_charge_once_and_enforce_the
) -> None:
cache, handler, alias, target = collision_limiter
auth: Final = UserAPIKeyAuth(
api_key="collision-frame-key", project_id="collision-project",
api_key="collision-frame-key",
project_id="collision-project",
project_metadata={metric: {alias: 5, target: 100} for metric in ("model_itpm_limit", "model_otpm_limit")},
)
await handler.enforce_project_io_token_quota_for_frame(auth, alias, 3, 2)
@ -369,7 +394,9 @@ async def test_latest_alias_is_blocked_by_either_rate_limit(scope: str, metric:
@pytest.mark.parametrize("scope", ("key", "team", "project", "organization"))
@pytest.mark.parametrize("metric", ("model_rpm_limit", "model_tpm_limit"))
@pytest.mark.parametrize("model", ("anthropic/claude-opus-5-5", "anthropic/unknown-model", None))
async def test_latest_alias_rate_limit_does_not_govern_other_requests(scope: str, metric: str, model: str | None) -> None:
async def test_latest_alias_rate_limit_does_not_govern_other_requests(
scope: str, metric: str, model: str | None
) -> None:
cache: Final = DualCache()
handler: Final = _PROXY_MaxParallelRequestsHandler(internal_usage_cache=InternalUsageCache(cache))
limits: Final = {metric: {"anthropic/claude-opus-latest": 0}}
@ -408,7 +435,10 @@ async def test_latest_alias_rpm_draws_from_both_model_counters(scope: str) -> No
assert await handler.async_pre_call_hook(auth, cache, {"model": alias}, "acompletion") is None
descriptor: Final = f"model_per_{scope}"
entity: Final = {
"key": auth.api_key, "team": auth.team_id, "project": auth.project_id, "organization": auth.org_id
"key": auth.api_key,
"team": auth.team_id,
"project": auth.project_id,
"organization": auth.org_id,
}[scope]
for model in (alias, target):
counter: Final = _counter(descriptor, f"{entity}:{model}", "requests")
@ -455,11 +485,12 @@ async def test_latest_alias_tpm_settles_both_counters(
assert stash is not None
descriptor: Final = f"model_per_{scope}"
entity: Final = {
"key": auth.api_key, "team": auth.team_id, "project": auth.project_id, "organization": auth.org_id
"key": auth.api_key,
"team": auth.team_id,
"project": auth.project_id,
"organization": auth.org_id,
}[scope]
keys: Final = tuple(
_counter(descriptor, f"{entity}:{model}", "tokens") for model in (alias, target)
)
keys: Final = tuple(_counter(descriptor, f"{entity}:{model}", "tokens") for model in (alias, target))
for key in keys:
assert int(await handler.rate_limits.get(key) or 0) == (stash.reserved_tokens if reservation else 1)
kwargs: Final = {
@ -580,7 +611,10 @@ async def test_latest_alias_project_io_limits_block_either_name(metric: str, lim
)
with pytest.raises(ProxyRateLimitError) as exc:
await handler.async_pre_call_hook(
auth, cache, {"model": alias, "messages": [{"role": "user", "content": "hi"}], "max_tokens": 1}, "acompletion"
auth,
cache,
{"model": alias, "messages": [{"role": "user", "content": "hi"}], "max_tokens": 1},
"acompletion",
)
assert exc.value.status_code == 429
@ -627,7 +661,9 @@ async def test_latest_alias_project_io_reservations_settle_both_counters(event:
for model in (alias, target):
counter: Final = _counter(scope, f"{auth.project_id}:{model}", "tokens")
assert await handler.rate_limits.get(counter) == reserved
usage: Final = Usage(prompt_tokens=3, completion_tokens=2, total_tokens=5, prompt_tokens_details={"cached_tokens": 1})
usage: Final = Usage(
prompt_tokens=3, completion_tokens=2, total_tokens=5, prompt_tokens_details={"cached_tokens": 1}
)
kwargs: Final = {
"standard_logging_object": {
"model_group": alias,
@ -845,8 +881,8 @@ async def test_sliding_window_rate_limit_v3(monkeypatch, time_controller):
)
# Mock the batch_rate_limiter_script to simulate window expiry and use correct key construction
window_starts: Dict[str, int] = {}
request_counts: Dict[str, int] = {}
window_starts: dict[str, int] = {}
request_counts: dict[str, int] = {}
async def mock_batch_rate_limiter(*args, **kwargs):
keys = kwargs.get("keys") if kwargs else args[0]
@ -929,8 +965,8 @@ async def test_rate_limiter_script_return_values_v3(monkeypatch, time_controller
)
# Mock the batch_rate_limiter_script to simulate window expiry and use correct key construction
window_starts: Dict[str, int] = {}
request_counts: Dict[str, int] = {}
window_starts: dict[str, int] = {}
request_counts: dict[str, int] = {}
async def mock_batch_rate_limiter(*args, **kwargs):
keys = kwargs.get("keys") if kwargs else args[0]
@ -1071,8 +1107,8 @@ async def test_normal_router_call_tpm_v3(monkeypatch, rate_limit_object, time_co
)
# Mock the batch_rate_limiter_script to simulate window expiry and use correct key construction
window_starts: Dict[str, int] = {}
request_counts: Dict[str, int] = {}
window_starts: dict[str, int] = {}
request_counts: dict[str, int] = {}
async def mock_batch_rate_limiter(*args, **kwargs):
print(f"args: {args}, kwargs: {kwargs}")
@ -1458,7 +1494,7 @@ async def test_rejected_request_does_not_consume_parallel_slot_v3():
_api_key = hash_token("sk-98765")
user_api_key_dict = UserAPIKeyAuth(api_key=_api_key, max_parallel_requests=1)
admitted_data: Dict[str, Any] = {"model": "gpt-3.5-turbo"}
admitted_data: dict[str, Any] = {"model": "gpt-3.5-turbo"}
await handler.async_pre_call_hook(
user_api_key_dict=user_api_key_dict,
cache=local_cache,
@ -1524,7 +1560,7 @@ async def test_parallel_gauge_uses_atomic_redis_script_v3():
fakes.parallel_acquire_script = fake_acquire
data: Dict[str, Any] = {"model": "gpt-3.5-turbo"}
data: dict[str, Any] = {"model": "gpt-3.5-turbo"}
await handler.async_pre_call_hook(
user_api_key_dict=user_api_key_dict,
cache=local_cache,
@ -1700,10 +1736,6 @@ async def test_model_specific_rate_limits_only_called_when_configured_v3():
"""
Test that model-specific rate limits only trigger should_rate_limit when actually configured for the requested model.
"""
from litellm.proxy.auth.auth_utils import (
get_key_model_rpm_limit,
get_key_model_tpm_limit,
)
_api_key = "sk-98765"
_api_key = hash_token(_api_key)
@ -2369,7 +2401,7 @@ async def test_get_rate_limit_type_default_is_total(monkeypatch):
parallel_request_handler = _PROXY_MaxParallelRequestsHandler(internal_usage_cache=InternalUsageCache(local_cache))
# Mock general_settings to return empty dict (no token_rate_limit_type set)
import litellm.proxy.proxy_server as proxy_server
from litellm.proxy import proxy_server
original_settings = getattr(proxy_server, "general_settings", {})
monkeypatch.setattr(proxy_server, "general_settings", {})
@ -2390,7 +2422,7 @@ async def test_get_rate_limit_type_invalid_falls_back_to_total(monkeypatch):
parallel_request_handler = _PROXY_MaxParallelRequestsHandler(internal_usage_cache=InternalUsageCache(local_cache))
# Mock general_settings to return an invalid token_rate_limit_type
import litellm.proxy.proxy_server as proxy_server
from litellm.proxy import proxy_server
original_settings = getattr(proxy_server, "general_settings", {})
monkeypatch.setattr(proxy_server, "general_settings", {"token_rate_limit_type": "invalid_type"})
@ -3022,8 +3054,8 @@ async def test_agent_rate_limit_429_on_over_limit(monkeypatch, time_controller):
rpm_limit=2,
)
window_starts: Dict[str, int] = {}
request_counts: Dict[str, int] = {}
window_starts: dict[str, int] = {}
request_counts: dict[str, int] = {}
async def mock_batch_rate_limiter(*args, **kwargs):
keys = kwargs.get("keys") if kwargs else args[0]
@ -3466,7 +3498,7 @@ async def test_pre_call_hook_keeps_internal_stash_out_of_request_body():
parallel_request_handler.should_rate_limit = mock_should_rate_limit
parallel_request_handler.reserve_tpm_tokens = mock_reserve_tpm_tokens
data: Dict[str, Any] = {
data: dict[str, Any] = {
"model": "gpt-4o-mini",
"messages": [{"role": "user", "content": "hello"}],
"max_tokens": 10,
@ -3509,7 +3541,7 @@ async def test_responses_route_body_untouched_by_pre_call_hook(caller_metadata):
internal_usage_cache=InternalUsageCache(local_cache),
)
data: Dict[str, Any] = {
data: dict[str, Any] = {
"model": "gpt-4o-mini",
"input": "hello",
}
@ -3608,7 +3640,7 @@ async def test_pre_call_hook_ignores_caller_supplied_stash_values():
"_litellm_tpm_reserved_tokens": 9999,
"_litellm_rate_limit_descriptors": victim_descriptors,
}
data: Dict[str, Any] = {
data: dict[str, Any] = {
"model": "gpt-4o-mini",
"messages": [{"role": "user", "content": "hi"}],
"metadata": dict(injected),
@ -3976,7 +4008,7 @@ async def test_a_pre_call_after_a_failure_leaves_the_success_charging_project_io
],
)
async def test_a_nested_mcp_pre_call_settles_each_project_io_hold_once_v3(
steps: tuple[str, ...], key_tpm_limit: Optional[int], expected_counters: tuple[int, int, int, int]
steps: tuple[str, ...], key_tpm_limit: int | None, expected_counters: tuple[int, int, int, int]
) -> None:
api_key: Final = hash_token("sk-nested-mcp-hold-flags")
model: Final = "gpt-4o-mini"
@ -4626,10 +4658,7 @@ async def test_a_reservation_made_after_a_released_one_in_the_same_context_settl
"itpm": ("model_per_project_itpm", "proj-second:gpt-4o-mini"),
"otpm": ("model_per_project_otpm", "proj-second:gpt-4o-mini"),
}
settled: Final = {
name: int(_local(handler, _counter(key, value)) or 0)
for name, (key, value) in counters.items()
}
settled: Final = {name: int(_local(handler, _counter(key, value)) or 0) for name, (key, value) in counters.items()}
assert settled == {"tokens": 27, "itpm": 20, "otpm": 7}
@ -5453,7 +5482,7 @@ async def test_post_call_failure_hook_releases_parallel_slot_v3():
user_api_key_dict = UserAPIKeyAuth(api_key=_api_key, max_parallel_requests=1)
counter_key = RateLimitScope("api_key", _api_key).gauge
admitted_data: Dict[str, Any] = {"model": "gpt-3.5-turbo"}
admitted_data: dict[str, Any] = {"model": "gpt-3.5-turbo"}
await handler.async_pre_call_hook(
user_api_key_dict=user_api_key_dict,
cache=local_cache,
@ -5501,7 +5530,7 @@ async def test_success_event_releases_parallel_slot_v3(monkeypatch):
user_api_key_dict = UserAPIKeyAuth(api_key=_api_key, max_parallel_requests=1)
counter_key = RateLimitScope("api_key", _api_key).gauge
admitted_data: Dict[str, Any] = {"model": "gpt-3.5-turbo"}
admitted_data: dict[str, Any] = {"model": "gpt-3.5-turbo"}
await handler.async_pre_call_hook(
user_api_key_dict=user_api_key_dict,
cache=local_cache,
@ -5833,7 +5862,7 @@ async def test_in_memory_fallback_respects_mirrored_redis_count_v3():
assert exc_info.value.status_code == 429
handler.rate_limits.set_local(counter_key, 4, None)
admitted_data: Dict[str, Any] = {"model": "gpt-3.5-turbo"}
admitted_data: dict[str, Any] = {"model": "gpt-3.5-turbo"}
await handler.async_pre_call_hook(
user_api_key_dict=user_api_key_dict,
cache=local_cache,
@ -5938,7 +5967,7 @@ async def test_async_data_generator_releases_counter_on_disconnect_v3(disconnect
gap that let a disconnect leak the slot in the default limiter-only config.
A mid-stream disconnect must still refund the pre-call +1.
"""
import litellm.proxy.proxy_server as proxy_server
from litellm.proxy import proxy_server
limiter, cache, counter_key, user_api_key_dict = await _build_seeded_limiter()
proxy_logging_obj = proxy_server.proxy_logging_obj
@ -5988,8 +6017,8 @@ async def test_async_data_generator_releases_counter_when_wrapped_v3():
from the outer generator: the counter returns to 0 (not -1), proving the
nested hook does not also refund and there is no double decrement.
"""
import litellm.proxy.proxy_server as proxy_server
from litellm.integrations.custom_logger import CustomLogger
from litellm.proxy import proxy_server
class _PassthroughIteratorOverride(CustomLogger):
async def async_post_call_streaming_iterator_hook(self, user_api_key_dict, response, request_data):
@ -6054,14 +6083,14 @@ async def test_pre_call_hook_reserves_tpm_when_enabled(monkeypatch):
user_api_key_dict = UserAPIKeyAuth(api_key=hash_token("sk-tpm"), tpm_limit=10_000)
should_rate_limit_calls: List[Dict[str, Any]] = []
should_rate_limit_calls: list[dict[str, Any]] = []
original_should_rate_limit = handler.should_rate_limit
async def spy_should_rate_limit(*args, **kwargs):
should_rate_limit_calls.append(kwargs)
return await original_should_rate_limit(*args, **kwargs)
reserve_calls: List[int] = []
reserve_calls: list[int] = []
original_reserve = handler.reserve_tpm_tokens
async def spy_reserve(*args, **kwargs):
@ -6094,14 +6123,14 @@ async def test_pre_call_hook_skips_reservation_when_disabled(monkeypatch):
user_api_key_dict = UserAPIKeyAuth(api_key=hash_token("sk-tpm"), tpm_limit=10_000)
should_rate_limit_calls: List[Dict[str, Any]] = []
should_rate_limit_calls: list[dict[str, Any]] = []
original_should_rate_limit = handler.should_rate_limit
async def spy_should_rate_limit(*args, **kwargs):
should_rate_limit_calls.append(kwargs)
return await original_should_rate_limit(*args, **kwargs)
reserve_calls: List[Any] = []
reserve_calls: list[Any] = []
async def spy_reserve(*args, **kwargs):
reserve_calls.append(kwargs)
@ -6428,7 +6457,7 @@ async def test_streaming_end_to_end_populates_slp_ratelimit_headers(monkeypatch)
# Real pre-call: populates data and stashes the response into metadata
# so the success callback can find it via litellm_params.metadata.
data: Dict[str, Any] = {
data: dict[str, Any] = {
"model": "gpt-4o-mini",
"metadata": {},
"stream": True,
@ -6451,7 +6480,7 @@ async def test_streaming_end_to_end_populates_slp_ratelimit_headers(monkeypatch)
usage=Usage(prompt_tokens=100, completion_tokens=50, total_tokens=150),
choices=[],
)
mock_kwargs: Dict[str, Any] = {
mock_kwargs: dict[str, Any] = {
"standard_logging_object": {
"metadata": {
"user_api_key_hash": _api_key,
@ -6503,7 +6532,7 @@ async def test_streaming_populates_model_per_key_ratelimit_headers(monkeypatch):
local_cache = DualCache()
handler = _PROXY_MaxParallelRequestsHandler(internal_usage_cache=InternalUsageCache(local_cache))
data: Dict[str, Any] = {"model": "gpt-4o-mini", "metadata": {}, "stream": True}
data: dict[str, Any] = {"model": "gpt-4o-mini", "metadata": {}, "stream": True}
await handler.async_pre_call_hook(
user_api_key_dict=user_api_key_dict,
cache=local_cache,
@ -6520,7 +6549,7 @@ async def test_streaming_populates_model_per_key_ratelimit_headers(monkeypatch):
choices=[],
)
mock_kwargs: Dict[str, Any] = {
mock_kwargs: dict[str, Any] = {
"standard_logging_object": {
"metadata": {
"user_api_key_hash": _api_key,
@ -6570,7 +6599,7 @@ async def test_async_log_success_event_no_mirror_when_no_snapshot(monkeypatch):
choices=[],
)
mock_kwargs: Dict[str, Any] = {
mock_kwargs: dict[str, Any] = {
"standard_logging_object": {
"metadata": {
"user_api_key_hash": _api_key,
@ -6615,7 +6644,7 @@ async def test_streaming_mirror_matches_non_streaming_header_shape(monkeypatch):
handler = _PROXY_MaxParallelRequestsHandler(internal_usage_cache=InternalUsageCache(local_cache))
# Drive pre-call once so both paths have the same authoritative snapshot.
data: Dict[str, Any] = {"model": "gpt-4o-mini", "metadata": {}}
data: dict[str, Any] = {"model": "gpt-4o-mini", "metadata": {}}
await handler.async_pre_call_hook(
user_api_key_dict=user_api_key_dict,
cache=local_cache,
@ -6641,7 +6670,7 @@ async def test_streaming_mirror_matches_non_streaming_header_shape(monkeypatch):
non_stream_headers = non_stream_response._hidden_params.get("additional_headers", {})
# Streaming path: async_logging_hook mirrors into standard_logging_object.
stream_kwargs: Dict[str, Any] = {
stream_kwargs: dict[str, Any] = {
"standard_logging_object": {"metadata": {"user_api_key_hash": _api_key}},
"litellm_params": {"metadata": data["metadata"]},
"model": "gpt-4o-mini",
@ -6661,7 +6690,7 @@ async def test_streaming_mirror_matches_non_streaming_header_shape(monkeypatch):
)
stream_slp_headers = stream_kwargs["standard_logging_object"].get("hidden_params", {}).get("additional_headers", {})
def _rl_only(headers: Dict[str, Any]) -> Dict[str, Any]:
def _rl_only(headers: dict[str, Any]) -> dict[str, Any]:
return {k: v for k, v in headers.items() if k.startswith("x-ratelimit-")}
assert _rl_only(stream_slp_headers) == _rl_only(non_stream_headers), (
@ -6794,7 +6823,7 @@ async def test_atomic_check_with_zero_increment_still_enforces_token_limit():
value="tpm-only-model",
rate_limit={"tokens_per_unit": 100, "window_size": 60},
)
zero_token_increment: Dict[str, int] = {"requests": 1, "tokens": 0}
zero_token_increment: dict[str, int] = {"requests": 1, "tokens": 0}
under_limit = await handler.atomic_check_and_increment_by_n(
descriptors=[descriptor],
@ -6822,7 +6851,7 @@ async def test_atomic_check_with_zero_increment_still_enforces_token_limit():
assert _local(handler, counter_key) == 100
negative_increment: Dict[str, int] = {"requests": -1, "tokens": -50}
negative_increment: dict[str, int] = {"requests": -1, "tokens": -50}
refund_attempt = await handler.atomic_check_and_increment_by_n(
descriptors=[descriptor],
increments=[negative_increment],
@ -7194,7 +7223,7 @@ async def test_declared_estimate_never_tightens_the_small_tpm_clamp(monkeypatch)
monkeypatch.delenv("LITELLM_TPM_TOKEN_RESERVATION_ENABLED", raising=False)
local_cache = DualCache()
handler = _PROXY_MaxParallelRequestsHandler(internal_usage_cache=InternalUsageCache(local_cache))
raised_data: Dict[str, Any] = {"model": "gpt-4o-mini", "messages": ONE_TOKEN_PROMPT}
raised_data: dict[str, Any] = {"model": "gpt-4o-mini", "messages": ONE_TOKEN_PROMPT}
raised_reserved = await _reserved_tokens_for(
handler,
local_cache,
@ -7208,7 +7237,7 @@ async def test_declared_estimate_never_tightens_the_small_tpm_clamp(monkeypatch)
assert raised_data["max_tokens"] == 900
assert raised_reserved == ONE_TOKEN_PROMPT_INPUT_ESTIMATE + 900
lowered_data: Dict[str, Any] = {"model": "gpt-4o-mini", "messages": ONE_TOKEN_PROMPT}
lowered_data: dict[str, Any] = {"model": "gpt-4o-mini", "messages": ONE_TOKEN_PROMPT}
lowered_reserved = await _reserved_tokens_for(
handler,
local_cache,
@ -7222,7 +7251,7 @@ async def test_declared_estimate_never_tightens_the_small_tpm_clamp(monkeypatch)
assert lowered_data["max_tokens"] == 500
assert lowered_reserved == ONE_TOKEN_PROMPT_INPUT_ESTIMATE + 500
unconfigured_data: Dict[str, Any] = {"model": "gpt-4o-mini", "messages": ONE_TOKEN_PROMPT}
unconfigured_data: dict[str, Any] = {"model": "gpt-4o-mini", "messages": ONE_TOKEN_PROMPT}
unconfigured_reserved = await _reserved_tokens_for(
handler,
local_cache,
@ -7432,7 +7461,7 @@ def test_internal_call_origin_success_ops_are_skipped(origin, rate_limit_type):
choices=[],
)
def _kwargs(metadata: Dict[str, Any]) -> Dict[str, Any]:
def _kwargs(metadata: dict[str, Any]) -> dict[str, Any]:
return {
"standard_logging_object": {
"metadata": {
@ -7458,7 +7487,7 @@ def test_internal_call_origin_success_ops_are_skipped(origin, rate_limit_type):
assert skipped == (charged if origin == "autorouter_compaction" else [])
def _conflicting_budget_bodies() -> Dict[str, Dict[str, object]]:
def _conflicting_budget_bodies() -> dict[str, dict[str, object]]:
"""The same request, three ways of declaring the output budget."""
base = {"model": "gpt-5-chat", "messages": [{"role": "user", "content": "hi"}]}
return {
@ -8054,10 +8083,7 @@ async def test_fail_closed_tpm_rejection_releases_the_parallel_slot_it_acquired(
assert redis.released_slots == [
(
[
scope_key(gauge, "max_parallel_requests")
for gauge in acquired["gauges"]
],
[scope_key(gauge, "max_parallel_requests") for gauge in acquired["gauges"]],
[acquired["slot_id"]],
)
]
@ -8066,7 +8092,7 @@ async def test_fail_closed_tpm_rejection_releases_the_parallel_slot_it_acquired(
@pytest.mark.asyncio
async def test_fail_closed_rate_limit_enforcement_is_read_from_general_settings(monkeypatch):
import litellm.proxy.proxy_server as proxy_server
from litellm.proxy import proxy_server
auth = UserAPIKeyAuth(api_key=hash_token("sk-fail-closed-settings"), rpm_limit=2)
@ -8088,7 +8114,7 @@ async def test_fail_closed_rate_limit_enforcement_is_read_from_general_settings(
async def test_fail_closed_rate_limit_enforcement_coerces_the_general_settings_value(
monkeypatch, configured_value, rejects
):
import litellm.proxy.proxy_server as proxy_server
from litellm.proxy import proxy_server
auth = UserAPIKeyAuth(api_key=hash_token("sk-fail-closed-coerced"), rpm_limit=2)
monkeypatch.setitem(proxy_server.general_settings, "fail_closed_rate_limit_enforcement", configured_value)
@ -8283,7 +8309,7 @@ async def test_request_capacity_enforces_shared_rpm_scopes(limits, request_data,
@pytest.mark.asyncio
async def test_request_capacity_keeps_dynamic_rpm_policy(monkeypatch):
import litellm.proxy.proxy_server as proxy_server
from litellm.proxy import proxy_server
router = Router(
model_list=[
@ -8659,7 +8685,7 @@ async def _rpm_request(
async def test_model_group_alias_shares_deployment_default_rpm_bucket_with_its_target(
monkeypatch: pytest.MonkeyPatch, first_name: str, second_name: str
) -> None:
import litellm.proxy.proxy_server as proxy_server
from litellm.proxy import proxy_server
router: Final = Router(
model_list=[
@ -9947,7 +9973,6 @@ async def test_end_user_model_rpm_limit_is_shared_across_keys_and_isolated_per_c
await _rpm_request(handler, cache, acme_via_key_one, "other-model")
@pytest.mark.asyncio
async def test_success_tpm_accounting_moves_the_charge_to_the_model_group_that_served_a_fallback() -> None:
cache: Final = DualCache()
@ -10047,7 +10072,6 @@ async def test_success_tpm_accounting_settles_an_alias_to_target_fallback_once_p
assert [inc.amount for inc in increments if inc.counter == alias_counter] == []
@pytest.mark.asyncio
async def test_success_tpm_accounting_ignores_a_client_supplied_model_group_without_a_router_fallback() -> None:
cache: Final = DualCache()
@ -10086,3 +10110,71 @@ async def test_success_tpm_accounting_ignores_a_client_supplied_model_group_with
charged: Final = {_increment_of(op).counter: _increment_of(op).amount for op in ops}
assert charged[_counter("model_per_key", f"{auth.api_key}:primary")] == 150 - stash.reserved_tokens
assert not any(":uncapped" in counter.scope.descriptor_value for counter in charged)
@pytest.mark.parametrize(
"rate_limit_type, team_limits, data",
[
(
"tokens",
{"model_tpm_limit": {"test-model": 500}},
{"model": "test-model", "messages": [{"role": "user", "content": "hi"}], "max_tokens": 200},
),
("requests", {"model_rpm_limit": {"test-model": 2}}, {"model": "test-model"}),
],
ids=["model_tpm_limit", "model_rpm_limit"],
)
@pytest.mark.parametrize(
"borrows, third_request_passes",
[(False, False), (True, True)],
ids=["by_default_the_team_limit_stays_a_ceiling", "with_borrowing_on_the_dynamic_limiter_takes_over_the_limit"],
)
@pytest.mark.asyncio
async def test_team_model_limits_stop_being_hard_caps_when_borrowing_is_on(
monkeypatch: pytest.MonkeyPatch,
borrows: bool,
third_request_passes: bool,
rate_limit_type: str,
team_limits: dict[str, dict[str, int]],
data: dict[str, object],
):
from litellm.types.utils import PriorityReservationSettings
monkeypatch.setattr(
litellm,
"priority_reservation_settings",
PriorityReservationSettings(team_model_limit_borrows_idle_capacity_from_other_teams=borrows),
)
monkeypatch.setattr(litellm, "callbacks", ["dynamic_rate_limiter_v3"])
monkeypatch.setattr(
"litellm.proxy.proxy_server.llm_router",
Router(
model_list=[
{
"model_name": "test-model",
"litellm_params": {"model": "gpt-3.5-turbo", "api_key": "k", "api_base": "b", "tpm": 1000},
}
]
),
)
cache = DualCache()
handler = _PROXY_MaxParallelRequestsHandler(internal_usage_cache=InternalUsageCache(cache))
auth = UserAPIKeyAuth(
api_key=hash_token("sk-borrow"),
team_id="t",
team_metadata=team_limits,
)
async def request():
await handler.async_pre_call_hook(user_api_key_dict=auth, cache=cache, data=dict(data), call_type="acompletion")
await request()
await request()
if third_request_passes:
await request()
return
with pytest.raises(HTTPException) as exc:
await request()
assert exc.value.status_code == 429
assert exc.value.headers is not None
assert exc.value.headers["rate_limit_type"] == rate_limit_type