Skip to content

fix(ptu): hand the prune a plain delete filter the query builder can serialise - #37571

Merged
yucheng-berri merged 2 commits into
litellm_internal_stagingfrom
litellm_lit5809_prune_filter_serialise
Aug 20, 2026
Merged

fix(ptu): hand the prune a plain delete filter the query builder can serialise#37571
yucheng-berri merged 2 commits into
litellm_internal_stagingfrom
litellm_lit5809_prune_filter_serialise

Conversation

@yucheng-berri

@yucheng-berri yucheng-berri commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

TLDR

Problem this solves:

  • The nightly PTU job raises once a config deployment is priced
  • Its delete predicate is a read-only mapping the query builder rejects
  • Charges land first, so the run looks like it produced rows

How it solves it:

  • The predicate is built as a plain dict
  • A test asserts that, since the in-memory fake accepts any mapping
  • The catch-up pass gains a config-declared reservation case

User Flow

Before: an admin who turns on PTU attribution with a deployment in config.yaml sees the day's costs appear and everything after it stop

  1. The admin runs a proxy with LITELLM_ENABLE_PTU_COST_ATTRIBUTION=True and a provisioned-throughput deployment declared in config.yaml
  2. The nightly job runs at 00:15 UTC and writes the team's reserved-capacity cost
  3. It then raises, so the run never finishes
  4. https://litellm-domain/ui/?page=usage shows the day's cost, which makes the run look successful
  5. The lapsed-window alert never fires and the catch-up pass never runs, so a reservation whose window closed goes unannounced and earlier unpriced days are never filled in

After: the same job finishes

  1. The admin runs the same proxy
  2. The nightly job runs and writes the same cost
  3. It completes, sweeping the rows it can account for
  4. https://litellm-domain/ui/?page=usage shows the same figure
  5. The lapsed-window alert and the catch-up pass both run

Relevant issues

Linear ticket

Refs LIT-5809

Pre-Submission checklist

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

  • 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 (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

Real Postgres, the real scheduled entry point, one config-declared PTU deployment at 100 units and $0.02 an hour. Both sides run the same three lines against the same database, and the exit code is checked rather than the output skimmed, which is how the defect reached staging in the first place.

proxy_server.llm_router = Router(model_list=yaml.safe_load(open("ptu.yaml"))["model_list"])
await run_scheduled_ptu_rollup(prisma, pod_lock_manager=<lock held>, target_date=date(2026, 8, 18))
await prisma.db.litellm_dailyteamspend.find_many(where={"api_key": "__ptu_flat_cost__"})

Before (c2b3c4b)

  1. Run it
exit=1
TypeError: Type <class 'mappingproxy'> not serializable
  1. Read the table back
1 sentinel row(s) written before it died

The charge is there, which is what makes the failure easy to miss: the upserts precede the sweep, so the usage view fills in and the run dies afterwards

After (db015e9)

  1. Run it
exit=0
rollup: RollupResult(day=2026-08-18, models_processed=1, rows_written=1, rows_failed=0, lapsed=())
flat-cost rows: [('ptu-team-0001', 'gpt-4o-ptu', 48.0)]
  1. Read the table back
tracebacks: 0

The run completes, so the lapsed-window alert and the catch-up pass that follow it both get to run

Type

🐛 Bug Fix

Changes

_prune_unrefreshed_sentinel_rows built its bounded predicate with MappingProxyType, and prisma/builder.py refuses to serialise a mappingproxy. A predicate builder now returns a plain dict for both the bounded and unbounded sweeps.

The unit suite could not catch this: its in-memory table accepts any mapping, so the delete looked fine. The new test asserts the predicate's concrete type, and a second one drives the catch-up pass over a config-declared reservation, which is the path the crash was blocking.

QA runbook

Caveats (if any)

  • The annotation says Mapping; the test is what pins the concrete dict

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

Open in Devin Review

Note

Cursor Bugbot is generating a summary for commit db015e9. Configure here.

…serialise

The bounded sweep built its predicate as a read-only mapping view, which the query
builder refuses to serialise, so the nightly job raised as soon as a config-declared
deployment was priced. The charges were already written by then, which is why the run
looked like it had produced its rows.

The in-memory table these tests run against accepts any mapping, so only a live run
caught it. A predicate builder now returns a plain dict and is asserted as one, and the
catch-up pass has a test covering a config-declared reservation.
@yucheng-berri

Copy link
Copy Markdown
Contributor Author

@greptileai @cursor review this. It fixes a crash shipped in #37556: the prune's delete predicate was a read-only mapping the query builder refuses to serialise.

@yucheng-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

devin-ai-integration[bot]

This comment was marked as resolved.

@cursor cursor Bot left a comment

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.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit db015e9. Configure here.

@greptile-apps

greptile-apps Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR replaces the PTU prune predicate’s read-only mapping with independently constructed plain dictionaries and expands regression coverage.

  • Builds bounded and unbounded Prisma delete filters in one expression.
  • Adds coverage for serializable predicate shapes and config-declared catch-up reservations.

Confidence Score: 5/5

The PR appears safe to merge with no blocking failure remaining.

No blocking failure remains.

Important Files Changed

Filename Overview
litellm/proxy/spend_tracking/ptu_flat_cost_rollup.py Constructs fresh plain-dictionary prune filters while preserving the existing bounded and unbounded deletion scopes.
tests/test_litellm/proxy/spend_tracking/test_ptu_flat_cost_rollup.py Adds focused predicate-shape and config-declared catch-up coverage.

Reviews (2): Last reviewed commit: "refactor(ptu): build the prune predicate..." | Re-trigger Greptile

greptile-apps[bot]

This comment was marked as resolved.

@codecov

codecov Bot commented Aug 20, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@tin-berri tin-berri left a comment

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.

Reviewed the PTU nightly rollup crash fix. Root cause and fix line up: _prune_unrefreshed_sentinel_rows built its bounded delete predicate via MappingProxyType({**unbounded, "model": {...}}), and prisma's query builder refuses to serialize a mappingproxy, so the job died mid-run — after the sentinel charge rows were already upserted, which is why the crash was easy to miss in the UI (the day's cost still showed up correctly, but the lapsed-window alert and catch-up pass never got to run). The fix replaces it with a plain dict via a new _prune_filter helper, used for both the bounded and unbounded sweep. The proof-of-fix is convincing: before/after runs against real Postgres show exit=1/TypeError becoming exit=0/RollupResult(...), checking the actual exit code rather than skimming output — which is explicitly called out as how the defect reached staging in the first place. Good catch that the in-memory test double couldn't have caught this (it accepts any mapping), so the new test asserts the predicate's concrete dict type directly, plus a second test drives the catch-up pass over a config-declared reservation (the path the crash was blocking). Small, well-scoped, no auth/security surface — CI green (only the non-blocking benchmarks check still pending, no failures).

@codspeed-hq

codspeed-hq Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing litellm_lit5809_prune_filter_serialise (6742549) with litellm_internal_staging (c2b3c4b)1

Open in CodSpeed

Footnotes

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

Both filter shapes are known upfront, so the bounded one is constructed
directly rather than by mutating a value already declared Final.

The catch-up test took two independent clock reads, which disagree across
UTC midnight; it now derives both the reservation start and the expected
last charged day from a single read, matching the three sibling tests.
@yucheng-berri

Copy link
Copy Markdown
Contributor Author

@greptileai re-review 6742549. The prune predicate is now built in one shot, and the catch-up test takes a single clock read.

@yucheng-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@cursor cursor Bot left a comment

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.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 6742549. Configure here.

@yucheng-berri
yucheng-berri merged commit 8cf0b50 into litellm_internal_staging Aug 20, 2026
74 checks passed
@yucheng-berri
yucheng-berri deleted the litellm_lit5809_prune_filter_serialise branch August 20, 2026 05:19
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.

2 participants