perf(proxy): one MGET and one pipeline for post-call spend counters, no team/user/org refetch on the response path (#40841)

* perf(auth): prefetch user, team, membership, org and project in one MGET, one query and one pipeline

Auth read each object with its own Redis GET and, on a miss, its own DB
query, then the admission spend counters with one GET each. The prefetch
warms every entry the checks read with one MGET, one raw query for the
Redis misses and one pipeline write, and a per-request batch serves the
spend counter reads from one MGET. The per-object getters stay the
readers and the fallback, so enforcement does not depend on the prefetch

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

* refactor(auth): keep prefetch and spend batch collections immutable

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

* perf(auth): let the cold spend-counter reseed reuse the admission MGET instead of one GET per counter

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

* perf(auth): prefetch referenced auth objects only after the key's model access check passes

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

* test(auth): give the prefetch-ordering test's patches their test-quality reasons

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

* test(auth): move the real-Postgres prefetch join test to the proxy_behavior shard

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

* fix(auth): read NULL nested permission and budget lists as [] in the prefetch join

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

* perf(proxy): batch post-call spend counter reads and carry budget state through the request

Post-call warm checks, reservation reads and reconcile reads for one request now go through a task-local spend counter batch: one MGET answers every counter, successful increments write their result back into the batch so no second Redis read follows, and invalidation forgets the key. RedisCache.async_increment sends INCRBYFLOAT and its TTL command in one pipeline round trip.

Auth pins frozen team, user and org budget snapshots on UserAPIKeyAuth, the pre-call setup writes them into the request metadata, and Prometheus reads them back instead of calling get_key_object, get_team_object, get_user_object and get_org_object on the response path. The getters stay as the fallback for requests that carried nothing (custom auth, unauthenticated routes, skipped checks).

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

* perf(proxy): reconcile the budget reservation and the post-call warm checks from one MGET and one pipeline

A scope opened inside an open spend counter batch binds into it instead of starting its own, so the reservation reconcile and the post-call warm checks share the request's single MGET. The reconcile reads every reserved counter concurrently, sends the consistent adjustments in one INCRBYFLOAT+EXPIRE pipeline and settles a flushed or reseeded counter on its own afterwards, keeping the pre-call resize fail-closed. PendingSpendIncrement moves to spend_counter_batch so budget_reservation can build a pipeline without importing a private name

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

* chore(proxy): drop the dataclass import left behind by the PendingSpendIncrement move

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

* fix(types): import Self from typing_extensions so the proxy imports on Python 3.10

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

* test(proxy): use a neutral organization alias in the carried budget state tests

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

* test(proxy): cover recorded and forgotten spend counter values in the request batch

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

* test(caching): assert async_set_cache_pipeline_with_ttls keeps per-entry TTLs

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

* refactor(proxy): type the reservation entry carried through reconcile adjustments

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

* fix(auth): map the model table's aliases column to model_aliases in the prefetch join and read user memberships the way get_user_object does

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

---------

Co-authored-by: yassin <yassin@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
devin-ai-integration[bot] 2026-09-12 16:05:50 +00:00 committed by GitHub
parent f2e0a5db1e
commit 1c61c2606e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 1369 additions and 158 deletions

View file

@ -1273,6 +1273,24 @@ class RedisCache(BaseCache):
if len(self.redis_batch_writing_buffer) >= self.redis_flush_size:
await self.flush_cache_buffer() # logging done in here
@staticmethod
async def _incrbyfloat_with_ttl(
_redis_client: "Redis", key: str, value: float, ttl: int | None, refresh_ttl: bool
) -> float:
"""INCRBYFLOAT plus its TTL command in one round trip; a third only when an unexpiring key needs an EXPIRE."""
if ttl is None:
return await _redis_client.incrbyfloat(name=key, amount=value)
async with _redis_client.pipeline(transaction=False) as pipe:
pipe.incrbyfloat(name=key, amount=value)
if refresh_ttl:
pipe.expire(key, ttl)
else:
pipe.ttl(key)
result, ttl_or_expire = await pipe.execute()
if not refresh_ttl and ttl_or_expire == -1:
await _redis_client.expire(key, ttl)
return float(result)
@_redis_circuit_breaker_guard
async def async_increment(
self,
@ -1289,14 +1307,9 @@ class RedisCache(BaseCache):
_used_ttl: Final = self.get_ttl(ttl=ttl)
key = self.check_and_fix_namespace(key=key)
try:
result: Final = await _redis_client.incrbyfloat(name=key, amount=value)
if _used_ttl is not None:
if refresh_ttl:
await _redis_client.expire(key, _used_ttl)
else:
current_ttl: Final = await _redis_client.ttl(key)
if current_ttl == -1:
await _redis_client.expire(key, _used_ttl)
result: Final = await self._incrbyfloat_with_ttl(
_redis_client, key=key, value=value, ttl=_used_ttl, refresh_ttl=refresh_ttl
)
## LOGGING ##
end_time = time.time()

View file

@ -52,6 +52,12 @@ from litellm.types.integrations.prometheus import (
_sanitize_prometheus_label_value,
validate_prometheus_deployment_and_latency_caller_identity,
)
from litellm.types.proxy.carried_budget_state import (
KeyBudgetSnapshot,
OrgBudgetSnapshot,
TeamBudgetSnapshot,
UserBudgetSnapshot,
)
from litellm.types.utils import (
StandardLoggingGuardrailInformation,
StandardLoggingPayload,
@ -1941,6 +1947,8 @@ class PrometheusLogger(CustomLogger):
_user_spend: Final = _metadata.get("user_api_key_user_spend", None)
_user_max_budget: Final = _metadata.get("user_api_key_user_max_budget", None)
_user_email: Final = _metadata.get("user_api_key_user_email", None)
_org_alias: Final = _metadata.get("user_api_key_org_alias", None)
# Bound the per-request budget-metric emission so that slow Redis/DB
# lookups under load cannot consume the whole LoggingWorker watchdog
@ -1957,6 +1965,7 @@ class PrometheusLogger(CustomLogger):
response_cost=response_cost,
key_max_budget=_api_key_max_budget,
key_spend=_api_key_spend,
carried=KeyBudgetSnapshot.from_metadata(_metadata),
),
self._set_team_budget_metrics_after_api_request(
user_api_team=user_api_team,
@ -1964,16 +1973,21 @@ class PrometheusLogger(CustomLogger):
team_spend=_team_spend,
team_max_budget=_team_max_budget,
response_cost=response_cost,
carried=TeamBudgetSnapshot.from_metadata(_metadata),
),
self._set_user_budget_metrics_after_api_request(
user_id=user_id,
user_spend=_user_spend,
user_max_budget=_user_max_budget,
response_cost=response_cost,
carried=UserBudgetSnapshot.from_metadata(_metadata),
user_email=_user_email if isinstance(_user_email, str) else None,
),
self._set_org_budget_metrics_after_api_request(
org_id=user_api_key_org_id,
response_cost=response_cost,
carried=OrgBudgetSnapshot.from_metadata(_metadata),
org_alias=_org_alias if isinstance(_org_alias, str) else None,
),
return_exceptions=True,
)
@ -3821,6 +3835,7 @@ class PrometheusLogger(CustomLogger):
team_spend: float | None,
team_max_budget: float | None,
response_cost: float,
carried: TeamBudgetSnapshot | None = None,
):
"""
Set team budget metrics after an LLM API request
@ -3839,6 +3854,7 @@ class PrometheusLogger(CustomLogger):
spend=team_spend,
max_budget=team_max_budget,
response_cost=response_cost,
carried=carried,
)
self._set_team_budget_metrics(team_object)
@ -3850,18 +3866,26 @@ class PrometheusLogger(CustomLogger):
spend: float | None,
max_budget: float | None,
response_cost: float,
carried: TeamBudgetSnapshot | None = None,
) -> LiteLLM_TeamTable:
"""
Assemble a LiteLLM_TeamTable object
for fields not available in metadata, we fetch from db
Fields not available in metadata:
- `budget_reset_at`
``budget_reset_at`` comes from the auth-carried snapshot when the request has one,
otherwise from the team lookup
"""
from litellm.proxy.auth.auth_checks import get_team_object
from litellm.proxy.proxy_server import prisma_client, user_api_key_cache
_total_team_spend: Final = (spend or 0) + response_cost
if carried is not None:
return LiteLLM_TeamTable(
team_id=team_id,
team_alias=team_alias,
spend=_total_team_spend,
max_budget=max_budget if max_budget is not None else carried.max_budget,
budget_reset_at=carried.budget_reset_at,
)
team_object: Final = LiteLLM_TeamTable(
team_id=team_id,
team_alias=team_alias,
@ -3946,11 +3970,13 @@ class PrometheusLogger(CustomLogger):
self,
org_id: str | None,
response_cost: float,
carried: OrgBudgetSnapshot | None = None,
org_alias: str | None = None,
):
"""
Set org budget metrics after an LLM API request
- Fetches org info via cache (get_org_object)
- Uses the auth-carried org budget when the request has one, else fetches via get_org_object
- Sets org budget metrics
"""
if isinstance(self.litellm_remaining_org_budget_metric, NoOpMetric):
@ -3959,6 +3985,16 @@ class PrometheusLogger(CustomLogger):
if not org_id:
return
if carried is not None:
self._set_org_budget_metrics(
org_id=org_id,
org_alias=org_alias or "",
spend=carried.spend + response_cost,
max_budget=carried.max_budget,
budget_reset_at=None,
)
return
from litellm.proxy.auth.auth_checks import get_org_object
from litellm.proxy.proxy_server import prisma_client, user_api_key_cache
@ -3979,7 +4015,6 @@ class PrometheusLogger(CustomLogger):
if org_info is None:
return
org_alias: Final = org_info.organization_alias or ""
_total_org_spend: Final = (org_info.spend or 0.0) + response_cost
budget_table: Final = org_info.litellm_budget_table
max_budget: Final = budget_table.max_budget if budget_table else None
@ -3987,7 +4022,7 @@ class PrometheusLogger(CustomLogger):
self._set_org_budget_metrics(
org_id=org_id,
org_alias=org_alias,
org_alias=org_info.organization_alias or "",
spend=_total_org_spend,
max_budget=max_budget,
budget_reset_at=budget_reset_at,
@ -4084,6 +4119,7 @@ class PrometheusLogger(CustomLogger):
response_cost: float,
key_max_budget: float | None,
key_spend: float | None,
carried: KeyBudgetSnapshot | None = None,
):
if isinstance(self.litellm_remaining_api_key_budget_metric, NoOpMetric):
return
@ -4095,6 +4131,7 @@ class PrometheusLogger(CustomLogger):
key_max_budget=key_max_budget,
key_spend=key_spend,
response_cost=response_cost,
carried=carried,
)
self._set_key_budget_metrics(user_api_key_dict)
@ -4105,6 +4142,7 @@ class PrometheusLogger(CustomLogger):
key_max_budget: float | None,
key_spend: float | None,
response_cost: float,
carried: KeyBudgetSnapshot | None = None,
) -> UserAPIKeyAuth:
"""
Assemble a UserAPIKeyAuth object
@ -4113,6 +4151,14 @@ class PrometheusLogger(CustomLogger):
from litellm.proxy.proxy_server import prisma_client, user_api_key_cache
_total_key_spend: Final = (key_spend or 0) + response_cost
if carried is not None:
return UserAPIKeyAuth(
token=user_api_key,
key_alias=user_api_key_alias,
max_budget=key_max_budget,
spend=_total_key_spend,
budget_reset_at=carried.budget_reset_at,
)
user_api_key_dict: Final = UserAPIKeyAuth(
token=user_api_key,
key_alias=user_api_key_alias,
@ -4140,6 +4186,8 @@ class PrometheusLogger(CustomLogger):
user_spend: float | None,
user_max_budget: float | None,
response_cost: float,
carried: UserBudgetSnapshot | None = None,
user_email: str | None = None,
):
"""
Set user budget metrics after an LLM API request
@ -4157,6 +4205,8 @@ class PrometheusLogger(CustomLogger):
spend=user_spend,
max_budget=user_max_budget,
response_cost=response_cost,
carried=carried,
user_email=user_email,
)
self._set_user_budget_metrics(user_object)
@ -4167,18 +4217,28 @@ class PrometheusLogger(CustomLogger):
spend: float | None,
max_budget: float | None,
response_cost: float,
carried: UserBudgetSnapshot | None = None,
user_email: str | None = None,
) -> LiteLLM_UserTable:
"""
Assemble a LiteLLM_UserTable object
for fields not available in metadata, we fetch from db
Fields not available in metadata:
- `budget_reset_at`
``budget_reset_at`` and ``user_alias`` come from the auth-carried snapshot when the
request has one, otherwise from the user lookup
"""
from litellm.proxy.auth.auth_checks import get_user_object
from litellm.proxy.proxy_server import prisma_client, user_api_key_cache
_total_user_spend: Final = (spend or 0) + response_cost
if carried is not None:
return LiteLLM_UserTable(
user_id=user_id,
spend=_total_user_spend,
max_budget=max_budget if max_budget is not None else carried.max_budget,
budget_reset_at=carried.budget_reset_at,
user_alias=carried.user_alias,
user_email=user_email,
)
user_object: Final = LiteLLM_UserTable(
user_id=user_id,
spend=_total_user_spend,

View file

@ -40,6 +40,11 @@ from litellm.types.mcp import (
MCPTransportType,
)
from litellm.types.mcp_server.mcp_server_manager import MCPInfo
from litellm.types.proxy.carried_budget_state import (
OrgBudgetSnapshot,
TeamBudgetSnapshot,
UserBudgetSnapshot,
)
from litellm.types.proxy.control_plane_endpoints import WorkerRegistryEntry
from litellm.types.router import RouterErrors, UpdateRouterConfig
from litellm.types.secret_managers.main import KeyManagementSystem
@ -3106,6 +3111,9 @@ class UserAPIKeyAuth(LiteLLM_VerificationTokenView): # the expected response ob
),
)
budget_reservation: dict[str, Any] | None = Field(default=None, exclude=True)
team_budget_snapshot: TeamBudgetSnapshot | None = Field(default=None, exclude=True)
user_budget_snapshot: UserBudgetSnapshot | None = Field(default=None, exclude=True)
org_budget_snapshot: OrgBudgetSnapshot | None = Field(default=None, exclude=True)
matched_model_access_groups: list[str] | None = Field(default=None, exclude=True)
budget_throttle_pct: float | None = Field(default=None, exclude=True)
user: Any | None = None # Expanded user object when expand=user is used

View file

@ -102,6 +102,7 @@ from litellm.proxy.guardrails.tool_name_extraction import (
)
from litellm.proxy.route_llm_request import route_request
from litellm.proxy.spend_tracking.budget_reservation import get_budget_window_start
from litellm.proxy.spend_tracking.carried_budget_state import carry_organization_budget_state
from litellm.proxy.utils import PrismaClient, ProxyLogging, log_db_metrics
from litellm.repositories.budget_repository import BudgetRepository
from litellm.repositories.object_permission_repository import ObjectPermissionRepository
@ -5635,6 +5636,8 @@ async def _organization_max_budget_check(
if org_table is None:
return
carry_organization_budget_state(valid_token=valid_token, org_table=org_table)
# Get max_budget from organization's budget table
org_max_budget: float | None = None
if org_table.litellm_budget_table is not None:

View file

@ -103,6 +103,7 @@ from litellm.proxy.common_utils.user_api_key_cache import (
)
from litellm.proxy.db.exception_handler import PrismaDBExceptionHandler
from litellm.proxy.litellm_pre_call_utils import LiteLLMProxyRequestSetup
from litellm.proxy.spend_tracking.carried_budget_state import carry_team_and_user_budget_state
from litellm.proxy.spend_tracking.spend_counter_batch import (
bind_admission_counter_keys,
release_spend_counter_batch,
@ -2631,6 +2632,11 @@ async def _run_centralized_common_checks(
None if isinstance(end_user_result, BaseException) else end_user_result
)
global_proxy_spend: float | None = None if isinstance(global_spend_result, BaseException) else global_spend_result
carry_team_and_user_budget_state(
valid_token=user_api_key_auth_obj,
team_object=team_object,
user_object=user_object,
)
if user_api_key_auth_obj.org_id is None and team_object is not None and team_object.organization_id is not None:
user_api_key_auth_obj.org_id = team_object.organization_id

View file

@ -24,7 +24,7 @@ from litellm.constants import SPEND_COUNTER_RESEED_LOCKS_MAX_SIZE
from litellm.litellm_core_utils.duration_parser import duration_in_seconds
from litellm.proxy._types import Litellm_EntityType
from litellm.proxy.db.db_lookup_gate import db_lookup_gate
from litellm.proxy.spend_tracking.spend_counter_batch import active_spend_counter_batch
from litellm.proxy.spend_tracking.spend_counter_batch import read_batched_spend_counter, record_spend_counter_value
from litellm.repositories.organization_repository import OrganizationRepository
from litellm.repositories.table_repositories import (
BudgetWindowSpendRepository,
@ -197,9 +197,8 @@ class SpendCounterReseed:
@staticmethod
async def _read_active_batch(counter_key: str) -> tuple[float | None, bool] | None:
"""The request's admission MGET already answered for this counter; a Redis miss there is authoritative."""
batch: Final = active_spend_counter_batch()
return None if batch is None else await batch.read(counter_key)
"""The request's MGET answers for this counter; a Redis miss there is authoritative."""
return await read_batched_spend_counter(counter_key)
@staticmethod
async def coalesced(
@ -263,6 +262,7 @@ class SpendCounterReseed:
key=counter_key,
value=current_value,
)
record_spend_counter_value(counter_key, current_value)
else:
cached_spend: Final = spend_counter_cache.in_memory_cache.get_cache(key=counter_key)
seeded_spend: Final = max(db_spend, float(cached_spend)) if cached_spend is not None else db_spend
@ -414,8 +414,11 @@ class SpendCounterReseed:
) -> float | None:
lock: Final = await SpendCounterReseed._get_lock(counter_key)
async with lock:
redis_clean_miss = False
if spend_counter_cache.redis_cache is not None:
batched: Final = await SpendCounterReseed._read_active_batch(counter_key)
if batched is not None and batched[0] is not None:
return batched[0]
redis_clean_miss = batched is not None
if spend_counter_cache.redis_cache is not None and not redis_clean_miss:
try:
val = await spend_counter_cache.redis_cache.async_get_cache(key=counter_key)
if val is not None:
@ -459,6 +462,7 @@ class SpendCounterReseed:
key=counter_key,
value=current_value,
)
record_spend_counter_value(counter_key, float(current_value))
else:
cached_spend: Final = spend_counter_cache.in_memory_cache.get_cache(key=counter_key)
seeded_spend: Final = (

View file

@ -64,6 +64,7 @@ from litellm.proxy.common_utils.callback_utils import (
strip_callback_config,
)
from litellm.proxy.common_utils.http_parsing_utils import _safe_get_request_headers
from litellm.proxy.spend_tracking.carried_budget_state import carried_budget_metadata
from litellm.types.integrations.anthropic_cache_control_hook import GATEWAY_INJECTED_CACHE_METADATA_KEY
# Cache special headers as a frozenset for O(1) lookup performance
@ -2300,6 +2301,7 @@ async def add_litellm_data_to_request(
data[_metadata_variable_name]["user_api_key_user_max_budget"] = user_api_key_dict.user_max_budget
user_model_budget: Final = user_api_key_dict.user_model_max_budget
data[_metadata_variable_name]["user_api_key_user_model_max_budget"] = user_model_budget # rebind-ok: out-param
data[_metadata_variable_name].update(carried_budget_metadata(user_api_key_dict))
data[_metadata_variable_name]["user_api_key_metadata"] = strip_callback_config(user_api_key_dict.metadata)
data[_metadata_variable_name]["user_api_key_team_metadata"] = strip_callback_config(user_api_key_dict.team_metadata)

View file

@ -26,7 +26,6 @@ from collections.abc import (
MutableMapping,
Sequence,
)
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from types import MappingProxyType, UnionType
from typing import (
@ -659,7 +658,15 @@ from litellm.proxy.route_priority import hot_routes_first
from litellm.proxy.search_endpoints.endpoints import router as search_router
from litellm.proxy.shutdown.graceful_shutdown_manager import GracefulShutdownManager
from litellm.proxy.spend_tracking.budget_reservation import get_budget_window_start
from litellm.proxy.spend_tracking.spend_counter_batch import active_spend_counter_batch
from litellm.proxy.spend_tracking.spend_counter_batch import (
PendingSpendIncrement,
active_spend_counter_batch,
forget_spend_counter,
post_call_counter_keys,
read_batched_spend_counter,
record_spend_counter_value,
spend_counter_batch_scope,
)
from litellm.proxy.spend_tracking.spend_management_endpoints import (
router as spend_management_router,
)
@ -2628,6 +2635,7 @@ async def _repair_stale_spend_counter(counter_key: str, db_spend: float) -> None
if needs_update:
spend_counter_cache.in_memory_cache.set_cache(key=counter_key, value=db_spend)
if spend_counter_cache.redis_cache is not None:
forget_spend_counter(counter_key)
try:
await spend_counter_cache.redis_cache.async_set_max(key=counter_key, value=db_spend)
except Exception:
@ -2768,12 +2776,6 @@ async def _read_spend_counter_estimate(counter_key: str, fallback_spend: float)
return fallback_spend, False
@dataclass(frozen=True, slots=True)
class _PendingSpendIncrement:
counter_key: str
increment: float
async def increment_spend_counters(
token: str | None,
team_id: str | None,
@ -2796,6 +2798,45 @@ async def increment_spend_counters(
Awaited (not create_task) in the cost callback, so the counter is
updated before the next request's auth check runs.
"""
with spend_counter_batch_scope(
spend_counter_cache.redis_cache,
counter_keys=post_call_counter_keys(
token=token,
team_id=team_id,
user_id=user_id,
org_id=org_id,
end_user_id=end_user_id,
tags=tags,
model_access_groups=model_access_groups,
),
):
await _increment_spend_counters_batched(
token=token,
team_id=team_id,
user_id=user_id,
response_cost=response_cost,
org_id=org_id,
budget_reservation=budget_reservation,
end_user_id=end_user_id,
tags=tags,
request_started_at=request_started_at,
model_access_groups=model_access_groups,
)
async def _increment_spend_counters_batched(
token: str | None,
team_id: str | None,
user_id: str | None,
response_cost: float | None,
org_id: str | None,
budget_reservation: dict | None,
end_user_id: str | None,
tags: list[str] | None,
request_started_at: datetime | None,
model_access_groups: Sequence[str] | None,
):
"""Runs inside one spend counter batch: the reservation reconcile and the warm checks share a single MGET."""
reserved_counter_keys: Final = await _reconcile_budget_reservation_for_counter_update(
budget_reservation=budget_reservation,
response_cost=response_cost,
@ -2808,7 +2849,7 @@ async def increment_spend_counters(
cost: Final[float] = response_cost
async def _key_scope(key_token: str) -> tuple[_PendingSpendIncrement | BaseException, ...]:
async def _key_scope(key_token: str) -> tuple[PendingSpendIncrement | BaseException, ...]:
# key_token arrives pre-hashed from metadata["user_api_key"] (auth flow
# hashes raw "sk-..." keys before they reach the callback). The
# startswith("sk-") check is a safety net matching update_cache —
@ -2819,7 +2860,7 @@ async def increment_spend_counters(
hash_token(token=key_token) if isinstance(key_token, str) and key_token.startswith("sk-") else key_token
)
key_counter_key: Final = f"spend:key:{hashed_token}"
key_pending: Final[tuple[_PendingSpendIncrement, ...]] = (
key_pending: Final[tuple[PendingSpendIncrement, ...]] = (
()
if key_counter_key in reserved_counter_keys
else (
@ -2831,7 +2872,7 @@ async def increment_spend_counters(
)
)
async def _key_window_increment(window: object) -> _PendingSpendIncrement | None:
async def _key_window_increment(window: object) -> PendingSpendIncrement | None:
duration = (
window["budget_duration"] if isinstance(window, dict) else getattr(window, "budget_duration", None)
)
@ -2878,9 +2919,9 @@ async def increment_spend_counters(
)
return key_pending + tuple(item for item in window_pending if item is not None)
async def _team_scope(scope_team_id: str) -> tuple[_PendingSpendIncrement | BaseException, ...]:
async def _team_scope(scope_team_id: str) -> tuple[PendingSpendIncrement | BaseException, ...]:
team_counter_key: Final = f"spend:team:{scope_team_id}"
team_pending: Final[tuple[_PendingSpendIncrement, ...]] = (
team_pending: Final[tuple[PendingSpendIncrement, ...]] = (
()
if team_counter_key in reserved_counter_keys
else (
@ -2892,7 +2933,7 @@ async def increment_spend_counters(
)
)
async def _team_window_increment(window: object) -> _PendingSpendIncrement | None:
async def _team_window_increment(window: object) -> PendingSpendIncrement | None:
duration = (
window["budget_duration"] if isinstance(window, dict) else getattr(window, "budget_duration", None)
)
@ -2941,7 +2982,7 @@ async def increment_spend_counters(
async def _team_member_scope(
scope_user_id: str, scope_team_id: str
) -> tuple[_PendingSpendIncrement | BaseException, ...]:
) -> tuple[PendingSpendIncrement | BaseException, ...]:
team_member_counter_key: Final = f"spend:team_member:{scope_user_id}:{scope_team_id}"
if team_member_counter_key in reserved_counter_keys:
return ()
@ -2953,7 +2994,7 @@ async def increment_spend_counters(
),
)
async def _user_scope(scope_user_id: str) -> tuple[_PendingSpendIncrement | BaseException, ...]:
async def _user_scope(scope_user_id: str) -> tuple[PendingSpendIncrement | BaseException, ...]:
user_counter_key: Final = f"spend:user:{scope_user_id}"
if user_counter_key in reserved_counter_keys:
return ()
@ -3063,7 +3104,7 @@ async def _prepare_end_user_and_tag_spend_increments(
tags: list[str] | None,
response_cost: float,
reserved_counter_keys: set[str],
) -> tuple[_PendingSpendIncrement | BaseException, ...]:
) -> tuple[PendingSpendIncrement | BaseException, ...]:
unique_tags: Final = (
tuple(dict.fromkeys(tag for tag in tags if tag and isinstance(tag, str))) if tags is not None else ()
)
@ -3100,7 +3141,7 @@ async def _prepare_model_access_group_spend_increments(
model_access_groups: Sequence[object],
response_cost: float,
reserved_counter_keys: set[str],
) -> tuple[_PendingSpendIncrement | BaseException, ...]:
) -> tuple[PendingSpendIncrement | BaseException, ...]:
"""Charge the model access groups that authorized this request.
Without this the counter auth reads is written only by the reservation path, so
@ -3133,7 +3174,7 @@ async def _prepare_org_spend_increment(
org_id: str | None,
response_cost: float,
reserved_counter_keys: set[str],
) -> tuple[_PendingSpendIncrement, ...]:
) -> tuple[PendingSpendIncrement, ...]:
if org_id is None:
return ()
@ -3151,7 +3192,7 @@ async def _prepare_unreserved_spend_counter_increment(
source_cache_key: str | list[str],
increment: float,
reserved_counter_keys: set[str],
) -> _PendingSpendIncrement | None:
) -> PendingSpendIncrement | None:
if counter_key in reserved_counter_keys:
return None
@ -3166,7 +3207,7 @@ async def _prepare_spend_counter_increment(
counter_key: str,
source_cache_key: str | list[str],
increment: float,
) -> _PendingSpendIncrement:
) -> PendingSpendIncrement:
"""
Initialize counter from the authoritative DB spend value if not yet
set, then return the pending increment for the caller to apply in one
@ -3188,7 +3229,7 @@ async def _prepare_spend_counter_increment(
counter_key=counter_key,
source_cache_key=source_cache_key,
)
return _PendingSpendIncrement(counter_key=counter_key, increment=increment)
return PendingSpendIncrement(counter_key=counter_key, increment=increment)
async def _enqueue_window_spend_row_update(
@ -3247,7 +3288,7 @@ async def _prepare_window_spend_counter_increment(
window_duration: str | None,
window_start: datetime | None,
increment: float,
) -> _PendingSpendIncrement | None:
) -> PendingSpendIncrement | None:
if window_start is None:
verbose_proxy_logger.warning(
"Skipping spend counter increment for invalid budget window %s",
@ -3264,7 +3305,7 @@ async def _prepare_window_spend_counter_increment(
)
if initialized is False:
return None
return _PendingSpendIncrement(counter_key=counter_key, increment=increment)
return PendingSpendIncrement(counter_key=counter_key, increment=increment)
async def _ensure_spend_counter_initialized(
@ -3331,6 +3372,14 @@ async def _ensure_window_spend_counter_initialized(
async def _is_spend_counter_cache_warm(counter_key: str) -> bool:
batched: Final = await read_batched_spend_counter(counter_key)
if batched is not None:
batched_value, _ = batched
if batched_value is None:
return False
spend_counter_cache.in_memory_cache.set_cache(key=counter_key, value=batched_value)
return True
if spend_counter_cache.redis_cache is not None:
try:
current_value: Final[object] = await spend_counter_cache.redis_cache.async_get_cache(
@ -3375,6 +3424,7 @@ async def _increment_spend_counter_cache(counter_key: str, increment: float):
key=counter_key,
value=current_value,
)
record_spend_counter_value(counter_key, float(current_value))
return current_value
return await SpendCounterReseed.increment_in_memory(
@ -3383,6 +3433,7 @@ async def _increment_spend_counter_cache(counter_key: str, increment: float):
async def _invalidate_spend_counter(counter_key: str):
forget_spend_counter(counter_key)
spend_counter_cache.in_memory_cache.delete_cache(key=counter_key)
if spend_counter_cache.redis_cache is not None:
try:
@ -3395,7 +3446,16 @@ async def _invalidate_spend_counter(counter_key: str):
)
async def _apply_spend_counter_increments(pending: Sequence[_PendingSpendIncrement]) -> None:
async def _apply_spend_counter_increments(pending: Sequence[PendingSpendIncrement]) -> None:
try:
await increment_spend_counters_pipeline(pending=pending)
except RedisCircuitBreakerOpenError:
return
async def increment_spend_counters_pipeline(pending: Sequence[PendingSpendIncrement]) -> None:
"""One INCRBYFLOAT+EXPIRE pipeline for every pending counter; on failure every counter is invalidated
before the error propagates, so no caller can read a half-applied batch."""
if not pending:
return
redis_cache: Final = spend_counter_cache.redis_cache
@ -3412,13 +3472,12 @@ async def _apply_spend_counter_increments(pending: Sequence[_PendingSpendIncreme
]
try:
results: Final = await redis_cache.async_increment_pipeline(increment_list=increment_list)
except Exception as e:
except Exception:
await asyncio.gather(*(_invalidate_spend_counter(counter_key=item.counter_key) for item in pending))
if isinstance(e, RedisCircuitBreakerOpenError):
return
raise
for item, current_value in zip(pending, results or ()):
spend_counter_cache.in_memory_cache.set_cache(key=item.counter_key, value=current_value)
record_spend_counter_value(item.counter_key, float(current_value))
async def update_cache(

View file

@ -32,6 +32,7 @@ from litellm.proxy.common_utils.user_api_key_cache import (
tag_cache_key,
team_membership_reservation_cache_key,
)
from litellm.proxy.spend_tracking.spend_counter_batch import PendingSpendIncrement, spend_counter_batch_scope
from litellm.proxy.utils import PrismaClient, ProxyLogging
from litellm.router import Router
from litellm.rust_bridge.token_counter import RustTokenizer, count_input_tokens, rust_tokenizer
@ -257,46 +258,47 @@ async def reserve_budget_for_request(
applied_entries: Final[list[dict[str, float | str]]] = []
try:
for counter in counters:
entry = _counter_to_reservation_entry(
counter=counter,
reserved_cost=reservation_cost,
)
applied_entries.append(entry)
try:
reserved_value = await _reserve_counter(
with _counters_batch_scope(frozenset(counter.counter_key for counter in counters)):
for counter in counters:
entry = _counter_to_reservation_entry(
counter=counter,
reservation_cost=reservation_cost,
reserved_cost=reservation_cost,
)
except _CounterReservationUnavailable as exc:
if exc.touched_counter and not exc.counter_invalidated:
await _release_applied_entries_best_effort(
entries=[entry],
default_reserved_cost=reservation_cost,
applied_entries.append(entry)
try:
reserved_value = await _reserve_counter(
counter=counter,
reservation_cost=reservation_cost,
)
applied_entries.remove(entry)
if fail_closed_budget_enforcement:
_raise_reservation_unavailable(counter_key=counter.counter_key)
continue
except _CounterReservationUnavailable as exc:
if exc.touched_counter and not exc.counter_invalidated:
await _release_applied_entries_best_effort(
entries=[entry],
default_reserved_cost=reservation_cost,
)
applied_entries.remove(entry)
if fail_closed_budget_enforcement:
_raise_reservation_unavailable(counter_key=counter.counter_key)
continue
if reserved_value is not None:
current_spend = reserved_value
else:
cached_spend = current_spend_by_counter_key.get(counter.counter_key)
if cached_spend is None:
cached_spend = await _get_current_counter_value(counter=counter)
current_spend = cached_spend + reservation_cost
if current_spend > counter.max_budget:
reservation_cost = await _apply_over_budget_reservation_policy(
counter=counter,
valid_token=valid_token,
entry=entry,
applied_entries=applied_entries,
reservation_cost=reservation_cost,
current_spend=current_spend,
fail_closed_budget_enforcement=fail_closed_budget_enforcement,
)
continue
if reserved_value is not None:
current_spend = reserved_value
else:
cached_spend = current_spend_by_counter_key.get(counter.counter_key)
if cached_spend is None:
cached_spend = await _get_current_counter_value(counter=counter)
current_spend = cached_spend + reservation_cost
if current_spend > counter.max_budget:
reservation_cost = await _apply_over_budget_reservation_policy(
counter=counter,
valid_token=valid_token,
entry=entry,
applied_entries=applied_entries,
reservation_cost=reservation_cost,
current_spend=current_spend,
fail_closed_budget_enforcement=fail_closed_budget_enforcement,
)
continue
except Exception:
await _release_applied_entries_best_effort(
entries=applied_entries,
@ -878,67 +880,92 @@ async def _get_current_counter_value(counter: _BudgetCounter) -> float:
)
def _counters_batch_scope(counter_keys: frozenset[str]) -> spend_counter_batch_scope:
"""Each counter is read once, then written, so one MGET up front serves every read in the loop."""
from litellm.proxy.proxy_server import spend_counter_cache
return spend_counter_batch_scope(spend_counter_cache.redis_cache, counter_keys=counter_keys)
@dataclass(frozen=True, slots=True)
class _EntryAdjustment:
entry: dict[str, float | str]
counter_key: str
target_adjustment: float
adjustment: float
def _entry_adjustment(
entry: dict[str, float | str], actual_cost: float, default_reserved_cost: float
) -> _EntryAdjustment | None:
counter_key: Final = entry.get("counter_key")
if counter_key is None:
return None
target_adjustment: Final = actual_cost - _get_entry_reserved_cost(
entry=entry, default_reserved_cost=default_reserved_cost
)
adjustment: Final = target_adjustment - float(entry.get("applied_adjustment") or 0.0)
if adjustment == 0:
return None
return _EntryAdjustment(
entry=entry, counter_key=str(counter_key), target_adjustment=target_adjustment, adjustment=adjustment
)
async def _set_reserved_entries_actual_cost(
entries: list[dict],
actual_cost: float,
default_reserved_cost: float,
reseed_on_inconsistent: bool = True,
) -> None:
for entry in entries:
await _set_reserved_entry_actual_cost(
entry=entry,
actual_cost=actual_cost,
default_reserved_cost=default_reserved_cost,
reseed_on_inconsistent=reseed_on_inconsistent,
"""Every reserved counter is read from one MGET and the consistent adjustments go out in one pipeline.
A counter that was flushed or reseeded since reservation is settled on its own after the pipeline."""
from litellm.proxy.proxy_server import increment_spend_counters_pipeline
with _counters_batch_scope(frozenset(str(entry["counter_key"]) for entry in entries if "counter_key" in entry)):
adjustments: Final = tuple(
adjustment
for entry in entries
if (adjustment := _entry_adjustment(entry, actual_cost, default_reserved_cost)) is not None
)
async def _set_reserved_entry_actual_cost(
entry: dict,
actual_cost: float,
default_reserved_cost: float,
reseed_on_inconsistent: bool = True,
) -> None:
from litellm.proxy.proxy_server import (
_increment_spend_counter_cache,
reseed_spend_counter_from_db,
)
counter_key: Final = entry.get("counter_key")
if counter_key is None:
return
reserved_cost: Final = _get_entry_reserved_cost(
entry=entry,
default_reserved_cost=default_reserved_cost,
)
target_adjustment: Final = actual_cost - reserved_cost
applied_adjustment: Final = float(entry.get("applied_adjustment") or 0.0)
adjustment: Final = target_adjustment - applied_adjustment
if adjustment == 0:
return
if await _counter_can_apply_adjustment(
counter_key=counter_key,
adjustment=adjustment,
):
await _increment_spend_counter_cache(
counter_key=counter_key,
increment=adjustment,
consistent: Final = tuple(
await asyncio.gather(
*(
_counter_can_apply_adjustment(counter_key=item.counter_key, adjustment=item.adjustment)
for item in adjustments
)
)
)
elif reseed_on_inconsistent:
# Post-call reconcile / release: the counter was flushed, expired or reseeded
# between reservation and reconcile, so the optimistic delta no longer applies.
# Reseed from the DB floor (which cannot include this request's cost yet) and
# add the settled cost, since increment_spend_counters skips reserved keys.
reseeded: Final = await reseed_spend_counter_from_db(counter_key=counter_key)
if reseeded and actual_cost > 0:
await _increment_spend_counter_cache(counter_key=counter_key, increment=actual_cost)
else:
# Pre-call admission resize: the in-flight reservation cost is not yet
# persisted, so the DB floor would discard it. Keep the original
# fail-closed behavior (raise -> reserve_budget_for_request releases and
# denies) rather than admitting against an inconsistent counter.
raise RuntimeError(f"Cannot resize budget reservation against inconsistent counter {counter_key}")
entry["applied_adjustment"] = target_adjustment
inconsistent: Final = tuple(item for item, ok in zip(adjustments, consistent) if not ok)
if inconsistent and not reseed_on_inconsistent:
# Pre-call admission resize: the in-flight reservation cost is not yet
# persisted, so the DB floor would discard it. Keep the original
# fail-closed behavior (raise -> reserve_budget_for_request releases and
# denies) rather than admitting against an inconsistent counter.
raise RuntimeError(
f"Cannot resize budget reservation against inconsistent counter {inconsistent[0].counter_key}"
)
applicable: Final = tuple(item for item, ok in zip(adjustments, consistent) if ok)
await increment_spend_counters_pipeline(
pending=tuple(
PendingSpendIncrement(counter_key=item.counter_key, increment=item.adjustment) for item in applicable
)
)
for item in inconsistent:
await _reseed_reserved_entry(item=item, actual_cost=actual_cost)
for item in adjustments:
item.entry["applied_adjustment"] = item.target_adjustment
async def _reseed_reserved_entry(item: _EntryAdjustment, actual_cost: float) -> None:
"""Post-call reconcile / release of a counter that was flushed, expired or reseeded between reservation and
reconcile: the optimistic delta no longer applies, so reseed from the DB floor (which cannot include this
request's cost yet) and add the settled cost, since increment_spend_counters skips reserved keys."""
from litellm.proxy.proxy_server import _increment_spend_counter_cache, reseed_spend_counter_from_db
reseeded: Final = await reseed_spend_counter_from_db(counter_key=item.counter_key)
if reseeded and actual_cost > 0:
await _increment_spend_counter_cache(counter_key=item.counter_key, increment=actual_cost)
async def _counter_can_apply_adjustment(
@ -963,8 +990,8 @@ async def _release_applied_entries_best_effort(
) -> None:
for entry in entries:
try:
await _set_reserved_entry_actual_cost(
entry=entry,
await _set_reserved_entries_actual_cost(
entries=[entry], # mutable-ok: the reconcile takes the reservation's list of entries
actual_cost=0.0,
default_reserved_cost=default_reserved_cost,
)

View file

@ -0,0 +1,60 @@
"""Pins the budget state auth resolved onto ``UserAPIKeyAuth`` and emits it as request metadata."""
from collections.abc import Mapping
from types import MappingProxyType
from typing import Final
from litellm.models.organization import LiteLLM_OrganizationTable
from litellm.models.team import LiteLLM_TeamTable
from litellm.models.user import LiteLLM_UserTable
from litellm.proxy._types import UserAPIKeyAuth
from litellm.types.proxy.carried_budget_state import (
OrgBudgetSnapshot,
TeamBudgetSnapshot,
UserBudgetSnapshot,
)
def carry_team_and_user_budget_state(
valid_token: UserAPIKeyAuth,
team_object: LiteLLM_TeamTable | None,
user_object: LiteLLM_UserTable | None,
) -> None:
if team_object is not None:
valid_token.team_budget_snapshot = TeamBudgetSnapshot( # rebind-ok: the request credential is pinned in place
budget_reset_at=team_object.budget_reset_at,
max_budget=team_object.max_budget,
)
if user_object is not None:
valid_token.user_budget_snapshot = UserBudgetSnapshot( # rebind-ok: same object the caller keeps using
budget_reset_at=user_object.budget_reset_at,
max_budget=user_object.max_budget,
user_alias=user_object.user_alias,
)
def carry_organization_budget_state(valid_token: UserAPIKeyAuth, org_table: LiteLLM_OrganizationTable) -> None:
budget_table: Final = org_table.litellm_budget_table
valid_token.organization_alias = (
org_table.organization_alias
) # rebind-ok: the request credential is pinned in place
valid_token.org_budget_snapshot = OrgBudgetSnapshot( # rebind-ok: same object the caller keeps using
spend=org_table.spend,
max_budget=budget_table.max_budget if budget_table is not None else None,
)
def carried_budget_metadata(valid_token: UserAPIKeyAuth) -> Mapping[str, object]:
snapshots: Final = (
valid_token.team_budget_snapshot,
valid_token.user_budget_snapshot,
valid_token.org_budget_snapshot,
)
return MappingProxyType(
{
key: value
for snapshot in snapshots
if snapshot is not None
for key, value in snapshot.metadata_entries().items()
}
)

View file

@ -1,8 +1,9 @@
"""One Redis MGET for every spend counter the admission checks read, instead of one GET per counter."""
"""One Redis MGET per phase (admission, reservation, post-call) for the spend counters it reads, not one GET each."""
import asyncio
from collections.abc import Iterator, Mapping
from collections.abc import Iterator, Mapping, Sequence
from contextvars import ContextVar, Token
from dataclasses import dataclass
from types import MappingProxyType, TracebackType
from typing import Final
@ -11,11 +12,18 @@ from pydantic import TypeAdapter
from litellm._logging import verbose_proxy_logger
from litellm.caching.redis_cache import RedisCache
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.common_utils.user_api_key_cache import model_access_group_spend_counter_key
_CounterValues: Final = TypeAdapter(dict[str, float | None])
_NO_VALUES: Final[Mapping[str, float | None]] = MappingProxyType({})
@dataclass(frozen=True, slots=True)
class PendingSpendIncrement:
counter_key: str
increment: float
class SpendCounterBatch:
"""Bound counters are read with one MGET on first use; counters bound later join the next MGET.
``async_batch_get_cache`` maps a clean miss to ``None`` and drops keys only when Redis failed, so an absent
@ -35,6 +43,10 @@ class SpendCounterBatch:
def counter_keys(self) -> frozenset[str]:
return self._keys
@property
def is_open(self) -> bool:
return self._open
def bind(self, counter_keys: frozenset[str]) -> None:
if self._open:
self._keys = self._keys | counter_keys
@ -52,12 +64,29 @@ class SpendCounterBatch:
return None
return loaded[counter_key], True
def record(self, counter_key: str, value: float) -> None:
"""A write returned the counter's new value; later reads in this scope see it instead of the MGET value."""
if not self._open:
return
key: Final = frozenset((counter_key,))
self._keys = self._keys | key
self._fetched = self._fetched | key
self._loaded = MappingProxyType({**self._loaded, counter_key: value})
def forget(self, counter_key: str) -> None:
"""A write left the counter's value unknown; later reads in this scope go to Redis."""
key: Final = frozenset((counter_key,))
self._keys = self._keys - key
self._fetched = self._fetched - key
self._loaded = MappingProxyType({k: v for k, v in self._loaded.items() if k != counter_key})
async def _load(self) -> Mapping[str, float | None]:
async with self._lock:
pending: Final = self._keys - self._fetched
if pending:
self._fetched = self._fetched | pending
self._loaded = MappingProxyType({**self._loaded, **await self._fetch(pending)})
fetched: Final = await self._fetch(pending)
self._loaded = MappingProxyType({**fetched, **self._loaded})
return self._loaded
async def _fetch(self, keys: frozenset[str]) -> Mapping[str, float | None]:
@ -78,17 +107,26 @@ def active_spend_counter_batch() -> SpendCounterBatch | None:
class spend_counter_batch_scope:
"""Reads inside the scope share one MGET once ``bind_admission_counter_keys`` has run."""
"""Reads inside the scope share one MGET for the keys bound here or by ``bind_*`` calls inside it.
Opened inside a scope whose batch is still open, it binds into that batch so both phases share the MGET."""
__slots__ = ("_redis_cache", "_token")
__slots__ = ("_counter_keys", "_redis_cache", "_token")
def __init__(self, redis_cache: RedisCache | None) -> None:
def __init__(self, redis_cache: RedisCache | None, counter_keys: frozenset[str] = frozenset()) -> None:
self._redis_cache: Final = redis_cache
self._counter_keys: Final = counter_keys
self._token: Token[SpendCounterBatch | None] | None = None
def __enter__(self) -> None:
if self._redis_cache is not None:
self._token = _active_batch.set(SpendCounterBatch(self._redis_cache))
if self._redis_cache is None:
return
outer: Final = _active_batch.get()
if outer is not None and outer.is_open:
outer.bind(self._counter_keys)
return
batch: Final = SpendCounterBatch(self._redis_cache)
batch.bind(self._counter_keys)
self._token = _active_batch.set(batch)
def __exit__(
self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None
@ -122,9 +160,58 @@ def admission_counter_keys(token: UserAPIKeyAuth, end_user_id: str | None) -> fr
return frozenset(_iter_admission_counter_keys(token, end_user_id))
def post_call_counter_keys(
token: str | None,
team_id: str | None,
user_id: str | None,
org_id: str | None,
end_user_id: str | None,
tags: Sequence[object] | None,
model_access_groups: Sequence[object] | None,
) -> frozenset[str]:
"""Every counter ``increment_spend_counters`` warm-checks, except budget windows which bind on read."""
entity_keys: Final = admission_counter_keys(
UserAPIKeyAuth(token=token, team_id=team_id, user_id=user_id, org_id=org_id), end_user_id
)
tag_keys: Final = frozenset(f"spend:tag:{tag}" for tag in tags or () if tag and isinstance(tag, str))
group_keys: Final = frozenset(
model_access_group_spend_counter_key(group)
for group in model_access_groups or ()
if group and isinstance(group, str)
)
return entity_keys | tag_keys | group_keys
def bind_admission_counter_keys(token: UserAPIKeyAuth, end_user_id: str | None) -> None:
"""Idempotent: call again after the token gains ids (end user, team org) so those counters join the MGET."""
bind_spend_counter_keys(admission_counter_keys(token, end_user_id))
def bind_spend_counter_keys(counter_keys: frozenset[str]) -> None:
batch: Final = _active_batch.get()
if batch is None:
return
batch.bind(admission_counter_keys(token, end_user_id))
batch.bind(counter_keys)
def record_spend_counter_value(counter_key: str, value: float) -> None:
batch: Final = _active_batch.get()
if batch is None:
return
batch.record(counter_key, value)
def forget_spend_counter(counter_key: str) -> None:
batch: Final = _active_batch.get()
if batch is None:
return
batch.forget(counter_key)
async def read_batched_spend_counter(counter_key: str) -> tuple[float | None, bool] | None:
"""Bind-on-read for counters only known at read time (budget windows); the first reader pays the MGET."""
batch: Final = _active_batch.get()
if batch is None:
return None
batch.bind(frozenset((counter_key,)))
return await batch.read(counter_key)

View file

@ -0,0 +1,64 @@
"""Budget fields auth already resolved, carried on the request so success logging does no object lookups.
Auth pins one snapshot per entity on ``UserAPIKeyAuth`` (request-scoped, never cached), the pre-call
setup writes them into the request metadata under the aliased ``user_api_key_*`` names, and a logger
reads them back with ``from_metadata``. ``None`` means this request never carried that entity
(unauthenticated route, custom auth, budget check skipped) and the logger keeps its own lookup.
"""
from collections.abc import Mapping
from datetime import datetime
from types import MappingProxyType
from pydantic import BaseModel, ConfigDict, Field, ValidationError
from typing_extensions import Self
class _BudgetSnapshot(BaseModel):
model_config = ConfigDict(frozen=True, populate_by_name=True, extra="ignore")
def metadata_entries(self) -> Mapping[str, object]:
return MappingProxyType(self.model_dump(by_alias=True, mode="json"))
@classmethod
def from_metadata(cls, metadata: Mapping[str, object]) -> Self | None:
try:
return cls.model_validate(metadata)
except ValidationError:
return None
class KeyBudgetSnapshot(_BudgetSnapshot):
"""Read-only view of the ``user_api_key_budget_reset_at`` entry ``add_user_api_key_auth_to_request_metadata`` writes."""
budget_reset_at: datetime | None = Field(
validation_alias="user_api_key_budget_reset_at", serialization_alias="user_api_key_budget_reset_at"
)
class TeamBudgetSnapshot(_BudgetSnapshot):
budget_reset_at: datetime | None = Field(
validation_alias="user_api_key_team_budget_reset_at", serialization_alias="user_api_key_team_budget_reset_at"
)
max_budget: float | None = Field(
validation_alias="user_api_key_team_table_max_budget", serialization_alias="user_api_key_team_table_max_budget"
)
class UserBudgetSnapshot(_BudgetSnapshot):
budget_reset_at: datetime | None = Field(
validation_alias="user_api_key_user_budget_reset_at", serialization_alias="user_api_key_user_budget_reset_at"
)
max_budget: float | None = Field(
validation_alias="user_api_key_user_table_max_budget", serialization_alias="user_api_key_user_table_max_budget"
)
user_alias: str | None = Field(
validation_alias="user_api_key_user_alias", serialization_alias="user_api_key_user_alias"
)
class OrgBudgetSnapshot(_BudgetSnapshot):
spend: float = Field(validation_alias="user_api_key_org_spend", serialization_alias="user_api_key_org_spend")
max_budget: float | None = Field(
validation_alias="user_api_key_org_max_budget", serialization_alias="user_api_key_org_max_budget"
)

View file

@ -1205,6 +1205,92 @@ async def test_a_probe_overtaken_by_a_later_outage_leaves_the_breaker_to_the_new
assert breaker._state == breaker.CLOSED
class _RoundTripCountingRedis:
"""Fake redis.asyncio client: one round trip per awaited command or pipeline execute."""
def __init__(self, ttl: int) -> None:
self.values: dict[str, float] = {}
self.ttls: dict[str, int] = {}
self.round_trips = 0
self._initial_ttl = ttl
async def incrbyfloat(self, name: str, amount: float) -> float:
self.round_trips += 1
return self._incr(name, amount)
async def expire(self, name: str, time: int) -> bool:
self.round_trips += 1
self.ttls[name] = time
return True
def _incr(self, name: str, amount: float) -> float:
self.values[name] = self.values.get(name, 0.0) + amount
self.ttls.setdefault(name, self._initial_ttl)
return self.values[name]
def pipeline(self, transaction: bool) -> "_RoundTripCountingRedis._Pipeline":
return _RoundTripCountingRedis._Pipeline(self)
class _Pipeline:
def __init__(self, client: "_RoundTripCountingRedis") -> None:
self._client = client
self._commands: list[tuple[str, tuple[object, ...]]] = []
async def __aenter__(self) -> "_RoundTripCountingRedis._Pipeline":
return self
async def __aexit__(self, *exc_info: object) -> None:
return None
def incrbyfloat(self, name: str, amount: float) -> None:
self._commands.append(("incrbyfloat", (name, amount)))
def expire(self, name: str, time: int) -> None:
self._commands.append(("expire", (name, time)))
def ttl(self, name: str) -> None:
self._commands.append(("ttl", (name,)))
async def execute(self) -> list[object]:
self._client.round_trips += 1
results: list[object] = []
for command, args in self._commands:
if command == "incrbyfloat":
results.append(self._client._incr(str(args[0]), float(args[1]))) # pyright: ignore[reportArgumentType] # fake stores str/float
elif command == "expire":
self._client.ttls[str(args[0])] = int(args[1]) # pyright: ignore[reportArgumentType] # fake stores int
results.append(True)
else:
results.append(self._client.ttls.get(str(args[0]), -2))
return results
@pytest.mark.asyncio
@pytest.mark.parametrize(
("refresh_ttl", "existing_ttl", "expected_round_trips", "expected_ttl"),
[
pytest.param(True, 100, 2, 60, id="refresh_ttl: INCRBYFLOAT+EXPIRE in one round trip each"),
pytest.param(False, 100, 2, 100, id="keep ttl: INCRBYFLOAT+TTL in one round trip each, no EXPIRE"),
pytest.param(False, -1, 3, 60, id="unexpiring key: INCRBYFLOAT+TTL then EXPIRE once, 1 trip after"),
],
)
async def test_async_increment_pipelines_the_ttl_command(
monkeypatch, redis_no_ping, refresh_ttl, existing_ttl, expected_round_trips, expected_ttl
):
monkeypatch.setenv("REDIS_HOST", "https://my-test-host")
redis_cache = RedisCache(namespace="ns")
client = _RoundTripCountingRedis(ttl=existing_ttl)
with patch.object(redis_cache, "init_async_client", return_value=client):
first = await redis_cache.async_increment(key="spend:key:k", value=1.5, ttl=60, refresh_ttl=refresh_ttl)
second = await redis_cache.async_increment(key="spend:key:k", value=2.0, ttl=60, refresh_ttl=refresh_ttl)
assert (first, second) == (1.5, 3.5)
assert client.values == {"ns:spend:key:k": 3.5}
assert client.ttls == {"ns:spend:key:k": expected_ttl}
assert client.round_trips == expected_round_trips
class _SetRecordingPipeline:
def __init__(self) -> None:
self.sets: list[tuple[str, str, timedelta | None]] = []

View file

@ -0,0 +1,289 @@
"""
Post-request budget gauges read the key/team/user/org state auth already resolved
from request metadata. get_*_object only runs when that state is missing (custom
auth, SDK callers, failure paths)
"""
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from prometheus_client import REGISTRY
from litellm.integrations.prometheus import PrometheusLogger
from litellm.proxy._types import (
LiteLLM_BudgetTable,
LiteLLM_OrganizationTable,
LiteLLM_TeamTable,
LiteLLM_UserTable,
UserAPIKeyAuth,
)
from litellm.proxy.litellm_pre_call_utils import LiteLLMProxyRequestSetup
from litellm.proxy.spend_tracking.carried_budget_state import (
carried_budget_metadata,
carry_organization_budget_state,
carry_team_and_user_budget_state,
)
from litellm.types.proxy.carried_budget_state import (
KeyBudgetSnapshot,
TeamBudgetSnapshot,
UserBudgetSnapshot,
)
TEAM_RESET_AT = datetime(2026, 10, 1, tzinfo=timezone.utc)
USER_RESET_AT = datetime(2026, 11, 1, tzinfo=timezone.utc)
KEY_RESET_AT = datetime(2026, 12, 1, tzinfo=timezone.utc)
@pytest.fixture(autouse=True)
def cleanup_prometheus_registry():
for collector in list(REGISTRY._collector_to_names.keys()):
try:
REGISTRY.unregister(collector)
except Exception:
pass
yield
for collector in list(REGISTRY._collector_to_names.keys()):
try:
REGISTRY.unregister(collector)
except Exception:
pass
@pytest.fixture
def prometheus_logger():
return PrometheusLogger()
@pytest.fixture
def getters():
"""Every response-path object getter, patched where prometheus imports them from."""
mocks = {
"get_key_object": AsyncMock(return_value=UserAPIKeyAuth(token="hashed", budget_reset_at=KEY_RESET_AT)),
"get_team_object": AsyncMock(
return_value=LiteLLM_TeamTable(team_id="t1", budget_reset_at=TEAM_RESET_AT, max_budget=300.0)
),
"get_user_object": AsyncMock(
return_value=LiteLLM_UserTable(
user_id="u1",
budget_reset_at=USER_RESET_AT,
user_email="alice@example.com",
user_alias="Alice",
max_budget=50.0,
)
),
"get_org_object": AsyncMock(
return_value=LiteLLM_OrganizationTable(
organization_id="o1",
organization_alias="platform-org",
budget_id="b1",
created_by="admin",
updated_by="admin",
spend=40.0,
litellm_budget_table=LiteLLM_BudgetTable(max_budget=500.0),
)
),
}
with (
patch.multiple( # test-quality-ok: prometheus reads these proxy_server globals at call time, no injection seam
"litellm.proxy.proxy_server", prisma_client=MagicMock(), user_api_key_cache=MagicMock()
),
patch.multiple( # test-quality-ok: the getters are the DB boundary this test counts calls to
"litellm.proxy.auth.auth_checks", **mocks
),
):
yield mocks
def _authed_token() -> UserAPIKeyAuth:
token = UserAPIKeyAuth(
token="hashed",
key_alias="key-alias",
team_id="t1",
team_alias="team-alias",
user_id="u1",
user_email="alice@example.com",
org_id="o1",
spend=1.0,
max_budget=10.0,
team_spend=20.0,
team_max_budget=300.0,
user_spend=5.0,
user_max_budget=50.0,
budget_reset_at=KEY_RESET_AT,
)
carry_team_and_user_budget_state(
valid_token=token,
team_object=LiteLLM_TeamTable(team_id="t1", budget_reset_at=TEAM_RESET_AT, max_budget=300.0),
user_object=LiteLLM_UserTable(user_id="u1", budget_reset_at=USER_RESET_AT, user_alias="Alice", max_budget=50.0),
)
carry_organization_budget_state(
valid_token=token,
org_table=LiteLLM_OrganizationTable(
organization_id="o1",
organization_alias="platform-org",
budget_id="b1",
created_by="admin",
updated_by="admin",
spend=40.0,
litellm_budget_table=LiteLLM_BudgetTable(max_budget=500.0),
),
)
return token
def _request_metadata(token: UserAPIKeyAuth) -> dict:
"""What add_user_api_key_auth_to_request_metadata leaves in litellm_params["metadata"]."""
return {
**LiteLLMProxyRequestSetup.get_sanitized_user_information_from_key(token),
**carried_budget_metadata(token),
}
def _stub_gauges(prometheus_logger: PrometheusLogger) -> None:
for name in (
"litellm_remaining_api_key_budget_metric",
"litellm_api_key_max_budget_metric",
"litellm_api_key_budget_remaining_hours_metric",
"litellm_remaining_team_budget_metric",
"litellm_team_max_budget_metric",
"litellm_team_budget_remaining_hours_metric",
"litellm_remaining_user_budget_metric",
"litellm_user_max_budget_metric",
"litellm_user_budget_remaining_hours_metric",
"litellm_remaining_org_budget_metric",
"litellm_org_max_budget_metric",
"litellm_org_budget_remaining_hours_metric",
):
setattr(prometheus_logger, name, MagicMock())
async def _emit(prometheus_logger: PrometheusLogger, metadata: dict) -> None:
await prometheus_logger._increment_remaining_budget_metrics(
user_api_team="t1",
user_api_team_alias="team-alias",
user_api_key="hashed",
user_api_key_alias="key-alias",
litellm_params={"metadata": metadata},
response_cost=2.0,
user_id="u1",
user_api_key_org_id="o1",
)
@pytest.mark.asyncio
async def test_authed_request_sets_every_gauge_without_any_object_getter(prometheus_logger, getters):
_stub_gauges(prometheus_logger)
await _emit(prometheus_logger, _request_metadata(_authed_token()))
assert all(getter.await_count == 0 for getter in getters.values()), {
name: getter.await_count for name, getter in getters.items()
}
remaining = {
"key": prometheus_logger.litellm_remaining_api_key_budget_metric.labels().set.call_args[0][0],
"team": prometheus_logger.litellm_remaining_team_budget_metric.labels().set.call_args[0][0],
"user": prometheus_logger.litellm_remaining_user_budget_metric.labels().set.call_args[0][0],
"org": prometheus_logger.litellm_remaining_org_budget_metric.labels().set.call_args[0][0],
}
assert remaining == {
"key": pytest.approx(7.0),
"team": pytest.approx(278.0),
"user": pytest.approx(43.0),
"org": 458.0,
}
prometheus_logger.litellm_org_max_budget_metric.labels().set.assert_called_once_with(500.0)
prometheus_logger.litellm_api_key_budget_remaining_hours_metric.labels().set.assert_called_once()
prometheus_logger.litellm_team_budget_remaining_hours_metric.labels().set.assert_called_once()
prometheus_logger.litellm_user_budget_remaining_hours_metric.labels().set.assert_called_once()
@pytest.mark.asyncio
async def test_metadata_without_carried_state_still_fetches_each_object_once(prometheus_logger, getters):
_stub_gauges(prometheus_logger)
await _emit(prometheus_logger, {"user_api_key_team_spend": 20.0, "user_api_key_team_max_budget": 300.0})
assert {name: getter.await_count for name, getter in getters.items()} == {
"get_key_object": 1,
"get_team_object": 1,
"get_user_object": 1,
"get_org_object": 1,
}
prometheus_logger.litellm_remaining_org_budget_metric.labels().set.assert_called_once_with(458.0)
prometheus_logger.litellm_team_budget_remaining_hours_metric.labels().set.assert_called_once()
@pytest.mark.asyncio
async def test_partial_carried_state_only_skips_the_carried_objects(prometheus_logger, getters):
_stub_gauges(prometheus_logger)
token = UserAPIKeyAuth(token="hashed", team_id="t1", user_id="u1", org_id="o1")
carry_team_and_user_budget_state(
valid_token=token,
team_object=LiteLLM_TeamTable(team_id="t1", budget_reset_at=TEAM_RESET_AT),
user_object=None,
)
await _emit(prometheus_logger, dict(carried_budget_metadata(token)))
assert {name: getter.await_count for name, getter in getters.items()} == {
"get_key_object": 1,
"get_team_object": 0,
"get_user_object": 1,
"get_org_object": 1,
}
@pytest.mark.asyncio
@pytest.mark.parametrize("metadata_max_budget", [300.0, None], ids=["metadata has max_budget", "filled from object"])
async def test_carried_objects_match_what_the_getters_would_have_produced(
prometheus_logger, getters, metadata_max_budget
):
metadata = _request_metadata(_authed_token())
user_max_budget = 50.0 if metadata_max_budget is not None else None
carried_team = await prometheus_logger._assemble_team_object(
team_id="t1",
team_alias="team-alias",
spend=20.0,
max_budget=metadata_max_budget,
response_cost=2.0,
carried=TeamBudgetSnapshot.from_metadata(metadata),
)
fetched_team = await prometheus_logger._assemble_team_object(
team_id="t1", team_alias="team-alias", spend=20.0, max_budget=metadata_max_budget, response_cost=2.0
)
carried_user = await prometheus_logger._assemble_user_object(
user_id="u1",
spend=5.0,
max_budget=user_max_budget,
response_cost=2.0,
carried=UserBudgetSnapshot.from_metadata(metadata),
user_email="alice@example.com",
)
fetched_user = await prometheus_logger._assemble_user_object(
user_id="u1", spend=5.0, max_budget=user_max_budget, response_cost=2.0
)
carried_key = await prometheus_logger._assemble_key_object(
user_api_key="hashed",
user_api_key_alias="key-alias",
key_max_budget=10.0,
key_spend=1.0,
response_cost=2.0,
carried=KeyBudgetSnapshot.from_metadata(metadata),
)
fetched_key = await prometheus_logger._assemble_key_object(
user_api_key="hashed", user_api_key_alias="key-alias", key_max_budget=10.0, key_spend=1.0, response_cost=2.0
)
assert carried_team == fetched_team
assert carried_team.max_budget == 300.0
assert carried_user == fetched_user
assert carried_user.max_budget == 50.0
assert carried_key == fetched_key
assert {name: getter.await_count for name, getter in getters.items()} == {
"get_key_object": 1,
"get_team_object": 1,
"get_user_object": 1,
"get_org_object": 0,
}

View file

@ -5791,6 +5791,42 @@ async def test_budget_checks_only_run_on_llm_api_routes(scope, route, expect_blo
assert await _run() is True
@pytest.mark.asyncio
async def test_organization_budget_check_carries_org_state_on_the_token():
"""The org row auth already fetched is pinned on the token so the response path
(Prometheus org budget gauges) reads it from request metadata instead of calling
get_org_object again."""
from litellm.proxy._types import LiteLLM_OrganizationTable
from litellm.proxy.auth.auth_checks import _organization_max_budget_check
from litellm.types.proxy.carried_budget_state import OrgBudgetSnapshot
org_table = LiteLLM_OrganizationTable(
organization_id="o1",
organization_alias="platform-org",
budget_id="b1",
created_by="admin",
updated_by="admin",
spend=12.5,
litellm_budget_table=LiteLLM_BudgetTable(max_budget=100.0),
)
token = UserAPIKeyAuth(token="k1", org_id="o1")
user_api_key_cache = UserApiKeyCache()
await user_api_key_cache.async_set_cache(
key="org_id:o1:with_budget", value=org_table, model_type=LiteLLM_OrganizationTable
)
await _organization_max_budget_check(
valid_token=token,
team_object=None,
prisma_client=MagicMock(),
user_api_key_cache=user_api_key_cache,
proxy_logging_obj=MagicMock(),
)
assert token.organization_alias == "platform-org"
assert token.org_budget_snapshot == OrgBudgetSnapshot(spend=12.5, max_budget=100.0)
@pytest.mark.parametrize("route", ["/health", "/health/services", "/health/test_connection"])
@pytest.mark.asyncio
async def test_spend_capable_non_llm_routes_still_enforce_budget(route):

View file

@ -5,7 +5,7 @@ import os
import subprocess
import sys
from contextlib import contextmanager
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from pathlib import Path
from textwrap import dedent
from types import SimpleNamespace
@ -23,6 +23,7 @@ from litellm.proxy._types import (
LiteLLM_JWTAuth,
LiteLLM_BudgetTable,
LiteLLM_EndUserTable,
LiteLLM_TeamTableCachedObj,
LiteLLM_UserTable,
LitellmUserRoles,
ProxyErrorTypes,
@ -48,6 +49,7 @@ from litellm.proxy.auth.user_api_key_auth import (
get_api_key,
user_api_key_auth,
)
from litellm.proxy.spend_tracking.carried_budget_state import carried_budget_metadata
class _RoutingRequest:
@ -4019,6 +4021,65 @@ async def test_centralized_common_checks_routes_header_tags_to_litellm_metadata(
assert "metadata" not in request_data
@pytest.mark.asyncio
async def test_centralized_common_checks_carries_team_and_user_budget_state_on_the_token():
"""The team and user objects auth resolves are pinned on the token so the
response path (Prometheus budget gauges) reads them from request metadata
instead of calling get_team_object / get_user_object again."""
from fastapi import Request
from starlette.datastructures import URL
import litellm.proxy.proxy_server as _proxy_server_mod
reset_at = datetime(2026, 10, 1, tzinfo=timezone.utc)
token = UserAPIKeyAuth(api_key="sk-test", token="hashed", team_id="t1", user_id="u1")
request = Request(scope={"type": "http"})
request._url = URL(url="/chat/completions")
user_api_key_cache = DualCache()
await user_api_key_cache.async_set_cache(
key="team_id:t1",
value=LiteLLM_TeamTableCachedObj(team_id="t1", budget_reset_at=reset_at, max_budget=300.0),
)
await user_api_key_cache.async_set_cache(
key="u1",
value=LiteLLM_UserTable(user_id="u1", user_alias="Alice", budget_reset_at=None, max_budget=None),
)
proxy_logging_obj = MagicMock()
proxy_logging_obj.service_logging_obj.async_service_success_hook = AsyncMock()
attrs = {
**_proxy_attrs_for_centralized_checks(user_custom_auth=None),
"prisma_client": MagicMock(),
"user_api_key_cache": user_api_key_cache,
"proxy_logging_obj": proxy_logging_obj,
}
originals = {a: getattr(_proxy_server_mod, a, None) for a in attrs}
try:
for k, v in attrs.items():
setattr(_proxy_server_mod, k, v)
with patch( # test-quality-ok: the authz gate has its own tests above; this one checks the carry step before it
"litellm.proxy.auth.user_api_key_auth.common_checks",
new_callable=AsyncMock,
):
await _run_centralized_common_checks(
user_api_key_auth_obj=token,
request=request,
request_data={"model": "gpt-5.4-mini"},
route="/chat/completions",
)
finally:
for k, v in originals.items():
setattr(_proxy_server_mod, k, v)
assert dict(carried_budget_metadata(token)) == {
"user_api_key_team_budget_reset_at": "2026-10-01T00:00:00Z",
"user_api_key_team_table_max_budget": 300.0,
"user_api_key_user_budget_reset_at": None,
"user_api_key_user_table_max_budget": None,
"user_api_key_user_alias": "Alice",
}
@pytest.mark.asyncio
async def test_centralized_common_checks_skipped_for_custom_auth_without_flag():
"""Existing RPS guarantee: custom-auth deployments without

View file

@ -350,7 +350,7 @@ async def test_cold_reseed_preserves_concurrent_local_increment(
increment_task: Final = asyncio.create_task(
proxy_server._apply_spend_counter_increments(
pending=(proxy_server._PendingSpendIncrement(counter_key=counter_key, increment=increment),)
pending=(proxy_server.PendingSpendIncrement(counter_key=counter_key, increment=increment),)
)
if batch
else proxy_server._increment_spend_counter_cache(counter_key=counter_key, increment=increment)

View file

@ -1151,10 +1151,10 @@ async def test_prepare_window_spend_counter_increment_missing_window_start_inval
# ---------------------------------------------------------------------------
def _two_pending_increments() -> tuple[ps._PendingSpendIncrement, ...]:
def _two_pending_increments() -> tuple[ps.PendingSpendIncrement, ...]:
return (
ps._PendingSpendIncrement(counter_key="spend:key:k", increment=1.5),
ps._PendingSpendIncrement(counter_key="spend:team:t", increment=1.5),
ps.PendingSpendIncrement(counter_key="spend:key:k", increment=1.5),
ps.PendingSpendIncrement(counter_key="spend:team:t", increment=1.5),
)

View file

@ -0,0 +1,119 @@
"""Auth-resolved budget state rides on ``UserAPIKeyAuth`` and round-trips through request metadata."""
from datetime import datetime, timezone
from litellm.proxy._types import (
LiteLLM_BudgetTable,
LiteLLM_OrganizationTable,
LiteLLM_TeamTable,
LiteLLM_UserTable,
UserAPIKeyAuth,
)
from litellm.proxy.spend_tracking.carried_budget_state import (
carried_budget_metadata,
carry_organization_budget_state,
carry_team_and_user_budget_state,
)
from litellm.types.proxy.carried_budget_state import (
KeyBudgetSnapshot,
OrgBudgetSnapshot,
TeamBudgetSnapshot,
UserBudgetSnapshot,
)
RESET_AT = datetime(2026, 10, 1, 12, 30, tzinfo=timezone.utc)
def test_team_and_user_state_round_trips_through_metadata():
token = UserAPIKeyAuth(token="hashed", team_id="t1", user_id="u1")
carry_team_and_user_budget_state(
valid_token=token,
team_object=LiteLLM_TeamTable(team_id="t1", budget_reset_at=RESET_AT, max_budget=300.0),
user_object=LiteLLM_UserTable(user_id="u1", budget_reset_at=None, max_budget=None, user_alias="Alice"),
)
metadata = dict(carried_budget_metadata(token))
assert metadata == {
"user_api_key_team_budget_reset_at": RESET_AT.isoformat().replace("+00:00", "Z"),
"user_api_key_team_table_max_budget": 300.0,
"user_api_key_user_budget_reset_at": None,
"user_api_key_user_table_max_budget": None,
"user_api_key_user_alias": "Alice",
}
assert TeamBudgetSnapshot.from_metadata(metadata) == TeamBudgetSnapshot(budget_reset_at=RESET_AT, max_budget=300.0)
assert UserBudgetSnapshot.from_metadata(metadata) == UserBudgetSnapshot(
budget_reset_at=None, max_budget=None, user_alias="Alice"
)
def test_missing_objects_leave_no_metadata_and_no_snapshot():
token = UserAPIKeyAuth(token="hashed", team_id="t1", user_id="u1")
carry_team_and_user_budget_state(valid_token=token, team_object=None, user_object=None)
assert dict(carried_budget_metadata(token)) == {}
assert TeamBudgetSnapshot.from_metadata({}) is None
assert UserBudgetSnapshot.from_metadata({"user_api_key_user_alias": "Alice"}) is None
assert OrgBudgetSnapshot.from_metadata({"user_api_key_org_spend": 1.0}) is None
assert KeyBudgetSnapshot.from_metadata({}) is None
def test_organization_state_carries_alias_spend_and_max_budget():
token = UserAPIKeyAuth(token="hashed", org_id="o1")
org = LiteLLM_OrganizationTable(
organization_id="o1",
organization_alias="platform-org",
budget_id="b1",
created_by="admin",
updated_by="admin",
spend=12.5,
litellm_budget_table=LiteLLM_BudgetTable(max_budget=100.0),
)
carry_organization_budget_state(valid_token=token, org_table=org)
assert token.organization_alias == "platform-org"
assert OrgBudgetSnapshot.from_metadata(carried_budget_metadata(token)) == OrgBudgetSnapshot(
spend=12.5, max_budget=100.0
)
def test_organization_without_budget_table_carries_no_cap():
token = UserAPIKeyAuth(token="hashed", org_id="o1")
org = LiteLLM_OrganizationTable(
organization_id="o1",
budget_id="b1",
created_by="admin",
updated_by="admin",
spend=3.0,
)
carry_organization_budget_state(valid_token=token, org_table=org)
assert token.org_budget_snapshot == OrgBudgetSnapshot(spend=3.0, max_budget=None)
def test_key_snapshot_parses_the_iso_string_auth_metadata_writes():
assert KeyBudgetSnapshot.from_metadata({"user_api_key_budget_reset_at": RESET_AT.isoformat()}) == KeyBudgetSnapshot(
budget_reset_at=RESET_AT
)
assert KeyBudgetSnapshot.from_metadata({"user_api_key_budget_reset_at": None}) == KeyBudgetSnapshot(
budget_reset_at=None
)
def test_snapshots_never_reach_the_serialized_token():
token = UserAPIKeyAuth(token="hashed", team_id="t1", user_id="u1", org_id="o1")
carry_team_and_user_budget_state(
valid_token=token,
team_object=LiteLLM_TeamTable(team_id="t1", budget_reset_at=RESET_AT),
user_object=LiteLLM_UserTable(user_id="u1", user_alias="Alice"),
)
token.org_budget_snapshot = OrgBudgetSnapshot(spend=1.0, max_budget=2.0)
dumped = token.model_dump()
assert "team_budget_snapshot" not in dumped
assert "user_budget_snapshot" not in dumped
assert "org_budget_snapshot" not in dumped
assert UserAPIKeyAuth(**dumped).team_budget_snapshot is None

View file

@ -3,7 +3,7 @@
from __future__ import annotations
import asyncio
from collections.abc import Sequence
from collections.abc import Mapping, Sequence
from unittest.mock import AsyncMock, MagicMock
import pytest
@ -52,6 +52,24 @@ class CountingRedis(RedisCache):
self.commands.append(f"MGET {' '.join(key_list)}")
return {key: self.store.get(key) for key in key_list}
def get_ttl(self, **kwargs: object) -> int | None:
return None
async def async_increment(self, key: str, value: float, **kwargs: object) -> float:
self.commands.append(f"INCRBYFLOAT {key} {value}")
return self._incr(key, value)
async def async_increment_pipeline(
self, increment_list: Sequence[Mapping[str, object]], **kwargs: object
) -> list[float]:
self.commands.append(f"PIPELINE {' '.join(str(op['key']) for op in increment_list)}")
return [self._incr(str(op["key"]), float(str(op["increment_value"]))) for op in increment_list]
def _incr(self, key: str, value: float) -> float:
total = float(str(self.store.get(key, 0.0))) + value
self.store[key] = total
return total
def _spend_counter_cache(redis: RedisCache | None, in_memory: dict[str, float] | None = None) -> MagicMock:
cache = MagicMock()
@ -119,6 +137,37 @@ async def test_keys_bound_after_the_first_read_join_one_more_mget_for_only_the_n
assert redis.commands == ["MGET spend:key:hashed", "MGET spend:org:org"]
@pytest.mark.asyncio
async def test_a_recorded_write_result_answers_later_reads_without_another_redis_read():
redis = CountingRedis({"spend:key:hashed": 1.0})
batch = SpendCounterBatch(redis)
batch.bind(frozenset({"spend:key:hashed"}))
assert await batch.read("spend:key:hashed") == (1.0, True)
batch.record("spend:key:hashed", 3.5)
batch.record("spend:org:org", 7.0)
assert await batch.read("spend:key:hashed") == (3.5, True)
assert await batch.read("spend:org:org") == (7.0, True)
assert redis.commands == ["MGET spend:key:hashed"]
@pytest.mark.asyncio
async def test_a_forgotten_counter_is_read_fresh_from_redis_when_it_is_bound_again():
redis = CountingRedis({"spend:key:hashed": 1.0})
batch = SpendCounterBatch(redis)
batch.bind(frozenset({"spend:key:hashed"}))
batch.record("spend:key:hashed", 3.5)
batch.forget("spend:key:hashed")
assert await batch.read("spend:key:hashed") is None
redis.store["spend:key:hashed"] = 9.0
batch.bind(frozenset({"spend:key:hashed"}))
assert await batch.read("spend:key:hashed") == (9.0, True)
assert redis.commands == ["MGET spend:key:hashed"]
@pytest.mark.asyncio
async def test_failed_mget_hands_every_counter_back_to_the_caller():
batch = SpendCounterBatch(CountingRedis(fail=True))
@ -298,3 +347,181 @@ async def test_reseed_outside_the_scope_still_re_checks_redis_itself():
assert value == 4.0
assert redis.commands == ["GET spend:key:hashed"]
POST_CALL_KEYS = TOKEN_KEYS | {"spend:tag:prod", "spend:model_access_group:premium"}
@pytest.mark.asyncio
async def test_post_call_increment_for_every_entity_costs_one_mget_and_one_pipeline(monkeypatch):
redis = CountingRedis({key: 1.0 for key in POST_CALL_KEYS})
monkeypatch.setattr(ps, "spend_counter_cache", _spend_counter_cache(redis))
monkeypatch.setattr(ps, "prisma_client", None)
await ps.increment_spend_counters(
token="hashed",
team_id="team",
user_id="user",
org_id="org",
end_user_id="eu",
tags=["prod"],
model_access_groups=["premium"],
response_cost=0.5,
)
assert [c.split()[0] for c in redis.commands] == ["MGET", "PIPELINE"], redis.commands
assert set(redis.commands[0].split()[1:]) == POST_CALL_KEYS
assert set(redis.commands[1].split()[1:]) == POST_CALL_KEYS
assert {key: redis.store[key] for key in POST_CALL_KEYS} == {key: 1.5 for key in POST_CALL_KEYS}
@pytest.mark.asyncio
async def test_post_call_cold_counters_seed_from_the_mget_miss_without_a_second_read(monkeypatch):
redis = CountingRedis({"spend:key:hashed": 1.0})
redis.async_set_cache = AsyncMock(return_value=True)
prisma = MagicMock()
prisma.db.litellm_teamtable.find_unique = AsyncMock(return_value=MagicMock(spend=4.0))
monkeypatch.setattr(ps, "spend_counter_cache", _spend_counter_cache(redis))
monkeypatch.setattr(ps, "prisma_client", prisma)
await ps.increment_spend_counters(token="hashed", team_id="team", user_id=None, response_cost=0.5)
assert [c.split()[0] for c in redis.commands] == ["MGET", "PIPELINE"], redis.commands
redis.async_set_cache.assert_awaited_once_with(key="spend:team:team", value=4.0, nx=True)
assert redis.store["spend:key:hashed"] == 1.5
RESERVED_KEYS = frozenset(
{"spend:key:hashed", "spend:team:team", "spend:team_member:user:team", "spend:end_user:eu", "spend:org:org"}
)
def _reservation(reserved_cost: float, counter_keys: frozenset[str] = RESERVED_KEYS) -> dict[str, object]:
return {
"reserved_cost": reserved_cost,
"entries": [
{"counter_key": key, "entity_type": "Key", "entity_id": key, "reserved_cost": reserved_cost}
for key in sorted(counter_keys)
],
}
@pytest.mark.asyncio
async def test_post_call_with_a_reservation_costs_one_mget_one_reconcile_pipeline_one_increment_pipeline(monkeypatch):
redis = CountingRedis({key: 1.0 for key in POST_CALL_KEYS})
monkeypatch.setattr(ps, "spend_counter_cache", _spend_counter_cache(redis))
monkeypatch.setattr(ps, "prisma_client", None)
reservation = _reservation(reserved_cost=0.4)
await ps.increment_spend_counters(
token="hashed",
team_id="team",
user_id="user",
org_id="org",
end_user_id="eu",
tags=["prod"],
model_access_groups=["premium"],
response_cost=0.5,
budget_reservation=reservation,
)
assert [c.split()[0] for c in redis.commands] == ["MGET", "PIPELINE", "PIPELINE"], redis.commands
assert set(redis.commands[0].split()[1:]) == POST_CALL_KEYS, "reconcile and warm checks share the MGET"
assert set(redis.commands[1].split()[1:]) == RESERVED_KEYS
assert set(redis.commands[2].split()[1:]) == POST_CALL_KEYS - RESERVED_KEYS
assert {key: round(redis.store[key], 6) for key in POST_CALL_KEYS} == {
key: (1.1 if key in RESERVED_KEYS else 1.5) for key in POST_CALL_KEYS
}
assert [round(entry["applied_adjustment"], 6) for entry in reservation["entries"]] == [0.1] * len(RESERVED_KEYS)
assert reservation["finalized"] is True
@pytest.mark.asyncio
async def test_reconcile_settles_a_flushed_counter_on_its_own_after_the_shared_pipeline(monkeypatch):
from litellm.proxy.spend_tracking.budget_reservation import reconcile_budget_reservation
redis = CountingRedis({key: 1.0 for key in RESERVED_KEYS - {"spend:team:team"}})
redis.async_set_max = AsyncMock(return_value=4.0)
prisma = MagicMock()
prisma.db.litellm_teamtable.find_unique = AsyncMock(return_value=MagicMock(spend=4.0))
monkeypatch.setattr(ps, "spend_counter_cache", _spend_counter_cache(redis))
monkeypatch.setattr(ps, "prisma_client", prisma)
reservation = _reservation(reserved_cost=0.4)
await reconcile_budget_reservation(budget_reservation=reservation, actual_cost=0.5)
assert [c.split()[0] for c in redis.commands] == ["MGET", "PIPELINE", "INCRBYFLOAT"], redis.commands
assert set(redis.commands[1].split()[1:]) == RESERVED_KEYS - {"spend:team:team"}
assert redis.commands[2] == "INCRBYFLOAT spend:team:team 0.5"
redis.async_set_max.assert_awaited_once()
assert redis.async_set_max.await_args.kwargs["key"] == "spend:team:team"
assert all(round(entry["applied_adjustment"], 6) == 0.1 for entry in reservation["entries"])
@pytest.mark.asyncio
async def test_pre_call_resize_against_an_inconsistent_counter_writes_nothing_and_denies(monkeypatch):
from litellm.proxy.spend_tracking.budget_reservation import _resize_applied_reservation
redis = CountingRedis({"spend:key:hashed": 1.0})
monkeypatch.setattr(ps, "spend_counter_cache", _spend_counter_cache(redis))
monkeypatch.setattr(ps, "prisma_client", None)
entries = _reservation(reserved_cost=0.4, counter_keys=frozenset({"spend:key:hashed", "spend:team:team"}))[
"entries"
]
with pytest.raises(RuntimeError, match="spend:team:team"):
await _resize_applied_reservation(entries=entries, current_reserved_cost=0.4, new_reserved_cost=0.9)
assert [c.split()[0] for c in redis.commands] == ["MGET"], redis.commands
assert redis.store["spend:key:hashed"] == 1.0
assert all("applied_adjustment" not in entry for entry in entries)
@pytest.mark.asyncio
async def test_a_failed_reconcile_pipeline_invalidates_every_reserved_counter_and_falls_back(monkeypatch):
redis = CountingRedis({key: 1.0 for key in POST_CALL_KEYS})
redis.async_delete_cache = AsyncMock()
reconcile_pipeline_failed = False
async def _pipeline(increment_list: Sequence[Mapping[str, object]], **kwargs: object) -> list[float]:
nonlocal reconcile_pipeline_failed
if not reconcile_pipeline_failed:
reconcile_pipeline_failed = True
raise ConnectionError("redis down")
return await CountingRedis.async_increment_pipeline(redis, increment_list, **kwargs)
redis.async_increment_pipeline = _pipeline # pyright: ignore[reportAttributeAccessIssue] # instance override
monkeypatch.setattr(ps, "spend_counter_cache", _spend_counter_cache(redis))
monkeypatch.setattr(ps, "prisma_client", None)
reservation = _reservation(reserved_cost=0.4)
await ps.increment_spend_counters(
token="hashed",
team_id="team",
user_id="user",
org_id="org",
end_user_id="eu",
response_cost=0.5,
budget_reservation=reservation,
)
assert {call.kwargs["key"] for call in redis.async_delete_cache.await_args_list} == RESERVED_KEYS
assert all("applied_adjustment" not in entry for entry in reservation["entries"])
assert redis.commands[-1].split()[0] == "PIPELINE"
assert set(redis.commands[-1].split()[1:]) == RESERVED_KEYS | {"spend:user:user"}
def test_a_scope_opened_inside_an_open_scope_joins_its_batch_and_a_closed_one_gets_its_own():
redis = CountingRedis()
with spend_counter_batch_scope(redis, counter_keys=frozenset({"spend:key:a"})):
outer = active_spend_counter_batch()
assert outer is not None
with spend_counter_batch_scope(redis, counter_keys=frozenset({"spend:key:b"})):
assert active_spend_counter_batch() is outer
assert outer.counter_keys == {"spend:key:a", "spend:key:b"}
release_spend_counter_batch()
with spend_counter_batch_scope(redis, counter_keys=frozenset({"spend:key:c"})):
inner = active_spend_counter_batch()
assert inner is not outer
assert inner is not None and inner.counter_keys == {"spend:key:c"}
assert active_spend_counter_batch() is outer

View file

@ -8419,7 +8419,7 @@ async def test_increment_spend_counters_finalizes_after_unreserved_increments():
async def assert_reservation_not_finalized_yet(**kwargs):
assert budget_reservation["finalized"] is False
incremented_counters.append(kwargs["counter_key"])
return ps._PendingSpendIncrement(
return ps.PendingSpendIncrement(
counter_key=kwargs["counter_key"], increment=kwargs["increment"]
)