Skip to content

feat(ptu): add PTU reservation table and admin CRUD endpoints - #33130

Closed
yucheng-berri wants to merge 4 commits into
litellm_internal_stagingfrom
litellm_lit1697_stage1_ptu_reservations
Closed

feat(ptu): add PTU reservation table and admin CRUD endpoints#33130
yucheng-berri wants to merge 4 commits into
litellm_internal_stagingfrom
litellm_lit1697_stage1_ptu_reservations

Conversation

@yucheng-berri

@yucheng-berri yucheng-berri commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Relevant issues

Linear ticket

Resolves LIT-1697 (stage 1 of 5)

Pre-Submission checklist

  • 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

Screenshots / Proof of Fix

Feature gate is off by default so the endpoints 403 out of the box. Enable in general_settings, then

$ MASTER_KEY=sk-1234
$ curl -s -X POST http://localhost:4000/config/general_settings \
    -H "Authorization: Bearer $MASTER_KEY" -H "Content-Type: application/json" \
    -d '{"enable_ptu_cost_attribution": true}' | jq .

# non-admin key sees 403
$ curl -s -X POST http://localhost:4000/ptu_reservation/new \
    -H "Authorization: Bearer $INTERNAL_USER_KEY" -H "Content-Type: application/json" \
    -d '{"team_id":"team_x","model":"gpt-4","ptu_count":1,"cost_per_ptu":200,"effective_from":"2026-08-01T00:00:00Z"}'
{"detail":{"error":"..., your role=internal_user"}}

# admin creates
$ curl -s -X POST http://localhost:4000/ptu_reservation/new \
    -H "Authorization: Bearer $MASTER_KEY" -H "Content-Type: application/json" \
    -d '{"team_id":"team_x","model":"gpt-4","ptu_count":1,"cost_per_ptu":200,"effective_from":"2026-08-01T00:00:00Z"}' | jq '.id, .team_id, .ptu_count'
"3f2a...."
"team_x"
1

# overlap for same (team, model) is rejected
$ curl -s -X POST http://localhost:4000/ptu_reservation/new \
    -H "Authorization: Bearer $MASTER_KEY" -H "Content-Type: application/json" \
    -d '{"team_id":"team_x","model":"gpt-4","ptu_count":10,"cost_per_ptu":200,"effective_from":"2026-08-15T00:00:00Z"}' | jq .
{"detail":{"error":"reservation overlaps existing active reservation(s) for the same (team, model)","overlapping_ids":["3f2a...."]}}

# close, then create a follow-up reservation at same team/model with new terms
$ curl -s -X POST http://localhost:4000/ptu_reservation/close \
    -H "Authorization: Bearer $MASTER_KEY" -H "Content-Type: application/json" \
    -d '{"id":"3f2a....","effective_to":"2026-08-15T00:00:00Z"}' | jq '.id, .effective_to'
"3f2a...."
"2026-08-15T00:00:00+00:00"

$ curl -s -X POST http://localhost:4000/ptu_reservation/new \
    -H "Authorization: Bearer $MASTER_KEY" -H "Content-Type: application/json" \
    -d '{"team_id":"team_x","model":"gpt-4","ptu_count":10,"cost_per_ptu":200,"effective_from":"2026-08-15T00:00:00Z"}' | jq '.id, .ptu_count'
"...new-uuid..."
10

# azure_billing mode is rejected in this release
$ curl -s -X POST http://localhost:4000/ptu_reservation/new \
    -H "Authorization: Bearer $MASTER_KEY" -H "Content-Type: application/json" \
    -d '{"team_id":"team_x","model":"gpt-4","cost_source":"azure_billing","azure_resource_id":"/subscriptions/x/deployments/gpt-4-ptu","effective_from":"2026-08-01T00:00:00Z"}'
{"detail":{"error":"cost_source='azure_billing' is not supported in this release"}}

Type

New Feature

Changes

Adds admin-registered PTU (Provisioned Throughput Unit) reservations as a storage and CRUD-only foundation. A reservation records that a specific team owns N PTUs of a specific model at $X/PTU/month over a time window [effective_from, effective_to). Later stages will read these rows and attribute prorated flat cost to LiteLLM_DailyTeamSpend and the Usage page; this PR intentionally does none of that.

Feature-gated by enable_ptu_cost_attribution in general_settings, default off. Every endpoint returns 403 when the flag is off; when the flag is on, every endpoint requires PROXY_ADMIN.

Endpoints:

  • POST /ptu_reservation/new -> create a reservation. Overlapping active reservations for the same (team, model) are rejected with 409
  • GET /ptu_reservation/list -> optional team_id, model, active_only filters
  • GET /ptu_reservation/info?id=X -> single row
  • POST /ptu_reservation/close -> set effective_to; refuses to close an already-closed reservation or one whose new effective_to is at or before effective_from. Never deletes rows

Editing a reservation is modeled as closing the existing row and creating a new one. Historical rows stay queryable for audit.

The schema carries cost_source and azure_resource_id columns so a future automated Azure Cost Management integration can land without a schema migration; cost_source="azure_billing" requests are rejected in this release.

Deviation from the admin-entity pattern

Every other admin CRUD entity in the codebase ships a /<entity>/update route (budget, team, tag, organization, model, customer). This PR intentionally does not. Every cost-affecting field on a reservation (team_id, model, ptu_count, cost_per_ptu, cost_source, azure_resource_id, effective_from) is history-load-bearing: mutating it in place would retroactively rewrite the flat cost the daily rollup (stage 2) has already attributed for past days, breaking the audit trail. The correct edit flow is /close the old row and /new a fresh one; the resulting two-row history is the feature, not the tradeoff. effective_to is already mutable through /close. updated_by / updated_at are server-managed. That leaves nothing meaningful for /update to touch, so shipping one would either always 400 or be a rename of /close.

Behavior changes

None. This change is strictly additive.

  • No changes to the per-request spend hot path: _PROXY_track_cost_callback, db_spend_update_writer.update_database, _batch_database_updates, _commit_spend_updates_to_db, common_daily_activity.py, LiteLLM_TeamTable.spend, budget enforcement are all untouched
  • No new writes to LiteLLM_DailyTeamSpend
  • No Usage page changes, no CSV export changes, no UI changes
  • Feature is off by default; enabling it only allows admins to create reservation rows, which have no read-side effect yet
  • No migration for existing customers required

Boilerplate & extensibility note

The new files closely mirror the shape of LiteLLM_BudgetTable and its friends (model in litellm/models/, repository in litellm/repositories/, endpoints in litellm/proxy/management_endpoints/, request types under litellm/types/proxy/management_endpoints/). Every new admin-owned CRUD entity in this codebase reproduces roughly the same scaffold (endpoint auth/flag/prisma/repo pattern, near-identical repository properties, form-shaped pydantic requests). Extracting a generic admin-entity router and repository base would remove several hundred lines per entity but touches Budget, Team, Organization, and Access Group at once, so it belongs in a separate refactor rather than inside a feature PR. Filing follow-up.

Files changed

  • schema.prisma: new LiteLLM_PTUReservation model with three indexes
  • litellm-proxy-extras/litellm_proxy_extras/migrations/20260713000000_add_litellm_ptu_reservation_table/migration.sql: DDL
  • litellm/models/ptu_reservation.py: LiteLLM_PTUReservation + LiteLLM_PTUReservationFull with pydantic validator enforcing manual/azure_billing invariants and effective_to > effective_from
  • litellm/repositories/ptu_reservation_repository.py: PTUReservationRepository with find_active and find_overlapping (half-open interval semantics)
  • litellm/proxy/management_endpoints/ptu_reservation_endpoints.py: four endpoints, feature-flag + admin-role gates
  • litellm/types/proxy/management_endpoints/ptu_reservation.py: request/response types
  • litellm/proxy/_types.py: routes added to management_routes allowlist
  • litellm/proxy/proxy_server.py: router import + app.include_router
  • tests/test_litellm/proxy/management_endpoints/test_ptu_reservation_endpoints.py: 28 tests

Tests

28 tests. Suite covers:

  • Valid create for cost_source="manual"
  • Validator rejects manual without ptu_count, without cost_per_ptu, with non-positive ptu_count, with negative cost_per_ptu
  • Validator rejects effective_to <= effective_from (both strictly-less and equal)
  • Endpoint rejects cost_source="azure_billing" in this release
  • Overlap detection returns 409 with overlapping_ids
  • Non-overlapping same (team, model) windows accepted
  • Feature-flag-off returns 403 on new, list, info, close
  • Non-admin role returns 403 on new, list, close
  • List with no filters, with team_id + model, with active_only
  • Info returns 404 for missing id, returns row when present
  • Close sets effective_to (default: now UTC; explicit override respected)
  • Close refuses already-closed reservation
  • Close refuses effective_to <= effective_from
  • Close returns 404 for missing id
  • Create allowed after closing an existing reservation for same (team, model)
$ .venv/bin/python -m pytest tests/test_litellm/proxy/management_endpoints/test_ptu_reservation_endpoints.py -q
28 passed

Note

Medium Risk
Adds a DB migration and new admin-only APIs behind a feature flag; spend attribution is not wired yet, so runtime LLM cost behavior is unchanged when the flag is off.

Overview
Introduces admin-managed PTU reservations—time-bounded (team, model) prepaid capacity with manual ptu_count and cost_per_ptu—as storage and API only (stage 1; no spend rollup or usage UI yet).

Adds LiteLLM_PTUReservation (Prisma + migration), Pydantic models with manual vs azure_billing field rules, and a repository with find_active / find_overlapping half-open windows. Four endpoints (/new, /list, /info, /close) are wired on the proxy, listed in management routes and the UI backend allowlist (/ptu_reservation/), and documented in OpenAPI types. No /update—changes are modeled as close + new for auditability.

Access is gated by enable_ptu_cost_attribution (default off) and PROXY_ADMIN. Creating overlapping reservations for the same team/model returns 409; azure_billing is rejected for this release. 28 endpoint tests cover validation, overlap, auth, and the feature flag.

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

@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you all sign our Contributor License Agreement before we can accept your contribution.
1 out of 2 committers have signed the CLA.

✅ yucheng-berri
❌ github-actions[bot]
You have signed the CLA already but the status is still pending? Let us recheck it.

@greptile-apps

greptile-apps Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds a PTU (Provisioned Throughput Unit) reservation table and four admin-only CRUD endpoints as a storage foundation for a future flat-cost attribution feature. The feature is gated by enable_ptu_cost_attribution in general_settings (off by default) and every endpoint requires PROXY_ADMIN; this revision also ships the P1 fix from the prior review round — naive datetime inputs are now coerced to UTC at the Pydantic layer in both request types and the domain model.

  • New table and schema: LiteLLM_PTUReservation added to all three schema files with three indexes and a matching additive SQL migration.
  • Four endpoints (/new, /list, /info, /close) with overlap detection using correct half-open interval semantics and an explicit azure_billing rejection guard.
  • Datetime TZ fix: _coerce_utc field validators on PTUReservationNewRequest, PTUReservationCloseRequest, and LiteLLM_PTUReservation ensure naive inputs are treated as UTC, preventing the TypeError on the close_at <= row.effective_from comparison; three regression tests verify the fix.

Confidence Score: 5/5

Safe to merge — purely additive behind a default-off feature flag with no changes to the hot request path.

The change is strictly additive: new table, new repository, new endpoints, all gated by a default-off flag. The datetime timezone comparison issue flagged in the previous round has been properly fixed with idempotent UTC coercion at the Pydantic validation layer in both the request types and the domain model, and three targeted regression tests confirm the fix. No existing code paths are modified.

No files require special attention — all changed files are new additions or trivial router registrations.

Important Files Changed

Filename Overview
litellm/types/proxy/management_endpoints/ptu_reservation.py Request/response types with field validators that coerce naive datetimes to UTC at the Pydantic layer — the P1 fix from the prior review round.
litellm/models/ptu_reservation.py Domain model with UTC-coercion validator and business-rule enforcement (cost_source invariants, effective_to > effective_from). Clean.
litellm/proxy/management_endpoints/ptu_reservation_endpoints.py Four admin-only endpoints with feature-flag + role gates. Timezone comparison in close() is now safe after pydantic-layer UTC coercion fix.
litellm/repositories/ptu_reservation_repository.py Repository with find_active and find_overlapping using correct half-open interval semantics for datetime window overlap detection.
litellm-proxy-extras/litellm_proxy_extras/migrations/20260713000000_add_litellm_ptu_reservation_table/migration.sql Additive migration creating LiteLLM_PTUReservation with three correct indexes; uses TIMESTAMP(3) consistently with Prisma conventions.
tests/test_litellm/proxy/management_endpoints/test_ptu_reservation_endpoints.py 28 mock-only tests covering create, list, info, close, flag-gating, RBAC, overlap detection, and three new regression tests for the naive-datetime TZ fix.

Reviews (2): Last reviewed commit: "fix(ptu): coerce naive datetimes on rese..." | Re-trigger Greptile

Comment on lines +218 to +219
close_at = body.effective_to or datetime.now(timezone.utc)
if close_at <= row.effective_from:

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.

P1 Timezone mismatch in close guard

body.effective_to is parsed by Pydantic as a naive datetime when the caller omits timezone info (e.g., "2026-08-15T00:00:00" with no Z/+00:00). row.effective_from returned by Prisma is a UTC-aware datetime. Python raises TypeError: can't compare offset-naive and offset-aware datetimes on the <= comparison, turning a valid-looking close request into a 500. The same naive input is then written to effective_to in the DB, storing a timezone-stripped value. Normalising close_at to UTC before the comparison (and before the update) fixes both: replace close_at = body.effective_to or datetime.now(timezone.utc) with an explicit replace(tzinfo=timezone.utc) guard on naive inputs.

Comment on lines +105 to +127
repo = PTUReservationRepository(prisma_client)
overlapping = await repo.find_overlapping(
team_id=validated_reservation["team_id"],
model=validated_reservation["model"],
effective_from=validated_reservation["effective_from"],
effective_to=validated_reservation["effective_to"],
)
if overlapping:
raise HTTPException(
status_code=409,
detail={
"error": "reservation overlaps existing active reservation(s) for the same (team, model)",
"overlapping_ids": [r.id for r in overlapping],
},
)

actor = user_api_key_dict.user_id or "admin"
create_data = {
**{k: v for k, v in validated_reservation.items() if k != "id" and v is not None},
"created_by": actor,
"updated_by": actor,
}
return await repo.table.create(data=create_data)

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.

P2 TOCTOU race in overlap + create

find_overlapping and table.create are two separate round-trips to the DB with no transaction wrapping them. Two concurrent admin requests for the same (team, model) window will both pass the overlap check (since neither row exists yet) and both succeed, silently creating duplicate reservations. The fix requires either a serializable transaction around the check+insert pair or a unique partial index on (team_id, model) for open-ended rows. Low-risk given admin-only traffic, but worth addressing before the later stages start reading these rows.

Comment on lines +156 to +161
where: dict = {}
if team_id is not None:
where["team_id"] = team_id
if model is not None:
where["model"] = model
return await repo.table.find_many(where=where)

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.

P2 List endpoint has no pagination

repo.table.find_many(where=where) with no take limit returns every matching row in a single query. As historical reservations accumulate over many teams and models, this will return unbounded result sets. The BaseRepository.find_many already accepts take/skip parameters; threading a limit query param through here (defaulting to e.g. 1 000) would be consistent with how other list endpoints in the repo behave.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@codecov

codecov Bot commented Jul 13, 2026

Copy link
Copy Markdown

@codspeed-hq

codspeed-hq Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing litellm_lit1697_stage1_ptu_reservations (da4e195) with litellm_internal_staging (f1f33f5)

Open in CodSpeed

@yucheng-berri

Copy link
Copy Markdown
Contributor Author

@greptileai please re-review — pushed P1 fix for the datetime-tz mismatch. Both /new and /close now coerce naive datetimes to UTC at the pydantic layer (field validators on both the request types and the domain model); 3 regression tests added that fail without the fix.

@yucheng-berri
yucheng-berri removed the request for review from ryan-crabbe-berri July 14, 2026 19:46
@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.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Want reviews to match your repository better? Bugbot Learning can learn team-specific rules from PR activity. A team admin can enable Learning in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 2a4b5a1. Configure here.

)

close_at = body.effective_to or datetime.now(timezone.utc)
if close_at <= row.effective_from:

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.

Close compares naive and aware datetimes

High Severity

The close_ptu_reservation endpoint can return a 500 error due to a TypeError. This occurs because close_at (a timezone-aware datetime) is compared directly with row.effective_from, which is a naive datetime loaded from Prisma, preventing reservations from being closed.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 2a4b5a1. Configure here.

@yucheng-berri
yucheng-berri force-pushed the litellm_lit1697_stage1_ptu_reservations branch 2 times, most recently from 9e82ea4 to 5105676 Compare July 15, 2026 17:32
yucheng-berri and others added 4 commits July 15, 2026 13:23
Adds a new LiteLLM_PTUReservation table storing admin-registered PTU
reservations for a (team, model) pair over a time window, plus admin CRUD
endpoints (new, list, info, close). Feature-gated by
enable_ptu_cost_attribution in general_settings; default off.

Stage 1 storage + endpoints only. No spend behavior change: no daily
rollup job, no writes to LiteLLM_DailyTeamSpend, no impact on the
per-request cost tracking pipeline.

- schema.prisma + migration for LiteLLM_PTUReservation
- Pydantic domain models in litellm/models/ptu_reservation.py
- Repository in litellm/repositories/ptu_reservation_repository.py
- Endpoints in litellm/proxy/management_endpoints/ptu_reservation_endpoints.py
- Request/response types under litellm/types/proxy/management_endpoints/
- 28 unit tests covering validation, feature-flag gate, admin-only auth,
  overlap detection, close/create semantics
- Routes listed under management_routes in litellm/proxy/_types.py
- Router mounted in proxy_server.py alongside budget_management_router
Pydantic accepted naive datetimes on effective_from and effective_to;
comparing those against Prisma's UTC-aware row values raised TypeError,
turning /ptu_reservation/close into a 500 on inputs like
'2026-08-15T00:00:00'. Adds a field validator on both the request types
and the domain model that stamps missing tzinfo as UTC and converts
tz-aware values into UTC. Three regression tests pin the new contract
(create with naive input, close with naive input, close with naive
input before effective_from).
Three CI failures on the stage 1 branch:

1. schema_migration_check drifted: the LiteLLM_PTUReservation CREATE TABLE
   omitted DEFAULT CURRENT_TIMESTAMP on updated_at, so prisma migrate diff
   reported the column as needing an ALTER. Added the default to match
   every other table's convention.

2. proxy-infra test_gateway_plus_backend_covers_full_app: the four new
   /ptu_reservation/* routes weren't listed on either component's
   allowlist. Added /ptu_reservation/ as a backend prefix (admin API,
   never data-plane).

3. Verify schema.d.ts matches the proxy OpenAPI spec: adding endpoints
   changed the spec so the dashboard's generated types were stale.
   Regenerated with npm run gen:api.
@yucheng-berri
yucheng-berri force-pushed the litellm_lit1697_stage1_ptu_reservations branch from 5105676 to da4e195 Compare July 15, 2026 20:24
@yucheng-berri

Copy link
Copy Markdown
Contributor Author

Closing as superseded. This is part of the v1 PTU design, which stored PTU config in a separate reservation table. The shipped design puts that config on the model deployment instead, merged as #35341, #35343, #35391, #35393 and #36829.

This one has no successor: the reservation table was dropped entirely rather than reimplemented, so everything it added is obsolete.

The branch is kept, so nothing here is lost.

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