mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-19 00:01:29 +00:00
4861 lines
213 KiB
Python
4861 lines
213 KiB
Python
"""
|
|
This is a rate limiter implementation based on a similar one by Envoy proxy.
|
|
|
|
This is currently in development and not yet ready for production.
|
|
"""
|
|
|
|
import asyncio
|
|
import binascii
|
|
import os
|
|
import uuid
|
|
from collections.abc import Awaitable, Callable, Mapping, Sequence, Set
|
|
from contextvars import ContextVar
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime
|
|
from typing import (
|
|
TYPE_CHECKING,
|
|
Any,
|
|
Final,
|
|
Literal,
|
|
Protocol,
|
|
TypeAlias,
|
|
TypedDict,
|
|
)
|
|
|
|
from typing_extensions import NotRequired, ReadOnly
|
|
|
|
from litellm import DualCache
|
|
from litellm._logging import verbose_proxy_logger
|
|
from litellm.constants import DYNAMIC_RATE_LIMIT_ERROR_THRESHOLD_PER_MINUTE, INTERNAL_CALL_ORIGIN_METADATA_KEY
|
|
from litellm.integrations.custom_logger import CustomLogger
|
|
from litellm.litellm_core_utils.prompt_templates.common_utils import (
|
|
get_str_from_messages,
|
|
)
|
|
from litellm.proxy._types import UserAPIKeyAuth
|
|
from litellm.proxy.auth.auth_utils import (
|
|
ESTIMATED_OUTPUT_TOKENS_FIELD,
|
|
get_estimated_output_tokens,
|
|
get_key_tag_rpm_limit,
|
|
get_model_rate_limit_from_metadata,
|
|
)
|
|
from litellm.proxy.auth.budget_throttle import throttled_limit
|
|
from litellm.proxy.common_utils.http_parsing_utils import get_tags_from_request_body
|
|
from litellm.proxy.common_utils.proxy_rate_limit_error import (
|
|
ProxyRateLimitError,
|
|
map_v3_rate_limit_type,
|
|
)
|
|
from litellm.proxy.hooks.batch_enqueued_tokens import (
|
|
BATCH_ENQUEUED_REFUND_STATUSES,
|
|
BatchEnqueuedTokenReservation,
|
|
BatchEnqueuedTokenStore,
|
|
batch_response_view,
|
|
canonical_provider_batch_id,
|
|
)
|
|
from litellm.proxy.hooks.rate_limiter_utils import resolve_llm_provider_for_rate_limit
|
|
from litellm.types.caching import RedisPipelineIncrementOperation
|
|
from litellm.types.llms.openai import BaseLiteLLMOpenAIResponseObject, ResponseAPIUsage
|
|
from litellm.types.utils import (
|
|
CallTypes,
|
|
EmbeddingResponse,
|
|
ModelResponse,
|
|
RerankResponse,
|
|
TextCompletionResponse,
|
|
Usage,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from opentelemetry.trace import Span as _Span
|
|
|
|
from litellm.proxy.utils import InternalUsageCache as _InternalUsageCache
|
|
from litellm.types.agents import AgentResponse
|
|
from litellm.types.caching import RedisPipelineIncrementOperation
|
|
|
|
Span = _Span | Any
|
|
InternalUsageCache = _InternalUsageCache
|
|
else:
|
|
Span = Any
|
|
InternalUsageCache = Any
|
|
|
|
|
|
BATCH_RATE_LIMITER_SCRIPT: Final = """
|
|
local results = {}
|
|
local now = tonumber(ARGV[1])
|
|
local window_size = tonumber(ARGV[2])
|
|
|
|
-- Process each window/counter pair
|
|
for i = 1, #KEYS, 2 do
|
|
local window_key = KEYS[i]
|
|
local counter_key = KEYS[i + 1]
|
|
local increment_value = 1
|
|
|
|
-- Check if window exists and is valid
|
|
local window_start = redis.call('GET', window_key)
|
|
if not window_start or (now - tonumber(window_start)) >= window_size then
|
|
-- Reset window and counter
|
|
redis.call('SET', window_key, tostring(now))
|
|
redis.call('SET', counter_key, increment_value)
|
|
redis.call('EXPIRE', window_key, window_size)
|
|
redis.call('EXPIRE', counter_key, window_size)
|
|
table.insert(results, tostring(now)) -- window_start
|
|
table.insert(results, increment_value) -- counter
|
|
else
|
|
local counter = redis.call('INCR', counter_key)
|
|
-- This happens when window_key exists but counter_key doesn't (e.g., tokens key
|
|
-- created after requests key when both share the same window_key)
|
|
local current_ttl = redis.call('TTL', counter_key)
|
|
if current_ttl == -1 then
|
|
redis.call('EXPIRE', counter_key, window_size)
|
|
end
|
|
table.insert(results, window_start) -- window_start
|
|
table.insert(results, counter) -- counter
|
|
end
|
|
end
|
|
|
|
return results
|
|
"""
|
|
|
|
CHECK_AND_INCREMENT_BY_N_SCRIPT: Final = """
|
|
-- Atomic check-and-increment-by-N across one or more descriptors.
|
|
-- All-or-nothing: if any descriptor would exceed its limit, no counter is
|
|
-- modified.
|
|
--
|
|
-- Uses Redis server time (`redis.call('TIME')`) instead of a client-supplied
|
|
-- timestamp so that window resets are deterministic across replicas with
|
|
-- skewed wall-clocks. This prevents a clock-skew-induced reopening of the
|
|
-- TOCTOU window across multi-replica deployments.
|
|
--
|
|
-- KEYS layout: pairs of (window_key, counter_key), one pair per descriptor.
|
|
-- ARGV layout: per-descriptor 4-tuple, starting at ARGV[1]:
|
|
-- ARGV[(i-1)*4 + 1] = limit
|
|
-- ARGV[(i-1)*4 + 2] = increment
|
|
-- ARGV[(i-1)*4 + 3] = ttl_seconds (counter TTL when window resets)
|
|
-- ARGV[(i-1)*4 + 4] = window_size_seconds (sliding-window length)
|
|
--
|
|
-- Return on success:
|
|
-- { 0, new_counter_1, window_start_1, new_counter_2, window_start_2, ... }
|
|
-- Return on over-limit: { 1, descriptor_index, current_counter, limit }
|
|
local time_reply = redis.call('TIME')
|
|
local now = tonumber(time_reply[1])
|
|
local descriptor_count = #KEYS / 2
|
|
|
|
-- Pass 1: read state, validate. Abort without writing if any over limit.
|
|
local descriptor_state = {}
|
|
for i = 1, descriptor_count do
|
|
local window_key = KEYS[(i - 1) * 2 + 1]
|
|
local counter_key = KEYS[(i - 1) * 2 + 2]
|
|
local arg_base = (i - 1) * 4 + 1
|
|
local limit = tonumber(ARGV[arg_base])
|
|
local increment = tonumber(ARGV[arg_base + 1])
|
|
local window_size = tonumber(ARGV[arg_base + 3])
|
|
|
|
local window_start = redis.call('GET', window_key)
|
|
local window_expired = (not window_start) or
|
|
((now - tonumber(window_start)) >= window_size)
|
|
|
|
local current_counter
|
|
if window_expired then
|
|
current_counter = 0
|
|
else
|
|
current_counter = tonumber(redis.call('GET', counter_key) or 0)
|
|
end
|
|
|
|
local blocked
|
|
if increment > 0 then
|
|
blocked = current_counter + increment > limit
|
|
else
|
|
blocked = current_counter >= limit
|
|
end
|
|
if blocked then
|
|
return { 1, i, current_counter, limit }
|
|
end
|
|
|
|
descriptor_state[i] = { window_expired, current_counter, window_start }
|
|
end
|
|
|
|
-- Pass 2: all checks passed. Apply increments.
|
|
local results = { 0 }
|
|
for i = 1, descriptor_count do
|
|
local window_key = KEYS[(i - 1) * 2 + 1]
|
|
local counter_key = KEYS[(i - 1) * 2 + 2]
|
|
local arg_base = (i - 1) * 4 + 1
|
|
local increment = tonumber(ARGV[arg_base + 1])
|
|
local ttl = tonumber(ARGV[arg_base + 2])
|
|
local window_size = tonumber(ARGV[arg_base + 3])
|
|
|
|
local window_expired = descriptor_state[i][1]
|
|
local active_window_start
|
|
|
|
if window_expired then
|
|
active_window_start = now
|
|
redis.call('SET', window_key, tostring(now))
|
|
redis.call('SET', counter_key, increment)
|
|
redis.call('EXPIRE', window_key, window_size)
|
|
if ttl > 0 then
|
|
redis.call('EXPIRE', counter_key, ttl)
|
|
end
|
|
table.insert(results, increment)
|
|
else
|
|
active_window_start = tonumber(descriptor_state[i][3])
|
|
local new_counter = redis.call('INCRBY', counter_key, increment)
|
|
local current_ttl = redis.call('TTL', counter_key)
|
|
if current_ttl == -1 and ttl > 0 then
|
|
redis.call('EXPIRE', counter_key, ttl)
|
|
end
|
|
table.insert(results, new_counter)
|
|
end
|
|
table.insert(results, active_window_start)
|
|
end
|
|
|
|
return results
|
|
"""
|
|
|
|
WINDOW_GUARDED_TOKEN_INCREMENT_SCRIPT: Final = """
|
|
local results = {}
|
|
for i = 1, #KEYS, 2 do
|
|
local window_key = KEYS[i]
|
|
local counter_key = KEYS[i + 1]
|
|
local arg_base = ((i - 1) / 2) * 3 + 1
|
|
local expected_window_start = ARGV[arg_base]
|
|
local increment = tonumber(ARGV[arg_base + 1])
|
|
local ttl = tonumber(ARGV[arg_base + 2])
|
|
local active_window_start = redis.call('GET', window_key)
|
|
|
|
if active_window_start and active_window_start == expected_window_start then
|
|
local new_counter = redis.call('INCRBY', counter_key, increment)
|
|
local current_ttl = redis.call('TTL', counter_key)
|
|
if current_ttl == -1 and ttl > 0 then
|
|
redis.call('EXPIRE', counter_key, ttl)
|
|
end
|
|
table.insert(results, 1)
|
|
table.insert(results, new_counter)
|
|
else
|
|
table.insert(results, 0)
|
|
table.insert(results, tonumber(redis.call('GET', counter_key) or 0))
|
|
end
|
|
end
|
|
return results
|
|
"""
|
|
|
|
PARALLEL_ACQUIRE_SCRIPT: Final = """
|
|
-- Atomic check-and-acquire for the max_parallel_requests concurrency gauge.
|
|
-- Each gauge key is a sorted set of per-request slot ids scored by acquire
|
|
-- time (Redis server clock). In-flight requests are counted by ZCARD after
|
|
-- pruning slots older than the slot TTL, so unlike the windowed RPM/TPM
|
|
-- counters the gauge is never reset while requests are in flight, a
|
|
-- rejected request never occupies a slot, and a slot leaked by a crashed
|
|
-- worker self-heals after the slot TTL even under continuous traffic.
|
|
--
|
|
-- KEYS: one gauge zset key per descriptor.
|
|
-- ARGV: per-key triples (limit, slot_ttl_seconds, slot_id).
|
|
-- Success: { 0, in_flight_1, ... }. Over-limit: { 1, key_index, in_flight, limit }.
|
|
local time_reply = redis.call('TIME')
|
|
local now = tonumber(time_reply[1])
|
|
for i = 1, #KEYS do
|
|
local limit = tonumber(ARGV[(i - 1) * 3 + 1])
|
|
local slot_ttl = tonumber(ARGV[(i - 1) * 3 + 2])
|
|
redis.call('ZREMRANGEBYSCORE', KEYS[i], '-inf', now - slot_ttl)
|
|
local in_flight = redis.call('ZCARD', KEYS[i])
|
|
if in_flight + 1 > limit then
|
|
return { 1, i, in_flight, limit }
|
|
end
|
|
end
|
|
local results = { 0 }
|
|
for i = 1, #KEYS do
|
|
local slot_ttl = tonumber(ARGV[(i - 1) * 3 + 2])
|
|
local slot_id = ARGV[(i - 1) * 3 + 3]
|
|
redis.call('ZADD', KEYS[i], now, slot_id)
|
|
redis.call('EXPIRE', KEYS[i], slot_ttl)
|
|
table.insert(results, redis.call('ZCARD', KEYS[i]))
|
|
end
|
|
return results
|
|
"""
|
|
|
|
PARALLEL_RELEASE_SCRIPT: Final = """
|
|
-- Release one slot per gauge key by removing this request's slot id.
|
|
-- ZREM of an absent member (or key) is a no-op, so a release without a
|
|
-- matching acquire (proxy-side rejection, double-fired callback, slot
|
|
-- already expired) can never free a slot owned by another request.
|
|
-- KEYS: gauge zset keys. ARGV: per-key slot_id.
|
|
-- Returns the remaining in-flight count per key.
|
|
local results = {}
|
|
for i = 1, #KEYS do
|
|
redis.call('ZREM', KEYS[i], ARGV[i])
|
|
table.insert(results, redis.call('ZCARD', KEYS[i]))
|
|
end
|
|
return results
|
|
"""
|
|
|
|
PARALLEL_COUNT_SCRIPT: Final = """
|
|
-- Read the current in-flight count per gauge key (prunes expired slots
|
|
-- first so leaked slots do not inflate the reading).
|
|
-- KEYS: gauge zset keys. ARGV: per-key slot_ttl_seconds.
|
|
local time_reply = redis.call('TIME')
|
|
local now = tonumber(time_reply[1])
|
|
local results = {}
|
|
for i = 1, #KEYS do
|
|
redis.call('ZREMRANGEBYSCORE', KEYS[i], '-inf', now - tonumber(ARGV[i]))
|
|
table.insert(results, redis.call('ZCARD', KEYS[i]))
|
|
end
|
|
return results
|
|
"""
|
|
|
|
TOKEN_INCREMENT_SCRIPT: Final = """
|
|
local results = {}
|
|
|
|
-- Process each key/increment_value/ttl triplet
|
|
for i = 1, #KEYS do
|
|
local key = KEYS[i]
|
|
local increment_value = tonumber(ARGV[i * 2 - 1])
|
|
local ttl_seconds = tonumber(ARGV[i * 2])
|
|
|
|
-- Increment the value
|
|
local new_value = redis.call('INCRBYFLOAT', key, increment_value)
|
|
|
|
-- Handle TTL: only set expire if ttl_seconds > 0 and key has no current TTL
|
|
-- ttl_seconds can be 0 (no TTL) or positive (set TTL)
|
|
if ttl_seconds and ttl_seconds > 0 then
|
|
local current_ttl = redis.call('TTL', key)
|
|
if current_ttl == -1 then
|
|
redis.call('EXPIRE', key, ttl_seconds)
|
|
end
|
|
end
|
|
|
|
table.insert(results, new_value)
|
|
end
|
|
|
|
return results
|
|
"""
|
|
|
|
# Redis cluster slot count
|
|
REDIS_CLUSTER_SLOTS: Final = 16384
|
|
REDIS_NODE_HASHTAG_NAME: Final = "all_keys"
|
|
|
|
# TPM token reservation tuning constants.
|
|
# When max_tokens is not specified in the request we still need to reserve
|
|
# *some* output budget; these define that fallback estimate.
|
|
DEFAULT_MAX_TOKENS_ESTIMATE: Final = 4096
|
|
DEFAULT_CHARS_PER_TOKEN: Final = 4
|
|
# Fraction of the available output budget reserved as the upfront floor when
|
|
# the request omits max_tokens. Applied to both DEFAULT_MAX_TOKENS_ESTIMATE
|
|
# (baseline floor) and to the smallest configured TPM limit (capped floor for
|
|
# small per-tenant TPM caps).
|
|
_TPM_FLOOR_FRACTION: Final = 4
|
|
# Both embeddings and the Responses API put their prompt in data["input"],
|
|
# but only embeddings have no output tokens. Every "is this an embedding"
|
|
# check on data["input"] must exclude these call types, or a Responses call
|
|
# gets misclassified as an embedding and skips output-token reservation/caps.
|
|
RESPONSES_API_CALL_TYPES: Final = ("aresponses", "responses")
|
|
EMBEDDING_API_CALL_TYPES: Final = ("aembedding", "embedding")
|
|
TEXT_COMPLETION_API_CALL_TYPES: Final = ("atext_completion", "text_completion")
|
|
RERANK_API_CALL_TYPES: Final = (CallTypes.rerank.value, CallTypes.arerank.value)
|
|
GOOGLE_GENAI_NATIVE_CALL_TYPES: Final = (
|
|
CallTypes.generate_content.value,
|
|
CallTypes.agenerate_content.value,
|
|
CallTypes.generate_content_stream.value,
|
|
CallTypes.agenerate_content_stream.value,
|
|
)
|
|
RESPONSES_API_MIN_OUTPUT_TOKENS: Final = 16
|
|
# litellm.token_counter has no per-type handling for "input_audio" content
|
|
# blocks (unlike images, which use use_default_image_token_count) -- it
|
|
# silently contributes 0 tokens for them. When the block carries a base64
|
|
# payload, the estimate is derived from the decoded byte count; when the
|
|
# block is a reference without a payload (or the payload is missing), this
|
|
# flat per-block floor is used instead.
|
|
DEFAULT_AUDIO_TOKEN_ESTIMATE: Final = 300
|
|
# Conservative bytes-per-token assumption for size-based audio estimation:
|
|
# equivalent to 8 kHz mono PCM-16 (16 000 bytes/s) at 10 tokens/s. Choosing
|
|
# the lowest reasonable bitrate means we never under-reserve for higher-
|
|
# quality audio recorded at the same wall-clock duration.
|
|
_AUDIO_BYTES_PER_TOKEN: Final = 1600
|
|
# Descriptor "key" values for project-scoped ITPM/OTPM. Distinct from
|
|
# "model_per_project" (the combined-TPM descriptor) so both can be enforced
|
|
# on the same project+model simultaneously without colliding on cache keys.
|
|
PROJECT_ITPM_DESCRIPTOR_KEY: Final = "model_per_project_itpm"
|
|
PROJECT_OTPM_DESCRIPTOR_KEY: Final = "model_per_project_otpm"
|
|
# How long an acquired slot counts toward the in-flight total before it is
|
|
# considered leaked (worker crashed without any release callback firing) and
|
|
# pruned. Also the longest request duration the gauge can track: a request
|
|
# running longer than this stops occupying its slot.
|
|
PARALLEL_REQUEST_SLOT_TTL_SECONDS: Final = 3600
|
|
|
|
|
|
CacheCounterValue: TypeAlias = int | float | str | bytes
|
|
|
|
CacheCounterValues: TypeAlias = Sequence[CacheCounterValue | None]
|
|
|
|
ParallelGaugeCacheValue: TypeAlias = dict[str, object] | int | float | str | bytes
|
|
|
|
|
|
class _AsyncLuaScript(Protocol):
|
|
"""A Lua script registered against the async Redis client, called with KEYS and ARGV."""
|
|
|
|
def __call__(self, *, keys: Sequence[str], args: Sequence[object]) -> Awaitable[list[CacheCounterValue]]: ...
|
|
|
|
|
|
class RateLimitDescriptorRateLimitObject(TypedDict, total=False):
|
|
requests_per_unit: int | None
|
|
tokens_per_unit: int | None
|
|
max_parallel_requests: int | None
|
|
window_size: int | None
|
|
|
|
|
|
class RateLimitDescriptor(TypedDict):
|
|
key: str
|
|
value: str
|
|
rate_limit: RateLimitDescriptorRateLimitObject | None
|
|
|
|
|
|
class ParallelRequestGauge(TypedDict):
|
|
counter_key: str
|
|
limit: int
|
|
descriptor_key: str
|
|
|
|
|
|
class ParallelSlotAcquisition(TypedDict):
|
|
slot_id: str
|
|
counter_keys: list[str]
|
|
|
|
|
|
class RateLimitStatus(TypedDict):
|
|
code: str
|
|
current_limit: int
|
|
limit_remaining: int
|
|
rate_limit_type: Literal["requests", "tokens", "max_parallel_requests"]
|
|
descriptor_key: str
|
|
# Only populated by the atomic_check_and_increment_by_n path. A caller
|
|
# matching a status back to its descriptor must key on (descriptor_key,
|
|
# descriptor_value) when this is present, not descriptor_key alone --
|
|
# e.g. a batch charging several models' project ITPM/OTPM in one call
|
|
# produces multiple statuses sharing the same descriptor_key.
|
|
descriptor_value: NotRequired[ReadOnly[str]]
|
|
|
|
|
|
class RateLimitResponse(TypedDict):
|
|
overall_code: str
|
|
statuses: list[RateLimitStatus]
|
|
reservation_windows: NotRequired[ReadOnly[frozenset[tuple[str, str, Literal["redis", "local"]]]]]
|
|
|
|
|
|
class ReservationAwareIncrementOperation(RedisPipelineIncrementOperation):
|
|
window_key: NotRequired[str]
|
|
expected_window_start: NotRequired[str]
|
|
reservation_backend: NotRequired[Literal["redis", "local"]]
|
|
|
|
|
|
class RateLimitResponseWithDescriptors(TypedDict):
|
|
descriptors: list[RateLimitDescriptor]
|
|
response: RateLimitResponse
|
|
|
|
|
|
class _RateLimitDescriptorSink(Protocol):
|
|
def append(self, descriptor: RateLimitDescriptor, /) -> None: ...
|
|
|
|
|
|
class WindowKeyMetadata(TypedDict):
|
|
requests_limit: int | None
|
|
tokens_limit: int | None
|
|
window_size: int
|
|
descriptor_key: str
|
|
|
|
|
|
class AtomicCounterMeta(TypedDict):
|
|
descriptor_key: str
|
|
descriptor_value: ReadOnly[str]
|
|
current_limit: int
|
|
rate_limit_type: Literal["requests", "tokens"]
|
|
window_key: str
|
|
counter_key: str
|
|
increment: int
|
|
ttl: int
|
|
window_size: int
|
|
|
|
|
|
class AtomicCounterState(TypedDict):
|
|
window_expired: bool
|
|
current: int
|
|
window_start: ReadOnly[str]
|
|
|
|
|
|
DescriptorAtomicGroup: TypeAlias = tuple[list[str], list[int], list[AtomicCounterMeta]]
|
|
|
|
|
|
class CallTypeRateLimiter(Protocol):
|
|
async def async_pre_call_hook(
|
|
self,
|
|
user_api_key_dict: UserAPIKeyAuth,
|
|
cache: DualCache,
|
|
data: dict[str, object],
|
|
call_type: str,
|
|
) -> Exception | str | dict[str, object] | None: ...
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class RequestRateLimiterStash:
|
|
"""
|
|
Per-request bookkeeping the pre-call hook hands to the success/failure/
|
|
disconnect callbacks. Lives on a ContextVar instead of the request body so
|
|
it never reaches provider-facing ``metadata`` channels.
|
|
|
|
A single mutable instance is shared by every context forked from the
|
|
request task (the SDK call, streaming generators, and the logging worker's
|
|
captured context all see the same object), which is what makes the
|
|
``reservation_released`` flag and ``parallel_slot`` clearing effective
|
|
across sibling callbacks: the first release wins, later callbacks observe
|
|
the cleared state.
|
|
|
|
Because the stash is context-inherited, nested LiteLLM calls made inside
|
|
the request (LLM-judge guardrails, silent experiments) would also see it
|
|
from their own logging callbacks. ``owner_litellm_call_id`` pins the stash
|
|
to the proxy request's ``litellm_call_id`` so those callbacks can tell the
|
|
owning request's events apart from a nested call's: router retries and
|
|
fallbacks reuse the request's call id and keep access, while nested calls
|
|
mint fresh ids and are ignored.
|
|
"""
|
|
|
|
owner_litellm_call_id: str | None = None
|
|
rate_limit_response: RateLimitResponse | None = None
|
|
parallel_slot: ParallelSlotAcquisition | None = None
|
|
reserved_tokens: int = 0
|
|
reserved_model: str | None = None
|
|
reserved_scopes: frozenset[tuple[str, str]] = field(default_factory=frozenset)
|
|
itpm_reserved_tokens: int = 0
|
|
itpm_reserved_scopes: frozenset[tuple[str, str]] = field(default_factory=frozenset)
|
|
itpm_reserved_window_identities: frozenset[tuple[str, str, Literal["redis", "local"]]] = field(
|
|
default_factory=frozenset
|
|
)
|
|
otpm_reserved_tokens: int = 0
|
|
otpm_reserved_scopes: frozenset[tuple[str, str]] = field(default_factory=frozenset)
|
|
otpm_reserved_window_identities: frozenset[tuple[str, str, Literal["redis", "local"]]] = field(
|
|
default_factory=frozenset
|
|
)
|
|
batch_enqueued_reservation: BatchEnqueuedTokenReservation | None = None
|
|
reservation_released: bool = False
|
|
|
|
|
|
_request_stash: Final[ContextVar[RequestRateLimiterStash | None]] = ContextVar(
|
|
"litellm_v3_rate_limiter_request_stash", default=None
|
|
)
|
|
|
|
|
|
def get_request_stash() -> RequestRateLimiterStash | None:
|
|
return _request_stash.get()
|
|
|
|
|
|
def get_or_create_request_stash() -> RequestRateLimiterStash:
|
|
stash = _request_stash.get()
|
|
if stash is None:
|
|
stash = RequestRateLimiterStash()
|
|
_request_stash.set(stash)
|
|
return stash
|
|
|
|
|
|
def claim_request_stash_for_data(data: dict) -> RequestRateLimiterStash:
|
|
stash: Final = get_or_create_request_stash()
|
|
owner_call_id: Final = data.get("litellm_call_id")
|
|
if isinstance(owner_call_id, str):
|
|
stash.owner_litellm_call_id = owner_call_id
|
|
return stash
|
|
|
|
|
|
def get_request_stash_for_call(litellm_call_id: str | None) -> RequestRateLimiterStash | None:
|
|
stash: Final = _request_stash.get()
|
|
if stash is None:
|
|
return None
|
|
if stash.owner_litellm_call_id is None or litellm_call_id is None:
|
|
return stash
|
|
return stash if litellm_call_id == stash.owner_litellm_call_id else None
|
|
|
|
|
|
def _call_id_from_callback_kwargs(kwargs: object) -> str | None:
|
|
if not isinstance(kwargs, dict):
|
|
return None
|
|
call_id: Final = kwargs.get("litellm_call_id")
|
|
return call_id if isinstance(call_id, str) else None
|
|
|
|
|
|
def _parse_output_cap_value(raw_value: object) -> int | None:
|
|
if isinstance(raw_value, bool) or not isinstance(raw_value, (int, float, str)):
|
|
return None
|
|
try:
|
|
return int(float(raw_value))
|
|
except (ValueError, OverflowError):
|
|
return None
|
|
|
|
|
|
class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
|
|
batch_rate_limiter_script: _AsyncLuaScript | None
|
|
token_increment_script: _AsyncLuaScript | None
|
|
check_and_increment_by_n_script: _AsyncLuaScript | None
|
|
window_guarded_token_increment_script: _AsyncLuaScript | None
|
|
parallel_acquire_script: _AsyncLuaScript | None
|
|
parallel_release_script: _AsyncLuaScript | None
|
|
parallel_count_script: _AsyncLuaScript | None
|
|
|
|
def __init__(
|
|
self,
|
|
internal_usage_cache: InternalUsageCache,
|
|
time_provider: Callable[[], datetime] | None = None,
|
|
):
|
|
self.internal_usage_cache = internal_usage_cache
|
|
self._time_provider = time_provider or datetime.now
|
|
if self.internal_usage_cache.dual_cache.redis_cache is not None:
|
|
self.batch_rate_limiter_script = self.internal_usage_cache.dual_cache.redis_cache.async_register_script(
|
|
BATCH_RATE_LIMITER_SCRIPT
|
|
)
|
|
self.token_increment_script = self.internal_usage_cache.dual_cache.redis_cache.async_register_script(
|
|
TOKEN_INCREMENT_SCRIPT
|
|
)
|
|
self.check_and_increment_by_n_script = (
|
|
self.internal_usage_cache.dual_cache.redis_cache.async_register_script(CHECK_AND_INCREMENT_BY_N_SCRIPT)
|
|
)
|
|
self.window_guarded_token_increment_script = (
|
|
self.internal_usage_cache.dual_cache.redis_cache.async_register_script(
|
|
WINDOW_GUARDED_TOKEN_INCREMENT_SCRIPT
|
|
)
|
|
)
|
|
self.parallel_acquire_script = self.internal_usage_cache.dual_cache.redis_cache.async_register_script(
|
|
PARALLEL_ACQUIRE_SCRIPT
|
|
)
|
|
self.parallel_release_script = self.internal_usage_cache.dual_cache.redis_cache.async_register_script(
|
|
PARALLEL_RELEASE_SCRIPT
|
|
)
|
|
self.parallel_count_script = self.internal_usage_cache.dual_cache.redis_cache.async_register_script(
|
|
PARALLEL_COUNT_SCRIPT
|
|
)
|
|
else:
|
|
self.batch_rate_limiter_script = None
|
|
self.token_increment_script = None
|
|
self.check_and_increment_by_n_script = None
|
|
self.window_guarded_token_increment_script = None
|
|
self.parallel_acquire_script = None
|
|
self.parallel_release_script = None
|
|
self.parallel_count_script = None
|
|
|
|
self.window_size = int(os.getenv("LITELLM_RATE_LIMIT_WINDOW_SIZE", 60))
|
|
|
|
# When disabled, TPM is enforced post-call from actual usage (pre-v1.82
|
|
# behavior) instead of reserving an estimated budget upfront, shedding
|
|
# the extra per-request Redis Lua round-trip and the global-lock
|
|
# in-memory fallback that the reservation path incurs.
|
|
self.tpm_reservation_enabled = os.getenv("LITELLM_TPM_TOKEN_RESERVATION_ENABLED", "true").lower() == "true"
|
|
|
|
# Batch rate limiter (lazy loaded)
|
|
self._batch_rate_limiter: CallTypeRateLimiter | None = None
|
|
self.batch_enqueued_token_store = BatchEnqueuedTokenStore(internal_usage_cache=internal_usage_cache)
|
|
|
|
# Serializes multi-phase check+increment sequences (batch + dynamic
|
|
# limiters) within this process to close the TOCTOU window between
|
|
# read-only check and counter increment. Multi-replica deployments
|
|
# additionally rely on Redis Lua atomicity for cross-process safety.
|
|
#
|
|
# Coarse granularity: this single lock serializes ALL atomic check+
|
|
# increment operations across batch and dynamic limiters on this
|
|
# instance. A slow batch input-file fetch (which happens upstream of
|
|
# the lock) does not block here, but Redis Lua latency does. If
|
|
# contention shows up under load (visible as p99 latency spikes
|
|
# correlated with batch traffic), shard to a per-descriptor-key lock
|
|
# via a `weakref.WeakValueDictionary[str, asyncio.Lock]`. Punted as a
|
|
# follow-up because Lua dominates wall-time and the lock is held for
|
|
# one round-trip.
|
|
self._check_and_increment_lock = asyncio.Lock()
|
|
|
|
def _get_batch_rate_limiter(self) -> CallTypeRateLimiter | None:
|
|
"""Get or lazy-load the batch rate limiter."""
|
|
if self._batch_rate_limiter is None:
|
|
try:
|
|
from litellm.proxy.hooks.batch_rate_limiter import (
|
|
_PROXY_BatchRateLimiter,
|
|
)
|
|
|
|
self._batch_rate_limiter = _PROXY_BatchRateLimiter(
|
|
internal_usage_cache=self.internal_usage_cache,
|
|
parallel_request_limiter=self,
|
|
)
|
|
except Exception as e:
|
|
verbose_proxy_logger.debug("Could not load batch rate limiter: %s", e)
|
|
return self._batch_rate_limiter
|
|
|
|
def _get_current_time(self) -> datetime:
|
|
"""Return the current time for rate limiting calculations."""
|
|
return self._time_provider()
|
|
|
|
@staticmethod
|
|
def no_max_tokens_output_floor(
|
|
min_configured_tpm_limit: int | None,
|
|
) -> int:
|
|
"""Output-budget floor used when the request omits max_tokens.
|
|
|
|
Capped at a fraction of the smallest configured TPM limit so a small
|
|
per-tenant cap can't be tripped by the floor alone. Returns the
|
|
baseline floor when no limit is provided.
|
|
"""
|
|
baseline: Final = DEFAULT_MAX_TOKENS_ESTIMATE // _TPM_FLOOR_FRACTION
|
|
if min_configured_tpm_limit is None:
|
|
return baseline
|
|
return min(baseline, max(1, min_configured_tpm_limit // _TPM_FLOOR_FRACTION))
|
|
|
|
@staticmethod
|
|
def _is_embedding_request(data: object, call_type: str | None) -> bool:
|
|
if call_type in EMBEDDING_API_CALL_TYPES:
|
|
return True
|
|
if call_type in RESPONSES_API_CALL_TYPES:
|
|
return False
|
|
if call_type:
|
|
return False
|
|
if not isinstance(data, dict):
|
|
return False
|
|
return data.get("input") is not None
|
|
|
|
@staticmethod
|
|
def _translate_google_genai_native_request(
|
|
data: object,
|
|
call_type: str | None,
|
|
) -> Mapping[str, object] | None:
|
|
contents: Final = data.get("contents") if isinstance(data, dict) else None
|
|
if (
|
|
not isinstance(data, dict)
|
|
or call_type not in GOOGLE_GENAI_NATIVE_CALL_TYPES
|
|
or not isinstance(contents, (dict, list))
|
|
):
|
|
return None
|
|
from litellm.google_genai.adapters.transformation import GoogleGenAIAdapter
|
|
|
|
config: Final = data.get("config") if "config" in data else data.get("generationConfig")
|
|
return GoogleGenAIAdapter().translate_generate_content_to_completion(
|
|
model=data.get("model") if isinstance(data.get("model"), str) else "",
|
|
contents=contents,
|
|
config=config if isinstance(config, dict) else None,
|
|
systemInstruction=data.get("systemInstruction"),
|
|
system_instruction=data.get("system_instruction"),
|
|
tools=data.get("tools"),
|
|
toolConfig=data.get("toolConfig"),
|
|
tool_config=data.get("tool_config"),
|
|
)
|
|
|
|
@staticmethod
|
|
def _get_explicit_output_cap(data: object, call_type: str | None) -> int | None:
|
|
if not isinstance(data, dict):
|
|
return None
|
|
if call_type in GOOGLE_GENAI_NATIVE_CALL_TYPES:
|
|
config: Final = data.get("config") if "config" in data else data.get("generationConfig")
|
|
google_cap_values: Final = tuple(
|
|
parsed
|
|
for field in ("maxOutputTokens", "max_output_tokens")
|
|
if isinstance(config, dict)
|
|
for parsed in (_parse_output_cap_value(config.get(field)),)
|
|
if parsed is not None
|
|
)
|
|
return max(google_cap_values, default=None)
|
|
if call_type in RESPONSES_API_CALL_TYPES:
|
|
responses_cap: Final = _parse_output_cap_value(data.get("max_output_tokens"))
|
|
if responses_cap is None:
|
|
return None
|
|
return max(RESPONSES_API_MIN_OUTPUT_TOKENS, responses_cap)
|
|
if call_type in EMBEDDING_API_CALL_TYPES:
|
|
return None
|
|
fields: Final = (
|
|
("max_tokens", "max_completion_tokens")
|
|
if call_type
|
|
else ("max_tokens", "max_completion_tokens", "max_output_tokens")
|
|
)
|
|
output_cap_values: Final = tuple(
|
|
parsed for field in fields for parsed in (_parse_output_cap_value(data.get(field)),) if parsed is not None
|
|
)
|
|
return max(output_cap_values, default=None)
|
|
|
|
@classmethod
|
|
def _has_explicit_output_cap(cls, data: object, call_type: str | None) -> bool:
|
|
"""Whether the caller explicitly set an output-token cap.
|
|
|
|
Checked via ``is not None`` (not truthiness) so an explicit 0 --
|
|
a legitimate zero-output request -- counts as explicit.
|
|
"""
|
|
return cls._get_explicit_output_cap(data, call_type) is not None
|
|
|
|
@staticmethod
|
|
def get_output_candidate_count(data: object, call_type: str | None = None) -> int:
|
|
if not isinstance(data, Mapping):
|
|
return 1
|
|
config: Final = (
|
|
(data.get("config") if "config" in data else data.get("generationConfig"))
|
|
if call_type in GOOGLE_GENAI_NATIVE_CALL_TYPES
|
|
else None
|
|
)
|
|
candidate_values: Final = (
|
|
data.get("n"),
|
|
data.get("best_of"),
|
|
config.get("candidateCount") if isinstance(config, dict) else None,
|
|
config.get("candidate_count") if isinstance(config, dict) else None,
|
|
)
|
|
candidate_count = 1 # rebind-ok: running maximum across candidate-count aliases
|
|
for value in candidate_values:
|
|
try:
|
|
candidate_count = max(candidate_count, int(value or 1))
|
|
except (TypeError, ValueError, OverflowError):
|
|
continue
|
|
return candidate_count
|
|
|
|
@staticmethod
|
|
def _apply_implicit_output_cap(
|
|
data: object,
|
|
min_configured_limit: int | None,
|
|
call_type: str | None,
|
|
configured_output_tokens: int | None = None,
|
|
) -> None:
|
|
"""Hard-cap generation length when the request has no explicit cap.
|
|
|
|
Guards against an unbounded response overshooting a small TPM/OTPM
|
|
budget before post-call reconciliation runs. Skips requests that
|
|
already set an explicit cap and embeddings, which have no generation
|
|
budget. The Responses API only honors ``max_output_tokens`` (its
|
|
underlying chat-completion transformation ignores ``max_tokens``), so
|
|
the cap must be written to that field for Responses call types.
|
|
|
|
``configured_output_tokens`` is the operator-declared per-tenant
|
|
estimate; when it exceeds the safety floor, the cap is raised to that
|
|
value instead of clamping every tenant to the same floor.
|
|
"""
|
|
if not isinstance(data, dict):
|
|
return
|
|
base_capped_floor: Final = _PROXY_MaxParallelRequestsHandler_v3.no_max_tokens_output_floor(min_configured_limit)
|
|
capped_floor: Final = (
|
|
max(base_capped_floor, RESPONSES_API_MIN_OUTPUT_TOKENS)
|
|
if call_type in RESPONSES_API_CALL_TYPES
|
|
else base_capped_floor
|
|
)
|
|
baseline_floor: Final = DEFAULT_MAX_TOKENS_ESTIMATE // _TPM_FLOOR_FRACTION
|
|
is_embedding: Final = _PROXY_MaxParallelRequestsHandler_v3._is_embedding_request(data, call_type)
|
|
if (
|
|
capped_floor >= baseline_floor
|
|
or _PROXY_MaxParallelRequestsHandler_v3._has_explicit_output_cap(data, call_type)
|
|
or is_embedding
|
|
):
|
|
return
|
|
effective_cap: Final = max(capped_floor, configured_output_tokens or 0)
|
|
if call_type in GOOGLE_GENAI_NATIVE_CALL_TYPES:
|
|
config_field: Final = "config" if "config" in data or "generationConfig" not in data else "generationConfig"
|
|
config: Final = data.get(config_field)
|
|
if config is None or isinstance(config, dict):
|
|
data[config_field] = { # rebind-ok: routed request needs cap # mutable-ok: downstream needs dict
|
|
**(config or {}), # mutable-ok: downstream native routing requires a mutable request config
|
|
"maxOutputTokens": effective_cap,
|
|
}
|
|
return
|
|
cap_field: Final = "max_output_tokens" if call_type in RESPONSES_API_CALL_TYPES else "max_tokens"
|
|
existing_cap: Final = data.get(cap_field)
|
|
if existing_cap is None or effective_cap < existing_cap:
|
|
data[cap_field] = effective_cap # rebind-ok: downstream routing requires the bounded output cap
|
|
|
|
def _estimate_tokens_for_request(
|
|
self,
|
|
data: dict,
|
|
model: str | None = None,
|
|
min_configured_tpm_limit: int | None = None,
|
|
call_type: str | None = None,
|
|
configured_output_tokens: int | None = None,
|
|
) -> int:
|
|
"""
|
|
Estimate total tokens this request will consume so we can reserve them
|
|
upfront (input + output budget):
|
|
estimated = input_tokens + max_tokens.
|
|
|
|
Supports chat (messages), completions (prompt), embeddings (input),
|
|
and the Responses API (also `input`, disambiguated from embeddings
|
|
via ``call_type``).
|
|
|
|
``min_configured_tpm_limit`` is the smallest ``tokens_per_unit`` among
|
|
the TPM-bearing descriptors this request will be charged against. When
|
|
provided, the no-``max_tokens`` output-budget floor is capped at a
|
|
fraction of that limit so small TPM caps remain usable. Omit to
|
|
preserve the unconstrained floor.
|
|
|
|
``configured_output_tokens`` is the operator-declared estimate resolved
|
|
from key or team metadata. When provided it replaces the heuristic
|
|
floor entirely, so the reservation reflects what this tenant's model
|
|
actually emits rather than one constant shared by every tenant.
|
|
"""
|
|
estimated_input_tokens, max_tokens_estimate = self._estimate_input_and_output_tokens(
|
|
data=data,
|
|
min_configured_tpm_limit=min_configured_tpm_limit,
|
|
call_type=call_type,
|
|
configured_output_tokens=configured_output_tokens,
|
|
)
|
|
total_estimated: Final = estimated_input_tokens + max_tokens_estimate
|
|
|
|
verbose_proxy_logger.debug(
|
|
"TPM reservation estimate: input=%s, max_tokens=%s, total=%s",
|
|
estimated_input_tokens,
|
|
max_tokens_estimate,
|
|
total_estimated,
|
|
)
|
|
|
|
return total_estimated
|
|
|
|
def _estimate_input_and_output_tokens(
|
|
self,
|
|
data: object,
|
|
min_configured_tpm_limit: int | None = None,
|
|
call_type: str | None = None,
|
|
configured_output_tokens: int | None = None,
|
|
) -> tuple[int, int]:
|
|
"""
|
|
Estimate input tokens and output (max_tokens) budget separately, so
|
|
callers needing independent ITPM/OTPM reservations (rather than one
|
|
combined TPM reservation) can use each half on its own.
|
|
|
|
``min_configured_tpm_limit`` is the smallest ``tokens_per_unit`` among
|
|
the TPM-bearing descriptors this request will be charged against. When
|
|
provided, the no-``max_tokens`` output-budget floor is capped at a
|
|
fraction of that limit so small TPM caps remain usable. Omit to
|
|
preserve the unconstrained floor.
|
|
|
|
``call_type`` disambiguates embeddings from the Responses API: both
|
|
put their prompt in ``data["input"]``, but only embeddings have no
|
|
output tokens. Unset (the default) preserves the historical
|
|
"any `input` means zero output" behavior for callers that don't have
|
|
a call type to pass.
|
|
|
|
``configured_output_tokens`` is the operator-declared estimate resolved
|
|
from key or team metadata. When provided it replaces the heuristic
|
|
floor entirely, so the reservation reflects what this tenant's model
|
|
actually emits rather than one constant shared by every tenant.
|
|
"""
|
|
if not isinstance(data, dict):
|
|
return 0, 0
|
|
translated_data: Final = self._translate_google_genai_native_request(data, call_type)
|
|
estimable_data: Final = translated_data if translated_data is not None else data
|
|
selected_fields: Final[tuple[object | None, object | None, object | None]] = (
|
|
(None, None, estimable_data.get("input"))
|
|
if call_type in RESPONSES_API_CALL_TYPES or call_type in EMBEDDING_API_CALL_TYPES
|
|
else (None, estimable_data.get("prompt"), None)
|
|
if call_type in TEXT_COMPLETION_API_CALL_TYPES
|
|
else (estimable_data.get("messages"), None, None)
|
|
if call_type
|
|
else (
|
|
estimable_data.get("messages"),
|
|
estimable_data.get("prompt"),
|
|
estimable_data.get("input"),
|
|
)
|
|
)
|
|
messages, prompt, input_text = selected_fields
|
|
|
|
total_chars: Final = (
|
|
len(get_str_from_messages(messages))
|
|
if isinstance(messages, list) and messages
|
|
else len(prompt)
|
|
if isinstance(prompt, str)
|
|
else sum(len(str(item)) for item in prompt)
|
|
if isinstance(prompt, list)
|
|
else len(input_text)
|
|
if isinstance(input_text, str)
|
|
else sum(len(str(item)) for item in input_text)
|
|
if isinstance(input_text, list)
|
|
else 0
|
|
)
|
|
|
|
estimated_input_tokens: Final = max(1, total_chars // DEFAULT_CHARS_PER_TOKEN) if total_chars > 0 else 0
|
|
|
|
explicit_max_tokens: Final = self._get_explicit_output_cap(data, call_type)
|
|
is_embedding: Final = self._is_embedding_request(data, call_type)
|
|
|
|
base_output_floor: Final = self.no_max_tokens_output_floor(min_configured_tpm_limit)
|
|
output_floor: Final = (
|
|
max(base_output_floor, RESPONSES_API_MIN_OUTPUT_TOKENS)
|
|
if call_type in RESPONSES_API_CALL_TYPES
|
|
else base_output_floor
|
|
)
|
|
max_tokens_estimate: Final = (
|
|
0
|
|
if is_embedding or (explicit_max_tokens is None and total_chars == 0 and configured_output_tokens is None)
|
|
else explicit_max_tokens
|
|
if explicit_max_tokens is not None
|
|
else configured_output_tokens
|
|
if configured_output_tokens is not None
|
|
else max(estimated_input_tokens, output_floor)
|
|
)
|
|
|
|
return estimated_input_tokens, max_tokens_estimate * self.get_output_candidate_count(data, call_type)
|
|
|
|
def _is_redis_cluster(self) -> bool:
|
|
"""
|
|
Check if the dual cache is using Redis cluster.
|
|
|
|
Returns:
|
|
bool: True if using Redis cluster, False otherwise.
|
|
"""
|
|
from litellm.caching.redis_cluster_cache import RedisClusterCache
|
|
|
|
return self.internal_usage_cache.dual_cache.redis_cache is not None and isinstance(
|
|
self.internal_usage_cache.dual_cache.redis_cache, RedisClusterCache
|
|
)
|
|
|
|
async def in_memory_cache_sliding_window(
|
|
self,
|
|
keys: list[str],
|
|
now_int: int,
|
|
window_size: int,
|
|
) -> CacheCounterValues:
|
|
"""
|
|
Implement sliding window rate limiting logic using in-memory cache operations.
|
|
This follows the same logic as the Redis Lua script but uses async cache operations.
|
|
"""
|
|
results: Final[list[CacheCounterValue | None]] = []
|
|
|
|
# Process each window/counter pair
|
|
for i in range(0, len(keys), 2):
|
|
window_key = keys[i]
|
|
counter_key = keys[i + 1]
|
|
increment_value = 1
|
|
|
|
# Get the window start time
|
|
window_start: CacheCounterValue | None = await self.internal_usage_cache.async_get_cache(
|
|
key=window_key,
|
|
litellm_parent_otel_span=None,
|
|
local_only=True,
|
|
)
|
|
|
|
# Check if window exists and is valid
|
|
if window_start is None or (now_int - int(window_start)) >= window_size:
|
|
# Reset window and counter
|
|
await self.internal_usage_cache.async_set_cache(
|
|
key=window_key,
|
|
value=str(now_int),
|
|
ttl=window_size,
|
|
litellm_parent_otel_span=None,
|
|
local_only=True,
|
|
)
|
|
await self.internal_usage_cache.async_set_cache(
|
|
key=counter_key,
|
|
value=increment_value,
|
|
ttl=window_size,
|
|
litellm_parent_otel_span=None,
|
|
local_only=True,
|
|
)
|
|
results.append(str(now_int)) # window_start
|
|
results.append(increment_value) # counter
|
|
else:
|
|
# Increment the counter
|
|
current_counter: CacheCounterValue | None = await self.internal_usage_cache.async_get_cache(
|
|
key=counter_key,
|
|
litellm_parent_otel_span=None,
|
|
local_only=True,
|
|
)
|
|
new_counter_value = (int(current_counter) if current_counter is not None else 0) + increment_value
|
|
await self.internal_usage_cache.async_set_cache(
|
|
key=counter_key,
|
|
value=new_counter_value,
|
|
ttl=window_size,
|
|
litellm_parent_otel_span=None,
|
|
local_only=True,
|
|
)
|
|
results.append(window_start) # window_start
|
|
results.append(new_counter_value) # counter
|
|
|
|
return results
|
|
|
|
def create_rate_limit_keys(
|
|
self,
|
|
key: str,
|
|
value: str,
|
|
rate_limit_type: Literal["requests", "tokens", "max_parallel_requests"],
|
|
) -> str:
|
|
"""
|
|
Create the rate limit keys for the given key and value.
|
|
"""
|
|
counter_key: Final = f"{{{key}:{value}}}:{rate_limit_type}"
|
|
|
|
return counter_key
|
|
|
|
def is_cache_list_over_limit(
|
|
self,
|
|
keys_to_fetch: list[str],
|
|
cache_values: CacheCounterValues,
|
|
key_metadata: dict[str, WindowKeyMetadata],
|
|
) -> RateLimitResponse:
|
|
"""
|
|
Check if the cache values are over the limit.
|
|
"""
|
|
statuses: Final[list[RateLimitStatus]] = []
|
|
overall_code = "OK"
|
|
|
|
for i in range(0, len(cache_values), 2):
|
|
item_code = "OK"
|
|
window_key = keys_to_fetch[i]
|
|
counter_key = keys_to_fetch[i + 1]
|
|
counter_value = cache_values[i + 1]
|
|
requests_limit = key_metadata[window_key]["requests_limit"]
|
|
tokens_limit = key_metadata[window_key]["tokens_limit"]
|
|
|
|
# Determine which limit to use for current_limit and limit_remaining
|
|
current_limit: int | None = None
|
|
rate_limit_type: Literal["requests", "tokens", "max_parallel_requests"] | None = None
|
|
if counter_key.endswith(":requests"):
|
|
current_limit = requests_limit
|
|
rate_limit_type = "requests"
|
|
elif counter_key.endswith(":tokens"):
|
|
current_limit = tokens_limit
|
|
rate_limit_type = "tokens"
|
|
|
|
if current_limit is None or rate_limit_type is None:
|
|
continue
|
|
|
|
if counter_value is not None and int(counter_value) > current_limit:
|
|
overall_code = "OVER_LIMIT"
|
|
item_code = "OVER_LIMIT"
|
|
|
|
# Only compute limit_remaining if current_limit is not None
|
|
limit_remaining = current_limit - int(counter_value) if counter_value is not None else current_limit
|
|
|
|
statuses.append(
|
|
{
|
|
"code": item_code,
|
|
"current_limit": current_limit,
|
|
"limit_remaining": limit_remaining,
|
|
"rate_limit_type": rate_limit_type,
|
|
"descriptor_key": key_metadata[window_key]["descriptor_key"],
|
|
}
|
|
)
|
|
|
|
return RateLimitResponse(overall_code=overall_code, statuses=statuses)
|
|
|
|
def keyslot_for_redis_cluster(self, key: str) -> int:
|
|
"""
|
|
Compute the Redis Cluster slot for a given key.
|
|
|
|
Simple implementation of `HASH_SLOT = CRC16(key) mod 16384`
|
|
|
|
Read more about hash slots here: https://medium.com/@linz07m/how-hash-slots-power-data-distribution-in-redis-cluster-bc5b7e74ca7d
|
|
|
|
Args:
|
|
key (str): The Redis key.
|
|
|
|
Returns:
|
|
int: The slot number (0-16383).
|
|
|
|
|
|
"""
|
|
# Handle hash tags: use substring between { and }
|
|
start: Final = key.find("{")
|
|
if start != -1:
|
|
end: Final = key.find("}", start + 1)
|
|
if end != -1 and end != start + 1:
|
|
key = key[start + 1 : end]
|
|
|
|
# Compute CRC16 and mod 16384
|
|
crc: Final = binascii.crc_hqx(key.encode("utf-8"), 0)
|
|
return crc % REDIS_CLUSTER_SLOTS
|
|
|
|
def _group_keys_by_hash_tag(self, keys: list[str]) -> dict[str, list[str]]:
|
|
"""
|
|
Group keys by their Redis hash tag to ensure cluster compatibility.
|
|
|
|
For Redis clusters, uses slot calculation to group keys that belong to the same slot.
|
|
For regular Redis, no grouping is needed - all keys can be processed together.
|
|
"""
|
|
groups: Final[dict[str, list[str]]] = {}
|
|
|
|
# Use slot calculation for Redis clusters only
|
|
if self._is_redis_cluster():
|
|
for key in keys:
|
|
slot = self.keyslot_for_redis_cluster(key)
|
|
slot_key = f"slot_{slot}"
|
|
|
|
if slot_key not in groups:
|
|
groups[slot_key] = []
|
|
groups[slot_key].append(key)
|
|
else:
|
|
# For regular Redis, no grouping needed - process all keys together
|
|
groups[REDIS_NODE_HASHTAG_NAME] = keys
|
|
|
|
return groups
|
|
|
|
async def _batch_get_counter_values(
|
|
self,
|
|
keys: list[str],
|
|
parent_otel_span: Span | None,
|
|
local_only: bool,
|
|
) -> CacheCounterValues | None:
|
|
"""Typed view over the DualCache batch read of window/counter keys."""
|
|
return await self.internal_usage_cache.async_batch_get_cache(
|
|
keys=keys,
|
|
parent_otel_span=parent_otel_span,
|
|
local_only=local_only,
|
|
)
|
|
|
|
async def _batch_get_gauge_values(
|
|
self,
|
|
keys: list[str],
|
|
parent_otel_span: Span | None,
|
|
) -> Sequence[ParallelGaugeCacheValue | None] | None:
|
|
"""Typed view over the DualCache batch read of parallel-request gauges."""
|
|
return await self.internal_usage_cache.async_batch_get_cache(
|
|
keys=keys,
|
|
parent_otel_span=parent_otel_span,
|
|
local_only=True,
|
|
)
|
|
|
|
async def _execute_redis_batch_rate_limiter_script(
|
|
self,
|
|
keys_to_fetch: list[str],
|
|
now_int: int,
|
|
) -> CacheCounterValues:
|
|
"""
|
|
Execute Redis operations grouped by hash tag for cluster compatibility.
|
|
|
|
Args:
|
|
keys_to_fetch: List[str] - List of keys to fetch
|
|
now_int: int - Current timestamp
|
|
|
|
Returns:
|
|
List of cache values
|
|
"""
|
|
if self.batch_rate_limiter_script is None:
|
|
return []
|
|
|
|
key_groups: Final = self._group_keys_by_hash_tag(keys_to_fetch)
|
|
all_cache_values: Final[list[CacheCounterValue | None]] = []
|
|
|
|
for hash_tag, group_keys in key_groups.items():
|
|
try:
|
|
group_cache_values: CacheCounterValues = await self.batch_rate_limiter_script(
|
|
keys=group_keys,
|
|
args=[now_int, self.window_size], # Use integer timestamp
|
|
)
|
|
all_cache_values.extend(group_cache_values)
|
|
except Exception as e:
|
|
verbose_proxy_logger.warning("Redis Lua script failed for hash tag %s: %s", hash_tag, e)
|
|
# Fallback to in-memory cache for this group
|
|
group_cache_values = await self.in_memory_cache_sliding_window(
|
|
keys=group_keys,
|
|
now_int=now_int,
|
|
window_size=self.window_size,
|
|
)
|
|
all_cache_values.extend(group_cache_values)
|
|
|
|
return all_cache_values
|
|
|
|
async def should_rate_limit(
|
|
self,
|
|
descriptors: Sequence[RateLimitDescriptor],
|
|
parent_otel_span: Span | None = None,
|
|
read_only: bool = False,
|
|
skip_tpm_check: bool = False,
|
|
parallel_slot_id: str | None = None,
|
|
) -> RateLimitResponse:
|
|
"""
|
|
Check if any of the rate limit descriptors should be rate limited.
|
|
Returns a RateLimitResponse with the overall code and status for each descriptor.
|
|
Uses batch operations for Redis to improve performance.
|
|
|
|
Args:
|
|
descriptors: List of rate limit descriptors to check
|
|
parent_otel_span: Optional OpenTelemetry span for tracing
|
|
read_only: If True, only check limits without incrementing counters
|
|
skip_tpm_check: If True, ignore each descriptor's ``tokens_per_unit``
|
|
— the :tokens counter is neither read nor incremented by this
|
|
pass. Callers that handle TPM via the atomic
|
|
``reserve_tpm_tokens`` reservation path should set this to
|
|
avoid the +1-per-key Lua / in-memory increment double-charging
|
|
the tokens counter.
|
|
|
|
``max_parallel_requests`` descriptors are enforced by the dedicated
|
|
concurrency-gauge path (``_check_parallel_request_gauges``), never by
|
|
the windowed counters. The gauge phase must stay AFTER the windowed
|
|
check so a windowed rejection never strands an acquired slot; the
|
|
reverse order would leak one gauge slot per RPM/TPM rejection.
|
|
``parallel_slot_id`` names the slot an admission registers; callers
|
|
that enforce (not read_only) should pass the id they will later
|
|
release with — when omitted, a generated slot id is used and the slot
|
|
can only be reclaimed by TTL expiry.
|
|
"""
|
|
|
|
current_time: Final = self._get_current_time()
|
|
now: Final = current_time.timestamp()
|
|
now_int: Final = int(now) # Convert to integer for Redis Lua script
|
|
|
|
keys_to_fetch, key_metadata, gauges = self._collect_windowed_keys_and_gauges(
|
|
descriptors=descriptors,
|
|
skip_tpm_check=skip_tpm_check,
|
|
)
|
|
|
|
windowed_response = RateLimitResponse(overall_code="OK", statuses=[])
|
|
if keys_to_fetch:
|
|
## CHECK IN-MEMORY CACHE
|
|
cache_values = await self._batch_get_counter_values( # rebind-ok: refreshed by the Redis read below when the in-memory pass is under limit
|
|
keys=keys_to_fetch,
|
|
parent_otel_span=parent_otel_span,
|
|
local_only=True,
|
|
)
|
|
|
|
if cache_values is not None:
|
|
rate_limit_response: Final = self.is_cache_list_over_limit(keys_to_fetch, cache_values, key_metadata)
|
|
if rate_limit_response["overall_code"] == "OVER_LIMIT":
|
|
return rate_limit_response
|
|
|
|
## IF under limit in-memory, check Redis
|
|
if read_only:
|
|
# READ-ONLY MODE: Just read current values without incrementing
|
|
cache_values = await self._batch_get_counter_values( # rebind-ok: read-only mode replaces the in-memory snapshot with Redis values
|
|
keys=keys_to_fetch,
|
|
parent_otel_span=parent_otel_span,
|
|
local_only=False, # Check Redis too
|
|
)
|
|
|
|
# For keys that don't exist yet, set them to 0
|
|
if cache_values is None:
|
|
cache_values = [ # rebind-ok: missing keys default to a zeroed window snapshot
|
|
str(now_int) if key.endswith(":window") else 0 for key in keys_to_fetch
|
|
]
|
|
elif self.batch_rate_limiter_script is not None:
|
|
# NORMAL MODE: Increment counters in Redis
|
|
# Group keys by hash tag for Redis cluster compatibility
|
|
cache_values = await self._execute_redis_batch_rate_limiter_script(
|
|
keys_to_fetch=keys_to_fetch,
|
|
now_int=now_int,
|
|
)
|
|
|
|
# update in-memory cache with new values
|
|
for i in range(0, len(cache_values), 2):
|
|
window_key = keys_to_fetch[i]
|
|
counter_key = keys_to_fetch[i + 1]
|
|
window_value = cache_values[i]
|
|
counter_value = cache_values[i + 1]
|
|
await self.internal_usage_cache.async_set_cache(
|
|
key=counter_key,
|
|
value=counter_value,
|
|
ttl=self.window_size,
|
|
litellm_parent_otel_span=parent_otel_span,
|
|
local_only=True,
|
|
)
|
|
await self.internal_usage_cache.async_set_cache(
|
|
key=window_key,
|
|
value=window_value,
|
|
ttl=self.window_size,
|
|
litellm_parent_otel_span=parent_otel_span,
|
|
local_only=True,
|
|
)
|
|
else:
|
|
# NORMAL MODE: In-memory sliding window (no Redis)
|
|
cache_values = await self.in_memory_cache_sliding_window(
|
|
keys=keys_to_fetch,
|
|
now_int=now_int,
|
|
window_size=self.window_size,
|
|
)
|
|
|
|
windowed_response = self.is_cache_list_over_limit(keys_to_fetch, cache_values, key_metadata)
|
|
if windowed_response["overall_code"] == "OVER_LIMIT":
|
|
return windowed_response
|
|
|
|
if not gauges:
|
|
return windowed_response
|
|
|
|
gauge_response: Final = await self._check_parallel_request_gauges(
|
|
gauges=gauges,
|
|
slot_id=parallel_slot_id or uuid.uuid4().hex,
|
|
parent_otel_span=parent_otel_span,
|
|
read_only=read_only,
|
|
)
|
|
return RateLimitResponse(
|
|
overall_code=gauge_response["overall_code"],
|
|
statuses=[*windowed_response["statuses"], *gauge_response["statuses"]],
|
|
)
|
|
|
|
def _collect_windowed_keys_and_gauges(
|
|
self,
|
|
descriptors: Sequence[RateLimitDescriptor],
|
|
skip_tpm_check: bool,
|
|
) -> tuple[list[str], dict[str, WindowKeyMetadata], list[ParallelRequestGauge]]:
|
|
"""
|
|
Split descriptors into the windowed (window_key, counter_key) fetch
|
|
list with its per-window metadata, and the concurrency gauges for
|
|
descriptors carrying a max_parallel_requests limit.
|
|
"""
|
|
keys_to_fetch: Final[list[str]] = []
|
|
key_metadata: Final[dict[str, WindowKeyMetadata]] = {}
|
|
gauges: Final[list[ParallelRequestGauge]] = []
|
|
for descriptor in descriptors:
|
|
descriptor_key = descriptor["key"]
|
|
descriptor_value = descriptor["value"]
|
|
rate_limit: RateLimitDescriptorRateLimitObject = (
|
|
descriptor.get("rate_limit") or RateLimitDescriptorRateLimitObject()
|
|
)
|
|
requests_limit = rate_limit.get("requests_per_unit")
|
|
tokens_limit = None if skip_tpm_check else rate_limit.get("tokens_per_unit")
|
|
max_parallel_requests_limit = rate_limit.get("max_parallel_requests")
|
|
window_size = rate_limit.get("window_size") or self.window_size
|
|
|
|
window_key = f"{{{descriptor_key}:{descriptor_value}}}:window"
|
|
|
|
if max_parallel_requests_limit is not None:
|
|
gauges.append(
|
|
ParallelRequestGauge(
|
|
counter_key=self.create_rate_limit_keys(
|
|
descriptor_key, descriptor_value, "max_parallel_requests"
|
|
),
|
|
limit=int(max_parallel_requests_limit),
|
|
descriptor_key=descriptor_key,
|
|
)
|
|
)
|
|
|
|
rate_limit_set = False
|
|
if requests_limit is not None:
|
|
rpm_key = self.create_rate_limit_keys(descriptor_key, descriptor_value, "requests")
|
|
keys_to_fetch.extend([window_key, rpm_key])
|
|
rate_limit_set = True
|
|
if tokens_limit is not None:
|
|
tpm_key = self.create_rate_limit_keys(descriptor_key, descriptor_value, "tokens")
|
|
keys_to_fetch.extend([window_key, tpm_key])
|
|
rate_limit_set = True
|
|
|
|
if not rate_limit_set:
|
|
continue
|
|
|
|
key_metadata[window_key] = {
|
|
"requests_limit": (int(requests_limit) if requests_limit is not None else None),
|
|
"tokens_limit": int(tokens_limit) if tokens_limit is not None else None,
|
|
"window_size": int(window_size),
|
|
"descriptor_key": descriptor_key,
|
|
}
|
|
return keys_to_fetch, key_metadata, gauges
|
|
|
|
def _gauge_status(self, gauge: ParallelRequestGauge, in_flight: int, code: str) -> RateLimitStatus:
|
|
return RateLimitStatus(
|
|
code=code,
|
|
current_limit=gauge["limit"],
|
|
limit_remaining=max(0, gauge["limit"] - in_flight),
|
|
rate_limit_type="max_parallel_requests",
|
|
descriptor_key=gauge["descriptor_key"],
|
|
)
|
|
|
|
def _gauge_in_flight_from_cache_value(self, raw_value: ParallelGaugeCacheValue | None) -> int:
|
|
"""
|
|
In-flight count from a cached gauge value: a dict of slot_id ->
|
|
acquire timestamp when the in-memory registry is authoritative, or
|
|
the mirrored integer count from the last Redis script result.
|
|
"""
|
|
if raw_value is None:
|
|
return 0
|
|
if isinstance(raw_value, dict):
|
|
cutoff: Final = self._get_current_time().timestamp() - PARALLEL_REQUEST_SLOT_TTL_SECONDS
|
|
return sum(1 for ts in raw_value.values() if isinstance(ts, (int, float)) and ts >= cutoff)
|
|
return max(0, int(raw_value))
|
|
|
|
async def _check_parallel_request_gauges(
|
|
self,
|
|
gauges: list[ParallelRequestGauge],
|
|
slot_id: str,
|
|
parent_otel_span: Span | None = None,
|
|
read_only: bool = False,
|
|
) -> RateLimitResponse:
|
|
"""
|
|
Enforce max_parallel_requests as a concurrency gauge over a per-slot
|
|
registry: each admitted request registers ``slot_id`` with its
|
|
acquire time, and admission requires in_flight + 1 <= limit over the
|
|
unexpired slots. Unlike the windowed RPM/TPM counters, the gauge is
|
|
never reset while requests are in flight, a rejected request never
|
|
occupies a slot, and a slot leaked by a crashed worker is pruned
|
|
after PARALLEL_REQUEST_SLOT_TTL_SECONDS even under continuous
|
|
traffic. Releases remove exactly this request's slot id, so a
|
|
double-fired or unmatched release can never free another request's
|
|
slot.
|
|
"""
|
|
gauge_keys: Final = [gauge["counter_key"] for gauge in gauges]
|
|
|
|
if read_only:
|
|
if self.parallel_count_script is not None:
|
|
try:
|
|
raw_counts: Final[list[CacheCounterValue]] = await self.parallel_count_script(
|
|
keys=gauge_keys,
|
|
args=[PARALLEL_REQUEST_SLOT_TTL_SECONDS for _ in gauges],
|
|
)
|
|
counts = [max(0, int(value)) for value in raw_counts]
|
|
except Exception as e: # noqa: BLE001 - any Redis/Lua failure degrades to the local mirror, never a 500
|
|
verbose_proxy_logger.warning("parallel_count_script failed, using local mirror: %s", e)
|
|
counts = await self._read_local_gauge_counts(gauge_keys, parent_otel_span)
|
|
else:
|
|
counts = await self._read_local_gauge_counts(gauge_keys, parent_otel_span)
|
|
statuses = []
|
|
overall_code = "OK"
|
|
for gauge, in_flight in zip(gauges, counts):
|
|
code = "OVER_LIMIT" if in_flight >= gauge["limit"] else "OK"
|
|
if code == "OVER_LIMIT":
|
|
overall_code = "OVER_LIMIT"
|
|
statuses.append(self._gauge_status(gauge, in_flight, code))
|
|
return RateLimitResponse(overall_code=overall_code, statuses=statuses)
|
|
|
|
local_counts: Final = await self._read_local_gauge_counts(gauge_keys, parent_otel_span)
|
|
for gauge, in_flight in zip(gauges, local_counts):
|
|
if in_flight >= gauge["limit"]:
|
|
return RateLimitResponse(
|
|
overall_code="OVER_LIMIT",
|
|
statuses=[self._gauge_status(gauge, in_flight, "OVER_LIMIT")],
|
|
)
|
|
|
|
if self.parallel_acquire_script is not None:
|
|
try:
|
|
raw: Final[list[CacheCounterValue]] = await self.parallel_acquire_script(
|
|
keys=gauge_keys,
|
|
args=[
|
|
arg for gauge in gauges for arg in (gauge["limit"], PARALLEL_REQUEST_SLOT_TTL_SECONDS, slot_id)
|
|
],
|
|
)
|
|
except Exception as e: # noqa: BLE001 - any Redis/Lua failure degrades to in-memory enforcement, never a 500
|
|
verbose_proxy_logger.warning("parallel_acquire_script failed, falling back to in-memory gauge: %s", e)
|
|
async with self._check_and_increment_lock:
|
|
return await self._acquire_parallel_slots_in_memory(gauges, slot_id, parent_otel_span)
|
|
if int(raw[0]) == 1:
|
|
gauge = gauges[int(raw[1]) - 1]
|
|
return RateLimitResponse(
|
|
overall_code="OVER_LIMIT",
|
|
statuses=[self._gauge_status(gauge, int(raw[2]), "OVER_LIMIT")],
|
|
)
|
|
statuses = []
|
|
for gauge, in_flight in zip(gauges, raw[1:]):
|
|
await self.internal_usage_cache.async_set_cache(
|
|
key=gauge["counter_key"],
|
|
value=int(in_flight),
|
|
ttl=PARALLEL_REQUEST_SLOT_TTL_SECONDS,
|
|
litellm_parent_otel_span=parent_otel_span,
|
|
local_only=True,
|
|
)
|
|
statuses.append(self._gauge_status(gauge, int(in_flight), "OK"))
|
|
return RateLimitResponse(overall_code="OK", statuses=statuses)
|
|
|
|
async with self._check_and_increment_lock:
|
|
return await self._acquire_parallel_slots_in_memory(gauges, slot_id, parent_otel_span)
|
|
|
|
async def _read_local_gauge_counts(
|
|
self,
|
|
gauge_keys: list[str],
|
|
parent_otel_span: Span | None = None,
|
|
) -> list[int]:
|
|
values: Final = await self._batch_get_gauge_values(
|
|
keys=gauge_keys,
|
|
parent_otel_span=parent_otel_span,
|
|
)
|
|
if values is None:
|
|
return [0 for _ in gauge_keys]
|
|
return [self._gauge_in_flight_from_cache_value(value) for value in values]
|
|
|
|
async def _acquire_parallel_slots_in_memory(
|
|
self,
|
|
gauges: list[ParallelRequestGauge],
|
|
slot_id: str,
|
|
parent_otel_span: Span | None = None,
|
|
) -> RateLimitResponse:
|
|
"""
|
|
All-or-nothing in-memory slot-registry acquire. Caller holds the lock.
|
|
|
|
A cached dict is the authoritative in-memory registry. A cached
|
|
integer is the count mirrored from the last successful Redis script
|
|
call: when Redis fails over to this path, that mirror still counts
|
|
the slots in flight on the Redis side, so it is carried forward as
|
|
an integer counter (not discarded as an empty registry, which would
|
|
briefly double the admitted concurrency during a Redis outage).
|
|
"""
|
|
now: Final = self._get_current_time().timestamp()
|
|
cutoff: Final = now - PARALLEL_REQUEST_SLOT_TTL_SECONDS
|
|
states: Final[list[tuple[dict[str, float] | None, int]]] = []
|
|
for gauge in gauges:
|
|
raw_value: ParallelGaugeCacheValue | None = await self.internal_usage_cache.async_get_cache(
|
|
key=gauge["counter_key"],
|
|
litellm_parent_otel_span=parent_otel_span,
|
|
local_only=True,
|
|
)
|
|
if isinstance(raw_value, dict):
|
|
registry: dict[str, float] | None = {
|
|
key: float(ts) for key, ts in raw_value.items() if isinstance(ts, (int, float)) and ts >= cutoff
|
|
}
|
|
in_flight = len(registry or {})
|
|
elif raw_value is None:
|
|
registry = {}
|
|
in_flight = 0
|
|
else:
|
|
registry = None
|
|
in_flight = max(0, int(raw_value))
|
|
if in_flight + 1 > gauge["limit"]:
|
|
return RateLimitResponse(
|
|
overall_code="OVER_LIMIT",
|
|
statuses=[self._gauge_status(gauge, in_flight, "OVER_LIMIT")],
|
|
)
|
|
states.append((registry, in_flight))
|
|
|
|
statuses: Final = []
|
|
for gauge, (registry, in_flight) in zip(gauges, states):
|
|
new_value: dict[str, float] | int = {**registry, slot_id: now} if registry is not None else in_flight + 1
|
|
await self.internal_usage_cache.async_set_cache(
|
|
key=gauge["counter_key"],
|
|
value=new_value,
|
|
ttl=PARALLEL_REQUEST_SLOT_TTL_SECONDS,
|
|
litellm_parent_otel_span=parent_otel_span,
|
|
local_only=True,
|
|
)
|
|
statuses.append(self._gauge_status(gauge, in_flight + 1, "OK"))
|
|
return RateLimitResponse(overall_code="OK", statuses=statuses)
|
|
|
|
async def _release_parallel_request_slots(
|
|
self,
|
|
acquisition: ParallelSlotAcquisition,
|
|
parent_otel_span: Span | None = None,
|
|
) -> None:
|
|
"""
|
|
Release the max_parallel_requests slots acquired at pre-call by
|
|
removing this request's slot id from every gauge it was registered
|
|
under. Removing an absent slot id is a no-op, so a release without a
|
|
matching acquire or a double-fired release can never free another
|
|
request's slot. The in-memory fallback decrements integer mirror
|
|
values (floored at 0) because the mirror carries no per-slot ids.
|
|
"""
|
|
counter_keys: Final = acquisition["counter_keys"]
|
|
slot_id: Final = acquisition["slot_id"]
|
|
if not counter_keys or not slot_id:
|
|
return
|
|
if self.parallel_release_script is not None:
|
|
try:
|
|
raw: Final[list[CacheCounterValue]] = await self.parallel_release_script(
|
|
keys=counter_keys,
|
|
args=[slot_id for _ in counter_keys],
|
|
)
|
|
for counter_key, remaining in zip(counter_keys, raw):
|
|
await self.internal_usage_cache.async_set_cache(
|
|
key=counter_key,
|
|
value=max(0, int(remaining)),
|
|
ttl=PARALLEL_REQUEST_SLOT_TTL_SECONDS,
|
|
litellm_parent_otel_span=parent_otel_span,
|
|
local_only=True,
|
|
)
|
|
return
|
|
except Exception as e: # noqa: BLE001 - any Redis/Lua failure degrades to the in-memory release, never a 500
|
|
verbose_proxy_logger.warning("parallel_release_script failed, falling back to in-memory release: %s", e)
|
|
|
|
async with self._check_and_increment_lock:
|
|
for counter_key in counter_keys:
|
|
raw_value: ParallelGaugeCacheValue | None = await self.internal_usage_cache.async_get_cache(
|
|
key=counter_key,
|
|
litellm_parent_otel_span=parent_otel_span,
|
|
local_only=True,
|
|
)
|
|
if isinstance(raw_value, dict):
|
|
if slot_id not in raw_value:
|
|
continue
|
|
new_value: dict[str, object] | int = {key: ts for key, ts in raw_value.items() if key != slot_id}
|
|
elif raw_value is None:
|
|
continue
|
|
else:
|
|
new_value = max(0, int(raw_value) - 1)
|
|
await self.internal_usage_cache.async_set_cache(
|
|
key=counter_key,
|
|
value=new_value,
|
|
ttl=PARALLEL_REQUEST_SLOT_TTL_SECONDS,
|
|
litellm_parent_otel_span=parent_otel_span,
|
|
local_only=True,
|
|
)
|
|
|
|
async def atomic_check_and_increment_by_n(
|
|
self,
|
|
descriptors: list[RateLimitDescriptor],
|
|
increments: list[dict[Literal["requests", "tokens"], int]],
|
|
parent_otel_span: Span | None = None,
|
|
) -> RateLimitResponse:
|
|
"""
|
|
Atomic check-and-increment-by-N across one or more descriptors.
|
|
|
|
All-or-nothing: if any descriptor would exceed its limit, no counter is
|
|
modified and the response carries `overall_code = "OVER_LIMIT"` with
|
|
the offending descriptor's status. Closes the TOCTOU window between
|
|
read and increment in both single-process and multi-process (Redis)
|
|
deployments.
|
|
|
|
Cluster-safety: each descriptor's keys all share a `{key:value}` hash
|
|
tag, so the Redis Lua path issues one Lua call per descriptor — every
|
|
call's keys co-locate on a single Redis Cluster slot, avoiding
|
|
CROSSSLOT errors. Cross-descriptor atomicity is preserved via
|
|
refund-on-rollback: if descriptor i is OVER_LIMIT, descriptors 0..i-1
|
|
get a direct INCRBY refund (refunds need no atomicity guarantee).
|
|
|
|
Args:
|
|
descriptors: rate-limit descriptors to check
|
|
increments: per-descriptor increment amounts, indexed parallel to
|
|
`descriptors`. Each entry is `{"requests": int, "tokens": int}`
|
|
— values default to 0 when a descriptor has no matching limit.
|
|
|
|
Returns:
|
|
RateLimitResponse with one status per (descriptor, rate_limit_type)
|
|
counter, mirroring `should_rate_limit`'s shape.
|
|
"""
|
|
if len(descriptors) != len(increments):
|
|
raise ValueError("atomic_check_and_increment_by_n: descriptors and increments must have the same length")
|
|
|
|
# Build per-descriptor (keys, args, meta) groups. All keys within a
|
|
# group share the descriptor's {key:value} hash tag, so a single Lua
|
|
# call per group never triggers CROSSSLOT on Redis Cluster.
|
|
descriptor_groups: Final[list[DescriptorAtomicGroup]] = []
|
|
for descriptor, increment_amounts in zip(descriptors, increments):
|
|
keys, args, meta = self._build_descriptor_atomic_payload(
|
|
descriptor=descriptor,
|
|
increment_amounts=increment_amounts,
|
|
)
|
|
if keys:
|
|
descriptor_groups.append((keys, args, meta))
|
|
|
|
if not descriptor_groups:
|
|
return RateLimitResponse(overall_code="OK", statuses=[])
|
|
|
|
# Multi-process atomicity via Redis Lua, per descriptor for slot
|
|
# co-location. Single-process atomicity falls back to the
|
|
# asyncio.Lock + in-memory sliding window below — there are no
|
|
# cluster slot concerns locally, so we keep the batched 2-phase
|
|
# critical section for true cross-descriptor atomicity.
|
|
if self.check_and_increment_by_n_script is not None:
|
|
return await self._atomic_lua_per_descriptor(
|
|
descriptor_groups=descriptor_groups,
|
|
parent_otel_span=parent_otel_span,
|
|
)
|
|
|
|
flat_meta: Final[list[AtomicCounterMeta]] = [
|
|
m for _keys, _args, group_meta in descriptor_groups for m in group_meta
|
|
]
|
|
async with self._check_and_increment_lock:
|
|
return await self._atomic_check_and_increment_in_memory(
|
|
per_counter_meta=flat_meta,
|
|
parent_otel_span=parent_otel_span,
|
|
)
|
|
|
|
def _build_descriptor_atomic_payload(
|
|
self,
|
|
descriptor: RateLimitDescriptor,
|
|
increment_amounts: dict[Literal["requests", "tokens"], int],
|
|
) -> DescriptorAtomicGroup:
|
|
"""
|
|
Build (KEYS, ARGV, per-counter meta) for a single descriptor's Lua
|
|
call. All keys returned share the descriptor's {key:value} hash tag.
|
|
"""
|
|
descriptor_key: Final = descriptor["key"]
|
|
descriptor_value: Final = descriptor["value"]
|
|
rate_limit: Final[RateLimitDescriptorRateLimitObject] = (
|
|
descriptor.get("rate_limit") or RateLimitDescriptorRateLimitObject()
|
|
)
|
|
window_size: Final = rate_limit.get("window_size") or self.window_size
|
|
window_key: Final = f"{{{descriptor_key}:{descriptor_value}}}:window"
|
|
|
|
keys: Final[list[str]] = []
|
|
args: Final[list[int]] = []
|
|
meta: Final[list[AtomicCounterMeta]] = []
|
|
|
|
rate_limit_types: Final[tuple[Literal["requests", "tokens"], ...]] = ("requests", "tokens")
|
|
for rlt in rate_limit_types:
|
|
if rlt == "requests":
|
|
limit_value = rate_limit.get("requests_per_unit")
|
|
inc_amount = int(increment_amounts.get("requests", 0) or 0)
|
|
else:
|
|
limit_value = rate_limit.get("tokens_per_unit")
|
|
inc_amount = int(increment_amounts.get("tokens", 0) or 0)
|
|
if limit_value is None or inc_amount < 0:
|
|
continue
|
|
counter_key = self.create_rate_limit_keys(descriptor_key, descriptor_value, rlt)
|
|
# Counter-key TTL and window_size are conceptually distinct
|
|
# ("how long the counter Redis key lives" vs "how long the
|
|
# sliding window is"). Kept as separate values so a future
|
|
# custom-TTL descriptor doesn't reintroduce a silent expiry bug.
|
|
ttl_seconds = int(window_size)
|
|
window_size_seconds = int(window_size)
|
|
keys.extend([window_key, counter_key])
|
|
# 4-tuple matches the Lua ARGV layout:
|
|
# [limit, increment, ttl_seconds, window_size_seconds].
|
|
args.extend([int(limit_value), inc_amount, ttl_seconds, window_size_seconds])
|
|
meta.append(
|
|
{
|
|
"descriptor_key": descriptor_key,
|
|
"descriptor_value": descriptor_value,
|
|
"current_limit": int(limit_value),
|
|
"rate_limit_type": rlt,
|
|
"window_key": window_key,
|
|
"counter_key": counter_key,
|
|
"increment": inc_amount,
|
|
"ttl": ttl_seconds,
|
|
"window_size": window_size_seconds,
|
|
}
|
|
)
|
|
return keys, args, meta
|
|
|
|
async def _atomic_lua_per_descriptor(
|
|
self,
|
|
descriptor_groups: list[DescriptorAtomicGroup],
|
|
parent_otel_span: Span | None = None,
|
|
) -> RateLimitResponse:
|
|
"""
|
|
Run Lua check-and-increment one descriptor at a time so each call's
|
|
keys co-locate on a single Redis Cluster slot. On OVER_LIMIT for
|
|
descriptor i, refund descriptors 0..i-1's increments. On Lua failure
|
|
mid-loop, refund applied increments and fall back to in-memory.
|
|
"""
|
|
if not descriptor_groups:
|
|
return RateLimitResponse(
|
|
overall_code="OK",
|
|
statuses=[], # mutable-ok: response contract requires a status list
|
|
)
|
|
applied: Final[list[list[AtomicCounterMeta]]] = []
|
|
statuses: Final[list[RateLimitStatus]] = []
|
|
raw: list[CacheCounterValue]
|
|
|
|
for _idx, (keys, args, meta) in enumerate(descriptor_groups):
|
|
try:
|
|
raw = await self.check_and_increment_by_n_script( # pyright: ignore[reportOptionalCall] # sole caller guards it is not None
|
|
keys=keys,
|
|
args=args,
|
|
)
|
|
except Exception as e:
|
|
# Lua failure (timeout, OOM, network partition) leaves Redis
|
|
# state ambiguous. Refund any prior groups so Redis returns
|
|
# to its pre-call state, then fall back to in-memory for the
|
|
# whole call (counters there are independent of Redis).
|
|
verbose_proxy_logger.error(
|
|
"atomic_check_and_increment_by_n: Redis Lua execution failed (%s: %s). Refunding %s prior descriptors and falling back to in-memory enforcement — counters will diverge from Redis until window expires (window_size=%ss).",
|
|
type(e).__name__,
|
|
e,
|
|
len(applied),
|
|
self.window_size,
|
|
)
|
|
await self._refund_applied_descriptor_groups(applied)
|
|
flat_meta: list[AtomicCounterMeta] = [m for _k, _a, group_meta in descriptor_groups for m in group_meta]
|
|
async with self._check_and_increment_lock:
|
|
return await self._atomic_check_and_increment_in_memory(
|
|
per_counter_meta=flat_meta,
|
|
parent_otel_span=parent_otel_span,
|
|
)
|
|
|
|
response = self._build_atomic_response(raw, meta)
|
|
if response["overall_code"] == "OVER_LIMIT":
|
|
await self._refund_applied_descriptor_groups(applied)
|
|
return response
|
|
if len(descriptor_groups) == 1:
|
|
return response
|
|
applied.append(meta)
|
|
statuses.extend(response["statuses"])
|
|
|
|
return RateLimitResponse(
|
|
overall_code="OK",
|
|
statuses=statuses,
|
|
reservation_windows=frozenset(),
|
|
)
|
|
|
|
async def _refund_applied_descriptor_groups(
|
|
self,
|
|
applied: list[list[AtomicCounterMeta]],
|
|
) -> None:
|
|
"""
|
|
Decrement counters for descriptor groups already applied via Lua.
|
|
Best-effort: refund failures are logged but not raised — the original
|
|
OVER_LIMIT / fallback decision is what matters to the caller.
|
|
"""
|
|
if not applied:
|
|
return
|
|
redis_cache: Final = self.internal_usage_cache.dual_cache.redis_cache
|
|
if redis_cache is None:
|
|
return
|
|
for group_meta in applied:
|
|
for entry in group_meta:
|
|
try:
|
|
await redis_cache.async_increment(
|
|
key=entry["counter_key"],
|
|
value=-entry["increment"],
|
|
)
|
|
except Exception as e:
|
|
verbose_proxy_logger.warning(
|
|
"Failed to refund %s on cross-descriptor rollback: %s", entry["counter_key"], e
|
|
)
|
|
|
|
def _build_atomic_response(
|
|
self,
|
|
raw: list[CacheCounterValue],
|
|
per_counter_meta: list[AtomicCounterMeta],
|
|
) -> RateLimitResponse:
|
|
"""Convert Lua script return value to RateLimitResponse.
|
|
|
|
Indexing invariant: `per_counter_meta` and `KEYS` are parallel-indexed
|
|
at the COUNTER level, not the descriptor level. A descriptor with both
|
|
RPM and TPM limits emits two `(window_key, counter_key)` pairs and
|
|
two meta entries — one per counter. The Lua script's loop variable
|
|
`i` therefore enumerates counters, and the over-limit return tuple
|
|
`{1, i, ...}` carries a counter index that maps directly to
|
|
`per_counter_meta[i - 1]`. Keep these arrays parallel at the counter
|
|
level when modifying this code.
|
|
"""
|
|
if not raw:
|
|
return RateLimitResponse(overall_code="OK", statuses=[])
|
|
|
|
status_code: Final = int(raw[0])
|
|
if status_code == 1:
|
|
# Over limit: { 1, counter_index (1-based), current_counter, limit }
|
|
descriptor_index: Final = int(raw[1]) - 1
|
|
current_counter: Final = int(raw[2])
|
|
limit: Final = int(raw[3])
|
|
meta = per_counter_meta[descriptor_index]
|
|
return RateLimitResponse(
|
|
overall_code="OVER_LIMIT",
|
|
statuses=[
|
|
RateLimitStatus(
|
|
code="OVER_LIMIT",
|
|
current_limit=limit,
|
|
limit_remaining=max(0, limit - current_counter),
|
|
rate_limit_type=meta["rate_limit_type"],
|
|
descriptor_key=meta["descriptor_key"],
|
|
descriptor_value=meta["descriptor_value"],
|
|
)
|
|
],
|
|
)
|
|
|
|
statuses: Final[list[RateLimitStatus]] = []
|
|
for index, meta in enumerate(per_counter_meta):
|
|
new_counter = raw[1 + index * 2]
|
|
statuses.append(
|
|
RateLimitStatus(
|
|
code="OK",
|
|
current_limit=meta["current_limit"],
|
|
limit_remaining=max(0, meta["current_limit"] - int(new_counter)),
|
|
rate_limit_type=meta["rate_limit_type"],
|
|
descriptor_key=meta["descriptor_key"],
|
|
descriptor_value=meta["descriptor_value"],
|
|
)
|
|
)
|
|
return RateLimitResponse(
|
|
overall_code="OK",
|
|
statuses=statuses,
|
|
reservation_windows=frozenset(
|
|
(
|
|
meta["counter_key"],
|
|
str(int(raw[2 + index * 2])),
|
|
"redis",
|
|
)
|
|
for index, meta in enumerate(per_counter_meta)
|
|
),
|
|
)
|
|
|
|
async def _atomic_check_and_increment_in_memory(
|
|
self,
|
|
per_counter_meta: list[AtomicCounterMeta],
|
|
parent_otel_span: Span | None = None,
|
|
) -> RateLimitResponse:
|
|
"""In-memory all-or-nothing check-and-increment. Caller holds lock.
|
|
|
|
Reads/writes the LOCAL DualCache (`local_only=True`) — note this is
|
|
a different store from Redis. When this fallback fires after a Lua
|
|
failure, in-memory counters will diverge from Redis until each key's
|
|
window expires (TTL bounds divergence).
|
|
"""
|
|
# Use a single 'now' for the duration of this critical section so all
|
|
# descriptors evaluate window expiry consistently.
|
|
now_int: Final = int(self._get_current_time().timestamp())
|
|
|
|
# Pass 1: read state, validate.
|
|
descriptor_state: Final[list[AtomicCounterState]] = []
|
|
for meta in per_counter_meta:
|
|
window_size = meta["window_size"]
|
|
window_start: CacheCounterValue | None = await self.internal_usage_cache.async_get_cache(
|
|
key=meta["window_key"],
|
|
litellm_parent_otel_span=parent_otel_span,
|
|
local_only=True,
|
|
)
|
|
window_expired = window_start is None or (now_int - int(window_start)) >= window_size
|
|
raw_counter: CacheCounterValue | None = (
|
|
None
|
|
if window_expired
|
|
else await self.internal_usage_cache.async_get_cache(
|
|
key=meta["counter_key"],
|
|
litellm_parent_otel_span=parent_otel_span,
|
|
local_only=True,
|
|
)
|
|
)
|
|
current_counter = 0 if window_expired else int(raw_counter or 0)
|
|
over_limit = (
|
|
current_counter + meta["increment"] > meta["current_limit"]
|
|
if meta["increment"] > 0
|
|
else current_counter >= meta["current_limit"]
|
|
)
|
|
if over_limit:
|
|
return RateLimitResponse(
|
|
overall_code="OVER_LIMIT",
|
|
statuses=[
|
|
RateLimitStatus(
|
|
code="OVER_LIMIT",
|
|
current_limit=meta["current_limit"],
|
|
limit_remaining=max(0, meta["current_limit"] - current_counter),
|
|
rate_limit_type=meta["rate_limit_type"],
|
|
descriptor_key=meta["descriptor_key"],
|
|
descriptor_value=meta["descriptor_value"],
|
|
)
|
|
],
|
|
)
|
|
descriptor_state.append(
|
|
{ # mutable-ok: local atomic-counter state is updated during pass two
|
|
"window_expired": window_expired,
|
|
"current": current_counter,
|
|
"window_start": str(now_int if window_expired else int(window_start)),
|
|
}
|
|
)
|
|
|
|
# Pass 2: apply increments.
|
|
statuses: Final[list[RateLimitStatus]] = []
|
|
for meta, state in zip(per_counter_meta, descriptor_state):
|
|
new_counter = meta["increment"] if state["window_expired"] else state["current"] + meta["increment"]
|
|
if state["window_expired"]:
|
|
await self.internal_usage_cache.async_set_cache(
|
|
key=meta["window_key"],
|
|
value=str(now_int),
|
|
ttl=meta["window_size"],
|
|
litellm_parent_otel_span=parent_otel_span,
|
|
local_only=True,
|
|
)
|
|
await self.internal_usage_cache.async_set_cache(
|
|
key=meta["counter_key"],
|
|
value=new_counter,
|
|
ttl=meta["ttl"],
|
|
litellm_parent_otel_span=parent_otel_span,
|
|
local_only=True,
|
|
)
|
|
statuses.append(
|
|
RateLimitStatus(
|
|
code="OK",
|
|
current_limit=meta["current_limit"],
|
|
limit_remaining=max(0, meta["current_limit"] - new_counter),
|
|
rate_limit_type=meta["rate_limit_type"],
|
|
descriptor_key=meta["descriptor_key"],
|
|
descriptor_value=meta["descriptor_value"],
|
|
)
|
|
)
|
|
return RateLimitResponse(
|
|
overall_code="OK",
|
|
statuses=statuses,
|
|
reservation_windows=frozenset(
|
|
(meta["counter_key"], state["window_start"], "local")
|
|
for meta, state in zip(per_counter_meta, descriptor_state)
|
|
),
|
|
)
|
|
|
|
async def reserve_tpm_tokens(
|
|
self,
|
|
descriptors: list[RateLimitDescriptor],
|
|
estimated_tokens: int,
|
|
parent_otel_span: Span | None = None,
|
|
) -> RateLimitResponse:
|
|
"""
|
|
Reserve ``estimated_tokens`` against every TPM-bearing descriptor
|
|
BEFORE the upstream call, so concurrent requests cannot all observe
|
|
"under limit" before any of them increments the counter.
|
|
|
|
Thin wrapper around ``atomic_check_and_increment_by_n``: builds a
|
|
TPM-only descriptor/increment list and delegates the all-or-nothing
|
|
atomicity (Lua on Redis, asyncio-locked DualCache otherwise) to the
|
|
shared primitive.
|
|
|
|
Excludes project ITPM/OTPM descriptors -- those are reserved
|
|
separately (different estimate per bucket) via ``reserve_io_tokens``.
|
|
"""
|
|
tpm_descriptors: Final[list[RateLimitDescriptor]] = [
|
|
RateLimitDescriptor(
|
|
key=d["key"],
|
|
value=d["value"],
|
|
rate_limit=RateLimitDescriptorRateLimitObject(
|
|
tokens_per_unit=(d.get("rate_limit") or {}).get("tokens_per_unit"),
|
|
window_size=(d.get("rate_limit") or {}).get("window_size"),
|
|
),
|
|
)
|
|
for d in descriptors
|
|
if d["key"] not in (PROJECT_ITPM_DESCRIPTOR_KEY, PROJECT_OTPM_DESCRIPTOR_KEY)
|
|
and (d.get("rate_limit") or {}).get("tokens_per_unit") is not None # mutable-ok: optional descriptor
|
|
]
|
|
if not tpm_descriptors:
|
|
return RateLimitResponse(overall_code="OK", statuses=[])
|
|
|
|
increments: Final[list[dict[Literal["requests", "tokens"], int]]] = [
|
|
{"tokens": estimated_tokens} for _ in tpm_descriptors
|
|
]
|
|
return await self.atomic_check_and_increment_by_n(
|
|
descriptors=tpm_descriptors,
|
|
increments=increments,
|
|
parent_otel_span=parent_otel_span,
|
|
)
|
|
|
|
async def _refund_reserved_tokens(
|
|
self,
|
|
scopes: Sequence[tuple[str, str]],
|
|
amount: int,
|
|
reservation_windows: frozenset[tuple[str, str, Literal["redis", "local"]]] = frozenset(),
|
|
parent_otel_span: Span | None = None,
|
|
) -> None:
|
|
"""
|
|
Directly decrement previously-reserved token counters for ``scopes``
|
|
by ``amount``. Used to roll back a reservation that already
|
|
succeeded once a *different* bucket in the same request turns out to
|
|
be over its limit (e.g. ITPM reserved fine, OTPM then hits its
|
|
limit -- the ITPM reservation must not be left inflated).
|
|
"""
|
|
if amount <= 0 or not scopes:
|
|
return
|
|
if not reservation_windows:
|
|
await self.async_increment_tokens_with_ttl_preservation(
|
|
pipeline_operations=self._build_reservation_aware_tpm_ops(
|
|
targets=scopes,
|
|
reserved_scopes=frozenset(scopes),
|
|
actual_tokens=0,
|
|
reserved_tokens=amount,
|
|
),
|
|
parent_otel_span=parent_otel_span,
|
|
)
|
|
return
|
|
pipeline_operations: Final = self._build_project_reservation_ops(
|
|
targets=scopes,
|
|
reserved_scopes=frozenset(scopes),
|
|
actual_tokens=0,
|
|
reserved_tokens=amount,
|
|
reservation_window_identities=reservation_windows,
|
|
)
|
|
await self.async_increment_reservation_aware_tokens(
|
|
pipeline_operations=pipeline_operations,
|
|
parent_otel_span=parent_otel_span,
|
|
)
|
|
|
|
async def reserve_io_tokens(
|
|
self,
|
|
descriptors: Sequence[RateLimitDescriptor],
|
|
estimated_input_tokens: int,
|
|
estimated_output_tokens: int,
|
|
parent_otel_span: Span | None = None,
|
|
) -> tuple[RateLimitResponse, int, int]:
|
|
"""
|
|
Reserve ``estimated_input_tokens`` against project ITPM descriptors
|
|
and ``estimated_output_tokens`` against project OTPM descriptors.
|
|
|
|
ITPM and OTPM are reserved from different-sized estimates, so unlike
|
|
same-size TPM descriptors they can't share a single
|
|
``atomic_check_and_increment_by_n`` call -- each bucket gets its own
|
|
all-or-nothing atomic call. If the OTPM reservation is over limit
|
|
after ITPM already succeeded, the ITPM reservation this call made is
|
|
rolled back before returning, so a partial reservation never leaks.
|
|
|
|
Returns ``(response, itpm_reserved, otpm_reserved)`` -- the latter two
|
|
are the amounts actually reserved (0 if that bucket wasn't
|
|
configured, or if the reservation failed), for the caller to stash
|
|
for post-call reconciliation.
|
|
"""
|
|
itpm_descriptors: Final = [ # mutable-ok: atomic limiter API requires lists
|
|
d for d in descriptors if d["key"] == PROJECT_ITPM_DESCRIPTOR_KEY
|
|
]
|
|
otpm_descriptors: Final = [ # mutable-ok: atomic limiter API requires lists
|
|
d for d in descriptors if d["key"] == PROJECT_OTPM_DESCRIPTOR_KEY
|
|
]
|
|
|
|
if not itpm_descriptors and not otpm_descriptors:
|
|
return RateLimitResponse(overall_code="OK", statuses=[]), 0, 0 # mutable-ok: response contract uses a list
|
|
|
|
itpm_response: Final = (
|
|
await self.atomic_check_and_increment_by_n(
|
|
descriptors=itpm_descriptors,
|
|
increments=[ # mutable-ok: atomic limiter API requires mutable increment records
|
|
{"tokens": estimated_input_tokens} # mutable-ok: atomic limiter increment record
|
|
for _ in itpm_descriptors
|
|
],
|
|
parent_otel_span=parent_otel_span,
|
|
)
|
|
if itpm_descriptors
|
|
else None
|
|
)
|
|
if itpm_response is not None and itpm_response["overall_code"] == "OVER_LIMIT":
|
|
return itpm_response, 0, 0
|
|
itpm_reserved: Final = estimated_input_tokens if itpm_response is not None else 0
|
|
|
|
if otpm_descriptors:
|
|
otpm_response: Final = await self.atomic_check_and_increment_by_n(
|
|
descriptors=otpm_descriptors,
|
|
increments=[ # mutable-ok: atomic limiter API requires mutable increment records
|
|
{"tokens": estimated_output_tokens} # mutable-ok: atomic limiter increment record
|
|
for _ in otpm_descriptors
|
|
],
|
|
parent_otel_span=parent_otel_span,
|
|
)
|
|
if otpm_response["overall_code"] == "OVER_LIMIT":
|
|
if itpm_reserved > 0:
|
|
await self._refund_reserved_tokens(
|
|
scopes=[ # mutable-ok: reservation rollback accepts collected scopes
|
|
(d["key"], d["value"]) for d in itpm_descriptors
|
|
],
|
|
amount=itpm_reserved,
|
|
reservation_windows=itpm_response.get("reservation_windows", frozenset()),
|
|
parent_otel_span=parent_otel_span,
|
|
)
|
|
return otpm_response, 0, 0
|
|
statuses: Final = (
|
|
[ # mutable-ok: response contract uses a list
|
|
*itpm_response["statuses"],
|
|
*otpm_response["statuses"],
|
|
]
|
|
if itpm_response is not None
|
|
else otpm_response["statuses"]
|
|
)
|
|
return (
|
|
RateLimitResponse(
|
|
overall_code="OK",
|
|
statuses=statuses,
|
|
reservation_windows=(
|
|
(
|
|
itpm_response.get("reservation_windows", frozenset())
|
|
if itpm_response is not None
|
|
else frozenset()
|
|
)
|
|
| otpm_response.get("reservation_windows", frozenset())
|
|
),
|
|
),
|
|
itpm_reserved,
|
|
estimated_output_tokens,
|
|
)
|
|
|
|
assert itpm_response is not None
|
|
return itpm_response, itpm_reserved, 0
|
|
|
|
async def enforce_project_io_token_quota_for_frame(
|
|
self,
|
|
user_api_key_dict: UserAPIKeyAuth | None,
|
|
requested_model: str | None,
|
|
estimated_input_tokens: int,
|
|
estimated_output_tokens: int,
|
|
) -> None:
|
|
"""Reserve one WebSocket ``response.create`` frame's tokens against
|
|
the caller's project ITPM/OTPM quota.
|
|
|
|
The Responses WebSocket connection-level pre-call hook only runs once
|
|
per connection, but a connection accepts many ``response.create``
|
|
frames over its lifetime. Without this, a project caller could send
|
|
unlimited high-token generations after a single minimal reservation.
|
|
There is no per-frame post-call hook to reconcile against, so --
|
|
like the batch rate limiter -- this charges the estimate immediately
|
|
and never refunds it.
|
|
"""
|
|
if user_api_key_dict is None:
|
|
return
|
|
descriptors: Final[list[RateLimitDescriptor]] = [] # mutable-ok: descriptor helper appends in place
|
|
self.add_project_io_token_rate_limit_descriptors_from_metadata(
|
|
user_api_key_dict=user_api_key_dict,
|
|
requested_model=requested_model,
|
|
descriptors=descriptors,
|
|
)
|
|
if not descriptors:
|
|
return
|
|
response, _itpm_reserved, _otpm_reserved = await self.reserve_io_tokens(
|
|
descriptors=descriptors,
|
|
estimated_input_tokens=estimated_input_tokens,
|
|
estimated_output_tokens=estimated_output_tokens,
|
|
parent_otel_span=user_api_key_dict.parent_otel_span,
|
|
)
|
|
if response["overall_code"] == "OVER_LIMIT":
|
|
self._handle_rate_limit_error(response, descriptors, requested_model)
|
|
|
|
def create_organization_rate_limit_descriptor(
|
|
self, user_api_key_dict: UserAPIKeyAuth, requested_model: str | None = None
|
|
) -> list[RateLimitDescriptor]:
|
|
descriptors: Final[list[RateLimitDescriptor]] = []
|
|
|
|
# Global org rate limits
|
|
if user_api_key_dict.org_id is not None and (
|
|
user_api_key_dict.organization_rpm_limit is not None or user_api_key_dict.organization_tpm_limit is not None
|
|
):
|
|
descriptors.append(
|
|
RateLimitDescriptor(
|
|
key="organization",
|
|
value=user_api_key_dict.org_id,
|
|
rate_limit={
|
|
"requests_per_unit": user_api_key_dict.organization_rpm_limit,
|
|
"tokens_per_unit": user_api_key_dict.organization_tpm_limit,
|
|
"window_size": self.window_size,
|
|
},
|
|
)
|
|
)
|
|
|
|
# Model specific org rate limits
|
|
if (
|
|
get_model_rate_limit_from_metadata(user_api_key_dict, "organization_metadata", "model_rpm_limit")
|
|
is not None
|
|
or get_model_rate_limit_from_metadata(user_api_key_dict, "organization_metadata", "model_tpm_limit")
|
|
is not None
|
|
):
|
|
_tpm_limit_for_team_model: Final = (
|
|
get_model_rate_limit_from_metadata(user_api_key_dict, "organization_metadata", "model_tpm_limit") or {}
|
|
)
|
|
_rpm_limit_for_team_model: Final = (
|
|
get_model_rate_limit_from_metadata(user_api_key_dict, "organization_metadata", "model_rpm_limit") or {}
|
|
)
|
|
|
|
should_check_rate_limit = False
|
|
if requested_model in _tpm_limit_for_team_model or requested_model in _rpm_limit_for_team_model:
|
|
should_check_rate_limit = True
|
|
|
|
if should_check_rate_limit:
|
|
model_specific_tpm_limit = None
|
|
model_specific_rpm_limit = None
|
|
if requested_model in _tpm_limit_for_team_model:
|
|
model_specific_tpm_limit = _tpm_limit_for_team_model[requested_model]
|
|
if requested_model in _rpm_limit_for_team_model:
|
|
model_specific_rpm_limit = _rpm_limit_for_team_model[requested_model]
|
|
descriptors.append(
|
|
RateLimitDescriptor(
|
|
key="model_per_organization",
|
|
value=f"{user_api_key_dict.org_id}:{requested_model}",
|
|
rate_limit={
|
|
"requests_per_unit": model_specific_rpm_limit,
|
|
"tokens_per_unit": model_specific_tpm_limit,
|
|
"window_size": self.window_size,
|
|
},
|
|
)
|
|
)
|
|
|
|
return descriptors
|
|
|
|
def _add_model_per_key_rate_limit_descriptor(
|
|
self,
|
|
user_api_key_dict: UserAPIKeyAuth,
|
|
requested_model: str | None,
|
|
descriptors: list[RateLimitDescriptor],
|
|
) -> None:
|
|
"""
|
|
Add model-specific rate limit descriptor for API key if applicable.
|
|
|
|
Args:
|
|
user_api_key_dict: User API key authentication dictionary
|
|
requested_model: The model being requested
|
|
descriptors: List of rate limit descriptors to append to
|
|
"""
|
|
from litellm.proxy.auth.auth_utils import (
|
|
get_key_model_rpm_limit,
|
|
get_key_model_tpm_limit,
|
|
)
|
|
|
|
if not requested_model:
|
|
return
|
|
|
|
_tpm_limit_for_key_model = get_key_model_tpm_limit(user_api_key_dict, model_name=requested_model)
|
|
_rpm_limit_for_key_model = get_key_model_rpm_limit(user_api_key_dict, model_name=requested_model)
|
|
|
|
if _tpm_limit_for_key_model is None and _rpm_limit_for_key_model is None:
|
|
return
|
|
|
|
_tpm_limit_for_key_model = _tpm_limit_for_key_model or {}
|
|
_rpm_limit_for_key_model = _rpm_limit_for_key_model or {}
|
|
|
|
# Check if model has any rate limits configured
|
|
should_check_rate_limit: Final = (
|
|
requested_model in _tpm_limit_for_key_model or requested_model in _rpm_limit_for_key_model
|
|
)
|
|
|
|
if not should_check_rate_limit:
|
|
return
|
|
|
|
# Get model-specific limits
|
|
model_specific_tpm_limit: Final[int | None] = _tpm_limit_for_key_model.get(requested_model)
|
|
model_specific_rpm_limit: Final[int | None] = _rpm_limit_for_key_model.get(requested_model)
|
|
|
|
descriptors.append(
|
|
RateLimitDescriptor(
|
|
key="model_per_key",
|
|
value=f"{user_api_key_dict.api_key}:{requested_model}",
|
|
rate_limit={
|
|
"requests_per_unit": model_specific_rpm_limit,
|
|
"tokens_per_unit": model_specific_tpm_limit,
|
|
"window_size": self.window_size,
|
|
},
|
|
)
|
|
)
|
|
|
|
def _add_tag_per_key_rate_limit_descriptor(
|
|
self,
|
|
user_api_key_dict: UserAPIKeyAuth,
|
|
data: dict,
|
|
descriptors: list[RateLimitDescriptor],
|
|
) -> None:
|
|
"""
|
|
Add per-request-tag rpm limit descriptors for the API key.
|
|
|
|
Each tag carried on the request that has a configured limit gets its own
|
|
``{api_key}:{tag}`` counter, so a burst on one tag/group never consumes
|
|
another's budget. Tags without a configured limit fall through to the
|
|
key-level descriptor.
|
|
"""
|
|
if not user_api_key_dict.api_key:
|
|
return
|
|
|
|
tag_rpm_limit: Final = get_key_tag_rpm_limit(user_api_key_dict) or {}
|
|
if not tag_rpm_limit:
|
|
return
|
|
|
|
for tag in dict.fromkeys(get_tags_from_request_body(data)):
|
|
rpm_limit = tag_rpm_limit.get(tag)
|
|
if rpm_limit is None:
|
|
continue
|
|
descriptors.append(
|
|
RateLimitDescriptor(
|
|
key="tag_per_key",
|
|
value=f"{user_api_key_dict.api_key}:{tag}",
|
|
rate_limit={
|
|
"requests_per_unit": rpm_limit,
|
|
"tokens_per_unit": None,
|
|
"window_size": self.window_size,
|
|
},
|
|
)
|
|
)
|
|
|
|
def _add_mcp_per_key_rate_limit_descriptor(
|
|
self,
|
|
user_api_key_dict: UserAPIKeyAuth,
|
|
mcp_server_name: str | None,
|
|
descriptors: list[RateLimitDescriptor],
|
|
) -> None:
|
|
"""
|
|
Add a per-MCP-server rpm descriptor for the API key, if a limit is
|
|
configured for the server being called.
|
|
|
|
MCP tool calls have no token usage, so only requests_per_unit is set;
|
|
tokens_per_unit stays None so the TPM reservation path is never engaged.
|
|
"""
|
|
from litellm.proxy.auth.auth_utils import get_key_mcp_rpm_limit
|
|
|
|
if not mcp_server_name or not user_api_key_dict.api_key:
|
|
return
|
|
|
|
mcp_rpm_limit: Final = get_key_mcp_rpm_limit(user_api_key_dict)
|
|
if not mcp_rpm_limit:
|
|
return
|
|
|
|
server_rpm_limit: Final = mcp_rpm_limit.get(mcp_server_name)
|
|
if server_rpm_limit is None:
|
|
return
|
|
|
|
descriptors.append(
|
|
RateLimitDescriptor(
|
|
key="mcp_per_key",
|
|
value=f"{user_api_key_dict.api_key}:{mcp_server_name}",
|
|
rate_limit={
|
|
"requests_per_unit": server_rpm_limit,
|
|
"tokens_per_unit": None,
|
|
"window_size": self.window_size,
|
|
},
|
|
)
|
|
)
|
|
|
|
def _add_mcp_per_team_rate_limit_descriptor(
|
|
self,
|
|
user_api_key_dict: UserAPIKeyAuth,
|
|
mcp_server_name: str | None,
|
|
descriptors: list[RateLimitDescriptor],
|
|
) -> None:
|
|
"""
|
|
Add a per-MCP-server rpm descriptor for the team, if a limit is
|
|
configured for the server being called.
|
|
"""
|
|
from litellm.proxy.auth.auth_utils import get_team_mcp_rpm_limit
|
|
|
|
if not mcp_server_name:
|
|
return
|
|
|
|
# Which teams' buckets does this call charge? A key is pinned to exactly one team. A keyless
|
|
# MCP-admitted subject reaches servers through SEVERAL teams at once and has no team_id, so
|
|
# without the second source below its calls charged no team bucket at all and it outran every
|
|
# team's mcp_rpm_limit. Every applicable team is charged rather than one being picked: the
|
|
# limiter enforces all descriptors, so each team's own ceiling binds on a call made through
|
|
# its grant, and there is no arbitrary attribution when several teams grant the same server.
|
|
team_limits: Final[list[tuple[str | None, dict[str, int] | None]]] = []
|
|
if user_api_key_dict.team_id:
|
|
team_limits.append((user_api_key_dict.team_id, get_team_mcp_rpm_limit(user_api_key_dict)))
|
|
for source_team_id, source_limit in (user_api_key_dict.mcp_source_team_rpm_limits or {}).items():
|
|
team_limits.append((source_team_id, source_limit))
|
|
|
|
for team_id, mcp_rpm_limit in team_limits:
|
|
if not team_id or not mcp_rpm_limit:
|
|
continue
|
|
server_rpm_limit = mcp_rpm_limit.get(mcp_server_name)
|
|
if server_rpm_limit is None:
|
|
continue
|
|
descriptors.append(
|
|
RateLimitDescriptor(
|
|
key="mcp_per_team",
|
|
value=f"{team_id}:{mcp_server_name}",
|
|
rate_limit={
|
|
"requests_per_unit": server_rpm_limit,
|
|
"tokens_per_unit": None,
|
|
"window_size": self.window_size,
|
|
},
|
|
)
|
|
)
|
|
|
|
def _should_enforce_rate_limit(
|
|
self,
|
|
limit_type: str | None,
|
|
model_has_failures: bool,
|
|
) -> bool:
|
|
"""
|
|
Determine if rate limit should be enforced based on limit type and model health.
|
|
|
|
Args:
|
|
limit_type: Type of rate limit ("dynamic", "guaranteed_throughput", "best_effort_throughput", or None)
|
|
model_has_failures: Whether the model has recent failures
|
|
|
|
Returns:
|
|
True if rate limit should be enforced, False otherwise
|
|
"""
|
|
if limit_type == "dynamic":
|
|
# Dynamic mode: only enforce if model has failures
|
|
return model_has_failures
|
|
# All other modes (including None): always enforce
|
|
return True
|
|
|
|
def _get_enforced_limit(
|
|
self,
|
|
limit_value: int | None,
|
|
limit_type: str | None,
|
|
model_has_failures: bool,
|
|
) -> int | None:
|
|
"""
|
|
Get the rate limit value to enforce based on limit type and model health.
|
|
|
|
Args:
|
|
limit_value: The configured limit value
|
|
limit_type: Type of rate limit ("dynamic", "guaranteed_throughput", "best_effort_throughput", or None)
|
|
model_has_failures: Whether the model has recent failures
|
|
|
|
Returns:
|
|
The limit value if it should be enforced, None otherwise
|
|
"""
|
|
if limit_value is None:
|
|
return None
|
|
|
|
if self._should_enforce_rate_limit(
|
|
limit_type=limit_type,
|
|
model_has_failures=model_has_failures,
|
|
):
|
|
return limit_value
|
|
|
|
return None
|
|
|
|
def _is_dynamic_rate_limiting_enabled(
|
|
self,
|
|
rpm_limit_type: str | None,
|
|
tpm_limit_type: str | None,
|
|
) -> bool:
|
|
"""
|
|
Check if dynamic rate limiting is enabled for either RPM or TPM.
|
|
|
|
Args:
|
|
rpm_limit_type: RPM rate limit type
|
|
tpm_limit_type: TPM rate limit type
|
|
|
|
Returns:
|
|
True if dynamic mode is enabled for either limit type
|
|
"""
|
|
return rpm_limit_type == "dynamic" or tpm_limit_type == "dynamic"
|
|
|
|
def _get_agent_from_registry(self, agent_id: str) -> "AgentResponse | None":
|
|
"""Look up an agent from the in-memory registry by ID."""
|
|
from litellm.proxy.agent_endpoints.agent_registry import global_agent_registry
|
|
|
|
return global_agent_registry.get_agent_by_id(agent_id=agent_id)
|
|
|
|
def _get_resolved_agent_id(self, user_api_key_dict: UserAPIKeyAuth, data: dict) -> str | None:
|
|
"""
|
|
Resolve the agent_id from either the API key or request metadata.
|
|
Key-level agent_id takes precedence over metadata/header-supplied agent_id.
|
|
"""
|
|
key_agent_id: Final = getattr(user_api_key_dict, "agent_id", None)
|
|
if key_agent_id:
|
|
return key_agent_id
|
|
metadata: Final = data.get("metadata") or {}
|
|
return metadata.get("agent_id")
|
|
|
|
def _get_session_id_from_data(self, data: dict) -> str | None:
|
|
"""Extract session_id from request metadata or litellm_session_id."""
|
|
session_id = data.get("litellm_session_id")
|
|
if session_id:
|
|
return str(session_id)
|
|
metadata: Final = data.get("metadata") or {}
|
|
session_id = metadata.get("session_id")
|
|
if session_id:
|
|
return str(session_id)
|
|
litellm_metadata: Final = data.get("litellm_metadata") or {}
|
|
session_id = litellm_metadata.get("session_id")
|
|
if session_id:
|
|
return str(session_id)
|
|
return None
|
|
|
|
def _create_agent_rate_limit_descriptors(
|
|
self,
|
|
agent_id: str,
|
|
data: dict,
|
|
) -> list[RateLimitDescriptor]:
|
|
"""
|
|
Create rate limit descriptors for agent-level and session-level limits.
|
|
|
|
Agent-level: caps total RPM/TPM across all sessions for a given agent.
|
|
Session-level: caps RPM/TPM within a single session (identified by session_id).
|
|
"""
|
|
descriptors: Final[list[RateLimitDescriptor]] = []
|
|
|
|
agent: Final = self._get_agent_from_registry(agent_id)
|
|
if agent is None:
|
|
return descriptors
|
|
|
|
agent_rpm: Final = getattr(agent, "rpm_limit", None)
|
|
agent_tpm: Final = getattr(agent, "tpm_limit", None)
|
|
if agent_rpm is not None or agent_tpm is not None:
|
|
descriptors.append(
|
|
RateLimitDescriptor(
|
|
key="agent",
|
|
value=agent_id,
|
|
rate_limit={
|
|
"requests_per_unit": agent_rpm,
|
|
"tokens_per_unit": agent_tpm,
|
|
"window_size": self.window_size,
|
|
},
|
|
)
|
|
)
|
|
|
|
session_rpm: Final = getattr(agent, "session_rpm_limit", None)
|
|
session_tpm: Final = getattr(agent, "session_tpm_limit", None)
|
|
if session_rpm is not None or session_tpm is not None:
|
|
session_id: Final = self._get_session_id_from_data(data)
|
|
if session_id is not None:
|
|
descriptors.append(
|
|
RateLimitDescriptor(
|
|
key="agent_session",
|
|
value=f"{agent_id}:{session_id}",
|
|
rate_limit={
|
|
"requests_per_unit": session_rpm,
|
|
"tokens_per_unit": session_tpm,
|
|
"window_size": self.window_size,
|
|
},
|
|
)
|
|
)
|
|
|
|
return descriptors
|
|
|
|
def _create_rate_limit_descriptors(
|
|
self,
|
|
user_api_key_dict: UserAPIKeyAuth,
|
|
data: dict,
|
|
rpm_limit_type: str | None,
|
|
tpm_limit_type: str | None,
|
|
model_has_failures: bool,
|
|
call_type: str | None = None,
|
|
) -> list[RateLimitDescriptor]:
|
|
"""
|
|
Create all rate limit descriptors for the request.
|
|
|
|
Returns list of descriptors for API key, user, team, team member, end user,
|
|
model-specific, agent, and agent-session limits.
|
|
"""
|
|
from litellm.proxy.auth.auth_utils import (
|
|
get_team_model_rpm_limit,
|
|
get_team_model_tpm_limit,
|
|
)
|
|
|
|
descriptors: Final = []
|
|
|
|
# API Key rate limits
|
|
if user_api_key_dict.api_key and (
|
|
user_api_key_dict.rpm_limit is not None
|
|
or user_api_key_dict.tpm_limit is not None
|
|
or user_api_key_dict.max_parallel_requests is not None
|
|
):
|
|
throttle_pct: Final = user_api_key_dict.budget_throttle_pct
|
|
descriptors.append(
|
|
RateLimitDescriptor(
|
|
key="api_key",
|
|
value=user_api_key_dict.api_key,
|
|
rate_limit={
|
|
"requests_per_unit": self._get_enforced_limit(
|
|
limit_value=throttled_limit(user_api_key_dict.rpm_limit, throttle_pct),
|
|
limit_type=rpm_limit_type,
|
|
model_has_failures=model_has_failures,
|
|
),
|
|
"tokens_per_unit": self._get_enforced_limit(
|
|
limit_value=throttled_limit(user_api_key_dict.tpm_limit, throttle_pct),
|
|
limit_type=tpm_limit_type,
|
|
model_has_failures=model_has_failures,
|
|
),
|
|
"max_parallel_requests": user_api_key_dict.max_parallel_requests,
|
|
"window_size": self.window_size,
|
|
},
|
|
)
|
|
)
|
|
|
|
# User rate limits
|
|
if user_api_key_dict.user_id and (
|
|
user_api_key_dict.user_rpm_limit is not None or user_api_key_dict.user_tpm_limit is not None
|
|
):
|
|
descriptors.append(
|
|
RateLimitDescriptor(
|
|
key="user",
|
|
value=user_api_key_dict.user_id,
|
|
rate_limit={
|
|
"requests_per_unit": user_api_key_dict.user_rpm_limit,
|
|
"tokens_per_unit": user_api_key_dict.user_tpm_limit,
|
|
"window_size": self.window_size,
|
|
},
|
|
)
|
|
)
|
|
|
|
# Team rate limits
|
|
if user_api_key_dict.team_id and (
|
|
user_api_key_dict.team_rpm_limit is not None or user_api_key_dict.team_tpm_limit is not None
|
|
):
|
|
descriptors.append(
|
|
RateLimitDescriptor(
|
|
key="team",
|
|
value=user_api_key_dict.team_id,
|
|
rate_limit={
|
|
"requests_per_unit": user_api_key_dict.team_rpm_limit,
|
|
"tokens_per_unit": user_api_key_dict.team_tpm_limit,
|
|
"window_size": self.window_size,
|
|
},
|
|
)
|
|
)
|
|
|
|
# Team Member rate limits
|
|
if user_api_key_dict.user_id and (
|
|
user_api_key_dict.team_member_rpm_limit is not None or user_api_key_dict.team_member_tpm_limit is not None
|
|
):
|
|
team_member_value: Final = f"{user_api_key_dict.team_id}:{user_api_key_dict.user_id}"
|
|
descriptors.append(
|
|
RateLimitDescriptor(
|
|
key="team_member",
|
|
value=team_member_value,
|
|
rate_limit={
|
|
"requests_per_unit": user_api_key_dict.team_member_rpm_limit,
|
|
"tokens_per_unit": user_api_key_dict.team_member_tpm_limit,
|
|
"window_size": self.window_size,
|
|
},
|
|
)
|
|
)
|
|
|
|
# End user rate limits
|
|
if user_api_key_dict.end_user_id and (
|
|
user_api_key_dict.end_user_rpm_limit is not None or user_api_key_dict.end_user_tpm_limit is not None
|
|
):
|
|
descriptors.append(
|
|
RateLimitDescriptor(
|
|
key="end_user",
|
|
value=user_api_key_dict.end_user_id,
|
|
rate_limit={
|
|
"requests_per_unit": user_api_key_dict.end_user_rpm_limit,
|
|
"tokens_per_unit": user_api_key_dict.end_user_tpm_limit,
|
|
"window_size": self.window_size,
|
|
},
|
|
)
|
|
)
|
|
|
|
# Model rate limits
|
|
requested_model: Final = data.get("model", None)
|
|
self._add_model_per_key_rate_limit_descriptor(
|
|
user_api_key_dict=user_api_key_dict,
|
|
requested_model=requested_model,
|
|
descriptors=descriptors,
|
|
)
|
|
|
|
# Per-request-tag rate limits scoped to this key
|
|
self._add_tag_per_key_rate_limit_descriptor(
|
|
user_api_key_dict=user_api_key_dict,
|
|
data=data,
|
|
descriptors=descriptors,
|
|
)
|
|
|
|
# REST MCP calls pass the raw body through this hook before server
|
|
# resolution; only the later synthetic hook payload may carry this key.
|
|
if call_type == CallTypes.call_mcp_tool.value and "server_id" not in data:
|
|
mcp_server_name: Final = data.get("mcp_server_name", None)
|
|
self._add_mcp_per_key_rate_limit_descriptor(
|
|
user_api_key_dict=user_api_key_dict,
|
|
mcp_server_name=mcp_server_name,
|
|
descriptors=descriptors,
|
|
)
|
|
self._add_mcp_per_team_rate_limit_descriptor(
|
|
user_api_key_dict=user_api_key_dict,
|
|
mcp_server_name=mcp_server_name,
|
|
descriptors=descriptors,
|
|
)
|
|
|
|
if (
|
|
get_team_model_rpm_limit(user_api_key_dict) is not None
|
|
or get_team_model_tpm_limit(user_api_key_dict) is not None
|
|
):
|
|
_tpm_limit_for_team_model: Final = get_team_model_tpm_limit(user_api_key_dict) or {}
|
|
_rpm_limit_for_team_model: Final = get_team_model_rpm_limit(user_api_key_dict) or {}
|
|
should_check_rate_limit = False
|
|
if requested_model in _tpm_limit_for_team_model or requested_model in _rpm_limit_for_team_model:
|
|
should_check_rate_limit = True
|
|
|
|
if should_check_rate_limit:
|
|
model_specific_tpm_limit = None
|
|
model_specific_rpm_limit = None
|
|
if requested_model in _tpm_limit_for_team_model:
|
|
model_specific_tpm_limit = _tpm_limit_for_team_model[requested_model]
|
|
if requested_model in _rpm_limit_for_team_model:
|
|
model_specific_rpm_limit = _rpm_limit_for_team_model[requested_model]
|
|
descriptors.append(
|
|
RateLimitDescriptor(
|
|
key="model_per_team",
|
|
value=f"{user_api_key_dict.team_id}:{requested_model}",
|
|
rate_limit={
|
|
"requests_per_unit": model_specific_rpm_limit,
|
|
"tokens_per_unit": model_specific_tpm_limit,
|
|
"window_size": self.window_size,
|
|
},
|
|
)
|
|
)
|
|
|
|
# Agent-level and session-level rate limits
|
|
resolved_agent_id: Final = self._get_resolved_agent_id(user_api_key_dict, data)
|
|
|
|
if resolved_agent_id:
|
|
descriptors.extend(
|
|
self._create_agent_rate_limit_descriptors(
|
|
agent_id=resolved_agent_id,
|
|
data=data,
|
|
)
|
|
)
|
|
|
|
return descriptors
|
|
|
|
async def _check_model_has_recent_failures(
|
|
self,
|
|
model: str,
|
|
parent_otel_span: Span | None = None,
|
|
) -> bool:
|
|
"""
|
|
Check if any deployment for this model has recent failures by using
|
|
the router's existing failure tracking.
|
|
|
|
Returns True if any deployment has failures in the current minute.
|
|
"""
|
|
from litellm.proxy.proxy_server import llm_router
|
|
from litellm.router_utils.router_callbacks.track_deployment_metrics import (
|
|
get_deployment_failures_for_current_minute,
|
|
)
|
|
|
|
if llm_router is None:
|
|
return False
|
|
|
|
try:
|
|
# Get all deployments for this model
|
|
model_list: Final = llm_router.get_model_list(model_name=model)
|
|
if not model_list:
|
|
return False
|
|
|
|
# Check each deployment's failure count
|
|
for deployment in model_list:
|
|
deployment_id = deployment.get("model_info", {}).get("id")
|
|
if not deployment_id:
|
|
continue
|
|
|
|
# Use router's existing failure tracking
|
|
failure_count = get_deployment_failures_for_current_minute(
|
|
litellm_router_instance=llm_router,
|
|
deployment_id=deployment_id,
|
|
)
|
|
|
|
if failure_count > DYNAMIC_RATE_LIMIT_ERROR_THRESHOLD_PER_MINUTE:
|
|
verbose_proxy_logger.debug(
|
|
"[Dynamic Rate Limit] Deployment %s has %s failures in current minute - enforcing rate limits for model %s",
|
|
deployment_id,
|
|
failure_count,
|
|
model,
|
|
)
|
|
return True
|
|
|
|
verbose_proxy_logger.debug(
|
|
"[Dynamic Rate Limit] No failures detected for model %s - allowing dynamic exceeding", model
|
|
)
|
|
return False
|
|
|
|
except Exception as e:
|
|
verbose_proxy_logger.debug("Error checking model failure status: %s, defaulting to enforce limits", e)
|
|
# Fail safe: enforce limits if we can't check
|
|
return True
|
|
|
|
def get_rate_limiter_for_call_type(self, call_type: str) -> CallTypeRateLimiter | None:
|
|
"""Get the rate limiter for the call type."""
|
|
if call_type == "acreate_batch":
|
|
batch_limiter: Final = self._get_batch_rate_limiter()
|
|
return batch_limiter
|
|
return None
|
|
|
|
def _add_team_model_rate_limit_descriptor_from_metadata(
|
|
self,
|
|
user_api_key_dict: UserAPIKeyAuth,
|
|
requested_model: str | None,
|
|
descriptors: list[RateLimitDescriptor],
|
|
) -> None:
|
|
"""Add team model rate limit descriptor from team_metadata if applicable."""
|
|
if (
|
|
get_model_rate_limit_from_metadata(user_api_key_dict, "team_metadata", "model_rpm_limit") is not None
|
|
or get_model_rate_limit_from_metadata(user_api_key_dict, "team_metadata", "model_tpm_limit") is not None
|
|
):
|
|
_tpm_limit_for_team_model: Final = (
|
|
get_model_rate_limit_from_metadata(user_api_key_dict, "team_metadata", "model_tpm_limit") or {}
|
|
)
|
|
_rpm_limit_for_team_model: Final = (
|
|
get_model_rate_limit_from_metadata(user_api_key_dict, "team_metadata", "model_rpm_limit") or {}
|
|
)
|
|
should_check_rate_limit: Final = (
|
|
requested_model in _tpm_limit_for_team_model or requested_model in _rpm_limit_for_team_model
|
|
)
|
|
|
|
if should_check_rate_limit and requested_model is not None:
|
|
model_specific_tpm_limit: Final = _tpm_limit_for_team_model.get(requested_model)
|
|
model_specific_rpm_limit: Final = _rpm_limit_for_team_model.get(requested_model)
|
|
descriptors.append(
|
|
RateLimitDescriptor(
|
|
key="model_per_team",
|
|
value=f"{user_api_key_dict.team_id}:{requested_model}",
|
|
rate_limit={
|
|
"requests_per_unit": model_specific_rpm_limit,
|
|
"tokens_per_unit": model_specific_tpm_limit,
|
|
"window_size": self.window_size,
|
|
},
|
|
)
|
|
)
|
|
|
|
def _add_project_model_rate_limit_descriptor_from_metadata(
|
|
self,
|
|
user_api_key_dict: UserAPIKeyAuth,
|
|
requested_model: str | None,
|
|
descriptors: list[RateLimitDescriptor],
|
|
) -> None:
|
|
"""Add project model rate limit descriptor from project_metadata if applicable."""
|
|
if (
|
|
get_model_rate_limit_from_metadata(user_api_key_dict, "project_metadata", "model_rpm_limit") is not None
|
|
or get_model_rate_limit_from_metadata(user_api_key_dict, "project_metadata", "model_tpm_limit") is not None
|
|
):
|
|
_tpm_limit_for_project_model: Final = (
|
|
get_model_rate_limit_from_metadata(user_api_key_dict, "project_metadata", "model_tpm_limit") or {}
|
|
)
|
|
_rpm_limit_for_project_model: Final = (
|
|
get_model_rate_limit_from_metadata(user_api_key_dict, "project_metadata", "model_rpm_limit") or {}
|
|
)
|
|
should_check_rate_limit: Final = (
|
|
requested_model in _tpm_limit_for_project_model or requested_model in _rpm_limit_for_project_model
|
|
)
|
|
|
|
if should_check_rate_limit and requested_model is not None:
|
|
model_specific_tpm_limit: Final = _tpm_limit_for_project_model.get(requested_model)
|
|
model_specific_rpm_limit: Final = _rpm_limit_for_project_model.get(requested_model)
|
|
descriptors.append(
|
|
RateLimitDescriptor(
|
|
key="model_per_project",
|
|
value=f"{user_api_key_dict.project_id}:{requested_model}",
|
|
rate_limit={
|
|
"requests_per_unit": model_specific_rpm_limit,
|
|
"tokens_per_unit": model_specific_tpm_limit,
|
|
"window_size": self.window_size,
|
|
},
|
|
)
|
|
)
|
|
|
|
def add_project_io_token_rate_limit_descriptors_from_metadata(
|
|
self,
|
|
user_api_key_dict: UserAPIKeyAuth,
|
|
requested_model: str | None,
|
|
descriptors: _RateLimitDescriptorSink,
|
|
) -> None:
|
|
"""Add project-scoped ITPM/OTPM descriptors from project_metadata.
|
|
|
|
Enforced independently of, and alongside, the combined ``model_per_project``
|
|
TPM descriptor above -- these give Bedrock Mantle-style separate input/output
|
|
token quotas at the project level.
|
|
"""
|
|
if requested_model is None or user_api_key_dict.project_id is None:
|
|
return
|
|
|
|
itpm_limit_for_project_model: Final = (
|
|
get_model_rate_limit_from_metadata(user_api_key_dict, "project_metadata", "model_itpm_limit")
|
|
or {} # mutable-ok: metadata helper returns an optional mapping
|
|
)
|
|
otpm_limit_for_project_model: Final = (
|
|
get_model_rate_limit_from_metadata(user_api_key_dict, "project_metadata", "model_otpm_limit")
|
|
or {} # mutable-ok: metadata helper returns an optional mapping
|
|
)
|
|
|
|
model_itpm_limit: Final = itpm_limit_for_project_model.get(requested_model)
|
|
model_otpm_limit: Final = otpm_limit_for_project_model.get(requested_model)
|
|
|
|
if model_itpm_limit is None and model_otpm_limit is None:
|
|
return
|
|
|
|
descriptor_value: Final = f"{user_api_key_dict.project_id}:{requested_model}"
|
|
if model_itpm_limit is not None:
|
|
descriptors.append(
|
|
RateLimitDescriptor(
|
|
key=PROJECT_ITPM_DESCRIPTOR_KEY,
|
|
value=descriptor_value,
|
|
rate_limit={ # mutable-ok: descriptor TypedDict requires a runtime dict
|
|
"requests_per_unit": None,
|
|
"tokens_per_unit": model_itpm_limit,
|
|
"window_size": self.window_size,
|
|
},
|
|
)
|
|
)
|
|
if model_otpm_limit is not None:
|
|
descriptors.append(
|
|
RateLimitDescriptor(
|
|
key=PROJECT_OTPM_DESCRIPTOR_KEY,
|
|
value=descriptor_value,
|
|
rate_limit={ # mutable-ok: descriptor TypedDict requires a runtime dict
|
|
"requests_per_unit": None,
|
|
"tokens_per_unit": model_otpm_limit,
|
|
"window_size": self.window_size,
|
|
},
|
|
)
|
|
)
|
|
|
|
def _handle_rate_limit_error(
|
|
self,
|
|
response: RateLimitResponse,
|
|
descriptors: list[RateLimitDescriptor],
|
|
requested_model: str | None = None,
|
|
) -> None:
|
|
"""Handle rate limit exceeded by raising :class:`ProxyRateLimitError` (a 429)."""
|
|
for status in response["statuses"]:
|
|
if status["code"] == "OVER_LIMIT":
|
|
descriptor_key = status["descriptor_key"]
|
|
matching_descriptor = next(
|
|
(desc for desc in descriptors if desc["key"] == descriptor_key),
|
|
None,
|
|
)
|
|
descriptor_value = matching_descriptor["value"] if matching_descriptor is not None else "unknown"
|
|
|
|
now = self._get_current_time().timestamp()
|
|
reset_time = now + self.window_size
|
|
reset_time_formatted = datetime.fromtimestamp(reset_time).strftime("%Y-%m-%d %H:%M:%S UTC")
|
|
|
|
remaining_display = max(0, status["limit_remaining"])
|
|
rate_limit_type = status["rate_limit_type"]
|
|
current_limit = status["current_limit"]
|
|
|
|
detail = (
|
|
f"Rate limit exceeded for {descriptor_key}: {descriptor_value}. "
|
|
f"Limit type: {rate_limit_type}. "
|
|
f"Current limit: {current_limit}, Remaining: {remaining_display}. "
|
|
f"Limit resets at: {reset_time_formatted}"
|
|
)
|
|
|
|
resolved_model, llm_provider = resolve_llm_provider_for_rate_limit(requested_model)
|
|
raise ProxyRateLimitError(
|
|
detail=detail,
|
|
headers={
|
|
"retry-after": str(self.window_size),
|
|
"rate_limit_type": str(status["rate_limit_type"]),
|
|
"reset_at": reset_time_formatted,
|
|
},
|
|
rate_limit_type=map_v3_rate_limit_type(status["rate_limit_type"]),
|
|
model=resolved_model,
|
|
llm_provider=llm_provider,
|
|
)
|
|
|
|
@staticmethod
|
|
def _estimate_audio_block_tokens(block: object) -> int:
|
|
"""
|
|
Token estimate for one ``input_audio`` content block.
|
|
|
|
When the block carries a base64 ``data`` payload, the estimate comes
|
|
from the decoded byte count (``len(b64) * 3 // 4 // _AUDIO_BYTES_PER_TOKEN``),
|
|
assuming the lowest reasonable audio bitrate so we never under-reserve
|
|
for higher-quality recordings of the same duration.
|
|
|
|
When no payload is present (reference-only block or missing ``data``),
|
|
falls back to ``DEFAULT_AUDIO_TOKEN_ESTIMATE``.
|
|
"""
|
|
if not isinstance(block, dict):
|
|
return DEFAULT_AUDIO_TOKEN_ESTIMATE
|
|
input_audio: Final = block.get("input_audio")
|
|
b64_data: Final = input_audio.get("data") if isinstance(input_audio, dict) else None
|
|
if b64_data and isinstance(b64_data, str):
|
|
decoded_bytes: Final = len(b64_data) * 3 // 4
|
|
return max(decoded_bytes // _AUDIO_BYTES_PER_TOKEN, DEFAULT_AUDIO_TOKEN_ESTIMATE)
|
|
return DEFAULT_AUDIO_TOKEN_ESTIMATE
|
|
|
|
@classmethod
|
|
def _estimate_audio_content_tokens(cls, messages: object) -> int:
|
|
"""
|
|
Sum of per-block audio token estimates across all ``messages``.
|
|
Returns 0 when there are no ``input_audio`` blocks, which the caller
|
|
uses to skip the (relatively expensive) strip pass.
|
|
"""
|
|
if not isinstance(messages, list):
|
|
return 0
|
|
return sum(
|
|
cls._estimate_audio_block_tokens(block)
|
|
for message in messages
|
|
if isinstance(message, dict)
|
|
for content in (message.get("content"),)
|
|
if isinstance(content, list)
|
|
for block in content
|
|
if isinstance(block, dict) and block.get("type") == "input_audio"
|
|
)
|
|
|
|
@staticmethod
|
|
def _strip_audio_content_blocks(messages: object) -> object:
|
|
"""
|
|
Drop ``input_audio`` content blocks before passing ``messages`` to
|
|
``token_counter``, which raises ``ValueError`` on them (no per-type
|
|
handling, unlike images). The audio contribution is added back
|
|
separately via ``DEFAULT_AUDIO_TOKEN_ESTIMATE`` so the rest of the
|
|
message (text/images/tools) still gets counted accurately instead of
|
|
the whole call falling back to the cheap char-count estimate.
|
|
"""
|
|
if not isinstance(messages, list):
|
|
return messages
|
|
sanitized: Final[list[object]] = [] # mutable-ok: token_counter requires a list of message dicts
|
|
for message in messages:
|
|
if not isinstance(message, dict):
|
|
sanitized.append(message)
|
|
continue
|
|
content = message.get("content")
|
|
if not isinstance(content, list):
|
|
sanitized.append(message)
|
|
continue
|
|
filtered_content = [ # mutable-ok: token_counter requires list content blocks
|
|
block for block in content if not (isinstance(block, dict) and block.get("type") == "input_audio")
|
|
]
|
|
sanitized.append( # mutable-ok: token_counter requires mutable message dicts
|
|
{**message, "content": filtered_content} # mutable-ok: token_counter requires message dicts
|
|
)
|
|
return sanitized
|
|
|
|
@staticmethod
|
|
def _responses_input_to_chat_messages(data: object) -> Sequence[object]:
|
|
"""
|
|
Convert a Responses API ``input`` (string or list of input items) into
|
|
chat-completion-style messages via the standard LiteLLM transformation
|
|
(the same one guardrails use, e.g. ``purview_dlp.py``), so multimodal
|
|
``input_image``/``input_text`` content blocks get counted by
|
|
``token_counter``'s ``messages`` path instead of silently contributing
|
|
zero tokens via its ``text`` path, which only joins plain strings.
|
|
"""
|
|
from litellm.responses.litellm_completion_transformation.transformation import (
|
|
LiteLLMCompletionResponsesConfig,
|
|
)
|
|
|
|
if not isinstance(data, dict):
|
|
return ()
|
|
return LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages(
|
|
input=data.get("input") or "",
|
|
responses_api_request=data,
|
|
)
|
|
|
|
@staticmethod
|
|
def _count_pretokenized_embedding_input(value: object) -> int | None:
|
|
if not isinstance(value, list):
|
|
return None
|
|
if all(isinstance(token, int) for token in value):
|
|
return len(value)
|
|
if all(
|
|
isinstance(token_ids, list) and all(isinstance(token, int) for token in token_ids) for token_ids in value
|
|
):
|
|
return sum(len(token_ids) for token_ids in value)
|
|
return None
|
|
|
|
@staticmethod
|
|
def _rerank_input_to_text(data: Mapping[str, object]) -> str:
|
|
documents: Final = data.get("documents")
|
|
document_items: Final[Sequence[object]] = documents if isinstance(documents, list) else () # pyright: ignore[reportUnknownVariableType] # rerank documents are validated runtime JSON
|
|
input_parts: Final[tuple[object, ...]] = ( # pyright: ignore[reportUnknownVariableType] # list narrowing preserves unknown JSON element types
|
|
data.get("query"),
|
|
*document_items,
|
|
)
|
|
return "\n".join(
|
|
str(part) # pyright: ignore[reportUnknownArgumentType] # accepted document dicts have provider-defined fields
|
|
for part in input_parts # pyright: ignore[reportUnknownVariableType] # runtime JSON list elements remain unknown after list narrowing
|
|
if isinstance(part, (str, dict))
|
|
)
|
|
|
|
def _estimate_precise_input_tokens(self, data: object, model: str | None, call_type: str | None = None) -> int:
|
|
"""
|
|
Model-aware input token estimate for the project ITPM reservation,
|
|
using ``litellm.token_counter`` -- the same approach the
|
|
deployment-level itpm/otpm check uses in
|
|
``io_token_rate_limit_check.py``. Unlike the cheap char-count
|
|
estimate the combined-TPM path uses, this accounts for image/tool
|
|
content and derives per-``input_audio``-block estimates from the
|
|
base64 payload size (assuming the lowest reasonable bitrate so
|
|
longer recordings always reserve proportionally more), so a burst
|
|
of multimodal, tool-heavy, or audio-heavy requests can't each
|
|
reserve only the one-token floor and blow past ITPM before
|
|
post-call reconciliation catches up.
|
|
|
|
For the Responses API, ``input`` is converted to chat messages first
|
|
(via ``_responses_input_to_chat_messages``) so its own multimodal
|
|
content blocks are counted the same way; ``token_counter``'s ``text``
|
|
argument can only see plain strings in a list, not content blocks.
|
|
|
|
Falls back to the cheap char-count estimate if ``token_counter``
|
|
can't resolve a tokenizer for this model (e.g. an unrecognized
|
|
custom model name) or otherwise raises -- the audio add-on still
|
|
applies on top of the fallback.
|
|
"""
|
|
from litellm import token_counter
|
|
|
|
if not isinstance(data, dict):
|
|
return 0
|
|
is_responses_request: Final = call_type in RESPONSES_API_CALL_TYPES
|
|
translated_request: Final = (
|
|
None if is_responses_request else self._translate_google_genai_native_request(data, call_type)
|
|
)
|
|
is_embedding_request: Final = self._is_embedding_request(data, call_type)
|
|
embedding_text: Final = data.get("input") if is_embedding_request else None
|
|
pretokenized_input_tokens: Final = (
|
|
self._count_pretokenized_embedding_input(embedding_text) if is_embedding_request else None
|
|
)
|
|
if pretokenized_input_tokens is not None:
|
|
return pretokenized_input_tokens
|
|
|
|
prompt: Final = data.get("prompt")
|
|
fallback_text: Final = prompt if prompt is not None else data.get("input")
|
|
selected_inputs: Final[tuple[object | None, object | None, object | None, object | None]] = (
|
|
(self._responses_input_to_chat_messages(data), None, data.get("tools"), data.get("tool_choice"))
|
|
if is_responses_request
|
|
else (
|
|
translated_request.get("messages"),
|
|
None,
|
|
translated_request.get("tools"),
|
|
translated_request.get("tool_choice"),
|
|
)
|
|
if translated_request is not None
|
|
else (None, embedding_text, data.get("tools"), data.get("tool_choice"))
|
|
if is_embedding_request
|
|
else (None, self._rerank_input_to_text(data), data.get("tools"), data.get("tool_choice"))
|
|
if call_type in RERANK_API_CALL_TYPES
|
|
else (None, prompt, data.get("tools"), data.get("tool_choice"))
|
|
if call_type in TEXT_COMPLETION_API_CALL_TYPES
|
|
else (data.get("messages"), fallback_text, data.get("tools"), data.get("tool_choice"))
|
|
)
|
|
messages, selected_text, countable_tools, countable_tool_choice = selected_inputs
|
|
|
|
audio_token_estimate: Final = self._estimate_audio_content_tokens(messages)
|
|
countable_messages: Final = self._strip_audio_content_blocks(messages) if audio_token_estimate > 0 else messages
|
|
|
|
try:
|
|
estimate: Final = max(
|
|
0,
|
|
int(
|
|
token_counter(
|
|
model=model or "",
|
|
messages=countable_messages,
|
|
text=selected_text,
|
|
tools=countable_tools,
|
|
tool_choice=countable_tool_choice,
|
|
use_default_image_token_count=True,
|
|
)
|
|
),
|
|
)
|
|
return estimate + audio_token_estimate
|
|
except Exception: # noqa: BLE001 # tokenizer failures degrade to the cheap estimate
|
|
if call_type in RERANK_API_CALL_TYPES and isinstance(selected_text, str):
|
|
return max(0, len(selected_text) // DEFAULT_CHARS_PER_TOKEN)
|
|
estimated_input_tokens, _ = self._estimate_input_and_output_tokens(data=data, call_type=call_type)
|
|
return estimated_input_tokens + audio_token_estimate
|
|
|
|
async def _reserve_project_io_tokens_or_raise(
|
|
self,
|
|
descriptors: Sequence[RateLimitDescriptor],
|
|
data: object,
|
|
requested_model: str | None,
|
|
user_api_key_dict: UserAPIKeyAuth,
|
|
tpm_reservation_scopes: Sequence[tuple[str, str]],
|
|
tpm_reservation_amount: int,
|
|
call_type: str | None = None,
|
|
) -> None:
|
|
"""
|
|
Reserve project-scoped ITPM/OTPM tokens (Bedrock Mantle-style
|
|
separate input/output token buckets), independently of -- and, when
|
|
both are configured, in addition to -- the combined-TPM reservation
|
|
the caller already made. Raises (via ``_handle_rate_limit_error``) on
|
|
an over-limit reservation, first rolling back the combined-TPM
|
|
reservation named by ``tpm_reservation_scopes``/``tpm_reservation_amount``
|
|
if one was made, so a partial reservation never leaks.
|
|
"""
|
|
if not isinstance(data, dict):
|
|
return
|
|
stash: Final = claim_request_stash_for_data(data)
|
|
io_token_descriptors: Final = [ # mutable-ok: reservation API requires descriptor lists
|
|
d for d in descriptors if d["key"] in (PROJECT_ITPM_DESCRIPTOR_KEY, PROJECT_OTPM_DESCRIPTOR_KEY)
|
|
]
|
|
if not io_token_descriptors:
|
|
return
|
|
|
|
configured_otpm_limits: Final = [ # mutable-ok: min calculation materializes validated limits
|
|
int(v)
|
|
for d in io_token_descriptors
|
|
if d["key"] == PROJECT_OTPM_DESCRIPTOR_KEY
|
|
for v in [ # mutable-ok: comprehension binds the optional descriptor value
|
|
(d.get("rate_limit") or {}).get( # mutable-ok: optional descriptor fallback
|
|
"tokens_per_unit"
|
|
)
|
|
]
|
|
if v is not None
|
|
]
|
|
min_configured_otpm_limit: Final = min(configured_otpm_limits) if configured_otpm_limits else None
|
|
_, raw_estimated_output_tokens = self._estimate_input_and_output_tokens(
|
|
data=data,
|
|
min_configured_tpm_limit=min_configured_otpm_limit,
|
|
call_type=call_type,
|
|
)
|
|
raw_estimated_input_tokens: Final = self._estimate_precise_input_tokens(
|
|
data=data, model=requested_model, call_type=call_type
|
|
)
|
|
estimated_input_tokens: Final = max(raw_estimated_input_tokens, 1)
|
|
estimated_output_tokens: Final = (
|
|
raw_estimated_output_tokens
|
|
if self._has_explicit_output_cap(data, call_type)
|
|
else max(raw_estimated_output_tokens, 1)
|
|
)
|
|
|
|
# Hard-cap generation length so an unbounded response can't overshoot
|
|
# the OTPM budget before post-call reconciliation runs, mirroring the
|
|
# combined-TPM floor cap in the caller.
|
|
self._apply_implicit_output_cap(
|
|
data=data,
|
|
min_configured_limit=min_configured_otpm_limit,
|
|
call_type=call_type,
|
|
)
|
|
|
|
io_response, itpm_reserved, otpm_reserved = await self.reserve_io_tokens(
|
|
descriptors=io_token_descriptors,
|
|
estimated_input_tokens=estimated_input_tokens,
|
|
estimated_output_tokens=estimated_output_tokens,
|
|
parent_otel_span=user_api_key_dict.parent_otel_span,
|
|
)
|
|
|
|
if io_response["overall_code"] == "OVER_LIMIT":
|
|
# A combined-TPM reservation may have already succeeded above for
|
|
# this same request; refund it too, or its counter stays inflated
|
|
# until the window's TTL expires. Mark it released so the
|
|
# ProxyRateLimitError we're about to raise doesn't get refunded
|
|
# a second time when async_post_call_failure_hook sees the same
|
|
# (still-stashed) reservation and refunds it again.
|
|
if tpm_reservation_amount > 0:
|
|
await self._refund_reserved_tokens(
|
|
scopes=tpm_reservation_scopes,
|
|
amount=tpm_reservation_amount,
|
|
parent_otel_span=user_api_key_dict.parent_otel_span,
|
|
)
|
|
stash.reservation_released = True
|
|
acquisition: Final = stash.parallel_slot
|
|
if acquisition is not None:
|
|
await self._release_parallel_request_slots(
|
|
acquisition=acquisition,
|
|
parent_otel_span=user_api_key_dict.parent_otel_span,
|
|
)
|
|
stash.parallel_slot = None
|
|
self._handle_rate_limit_error(
|
|
response=io_response,
|
|
descriptors=descriptors,
|
|
requested_model=requested_model,
|
|
)
|
|
|
|
if itpm_reserved > 0:
|
|
itpm_scopes: Final = tuple(
|
|
(d["key"], d["value"]) for d in io_token_descriptors if d["key"] == PROJECT_ITPM_DESCRIPTOR_KEY
|
|
)
|
|
stash.itpm_reserved_tokens = itpm_reserved
|
|
stash.itpm_reserved_scopes = frozenset(itpm_scopes)
|
|
stash.itpm_reserved_window_identities = frozenset(
|
|
(counter_key, window_start, backend)
|
|
for counter_key, window_start, backend in io_response.get("reservation_windows", frozenset())
|
|
if "model_per_project_itpm" in counter_key
|
|
)
|
|
if otpm_reserved > 0:
|
|
otpm_scopes: Final = tuple(
|
|
(d["key"], d["value"]) for d in io_token_descriptors if d["key"] == PROJECT_OTPM_DESCRIPTOR_KEY
|
|
)
|
|
stash.otpm_reserved_tokens = otpm_reserved
|
|
stash.otpm_reserved_scopes = frozenset(otpm_scopes)
|
|
stash.otpm_reserved_window_identities = frozenset(
|
|
(counter_key, window_start, backend)
|
|
for counter_key, window_start, backend in io_response.get("reservation_windows", frozenset())
|
|
if "model_per_project_otpm" in counter_key
|
|
)
|
|
|
|
if stash.rate_limit_response is not None:
|
|
stash.rate_limit_response["statuses"].extend(io_response["statuses"])
|
|
elif io_response["statuses"]:
|
|
stash.rate_limit_response = io_response
|
|
|
|
verbose_proxy_logger.debug(
|
|
"ITPM/OTPM tokens reserved: itpm=%s, otpm=%s for model %s",
|
|
itpm_reserved,
|
|
otpm_reserved,
|
|
requested_model,
|
|
)
|
|
|
|
async def async_pre_call_hook(
|
|
self,
|
|
user_api_key_dict: UserAPIKeyAuth,
|
|
cache: DualCache,
|
|
data: dict,
|
|
call_type: str,
|
|
):
|
|
"""
|
|
Pre-call hook to check rate limits before making the API call.
|
|
Supports dynamic rate limiting based on deployment health.
|
|
"""
|
|
verbose_proxy_logger.debug("Inside Rate Limit Pre-Call Hook")
|
|
|
|
stash: Final = claim_request_stash_for_data(data)
|
|
|
|
#########################################################
|
|
# Check if the call type has a specific rate limiter
|
|
# eg. for Batch APIs we need to use the batch rate limiter to read the input file and count the tokens and requests
|
|
#########################################################
|
|
call_type_specific_rate_limiter: Final = self.get_rate_limiter_for_call_type(call_type=call_type)
|
|
if call_type_specific_rate_limiter:
|
|
return await call_type_specific_rate_limiter.async_pre_call_hook(
|
|
user_api_key_dict=user_api_key_dict,
|
|
cache=cache,
|
|
data=data,
|
|
call_type=call_type,
|
|
)
|
|
|
|
# Get rate limit types from metadata
|
|
metadata: Final = user_api_key_dict.metadata or {}
|
|
rpm_limit_type: Final = metadata.get("rpm_limit_type")
|
|
tpm_limit_type: Final = metadata.get("tpm_limit_type")
|
|
|
|
# For dynamic mode, check if the model has recent failures
|
|
model_has_failures = False
|
|
requested_model: Final = data.get("model", None)
|
|
|
|
if (
|
|
self._is_dynamic_rate_limiting_enabled(
|
|
rpm_limit_type=rpm_limit_type,
|
|
tpm_limit_type=tpm_limit_type,
|
|
)
|
|
and requested_model
|
|
):
|
|
model_has_failures = await self._check_model_has_recent_failures(
|
|
model=requested_model,
|
|
parent_otel_span=user_api_key_dict.parent_otel_span,
|
|
)
|
|
|
|
# Create rate limit descriptors
|
|
descriptors: Final = self._create_rate_limit_descriptors(
|
|
user_api_key_dict=user_api_key_dict,
|
|
data=data,
|
|
rpm_limit_type=rpm_limit_type,
|
|
tpm_limit_type=tpm_limit_type,
|
|
model_has_failures=model_has_failures,
|
|
call_type=call_type,
|
|
)
|
|
|
|
# Add team model rate limits from team_metadata
|
|
self._add_team_model_rate_limit_descriptor_from_metadata(
|
|
user_api_key_dict=user_api_key_dict,
|
|
requested_model=requested_model,
|
|
descriptors=descriptors,
|
|
)
|
|
|
|
# Project Level Rate Limits
|
|
self._add_project_model_rate_limit_descriptor_from_metadata(
|
|
user_api_key_dict=user_api_key_dict,
|
|
requested_model=requested_model,
|
|
descriptors=descriptors,
|
|
)
|
|
self.add_project_io_token_rate_limit_descriptors_from_metadata(
|
|
user_api_key_dict=user_api_key_dict,
|
|
requested_model=requested_model,
|
|
descriptors=descriptors,
|
|
)
|
|
|
|
# Org Level Rate Limits
|
|
descriptors.extend(self.create_organization_rate_limit_descriptor(user_api_key_dict, requested_model))
|
|
|
|
# Only check rate limits if we have descriptors with actual limits
|
|
if descriptors:
|
|
# First pass: RPM and max_parallel_requests sliding-window check.
|
|
# When reservation is enabled, `skip_tpm_check=True` tells
|
|
# should_rate_limit to ignore each descriptor's tokens_per_unit so
|
|
# its +1-per-key Lua / in-memory increment never touches the
|
|
# :tokens counters — those are owned exclusively by the atomic
|
|
# reserve_tpm_tokens path below. Without this, every concurrent
|
|
# in-flight request would pre-inflate the :tokens counter by 1,
|
|
# shrinking the effective TPM budget by N and causing
|
|
# false-positive 429s under bursts. When reservation is disabled,
|
|
# this pass enforces TPM directly from the post-call counters --
|
|
# except for project ITPM/OTPM descriptors, which are excluded
|
|
# then because _reserve_project_io_tokens_or_raise below charges
|
|
# them unconditionally and counting them here too would
|
|
# double-charge every request.
|
|
parallel_counter_keys: Final = [
|
|
self.create_rate_limit_keys(d["key"], d["value"], "max_parallel_requests")
|
|
for d in descriptors
|
|
if (d.get("rate_limit") or {}).get("max_parallel_requests") is not None
|
|
]
|
|
parallel_slot_id: Final = uuid.uuid4().hex if parallel_counter_keys else None
|
|
|
|
first_pass_descriptors: Final = (
|
|
descriptors
|
|
if self.tpm_reservation_enabled
|
|
else tuple(
|
|
d for d in descriptors if d["key"] not in (PROJECT_ITPM_DESCRIPTOR_KEY, PROJECT_OTPM_DESCRIPTOR_KEY)
|
|
)
|
|
)
|
|
response: Final = await self.should_rate_limit(
|
|
descriptors=first_pass_descriptors,
|
|
parent_otel_span=user_api_key_dict.parent_otel_span,
|
|
skip_tpm_check=self.tpm_reservation_enabled,
|
|
parallel_slot_id=parallel_slot_id,
|
|
)
|
|
|
|
if response["overall_code"] == "OVER_LIMIT":
|
|
self._handle_rate_limit_error(
|
|
response=response,
|
|
descriptors=descriptors,
|
|
requested_model=requested_model,
|
|
)
|
|
else:
|
|
stash.rate_limit_response = response
|
|
if parallel_slot_id is not None:
|
|
stash.parallel_slot = ParallelSlotAcquisition(
|
|
slot_id=parallel_slot_id,
|
|
counter_keys=parallel_counter_keys,
|
|
)
|
|
|
|
# ----------------------------------------------------------------
|
|
# TPM token reservation
|
|
# Atomically reserve estimated tokens upfront so concurrent
|
|
# requests cannot all observe "under limit" before any of them
|
|
# has incremented the counter. atomic_check_and_increment_by_n
|
|
# uses Redis Lua when available and falls back to an asyncio-locked
|
|
# in-memory check otherwise — single-worker protection still holds
|
|
# even without Redis.
|
|
# ----------------------------------------------------------------
|
|
configured_tpm_limits: Final = [
|
|
int(v)
|
|
for d in descriptors
|
|
if d["key"] not in (PROJECT_ITPM_DESCRIPTOR_KEY, PROJECT_OTPM_DESCRIPTOR_KEY)
|
|
for v in [(d.get("rate_limit") or {}).get("tokens_per_unit")]
|
|
if v is not None
|
|
]
|
|
has_tpm_limits: Final = bool(configured_tpm_limits)
|
|
|
|
# Populated on a successful combined-TPM reservation below, so the
|
|
# project ITPM/OTPM block further down can roll it back if a
|
|
# different bucket in the same request subsequently hits its
|
|
# limit. Stays empty/0 whenever no combined-TPM reservation was
|
|
# made (or it was over limit, in which case execution never
|
|
# reaches the ITPM/OTPM block -- `_handle_rate_limit_error` raises).
|
|
tpm_reservation_scopes: Sequence[tuple[str, str]] = () # rebind-ok: set after successful reservation
|
|
tpm_reservation_amount = 0 # rebind-ok: set after successful reservation
|
|
|
|
if has_tpm_limits and self.tpm_reservation_enabled:
|
|
min_configured_tpm_limit: Final = min(configured_tpm_limits)
|
|
|
|
configured_output_tokens: Final = get_estimated_output_tokens(
|
|
user_api_key_dict=user_api_key_dict,
|
|
model_name=requested_model,
|
|
)
|
|
|
|
# When the configured TPM cap is small enough to constrain the
|
|
# no-max_tokens floor, also hard-cap the model output so
|
|
# concurrent unbounded generations can't spend past the limit
|
|
# before post-call reconciliation runs.
|
|
self._apply_implicit_output_cap(
|
|
data=data,
|
|
min_configured_limit=min_configured_tpm_limit,
|
|
call_type=call_type,
|
|
configured_output_tokens=configured_output_tokens,
|
|
)
|
|
|
|
# Floor at 1 token so contentless requests (/responses,
|
|
# tool-call continuations, empty messages) still flow
|
|
# through the atomic counter and get backpressure when at
|
|
# limit. Without this floor, N concurrent contentless
|
|
# requests would all pass pre-call with no enforcement.
|
|
# Post-call reconciliation refunds the over-reservation
|
|
# delta when actual usage comes in below the floor.
|
|
estimated_tokens: Final = max(
|
|
self._estimate_tokens_for_request(
|
|
data=data,
|
|
model=requested_model,
|
|
min_configured_tpm_limit=min_configured_tpm_limit,
|
|
call_type=call_type,
|
|
configured_output_tokens=configured_output_tokens,
|
|
),
|
|
1,
|
|
)
|
|
|
|
if configured_output_tokens is not None and estimated_tokens > min_configured_tpm_limit:
|
|
verbose_proxy_logger.debug(
|
|
"Reserving %s tokens for model %s (declared %s=%s plus the input estimate) exceeds the "
|
|
"smallest TPM limit this request is charged against (%s), so it cannot be admitted even "
|
|
"against an empty window. Lower the declared estimate or raise the TPM limit.",
|
|
estimated_tokens,
|
|
requested_model,
|
|
ESTIMATED_OUTPUT_TOKENS_FIELD,
|
|
configured_output_tokens,
|
|
min_configured_tpm_limit,
|
|
)
|
|
|
|
tpm_response: Final = await self.reserve_tpm_tokens(
|
|
descriptors=descriptors,
|
|
estimated_tokens=estimated_tokens,
|
|
parent_otel_span=user_api_key_dict.parent_otel_span,
|
|
)
|
|
|
|
if tpm_response["overall_code"] == "OVER_LIMIT":
|
|
acquisition: Final = stash.parallel_slot
|
|
if acquisition is not None:
|
|
await self._release_parallel_request_slots(
|
|
acquisition=acquisition,
|
|
parent_otel_span=user_api_key_dict.parent_otel_span,
|
|
)
|
|
stash.parallel_slot = None
|
|
self._handle_rate_limit_error(
|
|
response=tpm_response,
|
|
descriptors=descriptors,
|
|
requested_model=requested_model,
|
|
)
|
|
else:
|
|
# Capture the exact (key, value) scopes the reservation
|
|
# incremented so post-call reconciliation only applies
|
|
# the (actual - reserved) delta to those — unreserved
|
|
# scopes get charged the full actual usage instead.
|
|
stash.reserved_tokens = estimated_tokens
|
|
stash.reserved_model = requested_model
|
|
stash.reserved_scopes = frozenset(
|
|
(d["key"], d["value"])
|
|
for d in descriptors
|
|
if d["key"] not in (PROJECT_ITPM_DESCRIPTOR_KEY, PROJECT_OTPM_DESCRIPTOR_KEY)
|
|
and (d.get("rate_limit") or {}).get( # mutable-ok: optional descriptor fallback
|
|
"tokens_per_unit"
|
|
)
|
|
is not None
|
|
)
|
|
tpm_reservation_scopes = tuple( # rebind-ok: record successful reservation scopes
|
|
stash.reserved_scopes
|
|
)
|
|
tpm_reservation_amount = estimated_tokens # rebind-ok: record successful reservation amount
|
|
|
|
# Merge TPM statuses into the stored rate-limit response
|
|
# so x-ratelimit-{key}-remaining-tokens / -limit-tokens
|
|
# headers reach the client. Without this, the RPM-only
|
|
# response from should_rate_limit (skip_tpm_check=True)
|
|
# silently drops all token headers.
|
|
stored_response: Final = stash.rate_limit_response
|
|
if stored_response is not None:
|
|
stored_response["statuses"].extend(tpm_response["statuses"])
|
|
|
|
verbose_proxy_logger.debug(
|
|
"TPM tokens reserved: %s for model %s", estimated_tokens, requested_model
|
|
)
|
|
await self._reserve_project_io_tokens_or_raise(
|
|
descriptors=descriptors,
|
|
data=data,
|
|
requested_model=requested_model,
|
|
user_api_key_dict=user_api_key_dict,
|
|
tpm_reservation_scopes=tpm_reservation_scopes,
|
|
tpm_reservation_amount=tpm_reservation_amount,
|
|
call_type=call_type,
|
|
)
|
|
|
|
def _create_pipeline_operations(
|
|
self,
|
|
key: str,
|
|
value: str,
|
|
rate_limit_type: Literal["requests", "tokens", "max_parallel_requests"],
|
|
total_tokens: int,
|
|
) -> list["RedisPipelineIncrementOperation"]:
|
|
"""
|
|
Create pipeline operations for TPM increments
|
|
"""
|
|
pipeline_operations: Final[list[RedisPipelineIncrementOperation]] = []
|
|
counter_key: Final = self.create_rate_limit_keys(
|
|
key=key,
|
|
value=value,
|
|
rate_limit_type="tokens",
|
|
)
|
|
pipeline_operations.append(
|
|
RedisPipelineIncrementOperation(
|
|
key=counter_key,
|
|
increment_value=total_tokens,
|
|
ttl=self.window_size,
|
|
)
|
|
)
|
|
|
|
return pipeline_operations
|
|
|
|
def _get_total_tokens_from_usage(
|
|
self, usage: Any | None, rate_limit_type: Literal["output", "input", "total"]
|
|
) -> int:
|
|
"""
|
|
Get total tokens from response usage for rate limiting.
|
|
|
|
For 'input' and 'total' rate limit types, cached tokens are excluded
|
|
because providers like AWS Bedrock don't count cached tokens toward
|
|
rate limits. This aligns LiteLLM's TPM calculation with provider behavior.
|
|
"""
|
|
total_tokens = 0
|
|
cached_tokens = 0
|
|
|
|
if usage:
|
|
if isinstance(usage, Usage):
|
|
if rate_limit_type == "output":
|
|
total_tokens = usage.completion_tokens or 0
|
|
elif rate_limit_type == "input":
|
|
total_tokens = usage.prompt_tokens or 0
|
|
elif rate_limit_type == "total":
|
|
total_tokens = usage.total_tokens or 0
|
|
|
|
# Get cached tokens to exclude from input/total
|
|
if rate_limit_type in ("input", "total"):
|
|
if hasattr(usage, "prompt_tokens_details") and usage.prompt_tokens_details is not None:
|
|
cached_tokens = getattr(usage.prompt_tokens_details, "cached_tokens", 0) or 0
|
|
|
|
elif isinstance(usage, dict):
|
|
# Responses API usage comes as a dict
|
|
if rate_limit_type == "output":
|
|
total_tokens = usage.get("completion_tokens", 0) or 0
|
|
elif rate_limit_type == "input":
|
|
total_tokens = usage.get("prompt_tokens", 0) or 0
|
|
elif rate_limit_type == "total":
|
|
total_tokens = usage.get("total_tokens", 0) or 0
|
|
|
|
# Get cached tokens from dict
|
|
if rate_limit_type in ("input", "total"):
|
|
prompt_details: Final = usage.get("prompt_tokens_details") or {}
|
|
if isinstance(prompt_details, dict):
|
|
cached_tokens = prompt_details.get("cached_tokens", 0) or 0
|
|
|
|
# Subtract cached tokens for input/total (providers don't count them)
|
|
if cached_tokens > 0:
|
|
total_tokens = max(0, total_tokens - cached_tokens)
|
|
|
|
return total_tokens
|
|
|
|
@staticmethod
|
|
def _aggregate_only_total_tokens(usage: Usage | ResponseAPIUsage | Mapping[str, object] | None) -> int:
|
|
"""Total for usage that carries no input/output split, else 0.
|
|
|
|
A source that can only report one number for the whole request (a
|
|
pass-through target pricing its own multi-model call) charges that
|
|
number under every ``token_rate_limit_type``. Splitting it is
|
|
impossible, and reading 0 out of it would leave the window
|
|
uncharged, which is how pass-through traffic slips past a TPM limit
|
|
it is supposed to share.
|
|
"""
|
|
if usage is None:
|
|
return 0
|
|
token_counts: Final = (
|
|
(usage.prompt_tokens or 0, usage.completion_tokens or 0, usage.total_tokens or 0)
|
|
if isinstance(usage, Usage)
|
|
else (usage.input_tokens or 0, usage.output_tokens or 0, usage.total_tokens or 0)
|
|
if isinstance(usage, ResponseAPIUsage)
|
|
else (
|
|
usage.get("prompt_tokens") or usage.get("input_tokens") or 0,
|
|
usage.get("completion_tokens") or usage.get("output_tokens") or 0,
|
|
usage.get("total_tokens") or 0,
|
|
)
|
|
)
|
|
prompt_tokens, completion_tokens, total_tokens = token_counts
|
|
if prompt_tokens or completion_tokens or not isinstance(total_tokens, int):
|
|
return 0
|
|
return total_tokens
|
|
|
|
@staticmethod
|
|
def _response_usage(
|
|
response_obj: object,
|
|
) -> Usage | ResponseAPIUsage | Mapping[str, object] | None:
|
|
if isinstance(response_obj, (Usage, ResponseAPIUsage)):
|
|
return response_obj
|
|
if isinstance(
|
|
response_obj,
|
|
(ModelResponse, EmbeddingResponse, TextCompletionResponse, BaseLiteLLMOpenAIResponseObject),
|
|
):
|
|
usage: Final = getattr(response_obj, "usage", None)
|
|
return usage if isinstance(usage, (Usage, ResponseAPIUsage, dict)) else None
|
|
if isinstance(response_obj, dict):
|
|
nested_usage: Final = response_obj.get("usage")
|
|
if isinstance(nested_usage, (Usage, ResponseAPIUsage, dict)):
|
|
return nested_usage
|
|
return response_obj
|
|
return None
|
|
|
|
async def _execute_token_increment_script(
|
|
self,
|
|
pipeline_operations: list["RedisPipelineIncrementOperation"],
|
|
) -> None:
|
|
"""
|
|
Execute token increment script grouped by hash tag for cluster compatibility.
|
|
"""
|
|
if self.token_increment_script is None:
|
|
return
|
|
|
|
# Group operations by hash tag for Redis cluster compatibility
|
|
operation_keys: Final = [op["key"] for op in pipeline_operations]
|
|
key_groups: Final = self._group_keys_by_hash_tag(operation_keys)
|
|
|
|
for _hash_tag, group_keys in key_groups.items():
|
|
# Get operations for this hash tag group
|
|
group_operations = [op for op in pipeline_operations if op["key"] in group_keys]
|
|
|
|
keys = []
|
|
args = []
|
|
|
|
for op in group_operations:
|
|
# Convert None TTL to 0 for Lua script
|
|
ttl_value = op["ttl"] if op["ttl"] is not None else 0
|
|
|
|
verbose_proxy_logger.debug(
|
|
"Executing TTL-preserving increment for key=%s, increment=%s, ttl=%s",
|
|
op["key"],
|
|
op["increment_value"],
|
|
ttl_value,
|
|
)
|
|
keys.append(op["key"])
|
|
args.extend([op["increment_value"], ttl_value])
|
|
|
|
await self.token_increment_script(
|
|
keys=keys,
|
|
args=args,
|
|
)
|
|
|
|
async def async_increment_tokens_with_ttl_preservation(
|
|
self,
|
|
pipeline_operations: list["RedisPipelineIncrementOperation"],
|
|
parent_otel_span: Span | None = None,
|
|
) -> None:
|
|
"""
|
|
Increment token counters using Lua script to preserve existing TTL.
|
|
This prevents TTL reset on every token increment.
|
|
"""
|
|
if not pipeline_operations:
|
|
return
|
|
|
|
# Check if script is available
|
|
if self.token_increment_script is None:
|
|
verbose_proxy_logger.debug("TTL preservation script not available, using regular pipeline")
|
|
await self.internal_usage_cache.dual_cache.async_increment_cache_pipeline(
|
|
increment_list=pipeline_operations,
|
|
litellm_parent_otel_span=parent_otel_span,
|
|
)
|
|
return
|
|
|
|
try:
|
|
await self._execute_token_increment_script(pipeline_operations)
|
|
|
|
verbose_proxy_logger.debug(
|
|
"Successfully executed TTL-preserving increment for %s keys", len(pipeline_operations)
|
|
)
|
|
|
|
except Exception as e:
|
|
verbose_proxy_logger.warning("TTL preservation failed, falling back to regular pipeline: %s", e)
|
|
# Fallback to regular pipeline on error
|
|
await self.internal_usage_cache.dual_cache.async_increment_cache_pipeline(
|
|
increment_list=pipeline_operations,
|
|
litellm_parent_otel_span=parent_otel_span,
|
|
)
|
|
|
|
async def _apply_local_window_guarded_token_increments(
|
|
self,
|
|
operations: Sequence[ReservationAwareIncrementOperation],
|
|
parent_otel_span: Span | None = None,
|
|
) -> None:
|
|
async with self._check_and_increment_lock:
|
|
for operation in operations:
|
|
window_key = operation.get("window_key")
|
|
expected_window_start = operation.get("expected_window_start")
|
|
if window_key is None or expected_window_start is None:
|
|
continue
|
|
active_window_start: CacheCounterValue | None = await self.internal_usage_cache.async_get_cache(
|
|
key=window_key,
|
|
litellm_parent_otel_span=parent_otel_span,
|
|
local_only=True,
|
|
)
|
|
if active_window_start is None or str(active_window_start) != expected_window_start:
|
|
continue
|
|
current_counter = (
|
|
await self.internal_usage_cache.async_get_cache(
|
|
key=operation["key"],
|
|
litellm_parent_otel_span=parent_otel_span,
|
|
local_only=True,
|
|
)
|
|
or 0
|
|
)
|
|
await self.internal_usage_cache.async_set_cache(
|
|
key=operation["key"],
|
|
value=float(current_counter) + operation["increment_value"],
|
|
ttl=operation["ttl"],
|
|
litellm_parent_otel_span=parent_otel_span,
|
|
local_only=True,
|
|
)
|
|
|
|
async def _apply_redis_window_guarded_token_increments(
|
|
self,
|
|
operations: Sequence[ReservationAwareIncrementOperation],
|
|
parent_otel_span: Span | None = None,
|
|
) -> None:
|
|
for operation in operations:
|
|
window_key = operation.get("window_key")
|
|
expected_window_start = operation.get("expected_window_start")
|
|
if window_key is None or expected_window_start is None:
|
|
continue
|
|
if self.window_guarded_token_increment_script is not None:
|
|
try:
|
|
await self.window_guarded_token_increment_script(
|
|
keys=[ # mutable-ok: Redis script interface requires a key list
|
|
window_key,
|
|
operation["key"],
|
|
],
|
|
args=[ # mutable-ok: Redis script interface requires an argument list
|
|
expected_window_start,
|
|
operation["increment_value"],
|
|
operation["ttl"] or 0,
|
|
],
|
|
)
|
|
continue
|
|
except Exception as e: # noqa: BLE001 # Redis failures use the plain increment fallback
|
|
verbose_proxy_logger.warning(
|
|
"Window-guarded token adjustment failed for %s: %s",
|
|
operation["key"],
|
|
e,
|
|
)
|
|
if operation["increment_value"] > 0:
|
|
await self.internal_usage_cache.async_increment_cache(
|
|
key=operation["key"],
|
|
value=operation["increment_value"],
|
|
litellm_parent_otel_span=parent_otel_span,
|
|
ttl=operation["ttl"],
|
|
)
|
|
|
|
async def async_increment_reservation_aware_tokens(
|
|
self,
|
|
pipeline_operations: Sequence[ReservationAwareIncrementOperation],
|
|
parent_otel_span: Span | None = None,
|
|
) -> None:
|
|
for operation in pipeline_operations:
|
|
if operation.get("window_key") is None or operation.get("expected_window_start") is None:
|
|
await self.internal_usage_cache.async_increment_cache(
|
|
key=operation["key"],
|
|
value=operation["increment_value"],
|
|
litellm_parent_otel_span=parent_otel_span,
|
|
ttl=operation["ttl"],
|
|
)
|
|
local_guarded_operations: Final = tuple(
|
|
operation
|
|
for operation in pipeline_operations
|
|
if operation.get("window_key") is not None
|
|
and operation.get("expected_window_start") is not None
|
|
and operation.get("reservation_backend") == "local"
|
|
)
|
|
redis_guarded_operations: Final = tuple(
|
|
operation
|
|
for operation in pipeline_operations
|
|
if operation.get("window_key") is not None
|
|
and operation.get("expected_window_start") is not None
|
|
and operation.get("reservation_backend") != "local"
|
|
)
|
|
if local_guarded_operations:
|
|
await self._apply_local_window_guarded_token_increments(
|
|
operations=local_guarded_operations,
|
|
parent_otel_span=parent_otel_span,
|
|
)
|
|
if redis_guarded_operations:
|
|
await self._apply_redis_window_guarded_token_increments(
|
|
operations=redis_guarded_operations,
|
|
parent_otel_span=parent_otel_span,
|
|
)
|
|
|
|
def get_rate_limit_type(self) -> Literal["output", "input", "total"]:
|
|
from litellm.proxy.proxy_server import general_settings
|
|
|
|
specified_rate_limit_type: Final = general_settings.get("token_rate_limit_type", "total")
|
|
if specified_rate_limit_type not in [
|
|
"output",
|
|
"input",
|
|
"total",
|
|
]:
|
|
return "total" # default to total
|
|
return specified_rate_limit_type
|
|
|
|
@staticmethod
|
|
def _merge_ratelimit_statuses_into_additional_headers(
|
|
additional_headers: dict[str, object],
|
|
statuses: list[RateLimitStatus],
|
|
) -> dict[str, object]:
|
|
"""
|
|
Return ``additional_headers`` extended with
|
|
``x-ratelimit-{descriptor_key}-{remaining|limit}-{rate_limit_type}``
|
|
entries. Non-mutating so callers pick their own target dict.
|
|
"""
|
|
merged: Final[dict[str, object]] = dict(additional_headers)
|
|
for status in statuses:
|
|
prefix = f"x-ratelimit-{status['descriptor_key']}"
|
|
merged[f"{prefix}-remaining-{status['rate_limit_type']}"] = status["limit_remaining"]
|
|
merged[f"{prefix}-limit-{status['rate_limit_type']}"] = status["current_limit"]
|
|
return merged
|
|
|
|
@staticmethod
|
|
def _resolve_rerank_token_usage(response_obj: object) -> tuple[int, int, bool] | None:
|
|
if not isinstance(response_obj, RerankResponse) or response_obj.meta is None:
|
|
return None
|
|
|
|
rerank_tokens: Final = response_obj.meta.get("tokens") # pyright: ignore[reportUnknownMemberType] # TypedDict's optional generic metadata widens get overloads
|
|
if rerank_tokens is not None:
|
|
input_tokens: Final = rerank_tokens.get("input_tokens") or 0 # pyright: ignore[reportUnknownMemberType] # token fields are typed integers despite the generic get overload
|
|
output_tokens: Final = rerank_tokens.get("output_tokens") or 0 # pyright: ignore[reportUnknownMemberType] # token fields are typed integers despite the generic get overload
|
|
if input_tokens or output_tokens:
|
|
return max(0, input_tokens), max(0, output_tokens), True
|
|
|
|
billed_units: Final = response_obj.meta.get("billed_units") # pyright: ignore[reportUnknownMemberType] # TypedDict's optional generic metadata widens get overloads
|
|
if billed_units is not None:
|
|
total_tokens: Final = billed_units.get("total_tokens") or 0 # pyright: ignore[reportUnknownMemberType] # billed total is a typed integer despite the generic get overload
|
|
if total_tokens:
|
|
return max(0, total_tokens), 0, True
|
|
return None
|
|
|
|
def _resolve_io_token_reconcile_usage(
|
|
self,
|
|
response_obj: object,
|
|
) -> tuple[int, int, bool]:
|
|
"""
|
|
Resolve ``(billable_input_tokens, completion_tokens, usage_resolved)``
|
|
for ITPM/OTPM reconciliation. Cache-read tokens are excluded from
|
|
billable input -- Bedrock Mantle doesn't count them toward ITPM --
|
|
but they're untouched everywhere else (cost/usage logging still sees
|
|
the full prompt token count).
|
|
"""
|
|
rerank_usage: Final = self._resolve_rerank_token_usage(response_obj)
|
|
if rerank_usage is not None:
|
|
return rerank_usage
|
|
|
|
usage: Final = self._response_usage(response_obj)
|
|
|
|
if isinstance(usage, Usage):
|
|
prompt_tokens: Final = usage.prompt_tokens or 0
|
|
completion_tokens: Final = usage.completion_tokens or 0
|
|
cached_tokens: Final = (
|
|
getattr(usage.prompt_tokens_details, "cached_tokens", 0) or 0
|
|
if usage.prompt_tokens_details is not None
|
|
else 0
|
|
)
|
|
if prompt_tokens == 0 and completion_tokens == 0:
|
|
return 0, 0, False
|
|
return max(0, prompt_tokens - cached_tokens), completion_tokens, True
|
|
|
|
if isinstance(usage, ResponseAPIUsage):
|
|
response_input_tokens: Final = usage.input_tokens or 0
|
|
response_output_tokens: Final = usage.output_tokens or 0
|
|
response_cached_tokens: Final = (
|
|
usage.input_tokens_details.cached_tokens or 0 if usage.input_tokens_details is not None else 0
|
|
)
|
|
if response_input_tokens == 0 and response_output_tokens == 0:
|
|
return 0, 0, False
|
|
return max(0, response_input_tokens - response_cached_tokens), response_output_tokens, True
|
|
|
|
if isinstance(usage, Mapping):
|
|
raw_prompt_tokens: Final = usage.get("prompt_tokens") or usage.get("input_tokens") or 0
|
|
raw_completion_tokens: Final = usage.get("completion_tokens") or usage.get("output_tokens") or 0
|
|
mapped_prompt_tokens: Final = raw_prompt_tokens if isinstance(raw_prompt_tokens, int) else 0
|
|
mapped_completion_tokens: Final = raw_completion_tokens if isinstance(raw_completion_tokens, int) else 0
|
|
prompt_details: Final = usage.get("prompt_tokens_details") or usage.get("input_tokens_details")
|
|
raw_cached_tokens: Final = (
|
|
(prompt_details.get("cached_tokens", 0) if isinstance(prompt_details, dict) else 0)
|
|
or usage.get("cache_read_input_tokens")
|
|
or 0
|
|
)
|
|
mapped_cached_tokens: Final = raw_cached_tokens if isinstance(raw_cached_tokens, int) else 0
|
|
if mapped_prompt_tokens == 0 and mapped_completion_tokens == 0:
|
|
return 0, 0, False
|
|
return max(0, mapped_prompt_tokens - mapped_cached_tokens), mapped_completion_tokens, True
|
|
|
|
return 0, 0, False
|
|
|
|
def _build_io_token_reservation_ops(
|
|
self,
|
|
kwargs: object,
|
|
response_obj: object,
|
|
) -> Sequence[RedisPipelineIncrementOperation]:
|
|
"""
|
|
Reconcile project ITPM/OTPM reservations to actual usage on success:
|
|
ITPM to billable input tokens, OTPM to actual completion tokens.
|
|
Reuses ``_build_reservation_aware_tpm_ops``'s delta pattern -- ITPM/OTPM
|
|
are stored in the same ":tokens" cache bucket as combined TPM, just
|
|
under distinct scope keys, so the reservation-aware increment math is
|
|
identical; only the usage fields being reconciled against differ.
|
|
"""
|
|
if not isinstance(kwargs, dict):
|
|
return ()
|
|
stash: Final = get_request_stash_for_call(_call_id_from_callback_kwargs(kwargs))
|
|
if stash is None:
|
|
return ()
|
|
|
|
itpm_reserved: Final = stash.itpm_reserved_tokens
|
|
otpm_reserved: Final = stash.otpm_reserved_tokens
|
|
if itpm_reserved <= 0 and otpm_reserved <= 0:
|
|
return ()
|
|
|
|
response_usage: Final = self._resolve_io_token_reconcile_usage(response_obj)
|
|
combined_usage: Final = self._resolve_io_token_reconcile_usage(kwargs.get("combined_usage_object"))
|
|
aggregate_total: Final = self._aggregate_only_total_tokens(
|
|
self._response_usage(response_obj)
|
|
) or self._aggregate_only_total_tokens(self._response_usage(kwargs.get("combined_usage_object")))
|
|
|
|
if not response_usage[2] and not combined_usage[2] and aggregate_total <= 0 and not stash.reservation_released:
|
|
return ()
|
|
resolved_usage: Final = (
|
|
response_usage
|
|
if response_usage[2]
|
|
else combined_usage
|
|
if combined_usage[2]
|
|
else (aggregate_total, aggregate_total, True)
|
|
if aggregate_total > 0
|
|
else (itpm_reserved, otpm_reserved, False)
|
|
)
|
|
billable_input, completion_tokens, _ = resolved_usage
|
|
|
|
if stash.reservation_released or (
|
|
not stash.itpm_reserved_window_identities and not stash.otpm_reserved_window_identities
|
|
):
|
|
return self._build_reservation_aware_tpm_ops(
|
|
targets=tuple(stash.itpm_reserved_scopes),
|
|
reserved_scopes=frozenset() if stash.reservation_released else stash.itpm_reserved_scopes,
|
|
actual_tokens=billable_input,
|
|
reserved_tokens=0 if stash.reservation_released else itpm_reserved,
|
|
) + self._build_reservation_aware_tpm_ops(
|
|
targets=tuple(stash.otpm_reserved_scopes),
|
|
reserved_scopes=frozenset() if stash.reservation_released else stash.otpm_reserved_scopes,
|
|
actual_tokens=completion_tokens,
|
|
reserved_tokens=0 if stash.reservation_released else otpm_reserved,
|
|
)
|
|
|
|
itpm_ops: Final[Sequence[ReservationAwareIncrementOperation]] = (
|
|
self._build_project_reservation_ops(
|
|
targets=tuple(stash.itpm_reserved_scopes),
|
|
reserved_scopes=frozenset() if stash.reservation_released else stash.itpm_reserved_scopes,
|
|
actual_tokens=billable_input,
|
|
reserved_tokens=itpm_reserved,
|
|
reservation_window_identities=stash.itpm_reserved_window_identities,
|
|
)
|
|
if itpm_reserved > 0
|
|
else ()
|
|
)
|
|
otpm_ops: Final[Sequence[ReservationAwareIncrementOperation]] = (
|
|
self._build_project_reservation_ops(
|
|
targets=tuple(stash.otpm_reserved_scopes),
|
|
reserved_scopes=frozenset() if stash.reservation_released else stash.otpm_reserved_scopes,
|
|
actual_tokens=completion_tokens,
|
|
reserved_tokens=otpm_reserved,
|
|
reservation_window_identities=stash.otpm_reserved_window_identities,
|
|
)
|
|
if otpm_reserved > 0
|
|
else ()
|
|
)
|
|
return tuple((*itpm_ops, *otpm_ops))
|
|
|
|
def _collect_tpm_scope_targets(
|
|
self,
|
|
standard_logging_metadata: dict[str, Any],
|
|
kwargs: object,
|
|
model_group: str | None,
|
|
) -> list[tuple[str, str]]:
|
|
"""
|
|
Enumerate every (scope_key, scope_value) pair that *might* carry a
|
|
TPM counter for this request — independent of whether each scope had
|
|
a configured TPM limit at pre-call. Reservation awareness happens at
|
|
the emitter; this helper just lists the candidate scopes so callers
|
|
can split reserved-vs-unreserved.
|
|
"""
|
|
user_api_key: Final = standard_logging_metadata.get("user_api_key_hash")
|
|
user_api_key_user_id: Final = standard_logging_metadata.get("user_api_key_user_id")
|
|
user_api_key_team_id: Final = standard_logging_metadata.get("user_api_key_team_id")
|
|
user_api_key_organization_id: Final = standard_logging_metadata.get("user_api_key_org_id")
|
|
user_api_key_project_id: Final = standard_logging_metadata.get("user_api_key_project_id")
|
|
user_api_key_end_user_id: Final = (
|
|
kwargs.get("user") if isinstance(kwargs, dict) else None
|
|
) or standard_logging_metadata.get("user_api_key_end_user_id")
|
|
agent_id: Final = standard_logging_metadata.get("agent_id")
|
|
session_id: Final = standard_logging_metadata.get("session_id") or standard_logging_metadata.get("trace_id")
|
|
|
|
targets: Final[list[tuple[str, str]]] = []
|
|
if user_api_key:
|
|
targets.append(("api_key", user_api_key))
|
|
if user_api_key_user_id:
|
|
targets.append(("user", user_api_key_user_id))
|
|
if user_api_key_team_id:
|
|
targets.append(("team", user_api_key_team_id))
|
|
if user_api_key_team_id and user_api_key_user_id:
|
|
targets.append(("team_member", f"{user_api_key_team_id}:{user_api_key_user_id}"))
|
|
if user_api_key_end_user_id:
|
|
targets.append(("end_user", user_api_key_end_user_id))
|
|
if user_api_key_organization_id:
|
|
targets.append(("organization", user_api_key_organization_id))
|
|
if model_group:
|
|
if user_api_key:
|
|
targets.append(("model_per_key", f"{user_api_key}:{model_group}"))
|
|
if user_api_key_team_id:
|
|
targets.append(("model_per_team", f"{user_api_key_team_id}:{model_group}"))
|
|
if user_api_key_organization_id:
|
|
targets.append(
|
|
(
|
|
"model_per_organization",
|
|
f"{user_api_key_organization_id}:{model_group}",
|
|
)
|
|
)
|
|
if user_api_key_project_id:
|
|
targets.append(
|
|
(
|
|
"model_per_project",
|
|
f"{user_api_key_project_id}:{model_group}",
|
|
)
|
|
)
|
|
if agent_id:
|
|
targets.append(("agent", agent_id))
|
|
if session_id:
|
|
targets.append(("agent_session", f"{agent_id}:{session_id}"))
|
|
return targets
|
|
|
|
def _build_reservation_aware_tpm_ops(
|
|
self,
|
|
targets: Sequence[tuple[str, str]],
|
|
reserved_scopes: Set[tuple[str, str]],
|
|
actual_tokens: int,
|
|
reserved_tokens: int,
|
|
) -> list[RedisPipelineIncrementOperation]:
|
|
"""
|
|
Emit per-scope TPM increment ops with reservation awareness.
|
|
|
|
- Reserved scope (counter already at +reserved from pre-call):
|
|
reconcile to actual via ``actual - reserved``.
|
|
- Unreserved scope (counter never touched at pre-call):
|
|
charge the full ``actual``.
|
|
|
|
Same primitive serves success reconciliation, over-reservation
|
|
release, and failure refund — pass ``actual_tokens=0`` for the pure
|
|
refund case (reserved scopes get -reserved, unreserved get 0/skip).
|
|
"""
|
|
ops: Final[list[RedisPipelineIncrementOperation]] = []
|
|
for scope_key, scope_value in targets:
|
|
if (scope_key, scope_value) in reserved_scopes:
|
|
increment = actual_tokens - reserved_tokens
|
|
else:
|
|
increment = actual_tokens
|
|
if increment == 0:
|
|
continue
|
|
ops.append(
|
|
RedisPipelineIncrementOperation(
|
|
key=self.create_rate_limit_keys(scope_key, scope_value, "tokens"),
|
|
increment_value=increment,
|
|
ttl=self.window_size,
|
|
)
|
|
)
|
|
return ops
|
|
|
|
def _build_project_reservation_op(
|
|
self,
|
|
scope: tuple[str, str],
|
|
reserved_scopes: Set[tuple[str, str]],
|
|
actual_tokens: int,
|
|
reserved_tokens: int,
|
|
reservation_window_identities: frozenset[tuple[str, str, Literal["redis", "local"]]],
|
|
) -> ReservationAwareIncrementOperation | None:
|
|
scope_key, scope_value = scope
|
|
is_reserved_scope: Final = scope in reserved_scopes
|
|
increment: Final = actual_tokens - reserved_tokens if is_reserved_scope else actual_tokens
|
|
if increment == 0:
|
|
return None
|
|
counter_key: Final = self.create_rate_limit_keys(scope_key, scope_value, "tokens")
|
|
window_identity: Final = next(
|
|
(
|
|
(window_start, backend)
|
|
for identity_counter_key, window_start, backend in reservation_window_identities
|
|
if identity_counter_key == counter_key
|
|
),
|
|
None,
|
|
)
|
|
if not is_reserved_scope or window_identity is None:
|
|
return ReservationAwareIncrementOperation(
|
|
key=counter_key,
|
|
increment_value=increment,
|
|
ttl=self.window_size,
|
|
)
|
|
return ReservationAwareIncrementOperation(
|
|
key=counter_key,
|
|
increment_value=increment,
|
|
ttl=self.window_size,
|
|
window_key=f"{{{scope_key}:{scope_value}}}:window",
|
|
expected_window_start=window_identity[0],
|
|
reservation_backend=window_identity[1],
|
|
)
|
|
|
|
def _build_project_reservation_ops(
|
|
self,
|
|
targets: Sequence[tuple[str, str]],
|
|
reserved_scopes: Set[tuple[str, str]],
|
|
actual_tokens: int,
|
|
reserved_tokens: int,
|
|
reservation_window_identities: frozenset[tuple[str, str, Literal["redis", "local"]]],
|
|
) -> tuple[ReservationAwareIncrementOperation, ...]:
|
|
return tuple(
|
|
operation
|
|
for scope in targets
|
|
if (
|
|
operation := self._build_project_reservation_op(
|
|
scope=scope,
|
|
reserved_scopes=reserved_scopes,
|
|
actual_tokens=actual_tokens,
|
|
reserved_tokens=reserved_tokens,
|
|
reservation_window_identities=reservation_window_identities,
|
|
)
|
|
)
|
|
is not None
|
|
)
|
|
|
|
def _build_success_event_pipeline_operations(
|
|
self,
|
|
kwargs: dict[str, Any],
|
|
response_obj: object,
|
|
rate_limit_type: Literal["output", "input", "total"],
|
|
) -> list[RedisPipelineIncrementOperation]:
|
|
"""Build Redis pipeline increment ops for TPM / parallel-request counters."""
|
|
from litellm.litellm_core_utils.core_helpers import get_litellm_metadata_from_kwargs
|
|
from litellm.proxy.common_utils.callback_utils import (
|
|
get_model_group_from_litellm_kwargs,
|
|
)
|
|
|
|
# Get metadata from standard_logging_object - this correctly handles both
|
|
# 'metadata' and 'litellm_metadata' fields from litellm_params
|
|
standard_logging_object: Final = kwargs.get("standard_logging_object") or {}
|
|
request_metadata: Final = get_litellm_metadata_from_kwargs(kwargs)
|
|
if request_metadata.get(INTERNAL_CALL_ORIGIN_METADATA_KEY):
|
|
# Internal sub-calls bill spend to the caller but are not the caller's
|
|
# traffic; charging them here would let background evals eat TPM headroom.
|
|
return []
|
|
standard_logging_metadata: Final = standard_logging_object.get("metadata") or {}
|
|
|
|
model_group: Final = get_model_group_from_litellm_kwargs(kwargs)
|
|
|
|
# Get total tokens from response. Responses LiteLLM does not model
|
|
# (e.g. pass-through, whose usage is reported by the upstream rather
|
|
# than parsed out of the body) carry their usage in
|
|
# ``combined_usage_object`` instead, and would otherwise never charge
|
|
# the TPM window.
|
|
_usage: Usage | dict | None = None
|
|
if isinstance(
|
|
response_obj,
|
|
(
|
|
ModelResponse,
|
|
EmbeddingResponse,
|
|
TextCompletionResponse,
|
|
BaseLiteLLMOpenAIResponseObject,
|
|
),
|
|
):
|
|
_usage = getattr(response_obj, "usage", None)
|
|
else:
|
|
_combined_usage: Final = kwargs.get("combined_usage_object")
|
|
if isinstance(_combined_usage, Usage):
|
|
_usage = _combined_usage
|
|
total_tokens = self._get_total_tokens_from_usage(usage=_usage, rate_limit_type=rate_limit_type)
|
|
if total_tokens == 0:
|
|
total_tokens = self._aggregate_only_total_tokens(usage=_usage)
|
|
|
|
stash: Final = get_request_stash_for_call(_call_id_from_callback_kwargs(kwargs))
|
|
reserved_tokens: Final = stash.reserved_tokens if stash is not None else 0
|
|
reserved_model: Final = stash.reserved_model if stash is not None else None
|
|
reserved_scopes: Final[frozenset[tuple[str, str]]] = stash.reserved_scopes if stash is not None else frozenset()
|
|
# Reconciliation must target the same model-scoped counter that the
|
|
# pre-call reservation incremented. If a reservation was made,
|
|
# ``reserved_model`` is authoritative; otherwise fall back to the
|
|
# router's ``model_group`` (covers the no-reservation charge path).
|
|
reconcile_model: Final = reserved_model or model_group
|
|
|
|
pipeline_operations: Final[list[RedisPipelineIncrementOperation]] = []
|
|
|
|
# ----------------------------------------------------------------
|
|
# TPM reconciliation
|
|
# Per-scope behavior:
|
|
# reserved scope -> apply (actual - reserved) delta to settle
|
|
# the counter at +actual.
|
|
# unreserved scope -> charge the full actual usage (the
|
|
# reservation never incremented this scope).
|
|
# When no reservation was made, reserved_tokens=0 and reserved_scopes
|
|
# is empty, so every scope falls through the unreserved branch and
|
|
# gets the full actual charge — matching pre-PR behavior.
|
|
# ----------------------------------------------------------------
|
|
targets: Final = self._collect_tpm_scope_targets(
|
|
standard_logging_metadata=standard_logging_metadata,
|
|
kwargs=kwargs,
|
|
model_group=reconcile_model,
|
|
)
|
|
if reserved_tokens > 0 and total_tokens < reserved_tokens:
|
|
verbose_proxy_logger.debug(
|
|
"Releasing unused TPM budget on success: reserved=%s, actual=%s, release=%s",
|
|
reserved_tokens,
|
|
total_tokens,
|
|
reserved_tokens - total_tokens,
|
|
)
|
|
pipeline_operations.extend(
|
|
self._build_reservation_aware_tpm_ops(
|
|
targets=targets,
|
|
reserved_scopes=reserved_scopes,
|
|
actual_tokens=total_tokens,
|
|
reserved_tokens=reserved_tokens,
|
|
)
|
|
)
|
|
|
|
return pipeline_operations
|
|
|
|
async def async_log_success_event(self, kwargs, response_obj, start_time, end_time):
|
|
"""
|
|
Update TPM usage on successful API calls by incrementing counters using pipeline
|
|
"""
|
|
from litellm.litellm_core_utils.core_helpers import (
|
|
_get_parent_otel_span_from_kwargs,
|
|
)
|
|
|
|
rate_limit_type: Final = self.get_rate_limit_type()
|
|
|
|
litellm_parent_otel_span: Final[Span | None] = _get_parent_otel_span_from_kwargs(kwargs)
|
|
try:
|
|
verbose_proxy_logger.debug("INSIDE parallel request limiter ASYNC SUCCESS LOGGING")
|
|
|
|
stash: Final = get_request_stash_for_call(_call_id_from_callback_kwargs(kwargs))
|
|
acquisition: Final = stash.parallel_slot if stash is not None else None
|
|
if stash is not None and acquisition is not None:
|
|
await self._release_parallel_request_slots(
|
|
acquisition=acquisition,
|
|
parent_otel_span=litellm_parent_otel_span,
|
|
)
|
|
stash.parallel_slot = None
|
|
|
|
pipeline_operations: Final = self._build_success_event_pipeline_operations(
|
|
kwargs=kwargs,
|
|
response_obj=response_obj,
|
|
rate_limit_type=rate_limit_type,
|
|
)
|
|
if pipeline_operations:
|
|
await self.async_increment_tokens_with_ttl_preservation(
|
|
pipeline_operations=pipeline_operations,
|
|
parent_otel_span=litellm_parent_otel_span,
|
|
)
|
|
io_token_operations: Final = self._build_io_token_reservation_ops(
|
|
kwargs=kwargs,
|
|
response_obj=response_obj,
|
|
)
|
|
if io_token_operations:
|
|
if isinstance(io_token_operations, list):
|
|
await self.async_increment_tokens_with_ttl_preservation(
|
|
pipeline_operations=io_token_operations,
|
|
parent_otel_span=litellm_parent_otel_span,
|
|
)
|
|
else:
|
|
await self.async_increment_reservation_aware_tokens(
|
|
pipeline_operations=io_token_operations,
|
|
parent_otel_span=litellm_parent_otel_span,
|
|
)
|
|
|
|
except Exception as e:
|
|
verbose_proxy_logger.exception("Error in rate limit success event: %s", e)
|
|
|
|
async def async_logging_hook(
|
|
self,
|
|
kwargs: dict,
|
|
result: object,
|
|
call_type: str,
|
|
) -> tuple[dict, object]:
|
|
"""
|
|
Mirror the pre-call rate-limit snapshot into the SLP so streaming
|
|
success callbacks see the same ``x-ratelimit-*`` headers the
|
|
non-streaming path writes via ``async_post_call_success_hook``.
|
|
Runs in the earlier of the two callback loops inside
|
|
``async_success_handler`` so downstream callbacks see the values
|
|
regardless of registration order. Idempotent for non-streaming.
|
|
"""
|
|
self._mirror_ratelimit_response_into_logging_payload(
|
|
kwargs=kwargs,
|
|
response_obj=result,
|
|
)
|
|
return kwargs, result
|
|
|
|
def _mirror_ratelimit_response_into_logging_payload(
|
|
self,
|
|
kwargs: object,
|
|
response_obj: object,
|
|
) -> None:
|
|
"""
|
|
Copy the stashed ``RateLimitResponse`` into the SLP's
|
|
``hidden_params.additional_headers`` and the response object's
|
|
``_hidden_params.additional_headers`` (when the latter is a dict).
|
|
"""
|
|
if not isinstance(kwargs, dict):
|
|
return
|
|
|
|
stash: Final = get_request_stash_for_call(_call_id_from_callback_kwargs(kwargs))
|
|
rate_limit_response: Final = stash.rate_limit_response if stash is not None else None
|
|
statuses: Final = rate_limit_response["statuses"] if rate_limit_response is not None else []
|
|
if not statuses:
|
|
return
|
|
|
|
standard_logging_object: Final = kwargs.get("standard_logging_object")
|
|
if isinstance(standard_logging_object, dict):
|
|
hidden_params = standard_logging_object.get("hidden_params")
|
|
if not isinstance(hidden_params, dict):
|
|
hidden_params = {}
|
|
existing = hidden_params.get("additional_headers")
|
|
hidden_params["additional_headers"] = self._merge_ratelimit_statuses_into_additional_headers(
|
|
additional_headers=existing if isinstance(existing, dict) else {},
|
|
statuses=statuses,
|
|
)
|
|
standard_logging_object["hidden_params"] = hidden_params
|
|
|
|
response_hidden: Final = getattr(response_obj, "_hidden_params", None)
|
|
if isinstance(response_hidden, dict):
|
|
existing = response_hidden.get("additional_headers")
|
|
response_hidden["additional_headers"] = self._merge_ratelimit_statuses_into_additional_headers(
|
|
additional_headers=existing if isinstance(existing, dict) else {},
|
|
statuses=statuses,
|
|
)
|
|
|
|
def _recovered_partial_usage_tokens(self, source: Mapping[str, object]) -> tuple[int, int, int]:
|
|
usage: Final = source.get("combined_usage_object")
|
|
if not isinstance(usage, Usage) or (usage.completion_tokens or 0) <= 0:
|
|
return 0, 0, 0
|
|
billable_input, completion_tokens, _ = self._resolve_io_token_reconcile_usage(usage)
|
|
return (
|
|
self._get_total_tokens_from_usage(usage=usage, rate_limit_type=self.get_rate_limit_type()),
|
|
billable_input,
|
|
completion_tokens,
|
|
)
|
|
|
|
async def async_log_failure_event(self, kwargs, response_obj, start_time, end_time):
|
|
"""
|
|
On failure: decrement max_parallel_requests and refund the upfront
|
|
TPM reservation only against the scopes the reservation actually
|
|
charged. Unreserved scopes were never incremented at pre-call, so
|
|
refunding them would drive their counter negative. A failed stream
|
|
whose partial usage was recovered settles the reservation at that
|
|
usage instead of refunding it.
|
|
"""
|
|
from litellm.litellm_core_utils.core_helpers import (
|
|
_get_parent_otel_span_from_kwargs,
|
|
)
|
|
|
|
try:
|
|
litellm_parent_otel_span: Final[Span | None] = _get_parent_otel_span_from_kwargs(kwargs)
|
|
|
|
pipeline_operations: Final[list[RedisPipelineIncrementOperation]] = []
|
|
|
|
stash: Final = get_request_stash_for_call(_call_id_from_callback_kwargs(kwargs))
|
|
acquisition: Final = stash.parallel_slot if stash is not None else None
|
|
if stash is not None and acquisition is not None:
|
|
await self._release_parallel_request_slots(
|
|
acquisition=acquisition,
|
|
parent_otel_span=litellm_parent_otel_span,
|
|
)
|
|
stash.parallel_slot = None
|
|
|
|
# Skip the reservation refund if async_post_call_failure_hook
|
|
# already released it (proxy-level rejection that also bubbles up
|
|
# here as an LLM-error callback). max_parallel_requests is its
|
|
# own counter and is always decremented per call.
|
|
reserved_tokens, itpm_reserved, otpm_reserved = (
|
|
(0, 0, 0)
|
|
if stash is None or stash.reservation_released
|
|
else (stash.reserved_tokens, stash.itpm_reserved_tokens, stash.otpm_reserved_tokens)
|
|
)
|
|
tpm_actual, itpm_actual, otpm_actual = self._recovered_partial_usage_tokens(kwargs)
|
|
|
|
if stash is not None and reserved_tokens > 0:
|
|
verbose_proxy_logger.debug(
|
|
"Settling reserved TPM tokens on failure: reserved=%s actual=%s", reserved_tokens, tpm_actual
|
|
)
|
|
# Settle only against the scopes the reservation actually
|
|
# charged: unreserved scopes were never incremented, so a
|
|
# refund there would drive their counter negative.
|
|
pipeline_operations.extend(
|
|
self._build_reservation_aware_tpm_ops(
|
|
targets=list(stash.reserved_scopes),
|
|
reserved_scopes=stash.reserved_scopes,
|
|
actual_tokens=tpm_actual,
|
|
reserved_tokens=reserved_tokens,
|
|
)
|
|
)
|
|
|
|
# Settle project ITPM/OTPM reservations the same way: at the
|
|
# recovered partial usage, or a full refund when there is none.
|
|
itpm_operations: Final = (
|
|
self._build_project_reservation_ops(
|
|
targets=tuple(stash.itpm_reserved_scopes),
|
|
reserved_scopes=stash.itpm_reserved_scopes,
|
|
actual_tokens=itpm_actual,
|
|
reserved_tokens=itpm_reserved,
|
|
reservation_window_identities=stash.itpm_reserved_window_identities,
|
|
)
|
|
if stash is not None and itpm_reserved > 0 and stash.itpm_reserved_window_identities
|
|
else self._build_reservation_aware_tpm_ops(
|
|
targets=tuple(stash.itpm_reserved_scopes),
|
|
reserved_scopes=stash.itpm_reserved_scopes,
|
|
actual_tokens=itpm_actual,
|
|
reserved_tokens=itpm_reserved,
|
|
)
|
|
if stash is not None and itpm_reserved > 0
|
|
else ()
|
|
)
|
|
|
|
otpm_operations: Final = (
|
|
self._build_project_reservation_ops(
|
|
targets=tuple(stash.otpm_reserved_scopes),
|
|
reserved_scopes=stash.otpm_reserved_scopes,
|
|
actual_tokens=otpm_actual,
|
|
reserved_tokens=otpm_reserved,
|
|
reservation_window_identities=stash.otpm_reserved_window_identities,
|
|
)
|
|
if stash is not None and otpm_reserved > 0 and stash.otpm_reserved_window_identities
|
|
else self._build_reservation_aware_tpm_ops(
|
|
targets=tuple(stash.otpm_reserved_scopes),
|
|
reserved_scopes=stash.otpm_reserved_scopes,
|
|
actual_tokens=otpm_actual,
|
|
reserved_tokens=otpm_reserved,
|
|
)
|
|
if stash is not None and otpm_reserved > 0
|
|
else ()
|
|
)
|
|
|
|
if pipeline_operations:
|
|
await self.internal_usage_cache.dual_cache.async_increment_cache_pipeline(
|
|
increment_list=pipeline_operations,
|
|
litellm_parent_otel_span=litellm_parent_otel_span,
|
|
)
|
|
for project_operations in (itpm_operations, otpm_operations):
|
|
if isinstance(project_operations, list):
|
|
await self.internal_usage_cache.dual_cache.async_increment_cache_pipeline(
|
|
increment_list=project_operations,
|
|
litellm_parent_otel_span=litellm_parent_otel_span,
|
|
)
|
|
elif project_operations:
|
|
await self.async_increment_reservation_aware_tokens(
|
|
pipeline_operations=project_operations,
|
|
parent_otel_span=litellm_parent_otel_span,
|
|
)
|
|
if stash is not None and (reserved_tokens > 0 or itpm_reserved > 0 or otpm_reserved > 0):
|
|
stash.reservation_released = True
|
|
except Exception as e:
|
|
verbose_proxy_logger.exception("Error in rate limit failure event: %s", e)
|
|
|
|
async def async_release_max_parallel_requests_on_disconnect(
|
|
self,
|
|
user_api_key_dict: UserAPIKeyAuth,
|
|
) -> None:
|
|
"""
|
|
Release the api-key ``max_parallel_requests`` slot that
|
|
``async_pre_call_hook`` acquired, for a request that ended without
|
|
either logging callback firing.
|
|
|
|
The slot is normally released by ``async_log_success_event`` (natural
|
|
stream completion) or ``async_log_failure_event`` (LLM error). When a
|
|
client cancels a stream mid-flight, the cancellation surfaces as
|
|
``asyncio.CancelledError`` / ``GeneratorExit`` and neither callback
|
|
runs, so without this the slot leaks per cancelled stream until its
|
|
TTL prunes it. The stashed acquisition's presence (not the key
|
|
object's current max_parallel_requests configuration, which can
|
|
change mid-request) decides whether there is anything to release.
|
|
"""
|
|
stash: Final = get_request_stash()
|
|
if stash is None or stash.parallel_slot is None:
|
|
return
|
|
|
|
await self._release_parallel_request_slots(
|
|
acquisition=stash.parallel_slot,
|
|
parent_otel_span=None,
|
|
)
|
|
stash.parallel_slot = None
|
|
|
|
async def async_post_call_success_hook(self, data: dict, user_api_key_dict: UserAPIKeyAuth, response):
|
|
"""
|
|
Post-call hook to update rate limit headers in the response.
|
|
"""
|
|
try:
|
|
from pydantic import BaseModel
|
|
|
|
stash: Final = get_request_stash()
|
|
litellm_proxy_rate_limit_response: Final = stash.rate_limit_response if stash is not None else None
|
|
|
|
if litellm_proxy_rate_limit_response is not None:
|
|
# Update response headers
|
|
if hasattr(response, "_hidden_params"):
|
|
_hidden_params = getattr(response, "_hidden_params")
|
|
else:
|
|
_hidden_params = None
|
|
|
|
if _hidden_params is not None and (
|
|
isinstance(_hidden_params, BaseModel) or isinstance(_hidden_params, dict)
|
|
):
|
|
if isinstance(_hidden_params, BaseModel):
|
|
_hidden_params = _hidden_params.model_dump()
|
|
|
|
_additional_headers: Final = self._merge_ratelimit_statuses_into_additional_headers(
|
|
additional_headers=_hidden_params.get("additional_headers", {}) or {},
|
|
statuses=litellm_proxy_rate_limit_response["statuses"],
|
|
)
|
|
|
|
setattr(
|
|
response,
|
|
"_hidden_params",
|
|
{**_hidden_params, "additional_headers": _additional_headers},
|
|
)
|
|
|
|
except Exception as e:
|
|
verbose_proxy_logger.exception("Error in rate limit post-call hook: %s", e)
|
|
|
|
try:
|
|
await self._handle_batch_enqueued_post_call(user_api_key_dict=user_api_key_dict, response=response)
|
|
except Exception as e: # noqa: BLE001 # post-call batch accounting must never fail the response
|
|
verbose_proxy_logger.exception("Error in batch enqueued-token post-call hook: %s", e)
|
|
|
|
async def _handle_batch_enqueued_post_call(self, user_api_key_dict: UserAPIKeyAuth, response: object) -> None:
|
|
view: Final = batch_response_view(response)
|
|
if view is None:
|
|
return
|
|
span: Final = user_api_key_dict.parent_otel_span
|
|
stash: Final = get_request_stash()
|
|
if stash is not None and stash.batch_enqueued_reservation is not None:
|
|
await self.batch_enqueued_token_store.save_reservation(
|
|
batch_id=canonical_provider_batch_id(view.id),
|
|
reservation=stash.batch_enqueued_reservation,
|
|
litellm_parent_otel_span=span,
|
|
)
|
|
stash.batch_enqueued_reservation = None
|
|
if view.status.lower() in BATCH_ENQUEUED_REFUND_STATUSES:
|
|
popped: Final = await self.batch_enqueued_token_store.pop_reservation(
|
|
batch_id=canonical_provider_batch_id(view.id),
|
|
litellm_parent_otel_span=span,
|
|
)
|
|
if popped is not None:
|
|
await self.batch_enqueued_token_store.refund(reservation=popped, litellm_parent_otel_span=span)
|
|
|
|
async def async_post_call_failure_hook(
|
|
self,
|
|
request_data: dict,
|
|
original_exception: Exception,
|
|
user_api_key_dict: UserAPIKeyAuth,
|
|
traceback_str: str | None = None,
|
|
) -> None:
|
|
"""
|
|
Release the parallel-request slot and any TPM/ITPM/OTPM reservation
|
|
when the request is rejected after the pre-call hook acquired them
|
|
but before the LLM call ran (e.g. a downstream guardrail/auth hook
|
|
raised). Without this, those resources are stranded —
|
|
async_log_failure_event is a litellm completion-level callback and
|
|
never fires for proxy-side rejections, so a leaked slot would occupy
|
|
the gauge for the full PARALLEL_REQUEST_SLOT_TTL_SECONDS.
|
|
|
|
Idempotent: the slot release clears the stashed acquisition (and slot
|
|
removal is a no-op ZREM on a second run), and the TPM/ITPM/OTPM
|
|
refund is guarded by the stash's ``reservation_released`` flag — if
|
|
both this hook and async_log_failure_event end up running in the same
|
|
flow, only the first release/refund applies. A mid-stream failure
|
|
relayed here with recovered partial usage settles the reservation at
|
|
that usage instead of refunding it.
|
|
"""
|
|
try:
|
|
stash: Final = get_request_stash()
|
|
if stash is None:
|
|
return
|
|
if stash.parallel_slot is not None:
|
|
await self._release_parallel_request_slots(
|
|
acquisition=stash.parallel_slot,
|
|
parent_otel_span=user_api_key_dict.parent_otel_span,
|
|
)
|
|
stash.parallel_slot = None
|
|
|
|
if stash.batch_enqueued_reservation is not None:
|
|
await self.batch_enqueued_token_store.refund(
|
|
reservation=stash.batch_enqueued_reservation,
|
|
litellm_parent_otel_span=user_api_key_dict.parent_otel_span,
|
|
)
|
|
stash.batch_enqueued_reservation = None
|
|
|
|
if stash.reservation_released:
|
|
return
|
|
reserved_tokens: Final = stash.reserved_tokens
|
|
itpm_reserved: Final = stash.itpm_reserved_tokens
|
|
otpm_reserved: Final = stash.otpm_reserved_tokens
|
|
if reserved_tokens <= 0 and itpm_reserved <= 0 and otpm_reserved <= 0:
|
|
return
|
|
tpm_actual, itpm_actual, otpm_actual = self._recovered_partial_usage_tokens(request_data)
|
|
|
|
combined_ops: Final = (
|
|
self._build_reservation_aware_tpm_ops(
|
|
targets=tuple(stash.reserved_scopes),
|
|
reserved_scopes=stash.reserved_scopes,
|
|
actual_tokens=tpm_actual,
|
|
reserved_tokens=reserved_tokens,
|
|
)
|
|
if reserved_tokens > 0
|
|
else ()
|
|
)
|
|
itpm_ops: Final = (
|
|
self._build_project_reservation_ops(
|
|
targets=tuple(stash.itpm_reserved_scopes),
|
|
reserved_scopes=stash.itpm_reserved_scopes,
|
|
actual_tokens=itpm_actual,
|
|
reserved_tokens=itpm_reserved,
|
|
reservation_window_identities=stash.itpm_reserved_window_identities,
|
|
)
|
|
if itpm_reserved > 0 and stash.itpm_reserved_window_identities
|
|
else self._build_reservation_aware_tpm_ops(
|
|
targets=tuple(stash.itpm_reserved_scopes),
|
|
reserved_scopes=stash.itpm_reserved_scopes,
|
|
actual_tokens=itpm_actual,
|
|
reserved_tokens=itpm_reserved,
|
|
)
|
|
if itpm_reserved > 0
|
|
else ()
|
|
)
|
|
otpm_ops: Final = (
|
|
self._build_project_reservation_ops(
|
|
targets=tuple(stash.otpm_reserved_scopes),
|
|
reserved_scopes=stash.otpm_reserved_scopes,
|
|
actual_tokens=otpm_actual,
|
|
reserved_tokens=otpm_reserved,
|
|
reservation_window_identities=stash.otpm_reserved_window_identities,
|
|
)
|
|
if otpm_reserved > 0 and stash.otpm_reserved_window_identities
|
|
else self._build_reservation_aware_tpm_ops(
|
|
targets=tuple(stash.otpm_reserved_scopes),
|
|
reserved_scopes=stash.otpm_reserved_scopes,
|
|
actual_tokens=otpm_actual,
|
|
reserved_tokens=otpm_reserved,
|
|
)
|
|
if otpm_reserved > 0
|
|
else ()
|
|
)
|
|
if combined_ops or itpm_ops or otpm_ops:
|
|
verbose_proxy_logger.debug(
|
|
"Releasing reserved tokens on proxy-level rejection: tpm=%s, itpm=%s, otpm=%s",
|
|
reserved_tokens,
|
|
itpm_reserved,
|
|
otpm_reserved,
|
|
)
|
|
if combined_ops:
|
|
await self.internal_usage_cache.dual_cache.async_increment_cache_pipeline(
|
|
increment_list=combined_ops,
|
|
litellm_parent_otel_span=user_api_key_dict.parent_otel_span,
|
|
)
|
|
for project_ops in (itpm_ops, otpm_ops):
|
|
if isinstance(project_ops, list):
|
|
await self.internal_usage_cache.dual_cache.async_increment_cache_pipeline(
|
|
increment_list=project_ops,
|
|
litellm_parent_otel_span=user_api_key_dict.parent_otel_span,
|
|
)
|
|
elif project_ops:
|
|
await self.async_increment_reservation_aware_tokens(
|
|
pipeline_operations=project_ops,
|
|
parent_otel_span=user_api_key_dict.parent_otel_span,
|
|
)
|
|
stash.reservation_released = True
|
|
except Exception as e:
|
|
verbose_proxy_logger.exception("Error releasing TPM reservation on post-call failure: %s", e)
|
|
return
|