fix(proxy): keep team model aliases while a surviving replica serves the deleted name

Scrub aliases on delete only when the deleted deployment's model_name no
longer resolves in the router. A legacy load-balanced team model can have
several deployment rows sharing one internal name; deleting one replica
must not remove aliases that still route to the survivors, in any team
This commit is contained in:
mateo-berri 2026-07-28 20:07:30 +00:00
parent 5e1d9705db
commit d409fec6de
No known key found for this signature in database
2 changed files with 86 additions and 3 deletions

View file

@ -802,6 +802,9 @@ async def _remove_unbacked_team_models(
``{public_name: "model_name_{team_id}_{uuid}"}`` entry in the team's model_aliases,
so the alias scan runs for every team model; skipping it for internal-shaped names
left stale aliases that rewrote requests to deployments that no longer exist.
Aliases are scrubbed only when the deleted deployment's name no longer resolves in
the router, so deleting one replica of a load-balanced group never breaks aliases
that still route to the surviving replicas (in any team).
A public name that still resolves to a live router deployment (e.g. a gateway-level
model group shared with the team) is kept in team.models, so deleting a per-team
@ -811,9 +814,16 @@ async def _remove_unbacked_team_models(
if team_id is None:
return
removed_model_aliases = await delete_team_model_alias(
public_model_name=model_params.model_name,
prisma_client=prisma_client,
deleted_name_still_served = (
llm_router is not None and model_params.model_name in llm_router.model_name_to_deployment_indices
)
removed_model_aliases: List[Tuple[str, str]] = (
[]
if deleted_name_still_served
else await delete_team_model_alias(
public_model_name=model_params.model_name,
prisma_client=prisma_client,
)
)
removed_alias_names = {alias for alias_team_id, alias in removed_model_aliases if alias_team_id == team_id}
candidate_names = (

View file

@ -2277,6 +2277,79 @@ class TestDeleteTeamBYOKModelGhost:
mock_prisma.db.litellm_teamtable.update.assert_not_awaited()
mock_refresh.assert_not_awaited()
@pytest.mark.asyncio
async def test_delete_replica_keeps_alias_while_surviving_replica_serves_it(self):
"""Deleting one replica of a load-balanced legacy team model (several
deployment rows sharing one internal model_name) must not scrub the team
alias: the surviving replicas still serve the aliased name, so removing
the alias would break routing that works."""
from litellm.proxy.management_endpoints.model_management_endpoints import (
ModelInfoDelete,
delete_model as delete_model_endpoint,
)
team_id = "team-lb-legacy"
model_id = "lb-replica-1"
internal_name = f"model_name_{team_id}_shared-uuid"
db_row = LiteLLM_ProxyModelTable(
model_id=model_id,
model_name=internal_name,
litellm_params={"model": "openai/gpt-4.1-nano"},
model_info={"id": model_id, "team_id": team_id},
created_by="admin",
updated_by="admin",
)
team_row = LiteLLM_TeamTable(
team_id=team_id,
team_alias="lb-legacy-team",
members_with_roles=[Member(user_id="admin", role="admin")],
models=["gpt-4"],
)
mock_prisma = MagicMock()
mock_prisma.db = MagicMock()
mock_prisma.db.litellm_proxymodeltable = AsyncMock()
mock_prisma.db.litellm_proxymodeltable.find_unique = AsyncMock(
return_value=db_row
)
mock_prisma.db.litellm_proxymodeltable.delete = AsyncMock(return_value=db_row)
mock_prisma.db.litellm_proxymodeltable.find_many = AsyncMock(return_value=[])
mock_prisma.db.litellm_teamtable = AsyncMock()
mock_prisma.db.litellm_teamtable.find_unique = AsyncMock(return_value=team_row)
mock_prisma.db.litellm_teamtable.update = AsyncMock(return_value=team_row)
mock_prisma.db.litellm_modeltable = AsyncMock()
mock_prisma.db.litellm_modeltable.find_many = AsyncMock(return_value=[])
mock_prisma.db.litellm_modeltable.update = AsyncMock()
mock_router = MagicMock()
mock_router.model_name_to_deployment_indices = {internal_name: [0]}
admin_user = UserAPIKeyAuth(
user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN
)
_PS = "litellm.proxy.proxy_server"
_MOD = "litellm.proxy.management_endpoints.model_management_endpoints"
with (
patch(f"{_PS}.prisma_client", mock_prisma),
patch(f"{_PS}.store_model_in_db", True),
patch(f"{_PS}.premium_user", True),
patch(f"{_PS}.llm_router", mock_router),
patch(f"{_PS}.proxy_logging_obj", MagicMock()),
patch(f"{_PS}.user_api_key_cache", MagicMock()),
patch(f"{_MOD}._refresh_cached_team", new=AsyncMock()),
):
result = await delete_model_endpoint(
model_info=ModelInfoDelete(id=model_id),
user_api_key_dict=admin_user,
)
assert "deleted successfully" in result["message"]
mock_prisma.db.litellm_modeltable.find_many.assert_not_awaited()
mock_prisma.db.litellm_modeltable.update.assert_not_awaited()
mock_prisma.db.litellm_teamtable.update.assert_not_awaited()
class TestDeleteModelTeamAuth:
"""Team auth on the /model/delete path.