fix(proxy): reset carried spend when a budget window is (re)armed on key/user/team update - #34493
Conversation
… key/user/team update
Applying budget_duration on an existing key/user/team via update set
budget_reset_at but left the carried lifetime spend in place, so an entity
with prior spend was instantly over the new cap and 429'd on a brand-new
window (a key created with a budget starts at spend=0; update now matches).
Adds is_budget_window_newly_armed() so spend is only zeroed when the window
is genuinely (re)armed — first application, an expired window, or a changed
duration — never on a no-op re-send of the same active duration, which
preserves accumulated mid-window spend. Also invalidates the cross-pod spend
counter (spend:{key,user,team}:*) after the DB write so budget checks don't
read a stale value on the fresh window.
PR overviewAll previously flagged issues have been addressed. No open security concerns remain on this pull request. Security reviewNo open security issues remain on this pull request. Fixed/addressed: 2 · PR risk: 0/10 |
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Greptile SummaryUpdates key, user, and team budget-window handling to reset carried spend only when a window is newly armed, synchronize spend counters after persistence, tighten re-arm authorization, and include server-inferred budget fields in key audit records Confidence Score: 5/5The PR appears safe to merge No blocking failures remain
|
| Filename | Overview |
|---|---|
| litellm/proxy/common_utils/timezone_utils.py | Adds the shared predicate distinguishing newly armed or expired budget windows from unchanged active-window resubmissions |
| litellm/proxy/management_endpoints/internal_user_endpoints.py | Applies conditional spend resets to single and bulk user updates and invalidates the corresponding spend counters |
| litellm/proxy/management_endpoints/key_management_endpoints.py | Applies conditional key spend resets, synchronizes key spend counters, enforces budget-update authorization, and forwards inferred values for auditing |
| litellm/proxy/management_endpoints/team_endpoints.py | Resets team spend on authorized window re-arms and invalidates the plain team spend counter after persistence |
| litellm/proxy/hooks/key_management_event_hooks.py | Merges filtered server-applied budget-window values into key update and regeneration audit payloads |
Reviews (12): Last reviewed commit: "fix(proxy): record which users had spend..." | Re-trigger Greptile
…e-arm Address PR review (veria-ai + Greptile): - Gate budget_duration behind the admin budget-authority check on key and team updates so a non-proxy-admin cannot re-arm a budget window (which resets accumulated spend) without authorization. - Let a caller-supplied explicit spend take precedence over the window reset on key, user, and team updates. - Add coverage: explicit-spend precedence (key, user) and budget_duration authorization 403s (key, team).
|
@greptileai review |
|
Want your agent to iterate on Greptile's feedback? Try greploops. |
…team spend guard Address re-review (veria-ai team High + Greptile team P1): - Move the budget_duration re-arm authorization out of _check_team_budget_update_authority (which only runs for standalone teams) into a dedicated _check_team_budget_window_rearm_authority that runs unconditionally, so org-scoped team admins can no longer re-arm the window (and thereby zero accumulated spend) without proxy-admin authority. The guard uses the newly-armed predicate, so a no-op re-send of the same active duration is still allowed. - Drop the explicit-spend precedence guard from _reset_team_spend_if_budget_window_newly_armed: UpdateTeamRequest exposes no spend field and the base model ignores extras, so the branch was unreachable and left a misleading stale-counter path. The only team spend write is now the reset to 0.0, which always invalidates the counter. - Add coverage: standalone and org-scoped team-admin re-arm both rejected 403.
The key audit log logged only the raw /key/update request, omitting the server-inferred spend=0.0 written on a budget-window re-arm. Pass the non_default_values actually persisted to the DB into the hook so the audit trail reflects what was applied, not just what was requested.
|
@greptileai review |
Add direct unit tests for _sync_key_spend_counter (window-reset sync to redis, no-op when unchanged, redis-absent skip, redis-error swallow) and _apply_key_budget_window (invalid-duration no-op, null clears fields), closing the remaining uncovered lines in the spend-window reset fix.
prepare_metadata_fields copies existing metadata into non_default_values even when the caller did not send it, so passing the whole dict as the audit applied_values recorded unchanged metadata as an operator change. Merge only the server-inferred budget fields (spend, budget_duration, budget_reset_at) into the audit log instead.
|
@greptileai review |
Add a direct unit test for _check_team_budget_window_rearm_authority's allowed path: a non-admin re-sending the same active budget_duration is not a re-arm and must return without raising (closes the last uncovered patch line flagged by codecov).
|
@greptileai review |
|
@greptileai review |
…gfix/reset-spend-on-budget-window-apply # Conflicts: # tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py
… update path
/user/bulk_update with all_users=true applies budget_duration to every user via
update_many(where={}), which arms budget_reset_at while leaving carried spend in
place. Any user whose lifetime spend already exceeds the freshly applied cap is
locked out with a 429 on a brand-new window, and the cross-pod spend counter is
never invalidated so the lockout survives even after the DB spend is corrected.
The targeted users=[...] path already handles this via _update_single_user_helper;
only the optimized all-users branch was missed.
- _newly_armed_user_ids: resolve which rows are genuinely (re)armed, reusing
is_budget_window_newly_armed so an unchanged-duration resend on an active
window still preserves spend
- zero spend for that subset only, then invalidate spend:user:<id> for it
- an explicit spend in the payload keeps precedence and invalidates every counter
|
@greptileai review |
…gfix/reset-spend-on-budget-window-apply # Conflicts: # litellm/proxy/management_endpoints/internal_user_endpoints.py # tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py
|
@greptileai review |
…pers The upstream typing cleanup (fbfb63c) lowered both ceilings, so annotations this branch already carried now breach them. - ANN401: type the team helpers' existing_team_row as BaseModel instead of Any, matching how the same find_unique row is annotated elsewhere in the file - LIT001: annotate the read-only payload arguments as Mapping (the audit-value selector, the key spend-counter sync, the team counter invalidation and the key-update hook's applied_values never mutate their argument), return the newly-armed user ids as a tuple, and mark the three helpers that genuinely write back into the caller's payload with a mutable-ok reason
|
@greptileai review |
…t log /key/regenerate shares prepare_key_update_data, so a rotation that also applies budget_duration persists spend=0.0 and a fresh budget_reset_at. The rotation audit record is built from GenerateKeyResponse, which declares spend and budget_duration but not budget_reset_at, so the newly armed window silently never reached the audit trail. Pass the same server-inferred values the update path already passes, and share one serializer between both hooks. The cross-pod counter needs no equivalent call here: the counter is keyed on the token hash and regenerate writes a new one, so the post-rotation read is a clean miss that reseeds from the authoritative DB row.
…it log The all-users bulk audit entry logs non_default_values, which by design carries no spend key — the reset is applied to the newly-armed subset in a second write. So an operator reading the audit trail saw the budget window being armed with no record that spend was zeroed, or for whom.
Hand review of this PR (self-review, not a bot pass)I read the whole diff myself rather than only reacting to bot findings. Two real defects came out of it — one from @greptileai's P1 on the rotation path, one I found on my own. Both are fixed in this push. Recording the rest so reviewers do not have to re-derive it. Fixed as a result of this review1. Rotation audit log dropped 2. Bulk-update audit log did not record the spend reset (mine, Checked and deliberately left alone
Verification
|
|
The analysis is accurate and the fix is correctly scoped. Looking at the current state of
Your measurement is correct — Your note on the counter also holds: Fix is correct. Resolving. |
TLDR
Problem this solves:
budget_durationon an existing key/user/team leaves carried spend → instant 429 on a fresh windowHow it solves it:
Relevant issues
Fixes #34492
Pre-Submission checklist
Screenshots / Proof of Fix
Reproduction: create a key/user/team without a budget, accumulate spend (e.g. 100), then
/key/update(or/user/update,/team/update) withmax_budget=50+budget_duration="30d". Pre-fix the next request 429s because the carried spend exceeds the new cap on a brand-new window.Live proof on a running proxy to be added.
Type
🐛 Bug Fix
Changes
Applying a budget window on an existing entity via update set
budget_reset_atbut never reset the carried lifetimespend, so an entity with prior spend was instantly over-cap and 429'd on a brand-new window (a key/user/team created with a budget starts at spend=0; the update path now matches).is_budget_window_newly_armed(new_duration, existing_duration, existing_reset_at)inlitellm/proxy/common_utils/timezone_utils.py: True on first application, an expired window (existing_reset_at < now), or a changed duration; False on a no-op re-send of the same active duration.key_management_endpoints.py,internal_user_endpoints.py,team_endpoints.pyupdate paths: zerospendonly when the predicate says the window is newly armed.spend:key:*/spend:user:*/spend:team:*) so budget checks don't read a stale value on the fresh window. The user path reuses the existing_invalidate_user_spend_counter_if_changed; key and team invalidate explicitly (the existing key sync only fires whenspendis passed explicitly, and teams have no spend-triggered invalidation).Notes for review:
budget_duration(e.g. a UI form resubmitting the whole object alongside an unrelated edit) must NOT wipe accumulated mid-window spend. The predicate gates exactly that; regression tests cover the preserve case for all three paths.spend=0.0before the guard means a non-admin self-update that would arm a window is correctly blocked (zeroing one's own spend is itself an escalation), failing closed.Extends the counter-invalidation mechanism from #30785 and the
{spend, budget_reset_at}write pattern from #29358 to thebudget_duration-change trigger, which neither covered.Final Attestation