# NousResearch/hermes-agent · Smart routing api_mode loss and credential refresh bugs

## Open now

- Nothing open on GitHub.

These issues and fixes center on api_mode being silently dropped or reset during smart model routing and credential refresh flows. Topics include preserving cheap_model api_mode in routing, fixing _ensure_runtime_credentials() overwriting transport settings, and a removal of the smart routing feature itself.

## TL;DR

- 7 member(s): 0 open issue(s), 2 closed issue(s), 0 open PR(s), 1 merged PR(s), 4 PR(s) closed without merging.
- A fix was merged to main in PR #12732. Verify it addresses your case.
- **Recommended:** read PR #12732 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `unresolved`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): 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.26`: the share of member 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.

## Closed / duplicate issues

- #8515: \[Bug\]: Smart routing drops api_mode from cheap_model config
- #17574: _ensure_runtime_credentials() silently resets api_mode, breaking providers that require non-default transport

## Prior & competing PRs

- **#8321: fix(routing): pass api_mode from cheap_model config in smart routing**. closed without merge · fix tier `suggested`
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `#12732`, `#12846`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#12732: refactor: remove smart_model_routing feature**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `agent/smart_model_routing.py`, `cli-config.yaml.example`, `cli.py`, `cron/scheduler.py`, `gateway/run.py`, `hermes_cli/config.py`, `hermes_cli/dump.py`, `hermes_cli/tips.py`, `hermes_cli/web_server.py`, `skills/autonomous-ai-agents/hermes-agent/SKILL.md`, `tests/agent/test_credential_pool_routing.py`, `tests/agent/test_smart_model_routing.py`, `tests/cli/test_cli_provider_resolution.py`, `tests/cli/test_fast_command.py`, `tests/cron/test_codex_execution_paths.py`, `tests/gateway/test_discord_channel_prompts.py`, `tests/gateway/test_fast_command.py`, `website/docs/integrations/providers.md`
  - fix hunks:
  - `/dev/null`

    ```diff
    @@ -1,195 +0,0 @@
    -"""Helpers for optional cheap-vs-strong model routing."""
    -
    -from __future__ import annotations
    -
    -import os
    -import re
    -from typing import Any, Dict, Optional
    -
    -from utils import is_truthy_value
    -
    -_COMPLEX_KEYWORDS = {
    -    "debug",
    -    "debugging",
    -    "implement",
    -    "implementation",
    -    "refactor",
    -    "patch",
    -    "traceback",
    -    "stacktrace",
    -    "exception",
    -    "error",
    -    "analyze",
    -    "analysis",
    -    "investigate",
    -    "architecture",
    -    "design",
    -    "compare",
    -    "benchmark",
    -    "optimize",
    -    "optimise",
    -    "review",
    -    "terminal",
    -    "shell",
    -    "tool",
    -    "tools",
    -    "pytest",
    -    "test",
    -    "tests",
    -    "plan",
    -    "planning",
    -    "delegate",
    -    "subagent",
    -    "cron",
    -    "docker",
    -    "kubernetes",
    -}
    -
    -_URL_RE = re.compile(r"https?://|www\.", re.IGNORECASE)
    -
    -
    -def _coerce_bool(value: Any, default: bool = False) -> bool:
    -    return is_truthy_value(value, default=default)
    -
    -
    -def _coerce_int(value: Any, default: int) -> int:
    -    try:
    -        return int(value)
    -    except (TypeError, ValueError):
    -        return default
    -
    -
    -def choose_cheap_model_route(user_message: str, routing_config: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
    -    """Return the configured cheap-model route when a message looks simple.
    -
    -    Conservative by design: if the message has signs of code/tool/debugging/
    -    long-form work, keep the primary model.
    -    """
    -    cfg = routing_config or {}
    -    if not _coerce_bool(cfg.get("enabled"), False):
    -        return None
    -
    -    cheap_model = cfg.get("cheap_model") or {}
    -    if not isinstance(cheap_model, dict):
    -        return None
    -    provider = str(cheap_model.get("provider") or "").strip().lower()
    -    model = str(cheap_model.get("model") or "").strip()
    -    if not provider or not model:
    -        return None
    -
    -    text = (user_message or "").strip()
    -    if not text:
    -        return None
    -
    -    max_chars = _coerce_int(cfg.get("max_simple_chars"), 160)
    -    max_words = _coerce_int(cfg.get("max_simple_words"), 28)
    -
    -    if len(text) > max_chars:
    -        return None
    -    if len(text.split()) > max_words:
    -        return None
    -    if text.count("\n") > 1:
    -        return None
    -    if "```" in text or "`" in text:
    -        return None
    -    if _URL_RE.search(text):
    -        return None
    -
    -    lowered = text.lower()
    -    words = {token.strip(".,:;!?()[]{}\"'`") for token in lowered.split()}
    -    if words & _COMPLEX_KEYWORDS:
    -        return None
    -
    -    route = dict(cheap_model)
    -    route["provider"] = provider
    -    route["model"] = model
    -    route["routing_reason"] = "simple_turn"
    -    return route
    -
    -
    -def resolve_turn_route(user_message: str, routing_config: Optional[Dict[str, Any]], primary: Dict[str, Any]) -> Dict[str, Any]:
    -    """Resolve the effective model/runtime for one turn.
    -
    -    Returns a dict with model/runtime/signature/label fields.
    -    """
    -    route = choose_cheap_model_route(user_message, routing_config)
    -    if not route:
    -        return {
    -            "model": primary.get("model"),
    -            "runtime": {
    -                "api_key": primary.get("api_key"),
    -                "base_url": primary.get("base_url"),
    -                "provider": primary.get("provider"),
    -                "api_mode": primary.get("api_mode"),
    -                "command": primary.get("command"),
    -                "args": list(primary.get("args") or []),
    -                "credential_pool": primary.get("credential_pool"),
    -            },
    -            "label": None,
    -            "signature": (
    -                primary.get("model"),
    -                primary.get("provider"),
    -                primary.get("base_url"),
    -                primary.get("api_mode"),
    -                primary.get("command"),
    -                tuple(primary.get("args") or ()),
    -            ),
    -        }
    -
    -    from hermes_cli.runtime_provider import resolve_runtime_provider
    -
    -    explicit_api_key = None
    -    api_key_env = [redacted:secret-assignment]"api_key_env") or "").strip()
    -    if api_key_env:
    -        explicit_api_key = [redacted:secret-assignment] or None
    -
    -    try:
    -        runtime = resolve_runtime_provider(
    -            requested=route.get("provider"),
    -            explicit_api_key=[redacted:secret-assignment]
    -            explicit_base_url=route.get("base_url"),
    -        )
    -    except Exception:
    -        return {
    -            "model": primary.get("model"),
    -            "runtime": {
    -                "api_key": primary.get("api_key"),
    -                "base_url": primary.get("base_url"),
    -                "provider": primary.get("provider"),
    -                "api_mode": primary.get("api_mode"),
    -                "command": primary.get("command"),
    -                "args": list(primary.get("args") or []),
    -                "credential_pool": primary.get("credential_pool"),
    -            },
    -            "label": None,
    -            "signature": (
    -                primary.get("model"),
    -                primary.get("provider"),
    -                primary.get("base_url"),
    -                primary.get("api_mode"),
    -                primary.get("command"),
    -                tuple(primary.get("args") or ()),
    -            ),
    -        }
    -
    -    return {
    -        "model": route.get("model"),
    -        "runtime": {
    -            "api_key": runtime.get("api_key"),
    -            "base_url": runtime.get("base_url"),
    -            "provider": runtime.get("provider"),
    -            "api_mode": runtime.get("api_mode"),
    -            "command": runtime.get("command"),
    -            "args": list(runtime.get("args") or []),
    -            "credential_pool": runtime.get("credential_pool"),
    -        },
    -        "label": f"smart route → {route.get('model')} ({runtime.get('provider')})",
    -        "signature": (
    -            route.get("model"),
    -            runtime.get("provider"),
    -            runtime.get("base_url"),
    -            runtime.get("api_mode"),
    -            runtime.get("command"),
    -            tuple(runtime.get("args") or ()),
    -        ),
    -    }
    ```
  - `cli-config.yaml.example`

    ```diff
    @@ -114,20 +114,6 @@ model:
     #   # Data policy: "allow" (default) or "deny" to exclude providers that may store data
     #   # data_collection: "deny"
     
    -# =============================================================================
    -# Smart Model Routing (optional)
    -# =============================================================================
    -# Use a cheaper model for short/simple turns while keeping your main model for
    -# more complex requests. Disabled by default.
    -#
    -# smart_model_routing:
    -#   enabled: true
    -#   max_simple_chars: 160
    -#   max_simple_words: 28
    -#   cheap_model:
    -#     provider: openrouter
    -#     model: google/gemini-2.5-flash
    -
     # =============================================================================
     # Git Worktree Isolation
     # =============================================================================
    ```
  - `cli.py`

    ```diff
    @@ -310,12 +310,6 @@ def load_cli_config() -> Dict[str, Any]:
                 "enabled": True,      # Auto-compress when approaching context limit
                 "threshold": 0.50,    # Compress at 50% of model's context limit
             },
    -        "smart_model_routing": {
    -            "enabled": False,
    -            "max_simple_chars": 160,
    -            "max_simple_words": 28,
    -            "cheap_model": {},
    -        },
             "agent": {
                 "max_turns": 90,  # Default max tool-calling iterations (shared with subagents)
                 "verbose": False,
    ```

    ```diff
    @@ -1857,8 +1851,9 @@ def __init__(
                 fb = [fb] if fb.get("provider") and fb.get("model") else []
             self._fallback_model = fb
     
    -        # Optional cheap-vs-strong routing for simple turns
    -        self._smart_model_routing = CLI_CONFIG.get("smart_model_routing", {}) or {}
    +        # Signature of the currently-initialised agent's runtime.  Used to
    +        # rebuild the agent when provider / model / base_url changes across
    +        # turns (e.g. after /model or credential rotation).
             self._active_agent_route_signature = None
     
             # Agent will be initialized on first use
    ```

    ```diff
    @@ -2883,24 +2878,36 @@ def _ensure_runtime_credentials(self) -> bool:
             return True
     
         def _resolve_turn_agent_config(self, user_message: str) -> dict:
    -        """Resolve model/runtime overrides for a single user turn."""
    -        from agent.smart_model_routing import resolve_turn_route
    +        """Build the effective model/runtime config for a single user turn.
    +
    +        Always uses the session's primary model/provider.  If the user has
    +        toggled `/fast` on and the current model supports Priority
    +        Processing / Anthropic fast mode, attach `request_overrides` so the
    +        API call is marked accordingly.
    +        """
             from hermes_cli.models import resolve_fast_mode_overrides
     
    -        route = resolve_turn_route(
    -            user_message,
    -            self._smart_model_routing,
    -            {
    -                "model": self.model,
    -                "api_key": self.api_key,
    -                "base_url": self.base_url,
    -                "provider": self.provider,
    -                "api_mode": self.api_mode,
    -                "command": self.acp_command,
    -                "args": list(self.acp_args or []),
    -                "credential_pool": getattr(self, "_credential_pool", None),
    -            },
    -        )
    +        runtime = {
    +            "api_key": self.api_key,
    +            "base_url": self.base_url,
    +            "provider": self.provider,
    +            "api_mode": self.api_mode,
    +            "command": self.acp_command,
    +            "args": list(self.acp_args or []),
    +            "credential_pool": getattr(self, "_credential_pool", None),
    +        }
    +        route = {
    +            "model": self.model,
    +            "runtime": runtime,
    +            "signature": (
    +                self.model,
    +                runtime["provider"],
    +                runtime["base_url"],
    +                runtime["api_mode"],
    +                runtime["command"],
    +                tuple(runtime["args"]),
    +            ),
    +        }
     
             service_tier = getattr(self, "service_tier", None)
             if not service_tier:
    ```

    ```diff
    @@ -2908,13 +2915,13 @@ def _resolve_turn_agent_config(self, user_message: str) -> dict:
                 return route
     
             try:
    -            overrides = resolve_fast_mode_overrides(route.get("model"))
    +            overrides = resolve_fast_mode_overrides(route["model"])
             except Exception:
                 overrides = None
             route["request_overrides"] = overrides
             return route
     
    -    def _init_agent(self, *, model_override: str = None, runtime_override: dict = None, route_label: str = None, request_overrides: dict | None = None) -> bool:
    +    def _init_agent(self, *, model_override: str = None, runtime_override: dict = None, request_overrides: dict | None = None) -> bool:
             """
             Initialize the agent on first use.
             When resuming a session, restores conversation history from SQLite.
    ```

    ```diff
    @@ -7911,7 +7918,6 @@ def chat(self, message, images: list = None) -> Optional[str]:
             if not self._init_agent(
                 model_override=turn_route["model"],
                 runtime_override=turn_route["runtime"],
    -            route_label=turn_route["label"],
                 request_overrides=turn_route.get("request_overrides"),
             ):
                 return None
    ```

    ```diff
    @@ -10535,7 +10541,6 @@ def _signal_handler_q(signum, frame):
                     if cli._init_agent(
                         model_override=turn_route["model"],
                         runtime_override=turn_route["runtime"],
    -                    route_label=turn_route["label"],
                         request_overrides=turn_route.get("request_overrides"),
                     ):
                         cli.agent.quiet_mode = True
    ```
  - `cron/scheduler.py`

    ```diff
    @@ -826,7 +826,6 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
     
             # Provider routing
             pr = _cfg.get("provider_routing", {})
    -        smart_routing = _cfg.get("smart_model_routing", {}) or {}
     
             from hermes_cli.runtime_provider import (
                 resolve_runtime_provider,
    ```

    ```diff
    @@ -843,24 +842,9 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
                 message = format_runtime_provider_error(exc)
                 raise RuntimeError(message) from exc
     
    -        from agent.smart_model_routing import resolve_turn_route
    -        turn_route = resolve_turn_route(
    -            prompt,
    -            smart_routing,
    -            {
    -                "model": model,
    -                "api_key": runtime.get("api_key"),
    -                "base_url": runtime.get("base_url"),
    -                "provider": runtime.get("provider"),
    -                "api_mode": runtime.get("api_mode"),
    -                "command": runtime.get("command"),
    -                "args": list(runtime.get("args") or []),
    -            },
    -        )
    -
             fallback_model = _cfg.get("fallback_providers") or _cfg.get("fallback_model") or None
             credential_pool = None
    -        runtime_provider = str(turn_route["runtime"].get("provider") or "").strip().lower()
    +        runtime_provider = str(runtime.get("provider") or "").strip().lower()
             if runtime_provider:
                 try:
                     from agent.credential_pool import load_pool
    ```

    ```diff
    @@ -877,13 +861,13 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
                     logger.debug("Job '%s': failed to load credential pool for %s: %s", job_id, runtime_provider, e)
     
             agent = AIAgent(
    -            model=turn_route["model"],
    -            api_key=[redacted:secret-assignment]"runtime"].get("api_key"),
    -            base_url=turn_route["runtime"].get("base_url"),
    -            provider=turn_route["runtime"].get("provider"),
    -            api_mode=turn_route["runtime"].get("api_mode"),
    -            acp_command=turn_route["runtime"].get("command"),
    -            acp_args=turn_route["runtime"].get("args"),
    +            model=model,
    +            api_key=[redacted:secret-assignment]"api_key"),
    +            base_url=runtime.get("base_url"),
    +            provider=runtime.get("provider"),
    +            api_mode=runtime.get("api_mode"),
    +            acp_command=runtime.get("command"),
    +            acp_args=runtime.get("args"),
                 max_iterations=max_iterations,
                 reasoning_config=reasoning_config,
                 prefill_messages=prefill_messages,
    ```
  - `gateway/run.py`

    ```diff
    @@ -629,7 +629,6 @@ def __init__(self, config: Optional[GatewayConfig] = None):
             self._restart_drain_timeout = self._load_restart_drain_timeout()
             self._provider_routing = self._load_provider_routing()
             self._fallback_model = self._load_fallback_model()
    -        self._smart_model_routing = self._load_smart_model_routing()
     
             # Wire process registry into session store for reset protection
             from tools.process_registry import process_registry
    ```

    ```diff
    @@ -1082,11 +1081,16 @@ def _resolve_session_agent_runtime(
             return model, runtime_kwargs
     
         def _resolve_turn_agent_config(self, user_message: str, model: str, runtime_kwargs: dict) -> dict:
    -        from agent.smart_model_routing import resolve_turn_route
    +        """Build the effective model/runtime config for a single turn.
    +
    +        Always uses the session's primary model/provider.  If `/fast` is
    +        enabled and the model supports Priority Processing / Anthropic fast
    +        mode, attach `request_overrides` so the API call is marked
    +        accordingly.
    +        """
             from hermes_cli.models import resolve_fast_mode_overrides
     
    -        primary = {
    -            "model": model,
    +        runtime = {
                 "api_key": runtime_kwargs.get("api_key"),
                 "base_url": runtime_kwargs.get("base_url"),
                 "provider": runtime_kwargs.get("provider"),
    ```

    ```diff
    @@ -1095,7 +1099,18 @@ def _resolve_turn_agent_config(self, user_message: str, model: str, runtime_kwar
                 "args": list(runtime_kwargs.get("args") or []),
                 "credential_pool": runtime_kwargs.get("credential_pool"),
             }
    -        route = resolve_turn_route(user_message, getattr(self, "_smart_model_routing", {}), primary)
    +        route = {
    +            "model": model,
    +            "runtime": runtime,
    +            "signature": (
    +                model,
    +                runtime["provider"],
    +                runtime["base_url"],
    +                runtime["api_mode"],
    +                runtime["command"],
    +                tuple(runtime["args"]),
    +            ),
    +        }
     
             service_tier = getattr(self, "_service_tier", None)
             if not service_tier:
    ```

    ```diff
    @@ -1103,7 +1118,7 @@ def _resolve_turn_agent_config(self, user_message: str, model: str, runtime_kwar
                 return route
     
             try:
    -            overrides = resolve_fast_mode_overrides(route.get("model"))
    +            overrides = resolve_fast_mode_overrides(route["model"])
             except Exception:
                 overrides = None
             route["request_overrides"] = overrides
    ```

    ```diff
    @@ -1461,20 +1476,6 @@ def _load_fallback_model() -> list | dict | None:
                 pass
             return None
     
    -    @staticmethod
    -    def _load_smart_model_routing() -> dict:
    -        """Load optional smart cheap-vs-strong model routing config."""
    -        try:
    -            import yaml as _y
    -            cfg_path = _hermes_home / "config.yaml"
    -            if cfg_path.exists():
    -                with open(cfg_path, encoding="utf-8") as _f:
    -                    cfg = _y.safe_load(_f) or {}
    -                return cfg.get("smart_model_routing", {}) or {}
    -        except Exception:
    -            pass
    -        return {}
    -
         def _snapshot_running_agents(self) -> Dict[str, Any]:
             return {
                 session_key: agent
    ```
  - `hermes_cli/config.py`

    ```diff
    @@ -474,13 +474,6 @@ def _ensure_hermes_home_managed(home: Path):
             },
         },
     
    -    "smart_model_routing": {
    -        "enabled": False,
    -        "max_simple_chars": 160,
    -        "max_simple_words": 28,
    -        "cheap_model": {},
    -    },
    -    
         # Auxiliary model config — provider:model for each side task.
         # Format: provider is the provider name, model is the model slug.
         # "auto" for provider = auto-detect best available provider.
    ```

    ```diff
    @@ -2878,19 +2871,6 @@ def load_config() -> Dict[str, Any]:
     # fallback_model:
     #   provider: openrouter
     #   model: anthropic/claude-sonnet-4
    -#
    -# ── Smart Model Routing ────────────────────────────────────────────────
    -# Optional cheap-vs-strong routing for simple turns.
    -# Keeps the primary model for complex work, but can route short/simple
    -# messages to a cheaper model across providers.
    -#
    -# smart_model_routing:
    -#   enabled: true
    -#   max_simple_chars: 160
    -#   max_simple_words: 28
    -#   cheap_model:
    -#     provider: openrouter
    -#     model: google/gemini-2.5-flash
     """
     
     
    ```

    ```diff
    @@ -2922,19 +2902,6 @@ def load_config() -> Dict[str, Any]:
     # fallback_model:
     #   provider: openrouter
     #   model: anthropic/claude-sonnet-4
    -#
    -# ── Smart Model Routing ────────────────────────────────────────────────
    -# Optional cheap-vs-strong routing for simple turns.
    -# Keeps the primary model for complex work, but can route short/simple
    -# messages to a cheaper model across providers.
    -#
    -# smart_model_routing:
    -#   enabled: true
    -#   max_simple_chars: 160
    -#   max_simple_words: 28
    -#   cheap_model:
    -#     provider: openrouter
    -#     model: google/gemini-2.5-flash
     """
     
     
    ```
  - `hermes_cli/dump.py`

    ```diff
    @@ -160,7 +160,6 @@ def _config_overrides(config: dict) -> dict[str, str]:
             ("display", "streaming"),
             ("display", "skin"),
             ("display", "show_reasoning"),
    -        ("smart_model_routing", "enabled"),
             ("privacy", "redact_pii"),
             ("tts", "provider"),
         ]
    ```
  - `hermes_cli/tips.py`

    ```diff
    @@ -323,7 +323,6 @@
         "GPT-5 and Codex use 'developer' role instead of 'system' in the message format.",
         "Per-task auxiliary overrides: auxiliary.vision.provider, auxiliary.compression.model, etc. in config.yaml.",
         "The auxiliary client treats 'main' as a provider alias — resolves to your actual primary provider + model.",
    -    "Smart routing can auto-route simple queries to a cheaper model — set smart_model_routing.enabled: true.",
         "hermes claw migrate --dry-run previews OpenClaw migration without writing anything.",
         "File paths pasted with quotes or escaped spaces are handled automatically — no manual cleanup needed.",
         "Slash commands never trigger the large-paste collapse — /command with big arguments works correctly.",
    ```
  - `hermes_cli/web_server.py`

    ```diff
    @@ -232,7 +232,6 @@ async def auth_middleware(request: Request, call_next):
         "checkpoints": "agent",
         "approvals": "security",
         "human_delay": "display",
    -    "smart_model_routing": "agent",
         "dashboard": "display",
         "code_execution": "agent",
     }
    ```
  - `skills/autonomous-ai-agents/hermes-agent/SKILL.md`

    ```diff
    @@ -338,7 +338,6 @@ Edit with `hermes config edit` or `hermes config set section.key value`.
     | `memory` | `memory_enabled`, `user_profile_enabled`, `provider` |
     | `security` | `tirith_enabled`, `website_blocklist` |
     | `delegation` | `model`, `provider`, `base_url`, `api_key`, `max_iterations` (50), `reasoning_effort` |
    -| `smart_model_routing` | `enabled`, `cheap_model` |
     | `checkpoints` | `enabled`, `max_snapshots` (50) |
     
     Full config reference: https://hermes-agent.nousresearch.com/docs/user-guide/configuration
    ```
  - `tests/agent/test_credential_pool_routing.py`

    ```diff
    @@ -1,129 +1,25 @@
    -"""Tests for credential pool preservation through smart routing and 429 recovery.
    +"""Tests for credential pool preservation through turn config and 429 recovery.
     
     Covers:
    -1. credential_pool flows through resolve_turn_route (no-route and fallback paths)
    -2. CLI _resolve_turn_agent_config passes credential_pool to primary dict
    -3. Gateway _resolve_turn_agent_config passes credential_pool to primary dict
    -4. Eager fallback deferred when credential pool has credentials
    -5. Eager fallback fires when no credential pool exists
    -6. Full 429 rotation cycle: retry-same → rotate → exhaust → fallback
    +1. CLI _resolve_turn_agent_config passes credential_pool to runtime dict
    +2. Gateway _resolve_turn_agent_config passes credential_pool to runtime dict
    +3. Eager fallback deferred when credential pool has credentials
    +4. Eager fallback fires when no credential pool exists
    +5. Full 429 rotation cycle: retry-same → rotate → exhaust → fallback
     """
     
    -import os
    -import time
     from types import SimpleNamespace
    -from unittest.mock import MagicMock, patch, PropertyMock
    -
    -import pytest
    -
    -
    -# ---------------------------------------------------------------------------
    -# 1. smart_model_routing: credential_pool preserved in no-route path
    -# ---------------------------------------------------------------------------
    -
    -class TestSmartRoutingPoolPreservation:
    -    def test_no_route_preserves_credential_pool(self):
    -        from agent.smart_model_routing import resolve_turn_route
    -
    -        fake_pool = MagicMock(name="CredentialPool")
    -        primary = {
    -            "model": "gpt-5.4",
    -            "api_key": "sk-test",
    -            "base_url": None,
    -            "provider": "openai-codex",
    -            "api_mode": "codex_responses",
    -            "command": None,
    -            "args": [],
    -            "credential_pool": fake_pool,
    -        }
    -        # routing disabled
    -        result = resolve_turn_route("hello", None, primary)
    -        assert result["runtime"]["credential_pool"] is fake_pool
    -
    -    def test_no_route_none_pool(self):
    -        from agent.smart_model_routing import resolve_turn_route
    -
    -        primary = {
    -            "model": "gpt-5.4",
    -            "api_key": "sk-test",
    -            "base_url": None,
    -            "provider": "openai-codex",
    -            "api_mode": "codex_responses",
    -            "command": None,
    -            "args": [],
    -        }
    -        result = resolve_turn_route("hello", None, primary)
    -        assert result["runtime"]["credential_pool"] is None
    -
    -    def test_routing_disabled_preserves_pool(self):
    -        from agent.smart_model_routing import resolve_turn_route
    -
    -        fake_pool = MagicMock(name="CredentialPool")
    -        primary = {
    -            "model": "gpt-5.4",
    -            "api_key": "sk-test",
    -            "base_url": None,
    -            "provider": "openai-codex",
    -            "api_mode": "codex_responses",
    -            "command": None,
    -            "args": [],
    -            "credential_pool": fake_pool,
    -        }
    -        # routing explicitly disabled
    -        result = resolve_turn_route("hello", {"enabled": False}, primary)
    -        assert result["runtime"]["credential_pool"] is fake_pool
    -
    -    def test_route_fallback_on_resolve_error_preserves_pool(self, monkeypatch):
    -        """When smart routing picks a cheap model but resolve_runtime_provider
    -        fails, the fallback to primary must still include credential_pool."""
    -        from agent.smart_model_routing import resolve_turn_route
    -
    -        fake_pool = MagicMock(name="CredentialPool")
    -        primary = {
    -            "model": "gpt-5.4",
    -            "api_key": "sk-test",
    -            "base_url": None,
    -            "provider": "openai-codex",
    -            "api_mode": "codex_responses",
    -            "command": None,
    -            "args": [],
    -            "credential_pool": fake_pool,
    -        }
    -        routing_config = {
    -            "enabled": True,
    -            "cheap_model": "openai/gpt-4.1-mini",
    -            "cheap_provider": "openrouter",
    -            "max_tokens": 200,
    -            "patterns": ["^(hi|hello|hey)"],
    -        }
    -        # Force resolve_runtime_provider to fail so it falls back to primary
    -        monkeypatch.setattr(
    -            "hermes_cli.runtime_provider.resolve_runtime_provider",
    -            MagicMock(side_effect=RuntimeError("no credentials")),
    -        )
    -        result = resolve_turn_route("hi", routing_config, primary)
    -        assert result["runtime"]["credential_pool"] is fake_pool
    +from unittest.mock import MagicMock, patch
     
     
     # ---------------------------------------------------------------------------
    -# 2 & 3. CLI and Gateway _resolve_turn_agent_config include credential_pool
    +# 1. CLI _resolve_turn_agent_config includes credential_pool
     # ---------------------------------------------------------------------------
     
     class TestCliTurnRoutePool:
    -    def test_resolve_turn_includes_pool(self, monkeypatch, tmp_path):
    -        """CLI's _resolve_turn_agent_config must pass credential_pool to primary."""
    -        from agent.smart_model_routing import resolve_turn_route
    -        captured = {}
    -
    -        def spy_resolve(user_message, routing_config, primary):
    -            captured["primary"] = primary
    -            return resolve_turn_route(user_message, routing_config, primary)
    -
    -        monkeypatch.setattr(
    -            "agent.smart_model_routing.resolve_turn_route", spy_resolve
    -        )
    -
    -        # Build a minimal HermesCLI-like object with the method
    +    def test_resolve_turn_includes_pool(self):
    +        """CLI's _resolve_turn_agent_config must pass credential_pool in runtime."""
    +        fake_pool = MagicMock(name="FakePool")
             shell = SimpleNamespace(
                 model="gpt-5.4",
                 api_key="[redacted:secret-assignment]",
    ```

    ```diff
    @@ -132,58 +28,46 @@ def spy_resolve(user_message, routing_config, primary):
                 api_mode="codex_responses",
                 acp_command=None,
                 acp_args=[],
    -            _credential_pool=[redacted:secret-assignment]"FakePool"),
    -            _smart_model_routing={"enabled": False},
    +            _credential_pool=[redacted:secret-assignment]
    +            service_tier=None,
             )
     
    -        # Import and bind the real method
             from cli import HermesCLI
             bound = HermesCLI._resolve_turn_agent_config.__get__(shell)
    -        bound("test message")
    +        route = bound("test message")
     
    -        assert "credential_pool" in captured["primary"]
    -        assert captured["primary"]["credential_pool"] is shell._credential_pool
    +        assert route["runtime"]["credential_pool"] is fake_pool
     
     
    +# ---------------------------------------------------------------------------
    +# 2. Gateway _resolve_turn_agent_config includes credential_pool
    +# ---------------------------------------------------------------------------
    +
     class TestGatewayTurnRoutePool:
    -    def test_resolve_turn_includes_pool(self, monkeypatch):
    +    def test_resolve_turn_includes_pool(self):
             """Gateway's _resolve_turn_agent_config must pass credential_pool."""
    -        from agent.smart_model_routing import resolve_turn_route
    -        captured = {}
    -
    -        def spy_resolve(user_message, routing_config, primary):
    -            captured["primary"] = primary
    -            return resolve_turn_route(user_message, routing_config, primary)
    -
    -        monkeypatch.setattr(
    -            "agent.smart_model_routing.resolve_turn_route", spy_resolve
    -        )
    -
             from gateway.run import GatewayRunner
     
    -        runner = SimpleNamespace(
    -            _smart_model_routing={"enabled": False},
    -        )
    -
    +        fake_pool = MagicMock(name="FakePool")
    +        runner = SimpleNamespace(_service_tier=None)
             runtime_kwargs = {
    -            "api_key": "sk-test",
    +            "api_key": "***",
                 "base_url": None,
                 "provider": "openai-codex",
                 "api_mode": "codex_responses",
                 "command": None,
                 "args": [],
    -            "credential_pool": MagicMock(name="FakePool"),
    +            "credential_pool": fake_pool,
             }
     
             bound = GatewayRunner._resolve_turn_agent_config.__get__(runner)
    -        bound("test message", "gpt-5.4", runtime_kwargs)
    +        route = bound("test message", "gpt-5.4", runtime_kwargs)
     
    -        assert "credential_pool" in captured["primary"]
    -        assert captured["primary"]["credential_pool"] is runtime_kwargs["credential_pool"]
    +        assert route["runtime"]["credential_pool"] is fake_pool
     
     
     # ---------------------------------------------------------------------------
    -# 4 & 5. Eager fallback deferred/fires based on credential pool
    +# 3 & 4. Eager fallback deferred/fires based on credential pool
     # ---------------------------------------------------------------------------
     
     class TestEagerFallbackWithPool:
    ```

    ```diff
    @@ -251,7 +135,7 @@ def test_eager_fallback_fires_when_pool_exhausted(self):
     
     
     # ---------------------------------------------------------------------------
    -# 6. Full 429 rotation cycle via _recover_with_credential_pool
    +# 5. Full 429 rotation cycle via _recover_with_credential_pool
     # ---------------------------------------------------------------------------
     
     class TestPoolRotationCycle:
    ```
  - `/dev/null`

    ```diff
    @@ -1,61 +0,0 @@
    -from agent.smart_model_routing import choose_cheap_model_route
    -
    -
    -_BASE_CONFIG = {
    -    "enabled": True,
    -    "cheap_model": {
    -        "provider": "openrouter",
    -        "model": "google/gemini-2.5-flash",
    -    },
    -}
    -
    -
    -def test_returns_none_when_disabled():
    -    cfg = {**_BASE_CONFIG, "enabled": False}
    -    assert choose_cheap_model_route("what time is it in tokyo?", cfg) is None
    -
    -
    -def test_routes_short_simple_prompt():
    -    result = choose_cheap_model_route("what time is it in tokyo?", _BASE_CONFIG)
    -    assert result is not None
    -    assert result["provider"] == "openrouter"
    -    assert result["model"] == "google/gemini-2.5-flash"
    -    assert result["routing_reason"] == "simple_turn"
    -
    -
    -def test_skips_long_prompt():
    -    prompt = "please summarize this carefully " * 20
    -    assert choose_cheap_model_route(prompt, _BASE_CONFIG) is None
    -
    -
    -def test_skips_code_like_prompt():
    -    prompt = "debug this traceback: ```python\nraise ValueError('bad')\n```"
    -    assert choose_cheap_model_route(prompt, _BASE_CONFIG) is None
    -
    -
    -def test_skips_tool_heavy_prompt_keywords():
    -    prompt = "implement a patch for this docker error"
    -    assert choose_cheap_model_route(prompt, _BASE_CONFIG) is None
    -
    -
    -def test_resolve_turn_route_falls_back_to_primary_when_route_runtime_cannot_be_resolved(monkeypatch):
    -    from agent.smart_model_routing import resolve_turn_route
    -
    -    monkeypatch.setattr(
    -        "hermes_cli.runtime_provider.resolve_runtime_provider",
    -        lambda **kwargs: (_ for _ in ()).throw(RuntimeError("bad route")),
    -    )
    -    result = resolve_turn_route(
    -        "what time is it in tokyo?",
    -        _BASE_CONFIG,
    -        {
    -            "model": "anthropic/claude-sonnet-4",
    -            "provider": "openrouter",
    -            "base_url": "https://openrouter.ai/api/v1",
    -            "api_mode": "chat_completions",
    -            "api_key": "sk-primary",
    -        },
    -    )
    -    assert result["model"] == "anthropic/claude-sonnet-4"
    -    assert result["runtime"]["provider"] == "openrouter"
    -    assert result["label"] is None
    ```
  - `tests/cli/test_cli_provider_resolution.py`

    ```diff
    @@ -207,48 +207,11 @@ def test_cli_turn_routing_uses_primary_when_disabled(monkeypatch):
         shell.api_mode = "chat_completions"
         shell.base_url = "https://openrouter.ai/api/v1"
         shell.api_key = "[redacted:secret-assignment]"
    -    shell._smart_model_routing = {"enabled": False}
     
         result = shell._resolve_turn_agent_config("what time is it in tokyo?")
     
         assert result["model"] == "gpt-5"
         assert result["runtime"]["provider"] == "openrouter"
    -    assert result["label"] is None
    -
    -
    -def test_cli_turn_routing_uses_cheap_model_when_simple(monkeypatch):
    -    cli = _import_cli()
    -
    -    def _runtime_resolve(**kwargs):
    -        assert kwargs["requested"] == "zai"
    -        return {
    -            "provider": "zai",
    -            "api_mode": "chat_completions",
    -            "base_url": "https://open.z.ai/api/v1",
    -            "api_key": "cheap-key",
    -            "source": "env/config",
    -        }
    -
    -    monkeypatch.setattr("hermes_cli.runtime_provider.resolve_runtime_provider", _runtime_resolve)
    -
    -    shell = cli.HermesCLI(model="anthropic/claude-sonnet-4", compact=True, max_turns=1)
    -    shell.provider = "openrouter"
    -    shell.api_mode = "chat_completions"
    -    shell.base_url = "https://openrouter.ai/api/v1"
    -    shell.api_key = "[redacted:secret-assignment]"
    -    shell._smart_model_routing = {
    -        "enabled": True,
    -        "cheap_model": {"provider": "zai", "model": "glm-5-air"},
    -        "max_simple_chars": 160,
    -        "max_simple_words": 28,
    -    }
    -
    -    result = shell._resolve_turn_agent_config("what time is it in tokyo?")
    -
    -    assert result["model"] == "glm-5-air"
    -    assert result["runtime"]["provider"] == "zai"
    -    assert result["runtime"]["api_key"] == "cheap-key"
    -    assert result["label"] is not None
     
     
     def test_cli_prefers_config_provider_over_stale_env_override(monkeypatch):
    ```
  - `tests/cli/test_fast_command.py`

    ```diff
    @@ -183,27 +183,10 @@ def test_turn_route_injects_overrides_without_provider_switch(self):
                 acp_command=None,
                 acp_args=[],
                 _credential_pool=None,
    -            _smart_model_routing={},
                 service_tier="priority",
             )
     
    -        original_runtime = {
    -            "api_key": "***",
    -            "base_url": "https://openrouter.ai/api/v1",
    -            "provider": "openrouter",
    -            "api_mode": "chat_completions",
    -            "command": None,
    -            "args": [],
    -            "credential_pool": None,
    -        }
    -
    -        with patch("agent.smart_model_routing.resolve_turn_route", return_value={
    -            "model": "gpt-5.4",
    -            "runtime": dict(original_runtime),
    -            "label": None,
    -            "signature": ("gpt-5.4", "openrouter", "https://openrouter.ai/api/v1", "chat_completions", None, ()),
    -        }):
    -            route = cli_mod.HermesCLI._resolve_turn_agent_config(stub, "hi")
    +        route = cli_mod.HermesCLI._resolve_turn_agent_config(stub, "hi")
     
             # Provider should NOT have changed
             assert route["runtime"]["provider"] == "openrouter"
    ```

    ```diff
    @@ -222,26 +205,10 @@ def test_turn_route_keeps_primary_runtime_when_model_has_no_fast_backend(self):
                 acp_command=None,
                 acp_args=[],
                 _credential_pool=None,
    -            _smart_model_routing={},
                 service_tier="priority",
             )
     
    -        primary_route = {
    -            "model": "gpt-5.3-codex",
    -            "runtime": {
    -                "api_key": "***",
    -                "base_url": "https://openrouter.ai/api/v1",
    -                "provider": "openrouter",
    -                "api_mode": "chat_completions",
    -                "command": None,
    -                "args": [],
    -                "credential_pool": None,
    -            },
    -            "label": None,
    -            "signature": ("gpt-5.3-codex", "openrouter", "https://openrouter.ai/api/v1", "chat_completions", None, ()),
    -        }
    -        with patch("agent.smart_model_routing.resolve_turn_route", return_value=primary_route):
    -            route = cli_mod.HermesCLI._resolve_turn_agent_config(stub, "hi")
    +        route = cli_mod.HermesCLI._resolve_turn_agent_config(stub, "hi")
     
             assert route["runtime"]["provider"] == "openrouter"
             assert route.get("request_overrides") is None
    ```

    ```diff
    @@ -329,27 +296,10 @@ def test_turn_route_injects_speed_for_anthropic(self):
                 acp_command=None,
                 acp_args=[],
                 _credential_pool=None,
    -            _smart_model_routing={},
                 service_tier="priority",
             )
     
    -        original_runtime = {
    -            "api_key": "***",
    -            "base_url": "https://api.anthropic.com",
    -            "provider": "anthropic",
    -            "api_mode": "anthropic_messages",
    -            "command": None,
    -            "args": [],
    -            "credential_pool": None,
    -        }
    -
    -        with patch("agent.smart_model_routing.resolve_turn_route", return_value={
    -            "model": "claude-opus-4-6",
    -            "runtime": dict(original_runtime),
    -            "label": None,
    -            "signature": ("claude-opus-4-6", "anthropic", "https://api.anthropic.com", "anthropic_messages", None, ()),
    -        }):
    -            route = cli_mod.HermesCLI._resolve_turn_agent_config(stub, "hi")
    +        route = cli_mod.HermesCLI._resolve_turn_agent_config(stub, "hi")
     
             assert route["runtime"]["provider"] == "anthropic"
             assert route["request_overrides"] == {"speed": "fast"}
    ```
  - `tests/cron/test_codex_execution_paths.py`

    ```diff
    @@ -152,7 +152,6 @@ def test_gateway_run_agent_codex_path_handles_internal_401_refresh(monkeypatch):
         runner._provider_routing = {}
         runner._fallback_model = None
         runner._running_agents = {}
    -    runner._smart_model_routing = {}
         from unittest.mock import MagicMock, AsyncMock
         runner.hooks = MagicMock()
         runner.hooks.emit = AsyncMock()
    ```
  - `tests/gateway/test_discord_channel_prompts.py`

    ```diff
    @@ -75,7 +75,6 @@ def _make_runner():
         runner._service_tier = None
         runner._provider_routing = {}
         runner._fallback_model = None
    -    runner._smart_model_routing = {}
         runner._running_agents = {}
         runner._pending_model_notes = {}
         runner._session_db = None
    ```
  - `tests/gateway/test_fast_command.py`

    ```diff
    @@ -4,7 +4,7 @@
     import threading
     import types
     from types import SimpleNamespace
    -from unittest.mock import AsyncMock, patch
    +from unittest.mock import AsyncMock
     
     import pytest
     import yaml
    ```

    ```diff
    @@ -53,7 +53,6 @@ def _make_runner():
         runner._service_tier = None
         runner._provider_routing = {}
         runner._fallback_model = None
    -    runner._smart_model_routing = {}
         runner._running_agents = {}
         runner._pending_model_notes = {}
         runner._session_db = None
    ```

    ```diff
    @@ -97,13 +96,7 @@ def test_turn_route_injects_priority_processing_without_changing_runtime():
             "credential_pool": None,
         }
     
    -    with patch("agent.smart_model_routing.resolve_turn_route", return_value={
    -        "model": "gpt-5.4",
    -        "runtime": dict(runtime_kwargs),
    -        "label": None,
    -        "signature": ("gpt-5.4", "openrouter", "https://openrouter.ai/api/v1", "chat_completions", None, ()),
    -    }):
    -        route = gateway_run.GatewayRunner._resolve_turn_agent_config(runner, "hi", "gpt-5.4", runtime_kwargs)
    +    route = gateway_run.GatewayRunner._resolve_turn_agent_config(runner, "hi", "gpt-5.4", runtime_kwargs)
     
         assert route["runtime"]["provider"] == "openrouter"
         assert route["runtime"]["api_mode"] == "chat_completions"
    ```

    ```diff
    @@ -123,13 +116,7 @@ def test_turn_route_skips_priority_processing_for_unsupported_models():
             "credential_pool": None,
         }
     
    -    with patch("agent.smart_model_routing.resolve_turn_route", return_value={
    -        "model": "gpt-5.3-codex",
    -        "runtime": dict(runtime_kwargs),
    -        "label": None,
    -        "signature": ("gpt-5.3-codex", "openrouter", "https://openrouter.ai/api/v1", "chat_completions", None, ()),
    -    }):
    -        route = gateway_run.GatewayRunner._resolve_turn_agent_config(runner, "hi", "gpt-5.3-codex", runtime_kwargs)
    +    route = gateway_run.GatewayRunner._resolve_turn_agent_config(runner, "hi", "gpt-5.3-codex", runtime_kwargs)
     
         assert route["request_overrides"] is None
     
    ```
  - `website/docs/integrations/providers.md`

    ```diff
    @@ -1165,39 +1165,6 @@ Supported providers: `openrouter`, `nous`, `openai-codex`, `copilot`, `copilot-a
     Fallback is configured exclusively through `config.yaml` — there are no environment variables for it. For full details on when it triggers, supported providers, and how it interacts with auxiliary tasks and delegation, see [Fallback Providers](/docs/user-guide/features/fallback-providers).
     :::
     
    -## Smart Model Routing
    -
    -Optional cheap-vs-strong routing lets Hermes keep your main model for complex work while sending very short/simple turns to a cheaper model.
    -
    -```yaml
    -smart_model_routing:
    -  enabled: true
    -  max_simple_chars: 160
    -  max_simple_words: 28
    -  cheap_model:
    -    provider: openrouter
    -    model: google/gemini-2.5-flash
    -    # base_url: http://localhost:8000/v1  # optional custom endpoint
    -    # key_env: MY_CUSTOM_KEY              # optional env var name for that endpoint's API key
    -```
    -
    -How it works:
    -- If a turn is short, single-line, and does not look code/tool/debug heavy, Hermes may route it to `cheap_model`
    -- If the turn looks complex, Hermes stays on your primary model/provider
    -- If the cheap route cannot be resolved cleanly, Hermes falls back to the primary model automatically
    -
    -This is intentionally conservative. It is meant for quick, low-stakes turns like:
    -- short factual questions
    -- quick rewrites
    -- lightweight summaries
    -
    -It will avoid routing prompts that look like:
    -- coding/debugging work
    -- tool-heavy requests
    -- long or multi-line analysis asks
    -
    -Use this when you want lower latency or cost without fully changing your default model.
    -
     ---
     
     ## See Also
    ```
- **#17590: fix(cli): resolve runtime credentials for current model**. closed without merge · fix tier `suggested`
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `#17596`, `#17574`
- **#17596: fix(cli): preserve model-specific api_mode across credential refresh**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `fd403854b`, `#17574`, `#17590`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#8678: fix(routing): add CJK keyword matching and preserve cheap_model api_mode**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `#12732`

## Fix status

- Merged to main in PR #12732 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-04-20`. This confirmation is old and may be out of date (a revert/reopen would not have invalidated it here); re-verify.
- Subsystem: `agent`
