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:
Teknium
2026-08-29 17:13:32 -07:00
parent 82e4ac4b01
commit d26dbb2f77
6 changed files with 171 additions and 11 deletions

View File

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

View File

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

View 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

View File

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

View File

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

View File

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