Skip to content

refactor(ptu): give the rollup a source-agnostic deployment record - #37501

Merged
yucheng-berri merged 1 commit into
litellm_internal_stagingfrom
litellm_lit5809_ptu_deployment_record
Aug 19, 2026
Merged

refactor(ptu): give the rollup a source-agnostic deployment record#37501
yucheng-berri merged 1 commit into
litellm_internal_stagingfrom
litellm_lit5809_ptu_deployment_record

Conversation

@yucheng-berri

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

Copy link
Copy Markdown
Contributor

TLDR

Problem this solves:

  • The rollup only reads deployments from the database
  • A config.yaml PTU deployment therefore never accrues flat cost
  • One malformed stored row takes down the whole nightly run

How it solves it:

  • Adds the deployment shape the parser reads, from either source
  • Leaves the parser byte-identical, so existing cases prove no behaviour change
  • Stops treating non-object JSON as a mapping

User Flow

This is the first of several changes behind config.yaml PTU support, so the config.yaml half is not reachable yet. What is reachable today is the second problem above

Before: an admin whose proxy holds one malformed deployment loses every team's PTU cost for the day

  1. The admin runs a proxy with LITELLM_ENABLE_PTU_COST_ATTRIBUTION=True and a team on provisioned throughput, alongside an unrelated deployment whose stored settings are malformed
  2. The nightly attribution job runs at 00:15 UTC
  3. It stops on the malformed deployment and writes nothing at all
  4. The admin opens https://litellm-domain/ui/?page=usage for that team and sees no reserved-capacity cost for the day, for every team, not just the affected one
  5. Nothing in the usage view says why, and the amount is not recovered on later days for that date unless the malformed deployment is found and repaired

After: the same proxy skips only the malformed deployment and bills everyone else

  1. The admin runs the same proxy with the same two deployments
  2. The nightly attribution job runs at 00:15 UTC
  3. It skips the malformed deployment and prices the rest
  4. https://litellm-domain/ui/?page=usage shows the team's reserved-capacity cost for the day at its expected amount
  5. The malformed deployment still accrues nothing, which is correct, and repairing it starts it accruing without touching any other team

Relevant issues

Linear ticket

Refs LIT-5809

The ticket is only closed once a config.yaml PTU deployment actually accrues flat cost, which needs the loader union and the pricing rules that follow this

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, no mocks. Two deployments: one on provisioned throughput at 100 units and $0.02 per unit per hour, and one whose stored settings decode to a list rather than an object. The only thing not exercised is the cron trigger itself, since the job has no on-demand entry point

Setup, once:

docker run -d --name lit5809-pg -e POSTGRES_PASSWORD=pg -e POSTGRES_DB=lit5809 -p 5809:5432 postgres:16
prisma db push --schema=./schema.prisma --skip-generate

psql -c "INSERT INTO \"LiteLLM_ProxyModelTable\"
  (model_id, model_name, litellm_params, model_info, created_by, updated_by) VALUES
  ('dep-good','gpt-4o-ptu','{\"model\":\"azure/gpt-4o\"}'::jsonb,
   '{\"team_id\":\"team-alpha\",\"ptu_count\":100,\"cost_per_ptu_per_hour\":0.02,
     \"ptu_effective_from\":\"2026-01-01T00:00:00Z\"}'::jsonb,'seed','seed'),
  ('dep-bad','legacy-row','{\"model\":\"azure/gpt-4o\"}'::jsonb,
   '\"[1, 2, 3]\"'::jsonb,'seed','seed');"

Each side runs the same three lines, from its own worktree so the loaded tree is unambiguous:

print(litellm.__file__)
await run_scheduled_ptu_rollup(prisma, pod_lock_manager=None, target_date=date(2026, 8, 18))
await prisma.db.litellm_dailyteamspend.find_many(where={"api_key": "__ptu_flat_cost__"})

Before (4bb3152)

  1. Run it against the seeded database
tree : /Users/yucheng/litellm-wt/lit5809a_base/litellm/__init__.py
fix  : False

rollup RAISED AttributeError: 'list' object has no attribute 'get'

sentinel rows written: 0
  1. dep-good is a perfectly valid reservation and it was not billed either, because the run never got that far

After (7097d55)

  1. Run it against the same database, unchanged
tree : /Users/yucheng/litellm-wt/lit5809a/litellm/__init__.py
fix  : True

rollup result: RollupResult(day=2026-08-18, models_processed=1, rows_written=1, rows_failed=0, lapsed=())

sentinel rows written: 1
  team=team-alpha date=2026-08-18 model=dep-good group=gpt-4o-ptu flat_cost=48.0
  1. Read the row back out of Postgres
  team_id   |    date    |  model   | model_group |      api_key      | ptu_flat_cost
------------+------------+----------+-------------+-------------------+---------------
 team-alpha | 2026-08-18 | dep-good | gpt-4o-ptu  | __ptu_flat_cost__ |            48
  1. 100 units at $0.02 per hour over a full day is $48, and the malformed deployment is simply absent rather than fatal

Two notes on what is and is not claimed here. _parse_ptu_model is byte-identical to the merge base, verified by diffing the function body, so the 102 pre-existing cases in the mapped test file run unmodified and are the evidence that the parse path is unchanged; the test file diff is 177 insertions and 0 deletions. The behaviour that does change on the stored-deployment path is _decode_model_info returning None for valid JSON that is not an object, where it previously handed the decoded list or scalar back as a mapping

A stored deployment whose settings hold a JSON array directly, rather than a JSON string containing one, already skipped cleanly before this change, since the database driver hands back a native list that never reaches the string branch. The reachable failure is the string form shown above

Type

🧹 Refactoring

🐛 Bug Fix

Caveats (if any)

  • The new factory has no caller yet; the loader union follows
  • Deployment identity for config.yaml is unresolved, and blocks pricing them

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 7097d55. Configure here.

The flat-cost rollup reads deployments only from LiteLLM_ProxyModelTable, so a PTU
deployment declared in config.yaml never accrues flat cost. Those deployments live in
llm_router.model_list as plain dicts whose id sits in model_info rather than on the entry,
so they do not satisfy the shape _parse_ptu_model reads.

Adds a frozen record in that shape and a factory that maps a router entry onto it, leaving
_parse_ptu_model byte-identical so the existing cases stand as evidence of no behaviour
change. Nothing calls the factory yet; the caller lands with the loader union.

_decode_model_info also stops handing back valid JSON that is not an object. It decoded
a list or a scalar and returned it as a mapping, so the caller read fields off it and
raised, losing the whole run rather than the one bad deployment.

@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 found 1 potential issue.

View 1 additional finding in Devin Review.

Open in Devin Review

Comment on lines +130 to +161
@dataclass(frozen=True, slots=True)
class _PTUDeployment:
"""A deployment in the shape ``_parse_ptu_model`` reads, whatever declared it.

A ``LiteLLM_ProxyModelTable`` row already has it. A router entry does not: its id
lives in ``model_info.id`` rather than on the entry itself.
"""

model_id: str
model_name: str
model_info: Mapping[str, object]


def _router_deployment(deployment: Mapping[str, object]) -> _PTUDeployment | None:
"""A router ``model_list`` entry in the shape the parser reads, else None.

An id is required rather than defaulted because it keys the sentinel row: every
deployment without one would collapse onto a single row per team and only the last
would be billed. The mapping is copied because the router rewrites entries in place
while the rollup runs.
"""
model_info: Final = _decode_model_info(deployment.get("model_info"))
if model_info is None:
return None
model_id: Final = model_info.get("id")
if not isinstance(model_id, str) or not model_id:
return None
return _PTUDeployment(
model_id=model_id,
model_name=str(deployment.get("model_name") or ""),
model_info=MappingProxyType(dict(model_info)),
)

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.

🟡 New deployment record helper is never used anywhere in the product

A new deployment record type and its builder are added (_router_deployment at litellm/proxy/spend_tracking/ptu_flat_cost_rollup.py:143) with no code anywhere calling them, so the product gains code that never runs while the repository's guidelines ask for the minimum, non-speculative code that solves the problem.
Impact: No user-visible behavior change, but unused machinery ships and only tests exercise it, which conflicts with the repo's simplicity rule.

Dead code confirmed by a repo-wide search for callers

_PTUDeployment and _router_deployment are referenced only by their own definition and by tests/test_litellm/proxy/spend_tracking/test_ptu_flat_cost_rollup.py. The rollup itself still loads deployments only from LiteLLM_ProxyModelTable, so the router/config.yaml path this shape exists for is unreachable (the author notes the loader union follows in a later change). CLAUDE.md's "Simplicity First" section states "Nothing speculative", "No features beyond what was asked" and "No abstractions for single-use code".

Prompt for agents
The new _PTUDeployment dataclass and _router_deployment factory in litellm/proxy/spend_tracking/ptu_flat_cost_rollup.py have no production caller; only tests reference them. CLAUDE.md asks for non-speculative, minimum code. Consider landing this shape together with the loader change that actually feeds router/config.yaml deployments into the rollup, so the abstraction arrives with its consumer, or narrow the PR to only the _decode_model_info hardening that is reachable today.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@greptile-apps

greptile-apps Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR makes PTU deployment metadata source-agnostic while hardening the existing database rollup against JSON values that are not objects.

  • Adds an immutable deployment record and conversion factory for future config-based deployments.
  • Rejects malformed non-object model metadata without terminating the nightly rollup.
  • Adds parity, validation, immutability, and malformed-input tests.

Confidence Score: 5/5

The PR appears safe to merge, with no actionable failures found in the currently reachable rollup path.

The changed decoder now isolates malformed non-object metadata instead of allowing it to abort the nightly job, while the source-agnostic factory is not yet production-reachable and its expected behavior is covered by focused tests.

Important Files Changed

Filename Overview
litellm/proxy/spend_tracking/ptu_flat_cost_rollup.py Adds the source-agnostic deployment record and safely rejects model metadata that cannot support mapping-based parsing; no actionable regression was found on the currently reachable database path.
tests/test_litellm/proxy/spend_tracking/test_ptu_flat_cost_rollup.py Adds focused coverage for database/router parsing parity, malformed metadata, deployment identity, naming, and copied immutable state without weakening existing assertions.

Reviews (1): Last reviewed commit: "refactor(ptu): give the rollup a source-..." | Re-trigger Greptile

@codecov

codecov Bot commented Aug 19, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@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 7097d55. Configure here.

@codspeed-hq

codspeed-hq Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing litellm_lit5809_ptu_deployment_record (7097d55) with litellm_internal_staging (4d100bd)1

Open in CodSpeed

Footnotes

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

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

LGTM — small diff but billing-adjacent (PTU flat-cost rollup), so reviewed carefully.

  • The real fix is in _decode_model_info: previously a JSON string decoding to a non-dict (e.g. "[1, 2, 3]") was handed back as-is and treated like a mapping by callers, causing an AttributeError that crashed the entire nightly rollup on one malformed deployment — losing every team's PTU cost for the day, not just the bad row's. Now it correctly returns None for non-object JSON, so only the malformed deployment is skipped. Demonstrated with a real Postgres repro (not mocked) showing the before/after: crash-with-zero-rows vs. skip-and-bill-correctly.
  • _parse_ptu_model itself is untouched in this diff (doesn't appear at all), consistent with the "byte-identical, 102 existing cases prove no behavior change" claim — verified by its absence from the diff rather than just taking the claim at face value.
  • The new _router_deployment factory (for a future config.yaml PTU path) is well-tested — including a defensive copy into an immutable MappingProxyType verified to not alias the router's live, in-place-mutated model_info dict, and parity tests proving a DB-sourced and router-sourced deployment parse to an identical PTUModel. It's honestly caveated as having no caller yet, so this part is inert dead code until the follow-up PR wires it in — not yet a production behavior change.
  • Scope confined to the rollup module and its test file. CI green.

@yucheng-berri
yucheng-berri merged commit a1afc2f into litellm_internal_staging Aug 19, 2026
74 of 75 checks passed
@yucheng-berri
yucheng-berri deleted the litellm_lit5809_ptu_deployment_record branch August 19, 2026 20:51
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