fix(proxy): track global proxy spend with the shared spend-counter machinery

The previous iterations hand-rolled a separate cache path for the global
proxy spend scalar, and each variant reintroduced a race the shared spend
counter system was built to avoid. This routes global proxy spend through
that machinery instead:

- Write: _init_and_increment_spend_counter seeds a cold counter from the
  authoritative litellm-proxy-budget row (cross-pod SET NX) and then
  increments atomically, keyed spend:user:litellm-proxy-budget.

- Read: get_current_spend reads Redis-first, reseeds from the DB on a clean
  miss, and only floors to the DB row when the freshly loaded fallback
  exceeds the counter, repaired with the monotonic async_set_max.

- Reset: the existing user-reset path already zeroes the counter for the
  proxy-budget row; the job now also invalidates the cached row-spend hint
  so no worker acts on a pre-reset fallback.

Removes the bespoke coordinator, per-worker DB-floor marker, and delete-on-
reset invalidation.
This commit is contained in:
lostmartian 2026-08-15 23:32:40 +05:30
parent d66e7848cf
commit 3c3d7fd634
8 changed files with 181 additions and 535 deletions

View file

@ -1449,7 +1449,7 @@ LITELLM_EXPIRED_UI_SESSION_KEY_CLEANUP_BATCH_SIZE: Final = int(
)
LITELLM_PROXY_ADMIN_NAME: Final = "default_user_id"
LITELLM_PROXY_BUDGET_NAME: Final = "litellm-proxy-budget"
GLOBAL_PROXY_SPEND_CACHE_KEY: Final = f"{LITELLM_PROXY_ADMIN_NAME}:spend"
GLOBAL_PROXY_SPEND_COUNTER_KEY: Final = f"spend:user:{LITELLM_PROXY_BUDGET_NAME}"
########################### CLI SSO AUTHENTICATION CONSTANTS ###########################
LITELLM_CLI_SOURCE_IDENTIFIER: Final = "litellm-cli"

View file

@ -23,7 +23,7 @@ import litellm
from litellm._logging import verbose_logger, verbose_proxy_logger
from litellm._service_logger import ServiceLogging
from litellm.constants import (
GLOBAL_PROXY_SPEND_CACHE_KEY,
GLOBAL_PROXY_SPEND_COUNTER_KEY,
LITELLM_PROXY_BUDGET_NAME,
LITELLM_PROXY_MASTER_KEY_ALIAS,
)
@ -76,7 +76,6 @@ from litellm.proxy.auth.resolvers import CredentialRef, Principal
from litellm.proxy.auth.resolvers.store import IdentityStore
from litellm.proxy.auth.route_checks import RouteChecks
from litellm.proxy.auth.trusted_proxy_utils import get_trusted_proxy_cidrs
from litellm.proxy.common_utils.cache_coordinator import EventDrivenCacheCoordinator
from litellm.proxy.common_utils.http_parsing_utils import (
_read_request_body,
_safe_get_request_headers,
@ -491,103 +490,57 @@ def update_valid_token_with_end_user_params(valid_token: UserAPIKeyAuth, end_use
return valid_token
# Reusable coordinator for global spend to prevent cache stampede
_global_spend_coordinator: Final = EventDrivenCacheCoordinator(log_prefix="[GLOBAL SPEND]")
# Cache the authoritative DB spend read for a few seconds per worker so the
# global-proxy budget floor check does not query the DB on every request.
_GLOBAL_PROXY_SPEND_DB_FLOOR_TTL_SECONDS: Final = 5
_GLOBAL_PROXY_SPEND_DB_FLOOR_MARKER_PREFIX: Final = "global_proxy_spend_db_floor:"
async def _read_global_proxy_spend_hint_from_cache(user_api_key_cache: UserApiKeyCache) -> float | None:
redis_cache: Final = user_api_key_cache.redis_cache
if redis_cache is not None:
try:
cached: Final = await redis_cache.async_get_cache(key=LITELLM_PROXY_BUDGET_NAME)
except Exception as e: # noqa: BLE001 # hint read failure must not break auth; in-memory fallback below
verbose_proxy_logger.debug("Global proxy spend hint: Redis read failed, falling back to in-memory: %s", e)
else:
return float(cached) if cached is not None else None
cached_in_memory: Final = user_api_key_cache.in_memory_cache.get_cache(key=LITELLM_PROXY_BUDGET_NAME)
return float(cached_in_memory) if cached_in_memory is not None else None
def invalidate_global_proxy_spend_db_floor_marker(user_api_key_cache: UserApiKeyCache, cache_key: str) -> None:
"""Drop the worker-local DB-floor marker for ``cache_key``.
ResetBudgetJob calls this after zeroing the aggregate DB row so a stale
pre-reset marker is not used to write previous-window spend back into
Redis during the next auth-time floor check.
"""
marker_key: Final = f"{_GLOBAL_PROXY_SPEND_DB_FLOOR_MARKER_PREFIX}{cache_key}"
user_api_key_cache.in_memory_cache.delete_cache(key=marker_key)
async def _fetch_global_spend_with_event_coordination(
cache_key: str,
async def _load_global_proxy_spend_hint(
user_api_key_cache: UserApiKeyCache,
prisma_client: PrismaClient,
) -> float | None:
"""Spend of the proxy-budget aggregate user row, cached in the management cache.
This is only the fallback/staleness signal for get_current_spend; the
enforced value is the shared spend counter. Read Redis-first so the reset
job's invalidation is visible to every worker immediately.
"""
Fetch global spend with event-driven coordination to prevent cache stampede.
Uses EventDrivenCacheCoordinator: first request queries DB and signals others when done.
Reads the proxy budget aggregate user row, which accrues proxy-wide spend
per request and is zeroed by ResetBudgetJob every ``litellm.budget_duration``.
With Redis configured, the shared scalar is read straight from Redis: the
global spend is incremented by every worker, so a per-worker in-memory copy
can go stale across resets and falsely reject requests at auth time. Reading
the Redis value directly keeps the budget check on the shared total. On a
Redis miss the coordinator loads the authoritative DB spend once and
repopulates Redis. Without Redis (single process), the normal DualCache
coordinator path is used.
"""
async def _load_global_spend() -> float | None:
proxy_budget_row: Final = await prisma_client.db.litellm_usertable.find_unique(
where={"user_id": LITELLM_PROXY_BUDGET_NAME}
)
return float(proxy_budget_row.spend) if proxy_budget_row is not None else None
async def _authoritative_db_spend() -> float | None:
# Cached per-worker for a few seconds to avoid a DB query per request.
marker_key: Final = f"{_GLOBAL_PROXY_SPEND_DB_FLOOR_MARKER_PREFIX}{cache_key}"
cached = user_api_key_cache.in_memory_cache.get_cache(key=marker_key)
if cached is not None:
return float(cached)
db_spend = await _load_global_spend()
if db_spend is not None:
user_api_key_cache.in_memory_cache.set_cache(
key=marker_key,
value=db_spend,
ttl=_GLOBAL_PROXY_SPEND_DB_FLOOR_TTL_SECONDS,
)
return db_spend
spend_cache: Final = (
user_api_key_cache.redis_cache if user_api_key_cache.redis_cache is not None else user_api_key_cache
cached: Final = await _read_global_proxy_spend_hint_from_cache(user_api_key_cache)
if cached is not None:
return cached
proxy_budget_row: Final = await prisma_client.db.litellm_usertable.find_unique(
where={"user_id": LITELLM_PROXY_BUDGET_NAME}
)
value = await _global_spend_coordinator.get_or_load(
cache_key=cache_key,
cache=spend_cache, # pyright: ignore[reportArgumentType]
load_fn=_load_global_spend,
)
if value is None:
if proxy_budget_row is None:
return None
spend: Final = float(proxy_budget_row.spend or 0.0)
await user_api_key_cache.async_set_cache(key=LITELLM_PROXY_BUDGET_NAME, value=spend)
return spend
try:
numeric_value = float(value)
except (TypeError, ValueError):
numeric_value = 0.0
# Floor to the authoritative DB spend when Redis is in use. A Redis counter
# that was clobbered by a reset race, restarted from an old RDB snapshot,
# or recreated from zero after expiry can under-report current-period spend;
# capping the value to the DB row prevents requests from leaking past the
# global cap. Without Redis the coordinator already loaded the DB row.
if user_api_key_cache.redis_cache is not None:
db_spend = await _authoritative_db_spend()
if db_spend is not None and db_spend > numeric_value:
# Use a monotonic write so a concurrent increment that already
# pushed Redis above the DB value is not overwritten by a repair
# carrying a slightly-stale DB total. Use the resulting Redis value
# so the enforced total reflects any concurrent increment.
repaired_value: Final[float | None] = await user_api_key_cache.redis_cache.async_set_max(
key=cache_key, value=db_spend
)
numeric_value = repaired_value if repaired_value is not None else db_spend
user_api_key_cache.in_memory_cache.set_cache(key=cache_key, value=numeric_value)
async def _get_global_proxy_spend_value(
user_api_key_cache: UserApiKeyCache,
prisma_client: PrismaClient,
) -> float:
from litellm.proxy.proxy_server import get_current_spend
return numeric_value
hint: Final = await _load_global_proxy_spend_hint(
user_api_key_cache=user_api_key_cache,
prisma_client=prisma_client,
)
return await get_current_spend(
counter_key=GLOBAL_PROXY_SPEND_COUNTER_KEY,
fallback_spend=hint if hint is not None else 0.0,
max_budget=litellm.max_budget,
)
async def get_global_proxy_spend(
@ -599,10 +552,7 @@ async def get_global_proxy_spend(
) -> float | None:
global_proxy_spend = None
if litellm.max_budget > 0 and prisma_client is not None: # user set proxy max budget
# Use event-driven coordination to prevent cache stampede
cache_key: Final = GLOBAL_PROXY_SPEND_CACHE_KEY
global_proxy_spend = await _fetch_global_spend_with_event_coordination(
cache_key=cache_key,
global_proxy_spend = await _get_global_proxy_spend_value(
user_api_key_cache=user_api_key_cache,
prisma_client=prisma_client,
)
@ -2112,10 +2062,8 @@ async def _user_api_key_auth_builder(
global_proxy_spend = None
if litellm.max_budget > 0 and prisma_client is not None: # user set proxy max budget
cache_key: Final = GLOBAL_PROXY_SPEND_CACHE_KEY
with tracer.trace("litellm.proxy.auth.get_global_proxy_spend"):
global_proxy_spend = await _fetch_global_spend_with_event_coordination(
cache_key=cache_key,
global_proxy_spend = await _get_global_proxy_spend_value(
user_api_key_cache=user_api_key_cache,
prisma_client=prisma_client,
)

View file

@ -11,7 +11,6 @@ import litellm
from litellm._logging import verbose_proxy_logger
from litellm.caching.dual_cache import DualCache
from litellm.constants import (
GLOBAL_PROXY_SPEND_CACHE_KEY,
LITELLM_PROXY_BUDGET_NAME,
RESET_BUDGET_JOB_BATCH_SIZE,
RESET_BUDGET_JOB_MAX_CHUNKS_PER_RUN,
@ -262,28 +261,14 @@ class ResetBudgetJob:
@staticmethod
async def _invalidate_global_proxy_spend_cache() -> None:
"""Drop the cached global-proxy spend accumulator after the proxy
budget aggregate row is reset so the next auth-time read reloads the
zeroed DB row. Reads and writes are atomic now (increment-if-present on
Redis), so deleting cannot resurrect a stale pre-reset value, and it
cannot clobber a concurrent increment the way a plain SET to zero could.
"""
try:
from litellm.proxy.auth.user_api_key_auth import (
invalidate_global_proxy_spend_db_floor_marker,
)
from litellm.proxy.proxy_server import user_api_key_cache
"""Drop the cached proxy-budget row spend after the row is reset.
await user_api_key_cache.async_delete_cache(key=GLOBAL_PROXY_SPEND_CACHE_KEY)
invalidate_global_proxy_spend_db_floor_marker(
user_api_key_cache=user_api_key_cache,
cache_key=GLOBAL_PROXY_SPEND_CACHE_KEY,
)
except Exception as e: # noqa: BLE001 # cache invalidation failure must not break budget reset
verbose_proxy_logger.warning(
"Failed to invalidate global proxy spend cache after budget reset: %s",
e,
)
Auth uses it only as the fallback signal for the shared spend counter's
DB floor, so every worker must reload the zeroed row instead of acting
on a pre-reset value. The counter itself is zeroed by the standard
``_invalidate_spend_counter`` path for the proxy-budget user row.
"""
await ResetBudgetJob._invalidate_user_api_key_cache_entry(LITELLM_PROXY_BUDGET_NAME)
@staticmethod
async def _invalidate_user_api_key_cache_entry(cache_key: str) -> None:

View file

@ -235,7 +235,7 @@ from litellm.constants import (
DAYS_IN_A_MONTH,
DEFAULT_HEALTH_CHECK_INTERVAL,
DEFAULT_MODEL_CREATED_AT_TIME,
GLOBAL_PROXY_SPEND_CACHE_KEY,
GLOBAL_PROXY_SPEND_COUNTER_KEY,
LITELLM_PROXY_ADMIN_NAME,
LITELLM_PROXY_BUDGET_NAME,
MONTHLY_SPEND_REPORT_JOB_ID,
@ -292,7 +292,6 @@ from litellm.proxy.auth.model_checks import (
get_team_models,
)
from litellm.proxy.auth.user_api_key_auth import (
_fetch_global_spend_with_event_coordination,
user_api_key_auth,
user_api_key_auth_websocket,
)
@ -3123,27 +3122,16 @@ async def update_cache(
### UPDATE GLOBAL PROXY SPEND ###
async def _update_global_proxy_spend() -> None:
if response_cost is None:
if response_cost is None or litellm.max_budget <= 0:
return
try:
# Atomically accumulate the shared global-proxy scalar in Redis.
# Authorization reads this Redis scalar directly, so there is no
# per-worker in-memory copy that can go stale across resets.
if user_api_key_cache.redis_cache is not None:
await user_api_key_cache.redis_cache.async_increment(
key=GLOBAL_PROXY_SPEND_CACHE_KEY,
value=response_cost,
ttl=get_management_object_ttl(user_api_key_cache),
refresh_ttl=True,
)
else:
# No Redis: single-process, so an in-memory increment is coherent.
await user_api_key_cache.async_increment_cache(
key=GLOBAL_PROXY_SPEND_CACHE_KEY,
value=response_cost,
ttl=get_management_object_ttl(user_api_key_cache),
refresh_ttl=True,
)
# Same spend-counter path as every other budget entity: seed a cold
# counter from the authoritative DB row (SET NX), then increment.
await _init_and_increment_spend_counter(
counter_key=GLOBAL_PROXY_SPEND_COUNTER_KEY,
source_cache_key=LITELLM_PROXY_BUDGET_NAME,
increment=response_cost,
)
except Exception as e: # noqa: BLE001 # spend update must not fail the request
verbose_proxy_logger.warning(
"Spend tracking - failed to update global proxy spend in cache. "
@ -8613,11 +8601,9 @@ class ProxyStartupEvent:
) -> None:
"""Warm global spend cache once at startup to reduce impact of first wave of requests."""
try:
cache_key: Final = GLOBAL_PROXY_SPEND_CACHE_KEY
await _fetch_global_spend_with_event_coordination(
cache_key=cache_key,
user_api_key_cache=user_api_key_cache,
prisma_client=prisma_client,
await get_current_spend(
counter_key=GLOBAL_PROXY_SPEND_COUNTER_KEY,
fallback_spend=0.0,
)
except Exception as e:
verbose_proxy_logger.debug("Global spend cache warm-up at startup skipped or failed: %s", e)

View file

@ -6068,16 +6068,14 @@ class TestCheckKeyModelBudgetWithFallback:
@pytest.mark.asyncio
async def test_global_proxy_spend_reads_resettable_proxy_budget_row():
async def test_global_proxy_spend_hint_reads_resettable_proxy_budget_row():
"""Regression for the global proxy budget ignoring budget_duration
(LIT-4309 / gh#31292): the enforced global spend must be loaded from the
(LIT-4309 / gh#31292): the fallback spend must be loaded from the
"litellm-proxy-budget" user row, which the spend writer increments per
request and ResetBudgetJob zeroes every budget_duration. It must NOT be
loaded from the MonthlyGlobalSpend view, whose window is hardcoded to a
trailing 30 days and never resets on the configured duration."""
from litellm.proxy.auth.user_api_key_auth import (
_fetch_global_spend_with_event_coordination,
)
from litellm.proxy.auth.user_api_key_auth import _load_global_proxy_spend_hint
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
proxy_budget_row = MagicMock()
@ -6088,8 +6086,7 @@ async def test_global_proxy_spend_reads_resettable_proxy_budget_row():
side_effect=AssertionError("global spend must not be loaded from the fixed-30d MonthlyGlobalSpend view")
)
result = await _fetch_global_spend_with_event_coordination(
cache_key="default_user_id:spend",
result = await _load_global_proxy_spend_hint(
user_api_key_cache=UserApiKeyCache(),
prisma_client=prisma_client,
)
@ -6101,19 +6098,16 @@ async def test_global_proxy_spend_reads_resettable_proxy_budget_row():
@pytest.mark.asyncio
async def test_global_proxy_spend_none_when_proxy_budget_row_missing():
"""Before the startup upsert creates the aggregate row, enforcement must
see None (no cap applied) rather than raising."""
from litellm.proxy.auth.user_api_key_auth import (
_fetch_global_spend_with_event_coordination,
)
async def test_global_proxy_spend_hint_none_when_proxy_budget_row_missing():
"""Before the startup upsert creates the aggregate row, the fallback hint
is None so the counter falls back to 0 rather than raising."""
from litellm.proxy.auth.user_api_key_auth import _load_global_proxy_spend_hint
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
prisma_client = MagicMock()
prisma_client.db.litellm_usertable.find_unique = AsyncMock(return_value=None)
result = await _fetch_global_spend_with_event_coordination(
cache_key="default_user_id:spend",
result = await _load_global_proxy_spend_hint(
user_api_key_cache=UserApiKeyCache(),
prisma_client=prisma_client,
)
@ -6122,209 +6116,61 @@ async def test_global_proxy_spend_none_when_proxy_budget_row_missing():
@pytest.mark.asyncio
async def test_global_proxy_spend_reads_shared_redis_scalar_when_configured():
"""With Redis, the global spend read goes straight to the shared Redis
scalar instead of a per-worker in-memory copy, which can go stale across
resets and falsely reject requests at auth time."""
from litellm.proxy.auth.user_api_key_auth import (
_fetch_global_spend_with_event_coordination,
)
async def test_global_proxy_spend_hint_reads_redis_first():
"""The hint is read from the shared Redis tier first so a reset
invalidation is visible to every worker immediately; a per-worker
in-memory copy is only a Redis-outage fallback."""
from litellm.proxy.auth.user_api_key_auth import _load_global_proxy_spend_hint
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
cache = UserApiKeyCache()
redis_cache = MagicMock()
redis_cache.async_get_cache = AsyncMock(return_value=8.5)
redis_cache.async_set_cache = AsyncMock()
cache.redis_cache = redis_cache
cache.in_memory_cache.set_cache(key="litellm-proxy-budget", value=999.0)
proxy_budget_row = MagicMock()
proxy_budget_row.spend = 5.0
prisma_client = MagicMock()
prisma_client.db.litellm_usertable.find_unique = AsyncMock(
return_value=proxy_budget_row
side_effect=AssertionError("DB must not be queried when Redis has the hint")
)
result = await _fetch_global_spend_with_event_coordination(
cache_key="default_user_id:spend",
result = await _load_global_proxy_spend_hint(
user_api_key_cache=cache,
prisma_client=prisma_client,
)
assert result == 8.5
redis_cache.async_get_cache.assert_awaited_once_with(key="default_user_id:spend")
redis_cache.async_set_cache.assert_not_called()
redis_cache.async_get_cache.assert_awaited_once_with(key="litellm-proxy-budget")
@pytest.mark.asyncio
async def test_global_proxy_spend_floors_stale_low_redis_to_db():
"""A Redis counter that is stale-low (e.g., recreated from zero after a
reset or Redis restart) must be floored to the authoritative DB spend so
requests cannot leak past the global cap."""
from litellm.proxy.auth.user_api_key_auth import (
_fetch_global_spend_with_event_coordination,
)
async def test_global_proxy_spend_value_uses_shared_spend_counter(monkeypatch):
"""The enforced global proxy spend comes from the shared spend counter
(spend:user:litellm-proxy-budget) via get_current_spend, with the DB row
spend as the fallback signal and the configured cap as max_budget."""
import litellm
from litellm.proxy.auth.user_api_key_auth import _get_global_proxy_spend_value
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
cache = UserApiKeyCache()
redis_cache = MagicMock()
redis_cache.async_get_cache = AsyncMock(return_value="2.0")
redis_cache.async_set_max = AsyncMock(side_effect=lambda key, value: value)
cache.redis_cache = redis_cache
proxy_budget_row = MagicMock()
proxy_budget_row.spend = 9.0
prisma_client = MagicMock()
prisma_client.db.litellm_usertable.find_unique = AsyncMock(
return_value=proxy_budget_row
)
prisma_client.db.litellm_usertable.find_unique = AsyncMock(return_value=proxy_budget_row)
result = await _fetch_global_spend_with_event_coordination(
cache_key="default_user_id:spend",
user_api_key_cache=cache,
get_current_spend = AsyncMock(return_value=9.0)
monkeypatch.setattr("litellm.proxy.proxy_server.get_current_spend", get_current_spend)
monkeypatch.setattr(litellm, "max_budget", 100.0)
result = await _get_global_proxy_spend_value(
user_api_key_cache=UserApiKeyCache(),
prisma_client=prisma_client,
)
assert result == 9.0
redis_cache.async_get_cache.assert_awaited_once_with(key="default_user_id:spend")
redis_cache.async_set_max.assert_awaited_once_with(
key="default_user_id:spend", value=9.0
)
@pytest.mark.asyncio
async def test_global_proxy_spend_floor_does_not_overwrite_concurrent_increment():
"""When Redis already holds a higher value than the DB floor (because a
concurrent request incremented it after our read), the repair must use a
monotonic write so the concurrent increment is not discarded."""
from litellm.proxy.auth.user_api_key_auth import (
_fetch_global_spend_with_event_coordination,
)
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
cache = UserApiKeyCache()
redis_cache = MagicMock()
redis_cache.async_get_cache = AsyncMock(return_value="2.0")
redis_cache.async_set_max = AsyncMock(side_effect=lambda key, value: value)
cache.redis_cache = redis_cache
proxy_budget_row = MagicMock()
proxy_budget_row.spend = 1.0
prisma_client = MagicMock()
prisma_client.db.litellm_usertable.find_unique = AsyncMock(
return_value=proxy_budget_row
)
result = await _fetch_global_spend_with_event_coordination(
cache_key="default_user_id:spend",
user_api_key_cache=cache,
prisma_client=prisma_client,
)
# DB (1.0) is not greater than Redis (2.0), so no repair is attempted.
assert result == 2.0
redis_cache.async_set_max.assert_not_called()
@pytest.mark.asyncio
async def test_global_proxy_spend_marker_cached_db_value_used_without_db_query():
"""The DB-floor marker should be reused for subsequent reads so we do not
query Postgres on every request."""
from litellm.proxy.auth.user_api_key_auth import (
_fetch_global_spend_with_event_coordination,
_GLOBAL_PROXY_SPEND_DB_FLOOR_MARKER_PREFIX,
)
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
cache = UserApiKeyCache()
redis_cache = MagicMock()
redis_cache.async_get_cache = AsyncMock(return_value="2.0")
redis_cache.async_set_max = AsyncMock(side_effect=lambda key, value: value)
cache.redis_cache = redis_cache
# Seed the marker cache with a DB value greater than Redis.
marker_key = f"{_GLOBAL_PROXY_SPEND_DB_FLOOR_MARKER_PREFIX}default_user_id:spend"
cache.in_memory_cache.set_cache(key=marker_key, value=9.0)
prisma_client = MagicMock()
prisma_client.db.litellm_usertable.find_unique = AsyncMock(
side_effect=AssertionError("DB must not be queried when marker is present")
)
result = await _fetch_global_spend_with_event_coordination(
cache_key="default_user_id:spend",
user_api_key_cache=cache,
prisma_client=prisma_client,
)
assert result == 9.0
prisma_client.db.litellm_usertable.find_unique.assert_not_called()
redis_cache.async_set_max.assert_awaited_once_with(
key="default_user_id:spend", value=9.0
)
@pytest.mark.asyncio
async def test_global_proxy_spend_floor_falls_back_when_set_max_returns_none():
"""If the monotonic Redis repair returns None (e.g. connection hiccup),
the enforced total must still fall back to the authoritative DB floor."""
from litellm.proxy.auth.user_api_key_auth import (
_fetch_global_spend_with_event_coordination,
)
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
cache = UserApiKeyCache()
redis_cache = MagicMock()
redis_cache.async_get_cache = AsyncMock(return_value="2.0")
redis_cache.async_set_max = AsyncMock(return_value=None)
cache.redis_cache = redis_cache
proxy_budget_row = MagicMock()
proxy_budget_row.spend = 9.0
prisma_client = MagicMock()
prisma_client.db.litellm_usertable.find_unique = AsyncMock(
return_value=proxy_budget_row
)
result = await _fetch_global_spend_with_event_coordination(
cache_key="default_user_id:spend",
user_api_key_cache=cache,
prisma_client=prisma_client,
)
assert result == 9.0
@pytest.mark.asyncio
async def test_global_proxy_spend_treats_unparseable_redis_as_zero():
"""A corrupted/non-numeric Redis value is treated as 0.0 and then floored
to the DB total so enforcement remains safe."""
from litellm.proxy.auth.user_api_key_auth import (
_fetch_global_spend_with_event_coordination,
)
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
cache = UserApiKeyCache()
redis_cache = MagicMock()
redis_cache.async_get_cache = AsyncMock(return_value="not-a-number")
redis_cache.async_set_max = AsyncMock(side_effect=lambda key, value: value)
cache.redis_cache = redis_cache
proxy_budget_row = MagicMock()
proxy_budget_row.spend = 7.0
prisma_client = MagicMock()
prisma_client.db.litellm_usertable.find_unique = AsyncMock(
return_value=proxy_budget_row
)
result = await _fetch_global_spend_with_event_coordination(
cache_key="default_user_id:spend",
user_api_key_cache=cache,
prisma_client=prisma_client,
)
assert result == 7.0
redis_cache.async_set_max.assert_awaited_once_with(
key="default_user_id:spend", value=7.0
get_current_spend.assert_awaited_once_with(
counter_key="spend:user:litellm-proxy-budget",
fallback_spend=9.0,
max_budget=100.0,
)

View file

@ -863,48 +863,6 @@ def test_reset_budget_windows_uses_is_not_null_filter(monkeypatch):
assert "budget_limits IS NOT NULL" in team_query
def test_invalidate_global_proxy_spend_cache_deletes_key_and_db_floor_marker(monkeypatch):
"""ResetBudgetJob must drop both the shared Redis spend key and the worker-
local DB-floor marker. Otherwise a stale pre-reset marker can be used to
write previous-window spend back into Redis at the next auth check."""
from litellm.constants import GLOBAL_PROXY_SPEND_CACHE_KEY
from litellm.proxy.auth.user_api_key_auth import (
_GLOBAL_PROXY_SPEND_DB_FLOOR_MARKER_PREFIX,
)
user_api_key_cache = MagicMock()
user_api_key_cache.async_delete_cache = AsyncMock()
user_api_key_cache.in_memory_cache.delete_cache = MagicMock()
fake_module = types.ModuleType("litellm.proxy.proxy_server")
fake_module.user_api_key_cache = user_api_key_cache
monkeypatch.setitem(sys.modules, "litellm.proxy.proxy_server", fake_module)
job = ResetBudgetJob(proxy_logging_obj=MagicMock(), prisma_client=MagicMock())
asyncio.run(job._invalidate_global_proxy_spend_cache())
user_api_key_cache.async_delete_cache.assert_awaited_once_with(
key=GLOBAL_PROXY_SPEND_CACHE_KEY
)
user_api_key_cache.in_memory_cache.delete_cache.assert_called_once_with(
key=f"{_GLOBAL_PROXY_SPEND_DB_FLOOR_MARKER_PREFIX}{GLOBAL_PROXY_SPEND_CACHE_KEY}"
)
def test_invalidate_global_proxy_spend_cache_swallows_cache_failure(monkeypatch):
"""A failure to invalidate the cache must not break the budget reset job."""
user_api_key_cache = MagicMock()
user_api_key_cache.async_delete_cache = AsyncMock(side_effect=RuntimeError("redis down"))
fake_module = types.ModuleType("litellm.proxy.proxy_server")
fake_module.user_api_key_cache = user_api_key_cache
monkeypatch.setitem(sys.modules, "litellm.proxy.proxy_server", fake_module)
job = ResetBudgetJob(proxy_logging_obj=MagicMock(), prisma_client=MagicMock())
# Should not raise.
asyncio.run(job._invalidate_global_proxy_spend_cache())
def test_reset_budget_windows_resets_expired_key_window(monkeypatch):
"""A key whose window's `reset_at` has passed gets an update with a new
`reset_at` in the future, and the in-memory spend counter is cleared."""
@ -1134,8 +1092,9 @@ def test_reset_budget_for_users_invalidates_redis_counter(reset_budget_job, mock
def test_reset_budget_for_proxy_budget_row_resets_global_spend_cache(
reset_budget_job, mock_prisma_client, monkeypatch
):
"""Resetting the proxy-wide budget aggregate row invalidates the cached
global-spend accumulator so the next read reloads the zeroed DB row."""
"""Resetting the proxy-wide budget aggregate row zeroes its spend counter
and invalidates the cached row-spend hint so the next auth reloads the
zeroed DB row instead of acting on a pre-reset value."""
counter_cache = _make_counter_invalidation_job(monkeypatch)
now = datetime.now(timezone.utc)
@ -1155,16 +1114,19 @@ def test_reset_budget_for_proxy_budget_row_resets_global_spend_cache(
asyncio.run(reset_budget_job.reset_budget_for_litellm_users())
counter_cache.in_memory_cache.set_cache.assert_any_call(
key="spend:user:litellm-proxy-budget", value=0.0, ttl=60
)
counter_cache.user_api_key_cache.async_delete_cache.assert_any_call(
key="default_user_id:spend"
key="litellm-proxy-budget"
)
def test_reset_budget_for_ordinary_user_does_not_touch_global_spend_cache(
reset_budget_job, mock_prisma_client, monkeypatch
):
"""The global-spend accumulator must only be invalidated when the proxy
budget aggregate row itself resets, not on every user reset."""
"""The proxy-budget hint must only be invalidated when the proxy budget
aggregate row itself resets, not on every user reset."""
counter_cache = _make_counter_invalidation_job(monkeypatch)
now = datetime.now(timezone.utc)
@ -1185,7 +1147,7 @@ def test_reset_budget_for_ordinary_user_does_not_touch_global_spend_cache(
asyncio.run(reset_budget_job.reset_budget_for_litellm_users())
assert not any(
call.kwargs.get("key") == "default_user_id:spend"
call.kwargs.get("key") == "litellm-proxy-budget"
for call in counter_cache.user_api_key_cache.async_delete_cache.call_args_list
)
assert not any(

View file

@ -1273,133 +1273,79 @@ async def test_update_cache_user_cache_failure_invalid_state_is_swallowed(monkey
@pytest.mark.asyncio
async def test_update_cache_global_proxy_spend_starts_from_zero_after_reset(
monkeypatch,
):
"""Global proxy spend starts from zero after the cache key is reset."""
async def test_update_cache_global_proxy_spend_uses_spend_counter(monkeypatch):
"""Global proxy spend goes through the shared spend-counter path so cold
counters seed from the authoritative DB row and increments stay atomic."""
from unittest.mock import AsyncMock
from litellm.caching.caching import DualCache
cache = DualCache(default_in_memory_ttl=300)
monkeypatch.setattr(ps, "user_api_key_cache", cache)
init_and_increment = AsyncMock()
monkeypatch.setattr(ps, "_init_and_increment_spend_counter", init_and_increment)
monkeypatch.setattr(ps.litellm, "max_budget", 100.0)
monkeypatch.setattr(ps, "user_api_key_cache", DualCache(default_in_memory_ttl=300))
await cache.async_set_cache(key=ps.GLOBAL_PROXY_SPEND_CACHE_KEY, value=50.0)
await cache.async_delete_cache(key=ps.GLOBAL_PROXY_SPEND_CACHE_KEY)
await cache.async_set_cache(
key="user-lit",
value={"user_id": "user-lit", "spend": 0.0},
)
await ps.update_cache(
token=None,
user_id="user-lit",
end_user_id=None,
team_id=None,
response_cost=1.05,
parent_otel_span=None,
)
final_value = await cache.async_get_cache(key=ps.GLOBAL_PROXY_SPEND_CACHE_KEY)
assert final_value == 1.05
@pytest.mark.asyncio
async def test_update_cache_global_proxy_spend_concurrent_increments_after_reset(
monkeypatch,
):
"""Concurrent spend updates after a reset accumulate from zero."""
from litellm.caching.caching import DualCache
cache = DualCache(default_in_memory_ttl=300)
monkeypatch.setattr(ps, "user_api_key_cache", cache)
await cache.async_set_cache(key=ps.GLOBAL_PROXY_SPEND_CACHE_KEY, value=100.0)
await cache.async_delete_cache(key=ps.GLOBAL_PROXY_SPEND_CACHE_KEY)
await cache.async_set_cache(
key="user-concurrent",
value={"user_id": "user-concurrent", "spend": 0.0},
)
async def _update(cost: float) -> None:
await ps.update_cache(
token=None,
user_id="user-concurrent",
end_user_id=None,
team_id=None,
response_cost=cost,
parent_otel_span=None,
)
await asyncio.gather(*[_update(0.01) for _ in range(50)])
final_value = await cache.async_get_cache(key=ps.GLOBAL_PROXY_SPEND_CACHE_KEY)
assert final_value == pytest.approx(0.5)
@pytest.mark.asyncio
async def test_update_cache_global_proxy_spend_does_not_mirror_to_in_memory(
monkeypatch,
):
"""Authorization reads the shared Redis scalar directly, so the increment
path must not maintain a per-worker in-memory copy that could go stale
across resets and confuse budget checks."""
from unittest.mock import AsyncMock, MagicMock
from litellm.caching.caching import DualCache
redis_cache = MagicMock()
redis_cache.async_increment = AsyncMock(return_value=3.5)
cache = DualCache(redis_cache=redis_cache, default_in_memory_ttl=300)
monkeypatch.setattr(ps, "user_api_key_cache", cache)
await cache.async_set_cache(
key="user-no-mirror",
value={"user_id": "user-no-mirror", "spend": 0.0},
)
await ps.update_cache(
token=None,
user_id="user-no-mirror",
user_id=None,
end_user_id=None,
team_id=None,
response_cost=1.5,
parent_otel_span=None,
)
redis_cache.async_increment.assert_awaited_once_with(
key=ps.GLOBAL_PROXY_SPEND_CACHE_KEY,
value=1.5,
ttl=ps.get_management_object_ttl(cache),
refresh_ttl=True,
init_and_increment.assert_awaited_once_with(
counter_key="spend:user:litellm-proxy-budget",
source_cache_key="litellm-proxy-budget",
increment=1.5,
)
assert cache.in_memory_cache.get_cache(key=ps.GLOBAL_PROXY_SPEND_CACHE_KEY) is None
@pytest.mark.asyncio
async def test_update_cache_global_proxy_spend_swallows_redis_failure(monkeypatch):
"""A Redis failure on the global spend increment must not fail the request."""
from unittest.mock import AsyncMock, MagicMock
async def test_update_cache_global_proxy_spend_skipped_without_max_budget(monkeypatch):
"""No global cap configured means the proxy-budget row is never written by
the DB flusher either, so the counter must not accumulate spend it could
not be reseeded from."""
from unittest.mock import AsyncMock
from litellm.caching.caching import DualCache
redis_cache = MagicMock()
redis_cache.async_increment = AsyncMock(side_effect=RuntimeError("redis down"))
cache = DualCache(redis_cache=redis_cache, default_in_memory_ttl=300)
monkeypatch.setattr(ps, "user_api_key_cache", cache)
init_and_increment = AsyncMock()
monkeypatch.setattr(ps, "_init_and_increment_spend_counter", init_and_increment)
monkeypatch.setattr(ps.litellm, "max_budget", 0)
monkeypatch.setattr(ps, "user_api_key_cache", DualCache(default_in_memory_ttl=300))
await cache.async_set_cache(
key="user-redis-down",
value={"user_id": "user-redis-down", "spend": 0.0},
)
await ps.update_cache(
token=None,
user_id="user-redis-down",
user_id=None,
end_user_id=None,
team_id=None,
response_cost=1.5,
parent_otel_span=None,
)
init_and_increment.assert_not_called()
@pytest.mark.asyncio
async def test_update_cache_global_proxy_spend_swallows_failure(monkeypatch):
"""A failure on the global spend increment must not fail the request."""
from unittest.mock import AsyncMock
from litellm.caching.caching import DualCache
init_and_increment = AsyncMock(side_effect=RuntimeError("redis down"))
monkeypatch.setattr(ps, "_init_and_increment_spend_counter", init_and_increment)
monkeypatch.setattr(ps.litellm, "max_budget", 100.0)
monkeypatch.setattr(ps, "user_api_key_cache", DualCache(default_in_memory_ttl=300))
await ps.update_cache(
token=None,
user_id=None,
end_user_id=None,
team_id=None,
response_cost=1.0,
parent_otel_span=None,
)
redis_cache.async_increment.assert_awaited_once_with(
key=ps.GLOBAL_PROXY_SPEND_CACHE_KEY,
value=1.0,
ttl=ps.get_management_object_ttl(cache),
refresh_ttl=True,
)
init_and_increment.assert_awaited_once()

View file

@ -5273,65 +5273,38 @@ async def test_spend_tracking_never_writes_the_auth_object_back():
@pytest.mark.asyncio
async def test_update_cache_global_proxy_spend_scalar_stays_shared():
"""
The proxy-wide spend estimate must keep flowing to Redis when the spend
writeback goes per-pod: the global max_budget check reads the
``{litellm_proxy_admin_name}:spend`` cache entry between authoritative DB
reloads, so keeping it pod-local would let traffic spread across replicas
exceed the proxy budget by roughly a factor of the replica count within a
cache TTL. The scalar is updated via an atomic cache increment, which
remains shared across pods and avoids resurrecting a stale pre-reset value.
The proxy-wide spend estimate must live in the shared spend counter
(``spend:user:litellm-proxy-budget``), which seeds from the authoritative
DB row when cold and increments atomically. A per-pod cache key would let
traffic spread across replicas exceed the proxy budget by roughly a factor
of the replica count within a cache TTL.
"""
from litellm.caching.caching import DualCache
admin_name = litellm.proxy.proxy_server.litellm_proxy_admin_name
global_key = f"{admin_name}:spend"
async def fake_get(key, **kwargs):
if key == "user-lit":
return {"user_id": "user-lit", "spend": 1.0}
return None
original_cache = litellm.proxy.proxy_server.user_api_key_cache
cache = DualCache(default_in_memory_ttl=300)
setattr(litellm.proxy.proxy_server, "user_api_key_cache", cache)
original_max_budget = litellm.max_budget
init_and_increment = AsyncMock()
setattr(litellm.proxy.proxy_server, "user_api_key_cache", DualCache(default_in_memory_ttl=300))
setattr(litellm, "max_budget", 100.0)
setattr(litellm.proxy.proxy_server, "_init_and_increment_spend_counter", init_and_increment)
try:
with patch.object(cache, "async_get_cache", new=AsyncMock(side_effect=fake_get)):
with patch.object(cache, "async_set_cache_pipeline", new=AsyncMock()) as mock_set_cache:
with patch.object(cache, "async_increment_cache", new=AsyncMock()) as mock_increment:
await litellm.proxy.proxy_server.update_cache(
token=None,
user_id="user-lit",
end_user_id=None,
team_id=None,
response_cost=5.0,
parent_otel_span=None,
)
await litellm.proxy.proxy_server.update_cache(
token=None,
user_id=None,
end_user_id=None,
team_id=None,
response_cost=5.0,
parent_otel_span=None,
)
pending = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()]
if pending:
await asyncio.wait(pending, timeout=5)
calls = mock_set_cache.await_args_list
local_keys = [k for c in calls if c.kwargs.get("local_only") is True for k, _ in c.kwargs["cache_list"]]
shared_keys = [
k for c in calls if c.kwargs.get("local_only") is not True for k, _ in c.kwargs["cache_list"]
]
assert "user-lit" in local_keys
assert global_key not in local_keys
assert shared_keys == []
increment_calls = [
c
for c in mock_increment.await_args_list
if (c.args[0] if c.args else c.kwargs.get("key")) == global_key
]
assert len(increment_calls) == 1
call = increment_calls[0]
value = call.args[1] if len(call.args) >= 2 else call.kwargs.get("value")
assert value == 5.0
assert call.kwargs.get("refresh_ttl") is True
init_and_increment.assert_awaited_once_with(
counter_key="spend:user:litellm-proxy-budget",
source_cache_key="litellm-proxy-budget",
increment=5.0,
)
finally:
setattr(litellm.proxy.proxy_server, "user_api_key_cache", original_cache)
setattr(litellm, "max_budget", original_max_budget)
@pytest.mark.asyncio