fix(rate-limiting): reserve one concurrency unit per non-racing batch branch, release one per terminal event

Router.abatch_completion's comma-separated model fans a single admission
into several real, independent LLM calls below this hook entirely (it never
re-runs async_pre_call_hook per branch the way model_based_tag_rate_limits_hook's
per-Router-hop admission does). A single reservation for the whole batch was
released by whichever branch finished first, letting the still-running
siblings push real concurrent calls past the configured cap. Confirmed live
against the real Router.abatch_completion: admission fires exactly once per
dispatch, and every branch reliably fires its own terminal event (no
cancellation involved), unlike abatch_completion_fastest_response, whose
cancelled losing branches never fire one at all.

Admission now reserves one unit per comma-separated model for that specific,
non-racing call shape (_non_racing_batch_width), and each branch's own
success/failure event releases just its own share (_release_one_pending_for_call_id),
leaving the existing release-everything path for disconnect/exhausted-fallback
unchanged, since those abort every not-yet-completed branch together with no
per-branch event to rely on. The racing fastest_response variant keeps
reserving a single unit, since reserving per-model there would leak every
cancelled loser's share until the safety TTL.

The sibling-slot-release bug fixed on the parent branch does not reproduce
here: this hook's own admission point never runs more than once per call_id,
so there is never more than one reservation to disambiguate between.
This commit is contained in:
Deepanshu 2026-08-31 12:34:57 -04:00
parent 63299d9df6
commit a0a92093f6
2 changed files with 423 additions and 20 deletions

View file

@ -32,6 +32,22 @@ same inherited context (an LLM-judge guardrail, for example) gets its own
isolated entry instead of releasing the outer call's still-pending
reservation early. Confirmed live: a real streaming client disconnect
correctly releases its concurrency reservation through this mechanism.
`Router.abatch_completion`'s comma-separated `model` fans this one admission
out into several real, independent LLM calls below this hook entirely (it
never re-runs `async_pre_call_hook` per branch the way
`model_based_tag_rate_limits_hook`'s per-Router-hop admission does), each of
which reliably fires its own terminal success/failure event -- confirmed
live against the real dispatch. A single concurrency reservation for the
whole batch would be released by whichever branch finishes first, letting
the still-running siblings push real concurrent calls past the configured
cap, so admission instead reserves one unit per comma-separated model (see
`_non_racing_batch_width`) and each branch's own event releases just its own
share (see `_release_one_pending_for_call_id`). The racing
`abatch_completion_fastest_response` variant is excluded from that: it
cancels every losing branch without ever firing a terminal event for it
(confirmed live), so reserving more than the single unit it already does
would leak every losing branch's share until the safety TTL.
"""
import asyncio
@ -142,6 +158,34 @@ def _entry_applies_any_admitted_model(
return any(_entry_applies(entry, tags, key_alias, model) for model in admitted_models)
def _non_racing_batch_width(data: Mapping[str, object], call_type: str) -> int:
"""`Router.abatch_completion` (not `abatch_completion_fastest_response`)
fans this one admission out into one independent real LLM call per
comma-separated model in `model`, and every one of those branches
reliably fires its own terminal success/failure event -- confirmed
empirically, no cancellation involved, unlike the racing
`fastest_response` variant, which cancels every losing branch without
ever firing a terminal event for it and must keep reserving a single
unit released by whichever branch finishes first.
A single concurrency reservation for the whole non-racing dispatch would
get released by whichever branch finishes first, letting the
still-running siblings push real concurrent calls past the configured
cap. Reserving one unit per branch instead, released one at a time as
each branch's own event fires, keeps the count accurate.
Mirrors the exact condition `route_llm_request.py` uses to route into
`abatch_completion` in the first place, so this only ever fires for a
request that will actually take that path.
"""
if call_type != "acompletion" or data.get("fastest_response"):
return 1
model_field: Final = data.get("model")
if not isinstance(model_field, str) or "," not in model_field:
return 1
return len(model_field.split(","))
def _hash_tag(entry: TagRateLimitEntry, unit: _LimitUnit, tag_value: str, key_hash: str | None) -> str:
"""Namespaced under `tag_rl:global:` so it never collides with
`model_based_tag_rate_limits_hook`'s own `tag_rl:{model_group}:...` keys."""
@ -195,6 +239,11 @@ class _GlobalTagRateLimitStash:
# an earlier attempt must still get its accounting at success time even
# though the request ultimately serves from a later attempt's model.
admitted_models: frozenset[str] = field(default_factory=frozenset)
# One entry per reserved unit, not one entry per distinct key: a
# non-racing batch dispatch (see _non_racing_batch_width) reserves and
# appends `batch_width` entries for the same key, and each branch's own
# terminal event pops exactly one of them -- see
# _release_one_pending_for_call_id.
pending_concurrency_keys: list[tuple[str, _PartitionKey]] = field(default_factory=list) # mutable-ok: queue
# Keys already charged for this call_id, so a fallback retry (same
# litellm_call_id, different model) renews instead of double-charging.
@ -587,6 +636,7 @@ class _PROXY_GlobalTagRateLimitsHook( # pyright: ignore[reportUnusedClass] # o
already_reserved_concurrency_keys: Final = frozenset(
key for key, _partition_key in stash.pending_concurrency_keys
)
non_racing_batch_width: Final = _non_racing_batch_width(data, call_type)
failing_index, values = await self._atomic_check_and_increment(
tuple(
(
@ -596,13 +646,16 @@ class _PROXY_GlobalTagRateLimitsHook( # pyright: ignore[reportUnusedClass] # o
# A key already charged/reserved for this call_id (an
# earlier fallback attempt for the same request) renews
# at zero net cost instead of charging a second unit.
# Otherwise a concurrency check reserves one unit per
# non-racing batch branch (see _non_racing_batch_width),
# not just one for the whole dispatch.
0.0
if renewal_allowed
and (
(check.unit == "requests" and check.key in stash.charged_request_keys)
or (check.unit == "concurrency" and check.key in already_reserved_concurrency_keys)
)
else 1.0,
else (float(non_racing_batch_width) if check.unit == "concurrency" else 1.0),
self._ttl_for(check.unit, check.entry),
check.unit == "concurrency",
)
@ -617,11 +670,14 @@ class _PROXY_GlobalTagRateLimitsHook( # pyright: ignore[reportUnusedClass] # o
# Exclude already_reserved_concurrency_keys: that key renewed at
# zero cost above, so re-adding it would make release decrement
# twice for a counter only ever incremented once.
# twice for a counter only ever incremented once. One entry per
# reserved unit (non_racing_batch_width of them, ordinarily 1) --
# see pending_concurrency_keys's own docstring for why.
concurrency_reservations: Final = tuple(
(check.key, _partition_key(check.entry))
for check in atomic_checks
if check.unit == "concurrency" and check.key not in already_reserved_concurrency_keys
for _ in range(non_racing_batch_width)
)
if concurrency_reservations:
stash.pending_concurrency_keys.extend(concurrency_reservations) # mutable-ok: see field's own docstring
@ -645,6 +701,14 @@ class _PROXY_GlobalTagRateLimitsHook( # pyright: ignore[reportUnusedClass] # o
return data
async def _release_pending_for_call_id(self, request_kwargs: Mapping[str, object]) -> None:
"""Releases every reservation still pending for this call_id at once.
Correct for a disconnect or a chain-exhausted failure: either aborts
every not-yet-completed branch of a non-racing batch together (see
_non_racing_batch_width), so none of them will ever fire its own
terminal event to release its own share individually -- whatever's
still pending here is exactly what those abandoned branches reserved,
no more (any branch that already completed already popped its own
entry via _release_one_pending_for_call_id) and no less."""
stash: Final = _stash_for_call(_call_id_from_kwargs(request_kwargs))
if stash is None or not stash.pending_concurrency_keys:
return
@ -652,6 +716,21 @@ class _PROXY_GlobalTagRateLimitsHook( # pyright: ignore[reportUnusedClass] # o
stash.pending_concurrency_keys.clear()
await self._release_keys(release_keys)
async def _release_one_pending_for_call_id(
self, request_kwargs: Mapping[str, object]
) -> tuple[str, _PartitionKey] | None:
"""Releases exactly one reservation, not every reservation currently
pending: a non-racing batch dispatch reserves one unit per
comma-separated model (see _non_racing_batch_width), and each
branch's own terminal event must only release its own share, not a
still-running sibling's. Entries under one call_id are otherwise
fungible (same key repeated), so which single entry gets popped
doesn't matter."""
stash: Final = _stash_for_call(_call_id_from_kwargs(request_kwargs))
if stash is None or not stash.pending_concurrency_keys:
return None
return stash.pending_concurrency_keys.pop() # mutable-ok: see field's own docstring
async def async_release_disconnect_state_hook(self, request_data: Mapping[str, object]) -> None:
await self._release_pending_for_call_id(request_data)
@ -683,11 +762,15 @@ class _PROXY_GlobalTagRateLimitsHook( # pyright: ignore[reportUnusedClass] # o
end_time: datetime | None,
) -> None:
# Always release regardless of which hook raised: this hook's own
# rejection never reserves a slot, so pending_concurrency_keys is
# already empty in that case and the check below no-ops; a rejection
# from model_based_tag_rate_limits_hook (same error marker) can still
# land after this hook already reserved its own slot.
await self._release_pending_for_call_id(kwargs)
# rejection never reserves a slot, so there is nothing to pop in
# that case; a rejection from model_based_tag_rate_limits_hook (same
# error marker) can still land after this hook already reserved its
# own slot. Only this one branch's own share, not every reservation
# still pending for a non-racing batch's other, still-running
# branches -- see _release_one_pending_for_call_id.
released_entry: Final = await self._release_one_pending_for_call_id(kwargs)
if released_entry is not None:
await self._release_keys((released_entry,))
async def async_log_success_event(
self,
@ -696,14 +779,13 @@ class _PROXY_GlobalTagRateLimitsHook( # pyright: ignore[reportUnusedClass] # o
start_time: datetime | None,
end_time: datetime | None,
) -> None:
stash: Final = _stash_for_call(_call_id_from_kwargs(kwargs))
if stash is not None and stash.pending_concurrency_keys:
release_keys: Final = tuple(stash.pending_concurrency_keys)
stash.pending_concurrency_keys.clear()
release_task: Final = asyncio.create_task(self._release_keys(release_keys))
released_entry: Final = await self._release_one_pending_for_call_id(kwargs)
if released_entry is not None:
release_task: Final = asyncio.create_task(self._release_keys((released_entry,)))
_BACKGROUND_TASKS.add(release_task) # mutable-ok: see _BACKGROUND_TASKS's own docstring
release_task.add_done_callback(_BACKGROUND_TASKS.discard)
stash: Final = _stash_for_call(_call_id_from_kwargs(kwargs))
config: Final = self._refresh_config()
if config is None:
return

View file

@ -18,6 +18,7 @@ from litellm.proxy.common_utils.proxy_rate_limit_error import ProxyRateLimitErro
from litellm.proxy.hooks.global_tag_rate_limits_hook import (
_PROXY_GlobalTagRateLimitsHook,
)
from litellm.router import Router
class TimeController:
@ -60,7 +61,9 @@ def _redis_hook(time_controller: TimeController):
pytest.skip("Redis environment variables (REDIS_HOST, REDIS_PORT) not set")
redis_cache = RedisCache(host=redis_host, port=int(redis_port), password=os.getenv("REDIS_PASSWORD"))
dual_cache = DualCache(redis_cache=redis_cache)
return _PROXY_GlobalTagRateLimitsHook(internal_usage_cache=dual_cache, time_provider=time_controller.now), redis_cache
return _PROXY_GlobalTagRateLimitsHook(
internal_usage_cache=dual_cache, time_provider=time_controller.now
), redis_cache
# ---------------------------------------------------------------------------
@ -472,7 +475,9 @@ async def test_dollar_limit_respects_apply_to_models_at_accounting_time(time_con
@pytest.mark.asyncio
async def test_apply_to_models_fallback_does_not_re_narrow_accounting_to_the_serving_model(time_controller, monkeypatch):
async def test_apply_to_models_fallback_does_not_re_narrow_accounting_to_the_serving_model(
time_controller, monkeypatch
):
"""
Documented limitation, not a bug: apply_to_models is evaluated exactly
once, at admission, against the caller-requested model -- it is never
@ -673,7 +678,12 @@ async def test_a_rejected_admission_attempts_model_does_not_drive_later_accounti
"litellm_call_id": "call-1",
"metadata": {"tags": ["end_user_id:u1"]},
"model": "model-b",
"standard_logging_object": {"total_tokens": 0, "response_cost": 50.0, "model": "model-b", "model_group": "model-b"},
"standard_logging_object": {
"total_tokens": 0,
"response_cost": 50.0,
"model": "model-b",
"model_group": "model-b",
},
}
await hook.async_log_success_event(kwargs=kwargs, response_obj=None, start_time=0, end_time=0)
await asyncio.sleep(0)
@ -927,7 +937,11 @@ async def test_concurrency_reservation_released_when_a_different_hook_rejects_th
monkeypatch.setattr(
litellm,
"global_tag_rate_limits",
{"concurrency_limits": {"limits": [{"name": "conc", "tag_id": "end_user_id", "limit": 1, "period_seconds": 60}]}},
{
"concurrency_limits": {
"limits": [{"name": "conc", "tag_id": "end_user_id", "limit": 1, "period_seconds": 60}]
}
},
)
hook = _make_hook(time_controller)
@ -1097,6 +1111,303 @@ async def test_concurrent_requests_do_not_share_each_others_reservation_state(ti
assert results == [1, 1]
# ---------------------------------------------------------------------------
# Non-racing batch dispatch (Router.abatch_completion via a comma-separated
# `model`): one admission fans out into several real, independent LLM calls,
# each firing its own terminal event -- confirmed live against the real
# Router.abatch_completion (not just this hook in isolation) that every
# branch always emits exactly one success or failure event, unlike the
# racing abatch_completion_fastest_response variant, whose cancelled losers
# never emit any terminal event at all.
# ---------------------------------------------------------------------------
def _batch_data(tags: list[str], model: str, call_id: str = "call-1", fastest_response: bool = False) -> dict:
data = {**_data(tags, call_id=call_id), "model": model}
if fastest_response:
data["fastest_response"] = True
return data
@pytest.mark.asyncio
async def test_non_racing_batch_reserves_one_unit_per_model(time_controller, monkeypatch):
"""A single admission for a 3-model comma-separated batch must reserve 3
units, not 1 -- otherwise the cap is measured against the wrong number of
real concurrent LLM calls the batch actually makes."""
monkeypatch.setattr(
litellm,
"global_tag_rate_limits",
{
"concurrency_limits": {
"limits": [{"name": "conc", "tag_id": "end_user_id", "limit": 3, "period_seconds": 60}]
}
},
)
hook = _make_hook(time_controller)
await hook.async_pre_call_hook(
user_api_key_dict=_key(),
cache=DualCache(),
data=_batch_data(["end_user_id:u1"], model="model-a,model-b,model-c", call_id="call-1"),
call_type="acompletion",
)
# The batch alone already occupies every unit of the cap of 3.
with pytest.raises(ProxyRateLimitError):
await hook.async_pre_call_hook(
user_api_key_dict=_key(),
cache=DualCache(),
data=_data(["end_user_id:u1"], call_id="call-2"),
call_type="completion",
)
@pytest.mark.asyncio
async def test_non_racing_batch_releases_one_unit_per_branch(time_controller, monkeypatch):
"""Each branch's own success/failure event must release only its own
share of a non-racing batch's reservation -- releasing the whole
reservation on the first branch to finish would let a new, unrelated
request in while the batch's own remaining branches are still genuinely
in flight, exceeding the configured cap."""
monkeypatch.setattr(
litellm,
"global_tag_rate_limits",
{
"concurrency_limits": {
"limits": [{"name": "conc", "tag_id": "end_user_id", "limit": 3, "period_seconds": 60}]
}
},
)
hook = _make_hook(time_controller)
await hook.async_pre_call_hook(
user_api_key_dict=_key(),
cache=DualCache(),
data=_batch_data(["end_user_id:u1"], model="model-a,model-b,model-c", call_id="call-1"),
call_type="acompletion",
)
batch_kwargs = {"litellm_call_id": "call-1", "metadata": {"tags": ["end_user_id:u1"]}}
await hook.async_log_success_event(kwargs=batch_kwargs, response_obj=None, start_time=0, end_time=0)
await asyncio.sleep(0)
# Only 1 of 3 branches finished -- the other 2 still hold their own
# units, so a brand-new request needing all 3 remaining units of the cap
# must still be rejected.
with pytest.raises(ProxyRateLimitError):
await hook.async_pre_call_hook(
user_api_key_dict=_key(),
cache=DualCache(),
data=_batch_data(["end_user_id:u1"], model="model-x,model-y,model-z", call_id="call-2"),
call_type="acompletion",
)
# The other 2 branches finish (one success, one failure).
await hook.async_log_success_event(kwargs=batch_kwargs, response_obj=None, start_time=0, end_time=0)
await hook.async_log_failure_event(kwargs=batch_kwargs, response_obj=None, start_time=0, end_time=0)
await asyncio.sleep(0)
# Now every unit from the first batch is back -- no leak, no
# over-release along the way.
result = await hook.async_pre_call_hook(
user_api_key_dict=_key(),
cache=DualCache(),
data=_data(["end_user_id:u1"], call_id="call-3"),
call_type="completion",
)
assert result is not None
@pytest.mark.asyncio
async def test_non_racing_batch_disconnect_releases_only_what_remains(time_controller, monkeypatch):
"""A client disconnect mid-batch cancels every branch that hasn't
already finished together, so none of those cancelled branches ever
fires its own terminal event. The disconnect hook must release exactly
what's left -- not double-release a branch that already released its
own share via a real success/failure event first."""
monkeypatch.setattr(
litellm,
"global_tag_rate_limits",
{
"concurrency_limits": {
"limits": [{"name": "conc", "tag_id": "end_user_id", "limit": 3, "period_seconds": 60}]
}
},
)
hook = _make_hook(time_controller)
await hook.async_pre_call_hook(
user_api_key_dict=_key(),
cache=DualCache(),
data=_batch_data(["end_user_id:u1"], model="model-a,model-b,model-c", call_id="call-1"),
call_type="acompletion",
)
batch_kwargs = {"litellm_call_id": "call-1", "metadata": {"tags": ["end_user_id:u1"]}}
await hook.async_log_success_event(kwargs=batch_kwargs, response_obj=None, start_time=0, end_time=0)
await asyncio.sleep(0)
await hook.async_release_disconnect_state_hook({"litellm_call_id": "call-1"})
result = await hook.async_pre_call_hook(
user_api_key_dict=_key(),
cache=DualCache(),
data=_data(["end_user_id:u1"], call_id="call-2"),
call_type="completion",
)
assert result is not None
@pytest.mark.asyncio
async def test_fastest_response_batch_still_reserves_only_one_unit(time_controller, monkeypatch):
"""abatch_completion_fastest_response cancels every losing branch
without ever firing a terminal event for it, so it must keep reserving a
single unit for the whole dispatch -- reserving one per model here would
leak every losing branch's share until the safety TTL on every single
call."""
monkeypatch.setattr(
litellm,
"global_tag_rate_limits",
{
"concurrency_limits": {
"limits": [{"name": "conc", "tag_id": "end_user_id", "limit": 1, "period_seconds": 60}]
}
},
)
hook = _make_hook(time_controller)
await hook.async_pre_call_hook(
user_api_key_dict=_key(),
cache=DualCache(),
data=_batch_data(["end_user_id:u1"], model="model-a,model-b", call_id="call-1", fastest_response=True),
call_type="acompletion",
)
# If this admission had reserved 2 units instead of 1, this second,
# unrelated request (cap of 1) would already be rejected here too.
kwargs = {"litellm_call_id": "call-1", "metadata": {"tags": ["end_user_id:u1"]}}
await hook.async_log_success_event(kwargs=kwargs, response_obj=None, start_time=0, end_time=0)
await asyncio.sleep(0)
result = await hook.async_pre_call_hook(
user_api_key_dict=_key(),
cache=DualCache(),
data=_data(["end_user_id:u1"], call_id="call-2"),
call_type="completion",
)
assert result is not None
@pytest.mark.asyncio
async def test_real_abatch_completion_admits_once_and_releases_per_branch(time_controller, monkeypatch):
"""End-to-end against the real Router.abatch_completion (mocked LLM
responses, no network): confirms admission fires exactly once for the
whole dispatch -- not once per branch the way model_based_tag_rate_limits_hook's
per-Router-hop admission does -- and that a cap sized to the batch width
is never exceeded even while some branches are still genuinely running."""
monkeypatch.setattr(
litellm,
"global_tag_rate_limits",
{
"concurrency_limits": {
"limits": [{"name": "conc", "tag_id": "end_user_id", "limit": 3, "period_seconds": 60}]
}
},
)
hook = _make_hook(time_controller)
monkeypatch.setattr(litellm, "callbacks", [hook])
router = Router(
model_list=[
{
"model_name": "model-fast",
"litellm_params": {"model": "gpt-3.5-turbo", "mock_response": "fast-done", "mock_delay": 0.01},
},
{
"model_name": "model-slow-1",
"litellm_params": {"model": "gpt-3.5-turbo", "mock_response": "slow-1-done", "mock_delay": 0.3},
},
{
"model_name": "model-slow-2",
"litellm_params": {"model": "gpt-3.5-turbo", "mock_response": "slow-2-done", "mock_delay": 0.3},
},
]
)
admitted_data = await hook.async_pre_call_hook(
user_api_key_dict=_key(),
cache=DualCache(),
data=_batch_data(["end_user_id:u1"], model="model-fast,model-slow-1,model-slow-2", call_id="call-1"),
call_type="acompletion",
)
batch_task = asyncio.create_task(
router.abatch_completion(
models=["model-fast", "model-slow-1", "model-slow-2"],
messages=[{"role": "user", "content": "hello"}],
metadata=admitted_data["metadata"],
litellm_call_id=admitted_data["litellm_call_id"],
)
)
# Give only the fast branch (0.01s mock_delay) time to finish; the two
# slow branches (0.3s) are still genuinely in flight.
await asyncio.sleep(0.1)
# 1 of 3 units released, 2 still held by the still-running slow
# branches -- a brand-new request needing all 3 units of the cap must
# still be rejected.
with pytest.raises(ProxyRateLimitError):
await hook.async_pre_call_hook(
user_api_key_dict=_key(),
cache=DualCache(),
data=_batch_data(["end_user_id:u1"], model="model-x,model-y,model-z", call_id="call-2"),
call_type="acompletion",
)
responses = await batch_task
assert [r.choices[0].message.content for r in responses] == ["fast-done", "slow-1-done", "slow-2-done"]
await asyncio.sleep(0)
# Every branch has now finished and released its own share -- no leak.
result = await hook.async_pre_call_hook(
user_api_key_dict=_key(),
cache=DualCache(),
data=_data(["end_user_id:u1"], call_id="call-3"),
call_type="completion",
)
assert result is not None
@pytest.mark.asyncio
async def test_comma_separated_model_outside_acompletion_call_type_is_not_a_batch(time_controller, monkeypatch):
"""A comma in `model` only means a non-racing batch dispatch for the
exact call_type route_llm_request.py itself gates on -- any other
call_type must reserve just 1 unit, matching whatever that call_type
actually does under the hood."""
monkeypatch.setattr(
litellm,
"global_tag_rate_limits",
{
"concurrency_limits": {
"limits": [{"name": "conc", "tag_id": "end_user_id", "limit": 1, "period_seconds": 60}]
}
},
)
hook = _make_hook(time_controller)
await hook.async_pre_call_hook(
user_api_key_dict=_key(),
cache=DualCache(),
data=_batch_data(["end_user_id:u1"], model="model-a,model-b,model-c", call_id="call-1"),
call_type="completion",
)
with pytest.raises(ProxyRateLimitError):
await hook.async_pre_call_hook(
user_api_key_dict=_key(),
cache=DualCache(),
data=_data(["end_user_id:u1"], call_id="call-2"),
call_type="completion",
)
# ---------------------------------------------------------------------------
# Accounting: tokens/dollars via async_log_success_event
# ---------------------------------------------------------------------------
@ -1368,7 +1679,9 @@ async def test_a_caller_supplied_tag_cannot_shadow_the_policy_backed_identity_ta
"metadata": {"tags": ["company_id:real-company"], "inherited_tags": ["company_id:real-company"]},
}
with pytest.raises(ProxyRateLimitError):
await hook.async_pre_call_hook(user_api_key_dict=key, cache=DualCache(), data=victim_data, call_type="completion")
await hook.async_pre_call_hook(
user_api_key_dict=key, cache=DualCache(), data=victim_data, call_type="completion"
)
@pytest.mark.asyncio
@ -1380,7 +1693,9 @@ async def test_success_accounting_also_resolves_identity_from_the_policy_backed_
"global_tag_rate_limits",
{
"dollar_limits": {
"limits": [{"name": "per-company-spend", "tag_id": "company_id", "limit": 10.0, "period_seconds": 86400}]
"limits": [
{"name": "per-company-spend", "tag_id": "company_id", "limit": 10.0, "period_seconds": 86400}
]
}
},
)
@ -1440,7 +1755,10 @@ async def test_rejection_detail_does_not_disclose_the_resolved_tag_value(time_co
data = {
"litellm_call_id": "call-1",
"metadata": {"tags": ["company_id:secret-internal-name"], "inherited_tags": ["company_id:secret-internal-name"]},
"metadata": {
"tags": ["company_id:secret-internal-name"],
"inherited_tags": ["company_id:secret-internal-name"],
},
}
await hook.async_pre_call_hook(user_api_key_dict=key, cache=DualCache(), data=data, call_type="completion")
@ -1583,7 +1901,10 @@ async def test_admission_ignores_a_forged_empty_litellm_metadata_key(time_contro
poisoned = {"metadata": {"tags": ["end_user_id:u1"]}, "litellm_metadata": {}}
await hook.async_pre_call_hook(
user_api_key_dict=_key(), cache=DualCache(), data={**poisoned, "litellm_call_id": "call-1"}, call_type="completion"
user_api_key_dict=_key(),
cache=DualCache(),
data={**poisoned, "litellm_call_id": "call-1"},
call_type="completion",
)
with pytest.raises(ProxyRateLimitError):
await hook.async_pre_call_hook(