Skip to content
Merged
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
19 changes: 15 additions & 4 deletions agent/anthropic_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -1045,7 +1045,7 @@ def refresh_anthropic_oauth_pure(refresh_token: str, *, use_json: bool = False)
data=data,
headers={
"Content-Type": content_type,
"User-Agent": f"claude-code/{_get_claude_code_version()} (external, cli)",
"User-Agent": _OAUTH_TOKEN_USER_AGENT,
},
method="POST",
)
Expand Down Expand Up @@ -1378,6 +1378,16 @@ def run_oauth_setup_token() -> Optional[str]:
"https://console.anthropic.com/v1/oauth/token",
]
_OAUTH_TOKEN_URL = _OAUTH_TOKEN_URLS[0]
# User-Agent sent on the OAuth *token endpoint* (login exchange + refresh).
# Anthropic rate-limits (HTTP 429) any token-endpoint request whose UA starts
# with ``claude-code/`` — verified empirically against platform.claude.com:
# ``claude-code/2.1.200`` and ``Mozilla/5.0`` -> 429; ``axios/*``, ``node``,
# and SDK-style UAs -> 400 (reached code validation). The real Claude Code CLI
# exchanges the auth code with a bare axios client (``axios/<ver>``), NOT its
# ``claude-code/`` inference UA. We mirror that here. NOTE: the *inference* path
# (build_anthropic_kwargs) still uses the ``claude-code/`` UA + ``x-app: cli`` —
# that fingerprint is required there and is NOT throttled on the messages API.
_OAUTH_TOKEN_USER_AGENT = "axios/1.7.9"
_OAUTH_REDIRECT_URI = "https://console.anthropic.com/oauth/code/callback"
_OAUTH_SCOPES = "org:create_api_key user:profile user:inference"
_HERMES_OAUTH_FILE = get_hermes_home() / ".anthropic_oauth.json"
Expand Down Expand Up @@ -1478,8 +1488,9 @@ def run_hermes_oauth_login_pure() -> Optional[Dict[str, Any]]:
# Anthropic migrated the OAuth token endpoint to platform.claude.com;
# console.anthropic.com now 404s. Try the new host first, then fall
# back to console for older deployments (mirrors the refresh path).
# Use the claude-code/ UA prefix: Anthropic blocks claude-cli/ on the
# OAuth token endpoint (returns 404 for all versions).
# UA is _OAUTH_TOKEN_USER_AGENT (a non-claude-code UA) — see the
# constant's definition for why the token endpoint must not send
# claude-code/ (429 UA-prefix block).
result = None
last_error = None
for endpoint in _OAUTH_TOKEN_URLS:
Expand All @@ -1488,7 +1499,7 @@ def run_hermes_oauth_login_pure() -> Optional[Dict[str, Any]]:
data=exchange_data,
headers={
"Content-Type": "application/json",
"User-Agent": f"claude-code/{_get_claude_code_version()} (external, cli)",
"User-Agent": _OAUTH_TOKEN_USER_AGENT,
},
method="POST",
)
Expand Down
18 changes: 16 additions & 2 deletions tests/agent/test_anthropic_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -507,12 +507,22 @@ def test_keeps_static_anthropic_token_when_only_non_refreshable_claude_key_exist


class TestRefreshOauthToken:
def test_returns_none_without_refresh_token(self):
def test_returns_none_without_refresh_token(self, tmp_path, monkeypatch):
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
# Neutralize live Claude Code sources (macOS Keychain + ~/.claude file)
# so the adopt-already-refreshed branch can't short-circuit with a real
# credential on a dev/CI machine that happens to have Claude Code creds.
monkeypatch.setattr(
"agent.anthropic_adapter.read_claude_code_credentials", lambda: None
)
creds = {"accessToken": "expired", "refreshToken": "", "expiresAt": 0}
assert _refresh_oauth_token(creds) is None

def test_successful_refresh(self, tmp_path, monkeypatch):
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
monkeypatch.setattr(
"agent.anthropic_adapter.read_claude_code_credentials", lambda: None
)

creds = {
"accessToken": "old-token",
Expand Down Expand Up @@ -544,7 +554,11 @@ def test_successful_refresh(self, tmp_path, monkeypatch):
assert written["claudeAiOauth"]["accessToken"] == "new-token-abc"
assert written["claudeAiOauth"]["refreshToken"] == "new-refresh-456"

def test_failed_refresh_returns_none(self):
def test_failed_refresh_returns_none(self, tmp_path, monkeypatch):
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
monkeypatch.setattr(
"agent.anthropic_adapter.read_claude_code_credentials", lambda: None
)
creds = {
"accessToken": "old",
"refreshToken": "refresh-123",
Expand Down
62 changes: 40 additions & 22 deletions tests/agent/test_anthropic_oauth_ua_prefix.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
"""Regression tests for the OAuth User-Agent header in anthropic_adapter.py.

Anthropic now 404s the OAuth token endpoint for any ``claude-cli/`` UA prefix
(issue #48534). The adapter must use ``claude-code/`` instead.
Two DIFFERENT Anthropic endpoints impose OPPOSITE User-Agent requirements:

- Inference (``/v1/messages`` via build_anthropic_client): requires the
``claude-code/`` UA + ``x-app: cli`` fingerprint, or requests get
intermittent 500s. (issue #48534: ``claude-cli/`` is 404'd here.)
- OAuth token endpoint (``/v1/oauth/token`` login exchange + refresh):
Anthropic now RATE-LIMITS (HTTP 429) any UA whose prefix is ``claude-code/``
(or ``Mozilla/``). Verified empirically against platform.claude.com:
``claude-code/2.1.200`` -> 429; ``axios/*`` / ``node`` -> 400 (reached code
validation). The token endpoint must therefore use a non-``claude-code/`` UA
(we send ``axios/*``, matching the real Claude Code CLI's exchange client).
"""

from __future__ import annotations
Expand All @@ -13,10 +22,10 @@


class TestOAuthUserAgentPrefix:
"""All OAuth-related HTTP requests must use ``claude-code/`` UA, not ``claude-cli/``."""
"""Inference uses ``claude-code/``; the OAuth token endpoint must NOT."""

def test_build_anthropic_client_oauth_ua(self):
"""build_anthropic_client with OAuth token must use claude-code UA."""
"""build_anthropic_client (INFERENCE) with OAuth token must use claude-code UA."""
from agent.anthropic_adapter import build_anthropic_client

mock_sdk = MagicMock()
Expand Down Expand Up @@ -47,33 +56,40 @@ def test_no_claude_cli_in_source(self):
f"Line {i}: claude-cli/ still used in User-Agent header: {stripped}"
)

def test_token_exchange_ua_prefix(self):
"""run_hermes_oauth_login_pure must not send claude-cli/ UA."""
def test_token_exchange_ua_not_throttled(self):
"""run_hermes_oauth_login_pure must NOT send a throttled token-endpoint UA.

Anthropic 429s both ``claude-cli/`` and ``claude-code/`` UAs at the
token endpoint. The login exchange must use the shared
``_OAUTH_TOKEN_USER_AGENT`` constant (a non-claude-code UA).
"""
import inspect
import agent.anthropic_adapter as mod

# Get the source of the exchange function
try:
source = inspect.getsource(mod.run_hermes_oauth_login_pure)
except AttributeError:
pytest.skip("run_hermes_oauth_login_pure not found")

# Only fail on claude-cli/ in an actual User-Agent header line — a
# comment that references the old behavior (e.g. "Anthropic blocks
# claude-cli/ on the OAuth endpoint") is allowed. Mirrors the
# header-scoped check in test_no_claude_cli_in_source.
for i, line in enumerate(source.split("\n"), 1):
stripped = line.strip()
if "claude-cli/" in stripped and ("User-Agent" in stripped or "user-agent" in stripped):
if ("User-Agent" in stripped or "user-agent" in stripped) and (
"claude-cli/" in stripped or "claude-code/" in stripped
):
pytest.fail(
f"Line {i}: run_hermes_oauth_login_pure still uses claude-cli/ UA header: {stripped}"
f"Line {i}: throttled UA in token-exchange header: {stripped}"
)
assert "claude-code/" in source, (
"run_hermes_oauth_login_pure should use claude-code/ UA"
assert "_OAUTH_TOKEN_USER_AGENT" in source, (
"run_hermes_oauth_login_pure should send the shared "
"_OAUTH_TOKEN_USER_AGENT (non-claude-code) on the token endpoint"
)
assert not mod._OAUTH_TOKEN_USER_AGENT.startswith(("claude-code/", "claude-cli/")), (
f"_OAUTH_TOKEN_USER_AGENT must not be a throttled prefix: "
f"{mod._OAUTH_TOKEN_USER_AGENT!r}"
)

def test_token_refresh_ua_prefix(self):
"""refresh_anthropic_oauth_pure must not send claude-cli/ UA."""
def test_token_refresh_ua_not_throttled(self):
"""refresh_anthropic_oauth_pure must NOT send a throttled token-endpoint UA."""
import inspect
import agent.anthropic_adapter as mod

Expand All @@ -82,13 +98,15 @@ def test_token_refresh_ua_prefix(self):
pytest.skip("refresh_anthropic_oauth_pure not found")
source = inspect.getsource(func)

# Header-scoped check (comments referencing claude-cli/ are allowed).
for i, line in enumerate(source.split("\n"), 1):
stripped = line.strip()
if "claude-cli/" in stripped and ("User-Agent" in stripped or "user-agent" in stripped):
if ("User-Agent" in stripped or "user-agent" in stripped) and (
"claude-cli/" in stripped or "claude-code/" in stripped
):
pytest.fail(
f"Line {i}: refresh_anthropic_oauth_pure still uses claude-cli/ UA header: {stripped}"
f"Line {i}: throttled UA in refresh header: {stripped}"
)
assert "claude-code/" in source, (
"refresh_anthropic_oauth_pure should use claude-code/ UA"
assert "_OAUTH_TOKEN_USER_AGENT" in source, (
"refresh_anthropic_oauth_pure should send the shared "
"_OAUTH_TOKEN_USER_AGENT (non-claude-code) on the token endpoint"
)
Loading