mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-05 02:41:56 +00:00
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.
This commit is contained in:
parent
aeb55e7a11
commit
2d2f656aef
2 changed files with 138 additions and 5 deletions
|
|
@ -947,13 +947,32 @@ async def delete_model(
|
||||||
)
|
)
|
||||||
|
|
||||||
model_params = Deployment(**model_in_db.model_dump())
|
model_params = Deployment(**model_in_db.model_dump())
|
||||||
await ModelManagementAuthChecks.can_user_make_model_call(
|
|
||||||
model_params=model_params,
|
team_id = model_params.model_info.team_id if model_params.model_info else None
|
||||||
user_api_key_dict=user_api_key_dict,
|
team_exists = team_id is None or (
|
||||||
prisma_client=prisma_client,
|
await prisma_client.db.litellm_teamtable.find_unique(
|
||||||
premium_user=premium_user,
|
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
|
# update DB
|
||||||
if store_model_in_db is True:
|
if store_model_in_db is True:
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -1915,6 +1915,120 @@ class TestDeleteTeamBYOKModelGhost:
|
||||||
mock_refresh.assert_not_awaited()
|
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:
|
class TestGetTeamDeployments:
|
||||||
"""Tests for _get_team_deployments which filters by model_name prefix + Python-side team_id check."""
|
"""Tests for _get_team_deployments which filters by model_name prefix + Python-side team_id check."""
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue