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: 7 additions & 6 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -57,20 +57,21 @@ timezone: America/New_York
# Agent
agent:
model: claude-opus-5-5 # Primary model for conversations (Nerve's default)
# Use claude-fable-5-1 for Anthropic's newest generation (Mythos-class).
# Note: ~2x the cost of Opus ($10/$50 per MTok in/out).
# Use claude-fable-5-2 for Anthropic's newest generation (Mythos-class).
# Note: 2x the cost of Opus 5.5 ($8/$40 per MTok in/out).
cron_model: claude-sonnet-5-5 # Cheaper model for cron jobs
title_model: claude-haiku-5-5 # Session title generation
# outcome_titles: false # Keep the first-message title instead of renaming once the first answer ends
# Model-alias remapping for the CLI: short aliases ("opus") used in
# Agent/Workflow tool model options and skill frontmatter resolve to the
# mapped ID. Supported: opus, sonnet, haiku, fable. opus → claude-opus-5-5
# and haiku → claude-haiku-5-5 are the built-in defaults (not applied on
# Bedrock); "" unsets an alias. The CLI also uses the haiku model for its
# background calls.
# mapped ID. Supported: opus, sonnet, haiku, fable. opus → claude-opus-5-5,
# haiku → claude-haiku-5-5 and fable → claude-fable-5-2 are the built-in
# defaults (not applied on Bedrock); "" unsets an alias. The CLI also uses
# the haiku model for its background calls.
# model_aliases:
# opus: claude-opus-5-5
# haiku: claude-haiku-5-5
# fable: claude-fable-5-2
# Claude models selectable in the web composer's model picker. The
# configured `model` above is always offered first; entries here extend
# the list. Unset → the models the Anthropic Models API reports for your
Expand Down
4 changes: 2 additions & 2 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -539,10 +539,10 @@ the `reason` names the variable.
```json
Response: {
"models": [{ "id": "claude-opus-5-5", "label": "Claude Opus 5.5", "family": "opus", "recommended": true },
{ "id": "claude-fable-5-2", "label": "Claude Fable 5.2", "family": "fable", "recommended": false },
{ "id": "claude-haiku-5-5", "label": "Claude Haiku 5.5", "family": "haiku", "recommended": false },
{ "id": "claude-sonnet-5-5", "label": "Claude Sonnet 5.5", "family": "sonnet", "recommended": false },
{ "id": "claude-fable-5-1", "label": "Claude Fable 5.1", "family": "fable", "recommended": false },
{ "id": "claude-opus-5", "label": "Claude Opus 5", "family": "opus", "recommended": false }],
{ "id": "claude-fable-5-1", "label": "Claude Fable 5.1", "family": "fable", "recommended": false }],
"current": { "model": "claude-opus-5-5", "effort": "xhigh" },
"efforts": ["max", "xhigh", "high", "medium", "low"],
"discovered": true,
Expand Down
2 changes: 1 addition & 1 deletion docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -986,7 +986,7 @@ Backups follow the same rule:
| `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 / fable / 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' catalog endpoint advertises, and offer those in the picker — so a newly released model needs no config edit. Catalog membership is not a serving guarantee (an advertised ID can still 404 on send; discovery runs no serving probe — see `agent.model_discovery_excluded_models`). 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_discovery_excluded_models` | list | `[]` | Model-ID substrings pruned from **discovery** results before the picker sees them (case-insensitive; empty/whitespace patterns ignored). A proxy/provider catalog is not a serving guarantee — e.g. a retired ID that `GET /v1/models` still advertises but that 404s on send — so this drops such entries without a code change, and hot-reloads (read at pick time, not baked into the 6h discovery cache). Applied only to discovered entries: `agent.model` and an explicit `agent.models` list are never filtered, so naming a model in config overrides an exclusion. Excluding everything discovery returns just leaves the configured default (not a fallback to the built-in list) |
| `agent.model_aliases` | map | `{opus: claude-opus-5-5, haiku: claude-haiku-5-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-5` and `haiku → claude-haiku-5-5` defaults (the `agent.model` and `memory.fast_model` defaults; not applied on Bedrock — set geo-prefixed IDs explicitly there); `""` unsets an alias. The CLI also uses the `haiku` model for its background calls unless `ANTHROPIC_SMALL_FAST_MODEL` is set |
| `agent.model_aliases` | map | `{opus: claude-opus-5-5, haiku: claude-haiku-5-5, fable: claude-fable-5-2}` | 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-5`, `haiku → claude-haiku-5-5` and `fable → claude-fable-5-2` defaults (the `agent.model` and `memory.fast_model` defaults and the picker's second built-in entry; not applied on Bedrock — set geo-prefixed IDs explicitly there); `""` unsets an alias. The CLI also uses the `haiku` model for its background calls unless `ANTHROPIC_SMALL_FAST_MODEL` is set |
| `agent.max_turns` | int | `1000` | Max agentic turns (model ↔ tool round-trips) per request on the Claude backend; a request that reaches it fails with "Reached maximum number of turns (N)". A last-resort stop for a looping agent, not a budget — real work routinely passes 50 and 100. `0` = no cap (the CLI gets no `--max-turns` flag). Read when a session's CLI client is built, so a changed value applies to new sessions, and to existing ones once their client is rebuilt |
| `agent.max_concurrent` | int | `32` | Max concurrent agent sessions |
| `agent.cache_ttl` | string | `"5m"` | Prompt-cache write TTL policy: `5m` (status quo), `1h` (always request the 1-hour TTL), or `auto` (per session at client-build time: sparse-cadence sessions — persistent crons, wakeup loops, spaced chats — get `1h`; dense sessions stay on `5m`). Per-cron-job override via `cache_ttl` in jobs.yaml. See `nerve/agent/cache_policy.py` |
Expand Down
10 changes: 6 additions & 4 deletions nerve/agent/backends/claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -895,20 +895,22 @@ def _build_env(
# Model-alias remapping: short aliases ("opus", "sonnet", ...) used
# in Agent/Workflow tool model options, skill frontmatter, and cron
# overrides resolve INSIDE the CLI via ANTHROPIC_DEFAULT_<ALIAS>_MODEL
# env vars. Nerve defaults "opus" → nerve.config.DEFAULT_MODEL and
# "haiku" → nerve.config.DEFAULT_FAST_MODEL (skipped on Bedrock, where
# env vars. Nerve defaults "opus" → nerve.config.DEFAULT_MODEL,
# "haiku" → nerve.config.DEFAULT_FAST_MODEL and "fable" →
# nerve.config.DEFAULT_FALLBACK_MODEL (skipped on Bedrock, where
# model IDs need a geo prefix — set agent.model_aliases with explicit
# prefixed IDs there). The haiku value is also the CLI's small fast
# model for background calls: the CLI reads ANTHROPIC_SMALL_FAST_MODEL
# first and falls back to ANTHROPIC_DEFAULT_HAIKU_MODEL.
# User-configured aliases merge over the defaults; an empty value
# unsets one (CLI built-in mapping wins).
from nerve.config import DEFAULT_FAST_MODEL, DEFAULT_MODEL
from nerve.config import DEFAULT_FALLBACK_MODEL, DEFAULT_FAST_MODEL, DEFAULT_MODEL

aliases: dict[str, str] = {}
if not config.provider.is_bedrock:
aliases["opus"] = DEFAULT_MODEL
aliases["haiku"] = DEFAULT_FAST_MODEL
aliases["fable"] = DEFAULT_FALLBACK_MODEL
aliases.update(config.agent.model_aliases)
for alias, target in aliases.items():
env_name = _MODEL_ALIAS_ENV_VARS.get(alias.strip().lower())
Expand Down Expand Up @@ -1164,7 +1166,7 @@ def _parse_thinking_config(
# Ordered most-specific to least-specific; first match wins. Mirrors the
# pattern used by MODEL_PRICING in nerve/db/usage.py.
_MODEL_EFFORT_LEVELS: dict[str, tuple[str, ...]] = {
"fable-5": ("low", "medium", "high", "xhigh", "max"),
"fable-5": ("low", "medium", "high", "xhigh", "max"), # Fable 5 / 5.1 / 5.2
"opus-5-5": ("low", "medium", "high", "xhigh", "max"),
"opus-5": ("low", "medium", "high", "xhigh", "max"),
"sonnet-5-5": ("low", "medium", "high", "xhigh", "max"),
Expand Down
11 changes: 6 additions & 5 deletions nerve/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
#
# This block is the only place a default model id is spelled. Every other
# site (the setup writer, the Bedrock geo-prefixed ids, the Claude CLI's
# ``opus`` and ``haiku`` aliases, the terminal wizard's example line)
# ``opus``, ``haiku`` and ``fable`` aliases, the terminal wizard's example line)
# references these names, and ``tests/test_default_models.py`` fails on a
# model-id literal anywhere else under ``nerve/`` — so the next update is one
# edit here plus the regenerated goldens, examples and docs.
Expand All @@ -42,7 +42,7 @@
DEFAULT_RECALL_MODEL = "claude-sonnet-5-5" # memory.recall_model
DEFAULT_MEMORIZE_MODEL = "claude-sonnet-5-5" # memory.memorize_model
DEFAULT_FAST_MODEL = "claude-haiku-5-5" # memory.fast_model; also the CLI's ``haiku`` alias
DEFAULT_FALLBACK_MODEL = "claude-fable-5-1" # second entry of the built-in picker list
DEFAULT_FALLBACK_MODEL = "claude-fable-5-2" # second entry of the built-in picker list; also the CLI's ``fable`` alias
DEFAULT_EFFORT = "xhigh" # agent.effort (conversations); max is slower for no real gain on the 5.5 models

# Fallback for the composer's Claude model picker when ``agent.models`` is
Expand Down Expand Up @@ -1096,9 +1096,10 @@ class AgentConfig:
# emitted as ANTHROPIC_DEFAULT_<ALIAS>_MODEL env vars so short aliases
# ("opus" in Agent/Workflow tool calls, skill frontmatter, cron model
# overrides) resolve to the mapped model. Supported aliases: opus,
# sonnet, haiku, fable. Nerve defaults opus → DEFAULT_MODEL and
# haiku → DEFAULT_FAST_MODEL on non-Bedrock providers; the CLI also uses
# the haiku model for its background calls. Entries here merge over those
# sonnet, haiku, fable. Nerve defaults opus → DEFAULT_MODEL,
# haiku → DEFAULT_FAST_MODEL and fable → DEFAULT_FALLBACK_MODEL on
# non-Bedrock providers; the CLI also uses the haiku model for its
# background calls. Entries here merge over those
# defaults, and an empty value ("") unsets one (falls back to the CLI's
# built-in mapping).
model_aliases: dict[str, str] = field(default_factory=dict)
Expand Down
6 changes: 6 additions & 0 deletions nerve/db/usage.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@
# Key: canonical model short-name substring → (input, output, cache_read,
# cache_write_5m, cache_write_1h, web_search_per_req)
MODEL_PRICING: dict[str, tuple[float, float, float, float, float, float]] = {
# Fable: 5.2 is the current one ($8/$40, cache reads at 0.025x = $0.20);
# 5.1 kept the $10/$50 tier but already read the cache at 0.025x ($0.25);
# Fable 5 reads at the usual 0.1x ($1). Most-specific key first — the bare
# "fable-5" row must not catch 5.1 or 5.2. Verified 2026-11-09.
"fable-5-2": (8, 40, 0.20, 10.00, 16.00, 0.01), # Fable 5.2 (2x Opus 5.5)
"fable-5-1": (10, 50, 0.25, 12.50, 20.00, 0.01), # Fable 5.1
"fable-5": (10, 50, 1.00, 12.50, 20.00, 0.01), # Fable 5 (Mythos-class, ~2x Opus)
"opus-5-5": (4, 20, 0.20, 5.00, 8.00, 0.01), # Opus 5.5 standard (verified 2026-09-22)
"opus-5": (5, 25, 0.50, 6.25, 10.00, 0.01), # Opus 5 standard (verified 2026-07-28)
Expand Down
8 changes: 7 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,13 @@ dependencies = [
# rejects models released after it with "does not support this model;
# version 2.1.251 or newer is required". 0.2.150 bundles 2.1.257. Every
# release in 0.2.141..0.2.150 is a CLI-bundle bump only — no API change.
"claude-agent-sdk>=0.2.161",
# Floor raised to 0.2.180 (bundles 2.1.321) for Claude Fable 5.2: 0.2.176's
# 2.1.316 accepts the id but as an "unrecognized_model" — it reports a
# 200K context window (auto-compaction at a fifth of the real 1M) and
# prices the turn at Opus 5.5 rates, which is what Nerve records as the
# turn's cost. 0.2.177..0.2.180 change no Python API (one optional hook
# option added).
"claude-agent-sdk>=0.2.180",
# Declared explicitly because nerve/mcp_server/ imports `mcp` directly
# rather than only through claude-agent-sdk. Both bounds are load-bearing:
# nerve/mcp_server/ is written against the 2.x lowlevel API (constructor
Expand Down
15 changes: 15 additions & 0 deletions tests/test_cache_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,21 @@ def test_build_env_default_haiku_alias_maps_to_the_fast_model():
assert "ANTHROPIC_SMALL_FAST_MODEL" not in env


def test_build_env_default_fable_alias_maps_to_the_fallback_model():
# The "fable" alias resolves to nerve.config.DEFAULT_FALLBACK_MODEL (the
# picker's second built-in entry), so an Agent/Workflow call asking for
# "fable" gets Nerve's current Fable whatever the CLI's own default is.
from nerve.config import DEFAULT_FALLBACK_MODEL

env = _make_env_backend()._build_env()
assert env["ANTHROPIC_DEFAULT_FABLE_MODEL"] == DEFAULT_FALLBACK_MODEL == "claude-fable-5-2"
# Config overrides it and "" unsets it like the other defaults; Bedrock gets none.
env = _make_env_backend(aliases={"fable": "claude-fable-5-1"})._build_env()
assert env["ANTHROPIC_DEFAULT_FABLE_MODEL"] == "claude-fable-5-1"
assert "ANTHROPIC_DEFAULT_FABLE_MODEL" not in _make_env_backend(aliases={"fable": ""})._build_env()
assert "ANTHROPIC_DEFAULT_FABLE_MODEL" not in _make_env_backend(is_bedrock=True)._build_env()


def test_build_env_haiku_alias_config_overrides_and_unsets_default():
env = _make_env_backend(aliases={"haiku": "claude-haiku-4-5"})._build_env()
assert env["ANTHROPIC_DEFAULT_HAIKU_MODEL"] == "claude-haiku-4-5"
Expand Down
4 changes: 3 additions & 1 deletion tests/test_default_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ def test_no_default_model_literal_outside_the_constants_block():
def test_the_constants_agree_with_each_other():
assert DEFAULT_MODEL == "claude-opus-5-5"
assert DEFAULT_TITLE_MODEL == DEFAULT_FAST_MODEL == "claude-haiku-5-5"
assert DEFAULT_FALLBACK_MODEL == "claude-fable-5-2"
assert DEFAULT_CLAUDE_MODELS[0] == DEFAULT_MODEL
assert DEFAULT_CLAUDE_MODELS[1] == DEFAULT_FALLBACK_MODEL
assert len(set(DEFAULT_CLAUDE_MODELS)) == len(DEFAULT_CLAUDE_MODELS)
Expand Down Expand Up @@ -119,7 +120,7 @@ def test_the_writer_and_the_cli_alias_use_the_constants():
assert bedrock["memory"]["fast_model"] == f"{geo}.anthropic.{DEFAULT_FAST_MODEL}"
assert bedrock["memory"]["fast_model"] == "eu.anthropic.claude-haiku-5-5"

# The Claude CLI's built-in `opus` and `haiku` aliases are the defaults.
# The Claude CLI's built-in `opus`, `haiku` and `fable` aliases are the defaults.
from types import SimpleNamespace

from nerve.agent.backends.claude import ClaudeBackend
Expand All @@ -137,6 +138,7 @@ def test_the_writer_and_the_cli_alias_use_the_constants():
env = ClaudeBackend(SimpleNamespace(config=lambda: config))._build_env()
assert env["ANTHROPIC_DEFAULT_OPUS_MODEL"] == DEFAULT_MODEL
assert env["ANTHROPIC_DEFAULT_HAIKU_MODEL"] == DEFAULT_FAST_MODEL
assert env["ANTHROPIC_DEFAULT_FABLE_MODEL"] == DEFAULT_FALLBACK_MODEL


def test_the_examples_and_templates_carry_the_default():
Expand Down
3 changes: 3 additions & 0 deletions tests/test_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@
("max", "claude-fable-5", "max"),
("xhigh", "claude-fable-5", "xhigh"),
("low", "claude-fable-5", "low"),
# Fable 5.2 keeps the full ladder (default effort high; verified 2026-11-09)
("max", "claude-fable-5-2", "max"),
("xhigh", "claude-fable-5-2", "xhigh"),
# Opus 5 supports the full ladder (verified via Models API 2026-07-28)
("max", "claude-opus-5", "max"),
("xhigh", "claude-opus-5", "xhigh"),
Expand Down
2 changes: 1 addition & 1 deletion tests/test_setup_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -2662,7 +2662,7 @@ def test_model_labels_are_formatted_consistently():
from nerve.config import DEFAULT_CLAUDE_MODELS

assert [setup_routes._label(m) for m in DEFAULT_CLAUDE_MODELS] == [
"Claude Opus 5.5", "Claude Fable 5.1", "Claude Sonnet 5.5", "Claude Haiku 5.5",
"Claude Opus 5.5", "Claude Fable 5.2", "Claude Sonnet 5.5", "Claude Haiku 5.5",
]


Expand Down
21 changes: 21 additions & 0 deletions tests/test_usage_cache_ttl.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,27 @@ def test_fable_5_rates(self):
assert c5m == 12.50
assert c1h == 20.00

def test_fable_5_2_rates(self):
# Fable 5.2 (verified 2026-11-09): base $8/MTok in, $40/MTok out,
# 5m write = 1.25x = $10/MTok, 1h write = 2x = $16/MTok, cache reads
# at 0.025x = $0.20/MTok. The Fable 5.x generations differ in more
# than the base rate, so each carries its own row.
from nerve.db.usage import MODEL_PRICING, _get_pricing
p_in, p_out, p_read, c5m, c1h, _ = _get_pricing("claude-fable-5-2")
assert (p_in, p_out, p_read, c5m, c1h) == (8, 40, 0.20, 10.00, 16.00)
assert _get_pricing("us.anthropic.claude-fable-5-2") == MODEL_PRICING["fable-5-2"]

def test_fable_5_x_rows_do_not_bleed_into_each_other(self):
# "fable-5" is a substring of "fable-5-1" and "fable-5-2", so the
# specific rows must be checked first. Fable 5.1 kept Fable 5's
# $10/$50 tier but reads the cache at 0.025x ($0.25, not $1).
from nerve.db.usage import MODEL_PRICING, _get_pricing
assert _get_pricing("claude-fable-5-2") == MODEL_PRICING["fable-5-2"]
assert _get_pricing("claude-fable-5-1") == MODEL_PRICING["fable-5-1"]
assert _get_pricing("claude-fable-5-1")[2] == 0.25
assert _get_pricing("claude-fable-5") == MODEL_PRICING["fable-5"]
assert _get_pricing("claude-fable-5")[2] == 1.00

def test_opus_5_rates(self):
# Anchor: Opus 5 — same tier as 4.8 (verified against published
# pricing 2026-07-28). Base $5/MTok in, $25/MTok out, 5m write =
Expand Down
Loading
Loading