# NousResearch/hermes-agent · Anthropic auxiliary client base_url routing bug fixes

## Open now

- Nothing open on GitHub.

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.

## TL;DR

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

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `resolved` · deciding PR(s): `pr-52891`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.67`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #52608: auxiliary_client: _try_anthropic() misroutes side-channel calls when main config has provider=anthropic with a non-Anthropic base_url

## Prior & competing PRs

- **#52623: fix(auxiliary): gate Anthropic base_url override on Anthropic-compatible host (#52608)**. closed without merge · fix tier `suggested`
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `#52891`, `#52888`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#52891: fix(auxiliary): gate Anthropic base_url override on Anthropic-compatible host (#52608)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `agent/auxiliary_client.py`, `tests/agent/test_auxiliary_client_base_url_host_validation_52608.py`
  - fix hunks:
  - `agent/auxiliary_client.py`

    ```diff
    @@ -666,6 +666,28 @@ def _pool_runtime_base_url(entry: Any, fallback: str = "") -> str:
         return str(url or "").strip().rstrip("/")
     
     
    +# Hostnames (lowercase, exact) that the auxiliary Anthropic path is allowed to
    +# be pointed at via config.yaml model.base_url. Anything else falls back to the
    +# Anthropic default — operators routing main-session traffic through a
    +# non-Anthropic host (e.g. OpenRouter, OpenAI) with provider=anthropic in config
    +# must NOT have that foreign host leak into the auxiliary client. See #52608.
    +_ANTHROPIC_COMPATIBLE_HOSTS = frozenset({
    +    "api.anthropic.com",
    +})
    +
    +
    +def _is_anthropic_compatible_host(url: str) -> bool:
    +    """Return True if ``url``'s hostname is an Anthropic endpoint we trust for aux calls."""
    +    if not url:
    +        return False
    +    try:
    +        from urllib.parse import urlparse
    +        host = (urlparse(url).hostname or "").strip().lower().rstrip(".")
    +        return host in _ANTHROPIC_COMPATIBLE_HOSTS
    +    except Exception:
    +        return False
    +
    +
     def _nous_min_key_ttl_seconds() -> int:
         try:
             return max(60, int(os.getenv("HERMES_NOUS_MIN_KEY_TTL_SECONDS", "1800")))
    ```

    ```diff
    @@ -2256,9 +2278,16 @@ def _try_anthropic(explicit_api_key: str = None) -> Tuple[Optional[Any], Optiona
         if not token:
             return None, None
     
    -    # Allow base URL override from config.yaml model.base_url, but only
    -    # when the configured provider is anthropic — otherwise a non-Anthropic
    -    # base_url (e.g. Codex endpoint) would leak into Anthropic requests.
    +    # Allow base URL override from config.yaml model.base_url, but only when:
    +    #   1. the configured provider is anthropic (otherwise a non-Anthropic
    +    #      base_url, e.g. Codex endpoint, would leak into Anthropic requests), AND
    +    #   2. the override URL actually points at an Anthropic-compatible endpoint.
    +    # Without gate (2), operators who route main-session traffic through a
    +    # non-Anthropic provider that accepts Anthropic-format requests (e.g.
    +    # OpenRouter at openrouter.ai/api/v1, with provider=anthropic in config.yaml)
    +    # would have every auxiliary side-channel call (memory extractors,
    +    # reflection, vision, title generation) 401 from the foreign host —
    +    # see issue #52608.
         base_url = _pool_runtime_base_url(entry, _ANTHROPIC_DEFAULT_BASE_URL) if pool_present else _ANTHROPIC_DEFAULT_BASE_URL
         try:
             from hermes_cli.config import load_config
    ```

    ```diff
    @@ -2268,7 +2297,7 @@ def _try_anthropic(explicit_api_key: str = None) -> Tuple[Optional[Any], Optiona
                 cfg_provider = str(model_cfg.get("provider") or "").strip().lower()
                 if cfg_provider == "anthropic":
                     cfg_base_url = (model_cfg.get("base_url") or "").strip().rstrip("/")
    -                if cfg_base_url:
    +                if cfg_base_url and _is_anthropic_compatible_host(cfg_base_url):
                         base_url = cfg_base_url
         except Exception:
             pass
    ```
  - `tests/agent/test_auxiliary_client_base_url_host_validation_52608.py`

    ```diff
    @@ -0,0 +1,189 @@
    +"""Regression tests for issue #52608.
    +
    +auxiliary_client `_try_anthropic()` must NOT apply `cfg["model"]["base_url"]`
    +when the configured base_url host is not an Anthropic-compatible endpoint
    +(e.g. OpenRouter, OpenAI). Operators routing main traffic through a
    +non-Anthropic provider's endpoint while keeping `provider: anthropic` would
    +otherwise have every side-channel call (memory extractors, reflection,
    +vision, title generation) 401 from the foreign host.
    +"""
    +from unittest.mock import MagicMock, patch
    +
    +
    +def _extract_base_url_passed_to_build(mock_build):
    +    """Pull the base_url that `_try_anthropic()` actually handed to build_anthropic_client."""
    +    args, _kwargs = mock_build.call_args
    +    # build_anthropic_client(token, base_url) per agent/auxiliary_client.py line 2180
    +    assert len(args) >= 2, f"expected (token, base_url), got args={args}"
    +    return args[1]
    +
    +
    +class TestTryAnthropicBaseUrlHostValidation:
    +    """Issue #52608: side-channel calls must not be sent to a non-Anthropic host."""
    +
    +    def test_openrouter_base_url_does_not_leak_into_auxiliary(self, tmp_path, monkeypatch):
    +        """cfg.model.base_url=https://openrouter.ai/api/v1 must NOT override aux base_url."""
    +        import yaml
    +        from agent.auxiliary_client import _try_anthropic
    +        monkeypatch.setenv("HERMES_HOME", str(tmp_path))
    +        (tmp_path / "config.yaml").write_text(yaml.safe_dump({
    +            "model": {
    +                "provider": "anthropic",
    +                "model": "claude-haiku-4-5-20251001",
    +                "base_url": "https://openrouter.ai/api/v1",
    +            }
    +        }))
    +
    +        with (
    +            patch(
    +                "agent.auxiliary_client._select_pool_entry", return_value=(False, None)
    +            ),
    +            patch(
    +                "agent.anthropic_adapter.resolve_anthropic_token",
    +                return_value="***",
    +            ),
    +            patch(
    +                "agent.anthropic_adapter.build_anthropic_client"
    +            ) as mock_build,
    +        ):
    +            mock_build.return_value = MagicMock()
    +            client, _model = _try_anthropic()
    +
    +        assert client is not None, "auxiliary client must still be created"
    +        actual = _extract_base_url_passed_to_build(mock_build)
    +        assert actual == "https://api.anthropic.com", (
    +            f"Auxiliary client must use the Anthropic default base_url, "
    +            f"not the operator's main-session override. Got: {actual!r}"
    +        )
    +
    +    def test_anthropic_default_host_is_preserved(self, tmp_path, monkeypatch):
    +        """The common case (operator sets model.base_url to api.anthropic.com) must still apply."""
    +        import yaml
    +        from agent.auxiliary_client import _try_anthropic
    +        monkeypatch.setenv("HERMES_HOME", str(tmp_path))
    +        (tmp_path / "config.yaml").write_text(yaml.safe_dump({
    +            "model": {
    +                "provider": "anthropic",
    +                "model": "claude-haiku-4-5-20251001",
    +                "base_url": "https://api.anthropic.com",
    +            }
    +        }))
    +
    +        with (
    +            patch(
    +                "agent.auxiliary_client._select_pool_entry", return_value=(False, None)
    +            ),
    +            patch(
    +                "agent.anthropic_adapter.resolve_anthropic_token",
    +                return_value="***",
    +            ),
    +            patch(
    +                "agent.anthropic_adapter.build_anthropic_client"
    +            ) as mock_build,
    +        ):
    +            mock_build.return_value = MagicMock()
    +            client, _model = _try_anthropic()
    +
    +        assert client is not None
    +        actual = _extract_base_url_passed_to_build(mock_build)
    +        assert actual == "https://api.anthropic.com"
    +
    +    def test_openai_base_url_does_not_leak(self, tmp_path, monkeypatch):
    +        """Generic non-Anthropic host must not be applied as auxiliary base_url."""
    +        import yaml
    +        from agent.auxiliary_client import _try_anthropic
    +        monkeypatch.setenv("HERMES_HOME", str(tmp_path))
    +        (tmp_path / "config.yaml").write_text(yaml.safe_dump({
    +            "model": {
    +                "provider": "anthropic",
    +                "model": "claude-haiku-4-5-20251001",
    +                "base_url": "https://api.openai.com/v1",
    +            }
    +        }))
    +
    +        with (
    +            patch(
    +                "agent.auxiliary_client._select_pool_entry", return_value=(False, None)
    +            ),
    +            patch(
    +                "agent.anthropic_adapter.resolve_anthropic_token",
    +                return_value="***",
    +            ),
    +            patch(
    +                "agent.anthropic_adapter.build_anthropic_client"
    +            ) as mock_build,
    +        ):
    +            mock_build.return_value = MagicMock()
    +            client, _model = _try_anthropic()
    +
    +        assert client is not None
    +        actual = _extract_base_url_passed_to_build(mock_build)
    +        assert actual == "https://api.anthropic.com", (
    +            f"Non-Anthropic host must not be applied. Got: {actual!r}"
    +        )
    +
    +    def test_empty_base_url_falls_back_to_default(self, tmp_path, monkeypatch):
    +        """Empty model.base_url must not crash and must fall back to default."""
    +        import yaml
    +        from agent.auxiliary_client import _try_anthropic
    +        monkeypatch.setenv("HERMES_HOME", str(tmp_path))
    +        (tmp_path / "config.yaml").write_text(yaml.safe_dump({
    +            "model": {
    +                "provider": "anthropic",
    +                "model": "claude-haiku-4-5-20251001",
    +                "base_url": "",
    +            }
    +        }))
    +
    +        with (
    +            patch(
    +                "agent.auxiliary_client._select_pool_entry", return_value=(False, None)
    +            ),
    +            patch(
    +                "agent.anthropic_adapter.resolve_anthropic_token",
    +                return_value="***",
    +            ),
    +            patch(
    +                "agent.anthropic_adapter.build_anthropic_client"
    +            ) as mock_build,
    +        ):
    +            mock_build.return_value = MagicMock()
    +            client, _model = _try_anthropic()
    +
    +        assert client is not None
    +        actual = _extract_base_url_passed_to_build(mock_build)
    +        assert actual == "https://api.anthropic.com"
    +
    +    def test_anthropic_host_with_path_is_preserved(self, tmp_path, monkeypatch):
    +        """api.anthropic.com with a path suffix must still pass the host check."""
    +        import yaml
    +        from agent.auxiliary_client import _try_anthropic
    +        monkeypatch.setenv("HERMES_HOME", str(tmp_path))
    +        (tmp_path / "config.yaml").write_text(yaml.safe_dump({
    +            "model": {
    +                "provider": "anthropic",
    +                "model": "claude-haiku-4-5-20251001",
    +                "base_url": "https://api.anthropic.com/v1/messages",
    +            }
    +        }))
    +
    +        with (
    +            patch(
    +                "agent.auxiliary_client._select_pool_entry", return_value=(False, None)
    +            ),
    +            patch(
    +                "agent.anthropic_adapter.resolve_anthropic_token",
    +                return_value="***",
    +            ),
    +            patch(
    +                "agent.anthropic_adapter.build_anthropic_client"
    +            ) as mock_build,
    +        ):
    +            mock_build.return_value = MagicMock()
    +            client, _model = _try_anthropic()
    +
    +        assert client is not None
    +        actual = _extract_base_url_passed_to_build(mock_build)
    +        assert actual == "https://api.anthropic.com/v1/messages", (
    +            f"Anthropic host with path must be preserved. Got: {actual!r}"
    +        )
    ```

## Fix status

- Merged to main in PR #52891 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-06-26`.
