fix(proxy): expand all-proxy-models sentinel in direct access lookup (#31153)

A user provisioned with "All Proxy Models" stores the literal
"all-proxy-models" sentinel in user.models. get_direct_access_models looked
that string up as a real model_name via get_model_list, which matched no
deployment, so /v2/model/info marked every model direct_access=false and the
Models + Endpoints page rendered empty for such users when they have no teams.
The model dropdown / Playground worked because get_key_models already expands
the sentinel to the full proxy model list, hence the inconsistency in the
report.

Expand the sentinel to all non-team deployment ids via
get_model_ids(exclude_team_models=True), the same call the PROXY_ADMIN branch
in the caller already uses. This fixes both /v1/model/info and /v2/model/info
since they share _populate_team_access_on_models. Empty user.models stays "no
direct access" to match get_key_models semantics.

Fixes #22791
This commit is contained in:
mubashir1osmani 2026-06-23 22:23:14 -07:00 committed by GitHub
parent d0706c17fe
commit e0c8a6b483
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 98 additions and 12 deletions

View file

@ -11500,18 +11500,20 @@ def get_direct_access_models(
llm_router: Router,
) -> List[str]:
"""
Get all models that user has direct access to
"""
Get all models that user has direct access to.
direct_access_models: List[str] = []
for model in user_db_object.models:
deployments = llm_router.get_model_list(model_name=model)
if deployments is not None:
for deployment in deployments:
model_id = deployment.get("model_info", {}).get("id", None)
if model_id is not None:
direct_access_models.append(model_id)
return direct_access_models
The 'all-proxy-models' sentinel grants direct access to every non-team
deployment, mirroring how get_key_models expands it for the key/team path.
"""
if SpecialModelNames.all_proxy_models.value in user_db_object.models:
return llm_router.get_model_ids(exclude_team_models=True)
return [
model_id
for model in user_db_object.models
for deployment in (llm_router.get_model_list(model_name=model) or [])
if (model_id := deployment.get("model_info", {}).get("id", None)) is not None
]
def _filter_models_to_user_accessible(all_models: List[Dict]) -> List[Dict]:

View file

@ -14,7 +14,11 @@ from unittest.mock import AsyncMock, MagicMock
import pytest
import litellm.proxy.proxy_server as ps
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy._types import (
LiteLLM_UserTable,
LitellmUserRoles,
UserAPIKeyAuth,
)
from litellm.proxy.common_utils.model_listing_utils import TeamModelNameTranslator
from litellm.proxy.proxy_server import (
_get_proxy_model_info,
@ -1360,3 +1364,83 @@ async def test_retrieve_model_by_inaccessible_public_name_404s(monkeypatch):
assert exc_info.value.status_code == 404
router.get_deployment_by_model_group_name.assert_not_called()
def test_get_direct_access_models_expands_all_proxy_models_sentinel():
"""A user provisioned with 'all-proxy-models' has direct access to every non-team
deployment. The sentinel must resolve via get_model_ids, not be looked up as a
literal model_name (which matches nothing). Regression for GH#22791."""
router = MagicMock()
router.get_model_ids.return_value = ["global-id-1", "global-id-2"]
router.get_model_list.return_value = []
user = LiteLLM_UserTable(
user_id="u",
models=[ps.SpecialModelNames.all_proxy_models.value],
teams=[],
)
result = ps.get_direct_access_models(user_db_object=user, llm_router=router)
assert result == ["global-id-1", "global-id-2"]
router.get_model_ids.assert_called_once_with(exclude_team_models=True)
router.get_model_list.assert_not_called()
def test_get_direct_access_models_resolves_explicit_model_names():
"""Without the sentinel, only the user's explicitly listed models resolve to ids;
the all-proxy-models shortcut must not fire."""
router = MagicMock()
router.get_model_list.return_value = [{"model_info": {"id": "gpt4o-id"}}]
user = LiteLLM_UserTable(user_id="u", models=["gpt-4o"], teams=[])
result = ps.get_direct_access_models(user_db_object=user, llm_router=router)
assert result == ["gpt4o-id"]
router.get_model_ids.assert_not_called()
router.get_model_list.assert_called_once_with(model_name="gpt-4o")
@pytest.mark.asyncio
async def test_populate_team_access_grants_all_proxy_models_user_direct_access(
monkeypatch,
):
"""An internal user provisioned with 'all-proxy-models' and no teams must see proxy
models on the Models+Endpoints page. Before the fix _populate_team_access_on_models
marked direct_access=False, so _filter_models_to_user_accessible dropped every model
and the page was empty. Regression for GH#22791."""
global_row = {
"model_name": "gpt-4o",
"litellm_params": {"model": "gpt-4o"},
"model_info": {"id": "global-id-1", "db_model": False},
}
router = MagicMock()
router.get_model_ids.return_value = ["global-id-1"]
user_row = LiteLLM_UserTable(
user_id="u",
user_role=LitellmUserRoles.INTERNAL_USER.value,
models=[ps.SpecialModelNames.all_proxy_models.value],
teams=[],
)
prisma_client = MagicMock()
prisma_client.db.litellm_usertable.find_unique = AsyncMock(return_value=user_row)
monkeypatch.setattr(ps, "get_all_team_models", AsyncMock(return_value={}))
caller = UserAPIKeyAuth(
user_id="u", user_role=LitellmUserRoles.INTERNAL_USER, team_models=[]
)
populated = await ps._populate_team_access_on_models(
user_api_key_dict=caller,
prisma_client=prisma_client,
llm_router=router,
all_models=[global_row],
)
visible = ps._filter_models_to_user_accessible(populated)
assert [m["model_info"]["id"] for m in visible] == ["global-id-1"]
assert visible[0]["model_info"]["direct_access"] is True