Skip to content

Commit e1f3d6e

Browse files
Ar-maan05yuneng-berriyassin-berriai
authored
feat(proxy): serve Anthropic-native /v1/models for Claude Code gateway discovery (#35455)
* 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 * style: apply ruff format to changed lines * style: satisfy ruff strict-rule budget (UP006, I001) * style: satisfy type-discipline budget (LIT002 mutable-ok, LIT009 pyright ignore) * style: satisfy LIT001/LIT010 and drop explanatory comment per contributor rules * fix(proxy): translate team model names in the Anthropic /v1/models response * ci: trigger buildkite status report * feat(proxy): carry token limits into the Anthropic-native /v1/models entries * fix(proxy): cast the injected request so the anthropic-version guard is a real comparison * fix(proxy): explain the model listing casts so the type-discipline gate passes --------- Co-authored-by: yuneng-jiang <yuneng@berri.ai> Co-authored-by: Yassin Kortam <yassin@berri.ai>
1 parent 3d35cff commit e1f3d6e

4 files changed

Lines changed: 239 additions & 0 deletions

File tree

litellm/llms/anthropic/common_utils.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,15 @@
55
import copy
66
import re
77
from collections.abc import Mapping, Sequence
8+
from datetime import datetime, timezone
89
from types import MappingProxyType
910
from typing import Any, Final, Literal
1011

1112
import httpx
1213
from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError
1314

1415
import litellm
16+
from litellm.constants import DEFAULT_MODEL_CREATED_AT_TIME
1517
from litellm.litellm_core_utils.prompt_templates.common_utils import (
1618
get_file_ids_from_messages,
1719
)
@@ -28,6 +30,7 @@
2830
AnthropicMcpServerTool,
2931
)
3032
from litellm.types.llms.openai import AllMessageValues
33+
from litellm.types.proxy.model_listing import ModelInfoResponse
3134

3235
_BEDROCK_VERSION_SUFFIX_RE: Final = re.compile(r"-v\d+(?::\d+)?$")
3336
_INFERENCE_PROFILE_MINOR_RE: Final = re.compile(r":\d+$")
@@ -1221,3 +1224,39 @@ def process_anthropic_headers(headers: httpx.Headers | dict) -> dict:
12211224

12221225
additional_headers: Final = {**llm_response_headers, **openai_headers}
12231226
return additional_headers
1227+
1228+
1229+
def _anthropic_model_entry(model: ModelInfoResponse, created_at: str) -> Mapping[str, object]:
1230+
token_limits: Final = (
1231+
("max_input_tokens", model.get("max_input_tokens")),
1232+
("max_tokens", model.get("max_output_tokens")),
1233+
)
1234+
return { # mutable-ok: JSON response body, serialized by the route and never mutated
1235+
"type": "model",
1236+
"id": model["id"],
1237+
"display_name": model["id"],
1238+
"created_at": created_at,
1239+
**{name: limit for name, limit in token_limits if limit is not None}, # mutable-ok: merged into the body above
1240+
}
1241+
1242+
1243+
def create_anthropic_model_list_response(models: Sequence[ModelInfoResponse]) -> Mapping[str, object]:
1244+
"""Build the Anthropic-native /v1/models envelope.
1245+
1246+
Clients that send an anthropic-version header parse the Anthropic Models API
1247+
shape (type/display_name/created_at plus has_more/first_id/last_id) and filter
1248+
the list themselves, so every model is returned here. The token limits carry
1249+
over from the OpenAI-shaped listing, named as the Messages API names them
1250+
"""
1251+
created_at: Final = (
1252+
datetime.fromtimestamp(DEFAULT_MODEL_CREATED_AT_TIME, tz=timezone.utc).isoformat().replace("+00:00", "Z")
1253+
)
1254+
data: Final = [ # mutable-ok: JSON response body, serialized by the route and never mutated
1255+
_anthropic_model_entry(model, created_at) for model in models
1256+
]
1257+
return { # mutable-ok: JSON response body, serialized by the route and never mutated
1258+
"data": data,
1259+
"has_more": False,
1260+
"first_id": models[0]["id"] if models else None,
1261+
"last_id": models[-1]["id"] if models else None,
1262+
}

litellm/proxy/proxy_server.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9493,6 +9493,7 @@ def _init_pyroscope(cls):
94939493
"/models", dependencies=[Depends(user_api_key_auth)], tags=["model management"]
94949494
) # if project requires model list
94959495
async def model_list(
9496+
request: Request = None, # pyright: ignore[reportArgumentType] # FastAPI always injects the Request; the None default only serves direct in-process callers
94969497
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
94979498
return_wildcard_routes: bool | None = False,
94989499
team_id: str | None = None,
@@ -9529,13 +9530,22 @@ async def model_list(
95299530

95309531
settings: Final = cast(dict[str, object], general_settings) # any-ok: legacy settings
95319532

9533+
from litellm.llms.anthropic.common_utils import (
9534+
create_anthropic_model_list_response,
9535+
)
95329536
from litellm.proxy.management_endpoints.common_utils import (
95339537
_user_has_admin_privileges,
95349538
)
95359539
from litellm.proxy.utils import (
95369540
create_model_info_response,
95379541
get_available_models_for_user,
95389542
)
9543+
from litellm.types.proxy.model_listing import ModelInfoResponse
9544+
9545+
http_request: Final = cast(Request | None, request) # cast-ok: in-process callers pass no request
9546+
wants_anthropic_format: Final = (
9547+
http_request is not None and http_request.headers.get("anthropic-version") is not None
9548+
)
95399549

95409550
# Validate scope parameter if provided
95419551
if scope is not None and scope != "expand":
@@ -9619,6 +9629,10 @@ async def model_list(
96199629
model_info["id"] = response_id
96209630
model_data.append(model_info)
96219631

9632+
if wants_anthropic_format:
9633+
admin_listing: Final = cast(Sequence[ModelInfoResponse], model_data) # cast-ok: rows built above
9634+
return create_anthropic_model_list_response(admin_listing)
9635+
96229636
return dict(
96239637
data=model_data,
96249638
object="list",
@@ -9659,6 +9673,10 @@ async def model_list(
96599673
model_info["id"] = response_id
96609674
model_data.append(model_info)
96619675

9676+
if wants_anthropic_format:
9677+
listing: Final = cast(Sequence[ModelInfoResponse], model_data) # cast-ok: rows built above
9678+
return create_anthropic_model_list_response(listing)
9679+
96629680
return dict(
96639681
data=model_data,
96649682
object="list",

tests/test_litellm/llms/anthropic/test_anthropic_common_utils.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2028,3 +2028,82 @@ def test_native_anthropic_probe_still_reads_anthropic_entry(
20282028
AnthropicModelInfo._is_adaptive_thinking_model("claude-opus-4-8", "anthropic")
20292029
is True
20302030
)
2031+
def test_create_anthropic_model_list_response_shape():
2032+
from litellm.llms.anthropic.common_utils import (
2033+
create_anthropic_model_list_response,
2034+
)
2035+
2036+
response = create_anthropic_model_list_response(
2037+
[
2038+
{"id": "claude-opus-4-6", "object": "model", "created": 0, "owned_by": "openai"},
2039+
{"id": "gpt-4o", "object": "model", "created": 0, "owned_by": "openai"},
2040+
{"id": "claude-haiku-4-5", "object": "model", "created": 0, "owned_by": "openai"},
2041+
]
2042+
)
2043+
2044+
assert "object" not in response
2045+
assert response["has_more"] is False
2046+
assert response["first_id"] == "claude-opus-4-6"
2047+
assert response["last_id"] == "claude-haiku-4-5"
2048+
assert [m["id"] for m in response["data"]] == [
2049+
"claude-opus-4-6",
2050+
"gpt-4o",
2051+
"claude-haiku-4-5",
2052+
]
2053+
for entry in response["data"]:
2054+
assert entry["type"] == "model"
2055+
assert entry["display_name"] == entry["id"]
2056+
# ISO 8601 with a Z suffix, as the Anthropic Models API returns.
2057+
assert entry["created_at"].endswith("Z")
2058+
assert "+00:00" not in entry["created_at"]
2059+
assert "max_input_tokens" not in entry
2060+
assert "max_tokens" not in entry
2061+
2062+
2063+
def test_create_anthropic_model_list_response_carries_token_limits():
2064+
from litellm.llms.anthropic.common_utils import (
2065+
create_anthropic_model_list_response,
2066+
)
2067+
2068+
response = create_anthropic_model_list_response(
2069+
[
2070+
{
2071+
"id": "claude-opus-4-6",
2072+
"object": "model",
2073+
"created": 0,
2074+
"owned_by": "openai",
2075+
"max_input_tokens": 200000,
2076+
"max_output_tokens": 64000,
2077+
},
2078+
{
2079+
"id": "input-only",
2080+
"object": "model",
2081+
"created": 0,
2082+
"owned_by": "openai",
2083+
"max_input_tokens": 8192,
2084+
},
2085+
{"id": "unknown-limits", "object": "model", "created": 0, "owned_by": "openai"},
2086+
]
2087+
)
2088+
2089+
opus, input_only, unknown = response["data"]
2090+
assert opus["max_input_tokens"] == 200000
2091+
assert opus["max_tokens"] == 64000
2092+
assert "max_output_tokens" not in opus
2093+
assert input_only["max_input_tokens"] == 8192
2094+
assert "max_tokens" not in input_only
2095+
assert "max_input_tokens" not in unknown
2096+
assert "max_tokens" not in unknown
2097+
2098+
2099+
def test_create_anthropic_model_list_response_empty():
2100+
from litellm.llms.anthropic.common_utils import (
2101+
create_anthropic_model_list_response,
2102+
)
2103+
2104+
response = create_anthropic_model_list_response([])
2105+
2106+
assert response["data"] == []
2107+
assert response["has_more"] is False
2108+
assert response["first_id"] is None
2109+
assert response["last_id"] is None

tests/test_litellm/proxy/proxy_server/test_routes_models.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,62 @@ def test_get_models_happy_path(client, auth_as, patched_models, path):
9999
}
100100

101101

102+
@pytest.mark.parametrize("path", ["/v1/models", "/models"])
103+
def test_get_models_anthropic_format_when_header_present(
104+
client, auth_as, patched_models, path
105+
):
106+
"""Pins: ``GET /v1/models`` returns the Anthropic-native models shape when
107+
the caller sends an ``anthropic-version`` header (Claude Code gateway
108+
discovery), while the default OpenAI shape is unchanged without it."""
109+
with auth_as():
110+
response = client.get(path, headers={"anthropic-version": "2023-06-01"})
111+
assert response.status_code == 200
112+
body = response.json()
113+
assert "object" not in body
114+
assert body["has_more"] is False
115+
assert body["first_id"] == "gpt-4"
116+
assert body["last_id"] == "claude-sonnet"
117+
assert [m["id"] for m in body["data"]] == ["gpt-4", "claude-sonnet"]
118+
for entry in body["data"]:
119+
assert entry["type"] == "model"
120+
assert entry["display_name"] == entry["id"]
121+
assert entry["created_at"].endswith("Z")
122+
123+
124+
@pytest.mark.parametrize("path", ["/v1/models", "/models"])
125+
def test_anthropic_format_exposes_token_limits(
126+
client, auth_as, patched_models, monkeypatch, path
127+
):
128+
"""Claude Code sizes requests off the listing, so the Anthropic-native entries
129+
carry the same token limits the OpenAI listing resolves, with the output budget
130+
named max_tokens as the Messages API names it."""
131+
from litellm.proxy import utils as proxy_utils
132+
133+
def _create_model_info_response(model_id, provider="openai", **kwargs):
134+
if model_id != "claude-sonnet":
135+
return _stub_model_info_response(model_id=model_id, provider=provider)
136+
return {
137+
**_stub_model_info_response(model_id=model_id, provider=provider),
138+
"max_input_tokens": 200000,
139+
"max_output_tokens": 64000,
140+
}
141+
142+
monkeypatch.setattr(
143+
proxy_utils, "create_model_info_response", _create_model_info_response
144+
)
145+
146+
with auth_as():
147+
response = client.get(path, headers={"anthropic-version": "2023-06-01"})
148+
149+
assert response.status_code == 200
150+
gpt_4, claude = response.json()["data"]
151+
assert claude["max_input_tokens"] == 200000
152+
assert claude["max_tokens"] == 64000
153+
assert "max_output_tokens" not in claude
154+
assert "max_input_tokens" not in gpt_4
155+
assert "max_tokens" not in gpt_4
156+
157+
102158
@pytest.mark.parametrize("path", ["/v1/models", "/models"])
103159
def test_get_models_invalid_scope_returns_400(client, auth_as, patched_models, path):
104160
"""Pins: ``GET /v1/models``, ``GET /models`` (error path: invalid scope)."""
@@ -130,3 +186,50 @@ def test_get_model_by_id_not_found(client, auth_as, patched_models, path):
130186
response = client.get(path)
131187
assert response.status_code == 404
132188
assert "not found" in response.text.lower()
189+
190+
191+
@pytest.mark.parametrize("params", [{}, {"scope": "expand"}])
192+
def test_anthropic_format_returns_public_team_model_name(
193+
client, auth_as, patched_models, monkeypatch, params
194+
):
195+
"""Regression: the Anthropic-native listing must go through the same team
196+
name translation as the OpenAI listing, so a caller never sees the internal
197+
``model_name_{team_id}_{uuid}`` routing key."""
198+
from litellm.proxy import utils as proxy_utils
199+
from litellm.proxy.auth import model_checks
200+
201+
internal_name = "model_name_team-1_c0ffee"
202+
203+
patched_models.get_model_list = MagicMock(
204+
return_value=[
205+
{
206+
"model_name": internal_name,
207+
"model_info": {
208+
"team_id": "team-1",
209+
"team_public_model_name": "gpt-4-team",
210+
},
211+
}
212+
]
213+
)
214+
patched_models.get_model_names = MagicMock(return_value=[internal_name])
215+
216+
async def _fake_get_available_models_for_user(**kwargs):
217+
return [internal_name]
218+
219+
monkeypatch.setattr(
220+
proxy_utils,
221+
"get_available_models_for_user",
222+
_fake_get_available_models_for_user,
223+
)
224+
monkeypatch.setattr(
225+
model_checks, "get_complete_model_list", lambda **kwargs: [internal_name]
226+
)
227+
228+
with auth_as():
229+
response = client.get(
230+
"/v1/models", params=params, headers={"anthropic-version": "2023-06-01"}
231+
)
232+
233+
assert response.status_code == 200
234+
assert [m["id"] for m in response.json()["data"]] == ["gpt-4-team"]
235+
assert internal_name not in response.text

0 commit comments

Comments
 (0)