fix(proxy): list key and team aliases on /model_group/info and apply listing callbacks to them (#51754)

* fix(proxy): list key and team model aliases on /model_group/info

* fix(proxy): keep listing-callback and pause filters on model alias rows

A name a legacy or V2 listing callback hides stays hidden on /v1/models,
/v1/models/{model_id} and /model_group/info even when it is also an alias.
V2 Hook.list_models subscribers now limit which target an alias row
describes, /model_group/info describes no alias by a paused model, and
?model_group=<name> offers listing callbacks only the names that row reads.

* test(proxy): annotate the new alias test locals as Final

* fix(proxy): keep a caller alias listed when a router alias of the same name points at a hidden model

A key or team alias always wins over a router model_group_alias of the same name, so alias rows are now dropped only for names a listing callback hid itself, not for router aliases whose target it hid

* fix(proxy): describe an alias by an unlisted target only when listing callbacks keep it

Listing callbacks are now also offered the unlisted model each listed key or team alias
sends requests to, and the model a router alias of that name resolves to. The alias row
is described by that model only when the callback keeps every one of those names, and a
name the callback hides there hides no listed name. Before, any installed listing
callback, V2 included, left such an alias with no limits and a 404 lookup

* fix(proxy): describe an alias to a team model's public name when the key lists its internal name

/model_group/info lists team models by their internal routing name, so an alias to the
public name found no listed target and got no row while /v1/models listed it. Alias
resolution now reads the listed names by their public names. The listing callback
docstring also names litellm.model_alias_map among the alias sources.

---------

Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
This commit is contained in:
tavren27 2026-11-13 22:10:32 -08:00 • committed by GitHub
parent 8973e2dc4f
commit d76cdcbb8a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 487 additions and 50 deletions

View file

@ -440,7 +440,12 @@ class CustomLogger: # https://docs.litellm.ai/docs/observability/custom_callbac
"""Runs on the model listing routes (`/v1/models`, `/v1/models/{id}`, `/model/info`,
`/model_group/info`) with the public model names the route would otherwise return, so a
lookup of one model may offer just that name: decide per name, never by position in the
sequence. Return the names to keep as a sequence of strings; a name left out disappears
sequence. The names also include each unlisted model that a key alias, a team alias, or
`litellm.model_alias_map` sends a listed name to without checking the caller's access to that
model, and the model a `model_group_alias` of that name resolves to, and the listed name's row
carries that model's info only while the callback keeps all of them. Leaving one out hides
no listed name. Alias names a key or team maps without listing them are not offered. Return
the names to keep as a sequence of strings; a name left out disappears
from every listing, any alias of it offered in the same call goes with it, and
`/v1/models/{id}` answers 404 for it, exactly as for a model that does not exist. Names
outside `model_names` are ignored, so a callback can only narrow the listing, never widen

View file

@ -352,6 +352,18 @@ def alias_listing_entries(
return (*retargeted, *added)
def unlisted_alias_targets(entries: Sequence[tuple[str, str]], aliases: CallerAliases) -> tuple[str, ...]:
layers: Final = _alias_layers(aliases)
listed: Final = frozenset(chain.from_iterable(entries))
return tuple(
dict.fromkeys(
target
for target, checks_target in (_dispatch(response_id, layers) for response_id, _ in entries)
if not checks_target and target not in listed
)
)
def label_entries(listed: Sequence[tuple[str, str]], entries: Sequence[tuple[str, str]]) -> tuple[tuple[str, str], ...]:
"""`entries` with every `listed` name read under its own lookup id again, so an alias
`alias_listing_entries` describes by its target keeps its own display name, the way a
@ -448,7 +460,7 @@ class TeamModelNameTranslator:
@staticmethod
def _response_to_lookup_map(
model_names: Sequence[str],
internal_to_public: dict[str, str],
internal_to_public: Mapping[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
@ -484,6 +496,12 @@ class TeamModelNameTranslator:
return [(name, name) for name in model_names]
return list(TeamModelNameTranslator._response_to_lookup_map(model_names, internal_to_public).items())
@staticmethod
def public_name_entries(
model_names: Sequence[str], internal_to_public: Mapping[str, str]
) -> tuple[tuple[str, str], ...]:
return tuple(TeamModelNameTranslator._response_to_lookup_map(model_names, internal_to_public).items())
@staticmethod
def translate_listing(
model_names: list[str],

View file

@ -582,7 +582,9 @@ from litellm.proxy.common_utils.model_listing_utils import (
caller_alias_maps,
claude_code_view_ids,
configured_display_names,
dispatched_model,
label_entries,
unlisted_alias_targets,
)
from litellm.proxy.common_utils.openai_endpoint_utils import (
remove_sensitive_info_from_deployment,
@ -12768,7 +12770,10 @@ class ProxyStartupEvent:
async def _names_hidden_by_listing_callbacks(
user_api_key_dict: UserAPIKeyAuth, model_names: Sequence[str]
) -> frozenset[str]:
hidden: Final = await proxy_logging_obj.hidden_by_listing_callbacks(user_api_key_dict, model_names)
return _with_router_aliases_to(await proxy_logging_obj.hidden_by_listing_callbacks(user_api_key_dict, model_names))
def _with_router_aliases_to(hidden: frozenset[str]) -> frozenset[str]:
if not hidden or llm_router is None:
return hidden
aliases: Final = llm_router.registry().aliases()
@ -12781,11 +12786,47 @@ async def _names_hidden_by_listing_callbacks(
)
@dataclass(frozen=True, slots=True)
class _ListingCallbackVerdict:
hid: frozenset[str]
hidden: frozenset[str]
kept_alias_targets: frozenset[str]
async def _listing_callback_verdict(
user_api_key_dict: UserAPIKeyAuth,
listed_names: Sequence[str],
alias_targets: Sequence[str],
internal_to_public: Mapping[str, str],
) -> _ListingCallbackVerdict:
router_aliases: Final = llm_router.registry().aliases() if llm_router is not None else {}
target_reads: Final = MappingProxyType(
{
target: frozenset(
internal_to_public.get(name, name)
for name in (target, resolve_model_group_alias(router_aliases, target))
if name is not None
)
for target in alias_targets
}
)
offered_hid: Final = await proxy_logging_obj.hidden_by_listing_callbacks(
user_api_key_dict, tuple(dict.fromkeys((*listed_names, *chain.from_iterable(target_reads.values()))))
)
hid: Final = offered_hid.intersection(listed_names)
return _ListingCallbackVerdict(
hid,
_with_router_aliases_to(hid),
frozenset(target for target, names in target_reads.items() if offered_hid.isdisjoint(names)),
)
def _describable_model_groups(
listed_entries: Sequence[tuple[str, str]],
hidden_names: frozenset[str] | set[str],
user_api_key_dict: UserAPIKeyAuth,
team_id: str | None,
kept_alias_targets: frozenset[str],
) -> DeployedModelGroups:
listing_team_id: Final = team_id or user_api_key_dict.team_id
return DeployedModelGroups(
@ -12793,8 +12834,8 @@ def _describable_model_groups(
hidden_names | undiscoverable_model_groups(llm_router, user_api_key_dict, listing_team_id),
listing_team_id,
(
frozenset(lookup_id for _, lookup_id in listed_entries)
if ProxyLogging.callback_capabilities().listed_models_filters
frozenset((*(lookup_id for _, lookup_id in listed_entries), *kept_alias_targets))
if ProxyLogging.filters_listed_models()
else None
),
)
@ -12896,16 +12937,26 @@ async def _key_model_listing(
excluded: Final = hidden_names | undiscoverable_model_names(
names, llm_router, user_api_key_dict, team_id or user_api_key_dict.team_id
)
listed_entries: Final = await _entries_kept_by_listing_callbacks(
TeamModelNameTranslator.listing_entries(
[name for name in names if name not in excluded], llm_router, general_settings_view()
),
user_api_key_dict,
translated_entries: Final = TeamModelNameTranslator.listing_entries(
[name for name in names if name not in excluded], llm_router, general_settings_view()
)
entries: Final = alias_listing_entries(
listed_entries,
caller_aliases,
_describable_model_groups(listed_entries, hidden_names, user_api_key_dict, team_id),
verdict: Final = await _listing_callback_verdict(
user_api_key_dict,
tuple(response_id for response_id, _ in translated_entries),
unlisted_alias_targets(translated_entries, caller_aliases),
TeamModelNameTranslator.build_internal_to_public_map(llm_router, general_settings_view()),
)
listed_entries: Final = tuple(entry for entry in translated_entries if entry[0] not in verdict.hidden)
entries: Final = tuple(
entry
for entry in alias_listing_entries(
listed_entries,
caller_aliases,
_describable_model_groups(
listed_entries, hidden_names, user_api_key_dict, team_id, verdict.kept_alias_targets
),
)
if entry[0] not in verdict.hid
)
return _KeyModelListing(
rows=tuple(
@ -13248,18 +13299,18 @@ async def model_info(
hidden_names: Final = blocked_names | unhealthy_names
internal_to_public: Final = TeamModelNameTranslator.build_internal_to_public_map(llm_router, settings)
caller_aliases: Final = await request_caller_aliases(user_api_key_dict, team_id)
callback_hidden_names: Final = await _names_hidden_by_listing_callbacks(
user_api_key_dict,
tuple(
response_id
for response_id, _ in TeamModelNameTranslator.listing_entries(
tuple(m for m in all_models if m not in hidden_names), llm_router, settings
)
),
offered_entries: Final = TeamModelNameTranslator.listing_entries(
tuple(m for m in all_models if m not in hidden_names), llm_router, settings
)
if hidden_names or callback_hidden_names:
verdict: Final = await _listing_callback_verdict(
user_api_key_dict,
tuple(response_id for response_id, _ in offered_entries),
unlisted_alias_targets(offered_entries, caller_aliases),
internal_to_public,
)
if hidden_names or verdict.hidden:
all_models = [
m for m in all_models if m not in hidden_names and internal_to_public.get(m, m) not in callback_hidden_names
m for m in all_models if m not in hidden_names and internal_to_public.get(m, m) not in verdict.hidden
]
undiscoverable_names: Final = undiscoverable_model_names(
all_models, llm_router, user_api_key_dict, team_id or user_api_key_dict.team_id
@ -13268,11 +13319,17 @@ async def model_info(
listed_entries: Final = TeamModelNameTranslator.listing_entries(
tuple(m for m in all_models if m not in undiscoverable_names), llm_router, settings
)
aliased_model_id: Final = alias_target(
model_id,
caller_aliases,
listed_entries,
_describable_model_groups(listed_entries, hidden_names, user_api_key_dict, team_id),
aliased_model_id: Final = (
None
if model_id in verdict.hid
else alias_target(
model_id,
caller_aliases,
listed_entries,
_describable_model_groups(
listed_entries, hidden_names, user_api_key_dict, team_id, verdict.kept_alias_targets
),
)
)
resolved_model_id: Final = TeamModelNameTranslator.resolve_public_name(
model_id=aliased_model_id or model_id,
@ -19314,39 +19371,123 @@ async def model_group_info(
undiscoverable_group_names: Final = undiscoverable_model_names(
all_models_str, llm_router, user_api_key_dict, user_api_key_dict.team_id
)
listed_group_names: Final = tuple(name for name in all_models_str if name not in undiscoverable_group_names)
caller_aliases: Final = await request_caller_aliases(user_api_key_dict)
requested_names: Final = (
None
if model_group is None
else _names_a_model_group_row_reads(model_group, caller_aliases, llm_router.registry().aliases())
)
internal_to_public: Final = TeamModelNameTranslator.build_internal_to_public_map(llm_router, general_settings)
listed_group_names: Final = tuple(
dict.fromkeys(
name
for name in all_models_str
if name not in undiscoverable_group_names
and (requested_names is None or not requested_names.isdisjoint((name, internal_to_public.get(name, name))))
)
)
# Append A2A agents to model groups
from litellm.proxy.agent_endpoints.model_list_helpers import (
append_agents_to_model_group,
)
model_groups: Final = await append_agents_to_model_group(
model_groups=get_model_group_info(
llm_router=llm_router,
all_models_str=listed_group_names,
model_group=model_group,
team_id=user_api_key_dict.team_id,
agent_groups: Final = await append_agents_to_model_group(model_groups=[], user_api_key_dict=user_api_key_dict)
verdict: Final = await _listing_callback_verdict(
user_api_key_dict,
tuple(
dict.fromkeys(
(
*(internal_to_public.get(name, name) for name in listed_group_names),
*(group.model_group for group in agent_groups),
)
)
),
user_api_key_dict=user_api_key_dict,
unlisted_alias_targets(
TeamModelNameTranslator.public_name_entries(listed_group_names, internal_to_public), caller_aliases
),
internal_to_public,
)
internal_to_public: Final = TeamModelNameTranslator.build_internal_to_public_map(llm_router, general_settings)
public_group_names: Final = tuple(
internal_to_public.get(group.model_group, group.model_group) for group in model_groups
kept_entries: Final = tuple(
(name, name) for name in listed_group_names if internal_to_public.get(name, name) not in verdict.hidden
)
callback_hidden_names: Final = await _names_hidden_by_listing_callbacks(
user_api_key_dict, tuple(dict.fromkeys(public_group_names))
blocked_names: Final = llm_router.health().fully_blocked_model_names()
requested_entries: Final = tuple(
entry
for entry in _listing_with_aliases_to_unpaused_models(
kept_entries,
TeamModelNameTranslator.public_name_entries(
tuple(name for name, _ in kept_entries if name not in blocked_names), internal_to_public
),
caller_aliases,
_describable_model_groups(kept_entries, blocked_names, user_api_key_dict, None, verdict.kept_alias_targets),
)
if (model_group is None or entry[0] == model_group) and entry[0] not in verdict.hid
)
lookup_ids: Final = tuple(dict.fromkeys(lookup_id for _, lookup_id in requested_entries))
groups_by_lookup_id: Final = MappingProxyType(
dict(
zip(
lookup_ids,
get_model_group_info(
llm_router=llm_router,
all_models_str=lookup_ids,
model_group=None,
team_id=user_api_key_dict.team_id,
),
strict=True,
)
)
)
return {
"data": [
group
for group, public_name in zip(model_groups, public_group_names, strict=True)
if public_name not in callback_hidden_names
*(
_model_group_row_listed_as(groups_by_lookup_id[lookup_id], listed_name, lookup_id)
for listed_name, lookup_id in requested_entries
),
*(group for group in agent_groups if group.model_group not in verdict.hidden),
]
}
def _names_a_model_group_row_reads(model_group: str, aliases: CallerAliases, router_aliases: object) -> frozenset[str]:
return frozenset(
name
for name in (
model_group,
dispatched_model(model_group, aliases),
resolve_model_group_alias(router_aliases, model_group),
)
if name is not None
)
def _listing_with_aliases_to_unpaused_models(
listed_entries: Sequence[tuple[str, str]],
unpaused_public_entries: Sequence[tuple[str, str]],
aliases: CallerAliases,
deployed: Container[str],
) -> tuple[tuple[str, str], ...]:
described: Final = alias_listing_entries(unpaused_public_entries, aliases, deployed)
lookup_by_name: Final = MappingProxyType(dict(described))
shown_names: Final = frozenset(name for name, _ in (*listed_entries, *unpaused_public_entries))
return (
*((name, lookup_by_name.get(name, lookup_id)) for name, lookup_id in listed_entries),
*(entry for entry in described if entry[0] not in shown_names),
)
def _model_group_row_listed_as(row: ModelGroupInfoProxy, listed_name: str, lookup_id: str) -> ModelGroupInfoProxy:
if listed_name == lookup_id:
return row
return row.model_copy(
update={
"model_group": listed_name,
"is_public_model_group": listed_name in (litellm.public_model_groups or ()),
}
)
@router.get(
"/model/settings",
description="Returns provider name, description, and required parameters for each provider",

View file

@ -4977,6 +4977,10 @@ class ProxyLogging:
if result is not None:
merged_headers.update(result)
@staticmethod
def filters_listed_models() -> bool:
return bool(hook_subscribers(Hook.list_models, ProxyLogging.callback_capabilities().hook_callbacks).entries)
async def hidden_by_listing_callbacks(
self, user_api_key_dict: UserAPIKeyAuth, model_names: Sequence[str]
) -> frozenset[str]:

View file

@ -1,19 +1,25 @@
"""
Tests for key and team `model_aliases` on the model listing endpoints: GET /v1/models
(`model_list`, OpenAI and Anthropic shapes) and GET /v1/models/{id} (`model_info`).
An alias the caller can complete on is listed next to its target and resolves by name.
(`model_list`, OpenAI and Anthropic shapes), GET /v1/models/{id} (`model_info`) and
GET /model_group/info (`model_group_info`). An alias the caller can complete on is listed
next to its target and resolves by name.
"""
import json
from collections.abc import Sequence
from itertools import chain
from typing import Final
import pytest
from fastapi import HTTPException
from pydantic import TypeAdapter
from starlette.requests import Request
import litellm
from litellm import Router
from litellm.integrations.custom_logger import CustomLogger
from litellm.integrations.custom_logger_v2 import CallContext, CustomLoggerV2, Field, Hook, Patch, on
from litellm.integrations.custom_logger_v2.enforcement import DeclaredView
from litellm.proxy import proxy_server
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy.utils import ProxyLogging
@ -38,6 +44,35 @@ class _ListingGate(CustomLogger):
return [name for name in model_names if name not in self.hidden]
_LISTED_NAMES: TypeAdapter[tuple[str, ...]] = TypeAdapter(tuple[str, ...])
class _V2ListingGate(CustomLoggerV2):
def __init__(self, hidden: frozenset[str]) -> None:
super().__init__()
self.hidden = hidden
@on(Hook.list_models, reads={Field.model_list}, writes={Field.model_list})
async def list_models(self, view: DeclaredView, ctx: CallContext[object]) -> Patch:
names: Final = _LISTED_NAMES.validate_python(view.model_list)
return Patch().set(Field.model_list, tuple(name for name in names if name not in self.hidden))
_LISTING_GATES = pytest.mark.parametrize("gate", [_ListingGate, _V2ListingGate])
class _ListingRecorder(CustomLogger):
def __init__(self) -> None:
super().__init__()
self.offered: tuple[tuple[str, ...], ...] = ()
async def async_filter_listed_models(
self, user_api_key_dict: UserAPIKeyAuth, model_names: Sequence[str]
) -> Sequence[str]:
self.offered = (*self.offered, tuple(model_names))
return model_names
class _ListingAllowList(CustomLogger):
def __init__(self, kept: frozenset[str]) -> None:
super().__init__()
@ -306,10 +341,16 @@ async def test_alias_to_a_model_the_listing_hides_is_not_described_by_it(
("model_name_team1_abc", "team-chat", ["gpt-4.1-mini", "fast", "model_name_team1_abc"]),
],
)
@_LISTING_GATES
async def test_alias_to_a_model_a_listing_callback_hides_is_not_described_by_it(
router: Router, monkeypatch: pytest.MonkeyPatch, target: str, gated: str, models: list[str]
router: Router,
monkeypatch: pytest.MonkeyPatch,
target: str,
gated: str,
models: list[str],
gate: type[_ListingGate | _V2ListingGate],
) -> None:
monkeypatch.setattr(litellm, "callbacks", [_ListingGate(frozenset({gated}))])
monkeypatch.setattr(litellm, "callbacks", [gate(frozenset({gated}))])
ProxyLogging._callback_capabilities_cache.clear()
caller = _team_member(models=models, team_model_aliases={"fast": target})
@ -350,3 +391,231 @@ async def test_hidden_router_settings_alias_the_key_does_not_list_is_not_retriev
with pytest.raises(HTTPException) as retrieval:
await proxy_server.model_info(model_id="quiet", user_api_key_dict=caller)
assert retrieval.value.status_code == 404
async def _model_group_rows(
user_api_key_dict: UserAPIKeyAuth, model_group: str | None = None
) -> dict[str, dict[str, object]]:
response: Final = await proxy_server.model_group_info(user_api_key_dict=user_api_key_dict, model_group=model_group)
return {row.model_group: row.model_dump() for row in response["data"]}
def _team_listing(*extra: str, **aliases: dict[str, str]) -> UserAPIKeyAuth:
return _team_member(models=["gpt-4.1-mini", "team-chat", *extra], **aliases)
@pytest.mark.asyncio
@pytest.mark.parametrize(
("caller", "alias", "target"),
[
(_team_listing(aliases={"mini": "gpt-4.1-mini"}), "mini", "gpt-4.1-mini"),
(_team_listing(team_model_aliases={"claude-sonnet-4-5": "gpt-4.1-mini"}), "claude-sonnet-4-5", "gpt-4.1-mini"),
(_team_listing("fast", team_model_aliases={"fast": "gpt-4.1-mini"}), "fast", "gpt-4.1-mini"),
(_team_listing(team_model_aliases={"chat": "team-chat"}), "chat", "team-chat"),
(_alias_holder(aliases={"fast": "gpt-4.1"}), "fast", "gpt-4.1"),
(_alias_holder(router_settings={"model_group_alias": {"fast": "gpt-4.1"}}), "fast", "gpt-4.1"),
],
)
async def test_model_group_info_describes_every_alias_v1_models_lists_as_its_target(
router: Router, caller: UserAPIKeyAuth, alias: str, target: str
) -> None:
rows: Final = await _model_group_rows(caller)
assert list(rows) == await _v1_models(caller)
assert rows[alias] == {**rows[target], "model_group": alias}
assert await _model_group_rows(caller, model_group=alias) == {alias: rows[alias]}
@pytest.mark.asyncio
async def test_model_group_info_describes_a_team_alias_to_a_model_the_team_does_not_list(router: Router) -> None:
caller: Final = _team_listing("fast", team_model_aliases={"fast": "gpt-4.1"})
rows: Final = await _model_group_rows(caller)
assert list(rows) == await _v1_models(caller)
assert rows["fast"] == {**(await _model_group_rows(_keyholder("gpt-4.1")))["gpt-4.1"], "model_group": "fast"}
@pytest.mark.asyncio
@pytest.mark.parametrize("gate", [None, _ListingGate, _V2ListingGate])
async def test_model_group_info_describes_a_team_alias_to_a_team_model_the_key_lists_by_internal_name(
router: Router, monkeypatch: pytest.MonkeyPatch, gate: type[_ListingGate | _V2ListingGate] | None
) -> None:
caller: Final = _team_member(team_model_aliases={"chat": "team-chat"})
monkeypatch.setattr(litellm, "callbacks", [] if gate is None else [gate(frozenset({"unrelated"}))])
ProxyLogging._callback_capabilities_cache.clear()
rows: Final = await _model_group_rows(caller)
assert rows["chat"] == {**rows["model_name_team1_abc"], "model_group": "chat"}
assert await _model_group_rows(caller, model_group="chat") == {"chat": rows["chat"]}
@pytest.mark.asyncio
@_LISTING_GATES
async def test_a_team_alias_to_a_team_model_a_listing_callback_hides_gets_no_model_group_row(
router: Router, monkeypatch: pytest.MonkeyPatch, gate: type[_ListingGate | _V2ListingGate]
) -> None:
caller: Final = _team_member(team_model_aliases={"chat": "team-chat"})
monkeypatch.setattr(litellm, "callbacks", [gate(frozenset({"team-chat"}))])
ProxyLogging._callback_capabilities_cache.clear()
assert list(await _model_group_rows(caller)) == ["gpt-4.1-mini"]
assert await _model_group_rows(caller, model_group="chat") == {}
@pytest.mark.asyncio
@pytest.mark.parametrize(
("target", "team_id"), [("paused", "team1"), ("hidden", "team1"), ("model_name_team1_abc", "team2")]
)
async def test_model_group_info_does_not_describe_an_alias_to_a_model_the_listing_hides(
router: Router, target: str, team_id: str
) -> None:
models: Final = ["gpt-4.1-mini", "fast"]
caller: Final = _team_member(team_id=team_id, models=models, team_model_aliases={"fast": target})
assert await _model_group_rows(caller) == await _model_group_rows(_team_member(team_id=team_id, models=models))
@pytest.mark.asyncio
@pytest.mark.parametrize("models", [["gpt-4.1-mini", "paused"], ["gpt-4.1-mini", "fast", "paused"]])
async def test_model_group_info_lists_a_paused_model_but_describes_no_alias_by_it(
router: Router, models: list[str]
) -> None:
caller: Final = _team_member(models=models, team_model_aliases={"fast": "paused"})
assert await _model_group_rows(caller) == await _model_group_rows(_team_member(models=models))
@pytest.mark.asyncio
@pytest.mark.parametrize(
("target", "gated", "models"),
[
("gpt-4.1", "gpt-4.1", ["gpt-4.1-mini", "fast"]),
("gpt-4.1", "gpt-4.1", ["gpt-4.1-mini", "fast", "gpt-4.1"]),
("model_name_team1_abc", "team-chat", ["gpt-4.1-mini", "fast", "model_name_team1_abc"]),
],
)
@_LISTING_GATES
async def test_model_group_info_hides_an_alias_whose_target_a_listing_callback_hides(
router: Router,
monkeypatch: pytest.MonkeyPatch,
target: str,
gated: str,
models: list[str],
gate: type[_ListingGate | _V2ListingGate],
) -> None:
monkeypatch.setattr(litellm, "callbacks", [gate(frozenset({gated}))])
ProxyLogging._callback_capabilities_cache.clear()
caller: Final = _team_member(models=models, team_model_aliases={"fast": target})
assert await _model_group_rows(caller) == await _model_group_rows(_team_member(models=models))
@pytest.mark.asyncio
async def test_model_group_info_keeps_every_group_an_allow_list_callback_allows_when_an_alias_points_past_them(
router: Router, monkeypatch: pytest.MonkeyPatch
) -> None:
caller: Final = _team_member(models=["gpt-4.1-mini", "legacy", "fast"], team_model_aliases={"fast": "gpt-4.1"})
listed: Final = await _v1_models(caller)
monkeypatch.setattr(litellm, "callbacks", [_ListingAllowList(frozenset(listed))])
ProxyLogging._callback_capabilities_cache.clear()
assert list(await _model_group_rows(caller)) == listed
@pytest.mark.asyncio
@_LISTING_GATES
async def test_a_name_a_listing_callback_hides_stays_hidden_when_it_is_also_an_alias(
router: Router, monkeypatch: pytest.MonkeyPatch, gate: type[_ListingGate | _V2ListingGate]
) -> None:
monkeypatch.setattr(litellm, "callbacks", [gate(frozenset({"fast"}))])
ProxyLogging._callback_capabilities_cache.clear()
models: Final = ["gpt-4.1-mini", "fast"]
caller: Final = _team_member(models=models, team_model_aliases={"fast": "gpt-4.1-mini"})
unaliased: Final = _team_member(models=models)
assert await _v1_models(caller) == await _v1_models(unaliased)
assert await _model_group_rows(caller) == await _model_group_rows(unaliased)
with pytest.raises(HTTPException) as retrieval:
await proxy_server.model_info(model_id="fast", user_api_key_dict=caller)
with pytest.raises(HTTPException) as unaliased_retrieval:
await proxy_server.model_info(model_id="fast", user_api_key_dict=unaliased)
assert retrieval.value.status_code == unaliased_retrieval.value.status_code
@pytest.mark.asyncio
@_LISTING_GATES
async def test_an_alias_to_an_unlisted_model_a_listing_callback_keeps_is_described_by_that_model(
router: Router, monkeypatch: pytest.MonkeyPatch, gate: type[_ListingGate | _V2ListingGate]
) -> None:
caller: Final = _team_listing("fast", team_model_aliases={"fast": "gpt-4.1"})
target_holder: Final = _keyholder("gpt-4.1")
target_row: Final = {row["id"]: row for row in (await proxy_server.model_list(user_api_key_dict=target_holder, request=None))["data"]}
target_info: Final = await proxy_server.model_info(model_id="gpt-4.1", user_api_key_dict=target_holder)
target_group: Final = {**(await _model_group_rows(target_holder))["gpt-4.1"], "model_group": "fast"}
monkeypatch.setattr(litellm, "callbacks", [gate(frozenset({"unrelated"}))])
ProxyLogging._callback_capabilities_cache.clear()
rows: Final = {row["id"]: row for row in (await proxy_server.model_list(user_api_key_dict=caller, request=None))["data"]}
assert rows["fast"] == {**target_row["gpt-4.1"], "id": "fast"}
assert await proxy_server.model_info(model_id="fast", user_api_key_dict=caller) == {**target_info, "id": "fast"}
assert (await _model_group_rows(caller))["fast"] == target_group
assert await _model_group_rows(caller, model_group="fast") == {"fast": target_group}
@pytest.mark.asyncio
@_LISTING_GATES
async def test_an_alias_to_an_unlisted_model_a_listing_callback_hides_is_not_described_by_that_model(
router: Router, monkeypatch: pytest.MonkeyPatch, gate: type[_ListingGate | _V2ListingGate]
) -> None:
caller: Final = _team_listing("fast", team_model_aliases={"fast": "gpt-4.1"})
monkeypatch.setattr(litellm, "callbacks", [gate(frozenset({"gpt-4.1"}))])
ProxyLogging._callback_capabilities_cache.clear()
assert (await _model_group_rows(caller))["fast"]["providers"] == []
with pytest.raises(HTTPException) as lookup:
await proxy_server.model_info(model_id="fast", user_api_key_dict=caller)
assert lookup.value.status_code == 404
@pytest.mark.asyncio
@_LISTING_GATES
async def test_an_alias_named_like_a_router_alias_to_a_hidden_model_is_listed_as_its_own_target(
router: Router, monkeypatch: pytest.MonkeyPatch, gate: type[_ListingGate | _V2ListingGate]
) -> None:
monkeypatch.setattr(litellm, "callbacks", [gate(frozenset({"gpt-4.1"}))])
ProxyLogging._callback_capabilities_cache.clear()
caller: Final = UserAPIKeyAuth(
api_key="sk-test",
user_id="u",
user_role=LitellmUserRoles.INTERNAL_USER,
models=["gpt-4.1-mini", "gpt-4.1"],
aliases={"legacy": "gpt-4.1-mini"},
)
listing: Final = await proxy_server.model_list(user_api_key_dict=caller, request=None)
rows: Final = {row["id"]: row for row in listing["data"]}
assert rows["legacy"] == {**rows["gpt-4.1-mini"], "id": "legacy"}
target: Final = await proxy_server.model_info(model_id="gpt-4.1-mini", user_api_key_dict=caller)
assert await proxy_server.model_info(model_id="legacy", user_api_key_dict=caller) == {**target, "id": "legacy"}
groups: Final = await _model_group_rows(caller)
assert groups["legacy"] == {**groups["gpt-4.1-mini"], "model_group": "legacy"}
assert await _model_group_rows(caller, model_group="legacy") == {"legacy": groups["legacy"]}
@pytest.mark.asyncio
@pytest.mark.parametrize(("model_group", "offered"), [("gpt-4.1-mini", {"gpt-4.1-mini"}), ("fast", {"gpt-4.1"})])
async def test_model_group_info_for_one_group_offers_listing_callbacks_only_the_names_it_reads(
router: Router, monkeypatch: pytest.MonkeyPatch, model_group: str, offered: set[str]
) -> None:
recorder: Final = _ListingRecorder()
monkeypatch.setattr(litellm, "callbacks", [recorder])
ProxyLogging._callback_capabilities_cache.clear()
caller: Final = _team_member(
models=["gpt-4.1-mini", "gpt-4.1", "model_name_team1_abc"], team_model_aliases={"fast": "gpt-4.1"}
)
rows: Final = await _model_group_rows(caller, model_group=model_group)
assert list(rows) == [model_group]
assert set(chain.from_iterable(recorder.offered)) == offered
assert rows[model_group] == (await _model_group_rows(caller))[model_group]