From f5507e366cd9930edd5f7d3d4b00e3c4600e9c8c Mon Sep 17 00:00:00 2001 From: Rameshwaran Mohan Date: Fri, 18 Sep 2026 02:18:06 +0530 Subject: [PATCH 1/3] feat(proxy): name the successor model for deprecating deployments --- .../proxy/common_utils/model_deprecation.py | 39 ++++-- litellm/types/proxy/model_deprecation.py | 7 + .../common_utils/test_model_deprecation.py | 123 ++++++++++++++++++ 3 files changed, 158 insertions(+), 11 deletions(-) diff --git a/litellm/proxy/common_utils/model_deprecation.py b/litellm/proxy/common_utils/model_deprecation.py index 8176a8cb642..eab58300848 100644 --- a/litellm/proxy/common_utils/model_deprecation.py +++ b/litellm/proxy/common_utils/model_deprecation.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping, Sequence -from dataclasses import dataclass +from dataclasses import dataclass, replace from datetime import date, datetime, timezone from itertools import groupby from types import MappingProxyType @@ -27,6 +27,7 @@ class _ResolvedDeprecation: deprecation_date: date litellm_model: str | None litellm_provider: str | None + successor_model: str | None = None def _parse_deprecation_date(raw_value: object) -> date | None: @@ -42,20 +43,27 @@ def _parse_deprecation_date(raw_value: object) -> date | None: return None +def _successor_model(raw_value: object) -> str | None: + """A non-empty successor name, or None when the field is missing, blank, or not a string""" + 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: return None entry: Final = litellm.model_cost.get(model_key) if not isinstance(entry, Mapping): return None - parsed: Final = _parse_deprecation_date(entry.get("deprecation_date")) + fields: Final[Mapping[str, object]] = entry + parsed: Final = _parse_deprecation_date(fields.get("deprecation_date")) if parsed is None: return None - provider: Final = entry.get("litellm_provider") + provider: Final = fields.get("litellm_provider") return _ResolvedDeprecation( deprecation_date=parsed, litellm_model=model_key, litellm_provider=provider if isinstance(provider, str) else None, + successor_model=_successor_model(fields.get("successor_model")), ) @@ -67,9 +75,10 @@ def _mapping_field(deployment: Mapping[str, object], key: str) -> Mapping[str, o def _resolve_deployment_deprecation( deployment: Mapping[str, object], ) -> _ResolvedDeprecation | None: - """Resolve a deployment's deprecation date, preferring its explicit override""" + """Resolve a deployment's deprecation date and successor, preferring its explicit overrides""" model_info: Final = _mapping_field(deployment, "model_info") raw_model: Final = _mapping_field(deployment, "litellm_params").get("model") + explicit_successor: Final = _successor_model(model_info.get("successor_model")) override: Final = _parse_deprecation_date(model_info.get("deprecation_date")) if override is not None: @@ -78,21 +87,25 @@ def _resolve_deployment_deprecation( deprecation_date=override, litellm_model=raw_model if isinstance(raw_model, str) else None, litellm_provider=provider if isinstance(provider, str) else None, + successor_model=explicit_successor, ) unprefixed: Final = raw_model.split("/", 1)[1] if isinstance(raw_model, str) and "/" in raw_model else None - return next( + resolved: Final = next( ( - resolved - for resolved in ( + candidate + for candidate in ( _cost_map_lookup(model_info.get("base_model")), _cost_map_lookup(raw_model), _cost_map_lookup(unprefixed), ) - if resolved is not None + if candidate is not None ), None, ) + if resolved is None or explicit_successor is None: + return resolved + return replace(resolved, successor_model=explicit_successor) def _classify(days_until: int, warn_within_days: int) -> DeprecationStatus: @@ -120,14 +133,17 @@ def _build_info(deployment: Mapping[str, object], today: date, warn_within_days: days_until_deprecation=days_until, status=_classify(days_until, warn_within_days), litellm_provider=resolved.litellm_provider, + successor_model=resolved.successor_model, ) def _dedupe( models: Sequence[ModelDeprecationInfo], ) -> tuple[ModelDeprecationInfo, ...]: - """Report a model group carrying the same date on several deployments once""" - ordered: Final = sorted(models, key=lambda model: (model.model_name, model.deprecation_date)) + """Report a model group carrying the same date on several deployments once, keeping one that names a successor""" + ordered: Final = sorted( + models, key=lambda model: (model.model_name, model.deprecation_date, model.successor_model is None) + ) return tuple( next(group) for _, group in groupby(ordered, key=lambda model: (model.model_name, model.deprecation_date)) ) @@ -186,10 +202,11 @@ def _format_entry(info: ModelDeprecationInfo) -> str: if info.days_until_deprecation < 0 else f"in {info.days_until_deprecation}d" ) + migration: Final = f", migrate to `{_escape_slack_mrkdwn(info.successor_model)}`" if info.successor_model else "" return ( f"• `{_escape_slack_mrkdwn(info.model_name)}` " f"(provider: {_escape_slack_mrkdwn(info.litellm_provider) if info.litellm_provider else 'unknown'}, " - f"deprecates {info.deprecation_date.isoformat()}, {suffix})" + f"deprecates {info.deprecation_date.isoformat()}, {suffix}{migration})" ) diff --git a/litellm/types/proxy/model_deprecation.py b/litellm/types/proxy/model_deprecation.py index bbad63a278d..a4b1598bcd9 100644 --- a/litellm/types/proxy/model_deprecation.py +++ b/litellm/types/proxy/model_deprecation.py @@ -30,6 +30,13 @@ class ModelDeprecationInfo(BaseModel): ), ) litellm_provider: str | None = Field(default=None, description="The provider this model belongs to.") + successor_model: str | None = Field( + default=None, + description=( + "The model to migrate to, taken from model_info.successor_model on the deployment " + "or from the model cost map entry." + ), + ) class ModelDeprecationResponse(BaseModel): diff --git a/tests/test_litellm/proxy/common_utils/test_model_deprecation.py b/tests/test_litellm/proxy/common_utils/test_model_deprecation.py index 91055dbac9f..3d97450f3f4 100644 --- a/tests/test_litellm/proxy/common_utils/test_model_deprecation.py +++ b/tests/test_litellm/proxy/common_utils/test_model_deprecation.py @@ -357,3 +357,126 @@ class TestFormatDeprecationAlertMessage: assert "" not in message assert "<!channel> pwned" in message assert "<https://evil.example|openai> & co" in message + + +class TestSuccessorModel: + def test_should_surface_the_successor_set_on_the_deployment(self, monkeypatch): + monkeypatch.setattr(litellm, "model_cost", {}) + router = _make_router( + [ + { + "model_name": "old", + "litellm_params": {"model": "openai/old-model"}, + "model_info": {"id": "1", "deprecation_date": "2026-06-10", "successor_model": "new"}, + } + ] + ) + + snapshot = collect_model_deprecations(llm_router=router, warn_within_days=30, today=date(2026, 6, 1)) + + assert snapshot.imminent[0].successor_model == "new" + + def test_should_take_the_successor_from_the_cost_map_when_the_deployment_has_none(self, monkeypatch): + monkeypatch.setattr( + litellm, + "model_cost", + {"old-model": {"deprecation_date": "2026-06-10", "successor_model": "new-model"}}, + ) + router = _make_router( + [{"model_name": "old", "litellm_params": {"model": "old-model"}, "model_info": {"id": "1"}}] + ) + + snapshot = collect_model_deprecations(llm_router=router, warn_within_days=30, today=date(2026, 6, 1)) + + assert snapshot.imminent[0].successor_model == "new-model" + + def test_should_prefer_the_deployment_successor_over_the_cost_map(self, monkeypatch): + monkeypatch.setattr( + litellm, + "model_cost", + {"old-model": {"deprecation_date": "2026-06-10", "successor_model": "cost-map-pick"}}, + ) + router = _make_router( + [ + { + "model_name": "old", + "litellm_params": {"model": "old-model"}, + "model_info": {"id": "1", "successor_model": "my-pick"}, + } + ] + ) + + snapshot = collect_model_deprecations(llm_router=router, warn_within_days=30, today=date(2026, 6, 1)) + + assert snapshot.imminent[0].successor_model == "my-pick" + + def test_should_ignore_blank_or_non_string_successors(self, monkeypatch): + monkeypatch.setattr(litellm, "model_cost", {}) + router = _make_router( + [ + { + "model_name": "blank", + "litellm_params": {"model": "openai/a"}, + "model_info": {"id": "1", "deprecation_date": "2026-06-10", "successor_model": " "}, + }, + { + "model_name": "number", + "litellm_params": {"model": "openai/b"}, + "model_info": {"id": "2", "deprecation_date": "2026-06-10", "successor_model": 7}, + }, + ] + ) + + snapshot = collect_model_deprecations(llm_router=router, warn_within_days=30, today=date(2026, 6, 1)) + + assert [m.successor_model for m in snapshot.imminent] == [None, None] + + def test_should_keep_the_deployment_naming_a_successor_when_deduping(self, monkeypatch): + monkeypatch.setattr(litellm, "model_cost", {}) + router = _make_router( + [ + { + "model_name": "alias", + "litellm_params": {"model": "openai/a"}, + "model_info": {"id": "1", "deprecation_date": "2026-06-10"}, + }, + { + "model_name": "alias", + "litellm_params": {"model": "openai/b"}, + "model_info": {"id": "2", "deprecation_date": "2026-06-10", "successor_model": "alias-v2"}, + }, + ] + ) + + snapshot = collect_model_deprecations(llm_router=router, warn_within_days=30, today=date(2026, 6, 1)) + + assert [m.successor_model for m in snapshot.imminent] == ["alias-v2"] + + def test_should_name_the_successor_in_the_slack_entry_and_escape_it(self, monkeypatch): + monkeypatch.setattr(litellm, "model_cost", {}) + router = _make_router( + [ + { + "model_name": "old", + "litellm_params": {"model": "openai/old-model"}, + "model_info": { + "id": "1", + "deprecation_date": "2026-06-10", + "successor_model": "", + }, + }, + { + "model_name": "orphan", + "litellm_params": {"model": "openai/orphan-model"}, + "model_info": {"id": "2", "deprecation_date": "2026-06-10"}, + }, + ] + ) + + snapshot = collect_model_deprecations(llm_router=router, warn_within_days=30, today=date(2026, 6, 1)) + message = format_deprecation_alert_message(snapshot) + + assert message is not None + assert "migrate to `<https://evil.example|new>`" in message + assert "" not in message + assert message.count("migrate to") == 1 From 0b374fcb8fbdb68011d71820e811f6c8ea2e0228 Mon Sep 17 00:00:00 2001 From: Rameshwaran Mohan Date: Fri, 18 Sep 2026 02:18:07 +0530 Subject: [PATCH 2/3] chore(ui): add successor_model to the generated deprecation api types --- ui/litellm-dashboard/src/lib/http/schema.d.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 872875cc535..1281b0d4914 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -32741,6 +32741,11 @@ export interface components { * @enum {string} */ status: "upcoming" | "imminent" | "deprecated"; + /** + * Successor Model + * @description The model to migrate to, taken from model_info.successor_model on the deployment or from the model cost map entry. + */ + successor_model?: string | null; }; /** ModelDeprecationResponse */ ModelDeprecationResponse: { From a0e969cd112b6c2f3b9897041ece1283f64be194 Mon Sep 17 00:00:00 2001 From: Rameshwaran Mohan Date: Fri, 18 Sep 2026 02:35:02 +0530 Subject: [PATCH 3/3] fix(proxy): keep the cost map successor when a deployment overrides only the date --- .../proxy/common_utils/model_deprecation.py | 33 +++++++++---------- .../common_utils/test_model_deprecation.py | 21 ++++++++++++ 2 files changed, 37 insertions(+), 17 deletions(-) diff --git a/litellm/proxy/common_utils/model_deprecation.py b/litellm/proxy/common_utils/model_deprecation.py index eab58300848..ae807ea14c7 100644 --- a/litellm/proxy/common_utils/model_deprecation.py +++ b/litellm/proxy/common_utils/model_deprecation.py @@ -75,23 +75,11 @@ def _mapping_field(deployment: Mapping[str, object], key: str) -> Mapping[str, o def _resolve_deployment_deprecation( deployment: Mapping[str, object], ) -> _ResolvedDeprecation | None: - """Resolve a deployment's deprecation date and successor, preferring its explicit overrides""" + """Resolve a deployment's deprecation date and successor, preferring its explicit overrides field by field""" model_info: Final = _mapping_field(deployment, "model_info") raw_model: Final = _mapping_field(deployment, "litellm_params").get("model") - explicit_successor: Final = _successor_model(model_info.get("successor_model")) - - override: Final = _parse_deprecation_date(model_info.get("deprecation_date")) - if override is not None: - 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, - successor_model=explicit_successor, - ) - unprefixed: Final = raw_model.split("/", 1)[1] if isinstance(raw_model, str) and "/" in raw_model else None - resolved: Final = next( + from_cost_map: Final = next( ( candidate for candidate in ( @@ -103,9 +91,20 @@ def _resolve_deployment_deprecation( ), None, ) - if resolved is None or explicit_successor is None: - return resolved - return replace(resolved, successor_model=explicit_successor) + successor: Final = _successor_model(model_info.get("successor_model")) or ( + from_cost_map.successor_model if from_cost_map is not None else None + ) + + override: Final = _parse_deprecation_date(model_info.get("deprecation_date")) + if override is not None: + 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, + successor_model=successor, + ) + return None if from_cost_map is None else replace(from_cost_map, successor_model=successor) def _classify(days_until: int, warn_within_days: int) -> DeprecationStatus: diff --git a/tests/test_litellm/proxy/common_utils/test_model_deprecation.py b/tests/test_litellm/proxy/common_utils/test_model_deprecation.py index 3d97450f3f4..68cd9918faf 100644 --- a/tests/test_litellm/proxy/common_utils/test_model_deprecation.py +++ b/tests/test_litellm/proxy/common_utils/test_model_deprecation.py @@ -480,3 +480,24 @@ class TestSuccessorModel: assert "migrate to `<https://evil.example|new>`" in message assert "" not in message assert message.count("migrate to") == 1 + + def test_should_take_the_cost_map_successor_when_the_deployment_overrides_only_the_date(self, monkeypatch): + monkeypatch.setattr( + litellm, + "model_cost", + {"old-model": {"deprecation_date": "2030-01-01", "successor_model": "new-model"}}, + ) + router = _make_router( + [ + { + "model_name": "old", + "litellm_params": {"model": "old-model"}, + "model_info": {"id": "1", "deprecation_date": "2026-06-10"}, + } + ] + ) + + snapshot = collect_model_deprecations(llm_router=router, warn_within_days=30, today=date(2026, 6, 1)) + + assert snapshot.imminent[0].deprecation_date == date(2026, 6, 10) + assert snapshot.imminent[0].successor_model == "new-model"