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>
This commit is contained in:
rameswaran-mohan 2026-10-16 03:20:14 +05:30 • committed by GitHub
parent 06efa5de85
commit e65e5abc4e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 253 additions and 17 deletions

View file

@ -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})"
)

View file

@ -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,

View file

@ -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):

View file

@ -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 "<https://evil.example|openai>" not in message
assert "&lt;!channel&gt; pwned" in message
assert "&lt;https://evil.example|openai&gt; &amp; 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": "<https://evil.example|new>",
},
},
{
"model_name": "<https://evil.example|new>",
"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 `&lt;https://evil.example|new&gt;`" in message
assert "<https://evil.example|new>" not in message
assert message.count("migrate to") == 1

View file

@ -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: {