From 15c67842e06946e4a4499dbf4fd1af50391947b2 Mon Sep 17 00:00:00 2001 From: Jerry Reptak Date: Wed, 28 Oct 2026 09:04:31 -0400 Subject: [PATCH] feat(proxy): include model_info.description in Anthropic-format /v1/models Claude Code reads an optional description for each entry from /v1/models and shows it in the /model picker, otherwise it falls back to From gateway. The Anthropic-format listing only returned display_name, so the description set in model_info never reached the picker. Emit it when a non-empty string is set and omit the key otherwise --- litellm/llms/anthropic/common_utils.py | 16 +++++++-- .../proxy/common_utils/model_listing_utils.py | 16 +++++++++ litellm/proxy/proxy_server.py | 3 ++ litellm/router.py | 15 ++++++++ .../anthropic/test_anthropic_common_utils.py | 18 ++++++++++ .../proxy/proxy_server/test_routes_models.py | 17 ++++++++++ .../test_team_model_name_translation.py | 22 ++++++++++++ tests/unit/test_router/test_router.py | 34 +++++++++++++++++++ 8 files changed, 138 insertions(+), 3 deletions(-) diff --git a/litellm/llms/anthropic/common_utils.py b/litellm/llms/anthropic/common_utils.py index 1f5e4e7de39..b5d5fee877b 100644 --- a/litellm/llms/anthropic/common_utils.py +++ b/litellm/llms/anthropic/common_utils.py @@ -2240,7 +2240,11 @@ def process_anthropic_headers(headers: httpx.Headers | dict) -> dict: def _anthropic_model_entry( - model: ModelInfoResponse, created_at: str, display_names: Mapping[str, str], listed_ids: Mapping[str, str] + model: ModelInfoResponse, + created_at: str, + display_names: Mapping[str, str], + listed_ids: Mapping[str, str], + descriptions: Mapping[str, str], ) -> Mapping[str, object]: listed_id: Final = listed_ids.get(model["id"]) source: Final[Mapping[str, object]] = ( @@ -2254,6 +2258,7 @@ def _anthropic_model_entry( "created_at": created_at, "max_input_tokens": model.get("max_input_tokens"), "max_tokens": model.get("max_output_tokens"), + **({"description": descriptions[model["id"]]} if model["id"] in descriptions else {}), } @@ -2261,6 +2266,7 @@ def create_anthropic_model_list_response( models: Sequence[ModelInfoResponse], display_names: Mapping[str, str] = MappingProxyType({}), listed_ids: Mapping[str, str] = MappingProxyType({}), + descriptions: Mapping[str, str] = MappingProxyType({}), ) -> Mapping[str, object]: """Build the Anthropic-native /v1/models envelope. @@ -2272,12 +2278,16 @@ def create_anthropic_model_list_response( 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. listed_ids maps a model id to the id the caller should see it under (the Claude - Code view); ids without an entry are listed as they are + Code view); ids without an entry are listed as they are. + descriptions maps a listed model id to its configured description, which Claude + Code shows in its model picker; ids without an entry omit the key. """ created_at: Final = ( datetime.fromtimestamp(DEFAULT_MODEL_CREATED_AT_TIME, tz=timezone.utc).isoformat().replace("+00:00", "Z") ) - data: Final = [_anthropic_model_entry(model, created_at, display_names, listed_ids) for model in models] + data: Final = [ + _anthropic_model_entry(model, created_at, display_names, listed_ids, descriptions) for model in models + ] return { "data": data, "has_more": False, diff --git a/litellm/proxy/common_utils/model_listing_utils.py b/litellm/proxy/common_utils/model_listing_utils.py index 175432d1dc1..5058456d191 100644 --- a/litellm/proxy/common_utils/model_listing_utils.py +++ b/litellm/proxy/common_utils/model_listing_utils.py @@ -56,6 +56,22 @@ def configured_display_names( ) +def configured_descriptions( + entries: Sequence[tuple[str, str]], + llm_router: Router | None, +) -> Mapping[str, str]: + """response_id -> configured `model_info.description` for the listing entries + that have one, keyed and looked up the same way as `configured_display_names`.""" + if llm_router is None: + return MappingProxyType({}) + resolved: Final = ( + (response_id, llm_router.get_configured_description(lookup_id)) for response_id, lookup_id in entries + ) + return MappingProxyType( + {response_id: description for response_id, description in resolved if description is not None} + ) + + def _unmarked(name: str) -> str: return name[: -len(_ONE_MILLION_SUFFIX)] if name.lower().endswith(_ONE_MILLION_SUFFIX) else name diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index bcba76ca1b3..b1d1c957c30 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -514,6 +514,7 @@ from litellm.proxy.common_utils.model_listing_utils import ( alias_target, caller_alias_maps, claude_code_view_ids, + configured_descriptions, configured_display_names, label_entries, ) @@ -12448,6 +12449,7 @@ async def model_list( return create_anthropic_model_list_response( admin_listing, display_names=configured_display_names(admin_entries, llm_router), + descriptions=configured_descriptions(admin_entries, llm_router), listed_ids=claude_code_view_ids( admin_listing, client_headers, @@ -12517,6 +12519,7 @@ async def model_list( return create_anthropic_model_list_response( listing, display_names=configured_display_names(labels, llm_router), + descriptions=configured_descriptions(labels, llm_router), listed_ids=claude_code_view_ids( listing, client_headers, diff --git a/litellm/router.py b/litellm/router.py index 0d4045e6ca8..557b8013a9b 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -12021,6 +12021,21 @@ class Router: return display_name return None + def get_configured_description(self, model_name: str) -> "str | None": + """ + Return the description explicitly configured in a concrete deployment's + model_info for model_name; same lookup and absent-value rules as + get_configured_display_name. + """ + deployment: Final = self.get_deployment_by_model_group_name(model_group_name=model_name) + if deployment is None: + return None + + description: Final = deployment.model_info.get("description") # pyright: ignore[reportUnknownMemberType] # model_info keeps user-set extras untyped, like display_name + if isinstance(description, str) and description.strip(): + return description + return None + def routable_model_group(self, model_name: str) -> str: """ The model group a request to model_name routes to: its target when diff --git a/tests/unit/llms/anthropic/test_anthropic_common_utils.py b/tests/unit/llms/anthropic/test_anthropic_common_utils.py index eb5c1598c17..555e3f2ecbc 100644 --- a/tests/unit/llms/anthropic/test_anthropic_common_utils.py +++ b/tests/unit/llms/anthropic/test_anthropic_common_utils.py @@ -4086,6 +4086,24 @@ def test_create_anthropic_model_list_response_lists_ids_as_told(): assert (response["first_id"], response["last_id"]) == ("claude-router-gpt-4o[1m]", "claude-haiku-4-5") +def test_create_anthropic_model_list_response_includes_description_only_when_configured(): + from litellm.llms.anthropic.common_utils import ( + create_anthropic_model_list_response, + ) + + response = create_anthropic_model_list_response( + [ + {"id": "gpt-4o", "object": "model", "created": 0, "owned_by": "openai"}, + {"id": "claude-haiku-4-5", "object": "model", "created": 0, "owned_by": "openai"}, + ], + descriptions={"gpt-4o": "Opus Level - Agentic coding"}, + ) + + gpt, haiku = response["data"] + assert gpt["description"] == "Opus Level - Agentic coding" + assert "description" not in haiku + + class TestMalformedContentListItems: @pytest.mark.parametrize( "content", diff --git a/tests/unit/proxy/proxy_server/test_routes_models.py b/tests/unit/proxy/proxy_server/test_routes_models.py index 7be30c61f0c..4e3cafacccc 100644 --- a/tests/unit/proxy/proxy_server/test_routes_models.py +++ b/tests/unit/proxy/proxy_server/test_routes_models.py @@ -48,6 +48,7 @@ def patched_models(monkeypatch): router.get_deployment_by_model_group_name = MagicMock(return_value=deployment) router.get_routable_upstream_model = MagicMock(return_value="gpt-4") router.get_configured_display_name = MagicMock(return_value=None) + router.get_configured_description = MagicMock(return_value=None) router.get_configured_service_tiers = MagicMock(return_value=()) monkeypatch.setattr(proxy_server, "llm_router", router) @@ -194,6 +195,22 @@ 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_includes_configured_description(client, auth_as, patched_models, path): + """A deployment's ``model_info.description`` becomes the Anthropic-native + ``description`` Claude Code's picker shows; models without one omit the key.""" + patched_models.get_configured_description = MagicMock( + side_effect=lambda model_name: "Opus Level" if model_name == "gpt-4" else None + ) + + with auth_as(): + response = client.get(path, headers={"anthropic-version": "2023-06-01"}) + + gpt_4, claude = response.json()["data"] + assert gpt_4["description"] == "Opus Level" + assert "description" not in claude + + @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 diff --git a/tests/unit/proxy/proxy_server/test_team_model_name_translation.py b/tests/unit/proxy/proxy_server/test_team_model_name_translation.py index 679959b5728..3bcbf81db3e 100644 --- a/tests/unit/proxy/proxy_server/test_team_model_name_translation.py +++ b/tests/unit/proxy/proxy_server/test_team_model_name_translation.py @@ -22,6 +22,7 @@ from litellm.proxy._types import ( ) from litellm.proxy.common_utils.model_listing_utils import ( TeamModelNameTranslator, + configured_descriptions, configured_display_names, ) from litellm.proxy.proxy_server import ( @@ -1514,6 +1515,27 @@ def test_configured_display_names_empty_without_router(): assert configured_display_names(entries=[("gpt-4o", "gpt-4o")], llm_router=None) == {} +def test_configured_descriptions_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 description are omitted.""" + router = MagicMock() + router.get_configured_description = MagicMock( + side_effect=lambda model_name: "Team Sonnet notes" if model_name == "model_name_team-abc-123_4a6b8" else None + ) + + assert configured_descriptions( + entries=[ + ("team-claude-sonnet", "model_name_team-abc-123_4a6b8"), + ("gpt-4o", "gpt-4o"), + ], + llm_router=router, + ) == {"team-claude-sonnet": "Team Sonnet notes"} + + +def test_configured_descriptions_empty_without_router(): + assert configured_descriptions(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 diff --git a/tests/unit/test_router/test_router.py b/tests/unit/test_router/test_router.py index e2ee80b7874..51d000c0455 100644 --- a/tests/unit/test_router/test_router.py +++ b/tests/unit/test_router/test_router.py @@ -12859,6 +12859,40 @@ def test_get_configured_display_name_returns_none_for_unset_or_unknown(): assert router.get_configured_display_name("not-a-real-model") is None +def test_get_configured_description_reads_deployment_model_info(): + router = litellm.Router( + model_list=[ + { + "model_name": "Kimi K3-claude-compatible", + "litellm_params": {"model": "openai/some-unmapped-model"}, + "model_info": {"description": "Open-weight agentic coding model"}, + } + ] + ) + + assert router.get_configured_description("Kimi K3-claude-compatible") == "Open-weight agentic coding model" + + +def test_get_configured_description_returns_none_for_unset_blank_or_unknown(): + router = litellm.Router( + model_list=[ + { + "model_name": "no-description-model", + "litellm_params": {"model": "openai/some-unmapped-model"}, + }, + { + "model_name": "blank-description-model", + "litellm_params": {"model": "openai/some-unmapped-model"}, + "model_info": {"description": " "}, + }, + ] + ) + + assert router.get_configured_description("no-description-model") is None + assert router.get_configured_description("blank-description-model") is None + assert router.get_configured_description("not-a-real-model") is None + + def test_get_configured_display_name_skips_wildcard_pattern_matching(): router = litellm.Router( model_list=[