fix(batches): skip unnecessary batch input file reads (#29114)
* fix(batches): skip unnecessary batch input file reads Skip expensive pre-read of batch input files when no batch limits apply and model allowlist checks are not required, and decode model-embedded file IDs before file-content fetches to prevent upstream 404s. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(batch-rate-limiter): prevent user metadata flag from bypassing model allowlist The skip_batch_input_file_rate_limiting flag in litellm_metadata is user-controllable for batch requests (request-body metadata lands in litellm_metadata via LITELLM_METADATA_ROUTES). Honoring it unconditionally also skipped _enforce_batch_file_model_access, letting a restricted key submit a JSONL referencing models outside its allowlist. Only honor the metadata-based skip when the key has no model allowlist to enforce. Co-authored-by: Yassin Kortam <yassin@berri.ai> * fix(batch_rate_limiter): enforce model access check before honoring skip paths Admin-configured skips (disable_batch_input_file_rate_limiting, skip_batch_input_file_rate_limiting_for_models/_for_providers) and the no-applicable-rate-limits short-circuit previously bypassed _enforce_batch_file_model_access. A key with a restricted model allowlist could therefore submit a batch JSONL referencing models outside its allowlist whenever any of these skip paths fired, and the provider-skip path was attacker-controllable via the request body's custom_llm_provider field. Hoist the model-access guard to the top so restricted keys always have their JSONL validated regardless of which skip would otherwise apply. Co-authored-by: Yassin Kortam <yassin@berri.ai> * fix(batch_rate_limiter): wildcard model bypass + fail-open embedded model creds - _key_requires_batch_model_access_check: check '*' / all-proxy-models before access_group_ids so wildcard keys skip the JSONL download. - _resolve_batch_input_file_fetch_params: wrap embedded-model get_credentials_for_model in try/except HTTPException, mirroring the request-model fallback path, and always decode the file id. Co-authored-by: Yassin Kortam <yassin@berri.ai> * perf(batch_rate_limiter): reuse rate-limit descriptors across skip check and counter increment * test(batch_rate_limiter): cover skip-path and file-fetch helpers Add unit tests for the batch rate limiter's new skip/routing helpers so the diff's patch coverage no longer depends on the CircleCI batches job, whose coverage upload is blocked when an unrelated Bedrock integration test aborts the run. Covers _get_batch_routing_model, _matches_skip_list, _key_requires_batch_model_access_check, _has_applicable_batch_rate_limits, _should_skip_batch_input_file_processing, _resolve_batch_input_file_fetch_params, the descriptor-reuse path of _check_and_increment_batch_counters, and the non-bytes file content guard in count_input_file_usage. * fix(batch_rate_limiter): resolve provider skip from trusted deployment creds Resolve the batch provider from router deployment credentials instead of the user-supplied custom_llm_provider request field, so an unrestricted key cannot spoof a skip-listed provider to bypass batch rate limiting. Strengthen the provider-skip test to assert the file download and descriptor work were short-circuited, and add a test that a spoofed provider still falls through to rate-limit evaluation. * fix(batch_rate_limiter): guard model-embedded credential lookup on llm_router presence * test(batch_rate_limiter): drive real no-skip fetch path and pin wildcard+access-group predicate The spoofed-provider test configured empty descriptors, so the no-limits shortcut skipped the file fetch and the assertion only proved the provider allow-list did not short-circuit before descriptor evaluation. Give the key an applicable rate limit so the only thing that can prevent the fetch is the provider skip, then assert afile_content is awaited and the counters are incremented; the spoofed custom_llm_provider must not skip processing. Also cover the wildcard / all-proxy-models plus access_group_ids combination in the model-access predicate so the wildcard-wins behavior is locked down. * fix(batch_rate_limiter): drop client-controlled skip flag to close quota bypass The litellm_metadata.skip_batch_input_file_rate_limiting flag was read straight from the request body, so any caller whose key had unrestricted model access could send it and skip the input-file download, token count, and RPM/TPM reservation, bypassing their batch rate limits. Skip decisions now derive only from server-controlled general_settings. * fix(batch_rate_limiter): match per-model skip on file-bound model only The per-model skip resolved its model from _get_batch_routing_model, which prefers the client-supplied top-level model field. That field only selects routing credentials; the models a batch actually runs are the body.model entries in the input JSONL. An unrestricted key could therefore name a skip-listed deployment at the top level while routing a different, same-provider model through the file, skipping the download, token count and rate-limit reservation to bypass batch RPM/TPM limits. Match the per-model skip against the file-bound model only (model-embedded file id or unified managed file target), which is fixed when the file is created and reflects the model the batch runs. The provider skip keeps using the routing model since an admin opting out of a whole provider already accepts any of that provider's models. * fix(batch_rate_limiter): drop forgeable per-model skip to close quota bypass The per-model skip matched skip_batch_input_file_rate_limiting_for_models against the model bound to the input file id. That model comes from decode_model_from_file_id / the unified file id, both unsigned base64 the caller fully controls, so a caller could re-encode an accessible provider file id with a skip-listed model while the JSONL still routes rate-limited body.model entries and bypass the batch RPM/TPM counters. The models a batch actually runs are its JSONL body.model entries, which cannot be known without reading the file, so no caller-influenced model identifier can safely gate a skip. Remove the per-model skip entirely. The provider skip stays because the provider is resolved from trusted deployment credentials and the batch is constrained to run on that provider; the global disable and no-applicable-limits skips stay because they do not depend on caller input. * fix(batch_rate_limiter): warn when no-op per-model skip key is configured * test(batch_rate_limiter): patch llm_router so model-embedded credential-error test hits fallback * fix(batch_rate_limiter): resolve provider skip from file-bound model create_batch routes a model-embedded or unified file id on the model bound to that file and ignores the top-level model, so deriving the provider skip from the top-level model first let a caller point model at a skip-listed provider while the file routed a rate-limited one, skipping counter enforcement. Resolve the routing model from the file binding first, matching the batch endpoint. --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Yassin Kortam <yassin@berri.ai> Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
This commit is contained in:
parent
609e1e9763
commit
c233cbbc2a
@ -17,7 +17,7 @@ Quick summary:
|
||||
- async_log_success_event() fires on GET /v1/batches/{id} (batch completion)
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Union
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Tuple, Union
|
||||
|
||||
from fastapi import HTTPException
|
||||
from pydantic import BaseModel
|
||||
@ -25,12 +25,13 @@ from pydantic import BaseModel
|
||||
import litellm
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.batches.batch_utils import (
|
||||
_extract_file_access_credentials,
|
||||
_get_batch_job_input_file_usage,
|
||||
_get_file_content_as_dictionary,
|
||||
_get_models_from_batch_input_file_content,
|
||||
)
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
from litellm.proxy._types import SpecialModelNames, UserAPIKeyAuth
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from opentelemetry.trace import Span as _Span
|
||||
@ -97,6 +98,276 @@ class _PROXY_BatchRateLimiter(CustomLogger):
|
||||
"""
|
||||
self.internal_usage_cache = internal_usage_cache
|
||||
self.parallel_request_limiter = parallel_request_limiter
|
||||
self._warned_unsupported_model_skip = False
|
||||
|
||||
def _get_file_bound_batch_model(self, data: Dict) -> Optional[str]:
|
||||
"""Resolve the model bound to the batch input file ID.
|
||||
|
||||
``create_batch`` routes a file-bound id (model-embedded ``file-...`` or
|
||||
unified managed file) on that bound model and ignores the top-level
|
||||
``model``, so this is the authoritative routing model whenever the file
|
||||
binds one. The provider is then read from that deployment's trusted
|
||||
credentials for the provider-level skip decision.
|
||||
"""
|
||||
input_file_id = data.get("input_file_id")
|
||||
if not isinstance(input_file_id, str) or not input_file_id:
|
||||
return None
|
||||
|
||||
from litellm.proxy.openai_files_endpoints.common_utils import (
|
||||
_is_base64_encoded_unified_file_id,
|
||||
decode_model_from_file_id,
|
||||
get_models_from_unified_file_id,
|
||||
)
|
||||
|
||||
model_from_file_id = decode_model_from_file_id(input_file_id)
|
||||
if model_from_file_id:
|
||||
return model_from_file_id
|
||||
|
||||
unified_file_id = _is_base64_encoded_unified_file_id(input_file_id)
|
||||
if unified_file_id:
|
||||
target_model_names = get_models_from_unified_file_id(unified_file_id)
|
||||
if target_model_names:
|
||||
return target_model_names[0]
|
||||
|
||||
return None
|
||||
|
||||
def _get_batch_routing_model(self, data: Dict) -> Optional[str]:
|
||||
"""Resolve the deployment/model used for this batch from request data.
|
||||
|
||||
Mirrors ``create_batch`` routing precedence: a model bound to the input
|
||||
file id wins over the top-level ``model``, because the batch endpoint
|
||||
ignores the top-level model for file-bound ids. Resolving the provider
|
||||
skip from the top-level model first would let a caller point ``model``
|
||||
at a skip-listed provider while the file routes a rate-limited one.
|
||||
"""
|
||||
file_bound_model = self._get_file_bound_batch_model(data)
|
||||
if file_bound_model:
|
||||
return file_bound_model
|
||||
|
||||
model = data.get("model")
|
||||
if isinstance(model, str) and model:
|
||||
return model
|
||||
|
||||
return None
|
||||
|
||||
def _resolve_batch_provider(self, batch_model: Optional[str]) -> Optional[str]:
|
||||
"""Resolve the provider from the deployment that serves ``batch_model``.
|
||||
|
||||
The provider is read from trusted router credentials rather than the
|
||||
user-supplied ``custom_llm_provider`` request field, so a caller cannot
|
||||
spoof a skip-listed provider to bypass batch rate limiting.
|
||||
"""
|
||||
if not batch_model:
|
||||
return None
|
||||
|
||||
from litellm.proxy.openai_files_endpoints.common_utils import (
|
||||
get_credentials_for_model,
|
||||
)
|
||||
from litellm.proxy.proxy_server import llm_router
|
||||
|
||||
if llm_router is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
credentials = get_credentials_for_model(
|
||||
llm_router=llm_router,
|
||||
model_id=batch_model,
|
||||
operation_context="batch input file read (rate limiting)",
|
||||
)
|
||||
except HTTPException:
|
||||
return None
|
||||
|
||||
provider = credentials.get("custom_llm_provider")
|
||||
return provider if isinstance(provider, str) and provider else None
|
||||
|
||||
def _create_batch_rate_limit_descriptors(
|
||||
self,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
data: Dict,
|
||||
) -> List["RateLimitDescriptor"]:
|
||||
return self.parallel_request_limiter._create_rate_limit_descriptors(
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
data=data,
|
||||
rpm_limit_type=None,
|
||||
tpm_limit_type=None,
|
||||
model_has_failures=False,
|
||||
)
|
||||
|
||||
def _should_skip_batch_input_file_processing(
|
||||
self,
|
||||
data: Dict,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
) -> Tuple[bool, Optional[List["RateLimitDescriptor"]]]:
|
||||
"""
|
||||
Skip downloading batch input files when the operator disabled batch
|
||||
input-file rate limiting, when the batch runs entirely on a skip-listed
|
||||
provider, or when there is nothing to enforce (no applicable rate
|
||||
limits).
|
||||
|
||||
A skip is only honored for keys with unrestricted model access. When
|
||||
the key has a model allowlist, the JSONL must still be downloaded so
|
||||
``_enforce_batch_file_model_access`` can validate every ``body.model``
|
||||
entry, otherwise a restricted key could smuggle unauthorized models
|
||||
into the file via an admin-configured skip.
|
||||
|
||||
The skip is never keyed on a specific model name. The models a batch
|
||||
actually runs are its JSONL ``body.model`` entries, and any model
|
||||
identifier the caller can influence (the top-level ``model`` or the
|
||||
unsigned model embedded in a ``file-...`` id) can be pointed at a
|
||||
skip-listed deployment while the file routes a different, rate-limited
|
||||
model. The provider skip is safe because the provider is read from the
|
||||
routing deployment's trusted credentials and the batch is constrained
|
||||
to run on that provider.
|
||||
|
||||
Returns ``(should_skip, descriptors)`` where ``descriptors`` is the
|
||||
rate-limit descriptor list computed for the no-limits check, so the
|
||||
caller can reuse it for counter enforcement without recomputing.
|
||||
"""
|
||||
from litellm.proxy.proxy_server import general_settings
|
||||
|
||||
self._warn_if_unsupported_model_skip_configured(general_settings)
|
||||
|
||||
if self._key_requires_batch_model_access_check(user_api_key_dict):
|
||||
return False, None
|
||||
|
||||
if general_settings.get("disable_batch_input_file_rate_limiting") is True:
|
||||
return True, None
|
||||
|
||||
skip_providers = (
|
||||
general_settings.get("skip_batch_input_file_rate_limiting_for_providers")
|
||||
or []
|
||||
)
|
||||
if skip_providers:
|
||||
batch_provider = self._resolve_batch_provider(
|
||||
self._get_batch_routing_model(data)
|
||||
)
|
||||
if batch_provider and batch_provider in skip_providers:
|
||||
verbose_proxy_logger.debug(
|
||||
f"Skipping batch input file processing for provider={batch_provider}"
|
||||
)
|
||||
return True, None
|
||||
|
||||
descriptors = self._create_batch_rate_limit_descriptors(
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
data=data,
|
||||
)
|
||||
if not self._has_applicable_batch_rate_limits(descriptors):
|
||||
verbose_proxy_logger.debug(
|
||||
"Skipping batch input file processing: no rate limits configured"
|
||||
)
|
||||
return True, None
|
||||
|
||||
return False, descriptors
|
||||
|
||||
def _warn_if_unsupported_model_skip_configured(
|
||||
self, general_settings: Dict
|
||||
) -> None:
|
||||
"""Warn once that ``skip_batch_input_file_rate_limiting_for_models`` is a no-op.
|
||||
|
||||
A per-model skip is intentionally not honored because the model a batch
|
||||
runs on is caller-influenced and can be pointed at a skip-listed
|
||||
deployment while the JSONL routes a different, rate-limited model.
|
||||
"""
|
||||
if self._warned_unsupported_model_skip:
|
||||
return
|
||||
if general_settings.get("skip_batch_input_file_rate_limiting_for_models"):
|
||||
self._warned_unsupported_model_skip = True
|
||||
verbose_proxy_logger.warning(
|
||||
"general_settings.skip_batch_input_file_rate_limiting_for_models is not "
|
||||
"supported and has no effect. Use "
|
||||
"skip_batch_input_file_rate_limiting_for_providers or "
|
||||
"disable_batch_input_file_rate_limiting instead."
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _key_requires_batch_model_access_check(
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
) -> bool:
|
||||
"""True when the key may only call a subset of models (JSONL must be checked)."""
|
||||
models = user_api_key_dict.models or []
|
||||
if "*" in models:
|
||||
return False
|
||||
if SpecialModelNames.all_proxy_models.value in models:
|
||||
return False
|
||||
if user_api_key_dict.access_group_ids:
|
||||
return True
|
||||
if not models:
|
||||
return False
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _has_applicable_batch_rate_limits(
|
||||
descriptors: List["RateLimitDescriptor"],
|
||||
) -> bool:
|
||||
for descriptor in descriptors:
|
||||
rate_limit = descriptor.get("rate_limit") or {}
|
||||
if (
|
||||
rate_limit.get("requests_per_unit") is not None
|
||||
or rate_limit.get("tokens_per_unit") is not None
|
||||
or rate_limit.get("max_parallel_requests") is not None
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _resolve_batch_input_file_fetch_params(
|
||||
self,
|
||||
file_id: str,
|
||||
custom_llm_provider: str,
|
||||
data: Dict,
|
||||
) -> Tuple[str, Dict[str, Any]]:
|
||||
"""
|
||||
Map proxy-facing file IDs to provider file IDs and credentials.
|
||||
|
||||
Model-embedded IDs (``file-<base64>``) are not unified managed-file IDs;
|
||||
without decoding them, ``afile_content`` is called with the encoded ID
|
||||
and the upstream provider returns 404.
|
||||
"""
|
||||
from litellm.proxy.openai_files_endpoints.common_utils import (
|
||||
decode_model_from_file_id,
|
||||
get_credentials_for_model,
|
||||
get_original_file_id,
|
||||
)
|
||||
from litellm.proxy.proxy_server import llm_router
|
||||
|
||||
fetch_kwargs: Dict[str, Any] = {
|
||||
"custom_llm_provider": custom_llm_provider,
|
||||
}
|
||||
|
||||
model_from_file_id = decode_model_from_file_id(file_id)
|
||||
if model_from_file_id:
|
||||
if llm_router is not None:
|
||||
try:
|
||||
credentials = get_credentials_for_model(
|
||||
llm_router=llm_router,
|
||||
model_id=model_from_file_id,
|
||||
operation_context="batch input file read (rate limiting)",
|
||||
)
|
||||
fetch_kwargs.update(_extract_file_access_credentials(credentials))
|
||||
fetch_kwargs["model"] = model_from_file_id
|
||||
provider = credentials.get("custom_llm_provider")
|
||||
if provider:
|
||||
fetch_kwargs["custom_llm_provider"] = provider
|
||||
except HTTPException:
|
||||
pass
|
||||
return get_original_file_id(file_id), fetch_kwargs
|
||||
|
||||
request_model = data.get("model")
|
||||
if isinstance(request_model, str) and request_model and llm_router is not None:
|
||||
try:
|
||||
credentials = get_credentials_for_model(
|
||||
llm_router=llm_router,
|
||||
model_id=request_model,
|
||||
operation_context="batch input file read (rate limiting)",
|
||||
)
|
||||
fetch_kwargs.update(_extract_file_access_credentials(credentials))
|
||||
fetch_kwargs["model"] = request_model
|
||||
provider = credentials.get("custom_llm_provider")
|
||||
if provider:
|
||||
fetch_kwargs["custom_llm_provider"] = provider
|
||||
except HTTPException:
|
||||
pass
|
||||
|
||||
return file_id, fetch_kwargs
|
||||
|
||||
def _raise_rate_limit_error(
|
||||
self,
|
||||
@ -163,6 +434,7 @@ class _PROXY_BatchRateLimiter(CustomLogger):
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
data: Dict,
|
||||
batch_usage: BatchFileUsage,
|
||||
descriptors: Optional[List["RateLimitDescriptor"]] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Atomically check + increment rate-limit counters by the batch amounts.
|
||||
@ -171,14 +443,15 @@ class _PROXY_BatchRateLimiter(CustomLogger):
|
||||
case no counter is modified. Backed by `atomic_check_and_increment_by_n`
|
||||
which uses a Redis Lua script when available (multi-process atomic) and
|
||||
falls back to a per-process asyncio.Lock + in-memory operation.
|
||||
|
||||
``descriptors`` may be passed in by the pre-call hook to reuse the list
|
||||
already computed when deciding whether to skip file processing.
|
||||
"""
|
||||
descriptors = self.parallel_request_limiter._create_rate_limit_descriptors(
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
data=data,
|
||||
rpm_limit_type=None,
|
||||
tpm_limit_type=None,
|
||||
model_has_failures=False,
|
||||
)
|
||||
if descriptors is None:
|
||||
descriptors = self._create_batch_rate_limit_descriptors(
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
data=data,
|
||||
)
|
||||
|
||||
increment: Dict[Literal["requests", "tokens"], int] = {
|
||||
"requests": batch_usage.request_count,
|
||||
@ -211,6 +484,7 @@ class _PROXY_BatchRateLimiter(CustomLogger):
|
||||
file_id: str,
|
||||
custom_llm_provider: Literal["openai", "azure", "vertex_ai"] = "openai",
|
||||
user_api_key_dict: Optional[UserAPIKeyAuth] = None,
|
||||
data: Optional[Dict] = None,
|
||||
) -> BatchFileUsage:
|
||||
"""
|
||||
Count number of requests and tokens in a batch input file.
|
||||
@ -238,14 +512,27 @@ class _PROXY_BatchRateLimiter(CustomLogger):
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
)
|
||||
else:
|
||||
provider_file_id, fetch_kwargs = (
|
||||
self._resolve_batch_input_file_fetch_params(
|
||||
file_id=file_id,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
data=data or {},
|
||||
)
|
||||
)
|
||||
# For non-managed files, use the standard litellm.afile_content
|
||||
file_content = await litellm.afile_content(
|
||||
file_id=file_id,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
file_id=provider_file_id,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
**fetch_kwargs,
|
||||
)
|
||||
|
||||
file_content_as_dict = _get_file_content_as_dictionary(file_content.content)
|
||||
file_content_bytes = getattr(file_content, "content", None)
|
||||
if not isinstance(file_content_bytes, bytes):
|
||||
raise ValueError(
|
||||
f"Expected bytes content from file retrieval for {file_id}, "
|
||||
f"got {type(file_content_bytes)}"
|
||||
)
|
||||
file_content_as_dict = _get_file_content_as_dictionary(file_content_bytes)
|
||||
|
||||
# Validate every model named in the batch JSONL against the
|
||||
# caller's per-key model allowlist. Without this, a caller
|
||||
@ -441,6 +728,14 @@ class _PROXY_BatchRateLimiter(CustomLogger):
|
||||
)
|
||||
return data
|
||||
|
||||
should_skip, batch_rate_limit_descriptors = (
|
||||
self._should_skip_batch_input_file_processing(
|
||||
data=data, user_api_key_dict=user_api_key_dict
|
||||
)
|
||||
)
|
||||
if should_skip:
|
||||
return data
|
||||
|
||||
# Get custom_llm_provider for token counting
|
||||
custom_llm_provider = data.get("custom_llm_provider", "openai")
|
||||
|
||||
@ -452,6 +747,7 @@ class _PROXY_BatchRateLimiter(CustomLogger):
|
||||
file_id=input_file_id,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
data=data,
|
||||
)
|
||||
|
||||
verbose_proxy_logger.debug(
|
||||
@ -469,6 +765,7 @@ class _PROXY_BatchRateLimiter(CustomLogger):
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
data=data,
|
||||
batch_usage=batch_usage,
|
||||
descriptors=batch_rate_limit_descriptors,
|
||||
)
|
||||
|
||||
verbose_proxy_logger.debug(
|
||||
|
||||
@ -259,6 +259,188 @@ async def test_pre_call_allows_authorized_model_in_batch_file():
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pre_call_skips_file_fetch_when_disabled_in_general_settings():
|
||||
from litellm.proxy.hooks.batch_rate_limiter import _PROXY_BatchRateLimiter
|
||||
|
||||
rate_limiter = _PROXY_BatchRateLimiter(
|
||||
internal_usage_cache=MagicMock(),
|
||||
parallel_request_limiter=MagicMock(),
|
||||
)
|
||||
user = UserAPIKeyAuth(api_key="sk-ok", user_id="alice", models=["*"])
|
||||
|
||||
with patch(
|
||||
"litellm.proxy.proxy_server.general_settings",
|
||||
{"disable_batch_input_file_rate_limiting": True},
|
||||
):
|
||||
result = await rate_limiter.async_pre_call_hook(
|
||||
user_api_key_dict=user,
|
||||
cache=MagicMock(),
|
||||
data={"input_file_id": "file-abc123"},
|
||||
call_type="acreate_batch",
|
||||
)
|
||||
|
||||
assert result == {"input_file_id": "file-abc123"}
|
||||
rate_limiter.parallel_request_limiter._create_rate_limit_descriptors.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pre_call_skips_file_fetch_for_configured_provider():
|
||||
from litellm.proxy.hooks.batch_rate_limiter import _PROXY_BatchRateLimiter
|
||||
|
||||
rate_limiter = _PROXY_BatchRateLimiter(
|
||||
internal_usage_cache=MagicMock(),
|
||||
parallel_request_limiter=MagicMock(),
|
||||
)
|
||||
user = UserAPIKeyAuth(api_key="sk-ok", user_id="alice", models=["*"])
|
||||
data = {"input_file_id": "file-abc123", "model": "my-vllm-model"}
|
||||
|
||||
with (
|
||||
patch(
|
||||
"litellm.proxy.proxy_server.general_settings",
|
||||
{"skip_batch_input_file_rate_limiting_for_providers": ["hosted_vllm"]},
|
||||
),
|
||||
patch("litellm.proxy.proxy_server.llm_router", MagicMock()),
|
||||
patch(
|
||||
"litellm.proxy.openai_files_endpoints.common_utils.get_credentials_for_model",
|
||||
return_value={"custom_llm_provider": "hosted_vllm"},
|
||||
),
|
||||
patch("litellm.afile_content", new=AsyncMock()) as mock_afile_content,
|
||||
):
|
||||
result = await rate_limiter.async_pre_call_hook(
|
||||
user_api_key_dict=user,
|
||||
cache=MagicMock(),
|
||||
data=data,
|
||||
call_type="acreate_batch",
|
||||
)
|
||||
|
||||
assert result == data
|
||||
# A real skip must short-circuit before any file download or rate-limit
|
||||
# work — assert the skip happened rather than the hook's error-recovery
|
||||
# path (which also returns data unchanged).
|
||||
mock_afile_content.assert_not_awaited()
|
||||
rate_limiter.parallel_request_limiter._create_rate_limit_descriptors.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pre_call_does_not_skip_for_spoofed_provider():
|
||||
"""The provider skip is resolved from trusted deployment credentials, so a
|
||||
user-supplied ``custom_llm_provider`` that is not backed by the routing
|
||||
deployment must not trigger a skip: the input file must still be fetched
|
||||
and the rate-limit counters incremented."""
|
||||
from litellm.proxy.hooks.batch_rate_limiter import _PROXY_BatchRateLimiter
|
||||
|
||||
rate_limiter = _PROXY_BatchRateLimiter(
|
||||
internal_usage_cache=MagicMock(),
|
||||
parallel_request_limiter=MagicMock(),
|
||||
)
|
||||
# An applicable rate limit keeps the no-limits shortcut from firing, so the
|
||||
# only thing that could prevent the fetch below is the provider skip. If the
|
||||
# spoofed ``custom_llm_provider`` were honored, afile_content would never be
|
||||
# awaited.
|
||||
rate_limiter.parallel_request_limiter._create_rate_limit_descriptors.return_value = [
|
||||
{"rate_limit": {"requests_per_unit": 100}}
|
||||
]
|
||||
rate_limiter.parallel_request_limiter.atomic_check_and_increment_by_n = AsyncMock(
|
||||
return_value={"overall_code": "OK", "statuses": []}
|
||||
)
|
||||
user = UserAPIKeyAuth(api_key="sk-ok", user_id="alice", models=["*"])
|
||||
|
||||
mock_router = MagicMock()
|
||||
mock_router.model_list = []
|
||||
mock_router.resolve_model_name_from_model_id.return_value = "my-openai-model"
|
||||
|
||||
mock_content = MagicMock()
|
||||
mock_content.content = (
|
||||
b'{"body": {"model": "my-openai-model", '
|
||||
b'"messages": [{"role": "user", "content": "hi"}]}}\n'
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"litellm.proxy.proxy_server.general_settings",
|
||||
{"skip_batch_input_file_rate_limiting_for_providers": ["hosted_vllm"]},
|
||||
),
|
||||
patch("litellm.proxy.proxy_server.llm_router", mock_router),
|
||||
patch(
|
||||
"litellm.proxy.openai_files_endpoints.common_utils.get_credentials_for_model",
|
||||
return_value={"custom_llm_provider": "openai"},
|
||||
),
|
||||
patch(
|
||||
"litellm.afile_content", new=AsyncMock(return_value=mock_content)
|
||||
) as mock_afile_content,
|
||||
):
|
||||
await rate_limiter.async_pre_call_hook(
|
||||
user_api_key_dict=user,
|
||||
cache=MagicMock(),
|
||||
data={
|
||||
"input_file_id": "file-abc123",
|
||||
"model": "my-openai-model",
|
||||
"custom_llm_provider": "hosted_vllm",
|
||||
},
|
||||
call_type="acreate_batch",
|
||||
)
|
||||
|
||||
# The spoofed provider did not short-circuit the skip decision: the file was
|
||||
# fetched and the counters were incremented.
|
||||
mock_afile_content.assert_awaited_once()
|
||||
rate_limiter.parallel_request_limiter.atomic_check_and_increment_by_n.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_count_input_file_usage_decodes_model_embedded_file_id():
|
||||
import base64
|
||||
|
||||
from litellm.proxy.hooks.batch_rate_limiter import _PROXY_BatchRateLimiter
|
||||
|
||||
original_file_id = "file-provider-xyz"
|
||||
encoded_payload = (
|
||||
base64.urlsafe_b64encode(
|
||||
f"litellm:{original_file_id};model,my-vllm-batch".encode()
|
||||
)
|
||||
.decode()
|
||||
.rstrip("=")
|
||||
)
|
||||
encoded_file_id = f"file-{encoded_payload}"
|
||||
|
||||
rate_limiter = _PROXY_BatchRateLimiter(
|
||||
internal_usage_cache=MagicMock(),
|
||||
parallel_request_limiter=MagicMock(),
|
||||
)
|
||||
|
||||
mock_content = MagicMock()
|
||||
mock_content.content = b'{"custom_id": "1", "method": "POST", "url": "/v1/chat/completions", "body": {"model": "my-vllm-batch", "messages": [{"role": "user", "content": "hi"}]}}\n'
|
||||
|
||||
with (
|
||||
patch(
|
||||
"litellm.afile_content",
|
||||
new=AsyncMock(return_value=mock_content),
|
||||
) as mock_afile_content,
|
||||
patch(
|
||||
"litellm.proxy.proxy_server.llm_router",
|
||||
MagicMock(),
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy.openai_files_endpoints.common_utils.get_credentials_for_model",
|
||||
return_value={
|
||||
"api_key": "test-key",
|
||||
"api_base": "http://vllm:8000/v1",
|
||||
"custom_llm_provider": "hosted_vllm",
|
||||
},
|
||||
),
|
||||
):
|
||||
await rate_limiter.count_input_file_usage(
|
||||
file_id=encoded_file_id,
|
||||
custom_llm_provider="openai",
|
||||
user_api_key_dict=UserAPIKeyAuth(api_key="sk-ok", user_id="alice"),
|
||||
data={},
|
||||
)
|
||||
|
||||
mock_afile_content.assert_awaited_once()
|
||||
assert mock_afile_content.await_args.kwargs["file_id"] == original_file_id
|
||||
assert mock_afile_content.await_args.kwargs["custom_llm_provider"] == "hosted_vllm"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pre_call_allows_stripped_provider_model_when_key_has_proxy_alias():
|
||||
"""After replace_model_in_jsonl, body.model is the provider id (e.g. gpt-5.5).
|
||||
@ -323,3 +505,524 @@ async def test_pre_call_skips_check_when_no_models_present():
|
||||
user_api_key_dict=user,
|
||||
file_content_as_dict=[{"body": {}}],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Skip-path helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_rate_limiter():
|
||||
from litellm.proxy.hooks.batch_rate_limiter import _PROXY_BatchRateLimiter
|
||||
|
||||
return _PROXY_BatchRateLimiter(
|
||||
internal_usage_cache=MagicMock(),
|
||||
parallel_request_limiter=MagicMock(),
|
||||
)
|
||||
|
||||
|
||||
def test_get_batch_routing_model_uses_request_model_for_plain_file():
|
||||
rate_limiter = _make_rate_limiter()
|
||||
assert (
|
||||
rate_limiter._get_batch_routing_model({"model": "gpt-4o-mini"}) == "gpt-4o-mini"
|
||||
)
|
||||
|
||||
|
||||
def test_get_batch_routing_model_prefers_file_bound_over_request_model():
|
||||
"""``create_batch`` routes a model-embedded file id on its bound model and
|
||||
ignores the top-level ``model``. The skip decision must use the same
|
||||
precedence, otherwise a caller could point ``model`` at a skip-listed
|
||||
provider while the file routes a rate-limited one."""
|
||||
import base64
|
||||
|
||||
rate_limiter = _make_rate_limiter()
|
||||
encoded = (
|
||||
base64.urlsafe_b64encode(b"litellm:file-xyz;model,vllm-batch")
|
||||
.decode()
|
||||
.rstrip("=")
|
||||
)
|
||||
assert (
|
||||
rate_limiter._get_batch_routing_model(
|
||||
{"input_file_id": f"file-{encoded}", "model": "gpt-4o-mini"}
|
||||
)
|
||||
== "vllm-batch"
|
||||
)
|
||||
|
||||
|
||||
def test_get_batch_routing_model_returns_none_without_model_or_file():
|
||||
rate_limiter = _make_rate_limiter()
|
||||
assert rate_limiter._get_batch_routing_model({}) is None
|
||||
assert rate_limiter._get_batch_routing_model({"input_file_id": ""}) is None
|
||||
|
||||
|
||||
def test_get_batch_routing_model_decodes_model_embedded_file_id():
|
||||
import base64
|
||||
|
||||
rate_limiter = _make_rate_limiter()
|
||||
encoded = (
|
||||
base64.urlsafe_b64encode(b"litellm:file-xyz;model,vllm-batch")
|
||||
.decode()
|
||||
.rstrip("=")
|
||||
)
|
||||
assert (
|
||||
rate_limiter._get_batch_routing_model({"input_file_id": f"file-{encoded}"})
|
||||
== "vllm-batch"
|
||||
)
|
||||
|
||||
|
||||
def test_get_batch_routing_model_uses_unified_file_id_target():
|
||||
rate_limiter = _make_rate_limiter()
|
||||
with (
|
||||
patch(
|
||||
"litellm.proxy.openai_files_endpoints.common_utils.decode_model_from_file_id",
|
||||
return_value=None,
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy.openai_files_endpoints.common_utils._is_base64_encoded_unified_file_id",
|
||||
return_value="unified-id",
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy.openai_files_endpoints.common_utils.get_models_from_unified_file_id",
|
||||
return_value=["model-a", "model-b"],
|
||||
),
|
||||
):
|
||||
assert (
|
||||
rate_limiter._get_batch_routing_model({"input_file_id": "file-managed"})
|
||||
== "model-a"
|
||||
)
|
||||
|
||||
|
||||
def test_key_requires_batch_model_access_check_branches():
|
||||
from litellm.proxy.hooks.batch_rate_limiter import _PROXY_BatchRateLimiter
|
||||
|
||||
check = _PROXY_BatchRateLimiter._key_requires_batch_model_access_check
|
||||
assert check(UserAPIKeyAuth(api_key="sk", models=["*"])) is False
|
||||
assert check(UserAPIKeyAuth(api_key="sk", models=["all-proxy-models"])) is False
|
||||
assert (
|
||||
check(UserAPIKeyAuth(api_key="sk", models=[], access_group_ids=["grp"])) is True
|
||||
)
|
||||
assert check(UserAPIKeyAuth(api_key="sk", models=[])) is False
|
||||
assert check(UserAPIKeyAuth(api_key="sk", models=["gpt-4o-mini"])) is True
|
||||
# Wildcard / all-proxy-models grant access to every model, so
|
||||
# can_key_call_model passes any model regardless of access groups (which
|
||||
# only ever widen access). Such keys must not be forced to download and
|
||||
# validate the JSONL even when access_group_ids are also present.
|
||||
assert (
|
||||
check(UserAPIKeyAuth(api_key="sk", models=["*"], access_group_ids=["grp"]))
|
||||
is False
|
||||
)
|
||||
assert (
|
||||
check(
|
||||
UserAPIKeyAuth(
|
||||
api_key="sk", models=["all-proxy-models"], access_group_ids=["grp"]
|
||||
)
|
||||
)
|
||||
is False
|
||||
)
|
||||
# A concrete model allowlist is still a subset even with access groups.
|
||||
assert (
|
||||
check(
|
||||
UserAPIKeyAuth(
|
||||
api_key="sk", models=["gpt-4o-mini"], access_group_ids=["grp"]
|
||||
)
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
|
||||
def test_has_applicable_batch_rate_limits():
|
||||
from litellm.proxy.hooks.batch_rate_limiter import _PROXY_BatchRateLimiter
|
||||
|
||||
has_limits = _PROXY_BatchRateLimiter._has_applicable_batch_rate_limits
|
||||
assert has_limits([{"rate_limit": {"tokens_per_unit": 100}}]) is True
|
||||
assert has_limits([{"rate_limit": {"requests_per_unit": 5}}]) is True
|
||||
assert has_limits([{"rate_limit": {"max_parallel_requests": 2}}]) is True
|
||||
assert has_limits([{"rate_limit": {}}, {}]) is False
|
||||
|
||||
|
||||
def test_should_skip_returns_false_when_key_needs_model_access_check():
|
||||
rate_limiter = _make_rate_limiter()
|
||||
user = UserAPIKeyAuth(api_key="sk", models=["gpt-4o-mini"])
|
||||
should_skip, descriptors = rate_limiter._should_skip_batch_input_file_processing(
|
||||
data={"input_file_id": "file-abc"}, user_api_key_dict=user
|
||||
)
|
||||
assert should_skip is False
|
||||
assert descriptors is None
|
||||
|
||||
|
||||
def test_should_skip_ignores_client_supplied_metadata_flag():
|
||||
"""A caller must not be able to bypass batch rate limits by setting
|
||||
``litellm_metadata.skip_batch_input_file_rate_limiting`` in the request
|
||||
body. The skip decision is server-controlled only, so with applicable rate
|
||||
limits the JSONL is still processed despite the client flag."""
|
||||
rate_limiter = _make_rate_limiter()
|
||||
rate_limiter.parallel_request_limiter._create_rate_limit_descriptors.return_value = [
|
||||
{"rate_limit": {"requests_per_unit": 5}}
|
||||
]
|
||||
user = UserAPIKeyAuth(api_key="sk", models=["*"])
|
||||
with patch("litellm.proxy.proxy_server.general_settings", {}):
|
||||
should_skip, descriptors = (
|
||||
rate_limiter._should_skip_batch_input_file_processing(
|
||||
data={
|
||||
"input_file_id": "file-abc",
|
||||
"litellm_metadata": {"skip_batch_input_file_rate_limiting": True},
|
||||
},
|
||||
user_api_key_dict=user,
|
||||
)
|
||||
)
|
||||
assert should_skip is False
|
||||
|
||||
|
||||
def test_should_not_skip_for_forged_model_embedded_file_id():
|
||||
"""A ``file-<base64>`` id embeds an unsigned model name the caller fully
|
||||
controls, so a caller can re-encode any accessible provider file id with a
|
||||
skip-listed model while the JSONL still routes rate-limited ``body.model``
|
||||
entries. The per-model skip must therefore never fire: with applicable rate
|
||||
limits, a forged skip-listed file-bound model still falls through to file
|
||||
processing and counter enforcement."""
|
||||
import base64
|
||||
|
||||
rate_limiter = _make_rate_limiter()
|
||||
rate_limiter.parallel_request_limiter._create_rate_limit_descriptors.return_value = [
|
||||
{"rate_limit": {"requests_per_unit": 5}}
|
||||
]
|
||||
user = UserAPIKeyAuth(api_key="sk", models=["*"])
|
||||
encoded = (
|
||||
base64.urlsafe_b64encode(b"litellm:file-xyz;model,gpt-4o-mini")
|
||||
.decode()
|
||||
.rstrip("=")
|
||||
)
|
||||
with patch(
|
||||
"litellm.proxy.proxy_server.general_settings",
|
||||
{"skip_batch_input_file_rate_limiting_for_models": ["gpt-4o-mini"]},
|
||||
):
|
||||
should_skip, descriptors = (
|
||||
rate_limiter._should_skip_batch_input_file_processing(
|
||||
data={"input_file_id": f"file-{encoded}"},
|
||||
user_api_key_dict=user,
|
||||
)
|
||||
)
|
||||
assert should_skip is False
|
||||
assert descriptors is not None
|
||||
|
||||
|
||||
def test_should_not_skip_for_skip_listed_top_level_model():
|
||||
"""A caller must not bypass batch rate limits by naming a skip-listed model
|
||||
in the top-level ``model`` while routing a different model through the JSONL
|
||||
``body.model`` entries. No per-model skip exists, so a skip-listed model over
|
||||
a plain file still gets processed."""
|
||||
rate_limiter = _make_rate_limiter()
|
||||
rate_limiter.parallel_request_limiter._create_rate_limit_descriptors.return_value = [
|
||||
{"rate_limit": {"requests_per_unit": 5}}
|
||||
]
|
||||
user = UserAPIKeyAuth(api_key="sk", models=["*"])
|
||||
with patch(
|
||||
"litellm.proxy.proxy_server.general_settings",
|
||||
{"skip_batch_input_file_rate_limiting_for_models": ["gpt-4o-mini"]},
|
||||
):
|
||||
should_skip, descriptors = (
|
||||
rate_limiter._should_skip_batch_input_file_processing(
|
||||
data={"model": "gpt-4o-mini", "input_file_id": "file-abc"},
|
||||
user_api_key_dict=user,
|
||||
)
|
||||
)
|
||||
assert should_skip is False
|
||||
|
||||
|
||||
def test_should_not_skip_when_file_bound_provider_is_rate_limited():
|
||||
"""A caller must not bypass batch rate limits by pointing the top-level
|
||||
``model`` at a skip-listed provider while the model-embedded ``input_file_id``
|
||||
routes to a rate-limited provider. ``create_batch`` runs the batch on the
|
||||
file-bound model, so the skip decision must resolve the provider from that
|
||||
model and still process the file when its provider is not skip-listed."""
|
||||
import base64
|
||||
|
||||
rate_limiter = _make_rate_limiter()
|
||||
rate_limiter.parallel_request_limiter._create_rate_limit_descriptors.return_value = [
|
||||
{"rate_limit": {"requests_per_unit": 5}}
|
||||
]
|
||||
user = UserAPIKeyAuth(api_key="sk", models=["*"])
|
||||
encoded = (
|
||||
base64.urlsafe_b64encode(b"litellm:file-orig;model,vllm-batch")
|
||||
.decode()
|
||||
.rstrip("=")
|
||||
)
|
||||
|
||||
def _creds(model_id, **kwargs):
|
||||
provider = "hosted_vllm" if model_id == "vllm-batch" else "openai"
|
||||
return {"custom_llm_provider": provider}
|
||||
|
||||
with (
|
||||
patch(
|
||||
"litellm.proxy.proxy_server.general_settings",
|
||||
{"skip_batch_input_file_rate_limiting_for_providers": ["openai"]},
|
||||
),
|
||||
patch("litellm.proxy.proxy_server.llm_router", MagicMock()),
|
||||
patch(
|
||||
"litellm.proxy.openai_files_endpoints.common_utils.get_credentials_for_model",
|
||||
side_effect=_creds,
|
||||
),
|
||||
):
|
||||
should_skip, descriptors = (
|
||||
rate_limiter._should_skip_batch_input_file_processing(
|
||||
data={"input_file_id": f"file-{encoded}", "model": "gpt-skip"},
|
||||
user_api_key_dict=user,
|
||||
)
|
||||
)
|
||||
assert should_skip is False
|
||||
assert descriptors is not None
|
||||
|
||||
|
||||
def test_should_skip_when_file_bound_provider_is_skip_listed():
|
||||
"""The provider skip must still fire when the model the batch actually runs
|
||||
on (the file-bound model) resolves to a skip-listed provider, even if the
|
||||
top-level ``model`` resolves to a different, non-skipped provider."""
|
||||
import base64
|
||||
|
||||
rate_limiter = _make_rate_limiter()
|
||||
rate_limiter.parallel_request_limiter._create_rate_limit_descriptors.return_value = [
|
||||
{"rate_limit": {"requests_per_unit": 5}}
|
||||
]
|
||||
user = UserAPIKeyAuth(api_key="sk", models=["*"])
|
||||
encoded = (
|
||||
base64.urlsafe_b64encode(b"litellm:file-orig;model,vllm-batch")
|
||||
.decode()
|
||||
.rstrip("=")
|
||||
)
|
||||
|
||||
def _creds(model_id, **kwargs):
|
||||
provider = "hosted_vllm" if model_id == "vllm-batch" else "openai"
|
||||
return {"custom_llm_provider": provider}
|
||||
|
||||
with (
|
||||
patch(
|
||||
"litellm.proxy.proxy_server.general_settings",
|
||||
{"skip_batch_input_file_rate_limiting_for_providers": ["hosted_vllm"]},
|
||||
),
|
||||
patch("litellm.proxy.proxy_server.llm_router", MagicMock()),
|
||||
patch(
|
||||
"litellm.proxy.openai_files_endpoints.common_utils.get_credentials_for_model",
|
||||
side_effect=_creds,
|
||||
),
|
||||
):
|
||||
should_skip, descriptors = (
|
||||
rate_limiter._should_skip_batch_input_file_processing(
|
||||
data={"input_file_id": f"file-{encoded}", "model": "gpt-skip"},
|
||||
user_api_key_dict=user,
|
||||
)
|
||||
)
|
||||
assert should_skip is True
|
||||
|
||||
|
||||
def test_warns_once_for_unsupported_model_skip_setting():
|
||||
"""Operators who set the no-op per-model skip key get a single warning so a
|
||||
misconfigured deployment does not silently leave batch limits unenforced."""
|
||||
rate_limiter = _make_rate_limiter()
|
||||
rate_limiter.parallel_request_limiter._create_rate_limit_descriptors.return_value = [
|
||||
{"rate_limit": {"requests_per_unit": 5}}
|
||||
]
|
||||
user = UserAPIKeyAuth(api_key="sk", models=["*"])
|
||||
with (
|
||||
patch(
|
||||
"litellm.proxy.proxy_server.general_settings",
|
||||
{"skip_batch_input_file_rate_limiting_for_models": ["gpt-4o-mini"]},
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy.hooks.batch_rate_limiter.verbose_proxy_logger"
|
||||
) as mock_logger,
|
||||
):
|
||||
for _ in range(3):
|
||||
rate_limiter._should_skip_batch_input_file_processing(
|
||||
data={"model": "gpt-4o-mini", "input_file_id": "file-abc"},
|
||||
user_api_key_dict=user,
|
||||
)
|
||||
assert mock_logger.warning.call_count == 1
|
||||
assert (
|
||||
"skip_batch_input_file_rate_limiting_for_models"
|
||||
in mock_logger.warning.call_args[0][0]
|
||||
)
|
||||
|
||||
|
||||
def test_no_warning_when_model_skip_setting_absent():
|
||||
rate_limiter = _make_rate_limiter()
|
||||
rate_limiter.parallel_request_limiter._create_rate_limit_descriptors.return_value = [
|
||||
{"rate_limit": {"requests_per_unit": 5}}
|
||||
]
|
||||
user = UserAPIKeyAuth(api_key="sk", models=["*"])
|
||||
with (
|
||||
patch(
|
||||
"litellm.proxy.proxy_server.general_settings",
|
||||
{"skip_batch_input_file_rate_limiting_for_providers": ["openai"]},
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy.hooks.batch_rate_limiter.verbose_proxy_logger"
|
||||
) as mock_logger,
|
||||
):
|
||||
rate_limiter._should_skip_batch_input_file_processing(
|
||||
data={"model": "gpt-4o-mini", "input_file_id": "file-abc"},
|
||||
user_api_key_dict=user,
|
||||
)
|
||||
mock_logger.warning.assert_not_called()
|
||||
|
||||
|
||||
def test_should_skip_when_no_rate_limits_configured():
|
||||
rate_limiter = _make_rate_limiter()
|
||||
rate_limiter.parallel_request_limiter._create_rate_limit_descriptors.return_value = [
|
||||
{"rate_limit": {}}
|
||||
]
|
||||
user = UserAPIKeyAuth(api_key="sk", models=["*"])
|
||||
with patch("litellm.proxy.proxy_server.general_settings", {}):
|
||||
should_skip, descriptors = (
|
||||
rate_limiter._should_skip_batch_input_file_processing(
|
||||
data={"model": "gpt-4o-mini", "input_file_id": "file-abc"},
|
||||
user_api_key_dict=user,
|
||||
)
|
||||
)
|
||||
assert should_skip is True
|
||||
assert descriptors is None
|
||||
|
||||
|
||||
def test_should_not_skip_and_reuses_descriptors_when_limits_present():
|
||||
rate_limiter = _make_rate_limiter()
|
||||
descriptors = [{"rate_limit": {"tokens_per_unit": 100}}]
|
||||
rate_limiter.parallel_request_limiter._create_rate_limit_descriptors.return_value = (
|
||||
descriptors
|
||||
)
|
||||
user = UserAPIKeyAuth(api_key="sk", models=["*"])
|
||||
with patch("litellm.proxy.proxy_server.general_settings", {}):
|
||||
should_skip, returned = rate_limiter._should_skip_batch_input_file_processing(
|
||||
data={"model": "gpt-4o-mini", "input_file_id": "file-abc"},
|
||||
user_api_key_dict=user,
|
||||
)
|
||||
assert should_skip is False
|
||||
assert returned is descriptors
|
||||
|
||||
|
||||
def test_resolve_fetch_params_uses_request_model_credentials():
|
||||
rate_limiter = _make_rate_limiter()
|
||||
with (
|
||||
patch("litellm.proxy.proxy_server.llm_router", MagicMock()),
|
||||
patch(
|
||||
"litellm.proxy.openai_files_endpoints.common_utils.get_credentials_for_model",
|
||||
return_value={
|
||||
"api_key": "k",
|
||||
"api_base": "http://vllm:8000/v1",
|
||||
"custom_llm_provider": "hosted_vllm",
|
||||
},
|
||||
),
|
||||
):
|
||||
provider_file_id, fetch_kwargs = (
|
||||
rate_limiter._resolve_batch_input_file_fetch_params(
|
||||
file_id="file-plain-openai",
|
||||
custom_llm_provider="openai",
|
||||
data={"model": "my-vllm-batch"},
|
||||
)
|
||||
)
|
||||
assert provider_file_id == "file-plain-openai"
|
||||
assert fetch_kwargs["model"] == "my-vllm-batch"
|
||||
assert fetch_kwargs["custom_llm_provider"] == "hosted_vllm"
|
||||
assert fetch_kwargs["api_base"] == "http://vllm:8000/v1"
|
||||
|
||||
|
||||
def test_resolve_fetch_params_fails_open_on_credential_lookup_error():
|
||||
rate_limiter = _make_rate_limiter()
|
||||
with (
|
||||
patch("litellm.proxy.proxy_server.llm_router", MagicMock()),
|
||||
patch(
|
||||
"litellm.proxy.openai_files_endpoints.common_utils.get_credentials_for_model",
|
||||
side_effect=HTTPException(status_code=404, detail="no creds"),
|
||||
),
|
||||
):
|
||||
provider_file_id, fetch_kwargs = (
|
||||
rate_limiter._resolve_batch_input_file_fetch_params(
|
||||
file_id="file-plain-openai",
|
||||
custom_llm_provider="openai",
|
||||
data={"model": "my-vllm-batch"},
|
||||
)
|
||||
)
|
||||
assert provider_file_id == "file-plain-openai"
|
||||
assert fetch_kwargs == {"custom_llm_provider": "openai"}
|
||||
|
||||
|
||||
def test_resolve_fetch_params_model_embedded_fails_open_on_credential_error():
|
||||
import base64
|
||||
|
||||
rate_limiter = _make_rate_limiter()
|
||||
encoded = (
|
||||
base64.urlsafe_b64encode(b"litellm:file-orig;model,vllm-batch")
|
||||
.decode()
|
||||
.rstrip("=")
|
||||
)
|
||||
encoded_file_id = f"file-{encoded}"
|
||||
|
||||
get_credentials = MagicMock(
|
||||
side_effect=HTTPException(status_code=404, detail="no creds")
|
||||
)
|
||||
with (
|
||||
patch("litellm.proxy.proxy_server.llm_router", MagicMock()),
|
||||
patch(
|
||||
"litellm.proxy.openai_files_endpoints.common_utils.get_credentials_for_model",
|
||||
get_credentials,
|
||||
),
|
||||
):
|
||||
provider_file_id, fetch_kwargs = (
|
||||
rate_limiter._resolve_batch_input_file_fetch_params(
|
||||
file_id=encoded_file_id,
|
||||
custom_llm_provider="openai",
|
||||
data={},
|
||||
)
|
||||
)
|
||||
get_credentials.assert_called_once()
|
||||
assert provider_file_id == "file-orig"
|
||||
assert fetch_kwargs == {"custom_llm_provider": "openai"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_and_increment_computes_descriptors_when_not_passed():
|
||||
from litellm.proxy.hooks.batch_rate_limiter import (
|
||||
BatchFileUsage,
|
||||
_PROXY_BatchRateLimiter,
|
||||
)
|
||||
|
||||
parallel_request_limiter = MagicMock()
|
||||
parallel_request_limiter._create_rate_limit_descriptors.return_value = [
|
||||
{"rate_limit": {"tokens_per_unit": 100}}
|
||||
]
|
||||
parallel_request_limiter.atomic_check_and_increment_by_n = AsyncMock(
|
||||
return_value={"overall_code": "OK", "statuses": []}
|
||||
)
|
||||
rate_limiter = _PROXY_BatchRateLimiter(
|
||||
internal_usage_cache=MagicMock(),
|
||||
parallel_request_limiter=parallel_request_limiter,
|
||||
)
|
||||
|
||||
await rate_limiter._check_and_increment_batch_counters(
|
||||
user_api_key_dict=UserAPIKeyAuth(api_key="sk", models=["*"]),
|
||||
data={"model": "gpt-4o-mini"},
|
||||
batch_usage=BatchFileUsage(total_tokens=10, request_count=1),
|
||||
descriptors=None,
|
||||
)
|
||||
|
||||
parallel_request_limiter._create_rate_limit_descriptors.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_count_input_file_usage_raises_on_non_bytes_content():
|
||||
from litellm.proxy.hooks.batch_rate_limiter import _PROXY_BatchRateLimiter
|
||||
|
||||
rate_limiter = _PROXY_BatchRateLimiter(
|
||||
internal_usage_cache=MagicMock(),
|
||||
parallel_request_limiter=MagicMock(),
|
||||
)
|
||||
|
||||
bad_content = MagicMock()
|
||||
bad_content.content = "not-bytes"
|
||||
|
||||
with patch("litellm.afile_content", new=AsyncMock(return_value=bad_content)):
|
||||
with pytest.raises(ValueError, match="Expected bytes content"):
|
||||
await rate_limiter.count_input_file_usage(
|
||||
file_id="file-plain",
|
||||
custom_llm_provider="openai",
|
||||
user_api_key_dict=UserAPIKeyAuth(api_key="sk", models=["*"]),
|
||||
data={},
|
||||
)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user