From 0b14fd0b74c66e34fdd9eda7fdcb1fa2606d0b8d Mon Sep 17 00:00:00 2001 From: pengzh1 Date: Sat, 5 Sep 2026 08:43:17 +0800 Subject: [PATCH] fix(proxy): hide blocked models not owned by the team from teamId-filtered model lists GH#38949: a disabled (blocked) public deployment with no team assignment surfaced in a team's Models + Endpoints list whenever a stale team.models entry resolved to its public model name. _filter_models_by_team_id now skips blocked deployments the team does not own; team-owned blocked rows stay visible so admins can re-enable them. Fixes #38949 --- litellm/proxy/proxy_server.py | 9 ++ ...test_filter_models_by_team_access_group.py | 115 +++++++++++++++++- 2 files changed, 121 insertions(+), 3 deletions(-) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 5a39b8c610a..0a017afe4a7 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -13695,6 +13695,15 @@ async def _filter_models_by_team_id( filtered_models.append(_model) continue + # A paused (blocked) deployment the team does not own must not surface + # in the team's list: resolving a stale team.models entry or an + # access_via_team_ids grant can still reach a deployment the admin has + # disabled, and listing it makes the disabled public model look + # attached to the team (GH#38949). Team-owned blocked rows stay + # visible above so admins can re-enable them. + if model_info.get("blocked"): + continue + access_via_team_ids = model_info.get("access_via_team_ids", []) if isinstance(access_via_team_ids, list) and team_id in access_via_team_ids: filtered_models.append(_model) diff --git a/tests/test_litellm/proxy/test_filter_models_by_team_access_group.py b/tests/test_litellm/proxy/test_filter_models_by_team_access_group.py index 1a514ed2c57..6f5f65e0f0e 100644 --- a/tests/test_litellm/proxy/test_filter_models_by_team_access_group.py +++ b/tests/test_litellm/proxy/test_filter_models_by_team_access_group.py @@ -212,9 +212,7 @@ async def test_filter_db_fallback_receives_resolved_model_names(): mock_prisma = MagicMock() mock_prisma.db.litellm_teamtable.find_unique = AsyncMock(return_value=team_db) - mock_prisma.db.litellm_proxymodeltable.find_many = AsyncMock( - return_value=[mock_db_model] - ) + mock_prisma.db.litellm_proxymodeltable.find_many = AsyncMock(return_value=[mock_db_model]) result = await _filter_models_by_team_id( all_models=all_models, @@ -231,3 +229,114 @@ async def test_filter_db_fallback_receives_resolved_model_names(): "gpt-5", }, f"DB query should receive resolved model names, got {queried_names}" assert "Group-A" not in queried_names, "Raw access group name should not be in DB query" + + +@pytest.mark.asyncio +async def test_filter_excludes_blocked_public_model_with_no_team_assignment(): + """ + GH#38949: a disabled (blocked) public deployment with no team assignment in + the DB must not appear in a team's model list, even when a stale + team.models entry still resolves to its public model name via the router. + + Reproduction from the issue: team "Team-Alpha" has its own aliased + deployment; a chat call through the team key writes the public model name + into team.models; the team filter then resolves that name to the disabled + public deployment and listed it as a second, team-attached model. + """ + # Disabled public deployment: no team_id, paused by the admin. + public_blocked = { + "model_name": "gpt-5.1", + "litellm_params": {"model": "gpt-5.1"}, + "model_info": {"id": "id-public-blocked", "blocked": True, "team_id": None}, + } + # The team's own deployment (unique alias name), active. + team_alias = { + "model_name": "gpt-5.1-team-alpha", + "litellm_params": {"model": "gpt-5.1"}, + "model_info": {"id": "id-team-alias", "blocked": False, "team_id": "team_alpha"}, + } + all_models = [public_blocked, team_alias] + + mock_router = MagicMock() + mock_router.get_model_access_groups.return_value = {} + # Public reachability: resolving the (stale) team.models name returns the + # disabled public deployment. This mirrors should_include_deployment's + # semantics, which this fix deliberately does not touch. + mock_router.get_model_list = MagicMock( + side_effect=lambda model_name=None, team_id=None: [public_blocked] if model_name == "gpt-5.1" else [] + ) + + team_db = _make_team(models=["gpt-5.1"]) + + mock_prisma = MagicMock() + mock_prisma.db.litellm_teamtable.find_unique = AsyncMock(return_value=team_db) + mock_prisma.db.litellm_proxymodeltable.find_many = AsyncMock(return_value=[]) + + result = await _filter_models_by_team_id( + all_models=all_models, + team_id="team_alpha", + prisma_client=mock_prisma, + llm_router=mock_router, + ) + + result_ids = {m["model_info"]["id"] for m in result} + assert result_ids == { + "id-team-alias", + }, f"Disabled public model must not be listed for the team, got {result_ids}" + + +@pytest.mark.asyncio +async def test_filter_excludes_blocked_models_not_owned_by_team(): + """ + A blocked deployment the team does not own (explicit access_via_team_ids + grant or name resolution) must not surface in the team's list, while a + blocked deployment owned by the team stays visible so admins can re-enable + it from the team view. + """ + blocked_granted = { + "model_name": "gpt-4o", + "litellm_params": {"model": "gpt-4o"}, + "model_info": { + "id": "id-granted-blocked", + "blocked": True, + "access_via_team_ids": ["team_alpha"], + }, + } + blocked_owned = { + "model_name": "claude-3", + "litellm_params": {"model": "claude-3"}, + "model_info": {"id": "id-owned-blocked", "blocked": True, "team_id": "team_alpha"}, + } + active_granted = { + "model_name": "gpt-4o", + "litellm_params": {"model": "gpt-4o"}, + "model_info": { + "id": "id-granted-active", + "blocked": False, + "access_via_team_ids": ["team_alpha"], + }, + } + all_models = [blocked_granted, blocked_owned, active_granted] + + mock_router = MagicMock() + mock_router.get_model_access_groups.return_value = {} + mock_router.get_model_list = MagicMock(return_value=[]) + + team_db = _make_team(models=["gpt-4o"]) + + mock_prisma = MagicMock() + mock_prisma.db.litellm_teamtable.find_unique = AsyncMock(return_value=team_db) + mock_prisma.db.litellm_proxymodeltable.find_many = AsyncMock(return_value=[]) + + result = await _filter_models_by_team_id( + all_models=all_models, + team_id="team_alpha", + prisma_client=mock_prisma, + llm_router=mock_router, + ) + + result_ids = {m["model_info"]["id"] for m in result} + assert result_ids == { + "id-owned-blocked", + "id-granted-active", + }, f"Blocked non-owned deployments must be hidden, team-owned kept; got {result_ids}"