Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions nerve/anthropic.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"""Anthropic API integration helpers.

This module only handles model *discovery* — querying which chat models
the configured Anthropic API key can use (``GET /v1/models``) so the web
composer's model picker can offer them without a hand-maintained list.

Discovery is best-effort and never raises: without an API key (OAuth or
Bedrock setups) or with the API unreachable, callers get an empty list
and the picker falls back to the configured models.
"""

from __future__ import annotations

import json
import logging
import urllib.error
import urllib.request

logger = logging.getLogger(__name__)

_MODELS_URL = "https://api.anthropic.com/v1/models?limit=100"
_API_VERSION = "2023-06-01"

# Short, bounded timeout — discovery runs on the request path (model
# picker); a slow or unreachable API must never hang the UI.
_DISCOVERY_TIMEOUT = 3.0


def discover_models(api_key: str, timeout: float = _DISCOVERY_TIMEOUT) -> list[str]:
"""Return chat model ids available to an Anthropic API key.

Queries ``GET /v1/models`` and preserves the API's newest-first order.
Returns an empty list (never raises) when the key is missing, the API
is unreachable, or the response is malformed.

Uses the stdlib ``urllib`` so it is safe to call synchronously from a
worker thread without pulling in an async HTTP client.
"""
if not api_key:
return []
try:
req = urllib.request.Request(_MODELS_URL, headers={
"x-api-key": api_key,
"anthropic-version": _API_VERSION,
"Accept": "application/json",
})
with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310
payload = json.loads(resp.read().decode("utf-8"))
except (urllib.error.URLError, OSError, ValueError, json.JSONDecodeError) as e:
logger.warning("Anthropic model discovery failed: %s", e)
return []

if not isinstance(payload, dict):
return []

ids: list[str] = []
for entry in payload.get("data") or []:
if isinstance(entry, dict):
model_id = entry.get("id")
if model_id and str(model_id) not in ids:
ids.append(str(model_id))
return ids


def latest_per_family(model_ids: list[str]) -> list[str]:
"""Keep the first (newest, in API order) model of each family.

The family is the first alphabetic token after the ``claude-`` prefix
(``opus``, ``sonnet``, ``haiku``, ``fable``, ...), which also covers
legacy ``claude-3-5-sonnet-...`` ids where the version precedes it.
Ids with no recognizable family are kept as their own family.
"""
seen: set[str] = set()
kept: list[str] = []
for model_id in model_ids:
family = _family(model_id)
if family in seen:
continue
seen.add(family)
kept.append(model_id)
return kept


def _family(model_id: str) -> str:
for token in model_id.removeprefix("claude-").split("-"):
if token.isalpha():
return token
return model_id
39 changes: 37 additions & 2 deletions nerve/gateway/routes/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,12 @@

import asyncio
import logging
import time

from fastapi import APIRouter, Depends

from nerve.anthropic import discover_models as discover_anthropic_models
from nerve.anthropic import latest_per_family
from nerve.config import get_config
from nerve.gateway.auth import require_auth
from nerve.gateway.routes._deps import get_deps
Expand All @@ -25,6 +28,28 @@

router = APIRouter()

# Anthropic discovery cache — unlike the local Ollama/Codex probes this call
# leaves the box, and /api/models fires on every composer mount. Successful
# lists are reused for 15 minutes; failures retry after 60 seconds so a
# keyless or offline box doesn't pay the discovery timeout on every mount.
_ANTHROPIC_TTL_OK = 15 * 60.0
_ANTHROPIC_TTL_EMPTY = 60.0
_anthropic_cache: tuple[float, list[str]] = (0.0, [])


async def _discovered_anthropic_ids(api_key: str) -> list[str]:
global _anthropic_cache
cached_at, ids = _anthropic_cache
ttl = _ANTHROPIC_TTL_OK if ids else _ANTHROPIC_TTL_EMPTY
if cached_at and time.monotonic() - cached_at < ttl:
return ids
# Discovery does blocking I/O (stdlib urllib) — keep the event loop free.
found = latest_per_family(
await asyncio.to_thread(discover_anthropic_models, api_key),
)
_anthropic_cache = (time.monotonic(), found)
return found


@router.get("/api/models")
async def list_models(user: dict = Depends(require_auth)):
Expand All @@ -45,6 +70,16 @@ async def list_models(user: dict = Depends(require_auth)):
deps = get_deps()
default_model = config.agent.model

# Default model first, then the newest live-discovered model of each
# Anthropic family (opus/sonnet/haiku/...), de-duplicated. Discovery is
# best-effort — a keyless (OAuth/Bedrock) or offline box simply offers
# only the default model.
discovered = await _discovered_anthropic_ids(config.anthropic_api_key)
anthropic_ids: list[str] = [default_model]
for m in discovered:
if m and m not in anthropic_ids:
anthropic_ids.append(m)

codex_backend = deps.engine._backends.get("codex")
codex_preflight = (
await codex_backend.preflight() if codex_backend is not None
Expand All @@ -56,7 +91,7 @@ async def list_models(user: dict = Depends(require_auth)):
options = [
{
"id": "claude", "label": "Claude", "model": config.agent.model,
"models": [config.agent.model], "available": True,
"models": anthropic_ids, "available": True,
},
]
options.append({
Expand All @@ -81,7 +116,7 @@ async def list_models(user: dict = Depends(require_auth)):
}

models: list[dict[str, str]] = [
{"id": default_model, "provider": "anthropic", "backend": "claude"},
{"id": m, "provider": "anthropic", "backend": "claude"} for m in anthropic_ids
]
if codex_preflight.get("available"):
models.extend({
Expand Down
97 changes: 97 additions & 0 deletions tests/test_anthropic.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
"""Tests for Anthropic model discovery (``nerve/anthropic.py``)."""

from __future__ import annotations

import io
import json
import urllib.error
from unittest.mock import patch

from nerve.anthropic import discover_models, latest_per_family


class _FakeResponse(io.BytesIO):
"""Minimal stand-in for the ``urlopen`` context-manager response."""

def __enter__(self):
return self

def __exit__(self, *exc):
self.close()
return False


def _response(payload) -> _FakeResponse:
return _FakeResponse(json.dumps(payload).encode("utf-8"))


def test_discover_models_requires_api_key():
assert discover_models("") == []


def test_discover_models_parses_ids_in_api_order():
payload = {
"data": [
{"id": "claude-sonnet-5", "display_name": "Claude Sonnet 5"},
{"id": "claude-fable-5", "display_name": "Claude Fable 5"},
{"id": "claude-fable-5"}, # duplicate dropped
{"display_name": "no id, skipped"},
],
"has_more": False,
}
with patch(
"nerve.anthropic.urllib.request.urlopen", return_value=_response(payload),
):
assert discover_models("sk-test") == ["claude-sonnet-5", "claude-fable-5"]


def test_discover_models_swallows_network_errors():
with patch(
"nerve.anthropic.urllib.request.urlopen",
side_effect=urllib.error.URLError("connection refused"),
):
assert discover_models("sk-test") == []


def test_discover_models_tolerates_malformed_payload():
with patch(
"nerve.anthropic.urllib.request.urlopen", return_value=_response([1, 2]),
):
assert discover_models("sk-test") == []


def test_latest_per_family_keeps_newest_of_each_family():
ids = [
"claude-sonnet-5",
"claude-fable-5",
"claude-opus-4-8",
"claude-opus-4-7",
"claude-sonnet-4-6",
"claude-haiku-4-5-20251001",
"claude-opus-4-1-20250805",
]
assert latest_per_family(ids) == [
"claude-sonnet-5",
"claude-fable-5",
"claude-opus-4-8",
"claude-haiku-4-5-20251001",
]


def test_latest_per_family_handles_legacy_version_first_ids():
ids = [
"claude-3-5-sonnet-20241022",
"claude-3-sonnet-20240229",
"claude-3-haiku-20240307",
]
assert latest_per_family(ids) == [
"claude-3-5-sonnet-20241022",
"claude-3-haiku-20240307",
]


def test_latest_per_family_keeps_unrecognizable_ids():
assert latest_per_family(["weird-id-123", "claude-opus-4-8"]) == [
"weird-id-123",
"claude-opus-4-8",
]
Loading