diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index cf8544316e9..032b7d17423 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -34809,6 +34809,7 @@ "gpt-4.1": { "cache_read_input_token_cost": 5e-07, "cache_read_input_token_cost_priority": 8.75e-07, + "deprecation_date": "2027-01-14", "input_cost_per_token": 2e-06, "input_cost_per_token_batches": 1e-06, "input_cost_per_token_priority": 3.5e-06, @@ -34894,6 +34895,7 @@ "gpt-4.1-mini": { "cache_read_input_token_cost": 1e-07, "cache_read_input_token_cost_priority": 1.75e-07, + "deprecation_date": "2027-01-14", "input_cost_per_token": 4e-07, "input_cost_per_token_batches": 2e-07, "input_cost_per_token_priority": 7e-07, @@ -35053,6 +35055,7 @@ "gpt-4o": { "cache_read_input_token_cost": 1.25e-06, "cache_read_input_token_cost_priority": 2.125e-06, + "deprecation_date": "2027-01-14", "input_cost_per_token": 2.5e-06, "input_cost_per_token_batches": 1.25e-06, "input_cost_per_token_priority": 4.25e-06, @@ -35397,6 +35400,7 @@ "gpt-4o-mini": { "cache_read_input_token_cost": 7.5e-08, "cache_read_input_token_cost_priority": 1.25e-07, + "deprecation_date": "2027-01-14", "input_cost_per_token": 1.5e-07, "input_cost_per_token_batches": 7.5e-08, "input_cost_per_token_priority": 2.5e-07, diff --git a/litellm/proxy/common_utils/model_deprecation.py b/litellm/proxy/common_utils/model_deprecation.py index 21ad8cf1fde..5c764d1a33e 100644 --- a/litellm/proxy/common_utils/model_deprecation.py +++ b/litellm/proxy/common_utils/model_deprecation.py @@ -1,14 +1,15 @@ from __future__ import annotations -from collections.abc import Mapping, Sequence +from collections.abc import Callable, Mapping, Sequence from dataclasses import dataclass from datetime import date, datetime, timezone from itertools import groupby from types import MappingProxyType -from typing import TYPE_CHECKING, Final +from typing import TYPE_CHECKING, Final, TypeAlias import litellm from litellm._logging import verbose_logger +from litellm.litellm_core_utils.get_model_cost_map import AliasedCostEntry, GetModelCostMap from litellm.types.proxy.model_deprecation import ( DEFAULT_DEPRECATION_WARN_DAYS, DeprecationStatus, @@ -20,6 +21,9 @@ if TYPE_CHECKING: from litellm.router import Router _NO_MODEL_METADATA: Final[Mapping[str, object]] = MappingProxyType({}) +_SHARED_SCHEDULE_PROVIDER: Final[Mapping[str, str]] = MappingProxyType({"azure_ai": "azure"}) + +LoadedCatalog: TypeAlias = Callable[[], Mapping[str, Mapping[str, object]]] @dataclass(frozen=True, slots=True) @@ -27,6 +31,7 @@ class _ResolvedDeprecation: deprecation_date: date litellm_model: str | None litellm_provider: str | None + copied_from_another_row: bool = False def _parse_deprecation_date(raw_value: object) -> date | None: @@ -47,8 +52,8 @@ def _successor_model(raw_value: object) -> str | None: return raw_value.strip() or None if isinstance(raw_value, str) else None -def _cost_map_lookup(model_key: object) -> _ResolvedDeprecation | None: - if not isinstance(model_key, str) or not model_key: +def _cost_map_lookup(model_key: str | None, catalog: Mapping[str, object]) -> _ResolvedDeprecation | None: + if model_key is None: return None entry: Final = litellm.model_cost.get(model_key) if not isinstance(entry, Mapping): @@ -61,6 +66,51 @@ def _cost_map_lookup(model_key: object) -> _ResolvedDeprecation | None: deprecation_date=parsed, litellm_model=model_key, litellm_provider=provider if isinstance(provider, str) else None, + copied_from_another_row=isinstance(entry, AliasedCostEntry) + and "deprecation_date" not in entry.registered_fields + and model_key not in catalog, + ) + + +def _first_own_row(model_keys: Sequence[str | None], catalog: Mapping[str, object]) -> _ResolvedDeprecation | None: + found: Final = tuple( + resolved for resolved in (_cost_map_lookup(key, catalog) for key in model_keys) if resolved is not None + ) + return next((resolved for resolved in found if not resolved.copied_from_another_row), next(iter(found), None)) + + +def _deployment_provider(litellm_params: Mapping[str, object]) -> str | None: + declared: Final = litellm_params.get("custom_llm_provider") + if isinstance(declared, str) and declared: + return declared + model: Final = litellm_params.get("model") + prefix: Final = model.split("/", 1)[0] if isinstance(model, str) and "/" in model else None + return prefix if prefix in litellm.provider_list else None + + +def _provider_qualified(provider: str | None, model_key: str | None) -> str | None: + if provider is None or model_key is None or model_key.startswith(f"{provider}/"): + return None + return f"{provider}/{model_key}" + + +def _as_model_key(raw_value: object) -> str | None: + return raw_value if isinstance(raw_value, str) and raw_value else None + + +def _without_provider(provider: str | None, model_name: str | None) -> str | None: + if provider is None or model_name is None: + return model_name + return model_name.removeprefix(f"{provider}/") + + +def _schedule_keys(provider: str | None, model_name: str | None) -> tuple[str | None, ...]: + shared_provider: Final = _SHARED_SCHEDULE_PROVIDER.get(provider) if provider is not None else None + unprefixed: Final = _without_provider(provider, model_name) + return ( + _provider_qualified(provider, unprefixed), + _provider_qualified(shared_provider, unprefixed), + model_name, ) @@ -70,34 +120,37 @@ def _mapping_field(deployment: Mapping[str, object], key: str) -> Mapping[str, o def _resolve_deployment_deprecation( - deployment: Mapping[str, object], + deployment: Mapping[str, object], catalog: Mapping[str, object] ) -> _ResolvedDeprecation | None: """Resolve a deployment's deprecation date, preferring its explicit override""" model_info: Final = _mapping_field(deployment, "model_info") - raw_model: Final = _mapping_field(deployment, "litellm_params").get("model") + litellm_params: Final = _mapping_field(deployment, "litellm_params") + model_key: Final = _as_model_key(litellm_params.get("model")) override: Final = _parse_deprecation_date(model_info.get("deprecation_date")) if override is not None: - provider: Final = model_info.get("litellm_provider") + override_provider: Final = model_info.get("litellm_provider") return _ResolvedDeprecation( deprecation_date=override, - litellm_model=raw_model if isinstance(raw_model, str) else None, - litellm_provider=provider if isinstance(provider, str) else None, + litellm_model=model_key, + litellm_provider=override_provider if isinstance(override_provider, str) else None, ) - unprefixed: Final = raw_model.split("/", 1)[1] if isinstance(raw_model, str) and "/" in raw_model else None - return next( - ( - resolved - for resolved in ( - _cost_map_lookup(model_info.get("base_model")), - _cost_map_lookup(raw_model), - _cost_map_lookup(unprefixed), - ) - if resolved is not None - ), - None, + provider: Final = _deployment_provider(litellm_params) + base_model: Final = _as_model_key(model_info.get("base_model")) + model_name: Final = model_key.split("/", 1)[1] if model_key is not None and "/" in model_key else model_key + picks: Final = tuple( + pick + for pick in ( + _first_own_row(_schedule_keys(provider, base_model), catalog), + _first_own_row( + (_provider_qualified(provider, model_key), model_key, *_schedule_keys(provider, model_name)), catalog + ), + ) + if pick is not None ) + own_rows: Final = tuple(pick for pick in picks if not pick.copied_from_another_row) + return min(own_rows or picks, key=lambda pick: pick.deprecation_date, default=None) def _classify(days_until: int, warn_within_days: int) -> DeprecationStatus: @@ -124,13 +177,17 @@ def _checked_successor(model_name: str, model_info: Mapping[str, object], llm_ro def _build_info( - deployment: Mapping[str, object], llm_router: Router, today: date, warn_within_days: int + deployment: Mapping[str, object], + llm_router: Router, + today: date, + warn_within_days: int, + catalog: Mapping[str, object], ) -> ModelDeprecationInfo | None: model_name: Final = deployment.get("model_name") if not isinstance(model_name, str) or not model_name: return None - resolved: Final = _resolve_deployment_deprecation(deployment) + resolved: Final = _resolve_deployment_deprecation(deployment, catalog) if resolved is None: return None @@ -182,15 +239,12 @@ def _bucket(models: Sequence[ModelDeprecationInfo], status: DeprecationStatus) - ) -def _collect_from_router(llm_router: Router, today: date, warn_within_days: int) -> tuple[ModelDeprecationInfo, ...]: +def _collect_from_router( + llm_router: Router, today: date, warn_within_days: int, catalog: Mapping[str, object] +) -> tuple[ModelDeprecationInfo, ...]: deployments: Final = llm_router.registry().deployments() or () - deduped: Final = _dedupe( - tuple( - info - for info in (_build_info(deployment, llm_router, today, warn_within_days) for deployment in deployments) - if info is not None - ) - ) + infos: Final = (_build_info(deployment, llm_router, today, warn_within_days, catalog) for deployment in deployments) + deduped: Final = _dedupe(tuple(info for info in infos if info is not None)) verbose_logger.debug( "model_deprecation: %d/%d deployments carry a deprecation date", @@ -204,12 +258,15 @@ def collect_model_deprecations( llm_router: Router | None, warn_within_days: int = DEFAULT_DEPRECATION_WARN_DAYS, today: date | None = None, + loaded_catalog: LoadedCatalog = GetModelCostMap.loaded_model_cost_map, ) -> ModelDeprecationResponse: """Bucket every deployment carrying a deprecation date by how urgent it is""" snapshot_time: Final = datetime.now(timezone.utc) effective_today: Final = today or snapshot_time.date() deduped: Final = ( - _collect_from_router(llm_router, effective_today, warn_within_days) if llm_router is not None else () + _collect_from_router(llm_router, effective_today, warn_within_days, loaded_catalog()) + if llm_router is not None + else () ) return ModelDeprecationResponse( diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index cf8544316e9..032b7d17423 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -34809,6 +34809,7 @@ "gpt-4.1": { "cache_read_input_token_cost": 5e-07, "cache_read_input_token_cost_priority": 8.75e-07, + "deprecation_date": "2027-01-14", "input_cost_per_token": 2e-06, "input_cost_per_token_batches": 1e-06, "input_cost_per_token_priority": 3.5e-06, @@ -34894,6 +34895,7 @@ "gpt-4.1-mini": { "cache_read_input_token_cost": 1e-07, "cache_read_input_token_cost_priority": 1.75e-07, + "deprecation_date": "2027-01-14", "input_cost_per_token": 4e-07, "input_cost_per_token_batches": 2e-07, "input_cost_per_token_priority": 7e-07, @@ -35053,6 +35055,7 @@ "gpt-4o": { "cache_read_input_token_cost": 1.25e-06, "cache_read_input_token_cost_priority": 2.125e-06, + "deprecation_date": "2027-01-14", "input_cost_per_token": 2.5e-06, "input_cost_per_token_batches": 1.25e-06, "input_cost_per_token_priority": 4.25e-06, @@ -35397,6 +35400,7 @@ "gpt-4o-mini": { "cache_read_input_token_cost": 7.5e-08, "cache_read_input_token_cost_priority": 1.25e-07, + "deprecation_date": "2027-01-14", "input_cost_per_token": 1.5e-07, "input_cost_per_token_batches": 7.5e-08, "input_cost_per_token_priority": 2.5e-07, diff --git a/tests/unit/proxy/common_utils/test_model_deprecation.py b/tests/unit/proxy/common_utils/test_model_deprecation.py index 4f9f947fadb..7e96ad591b7 100644 --- a/tests/unit/proxy/common_utils/test_model_deprecation.py +++ b/tests/unit/proxy/common_utils/test_model_deprecation.py @@ -5,14 +5,17 @@ Slack integration — so they can run without the full proxy stack. """ import logging +from collections.abc import Mapping from datetime import date, datetime, timezone +from typing import Final from unittest.mock import MagicMock - +import pytest import litellm from litellm import Router from litellm._logging import verbose_logger +from litellm.litellm_core_utils.get_model_cost_map import AliasedCostEntry from litellm.proxy.common_utils.model_deprecation import ( _classify, _parse_deprecation_date, @@ -37,12 +40,7 @@ class TestParseDeprecationDate: def test_should_return_none_for_documentation_sentinel(self): # The JSON map ships a sentinel string under the "sample_spec" key. - assert ( - _parse_deprecation_date( - "date when the model becomes deprecated in the format YYYY-MM-DD" - ) - is None - ) + assert _parse_deprecation_date("date when the model becomes deprecated in the format YYYY-MM-DD") is None def test_should_return_none_for_none(self): assert _parse_deprecation_date(None) is None @@ -51,9 +49,7 @@ class TestParseDeprecationDate: assert _parse_deprecation_date(12345) is None def test_should_narrow_datetime_to_date(self): - assert _parse_deprecation_date( - datetime(2026, 12, 31, 23, 59, tzinfo=timezone.utc) - ) == date(2026, 12, 31) + assert _parse_deprecation_date(datetime(2026, 12, 31, 23, 59, tzinfo=timezone.utc)) == date(2026, 12, 31) class TestClassify: @@ -134,9 +130,7 @@ class TestCollectModelDeprecations: ] ) - snapshot = collect_model_deprecations( - llm_router=router, warn_within_days=30, today=today - ) + snapshot = collect_model_deprecations(llm_router=router, warn_within_days=30, today=today) assert [m.model_name for m in snapshot.deprecated] == ["deprecated-alias"] assert [m.model_name for m in snapshot.imminent] == ["imminent-alias"] @@ -166,9 +160,7 @@ class TestCollectModelDeprecations: ] ) - snapshot = collect_model_deprecations( - llm_router=router, warn_within_days=30, today=today - ) + snapshot = collect_model_deprecations(llm_router=router, warn_within_days=30, today=today) assert len(snapshot.imminent) == 1 assert snapshot.imminent[0].deprecation_date == date(2026, 6, 10) @@ -195,9 +187,7 @@ class TestCollectModelDeprecations: ] ) - snapshot = collect_model_deprecations( - llm_router=router, warn_within_days=30, today=today - ) + snapshot = collect_model_deprecations(llm_router=router, warn_within_days=30, today=today) assert len(snapshot.imminent) == 1 @@ -217,9 +207,7 @@ class TestCollectModelDeprecations: ] ) - snapshot = collect_model_deprecations( - llm_router=router, warn_within_days=30, today=date(2026, 6, 1) - ) + snapshot = collect_model_deprecations(llm_router=router, warn_within_days=30, today=date(2026, 6, 1)) assert [m.litellm_model for m in snapshot.imminent] == ["gpt-4o"] @@ -244,9 +232,7 @@ class TestCollectModelDeprecations: ] ) - snapshot = collect_model_deprecations( - llm_router=router, warn_within_days=30, today=date(2026, 6, 1) - ) + snapshot = collect_model_deprecations(llm_router=router, warn_within_days=30, today=date(2026, 6, 1)) assert len(snapshot.imminent) == 1 assert len(snapshot.upcoming) == 1 @@ -268,14 +254,136 @@ class TestCollectModelDeprecations: ] ) - snapshot = collect_model_deprecations( - llm_router=router, warn_within_days=30, today=today - ) + snapshot = collect_model_deprecations(llm_router=router, warn_within_days=30, today=today) assert len(snapshot.imminent) == 1 assert snapshot.imminent[0].litellm_model == "base-thing" +class TestProviderScopedResolution: + CATALOG = { + "gpt-4o": {"deprecation_date": "2027-01-14", "litellm_provider": "openai"}, + "azure/gpt-4o": {"deprecation_date": "2027-04-14", "litellm_provider": "azure"}, + "github_copilot/gpt-4o": {"litellm_provider": "github_copilot"}, + "azure_ai/gpt-4.1": AliasedCostEntry( + {"deprecation_date": "2027-05-01", "litellm_provider": "azure_ai", "mode": "chat"}, () + ), + "custom-base": {"deprecation_date": "2026-12-01"}, + "gpt-4.1": {"deprecation_date": "2027-01-14", "litellm_provider": "openai"}, + "azure/gpt-4.1": {"deprecation_date": "2027-04-14", "litellm_provider": "azure"}, + "gpt-4o-2024-05-13": {"deprecation_date": "2026-10-23", "litellm_provider": "openai"}, + "azure/gpt-4o-2024-05-13": {"deprecation_date": "2026-12-09", "litellm_provider": "azure"}, + "azure/aliased-base": AliasedCostEntry( + {"deprecation_date": "2026-11-20"}, ("azure/aliased-base", "azure/aliased-base-v1") + ), + "aliased-base": {"deprecation_date": "2027-02-01"}, + } + COST_MAP = { + **CATALOG, + "azure_ai/gpt-4o": AliasedCostEntry( + {"deprecation_date": "2027-01-14", "litellm_provider": "azure", "mode": "chat"}, () + ), + "registered-base": AliasedCostEntry({"deprecation_date": "2026-12-15"}, (), frozenset({"deprecation_date"})), + } + + @pytest.mark.parametrize( + ("litellm_params", "model_info", "expected"), + [ + ({"model": "gpt-4o"}, {}, ("gpt-4o", date(2027, 1, 14), "openai")), + ({"model": "openai/gpt-4o"}, {}, ("gpt-4o", date(2027, 1, 14), "openai")), + ( + {"model": "gpt-4o", "custom_llm_provider": "azure"}, + {}, + ("azure/gpt-4o", date(2027, 4, 14), "azure"), + ), + ({"model": "azure/gpt-4o"}, {}, ("azure/gpt-4o", date(2027, 4, 14), "azure")), + ( + {"model": "azure/prod-chat"}, + {"base_model": "gpt-4o"}, + ("azure/gpt-4o", date(2027, 4, 14), "azure"), + ), + ( + {"model": "gpt-4o", "custom_llm_provider": "github_copilot"}, + {}, + ("gpt-4o", date(2027, 1, 14), "openai"), + ), + ({"model": "azure_ai/gpt-4o"}, {}, ("azure/gpt-4o", date(2027, 4, 14), "azure")), + ( + {"model": "azure_ai/prod-chat"}, + {"base_model": "gpt-4o"}, + ("azure/gpt-4o", date(2027, 4, 14), "azure"), + ), + ( + {"model": "azure_ai/gpt-4o"}, + {"base_model": "azure_ai/gpt-4o"}, + ("azure/gpt-4o", date(2027, 4, 14), "azure"), + ), + ( + {"model": "azure_ai/prod-chat"}, + {"base_model": "azure_ai/gpt-4o"}, + ("azure/gpt-4o", date(2027, 4, 14), "azure"), + ), + ({"model": "azure_ai/gpt-4.1"}, {}, ("azure_ai/gpt-4.1", date(2027, 5, 1), "azure_ai")), + ({"model": "azure/gpt-4o"}, {"base_model": "custom-base"}, ("custom-base", date(2026, 12, 1), None)), + ( + {"model": "azure/gpt-4o"}, + {"base_model": "registered-base"}, + ("registered-base", date(2026, 12, 15), None), + ), + ( + {"model": "azure/prod-chat"}, + {"base_model": "aliased-base"}, + ("azure/aliased-base", date(2026, 11, 20), None), + ), + ( + {"model": "openai/gpt-4o-2024-05-13"}, + {"base_model": "gpt-4o"}, + ("gpt-4o-2024-05-13", date(2026, 10, 23), "openai"), + ), + ( + {"model": "azure/gpt-4o-2024-05-13"}, + {"base_model": "gpt-4o"}, + ("azure/gpt-4o-2024-05-13", date(2026, 12, 9), "azure"), + ), + ], + ids=[ + "bare", + "openai-prefix", + "declared-azure", + "azure-prefix", + "azure-base-model", + "undated-own-row", + "azure-ai-reads-azure", + "azure-ai-base-model-reads-azure", + "azure-ai-copied-base-model-yields-to-azure", + "azure-ai-prefixed-base-model-on-custom-deployment-reads-azure", + "azure-ai-own-row-wins", + "custom-base-model-row-wins", + "registered-base-model-row-wins", + "aliased-base-model-row-wins", + "openai-snapshot-shuts-down-before-its-base-model", + "azure-snapshot-shuts-down-before-its-base-model", + ], + ) + def test_should_prefer_the_deployments_own_provider_date( + self, + monkeypatch: pytest.MonkeyPatch, + litellm_params: Mapping[str, str], + model_info: Mapping[str, str], + expected: tuple[str, date, str | None], + ) -> None: + monkeypatch.setattr(litellm, "model_cost", self.COST_MAP) + router: Final = _make_router( + [{"model_name": "alias", "litellm_params": litellm_params, "model_info": {"id": "1", **model_info}}] + ) + + snapshot: Final = collect_model_deprecations( + llm_router=router, warn_within_days=30, today=date(2026, 6, 1), loaded_catalog=lambda: self.CATALOG + ) + + assert [(m.litellm_model, m.deprecation_date, m.litellm_provider) for m in snapshot.upcoming] == [expected] + + class TestFormatDeprecationAlertMessage: def test_should_return_none_when_nothing_to_alert(self): snapshot = collect_model_deprecations(llm_router=None) @@ -321,9 +429,7 @@ class TestFormatDeprecationAlertMessage: ] ) - snapshot = collect_model_deprecations( - llm_router=router, warn_within_days=30, today=today - ) + snapshot = collect_model_deprecations(llm_router=router, warn_within_days=30, today=today) message = format_deprecation_alert_message(snapshot) assert message is not None @@ -350,9 +456,7 @@ class TestFormatDeprecationAlertMessage: ] ) - snapshot = collect_model_deprecations( - llm_router=router, warn_within_days=30, today=today - ) + snapshot = collect_model_deprecations(llm_router=router, warn_within_days=30, today=today) message = format_deprecation_alert_message(snapshot) assert message is not None diff --git a/tests/unit/test_model_prices_and_context_window.py b/tests/unit/test_model_prices_and_context_window.py index 4e8ede5c446..759eee51ba4 100644 --- a/tests/unit/test_model_prices_and_context_window.py +++ b/tests/unit/test_model_prices_and_context_window.py @@ -388,6 +388,34 @@ def test_vertex_gemini_flash_rows_carry_the_documented_retirement_date(prices: d assert prices[f"{prefix}{model}"]["deprecation_date"] == VERTEX_GEMINI_FLASH_RETIREMENT_DATES[model] +# https://developers.openai.com/api/docs/deprecations, the 2026-10-14 GPT-4 series notice +OPENAI_GPT_4_SERIES_SNAPSHOTS: Final = MappingProxyType( + { + "gpt-4o": ("gpt-4o-2024-08-06", "gpt-4o-2024-11-20"), + "gpt-4o-mini": ("gpt-4o-mini-2024-07-18",), + "gpt-4.1": ("gpt-4.1-2025-04-14",), + "gpt-4.1-mini": ("gpt-4.1-mini-2025-04-14",), + } +) + + +class _ShutdownRow(BaseModel): + model_config = ConfigDict(frozen=True, extra="ignore") + + deprecation_date: str | None = None + + +@pytest.mark.parametrize("path", (PRICES_PATH, BACKUP_PRICES_PATH), ids=("main", "backup")) +@pytest.mark.parametrize("alias", sorted(OPENAI_GPT_4_SERIES_SNAPSHOTS)) +def test_openai_gpt_4_series_aliases_retire_with_their_snapshots(path: Path, alias: str) -> None: + prices: Final[dict[str, JsonValue]] = TypeAdapter(dict[str, JsonValue]).validate_json(path.read_bytes()) + snapshot_dates: Final = frozenset( + _ShutdownRow.model_validate(prices[snapshot]).deprecation_date + for snapshot in OPENAI_GPT_4_SERIES_SNAPSHOTS[alias] + ) + assert snapshot_dates == {_ShutdownRow.model_validate(prices[alias]).deprecation_date} + + def _sampling_flags_across_provider_twins(prices: Mapping[str, Mapping[str, object]], key: str) -> frozenset[object]: bare: Final = key.split("/")[-1] twins: Final = tuple(twin for twin in (bare, f"vertex_ai/{bare}", f"gemini/{bare}") if twin in prices)