mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-16 04:29:31 +00:00
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:
parent
06efa5de85
commit
e65e5abc4e
5 changed files with 253 additions and 17 deletions
|
|
@ -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})"
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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 "<!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": "<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 `<https://evil.example|new>`" in message
|
||||
assert "<https://evil.example|new>" not in message
|
||||
assert message.count("migrate to") == 1
|
||||
|
|
|
|||
11
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
11
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue