Skip to content

feat(ptu): surface ptu_flat_cost via /team/daily/activity response - #33266

Closed
yucheng-berri wants to merge 1 commit into
litellm_lit1697_stage2_rollup_jobfrom
litellm_lit1697_stage3_read_path
Closed

feat(ptu): surface ptu_flat_cost via /team/daily/activity response#33266
yucheng-berri wants to merge 1 commit into
litellm_lit1697_stage2_rollup_jobfrom
litellm_lit1697_stage3_read_path

Conversation

@yucheng-berri

Copy link
Copy Markdown
Contributor

Relevant issues

Linear ticket

Resolves LIT-1697 (stage 3 of 5)

Stacks on #33137. Merge that first.

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

Prereq: stages 1 and 2 already applied and a reservation seeded. Enable flag, run a real Azure gpt-4 request as a real team key so we have per-request spend on the same team+model+day, and run the backfill for that day.

$ MASTER_KEY=sk-1234
$ TEAM=<real team id>
$ 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}' > /dev/null

# real request
$ curl -s -X POST http://localhost:4000/v1/chat/completions \
    -H "Authorization: Bearer $TEAM_KEY" -H "Content-Type: application/json" \
    -d '{"model":"gpt-4","messages":[{"role":"user","content":"hi"}]}' > /dev/null

# ensure a PTU sentinel row lands for the same day
$ .venv/bin/python scripts/ptu_reservation_backfill.py --date 2026-07-14
[2026-07-14] reservations=1 rows_written=1

# Response now includes flat_cost + total_flat_cost
$ curl -s "http://localhost:4000/team/daily/activity?team_ids=$TEAM&start_date=2026-07-14&end_date=2026-07-14&page_size=10" \
    -H "Authorization: Bearer $MASTER_KEY" | jq '{
      total_spend: .metadata.total_spend,
      total_flat_cost: .metadata.total_flat_cost,
      day_spend: .results[0].metrics.spend,
      day_flat_cost: .results[0].metrics.flat_cost,
      api_keys_in_breakdown: (.results[0].breakdown.api_keys | keys),
      model_flat_cost: .results[0].breakdown.models["gpt-4"].metrics.flat_cost
    }'
{
  "total_spend": 0.0135,
  "total_flat_cost": 6.4516,
  "day_spend": 0.0135,
  "day_flat_cost": 6.4516,
  "api_keys_in_breakdown": ["sk-abc123..."],       # sentinel is NOT here
  "model_flat_cost": 6.4516                        # sentinel DID contribute
}

# Confirm sentinel row is included when a caller explicitly filters for it
$ curl -s "http://localhost:4000/team/daily/activity?team_ids=$TEAM&start_date=2026-07-14&end_date=2026-07-14&api_key=__ptu_reservation__&page_size=10" \
    -H "Authorization: Bearer $MASTER_KEY" | jq '.metadata.total_flat_cost'
6.4516

# Non-team daily-activity endpoints unaffected — flat_cost stays 0
$ curl -s "http://localhost:4000/user/daily/activity?user_ids=<me>&start_date=2026-07-14&end_date=2026-07-14" \
    -H "Authorization: Bearer $MASTER_KEY" | jq '{total_spend: .metadata.total_spend, total_flat_cost: .metadata.total_flat_cost}'
{"total_spend": 0.0135, "total_flat_cost": 0}

Aggregated variant (?aggregated=true) shows the same fields in metadata because both paths run through the same shared response construction; also verified.

Type

New Feature

Changes

Extends the shared daily-activity read path so team daily activity surfaces prorated PTU flat cost alongside per-request spend. All changes are additive to the response shape; consumers that don't know about flat_cost / total_flat_cost see them as zero-valued defaults and behave exactly as before.

Response shape. SpendMetrics gains a flat_cost: float = 0.0 peer of spend. DailySpendMetadata gains total_flat_cost: float = 0.0 peer of total_spend. Peer of, not folded into: an auditor asking "why does this team show $206 total for a day with two requests" needs the components independently. The UI in stage 4 will compute the display total by summing the two.

Row-level aggregation. update_metrics and _record_to_spend_metrics read ptu_flat_cost via getattr(record, "ptu_flat_cost", None) or 0.0, so rows from LiteLLM_DailyUserSpend / LiteLLM_DailyOrganizationSpend / LiteLLM_DailyTagSpend / LiteLLM_DailyAgentSpend / LiteLLM_DailyEndUserSpend (which don't have that column) pass through with flat_cost=0.0. No table-shape check needed at the row level.

Grouping-sets SQL query. _build_aggregated_sql_query now conditionally emits SUM(ptu_flat_cost)::float AS ptu_flat_cost when the table is litellm_dailyteamspend, and 0::float AS ptu_flat_cost otherwise. Every daily-* table's query returns the same column shape so _record_to_spend_metrics doesn't need to branch on entity.

Sentinel filtering. The rollup in stage 2 writes rows with api_key = "__ptu_reservation__". Those rows must contribute their ptu_flat_cost to every parent bucket (per-day totals, per-model, per-provider, per-endpoint, per-entity) but never appear in any api_keys / api_key_breakdown map — the sentinel string is not a real key alias.

Two aggregator changes enforce that invariant:

  • update_breakdown_metrics (paginated path): sets is_ptu_sentinel = record.api_key == PTU_SENTINEL_API_KEY at the top and guards every api_key / api_key_breakdown write on it. Parent buckets (models, providers, mcp_servers, endpoints, entities) still receive the row via update_metrics, so flat_cost accumulates there.
  • _aggregate_grouping_sets_records_sync (aggregated path): computes real_api_key = record.api_key and record.api_key != PTU_SENTINEL_API_KEY per row and gates each *_API_KEY grouping-level dispatch on it. The _GROUP_DATE, _GROUP_DATE_MODEL, _GROUP_DATE_PROVIDER, etc. levels are unaffected — sentinel rows contribute normally.

get_api_key_metadata also skips the sentinel when building the set of tokens to look up, so we don't issue a wasted Prisma query for "__ptu_reservation__".

Constant relocation. PTU_SENTINEL_API_KEY and PTU_ROLLUP_JOB_ID moved from litellm/proxy/spend_tracking/ptu_reservation_rollup.py to litellm/constants.py. The rollup module re-exports both from the top so external callers continue to work. This addresses the Greptile P2 finding on #33137.

Deviation from the admin-entity pattern

None new in this stage. Stage 1's rationale for skipping /update still applies; stage 3 doesn't add or remove endpoints.

Behavior changes

  • SpendMetrics and DailySpendMetadata grow two fields. Additive. Any consumer that deserializes the response body ignores unknown fields, and any consumer that reads specific fields keeps working.
  • GET /team/daily/activity responses now carry flat_cost and total_flat_cost. Zero-valued if the feature flag is off, zero-valued for teams with no reservations, non-zero for teams whose rollup has landed rows.
  • GET /user/daily/activity, /organization/daily/activity, /customer/daily/activity, /tag/daily/activity, /agent/daily/activity, /enduser/daily/activity, /mcp_server/daily/activity: each gains the fields too, but always zero — none of those daily tables carry PTU flat cost. Kept identical response shape across entities so downstream consumers don't need per-entity branches.
  • api_key filter semantics: a client filtering api_key=<real token> naturally excludes sentinel PTU rows because the sentinel value doesn't equal any real hashed token. A client explicitly passing api_key=__ptu_reservation__ sees only PTU rows (undocumented but harmless; useful for FinOps auditors).
  • No change to LiteLLM_TeamTable.spend, budget enforcement, per-request spend hot path, or any Prometheus metric.
  • No new writes: this PR only threads existing columns into the response. The rollup still writes only what stage 2 wrote.

Files changed

  • litellm/constants.py: PTU_SENTINEL_API_KEY and PTU_ROLLUP_JOB_ID constants
  • litellm/proxy/spend_tracking/ptu_reservation_rollup.py: re-exports constants from litellm.constants
  • litellm/types/proxy/management_endpoints/common_daily_activity.py: flat_cost on SpendMetrics, total_flat_cost on DailySpendMetadata
  • litellm/proxy/management_endpoints/common_daily_activity.py:
    • update_metrics and _record_to_spend_metrics read ptu_flat_cost
    • update_breakdown_metrics guards all api_key writes on sentinel
    • _build_aggregated_sql_query emits SUM(ptu_flat_cost) for team, 0::float for others
    • _aggregate_grouping_sets_records_sync gates *_API_KEY levels on sentinel
    • get_daily_activity and get_daily_activity_aggregated populate total_flat_cost on the response
    • _aggregate_spend_records and _aggregate_grouping_sets_records skip sentinel in get_api_key_metadata lookup
  • ui/litellm-dashboard/src/lib/http/schema.d.ts: regenerated (10 lines added for the two new fields)
  • tests/test_litellm/proxy/management_endpoints/test_common_daily_activity.py: 15 new tests

Tests

15 new tests covering:

  • SpendMetrics.flat_cost defaults to 0.0
  • update_metrics accumulates ptu_flat_cost; handles missing attr (user/org/tag rows); handles None
  • _record_to_spend_metrics reads ptu_flat_cost; defaults to 0.0 when absent
  • Sentinel row is skipped from top-level api_keys breakdown but shows up in models, providers, endpoints, entities breakdowns with correct flat_cost
  • Real key + sentinel share a model bucket; only the real key appears in api_key_breakdown under that model
  • get_api_key_metadata lookup excludes sentinel from the Prisma .find_many IN clause
  • SQL builder emits SUM(ptu_flat_cost) for team table only, 0::float for user/org/etc.
  • End-to-end get_daily_activity on team table with mixed real + sentinel rows returns correct total_spend, total_flat_cost, per-day flat_cost, sentinel-free api_keys breakdown
$ .venv/bin/python -m pytest tests/test_litellm/proxy/management_endpoints/test_common_daily_activity.py -q
47 passed
$ .venv/bin/python -m pytest tests/test_litellm/proxy/management_endpoints/test_common_daily_activity.py \
    tests/test_litellm/proxy/management_endpoints/test_ptu_reservation_endpoints.py \
    tests/test_litellm/proxy/spend_tracking/test_ptu_reservation_rollup.py \
    tests/test_scripts/ \
    tests/test_litellm/proxy/test_component_allowlists.py -q
114 passed

Mutation checks (locally, not permanent):

  • Removing the ptu_flat_cost accumulation in update_metrics fails 6 tests
  • Setting is_ptu_sentinel = False unconditionally fails 6 tests

@greptile-apps

greptile-apps Bot commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR threads prorated PTU reservation flat cost (ptu_flat_cost) through the shared daily-activity read path so that /team/daily/activity responses surface both per-request spend and a separate flat_cost / total_flat_cost field. All response-shape changes are additive and zero-defaulted, so consumers unaware of the new fields are unaffected.

  • SpendMetrics.flat_cost and DailySpendMetadata.total_flat_cost are added as 0.0 defaults; update_metrics and _record_to_spend_metrics accumulate them from ptu_flat_cost via getattr with a None fallback, making non-team tables transparent.
  • Sentinel rows (api_key == \"__ptu_reservation__\") are filtered from every api_keys / api_key_breakdown map in both the paginated and grouping-sets aggregated paths, while still contributing flat_cost to per-model, per-provider, per-endpoint, and per-day buckets.
  • PTU_SENTINEL_API_KEY and PTU_ROLLUP_JOB_ID are correctly relocated to litellm/constants.py per the repository's sentinel-constant rule, with re-exports from the rollup module for backward compatibility.

Confidence Score: 4/5

Safe to merge; all changes are additive and the sentinel-filtering invariant is consistently enforced across both read paths

The sentinel-exclusion logic is correctly applied in both the paginated row-by-row path and the SQL GROUPING SETS aggregated path. Two observations are worth noting before production: total_flat_cost in the paginated metadata reflects only the current page rows (same constraint as total_spend), and the metadata_metrics_func override path would silently zero out flat_cost if ever wired to a team endpoint.

litellm/proxy/management_endpoints/common_daily_activity.py — specifically the metadata_metrics_func branch and the paginated totals accumulation

Important Files Changed

Filename Overview
litellm/constants.py Adds PTU_SENTINEL_API_KEY and PTU_ROLLUP_JOB_ID constants at end of file — correctly follows the sentinel-constant-in-constants.py rule
litellm/types/proxy/management_endpoints/common_daily_activity.py Adds flat_cost to SpendMetrics and total_flat_cost to DailySpendMetadata; both default to 0.0, fully additive and backward-compatible
litellm/proxy/spend_tracking/ptu_reservation_rollup.py Removes inline constant definitions and imports them from litellm.constants instead; no behavioral change
litellm/proxy/management_endpoints/common_daily_activity.py Core logic for surfacing PTU flat_cost; sentinel filtering is consistently applied across both paginated and aggregated paths; minor concern around metadata_metrics_func bypassing flat_cost for the team path
tests/test_litellm/proxy/management_endpoints/test_common_daily_activity.py Adds 15 new focused unit tests covering flat_cost accumulation, sentinel exclusion from api_key breakdowns, SQL query shape, and end-to-end get_daily_activity; all mocked, no network calls
ui/litellm-dashboard/src/lib/http/schema.d.ts Regenerated type definitions adding flat_cost to SpendMetrics and total_flat_cost to DailySpendMetadata, both with default 0

Comments Outside Diff (1)

  1. litellm/proxy/management_endpoints/common_daily_activity.py, line 882-884 (link)

    P2 total_flat_cost silently zeroed when metadata_metrics_func is supplied

    When a caller provides metadata_metrics_func (currently used by the tag endpoint with compute_tag_metadata_totals), metadata_metrics is replaced by the function's return value. That function builds SpendMetrics from deduped records via update_metrics, but records from non-team daily tables never carry ptu_flat_cost, so flat_cost will always be 0.0. This is harmless today because no team endpoint sets metadata_metrics_func, but if one ever does, total_flat_cost would silently vanish from the response while per-day flat_cost would still be populated correctly — making the metadata inconsistent. A follow-up note or assertion documenting that metadata_metrics_func is incompatible with PTU flat-cost surfacing would prevent a future silent regression.

Reviews (1): Last reviewed commit: "feat(ptu): surface ptu_flat_cost via /te..." | Re-trigger Greptile

Comment on lines +589 to +591
api_keys: Set[str] = {
record.api_key for record in records if record.api_key and record.api_key != PTU_SENTINEL_API_KEY
}

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 Paginated total_flat_cost is page-scoped, not global

_aggregate_spend_records processes only the records fetched on the current page. If sentinel PTU rows happen to land on page 2 and the caller is reading page 1, total_flat_cost in the metadata will be 0.0 even though flat cost exists for those days. This is the same pre-existing limitation as total_spend in the paginated path, but it's newly surfaced via total_flat_cost. Worth documenting — especially for FinOps consumers of this endpoint who may aggregate total_flat_cost across pages and get an undercount. The aggregated variant (?aggregated=true) correctly returns a single grand-total row from SQL and is unaffected.

@codecov

codecov Bot commented Jul 14, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 80.85106% with 9 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...roxy/management_endpoints/common_daily_activity.py 78.57% 9 Missing ⚠️

📢 Thoughts on this report? Let us know!

@yucheng-berri

Copy link
Copy Markdown
Contributor Author

@greptileai thanks for the review. Both P2s are non-blocking and I'm shipping as-is; wanted to acknowledge them explicitly rather than leave them ambiguous.

P2 (inline, page-scoped total_flat_cost): this is the exact same page-scoped semantics that total_spend already has — both fields sum only the current page's rows through _aggregate_spend_records. The aggregated variant (?aggregated=true) hits _build_aggregated_sql_query with SQL GROUPING SETS and returns the grand-total row from Postgres, which is the correct path for a FinOps consumer wanting a single number across an entire date range. Anyone paging through daily rows and summing per-page total_spend is already undercounting today; total_flat_cost inherits that shape by design so the two fields stay analogous. Not going to change the pagination contract in this PR.

P2 (outside diff, metadata_metrics_func override): the override contract is currently only used by the tag endpoint via compute_tag_metadata_totals, and tags don't carry PTU rows — the only daily table with ptu_flat_cost is LiteLLM_DailyTeamSpend. So there's no live path where the override silently drops flat_cost. If a future endpoint ever wired this override to a table that does carry PTU, the fix would be to teach that specific override function to sum ptu_flat_cost too. Filing this shape as a follow-up rather than pre-emptively refactoring the override contract now.

Both are documentation-level, not correctness bugs. Merging when the stack lands.

@greptile-apps

greptile-apps Bot commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Both explanations are technically sound — acknowledged and accepted.

On the page-scoped total_flat_cost: you're right that it mirrors total_spend exactly. Any consumer expecting grand-total numbers should be using the ?aggregated=true path; adding a carve-out for flat_cost alone would create a surprising asymmetry between the two fields. The shape is consistent by design.

On the metadata_metrics_func override: the constraint that only LiteLLM_DailyTeamSpend carries ptu_flat_cost makes the current non-live status accurate. A follow-up issue to document the override contract (e.g. "any override function must sum ptu_flat_cost if its table carries it") would be a reasonable guard for the next implementer, but that's your call on prioritization.

Nothing blocking here. Ship it.

@yucheng-berri
yucheng-berri force-pushed the litellm_lit1697_stage2_rollup_job branch from 1c317df to 56ad42e Compare July 15, 2026 17:05
@yucheng-berri
yucheng-berri force-pushed the litellm_lit1697_stage3_read_path branch from 4454840 to 66aca9a Compare July 15, 2026 17:05
@yucheng-berri
yucheng-berri force-pushed the litellm_lit1697_stage2_rollup_job branch from 56ad42e to 2f89070 Compare July 15, 2026 17:26
@yucheng-berri
yucheng-berri force-pushed the litellm_lit1697_stage3_read_path branch from 66aca9a to 69dd1e1 Compare July 15, 2026 17:26
@yucheng-berri
yucheng-berri force-pushed the litellm_lit1697_stage2_rollup_job branch from 2f89070 to 6962709 Compare July 15, 2026 17:32
@yucheng-berri
yucheng-berri force-pushed the litellm_lit1697_stage3_read_path branch from 69dd1e1 to e724ae5 Compare July 15, 2026 17:32
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.
@yucheng-berri
yucheng-berri force-pushed the litellm_lit1697_stage2_rollup_job branch from 6962709 to 1a98990 Compare July 15, 2026 20:24
@yucheng-berri
yucheng-berri force-pushed the litellm_lit1697_stage3_read_path branch from e724ae5 to 48d46cc 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.

The read path landed as #35391; staging already returns flat_cost and total_flat_cost from common_daily_activity.py.

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.

1 participant