From 57cbd75de6d097d40639f97f7a4617ad0e3efb32 Mon Sep 17 00:00:00 2001 From: Pervakov Grigorii Date: Mon, 6 Jul 2026 22:30:30 +0200 Subject: [PATCH] Offer multiple Anthropic models in the composer model picker The picker (added in #147) only exposed the single configured Anthropic model plus local Ollama models, so there was no way to switch, e.g., Opus 4.8 to Fable 5 for a message. Add agent.selectable_models: extra Anthropic model ids that GET /api/models emits alongside the default (de-duplicated, order preserved). The existing composer picker already renders 2+ models and the engine already switches model per turn, so no session state, migration, or new UI is needed. Enabled with [claude-fable-5] in config.example.yaml. Co-Authored-By: Claude Opus 4.7 --- config.example.yaml | 4 ++++ nerve/config.py | 4 ++++ nerve/gateway/routes/models.py | 9 ++++++++- tests/test_engine.py | 10 ++++++++++ 4 files changed, 26 insertions(+), 1 deletion(-) diff --git a/config.example.yaml b/config.example.yaml index 5fd7e09..d81d3c6 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -36,6 +36,10 @@ agent: # Use claude-fable-5 for Anthropic's most capable (Mythos-class) model. # Note: ~2x the cost of Opus 4.8 ($10/$50 per MTok in/out). cron_model: claude-sonnet-4-6 # Cheaper model for cron jobs + # Extra Anthropic models offered in the web composer's model picker (alongside + # `model`). Lets you switch a message to a stronger model like Fable 5 on demand. + selectable_models: + - claude-fable-5 title_model: claude-haiku-4-5-20251001 # Session title generation max_turns: 50 # Max agentic turns per request max_concurrent: 4 # Max concurrent agent sessions diff --git a/nerve/config.py b/nerve/config.py index 7d5849f..61eb2bb 100644 --- a/nerve/config.py +++ b/nerve/config.py @@ -143,6 +143,9 @@ class AgentConfig: cron_backend: str = "" model: str = "claude-opus-4-8" cron_model: str = "claude-sonnet-4-6" + # Extra Anthropic chat models to offer in the composer's model picker + # alongside `model`. Empty = only the default model is selectable. + selectable_models: list[str] = field(default_factory=list) title_model: str = "claude-haiku-4-5-20251001" # Session title generation max_turns: int = 100 max_concurrent: int = 4 @@ -202,6 +205,7 @@ def from_dict(cls, d: dict) -> AgentConfig: cron_backend=str(d.get("cron_backend") or "").strip().lower(), model=d.get("model", "claude-opus-4-8"), cron_model=d.get("cron_model", "claude-sonnet-4-6"), + selectable_models=list(d.get("selectable_models") or []), title_model=d.get("title_model", "claude-haiku-4-5-20251001"), max_turns=d.get("max_turns", 100), max_concurrent=d.get("max_concurrent", 4), diff --git a/nerve/gateway/routes/models.py b/nerve/gateway/routes/models.py index c2a9d62..ae9065e 100644 --- a/nerve/gateway/routes/models.py +++ b/nerve/gateway/routes/models.py @@ -80,8 +80,15 @@ async def list_models(user: dict = Depends(require_auth)): "diagnostics": {"codex": codex_preflight}, } + # Default model first, then any extra selectable Anthropic models + # (config.agent.selectable_models), de-duplicated, order preserved. + anthropic_ids: list[str] = [default_model] + for m in config.agent.selectable_models: + if m and m not in anthropic_ids: + anthropic_ids.append(m) + 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({ diff --git a/tests/test_engine.py b/tests/test_engine.py index b2ec645..2477fee 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -130,6 +130,16 @@ def test_claude_system_prompt_excludes_codex_runbook_policy(tmp_path): assert "Codex-native skills" not in str(options.system_prompt) +def test_agent_config_selectable_models(): + # Defaults to an empty list (only the default model is selectable). + assert AgentConfig.from_dict({}).selectable_models == [] + # Explicit list preserved; None coerces to []. + assert AgentConfig.from_dict( + {"selectable_models": ["claude-fable-5"]} + ).selectable_models == ["claude-fable-5"] + assert AgentConfig.from_dict({"selectable_models": None}).selectable_models == [] + + # --------------------------------------------------------------------------- # ClaudeClient.receive_turn — per-message idle timeout (hung-CLI detection) # ---------------------------------------------------------------------------