mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-06 02:48:13 +00:00
fix(proxy): invalidate cached end-user object on budget reset
_reset_budget_for_enduser() only reset spend=0.0 on the DB object but
did not invalidate the in-memory/Redis cached end-user entry. This
caused auth checks to read stale spend values from cache until TTL
expired, making budget resets appear to not work.
Now deletes the cached end_user_id:{id} entry on reset so the next
auth check fetches fresh spend from the DB.
Fixes #24675
This commit is contained in:
parent
428d837704
commit
efb25f2932
2 changed files with 74 additions and 8 deletions
|
|
@ -66,10 +66,8 @@ class ResetBudgetJob:
|
|||
try:
|
||||
from litellm.proxy.proxy_server import spend_counter_cache
|
||||
|
||||
memberships = (
|
||||
await self.prisma_client.db.litellm_teammembership.find_many(
|
||||
where={"budget_id": {"in": budget_ids}}
|
||||
)
|
||||
memberships = await self.prisma_client.db.litellm_teammembership.find_many(
|
||||
where={"budget_id": {"in": budget_ids}}
|
||||
)
|
||||
for m in memberships:
|
||||
counter_key = f"spend:team_member:{m.user_id}:{m.team_id}"
|
||||
|
|
@ -574,7 +572,11 @@ class ResetBudgetJob:
|
|||
counter_key = None
|
||||
if item_type == "key" and hasattr(item, "token") and item.token is not None:
|
||||
counter_key = f"spend:key:{item.token}"
|
||||
elif item_type == "team" and hasattr(item, "team_id") and item.team_id is not None:
|
||||
elif (
|
||||
item_type == "team"
|
||||
and hasattr(item, "team_id")
|
||||
and item.team_id is not None
|
||||
):
|
||||
counter_key = f"spend:team:{item.team_id}"
|
||||
|
||||
if counter_key is not None:
|
||||
|
|
@ -637,6 +639,25 @@ class ResetBudgetJob:
|
|||
) -> Optional[LiteLLM_EndUserTable]:
|
||||
try:
|
||||
enduser.spend = 0.0
|
||||
|
||||
# Invalidate the cached end-user object so auth checks pick up
|
||||
# the reset spend instead of reading a stale value until TTL
|
||||
# expires. Uses the same cache key format as get_end_user_object
|
||||
# in litellm/proxy/auth/auth_checks.py.
|
||||
if enduser.user_id is not None:
|
||||
try:
|
||||
from litellm.proxy.proxy_server import user_api_key_cache
|
||||
|
||||
await user_api_key_cache.async_delete_cache(
|
||||
key="end_user_id:{}".format(enduser.user_id)
|
||||
)
|
||||
except Exception as cache_err:
|
||||
verbose_proxy_logger.warning(
|
||||
"Failed to invalidate cached end-user object for %s: %s. "
|
||||
"Budget may be over-enforced until cache entry expires.",
|
||||
enduser.user_id,
|
||||
cache_err,
|
||||
)
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.exception(
|
||||
"Error resetting budget for enduser: %s. Item: %s", e, enduser
|
||||
|
|
|
|||
|
|
@ -252,6 +252,53 @@ def test_reset_budget_for_enduser(reset_budget_job, mock_prisma_client):
|
|||
assert updated_budget.budget_reset_at > now
|
||||
|
||||
|
||||
def test_reset_budget_for_enduser_invalidates_cache():
|
||||
"""
|
||||
Test that _reset_budget_for_enduser invalidates the cached end-user object
|
||||
so auth checks pick up the reset spend instead of a stale cached value.
|
||||
"""
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
test_enduser = type(
|
||||
"LiteLLM_EndUserTable",
|
||||
(),
|
||||
{
|
||||
"spend": 45.0,
|
||||
"user_id": "cached-enduser-1",
|
||||
},
|
||||
)
|
||||
|
||||
mock_cache = AsyncMock()
|
||||
mock_cache.async_delete_cache = AsyncMock()
|
||||
|
||||
async def _run():
|
||||
with patch(
|
||||
"litellm.proxy.common_utils.reset_budget_job.user_api_key_cache",
|
||||
mock_cache,
|
||||
create=True,
|
||||
):
|
||||
# Patch the import inside the function
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"litellm.proxy.proxy_server": type(
|
||||
"module", (), {"user_api_key_cache": mock_cache}
|
||||
)()
|
||||
},
|
||||
):
|
||||
result = await ResetBudgetJob._reset_budget_for_enduser(
|
||||
enduser=test_enduser
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert result.spend == 0.0
|
||||
mock_cache.async_delete_cache.assert_called_once_with(
|
||||
key="end_user_id:cached-enduser-1"
|
||||
)
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
def test_reset_budget_all(reset_budget_job, mock_prisma_client):
|
||||
# Setup test data with timezone-aware datetime
|
||||
now = datetime.now(timezone.utc)
|
||||
|
|
@ -434,9 +481,7 @@ def test_reset_budget_for_keys_linked_to_budgets_empty(
|
|||
"""
|
||||
# Run with empty list
|
||||
asyncio.run(
|
||||
reset_budget_job.reset_budget_for_keys_linked_to_budgets(
|
||||
budgets_to_reset=[]
|
||||
)
|
||||
reset_budget_job.reset_budget_for_keys_linked_to_budgets(budgets_to_reset=[])
|
||||
)
|
||||
|
||||
# Verify no update_many calls were made
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue