diff --git a/litellm/proxy/management_endpoints/model_management_endpoints.py b/litellm/proxy/management_endpoints/model_management_endpoints.py index e4ecda3fe31..cd217e4a344 100644 --- a/litellm/proxy/management_endpoints/model_management_endpoints.py +++ b/litellm/proxy/management_endpoints/model_management_endpoints.py @@ -947,13 +947,32 @@ async def delete_model( ) model_params = Deployment(**model_in_db.model_dump()) - await ModelManagementAuthChecks.can_user_make_model_call( - model_params=model_params, - user_api_key_dict=user_api_key_dict, - prisma_client=prisma_client, - premium_user=premium_user, + + team_id = model_params.model_info.team_id if model_params.model_info else None + team_exists = team_id is None or ( + await prisma_client.db.litellm_teamtable.find_unique( + where={"team_id": team_id} + ) + is not None ) + if team_exists: + await ModelManagementAuthChecks.can_user_make_model_call( + model_params=model_params, + user_api_key_dict=user_api_key_dict, + prisma_client=prisma_client, + premium_user=premium_user, + ) + elif user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN: + # The model's team was deleted; without it team-admin membership can't be + # verified, so only a proxy admin may delete the orphaned model. + raise HTTPException( + status_code=403, + detail={ + "error": "Only a proxy admin can delete a model whose team has been deleted." + }, + ) + # update DB if store_model_in_db is True: """ 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 a9bb2b09a15..d3f21cf061c 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 @@ -1915,6 +1915,120 @@ class TestDeleteTeamBYOKModelGhost: mock_refresh.assert_not_awaited() +class TestDeleteOrphanedTeamModel: + """Deleting a team BYOK model whose team was deleted. + + A model added via /model/new with model_info.team_id is orphaned once its + team is deleted: can_user_make_model_call looked the team up and raised + 'Team id=... does not exist in db' before the delete could run, so the model + was undeletable from the Models + Endpoints page. Without the team, team-admin + membership can't be verified, so a proxy admin (and only a proxy admin) may + delete the orphan; a missing team must never let a non-admin through. + """ + + def _orphaned_model_mocks(self, team_id, model_id): + db_row = LiteLLM_ProxyModelTable( + model_id=model_id, + model_name=f"model_name_{team_id}_abc-uuid", + litellm_params={"model": "openai/gpt-4.1-nano"}, + model_info={ + "id": model_id, + "team_id": team_id, + "team_public_model_name": "orphaned-gpt", + }, + created_by="admin", + updated_by="admin", + ) + 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=[]) + # The team is gone -> every team lookup returns None. + mock_prisma.db.litellm_teamtable = AsyncMock() + mock_prisma.db.litellm_teamtable.find_unique = AsyncMock(return_value=None) + mock_prisma.db.litellm_teamtable.update = AsyncMock() + mock_prisma.db.litellm_modeltable = AsyncMock() + mock_prisma.db.litellm_modeltable.find_many = AsyncMock(return_value=[]) + return mock_prisma + + @pytest.mark.asyncio + async def test_proxy_admin_can_delete_model_when_team_deleted(self): + from litellm.proxy.management_endpoints.model_management_endpoints import ( + ModelInfoDelete, + delete_model as delete_model_endpoint, + ) + + team_id = "deleted-team-xyz" + model_id = "orphaned-byok-1" + mock_prisma = self._orphaned_model_mocks(team_id, model_id) + + 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", MagicMock()), + 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_proxymodeltable.delete.assert_awaited_once() + # Team is gone -> no team.models cleanup to do. + mock_prisma.db.litellm_teamtable.update.assert_not_awaited() + + @pytest.mark.asyncio + async def test_non_admin_cannot_delete_model_when_team_deleted(self): + """A missing team must never let a non-admin delete the orphan (no fail-open).""" + from litellm.proxy.management_endpoints.model_management_endpoints import ( + ModelInfoDelete, + delete_model as delete_model_endpoint, + ) + from litellm.proxy.proxy_server import ProxyException + + team_id = "deleted-team-abc" + model_id = "orphaned-byok-2" + mock_prisma = self._orphaned_model_mocks(team_id, model_id) + + non_admin = UserAPIKeyAuth( + user_id="someone", user_role=LitellmUserRoles.INTERNAL_USER + ) + + _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", MagicMock()), + patch(f"{_PS}.proxy_logging_obj", MagicMock()), + patch(f"{_PS}.user_api_key_cache", MagicMock()), + patch(f"{_MOD}._refresh_cached_team", new=AsyncMock()), + ): + with pytest.raises(ProxyException) as exc_info: + await delete_model_endpoint( + model_info=ModelInfoDelete(id=model_id), + user_api_key_dict=non_admin, + ) + + assert str(exc_info.value.code) == "403" + mock_prisma.db.litellm_proxymodeltable.delete.assert_not_awaited() + + class TestGetTeamDeployments: """Tests for _get_team_deployments which filters by model_name prefix + Python-side team_id check."""