From 2e21139a7a55fdf2d8ad6b45c33ed4c4ad723f14 Mon Sep 17 00:00:00 2001 From: x7peeps Date: Thu, 23 Jul 2026 19:37:05 +0800 Subject: [PATCH] fix(agent): strip x-stainless-* headers from OAuth Anthropic requests to prevent 429 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix #70039 Anthropic's Python SDK automatically injects x-stainless-* headers (language, runtime, OS, package version) into every request. Anthropic's infrastructure uses these headers as a fingerprint to differentiate "Claude Code CLI" (which uses axios) from "third-party SDK" requests. When a subscription OAuth token is used through Hermes, the presence of these SDK headers causes Anthropic to apply stricter rate-limit tiers, resulting in persistent HTTP 429 errors — even when the User-Agent, x-app, and anthropic-beta headers match Claude Code's fingerprint exactly. Fix: For OAuth token auth, route requests through a custom httpx.Client with a request event hook that strips all x-stainless-* headers before they go on the wire. This makes the HTTP request indistinguishable from the genuine Claude Code CLI's traffic, allowing subscription tokens to work through the native adapter. The pattern mirrors the existing Azure Entra ID bearer hook (_build_anthropic_client_with_bearer_hook) already in the codebase. --- agent/anthropic_adapter.py | 45 +++++++++++ tests/agent/test_anthropic_oauth_ua_prefix.py | 75 +++++++++++++++++++ 2 files changed, 120 insertions(+) diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index fd7596e7e3bf..ebfbb2a43aa1 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -724,6 +724,38 @@ def _build_anthropic_client_with_bearer_hook( return _anthropic_sdk.Anthropic(**kwargs) +# ── HTTP client helper for OAuth requests ────────────────────────────── + +def _build_oauth_http_client(timeout): + """Create an httpx.Client that strips ``x-stainless-*`` headers per-request. + + Anthropic's infrastructure uses the ``x-stainless-*`` headers injected by + the SDK (language, runtime, OS, package version) as a fingerprint to + distinguish "Claude Code CLI" requests from "third-party SDK" requests. + Subscription OAuth tokens are rate-limited (HTTP 429) when these SDK + headers are present, even when the UA and other identity headers match + Claude Code's fingerprint. Stripping them lets the request flow through + the same pipeline as the genuine CLI (which uses axios, not the Anthropic + Python SDK). + + The hook is applied at the httpx level so the SDK is unaware of the + removal — exactly the pattern used for the Azure Entra ID bearer hook + in ``_build_anthropic_client_with_bearer_hook``. + """ + import httpx + + client = httpx.Client(timeout=timeout) + + def _strip_stainless(request: httpx.Request) -> None: + """Strip all x-stainless-* headers before the request goes on the wire.""" + to_remove = [k for k in request.headers if k.lower().startswith("x-stainless-")] + for k in to_remove: + del request.headers[k] + + client.event_hooks["request"].append(_strip_stainless) + return client + + def build_anthropic_client( api_key, base_url: str = None, @@ -837,8 +869,21 @@ def build_anthropic_client( # OAuth access token / setup-token → Bearer auth + Claude Code identity. # Anthropic routes OAuth requests based on user-agent and headers; # without Claude Code's fingerprint, requests get intermittent 500s. + # + # Additionally, the Anthropic Python SDK injects ``x-stainless-*`` + # headers (SDK fingerprint: language, runtime, OS, package version) + # into every request. Anthropic's infrastructure uses these headers + # to differentiate "Claude Code CLI" (which uses axios) from + # "third-party SDK" requests. Subscription OAuth tokens are then + # rate-limited (HTTP 429) when the SDK headers are present, even when + # the UA and other identity headers match Claude Code's fingerprint. + # A custom httpx.Client with a request hook strips these headers so + # the request indistinguishable from the genuine CLI's traffic. all_betas = common_betas + _OAUTH_ONLY_BETAS kwargs["auth_token"] = api_key + kwargs["http_client"] = _build_oauth_http_client( + Timeout(timeout=float(_read_timeout), connect=10.0), + ) kwargs["default_headers"] = { "anthropic-beta": ",".join(all_betas), "user-agent": f"claude-code/{_get_claude_code_version()} (external, cli)", diff --git a/tests/agent/test_anthropic_oauth_ua_prefix.py b/tests/agent/test_anthropic_oauth_ua_prefix.py index a0dcc47c84aa..697f8aa69c7b 100644 --- a/tests/agent/test_anthropic_oauth_ua_prefix.py +++ b/tests/agent/test_anthropic_oauth_ua_prefix.py @@ -110,3 +110,78 @@ def test_token_refresh_ua_not_throttled(self): "refresh_anthropic_oauth_pure should send the shared " "_OAUTH_TOKEN_USER_AGENT (non-claude-code) on the token endpoint" ) + + +class TestOAuthStainlessHeaders: + """Regression tests for GH-70039: OAuth subscription tokens get 429 when + Anthropic SDK's x-stainless-* headers leak the client fingerprint. + """ + + def test_oauth_client_strips_stainless_headers(self): + """build_anthropic_client with an OAuth token must provide a custom + http_client that strips x-stainless-* headers from outgoing requests. + + Anthropic uses these SDK headers to differentiate \"Claude Code CLI\" + (axios) from \"third-party SDK\" (Python) requests. Subscription OAuth + tokens are rate-limited (429) when the headers are present. + """ + from agent.anthropic_adapter import _build_oauth_http_client, _is_oauth_token + import httpx + + assert _is_oauth_token("sk-ant-oat-foobar"), "sk-ant-oat* should be OAuth" + assert _is_oauth_token("eyJhbGciOi"), "eyJ* JWT should be OAuth" + assert _is_oauth_token("cc-abc123"), "cc-* should be OAuth" + assert not _is_oauth_token("sk-ant-api03-..."), "sk-ant-api* should NOT be OAuth" + + timeout = httpx.Timeout(timeout=900.0, connect=10.0) + client = _build_oauth_http_client(timeout) + assert client is not None + + # Verify the hook is registered + assert len(client.event_hooks.get("request", [])) >= 1 + hook = client.event_hooks["request"][0] + assert callable(hook) + + # Test the hook directly — simulate what the SDK would attach + mock_request = httpx.Request("POST", "https://api.anthropic.com/v1/messages") + mock_request.headers["x-stainless-lang"] = "python" + mock_request.headers["x-stainless-runtime"] = "CPython" + mock_request.headers["x-stainless-runtime-version"] = "3.11.15" + mock_request.headers["x-stainless-package-version"] = "0.50.0" + mock_request.headers["x-stainless-arch"] = "arm64" + mock_request.headers["x-stainless-os"] = "macOS" + # Keep these — should NOT be stripped + mock_request.headers["user-agent"] = "claude-code/2.1.217 (external, cli)" + mock_request.headers["x-app"] = "cli" + mock_request.headers["anthropic-beta"] = "claude-code-20250219" + + hook(mock_request) + + # Verify all x-stainless-* headers are gone + for key in mock_request.headers: + assert not key.lower().startswith("x-stainless-"), ( + f"x-stainless header should be stripped: {key}" + ) + + # Verify our identity headers are untouched + assert mock_request.headers["user-agent"] == "claude-code/2.1.217 (external, cli)" + assert mock_request.headers["x-app"] == "cli" + assert mock_request.headers["anthropic-beta"] == "claude-code-20250219" + + client.close() + + def test_build_anthropic_client_uses_custom_http_client_for_oauth(self): + """OAuth branch of build_anthropic_client must set http_client kwarg.""" + from agent.anthropic_adapter import build_anthropic_client + + mock_sdk = MagicMock() + with patch("agent.anthropic_adapter._get_anthropic_sdk", return_value=mock_sdk): + build_anthropic_client( + "sk-ant-oat-some-token", + "https://api.anthropic.com", + ) + + call_kwargs = mock_sdk.Anthropic.call_args[1] + assert "http_client" in call_kwargs, ( + "OAuth client must provide a custom http_client to strip x-stainless-* headers" + )