From e65e5abc4e79945479cd2e3d04f07567398150dd Mon Sep 17 00:00:00 2001 From: rameswaran-mohan Date: Fri, 16 Oct 2026 03:20:14 +0530 Subject: [PATCH] feat(proxy): name the successor model for deprecating deployments (#41670) * feat(proxy): name the successor model for deprecating deployments * chore(ui): add successor_model to the generated deprecation api types * fix(proxy): keep the cost map successor when a deployment overrides only the date * fix(proxy): read the cost map successor from metadata.successor The cost map schema rejects a top-level successor_model key, and curated entries already carry the successor under metadata.successor. Read that key, and look it up even when the cost map entry has no deprecation date, so a deployment that sets only its own date still names the successor. * fix(proxy): keep the first deployment when deduping, let a blank successor hide the cost map one, and name a served successor by its model group * fix(proxy): name only the deployment's own successor_model, since cost map successors are LiteLLM ids rather than served model names * fix(proxy): leave out a successor_model the deployment's callers cannot call, with a warning * fix(proxy): report successor_model as the admin set it and only warn when no model matches it --------- Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com> --- .../proxy/common_utils/model_deprecation.py | 75 ++++++-- litellm/proxy/proxy_server.py | 3 +- litellm/types/proxy/model_deprecation.py | 4 + .../common_utils/test_model_deprecation.py | 177 ++++++++++++++++++ ui/litellm-dashboard/src/lib/http/schema.d.ts | 11 +- 5 files changed, 253 insertions(+), 17 deletions(-) diff --git a/litellm/proxy/common_utils/model_deprecation.py b/litellm/proxy/common_utils/model_deprecation.py index 8176a8cb642..ce9bf21d47f 100644 --- a/litellm/proxy/common_utils/model_deprecation.py +++ b/litellm/proxy/common_utils/model_deprecation.py @@ -42,6 +42,11 @@ 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 @@ -103,7 +108,24 @@ def _classify(days_until: int, warn_within_days: int) -> DeprecationStatus: return "upcoming" -def _build_info(deployment: Mapping[str, object], today: date, warn_within_days: int) -> ModelDeprecationInfo | None: +def _checked_successor(model_name: str, model_info: Mapping[str, object], llm_router: Router) -> str | None: + """The deployment's successor_model as set, with a warning when no model in its team scope matches it""" + successor: Final = _successor_model(model_info.get("successor_model")) + if successor is None: + return None + team_id: Final = model_info.get("team_id") + if not llm_router.get_model_list(model_name=successor, team_id=team_id if isinstance(team_id, str) else None): + verbose_logger.warning( + "model_deprecation: %s names successor %s, which matches no model group, alias, or wildcard route here", + model_name, + successor, + ) + return successor + + +def _build_info( + deployment: Mapping[str, object], llm_router: Router, today: date, warn_within_days: int +) -> ModelDeprecationInfo | None: model_name: Final = deployment.get("model_name") if not isinstance(model_name, str) or not model_name: return None @@ -120,16 +142,34 @@ 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=_checked_successor(model_name, _mapping_field(deployment, "model_info"), llm_router), ) +def _merge_group(group: Sequence[ModelDeprecationInfo]) -> ModelDeprecationInfo: + """Keep the group's first deployment in config order, naming the first successor any of its deployments names""" + first: Final = group[0] + successors: Final = tuple(model.successor_model for model in group if model.successor_model is not None) + if not successors: + return first + if len(frozenset(successors)) > 1: + verbose_logger.warning( + "model_deprecation: %s names several successors %s, reporting %s", + first.model_name, + sorted(frozenset(successors)), + successors[0], + ) + return first.model_copy(update={"successor_model": successors[0]}) + + 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)) return tuple( - next(group) for _, group in groupby(ordered, key=lambda model: (model.model_name, model.deprecation_date)) + _merge_group(tuple(group)) + for _, group in groupby(ordered, key=lambda model: (model.model_name, model.deprecation_date)) ) @@ -142,20 +182,12 @@ def _bucket(models: Sequence[ModelDeprecationInfo], status: DeprecationStatus) - ) -def collect_model_deprecations( - llm_router: Router | None, - warn_within_days: int = DEFAULT_DEPRECATION_WARN_DAYS, - today: date | None = None, -) -> 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() - deployments: Final = (llm_router.get_model_list() or ()) if llm_router is not None else () - +def _collect_from_router(llm_router: Router, today: date, warn_within_days: int) -> tuple[ModelDeprecationInfo, ...]: + deployments: Final = llm_router.get_model_list() or () deduped: Final = _dedupe( tuple( info - for info in (_build_info(deployment, effective_today, warn_within_days) for deployment in deployments) + for info in (_build_info(deployment, llm_router, today, warn_within_days) for deployment in deployments) if info is not None ) ) @@ -165,6 +197,20 @@ def collect_model_deprecations( len(deduped), len(deployments), ) + return deduped + + +def collect_model_deprecations( + llm_router: Router | None, + warn_within_days: int = DEFAULT_DEPRECATION_WARN_DAYS, + today: date | None = None, +) -> 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 () + ) return ModelDeprecationResponse( deprecated=_bucket(deduped, "deprecated"), @@ -186,10 +232,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/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index fa2e7db5633..4459172cf58 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -16880,7 +16880,8 @@ async def model_deprecations( Reads `deprecation_date` metadata from `model_prices_and_context_window.json` (and any per-deployment `model_info.deprecation_date` overrides) for the - models configured on this proxy. + models configured on this proxy. An entry also names its `successor_model` + when the deployment sets `model_info.successor_model`. Parameters: warn_within_days: Window (in days) used to bucket "imminent" models, diff --git a/litellm/types/proxy/model_deprecation.py b/litellm/types/proxy/model_deprecation.py index 0b0cd2d7024..8645f89b65d 100644 --- a/litellm/types/proxy/model_deprecation.py +++ b/litellm/types/proxy/model_deprecation.py @@ -32,6 +32,10 @@ class ModelDeprecationInfo(LiteLLMBaseModel): ), ) 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, as set in model_info.successor_model on the deployment."), + ) class ModelDeprecationResponse(LiteLLMBaseModel): diff --git a/tests/unit/proxy/common_utils/test_model_deprecation.py b/tests/unit/proxy/common_utils/test_model_deprecation.py index 91055dbac9f..2f030dc7c82 100644 --- a/tests/unit/proxy/common_utils/test_model_deprecation.py +++ b/tests/unit/proxy/common_utils/test_model_deprecation.py @@ -4,12 +4,15 @@ These tests focus on the helper itself — not on the proxy endpoint or Slack integration — so they can run without the full proxy stack. """ +import logging from datetime import date, datetime, timezone from unittest.mock import MagicMock import litellm +from litellm import Router +from litellm._logging import verbose_logger from litellm.proxy.common_utils.model_deprecation import ( _classify, _parse_deprecation_date, @@ -357,3 +360,177 @@ 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 = Router( + model_list=[ + { + "model_name": "old", + "litellm_params": {"model": "openai/old-model"}, + "model_info": {"id": "1", "deprecation_date": "2026-06-10", "successor_model": "new"}, + }, + {"model_name": "new", "litellm_params": {"model": "openai/new-model"}, "model_info": {"id": "2"}}, + ] + ) + + 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_name_the_deployment_successor_when_the_date_comes_from_the_cost_map(self, monkeypatch): + monkeypatch.setattr(litellm, "model_cost", {"old-model": {"deprecation_date": "2026-06-10"}}) + router = Router( + model_list=[ + { + "model_name": "old", + "litellm_params": {"model": "openai/old-model"}, + "model_info": {"id": "1", "successor_model": "my-pick"}, + }, + {"model_name": "my-pick", "litellm_params": {"model": "openai/pick"}, "model_info": {"id": "2"}}, + ] + ) + + 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_report_successors_as_set_and_warn_when_no_model_in_the_team_scope_matches( + self, monkeypatch, caplog + ): + monkeypatch.setattr(litellm, "model_cost", {}) + router = Router( + model_list=[ + { + "model_name": "model_name_t1_old", + "litellm_params": {"model": "openai/team-old"}, + "model_info": { + "id": "1", + "team_id": "t1", + "team_public_model_name": "team-old", + "deprecation_date": "2026-06-10", + "successor_model": "team-new", + }, + }, + { + "model_name": "model_name_t1_new", + "litellm_params": {"model": "openai/team-new"}, + "model_info": {"id": "2", "team_id": "t1", "team_public_model_name": "team-new"}, + }, + { + "model_name": "global-old", + "litellm_params": {"model": "openai/global-old"}, + "model_info": {"id": "3", "deprecation_date": "2026-06-10", "successor_model": "team-new"}, + }, + { + "model_name": "typo-old", + "litellm_params": {"model": "openai/typo-old"}, + "model_info": {"id": "4", "deprecation_date": "2026-06-10", "successor_model": "gpt-nwe"}, + }, + { + "model_name": "wildcard-old", + "litellm_params": {"model": "openai/wildcard-old"}, + "model_info": {"id": "5", "deprecation_date": "2026-06-10", "successor_model": "anthropic/x"}, + }, + {"model_name": "anthropic/*", "litellm_params": {"model": "anthropic/*"}, "model_info": {"id": "6"}}, + ] + ) + + with caplog.at_level(logging.WARNING, logger=verbose_logger.name): + snapshot = collect_model_deprecations(llm_router=router, warn_within_days=30, today=date(2026, 6, 1)) + + assert {m.model_name: m.successor_model for m in snapshot.imminent} == { + "model_name_t1_old": "team-new", + "global-old": "team-new", + "typo-old": "gpt-nwe", + "wildcard-old": "anthropic/x", + } + warnings = [record.getMessage() for record in caplog.records if "names successor" in record.getMessage()] + assert len(warnings) == 2 + assert any("global-old names successor team-new" in warning for warning in warnings) + assert any("typo-old names successor gpt-nwe" in warning for warning in warnings) + + def test_should_keep_the_first_deployment_and_carry_the_group_successor_when_deduping(self, monkeypatch): + monkeypatch.setattr(litellm, "model_cost", {}) + router = Router( + model_list=[ + { + "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"}, + }, + { + "model_name": "alias", + "litellm_params": {"model": "openai/c"}, + "model_info": {"id": "3", "deprecation_date": "2026-06-10", "successor_model": "alias-v3"}, + }, + {"model_name": "alias-v2", "litellm_params": {"model": "openai/v2"}, "model_info": {"id": "4"}}, + {"model_name": "alias-v3", "litellm_params": {"model": "openai/v3"}, "model_info": {"id": "5"}}, + ] + ) + + snapshot = collect_model_deprecations(llm_router=router, warn_within_days=30, today=date(2026, 6, 1)) + + assert [(m.litellm_model, m.successor_model) for m in snapshot.imminent] == [("openai/a", "alias-v2")] + + def test_should_name_the_successor_in_the_slack_entry_and_escape_it(self, monkeypatch): + monkeypatch.setattr(litellm, "model_cost", {}) + router = Router( + model_list=[ + { + "model_name": "old", + "litellm_params": {"model": "openai/old-model"}, + "model_info": { + "id": "1", + "deprecation_date": "2026-06-10", + "successor_model": "", + }, + }, + { + "model_name": "", + "litellm_params": {"model": "openai/new-model"}, + "model_info": {"id": "2"}, + }, + { + "model_name": "orphan", + "litellm_params": {"model": "openai/orphan-model"}, + "model_info": {"id": "3", "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 diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 95461192a8c..32b8e5db866 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -9913,7 +9913,8 @@ export interface paths { * * Reads `deprecation_date` metadata from `model_prices_and_context_window.json` * (and any per-deployment `model_info.deprecation_date` overrides) for the - * models configured on this proxy. + * models configured on this proxy. An entry also names its `successor_model` + * when the deployment sets `model_info.successor_model`. * * Parameters: * warn_within_days: Window (in days) used to bucket "imminent" models, @@ -21204,7 +21205,8 @@ export interface paths { * * Reads `deprecation_date` metadata from `model_prices_and_context_window.json` * (and any per-deployment `model_info.deprecation_date` overrides) for the - * models configured on this proxy. + * models configured on this proxy. An entry also names its `successor_model` + * when the deployment sets `model_info.successor_model`. * * Parameters: * warn_within_days: Window (in days) used to bucket "imminent" models, @@ -38057,6 +38059,11 @@ export interface components { * @enum {string} */ status: "upcoming" | "imminent" | "deprecated"; + /** + * Successor Model + * @description The model to migrate to, as set in model_info.successor_model on the deployment. + */ + successor_model?: string | null; }; /** ModelDeprecationResponse */ ModelDeprecationResponse: {