fix(proxy): optionally surface public team model name in /v1/models

Behind general_settings.use_team_public_model_name (default False). When
enabled, /v1/models and /models surface the public team_public_model_name
for team-scoped (BYOK) models instead of the internal routing key
model_name_{team_id}_{uuid} -- consistent with /v1/model/info and
OpenAI-compatible. Off by default so the listing's model ids stay
backward-compatible for callers that scripted against the internal name;
routing by the internal name is unchanged regardless of the flag.

Presentation-layer only: access-group, auth, and routing semantics are
unchanged; non-team models are pass-through.
This commit is contained in:
Tushar More 2026-06-16 10:07:51 -07:00
parent 45d5153c12
commit 957af30235
2 changed files with 169 additions and 0 deletions

View file

@ -8341,6 +8341,12 @@ async def model_list(
if hidden_names:
all_models = [m for m in all_models if m not in hidden_names]
# Opt-in (default off): surface the public name for team-scoped rows.
# Off by default so /v1/models model ids stay backward-compatible for
# callers that scripted against the internal routing name.
if general_settings.get("use_team_public_model_name", False):
all_models = _translate_model_names_for_listing(all_models, llm_router)
# Build response data with all proxy models
model_data = []
for model in all_models:
@ -8378,6 +8384,12 @@ async def model_list(
if hidden_names:
all_models = [m for m in all_models if m not in hidden_names]
# Opt-in (default off): surface the public name for team-scoped rows.
# Off by default so /v1/models model ids stay backward-compatible for
# callers that scripted against the internal routing name.
if general_settings.get("use_team_public_model_name", False):
all_models = _translate_model_names_for_listing(all_models, llm_router)
# Build response data
model_data = []
for model in all_models:
@ -12587,6 +12599,34 @@ def _translate_model_name_for_response(model: dict) -> dict:
return {**model, "model_name": team_public}
def _translate_model_names_for_listing(model_names: List[str], llm_router) -> List[str]:
"""Swap internal team routing keys for their public names in list-style
responses (e.g. `/v1/models`, `/models`).
`/v1/models` builds from bare model-name strings produced by access-group
expansion (`get_model_access_groups`), which surfaces the internal routing
key `model_name_{team_id}_{uuid}` for team-scoped (BYOK) deployments. This
is a presentation-layer swap only -- access-group/auth semantics are
unchanged (see issue #28382). Sibling deployments collapse to one public
name, so the result is de-duplicated while preserving order.
"""
if llm_router is None:
return model_names
internal_to_public: Dict[str, str] = {}
for m in getattr(llm_router, "model_list", None) or []:
model_info = m.get("model_info") or {}
if not isinstance(model_info, dict):
continue
team_id = model_info.get("team_id")
team_public = model_info.get("team_public_model_name")
name = m.get("model_name") or ""
if team_id and team_public and name.startswith(f"model_name_{team_id}_"):
internal_to_public[name] = team_public
if not internal_to_public:
return model_names
return list(dict.fromkeys(internal_to_public.get(n, n) for n in model_names))
def _get_proxy_model_info(model: dict) -> dict:
# provided model_info in config.yaml
model_info = model.get("model_info", {})

View file

@ -593,3 +593,132 @@ async def test_model_info_v1_litellm_model_id_team_id_applies_team_filter(monkey
team_filter.assert_awaited_once()
assert team_filter.await_args.kwargs["team_id"] == "other-team"
assert team_filter.await_args.kwargs["all_models"] == [team_row]
@pytest.mark.asyncio
async def test_v1_models_translates_team_model_for_access_group_key(monkeypatch):
"""Regression (#28382 sibling leak): a virtual key whose model access group
resolves to a team BYOK deployment must list the PUBLIC name in /v1/models,
not the internal routing key model_name_{team_id}_{uuid}.
The /model/info read-path fix did not cover /v1/models, which builds from
bare model-name strings via access-group expansion.
"""
team_dep = {
"model_name": "model_name_teamX_uuid9",
"litellm_params": {"model": "azure/gpt-4.1"},
"model_info": {
"id": "id1",
"team_id": "teamX",
"team_public_model_name": "tushar-gpt-4.1",
"access_groups": ["grp-a"],
},
}
router = MagicMock()
router.get_model_names.return_value = ["model_name_teamX_uuid9"]
router.get_model_access_groups.return_value = {"grp-a": ["model_name_teamX_uuid9"]}
router.get_fully_blocked_model_names.return_value = set()
router.model_list = [team_dep]
router.get_model_list.return_value = [team_dep]
monkeypatch.setattr(ps, "llm_router", router)
monkeypatch.setattr(ps, "user_model", None)
# opt-in flag ON -> listing surfaces public names
monkeypatch.setattr(ps, "general_settings", {"use_team_public_model_name": True})
# virtual key granted access via the access group (no team membership)
key = UserAPIKeyAuth(
user_id="u", api_key="sk-test", models=["grp-a"], team_models=[]
)
resp = await ps.model_list(user_api_key_dict=key)
ids = [d["id"] for d in resp["data"]]
assert "tushar-gpt-4.1" in ids
assert "model_name_teamX_uuid9" not in ids
@pytest.mark.asyncio
async def test_v1_models_keeps_internal_names_when_flag_off(monkeypatch):
"""Default (flag off): /v1/models stays backward-compatible and lists the
internal routing name, so consumers that scripted against those ids are not
broken. Translation is opt-in via
general_settings['use_team_public_model_name'].
"""
team_dep = {
"model_name": "model_name_teamX_uuid9",
"litellm_params": {"model": "azure/gpt-4.1"},
"model_info": {
"id": "id1",
"team_id": "teamX",
"team_public_model_name": "tushar-gpt-4.1",
"access_groups": ["grp-a"],
},
}
router = MagicMock()
router.get_model_names.return_value = ["model_name_teamX_uuid9"]
router.get_model_access_groups.return_value = {"grp-a": ["model_name_teamX_uuid9"]}
router.get_fully_blocked_model_names.return_value = set()
router.model_list = [team_dep]
router.get_model_list.return_value = [team_dep]
monkeypatch.setattr(ps, "llm_router", router)
monkeypatch.setattr(ps, "user_model", None)
monkeypatch.setattr(ps, "general_settings", {}) # flag absent -> default off
key = UserAPIKeyAuth(
user_id="u", api_key="sk-test", models=["grp-a"], team_models=[]
)
resp = await ps.model_list(user_api_key_dict=key)
ids = [d["id"] for d in resp["data"]]
assert "model_name_teamX_uuid9" in ids # internal id preserved (backward-compat)
assert "tushar-gpt-4.1" not in ids
def test_translate_model_names_for_listing_swaps_and_dedupes():
"""Internal team routing keys -> public name; sibling deployments sharing a
public name collapse to one entry (order preserved); globals untouched."""
from litellm.proxy.proxy_server import _translate_model_names_for_listing
router = MagicMock()
router.model_list = [
{
"model_name": "model_name_teamX_uuidA",
"model_info": {
"team_id": "teamX",
"team_public_model_name": "tushar-gpt-4.1",
},
},
{
"model_name": "model_name_teamX_uuidB", # sibling: same public name
"model_info": {
"team_id": "teamX",
"team_public_model_name": "tushar-gpt-4.1",
},
},
{"model_name": "gpt-4o", "model_info": {"db_model": False}},
]
out = _translate_model_names_for_listing(
["model_name_teamX_uuidA", "model_name_teamX_uuidB", "gpt-4o"], router
)
assert out == ["tushar-gpt-4.1", "gpt-4o"]
def test_translate_model_names_for_listing_leaves_unmapped_names():
"""Names with no team mapping (globals, access-group keys) pass through."""
from litellm.proxy.proxy_server import _translate_model_names_for_listing
router = MagicMock()
router.model_list = [{"model_name": "gpt-4o", "model_info": {"db_model": False}}]
assert _translate_model_names_for_listing(["gpt-4o", "beta-group"], router) == [
"gpt-4o",
"beta-group",
]
def test_translate_model_names_for_listing_none_router():
"""No router -> return the input list unchanged."""
from litellm.proxy.proxy_server import _translate_model_names_for_listing
assert _translate_model_names_for_listing(["a", "b"], None) == ["a", "b"]