mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-09 03:18:44 +00:00
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:
parent
22ecb4cca7
commit
4e3188525e
4 changed files with 111 additions and 0 deletions
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)."""
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue