Skip to content

fix(proxy): reset a key's budget-window counters on spend reset - #38686

Merged
yassin-berriai merged 5 commits into
litellm_internal_stagingfrom
litellm_fix_reset_spend_budget_windows
Aug 28, 2026
Merged

yassin-berriai merged 5 commits into
litellm_internal_stagingfrom
litellm_fix_reset_spend_budget_windows

Conversation

@yassin-berriai

@yassin-berriai yassin-berriai commented Aug 28, 2026 •

Copy link
Copy Markdown
Contributor

TLDR

Problem this solves:

  • Resetting a key's spend can leave it 429ing forever
  • The key's own extra budget window (e.g. daily cap) stays stuck at the old spend
  • Another proxy pod can also keep enforcing the pre-reset spend for up to a minute

How it solves it:

  • Reset also zeroes each of the key's budget-window counters and restarts the window from now
  • The cached key object's eviction is now broadcast to every pod, not just the one handling the request

User Flow

Before: a proxy admin resets a key's spend because it's stuck returning 429s, but the key keeps 429ing anyway

  1. The key has a lifetime max_budget of $1000 and an extra daily budget window of $50 (budget_limits)
  2. Once the key's spend inside the current day crosses $50, POST https://litellm-domain/v1/chat/completions starts returning 429 {"error":{"message":"ExceededBudget: Key over 1d budget...
  3. The admin clicks Reset Spend on https://litellm-domain/ui/?page=api-keys (or calls POST https://litellm-domain/key/<key_id>/reset_spend), and the response reports "spend": 0.0
  4. The admin retries the same chat completion and still gets 429 {"error":{"message":"ExceededBudget: Key over 1d budget..., even though the key's own dashboard now shows $0 spent

After: the same reset actually unblocks the key

  1. Same setup: a $1000 lifetime budget, a $50 daily window, and the daily window exceeded
  2. The same 429 on POST https://litellm-domain/v1/chat/completions
  3. The admin clicks Reset Spend (or calls the same POST .../reset_spend), which again reports "spend": 0.0
  4. The admin retries the same chat completion and now gets a normal 200 with a real completion

Relevant issues

Linear ticket

Pre-Submission checklist

Please complete all items before asking a LiteLLM maintainer to review your PR

  • I have added meaningful tests
  • The handful of test files covering my change pass locally, e.g. uv run pytest tests/test_litellm/<your_test_file>.py -v. Leave the suites (make test-unit-*, make test-unit) to CI: it finishes in ~15 minutes where a laptop takes an hour or more
  • My PR passes all required CI/CD checks (e.g., lint, schema.d.ts sync check, etc.)
  • My PR's scope is as isolated as possible; it only solves 1 specific problem
  • I have received a Greptile Confidence Score of at least 4/5 before requesting a maintainer review (Greptile reviews automatically once the PR is opened; only comment @greptileai to re-request a review after pushing changes)

Delays in PR merge?

If you're seeing a delay in your PR being merged, ping the LiteLLM Team on Slack (#pr-review).

Screenshots / Proof of Fix

Setup for both runs: a local proxy against a real Postgres and Redis (general_settings.coordination_redis configured, so spend counters are Redis-backed and cross-pod), with a real openai/gpt-4o-mini deployment.

model_list:
  - model_name: gpt-4o-mini
    litellm_params:
      model: openai/gpt-4o-mini
      api_key: os.environ/OPENAI_API_KEY
general_settings:
  master_key: sk-...
  coordination_redis:
    host: localhost
    port: <redis-port>

Before (597b4bb)

  1. Create a key with a tiny (effectively $0) daily budget window so a single real completion call blows through it:
    curl -X POST http://localhost:14920/key/generate -H "Authorization: Bearer $MASTER_KEY" -d '{"budget_limits": [{"budget_duration": "1d", "max_budget": 0.0000001}]}'
    -> {"key":"sk-KPDImrEUugFGOFpS6yi6ZA", "token":"f070eb42...e38a88", ...}
  2. Real completion call, succeeds and records real spend:
    curl -X POST http://localhost:14920/v1/chat/completions -H "Authorization: Bearer sk-KPDImrEUugFGOFpS6yi6ZA" -d '{"model": "gpt-4o-mini", "messages": [{"role": "user", "content": "say hi"}], "max_tokens": 5}'
    -> 200, a real OpenAI response
  3. Second call, now over the $0.0000001 daily budget:
    same curl as step 2
    -> 429 {"error":{"message":"ExceededBudget: Key over 1d budget. Spend=$0.0000, Limit=$0.00","type":"budget_exceeded","code":"429"}}
  4. Admin resets the key's spend:
    curl -X POST http://localhost:14920/key/f070eb42.../reset_spend -H "Authorization: Bearer $MASTER_KEY" -d '{"reset_to": 0}'
    -> {"key_hash":"f070eb42...","spend":0.0,"previous_spend":4.35e-6,"max_budget":null,"budget_reset_at":null}
  5. Same call as step 2, right after the reset that reported spend: 0.0:
    -> still 429 {"error":{"message":"ExceededBudget: Key over 1d budget. Spend=$0.0000, Limit=$0.00", ...}} (the bug)
  6. Redis state right after the reset:
    redis-cli GET spend:key:f070eb42... -> 0.0 (the lifetime counter was reset)
    redis-cli GET spend:key:f070eb42...:window:1d -> 0.00000435 (the daily window counter, still stale)

After (4448c42)

  1. Same key setup as Before, step 1: budget_limits: [{"budget_duration": "1d", "max_budget": 0.0000001}]
    -> {"key":"sk-PKjmwcEnS9thQ_HWeLYzgQ", "token":"03b89980...ec6257", ...}
  2. Real completion call, succeeds:
    -> 200, a real OpenAI response
  3. Second call, over the daily budget:
    -> 429 {"error":{"message":"ExceededBudget: Key over 1d budget. Spend=$0.0000, Limit=$0.00", ...}}
  4. Admin resets the key's spend:
    curl -X POST http://localhost:14921/key/03b89980.../reset_spend -H "Authorization: Bearer $MASTER_KEY" -d '{"reset_to": 0}'
    -> {"key_hash":"03b89980...","spend":0.0,"previous_spend":0.0,"max_budget":null,"budget_reset_at":null}
  5. Redis state right after the reset:
    redis-cli GET spend:key:03b89980... -> 0.0
    redis-cli GET spend:key:03b89980...:window:1d -> 0.0 (now reset too)
  6. Same call as step 2, right after the reset:
    -> 200, a real OpenAI response (fixed)

Type

🐛 Bug Fix

Review notes

Greptile's outstanding finding (4/5): even after persisting each window's new boundary before zeroing its counter, a peer pod's key-object cache and floor marker are invalidated by a broadcast (publish_auth_cache_invalidation), which is asynchronous pub/sub with no delivery acknowledgment. A request landing on that peer between the DB commit and the broadcast's arrival could, in principle, recompute and republish the pre-reset spend.

That is true, and it is also the same tradeoff invalidate_team_member_spend_state already ships with (litellm/proxy/auth/auth_checks.py:2511-2512) for the LIT-3803 pattern this PR extends to keys: it sets the counter and floor marker locally, then does the identical fire-and-forget broadcast, with no synchronous cross-pod acknowledgment either. No reset path anywhere in the proxy (team, team-member, tag, customer, or key) achieves synchronous cross-pod cache coherence; all of them rely on best-effort pub/sub bounded by the receiving pod's own short cache TTLs (5s for the floor marker, 60s for the counter and key object). Closing this fully would mean adding a distributed acknowledgment or lock protocol to the whole family of reset endpoints, which is a materially larger change than a bug fix PR, and inconsistent with the pattern this PR is deliberately matching.

What this PR does narrow relative to before it: previously a key's window counter was never reset at all (permanently stuck), and the key-object cache eviction was never broadcast for keys (only broadcast for bulk key deletion). Both gaps are closed to the same best-effort, sub-TTL-bounded consistency every other reset path in the proxy already has.

Caveats (if any)

Medium

  • /key/update's separate spend field (an admin directly overriding a key's stored spend, distinct from the Reset Spend action) still only resets the lifetime counter, not budget windows; same gap, narrower and less commonly used path, left out of scope here
  • The cross-pod cache-eviction broadcast is best-effort, matching the existing pattern for team/team-member/customer/tag caches: a dropped broadcast still self-heals once that pod's own cache entry expires (its existing TTL, not a new one)

Low

  • Resetting a key does not touch team-, user-, or org-level budgets the key may also be counted against; that's intentional (the admin explicitly scoped the reset to the key), not a gap

Final Attestation

  • The tests check the right things, including the edge cases, and regressions in the respective real-world customer use-cases are not possible after this PR

@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

…set cross-pod

/key/{id}/reset_spend already reset the key's lifetime spend counter in
Redis, but a key with its own budget_limits (an extra time-windowed cap,
e.g. a daily budget layered on top of the lifetime max_budget) kept its
window counter untouched, so the key stayed 429'd on
"ExceededBudget: Key over <duration> budget" even after the admin action
reported spend back to $0.

Force-expire each window on reset: zero its Redis counter and restart the
window from now (window_start is derived as reset_at - budget_duration,
so reset_at must float to now + duration, not the next calendar boundary
get_budget_reset_time gives key creation - that boundary can still be
in the past relative to the spend that triggered the block).

Also close a second, narrower race: _delete_cache_key_object evicted the
cached key object only on the handling pod, so another pod could keep
serving the stale pre-reset object (and re-derive the pre-reset spend
counter via its own floor-marker cache) until its own TTL expired. It now
broadcasts the eviction, matching the pattern already used for team,
team-member, customer, and tag caches.
@greptile-apps

greptile-apps Bot commented Aug 28, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR makes key spend resets restart the key’s budget windows and propagate relevant cache and counter updates across proxy workers.

  • Advances and persists each configured budget-window boundary before resetting its shared counter.
  • Updates lifetime and window counter floor markers and broadcasts the new values.
  • Broadcasts key-object eviction from the shared cache-deletion helper.
  • Adds regression coverage for window resets, no-window keys, floor markers, and cache invalidation.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
litellm/proxy/auth/auth_checks.py Centralizes cross-worker key-object invalidation in the single-key cache deletion helper without introducing a blocking failure.
litellm/proxy/management_endpoints/key_management_endpoints.py Persists restarted key budget windows, resets shared spend state, and evicts cached key objects after durable updates.
tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py Adds mock-based regression coverage for window-counter resets, persisted boundaries, floor markers, and broadcast eviction.

Reviews (5): Last reviewed commit: "fix(proxy): persist advanced budget-wind..." | Re-trigger Greptile

Comment on lines +5177 to +5190
"""
Set a Redis-backed spend counter to `value`, mirror it into the short-lived
spend_db_floor marker `_authoritative_floor_spend` reads, and broadcast both
to every worker (LIT-3803 pattern: setting, not deleting, means a worker's
own self-delivered broadcast still carries the reset value forward).

Without the floor marker, `_authoritative_floor_spend` can re-derive a
stale, pre-reset value from a marker another worker cached moments earlier
and raise the just-reset counter right back up via `_repair_stale_spend_counter`.
Without the broadcast, a worker that already cached the pre-reset key object
or floor marker keeps enforcing against it until its own TTL expires.
"""
from litellm.proxy.proxy_server import SPEND_DB_FLOOR_CACHE_TTL_SECONDS, spend_counter_cache

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Reset comments duplicate implementation

The new helper docstrings repeat implementation details across several long blocks, increasing maintenance cost and becoming misleading when cache behavior changes

Context Used: CLAUDE.md (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@yassin-berriai
yassin-berriai force-pushed the litellm_fix_reset_spend_budget_windows branch from 4448c42 to cd7f877 Compare August 28, 2026 18:53
Greptile P1: eviction ran before the window-reset DB write committed, so a
request racing the reset could re-fetch and re-cache the pre-write row,
pinning that pod to the stale budget_limits for the rest of its own cache
TTL even after the write went through. Move the eviction to run last.
@yassin-berriai

Copy link
Copy Markdown
Contributor Author

@greptileai Fixed: moved the cache eviction to run after the window-reset DB write. Please re-review the current head dd695e3.

test_delete_cache_key_object_broadcasts_invalidation now asserts a real
UserApiKeyCache no longer holds the evicted entry, rather than only
inspecting a mock's call args. Suppress test-quality-ok on the
hash_token/_check_proxy_or_team_admin_for_key/_delete_cache_key_object/
publish_auth_cache_invalidation patches: none has an HTTP boundary to
fake, matching the pattern the file already uses for these same targets.
@codecov

codecov Bot commented Aug 28, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.50000% with 3 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...y/management_endpoints/key_management_endpoints.py 92.30% 3 Missing ⚠️

📢 Thoughts on this report? Let us know!

isinstance(x, list) in the else branch still leaves Sequence[object] | str
(a tuple satisfies Sequence without being a list), so json.loads() saw a
possible non-str argument. Check isinstance(x, str) instead, which narrows
each branch to exactly the type it needs.
@yassin-berriai

Copy link
Copy Markdown
Contributor Author

@greptileai Pushed test-quality-gate fixes and a basedpyright type-narrowing fix since the last review. Please re-review the current head 05fda30.

Comment thread litellm/proxy/management_endpoints/key_management_endpoints.py Outdated
…counters

Greptile P1: publishing a zeroed window counter before the new reset_at
committed let a request racing the write compute window_start from the
stale boundary, re-sum the historical spend log rows the reset was
clearing, and put the counter right back above budget. Compute every
window's new boundary, persist all of them in one DB write, then zero
each window's Redis counter only once that write has landed.
@yassin-berriai

Copy link
Copy Markdown
Contributor Author

@greptileai Fixed: window boundaries now persist before their counters zero, closing the race. Please re-review the current head 5720281.

Comment on lines +5195 to +5208
await spend_counter_cache.redis_cache.async_set_cache(key=counter_key, value=value, ttl=60)
except Exception as redis_err:
verbose_proxy_logger.warning(
"Failed to update spend counter %s in Redis: %s. "
"Budget checks may use stale value until counter expires.",
counter_key,
redis_err,
)

floor_key: Final = f"spend_db_floor:{counter_key}"
spend_counter_cache.in_memory_cache.set_cache(key=floor_key, value=value, ttl=SPEND_DB_FLOOR_CACHE_TTL_SECONDS)

await publish_auth_cache_invalidation(cache_key=counter_key, new_value=value, ttl=60)
await publish_auth_cache_invalidation(cache_key=floor_key, new_value=value, ttl=SPEND_DB_FLOOR_CACHE_TTL_SECONDS)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Peer restores reset counter

If a request reaches a peer pod before it processes the floor-marker and key-object broadcasts, that pod derives the old window boundary from its cached key object, re-sums historical spend after observing the zeroed shared counter, and monotonically restores the counter. The key then continues returning 429 responses after the reset reports success.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@yassin-berriai

Copy link
Copy Markdown
Contributor Author

@greptileai Added Review notes in the PR description: this race matches invalidate_team_member_spend_state's existing broadcast pattern. Please re-review.

@codspeed

codspeed Bot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing litellm_fix_reset_spend_budget_windows (5720281) with litellm_internal_staging (c5cdd2e)1

Open in CodSpeed

Footnotes

  1. No successful run was found on litellm_internal_staging (1e86532) during the generation of this report, so c5cdd2e was used instead as the comparison base. There might be some changes unrelated to this pull request in this report. ↩

@yassin-berriai
yassin-berriai merged commit 671f89d into litellm_internal_staging Aug 28, 2026
81 checks passed
@yassin-berriai
yassin-berriai deleted the litellm_fix_reset_spend_budget_windows branch August 28, 2026 22:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants