Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions agent/anthropic_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)",
Expand Down
75 changes: 75 additions & 0 deletions tests/agent/test_anthropic_oauth_ua_prefix.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Loading