fix(model-management): allow deleting a BYOK model after its team is deleted (#29875)

* fix(model-management): allow deleting a BYOK model after its team is deleted

A team BYOK model (model_info.team_id set) became undeletable once its team
was deleted: POST /model/delete ran can_user_make_model_call, which looked the
team up and raised 400 "Team id=... does not exist in db" before the delete
could run, so the model lingered on the Models + Endpoints page with no way to
remove it.

Drop the team-existence prerequisite from the delete path. When the model's
team still exists the normal auth check runs unchanged; when it is gone a proxy
admin may delete the orphan and any other caller gets a 403. The check is
fail-closed, so a missing or errored team lookup can only block the delete or
require an admin, never grant a non-admin access. Add/update/health keep their
team-existence validation.

* refactor(model-management): drop redundant team lookup on model delete

Move the orphaned-team handling into can_user_make_model_call behind an
allow_missing_team flag instead of pre-checking team existence in delete_model.
The endpoint no longer issues its own litellm_teamtable lookup, so deleting a
model whose team still exists hits the team table once instead of twice. The
auth behavior is unchanged: a proxy admin can delete a model whose team was
deleted, any other caller gets a 403, and add/update/health keep the strict
"team must exist" validation.
This commit is contained in:
yuneng-jiang 2026-06-08 14:28:39 -07:00 committed by GitHub
parent dfb68a23de
commit 69a7bdb247
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 198 additions and 0 deletions

View File

@ -860,6 +860,7 @@ class ModelManagementAuthChecks:
user_api_key_dict: UserAPIKeyAuth,
prisma_client: PrismaClient,
premium_user: bool,
allow_missing_team: bool = False,
) -> Literal[True]:
## Check team model auth
if (
@ -870,6 +871,18 @@ class ModelManagementAuthChecks:
where={"team_id": model_params.model_info.team_id}
)
if team_obj_row is None:
# The team was deleted. Callers that opt in (e.g. model deletion) may
# act on the orphaned model, but only as a proxy admin -- without the
# team there is no team-admin membership left to verify.
if allow_missing_team:
if user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN:
return True
raise HTTPException(
status_code=403,
detail={
"error": "Only a proxy admin can delete a model whose team has been deleted."
},
)
raise HTTPException(
status_code=400,
detail={
@ -955,6 +968,7 @@ async def delete_model(
user_api_key_dict=user_api_key_dict,
prisma_client=prisma_client,
premium_user=premium_user,
allow_missing_team=True,
)
# update DB

View File

@ -1915,6 +1915,190 @@ class TestDeleteTeamBYOKModelGhost:
mock_refresh.assert_not_awaited()
class TestDeleteModelTeamAuth:
"""Team auth on the /model/delete path.
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. The team
is also looked up exactly once -- the auth check must not add a second query.
"""
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()
@pytest.mark.asyncio
async def test_live_team_delete_looks_up_team_once(self):
"""The auth check must not add a redundant team query on the live-team path."""
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 = "live-team-1"
model_id = "live-byok-1"
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": "live-gpt",
},
created_by="admin",
updated_by="admin",
)
team_row = LiteLLM_TeamTable(
team_id=team_id,
team_alias="live-team",
members_with_roles=[Member(user_id="admin", role="admin")],
models=["live-gpt"],
)
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_modeltable = AsyncMock()
mock_prisma.db.litellm_modeltable.find_many = AsyncMock(return_value=[])
# A team member who is not the team admin: rejected before the delete runs,
# so the only team lookup is the single one inside the auth check.
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"
assert mock_prisma.db.litellm_teamtable.find_unique.await_count == 1
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."""