mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-13 08:52:02 +00:00
feat(dynamic_rate_limiter_v3): priority_class_share_splits_equally_among_active_teams splits a priority class share among the teams active on a model (#51366)
* feat(dynamic_rate_limiter_v3): split a priority class share equally among the teams active on a model Adds priority_reservation_settings.priority_class_share_splits_equally_among_active_teams (default false). When on, the class weights are renormalized over the classes with a team active on the model in the current window, each class share is divided equally among its active teams, and once the model saturates each team is held to its own share with a 429 whose rate_limited_by header reads priority_class_share_saturated. Active teams are one Redis sorted set per model with the window as TTL. * fix(proxy): keep basedpyright counts flat for the priority class share setting PriorityReservationSettings is built with model_validate instead of **value, so a new field no longer adds an unknown-argument diagnostic, and the limiter unit test helpers type the handler by its implementation class. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(dynamic_rate_limiter_v3): keep team shares usable when Redis, mixed-priority keys, WebSocket turns or tiny shares get in the way A registry failure falls back to the class pool instead of skipping the model limits, a team's counter is keyed by model and team so keys of different priorities spend one share, Responses WebSocket turns are held to the class share like the team model limit, a share is floored at one request and one token, and the registry declares the rate limits key family. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(dynamic_rate_limiter_v3): catch only Redis and validation errors when the active team registry fails Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(dynamic_rate_limiter_v3): keys without a team keep their class present, team ids may contain the registry separator, breaker-open counts as a registry failure Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(dynamic_rate_limiter_v3): per-class pool markers that take no team slice, collision-free model and team counter keys, local registry pruned by last-seen time Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(dynamic_rate_limiter_v3): settle tokens only on the stash the call owns, keep a configured class named default apart from teams with no priority Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(dynamic_rate_limiter_v3): tag team registry members and count every unenforced request against the team share A team id can no longer spell a pool marker, since real teams are stored under a team prefix. Below saturation the team share counter is bumped through the atomic increment at the model-wide limit, so the local over-limit short-circuit never skips a request and the counter is complete when the share grows. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(dynamic_rate_limiter_v3): list the caller under its own class and resync the socket stash share on every frame Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(dynamic_rate_limiter_v3): keep the connection share across a registry outage and settle fallback tokens on the served model's team counter 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:
parent
52d1c4cd80
commit
c0114e55aa
8 changed files with 1074 additions and 30 deletions
|
|
@ -11,12 +11,14 @@ from typing import TYPE_CHECKING, Final, Literal, TypeAlias
|
|||
|
||||
from fastapi import HTTPException
|
||||
from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError
|
||||
from redis.exceptions import RedisError
|
||||
|
||||
import litellm
|
||||
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.caching.redis_cache import RedisCircuitBreakerOpenError
|
||||
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 (
|
||||
|
|
@ -37,13 +39,25 @@ from litellm.proxy.hooks.parallel_request_limiter_v3 import ( # noqa: F401 # l
|
|||
RateLimitedModel,
|
||||
RateLimitResponse,
|
||||
RateLimitStatus,
|
||||
_call_id_from_callback_kwargs, # pyright: ignore[reportPrivateUsage] # same call-ownership check the regular limiter uses
|
||||
_is_socket_turn, # pyright: ignore[reportPrivateUsage] # same socket-turn check the regular limiter uses
|
||||
_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,
|
||||
get_request_stash,
|
||||
get_request_stash_for_call,
|
||||
)
|
||||
from litellm.proxy.hooks.priority_class_share import (
|
||||
DEFAULT_CLASS,
|
||||
ActiveTeamRegistry,
|
||||
TeamShare,
|
||||
counter_value,
|
||||
pool_member,
|
||||
team_member,
|
||||
team_share,
|
||||
)
|
||||
from litellm.proxy.hooks.rate_limiter_utils import (
|
||||
convert_priority_to_percent,
|
||||
resolve_llm_provider_for_rate_limit,
|
||||
|
|
@ -341,6 +355,7 @@ def _usage_of(response_obj: object) -> Usage | None:
|
|||
|
||||
|
||||
TEAM_MODEL_LIMIT_AT_SATURATION: Final = "model_per_team_saturated"
|
||||
PRIORITY_CLASS_SHARE_AT_SATURATION: Final = "priority_class_share_saturated"
|
||||
TEAM_MODEL_LIMIT_MAX_BORROW_MULTIPLE: Final = "model_per_team_max_borrow_multiple"
|
||||
|
||||
|
||||
|
|
@ -427,6 +442,7 @@ class _PROXY_DynamicRateLimitHandlerV3(CustomLogger):
|
|||
self.internal_usage_cache = InternalUsageCache(dual_cache=internal_usage_cache)
|
||||
self._is_model_authorized = is_model_authorized
|
||||
self.v3_limiter = PROXY_MaxParallelRequestsHandler_v3(self.internal_usage_cache, time_provider=time_provider)
|
||||
self.active_teams = ActiveTeamRegistry(internal_usage_cache)
|
||||
|
||||
def update_variables(self, llm_router: Router) -> None:
|
||||
self.llm_router = llm_router
|
||||
|
|
@ -619,11 +635,54 @@ class _PROXY_DynamicRateLimitHandlerV3(CustomLogger):
|
|||
# Fail open: assume not saturated on error
|
||||
return 0.0
|
||||
|
||||
def _priority_class(self, priority: str | None) -> str:
|
||||
"""The class a team is counted under: its explicit priority, or one shared default class."""
|
||||
if (
|
||||
priority is not None
|
||||
and litellm.priority_reservation is not None
|
||||
and priority in litellm.priority_reservation
|
||||
):
|
||||
return priority
|
||||
return DEFAULT_CLASS
|
||||
|
||||
async def _team_class_share(
|
||||
self,
|
||||
model: str,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
priority: str | None,
|
||||
normalized_weights: Mapping[str, float],
|
||||
) -> TeamShare | None:
|
||||
"""The caller's slice of its class share, or None when the split is off or the key has no team. A key with no
|
||||
team keeps the class pool but still marks its class active, so the pool's capacity is not lent away."""
|
||||
if (
|
||||
litellm.priority_reservation is None
|
||||
or not _get_priority_settings().priority_class_share_splits_equally_among_active_teams
|
||||
):
|
||||
return None
|
||||
priority_class: Final = self._priority_class(priority)
|
||||
member: Final = (
|
||||
pool_member(priority_class) if user_api_key_dict.team_id is None else team_member(user_api_key_dict.team_id)
|
||||
)
|
||||
try:
|
||||
members: Final = await self.active_teams.touch(model, member, priority_class, self.v3_limiter.window_size)
|
||||
except (RedisError, RedisCircuitBreakerOpenError, ValidationError) as e:
|
||||
verbose_proxy_logger.warning(
|
||||
"Active team registry unavailable for %s, holding team %s to the class pool: %s",
|
||||
model,
|
||||
user_api_key_dict.team_id,
|
||||
e,
|
||||
)
|
||||
return None
|
||||
if user_api_key_dict.team_id is None:
|
||||
return None
|
||||
return team_share(priority_class, members, normalized_weights, _get_priority_settings().default_priority)
|
||||
|
||||
def _create_priority_based_descriptors(
|
||||
self,
|
||||
model: str,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
priority: str | None,
|
||||
share: TeamShare | None = None,
|
||||
) -> list[RateLimitDescriptor]:
|
||||
"""
|
||||
Create rate limit descriptors with normalized priority weights.
|
||||
|
|
@ -653,14 +712,16 @@ class _PROXY_DynamicRateLimitHandlerV3(CustomLogger):
|
|||
)
|
||||
|
||||
rate_limit_config: Final[RateLimitDescriptorRateLimitObject] = {}
|
||||
weight: Final = priority_weight if share is None else share.fraction
|
||||
floor: Final = 0 if share is None else 1
|
||||
|
||||
# Apply priority weight to model limits
|
||||
if model_group_info.tpm is not None:
|
||||
reserved_tpm: Final = int(model_group_info.tpm * priority_weight)
|
||||
reserved_tpm: Final = max(floor, int(model_group_info.tpm * weight))
|
||||
rate_limit_config["tokens_per_unit"] = reserved_tpm
|
||||
|
||||
if model_group_info.rpm is not None:
|
||||
reserved_rpm: Final = int(model_group_info.rpm * priority_weight)
|
||||
reserved_rpm: Final = max(floor, int(model_group_info.rpm * weight))
|
||||
rate_limit_config["requests_per_unit"] = reserved_rpm
|
||||
|
||||
if rate_limit_config:
|
||||
|
|
@ -668,8 +729,8 @@ class _PROXY_DynamicRateLimitHandlerV3(CustomLogger):
|
|||
|
||||
descriptors.append(
|
||||
RateLimitDescriptor(
|
||||
key="priority_model",
|
||||
value=priority_key,
|
||||
key="priority_model" if share is None else PRIORITY_CLASS_SHARE_AT_SATURATION,
|
||||
value=priority_key if share is None else counter_value(model, str(user_api_key_dict.team_id)),
|
||||
rate_limit=rate_limit_config,
|
||||
)
|
||||
)
|
||||
|
|
@ -783,35 +844,93 @@ class _PROXY_DynamicRateLimitHandlerV3(CustomLogger):
|
|||
llm_provider=llm_provider,
|
||||
)
|
||||
|
||||
def _class_share_error(
|
||||
self,
|
||||
model: str,
|
||||
status: RateLimitStatus,
|
||||
saturation: float,
|
||||
priority: str | None,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
share: TeamShare,
|
||||
) -> ProxyRateLimitError:
|
||||
resolved_model, llm_provider = resolve_llm_provider_for_rate_limit(model)
|
||||
return ProxyRateLimitError(
|
||||
detail={
|
||||
"error": f"Priority class share reached for {model} and the model is saturated. "
|
||||
f"Team: {user_api_key_dict.team_id}, "
|
||||
f"Priority: {priority or 'default'}, "
|
||||
f"Active teams in class: {share.active_teams_in_class}, "
|
||||
f"Active classes: {share.active_classes}, "
|
||||
f"Rate limit type: {status['rate_limit_type']}, "
|
||||
f"Team share: {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": PRIORITY_CLASS_SHARE_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."""
|
||||
"""Hold a Responses WebSocket turn to the team's shares once the model saturates, like the regular limiter does."""
|
||||
if requested_model is None:
|
||||
return
|
||||
team_share: Final = self._borrowing_team_share(model=requested_model, user_api_key_dict=user_api_key_dict)
|
||||
model_group_info: Final = self.llm_router.registry().group(requested_model)
|
||||
if team_share is None or model_group_info is None:
|
||||
if model_group_info is None:
|
||||
return
|
||||
priority: Final = self._get_priority_from_user_api_key_dict(user_api_key_dict)
|
||||
share: Final = await self._team_class_share(
|
||||
requested_model, user_api_key_dict, priority, self._normalize_priority_weights(model_group_info)
|
||||
)
|
||||
share_descriptors: Final = (
|
||||
()
|
||||
if share is None
|
||||
else tuple(
|
||||
self._create_priority_based_descriptors(
|
||||
model=requested_model, user_api_key_dict=user_api_key_dict, priority=priority, share=share
|
||||
)
|
||||
)
|
||||
)
|
||||
frame_share: Final = next(
|
||||
(d for d in share_descriptors if d["key"] == PRIORITY_CLASS_SHARE_AT_SATURATION), None
|
||||
)
|
||||
connection_stash: Final = get_request_stash()
|
||||
if connection_stash is not None and frame_share is not None:
|
||||
connection_stash.priority_class_share = frame_share
|
||||
borrowing: Final = self._borrowing_team_share(model=requested_model, user_api_key_dict=user_api_key_dict)
|
||||
if not share_descriptors and borrowing is None:
|
||||
return
|
||||
saturation: Final = await self._check_model_saturation(requested_model, model_group_info)
|
||||
enforced: Final = team_share.enforced(saturation >= _get_priority_settings().saturation_threshold)
|
||||
if enforced is None:
|
||||
saturated: Final = saturation >= _get_priority_settings().saturation_threshold
|
||||
enforced_team: Final = None if borrowing is None else borrowing.enforced(saturated)
|
||||
descriptors: Final = (share_descriptors if saturated else ()) + (
|
||||
() if enforced_team is None else (enforced_team,)
|
||||
)
|
||||
if not descriptors:
|
||||
return
|
||||
tpm_windows: Final = _socket_turn_tpm_windows((enforced,))
|
||||
tpm_windows: Final = _socket_turn_tpm_windows(descriptors)
|
||||
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_share_error(
|
||||
requested_model,
|
||||
over_limit,
|
||||
saturation,
|
||||
self._get_priority_from_user_api_key_dict(user_api_key_dict),
|
||||
user_api_key_dict,
|
||||
team_share,
|
||||
)
|
||||
over_class_share: Final = over_limit["descriptor_key"] == PRIORITY_CLASS_SHARE_AT_SATURATION
|
||||
if share is not None and (over_class_share or borrowing is None):
|
||||
raise self._class_share_error(requested_model, over_limit, saturation, priority, user_api_key_dict, share)
|
||||
if borrowing is not None:
|
||||
raise self._team_share_error(
|
||||
requested_model, over_limit, saturation, priority, user_api_key_dict, borrowing
|
||||
)
|
||||
|
||||
def _create_model_tracking_descriptor(
|
||||
self,
|
||||
|
|
@ -893,10 +1012,17 @@ class _PROXY_DynamicRateLimitHandlerV3(CustomLogger):
|
|||
descriptors_to_check.append(model_wide_descriptor)
|
||||
|
||||
# Priority descriptors (always track, conditionally enforce)
|
||||
share: Final = await self._team_class_share(
|
||||
model, user_api_key_dict, priority, self._normalize_priority_weights(model_group_info)
|
||||
)
|
||||
priority_descriptors: Final = self._create_priority_based_descriptors(
|
||||
model=model,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
priority=priority,
|
||||
share=share,
|
||||
)
|
||||
get_or_create_request_stash().priority_class_share = next(
|
||||
(d for d in priority_descriptors if d["key"] == PRIORITY_CLASS_SHARE_AT_SATURATION), None
|
||||
)
|
||||
if priority_descriptors:
|
||||
descriptors_to_check.extend(priority_descriptors)
|
||||
|
|
@ -986,6 +1112,8 @@ class _PROXY_DynamicRateLimitHandlerV3(CustomLogger):
|
|||
|
||||
if descriptor_key == TEAM_MODEL_LIMIT_AT_SATURATION and team_share is not None:
|
||||
raise self._team_share_error(model, status, saturation, priority, user_api_key_dict, team_share)
|
||||
if descriptor_key == PRIORITY_CLASS_SHARE_AT_SATURATION and share is not None:
|
||||
raise self._class_share_error(model, status, saturation, priority, user_api_key_dict, share)
|
||||
|
||||
# Fail-closed guard: overall_code says OVER_LIMIT but no status
|
||||
# matched a descriptor key we know how to translate into a 429.
|
||||
|
|
@ -1032,7 +1160,22 @@ class _PROXY_DynamicRateLimitHandlerV3(CustomLogger):
|
|||
increments=[per_request_increment],
|
||||
parent_otel_span=user_api_key_dict.parent_otel_span,
|
||||
)
|
||||
if priority_descriptors and not should_enforce_priority:
|
||||
if priority_descriptors and share is not None and not should_enforce_priority:
|
||||
tracking_only: Final = tuple(
|
||||
RateLimitDescriptor(
|
||||
key=descriptor["key"],
|
||||
value=descriptor["value"],
|
||||
rate_limit=model_wide_descriptor["rate_limit"],
|
||||
)
|
||||
for descriptor in priority_descriptors
|
||||
)
|
||||
await self.v3_limiter.atomic_check_and_increment_by_n(
|
||||
descriptors=list(tracking_only),
|
||||
increments=[per_request_increment for _ in tracking_only],
|
||||
parent_otel_span=user_api_key_dict.parent_otel_span,
|
||||
)
|
||||
get_or_create_request_stash().rate_limit_response = atomic_response
|
||||
elif priority_descriptors and not should_enforce_priority:
|
||||
priority_tracking_response: Final = await self.v3_limiter.should_rate_limit(
|
||||
descriptors=priority_descriptors,
|
||||
parent_otel_span=user_api_key_dict.parent_otel_span,
|
||||
|
|
@ -1378,13 +1521,27 @@ class _PROXY_DynamicRateLimitHandlerV3(CustomLogger):
|
|||
else:
|
||||
priority_key = f"{model_group}:default_pool"
|
||||
|
||||
call_stash: Final = get_request_stash_for_call(_call_id_from_callback_kwargs(kwargs))
|
||||
stash: Final = (
|
||||
call_stash if call_stash is not None else (get_request_stash() if _is_socket_turn(kwargs) else None)
|
||||
)
|
||||
team_model_tokens: Final = _team_model_token_increment(
|
||||
get_or_create_request_stash().borrowing_team_model_limit, total_tokens, self.v3_limiter.window_size
|
||||
None if stash is None else stash.borrowing_team_model_limit, total_tokens, self.v3_limiter.window_size
|
||||
)
|
||||
class_share: Final = None if stash is None else stash.priority_class_share
|
||||
settled_team_id: Final = settlement_metadata.user_api_key_team_id
|
||||
priority_scope: Final = (
|
||||
RateLimitScope("priority_model", priority_key)
|
||||
if class_share is None
|
||||
else RateLimitScope(
|
||||
class_share["key"],
|
||||
class_share["value"] if settled_team_id is None else counter_value(model_group, settled_team_id),
|
||||
)
|
||||
)
|
||||
pipeline_operations: Final = (
|
||||
model_tokens,
|
||||
CounterIncrement(
|
||||
counter=RateLimitScope("priority_model", priority_key).counter("tokens"),
|
||||
counter=priority_scope.counter("tokens"),
|
||||
amount=total_tokens,
|
||||
ttl=self.v3_limiter.window_size,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -615,6 +615,7 @@ class RequestRateLimiterStash:
|
|||
batch_enqueued_reservation: BatchEnqueuedTokenReservation | None = None
|
||||
batch_tpd_refund_ops: tuple[ReservationOperation, ...] = ()
|
||||
borrowing_team_model_limit: RateLimitDescriptor | None = None
|
||||
priority_class_share: RateLimitDescriptor | None = None
|
||||
reservation_released: bool = False
|
||||
io_reservation_released: bool = False
|
||||
tpm_limited_tags: frozenset[str] = field(default_factory=frozenset)
|
||||
|
|
|
|||
127
litellm/proxy/hooks/priority_class_share.py
Normal file
127
litellm/proxy/hooks/priority_class_share.py
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
"""Splits a priority class's share of a model among the teams active on it.
|
||||
|
||||
Enabled by ``priority_reservation_settings.priority_class_share_splits_equally_among_active_teams``.
|
||||
"""
|
||||
|
||||
import time
|
||||
from collections.abc import Callable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from types import MappingProxyType
|
||||
from typing import Final
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from litellm._internal_context import with_service_target
|
||||
from litellm.caching.caching import DualCache
|
||||
from litellm.redis.stores.rate_limits import RATE_LIMITS_TARGET
|
||||
|
||||
ACTIVE_TEAMS_KEY_PREFIX: Final = "dynamic_rate_limiter:active_teams:"
|
||||
MEMBER_SEPARATOR: Final = "\x1f"
|
||||
DEFAULT_CLASS: Final = "\x1edefault"
|
||||
_POOL_PREFIX: Final = f"{MEMBER_SEPARATOR}pool{MEMBER_SEPARATOR}"
|
||||
|
||||
_TOUCH_LUA: Final = (
|
||||
"redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', ARGV[1]) "
|
||||
"redis.call('ZADD', KEYS[1], ARGV[2], ARGV[3]) "
|
||||
"redis.call('EXPIRE', KEYS[1], ARGV[4]) "
|
||||
"return redis.call('ZRANGE', KEYS[1], 0, -1)"
|
||||
)
|
||||
_MEMBERS: Final = TypeAdapter[tuple[str | bytes, ...]](tuple[str | bytes, ...])
|
||||
|
||||
|
||||
def pool_member(priority: str) -> str:
|
||||
"""The registry id that stands for every key without a team in ``priority``. It keeps the class present without
|
||||
being a team, and starts with the separator so no team id can spell it."""
|
||||
return f"{_POOL_PREFIX}{priority}"
|
||||
|
||||
|
||||
def team_member(team_id: str) -> str:
|
||||
"""The registry id of a real team. It never starts with the separator, so it cannot spell a pool marker."""
|
||||
return f"team{MEMBER_SEPARATOR}{team_id}"
|
||||
|
||||
|
||||
def is_pool_member(member: str) -> bool:
|
||||
return member.startswith(_POOL_PREFIX)
|
||||
|
||||
|
||||
def counter_value(model: str, team_id: str) -> str:
|
||||
"""One counter per model and team. The length prefix keeps ``chat`` + ``prod:victim`` apart from
|
||||
``chat:prod`` + ``victim``."""
|
||||
return f"{len(model)}:{model}:{team_id}"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TeamShare:
|
||||
fraction: float
|
||||
active_teams_in_class: int
|
||||
active_classes: int
|
||||
|
||||
|
||||
def team_share(
|
||||
priority: str, members: Mapping[str, str], class_weights: Mapping[str, float], default_weight: float
|
||||
) -> TeamShare:
|
||||
"""``members`` maps every team active on the model, the caller included, to its priority class. Classes with no
|
||||
active member give their weight away, and each present class is split equally among its active teams. A pool
|
||||
member keeps its class present but takes no slice, since keys without a team are held to the class pool."""
|
||||
present: Final = frozenset(members.values())
|
||||
weights: Final = {cls: class_weights.get(cls, default_weight) for cls in present}
|
||||
total: Final = sum(weights.values())
|
||||
class_fraction: Final = weights[priority] / total if total > 0 else 1.0 / len(present)
|
||||
teams_in_class: Final = sum(1 for team, cls in members.items() if cls == priority and not is_pool_member(team))
|
||||
return TeamShare(
|
||||
fraction=class_fraction / max(1, teams_in_class),
|
||||
active_teams_in_class=teams_in_class,
|
||||
active_classes=len(present),
|
||||
)
|
||||
|
||||
|
||||
def _members(raw: tuple[str | bytes, ...]) -> Mapping[str, str]:
|
||||
"""Oldest first, so a team seen under two classes in one window keeps its most recent one. The class is split off
|
||||
from the right because a team id may itself contain the separator."""
|
||||
decoded: Final = (item.decode() if isinstance(item, bytes) else item for item in raw)
|
||||
pairs: Final = (member.rsplit(MEMBER_SEPARATOR, 1) for member in decoded)
|
||||
return MappingProxyType({pair[0]: pair[1] for pair in pairs if len(pair) == 2})
|
||||
|
||||
|
||||
class ActiveTeamRegistry:
|
||||
"""Which teams used a model in the current window, as one sorted set per model scored by last-seen time, in Redis
|
||||
when the proxy has one and otherwise in process memory."""
|
||||
|
||||
def __init__(self, dual_cache: DualCache, time_provider: Callable[[], float] = time.time) -> None:
|
||||
self._dual_cache = dual_cache
|
||||
self._local: Mapping[str, Mapping[str, float]] = MappingProxyType({})
|
||||
self._now = time_provider
|
||||
|
||||
@with_service_target(RATE_LIMITS_TARGET)
|
||||
async def touch(self, model: str, team_id: str, priority: str, window: int) -> Mapping[str, str]:
|
||||
"""Record ``team_id`` as active on ``model`` and return every team active on it, the caller included. The
|
||||
caller is always listed under ``priority``, whatever class another worker's clock last saw it in."""
|
||||
key: Final = f"{ACTIVE_TEAMS_KEY_PREFIX}{model}"
|
||||
member: Final = f"{team_id}{MEMBER_SEPARATOR}{priority}"
|
||||
now: Final = self._now()
|
||||
redis_cache: Final = self._dual_cache.redis_cache
|
||||
seen: Final = (
|
||||
self._touch_in_memory(key, member, now, window)
|
||||
if redis_cache is None
|
||||
else _members(
|
||||
_MEMBERS.validate_python(
|
||||
await redis_cache.async_register_script(_TOUCH_LUA)(
|
||||
keys=(key,), args=(now - window, now, member, window)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
return MappingProxyType({**seen, team_id: priority})
|
||||
|
||||
def _touch_in_memory(self, key: str, member: str, now: float, window: int) -> Mapping[str, str]:
|
||||
"""Prunes by each member's own last-seen time, so the map lives exactly as long as its newest member."""
|
||||
cutoff: Final = now - window
|
||||
alive: Final = {seen_member: seen for seen_member, seen in self._local.get(key, {}).items() if seen > cutoff}
|
||||
current: Final = MappingProxyType({**alive, member: now})
|
||||
others: Final = {
|
||||
other_key: others_members
|
||||
for other_key, others_members in self._local.items()
|
||||
if other_key != key and any(seen > cutoff for seen in others_members.values())
|
||||
}
|
||||
self._local = MappingProxyType({**others, key: current})
|
||||
return _members(tuple(sorted(current, key=current.__getitem__)))
|
||||
|
|
@ -7080,7 +7080,7 @@ class ProxyConfig:
|
|||
elif key == "priority_reservation_settings":
|
||||
from litellm.types.utils import PriorityReservationSettings
|
||||
|
||||
litellm.priority_reservation_settings = PriorityReservationSettings(**value)
|
||||
litellm.priority_reservation_settings = PriorityReservationSettings.model_validate(value)
|
||||
elif key == "callbacks":
|
||||
initialize_callbacks_on_proxy(
|
||||
value=value,
|
||||
|
|
|
|||
|
|
@ -5393,6 +5393,10 @@ class PriorityReservationSettings(LiteLLMBaseModel):
|
|||
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.",
|
||||
)
|
||||
priority_class_share_splits_equally_among_active_teams: bool = Field(
|
||||
default=False,
|
||||
description="When true, a priority class's share of a model is renormalized over the classes whose teams used the model in the current window and split equally among that class's active teams, and once the model saturates each team is held to its own share instead of the class pool. Keys without a team keep the class pool.",
|
||||
)
|
||||
|
||||
saturation_check_cache_ttl: int = Field(
|
||||
default=60,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,136 @@
|
|||
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, Scenario, 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
|
||||
TOKENS_PER_CALL: Final = 30
|
||||
UPSTREAM_REPLY: Final = json.dumps(
|
||||
{
|
||||
"id": "chatcmpl_priority_class_share",
|
||||
"object": "chat.completion",
|
||||
"created": 1700000000,
|
||||
"model": OPENAI_MODEL,
|
||||
"choices": [{"index": 0, "message": {"role": "assistant", "content": "shared"}, "finish_reason": "stop"}],
|
||||
"usage": {"prompt_tokens": 20, "completion_tokens": 10, "total_tokens": TOKENS_PER_CALL},
|
||||
}
|
||||
).encode()
|
||||
|
||||
|
||||
def _respond(request: Request) -> Reply:
|
||||
assert request.method == "POST" and request.target == "/v1/chat/completions", request.target
|
||||
return Reply(body=UPSTREAM_REPLY)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def saturated_model(tmp_path_factory: pytest.TempPathFactory) -> Iterator[tuple[Gateway, str]]:
|
||||
directory: Final = tmp_path_factory.mktemp("priority-class-share")
|
||||
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": 0.0,
|
||||
"priority_class_share_splits_equally_among_active_teams": True,
|
||||
},
|
||||
}
|
||||
path: Final = directory / "priority_class_share.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
|
||||
|
||||
|
||||
def _team_key(scenario: Scenario, priority: str) -> str:
|
||||
return scenario.key(team_id=scenario.team(metadata={"priority": priority}))
|
||||
|
||||
|
||||
def _call(owned: Gateway, model: str, key: str) -> httpx.Response:
|
||||
return owned.request(
|
||||
"POST",
|
||||
"/v1/chat/completions",
|
||||
{"model": model, "messages": [{"role": "user", "content": f"class share {uuid.uuid4().hex}"}]},
|
||||
key=key,
|
||||
)
|
||||
|
||||
|
||||
def _spend_until_blocked(owned: Gateway, model: str, key: str, max_calls: int) -> tuple[httpx.Response, ...]:
|
||||
def calls() -> Iterator[httpx.Response]:
|
||||
for _ in range(max_calls):
|
||||
response: Final = _call(owned, model, key)
|
||||
yield response
|
||||
if response.status_code == 429:
|
||||
return
|
||||
|
||||
return tuple(calls())
|
||||
|
||||
|
||||
def test_second_interactive_team_keeps_its_half_of_the_class_share(saturated_model: tuple[Gateway, str]) -> None:
|
||||
"""Two interactive teams, batch idle: the class share is the whole model and each team is held to half of it."""
|
||||
owned, upstream = saturated_model
|
||||
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
|
||||
)
|
||||
first: Final = _team_key(scenario, "interactive")
|
||||
second: Final = _team_key(scenario, "interactive")
|
||||
assert _call(owned, model, second).status_code == 200
|
||||
|
||||
responses: Final = _spend_until_blocked(owned, model, first, max_calls=MODEL_TPM // TOKENS_PER_CALL + 1)
|
||||
blocked: Final = responses[-1]
|
||||
assert blocked.status_code == 429, blocked.text
|
||||
assert blocked.headers.get("rate_limited_by") == "priority_class_share_saturated", dict(blocked.headers)
|
||||
served: Final = (len(responses) - 1) * TOKENS_PER_CALL
|
||||
assert MODEL_TPM // 2 <= served < MODEL_TPM // 2 + 2 * TOKENS_PER_CALL, served
|
||||
|
||||
still_served: Final = _call(owned, model, second)
|
||||
assert still_served.status_code == 200, still_served.text
|
||||
|
||||
|
||||
def test_only_active_class_gets_the_whole_model(saturated_model: tuple[Gateway, str]) -> None:
|
||||
"""One batch team alone on the model is held to 100% of the model, not to batch's 40%."""
|
||||
owned, upstream = saturated_model
|
||||
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
|
||||
)
|
||||
batch: Final = _team_key(scenario, "batch")
|
||||
responses: Final = _spend_until_blocked(owned, model, batch, max_calls=MODEL_TPM // TOKENS_PER_CALL + 1)
|
||||
served: Final = sum(1 for response in responses if response.status_code == 200) * TOKENS_PER_CALL
|
||||
assert served >= MODEL_TPM - 2 * TOKENS_PER_CALL, [r.text for r in responses]
|
||||
|
||||
|
||||
def test_class_weights_renormalize_when_batch_wakes_up(saturated_model: tuple[Gateway, str]) -> None:
|
||||
"""Interactive and batch both active: interactive is held to 60% of the model and batch to 40%."""
|
||||
owned, upstream = saturated_model
|
||||
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
|
||||
)
|
||||
interactive: Final = _team_key(scenario, "interactive")
|
||||
batch: Final = _team_key(scenario, "batch")
|
||||
assert _call(owned, model, batch).status_code == 200
|
||||
|
||||
responses: Final = _spend_until_blocked(owned, model, interactive, max_calls=MODEL_TPM // TOKENS_PER_CALL + 1)
|
||||
assert responses[-1].status_code == 429, responses[-1].text
|
||||
served: Final = (len(responses) - 1) * TOKENS_PER_CALL
|
||||
assert int(MODEL_TPM * 0.6) <= served < int(MODEL_TPM * 0.6) + 2 * TOKENS_PER_CALL, served
|
||||
|
||||
batch_responses: Final = _spend_until_blocked(owned, model, batch, max_calls=MODEL_TPM // TOKENS_PER_CALL + 1)
|
||||
batch_served: Final = sum(1 for response in batch_responses if response.status_code == 200) * TOKENS_PER_CALL
|
||||
assert batch_served >= int(MODEL_TPM * 0.4) - 2 * TOKENS_PER_CALL, [r.text for r in batch_responses]
|
||||
assert batch_responses[-1].status_code == 429, batch_responses[-1].text
|
||||
|
|
@ -22,6 +22,7 @@ from litellm.proxy.common_utils.proxy_rate_limit_error import ProxyRateLimitErro
|
|||
from litellm.proxy.hooks.dynamic_rate_limiter_v3 import (
|
||||
AlternateGrant,
|
||||
PROXY_DynamicRateLimitHandlerV3,
|
||||
_PROXY_DynamicRateLimitHandlerV3, # pyright: ignore[reportPrivateUsage] # the public name is a Final alias, not a type
|
||||
)
|
||||
from litellm.proxy.hooks.dynamic_rate_limiter_v3 import (
|
||||
PROXY_DynamicRateLimitHandlerV3 as DynamicRateLimitHandler,
|
||||
|
|
@ -29,6 +30,7 @@ from litellm.proxy.hooks.dynamic_rate_limiter_v3 import (
|
|||
from litellm.proxy.hooks.parallel_request_limiter_v3 import (
|
||||
PROXY_MaxParallelRequestsHandler_v3,
|
||||
)
|
||||
from litellm.proxy.hooks.priority_class_share import pool_member
|
||||
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
|
||||
|
|
@ -2114,7 +2116,9 @@ def _borrowing_handler(
|
|||
borrows: bool,
|
||||
saturation_threshold: float = 0.5,
|
||||
model_group_alias: dict[str, str] | None = None,
|
||||
) -> PROXY_DynamicRateLimitHandlerV3:
|
||||
splits: bool = False,
|
||||
rpm: int = 100,
|
||||
) -> _PROXY_DynamicRateLimitHandlerV3:
|
||||
from litellm.types.utils import PriorityReservationSettings
|
||||
|
||||
monkeypatch.setenv("LITELLM_LICENSE", "test-license-key")
|
||||
|
|
@ -2123,7 +2127,9 @@ def _borrowing_handler(
|
|||
litellm,
|
||||
"priority_reservation_settings",
|
||||
PriorityReservationSettings(
|
||||
saturation_threshold=saturation_threshold, team_model_limit_borrows_idle_capacity_from_other_teams=borrows
|
||||
saturation_threshold=saturation_threshold,
|
||||
team_model_limit_borrows_idle_capacity_from_other_teams=borrows,
|
||||
priority_class_share_splits_equally_among_active_teams=splits,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(litellm, "callbacks", ["dynamic_rate_limiter_v3"])
|
||||
|
|
@ -2131,8 +2137,20 @@ def _borrowing_handler(
|
|||
model_list=[
|
||||
{
|
||||
"model_name": "shared-model",
|
||||
"litellm_params": {"model": "gpt-3.5-turbo", "api_key": "k", "api_base": "b", "tpm": 1000, "rpm": rpm},
|
||||
},
|
||||
{
|
||||
"model_name": "other-model",
|
||||
"litellm_params": {"model": "gpt-3.5-turbo", "api_key": "k", "api_base": "b", "tpm": 1000, "rpm": 100},
|
||||
}
|
||||
},
|
||||
{
|
||||
"model_name": "chat",
|
||||
"litellm_params": {"model": "gpt-3.5-turbo", "api_key": "k", "api_base": "b", "tpm": 1000, "rpm": 100},
|
||||
},
|
||||
{
|
||||
"model_name": "chat:prod",
|
||||
"litellm_params": {"model": "gpt-3.5-turbo", "api_key": "k", "api_base": "b", "tpm": 1000, "rpm": 100},
|
||||
},
|
||||
],
|
||||
model_group_alias=model_group_alias or {},
|
||||
)
|
||||
|
|
@ -2142,12 +2160,14 @@ def _borrowing_handler(
|
|||
return handler
|
||||
|
||||
|
||||
async def _admit_then_settle(handler: PROXY_DynamicRateLimitHandlerV3, auth: UserAPIKeyAuth, tokens: int) -> None:
|
||||
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:
|
||||
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"}},
|
||||
|
|
@ -2177,12 +2197,16 @@ def _team_key(
|
|||
|
||||
|
||||
async def _check(
|
||||
handler: PROXY_DynamicRateLimitHandlerV3, auth: UserAPIKeyAuth, saturation: float, model: str = "shared-model"
|
||||
handler: _PROXY_DynamicRateLimitHandlerV3,
|
||||
auth: UserAPIKeyAuth,
|
||||
saturation: float,
|
||||
model: str = "shared-model",
|
||||
priority: str | None = None,
|
||||
) -> 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
|
||||
await handler._check_rate_limits( # pyright: ignore[reportPrivateUsage] # saturation is an input here, the public hook reads it from Redis
|
||||
model=model, model_group_info=group, user_api_key_dict=auth, priority=priority, saturation=saturation
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -2367,6 +2391,273 @@ async def test_a_websocket_turn_still_borrows_while_the_model_is_idle(monkeypatc
|
|||
await handler.enforce_token_limits_for_frame(user_api_key_dict=_team_key(), requested_model="shared-model")
|
||||
|
||||
|
||||
def _class_team(team_id: str, priority: str | None = "interactive") -> UserAPIKeyAuth:
|
||||
return UserAPIKeyAuth(api_key="sk-class", team_id=team_id, team_metadata={}, metadata={"priority": priority})
|
||||
|
||||
|
||||
async def _burn(
|
||||
handler: _PROXY_DynamicRateLimitHandlerV3,
|
||||
auth: UserAPIKeyAuth,
|
||||
priority: str | None,
|
||||
tokens: int,
|
||||
model: str = "shared-model",
|
||||
):
|
||||
await _check(handler, auth, saturation=0.0, priority=priority, model=model)
|
||||
await handler.async_log_success_event(
|
||||
kwargs={
|
||||
"litellm_params": {"metadata": {"model_group": model}},
|
||||
"standard_logging_object": {
|
||||
"metadata": {
|
||||
"user_api_key_team_id": auth.team_id,
|
||||
"user_api_key_auth_metadata": {"priority": priority},
|
||||
}
|
||||
},
|
||||
},
|
||||
response_obj=litellm.ModelResponse(
|
||||
usage=litellm.Usage(prompt_tokens=tokens, completion_tokens=0, total_tokens=tokens)
|
||||
),
|
||||
start_time=datetime.now(),
|
||||
end_time=datetime.now(),
|
||||
)
|
||||
|
||||
|
||||
def _held_to_class_share(exc: ProxyRateLimitError) -> None:
|
||||
assert exc.status_code == 429
|
||||
assert exc.headers is not None
|
||||
assert exc.headers["rate_limited_by"] == "priority_class_share_saturated"
|
||||
assert exc.headers["rate_limit_type"] == "tokens"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_two_interactive_teams_each_keep_half_of_the_interactive_share(monkeypatch: pytest.MonkeyPatch):
|
||||
handler = _borrowing_handler(monkeypatch, borrows=False, splits=True)
|
||||
await _burn(handler, _class_team("team-a"), "interactive", 20)
|
||||
await _burn(handler, _class_team("team-b"), "interactive", 300)
|
||||
await _burn(handler, _class_team("team-b"), "interactive", 200)
|
||||
with pytest.raises(ProxyRateLimitError) as exc:
|
||||
await _check(handler, _class_team("team-b"), saturation=0.52, priority="interactive")
|
||||
_held_to_class_share(exc.value)
|
||||
assert "Team: team-b, Priority: interactive, Active teams in class: 2, Active classes: 1, " in str(exc.value.detail)
|
||||
assert "Team share: 500, Remaining: 0, Model saturation: 52.0%" in str(exc.value.detail)
|
||||
await _check(handler, _class_team("team-a"), saturation=0.52, priority="interactive")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_only_active_class_gets_the_whole_model_instead_of_its_weight(monkeypatch: pytest.MonkeyPatch):
|
||||
handler = _borrowing_handler(monkeypatch, borrows=False, splits=True)
|
||||
await _burn(handler, _class_team("team-b", "batch"), "batch", 950)
|
||||
await _check(handler, _class_team("team-b", "batch"), saturation=0.95, priority="batch")
|
||||
|
||||
unsplit = _borrowing_handler(monkeypatch, borrows=False)
|
||||
await _burn(unsplit, _class_team("team-b", "batch"), "batch", 950)
|
||||
with pytest.raises(ProxyRateLimitError) as exc:
|
||||
await _check(unsplit, _class_team("team-b", "batch"), saturation=0.95, priority="batch")
|
||||
assert "Priority-based rate limit exceeded" in str(exc.value.detail)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_class_weights_apply_again_once_a_team_of_the_other_class_is_active(monkeypatch: pytest.MonkeyPatch):
|
||||
handler = _borrowing_handler(monkeypatch, borrows=False, splits=True)
|
||||
await _burn(handler, _class_team("team-b", "batch"), "batch", 10)
|
||||
await _burn(handler, _class_team("team-a"), "interactive", 600)
|
||||
with pytest.raises(ProxyRateLimitError) as exc:
|
||||
await _check(handler, _class_team("team-a"), saturation=0.61, priority="interactive")
|
||||
assert "Active teams in class: 1, Active classes: 2, Rate limit type: tokens, Team share: 600, " in str(
|
||||
exc.value.detail
|
||||
)
|
||||
await _check(handler, _class_team("team-b", "batch"), saturation=0.61, priority="batch")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_teams_without_a_priority_split_the_default_share_between_them(monkeypatch: pytest.MonkeyPatch):
|
||||
handler = _borrowing_handler(monkeypatch, borrows=False, splits=True)
|
||||
await _burn(handler, _class_team("team-x", None), None, 600)
|
||||
await _burn(handler, _class_team("team-y", None), None, 1)
|
||||
with pytest.raises(ProxyRateLimitError) as exc:
|
||||
await _check(handler, _class_team("team-x", None), saturation=0.6, priority=None)
|
||||
assert "Priority: default, Active teams in class: 2, Active classes: 1, " in str(exc.value.detail)
|
||||
assert "Team share: 500, " in str(exc.value.detail)
|
||||
await _check(handler, _class_team("team-y", None), saturation=0.6, priority=None)
|
||||
assert exc.value.headers is not None and exc.value.headers["x-litellm-priority"] == "default"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_key_without_a_team_keeps_the_shared_class_pool(monkeypatch: pytest.MonkeyPatch):
|
||||
handler = _borrowing_handler(monkeypatch, borrows=False, splits=True)
|
||||
await _burn(handler, _class_team("team-a"), "interactive", 100)
|
||||
no_team: Final = UserAPIKeyAuth(api_key="sk-plain", metadata={"priority": "interactive"})
|
||||
await _burn(handler, no_team, "interactive", 600)
|
||||
with pytest.raises(ProxyRateLimitError) as exc:
|
||||
await _check(handler, no_team, saturation=0.7, priority="interactive")
|
||||
assert exc.value.headers is not None and "rate_limited_by" not in exc.value.headers
|
||||
assert "Priority-based rate limit exceeded" in str(exc.value.detail)
|
||||
await _check(handler, _class_team("team-a"), saturation=0.7, priority="interactive")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_below_saturation_a_team_may_run_past_its_share(monkeypatch: pytest.MonkeyPatch):
|
||||
handler = _borrowing_handler(monkeypatch, borrows=False, splits=True)
|
||||
await _burn(handler, _class_team("team-a"), "interactive", 1)
|
||||
await _burn(handler, _class_team("team-b"), "interactive", 400)
|
||||
await _check(handler, _class_team("team-b"), saturation=0.49, priority="interactive")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_split_is_off_by_default_so_one_team_can_still_empty_the_class_pool(monkeypatch: pytest.MonkeyPatch):
|
||||
handler = _borrowing_handler(monkeypatch, borrows=False)
|
||||
await _burn(handler, _class_team("team-a"), "interactive", 600)
|
||||
with pytest.raises(ProxyRateLimitError) as exc:
|
||||
await _check(handler, _class_team("team-b"), saturation=0.6, priority="interactive")
|
||||
assert exc.value.headers is not None and "rate_limited_by" not in exc.value.headers
|
||||
assert "Priority-based rate limit exceeded" in str(exc.value.detail)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_team_model_limit_and_the_class_share_both_apply_when_both_are_on(monkeypatch: pytest.MonkeyPatch):
|
||||
handler = _borrowing_handler(monkeypatch, borrows=True, splits=True)
|
||||
team: Final = UserAPIKeyAuth(
|
||||
api_key="sk-both",
|
||||
team_id="team-a",
|
||||
team_metadata={"model_tpm_limit": {"shared-model": 100}},
|
||||
metadata={"priority": "interactive"},
|
||||
)
|
||||
await _burn(handler, team, "interactive", 150)
|
||||
with pytest.raises(ProxyRateLimitError) as exc:
|
||||
await _check(handler, team, saturation=0.5, priority="interactive")
|
||||
_held_to_team_limit(exc.value, "tokens", "model_tpm_limit")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_team_active_on_another_model_does_not_shrink_this_models_shares(monkeypatch: pytest.MonkeyPatch):
|
||||
handler = _borrowing_handler(monkeypatch, borrows=False, splits=True)
|
||||
await _check(handler, _class_team("team-b"), saturation=0.0, priority="interactive", model="other-model")
|
||||
await _burn(handler, _class_team("team-a"), "interactive", 700)
|
||||
await _check(handler, _class_team("team-a"), saturation=0.7, priority="interactive")
|
||||
|
||||
|
||||
class _UnavailableRegistry:
|
||||
async def touch(self, model: str, team_id: str, priority: str, window: int) -> Mapping[str, str]:
|
||||
from redis.exceptions import ConnectionError as RedisConnectionError
|
||||
|
||||
raise RedisConnectionError("redis is down")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_registry_failure_falls_back_to_the_class_pool_instead_of_skipping_the_limits(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
handler = _borrowing_handler(monkeypatch, borrows=False, splits=True)
|
||||
handler.active_teams = _UnavailableRegistry() # pyright: ignore[reportAttributeAccessIssue] # injected failing registry
|
||||
await _burn(handler, _class_team("team-a"), "interactive", 650)
|
||||
with pytest.raises(ProxyRateLimitError) as exc:
|
||||
await _check(handler, _class_team("team-a"), saturation=0.65, priority="interactive")
|
||||
assert exc.value.headers is not None
|
||||
assert "rate_limited_by" not in exc.value.headers
|
||||
assert "Priority-based rate limit exceeded" in str(exc.value.detail)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_one_team_using_keys_of_two_priorities_still_spends_one_share(monkeypatch: pytest.MonkeyPatch):
|
||||
handler = _borrowing_handler(monkeypatch, borrows=False, splits=True)
|
||||
await _burn(handler, _class_team("team-b"), "interactive", 20)
|
||||
await _burn(handler, _class_team("team-a", "interactive"), "interactive", 300)
|
||||
await _burn(handler, _class_team("team-a", "batch"), "batch", 300)
|
||||
with pytest.raises(ProxyRateLimitError) as exc:
|
||||
await _check(handler, _class_team("team-a", "batch"), saturation=0.62, priority="batch")
|
||||
_held_to_class_share(exc.value)
|
||||
assert "Team: team-a, Priority: batch, Active teams in class: 1, Active classes: 2, " in str(exc.value.detail)
|
||||
assert "Team share: 400, Remaining: 0," in str(exc.value.detail)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_share_smaller_than_one_request_still_lets_each_team_send_one(monkeypatch: pytest.MonkeyPatch):
|
||||
handler = _borrowing_handler(monkeypatch, borrows=False, splits=True, rpm=2)
|
||||
for team in ("team-a", "team-b", "team-c"):
|
||||
await handler.active_teams.touch("shared-model", team, "interactive", 60)
|
||||
await _check(handler, _class_team("team-d"), saturation=0.9, priority="interactive")
|
||||
with pytest.raises(ProxyRateLimitError) as exc:
|
||||
await _check(handler, _class_team("team-d"), saturation=0.9, priority="interactive")
|
||||
assert exc.value.headers is not None
|
||||
assert exc.value.headers["rate_limited_by"] == "priority_class_share_saturated"
|
||||
assert exc.value.headers["rate_limit_type"] == "requests"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_websocket_turn_is_held_to_the_class_share_once_the_model_saturates(monkeypatch: pytest.MonkeyPatch):
|
||||
handler = _borrowing_handler(monkeypatch, borrows=False, splits=True, saturation_threshold=0.1)
|
||||
await _burn(handler, _class_team("team-a"), "interactive", 20)
|
||||
await _burn(handler, _class_team("team-b"), "interactive", 600)
|
||||
with pytest.raises(ProxyRateLimitError) as exc:
|
||||
await handler.enforce_token_limits_for_frame(
|
||||
user_api_key_dict=_class_team("team-b"), requested_model="shared-model"
|
||||
)
|
||||
_held_to_class_share(exc.value)
|
||||
await handler.enforce_token_limits_for_frame(
|
||||
user_api_key_dict=_class_team("team-a"), requested_model="shared-model"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_websocket_turn_ignores_the_class_share_while_the_split_is_off(monkeypatch: pytest.MonkeyPatch):
|
||||
handler = _borrowing_handler(monkeypatch, borrows=False, saturation_threshold=0.1)
|
||||
await _burn(handler, _class_team("team-a"), "interactive", 20)
|
||||
await _burn(handler, _class_team("team-b"), "interactive", 600)
|
||||
await handler.enforce_token_limits_for_frame(
|
||||
user_api_key_dict=_class_team("team-b"), requested_model="shared-model"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_traffic_from_keys_without_a_team_keeps_its_class_present_in_the_split(monkeypatch: pytest.MonkeyPatch):
|
||||
handler = _borrowing_handler(monkeypatch, borrows=False, splits=True)
|
||||
no_team: Final = UserAPIKeyAuth(api_key="sk-plain", metadata={"priority": "interactive"})
|
||||
await _burn(handler, no_team, "interactive", 20)
|
||||
await _burn(handler, _class_team("team-c", "batch"), "batch", 450)
|
||||
with pytest.raises(ProxyRateLimitError) as exc:
|
||||
await _check(handler, _class_team("team-c", "batch"), saturation=0.6, priority="batch")
|
||||
_held_to_class_share(exc.value)
|
||||
assert "Team share: 400, Remaining: 0," in str(exc.value.detail)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_team_id_containing_the_registry_separator_is_still_held_to_its_share(monkeypatch: pytest.MonkeyPatch):
|
||||
handler = _borrowing_handler(monkeypatch, borrows=False, splits=True)
|
||||
await _burn(handler, _class_team("team-b"), "interactive", 20)
|
||||
await _burn(handler, _class_team("owned\x1fextra", "batch"), "batch", 450)
|
||||
with pytest.raises(ProxyRateLimitError) as exc:
|
||||
await _check(handler, _class_team("owned\x1fextra", "batch"), saturation=0.6, priority="batch")
|
||||
_held_to_class_share(exc.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_team_share_is_not_shrunk_by_teamless_traffic_in_its_class(monkeypatch: pytest.MonkeyPatch):
|
||||
handler = _borrowing_handler(monkeypatch, borrows=False, splits=True)
|
||||
no_team: Final = UserAPIKeyAuth(api_key="sk-plain", metadata={"priority": "interactive"})
|
||||
await _burn(handler, no_team, "interactive", 20)
|
||||
await _burn(handler, _class_team("team-a"), "interactive", 900)
|
||||
await _check(handler, _class_team("team-a"), saturation=0.92, priority="interactive")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_teamless_traffic_in_two_classes_keeps_both_classes_present(monkeypatch: pytest.MonkeyPatch):
|
||||
handler = _borrowing_handler(monkeypatch, borrows=False, splits=True)
|
||||
await _burn(handler, UserAPIKeyAuth(api_key="sk-i", metadata={"priority": "interactive"}), "interactive", 20)
|
||||
await _burn(handler, UserAPIKeyAuth(api_key="sk-b", metadata={"priority": "batch"}), "batch", 20)
|
||||
await _burn(handler, _class_team("team-c", "batch"), "batch", 450)
|
||||
with pytest.raises(ProxyRateLimitError) as exc:
|
||||
await _check(handler, _class_team("team-c", "batch"), saturation=0.6, priority="batch")
|
||||
_held_to_class_share(exc.value)
|
||||
assert "Team share: 400, Remaining: 0," in str(exc.value.detail)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_team_counters_do_not_collide_across_models_whose_names_share_a_colon(monkeypatch: pytest.MonkeyPatch):
|
||||
handler = _borrowing_handler(monkeypatch, borrows=False, splits=True)
|
||||
await _burn(handler, _class_team("prod:victim"), "interactive", 600, model="chat")
|
||||
await _burn(handler, _class_team("team-x"), "interactive", 20, model="chat:prod")
|
||||
await _check(handler, _class_team("victim"), saturation=0.6, priority="interactive", model="chat:prod")
|
||||
|
||||
|
||||
def _capped_team_key(multiple: object, limit: str = "model_tpm_limit", share: int = 100) -> UserAPIKeyAuth:
|
||||
return _team_key(team_limits={limit: {"shared-model": share}, "team_model_limit_max_borrow_multiple": multiple})
|
||||
|
||||
|
|
@ -2494,3 +2785,174 @@ async def test_a_websocket_turn_under_the_teams_max_borrow_multiple_still_borrow
|
|||
handler = _borrowing_handler(monkeypatch, borrows=True, saturation_threshold=0.5)
|
||||
await _admit_then_settle(handler, _capped_team_key(2), 190)
|
||||
await handler.enforce_token_limits_for_frame(user_api_key_dict=_capped_team_key(2), requested_model="shared-model")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_configured_class_named_default_is_not_merged_with_teams_that_have_no_priority(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
handler = _borrowing_handler(monkeypatch, borrows=False, splits=True)
|
||||
monkeypatch.setattr(litellm, "priority_reservation", {"default": 0.6, "batch": 0.4})
|
||||
await _burn(handler, _class_team("team-a", "default"), "default", 20)
|
||||
await _burn(handler, _class_team("team-n", None), None, 20)
|
||||
await _burn(handler, _class_team("team-a", "default"), "default", 700)
|
||||
with pytest.raises(ProxyRateLimitError) as exc:
|
||||
await _check(handler, _class_team("team-a", "default"), saturation=0.74, priority="default")
|
||||
assert "Priority: default, Active teams in class: 1, Active classes: 2, " in str(exc.value.detail)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tokens_from_a_nested_call_do_not_land_on_the_outer_requests_team_counter(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
handler = _borrowing_handler(monkeypatch, borrows=False, splits=True)
|
||||
await _burn(handler, _class_team("team-b"), "interactive", 20)
|
||||
await _check(handler, _class_team("team-a"), saturation=0.0, priority="interactive")
|
||||
from litellm.proxy.hooks.parallel_request_limiter_v3 import get_or_create_request_stash
|
||||
|
||||
stash: Final = get_or_create_request_stash()
|
||||
stash.owner_litellm_call_id = "outer-call"
|
||||
await handler.async_log_success_event(
|
||||
kwargs={
|
||||
"litellm_call_id": "judge-call",
|
||||
"litellm_params": {"metadata": {"model_group": "shared-model"}},
|
||||
"standard_logging_object": {
|
||||
"metadata": {
|
||||
"user_api_key_team_id": "team-a",
|
||||
"user_api_key_auth_metadata": {"priority": "interactive"},
|
||||
}
|
||||
},
|
||||
},
|
||||
response_obj=litellm.ModelResponse(
|
||||
usage=litellm.Usage(prompt_tokens=600, completion_tokens=0, total_tokens=600)
|
||||
),
|
||||
start_time=datetime.now(),
|
||||
end_time=datetime.now(),
|
||||
)
|
||||
await _check(handler, _class_team("team-a"), saturation=0.62, priority="interactive")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_team_id_spelled_like_a_pool_marker_still_counts_as_a_team(monkeypatch: pytest.MonkeyPatch):
|
||||
handler = _borrowing_handler(monkeypatch, borrows=False, splits=True)
|
||||
await _burn(handler, _class_team(pool_member("interactive")), "interactive", 20)
|
||||
await _burn(handler, _class_team("team-a"), "interactive", 600)
|
||||
with pytest.raises(ProxyRateLimitError) as exc:
|
||||
await _check(handler, _class_team("team-a"), saturation=0.62, priority="interactive")
|
||||
assert "Active teams in class: 2, Active classes: 1, " in str(exc.value.detail)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_requests_sent_while_the_share_was_not_enforced_all_count_once_the_share_grows(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
handler = _borrowing_handler(monkeypatch, borrows=False, splits=True)
|
||||
await _check(handler, _class_team("team-b"), saturation=0.0, priority="interactive")
|
||||
for _ in range(70):
|
||||
await _check(handler, _class_team("team-a"), saturation=0.0, priority="interactive")
|
||||
await _check(handler, _class_team("team-b", "batch"), saturation=0.0, priority="batch")
|
||||
with pytest.raises(ProxyRateLimitError) as exc:
|
||||
await _check(handler, _class_team("team-a"), saturation=0.6, priority="interactive")
|
||||
assert "Active teams in class: 1, Active classes: 2, " in str(exc.value.detail)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_websocket_turn_settles_on_the_team_share_once_the_registry_recovers(monkeypatch: pytest.MonkeyPatch):
|
||||
handler = _borrowing_handler(monkeypatch, borrows=False, splits=True, saturation_threshold=0.1)
|
||||
await _burn(handler, _class_team("team-b"), "interactive", 20)
|
||||
registry: Final = handler.active_teams
|
||||
handler.active_teams = _UnavailableRegistry() # pyright: ignore[reportAttributeAccessIssue] # registry down at connect
|
||||
await _check(handler, _class_team("team-a"), saturation=0.0, priority="interactive")
|
||||
handler.active_teams = registry
|
||||
await handler.enforce_token_limits_for_frame(
|
||||
user_api_key_dict=_class_team("team-a"), requested_model="shared-model"
|
||||
)
|
||||
await handler.async_log_success_event(
|
||||
kwargs={
|
||||
"call_type": "_aresponses_websocket",
|
||||
"litellm_params": {"metadata": {"model_group": "shared-model"}},
|
||||
"standard_logging_object": {
|
||||
"metadata": {
|
||||
"user_api_key_team_id": "team-a",
|
||||
"user_api_key_auth_metadata": {"priority": "interactive"},
|
||||
}
|
||||
},
|
||||
},
|
||||
response_obj=litellm.ModelResponse(
|
||||
usage=litellm.Usage(prompt_tokens=550, completion_tokens=0, total_tokens=550)
|
||||
),
|
||||
start_time=datetime.now(),
|
||||
end_time=datetime.now(),
|
||||
)
|
||||
with pytest.raises(ProxyRateLimitError) as exc:
|
||||
await handler.enforce_token_limits_for_frame(
|
||||
user_api_key_dict=_class_team("team-a"), requested_model="shared-model"
|
||||
)
|
||||
_held_to_class_share(exc.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_frame_checked_while_the_registry_is_down_does_not_erase_the_connections_team_share(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
handler = _borrowing_handler(monkeypatch, borrows=False, splits=True, saturation_threshold=0.1)
|
||||
await _burn(handler, _class_team("team-b"), "interactive", 20)
|
||||
await _check(handler, _class_team("team-a"), saturation=0.0, priority="interactive")
|
||||
registry: Final = handler.active_teams
|
||||
handler.active_teams = _UnavailableRegistry() # pyright: ignore[reportAttributeAccessIssue] # registry down mid-connection
|
||||
await handler.enforce_token_limits_for_frame(
|
||||
user_api_key_dict=_class_team("team-a"), requested_model="shared-model"
|
||||
)
|
||||
handler.active_teams = registry
|
||||
await handler.async_log_success_event(
|
||||
kwargs={
|
||||
"call_type": "_aresponses_websocket",
|
||||
"litellm_params": {"metadata": {"model_group": "shared-model"}},
|
||||
"standard_logging_object": {
|
||||
"metadata": {
|
||||
"user_api_key_team_id": "team-a",
|
||||
"user_api_key_auth_metadata": {"priority": "interactive"},
|
||||
}
|
||||
},
|
||||
},
|
||||
response_obj=litellm.ModelResponse(
|
||||
usage=litellm.Usage(prompt_tokens=550, completion_tokens=0, total_tokens=550)
|
||||
),
|
||||
start_time=datetime.now(),
|
||||
end_time=datetime.now(),
|
||||
)
|
||||
with pytest.raises(ProxyRateLimitError) as exc:
|
||||
await handler.enforce_token_limits_for_frame(
|
||||
user_api_key_dict=_class_team("team-a"), requested_model="shared-model"
|
||||
)
|
||||
_held_to_class_share(exc.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tokens_served_by_a_fallback_model_land_on_the_teams_counter_for_that_model(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
handler = _borrowing_handler(monkeypatch, borrows=False, splits=True)
|
||||
await _burn(handler, _class_team("team-b"), "interactive", 20, model="other-model")
|
||||
await _burn(handler, _class_team("team-a"), "interactive", 10, model="other-model")
|
||||
await _check(handler, _class_team("team-a"), saturation=0.0, priority="interactive")
|
||||
await handler.async_log_success_event(
|
||||
kwargs={
|
||||
"litellm_params": {"metadata": {"model_group": "other-model"}},
|
||||
"standard_logging_object": {
|
||||
"metadata": {
|
||||
"user_api_key_team_id": "team-a",
|
||||
"user_api_key_auth_metadata": {"priority": "interactive"},
|
||||
}
|
||||
},
|
||||
},
|
||||
response_obj=litellm.ModelResponse(
|
||||
usage=litellm.Usage(prompt_tokens=550, completion_tokens=0, total_tokens=550)
|
||||
),
|
||||
start_time=datetime.now(),
|
||||
end_time=datetime.now(),
|
||||
)
|
||||
await _check(handler, _class_team("team-a"), saturation=0.62, priority="interactive")
|
||||
with pytest.raises(ProxyRateLimitError) as exc:
|
||||
await _check(handler, _class_team("team-a"), saturation=0.62, priority="interactive", model="other-model")
|
||||
_held_to_class_share(exc.value)
|
||||
|
|
|
|||
157
tests/unit/proxy/hooks/test_priority_class_share.py
Normal file
157
tests/unit/proxy/hooks/test_priority_class_share.py
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
"""Share math and the active-team registry behind
|
||||
``priority_reservation_settings.priority_class_share_splits_equally_among_active_teams``."""
|
||||
|
||||
from typing import Final
|
||||
|
||||
import pytest
|
||||
|
||||
from litellm.caching.caching import DualCache
|
||||
from litellm.proxy.hooks.priority_class_share import ActiveTeamRegistry, pool_member, team_share
|
||||
|
||||
WEIGHTS: Final = {"interactive": 0.6, "batch": 0.4}
|
||||
|
||||
|
||||
def test_two_interactive_and_four_batch_teams_split_the_model_sixty_forty():
|
||||
members: Final = {
|
||||
"i1": "interactive",
|
||||
"i2": "interactive",
|
||||
"b1": "batch",
|
||||
"b2": "batch",
|
||||
"b3": "batch",
|
||||
"b4": "batch",
|
||||
}
|
||||
interactive: Final = team_share("interactive", members, WEIGHTS, default_weight=0.2)
|
||||
batch: Final = team_share("batch", members, WEIGHTS, default_weight=0.2)
|
||||
assert int(500_000_000 * interactive.fraction) == 150_000_000
|
||||
assert int(500_000_000 * batch.fraction) == 50_000_000
|
||||
assert (interactive.active_teams_in_class, interactive.active_classes) == (2, 2)
|
||||
|
||||
|
||||
def test_only_active_class_takes_the_whole_model():
|
||||
members: Final = {"b1": "batch", "b2": "batch", "b3": "batch", "b4": "batch"}
|
||||
assert int(500_000_000 * team_share("batch", members, WEIGHTS, default_weight=0.2).fraction) == 125_000_000
|
||||
|
||||
|
||||
def test_a_single_active_team_gets_everything():
|
||||
share: Final = team_share("interactive", {"i1": "interactive"}, WEIGHTS, default_weight=0.2)
|
||||
assert share.fraction == 1.0
|
||||
assert (share.active_teams_in_class, share.active_classes) == (1, 1)
|
||||
|
||||
|
||||
def test_a_class_without_a_configured_weight_uses_the_default_weight():
|
||||
members: Final = {"i1": "interactive", "d1": "default"}
|
||||
assert team_share("default", members, WEIGHTS, default_weight=0.2).fraction == pytest.approx(0.2 / 0.8)
|
||||
assert team_share("interactive", members, WEIGHTS, default_weight=0.2).fraction == pytest.approx(0.6 / 0.8)
|
||||
|
||||
|
||||
def test_active_team_fractions_always_sum_to_one():
|
||||
members: Final = {"i1": "interactive", "i2": "interactive", "b1": "batch", "d1": "default", "d2": "default"}
|
||||
total: Final = sum(team_share(cls, members, WEIGHTS, default_weight=0.2).fraction for cls in members.values())
|
||||
assert total == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_zero_weights_split_the_model_equally_between_classes():
|
||||
members: Final = {"a": "interactive", "b": "batch"}
|
||||
assert team_share("batch", members, {"interactive": 0.0, "batch": 0.0}, default_weight=0.0).fraction == 0.5
|
||||
|
||||
|
||||
class _Clock:
|
||||
def __init__(self) -> None:
|
||||
self.now = 1_000.0
|
||||
|
||||
def __call__(self) -> float:
|
||||
return self.now
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_registry_lists_every_team_seen_in_the_window_oldest_first():
|
||||
clock: Final = _Clock()
|
||||
registry: Final = ActiveTeamRegistry(DualCache(), time_provider=clock)
|
||||
assert dict(await registry.touch("m", "a", "interactive", window=60)) == {"a": "interactive"}
|
||||
clock.now += 10
|
||||
assert dict(await registry.touch("m", "b", "batch", window=60)) == {"a": "interactive", "b": "batch"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_registry_forgets_a_team_once_its_last_request_leaves_the_window():
|
||||
clock: Final = _Clock()
|
||||
registry: Final = ActiveTeamRegistry(DualCache(), time_provider=clock)
|
||||
await registry.touch("m", "a", "interactive", window=60)
|
||||
clock.now += 59
|
||||
assert "a" in await registry.touch("m", "b", "batch", window=60)
|
||||
clock.now += 1
|
||||
assert dict(await registry.touch("m", "b", "batch", window=60)) == {"b": "batch"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_registry_refreshes_a_teams_last_seen_time_on_every_request():
|
||||
clock: Final = _Clock()
|
||||
registry: Final = ActiveTeamRegistry(DualCache(), time_provider=clock)
|
||||
await registry.touch("m", "a", "interactive", window=60)
|
||||
clock.now += 40
|
||||
await registry.touch("m", "a", "interactive", window=60)
|
||||
clock.now += 40
|
||||
assert "a" in await registry.touch("m", "b", "batch", window=60)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_registry_keeps_models_apart():
|
||||
registry: Final = ActiveTeamRegistry(DualCache(), time_provider=_Clock())
|
||||
await registry.touch("m1", "a", "interactive", window=60)
|
||||
assert dict(await registry.touch("m2", "b", "batch", window=60)) == {"b": "batch"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_registry_keeps_a_teams_newest_class_when_it_changed_class_mid_window():
|
||||
clock: Final = _Clock()
|
||||
registry: Final = ActiveTeamRegistry(DualCache(), time_provider=clock)
|
||||
await registry.touch("m", "a", "interactive", window=60)
|
||||
clock.now += 1
|
||||
assert dict(await registry.touch("m", "a", "batch", window=60)) == {"a": "batch"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_registry_lists_the_caller_under_the_class_it_just_sent_with_even_when_another_worker_wrote_later():
|
||||
clock: Final = _Clock()
|
||||
registry: Final = ActiveTeamRegistry(DualCache(), time_provider=clock)
|
||||
clock.now += 5
|
||||
await registry.touch("m", "a", "batch", window=60)
|
||||
clock.now -= 5
|
||||
members: Final = await registry.touch("m", "a", "interactive", window=60)
|
||||
assert dict(members) == {"a": "interactive"}
|
||||
assert team_share("interactive", members, WEIGHTS, 1.0).fraction == 1.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_registry_keeps_a_team_id_that_contains_the_separator_whole():
|
||||
registry: Final = ActiveTeamRegistry(DualCache(), time_provider=_Clock())
|
||||
members: Final = await registry.touch("m", "owned\x1fextra", "batch", window=60)
|
||||
assert dict(members) == {"owned\x1fextra": "batch"}
|
||||
assert team_share("batch", members, {"interactive": 0.6, "batch": 0.4}, 1.0).fraction == 1.0
|
||||
|
||||
|
||||
def test_a_pool_member_keeps_its_class_present_without_taking_a_team_slice():
|
||||
members: Final = {pool_member("interactive"): "interactive", "a": "interactive", "c": "batch"}
|
||||
share: Final = team_share("interactive", members, {"interactive": 0.6, "batch": 0.4}, 1.0)
|
||||
assert share.fraction == 0.6
|
||||
assert share.active_teams_in_class == 1
|
||||
assert share.active_classes == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_registry_keeps_pool_markers_of_different_classes_apart():
|
||||
registry: Final = ActiveTeamRegistry(DualCache(), time_provider=_Clock())
|
||||
await registry.touch("m", pool_member("interactive"), "interactive", window=60)
|
||||
members: Final = await registry.touch("m", pool_member("batch"), "batch", window=60)
|
||||
assert set(members.values()) == {"interactive", "batch"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_registry_keeps_a_team_that_was_refreshed_after_the_first_window_opened():
|
||||
clock: Final = _Clock()
|
||||
registry: Final = ActiveTeamRegistry(DualCache(), time_provider=clock)
|
||||
await registry.touch("m", "a", "interactive", window=60)
|
||||
clock.now += 50
|
||||
await registry.touch("m", "a", "interactive", window=60)
|
||||
clock.now += 50
|
||||
assert dict(await registry.touch("m", "b", "batch", window=60)) == {"a": "interactive", "b": "batch"}
|
||||
Loading…
Add table
Reference in a new issue