mirror of
https://github.com/tiennm99/litellm.git
synced 2026-07-12 23:05:52 +00:00
1197 lines
39 KiB
Python
1197 lines
39 KiB
Python
"""
|
||
Unit tests for TPM rate limit for concurrent requests
|
||
|
||
Verifies token-reservation pattern:
|
||
- Concurrent requests cannot all observe "under limit" before any of them
|
||
has incremented the counter (atomic reservation via
|
||
``atomic_check_and_increment_by_n``).
|
||
- After a successful request, the counter is reconciled to actual usage.
|
||
- After a failed request, the full reservation is released.
|
||
|
||
The reservation path delegates atomicity to ``atomic_check_and_increment_by_n``,
|
||
which uses Redis Lua when available and an asyncio-locked in-memory check
|
||
otherwise. These tests exercise the in-memory fallback so they run without
|
||
Redis.
|
||
"""
|
||
|
||
import asyncio
|
||
from datetime import datetime
|
||
from typing import Any, Dict
|
||
|
||
import pytest
|
||
|
||
from litellm.caching.caching import DualCache
|
||
from litellm.proxy._types import UserAPIKeyAuth
|
||
from litellm.proxy.hooks.parallel_request_limiter_v3 import (
|
||
RATE_LIMIT_DESCRIPTORS_KEY,
|
||
TPM_RESERVATION_RELEASED_KEY,
|
||
TPM_RESERVED_MODEL_KEY,
|
||
TPM_RESERVED_SCOPES_KEY,
|
||
TPM_RESERVED_TOKENS_KEY,
|
||
_PROXY_MaxParallelRequestsHandler_v3 as RateLimitHandler,
|
||
)
|
||
from litellm.proxy.utils import InternalUsageCache, hash_token
|
||
from litellm.types.utils import ModelResponse, Usage
|
||
|
||
|
||
@pytest.fixture
|
||
def rate_limiter():
|
||
cache = DualCache()
|
||
handler = RateLimitHandler(internal_usage_cache=InternalUsageCache(cache))
|
||
return handler, cache
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_token_reservation_prevents_concurrent_bypass(rate_limiter):
|
||
"""
|
||
With a 100 TPM limit and 5 concurrent requests each estimated at ~50+ tokens,
|
||
upfront reservation must reject the late arrivals — not let all 5 through.
|
||
Exercises the in-memory fallback in ``atomic_check_and_increment_by_n``.
|
||
"""
|
||
handler, cache = rate_limiter
|
||
|
||
api_key = hash_token("sk-test-key")
|
||
user_api_key_dict = UserAPIKeyAuth(
|
||
api_key=api_key,
|
||
tpm_limit=100,
|
||
)
|
||
|
||
request_data = {
|
||
"model": "gpt-3.5-turbo",
|
||
"messages": [
|
||
{
|
||
"role": "user",
|
||
"content": "Hello, this is a test message for concurrent bypass testing.",
|
||
}
|
||
],
|
||
"max_tokens": 50,
|
||
}
|
||
|
||
async def make_request(request_id: int) -> Dict[str, Any]:
|
||
data = request_data.copy()
|
||
try:
|
||
await handler.async_pre_call_hook(
|
||
user_api_key_dict=user_api_key_dict,
|
||
cache=cache,
|
||
data=data,
|
||
call_type="",
|
||
)
|
||
return {
|
||
"request_id": request_id,
|
||
"success": True,
|
||
"reserved_tokens": data.get(TPM_RESERVED_TOKENS_KEY, 0),
|
||
}
|
||
except Exception as e:
|
||
return {
|
||
"request_id": request_id,
|
||
"success": False,
|
||
"error": str(e),
|
||
"status_code": getattr(e, "status_code", None),
|
||
}
|
||
|
||
tasks = [make_request(i) for i in range(5)]
|
||
results = await asyncio.gather(*tasks)
|
||
|
||
successful = [r for r in results if r["success"]]
|
||
failed = [r for r in results if not r["success"]]
|
||
rate_limited = [r for r in failed if r.get("status_code") == 429]
|
||
|
||
assert len(rate_limited) > 0, (
|
||
f"Expected some rate-limited requests but all {len(successful)} succeeded — "
|
||
f"the concurrent bypass bug is still present."
|
||
)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_no_leak_on_over_limit_rejection(rate_limiter):
|
||
"""
|
||
When a reservation would exceed the TPM limit, the counter must NOT be
|
||
bumped. Otherwise rejected requests would silently consume quota with no
|
||
path to refund (the failure callback only fires after the reservation
|
||
was successfully stashed).
|
||
"""
|
||
handler, cache = rate_limiter
|
||
|
||
user_api_key_dict = UserAPIKeyAuth(
|
||
api_key=hash_token("sk-no-leak"),
|
||
tpm_limit=10, # tiny limit, easy to blow past
|
||
)
|
||
counter_key = handler.create_rate_limit_keys(
|
||
key="api_key", value=user_api_key_dict.api_key, rate_limit_type="tokens"
|
||
)
|
||
|
||
# Reservation will estimate >> 10 tokens, so this should be rejected.
|
||
data = {
|
||
"model": "gpt-3.5-turbo",
|
||
"messages": [{"role": "user", "content": "x" * 200}],
|
||
"max_tokens": 200,
|
||
}
|
||
|
||
estimated = handler._estimate_tokens_for_request(data=data)
|
||
assert estimated > user_api_key_dict.tpm_limit, (
|
||
"Test assumes the reservation amount blows past the limit; "
|
||
f"estimated={estimated}, limit={user_api_key_dict.tpm_limit}"
|
||
)
|
||
|
||
with pytest.raises(Exception) as exc_info:
|
||
await handler.async_pre_call_hook(
|
||
user_api_key_dict=user_api_key_dict,
|
||
cache=cache,
|
||
data=data,
|
||
call_type="",
|
||
)
|
||
assert getattr(exc_info.value, "status_code", None) == 429
|
||
|
||
# The reservation bump (estimated_tokens) must NOT have committed. The
|
||
# counter may carry a tiny pre-existing bump from should_rate_limit's
|
||
# per-request +1 sliding-window logic, but it must be far below the
|
||
# reservation amount — proving the all-or-nothing primitive rolled back
|
||
# cleanly on rejection.
|
||
cached_value = await cache.async_get_cache(key=counter_key, local_only=True)
|
||
cached_int = int(cached_value or 0)
|
||
assert cached_int < estimated, (
|
||
f"Reservation leaked: counter={cached_int} after rejection of an "
|
||
f"estimated_tokens={estimated} reservation."
|
||
)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_token_adjustment_on_success(rate_limiter):
|
||
"""
|
||
On success a reserved scope's counter is reconciled to actual via
|
||
`actual - reserved`. With actual=50 and reserved=100, the api_key
|
||
counter should see a -50 delta — and only because api_key was
|
||
reserved against. Unreserved scopes get the full +actual instead.
|
||
"""
|
||
handler, _cache = rate_limiter
|
||
|
||
api_key = hash_token("sk-test-adjust")
|
||
|
||
mock_kwargs = {
|
||
"standard_logging_object": {
|
||
"metadata": {
|
||
"user_api_key_hash": api_key,
|
||
TPM_RESERVED_TOKENS_KEY: 100,
|
||
TPM_RESERVED_SCOPES_KEY: [["api_key", api_key]],
|
||
}
|
||
},
|
||
"model": "gpt-3.5-turbo",
|
||
}
|
||
|
||
mock_response = ModelResponse(
|
||
id="test",
|
||
object="chat.completion",
|
||
created=int(datetime.now().timestamp()),
|
||
model="gpt-3.5-turbo",
|
||
usage=Usage(prompt_tokens=20, completion_tokens=30, total_tokens=50),
|
||
choices=[],
|
||
)
|
||
|
||
increments = []
|
||
|
||
async def mock_increment(increment_list, **kwargs):
|
||
for op in increment_list:
|
||
increments.append(
|
||
{
|
||
"key": op["key"],
|
||
"increment": op["increment_value"],
|
||
}
|
||
)
|
||
|
||
handler.internal_usage_cache.dual_cache.async_increment_cache_pipeline = (
|
||
mock_increment
|
||
)
|
||
|
||
await handler.async_log_success_event(
|
||
kwargs=mock_kwargs,
|
||
response_obj=mock_response,
|
||
start_time=datetime.now(),
|
||
end_time=datetime.now(),
|
||
)
|
||
|
||
token_adjustments = [i for i in increments if "tokens" in i["key"]]
|
||
|
||
assert any(i["increment"] == -50 for i in token_adjustments), (
|
||
f"Expected a -50 token adjustment (50 actual - 100 reserved) but got: "
|
||
f"{token_adjustments}"
|
||
)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_token_release_on_failure(rate_limiter):
|
||
"""On failure the entire reservation must be refunded — but only against
|
||
scopes that were actually charged at pre-call. Unreserved scopes were
|
||
never incremented and must not receive a -reserved op (would drift
|
||
negative)."""
|
||
handler, _cache = rate_limiter
|
||
|
||
api_key = hash_token("sk-test-fail")
|
||
|
||
mock_kwargs = {
|
||
"standard_logging_object": {
|
||
"metadata": {
|
||
"user_api_key_hash": api_key,
|
||
TPM_RESERVED_TOKENS_KEY: 100,
|
||
TPM_RESERVED_SCOPES_KEY: [["api_key", api_key]],
|
||
}
|
||
},
|
||
}
|
||
|
||
increments = []
|
||
|
||
async def mock_increment(increment_list, **kwargs):
|
||
for op in increment_list:
|
||
increments.append(
|
||
{
|
||
"key": op["key"],
|
||
"increment": op["increment_value"],
|
||
}
|
||
)
|
||
|
||
handler.internal_usage_cache.dual_cache.async_increment_cache_pipeline = (
|
||
mock_increment
|
||
)
|
||
|
||
await handler.async_log_failure_event(
|
||
kwargs=mock_kwargs,
|
||
response_obj=None,
|
||
start_time=datetime.now(),
|
||
end_time=datetime.now(),
|
||
)
|
||
|
||
token_releases = [i for i in increments if "tokens" in i["key"]]
|
||
|
||
assert any(
|
||
i["increment"] == -100 for i in token_releases
|
||
), f"Expected the full reservation (-100) to be released, got: {token_releases}"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_model_scope_refund_targets_reserved_model(rate_limiter):
|
||
"""
|
||
The pre-call reservation is charged against ``data["model"]`` but the
|
||
router later writes ``model_group`` into ``litellm_params.metadata``,
|
||
which can be ``None`` or a different value. Reconciliation MUST refund the
|
||
same model-scoped counter that was incremented; otherwise model-level
|
||
counters (model_per_team / model_per_key / etc.) drift up forever.
|
||
|
||
This test makes ``model_group`` absent from kwargs (the failure mode in
|
||
the Greptile P1) and asserts the refund still targets the model the
|
||
reservation used.
|
||
"""
|
||
handler, _cache = rate_limiter
|
||
|
||
api_key = hash_token("sk-test-model-mismatch")
|
||
team_id = "team-abc"
|
||
reserved_model = "gpt-4o-mini"
|
||
|
||
mock_kwargs = {
|
||
# NOTE: no litellm_params.metadata.model_group — get_model_group_from_litellm_kwargs
|
||
# returns None on this kwargs dict.
|
||
"standard_logging_object": {
|
||
"metadata": {
|
||
"user_api_key_hash": api_key,
|
||
"user_api_key_team_id": team_id,
|
||
TPM_RESERVED_TOKENS_KEY: 100,
|
||
TPM_RESERVED_MODEL_KEY: reserved_model,
|
||
TPM_RESERVED_SCOPES_KEY: [
|
||
["model_per_team", f"{team_id}:{reserved_model}"]
|
||
],
|
||
}
|
||
},
|
||
}
|
||
|
||
increments = []
|
||
|
||
async def mock_increment(increment_list, **kwargs):
|
||
for op in increment_list:
|
||
increments.append({"key": op["key"], "increment": op["increment_value"]})
|
||
|
||
handler.internal_usage_cache.dual_cache.async_increment_cache_pipeline = (
|
||
mock_increment
|
||
)
|
||
|
||
await handler.async_log_failure_event(
|
||
kwargs=mock_kwargs,
|
||
response_obj=None,
|
||
start_time=datetime.now(),
|
||
end_time=datetime.now(),
|
||
)
|
||
|
||
expected_model_per_team_key = handler.create_rate_limit_keys(
|
||
key="model_per_team",
|
||
value=f"{team_id}:{reserved_model}",
|
||
rate_limit_type="tokens",
|
||
)
|
||
matching = [i for i in increments if i["key"] == expected_model_per_team_key]
|
||
assert matching, (
|
||
f"Expected a refund on the reserved model_per_team counter "
|
||
f"({expected_model_per_team_key}) but got: "
|
||
f"{[i['key'] for i in increments]}"
|
||
)
|
||
assert matching[0]["increment"] == -100, (
|
||
f"Expected full -100 refund on model_per_team counter, got "
|
||
f"{matching[0]['increment']}"
|
||
)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_should_rate_limit_does_not_inflate_tokens_counter(rate_limiter):
|
||
"""
|
||
The pre-call sliding-window check (`should_rate_limit`) must not bump the
|
||
`:tokens` counter. That counter is owned exclusively by the atomic
|
||
`reserve_tpm_tokens` path; double-handling shrinks the effective TPM
|
||
budget by 1 per concurrent in-flight request.
|
||
"""
|
||
handler, cache = rate_limiter
|
||
|
||
api_key = hash_token("sk-no-tokens-inflation")
|
||
user_api_key_dict = UserAPIKeyAuth(
|
||
api_key=api_key,
|
||
rpm_limit=100,
|
||
tpm_limit=10_000,
|
||
)
|
||
|
||
tokens_counter_key = handler.create_rate_limit_keys(
|
||
key="api_key", value=api_key, rate_limit_type="tokens"
|
||
)
|
||
|
||
data = {
|
||
"model": "gpt-3.5-turbo",
|
||
"messages": [{"role": "user", "content": "hi"}],
|
||
"max_tokens": 10,
|
||
}
|
||
|
||
estimated = handler._estimate_tokens_for_request(data=data)
|
||
|
||
await handler.async_pre_call_hook(
|
||
user_api_key_dict=user_api_key_dict,
|
||
cache=cache,
|
||
data=data,
|
||
call_type="",
|
||
)
|
||
|
||
cached = int(
|
||
await cache.async_get_cache(key=tokens_counter_key, local_only=True) or 0
|
||
)
|
||
|
||
# The :tokens counter should reflect ONLY the reservation amount — not
|
||
# an additional +1 from the should_rate_limit pre-pass.
|
||
assert cached == estimated, (
|
||
f"Expected :tokens counter to equal the reservation ({estimated}) "
|
||
f"with no +1 inflation from should_rate_limit, got {cached}"
|
||
)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_concurrent_burst_within_tpm_budget_all_succeed(rate_limiter):
|
||
"""
|
||
With a TPM limit comfortably above (N concurrent × per-request reservation),
|
||
all N requests must succeed. Pre-fix the should_rate_limit +1-per-key
|
||
inflation could 429 late arrivals on tight budgets.
|
||
"""
|
||
handler, cache = rate_limiter
|
||
|
||
user_api_key_dict = UserAPIKeyAuth(
|
||
api_key=hash_token("sk-burst-budget"),
|
||
tpm_limit=1000,
|
||
rpm_limit=100,
|
||
)
|
||
|
||
request_data = {
|
||
"model": "gpt-3.5-turbo",
|
||
"messages": [{"role": "user", "content": "x" * 40}], # ~10 input tokens
|
||
"max_tokens": 100,
|
||
}
|
||
|
||
estimated_per_request = handler._estimate_tokens_for_request(data=request_data)
|
||
n_concurrent = 3
|
||
# Sanity: total reservation must fit within tpm_limit and we want enough
|
||
# headroom that any +1 inflation would NOT push us over.
|
||
assert estimated_per_request * n_concurrent < user_api_key_dict.tpm_limit
|
||
|
||
async def make_request(request_id: int):
|
||
data = request_data.copy()
|
||
try:
|
||
await handler.async_pre_call_hook(
|
||
user_api_key_dict=user_api_key_dict,
|
||
cache=cache,
|
||
data=data,
|
||
call_type="",
|
||
)
|
||
return True
|
||
except Exception:
|
||
return False
|
||
|
||
results = await asyncio.gather(*[make_request(i) for i in range(n_concurrent)])
|
||
|
||
assert all(results), (
|
||
f"All {n_concurrent} requests should fit within tpm_limit="
|
||
f"{user_api_key_dict.tpm_limit} (estimated_per_request="
|
||
f"{estimated_per_request}), but only {sum(results)} succeeded — "
|
||
f"the should_rate_limit :tokens-counter inflation bug is back."
|
||
)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_org_scope_refund_on_failure(rate_limiter):
|
||
"""
|
||
The plain `organization` scope is reserved upfront (it carries
|
||
tokens_per_unit) — so on failure, the full reservation must be released
|
||
against {organization:org_id}:tokens. Pre-fix this scope was missing
|
||
from `_build_tpm_scope_pipeline_operations`, leaking forever.
|
||
"""
|
||
handler, _cache = rate_limiter
|
||
|
||
api_key = hash_token("sk-org-refund")
|
||
org_id = "org-acme"
|
||
|
||
mock_kwargs = {
|
||
"standard_logging_object": {
|
||
"metadata": {
|
||
"user_api_key_hash": api_key,
|
||
"user_api_key_org_id": org_id,
|
||
TPM_RESERVED_TOKENS_KEY: 100,
|
||
TPM_RESERVED_SCOPES_KEY: [["organization", org_id]],
|
||
}
|
||
},
|
||
}
|
||
|
||
increments = []
|
||
|
||
async def mock_increment(increment_list, **kwargs):
|
||
for op in increment_list:
|
||
increments.append({"key": op["key"], "increment": op["increment_value"]})
|
||
|
||
handler.internal_usage_cache.dual_cache.async_increment_cache_pipeline = (
|
||
mock_increment
|
||
)
|
||
|
||
await handler.async_log_failure_event(
|
||
kwargs=mock_kwargs,
|
||
response_obj=None,
|
||
start_time=datetime.now(),
|
||
end_time=datetime.now(),
|
||
)
|
||
|
||
expected_org_key = handler.create_rate_limit_keys(
|
||
key="organization", value=org_id, rate_limit_type="tokens"
|
||
)
|
||
matching = [i for i in increments if i["key"] == expected_org_key]
|
||
assert matching, (
|
||
f"Expected a refund on the org tokens counter ({expected_org_key}) "
|
||
f"but got keys: {[i['key'] for i in increments]}"
|
||
)
|
||
assert (
|
||
matching[0]["increment"] == -100
|
||
), f"Expected full -100 refund on org counter, got {matching[0]['increment']}"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_org_scope_reconciled_on_success(rate_limiter):
|
||
"""
|
||
On success the org tokens counter must be reconciled to actual usage.
|
||
With reserved=100 and actual=50, the org scope should see a -50 delta.
|
||
"""
|
||
handler, _cache = rate_limiter
|
||
|
||
api_key = hash_token("sk-org-success")
|
||
org_id = "org-acme"
|
||
|
||
mock_kwargs = {
|
||
"standard_logging_object": {
|
||
"metadata": {
|
||
"user_api_key_hash": api_key,
|
||
"user_api_key_org_id": org_id,
|
||
TPM_RESERVED_TOKENS_KEY: 100,
|
||
TPM_RESERVED_SCOPES_KEY: [["organization", org_id]],
|
||
}
|
||
},
|
||
"model": "gpt-3.5-turbo",
|
||
}
|
||
|
||
mock_response = ModelResponse(
|
||
id="test",
|
||
object="chat.completion",
|
||
created=int(datetime.now().timestamp()),
|
||
model="gpt-3.5-turbo",
|
||
usage=Usage(prompt_tokens=20, completion_tokens=30, total_tokens=50),
|
||
choices=[],
|
||
)
|
||
|
||
increments = []
|
||
|
||
async def mock_increment(increment_list, **kwargs):
|
||
for op in increment_list:
|
||
increments.append({"key": op["key"], "increment": op["increment_value"]})
|
||
|
||
handler.internal_usage_cache.dual_cache.async_increment_cache_pipeline = (
|
||
mock_increment
|
||
)
|
||
|
||
await handler.async_log_success_event(
|
||
kwargs=mock_kwargs,
|
||
response_obj=mock_response,
|
||
start_time=datetime.now(),
|
||
end_time=datetime.now(),
|
||
)
|
||
|
||
expected_org_key = handler.create_rate_limit_keys(
|
||
key="organization", value=org_id, rate_limit_type="tokens"
|
||
)
|
||
matching = [i for i in increments if i["key"] == expected_org_key]
|
||
assert matching, (
|
||
f"Expected a reconciliation op on the org tokens counter "
|
||
f"({expected_org_key}), got keys: {[i['key'] for i in increments]}"
|
||
)
|
||
assert matching[0]["increment"] == -50, (
|
||
f"Expected -50 delta on org counter (50 actual - 100 reserved), got "
|
||
f"{matching[0]['increment']}"
|
||
)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_estimate_tokens_uses_max_tokens_when_explicit(rate_limiter):
|
||
"""When max_tokens is set explicitly, reservation should equal input + max_tokens."""
|
||
handler, _cache = rate_limiter
|
||
|
||
estimate = handler._estimate_tokens_for_request(
|
||
data={
|
||
"messages": [
|
||
{"role": "user", "content": "abcd" * 4}
|
||
], # 16 chars ~ 4 tokens
|
||
"max_tokens": 25,
|
||
}
|
||
)
|
||
# input ~= 16/4 = 4 tokens; max_tokens = 25; total ~= 29
|
||
assert estimate == 4 + 25
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_estimate_tokens_zero_for_empty_embeddings(rate_limiter):
|
||
"""Embeddings have no output budget — reservation should equal input only."""
|
||
handler, _cache = rate_limiter
|
||
|
||
estimate = handler._estimate_tokens_for_request(
|
||
data={"input": "hello world"} # 11 chars
|
||
)
|
||
# input ~= 11/4 = 2 tokens (max(1, 11//4)); max_tokens = 0
|
||
assert estimate == 2
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_contentless_request_reserves_minimum(rate_limiter):
|
||
"""
|
||
A contentless request (no messages/prompt/input — e.g. /responses,
|
||
tool-call continuations) must still hit the atomic counter so concurrent
|
||
contentless requests don't all observe "under limit". Pre-fix the
|
||
`has_estimable_content` short-circuit skipped the reservation entirely
|
||
and post-call reconciliation provided no backpressure.
|
||
"""
|
||
handler, cache = rate_limiter
|
||
|
||
api_key = hash_token("sk-contentless")
|
||
user_api_key_dict = UserAPIKeyAuth(api_key=api_key, tpm_limit=2)
|
||
|
||
counter_key = handler.create_rate_limit_keys(
|
||
key="api_key", value=api_key, rate_limit_type="tokens"
|
||
)
|
||
|
||
# Two contentless requests should consume two slots of the 2-token
|
||
# budget. The third must 429.
|
||
for _ in range(2):
|
||
data = {"model": "gpt-3.5-turbo"}
|
||
await handler.async_pre_call_hook(
|
||
user_api_key_dict=user_api_key_dict,
|
||
cache=cache,
|
||
data=data,
|
||
call_type="",
|
||
)
|
||
assert (data.get("metadata") or {}).get(
|
||
TPM_RESERVED_TOKENS_KEY
|
||
) == 1, "Contentless request should reserve the floor of 1 token"
|
||
|
||
counter_after_two = int(
|
||
await cache.async_get_cache(key=counter_key, local_only=True) or 0
|
||
)
|
||
assert counter_after_two == 2, (
|
||
f"After two contentless requests at the floor, the api_key tokens "
|
||
f"counter should be 2, got {counter_after_two}"
|
||
)
|
||
|
||
with pytest.raises(Exception) as exc_info:
|
||
await handler.async_pre_call_hook(
|
||
user_api_key_dict=user_api_key_dict,
|
||
cache=cache,
|
||
data={"model": "gpt-3.5-turbo"},
|
||
call_type="",
|
||
)
|
||
assert getattr(exc_info.value, "status_code", None) == 429, (
|
||
"Third contentless request must be rate-limited; pre-fix it would "
|
||
"have bypassed the TPM check entirely."
|
||
)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_atomic_keys_share_hash_tag_per_descriptor(rate_limiter):
|
||
"""
|
||
Cluster safety: every key in a single descriptor's Lua payload must
|
||
share a `{key:value}` hash tag so the call lands on a single Redis
|
||
Cluster slot. Otherwise the Lua script raises CROSSSLOT in cluster mode.
|
||
"""
|
||
handler, _cache = rate_limiter
|
||
|
||
descriptors = [
|
||
{
|
||
"key": "api_key",
|
||
"value": "abc",
|
||
"rate_limit": {
|
||
"requests_per_unit": 10,
|
||
"tokens_per_unit": 100,
|
||
"window_size": 60,
|
||
},
|
||
},
|
||
{
|
||
"key": "user",
|
||
"value": "xyz",
|
||
"rate_limit": {"tokens_per_unit": 200, "window_size": 60},
|
||
},
|
||
]
|
||
increments = [{"requests": 1, "tokens": 10}, {"tokens": 10}]
|
||
|
||
for descriptor, inc in zip(descriptors, increments):
|
||
keys, _args, _meta = handler._build_descriptor_atomic_payload(
|
||
descriptor=descriptor,
|
||
increment_amounts=inc,
|
||
)
|
||
# All keys in a descriptor's payload must share the same {tag}
|
||
# — that's the prefix between the first '{' and '}'.
|
||
tags = {k[: k.index("}") + 1] for k in keys}
|
||
assert len(tags) == 1, (
|
||
f"Descriptor {descriptor['key']}:{descriptor['value']} produced "
|
||
f"keys spanning multiple hash tags: {tags}. Redis Cluster would "
|
||
f"reject this Lua call with CROSSSLOT."
|
||
)
|
||
expected_tag = f"{{{descriptor['key']}:{descriptor['value']}}}"
|
||
assert tags == {expected_tag}, f"Expected hash tag {expected_tag}, got {tags}"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_reservation_released_on_proxy_rejection(rate_limiter):
|
||
"""
|
||
If the request is rejected after the pre-call reservation succeeds but
|
||
before the LLM call (e.g. a downstream guardrail/auth hook raises),
|
||
`async_post_call_failure_hook` must release the reservation. Otherwise
|
||
the tokens leak — `async_log_failure_event` is a litellm completion
|
||
callback and never fires for proxy-side rejections.
|
||
"""
|
||
handler, cache = rate_limiter
|
||
|
||
api_key = hash_token("sk-leak-fix")
|
||
user_api_key_dict = UserAPIKeyAuth(api_key=api_key, tpm_limit=1000)
|
||
|
||
data = {
|
||
"model": "gpt-3.5-turbo",
|
||
"messages": [{"role": "user", "content": "hello"}],
|
||
"max_tokens": 50,
|
||
}
|
||
|
||
await handler.async_pre_call_hook(
|
||
user_api_key_dict=user_api_key_dict,
|
||
cache=cache,
|
||
data=data,
|
||
call_type="",
|
||
)
|
||
reserved = (data.get("metadata") or {})[TPM_RESERVED_TOKENS_KEY]
|
||
assert reserved > 0
|
||
|
||
counter_key = handler.create_rate_limit_keys(
|
||
key="api_key", value=api_key, rate_limit_type="tokens"
|
||
)
|
||
counter_after_reserve = int(
|
||
await cache.async_get_cache(key=counter_key, local_only=True) or 0
|
||
)
|
||
assert counter_after_reserve == reserved
|
||
|
||
# Simulate a downstream guardrail rejecting the request.
|
||
await handler.async_post_call_failure_hook(
|
||
request_data=data,
|
||
original_exception=Exception("guardrail rejected"),
|
||
user_api_key_dict=user_api_key_dict,
|
||
)
|
||
|
||
counter_after_release = int(
|
||
await cache.async_get_cache(key=counter_key, local_only=True) or 0
|
||
)
|
||
assert counter_after_release == 0, (
|
||
f"Reservation leaked: counter={counter_after_release} after "
|
||
f"proxy-level rejection refund (expected 0)."
|
||
)
|
||
assert (data.get("metadata") or {}).get(TPM_RESERVATION_RELEASED_KEY) is True, (
|
||
"Released marker must be stamped to prevent "
|
||
"async_log_failure_event from double-refunding."
|
||
)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_reservation_release_idempotent(rate_limiter):
|
||
"""
|
||
If both `async_post_call_failure_hook` and `async_log_failure_event` end
|
||
up firing for the same request, only the first refund applies — the
|
||
second sees the released marker and no-ops.
|
||
"""
|
||
handler, _cache = rate_limiter
|
||
|
||
api_key = hash_token("sk-idempotent")
|
||
|
||
increments = []
|
||
|
||
async def mock_increment(increment_list, **kwargs):
|
||
for op in increment_list:
|
||
increments.append({"key": op["key"], "increment": op["increment_value"]})
|
||
|
||
handler.internal_usage_cache.dual_cache.async_increment_cache_pipeline = (
|
||
mock_increment
|
||
)
|
||
|
||
# Shared metadata dict simulates the propagation between
|
||
# request_data["metadata"] and kwargs["litellm_params"]["metadata"] —
|
||
# the post-call-failure-hook stamps the released marker there, and the
|
||
# log-failure-event reads it.
|
||
shared_metadata = {
|
||
"user_api_key_hash": api_key,
|
||
TPM_RESERVED_TOKENS_KEY: 100,
|
||
RATE_LIMIT_DESCRIPTORS_KEY: [
|
||
{
|
||
"key": "api_key",
|
||
"value": api_key,
|
||
"rate_limit": {"tokens_per_unit": 10000, "window_size": 60},
|
||
}
|
||
],
|
||
}
|
||
|
||
request_data = {
|
||
"metadata": shared_metadata,
|
||
}
|
||
|
||
await handler.async_post_call_failure_hook(
|
||
request_data=request_data,
|
||
original_exception=Exception("rejected"),
|
||
user_api_key_dict=UserAPIKeyAuth(api_key=api_key),
|
||
)
|
||
|
||
first_refund_count = len([i for i in increments if "tokens" in i["key"]])
|
||
assert first_refund_count > 0, "First refund should have applied"
|
||
|
||
# Now simulate async_log_failure_event firing afterwards. It must see
|
||
# the released marker (via shared metadata) and not double-refund.
|
||
await handler.async_log_failure_event(
|
||
kwargs={
|
||
"litellm_params": {"metadata": shared_metadata},
|
||
"standard_logging_object": {"metadata": shared_metadata},
|
||
},
|
||
response_obj=None,
|
||
start_time=datetime.now(),
|
||
end_time=datetime.now(),
|
||
)
|
||
|
||
second_refund_count = len([i for i in increments if "tokens" in i["key"]])
|
||
assert second_refund_count == first_refund_count, (
|
||
f"Idempotency violated: refund count went from {first_refund_count} "
|
||
f"to {second_refund_count} after second hook fired."
|
||
)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_unreserved_scopes_charged_actual_not_delta_on_success(rate_limiter):
|
||
"""
|
||
Counter-drift fix: a scope present in metadata but NOT reserved at
|
||
pre-call (no configured TPM limit for it) must be charged the full
|
||
`actual_tokens` on success — never the `delta = actual - reserved`.
|
||
Otherwise that scope's counter goes negative whenever `actual < reserved`
|
||
(the common case, since the reservation includes a conservative output
|
||
pad).
|
||
"""
|
||
handler, _cache = rate_limiter
|
||
|
||
api_key = hash_token("sk-mixed-scopes")
|
||
team_id = "team-no-tpm-limit"
|
||
|
||
# Reservation ONLY hit api_key — team had no TPM limit configured.
|
||
mock_kwargs = {
|
||
"standard_logging_object": {
|
||
"metadata": {
|
||
"user_api_key_hash": api_key,
|
||
"user_api_key_team_id": team_id,
|
||
TPM_RESERVED_TOKENS_KEY: 100,
|
||
TPM_RESERVED_SCOPES_KEY: [["api_key", api_key]],
|
||
}
|
||
},
|
||
"model": "gpt-3.5-turbo",
|
||
}
|
||
|
||
mock_response = ModelResponse(
|
||
id="t",
|
||
object="chat.completion",
|
||
created=int(datetime.now().timestamp()),
|
||
model="gpt-3.5-turbo",
|
||
usage=Usage(prompt_tokens=20, completion_tokens=30, total_tokens=50),
|
||
choices=[],
|
||
)
|
||
|
||
increments = []
|
||
|
||
async def mock_increment(increment_list, **kwargs):
|
||
for op in increment_list:
|
||
increments.append({"key": op["key"], "increment": op["increment_value"]})
|
||
|
||
handler.internal_usage_cache.dual_cache.async_increment_cache_pipeline = (
|
||
mock_increment
|
||
)
|
||
|
||
await handler.async_log_success_event(
|
||
kwargs=mock_kwargs,
|
||
response_obj=mock_response,
|
||
start_time=datetime.now(),
|
||
end_time=datetime.now(),
|
||
)
|
||
|
||
api_key_token_key = handler.create_rate_limit_keys(
|
||
key="api_key", value=api_key, rate_limit_type="tokens"
|
||
)
|
||
team_token_key = handler.create_rate_limit_keys(
|
||
key="team", value=team_id, rate_limit_type="tokens"
|
||
)
|
||
|
||
api_key_ops = [i for i in increments if i["key"] == api_key_token_key]
|
||
team_ops = [i for i in increments if i["key"] == team_token_key]
|
||
|
||
assert api_key_ops and api_key_ops[0]["increment"] == -50, (
|
||
f"Reserved api_key scope must reconcile via delta (50-100=-50), "
|
||
f"got {api_key_ops}"
|
||
)
|
||
assert team_ops and team_ops[0]["increment"] == 50, (
|
||
f"Unreserved team scope must be charged full actual (+50), not the "
|
||
f"-50 delta (which would drift its counter negative). Got {team_ops}"
|
||
)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_unreserved_scopes_not_refunded_on_failure(rate_limiter):
|
||
"""
|
||
Failure refund must only emit ops against scopes the reservation
|
||
actually charged. Refunding an unreserved scope (which was never
|
||
incremented at pre-call) would drive its counter to -reserved.
|
||
"""
|
||
handler, _cache = rate_limiter
|
||
|
||
api_key = hash_token("sk-mixed-fail")
|
||
team_id = "team-no-tpm"
|
||
|
||
mock_kwargs = {
|
||
"standard_logging_object": {
|
||
"metadata": {
|
||
"user_api_key_hash": api_key,
|
||
"user_api_key_team_id": team_id,
|
||
TPM_RESERVED_TOKENS_KEY: 100,
|
||
TPM_RESERVED_SCOPES_KEY: [["api_key", api_key]],
|
||
}
|
||
},
|
||
}
|
||
|
||
increments = []
|
||
|
||
async def mock_increment(increment_list, **kwargs):
|
||
for op in increment_list:
|
||
increments.append({"key": op["key"], "increment": op["increment_value"]})
|
||
|
||
handler.internal_usage_cache.dual_cache.async_increment_cache_pipeline = (
|
||
mock_increment
|
||
)
|
||
|
||
await handler.async_log_failure_event(
|
||
kwargs=mock_kwargs,
|
||
response_obj=None,
|
||
start_time=datetime.now(),
|
||
end_time=datetime.now(),
|
||
)
|
||
|
||
team_token_key = handler.create_rate_limit_keys(
|
||
key="team", value=team_id, rate_limit_type="tokens"
|
||
)
|
||
api_key_token_key = handler.create_rate_limit_keys(
|
||
key="api_key", value=api_key, rate_limit_type="tokens"
|
||
)
|
||
|
||
team_ops = [i for i in increments if i["key"] == team_token_key]
|
||
api_key_ops = [i for i in increments if i["key"] == api_key_token_key]
|
||
|
||
assert not team_ops, (
|
||
f"Unreserved team scope must NOT be refunded (would drift negative), "
|
||
f"got {team_ops}"
|
||
)
|
||
assert (
|
||
api_key_ops and api_key_ops[0]["increment"] == -100
|
||
), f"Reserved api_key scope must be refunded -100, got {api_key_ops}"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_token_rate_limit_headers_present_in_stored_response(rate_limiter):
|
||
"""
|
||
With `skip_tpm_check=True` on the RPM sliding-window pass, token statuses
|
||
only come from `reserve_tpm_tokens`. They must be merged into
|
||
`data["litellm_proxy_rate_limit_response"]` so the post-call hook can
|
||
emit `x-ratelimit-{key}-remaining-tokens` / `-limit-tokens` headers to
|
||
the client.
|
||
"""
|
||
handler, cache = rate_limiter
|
||
|
||
api_key = hash_token("sk-headers")
|
||
user_api_key_dict = UserAPIKeyAuth(
|
||
api_key=api_key,
|
||
rpm_limit=100,
|
||
tpm_limit=10_000,
|
||
)
|
||
|
||
data = {
|
||
"model": "gpt-3.5-turbo",
|
||
"messages": [{"role": "user", "content": "hello"}],
|
||
"max_tokens": 20,
|
||
}
|
||
|
||
await handler.async_pre_call_hook(
|
||
user_api_key_dict=user_api_key_dict,
|
||
cache=cache,
|
||
data=data,
|
||
call_type="",
|
||
)
|
||
|
||
response = data.get("litellm_proxy_rate_limit_response")
|
||
assert isinstance(
|
||
response, dict
|
||
), "Expected litellm_proxy_rate_limit_response to be set after pre-call"
|
||
|
||
statuses = response.get("statuses") or []
|
||
token_statuses = [s for s in statuses if s.get("rate_limit_type") == "tokens"]
|
||
request_statuses = [s for s in statuses if s.get("rate_limit_type") == "requests"]
|
||
|
||
assert token_statuses, (
|
||
f"Token rate-limit status missing from stored response. Without it, "
|
||
f"x-ratelimit-*-tokens headers never reach the client. Got "
|
||
f"statuses: {[(s.get('descriptor_key'), s.get('rate_limit_type')) for s in statuses]}"
|
||
)
|
||
assert request_statuses, (
|
||
"RPM rate-limit status was clobbered by the TPM merge — both must "
|
||
"coexist in the stored response."
|
||
)
|
||
|
||
# The token status carries the limit and a positive remaining budget.
|
||
api_key_tokens = next(
|
||
(s for s in token_statuses if s.get("descriptor_key") == "api_key"),
|
||
None,
|
||
)
|
||
assert api_key_tokens is not None, f"api_key token status absent: {token_statuses}"
|
||
assert api_key_tokens["current_limit"] == 10_000
|
||
assert api_key_tokens["limit_remaining"] >= 0
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_estimate_tokens_floor_caps_at_smallest_configured_tpm(rate_limiter):
|
||
"""
|
||
Regression: with a small configured TPM cap and no max_tokens, the
|
||
output-budget floor must be capped at a fraction of that limit so the
|
||
reservation alone can't trip the limit.
|
||
"""
|
||
handler, _cache = rate_limiter
|
||
|
||
estimate = handler._estimate_tokens_for_request(
|
||
data={"messages": [{"role": "user", "content": "hello"}]},
|
||
min_configured_tpm_limit=1000,
|
||
)
|
||
# input ~= 5//4 = 1 token; output floor capped at 1000//4 = 250;
|
||
# total ~= 251 (well under 1000).
|
||
assert (
|
||
estimate <= 1000 // 2
|
||
), f"With TPM=1000, reservation must stay well under the limit; got {estimate}"
|
||
assert estimate >= 1, "Estimate must be at least the call-site floor of 1"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_estimate_tokens_floor_unchanged_for_large_tpm(rate_limiter):
|
||
"""
|
||
Large TPM budgets must keep the 1024-token floor so a stream of small
|
||
concurrent requests can't collectively bypass the limit.
|
||
"""
|
||
handler, _cache = rate_limiter
|
||
|
||
estimate = handler._estimate_tokens_for_request(
|
||
data={"messages": [{"role": "user", "content": "hello"}]},
|
||
min_configured_tpm_limit=100_000,
|
||
)
|
||
# input ~= 1; output floor = min(1024, 100_000//4=25_000) = 1024;
|
||
# total ~= 1025.
|
||
assert estimate == 1 + 1024
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_estimate_tokens_floor_unchanged_when_kwarg_omitted(rate_limiter):
|
||
"""
|
||
Callers that don't pass min_configured_tpm_limit (legacy path, tests that
|
||
stub the estimator) must observe the pre-fix floor.
|
||
"""
|
||
handler, _cache = rate_limiter
|
||
|
||
estimate = handler._estimate_tokens_for_request(
|
||
data={"messages": [{"role": "user", "content": "hello"}]},
|
||
)
|
||
assert estimate == 1 + 1024
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_small_tpm_cap_admits_no_max_tokens_request(rate_limiter):
|
||
"""
|
||
Regression (end-to-end at the hook level): a project-level model_tpm_limit
|
||
of 1000 with a tiny no-max_tokens request must not 429 on the first call.
|
||
Pre-fix the 1024-token floor tripped OVER_LIMIT against the 1000-token cap
|
||
on every request.
|
||
"""
|
||
handler, cache = rate_limiter
|
||
|
||
api_key = hash_token("sk-small-tpm")
|
||
user_api_key_dict = UserAPIKeyAuth(
|
||
api_key=api_key,
|
||
project_id="proj-small-tpm",
|
||
project_metadata={
|
||
"model_tpm_limit": {"gpt-3.5-turbo": 1000},
|
||
"model_rpm_limit": {"gpt-3.5-turbo": 60},
|
||
},
|
||
)
|
||
|
||
data = {
|
||
"model": "gpt-3.5-turbo",
|
||
"messages": [{"role": "user", "content": "hello"}],
|
||
}
|
||
|
||
# Must not raise — pre-fix this was a 429.
|
||
await handler.async_pre_call_hook(
|
||
user_api_key_dict=user_api_key_dict,
|
||
cache=cache,
|
||
data=data,
|
||
call_type="",
|
||
)
|
||
|
||
reserved = (data.get("metadata") or {}).get(TPM_RESERVED_TOKENS_KEY)
|
||
assert reserved is not None, "Reservation should have been stashed"
|
||
assert reserved <= 1000 // 2, (
|
||
f"Capped floor must keep the reservation well under the 1000 TPM "
|
||
f"cap; got {reserved}"
|
||
)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_small_tpm_cap_injects_matching_max_tokens(rate_limiter):
|
||
"""
|
||
When a small TPM cap forces the no-max_tokens floor below the baseline,
|
||
the hook must also write data['max_tokens'] = capped_floor so the actual
|
||
model output is bounded by the reservation. Without this cap, concurrent
|
||
no-max_tokens generations can spend past the TPM limit before post-call
|
||
reconciliation runs.
|
||
"""
|
||
handler, cache = rate_limiter
|
||
|
||
user_api_key_dict = UserAPIKeyAuth(
|
||
api_key=hash_token("sk-small-tpm-cap"),
|
||
project_id="proj-small-tpm-cap",
|
||
project_metadata={
|
||
"model_tpm_limit": {"gpt-3.5-turbo": 1000},
|
||
},
|
||
)
|
||
|
||
data: dict = {
|
||
"model": "gpt-3.5-turbo",
|
||
"messages": [{"role": "user", "content": "hello"}],
|
||
}
|
||
|
||
await handler.async_pre_call_hook(
|
||
user_api_key_dict=user_api_key_dict,
|
||
cache=cache,
|
||
data=data,
|
||
call_type="",
|
||
)
|
||
|
||
assert data.get("max_tokens") == 1000 // 4, (
|
||
f"Capped floor must be written to max_tokens to bound the actual "
|
||
f"model output; got {data.get('max_tokens')}"
|
||
)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_large_tpm_cap_does_not_inject_max_tokens(rate_limiter):
|
||
"""
|
||
A TPM cap that doesn't constrain the floor must not silently inject
|
||
max_tokens — that would change behaviour for tenants who already have
|
||
plenty of budget.
|
||
"""
|
||
handler, cache = rate_limiter
|
||
|
||
user_api_key_dict = UserAPIKeyAuth(
|
||
api_key=hash_token("sk-large-tpm-cap"),
|
||
project_id="proj-large-tpm-cap",
|
||
project_metadata={
|
||
"model_tpm_limit": {"gpt-3.5-turbo": 100_000},
|
||
},
|
||
)
|
||
|
||
data: dict = {
|
||
"model": "gpt-3.5-turbo",
|
||
"messages": [{"role": "user", "content": "hello"}],
|
||
}
|
||
|
||
await handler.async_pre_call_hook(
|
||
user_api_key_dict=user_api_key_dict,
|
||
cache=cache,
|
||
data=data,
|
||
call_type="",
|
||
)
|
||
|
||
assert "max_tokens" not in data, (
|
||
f"Large TPM caps should leave max_tokens alone; got "
|
||
f"{data.get('max_tokens')}"
|
||
)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_small_tpm_cap_preserves_explicit_max_tokens(rate_limiter):
|
||
"""
|
||
Explicit max_tokens from the caller must never be overwritten by the
|
||
bypass mitigation — the user already declared their budget.
|
||
"""
|
||
handler, cache = rate_limiter
|
||
|
||
user_api_key_dict = UserAPIKeyAuth(
|
||
api_key=hash_token("sk-explicit-max-tokens"),
|
||
project_id="proj-explicit-max-tokens",
|
||
project_metadata={
|
||
"model_tpm_limit": {"gpt-3.5-turbo": 1000},
|
||
},
|
||
)
|
||
|
||
data: dict = {
|
||
"model": "gpt-3.5-turbo",
|
||
"messages": [{"role": "user", "content": "hello"}],
|
||
"max_tokens": 500,
|
||
}
|
||
|
||
await handler.async_pre_call_hook(
|
||
user_api_key_dict=user_api_key_dict,
|
||
cache=cache,
|
||
data=data,
|
||
call_type="",
|
||
)
|
||
|
||
assert data["max_tokens"] == 500
|
||
|
||
|
||
if __name__ == "__main__":
|
||
pytest.main([__file__, "-v", "-s"])
|