fix(proxy): align /v1/model/info with router deployments

Return router model_list entries (including team-scoped models) with team
access metadata instead of wildcard-expanded names from get_complete_model_list.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Sameer Kankute 2026-06-09 18:38:04 +05:30
parent 51ba6e39cd
commit 72a960173a
No known key found for this signature in database
4 changed files with 127 additions and 50 deletions

View file

@ -12384,6 +12384,27 @@ async def model_metrics_exceptions(
return {"data": response, "exception_types": list(exception_types)}
def _deployment_matches_allowed_model_names(
model: Dict[str, Any], allowed_model_names: Set[str]
) -> bool:
"""Match a router deployment against allowed public model names.
Team-scoped rows store an internal routing key in ``model_name``; callers
with key/team restrictions still refer to the public name in
``model_info.team_public_model_name``.
"""
if model.get("model_name") in allowed_model_names:
return True
model_info = model.get("model_info")
if not isinstance(model_info, dict):
return False
team_public_model_name = model_info.get("team_public_model_name")
return (
isinstance(team_public_model_name, str)
and team_public_model_name in allowed_model_names
)
def _translate_model_name_for_response(model: dict) -> dict:
"""For team-scoped DB rows, replace `model_name` with the public name
in `model_info.team_public_model_name` before returning. The DB column
@ -12553,49 +12574,67 @@ async def model_info_v1( # noqa: PLR0915
)
return {"data": [_deployment_info_dict]}
all_models: List[dict] = []
model_access_groups: Dict[str, List[str]] = defaultdict(list)
## CHECK IF MODEL RESTRICTIONS ARE SET AT KEY/TEAM LEVEL ##
if llm_router is None:
proxy_model_list = []
# Return router deployments (same source as /v2/model/info), not wildcard-
# expanded model names from get_complete_model_list(). Team-scoped rows
# use internal routing keys (model_name_{team_id}_{uuid}) and were omitted
# when v1 resolved models only via public model_name strings.
all_models: List[dict] = copy.deepcopy(llm_router.model_list)
if prisma_client is not None:
all_models = await get_all_team_and_direct_access_models(
user_api_key_dict=user_api_key_dict,
prisma_client=prisma_client,
llm_router=llm_router,
all_models=all_models,
)
else:
proxy_model_list = llm_router.get_model_names()
model_access_groups = llm_router.get_model_access_groups()
key_models = get_key_models(
proxy_model_list = llm_router.get_model_names()
key_models = get_key_models(
user_api_key_dict=user_api_key_dict,
proxy_model_list=proxy_model_list,
model_access_groups=model_access_groups,
)
team_models = get_team_models(
team_models=user_api_key_dict.team_models,
proxy_model_list=proxy_model_list,
model_access_groups=model_access_groups,
)
if key_models or team_models:
allowed_model_names = set(
get_complete_model_list(
key_models=key_models,
team_models=team_models,
proxy_model_list=proxy_model_list,
user_model=user_model,
infer_model_from_keys=general_settings.get(
"infer_model_from_keys", False
),
llm_router=llm_router,
return_wildcard_routes=False,
)
)
all_models = [
model
for model in all_models
if _deployment_matches_allowed_model_names(model, allowed_model_names)
]
all_models = [
_translate_model_name_for_response(
_enrich_model_info_with_litellm_data(model=model, llm_router=llm_router)
)
for model in all_models
]
from litellm.proxy.agent_endpoints.model_list_helpers import (
append_agents_to_model_info,
)
all_models = await append_agents_to_model_info(
models=all_models,
user_api_key_dict=user_api_key_dict,
proxy_model_list=proxy_model_list,
model_access_groups=model_access_groups,
)
team_models = get_team_models(
team_models=user_api_key_dict.team_models,
proxy_model_list=proxy_model_list,
model_access_groups=model_access_groups,
)
all_models_str = get_complete_model_list(
key_models=key_models,
team_models=team_models,
proxy_model_list=proxy_model_list,
user_model=user_model,
infer_model_from_keys=general_settings.get("infer_model_from_keys", False),
llm_router=llm_router,
)
if len(all_models_str) > 0:
_relevant_models = []
for model in all_models_str:
router_models = llm_router.get_model_list(model_name=model)
if router_models is not None:
_relevant_models.extend(router_models)
if llm_model_list is not None:
all_models = copy.deepcopy(_relevant_models) # type: ignore
else:
all_models = []
# Reassign each entry: _get_proxy_model_info returns a (possibly new)
# dict via _translate_model_name_for_response, which does NOT mutate in
# place. Binding only the loop variable would drop the public-name swap
# for team-scoped rows and leak the internal routing key (#28382).
all_models = [_get_proxy_model_info(model=model) for model in all_models]
verbose_proxy_logger.debug("all_models: %s", all_models)
return {"data": all_models}

View file

@ -151,21 +151,53 @@ async def test_model_info_v2_translates_team_model_name(monkeypatch):
@pytest.mark.asyncio
async def test_model_info_v1_list_path_translates_team_model_name(monkeypatch):
"""/v1/model/info list path (no litellm_model_id) must surface the public
name. Covers the list comprehension that assigns _get_proxy_model_info's
return back into all_models (#28382 review)."""
"""/v1/model/info list path (no litellm_model_id) must include team-scoped
deployments from the router model list and surface the public name plus
access_via_team_ids (#28382)."""
team_row = _team_row()
global_row = {
"model_name": "gpt-4o",
"litellm_params": {"model": "gpt-4o"},
"model_info": {"id": "normal-id-1", "db_model": False},
}
router = MagicMock()
router.get_model_names.return_value = ["team-claude-sonnet"]
router.model_list = [team_row, global_row]
router.get_model_names.return_value = ["gpt-4o"]
router.get_model_access_groups.return_value = {}
router.get_model_list.return_value = [_team_row()]
monkeypatch.setattr(ps, "user_model", None)
monkeypatch.setattr(ps, "llm_model_list", [_team_row()])
monkeypatch.setattr(ps, "llm_model_list", router.model_list)
monkeypatch.setattr(ps, "llm_router", router)
monkeypatch.setattr(ps, "get_key_models", lambda **kw: [])
monkeypatch.setattr(ps, "get_team_models", lambda **kw: [])
monkeypatch.setattr(ps, "prisma_client", MagicMock())
monkeypatch.setattr(
ps, "get_complete_model_list", lambda **kw: ["team-claude-sonnet"]
ps,
"get_all_team_and_direct_access_models",
AsyncMock(
side_effect=lambda all_models, **kwargs: [
{
**m,
"model_info": {
**m.get("model_info", {}),
**(
{"access_via_team_ids": ["team-abc-123"]}
if m.get("model_info", {}).get("team_id")
else {"direct_access": True}
),
},
}
for m in all_models
]
),
)
monkeypatch.setattr(
ps, "_enrich_model_info_with_litellm_data", lambda model, **kw: model
)
import litellm.proxy.agent_endpoints.model_list_helpers as mlh
monkeypatch.setattr(
mlh,
"append_agents_to_model_info",
AsyncMock(side_effect=lambda models, **kw: models),
)
admin = UserAPIKeyAuth(
@ -176,3 +208,7 @@ async def test_model_info_v1_list_path_translates_team_model_name(monkeypatch):
names = [m["model_name"] for m in resp["data"]]
assert "team-claude-sonnet" in names
assert "model_name_team-abc-123_4a6b8" not in names
team_model = next(
m for m in resp["data"] if m["model_name"] == "team-claude-sonnet"
)
assert team_model["model_info"]["access_via_team_ids"] == ["team-abc-123"]

View file

@ -146,9 +146,9 @@ class TestModelInfoEndpointWithRouter:
deployment_dict = deployment.model_dump(exclude_none=True)
mock_router = MagicMock()
mock_router.model_list = [deployment_dict]
mock_router.get_model_names.return_value = ["model1"]
mock_router.get_model_access_groups.return_value = {}
mock_router.get_model_list.return_value = [deployment_dict]
user_api_key_dict = UserAPIKeyAuth(api_key="sk-test")
@ -156,6 +156,7 @@ class TestModelInfoEndpointWithRouter:
patch("litellm.proxy.proxy_server.llm_router", mock_router),
patch("litellm.proxy.proxy_server.llm_model_list", [deployment_dict]),
patch("litellm.proxy.proxy_server.user_model", None),
patch("litellm.proxy.proxy_server.prisma_client", None),
patch("litellm.proxy.proxy_server.get_key_models", return_value=["model1"]),
patch(
"litellm.proxy.proxy_server.get_team_models", return_value=["model1"]

View file

@ -3810,14 +3810,15 @@ async def test_model_info_v1_oci_secrets_not_leaked():
# Mock the llm_router to return our test data
mock_router = MagicMock()
mock_router.model_list = [mock_model_data]
mock_router.get_model_names.return_value = ["oci-grok-test"]
mock_router.get_model_access_groups.return_value = {}
mock_router.get_model_list.return_value = [mock_model_data]
# Mock global variables
with (
patch("litellm.proxy.proxy_server.llm_router", mock_router),
patch("litellm.proxy.proxy_server.llm_model_list", [mock_model_data]),
patch("litellm.proxy.proxy_server.prisma_client", None),
patch(
"litellm.proxy.proxy_server.general_settings",
{"infer_model_from_keys": False},