fix(proxy): list public team model name in /v1/models (#30588)

* 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.

* fix(proxy): default team model listings to public names

* test(proxy): cover team model listing metadata

* test(proxy): cover empty team listing deployments

* refactor(proxy): simplify team model listing translation

* fix(proxy): resolve public team model name on GET /v1/models/{id}

The listing endpoints advertise team_public_model_name, but the retrieve
endpoint validated and looked up by the raw id, so a public name 404'd.
Resolve the public name back to the internal routing key (scoped to the
caller's accessible models so colliding names never cross teams), look up
by it, and echo the public name back as the response id.

* test(proxy): cover public-name resolution on model retrieve

* refactor(proxy): extract team model-name translation into TeamModelNameTranslator

Move the team-scoped (BYOK) listing/retrieve name translation out of
proxy_server.py into a dedicated common_utils module. Static methods with
general_settings injected so the logic is unit-testable without globals and
proxy_server.py stays thin.

* refactor(proxy): use TeamModelNameTranslator in model_list and model_info

* test(proxy): target TeamModelNameTranslator for model-name translation

* fix(proxy): type create_model_info_response return as dict[str, object]

* fix(proxy): keep internal routing key for team model listing metadata lookup

Add listing_entries returning (public response id, internal lookup id) so
include_metadata=true resolves fallbacks against the routing key the router
indexes by, instead of the translated public name (which never matches).

* fix(proxy): build /v1/models metadata from internal key, show public id

* test(proxy): cover team listing fallback metadata via internal key

* fix(proxy): use builtin dict generics in create_model_info_response (UP006)

---------

Co-authored-by: Tushar More <tusharmore8408@gmail.com>
Co-authored-by: Ishaan Jaffer <ishaanjaffer0324@gmail.com>
(cherry picked from commit 60f4c01b74)
This commit is contained in:
ishaan-berri 2026-06-17 09:17:22 -07:00 committed by Yuneng Jiang
parent 98636c7d6e
commit 5b2477bca1
No known key found for this signature in database
7 changed files with 946 additions and 52 deletions

View file

@ -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)

View file

@ -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,

View file

@ -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(

View file

@ -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]

View file

@ -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,
},
},

View file

@ -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()

View file

@ -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;