feat(proxy): add per-model project batch enqueued token limits (#51608)

* feat(proxy): add per-model project batch enqueued token limits

Enqueued batch token limits only read key and team metadata, so a deployment
that gives each application its own project under a shared team cannot cap one
application's queued batch tokens for a given model. The team limit is shared
by every project in it, and JWT or custom-auth callers get a new key scope with
every token.

Read model_batch_enqueued_token_limit from project metadata, keyed by model like
model_tpm_limit. Each limited model group the batch runs is reserved against
only its own rows' tokens, rows that name no model count toward the routing
model, and the reservation records each scope's charge so the terminal-state
refund releases exactly that. Only proxy admins may set it, on project create
and update as well as on keys and teams, so a team admin cannot lift their own
project's cap.

* fix(proxy): charge the routing model and keep daily caps for project batch limits

Rows can name any model while the batch runs on the routing deployment, for
example LiteLLM-executed batches replace every row's model with the run model,
so charging only row models let a batch skip a capped deployment's per-model
limit. Charge the routing model the whole batch as well as each other limited
row model its own rows' tokens.

An enqueued-token reservation replaced every batch counter, so a project limit
also skipped the key, team and end-user daily token caps. Keep checking those
daily caps after reserving, and refund the reservation if one rejects the batch.

* fix(proxy): keep per-minute checks for batches a project allowance does not cover

A per-model project allowance replaced every per-minute batch check as soon as
any row's model had one. A batch routed to an unlimited model could add a
single tiny row naming a limited model and skip the routing model's RPM/TPM and
project ITPM/OTPM quotas, although every row runs on the routing model. Waive
those checks only when the reservation covers the whole batch, through a key or
team allowance or a project allowance on the routing model; otherwise reserve
the covered models and run the normal checks too.

* test(proxy): cover the project per-model batch cap through custom auth on a live proxy

* fix(proxy): keep key and team batch allowances authoritative and harden project limits

Key and team enqueued allowances again replace the other batch checks. A project
allowance on the routing model keeps only the daily caps. Team admins can only be
blocked from the project key, invalid project maps answer 400, the edit modal keeps
stored limits, aliases share their tightest limit, file-bound batches charge only
the routing model, deployment ids resolve to their model group, and reservation
records keep project scopes apart so older pods still refund key and team scopes.

* fix(proxy): charge row models for model-bound batch uploads

Only a managed-file upload rewrites rows to the deployment model. A model-bound
upload keeps each row's model, so its rows still count toward the project caps of
the models they name.

* fix(proxy): skip per-minute batch limits only when the project caps every model in the batch

A project cap on the routing model alone used to switch off the project's
per-minute input and output limits for the other models a batch file names.
The skip now needs a project cap on every one of those models.

-latest names are charged under the cap of the model they point to, the
limit map only takes JSON integers, and a /project/update that sends only
top-level fields keeps the stored metadata instead of replacing it.

* fix(proxy): lift per-minute limits off managed batches under a project cap and edit the cap in the Admin UI

* fix(ui): keep the project batch cap read-only for team admins and require a whole number above 0

* fix(ui): narrow project metadata directly when keeping the stored batch cap

* fix(proxy): charge dated snapshot rows and header or query routed batches to project caps

---------

Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
This commit is contained in:
kirti22jain 2026-11-15 01:06:53 +05:30 • committed by GitHub
parent 9fba44d300
commit 73e17cba74
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 1594 additions and 71 deletions

View file

@ -2349,10 +2349,12 @@ PTU_PRUNE_SKEW_GRACE_SECONDS: Final[int] = 300
# was never observed (e.g. proxy restart); expiry returns the tokens to the caller.
BATCH_ENQUEUED_TOKEN_TTL_SECONDS: Final[int] = 8 * 24 * 60 * 60
# Key/team metadata field that opts batches into enqueued-token limiting. Only proxy
# admins may write it: when present it replaces the standard RPM/TPM checks for
# batch submissions.
# Key/team metadata field that opts batches into enqueued-token limiting, and its
# per-model project counterpart. Only proxy admins may write them. A key or team limit
# replaces the standard batch rate-limit checks. A project limit on the model the batch
# routes to replaces the per-minute checks, and daily caps still apply.
BATCH_ENQUEUED_TOKEN_LIMIT_METADATA_KEY: Final = "batch_enqueued_token_limit"
MODEL_BATCH_ENQUEUED_TOKEN_LIMIT_METADATA_KEY: Final = "model_batch_enqueued_token_limit"
MAX_BATCH_FILE_RECORDS_KEY: Final = "max_batch_file_records"
MAX_BATCH_FILE_UPLOADS_PER_DAY_KEY: Final = "max_batch_file_uploads_per_day"
MAX_FILE_DOWNLOADS_PER_MINUTE_KEY: Final = "max_file_downloads_per_minute"

View file

@ -23,10 +23,13 @@ from pydantic import TypeAdapter, ValidationError
from litellm._logging import verbose_proxy_logger
from litellm._uuid import uuid
from litellm.constants import MODEL_BATCH_ENQUEUED_TOKEN_LIMIT_METADATA_KEY
from litellm.proxy._types import *
from litellm.proxy.auth.auth_checks import delete_cached_project_object
from litellm.proxy.auth.auth_utils import BatchLimitRequest, enforce_batch_limits_are_admin_only
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.guardrails.guardrail_helpers import validate_guardrail_names
from litellm.proxy.hooks.batch_enqueued_tokens import model_batch_enqueued_token_limits_are_valid
from litellm.proxy.management.teams.authz import TEAM_OR_ORG_ADMIN, TeamAccess, is_team_admin, is_team_member
from litellm.proxy.management.teams.dependencies import get_team_access
from litellm.proxy.management.users.service import org_admin_org_ids
@ -118,6 +121,32 @@ def _jsonified(prisma_client: PrismaClient, payload: dict[str, object]) -> dict[
return jsonified
def _check_project_batch_limits(
data: BatchLimitRequest,
existing_metadata: Mapping[str, object] | None,
user_api_key_dict: UserAPIKeyAuth,
) -> None:
enforce_batch_limits_are_admin_only(
data=data,
existing_metadata=existing_metadata,
user_api_key_dict=user_api_key_dict,
entity="project",
)
if not model_batch_enqueued_token_limits_are_valid(data.metadata):
raise HTTPException(
status_code=400,
detail={
"error": f"{MODEL_BATCH_ENQUEUED_TOKEN_LIMIT_METADATA_KEY} must map each model name to a positive integer"
},
)
def _stored_metadata_with(
request: BatchLimitRequest, stored_metadata: Mapping[str, object]
) -> dict[str, object] | None:
return None if request.metadata is None else {**stored_metadata, **request.metadata}
async def _check_user_permission_for_project(
user_api_key_dict: UserAPIKeyAuth,
team_id: str | None,
@ -606,6 +635,11 @@ async def new_project(
"error": f"Only admins or team admins can create projects. Your role is {user_api_key_dict.user_role}"
},
)
_check_project_batch_limits(
data=data,
existing_metadata=None,
user_api_key_dict=user_api_key_dict,
)
await validate_guardrail_names(
None,
data.metadata, # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] # request models declare `metadata` as bare dict
@ -845,6 +879,18 @@ async def update_project(
status_code=403,
detail={"error": "Cannot reassign project to a team you are not an admin of"},
)
stored_metadata: Final[dict[str, object]] = (
existing_project.metadata if isinstance(existing_project.metadata, dict) else {}
)
merged_metadata: Final = None if data.sent_metadata else _stored_metadata_with(data, stored_metadata)
merged_request: Final = (
data if merged_metadata is None else data.model_copy(update={"metadata": merged_metadata})
)
_check_project_batch_limits(
data=merged_request,
existing_metadata=stored_metadata,
user_api_key_dict=user_api_key_dict,
)
await validate_guardrail_names(
None,
data.metadata, # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] # request models declare `metadata` as bare dict
@ -863,7 +909,7 @@ async def update_project(
quota_policy: Final = _project_model_quota_policy(general_settings_view())
if quota_policy is not None:
_raise_on_missing_project_model_quota_on_update(
data,
merged_request,
existing_project,
_router_access_group_names(llm_router),
quota_policy,
@ -872,7 +918,7 @@ async def update_project(
# Prepare update data
await validate_scalar_budget_attachment(prisma_client, data.budget_id)
update_data = _jsonified(prisma_client, data.model_dump(exclude_none=True, exclude={"project_id"}))
update_data = _jsonified(prisma_client, merged_request.model_dump(exclude_none=True, exclude={"project_id"}))
update_data["updated_by"] = user_api_key_dict.user_id or litellm_proxy_admin_name
# Handle budget updates

View file

@ -15,8 +15,10 @@ from pydantic import (
Field,
Json,
JsonValue,
ModelWrapValidatorHandler,
NonNegativeInt,
PositiveInt,
PrivateAttr,
TypeAdapter,
ValidationError,
ValidatorFunctionWrapHandler,
@ -28,6 +30,7 @@ from typing_extensions import (
NotRequired,
ReadOnly,
Required,
Self,
TypedDict,
)
@ -3518,6 +3521,7 @@ class UpdateProjectRequest(LiteLLM_BudgetTable):
blocked: bool | None = None
budget_id: str | None = None
object_permission: LiteLLM_ObjectPermissionBase | None = None
_sent_metadata: bool = PrivateAttr(default=True)
@model_validator(mode="before")
@classmethod
@ -3533,6 +3537,18 @@ class UpdateProjectRequest(LiteLLM_BudgetTable):
values.pop(field)
return values
@model_validator(mode="wrap")
@classmethod
def remember_whether_metadata_was_sent(cls, values: object, handler: ModelWrapValidatorHandler[Self]) -> Self:
sent_metadata: Final = not isinstance(values, Mapping) or "metadata" in values
request: Final = handler(values)
request._sent_metadata = sent_metadata
return request
@property
def sent_metadata(self) -> bool:
return self._sent_metadata
class DeleteProjectRequest(LiteLLMPydanticObjectBase):
"""Request model for DELETE /project/delete"""

View file

@ -21,6 +21,7 @@ from litellm.constants import (
EMPTY_MAPPING,
INVALID_VIRTUAL_KEY_ERROR_MARKER,
MINIMUM_CUSTOM_KEY_LENGTH,
MODEL_BATCH_ENQUEUED_TOKEN_LIMIT_METADATA_KEY,
STANDARD_CUSTOMER_ID_HEADERS,
)
from litellm.litellm_core_utils.clientside_credential_handler import (
@ -1928,15 +1929,16 @@ def enforce_batch_limits_are_admin_only(
data: BatchLimitRequest,
existing_metadata: Mapping[str, object] | None,
user_api_key_dict: UserAPIKeyAuth,
entity: Literal["key", "team"],
entity: Literal["key", "team", "project"],
) -> None:
"""Only a proxy admin may change a key or team's batch limits.
"""Only a proxy admin may change a key, team, or project's batch limits.
Every key in ``ADMIN_ONLY_BATCH_LIMIT_METADATA_KEYS`` caps what the holder
may do with batches, so a holder-writable copy would let a caller lift their
own quota, and the S3 bucket list grants access to buckets with the proxy's
own credentials. Gated on the resulting value rather than on presence, so a form
resending the stored value stays a no-op.
own credentials. A project only enforces ``model_batch_enqueued_token_limit``,
so that is the one key gated there. Gated on the resulting value rather than
on presence, so a form resending the stored value stays a no-op.
"""
if user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value:
return
@ -1944,8 +1946,11 @@ def enforce_batch_limits_are_admin_only(
requested: Final[Mapping[str, object]] = (
(data.metadata or EMPTY_MAPPING) if "metadata" in data.model_fields_set else stored
)
admin_only_keys: Final[tuple[str, ...]] = (
(MODEL_BATCH_ENQUEUED_TOKEN_LIMIT_METADATA_KEY,) if entity == "project" else _ADMIN_ONLY_METADATA_KEYS
)
changed: Final = next(
(key for key in _ADMIN_ONLY_METADATA_KEYS if requested.get(key) != stored.get(key)),
(key for key in admin_only_keys if requested.get(key) != stored.get(key)),
None,
)
if changed is None:

View file

@ -1,7 +1,8 @@
"""
Enqueued-token accounting for batch submissions.
Opt-in via admin-set ``batch_enqueued_token_limit`` in key or team metadata: batch
Opt-in via admin-set ``batch_enqueued_token_limit`` in key or team metadata, or a
per-model ``model_batch_enqueued_token_limit`` in project metadata: batch
submissions reserve their estimated token count against a long-lived
enqueued-token allowance instead of the per-minute rate-limit windows, and
the reservation is refunded when the batch reaches a terminal state
@ -9,20 +10,28 @@ the reservation is refunded when the batch reaches a terminal state
"""
import asyncio
import itertools
import logging
import math
import re
import time
import uuid
from collections.abc import Awaitable, Callable, Mapping, Sequence
from collections.abc import Awaitable, Callable, Iterable, Iterator, Mapping, Sequence
from dataclasses import dataclass, field
from types import MappingProxyType
from typing import TYPE_CHECKING, Annotated, Final, Literal, Protocol, TypeAlias
from pydantic import ConfigDict, Field, TypeAdapter, ValidationError
from pydantic import ConfigDict, Field, StrictInt, TypeAdapter, ValidationError
from litellm._internal_context import with_service_target
from litellm._logging import verbose_proxy_logger
from litellm.caching.redis_cache import log_redis_failure
from litellm.constants import BATCH_ENQUEUED_TOKEN_LIMIT_METADATA_KEY, BATCH_ENQUEUED_TOKEN_TTL_SECONDS
from litellm.constants import (
BATCH_ENQUEUED_TOKEN_LIMIT_METADATA_KEY,
BATCH_ENQUEUED_TOKEN_TTL_SECONDS,
MODEL_BATCH_ENQUEUED_TOKEN_LIMIT_METADATA_KEY,
)
from litellm.litellm_core_utils.model_latest_aliases import expand_model_latest_alias
from litellm.proxy._types import UserAPIKeyAuth
from litellm.redis.stores.rate_limits import RATE_LIMITS_TARGET
from litellm.types.llms.base import LiteLLMBaseModel
@ -39,7 +48,7 @@ BATCH_ENQUEUED_REFUND_STATUSES: Final[frozenset[str]] = frozenset(
{"completed", "complete", "failed", "expired", "cancelled", "cancelling"}
)
ScopeKey: TypeAlias = Literal["api_key", "team"]
ScopeKey: TypeAlias = Literal["api_key", "team", "project_model"]
RESERVE_ENQUEUED_TOKENS_SCRIPT: Final = """
local amount = tonumber(ARGV[1])
@ -81,6 +90,10 @@ class BatchEnqueuedTokenScope:
key: ScopeKey
value: str
limit: int
tokens: int | None = None
def charge(self, batch_tokens: int) -> int:
return batch_tokens if self.tokens is None else self.tokens
ReservationBackend: TypeAlias = Literal["redis", "memory"]
@ -103,11 +116,42 @@ class BatchEnqueuedTokenOverLimit:
BatchEnqueuedTokenOutcome: TypeAlias = BatchEnqueuedTokenReservation | BatchEnqueuedTokenOverLimit
@dataclass(frozen=True, slots=True)
class _StoredReservation:
"""A reservation record as stored, with project scopes apart so pods that predate them still refund the rest."""
tokens: int
scopes: tuple[BatchEnqueuedTokenScope, ...]
backend: ReservationBackend = "redis"
owner: str = ""
project_scopes: tuple[BatchEnqueuedTokenScope, ...] = ()
@classmethod
def of(cls, reservation: BatchEnqueuedTokenReservation) -> "_StoredReservation":
return cls(
tokens=reservation.tokens,
scopes=tuple(scope for scope in reservation.scopes if scope.key != "project_model"),
backend=reservation.backend,
owner=reservation.owner,
project_scopes=tuple(scope for scope in reservation.scopes if scope.key == "project_model"),
)
def restored(self) -> BatchEnqueuedTokenReservation:
return BatchEnqueuedTokenReservation(
tokens=self.tokens, scopes=(*self.scopes, *self.project_scopes), backend=self.backend, owner=self.owner
)
_LIMIT_ADAPTER: Final = TypeAdapter(Annotated[int, Field(gt=0)])
_MODEL_LIMITS_ADAPTER: Final[TypeAdapter[Mapping[str, int]]] = TypeAdapter(
Mapping[str, Annotated[StrictInt, Field(gt=0)]]
)
_NO_MODEL_LIMITS: Final[Mapping[str, int]] = MappingProxyType({})
_RESERVE_RESULT_ADAPTER: Final = TypeAdapter(tuple[int, int])
_POPPED_VALUE_ADAPTER: Final = TypeAdapter(str | bytes | None)
_STORED_COUNTER_ADAPTER: Final = TypeAdapter(int | None)
_RESERVATION_ADAPTER: Final = TypeAdapter(BatchEnqueuedTokenReservation)
_RESERVATION_ADAPTER: Final = TypeAdapter(_StoredReservation)
_LOCAL_RECORD_ADAPTER: Final = TypeAdapter(object)
@ -148,6 +192,112 @@ def resolve_batch_enqueued_token_scopes(
return tuple(scope for scope in candidates if scope is not None)
def _read_project_model_limits(user_api_key_dict: UserAPIKeyAuth) -> Mapping[str, int]:
metadata: Final[Mapping[str, object]] = user_api_key_dict.project_metadata or _NO_MODEL_LIMITS
raw: Final = metadata.get(MODEL_BATCH_ENQUEUED_TOKEN_LIMIT_METADATA_KEY)
if raw is None or not user_api_key_dict.project_id:
return _NO_MODEL_LIMITS
try:
return MappingProxyType(_MODEL_LIMITS_ADAPTER.validate_python(raw))
except ValidationError:
verbose_proxy_logger.warning(
"Ignoring invalid %s value %r; expected a mapping of model name to positive integer",
MODEL_BATCH_ENQUEUED_TOKEN_LIMIT_METADATA_KEY,
raw,
)
return _NO_MODEL_LIMITS
def model_batch_enqueued_token_limits_are_valid(metadata: Mapping[str, object] | None) -> bool:
raw: Final = (metadata or _NO_MODEL_LIMITS).get(MODEL_BATCH_ENQUEUED_TOKEN_LIMIT_METADATA_KEY)
if raw is None:
return True
try:
_MODEL_LIMITS_ADAPTER.validate_python(raw)
except ValidationError:
return False
return True
def project_has_model_batch_enqueued_token_limits(user_api_key_dict: UserAPIKeyAuth) -> bool:
return bool(_read_project_model_limits(user_api_key_dict))
_DATED_SNAPSHOT_SUFFIX: Final = re.compile(r"-\d{4}-\d{2}-\d{2}$")
def _tightest_limit_by_group(limits: Mapping[str, int], model_group_of: Callable[[str], str]) -> Mapping[str, int]:
resolved: Final = tuple((model_group_of(name), limit) for name, limit in limits.items())
return MappingProxyType({group: min(limit for g, limit in resolved if g == group) for group, _ in resolved})
def _model_groups(model: str, model_group_of: Callable[[str], str]) -> frozenset[str]:
names: Final = (*expand_model_latest_alias(model), _DATED_SNAPSHOT_SUFFIX.sub("", model))
return frozenset(model_group_of(candidate) for candidate in names)
def _limited_model_charges(
limits: Mapping[str, int],
tokens_by_model: Mapping[str, int],
model_group_of: Callable[[str], str],
) -> Iterator[tuple[str, int, int]]:
group_limits: Final = _tightest_limit_by_group(limits, model_group_of)
return itertools.chain.from_iterable(
(
(group, group_limits[group], tokens)
for group in _model_groups(model, model_group_of)
if group in group_limits
)
for model, tokens in tokens_by_model.items()
)
def project_limits_every_model(
user_api_key_dict: UserAPIKeyAuth,
models: Iterable[str],
model_group_of: Callable[[str], str],
) -> bool:
group_limits: Final = _tightest_limit_by_group(_read_project_model_limits(user_api_key_dict), model_group_of)
return all(not group_limits.keys().isdisjoint(_model_groups(model, model_group_of)) for model in models)
def resolve_project_model_batch_enqueued_token_scopes(
user_api_key_dict: UserAPIKeyAuth,
row_tokens_by_model: Mapping[str, int],
routing_model: str | None,
batch_tokens: int,
model_group_of: Callable[[str], str],
) -> tuple[BatchEnqueuedTokenScope, ...]:
"""One scope per limited model group a batch touches, under the tightest limit keyed by any of its names.
The routing model is charged the whole batch, because rows run on it whatever model they name, and
every other model a row names is charged that row's tokens, so neither can hide behind the other.
"""
limits: Final = _read_project_model_limits(user_api_key_dict)
row_charges: Final = tuple(_limited_model_charges(limits, row_tokens_by_model, model_group_of))
routing_charges: Final = tuple(
_limited_model_charges(
limits,
MappingProxyType({routing_model: batch_tokens}) if routing_model else _NO_MODEL_LIMITS,
model_group_of,
)
)
charges: Final = (*row_charges, *routing_charges)
groups: Final = sorted(frozenset(group for group, _, _ in charges))
return tuple(
BatchEnqueuedTokenScope(
key="project_model",
value=f"{user_api_key_dict.project_id}:{group}",
limit=min(limit for charge_group, limit, _ in charges if charge_group == group),
tokens=max(
sum(tokens for charge_group, _, tokens in row_charges if charge_group == group),
max((tokens for charge_group, _, tokens in routing_charges if charge_group == group), default=0),
),
)
for group in groups
)
def canonical_provider_batch_id(batch_id: str) -> str:
from litellm.proxy.openai_files_endpoints.common_utils import (
get_batch_id_from_unified_batch_id,
@ -282,7 +432,7 @@ class BatchEnqueuedTokenStore:
try:
raw_result: Final = await reserve_script(
(self._counter_key(scope),),
(tokens, BATCH_ENQUEUED_TOKEN_TTL_SECONDS, scope.limit),
(scope.charge(tokens), BATCH_ENQUEUED_TOKEN_TTL_SECONDS, scope.limit),
)
return _RESERVE_RESULT_ADAPTER.validate_python(raw_result)
except Exception:
@ -310,7 +460,7 @@ class BatchEnqueuedTokenStore:
scopes: tuple[BatchEnqueuedTokenScope, ...],
) -> None:
for scope in scopes:
await refund_script((self._counter_key(scope),), (tokens,))
await refund_script((self._counter_key(scope),), (scope.charge(tokens),))
async def _reserve_in_memory(
self,
@ -322,10 +472,10 @@ class BatchEnqueuedTokenStore:
async with self._lock:
currents: Final = tuple([await self._get_local_counter(scope, span) for scope in scopes])
for scope, current in zip(scopes, currents):
if current + tokens > scope.limit:
if current + scope.charge(tokens) > scope.limit:
return BatchEnqueuedTokenOverLimit(scope=scope, enqueued=current)
for scope, current in zip(scopes, currents):
await self._set_local_counter(scope, current + tokens, span)
await self._set_local_counter(scope, current + scope.charge(tokens), span)
return BatchEnqueuedTokenReservation(
tokens=tokens, scopes=scopes, backend="memory", owner=self._owner_token, reserved_at_monotonic=started
)
@ -349,7 +499,7 @@ class BatchEnqueuedTokenStore:
async with self._lock:
for scope in reservation.scopes:
current = await self._get_local_counter(scope, litellm_parent_otel_span)
remaining = current - reservation.tokens
remaining = current - scope.charge(reservation.tokens)
if remaining <= 0:
self.internal_usage_cache.dual_cache.in_memory_cache.delete_cache(key=self._counter_key(scope))
else:
@ -376,7 +526,7 @@ class BatchEnqueuedTokenStore:
reservation: BatchEnqueuedTokenReservation,
litellm_parent_otel_span: "Span | None" = None,
) -> None:
serialized: Final = _RESERVATION_ADAPTER.dump_json(reservation).decode("utf-8")
serialized: Final = _RESERVATION_ADAPTER.dump_json(_StoredReservation.of(reservation)).decode("utf-8")
elapsed: Final = self._monotonic() - reservation.reserved_at_monotonic
ttl: Final = max(1, BATCH_ENQUEUED_TOKEN_TTL_SECONDS - math.ceil(elapsed))
if self._save_script is not None:
@ -422,8 +572,8 @@ class BatchEnqueuedTokenStore:
return None
try:
if isinstance(raw, (str, bytes)):
return _RESERVATION_ADAPTER.validate_json(raw)
return _RESERVATION_ADAPTER.validate_python(raw)
return _RESERVATION_ADAPTER.validate_json(raw).restored()
return _RESERVATION_ADAPTER.validate_python(raw).restored()
except ValidationError:
verbose_proxy_logger.warning("Discarding malformed enqueued-token reservation record for %s", batch_id)
return None

View file

@ -18,7 +18,7 @@ Quick summary:
"""
import json
from collections.abc import Callable, Iterable, Mapping, Sequence
from collections.abc import Awaitable, Callable, Iterable, Mapping, Sequence
from datetime import datetime, timezone
from types import MappingProxyType
from typing import TYPE_CHECKING, Any, Final, Literal, NoReturn, TypeAlias
@ -56,7 +56,10 @@ from litellm.proxy.hooks.batch_enqueued_tokens import (
BatchEnqueuedTokenOverLimit,
BatchEnqueuedTokenReservation,
BatchEnqueuedTokenScope,
project_has_model_batch_enqueued_token_limits,
project_limits_every_model,
resolve_batch_enqueued_token_scopes,
resolve_project_model_batch_enqueued_token_scopes,
)
from litellm.proxy.hooks.parallel_request_limiter_v3 import (
PROJECT_ITPM_DESCRIPTOR_KEY,
@ -151,6 +154,19 @@ class BatchFileUsage(LiteLLMBaseModel):
per_model_usage: dict[str, dict[str, int]] = Field(default_factory=dict)
def _rows_were_rewritten_on_upload(input_file_id: str) -> bool:
from litellm.proxy.openai_files_endpoints.common_utils import is_base64_encoded_unified_file_id
return bool(is_base64_encoded_unified_file_id(input_file_id))
def _deployment_model_name_via_proxy_router(model: str) -> str | None:
from litellm.proxy.proxy_server import llm_router
deployment: Final = None if llm_router is None else llm_router.registry().deployment(model)
return None if deployment is None else deployment.model_name
class _PROXY_BatchRateLimiter(CustomLogger):
"""
Rate limiter for batch API requests.
@ -165,6 +181,7 @@ class _PROXY_BatchRateLimiter(CustomLogger):
internal_usage_cache: InternalUsageCache,
parallel_request_limiter: ParallelRequestLimiter,
time_provider: Callable[[], datetime] | None = None,
deployment_model_name: Callable[[str], str | None] = _deployment_model_name_via_proxy_router,
):
"""
Initialize the batch rate limiter.
@ -176,10 +193,12 @@ class _PROXY_BatchRateLimiter(CustomLogger):
internal_usage_cache: Cache for storing rate limit data (auto-injected)
parallel_request_limiter: Existing rate limiter to integrate with (needs custom injection)
time_provider: Clock used for rate limit reset times (defaults to ``datetime.now``)
deployment_model_name: Model name of the deployment a batch names by id, else None
"""
self.internal_usage_cache = internal_usage_cache
self.parallel_request_limiter = parallel_request_limiter
self._time_provider: Final = time_provider or datetime.now
self._deployment_model_name: Final = deployment_model_name
self._warned_unsupported_model_skip = False
def _get_file_bound_batch_model(self, data: dict) -> str | None:
@ -226,11 +245,7 @@ class _PROXY_BatchRateLimiter(CustomLogger):
if file_bound_model:
return file_bound_model
model: Final = data.get("model")
if isinstance(model, str) and model:
return model
return None
return _batch_create_routing_model(_BATCH_BODY_ADAPTER.validate_python(data))
def _resolve_batch_provider(self, batch_model: str | None) -> str | None:
"""Resolve the provider from the deployment that serves ``batch_model``.
@ -622,8 +637,10 @@ class _PROXY_BatchRateLimiter(CustomLogger):
) -> None:
"""Reserve the batch's estimated tokens against the caller's enqueued-token allowance.
Runs instead of the per-minute counter charge when the key or team
opted in via ``batch_enqueued_token_limit`` metadata. The reservation
Runs instead of the per-minute counter charge when the key or team opted
in via ``batch_enqueued_token_limit`` metadata, or the project limits the
batch's routing model via ``model_batch_enqueued_token_limit``; otherwise it
runs alongside the normal counters. The reservation
is stashed on the request so the v3 limiter's post-call hooks can
persist it (keyed by the provider batch id) and refund it when the
batch reaches a terminal state.
@ -639,6 +656,27 @@ class _PROXY_BatchRateLimiter(CustomLogger):
case BatchEnqueuedTokenReservation():
get_or_create_request_stash().batch_enqueued_reservation = outcome
async def _refund_enqueued_reservation_if_rejected(
self, user_api_key_dict: UserAPIKeyAuth, check: Awaitable[None]
) -> None:
"""Run a check that follows the enqueued-token reservation, refunding the reservation if it rejects the batch."""
try:
await check
except HTTPException:
stash: Final = get_or_create_request_stash()
if stash.batch_enqueued_reservation is not None:
await self.parallel_request_limiter.batch_enqueued_token_store.refund(
reservation=stash.batch_enqueued_reservation,
litellm_parent_otel_span=user_api_key_dict.parent_otel_span,
)
stash.batch_enqueued_reservation = None
raise
def _model_group_of(self, model: str) -> str:
named_model: Final = self._deployment_model_name(model) or model
rate_limited: Final = self.parallel_request_limiter.rate_limited_model(named_model)
return rate_limited.group if rate_limited is not None else named_model
def _raise_enqueued_limit_error(
self,
over_limit: BatchEnqueuedTokenOverLimit,
@ -649,7 +687,8 @@ class _PROXY_BatchRateLimiter(CustomLogger):
remaining: Final = max(0, scope.limit - over_limit.enqueued)
detail: Final = (
f"Batch enqueued token limit exceeded for {scope.key}: {scope.value}. "
f"Batch requires {batch_usage.total_tokens} tokens but only {remaining} enqueued tokens remaining "
f"Batch requires {scope.charge(batch_usage.total_tokens)} tokens "
f"but only {remaining} enqueued tokens remaining "
f"out of {scope.limit} enqueued token limit. "
f"Tokens free up as running batches complete or are cancelled."
)
@ -773,6 +812,7 @@ class _PROXY_BatchRateLimiter(CustomLogger):
data: dict,
batch_usage: BatchFileUsage,
descriptors: list["RateLimitDescriptor"] | None = None,
include_project_io_limits: bool = True,
) -> None:
"""
Atomically check + increment rate-limit counters by the batch amounts.
@ -802,9 +842,13 @@ class _PROXY_BatchRateLimiter(CustomLogger):
for _d in descriptors
]
project_io_descriptors, project_io_increments = self._create_project_io_descriptors_for_models(
user_api_key_dict=user_api_key_dict,
per_model_usage=batch_usage.per_model_usage,
project_io_descriptors, project_io_increments = (
self._create_project_io_descriptors_for_models(
user_api_key_dict=user_api_key_dict,
per_model_usage=batch_usage.per_model_usage,
)
if include_project_io_limits
else ([], [])
)
descriptors = [*descriptors, *project_io_descriptors]
increments = [*increments, *project_io_increments]
@ -1286,9 +1330,12 @@ class _PROXY_BatchRateLimiter(CustomLogger):
verbose_proxy_logger.debug("No input_file_id in batch request, skipping rate limiting")
return data
enqueued_scopes: Final = resolve_batch_enqueued_token_scopes(user_api_key_dict)
key_and_team_scopes: Final = resolve_batch_enqueued_token_scopes(user_api_key_dict)
should_skip, batch_rate_limit_descriptors = self._should_skip_batch_input_file_processing(
data=data, user_api_key_dict=user_api_key_dict, has_enqueued_scopes=bool(enqueued_scopes)
data=data,
user_api_key_dict=user_api_key_dict,
has_enqueued_scopes=bool(key_and_team_scopes)
or project_has_model_batch_enqueued_token_limits(user_api_key_dict),
)
if should_skip:
return data
@ -1314,6 +1361,22 @@ class _PROXY_BatchRateLimiter(CustomLogger):
data["_batch_token_count"] = batch_usage.total_tokens
data["_batch_request_count"] = batch_usage.request_count
routing_model: Final = self._get_batch_routing_model(data) # pyright: ignore[reportUnknownMemberType] # hook data is the untyped proxy request dict
row_tokens_by_model: Final[Mapping[str, int]] = MappingProxyType(
{model: usage.get("total_tokens", 0) for model, usage in batch_usage.per_model_usage.items()}
if not (isinstance(input_file_id, str) and _rows_were_rewritten_on_upload(input_file_id))
else {}
)
enqueued_scopes: Final = (
*key_and_team_scopes,
*resolve_project_model_batch_enqueued_token_scopes(
user_api_key_dict,
row_tokens_by_model=row_tokens_by_model,
routing_model=routing_model,
batch_tokens=batch_usage.total_tokens,
model_group_of=self._model_group_of,
),
)
if enqueued_scopes:
await self._reserve_batch_enqueued_tokens(
user_api_key_dict=user_api_key_dict,
@ -1321,6 +1384,32 @@ class _PROXY_BatchRateLimiter(CustomLogger):
batch_usage=batch_usage,
scopes=enqueued_scopes,
)
if not key_and_team_scopes:
project_covers_batch: Final = routing_model is not None and project_limits_every_model(
user_api_key_dict, (routing_model, *row_tokens_by_model), self._model_group_of
)
remaining_descriptors: Final = [
descriptor
for descriptor in (
batch_rate_limit_descriptors
if batch_rate_limit_descriptors is not None
else self._create_batch_rate_limit_descriptors( # pyright: ignore[reportUnknownMemberType] # hook data is the untyped proxy request dict
user_api_key_dict=user_api_key_dict, data=data
)
)
if not project_covers_batch or descriptor["key"].endswith(BATCH_TPD_DESCRIPTOR_SUFFIX)
]
if remaining_descriptors or not project_covers_batch:
await self._refund_enqueued_reservation_if_rejected(
user_api_key_dict,
self._check_and_increment_batch_counters( # pyright: ignore[reportUnknownMemberType] # hook data is the untyped proxy request dict
user_api_key_dict=user_api_key_dict,
data=data,
batch_usage=batch_usage,
descriptors=remaining_descriptors,
include_project_io_limits=not project_covers_batch,
),
)
verbose_proxy_logger.debug("Batch enqueued-token reservation succeeded")
return data

View file

@ -10,8 +10,9 @@ vi.mock("@/app/(dashboard)/hooks/projects/useUpdateProject", () => ({
useUpdateProject: () => ({ mutate, isPending: false }),
}));
const mockUseAuthorized = vi.fn();
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
default: () => ({ accessToken: "test-token", userId: "u-1", userRole: "Admin" }),
default: () => mockUseAuthorized(),
}));
vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({
@ -79,6 +80,7 @@ const variables = () => mutate.mock.calls.at(-1)?.[0] as { projectId: string; pa
describe("EditProjectModal submit payload", () => {
beforeEach(() => {
vi.clearAllMocks();
mockUseAuthorized.mockReturnValue({ accessToken: "test-token", userId: "u-1", userRole: "Admin" });
});
it("sends exactly the antd payload for an untouched save", async () => {
@ -172,6 +174,102 @@ describe("EditProjectModal submit payload", () => {
expect(variables().params.metadata).toStrictEqual({ owner: "platform" });
});
it("keeps the stored per-model batch enqueued limits when saving after opening Advanced Settings", async () => {
const user = setup();
renderModal({
...project,
metadata: { ...project.metadata, model_batch_enqueued_token_limit: { "gpt-4": 1000 } },
} as unknown as ProjectResponse);
await screen.findByDisplayValue("My Project");
await user.click(screen.getByText("Advanced Settings"));
await screen.findByText("Model-Specific Limits");
await save(user);
await waitFor(() => expect(mutate).toHaveBeenCalled());
expect(variables().params.metadata).toStrictEqual({
owner: "platform",
model_batch_enqueued_token_limit: { "gpt-4": 1000 },
});
});
it("sends the per-model batch enqueued limit edited in its model limit row", async () => {
const user = setup();
renderModal({
...project,
metadata: { ...project.metadata, model_batch_enqueued_token_limit: { "gpt-4": 1000 } },
} as unknown as ProjectResponse);
await screen.findByDisplayValue("My Project");
await user.click(screen.getByText("Advanced Settings"));
const batchLimit = await screen.findByLabelText("Batch Enqueued Token Limit");
expect(batchLimit).toHaveValue(1000);
fireEvent.change(batchLimit, { target: { value: "2500" } });
await save(user);
await waitFor(() => expect(mutate).toHaveBeenCalled());
expect(variables().params.metadata).toStrictEqual({
owner: "platform",
model_batch_enqueued_token_limit: { "gpt-4": 2500 },
});
});
it("drops the per-model batch enqueued limit once its model limit row is removed", async () => {
const user = setup();
renderModal({
...project,
metadata: { ...project.metadata, model_batch_enqueued_token_limit: { "gpt-4": 1000 } },
} as unknown as ProjectResponse);
await screen.findByDisplayValue("My Project");
await user.click(screen.getByText("Advanced Settings"));
await screen.findByText("Model-Specific Limits");
await user.click(screen.getByRole("button", { name: "Remove model limit 1" }));
await save(user);
await waitFor(() => expect(mutate).toHaveBeenCalled());
expect(variables().params.metadata).toStrictEqual({ owner: "platform" });
});
it("blocks the save when the per-model batch enqueued limit is not a whole number above 0", async () => {
const user = setup();
renderModal({
...project,
metadata: { ...project.metadata, model_batch_enqueued_token_limit: { "gpt-4": 1000 } },
} as unknown as ProjectResponse);
await screen.findByDisplayValue("My Project");
await user.click(screen.getByText("Advanced Settings"));
const batchLimit = await screen.findByLabelText("Batch Enqueued Token Limit");
fireEvent.change(batchLimit, { target: { value: "0" } });
await save(user);
expect(await screen.findByText("Enter a whole number above 0")).toBeInTheDocument();
expect(mutate).not.toHaveBeenCalled();
});
it("keeps the stored per-model batch enqueued limit when a team admin removes its model limit row", async () => {
mockUseAuthorized.mockReturnValue({ accessToken: "test-token", userId: "u-2", userRole: "Internal User" });
const user = setup();
renderModal({
...project,
metadata: { ...project.metadata, model_batch_enqueued_token_limit: { "gpt-4": 1000 } },
} as unknown as ProjectResponse);
await screen.findByDisplayValue("My Project");
await user.click(screen.getByText("Advanced Settings"));
expect(await screen.findByLabelText("Batch Enqueued Token Limit")).toBeDisabled();
await user.click(screen.getByRole("button", { name: "Remove model limit 1" }));
await save(user);
await waitFor(() => expect(mutate).toHaveBeenCalled());
expect(variables().params).toMatchObject({ model_tpm_limit: {}, model_rpm_limit: {} });
expect(variables().params.metadata).toStrictEqual({
owner: "platform",
model_batch_enqueued_token_limit: { "gpt-4": 1000 },
});
});
it("blocks the save when the project name is cleared", async () => {
const user = setup();
renderModal();

View file

@ -10,9 +10,11 @@ import { Button } from "@/components/ui/button";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import { ProjectResponse } from "@/app/(dashboard)/hooks/projects/useProjects";
import { useUpdateProject, ProjectUpdateParams } from "@/app/(dashboard)/hooks/projects/useUpdateProject";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { isProxyAdminRole } from "@/utils/roles";
import { ProjectBaseForm } from "./ProjectBaseForm";
import { projectFormSchema, type ProjectFormValues, type ProjectSubmitValues } from "./projectFormSchema";
import { buildProjectUpdateParams } from "./projectFormUtils";
import { buildProjectUpdateParams, withStoredBatchEnqueuedTokenLimit } from "./projectFormUtils";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
interface EditProjectModalProps {
@ -27,6 +29,7 @@ const INTERNAL_METADATA_KEYS = new Set([
"model_tpm_limit",
"model_itpm_limit",
"model_otpm_limit",
"model_batch_enqueued_token_limit",
"guardrails",
]);
@ -36,6 +39,7 @@ export const toFormValues = (project: ProjectResponse): ProjectFormValues => {
const tpmLimits = (metadataObj.model_tpm_limit ?? {}) as Record<string, number>;
const itpmLimits = (metadataObj.model_itpm_limit ?? {}) as Record<string, number>;
const otpmLimits = (metadataObj.model_otpm_limit ?? {}) as Record<string, number>;
const batchEnqueuedTokenLimits = (metadataObj.model_batch_enqueued_token_limit ?? {}) as Record<string, number>;
const guardrails = (Array.isArray(metadataObj.guardrails) ? metadataObj.guardrails : []) as string[];
const modelLimits = Array.from(
@ -44,6 +48,7 @@ export const toFormValues = (project: ProjectResponse): ProjectFormValues => {
...Object.keys(tpmLimits),
...Object.keys(itpmLimits),
...Object.keys(otpmLimits),
...Object.keys(batchEnqueuedTokenLimits),
]),
).map((model) => ({
model,
@ -51,6 +56,7 @@ export const toFormValues = (project: ProjectResponse): ProjectFormValues => {
tpm: tpmLimits[model],
itpm: itpmLimits[model],
otpm: otpmLimits[model],
batchEnqueuedTokens: batchEnqueuedTokenLimits[model],
}));
const metadata = Object.entries(metadataObj)
@ -74,6 +80,7 @@ function EditProjectForm({ project, onClose, onSuccess }: Omit<EditProjectModalP
const { t } = useTranslation();
const form = useZodForm(projectFormSchema, { defaultValues: toFormValues(project) });
const updateMutation = useUpdateProject();
const { userRole } = useAuthorized();
const [advancedOpen, setAdvancedOpen] = useState(false);
const [advancedEverOpened, setAdvancedEverOpened] = useState(false);
@ -87,10 +94,12 @@ function EditProjectForm({ project, onClose, onSuccess }: Omit<EditProjectModalP
? values
: { ...values, guardrails: undefined, modelLimits: undefined, metadata: undefined };
const params: ProjectUpdateParams = {
...buildProjectUpdateParams(submitted, project.litellm_budget_table?.max_budget),
team_id: submitted.team_id,
};
const built = buildProjectUpdateParams(submitted, project.litellm_budget_table?.max_budget);
const storedBatchLimitKept =
built.metadata !== undefined && !isProxyAdminRole(userRole ?? "")
? { metadata: withStoredBatchEnqueuedTokenLimit(built.metadata, project.metadata) }
: {};
const params: ProjectUpdateParams = { ...built, ...storedBatchLimitKept, team_id: submitted.team_id };
updateMutation.mutate(
{ projectId: project.project_id, params },

View file

@ -6,6 +6,7 @@ import { useFieldArray, useWatch, type UseFormReturn } from "react-hook-form";
import { ChevronDown, CircleAlert, Minus, Plus } from "lucide-react";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { isProxyAdminRole } from "@/utils/roles";
import { ALL_TEAM_MODELS, type ProjectFormValues, type ProjectSubmitValues } from "./projectFormSchema";
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
import { Team } from "@/components/key_team_helpers/key_list";
@ -44,6 +45,7 @@ export type { ProjectFormValues } from "./projectFormSchema";
export function ProjectBaseForm({ form, advancedOpen, onAdvancedOpenChange }: ProjectBaseFormProps) {
const { t } = useTranslation();
const { accessToken, userId, userRole } = useAuthorized();
const canEditBatchLimit = isProxyAdminRole(userRole ?? "");
const { data: teams } = useTeams();
const [selectedTeam, setSelectedTeam] = useState<Team | null>(null);
@ -58,6 +60,7 @@ export function ProjectBaseForm({ form, advancedOpen, onAdvancedOpenChange }: Pr
rpm: undefined,
itpm: undefined,
otpm: undefined,
batchEnqueuedTokens: undefined,
};
const teamIdValue = useWatch({ control: form.control, name: "team_id" });
@ -292,7 +295,7 @@ export function ProjectBaseForm({ form, advancedOpen, onAdvancedOpenChange }: Pr
{modelLimits.fields.map((field, index) => (
<div
key={field.id}
className="mb-2 grid grid-cols-1 items-start gap-2 sm:grid-cols-2 xl:grid-cols-[minmax(0,2fr)_repeat(4,minmax(0,1fr))_auto]"
className="mb-2 grid grid-cols-1 items-start gap-2 sm:grid-cols-2 xl:grid-cols-[minmax(0,2fr)_repeat(5,minmax(0,1fr))_auto]"
>
<FormField
control={form.control}
@ -376,6 +379,25 @@ export function ProjectBaseForm({ form, advancedOpen, onAdvancedOpenChange }: Pr
/>
)}
</FormField>
<FormField
control={form.control}
name={`modelLimits.${index}.batchEnqueuedTokens`}
label={t("projects.form.modelLimits.batchEnqueuedTokens")}
>
{({ ref, value, onChange, ...control }) => (
<Input
{...control}
ref={ref}
type="number"
min={1}
step={1}
disabled={!canEditBatchLimit}
placeholder={t("projects.form.modelLimits.batchEnqueuedTokens")}
value={value ?? ""}
onChange={(event) => onChange(toOptionalNumber(event.target.value))}
/>
)}
</FormField>
<Button
type="button"
variant="ghost"

View file

@ -13,6 +13,11 @@ const modelLimitSchema = z.object({
rpm: z.number().optional(),
itpm: z.number().optional(),
otpm: z.number().optional(),
batchEnqueuedTokens: z
.number()
.int({ error: () => i18n.t("projects.form.errors.batchEnqueuedTokens") })
.positive({ error: () => i18n.t("projects.form.errors.batchEnqueuedTokens") })
.optional(),
});
export const projectFormSchema = z

View file

@ -1,5 +1,9 @@
import { describe, it, expect } from "vitest";
import { buildProjectCreateParams, buildProjectUpdateParams } from "./projectFormUtils";
import {
buildProjectCreateParams,
buildProjectUpdateParams,
withStoredBatchEnqueuedTokenLimit,
} from "./projectFormUtils";
import { ProjectFormValues } from "./ProjectBaseForm";
const baseValues: ProjectFormValues = {
@ -152,6 +156,45 @@ describe("buildProjectCreateParams", () => {
});
});
describe("per-model batch enqueued token limit", () => {
it("should send the limit inside metadata next to the key-value entries", () => {
const result = buildProjectCreateParams({
...baseValues,
modelLimits: [{ model: "gpt-4", batchEnqueuedTokens: 5000 }],
metadata: [{ key: "owner", value: "platform" }],
});
expect(result.metadata).toEqual({ owner: "platform", model_batch_enqueued_token_limit: { "gpt-4": 5000 } });
expect(result).not.toHaveProperty("model_batch_enqueued_token_limit");
});
it("should leave the limit out of metadata when no row sets one", () => {
const result = buildProjectUpdateParams({ ...baseValues, modelLimits: [{ model: "gpt-4", rpm: 20 }] });
expect(result.metadata).toEqual({});
});
it("should put back the stored limit that the form dropped", () => {
const stored = { owner: "old", model_batch_enqueued_token_limit: { "gpt-4": 1000 } };
expect(withStoredBatchEnqueuedTokenLimit({ owner: "platform" }, stored)).toStrictEqual({
owner: "platform",
model_batch_enqueued_token_limit: { "gpt-4": 1000 },
});
});
it("should replace a limit the form changed with the stored one", () => {
const stored = { model_batch_enqueued_token_limit: { "gpt-4": 1000 } };
const edited = { owner: "platform", model_batch_enqueued_token_limit: { "gpt-4": 9000 } };
expect(withStoredBatchEnqueuedTokenLimit(edited, stored)).toStrictEqual({
owner: "platform",
model_batch_enqueued_token_limit: { "gpt-4": 1000 },
});
});
it("should drop a limit the form added when none is stored", () => {
const edited = { owner: "platform", model_batch_enqueued_token_limit: { "gpt-4": 9000 } };
expect(withStoredBatchEnqueuedTokenLimit(edited, null)).toStrictEqual({ owner: "platform" });
});
});
describe("buildProjectUpdateParams clearing", () => {
it("should send empty limit maps when every model limit row has been removed", () => {
const result = buildProjectUpdateParams({ ...baseValues, modelLimits: [] });

View file

@ -16,6 +16,30 @@ const buildModelLimitMap = (
const buildMetadata = (entries: ProjectFormValues["metadata"]): Record<string, string> | undefined =>
entries && Object.fromEntries(entries.flatMap((entry) => (entry.key ? [[entry.key, entry.value] as const] : [])));
const withBatchEnqueuedTokenLimit = (
metadata: Record<string, string> | undefined,
batchEnqueuedTokenLimit: Record<string, number>,
clearsRemovedLimits: boolean,
): Record<string, unknown> | undefined => {
if (Object.keys(batchEnqueuedTokenLimit).length > 0) {
return { ...metadata, model_batch_enqueued_token_limit: batchEnqueuedTokenLimit };
}
return clearsRemovedLimits ? { ...metadata } : metadata;
};
const BATCH_ENQUEUED_TOKEN_LIMIT_KEY = "model_batch_enqueued_token_limit";
export const withStoredBatchEnqueuedTokenLimit = (
metadata: Record<string, unknown>,
storedMetadata: Record<string, unknown> | null,
): Record<string, unknown> => {
const withoutLimit = Object.fromEntries(
Object.entries(metadata).filter(([key]) => key !== BATCH_ENQUEUED_TOKEN_LIMIT_KEY),
);
const storedLimit = storedMetadata?.[BATCH_ENQUEUED_TOKEN_LIMIT_KEY];
return storedLimit === undefined ? withoutLimit : { ...withoutLimit, [BATCH_ENQUEUED_TOKEN_LIMIT_KEY]: storedLimit };
};
const roundBudget = (value: number): number => {
const rounded = Math.round(value * 100) / 100;
return Number.isFinite(rounded) ? rounded : value;
@ -27,8 +51,12 @@ const buildProjectApiParams = (values: ProjectFormValues, sendEmpty: boolean) =>
const modelTpmLimit = buildModelLimitMap(limitEntries, (entry) => entry.tpm);
const modelItpmLimit = buildModelLimitMap(limitEntries, (entry) => entry.itpm);
const modelOtpmLimit = buildModelLimitMap(limitEntries, (entry) => entry.otpm);
const metadata = buildMetadata(values.metadata);
const keepLimits = sendEmpty && values.modelLimits !== undefined;
const metadata = withBatchEnqueuedTokenLimit(
buildMetadata(values.metadata),
buildModelLimitMap(limitEntries, (entry) => entry.batchEnqueuedTokens),
keepLimits,
);
const keep = (map: Record<string, number>) => keepLimits || Object.keys(map).length > 0;
const guardrailsParam =
values.guardrails !== undefined && (sendEmpty || values.guardrails.length > 0)

View file

@ -3267,6 +3267,7 @@
"rpm": "RPM Limit",
"itpm": "Input TPM Limit",
"otpm": "Output TPM Limit",
"batchEnqueuedTokens": "Batch Enqueued Token Limit",
"remove": "Remove model limit {{number}}",
"add": "Add Model Limit"
},
@ -3284,7 +3285,8 @@
"missingKey": "Missing key",
"missingValue": "Missing value",
"duplicateModel": "Duplicate model",
"duplicateKey": "Duplicate key"
"duplicateKey": "Duplicate key",
"batchEnqueuedTokens": "Enter a whole number above 0"
}
},
"createModal": {

View file

@ -0,0 +1,188 @@
import json
import uuid
from pathlib import Path
from typing import Final
import httpx
import pytest
import yaml
from integration._support.client import JSON_OBJECT, Gateway, Scenario, object_value, string_value
from integration._support.process import OwnedState, owned_proxy
from integration._support.upstream import delete_scenario, register_scenario
from integration.cost_calculation.cost_tracking_case import JsonResponse, RoutedResponse, TextResponse
from pydantic import JsonValue
ROWS_PER_BATCH: Final = 3
WORDS_PER_ROW: Final = 200
ONE_BATCH_BUT_NOT_TWO: Final = 2000
CUSTOM_AUTH_DOUBLE: Final = """
import json
import os
from fastapi import Request
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
async def user_api_key_auth(request: Request, api_key: str) -> UserAPIKeyAuth:
if api_key == os.environ["LITELLM_MASTER_KEY"]:
return UserAPIKeyAuth(api_key=api_key, user_role=LitellmUserRoles.PROXY_ADMIN)
callers = json.loads(os.environ["INTEGRATION_CUSTOM_AUTH_CALLERS"])
if api_key not in callers:
raise Exception("unknown caller")
return UserAPIKeyAuth(api_key=api_key, **callers[api_key])
"""
def _custom_auth_config(directory: Path) -> Path:
(directory / "project_custom_auth.py").write_text(CUSTOM_AUTH_DOUBLE)
base: Final = JSON_OBJECT.validate_python(yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text()))
path: Final = directory / "project-custom-auth.yaml"
path.write_text(
yaml.safe_dump(
{
**base,
"general_settings": {
**object_value(base["general_settings"]),
"custom_auth": "project_custom_auth.user_api_key_auth",
"custom_auth_run_common_checks": True,
},
}
)
)
return path
def _input_lines(model_name: str) -> str:
return "".join(
json.dumps(
{
"custom_id": f"r{index}",
"method": "POST",
"url": "/v1/chat/completions",
"body": {"model": model_name, "messages": [{"role": "user", "content": "enqueued " * WORDS_PER_ROW}]},
},
separators=(",", ":"),
)
+ "\n"
for index in range(ROWS_PER_BATCH)
)
def _batch(status: str) -> dict[str, JsonValue]:
return {
"id": "batch-$REQUEST_ID",
"object": "batch",
"endpoint": "/v1/chat/completions",
"errors": None,
"input_file_id": "file-in-$REQUEST_ID",
"completion_window": "24h",
"status": status,
"output_file_id": None,
"error_file_id": None,
"created_at": 1,
"expires_at": 1,
"request_counts": {"total": ROWS_PER_BATCH, "completed": 0, "failed": 0},
"metadata": None,
}
def _batch_provider(scenario: Scenario) -> str:
model_name: Final = f"integration-{uuid.uuid4().hex}"
handle: Final = register_scenario(
f"project-batch-limit-{uuid.uuid4().hex[:12]}",
RoutedResponse(
content_type="application/x-routed",
routes={
"POST /files": JsonResponse(
content_type="application/json",
body={
"id": "file-in-$REQUEST_ID",
"object": "file",
"purpose": "batch",
"bytes": 100,
"created_at": 1,
"filename": "in.jsonl",
"status": "processed",
},
),
"GET /files/file-in-$REQUEST_ID/content": TextResponse(
content_type="application/jsonl", body=_input_lines(model_name)
),
"POST /batches": JsonResponse(content_type="application/json", body=_batch("validating")),
"GET /batches/batch-$REQUEST_ID": JsonResponse(
content_type="application/json", body=_batch("cancelled")
),
},
),
)
scenario.cleanups.callback(delete_scenario, handle)
scenario.model(name=model_name, api_base=handle.api_base())
return model_name
def _submit(candidate: Gateway, model_name: str, caller: str) -> httpx.Response:
uploaded: Final = candidate.request_multipart(
"/v1/files",
{"purpose": "batch", "model": model_name},
{"file": ("in.jsonl", _input_lines(model_name).encode(), "application/jsonl")},
key=caller,
)
assert uploaded.status_code == 200, uploaded.text
return candidate.request(
"POST",
"/v1/batches",
{
"input_file_id": string_value(JSON_OBJECT.validate_json(uploaded.content)["id"]),
"endpoint": "/v1/chat/completions",
"completion_window": "24h",
"model": model_name,
},
key=caller,
)
@pytest.mark.timeout(480)
def test_custom_auth_callers_share_the_project_per_model_enqueued_limit_until_a_batch_ends(
gateway: Gateway, tmp_path: Path
) -> None:
with gateway.scenario() as scenario:
limited: Final = _batch_provider(scenario)
unlimited: Final = _batch_provider(scenario)
team: Final = scenario.team()
project: Final = scenario.project(
team, metadata={"model_batch_enqueued_token_limit": {limited: ONE_BATCH_BUT_NOT_TWO}}
)
first_caller: Final = "sk-custom-" + uuid.uuid4().hex
rotated_caller: Final = "sk-custom-" + uuid.uuid4().hex
callers: Final = json.dumps(
{
first_caller: {"user_id": "project-batch-caller", "team_id": team, "project_id": project},
rotated_caller: {"user_id": "project-batch-caller", "team_id": team, "project_id": project},
}
)
with owned_proxy(
gateway,
tmp_path,
{"INTEGRATION_CUSTOM_AUTH_CALLERS": callers},
config=_custom_auth_config(tmp_path),
state=OwnedState.SHARED_WITH_WORKER,
) as candidate:
first: Final = _submit(candidate, limited, first_caller)
assert first.status_code == 200, first.text
refused: Final = _submit(candidate, limited, rotated_caller)
assert refused.status_code == 429, refused.text
assert f"project_model: {project}:{limited}" in refused.text, refused.text
other_model: Final = _submit(candidate, unlimited, rotated_caller)
assert other_model.status_code == 200, other_model.text
first_batch: Final = string_value(JSON_OBJECT.validate_json(first.content)["id"])
ended: Final = candidate.request("GET", f"/v1/batches/{first_batch}", key=first_caller)
assert ended.status_code == 200, ended.text
assert JSON_OBJECT.validate_json(ended.content)["status"] == "cancelled", ended.text
after_refund: Final = _submit(candidate, limited, rotated_caller)
assert after_refund.status_code == 200, after_refund.text

View file

@ -1241,9 +1241,9 @@ async def test_new_project_flag_on_missing_rpm_tpm_returns_400(monkeypatch):
assert "rpm/tpm quota" in str(exc_info.value)
def _project_update_mocks(monkeypatch, stored_metadata: dict) -> mock.MagicMock:
def _project_update_mocks(monkeypatch, stored_metadata: dict, team_id: str | None = None) -> mock.MagicMock:
existing_row = mock.MagicMock(
team_id=None, budget_id=None, object_permission_id=None, metadata=stored_metadata
team_id=team_id, budget_id=None, object_permission_id=None, metadata=stored_metadata
)
mock_prisma = mock.MagicMock()
mock_prisma.jsonify_object = lambda data: data
@ -1251,7 +1251,7 @@ def _project_update_mocks(monkeypatch, stored_metadata: dict) -> mock.MagicMock:
mock_prisma.db.litellm_projecttable.update = mock.AsyncMock(return_value=mock.MagicMock())
mock_prisma.writer_db = mock.MagicMock()
mock_prisma.writer_db.litellm_projecttable.find_unique = mock.AsyncMock(
return_value={"project_id": "project-update-test", "team_id": None}
return_value={"project_id": "project-update-test", "team_id": team_id}
)
mock_prisma.writer_db.litellm_verificationtoken.count = mock.AsyncMock(return_value=0)
@ -2411,3 +2411,163 @@ async def test_project_writes_reject_an_unregistered_guardrail(monkeypatch, oper
assert str(getattr(exc.value, "code", None) or getattr(exc.value, "status_code", "")) == "400"
mock_prisma.db.litellm_projecttable.create.assert_not_awaited()
mock_prisma.db.litellm_projecttable.update.assert_not_awaited()
_TEAM_ADMIN: Final = UserAPIKeyAuth(
user_role=LitellmUserRoles.INTERNAL_USER, api_key="hashed-team-admin-key", user_id="team-admin"
)
_PROXY_ADMIN: Final = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, api_key=MASTER_KEY, user_id="1234")
_PROJECT_BATCH_LIMIT_REFUSAL: Final = "Only proxy admins can set model_batch_enqueued_token_limit on a project"
def _stored_project_metadata() -> dict[str, object]:
return {"owner": "platform", "model_batch_enqueued_token_limit": {"gpt-5.5": 1000}}
def _team_admins_manage_projects(monkeypatch: pytest.MonkeyPatch, prisma: mock.MagicMock) -> None:
team: Final = LiteLLM_TeamTable(
team_id="test-team", members_with_roles=[Member(user_id=_TEAM_ADMIN.user_id, role="admin")]
)
prisma.db.litellm_teamtable.find_unique = mock.AsyncMock(return_value=team)
monkeypatch.setattr(litellm.proxy.proxy_server, "general_settings", {"team_admin_editable_team_fields": ["projects"]})
@pytest.mark.asyncio
async def test_team_admin_cannot_put_a_model_batch_enqueued_token_limit_on_a_new_project(
monkeypatch: pytest.MonkeyPatch,
) -> None:
prisma: Final = mock.MagicMock()
_team_admins_manage_projects(monkeypatch, prisma)
monkeypatch.setattr(litellm.proxy.proxy_server, "prisma_client", prisma)
monkeypatch.setattr(litellm.proxy.proxy_server, "premium_user", True)
monkeypatch.setattr(litellm.proxy.proxy_server, "llm_router", None)
with pytest.raises(ProxyException) as exc:
await new_project(
data=NewProjectRequest(
team_id="test-team", metadata={"model_batch_enqueued_token_limit": {"gpt-5.5": 1000}}
),
http_request=Request(scope={"type": "http"}),
user_api_key_dict=_TEAM_ADMIN,
)
assert exc.value.code == "403"
assert _PROJECT_BATCH_LIMIT_REFUSAL in exc.value.message
@pytest.mark.asyncio
@pytest.mark.parametrize(
"requested_metadata",
[{"owner": "platform", "model_batch_enqueued_token_limit": {"gpt-5.5": 999_999}}, {"owner": "platform"}],
ids=["raises the limit", "drops the limit"],
)
async def test_team_admin_cannot_change_a_project_model_batch_enqueued_token_limit(
monkeypatch: pytest.MonkeyPatch, requested_metadata: dict[str, object]
) -> None:
mock_prisma: Final = _project_update_mocks(monkeypatch, _stored_project_metadata(), team_id="test-team")
_team_admins_manage_projects(monkeypatch, mock_prisma)
with pytest.raises(ProxyException) as exc:
await update_project(
data=UpdateProjectRequest(project_id=f"project-{uuid.uuid4()}", metadata=requested_metadata),
http_request=Request(scope={"type": "http"}),
user_api_key_dict=_TEAM_ADMIN,
)
assert exc.value.code == "403"
assert _PROJECT_BATCH_LIMIT_REFUSAL in exc.value.message
mock_prisma.db.litellm_projecttable.update.assert_not_awaited()
@pytest.mark.asyncio
@pytest.mark.parametrize(
("caller", "requested_limit"),
[(_TEAM_ADMIN, {"gpt-5.5": 1000}), (_PROXY_ADMIN, {"gpt-5.5": 5000})],
ids=["team admin resends the stored limit", "proxy admin changes the limit"],
)
async def test_project_update_keeps_an_allowed_model_batch_enqueued_token_limit(
monkeypatch: pytest.MonkeyPatch, caller: UserAPIKeyAuth, requested_limit: dict[str, int]
) -> None:
mock_prisma: Final = _project_update_mocks(monkeypatch, _stored_project_metadata(), team_id="test-team")
_team_admins_manage_projects(monkeypatch, mock_prisma)
await update_project(
data=UpdateProjectRequest(
project_id=f"project-{uuid.uuid4()}",
metadata={"owner": "renamed", "model_batch_enqueued_token_limit": requested_limit},
),
http_request=Request(scope={"type": "http"}),
user_api_key_dict=caller,
)
written_metadata: Final = _written_project_data(mock_prisma)["metadata"]
assert written_metadata["owner"] == "renamed"
assert written_metadata["model_batch_enqueued_token_limit"] == requested_limit
@pytest.mark.asyncio
async def test_team_admin_can_write_batch_keys_a_project_does_not_enforce(monkeypatch: pytest.MonkeyPatch) -> None:
mock_prisma: Final = _project_update_mocks(monkeypatch, _stored_project_metadata(), team_id="test-team")
_team_admins_manage_projects(monkeypatch, mock_prisma)
requested_metadata: Final = {
**_stored_project_metadata(),
"batch_enqueued_token_limit": 50,
"max_batch_file_records": 10,
"allowed_s3_buckets": ["team-bucket"],
}
await update_project(
data=UpdateProjectRequest(project_id=f"project-{uuid.uuid4()}", metadata=requested_metadata),
http_request=Request(scope={"type": "http"}),
user_api_key_dict=_TEAM_ADMIN,
)
assert _written_project_data(mock_prisma)["metadata"] == requested_metadata
@pytest.mark.asyncio
@pytest.mark.parametrize(
"requested_limit",
[{"gpt-5.5": 1000, "gpt-5.4": 0}, {"gpt-5.5": "lots"}, [1000], {"gpt-5.5": True}, {"gpt-5.5": "2000"}],
ids=["one model at zero", "a non-numeric limit", "not a model map", "a boolean limit", "a numeric string limit"],
)
async def test_project_update_rejects_a_model_batch_enqueued_token_limit_it_cannot_enforce(
monkeypatch: pytest.MonkeyPatch, requested_limit: object
) -> None:
mock_prisma: Final = _project_update_mocks(monkeypatch, _stored_project_metadata(), team_id="test-team")
_team_admins_manage_projects(monkeypatch, mock_prisma)
with pytest.raises(ProxyException) as exc:
await update_project(
data=UpdateProjectRequest(
project_id=f"project-{uuid.uuid4()}", metadata={"model_batch_enqueued_token_limit": requested_limit}
),
http_request=Request(scope={"type": "http"}),
user_api_key_dict=_PROXY_ADMIN,
)
assert exc.value.code == "400"
assert "model_batch_enqueued_token_limit must map each model name to a positive integer" in exc.value.message
mock_prisma.db.litellm_projecttable.update.assert_not_awaited()
@pytest.mark.asyncio
@pytest.mark.parametrize(
"top_level_fields",
[{"model_tpm_limit": {"gpt-5.5": 2000}}, {"tags": ["batch"]}],
ids=["a per-model limit", "a premium metadata field"],
)
@pytest.mark.parametrize("caller", [_TEAM_ADMIN, _PROXY_ADMIN], ids=["team admin", "proxy admin"])
async def test_project_update_with_only_top_level_fields_keeps_the_stored_metadata(
monkeypatch: pytest.MonkeyPatch, top_level_fields: dict[str, object], caller: UserAPIKeyAuth
) -> None:
mock_prisma: Final = _project_update_mocks(monkeypatch, _stored_project_metadata(), team_id="test-team")
_team_admins_manage_projects(monkeypatch, mock_prisma)
await update_project(
data=UpdateProjectRequest(project_id=f"project-{uuid.uuid4()}", **top_level_fields),
http_request=Request(scope={"type": "http"}),
user_api_key_dict=caller,
)
assert _written_project_data(mock_prisma)["metadata"] == {**_stored_project_metadata(), **top_level_fields}

View file

@ -10,14 +10,17 @@ import base64
import logging
import uuid
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from types import MappingProxyType, SimpleNamespace
from typing import Final
from typing import Final, Literal
import pytest
from pydantic import TypeAdapter
from litellm.caching.caching import DualCache
from litellm.caching.redis_cache import RedisCircuitBreakerOpenError
from litellm.constants import BATCH_ENQUEUED_TOKEN_TTL_SECONDS
from litellm.litellm_core_utils.model_latest_aliases import get_model_latest_aliases
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.hooks.batch_enqueued_tokens import (
BatchEnqueuedTokenOverLimit,
@ -26,7 +29,10 @@ from litellm.proxy.hooks.batch_enqueued_tokens import (
BatchEnqueuedTokenStore,
batch_response_view,
canonical_provider_batch_id,
project_has_model_batch_enqueued_token_limits,
project_limits_every_model,
resolve_batch_enqueued_token_scopes,
resolve_project_model_batch_enqueued_token_scopes,
)
from litellm.proxy.utils import InternalUsageCache
@ -69,6 +75,156 @@ def test_scope_resolution_skips_team_scope_without_team_id():
assert resolve_batch_enqueued_token_scopes(user) == ()
def _project_user(model_limits: object, project_id: str | None = "project-1") -> UserAPIKeyAuth:
return UserAPIKeyAuth(
api_key="hashed-jwt-abc",
team_id="shared-team",
project_id=project_id,
project_metadata={"model_batch_enqueued_token_limit": model_limits},
)
def test_project_model_scopes_charge_each_limited_row_model_only_its_own_tokens():
scopes: Final = resolve_project_model_batch_enqueued_token_scopes(
_project_user({"gpt-5.5": 100, "claude-opus-5-5": 50}),
row_tokens_by_model={"gpt-5.5": 80, "unlimited-model": 999},
routing_model="unlimited-model",
batch_tokens=1079,
model_group_of=lambda model: model,
)
assert scopes == (BatchEnqueuedTokenScope(key="project_model", value="project-1:gpt-5.5", limit=100, tokens=80),)
def test_project_model_scopes_charge_the_routing_model_the_whole_batch_whatever_rows_name():
scopes: Final = resolve_project_model_batch_enqueued_token_scopes(
_project_user({"capped-deployment": 100}),
row_tokens_by_model={"unlisted-model": 400},
routing_model="capped-deployment",
batch_tokens=500,
model_group_of=lambda model: model,
)
assert scopes == (
BatchEnqueuedTokenScope(key="project_model", value="project-1:capped-deployment", limit=100, tokens=500),
)
def test_project_model_scopes_charge_rows_of_the_routing_group_once():
groups: Final = MappingProxyType({"mini-alias": "gpt-mini", "gpt-mini": "gpt-mini"})
scopes: Final = resolve_project_model_batch_enqueued_token_scopes(
_project_user({"gpt-mini": 100}),
row_tokens_by_model={"mini-alias": 30},
routing_model="gpt-mini",
batch_tokens=30,
model_group_of=groups.__getitem__,
)
assert scopes == (BatchEnqueuedTokenScope(key="project_model", value="project-1:gpt-mini", limit=100, tokens=30),)
def test_project_model_scopes_share_one_counter_per_model_group_under_the_tightest_limit():
groups: Final = MappingProxyType(
{"gpt-latest": "gpt-5.5", "gpt-5.5": "gpt-5.5", "mini": "gpt-mini", "gpt-mini": "gpt-mini"}
)
scopes: Final = resolve_project_model_batch_enqueued_token_scopes(
_project_user({"gpt-latest": 100, "gpt-5.5": 70, "gpt-mini": 40}),
row_tokens_by_model={"gpt-latest": 30, "gpt-5.5": 20, "mini": 5},
routing_model=None,
batch_tokens=55,
model_group_of=groups.__getitem__,
)
assert scopes == (
BatchEnqueuedTokenScope(key="project_model", value="project-1:gpt-5.5", limit=70, tokens=50),
BatchEnqueuedTokenScope(key="project_model", value="project-1:gpt-mini", limit=40, tokens=5),
)
@pytest.mark.parametrize("named_model", ["gpt-latest", "gpt-5.5"], ids=["only an alias", "only the group"])
def test_project_model_scope_takes_its_groups_tightest_limit_whichever_name_the_batch_uses(named_model: str) -> None:
groups: Final = MappingProxyType({"gpt-latest": "gpt-5.5", "gpt-pinned": "gpt-5.5", "gpt-5.5": "gpt-5.5"})
scopes: Final = resolve_project_model_batch_enqueued_token_scopes(
_project_user({"gpt-latest": 100, "gpt-5.5": 70, "gpt-pinned": 60}),
row_tokens_by_model={named_model: 30},
routing_model=named_model,
batch_tokens=30,
model_group_of=groups.__getitem__,
)
assert scopes == (BatchEnqueuedTokenScope(key="project_model", value="project-1:gpt-5.5", limit=60, tokens=30),)
@pytest.mark.parametrize(
"user",
[
_project_user(100),
_project_user({"gpt-5.5": 0}),
_project_user({"gpt-5.5": "lots"}),
_project_user(["gpt-5.5"]),
_project_user({"gpt-5.5": True}),
_project_user({"gpt-5.5": "100"}),
_project_user({"gpt-5.5": 100.0}),
_project_user({"gpt-5.5": 100}, project_id=None),
UserAPIKeyAuth(api_key="k", project_id="project-1"),
],
ids=["scalar", "zero", "non-numeric", "list", "boolean", "numeric string", "float", "no project id", "no metadata"],
)
def test_project_model_scopes_ignore_unusable_limits(user):
assert not project_has_model_batch_enqueued_token_limits(user)
assert (
resolve_project_model_batch_enqueued_token_scopes(
user, row_tokens_by_model={"gpt-5.5": 10}, routing_model="gpt-5.5", batch_tokens=10, model_group_of=str
)
== ()
)
@pytest.mark.parametrize(
("models", "expected"),
[
(("gpt-5.5",), True),
(("gpt-latest",), True),
(("unlisted-model",), False),
(("gpt-5.5", "unlisted-model"), False),
],
ids=["limited model", "alias of a limited group", "unlisted model", "one of the models is unlisted"],
)
def test_project_limits_every_model_only_when_each_model_has_a_limit(models, expected):
groups: Final = MappingProxyType({"gpt-latest": "gpt-5.5"})
assert (
project_limits_every_model(
_project_user({"gpt-5.5": 100}), models, model_group_of=lambda name: groups.get(name, name)
)
is expected
)
def test_a_latest_alias_is_charged_under_the_project_limit_of_its_target():
alias, target = next(iter(get_model_latest_aliases()["openai"].items()))
user: Final = _project_user({target: 100})
assert project_limits_every_model(user, (alias,), model_group_of=str)
assert resolve_project_model_batch_enqueued_token_scopes(
user, row_tokens_by_model={alias: 40}, routing_model=alias, batch_tokens=40, model_group_of=str
) == (BatchEnqueuedTokenScope(key="project_model", value=f"project-1:{target}", limit=100, tokens=40),)
def test_a_dated_snapshot_row_is_charged_under_the_project_limit_of_its_base_model():
user: Final = _project_user({"gpt-5.5": 100, "gpt-5.5-2026-04-23": 60})
assert project_limits_every_model(_project_user({"gpt-5.5": 100}), ("gpt-5.5-2026-04-23",), model_group_of=str)
assert resolve_project_model_batch_enqueued_token_scopes(
user,
row_tokens_by_model={"gpt-5.5-2026-04-23": 40},
routing_model="gpt-5.4-nano",
batch_tokens=40,
model_group_of=str,
) == (
BatchEnqueuedTokenScope(key="project_model", value="project-1:gpt-5.5", limit=100, tokens=40),
BatchEnqueuedTokenScope(key="project_model", value="project-1:gpt-5.5-2026-04-23", limit=60, tokens=40),
)
def test_project_model_limits_are_detected_before_the_batch_file_is_read():
assert project_has_model_batch_enqueued_token_limits(_project_user({"gpt-5.5": 100}))
@pytest.mark.asyncio
async def test_reserve_rejects_once_allowance_is_exhausted():
store = _in_memory_store()
@ -216,6 +372,89 @@ async def test_redis_reserve_issues_single_key_calls_and_rolls_back_on_over_limi
assert all(len(keys) == 1 for _, keys in fake.script_calls)
@pytest.mark.asyncio
async def test_project_model_scope_is_charged_its_own_tokens_and_refunded_after_a_round_trip():
fake: Final = _SingleKeyRedisFake()
store: Final = BatchEnqueuedTokenStore(
internal_usage_cache=InternalUsageCache(DualCache(redis_cache=fake, default_in_memory_ttl=60))
)
key_scope: Final = _scope(limit=1000, key="api_key")
model_scope: Final = BatchEnqueuedTokenScope(
key="project_model", value=f"project-{uuid.uuid4().hex}:gpt-5.5", limit=40, tokens=30
)
key_counter: Final = f"batch_enqueued_tokens:api_key:{key_scope.value}"
model_counter: Final = f"batch_enqueued_tokens:project_model:{model_scope.value}"
reservation: Final = await store.reserve(tokens=100, scopes=(key_scope, model_scope))
assert isinstance(reservation, BatchEnqueuedTokenReservation)
assert fake.counters == {key_counter: 100, model_counter: 30}
over: Final = await store.reserve(
tokens=100,
scopes=(key_scope, BatchEnqueuedTokenScope(key="project_model", value=model_scope.value, limit=40, tokens=20)),
)
assert isinstance(over, BatchEnqueuedTokenOverLimit)
assert over.enqueued == 30
assert fake.counters == {key_counter: 100, model_counter: 30}
await store.save_reservation("batch_project_model", reservation)
popped: Final = await store.pop_reservation("batch_project_model")
assert popped == reservation
await store.refund(popped)
assert not fake.counters
@dataclass(frozen=True, slots=True)
class _ScopeBeforeProjectScopes:
key: Literal["api_key", "team"]
value: str
limit: int
@dataclass(frozen=True, slots=True)
class _ReservationBeforeProjectScopes:
tokens: int
scopes: tuple[_ScopeBeforeProjectScopes, ...]
@pytest.mark.asyncio
async def test_a_pod_that_predates_project_scopes_still_reads_the_key_and_team_scopes_of_a_saved_record():
fake: Final = _SingleKeyRedisFake()
store: Final = BatchEnqueuedTokenStore(
internal_usage_cache=InternalUsageCache(DualCache(redis_cache=fake, default_in_memory_ttl=60))
)
key_scope: Final = _scope(limit=1000, key="api_key")
model_scope: Final = BatchEnqueuedTokenScope(key="project_model", value="project-1:gpt-5.5", limit=40, tokens=30)
reservation: Final = await store.reserve(tokens=100, scopes=(key_scope, model_scope))
assert isinstance(reservation, BatchEnqueuedTokenReservation)
await store.save_reservation("batch_mixed_versions", reservation)
(record,) = fake.records.values()
assert TypeAdapter(_ReservationBeforeProjectScopes).validate_json(record) == _ReservationBeforeProjectScopes(
tokens=100, scopes=(_ScopeBeforeProjectScopes(key="api_key", value=key_scope.value, limit=1000),)
)
@pytest.mark.asyncio
async def test_in_memory_project_model_scope_is_charged_and_refunded_its_own_tokens():
store: Final = _in_memory_store()
key_scope: Final = _scope(limit=1000, key="api_key")
model_scope: Final = BatchEnqueuedTokenScope(
key="project_model", value=f"project-{uuid.uuid4().hex}:gpt-5.5", limit=40, tokens=30
)
reservation: Final = await store.reserve(tokens=100, scopes=(key_scope, model_scope))
assert isinstance(reservation, BatchEnqueuedTokenReservation)
blocked: Final = BatchEnqueuedTokenScope(key="project_model", value=model_scope.value, limit=40, tokens=20)
assert await store.reserve(tokens=1, scopes=(blocked,)) == BatchEnqueuedTokenOverLimit(scope=blocked, enqueued=30)
await store.refund(reservation)
full: Final = BatchEnqueuedTokenScope(key="project_model", value=model_scope.value, limit=40, tokens=40)
assert isinstance(await store.reserve(tokens=1, scopes=(full,)), BatchEnqueuedTokenReservation)
assert isinstance(await store.reserve(tokens=900, scopes=(key_scope,)), BatchEnqueuedTokenReservation)
assert isinstance(await store.reserve(tokens=101, scopes=(key_scope,)), BatchEnqueuedTokenOverLimit)
@pytest.mark.asyncio
async def test_partial_redis_reserve_failure_rolls_back_and_grants_in_memory():
key_scope = _scope(limit=100, key="api_key")

View file

@ -7,6 +7,8 @@ VERIA-39 regression tests:
models the caller is not authorized to use.
"""
from collections.abc import Callable, Mapping
from typing import Final
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@ -2101,7 +2103,10 @@ def test_estimate_entry_output_tokens_multiplies_candidate_count(body_extra, exp
# ---------------------------------------------------------------------------
def _enqueued_rate_limiter():
def _enqueued_rate_limiter(
model_group_resolver: Callable[[str], str | None] = lambda _model: None,
deployment_model_name: Callable[[str], str | None] = lambda _model: None,
):
from litellm import DualCache
from litellm.proxy.hooks.batch_rate_limiter import PROXY_BatchRateLimiter
from litellm.proxy.hooks.parallel_request_limiter_v3 import (
@ -2111,10 +2116,13 @@ def _enqueued_rate_limiter():
local_cache = DualCache(default_in_memory_ttl=60)
internal_usage_cache = InternalUsageCache(local_cache)
parallel_request_limiter = PROXY_MaxParallelRequestsHandler_v3(internal_usage_cache=internal_usage_cache)
parallel_request_limiter: Final = PROXY_MaxParallelRequestsHandler_v3(
internal_usage_cache=internal_usage_cache, model_group_resolver=model_group_resolver
)
rate_limiter = PROXY_BatchRateLimiter(
internal_usage_cache=internal_usage_cache,
parallel_request_limiter=parallel_request_limiter,
deployment_model_name=deployment_model_name,
)
return rate_limiter, local_cache
@ -2126,9 +2134,9 @@ _ENQUEUED_BATCH_FILE_CONTENT = (
)
def _enqueued_batch_patches():
def _enqueued_batch_patches(file_content: bytes = _ENQUEUED_BATCH_FILE_CONTENT):
mock_content = MagicMock()
mock_content.content = _ENQUEUED_BATCH_FILE_CONTENT
mock_content.content = file_content
afile_content_mock = AsyncMock(return_value=mock_content)
return afile_content_mock, (
patch("litellm.proxy.proxy_server.general_settings", {}),
@ -2261,6 +2269,307 @@ async def test_enqueued_team_limit_applies_to_batch_submission():
assert "Batch enqueued token limit exceeded for team: team-enqueued-batch" in str(exc.value.detail)
_MODEL_LESS_BATCH_FILE_CONTENT = (
b'{"body": {"max_tokens": 40, "messages": [{"role": "user", "content": "hi"}]}}\n'
b'{"body": {"max_tokens": 40, "messages": [{"role": "user", "content": "hi"}]}}\n'
)
_ALIASED_BATCH_FILE_CONTENT = _ENQUEUED_BATCH_FILE_CONTENT.replace(b"gpt-4o-mini", b"mini-alias")
_UNLISTED_MODEL_BATCH_FILE_CONTENT = _ENQUEUED_BATCH_FILE_CONTENT.replace(b"gpt-4o-mini", b"unlisted-model")
_MIXED_MODEL_BATCH_FILE_CONTENT = (
_ENQUEUED_BATCH_FILE_CONTENT
+ b'{"body": {"model": "gpt-5.5", "max_tokens": 40, "messages": [{"role": "user", "content": "hello there"}]}}\n'
)
def _project_model_limited_user(model_limits: Mapping[str, int]) -> UserAPIKeyAuth:
return UserAPIKeyAuth(
api_key="hashed-jwt-project-caller",
models=["*"],
team_id="shared-team",
project_id="project-enqueued-batch",
project_metadata={"model_batch_enqueued_token_limit": model_limits},
)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"file_content",
[
_ENQUEUED_BATCH_FILE_CONTENT,
_MODEL_LESS_BATCH_FILE_CONTENT,
_ALIASED_BATCH_FILE_CONTENT,
_UNLISTED_MODEL_BATCH_FILE_CONTENT,
],
ids=[
"rows name the model",
"rows name no model",
"rows name an alias of the model",
"rows name an unlisted model",
],
)
async def test_project_model_enqueued_limit_rejects_a_batch_over_that_models_limit(file_content):
rate_limiter, local_cache = _enqueued_rate_limiter({"mini-alias": "gpt-4o-mini"}.get)
afile_content_mock, patches = _enqueued_batch_patches(file_content)
with patches[0], patches[1], patches[2], patch("litellm.afile_content", new=afile_content_mock):
with pytest.raises(HTTPException) as exc:
await rate_limiter.async_pre_call_hook(
user_api_key_dict=_project_model_limited_user({"gpt-4o-mini": 10}),
cache=local_cache,
data={"input_file_id": "file-abc123", "model": "gpt-4o-mini"},
call_type="acreate_batch",
)
assert exc.value.status_code == 429
assert "Batch enqueued token limit exceeded for project_model: project-enqueued-batch:gpt-4o-mini" in str(
exc.value.detail
)
@pytest.mark.asyncio
async def test_project_model_enqueued_limit_reserves_only_the_limited_models_rows():
from litellm.proxy.hooks.parallel_request_limiter_v3 import get_request_stash
rate_limiter, local_cache = _enqueued_rate_limiter()
afile_content_mock, patches = _enqueued_batch_patches(_MIXED_MODEL_BATCH_FILE_CONTENT)
with patches[0], patches[1], patches[2], patch("litellm.afile_content", new=afile_content_mock):
data: Final = {"input_file_id": "file-abc123", "model": "gpt-4o-mini"}
await rate_limiter.async_pre_call_hook(
user_api_key_dict=_project_model_limited_user({"gpt-5.5": 100000}),
cache=local_cache,
data=data,
call_type="acreate_batch",
)
stash: Final = get_request_stash()
assert stash is not None and stash.batch_enqueued_reservation is not None
(scope,) = stash.batch_enqueued_reservation.scopes
assert scope.value == "project-enqueued-batch:gpt-5.5"
assert scope.tokens is not None
assert 0 < scope.tokens < data["_batch_token_count"]
@pytest.mark.asyncio
async def test_project_model_enqueued_limit_for_another_model_leaves_the_batch_on_per_minute_limits():
from litellm.proxy.hooks.parallel_request_limiter_v3 import get_request_stash
rate_limiter, local_cache = _enqueued_rate_limiter()
afile_content_mock, patches = _enqueued_batch_patches()
with patches[0], patches[1], patches[2], patch("litellm.afile_content", new=afile_content_mock):
data: Final = {"input_file_id": "file-abc123", "model": "gpt-4o-mini"}
result: Final = await rate_limiter.async_pre_call_hook(
user_api_key_dict=_project_model_limited_user({"gpt-5.5": 10}),
cache=local_cache,
data=data,
call_type="acreate_batch",
)
assert result is data
assert data["_batch_token_count"] > 10
stash: Final = get_request_stash()
assert stash is None or stash.batch_enqueued_reservation is None
@pytest.mark.asyncio
async def test_per_minute_limits_still_apply_when_only_a_rows_model_has_a_project_allowance():
from litellm.proxy.hooks.batch_enqueued_tokens import BatchEnqueuedTokenScope
rate_limiter, local_cache = _enqueued_rate_limiter()
afile_content_mock, patches = _enqueued_batch_patches(_MIXED_MODEL_BATCH_FILE_CONTENT)
allowance: Final = 1000000
with patches[0], patches[1], patches[2], patch("litellm.afile_content", new=afile_content_mock):
per_minute_capped: Final = _project_model_limited_user({"gpt-5.5": allowance}).model_copy(
update={"rpm_limit": 1, "tpm_limit": 10}
)
with pytest.raises(HTTPException) as exc:
await rate_limiter.async_pre_call_hook(
user_api_key_dict=per_minute_capped,
cache=local_cache,
data={"input_file_id": "file-abc123", "model": "gpt-4o-mini"},
call_type="acreate_batch",
)
assert exc.value.status_code == 429
assert "Batch enqueued token limit" not in str(exc.value.detail)
whole_allowance: Final = BatchEnqueuedTokenScope(
key="project_model", value="project-enqueued-batch:gpt-5.5", limit=allowance, tokens=allowance
)
refilled: Final = await rate_limiter.parallel_request_limiter.batch_enqueued_token_store.reserve(
tokens=allowance, scopes=(whole_allowance,)
)
assert refilled.scopes == (whole_allowance,)
@pytest.mark.asyncio
async def test_a_project_allowance_for_the_routing_model_replaces_per_minute_limits():
from litellm.proxy.hooks.parallel_request_limiter_v3 import get_request_stash
rate_limiter, local_cache = _enqueued_rate_limiter()
afile_content_mock, patches = _enqueued_batch_patches()
with patches[0], patches[1], patches[2], patch("litellm.afile_content", new=afile_content_mock):
per_minute_capped: Final = _project_model_limited_user({"gpt-4o-mini": 1000000}).model_copy(
update={"rpm_limit": 1, "tpm_limit": 10}
)
data: Final = {"input_file_id": "file-abc123", "model": "gpt-4o-mini"}
result: Final = await rate_limiter.async_pre_call_hook(
user_api_key_dict=per_minute_capped,
cache=local_cache,
data=data,
call_type="acreate_batch",
)
assert result is data
stash: Final = get_request_stash()
assert stash is not None and stash.batch_enqueued_reservation is not None
assert stash.batch_enqueued_reservation.tokens == data["_batch_token_count"] > 10
@pytest.mark.asyncio
async def test_a_routing_model_allowance_keeps_project_input_limits_on_the_other_row_models():
from litellm.proxy.hooks.batch_enqueued_tokens import BatchEnqueuedTokenScope
rate_limiter, local_cache = _enqueued_rate_limiter()
afile_content_mock, patches = _enqueued_batch_patches(_MIXED_MODEL_BATCH_FILE_CONTENT)
allowance: Final = 1000000
user: Final = _project_model_limited_user({"gpt-4o-mini": allowance}).model_copy(
update={
"project_metadata": {
"model_batch_enqueued_token_limit": {"gpt-4o-mini": allowance},
"model_itpm_limit": {"gpt-5.5": 1},
}
}
)
with patches[0], patches[1], patches[2], patch("litellm.afile_content", new=afile_content_mock):
with pytest.raises(HTTPException) as exc:
await rate_limiter.async_pre_call_hook(
user_api_key_dict=user,
cache=local_cache,
data={"input_file_id": "file-abc123", "model": "gpt-4o-mini"},
call_type="acreate_batch",
)
assert exc.value.status_code == 429
assert "model_per_project_itpm" in str(exc.value.detail)
whole_allowance: Final = BatchEnqueuedTokenScope(
key="project_model", value="project-enqueued-batch:gpt-4o-mini", limit=allowance, tokens=allowance
)
refilled: Final = await rate_limiter.parallel_request_limiter.batch_enqueued_token_store.reserve(
tokens=allowance, scopes=(whole_allowance,)
)
assert refilled.scopes == (whole_allowance,)
@pytest.mark.asyncio
async def test_daily_token_limit_still_applies_alongside_a_project_model_reservation():
rate_limiter, local_cache = _enqueued_rate_limiter()
afile_content_mock, patches = _enqueued_batch_patches()
with patches[0], patches[1], patches[2], patch("litellm.afile_content", new=afile_content_mock):
daily_capped: Final = _project_model_limited_user({"gpt-4o-mini": 1000000}).model_copy(update={"tpd_limit": 10})
rejected_data: Final = {"input_file_id": "file-abc123", "model": "gpt-4o-mini"}
with pytest.raises(HTTPException) as exc:
await rate_limiter.async_pre_call_hook(
user_api_key_dict=daily_capped,
cache=local_cache,
data=rejected_data,
call_type="acreate_batch",
)
assert exc.value.status_code == 429
assert "Batch enqueued token limit" not in str(exc.value.detail)
exact_fit: Final = _project_model_limited_user({"gpt-4o-mini": rejected_data["_batch_token_count"]})
accepted_data: Final = {"input_file_id": "file-abc123", "model": "gpt-4o-mini"}
result: Final = await rate_limiter.async_pre_call_hook(
user_api_key_dict=exact_fit,
cache=local_cache,
data=accepted_data,
call_type="acreate_batch",
)
assert result is accepted_data
@pytest.mark.asyncio
@pytest.mark.parametrize(
"opted_in_user",
[
UserAPIKeyAuth(
api_key="sk-enqueued-daily", models=["*"], tpd_limit=10, metadata={"batch_enqueued_token_limit": 1000000}
),
UserAPIKeyAuth(
api_key="sk-team-enqueued-daily",
models=["*"],
team_id="team-enqueued-daily",
team_tpd_limit=10,
team_metadata={"batch_enqueued_token_limit": 1000000},
),
],
ids=["key", "team"],
)
async def test_key_or_team_enqueued_limit_alone_governs_a_batch_over_its_daily_cap(opted_in_user):
rate_limiter, local_cache = _enqueued_rate_limiter()
afile_content_mock, patches = _enqueued_batch_patches()
with patches[0], patches[1], patches[2], patch("litellm.afile_content", new=afile_content_mock):
data: Final = {"input_file_id": "file-abc123", "model": "gpt-4o-mini"}
result: Final = await rate_limiter.async_pre_call_hook(
user_api_key_dict=opted_in_user,
cache=local_cache,
data=data,
call_type="acreate_batch",
)
assert result is data
assert data["_batch_token_count"] > 10
@pytest.mark.asyncio
async def test_a_model_bound_upload_still_charges_the_model_its_rows_name():
from litellm.proxy.openai_files_endpoints.common_utils import encode_file_id_with_model
rate_limiter, local_cache = _enqueued_rate_limiter()
afile_content_mock, patches = _enqueued_batch_patches()
with patches[0], patches[1], patches[2], patch("litellm.afile_content", new=afile_content_mock):
with pytest.raises(HTTPException) as exc:
await rate_limiter.async_pre_call_hook(
user_api_key_dict=_project_model_limited_user({"gpt-4o-mini-priority": 1000000, "gpt-4o-mini": 10}),
cache=local_cache,
data={"input_file_id": encode_file_id_with_model("file-abc123", "gpt-4o-mini-priority")},
call_type="acreate_batch",
)
assert exc.value.status_code == 429
assert "Batch enqueued token limit exceeded for project_model: project-enqueued-batch:gpt-4o-mini." in str(
exc.value.detail
)
@pytest.mark.asyncio
async def test_project_model_enqueued_limit_applies_to_a_batch_that_names_a_deployment_id():
rate_limiter, local_cache = _enqueued_rate_limiter(deployment_model_name={"deployment-1": "gpt-4o-mini"}.get)
afile_content_mock, patches = _enqueued_batch_patches(_MODEL_LESS_BATCH_FILE_CONTENT)
with patches[0], patches[1], patches[2], patch("litellm.afile_content", new=afile_content_mock):
with pytest.raises(HTTPException) as exc:
await rate_limiter.async_pre_call_hook(
user_api_key_dict=_project_model_limited_user({"gpt-4o-mini": 10}),
cache=local_cache,
data={"input_file_id": "file-abc123", "model": "deployment-1"},
call_type="acreate_batch",
)
assert exc.value.status_code == 429
assert "Batch enqueued token limit exceeded for project_model: project-enqueued-batch:gpt-4o-mini" in str(
exc.value.detail
)
@pytest.mark.asyncio
async def test_disable_flag_still_skips_batch_processing_with_enqueued_limits():
rate_limiter, local_cache = _enqueued_rate_limiter()

View file

@ -6,6 +6,7 @@ batch under a per-minute RPM/TPM budget. Scopes that configure `tpd_limit`
are charged against a 24h token window instead of their minute counters.
"""
import base64
import json
import time
from collections.abc import Callable, Iterator, Sequence
@ -555,18 +556,13 @@ def _serve_file_to_the_deployment_key(rows: Sequence[_BatchLine]) -> Callable[[h
return respond
@pytest.mark.asyncio
@pytest.mark.parametrize(
"query_string, routing_headers",
[(b"model=routed-batch-model", ()), (b"", ((b"x-litellm-model", b"routed-batch-model"),))],
ids=["query", "header"],
)
async def test_model_routed_batch_is_counted_with_the_deployment_key_not_the_request_key(
async def _model_routed_batch_data(
openai_files: respx.MockRouter,
monkeypatch: pytest.MonkeyPatch,
query_string: bytes,
routing_headers: tuple[tuple[bytes, bytes], ...],
):
user_api_key_dict: UserAPIKeyAuth,
) -> tuple[dict[str, object], respx.Route]:
from starlette.requests import Request
from litellm import Router
@ -593,7 +589,6 @@ async def test_model_routed_batch_is_counted_with_the_deployment_key_not_the_req
file_route: Final = openai_files.get(_ROUTED_FILE_URL).mock(
side_effect=_serve_file_to_the_deployment_key(_batch_rows(("Hello " * 50,)))
)
user_api_key_dict: Final = UserAPIKeyAuth(api_key=hash_token("sk-batch"), tpm_limit=1)
request: Final = Request(
{
"type": "http",
@ -620,6 +615,28 @@ async def test_model_routed_batch_is_counted_with_the_deployment_key_not_the_req
proxy_config=MagicMock(),
general_settings={},
)
return data, file_route
_MODEL_ROUTES: Final = pytest.mark.parametrize(
"query_string, routing_headers",
[(b"model=routed-batch-model", ()), (b"", ((b"x-litellm-model", b"routed-batch-model"),))],
ids=["query", "header"],
)
@pytest.mark.asyncio
@_MODEL_ROUTES
async def test_model_routed_batch_is_counted_with_the_deployment_key_not_the_request_key(
openai_files: respx.MockRouter,
monkeypatch: pytest.MonkeyPatch,
query_string: bytes,
routing_headers: tuple[tuple[bytes, bytes], ...],
):
user_api_key_dict: Final = UserAPIKeyAuth(api_key=hash_token("sk-batch"), tpm_limit=1)
data, file_route = await _model_routed_batch_data(
openai_files, monkeypatch, query_string, routing_headers, user_api_key_dict
)
_, _, batch_limiter = _make_limiters()
with pytest.raises(HTTPException) as exc:
@ -631,8 +648,37 @@ async def test_model_routed_batch_is_counted_with_the_deployment_key_not_the_req
assert file_route.call_count == 1
@pytest.mark.asyncio
@_MODEL_ROUTES
async def test_model_routed_batch_is_charged_to_the_project_cap_of_the_model_it_routes_to(
openai_files: respx.MockRouter,
monkeypatch: pytest.MonkeyPatch,
query_string: bytes,
routing_headers: tuple[tuple[bytes, bytes], ...],
):
user_api_key_dict: Final = UserAPIKeyAuth(
api_key=hash_token("sk-batch"),
project_id="project-1",
project_metadata={"model_batch_enqueued_token_limit": {"routed-batch-model": 1}},
)
data, _ = await _model_routed_batch_data(openai_files, monkeypatch, query_string, routing_headers, user_api_key_dict)
_, _, batch_limiter = _make_limiters()
with pytest.raises(HTTPException) as exc:
await batch_limiter.async_pre_call_hook(
user_api_key_dict=user_api_key_dict, cache=DualCache(), data=data, call_type="acreate_batch"
)
assert exc.value.status_code == 429
assert "project_model: project-1:routed-batch-model" in str(exc.value.detail)
async def _install_managed_file_hook(
openai_files: respx.MockRouter, monkeypatch: pytest.MonkeyPatch, rows: str
openai_files: respx.MockRouter,
monkeypatch: pytest.MonkeyPatch,
rows: Sequence[_BatchLine],
file_id: str = _MANAGED_FILE_ID,
model_name: str = _BATCH_MODEL,
) -> tuple[respx.Route, respx.Route]:
from litellm_enterprise.proxy.hooks.managed_files import _PROXY_LiteLLMManagedFiles
@ -642,18 +688,18 @@ async def _install_managed_file_hook(
from litellm.proxy.openai_files_endpoints.common_utils import is_base64_encoded_unified_file_id
from litellm.proxy.utils import ProxyLogging
assert is_base64_encoded_unified_file_id(_MANAGED_FILE_ID)
assert is_base64_encoded_unified_file_id(file_id)
provider_route: Final = _serve_file(openai_files, "file-provider-1", rows)
unrouted_managed_read: Final = _serve_file(openai_files, _MANAGED_FILE_ID, rows)
unrouted_managed_read: Final = _serve_file(openai_files, file_id, rows)
row: Final = LiteLLM_ManagedFileTable(
unified_file_id=_MANAGED_FILE_ID,
unified_file_id=file_id,
model_mappings={"deployment-1": "file-provider-1"},
flat_model_file_ids=["file-provider-1"],
created_by="test-user-regression",
team_id="team-owner",
)
file_cache: Final = InternalUsageCache(dual_cache=DualCache())
await file_cache.async_set_cache(key=_MANAGED_FILE_ID, value=row.model_dump(), litellm_parent_otel_span=None)
await file_cache.async_set_cache(key=file_id, value=row.model_dump(), litellm_parent_otel_span=None)
prisma_client: Final = MagicMock()
prisma_client.db.litellm_managedfiletable.find_first = AsyncMock(return_value=row)
proxy_logging: Final = ProxyLogging(user_api_key_cache=DualCache())
@ -663,7 +709,7 @@ async def _install_managed_file_hook(
router: Final = Router(
model_list=[
{
"model_name": _BATCH_MODEL,
"model_name": model_name,
"litellm_params": {"model": f"openai/{_BATCH_MODEL}", "api_key": "sk-test"},
"model_info": {"id": "deployment-1"},
}
@ -674,6 +720,72 @@ async def _install_managed_file_hook(
return provider_route, unrouted_managed_read
@pytest.mark.asyncio
async def test_a_managed_batch_file_charges_only_the_project_cap_of_the_model_it_routes_to(
openai_files: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
):
from litellm.proxy.hooks.parallel_request_limiter_v3 import get_request_stash
priority_group: Final = f"{_BATCH_MODEL}-priority"
managed_file_id: Final = base64.urlsafe_b64encode(
f"litellm_proxy:application/octet-stream;unified_id,priority-batch;target_model_names,{priority_group}".encode()
).decode()
await _install_managed_file_hook(
openai_files, monkeypatch, _batch_rows(("Hello",) * 3), file_id=managed_file_id, model_name=priority_group
)
_, _, batch_limiter = _make_limiters()
data: Final = {"input_file_id": managed_file_id}
result: Final = await batch_limiter.async_pre_call_hook(
user_api_key_dict=UserAPIKeyAuth(
api_key="test-key-regression",
user_id="test-user-regression",
team_id="team-owner",
project_id="priority-project",
project_metadata={"model_batch_enqueued_token_limit": {priority_group: 1000000, _BATCH_MODEL: 1}},
),
cache=DualCache(),
data=data,
call_type="acreate_batch",
)
assert result is data
stash: Final = get_request_stash()
assert stash is not None and stash.batch_enqueued_reservation is not None
assert [scope.value for scope in stash.batch_enqueued_reservation.scopes] == [f"priority-project:{priority_group}"]
@pytest.mark.asyncio
async def test_a_project_cap_on_the_routed_model_lifts_per_minute_limits_off_a_managed_batch_file(
openai_files: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
):
priority_group: Final = f"{_BATCH_MODEL}-priority"
managed_file_id: Final = base64.urlsafe_b64encode(
f"litellm_proxy:application/octet-stream;unified_id,priority-minute;target_model_names,{priority_group}".encode()
).decode()
await _install_managed_file_hook(
openai_files, monkeypatch, _batch_rows(("Hello",) * 3), file_id=managed_file_id, model_name=priority_group
)
_, _, batch_limiter = _make_limiters()
data: Final = {"input_file_id": managed_file_id}
result: Final = await batch_limiter.async_pre_call_hook(
user_api_key_dict=UserAPIKeyAuth(
api_key="test-key-regression",
user_id="test-user-regression",
team_id="team-owner",
tpm_limit=1,
project_id="priority-project",
project_metadata={"model_batch_enqueued_token_limit": {priority_group: 1000000}},
),
cache=DualCache(),
data=data,
call_type="acreate_batch",
)
assert result is data
@pytest.mark.asyncio
async def test_managed_file_is_read_through_the_managed_files_hook_with_user_context(
openai_files: respx.MockRouter, monkeypatch: pytest.MonkeyPatch