From b9d86129b5c8c56d6451e2ce1563cb02b004eb62 Mon Sep 17 00:00:00 2001 From: ajitsharmas2007 Date: Sat, 26 Sep 2026 22:22:54 +0530 Subject: [PATCH] fix(proxy): reserve team-public model names and group aliases from the catalog get_model_names() is not the set of routable names. Given no team id it drops team-scoped deployments along with their public name, and despite its docstring it never returns model_group_alias keys. An advertised entry could therefore claim either kind of name and disclose it to a caller who cannot list the model behind it. Read team_public_model_names and model_group_alias off the router instead, and record on the helper why get_model_names() alone will not do. Also record the test callback's observations in a tuple rather than growing a list. --- .../proxy/common_utils/advertised_models.py | 17 +++++++--- .../common_utils/test_advertised_models.py | 33 ++++++++++++++++++- .../test_model_list_advertised_models.py | 4 +-- 3 files changed, 47 insertions(+), 7 deletions(-) diff --git a/litellm/proxy/common_utils/advertised_models.py b/litellm/proxy/common_utils/advertised_models.py index d7c14bda446..815fdf76a8a 100644 --- a/litellm/proxy/common_utils/advertised_models.py +++ b/litellm/proxy/common_utils/advertised_models.py @@ -81,13 +81,22 @@ def _listing_row(entry: AdvertisedModel) -> ModelInfoResponse: def _reserved_ids(listed_ids: Collection[str], llm_router: Router | None) -> frozenset[str]: """Ids a catalog entry may not claim. - Every name the router knows counts, not just the ids this caller can see, so - an entry cannot re-expose a deployment that team scoping, a pause or a health - filter had already removed from their listing. + Every routable name counts, not just the ids this caller can see, so an entry + cannot re-expose a deployment that team scoping, a pause or a health filter + had already removed from their listing. + + `get_model_names()` alone is not that set: with no team id it drops + team-scoped deployments, and despite its docstring it never returns + `model_group_alias` keys, so both are unioned in explicitly. """ if llm_router is None: return frozenset(listed_ids) - return frozenset(listed_ids).union(llm_router.get_model_names(), llm_router.get_model_access_groups()) + return frozenset(listed_ids).union( + llm_router.get_model_names(), + llm_router.get_model_access_groups(), + llm_router.team_public_model_names, + llm_router.model_group_alias or (), + ) def _first_per_id(entries: tuple[AdvertisedModel, ...]) -> tuple[AdvertisedModel, ...]: diff --git a/tests/test_litellm/proxy/common_utils/test_advertised_models.py b/tests/test_litellm/proxy/common_utils/test_advertised_models.py index 88fe501d9b8..eda8e98e20d 100644 --- a/tests/test_litellm/proxy/common_utils/test_advertised_models.py +++ b/tests/test_litellm/proxy/common_utils/test_advertised_models.py @@ -98,11 +98,20 @@ def test_configured_entries_are_typed_and_keep_their_order(): class _RouterStub: """Minimal stand-in for the bits of Router this module reads.""" - def __init__(self, names: tuple[str, ...], groups: tuple[str, ...] = ()) -> None: + def __init__( + self, + names: tuple[str, ...], + groups: tuple[str, ...] = (), + team_public: frozenset[str] = frozenset(), + aliases: tuple[str, ...] = (), + ) -> None: self._names: Final = names self._groups: Final = groups + self.team_public_model_names: Final = team_public + self.model_group_alias: Final = {alias: "some-target" for alias in aliases} def get_model_names(self) -> list[str]: + """Mirrors the real method: team-scoped names and aliases are absent here.""" return list(self._names) def get_model_access_groups(self) -> dict[str, list[str]]: @@ -154,3 +163,25 @@ def test_every_row_is_marked_catalog_only(): True, True, ], f"every catalog row must be marked so clients can tell it from a routable model, got {rows}" + + +def test_entry_naming_a_team_scoped_public_name_is_dropped(): + """`get_model_names()` omits team-scoped deployments when given no team id.""" + rows: Final = advertised_model_rows( + _settings({"id": "team-public-gpt", "owned_by": "impostor"}), + [], + _RouterStub((), team_public=frozenset({"team-public-gpt"})), + ) + + assert rows == (), f"a team's public model name must stay reserved, got {rows}" + + +def test_entry_naming_a_model_group_alias_is_dropped(): + """`get_model_names()` does not return alias keys despite its docstring.""" + rows: Final = advertised_model_rows( + _settings({"id": "gpt-alias", "owned_by": "impostor"}), + [], + _RouterStub((), aliases=("gpt-alias",)), + ) + + assert rows == (), f"a routable alias must stay reserved, got {rows}" diff --git a/tests/test_litellm/proxy/test_model_list_advertised_models.py b/tests/test_litellm/proxy/test_model_list_advertised_models.py index f106c587146..5e58b7f591b 100644 --- a/tests/test_litellm/proxy/test_model_list_advertised_models.py +++ b/tests/test_litellm/proxy/test_model_list_advertised_models.py @@ -174,10 +174,10 @@ class _HidingGate(CustomLogger): def __init__(self, hidden: frozenset[str]) -> None: super().__init__() self.hidden = hidden - self.seen: list[tuple[str, ...]] = [] + self.seen: tuple[tuple[str, ...], ...] = () async def async_filter_listed_models(self, user_api_key_dict, model_names): - self.seen.append(tuple(model_names)) + self.seen = (*self.seen, tuple(model_names)) return [name for name in model_names if name not in self.hidden]