diff --git a/litellm/proxy/common_utils/model_listing_utils.py b/litellm/proxy/common_utils/model_listing_utils.py new file mode 100644 index 00000000000..3a70377037d --- /dev/null +++ b/litellm/proxy/common_utils/model_listing_utils.py @@ -0,0 +1,167 @@ +"""Team-scoped (BYOK) model-name translation for the model listing endpoints. + +`/v1/models`, `/models`, and `GET /v1/models/{id}` should surface the public +`team_public_model_name` rather than the internal routing key +`model_name_{team_id}_{uuid}`, consistent with `/v1/model/info`. The internal +key still routes regardless; this is a presentation-layer swap only and does not +touch access-group or auth semantics (see issue #28382). Operators can pin the +legacy internal names with `general_settings.use_team_public_model_name: false`. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, cast + +if TYPE_CHECKING: + from litellm.router import Router + + +class TeamModelNameTranslator: + """Translates internal team routing keys to their public names for the model + listing/retrieve responses. Stateless; the live router and general_settings + are injected per call so the unit tests can drive it without globals. + """ + + @staticmethod + def _internal_public_pair(model: object) -> tuple[str, str] | None: + """`(internal_routing_key, public_name)` for a team-scoped row, else None.""" + if not isinstance(model, dict): + return None + model_dict = cast(dict[str, object], model) # any-ok: checked + model_info_raw: object = model_dict.get("model_info") + if not isinstance(model_info_raw, Mapping): + return None + 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 = model_dict.get("model_name") + if ( + isinstance(team_id, str) + and isinstance(team_public, str) + and isinstance(name, str) + and team_id + and team_public + and name.startswith(f"model_name_{team_id}_") + ): + return name, team_public + return None + + @staticmethod + def _is_enabled(general_settings: Mapping[str, object]) -> bool: + return general_settings.get("use_team_public_model_name", True) is not False + + @staticmethod + def build_internal_to_public_map( + llm_router: "Router | None", + general_settings: Mapping[str, object], + ) -> dict[str, str]: + """Internal team routing key -> public `team_public_model_name`. + + Empty when disabled via the legacy flag, the router is absent, or the + router model list is malformed. + """ + if llm_router is None or not TeamModelNameTranslator._is_enabled( + general_settings + ): + return {} + router_model_list = llm_router.get_model_list() + if not isinstance(router_model_list, list): + return {} + return dict( + pair + for pair in ( + TeamModelNameTranslator._internal_public_pair(model) + for model in router_model_list + ) + if pair is not None + ) + + @staticmethod + def _response_to_lookup_map( + model_names: list[str], + internal_to_public: dict[str, str], + ) -> dict[str, str]: + """Map each public response id to the first internal lookup id seen in + `model_names`, preserving first-occurrence order. First-wins keeps list + and retrieve in agreement on which accessible deployment a shared public + id resolves to: a global iterated before a colliding team alias stays + the listed entry, and sibling team rows collapse to their first + occurrence. + """ + result: dict[str, str] = {} + for name in model_names: + result.setdefault(internal_to_public.get(name, name), name) + return result + + @staticmethod + def listing_entries( + model_names: list[str], + llm_router: "Router | None", + general_settings: Mapping[str, object], + ) -> list[tuple[str, str]]: + """`(response_id, metadata_lookup_id)` for each listed model, de-duplicated + by response_id while preserving order. + + For team-scoped rows `response_id` is the public name shown to the client, + while `metadata_lookup_id` stays the internal routing key so downstream + metadata/fallback lookups (keyed by the routing name) still resolve. The + lookup id is always one of `model_names` (the caller's accessible set), so + a public name shared across teams never resolves to another team's + internal key. Both ids are identical for unmapped names (globals, + access-group keys). + """ + internal_to_public = TeamModelNameTranslator.build_internal_to_public_map( + llm_router, general_settings + ) + if not internal_to_public: + return [(name, name) for name in model_names] + return list( + TeamModelNameTranslator._response_to_lookup_map( + model_names, internal_to_public + ).items() + ) + + @staticmethod + def translate_listing( + model_names: list[str], + llm_router: "Router | None", + general_settings: Mapping[str, object], + ) -> list[str]: + """Public-name view of `model_names` (the `response_id` of each listing + entry). Sibling deployments sharing a public name collapse to one entry + while preserving order; unmapped names pass through. + """ + return [ + entry[0] + for entry in TeamModelNameTranslator.listing_entries( + model_names, llm_router, general_settings + ) + ] + + @staticmethod + def resolve_public_name( + model_id: str, + available_models: list[str], + llm_router: "Router | None", + general_settings: Mapping[str, object], + ) -> str: + """Resolve a public team name back to the internal routing key the router + indexes by, so `GET /v1/models/{id}` accepts the name the listing returns. + + Resolution is restricted to `available_models` (the caller's accessible + set) so colliding public names across teams never resolve across an access + boundary. Uses the same first-occurrence dedup as `listing_entries` so a + public id advertised by `/v1/models` resolves to the same internal + deployment that the listing's metadata was built from. Returns `model_id` + unchanged when it is not an accessible public team name (already-internal + names and globals pass through). + """ + internal_to_public = TeamModelNameTranslator.build_internal_to_public_map( + llm_router, general_settings + ) + if not internal_to_public: + return model_id + return TeamModelNameTranslator._response_to_lookup_map( + available_models, internal_to_public + ).get(model_id, model_id) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 95e8994ff32..3f7ec4d74fa 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 from datetime import datetime, timedelta, timezone from typing import ( TYPE_CHECKING, @@ -302,6 +303,7 @@ from litellm.proxy.common_utils.load_config_utils import ( get_config_file_contents_from_gcs, get_file_contents_from_s3, ) +from litellm.proxy.common_utils.model_listing_utils import TeamModelNameTranslator from litellm.proxy.common_utils.openai_endpoint_utils import ( remove_sensitive_info_from_deployment, ) @@ -8228,6 +8230,8 @@ async def model_list( """ global llm_model_list, general_settings, llm_router, prisma_client, user_api_key_cache, proxy_logging_obj + settings = cast(dict[str, object], general_settings) # any-ok: legacy settings + from litellm.proxy.management_endpoints.common_utils import ( _user_has_admin_privileges, ) @@ -8307,16 +8311,21 @@ async def model_list( if hidden_names: all_models = [m for m in all_models if m not in hidden_names] - # Build response data with all proxy models + # Surface the public team name by default; legacy internal keys via flag. + # The internal routing key drives the metadata/fallback lookup, while the + # public name is what the client sees as the model id. model_data = [] - for model in all_models: + for response_id, lookup_id in TeamModelNameTranslator.listing_entries( + all_models, llm_router, settings + ): model_info = create_model_info_response( - model_id=model, + model_id=lookup_id, provider="openai", include_metadata=include_metadata or False, fallback_type=fallback_type, llm_router=llm_router, ) + model_info["id"] = response_id model_data.append(model_info) return dict( @@ -8344,16 +8353,21 @@ async def model_list( if hidden_names: all_models = [m for m in all_models if m not in hidden_names] - # Build response data + # Surface the public team name by default; legacy internal keys via flag. + # The internal routing key drives the metadata/fallback lookup, while the + # public name is what the client sees as the model id. model_data = [] - for model in all_models: + for response_id, lookup_id in TeamModelNameTranslator.listing_entries( + all_models, llm_router, settings + ): model_info = create_model_info_response( - model_id=model, + model_id=lookup_id, provider="openai", include_metadata=include_metadata or False, fallback_type=fallback_type, llm_router=llm_router, ) + model_info["id"] = response_id model_data.append(model_info) return dict( @@ -8375,6 +8389,8 @@ async def model_list( async def model_info( model_id: str, user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), + team_id: Optional[str] = None, + healthy_only: Optional[bool] = False, ): """ Retrieve information about a specific model accessible to your API key. @@ -8384,16 +8400,21 @@ async def model_info( Follows OpenAI API specification for individual model retrieval. https://platform.openai.com/docs/api-reference/models/retrieve + + Query parameters mirror `/v1/models` so the same caller context (team + scoping, health filtering, paused deployments) drives both endpoints; the + listing's public id must resolve to the same internal deployment here. """ global llm_model_list, general_settings, llm_router, prisma_client, user_api_key_cache, proxy_logging_obj + settings = cast(dict[str, object], general_settings) # any-ok: legacy settings + from litellm.proxy.utils import ( create_model_info_response, get_available_models_for_user, validate_model_access, ) - # Get available models for the user all_models = await get_available_models_for_user( user_api_key_dict=user_api_key_dict, llm_router=llm_router, @@ -8401,21 +8422,43 @@ async def model_info( user_model=user_model, prisma_client=prisma_client, proxy_logging_obj=proxy_logging_obj, - team_id=None, + team_id=team_id, include_model_access_groups=False, only_model_access_groups=False, return_wildcard_routes=False, user_api_key_cache=user_api_key_cache, ) + # Mirror /v1/models' visibility filter so first-occurrence resolution + # cannot land on a deployment the listing had hidden. + blocked_names = ( + llm_router.get_fully_blocked_model_names() if llm_router is not None else set() + ) + unhealthy_names: set[str] = set() + if healthy_only and llm_router is not None: + unhealthy_names = await llm_router.async_get_fully_unhealthy_model_names() + hidden_names = blocked_names | unhealthy_names + if hidden_names: + all_models = [m for m in all_models if m not in hidden_names] + + internal_to_public = TeamModelNameTranslator.build_internal_to_public_map( + llm_router, settings + ) + resolved_model_id = TeamModelNameTranslator.resolve_public_name( + model_id=model_id, + available_models=all_models, + llm_router=llm_router, + general_settings=settings, + ) + # Validate that the requested model is accessible - validate_model_access(model_id=model_id, available_models=all_models) + validate_model_access(model_id=resolved_model_id, available_models=all_models) # Get provider information from the router deployment if llm_router is None: raise HTTPException(status_code=500, detail="Router not initialized") - deployment = llm_router.get_deployment_by_model_group_name(model_id) + deployment = llm_router.get_deployment_by_model_group_name(resolved_model_id) if deployment is None: raise HTTPException( status_code=404, @@ -8425,9 +8468,9 @@ async def model_info( # Use the actual litellm model from the deployment to get provider info _, provider, _, _ = litellm.get_llm_provider(model=deployment.litellm_params.model) - # Return the model information in the same format as the list endpoint + response_id = internal_to_public.get(resolved_model_id, model_id) return create_model_info_response( - model_id=model_id, + model_id=response_id, provider=provider, include_metadata=False, fallback_type=None, diff --git a/litellm/proxy/utils.py b/litellm/proxy/utils.py index c15c37f6ad8..d86cd38a51c 100644 --- a/litellm/proxy/utils.py +++ b/litellm/proxy/utils.py @@ -45,6 +45,7 @@ from litellm.proxy._types import ( ) from litellm.proxy.spend_tracking.spend_log_error_logger import spend_log_error from litellm.types.guardrails import GuardrailEventHooks +from litellm.types.proxy.model_listing import ModelInfoResponse from litellm.types.utils import CallTypes, CallTypesLiteral try: @@ -6231,56 +6232,39 @@ def create_model_info_response( include_metadata: bool = False, fallback_type: Optional[str] = None, llm_router: Optional["Router"] = None, -) -> dict: +) -> ModelInfoResponse: """ - Create a standardized model info response. + Create a standardized OpenAI-compatible model object. - Args: - model_id: The model ID - provider: The model provider - include_metadata: Whether to include metadata - fallback_type: Type of fallbacks to include - llm_router: LiteLLM router instance - - Returns: - Dictionary containing model information + When include_metadata is true, attaches the model's configured fallbacks + (resolved via the router under fallback_type, defaulting to "general"). + Raises HTTPException(400) for an unknown fallback_type. """ from litellm.proxy.auth.model_checks import get_all_fallbacks - model_info = { + base: ModelInfoResponse = { "id": model_id, "object": "model", "created": DEFAULT_MODEL_CREATED_AT_TIME, "owned_by": provider, } + if not include_metadata: + return base - # Add metadata if requested - if include_metadata: - metadata = {} - - # Default fallback_type to "general" if include_metadata is true - effective_fallback_type = ( - fallback_type if fallback_type is not None else "general" + effective_fallback_type = fallback_type if fallback_type is not None else "general" + valid_fallback_types = ("general", "context_window", "content_policy") + if effective_fallback_type not in valid_fallback_types: + raise HTTPException( + status_code=400, + detail=f"Invalid fallback_type. Must be one of: {list(valid_fallback_types)}", ) - # Validate fallback_type - valid_fallback_types = ["general", "context_window", "content_policy"] - if effective_fallback_type not in valid_fallback_types: - raise HTTPException( - status_code=400, - detail=f"Invalid fallback_type. Must be one of: {valid_fallback_types}", - ) - - fallbacks = get_all_fallbacks( - model=model_id, - llm_router=llm_router, - fallback_type=effective_fallback_type, - ) - metadata["fallbacks"] = fallbacks - - model_info["metadata"] = metadata - - return model_info + fallbacks = get_all_fallbacks( + model=model_id, + llm_router=llm_router, + fallback_type=effective_fallback_type, + ) + return {**base, "metadata": {"fallbacks": fallbacks}} def validate_model_access( diff --git a/litellm/types/proxy/model_listing.py b/litellm/types/proxy/model_listing.py new file mode 100644 index 00000000000..c3330da0d66 --- /dev/null +++ b/litellm/types/proxy/model_listing.py @@ -0,0 +1,21 @@ +"""Response types for the model listing/retrieve endpoints (/v1/models, /models).""" + +from typing import Literal + +from typing_extensions import NotRequired, TypedDict + + +class ModelInfoMetadata(TypedDict): + fallbacks: list[str] + + +class ModelInfoResponse(TypedDict): + """OpenAI-compatible model object. `metadata` is present only when the + endpoint is called with include_metadata=true. + """ + + id: str + object: Literal["model"] + created: int + owned_by: str + metadata: NotRequired[ModelInfoMetadata] diff --git a/tests/llm_translation/base_llm_unit_tests.py b/tests/llm_translation/base_llm_unit_tests.py index fef1d23d867..a184798b503 100644 --- a/tests/llm_translation/base_llm_unit_tests.py +++ b/tests/llm_translation/base_llm_unit_tests.py @@ -906,7 +906,10 @@ class BaseLLMChatTest(ABC): { "type": "image_url", "image_url": { - "url": "https://www.gstatic.com/webp/gallery/1.webp", + # sha-pinned in-repo logo via jsdelivr; gstatic's + # robots.txt blocks server-side fetchers (e.g. + # Anthropic), which 400s the request. + "url": "https://cdn.jsdelivr.net/gh/BerriAI/litellm@d769e81c90d453240c61fc572cdb27fae06a89d0/ui/litellm-dashboard/public/assets/logos/litellm_logo.jpg", "detail": detail, }, }, 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..0f87fcda588 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 @@ -15,6 +15,7 @@ import pytest import litellm.proxy.proxy_server as ps from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth +from litellm.proxy.common_utils.model_listing_utils import TeamModelNameTranslator from litellm.proxy.proxy_server import ( _get_proxy_model_info, _translate_model_name_for_response, @@ -593,3 +594,664 @@ 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) + # Default behavior: listing surfaces public names. + monkeypatch.setattr(ps, "general_settings", {}) + + # 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_public_name_flag_disabled( + monkeypatch, +): + """Compatibility override: /v1/models can still list the internal routing + name for consumers that scripted against those ids. Translation is enabled + by default and disabled 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", {"use_team_public_model_name": False}) + + 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 + + +@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": []}, + } + ] + + +@pytest.mark.asyncio +async def test_v1_models_metadata_fallbacks_use_internal_routing_key(monkeypatch): + """Regression: with include_metadata=true, fallbacks configured for a team + model under its internal routing key must still surface. The metadata lookup + has to run against the internal name, not the translated public name (which + the router's fallback config never keys on) -- otherwise fallbacks silently + drop to [].""" + 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] + # Fallbacks are keyed on the internal routing name, as the router stores them. + router.fallbacks = [{"model_name_teamX_uuid9": ["gpt-4o-backup"]}] + + 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": ["gpt-4o-backup"]}, + } + ] + + +@pytest.mark.asyncio +async def test_v1_models_metadata_does_not_leak_other_team_fallbacks(monkeypatch): + """Regression: two teams can publish the same team_public_model_name. With + include_metadata=true a caller scoped to teamX must see teamX's fallbacks for + the shared public name, never teamY's. The metadata lookup has to stay within + the caller's accessible models; resolving the public name through a router-wide + reverse map could point it at another team's internal routing key.""" + team_x = { + "model_name": "model_name_teamX_uuid9", + "litellm_params": {"model": "azure/gpt-4.1"}, + "model_info": { + "id": "idX", + "team_id": "teamX", + "team_public_model_name": "tushar-gpt-4.1", + "access_groups": ["grp-a"], + }, + } + team_y = { + "model_name": "model_name_teamY_uuidZ", + "litellm_params": {"model": "azure/gpt-4.1"}, + "model_info": { + "id": "idY", + "team_id": "teamY", + "team_public_model_name": "tushar-gpt-4.1", # same public name, other team + }, + } + 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_x, team_y] + router.get_model_list.return_value = [team_x, team_y] + router.fallbacks = [ + {"model_name_teamX_uuid9": ["teamX-backup"]}, + {"model_name_teamY_uuidZ": ["teamY-backup"]}, + ] + + 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": ["teamX-backup"]}, + } + ] + + +def test_translate_team_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.""" + router = MagicMock() + router.get_model_list.return_value = [ + { + "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 = TeamModelNameTranslator.translate_listing( + ["model_name_teamX_uuidA", "model_name_teamX_uuidB", "gpt-4o"], + router, + {}, + ) + assert out == ["tushar-gpt-4.1", "gpt-4o"] + + +def test_listing_entries_keep_internal_lookup_id_for_team_rows(): + """`listing_entries` returns (public response id, internal lookup id) so the + response shows the public name while metadata lookups keep the routing key. + Sibling deployments collapse to one entry; globals map to themselves.""" + router = MagicMock() + router.get_model_list.return_value = [ + { + "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}}, + ] + + entries = TeamModelNameTranslator.listing_entries( + ["model_name_teamX_uuidA", "model_name_teamX_uuidB", "gpt-4o"], + router, + {}, + ) + # public id for the client; an internal routing key for the metadata lookup + assert entries[0][0] == "tushar-gpt-4.1" + assert entries[0][1].startswith("model_name_teamX_uuid") + assert entries[1] == ("gpt-4o", "gpt-4o") + assert len(entries) == 2 + + +def test_listing_entries_lookup_id_never_crosses_team_boundary(): + """Regression: when two teams share a team_public_model_name, the lookup id for + the shared public name must stay within the caller's accessible model_names and + never resolve to the other team's internal routing key (which would leak that + team's fallback metadata under include_metadata=true).""" + router = MagicMock() + router.get_model_list.return_value = [ + { + "model_name": "model_name_teamX_uuidA", + "model_info": { + "team_id": "teamX", + "team_public_model_name": "shared-name", + }, + }, + { + "model_name": "model_name_teamY_uuidB", # different team, same public name + "model_info": { + "team_id": "teamY", + "team_public_model_name": "shared-name", + }, + }, + ] + + # caller can only access teamX's internal key + entries = TeamModelNameTranslator.listing_entries( + ["model_name_teamX_uuidA"], router, {} + ) + + assert entries == [("shared-name", "model_name_teamX_uuidA")] + + +def test_listing_entries_global_wins_when_team_alias_collides_with_global(): + """Regression: when an accessible global model shares its name with a team + deployment's `team_public_model_name`, the listing must keep the global + entry rather than overwriting its lookup id with the colliding team's + internal routing key (which would surface the team's metadata under the + global id).""" + router = MagicMock() + router.get_model_list.return_value = [ + { + "model_name": "model_name_teamX_uuidA", + "model_info": { + "team_id": "teamX", + "team_public_model_name": "gpt-4o", + }, + }, + {"model_name": "gpt-4o", "model_info": {"db_model": False}}, + ] + + entries = TeamModelNameTranslator.listing_entries( + ["gpt-4o", "model_name_teamX_uuidA"], router, {} + ) + + assert entries == [("gpt-4o", "gpt-4o")] + + +def test_listing_and_resolve_agree_on_sibling_internal_key(): + """Regression: when two team deployments share a public name, listing and + retrieve must pick the same internal routing key, otherwise `/v1/models/{id}` + describes a different deployment than what the listing's metadata was built + from.""" + router = MagicMock() + router.get_model_list.return_value = [ + { + "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", + "model_info": { + "team_id": "teamX", + "team_public_model_name": "tushar-gpt-4.1", + }, + }, + ] + available = ["model_name_teamX_uuidA", "model_name_teamX_uuidB"] + + [(_, listing_lookup)] = TeamModelNameTranslator.listing_entries( + available, router, {} + ) + resolve_lookup = TeamModelNameTranslator.resolve_public_name( + model_id="tushar-gpt-4.1", + available_models=available, + llm_router=router, + general_settings={}, + ) + + assert listing_lookup == resolve_lookup + + +def test_listing_entries_skips_empty_team_public_model_name(): + """Regression: a misconfigured row with `team_public_model_name: ""` must not + produce a listing entry with an empty `id`; the internal routing key should + pass through unchanged, matching `/v1/model/info`'s falsy-check behavior.""" + router = MagicMock() + router.get_model_list.return_value = [ + { + "model_name": "model_name_teamX_uuidA", + "model_info": { + "team_id": "teamX", + "team_public_model_name": "", + }, + }, + ] + + entries = TeamModelNameTranslator.listing_entries( + ["model_name_teamX_uuidA"], router, {} + ) + + assert entries == [("model_name_teamX_uuidA", "model_name_teamX_uuidA")] + + +def test_listing_entries_passthrough_when_disabled(): + """Legacy flag / no router -> response id equals lookup id (no translation).""" + assert TeamModelNameTranslator.listing_entries(["a", "b"], None, {}) == [ + ("a", "a"), + ("b", "b"), + ] + + +def test_translate_team_model_names_for_listing_leaves_unmapped_names(): + """Names with no team mapping (globals, access-group keys) pass through.""" + router = MagicMock() + router.get_model_list.return_value = [ + {"model_name": "gpt-4o", "model_info": {"db_model": False}} + ] + + assert TeamModelNameTranslator.translate_listing( + ["gpt-4o", "beta-group"], router, {} + ) == ["gpt-4o", "beta-group"] + + +def test_translate_team_model_names_for_listing_none_router(): + """No router -> return the input list unchanged.""" + assert TeamModelNameTranslator.translate_listing(["a", "b"], None, {}) == ["a", "b"] + + +def test_translate_team_model_names_for_listing_respects_legacy_flag(): + """Operators can keep returning the legacy internal routing key.""" + router = MagicMock() + router.get_model_list.return_value = [ + { + "model_name": "model_name_teamX_uuidA", + "model_info": { + "team_id": "teamX", + "team_public_model_name": "tushar-gpt-4.1", + }, + } + ] + + assert TeamModelNameTranslator.translate_listing( + ["model_name_teamX_uuidA"], router, {"use_team_public_model_name": False} + ) == ["model_name_teamX_uuidA"] + + +def _public_named_router(*team_rows: dict) -> MagicMock: + router = MagicMock() + router.get_model_list.return_value = list(team_rows) + return router + + +def test_resolve_public_name_to_internal_routing_key(): + """A public team name resolves back to the internal routing key the router + indexes by, so `GET /v1/models/{public_name}` can find the deployment.""" + router = _public_named_router(_team_row()) + + assert ( + TeamModelNameTranslator.resolve_public_name( + model_id="team-claude-sonnet", + available_models=["model_name_team-abc-123_4a6b8"], + llm_router=router, + general_settings={}, + ) + == "model_name_team-abc-123_4a6b8" + ) + + +def test_resolve_public_name_is_access_scoped_across_teams(): + """Two teams can publish the SAME public name. A caller's query must resolve + to the internal key they can actually access, never another team's.""" + # both rows share public name "team-claude-sonnet" + router = _public_named_router(_team_row(), _other_team_row()) + + # caller only has access to their own team's internal key + resolved = TeamModelNameTranslator.resolve_public_name( + model_id="team-claude-sonnet", + available_models=["model_name_team-abc-123_4a6b8"], + llm_router=router, + general_settings={}, + ) + assert resolved == "model_name_team-abc-123_4a6b8" + assert resolved != "model_name_team-other_9f2c1" + + +def test_resolve_public_name_unmapped_passes_through(): + """A public name with no accessible internal mapping is returned unchanged so + the caller hits the normal 404/access path; internal names pass through too.""" + router = _public_named_router(_team_row()) + + # not accessible -> unchanged (downstream validate_model_access will 404) + assert ( + TeamModelNameTranslator.resolve_public_name( + model_id="team-claude-sonnet", + available_models=[], + llm_router=router, + general_settings={}, + ) + == "team-claude-sonnet" + ) + # already an internal routing key -> unchanged + assert ( + TeamModelNameTranslator.resolve_public_name( + model_id="model_name_team-abc-123_4a6b8", + available_models=["model_name_team-abc-123_4a6b8"], + llm_router=router, + general_settings={}, + ) + == "model_name_team-abc-123_4a6b8" + ) + + +def test_resolve_public_name_respects_legacy_flag(): + """With the legacy flag set, no public-name resolution happens.""" + router = _public_named_router(_team_row()) + + assert ( + TeamModelNameTranslator.resolve_public_name( + model_id="team-claude-sonnet", + available_models=["model_name_team-abc-123_4a6b8"], + llm_router=router, + general_settings={"use_team_public_model_name": False}, + ) + == "team-claude-sonnet" + ) + + +@pytest.mark.asyncio +async def test_retrieve_model_by_public_name_returns_200(monkeypatch): + """Regression: `GET /v1/models/{public_name}` must NOT 404. The listing + advertises the public team name, so retrieve must accept the same name, + resolve it to the internal routing key for lookup, and echo the public name + back as the model id.""" + import litellm + import litellm.proxy.utils as proxy_utils + + team_row = _team_row() + router = _public_named_router(team_row) + deployment = MagicMock() + deployment.litellm_params.model = "azure/gpt-5.2-low-rpm-testing" + router.get_deployment_by_model_group_name.return_value = deployment + + monkeypatch.setattr(ps, "llm_router", router) + monkeypatch.setattr(ps, "general_settings", {}) + monkeypatch.setattr( + proxy_utils, + "get_available_models_for_user", + AsyncMock(return_value=["model_name_team-abc-123_4a6b8"]), + ) + monkeypatch.setattr( + litellm, "get_llm_provider", lambda model: (model, "openai", None, None) + ) + + key = UserAPIKeyAuth(user_id="u", api_key="sk-test", team_models=[]) + resp = await ps.model_info(model_id="team-claude-sonnet", user_api_key_dict=key) + + assert resp["id"] == "team-claude-sonnet" + # lookup happened by the internal routing key, not the public name + router.get_deployment_by_model_group_name.assert_called_once_with( + "model_name_team-abc-123_4a6b8" + ) + + +@pytest.mark.asyncio +async def test_retrieve_model_by_internal_name_returns_public_id(monkeypatch): + """Regression: retrieving by the internal routing key must echo the SAME + public id `/v1/models` advertises for that deployment, not the path. Otherwise + a client iterating the listing's id and then retrieving each one would observe + a different id depending on which alias they queried by.""" + import litellm + import litellm.proxy.utils as proxy_utils + + router = _public_named_router(_team_row()) + deployment = MagicMock() + deployment.litellm_params.model = "azure/gpt-5.2-low-rpm-testing" + router.get_deployment_by_model_group_name.return_value = deployment + + monkeypatch.setattr(ps, "llm_router", router) + monkeypatch.setattr(ps, "general_settings", {}) + monkeypatch.setattr( + proxy_utils, + "get_available_models_for_user", + AsyncMock(return_value=["model_name_team-abc-123_4a6b8"]), + ) + monkeypatch.setattr( + litellm, "get_llm_provider", lambda model: (model, "openai", None, None) + ) + + key = UserAPIKeyAuth(user_id="u", api_key="sk-test", team_models=[]) + resp = await ps.model_info( + model_id="model_name_team-abc-123_4a6b8", user_api_key_dict=key + ) + + assert resp["id"] == "team-claude-sonnet" + + +@pytest.mark.asyncio +async def test_retrieve_model_by_internal_name_keeps_internal_id_when_flag_disabled( + monkeypatch, +): + """With `use_team_public_model_name=false`, retrieve must keep the internal + routing key as the response id, mirroring `/v1/models`' legacy output.""" + import litellm + import litellm.proxy.utils as proxy_utils + + router = _public_named_router(_team_row()) + deployment = MagicMock() + deployment.litellm_params.model = "azure/gpt-5.2-low-rpm-testing" + router.get_deployment_by_model_group_name.return_value = deployment + + monkeypatch.setattr(ps, "llm_router", router) + monkeypatch.setattr(ps, "general_settings", {"use_team_public_model_name": False}) + monkeypatch.setattr( + proxy_utils, + "get_available_models_for_user", + AsyncMock(return_value=["model_name_team-abc-123_4a6b8"]), + ) + monkeypatch.setattr( + litellm, "get_llm_provider", lambda model: (model, "openai", None, None) + ) + + key = UserAPIKeyAuth(user_id="u", api_key="sk-test", team_models=[]) + resp = await ps.model_info( + model_id="model_name_team-abc-123_4a6b8", user_api_key_dict=key + ) + + assert resp["id"] == "model_name_team-abc-123_4a6b8" + + +@pytest.mark.asyncio +async def test_retrieve_model_by_inaccessible_public_name_404s(monkeypatch): + """A caller without access to a team model still gets 404 when retrieving by + its public name; resolution never crosses the access boundary.""" + import litellm + import litellm.proxy.utils as proxy_utils + + router = _public_named_router(_team_row()) + deployment = MagicMock() + deployment.litellm_params.model = "azure/gpt-5.2-low-rpm-testing" + router.get_deployment_by_model_group_name.return_value = deployment + + monkeypatch.setattr(ps, "llm_router", router) + monkeypatch.setattr(ps, "general_settings", {}) + monkeypatch.setattr( + proxy_utils, + "get_available_models_for_user", + AsyncMock(return_value=[]), # caller has no access + ) + monkeypatch.setattr( + litellm, "get_llm_provider", lambda model: (model, "openai", None, None) + ) + + key = UserAPIKeyAuth(user_id="u", api_key="sk-test", team_models=[]) + with pytest.raises(ps.HTTPException) as exc_info: + await ps.model_info(model_id="team-claude-sonnet", user_api_key_dict=key) + + assert exc_info.value.status_code == 404 + router.get_deployment_by_model_group_name.assert_not_called() diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index c5560ca7f20..9699da9a6ef 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -6279,6 +6279,10 @@ export interface paths { * * Follows OpenAI API specification for individual model retrieval. * https://platform.openai.com/docs/api-reference/models/retrieve + * + * Query parameters mirror `/v1/models` so the same caller context (team + * scoping, health filtering, paused deployments) drives both endpoints; the + * listing's public id must resolve to the same internal deployment here. */ get: operations["model_info_models__model_id__get"]; put?: never; @@ -14472,6 +14476,10 @@ export interface paths { * * Follows OpenAI API specification for individual model retrieval. * https://platform.openai.com/docs/api-reference/models/retrieve + * + * Query parameters mirror `/v1/models` so the same caller context (team + * scoping, health filtering, paused deployments) drives both endpoints; the + * listing's public id must resolve to the same internal deployment here. */ get: operations["model_info_v1_models__model_id__get"]; put?: never; @@ -38145,7 +38153,10 @@ export interface operations { }; model_info_models__model_id__get: { parameters: { - query?: never; + query?: { + team_id?: string | null; + healthy_only?: boolean | null; + }; header?: never; path: { model_id: string; @@ -48211,7 +48222,10 @@ export interface operations { }; model_info_v1_models__model_id__get: { parameters: { - query?: never; + query?: { + team_id?: string | null; + healthy_only?: boolean | null; + }; header?: never; path: { model_id: string;