mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-26 06:03:19 +00:00
* fix(rag): use team provider credentials and allow viewer and JWT team access Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(rag): assert persisted managed store data excludes team credentials Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(jwt): run real team model checks in the route denial test Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(rag): scope team provider credentials to openai vector stores Bedrock and other providers kept their pre-PR credential and region resolution: a team bedrock deployment's region no longer overrides the caller's region on RAG query. Query tests now fake the provider at the HTTP boundary with a real vector store registry. * fix(rag): use only the team's own OpenAI deployment for RAG credentials * fix(rag): ignore caller credential fields the query search never uses when picking team credentials * fix(rag): treat blank credential fields as absent when picking team credentials * test(rag): delete the stores the integration ingests create before the owned proxy exits * fix(proxy): let a restricted key use the team deployment its wildcard or model covers A key restricted to gpt-4o on a team whose own OpenAI deployment is openai/* never resolved that deployment's credentials, because the key allowlist was matched against the deployment's public name literally. The allowlist now matches a wildcard on either side the way request authorization does, through the auth layer's own helper, so /rag, /files and /batches resolve the team deployment for every key that may call a model on it. * fix(batches): read a restricted key's batch input through its team deployment The batch access check downloaded the input JSONL with the proxy's OPENAI_API_KEY while the upload and the create used the team deployment, so a key restricted under a team openai/* deployment could not create a batch when the two keys belong to different OpenAI projects. The read now resolves the team deployment the way the create does when no model pins it Adds the audit cells for keys restricted under the team wildcard on /rag, /files and /batches, and for the rag-vs-chat split of a key restricted to a global exact-name deployment * fix(rag): load deployments oldest first, reject file_id on stores that cannot take one, and show ingest errors in the dashboard * fix(rag): pick the team deployment with the lowest id, return 400 for caller mistakes, keep the ingest id in errors, and show partial success in the dashboard * fix(rag): pick the team's oldest OpenAI deployment so adding one never moves the team * fix(rag): keep config file team deployments first and try JWT teams in sorted order A team's config file OpenAI deployments now win in file order, as on the base, before its DB ones oldest first, so upgrading never moves a team that has both. A JWT in several teams tries them in sorted order, so every worker resolves the same team. * fix(rag): return 400 for Bedrock, S3 Vectors and Gemini store config mistakes on ingest * fix(rag): keep forwarded provider keys on /rag/query and mixed JWT team claims working A forwarded x-api-key now counts as a caller credential, so the /rag/query search uses it over the team deployment as at the merge base. Sorting the JWT team claim compares entries as text, so a stray number or null no longer turns into a 401. A Gemini store with no proxy key is a 500 again, and a non-string vector_store_id keeps the merge base's 400 message. * fix(rag): let a forwarded provider key win over team credentials on files, batches and ingest * fix(rag): keep team credentials when a request carries its own key or api_base A forwarded x-api-key or a body api_key now replaces only the key of the team deployment, keeping its api_base, as chat completions does. A body litellm_credential_name no longer turns team credentials off on /v1/batches. * fix(batches): keep the team key on the team host when a request sends only api_base A request api_base now replaces the team deployment's api_base only when the request also brings its own api_key, and a non-string api_key or api_base is ignored, as on the merge base. * fix(files): ignore a blank request api_key or api_base next to team credentials * fix(files): answer 400 for a request api_key that cannot be sent as an HTTP header * fix(rag): honor a body drop_params for a forwarded key that cannot be an HTTP header * fix(rag): read the request field for drop_params only on multipart ingest bodies * fix(rag): type the ingest body read for drop_params * fix(batches): count a model-routed batch's input with the deployment key it is created with * fix(batches): reject a batch create whose x-litellm-model headers name different models * fix(rag): treat a null provider and whitespace store credentials as unset when picking team credentials * refactor(files): type the team deployment the credential resolver ranks --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
722 lines
29 KiB
Python
722 lines
29 KiB
Python
"""
|
|
Tests for `tpd_limit` (tokens per day) enforcement on batch submissions.
|
|
|
|
A batch's rows are scheduled by the provider, so a caller cannot keep a large
|
|
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 json
|
|
import time
|
|
from collections.abc import Callable, Iterator, Sequence
|
|
from datetime import datetime, timezone
|
|
from typing import Final, Literal
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import httpx
|
|
import pytest
|
|
import respx
|
|
from fastapi import HTTPException
|
|
from typing_extensions import ReadOnly, TypedDict
|
|
|
|
import litellm
|
|
from litellm import DualCache
|
|
from litellm.constants import BATCH_TPD_WINDOW_SECONDS
|
|
from litellm.proxy._types import UserAPIKeyAuth
|
|
from litellm.proxy.hooks.batch_rate_limiter import BatchFileUsage
|
|
from litellm.proxy.hooks.parallel_request_limiter_v3 import (
|
|
PROXY_MaxParallelRequestsHandler_v3,
|
|
)
|
|
from litellm.proxy.utils import InternalUsageCache, hash_token
|
|
from litellm.types.llms.openai import ChatCompletionUserMessage, LiteLLMBatchCreateRequest
|
|
|
|
|
|
class _Clock:
|
|
def __init__(self, start: datetime):
|
|
self.now = start
|
|
|
|
def __call__(self) -> datetime:
|
|
return self.now
|
|
|
|
|
|
def _make_limiters(clock: _Clock | None = None):
|
|
internal_usage_cache = InternalUsageCache(dual_cache=DualCache())
|
|
rate_limiter = PROXY_MaxParallelRequestsHandler_v3(internal_usage_cache=internal_usage_cache, time_provider=clock)
|
|
batch_limiter = rate_limiter._get_batch_rate_limiter()
|
|
assert batch_limiter is not None
|
|
return internal_usage_cache, rate_limiter, batch_limiter
|
|
|
|
|
|
async def _counter(internal_usage_cache, rate_limiter, descriptor_key, value, rate_limit_type):
|
|
cache_key = rate_limiter.create_rate_limit_keys(descriptor_key, value, rate_limit_type)
|
|
raw = await internal_usage_cache.async_get_cache(key=cache_key, litellm_parent_otel_span=None, local_only=True)
|
|
return int(raw or 0)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_batch_over_rpm_and_tpm_but_under_tpd_is_accepted():
|
|
internal_usage_cache, rate_limiter, batch_limiter = _make_limiters()
|
|
api_key = hash_token("tpd-key")
|
|
user_api_key_dict = UserAPIKeyAuth(api_key=api_key, rpm_limit=1, tpm_limit=10, tpd_limit=1000)
|
|
|
|
await batch_limiter._check_and_increment_batch_counters(
|
|
user_api_key_dict=user_api_key_dict,
|
|
data={},
|
|
batch_usage=BatchFileUsage(total_tokens=500, request_count=50),
|
|
)
|
|
|
|
assert await _counter(internal_usage_cache, rate_limiter, "api_key_tpd", api_key, "tokens") == 500
|
|
assert await _counter(internal_usage_cache, rate_limiter, "api_key", api_key, "requests") == 0
|
|
assert await _counter(internal_usage_cache, rate_limiter, "api_key", api_key, "tokens") == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cumulative_batch_tokens_over_tpd_returns_429_with_remaining_daily_window():
|
|
window_start = datetime(2026, 9, 13, 8, 0, 0)
|
|
clock = _Clock(window_start)
|
|
_internal_usage_cache, _rate_limiter, batch_limiter = _make_limiters(clock)
|
|
user_api_key_dict = UserAPIKeyAuth(api_key=hash_token("tpd-key-2"), rpm_limit=1, tpd_limit=1000)
|
|
|
|
await batch_limiter._check_and_increment_batch_counters(
|
|
user_api_key_dict=user_api_key_dict,
|
|
data={},
|
|
batch_usage=BatchFileUsage(total_tokens=600, request_count=6),
|
|
)
|
|
clock.now = datetime(2026, 9, 13, 11, 0, 0)
|
|
with pytest.raises(HTTPException) as exc:
|
|
await batch_limiter._check_and_increment_batch_counters(
|
|
user_api_key_dict=user_api_key_dict,
|
|
data={},
|
|
batch_usage=BatchFileUsage(total_tokens=600, request_count=6),
|
|
)
|
|
|
|
assert exc.value.status_code == 429
|
|
assert "api_key_tpd" in str(exc.value.detail)
|
|
assert "600 tokens but only 400 tokens remaining out of 1000 TPD limit" in str(exc.value.detail)
|
|
assert exc.value.headers["retry-after"] == str(BATCH_TPD_WINDOW_SECONDS - 3 * 3600)
|
|
assert exc.value.headers["reset_at"] == "2026-09-14 08:00:00 UTC"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_failed_batch_submission_refunds_tpd_tokens():
|
|
internal_usage_cache, rate_limiter, batch_limiter = _make_limiters()
|
|
api_key = hash_token("tpd-refund-key")
|
|
user_api_key_dict = UserAPIKeyAuth(api_key=api_key, rpm_limit=1, tpd_limit=1000)
|
|
|
|
await batch_limiter._check_and_increment_batch_counters(
|
|
user_api_key_dict=user_api_key_dict,
|
|
data={},
|
|
batch_usage=BatchFileUsage(total_tokens=600, request_count=6),
|
|
)
|
|
await rate_limiter.async_post_call_failure_hook(
|
|
request_data={},
|
|
original_exception=RuntimeError("provider rejected the file"),
|
|
user_api_key_dict=user_api_key_dict,
|
|
)
|
|
|
|
assert await _counter(internal_usage_cache, rate_limiter, "api_key_tpd", api_key, "tokens") == 0
|
|
await batch_limiter._check_and_increment_batch_counters(
|
|
user_api_key_dict=user_api_key_dict,
|
|
data={},
|
|
batch_usage=BatchFileUsage(total_tokens=1000, request_count=10),
|
|
)
|
|
assert await _counter(internal_usage_cache, rate_limiter, "api_key_tpd", api_key, "tokens") == 1000
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tpd_refund_applies_once_and_only_to_daily_counters():
|
|
internal_usage_cache, rate_limiter, batch_limiter = _make_limiters()
|
|
team_key = UserAPIKeyAuth(
|
|
api_key=hash_token("tpd-refund-team-key"),
|
|
rpm_limit=100,
|
|
tpm_limit=10_000,
|
|
team_id="team-r",
|
|
team_tpd_limit=5000,
|
|
)
|
|
|
|
await batch_limiter._check_and_increment_batch_counters(
|
|
user_api_key_dict=team_key,
|
|
data={},
|
|
batch_usage=BatchFileUsage(total_tokens=800, request_count=8),
|
|
)
|
|
await rate_limiter.async_post_call_failure_hook(
|
|
request_data={}, original_exception=RuntimeError("boom"), user_api_key_dict=team_key
|
|
)
|
|
await rate_limiter.async_post_call_failure_hook(
|
|
request_data={}, original_exception=RuntimeError("boom"), user_api_key_dict=team_key
|
|
)
|
|
|
|
assert await _counter(internal_usage_cache, rate_limiter, "team_tpd", "team-r", "tokens") == 0
|
|
assert await _counter(internal_usage_cache, rate_limiter, "api_key", team_key.api_key, "tokens") == 800
|
|
assert await _counter(internal_usage_cache, rate_limiter, "api_key", team_key.api_key, "requests") == 8
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rejected_batch_leaves_nothing_to_refund():
|
|
internal_usage_cache, rate_limiter, batch_limiter = _make_limiters()
|
|
api_key = hash_token("tpd-rejected-key")
|
|
user_api_key_dict = UserAPIKeyAuth(api_key=api_key, tpd_limit=100)
|
|
|
|
await batch_limiter._check_and_increment_batch_counters(
|
|
user_api_key_dict=user_api_key_dict, data={}, batch_usage=BatchFileUsage(total_tokens=90, request_count=9)
|
|
)
|
|
with pytest.raises(HTTPException):
|
|
await batch_limiter._check_and_increment_batch_counters(
|
|
user_api_key_dict=user_api_key_dict, data={}, batch_usage=BatchFileUsage(total_tokens=20, request_count=2)
|
|
)
|
|
await rate_limiter.async_post_call_failure_hook(
|
|
request_data={}, original_exception=RuntimeError("429 bubbled up"), user_api_key_dict=user_api_key_dict
|
|
)
|
|
|
|
assert await _counter(internal_usage_cache, rate_limiter, "api_key_tpd", api_key, "tokens") == 90
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_batch_without_tpd_still_enforces_minute_rpm():
|
|
_internal_usage_cache, _rate_limiter, batch_limiter = _make_limiters()
|
|
user_api_key_dict = UserAPIKeyAuth(api_key=hash_token("rpm-only-key"), rpm_limit=1, tpm_limit=1000)
|
|
|
|
with pytest.raises(HTTPException) as exc:
|
|
await batch_limiter._check_and_increment_batch_counters(
|
|
user_api_key_dict=user_api_key_dict,
|
|
data={},
|
|
batch_usage=BatchFileUsage(total_tokens=50, request_count=5),
|
|
)
|
|
|
|
assert exc.value.status_code == 429
|
|
assert "RPM limit" in str(exc.value.detail)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_team_tpd_replaces_team_minute_limits_but_key_minute_limits_still_apply():
|
|
internal_usage_cache, rate_limiter, batch_limiter = _make_limiters()
|
|
team_key = UserAPIKeyAuth(
|
|
api_key=hash_token("team-key"),
|
|
team_id="team-1",
|
|
team_rpm_limit=1,
|
|
team_tpm_limit=10,
|
|
team_tpd_limit=5000,
|
|
)
|
|
|
|
await batch_limiter._check_and_increment_batch_counters(
|
|
user_api_key_dict=team_key,
|
|
data={},
|
|
batch_usage=BatchFileUsage(total_tokens=800, request_count=8),
|
|
)
|
|
assert await _counter(internal_usage_cache, rate_limiter, "team_tpd", "team-1", "tokens") == 800
|
|
assert await _counter(internal_usage_cache, rate_limiter, "team", "team-1", "requests") == 0
|
|
|
|
key_rpm_in_team_with_tpd = UserAPIKeyAuth(
|
|
api_key=hash_token("team-key-2"),
|
|
rpm_limit=1,
|
|
team_id="team-1",
|
|
team_tpd_limit=5000,
|
|
)
|
|
with pytest.raises(HTTPException) as exc:
|
|
await batch_limiter._check_and_increment_batch_counters(
|
|
user_api_key_dict=key_rpm_in_team_with_tpd,
|
|
data={},
|
|
batch_usage=BatchFileUsage(total_tokens=10, request_count=2),
|
|
)
|
|
assert exc.value.status_code == 429
|
|
assert "api_key:" in str(exc.value.detail)
|
|
assert "RPM limit" in str(exc.value.detail)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_end_user_tpd_is_enforced_per_end_user():
|
|
_internal_usage_cache, _rate_limiter, batch_limiter = _make_limiters()
|
|
first_customer = UserAPIKeyAuth(
|
|
api_key=hash_token("shared-key"), end_user_id="customer-a", end_user_rpm_limit=1, end_user_tpd_limit=100
|
|
)
|
|
second_customer = UserAPIKeyAuth(
|
|
api_key=hash_token("shared-key"), end_user_id="customer-b", end_user_rpm_limit=1, end_user_tpd_limit=100
|
|
)
|
|
|
|
await batch_limiter._check_and_increment_batch_counters(
|
|
user_api_key_dict=first_customer, data={}, batch_usage=BatchFileUsage(total_tokens=90, request_count=9)
|
|
)
|
|
await batch_limiter._check_and_increment_batch_counters(
|
|
user_api_key_dict=second_customer, data={}, batch_usage=BatchFileUsage(total_tokens=90, request_count=9)
|
|
)
|
|
with pytest.raises(HTTPException) as exc:
|
|
await batch_limiter._check_and_increment_batch_counters(
|
|
user_api_key_dict=first_customer, data={}, batch_usage=BatchFileUsage(total_tokens=20, request_count=2)
|
|
)
|
|
assert exc.value.status_code == 429
|
|
assert "end_user_tpd: customer-a" in str(exc.value.detail)
|
|
|
|
|
|
def test_tpd_only_key_is_not_skipped_as_having_no_limits():
|
|
_internal_usage_cache, _rate_limiter, batch_limiter = _make_limiters()
|
|
descriptors = batch_limiter._create_batch_rate_limit_descriptors(
|
|
user_api_key_dict=UserAPIKeyAuth(api_key=hash_token("tpd-only"), tpd_limit=100),
|
|
data={},
|
|
)
|
|
assert batch_limiter._has_applicable_batch_rate_limits(descriptors) is True
|
|
|
|
|
|
def test_online_descriptors_ignore_tpd_limit():
|
|
_internal_usage_cache, rate_limiter, _batch_limiter = _make_limiters()
|
|
api_key = hash_token("online-key")
|
|
descriptors = rate_limiter.create_rate_limit_descriptors(
|
|
user_api_key_dict=UserAPIKeyAuth(api_key=api_key, rpm_limit=5, tpd_limit=100, team_id="t", team_tpd_limit=9),
|
|
data={"model": "gpt-4o"},
|
|
rpm_limit_type=None,
|
|
tpm_limit_type=None,
|
|
model_has_failures=False,
|
|
)
|
|
assert [(d["key"], d["rate_limit"]["window_size"]) for d in descriptors] == [("api_key", rate_limiter.window_size)]
|
|
|
|
|
|
@pytest.fixture(params=["Europe/Paris", "Asia/Kolkata", "America/Los_Angeles"])
|
|
def process_timezone(request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch) -> Iterator[str]:
|
|
monkeypatch.setenv("TZ", request.param)
|
|
time.tzset()
|
|
yield request.param
|
|
monkeypatch.undo()
|
|
time.tzset()
|
|
|
|
|
|
@pytest.mark.skipif(not hasattr(time, "tzset"), reason="switching the process timezone needs time.tzset()")
|
|
@pytest.mark.asyncio
|
|
async def test_batch_rate_limit_error_reports_reset_time_in_utc_on_a_non_utc_proxy(process_timezone: str) -> None:
|
|
window_start: Final = datetime(2026, 9, 13, 8, 0, 0, tzinfo=timezone.utc)
|
|
clock: Final = _Clock(window_start)
|
|
_internal_usage_cache, _rate_limiter, batch_limiter = _make_limiters(clock)
|
|
user_api_key_dict: Final = UserAPIKeyAuth(api_key=hash_token("tpd-key-utc"), rpm_limit=1, tpd_limit=1000)
|
|
|
|
await batch_limiter._check_and_increment_batch_counters(
|
|
user_api_key_dict=user_api_key_dict,
|
|
data={},
|
|
batch_usage=BatchFileUsage(total_tokens=600, request_count=6),
|
|
)
|
|
clock.now = datetime(2026, 9, 13, 11, 0, 0, tzinfo=timezone.utc)
|
|
with pytest.raises(HTTPException) as exc:
|
|
await batch_limiter._check_and_increment_batch_counters(
|
|
user_api_key_dict=user_api_key_dict,
|
|
data={},
|
|
batch_usage=BatchFileUsage(total_tokens=600, request_count=6),
|
|
)
|
|
|
|
assert exc.value.status_code == 429
|
|
assert exc.value.headers["retry-after"] == str(BATCH_TPD_WINDOW_SECONDS - 3 * 3600)
|
|
assert exc.value.headers["reset_at"] == "2026-09-14 08:00:00 UTC"
|
|
assert str(exc.value.detail).endswith("Limit resets at: 2026-09-14 08:00:00 UTC")
|
|
|
|
|
|
_BATCH_MODEL: Final = "gpt-3.5-turbo"
|
|
_MANAGED_FILE_ID: Final = (
|
|
"bGl0ZWxsbV9wcm94eTphcHBsaWNhdGlvbi9vY3RldC1zdHJlYW07dW5pZmllZF9pZCxyZWdyZXNzaW9uLXRlc3QtZmlsZQ=="
|
|
)
|
|
|
|
|
|
class _BatchBody(TypedDict):
|
|
model: ReadOnly[str]
|
|
messages: ReadOnly[Sequence[ChatCompletionUserMessage]]
|
|
|
|
|
|
class _BatchLine(TypedDict):
|
|
custom_id: ReadOnly[str]
|
|
method: ReadOnly[Literal["POST"]]
|
|
url: ReadOnly[Literal["/v1/chat/completions"]]
|
|
body: ReadOnly[_BatchBody]
|
|
|
|
|
|
@pytest.fixture
|
|
def openai_files(monkeypatch: pytest.MonkeyPatch, respx_mock: respx.MockRouter) -> respx.MockRouter:
|
|
monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
|
|
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
|
return respx_mock
|
|
|
|
|
|
def _batch_rows(messages: Sequence[str]) -> tuple[_BatchLine, ...]:
|
|
return tuple(
|
|
_BatchLine(
|
|
custom_id=f"request-{i}",
|
|
method="POST",
|
|
url="/v1/chat/completions",
|
|
body=_BatchBody(model=_BATCH_MODEL, messages=(ChatCompletionUserMessage(role="user", content=message),)),
|
|
)
|
|
for i, message in enumerate(messages, start=1)
|
|
)
|
|
|
|
|
|
def _serve_file(router: respx.MockRouter, file_id: str, rows: Sequence[_BatchLine]) -> respx.Route:
|
|
jsonl: Final = "\n".join(json.dumps(row) for row in rows)
|
|
return router.get(f"https://api.openai.com/v1/files/{file_id}/content").mock(
|
|
return_value=httpx.Response(200, content=jsonl.encode())
|
|
)
|
|
|
|
|
|
def _token_counter_total(rows: Sequence[_BatchLine]) -> int:
|
|
return sum(litellm.token_counter(model=row["body"]["model"], messages=row["body"]["messages"]) for row in rows)
|
|
|
|
|
|
def _create_batch_data(input_file_id: str) -> LiteLLMBatchCreateRequest:
|
|
return LiteLLMBatchCreateRequest(model=_BATCH_MODEL, input_file_id=input_file_id)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_count_input_file_usage_matches_token_counter(openai_files: respx.MockRouter):
|
|
_, _, batch_limiter = _make_limiters()
|
|
rows: Final = _batch_rows(("Hello", "Hi there", "Hey"))
|
|
content_route: Final = _serve_file(openai_files, "file-abc123", rows)
|
|
|
|
usage: Final = await batch_limiter.count_input_file_usage(file_id="file-abc123", custom_llm_provider="openai")
|
|
|
|
assert content_route.call_count == 1
|
|
assert usage.request_count == 3
|
|
assert usage.total_tokens == _token_counter_total(rows)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_batch_rate_limit_single_file_under_and_over_tpm(openai_files: respx.MockRouter):
|
|
small_rows: Final = _batch_rows(("Hello", "Hi", "Hey"))
|
|
big_rows: Final = _batch_rows(
|
|
("This is a longer message that will consume more tokens from the rate limit. " * 100,) * 3
|
|
)
|
|
_serve_file(openai_files, "file-small", small_rows)
|
|
_serve_file(openai_files, "file-big", big_rows)
|
|
user_api_key_dict: Final = UserAPIKeyAuth(api_key="test-key-123", tpm_limit=200, rpm_limit=10)
|
|
_, _, small_limiter = _make_limiters()
|
|
|
|
data_small: Final = dict(_create_batch_data("file-small"))
|
|
result: Final = await small_limiter.async_pre_call_hook(
|
|
user_api_key_dict=user_api_key_dict,
|
|
cache=DualCache(),
|
|
data=data_small,
|
|
call_type="acreate_batch",
|
|
)
|
|
|
|
assert result is data_small
|
|
assert data_small["_batch_token_count"] == _token_counter_total(small_rows)
|
|
assert data_small["_batch_request_count"] == 3
|
|
|
|
_, _, big_limiter = _make_limiters()
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await big_limiter.async_pre_call_hook(
|
|
user_api_key_dict=user_api_key_dict,
|
|
cache=DualCache(),
|
|
data=dict(_create_batch_data("file-big")),
|
|
call_type="acreate_batch",
|
|
)
|
|
assert exc_info.value.status_code == 429
|
|
assert "tokens" in str(exc_info.value.detail).lower()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_batch_rate_limit_cumulative_tpm_rejects_second_request(openai_files: respx.MockRouter):
|
|
_, _, batch_limiter = _make_limiters()
|
|
user_api_key_dict: Final = UserAPIKeyAuth(api_key="test-key-456", tpm_limit=200, rpm_limit=10)
|
|
first_rows: Final = _batch_rows(("This message has some content to reach about 100 tokens total. " * 4,) * 2)
|
|
second_rows: Final = _batch_rows(
|
|
("This is another message with more content to exceed the remaining limit. " * 11,) * 2
|
|
)
|
|
_serve_file(openai_files, "file-1", first_rows)
|
|
_serve_file(openai_files, "file-2", second_rows)
|
|
first_tokens: Final = _token_counter_total(first_rows)
|
|
assert first_tokens <= 200 < first_tokens + _token_counter_total(second_rows)
|
|
|
|
first_data: Final = dict(_create_batch_data("file-1"))
|
|
first_result: Final = await batch_limiter.async_pre_call_hook(
|
|
user_api_key_dict=user_api_key_dict,
|
|
cache=DualCache(),
|
|
data=first_data,
|
|
call_type="acreate_batch",
|
|
)
|
|
assert first_result is first_data
|
|
assert first_data["_batch_token_count"] == first_tokens
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await batch_limiter.async_pre_call_hook(
|
|
user_api_key_dict=user_api_key_dict,
|
|
cache=DualCache(),
|
|
data=dict(_create_batch_data("file-2")),
|
|
call_type="acreate_batch",
|
|
)
|
|
assert exc_info.value.status_code == 429
|
|
assert "tokens" in str(exc_info.value.detail).lower()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_batch_rate_limiter_reads_a_provider_file_with_user_context(openai_files: respx.MockRouter):
|
|
_, _, batch_limiter = _make_limiters()
|
|
user_api_key_dict: Final = UserAPIKeyAuth(
|
|
api_key="test-key-managed-files", user_id="test-user-abc123", tpm_limit=500, rpm_limit=10
|
|
)
|
|
rows: Final = _batch_rows(("This is a test message for batch rate limiting with managed files. " * 5,) * 3)
|
|
content_route: Final = _serve_file(openai_files, "file-abc123", rows)
|
|
|
|
data: Final = dict(_create_batch_data("file-abc123"))
|
|
result: Final = await batch_limiter.async_pre_call_hook(
|
|
user_api_key_dict=user_api_key_dict,
|
|
cache=DualCache(),
|
|
data=data,
|
|
call_type="acreate_batch",
|
|
)
|
|
|
|
assert content_route.call_count == 1
|
|
assert result is data
|
|
assert data["_batch_token_count"] == _token_counter_total(rows)
|
|
assert data["_batch_request_count"] == 3
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_batch_rate_limiter_without_user_context(openai_files: respx.MockRouter):
|
|
_, _, batch_limiter = _make_limiters()
|
|
rows: Final = _batch_rows(("Hello",))
|
|
content_route: Final = _serve_file(openai_files, "file-abc123", rows)
|
|
|
|
usage_without_context: Final = await batch_limiter.count_input_file_usage(
|
|
file_id="file-abc123", custom_llm_provider="openai", user_api_key_dict=None
|
|
)
|
|
usage_with_context: Final = await batch_limiter.count_input_file_usage(
|
|
file_id="file-abc123",
|
|
custom_llm_provider="openai",
|
|
user_api_key_dict=UserAPIKeyAuth(api_key="test-key", user_id="test-user-123"),
|
|
)
|
|
|
|
assert content_route.call_count == 2
|
|
assert usage_without_context.request_count == usage_with_context.request_count == 1
|
|
assert usage_without_context.total_tokens == usage_with_context.total_tokens == _token_counter_total(rows)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_batch_input_without_a_model_is_read_through_the_team_deployment(
|
|
openai_files: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
|
|
):
|
|
from litellm import Router
|
|
from litellm.proxy import proxy_server
|
|
|
|
monkeypatch.setattr(
|
|
proxy_server,
|
|
"llm_router",
|
|
Router(
|
|
model_list=[
|
|
{
|
|
"model_name": "openai/*",
|
|
"litellm_params": {
|
|
"model": "openai/*",
|
|
"api_key": "sk-team",
|
|
"api_base": "https://team.example/v1",
|
|
},
|
|
"model_info": {"id": "team-openai", "team_id": "team-1"},
|
|
}
|
|
]
|
|
),
|
|
)
|
|
_, _, batch_limiter = _make_limiters()
|
|
rows: Final = _batch_rows(("Hello",))
|
|
environment_route: Final = _serve_file(openai_files, "file-abc123", rows)
|
|
team_route: Final = openai_files.get("https://team.example/v1/files/file-abc123/content").mock(
|
|
return_value=httpx.Response(200, content="\n".join(json.dumps(row) for row in rows).encode())
|
|
)
|
|
team_key: Final = UserAPIKeyAuth(api_key="sk", team_id="team-1", team_models=["openai/*"], models=[_BATCH_MODEL])
|
|
teamless_key: Final = UserAPIKeyAuth(api_key="sk")
|
|
|
|
team_usage: Final = await batch_limiter.count_input_file_usage(
|
|
file_id="file-abc123", custom_llm_provider="openai", user_api_key_dict=team_key, data={}
|
|
)
|
|
teamless_usage: Final = await batch_limiter.count_input_file_usage(
|
|
file_id="file-abc123", custom_llm_provider="openai", user_api_key_dict=teamless_key, data={}
|
|
)
|
|
|
|
assert team_route.call_count == 1
|
|
assert team_route.calls.last.request.headers["authorization"] == "Bearer sk-team"
|
|
assert environment_route.call_count == 1
|
|
assert environment_route.calls.last.request.headers["authorization"] == "Bearer sk-test"
|
|
assert team_usage.total_tokens == teamless_usage.total_tokens == _token_counter_total(rows)
|
|
|
|
forwarded_usage: Final = await batch_limiter.count_input_file_usage(
|
|
file_id="file-abc123",
|
|
custom_llm_provider="openai",
|
|
user_api_key_dict=team_key,
|
|
data={"api_key": "sk-forwarded"},
|
|
)
|
|
|
|
assert team_route.call_count == 2
|
|
assert team_route.calls.last.request.headers["authorization"] == "Bearer sk-forwarded"
|
|
assert environment_route.call_count == 1
|
|
assert forwarded_usage.total_tokens == _token_counter_total(rows)
|
|
|
|
|
|
_ROUTED_FILE_URL: Final = "https://routed.example/v1/files/file-abc123/content"
|
|
|
|
|
|
def _serve_file_to_the_deployment_key(rows: Sequence[_BatchLine]) -> Callable[[httpx.Request], httpx.Response]:
|
|
jsonl: Final = "\n".join(json.dumps(row) for row in rows).encode()
|
|
|
|
def respond(request: httpx.Request) -> httpx.Response:
|
|
if request.headers.get("authorization") != "Bearer sk-deployment":
|
|
return httpx.Response(401, json={"error": {"message": "Incorrect API key provided"}})
|
|
return httpx.Response(200, content=jsonl)
|
|
|
|
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(
|
|
openai_files: respx.MockRouter,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
query_string: bytes,
|
|
routing_headers: tuple[tuple[bytes, bytes], ...],
|
|
):
|
|
from starlette.requests import Request
|
|
|
|
from litellm import Router
|
|
from litellm.proxy import proxy_server
|
|
from litellm.proxy.litellm_pre_call_utils import add_litellm_data_to_request
|
|
|
|
monkeypatch.setattr(
|
|
proxy_server,
|
|
"llm_router",
|
|
Router(
|
|
model_list=[
|
|
{
|
|
"model_name": "routed-batch-model",
|
|
"litellm_params": {
|
|
"model": f"openai/{_BATCH_MODEL}",
|
|
"api_key": "sk-deployment",
|
|
"api_base": "https://routed.example/v1",
|
|
},
|
|
"model_info": {"id": "routed-batch-deployment"},
|
|
}
|
|
]
|
|
),
|
|
)
|
|
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",
|
|
"method": "POST",
|
|
"path": "/v1/batches",
|
|
"raw_path": b"/v1/batches",
|
|
"root_path": "",
|
|
"scheme": "http",
|
|
"server": ("proxy.test", 80),
|
|
"client": ("127.0.0.1", 50000),
|
|
"query_string": query_string,
|
|
"headers": [(b"content-type", b"application/json"), *routing_headers],
|
|
}
|
|
)
|
|
data: Final = await add_litellm_data_to_request(
|
|
data={
|
|
"input_file_id": "file-abc123",
|
|
"endpoint": "/v1/chat/completions",
|
|
"completion_window": "24h",
|
|
"api_key": "sk-not-the-deployment-key",
|
|
},
|
|
request=request,
|
|
user_api_key_dict=user_api_key_dict,
|
|
proxy_config=MagicMock(),
|
|
general_settings={},
|
|
)
|
|
_, _, 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 file_route.call_count == 1
|
|
|
|
|
|
async def _install_managed_file_hook(
|
|
openai_files: respx.MockRouter, monkeypatch: pytest.MonkeyPatch, rows: str
|
|
) -> tuple[respx.Route, respx.Route]:
|
|
from litellm_enterprise.proxy.hooks.managed_files import _PROXY_LiteLLMManagedFiles
|
|
|
|
from litellm import Router
|
|
from litellm.models.managed_files import LiteLLM_ManagedFileTable
|
|
from litellm.proxy import proxy_server
|
|
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)
|
|
provider_route: Final = _serve_file(openai_files, "file-provider-1", rows)
|
|
unrouted_managed_read: Final = _serve_file(openai_files, _MANAGED_FILE_ID, rows)
|
|
row: Final = LiteLLM_ManagedFileTable(
|
|
unified_file_id=_MANAGED_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)
|
|
prisma_client: Final = MagicMock()
|
|
prisma_client.db.litellm_managedfiletable.find_first = AsyncMock(return_value=row)
|
|
proxy_logging: Final = ProxyLogging(user_api_key_cache=DualCache())
|
|
proxy_logging.proxy_hook_mapping["managed_files"] = _PROXY_LiteLLMManagedFiles(
|
|
internal_usage_cache=file_cache, prisma_client=prisma_client
|
|
)
|
|
router: Final = Router(
|
|
model_list=[
|
|
{
|
|
"model_name": _BATCH_MODEL,
|
|
"litellm_params": {"model": f"openai/{_BATCH_MODEL}", "api_key": "sk-test"},
|
|
"model_info": {"id": "deployment-1"},
|
|
}
|
|
]
|
|
)
|
|
monkeypatch.setattr(proxy_server, "proxy_logging_obj", proxy_logging)
|
|
monkeypatch.setattr(proxy_server, "llm_router", router)
|
|
return provider_route, unrouted_managed_read
|
|
|
|
|
|
@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
|
|
):
|
|
rows: Final = _batch_rows(("Test message for regression",))
|
|
provider_route, unrouted_managed_read = await _install_managed_file_hook(openai_files, monkeypatch, rows)
|
|
standard_route: Final = _serve_file(openai_files, "file-abc123", rows)
|
|
_, _, batch_limiter = _make_limiters()
|
|
user_api_key_dict: Final = UserAPIKeyAuth(
|
|
api_key="test-key-regression", user_id="test-user-regression", tpm_limit=1000, rpm_limit=10
|
|
)
|
|
|
|
managed_usage: Final = await batch_limiter.count_input_file_usage(
|
|
file_id=_MANAGED_FILE_ID, custom_llm_provider="openai", user_api_key_dict=user_api_key_dict
|
|
)
|
|
standard_usage: Final = await batch_limiter.count_input_file_usage(
|
|
file_id="file-abc123", custom_llm_provider="openai", user_api_key_dict=user_api_key_dict
|
|
)
|
|
|
|
assert provider_route.call_count == 1
|
|
assert standard_route.call_count == 1
|
|
assert not unrouted_managed_read.called
|
|
assert managed_usage.request_count == standard_usage.request_count == 1
|
|
assert managed_usage.total_tokens == standard_usage.total_tokens == _token_counter_total(rows)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_managed_file_of_another_tenant_is_denied_before_it_is_read(
|
|
openai_files: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
|
|
):
|
|
rows: Final = _batch_rows(("Test message for regression",))
|
|
provider_route, unrouted_managed_read = await _install_managed_file_hook(openai_files, monkeypatch, rows)
|
|
_, _, batch_limiter = _make_limiters()
|
|
outsider: Final = UserAPIKeyAuth(api_key="test-key-outsider", user_id="outsider", team_id="team-other", tpm_limit=1)
|
|
|
|
with pytest.raises(HTTPException) as denied:
|
|
await batch_limiter.async_pre_call_hook(
|
|
user_api_key_dict=outsider,
|
|
cache=DualCache(),
|
|
data=dict(_create_batch_data(_MANAGED_FILE_ID)),
|
|
call_type="acreate_batch",
|
|
)
|
|
|
|
assert denied.value.status_code == 403
|
|
assert not provider_route.called
|
|
assert not unrouted_managed_read.called
|