diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index d3396b2fbe1..73352c357d5 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -15,6 +15,7 @@ import threading import time import traceback import warnings +from collections.abc import Mapping, Sequence from datetime import datetime, timedelta, timezone from typing import ( TYPE_CHECKING, @@ -8345,8 +8346,10 @@ async def model_list( # Surface the public name for team-scoped rows by default. Operators # that need legacy internal routing keys can explicitly disable this. - if general_settings.get("use_team_public_model_name", True): - all_models = _translate_model_names_for_listing(all_models, llm_router) + if _should_use_team_public_model_name(): + all_models = _translate_model_names_for_listing( + all_models, _get_team_model_deployments_for_listing(llm_router) + ) # Build response data with all proxy models model_data = [] @@ -8387,8 +8390,10 @@ async def model_list( # Surface the public name for team-scoped rows by default. Operators that # need legacy internal routing keys can explicitly disable this. - if general_settings.get("use_team_public_model_name", True): - all_models = _translate_model_names_for_listing(all_models, llm_router) + if _should_use_team_public_model_name(): + all_models = _translate_model_names_for_listing( + all_models, _get_team_model_deployments_for_listing(llm_router) + ) # Build response data model_data = [] @@ -12599,7 +12604,24 @@ 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]: +def _should_use_team_public_model_name() -> bool: + settings = cast(dict[str, object], general_settings) # any-ok: legacy settings + use_public_name = settings.get("use_team_public_model_name", True) + return use_public_name is not False + + +def _get_team_model_deployments_for_listing( + llm_router: Router | None, +) -> Sequence[Mapping[str, object]]: + if llm_router is None: + return () + return cast(Sequence[Mapping[str, object]], llm_router.get_model_list() or ()) + + +def _translate_model_names_for_listing( + model_names: list[str], + model_deployments: Sequence[Mapping[str, object]], +) -> list[str]: """Swap internal team routing keys for their public names in list-style responses (e.g. `/v1/models`, `/models`). @@ -12610,21 +12632,35 @@ def _translate_model_names_for_listing(model_names: List[str], llm_router) -> Li 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: + if not model_deployments: 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): + internal_to_public: dict[str, str] = {} + for model in model_deployments: + model_info_raw = model.get("model_info") + if not isinstance(model_info_raw, Mapping): continue + model_info = cast(Mapping[str, object], model_info_raw) # any-ok: checked 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}_"): + name = model.get("model_name") + if ( + isinstance(team_id, str) + and isinstance(team_public, str) + and isinstance(name, str) + 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)) + translated_names: list[str] = [] + seen_names: set[str] = set() + for model_name in model_names: + translated_name = internal_to_public.get(model_name, model_name) + if translated_name in seen_names: + continue + seen_names.add(translated_name) + translated_names.append(translated_name) + return translated_names def _get_proxy_model_info(model: dict) -> dict: 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 f8532fecafa..260e155936c 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 @@ -676,6 +676,46 @@ async def test_v1_models_keeps_internal_names_when_public_name_flag_disabled( assert "tushar-gpt-4.1" not in ids +@pytest.mark.asyncio +async def test_v1_models_translates_team_model_with_metadata(monkeypatch): + """include_metadata=true must build metadata for the public model id.""" + 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", {}) + + key = UserAPIKeyAuth( + user_id="u", api_key="sk-test", models=["grp-a"], team_models=[] + ) + resp = await ps.model_list(user_api_key_dict=key, include_metadata=True) + + assert resp["data"] == [ + { + "id": "tushar-gpt-4.1", + "object": "model", + "created": 1677610602, + "owned_by": "openai", + "metadata": {"fallbacks": []}, + } + ] + + 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.""" @@ -701,7 +741,8 @@ def test_translate_model_names_for_listing_swaps_and_dedupes(): ] out = _translate_model_names_for_listing( - ["model_name_teamX_uuidA", "model_name_teamX_uuidB", "gpt-4o"], router + ["model_name_teamX_uuidA", "model_name_teamX_uuidB", "gpt-4o"], + router.model_list, ) assert out == ["tushar-gpt-4.1", "gpt-4o"] @@ -712,14 +753,13 @@ def test_translate_model_names_for_listing_leaves_unmapped_names(): 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", - ] + assert _translate_model_names_for_listing( + ["gpt-4o", "beta-group"], router.model_list + ) == ["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"] + assert _translate_model_names_for_listing(["a", "b"], ()) == ["a", "b"]