{"repo":"NousResearch/hermes-agent","public_id":"52608","display_label":"Anthropic auxiliary client base_url routing bug fixes","summary":"These issues and PRs address a bug where the Anthropic auxiliary client incorrectly inherits or applies non-Anthropic base URLs from the main provider configuration. Multiple fix attempts refine how base_url overrides are gated to Anthropic-compatible hosts across various resolution paths.","subsystem":null,"counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":52891,"merged_at":"2026-06-26T06:54:19+00:00"},"resolution":{"state":"resolved","by":"pr-52891"},"freshness":{"state":"verified","last_verified_at":"2026-06-26T06:54:19+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":52608,"pr_number":52891,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-06-26T06:54:19+00:00"}],"members":[{"kind":"issue","number":52608,"title":"auxiliary_client: _try_anthropic() misroutes side-channel calls when main config has provider=anthropic with a non-Anthropic base_url","url":null,"state":"closed"},{"kind":"pr","number":52623,"title":"fix(auxiliary): gate Anthropic base_url override on Anthropic-compatible host (#52608)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#52891","number":52891},{"kind":"pr","value":"#52888","number":52888}],"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":52891,"title":"fix(auxiliary): gate Anthropic base_url override on Anthropic-compatible host (#52608)","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_base_url_host_validation_52608.py"],"fix_hunks":[{"file":"agent/auxiliary_client.py","hunks":["@@ -666,6 +666,28 @@ def _pool_runtime_base_url(entry: Any, fallback: str = \"\") -> str:\n     return str(url or \"\").strip().rstrip(\"/\")\n \n \n+# Hostnames (lowercase, exact) that the auxiliary Anthropic path is allowed to\n+# be pointed at via config.yaml model.base_url. Anything else falls back to the\n+# Anthropic default — operators routing main-session traffic through a\n+# non-Anthropic host (e.g. OpenRouter, OpenAI) with provider=anthropic in config\n+# must NOT have that foreign host leak into the auxiliary client. See #52608.\n+_ANTHROPIC_COMPATIBLE_HOSTS = frozenset({\n+    \"api.anthropic.com\",\n+})\n+\n+\n+def _is_anthropic_compatible_host(url: str) -> bool:\n+    \"\"\"Return True if ``url``'s hostname is an Anthropic endpoint we trust for aux calls.\"\"\"\n+    if not url:\n+        return False\n+    try:\n+        from urllib.parse import urlparse\n+        host = (urlparse(url).hostname or \"\").strip().lower().rstrip(\".\")\n+        return host in _ANTHROPIC_COMPATIBLE_HOSTS\n+    except Exception:\n+        return False\n+\n+\n def _nous_min_key_ttl_seconds() -> int:\n     try:\n         return max(60, int(os.getenv(\"HERMES_NOUS_MIN_KEY_TTL_SECONDS\", \"1800\")))","@@ -2256,9 +2278,16 @@ def _try_anthropic(explicit_api_key: str = None) -> Tuple[Optional[Any], Optiona\n     if not token:\n         return None, None\n \n-    # Allow base URL override from config.yaml model.base_url, but only\n-    # when the configured provider is anthropic — otherwise a non-Anthropic\n-    # base_url (e.g. Codex endpoint) would leak into Anthropic requests.\n+    # Allow base URL override from config.yaml model.base_url, but only when:\n+    #   1. the configured provider is anthropic (otherwise a non-Anthropic\n+    #      base_url, e.g. Codex endpoint, would leak into Anthropic requests), AND\n+    #   2. the override URL actually points at an Anthropic-compatible endpoint.\n+    # Without gate (2), operators who route main-session traffic through a\n+    # non-Anthropic provider that accepts Anthropic-format requests (e.g.\n+    # OpenRouter at openrouter.ai/api/v1, with provider=anthropic in config.yaml)\n+    # would have every auxiliary side-channel call (memory extractors,\n+    # reflection, vision, title generation) 401 from the foreign host —\n+    # see issue #52608.\n     base_url = _pool_runtime_base_url(entry, _ANTHROPIC_DEFAULT_BASE_URL) if pool_present else _ANTHROPIC_DEFAULT_BASE_URL\n     try:\n         from hermes_cli.config import load_config","@@ -2268,7 +2297,7 @@ def _try_anthropic(explicit_api_key: str = None) -> Tuple[Optional[Any], Optiona\n             cfg_provider = str(model_cfg.get(\"provider\") or \"\").strip().lower()\n             if cfg_provider == \"anthropic\":\n                 cfg_base_url = (model_cfg.get(\"base_url\") or \"\").strip().rstrip(\"/\")\n-                if cfg_base_url:\n+                if cfg_base_url and _is_anthropic_compatible_host(cfg_base_url):\n                     base_url = cfg_base_url\n     except Exception:\n         pass"]},{"file":"tests/agent/test_auxiliary_client_base_url_host_validation_52608.py","hunks":["@@ -0,0 +1,189 @@\n+\"\"\"Regression tests for issue #52608.\n+\n+auxiliary_client `_try_anthropic()` must NOT apply `cfg[\"model\"][\"base_url\"]`\n+when the configured base_url host is not an Anthropic-compatible endpoint\n+(e.g. OpenRouter, OpenAI). Operators routing main traffic through a\n+non-Anthropic provider's endpoint while keeping `provider: anthropic` would\n+otherwise have every side-channel call (memory extractors, reflection,\n+vision, title generation) 401 from the foreign host.\n+\"\"\"\n+from unittest.mock import MagicMock, patch\n+\n+\n+def _extract_base_url_passed_to_build(mock_build):\n+    \"\"\"Pull the base_url that `_try_anthropic()` actually handed to build_anthropic_client.\"\"\"\n+    args, _kwargs = mock_build.call_args\n+    # build_anthropic_client(token, base_url) per agent/auxiliary_client.py line 2180\n+    assert len(args) >= 2, f\"expected (token, base_url), got args={args}\"\n+    return args[1]\n+\n+\n+class TestTryAnthropicBaseUrlHostValidation:\n+    \"\"\"Issue #52608: side-channel calls must not be sent to a non-Anthropic host.\"\"\"\n+\n+    def test_openrouter_base_url_does_not_leak_into_auxiliary(self, tmp_path, monkeypatch):\n+        \"\"\"cfg.model.base_url=https://openrouter.ai/api/v1 must NOT override aux base_url.\"\"\"\n+        import yaml\n+        from agent.auxiliary_client import _try_anthropic\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+        (tmp_path / \"config.yaml\").write_text(yaml.safe_dump({\n+            \"model\": {\n+                \"provider\": \"anthropic\",\n+                \"model\": \"claude-haiku-4-5-20251001\",\n+                \"base_url\": \"https://openrouter.ai/api/v1\",\n+            }\n+        }))\n+\n+        with (\n+            patch(\n+                \"agent.auxiliary_client._select_pool_entry\", return_value=(False, None)\n+            ),\n+            patch(\n+                \"agent.anthropic_adapter.resolve_anthropic_token\",\n+                return_value=\"***\",\n+            ),\n+            patch(\n+                \"agent.anthropic_adapter.build_anthropic_client\"\n+            ) as mock_build,\n+        ):\n+            mock_build.return_value = MagicMock()\n+            client, _model = _try_anthropic()\n+\n+        assert client is not None, \"auxiliary client must still be created\"\n+        actual = _extract_base_url_passed_to_build(mock_build)\n+        assert actual == \"https://api.anthropic.com\", (\n+            f\"Auxiliary client must use the Anthropic default base_url, \"\n+            f\"not the operator's main-session override. Got: {actual!r}\"\n+        )\n+\n+    def test_anthropic_default_host_is_preserved(self, tmp_path, monkeypatch):\n+        \"\"\"The common case (operator sets model.base_url to api.anthropic.com) must still apply.\"\"\"\n+        import yaml\n+        from agent.auxiliary_client import _try_anthropic\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+        (tmp_path / \"config.yaml\").write_text(yaml.safe_dump({\n+            \"model\": {\n+                \"provider\": \"anthropic\",\n+                \"model\": \"claude-haiku-4-5-20251001\",\n+                \"base_url\": \"https://api.anthropic.com\",\n+            }\n+        }))\n+\n+        with (\n+            patch(\n+                \"agent.auxiliary_client._select_pool_entry\", return_value=(False, None)\n+            ),\n+            patch(\n+                \"agent.anthropic_adapter.resolve_anthropic_token\",\n+                return_value=\"***\",\n+            ),\n+            patch(\n+                \"agent.anthropic_adapter.build_anthropic_client\"\n+            ) as mock_build,\n+        ):\n+            mock_build.return_value = MagicMock()\n+            client, _model = _try_anthropic()\n+\n+        assert client is not None\n+        actual = _extract_base_url_passed_to_build(mock_build)\n+        assert actual == \"https://api.anthropic.com\"\n+\n+    def test_openai_base_url_does_not_leak(self, tmp_path, monkeypatch):\n+        \"\"\"Generic non-Anthropic host must not be applied as auxiliary base_url.\"\"\"\n+        import yaml\n+        from agent.auxiliary_client import _try_anthropic\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+        (tmp_path / \"config.yaml\").write_text(yaml.safe_dump({\n+            \"model\": {\n+                \"provider\": \"anthropic\",\n+                \"model\": \"claude-haiku-4-5-20251001\",\n+                \"base_url\": \"https://api.openai.com/v1\",\n+            }\n+        }))\n+\n+        with (\n+            patch(\n+                \"agent.auxiliary_client._select_pool_entry\", return_value=(False, None)\n+            ),\n+            patch(\n+                \"agent.anthropic_adapter.resolve_anthropic_token\",\n+                return_value=\"***\",\n+            ),\n+            patch(\n+                \"agent.anthropic_adapter.build_anthropic_client\"\n+            ) as mock_build,\n+        ):\n+            mock_build.return_value = MagicMock()\n+            client, _model = _try_anthropic()\n+\n+        assert client is not None\n+        actual = _extract_base_url_passed_to_build(mock_build)\n+        assert actual == \"https://api.anthropic.com\", (\n+            f\"Non-Anthropic host must not be applied. Got: {actual!r}\"\n+        )\n+\n+    def test_empty_base_url_falls_back_to_default(self, tmp_path, monkeypatch):\n+        \"\"\"Empty model.base_url must not crash and must fall back to default.\"\"\"\n+        import yaml\n+        from agent.auxiliary_client import _try_anthropic\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+        (tmp_path / \"config.yaml\").write_text(yaml.safe_dump({\n+            \"model\": {\n+                \"provider\": \"anthropic\",\n+                \"model\": \"claude-haiku-4-5-20251001\",\n+                \"base_url\": \"\",\n+            }\n+        }))\n+\n+        with (\n+            patch(\n+                \"agent.auxiliary_client._select_pool_entry\", return_value=(False, None)\n+            ),\n+            patch(\n+                \"agent.anthropic_adapter.resolve_anthropic_token\",\n+                return_value=\"***\",\n+            ),\n+            patch(\n+                \"agent.anthropic_adapter.build_anthropic_client\"\n+            ) as mock_build,\n+        ):\n+            mock_build.return_value = MagicMock()\n+            client, _model = _try_anthropic()\n+\n+        assert client is not None\n+        actual = _extract_base_url_passed_to_build(mock_build)\n+        assert actual == \"https://api.anthropic.com\"\n+\n+    def test_anthropic_host_with_path_is_preserved(self, tmp_path, monkeypatch):\n+        \"\"\"api.anthropic.com with a path suffix must still pass the host check.\"\"\"\n+        import yaml\n+        from agent.auxiliary_client import _try_anthropic\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+        (tmp_path / \"config.yaml\").write_text(yaml.safe_dump({\n+            \"model\": {\n+                \"provider\": \"anthropic\",\n+                \"model\": \"claude-haiku-4-5-20251001\",\n+                \"base_url\": \"https://api.anthropic.com/v1/messages\",\n+            }\n+        }))\n+\n+        with (\n+            patch(\n+                \"agent.auxiliary_client._select_pool_entry\", return_value=(False, None)\n+            ),\n+            patch(\n+                \"agent.anthropic_adapter.resolve_anthropic_token\",\n+                return_value=\"***\",\n+            ),\n+            patch(\n+                \"agent.anthropic_adapter.build_anthropic_client\"\n+            ) as mock_build,\n+        ):\n+            mock_build.return_value = MagicMock()\n+            client, _model = _try_anthropic()\n+\n+        assert client is not None\n+        actual = _extract_base_url_passed_to_build(mock_build)\n+        assert actual == \"https://api.anthropic.com/v1/messages\", (\n+            f\"Anthropic host with path must be preserved. Got: {actual!r}\"\n+        )\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}