mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-10 22:41:41 +00:00
Merge pull request #31725 from Srivatsa03/time-based-cost-pricing
feat(cost): support time-based off-peak pricing in cost calculation
This commit is contained in:
commit
c913b09e66
8 changed files with 1039 additions and 25 deletions
|
|
@ -111,6 +111,7 @@ from litellm.types.mcp import MCPPostCallResponseObject
|
|||
from litellm.types.prompts.init_prompts import PromptSpec
|
||||
from litellm.types.rerank import RerankResponse
|
||||
from litellm.types.utils import (
|
||||
DEPLOYMENT_SCOPED_PRICING_FIELDS,
|
||||
CachingDetails,
|
||||
CallTypes,
|
||||
CostBreakdown,
|
||||
|
|
@ -255,6 +256,7 @@ _STANDARD_LOGGING_METADATA_KEYS: Final[frozenset[str]] = frozenset(StandardLoggi
|
|||
|
||||
# Cache custom pricing keys as frozenset for O(1) lookups instead of looping through 49 keys
|
||||
_CUSTOM_PRICING_KEYS: Final[frozenset[str]] = frozenset(CustomPricingLiteLLMParams.model_fields.keys())
|
||||
_MODEL_INFO_CUSTOM_PRICING_KEYS: Final[frozenset[str]] = _CUSTOM_PRICING_KEYS | DEPLOYMENT_SCOPED_PRICING_FIELDS
|
||||
|
||||
sentry_sdk_instance = None
|
||||
capture_exception = None
|
||||
|
|
@ -5033,7 +5035,9 @@ def use_custom_pricing_for_model(litellm_params: dict | None) -> bool:
|
|||
"""
|
||||
Check if the model uses custom pricing
|
||||
|
||||
Returns True if any of `SPECIAL_MODEL_INFO_PARAMS` are present in `litellm_params` or `model_info`
|
||||
Returns True if any custom pricing field is present in `litellm_params`, or if
|
||||
any custom pricing or deployment-scoped pricing field (such as
|
||||
``off_peak_pricing``) is present in the metadata ``model_info``
|
||||
"""
|
||||
if litellm_params is None:
|
||||
return False
|
||||
|
|
@ -5051,7 +5055,7 @@ def use_custom_pricing_for_model(litellm_params: dict | None) -> bool:
|
|||
model_info: dict = metadata.get("model_info", {}) or {}
|
||||
|
||||
if model_info:
|
||||
matching_keys = _CUSTOM_PRICING_KEYS & model_info.keys()
|
||||
matching_keys = _MODEL_INFO_CUSTOM_PRICING_KEYS & model_info.keys()
|
||||
for key in matching_keys:
|
||||
if model_info.get(key) is not None:
|
||||
return True
|
||||
|
|
|
|||
|
|
@ -2,10 +2,12 @@
|
|||
## Helper utilities for cost_per_token()
|
||||
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone, tzinfo
|
||||
from types import MappingProxyType
|
||||
from typing import Any, Final, Literal, TypedDict, cast
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
import litellm
|
||||
from litellm._logging import verbose_logger
|
||||
|
|
@ -290,10 +292,187 @@ def _get_tiered_base_costs(model_info: ModelInfo, usage: Usage) -> tuple[float,
|
|||
)
|
||||
|
||||
|
||||
def _is_within_off_peak_window(off_peak_hours_utc: str | Sequence[str], current_time: datetime | None = None) -> bool:
|
||||
"""Return True if current_time (UTC, defaulting to now) falls inside any off-peak window.
|
||||
|
||||
off_peak_hours_utc is a "HH:MM-HH:MM" string in UTC, or a list of such strings for providers
|
||||
with multiple daily windows (e.g. ["16:30-00:30", "04:00-06:00"]). A window may wrap past
|
||||
midnight, and a window whose start equals its end covers the whole day. The start is
|
||||
inclusive and the end is exclusive; malformed windows are ignored.
|
||||
|
||||
An aware current_time is converted to UTC. A naive one is taken to already be UTC rather
|
||||
than being localised, so callers must pass datetime.now(timezone.utc), never datetime.now(),
|
||||
or every window shifts by the host's offset.
|
||||
"""
|
||||
reference: Final = current_time if current_time is not None else datetime.now(timezone.utc)
|
||||
now: Final = (reference.astimezone(timezone.utc) if reference.tzinfo is not None else reference).time()
|
||||
windows: Final = (off_peak_hours_utc,) if isinstance(off_peak_hours_utc, str) else off_peak_hours_utc
|
||||
for window in windows:
|
||||
try:
|
||||
start_str, end_str = window.split("-")
|
||||
start = datetime.strptime(start_str.strip(), "%H:%M").replace(tzinfo=timezone.utc).time()
|
||||
end = datetime.strptime(end_str.strip(), "%H:%M").replace(tzinfo=timezone.utc).time()
|
||||
except (ValueError, AttributeError):
|
||||
continue
|
||||
if start < end:
|
||||
if start <= now < end:
|
||||
return True
|
||||
elif now >= start or now < end:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
_WEEKDAY_NUMBERS: Final = MappingProxyType(
|
||||
{
|
||||
"mon": 1,
|
||||
"monday": 1,
|
||||
"tue": 2,
|
||||
"tues": 2,
|
||||
"tuesday": 2,
|
||||
"wed": 3,
|
||||
"wednesday": 3,
|
||||
"thu": 4,
|
||||
"thur": 4,
|
||||
"thurs": 4,
|
||||
"thursday": 4,
|
||||
"fri": 5,
|
||||
"friday": 5,
|
||||
"sat": 6,
|
||||
"saturday": 6,
|
||||
"sun": 7,
|
||||
"sunday": 7,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _normalize_weekday(value: object) -> int | None:
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
if isinstance(value, int):
|
||||
return value if 1 <= value <= 7 else None
|
||||
if isinstance(value, str):
|
||||
return _WEEKDAY_NUMBERS.get(value.strip().lower())
|
||||
return None
|
||||
|
||||
|
||||
def _weekday_calendar(weekday_timezone: object) -> tzinfo:
|
||||
if isinstance(weekday_timezone, str) and weekday_timezone.strip():
|
||||
try:
|
||||
return ZoneInfo(weekday_timezone.strip())
|
||||
except (ValueError, ZoneInfoNotFoundError):
|
||||
return timezone.utc
|
||||
return timezone.utc
|
||||
|
||||
|
||||
def _matches_weekdays(reference_utc: datetime, weekdays: object, weekday_timezone: object) -> bool:
|
||||
"""Return True when reference_utc falls on one of the rule's weekdays, read on the calendar
|
||||
named by weekday_timezone (default UTC). An absent weekdays means every day. The calendar
|
||||
matters even when UTC and vendor-local weekdays agree at every currently priced hour: a
|
||||
window past 16:00 UTC is where an Asia/Shanghai weekday diverges from the UTC one.
|
||||
"""
|
||||
if weekdays is None:
|
||||
return True
|
||||
if isinstance(weekdays, str) or not isinstance(weekdays, Sequence):
|
||||
return False
|
||||
allowed: Final = frozenset(day for day in map(_normalize_weekday, weekdays) if day is not None)
|
||||
return reference_utc.astimezone(_weekday_calendar(weekday_timezone)).isoweekday() in allowed
|
||||
|
||||
|
||||
def _as_window_strings(value: object) -> tuple[str, ...]:
|
||||
if isinstance(value, str):
|
||||
return (value,)
|
||||
if isinstance(value, Sequence):
|
||||
return tuple(entry for entry in value if isinstance(entry, str))
|
||||
return ()
|
||||
|
||||
|
||||
def _is_off_peak(off_peak: Mapping[str, object], current_time: datetime | None = None) -> bool:
|
||||
"""Return True when current_time (UTC, defaulting to now) is off-peak under the block's
|
||||
rules: the flat hours_utc windows, which apply every day, or any entry in windows, whose
|
||||
hours apply only on its weekdays.
|
||||
"""
|
||||
reference: Final = current_time if current_time is not None else datetime.now(timezone.utc)
|
||||
reference_utc: Final = (
|
||||
reference.astimezone(timezone.utc) if reference.tzinfo is not None else reference.replace(tzinfo=timezone.utc)
|
||||
)
|
||||
flat_windows: Final = _as_window_strings(off_peak.get("hours_utc"))
|
||||
if flat_windows and _is_within_off_peak_window(flat_windows, reference_utc):
|
||||
return True
|
||||
windows: Final = off_peak.get("windows")
|
||||
if isinstance(windows, str) or not isinstance(windows, Sequence):
|
||||
return False
|
||||
weekday_timezone: Final = off_peak.get("weekday_timezone")
|
||||
for rule in windows:
|
||||
if not isinstance(rule, Mapping):
|
||||
continue
|
||||
rule_windows = _as_window_strings(rule.get("hours_utc"))
|
||||
if not rule_windows:
|
||||
continue
|
||||
if not _matches_weekdays(reference_utc, rule.get("weekdays"), weekday_timezone):
|
||||
continue
|
||||
if _is_within_off_peak_window(rule_windows, reference_utc):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _coerce_off_peak_rate(value: object, default: float) -> float:
|
||||
if isinstance(value, bool):
|
||||
return default
|
||||
if isinstance(value, (int, float)):
|
||||
return float(value)
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return float(value)
|
||||
except ValueError:
|
||||
return default
|
||||
return default
|
||||
|
||||
|
||||
def _apply_off_peak_pricing(
|
||||
model_info: ModelInfo,
|
||||
current_time: datetime | None,
|
||||
prompt_base_cost: float,
|
||||
completion_base_cost: float,
|
||||
cache_read_cost: float,
|
||||
) -> tuple[float, float, float]:
|
||||
"""Swap in off-peak per-token rates when the current UTC time is inside one of the model's
|
||||
off_peak_pricing rules, the every-day hours_utc windows or a day-of-week-qualified entry in
|
||||
windows. An off-peak rate replaces the rate that would otherwise apply rather than
|
||||
discounting it, so a model that also has tiered or above-threshold pricing bills the flat
|
||||
off-peak rate for the whole request while the window is open. Any rate left unset in
|
||||
off_peak_pricing falls back to the standard rate.
|
||||
"""
|
||||
off_peak: Final = model_info.get("off_peak_pricing")
|
||||
if not isinstance(off_peak, Mapping) or not _is_off_peak(off_peak, current_time):
|
||||
return prompt_base_cost, completion_base_cost, cache_read_cost
|
||||
return (
|
||||
_coerce_off_peak_rate(off_peak.get("input_cost_per_token"), prompt_base_cost),
|
||||
_coerce_off_peak_rate(off_peak.get("output_cost_per_token"), completion_base_cost),
|
||||
_coerce_off_peak_rate(off_peak.get("cache_read_input_token_cost"), cache_read_cost),
|
||||
)
|
||||
|
||||
|
||||
def _apply_off_peak_to_base_costs(
|
||||
model_info: ModelInfo,
|
||||
current_time: datetime | None,
|
||||
base_costs: tuple[float, float, float, float, float],
|
||||
) -> tuple[float, float, float, float, float]:
|
||||
"""Apply off-peak rates to an already-resolved set of base costs, whichever pricing path
|
||||
produced them. Cache-creation rates are passed through untouched, since off_peak_pricing
|
||||
has no field for them.
|
||||
"""
|
||||
prompt, completion, cache_creation, cache_creation_above_1hr, cache_read = base_costs
|
||||
off_peak_prompt, off_peak_completion, off_peak_cache_read = _apply_off_peak_pricing(
|
||||
model_info, current_time, prompt, completion, cache_read
|
||||
)
|
||||
return (off_peak_prompt, off_peak_completion, cache_creation, cache_creation_above_1hr, off_peak_cache_read)
|
||||
|
||||
|
||||
def _get_token_base_cost(
|
||||
model_info: ModelInfo,
|
||||
usage: Usage,
|
||||
service_tier: str | None = None,
|
||||
current_time: datetime | None = None,
|
||||
*,
|
||||
threshold_is_inclusive: bool = False,
|
||||
) -> tuple[float, float, float, float, float]:
|
||||
|
|
@ -311,7 +490,7 @@ def _get_token_base_cost(
|
|||
"""
|
||||
tiered_base_costs: Final = _get_tiered_base_costs(model_info=model_info, usage=usage)
|
||||
if tiered_base_costs is not None:
|
||||
return tiered_base_costs
|
||||
return _apply_off_peak_to_base_costs(model_info, current_time, tiered_base_costs)
|
||||
|
||||
# Get service tier aware cost keys
|
||||
input_cost_key: Final = _get_service_tier_cost_key("input_cost_per_token", service_tier)
|
||||
|
|
@ -345,12 +524,16 @@ def _get_token_base_cost(
|
|||
k for k in model_info if k.startswith("input_cost_per_token_above_") and not k.endswith(_SERVICE_TIER_SUFFIXES)
|
||||
]
|
||||
if not threshold_keys:
|
||||
return (
|
||||
prompt_base_cost,
|
||||
completion_base_cost,
|
||||
cache_creation_cost,
|
||||
cache_creation_cost_above_1hr,
|
||||
cache_read_cost,
|
||||
return _apply_off_peak_to_base_costs(
|
||||
model_info,
|
||||
current_time,
|
||||
(
|
||||
prompt_base_cost,
|
||||
completion_base_cost,
|
||||
cache_creation_cost,
|
||||
cache_creation_cost_above_1hr,
|
||||
cache_read_cost,
|
||||
),
|
||||
)
|
||||
|
||||
# Only sort the threshold keys (typically 1-2 keys instead of 66+)
|
||||
|
|
@ -451,12 +634,16 @@ def _get_token_base_cost(
|
|||
except Exception:
|
||||
continue
|
||||
|
||||
return (
|
||||
prompt_base_cost,
|
||||
completion_base_cost,
|
||||
cache_creation_cost,
|
||||
cache_creation_cost_above_1hr,
|
||||
cache_read_cost,
|
||||
return _apply_off_peak_to_base_costs(
|
||||
model_info,
|
||||
current_time,
|
||||
(
|
||||
prompt_base_cost,
|
||||
completion_base_cost,
|
||||
cache_creation_cost,
|
||||
cache_creation_cost_above_1hr,
|
||||
cache_read_cost,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -8165,6 +8165,52 @@ class Router:
|
|||
if backend_value is not None:
|
||||
model_info[field] = backend_value
|
||||
|
||||
@staticmethod
|
||||
def _inherit_builtin_base_rates_for_off_peak(
|
||||
model_info: dict, # mutable-ok: cost-map entry filled in place
|
||||
backend_model: str,
|
||||
custom_llm_provider: str | None,
|
||||
) -> None:
|
||||
"""Fill missing pricing fields on a deployment entry that only sets
|
||||
``off_peak_pricing``, from the backend model's built-in cost map entry.
|
||||
|
||||
Cost lookup selects the deployment-scoped entry over the shared backend
|
||||
entry only when the deployment entry carries a base pricing field, and
|
||||
``off_peak_pricing`` is deliberately kept off the shared entry, so a
|
||||
deployment spelling out only its off-peak schedule would otherwise
|
||||
never receive the discount. The backend model's entire canonical cost
|
||||
map entry is copied, field by field, so threshold, tiered,
|
||||
service-tier, cache, character, and per-second rates as well as
|
||||
companion billing fields like ``web_search_billing_unit`` and the
|
||||
regional uplift multipliers all carry over, and peak-hour billing
|
||||
through the deployment entry matches the shared backend entry exactly.
|
||||
The raw ``litellm.model_cost`` entry is the copy source rather than
|
||||
``get_model_info``'s view of it, since that view synthesizes zero flat
|
||||
token rates for backends without one and storing those would mark a
|
||||
tiered-only backend explicitly priced free. Values are deep-copied to
|
||||
keep the builtin entry isolated. User-specified fields always win;
|
||||
no-op when any base pricing field is already set or the backend model
|
||||
has no canonical entry.
|
||||
"""
|
||||
if not model_info.get("off_peak_pricing"):
|
||||
return
|
||||
if any(
|
||||
model_info.get(field) is not None
|
||||
for field in ("input_cost_per_token", "input_cost_per_second", "tiered_pricing")
|
||||
):
|
||||
return
|
||||
try:
|
||||
backend_info: Final = litellm.get_model_info(model=backend_model, custom_llm_provider=custom_llm_provider)
|
||||
except Exception: # noqa: BLE001 # get_model_info raises plain Exception for an unmapped backend model
|
||||
return
|
||||
backend_entry: Final = litellm.model_cost.get(backend_info.get("key") or "")
|
||||
if not isinstance(backend_entry, dict):
|
||||
return
|
||||
for field, backend_value in backend_entry.items():
|
||||
if model_info.get(field) is not None or backend_value is None:
|
||||
continue
|
||||
model_info[field] = copy.deepcopy(backend_value)
|
||||
|
||||
@staticmethod
|
||||
def _inherit_builtin_tiered_output_rate(
|
||||
model_info: dict, backend_model: str, custom_llm_provider: str | None
|
||||
|
|
@ -8253,6 +8299,11 @@ class Router:
|
|||
if deployment.litellm_params.get(field) is not None:
|
||||
_model_info[field] = deployment.litellm_params[field]
|
||||
|
||||
Router._inherit_builtin_base_rates_for_off_peak(
|
||||
model_info=_model_info,
|
||||
backend_model=deployment.litellm_params.model,
|
||||
custom_llm_provider=deployment.litellm_params.custom_llm_provider,
|
||||
)
|
||||
if _model_info.get("input_cost_per_token") is not None:
|
||||
Router._inherit_builtin_cache_pricing(
|
||||
model_info=_model_info,
|
||||
|
|
@ -8994,6 +9045,11 @@ class Router:
|
|||
if field_value is not None:
|
||||
_model_info_dict[field] = field_value
|
||||
|
||||
Router._inherit_builtin_base_rates_for_off_peak(
|
||||
model_info=_model_info_dict,
|
||||
backend_model=deployment.litellm_params.model,
|
||||
custom_llm_provider=deployment.litellm_params.custom_llm_provider,
|
||||
)
|
||||
if _model_info_dict.get("input_cost_per_token") is not None:
|
||||
Router._inherit_builtin_cache_pricing(
|
||||
model_info=_model_info_dict,
|
||||
|
|
@ -9249,6 +9305,11 @@ class Router:
|
|||
field_value = deployment.litellm_params.get(field)
|
||||
if field_value is not None:
|
||||
model_info[field] = field_value
|
||||
Router._inherit_builtin_base_rates_for_off_peak(
|
||||
model_info=model_info,
|
||||
backend_model=deployment.litellm_params.model,
|
||||
custom_llm_provider=deployment.litellm_params.custom_llm_provider,
|
||||
)
|
||||
if model_info.get("input_cost_per_token") is not None:
|
||||
Router._inherit_builtin_cache_pricing(
|
||||
model_info=model_info,
|
||||
|
|
|
|||
|
|
@ -193,6 +193,38 @@ class AgenticLoopParams(TypedDict, total=False):
|
|||
"""The LLM provider name (e.g., 'bedrock', 'anthropic')"""
|
||||
|
||||
|
||||
class OffPeakWindow(TypedDict, total=False):
|
||||
"""One off-peak rule: UTC time-of-day windows, optionally restricted to weekdays.
|
||||
|
||||
hours_utc is a "HH:MM-HH:MM" string in UTC, or a list of them; a window may wrap past
|
||||
midnight and an equal-ended window covers the whole day. weekdays is a list of days the
|
||||
rule applies on, as ISO-8601 numbers (1 = Monday .. 7 = Sunday) or English day names;
|
||||
omitted means every day. The weekday is read on the calendar named by the block's
|
||||
weekday_timezone.
|
||||
"""
|
||||
|
||||
hours_utc: ReadOnly[str | Sequence[str]]
|
||||
weekdays: ReadOnly[Sequence[int | str]]
|
||||
|
||||
|
||||
class OffPeakPricing(TypedDict, total=False):
|
||||
"""Time-windowed off-peak rates for providers that discount by time of day (e.g. DeepSeek).
|
||||
|
||||
hours_utc is a "HH:MM-HH:MM" string in UTC, or a list of them for multiple daily windows,
|
||||
applying on every day of the week; a window may wrap past midnight. windows adds
|
||||
day-of-week-qualified rules (e.g. weekend-only whole-day off-peak), matched as a union
|
||||
with hours_utc. weekday_timezone names the IANA calendar weekdays are read on, defaulting
|
||||
to UTC. Any rate left unset falls back to the standard rate.
|
||||
"""
|
||||
|
||||
hours_utc: ReadOnly[str | Sequence[str]]
|
||||
windows: ReadOnly[Sequence[OffPeakWindow]]
|
||||
weekday_timezone: ReadOnly[str]
|
||||
input_cost_per_token: ReadOnly[float]
|
||||
output_cost_per_token: ReadOnly[float]
|
||||
cache_read_input_token_cost: ReadOnly[float]
|
||||
|
||||
|
||||
class ModelInfoBase(ProviderSpecificModelInfo, total=False):
|
||||
key: Required[str] # the key in litellm.model_cost which is returned
|
||||
|
||||
|
|
@ -225,6 +257,7 @@ class ModelInfoBase(ProviderSpecificModelInfo, total=False):
|
|||
# Smallest prefix this model will actually cache, whatever caching mechanism its provider uses.
|
||||
# Absent means the provider-agnostic default applies; see MINIMUM_PROMPT_CACHE_TOKEN_COUNT.
|
||||
prompt_cache_min_tokens: int | None
|
||||
off_peak_pricing: ReadOnly[OffPeakPricing | None] # time-windowed off-peak rates
|
||||
input_cost_per_character: float | None # only for vertex ai models
|
||||
input_cost_per_audio_token: float | None
|
||||
input_cost_per_token_above_128k_tokens: float | None # only for vertex ai models
|
||||
|
|
@ -3486,17 +3519,22 @@ class CustomPricingLiteLLMParams(MirroredPricingParams):
|
|||
return {k: v for k, v in model_info.items() if k not in cls.model_fields}
|
||||
|
||||
|
||||
SHARED_BACKEND_MODEL_INFO_FIELDS: Final[frozenset[str]] = frozenset(
|
||||
ModelInfoBase.__required_keys__ | ModelInfoBase.__optional_keys__
|
||||
) - frozenset(CustomPricingLiteLLMParams.model_fields)
|
||||
DEPLOYMENT_SCOPED_PRICING_FIELDS: Final[frozenset[str]] = frozenset({"off_peak_pricing"})
|
||||
|
||||
SHARED_BACKEND_MODEL_INFO_FIELDS: Final[frozenset[str]] = (
|
||||
frozenset(ModelInfoBase.__required_keys__ | ModelInfoBase.__optional_keys__)
|
||||
- frozenset(CustomPricingLiteLLMParams.model_fields)
|
||||
- DEPLOYMENT_SCOPED_PRICING_FIELDS
|
||||
)
|
||||
|
||||
|
||||
def shared_backend_model_info(model_info: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Return only the fields safe to register under a shared ``{provider}/{model}``
|
||||
key in ``litellm.model_cost``: cost-map schema fields (``ModelInfoBase``) minus
|
||||
per-deployment pricing overrides. Per-deployment metadata (``id``,
|
||||
``access_via_team_ids``, arbitrary custom keys) never belongs on the shared key;
|
||||
it stays under the deployment's unique model id.
|
||||
per-deployment pricing overrides and deployment-scoped pricing blocks such as
|
||||
``off_peak_pricing``. Per-deployment metadata (``id``, ``access_via_team_ids``,
|
||||
arbitrary custom keys) never belongs on the shared key; it stays under the
|
||||
deployment's unique model id.
|
||||
"""
|
||||
return {k: v for k, v in model_info.items() if k in SHARED_BACKEND_MODEL_INFO_FIELDS}
|
||||
|
||||
|
|
|
|||
|
|
@ -2869,10 +2869,9 @@ def _update_dictionary(existing_dict: dict, new_dict: dict) -> dict:
|
|||
elif isinstance(v, dict):
|
||||
existing_nested_dict = existing_dict.get(k)
|
||||
if isinstance(existing_nested_dict, dict):
|
||||
existing_nested_dict.update(v)
|
||||
existing_dict[k] = existing_nested_dict
|
||||
existing_dict[k] = {**existing_nested_dict, **v} # mutable-ok: copy-on-write merge
|
||||
else:
|
||||
existing_dict[k] = v
|
||||
existing_dict[k] = dict(v) # mutable-ok: detached copy, never the caller's dict by reference
|
||||
else:
|
||||
existing_dict[k] = v
|
||||
|
||||
|
|
@ -5860,6 +5859,7 @@ def _get_model_info_helper(
|
|||
cache_creation_input_token_cost_above_1hr=_model_info.get(
|
||||
"cache_creation_input_token_cost_above_1hr", None
|
||||
),
|
||||
off_peak_pricing=_model_info.get("off_peak_pricing", None),
|
||||
input_cost_per_character=_model_info.get("input_cost_per_character", None),
|
||||
input_cost_per_token_above_128k_tokens=_model_info.get("input_cost_per_token_above_128k_tokens", None),
|
||||
input_cost_per_token_above_200k_tokens=_model_info.get("input_cost_per_token_above_200k_tokens", None),
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ from litellm.litellm_core_utils.llm_cost_calc.utils import (
|
|||
TokenTypeCostBreakdown,
|
||||
_calculate_input_cost,
|
||||
_get_token_base_cost,
|
||||
_is_off_peak,
|
||||
_is_within_off_peak_window,
|
||||
calculate_cache_writing_cost,
|
||||
generic_cost_per_token,
|
||||
get_token_type_cost_breakdown,
|
||||
|
|
@ -409,6 +411,377 @@ def test_get_token_base_cost_picks_highest_crossed_tier():
|
|||
assert prompt_base_cost == 9e-6
|
||||
|
||||
|
||||
def test_is_within_off_peak_window_same_day():
|
||||
from datetime import datetime, timezone
|
||||
|
||||
window = "09:00-17:00"
|
||||
assert _is_within_off_peak_window(window, datetime(2026, 1, 1, 12, 0, tzinfo=timezone.utc)) is True
|
||||
assert _is_within_off_peak_window(window, datetime(2026, 1, 1, 8, 59, tzinfo=timezone.utc)) is False
|
||||
assert _is_within_off_peak_window(window, datetime(2026, 1, 1, 9, 0, tzinfo=timezone.utc)) is True
|
||||
assert _is_within_off_peak_window(window, datetime(2026, 1, 1, 17, 0, tzinfo=timezone.utc)) is False
|
||||
|
||||
|
||||
def test_is_within_off_peak_window_wraps_midnight():
|
||||
from datetime import datetime, timezone
|
||||
|
||||
window = "16:30-00:30"
|
||||
assert _is_within_off_peak_window(window, datetime(2026, 1, 1, 18, 0, tzinfo=timezone.utc)) is True
|
||||
assert _is_within_off_peak_window(window, datetime(2026, 1, 1, 0, 15, tzinfo=timezone.utc)) is True
|
||||
assert _is_within_off_peak_window(window, datetime(2026, 1, 1, 16, 30, tzinfo=timezone.utc)) is True
|
||||
assert _is_within_off_peak_window(window, datetime(2026, 1, 1, 0, 30, tzinfo=timezone.utc)) is False
|
||||
assert _is_within_off_peak_window(window, datetime(2026, 1, 1, 12, 0, tzinfo=timezone.utc)) is False
|
||||
|
||||
|
||||
def test_is_within_off_peak_window_equal_start_and_end_covers_whole_day():
|
||||
"""An equal start and end is the natural way to spell off-peak all day. It used to take the
|
||||
non-wrap branch, where start <= now < end can never hold, so it matched nothing and billed at
|
||||
standard rates around the clock without raising or logging anything."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
for window in ("00:00-00:00", "10:00-10:00"):
|
||||
for hour in range(24):
|
||||
assert (
|
||||
_is_within_off_peak_window(window, datetime(2026, 1, 1, hour, 0, tzinfo=timezone.utc)) is True
|
||||
), f"{window} should cover {hour:02d}:00"
|
||||
|
||||
|
||||
def test_is_within_off_peak_window_multiple_windows():
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# Providers like DeepSeek V4 have more than one daily peak/off-peak window.
|
||||
windows = ["01:00-05:00", "13:00-16:00"]
|
||||
assert _is_within_off_peak_window(windows, datetime(2026, 1, 1, 3, 0, tzinfo=timezone.utc)) is True
|
||||
assert _is_within_off_peak_window(windows, datetime(2026, 1, 1, 14, 30, tzinfo=timezone.utc)) is True
|
||||
assert _is_within_off_peak_window(windows, datetime(2026, 1, 1, 9, 0, tzinfo=timezone.utc)) is False
|
||||
# a malformed entry in the list is ignored, valid entries still match
|
||||
assert _is_within_off_peak_window(["bad", "13:00-16:00"], datetime(2026, 1, 1, 14, 0, tzinfo=timezone.utc)) is True
|
||||
assert _is_within_off_peak_window([], datetime(2026, 1, 1, 14, 0, tzinfo=timezone.utc)) is False
|
||||
|
||||
|
||||
def test_is_within_off_peak_window_normalizes_timezone_aware_input():
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
# A caller may pass a non-UTC aware datetime; the window is UTC and must be
|
||||
# evaluated in UTC, not against the caller's wall-clock. 09:00 at UTC+8 is
|
||||
# 01:00 UTC, inside the 01:00-05:00 window.
|
||||
tz_plus_8 = timezone(timedelta(hours=8))
|
||||
assert _is_within_off_peak_window("01:00-05:00", datetime(2026, 1, 1, 9, 0, tzinfo=tz_plus_8)) is True
|
||||
assert _is_within_off_peak_window("01:00-05:00", datetime(2026, 1, 1, 12, 0, tzinfo=tz_plus_8)) is True
|
||||
# 06:00 at UTC+8 is 22:00 UTC the previous day, outside the window
|
||||
assert _is_within_off_peak_window("01:00-05:00", datetime(2026, 1, 1, 6, 0, tzinfo=tz_plus_8)) is False
|
||||
|
||||
|
||||
def test_is_within_off_peak_window_malformed_returns_false():
|
||||
from datetime import datetime, timezone
|
||||
|
||||
now = datetime(2026, 1, 1, 18, 0, tzinfo=timezone.utc)
|
||||
assert _is_within_off_peak_window("not-a-window", now) is False
|
||||
assert _is_within_off_peak_window("16:30", now) is False
|
||||
assert _is_within_off_peak_window("25:00-26:00", now) is False
|
||||
|
||||
|
||||
def test_is_off_peak_weekday_qualified_windows_deepseek_schedule():
|
||||
"""DeepSeek since 2026-08-23: peak is 01:00-04:00 and 06:00-10:00 UTC on weekdays only, with
|
||||
weekends off-peak around the clock. The weekday axis is not a filter on one window set; on
|
||||
two days of seven the off-peak window becomes the whole day, so the schedule needs two
|
||||
day-qualified rules. The weekend instants inside would-be peak hours are the ones a
|
||||
time-only implementation bills wrong."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
deepseek = {
|
||||
"windows": [
|
||||
{"hours_utc": ["00:00-01:00", "04:00-06:00", "10:00-00:00"], "weekdays": [1, 2, 3, 4, 5]},
|
||||
{"hours_utc": "00:00-00:00", "weekdays": [6, 7]},
|
||||
],
|
||||
}
|
||||
peak_instants = [
|
||||
datetime(2026, 8, 24, 1, 30, tzinfo=timezone.utc),
|
||||
datetime(2026, 8, 26, 7, 0, tzinfo=timezone.utc),
|
||||
datetime(2026, 8, 28, 9, 59, tzinfo=timezone.utc),
|
||||
]
|
||||
off_peak_instants = [
|
||||
datetime(2026, 8, 23, 1, 30, tzinfo=timezone.utc),
|
||||
datetime(2026, 8, 29, 2, 0, tzinfo=timezone.utc),
|
||||
datetime(2026, 8, 30, 8, 0, tzinfo=timezone.utc),
|
||||
datetime(2026, 8, 26, 5, 0, tzinfo=timezone.utc),
|
||||
datetime(2026, 8, 28, 16, 30, tzinfo=timezone.utc),
|
||||
datetime(2026, 8, 24, 0, 30, tzinfo=timezone.utc),
|
||||
]
|
||||
for when in peak_instants:
|
||||
assert _is_off_peak(deepseek, when) is False, f"{when.isoformat()} should bill peak"
|
||||
for when in off_peak_instants:
|
||||
assert _is_off_peak(deepseek, when) is True, f"{when.isoformat()} should bill off-peak"
|
||||
|
||||
|
||||
def test_is_off_peak_weekday_timezone_reads_vendor_calendar():
|
||||
"""The UTC and Asia/Shanghai calendars only disagree about the date over 16:00-24:00 UTC, so
|
||||
a window in that stretch is the one place a vendor-local weekday differs from a UTC one:
|
||||
2026-08-28T16:30Z is Friday in UTC but already Saturday in Beijing."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
shanghai_saturday = {
|
||||
"weekday_timezone": "Asia/Shanghai",
|
||||
"windows": [{"hours_utc": "16:00-17:00", "weekdays": [6]}],
|
||||
}
|
||||
assert _is_off_peak(shanghai_saturday, datetime(2026, 8, 28, 16, 30, tzinfo=timezone.utc)) is True
|
||||
assert _is_off_peak(shanghai_saturday, datetime(2026, 8, 29, 16, 30, tzinfo=timezone.utc)) is False
|
||||
|
||||
|
||||
def test_is_off_peak_weekdays_default_utc_calendar_and_accept_names():
|
||||
from datetime import datetime, timezone
|
||||
|
||||
named_weekend = {"windows": [{"hours_utc": "00:00-00:00", "weekdays": ["Sat", "sunday"]}]}
|
||||
assert _is_off_peak(named_weekend, datetime(2026, 8, 29, 12, 0, tzinfo=timezone.utc)) is True
|
||||
assert _is_off_peak(named_weekend, datetime(2026, 8, 28, 12, 0, tzinfo=timezone.utc)) is False
|
||||
|
||||
utc_friday = {"windows": [{"hours_utc": "16:00-17:00", "weekdays": [5]}]}
|
||||
assert _is_off_peak(utc_friday, datetime(2026, 8, 28, 16, 30, tzinfo=timezone.utc)) is True
|
||||
assert _is_off_peak(utc_friday, datetime(2026, 8, 29, 16, 30, tzinfo=timezone.utc)) is False
|
||||
|
||||
|
||||
def test_is_off_peak_naive_current_time_read_as_utc():
|
||||
from datetime import datetime
|
||||
|
||||
block = {"windows": [{"hours_utc": "16:00-17:00", "weekdays": [5]}]}
|
||||
assert _is_off_peak(block, datetime(2026, 8, 28, 16, 30)) is True
|
||||
assert _is_off_peak(block, datetime(2026, 8, 29, 16, 30)) is False
|
||||
|
||||
|
||||
def test_is_off_peak_invalid_weekday_timezone_falls_back_to_utc():
|
||||
from datetime import datetime, timezone
|
||||
|
||||
block = {"weekday_timezone": "Not/AZone", "windows": [{"hours_utc": "16:00-17:00", "weekdays": [5]}]}
|
||||
assert _is_off_peak(block, datetime(2026, 8, 28, 16, 30, tzinfo=timezone.utc)) is True
|
||||
assert _is_off_peak(block, datetime(2026, 8, 29, 16, 30, tzinfo=timezone.utc)) is False
|
||||
|
||||
|
||||
def test_is_off_peak_ignores_malformed_weekday_rules():
|
||||
from datetime import datetime, timezone
|
||||
|
||||
when = datetime(2026, 8, 29, 12, 0, tzinfo=timezone.utc)
|
||||
assert _is_off_peak({"windows": [{"hours_utc": "00:00-00:00", "weekdays": []}]}, when) is False
|
||||
assert _is_off_peak({"windows": [{"hours_utc": "00:00-00:00", "weekdays": [0, 8, "noday", True]}]}, when) is False
|
||||
assert _is_off_peak({"windows": [{"weekdays": [6]}]}, when) is False
|
||||
assert _is_off_peak({"windows": [{"hours_utc": 1630}]}, when) is False
|
||||
assert _is_off_peak({"windows": ["00:00-00:00"]}, when) is False
|
||||
assert _is_off_peak({"windows": "00:00-00:00"}, when) is False
|
||||
assert _is_off_peak({"hours_utc": 1630}, when) is False
|
||||
assert _is_off_peak({}, when) is False
|
||||
|
||||
|
||||
def test_is_off_peak_flat_hours_and_windows_are_a_union():
|
||||
from datetime import datetime, timezone
|
||||
|
||||
block = {
|
||||
"hours_utc": "04:00-06:00",
|
||||
"windows": [{"hours_utc": "00:00-00:00", "weekdays": [7]}],
|
||||
}
|
||||
assert _is_off_peak(block, datetime(2026, 8, 28, 5, 0, tzinfo=timezone.utc)) is True
|
||||
assert _is_off_peak(block, datetime(2026, 8, 30, 20, 0, tzinfo=timezone.utc)) is True
|
||||
assert _is_off_peak(block, datetime(2026, 8, 28, 20, 0, tzinfo=timezone.utc)) is False
|
||||
|
||||
|
||||
def test_get_token_base_cost_weekend_only_off_peak_rate():
|
||||
from datetime import datetime, timezone
|
||||
from typing import cast
|
||||
|
||||
from litellm.types.utils import ModelInfo
|
||||
|
||||
model_info = cast(
|
||||
ModelInfo,
|
||||
{
|
||||
"input_cost_per_token": 1e-6,
|
||||
"output_cost_per_token": 2e-6,
|
||||
"off_peak_pricing": {
|
||||
"windows": [
|
||||
{"hours_utc": ["00:00-01:00", "04:00-06:00", "10:00-00:00"], "weekdays": [1, 2, 3, 4, 5]},
|
||||
{"hours_utc": "00:00-00:00", "weekdays": [6, 7]},
|
||||
],
|
||||
"input_cost_per_token": 5e-7,
|
||||
"output_cost_per_token": 1e-6,
|
||||
},
|
||||
},
|
||||
)
|
||||
usage = Usage(prompt_tokens=100, completion_tokens=50, total_tokens=150)
|
||||
|
||||
saturday_peak_hours = _get_token_base_cost(
|
||||
model_info, usage, current_time=datetime(2026, 8, 29, 2, 0, tzinfo=timezone.utc)
|
||||
)
|
||||
assert saturday_peak_hours[:2] == (5e-7, 1e-6)
|
||||
|
||||
monday_same_hours = _get_token_base_cost(
|
||||
model_info, usage, current_time=datetime(2026, 8, 24, 2, 0, tzinfo=timezone.utc)
|
||||
)
|
||||
assert monday_same_hours[:2] == (1e-6, 2e-6)
|
||||
|
||||
|
||||
def test_get_token_base_cost_applies_off_peak_pricing():
|
||||
from datetime import datetime, timezone
|
||||
from typing import cast
|
||||
|
||||
from litellm.types.utils import ModelInfo
|
||||
|
||||
model_info = cast(
|
||||
ModelInfo,
|
||||
{
|
||||
"input_cost_per_token": 1e-6,
|
||||
"output_cost_per_token": 2e-6,
|
||||
"cache_read_input_token_cost": 1e-7,
|
||||
"off_peak_pricing": {
|
||||
"hours_utc": "16:30-00:30",
|
||||
"input_cost_per_token": 5e-7,
|
||||
"output_cost_per_token": 1e-6,
|
||||
"cache_read_input_token_cost": 5e-8,
|
||||
},
|
||||
},
|
||||
)
|
||||
usage = Usage(prompt_tokens=100, completion_tokens=50, total_tokens=150)
|
||||
|
||||
off_peak = _get_token_base_cost(model_info, usage, current_time=datetime(2026, 1, 1, 18, 0, tzinfo=timezone.utc))
|
||||
assert off_peak[0] == 5e-7
|
||||
assert off_peak[1] == 1e-6
|
||||
assert off_peak[4] == 5e-8
|
||||
|
||||
peak = _get_token_base_cost(model_info, usage, current_time=datetime(2026, 1, 1, 12, 0, tzinfo=timezone.utc))
|
||||
assert peak[0] == 1e-6
|
||||
assert peak[1] == 2e-6
|
||||
assert peak[4] == 1e-7
|
||||
|
||||
|
||||
def test_get_token_base_cost_non_mapping_off_peak_block_bills_standard_rates():
|
||||
"""A truthy non-mapping off_peak_pricing value (a bare string or a list in
|
||||
YAML) must bill standard rates rather than raising, matching how every
|
||||
other malformed piece of the block behaves.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
from typing import cast
|
||||
|
||||
from litellm.types.utils import ModelInfo
|
||||
|
||||
usage = Usage(prompt_tokens=100, completion_tokens=50, total_tokens=150)
|
||||
when = datetime(2026, 1, 1, 18, 0, tzinfo=timezone.utc)
|
||||
|
||||
for malformed_block in ("16:00-19:00", ["16:00-19:00"], 5e-7, True):
|
||||
model_info = cast(
|
||||
ModelInfo,
|
||||
{
|
||||
"input_cost_per_token": 1e-6,
|
||||
"output_cost_per_token": 2e-6,
|
||||
"off_peak_pricing": malformed_block,
|
||||
},
|
||||
)
|
||||
result = _get_token_base_cost(model_info, usage, current_time=when)
|
||||
assert result[0] == 1e-6
|
||||
assert result[1] == 2e-6
|
||||
|
||||
|
||||
def test_get_token_base_cost_off_peak_falls_back_to_standard_when_unset():
|
||||
from datetime import datetime, timezone
|
||||
from typing import cast
|
||||
|
||||
from litellm.types.utils import ModelInfo
|
||||
|
||||
model_info = cast(
|
||||
ModelInfo,
|
||||
{
|
||||
"input_cost_per_token": 1e-6,
|
||||
"output_cost_per_token": 2e-6,
|
||||
"off_peak_pricing": {"hours_utc": "16:30-00:30", "input_cost_per_token": 5e-7},
|
||||
},
|
||||
)
|
||||
usage = Usage(prompt_tokens=100, completion_tokens=50, total_tokens=150)
|
||||
|
||||
result = _get_token_base_cost(model_info, usage, current_time=datetime(2026, 1, 1, 18, 0, tzinfo=timezone.utc))
|
||||
assert result[0] == 5e-7
|
||||
assert result[1] == 2e-6
|
||||
|
||||
|
||||
def test_get_token_base_cost_off_peak_wins_over_threshold():
|
||||
from datetime import datetime, timezone
|
||||
from typing import cast
|
||||
|
||||
from litellm.types.utils import ModelInfo
|
||||
|
||||
model_info = cast(
|
||||
ModelInfo,
|
||||
{
|
||||
"input_cost_per_token": 1e-6,
|
||||
"output_cost_per_token": 2e-6,
|
||||
"input_cost_per_token_above_200k_tokens": 3e-6,
|
||||
"output_cost_per_token_above_200k_tokens": 4e-6,
|
||||
"off_peak_pricing": {
|
||||
"hours_utc": "16:30-00:30",
|
||||
"input_cost_per_token": 5e-7,
|
||||
"output_cost_per_token": 1e-6,
|
||||
},
|
||||
},
|
||||
)
|
||||
usage = Usage(prompt_tokens=250000, completion_tokens=250000, total_tokens=500000)
|
||||
|
||||
off_peak = _get_token_base_cost(model_info, usage, current_time=datetime(2026, 1, 1, 18, 0, tzinfo=timezone.utc))
|
||||
assert off_peak[0] == 5e-7
|
||||
assert off_peak[1] == 1e-6
|
||||
|
||||
peak = _get_token_base_cost(model_info, usage, current_time=datetime(2026, 1, 1, 12, 0, tzinfo=timezone.utc))
|
||||
assert peak[0] == 3e-6
|
||||
assert peak[1] == 4e-6
|
||||
|
||||
|
||||
def test_get_model_info_propagates_off_peak_fields():
|
||||
model_name = "test-off-peak-model"
|
||||
off_peak_pricing = {
|
||||
"hours_utc": "16:30-00:30",
|
||||
"input_cost_per_token": 5e-7,
|
||||
"output_cost_per_token": 1e-6,
|
||||
"cache_read_input_token_cost": 5e-8,
|
||||
}
|
||||
litellm.register_model(
|
||||
{
|
||||
model_name: {
|
||||
"litellm_provider": "openai",
|
||||
"mode": "chat",
|
||||
"input_cost_per_token": 1e-6,
|
||||
"output_cost_per_token": 2e-6,
|
||||
"off_peak_pricing": off_peak_pricing,
|
||||
}
|
||||
}
|
||||
)
|
||||
info = litellm.get_model_info(model=model_name)
|
||||
assert info["off_peak_pricing"] == off_peak_pricing
|
||||
|
||||
|
||||
def test_get_token_base_cost_off_peak_wins_over_tiered_pricing():
|
||||
"""Tiered pricing resolves base rates on its own path and returns early, so off-peak has to
|
||||
be applied there too or a model carrying both would silently bill the tier rate all day."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
model_name = "litellm-test-off-peak-tiered"
|
||||
litellm.register_model(
|
||||
{
|
||||
model_name: {
|
||||
"litellm_provider": "openai",
|
||||
"mode": "chat",
|
||||
"tiered_pricing": [
|
||||
{"range": [0, 128000], "input_cost_per_token": 3e-6, "output_cost_per_token": 6e-6},
|
||||
],
|
||||
"off_peak_pricing": {
|
||||
"hours_utc": "16:30-00:30",
|
||||
"input_cost_per_token": 5e-7,
|
||||
"output_cost_per_token": 1e-6,
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
info = litellm.get_model_info(model=model_name)
|
||||
usage = Usage(prompt_tokens=1_000, completion_tokens=100, total_tokens=1_100)
|
||||
|
||||
inside = _get_token_base_cost(info, usage, current_time=datetime(2026, 1, 1, 18, 0, tzinfo=timezone.utc))
|
||||
assert inside[:2] == (5e-7, 1e-6)
|
||||
|
||||
outside = _get_token_base_cost(info, usage, current_time=datetime(2026, 1, 1, 12, 0, tzinfo=timezone.utc))
|
||||
assert outside[:2] == (3e-6, 6e-6)
|
||||
|
||||
|
||||
def test_generic_cost_per_token_gpt54_above_272k_tokens(_local_model_cost_map):
|
||||
"""GPT-5.4/5.4-pro: prompts >272K input tokens priced at 2x input, 1.5x output."""
|
||||
model = "gpt-5.4"
|
||||
|
|
|
|||
|
|
@ -793,3 +793,203 @@ def test_embedding_direct_sdk_custom_pricing_still_registers_shared_key():
|
|||
finally:
|
||||
litellm.model_cost.pop(model_key, None)
|
||||
_invalidate_model_cost_lowercase_map()
|
||||
|
||||
|
||||
def test_update_dictionary_merges_nested_dicts_without_aliasing():
|
||||
"""A nested dict must be merged copy-on-write: the pre-existing nested dict
|
||||
object stays untouched, and the caller's incoming nested dict is never
|
||||
inserted by reference into the merged result.
|
||||
"""
|
||||
from litellm.utils import _update_dictionary
|
||||
|
||||
existing_nested = {"hours_utc": "01:00-02:00"}
|
||||
existing = {"off_peak_pricing": existing_nested}
|
||||
incoming_nested = {"windows": [{"hours_utc": "16:00-19:00", "weekdays": [2]}]}
|
||||
incoming = {"off_peak_pricing": incoming_nested}
|
||||
|
||||
merged = _update_dictionary(existing, incoming)
|
||||
|
||||
assert merged["off_peak_pricing"] == {
|
||||
"hours_utc": "01:00-02:00",
|
||||
"windows": [{"hours_utc": "16:00-19:00", "weekdays": [2]}],
|
||||
}
|
||||
assert existing_nested == {"hours_utc": "01:00-02:00"}
|
||||
assert merged["off_peak_pricing"] is not incoming_nested
|
||||
|
||||
fresh = _update_dictionary({}, incoming)
|
||||
assert fresh["off_peak_pricing"] == incoming_nested
|
||||
assert fresh["off_peak_pricing"] is not incoming_nested
|
||||
|
||||
|
||||
def test_router_deployments_sharing_backend_keep_their_own_off_peak_pricing():
|
||||
"""Two deployments of the same backend model with different
|
||||
``off_peak_pricing`` blocks must each keep their own schedule under their
|
||||
unique model id, and neither block may leak onto the shared backend keys.
|
||||
|
||||
Before the fix, ``register_model`` inserted the first deployment's block by
|
||||
reference into the built-in ``gpt-4o-mini`` entry, and the second
|
||||
deployment's registration merged its keys into that same object, corrupting
|
||||
the first deployment's schedule and polluting the built-in entry.
|
||||
"""
|
||||
from litellm import Router
|
||||
|
||||
active_block = {
|
||||
"windows": [{"hours_utc": "16:00-19:00", "weekdays": [2]}],
|
||||
"input_cost_per_token": 5e-07,
|
||||
"output_cost_per_token": 1e-06,
|
||||
}
|
||||
inactive_block = {
|
||||
"hours_utc": "05:00-06:00",
|
||||
"input_cost_per_token": 5e-07,
|
||||
"output_cost_per_token": 1e-06,
|
||||
}
|
||||
shared_keys = ["gpt-4o-mini", "openai/gpt-4o-mini"]
|
||||
deployment_ids = ["offpeak-alias-dep-1", "offpeak-alias-dep-2"]
|
||||
original_entries = _snapshot_model_cost_entries(shared_keys)
|
||||
|
||||
router = Router(
|
||||
model_list=[
|
||||
{
|
||||
"model_name": "offpeak-active-weekday",
|
||||
"litellm_params": {
|
||||
"model": "openai/gpt-4o-mini",
|
||||
"api_key": "fake-key-for-registration",
|
||||
},
|
||||
"model_info": {
|
||||
"id": deployment_ids[0],
|
||||
"input_cost_per_token": 1e-06,
|
||||
"output_cost_per_token": 2e-06,
|
||||
"off_peak_pricing": dict(active_block),
|
||||
},
|
||||
},
|
||||
{
|
||||
"model_name": "offpeak-inactive-hours",
|
||||
"litellm_params": {
|
||||
"model": "openai/gpt-4o-mini",
|
||||
"api_key": "fake-key-for-registration",
|
||||
},
|
||||
"model_info": {
|
||||
"id": deployment_ids[1],
|
||||
"input_cost_per_token": 1e-06,
|
||||
"output_cost_per_token": 2e-06,
|
||||
"off_peak_pricing": dict(inactive_block),
|
||||
},
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
try:
|
||||
registered_first = litellm.model_cost[deployment_ids[0]]["off_peak_pricing"]
|
||||
registered_second = litellm.model_cost[deployment_ids[1]]["off_peak_pricing"]
|
||||
assert registered_first == active_block
|
||||
assert registered_second == inactive_block
|
||||
for shared_key in shared_keys:
|
||||
shared_entry = litellm.model_cost.get(shared_key) or {}
|
||||
assert not shared_entry.get("off_peak_pricing")
|
||||
finally:
|
||||
for deployment_id in deployment_ids:
|
||||
litellm.model_cost.pop(deployment_id, None)
|
||||
_restore_model_cost_entries(original_entries)
|
||||
del router
|
||||
|
||||
|
||||
def test_router_off_peak_only_deployment_inherits_builtin_base_rates():
|
||||
"""A deployment that sets only ``off_peak_pricing`` on its model_info must
|
||||
still be costed from its deployment-scoped entry: the base token rates are
|
||||
inherited from the backend model's built-in cost map entry, since the
|
||||
shared backend key deliberately never carries the off-peak block.
|
||||
"""
|
||||
from litellm import Router
|
||||
|
||||
block = {
|
||||
"hours_utc": "00:00-00:00",
|
||||
"input_cost_per_token": 5e-05,
|
||||
"output_cost_per_token": 1e-04,
|
||||
}
|
||||
shared_keys = ["gpt-4o-mini", "openai/gpt-4o-mini"]
|
||||
deployment_id = "offpeak-only-dep-1"
|
||||
original_entries = _snapshot_model_cost_entries(shared_keys + [deployment_id])
|
||||
builtin_info = litellm.get_model_info(model="openai/gpt-4o-mini")
|
||||
|
||||
router = Router(
|
||||
model_list=[
|
||||
{
|
||||
"model_name": "offpeak-only",
|
||||
"litellm_params": {
|
||||
"model": "openai/gpt-4o-mini",
|
||||
"api_key": "fake-key-for-registration",
|
||||
},
|
||||
"model_info": {"id": deployment_id, "off_peak_pricing": dict(block)},
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
try:
|
||||
entry = litellm.model_cost[deployment_id]
|
||||
assert entry["off_peak_pricing"] == block
|
||||
assert entry["input_cost_per_token"] is not None
|
||||
assert entry["input_cost_per_token"] == builtin_info["input_cost_per_token"]
|
||||
assert entry["output_cost_per_token"] == builtin_info["output_cost_per_token"]
|
||||
for shared_key in shared_keys:
|
||||
shared_entry = litellm.model_cost.get(shared_key) or {}
|
||||
assert not shared_entry.get("off_peak_pricing")
|
||||
finally:
|
||||
_restore_model_cost_entries(original_entries)
|
||||
del router
|
||||
|
||||
|
||||
def test_use_custom_pricing_for_model_sees_off_peak_only_model_info():
|
||||
from litellm.litellm_core_utils.litellm_logging import use_custom_pricing_for_model
|
||||
|
||||
block = {"hours_utc": "00:00-00:00", "input_cost_per_token": 5e-05}
|
||||
assert use_custom_pricing_for_model({"metadata": {"model_info": {"off_peak_pricing": block}}}) is True
|
||||
assert use_custom_pricing_for_model({"metadata": {"model_info": {"off_peak_pricing": None}}}) is False
|
||||
assert use_custom_pricing_for_model({"metadata": {"model_info": {"id": "some-id"}}}) is False
|
||||
|
||||
|
||||
def test_completion_cost_applies_off_peak_only_deployment_pricing():
|
||||
"""End to end through the cost calculator: with ``custom_pricing`` set and
|
||||
a ``router_model_id`` whose entry carries only an always-on off-peak block,
|
||||
the request bills at the block's rates rather than the shared backend rate.
|
||||
"""
|
||||
from litellm import Router
|
||||
from litellm.types.utils import ModelResponse, Usage
|
||||
|
||||
block = {
|
||||
"hours_utc": "00:00-00:00",
|
||||
"input_cost_per_token": 5e-05,
|
||||
"output_cost_per_token": 1e-04,
|
||||
}
|
||||
shared_keys = ["gpt-4o-mini", "openai/gpt-4o-mini"]
|
||||
deployment_id = "offpeak-only-dep-2"
|
||||
original_entries = _snapshot_model_cost_entries(shared_keys + [deployment_id])
|
||||
|
||||
router = Router(
|
||||
model_list=[
|
||||
{
|
||||
"model_name": "offpeak-only",
|
||||
"litellm_params": {
|
||||
"model": "openai/gpt-4o-mini",
|
||||
"api_key": "fake-key-for-registration",
|
||||
},
|
||||
"model_info": {"id": deployment_id, "off_peak_pricing": dict(block)},
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
try:
|
||||
response = ModelResponse(
|
||||
model="gpt-4o-mini",
|
||||
usage=Usage(prompt_tokens=100, completion_tokens=50, total_tokens=150),
|
||||
)
|
||||
cost = litellm.completion_cost(
|
||||
completion_response=response,
|
||||
model="openai/gpt-4o-mini",
|
||||
custom_llm_provider="openai",
|
||||
custom_pricing=True,
|
||||
router_model_id=deployment_id,
|
||||
)
|
||||
assert cost == pytest.approx(100 * 5e-05 + 50 * 1e-04)
|
||||
finally:
|
||||
_restore_model_cost_entries(original_entries)
|
||||
del router
|
||||
|
|
|
|||
|
|
@ -538,6 +538,157 @@ def test_inherit_builtin_cache_pricing_noop_for_unknown_backend():
|
|||
assert model_info == {"input_cost_per_token": 0.000003}
|
||||
|
||||
|
||||
def test_inherit_builtin_base_rates_for_off_peak_fills_missing_rates():
|
||||
"""Direct unit test of the helper: an entry carrying only an
|
||||
off_peak_pricing block inherits the backend model's built-in base token
|
||||
rates, so cost lookup via the deployment id can bill standard rates
|
||||
outside the windows.
|
||||
"""
|
||||
backend_model = "gpt-4o-mini"
|
||||
builtin_info = litellm.get_model_info(model=backend_model, custom_llm_provider="openai")
|
||||
off_peak_block = {
|
||||
"hours_utc": "00:00-00:00",
|
||||
"input_cost_per_token": 5e-07,
|
||||
"output_cost_per_token": 1e-06,
|
||||
}
|
||||
model_info = {"off_peak_pricing": off_peak_block}
|
||||
|
||||
Router._inherit_builtin_base_rates_for_off_peak(
|
||||
model_info=model_info,
|
||||
backend_model=backend_model,
|
||||
custom_llm_provider="openai",
|
||||
)
|
||||
|
||||
assert model_info["input_cost_per_token"] == builtin_info["input_cost_per_token"]
|
||||
assert model_info["output_cost_per_token"] == builtin_info["output_cost_per_token"]
|
||||
assert model_info["off_peak_pricing"] == off_peak_block
|
||||
|
||||
|
||||
def test_inherit_builtin_base_rates_for_off_peak_carries_threshold_rates():
|
||||
"""A backend with above-threshold pricing hands the whole rate structure to
|
||||
the deployment entry, so peak-hour billing of large prompts through that
|
||||
entry matches the shared backend entry instead of flattening to the base
|
||||
rate.
|
||||
"""
|
||||
backend_model = "gemini/gemini-2.5-pro"
|
||||
builtin_info = litellm.get_model_info(model=backend_model)
|
||||
assert builtin_info["input_cost_per_token_above_200k_tokens"] is not None
|
||||
|
||||
model_info = {
|
||||
"off_peak_pricing": {"hours_utc": "00:00-00:00", "input_cost_per_token": 5e-07},
|
||||
}
|
||||
|
||||
Router._inherit_builtin_base_rates_for_off_peak(
|
||||
model_info=model_info,
|
||||
backend_model=backend_model,
|
||||
custom_llm_provider="gemini",
|
||||
)
|
||||
|
||||
assert model_info["input_cost_per_token"] == builtin_info["input_cost_per_token"]
|
||||
assert (
|
||||
model_info["input_cost_per_token_above_200k_tokens"]
|
||||
== builtin_info["input_cost_per_token_above_200k_tokens"]
|
||||
)
|
||||
assert (
|
||||
model_info["output_cost_per_token_above_200k_tokens"]
|
||||
== builtin_info["output_cost_per_token_above_200k_tokens"]
|
||||
)
|
||||
|
||||
|
||||
def test_inherit_builtin_base_rates_for_off_peak_carries_companion_billing_fields():
|
||||
"""Billing rules that are not literal cost rates, like the web search
|
||||
billing unit, must ride along, or grounding and regional uplifts would
|
||||
bill differently through the deployment entry than through the shared
|
||||
backend entry.
|
||||
"""
|
||||
backend_model = "gemini-3-pro-image"
|
||||
raw_entry = litellm.model_cost[backend_model]
|
||||
assert raw_entry.get("web_search_billing_unit") is not None
|
||||
|
||||
model_info = {
|
||||
"off_peak_pricing": {"hours_utc": "00:00-00:00", "input_cost_per_token": 5e-07},
|
||||
}
|
||||
|
||||
Router._inherit_builtin_base_rates_for_off_peak(
|
||||
model_info=model_info,
|
||||
backend_model=backend_model,
|
||||
custom_llm_provider=None,
|
||||
)
|
||||
|
||||
assert model_info["web_search_billing_unit"] == raw_entry["web_search_billing_unit"]
|
||||
assert model_info["input_cost_per_token"] == raw_entry["input_cost_per_token"]
|
||||
|
||||
|
||||
def test_inherit_builtin_base_rates_for_off_peak_tiered_only_backend_stores_no_zero():
|
||||
"""A tiered-only backend has no flat token rates; get_model_info synthesizes
|
||||
zeros for them, and storing those would mark the deployment explicitly
|
||||
priced free. The tier table itself must carry over as an isolated copy so
|
||||
mutating the deployment entry never touches the shared cost map.
|
||||
"""
|
||||
backend_model = "dashscope/qwen-flash"
|
||||
raw_tiers = litellm.model_cost[backend_model]["tiered_pricing"]
|
||||
|
||||
model_info = {
|
||||
"off_peak_pricing": {"hours_utc": "00:00-00:00", "input_cost_per_token": 5e-07},
|
||||
}
|
||||
|
||||
Router._inherit_builtin_base_rates_for_off_peak(
|
||||
model_info=model_info,
|
||||
backend_model=backend_model,
|
||||
custom_llm_provider="dashscope",
|
||||
)
|
||||
|
||||
assert model_info.get("input_cost_per_token") != 0
|
||||
assert model_info.get("output_cost_per_token") != 0
|
||||
assert model_info["tiered_pricing"] == raw_tiers
|
||||
assert model_info["tiered_pricing"] is not raw_tiers
|
||||
assert model_info["tiered_pricing"][0] is not raw_tiers[0]
|
||||
|
||||
original_first_tier = copy.deepcopy(raw_tiers[0])
|
||||
model_info["tiered_pricing"][0]["input_cost_per_token"] = 123.0
|
||||
assert raw_tiers[0] == original_first_tier
|
||||
|
||||
|
||||
def test_inherit_builtin_base_rates_for_off_peak_leaves_explicit_rates_alone():
|
||||
"""An entry that sets its own base rate beside the block already counts as
|
||||
a full custom pricing entry; the helper must not mix builtin rates into it.
|
||||
"""
|
||||
model_info = {
|
||||
"off_peak_pricing": {"hours_utc": "00:00-00:00", "input_cost_per_token": 5e-07},
|
||||
"input_cost_per_token": 3e-06,
|
||||
}
|
||||
|
||||
Router._inherit_builtin_base_rates_for_off_peak(
|
||||
model_info=model_info,
|
||||
backend_model="gpt-4o-mini",
|
||||
custom_llm_provider="openai",
|
||||
)
|
||||
|
||||
assert model_info["input_cost_per_token"] == 3e-06
|
||||
assert "output_cost_per_token" not in model_info
|
||||
|
||||
|
||||
def test_inherit_builtin_base_rates_for_off_peak_noop_without_block_or_backend():
|
||||
"""Nothing happens without an off_peak_pricing block, and an unmapped
|
||||
backend model leaves the entry unchanged rather than raising.
|
||||
"""
|
||||
plain_info = {"id": "dep-1"}
|
||||
Router._inherit_builtin_base_rates_for_off_peak(
|
||||
model_info=plain_info,
|
||||
backend_model="gpt-4o-mini",
|
||||
custom_llm_provider="openai",
|
||||
)
|
||||
assert plain_info == {"id": "dep-1"}
|
||||
|
||||
off_peak_info = {"off_peak_pricing": {"hours_utc": "00:00-00:00", "input_cost_per_token": 5e-07}}
|
||||
Router._inherit_builtin_base_rates_for_off_peak(
|
||||
model_info=off_peak_info,
|
||||
backend_model="this-backend-model-does-not-exist-x9y8z7",
|
||||
custom_llm_provider=None,
|
||||
)
|
||||
assert "input_cost_per_token" not in off_peak_info
|
||||
|
||||
|
||||
def test_custom_pricing_field_denylist_covers_all_builtin_pricing_fields():
|
||||
"""The shared-backend-key stripping in Router relies on
|
||||
CustomPricingLiteLLMParams enumerating every per-deployment pricing field.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue