fix(anthropic): OAuth token endpoint UA must not be claude-code/ (login 429, #48534)

hermes auth add anthropic fails 100% at token exchange with HTTP 429 while
Claude Code /login succeeds through the same client_id/redirect/scope. The
discriminator is the User-Agent on the /v1/oauth/token request.

Verified live against platform.claude.com (throwaway code, nothing burned):
  claude-code/2.1.200 (external, cli)  -> 429 rate_limit   (Hermes, blocked)
  Mozilla/5.0                          -> 429 rate_limit
  axios/1.7.9                          -> 400 invalid_grant (reached validation)
  node / empty / SDK-style UAs         -> 400 invalid_grant

Anthropic now rate-limits token-endpoint requests whose UA starts with
claude-code/ (the anti-abuse net for Max-sub-as-API-key). This is the same
prefix-block shape that #48534 first hit on claude-cli/, then #56263 dodged by
switching to claude-code/ — which held ~2 weeks and is now blocked too. Bumping
_CLAUDE_CODE_VERSION_FALLBACK cannot help; the gate is prefix-based.

Fix: shared _OAUTH_TOKEN_USER_AGENT (axios/) on the token endpoint only — the
two refresh POSTs (refresh_anthropic_oauth_pure) and the login exchange POST
(run_hermes_oauth_login_pure). The real Claude Code CLI exchanges the auth code
with a bare axios client, NOT its claude-code/ inference UA.

The INFERENCE client (build_anthropic_kwargs, /v1/messages) is deliberately left
on claude-code/ + x-app: cli — that fingerprint is required there and is NOT
throttled on the messages API. Two endpoints, opposite UA requirements.

Also isolate two _refresh_oauth_token tests from live ~/.claude creds and update
the UA regression tests to assert the split (token endpoint uses a
non-claude-code UA while inference keeps claude-code/).

Verified E2E: Hermes' own login path now returns 400 (past the 429 wall)
instead of 429, using the real _OAUTH_TOKEN_USER_AGENT constant against the live
platform.claude.com token endpoint.

Salvaged from #57922 (authorize-host + scope changes dropped as non-load-bearing;
they only add a redirect hop back to claude.ai and the UA fix alone clears 429).
This commit is contained in:
Michael Steuer 2026-07-04 15:03:01 +05:30 committed by kshitij
parent 88f2c0caf6
commit 4c5b4417bb
3 changed files with 56 additions and 26 deletions

View file

@ -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",
)
@ -1378,6 +1378,16 @@ _OAUTH_TOKEN_URLS = [
"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"
@ -1488,7 +1498,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",
)

View file

@ -507,7 +507,8 @@ class TestResolveAnthropicToken:
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)
creds = {"accessToken": "expired", "refreshToken": "", "expiresAt": 0}
assert _refresh_oauth_token(creds) is None
@ -544,7 +545,8 @@ class TestRefreshOauthToken:
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)
creds = {
"accessToken": "old",
"refreshToken": "refresh-123",

View file

@ -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
@ -13,10 +22,10 @@ import pytest
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()
@ -47,33 +56,40 @@ class TestOAuthUserAgentPrefix:
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
@ -82,13 +98,15 @@ class TestOAuthUserAgentPrefix:
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"
)