Skip to content

fix(ptu): require an operator-declared id on a config.yaml reservation - #37794

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

fix(ptu): require an operator-declared id on a config.yaml reservation#37794
yucheng-berri merged 1 commit into
litellm_internal_stagingfrom
litellm_ptu_config_id_required

Conversation

@yucheng-berri

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

Copy link
Copy Markdown
Contributor

TLDR

Problem this solves:

  • A config.yaml reservation is keyed by a hash of its params
  • Rotating a credential mints a second identity for it
  • The catch-up then bills the whole window again

How it solves it:

  • PTU config in config.yaml now requires model_info.id
  • Two deployments cannot share one id
  • The id is read before the router mints one

User Flow

Before: an admin rotates an Azure key on a schedule and the next morning the team is billed twice for one reservation

  1. The admin runs a proxy with LITELLM_ENABLE_PTU_COST_ATTRIBUTION=True and a provisioned-throughput deployment declared in config.yaml with api_key: os.environ/AZURE_API_KEY
  2. They rotate that secret in their secret store and restart, without editing config.yaml
  3. The nightly job runs at 00:15 UTC, finds no history under what it now treats as a new deployment, and prices every elapsed day of the reservation again
  4. https://litellm-domain/ui/usage under Team Usage shows the team's reserved-capacity cost doubled across the whole window, up to 91 days
  5. The earlier charges are never swept, so the doubling is permanent and no rerun corrects it

After: the same rotation changes nothing about the bill

  1. The admin declares the same deployment with an id they choose, for example id: azure-ptu-eastus, under model_info
  2. They rotate the same secret and restart
  3. The nightly job prices the reservation once, against the id the admin declared
  4. https://litellm-domain/ui/usage shows the same total before and after the rotation
  5. An admin who leaves id out is told so at startup, and that one deployment does not load while everything else in the file serves normally

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, a real proxy, and a real Gemini call. Gemini stands in for Azure because provisioned capacity cannot be bought here; the fields the feature reads are provider agnostic and what is under test is the cost path.

model_list:
  - model_name: azure-ptu
    litellm_params:
      model: gemini/gemini-2.5-flash
      api_key: os.environ/GEMINI_API_KEY
    model_info:
      team_id: ptu-demo-team
      ptu_count: 10
      cost_per_ptu_per_hour: 1.00
      ptu_effective_from: "2026-08-17T00:00:00Z"
  - model_name: plain-sibling
    litellm_params:
      model: gemini/gemini-2.5-flash
      api_key: os.environ/GEMINI_API_KEY
export LITELLM_ENABLE_PTU_COST_ATTRIBUTION=true
python -m litellm.proxy.proxy_cli --config d2.yaml --port <port>

Before (ff02d5c)

a reservation declared with no id

  1. Start the proxy and list what registered
curl -s /v2/model/info -H "Authorization: Bearer $MASTER_KEY" | jq -r '.data[].model_name'
registered: ['azure-ptu', 'plain-sibling']

The reservation loads, and its identity is a hash of its resolved litellm_params

the credential is rotated

  1. Price the window, rotate api_key, price it again
before rotation: 1 identity, $1200  ['b43542621c3c56']
after rotation : 2 identity, $2400  ['11fd23bbfbe5b1', 'b43542621c3c56']

One reservation, two identities, and the total doubles

After (5cf37a1)

a reservation declared with no id

  1. Start the proxy and list what registered
curl -s /v2/model/info -H "Authorization: Bearer $MASTER_KEY" | jq -r '.data[].model_name'
registered: ['plain-sibling']
  1. Read the startup log
PTU configuration on model 'azure-ptu' is invalid: model_info.id is required when PTU
fields are set. Without one the deployment is identified by a hash of its litellm_params

The rest of the file is unaffected, and plain-sibling still serves

the credential is rotated

  1. Declare id: azure-ptu-eastus, then price the window, rotate api_key, price it again
before rotation: 1 identity, $1200  ['azure-ptu-east']
after rotation : 1 identity, $1200  ['azure-ptu-east']
  1. Send a real request through the reservation and read the spend row
curl -s /v1/chat/completions -H "Authorization: Bearer $TEAM_KEY" \
  -d '{"model":"azure-ptu","messages":[{"role":"user","content":"Reply with exactly: hello there"}],"max_tokens":200}'
reply : hello there
tokens: 7 / 45

          model          | spend | prompt_tokens | completion_tokens
-------------------------+-------+---------------+-------------------
 gemini/gemini-2.5-flash |     0 |             7 |                45
  1. Team Usage at https://litellm-domain/ui/usage after the rotation, still one reservation

Team Usage

Type

🐛 Bug Fix

Changes

generate_model_id hashes the model group together with every resolved litellm_params value, and set_model_list mints that id only when model_info.id is absent. os.environ/ references are resolved before the hash, so rotating a secret changes a deployment's identity without the config file changing at all. Flat cost is keyed by that id, and a written charge is not retracted, so the second identity's charges land beside the first permanently.

ptu_identity_error in litellm_core_utils.ptu_pricing states the rule, beside the pricing rules the write endpoints and config registration already share. Registration reads what the operator declared before the id is minted, which is why declared_id is threaded in rather than read back off model_info, where it would always be present and the rule would never fire.

A reservation whose window bound was written unquoted, as ptu_effective_to: 2027-01-01, was loaded as a date rather than a string and failed to parse, which took the deployment out of PTU handling altogether: no id rule, no zeroing, and no flat cost, while the provider still invoiced the reservation hourly. That bound is now read as the day's opening midnight, so the shape is recognised. An id of 0 is likewise a declared id, where reading it by truthiness had refused it and had also skipped it in the duplicate scan.

The rule is scoped to config-declared deployments carrying PTU fields, while the feature is enabled. Database-backed deployments already hold a stable primary key, so POST /model/new, PATCH /model/{model_id}/update and the dashboard's Add Model flow are untouched, as is the per-request credential clone.

Caveats (if any)

  • An existing id-less PTU deployment stops loading on upgrade
  • Duplicate ids are rejected only within one config file
  • An unquoted yaml date bound now counts, where it was silently ignored
  • A complexity-router entry is stamped with an id before this rule sees it

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

Note

Medium Risk
Changes how PTU flat-cost identity is assigned and billed. Existing id-less config.yaml reservations will fail to load when the feature is enabled, which is the intended fix but is a breaking config change.

Overview
Config-declared PTU reservations must now carry an operator-owned model_info.id, so rotating a credential no longer hashes a new identity and double-bills the reservation window.

When PTU cost attribution is on, router registration refuses a config.yaml PTU deployment with no id, or with an id shared by another deployment in the same file. The check uses the declared id before the router mints a hash. DB-backed model endpoints are unchanged; they already have a stable primary key.

Unquoted YAML dates like 2027-01-01 are parsed as that day’s UTC midnight instead of dropping the deployment out of PTU handling. An id of 0 is treated as a real id.

Reviewed by Cursor Bugbot for commit 5cf37a1. Bugbot is set up for automated code reviews on this repo. Configure here.

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[bot]

This comment was marked as resolved.

@greptile-apps

greptile-apps Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR gives config-declared PTU reservations stable operator-provided identities so credential rotation does not create duplicate billing identities

  • Requires a unique model_info.id for enabled config-based PTU reservations
  • Recognizes YAML date values as UTC midnight reservation bounds
  • Preserves database-backed deployment behavior and adds identity-validation coverage

Confidence Score: 5/5

The PR appears safe to merge

No blocking failure remains

Important Files Changed

Filename Overview
litellm/litellm_core_utils/ptu_pricing.py Adds PTU identity validation and support for YAML date objects in reservation bounds
litellm/router.py Captures operator-declared IDs before generated-ID assignment and rejects missing or duplicate PTU identities during registration
litellm/proxy/management_endpoints/model_management_endpoints.py Clarifies that database-backed deployments retain their stable primary-key identity behavior
tests/test_litellm/litellm_core_utils/test_ptu_pricing.py Covers identity requirements, duplicate detection, zero-like IDs, and YAML date parsing
tests/test_litellm/test_router_model_cost_isolation.py Exercises config registration behavior for declared, missing, duplicate, and generated PTU identities

Reviews (6): Last reviewed commit: "fix(ptu): require an operator-declared i..." | Re-trigger Greptile

greptile-apps[bot]

This comment was marked as resolved.

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

cursor[bot]

This comment was marked as resolved.

@yucheng-berri
yucheng-berri force-pushed the litellm_ptu_config_id_required branch from a396ad3 to 8cc9368 Compare August 21, 2026 08:11
@yucheng-berri

Copy link
Copy Markdown
Contributor Author

@greptileai re-review 8cc9368. Fixed the numeric-id duplicate bypass Devin found, with a regression test. SDK point answered inline.

@yucheng-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@yucheng-berri

Copy link
Copy Markdown
Contributor Author

@greptileai the SDK path already zeroed pricing to 0.0 on base with nothing to charge it, so that config served free. Evidence inline.

@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 8cc9368. Configure here.

@greptile-apps

greptile-apps Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

That's correct — zeroed_ptu_pricing explicitly returns None (no zeroing) when the feature flag is off, and the docstring even calls out the SDK case directly:

Without the flag no flat cost accrues, so zeroing would leave the deployment serving for free with nothing charged in its place, which is what an SDK user who happens to carry ptu_count would otherwise get.

So the 0 spend in the screenshot is the expected zeroed-per-token pricing for a PTU reservation when the flat cost is charged out-of-band — not a gap. Acknowledged, and the logic is consistent.

@yucheng-berri

Copy link
Copy Markdown
Contributor Author

@greptileai you acknowledged the SDK concern and all three threads are resolved. Please re-score the current head 8cc9368.

@yucheng-berri
yucheng-berri force-pushed the litellm_ptu_config_id_required branch from 8cc9368 to 8beb446 Compare August 21, 2026 08:23
@yucheng-berri

Copy link
Copy Markdown
Contributor Author

@greptileai re-review 8beb446. Only change is adding match= to three pytest.raises calls, which the test-tree lint requires.

@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 8beb446. 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_config_id_required (5cf37a1) with litellm_internal_staging (ff02d5c)

Open in CodSpeed

A config deployment is otherwise keyed by a hash of its resolved
litellm_params, so rotating a credential or editing an endpoint mints a
second identity and the catch-up bills the reservation again under it. Flat
cost is keyed by that id and a written charge is never retracted, so the
duplicate is permanent.

The id is read before set_model_list mints one, or the rule would inspect
the value it is meant to reject. Duplicates are counted once per config
entry across the whole file, so the check is order-independent and an
organization fan-out cannot collide with itself. The refusal names the id
the deployment already uses, since inventing a fresh one starts exactly the
second identity this prevents
@yucheng-berri
yucheng-berri force-pushed the litellm_ptu_config_id_required branch from 8beb446 to 5cf37a1 Compare August 21, 2026 09:46
@yucheng-berri

Copy link
Copy Markdown
Contributor Author

@greptileai re-review 5cf37a1. Gauntlet found an unquoted yaml date bound skipping PTU entirely, and id 0 read as absent. Both fixed.

@yucheng-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

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

@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 5cf37a1. Configure here.

@yucheng-berri

Copy link
Copy Markdown
Contributor Author

@greptileai review latest head

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

Companion to #37793, and just as important — this closes a permanent double-billing bug: a config.yaml PTU deployment is otherwise keyed by a hash of its resolved litellm_params, so routine credential rotation (a standard security practice) mints a second identity and the nightly catch-up re-prices the entire reservation window (up to 91 days) under it, with no rerun able to correct it since written charges are never retracted.

The fix requires an operator-declared model_info.id on any config.yaml deployment with actual PTU terms set — correctly scoped via ptu_terms(_model_info) is not None, so it doesn't touch the vast majority of ordinary config.yaml deployments, and doesn't fire at all with the feature flag off. Duplicate-id detection runs once per set_model_list() across all entries, comparing as strings so a YAML-unquoted numeric id still collides correctly with its string form.

Two real edge-case bugs caught and fixed in the same PR: a falsy id like "0" was being treated as absent by the duplicate scanner (now explicitly tested — test_a_falsy_id_is_still_scanned_for_collisions), and an unquoted ptu_effective_to: 2027-01-01 in YAML parses as a Python date rather than str/datetime, which previously discarded the reservation from PTU handling entirely (billing per-token with zero flat cost) — now handled by matching date before falling through to string parsing. Error message is good UX for a breaking change too: points the operator at GET /model/info to find the id their existing charges are already keyed under. Extensive test coverage, CI green. Approved.

@yucheng-berri
yucheng-berri merged commit 8122cfc into litellm_internal_staging Aug 21, 2026
76 checks passed
@yucheng-berri
yucheng-berri deleted the litellm_ptu_config_id_required branch August 21, 2026 18:20
yucheng-berri added a commit to BerriAI/litellm-docs that referenced this pull request Aug 21, 2026
BerriAI/litellm#37794 refuses a config-declared PTU deployment that does
not carry an id, so the page can no longer present pinning one as advice.
Adds the upgrade note, since setting a fresh name rather than the id the
deployment already uses strands the charges already written
yucheng-berri added a commit to BerriAI/litellm-docs that referenced this pull request Aug 21, 2026
…PTU page (#968)

* docs(proxy): drop the status code from the PTU rejection note

Some invalid PTU configurations answer 422 rather than 400, so naming a
single code on the page is misleading. The behaviour that matters is that
the rejection names the offending field.

* docs(proxy): model_info.id is now required for a config.yaml reservation

BerriAI/litellm#37794 refuses a config-declared PTU deployment that does
not carry an id, so the page can no longer present pinning one as advice.
Adds the upgrade note, since setting a fresh name rather than the id the
deployment already uses strands the charges already written
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