feat(ptu): accrue flat cost for PTU deployments declared in config.yaml - #37556
Conversation
The flat-cost rollup reads deployments from LiteLLM_ProxyModelTable, and config.yaml models never reach that table by design, so a PTU deployment declared there accrued no flat cost at all while still billing its traffic per token. The provider bills the reservation whichever file declared it. The rollup now also reads the deployments the router holds that no database row owns, identified by db_model, skipping the per-request credential clones that carry original_model_id and reuse their source's PTU config under a fresh id. Registering such a deployment zeroes its pricing, since reserved capacity already pays for the traffic it serves, and leaving a rate unset falls back to the public cost map, which makes the double charge the default rather than an opt-in. The rules both halves apply now live in one module. The rollup's test for what it will charge and the router's test for what to zero have to agree, or a deployment one accepts and the other declines serves its traffic for free. That module also owns the fields the write endpoints already zero, so the two paths cannot drift: tiered_pricing is emptied rather than zeroed because its tiers outrank the rates beside them, the search context table is written zeroed because an absent one means the provider default, and any further rate the deployment itself declares is zeroed alongside the standing set. The prune is bounded to the deployments a run scanned, but only for a run that priced a config-declared deployment. Deciding a row is garbage on staleness alone stays correct while every run derives its charges from the same table, so a database-only run sweeps exactly as it did before; once one host's charges come from a file the others cannot read, a row it never considered is not evidence of anything. Behaviour change worth calling out: a zeroed deployment sorts ahead of an unpriced sibling in QualityRouter's cost tiebreak, where an unset rate previously sorted last. Reserved capacity really is the cheaper choice, but the ordering moves.
Greptile SummaryThe PR adds flat-cost attribution for config-declared PTU deployments while zeroing their per-request pricing to avoid double billing.
Confidence Score: 5/5The PR appears safe to merge. No blocking failure remains.
|
| Filename | Overview |
|---|---|
| litellm/litellm_core_utils/ptu_pricing.py | Centralizes PTU eligibility, date validation, feature-flag handling, and zero-pricing behavior. |
| litellm/proxy/spend_tracking/ptu_flat_cost_rollup.py | Extends attribution to router-backed config deployments and scopes pruning to deployments observed by config-aware runs. |
| litellm/router.py | Applies zeroed pricing when eligible config-declared PTU deployments are registered. |
| litellm/proxy/management_endpoints/model_management_endpoints.py | Reuses centralized PTU pricing-field definitions in model-management validation. |
| tests/test_litellm/proxy/spend_tracking/test_ptu_flat_cost_rollup.py | Adds coverage for loading, charging, deduplicating, and reconciling config-declared PTU deployments. |
| tests/test_litellm/test_router_model_cost_isolation.py | Verifies PTU pricing overrides remain isolated between router deployments. |
Reviews (2): Last reviewed commit: "refactor(ptu): drop a Final rebind and t..." | Re-trigger Greptile
| try: | ||
| litellm_params: Final[LiteLLM_Params] = LiteLLM_Params(**_litellm_params) | ||
| zeroed_pricing: Final = ( | ||
| zeroed_ptu_pricing(_model_info, _litellm_params) if _model_info.get("db_model") is not True else None |
There was a problem hiding this comment.
Medium: Budget bypass outside the PTU effective window
This zeroes per-request pricing without checking whether the current time is within ptu_effective_from and ptu_effective_to, while _active_hours_on_day drops the flat charge outside that interval. An authenticated caller can keep invoking a routable config deployment before its start or after its end without increasing tracked spend, so budget enforcement never catches up. Keep the deployment unavailable outside the interval, or reevaluate and restore its normal pricing when the interval is inactive.
There was a problem hiding this comment.
Pre-existing: #36829 zeroes on carrying PTU config, not window state, so the database path already does this. Gating here alone would split the paths.
There was a problem hiding this comment.
Thanks for the detail. I can't automatically confirm this is safe to dismiss, so I'm leaving the thread open for a maintainer to make the call.
There was a problem hiding this comment.
#36829 exercised a closed window live and states it: "A closed window alerts; it does not resume per-token billing."
There was a problem hiding this comment.
Thanks for the detail. I can't automatically confirm this is safe to dismiss, so I'm leaving the thread open for a maintainer to make the call.
PR overviewThis PR adds flat-cost accrual for provisioned throughput unit (PTU) deployments declared in One security issue remains open: authenticated callers can use a routable PTU deployment outside its configured effective window without request costs being added to tracked spend. This allows budget enforcement to be bypassed for that deployment before the start time or after the end time; no issues have yet been addressed. Open issues (1)
Fixed/addressed: 0 · PR risk: 6/10 |
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
| if ptu_terms(model_info) is None: | ||
| return None | ||
| if not is_ptu_cost_attribution_enabled(): | ||
| return None |
There was a problem hiding this comment.
🟡 Reserved-capacity deployment serves traffic for free before its start date
A PTU deployment whose reservation start is in the future has its per-token pricing zeroed at registration (zeroed_ptu_pricing at litellm/litellm_core_utils/ptu_pricing.py:118-146) even though flat cost only begins accruing from that future start, so the team is charged nothing at all for traffic served in the gap between when the proxy boots and when the reservation begins.
Impact: A team can send requests through a not-yet-active reserved deployment and be billed neither per token nor for reserved capacity, silently underbilling until the start date arrives.
How the future-dated window escapes both charge paths
ptu_terms accepts a reservation with an effective_from in the future (litellm/litellm_core_utils/ptu_pricing.py:101-115 only rejects an inverted or unparseable window, not a future one), so zeroed_ptu_pricing returns the zeroed pricing map and the router registers the deployment priced at 0. Meanwhile the rollup's _active_hours_on_day (litellm/proxy/spend_tracking/ptu_flat_cost_rollup.py:213-221) clamps start to effective_from, so any day before the start yields 0 active hours and no flat charge. The result is that requests served before effective_from cost the team nothing on either path. This extends the pre-existing DB-path behavior to config.yaml deployments and is acknowledged as a caveat in the PR description.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Agreed, and as you note this is the database path's existing behaviour from #36829. Changing it here alone would split the two paths.
There was a problem hiding this comment.
Confirmed deliberate: #36829 demonstrated a closed window live and documented it as "A closed window alerts; it does not resume per-token billing."
The basedpyright budget rejected reassigning a Final in the datetime coercion and two isinstance calls the router entry's own type already guarantees. Filtering the built records rather than the raw entries removes both guards and leaves _router_deployment as the single validator.
|
@greptileai please review the current head c7ca8f4. Divergent replica configs break serving before billing; documented as a caveat, and unfixable without persisting config models. |
Live verification on staging: the customer's config.yaml PTU model now accrues flat costRan the reported repro end to end on A
Pricing zeroing holds and does not double bill: the same backend model reached through a non-PTU config entry billed One thing a user will still hitThe customer's snippet as written has no Separately, the Usage export mode "Day-by-day by team and model" has no Carried and unchanged on this head: some invalid PTU configs answer HTTP 422 rather than the documented 400; the aggregated GROUPING SETS path emits an all-zero SuitesThe PTU backend suites plus |
TLDR
Problem this solves:
How it solves it:
User Flow
Before: a team on reserved capacity is billed for its tokens and never for the capacity
config.yamlwithteam_id,ptu_count,cost_per_ptu_per_hourandptu_effective_from, then starts the proxy withLITELLM_ENABLE_PTU_COST_ATTRIBUTION=TrueAfter: the same team is billed for the capacity it reserved, and not again for the traffic that capacity serves
An admin who wants the deployment billed per token instead removes
ptu_countandcost_per_ptu_per_hour, exactly as on the API pathRelevant issues
Linear ticket
Resolves LIT-5809
Pre-Submission checklist
Please complete all items before asking a LiteLLM maintainer to review your PR
@greptileaito 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, real Gemini traffic, a proxy booted from the config below on each side, no mocks. Gemini stands in for Azure because PTU capacity cannot be provisioned here; the fields the feature reads are provider-agnostic, and what is being proven is the cost path. The only thing not exercised is the cron trigger itself, since the job has no on-demand entry point, so each side calls the same scheduled function directly against its own database.
Before (eecb226)
a real request through the config.yaml PTU deployment
the nightly attribution job
The tokens are billed at 4 x 5e-06 plus 21 x 1.5e-05, and the reservation the provider is invoicing hourly is recorded nowhere
After (286bbc9)
a real request through the config.yaml PTU deployment
the nightly attribution job
100 units at $0.02 an hour over a full day is $48, the same request records 4 and 21 real tokens at no per-token charge, and the deployment id is unchanged from the before run, so cooldowns, deployment budgets and existing spend rows all still key on it
Re-run at the current tip, exit code checked and the log grepped for a traceback, after an earlier capture turned out to be a partially completed run
Type
🆕 New Feature
Changes
The rollup reads the router's deployments alongside the table, keeping the ones no database row owns and dropping the per-request credential clones, which carry
original_model_idand would otherwise bill one reservation once per distinct caller key.Registering such a deployment zeroes its pricing. The rules for what accrues and what that zeroes live in
litellm/litellm_core_utils/ptu_pricing.py, which the write endpoints now import as well, because a deployment the rollup declines to charge and the router prices at zero would serve its traffic for free.tiered_pricingis emptied rather than zeroed, since its tiers outrank the rates beside them; the search context table is written zeroed because an absent one means the provider's default rather than free; and any further rate the deployment declares is zeroed alongside the standing set.The prune is bounded to the deployments a run scanned, but only for a run that priced a config-declared deployment, so a database-only proxy sweeps exactly as it does today.
The nightly catch-up shares that loader, so a config-declared reservation is priced for its elapsed days too. Verified live: a reservation started five days ago writes five days at $240, one started two hundred days ago writes 91 days at $4,368, and a second run of either writes nothing further.
QA runbook
Caveats (if any)
Final Attestation