Port from OpenHands/OpenHands#16758: Anthropic model catalogs no longer stop at the first page
Anthropic's /v1/models is cursor-paginated with a default page size of 20. Both hermes fetchers read a single unpaginated page, so any model past the first page silently vanished from the /model picker and provider catalogs. - hermes_cli/models.py _fetch_anthropic_models(): request limit=1000 and follow has_more/last_id (bounded, repeated-cursor guarded, de-duped) - plugins/model-providers/anthropic fetch_models(): same pagination walk, and it now honors the base_url argument instead of hardcoding api.anthropic.com - tests: live-HTTP paginated-server regression tests for both fetchers, incl. single-page and stuck-cursor termination; updated the two URL-pinning pool-discovery tests for the ?limit=1000 contract
This commit is contained in:
@@ -795,9 +795,31 @@ def _base_url_looks_like_anthropic_messages(base_url: str) -> bool:
|
||||
return urllib.parse.urlparse(normalized).path.rstrip("/").endswith(("/anthropic", "/anthropic/v1"))
|
||||
|
||||
|
||||
def _anthropic_models_url(base_url: Optional[str] = None) -> str:
|
||||
def _anthropic_models_url(base_url: Optional[str] = None, *, after_id: Optional[str] = None) -> str:
|
||||
"""Anthropic ``/v1/models`` page URL. The endpoint is cursor-paginated with a default page of
|
||||
20 (smaller than the live catalog), so every request asks for the maximum page size and
|
||||
``after_id`` continues from a previous page's ``last_id``."""
|
||||
endpoint = str(base_url or "https://api.anthropic.com").strip().rstrip("/")
|
||||
return endpoint + ("/models" if endpoint.endswith("/v1") else "/v1/models")
|
||||
url = endpoint + ("/models" if endpoint.endswith("/v1") else "/v1/models")
|
||||
params = {"limit": "1000"}
|
||||
if after_id:
|
||||
params["after_id"] = after_id
|
||||
return url + ("&" if "?" in url else "?") + urllib.parse.urlencode(params)
|
||||
|
||||
|
||||
_ANTHROPIC_MODELS_MAX_PAGES = 20
|
||||
|
||||
|
||||
def _anthropic_next_cursor(page: Any, seen_cursors: set[str]) -> Optional[str]:
|
||||
"""``last_id`` to continue from, or None when the page is final or the server repeats a
|
||||
cursor (which would otherwise loop forever)."""
|
||||
if not isinstance(page, dict) or page.get("has_more") is not True:
|
||||
return None
|
||||
last_id = page.get("last_id")
|
||||
if not isinstance(last_id, str) or not last_id or last_id in seen_cursors:
|
||||
return None
|
||||
seen_cursors.add(last_id)
|
||||
return last_id
|
||||
|
||||
|
||||
def curated_models_for_provider(
|
||||
@@ -1827,6 +1849,14 @@ def _fetch_anthropic_models(
|
||||
)
|
||||
data = _get_json(url, timeout=timeout, headers=headers)
|
||||
models = [m["id"] for m in data.get("data", []) if m.get("id")]
|
||||
seen_cursors: set[str] = set()
|
||||
for _page in range(_ANTHROPIC_MODELS_MAX_PAGES):
|
||||
cursor = _anthropic_next_cursor(data, seen_cursors)
|
||||
if cursor is None:
|
||||
break
|
||||
data = _get_json(_anthropic_models_url(resolved_base_url, after_id=cursor), timeout=timeout, headers=headers)
|
||||
models.extend(m["id"] for m in data.get("data", []) if m.get("id"))
|
||||
models = list(dict.fromkeys(models))
|
||||
# opus, then sonnet, then haiku; alphabetical within tier.
|
||||
return sorted(models, key=lambda m: ("opus" not in m, "sonnet" not in m, "haiku" not in m, m))
|
||||
except Exception as e:
|
||||
|
||||
@@ -17,16 +17,30 @@ class AnthropicProfile(ProviderProfile):
|
||||
def fetch_models(
|
||||
self, *, api_key: str | None = None, base_url: str | None = None, timeout: float = 8.0
|
||||
) -> list[str] | None:
|
||||
"""Anthropic uses x-api-key header and anthropic-version."""
|
||||
"""Anthropic uses x-api-key header and anthropic-version. ``/v1/models`` is cursor-paginated
|
||||
(default page 20, smaller than the live catalog), so follow ``has_more``/``last_id``."""
|
||||
if not api_key:
|
||||
return None
|
||||
try:
|
||||
req = urllib.request.Request("https://api.anthropic.com/v1/models")
|
||||
from hermes_cli.models import _ANTHROPIC_MODELS_MAX_PAGES, _anthropic_models_url, _anthropic_next_cursor
|
||||
|
||||
def _page(after_id: str | None):
|
||||
req = urllib.request.Request(_anthropic_models_url(base_url, after_id=after_id))
|
||||
for k, v in (("x-api-key", api_key), ("anthropic-version", "2023-06-01"), ("Accept", "application/json")):
|
||||
req.add_header(k, v)
|
||||
with open_credentialed_url(req, timeout=timeout) as resp:
|
||||
data = json.loads(resp.read().decode())
|
||||
return [m["id"] for m in data.get("data", []) if isinstance(m, dict) and "id" in m]
|
||||
return json.loads(resp.read().decode())
|
||||
|
||||
try:
|
||||
models: list[str] = []
|
||||
seen_cursors: set[str] = set()
|
||||
cursor: str | None = None
|
||||
for _ in range(_ANTHROPIC_MODELS_MAX_PAGES):
|
||||
data = _page(cursor)
|
||||
models.extend(m["id"] for m in data.get("data", []) if isinstance(m, dict) and "id" in m)
|
||||
cursor = _anthropic_next_cursor(data, seen_cursors)
|
||||
if cursor is None:
|
||||
break
|
||||
return list(dict.fromkeys(models))
|
||||
except Exception as exc:
|
||||
logger.debug("fetch_models(anthropic): %s", exc)
|
||||
return None
|
||||
|
||||
116
tests/hermes_cli/test_anthropic_models_pagination.py
Normal file
116
tests/hermes_cli/test_anthropic_models_pagination.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""Anthropic /v1/models cursor pagination — regression tests.
|
||||
|
||||
The endpoint defaults to a 20-item page and signals continuation via
|
||||
``has_more``/``last_id``/``after_id``. An unpaginated read silently drops
|
||||
every model past the first page (bug class ported from
|
||||
OpenHands/OpenHands#16758). Covers ``_fetch_anthropic_models`` and the
|
||||
anthropic provider-plugin ``fetch_models``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli.models import _fetch_anthropic_models
|
||||
|
||||
MODELS = [f"claude-fake-{i:03d}" for i in range(55)]
|
||||
|
||||
|
||||
class _PagedHandler(BaseHTTPRequestHandler):
|
||||
page_cap = 20 # server-side max page size (forces pagination even at limit=1000)
|
||||
repeat_cursor = False # simulate a buggy server that never advances
|
||||
|
||||
def log_message(self, *args): # noqa: D102
|
||||
pass
|
||||
|
||||
def do_GET(self): # noqa: N802
|
||||
u = urlparse(self.path)
|
||||
if not u.path.endswith("/models"):
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
return
|
||||
q = parse_qs(u.query)
|
||||
limit = min(int(q.get("limit", ["20"])[0]), self.page_cap)
|
||||
after = q.get("after_id", [None])[0]
|
||||
start = MODELS.index(after) + 1 if after in MODELS else 0
|
||||
page = MODELS[start : start + limit]
|
||||
last_id = page[-1] if page else None
|
||||
if self.repeat_cursor and after is not None:
|
||||
last_id = after # cursor never advances
|
||||
body = {
|
||||
"data": [{"id": m, "type": "model"} for m in page],
|
||||
"has_more": True if self.repeat_cursor else start + limit < len(MODELS),
|
||||
"first_id": page[0] if page else None,
|
||||
"last_id": last_id,
|
||||
}
|
||||
raw = json.dumps(body).encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(raw)))
|
||||
self.end_headers()
|
||||
self.wfile.write(raw)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def paged_server():
|
||||
handler = type("Handler", (_PagedHandler,), {})
|
||||
srv = HTTPServer(("127.0.0.1", 0), handler)
|
||||
threading.Thread(target=srv.serve_forever, daemon=True).start()
|
||||
try:
|
||||
yield f"http://127.0.0.1:{srv.server_address[1]}", handler
|
||||
finally:
|
||||
srv.shutdown()
|
||||
|
||||
|
||||
def _load_plugin_profile():
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"anthropic_provider_under_test",
|
||||
root / "plugins" / "model-providers" / "anthropic" / "__init__.py",
|
||||
)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod.anthropic
|
||||
|
||||
|
||||
class TestFetchAnthropicModelsPagination:
|
||||
def test_follows_cursor_across_all_pages(self, paged_server):
|
||||
base, _handler = paged_server
|
||||
got = _fetch_anthropic_models(base_url=base, api_key="sk-ant-api-test")
|
||||
assert got is not None
|
||||
assert len(got) == len(MODELS)
|
||||
assert set(got) == set(MODELS)
|
||||
|
||||
def test_single_page_catalog_still_works(self, paged_server):
|
||||
base, handler = paged_server
|
||||
handler.page_cap = 1000 # whole catalog fits one page
|
||||
got = _fetch_anthropic_models(base_url=base, api_key="sk-ant-api-test")
|
||||
assert got is not None and len(got) == len(MODELS)
|
||||
|
||||
def test_repeated_cursor_terminates(self, paged_server):
|
||||
base, handler = paged_server
|
||||
handler.repeat_cursor = True
|
||||
got = _fetch_anthropic_models(base_url=base, api_key="sk-ant-api-test")
|
||||
# Must not hang or loop forever; returns the de-duped pages it saw.
|
||||
assert got is not None
|
||||
assert 0 < len(got) <= len(MODELS)
|
||||
|
||||
|
||||
class TestPluginFetchModelsPagination:
|
||||
def test_follows_cursor_across_all_pages(self, paged_server):
|
||||
base, _handler = paged_server
|
||||
profile = _load_plugin_profile()
|
||||
got = profile.fetch_models(api_key="sk-ant-api-test", base_url=base)
|
||||
assert got is not None
|
||||
assert len(got) == len(MODELS)
|
||||
|
||||
def test_no_api_key_returns_none(self):
|
||||
profile = _load_plugin_profile()
|
||||
assert profile.fetch_models(api_key=None) is None
|
||||
@@ -56,7 +56,7 @@ def test_anthropic_picker_discovers_models_with_pool_api_key(monkeypatch):
|
||||
result = models.provider_model_ids("anthropic")
|
||||
|
||||
assert "claude-opus-5" in result
|
||||
assert captured["url"] == "https://api.anthropic.com/v1/models"
|
||||
assert captured["url"] == "https://api.anthropic.com/v1/models?limit=1000"
|
||||
assert captured["headers"]["x-api-key"] == "sk-ant-api03-pool-key"
|
||||
assert "authorization" not in captured["headers"]
|
||||
|
||||
@@ -103,7 +103,7 @@ def test_anthropic_pool_api_key_overrides_conflicting_active_endpoint(monkeypatc
|
||||
assert models.provider_model_ids("anthropic") == ["claude-proxy-model"]
|
||||
assert requests == [
|
||||
(
|
||||
f"{pool_endpoint}/models",
|
||||
f"{pool_endpoint}/models?limit=1000",
|
||||
{
|
||||
"anthropic-version": "2023-06-01",
|
||||
"x-api-key": "proxy-key",
|
||||
|
||||
@@ -134,7 +134,7 @@ class TestProviderModelIds:
|
||||
assert provider_model_ids("anthropic") == ["enterprise-claude"]
|
||||
|
||||
req = mock_urlopen.call_args[0][0]
|
||||
assert req.full_url == "http://localhost:6655/anthropic/v1/models"
|
||||
assert req.full_url == "http://localhost:6655/anthropic/v1/models?limit=1000"
|
||||
assert req.get_header("X-api-key") == "proxy-key"
|
||||
|
||||
def test_custom_provider_passes_anthropic_mode_for_versioned_proxy_catalog(self):
|
||||
|
||||
@@ -318,7 +318,7 @@ def test_anthropic_profile_drops_x_api_key_on_redirect(monkeypatch):
|
||||
original_request = urllib.request.Request
|
||||
|
||||
def local_anthropic_request(url, *args, **kwargs):
|
||||
if url == "https://api.anthropic.com/v1/models":
|
||||
if url.startswith("https://api.anthropic.com/v1/models"):
|
||||
url = f"http://127.0.0.1:{source.server_port}/redirect"
|
||||
return original_request(url, *args, **kwargs)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user