Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions litellm/proxy/hooks/model_max_budget_limiter.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import asyncio
import json
import time
from collections.abc import Iterable, Mapping, Sequence
Expand Down Expand Up @@ -297,6 +298,9 @@ class _PROXY_VirtualKeyModelMaxBudgetLimiter(RouterBudgetLimiting):
def __init__(self, dual_cache: DualCache):
self.dual_cache = dual_cache
self.redis_increment_operation_queue = []
self._redis_increment_queue_lock = asyncio.Lock()
self._redis_increment_flush_lock = asyncio.Lock()
self._detached_increment_operations = None
self.deployment_budget_config = None

async def is_key_within_model_budget(
Expand Down
203 changes: 146 additions & 57 deletions litellm/router_strategy/budget_limiter.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,10 @@
import asyncio
import builtins
import logging
from collections.abc import Mapping
from collections.abc import Mapping, Sequence
from datetime import datetime, timedelta, timezone
from itertools import groupby
from types import MappingProxyType
from typing import Any, Final

import litellm
Expand Down Expand Up @@ -93,11 +95,12 @@ def model_dump(self) -> builtins.dict[str, object]:
return dict(self._params)


async def _push_increments_to_redis(redis_cache: RedisCache, queued: list[RedisPipelineIncrementOperation]) -> None:
try:
await redis_cache.async_increment_pipeline(increment_list=queued)
except Exception as e:
log_redis_failure(verbose_router_logger, logging.ERROR, "Error syncing in-memory cache with Redis", e)
def _sum_increments_by_key(operations: Sequence[RedisPipelineIncrementOperation]) -> Mapping[str, float]:
by_key: Final = groupby(
sorted(operations, key=lambda operation: operation["key"]),
key=lambda operation: operation["key"],
)
return MappingProxyType({key: sum(operation["increment_value"] for operation in group) for key, group in by_key})


class RouterBudgetLimiting(CustomLogger):
Expand All @@ -109,6 +112,9 @@ def __init__(
):
self.dual_cache = dual_cache
self.redis_increment_operation_queue: list[RedisPipelineIncrementOperation] = []
self._redis_increment_queue_lock = asyncio.Lock()
self._redis_increment_flush_lock = asyncio.Lock()
self._detached_increment_operations: tuple[RedisPipelineIncrementOperation, ...] | None = None
asyncio.create_task(self.periodic_sync_in_memory_spend_with_redis())
self.provider_budget_config: GenericBudgetConfigType | None = provider_budget_config
self.deployment_budget_config: GenericBudgetConfigType | None = None
Expand Down Expand Up @@ -392,17 +398,97 @@ async def _increment_spend_in_current_window(self, spend_key: str, response_cost
- Increments the spend in memory cache (so spend instantly updated in memory)
- Queues the increment operation to Redis Pipeline (using batched pipeline to optimize performance. Using Redis for multi instance environment of LiteLLM)
"""
await self.dual_cache.in_memory_cache.async_increment(
key=spend_key,
value=response_cost,
ttl=ttl,
)
increment_op: Final = RedisPipelineIncrementOperation(
key=spend_key,
increment_value=response_cost,
ttl=ttl,
)
self.redis_increment_operation_queue.append(increment_op)
async with self._get_redis_increment_queue_lock():
await self.dual_cache.in_memory_cache.async_increment(
key=spend_key,
value=response_cost,
ttl=ttl,
)
self.redis_increment_operation_queue.append(increment_op)

def _get_redis_increment_queue_lock(self) -> asyncio.Lock:
return self._redis_increment_queue_lock

async def _detach_queued_increment_operations(self) -> tuple[RedisPipelineIncrementOperation, ...]:
async with self._get_redis_increment_queue_lock():
if self._detached_increment_operations is not None:
return self._detached_increment_operations
increment_operations_to_flush: Final = tuple(self.redis_increment_operation_queue)
if not increment_operations_to_flush:
return increment_operations_to_flush
self.redis_increment_operation_queue = [] # mutable-ok: emptied queue must stay appendable
self._detached_increment_operations = increment_operations_to_flush
return increment_operations_to_flush

async def _clear_detached_increment_operations(self) -> None:
async with self._get_redis_increment_queue_lock():
self._detached_increment_operations = None

async def _requeue_detached_increment_operations(self) -> None:
async with self._get_redis_increment_queue_lock():
detached_increment_operations: Final = self._detached_increment_operations
if detached_increment_operations is None:
return
operations: Final = (*detached_increment_operations, *self.redis_increment_operation_queue)
grouped_operations: Final = (
(key, tuple(group))
for key, group in groupby(
sorted(operations, key=lambda operation: operation["key"]),
key=lambda operation: operation["key"],
)
)
self.redis_increment_operation_queue = [
RedisPipelineIncrementOperation(
key=key,
increment_value=sum(operation["increment_value"] for operation in group),
ttl=group[-1]["ttl"],
)
for key, group in grouped_operations
]
Comment on lines +445 to +452

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Retries mix budget windows If Redis remains unavailable across a window reset, grouping by key gives old and new spend one expiry. Expired spend can falsely exhaust the new budget.

Knowledge Base Used: Routing and resilience

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only a Redis outage spanning a window reset replays old-window spend, bounded by what queued during the outage; main lost it entirely, undercounting

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right — this is limited to a Redis outage (or stalled flush) that crosses the budget-window boundary. The retained queue is bounded to increments accumulated during that outage; without this requeue, main drops those increments and undercounts spend. Since the window reset writes the new spend under the same Redis key, replaying the retained batch can temporarily mix old-window spend into the new window, but that is an outage-only edge case rather than the general behavior I implied. I’m withdrawing the broader finding.

self._detached_increment_operations = None

async def _flush_queued_increment_operations(self, redis_cache: RedisCache) -> bool:
flush_task: Final = asyncio.create_task(self._write_queued_increment_operations(redis_cache))
return await self._await_flush_task(flush_task)

async def _await_flush_task(self, flush_task: asyncio.Task[bool]) -> bool:
try:
return await asyncio.shield(flush_task)
except asyncio.CancelledError:
while not flush_task.done():
try:
await asyncio.shield(flush_task)
except asyncio.CancelledError:
continue
flush_task.result()
raise

async def _write_queued_increment_operations(self, redis_cache: RedisCache) -> bool:
increment_operations_to_flush: Final = await self._detach_queued_increment_operations()
if len(increment_operations_to_flush) == 0:
await self._clear_detached_increment_operations()
return True

verbose_router_logger.debug(
"Pushing Redis Increment Pipeline for queue: %s",
increment_operations_to_flush,
)
increment_list: Final = list( # mutable-ok: Redis pipeline contract requires a list
increment_operations_to_flush
)
try:
await redis_cache.async_increment_pipeline(increment_list=increment_list)
except Exception as error:
log_redis_failure(verbose_router_logger, logging.ERROR, "Error syncing in-memory cache with Redis", error)
await self._requeue_detached_increment_operations()
return False
Comment on lines +484 to +489

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Failed pipelines can double spend If Redis applies some increments before a connection failure, this non-transactional pipeline retries them all. Shared spend rises twice, potentially rejecting valid requests.

Knowledge Base Used: Routing and resilience

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A drop between send and reply replays at most the failed batch, an overcount the window reset clears; main dropped that spend, undercounting

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That’s fair. A Redis connection drop makes execution ambiguous, so re-queuing the detached batch deliberately favors conservative accounting: it can overcount a batch that Redis partially applied, but avoids the undercount/data loss in the previous fire-and-forget path, and the window reset bounds the effect. I was treating any possible duplicate as an unconditional correctness bug; given this budget-limiter trade-off, I’m withdrawing the finding.

await self._clear_detached_increment_operations()
return True

async def async_log_success_event(self, kwargs, response_obj, start_time, end_time):
"""Original method now uses helper functions"""
Expand Down Expand Up @@ -528,29 +614,25 @@ async def periodic_sync_in_memory_spend_with_redis(self):
DEFAULT_REDIS_SYNC_INTERVAL
) # Still wait DEFAULT_REDIS_SYNC_INTERVAL seconds on error before retrying

async def _push_in_memory_increments_to_redis(self):
async def _push_in_memory_increments_to_redis(self) -> bool:
"""
How this works:
- async_log_success_event collects all provider spend increments in `redis_increment_operation_queue`
- This function pushes all increments to Redis in a batched pipeline to optimize performance

Only runs if Redis is initialized
Only runs if Redis is initialized. Returns False when the detached batch could not be
written, so callers must not treat Redis as up to date.
"""
try:
if not self.dual_cache.redis_cache:
return # Redis is not initialized
redis_cache: Final = self.dual_cache.redis_cache
if redis_cache is None:
return True

verbose_router_logger.debug(
"Pushing Redis Increment Pipeline for queue: %s",
self.redis_increment_operation_queue,
)
queued: Final = self.redis_increment_operation_queue
self.redis_increment_operation_queue = []
if queued:
asyncio.create_task(_push_increments_to_redis(self.dual_cache.redis_cache, queued))
flush_task: Final = asyncio.create_task(self._flush_queued_increments_with_lock(redis_cache))
return await self._await_flush_task(flush_task)
Comment on lines +630 to +631

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Redis delays completed requests When Redis is slow, the awaited success callback waits for this write and any earlier flushes. Otherwise completed requests are delayed.

Knowledge Base Used: Observability and governance

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This callback runs on the background logging worker or a stream-end task after the response is sent, so awaiting delays spend recording, not the client

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right — in these invocation paths async_log_success_event runs after the response has been delivered (or in the background logging worker), so awaiting the Redis flush does not add latency to the client request. It can delay spend recording while Redis is slow, but that is the intended trade-off here to ensure the subsequent sync cannot overwrite memory with stale Redis data. I’m withdrawing the “delays completed requests” concern.


except Exception as e:
log_redis_failure(verbose_router_logger, logging.ERROR, "Error syncing in-memory cache with Redis", e)
async def _flush_queued_increments_with_lock(self, redis_cache: RedisCache) -> bool:
async with self._redis_increment_flush_lock:
return await self._flush_queued_increment_operations(redis_cache)

async def _sync_in_memory_spend_with_redis(self):
"""
Expand All @@ -569,43 +651,50 @@ async def _sync_in_memory_spend_with_redis(self):
# No need to sync if Redis cache is not initialized
if self.dual_cache.redis_cache is None:
return
async with self._redis_increment_flush_lock:
await self._flush_increments_then_copy_redis_spend()
except Exception as e:
log_redis_failure(verbose_router_logger, logging.ERROR, "Error syncing in-memory cache with Redis", e)

# 1. Push all provider spend increments to Redis
await self._push_in_memory_increments_to_redis()

# 2. Fetch all current provider spend from Redis to update in-memory cache
cache_keys: Final = []
async def _flush_increments_then_copy_redis_spend(self) -> None:
redis_cache: Final = self.dual_cache.redis_cache
if redis_cache is None or not await self._flush_queued_increment_operations(redis_cache):
return

if self.provider_budget_config is not None:
for provider, config in self.provider_budget_config.items():
if config is None:
continue
cache_keys.append(f"provider_spend:{provider}:{config.budget_duration}")
cache_keys: Final = []

if self.deployment_budget_config is not None:
for model_id, config in self.deployment_budget_config.items():
if config is None:
continue
cache_keys.append(f"deployment_spend:{model_id}:{config.budget_duration}")
if self.provider_budget_config is not None:
for provider, config in self.provider_budget_config.items():
if config is None:
continue
cache_keys.append(f"provider_spend:{provider}:{config.budget_duration}")

if self.tag_budget_config is not None:
for tag, config in self.tag_budget_config.items():
if config is None:
continue
cache_keys.append(f"tag_spend:{tag}:{config.budget_duration}")
if self.deployment_budget_config is not None:
for model_id, config in self.deployment_budget_config.items():
if config is None:
continue
cache_keys.append(f"deployment_spend:{model_id}:{config.budget_duration}")

# Batch fetch current spend values from Redis
redis_values: Final = await self.dual_cache.redis_cache.async_batch_get_cache(key_list=cache_keys)
if self.tag_budget_config is not None:
for tag, config in self.tag_budget_config.items():
if config is None:
continue
cache_keys.append(f"tag_spend:{tag}:{config.budget_duration}")

# Update in-memory cache with Redis values
if isinstance(redis_values, dict): # Check if redis_values is a dictionary
for key, value in redis_values.items():
if value is not None:
await self.dual_cache.in_memory_cache.async_set_cache(key=key, value=float(value))
verbose_router_logger.debug("Updated in-memory cache for %s: %s", key, value)
redis_values: Final = await redis_cache.async_batch_get_cache(key_list=cache_keys)

except Exception as e:
log_redis_failure(verbose_router_logger, logging.ERROR, "Error syncing in-memory cache with Redis", e)
if not isinstance(redis_values, dict):
return
async with self._get_redis_increment_queue_lock():
pending_spend_by_key: Final = _sum_increments_by_key(self.redis_increment_operation_queue)
updated_spend_by_key: Final = tuple(
(key, float(value) + pending_spend_by_key.get(key, 0.0))
for key, value in redis_values.items()
if value is not None
)
for key, updated_spend in updated_spend_by_key:
await self.dual_cache.in_memory_cache.async_set_cache(key=key, value=updated_spend)
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved
verbose_router_logger.debug("Updated in-memory cache for %s: %s", key, updated_spend)

def _get_budget_config_for_deployment(
self,
Expand Down
Loading
Loading