Skip to content

fix(reset_budget): cherry-pick #29358 into patch/v1.87.0rc1 - #29361

Merged
yuneng-berri merged 1 commit into
patch/v1.87.0rc1from
patch/v1.87.0rc1-cp-29358
May 31, 2026
Merged

fix(reset_budget): cherry-pick #29358 into patch/v1.87.0rc1#29361
yuneng-berri merged 1 commit into
patch/v1.87.0rc1from
patch/v1.87.0rc1-cp-29358

Conversation

@yuneng-berri

Copy link
Copy Markdown
Collaborator

Relevant issues

Cherry-pick of #29358 (merge commit 7d1bd9d9f4) onto the patch/v1.87.0rc1 branch. Original PR fixes #27730.

Linear ticket

n/a

Pre-Submission checklist

CI (LiteLLM team)

  • Branch creation CI run -- link:
  • CI run for the last commit -- link:
  • Merge / cherry-pick CI run -- links:

Bug verification on v1.87.0-rc.1

Confirmed the bug is present at the v1.87.0-rc.1 tag (the head of patch/v1.87.0rc1) before this cherry-pick lands.

Trigger half — full Pydantic model is shipped through update_data:

$ git show v1.87.0-rc.1:litellm/proxy/common_utils/reset_budget_job.py | sed -n '456,466p'
                if updated_keys:
                    await self.prisma_client.update_data(
                        query_type="update_many",
                        data_list=updated_keys,
                        table_name="key",
                    )
                    for k in updated_keys:
                        token = getattr(k, "token", None)
                        if token:
                            await self._invalidate_spend_counter(f"spend:key:{token}")

Same shape at lines 545-555 (updated_users) and 642-652 (updated_teams). Each ships the full Pydantic row; Prisma rejects object_permission_id and budget_limits on the update input type and detonates the batch.

Mechanism half — _reset_budget_common zeroes the cross-pod counter before the DB write:

$ git show v1.87.0-rc.1:litellm/proxy/common_utils/reset_budget_job.py | awk '/def _reset_budget_common/,/return item/'
...
            if counter_key is not None:
                spend_counter_cache.in_memory_cache.set_cache(key=counter_key, value=0.0)
                if spend_counter_cache.redis_cache is not None:
                    try:
                        await spend_counter_cache.redis_cache.async_set_cache(
                            key=counter_key, value=0.0
                        )
                    ...

Counter is zeroed inside _reset_budget_common, which the caller runs before the (failing) update_data batch. After this cherry-pick lands, the counter writes only fire via _invalidate_spend_counter after a successful DB commit.

Screenshots / Proof of Fix

End-to-end proof-of-fix harness output (pre-fix DataError + bypass; post-fix clean reset + normal enforcement) is captured in the original PR body at #29358 and was run against a real Postgres + Redis proxy. The cherry-pick is a verbatim replay of that commit (git diff between this PR and the merge commit's tree on the touched files is empty) so the same harness output applies.

Type

Bug Fix

Changes

Verbatim cherry-pick of #29358's merge commit 7d1bd9d9f4c313dc1e2cd4aeec5e70a199ee6529 onto patch/v1.87.0rc1. No conflicts. 3 files, +409/-132 (identical stat to the original merge commit). Full rationale and trigger/mechanism analysis live in the #29358 PR body.

…eroing counter (#29358)

* fix(reset_budget): write only {spend, budget_reset_at} and stop pre-zeroing counter

ResetBudgetJob's batched update_data path shipped the full key/user/team
model on each reset. Prisma rejects object_permission_id and budget_limits
on the update input type, so any row carrying those fields detonated the
entire batch -- spend never reset, budget_reset_at never advanced. After
v1.84.0 started populating object_permission_id on UI-created keys, this
fires routinely.

_reset_budget_common also zeroed the cross-pod spend counter before the
DB write, so failed resets left enforcement reading 0 from the counter
while the DB still held the over-budget spend, admitting requests past
the cap until the counter naturally re-saturated from new reservations.

Switch the write to per-row narrow updates ({spend, budget_reset_at})
via db.batch_, and move the counter invalidation out of
_reset_budget_common so it only fires after the DB write commits. On
DB-write failure the counter is left untouched, enforcement continues
to block, and the next scheduler tick can retry without leaving a
bypass window.

Fixes #27730.

* fix(reset_budget): address Greptile review on #29358

- Strengthen the bypass-half regression test: replace the for-loop over
  call_args_list (vacuously true when empty) with assert_not_called(),
  so the test would actually flag a re-introduction of counter-zeroing
  via any code path.
- Add the same explanatory docstring on _write_user_reset_updates and
  _write_team_reset_updates that _write_key_reset_updates already has,
  so all three helpers point future maintainers at #27730.

* test(reset_budget): update test_proxy_budget_reset for new batch-write path

Same shape as the previous test_reset_budget_job.py update: keys/users/teams
now write through prisma.db.batch_().<table>.update, not update_data, so the
tests need a batcher mock and updated assertions. Adds:

- _wire_batcher_for_test helper that returns a list which accumulates per-row
  batch updates captured from prisma_client.db.batch_().
- _attrify helper that wraps dict fixtures so getattr(item, "token") works
  alongside the dict item-access the fake_reset_* mocks rely on. The new
  narrow-write helpers use getattr to pull out the row's id, and would
  silently skip plain dicts otherwise.
- Updates 3 partial_failure tests to assert against the batch-call list
  (rows by id, payload contains only {spend, budget_reset_at}) instead of
  update_data.assert_awaited_once + data_list inspection.
- Updates test_reset_budget_continues_other_categories_on_failure: only
  budget + enduser still flow through update_data; key/user/team go through
  the batch path now.
- Wires the batcher mock into 3 service_logger_*_success tests so commit()
  is actually awaitable and the success hook fires.

These tests were silently passing locally only because the editable install
in .venv pointed at the main repo, not the worktree — running pytest with
PYTHONPATH overridden to the worktree (matching CI) reproduces the failures.
@greptile-apps

greptile-apps Bot commented May 31, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This cherry-pick of #29358 onto patch/v1.87.0rc1 fixes two interleaved bugs in the budget-reset job that together allowed users to bypass spend caps after a failed reset cycle.

  • Trigger half ([Bug] ResetBudgetJob fails on key/team batch updates when rows include relation and JSON fields #27730): The original update_data(query_type="update_many", data_list=updated_keys) call shipped the full Pydantic model to Prisma; rows carrying object_permission_id or budget_limits caused a DataError that aborted the entire batch, leaving spend and budget_reset_at unchanged in the DB forever. Three new narrow-write helpers (_write_key_reset_updates, _write_user_reset_updates, _write_team_reset_updates) send only {spend, budget_reset_at} per row via prisma.db.batch_(), avoiding the rejected fields.
  • Bypass half: The old _reset_budget_common zeroed the Redis spend counter before the DB write; if the write exploded, get_current_spend read 0 from Redis while the DB still held the pre-reset value, admitting requests past the cap. Counter invalidation is now deferred to the callers, strictly after a successful batcher.commit(), so a DB failure leaves the counter intact.

Confidence Score: 4/5

Safe to merge; the fix correctly eliminates both the Prisma DataError and the Redis counter-bypass window, backed by targeted regression tests for each half.

The core logic — narrow-field batch writes + post-commit counter invalidation — is sound and all three entity paths (key/user/team) are handled consistently. Two minor style-level observations: the spend literal is 0 (int) rather than 0.0 (float) for consistency with the rest of the codebase, and all three write helpers call batcher.commit() even when every row was skipped due to a None identifier (harmless empty round-trip). Neither affects correctness.

No files require special attention; reset_budget_job.py carries the substantive change and the test files provide solid regression coverage for both bug halves.

Important Files Changed

Filename Overview
litellm/proxy/common_utils/reset_budget_job.py Core fix: three narrow-write helpers replace full-model update_data calls, and counter invalidation moved post-commit; logic is correct but empty-batch guard is missing.
tests/litellm_utils_tests/test_proxy_budget_reset.py Existing partial-failure tests updated to wire the new batch_ path and assert narrow {spend, budget_reset_at} payload; _attrify helper and _wire_batcher_for_test correctly simulate the Prisma batcher interface.
tests/test_litellm/proxy/common_utils/test_reset_budget_job.py New MockBatcher class mirrors Prisma batch_() ergonomics; two new regression tests verify counter is not zeroed on DB failure and payload never includes extra fields.

Reviews (1): Last reviewed commit: "fix(reset_budget): write only {spend, bu..." | Re-trigger Greptile

Comment on lines +430 to +439
batcher = self.prisma_client.db.batch_()
for k in updated_keys:
token = getattr(k, "token", None)
if token is None:
continue
batcher.litellm_verificationtoken.update(
where={"token": token},
data={"spend": 0, "budget_reset_at": k.budget_reset_at},
)
await batcher.commit()

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 If every key in updated_keys has token=None, no update calls are staged but batcher.commit() is still awaited. Prisma handles an empty batch gracefully, but the cost is an extra round-trip to the database engine. The same applies to _write_user_reset_updates and _write_team_reset_updates. Guarding with a staged flag makes the intent explicit and avoids the needless round-trip.

Suggested change
batcher = self.prisma_client.db.batch_()
for k in updated_keys:
token = getattr(k, "token", None)
if token is None:
continue
batcher.litellm_verificationtoken.update(
where={"token": token},
data={"spend": 0, "budget_reset_at": k.budget_reset_at},
)
await batcher.commit()
batcher = self.prisma_client.db.batch_()
staged = False
for k in updated_keys:
token = getattr(k, "token", None)
if token is None:
continue
batcher.litellm_verificationtoken.update(
where={"token": token},
data={"spend": 0, "budget_reset_at": k.budget_reset_at},
)
staged = True
if staged:
await batcher.commit()

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!

Comment on lines +435 to +438
batcher.litellm_verificationtoken.update(
where={"token": token},
data={"spend": 0, "budget_reset_at": k.budget_reset_at},
)

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 The spend field is written as the integer literal 0 here, but _reset_budget_common sets item.spend = 0.0 (a float). Prisma coerces both to the Float column type, so there is no functional issue, but keeping the value as 0.0 is more consistent and makes it clear the column is a float field.

Suggested change
batcher.litellm_verificationtoken.update(
where={"token": token},
data={"spend": 0, "budget_reset_at": k.budget_reset_at},
)
batcher.litellm_verificationtoken.update(
where={"token": token},
data={"spend": 0.0, "budget_reset_at": k.budget_reset_at},
)

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!

@yuneng-berri
yuneng-berri merged commit 246e4dc into patch/v1.87.0rc1 May 31, 2026
2 checks passed
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.

1 participant