Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
13 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions litellm/proxy/common_utils/model_listing_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,12 @@
import re
from collections.abc import Container, Mapping, Sequence
from dataclasses import dataclass
from functools import reduce
from types import MappingProxyType
from typing import TYPE_CHECKING, Final, cast

from pydantic import TypeAdapter, ValidationError

import litellm

if TYPE_CHECKING:
Expand All @@ -28,6 +31,8 @@
_CLAUDE_CODE_ALIAS_PREFIX: Final = "claude-router-"
_ONE_MILLION_SUFFIX: Final = "[1m]"
_ONE_MILLION_TOKENS: Final = 1_000_000
_ALIAS_ENTRIES: Final = TypeAdapter(Mapping[object, object])
_NO_ALIASES: Final[Mapping[str, str]] = MappingProxyType({})


def configured_display_names(
Expand Down Expand Up @@ -152,6 +157,77 @@ def __contains__(self, name: object) -> bool:
)


@dataclass(frozen=True, slots=True)
class CallerAliases:
"""`own` are the caller's key and team alias maps, the names `/v1/models` lists for it.
`rewrite` are the maps `/chat/completions` rewrites its model through, in the order it
applies them: the team's, the key's in `add_litellm_data_to_request`, then the global
`model_alias_map` and the key's again in `common_processing_pre_call_logic`."""

own: tuple[object, ...]
rewrite: tuple[object, ...]


def caller_alias_maps(
key_aliases: object,
team_aliases: object,
key_team_id: str | None,
listed_team_id: str | None,
) -> CallerAliases:
"""Team aliases count only when listing the team the key authenticated as."""
if listed_team_id is not None and listed_team_id != key_team_id:
return CallerAliases((key_aliases,), (key_aliases, litellm.model_alias_map, key_aliases))
return CallerAliases((team_aliases, key_aliases), (team_aliases, key_aliases, litellm.model_alias_map, key_aliases))


def _alias_map(aliases: object) -> Mapping[str, str]:
try:
entries: Final = _ALIAS_ENTRIES.validate_python(aliases, strict=True)
except ValidationError:
return _NO_ALIASES
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved
return MappingProxyType(
{alias: target for alias, target in entries.items() if isinstance(alias, str) and isinstance(target, str)}
)


def _alias_names(alias_maps: Sequence[Mapping[str, str]]) -> tuple[str, ...]:
return tuple(dict.fromkeys(alias for aliases in alias_maps for alias in aliases))


def _rewrite(model_id: str, alias_maps: Sequence[Mapping[str, str]]) -> str | None:
target: Final = reduce(lambda name, aliases: aliases.get(name, name), alias_maps, model_id)
return None if target == model_id else target
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved


def alias_target(model_id: str, aliases: CallerAliases, listed: Container[str] = frozenset()) -> str | None:
"""The model group `/chat/completions` rewrites `model_id` to, else None. A `model_id`
already `listed` keeps its own row, so it is never rewritten."""
if model_id in listed:
return None
return _rewrite(model_id, tuple(_alias_map(alias_map) for alias_map in aliases.rewrite))


def alias_listing_entries(
entries: Sequence[tuple[str, str]],
aliases: CallerAliases,
) -> tuple[tuple[str, str], ...]:
"""`entries` plus one `(alias, lookup_id)` row per key or team alias whose target is
listed. An alias colliding with a listed id keeps the listed entry."""
maps: Final = tuple(_alias_map(alias_map) for alias_map in aliases.rewrite)
own: Final = tuple(_alias_map(alias_map) for alias_map in aliases.own)
lookup_by_response: Final = MappingProxyType(dict(entries))
lookup_ids: Final = frozenset(lookup_by_response.values())
targets: Final = MappingProxyType(
{alias: _rewrite(alias, maps) for alias in _alias_names(own) if alias not in lookup_by_response}
)
added: Final = tuple(
(alias, lookup_by_response.get(target, target))
for alias, target in targets.items()
if target is not None and (target in lookup_by_response or target in lookup_ids)
)
return (*entries, *added)


def claude_code_requested_group(
requested: str,
llm_router: Router,
Expand Down
39 changes: 30 additions & 9 deletions litellm/proxy/proxy_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,9 @@ def generate_feedback_box():
from litellm.proxy.common_utils.model_listing_utils import (
ClaudeCodeRoutingNames,
TeamModelNameTranslator,
alias_listing_entries,
alias_target,
caller_alias_maps,
claude_code_view_ids,
configured_display_names,
is_claude_code_client,
Expand Down Expand Up @@ -11172,14 +11175,13 @@ async def model_list(
view_aliases: Final = (
view_router_settings.get("model_group_alias") if isinstance(view_router_settings, Mapping) else None
)
caller_aliases: Final = caller_alias_maps(
user_api_key_dict.aliases, user_api_key_dict.team_model_aliases, user_api_key_dict.team_id, team_id
)
routing_names: Final = ClaudeCodeRoutingNames(
llm_router,
team_id or user_api_key_dict.team_id,
(
user_api_key_dict.aliases,
user_api_key_dict.team_model_aliases,
view_aliases,
),
(*caller_aliases.rewrite, view_aliases),
)

# Validate scope parameter if provided
Expand Down Expand Up @@ -11307,7 +11309,9 @@ async def model_list(
# The internal routing key drives the metadata/fallback lookup, while the
# public name is what the client sees as the model id.
model_data = []
entries: Final = TeamModelNameTranslator.listing_entries(all_models, llm_router, settings)
entries: Final = alias_listing_entries(
TeamModelNameTranslator.listing_entries(all_models, llm_router, settings), caller_aliases
)
for response_id, lookup_id in entries:
model_info = create_model_info_response(
model_id=lookup_id,
Expand Down Expand Up @@ -11391,7 +11395,8 @@ async def model_info(
)

# Mirror /v1/models' visibility filter so first-occurrence resolution
# cannot land on a deployment the listing had hidden.
# cannot land on a deployment the listing had hidden. Undiscoverable
# models stay retrievable by id, they only drop out of the alias guard.
blocked_names: Final = llm_router.get_fully_blocked_model_names() if llm_router is not None else set()
unhealthy_names: Final = await get_hidden_unhealthy_model_names(
healthy_only=healthy_only,
Expand All @@ -11401,10 +11406,25 @@ async def model_info(
hidden_names: Final = blocked_names | unhealthy_names
if hidden_names:
all_models = [m for m in all_models if m not in hidden_names]
undiscoverable_names: Final = undiscoverable_model_names(
all_models, llm_router, user_api_key_dict, team_id or user_api_key_dict.team_id
)

internal_to_public: Final = TeamModelNameTranslator.build_internal_to_public_map(llm_router, settings)
aliased_model_id: Final = alias_target(
model_id,
caller_alias_maps(
user_api_key_dict.aliases, user_api_key_dict.team_model_aliases, user_api_key_dict.team_id, team_id
),
frozenset(
response_id
for response_id, _ in TeamModelNameTranslator.listing_entries(
[m for m in all_models if m not in undiscoverable_names], llm_router, settings
)
),
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
)
resolved_model_id: Final = TeamModelNameTranslator.resolve_public_name(
model_id=model_id,
model_id=aliased_model_id or model_id,
available_models=all_models,
Comment on lines 11426 to 11428

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Listed ID retrieves different model

If an undiscoverable team model and a discoverable model share a public ID, /v1/models filters out the undiscoverable model and shows the discoverable model’s metadata. This retrieval path instead resolves the ID using all_models, which still contains the undiscoverable model. If that model comes first, GET /v1/models/{id} returns its metadata for the ID shown in the list, so clients see conflicting information for the same model.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pre-existing and out of scope: model_info has always resolved through all_models including undiscoverable deployments, and test_v1_models_by_id_still_serves_the_hidden_model_to_non_admin pins that retrieval. This PR only excludes undiscoverable ids from the alias guard so alias resolution mirrors listing; a public id shared by a hidden and a visible deployment is a config collision the existing first-occurrence resolver already handled this way before this change.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right — the existing model_info contract intentionally resolves direct IDs through all_models, including undiscoverable deployments, and test_v1_models_by_id_still_serves_the_hidden_model_to_non_admin pins that behavior. This PR only removes undiscoverable IDs from the alias collision guard so alias resolution matches the list; it does not introduce the hidden/visible public-ID collision behavior. I’m withdrawing this comment.

llm_router=llm_router,
general_settings=settings,
Expand Down Expand Up @@ -11434,7 +11454,8 @@ async def model_info(
fallback_type=None,
llm_router=llm_router,
)
return {**response, "id": internal_to_public.get(resolved_model_id, model_id)} # mutable-ok: response id differs
response_id: Final = model_id if aliased_model_id else internal_to_public.get(resolved_model_id, model_id)
return {**response, "id": response_id} # mutable-ok: response id differs


def _blocked_response_usage(original_response: object | None) -> "litellm.Usage":
Expand Down
1 change: 1 addition & 0 deletions tests/e2e/coverage_registry/other.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
- {id: other.auth.jwt.virtual_key_unaffected, module: other, tier: P0, area: auth, assertions: [virtual_key_unaffected], source: "handle_jwt.py:213 is_jwt / user_api_key_auth.py:1332-1333", rationale: "enable_jwt_auth only routes three-segment bearer tokens into the JWT branch, so sk- virtual keys keep working on the same proxy"}
- {id: other.auth.jwt.team_header_alias_binds_team, module: other, tier: P0, area: auth, assertions: [team_header_alias_binds_team], source: "handle_jwt.py JWTAuthManager.resolve_team_from_header / LIT-7181", fail_before_fix: proven, rationale: "x-litellm-team-id carrying the team alias binds and attributes the same team as the team id, so a managed client can pin a stable alias instead of a uuid"}
- {id: other.auth.jwt.team_header_non_member_alias_denied, module: other, tier: P0, area: auth, assertions: [team_header_non_member_alias_denied], source: "handle_jwt.py JWTAuthManager.resolve_team_from_header / LIT-7181", rationale: "x-litellm-team-id naming the alias of a team the JWT does not grant is denied 403 with the same body as an unknown value, so the response does not reveal whether that team exists"}
- {id: other.auth.jwt.team_model_alias_listed_and_routes, module: other, tier: P1, area: auth, assertions: [team_model_alias_listed_and_routes], source: "proxy_server.py model_list / common_utils/model_listing_utils.py alias_listing_entries / LIT-8515", fail_before_fix: proven, rationale: "A team model_aliases name the JWT caller can complete on is also listed by GET /v1/models for that caller, in the OpenAI and the Anthropic (Claude Code) shapes, next to its target, so a managed client can discover the alias it is meant to send"}
- {id: other.auth.model_access_group.wildcard_bare_name_allowed, module: other, tier: P0, area: auth, assertions: [wildcard_bare_name_allowed], source: "auth_checks.py:3232 / LIT-5813", fail_before_fix: proven, rationale: "A grant of a group holding a wildcard deployment covers the bare model names callers actually send, not only the provider-prefixed spelling"}
- {id: other.auth.model_access_group.member_allowed, module: other, tier: P0, area: auth, assertions: [member_allowed], source: "auth_checks.py:3232", rationale: "A key whose allow-list is a model access group can call the deployments in that group"}
- {id: other.auth.model_access_group.non_member_denied, module: other, tier: P0, area: auth, assertions: [non_member_denied], source: "auth_checks.py:3232", rationale: "That same grant reaches nothing outside the group, including provider models the group's wildcard does not cover"}
Expand Down
1 change: 1 addition & 0 deletions tests/e2e/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1426,6 +1426,7 @@ class TeamNewBody(BaseModel):
team_id: str | None = None
organization_id: str | None = None
metadata: TeamMetadata | None = None
model_aliases: dict[str, str] | None = None


class TeamNewResponse(BaseModel):
Expand Down
16 changes: 15 additions & 1 deletion tests/e2e/other/other_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,15 @@
from __future__ import annotations

from dataclasses import dataclass
from typing import Final

from e2e_http import AuthHeaders, NoBody, ProbeResult, Result
from e2e_http import AnthropicHeaders, AuthHeaders, NoBody, ProbeResult, Result
from idp import Keycloak, keycloak_from_env
from models import (
ChatBody,
ChatResponse,
ModelsListParams,
ModelsListResponse,
ReadinessDetailsResponse,
ReadinessResponse,
UserListParams,
Expand Down Expand Up @@ -88,6 +91,17 @@ def chat_as_team(self, token: str, team: str, body: ChatBody) -> Result[ChatResp
response_type=ChatResponse,
)

def list_models_as(self, token: str, *, anthropic: bool = False) -> Result[ModelsListResponse]:
"""GET /v1/models under `token`, in the OpenAI shape or, with `anthropic`, the
Anthropic Models API shape Claude Code reads. Both carry `data[].id`."""
bearer: Final = self.proxy.transport.bearer(token)
return self.proxy.transport.get(
"/v1/models",
headers=AnthropicHeaders(authorization=bearer.authorization) if anthropic else bearer,
params=ModelsListParams(return_wildcard_routes=False),
response_type=ModelsListResponse,
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved
)

def list_users_as(self, key: str) -> Result[UserListResponse]:
"""GET /user/list under `key`. Admin-only, so it doubles as the master
key's authorization proof: the master key (proxy admin) reads it, a
Expand Down
47 changes: 45 additions & 2 deletions tests/e2e/other/test_jwt_auth_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,35 @@ def bound_team(client: OtherClient, resources: ResourceManager) -> BoundTeam:
return BoundTeam(identity=provisioned, team_id=provisioned.group, team_alias=team_alias)


def _ping() -> ChatBody:
@dataclass(frozen=True, slots=True)
class AliasedTeam:
identity: Identity
alias: str
target: str


@pytest.fixture
def aliased_team(client: OtherClient, resources: ResourceManager) -> AliasedTeam:
"""An identity whose team carries a model_aliases entry, the name a managed
client such as Claude Code sends and the team rewrites to a real model group."""
marker: Final = unique_marker()
provisioned: Final = _provision(client, resources, marker=marker)
alias: Final = f"e2e-jwt-model-alias-{marker}"
team_id: Final = client.proxy.create_team(
TeamNewBody(
team_alias=f"e2e-jwt-aliased-{marker}",
team_id=provisioned.group,
models=[CHEAP_OPENAI_MODEL],
model_aliases={alias: CHEAP_OPENAI_MODEL},
)
)
resources.defer(lambda: client.proxy.delete_team(team_id))
return AliasedTeam(identity=provisioned, alias=alias, target=CHEAP_OPENAI_MODEL)


def _ping(model: str = CHEAP_OPENAI_MODEL) -> ChatBody:
return ChatBody(
model=CHEAP_OPENAI_MODEL,
model=model,
messages=[ChatMessage(role="user", content=f"Reply with the single word pong. {unique_marker()}")],
max_tokens=16,
)
Expand Down Expand Up @@ -221,6 +247,23 @@ def test_team_header_with_the_team_alias_binds_the_same_team_as_the_team_id(
f"{bound_team.team_id!r}, got {by_alias!r}"
)

@pytest.mark.covers("other.auth.jwt.team_model_alias_listed_and_routes")
@pytest.mark.parametrize("anthropic", [False, True], ids=["openai_shape", "anthropic_shape"])
def test_team_model_alias_is_listed_by_v1_models_under_the_same_token_that_routes_it(
self, client: OtherClient, aliased_team: AliasedTeam, anthropic: bool
) -> None:
token: Final = client.idp.access_token(aliased_team.identity)

routed: Final = unwrap(client.proxy.chat(token, _ping(model=aliased_team.alias)))
assert routed.choices, f"precondition: /chat/completions must route the team alias, got {routed}"

listed: Final = tuple(entry.id for entry in unwrap(client.list_models_as(token, anthropic=anthropic)).data)
assert aliased_team.alias in listed, (
f"/v1/models must list team alias {aliased_team.alias!r} that the same token routes on "
f"/chat/completions, got {listed}"
)
assert aliased_team.target in listed, f"the alias target {aliased_team.target!r} must stay listed, got {listed}"

@pytest.mark.covers("other.auth.jwt.team_header_non_member_alias_denied")
def test_team_header_with_the_alias_of_a_team_the_caller_is_not_in_is_rejected_like_an_unknown_value(
self, client: OtherClient, resources: ResourceManager, bound_team: BoundTeam
Expand Down
Loading
Loading