diff --git a/config.example.yaml b/config.example.yaml index f494f8dd..3d3c617c 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -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: diff --git a/docs/config.md b/docs/config.md index aa928435..95374504 100644 --- a/docs/config.md +++ b/docs/config.md @@ -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 | diff --git a/nerve/agent/engine.py b/nerve/agent/engine.py index b7b82c12..bef2f712 100644 --- a/nerve/agent/engine.py +++ b/nerve/agent/engine.py @@ -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 @@ -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, diff --git a/nerve/bootstrap.py b/nerve/bootstrap.py index 733e500e..387c1683 100644 --- a/nerve/bootstrap.py +++ b/nerve/bootstrap.py @@ -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": { diff --git a/nerve/config.py b/nerve/config.py index f62181b1..263b86a0 100644 --- a/nerve/config.py +++ b/nerve/config.py @@ -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. @@ -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 [] diff --git a/tests/test_engine_options.py b/tests/test_engine_options.py index c448602a..25e7493b 100644 --- a/tests/test_engine_options.py +++ b/tests/test_engine_options.py @@ -1,15 +1,16 @@ -"""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 @@ -17,96 +18,126 @@ 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"