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