litellm/tests/test_litellm/proxy/hooks/test_tpm_concurrent.py
yuneng-jiang a11a93f44a
test: move tests/test_litellm core utils, routing, responses, caching and rust_bridge into tests/unit (#43199)
* ci: run the unit_selection.sh shard files on every event instead of only fork pull requests

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* ci: rename fork-flag to unit-flag now that it applies on every event

* test: move tests/test_litellm root and small trees into tests/unit

Pure renames, no content changes. Follow-up commits in this PR fix
references, merge the three files that already existed in tests/unit,
keep live-provider tests in tests/test_litellm and wire CI.

* test: carry tests/test_litellm conftest isolation into tests/unit

Callback lists, routing fallbacks, cached HTTP clients, logger state, AWS,
proxy-URL and keychain env, and session-end client cleanup now reset for
unit tests too. The environment isolation owns its MonkeyPatch so a test's
own monkeypatch is undone before the model-cost teardown runs.

* test: merge, split and prune the moved root and small-tree tests

Merge batches/test_batch_utils.py and the chat_completions and messages
dispatch tests into the files that already existed in tests/unit. Keep
the live Gemini interactions tests, the async image-fetch format test and
the OpenAI embedding scorer test in tests/test_litellm since they need
real network or keys. Put test_router.py under tests/unit/test_router so
the existing package no longer shadows it. Delete eight tests the audit
found superseded by stronger ones kept in this move.

* ci: run the moved root and small-tree tests under their legacy flags

Add the misc and responses-caching-types flags to unit_selection.sh and
CircleCI, extend enterprise-routing and mcp-integration, and point the
legacy GHA shards, Makefile, redis-compat workflow, merge smoke manifest
and change classifier at the new paths.

* test: make the new tests/unit directories packages

tests/unit/test_package_layout.py requires every directory to carry an
__init__.py, and without one the moved and retained
test_litellm_responses_bridge.py modules collide on import.

* test: scope the unit socket block to tests/unit in shared sessions

The GHA shards collect the legacy test-path and the unit selection in one
pytest session. The unit conftest's loopback-only block leaked into legacy
modules that reach the network at import. The legacy conftest now lifts the
restriction at collect and setup time, and the unit conftest re-applies it
when collecting its own modules.

* test: move tests/test_litellm/llms into tests/unit/llms

Rename-only. Moves the provider tests and the fine-tuning fixtures they
load, mirroring the old paths. Follow-up commits merge, split and wire them.

* test: merge, split and prune the moved llms tests

Merges the Databricks chat transformation tests into the existing unit
file, keeps the tests that need real keys or the network in
tests/test_litellm, deletes the audited tests a stronger unit test
already covers, and points imports at tests.unit.llms.

* ci: run the moved llms tests under their legacy flags

The Vertex AI and All Other Providers shards keep their legacy test-path
for the retained files and add the llm-vertex-ai and llm-other-providers
unit selections. CircleCI gets matching unit jobs.

* test: make the tests/unit/llms directories packages

Adds __init__.py to the moved dirs and drops the legacy ones whose
directories no longer hold tests.

* test: drop script runners and path hacks the llms split left dangling

The __main__ runners in the split openai_like files and the Databricks e2e
runner called tests that now live in the other half of the split or were
deleted. The retained legacy halves also no longer need sys.path edits.

* test: give the shard-script tests their own GITHUB_OUTPUT

They only passed where the runner set it. The CircleCI unit job's env
allowlist drops it, so the script's redirect failed there.

* test: point the router and module-deletion checks at tests/unit

router_code_coverage and code_qa_check_tests only searched tests/test_litellm,
so the moved router tests no longer counted. The two silent-experiment tests
the audit deleted were the only direct callers of those methods; they are
replaced with tests that assert the forwarded shadow request and the
recursion guard.

* test: move tests/test_litellm integrations and secret_managers into tests/unit

Rename-only. Mirrors the old paths, including the directory conftests
and the prompt and JSON fixtures. Follow-up commits prune and wire them.

* test: prune and repoint the moved integrations tests

Deletes the 7 audited tests a stronger test in the same tree already
covers, imports the TLS sink helpers from their new conftest path, and
restores os.environ after each integrations test. Some presets write
OTEL_EXPORTER_OTLP_HEADERS straight into os.environ, and without the
legacy tree's test ordering that header leaked into the AgentOps tests.

* ci: run the moved integrations tests under their legacy flag

The integrations GHA shard and a new CircleCI job run the integrations
unit selection. secret_managers joins the misc selection.

* docs: point integrations and secret_managers references at tests/unit

* test: make the moved integrations directories packages

* test: keep the Databricks manual e2e runner and fix the SageMaker Nova run path

The Databricks e2e file is a manual script whose main() calls the tests
that were pruned, so pruning them broke the documented run. It is back to
its main version. The SageMaker Nova docstring now points at the file's
real location in tests/local_testing.

* test: move tests/test_litellm core utils, routing, responses, caching and rust_bridge into tests/unit

Rename-only. Mirrors the old paths, including fixtures, the stubtest config
and the native-route wheel script. Two files that collide with existing unit
files are merged in a follow-up commit.

* test: merge, prune and repoint the moved core, routing, responses, caching and rust_bridge tests

Merges the two files that collided with existing unit files, folding the
legacy extra case into test_is_chat_completion_cached_dict, and deletes the
9 audited tests a stronger test in the same file already covers.

Keeps what needs the network in tests/test_litellm: test_tokenizers pulls a
tokenizer from the Hugging Face hub, and the gpt2 and r50k_base tokenizer
cases download their BPE files. The unit core_utils conftest points
TIKTOKEN_CACHE_DIR at litellm's bundled encodings so the rest never depend on
import order to stay offline, and FakeSecretVault moves to a shared module
so both trees can build it.

* ci: run the moved core, routing, responses, caching and rust_bridge tests under their flags

core_utils gets a core-utils flag and CircleCI job, and its GHA shard keeps
the legacy path for the retained network tests. router_utils and
router_strategy join enterprise-routing, responses joins
responses-caching-types (minus responses/mcp, which mcp-integration owns),
caching joins caching-local and rust_bridge joins misc. The redis-compat,
test-rust, stubtest and merge-smoke paths follow the move.

* docs: point the Rust crate references at tests/unit

* test: make the moved core, routing and rust_bridge directories packages

* test: keep the no-loop DualCache batch_get_cache regression test

It runs the sync path outside any event loop, which the inside-loop test
cannot, so a change that picks the Redis client by loop state would only
show up there.

* test: keep the job's UNIT_FLAG out of the shard-script tests

* fix(url_utils): block 192.0.0.0/24 on every Python patch release

* test: move the new budget limiter tests into tests/unit/router_strategy

* test: move the new sentry scrubbing tests into tests/unit/litellm_core_utils

* test: move the new zerobus tests into tests/unit/integrations

* test: make tests/unit/integrations/zerobus a package

* test: load litellm's own tiktoken cache setup once instead of resetting it per test

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-25 17:10:13 -07:00

3712 lines
121 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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, timedelta
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 (
PROJECT_ITPM_DESCRIPTOR_KEY,
PROJECT_OTPM_DESCRIPTOR_KEY,
RateLimitedModel,
_AUDIO_BYTES_PER_TOKEN,
_PROXY_MaxParallelRequestsHandler_v3 as RateLimitHandler,
)
from litellm.proxy.hooks.parallel_request_limiter_v3 import (
_call_id_from_callback_kwargs,
_request_stash,
get_or_create_request_stash,
get_request_stash,
)
from litellm.proxy.utils import InternalUsageCache, hash_token
from litellm.types.llms.openai import (
InputTokensDetails,
ResponseAPIUsage,
ResponsesAPIResponse,
)
from litellm.types.rerank import RerankResponse
from litellm.types.utils import ModelResponse, PromptTokensDetailsWrapper, Usage
@pytest.fixture
def rate_limiter():
cache = DualCache()
handler = RateLimitHandler(internal_usage_cache=InternalUsageCache(cache))
return handler, cache
@pytest.fixture(autouse=True)
def _isolated_request_stash():
token = _request_stash.set(None)
yield
_request_stash.reset(token)
@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": get_request_stash().reserved_tokens,
}
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, match='Limit type: tokens\\. Current limit') 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")
stash = get_or_create_request_stash()
stash.reserved_tokens = 100
stash.reserved_scopes = frozenset({("api_key", api_key)})
mock_kwargs = {
"standard_logging_object": {
"metadata": {
"user_api_key_hash": 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")
stash = get_or_create_request_stash()
stash.reserved_tokens = 100
stash.reserved_scopes = frozenset({("api_key", api_key)})
mock_kwargs = {
"standard_logging_object": {
"metadata": {
"user_api_key_hash": 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"
stash = get_or_create_request_stash()
stash.reserved_tokens = 100
stash.reserved_model = RateLimitedModel(requested=reserved_model, group=reserved_model)
stash.reserved_scopes = frozenset({("model_per_team", f"{team_id}:{reserved_model}")})
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,
}
},
}
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"
stash = get_or_create_request_stash()
stash.reserved_tokens = 100
stash.reserved_scopes = frozenset({("organization", org_id)})
mock_kwargs = {
"standard_logging_object": {
"metadata": {
"user_api_key_hash": api_key,
"user_api_key_org_id": 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"
stash = get_or_create_request_stash()
stash.reserved_tokens = 100
stash.reserved_scopes = frozenset({("organization", org_id)})
mock_kwargs = {
"standard_logging_object": {
"metadata": {
"user_api_key_hash": api_key,
"user_api_key_org_id": 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_honors_explicit_zero_max_tokens(rate_limiter):
"""
Regression for a Greptile finding: explicit_max_tokens was resolved via
`data.get("max_tokens") or data.get("max_completion_tokens") or
data.get("max_output_tokens")`, so an explicit 0 in the first field was
falsy and fell through to the next field (or the no-max_tokens floor),
silently discarding a caller's explicit zero-output request.
"""
handler, _cache = rate_limiter
estimate = handler._estimate_tokens_for_request(
data={
"messages": [
{"role": "user", "content": "abcd" * 4}
], # 16 chars ~ 4 tokens
"max_tokens": 0,
}
)
assert estimate == 4, (
f"expected input-only reservation (4) for an explicit max_tokens=0, got {estimate}"
)
@pytest.mark.asyncio
async def test_estimate_tokens_honors_explicit_zero_max_output_tokens_for_responses(
rate_limiter,
):
handler, _cache = rate_limiter
estimate = handler._estimate_tokens_for_request(
data={
"input": "describe this image in detail", # 29 chars ~ 7 tokens
"max_output_tokens": 0,
},
min_configured_tpm_limit=40,
call_type="aresponses",
)
assert estimate == 23
@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 (
get_request_stash().reserved_tokens == 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, match='Limit type: tokens\\. Current limit') 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 = get_request_stash().reserved_tokens
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 get_request_stash().reservation_released is True, (
"Released flag must be set 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
)
# Both hooks read the same per-request ContextVar stash: the
# post-call-failure-hook flips reservation_released on it, and the
# log-failure-event observes the flip.
stash = get_or_create_request_stash()
stash.reserved_tokens = 100
stash.reserved_scopes = frozenset({("api_key", api_key)})
await handler.async_post_call_failure_hook(
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 flag on the stash and not double-refund.
await handler.async_log_failure_event(
kwargs={
"standard_logging_object": {"metadata": {"user_api_key_hash": api_key}},
},
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.
stash = get_or_create_request_stash()
stash.reserved_tokens = 100
stash.reserved_scopes = frozenset({("api_key", api_key)})
mock_kwargs = {
"standard_logging_object": {
"metadata": {
"user_api_key_hash": api_key,
"user_api_key_team_id": team_id,
}
},
"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"
stash = get_or_create_request_stash()
stash.reserved_tokens = 100
stash.reserved_scopes = frozenset({("api_key", api_key)})
mock_kwargs = {
"standard_logging_object": {
"metadata": {
"user_api_key_hash": api_key,
"user_api_key_team_id": team_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(),
)
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 the stashed
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 = get_request_stash().rate_limit_response
assert isinstance(
response, dict
), "Expected the stashed 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 = get_request_stash().reserved_tokens
assert reserved > 0, "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
@pytest.mark.asyncio
async def test_project_otpm_reservation_prevents_concurrent_bypass(rate_limiter):
"""
Bedrock Mantle-style OTPM: with a 100 OTPM limit and 5 concurrent
requests each reserving 50+ output 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`` for the
project-scoped ITPM/OTPM descriptors specifically.
"""
handler, cache = rate_limiter
user_api_key_dict = UserAPIKeyAuth(
api_key=hash_token("sk-otpm-bypass"),
project_id="proj-mantle-bypass",
project_metadata={
"model_otpm_limit": {"bedrock_mantle/claude-opus": 100},
},
)
request_data = {
"model": "bedrock_mantle/claude-opus",
"messages": [{"role": "user", "content": "hello"}],
"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}
except Exception as e:
return {
"request_id": request_id,
"success": False,
"status_code": getattr(e, "status_code", None),
}
results = await asyncio.gather(*[make_request(i) for i in range(5)])
successful = [r for r in results if r["success"]]
rate_limited = [
r for r in results if not r["success"] and r.get("status_code") == 429
]
assert len(rate_limited) > 0, (
f"Expected some OTPM-rate-limited requests but all {len(successful)} succeeded."
)
@pytest.mark.asyncio
async def test_project_otpm_rejects_multiple_completion_candidates(rate_limiter):
handler, cache = rate_limiter
user_api_key_dict = UserAPIKeyAuth(
api_key=hash_token("sk-otpm-multiple-candidates"),
project_id="proj-multiple-candidates",
project_metadata={
"model_otpm_limit": {"bedrock_mantle/claude-opus": 500},
},
)
data = {
"model": "bedrock_mantle/claude-opus",
"messages": [{"role": "user", "content": "hello"}],
"max_tokens": 100,
"n": 10,
}
with pytest.raises(Exception, match='Rate limit exceeded for model_per_project_otpm') as exc_info:
await handler.async_pre_call_hook(
user_api_key_dict=user_api_key_dict,
cache=cache,
data=data,
call_type="acompletion",
)
assert getattr(exc_info.value, "status_code", None) == 429
@pytest.mark.asyncio
async def test_project_otpm_reserves_largest_conflicting_output_cap(rate_limiter):
handler, cache = rate_limiter
user_api_key_dict = UserAPIKeyAuth(
api_key=hash_token("sk-otpm-conflicting-caps"),
project_id="proj-conflicting-caps",
project_metadata={
"model_otpm_limit": {"bedrock_mantle/claude-opus": 50},
},
)
data = {
"model": "bedrock_mantle/claude-opus",
"messages": [{"role": "user", "content": "hello"}],
"max_tokens": 1,
"max_completion_tokens": 100,
}
with pytest.raises(Exception, match='Rate limit exceeded for model_per_project_otpm') as exc_info:
await handler.async_pre_call_hook(
user_api_key_dict=user_api_key_dict,
cache=cache,
data=data,
call_type="acompletion",
)
assert getattr(exc_info.value, "status_code", None) == 429
@pytest.mark.asyncio
@pytest.mark.parametrize(
"call_type",
["agenerate_content", "agenerate_content_stream"],
)
@pytest.mark.parametrize("config_field", ["config", "generationConfig"])
async def test_project_otpm_rejects_google_genai_native_output_cap(
rate_limiter,
call_type,
config_field,
):
handler, cache = rate_limiter
model = "gemini/gemini-3-flash-preview"
user_api_key_dict = UserAPIKeyAuth(
api_key=hash_token("sk-google-genai-native-otpm"),
project_id="project-google-genai-native-otpm",
project_metadata={"model_otpm_limit": {model: 50}},
)
with pytest.raises(Exception, match='Rate limit exceeded for model_per_project_otpm') as exc_info:
await handler.async_pre_call_hook(
user_api_key_dict=user_api_key_dict,
cache=cache,
data={
"model": model,
"contents": [{"role": "user", "parts": [{"text": "Hello"}]}],
config_field: {"maxOutputTokens": 100},
},
call_type=call_type,
)
assert getattr(exc_info.value, "status_code", None) == 429
@pytest.mark.asyncio
@pytest.mark.parametrize(
"call_type",
["agenerate_content", "agenerate_content_stream"],
)
@pytest.mark.parametrize("candidate_count_field", ["candidateCount", "candidate_count"])
async def test_project_otpm_rejects_google_genai_native_candidate_count(
rate_limiter,
call_type,
candidate_count_field,
):
handler, cache = rate_limiter
model = "gemini/gemini-3-flash-preview"
user_api_key_dict = UserAPIKeyAuth(
api_key=hash_token("sk-google-genai-native-candidate-count"),
project_id="project-google-genai-native-candidate-count",
project_metadata={"model_otpm_limit": {model: 150}},
)
with pytest.raises(Exception, match='Rate limit exceeded for model_per_project_otpm') as exc_info:
await handler.async_pre_call_hook(
user_api_key_dict=user_api_key_dict,
cache=cache,
data={
"model": model,
"contents": [{"role": "user", "parts": [{"text": "Hello"}]}],
"config": {
"maxOutputTokens": 50,
candidate_count_field: 4,
},
},
call_type=call_type,
)
assert getattr(exc_info.value, "status_code", None) == 429
@pytest.mark.asyncio
@pytest.mark.parametrize(
"call_type",
["agenerate_content", "agenerate_content_stream"],
)
@pytest.mark.parametrize("config_field", [None, "config", "generationConfig"])
async def test_project_otpm_injects_google_genai_native_output_cap(
rate_limiter,
call_type,
config_field,
):
handler, cache = rate_limiter
model = "gemini/gemini-3-flash-preview"
user_api_key_dict = UserAPIKeyAuth(
api_key=hash_token("sk-google-genai-native-implicit-otpm"),
project_id="project-google-genai-native-implicit-otpm",
project_metadata={"model_otpm_limit": {model: 40}},
)
data = {
"model": model,
"contents": [{"role": "user", "parts": [{"text": "Hello"}]}],
}
if config_field is not None:
data[config_field] = {"temperature": 0}
await handler.async_pre_call_hook(
user_api_key_dict=user_api_key_dict,
cache=cache,
data=data,
call_type=call_type,
)
stash = get_request_stash()
assert stash is not None
assert stash.otpm_reserved_tokens == 10
expected_config_field = config_field or "config"
assert data[expected_config_field]["maxOutputTokens"] == 10
assert "max_tokens" not in data
@pytest.mark.asyncio
async def test_project_otpm_over_limit_rolls_back_itpm_reservation(rate_limiter):
"""
When ITPM reserves fine but OTPM is then over limit, the ITPM
reservation this same pre-call already made must be rolled back --
otherwise it leaks until the window's TTL, silently shrinking the ITPM
budget for every other request in that minute.
"""
handler, cache = rate_limiter
user_api_key_dict = UserAPIKeyAuth(
api_key=hash_token("sk-otpm-rollback"),
project_id="proj-mantle-rollback",
project_metadata={
"model_itpm_limit": {"bedrock_mantle/claude-opus": 1000000},
"model_otpm_limit": {"bedrock_mantle/claude-opus": 10},
},
)
itpm_counter_key = handler.create_rate_limit_keys(
key="model_per_project_itpm",
value="proj-mantle-rollback:bedrock_mantle/claude-opus",
rate_limit_type="tokens",
)
data = {
"model": "bedrock_mantle/claude-opus",
"messages": [{"role": "user", "content": "hello"}],
"max_tokens": 500, # blows past the 10-token OTPM limit
}
with pytest.raises(Exception, match='Rate limit exceeded for model_per_project_otpm') 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
cached_value = await cache.async_get_cache(key=itpm_counter_key, local_only=True)
assert int(cached_value or 0) == 0, (
f"ITPM reservation leaked after OTPM rejection: counter={cached_value}"
)
@pytest.mark.asyncio
async def test_project_itpm_reconciled_on_success_excludes_cached_tokens(rate_limiter):
"""
On success, ITPM reconciles to billable input tokens (prompt_tokens
minus cached_tokens) -- not raw prompt_tokens. Cached prompt-read tokens
are free under Bedrock Mantle and must not count against the ITPM quota,
even though they still appear in usage/cost logging elsewhere.
"""
handler, _cache = rate_limiter
itpm_scope = ("model_per_project_itpm", "proj-mantle:model")
otpm_scope = ("model_per_project_otpm", "proj-mantle:model")
stash = get_or_create_request_stash()
stash.itpm_reserved_tokens = 100
stash.itpm_reserved_scopes = frozenset({itpm_scope})
stash.otpm_reserved_tokens = 60
stash.otpm_reserved_scopes = frozenset({otpm_scope})
mock_kwargs = {}
mock_response = ModelResponse(
id="test",
object="chat.completion",
created=int(datetime.now().timestamp()),
model="bedrock_mantle/claude-opus",
usage=Usage(
prompt_tokens=80,
completion_tokens=40,
total_tokens=120,
prompt_tokens_details=PromptTokensDetailsWrapper(cached_tokens=30),
),
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(),
)
itpm_adjustments = [i for i in increments if "model_per_project_itpm" in i["key"]]
otpm_adjustments = [i for i in increments if "model_per_project_otpm" in i["key"]]
# billable_input = 80 - 30 cached = 50; delta = 50 - 100 reserved = -50
assert any(i["increment"] == -50 for i in itpm_adjustments), (
f"Expected a -50 ITPM adjustment (50 billable - 100 reserved), got: {itpm_adjustments}"
)
# delta = 40 actual completion - 60 reserved = -20
assert any(i["increment"] == -20 for i in otpm_adjustments), (
f"Expected a -20 OTPM adjustment (40 actual - 60 reserved), got: {otpm_adjustments}"
)
@pytest.mark.asyncio
async def test_project_reconciliation_does_not_decrement_later_window():
current_time = datetime(2026, 8, 5, 12, 0, 0)
cache = DualCache()
handler = RateLimitHandler(
internal_usage_cache=InternalUsageCache(cache),
time_provider=lambda: current_time,
)
handler.window_size = 60
scope = (PROJECT_ITPM_DESCRIPTOR_KEY, "project:model")
descriptor = {
"key": scope[0],
"value": scope[1],
"rate_limit": {"tokens_per_unit": 1000, "window_size": 60},
}
reservation = await handler.atomic_check_and_increment_by_n(
descriptors=[descriptor],
increments=[{"tokens": 100}],
)
counter_key = handler.create_rate_limit_keys(*scope, rate_limit_type="tokens")
window_identity = next(
identity
for identity in reservation["reservation_windows"]
if identity[0] == counter_key
)
stash = get_or_create_request_stash()
stash.itpm_reserved_tokens = 100
stash.itpm_reserved_scopes = frozenset({scope})
stash.itpm_reserved_window_identities = frozenset(
{window_identity}
)
current_time += timedelta(seconds=61)
later_reservation = await handler.atomic_check_and_increment_by_n(
descriptors=[descriptor],
increments=[{"tokens": 20}],
)
assert window_identity not in later_reservation["reservation_windows"]
await handler.async_log_success_event(
kwargs={},
response_obj=ModelResponse(
usage=Usage(prompt_tokens=10, completion_tokens=0, total_tokens=10)
),
start_time=current_time,
end_time=current_time,
)
assert float(await cache.async_get_cache(key=counter_key, local_only=True) or 0) == 20
@pytest.mark.asyncio
async def test_project_reconciliation_decrements_its_active_window(rate_limiter):
handler, cache = rate_limiter
scope = (PROJECT_ITPM_DESCRIPTOR_KEY, "project:model")
descriptor = {
"key": scope[0],
"value": scope[1],
"rate_limit": {"tokens_per_unit": 1000, "window_size": 60},
}
reservation = await handler.atomic_check_and_increment_by_n(
descriptors=[descriptor],
increments=[{"tokens": 100}],
)
counter_key = handler.create_rate_limit_keys(*scope, rate_limit_type="tokens")
window_identity = next(
identity
for identity in reservation["reservation_windows"]
if identity[0] == counter_key
)
stash = get_or_create_request_stash()
stash.itpm_reserved_tokens = 100
stash.itpm_reserved_scopes = frozenset({scope})
stash.itpm_reserved_window_identities = frozenset(
{window_identity}
)
await handler.async_log_success_event(
kwargs={},
response_obj=ModelResponse(
usage=Usage(prompt_tokens=10, completion_tokens=0, total_tokens=10)
),
start_time=datetime.now(),
end_time=datetime.now(),
)
assert float(await cache.async_get_cache(key=counter_key, local_only=True) or 0) == 10
@pytest.mark.asyncio
async def test_redis_window_guard_uses_reservation_identity_and_never_falls_back_negative(
rate_limiter,
):
handler, _cache = rate_limiter
calls = []
async def failing_guard(*, keys, args):
calls.append((keys, args))
raise RuntimeError("redis unavailable")
unguarded_calls = []
async def capture_unguarded(pipeline_operations, **_kwargs):
unguarded_calls.extend(pipeline_operations)
handler.window_guarded_token_increment_script = failing_guard
handler.async_increment_tokens_with_ttl_preservation = capture_unguarded
await handler.async_increment_reservation_aware_tokens(
pipeline_operations=[
{
"key": "{model_per_project_itpm:project:model}:tokens",
"increment_value": -90,
"ttl": 60,
"window_key": "{model_per_project_itpm:project:model}:window",
"expected_window_start": "1234",
"reservation_backend": "redis",
}
]
)
assert calls == [
(
[
"{model_per_project_itpm:project:model}:window",
"{model_per_project_itpm:project:model}:tokens",
],
["1234", -90, 60],
)
]
assert unguarded_calls == []
@pytest.mark.asyncio
async def test_atomic_lua_response_carries_redis_window_identity(rate_limiter):
handler, _cache = rate_limiter
counter_key = "{model_per_project_itpm:project:model}:tokens"
meta = [
{
"descriptor_key": PROJECT_ITPM_DESCRIPTOR_KEY,
"descriptor_value": "project:model",
"current_limit": 100,
"rate_limit_type": "tokens",
"counter_key": counter_key,
}
]
async def successful_reservation(*, keys, args):
return [0, 25, 1234]
handler.check_and_increment_by_n_script = successful_reservation
assert await handler._atomic_lua_per_descriptor([]) == {
"overall_code": "OK",
"statuses": [],
}
response = await handler._atomic_lua_per_descriptor(
descriptor_groups=[
(
[
"{model_per_project_itpm:project:model}:window",
counter_key,
],
[100, 25, 60, 60],
meta,
)
]
)
assert response["statuses"][0]["limit_remaining"] == 75
assert response["reservation_windows"] == frozenset(
{(counter_key, "1234", "redis")}
)
@pytest.mark.asyncio
async def test_project_itpm_otpm_released_on_failure(rate_limiter):
"""On failure, the full ITPM and OTPM reservations must be refunded."""
handler, _cache = rate_limiter
itpm_scope = ("model_per_project_itpm", "proj-mantle:model")
otpm_scope = ("model_per_project_otpm", "proj-mantle:model")
stash = get_or_create_request_stash()
stash.itpm_reserved_tokens = 100
stash.itpm_reserved_scopes = frozenset({itpm_scope})
stash.otpm_reserved_tokens = 60
stash.otpm_reserved_scopes = frozenset({otpm_scope})
mock_kwargs = {}
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(),
)
itpm_releases = [i for i in increments if "model_per_project_itpm" in i["key"]]
otpm_releases = [i for i in increments if "model_per_project_otpm" in i["key"]]
assert any(i["increment"] == -100 for i in itpm_releases), itpm_releases
assert any(i["increment"] == -60 for i in otpm_releases), otpm_releases
@pytest.mark.asyncio
async def test_proxy_rejection_refunds_itpm_otpm_by_their_own_amount_not_combined(
rate_limiter,
):
"""
Regression for a Greptile-flagged bug: when a project configures both a
combined model_tpm_limit and split model_itpm_limit/model_otpm_limit for
the same model, async_post_call_failure_hook's proxy-side refund path
used to decrement every token descriptor -- including the ITPM/OTPM
ones -- by the flat combined reservation amount, instead of each
bucket's own reserved amount. That drives the split counters negative
(or under-refunds them) instead of returning them to exactly zero.
"""
handler, cache = rate_limiter
api_key = hash_token("sk-mixed-tpm-io")
user_api_key_dict = UserAPIKeyAuth(
api_key=api_key,
project_id="proj-mixed",
project_metadata={
"model_tpm_limit": {"bedrock_mantle/claude-opus": 100000},
"model_itpm_limit": {"bedrock_mantle/claude-opus": 100000},
"model_otpm_limit": {"bedrock_mantle/claude-opus": 100000},
},
)
data = {
"model": "bedrock_mantle/claude-opus",
"messages": [
{"role": "user", "content": "hello there, this is a test message"}
],
"max_tokens": 60,
}
await handler.async_pre_call_hook(
user_api_key_dict=user_api_key_dict,
cache=cache,
data=data,
call_type="",
)
tpm_counter_key = handler.create_rate_limit_keys(
key="model_per_project",
value="proj-mixed:bedrock_mantle/claude-opus",
rate_limit_type="tokens",
)
itpm_counter_key = handler.create_rate_limit_keys(
key="model_per_project_itpm",
value="proj-mixed:bedrock_mantle/claude-opus",
rate_limit_type="tokens",
)
otpm_counter_key = handler.create_rate_limit_keys(
key="model_per_project_otpm",
value="proj-mixed:bedrock_mantle/claude-opus",
rate_limit_type="tokens",
)
tpm_reserved = int(
await cache.async_get_cache(key=tpm_counter_key, local_only=True) or 0
)
itpm_reserved = int(
await cache.async_get_cache(key=itpm_counter_key, local_only=True) or 0
)
otpm_reserved = int(
await cache.async_get_cache(key=otpm_counter_key, local_only=True) or 0
)
assert tpm_reserved > 0 and itpm_reserved > 0 and otpm_reserved > 0
await handler.async_post_call_failure_hook(
request_data=data,
original_exception=Exception("guardrail rejected"),
user_api_key_dict=user_api_key_dict,
)
tpm_after = int(
await cache.async_get_cache(key=tpm_counter_key, local_only=True) or 0
)
itpm_after = int(
await cache.async_get_cache(key=itpm_counter_key, local_only=True) or 0
)
otpm_after = int(
await cache.async_get_cache(key=otpm_counter_key, local_only=True) or 0
)
assert tpm_after == 0, f"combined TPM counter leaked: {tpm_after}"
assert itpm_after == 0, (
f"ITPM counter corrupted by combined-amount refund: {itpm_after}"
)
assert otpm_after == 0, (
f"OTPM counter corrupted by combined-amount refund: {otpm_after}"
)
@pytest.mark.asyncio
async def test_proxy_rejection_refunds_itpm_otpm_only_reservation_with_no_combined_tpm(
rate_limiter,
):
"""
Regression for the second half of the same bug: with only
model_itpm_limit/model_otpm_limit configured (no model_tpm_limit), the
combined reserved_tokens is 0, and the proxy-side refund path used to
return immediately on that -- leaking the ITPM/OTPM reservations until
the rate-limit window's TTL expired.
"""
handler, cache = rate_limiter
api_key = hash_token("sk-io-only")
user_api_key_dict = UserAPIKeyAuth(
api_key=api_key,
project_id="proj-io-only",
project_metadata={
"model_itpm_limit": {"bedrock_mantle/claude-opus": 100000},
"model_otpm_limit": {"bedrock_mantle/claude-opus": 100000},
},
)
data = {
"model": "bedrock_mantle/claude-opus",
"messages": [
{"role": "user", "content": "hello there, this is a test message"}
],
"max_tokens": 60,
}
await handler.async_pre_call_hook(
user_api_key_dict=user_api_key_dict,
cache=cache,
data=data,
call_type="",
)
itpm_counter_key = handler.create_rate_limit_keys(
key="model_per_project_itpm",
value="proj-io-only:bedrock_mantle/claude-opus",
rate_limit_type="tokens",
)
otpm_counter_key = handler.create_rate_limit_keys(
key="model_per_project_otpm",
value="proj-io-only:bedrock_mantle/claude-opus",
rate_limit_type="tokens",
)
assert (
int(await cache.async_get_cache(key=itpm_counter_key, local_only=True) or 0) > 0
)
assert (
int(await cache.async_get_cache(key=otpm_counter_key, local_only=True) or 0) > 0
)
await handler.async_post_call_failure_hook(
request_data=data,
original_exception=Exception("guardrail rejected"),
user_api_key_dict=user_api_key_dict,
)
itpm_after = int(
await cache.async_get_cache(key=itpm_counter_key, local_only=True) or 0
)
otpm_after = int(
await cache.async_get_cache(key=otpm_counter_key, local_only=True) or 0
)
assert itpm_after == 0, (
f"ITPM-only reservation leaked on proxy rejection: {itpm_after}"
)
assert otpm_after == 0, (
f"OTPM-only reservation leaked on proxy rejection: {otpm_after}"
)
@pytest.mark.asyncio
async def test_otpm_rejection_does_not_double_refund_combined_tpm(rate_limiter):
"""
Regression for a High-severity review finding: when the project ITPM
reservation succeeds but OTPM is then over limit,
_reserve_project_io_tokens_or_raise rolls back the combined-TPM
reservation that already succeeded earlier in the same pre-call, then
raises. If it doesn't also mark that reservation released,
async_post_call_failure_hook -- which fires next in the real request
lifecycle, since raising from async_pre_call_hook triggers it -- sees
the same still-stashed reservation and refunds it a second time,
driving the combined TPM counter negative and letting a caller push
past the project's real TPM budget by repeatedly triggering OTPM
rejections.
"""
handler, cache = rate_limiter
api_key = hash_token("sk-double-refund")
user_api_key_dict = UserAPIKeyAuth(
api_key=api_key,
project_id="proj-double-refund",
project_metadata={
"model_tpm_limit": {"bedrock_mantle/claude-opus": 100000},
"model_itpm_limit": {"bedrock_mantle/claude-opus": 100000},
"model_otpm_limit": {"bedrock_mantle/claude-opus": 5},
},
)
data = {
"model": "bedrock_mantle/claude-opus",
"messages": [
{"role": "user", "content": "hello there, this is a test message"}
],
"max_tokens": 60, # blows past the 5-token OTPM limit
}
tpm_counter_key = handler.create_rate_limit_keys(
key="model_per_project",
value="proj-double-refund:bedrock_mantle/claude-opus",
rate_limit_type="tokens",
)
with pytest.raises(Exception, match='Rate limit exceeded for model_per_project_otpm') 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
tpm_after_pre_call = int(
await cache.async_get_cache(key=tpm_counter_key, local_only=True) or 0
)
assert tpm_after_pre_call == 0, (
f"combined TPM reservation not rolled back: {tpm_after_pre_call}"
)
# In the real request lifecycle, async_post_call_failure_hook fires next
# for a pre-call rejection. It must not refund the same reservation again.
await handler.async_post_call_failure_hook(
request_data=data,
original_exception=exc_info.value,
user_api_key_dict=user_api_key_dict,
)
tpm_after_failure_hook = int(
await cache.async_get_cache(key=tpm_counter_key, local_only=True) or 0
)
assert tpm_after_failure_hook == 0, (
f"combined TPM counter went negative from a double refund: {tpm_after_failure_hook}"
)
@pytest.mark.parametrize(
"embedding_input",
[
list(range(51)),
[list(range(25)), list(range(26))],
],
)
@pytest.mark.asyncio
async def test_project_itpm_rejects_pretokenized_embedding_input(
rate_limiter,
embedding_input,
):
handler, cache = rate_limiter
user_api_key_dict = UserAPIKeyAuth(
api_key=hash_token("sk-pretokenized-embedding-itpm"),
project_id="proj-pretokenized-embedding",
project_metadata={
"model_itpm_limit": {"text-embedding-3-small": 50},
},
)
data = {
"model": "text-embedding-3-small",
"input": embedding_input,
}
with pytest.raises(Exception, match='Rate limit exceeded for model_per_project_itpm') as exc_info:
await handler.async_pre_call_hook(
user_api_key_dict=user_api_key_dict,
cache=cache,
data=data,
call_type="aembedding",
)
assert getattr(exc_info.value, "status_code", None) == 429
@pytest.mark.asyncio
async def test_responses_api_not_misclassified_as_embedding_for_output_estimate(
rate_limiter,
):
"""
Regression for a High-severity review finding: the Responses API also
puts its prompt in data["input"], the same field embeddings use, so the
output-token estimate treated every Responses call as an embedding and
reserved zero output tokens. call_type now disambiguates the two: the
same input-only payload gets zero output tokens for an embedding call
but a real floor for a Responses API call.
"""
handler, _cache = rate_limiter
data = {"input": "describe this image in detail"}
_, embedding_output_estimate = handler._estimate_input_and_output_tokens(
data=data, call_type="aembedding"
)
assert embedding_output_estimate == 0
_, responses_output_estimate = handler._estimate_input_and_output_tokens(
data=data, call_type="aresponses"
)
assert responses_output_estimate > 0, (
"Responses API call was misclassified as an embedding and reserved zero output tokens"
)
@pytest.mark.parametrize(
("data", "call_type", "expected_output_tokens"),
[
(
{
"messages": [{"role": "user", "content": "hello"}],
"max_tokens": 100,
"n": 10,
},
"acompletion",
1000,
),
(
{
"prompt": "hello",
"max_tokens": 100,
"n": 2,
"best_of": 5,
},
"text_completion",
500,
),
(
{
"messages": [{"role": "user", "content": "hello"}],
"max_tokens": 100,
"n": 0,
"best_of": "invalid",
},
"acompletion",
100,
),
],
)
def test_output_estimate_accounts_for_completion_candidates(
rate_limiter,
data,
call_type,
expected_output_tokens,
):
handler, _cache = rate_limiter
_, estimated_output_tokens = handler._estimate_input_and_output_tokens(
data=data,
call_type=call_type,
)
assert estimated_output_tokens == expected_output_tokens
@pytest.mark.asyncio
async def test_responses_api_usage_reconciles_using_input_output_tokens_fields(
rate_limiter,
):
"""
Regression for the other half of the same finding: ResponseAPIUsage
exposes input_tokens/output_tokens, not prompt_tokens/completion_tokens.
Before this fix, _resolve_io_token_reconcile_usage couldn't resolve
Responses API usage at all, so the reservation was silently kept as-is
instead of being trued up to the much larger actual usage.
"""
handler, _cache = rate_limiter
itpm_scope = ("model_per_project_itpm", "proj-responses:model")
otpm_scope = ("model_per_project_otpm", "proj-responses:model")
stash = get_or_create_request_stash()
stash.itpm_reserved_tokens = 10
stash.itpm_reserved_scopes = frozenset({itpm_scope})
stash.otpm_reserved_tokens = 10
stash.otpm_reserved_scopes = frozenset({otpm_scope})
mock_kwargs = {}
mock_response = ResponsesAPIResponse(
id="resp_test",
created_at=int(datetime.now().timestamp()),
output=[],
usage=ResponseAPIUsage(input_tokens=80, output_tokens=400, total_tokens=480),
)
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(),
)
itpm_adjustments = [i for i in increments if "model_per_project_itpm" in i["key"]]
otpm_adjustments = [i for i in increments if "model_per_project_otpm" in i["key"]]
# delta = 80 actual input - 10 reserved = +70
assert any(i["increment"] == 70 for i in itpm_adjustments), (
f"ITPM reservation was never trued up to actual Responses API usage: {itpm_adjustments}"
)
# delta = 400 actual output - 10 reserved = +390
assert any(i["increment"] == 390 for i in otpm_adjustments), (
f"OTPM reservation was never trued up to actual Responses API usage: {otpm_adjustments}"
)
@pytest.mark.asyncio
async def test_itpm_reservation_accounts_for_audio_content_not_just_text(rate_limiter):
"""
Regression for the audio half of a Medium-severity review finding:
litellm.token_counter has no per-type handling for `input_audio`
content blocks (unlike images, which it does count via
use_default_image_token_count), so it silently contributes 0 tokens for
them. Without DEFAULT_AUDIO_TOKEN_ESTIMATE, a burst of audio-heavy
requests with minimal text would each reserve only the one-token floor
and blow past the project ITPM limit before post-call reconciliation.
"""
handler, cache = rate_limiter
api_key = hash_token("sk-audio-itpm")
user_api_key_dict = UserAPIKeyAuth(
api_key=api_key,
project_id="proj-audio",
project_metadata={
# Tighter than DEFAULT_AUDIO_TOKEN_ESTIMATE (300), but far bigger
# than the handful of tokens the bare text "hi" would cost.
"model_itpm_limit": {"bedrock_mantle/claude-opus": 50},
},
)
data = {
"model": "bedrock_mantle/claude-opus",
"messages": [
{
"role": "user",
"content": [
{"type": "text", "text": "hi"},
{
"type": "input_audio",
"input_audio": {"data": "base64-audio-bytes", "format": "wav"},
},
],
}
],
}
with pytest.raises(Exception, match='Rate limit exceeded for model_per_project_itpm') 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, (
"Expected the audio content to push the ITPM reservation over the "
"50-token limit; if this doesn't raise, audio content isn't being "
"counted again."
)
def test_audio_token_estimate_scales_with_payload_size():
"""
Regression for veria-ai Low finding: audio token reservation was flat
300 per block regardless of duration. A short clip and a long clip both
reserved the same amount, letting a caller hide long audio in one block
to exhaust ITPM quota while reserving almost nothing.
The estimate must now grow proportionally with the base64 payload size
(len(b64) * 3 // 4 // _AUDIO_BYTES_PER_TOKEN), floored at
DEFAULT_AUDIO_TOKEN_ESTIMATE so reference-only blocks and genuinely
short clips still get a non-trivial reservation.
To exceed the floor the decoded payload must be > 300 * 1600 = 480 000
bytes. We synthesise a fake b64-length string of 650 000 chars
(decoded ≈ 487 500 bytes → 304 tokens) to avoid actually allocating
and encoding ~480 kB of audio in every test run.
"""
large_b64 = "A" * 650_000
very_large_b64 = "A" * 12_900_000
small_b64 = "A" * 1_000
large_block = {
"type": "input_audio",
"input_audio": {"data": large_b64, "format": "wav"},
}
small_block = {
"type": "input_audio",
"input_audio": {"data": small_b64, "format": "wav"},
}
very_large_block = {
"type": "input_audio",
"input_audio": {"data": very_large_b64, "format": "wav"},
}
no_data_block = {"type": "input_audio", "input_audio": {"format": "wav"}}
large_estimate = RateLimitHandler._estimate_audio_block_tokens(large_block)
very_large_estimate = RateLimitHandler._estimate_audio_block_tokens(
very_large_block
)
small_estimate = RateLimitHandler._estimate_audio_block_tokens(small_block)
no_data_estimate = RateLimitHandler._estimate_audio_block_tokens(no_data_block)
assert large_estimate > small_estimate, (
f"Large payload ({large_estimate}) must reserve more than small payload "
f"({small_estimate}); flat-rate bug is back"
)
assert very_large_estimate == len(very_large_b64) * 3 // 4 // _AUDIO_BYTES_PER_TOKEN
assert very_large_estimate > 6_000
assert no_data_estimate >= 300, (
f"Reference-only block (no data) must use the DEFAULT_AUDIO_TOKEN_ESTIMATE floor; got {no_data_estimate}"
)
assert small_estimate >= 300, (
f"Small payload must be floored at DEFAULT_AUDIO_TOKEN_ESTIMATE=300; got {small_estimate}"
)
@pytest.mark.asyncio
async def test_itpm_rejects_large_audio_payload_that_would_pass_flat_estimate(
rate_limiter,
):
"""
Regression: a caller placing a long audio clip in one block previously
reserved only 300 tokens (the flat estimate). With the size-proportional
estimate, the same clip now reserves proportionally more and must trip
the ITPM limit when the limit is tuned to exactly expose the difference.
1 100 000 b64 chars → decoded ≈ 825 000 bytes → 825 000 // 1600 ≈ 515
tokens > the 400-token limit. The flat estimate (300) would have passed.
"""
handler, cache = rate_limiter
large_b64 = "A" * 1_100_000
user_api_key_dict = UserAPIKeyAuth(
api_key=hash_token("sk-large-audio"),
project_id="proj-large-audio",
project_metadata={
"model_itpm_limit": {"bedrock_mantle/claude-opus": 400},
},
)
data = {
"model": "bedrock_mantle/claude-opus",
"messages": [
{
"role": "user",
"content": [
{"type": "text", "text": "transcribe this"},
{
"type": "input_audio",
"input_audio": {"data": large_b64, "format": "wav"},
},
],
}
],
}
with pytest.raises(Exception, match='Rate limit exceeded for model_per_project_itpm') 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, (
"Large audio payload must exceed the 400-token ITPM limit under the "
"size-proportional estimate; the old flat-rate estimate (300 tokens) "
"would have passed this limit silently"
)
@pytest.mark.asyncio
@pytest.mark.parametrize(
("call_type", "request_data"),
[
(
"acompletion",
{
"messages": [
{
"role": "user",
"content": [
{"type": "text", "text": "describe this"},
{
"type": "image_url",
"image_url": {
"url": "https://example.com/high-resolution.png",
"detail": "high",
},
},
],
}
]
},
),
(
"acompletion",
{
"messages": [
{
"role": "user",
"content": [
{"type": "text", "text": "summarize this"},
{
"type": "file",
"file": {
"filename": "document.pdf",
"file_data": "data:application/pdf;base64,dGVzdA==",
},
},
],
}
]
},
),
(
"aresponses",
{
"input": [
{
"role": "user",
"content": [
{"type": "input_text", "text": "describe this"},
{
"type": "input_image",
"image_url": "https://example.com/high-resolution.png",
"detail": "high",
},
],
}
]
},
),
(
"aresponses",
{"input": "continue", "previous_response_id": "resp-123"},
),
],
)
async def test_multimodal_requests_reserve_measured_project_itpm_not_full_limit(
rate_limiter,
call_type,
request_data,
):
"""
Regression: image, file, and previous_response_id requests used to
reserve the project's whole ITPM limit up front. Because the atomic
check is ``current + increment > limit``, that made every such request
429 as soon as the window carried any usage at all and, while in
flight, blocked every other request for the same project + model. They
now reserve the token_counter estimate like everything else, so two
multimodal requests fit in the same window.
"""
handler, cache = rate_limiter
model = "bedrock_mantle/claude-opus"
project_itpm_limit = 10_000
user_api_key_dict = UserAPIKeyAuth(
api_key=hash_token("sk-multimodal-measured"),
project_id="project-multimodal-measured",
project_metadata={"model_itpm_limit": {model: project_itpm_limit}},
)
await handler.async_pre_call_hook(
user_api_key_dict=user_api_key_dict,
cache=cache,
data={"model": model, **request_data},
call_type=call_type,
)
first_stash = get_request_stash()
assert first_stash is not None
first_reservation = first_stash.itpm_reserved_tokens
assert 0 < first_reservation < project_itpm_limit // 2
_request_stash.set(None)
await handler.async_pre_call_hook(
user_api_key_dict=user_api_key_dict,
cache=cache,
data={"model": model, **request_data},
call_type=call_type,
)
second_stash = get_request_stash()
assert second_stash is not None
assert second_stash is not first_stash
assert second_stash.itpm_reserved_tokens == first_reservation
@pytest.mark.asyncio
async def test_itpm_otpm_reservation_is_kept_on_stream_disconnect(rate_limiter):
handler, cache = rate_limiter
api_key = hash_token("sk-disconnect-test")
user_api_key_dict = UserAPIKeyAuth(
api_key=api_key,
project_id="proj-disconnect",
project_metadata={
"model_itpm_limit": {"bedrock_mantle/claude-opus": 1000},
"model_otpm_limit": {"bedrock_mantle/claude-opus": 500},
},
)
data: Dict[str, Any] = {
"model": "bedrock_mantle/claude-opus",
"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="",
)
stash = get_request_stash()
assert stash is not None
assert stash.itpm_reserved_tokens > 0, (
"pre-call hook must stash an ITPM reservation"
)
assert stash.otpm_reserved_tokens > 0, (
"pre-call hook must stash an OTPM reservation"
)
increment_calls: list[dict] = []
async def mock_increment(increment_list, litellm_parent_otel_span=None):
for op in increment_list:
increment_calls.append(
{"key": op["key"], "increment": op["increment_value"]}
)
handler.internal_usage_cache.dual_cache.async_increment_cache_pipeline = (
mock_increment
)
await handler.async_release_max_parallel_requests_on_disconnect(
user_api_key_dict=user_api_key_dict
)
itpm_refunds = [
c
for c in increment_calls
if "model_per_project_itpm" in c["key"] and c["increment"] < 0
]
otpm_refunds = [
c
for c in increment_calls
if "model_per_project_otpm" in c["key"] and c["increment"] < 0
]
assert not itpm_refunds
assert not otpm_refunds
assert stash.reservation_released is False
@pytest.mark.asyncio
async def test_responses_api_otpm_output_cap_applied_not_skipped_as_embedding(
rate_limiter,
):
"""
Regression for a Greptile P1 finding: _reserve_project_io_tokens_or_raise
classified any request with data["input"] set as an embedding (no output
tokens), which also misclassifies the Responses API -- it puts its prompt
in "input" too, but does generate output. That skipped the output cap
applied whenever the configured OTPM limit is small enough to need it,
letting an unbounded Responses generation blow past OTPM before
post-call reconciliation catches up.
The cap must land on data["max_output_tokens"], not data["max_tokens"]:
the Responses-to-chat-completion transformation only reads
max_output_tokens, so a max_tokens cap is silently dropped before
provider dispatch (a second Greptile finding on the same code path).
"""
handler, cache = rate_limiter
api_key = hash_token("sk-responses-otpm-cap")
user_api_key_dict = UserAPIKeyAuth(
api_key=api_key,
project_id="proj-responses-otpm",
project_metadata={
"model_otpm_limit": {"bedrock_mantle/claude-opus": 40},
},
)
data: Dict[str, Any] = {
"model": "bedrock_mantle/claude-opus",
"input": "describe this image in detail",
}
await handler.async_pre_call_hook(
user_api_key_dict=user_api_key_dict,
cache=cache,
data=data,
call_type="aresponses",
)
assert data.get("max_output_tokens") is not None, (
"Responses call was misclassified as an embedding and skipped the OTPM output cap"
)
assert data["max_output_tokens"] == 16
assert data.get("max_tokens") is None, (
"OTPM output cap was written to max_tokens, which the Responses transformation ignores"
)
@pytest.mark.asyncio
async def test_explicit_zero_output_responses_call_reserves_effective_provider_minimum(
rate_limiter,
):
handler, cache = rate_limiter
user_api_key_dict = UserAPIKeyAuth(
api_key=hash_token("sk-responses-zero-output"),
project_id="proj-responses-zero-output",
project_metadata={
"model_otpm_limit": {"bedrock_mantle/claude-opus": 5},
},
)
with pytest.raises(Exception, match='Rate limit exceeded for model_per_project_otpm') as exc_info:
await handler.async_pre_call_hook(
user_api_key_dict=user_api_key_dict,
cache=cache,
data={
"model": "bedrock_mantle/claude-opus",
"input": "describe this image in detail",
"max_output_tokens": 0,
},
call_type="aresponses",
)
assert getattr(exc_info.value, "status_code", None) == 429
@pytest.mark.asyncio
async def test_responses_api_combined_tpm_output_cap_applied_not_skipped_as_embedding(
rate_limiter,
):
"""
Regression for the same misclassification bug in the combined-TPM
output-cap block of async_pre_call_hook (a second, independent
`is_embedding = data.get("input") is not None` check). A project with
only a combined model_tpm_limit (no split itpm/otpm) configured small
enough to need the output cap must still apply it to a Responses call,
and must write it to max_output_tokens for the same reason as the OTPM
case above.
"""
handler, cache = rate_limiter
api_key = hash_token("sk-responses-tpm-cap")
user_api_key_dict = UserAPIKeyAuth(
api_key=api_key,
project_id="proj-responses-tpm",
project_metadata={
"model_tpm_limit": {"bedrock_mantle/claude-opus": 40},
},
)
data: Dict[str, Any] = {
"model": "bedrock_mantle/claude-opus",
"input": "describe this image in detail",
}
await handler.async_pre_call_hook(
user_api_key_dict=user_api_key_dict,
cache=cache,
data=data,
call_type="aresponses",
)
assert data.get("max_output_tokens") is not None, (
"Responses call was misclassified as an embedding and skipped the combined-TPM output cap"
)
assert data["max_output_tokens"] == 16
assert data.get("max_tokens") is None, (
"combined-TPM output cap was written to max_tokens, which the Responses transformation ignores"
)
@pytest.mark.asyncio
async def test_responses_api_multimodal_input_counts_image_content(rate_limiter):
"""
Regression for a Low-severity veria-ai finding: the Responses API's
`input` is commonly a list of message/content-block dicts, but
litellm.token_counter's `text` argument only joins plain string entries
in a list and silently drops everything else -- so an `input_image`
block contributed ~0 tokens to the ITPM estimate instead of the real
image token count. _estimate_precise_input_tokens now converts Responses
`input` to chat messages first (via the standard
transform_responses_api_input_to_messages helper) so image content is
counted the same way a chat completion's image content already is.
"""
handler, _cache = rate_limiter
text_only_estimate = handler._estimate_precise_input_tokens(
data={"input": "hi"},
model="bedrock_mantle/claude-opus",
call_type="aresponses",
)
multimodal_estimate = handler._estimate_precise_input_tokens(
data={
"input": [
{
"role": "user",
"content": [
{"type": "input_text", "text": "hi"},
{
"type": "input_image",
"image_url": "https://example.com/some-image.png",
},
],
}
],
},
model="bedrock_mantle/claude-opus",
call_type="aresponses",
)
assert multimodal_estimate > text_only_estimate + 100, (
"Responses API input_image content block was not counted; got "
f"text_only={text_only_estimate}, multimodal={multimodal_estimate}"
)
@pytest.mark.asyncio
async def test_refund_reserved_tokens_noop_when_amount_zero(rate_limiter):
"""_refund_reserved_tokens returns immediately without calling Redis when amount=0."""
handler, _cache = rate_limiter
calls = []
async def mock_increment(pipeline_operations, **kwargs):
calls.extend(pipeline_operations)
handler.async_increment_tokens_with_ttl_preservation = mock_increment
await handler._refund_reserved_tokens(
scopes=[("api_key", "sk-test")],
amount=0,
)
assert not calls, "No Redis ops expected when amount is zero"
@pytest.mark.asyncio
async def test_reserve_io_tokens_noop_when_no_itpm_otpm_descriptors(rate_limiter):
"""reserve_io_tokens returns OK immediately when no ITPM/OTPM descriptors present."""
handler, _cache = rate_limiter
non_io_descriptor = {
"key": "api_key",
"value": "sk-test",
"rate_limit": {"tokens_per_unit": 1000, "window_size": 60},
}
response, itpm_reserved, otpm_reserved = await handler.reserve_io_tokens(
descriptors=[non_io_descriptor],
estimated_input_tokens=50,
estimated_output_tokens=50,
)
assert response["overall_code"] == "OK"
assert itpm_reserved == 0
assert otpm_reserved == 0
@pytest.mark.asyncio
async def test_reserve_io_tokens_itpm_only_no_otpm(rate_limiter):
"""When only ITPM descriptors are present (no OTPM), returns itpm_reserved with otpm=0."""
handler, cache = rate_limiter
itpm_descriptor = {
"key": PROJECT_ITPM_DESCRIPTOR_KEY,
"value": "proj-a:model",
"rate_limit": {"tokens_per_unit": 10000, "window_size": 60},
}
response, itpm_reserved, otpm_reserved = await handler.reserve_io_tokens(
descriptors=[itpm_descriptor],
estimated_input_tokens=100,
estimated_output_tokens=50,
)
assert response["overall_code"] == "OK"
assert itpm_reserved == 100
assert otpm_reserved == 0
def test_strip_audio_content_blocks_passthrough_non_list_messages():
"""Non-list input is returned unchanged (early return on line 2605)."""
result = RateLimitHandler._strip_audio_content_blocks("not a list")
assert result == "not a list"
def test_strip_audio_content_blocks_passthrough_non_dict_message():
"""Non-dict entries in the message list are appended unchanged."""
messages = ["plain string message"]
result = RateLimitHandler._strip_audio_content_blocks(messages)
assert result == ["plain string message"]
def test_strip_audio_content_blocks_passthrough_non_list_content():
"""Messages with non-list content (e.g. plain string) pass through unchanged."""
messages = [{"role": "user", "content": "hello"}]
result = RateLimitHandler._strip_audio_content_blocks(messages)
assert result == [{"role": "user", "content": "hello"}]
@pytest.mark.asyncio
async def test_otpm_rejection_releases_stashed_parallel_slot(rate_limiter):
"""
When OTPM is over limit and a parallel slot was already acquired, the
disconnect cleanup path in _reserve_project_io_tokens_or_raise must
release that slot. Exercises lines 2773-2777.
"""
handler, cache = rate_limiter
user_api_key_dict = UserAPIKeyAuth(
api_key=hash_token("sk-otpm-slot"),
project_id="proj-slot",
project_metadata={"model_otpm_limit": {"m": 5}},
)
data: Dict[str, Any] = {
"model": "m",
"messages": [{"role": "user", "content": "hello"}],
"max_tokens": 50,
}
slot_released = []
async def mock_release(acquisition, parent_otel_span=None):
slot_released.append(acquisition)
handler._release_parallel_request_slots = mock_release
stash = get_or_create_request_stash()
stash.parallel_slot = {
"slot_id": "test-slot-id",
"counter_keys": ["some-key"],
}
otpm_descriptor = {
"key": PROJECT_OTPM_DESCRIPTOR_KEY,
"value": "proj-slot:m",
"rate_limit": {"tokens_per_unit": 5, "window_size": 60},
}
with pytest.raises(Exception, match='Rate limit exceeded for model_per_project_otpm') as exc_info:
await handler._reserve_project_io_tokens_or_raise(
descriptors=[otpm_descriptor],
data=data,
requested_model="m",
user_api_key_dict=user_api_key_dict,
tpm_reservation_scopes=[],
tpm_reservation_amount=0,
)
assert getattr(exc_info.value, "status_code", None) == 429
assert slot_released, "Parallel slot must be released when OTPM rejects"
assert stash.parallel_slot is None
@pytest.mark.asyncio
async def test_itpm_only_status_stored_when_no_prior_rate_limit_response(rate_limiter):
"""
When only ITPM is configured (no combined TPM/RPM to pre-populate
the request stash), a successful ITPM reservation must store its status
there so post-call headers can read it.
"""
handler, cache = rate_limiter
user_api_key_dict = UserAPIKeyAuth(
api_key=hash_token("sk-itpm-only-store"),
project_id="proj-store",
)
data: Dict[str, Any] = {"model": "m", "messages": []}
itpm_descriptor = {
"key": PROJECT_ITPM_DESCRIPTOR_KEY,
"value": "proj-store:m",
"rate_limit": {"tokens_per_unit": 100000, "window_size": 60},
}
await handler._reserve_project_io_tokens_or_raise(
descriptors=[itpm_descriptor],
data=data,
requested_model="m",
user_api_key_dict=user_api_key_dict,
tpm_reservation_scopes=[],
tpm_reservation_amount=0,
)
stash = get_request_stash()
assert stash is not None
stored = stash.rate_limit_response
assert stored is not None, (
"ITPM status must be stored in litellm_proxy_rate_limit_response"
)
assert stored.get("statuses"), "Stored response must contain statuses"
def test_resolve_io_token_usage_responses_api_with_cached_tokens(rate_limiter):
"""
ResponsesAPIResponse whose usage.input_tokens_details.cached_tokens is set
subtracts the cached portion from billable input. Covers line 3501.
"""
handler, _cache = rate_limiter
response_obj = ResponsesAPIResponse(
id="resp_cached",
created_at=int(datetime.now().timestamp()),
output=[],
usage=ResponseAPIUsage(
input_tokens=100,
output_tokens=50,
total_tokens=150,
input_tokens_details=InputTokensDetails(cached_tokens=25),
),
)
billable_input, completion_tokens, resolved = (
handler._resolve_io_token_reconcile_usage(response_obj)
)
assert resolved is True
assert billable_input == 75, f"Expected 100 - 25 cached = 75, got {billable_input}"
assert completion_tokens == 50
def test_resolve_io_token_usage_dict_format(rate_limiter):
"""
Dict-shaped usage on a ModelResponse (older SDK versions or raw dicts in
the usage field) is parsed correctly. Covers lines 3502-3506.
"""
handler, _cache = rate_limiter
response_obj = ModelResponse.model_construct(
usage={
"prompt_tokens": 80,
"completion_tokens": 40,
"prompt_tokens_details": {"cached_tokens": 20},
}
)
billable_input, completion_tokens, resolved = (
handler._resolve_io_token_reconcile_usage(response_obj)
)
assert resolved is True
assert billable_input == 60, f"Expected 80 - 20 cached = 60, got {billable_input}"
assert completion_tokens == 40
def test_resolve_io_token_usage_unknown_type_returns_unresolved(rate_limiter):
"""
A ModelResponse whose usage attribute is not a Usage, ResponseAPIUsage,
or dict (e.g. a plain int) returns (0, 0, False) so the reservation is
kept rather than guessed. Covers lines 3507-3508.
"""
handler, _cache = rate_limiter
response_obj = ModelResponse.model_construct(usage=42)
billable_input, completion_tokens, resolved = (
handler._resolve_io_token_reconcile_usage(response_obj)
)
assert resolved is False
assert billable_input == 0
assert completion_tokens == 0
@pytest.mark.parametrize(
("combined_usage", "expected_increments"),
[
(None, ()),
(
Usage(prompt_tokens=40, completion_tokens=15, total_tokens=55),
(-60, -45),
),
],
)
def test_zero_usage_keeps_reservations_unless_measured_fallback_exists(
rate_limiter,
combined_usage,
expected_increments,
):
handler, _cache = rate_limiter
itpm_scope = (PROJECT_ITPM_DESCRIPTOR_KEY, "project:model")
otpm_scope = (PROJECT_OTPM_DESCRIPTOR_KEY, "project:model")
stash = get_or_create_request_stash()
stash.itpm_reserved_tokens = 100
stash.itpm_reserved_scopes = frozenset({itpm_scope})
stash.otpm_reserved_tokens = 60
stash.otpm_reserved_scopes = frozenset({otpm_scope})
kwargs = {} if combined_usage is None else {"combined_usage_object": combined_usage}
response_obj = ModelResponse(
usage=Usage(prompt_tokens=0, completion_tokens=0, total_tokens=0)
)
operations = handler._build_io_token_reservation_ops(kwargs, response_obj)
assert tuple(operation["increment_value"] for operation in operations) == expected_increments
@pytest.mark.parametrize(
("usage", "expected_increments"),
[
(
Usage(prompt_tokens=40, completion_tokens=15, total_tokens=55),
(40, 15),
),
(
Usage(prompt_tokens=0, completion_tokens=0, total_tokens=0),
(100, 60),
),
],
)
def test_retry_success_charges_released_project_io_reservations(
rate_limiter,
usage,
expected_increments,
):
handler, _cache = rate_limiter
itpm_scope = (PROJECT_ITPM_DESCRIPTOR_KEY, "project:model")
otpm_scope = (PROJECT_OTPM_DESCRIPTOR_KEY, "project:model")
stash = get_or_create_request_stash()
stash.itpm_reserved_tokens = 100
stash.itpm_reserved_scopes = frozenset({itpm_scope})
stash.otpm_reserved_tokens = 60
stash.otpm_reserved_scopes = frozenset({otpm_scope})
stash.reservation_released = True
operations = handler._build_io_token_reservation_ops(
{},
ModelResponse(usage=usage),
)
assert tuple(operation["increment_value"] for operation in operations) == expected_increments
@pytest.mark.asyncio
async def test_build_io_token_reservation_ops_skips_unresolvable_usage(rate_limiter):
"""
When response_obj has no parseable usage, _build_io_token_reservation_ops
returns [] to keep the reservation as-is rather than zeroing it out on a
bad guess. Covers line 3538.
"""
handler, _cache = rate_limiter
itpm_scope = ("model_per_project_itpm", "proj-b:model")
stash = get_or_create_request_stash()
stash.itpm_reserved_tokens = 50
stash.itpm_reserved_scopes = frozenset({itpm_scope})
mock_kwargs = {}
ops = handler._build_io_token_reservation_ops(
kwargs=mock_kwargs,
response_obj=object(),
)
assert not ops, f"Expected empty ops for unresolvable usage, got {ops}"
@pytest.mark.asyncio
async def test_post_call_failure_skips_rpm_only_descriptor_in_tpm_refund(rate_limiter):
"""
async_post_call_failure_hook skips descriptors without tokens_per_unit
(e.g. an RPM-only api_key scope) when building the combined-TPM refund ops,
so a key with rpm_limit but no tpm_limit doesn't receive a spurious refund
that would drive its counter negative. Covers the continue guard at line 4250.
"""
handler, cache = rate_limiter
api_key = hash_token("sk-rpm-only-desc")
user_api_key_dict = UserAPIKeyAuth(
api_key=api_key,
rpm_limit=100,
project_id="proj-rpm-only-desc",
project_metadata={"model_tpm_limit": {"gpt-3.5-turbo": 100000}},
)
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="",
)
rpm_tokens_key = handler.create_rate_limit_keys(
key="api_key", value=api_key, rate_limit_type="tokens"
)
await handler.async_post_call_failure_hook(
request_data=data,
original_exception=Exception("rejected"),
user_api_key_dict=user_api_key_dict,
)
api_key_tokens_after = int(
await cache.async_get_cache(key=rpm_tokens_key, local_only=True) or 0
)
assert api_key_tokens_after >= 0, (
f"RPM-only api_key scope must not receive a negative TPM refund; got {api_key_tokens_after}"
)
@pytest.mark.asyncio
async def test_max_output_tokens_prevents_cap_injection(rate_limiter):
"""
Regression for veria-ai comment: when a Responses API request supplies
max_output_tokens (the canonical Responses output bound) but not max_tokens
or max_completion_tokens, the has_explicit_max_tokens check was False, so
the code injected data["max_tokens"] = capped_floor and silently truncated
the response.
With the fix, max_output_tokens is included in the explicit-cap check and
data["max_tokens"] must NOT be injected when max_output_tokens is already
set.
"""
handler, cache = rate_limiter
user_api_key_dict = UserAPIKeyAuth(
api_key=hash_token("sk-max-output-tokens"),
project_id="proj-responses-max-output",
project_metadata={
"model_otpm_limit": {"mock-model": 100},
},
)
data: dict = {
"model": "mock-model",
"input": "Summarise the document",
"max_output_tokens": 80,
"litellm_call_id": "test-max-output-tokens",
"metadata": {},
}
try:
await handler.async_pre_call_hook(
user_api_key_dict=user_api_key_dict,
cache=cache,
data=data,
call_type="responses",
)
except Exception:
pass
assert "max_tokens" not in data, (
"data['max_tokens'] must not be injected when max_output_tokens is already "
"set; the cap injection was overriding the caller's explicit output bound"
)
@pytest.mark.asyncio
@pytest.mark.parametrize(
("call_type", "request_data", "cap_field", "reserved_tokens"),
[
("aresponses", {"input": "hello", "max_tokens": 1}, "max_output_tokens", 16),
(
"acompletion",
{
"messages": [{"role": "user", "content": "hello"}],
"max_output_tokens": 1,
},
"max_tokens",
10,
),
],
)
async def test_output_reservation_ignores_cap_fields_from_other_endpoints(
rate_limiter,
call_type,
request_data,
cap_field,
reserved_tokens,
):
handler, cache = rate_limiter
user_api_key_dict = UserAPIKeyAuth(
api_key=hash_token(f"sk-{call_type}"),
project_id=f"project-{call_type}",
project_metadata={"model_otpm_limit": {"model": 40}},
)
data = {"model": "model", **request_data}
await handler.async_pre_call_hook(
user_api_key_dict=user_api_key_dict,
cache=cache,
data=data,
call_type=call_type,
)
assert data[cap_field] == reserved_tokens
stash = get_request_stash()
assert stash is not None
assert stash.otpm_reserved_tokens == reserved_tokens
def test_responses_input_is_counted_even_when_messages_is_present(rate_limiter):
handler, _cache = rate_limiter
small_estimate = handler._estimate_precise_input_tokens(
data={"input": "short", "messages": [{"role": "user", "content": "ignored"}]},
model="",
call_type="aresponses",
)
large_estimate = handler._estimate_precise_input_tokens(
data={"input": "large input " * 500, "messages": []},
model="",
call_type="aresponses",
)
assert large_estimate > small_estimate
def test_anthropic_messages_usage_reconciles_split_project_quota(rate_limiter):
handler, _cache = rate_limiter
billable_input, output_tokens, resolved = handler._resolve_io_token_reconcile_usage(
{
"usage": {
"input_tokens": 100,
"output_tokens": 25,
"cache_read_input_tokens": 30,
}
}
)
assert resolved is True
assert billable_input == 70
assert output_tokens == 25
@pytest.mark.asyncio
@pytest.mark.parametrize(
"call_type",
["agenerate_content", "agenerate_content_stream"],
)
async def test_google_genai_native_contents_reserve_project_itpm(
rate_limiter,
call_type,
):
handler, cache = rate_limiter
model = "gemini/gemini-2.5-flash"
user_api_key_dict = UserAPIKeyAuth(
api_key=hash_token("sk-google-genai-native-itpm"),
project_id="project-google-genai-native-itpm",
project_metadata={"model_itpm_limit": {model: 10_000}},
)
await handler.async_pre_call_hook(
user_api_key_dict=user_api_key_dict,
cache=cache,
data={
"model": model,
"contents": [
{
"role": "user",
"parts": [{"text": "Gemini quota input " * 200}],
}
],
},
call_type=call_type,
)
stash = get_request_stash()
assert stash is not None
assert stash.itpm_reserved_tokens > 100
@pytest.mark.asyncio
@pytest.mark.parametrize("call_type", ["rerank", "arerank"])
async def test_rerank_query_and_documents_enforce_project_itpm(
rate_limiter,
monkeypatch,
call_type,
):
handler, cache = rate_limiter
captured = {}
def token_counter(**kwargs):
captured.update(kwargs)
return 101
monkeypatch.setattr("litellm.token_counter", token_counter)
user_api_key_dict = UserAPIKeyAuth(
api_key=hash_token(f"sk-{call_type}-itpm"),
project_id=f"project-{call_type}-itpm",
project_metadata={"model_itpm_limit": {"rerank-model": 100}},
)
with pytest.raises(Exception, match='Rate limit exceeded for model_per_project_itpm') as exc_info:
await handler.async_pre_call_hook(
user_api_key_dict=user_api_key_dict,
cache=cache,
data={
"model": "rerank-model",
"query": "Which document is most relevant?",
"documents": ["first document", {"text": "second document"}],
},
call_type=call_type,
)
assert getattr(exc_info.value, "status_code", None) == 429
assert captured["text"] == (
"Which document is most relevant?\n"
"first document\n"
"{'text': 'second document'}"
)
def test_rerank_input_estimate_falls_back_to_character_count(
rate_limiter,
monkeypatch,
):
handler, _cache = rate_limiter
data = {
"query": "query text",
"documents": ["first document", "second document"],
}
def token_counter(**_kwargs):
raise ValueError("tokenizer unavailable")
monkeypatch.setattr("litellm.token_counter", token_counter)
rerank_text = handler._rerank_input_to_text(data)
assert handler._estimate_precise_input_tokens(
data,
model="custom-rerank-model",
call_type="rerank",
) == len(rerank_text) // 4
@pytest.mark.parametrize(
("response_obj", "expected"),
[
(
RerankResponse(
meta={"tokens": {"input_tokens": 42, "output_tokens": 3}}
),
(42, 3, True),
),
(
RerankResponse(
meta={
"tokens": {"input_tokens": 0, "output_tokens": 0},
"billed_units": {"total_tokens": 57},
}
),
(57, 0, True),
),
(
RerankResponse(
meta={
"tokens": {"input_tokens": 0, "output_tokens": 0},
"billed_units": {"total_tokens": 0},
}
),
(0, 0, False),
),
],
)
def test_rerank_usage_reconciles_project_split_token_quota(
rate_limiter,
response_obj,
expected,
):
handler, _cache = rate_limiter
assert handler._resolve_io_token_reconcile_usage(response_obj) == expected
def test_split_quota_helpers_handle_non_mapping_inputs(rate_limiter):
handler, _cache = rate_limiter
assert _call_id_from_callback_kwargs(object()) is None
assert handler._is_embedding_request(object(), None) is False
assert handler._get_explicit_output_cap(object(), None) is None
assert handler.get_output_candidate_count(object()) == 1
assert handler.get_output_candidate_count({"n": 1e309}) == 1
assert (
handler._get_explicit_output_cap({"max_output_tokens": []}, "responses") is None
)
assert handler._apply_implicit_output_cap(object(), 100, "responses") is None
assert handler._estimate_input_and_output_tokens(object()) == (0, 0)
assert handler._build_io_token_reservation_ops(object(), object()) == ()
@pytest.mark.parametrize(
("data", "call_type", "expected"),
[
({"max_tokens": "30.0"}, "", 30),
({"max_tokens": "not-a-number"}, "", None),
({"max_tokens": True}, "", None),
({"max_output_tokens": "30.0"}, "responses", 30),
({"max_output_tokens": "nan"}, "responses", None),
({"generationConfig": {"maxOutputTokens": "12.5"}}, "agenerate_content", 12),
({"generationConfig": {"maxOutputTokens": "oops"}}, "agenerate_content", None),
],
)
def test_get_explicit_output_cap_tolerates_unparseable_values(
rate_limiter, data, call_type, expected
):
"""A client-supplied cap the proxy cannot parse must fall back to the
no-cap output estimate instead of raising ValueError and 500ing the
request before it ever reaches the provider."""
handler, _cache = rate_limiter
assert handler._get_explicit_output_cap(data, call_type) == expected
@pytest.mark.asyncio
async def test_project_io_counters_not_double_charged_when_reservation_disabled(
monkeypatch,
):
"""With LITELLM_TPM_TOKEN_RESERVATION_ENABLED=false the first
should_rate_limit pass used to +1 every ITPM/OTPM counter on top of the
full reservation _reserve_project_io_tokens_or_raise always makes,
permanently inflating each bucket by one token per request."""
monkeypatch.setenv("LITELLM_TPM_TOKEN_RESERVATION_ENABLED", "false")
cache = DualCache()
handler = RateLimitHandler(internal_usage_cache=InternalUsageCache(cache))
assert handler.tpm_reservation_enabled is False
user_api_key_dict = UserAPIKeyAuth(
api_key=hash_token("sk-io-no-reservation"),
project_id="proj-io-no-reservation",
project_metadata={
"model_itpm_limit": {"bedrock_mantle/claude-opus": 1000000},
"model_otpm_limit": {"bedrock_mantle/claude-opus": 1000000},
},
)
data = {
"model": "bedrock_mantle/claude-opus",
"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="",
)
stash = get_request_stash()
assert stash is not None
assert stash.itpm_reserved_tokens > 0
assert stash.otpm_reserved_tokens > 0
for descriptor_key, reserved in (
("model_per_project_itpm", stash.itpm_reserved_tokens),
("model_per_project_otpm", stash.otpm_reserved_tokens),
):
counter_key = handler.create_rate_limit_keys(
key=descriptor_key,
value="proj-io-no-reservation:bedrock_mantle/claude-opus",
rate_limit_type="tokens",
)
cached = await cache.async_get_cache(key=counter_key, local_only=True)
assert int(cached or 0) == reserved, (
f"{descriptor_key} counter {cached} != reserved {reserved}: "
"first-pass should_rate_limit double-charged the bucket"
)
@pytest.mark.parametrize(
("call_type", "data"),
[
(
"text_completion",
{
"messages": [{"role": "user", "content": "ignored"}],
"prompt": "abcd",
"input": "ignored",
"max_tokens": 1,
},
),
(None, {"prompt": "abcd", "max_tokens": 1}),
(None, {"prompt": ["abcd", "efgh"], "max_tokens": 1}),
],
)
def test_split_token_estimate_selects_endpoint_input(rate_limiter, call_type, data):
handler, _cache = rate_limiter
estimated_input, estimated_output = handler._estimate_input_and_output_tokens(
data=data,
call_type=call_type,
)
assert estimated_input > 0
assert estimated_output == 1
def test_split_quota_multimodal_guards_handle_non_mapping_inputs(rate_limiter):
handler, _cache = rate_limiter
assert handler._estimate_audio_block_tokens(
object()
) == handler._estimate_audio_block_tokens({})
assert handler._responses_input_to_chat_messages(object()) == ()
assert handler._estimate_precise_input_tokens(object(), model=None) == 0
@pytest.mark.parametrize(
("call_type", "data", "expected_text"),
[
("embedding", {"input": "embedding input"}, "embedding input"),
(
"embedding",
{"input": ["first embedding", "second embedding"]},
["first embedding", "second embedding"],
),
("text_completion", {"prompt": "completion prompt"}, "completion prompt"),
],
)
def test_precise_input_estimate_selects_endpoint_text(
rate_limiter,
monkeypatch,
call_type,
data,
expected_text,
):
handler, _cache = rate_limiter
captured = {}
def token_counter(**kwargs):
captured.update(kwargs)
return 7
monkeypatch.setattr("litellm.token_counter", token_counter)
assert (
handler._estimate_precise_input_tokens(data, model="test", call_type=call_type)
== 7
)
assert captured["messages"] is None
assert captured["text"] == expected_text
@pytest.mark.asyncio
async def test_project_io_reservation_ignores_non_mapping_request_data(rate_limiter):
handler, _cache = rate_limiter
await handler._reserve_project_io_tokens_or_raise(
descriptors=[],
data=object(),
requested_model=None,
user_api_key_dict=UserAPIKeyAuth(),
tpm_reservation_scopes=(),
tpm_reservation_amount=0,
)
@pytest.mark.asyncio
async def test_streaming_combined_usage_reconciles_project_io_reservations(
rate_limiter,
):
handler, _cache = rate_limiter
itpm_scope = (PROJECT_ITPM_DESCRIPTOR_KEY, "project:model")
otpm_scope = (PROJECT_OTPM_DESCRIPTOR_KEY, "project:model")
stash = get_or_create_request_stash()
stash.itpm_reserved_tokens = 100
stash.itpm_reserved_scopes = frozenset({itpm_scope})
stash.otpm_reserved_tokens = 60
stash.otpm_reserved_scopes = frozenset({otpm_scope})
kwargs = {
"combined_usage_object": Usage(
prompt_tokens=40,
completion_tokens=15,
total_tokens=55,
),
}
increments = []
async def capture_increments(increment_list, **_kwargs):
increments.extend(increment_list)
handler.internal_usage_cache.dual_cache.async_increment_cache_pipeline = (
capture_increments
)
await handler.async_log_success_event(
kwargs=kwargs,
response_obj={"response": "stream body"},
start_time=datetime.now(),
end_time=datetime.now(),
)
itpm_adjustments = [
operation
for operation in increments
if PROJECT_ITPM_DESCRIPTOR_KEY in operation["key"]
]
otpm_adjustments = [
operation
for operation in increments
if PROJECT_OTPM_DESCRIPTOR_KEY in operation["key"]
]
assert [operation["increment_value"] for operation in itpm_adjustments] == [-60]
assert [operation["increment_value"] for operation in otpm_adjustments] == [-45]
def test_aggregate_only_combined_usage_reconciles_project_io_reservations(rate_limiter):
handler, _cache = rate_limiter
stash = get_or_create_request_stash()
stash.itpm_reserved_tokens = 100
stash.itpm_reserved_scopes = frozenset(
{(PROJECT_ITPM_DESCRIPTOR_KEY, "project:model")}
)
stash.otpm_reserved_tokens = 80
stash.otpm_reserved_scopes = frozenset(
{(PROJECT_OTPM_DESCRIPTOR_KEY, "project:model")}
)
kwargs = {
"combined_usage_object": Usage(total_tokens=55),
}
operations = handler._build_io_token_reservation_ops(kwargs, object())
assert [operation["increment_value"] for operation in operations] == [-45, -25]
def test_raw_split_usage_dict_reconciles_project_io_tokens(rate_limiter):
handler, _cache = rate_limiter
assert handler._resolve_io_token_reconcile_usage(
{
"input_tokens": 30,
"output_tokens": 12,
"input_tokens_details": {"cached_tokens": 5},
}
) == (25, 12, True)
@pytest.mark.asyncio
async def test_post_call_success_hook_contains_header_merge_failures(
rate_limiter, monkeypatch
):
handler, _cache = rate_limiter
response = ModelResponse()
response._hidden_params = {}
def raise_on_merge(**_kwargs):
raise RuntimeError("header merge failed")
monkeypatch.setattr(
handler,
"_merge_ratelimit_statuses_into_additional_headers",
raise_on_merge,
)
await handler.async_post_call_success_hook(
data={
"litellm_proxy_rate_limit_response": {
"overall_code": "OK",
"statuses": (),
}
},
user_api_key_dict=UserAPIKeyAuth(),
response=response,
)
@pytest.mark.asyncio
async def test_the_project_itpm_reservation_counts_the_request_off_the_event_loop(rate_limiter):
from tests.large_text import text
from tests.unit.litellm_core_utils.event_loop_lag import (
assert_loop_stayed_free,
timed_with_loop_lags,
warm_tokenizer,
)
handler, _cache = rate_limiter
stash = get_or_create_request_stash()
warm_tokenizer("claude-fable-5")
data: dict[str, object] = {
"model": "claude-fable-5",
"messages": [{"role": "user", "content": text * 100}],
}
itpm_descriptor = {
"key": PROJECT_ITPM_DESCRIPTOR_KEY,
"value": "proj-loop:claude-fable-5",
"rate_limit": {"tokens_per_unit": 10_000_000, "window_size": 60},
}
_, took, lags = await timed_with_loop_lags(
lambda: handler._reserve_project_io_tokens_or_raise(
descriptors=[itpm_descriptor],
data=data,
requested_model="claude-fable-5",
user_api_key_dict=UserAPIKeyAuth(api_key=hash_token("sk-itpm-loop"), project_id="proj-loop"),
tpm_reservation_scopes=[],
tpm_reservation_amount=0,
)
)
assert stash.rate_limit_response is not None
assert_loop_stayed_free(took, lags)
if __name__ == "__main__":
pytest.main([__file__, "-v", "-s"])