diff --git a/litellm/llms/anthropic/common_utils.py b/litellm/llms/anthropic/common_utils.py index 5741513903c..329789616b3 100644 --- a/litellm/llms/anthropic/common_utils.py +++ b/litellm/llms/anthropic/common_utils.py @@ -3,11 +3,13 @@ This file contains common utils for anthropic calls. """ import copy +from datetime import datetime, timezone from typing import Any, Dict, List, Optional, Union import httpx import litellm +from litellm.constants import DEFAULT_MODEL_CREATED_AT_TIME from litellm.litellm_core_utils.prompt_templates.common_utils import ( get_file_ids_from_messages, ) @@ -1014,3 +1016,35 @@ def process_anthropic_headers(headers: Union[httpx.Headers, dict]) -> dict: additional_headers = {**llm_response_headers, **openai_headers} return additional_headers + + +def create_anthropic_model_list_response(model_ids: List[str]) -> dict: + """Build the Anthropic-native /v1/models envelope for Claude Code gateway discovery. + + Claude Code 2.1.126+ queries the gateway root's /v1/models with an + anthropic-version header and only parses the Anthropic Models API shape + (type/display_name/created_at plus has_more/first_id/last_id). It keeps + models whose id starts with claude/anthropic, so the full list is returned + here and the client filters. display_name falls back to the id, which is + the stable label a gateway can offer for arbitrary upstream models. + """ + created_at = ( + datetime.fromtimestamp(DEFAULT_MODEL_CREATED_AT_TIME, tz=timezone.utc) + .isoformat() + .replace("+00:00", "Z") + ) + data = [ + { + "type": "model", + "id": model_id, + "display_name": model_id, + "created_at": created_at, + } + for model_id in model_ids + ] + return { + "data": data, + "has_more": False, + "first_id": model_ids[0] if model_ids else None, + "last_id": model_ids[-1] if model_ids else None, + } diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 873831af833..cfe3909daf2 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -8342,6 +8342,7 @@ class ProxyStartupEvent: "/models", dependencies=[Depends(user_api_key_auth)], tags=["model management"] ) # if project requires model list async def model_list( + request: Request = None, # type: ignore[assignment] user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), return_wildcard_routes: Optional[bool] = False, team_id: Optional[str] = None, @@ -8379,11 +8380,20 @@ async def model_list( from litellm.proxy.management_endpoints.common_utils import ( _user_has_admin_privileges, ) + from litellm.llms.anthropic.common_utils import ( + create_anthropic_model_list_response, + ) from litellm.proxy.utils import ( create_model_info_response, get_available_models_for_user, ) + # Claude Code's gateway discovery sends the same anthropic-version header it + # uses for /v1/messages and only parses the Anthropic-native models shape. + wants_anthropic_format = ( + request is not None and request.headers.get("anthropic-version") is not None + ) + # Validate scope parameter if provided if scope is not None and scope != "expand": raise HTTPException( @@ -8455,6 +8465,9 @@ async def model_list( if hidden_names: all_models = [m for m in all_models if m not in hidden_names] + if wants_anthropic_format: + return create_anthropic_model_list_response(all_models) + # Build response data with all proxy models model_data = [] for model in all_models: @@ -8492,6 +8505,9 @@ async def model_list( if hidden_names: all_models = [m for m in all_models if m not in hidden_names] + if wants_anthropic_format: + return create_anthropic_model_list_response(all_models) + # Build response data model_data = [] for model in all_models: diff --git a/tests/test_litellm/llms/anthropic/test_anthropic_common_utils.py b/tests/test_litellm/llms/anthropic/test_anthropic_common_utils.py index a09e55d4ed7..c437c6a8938 100644 --- a/tests/test_litellm/llms/anthropic/test_anthropic_common_utils.py +++ b/tests/test_litellm/llms/anthropic/test_anthropic_common_utils.py @@ -1450,3 +1450,42 @@ class TestClaudeOpus48AdaptiveThinking: from litellm.llms.anthropic.common_utils import AnthropicModelInfo assert AnthropicModelInfo._is_adaptive_thinking_model(model) is False + + +def test_create_anthropic_model_list_response_shape(): + from litellm.llms.anthropic.common_utils import ( + create_anthropic_model_list_response, + ) + + response = create_anthropic_model_list_response( + ["claude-opus-4-6", "gpt-4o", "claude-haiku-4-5"] + ) + + assert "object" not in response + assert response["has_more"] is False + assert response["first_id"] == "claude-opus-4-6" + assert response["last_id"] == "claude-haiku-4-5" + assert [m["id"] for m in response["data"]] == [ + "claude-opus-4-6", + "gpt-4o", + "claude-haiku-4-5", + ] + for entry in response["data"]: + assert entry["type"] == "model" + assert entry["display_name"] == entry["id"] + # ISO 8601 with a Z suffix, as the Anthropic Models API returns. + assert entry["created_at"].endswith("Z") + assert "+00:00" not in entry["created_at"] + + +def test_create_anthropic_model_list_response_empty(): + from litellm.llms.anthropic.common_utils import ( + create_anthropic_model_list_response, + ) + + response = create_anthropic_model_list_response([]) + + assert response["data"] == [] + assert response["has_more"] is False + assert response["first_id"] is None + assert response["last_id"] is None diff --git a/tests/test_litellm/proxy/proxy_server/test_routes_models.py b/tests/test_litellm/proxy/proxy_server/test_routes_models.py index 381835fbc14..2cd83c37e4b 100644 --- a/tests/test_litellm/proxy/proxy_server/test_routes_models.py +++ b/tests/test_litellm/proxy/proxy_server/test_routes_models.py @@ -99,6 +99,28 @@ def test_get_models_happy_path(client, auth_as, patched_models, path): } +@pytest.mark.parametrize("path", ["/v1/models", "/models"]) +def test_get_models_anthropic_format_when_header_present( + client, auth_as, patched_models, path +): + """Pins: ``GET /v1/models`` returns the Anthropic-native models shape when + the caller sends an ``anthropic-version`` header (Claude Code gateway + discovery), while the default OpenAI shape is unchanged without it.""" + with auth_as(): + response = client.get(path, headers={"anthropic-version": "2023-06-01"}) + assert response.status_code == 200 + body = response.json() + assert "object" not in body + assert body["has_more"] is False + assert body["first_id"] == "gpt-4" + assert body["last_id"] == "claude-sonnet" + assert [m["id"] for m in body["data"]] == ["gpt-4", "claude-sonnet"] + for entry in body["data"]: + assert entry["type"] == "model" + assert entry["display_name"] == entry["id"] + assert entry["created_at"].endswith("Z") + + @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)."""