diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index 1cde73419a44..c6ed459e93d9 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -1305,6 +1305,13 @@ def restore_primary_runtime(agent) -> bool: primary_provider or "?", ) + # ── Restore reasoning_config if it was saved ── + # switch_model saves reasoning_config in _primary_runtime. If the + # snapshot predates that (older sessions), keep the current value. + saved_reasoning = rt.get("reasoning_config") + if saved_reasoning is not None: + agent.reasoning_config = dict(saved_reasoning) + # ── Reset fallback chain for the new turn ── agent._fallback_activated = False agent._fallback_index = 0 @@ -2065,6 +2072,24 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo api_mode=agent.api_mode, ) + # ── Re-resolve reasoning_config from per-model override ── + # The new model may have a different reasoning_effort override. Re-read + # config so the override takes effect immediately on /model switch — + # resolved through the shared chokepoint (per-model > global; YAML + # boolean False = disabled). + try: + from hermes_constants import resolve_reasoning_config + from hermes_cli.config import load_config as _sm_load_config + + _reasoning_cfg = _sm_load_config() or {} + agent.reasoning_config = resolve_reasoning_config(_reasoning_cfg, agent.model) + logger.info( + "switch_model: reasoning_config resolved for %s: %s", + agent.model, agent.reasoning_config, + ) + except Exception as _reasoning_err: + logger.debug("switch_model: could not re-resolve reasoning_config: %s", _reasoning_err) + # ── Invalidate cached system prompt so it rebuilds next turn ── agent._cached_system_prompt = None @@ -2087,6 +2112,7 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo "client_kwargs": dict(agent._client_kwargs), "use_prompt_caching": agent._use_prompt_caching, "use_native_cache_layout": agent._use_native_cache_layout, + "reasoning_config": dict(agent.reasoning_config) if getattr(agent, "reasoning_config", None) else None, "compressor_model": getattr(_cc, "model", agent.model) if _cc else agent.model, "compressor_base_url": getattr(_cc, "base_url", agent.base_url) if _cc else agent.base_url, "compressor_api_key": getattr(_cc, "api_key", "") if _cc else "", diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index b93dcf661717..9a4bc8a1fe6c 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -1635,6 +1635,28 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool api_mode=agent.api_mode, ) + # Re-resolve reasoning_config for the new fallback model (Closes #21256). + # Shared chokepoint: per-model override > global reasoning_effort + # (YAML boolean False = disabled). Wrapped in try/except because a + # config load failure must not kill the swap. + try: + from hermes_cli.config import load_config + from hermes_constants import resolve_reasoning_config + + agent.reasoning_config = resolve_reasoning_config( + load_config() or {}, agent.model + ) + logger.info( + "Fallback %s: reasoning_config resolved: %s", + agent.model, agent.reasoning_config, + ) + except Exception as _reasoning_err: + logger.debug( + "Failed to resolve reasoning_config for fallback %s; keeping current: %s", + agent.model, _reasoning_err, + ) + # Keep whatever reasoning_config was active — don't break the fallback swap. + # Keep the prompt's self-identity in sync with the model actually # answering, so "what model are you?" doesn't report the primary. rewrite_prompt_model_identity(agent, fb_model, fb_provider) diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 1858f451c9a4..2583bfb54f0b 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -742,6 +742,19 @@ agent: # Options: "xhigh" (max), "high", "medium", "low", "minimal", "none" (disable) reasoning_effort: "medium" + # Per-model reasoning effort overrides (optional dict) + # Key: any sensible model spelling works (exact, dots↔dashes interchangeable, + # provider prefix optional). First match wins. + # Value: reasoning effort level (same options as reasoning_effort) + # Override the global reasoning_effort for that specific model. + # NOTE: no `hermes config set` support for this key -- edit YAML directly. + # reasoning_overrides: + # "openrouter/anthropic/claude-opus-4.5": "xhigh" + # "openai/gpt-5": "low" + # "claude-opus-4.6": "high" # bare model name also works + # "deepseek/deepseek-v4-pro": "xhigh" # dots and dashes are interchangeable + reasoning_overrides: {} + # Predefined personalities (use with /personality command) personalities: helpful: "You are a helpful, friendly AI assistant." diff --git a/cli.py b/cli.py index 17d2bdd0e4e3..25cce4f95d05 100644 --- a/cli.py +++ b/cli.py @@ -3917,9 +3917,10 @@ def __init__( ) # Reasoning config (OpenRouter reasoning effort level) - self.reasoning_config = _parse_reasoning_config( - CLI_CONFIG["agent"].get("reasoning_effort", "") - ) + # Per-model override > global reasoning_effort — resolved through the + # shared chokepoint in hermes_constants (Closes #21256). + from hermes_constants import resolve_reasoning_config + self.reasoning_config = resolve_reasoning_config(CLI_CONFIG, self.model) self.service_tier = _parse_service_tier_config( CLI_CONFIG["agent"].get("service_tier", "") ) diff --git a/cron/scheduler.py b/cron/scheduler.py index a50704ea10be..176561c89f70 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -2988,11 +2988,12 @@ def run_job( except Exception: pass - # Reasoning config from config.yaml (raw value — a YAML boolean False - # means thinking disabled, see parse_reasoning_effort) - from hermes_constants import parse_reasoning_effort - reasoning_config = parse_reasoning_effort( - _cfg.get("agent", {}).get("reasoning_effort", "") + # Reasoning config from config.yaml (per-model override > global) — + # resolved through the shared chokepoint against the job's effective + # model (per-job override > HERMES_MODEL env > config.yaml default). + from hermes_constants import resolve_reasoning_config + reasoning_config = resolve_reasoning_config( + _cfg if isinstance(_cfg, dict) else {}, str(model) ) # Prefill messages from env or config.yaml. The top-level diff --git a/gateway/run.py b/gateway/run.py index 8d63da9c952a..3bea0eb4034b 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -4851,23 +4851,21 @@ def _get_system_prompt_for_channel( return getattr(self, "_ephemeral_system_prompt", None) or "" @staticmethod - def _load_reasoning_config() -> dict | None: - """Load reasoning effort from config.yaml. + def _load_reasoning_config(model: str = "") -> dict | None: + """Load reasoning effort from config.yaml, respecting per-model overrides. - Reads agent.reasoning_effort from config.yaml. Valid: "none", - "minimal", "low", "medium", "high", "xhigh", "max", "ultra". Returns None to use - default (medium). + Thin wrapper over the shared chokepoint + :func:`hermes_constants.resolve_reasoning_config` (per-model override > + global ``agent.reasoning_effort``; YAML boolean False = disabled). + Closes #21256. + + Args: + model: The effective model for the calling session. When empty, + the config's ``model.default`` is used. """ - from hermes_constants import parse_reasoning_effort + from hermes_constants import resolve_reasoning_config cfg = _load_gateway_runtime_config() - # Keep the raw value — coercing with ``or ""`` turns a YAML boolean - # False (``reasoning_effort: false``/``off``/``no``) into "", silently - # re-enabling thinking for users who explicitly disabled it. - effort = cfg_get(cfg, "agent", "reasoning_effort", default="") - result = parse_reasoning_effort(effort) - if effort and str(effort).strip() and result is None: - logger.warning("Unknown reasoning_effort '%s', using default (medium)", effort) - return result + return resolve_reasoning_config(cfg, model) @staticmethod def _parse_reasoning_command_args(raw_args: str) -> tuple[str, bool]: @@ -4900,8 +4898,17 @@ def _resolve_session_reasoning_config( *, source: Optional[SessionSource] = None, session_key: Optional[str] = None, + model: str = "", ) -> dict | None: - """Resolve reasoning effort for a session, honoring session overrides.""" + """Resolve reasoning effort for a session, honoring session overrides. + + Priority: session-scoped ``/reasoning --session`` override > + per-model override (``agent.reasoning_overrides``) > global + ``agent.reasoning_effort``. ``model`` should be the session's + *effective* model (session ``/model`` override included) so + per-model overrides track what the session actually runs — when + empty, the config's ``model.default`` is used. + """ resolved_session_key = session_key if not resolved_session_key and source is not None: try: @@ -4912,7 +4919,7 @@ def _resolve_session_reasoning_config( overrides = getattr(self, "_session_reasoning_overrides", {}) or {} if resolved_session_key and resolved_session_key in overrides: return overrides[resolved_session_key] - return self._load_reasoning_config() + return self._load_reasoning_config(model) def _set_session_reasoning_override( self, @@ -13482,7 +13489,9 @@ async def _run_background_task( pr = self._provider_routing max_iterations = _current_max_iterations() - reasoning_config = self._resolve_session_reasoning_config(source=source) + reasoning_config = self._resolve_session_reasoning_config( + source=source, model=model + ) self._reasoning_config = reasoning_config self._service_tier = self._load_service_tier() turn_route = self._resolve_turn_agent_config(prompt, model, runtime_kwargs) @@ -18233,6 +18242,7 @@ def run_sync(): reasoning_config = self._resolve_session_reasoning_config( source=source, session_key=session_key, + model=model, ) self._reasoning_config = reasoning_config self._service_tier = self._load_service_tier() diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index 765369f1c5e6..731ec0d1781c 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -2658,9 +2658,15 @@ async def _handle_reasoning_command(self, event: MessageEvent) -> str: _reasoning_source = await asyncio.to_thread(self._normalize_source_for_session_key, event.source) session_key = self._session_key_for_source(_reasoning_source) self._show_reasoning = self._load_show_reasoning() + # Use the session's effective model (session /model override wins over + # config default) so per-model reasoning_overrides display correctly. + _session_model = str( + ((getattr(self, "_session_model_overrides", {}) or {}).get(session_key) or {}).get("model") or "" + ) self._reasoning_config = self._resolve_session_reasoning_config( source=event.source, session_key=session_key, + model=_session_model, ) def _save_config_key(key_path: str, value): diff --git a/hermes_cli/config.py b/hermes_cli/config.py index b95b1c666a89..f70ea9c517cf 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1158,8 +1158,15 @@ def _ensure_hermes_home_managed(home: Path): # only controls how inbound user images are presented. "image_input_mode": "auto", "disabled_toolsets": [], + + # Per-model reasoning effort overrides (spelling-tolerant). + # Dict mapping model names (any reasonable spelling) to effort levels. + # Takes precedence over agent.reasoning_effort when the current model + # matches a key in this dict. + # Edit directly in config.yaml (no CLI support due to dots in keys). + "reasoning_overrides": {}, }, - + "terminal": { "backend": "local", "modal_mode": "auto", diff --git a/hermes_constants.py b/hermes_constants.py index 26842530c22d..6e344844812d 100644 --- a/hermes_constants.py +++ b/hermes_constants.py @@ -823,6 +823,192 @@ def parse_reasoning_effort(effort) -> dict | None: return None +def _canonical_model_variants(model: str) -> list[str]: + """Generate bounded spelling variants for tolerant override matching. + + Model names mix two types of separators: + - **Word separators**: dashes between words (``claude-opus``) + - **Version separators**: dots or dashes between version digits (``4.5``, ``4-5``) + + The tricky case is that ``.`` appears in BOTH roles (word sep in some + spellings, version sep in others), so a blanket ``.replace('.', '-')`` + is lossy — it collapses version dots into dashes and no later step + recovers the canonical form (``claude-opus-4.5``). + + Strategy: generate a small set of base forms, then apply version-dot + recovery to EACH of them. This ensures symmetry: + ``claude-opus-4.5``, ``claude-opus-4-5``, and ``claude-opus.4.5`` all + produce the same variant set. + + Steps: + 1. Exact input + 2. Dots/dashes cross-substitution on the entire string + 3. Version-dot recovery applied to ALL derivatives + 4. Strip provider/aggregator prefix → bare model variants + 5. Apply version-dot recovery to bare derivatives + 6. Prepend known provider/aggregator prefixes + + Duplicates removed in insertion order (exact always wins). + """ + import re + + # Version-dot regexes — digit-separator-digit interconversion + _dash_to_dot = lambda s: re.sub(r'(\d)-(\d)', r'\1.\2', s) + _dot_to_dash = lambda s: re.sub(r'(\d)\.(\d)', r'\1-\2', s) + + seen = set() + variants = [] + + def _add(v): + if v and v not in seen: + seen.add(v) + variants.append(v) + + def _add_with_derivatives(s): + """Add s plus its dots↔dashes and version-dot derivatives.""" + _add(s) + all_dashed = s.replace('.', '-') + _add(all_dashed) + all_dotted = s.replace('-', '.') + _add(all_dotted) + # Version-dot recovery on each base form + _add(_dash_to_dot(s)) + _add(_dot_to_dash(s)) + _add(_dash_to_dot(all_dashed)) + _add(_dot_to_dash(all_dotted)) + + # 1-3. Base variants for the full string + _add_with_derivatives(model) + + # Split by / to handle provider prefix + parts = model.split('/') + + # 4. Bare model variants (strip provider/aggregator prefix) + if len(parts) >= 2: + bare = parts[-1] + _add_with_derivatives(bare) + + # Strip aggregator only (3+ parts) + # e.g. "openrouter/anthropic/claude-opus-4.5" → "anthropic/claude-opus-4.5" + if len(parts) >= 3: + _add_with_derivatives('/'.join(parts[1:])) + + # 5. Prepend known provider prefixes to bare variants + known_providers = ( + 'anthropic', 'openai', 'google', 'openrouter', 'groq', 'mistral', + 'xai', 'cohere', 'perplexity', 'together', 'fireworks', 'deepseek', + ) + bare_variants = [v for v in variants if '/' not in v] + for v in bare_variants: + for provider in known_providers: + _add(f"{provider}/{v}") + + # Prepend aggregator to single-slash variants + single_slash_variants = [v for v in variants if v.count('/') == 1] + known_aggregators = ('openrouter', 'opencode', 'fireworks', 'groq', 'together') + for v in single_slash_variants: + for agg in known_aggregators: + _add(f"{agg}/{v}") + + return variants + + +def resolve_per_model_reasoning_effort(model: str, overrides: dict | None) -> dict | None: + """Lookup a per-model reasoning_effort override with spelling-tolerance. + + Args: + model: The model string (any spelling — exact, normalized, bare, + with provider prefix, etc.) + overrides: The dict of per-model overrides from + agent.reasoning_overrides in config.yaml. Keys can be + any sensible spelling of the model name. + + Returns: + The parsed reasoning_config dict if a match is found, + None otherwise (caller should fall back to global reasoning_effort). + + Resolution order: + 1. Exact match + 2. Dots ↔ dashes variants + 3. Strip provider prefix (bare model name only) + 4. Strip aggregator prefix (middle segment only) + 5. Prepend known aggregator prefixes to bare/single-slash variants + + First non-None parse_reasoning_effort result wins. + """ + if not overrides or not isinstance(overrides, dict) or not model: + return None + + for variant in _canonical_model_variants(model): + if variant in overrides: + result = parse_reasoning_effort(overrides[variant]) + if result is not None: + return result + + return None + + +def resolve_reasoning_config(cfg: dict | None, model: str = "") -> dict | None: + """Resolve the effective reasoning config for *model* from a config dict. + + Single chokepoint for reasoning-effort resolution, shared by every + surface (CLI startup, messaging gateway, Desktop/TUI, cron, ``/model`` + switch, fallback activation). Priority: + + 1. Per-model override from ``agent.reasoning_overrides`` + (spelling-tolerant — see :func:`resolve_per_model_reasoning_effort`) + 2. Global ``agent.reasoning_effort`` — the raw value is passed through + so a YAML boolean ``False`` (``reasoning_effort: false``/``off``/ + ``no``) means "thinking disabled", never silently re-enabled. + + Session-scoped overrides (gateway ``/reasoning --session``) are resolved + by the caller BEFORE this function — they always win. + + Args: + cfg: A loaded config dict (any of the three loaders' shapes — only + the ``agent`` and ``model`` sections are read). + model: The effective model for this surface/session. When empty, + it is derived from the config's ``model`` section (string + form, or a dict's ``default``/``model`` keys). + + Returns: + The parsed reasoning config dict, or None when unset/unrecognized + (caller uses the provider default). + """ + cfg = cfg if isinstance(cfg, dict) else {} + agent_cfg = cfg.get("agent") + if not isinstance(agent_cfg, dict): + agent_cfg = {} + + if not model: + model_cfg = cfg.get("model") + if isinstance(model_cfg, str): + model = model_cfg.strip() + elif isinstance(model_cfg, dict): + model = str( + model_cfg.get("default") or model_cfg.get("model") or "" + ).strip() + else: + model = "" + + overrides = agent_cfg.get("reasoning_overrides") or {} + per_model = resolve_per_model_reasoning_effort(model, overrides) + if per_model is not None: + return per_model + + # Global fallback — keep the raw value; coercing with ``or ""`` turns a + # YAML boolean False into "", silently re-enabling thinking for users + # who explicitly disabled it. + effort = agent_cfg.get("reasoning_effort", "") + result = parse_reasoning_effort(effort) + if effort and str(effort).strip() and result is None: + import logging + logging.getLogger(__name__).warning( + "Unknown reasoning_effort '%s', using default (medium)", effort + ) + return result + + def is_termux() -> bool: """Return True when running inside a Termux (Android) environment. diff --git a/tests/cron/test_reasoning_config_per_model.py b/tests/cron/test_reasoning_config_per_model.py new file mode 100644 index 000000000000..bdd09ade118b --- /dev/null +++ b/tests/cron/test_reasoning_config_per_model.py @@ -0,0 +1,106 @@ +"""Tests for per-model reasoning_effort override in cron scheduler.""" + +import pytest + + +class TestCronPerModelReasoningConfig: + """Test cron scheduler respects per-model reasoning overrides. + + Rather than spinning up a full CronScheduler (heavy), we verify the + resolution logic by testing the helper directly against a config dict + shaped the same way the scheduler reads it. + """ + + def test_per_model_override_resolves_for_cron_model(self): + """The spelling-tolerant helper resolves the cron config's model.""" + from hermes_constants import resolve_per_model_reasoning_effort + + # Simulate cron scheduler config shape + _cfg = { + "model": {"default": "anthropic/claude-opus-4.5"}, + "agent": { + "reasoning_effort": "medium", + "reasoning_overrides": { + "anthropic/claude-opus-4.5": "xhigh", + }, + }, + } + _model_cfg = _cfg.get("model", {}) + _model = str(_model_cfg.get("default", "") or "").strip() + _overrides = (_cfg.get("agent", {}) or {}).get("reasoning_overrides", {}) or {} + + result = resolve_per_model_reasoning_effort(_model, _overrides) + assert result is not None + assert result["effort"] == "xhigh" + + def test_cron_falls_back_to_global_when_no_override(self): + """When no per-model override matches, global effort is used.""" + from hermes_constants import parse_reasoning_effort, resolve_per_model_reasoning_effort + + _cfg = { + "model": {"default": "gpt-5"}, + "agent": { + "reasoning_effort": "low", + "reasoning_overrides": { + "anthropic/claude-opus-4.5": "xhigh", + }, + }, + } + _model = _cfg["model"]["default"] + _overrides = _cfg["agent"]["reasoning_overrides"] + + per_model = resolve_per_model_reasoning_effort(_model, _overrides) + assert per_model is None # no match + + # Scheduler falls back to global + effort = _cfg["agent"]["reasoning_effort"] + result = parse_reasoning_effort(effort) + assert result is not None + assert result["effort"] == "low" + + def test_cron_handles_missing_model_key(self): + """Works when config has no model.default.""" + from hermes_constants import resolve_per_model_reasoning_effort + + _cfg = { + "agent": { + "reasoning_effort": "medium", + "reasoning_overrides": {"claude-opus-4.5": "high"}, + }, + } + _model_cfg = _cfg.get("model", {}) if isinstance(_cfg.get("model", {}), dict) else {} + _model = str(_model_cfg.get("default", "") or _model_cfg.get("model", "") or "").strip() + _overrides = (_cfg.get("agent", {}) or {}).get("reasoning_overrides", {}) or {} + + # Empty model → resolve returns None → scheduler uses global + result = resolve_per_model_reasoning_effort(_model, _overrides) + assert result is None + + def test_global_fallback_with_yaml_false(self): + """YAML boolean False must reach parse_reasoning_effort uncoerced. + + Regression: str(... or "").strip() turned False into "", silently + re-enabling thinking. The raw value must pass through so + parse_reasoning_effort(False) returns {'enabled': False}. + """ + from hermes_constants import parse_reasoning_effort, resolve_per_model_reasoning_effort + + _cfg = { + "model": {"default": "gpt-5"}, + "agent": { + "reasoning_effort": False, # YAML boolean, not string + "reasoning_overrides": {"claude-opus-4.5": "xhigh"}, + }, + } + _model = _cfg["model"]["default"] + _overrides = _cfg["agent"]["reasoning_overrides"] + + per_model = resolve_per_model_reasoning_effort(_model, _overrides) + assert per_model is None # no match + + # Scheduler global fallback — raw value, no coercion + result = parse_reasoning_effort( + _cfg.get("agent", {}).get("reasoning_effort", "") + ) + assert result is not None + assert result.get("enabled") is False diff --git a/tests/gateway/test_api_server.py b/tests/gateway/test_api_server.py index 3892652c3be7..abe8cdd64d34 100644 --- a/tests/gateway/test_api_server.py +++ b/tests/gateway/test_api_server.py @@ -3887,7 +3887,7 @@ def _patch_create_agent_runtime(monkeypatch, captured: dict, fake_agent_cls): monkeypatch.setattr("gateway.run._resolve_gateway_model", lambda: "global/model") monkeypatch.setattr("gateway.run._load_gateway_config", lambda: {}) monkeypatch.setattr( - "gateway.run.GatewayRunner._load_reasoning_config", staticmethod(lambda: {}) + "gateway.run.GatewayRunner._load_reasoning_config", staticmethod(lambda model="": {}) ) monkeypatch.setattr( "gateway.run.GatewayRunner._load_fallback_model", staticmethod(lambda: None) diff --git a/tests/gateway/test_reasoning_config_per_model.py b/tests/gateway/test_reasoning_config_per_model.py new file mode 100644 index 000000000000..ffd6ba0b9444 --- /dev/null +++ b/tests/gateway/test_reasoning_config_per_model.py @@ -0,0 +1,176 @@ +"""Tests for per-model reasoning_effort override in gateway _load_reasoning_config.""" + +import pytest + +import gateway.run as gateway_run + + +class TestGatewayPerModelReasoningConfig: + """Test GatewayRunner._load_reasoning_config respects per-model overrides.""" + + def test_per_model_override_takes_precedence(self, monkeypatch): + """Per-model override wins over global reasoning_effort.""" + from hermes_cli.config import DEFAULT_CONFIG + + fake_cfg = { + "model": {"default": "anthropic/claude-opus-4.5"}, + "agent": { + "reasoning_effort": "medium", + "reasoning_overrides": { + "anthropic/claude-opus-4.5": "xhigh", + }, + }, + } + monkeypatch.setattr(gateway_run, "_load_gateway_runtime_config", lambda: fake_cfg) + + result = gateway_run.GatewayRunner._load_reasoning_config() + assert result is not None + assert result["enabled"] is True + assert result["effort"] == "xhigh" + + def test_global_fallback_when_no_override(self, monkeypatch): + """Global reasoning_effort applies when no per-model override matches.""" + fake_cfg = { + "model": {"default": "gpt-5"}, + "agent": { + "reasoning_effort": "high", + "reasoning_overrides": { + "anthropic/claude-opus-4.5": "xhigh", + }, + }, + } + monkeypatch.setattr(gateway_run, "_load_gateway_runtime_config", lambda: fake_cfg) + + result = gateway_run.GatewayRunner._load_reasoning_config() + assert result is not None + assert result["effort"] == "high" + + def test_spelling_tolerant_match_in_gateway(self, monkeypatch): + """Override matches even with different spelling (dots vs dashes).""" + fake_cfg = { + "model": {"default": "claude-opus-4-5"}, + "agent": { + "reasoning_effort": "medium", + "reasoning_overrides": { + "claude-opus-4.5": "xhigh", # key has dots, model has dashes + }, + }, + } + monkeypatch.setattr(gateway_run, "_load_gateway_runtime_config", lambda: fake_cfg) + + result = gateway_run.GatewayRunner._load_reasoning_config() + assert result is not None + assert result["effort"] == "xhigh" + + def test_no_overrides_dict(self, monkeypatch): + """Works fine when reasoning_overrides key is absent.""" + fake_cfg = { + "model": {"default": "gpt-5"}, + "agent": { + "reasoning_effort": "low", + }, + } + monkeypatch.setattr(gateway_run, "_load_gateway_runtime_config", lambda: fake_cfg) + + result = gateway_run.GatewayRunner._load_reasoning_config() + assert result is not None + assert result["effort"] == "low" + + def test_empty_overrides(self, monkeypatch): + """Empty overrides dict falls back to global.""" + fake_cfg = { + "model": {"default": "gpt-5"}, + "agent": { + "reasoning_effort": "medium", + "reasoning_overrides": {}, + }, + } + monkeypatch.setattr(gateway_run, "_load_gateway_runtime_config", lambda: fake_cfg) + + result = gateway_run.GatewayRunner._load_reasoning_config() + assert result is not None + assert result["effort"] == "medium" + + def test_global_fallback_with_yaml_false(self, monkeypatch): + """YAML boolean False must reach parse_reasoning_effort uncoerced. + + Regression: str(... or "").strip() turned False into "", silently + re-enabling thinking. The raw value must pass through so + parse_reasoning_effort(False) returns {'enabled': False}. + """ + fake_cfg = { + "model": {"default": "gpt-5"}, + "agent": { + "reasoning_effort": False, # YAML boolean, not string + }, + } + monkeypatch.setattr(gateway_run, "_load_gateway_runtime_config", lambda: fake_cfg) + + result = gateway_run.GatewayRunner._load_reasoning_config() + assert result is not None + assert result.get("enabled") is False + + +class TestGatewaySessionEffectiveModel: + """The reasoning override must track the SESSION's effective model. + + Regression guard: _load_reasoning_config used to always read + model.default from config.yaml, so a session-only /model switch to a + different model kept resolving the config default's override. + """ + + def test_explicit_model_beats_config_default(self, monkeypatch): + """_load_reasoning_config(model=...) resolves for that model, not model.default.""" + fake_cfg = { + "model": {"default": "gpt-5"}, + "agent": { + "reasoning_effort": "medium", + "reasoning_overrides": { + "gpt-5": "low", + "claude-opus-4.5": "xhigh", + }, + }, + } + monkeypatch.setattr(gateway_run, "_load_gateway_runtime_config", lambda: fake_cfg) + + # Session switched (session-only) to claude-opus-4.5 — its override + # must win over the config default model's override. + result = gateway_run.GatewayRunner._load_reasoning_config("claude-opus-4.5") + assert result is not None + assert result["effort"] == "xhigh" + + # And without a model arg, the config default's override applies. + result_default = gateway_run.GatewayRunner._load_reasoning_config() + assert result_default is not None + assert result_default["effort"] == "low" + + def test_resolve_session_reasoning_forwards_model(self, monkeypatch): + """_resolve_session_reasoning_config passes the effective model through + (and session-scoped /reasoning overrides still win over it).""" + fake_cfg = { + "model": {"default": "gpt-5"}, + "agent": { + "reasoning_effort": "medium", + "reasoning_overrides": {"claude-opus-4.5": "xhigh"}, + }, + } + monkeypatch.setattr(gateway_run, "_load_gateway_runtime_config", lambda: fake_cfg) + + runner = object.__new__(gateway_run.GatewayRunner) + runner._session_reasoning_overrides = {} + + # No session override → per-model override for the effective model. + result = runner._resolve_session_reasoning_config( + session_key="agent:main:telegram:private:1", model="claude-opus-4.5" + ) + assert result is not None + assert result["effort"] == "xhigh" + + # Session-scoped /reasoning override still wins over per-model. + runner._session_reasoning_overrides = { + "agent:main:telegram:private:1": {"enabled": True, "effort": "minimal"} + } + result = runner._resolve_session_reasoning_config( + session_key="agent:main:telegram:private:1", model="claude-opus-4.5" + ) + assert result == {"enabled": True, "effort": "minimal"} diff --git a/tests/run_agent/test_fallback_reasoning_override.py b/tests/run_agent/test_fallback_reasoning_override.py new file mode 100644 index 000000000000..1c2c3e6228a7 --- /dev/null +++ b/tests/run_agent/test_fallback_reasoning_override.py @@ -0,0 +1,144 @@ +"""Tests for per-model reasoning_effort override during fallback activation. + +Tests that try_activate_fallback re-resolves reasoning_config when +swapping to a fallback model, so per-model overrides are honored even +during error recovery. +""" + +import pytest +from unittest.mock import MagicMock, patch + + +class TestFallbackReasoningOverride: + """Test try_activate_fallback re-resolves reasoning_config.""" + + def test_fallback_re_resolves_reasoning_config(self): + """When fallback activates, reasoning_config should be re-resolved. + + We test the resolution logic directly rather than spinning up a + full try_activate_fallback (which requires extensive agent setup). + The production code calls resolve_per_model_reasoning_effort with + the fallback model string — we verify that works correctly. + """ + from hermes_constants import resolve_per_model_reasoning_effort + + # Simulate: primary was gemini-flash (medium), fallback to claude-opus-4.5 (xhigh) + overrides = { + "claude-opus-4.5": "xhigh", + "gemini-flash": "medium", + } + + # Fallback model lookup + fb_result = resolve_per_model_reasoning_effort("claude-opus-4.5", overrides) + assert fb_result is not None + assert fb_result["effort"] == "xhigh" + + # Primary model lookup (for comparison) + primary_result = resolve_per_model_reasoning_effort("gemini-flash", overrides) + assert primary_result is not None + assert primary_result["effort"] == "medium" + + # The key point: fallback result differs from primary + assert fb_result["effort"] != primary_result["effort"] + + def test_fallback_to_model_without_override_uses_global(self): + """Fallback to a model with no override should resolve to None (→ global).""" + from hermes_constants import resolve_per_model_reasoning_effort + + overrides = {"claude-opus-4.5": "xhigh"} + + # Fallback to gpt-5 which has no override + result = resolve_per_model_reasoning_effort("gpt-5", overrides) + assert result is None # caller falls back to global + + def test_fallback_with_normalized_model_name(self): + """Fallback model name may be normalized (dots→dashes); override should still match.""" + from hermes_constants import resolve_per_model_reasoning_effort + + # User wrote key with dots, but normalize_model_for_provider converts to dashes + overrides = {"claude-sonnet-4.6": "high"} + + result = resolve_per_model_reasoning_effort("claude-sonnet-4-6", overrides) + assert result is not None + assert result["effort"] == "high" + + def test_fallback_recovery_restores_primary_reasoning(self): + """After fallback + restore_primary_runtime, reasoning_config returns to primary's value. + + This tests the integration of Task 6 (_primary_runtime snapshot) with + Task 6b (fallback re-resolution). The full cycle: + 1. Primary model = gemini-flash, reasoning = medium + 2. /model switch → _primary_runtime captures reasoning_config + 3. Fallback activates → reasoning re-resolved for fallback model + 4. restore_primary_runtime → reasoning_config restored from snapshot + """ + from agent.agent_runtime_helpers import restore_primary_runtime + + agent = MagicMock() + # Simulate: _primary_runtime was captured during /model switch + agent._primary_runtime = { + "model": "gemini-flash", + "provider": "google", + "base_url": "", + "api_mode": "openai", + "api_key": "key", + "client_kwargs": {}, + "use_prompt_caching": False, + "use_native_cache_layout": False, + "reasoning_config": {"enabled": True, "effort": "medium"}, + "compressor_model": "gemini-flash", + "compressor_base_url": "", + "compressor_api_key": "", + "compressor_provider": "", + "compressor_context_length": 0, + "compressor_api_mode": "", + "compressor_threshold_tokens": 0, + } + agent._fallback_activated = True + agent._fallback_index = 0 + agent._fallback_chain = [] + agent._fallback_model = None + agent._transport_cache = {} + agent._config_context_length = None + agent._rate_limited_until = 0 + # During fallback, reasoning was changed to xhigh (fallback model's override) + agent.model = "claude-opus-4.5" + agent.provider = "anthropic" + agent.reasoning_config = {"enabled": True, "effort": "xhigh"} + agent.context_compressor = MagicMock() + agent.base_url = "" + agent._anthropic_prompt_cache_policy = MagicMock(return_value=(False, False)) + agent._create_openai_client = MagicMock(return_value=MagicMock()) + agent._ensure_lmstudio_runtime_loaded = MagicMock() + + result = restore_primary_runtime(agent) + assert result is True + # reasoning_config should be restored to primary's value (medium) + assert agent.reasoning_config == {"enabled": True, "effort": "medium"} + + def test_fallback_global_fallback_with_yaml_false(self): + """Fallback global fallback must not coerce YAML boolean False. + + Regression: ``or ""`` turned False into "", silently re-enabling + thinking. The raw value must pass through so + parse_reasoning_effort(False) returns {'enabled': False}. + + The production code in try_activate_fallback does: + _fb_global_effort = _fb_agent_cfg.get("reasoning_effort", "") + agent.reasoning_config = parse_reasoning_effort(_fb_global_effort) + We verify that passing the raw False (not coerced "") produces + the disabled config. + """ + from hermes_constants import parse_reasoning_effort + + # Simulate: no per-model override matches, global is YAML False + _fb_agent_cfg = {"reasoning_effort": False} + + # This is the exact line from try_activate_fallback's else branch. + # The bug was: _fb_global_effort = _fb_agent_cfg.get(...) or "" + # which turned False into "". The fix passes the raw value. + _fb_global_effort = _fb_agent_cfg.get("reasoning_effort", "") + result = parse_reasoning_effort(_fb_global_effort) + + assert result is not None + assert result.get("enabled") is False diff --git a/tests/run_agent/test_switch_model_reasoning_override.py b/tests/run_agent/test_switch_model_reasoning_override.py new file mode 100644 index 000000000000..ae304e118df3 --- /dev/null +++ b/tests/run_agent/test_switch_model_reasoning_override.py @@ -0,0 +1,220 @@ +"""Tests for per-model reasoning_effort override during /model switch. + +Tests that switch_model: +1. Re-resolves reasoning_config when switching to a model with an override +2. Falls back to global when switching to a model without an override +3. Saves reasoning_config into _primary_runtime for fallback recovery +""" + +import pytest +from unittest.mock import MagicMock, patch + + +class TestSwitchModelReasoningOverride: + """Test switch_model re-resolves reasoning_config on model switch.""" + + def _make_fake_agent(self, model="gpt-5", provider="openai"): + """Create a minimal fake agent for switch_model testing.""" + agent = MagicMock() + agent.model = model + agent.provider = provider + agent.base_url = "https://api.openai.com/v1" + agent.api_mode = "openai" + agent.api_key = "test-key" + agent._client_kwargs = {"api_key": "test-key", "base_url": "https://api.openai.com/v1"} + agent._use_prompt_caching = False + agent._use_native_cache_layout = False + agent.reasoning_config = {"enabled": True, "effort": "medium"} + agent._fallback_activated = False + agent._fallback_index = 0 + agent._fallback_chain = [] + agent._fallback_model = None + agent._config_context_length = None + agent._transport_cache = {} + agent.context_compressor = None + agent._cached_system_prompt = None + agent._anthropic_api_key = "" + agent._anthropic_base_url = None + agent._is_anthropic_oauth = False + agent._anthropic_prompt_cache_policy = MagicMock( + return_value=(False, False) + ) + agent._ensure_lmstudio_runtime_loaded = MagicMock() + agent._create_openai_client = MagicMock(return_value=MagicMock()) + return agent + + def test_primary_runtime_includes_reasoning_config(self): + """After switch_model, _primary_runtime should contain reasoning_config key.""" + from agent.agent_runtime_helpers import switch_model + + agent = self._make_fake_agent() + + fake_cfg = { + "model": {"default": "claude-opus-4.5"}, + "agent": { + "reasoning_effort": "medium", + "reasoning_overrides": { + "claude-opus-4.5": "xhigh", + }, + }, + } + + with patch("hermes_cli.config.load_config", return_value=fake_cfg): + try: + switch_model( + agent, + new_model="claude-opus-4.5", + new_provider="anthropic", + base_url="https://api.anthropic.com", + api_mode="anthropic_messages", + ) + except Exception: + # Client creation may fail in test env; check _primary_runtime was set + pass + + assert hasattr(agent, "_primary_runtime") + assert "reasoning_config" in agent._primary_runtime + + def test_reasoning_config_resolves_to_override_on_switch(self): + """switch_model should resolve reasoning_config to per-model override.""" + from agent.agent_runtime_helpers import switch_model + + agent = self._make_fake_agent() + + fake_cfg = { + "model": {"default": "claude-opus-4.5"}, + "agent": { + "reasoning_effort": "medium", + "reasoning_overrides": { + "claude-opus-4.5": "xhigh", + }, + }, + } + + with patch("hermes_cli.config.load_config", return_value=fake_cfg): + try: + switch_model( + agent, + new_model="claude-opus-4.5", + new_provider="anthropic", + base_url="https://api.anthropic.com", + api_mode="anthropic_messages", + ) + except Exception: + pass + + # reasoning_config should be updated to xhigh + assert agent.reasoning_config is not None + assert agent.reasoning_config.get("effort") == "xhigh" + + def test_reasoning_config_falls_back_to_global(self): + """switch_model should fall back to global when no override for new model.""" + from agent.agent_runtime_helpers import switch_model + + agent = self._make_fake_agent() + + fake_cfg = { + "model": {"default": "gpt-5"}, + "agent": { + "reasoning_effort": "low", + "reasoning_overrides": { + "claude-opus-4.5": "xhigh", # override for different model + }, + }, + } + + with patch("hermes_cli.config.load_config", return_value=fake_cfg): + try: + switch_model( + agent, + new_model="gpt-5", + new_provider="openai", + api_mode="openai", + ) + except Exception: + pass + + # No override for gpt-5 → should fall back to global "low" + assert agent.reasoning_config is not None + assert agent.reasoning_config.get("effort") == "low" + + def test_restore_primary_runtime_restores_reasoning(self): + """restore_primary_runtime should restore reasoning_config from snapshot.""" + from agent.agent_runtime_helpers import restore_primary_runtime + + agent = MagicMock() + agent._primary_runtime = { + "model": "claude-opus-4.5", + "provider": "anthropic", + "base_url": "https://api.anthropic.com", + "api_mode": "anthropic_messages", + "api_key": "key", + "client_kwargs": {}, + "use_prompt_caching": True, + "use_native_cache_layout": False, + "reasoning_config": {"enabled": True, "effort": "xhigh"}, + "compressor_model": "claude-opus-4.5", + "compressor_base_url": "", + "compressor_api_key": "", + "compressor_provider": "", + "compressor_context_length": 0, + "compressor_api_mode": "", + "compressor_threshold_tokens": 0, + "anthropic_api_key": "key", + "anthropic_base_url": "https://api.anthropic.com", + "is_anthropic_oauth": False, + } + agent._fallback_activated = True + agent._fallback_index = 0 + agent._fallback_chain = [] + agent._fallback_model = None + agent._transport_cache = {} + agent._config_context_length = None + agent._rate_limited_until = 0 + agent.model = "fallback-model" + agent.provider = "openai" + agent.reasoning_config = {"enabled": True, "effort": "medium"} + agent.context_compressor = MagicMock() + agent.base_url = "" + # Mock the methods restore_primary_runtime calls + agent._anthropic_prompt_cache_policy = MagicMock(return_value=(True, False)) + agent._create_openai_client = MagicMock(return_value=MagicMock()) + agent._ensure_lmstudio_runtime_loaded = MagicMock() + + result = restore_primary_runtime(agent) + assert result is True + assert agent.reasoning_config == {"enabled": True, "effort": "xhigh"} + + def test_switch_model_global_fallback_with_yaml_false(self): + """switch_model global fallback must not coerce YAML boolean False. + + Regression: str(... or "").strip() turned False into "", silently + re-enabling thinking. The raw value must pass through so + parse_reasoning_effort(False) returns {'enabled': False}. + """ + from agent.agent_runtime_helpers import switch_model + + agent = self._make_fake_agent() + + fake_cfg = { + "model": {"default": "gpt-5"}, + "agent": { + "reasoning_effort": False, # YAML boolean, not string + "reasoning_overrides": {}, + }, + } + + with patch("hermes_cli.config.load_config", return_value=fake_cfg): + try: + switch_model( + agent, + new_model="gpt-5", + new_provider="openai", + api_mode="openai", + ) + except Exception: + pass + + # No override for gpt-5 → global fallback with raw False + assert agent.reasoning_config is not None + assert agent.reasoning_config.get("enabled") is False \ No newline at end of file diff --git a/tests/test_hermes_constants.py b/tests/test_hermes_constants.py index 44a449d91d08..8eddceb70eaf 100644 --- a/tests/test_hermes_constants.py +++ b/tests/test_hermes_constants.py @@ -490,6 +490,307 @@ def test_known_supported_levels_are_documented(self): assert documented.issubset(set(VALID_REASONING_EFFORTS)) +class TestResolvePerModelReasoningEffort: + """Tests for resolve_per_model_reasoning_effort() — spelling-tolerant + per-model override lookup from agent.reasoning_overrides dict. + + Contract: the override key the user writes in config.yaml should match + regardless of how downstream consumers normalize the model string. + normalize_model_for_provider() converts dots to dashes and + adds/strips provider prefixes. Our resolver tolerates these + variations so the user's intent ("this model always gets xhigh") + is honored no matter which code path feeds the model string. + """ + + def test_exact_match(self): + """Exact model string match returns the parsed override.""" + from hermes_constants import resolve_per_model_reasoning_effort + overrides = {"claude-opus-4.5": "xhigh"} + result = resolve_per_model_reasoning_effort("claude-opus-4.5", overrides) + assert result == {"enabled": True, "effort": "xhigh"} + + def test_none_when_no_matching_key(self): + """Model not in overrides returns None (caller falls back to global).""" + from hermes_constants import resolve_per_model_reasoning_effort + overrides = {"claude-opus-4.5": "xhigh"} + assert resolve_per_model_reasoning_effort("gpt-5", overrides) is None + + def test_none_value_returns_disabled(self): + """Override set to 'none' returns {'enabled': False}.""" + from hermes_constants import resolve_per_model_reasoning_effort + overrides = {"claude-opus-4.5": "none"} + result = resolve_per_model_reasoning_effort("claude-opus-4.5", overrides) + assert result == {"enabled": False} + + def test_invalid_value_returns_none(self): + """Override with invalid effort falls back to None (global).""" + from hermes_constants import resolve_per_model_reasoning_effort + overrides = {"claude-opus-4.5": "banana"} + assert resolve_per_model_reasoning_effort("claude-opus-4.5", overrides) is None + + def test_none_or_empty_overrides_returns_none(self): + """None or empty overrides dict returns None.""" + from hermes_constants import resolve_per_model_reasoning_effort + assert resolve_per_model_reasoning_effort("claude-opus-4.5", None) is None + assert resolve_per_model_reasoning_effort("claude-opus-4.5", {}) is None + + def test_empty_model_returns_none(self): + """Empty model string returns None.""" + from hermes_constants import resolve_per_model_reasoning_effort + assert resolve_per_model_reasoning_effort("", {"gpt-5": "low"}) is None + + # --- Spelling tolerance layer --- + + def test_dots_to_dashes_variant(self): + """User wrote key with dots; input comes in normalized with dashes. + + normalize_model_for_provider converts claude-opus-4.5 → claude-opus-4-5 + for the anthropic provider. The user's override key should still match. + """ + from hermes_constants import resolve_per_model_reasoning_effort + overrides = {"claude-opus-4.5": "xhigh"} + result = resolve_per_model_reasoning_effort("claude-opus-4-5", overrides) + assert result == {"enabled": True, "effort": "xhigh"} + + def test_dashes_to_dots_variant(self): + """User wrote key with dashes; input comes in with dots.""" + from hermes_constants import resolve_per_model_reasoning_effort + overrides = {"claude-opus-4-5": "high"} + result = resolve_per_model_reasoning_effort("claude-opus.4.5", overrides) + assert result == {"enabled": True, "effort": "high"} + + def test_strip_provider_prefix(self): + """User wrote key WITH provider prefix; input comes in bare. + + E.g. user config: model.default: claude-opus-4.5 (no prefix), + but override key: anthropic/claude-opus-4.5. + """ + from hermes_constants import resolve_per_model_reasoning_effort + overrides = {"anthropic/claude-opus-4.5": "high"} + result = resolve_per_model_reasoning_effort("claude-opus-4.5", overrides) + assert result == {"enabled": True, "effort": "high"} + + def test_prepend_provider_prefix(self): + """User wrote key bare; input comes in WITH provider prefix. + + E.g. user config: model.default: anthropic/claude-opus-4.5, + but override key: claude-opus-4.5 (no prefix). + """ + from hermes_constants import resolve_per_model_reasoning_effort + overrides = {"claude-opus-4.5": "high"} + result = resolve_per_model_reasoning_effort("anthropic/claude-opus-4.5", overrides) + assert result == {"enabled": True, "effort": "high"} + + def test_aggregator_prefix_stripping(self): + """openrouter/anthropic/claude-opus-4.5 should match key anthropic/claude-opus-4.5. + + Aggregator providers (openrouter) prepend their own name, + creating a triple-prefix. The resolver strips the aggregator + layer to find the user's two-segment key. + """ + from hermes_constants import resolve_per_model_reasoning_effort + overrides = {"anthropic/claude-opus-4.5": "xhigh"} + result = resolve_per_model_reasoning_effort("openrouter/anthropic/claude-opus-4.5", overrides) + assert result == {"enabled": True, "effort": "xhigh"} + + def test_exact_match_wins_over_variant(self): + """Ambiguity resolution: exact match takes priority over a variant. + + If both 'claude-opus-4.5' (exact) and 'claude-opus-4-5' (dashes + variant) are keys, the exact input matches the exact key first. + """ + from hermes_constants import resolve_per_model_reasoning_effort + overrides = {"claude-opus-4.5": "high", "claude-opus-4-5": "xhigh"} + result = resolve_per_model_reasoning_effort("claude-opus-4.5", overrides) + assert result == {"enabled": True, "effort": "high"} + + def test_none_when_no_variant_matches(self): + """All variants exhausted without a match returns None.""" + from hermes_constants import resolve_per_model_reasoning_effort + overrides = {"gpt-5": "low"} + assert resolve_per_model_reasoning_effort("claude-opus-4.5", overrides) is None + + def test_all_dotted_input_matches_canonical_key(self): + """Regression: all-dotted input (claude-opus.4.5) must match + canonical key (claude-opus-4.5). + + This was a real bug found by delegate review: the old + all_dashed = model.replace('.', '-') collapsed version dots, + making the canonical form unreachable from all-dotted input. + """ + from hermes_constants import resolve_per_model_reasoning_effort + overrides = {"claude-opus-4.5": "xhigh"} + result = resolve_per_model_reasoning_effort("claude-opus.4.5", overrides) + assert result is not None + assert result["effort"] == "xhigh" + + def test_different_models_do_not_match(self): + """No false positives: gemini-2.0-flash must not match gemini-flash.""" + from hermes_constants import resolve_per_model_reasoning_effort + overrides = {"gemini-flash": "low"} + assert resolve_per_model_reasoning_effort("gemini-2.0-flash", overrides) is None + + +class TestResolveReasoningConfig: + """Tests for resolve_reasoning_config() — the single shared chokepoint + every surface (CLI, gateway, TUI, cron, /model switch, fallback) calls. + + Contract: per-model override > global agent.reasoning_effort; the raw + global value passes through uncoerced (YAML False = disabled); an + explicit model argument wins over the config's model.default. + """ + + def _cfg(self, effort: object = "medium", overrides=None, default_model="gpt-5"): + return { + "model": {"default": default_model}, + "agent": { + "reasoning_effort": effort, + "reasoning_overrides": overrides or {}, + }, + } + + def test_per_model_override_wins(self): + from hermes_constants import resolve_reasoning_config + cfg = self._cfg(overrides={"claude-opus-4.5": "xhigh"}) + result = resolve_reasoning_config(cfg, "claude-opus-4.5") + assert result == {"enabled": True, "effort": "xhigh"} + + def test_global_fallback_when_no_override(self): + from hermes_constants import resolve_reasoning_config + cfg = self._cfg(effort="low", overrides={"claude-opus-4.5": "xhigh"}) + assert resolve_reasoning_config(cfg, "gpt-5") == {"enabled": True, "effort": "low"} + + def test_explicit_model_wins_over_config_default(self): + """The session's effective model (e.g. after a session-only /model + switch) must be used for override lookup — NOT model.default.""" + from hermes_constants import resolve_reasoning_config + cfg = self._cfg( + effort="medium", + overrides={"gpt-5": "low", "claude-opus-4.5": "xhigh"}, + default_model="gpt-5", + ) + # Session switched to opus; its override must win over gpt-5's. + result = resolve_reasoning_config(cfg, "claude-opus-4.5") + assert result == {"enabled": True, "effort": "xhigh"} + + def test_empty_model_derives_from_config_default(self): + from hermes_constants import resolve_reasoning_config + cfg = self._cfg(overrides={"gpt-5": "high"}, default_model="gpt-5") + assert resolve_reasoning_config(cfg) == {"enabled": True, "effort": "high"} + + def test_empty_model_derives_from_model_alias_key(self): + """model: {model: ...} alias shape (older configs) also resolves.""" + from hermes_constants import resolve_reasoning_config + cfg = { + "model": {"model": "gpt-5"}, + "agent": {"reasoning_effort": "medium", "reasoning_overrides": {"gpt-5": "high"}}, + } + assert resolve_reasoning_config(cfg) == {"enabled": True, "effort": "high"} + + def test_string_model_section(self): + """Top-level ``model: `` config shape (cron raw-YAML path).""" + from hermes_constants import resolve_reasoning_config + cfg = { + "model": "claude-opus-4.5", + "agent": {"reasoning_effort": "low", "reasoning_overrides": {"claude-opus-4.5": "xhigh"}}, + } + assert resolve_reasoning_config(cfg) == {"enabled": True, "effort": "xhigh"} + + def test_yaml_false_global_uncoerced(self): + """YAML boolean False must mean disabled — never coerced to ''.""" + from hermes_constants import resolve_reasoning_config + cfg = self._cfg(effort=False) + assert resolve_reasoning_config(cfg, "gpt-5") == {"enabled": False} + + def test_yaml_false_not_shadowed_by_other_models_override(self): + from hermes_constants import resolve_reasoning_config + cfg = self._cfg(effort=False, overrides={"claude-opus-4.5": "xhigh"}) + assert resolve_reasoning_config(cfg, "gpt-5") == {"enabled": False} + + def test_override_none_disables_for_model(self): + """Per-model override value 'none' disables thinking for that model.""" + from hermes_constants import resolve_reasoning_config + cfg = self._cfg(effort="high", overrides={"gemini-flash": "none"}) + assert resolve_reasoning_config(cfg, "gemini-flash") == {"enabled": False} + + def test_unknown_global_returns_none(self): + from hermes_constants import resolve_reasoning_config + cfg = self._cfg(effort="bogus-level") + assert resolve_reasoning_config(cfg, "gpt-5") is None + + def test_empty_config_returns_none(self): + from hermes_constants import resolve_reasoning_config + assert resolve_reasoning_config({}) is None + assert resolve_reasoning_config(None) is None + + def test_malformed_sections_tolerated(self): + """Non-dict agent/model sections must not raise.""" + from hermes_constants import resolve_reasoning_config + assert resolve_reasoning_config({"agent": "oops", "model": 42}) is None + assert resolve_reasoning_config({"agent": None, "model": None}) is None + assert resolve_reasoning_config({"agent": {"reasoning_overrides": "bad"}}) is None + + def test_invalid_override_value_falls_back_to_global(self): + """A junk override value for the matching model falls through to global.""" + from hermes_constants import resolve_reasoning_config + cfg = self._cfg(effort="medium", overrides={"gpt-5": "turbo-max"}) + assert resolve_reasoning_config(cfg, "gpt-5") == {"enabled": True, "effort": "medium"} + + +class TestReasoningOverridesDefaultConfig: + """Tests for the agent.reasoning_overrides default config key (Task 2).""" + + def test_default_config_has_reasoning_overrides_key(self): + """DEFAULT_CONFIG['agent'] contains 'reasoning_overrides' as an empty dict.""" + from hermes_cli.config import DEFAULT_CONFIG + assert "reasoning_overrides" in DEFAULT_CONFIG["agent"] + assert DEFAULT_CONFIG["agent"]["reasoning_overrides"] == {} + + def test_load_config_preserves_user_reasoning_overrides(self, tmp_path, monkeypatch): + """User-added reasoning_overrides are preserved through load_config().""" + import yaml + from hermes_cli.config import load_config, get_config_path + + user_config = { + "agent": { + "reasoning_overrides": { + "anthropic/claude-opus-4-5": "high", + "openrouter/anthropic/claude-sonnet-4-6": "low", + } + } + } + config_path = tmp_path / "config.yaml" + config_path.write_text(yaml.safe_dump(user_config)) + + # load_config() reads from get_config_path() — patch its global reference + monkeypatch.setitem( + load_config.__globals__, "get_config_path", lambda: config_path + ) + + loaded = load_config() + assert loaded["agent"]["reasoning_overrides"] == { + "anthropic/claude-opus-4-5": "high", + "openrouter/anthropic/claude-sonnet-4-6": "low", + } + + def test_spelling_tolerant_lookup_works_with_user_config(self): + """resolve_per_model_reasoning_effort works with user-added overrides.""" + from hermes_constants import resolve_per_model_reasoning_effort + # User config with one override, query uses different spelling + overrides = { + "anthropic/claude-opus-4.5": "xhigh", # user wrote with dots + } + # Lookup with different spelling (bare, dashes) — should still match + result = resolve_per_model_reasoning_effort("claude-opus-4-5", overrides) + assert result == {"enabled": True, "effort": "xhigh"} + + # Another override, bare key + overrides2 = {"gpt-5": "low"} + # Lookup with provider prefix — should match + result2 = resolve_per_model_reasoning_effort("openai/gpt-5", overrides2) + assert result2 == {"enabled": True, "effort": "low"} + + class TestSecureParentDir: """Tests for secure_parent_dir() — prevents chmod on / or top-level dirs.""" diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index b8b0bbd77322..f117af57a63e 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -7596,7 +7596,7 @@ def _setup_make_agent_mocks(monkeypatch, cfg): }, ) monkeypatch.setattr(server, "_load_tool_progress_mode", lambda: "off") - monkeypatch.setattr(server, "_load_reasoning_config", lambda: None) + monkeypatch.setattr(server, "_load_reasoning_config", lambda model="": None) monkeypatch.setattr(server, "_load_service_tier", lambda: None) monkeypatch.setattr(server, "_load_enabled_toolsets", lambda: None) monkeypatch.setattr(server, "_get_db", lambda: None) diff --git a/tests/tui_gateway/test_reasoning_config_per_model.py b/tests/tui_gateway/test_reasoning_config_per_model.py new file mode 100644 index 000000000000..d3468ff530a9 --- /dev/null +++ b/tests/tui_gateway/test_reasoning_config_per_model.py @@ -0,0 +1,100 @@ +"""Tests for per-model reasoning_effort override in TUI gateway _load_reasoning_config.""" + +import pytest + +import tui_gateway.server as tui_server + + +class TestTUIPerModelReasoningConfig: + """Test tui_gateway _load_reasoning_config respects per-model overrides.""" + + def test_per_model_override_takes_precedence(self, monkeypatch): + """Per-model override wins over global reasoning_effort.""" + fake_cfg = { + "model": {"default": "anthropic/claude-opus-4.5"}, + "agent": { + "reasoning_effort": "medium", + "reasoning_overrides": { + "anthropic/claude-opus-4.5": "xhigh", + }, + }, + } + monkeypatch.setattr(tui_server, "_load_cfg", lambda: fake_cfg) + + result = tui_server._load_reasoning_config() + assert result is not None + assert result["enabled"] is True + assert result["effort"] == "xhigh" + + def test_global_fallback_when_no_override(self, monkeypatch): + """Global reasoning_effort applies when no per-model override matches.""" + fake_cfg = { + "model": {"default": "gpt-5"}, + "agent": { + "reasoning_effort": "high", + "reasoning_overrides": { + "anthropic/claude-opus-4.5": "xhigh", + }, + }, + } + monkeypatch.setattr(tui_server, "_load_cfg", lambda: fake_cfg) + + result = tui_server._load_reasoning_config() + assert result is not None + assert result["effort"] == "high" + + def test_spelling_tolerant_match(self, monkeypatch): + """Override matches even with different spelling (provider prefix).""" + fake_cfg = { + "model": {"default": "claude-opus-4.5"}, + "agent": { + "reasoning_effort": "medium", + "reasoning_overrides": { + "anthropic/claude-opus-4.5": "high", # key has prefix, model doesn't + }, + }, + } + monkeypatch.setattr(tui_server, "_load_cfg", lambda: fake_cfg) + + result = tui_server._load_reasoning_config() + assert result is not None + assert result["effort"] == "high" + + def test_parity_with_gateway_loader(self, monkeypatch): + """TUI and gateway loaders return identical results for same config.""" + import gateway.run as gateway_run + + fake_cfg = { + "model": {"default": "openrouter/anthropic/claude-sonnet-4.6"}, + "agent": { + "reasoning_effort": "low", + "reasoning_overrides": { + "claude-sonnet-4.6": "high", + }, + }, + } + monkeypatch.setattr(tui_server, "_load_cfg", lambda: fake_cfg) + monkeypatch.setattr(gateway_run, "_load_gateway_runtime_config", lambda: fake_cfg) + + tui_result = tui_server._load_reasoning_config() + gw_result = gateway_run.GatewayRunner._load_reasoning_config() + assert tui_result == gw_result + + def test_global_fallback_with_yaml_false(self, monkeypatch): + """YAML boolean False must reach parse_reasoning_effort uncoerced. + + Regression: str(... or "").strip() turned False into "", silently + re-enabling thinking. The raw value must pass through so + parse_reasoning_effort(False) returns {'enabled': False}. + """ + fake_cfg = { + "model": {"default": "gpt-5"}, + "agent": { + "reasoning_effort": False, # YAML boolean, not string + }, + } + monkeypatch.setattr(tui_server, "_load_cfg", lambda: fake_cfg) + + result = tui_server._load_reasoning_config() + assert result is not None + assert result.get("enabled") is False diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 83c79975e2ae..af5cead103c1 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -2562,15 +2562,17 @@ def _display_mouse_tracking(display: dict) -> str: return "all" -def _load_reasoning_config() -> dict | None: - from hermes_constants import parse_reasoning_effort - - # Pass the raw value through — ``or ""`` would coerce a YAML boolean - # False (``reasoning_effort: false``/``off``/``no``) to "", silently - # re-enabling thinking for users who explicitly turned it off. - return parse_reasoning_effort( - (_load_cfg().get("agent") or {}).get("reasoning_effort", "") - ) +def _load_reasoning_config(model: str = "") -> dict | None: + """Load reasoning effort from config.yaml, respecting per-model overrides. + + Thin wrapper over the shared chokepoint + :func:`hermes_constants.resolve_reasoning_config` (per-model override > + global ``agent.reasoning_effort``; YAML boolean False = disabled). + Closes #21256. + """ + from hermes_constants import resolve_reasoning_config + + return resolve_reasoning_config(_load_cfg(), model) def _load_service_tier() -> str | None: @@ -4211,7 +4213,7 @@ def _background_agent_kwargs(agent, task_id: str) -> dict: "openrouter_min_coding_score": getattr(agent, "openrouter_min_coding_score", None), "session_id": task_id, "reasoning_config": getattr(agent, "reasoning_config", None) - or _load_reasoning_config(), + or _load_reasoning_config(str(getattr(agent, "model", "") or "")), "service_tier": getattr(agent, "service_tier", None) or _load_service_tier(), "request_overrides": dict(getattr(agent, "request_overrides", {}) or {}), "platform": "tui", @@ -4640,7 +4642,7 @@ def _make_agent( reasoning_config=( reasoning_config_override if reasoning_config_override is not None - else _load_reasoning_config() + else _load_reasoning_config(str(model or "")) ), service_tier=( service_tier_override diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index 40748ce23197..67f3a0ed5c5b 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -1301,6 +1301,37 @@ You can also change the reasoning effort at runtime with the `/reasoning` comman /reasoning hide # Hide model thinking ``` +#### Per-Model Reasoning Overrides + +You can set different reasoning effort levels for different models. This is useful when you want high reasoning for complex models but medium for faster ones: + +```yaml +agent: + reasoning_effort: "medium" # global default + reasoning_overrides: + "openrouter/anthropic/claude-opus-4.5": "xhigh" + "openai/gpt-5": "low" + "claude-sonnet-4.6": "high" # bare model name also works +``` + +The key matching is **spelling-tolerant** — any reasonable spelling will match: +- `claude-opus-4.5`, `claude-opus-4-5`, `claude-opus.4.5` (dots and dashes are interchangeable) +- `anthropic/claude-opus-4.5`, `openrouter/anthropic/claude-opus-4.5` (provider prefix optional) +- Exact matches take precedence over variants + +:::note +There is no `hermes config set` support for `reasoning_overrides` keys — edit the YAML file directly. This is because model names often contain dots (e.g. `claude-opus-4.5`), which conflict with the CLI's dotted-key syntax. +::: + +**Resolution priority:** + +1. Session-scoped `/reasoning --session` override (gateway only) +2. Per-model override from `agent.reasoning_overrides` (spelling-tolerant) +3. Global `agent.reasoning_effort` +4. Provider default + +The override applies automatically everywhere: CLI startup, messaging gateway, Desktop/TUI, cron jobs, `/model` mid-session switches, and fallback model activation. + ## Tool-Use Enforcement Some models occasionally describe intended actions as text instead of making tool calls ("I would run the tests..." instead of actually calling the terminal). Tool-use enforcement injects system prompt guidance that steers the model back to actually calling tools.