From 1be2799017a505f7886fb1493368618802ff59a2 Mon Sep 17 00:00:00 2001 From: briandevans <252620095+briandevans@users.noreply.github.com> Date: Tue, 12 May 2026 03:11:30 -0700 Subject: [PATCH] fix(anthropic): override SDK User-Agent for third-party endpoints (#24293) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-hosted Anthropic-compatible proxies (e.g. GLM relays, custom Cloudflare-fronted gateways) commonly enable bot-detection rules that 403 the Anthropic Python SDK's default ``Anthropic/Python `` User-Agent. The user-reported workaround was to manually patch ``build_anthropic_client`` to send a non-SDK UA in the third-party branch. This change applies that workaround unconditionally for any ``_is_third_party_anthropic_endpoint`` request, setting ``User-Agent: hermes-agent/``. The pattern mirrors the existing OAuth (``claude-cli/...``) and Kimi-coding (``claude-code/0.1.0``) branches in the same function, which already override the SDK UA for routing reasons. Direct ``anthropic.com`` traffic is unaffected — those requests still ship with the SDK's native UA, which Anthropic's infrastructure relies on for routing. Co-Authored-By: Claude Opus 4.7 (1M context) --- agent/anthropic_adapter.py | 38 +++++++++++++++++++++++---- tests/agent/test_anthropic_adapter.py | 37 +++++++++++++++++++++++--- 2 files changed, 67 insertions(+), 8 deletions(-) diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index a4a211843eed..3dcf5f1cdeed 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -327,6 +327,27 @@ def _get_claude_code_version() -> str: return _claude_code_version_cache +_HERMES_AGENT_VERSION_FALLBACK = "0.0.0" +_hermes_agent_version_cache: Optional[str] = None + + +def _get_hermes_agent_version() -> str: + """Lazily resolve the installed hermes-agent version for User-Agent headers. + + Used when overriding the Anthropic SDK's default ``Anthropic/Python `` + user-agent on third-party endpoints; self-hosted proxies behind Cloudflare + bot-detection 403 the SDK fingerprint, but accept any non-SDK UA. + """ + global _hermes_agent_version_cache + if _hermes_agent_version_cache is None: + try: + from hermes_cli import __version__ as _v + _hermes_agent_version_cache = str(_v) if _v else _HERMES_AGENT_VERSION_FALLBACK + except Exception: + _hermes_agent_version_cache = _HERMES_AGENT_VERSION_FALLBACK + return _hermes_agent_version_cache + + def _is_oauth_token(key: str) -> bool: """Check if the key is an Anthropic OAuth/setup token. @@ -734,13 +755,20 @@ def build_anthropic_client( if common_betas: kwargs["default_headers"] = {"anthropic-beta": ",".join(common_betas)} elif _is_third_party_anthropic_endpoint(base_url): - # Third-party proxies (Microsoft Foundry, AWS Bedrock, etc.) use their - # own API keys with x-api-key auth. Skip OAuth detection — their keys - # don't follow Anthropic's sk-ant-* prefix convention and would be - # misclassified as OAuth tokens. + # Third-party proxies (Microsoft Foundry, AWS Bedrock, self-hosted + # relays, GLM proxies, etc.) use their own API keys with x-api-key + # auth. Skip OAuth detection — their keys don't follow Anthropic's + # sk-ant-* prefix convention and would be misclassified as OAuth + # tokens. + # + # Override the SDK's default ``Anthropic/Python `` user-agent + # because Cloudflare-style WAF bot detection on self-hosted proxies + # 403s the SDK fingerprint by default. (#24293) kwargs["api_key"] = api_key + headers = {"User-Agent": f"hermes-agent/{_get_hermes_agent_version()}"} if common_betas: - kwargs["default_headers"] = {"anthropic-beta": ",".join(common_betas)} + headers["anthropic-beta"] = ",".join(common_betas) + kwargs["default_headers"] = headers elif _is_oauth_token(api_key): # OAuth access token / setup-token → Bearer auth + Claude Code identity. # Anthropic routes OAuth requests based on user-agent and headers; diff --git a/tests/agent/test_anthropic_adapter.py b/tests/agent/test_anthropic_adapter.py index 818a016f4ea5..2dbcbad9b73b 100644 --- a/tests/agent/test_anthropic_adapter.py +++ b/tests/agent/test_anthropic_adapter.py @@ -109,9 +109,40 @@ def test_custom_base_url(self): build_anthropic_client("sk-ant-api03-x", base_url="https://custom.api.com") kwargs = mock_sdk.Anthropic.call_args[1] assert kwargs["base_url"] == "https://custom.api.com" - assert kwargs["default_headers"] == { - "anthropic-beta": "interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14" - } + headers = kwargs["default_headers"] + assert headers["anthropic-beta"] == ( + "interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14" + ) + # Third-party endpoints override the SDK's default User-Agent so + # Cloudflare-style WAF bot detection doesn't 403 the SDK fingerprint. + assert headers["User-Agent"].startswith("hermes-agent/") + + def test_third_party_endpoint_overrides_sdk_user_agent(self): + """Custom-provider endpoints get a hermes-agent UA instead of the + Anthropic SDK's default ``Anthropic/Python ``, which Cloudflare + WAF rules block on self-hosted proxies (#24293).""" + with patch("agent.anthropic_adapter._anthropic_sdk") as mock_sdk: + build_anthropic_client( + "proxy-key-xyz", + base_url="https://proxy.example.com/v1", + ) + kwargs = mock_sdk.Anthropic.call_args[1] + assert kwargs["api_key"] == "proxy-key-xyz" + ua = kwargs["default_headers"]["User-Agent"] + assert ua.startswith("hermes-agent/"), ua + # The Anthropic SDK default fingerprint must NOT leak through. + assert "Anthropic/Python" not in ua + + def test_native_anthropic_endpoint_does_not_override_user_agent(self): + """Direct anthropic.com requests must NOT carry the third-party UA + override — the SDK's default ``Anthropic/Python`` UA is expected by + Anthropic's own infrastructure and routing.""" + with patch("agent.anthropic_adapter._anthropic_sdk") as mock_sdk: + build_anthropic_client("sk-ant-api03-direct") + kwargs = mock_sdk.Anthropic.call_args[1] + headers = kwargs.get("default_headers", {}) or {} + assert "User-Agent" not in headers + assert "user-agent" not in headers def test_azure_anthropic_endpoint_keeps_context_1m_beta(self): with patch("agent.anthropic_adapter._anthropic_sdk") as mock_sdk: