feat(ptu): daily rollup job writes flat cost to LiteLLM_DailyTeamSpend - #33137
feat(ptu): daily rollup job writes flat cost to LiteLLM_DailyTeamSpend#33137yucheng-berri wants to merge 3 commits into
Conversation
|
|
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
Greptile SummaryThis PR implements stage 2 of the PTU reservation cost attribution feature: a nightly APScheduler cron job that writes prorated flat PTU costs into
Confidence Score: 5/5Safe to merge — additive schema changes with safe defaults, the rollup is off by default, and the previously broken backfill CLI is now correctly self-contained. The only previously blocking issue (backfill CLI always exiting with zero rows due to importing proxy module globals that are never populated when running standalone) is correctly fixed: the script creates its own PrismaClient from DATABASE_URL and uses force=True to bypass the flag check. Rollup logic, idempotency, fault isolation, schema migration, and test coverage are all solid. No files require special attention.
|
| Filename | Overview |
|---|---|
| litellm/proxy/spend_tracking/ptu_reservation_rollup.py | New rollup module: proration math, idempotent upsert, feature-flag re-check at runtime, force bypass for CLI — all logic is correct and well-isolated |
| scripts/ptu_reservation_backfill.py | Backfill CLI now correctly bootstraps its own PrismaClient from DATABASE_URL and passes force=True to bypass the feature-flag check; P1 from prior review is resolved |
| litellm/proxy/proxy_server.py | Cron job registered at 00:15 UTC behind the enable_ptu_cost_attribution flag, following the same pattern as existing scheduled jobs; prisma_client captured at startup time matches the established convention |
| litellm-proxy-extras/litellm_proxy_extras/migrations/20260713010000_add_ptu_columns_to_daily_team_spend/migration.sql | Additive migration using ADD COLUMN IF NOT EXISTS and CREATE INDEX IF NOT EXISTS — fully safe for rolling deploys and idempotent re-runs |
| tests/test_litellm/proxy/spend_tracking/test_ptu_reservation_rollup.py | 19 pure-mock tests covering proration math, flag bypass, force=True path, idempotency, per-reservation fault isolation, and sentinel payload invariants |
| tests/test_scripts/test_ptu_reservation_backfill.py | 8 new tests covering date parsing, range validation, DATABASE_URL guard, force=True forwarding, connect/disconnect lifecycle — all properly mocked, no real network calls |
| schema.prisma | Adds ptu_flat_cost (Float default 0.0) and ptu_reservation_id (String?) to LiteLLM_DailyTeamSpend plus a new index; both columns are default-safe and additive |
Reviews (2): Last reviewed commit: "fix(ptu): backfill CLI bootstraps its ow..." | Re-trigger Greptile
|
|
||
|
|
There was a problem hiding this comment.
Sentinel constant should live in
constants.py
PTU_SENTINEL_API_KEY = "__ptu_reservation__" is a sentinel string defined inline in ptu_reservation_rollup.py. The team's convention (see existing sentinels such as _NEGATIVE_TEAM_SENTINEL) is to place all sentinel-like variables in constants.py so they have a single authoritative home. Stage 3 will need to reference this value when filtering spend rows, and any consumer that copies the string literal rather than importing it will silently drift. PTU_ROLLUP_JOB_ID is less critical but follows the same pattern.
Rule Used: What: Require sentinel-like variables (e.g., `_NEG... (source)
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!
e75150c to
481e59c
Compare
|
@greptileai please re-review — pushed P1 fix for the backfill CLI. It now bootstraps its own PrismaClient from DATABASE_URL and passes force=True to run_ptu_reservation_rollup (new kwarg; default False keeps the scheduler contract intact). 8 new tests: 6 CLI-side, 2 rollup-side for the force branch. Base also rebased onto the updated stage 1 branch. |
481e59c to
1c317df
Compare
|
bugbot run |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 4 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 1c317df. Configure here.
| replace_existing=True, | ||
| misfire_grace_time=APSCHEDULER_MISFIRE_GRACE_TIME, | ||
| ) | ||
| verbose_proxy_logger.info("PTU reservation rollup job scheduled at 00:15 UTC daily") |
There was a problem hiding this comment.
Cron schedule is not UTC
Medium Severity
The PTU reservation rollup job schedules for 00:15 without an explicit timezone. This defaults to the host's local timezone, conflicting with the 00:15 UTC stated in logs and the rollup's UTC-based date logic. On non-UTC hosts, this means the job fires at the wrong wall time for the UTC day it attributes.
Reviewed by Cursor Bugbot for commit 1c317df. Configure here.
| id=PTU_ROLLUP_JOB_ID, | ||
| replace_existing=True, | ||
| misfire_grace_time=APSCHEDULER_MISFIRE_GRACE_TIME, | ||
| ) |
There was a problem hiding this comment.
Outages skip rollup days
High Severity
The daily job inherits misfire_grace_time of one hour and only ever rolls yesterday UTC when it runs. If the proxy is down or late by more than that window around the cron fire, that UTC day is never written automatically; the next successful run attributes a different day, so flat PTU cost rows are permanently missing until a manual backfill.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 1c317df. Configure here.
| reservations_processed=0, | ||
| rows_written=0, | ||
| skipped_flag_off=True, | ||
| ) |
There was a problem hiding this comment.
Flag kill-switch never updates
Medium Severity
The rollup re-reads general_settings["enable_ptu_cost_attribution"] intending to no-op after a runtime toggle-off, but live DB config sync only copies a fixed allowlist of keys into the in-memory dict and that flag is not among them. Once the job is registered at startup, turning the flag off does not stop rollup writes without a process restart.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 1c317df. Configure here.
| day=day, | ||
| reservations_processed=len(reservations), | ||
| rows_written=rows_written, | ||
| ) |
There was a problem hiding this comment.
Stale PTU rows persist
Medium Severity
The rollup only upserts reservations still active at day_start. It never clears or zeros an existing sentinel row when that reservation is later closed, backdated, or no longer active for the target day. Re-running the job or backfill CLI after fixing reservation windows therefore leaves orphaned ptu_flat_cost / ptu_reservation_id values that stage 3 activity sums would still pick up.
Reviewed by Cursor Bugbot for commit 1c317df. Configure here.
2a4b5a1 to
4f82356
Compare
1c317df to
56ad42e
Compare
Extends the shared daily-activity read path so team daily activity carries prorated PTU flat cost alongside per-request spend. All changes are additive to the response shape. - SpendMetrics gains flat_cost (float, default 0.0) - DailySpendMetadata gains total_flat_cost (float, default 0.0) - update_metrics + _record_to_spend_metrics read ptu_flat_cost from rollup rows via getattr, so non-team daily tables (user, org, tag, agent, end_user) pass through with flat_cost defaulting to 0 - Grouping-sets SQL query selects SUM(ptu_flat_cost) only for litellm_dailyteamspend; every other daily table emits a 0::float shim so the response shape stays uniform - Sentinel PTU rows (api_key = PTU_SENTINEL_API_KEY) contribute to per-day, per-model, per-provider, per-endpoint, and per-entity totals, but never appear in api_keys / api_key_breakdown maps at any level - get_api_key_metadata skips the sentinel in its Prisma lookup PTU_SENTINEL_API_KEY and PTU_ROLLUP_JOB_ID moved to litellm/constants.py. The rollup module re-exports both so external callers continue working. Addresses Greptile P2 on #33137. Regenerated ui/litellm-dashboard/src/lib/http/schema.d.ts. 15 new unit tests, 8 kill mutations that either drop the ptu_flat_cost accumulation or turn off the sentinel filter.
56ad42e to
2f89070
Compare
Extends the shared daily-activity read path so team daily activity carries prorated PTU flat cost alongside per-request spend. All changes are additive to the response shape. - SpendMetrics gains flat_cost (float, default 0.0) - DailySpendMetadata gains total_flat_cost (float, default 0.0) - update_metrics + _record_to_spend_metrics read ptu_flat_cost from rollup rows via getattr, so non-team daily tables (user, org, tag, agent, end_user) pass through with flat_cost defaulting to 0 - Grouping-sets SQL query selects SUM(ptu_flat_cost) only for litellm_dailyteamspend; every other daily table emits a 0::float shim so the response shape stays uniform - Sentinel PTU rows (api_key = PTU_SENTINEL_API_KEY) contribute to per-day, per-model, per-provider, per-endpoint, and per-entity totals, but never appear in api_keys / api_key_breakdown maps at any level - get_api_key_metadata skips the sentinel in its Prisma lookup PTU_SENTINEL_API_KEY and PTU_ROLLUP_JOB_ID moved to litellm/constants.py. The rollup module re-exports both so external callers continue working. Addresses Greptile P2 on #33137. Regenerated ui/litellm-dashboard/src/lib/http/schema.d.ts. 15 new unit tests, 8 kill mutations that either drop the ptu_flat_cost accumulation or turn off the sentinel filter.
9e82ea4 to
5105676
Compare
2f89070 to
6962709
Compare
Extends the shared daily-activity read path so team daily activity carries prorated PTU flat cost alongside per-request spend. All changes are additive to the response shape. - SpendMetrics gains flat_cost (float, default 0.0) - DailySpendMetadata gains total_flat_cost (float, default 0.0) - update_metrics + _record_to_spend_metrics read ptu_flat_cost from rollup rows via getattr, so non-team daily tables (user, org, tag, agent, end_user) pass through with flat_cost defaulting to 0 - Grouping-sets SQL query selects SUM(ptu_flat_cost) only for litellm_dailyteamspend; every other daily table emits a 0::float shim so the response shape stays uniform - Sentinel PTU rows (api_key = PTU_SENTINEL_API_KEY) contribute to per-day, per-model, per-provider, per-endpoint, and per-entity totals, but never appear in api_keys / api_key_breakdown maps at any level - get_api_key_metadata skips the sentinel in its Prisma lookup PTU_SENTINEL_API_KEY and PTU_ROLLUP_JOB_ID moved to litellm/constants.py. The rollup module re-exports both so external callers continue working. Addresses Greptile P2 on #33137. Regenerated ui/litellm-dashboard/src/lib/http/schema.d.ts. 15 new unit tests, 8 kill mutations that either drop the ptu_flat_cost accumulation or turn off the sentinel filter.
Extends the shared daily-activity read path so team daily activity carries prorated PTU flat cost alongside per-request spend. All changes are additive to the response shape. - SpendMetrics gains flat_cost (float, default 0.0) - DailySpendMetadata gains total_flat_cost (float, default 0.0) - update_metrics + _record_to_spend_metrics read ptu_flat_cost from rollup rows via getattr, so non-team daily tables (user, org, tag, agent, end_user) pass through with flat_cost defaulting to 0 - Grouping-sets SQL query selects SUM(ptu_flat_cost) only for litellm_dailyteamspend; every other daily table emits a 0::float shim so the response shape stays uniform - Sentinel PTU rows (api_key = PTU_SENTINEL_API_KEY) contribute to per-day, per-model, per-provider, per-endpoint, and per-entity totals, but never appear in api_keys / api_key_breakdown maps at any level - get_api_key_metadata skips the sentinel in its Prisma lookup PTU_SENTINEL_API_KEY and PTU_ROLLUP_JOB_ID moved to litellm/constants.py. The rollup module re-exports both so external callers continue working. Addresses Greptile P2 on #33137. Regenerated ui/litellm-dashboard/src/lib/http/schema.d.ts. 15 new unit tests, 8 kill mutations that either drop the ptu_flat_cost accumulation or turn off the sentinel filter.
Adds a scheduled APScheduler job that walks active PTU reservations once per UTC day and upserts prorated flat cost into LiteLLM_DailyTeamSpend using a sentinel api_key. Backfill CLI helper included. - Two additive columns on LiteLLM_DailyTeamSpend: ptu_flat_cost (float, default 0.0) and ptu_reservation_id (nullable text) plus an index - Rollup module in litellm/proxy/spend_tracking/ptu_reservation_rollup.py with calendar-month proration and idempotent upsert; job re-reads the feature flag at execution and no-ops when off - Cron job registered at 00:15 UTC daily in initialize_scheduled_background_jobs, gated on the config flag - CLI backfill at scripts/ptu_reservation_backfill.py accepting --date or --date-range - 19 unit tests covering proration math for 28/29/30/31-day months, flag-off short-circuit, sentinel api_key, idempotency under repeated runs, per-reservation upsert failure isolation, effective_from boundary
The script imported prisma_client and general_settings from proxy_server, but both are only populated during the async proxy startup path, so a standalone invocation exited early with an empty result. Rewritten to construct its own PrismaClient from DATABASE_URL, connect it, and pass force=True to run_ptu_reservation_rollup so backfill runs regardless of whether the config flag is on. Adds a force kwarg on the rollup entry point (default False keeps the scheduler contract intact). 8 new tests: 6 CLI-side, 2 rollup-side for the force branch.
5105676 to
da4e195
Compare
6962709 to
1a98990
Compare
Extends the shared daily-activity read path so team daily activity carries prorated PTU flat cost alongside per-request spend. All changes are additive to the response shape. - SpendMetrics gains flat_cost (float, default 0.0) - DailySpendMetadata gains total_flat_cost (float, default 0.0) - update_metrics + _record_to_spend_metrics read ptu_flat_cost from rollup rows via getattr, so non-team daily tables (user, org, tag, agent, end_user) pass through with flat_cost defaulting to 0 - Grouping-sets SQL query selects SUM(ptu_flat_cost) only for litellm_dailyteamspend; every other daily table emits a 0::float shim so the response shape stays uniform - Sentinel PTU rows (api_key = PTU_SENTINEL_API_KEY) contribute to per-day, per-model, per-provider, per-endpoint, and per-entity totals, but never appear in api_keys / api_key_breakdown maps at any level - get_api_key_metadata skips the sentinel in its Prisma lookup PTU_SENTINEL_API_KEY and PTU_ROLLUP_JOB_ID moved to litellm/constants.py. The rollup module re-exports both so external callers continue working. Addresses Greptile P2 on #33137. Regenerated ui/litellm-dashboard/src/lib/http/schema.d.ts. 15 new unit tests, 8 kill mutations that either drop the ptu_flat_cost accumulation or turn off the sentinel filter.
|
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. The rollup landed as #35343, keyed off the model deployment rather than a reservation row. The branch is kept, so nothing here is lost. |


Relevant issues
Linear ticket
Resolves LIT-1697 (stage 2 of 5)
Stacks on #33130 (stage 1). Merge that first.
Pre-Submission checklist
Screenshots / Proof of Fix
Stage 1 shipped the reservation storage and endpoints; stage 2 makes the daily rollup actually write. To exercise end-to-end against a live proxy on this branch's HEAD, with the feature flag on and a manual PTU reservation:
The rollup does not fire when the flag is off:
Per-request rows are untouched by the rollup; a team that also has real Azure gpt-4 traffic on the same day has its own row with a real hashed
api_keyand the sentinel PTU row alongside it. Stage 3 will teach/team/daily/activityto sum the two.Type
New Feature
Changes
Adds the daily rollup that writes flat PTU cost onto
LiteLLM_DailyTeamSpend. Stage 1 gave admins a way to register reservations; stage 2 makes those reservations show up in per-day spend rows without touching the per-request pipeline.Storage. Two additive columns on
LiteLLM_DailyTeamSpend:ptu_flat_cost(float, default 0.0) andptu_reservation_id(nullable text). New index onptu_reservation_id. No change to the existing unique constraint; PTU rows share it via a sentinelapi_key = "__ptu_reservation__"that also namespaces them away from real per-request rows in every existing filter that keys on api_key.Rollup job. One scheduled APScheduler cron job at 00:15 UTC daily, registered inside
initialize_scheduled_background_jobsbehind theenable_ptu_cost_attributionconfig flag. The job body also re-reads the flag at execution time and no-ops if the flag has been toggled off since registration. For each active reservation at the day's start (effective_from <= 00:00 UTC < effective_to), it computesptu_count * cost_per_ptu / days_in_that_calendar_monthand upserts a sentinel-api_key row for(team_id, date, model). Calendar-month proration is the deliberate choice: Feb 28 days yields ~$7.14/day for a $200/PTU reservation, Jul 31 days yields ~$6.45/day, matching how Azure amortizes reservation cost.Idempotency. The upsert uses
LiteLLM_DailyTeamSpend's composite unique constraint. Re-running the job for the same day overwritesptu_flat_costandptu_reservation_idwith the same values; no duplicate rows. Failures on one reservation don't stop the batch; the loop logs and continues so a bad row can't block the rest.Backfill CLI.
scripts/ptu_reservation_backfill.pycalls the same rollup function for a specific date or an inclusiveYYYY-MM-DD:YYYY-MM-DDrange. Same idempotency guarantees. Operators use this after a proxy outage or when onboarding Evernorth's already-elapsed months (if requested; see stage 5).Mid-day edit semantics. Whichever reservation is active at 00:00 UTC of a given day owns that day's full flat cost. If an admin closes a reservation at 09:00 UTC and creates a replacement, the old one gets that day; the new one starts contributing the following day. No hour-level proration.
Deviation from the admin-entity pattern
Stage 1 already documented the "close old + create new" edit flow. Stage 2 does not introduce further deviations from established patterns. The scheduled-job registration follows the same shape as
spend_log_cleanup_joband the tag-spend batch job registered in the sameinitialize_scheduled_background_jobsblock.Behavior changes
LiteLLM_DailyTeamSpend. Once the flag is on, teams with active reservations will start seeing rows withapi_key = "__ptu_reservation__"from the day after the flag is enabled. Any external SQL consumer ofLiteLLM_DailyTeamSpendthat already filters onapi_keyin a specific set will naturally exclude these; consumers filtering onapi_key = ANYwill see them and should special-case the sentinel value if the desired output is per-request-only.LiteLLM_DailyTeamSpend:ptu_flat_costandptu_reservation_id. Both default-safe (0.0 and null); no code path outside this feature reads them yet. Stage 3 will surface them in/team/daily/activity.spend,prompt_tokens,api_requests, etc. numeric column_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 untouchedBoilerplate note
None new. The rollup module is a first-of-its-kind cost writer that runs on a schedule; no existing template to copy. The CLI helper is small and self-contained.
Files changed
schema.prisma:ptu_flat_cost(Float default 0.0) andptu_reservation_id(String?) added toLiteLLM_DailyTeamSpend; new@@index([ptu_reservation_id])litellm-proxy-extras/.../20260713010000_add_ptu_columns_to_daily_team_spend/migration.sql: ADD COLUMN IF NOT EXISTS + CREATE INDEX IF NOT EXISTSlitellm/proxy/spend_tracking/ptu_reservation_rollup.py: rollup job body, proration function, upsert helper, sentinel constant, dataclass result, module__all__litellm/proxy/proxy_server.py: PTU rollup job registered insideinitialize_scheduled_background_jobswhenenable_ptu_cost_attributionis truthy at startup; uses the string-based"cron"trigger form to avoid a stub-tracking import deltascripts/ptu_reservation_backfill.py:--date/--date-rangeCLI wrapping the same rollup functiontests/test_litellm/proxy/spend_tracking/test_ptu_reservation_rollup.py: 19 testsTests
19 tests in
test_ptu_reservation_rollup.py. Suite covers:Proration math:
_days_in_monthreturns 31/28/29/30 across the four calendar shapes (including leap Feb 2024)ptu_count(100 PTU = 100 × 1 PTU)cost_source != "manual"(forward-compat withazure_billing)Job body:
find_manynever awaited)prisma_client is Nonereturns zero-row resultwhereclause andcreate/updatepayloadscost_source="azure_billing"reservation is walked but skipped (rows_written == 0)target_datedefaults to yesterday UTCfind_manyis called with the correct half-open(effective_from <= day_start, effective_to > day_start OR null)where clauseapi_key = "__ptu_reservation__"used (not a real token)ptu_flat_costandptu_reservation_idon their payload; do not touchspend,prompt_tokens,completion_tokens,api_requestseffective_fromexactly at 00:00 UTC of the target day is treated as active (inclusive left boundary)Combined stage 1 + stage 2 suite:
Neighboring spend_tracking + budget tests all still pass (276 passed).
Note
Medium Risk
Touches daily team spend storage and a new scheduled writer; feature-flagged and additive, but SQL/API consumers of
LiteLLM_DailyTeamSpendmay see new sentinel rows until stage 3 aggregates them.Overview
Adds stage 2 PTU cost attribution: a scheduled daily rollup that writes prorated prepaid PTU cost into
LiteLLM_DailyTeamSpend, separate from per-requestspend.Schema & migration.
LiteLLM_DailyTeamSpendgainsptu_flat_cost(default 0) andptu_reservation_id, plus an index onptu_reservation_id. Prisma schemas and a proxy-extras migration useIF NOT EXISTSfor safe rollout.Rollup behavior. New
ptu_reservation_rolluploads active reservations at UTC day start, prorates manual reservations as(ptu_count × cost_per_ptu) / days_in_calendar_month, and idempotently upserts rows keyed by team/date/model with sentinelapi_key = "__ptu_reservation__"so they sit beside real API-key rows without changingspendor token counters. Non-manual (azure_billing) reservations are skipped; per-reservation upsert failures are logged and the batch continues.Scheduling & ops. When
enable_ptu_cost_attributionis on at proxy startup, APScheduler registers a 00:15 UTC cron job; the job also no-ops at runtime if the flag is off.scripts/ptu_reservation_backfill.pyre-runs the same logic for one day or an inclusive date range withforce=True(no flag required).Tests. Broad unit coverage for proration math, flag gating, upsert shape, idempotency, and the backfill CLI.
Reviewed by Cursor Bugbot for commit 1c317df. Bugbot is set up for automated code reviews on this repo. Configure here.