From 957af3023594810253a0abf06cf51348bfa43a4c Mon Sep 17 00:00:00 2001 From: Tushar More Date: Tue, 16 Jun 2026 10:07:51 -0700 Subject: [PATCH] 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. --- litellm/proxy/proxy_server.py | 40 ++++++ .../test_team_model_name_translation.py | 129 ++++++++++++++++++ 2 files changed, 169 insertions(+) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 0d6374fec69..3f9c7b2bda6 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -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", {}) diff --git a/tests/test_litellm/proxy/proxy_server/test_team_model_name_translation.py b/tests/test_litellm/proxy/proxy_server/test_team_model_name_translation.py index 6a8e0d15d8b..4165664e0e4 100644 --- a/tests/test_litellm/proxy/proxy_server/test_team_model_name_translation.py +++ b/tests/test_litellm/proxy/proxy_server/test_team_model_name_translation.py @@ -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"]