{"repo":"NousResearch/hermes-agent","public_id":"27555","display_label":"Auxiliary/vision fallback chain provider kwarg handling fixes","summary":"Most members concern how the auxiliary and vision fallback chain passes explicit base URL and API key arguments when resolving a provider (e.g. in _resolve_single_provider and resolve_provider_client). One or two touch adjacent fallback-policy or model-default behavior, but the shared theme is fallback provider resolution.","subsystem":"agent","counts":{"members":12,"open_issues":0,"open_issues_distinct":0,"open_prs":1,"closed_issues":3,"merged_prs":1,"attempt_prs":8},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":47235,"merged_at":"2026-06-16T13:23:25+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-06-16T13:23:25+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.18303030303030302},"fix_edges":[{"issue_number":27555,"pr_number":47235,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-06-16T13:23:25+00:00"},{"issue_number":42082,"pr_number":47235,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-06-16T13:23:25+00:00"}],"members":[{"kind":"issue","number":27555,"title":"Bug: vision fallback_chain silently broken — wrong kwargs in _resolve_single_provider","url":null,"state":"closed"},{"kind":"issue","number":35876,"title":"fix(vision): _resolve_single_provider kwargs regression — fallback_chain silently fails on Gemini quota errors","url":null,"state":"closed"},{"kind":"issue","number":42082,"title":"fix(auxiliary): pass explicit_base_url/explicit_api_key to resolve_provider_client in fallback chain","url":null,"state":"closed"},{"kind":"pr","number":27718,"title":"fix(auxiliary): resolve fallback_chain explicit overrides","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"d7d53d3c6","number":0},{"kind":"pr","value":"#27555","number":27555}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":30160,"title":"fix(auxiliary): resolve fallback_chain endpoint overrides","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"4858942c552733f72de5b2d0dfdfcc7a3a1dc248","number":0},{"kind":"pr","value":"#47235","number":47235}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":33813,"title":"fix(vision): preserve fallback provider kwargs and model defaults","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#29091","number":29091}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":35990,"title":"fix(vision): correct kwarg names in _resolve_single_provider for fallback chain (#35876)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#33813","number":33813}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":36255,"title":"fix(agent): prevent base_url kwarg leak in vision fallback path (#35876)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"4858942c552733f72de5b2d0dfdfcc7a3a1dc248","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":42114,"title":"fix(auxiliary): forward explicit_base_url/explicit_api_key in fallback","url":null,"state":"closed","close_reason":{"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":42118,"title":"fix(auxiliary): pass explicit_base_url/explicit_api_key in _resolve_single_provider","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"4858942c552733f72de5b2d0dfdfcc7a3a1dc248","number":0},{"kind":"pr","value":"#47235","number":47235}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":46168,"title":"[codex] fix auxiliary fallback provider resolution","url":null,"state":"open","close_reason":{"refs":[{"kind":"pr","value":"#30160","number":30160}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":47235,"title":"fix(auxiliary): honor fallback policy for auto tasks","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/auxiliary_client.py","tests/agent/test_auxiliary_client.py","tests/agent/test_auxiliary_main_first.py","tests/run_agent/test_async_httpx_del_neuter.py","website/docs/reference/environment-variables.md","website/docs/user-guide/configuring-models.md","website/docs/user-guide/features/fallback-providers.md"],"fix_hunks":[{"file":"agent/auxiliary_client.py","hunks":["@@ -3079,23 +3079,20 @@ def _try_configured_fallback_chain(\n         if not fb_provider or fb_provider.lower() == skip:\n             continue\n         fb_model = str(entry.get(\"model\", \"\")).strip() or None\n-        fb_base_url = str(entry.get(\"base_url\", \"\")).strip() or None\n-        fb_api_key = [redacted:secret-assignment]\"api_key\", \"\")).strip() or None\n \n         label = f\"fallback_chain[{i}]({fb_provider})\"\n \n         try:\n-            fb_client = _resolve_single_provider(\n-                fb_provider, fb_model, fb_base_url, fb_api_key)\n+            fb_client, resolved_model = _resolve_fallback_entry(entry)\n         except Exception:\n-            fb_client = None\n+            fb_client, resolved_model = None, None\n \n         if fb_client is not None:\n             logger.info(\n                 \"Auxiliary %s: %s on %s — configured fallback to %s (%s)\",\n-                task, reason, failed_provider, label, fb_model or \"default\",\n+                task, reason, failed_provider, label, resolved_model or fb_model or \"default\",\n             )\n-            return fb_client, fb_model, label\n+            return fb_client, resolved_model or fb_model, label\n         tried.append(label)\n \n     if tried:","@@ -3106,6 +3103,103 @@ def _try_configured_fallback_chain(\n     return None, None, \"\"\n \n \n+def _fallback_entry_api_key(entry: Dict[str, Any]) -> Optional[str]:\n+    \"\"\"Resolve inline or env-backed API key from a fallback-chain entry.\"\"\"\n+    explicit = str(entry.get(\"api_key\") or \"\").strip()\n+    if explicit:\n+        return explicit\n+    key_env = str(entry.get(\"key_env\") or entry.get(\"api_key_env\") or \"\").strip()\n+    if key_env:\n+        return os.getenv(key_env, \"\").strip() or None\n+    return None\n+\n+\n+def _resolve_fallback_entry(entry: Dict[str, Any]) -> Tuple[Optional[Any], Optional[str]]:\n+    \"\"\"Resolve one fallback entry through the central provider router.\"\"\"\n+    provider = str(entry.get(\"provider\") or \"\").strip()\n+    model = str(entry.get(\"model\") or \"\").strip() or None\n+    if not provider or not model:\n+        return None, None\n+    base_url = str(entry.get(\"base_url\") or \"\").strip() or None\n+    api_key = [redacted:secret-assignment]\n+    api_mode = str(entry.get(\"api_mode\") or entry.get(\"transport\") or \"\").strip() 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+\n+def _try_main_fallback_chain(\n+    task: Optional[str],\n+    failed_provider: str = \"\",\n+    reason: str = \"error\",\n+) -> Tuple[Optional[Any], Optional[str], str]:\n+    \"\"\"Try the top-level main-agent fallback chain for an auxiliary call.\n+\n+    ``provider: auto`` auxiliary tasks should respect the user's declared\n+    main fallback policy before dropping into Hermes' built-in discovery\n+    chain. The top-level chain is read through ``get_fallback_chain`` so\n+    both modern ``fallback_providers`` and legacy ``fallback_model`` entries\n+    participate in the same order as the main agent.\n+    \"\"\"\n+    try:\n+        from hermes_cli.config import load_config\n+        from hermes_cli.fallback_config import get_fallback_chain\n+\n+        chain = get_fallback_chain(load_config())\n+    except Exception as exc:\n+        logger.debug(\"Auxiliary %s: could not load main fallback chain: %s\", task or \"call\", exc)\n+        return None, None, \"\"\n+\n+    if not chain:\n+        return None, None, \"\"\n+\n+    failed_norm = (failed_provider or \"\").strip().lower()\n+    main_norm = (_read_main_provider() or \"\").strip().lower()\n+    skip = {p for p in (failed_norm, main_norm, \"auto\") if p}\n+    tried: List[str] = []\n+\n+    for i, entry in enumerate(chain):\n+        if not isinstance(entry, dict):\n+            continue\n+        fb_provider = str(entry.get(\"provider\") or \"\").strip()\n+        fb_model = str(entry.get(\"model\") or \"\").strip()\n+        if not fb_provider or not fb_model:\n+            continue\n+        fb_norm = fb_provider.lower()\n+        label = f\"fallback_providers[{i}]({fb_provider})\"\n+        if fb_norm in skip:\n+            tried.append(f\"{label} (skipped)\")\n+            continue\n+        if _is_provider_unhealthy(fb_norm):\n+            _log_skip_unhealthy(fb_norm, task)\n+            tried.append(f\"{label} (unhealthy)\")\n+            continue\n+        try:\n+            fb_client, resolved_model = _resolve_fallback_entry(entry)\n+        except Exception as exc:\n+            logger.debug(\"Auxiliary %s: main fallback %s failed to resolve: %s\", task or \"call\", label, exc)\n+            fb_client, resolved_model = None, None\n+        if fb_client is not None:\n+            logger.info(\n+                \"Auxiliary %s: %s on %s — main fallback chain to %s (%s)\",\n+                task or \"call\", reason, failed_provider or \"auto\", label,\n+                resolved_model or fb_model,\n+            )\n+            return fb_client, resolved_model or fb_model, fb_provider\n+        tried.append(label)\n+\n+    if tried:\n+        logger.debug(\n+            \"Auxiliary %s: main fallback chain exhausted (tried: %s)\",\n+            task or \"call\", \", \".join(tried),\n+        )\n+    return None, None, \"\"\n+\n+\n def _resolve_single_provider(\n     provider: str,\n     model: Optional[str] = None,","@@ -3116,16 +3210,19 @@ def _resolve_single_provider(\n \n     Uses the existing provider resolution infrastructure where possible.\n     \"\"\"\n-    # Reuse resolve_provider_client which handles provider→client mapping\n+    # Reuse resolve_provider_client which handles provider→client mapping.\n     client, resolved_model = resolve_provider_client(\n         provider=provider,\n         model=model,\n-        base_url=base_url,\n-        api_key=[redacted:secret-assignment]\n+        explicit_base_url=base_url,\n+        explicit_api_key=[redacted:secret-assignment]\n     )\n     return client\n \n-def _resolve_auto(main_runtime: Optional[Dict[str, Any]] = None) -> Tuple[Optional[OpenAI], Optional[str]]:\n+def _resolve_auto(\n+    main_runtime: Optional[Dict[str, Any]] = None,\n+    task: Optional[str] = None,\n+) -> Tuple[Optional[OpenAI], Optional[str]]:\n     \"\"\"Full auto-detection chain.\n \n     Priority:","@@ -3223,7 +3320,22 @@ def _resolve_auto(main_runtime: Optional[Dict[str, Any]] = None) -> Tuple[Option\n                             main_provider, resolved or main_model)\n                 return client, resolved or main_model\n \n-    # ── Step 2: aggregator / fallback chain ──────────────────────────────\n+    # ── Step 2: user-configured fallback policy ─────────────────────────\n+    # In auto mode, respect the task-specific fallback chain first, then the\n+    # main agent's top-level fallback_providers/fallback_model chain. The\n+    # hardcoded provider discovery chain below is only the convenience default\n+    # for users who have not declared a fallback policy.\n+    if task:\n+        fb_client, fb_model, _fb_label = _try_configured_fallback_chain(\n+            task, main_provider or \"auto\", reason=\"main provider unavailable\")\n+        if fb_client is not None:\n+            return fb_client, fb_model\n+    fb_client, fb_model, _fb_label = _try_main_fallback_chain(\n+        task, main_provider or \"auto\", reason=\"main provider unavailable\")\n+    if fb_client is not None:\n+        return fb_client, fb_model\n+\n+    # ── Step 3: aggregator / fallback chain ──────────────────────────────\n     tried = []\n     for label, try_fn in _get_provider_chain():\n         if _is_provider_unhealthy(label):","@@ -3344,6 +3456,7 @@ def resolve_provider_client(\n     api_mode: str = None,\n     main_runtime: Optional[Dict[str, Any]] = None,\n     is_vision: bool = False,\n+    task: Optional[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.","@@ -3464,7 +3577,7 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = \"\",\n \n     # ── Auto: try all providers in priority order ────────────────────\n     if provider == \"auto\":\n-        client, resolved = _resolve_auto(main_runtime=main_runtime)\n+        client, resolved = _resolve_auto(main_runtime=main_runtime, task=task)\n         if client is None:\n             return None, None\n         # When auto-detection lands on a non-OpenRouter provider (e.g. a","@@ -4357,11 +4470,16 @@ def _client_cache_key(\n     api_mode: Optional[str] = None,\n     main_runtime: Optional[Dict[str, Any]] = None,\n     is_vision: bool = False,\n+    task: Optional[str] = None,\n ) -> tuple:\n     runtime = _normalize_main_runtime(main_runtime)\n     runtime_key = tuple(runtime.get(field, \"\") for field in _MAIN_RUNTIME_FIELDS) if provider == \"auto\" else ()\n+    # `auto` can now resolve through task-specific or main fallback policy,\n+    # so the task participates in the cache key. Non-auto providers keep the\n+    # old cache shape because the explicit provider/model tuple is sufficient.\n+    task_key = (task or \"\") if provider == \"auto\" else \"\"\n     pool_hint = _pool_cache_hint(provider, main_runtime=main_runtime)\n-    return (provider, async_mode, base_url or \"\", api_key or \"\", api_mode or \"\", runtime_key, is_vision, pool_hint)\n+    return (provider, async_mode, base_url or \"\", api_key or \"\", api_mode or \"\", runtime_key, is_vision, task_key, pool_hint)\n \n \n def _store_cached_client(cache_key: tuple, client: Any, default_model: Optional[str], *, bound_loop: Any = None) -> None:","@@ -4554,6 +4672,7 @@ def _get_cached_client(\n     api_mode: str = None,\n     main_runtime: Optional[Dict[str, Any]] = None,\n     is_vision: bool = False,\n+    task: Optional[str] = None,\n ) -> Tuple[Optional[Any], Optional[str]]:\n     \"\"\"Get or create a cached client for the given provider.\n ","@@ -4591,6 +4710,7 @@ def _get_cached_client(\n         api_mode=api_mode,\n         main_runtime=main_runtime,\n         is_vision=is_vision,\n+        task=task,\n     )\n     with _client_cache_lock:\n         if cache_key in _client_cache:","@@ -4635,6 +4755,7 @@ def _get_cached_client(\n         api_mode=api_mode,\n         main_runtime=runtime,\n         is_vision=is_vision,\n+        task=task,\n     )\n     if client is not None:\n         # For async clients, remember which loop they were created on so we","@@ -5140,7 +5261,7 @@ def call_llm(\n             if not resolved_base_url:\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\", main_runtime=main_runtime)\n+                client, final_model = _get_cached_client(\"auto\", main_runtime=main_runtime, task=task)\n         if client is None:\n             raise RuntimeError(\n                 f\"No LLM provider configured for task={task} provider={resolved_provider}. \"","@@ -5466,14 +5587,19 @@ def call_llm(\n \n             # Fallback order (#26882, #26803):\n             #   1. User-configured fallback_chain (per-task) if set\n-            #   2. Main agent model (last-resort safety net)\n-            # For auto users (no explicit aux provider), use the full\n-            # auto-detection chain instead — its Step 1 IS the main agent\n-            # model, so users on `auto` already get main-model fallback.\n+            #   2. For auto: top-level main fallback_providers/fallback_model\n+            #   3. For auto: built-in auxiliary discovery chain\n+            #   4. For explicit aux providers: main agent model safety net\n             fb_client, fb_model, fb_label = (None, None, \"\")\n             if is_auto:\n-                fb_client, fb_model, fb_label = _try_payment_fallback(\n-                    resolved_provider, task, reason=reason)\n+                fb_client, fb_model, fb_label = _try_configured_fallback_chain(\n+                    task, resolved_provider or \"auto\", reason=reason)\n+                if fb_client is None:\n+                    fb_client, fb_model, fb_label = _try_main_fallback_chain(\n+                        task, resolved_provider or \"auto\", reason=reason)\n+                if fb_client is None:\n+                    fb_client, fb_model, fb_label = _try_payment_fallback(\n+                        resolved_provider, task, reason=reason)\n             else:\n                 fb_client, fb_model, fb_label = _try_configured_fallback_chain(\n                     task, resolved_provider or \"auto\", reason=reason)","@@ -5636,7 +5762,7 @@ async def async_call_llm(\n             if not resolved_base_url:\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+                client, final_model = _get_cached_client(\"auto\", async_mode=True, main_runtime=main_runtime, task=task)\n         if client is None:\n             raise RuntimeError(\n                 f\"No LLM provider configured for task={task} provider={resolved_provider}. \"","@@ -5904,13 +6030,19 @@ async def async_call_llm(\n \n             # Fallback order (#26882, #26803):\n             #   1. User-configured fallback_chain (per-task) if set\n-            #   2. Main agent model (last-resort safety net)\n-            # Auto users get the full auto-detection chain instead — its\n-            # Step 1 IS the main agent model.\n+            #   2. For auto: top-level main fallback_providers/fallback_model\n+            #   3. For auto: built-in auxiliary discovery chain\n+            #   4. For explicit aux providers: main agent model safety net\n             fb_client, fb_model, fb_label = (None, None, \"\")\n             if is_auto:\n-                fb_client, fb_model, fb_label = _try_payment_fallback(\n-                    resolved_provider, task, reason=reason)\n+                fb_client, fb_model, fb_label = _try_configured_fallback_chain(\n+                    task, resolved_provider or \"auto\", reason=reason)\n+                if fb_client is None:\n+                    fb_client, fb_model, fb_label = _try_main_fallback_chain(\n+                        task, resolved_provider or \"auto\", reason=reason)\n+                if fb_client is None:\n+                    fb_client, fb_model, fb_label = _try_payment_fallback(\n+                        resolved_provider, task, reason=reason)\n             else:\n                 fb_client, fb_model, fb_label = _try_configured_fallback_chain(\n                     task, resolved_provider or \"auto\", reason=reason)"]},{"file":"tests/agent/test_auxiliary_client.py","hunks":["@@ -1653,6 +1653,37 @@ def _make_payment_err(self):\n         exc.status_code = 402\n         return exc\n \n+    def test_auto_provider_uses_task_then_main_chain_before_builtin_chain(self, monkeypatch):\n+        \"\"\"Auto aux call failures try per-task then top-level fallback before built-ins.\"\"\"\n+        primary_client = MagicMock()\n+        primary_client.chat.completions.create.side_effect = self._make_payment_err()\n+\n+        main_chain_client = MagicMock()\n+        main_chain_client.chat.completions.create.return_value = MagicMock(choices=[\n+            MagicMock(message=MagicMock(content=\"from main fallback chain\"))\n+        ])\n+\n+        with patch(\"agent.auxiliary_client._get_cached_client\",\n+                   return_value=(primary_client, \"qwen/qwen3.5-122b-a10b\")), \\\n+             patch(\"agent.auxiliary_client._resolve_task_provider_model\",\n+                   return_value=(\"auto\", None, None, None, None)), \\\n+             patch(\"agent.auxiliary_client._try_configured_fallback_chain\",\n+                   return_value=(None, None, \"\")) as mock_task_chain, \\\n+             patch(\"agent.auxiliary_client._try_main_fallback_chain\",\n+                   return_value=(main_chain_client, \"inclusionai/ring-2.6-1t:free\", \"openrouter\")) as mock_main_chain, \\\n+             patch(\"agent.auxiliary_client._try_payment_fallback\") as mock_builtin_chain:\n+            result = call_llm(\n+                task=\"title_generation\",\n+                messages=[{\"role\": \"user\", \"content\": \"hello\"}],\n+            )\n+\n+        assert main_chain_client.chat.completions.create.called\n+        mock_task_chain.assert_called_once_with(\n+            \"title_generation\", \"auto\", reason=\"payment error\")\n+        mock_main_chain.assert_called_once_with(\n+            \"title_generation\", \"auto\", reason=\"payment error\")\n+        mock_builtin_chain.assert_not_called()\n+\n     def test_explicit_provider_uses_configured_chain_first(self, monkeypatch, caplog):\n         \"\"\"When a user has fallback_chain configured, it's tried BEFORE the main agent model.\"\"\"\n         monkeypatch.setenv(\"OPENROUTER_API_KEY\", \"or-key\")"]},{"file":"tests/agent/test_auxiliary_main_first.py","hunks":["@@ -118,6 +118,64 @@ def test_main_unavailable_falls_through_to_chain(self, monkeypatch):\n         assert client is chain_client\n         assert model == \"google/gemini-3-flash-preview\"\n \n+    def test_main_unavailable_uses_task_fallback_chain_before_builtin_chain(self):\n+        \"\"\"Auto aux resolution honors auxiliary.<task>.fallback_chain before built-ins.\"\"\"\n+        task_client = MagicMock()\n+        with patch(\n+            \"agent.auxiliary_client._read_main_provider\", return_value=\"nvidia\",\n+        ), patch(\n+            \"agent.auxiliary_client._read_main_model\", return_value=\"qwen/qwen3.5-122b-a10b\",\n+        ), patch(\n+            \"agent.auxiliary_client.resolve_provider_client\",\n+            return_value=(None, None),  # main provider has no client\n+        ), patch(\n+            \"agent.auxiliary_client._try_configured_fallback_chain\",\n+            return_value=(task_client, \"task-free-model\", \"fallback_chain[0](openrouter)\"),\n+        ) as mock_task_chain, patch(\n+            \"agent.auxiliary_client._try_main_fallback_chain\",\n+        ) as mock_main_chain, patch(\n+            \"agent.auxiliary_client._try_openrouter\",\n+        ) as mock_openrouter:\n+            from agent.auxiliary_client import _resolve_auto\n+\n+            client, model = _resolve_auto(task=\"title_generation\")\n+\n+        assert client is task_client\n+        assert model == \"task-free-model\"\n+        mock_task_chain.assert_called_once_with(\n+            \"title_generation\", \"nvidia\", reason=\"main provider unavailable\")\n+        mock_main_chain.assert_not_called()\n+        mock_openrouter.assert_not_called()\n+\n+    def test_main_unavailable_uses_main_fallback_chain_before_builtin_chain(self):\n+        \"\"\"Auto aux resolution honors top-level fallback_providers before built-ins.\"\"\"\n+        main_fallback_client = MagicMock()\n+        with patch(\n+            \"agent.auxiliary_client._read_main_provider\", return_value=\"nvidia\",\n+        ), patch(\n+            \"agent.auxiliary_client._read_main_model\", return_value=\"qwen/qwen3.5-122b-a10b\",\n+        ), patch(\n+            \"agent.auxiliary_client.resolve_provider_client\",\n+            return_value=(None, None),  # main provider has no client\n+        ), patch(\n+            \"agent.auxiliary_client._try_configured_fallback_chain\",\n+            return_value=(None, None, \"\"),\n+        ), patch(\n+            \"agent.auxiliary_client._try_main_fallback_chain\",\n+            return_value=(main_fallback_client, \"inclusionai/ring-2.6-1t:free\", \"openrouter\"),\n+        ) as mock_main_chain, patch(\n+            \"agent.auxiliary_client._try_openrouter\",\n+        ) as mock_openrouter:\n+            from agent.auxiliary_client import _resolve_auto\n+\n+            client, model = _resolve_auto(task=\"title_generation\")\n+\n+        assert client is main_fallback_client\n+        assert model == \"inclusionai/ring-2.6-1t:free\"\n+        mock_main_chain.assert_called_once_with(\n+            \"title_generation\", \"nvidia\", reason=\"main provider unavailable\")\n+        mock_openrouter.assert_not_called()\n+\n     def test_no_main_config_uses_chain_directly(self):\n         \"\"\"No main provider configured → skip step 1, use chain (no regression).\"\"\"\n         chain_client = MagicMock()"]},{"file":"tests/run_agent/test_async_httpx_del_neuter.py","hunks":["@@ -176,11 +176,16 @@ def test_same_key_replaces_stale_loop_entry(self):\n         \"\"\"When the loop changes, the old entry should be replaced, not duplicated.\"\"\"\n         from agent.auxiliary_client import (\n             _client_cache,\n+            _client_cache_key,\n             _client_cache_lock,\n             _get_cached_client,\n         )\n \n-        key = (\"test_replace\", True, \"\", \"\", \"\", (), False, \"\")\n+        key = _client_cache_key(\n+            \"test_replace\",\n+            async_mode=True,\n+            task=\"\",\n+        )\n \n         # Simulate a stale entry from a closed loop\n         old_loop = asyncio.new_event_loop()"]},{"file":"website/docs/reference/environment-variables.md","hunks":["@@ -687,7 +687,7 @@ For task-specific direct endpoints, Hermes uses the task's configured API key or\n \n ## Fallback Providers (config.yaml only)\n \n-The primary model fallback chain is configured exclusively through `config.yaml` — there are no environment variables for it. Add a top-level `fallback_providers` list with `provider` and `model` keys to enable automatic failover when your main model encounters errors.\n+The primary model fallback chain is configured exclusively through `config.yaml` — there are no environment variables for it. Add a top-level `fallback_providers` list with `provider` and `model` keys to enable automatic failover when your main model encounters errors. Auxiliary tasks whose provider is `auto` also consult this chain before Hermes' built-in auxiliary discovery chain.\n \n ```yaml\n fallback_providers:","@@ -695,7 +695,7 @@ fallback_providers:\n     model: anthropic/claude-sonnet-4\n ```\n \n-The older top-level `fallback_model` single-provider shape is still read for backward compatibility, but new configuration should use `fallback_providers`.\n+The older top-level `fallback_model` single-provider shape is still read for backward compatibility, but new configuration should use `fallback_providers`. For task-specific auxiliary policy, use `auxiliary.<task>.fallback_chain` in `config.yaml`; there is no environment variable equivalent.\n \n See [Fallback Providers](/user-guide/features/fallback-providers) for full details.\n "]},{"file":"website/docs/user-guide/configuring-models.md","hunks":["@@ -53,7 +53,7 @@ Click **Show auxiliary** to reveal the 11 task slots:\n \n ![Auxiliary panel expanded](/img/docs/dashboard-models/auxiliary-expanded.png)\n \n-Every auxiliary task defaults to `auto` — meaning Hermes uses your main model for that job too. Override a specific task when you want a cheaper or faster model for a side-job.\n+Every auxiliary task defaults to `auto` — meaning Hermes tries your main model for that job too. If that route is unavailable or hits a capacity-style failure, `auto` follows any task-specific `auxiliary.<task>.fallback_chain`, then the main `fallback_providers` / `fallback_model` chain, then Hermes' built-in auxiliary discovery chain. Override a specific task when you want a cheaper or faster model for a side-job.\n \n ### Common override patterns\n ","@@ -129,7 +129,21 @@ auxiliary:\n     # ... other fields unchanged\n ```\n \n-`provider: auto` with `model: ''` tells Hermes to use the main model for that task.\n+`provider: auto` with `model: ''` tells Hermes to use the main model for that task, while still honoring fallback policy if the main route cannot serve the auxiliary call.\n+\n+Optional task-specific fallback chains live under the same auxiliary task:\n+\n+```yaml\n+auxiliary:\n+  title_generation:\n+    provider: auto\n+    model: ''\n+    fallback_chain:\n+      - provider: openrouter\n+        model: inclusionai/ring-2.6-1t:free\n+```\n+\n+When `fallback_chain` is absent, `auto` uses the top-level `fallback_providers` chain before the built-in auxiliary discovery chain.\n \n ## When does it take effect?\n "]},{"file":"website/docs/user-guide/features/fallback-providers.md","hunks":["@@ -168,7 +168,7 @@ fallback_providers:\n | Messaging gateway (Telegram, Discord, etc.) | ✔ |\n | Subagent delegation | ✔ (subagents inherit the parent fallback chain) |\n | Cron jobs | ✔ (cron agents inherit configured fallback providers) |\n-| Auxiliary tasks (vision, compression) | ✘ (use their own provider chain — see below) |\n+| Auxiliary tasks on `provider: auto` | ✔ (try per-task fallback, then the main fallback chain before built-in aux discovery) |\n \n :::tip\n There are no environment variables for the primary fallback chain — configure it exclusively through `config.yaml` or `hermes fallback`. This is intentional: fallback configuration is a deliberate choice, not something a stale shell export should override.","@@ -195,23 +195,30 @@ Hermes uses separate lightweight models for side tasks. Each task has its own pr\n \n ### Auto-Detection Chain\n \n-When a task's provider is set to `\"auto\"` (the default), Hermes tries providers in order until one works:\n+When a task's provider is set to `\"auto\"` (the default), Hermes first tries the main provider + main model for that auxiliary task. If that route is unavailable or later fails with a capacity-style error, Hermes now honors user-configured fallback policy before using the built-in discovery chain:\n \n-**For text tasks (compression, web extract, etc.):**\n+```text\n+Main provider + main model → auxiliary.<task>.fallback_chain →\n+fallback_providers / fallback_model → built-in auxiliary discovery chain\n+```\n+\n+The task-specific chain is most precise and wins when present. The top-level `fallback_providers` chain is the same policy the main agent uses, so free-only or same-provider fallback rules apply to auxiliary tasks on `auto` as well.\n+\n+**Built-in text discovery chain (compression, web extract, title generation, etc.):**\n \n ```text\n OpenRouter → Nous Portal → Custom endpoint → Codex OAuth →\n API-key providers (z.ai, Kimi, MiniMax, Xiaomi MiMo, Hugging Face, Anthropic) → give up\n ```\n \n-**For vision tasks:**\n+**Built-in vision discovery chain:**\n \n ```text\n Main provider (if vision-capable) → OpenRouter → Nous Portal →\n Codex OAuth → Anthropic → Custom endpoint → give up\n ```\n \n-If the resolved provider fails at call time, Hermes also has an internal retry: if the provider is not OpenRouter and no explicit `base_url` is set, it tries OpenRouter as a last-resort fallback.\n+Those built-in chains are a convenience fallback for users who have not declared a task-specific or main fallback policy.\n \n ### Configuring Auxiliary Providers\n ","@@ -232,6 +239,9 @@ auxiliary:\n   compression:\n     provider: \"auto\"\n     model: \"\"\n+    fallback_chain:              # optional, task-specific fallback policy\n+      - provider: openrouter\n+        model: inclusionai/ring-2.6-1t:free\n \n   skills_hub:\n     provider: \"auto\"","@@ -242,7 +252,9 @@ auxiliary:\n     model: \"\"\n ```\n \n-Every task above follows the same **provider / model / base_url** pattern. Context compression is configured under `auxiliary.compression`:\n+Every task above follows the same **provider / model / base_url** pattern. Each task can also declare its own `fallback_chain`; if omitted, `provider: auto` uses the top-level `fallback_providers` chain before Hermes' built-in auxiliary discovery chain.\n+\n+Context compression is configured under `auxiliary.compression`:\n \n ```yaml\n auxiliary:\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}