Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
44 changes: 44 additions & 0 deletions litellm/proxy/common_utils/model_listing_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,50 @@ def __contains__(self, name: object) -> bool:
)


def caller_alias_maps(
key_aliases: object,
team_aliases: object,
key_team_id: str | None,
listed_team_id: str | None,
) -> tuple[object, ...]:
"""The alias maps `/chat/completions` rewrites this caller's model through: the key's
own aliases always, the team's only when listing the team the key authenticated as."""
if listed_team_id is not None and listed_team_id != key_team_id:
return (key_aliases,)
return (key_aliases, team_aliases)


def _alias_pairs(alias_maps: Sequence[object]) -> tuple[tuple[str, str], ...]:
return tuple(
(alias, target)
for aliases in alias_maps
if isinstance(aliases, Mapping)
for alias, target in cast(Mapping[object, object], aliases).items() # any-ok: checked per pair
if isinstance(alias, str) and isinstance(target, str)
)


def alias_target(model_id: str, alias_maps: Sequence[object]) -> str | None:
"""The model group a key or team alias rewrites `model_id` to, else None."""
return next((target for alias, target in _alias_pairs(alias_maps) if alias == model_id), None)


def alias_listing_entries(
entries: Sequence[tuple[str, str]],
alias_maps: Sequence[object],
) -> list[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."""
lookup_by_response: Final = dict(entries)
lookup_ids: Final = frozenset(lookup_by_response.values())
added: Final = {
alias: lookup_by_response.get(target, target)
for alias, target in _alias_pairs(alias_maps)
if alias not in lookup_by_response and (target in lookup_by_response or target in lookup_ids)
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved
return [*entries, *added.items()]


def claude_code_requested_group(
requested: str,
llm_router: Router,
Expand Down
24 changes: 17 additions & 7 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, 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 @@ -11404,7 +11408,13 @@ async def model_info(

internal_to_public: Final = TeamModelNameTranslator.build_internal_to_public_map(llm_router, settings)
resolved_model_id: Final = TeamModelNameTranslator.resolve_public_name(
model_id=model_id,
model_id=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
),
)
or model_id,
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved

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.

Alias retrieve overwrites response id

Medium Severity

GET /v1/models/{alias} resolves the alias through resolve_public_name and then sets id from internal_to_public. When the target is a team-scoped model, that map replaces the requested alias with the team's public name, so retrieve returns a different id than the listing row for the same alias.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d6ec911. Configure here.

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.

Fixed in f187b8a: when the request is an alias the response id is the alias, regression test added for a team scoped target

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
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
103 changes: 84 additions & 19 deletions tests/test_litellm/proxy/common_utils/test_model_listing_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
from litellm import Router
from litellm.proxy.common_utils.model_listing_utils import (
ClaudeCodeRoutingNames,
alias_listing_entries,
alias_target,
caller_alias_maps,
claude_code_group_name,
claude_code_model_id,
claude_code_requested_group,
Expand All @@ -29,15 +32,16 @@ def _row(name, limit=1000000):
def _router(*names, aliases=None):
return Router(
model_list=[
{"model_name": name, "litellm_params": {"model": "openai/gpt-4o", "api_key": "sk-fake"}}
for name in names
{"model_name": name, "litellm_params": {"model": "openai/gpt-4o", "api_key": "sk-fake"}} for name in names
],
model_group_alias=aliases,
)


@pytest.mark.parametrize("limit", [None, 999999, 1000000])
@pytest.mark.parametrize("name", ["foo", "foo[1m]", "foo[1M]", "a/b: 世界", "claude-router-foo", "claude-opus-5", "claude-opus-5[1m]"])
@pytest.mark.parametrize(
"name", ["foo", "foo[1m]", "foo[1M]", "a/b: 世界", "claude-router-foo", "claude-opus-5", "claude-opus-5[1m]"]
)
def test_listing_round_trips_entire_source_name(name, limit):
names = frozenset({name})
view = claude_code_model_id(name, limit, names)
Expand All @@ -48,7 +52,15 @@ def test_listing_round_trips_entire_source_name(name, limit):


def test_collision_matrix_round_trips_without_duplicate_ids():
universe = ("foo", "foo[1m]", "claude-router-foo", _encoded("foo"), _encoded("foo") + "[1m]", "claude-opus-5", "claude-opus-5[1m]")
universe = (
"foo",
"foo[1m]",
"claude-router-foo",
_encoded("foo"),
_encoded("foo") + "[1m]",
"claude-opus-5",
"claude-opus-5[1m]",
)
for pair in combinations(universe, 2):
for visible in (pair, pair[:1], pair[1:]):
names = frozenset(pair)
Expand All @@ -57,18 +69,31 @@ def test_collision_matrix_round_trips_without_duplicate_ids():
assert all((claude_code_group_name(shown, names) or shown) == source for source, shown in view.items())


@pytest.mark.parametrize("spelling", ["claude-router-foo", "claude-router-ff", "claude-router-66 6f6f", "claude-router-666F6F", "claude-router-", _encoded("missing")])
@pytest.mark.parametrize(
"spelling",
[
"claude-router-foo",
"claude-router-ff",
"claude-router-66 6f6f",
"claude-router-666F6F",
"claude-router-",
_encoded("missing"),
],
)
def test_unknown_or_noncanonical_ids_are_never_guessed(spelling):
assert claude_code_group_name(spelling, frozenset({"foo"})) is None


@pytest.mark.parametrize("headers,enabled", [
({"user-agent": "claude-code/2.1.267"}, True),
({"user-agent": "claude-cli/2.1.267 (external, sdk-cli)"}, True),
({"x-gateway-client": "Claude-Code"}, True),
({"user-agent": "anthropic-sdk-python/0.40"}, False),
({}, False),
])
@pytest.mark.parametrize(
"headers,enabled",
[
({"user-agent": "claude-code/2.1.267"}, True),
({"user-agent": "claude-cli/2.1.267 (external, sdk-cli)"}, True),
({"x-gateway-client": "Claude-Code"}, True),
({"user-agent": "anthropic-sdk-python/0.40"}, False),
({}, False),
],
)
def test_only_claude_code_gets_the_view(headers, enabled):
rows = (_row("foo"), _row("claude-opus-5"))
view = claude_code_view_ids(rows, headers, frozenset(row["id"] for row in rows))
Expand All @@ -82,7 +107,12 @@ def test_configured_names_outrank_generated_ids_even_when_hidden_from_listing(mo
encoded = _encoded("foo")
alias = {encoded: "other"}
monkeypatch.setattr(litellm, "model_alias_map", alias if layer == "global" else {})
router = _router("foo", "other", *( (encoded,) if layer == "literal" else ("*",) if layer == "wildcard" else ()), aliases=alias if layer == "router" else None)
router = _router(
"foo",
"other",
*((encoded,) if layer == "literal" else ("*",) if layer == "wildcard" else ()),
aliases=alias if layer == "router" else None,
)
maps = (alias,) if layer in ("key", "team") else ()
names = ClaudeCodeRoutingNames(router, None, maps)
assert claude_code_requested_group(encoded, router, None, maps) is None
Expand All @@ -98,12 +128,47 @@ def test_mutation_breaking_the_hex_name_cannot_route_to_the_source(source):
assert claude_code_requested_group(_marked(source), router, None) == source


def test_team_alias_is_listed_under_its_target_metadata_and_only_when_the_target_is_accessible():
entries = [("gpt-4.1-mini", "gpt-4.1-mini"), ("team-public", "model_name_team_1_abc")]
aliases = (
{"claude-sonnet-4-5": "gpt-4.1-mini", "via-public": "team-public", "not-granted": "gpt-4.1"},
None,
{"gpt-4.1-mini": "team-public"},
)
assert alias_listing_entries(entries, aliases) == [
*entries,
("claude-sonnet-4-5", "gpt-4.1-mini"),
("via-public", "model_name_team_1_abc"),
]
assert alias_listing_entries(entries, (None, {})) == entries


def test_alias_target_resolves_the_requested_alias_across_key_and_team_maps():
assert (
alias_target("claude-sonnet-4-5", ({"o": "gpt-4.1"}, {"claude-sonnet-4-5": "gpt-4.1-mini"})) == "gpt-4.1-mini"
)
assert alias_target("gpt-4.1-mini", (None, {"claude-sonnet-4-5": "gpt-4.1-mini"})) is None


def test_team_aliases_only_apply_when_listing_the_team_the_key_authenticated_as():
key_aliases, team_aliases = {"k": "gpt-4.1"}, {"t": "gpt-4.1-mini"}
assert caller_alias_maps(key_aliases, team_aliases, "team-a", None) == (key_aliases, team_aliases)
assert caller_alias_maps(key_aliases, team_aliases, "team-a", "team-a") == (key_aliases, team_aliases)
assert caller_alias_maps(key_aliases, team_aliases, "team-a", "team-b") == (key_aliases,)


def test_team_public_name_uses_the_same_scope_at_list_and_request():
router = Router(model_list=[{
"model_name": "model_name_team-a_id",
"litellm_params": {"model": "openai/gpt-4o", "api_key": "sk-fake"},
"model_info": {"team_id": "team-a", "team_public_model_name": "shared"},
}])
shown = claude_code_view_ids((_row("shared"),), {"user-agent": "claude-code/2.1.267"}, ClaudeCodeRoutingNames(router, "team-a"))["shared"]
router = Router(
model_list=[
{
"model_name": "model_name_team-a_id",
"litellm_params": {"model": "openai/gpt-4o", "api_key": "sk-fake"},
"model_info": {"team_id": "team-a", "team_public_model_name": "shared"},
}
]
)
shown = claude_code_view_ids(
(_row("shared"),), {"user-agent": "claude-code/2.1.267"}, ClaudeCodeRoutingNames(router, "team-a")
)["shared"]
assert claude_code_requested_group(shown, router, "team-a") == "shared"
assert claude_code_requested_group(shown, router, "team-b") is None
Loading