mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-10 22:41:41 +00:00
Merge pull request #39238 from BerriAI/litellm_anthropic_models_display_name
feat(proxy): configurable display_name for the Anthropic-shaped /v1/models listing
This commit is contained in:
commit
6ed2cdd428
7 changed files with 240 additions and 12 deletions
|
|
@ -1378,31 +1378,38 @@ def process_anthropic_headers(headers: httpx.Headers | dict) -> dict:
|
|||
return additional_headers
|
||||
|
||||
|
||||
def _anthropic_model_entry(model: ModelInfoResponse, created_at: str) -> Mapping[str, object]:
|
||||
def _anthropic_model_entry(
|
||||
model: ModelInfoResponse, created_at: str, display_names: Mapping[str, str]
|
||||
) -> Mapping[str, object]:
|
||||
return { # mutable-ok: JSON response body, serialized by the route and never mutated
|
||||
"type": "model",
|
||||
"id": model["id"],
|
||||
"display_name": model["id"],
|
||||
"display_name": display_names.get(model["id"], model["id"]),
|
||||
"created_at": created_at,
|
||||
"max_input_tokens": model.get("max_input_tokens"),
|
||||
"max_tokens": model.get("max_output_tokens"),
|
||||
}
|
||||
|
||||
|
||||
def create_anthropic_model_list_response(models: Sequence[ModelInfoResponse]) -> Mapping[str, object]:
|
||||
def create_anthropic_model_list_response(
|
||||
models: Sequence[ModelInfoResponse],
|
||||
display_names: Mapping[str, str] = MappingProxyType({}),
|
||||
) -> Mapping[str, object]:
|
||||
"""Build the Anthropic-native /v1/models envelope.
|
||||
|
||||
Clients that send an anthropic-version header parse the Anthropic Models API
|
||||
shape (type/display_name/created_at plus has_more/first_id/last_id) and filter
|
||||
the list themselves, so every model is returned here. The token limits carry
|
||||
over from the OpenAI-shaped listing, named as the Messages API names them, and
|
||||
are always present because the vendor shape declares them nullable, not optional
|
||||
are always present because the vendor shape declares them nullable, not optional.
|
||||
display_names maps a listed model id to a configured human-readable name; ids
|
||||
without an entry fall back to the id itself, matching the vendor behavior
|
||||
"""
|
||||
created_at: Final = (
|
||||
datetime.fromtimestamp(DEFAULT_MODEL_CREATED_AT_TIME, tz=timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
)
|
||||
data: Final = [ # mutable-ok: JSON response body, serialized by the route and never mutated
|
||||
_anthropic_model_entry(model, created_at) for model in models
|
||||
_anthropic_model_entry(model, created_at, display_names) for model in models
|
||||
]
|
||||
return { # mutable-ok: JSON response body, serialized by the route and never mutated
|
||||
"data": data,
|
||||
|
|
|
|||
|
|
@ -10,13 +10,36 @@ legacy internal names with `general_settings.use_team_public_model_name: false`.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from collections.abc import Mapping, Sequence
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Final, cast
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.router import Router
|
||||
|
||||
|
||||
def configured_display_names(
|
||||
entries: Sequence[tuple[str, str]],
|
||||
llm_router: Router | None,
|
||||
) -> Mapping[str, str]:
|
||||
"""response_id -> configured `model_info.display_name` for the listing entries
|
||||
that have one.
|
||||
|
||||
Metadata is looked up by each entry's internal lookup id (so team-scoped rows
|
||||
resolve), while the returned map is keyed by the public response id the
|
||||
Anthropic-shaped listing is built from. Entries without a configured name are
|
||||
omitted so the listing falls back to the id itself.
|
||||
"""
|
||||
if llm_router is None:
|
||||
return MappingProxyType({})
|
||||
resolved: Final = (
|
||||
(response_id, llm_router.get_configured_display_name(lookup_id)) for response_id, lookup_id in entries
|
||||
)
|
||||
return MappingProxyType(
|
||||
{response_id: display_name for response_id, display_name in resolved if display_name is not None}
|
||||
)
|
||||
|
||||
|
||||
class TeamModelNameTranslator:
|
||||
"""Translates internal team routing keys to their public names for the model
|
||||
listing/retrieve responses. Stateless; the live router and general_settings
|
||||
|
|
|
|||
|
|
@ -352,7 +352,10 @@ from litellm.proxy.common_utils.load_config_utils import (
|
|||
get_file_contents_from_s3,
|
||||
)
|
||||
from litellm.proxy.common_utils.model_deprecation import collect_model_deprecations
|
||||
from litellm.proxy.common_utils.model_listing_utils import TeamModelNameTranslator
|
||||
from litellm.proxy.common_utils.model_listing_utils import (
|
||||
TeamModelNameTranslator,
|
||||
configured_display_names,
|
||||
)
|
||||
from litellm.proxy.common_utils.openai_endpoint_utils import (
|
||||
remove_sensitive_info_from_deployment,
|
||||
)
|
||||
|
|
@ -10223,7 +10226,8 @@ 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 = []
|
||||
for response_id, lookup_id in TeamModelNameTranslator.listing_entries(all_models, llm_router, settings):
|
||||
admin_entries: Final = TeamModelNameTranslator.listing_entries(all_models, llm_router, settings)
|
||||
for response_id, lookup_id in admin_entries:
|
||||
model_info = create_model_info_response(
|
||||
model_id=lookup_id,
|
||||
provider="openai",
|
||||
|
|
@ -10236,7 +10240,10 @@ async def model_list(
|
|||
|
||||
if wants_anthropic_format:
|
||||
admin_listing: Final = cast(Sequence[ModelInfoResponse], model_data) # cast-ok: rows built above
|
||||
return create_anthropic_model_list_response(admin_listing)
|
||||
return create_anthropic_model_list_response(
|
||||
admin_listing,
|
||||
display_names=configured_display_names(admin_entries, llm_router),
|
||||
)
|
||||
|
||||
return dict(
|
||||
data=model_data,
|
||||
|
|
@ -10267,7 +10274,8 @@ 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 = []
|
||||
for response_id, lookup_id in TeamModelNameTranslator.listing_entries(all_models, llm_router, settings):
|
||||
entries: Final = TeamModelNameTranslator.listing_entries(all_models, llm_router, settings)
|
||||
for response_id, lookup_id in entries:
|
||||
model_info = create_model_info_response(
|
||||
model_id=lookup_id,
|
||||
provider="openai",
|
||||
|
|
@ -10280,7 +10288,10 @@ async def model_list(
|
|||
|
||||
if wants_anthropic_format:
|
||||
listing: Final = cast(Sequence[ModelInfoResponse], model_data) # cast-ok: rows built above
|
||||
return create_anthropic_model_list_response(listing)
|
||||
return create_anthropic_model_list_response(
|
||||
listing,
|
||||
display_names=configured_display_names(entries, llm_router),
|
||||
)
|
||||
|
||||
return dict(
|
||||
data=model_data,
|
||||
|
|
|
|||
|
|
@ -9746,6 +9746,26 @@ class Router:
|
|||
coerce_token_limit(model_info.get("max_output_tokens")),
|
||||
)
|
||||
|
||||
def get_configured_display_name(self, model_name: str) -> "str | None":
|
||||
"""
|
||||
Return the display_name explicitly configured in a concrete deployment's
|
||||
model_info for model_name, via O(1) index lookup.
|
||||
|
||||
Returns None for wildcard-expanded or unknown names, and treats a
|
||||
non-string or empty configured value as absent rather than failing the
|
||||
listing. Like get_configured_token_limits, this never triggers pattern
|
||||
matching or deep copies, so it is safe to call per listed model on the
|
||||
/v1/models hot path.
|
||||
"""
|
||||
deployment: Final = self.get_deployment_by_model_group_name(model_group_name=model_name)
|
||||
if deployment is None:
|
||||
return None
|
||||
|
||||
display_name: Final = deployment.model_info.get("display_name")
|
||||
if isinstance(display_name, str) and display_name.strip():
|
||||
return display_name
|
||||
return None
|
||||
|
||||
def get_deployment_credentials_with_provider(
|
||||
self, model_id: str, team_id: str | None = None
|
||||
) -> dict[str, Any] | None:
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ def patched_models(monkeypatch):
|
|||
deployment = MagicMock()
|
||||
deployment.litellm_params.model = "gpt-4"
|
||||
router.get_deployment_by_model_group_name = MagicMock(return_value=deployment)
|
||||
router.get_configured_display_name = MagicMock(return_value=None)
|
||||
|
||||
monkeypatch.setattr(proxy_server, "llm_router", router)
|
||||
monkeypatch.setattr(proxy_server, "prisma_client", MagicMock())
|
||||
|
|
@ -187,6 +188,83 @@ def test_anthropic_format_carries_router_configured_token_limits(client, auth_as
|
|||
assert (claude["max_input_tokens"], claude["max_tokens"]) == (500000, 4096)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", ["/v1/models", "/models"])
|
||||
def test_anthropic_format_uses_configured_display_name(client, auth_as, patched_models, path):
|
||||
"""A deployment's ``model_info.display_name`` becomes the Anthropic-native
|
||||
``display_name`` so Claude Code's picker shows a clean name while the id keeps
|
||||
routing; models without one keep the id fallback, and the OpenAI-shaped
|
||||
listing carries no display_name either way."""
|
||||
|
||||
def _configured(model_name):
|
||||
return "Kimi K3" if model_name == "gpt-4" else None
|
||||
|
||||
patched_models.get_configured_display_name = MagicMock(side_effect=_configured)
|
||||
|
||||
with auth_as():
|
||||
anthropic_response = client.get(path, headers={"anthropic-version": "2023-06-01"})
|
||||
openai_response = client.get(path)
|
||||
|
||||
assert anthropic_response.status_code == 200
|
||||
gpt_4, claude = anthropic_response.json()["data"]
|
||||
assert (gpt_4["id"], gpt_4["display_name"]) == ("gpt-4", "Kimi K3")
|
||||
assert (claude["id"], claude["display_name"]) == ("claude-sonnet", "claude-sonnet")
|
||||
|
||||
assert openai_response.status_code == 200
|
||||
openai_models = openai_response.json()["data"]
|
||||
assert [m["id"] for m in openai_models] == ["gpt-4", "claude-sonnet"]
|
||||
assert all("display_name" not in m for m in openai_models)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("params", [{}, {"scope": "expand"}])
|
||||
def test_anthropic_display_name_resolved_via_internal_team_key(
|
||||
client, auth_as, patched_models, monkeypatch, params
|
||||
):
|
||||
"""For a team-scoped row the configured display name must be looked up by the
|
||||
internal routing key while the entry itself is keyed by the public name, so
|
||||
the clean name lands on the id the client actually sees."""
|
||||
from litellm.proxy import utils as proxy_utils
|
||||
from litellm.proxy.auth import model_checks
|
||||
|
||||
internal_name = "model_name_team-1_c0ffee"
|
||||
|
||||
patched_models.get_model_list = MagicMock(
|
||||
return_value=[
|
||||
{
|
||||
"model_name": internal_name,
|
||||
"model_info": {
|
||||
"team_id": "team-1",
|
||||
"team_public_model_name": "gpt-4-team",
|
||||
},
|
||||
}
|
||||
]
|
||||
)
|
||||
patched_models.get_model_names = MagicMock(return_value=[internal_name])
|
||||
patched_models.get_configured_display_name = MagicMock(
|
||||
side_effect=lambda model_name: "Team GPT" if model_name == internal_name else None
|
||||
)
|
||||
|
||||
async def _fake_get_available_models_for_user(**kwargs):
|
||||
return [internal_name]
|
||||
|
||||
monkeypatch.setattr(
|
||||
proxy_utils,
|
||||
"get_available_models_for_user",
|
||||
_fake_get_available_models_for_user,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
model_checks, "get_complete_model_list", lambda **kwargs: [internal_name]
|
||||
)
|
||||
|
||||
with auth_as():
|
||||
response = client.get(
|
||||
"/v1/models", params=params, headers={"anthropic-version": "2023-06-01"}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
(entry,) = response.json()["data"]
|
||||
assert (entry["id"], entry["display_name"]) == ("gpt-4-team", "Team GPT")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", ["/v1/models", "/models"])
|
||||
def test_get_models_invalid_scope_returns_400(client, auth_as, patched_models, path):
|
||||
"""Pins: ``GET /v1/models``, ``GET /models`` (error path: invalid scope)."""
|
||||
|
|
|
|||
|
|
@ -19,7 +19,10 @@ from litellm.proxy._types import (
|
|||
LitellmUserRoles,
|
||||
UserAPIKeyAuth,
|
||||
)
|
||||
from litellm.proxy.common_utils.model_listing_utils import TeamModelNameTranslator
|
||||
from litellm.proxy.common_utils.model_listing_utils import (
|
||||
TeamModelNameTranslator,
|
||||
configured_display_names,
|
||||
)
|
||||
from litellm.proxy.proxy_server import (
|
||||
_get_proxy_model_info,
|
||||
_translate_model_name_for_response,
|
||||
|
|
@ -1391,6 +1394,27 @@ def test_resolve_public_name_respects_legacy_flag():
|
|||
)
|
||||
|
||||
|
||||
def test_configured_display_names_keyed_by_response_id():
|
||||
"""The map is keyed by the public response id while the router lookup uses
|
||||
the internal routing key, and entries without a configured name are omitted."""
|
||||
router = MagicMock()
|
||||
router.get_configured_display_name = MagicMock(
|
||||
side_effect=lambda model_name: "Team Sonnet" if model_name == "model_name_team-abc-123_4a6b8" else None
|
||||
)
|
||||
|
||||
assert configured_display_names(
|
||||
entries=[
|
||||
("team-claude-sonnet", "model_name_team-abc-123_4a6b8"),
|
||||
("gpt-4o", "gpt-4o"),
|
||||
],
|
||||
llm_router=router,
|
||||
) == {"team-claude-sonnet": "Team Sonnet"}
|
||||
|
||||
|
||||
def test_configured_display_names_empty_without_router():
|
||||
assert configured_display_names(entries=[("gpt-4o", "gpt-4o")], llm_router=None) == {}
|
||||
|
||||
|
||||
@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
|
||||
|
|
|
|||
|
|
@ -7271,6 +7271,71 @@ def test_get_configured_token_limits_coerces_numeric_strings():
|
|||
assert router.get_configured_token_limits("quoted-limits-model") == (32000, 8000)
|
||||
|
||||
|
||||
def test_get_configured_display_name_reads_deployment_model_info():
|
||||
router = litellm.Router(
|
||||
model_list=[
|
||||
{
|
||||
"model_name": "Kimi K3-claude-compatible",
|
||||
"litellm_params": {"model": "openai/some-unmapped-model"},
|
||||
"model_info": {"display_name": "Kimi K3"},
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
assert router.get_configured_display_name("Kimi K3-claude-compatible") == "Kimi K3"
|
||||
|
||||
|
||||
def test_get_configured_display_name_returns_none_for_unset_or_unknown():
|
||||
router = litellm.Router(
|
||||
model_list=[
|
||||
{
|
||||
"model_name": "no-display-model",
|
||||
"litellm_params": {"model": "openai/some-unmapped-model"},
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
assert router.get_configured_display_name("no-display-model") is None
|
||||
assert router.get_configured_display_name("not-a-real-model") is None
|
||||
|
||||
|
||||
def test_get_configured_display_name_skips_wildcard_pattern_matching():
|
||||
router = litellm.Router(
|
||||
model_list=[
|
||||
{
|
||||
"model_name": "bedrock/*",
|
||||
"litellm_params": {"model": "bedrock/*"},
|
||||
"model_info": {"display_name": "Bedrock"},
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
router.pattern_router, "route", side_effect=AssertionError("pattern route called")
|
||||
):
|
||||
assert (
|
||||
router.get_configured_display_name("bedrock/anthropic.claude-3-5-sonnet-20240620-v1:0")
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_get_configured_display_name_treats_malformed_values_as_absent():
|
||||
malformed = ["", " ", 12345, ["Kimi K3"], {"name": "Kimi K3"}, True]
|
||||
router = litellm.Router(
|
||||
model_list=[
|
||||
{
|
||||
"model_name": f"bad-display-{i}",
|
||||
"litellm_params": {"model": "openai/some-unmapped-model"},
|
||||
"model_info": {"display_name": bad},
|
||||
}
|
||||
for i, bad in enumerate(malformed)
|
||||
]
|
||||
)
|
||||
|
||||
for i in range(len(malformed)):
|
||||
assert router.get_configured_display_name(f"bad-display-{i}") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_acreate_batch_disable_fallbacks_surfaces_owning_provider_error():
|
||||
router = litellm.Router(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue