Skip to content

fix(ptu): never retract a flat charge for a deployment the run cannot see - #37793

Merged
yucheng-berri merged 1 commit into
litellm_internal_stagingfrom
litellm_ptu_prune_always_bounded
Aug 21, 2026
Merged

fix(ptu): never retract a flat charge for a deployment the run cannot see#37793
yucheng-berri merged 1 commit into
litellm_internal_stagingfrom
litellm_ptu_prune_always_bounded

Conversation

@yucheng-berri

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

Copy link
Copy Markdown
Contributor

TLDR

Problem this solves:

  • The nightly sweep ran unbounded when no config.yaml model existed
  • It deleted charges for deployments absent from its own view
  • A reservation that really was held lost its record

How it solves it:

  • The sweep is always bounded to the deployments it scanned
  • A run only retracts charges it can reassess
  • config_sourced and the unbounded branch are gone

User Flow

Before: an admin retires one of a team's provisioned-throughput deployments and finds the capacity it already served silently erased from the team's bill

  1. The admin runs a proxy with LITELLM_ENABLE_PTU_COST_ATTRIBUTION=True and two provisioned-throughput deployments owned by one team
  2. On the 20th they retire one of them, because that contract ended
  3. The nightly job runs at 00:15 UTC and prices the deployment that remains
  4. It then sweeps, and because it can no longer see the retired deployment it deletes that deployment's charge for the day as well
  5. https://litellm-domain/ui/usage under Team Usage now shows the team billed only for the surviving deployment, so capacity the provider did invoice appears nowhere and re-running the job never brings it back

After: the same retirement leaves the record intact

  1. The admin runs the same proxy and retires the same deployment on the same day
  2. The nightly job runs and prices the deployment that remains
  3. It sweeps only the deployments it scanned, so the retired one is left alone
  4. https://litellm-domain/ui/usage shows both the surviving deployment's charge and the retired deployment's charge for the day it was held
  5. A deployment the job can still see, whose reservation window closed or whose PTU settings were removed, is swept exactly as before

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

Real Postgres, the real scheduled entry point, no mocks. One team owns a live reservation and a retired one, and the retired deployment's charge sits on the day the nightly job sweeps.

INSERT INTO "LiteLLM_DailyTeamSpend" (team_id, date, api_key, model, ptu_flat_cost, updated_at)
VALUES ('ptu-demo-team', '2026-08-20', '__ptu_flat_cost__', 'azure-ptu-retired', 240.0, NOW() - INTERVAL '2 hours');
proxy_server.llm_router = Router(model_list=yaml.safe_load(open("d1.yaml"))["model_list"])
await run_scheduled_ptu_rollup(prisma, pod_lock_manager=<lock held>, target_date=None)

Before (ff02d5c)

  1. Read the charges back
  before: {'db-ptu-retired': 240.0}
  after : {'db-ptu-live': 240.0}
  retired deployment's charge kept: False

The retired reservation's charge is gone, and no rerun restores it

After (58fb068)

  1. Read the charges back
  before: {'db-ptu-retired': 240.0}
  after : {'db-ptu-retired': 240.0, 'db-ptu-live': 240.0}
  retired deployment's charge kept: True
  1. The same day through the scheduled job, then the table
rollup: RollupResult(day=2026-08-20, models_processed=1, rows_written=1, rows_failed=0, lapsed=())

    date    |       model       | ptu_flat_cost
------------+-------------------+---------------
 2026-08-19 | azure-ptu-live    |           240
 2026-08-20 | azure-ptu-live    |           240
 2026-08-20 | azure-ptu-retired |           240
  1. Team Usage at https://litellm-domain/ui/usage, showing the retired reservation's charge retained beside the live one on 2026-08-20

Team Usage

Type

🐛 Bug Fix

Changes

_LoadedDeployments.config_sourced decided whether the sweep was bounded, and it was false whenever the router held no config.yaml deployment, which is every database-only proxy. The sweep then deleted the day's sentinel rows for deployments the run had never read, including ones another host declares and ones that were simply retired.

A written charge records capacity that was reserved. The only rows a run can justly retract are the ones it can reassess, meaning deployments it scanned and then declined to charge, so the prune is now always bounded to scanned_ids and the field is deleted. With nothing scanned the sweep issues no statements at all, which is the same protection the empty-scan case already relied on during a master-key rotation.

Caveats (if any)

  • A retired deployment's charge is now permanent, by design
  • An empty scan set issues no delete statements
  • A proxy without Redis still never prunes, unchanged here
  • Reassigning a deployment's team still double-charges history

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

yucheng-berri added a commit that referenced this pull request Aug 21, 2026
@yucheng-berri

Copy link
Copy Markdown
Contributor Author

@greptileai @cursor review this. Live proxy and real Postgres evidence is in the description, base versus head on the same scenarios.

@yucheng-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@devin-ai-integration devin-ai-integration 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.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no bugs or issues to report.

Open in Devin Review

@greptile-apps

greptile-apps Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR bounds PTU sentinel-row pruning to deployments visible to the current rollup, preserving charges that the run cannot reassess.

  • Removes the config_sourced distinction and unbounded deletion path.
  • Skips deletion when no deployments were scanned.
  • Updates tests for visible, retired, and empty-scan deployment cases.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
litellm/proxy/spend_tracking/ptu_flat_cost_rollup.py Bounds stale PTU charge pruning to scanned deployment IDs and avoids deletion for an empty scan.
tests/test_litellm/proxy/spend_tracking/test_ptu_flat_cost_rollup.py Updates pruning expectations and uses concise behavioral-rationale docstrings consistent with the surrounding tests.

Reviews (2): Last reviewed commit: "fix(ptu): never retract a flat charge fo..." | Re-trigger Greptile

greptile-apps[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 450004c. Configure here.

@codecov

codecov Bot commented Aug 21, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

… see

The sweep ran unbounded whenever no config.yaml deployment was present,
deleting the day's sentinel rows for deployments absent from the run's own
view. A written charge records capacity that was reserved, so the only rows
a run may retract are the ones it can reassess: a deployment it scanned and
then declined to charge, because the window closed or the PTU config was
removed. It is now always bounded to the ids it scanned
@yucheng-berri
yucheng-berri force-pushed the litellm_ptu_prune_always_bounded branch from 450004c to 58fb068 Compare August 21, 2026 08:11
@yucheng-berri

Copy link
Copy Markdown
Contributor Author

@greptileai re-review 58fb068. Tightened the docstring that restated setup; the others state why the behaviour is right, matching this file.

@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 58fb068. Configure here.

yucheng-berri added a commit that referenced this pull request Aug 21, 2026
@codspeed-hq

codspeed-hq Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing litellm_ptu_prune_always_bounded (58fb068) with litellm_internal_staging (ff02d5c)

Open in CodSpeed

@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.

Real billing-integrity fix, and the direction of the fix matters here. The old logic treated a "database-only" run (no config.yaml records involved) as license to sweep unbounded — delete any stale sentinel row regardless of whether this run could actually see that deployment. That's backwards for billing: a deployment retiring or a router failing to register it isn't evidence the reservation never happened, it just means this run can't reassess it. The fix always bounds the prune to scanned_ids, so a charge is only retracted when the run positively confirms the window closed or the config was removed — never just because the deployment vanished from view.

Good test coverage, including test_a_run_that_scanned_nothing_issues_no_delete_statements for the master-key-rotation model-table-wipe window (a real, previously-noted race in this area) — a run that sees zero deployments now correctly touches zero rows instead of wiping the day's charges. `test_a_charge_the_run_cannot_reassess_is_left_alone" directly flips the old test's assertion to prove the new conservative behavior. Small, focused diff, CI green. Approved.

@yucheng-berri
yucheng-berri merged commit 0a5fa4f into litellm_internal_staging Aug 21, 2026
76 checks passed
@yucheng-berri
yucheng-berri deleted the litellm_ptu_prune_always_bounded branch August 21, 2026 18:31
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