Skip to content

feat(proxy): split Azure PTU deployments across teams with per-team PTU ceilings and PTU-hour reporting - #43043

Open
devin-ai-integration[bot] wants to merge 34 commits into
mainfrom
litellm_ptu_shares_per_team
Open

devin-ai-integration[bot] wants to merge 34 commits into
mainfrom
litellm_ptu_shares_per_team

Conversation

@devin-ai-integration

@devin-ai-integration devin-ai-integration Bot commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

TLDR

Problem this solves:

  • One Azure PTU deployment belongs to exactly one team
  • Sharing it means hand-converting PTUs into per-team TPM limits
  • Flat cost lands on one team, usage never shows PTUs

How it solves it:

  • model_info.ptu_shares splits ptu_count across teams in whole PTUs
  • Only teams holding a share get routed to the deployment
  • Each share becomes a per-minute team token ceiling
  • Daily rollup splits the flat cost across teams by share
  • Team usage API and page report PTU-hours next to tokens

Docs for the new fields, the ceiling, and the cost split are in BerriAI/litellm-docs#1700 (docs/proxy/ptu_flat_cost.md and docs/proxy/dynamic_rate_limit.md), to merge after this PR

User Flow

Before: a proxy admin cannot split one PTU deployment across two departments, and neither department ever sees PTU consumption

  1. The admin sends POST https://litellm-domain/model/new with model_info: {"ptu_count": 50, "cost_per_ptu_per_hour": 1.0, "ptu_effective_from": "2026-09-23T00:00:00Z", "ptu_shares": {"<team-a>": 30, "<team-b>": 20}} and gets 400 team_id is required when PTU fields are set (one model maps to one team)
  2. They register it the documented way with "team_id": "<team-a>" instead, and team B's key then gets 400 Invalid model name passed in model=gpt-4.1-ptu on POST https://litellm-domain/v1/chat/completions
  3. To cap team A at its 30 PTU they set metadata.model_tpm_limit through POST https://litellm-domain/team/update to a TPM number they worked out by hand, and nothing keeps that number in step with the reservation
  4. GET https://litellm-domain/team/daily/activity?team_ids=&team_ids=&start_date=...&end_date=... shows the whole day's flat_cost under team A, nothing under team B, and no PTU figure anywhere; the Team Usage view at https://litellm-domain/ui/usage shows tokens only

After: the admin declares the split in PTUs once, the proxy enforces it per team, and cost and PTU-hours land on each team

  1. The admin sends the same POST https://litellm-domain/model/new with ptu_shares and no team_id and gets 200; shares that do not add up to ptu_count, or a team_id next to ptu_shares, get a 400 saying so
  2. Team A's and team B's keys both get 200 on POST https://litellm-domain/v1/chat/completions to gpt-4.1-ptu, while a key from a team holding no share gets 400 Deployment gpt-4.1-ptu is reserved for the teams holding a PTU share of it
  3. Once team A's requests pass 30 PTU worth of Azure-normalized tokens in a minute (30 x 3,000 TPM on gpt-4.1, output counted at 4x) the next one gets 429 Rate limit exceeded for model_per_team_ptu: <team-a>:gpt-4.1-ptu with a retry-after, and team B keeps its own 20 PTU
  4. The same GET https://litellm-domain/team/daily/activity shows the day's flat_cost split 720 under team A and 480 under team B (30 and 20 of the $1,200 day) plus ptu_hours per team and per model group next to the tokens, and the Team Usage page shows a PTU Hours tile

Design decisions

  1. ptu_shares and team_id are mutually exclusive and the shares must add up to ptu_count exactly. The alternative, letting a partial split leave the rest unowned, would silently drop cost, so the deployment either belongs to one team the old way or is fully split. For the same reason ptu_shares without ptu_count and cost_per_ptu_per_hour is refused too: a split with no count has nothing to add up to, and the deployment would route and cap by share while its flat cost never rolled up
  2. A shared deployment is not team-scoped in the database, so it keeps the public model name and stays visible in model lists. Callers holding no share, the master key and team-less keys included, are dropped at routing the way access windows drop them, so a request from a share-less team fails with a 400 that names the reason instead of the generic invalid-model error
  3. The ceiling applies only to ptu_shares deployments. A single-team team_id deployment gets no new 429s, since the team already owns all of it, but its usage does report PTU-hours
  4. The Azure sizing table lives in one module under the Azure provider (litellm/llms/azure/ptu_capacity.py) with the source URL and the date it was read. A deployment declaring shares, or an Azure deployment reserved for one team, whose model has no row logs a warning at registration and fails open (no ceiling, zero PTU-hours); a single-team reservation on another provider only ever fed the flat-cost rollup, which needs no sizing, so it is not warned about. model_info.base_model is the preferred lookup key because an Azure deployment name is arbitrary
  5. The limiter charges Azure's normalized tokens (uncached input, cached input at the model's cached ratio, output times the model's output ratio) so the ceiling matches how Azure sizes a PTU. The pre-call reservation against the ceiling is taken in those same units, the output budget weighted by the model's output ratio, so a burst of concurrent requests cannot admit more than the share the way a raw-token reservation would (300 output tokens reserve 1,200 normalized tokens at 4:1, not 300). It is settled after the call at the response's normalized usage, on a failed stream at the recovered partial usage, and refunded whole on a proxy-side rejection, always with the ceiling admission resolved even if the share changed meanwhile. When usage cannot be resolved it charges the raw total the other scopes charge. A request without max_tokens gets the proxy's usual output floor, but measured against the share expressed in raw output tokens (the ceiling divided by the model's output ratio), so the floor and the implicit max_tokens cap the proxy writes stay in raw tokens and an uncapped request never reserves more than a quarter of the share: one PTU of gpt-4.1 admits four uncapped requests a minute instead of refusing the first
  6. Two shared deployments of different models in one model group take the larger output ratio and the larger cached-input ratio, which over-counts on the cheaper model rather than under-counting on the dearer one
  7. GPT-6 cache-write and long-context weights from the Azure page are ignored; only the cached-input tenth is modeled
  8. PTU-hours are computed on the read path of GET /team/daily/activity from the tokens already stored, so no schema change and no new rollup. Per-key, per-user and per-tag rows keep ptu_hours at zero because those rollups carry no model group today
  9. The share filter (which teams a shared deployment is served to) applies whenever ptu_shares is declared, flag or no flag, because a declared split is an access rule and a proxy that ignored it would hand one department's reservation to another. The ceiling, the cost split, PTU-hours, and the registration validation stay gated on LITELLM_ENABLE_PTU_COST_ATTRIBUTION, like the existing flat-cost attribution, so a proxy with neither the flag nor shares behaves exactly as before, with one carve-out: a config.yaml entry that declares ptu_shares has its PTU fields validated with the flag off too, since the split it declares is enforced either way and a malformed one is better dropped at load, with the startup log naming the model and the reason, than honoured or ignored without a word (the model endpoints already refuse every PTU field with the flag off). The drop follows how the proxy has handled a malformed PTU entry with the flag on since before this PR: the router raises, the proxy's ignore_invalid_deployments logs the error and starts without that deployment, and a request to its group gets a 400 with no healthy deployments, so this release adds no startup refusal
  10. The Team Usage page adds a sixth "PTU Hours" tile only for a team whose window consumed PTU-hours; every other team sees the same five tiles as before
  11. The ceiling is a per-minute descriptor (model_per_team_ptu:<team_id>:<model group>) on the same window as the team's model_tpm_limit, so a 429 from it reads and resets the same way as the limits admins already know. A rolling hour was considered and rejected because Azure sizes a PTU per minute
  12. LITELLM_ENABLE_PTU_COST_ATTRIBUTION is read from the process environment, never through the secret-manager path get_secret takes, because the flag is checked on every request in the router and the limiter. Caching a secret-manager read was the alternative and would add a cache to invalidate for a value that only ever lives in the pod's environment
  13. ptu_count and every share are whole PTUs, which is how Azure sells them: 100.0 is accepted as 100, and 100.5 is refused at registration from POST /model/new and config.yaml alike
  14. A deployment registered for one team gets a unique internal model_name and keeps the public one in model_info.team_public_model_name, so the sizing lookup for PTU-hours and the ceiling matches on either name; usage rows carry the public name
  15. Every team id named in ptu_shares is checked against the team table on POST /model/new and PATCH /model/{model_id}/update, the same check team_id already gets there, so a mistyped id is a 400 naming it instead of a share no key can ever use. config.yaml registration stays unchecked, since the router loads before the database, exactly as it does for team_id
  16. A deployment one team owns through team_id is not switched to ptu_shares by a PATCH: the row is team-scoped (internal routing name, entry in the team's model list) and a shared row is proxy-wide, so the switch is a delete and a POST /model/new with the same model_info.id, which keeps the id the flat-cost rows are keyed on, and the docs say so. The alternative, letting an explicit team_id: null clear it in place, would need the PATCH path to repeat the delete path's team cleanup on a field the update endpoint deliberately keeps team admins from clearing
  17. A request naming the shared deployment by model_info.id or provider model (litellm_params.model), or through a routing group or model_group_alias, counts against the ceiling of the group where the calling team holds its share, and settlement credits that same counter. A group name wins over a colliding deployment id, and a deployment id wins over any wildcard, as in the router. A name served through a wildcard counts against the group of the deployment behind the one pattern the router picks for it, the most specific proxy-wide pattern or, when none matches, the team's own, so a team's wildcard reservation behind an open * route is never charged or sized for traffic * serves. A provider model or routing group that also reaches an open deployment counts against the shared group even when the router serves it from the open one, which over-counts rather than leaks; naming the group keeps the count exact. A team with shares in two groups over one provider model is counted against the first in config order. A usage row keyed by any of those names (the router stamps the row with the name the request used) is sized by the same resolution, aliases included, so the PTU-hours a team reads back match what the ceiling charged however it named the deployment. One team's usage page sizes the row by the deployment that team is served from behind the name, its shared one first and never one reserved for another team (the router's own team-ownership rule, now one shared predicate), so a team with no share on a name, or served by an open deployment next to another team's reservation, reads no PTU-hours on it; a page spanning several or all teams merges every team into one row per name and sizes it by the first reserved deployment behind the name
  18. Each deployment-day's team charges are written in one database transaction, retried as a whole, because the backfill counts a deployment-day as priced once any team's row for it exists, so a partial write would leave the missing team unbilled for good. Checking that a day's rows add up to its flat cost was the alternative, but it bills a day again whenever the rate or the shares change afterwards. Storing the share map each day was billed under would also work and needs a new column

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/unit/<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
  • Every automated review on my PR's latest commit has finished, and I have addressed or replied to each of its findings before requesting a maintainer review

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

Last updated: 8a97ba4. QA ran on this tip (after) and on its merge base aa879f7 (before) on 2026-10-14 UTC

Both legs ran the same script against fresh databases. Each leg is two proxies, P1 and P2, each booted with --num_workers 2, sharing one Postgres database and one Redis, with LITELLM_ENABLE_PTU_COST_ATTRIBUTION=True and LITELLM_LICENSE exported, and calling the real Azure deployment behind azure/gpt-5.4-mini. Teams are created on P1 and keys on P2, and model calls alternate between the two. The deployment under test is 5 PTU of gpt-5.4-mini at $10 per PTU-hour, so $1,200 a day. Azure sizes gpt-5.4-mini at 7,900 tokens per PTU per minute with output counted 6x, so team A's 3 PTU is a ceiling of 23,700 and team B's 2 PTU is 15,800. Each P1 serves the dashboard built from its own worktree's ui/ tree

MK=(-H "Authorization: Bearer $LITELLM_MASTER_KEY"); CT=(-H 'Content-Type: application/json')
EFF=2026-10-11T00:00:00Z; TODAY=2026-10-14
call() {
  case $4 in
    chat) r=/v1/chat/completions b="{\"model\": \"gpt-5.4-mini-ptu\", \"messages\": [{\"role\": \"user\", \"content\": \"$6\"}], \"max_tokens\": $5, \"cache\": {\"no-cache\": true}}";;
    messages) r=/v1/messages b="{\"model\": \"gpt-5.4-mini-ptu\", \"messages\": [{\"role\": \"user\", \"content\": \"$6\"}], \"max_tokens\": $5, \"cache\": {\"no-cache\": true}}";;
    responses) r=/v1/responses b="{\"model\": \"gpt-5.4-mini-ptu\", \"input\": \"$6\", \"max_output_tokens\": $5, \"cache\": {\"no-cache\": true}}";;
  esac
  curl -s -i -m 300 -X POST "$2$r" -H "Authorization: Bearer $3" "${CT[@]}" -d "$b" | python3 show.py "$1 (sent $(date -u +%TZ), $r)"
}
model_body() {
  NAME=$1 SHARES=$2 TEAM=$3 FROM=$4 TO=$5 python3 - <<'EOF'
import json, os
info = {"base_model": "gpt-5.4-mini", "ptu_count": 5, "cost_per_ptu_per_hour": 10.0, "ptu_effective_from": os.environ["FROM"]}
if os.environ["TO"]: info["ptu_effective_to"] = os.environ["TO"]
if os.environ["SHARES"]: info["ptu_shares"] = json.loads(os.environ["SHARES"])
if os.environ["TEAM"]: info["team_id"] = os.environ["TEAM"]
print(json.dumps({"model_name": os.environ["NAME"], "litellm_params": {"model": "azure/gpt-5.4-mini", "api_base": os.environ["AZURE_QA_API_BASE"], "api_key": os.environ["AZURE_QA_API_KEY"], "api_version": "2025-04-01-preview"}, "model_info": info}))
EOF
}
activity() { curl -s "$P2/team/daily/activity?team_ids=$TEAM_A_ID,$TEAM_B_ID,$TEAM_C_ID&start_date=$1&end_date=$2&page_size=100" "${MK[@]}" | python3 flat_cost_by_team.py; }
one_team() { curl -s "$P2/team/daily/activity?team_ids=$1&start_date=$TODAY&end_date=$TODAY&page_size=100" "${MK[@]}" | python3 ptu_by_group.py; }

show.py prints the status, retry-after, ptu R/L from the x-ratelimit-model_per_team_ptu-remaining-tokens and -limit-tokens response headers, the x-litellm-model-group header, the usage and the error message. flat_cost_by_team.py prints total_flat_cost, total_ptu_hours and each day's flat_cost per team, and ptu_by_group.py prints total_tokens, total_ptu_hours and ptu_hours per model group. Team ids print as team A, B and C. The daily flat-cost rollup and its backfill are the proxy's scheduled job, so the rig calls them once from the leg's own worktree: run_rollup.py rollup <day>... awaits run_ptu_flat_cost_rollup for each day and run_rollup.py backfill <today> awaits run_ptu_flat_cost_backfill, and every verdict is then read back through GET /team/daily/activity or the Admin UI

Before (aa879f7)

Setup

  1. create teams ptu-team-a, ptu-team-b and ptu-team-c on P1 and one key per team on P2
for t in a b c; do
  curl -s -X POST $P1/team/new "${MK[@]}" "${CT[@]}" -d "{\"team_alias\": \"ptu-team-$t\"}"
  curl -s -X POST $P2/key/generate "${MK[@]}" "${CT[@]}" -d "{\"team_id\": \"<that team's id>\", \"key_alias\": \"ptu-team-$t-key\"}"
done
POST /team/new on P1 ptu-team-a -> team A, POST /key/generate on P2 -> ptu-team-a-key
POST /team/new on P1 ptu-team-b -> team B, POST /key/generate on P2 -> ptu-team-b-key
POST /team/new on P1 ptu-team-c -> team C, POST /key/generate on P2 -> ptu-team-c-key

Case 1: register one PTU deployment split between team A (3 PTU) and team B (2 PTU)

  1. POST /model/new on P1 with ptu_shares and no team_id, then, since that is refused, the documented single-team registration, then GET /model/info on P2 after its database poll
curl -s -w '\nHTTP %{http_code}' -X POST $P1/model/new "${MK[@]}" "${CT[@]}" -d "$(model_body gpt-5.4-mini-ptu "{\"$TEAM_A_ID\": 3, \"$TEAM_B_ID\": 2}" "" $EFF "")"
curl -s -w '\nHTTP %{http_code}' -X POST $P1/model/new "${MK[@]}" "${CT[@]}" -d "$(model_body gpt-5.4-mini-ptu "" "$TEAM_A_ID" $EFF "")"
sleep 12; curl -s $P2/model/info "${MK[@]}"
POST /model/new on P1 with ptu_shares and no team_id: HTTP 400, "team_id is required when PTU fields are set (one model maps to one team)"
fallback, the documented single-team way: POST /model/new on P1 with model_info.team_id = team A: HTTP 200
GET /model/info on P2: gpt-5.4-mini-ptu ptu_shares None team_id team A

Case 2: who may call the deployment, on all three unified endpoints

  1. team A on /v1/chat/completions, /v1/responses and /v1/messages, then teams B and C on /v1/chat/completions, alternating P1 and P2
call c2-a-chat $P1 $TEAM_A_KEY chat 20 'Say hi'; call c2-a-responses $P2 $TEAM_A_KEY responses 20 'Say hi'
call c2-a-messages $P1 $TEAM_A_KEY messages 20 'Say hi'; call c2-b-chat $P2 $TEAM_B_KEY chat 20 'Say hi'; call c2-c-chat $P1 $TEAM_C_KEY chat 20 'Say hi'
c2-a-chat (sent 07:05:29Z, /v1/chat/completions) 07:05:31Z: HTTP 200, no ptu headers, group gpt-5.4-mini-ptu, usage in 8 out 5 total 13, finish_reason stop
c2-a-responses (sent 07:05:31Z, /v1/responses) 07:05:32Z: HTTP 200, no ptu headers, group gpt-5.4-mini-ptu, usage in 8 out 6 total 14
c2-a-messages (sent 07:05:32Z, /v1/messages) 07:05:34Z: HTTP 200, no ptu headers, group gpt-5.4-mini-ptu, usage in 8 out 5, stop_reason end_turn
c2-b-chat (sent 07:05:34Z, /v1/chat/completions) 07:05:34Z: HTTP 400, no ptu headers, "/chat/completions: Invalid model name passed in model=gpt-5.4-mini-ptu."
c2-c-chat (sent 07:05:34Z, /v1/chat/completions) 07:05:34Z: HTTP 400, no ptu headers, "/chat/completions: Invalid model name passed in model=gpt-5.4-mini-ptu."

Team B, which should hold 2 PTU, cannot call the deployment at all

Case 3: team A's share is a per-minute ceiling, and team B keeps its own

  1. in a fresh minute, team A asks for 4,000 output tokens (24,000 normalized, more than its 23,700), then team B and team A send small calls
call c3-a-4000 $P2 $TEAM_A_KEY chat 4000 'Say hi'; call c3-b-20 $P1 $TEAM_B_KEY chat 20 'Say hi'; call c3-a-20 $P2 $TEAM_A_KEY chat 20 'Say hi'
c3-a-4000 (sent 07:06:36Z, /v1/chat/completions) 07:06:37Z: HTTP 200, no ptu headers, group gpt-5.4-mini-ptu, usage in 8 out 5 total 13, finish_reason stop
c3-b-20 (sent 07:06:38Z, /v1/chat/completions) 07:06:38Z: HTTP 400, no ptu headers, "/chat/completions: Invalid model name passed in model=gpt-5.4-mini-ptu."
c3-a-20 (sent 07:06:38Z, /v1/chat/completions) 07:06:40Z: HTTP 200, no ptu headers, group gpt-5.4-mini-ptu, usage in 8 out 5 total 13, finish_reason stop

No ceiling applies to team A, and team B has no access

Case 4: a call that settles after its minute rolled over leaves the next minute alone

  1. at a fresh minute T0 one small call, at T0+50s a 3,900-token call ($LONG asks for a 3,000 word story) in the background, at T0+61s a small call in the next minute, and two more small calls once the long one returns
T0=$(date -u +%s); call c4-w1-open $P2 $TEAM_A_KEY chat 20 'Say hi'
while [ $(date -u +%s) -lt $((T0 + 50)) ]; do sleep 0.2; done; call c4-long $P1 $TEAM_A_KEY chat 3900 "$LONG" > c4_long.out & lpid=$!
while [ $(date -u +%s) -lt $((T0 + 61)) ]; do sleep 0.2; done; kill -0 $lpid && echo "long call still in flight at $(date -u +%TZ), opening the next minute"
call c4-w2-open $P2 $TEAM_A_KEY chat 20 'Say hi'; wait $lpid; cat c4_long.out
call c4-next-1 $P2 $TEAM_A_KEY chat 20 'Say hi'; call c4-next-2 $P1 $TEAM_A_KEY chat 20 'Say hi'
c4-w1-open (sent 07:07:42Z, /v1/chat/completions) 07:07:44Z: HTTP 200, no ptu headers, group gpt-5.4-mini-ptu, usage in 8 out 5 total 13, finish_reason stop
long call still in flight at 07:08:43Z, opening the next minute
c4-w2-open (sent 07:08:43Z, /v1/chat/completions) 07:08:44Z: HTTP 200, no ptu headers, group gpt-5.4-mini-ptu, usage in 8 out 5 total 13, finish_reason stop
c4-long (sent 07:08:32Z, /v1/chat/completions) 07:09:12Z: HTTP 200, no ptu headers, group gpt-5.4-mini-ptu, usage in 24 out 3900 total 3924, finish_reason length
c4-next-1 (sent 07:09:12Z, /v1/chat/completions) 07:09:13Z: HTTP 200, no ptu headers, group gpt-5.4-mini-ptu, usage in 8 out 5 total 13, finish_reason stop
c4-next-2 (sent 07:09:13Z, /v1/chat/completions) 07:09:17Z: HTTP 200, no ptu headers, group gpt-5.4-mini-ptu, usage in 8 out 5 total 13, finish_reason stop

No per-minute PTU accounting exists to check

Case 5: PTU-hours per team on today's usage

  1. 75 seconds after the last call, so the spend flush has run, read team A and team B on their own
one_team $TEAM_A_ID; one_team $TEAM_B_ID
GET /team/daily/activity?team_ids=<team A> on P2 for 2026-10-14: total_tokens 4042 | total_ptu_hours <field absent> | model_groups ptu_hours {'gpt-5.4-mini-ptu': '<field absent>'}
GET /team/daily/activity?team_ids=<team B> on P2 for 2026-10-14: total_tokens 0 | total_ptu_hours <field absent> | model_groups ptu_hours {'unknown-model': '<field absent>'}

Case 6: the daily rollup splits the flat cost by share, and moving a share does not bill a paid day again

  1. run the rollup for 2026-10-11 to 2026-10-13 and read the three teams back
$WT/.venv/bin/python run_rollup.py rollup 2026-10-11 2026-10-12 2026-10-13; activity 2026-10-11 2026-10-13
rollup 2026-10-11 models_processed=1, rows_written=1, rows_failed=0, lapsed=())
rollup 2026-10-12 models_processed=1, rows_written=1, rows_failed=0, lapsed=())
rollup 2026-10-13 models_processed=1, rows_written=1, rows_failed=0, lapsed=())
total_flat_cost 3600.0 | total_ptu_hours <field absent>
2026-10-11 flat_cost 1200.0 {'team A': 1200.0}
2026-10-12 flat_cost 1200.0 {'team A': 1200.0}
2026-10-13 flat_cost 1200.0 {'team A': 1200.0}
  1. the deployment declares no shares on this leg, so there is no share to move and step 2 of the after leg has nothing to run

Case 7: a split deployment day whose write fails for one team is billed to every team once the database recovers

  1. POST /model/new on P1 for a second deployment split team A 3, team C 2, live only on 2026-10-09
curl -s -o /dev/null -w 'HTTP %{http_code}' -X POST $P1/model/new "${MK[@]}" "${CT[@]}" -d "$(model_body gpt-5.4-mini-ptu-split "{\"$TEAM_A_ID\": 3, \"$TEAM_C_ID\": 2}" "" 2026-10-09T00:00:00Z 2026-10-10T00:00:00Z)"
HTTP 400
  1. with a BEFORE INSERT OR UPDATE trigger on LiteLLM_DailyTeamSpend raising for team C's flat-cost rows, run the rollup for 2026-10-09, drop the trigger, read the day, run the next backfill and read the day again
$WT/.venv/bin/python run_rollup.py rollup 2026-10-09; activity 2026-10-09 2026-10-09
$WT/.venv/bin/python run_rollup.py backfill 2026-10-14; activity 2026-10-09 2026-10-09
rollup 2026-10-09 models_processed=1, rows_written=0, rows_failed=0, lapsed=())
total_flat_cost 0.0 | total_ptu_hours <field absent>
backfill BackfillResult(start=datetime.date(2026, 10, 11), end=datetime.date(2026, 10, 13), days_scanned=3, rows_written=0, rows_failed=0)
total_flat_cost 0.0 | total_ptu_hours <field absent>

The split cannot be registered, so team C is never billed

Case 8: Team Usage page in the Admin UI

  1. open http://127.0.0.1:38511/ui/login and sign in as admin
  2. go to http://127.0.0.1:38511/ui/usage and pick Team Usage in the usage selector
  3. set the range to 9 Oct to 14 Oct
  4. type ptu-team-a in "Search teams by alias..." and pick it

All teams read $3,600.00 and team A reads $3,600.00, the whole flat cost on team A, with five tiles and no PTU figure

pr43043-8a97ba47d4-r2_before_all.png
pr43043-8a97ba47d4-r2_before_team_a.png

After (8a97ba4)

Setup

  1. the same commands as the before leg
POST /team/new on P1 ptu-team-a -> team A, POST /key/generate on P2 -> ptu-team-a-key
POST /team/new on P1 ptu-team-b -> team B, POST /key/generate on P2 -> ptu-team-b-key
POST /team/new on P1 ptu-team-c -> team C, POST /key/generate on P2 -> ptu-team-c-key

Case 1: register one PTU deployment split between team A (3 PTU) and team B (2 PTU)

  1. POST /model/new on P1 with ptu_shares and no team_id, then shares adding up to 4 of 5 PTU, then GET /model/info on P2 after its database poll
curl -s -w '\nHTTP %{http_code}' -X POST $P1/model/new "${MK[@]}" "${CT[@]}" -d "$(model_body gpt-5.4-mini-ptu "{\"$TEAM_A_ID\": 3, \"$TEAM_B_ID\": 2}" "" $EFF "")"
curl -s -w ' HTTP %{http_code}' -X POST $P1/model/new "${MK[@]}" "${CT[@]}" -d "$(model_body gpt-5.4-mini-ptu-bad "{\"$TEAM_A_ID\": 3, \"$TEAM_B_ID\": 1}" "" $EFF "")"
sleep 12; curl -s $P2/model/info "${MK[@]}"
POST /model/new on P1 with ptu_shares and no team_id: HTTP 200, {"model_id": "<returned>"}
POST /model/new on P1 with shares adding up to 4 of 5 PTU: HTTP 400 {"error":{"message":"ptu_shares must add up to ptu_count (4 of 5 allocated)","type":"auth_error","param":"None","code":"400"}}
GET /model/info on P2: gpt-5.4-mini-ptu ptu_shares {'team B': 2, 'team A': 3} team_id None

Case 2: who may call the deployment, on all three unified endpoints

  1. the same five calls as the before leg
c2-a-chat (sent 07:05:29Z, /v1/chat/completions) 07:05:32Z: HTTP 200, ptu 23579/23700, group gpt-5.4-mini-ptu, usage in 8 out 5 total 13, finish_reason stop
c2-a-responses (sent 07:05:32Z, /v1/responses) 07:05:33Z: HTTP 200, ptu 23541/23700, group gpt-5.4-mini-ptu, usage in 8 out 6 total 14
c2-a-messages (sent 07:05:33Z, /v1/messages) 07:05:34Z: HTTP 200, ptu 23497/23700, group gpt-5.4-mini-ptu, usage in 8 out 5, stop_reason end_turn
c2-b-chat (sent 07:05:34Z, /v1/chat/completions) 07:05:35Z: HTTP 200, ptu 15679/15800, group gpt-5.4-mini-ptu, usage in 8 out 5 total 13, finish_reason stop
c2-c-chat (sent 07:05:35Z, /v1/chat/completions) 07:05:36Z: HTTP 400, no ptu headers, "litellm.BadRequestError: Deployment gpt-5.4-mini-ptu is reserved for the teams holding a PTU share of it\n\nLiteLLM: model group 'gpt-5.4-mini-ptu' failed with the error above. No fallback was attempted."

Teams A and B are served, each against its own share, on every endpoint, and team C gets a 400 naming the reason

Case 3: team A's share is a per-minute ceiling, and team B keeps its own

  1. the same three calls as the before leg
c3-a-4000 (sent 07:06:38Z, /v1/chat/completions) 07:06:38Z: HTTP 429, retry-after 60, no ptu headers, "Rate limit exceeded for model_per_team_ptu: team A:gpt-5.4-mini-ptu. Limit type: tokens. Current limit: 23700, Remaining: 23700. Limit resets at: 2026-10-14 07:07:38 UTC"
c3-b-20 (sent 07:06:38Z, /v1/chat/completions) 07:06:49Z: HTTP 200, ptu 15679/15800, group gpt-5.4-mini-ptu, usage in 8 out 5 total 13, finish_reason stop
c3-a-20 (sent 07:06:49Z, /v1/chat/completions) 07:06:50Z: HTTP 200, ptu 23579/23700, group gpt-5.4-mini-ptu, usage in 8 out 5 total 13, finish_reason stop

The 24,000-token reservation is refused before it reaches Azure, team B is unaffected, and a call that fits is still served

Case 4: a call that settles after its minute rolled over leaves the next minute alone

  1. the same calls and timing as the before leg
c4-w1-open (sent 07:07:52Z, /v1/chat/completions) 07:07:53Z: HTTP 200, ptu 23579/23700, group gpt-5.4-mini-ptu, usage in 8 out 5 total 13, finish_reason stop
long call still in flight at 07:08:53Z, opening the next minute
c4-w2-open (sent 07:08:53Z, /v1/chat/completions) 07:08:54Z: HTTP 200, ptu 23579/23700, group gpt-5.4-mini-ptu, usage in 8 out 5 total 13, finish_reason stop
c4-long (sent 07:08:42Z, /v1/chat/completions) 07:09:23Z: HTTP 200, ptu 244/23700, group gpt-5.4-mini-ptu, usage in 24 out 3900 total 3924, finish_reason length
c4-next-1 (sent 07:09:23Z, /v1/chat/completions) 07:09:24Z: HTTP 200, ptu 23541/23700, group gpt-5.4-mini-ptu, usage in 8 out 5 total 13, finish_reason stop
c4-next-2 (sent 07:09:24Z, /v1/chat/completions) 07:09:25Z: HTTP 200, ptu 23503/23700, group gpt-5.4-mini-ptu, usage in 8 out 5 total 13, finish_reason stop

The long call took its reservation from the first minute (244 left) and settled in the second. The next two calls read 23,541 and 23,503, which leaves room only for c4-w2-open and their own reservations, so the late settlement did not move the second minute's counter

Case 5: PTU-hours per team on today's usage

  1. the same two reads as the before leg
GET /team/daily/activity?team_ids=<team A> on P2 for 2026-10-14: total_tokens 4029 | total_ptu_hours 0.050071729957805906 | model_groups ptu_hours {'gpt-5.4-mini-ptu': 0.050071729957805906}
GET /team/daily/activity?team_ids=<team B> on P2 for 2026-10-14: total_tokens 26 | total_ptu_hours 0.00016033755274261603 | model_groups ptu_hours {'gpt-5.4-mini-ptu': 0.00016033755274261603}

Case 6: the daily rollup splits the flat cost by share, and moving a share does not bill a paid day again

  1. the same rollup and read as the before leg
rollup 2026-10-11 models_processed=1, rows_written=2, rows_failed=0, lapsed=())
rollup 2026-10-12 models_processed=1, rows_written=2, rows_failed=0, lapsed=())
rollup 2026-10-13 models_processed=1, rows_written=2, rows_failed=0, lapsed=())
total_flat_cost 3600.0 | total_ptu_hours 0.0
2026-10-11 flat_cost 1200.0 {'team A': 720.0, 'team B': 480.0}
2026-10-12 flat_cost 1200.0 {'team A': 720.0, 'team B': 480.0}
2026-10-13 flat_cost 1200.0 {'team A': 720.0, 'team B': 480.0}
  1. PATCH the deployment on P1 to move team B's 2 PTU to team C, run the next backfill and read the same days again
curl -s -o /dev/null -w 'HTTP %{http_code}' -X PATCH "$P1/model/$MODEL_ID/update" "${MK[@]}" "${CT[@]}" -d "{\"model_info\": {\"base_model\": \"gpt-5.4-mini\", \"ptu_count\": 5, \"cost_per_ptu_per_hour\": 10.0, \"ptu_effective_from\": \"$EFF\", \"ptu_shares\": {\"$TEAM_A_ID\": 3, \"$TEAM_C_ID\": 2}}}"
sleep 12; $WT/.venv/bin/python run_rollup.py backfill 2026-10-14; activity 2026-10-11 2026-10-13
HTTP 200
backfill BackfillResult(start=datetime.date(2026, 10, 11), end=datetime.date(2026, 10, 13), days_scanned=3, rows_written=0, rows_failed=0)
total_flat_cost 3600.0 | total_ptu_hours 0.0
2026-10-11 flat_cost 1200.0 {'team A': 720.0, 'team B': 480.0}
2026-10-12 flat_cost 1200.0 {'team A': 720.0, 'team B': 480.0}
2026-10-13 flat_cost 1200.0 {'team A': 720.0, 'team B': 480.0}

Each day is split 720 and 480 by share, and team C is not billed again for days team B already paid

Case 7: a split deployment day whose write fails for one team is billed to every team once the database recovers

  1. the same POST as the before leg
HTTP 200
  1. the same fault, rollup, reads and backfill as the before leg
PTU rollup: write failed after 3 attempts for model=gpt-5.4-mini-ptu-split teams=team A,team C day=2026-10-09
rollup 2026-10-09 models_processed=2, rows_written=0, rows_failed=2, lapsed=('gpt-5.4-mini-ptu-split',))
total_flat_cost 0.0 | total_ptu_hours 0.0
backfill BackfillResult(start=datetime.date(2026, 10, 9), end=datetime.date(2026, 10, 13), days_scanned=5, rows_written=2, rows_failed=0)
total_flat_cost 1200.0 | total_ptu_hours 0.0
2026-10-09 flat_cost 1200.0 {'team A': 720.0, 'team C': 480.0}

The failed day wrote nothing for either team, so the backfill billed both once the database recovered

Case 8: Team Usage page in the Admin UI

  1. the same steps as the before leg, on this leg's P1 at http://127.0.0.1:49960

All teams read $4,800.00 (the three split days plus the 2026-10-09 day) with a PTU Hours tile of 0.05, and team A reads $2,880.00, four days at $720, with the same tile

pr43043-8a97ba47d4-r2_after_all.png
pr43043-8a97ba47d4-r2_after_team_a.png

Surprises

  • Over-share request gets 429 with retry-after, caused by this PR

Earlier rounds as controls

At 2d1d021 a settlement landing after its minute rolled over moved the next minute's counter, which Case 4 now covers. At 17abc6e a split day whose write failed for one team kept the other team's row, so the backfill counted the day as priced and never billed the missing team, which Case 7 now covers

Recent commits

17abc6e guards every PTU settle and refund by the window the request reserved in, the same guard project ITPM/OTPM already use, and keys the backfill by deployment-day, so moving a share no longer bills the new holder for days already paid

1a4b393 writes each deployment-day's team charges in one Prisma batch with the retry around the whole batch, so a day is fully written or not at all (design decision 18)

8a97ba4 merges main at aa879f7 with no conflicts. Main touched no PR file in that range except the generated schema.d.ts, and the PR diff is unchanged

Type

🆕 New Feature

Caveats (if any)

Medium

  • Uncapped requests from a shared team get max_tokens = min(1024, a quarter of the share in output tokens, the ceiling divided by the model's output ratio)
    • The same output floor the proxy applies under any small TPM limit
    • Send max_tokens yourself to pick the cap; capped requests are untouched
  • Per-key, per-user, per-tag and per-deployment usage rows report ptu_hours: 0
    • Only team rows and breakdown.model_groups know their model group
  • Models missing an Azure sizing row get no ceiling or PTU-hours
    • Registration logs a warning naming the model
  • Daily team rows are keyed by model, not model group
    • The table's unique key predates this PR
    • A chat completions request by alias, deployment id or provider model lands under the group row too, while responses and messages requests keep the id or provider model they named
    • Two groups on one model merge into the first-written row
    • That row's PTU-hours are sized by the surviving group
    • A team reaching its reservation and an open route on the same model with one key reads the PTU-hours of whichever name was flushed first
    • One model group per underlying model keeps the figure exact

Low

  • The Admin UI has no ptu_shares editor yet
    • Set it through config.yaml or POST /model/new
  • A shared deployment still lists in GET /v1/models for share-less teams, and GET /health?model=<group> from such a team still probes it once
    • The refusal comes at request time as a 400; the health probe is the owner rule only, which the shares do not extend
  • Open deployments in a shared group count toward the ceiling
    • Their tokens count toward PTU-hours too
  • A page spanning teams sizes a group mixing models, or a provider-model row, by its first reserved deployment
    • One team's page uses the deployment that team is served from, so the two pages can differ on such a row
  • PTU-hours are computed at read time from the shipped sizing table
    • A later release updating a model's row moves past days' figures
    • Stored tokens and the flat cost do not move
  • The Team Usage PTU Hours tile rounds to two decimals
    • GET /team/daily/activity carries the full value
  • ptu_shares has no cap on how many teams it names, and the team existence check looks them up in chunks under the database's IN list bound
  • A deployment one team already owns through team_id is split by deleting it and registering it again with ptu_shares and the same model_info.id, not by a PATCH, which refuses the mix with a 400 (design decision 16, documented)
  • A multi-team GET /team/daily/activity query reports ptu_hours: 0 under breakdown.entities.<team_id>
    • The date-level total and a single-team query carry the real figure
  • With fail_closed_rate_limit_enforcement: true and Redis down, share-holding teams get a 503 on the shared deployment
  • Deleting a team that holds a share leaves its share on the deployment and its flat cost keeps rolling up under the deleted id
    • PATCH /model/{id}/update with a ptu_shares map summing to ptu_count reassigns it
  • LITELLM_ENABLE_PTU_COST_ATTRIBUTION is read from the process environment only (design decision 12)
    • A value held only in a secret manager is no longer read; set it in the pod environment
  • ptu_shares in config.yaml is not checked against the team table, only POST /model/new and the PATCH are
    • A config share naming a team that does not exist boots and accrues that team's flat cost
  • The master key gets the 400 on a shared deployment (design decision 2)
    • Use a key in a holder team for smoke calls and the playground
  • A single-team reservation registered beside a shared registration of the same Azure deployment charges that team for both
    • Register each Azure reservation once
  • A share-less team whose group has fallbacks is served by the fallback group instead of getting the 400
  • A share-less team naming a name a shared wildcard matches gets the 400 even when an open * route also matches it
    • The router picks the most specific pattern and does not fall through to a broader one
  • A share-less team calling a shared deployment through the /azure pass-through gets a 500 instead of the 400, and nothing reaches Azure
    • Pre-existing: that route answers 500 for any name the router cannot serve, on main too
  • A refusal by deployment id names the provider model, and one by routing group calls the group a deployment
  • A provider-model request served by an open deployment in another group still counts against the team's share (design decision 17)
  • A ptu_shares map written to the model table before this release, by a client posting the then-unknown key, is enforced at load: the deployment serves only the teams it names, sized from their shares, with no flat cost attributed until its terms exist
    • PATCH /model/{id}/update with ptu_count, cost_per_ptu_per_hour and ptu_effective_from completes it, or delete and re-register the deployment without the map
    • The database poll admits such a row without a log line, and a row registered with its terms and then stripped by a direct table write keeps the zero per-token prices POST /model/new wrote, so it bills nothing on either path until the PATCH
  • A PTU deployment registered through POST /model/new while the flag was on serves for free once the flag is off: its token prices were zeroed at registration and no flat cost accrues without the flag, while a config.yaml entry keeps its token prices
    • Pre-existing for team_id reservations, now also the case for shared ones; turn the flag back on or re-register the deployment
  • A config.yaml entry declaring ptu_shares without ptu_count and cost_per_ptu_per_hour is dropped at load, flag on or off: the startup log names the model and the reason, the proxy starts without it, and a request to its group gets a 400 with no healthy deployments
    • The same handling a malformed PTU entry already gets with the flag on; nothing in config.yaml stops the proxy from starting
  • A fallback from another group into a shared PTU group is not checked against the team's ceiling, since the limiter runs once before routing
    • Per-model team TPM limits on main behave the same way; checking each fallback hop is a separate limiter change
  • POST /v1/messages without max_tokens is a 500 from the Anthropic handler on main and on this branch for a team under no ceiling; under a ceiling this branch writes the implicit cap before the handler runs, so the same call is 200
    • Pre-existing and unchanged here; a 400 naming the field is tracked separately
  • osv-scan fails on the tip for an upstream advisory on a dashboard dev dependency with no fixed release, in a lockfile this PR does not touch; it is not a required check
  • The usage export carries no PTU-hours column
    • Main's export streams rows server-side with no summary object, so the column this PR had added was dropped in the second merge of main
    • The Team Usage page and GET /team/daily/activity carry the figure

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

  • b41eed4 passes /live-pr-risk

  • e4b33bc passes /live-pr-risk

  • 6f3915b passes /live-pr-risk


Note

High Risk
Changes request routing, per-team rate limiting in normalized tokens, and daily cost rollup for reserved capacity—mistakes could block traffic, mis-attribute spend, or leak capacity across teams.

Overview
Adds model_info.ptu_shares so one Azure PTU deployment can split whole PTUs across teams (exclusive with team_id), with validation that shares sum to ptu_count and that named teams exist on model create/update.

Routing & limits: Share-holding teams only get routed to split deployments; the parallel request limiter enforces a per-team model_per_team_ptu ceiling in Azure normalized tokens (reservation, success/failure settlement, and window-aware refunds). New litellm/llms/azure/ptu_capacity.py and router_utils/ptu_shares.py drive ceilings and access; the router adds share filtering, wildcard resolution order, and sizing warnings.

Cost & reporting: Daily flat-cost rollup writes one sentinel row per team share per deployment-day (batched upserts). Team daily activity APIs attach ptu_hours from stored tokens when PTU attribution is on (ptu_consumption.py). PTUTerms now carries a shares map instead of a single team_id.

Ops: LITELLM_ENABLE_PTU_COST_ATTRIBUTION is read from the process environment only (not the secret manager). OpenAPI and spend metrics types gain ptu_hours / total_ptu_hours.

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

A PTU deployment's model_info takes ptu_shares (team_id -> whole PTUs adding up to
ptu_count) in place of a single team_id. With LITELLM_ENABLE_PTU_COST_ATTRIBUTION on,
routing hides the deployment from teams holding no share, the v3 limiter enforces each
share as a per-team TPM ceiling from Azure's per-model TPM-per-PTU sizing, the daily
flat-cost rollup splits the hourly cost by share, and /team/daily/activity plus the Team
Usage page report PTU-hours next to tokens.
@devin-ai-integration

devin-ai-integration Bot commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor Author

I'll fix CI failures and address comments from users with write access. I'll skip comments containing "(aside)".

  • Disable automatic comment, CI, and merge conflict monitoring

@greptile-apps

greptile-apps Bot commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 2/5

[High impact] Splits Azure PTU deployments across teams with per-team ceilings and usage tracking.

Fix the PTU ceiling gaps and duplicate historical charges before merging, and bring the new tests into line with the unit-test guide.

Findings

  1. P1 Late refunds reopen capacity ▶
  2. P1 Reassigned shares duplicate old charges ▶
  3. P1 Fallback skips the PTU ceiling ▶
  4. P2 Tests skip the real query ▶
  5. P2 Tests read the real clock ▶

Summary

Adds ptu_shares to split an Azure reservation across teams, enforce per-team ceilings, divide flat cost, and show PTU-hours in team usage.

  • Teams can share one PTU deployment through whole-PTU allocations.
  • Each team’s PTU share sets its own Azure-weighted limit per minute.
  • Team usage reports PTU-hours alongside tokens and cost.

Reviews (27) · Last reviewed commit: "fix(proxy): accept whole-number PTU shar..." · Reviewed by Greptile

Comment thread litellm/router.py Outdated
Comment thread litellm/proxy/hooks/parallel_request_limiter_v3.py
Comment thread litellm/proxy/hooks/parallel_request_limiter_v3.py Outdated
Comment thread litellm/proxy/hooks/parallel_request_limiter_v3.py
Comment thread litellm/router_utils/ptu_shares.py
Comment thread litellm/proxy/management_endpoints/team_endpoints.py Outdated
Comment thread litellm/llms/azure/ptu_capacity.py
Comment thread litellm/router.py
Comment thread ui/litellm-dashboard/src/components/EntityUsageExport/utils.ts Outdated
…r_team

# Conflicts:
#	tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py
@codspeed

codspeed Bot commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

⚠️ 1 benchmark spent significant time in system calls

System calls cannot be consistently instrumented, so they are not included in the measure, which understates the real cost. Please switch to the Walltime instrument to accurately measure system calls.

Measurement and system calls

✅ 31 untouched benchmarks


Comparing litellm_ptu_shares_per_team (8a97ba4) with main (aa879f7)1

Open in CodSpeed

Footnotes

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

@codecov

codecov Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

…deployments by public name

The PTU flag is read from the process environment on every check instead of
through the secret-manager path, so a proxy with a hosted secret manager does
not pay a round trip per request. A team's ceiling now carries the model's
cached-input ratio and settlement charges cache reads at that ratio, matching
how Azure sizes a PTU. A deployment registered for one team is found by its
team_public_model_name for PTU-hours and the ceiling, since its model_name is
rewritten to a unique internal name. ptu_shares are StrictInt on ModelInfo and
parsed_ptu_shares refuses booleans, strings, and non-string team ids, a
fractional ptu_count reserves nothing, and models_processed counts distinct
deployments rather than holdings.
…ting flag-independent, and warn only shared or Azure reservations
@mateo-berri

Copy link
Copy Markdown
Contributor

@greptileai

@mateo-berri

Copy link
Copy Markdown
Contributor

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.

Stale Bugbot comment from a previous run.

Comment thread litellm/proxy/hooks/parallel_request_limiter_v3.py
@greptile-apps

greptile-apps Bot commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Comments Outside Diff

These findings could not be posted inline.

  • P1 Security Unescaped Team IDs in CSV litellm/proxy/management_endpoints/team_endpoints.py:6885 ▶

    A team can have a caller-supplied ID beginning with =, +, -, or @. The new CSV export escapes the team alias but writes the ID unchanged, so opening the export in a spreadsheet can evaluate that ID as a formula. Escape the Team ID field too.

    How this was verified: Team creation accepts caller-supplied IDs, and the CSV writer escapes the alias but not the exported ID.

            "Team ID": _csv_safe(row.team_id),
    
  • P2 Key Search Silently Omits Matches litellm/proxy/management_endpoints/team_endpoints.py:7093 ▶

    When more than USAGE_TOP_API_KEYS_LIMIT keys match a search, this query returns only the highest-spend matches. The same cap applies to user key search, but the UI does not indicate that either result is incomplete. Users therefore cannot find lower-spend matching keys, even though search is meant to reach beyond the initially loaded keys. Return truncation information or allow users to page through matches.

    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!

…aw output tokens and escape the exported team id
@mateo-berri

Copy link
Copy Markdown
Contributor

@greptileai

@mateo-berri

Copy link
Copy Markdown
Contributor

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.

Stale Bugbot comment from a previous run.

@mateo-berri

Copy link
Copy Markdown
Contributor

@greptileai

Comment thread litellm/router_utils/ptu_shares.py
Comment thread litellm/proxy/management_endpoints/model_management_endpoints.py Outdated
@mateo-berri

Copy link
Copy Markdown
Contributor

@greptileai

1 similar comment
@mateo-berri

Copy link
Copy Markdown
Contributor

@greptileai

Comment thread litellm/proxy/management_endpoints/ptu_consumption.py Outdated
…r picks, and a deployment id before any wildcard

A team's wildcard reservation behind an open proxy-wide * route was charged and sized for traffic the * route served, and naming a shared deployment by id next to a catch-all wildcard escaped its per-team ceiling. Router.wildcard_route_deployments now owns the most-specific-proxy-wide-else-team precedence, and both the limiter and the consumption page resolve through it after listed rows and deployment ids.
@mateo-berri

Copy link
Copy Markdown
Contributor

@greptileai

@mateo-berri

Copy link
Copy Markdown
Contributor

@greptileai

@mateo-berri

Copy link
Copy Markdown
Contributor

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.

Stale Bugbot comment from a previous run.

# Conflicts:
#	litellm/types/router.py
…count already does

A share of 2.0 now reads as 2 on POST /model/new and in config.yaml, where it was
refused with a 422 and dropped at load. Fractional, infinite, boolean and string
shares stay refused. Adds unit tests for the malformed-rate, provider-over-prefix,
zero-token Responses usage, fractional-count and sizing-warning paths.
@mateo-berri

Copy link
Copy Markdown
Contributor

@greptileai

Comment on lines +5215 to +5221
return self._build_reservation_aware_tpm_ops(
targets=reserved_ptu_scopes,
reserved_scopes=reserved_scopes,
actual_tokens=self._ptu_settlement_tokens(
reserved_ceiling, self._resolve_reconciled_usage(usage_source), total_tokens
),
reserved_tokens=reserved_tokens,

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 Late refunds reopen capacity

A PTU request that finishes after its minute expires can refund tokens into the next minute. _build_team_ptu_tpm_ops subtracts the old reservation from the current counter without checking which minute owns it. If another request has started a new window, the late refund reduces that request's counter and lets the team exceed its share.

Save the window returned at admission and check it before applying settlement or refunds, as the project token limits already do.

Comment on lines +185 to +191
team_id=team_id,
ptu_count=share,
cost_per_ptu_per_hour=terms.cost_per_ptu_per_hour,
effective_from=terms.effective_from,
effective_to=terms.effective_to,
)
for team_id, share in sorted(terms.shares.items())

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 Reassigned shares duplicate old charges

Reassigning a share to a new team can add a second charge to already billed days. _parse_ptu_models gives the new holder the deployment's original effective_from. The nightly backfill adds that holder's missing historical charges while retaining the former holder's rows.

For example, changing a 30/20 split from A/B to A/C makes older days carry 30 PTUs for A, 20 for B, and another 20 for C. Keep already attributed deployment-days unchanged when backfilling a changed split, or store when each split takes effect.

Comment on lines +3539 to +3544
model: Final = self._rate_limited_model(requested_model)
if model is None or not user_api_key_dict.team_id:
return
ceiling: Final = self._ptu_team_ceiling_resolver(user_api_key_dict.team_id, model.group)
if ceiling is None:
return

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 Fallback skips the PTU ceiling

A fallback from an open model to a shared PTU group skips the group's ceiling at admission. _add_team_ptu_rate_limit_descriptor checks only the original requested model. Upstream failures switch groups inside run_async_fallback, which calls the router again without rerunning the proxy's limiter hook. A holder can therefore reach its shared deployment even when its PTU counter is full.

Check and reserve the selected shared group's ceiling before each fallback attempt, rather than only before the original request.

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.

Team per-model TPM limits on main also check only the requested model. Re-checking fallback hops is a separate limiter change, listed in Caveats

page = _ptu_activity_page()

with (
patch("litellm.proxy.management_endpoints.team_endpoints.get_daily_activity", AsyncMock(return_value=page)),

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 Tests skip the real query

The new team activity tests replace LiteLLM's own get_daily_activity with an AsyncMock. The unit-test guide says “No doubles on our own code.” These tests cannot catch a change that stops the real query from supplying the model-group rows PTU reporting needs.

Use the real activity builder with a fake database boundary before merging to satisfy the repository requirement. The same pattern appears at lines 17156 and 17188.

File Used: tests/unit/AGENTS.md (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!

return ModelResponse(
id="ptu-share",
object="chat.completion",
created=int(datetime.now().timestamp()),

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 Tests read the real clock

_ptu_response reads the real clock even though the new tests need only a fixed response timestamp. The unit-test guide requires “No network, clock or subprocess.” Use a fixed created value before merging to satisfy that requirement and keep these tests independent of the machine's time.

File Used: tests/unit/AGENTS.md (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!

requested_model=requested_model if isinstance(requested_model, str) else None,
descriptors=descriptors,
)
self._add_team_ptu_rate_limit_descriptor(

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.

Low: Native Azure requests bypass PTU ceilings

Native Azure inference (POST /azure/openai/deployments/<group>/chat/completions) goes through _relay_router_model → llm_router.allm_passthrough_route without running this proxy pre-call limiter. A share-holding team can therefore keep consuming the deployment after exhausting its PTU ceiling; apply the same PTU reservation before forwarding native requests and reconcile it when they complete.

servable: Final = team_servable_deployments(
routed_deployments(listed_rows, deployments, requested_model, wildcard_route), team_id
)
return next(

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.

Medium: Routing groups charge the wrong PTU allowance

With shared groups A and B, A first in model_list, and a callable routing group that prioritizes B, this next(...) resolves the request to A's ceiling even though the router selects B. An authorized caller can exhaust B through its direct model name and continue consuming B through the routing-group name because admission and settlement charge A instead; reserve against the selected deployment's PTU scope, or enforce the relevant candidate scopes.

at the model's cached ratio, output weighted by the output-to-input ratio."""
cached: Final = min(max(cache_read_tokens, 0), max(prompt_tokens, 0))
uncached: Final = max(prompt_tokens, 0) - cached
return uncached + weights.cached_input_ratio * cached + weights.output_to_input_ratio * max(completion_tokens, 0)

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.

Low: Long-context GPT-6 requests undercount PTU usage

Azure's long-context GPT-6 PTU weights are 2 for uncached input and 7.5 for output, but this normalizer always uses input weight 1 and the table's output weight 5. A caller with a 200-PTU GPT-6 Sol share (600,000 normalized tokens/minute) can submit 400,000 uncached input tokens with a 10,000-token output cap: the current estimator reserves 550,000 and settlement charges 450,000, while that usage consumes 875,000 under the long-context weights. Apply context-tier weights consistently to admission, settlement, and PTU-hour reporting.

@veria-ai

veria-ai Bot commented Oct 14, 2026

Copy link
Copy Markdown
Contributor

PR overview

This PR adds support for sharing Azure PTU deployments across teams, with per-team PTU ceilings and PTU-hour usage reporting.

Authorized teams can exceed their PTU allocations through native Azure requests or routing groups that charge the wrong deployment's allowance, consuming shared capacity beyond their intended limits. Long-context GPT-6 usage is also undercounted, weakening ceiling enforcement and understating PTU-hour usage. All three issues remain open, with none addressed yet.

Open issues (3)

Fixed/addressed: 0 · PR risk: 5/10

@mateo-berri

Copy link
Copy Markdown
Contributor

@litereview code-review

1 similar comment
@mateo-berri

Copy link
Copy Markdown
Contributor

@litereview code-review

@litereview

litereview Bot commented Oct 14, 2026

Copy link
Copy Markdown

Review already requested

code-review is already reviewing 17abc6e26c58. View existing review.

@litereview

litereview Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

✅ code-review

5/5 · Ready for human review

Scope: full · Full PR review.

No correctness findings.

The earlier backfill finding is fixed. Each deployment-day's team rows now go through one Prisma batch transaction, and a regression test covers a partial failure. The 4 failures in test_router.py come from the missing semantic_router module in this sandbox. None of them touches PTU code.

Checks and execution details
  • Passed · Real testing evidence (required): Not applicable: no LLM translation changes in the scoped paths. The only scoped file is the new litellm/llms/azure/ptu_capacity.py. It holds an Azure PTU sizing table plus helpers (normalized_tokens, ptu_hours, deployment_ptu_capacity). The proxy rate limiter and usage reporting call these helpers. Nothing in this file changes provider requests, messages, parameters, tool calls, responses, streaming, usage translation, or errors. The PR changes nothing under litellm/litellm_core_utils/prompt_templates/**.
  • Passed · PTU unit/integration suites (optional): Ran uv run pytest -n 8 on these files: test_ptu_pricing.py, test_azure_ptu_capacity.py, test_ptu_shares.py, test_ptu_consumption.py, test_ptu_model_settings.py, test_ptu_feature_flag.py, test_ptu_flat_cost_rollup.py, test_tpm_concurrent.py, test_daily_activity_routes.py and test_router_model_cost_isolation.py. Result: 784 passed.
  • Passed · Limiter, team endpoints, router suites (optional): test_parallel_request_limiter_v3.py, test_team_endpoints.py, test_router.py: 1805 passed, 11 skipped, 4 failed. All 4 failures are TestAutoRouterTraceProvenance cases. They raise ModuleNotFoundError: No module named 'semantic_router' in auto_router.py:83. That optional dependency is missing from this sandbox, and the tests are unrelated to this PR.

View review · Reviewed 8a97ba47d44a

return tuple(start + timedelta(days=offset) for offset in range((end - start).days + 1))


def _unpriced_charges(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium · Backfill skips a split deployment's day when one team's charge failed to write

  1. An admin splits a deployment with ptu_shares: {"team-a": 30, "team-b": 20}. The daily rollup then writes one sentinel row per team.
  2. One night, team A's upsert lands. Team B's upsert uses up its retries because of a transient DB error. The rollup alerts and moves on.
  3. The scheduled catch-up pass (run_ptu_flat_cost_backfill) treats a day as priced once a sentinel row exists for that deployment id and date for any team. This PR dropped team_id from _existing_sentinel_keys, and _unpriced_charges filters on (model_id, date).
  4. Team A's row marks the day as priced, so the backfill never writes team B's 480 charge. Team B's usage keeps showing no flat cost for that day unless someone notices the alert and reruns that date by hand. Before this PR, a failed write left no row behind, and the next night's backfill filled the day in automatically.

You can keep the reassignment guarantee and still catch partial days: treat (model_id, date) as priced only when the stored rows' ptu_flat_cost adds up to the deployment's full daily cost. Another option is to record the share map that was billed.

Evidence

Reproducer: a temporary test in tests/unit/proxy/spend_tracking/ that reuses the existing _FakeSentinelTable and _prisma_for helpers. It seeds only ("team-a", 2026-07-30, "shared") for a 30/20 split effective from that day, then calls run_ptu_flat_cost_backfill(prisma, today=TODAY).

Observed output: upserts: [] rows_written: 0. The assertion that team-b's row gets written fails.

_existing_sentinel_keys now returns frozenset[(model, date)], and _unpriced_charges checks (charge.model_id, day.isoformat()) not in priced.

_run_and_alert shows the backfill as the automatic path for unpriced days: if target_date is None: await _backfill_and_alert(...).

The PR adds test_backfill_does_not_bill_a_priced_day_again_after_its_shares_moved_to_other_teams, which covers reassignment. No test covers a partial write within one split day.

Latest summary by code-review · View this review · Reviewed 17abc6e26c58

…action

The backfill counts a deployment day as priced once any team's row exists for it,
so a rollup that wrote team A's share and lost team C's left team C unbilled for
good. Each deployment day's team rows now go through one Prisma batch with the
retry around the whole batch, so a day is fully written or not written at all and
the next backfill prices it for every team.
@mateo-berri

Copy link
Copy Markdown
Contributor

@litereview code-review

@mateo-berri

Copy link
Copy Markdown
Contributor

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.

Stale Bugbot comment from a previous run.

@mateo-berri mateo-berri reopened this Oct 14, 2026
@mateo-berri

Copy link
Copy Markdown
Contributor

@litereview code-review

@mateo-berri

Copy link
Copy Markdown
Contributor

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

This branch has not been deployed

No deployments
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