{"repo":"NousResearch/hermes-agent","public_id":"879","display_label":"Auxiliary model fallback routing and token cap issues","summary":"Multiple related bugs and fixes around auxiliary model routing: fallback chains ignoring user configuration, max_tokens mismatches causing errors or unexpected costs, and hardcoded defaults overriding explicit provider/model selections.","subsystem":"agent","counts":{"members":42,"open_issues":3,"open_issues_distinct":3,"open_prs":3,"closed_issues":12,"merged_prs":3,"attempt_prs":24},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":2464},"resolution":{"state":"partially_resolved","by":"pr-2464,pr-7647"},"freshness":{"state":"verified","last_verified_at":"2026-08-31T20:00:26+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.042137049941927976},"fix_edges":[{"issue_number":2453,"pr_number":2464,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":5161,"pr_number":7647,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":6800,"pr_number":7647,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":7264,"pr_number":7647,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":7512,"pr_number":7647,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":7559,"pr_number":7647,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":41035,"pr_number":99725,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":879,"title":"[Feature]: Local model routing for auxiliary tasks (Ollama/custom endpoint support)","url":null,"state":"closed"},{"kind":"issue","number":2453,"title":"[Bug]: Agent uses gemini 3 flash preview from openrouter for ( compression / auxiliary _client and web_tools)","url":null,"state":"closed"},{"kind":"issue","number":4171,"title":"[Bug] Provider auto-detection ignores config.yaml — stale API keys override explicit custom endpoint","url":null,"state":"closed"},{"kind":"issue","number":7559,"title":"[Bug] Auxiliary model silently falls back to Gemini Flash on OpenRouter even when user configured local-only","url":null,"state":"closed"},{"kind":"issue","number":10314,"title":"[Bug]: Context compression falls back to OpenRouter default model instead of main provider when summary_provider=auto","url":null,"state":"closed"},{"kind":"issue","number":21352,"title":"[Bug]: provider: ollama not recognized by auxiliary_client.py — silently falls back to OpenRouter","url":null,"state":"closed"},{"kind":"issue","number":24029,"title":"Auxiliary tasks silently fall back to paid OpenRouter models, bypassing user's free-only configuration","url":null,"state":"closed"},{"kind":"issue","number":24039,"title":"Auxiliary fallback chain should reuse fallback_providers, not maintain a separate hardcoded list","url":null,"state":"closed"},{"kind":"issue","number":36759,"title":"Auxiliary fallback ignores session-level model switch and routes to paid model via unselected credential pool","url":null,"state":"closed"},{"kind":"issue","number":41035,"title":"aux: _build_call_kwargs strips max_tokens for OpenRouter — free tier gets HTTP 402, falls back to text-only model","url":null,"state":"closed"},{"kind":"issue","number":44894,"title":"When openrouter key hermes uses paid Auxiliary default models for silly things like session title generation","url":null,"state":"closed"},{"kind":"issue","number":50132,"title":"[Bug]: _build_call_kwargs silently drops max_tokens for OpenAI-compatible endpoints, causing runaway compression summaries on local backends","url":null,"state":"open"},{"kind":"issue","number":50954,"title":"Auxiliary fallback: make the hardcoded last-resort default genuinely free + add model-selection audit trail (follow-up to #36759)","url":null,"state":"open"},{"kind":"issue","number":53677,"title":"Auxiliary vision fails with LiteLLM proxy + Bedrock: max_tokens omitted causes HTTP 400","url":null,"state":"open"},{"kind":"issue","number":56901,"title":"Auxiliary title generation falls back to OpenRouter with inflated max_tokens (65536) when user's main provider is a custom endpoint","url":null,"state":"closed"},{"kind":"pr","number":2457,"title":"fix(compression): remove hardcoded gemini-3-flash-preview as default summary model","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#2464","number":2464}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":2464,"title":"fix(compression): remove hardcoded gemini-3-flash-preview as default summary model","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cli.py","hermes_cli/config.py"],"fix_hunks":[{"file":"cli.py","hunks":["@@ -180,7 +180,7 @@ def load_cli_config() -> Dict[str, Any]:\n         \"compression\": {\n             \"enabled\": True,      # Auto-compress when approaching context limit\n             \"threshold\": 0.50,    # Compress at 50% of model's context limit\n-            \"summary_model\": \"google/gemini-3-flash-preview\",  # Fast/cheap model for summaries\n+            \"summary_model\": \"\",  # Model for summaries (empty = use main model)\n         },\n         \"smart_model_routing\": {\n             \"enabled\": False,"]},{"file":"hermes_cli/config.py","hunks":["@@ -159,7 +159,7 @@ def ensure_hermes_home():\n     \"compression\": {\n         \"enabled\": True,\n         \"threshold\": 0.50,\n-        \"summary_model\": \"google/gemini-3-flash-preview\",\n+        \"summary_model\": \"\",  # empty = use main configured model\n         \"summary_provider\": \"auto\",\n         \"summary_base_url\": None,\n     },","@@ -1659,7 +1659,8 @@ def show_config():\n     print(f\"  Enabled:      {'yes' if enabled else 'no'}\")\n     if enabled:\n         print(f\"  Threshold:    {compression.get('threshold', 0.50) * 100:.0f}%\")\n-        print(f\"  Model:        {compression.get('summary_model', 'google/gemini-3-flash-preview')}\")\n+        _sm = compression.get('summary_model', '') or '(main model)'\n+        print(f\"  Model:        {_sm}\")\n         comp_provider = compression.get('summary_provider', 'auto')\n         if comp_provider != 'auto':\n             print(f\"  Provider:     {comp_provider}\")\n"]}]},{"kind":"pr","number":7594,"title":"fix(auxiliary): harden fallback behavior for non-OpenRouter users","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#7647","number":7647}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":7647,"title":"fix(auxiliary): consolidate auxiliary client UX hardening (#7605)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/auxiliary_client.py","hermes_cli/main.py","tests/agent/test_auxiliary_client.py","tests/hermes_cli/test_clear_stale_base_url.py"],"fix_hunks":[{"file":"agent/auxiliary_client.py","hunks":["@@ -59,6 +59,9 @@\n \n logger = logging.getLogger(__name__)\n \n+# Module-level flag: only warn once per process about stale OPENAI_BASE_URL.\n+_stale_base_url_warned = False\n+\n _PROVIDER_ALIASES = {\n     \"google\": \"gemini\",\n     \"google-gemini\": \"gemini\",","@@ -707,7 +710,9 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:\n             base_url = _to_openai_base_url(\n                 _pool_runtime_base_url(entry, pconfig.inference_base_url) or pconfig.inference_base_url\n             )\n-            model = _API_KEY_PROVIDER_AUX_MODELS.get(provider_id, \"default\")\n+            model = _API_KEY_PROVIDER_AUX_MODELS.get(provider_id)\n+            if model is None:\n+                continue  # skip provider if we don't know a valid aux model\n             logger.debug(\"Auxiliary text client: %s (%s) via pool\", pconfig.name, model)\n             extra = {}\n             if \"api.kimi.com\" in base_url.lower():","@@ -726,7 +731,9 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:\n         base_url = _to_openai_base_url(\n             str(creds.get(\"base_url\", \"\")).strip().rstrip(\"/\") or pconfig.inference_base_url\n         )\n-        model = _API_KEY_PROVIDER_AUX_MODELS.get(provider_id, \"default\")\n+        model = _API_KEY_PROVIDER_AUX_MODELS.get(provider_id)\n+        if model is None:\n+            continue  # skip provider if we don't know a valid aux model\n         logger.debug(\"Auxiliary text client: %s (%s)\", pconfig.name, model)\n         extra = {}\n         if \"api.kimi.com\" in base_url.lower():","@@ -1075,11 +1082,12 @@ def _is_connection_error(exc: Exception) -> bool:\n def _try_payment_fallback(\n     failed_provider: str,\n     task: str = None,\n+    reason: str = \"payment error\",\n ) -> Tuple[Optional[Any], Optional[str], str]:\n-    \"\"\"Try alternative providers after a payment/credit error.\n+    \"\"\"Try alternative providers after a payment/credit or connection error.\n \n     Iterates the standard auto-detection chain, skipping the provider that\n-    returned a payment error.\n+    failed.\n \n     Returns:\n         (client, model, provider_label) or (None, None, \"\") if no fallback.","@@ -1105,15 +1113,15 @@ def _try_payment_fallback(\n         client, model = try_fn()\n         if client is not None:\n             logger.info(\n-                \"Auxiliary %s: payment error on %s — falling back to %s (%s)\",\n-                task or \"call\", failed_provider, label, model or \"default\",\n+                \"Auxiliary %s: %s on %s — falling back to %s (%s)\",\n+                task or \"call\", reason, failed_provider, label, model or \"default\",\n             )\n             return client, model, label\n         tried.append(label)\n \n     logger.warning(\n-        \"Auxiliary %s: payment error on %s and no fallback available (tried: %s)\",\n-        task or \"call\", failed_provider, \", \".join(tried),\n+        \"Auxiliary %s: %s on %s and no fallback available (tried: %s)\",\n+        task or \"call\", reason, failed_provider, \", \".join(tried),\n     )\n     return None, None, \"\"\n ","@@ -1128,9 +1136,28 @@ def _resolve_auto() -> Tuple[Optional[OpenAI], Optional[str]]:\n          provider they already have credentials for — no OpenRouter key needed.\n       2. OpenRouter → Nous → custom → Codex → API-key providers (original chain).\n     \"\"\"\n-    global auxiliary_is_nous\n+    global auxiliary_is_nous, _stale_base_url_warned\n     auxiliary_is_nous = False  # Reset — _try_nous() will set True if it wins\n \n+    # ── Warn once if OPENAI_BASE_URL is set but config.yaml uses a named\n+    #    provider (not 'custom').  This catches the common \"env poisoning\"\n+    #    scenario where a user switches providers via `hermes model` but the\n+    #    old OPENAI_BASE_URL lingers in ~/.hermes/.env. ──\n+    if not _stale_base_url_warned:\n+        _env_base = os.getenv(\"OPENAI_BASE_URL\", \"\").strip()\n+        _cfg_provider = _read_main_provider()\n+        if (_env_base and _cfg_provider\n+                and _cfg_provider != \"custom\"\n+                and not _cfg_provider.startswith(\"custom:\")):\n+            logger.warning(\n+                \"OPENAI_BASE_URL is set (%s) but model.provider is '%s'. \"\n+                \"Auxiliary clients may route to the wrong endpoint. \"\n+                \"Run: hermes model to reconfigure, or remove \"\n+                \"OPENAI_BASE_URL from ~/.hermes/.env\",\n+                _env_base, _cfg_provider,\n+            )\n+            _stale_base_url_warned = True\n+\n     # ── Step 1: non-aggregator main provider → use main model directly ──\n     main_provider = _read_main_provider()\n     main_model = _read_main_model()","@@ -1217,6 +1244,7 @@ def resolve_provider_client(\n     raw_codex: bool = False,\n     explicit_base_url: str = None,\n     explicit_api_key: str = None,\n+    api_mode: str = None,\n ) -> Tuple[Optional[Any], Optional[str]]:\n     \"\"\"Central router: given a provider name and optional model, return a\n     configured client with the correct auth, base URL, and API format.","@@ -1240,6 +1268,10 @@ def resolve_provider_client(\n             the main agent loop).\n         explicit_base_url: Optional direct OpenAI-compatible endpoint.\n         explicit_api_key: Optional API key paired with explicit_base_url.\n+        api_mode: API mode override.  One of \"chat_completions\",\n+            \"codex_responses\", or None (auto-detect).  When set to\n+            \"codex_responses\", the client is wrapped in\n+            CodexAuxiliaryClient to route through the Responses API.\n \n     Returns:\n         (client, resolved_model) or (None, None) if auth is unavailable.","@@ -1247,6 +1279,40 @@ def resolve_provider_client(\n     # Normalise aliases\n     provider = _normalize_aux_provider(provider)\n \n+    def _needs_codex_wrap(client_obj, base_url_str: str, model_str: str) -> bool:\n+        \"\"\"Decide if a plain OpenAI client should be wrapped for Responses API.\n+\n+        Returns True when api_mode is explicitly \"codex_responses\", or when\n+        auto-detection (api.openai.com + codex-family model) suggests it.\n+        Already-wrapped clients (CodexAuxiliaryClient) are skipped.\n+        \"\"\"\n+        if isinstance(client_obj, CodexAuxiliaryClient):\n+            return False\n+        if raw_codex:\n+            return False\n+        if api_mode == \"codex_responses\":\n+            return True\n+        # Auto-detect: api.openai.com + codex model name pattern\n+        if api_mode and api_mode != \"codex_responses\":\n+            return False  # explicit non-codex mode\n+        normalized_base = (base_url_str or \"\").strip().lower()\n+        if \"api.openai.com\" in normalized_base and \"openrouter\" not in normalized_base:\n+            model_lower = (model_str or \"\").lower()\n+            if \"codex\" in model_lower:\n+                return True\n+        return False\n+\n+    def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = \"\"):\n+        \"\"\"Wrap a plain OpenAI client in CodexAuxiliaryClient if Responses API is needed.\"\"\"\n+        if _needs_codex_wrap(client_obj, base_url_str, final_model_str):\n+            logger.debug(\n+                \"resolve_provider_client: wrapping client in CodexAuxiliaryClient \"\n+                \"(api_mode=%s, model=%s, base_url=%s)\",\n+                api_mode or \"auto-detected\", final_model_str,\n+                base_url_str[:60] if base_url_str else \"\")\n+            return CodexAuxiliaryClient(client_obj, final_model_str)\n+        return client_obj\n+\n     # ── Auto: try all providers in priority order ────────────────────\n     if provider == \"auto\":\n         client, resolved = _resolve_auto()","@@ -1336,6 +1402,7 @@ def resolve_provider_client(\n                 from hermes_cli.models import copilot_default_headers\n                 extra[\"default_headers\"] = copilot_default_headers()\n             client = OpenAI(api_key=[redacted:secret-assignment] base_url=custom_base, **extra)\n+            client = _wrap_if_needed(client, final_model, custom_base)\n             return (_to_async_client(client, final_model) if async_mode\n                     else (client, final_model))\n         # Try custom first, then codex, then API-key providers","@@ -1344,6 +1411,8 @@ def resolve_provider_client(\n             client, default = try_fn()\n             if client is not None:\n                 final_model = _normalize_resolved_model(model or default, provider)\n+                _cbase = str(getattr(client, \"base_url\", \"\") or \"\")\n+                client = _wrap_if_needed(client, final_model, _cbase)\n                 return (_to_async_client(client, final_model) if async_mode\n                         else (client, final_model))\n         logger.warning(\"resolve_provider_client: custom/main requested \"","@@ -1363,6 +1432,7 @@ def resolve_provider_client(\n                     provider,\n                 )\n                 client = OpenAI(api_key=[redacted:secret-assignment] base_url=custom_base)\n+                client = _wrap_if_needed(client, final_model, custom_base)\n                 logger.debug(\n                     \"resolve_provider_client: named custom provider %r (%s)\",\n                     provider, final_model)","@@ -1442,6 +1512,11 @@ def resolve_provider_client(\n             except ImportError:\n                 pass\n \n+        # Honor api_mode for any API-key provider (e.g. direct OpenAI with\n+        # codex-family models).  The copilot-specific wrapping above handles\n+        # copilot; this covers the general case (#6800).\n+        client = _wrap_if_needed(client, final_model, base_url)\n+\n         logger.debug(\"resolve_provider_client: %s (%s)\", provider, final_model)\n         return (_to_async_client(client, final_model) if async_mode\n                 else (client, final_model))","@@ -1474,12 +1549,13 @@ def get_text_auxiliary_client(task: str = \"\") -> Tuple[Optional[OpenAI], Optiona\n     Callers may override the returned model with a per-task env var\n     (e.g. CONTEXT_COMPRESSION_MODEL, AUXILIARY_WEB_EXTRACT_MODEL).\n     \"\"\"\n-    provider, model, base_url, api_key = [redacted:secret-assignment] or None)\n+    provider, model, base_url, api_key, api_mode = _resolve_task_provider_model(task or None)\n     return resolve_provider_client(\n         provider,\n         model=model,\n         explicit_base_url=base_url,\n         explicit_api_key=[redacted:secret-assignment]\n+        api_mode=api_mode,\n     )\n \n ","@@ -1490,13 +1566,14 @@ def get_async_text_auxiliary_client(task: str = \"\"):\n     (AsyncCodexAuxiliaryClient, model) which wraps the Responses API.\n     Returns (None, None) when no provider is available.\n     \"\"\"\n-    provider, model, base_url, api_key = [redacted:secret-assignment] or None)\n+    provider, model, base_url, api_key, api_mode = _resolve_task_provider_model(task or None)\n     return resolve_provider_client(\n         provider,\n         model=model,\n         async_mode=True,\n         explicit_base_url=base_url,\n         explicit_api_key=[redacted:secret-assignment]\n+        api_mode=api_mode,\n     )\n \n ","@@ -1569,7 +1646,7 @@ def resolve_vision_provider_client(\n     backends, so users can intentionally force experimental providers. Auto mode\n     stays conservative and only tries vision backends known to work today.\n     \"\"\"\n-    requested, resolved_model, resolved_base_url, resolved_api_key = [redacted:secret-assignment]\n+    requested, resolved_model, resolved_base_url, resolved_api_key, resolved_api_mode = _resolve_task_provider_model(\n         \"vision\", provider, model, base_url, api_key\n     )\n     requested = _normalize_vision_provider(requested)","@@ -1785,12 +1862,30 @@ def cleanup_stale_async_clients() -> None:\n             del _client_cache[key]\n \n \n+def _is_openrouter_client(client: Any) -> bool:\n+    for obj in (client, getattr(client, \"_client\", None), getattr(client, \"client\", None)):\n+        if obj and \"openrouter\" in str(getattr(obj, \"base_url\", \"\") or \"\").lower():\n+            return True\n+    return False\n+\n+\n+def _compat_model(client: Any, model: Optional[str], cached_default: Optional[str]) -> Optional[str]:\n+    \"\"\"Drop OpenRouter-format model slugs (with '/') for non-OpenRouter clients.\n+\n+    Mirrors the guard in resolve_provider_client() which is skipped on cache hits.\n+    \"\"\"\n+    if model and \"/\" in model and not _is_openrouter_client(client):\n+        return cached_default\n+    return model or cached_default\n+\n+\n def _get_cached_client(\n     provider: str,\n     model: str = None,\n     async_mode: bool = False,\n     base_url: str = None,\n     api_key: str = None,\n+    api_mode: str = None,\n ) -> Tuple[Optional[Any], Optional[str]]:\n     \"\"\"Get or create a cached client for the given provider.\n ","@@ -1814,7 +1909,7 @@ def _get_cached_client(\n             loop_id = id(current_loop)\n         except RuntimeError:\n             pass\n-    cache_key = (provider, async_mode, base_url or \"\", api_key or \"\", loop_id)\n+    cache_key = (provider, async_mode, base_url or \"\", api_key or \"\", api_mode or \"\", loop_id)\n     with _client_cache_lock:\n         if cache_key in _client_cache:\n             cached_client, cached_default, cached_loop = _client_cache[cache_key]","@@ -1826,9 +1921,11 @@ def _get_cached_client(\n                     _force_close_async_httpx(cached_client)\n                     del _client_cache[cache_key]\n                 else:\n-                    return cached_client, model or cached_default\n+                    effective = _compat_model(cached_client, model, cached_default)\n+                    return cached_client, effective\n             else:\n-                return cached_client, model or cached_default\n+                effective = _compat_model(cached_client, model, cached_default)\n+                return cached_client, effective\n     # Build outside the lock\n     client, default_model = resolve_provider_client(\n         provider,","@@ -1836,6 +1933,7 @@ def _get_cached_client(\n         async_mode,\n         explicit_base_url=base_url,\n         explicit_api_key=[redacted:secret-assignment]\n+        api_mode=api_mode,\n     )\n     if client is not None:\n         # For async clients, remember which loop they were created on so we","@@ -1855,7 +1953,7 @@ def _resolve_task_provider_model(\n     model: str = None,\n     base_url: str = None,\n     api_key: str = None,\n-) -> Tuple[str, Optional[str], Optional[str], Optional[str]]:\n+) -> Tuple[str, Optional[str], Optional[str], Optional[str], Optional[str]]:\n     \"\"\"Determine provider + model for a call.\n \n     Priority:","@@ -1864,15 +1962,17 @@ def _resolve_task_provider_model(\n       3. Config file (auxiliary.{task}.* or compression.*)\n       4. \"auto\" (full auto-detection chain)\n \n-    Returns (provider, model, base_url, api_key) where model may be None\n-    (use provider default). When base_url is set, provider is forced to\n-    \"custom\" and the task uses that direct endpoint.\n+    Returns (provider, model, base_url, api_key, api_mode) where model may\n+    be None (use provider default). When base_url is set, provider is forced\n+    to \"custom\" and the task uses that direct endpoint. api_mode is one of\n+    \"chat_completions\", \"codex_responses\", or None (auto-detect).\n     \"\"\"\n     config = {}\n     cfg_provider = None\n     cfg_model = None\n     cfg_base_url = None\n     cfg_api_key = None\n+    cfg_api_mode = None\n \n     if task:\n         try:","@@ -1889,6 +1989,7 @@ def _resolve_task_provider_model(\n         cfg_model = str(task_config.get(\"model\", \"\")).strip() or None\n         cfg_base_url = str(task_config.get(\"base_url\", \"\")).strip() or None\n         cfg_api_key = [redacted:secret-assignment]\"api_key\", \"\")).strip() or None\n+        cfg_api_mode = str(task_config.get(\"api_mode\", \"\")).strip() or None\n \n         # Backwards compat: compression section has its own keys.\n         # The auxiliary.compression defaults to provider=\"auto\", so treat","@@ -1902,30 +2003,32 @@ def _resolve_task_provider_model(\n                 cfg_base_url = cfg_base_url or _sbu.strip() or None\n \n     env_model = _get_auxiliary_env_override(task, \"MODEL\") if task else None\n+    env_api_mode = _get_auxiliary_env_override(task, \"API_MODE\") if task else None\n     resolved_model = model or env_model or cfg_model\n+    resolved_api_mode = env_api_mode or cfg_api_mode\n \n     if base_url:\n-        return \"custom\", resolved_model, base_url, api_key\n+        return \"custom\", resolved_model, base_url, api_key, resolved_api_mode\n     if provider:\n-        return provider, resolved_model, base_url, api_key\n+        return provider, resolved_model, base_url, api_key, resolved_api_mode\n \n     if task:\n         env_base_url = _get_auxiliary_env_override(task, \"BASE_URL\")\n         env_api_key = [redacted:secret-assignment] \"API_KEY\")\n         if env_base_url:\n-            return \"custom\", resolved_model, env_base_url, env_api_key or cfg_api_key\n+            return \"custom\", resolved_model, env_base_url, env_api_key or cfg_api_key, resolved_api_mode\n \n         env_provider = _get_auxiliary_provider(task)\n         if env_provider != \"auto\":\n-            return env_provider, resolved_model, None, None\n+            return env_provider, resolved_model, None, None, resolved_api_mode\n \n         if cfg_base_url:\n-            return \"custom\", resolved_model, cfg_base_url, cfg_api_key\n+            return \"custom\", resolved_model, cfg_base_url, cfg_api_key, resolved_api_mode\n         if cfg_provider and cfg_provider != \"auto\":\n-            return cfg_provider, resolved_model, None, None\n-        return \"auto\", resolved_model, None, None\n+            return cfg_provider, resolved_model, None, None, resolved_api_mode\n+        return \"auto\", resolved_model, None, None, resolved_api_mode\n \n-    return \"auto\", resolved_model, None, None\n+    return \"auto\", resolved_model, None, None, resolved_api_mode\n \n \n _DEFAULT_AUX_TIMEOUT = 30.0","@@ -1997,6 +2100,37 @@ def _build_call_kwargs(\n     return kwargs\n \n \n+def _validate_llm_response(response: Any, task: str = None) -> Any:\n+    \"\"\"Validate that an LLM response has the expected .choices[0].message shape.\n+\n+    Fails fast with a clear error instead of letting malformed payloads\n+    propagate to downstream consumers where they crash with misleading\n+    AttributeError (e.g. \"'str' object has no attribute 'choices'\").\n+\n+    See #7264.\n+    \"\"\"\n+    if response is None:\n+        raise RuntimeError(\n+            f\"Auxiliary {task or 'call'}: LLM returned None response\"\n+        )\n+    # Allow SimpleNamespace responses from adapters (CodexAuxiliaryClient,\n+    # AnthropicAuxiliaryClient) — they have .choices[0].message.\n+    try:\n+        choices = response.choices\n+        if not choices or not hasattr(choices[0], \"message\"):\n+            raise AttributeError(\"missing choices[0].message\")\n+    except (AttributeError, TypeError, IndexError) as exc:\n+        response_type = type(response).__name__\n+        response_preview = str(response)[:120]\n+        raise RuntimeError(\n+            f\"Auxiliary {task or 'call'}: LLM returned invalid response \"\n+            f\"(type={response_type}): {response_preview!r}. \"\n+            f\"Expected object with .choices[0].message — check provider \"\n+            f\"adapter or custom endpoint compatibility.\"\n+        ) from exc\n+    return response\n+\n+\n def call_llm(\n     task: str = None,\n     *,","@@ -2035,7 +2169,7 @@ def call_llm(\n     Raises:\n         RuntimeError: If no provider is configured.\n     \"\"\"\n-    resolved_provider, resolved_model, resolved_base_url, resolved_api_key = [redacted:secret-assignment]\n+    resolved_provider, resolved_model, resolved_base_url, resolved_api_key, resolved_api_mode = _resolve_task_provider_model(\n         task, provider, model, base_url, api_key)\n \n     if task == \"vision\":","@@ -2068,6 +2202,7 @@ def call_llm(\n             resolved_model,\n             base_url=resolved_base_url,\n             api_key=[redacted:secret-assignment]\n+            api_mode=resolved_api_mode,\n         )\n         if client is None:\n             # When the user explicitly chose a non-OpenRouter provider but no","@@ -2111,18 +2246,20 @@ def call_llm(\n \n     # Handle max_tokens vs max_completion_tokens retry, then payment fallback.\n     try:\n-        return client.chat.completions.create(**kwargs)\n+        return _validate_llm_response(\n+            client.chat.completions.create(**kwargs), task)\n     except Exception as first_err:\n         err_str = str(first_err)\n         if \"max_tokens\" in err_str or \"unsupported_parameter\" in err_str:\n             kwargs.pop(\"max_tokens\", None)\n             kwargs[\"max_completion_tokens\"] = max_tokens\n             try:\n-                return client.chat.completions.create(**kwargs)\n+                return _validate_llm_response(\n+                    client.chat.completions.create(**kwargs), task)\n             except Exception as retry_err:\n-                # If the max_tokens retry also hits a payment error,\n-                # fall through to the payment fallback below.\n-                if not _is_payment_error(retry_err):\n+                # If the max_tokens retry also hits a payment or connection\n+                # error, fall through to the fallback chain below.\n+                if not (_is_payment_error(retry_err) or _is_connection_error(retry_err)):\n                     raise\n                 first_err = retry_err\n ","@@ -2139,19 +2276,24 @@ def call_llm(\n         # and providers the user never configured that got picked up by\n         # the auto-detection chain.\n         should_fallback = _is_payment_error(first_err) or _is_connection_error(first_err)\n-        if should_fallback:\n+        # Only try alternative providers when the user didn't explicitly\n+        # configure this task's provider.  Explicit provider = hard constraint;\n+        # auto (the default) = best-effort fallback chain.  (#7559)\n+        is_auto = resolved_provider in (\"auto\", \"\", None)\n+        if should_fallback and is_auto:\n             reason = \"payment error\" if _is_payment_error(first_err) else \"connection error\"\n             logger.info(\"Auxiliary %s: %s on %s (%s), trying fallback\",\n                         task or \"call\", reason, resolved_provider, first_err)\n             fb_client, fb_model, fb_label = _try_payment_fallback(\n-                resolved_provider, task)\n+                resolved_provider, task, reason=reason)\n             if fb_client is not None:\n                 fb_kwargs = _build_call_kwargs(\n                     fb_label, fb_model, messages,\n                     temperature=temperature, max_tokens=[redacted:secret-assignment]\n                     tools=tools, timeout=effective_timeout,\n                     extra_body=extra_body)\n-                return fb_client.chat.completions.create(**fb_kwargs)\n+                return _validate_llm_response(\n+                    fb_client.chat.completions.create(**fb_kwargs), task)\n         raise\n \n ","@@ -2229,7 +2371,7 @@ async def async_call_llm(\n \n     Same as call_llm() but async. See call_llm() for full documentation.\n     \"\"\"\n-    resolved_provider, resolved_model, resolved_base_url, resolved_api_key = [redacted:secret-assignment]\n+    resolved_provider, resolved_model, resolved_base_url, resolved_api_key, resolved_api_mode = _resolve_task_provider_model(\n         task, provider, model, base_url, api_key)\n \n     if task == \"vision\":","@@ -2263,6 +2405,7 @@ async def async_call_llm(\n             async_mode=True,\n             base_url=resolved_base_url,\n             api_key=[redacted:secret-assignment]\n+            api_mode=resolved_api_mode,\n         )\n         if client is None:\n             _explicit = (resolved_provider or \"\").strip().lower()","@@ -2273,11 +2416,9 @@ async def async_call_llm(\n                     f\"variable, or switch to a different provider with `hermes model`.\"\n                 )\n             if not resolved_base_url:\n-                logger.warning(\"Provider %s unavailable, falling back to openrouter\",\n-                               resolved_provider)\n-                client, final_model = _get_cached_client(\n-                    \"openrouter\", resolved_model or _OPENROUTER_MODEL,\n-                    async_mode=True)\n+                logger.info(\"Auxiliary %s: provider %s unavailable, trying auto-detection chain\",\n+                            task or \"call\", resolved_provider)\n+                client, final_model = _get_cached_client(\"auto\", async_mode=True)\n         if client is None:\n             raise RuntimeError(\n                 f\"No LLM provider configured for task={task} provider={resolved_provider}. \"","@@ -2292,11 +2433,42 @@ async def async_call_llm(\n         base_url=resolved_base_url)\n \n     try:\n-        return await client.chat.completions.create(**kwargs)\n+        return _validate_llm_response(\n+            await client.chat.completions.create(**kwargs), task)\n     except Exception as first_err:\n         err_str = str(first_err)\n         if \"max_tokens\" in err_str or \"unsupported_parameter\" in err_str:\n             kwargs.pop(\"max_tokens\", None)\n             kwargs[\"max_completion_tokens\"] = max_tokens\n-            return await client.chat.completions.create(**kwargs)\n+            try:\n+                return _validate_llm_response(\n+                    await client.chat.completions.create(**kwargs), task)\n+            except Exception as retry_err:\n+                # If the max_tokens retry also hits a payment or connection\n+                # error, fall through to the fallback chain below.\n+                if not (_is_payment_error(retry_err) or _is_connection_error(retry_err)):\n+                    raise\n+                first_err = retry_err\n+\n+        # ── Payment / connection fallback (mirrors sync call_llm) ─────\n+        should_fallback = _is_payment_error(first_err) or _is_connection_error(first_err)\n+        is_auto = resolved_provider in (\"auto\", \"\", None)\n+        if should_fallback and is_auto:\n+            reason = \"payment error\" if _is_payment_error(first_err) else \"connection error\"\n+            logger.info(\"Auxiliary %s (async): %s on %s (%s), trying fallback\",\n+                        task or \"call\", reason, resolved_provider, first_err)\n+            fb_client, fb_model, fb_label = _try_payment_fallback(\n+                resolved_provider, task, reason=reason)\n+            if fb_client is not None:\n+                fb_kwargs = _build_call_kwargs(\n+                    fb_label, fb_model, messages,\n+                    temperature=temperature, max_tokens=[redacted:secret-assignment]\n+                    tools=tools, timeout=effective_timeout,\n+                    extra_body=extra_body)\n+                # Convert sync fallback client to async\n+                async_fb, async_fb_model = _to_async_client(fb_client, fb_model or \"\")\n+                if async_fb_model and async_fb_model != fb_kwargs.get(\"model\"):\n+                    fb_kwargs[\"model\"] = async_fb_model\n+                return _validate_llm_response(\n+                    await async_fb.chat.completions.create(**fb_kwargs), task)\n         raise"]},{"file":"hermes_cli/main.py","hunks":["@@ -1080,6 +1080,42 @@ def _named_custom_provider_map(cfg) -> dict[str, dict[str, str]]:\n     elif selected_provider in (\"gemini\", \"zai\", \"minimax\", \"minimax-cn\", \"kilocode\", \"opencode-zen\", \"opencode-go\", \"ai-gateway\", \"alibaba\", \"huggingface\"):\n         _model_flow_api_key_provider(config, selected_provider, current_model)\n \n+    # ── Post-switch cleanup: clear stale OPENAI_BASE_URL ──────────────\n+    # When the user switches to a named provider (anything except \"custom\"),\n+    # a leftover OPENAI_BASE_URL in ~/.hermes/.env can poison auxiliary\n+    # clients that use provider:auto. Clear it proactively.  (#5161)\n+    if selected_provider not in (\"custom\", \"cancel\", \"remove-custom\") \\\n+            and not selected_provider.startswith(\"custom:\"):\n+        _clear_stale_openai_base_url()\n+\n+\n+def _clear_stale_openai_base_url():\n+    \"\"\"Remove OPENAI_BASE_URL from ~/.hermes/.env if the active provider is not 'custom'.\n+\n+    After a provider switch, a leftover OPENAI_BASE_URL causes auxiliary\n+    clients (compression, vision, delegation) with provider:auto to route\n+    requests to the old custom endpoint instead of the newly selected\n+    provider.  See issue #5161.\n+    \"\"\"\n+    from hermes_cli.config import get_env_value, save_env_value, load_config\n+\n+    cfg = load_config()\n+    model_cfg = cfg.get(\"model\", {})\n+    if isinstance(model_cfg, dict):\n+        provider = (model_cfg.get(\"provider\") or \"\").strip().lower()\n+    else:\n+        provider = \"\"\n+\n+    if provider == \"custom\" or not provider:\n+        return  # custom provider legitimately uses OPENAI_BASE_URL\n+\n+    stale_url = get_env_value(\"OPENAI_BASE_URL\")\n+    if stale_url:\n+        save_env_value(\"OPENAI_BASE_URL\", \"\")\n+        print(f\"Cleared stale OPENAI_BASE_URL from .env (was: {stale_url[:40]}...)\"\n+              if len(stale_url) > 40\n+              else f\"Cleared stale OPENAI_BASE_URL from .env (was: {stale_url})\")\n+\n \n def _prompt_provider_choice(choices, *, default=0):\n     \"\"\"Show provider selection menu with curses arrow-key navigation."]},{"file":"tests/agent/test_auxiliary_client.py","hunks":["@@ -1,9 +1,10 @@\n \"\"\"Tests for agent.auxiliary_client resolution chain, provider overrides, and model overrides.\"\"\"\n \n import json\n+import logging\n import os\n from pathlib import Path\n-from unittest.mock import patch, MagicMock\n+from unittest.mock import patch, MagicMock, AsyncMock\n \n import pytest\n ","@@ -14,6 +15,7 @@\n     resolve_provider_client,\n     auxiliary_max_tokens_param,\n     call_llm,\n+    async_call_llm,\n     _read_codex_access_token,\n     _get_auxiliary_provider,\n     _get_provider_chain,","@@ -1122,8 +1124,8 @@ def _make_402_error(self, msg=\"Payment Required: insufficient credits\"):\n         exc.status_code = 402\n         return exc\n \n-    def test_402_triggers_fallback(self, monkeypatch):\n-        \"\"\"When the primary provider returns 402, call_llm tries the next one.\"\"\"\n+    def test_402_triggers_fallback_when_auto(self, monkeypatch):\n+        \"\"\"When provider is auto and returns 402, call_llm tries the next one.\"\"\"\n         monkeypatch.setenv(\"OPENROUTER_API_KEY\", \"or-key\")\n \n         primary_client = MagicMock()","@@ -1136,7 +1138,7 @@ def test_402_triggers_fallback(self, monkeypatch):\n         with patch(\"agent.auxiliary_client._get_cached_client\",\n                     return_value=(primary_client, \"google/gemini-3-flash-preview\")), \\\n              patch(\"agent.auxiliary_client._resolve_task_provider_model\",\n-                    return_value=(\"openrouter\", \"google/gemini-3-flash-preview\", None, None)), \\\n+                    return_value=(\"auto\", \"google/gemini-3-flash-preview\", None, None, None)), \\\n              patch(\"agent.auxiliary_client._try_payment_fallback\",\n                     return_value=(fallback_client, \"gpt-5.2-codex\", \"openai-codex\")) as mock_fb:\n             result = call_llm(","@@ -1145,13 +1147,62 @@ def test_402_triggers_fallback(self, monkeypatch):\n             )\n \n         assert result is fallback_response\n-        mock_fb.assert_called_once_with(\"openrouter\", \"compression\")\n+        mock_fb.assert_called_once_with(\"auto\", \"compression\", reason=\"payment error\")\n         # Fallback call should use the fallback model\n         fb_kwargs = fallback_client.chat.completions.create.call_args.kwargs\n         assert fb_kwargs[\"model\"] == \"gpt-5.2-codex\"\n \n+    def test_402_no_fallback_when_explicit_provider(self, monkeypatch):\n+        \"\"\"When provider is explicitly configured (not auto), 402 should NOT fallback (#7559).\"\"\"\n+        monkeypatch.setenv(\"OPENROUTER_API_KEY\", \"or-key\")\n+\n+        primary_client = MagicMock()\n+        primary_client.chat.completions.create.side_effect = self._make_402_error()\n+\n+        with patch(\"agent.auxiliary_client._get_cached_client\",\n+                    return_value=(primary_client, \"local-model\")), \\\n+             patch(\"agent.auxiliary_client._resolve_task_provider_model\",\n+                    return_value=(\"custom\", \"local-model\", None, None, None)), \\\n+             patch(\"agent.auxiliary_client._try_payment_fallback\") as mock_fb:\n+            with pytest.raises(Exception, match=\"insufficient credits\"):\n+                call_llm(\n+                    task=\"compression\",\n+                    messages=[{\"role\": \"user\", \"content\": \"hello\"}],\n+                )\n+\n+        # Fallback should NOT be attempted when provider is explicit\n+        mock_fb.assert_not_called()\n+\n+    def test_connection_error_triggers_fallback_when_auto(self, monkeypatch):\n+        \"\"\"Connection errors also trigger fallback when provider is auto.\"\"\"\n+        monkeypatch.setenv(\"OPENROUTER_API_KEY\", \"or-key\")\n+\n+        primary_client = MagicMock()\n+        conn_err = Exception(\"Connection refused\")\n+        conn_err.status_code = None\n+        primary_client.chat.completions.create.side_effect = conn_err\n+\n+        fallback_client = MagicMock()\n+        fallback_response = MagicMock()\n+        fallback_client.chat.completions.create.return_value = fallback_response\n+\n+        with patch(\"agent.auxiliary_client._get_cached_client\",\n+                    return_value=(primary_client, \"model\")), \\\n+             patch(\"agent.auxiliary_client._resolve_task_provider_model\",\n+                    return_value=(\"auto\", \"model\", None, None, None)), \\\n+             patch(\"agent.auxiliary_client._is_connection_error\", return_value=True), \\\n+             patch(\"agent.auxiliary_client._try_payment_fallback\",\n+                    return_value=(fallback_client, \"fb-model\", \"nous\")) as mock_fb:\n+            result = call_llm(\n+                task=\"compression\",\n+                messages=[{\"role\": \"user\", \"content\": \"hello\"}],\n+            )\n+\n+        assert result is fallback_response\n+        mock_fb.assert_called_once_with(\"auto\", \"compression\", reason=\"connection error\")\n+\n     def test_non_payment_error_not_caught(self, monkeypatch):\n-        \"\"\"Non-payment errors (500, connection, etc.) should NOT trigger fallback.\"\"\"\n+        \"\"\"Non-payment/non-connection errors (500) should NOT trigger fallback.\"\"\"\n         monkeypatch.setenv(\"OPENROUTER_API_KEY\", \"or-key\")\n \n         primary_client = MagicMock()","@@ -1162,7 +1213,7 @@ def test_non_payment_error_not_caught(self, monkeypatch):\n         with patch(\"agent.auxiliary_client._get_cached_client\",\n                     return_value=(primary_client, \"google/gemini-3-flash-preview\")), \\\n              patch(\"agent.auxiliary_client._resolve_task_provider_model\",\n-                    return_value=(\"openrouter\", \"google/gemini-3-flash-preview\", None, None)):\n+                    return_value=(\"auto\", \"google/gemini-3-flash-preview\", None, None, None)):\n             with pytest.raises(Exception, match=\"Internal Server Error\"):\n                 call_llm(\n                     task=\"compression\",","@@ -1179,7 +1230,7 @@ def test_402_with_no_fallback_reraises(self, monkeypatch):\n         with patch(\"agent.auxiliary_client._get_cached_client\",\n                     return_value=(primary_client, \"google/gemini-3-flash-preview\")), \\\n              patch(\"agent.auxiliary_client._resolve_task_provider_model\",\n-                    return_value=(\"openrouter\", \"google/gemini-3-flash-preview\", None, None)), \\\n+                    return_value=(\"auto\", \"google/gemini-3-flash-preview\", None, None, None)), \\\n              patch(\"agent.auxiliary_client._try_payment_fallback\",\n                     return_value=(None, None, \"\")):\n             with pytest.raises(Exception, match=\"insufficient credits\"):","@@ -1229,3 +1280,283 @@ def mock_try_anthropic():\n \n     assert \"anthropic\" not in called, \\\n         \"_try_anthropic() should not be called when anthropic is not explicitly configured\"\n+\n+\n+# ---------------------------------------------------------------------------\n+# model=\"default\" elimination (#7512)\n+# ---------------------------------------------------------------------------\n+\n+\n+class TestModelDefaultElimination:\n+    \"\"\"_resolve_api_key_provider must skip providers without known aux models.\"\"\"\n+\n+    def test_unknown_provider_skipped(self, monkeypatch):\n+        \"\"\"Providers not in _API_KEY_PROVIDER_AUX_MODELS are skipped, not sent model='default'.\"\"\"\n+        from agent.auxiliary_client import _API_KEY_PROVIDER_AUX_MODELS\n+\n+        # Verify our known providers have entries\n+        assert \"gemini\" in _API_KEY_PROVIDER_AUX_MODELS\n+        assert \"kimi-coding\" in _API_KEY_PROVIDER_AUX_MODELS\n+\n+        # A random provider_id not in the dict should return None\n+        assert _API_KEY_PROVIDER_AUX_MODELS.get(\"totally-unknown-provider\") is None\n+\n+    def test_known_provider_gets_real_model(self):\n+        \"\"\"Known providers get a real model name, not 'default'.\"\"\"\n+        from agent.auxiliary_client import _API_KEY_PROVIDER_AUX_MODELS\n+\n+        for provider_id, model in _API_KEY_PROVIDER_AUX_MODELS.items():\n+            assert model != \"default\", f\"{provider_id} should not map to 'default'\"\n+            assert isinstance(model, str) and model.strip(), \\\n+                f\"{provider_id} should have a non-empty model string\"\n+\n+\n+# ---------------------------------------------------------------------------\n+# _try_payment_fallback reason parameter (#7512 bug 3)\n+# ---------------------------------------------------------------------------\n+\n+\n+class TestTryPaymentFallbackReason:\n+    \"\"\"_try_payment_fallback uses the reason parameter in log messages.\"\"\"\n+\n+    def test_reason_parameter_passed_through(self, monkeypatch):\n+        \"\"\"The reason= parameter is accepted without error.\"\"\"\n+        from agent.auxiliary_client import _try_payment_fallback\n+\n+        # Mock the provider chain to return nothing\n+        monkeypatch.setattr(\n+            \"agent.auxiliary_client._get_provider_chain\",\n+            lambda: [],\n+        )\n+        monkeypatch.setattr(\n+            \"agent.auxiliary_client._read_main_provider\",\n+            lambda: \"\",\n+        )\n+\n+        client, model, label = _try_payment_fallback(\n+            \"openrouter\", task=\"compression\", reason=\"connection error\"\n+        )\n+        assert client is None\n+        assert label == \"\"\n+\n+\n+# ---------------------------------------------------------------------------\n+# _is_connection_error coverage\n+# ---------------------------------------------------------------------------\n+\n+\n+class TestIsConnectionError:\n+    \"\"\"Tests for _is_connection_error detection.\"\"\"\n+\n+    def test_connection_refused(self):\n+        from agent.auxiliary_client import _is_connection_error\n+        err = Exception(\"Connection refused\")\n+        assert _is_connection_error(err) is True\n+\n+    def test_timeout(self):\n+        from agent.auxiliary_client import _is_connection_error\n+        err = Exception(\"Request timed out.\")\n+        assert _is_connection_error(err) is True\n+\n+    def test_dns_failure(self):\n+        from agent.auxiliary_client import _is_connection_error\n+        err = Exception(\"Name or service not known\")\n+        assert _is_connection_error(err) is True\n+\n+    def test_normal_api_error_not_connection(self):\n+        from agent.auxiliary_client import _is_connection_error\n+        err = Exception(\"Bad Request: invalid model\")\n+        err.status_code = 400\n+        assert _is_connection_error(err) is False\n+\n+    def test_500_not_connection(self):\n+        from agent.auxiliary_client import _is_connection_error\n+        err = Exception(\"Internal Server Error\")\n+        err.status_code = 500\n+        assert _is_connection_error(err) is False\n+\n+\n+# ---------------------------------------------------------------------------\n+# async_call_llm payment / connection fallback (#7512 bug 2)\n+# ---------------------------------------------------------------------------\n+\n+\n+class TestAsyncCallLlmFallback:\n+    \"\"\"async_call_llm mirrors call_llm fallback behavior.\"\"\"\n+\n+    def _make_402_error(self, msg=\"Payment Required: insufficient credits\"):\n+        exc = Exception(msg)\n+        exc.status_code = 402\n+        return exc\n+\n+    @pytest.mark.asyncio\n+    async def test_402_triggers_async_fallback_when_auto(self, monkeypatch):\n+        \"\"\"When provider is auto and returns 402, async_call_llm tries fallback.\"\"\"\n+        monkeypatch.setenv(\"OPENROUTER_API_KEY\", \"or-key\")\n+\n+        primary_client = MagicMock()\n+        primary_client.chat.completions.create = AsyncMock(\n+            side_effect=self._make_402_error())\n+\n+        # Fallback client (sync) returned by _try_payment_fallback\n+        fb_sync_client = MagicMock()\n+        fb_async_client = MagicMock()\n+        fb_response = MagicMock()\n+        fb_async_client.chat.completions.create = AsyncMock(return_value=fb_response)\n+\n+        with patch(\"agent.auxiliary_client._get_cached_client\",\n+                    return_value=(primary_client, \"google/gemini-3-flash-preview\")), \\\n+             patch(\"agent.auxiliary_client._resolve_task_provider_model\",\n+                    return_value=(\"auto\", \"google/gemini-3-flash-preview\", None, None, None)), \\\n+             patch(\"agent.auxiliary_client._try_payment_fallback\",\n+                    return_value=(fb_sync_client, \"gpt-5.2-codex\", \"openai-codex\")) as mock_fb, \\\n+             patch(\"agent.auxiliary_client._to_async_client\",\n+                    return_value=(fb_async_client, \"gpt-5.2-codex\")):\n+            result = await async_call_llm(\n+                task=\"compression\",\n+                messages=[{\"role\": \"user\", \"content\": \"hello\"}],\n+            )\n+\n+        assert result is fb_response\n+        mock_fb.assert_called_once_with(\"auto\", \"compression\", reason=\"payment error\")\n+\n+    @pytest.mark.asyncio\n+    async def test_402_no_async_fallback_when_explicit(self, monkeypatch):\n+        \"\"\"When provider is explicit, 402 should NOT trigger async fallback.\"\"\"\n+        monkeypatch.setenv(\"OPENROUTER_API_KEY\", \"or-key\")\n+\n+        primary_client = MagicMock()\n+        primary_client.chat.completions.create = AsyncMock(\n+            side_effect=self._make_402_error())\n+\n+        with patch(\"agent.auxiliary_client._get_cached_client\",\n+                    return_value=(primary_client, \"local-model\")), \\\n+             patch(\"agent.auxiliary_client._resolve_task_provider_model\",\n+                    return_value=(\"custom\", \"local-model\", None, None, None)), \\\n+             patch(\"agent.auxiliary_client._try_payment_fallback\") as mock_fb:\n+            with pytest.raises(Exception, match=\"insufficient credits\"):\n+                await async_call_llm(\n+                    task=\"compression\",\n+                    messages=[{\"role\": \"user\", \"content\": \"hello\"}],\n+                )\n+\n+        mock_fb.assert_not_called()\n+\n+    @pytest.mark.asyncio\n+    async def test_connection_error_triggers_async_fallback(self, monkeypatch):\n+        \"\"\"Connection errors trigger async fallback when provider is auto.\"\"\"\n+        monkeypatch.setenv(\"OPENROUTER_API_KEY\", \"or-key\")\n+\n+        primary_client = MagicMock()\n+        conn_err = Exception(\"Connection refused\")\n+        conn_err.status_code = None\n+        primary_client.chat.completions.create = AsyncMock(side_effect=conn_err)\n+\n+        fb_sync_client = MagicMock()\n+        fb_async_client = MagicMock()\n+        fb_response = MagicMock()\n+        fb_async_client.chat.completions.create = AsyncMock(return_value=fb_response)\n+\n+        with patch(\"agent.auxiliary_client._get_cached_client\",\n+                    return_value=(primary_client, \"model\")), \\\n+             patch(\"agent.auxiliary_client._resolve_task_provider_model\",\n+                    return_value=(\"auto\", \"model\", None, None, None)), \\\n+             patch(\"agent.auxiliary_client._is_connection_error\", return_value=True), \\\n+             patch(\"agent.auxiliary_client._try_payment_fallback\",\n+                    return_value=(fb_sync_client, \"fb-model\", \"nous\")) as mock_fb, \\\n+             patch(\"agent.auxiliary_client._to_async_client\",\n+                    return_value=(fb_async_client, \"fb-model\")):\n+            result = await async_call_llm(\n+                task=\"compression\",\n+                messages=[{\"role\": \"user\", \"content\": \"hello\"}],\n+            )\n+\n+        assert result is fb_response\n+        mock_fb.assert_called_once_with(\"auto\", \"compression\", reason=\"connection error\")\n+class TestStaleBaseUrlWarning:\n+    \"\"\"_resolve_auto() warns when OPENAI_BASE_URL conflicts with config provider (#5161).\"\"\"\n+\n+    def test_warns_when_openai_base_url_set_with_named_provider(self, monkeypatch, caplog):\n+        \"\"\"Warning fires when OPENAI_BASE_URL is set but provider is a named provider.\"\"\"\n+        import agent.auxiliary_client as mod\n+        # Reset the module-level flag so the warning fires\n+        monkeypatch.setattr(mod, \"_stale_base_url_warned\", False)\n+        monkeypatch.setenv(\"OPENAI_BASE_URL\", \"http://localhost:11434/v1\")\n+        monkeypatch.setenv(\"OPENROUTER_API_KEY\", \"sk-or-test\")\n+\n+        with patch(\"agent.auxiliary_client._read_main_provider\", return_value=\"openrouter\"), \\\n+             patch(\"agent.auxiliary_client._read_main_model\", return_value=\"google/gemini-flash\"), \\\n+             caplog.at_level(logging.WARNING, logger=\"agent.auxiliary_client\"):\n+            _resolve_auto()\n+\n+        assert any(\"OPENAI_BASE_URL is set\" in rec.message for rec in caplog.records), \\\n+            \"Expected a warning about stale OPENAI_BASE_URL\"\n+        assert mod._stale_base_url_warned is True\n+\n+    def test_no_warning_when_provider_is_custom(self, monkeypatch, caplog):\n+        \"\"\"No warning when the provider is 'custom' — OPENAI_BASE_URL is expected.\"\"\"\n+        import agent.auxiliary_client as mod\n+        monkeypatch.setattr(mod, \"_stale_base_url_warned\", False)\n+        monkeypatch.setenv(\"OPENAI_BASE_URL\", \"http://localhost:11434/v1\")\n+        monkeypatch.setenv(\"OPENAI_API_KEY\", \"test-key\")\n+\n+        with patch(\"agent.auxiliary_client._read_main_provider\", return_value=\"custom\"), \\\n+             patch(\"agent.auxiliary_client._read_main_model\", return_value=\"llama3\"), \\\n+             patch(\"agent.auxiliary_client._resolve_custom_runtime\",\n+                   return_value=(\"http://localhost:11434/v1\", \"test-key\", None)), \\\n+             patch(\"agent.auxiliary_client.OpenAI\") as mock_openai, \\\n+             caplog.at_level(logging.WARNING, logger=\"agent.auxiliary_client\"):\n+            mock_openai.return_value = MagicMock()\n+            _resolve_auto()\n+\n+        assert not any(\"OPENAI_BASE_URL is set\" in rec.message for rec in caplog.records), \\\n+            \"Should NOT warn when provider is 'custom'\"\n+\n+    def test_no_warning_when_provider_is_named_custom(self, monkeypatch, caplog):\n+        \"\"\"No warning when the provider is 'custom:myname' — base_url comes from config.\"\"\"\n+        import agent.auxiliary_client as mod\n+        monkeypatch.setattr(mod, \"_stale_base_url_warned\", False)\n+        monkeypatch.setenv(\"OPENAI_BASE_URL\", \"http://localhost:11434/v1\")\n+        monkeypatch.setenv(\"OPENAI_API_KEY\", \"test-key\")\n+\n+        with patch(\"agent.auxiliary_client._read_main_provider\", return_value=\"custom:ollama-local\"), \\\n+             patch(\"agent.auxiliary_client._read_main_model\", return_value=\"llama3\"), \\\n+             patch(\"agent.auxiliary_client.resolve_provider_client\",\n+                   return_value=(MagicMock(), \"llama3\")), \\\n+             caplog.at_level(logging.WARNING, logger=\"agent.auxiliary_client\"):\n+            _resolve_auto()\n+\n+        assert not any(\"OPENAI_BASE_URL is set\" in rec.message for rec in caplog.records), \\\n+            \"Should NOT warn when provider is 'custom:*'\"\n+\n+    def test_no_warning_when_openai_base_url_not_set(self, monkeypatch, caplog):\n+        \"\"\"No warning when OPENAI_BASE_URL is absent.\"\"\"\n+        import agent.auxiliary_client as mod\n+        monkeypatch.setattr(mod, \"_stale_base_url_warned\", False)\n+        monkeypatch.delenv(\"OPENAI_BASE_URL\", raising=False)\n+        monkeypatch.setenv(\"OPENROUTER_API_KEY\", \"sk-or-test\")\n+\n+        with patch(\"agent.auxiliary_client._read_main_provider\", return_value=\"openrouter\"), \\\n+             patch(\"agent.auxiliary_client._read_main_model\", return_value=\"google/gemini-flash\"), \\\n+             caplog.at_level(logging.WARNING, logger=\"agent.auxiliary_client\"):\n+            _resolve_auto()\n+\n+        assert not any(\"OPENAI_BASE_URL is set\" in rec.message for rec in caplog.records), \\\n+            \"Should NOT warn when OPENAI_BASE_URL is not set\"\n+\n+    def test_warning_only_fires_once(self, monkeypatch, caplog):\n+        \"\"\"Warning is suppressed after the first invocation.\"\"\"\n+        import agent.auxiliary_client as mod\n+        monkeypatch.setattr(mod, \"_stale_base_url_warned\", False)\n+        monkeypatch.setenv(\"OPENAI_BASE_URL\", \"http://localhost:11434/v1\")\n+        monkeypatch.setenv(\"OPENROUTER_API_KEY\", \"sk-or-test\")\n+\n+        with patch(\"agent.auxiliary_client._read_main_provider\", return_value=\"openrouter\"), \\\n+             patch(\"agent.auxiliary_client._read_main_model\", return_value=\"google/gemini-flash\"), \\\n+             caplog.at_level(logging.WARNING, logger=\"agent.auxiliary_client\"):\n+            _resolve_auto()\n+            caplog.clear()\n+            _resolve_auto()\n+\n+        assert not any(\"OPENAI_BASE_URL is set\" in rec.message for rec in caplog.records), \\\n+            \"Warning should not fire a second time\""]},{"file":"tests/hermes_cli/test_clear_stale_base_url.py","hunks":["@@ -0,0 +1,75 @@\n+\"\"\"Tests for _clear_stale_openai_base_url() cleanup after provider switch (#5161).\"\"\"\n+\n+from __future__ import annotations\n+\n+from unittest.mock import patch\n+\n+from hermes_cli.config import load_config, save_config, save_env_value, get_env_value\n+\n+\n+def _write_provider(provider: str, model: str = \"test-model\"):\n+    \"\"\"Helper: write a provider + model to config.yaml.\"\"\"\n+    cfg = load_config()\n+    model_cfg = cfg.get(\"model\", {})\n+    if not isinstance(model_cfg, dict):\n+        model_cfg = {}\n+    model_cfg[\"provider\"] = provider\n+    model_cfg[\"default\"] = model\n+    cfg[\"model\"] = model_cfg\n+    save_config(cfg)\n+\n+\n+class TestClearStaleOpenaiBaseUrl:\n+    \"\"\"_clear_stale_openai_base_url() removes OPENAI_BASE_URL when provider is not custom.\"\"\"\n+\n+    def test_clears_when_provider_is_named(self, monkeypatch):\n+        \"\"\"OPENAI_BASE_URL is cleared when config provider is a named provider.\"\"\"\n+        from hermes_cli.main import _clear_stale_openai_base_url\n+\n+        _write_provider(\"openrouter\")\n+        save_env_value(\"OPENAI_BASE_URL\", \"http://localhost:11434/v1\")\n+\n+        _clear_stale_openai_base_url()\n+\n+        result = get_env_value(\"OPENAI_BASE_URL\")\n+        assert not result, f\"Expected OPENAI_BASE_URL to be cleared, got: {result!r}\"\n+\n+    def test_preserves_when_provider_is_custom(self, monkeypatch):\n+        \"\"\"OPENAI_BASE_URL is NOT cleared when config provider is 'custom'.\"\"\"\n+        from hermes_cli.main import _clear_stale_openai_base_url\n+\n+        _write_provider(\"custom\")\n+        save_env_value(\"OPENAI_BASE_URL\", \"http://localhost:11434/v1\")\n+\n+        _clear_stale_openai_base_url()\n+\n+        result = get_env_value(\"OPENAI_BASE_URL\")\n+        assert result == \"http://localhost:11434/v1\", \\\n+            f\"Expected OPENAI_BASE_URL to be preserved, got: {result!r}\"\n+\n+    def test_noop_when_no_openai_base_url(self, monkeypatch):\n+        \"\"\"No error when OPENAI_BASE_URL is not set.\"\"\"\n+        from hermes_cli.main import _clear_stale_openai_base_url\n+\n+        _write_provider(\"openrouter\")\n+        # Ensure it's not set\n+        save_env_value(\"OPENAI_BASE_URL\", \"\")\n+        monkeypatch.delenv(\"OPENAI_BASE_URL\", raising=False)\n+\n+        # Should not raise\n+        _clear_stale_openai_base_url()\n+\n+    def test_noop_when_provider_empty(self, monkeypatch):\n+        \"\"\"No cleanup when provider is not set in config.\"\"\"\n+        from hermes_cli.main import _clear_stale_openai_base_url\n+\n+        cfg = load_config()\n+        cfg.pop(\"model\", None)\n+        save_config(cfg)\n+        save_env_value(\"OPENAI_BASE_URL\", \"http://localhost:11434/v1\")\n+\n+        _clear_stale_openai_base_url()\n+\n+        result = get_env_value(\"OPENAI_BASE_URL\")\n+        assert result == \"http://localhost:11434/v1\", \\\n+            \"Should not clear when provider is not configured\"\n"]}]},{"kind":"pr","number":10339,"title":"fix: compression auto-detect reads config base_url for custom providers","url":null,"state":"closed","close_reason":{"category":"volume_quota","implication":"active"},"merged":false},{"kind":"pr","number":21384,"title":"fix(agent): route bare ollama provider through custom branch","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"6a159be7ca936f075be2875516498abaed4810c4","number":0},{"kind":"pr","value":"#21352","number":21352}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":25180,"title":"fix(auxiliary): prefer fallback_providers model for OpenRouter aux tasks","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#36759","number":36759},{"kind":"pr","value":"#50954","number":50954}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":25316,"title":"fix(auxiliary): preserve :free suffix in auxiliary fallback models","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#25180","number":25180}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":36789,"title":"fix(agent): honor declared fallback chain for aux runtime overrides","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":38716,"title":"fix(agent): propagate mid-session model switch to auxiliary client (#36759)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#36759","number":36759},{"kind":"pr","value":"#50954","number":50954}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":41055,"title":"fix(auxiliary): preserve max_tokens for OpenRouter to prevent free-tier 402","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"13c3958df5","number":0},{"kind":"pr","value":"#99725","number":99725},{"kind":"pr","value":"#49785","number":49785},{"kind":"pr","value":"#41035","number":41035}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":41063,"title":"fix(openrouter): include max_tokens + fix credential pool fallback (#41035)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"c56b39c11e814ee571e391fc46384b0e7888a490","number":0},{"kind":"pr","value":"#53906","number":53906},{"kind":"pr","value":"#23495","number":23495},{"kind":"pr","value":"#41035","number":41035}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":44939,"title":"fix(auxiliary): use empty default for OpenRouter auxiliary model (#44894)","url":null,"state":"closed","close_reason":{"category":"silent_close","implication":"active"},"merged":false},{"kind":"pr","number":45077,"title":"fix: When openrouter key hermes uses paid Auxiliary default models for silly things like session title generation (#44894)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#44939","number":44939},{"kind":"pr","value":"#44894","number":44894},{"kind":"pr","value":"#45091","number":45091}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":45091,"title":"fix(provider): OpenRouter key uses paid Auxiliary default models for silly things like session title generation (#44894)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#44939","number":44939},{"kind":"pr","value":"#44894","number":44894}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":45121,"title":"fix(auxiliary): use free default models for auxiliary tasks (#44894)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#44939","number":44939},{"kind":"pr","value":"#44894","number":44894}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":50139,"title":"fix(auxiliary): preserve explicit max_tokens caps (#50132)","url":null,"state":"open","close_reason":{"refs":[{"kind":"pr","value":"#50139","number":50139},{"kind":"pr","value":"#50132","number":50132},{"kind":"pr","value":"#50139","number":50139}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":50147,"title":"fix(auxiliary): preserve explicit caps for compatible routes","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#50132","number":50132},{"kind":"pr","value":"#50132","number":50132}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":50158,"title":"fix(agent): pass max_tokens unconditionally in _build_call_kwargs","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#50139","number":50139},{"kind":"pr","value":"#50132","number":50132},{"kind":"pr","value":"#50139","number":50139},{"kind":"pr","value":"#50147","number":50147}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":53681,"title":"fix(auxiliary): pass max_tokens explicitly for LiteLLM/Bedrock proxies","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":56934,"title":"fix(auxiliary): preserve max_tokens for OpenRouter to prevent 402","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#41055","number":41055},{"kind":"pr","value":"#41055","number":41055}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":56957,"title":"[codex] Keep OpenRouter auxiliary token caps","url":null,"state":"open","close_reason":{"refs":[{"kind":"pr","value":"#41055","number":41055},{"kind":"pr","value":"#41055","number":41055},{"kind":"pr","value":"#56960","number":56960},{"kind":"pr","value":"#41055","number":41055}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":56960,"title":"fix(auxiliary): preserve OpenRouter max_tokens for title generation","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#41055","number":41055},{"kind":"pr","value":"#41055","number":41055},{"kind":"pr","value":"#56934","number":56934},{"kind":"pr","value":"#56957","number":56957},{"kind":"pr","value":"#41055","number":41055}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":57382,"title":"fix(auxiliary): cap OpenRouter max tokens","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#41055","number":41055},{"kind":"pr","value":"#56901","number":56901}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":58053,"title":"fix(auxiliary): preserve OpenRouter max tokens","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#41055","number":41055}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":99725,"title":"fix(compression): OpenRouter fallback survives credit-limited 402s by clamping to the affordable budget (salvage #41055)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/auxiliary_client.py","tests/agent/test_aux_affordable_402_retry.py","tests/agent/test_auxiliary_openrouter_max_tokens.py"]},{"kind":"pr","number":106319,"title":"feat(cli): add profile-scoped free-only OpenRouter choices","url":null,"state":"closed","close_reason":{"category":"silent_close","implication":"active"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}