mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-12 23:01:41 +00:00
fix(rate-limiting): fix cross-attempt accounting loss, trim comment verbosity
Track every model an admission attempt saw for a call_id (admitted_models) instead of overwriting a single field, so an apply_to_models-scoped entry that matched an earlier _pre_call_with_fallbacks attempt still gets its success-time token/dollar accounting even after a later attempt admits with a different, out-of-scope model. Also trims the module docstring, stash dataclass, and several inline comments down to essential rationale per CLAUDE.md's comment policy, and corrects the docstring's claim that the Logging object doesn't exist yet at admission time (it does; the real reason for the ContextVar-based stash is that _pre_call_with_fallbacks builds a fresh one per retry).
This commit is contained in:
parent
d7629fc9c6
commit
978fc694eb
2 changed files with 203 additions and 154 deletions
|
|
@ -1,70 +1,37 @@
|
|||
"""
|
||||
Tag-scoped token, request, dollar, and concurrency rate limits declared once,
|
||||
globally, in `litellm_settings.global_tag_rate_limits` -- enforced once per
|
||||
request in `async_pre_call_hook`, before Router does any routing, so a limit
|
||||
applies regardless of which model or fallback chain the request ends up
|
||||
hitting.
|
||||
globally, in `litellm_settings.global_tag_rate_limits` and enforced in
|
||||
`async_pre_call_hook`, before Router does any routing -- so a limit applies
|
||||
regardless of which model or fallback chain the request ends up hitting.
|
||||
|
||||
This is the model-independent sibling of `model_based_tag_rate_limits_hook`,
|
||||
which enforces the same `TagRateLimitEntry` shape but nested per-deployment
|
||||
under `model_info.tag_rate_limits`, once per routing hop
|
||||
(`async_filter_deployments`). A global entry has no deployment/routing-group
|
||||
to reconcile -- there is exactly one config value, read once -- so this hook
|
||||
reuses that sibling's free, already-hardened helper functions
|
||||
(`entry_applies`, the Lua atomic check-and-increment scripts, cache
|
||||
partitioning, bucket-key hashing primitives) from `tag_rate_limits_shared.py`
|
||||
rather than duplicating them, but implements its own, much smaller
|
||||
admission/accounting engine: no `_LimitsIndex`, no routing-group or
|
||||
team-alias resolution, no per-deployment dedup signatures.
|
||||
Model-independent sibling of `model_based_tag_rate_limits_hook`, which
|
||||
enforces the same `TagRateLimitEntry` shape per-deployment instead. Reuses
|
||||
that sibling's shared helpers (`entry_applies`, the atomic Lua scripts, cache
|
||||
partitioning, bucket-key hashing) from `tag_rate_limits_shared.py`, but has
|
||||
its own smaller admission/accounting engine with no routing-group or
|
||||
per-deployment concerns.
|
||||
|
||||
Three independent entry-level knobs decide who a global entry applies to and
|
||||
how its bucket is shared:
|
||||
Three entry-level knobs: `apply_to_key_alias` scopes an entry to specific
|
||||
virtual-key aliases; `apply_to_models` scopes it to specific caller-facing
|
||||
model names, letting one entry cap a whole fallback chain as a unit (a
|
||||
rejection then carries `detail["cross_model_scope"] = True` so
|
||||
`_pre_call_with_fallbacks` re-raises instead of silently admitting the
|
||||
request through an unlisted fallback model); `scope_by_key_hash` controls
|
||||
whether matching keys share one bucket or each gets its own.
|
||||
|
||||
- `apply_to_key_alias`: unset means every request, any key, any model. Set
|
||||
to a list of virtual-key aliases, only those keys' requests count.
|
||||
- `apply_to_models`: unset means every model. Set to a list of model names,
|
||||
only requests whose caller-facing `model` field is in that list count --
|
||||
letting one entry rate-limit a whole fallback chain as a single unit by
|
||||
naming every model in the chain. Each check is a fresh, independent
|
||||
evaluation of `_entry_applies` against whatever `model` is current at that
|
||||
moment, not a one-time decision that then sticks for the rest of the
|
||||
request. Two concrete consequences follow from that:
|
||||
(1) if the request's own model fails mid-flight and Router internally
|
||||
retries a different model for the *same* admitted call, that retry is
|
||||
never re-checked -- the original admission (against the originally
|
||||
requested model) already stands, so an operator who needs the limit to
|
||||
track whichever model actually ends up serving a request needs
|
||||
`model_info.tag_rate_limits` instead; but
|
||||
(2) if this hook's own admission *rejects* the request,
|
||||
`common_request_processing.py` would otherwise catch that rejection and
|
||||
retry the whole pre-call pipeline against
|
||||
`litellm_settings.fallbacks`/`router_settings.fallbacks`, with
|
||||
`data["model"]` mutated to the fallback target -- silently admitting the
|
||||
request via a model outside `apply_to_models`, defeating the cap. A
|
||||
rejection from an `apply_to_models`-scoped entry carries
|
||||
`detail["cross_model_scope"] = True` for exactly this reason:
|
||||
`_pre_call_with_fallbacks` checks that marker and re-raises immediately
|
||||
instead of trying any fallback, so this bypass is closed regardless of
|
||||
whether the fallback chain is also listed in `apply_to_models`.
|
||||
- `scope_by_key_hash` (already exists on `TagRateLimitEntry`): whether the
|
||||
keys an entry applies to share one bucket, or each gets its own.
|
||||
|
||||
`async_pre_call_hook` runs before Router constructs `Logging`/`litellm_logging_obj`
|
||||
for this request (see `common_request_processing.py`: `pre_call_hook` fires
|
||||
well before `base_process_llm_request` builds the logging object), so unlike
|
||||
`model_based_tag_rate_limits_hook` this hook cannot stash pending concurrency
|
||||
reservations on `data["litellm_logging_obj"].model_call_details` -- that
|
||||
object doesn't exist yet. Per-request state is instead kept on a
|
||||
`ContextVar`-based stash, the same established pattern
|
||||
`parallel_request_limiter_v3.py`'s v3 handler already uses for exactly this
|
||||
problem, with one difference: the stash here is a dict keyed by
|
||||
`litellm_call_id` rather than one shared mutable instance with an
|
||||
overwritable "owner" field, so a nested LiteLLM call made inside the request
|
||||
(e.g. a guardrail's own LLM judge call) -- which mints its own fresh call id
|
||||
but inherits the same ContextVar-held ancestor context, not a separate one
|
||||
-- gets its own isolated entry instead of overwriting the outer call's and
|
||||
having its own success callback release the outer call's still-pending
|
||||
reservations early.
|
||||
`data["litellm_logging_obj"]` does exist by the time `async_pre_call_hook`
|
||||
runs, but `_pre_call_with_fallbacks` re-runs the whole pre-call pipeline
|
||||
(building a fresh `Logging` object each time) once per fallback model on the
|
||||
same `litellm_call_id`, so unlike `model_based_tag_rate_limits_hook`'s
|
||||
per-Router-hop reservations, a stash keyed to one attempt's own
|
||||
`model_call_details` wouldn't survive to a later attempt. Per-request state
|
||||
instead lives on a `ContextVar`-based stash (the same pattern
|
||||
`parallel_request_limiter_v3.py` uses for the identical admission-to-release
|
||||
problem), keyed by `litellm_call_id` so a nested LiteLLM call sharing the
|
||||
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.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
|
@ -84,7 +51,7 @@ from litellm.litellm_core_utils.core_helpers import get_metadata_variable_name_f
|
|||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
from litellm.proxy.common_utils.proxy_rate_limit_error import ProxyRateLimitError
|
||||
from litellm.proxy.hooks.parallel_request_limiter_v3 import (
|
||||
_PROXY_MaxParallelRequestsHandler_v3, # pyright: ignore[reportPrivateUsage] # reused across module boundaries, matching model_based_tag_rate_limits_hook's identical import
|
||||
_PROXY_MaxParallelRequestsHandler_v3, # pyright: ignore[reportPrivateUsage] # shared private helper, reused by model_based_tag_rate_limits_hook too
|
||||
)
|
||||
from litellm.proxy.hooks.tag_rate_limits_shared import (
|
||||
ATOMIC_UNITS as _ATOMIC_UNITS,
|
||||
|
|
@ -146,7 +113,7 @@ from litellm.proxy.hooks.tag_rate_limits_shared import (
|
|||
)
|
||||
from litellm.proxy.utils import InternalUsageCache
|
||||
from litellm.router_strategy.tag_based_routing import (
|
||||
_get_tags_from_request_kwargs, # pyright: ignore[reportPrivateUsage] # reused across module boundaries, matching model_based_tag_rate_limits_hook's identical import
|
||||
_get_tags_from_request_kwargs, # pyright: ignore[reportPrivateUsage] # shared private helper, reused by model_based_tag_rate_limits_hook too
|
||||
)
|
||||
from litellm.types.caching import RedisPipelineIncrementOperation
|
||||
from litellm.types.router import TagRateLimitEntry, TagRateLimits
|
||||
|
|
@ -160,16 +127,23 @@ else:
|
|||
Span: TypeAlias = object
|
||||
|
||||
|
||||
def _entry_applies_any_admitted_model(
|
||||
entry: TagRateLimitEntry, tags: Sequence[str], key_alias: str | None, admitted_models: frozenset[str]
|
||||
) -> bool:
|
||||
"""Same as `_entry_applies`, except an `apply_to_models`-scoped entry
|
||||
counts as applying if ANY model an admission attempt for this call_id
|
||||
saw was in scope -- not just whichever model the call ultimately served.
|
||||
A `_pre_call_with_fallbacks` retry re-admits with a different model for
|
||||
the same call_id, and an entry that matched an earlier attempt must
|
||||
still get its success-time accounting."""
|
||||
if not admitted_models:
|
||||
return _entry_applies(entry, tags, key_alias, None)
|
||||
return any(_entry_applies(entry, tags, key_alias, model) for model in admitted_models)
|
||||
|
||||
|
||||
def _hash_tag(entry: TagRateLimitEntry, unit: _LimitUnit, tag_value: str, key_hash: str | None) -> str:
|
||||
"""
|
||||
Global-hook equivalent of `model_based_tag_rate_limits_hook._hash_tag`,
|
||||
without a `model_group`/deployment-scope/team-scope dimension -- a global
|
||||
entry has none of those. Namespaced under `tag_rl:global:` so it can never
|
||||
collide with that sibling hook's own `tag_rl:{model_group}:...` keys even
|
||||
if an operator names a deployment "global": every key also differs by
|
||||
`unit`/`name`/`tag_id`/`_policy_fingerprint`, and the two hooks' entries
|
||||
are never meant to share a bucket in the first place.
|
||||
"""
|
||||
"""Namespaced under `tag_rl:global:` so it never collides with
|
||||
`model_based_tag_rate_limits_hook`'s own `tag_rl:{model_group}:...` keys."""
|
||||
key_suffix: Final = f":key:{key_hash}" if key_hash is not None else ""
|
||||
policy_suffix: Final = f":policy:{_policy_fingerprint(entry)}"
|
||||
return f"tag_rl:global:{unit}:{entry.name}:{entry.tag_id}:{_fixed_length_identity(tag_value)}{key_suffix}{policy_suffix}"
|
||||
|
|
@ -203,45 +177,30 @@ class _CachePartition:
|
|||
@dataclass(slots=True)
|
||||
class _GlobalTagRateLimitStash:
|
||||
"""Per-call bookkeeping `async_pre_call_hook` hands to that same call's
|
||||
success/failure/disconnect callbacks -- see module docstring for why
|
||||
this lives on a `ContextVar`, not `model_call_details`.
|
||||
success/failure/disconnect callbacks -- see module docstring for why this
|
||||
lives on a `ContextVar`, not `model_call_details`.
|
||||
|
||||
Keyed by `litellm_call_id` in the dict below rather than one shared
|
||||
mutable instance with an overwritable "owner" field: a nested LiteLLM
|
||||
call made inside the request (an LLM-judge guardrail, a silent
|
||||
experiment) that mints its own fresh call id runs inside the *same*
|
||||
inherited context, not a separate one, so a single shared instance's
|
||||
owner field would get reassigned to the nested call and its own
|
||||
success callback would then release the outer call's still-pending
|
||||
reservations early -- letting extra same-tag requests through while
|
||||
the outer request is still genuinely in flight. Keying by call id
|
||||
isolates each call's own reservations regardless of nesting.
|
||||
Keyed by `litellm_call_id` rather than one shared mutable instance so a
|
||||
nested LiteLLM call (an LLM-judge guardrail) that mints its own call id
|
||||
but inherits the same context doesn't release the outer call's
|
||||
still-pending reservation early.
|
||||
"""
|
||||
|
||||
admission_time: float | None = None
|
||||
# The caller-facing `model` admission read from `data.get("model")`, so
|
||||
# async_log_success_event's tokens/dollars accounting gates
|
||||
# apply_to_models against the same, originally-requested model admission
|
||||
# decided on -- not whatever model a later fallback actually served.
|
||||
model: str | None = None
|
||||
# Every model any admission attempt for this call_id has classified
|
||||
# entries against, accumulated rather than overwritten: a
|
||||
# _pre_call_with_fallbacks retry re-runs admission with a *different*
|
||||
# model for the same call_id, and an apply_to_models entry that matched
|
||||
# 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)
|
||||
pending_concurrency_keys: list[tuple[str, _PartitionKey]] = field(default_factory=list) # mutable-ok: queue
|
||||
# "requests" keys already charged for this call_id -- veria-ai finding:
|
||||
# ProxyBaseLLMRequestProcessing._pre_call_with_fallbacks reruns the whole
|
||||
# pre-call pipeline (this hook included) once per fallback model on ANY
|
||||
# ProxyRateLimitError, not only one this hook itself raised, but reuses
|
||||
# the same litellm_call_id (self.data is mutated in place, only `model`
|
||||
# changes) across every attempt -- so this stash is the SAME object each
|
||||
# time. A "requests" check matching an already-charged key here renews
|
||||
# at zero net cost instead of charging a second unit for the same
|
||||
# logical request; see async_pre_call_hook's own comment for how.
|
||||
# Keys already charged for this call_id, so a fallback retry (same
|
||||
# litellm_call_id, different model) renews instead of double-charging.
|
||||
charged_request_keys: list[str] = field(default_factory=list) # mutable-ok: see comment above
|
||||
# The server-authenticated key_hash (UserAPIKeyAuth.api_key) of whichever
|
||||
# call first claimed this stash. litellm_call_id is caller-controlled via
|
||||
# the x-litellm-call-id header (the exact forgery vector
|
||||
# model_based_tag_rate_limits_hook's own pending-reservations mirror was
|
||||
# hardened against earlier), so two unrelated requests sharing a
|
||||
# caller-chosen id must not be allowed to "renew" each other's charge --
|
||||
# only a later admission carrying this same, authenticated key_hash may.
|
||||
# key_hash of whoever first claimed this stash. litellm_call_id is
|
||||
# caller-controlled (x-litellm-call-id), so only a later admission with
|
||||
# the same authenticated key_hash may renew this stash's charges.
|
||||
owner_key_hash: str | None = None
|
||||
|
||||
|
||||
|
|
@ -392,12 +351,8 @@ class _PROXY_GlobalTagRateLimitsHook( # pyright: ignore[reportUnusedClass] # o
|
|||
self,
|
||||
checks: Sequence[tuple[InternalUsageCache, str, float, float, int]],
|
||||
) -> tuple[int | None, tuple[float, ...]]:
|
||||
"""All-or-nothing atomic admission across `checks` -- see
|
||||
`model_based_tag_rate_limits_hook._PROXY_ModelBasedTagRateLimitsHook._atomic_check_and_increment`'s
|
||||
own docstring for the full rationale (refund-on-rollback, why a
|
||||
raising key's own outcome is never refunded); identical logic,
|
||||
duplicated rather than shared since it lives as instance methods
|
||||
rather than free functions."""
|
||||
"""All-or-nothing atomic admission across `checks`: on a rejection,
|
||||
refunds every check admitted earlier in this batch."""
|
||||
if not checks:
|
||||
return None, ()
|
||||
admitted_values: Final = [] # mutable-ok: sequential async accumulator, discardable on early rejection
|
||||
|
|
@ -563,12 +518,10 @@ class _PROXY_GlobalTagRateLimitsHook( # pyright: ignore[reportUnusedClass] # o
|
|||
if config is None:
|
||||
return data
|
||||
|
||||
# async_pre_call_hook fires once per request in the common case, but
|
||||
# ProxyBaseLLMRequestProcessing._pre_call_with_fallbacks can re-run
|
||||
# this same pipeline once per fallback model on any ProxyRateLimitError
|
||||
# (not only one this hook raised) -- see charged_request_keys' own
|
||||
# docstring for how a repeat run for the same call_id renews rather
|
||||
# than re-charges both "requests" and "concurrency" checks below.
|
||||
# _pre_call_with_fallbacks can re-run this pipeline once per fallback
|
||||
# model on any ProxyRateLimitError, reusing the same litellm_call_id --
|
||||
# see charged_request_keys for how a repeat run renews instead of
|
||||
# re-charging.
|
||||
stash: Final = _claim_stash_for_data(data)
|
||||
|
||||
metadata_variable_name: Final = get_metadata_variable_name_from_kwargs(data)
|
||||
|
|
@ -577,17 +530,16 @@ class _PROXY_GlobalTagRateLimitsHook( # pyright: ignore[reportUnusedClass] # o
|
|||
key_hash: Final = user_api_key_dict.api_key
|
||||
model: Final = data.get("model") if isinstance(data.get("model"), str) else None
|
||||
|
||||
# Only a repeat admission carrying the SAME authenticated key_hash as
|
||||
# whichever call first claimed this stash may renew its charges --
|
||||
# see owner_key_hash's own docstring for why a bare call_id match is
|
||||
# not enough. First admission for this stash claims ownership here.
|
||||
# First admission for this stash claims ownership; only a later one
|
||||
# with the same key_hash may renew its charges (see owner_key_hash).
|
||||
if stash.owner_key_hash is None:
|
||||
stash.owner_key_hash = key_hash
|
||||
renewal_allowed: Final = stash.owner_key_hash == key_hash
|
||||
|
||||
now: Final = self._time_provider().timestamp()
|
||||
stash.admission_time = now
|
||||
stash.model = model
|
||||
if renewal_allowed and model is not None:
|
||||
stash.admitted_models = stash.admitted_models | frozenset((model,))
|
||||
classified: Final = self._classify(config, tags, key_alias, key_hash, now, model)
|
||||
if not classified:
|
||||
return data
|
||||
|
|
@ -615,15 +567,8 @@ class _PROXY_GlobalTagRateLimitsHook( # pyright: ignore[reportUnusedClass] # o
|
|||
check.key,
|
||||
check.entry.limit,
|
||||
# A key already charged/reserved for this call_id (an
|
||||
# earlier _pre_call_with_fallbacks attempt for the
|
||||
# same logical request) renews at zero net cost
|
||||
# instead of charging or reserving a second unit --
|
||||
# folded into this same all-or-nothing batch so a
|
||||
# rollback here (some other check in the batch
|
||||
# rejecting) refunds that zero-cost renewal as a
|
||||
# genuine no-op, same reasoning as
|
||||
# model_based_tag_rate_limits_hook's identical fix
|
||||
# for its own per-hop retries.
|
||||
# earlier fallback attempt for the same request) renews
|
||||
# at zero net cost instead of charging a second unit.
|
||||
0.0
|
||||
if renewal_allowed
|
||||
and (
|
||||
|
|
@ -642,11 +587,9 @@ class _PROXY_GlobalTagRateLimitsHook( # pyright: ignore[reportUnusedClass] # o
|
|||
failing_check.unit, failing_check.entry, failing_check.tag_value, model, current=values[0]
|
||||
)
|
||||
|
||||
# Only genuinely new reservations, never a key already in
|
||||
# already_reserved_concurrency_keys: that key's own check just
|
||||
# renewed at zero net cost above, so re-adding it here would
|
||||
# make release (which decrements once per queued entry) decrement
|
||||
# twice for a counter that was only ever incremented once.
|
||||
# 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.
|
||||
concurrency_reservations: Final = tuple(
|
||||
(check.key, _partition_key(check.entry))
|
||||
for check in atomic_checks
|
||||
|
|
@ -655,12 +598,9 @@ class _PROXY_GlobalTagRateLimitsHook( # pyright: ignore[reportUnusedClass] # o
|
|||
if concurrency_reservations:
|
||||
stash.pending_concurrency_keys.extend(concurrency_reservations) # mutable-ok: see field's own docstring
|
||||
|
||||
# Only recorded when renewal_allowed: an admission that didn't
|
||||
# own this stash (a call_id collision from a different key_hash)
|
||||
# must not contaminate the rightful owner's own renewal
|
||||
# tracking, or a later, genuine fallback retry from the owner
|
||||
# could wrongly treat the impostor's charge as its own and
|
||||
# renew for free.
|
||||
# Only recorded when renewal_allowed, so a call_id collision from
|
||||
# a different key_hash can't contaminate the rightful owner's
|
||||
# renewal tracking.
|
||||
request_keys: Final = (
|
||||
tuple(
|
||||
check.key
|
||||
|
|
@ -684,15 +624,11 @@ class _PROXY_GlobalTagRateLimitsHook( # pyright: ignore[reportUnusedClass] # o
|
|||
await self._release_keys(release_keys)
|
||||
|
||||
async def async_log_failure_event(self, kwargs, response_obj, start_time, end_time) -> None:
|
||||
# No special-case skip for this hook's own tag_rate_limit_exceeded
|
||||
# rejection: that rejection never reaches the point where a
|
||||
# concurrency reservation is queued (see async_pre_call_hook), so
|
||||
# stash.pending_concurrency_keys is already empty in that case and
|
||||
# the check below naturally no-ops. Skipping release based on the
|
||||
# exception's error marker alone would be wrong here, since
|
||||
# model_based_tag_rate_limits_hook raises the identical marker --
|
||||
# that rejection can land after this hook already reserved a slot
|
||||
# for this same request, and that slot must still be released.
|
||||
# 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.
|
||||
stash: Final = _stash_for_call(_call_id_from_kwargs(kwargs))
|
||||
if stash is None or not stash.pending_concurrency_keys:
|
||||
return
|
||||
|
|
@ -735,7 +671,7 @@ class _PROXY_GlobalTagRateLimitsHook( # pyright: ignore[reportUnusedClass] # o
|
|||
if stash is not None and stash.admission_time is not None
|
||||
else self._time_provider().timestamp()
|
||||
)
|
||||
model: Final = stash.model if stash is not None else None
|
||||
admitted_models: Final = stash.admitted_models if stash is not None else frozenset()
|
||||
increment_by_unit: Final[Mapping[_LimitUnit, float]] = MappingProxyType(
|
||||
{
|
||||
"tokens": float(standard_logging_object.get("total_tokens") or 0),
|
||||
|
|
@ -752,7 +688,7 @@ class _PROXY_GlobalTagRateLimitsHook( # pyright: ignore[reportUnusedClass] # o
|
|||
tag_value = _extract_identity(tags, entry.tag_id)
|
||||
if tag_value is None:
|
||||
continue
|
||||
if not _entry_applies(entry, tags, key_alias, model):
|
||||
if not _entry_applies_any_admitted_model(entry, tags, key_alias, admitted_models):
|
||||
continue
|
||||
increment_value = increment_by_unit[unit]
|
||||
if increment_value == 0:
|
||||
|
|
|
|||
|
|
@ -517,6 +517,79 @@ async def test_apply_to_models_fallback_does_not_re_narrow_accounting_to_the_ser
|
|||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_to_models_accounts_when_a_fallback_retry_re_admits_with_a_different_model(
|
||||
time_controller, monkeypatch
|
||||
):
|
||||
"""
|
||||
Unlike the test above (one admission, a later Router-level fallback
|
||||
reported only at success time), this simulates
|
||||
_pre_call_with_fallbacks itself re-running async_pre_call_hook for the
|
||||
SAME call_id with a DIFFERENT model after some other hook rejected the
|
||||
original one. The first admission (opus-chain, in apply_to_models scope)
|
||||
must still get its success-time accounting even though the second
|
||||
admission (sonnet-chain, out of scope) is the one that actually proceeds
|
||||
-- overwriting a single "last admitted model" field would silently drop
|
||||
the spend for the in-scope entry.
|
||||
"""
|
||||
monkeypatch.setattr(
|
||||
litellm,
|
||||
"global_tag_rate_limits",
|
||||
{
|
||||
"dollar_limits": {
|
||||
"limits": [
|
||||
{
|
||||
"name": "chain_spend",
|
||||
"tag_id": "end_user_id",
|
||||
"limit": 10.0,
|
||||
"period_seconds": 86400,
|
||||
"apply_to_models": ["opus-chain"],
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
)
|
||||
hook = _make_hook(time_controller)
|
||||
|
||||
await hook.async_pre_call_hook(
|
||||
user_api_key_dict=_key(),
|
||||
cache=DualCache(),
|
||||
data={**_data(["end_user_id:u1"], call_id="call-1"), "model": "opus-chain"},
|
||||
call_type="completion",
|
||||
)
|
||||
# _pre_call_with_fallbacks retry: same call_id, fallback model outside apply_to_models.
|
||||
await hook.async_pre_call_hook(
|
||||
user_api_key_dict=_key(),
|
||||
cache=DualCache(),
|
||||
data={**_data(["end_user_id:u1"], call_id="call-1"), "model": "sonnet-chain"},
|
||||
call_type="completion",
|
||||
)
|
||||
|
||||
kwargs = {
|
||||
"litellm_call_id": "call-1",
|
||||
"metadata": {"tags": ["end_user_id:u1"]},
|
||||
"model": "sonnet-chain",
|
||||
"standard_logging_object": {
|
||||
"total_tokens": 0,
|
||||
"response_cost": 12.0,
|
||||
"model": "sonnet-chain",
|
||||
"model_group": "sonnet-chain",
|
||||
},
|
||||
}
|
||||
await hook.async_log_success_event(kwargs=kwargs, response_obj=None, start_time=0, end_time=0)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
# The $12 spend landed in the opus-chain-scoped bucket, so a fresh
|
||||
# opus-chain request is now over the $10 limit and rejected.
|
||||
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"), "model": "opus-chain"},
|
||||
call_type="completion",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _pre_call_with_fallbacks reruns admission for the same logical request:
|
||||
# a repeat call_id must renew, not double-charge -- veria-ai finding on
|
||||
|
|
@ -798,6 +871,46 @@ async def test_concurrency_reservation_released_on_disconnect(time_controller, m
|
|||
assert result is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrency_reservation_released_when_disconnect_runs_in_a_forked_task(time_controller, monkeypatch):
|
||||
"""
|
||||
common_request_processing.py's real disconnect path runs the release call
|
||||
inside a task forked via asyncio.create_task AFTER admission already ran
|
||||
in the parent task (_await_llm_call_cancelling_on_disconnect's `monitor`
|
||||
task, the streaming generator's own task), not in the same coroutine as
|
||||
admission the way the test above calls it. A ContextVar-based stash that
|
||||
only happens to work when both calls share one coroutine has caused a
|
||||
real leak in this codebase before, so this exercises the actual
|
||||
parent-then-forked-child topology instead.
|
||||
"""
|
||||
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)
|
||||
|
||||
data = _data(["end_user_id:u1"], call_id="call-1")
|
||||
await hook.async_pre_call_hook(user_api_key_dict=_key(), cache=DualCache(), data=data, call_type="completion")
|
||||
|
||||
async def release_in_child_task() -> None:
|
||||
await hook.async_release_disconnect_state_hook({"litellm_call_id": "call-1"})
|
||||
|
||||
await asyncio.create_task(release_in_child_task())
|
||||
|
||||
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_concurrent_requests_do_not_share_each_others_reservation_state(time_controller, monkeypatch):
|
||||
"""Two logically distinct requests running as separate asyncio Tasks must
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue