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
10 changes: 4 additions & 6 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,11 @@ agent:
max_concurrent: 4 # Max concurrent agent sessions
thinking: max # Thinking budget for the main model
effort: max # Reasoning effort for the main model
# Cron / hook sessions use the cron_model (Sonnet). Under Claude OAuth
# (subscription) Sonnet caps thinking budget and rejects "max" with
# Note: cron/hook sessions run on cron_model (Sonnet). Under Claude OAuth
# (subscription) Sonnet rejects thinking/effort="max" with
# level "max" not supported, valid levels: low, medium, high
# Keep these at "high" unless your provider accepts "max" for the
# cron model.
cron_thinking: high
cron_effort: high
# Nerve detects OAuth via `proxy.enabled` and automatically caps
# cron/hook sessions at "high" in that case. API users keep "max".
# First-prompt rewrite — the web UI can refine the opening message of a
# new chat with a fast model, preview it, and send only after approval.
prompt_rewrite:
Expand Down
6 changes: 2 additions & 4 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,8 @@ from any working directory:
| `agent.cron_model` | string | `claude-sonnet-4-6` | Model for cron jobs (cheaper) |
| `agent.max_turns` | int | `50` | Max agentic turns per request |
| `agent.max_concurrent` | int | `4` | Max concurrent agent sessions |
| `agent.thinking` | string | `max` | Thinking budget for the main model: `max` / `high` / `medium` / `low` / `disabled` / `adaptive` / explicit token count |
| `agent.effort` | string | `max` | Reasoning effort for the main model: `max` / `high` / `medium` / `low` |
| `agent.cron_thinking` | string | `high` | Thinking budget for cron and hook sessions (which run on `cron_model`). Claude OAuth caps non-flagship models at `high` and rejects `max` with `level "max" not supported` — keep this at `high` unless you're using a provider that accepts `max` for the cron model. |
| `agent.cron_effort` | string | `high` | Reasoning effort for cron and hook sessions. Same caveat as `cron_thinking`. |
| `agent.thinking` | string | `max` | Thinking budget for the main model: `max` / `high` / `medium` / `low` / `disabled` / `adaptive` / explicit token count. Automatically capped at `high` for cron and hook sessions when `proxy.enabled` is true (Claude OAuth subscription rejects `max` on non-flagship models like Sonnet). |
| `agent.effort` | string | `max` | Reasoning effort for the main model: `max` / `high` / `medium` / `low`. Same OAuth+cron cap as `thinking`. |
| `agent.prompt_rewrite.enabled` | bool | `true` | Offer the first-prompt rewrite feature in the web UI (per-user toggle lives in the composer) |
| `agent.prompt_rewrite.model` | string | `""` | Model for prompt rewriting (empty = `agent.model`, the chat model) |
| `agent.prompt_rewrite.max_tokens` | int | `1024` | Max tokens for the rewritten prompt |
Expand Down
48 changes: 27 additions & 21 deletions nerve/agent/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,27 +70,33 @@

_SURROGATE_RE = re.compile(r"[\ud800-\udfff]")

def _select_thinking_effort(agent_config: Any, source: str) -> tuple[str, str]:
"""Pick (thinking, effort) settings for a session based on its source.

Cron and hook sessions run on `cron_model` (typically Sonnet), which
under Claude OAuth (subscription) does not accept `level=max` for
thinking/effort and rejects requests with
`level "max" not supported, valid levels: low, medium, high`. Use
the dedicated `cron_*` overrides for those sources so cron jobs
don't get blocked while interactive sessions keep their full
thinking budget.
_OAUTH_CRON_CAP = "high"


def _select_thinking_effort(config: Any, source: str) -> tuple[str, str]:
"""Pick (thinking, effort) for a session, downgrading only under OAuth.

Cron and hook sessions run on ``agent.cron_model`` (typically Sonnet).
Under Claude OAuth (subscription) Sonnet caps thinking/effort at
``high`` and the CLI rejects ``max`` with::

API Error: 400 level "max" not supported, valid levels: low, medium, high

OAuth mode in nerve is gated by the local cli-proxy-api wrapping
the user's subscription credentials — i.e. ``config.proxy.enabled``.
When that's on AND the session is cron/hook, cap both knobs to
``high``. API users (no proxy) and interactive sessions keep the
user's configured value unchanged — defaults stay ``max`` for
everyone, only the narrow OAuth+cron path is downgraded.
"""
is_cron_like = source in ("cron", "hook")
thinking_value = (
agent_config.cron_thinking if is_cron_like
else agent_config.thinking
)
effort_value = (
agent_config.cron_effort if is_cron_like
else agent_config.effort
)
return thinking_value, effort_value
thinking = config.agent.thinking
effort = config.agent.effort
if source in ("cron", "hook") and config.proxy.enabled:
if thinking == "max":
thinking = _OAUTH_CRON_CAP
if effort == "max":
effort = _OAUTH_CRON_CAP
return thinking, effort


# Anthropic API image limits
Expand Down Expand Up @@ -992,7 +998,7 @@ def _build_options(
# interactive → main settings), then cap each to what the resolved
# model actually supports.
thinking_value, effort_value = _select_thinking_effort(
self.config.agent, source,
self.config, source,
)
thinking_config = self._parse_thinking_config(
thinking_value,
Expand Down
2 changes: 0 additions & 2 deletions nerve/bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -1962,8 +1962,6 @@ def _write_config_yaml(self) -> None:
"max_concurrent": 4,
"thinking": "max",
"effort": "max",
"cron_thinking": "high",
"cron_effort": "high",
"context_1m": True,
},
"gateway": {
Expand Down
11 changes: 0 additions & 11 deletions nerve/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,15 +138,6 @@ class AgentConfig:
max_concurrent: int = 4
thinking: str = "max" # max, high, medium, low, disabled, adaptive, or number (budget_tokens)
effort: str = "max" # max, xhigh, high, medium, low
# Cron / hook overrides — Claude OAuth (subscription) caps thinking budget
# for non-flagship models like Sonnet, rejecting effort/thinking="max" with
# `level "max" not supported, valid levels: low, medium, high`. Use a
# separate, lower setting for cron sessions (which run on `cron_model`)
# so cron jobs don't get blocked while keeping `effort=max` for the main
# interactive model. (Note: upstream's _effective_effort also caps per
# model — this picks the *raw* level, capping happens downstream.)
cron_thinking: str = "high"
cron_effort: str = "high"
context_1m: bool = True # Enable 1M context window beta
# Substrings of model names for which the context-1m beta header must NOT
# be sent (some subscriptions reject the beta for specific models — e.g.
Expand All @@ -172,8 +163,6 @@ def from_dict(cls, d: dict) -> AgentConfig:
max_concurrent=d.get("max_concurrent", 4),
thinking=str(d.get("thinking", "max")),
effort=str(d.get("effort", "max")),
cron_thinking=str(d.get("cron_thinking", "high")),
cron_effort=str(d.get("cron_effort", "high")),
context_1m=d.get("context_1m", True),
context_1m_excluded_models=list(
d.get("context_1m_excluded_models", []) or []
Expand Down
195 changes: 113 additions & 82 deletions tests/test_engine_options.py
Original file line number Diff line number Diff line change
@@ -1,112 +1,143 @@
"""Tests for engine option helpers — thinking/effort selection per source.
"""Tests for engine option helpers — OAuth-conditional thinking/effort cap.

Regression for the issue where every cron run failed with
``API Error: 400 level "max" not supported, valid levels: low, medium, high``
because the global ``effort=max`` / ``thinking=max`` settings were applied
to cron sessions that run on ``cron_model`` (Sonnet) under Claude OAuth,
to cron sessions running on ``cron_model`` (Sonnet) under Claude OAuth,
which caps non-flagship models at ``high``.

The fix introduces dedicated ``agent.cron_thinking`` / ``agent.cron_effort``
fields and a ``_select_thinking_effort`` helper that picks the right pair
based on ``source`` (``cron`` / ``hook`` get the cron overrides, everything
else keeps the main settings).
The fix downgrades ``thinking`` and ``effort`` to ``high`` for cron and
hook sessions **only when OAuth is in use** (``config.proxy.enabled``).
API users keep ``max`` for every session, and interactive sessions
(web/Telegram/Discord/...) keep ``max`` even under OAuth — only the
narrow OAuth+cron path is touched.
"""

from __future__ import annotations

import pytest

from nerve.agent.engine import _select_thinking_effort
from nerve.config import AgentConfig
from nerve.config import AgentConfig, NerveConfig, ProxyConfig


@pytest.fixture
def agent_config() -> AgentConfig:
"""Default AgentConfig — main = max, cron = high."""
return AgentConfig()
def _make_config(
*,
thinking: str = "max",
effort: str = "max",
proxy_enabled: bool = False,
) -> NerveConfig:
"""Build a minimal NerveConfig for testing _select_thinking_effort.

Only the ``agent`` and ``proxy`` fields are read by the helper; the
rest can stay at their defaults.
"""
return NerveConfig(
agent=AgentConfig(thinking=thinking, effort=effort),
proxy=ProxyConfig(enabled=proxy_enabled),
)


class TestSelectThinkingEffort:
"""``_select_thinking_effort`` routes settings by session source."""
"""``_select_thinking_effort`` downgrades only when OAuth + cron/hook."""

def test_defaults_match_documented_values(self, agent_config: AgentConfig):
"""Sanity check: the defaults are the ones the docs promise."""
assert agent_config.thinking == "max"
assert agent_config.effort == "max"
assert agent_config.cron_thinking == "high"
assert agent_config.cron_effort == "high"
# ------------------------------------------------------------------ #
# OAuth on, cron-like source: must downgrade max -> high #
# ------------------------------------------------------------------ #

@pytest.mark.parametrize("source", ["web", "telegram", "discord", "api", ""])
def test_interactive_sources_use_main_settings(
self, agent_config: AgentConfig, source: str,
):
"""Anything that isn't cron/hook keeps the main thinking/effort."""
thinking, effort = _select_thinking_effort(agent_config, source)
assert thinking == "max"
assert effort == "max"
@pytest.mark.parametrize("source", ["cron", "hook"])
def test_oauth_caps_cron_max_to_high(self, source: str):
"""The exact bug being fixed: OAuth + cron + max -> 'high'."""
config = _make_config(thinking="max", effort="max", proxy_enabled=True)
assert _select_thinking_effort(config, source) == ("high", "high")

@pytest.mark.parametrize("source", ["cron", "hook"])
def test_cron_and_hook_use_cron_overrides(
self, agent_config: AgentConfig, source: str,
):
"""Cron and hook sessions must use ``cron_thinking`` / ``cron_effort``.

This is the regression — the original code always passed
``effort=max`` regardless of source, blocking every cron run.
"""
thinking, effort = _select_thinking_effort(agent_config, source)
assert thinking == "high"
assert effort == "high"

def test_overrides_propagate_from_config(self):
"""Custom values in AgentConfig are honored, not overwritten."""
config = AgentConfig(
thinking="medium",
effort="medium",
cron_thinking="low",
cron_effort="low",
def test_oauth_does_not_upgrade_lower_values(self, source: str):
"""The cap is a max-only cap, not a forced value. Lower settings pass through."""
config = _make_config(
thinking="medium", effort="low", proxy_enabled=True,
)
assert _select_thinking_effort(config, "web") == ("medium", "medium")
assert _select_thinking_effort(config, "cron") == ("low", "low")
assert _select_thinking_effort(config, "hook") == ("low", "low")

def test_main_and_cron_can_differ_independently(self):
"""Cron settings don't have to mirror main settings."""
config = AgentConfig(
thinking="max", effort="max",
cron_thinking="medium", cron_effort="low",
assert _select_thinking_effort(config, source) == ("medium", "low")

@pytest.mark.parametrize("source", ["cron", "hook"])
def test_oauth_caps_individually(self, source: str):
"""Each knob is capped independently if it's at 'max'."""
config = _make_config(
thinking="max", effort="medium", proxy_enabled=True,
)
thinking_main, effort_main = _select_thinking_effort(config, "web")
thinking_cron, effort_cron = _select_thinking_effort(config, "cron")
assert (thinking_main, effort_main) == ("max", "max")
assert (thinking_cron, effort_cron) == ("medium", "low")
assert _select_thinking_effort(config, source) == ("high", "medium")

config = _make_config(
thinking="medium", effort="max", proxy_enabled=True,
)
assert _select_thinking_effort(config, source) == ("medium", "high")

class TestAgentConfigFromDict:
"""``AgentConfig.from_dict`` accepts the new cron_* fields."""

def test_omitted_cron_fields_default_to_high(self):
"""Configs predating this fix still load — defaults kick in."""
config = AgentConfig.from_dict({})
assert config.cron_thinking == "high"
assert config.cron_effort == "high"

def test_cron_fields_are_loaded_from_dict(self):
config = AgentConfig.from_dict({
"cron_thinking": "medium",
"cron_effort": "low",
})
assert config.cron_thinking == "medium"
assert config.cron_effort == "low"
# ------------------------------------------------------------------ #
# OAuth on, interactive source: must NOT downgrade #
# ------------------------------------------------------------------ #

@pytest.mark.parametrize("source", ["web", "telegram", "discord", "api", ""])
def test_oauth_does_not_downgrade_interactive_sources(self, source: str):
"""Interactive sessions run on agent.model (Opus by default) which
accepts max under OAuth. Don't touch them."""
config = _make_config(thinking="max", effort="max", proxy_enabled=True)
assert _select_thinking_effort(config, source) == ("max", "max")

# ------------------------------------------------------------------ #
# OAuth off (API key): never downgrade — Artem's requirement #
# ------------------------------------------------------------------ #

@pytest.mark.parametrize("source", ["cron", "hook", "web", "telegram", ""])
def test_api_users_never_downgraded(self, source: str):
"""Without the local proxy (i.e. user has a real Anthropic API key),
every source keeps the configured value. This is exactly what Artem
asked for on ClickHouse/nerve#129 — don't change behavior for API
users."""
config = _make_config(thinking="max", effort="max", proxy_enabled=False)
assert _select_thinking_effort(config, source) == ("max", "max")

@pytest.mark.parametrize("source", ["cron", "hook"])
def test_api_users_keep_custom_values_for_cron(self, source: str):
"""API users can pick any value for cron sessions too — no special
treatment."""
config = _make_config(
thinking="medium", effort="low", proxy_enabled=False,
)
assert _select_thinking_effort(config, source) == ("medium", "low")

# ------------------------------------------------------------------ #
# Defaults sanity check #
# ------------------------------------------------------------------ #

def test_main_settings_unaffected_by_cron_fields(self):
config = AgentConfig.from_dict({
def test_defaults_match_documented_values(self):
"""Defaults: max/max for everyone; no special cron knobs anymore."""
cfg = AgentConfig()
assert cfg.thinking == "max"
assert cfg.effort == "max"
# The cron_thinking / cron_effort knobs were removed — keep them
# gone so we don't reintroduce the unconditional downgrade.
assert not hasattr(cfg, "cron_thinking")
assert not hasattr(cfg, "cron_effort")


class TestAgentConfigFromDict:
"""``AgentConfig.from_dict`` no longer reads cron_thinking/cron_effort."""

def test_from_dict_ignores_legacy_cron_keys(self):
"""Old configs with cron_thinking/cron_effort load cleanly — the
keys are silently ignored. Backwards-compatible: nothing crashes,
the keys just don't do anything anymore (their behavior moved to
the engine's OAuth check)."""
cfg = AgentConfig.from_dict({
"thinking": "max",
"effort": "max",
"cron_thinking": "low",
"cron_effort": "low",
"cron_thinking": "high", # legacy, ignored
"cron_effort": "high", # legacy, ignored
})
assert config.thinking == "max"
assert config.effort == "max"
assert config.cron_thinking == "low"
assert config.cron_effort == "low"
assert cfg.thinking == "max"
assert cfg.effort == "max"

def test_from_dict_empty_uses_defaults(self):
cfg = AgentConfig.from_dict({})
assert cfg.thinking == "max"
assert cfg.effort == "max"