From 4c5b4417bbb65cfbe92f5a59b8e23dc72c016200 Mon Sep 17 00:00:00 2001 From: Michael Steuer Date: Sat, 4 Jul 2026 15:03:01 +0530 Subject: [PATCH] fix(anthropic): OAuth token endpoint UA must not be claude-code/ (login 429, #48534) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- agent/anthropic_adapter.py | 14 ++++- tests/agent/test_anthropic_adapter.py | 6 +- tests/agent/test_anthropic_oauth_ua_prefix.py | 62 ++++++++++++------- 3 files changed, 56 insertions(+), 26 deletions(-) diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index c124205c178..2cedf51c69d 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -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/``), 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", ) diff --git a/tests/agent/test_anthropic_adapter.py b/tests/agent/test_anthropic_adapter.py index 9610f7fb57c..f2661470a9d 100644 --- a/tests/agent/test_anthropic_adapter.py +++ b/tests/agent/test_anthropic_adapter.py @@ -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", diff --git a/tests/agent/test_anthropic_oauth_ua_prefix.py b/tests/agent/test_anthropic_oauth_ua_prefix.py index 614eddbcddc..a0dcc47c84a 100644 --- a/tests/agent/test_anthropic_oauth_ua_prefix.py +++ b/tests/agent/test_anthropic_oauth_ua_prefix.py @@ -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" )