feat(proxy): serve Anthropic-native /v1/models for Claude Code gateway discovery (#30273)

* feat(proxy): serve Anthropic-native /v1/models for Claude Code gateway discovery

* refactor(proxy): move Anthropic model-list formatter into llms/anthropic/common_utils

* fix(proxy): make model_list request param optional for direct callers
This commit is contained in:
Armaan Sandhu 2026-06-17 16:53:21 +05:30 • committed by GitHub
parent 22ecb4cca7
commit 4e3188525e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 111 additions and 0 deletions

View file

@ -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,
}

View file

@ -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:

View file

@ -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

View file

@ -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)."""