diff --git a/litellm/litellm_core_utils/ptu_pricing.py b/litellm/litellm_core_utils/ptu_pricing.py index 6923e6beb968..2e73719cf52d 100644 --- a/litellm/litellm_core_utils/ptu_pricing.py +++ b/litellm/litellm_core_utils/ptu_pricing.py @@ -8,7 +8,7 @@ from collections.abc import Mapping from dataclasses import dataclass -from datetime import datetime, timezone +from datetime import date, datetime, time, timezone from types import MappingProxyType from typing import Final @@ -68,9 +68,17 @@ def _to_utc(parsed: datetime) -> datetime: def _as_utc(value: object) -> datetime | None: - """A model_info datetime as UTC, parsing an ISO string, else None.""" + """A model_info datetime as UTC, parsing an ISO string, else None. + + An unquoted ``2027-01-01`` in config.yaml is loaded as a ``date``, not a string, and a + reservation bound that fails to parse takes the whole deployment out of PTU handling, + so the day is read as its opening midnight rather than discarded. ``datetime`` derives + from ``date``, so it has to be matched first. + """ if isinstance(value, datetime): return _to_utc(value) + if isinstance(value, date): + return datetime.combine(value, time.min, tzinfo=timezone.utc) if not isinstance(value, str): return None try: @@ -84,6 +92,38 @@ def _named(reason: str, model_name: str | None) -> str: return reason if model_name is None else f"PTU configuration on model '{model_name}' is invalid: {reason}" +def ptu_identity_error( + *, declared_id: str | None, taken: bool, current_id: str | None = None, model_name: str | None = None +) -> str | None: + """Why this config-declared reservation cannot be identified, else None. + + A deployment declared in config.yaml is otherwise keyed by a hash of its resolved + ``litellm_params``, so rotating a credential or editing an endpoint mints a second + identity and the reservation is charged again under it. The flat cost is keyed by that + id, and a charge already written is never retracted, so the duplicate is permanent. + + ``current_id`` is what the deployment is keyed by today. Naming it is the difference + between an operator carrying their history forward and an operator inventing a fresh + id, which starts a second identity beside the charges already written. + """ + if not declared_id: + return _named( + "model_info.id is required when PTU fields are set. Without one the deployment is " + "identified by a hash of its litellm_params, so rotating a credential bills the " + "reservation a second time under the new identity. Set it to the id this deployment " + f"already uses, {current_id or 'shown by GET /model/info'}, so the flat cost already " + "written stays under one identity; any other value starts a second one", + model_name, + ) + if taken: + return _named( + f"model_info.id '{declared_id}' is declared on more than one deployment. Each would key " + "the same flat-cost row, so one reservation would go unbilled", + model_name, + ) + return None + + def ptu_config_error(model_info: Mapping[str, object], *, model_name: str | None = None) -> str | None: """Why this PTU configuration cannot be honoured, else None. diff --git a/litellm/proxy/management_endpoints/model_management_endpoints.py b/litellm/proxy/management_endpoints/model_management_endpoints.py index ec98d7d65f1e..b003daa9d79e 100644 --- a/litellm/proxy/management_endpoints/model_management_endpoints.py +++ b/litellm/proxy/management_endpoints/model_management_endpoints.py @@ -312,6 +312,10 @@ def _validate_ptu_model_info(model_info: Mapping[str, object]) -> None: The rules live in litellm_core_utils.ptu_pricing so that config.yaml registration refuses the same deployments this endpoint does, for the same reason. Per-field bounds (positive count, non-negative rate) are enforced by ModelInfo itself. + + Registration additionally requires an operator-declared ``model_info.id``, which this + endpoint does not: a stored deployment already holds a stable primary key, where a + config-declared one is otherwise keyed by a hash of its own parameters. """ error: Final = ptu_config_error(model_info) if error is not None: diff --git a/litellm/router.py b/litellm/router.py index e9eeab539342..332669b3afb9 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -67,6 +67,8 @@ from litellm.litellm_core_utils.ptu_pricing import ( is_ptu_cost_attribution_enabled, ptu_config_error, + ptu_identity_error, + ptu_terms, zeroed_ptu_pricing, ) from litellm.litellm_core_utils.request_timeout_resolver import ( @@ -7694,6 +7696,9 @@ def _create_deployment( _model_name: str, _litellm_params: dict, _model_info: dict, + *, + declared_id: str | None = None, + duplicate_ids: frozenset[str] = frozenset(), ) -> Deployment | None: """ Create a deployment object and add it to the model list @@ -7706,7 +7711,19 @@ def _create_deployment( """ try: config_sourced: Final = _model_info.get("db_model") is not True - ptu_error: Final = ptu_config_error(_model_info, model_name=_model_name) if config_sourced else None + identity_error: Final = ( + ptu_identity_error( + declared_id=declared_id, + taken=declared_id in duplicate_ids, + current_id=_model_info.get("id"), + model_name=_model_name, + ) + if config_sourced and ptu_terms(_model_info) is not None + else None + ) + ptu_error: Final = ( + (ptu_config_error(_model_info, model_name=_model_name) or identity_error) if config_sourced else None + ) if ptu_error is not None and is_ptu_cost_attribution_enabled(): raise ValueError(ptu_error) zeroed_pricing: Final = zeroed_ptu_pricing(_model_info, _litellm_params) if config_sourced else None @@ -8209,6 +8226,13 @@ def set_model_list(self, model_list: list): self._invalidate_access_groups_cache() # we add api_base/api_key each model so load balancing between azure/gpt on api_base1 and api_base2 works + declared_ids: Final = tuple( + str(entry["model_info"]["id"]) + for entry in original_model_list + if isinstance(entry.get("model_info"), dict) and entry["model_info"].get("id") is not None + ) + duplicate_ids: Final = frozenset(model_id for model_id in declared_ids if declared_ids.count(model_id) > 1) + for model in original_model_list: _model_name = model.pop("model_name") _litellm_params = model.pop("litellm_params") @@ -8220,6 +8244,8 @@ def set_model_list(self, model_list: list): _model_info: dict = model.pop("model_info", {}) + declared_id = None if _model_info.get("id") is None else str(_model_info["id"]) + # check if model info has id if "id" not in _model_info: _id = self.generate_model_id(_model_name, _litellm_params) @@ -8235,6 +8261,8 @@ def set_model_list(self, model_list: list): _model_name=_model_name, _litellm_params=_litellm_params, _model_info=_model_info, + declared_id=declared_id, + duplicate_ids=duplicate_ids, ) else: self._create_deployment( @@ -8242,6 +8270,8 @@ def set_model_list(self, model_list: list): _model_name=_model_name, _litellm_params=_litellm_params, _model_info=_model_info, + declared_id=declared_id, + duplicate_ids=duplicate_ids, ) verbose_router_logger.debug("\nInitialized Model List %s", self.get_model_names()) diff --git a/tests/test_litellm/litellm_core_utils/test_ptu_pricing.py b/tests/test_litellm/litellm_core_utils/test_ptu_pricing.py index b953bfaa5656..f5339daad208 100644 --- a/tests/test_litellm/litellm_core_utils/test_ptu_pricing.py +++ b/tests/test_litellm/litellm_core_utils/test_ptu_pricing.py @@ -1,13 +1,14 @@ """Tests for the shared PTU rules: which deployments accrue flat cost, and what that zeroes.""" import os -from datetime import datetime, timezone +from datetime import date, datetime, timezone from unittest.mock import patch import pytest from litellm.litellm_core_utils.ptu_pricing import ( ptu_config_error, + ptu_identity_error, CUSTOM_PRICING_FIELDS, PTU_EMPTIED_PRICING_FIELDS, PTU_ZEROED_PRICING_FIELDS, @@ -209,3 +210,78 @@ def test_an_inverted_window_is_caught_before_the_count_and_rate_gate(): } assert ptu_config_error(window_only) == "ptu_effective_to must be after ptu_effective_from" + + +# --- the identity a config.yaml reservation has to declare --------------------------- + + +def test_a_declared_unique_id_is_accepted(): + assert ptu_identity_error(declared_id="azure-ptu-eastus", taken=False) is None + + +@pytest.mark.parametrize("missing", [None, ""], ids=["absent", "blank"]) +def test_a_reservation_without_an_id_is_refused(missing): + error = ptu_identity_error(declared_id=missing, taken=False) + + assert error is not None + assert error.startswith("model_info.id is required when PTU fields are set") + + +def test_the_refusal_names_the_id_the_deployment_already_uses(): + """An operator who invents a fresh name starts a second identity beside the charges + already written, which is the duplicate this rule exists to prevent.""" + error = ptu_identity_error(declared_id=None, taken=False, current_id="0ba149287615") + + assert error is not None + assert "0ba149287615" in error + + +def test_the_refusal_points_at_the_model_info_route_when_the_current_id_is_unknown(): + error = ptu_identity_error(declared_id=None, taken=False) + + assert error is not None + assert "GET /model/info" in error + + +def test_an_id_declared_twice_is_refused(): + error = ptu_identity_error(declared_id="azure-ptu-eastus", taken=True) + + assert error is not None + assert "declared on more than one deployment" in error + + +def test_the_deployment_is_named_when_the_caller_supplies_one(): + error = ptu_identity_error(declared_id=None, taken=False, model_name="azure-ptu") + + assert error is not None + assert error.startswith("PTU configuration on model 'azure-ptu' is invalid:") + + +def test_a_bare_yaml_date_bound_is_read_as_that_day_opening(): + """An unquoted 2027-01-01 in config.yaml loads as a date, not a string. Discarding it + took the whole deployment out of PTU handling, so it billed per token and accrued no + flat cost while the provider invoiced the reservation hourly.""" + terms = ptu_terms({**_VALID, "ptu_effective_to": date(2027, 1, 1)}) + + assert terms is not None + assert terms.effective_to == datetime(2027, 1, 1, tzinfo=timezone.utc) + + +def test_a_bare_yaml_date_start_is_read_as_that_day_opening(): + terms = ptu_terms({**_VALID, "ptu_effective_from": date(2026, 5, 1)}) + + assert terms is not None + assert terms.effective_from == datetime(2026, 5, 1, tzinfo=timezone.utc) + + +def test_the_string_zero_is_a_declared_id(): + """0 is a perfectly stable id, and ModelInfo stores it as a string. Reading it as absent + refused a deployment whose identity was never in doubt.""" + assert ptu_identity_error(declared_id="0", taken=False) is None + + +def test_an_empty_id_is_no_id(): + error = ptu_identity_error(declared_id="", taken=False) + + assert error is not None + assert error.startswith("model_info.id is required") diff --git a/tests/test_litellm/test_router_model_cost_isolation.py b/tests/test_litellm/test_router_model_cost_isolation.py index dc210f900bff..4fdb5faf3056 100644 --- a/tests/test_litellm/test_router_model_cost_isolation.py +++ b/tests/test_litellm/test_router_model_cost_isolation.py @@ -14,9 +14,7 @@ import pytest -sys.path.insert( - 0, os.path.abspath("../../..") -) # Adds the parent directory to the system path +sys.path.insert(0, os.path.abspath("../../..")) # Adds the parent directory to the system path import litellm from litellm import Router @@ -76,12 +74,8 @@ def test_should_not_pollute_shared_key_with_zero_cost_pricing(): builtin_output_cost = builtin_info["output_cost_per_token"] # Sanity: built-in pricing should be non-zero for this model - assert ( - builtin_input_cost > 0 - ), "Test requires a model with non-zero built-in pricing" - assert ( - builtin_output_cost > 0 - ), "Test requires a model with non-zero built-in pricing" + assert builtin_input_cost > 0, "Test requires a model with non-zero built-in pricing" + assert builtin_output_cost > 0, "Test requires a model with non-zero built-in pricing" router = Router( model_list=[ @@ -128,12 +122,10 @@ def test_should_not_pollute_shared_key_with_zero_cost_pricing(): ) assert info_b is not None assert info_b["input_cost_per_token"] == builtin_input_cost, ( - f"Deployment B should use built-in input cost {builtin_input_cost}, " - f"got {info_b['input_cost_per_token']}" + f"Deployment B should use built-in input cost {builtin_input_cost}, got {info_b['input_cost_per_token']}" ) assert info_b["output_cost_per_token"] == builtin_output_cost, ( - f"Deployment B should use built-in output cost {builtin_output_cost}, " - f"got {info_b['output_cost_per_token']}" + f"Deployment B should use built-in output cost {builtin_output_cost}, got {info_b['output_cost_per_token']}" ) @@ -265,9 +257,7 @@ def test_should_preserve_builtin_pricing_regardless_of_deployment_order(): ], ) - info_std_1 = router1.get_deployment_model_info( - model_id="order1-standard", model_name=backend_model - ) + info_std_1 = router1.get_deployment_model_info(model_id="order1-standard", model_name=backend_model) assert info_std_1["input_cost_per_token"] == builtin_input_cost assert info_std_1["output_cost_per_token"] == builtin_output_cost @@ -297,16 +287,12 @@ def test_should_preserve_builtin_pricing_regardless_of_deployment_order(): ], ) - info_std_2 = router2.get_deployment_model_info( - model_id="order2-standard", model_name=backend_model - ) + info_std_2 = router2.get_deployment_model_info(model_id="order2-standard", model_name=backend_model) assert info_std_2["input_cost_per_token"] == builtin_input_cost, ( - f"Order should not matter. Expected {builtin_input_cost}, " - f"got {info_std_2['input_cost_per_token']}" + f"Order should not matter. Expected {builtin_input_cost}, got {info_std_2['input_cost_per_token']}" ) assert info_std_2["output_cost_per_token"] == builtin_output_cost, ( - f"Order should not matter. Expected {builtin_output_cost}, " - f"got {info_std_2['output_cost_per_token']}" + f"Order should not matter. Expected {builtin_output_cost}, got {info_std_2['output_cost_per_token']}" ) @@ -334,12 +320,7 @@ def test_responses_prefix_stripped_alias_registered_for_model_list(): ) assert "azure/responses/gpt-strip-test-a1b2c3d4" in litellm.model_cost assert "azure/gpt-strip-test-a1b2c3d4" in litellm.model_cost - assert ( - litellm.model_cost["azure/gpt-strip-test-a1b2c3d4"].get( - "supports_native_streaming" - ) - is True - ) + assert litellm.model_cost["azure/gpt-strip-test-a1b2c3d4"].get("supports_native_streaming") is True def test_responses_prefix_stripped_alias_registered_for_add_deployment(): @@ -358,12 +339,7 @@ def test_responses_prefix_stripped_alias_registered_for_add_deployment(): router.add_deployment(deployment=deployment) assert "azure/responses/gpt-add-strip-e5f6a7b8" in litellm.model_cost assert "azure/gpt-add-strip-e5f6a7b8" in litellm.model_cost - assert ( - litellm.model_cost["azure/gpt-add-strip-e5f6a7b8"].get( - "supports_native_streaming" - ) - is True - ) + assert litellm.model_cost["azure/gpt-add-strip-e5f6a7b8"].get("supports_native_streaming") is True def test_should_not_downgrade_chatgpt_shared_key_mode_with_alias_override(): @@ -376,12 +352,8 @@ def test_should_not_downgrade_chatgpt_shared_key_mode_with_alias_override(): backend_model = "chatgpt/gpt-5.4" model_keys = { backend_model: copy.deepcopy(litellm.model_cost.get(backend_model)), - "chatgpt-shared-mode-base": copy.deepcopy( - litellm.model_cost.get("chatgpt-shared-mode-base") - ), - "chatgpt-shared-mode-alias": copy.deepcopy( - litellm.model_cost.get("chatgpt-shared-mode-alias") - ), + "chatgpt-shared-mode-base": copy.deepcopy(litellm.model_cost.get("chatgpt-shared-mode-base")), + "chatgpt-shared-mode-alias": copy.deepcopy(litellm.model_cost.get("chatgpt-shared-mode-alias")), } try: @@ -392,9 +364,7 @@ def test_should_not_downgrade_chatgpt_shared_key_mode_with_alias_override(): _invalidate_model_cost_lowercase_map() router = Router(model_list=[]) - with patch.object( - Router, "_add_deployment", lambda self, deployment: deployment - ): + with patch.object(Router, "_add_deployment", lambda self, deployment: deployment): router._create_deployment( deployment_info={}, _model_name="chatgpt/gpt-5.4", @@ -582,9 +552,7 @@ def test_custom_pricing_field_denylist_covers_all_builtin_pricing_fields(): pricing_markers = ("cost", "price", "uplift", "vector_size", "tiered_pricing") builtin_pricing_fields = { - name - for name in typing.get_type_hints(ModelInfoBase) - if any(marker in name for marker in pricing_markers) + name for name in typing.get_type_hints(ModelInfoBase) if any(marker in name for marker in pricing_markers) } denylisted_fields = set(CustomPricingLiteLLMParams.model_fields.keys()) @@ -641,8 +609,7 @@ def test_tiered_pricing_override_isolated_from_sibling_via_model_info_lookup(): shared = litellm.get_model_info(model=backend_model) assert shared.get("input_cost_per_token_above_272k_tokens") != override, ( - "Tiered override leaked into the shared backend key; siblings read " - "the wrong rate via /model/info" + "Tiered override leaked into the shared backend key; siblings read the wrong rate via /model/info" ) assert shared.get("cache_read_input_token_cost_above_272k_tokens") != override @@ -699,9 +666,7 @@ def test_custom_pricing_isolated_from_sibling_via_proxy_model_info_path(): ) resolved = { - m["model_name"]: _get_proxy_model_info(model=copy.deepcopy(m))[ - "model_info" - ]["input_cost_per_token"] + m["model_name"]: _get_proxy_model_info(model=copy.deepcopy(m))["model_info"]["input_cost_per_token"] for m in router.model_list } @@ -759,10 +724,7 @@ def test_custom_model_info_metadata_not_leaked_to_shared_backend_key(): for shared_key in shared_keys: shared_entry = litellm.model_cost.get(shared_key) or {} leaked = [field for field in leak_fields if field in shared_entry] - assert not leaked, ( - f"per-deployment metadata {leaked} leaked onto shared key " - f"{shared_key}: {shared_entry}" - ) + assert not leaked, f"per-deployment metadata {leaked} leaked onto shared key {shared_key}: {shared_entry}" entry_a = litellm.model_cost["lit4544-deploy-a"] assert entry_a["additionalProp1"] == {"restricted": False, "model_location": "EU"} @@ -782,10 +744,7 @@ def test_add_deployment_does_not_leak_custom_metadata_to_shared_backend_key(): shared_keys = ("gpt-4o-mini", backend_model) deploy_id = "lit4544-add-deployment" - model_keys = { - key: copy.deepcopy(litellm.model_cost.get(key)) - for key in (*shared_keys, deploy_id) - } + model_keys = {key: copy.deepcopy(litellm.model_cost.get(key)) for key in (*shared_keys, deploy_id)} try: router = Router(model_list=[]) router.add_deployment( @@ -806,14 +765,9 @@ def test_add_deployment_does_not_leak_custom_metadata_to_shared_backend_key(): for shared_key in shared_keys: shared_entry = litellm.model_cost.get(shared_key) or {} leaked = [ - field - for field in ("id", "additionalProp1", "access_via_team_ids", "db_model") - if field in shared_entry + field for field in ("id", "additionalProp1", "access_via_team_ids", "db_model") if field in shared_entry ] - assert not leaked, ( - f"per-deployment metadata {leaked} leaked onto shared key " - f"{shared_key}: {shared_entry}" - ) + assert not leaked, f"per-deployment metadata {leaked} leaked onto shared key {shared_key}: {shared_entry}" assert litellm.model_cost[deploy_id]["access_via_team_ids"] == ["team-dynamic"] finally: @@ -870,10 +824,7 @@ def test_capability_flags_propagate_from_deployment_model_info_to_shared_key(): backend_model = f"bedrock_mantle/{bare_model}" deploy_id = "lit4544-mantle-deploy" - model_keys = { - key: copy.deepcopy(litellm.model_cost.get(key)) - for key in (bare_model, backend_model, deploy_id) - } + model_keys = {key: copy.deepcopy(litellm.model_cost.get(key)) for key in (bare_model, backend_model, deploy_id)} try: Router( model_list=[ @@ -912,16 +863,12 @@ def test_wildcard_zero_cost_request_does_not_poison_named_deployment_pricing(): shared_key = "openai/text-embedding-3-small" model_keys = { shared_key: copy.deepcopy(litellm.model_cost.get(shared_key)), - "text-embedding-3-small": copy.deepcopy( - litellm.model_cost.get("text-embedding-3-small") - ), + "text-embedding-3-small": copy.deepcopy(litellm.model_cost.get("text-embedding-3-small")), "openai/*": copy.deepcopy(litellm.model_cost.get("openai/*")), "lit3991-named": litellm.model_cost.get("lit3991-named"), "lit3991-wildcard": litellm.model_cost.get("lit3991-wildcard"), } - builtin_input_cost = litellm.get_model_info(model=shared_key)[ - "input_cost_per_token" - ] + builtin_input_cost = litellm.get_model_info(model=shared_key)["input_cost_per_token"] assert builtin_input_cost > 0 try: @@ -954,12 +901,8 @@ def test_wildcard_zero_cost_request_does_not_poison_named_deployment_pricing(): mock_response=[0.1, 0.2], ) - assert ( - litellm.get_model_info(model=shared_key)["input_cost_per_token"] - == builtin_input_cost - ), ( - "one call through the zero-cost wildcard poisoned the shared " - f"{shared_key} pricing for the named deployment" + assert litellm.get_model_info(model=shared_key)["input_cost_per_token"] == builtin_input_cost, ( + f"one call through the zero-cost wildcard poisoned the shared {shared_key} pricing for the named deployment" ) named_response = router.embedding( @@ -967,9 +910,7 @@ def test_wildcard_zero_cost_request_does_not_poison_named_deployment_pricing(): input=["hello"], mock_response=[0.1, 0.2], ) - named_cost = litellm.completion_cost( - completion_response=named_response, call_type="embedding" - ) + named_cost = litellm.completion_cost(completion_response=named_response, call_type="embedding") assert named_cost == pytest.approx(10 * builtin_input_cost) finally: _restore_model_cost_entries(model_keys) @@ -984,6 +925,7 @@ def test_price_data_reload_preserves_router_registered_model_info(monkeypatch): /model_group/info starts reporting nulls. """ from litellm import utils as litellm_utils + monkeypatch.setattr( litellm_utils, "_runtime_registered_model_cost", @@ -1031,6 +973,7 @@ def test_price_data_reload_preserves_custom_override_of_a_catalog_model(monkeypa operator's model_info override to the upstream catalog values. """ from litellm import utils as litellm_utils + monkeypatch.setattr( litellm_utils, "_runtime_registered_model_cost", @@ -1082,6 +1025,7 @@ def test_deleted_deployments_are_not_replayed_onto_later_reloads(monkeypatch): deletion. """ from litellm import utils as litellm_utils + monkeypatch.setattr( litellm_utils, "_runtime_registered_model_cost", @@ -1180,6 +1124,7 @@ def test_repointing_a_deployment_drops_its_previous_backend_key(monkeypatch): later catalog for the life of the process. """ from litellm import utils as litellm_utils + monkeypatch.setattr( litellm_utils, "_runtime_registered_model_cost", @@ -1388,9 +1333,7 @@ def test_register_deployment_in_model_cost_writes_both_key_families(): """ model_keys = { "both-families-id": copy.deepcopy(litellm.model_cost.get("both-families-id")), - "hosted_vllm/both-families-backend": copy.deepcopy( - litellm.model_cost.get("hosted_vllm/both-families-backend") - ), + "hosted_vllm/both-families-backend": copy.deepcopy(litellm.model_cost.get("hosted_vllm/both-families-backend")), } try: Router._register_deployment_in_model_cost( @@ -1494,6 +1437,7 @@ def test_strategy_router_alias_pricing_never_enters_model_cost(monkeypatch): walking the live routers. """ from litellm import utils as litellm_utils + monkeypatch.setattr( litellm_utils, "_runtime_registered_model_cost", @@ -1600,6 +1544,7 @@ def test_inherit_builtin_tiered_output_rate_leaves_a_user_rate_alone(): # --- a config.yaml PTU deployment must not also bill per token ------------------ _PTU_MODEL_INFO = { + "id": "ptu-alpha-eastus", "team_id": "team-alpha", "ptu_count": 100, "cost_per_ptu_per_hour": 0.02, @@ -1674,14 +1619,18 @@ def test_zeroing_a_ptu_deployment_leaves_its_backend_model_priced(): assert litellm.get_model_info(model=backend)["input_cost_per_token"] == builtin -def test_zeroing_does_not_change_the_deployment_id(): - """The id is a hash of the deployment's params and keys its cooldowns, its budget, and - every spend row already written against it.""" +def test_the_registered_id_is_the_one_the_operator_declared(): + """Registration must key the deployment by the declared id, not by a hash of params that + zeroing has just rewritten. The id keys cooldowns, budgets and every spend row already + written, so minting one here would move all of them. + + A derived id is no longer reachable for a reservation: zeroing requires PTU terms and + PTU terms now require a declared id, so the two never combine.""" params = {"input_cost_per_token": 5e-06} priced = _ptu_router(litellm_params=params, ptu_enabled=False).model_list[0]["model_info"]["id"] zeroed = _ptu_router(litellm_params=params).model_list[0]["model_info"]["id"] - assert priced == zeroed + assert priced == zeroed == "ptu-alpha-eastus" def test_a_database_backed_deployment_is_left_alone(): @@ -1931,3 +1880,129 @@ def test_router_model_info_deep_copies_nested_cached_metadata(): assert litellm.get_model_info(model=model)["search_context_cost_per_query"] == expected_nested finally: litellm.get_model_info.cache_clear() + + +# --- a config.yaml reservation must carry an id its operator owns -------------------- + + +def test_a_reservation_without_a_declared_id_is_refused(): + """Left underived the id is a hash of the resolved litellm_params, so rotating the + credential mints a second identity and the catch-up bills the window again under it. + The flat cost is keyed by that id and a written charge is never retracted, so the + duplicate is permanent.""" + anonymous = {k: v for k, v in _PTU_MODEL_INFO.items() if k != "id"} + + with pytest.raises(ValueError, match="model_info.id is required"): + _ptu_router(model_info=anonymous) + + +def test_the_id_rule_does_not_reach_a_deployment_without_ptu_config(): + """An ordinary deployment keeps deriving its id, which is most of every config.yaml.""" + entry = _ptu_router(model_info={"team_id": "team-alpha"}).model_list[0] + + assert entry["model_info"]["id"] + + +def test_a_reservation_is_left_alone_while_the_feature_is_off(): + anonymous = {k: v for k, v in _PTU_MODEL_INFO.items() if k != "id"} + entry = _ptu_router(model_info=anonymous, ptu_enabled=False).model_list[0] + + assert entry["model_info"]["id"] + + +def test_two_reservations_cannot_share_one_id(): + """Both would key the same sentinel row, so the second upsert overwrites the first and + one reservation is billed at the other's rate.""" + with patch.dict(os.environ, {"LITELLM_ENABLE_PTU_COST_ATTRIBUTION": "True"}, clear=False): + with pytest.raises(ValueError, match="declared on more than one deployment"): + Router( + model_list=[ + { + "model_name": "azure-ptu", + "litellm_params": {"model": "azure/gpt-4o", "api_key": "k", "api_base": "https://e.azure.com"}, + "model_info": dict(_PTU_MODEL_INFO), + }, + { + "model_name": "azure-ptu-west", + "litellm_params": {"model": "azure/gpt-4o", "api_key": "k", "api_base": "https://w.azure.com"}, + "model_info": dict(_PTU_MODEL_INFO), + }, + ] + ) + + +def test_two_reservations_with_distinct_ids_both_register(): + """The refusal must be scoped to a collision, not to a team running two regions.""" + with patch.dict(os.environ, {"LITELLM_ENABLE_PTU_COST_ATTRIBUTION": "True"}, clear=False): + router = Router( + model_list=[ + { + "model_name": "azure-ptu", + "litellm_params": {"model": "azure/gpt-4o", "api_key": "k", "api_base": "https://e.azure.com"}, + "model_info": dict(_PTU_MODEL_INFO), + }, + { + "model_name": "azure-ptu-west", + "litellm_params": {"model": "azure/gpt-4o", "api_key": "k", "api_base": "https://w.azure.com"}, + "model_info": {**_PTU_MODEL_INFO, "id": "ptu-alpha-westus"}, + }, + ] + ) + + assert sorted(m["model_info"]["id"] for m in router.model_list) == ["ptu-alpha-eastus", "ptu-alpha-westus"] + + +@pytest.mark.parametrize("declared", ["dup-id", 12345], ids=["string id", "numeric id"]) +def test_a_duplicate_id_is_caught_whatever_yaml_parsed_it_as(declared): + """An unquoted id in config.yaml arrives as an int, and ModelInfo stores it as a string, + so both deployments would still key one flat-cost row.""" + + def entry(name, region): + return { + "model_name": name, + "litellm_params": {"model": "azure/gpt-4o", "api_key": "k", "api_base": f"https://{region}.azure.com"}, + "model_info": {**_PTU_MODEL_INFO, "id": declared}, + } + + with patch.dict(os.environ, {"LITELLM_ENABLE_PTU_COST_ATTRIBUTION": "True"}, clear=False): + with pytest.raises(ValueError, match="declared on more than one deployment"): + Router(model_list=[entry("a", "eastus"), entry("b", "westus")]) + + +def test_a_bare_yaml_date_bound_does_not_escape_the_id_rule(): + """`ptu_effective_to: 2027-01-01` unquoted loads as a date. While that failed to parse, + the reservation was invisible to PTU entirely: no id rule, no zeroing, no flat cost.""" + import datetime as _dt + + windowed = {k: v for k, v in _PTU_MODEL_INFO.items() if k != "id"} + + with pytest.raises(ValueError, match="model_info.id is required"): + _ptu_router(model_info={**windowed, "ptu_effective_to": _dt.date(2027, 1, 1)}) + + +def test_a_reservation_declaring_id_zero_registers(): + """0 is stable and unique, so reading it as absent refused a correct config.""" + entry = _ptu_router(model_info={**_PTU_MODEL_INFO, "id": 0}).model_list[0] + + assert entry["model_info"]["id"] == "0" + + +def test_a_falsy_id_is_still_scanned_for_collisions(): + """The duplicate scan skipped falsy ids, so a reservation on '0' could share its key with + an ordinary deployment and the id index would keep only the last one registered.""" + with patch.dict(os.environ, {"LITELLM_ENABLE_PTU_COST_ATTRIBUTION": "True"}, clear=False): + with pytest.raises(ValueError, match="declared on more than one deployment"): + Router( + model_list=[ + { + "model_name": "azure-ptu", + "litellm_params": {"model": "azure/gpt-4o", "api_key": "k", "api_base": "https://e.azure.com"}, + "model_info": {**_PTU_MODEL_INFO, "id": "0"}, + }, + { + "model_name": "plain-sibling", + "litellm_params": {"model": "azure/gpt-4o", "api_key": "k", "api_base": "https://w.azure.com"}, + "model_info": {"id": 0}, + }, + ] + )