Repository navigation
feat(proxy): split Azure PTU deployments across teams with per-team PTU ceilings and PTU-hour reporting #43043
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
2d49656
95e3cae
304e9d2
0d80930
4ad0db3
6a24e7f
00f6210
e9c8e62
77e7250
48c0be3
d1f698f
ea8b63d
b41eed4
f8425f3
d2eda3a
5edea75
96a5eee
d0662cd
4a5828e
5286e6c
e65e12d
495cf74
6fb24d1
e4b33bc
dbb0cf2
c4874bd
e076b47
6f3915b
b576bab
c46fb5d
2d1d021
17abc6e
1a4b393
8a97ba4
b70f6f7
e3492ae
a5f9321
6d9a3d0
4d5903d
ff7b7f0
1251658
109f9bc
c106f93
cdce71e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
|
@@ -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: | ||
|
|
@@ -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) | ||
|
|
||
|
|
||
| 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) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Invalid ptu_count skips share-sum checkMedium Severity
Additional Locations (1)Reviewed by Cursor Bugbot for commit cdce71e. Configure here.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
|
||
|
|
||
|
|
@@ -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) | ||
|
|
@@ -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, | ||
|
|
||


Uh oh!
There was an error while loading. Please reload this page.