diff --git a/hermes_cli/models.py b/hermes_cli/models.py index a21c3be470..b975b1dee7 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -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: diff --git a/plugins/model-providers/anthropic/__init__.py b/plugins/model-providers/anthropic/__init__.py index 267902b274..032bede230 100644 --- a/plugins/model-providers/anthropic/__init__.py +++ b/plugins/model-providers/anthropic/__init__.py @@ -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 diff --git a/tests/hermes_cli/test_anthropic_models_pagination.py b/tests/hermes_cli/test_anthropic_models_pagination.py new file mode 100644 index 0000000000..6482736363 --- /dev/null +++ b/tests/hermes_cli/test_anthropic_models_pagination.py @@ -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 diff --git a/tests/hermes_cli/test_anthropic_pool_model_discovery.py b/tests/hermes_cli/test_anthropic_pool_model_discovery.py index edeaa0fcdf..70c90ac5f9 100644 --- a/tests/hermes_cli/test_anthropic_pool_model_discovery.py +++ b/tests/hermes_cli/test_anthropic_pool_model_discovery.py @@ -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", diff --git a/tests/hermes_cli/test_model_validation.py b/tests/hermes_cli/test_model_validation.py index 441a3fc896..43c983b883 100644 --- a/tests/hermes_cli/test_model_validation.py +++ b/tests/hermes_cli/test_model_validation.py @@ -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): diff --git a/tests/hermes_cli/test_urllib_security.py b/tests/hermes_cli/test_urllib_security.py index f79379e04a..18d847997e 100644 --- a/tests/hermes_cli/test_urllib_security.py +++ b/tests/hermes_cli/test_urllib_security.py @@ -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)