Skip to content
Merged
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
13 changes: 10 additions & 3 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_<ALIAS>_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 |
Expand Down
64 changes: 45 additions & 19 deletions nerve/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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")),
Expand Down Expand Up @@ -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.

Expand Down
27 changes: 21 additions & 6 deletions nerve/gateway/routes/models.py
Original file line number Diff line number Diff line change
@@ -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),
Expand All @@ -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
Expand Down Expand Up @@ -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 = (
Expand Down
14 changes: 14 additions & 0 deletions nerve/gateway/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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``
Expand Down
Loading
Loading