{"repo":"NousResearch/hermes-agent","public_id":"17929","display_label":"Init-time fallback when credential pool exhausted","summary":"These items address a bug where exhausting a single-key credential pool at initialization raises a RuntimeError instead of attempting fallback providers. The fix ensures the fallback chain is tried during init when the primary pool is rate-limited or exhausted.","subsystem":null,"counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":2,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":100135,"merged_at":"2026-09-01T09:00:06+00:00"},"resolution":{"state":"resolved","by":"pr-18762"},"freshness":{"state":"verified","last_verified_at":"2026-09-01T09:00:06+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":17929,"pr_number":18762,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-05-02T09:09:46+00:00"},{"issue_number":17929,"pr_number":100135,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-09-01T09:00:06+00:00"}],"members":[{"kind":"issue","number":17929,"title":"Single-key credential pool: rate-limit cooldown causes init-time RuntimeError, fallback chain never tried","url":null,"state":"closed"},{"kind":"pr","number":17958,"title":"fix(agent): try fallback providers at init when primary credential pool is exhausted","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#18762","number":18762}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":18762,"title":"fix(agent): try fallback providers at init when primary credential pool is exhausted (salvage #17958)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["run_agent.py","tests/run_agent/test_init_fallback_on_exhausted_pool.py"],"fix_hunks":[{"file":"run_agent.py","hunks":["@@ -1473,17 +1473,49 @@ def __init__(\n                                 _env_hint = _pcfg.api_key_env_vars[0]\n                         except Exception:\n                             pass\n+                        # --- Init-time fallback (#17929) ---\n+                        _fb_entries = []\n+                        if isinstance(fallback_model, list):\n+                            _fb_entries = [\n+                                f for f in fallback_model\n+                                if isinstance(f, dict) and f.get(\"provider\") and f.get(\"model\")\n+                            ]\n+                        elif isinstance(fallback_model, dict) and fallback_model.get(\"provider\") and fallback_model.get(\"model\"):\n+                            _fb_entries = [fallback_model]\n+                        _fb_resolved = False\n+                        for _fb in _fb_entries:\n+                            _fb_client, _fb_model = resolve_provider_client(\n+                                _fb[\"provider\"], model=_fb[\"model\"], raw_codex=True,\n+                                explicit_base_url=_fb.get(\"base_url\"),\n+                                explicit_api_key=_fb.get(\"api_key\"),\n+                            )\n+                            if _fb_client is not None:\n+                                self.provider = _fb[\"provider\"]\n+                                self.model = _fb_model or _fb[\"model\"]\n+                                self._fallback_activated = True\n+                                client_kwargs = {\n+                                    \"api_key\": [redacted:secret-assignment]\n+                                    \"base_url\": str(_fb_client.base_url),\n+                                }\n+                                if _provider_timeout is not None:\n+                                    client_kwargs[\"timeout\"] = _provider_timeout\n+                                if hasattr(_fb_client, \"_default_headers\") and _fb_client._default_headers:\n+                                    client_kwargs[\"default_headers\"] = dict(_fb_client._default_headers)\n+                                _fb_resolved = True\n+                                break\n+                        if not _fb_resolved:\n+                            raise RuntimeError(\n+                                f\"Provider '{_explicit}' is set in config.yaml but no API key \"\n+                                f\"was found. Set the {_env_hint} environment \"\n+                                f\"variable, or switch to a different provider with `hermes model`.\"\n+                            )\n+                    if not getattr(self, \"_fallback_activated\", False):\n+                        # No provider configured — reject with a clear message.\n                         raise RuntimeError(\n-                            f\"Provider '{_explicit}' is set in config.yaml but no API key \"\n-                            f\"was found. Set the {_env_hint} environment \"\n-                            f\"variable, or switch to a different provider with `hermes model`.\"\n+                            \"No LLM provider configured. Run `hermes model` to \"\n+                            \"select a provider, or run `hermes setup` for first-time \"\n+                            \"configuration.\"\n                         )\n-                    # No provider configured — reject with a clear message.\n-                    raise RuntimeError(\n-                        \"No LLM provider configured. Run `hermes model` to \"\n-                        \"select a provider, or run `hermes setup` for first-time \"\n-                        \"configuration.\"\n-                    )\n             \n             self._client_kwargs = client_kwargs  # stored for rebuilding after interrupt\n ","@@ -1536,7 +1568,7 @@ def __init__(\n         else:\n             self._fallback_chain = []\n         self._fallback_index = 0\n-        self._fallback_activated = False\n+        self._fallback_activated = getattr(self, \"_fallback_activated\", False)\n         # Legacy attribute kept for backward compat (tests, external callers)\n         self._fallback_model = self._fallback_chain[0] if self._fallback_chain else None\n         if self._fallback_chain and not self.quiet_mode:"]},{"file":"tests/run_agent/test_init_fallback_on_exhausted_pool.py","hunks":["@@ -0,0 +1,69 @@\n+\"\"\"Regression test for #17929: AIAgent.__init__ should try fallback_model\n+when primary provider credentials are exhausted.\"\"\"\n+import pytest\n+from unittest.mock import patch, MagicMock\n+from run_agent import AIAgent\n+\n+\n+def _make_tool_defs():\n+    return [{\"type\": \"function\", \"function\": {\"name\": \"web_search\",\n+             \"description\": \"search\", \"parameters\": {\"type\": \"object\", \"properties\": {}}}}]\n+\n+\n+def _mock_client(api_key=\"[redacted:secret-assignment]\", base_url=\"https://fb.example.com/v1\"):\n+    c = MagicMock()\n+    c.api_key = [redacted:secret-assignment]\n+    c.base_url = base_url\n+    c._default_headers = None\n+    return c\n+\n+\n+def test_init_tries_fallback_when_primary_returns_none():\n+    \"\"\"When resolve_provider_client returns None for primary but succeeds for\n+    a fallback entry, __init__ should NOT raise RuntimeError.\"\"\"\n+    fb = _mock_client()\n+\n+    def fake_resolve(provider, model=None, raw_codex=False,\n+                     explicit_base_url=None, explicit_api_key=[redacted:secret-assignment]\n+        if provider == \"tencent-token-plan\":\n+            return fb, \"kimi2.5\"\n+        return None, None  # primary exhausted\n+\n+    with patch(\"agent.auxiliary_client.resolve_provider_client\", side_effect=fake_resolve), \\\n+         patch(\"run_agent.get_tool_definitions\", return_value=_make_tool_defs()), \\\n+         patch(\"run_agent.check_toolset_requirements\", return_value={}), \\\n+         patch(\"run_agent.OpenAI\", return_value=MagicMock()):\n+\n+        agent = AIAgent(\n+            provider=\"alibaba-coding-plan\",\n+            model=\"qwen3.6-plus\",\n+            api_key=None,\n+            base_url=None,\n+            quiet_mode=True,\n+            skip_context_files=True,\n+            skip_memory=True,\n+            fallback_model=[{\"provider\": \"tencent-token-plan\", \"model\": \"kimi2.5\"}],\n+        )\n+        assert agent.provider == \"tencent-token-plan\"\n+        assert agent.model == \"kimi2.5\"\n+        assert agent._fallback_activated is True\n+\n+\n+def test_init_raises_when_no_fallback_configured():\n+    \"\"\"When primary returns None and no fallback is set, should raise.\"\"\"\n+    with patch(\"agent.auxiliary_client.resolve_provider_client\", return_value=(None, None)), \\\n+         patch(\"run_agent.get_tool_definitions\", return_value=_make_tool_defs()), \\\n+         patch(\"run_agent.check_toolset_requirements\", return_value={}), \\\n+         patch(\"run_agent.OpenAI\", return_value=MagicMock()):\n+\n+        with pytest.raises(RuntimeError, match=\"no API key was found\"):\n+            AIAgent(\n+                provider=\"alibaba-coding-plan\",\n+                model=\"qwen3.6-plus\",\n+                api_key=None,\n+                base_url=None,\n+                quiet_mode=True,\n+                skip_context_files=True,\n+                skip_memory=True,\n+                fallback_model=None,\n+            )\n"]}]},{"kind":"pr","number":100135,"title":"fix(agent): run init-time fallback for ANY exhausted primary provider (salvage #93439)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/agent_init.py","contributors/emails/vjpixel@users.noreply.github.com","tests/run_agent/test_init_fallback_on_exhausted_pool.py"]}],"trust":{"default_tier":"verified","includes_suggested":false}}