From d409fec6de277521f76c170bae42bdb9584f9e19 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Tue, 28 Jul 2026 20:07:30 +0000 Subject: [PATCH] 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 --- .../model_management_endpoints.py | 16 +++- .../test_model_management_endpoints.py | 73 +++++++++++++++++++ 2 files changed, 86 insertions(+), 3 deletions(-) diff --git a/litellm/proxy/management_endpoints/model_management_endpoints.py b/litellm/proxy/management_endpoints/model_management_endpoints.py index ddb7f08ad15..bdaa69cd8fe 100644 --- a/litellm/proxy/management_endpoints/model_management_endpoints.py +++ b/litellm/proxy/management_endpoints/model_management_endpoints.py @@ -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 = ( diff --git a/tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py index 54c17a845d2..b14c9d1e490 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py @@ -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.