Anthropic OAuth token exchange User-Agent string disputes
NousResearch/hermes-agent · cluster 48534 · agent
Data as of
Nothing open on GitHub. Fix merged in #56263. Check it covers your case.
The short answer
- Known?
- Yes. 7 related items grouped here, 1 issue already closed.
- Fix event
- A fix was merged to main in PR #56263. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
resolved, deciding PR #58178. That is separate from the fix event. - Still open
- Nothing open on GitHub.
Multiple conflicting attempts to determine the correct User-Agent header for Anthropic OAuth token exchange, after the original value began causing 404 or 429 errors. Proposed values include axios/, claude-code/, and others, with no clear consensus across the members.
Open issues0
Open PRs0
Fixes on record
-
#56263 fix(anthropic): tool_use/tool_result adjacency + OAuth claude-code/ UA (salvage #52145 #51948)
files:
agent/anthropic_adapter.py,tests/agent/test_anthropic_adapter.py,tests/agent/test_anthropic_oauth_ua_prefix.py -
#58178 fix(anthropic): OAuth token endpoint UA must not be claude-code/ (login 429, #48534)
files:
agent/anthropic_adapter.py,tests/agent/test_anthropic_adapter.py,tests/agent/test_anthropic_oauth_ua_prefix.pyShow the diff
-
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 @@ def run_oauth_setup_token() -> Optional[str]: "https://console.anthropic.com/v1/oauth/token", ] _OAUTH_TOKEN_URL = [redacted:secret-assignment] +# 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 = "[redacted:secret-assignment]" _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" @@ -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: @@ -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", ) -
tests/agent/test_anthropic_adapter.py@@ -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", @@ -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", -
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 @@ 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 @@ 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 @@ -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" )
-
4 PRs closed without merging
From the close comments: superseded by another PR 2 · closed without comment 1 · merged via another change 1
-
#48580 fix(anthropic): use claude-code/ UA prefix for OAuth to avoid 404 (#48534) · superseded by another PR →
#56263,#48534 -
#48687 fix(oauth): change User-Agent for token exchange (#48534) · superseded by another PR →
#56263,#48534 - #51948 fix(anthropic): use claude-code/ UA prefix for OAuth to avoid 404 (#48534) · closed without comment
-
#57922 fix(anthropic): OAuth token endpoint UA must be axios/, not claude-code/ (login 429, #48534) · merged via another change →
#58178,#48534
Closed issues · 1
- Closed#48534Anthropic Max OAuth fails: token exchange 404s because Anthropic now blocks the claude-cli/ User-Agent
How this cluster was grouped
-
Link evidence (
structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference. -
Pairwise link density
0.29: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the Lab