Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
44 changes: 42 additions & 2 deletions litellm/litellm_core_utils/ptu_pricing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand All @@ -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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
32 changes: 31 additions & 1 deletion litellm/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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
Expand All @@ -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,
)
Comment thread
cursor[bot] marked this conversation as resolved.
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)
Comment thread
greptile-apps[bot] marked this conversation as resolved.
zeroed_pricing: Final = zeroed_ptu_pricing(_model_info, _litellm_params) if config_sourced else None
Expand Down Expand Up @@ -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")
Expand All @@ -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)
Expand All @@ -8235,13 +8261,17 @@ 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(
deployment_info=model,
_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())
Expand Down
78 changes: 77 additions & 1 deletion tests/test_litellm/litellm_core_utils/test_ptu_pricing.py
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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")
Loading
Loading