Skip to content

fix(proxy): reset carried spend when a budget window is (re)armed on key/user/team update - #34493

Draft
yryzhan-vitech wants to merge 13 commits into
BerriAI:litellm_internal_stagingfrom
yryzhan-vitech:bugfix/reset-spend-on-budget-window-apply
Draft

fix(proxy): reset carried spend when a budget window is (re)armed on key/user/team update#34493
yryzhan-vitech wants to merge 13 commits into
BerriAI:litellm_internal_stagingfrom
yryzhan-vitech:bugfix/reset-spend-on-budget-window-apply

Conversation

@yryzhan-vitech

@yryzhan-vitech yryzhan-vitech commented Jul 24, 2026

Copy link
Copy Markdown

TLDR

Problem this solves:

  • Applying budget_duration on an existing key/user/team leaves carried spend → instant 429 on a fresh window
  • The cross-pod spend counter also isn't invalidated on that path, so it stays warm at the stale value

How it solves it:

  • Zero spend + invalidate the counter, but ONLY when the window is genuinely (re)armed — never on a no-op re-send

Relevant issues

Fixes #34492

Pre-Submission checklist

  • I have added meaningful tests
  • My PR passes all CI/CD checks (e.g., lint, format, unit tests)
  • 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

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) with max_budget=50 + budget_duration="30d". Pre-fix the next request 429s because the carried spend exceeds the new cap on a brand-new window.

$ pytest tests/test_litellm/proxy/common_utils/test_timezone_utils.py -k "newly_armed or rearms or resend" \
         tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py -k "budget_window or window_resend" \
         tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py -k "budget_window or unchanged_window" \
         tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py -k "zeroes_spend_on_new or preserves_spend_on_unchanged"
11 passed

Live proof on a running proxy to be added.

image

Type

🐛 Bug Fix

Changes

Applying a budget window on an existing entity via update set budget_reset_at but never reset the carried lifetime spend, 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).

  • New shared predicate is_budget_window_newly_armed(new_duration, existing_duration, existing_reset_at) in litellm/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.py update paths: zero spend only when the predicate says the window is newly armed.
  • Post-commit, invalidate/re-seed the cross-pod spend counter (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 when spend is passed explicitly, and teams have no spend-triggered invalidation).

Notes for review:

  • The key correctness requirement is that a no-op re-send of the same active 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.
  • The self-escalation guard (GHSA-wvg4) ordering is preserved: injecting spend=0.0 before 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.
  • Counter invalidation is done AFTER the DB commit, matching the existing anti-bypass ordering.

Extends the counter-invalidation mechanism from #30785 and the {spend, budget_reset_at} write pattern from #29358 to the budget_duration-change trigger, which neither covered.

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

… 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.
Comment thread litellm/proxy/management_endpoints/key_management_endpoints.py
Comment thread litellm/proxy/management_endpoints/team_endpoints.py
@veria-ai

veria-ai Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

PR overview

All previously flagged issues have been addressed. No open security concerns remain on this pull request.

Security review

No open security issues remain on this pull request.

Fixed/addressed: 2 · PR risk: 0/10

@codecov

codecov Bot commented Jul 24, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@greptile-apps

greptile-apps Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Updates 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/5

The PR appears safe to merge

No blocking failures remain

Important Files Changed

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

Comment thread litellm/proxy/management_endpoints/internal_user_endpoints.py
Comment thread litellm/proxy/management_endpoints/key_management_endpoints.py
@codspeed-hq

codspeed-hq Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing yryzhan-vitech:bugfix/reset-spend-on-budget-window-apply (075df61) with litellm_internal_staging (f4a68a7)

Open in CodSpeed

…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).
@yryzhan-vitech

Copy link
Copy Markdown
Author

@greptileai review

@greptile-apps

greptile-apps Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Want your agent to iterate on Greptile's feedback? Try greploops.

Comment thread litellm/proxy/management_endpoints/team_endpoints.py
…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.
@yryzhan-vitech yryzhan-vitech changed the title fix(budget): reset carried spend when a budget window is (re)armed on key/user/team update fix(proxy): reset carried spend when a budget window is (re)armed on key/user/team update Jul 24, 2026
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.
@yryzhan-vitech

Copy link
Copy Markdown
Author

@greptileai review

@yryzhan-vitech
yryzhan-vitech marked this pull request as draft July 24, 2026 12:52
@yryzhan-vitech
yryzhan-vitech marked this pull request as ready for review July 24, 2026 13:34
Comment thread litellm/proxy/hooks/key_management_event_hooks.py Outdated
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.
@yryzhan-vitech

Copy link
Copy Markdown
Author

@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).
@yryzhan-vitech

Copy link
Copy Markdown
Author

@greptileai review

Comment thread litellm/proxy/management_endpoints/team_endpoints.py
@yryzhan-vitech

Copy link
Copy Markdown
Author

@greptileai review

@yryzhan-vitech
yryzhan-vitech marked this pull request as draft July 24, 2026 14:55
…gfix/reset-spend-on-budget-window-apply

# Conflicts:
#	tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py
@yryzhan-vitech

Copy link
Copy Markdown
Author

@greptileai

… 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
@yryzhan-vitech

Copy link
Copy Markdown
Author

@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
@yryzhan-vitech

Copy link
Copy Markdown
Author

@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
@yryzhan-vitech

Copy link
Copy Markdown
Author

@greptileai review

Comment thread litellm/proxy/management_endpoints/key_management_endpoints.py
…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.
@yryzhan-vitech

Copy link
Copy Markdown
Author

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 review

1. Rotation audit log dropped budget_reset_at (from the P1 above, a0ee6805ab)
/key/regenerate shares prepare_key_update_data, so a rotation carrying 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 armed window silently never reached the audit trail. Both hooks now share one serializer.

2. Bulk-update audit log did not record the spend reset (mine, 075df61058)
Same defect class, different endpoint, and the bot did not flag it. The all-users bulk audit entry logs non_default_values, which by design carries no spend key — the reset is a second, subset-scoped write. So an operator saw "budget window armed" with no record that spend was zeroed or for whom. Now emits spend_reset_user_ids.

Checked and deliberately left alone

  • Cross-pod counter on the rotation path. I checked whether regenerate also needs the _sync_key_spend_counter call update_key_fn makes. It does not: the counter is keyed spend:key:{token_hash} and regenerate writes a new hash, so the post-rotation read is a clean miss and get_current_spend reseeds from the authoritative DB row. Syncing there would write a counter for an already-superseded token.
  • Naive (tz-less) budget_reset_at. is_budget_window_newly_armed derives now from existing_reset_at.tzinfo, so a naive DB value compares against a naive now instead of raising TypeError. Intentional.
  • Boundary reset_at == now arms the window (treated as expired). Deliberate: erring toward a reset is the safe direction, since the alternative locks an entity out of a window that has just rolled over.
  • Read-then-write race in the bulk path. find_manyupdate_many(all)update_many(armed subset) is not atomic, so a window armed by a concurrent request in that gap is not accounted for. This is the pre-existing shape of the endpoint and I did not widen it; a transaction here would be a separate change.
  • Two-query split vs. one. I first tried splitting the main write into armed/not-armed halves. Reverted: it changes the failure semantics of the existing except fallback. The base bulk write is now untouched and the reset is additive after it.
  • Empty-string budget_duration returns False (no arm), matching the len(budget_duration) > 0 guard the key path already had.

Verification

  • 736 tests green across the five touched test files; the 5 unrelated failures in test_cache_settings_endpoints.py / test_coordination_redis_endpoints.py reproduce identically on the base with my changes stashed.
  • Patch coverage: no uncovered lines in the changed ranges.
  • ruff check / ruff format, ruff_strict_gate.py and type_discipline_gate.py all clean against this PR's base SHA. Note ANN401 and LIT001 both tightened under fbfb63c948 mid-review; the read-only payload arguments are now Mapping, and the three helpers that genuinely write back into the caller's payload carry a mutable-ok reason rather than a blanket suppression.
  • Negative controls for every behavioural claim, mutated narrowly one mechanism at a time rather than by deleting the fix: removing only the subset reset query, only the counter invalidation, only the re-arm predicate, only the rotation merge, and only the bulk audit merge each fail exactly the test that asserts them.

@greptile-apps

greptile-apps Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

The analysis is accurate and the fix is correctly scoped. Looking at the current state of async_key_rotated_hook in key_management_event_hooks.py, commit a0ee6805ab added:

  1. applied_values: Mapping[str, Any] | None = None parameter to async_key_rotated_hook
  2. Shared _merge_applied_values serializer: KeyManagementEventHooks._merge_applied_values(base=response.model_dump(exclude_none=True), applied_values=applied_values)

Your measurement is correct — spend and budget_duration were already on GenerateKeyResponse so they were never lost; budget_reset_at was the only actual gap. The fix properly threads through the server-inferred values via applied_values without touching GenerateKeyResponse's schema, which is the right approach since that type is part of the public SDK surface.

Your note on the counter also holds: spend:key:{token_hash} is keyed on the token hash and regenerate_key_fn writes a new hash, so the post-rotation read is a guaranteed cache miss that reseeds from the DB row at spend=0.0 — no explicit invalidation needed.

Fix is correct. Resolving.

@yryzhan-vitech

Copy link
Copy Markdown
Author

@greptileai

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.

[Bug]: Applying budget_duration on an existing key/user/team doesn't reset carried spend — instant 429 on a fresh window

1 participant