From 81518cdd5b8b56852555039f6ccb2df7dfb4cbe7 Mon Sep 17 00:00:00 2001 From: pufit Date: Sat, 1 Aug 2026 00:07:26 -0400 Subject: [PATCH] Offer the models the account can actually reach in the model picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Claude side of GET /api/models was a hardcoded current-generation list plus whatever agent.model/agent.models named, so every model shipped after that constant was written stayed invisible in the composer even though the configured credentials could use it. Ask the Anthropic Models API (GET /v1/models) instead: primed once at gateway startup, cached in-process for 6h and refreshed in the background, so a newly released model needs no code change or config edit. Best-effort throughout — an explicit agent.models still wins, and Bedrock (no Models API on that client), a missing API key or an unreachable API all fall back to the built-in list. agent.model_discovery turns it off. --- config.example.yaml | 13 ++- docs/config.md | 3 +- nerve/config.py | 64 +++++++---- nerve/gateway/routes/models.py | 27 ++++- nerve/gateway/server.py | 14 +++ nerve/models_catalog.py | 196 +++++++++++++++++++++++++++++++++ tests/test_models_catalog.py | 159 ++++++++++++++++++++++++++ tests/test_models_route.py | 113 ++++++++++++++++++- 8 files changed, 557 insertions(+), 32 deletions(-) create mode 100644 nerve/models_catalog.py create mode 100644 tests/test_models_catalog.py diff --git a/config.example.yaml b/config.example.yaml index cc4f1db1..22154b35 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -52,12 +52,19 @@ agent: # opus: claude-opus-5 # Claude models selectable in the web composer's model picker. The # configured `model` above is always offered first; entries here extend - # the list. Unset → a built-in current-generation list (opus / sonnet / - # haiku). On Bedrock only models named in config are offered (Bedrock - # IDs are region-prefixed, so the bare built-ins would not resolve). + # the list. Unset → the models the Anthropic Models API reports for your + # credentials (see model_discovery below), falling back to a built-in + # current-generation list (opus / sonnet / haiku). On Bedrock only models + # named in config are offered (Bedrock IDs are region-prefixed, so + # neither discovery nor the bare built-ins would resolve). # models: # - claude-opus-5 # - claude-sonnet-4-6 + # Ask the Anthropic Models API (GET /v1/models) at startup which models + # the configured credentials can reach, so a newly released model shows up + # in the picker without a config edit. Ignored when `models` above is set, + # on Bedrock, or without an API key. false → always use the built-in list. + model_discovery: true max_turns: 50 # Max agentic turns per request max_concurrent: 32 # Max concurrent agent sessions background_agent_permissions: true # Background sub-agents (Agent run_in_background) get the same tool permissions as foreground; false denies their Write/Edit/Bash diff --git a/docs/config.md b/docs/config.md index 10e6aa07..d9ab358c 100644 --- a/docs/config.md +++ b/docs/config.md @@ -886,7 +886,8 @@ from any working directory: |-----|------|---------|-------------| | `agent.model` | string | `claude-opus-5` | Primary model for conversations | | `agent.cron_model` | string | `claude-sonnet-4-6` | Model for cron jobs (cheaper) | -| `agent.models` | list | `[]` | Claude models offered in the composer's model picker. `agent.model` always leads; entries extend the list. Empty → a built-in current-generation list (opus / sonnet / haiku) on the direct Anthropic API; on Bedrock only configured models are offered | +| `agent.models` | list | `[]` | Claude models offered in the composer's model picker. `agent.model` always leads; entries extend the list. Empty → the discovered catalog (see `agent.model_discovery`), else a built-in current-generation list (opus / sonnet / haiku); on Bedrock only configured models are offered | +| `agent.model_discovery` | bool | `true` | Ask the Anthropic Models API (`GET /v1/models`) which models the configured credentials can reach, and offer those in the picker — so a newly released model needs no config edit. Primed at gateway startup, cached in-process (6h) and refreshed in the background. Best-effort: ignored when `agent.models` is set, on Bedrock (the Bedrock client has no Models API), without an API key, or when the API is unreachable — the built-in list applies | | `agent.model_aliases` | map | `{opus: claude-opus-5}` | Alias → model ID remapping for the CLI (emitted as `ANTHROPIC_DEFAULT__MODEL` env vars). Aliases (`opus`, `sonnet`, `haiku`, `fable`) used in Agent/Workflow tool model options, skill frontmatter, and cron overrides resolve to the mapped ID. Entries merge over the built-in `opus → claude-opus-5` default (not applied on Bedrock — set geo-prefixed IDs explicitly there); `""` unsets an alias | | `agent.max_turns` | int | `50` | Max agentic turns per request | | `agent.max_concurrent` | int | `32` | Max concurrent agent sessions | diff --git a/nerve/config.py b/nerve/config.py index 0941b214..43415345 100644 --- a/nerve/config.py +++ b/nerve/config.py @@ -777,10 +777,11 @@ def from_dict(cls, d: dict) -> PromptRewriteConfig: ) -# Built-in fallback for the composer's Claude model picker when -# ``agent.models`` is unset — the current-generation models Nerve itself -# defaults to elsewhere in this file. Bare Anthropic API IDs: they do not -# apply on Bedrock, where model IDs are region-prefixed. +# Fallback for the composer's Claude model picker when ``agent.models`` is +# unset and live discovery is off or unavailable (see nerve/models_catalog.py) +# — the current-generation models Nerve itself defaults to elsewhere in this +# file. Bare Anthropic API IDs: they do not apply on Bedrock, where model IDs +# are region-prefixed. DEFAULT_CLAUDE_MODELS: tuple[str, ...] = ( "claude-opus-5", "claude-sonnet-4-6", @@ -813,11 +814,19 @@ class AgentConfig: # Claude models selectable in the web composer's model picker # (GET /api/models). The configured `model` above is always offered # first; entries here extend the list (order-preserving, deduped). - # Empty → a built-in current-generation list (DEFAULT_CLAUDE_MODELS) - # on the direct Anthropic API; on Bedrock only the models named in - # config are offered (Bedrock IDs are region-prefixed, so the bare - # built-ins would not resolve there). + # Empty → the models the configured credentials actually expose, read + # from the Anthropic Models API (see `model_discovery` below), falling + # back to a built-in current-generation list (DEFAULT_CLAUDE_MODELS). + # On Bedrock only the models named in config are offered (Bedrock IDs + # are region-prefixed, so neither discovery nor the bare built-ins + # resolve there). models: list[str] = field(default_factory=list) + # Ask the Anthropic Models API (GET /v1/models) which models the + # configured credentials can reach, and offer those in the picker, so a + # newly released model needs no code change or config edit. Best-effort: + # ignored when `models` above is set explicitly, on Bedrock, without an + # API key, or when the API is unreachable — the built-in list applies. + model_discovery: bool = True max_turns: int = 100 max_concurrent: int = 32 thinking: str = "max" # max, high, medium, low, disabled, adaptive, or number (budget_tokens) @@ -883,6 +892,7 @@ def from_dict(cls, d: dict) -> AgentConfig: for k, v in (d.get("model_aliases") or {}).items() }, models=_str_list(d.get("models"), clean=True), + model_discovery=d.get("model_discovery", True), max_turns=d.get("max_turns", 100), max_concurrent=d.get("max_concurrent", 32), thinking=str(d.get("thinking", "max")), @@ -2588,26 +2598,42 @@ def ollama_routable(self) -> bool: """ return self.ollama.enabled and self.proxy.enabled - @property - def claude_models(self) -> list[str]: + def selectable_claude_models( + self, discovered: list[str] | None = None, + ) -> list[str]: """Selectable Claude chat models for the composer's model picker. - The configured default (``agent.model``) always leads; ``agent.models`` - entries follow in config order (deduped). When ``agent.models`` is - unset, the built-in :data:`DEFAULT_CLAUDE_MODELS` list applies on the - direct Anthropic API. Bedrock model IDs are region-prefixed, so the - bare built-ins are skipped there — Bedrock offers only the models - named in config. + The configured default (``agent.model``) always leads; the rest come + from the first source that has anything to say: + + 1. ``agent.models`` — an explicit list always wins, + 2. *discovered* — what the Anthropic Models API reports the + credentials can reach (see :mod:`nerve.models_catalog`), + 3. the built-in :data:`DEFAULT_CLAUDE_MODELS` list. + + Bedrock model IDs are region-prefixed, so neither discovery nor the + bare built-ins apply there — Bedrock offers only configured models. """ - extras = self.agent.models or ( - [] if self.provider.is_bedrock else list(DEFAULT_CLAUDE_MODELS) - ) + if self.agent.models: + extras = list(self.agent.models) + elif discovered: + extras = list(discovered) + elif self.provider.is_bedrock: + extras = [] + else: + extras = list(DEFAULT_CLAUDE_MODELS) + ordered: list[str] = [] for m in (self.agent.model, *extras): if m and m not in ordered: ordered.append(m) return ordered + @property + def claude_models(self) -> list[str]: + """Config-only view of :meth:`selectable_claude_models` (no discovery).""" + return self.selectable_claude_models() + def create_anthropic_client(self, timeout: float = 60.0) -> Any: """Create an Anthropic client based on the configured provider. diff --git a/nerve/gateway/routes/models.py b/nerve/gateway/routes/models.py index 91b8eeb2..832bb0a2 100644 --- a/nerve/gateway/routes/models.py +++ b/nerve/gateway/routes/models.py @@ -1,10 +1,17 @@ """Model discovery routes — which chat models the UI can offer. -Exposes the selectable Claude models (``config.claude_models`` — the -configured default plus ``agent.models`` or a built-in current-generation -list), the Codex app-server's advertised models, and any locally-installed -Ollama models (auto-discovered from the running Ollama server). The web -composer's model picker calls GET /api/models to populate its options. +Exposes the selectable Claude models (the configured default plus +``agent.models``, or the live Anthropic catalog, or a built-in +current-generation list — see :meth:`NerveConfig.selectable_claude_models`), +the Codex app-server's advertised models, and any locally-installed Ollama +models (auto-discovered from the running Ollama server). The web composer's +model picker calls GET /api/models to populate its options. + +The Claude catalog comes from :mod:`nerve.models_catalog`, which asks the +Anthropic Models API which models the configured credentials can actually +reach — so a newly released model appears in the picker without a code +change. It is cached (primed at startup) and falls back to the built-in +list whenever discovery is off or unavailable. Ollama models are only listed when they are actually routable (``config.ollama_routable`` — Ollama enabled *and* the proxy running), @@ -18,6 +25,7 @@ from fastapi import APIRouter, Depends +from nerve import models_catalog from nerve.config import get_config from nerve.gateway.auth import require_auth from nerve.gateway.routes._deps import get_deps @@ -46,7 +54,14 @@ async def list_models(user: dict = Depends(require_auth)): config = get_config() deps = get_deps() default_model = config.agent.model - claude_models = config.claude_models + # Live catalog first (cached; empty when discovery is off/unavailable), + # then whatever config names — the fallback chain lives in the config. + discovered = ( + await models_catalog.get_models(config) + if config.agent.model_discovery + else [] + ) + claude_models = config.selectable_claude_models(discovered) codex_backend = deps.engine._backends.get("codex") codex_preflight = ( diff --git a/nerve/gateway/server.py b/nerve/gateway/server.py index 3fc7ff8b..8aea902e 100644 --- a/nerve/gateway/server.py +++ b/nerve/gateway/server.py @@ -270,6 +270,18 @@ async def lifespan(app: FastAPI): # Wire up routes init_deps(_engine, db) + # Prime the Anthropic model catalog so the composer's model picker + # offers every model these credentials can reach (instead of a built-in + # list that goes stale on each release). Off the critical path and + # best-effort: until it lands — or if it fails — the picker falls back + # to the configured/built-in list. Runs after the proxy is up, since + # discovery goes through it when proxy.enabled. + models_prime_task = None + if config.agent.model_discovery: + from nerve import models_catalog + + models_prime_task = asyncio.create_task(models_catalog.prime(config)) + # Initialize notification service. The engine has a setter so the # per-session ``ToolContext`` constructed inside ``engine.run()`` # picks up the live reference. We also seed the legacy module @@ -698,6 +710,8 @@ async def _periodic_notify_maintenance(): idle_sweep_task.cancel() memorize_task.cancel() cleanup_task.cancel() + if models_prime_task is not None and not models_prime_task.done(): + models_prime_task.cancel() await _engine.shutdown() # Flush Langfuse spans last — after the engine has reported its final # ResultMessage and any in-flight memU spans have completed. ``flush`` diff --git a/nerve/models_catalog.py b/nerve/models_catalog.py new file mode 100644 index 00000000..efa9eaf1 --- /dev/null +++ b/nerve/models_catalog.py @@ -0,0 +1,196 @@ +"""Anthropic model-catalog discovery for the composer's model picker. + +The Claude side of ``GET /api/models`` used to be a hardcoded list +(:data:`nerve.config.DEFAULT_CLAUDE_MODELS`) plus whatever ``agent.model`` / +``agent.models`` named, so every model Anthropic released after that constant +was written stayed invisible in the UI even when the configured credentials +could use it. This module asks the Anthropic Models API (``GET /v1/models``) +which models the account can actually reach, so new releases show up without +a code change or a config edit. + +Discovery is best-effort and never raises. It returns an empty list — and the +caller falls back to the built-in list — when: + +* the provider is Bedrock (``AnthropicBedrock`` exposes no Models API, and + Bedrock IDs are region-prefixed anyway), +* no API key is configured (e.g. OAuth-token installs), or +* the API is unreachable / returns something unexpected. + +Results are cached in-process: primed once at gateway startup, re-fetched in +the background when older than :data:`_TTL_SECONDS`, so a long-lived gateway +picks up a newly released model without a restart. +""" + +from __future__ import annotations + +import asyncio +import logging +import time +from itertools import islice +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: # pragma: no cover - typing only + from nerve.config import NerveConfig + +logger = logging.getLogger(__name__) + +# Bounded: discovery runs at startup and, when the cache is cold, on the +# /api/models request path — it must never hang the picker. +_DISCOVERY_TIMEOUT = 8.0 + +# The catalog only changes when Anthropic ships a model, so hours of +# staleness are fine. A stale entry is still served immediately; the refresh +# happens in the background. +_TTL_SECONDS = 6 * 3600.0 + +# After a failure, serve the built-in fallback for a while instead of +# retrying the unreachable API on every request. +_FAILURE_BACKOFF_SECONDS = 300.0 + +# Safety net against an unexpectedly long paginated response. +_MAX_MODELS = 200 + +# Cache shape: fingerprint identifies the credentials/endpoint the entry was +# fetched with, so a config reload that switches provider, key or base URL +# invalidates it instead of serving another account's catalog. +_cache: dict[str, Any] = { + "fingerprint": None, # tuple | None + "models": [], # list[str] + "checked_at": 0.0, # monotonic timestamp of the last attempt + "ok": False, # whether that attempt returned models +} + +# Background refresh handle — kept module-level so the task is not garbage +# collected mid-flight. +_refresh_task: asyncio.Task[Any] | None = None + + +def reset_cache() -> None: + """Drop the cached catalog (used by tests and config reloads).""" + _cache.update({"fingerprint": None, "models": [], "checked_at": 0.0, "ok": False}) + + +def _fingerprint(config: NerveConfig) -> tuple[str, str, str]: + """Identity of the credentials/endpoint a cached catalog belongs to.""" + key = config.effective_api_key or "" + return ( + "bedrock" if config.provider.is_bedrock else "anthropic", + config.anthropic_api_base_url, + key[-6:], # tail only — never log or store the whole key + ) + + +def fetch_models( + config: NerveConfig, timeout: float = _DISCOVERY_TIMEOUT, +) -> list[str]: + """Return Claude model IDs the configured credentials can reach. + + Queries ``GET /v1/models`` (newest first, as the API orders it). Blocking + — call it from a worker thread. Never raises: returns ``[]`` when + discovery is impossible or fails, and the caller falls back to the + built-in list. + """ + if config.provider.is_bedrock: + # The Bedrock client has no `.models` resource, and Bedrock IDs are + # region-prefixed — configured models are the only sane source there. + return [] + if not config.effective_api_key: + logger.debug("Claude model discovery skipped: no API key configured") + return [] + + client = None + try: + client = config.create_anthropic_client(timeout=timeout) + # Iterating the page object auto-paginates; islice bounds it. + raw = [ + str(m.id) + for m in islice(client.models.list(limit=100), _MAX_MODELS) + if getattr(m, "id", None) + ] + except Exception as e: + logger.warning("Claude model discovery failed: %s", e) + return [] + finally: + if client is not None: + try: + client.close() + except Exception: # pragma: no cover - defensive + pass + + # Keep Claude chat models only. With `proxy.enabled` the request is + # served by CLIProxyAPI, whose catalog can include non-Anthropic + # upstreams (Ollama, ...) that the picker already lists separately. + models: list[str] = [] + seen: set[str] = set() + for model_id in raw: + if "claude" in model_id.lower() and model_id not in seen: + seen.add(model_id) + models.append(model_id) + return models + + +async def _refresh(config: NerveConfig, fingerprint: tuple[str, str, str]) -> list[str]: + """Fetch in a worker thread and update the cache. Never raises.""" + try: + models = await asyncio.to_thread(fetch_models, config) + except Exception as e: # pragma: no cover - to_thread itself failing + logger.warning("Claude model discovery failed: %s", e) + models = [] + _cache.update({ + "fingerprint": fingerprint, + "models": models, + "checked_at": time.monotonic(), + "ok": bool(models), + }) + return models + + +def _schedule_refresh(config: NerveConfig, fingerprint: tuple[str, str, str]) -> None: + """Kick off a background refresh unless one is already running.""" + global _refresh_task + if _refresh_task is not None and not _refresh_task.done(): + return + try: + _refresh_task = asyncio.create_task(_refresh(config, fingerprint)) + except RuntimeError: # pragma: no cover - no running loop + pass + + +async def get_models(config: NerveConfig) -> list[str]: + """Cached Claude catalog for the model picker (``[]`` when unavailable). + + A usable entry is served immediately, refreshing in the background once + stale. Only a cold cache waits on the network, and a recent failure is + remembered so a down API is not retried on every request. + + Two concurrent cold callers may both fetch — the request is an idempotent + GET and startup priming makes it rare, so this is left unsynchronized on + purpose. + """ + fingerprint = _fingerprint(config) + if _cache["fingerprint"] == fingerprint: + age = time.monotonic() - float(_cache["checked_at"]) + if _cache["ok"]: + if age < _TTL_SECONDS: + return list(_cache["models"]) + _schedule_refresh(config, fingerprint) + return list(_cache["models"]) # stale but usable + if age < _FAILURE_BACKOFF_SECONDS: + return [] + return await _refresh(config, fingerprint) + + +async def prime(config: NerveConfig) -> list[str]: + """Warm the catalog at startup so the first picker render is complete.""" + models = await get_models(config) + if models: + logger.info( + "Discovered %d Claude models from the Anthropic API (default: %s)", + len(models), config.agent.model, + ) + else: + logger.info( + "Claude model discovery returned nothing — the model picker " + "falls back to the configured/built-in list", + ) + return models diff --git a/tests/test_models_catalog.py b/tests/test_models_catalog.py new file mode 100644 index 00000000..debec4ed --- /dev/null +++ b/tests/test_models_catalog.py @@ -0,0 +1,159 @@ +"""Tests for the Anthropic model-catalog discovery (nerve/models_catalog.py). + +The composer's model picker must offer what the configured credentials can +actually reach, and must degrade to the built-in list — never to an error or +a hang — when the Models API is unusable (Bedrock, no key, unreachable API). +""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from nerve import models_catalog +from nerve.config import NerveConfig + + +class FakeModels: + """Stand-in for ``client.models`` — records calls, replays a page.""" + + def __init__(self, ids: list[str], error: Exception | None = None): + self._ids = ids + self._error = error + self.calls: list[dict] = [] + + def list(self, **kwargs): + self.calls.append(kwargs) + if self._error is not None: + raise self._error + return iter([SimpleNamespace(id=i) for i in self._ids]) + + +class FakeClient: + def __init__(self, ids: list[str], error: Exception | None = None): + self.models = FakeModels(ids, error) + self.closed = False + + def close(self): + self.closed = True + + +def make_config( + ids: list[str] | None = None, + *, + error: Exception | None = None, + api_key: str = "sk-ant-test-0000000000", + bedrock: bool = False, +) -> tuple[NerveConfig, list[FakeClient]]: + """A config whose Anthropic client is a fake. Returns (config, clients).""" + cfg = NerveConfig() + cfg.anthropic_api_key = api_key + if bedrock: + cfg.provider.type = "bedrock" + + clients: list[FakeClient] = [] + + def _create(timeout: float = 60.0): + client = FakeClient(ids or [], error) + clients.append(client) + return client + + cfg.create_anthropic_client = _create # type: ignore[method-assign] + return cfg, clients + + +@pytest.fixture(autouse=True) +def _clean_cache(): + models_catalog.reset_cache() + yield + models_catalog.reset_cache() + + +class TestFetchModels: + def test_returns_ids_in_api_order(self): + cfg, clients = make_config( + ["claude-opus-5", "claude-fable-5", "claude-sonnet-4-6"], + ) + assert models_catalog.fetch_models(cfg) == [ + "claude-opus-5", "claude-fable-5", "claude-sonnet-4-6", + ] + assert clients[0].closed is True + + def test_filters_non_claude_and_dedupes(self): + """A proxy-served catalog can carry other upstreams — drop them.""" + cfg, _ = make_config([ + "claude-opus-5", "llama3:8b", "claude-opus-5", "gpt-5.6-sol", + "claude-haiku-4-5-20251001", + ]) + assert models_catalog.fetch_models(cfg) == [ + "claude-opus-5", "claude-haiku-4-5-20251001", + ] + + def test_bedrock_never_queries(self): + cfg, clients = make_config(["claude-opus-5"], bedrock=True) + assert models_catalog.fetch_models(cfg) == [] + assert clients == [] + + def test_no_api_key_never_queries(self): + cfg, clients = make_config(["claude-opus-5"], api_key="") + assert models_catalog.fetch_models(cfg) == [] + assert clients == [] + + def test_api_error_is_swallowed(self): + cfg, clients = make_config(error=RuntimeError("connection refused")) + assert models_catalog.fetch_models(cfg) == [] + assert clients[0].closed is True # client still cleaned up + + +class TestGetModels: + @pytest.mark.asyncio + async def test_caches_between_calls(self): + cfg, clients = make_config(["claude-opus-5", "claude-fable-5"]) + + first = await models_catalog.get_models(cfg) + second = await models_catalog.get_models(cfg) + + assert first == second == ["claude-opus-5", "claude-fable-5"] + assert len(clients) == 1 # second call served from cache + + @pytest.mark.asyncio + async def test_failure_is_backed_off_not_retried(self): + cfg, clients = make_config(error=RuntimeError("boom")) + + assert await models_catalog.get_models(cfg) == [] + assert await models_catalog.get_models(cfg) == [] + assert len(clients) == 1 # unreachable API is not hammered + + @pytest.mark.asyncio + async def test_credential_change_invalidates_cache(self): + cfg, clients = make_config(["claude-opus-5"]) + assert await models_catalog.get_models(cfg) == ["claude-opus-5"] + + cfg.anthropic_api_key = "sk-ant-other-1111111111" + await models_catalog.get_models(cfg) + + assert len(clients) == 2 # a different account gets its own catalog + + @pytest.mark.asyncio + async def test_stale_entry_is_served_while_refreshing(self, monkeypatch): + cfg, clients = make_config(["claude-opus-5"]) + assert await models_catalog.get_models(cfg) == ["claude-opus-5"] + + # Age the entry past the TTL — the stale list must still come back + # immediately (the refresh happens in the background). + monkeypatch.setitem( + models_catalog._cache, "checked_at", + models_catalog._cache["checked_at"] - models_catalog._TTL_SECONDS - 1, + ) + assert await models_catalog.get_models(cfg) == ["claude-opus-5"] + + task = models_catalog._refresh_task + assert task is not None + await task + assert len(clients) == 2 + + @pytest.mark.asyncio + async def test_prime_never_raises(self): + cfg, _ = make_config(error=RuntimeError("boom")) + assert await models_catalog.prime(cfg) == [] diff --git a/tests/test_models_route.py b/tests/test_models_route.py index 7e8adae2..b5731659 100644 --- a/tests/test_models_route.py +++ b/tests/test_models_route.py @@ -3,9 +3,10 @@ Pattern mirrors test_workflow_routes.py: a minimal FastAPI app with the real router, auth disabled via an empty jwt_secret, and a fake engine carrying stub backends. The route must advertise the full selectable -Claude model list (config.claude_models) — not just the configured -default — so the composer's model picker renders for Claude sessions -the same way it does for Codex. +Claude model list — not just the configured default — so the composer's +model picker renders for Claude sessions the same way it does for Codex, +and that list must include whatever the Anthropic Models API reports for +the configured credentials (nerve/models_catalog.py). """ from __future__ import annotations @@ -24,6 +25,30 @@ async def preflight(self) -> dict[str, Any]: return self._preflight +@pytest.fixture(autouse=True) +def _clean_catalog_cache(): + """Keep the process-wide discovery cache out of the route tests.""" + from nerve import models_catalog + + models_catalog.reset_cache() + yield + models_catalog.reset_cache() + + +@pytest.fixture +def stub_discovery(monkeypatch): + """Make the Anthropic Models API return a fixed catalog (no network).""" + from nerve import models_catalog + + def _stub(models: list[str]): + async def _get_models(config): + return list(models) + + monkeypatch.setattr(models_catalog, "get_models", _get_models) + + return _stub + + @pytest.fixture def make_client(): """Factory: build a TestClient around a config + codex preflight stub.""" @@ -106,6 +131,88 @@ def test_configured_models_reach_the_response(self, make_client): "claude-fable-5", "claude-opus-5", "claude-haiku-4-5-20251001", ] + def test_discovered_models_reach_the_picker(self, make_client, stub_discovery): + """A model released after DEFAULT_CLAUDE_MODELS still shows up.""" + from nerve.config import AgentConfig, NerveConfig + + stub_discovery([ + "claude-opus-5", "claude-fable-5", "claude-sonnet-4-6", + ]) + cfg = NerveConfig(agent=AgentConfig.from_dict({"model": "claude-opus-5"})) + data = make_client(cfg=cfg).get("/api/models").json() + + claude_ids = [ + m["id"] for m in data["models"] if m["backend"] == "claude" + ] + assert claude_ids == [ + "claude-opus-5", "claude-fable-5", "claude-sonnet-4-6", + ] + + def test_configured_default_leads_the_discovered_list( + self, make_client, stub_discovery, + ): + """agent.model always heads the picker, wherever it sits in the API order.""" + from nerve.config import AgentConfig, NerveConfig + + stub_discovery(["claude-opus-5", "claude-fable-5"]) + cfg = NerveConfig(agent=AgentConfig.from_dict({"model": "claude-fable-5"})) + data = make_client(cfg=cfg).get("/api/models").json() + + claude_ids = [ + m["id"] for m in data["models"] if m["backend"] == "claude" + ] + assert claude_ids == ["claude-fable-5", "claude-opus-5"] + assert data["defaults"]["claude"] == "claude-fable-5" + + def test_explicit_models_win_over_discovery(self, make_client, stub_discovery): + from nerve.config import AgentConfig, NerveConfig + + stub_discovery(["claude-opus-5", "claude-fable-5", "claude-sonnet-4-6"]) + cfg = NerveConfig(agent=AgentConfig.from_dict({ + "model": "claude-opus-5", + "models": ["claude-sonnet-4-6"], + })) + data = make_client(cfg=cfg).get("/api/models").json() + + claude_ids = [ + m["id"] for m in data["models"] if m["backend"] == "claude" + ] + assert claude_ids == ["claude-opus-5", "claude-sonnet-4-6"] + + def test_discovery_disabled_falls_back_to_builtin( + self, make_client, stub_discovery, + ): + from nerve.config import DEFAULT_CLAUDE_MODELS, AgentConfig, NerveConfig + + stub_discovery(["claude-fable-5"]) # must be ignored + cfg = NerveConfig(agent=AgentConfig.from_dict({ + "model": "claude-opus-5", "model_discovery": False, + })) + data = make_client(cfg=cfg).get("/api/models").json() + + claude_ids = [ + m["id"] for m in data["models"] if m["backend"] == "claude" + ] + assert claude_ids[0] == "claude-opus-5" + assert "claude-fable-5" not in claude_ids + assert set(DEFAULT_CLAUDE_MODELS) <= set(claude_ids) + + def test_discovery_failure_falls_back_to_builtin(self, make_client, monkeypatch): + """An unreachable Models API must not empty the picker.""" + from nerve import models_catalog + from nerve.config import DEFAULT_CLAUDE_MODELS + + async def _boom(config): + return [] + + monkeypatch.setattr(models_catalog, "get_models", _boom) + data = make_client().get("/api/models").json() + + claude_ids = [ + m["id"] for m in data["models"] if m["backend"] == "claude" + ] + assert set(DEFAULT_CLAUDE_MODELS) <= set(claude_ids) + def test_codex_models_from_preflight(self, make_client): client = make_client(codex_preflight={ "available": True, "models": ["gpt-5.6-sol", "gpt-5.6-mini"],