Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
2d49656
feat(proxy): split Azure PTU deployments across teams by share
mateo-berri Sep 24, 2026
95e3cae
Merge remote-tracking branch 'origin/main' into litellm_ptu_shares_pe…
mateo-berri Sep 24, 2026
304e9d2
fix(proxy): weight cached input in PTU ceilings and size team-scoped …
mateo-berri Sep 24, 2026
0d80930
fix(proxy): reserve PTU ceilings in normalized tokens, keep share rou…
mateo-berri Sep 24, 2026
4ad0db3
fix(proxy): size the uncapped output floor against the PTU share in r…
mateo-berri Sep 24, 2026
6a24e7f
fix(ptu): refuse ptu_shares naming a team that does not exist
mateo-berri Sep 25, 2026
00f6210
refactor(ptu): keep Azure detection under llms/azure and check share …
mateo-berri Sep 25, 2026
e9c8e62
Merge remote-tracking branch 'origin/main' into litellm_ptu_shares_pe…
mateo-berri Sep 25, 2026
77e7250
Merge remote-tracking branch 'origin/main' into litellm_ptu_shares_pe…
mateo-berri Sep 28, 2026
48c0be3
fix(proxy): drop the reverted TeamIdSearchFilter import after merging…
mateo-berri Sep 28, 2026
d1f698f
fix(proxy): chunk the ptu_shares team lookup under the IN-list bound
mateo-berri Sep 28, 2026
ea8b63d
fix(proxy): count a request naming a shared PTU deployment directly a…
mateo-berri Sep 29, 2026
b41eed4
fix(proxy): resolve every name a shared PTU deployment is served unde…
mateo-berri Sep 29, 2026
f8425f3
Merge remote-tracking branch 'origin/main' into litellm_ptu_shares_pe…
mateo-berri Oct 1, 2026
d2eda3a
fix(ptu): refuse ptu_shares declared without ptu_count and cost_per_p…
mateo-berri Oct 1, 2026
5edea75
Merge remote-tracking branch 'origin/main' into litellm_ptu_shares_pe…
mateo-berri Oct 2, 2026
96a5eee
fix(ui): seed total_ptu_hours in the empty daily activity metadata
mateo-berri Oct 2, 2026
d0662cd
Merge remote-tracking branch 'origin/main' into litellm_ptu_shares_pe…
mateo-berri Oct 2, 2026
4a5828e
fix(ptu): size usage rows by the name the request used
mateo-berri Oct 2, 2026
5286e6c
fix(ptu): size a team's usage rows by the deployment it is served from
mateo-berri Oct 2, 2026
e65e12d
fix(ptu): never size a team's usage row by another team's reservation
mateo-berri Oct 2, 2026
495cf74
fix(ptu): reserve an uncapped request against the cap the proxy write…
mateo-berri Oct 3, 2026
6fb24d1
fix(ptu): read a share map only through the terms registration requires
mateo-berri Oct 3, 2026
e4b33bc
fix(ptu): keep a declared split as the access rule and check a config…
mateo-berri Oct 3, 2026
dbb0cf2
Merge branch 'main' into litellm_ptu_shares_per_team
mateo-berri Oct 9, 2026
c4874bd
fix(ptu): charge wildcard-served names, pass-through usage, and team-…
mateo-berri Oct 9, 2026
e076b47
fix(ptu): resolve a wildcard-served name to the one pattern the route…
mateo-berri Oct 10, 2026
6f3915b
test(router): pin the wildcard route precedence that PTU sizing and t…
mateo-berri Oct 10, 2026
b576bab
Merge remote-tracking branch 'origin/main' into HEAD
mateo-berri Oct 10, 2026
c46fb5d
fix(proxy): accept whole-number PTU shares written as floats, as ptu_…
mateo-berri Oct 10, 2026
2d1d021
Merge remote-tracking branch 'origin/main' into litellm_ptu_shares_pe…
mateo-berri Oct 14, 2026
17abc6e
fix(proxy): settle team PTU reservations in their own minute and keep…
mateo-berri Oct 14, 2026
1a4b393
fix(proxy): write each PTU deployment day's team charges in one trans…
mateo-berri Oct 14, 2026
8a97ba4
Merge remote-tracking branch 'origin/main' into litellm_ptu_shares_pe…
mateo-berri Oct 14, 2026
b70f6f7
fix(proxy): read the PTU flag from a secret manager, serve share hold…
mateo-berri Oct 14, 2026
e3492ae
test(router): list the cached PTU ceiling helpers in the router cover…
mateo-berri Oct 14, 2026
a5f9321
fix(proxy): read the PTU flag only for share holders, guard org and S…
mateo-berri Oct 14, 2026
6d9a3d0
fix(proxy): bill a rerun PTU day to the current share holders alone
mateo-berri Oct 14, 2026
4d5903d
fix(proxy): replace a PTU day's rows by day and deployment, not a neg…
mateo-berri Oct 14, 2026
ff7b7f0
fix(proxy): read the PTU flag from the secret manager first, like get…
mateo-berri Oct 14, 2026
1251658
fix(router): scope retry health lookups to the caller only for PTU sh…
mateo-berri Oct 14, 2026
109f9bc
fix(router): move the share-holder retry check into ptu_shares
mateo-berri Oct 14, 2026
c106f93
test(integration): cover PTU shares across routing, delete guards, co…
mateo-berri Oct 14, 2026
cdce71e
Merge remote-tracking branch 'origin/main' into litellm_ptu_shares_pe…
mateo-berri Oct 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions litellm/litellm_core_utils/azure_ptu_capacity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
"""How much throughput one Azure OpenAI provisioned throughput unit (PTU) serves per model.

Azure sizes a provisioned deployment in normalized tokens per minute:
``input TPM x (1 - cache hit rate) + output-to-input ratio x output TPM``, divided by the
model's "Input TPM per PTU" to get the PTUs required. The same two numbers turn a team's
PTU share into a per-minute token ceiling and a request's usage back into PTU-hours.

Table read from
https://learn.microsoft.com/en-us/azure/foundry/openai/how-to/provisioned-throughput-sizing#deployment-parameters-and-throughput-values-by-model
on 2026-09-24 (page dated 2026-09-23). Azure deducts cached input tokens in full for every
model except the GPT-6 family, where a cached input token costs a tenth of an uncached one.
"""

import re
from collections.abc import Mapping
from dataclasses import dataclass
from types import MappingProxyType
from typing import Final


@dataclass(frozen=True, slots=True)
class PTUCapacity:
input_tpm_per_ptu: int
output_to_input_ratio: float
cached_input_ratio: float = 0.0

@property
def normalized_tokens_per_ptu_hour(self) -> int:
return self.input_tpm_per_ptu * 60


AZURE_PTU_CAPACITY: Final[Mapping[str, PTUCapacity]] = MappingProxyType(
{
"gpt-6-sol": PTUCapacity(3_000, 5.0, cached_input_ratio=0.1),
"gpt-6-astra": PTUCapacity(600, 5.0, cached_input_ratio=0.1),
"gpt-5.6-luna": PTUCapacity(30_000, 6.0),
"gpt-5.6-terra": PTUCapacity(3_000, 6.0),
"gpt-5.6-sol": PTUCapacity(1_200, 6.0),
"gpt-5.5": PTUCapacity(1_200, 6.0),
"gpt-5.4": PTUCapacity(2_400, 6.0),
"gpt-5.4-mini": PTUCapacity(7_900, 6.0),
"gpt-5.3-codex": PTUCapacity(3_400, 8.0),
"gpt-5.2": PTUCapacity(3_400, 8.0),
"gpt-5.2-codex": PTUCapacity(3_400, 8.0),
"gpt-5.1": PTUCapacity(4_750, 8.0),
"gpt-5.1-codex": PTUCapacity(4_750, 8.0),
"gpt-5": PTUCapacity(4_750, 8.0),
"gpt-5-mini": PTUCapacity(23_750, 8.0),
"gpt-4.1": PTUCapacity(3_000, 4.0),
"gpt-4.1-mini": PTUCapacity(14_900, 4.0),
"gpt-4.1-nano": PTUCapacity(59_400, 4.0),
"o3": PTUCapacity(3_000, 4.0),
"o4-mini": PTUCapacity(5_400, 4.0),
"gpt-4o": PTUCapacity(2_500, 4.0),
"gpt-4o-mini": PTUCapacity(37_000, 4.0),
"o3-mini": PTUCapacity(2_500, 4.0),
"o1": PTUCapacity(230, 4.0),
"llama-3.3-70b-instruct": PTUCapacity(8_450, 4.0),
}
)

_VERSION_SUFFIX: Final = re.compile(r"-\d{4}-\d{2}-\d{2}$")


def azure_ptu_capacity(model: str) -> PTUCapacity | None:
"""The sizing row for ``model``, read as its last path segment with a dated version dropped.

``azure/gpt-4.1-2025-04-14`` and ``gpt-4.1`` both resolve to the ``gpt-4.1`` row; a
deployment name that is not a model name resolves to nothing, which is why callers
prefer ``model_info.base_model``.
"""
name: Final = model.rsplit("/", 1)[-1].strip().lower()
return AZURE_PTU_CAPACITY.get(name) or AZURE_PTU_CAPACITY.get(_VERSION_SUFFIX.sub("", name))


def deployment_ptu_capacity(deployment: Mapping[str, object]) -> PTUCapacity | None:
"""The sizing row a deployment resolves to: ``model_info.base_model`` first, since an
Azure deployment name is arbitrary, then ``litellm_params.model``."""
model_info: Final = deployment.get("model_info")
litellm_params: Final = deployment.get("litellm_params")
candidates: Final = tuple(
value
for value in (
model_info.get("base_model") if isinstance(model_info, Mapping) else None,
litellm_params.get("model") if isinstance(litellm_params, Mapping) else None,
)
if isinstance(value, str) and value
)
return next((capacity for capacity in map(azure_ptu_capacity, candidates) if capacity is not None), None)


def normalized_tokens(
capacity: PTUCapacity, *, prompt_tokens: int, completion_tokens: int, cache_read_tokens: int = 0
) -> float:
"""Azure's normalized token count for one request: uncached input in full, cached input
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 + capacity.cached_input_ratio * cached + capacity.output_to_input_ratio * max(completion_tokens, 0)


def ptu_hours(capacity: PTUCapacity, normalized: float) -> float:
"""PTU-hours ``normalized`` tokens amount to: one PTU serves its input TPM for sixty minutes."""
return normalized / capacity.normalized_tokens_per_ptu_hour
88 changes: 80 additions & 8 deletions litellm/litellm_core_utils/ptu_pricing.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,13 @@ def is_ptu_cost_attribution_enabled() -> bool:

@dataclass(frozen=True, slots=True)
class PTUTerms:
"""The reservation a deployment declares, once every field has been validated."""
"""The reservation a deployment declares, once every field has been validated.

team_id: str
``shares`` maps every team the capacity is attributed to onto its PTUs and adds up to
``ptu_count``: a deployment declaring a single ``team_id`` holds the whole count under it.
"""

shares: Mapping[str, int]
ptu_count: int
cost_per_ptu_per_hour: float
effective_from: datetime
Expand Down Expand Up @@ -127,7 +131,44 @@ def ptu_identity_error(
return None


PTU_MODEL_INFO_FIELDS: Final = ("ptu_count", "cost_per_ptu_per_hour", "ptu_effective_from", "ptu_effective_to")
PTU_MODEL_INFO_FIELDS: Final = (
"ptu_count",
"cost_per_ptu_per_hour",
"ptu_effective_from",
"ptu_effective_to",
"ptu_shares",
)


def parsed_ptu_shares(raw: object) -> Mapping[str, int] | None:
"""``ptu_shares`` as team id -> whole PTUs, else None when empty or any entry is unusable.

A share is a count of reserved units, so it has to be a positive integer; ``bool`` is
excluded because it is an ``int`` subclass and ``True`` would read as one PTU.
"""
if not isinstance(raw, Mapping) or not raw:
return None
entries: Final = tuple((str(team_id), share) for team_id, share in raw.items())
if any(
not team_id or isinstance(share, bool) or not isinstance(share, int) or share <= 0 for team_id, share in entries
):
return None
return MappingProxyType(dict(entries))


def _declared_shares(model_info: Mapping[str, object], ptu_count: int) -> Mapping[str, int] | None:
"""Who holds the capacity: the single ``team_id`` holding all of it, or the ``ptu_shares``
that add up to it, else None."""
team_id: Final = model_info.get("team_id")
raw_shares: Final = model_info.get("ptu_shares")
if team_id and raw_shares is None:
return MappingProxyType({str(team_id): ptu_count})
if team_id or raw_shares is None:
return None
shares: Final = parsed_ptu_shares(raw_shares)
if shares is None or sum(shares.values()) != ptu_count:
return None
return shares


def declares_ptu(model_info: Mapping[str, object]) -> bool:
Expand Down Expand Up @@ -164,8 +205,37 @@ def ptu_config_error(model_info: Mapping[str, object], *, model_name: str | None
"today could be billed for days it did not exist",
model_name,
)
if not model_info.get("team_id"):
return _named("team_id is required when PTU fields are set (one model maps to one team)", model_name)
return _ptu_holder_error(model_info, model_name)
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved


def _ptu_holder_error(model_info: Mapping[str, object], model_name: str | None) -> str | None:
"""Why the teams this capacity is attributed to cannot be read, else None.

The shares have to add up to the count exactly: a shortfall would leave PTU-hours the
provider bills attributed to nobody, and a surplus would attribute capacity that was
never reserved.
"""
team_id: Final = model_info.get("team_id")
raw_shares: Final = model_info.get("ptu_shares")
if team_id and raw_shares is not None:
return _named(
"team_id and ptu_shares cannot both be set; ptu_shares lists every team the capacity is split across",
model_name,
)
if not team_id and raw_shares is None:
return _named("team_id or ptu_shares is required when PTU fields are set", model_name)
if raw_shares is None:
return None
shares: Final = parsed_ptu_shares(raw_shares)
if shares is None:
return _named("ptu_shares must map at least one team_id to a positive whole number of PTUs", model_name)
try:
ptu_count: Final = int(str(model_info.get("ptu_count")))
except ValueError:
return None
allocated: Final = sum(shares.values())
if allocated != ptu_count:
return _named(f"ptu_shares must add up to ptu_count ({allocated} of {ptu_count} allocated)", model_name)

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.

Invalid ptu_count skips share-sum check

Medium Severity

_ptu_holder_error returns success when ptu_count is present but not a positive whole number in range, so the ptu_shares sum is never compared. ptu_config_error has no other bounds check, so POST /model/new can accept ptu_count: 0 (or another out-of-range value) with shares that do not add up. The share filter and per-team ceiling still apply from the map, while ptu_terms fails and flat cost is not rolled up.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit cdce71e. Configure here.

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.

ModelInfo's validator rejects a ptu_count outside 1 to 1,000,000 on /model/new, PATCH and config load, so this check never sees one

return None


Expand All @@ -178,8 +248,7 @@ def ptu_terms(model_info: Mapping[str, object]) -> PTUTerms | None:
"""
ptu_count: Final = model_info.get("ptu_count")
cost_per_hour: Final = model_info.get("cost_per_ptu_per_hour")
team_id: Final = model_info.get("team_id")
if ptu_count is None or cost_per_hour is None or not team_id:
if ptu_count is None or cost_per_hour is None:
return None
try:
ptu_count_int: Final = int(ptu_count)
Expand All @@ -199,8 +268,11 @@ def ptu_terms(model_info: Mapping[str, object]) -> PTUTerms | None:
return None
if effective_to is not None and effective_to <= effective_from:
return None
shares: Final = _declared_shares(model_info, ptu_count_int)
if shares is None:
return None
return PTUTerms(
team_id=str(team_id),
shares=shares,
ptu_count=ptu_count_int,
cost_per_ptu_per_hour=cost_per_hour_float,
effective_from=effective_from,
Expand Down
10 changes: 10 additions & 0 deletions litellm/proxy/_lazy_openapi_snapshot.json
Original file line number Diff line number Diff line change
Expand Up @@ -3177,6 +3177,11 @@
"title": "Total Prompt Tokens",
"type": "integer"
},
"total_ptu_hours": {
"default": 0.0,
"title": "Total Ptu Hours",
"type": "number"
},
"total_response_time_ms": {
"default": 0,
"title": "Total Response Time Ms",
Expand Down Expand Up @@ -3720,6 +3725,11 @@
"title": "Prompt Tokens",
"type": "integer"
},
"ptu_hours": {
"default": 0.0,
"title": "Ptu Hours",
"type": "number"
},
"spend": {
"default": 0.0,
"title": "Spend",
Expand Down
Loading
Loading