NousResearch/hermes-agent

Per-model reasoning_effort config override support

Cluster 15511 · agent. Data as of

1 open item. Fix merged in #64458 on 14 Jul 2026. Check it covers your case.

The short answer

1open issue
0open PRs
6items grouped
Known?
Yes. 6 related items grouped here, 2 issues already closed.
Fix event
A fix was merged to main in PR #64458. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this partially resolved, deciding PR #64458. That is separate from the fix event.
Still open
1 issue and 0 PRs.

These items all concern adding support for configuring reasoning_effort at various granularities—per-model, per-provider, per-fallback-chain entry, and via custom_providers—rather than only globally. The work spans multiple configuration resolution points and scopes.

Open issues1

  • Issue#31589Support provider-scoped agent.reasoning_effort overrides

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #64458 feat(config): per-model reasoning_effort overrides via unified resolution chokepoint

    merged to main

    files: agent/agent_runtime_helpers.py, agent/chat_completion_helpers.py, cli-config.yaml.example, cli.py, cron/scheduler.py, gateway/run.py, gateway/slash_commands.py, hermes_cli/config.py, hermes_constants.py, tests/cron/test_reasoning_config_per_model.py, tests/gateway/test_api_server.py, tests/gateway/test_reasoning_config_per_model.py, tests/run_agent/test_fallback_reasoning_override.py, tests/run_agent/test_switch_model_reasoning_override.py, tests/test_hermes_constants.py, tests/test_tui_gateway_server.py, tests/tui_gateway/test_reasoning_config_per_model.py, tui_gateway/server.py, website/docs/user-guide/configuration.md

    Show the diff
    • 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": [redacted:secret-assignment] "api_key", "") if _cc else "",
    • 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)
    • 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."
    • 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", "")
               )
    • 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
    • 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()
    • 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):
    • 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",
    • 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.
       
    • 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
    • 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)
    • 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"}
    • 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
    • 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 = "[redacted:secret-assignment]"
      +        agent._client_kwargs = {"api_key": "[redacted:secret-assignment]", "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
    • 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: <string>`` 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."""
       
    • 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)
    • 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
    • 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
    • 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.

2 PRs closed without merging

From the close comments: merged via another change 1 · superseded by another PR 1

  • #42447 feat(config): per-entry reasoning_effort in fallback_model chain (#21256) · superseded by another PR → #64458, #21256
  • #51377 feat(config): support per-model reasoning_effort overrides · merged via another change → d9cdb81, #64458, #21256
Closed issues · 2
  • Closed#15511Support per-model reasoning_effort in custom_providers[].models
  • Closed#21256feat(config): support per-model reasoning_effort defaults via custom_providers
How this cluster was grouped
  • Link evidence (partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
  • Members outside that structural core may belong to a different underlying problem.
  • Pairwise link density 0.38: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
agent
Merged PRs
#64458
Closed issues
2
Attempts
2 PRs closed without merging
Fix link checked
14 Jul 2026

For your agent

The same facts, typed.