{"repo":"NousResearch/hermes-agent","public_id":"48534","display_label":"Anthropic OAuth token exchange User-Agent string disputes","summary":"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.","subsystem":"agent","counts":{"members":7,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":2,"attempt_prs":4},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":56263},"resolution":{"state":"resolved","by":"pr-58178"},"freshness":{"state":"verified","last_verified_at":"2026-07-04T09:46:13+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.2857142857142857},"fix_edges":[{"issue_number":48534,"pr_number":56263,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":48534,"pr_number":58178,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":58013,"pr_number":58178,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":48534,"title":"Anthropic Max OAuth fails: token exchange 404s because Anthropic now blocks the claude-cli/ User-Agent","url":null,"state":"closed"},{"kind":"pr","number":48580,"title":"fix(anthropic): use claude-code/ UA prefix for OAuth to avoid 404 (#48534)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#56263","number":56263},{"kind":"pr","value":"#48534","number":48534},{"kind":"pr","value":"#48534","number":48534}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":48687,"title":"fix(oauth): change User-Agent for token exchange (#48534)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#56263","number":56263},{"kind":"pr","value":"#48534","number":48534},{"kind":"pr","value":"#48534","number":48534}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":51948,"title":"fix(anthropic): use claude-code/ UA prefix for OAuth to avoid 404 (#48534)","url":null,"state":"closed","close_reason":{"category":"silent_close","implication":"active"},"merged":false},{"kind":"pr","number":56263,"title":"fix(anthropic): tool_use/tool_result adjacency + OAuth claude-code/ UA (salvage #52145 #51948)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/anthropic_adapter.py","tests/agent/test_anthropic_adapter.py","tests/agent/test_anthropic_oauth_ua_prefix.py"]},{"kind":"pr","number":57922,"title":"fix(anthropic): OAuth token endpoint UA must be axios/, not claude-code/ (login 429, #48534)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#58178","number":58178},{"kind":"pr","value":"#48534","number":48534}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":58178,"title":"fix(anthropic): OAuth token endpoint UA must not be claude-code/ (login 429, #48534)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/anthropic_adapter.py","tests/agent/test_anthropic_adapter.py","tests/agent/test_anthropic_oauth_ua_prefix.py"],"fix_hunks":[{"file":"agent/anthropic_adapter.py","hunks":["@@ -1045,7 +1045,7 @@ def refresh_anthropic_oauth_pure(refresh_token: str, *, use_json: bool = False)\n             data=data,\n             headers={\n                 \"Content-Type\": content_type,\n-                \"User-Agent\": f\"claude-code/{_get_claude_code_version()} (external, cli)\",\n+                \"User-Agent\": _OAUTH_TOKEN_USER_AGENT,\n             },\n             method=\"POST\",\n         )","@@ -1378,6 +1378,16 @@ def run_oauth_setup_token() -> Optional[str]:\n     \"https://console.anthropic.com/v1/oauth/token\",\n ]\n _OAUTH_TOKEN_URL = [redacted:secret-assignment]\n+# User-Agent sent on the OAuth *token endpoint* (login exchange + refresh).\n+# Anthropic rate-limits (HTTP 429) any token-endpoint request whose UA starts\n+# with ``claude-code/`` — verified empirically against platform.claude.com:\n+# ``claude-code/2.1.200`` and ``Mozilla/5.0`` -> 429; ``axios/*``, ``node``,\n+# and SDK-style UAs -> 400 (reached code validation). The real Claude Code CLI\n+# exchanges the auth code with a bare axios client (``axios/<ver>``), NOT its\n+# ``claude-code/`` inference UA. We mirror that here. NOTE: the *inference* path\n+# (build_anthropic_kwargs) still uses the ``claude-code/`` UA + ``x-app: cli`` —\n+# that fingerprint is required there and is NOT throttled on the messages API.\n+_OAUTH_TOKEN_USER_AGENT = \"[redacted:secret-assignment]\"\n _OAUTH_REDIRECT_URI = \"https://console.anthropic.com/oauth/code/callback\"\n _OAUTH_SCOPES = \"org:create_api_key user:profile user:inference\"\n _HERMES_OAUTH_FILE = get_hermes_home() / \".anthropic_oauth.json\"","@@ -1478,8 +1488,9 @@ def run_hermes_oauth_login_pure() -> Optional[Dict[str, Any]]:\n         # Anthropic migrated the OAuth token endpoint to platform.claude.com;\n         # console.anthropic.com now 404s. Try the new host first, then fall\n         # back to console for older deployments (mirrors the refresh path).\n-        # Use the claude-code/ UA prefix: Anthropic blocks claude-cli/ on the\n-        # OAuth token endpoint (returns 404 for all versions).\n+        # UA is _OAUTH_TOKEN_USER_AGENT (a non-claude-code UA) — see the\n+        # constant's definition for why the token endpoint must not send\n+        # claude-code/ (429 UA-prefix block).\n         result = None\n         last_error = None\n         for endpoint in _OAUTH_TOKEN_URLS:","@@ -1488,7 +1499,7 @@ def run_hermes_oauth_login_pure() -> Optional[Dict[str, Any]]:\n                 data=exchange_data,\n                 headers={\n                     \"Content-Type\": \"application/json\",\n-                    \"User-Agent\": f\"claude-code/{_get_claude_code_version()} (external, cli)\",\n+                    \"User-Agent\": _OAUTH_TOKEN_USER_AGENT,\n                 },\n                 method=\"POST\",\n             )"]},{"file":"tests/agent/test_anthropic_adapter.py","hunks":["@@ -507,12 +507,22 @@ def test_keeps_static_anthropic_token_when_only_non_refreshable_claude_key_exist\n \n \n class TestRefreshOauthToken:\n-    def test_returns_none_without_refresh_token(self):\n+    def test_returns_none_without_refresh_token(self, tmp_path, monkeypatch):\n+        monkeypatch.setattr(\"agent.anthropic_adapter.Path.home\", lambda: tmp_path)\n+        # Neutralize live Claude Code sources (macOS Keychain + ~/.claude file)\n+        # so the adopt-already-refreshed branch can't short-circuit with a real\n+        # credential on a dev/CI machine that happens to have Claude Code creds.\n+        monkeypatch.setattr(\n+            \"agent.anthropic_adapter.read_claude_code_credentials\", lambda: None\n+        )\n         creds = {\"accessToken\": \"expired\", \"refreshToken\": \"\", \"expiresAt\": 0}\n         assert _refresh_oauth_token(creds) is None\n \n     def test_successful_refresh(self, tmp_path, monkeypatch):\n         monkeypatch.setattr(\"agent.anthropic_adapter.Path.home\", lambda: tmp_path)\n+        monkeypatch.setattr(\n+            \"agent.anthropic_adapter.read_claude_code_credentials\", lambda: None\n+        )\n \n         creds = {\n             \"accessToken\": \"old-token\",","@@ -544,7 +554,11 @@ def test_successful_refresh(self, tmp_path, monkeypatch):\n         assert written[\"claudeAiOauth\"][\"accessToken\"] == \"new-token-abc\"\n         assert written[\"claudeAiOauth\"][\"refreshToken\"] == \"new-refresh-456\"\n \n-    def test_failed_refresh_returns_none(self):\n+    def test_failed_refresh_returns_none(self, tmp_path, monkeypatch):\n+        monkeypatch.setattr(\"agent.anthropic_adapter.Path.home\", lambda: tmp_path)\n+        monkeypatch.setattr(\n+            \"agent.anthropic_adapter.read_claude_code_credentials\", lambda: None\n+        )\n         creds = {\n             \"accessToken\": \"old\",\n             \"refreshToken\": \"refresh-123\","]},{"file":"tests/agent/test_anthropic_oauth_ua_prefix.py","hunks":["@@ -1,7 +1,16 @@\n \"\"\"Regression tests for the OAuth User-Agent header in anthropic_adapter.py.\n \n-Anthropic now 404s the OAuth token endpoint for any ``claude-cli/`` UA prefix\n-(issue #48534). The adapter must use ``claude-code/`` instead.\n+Two DIFFERENT Anthropic endpoints impose OPPOSITE User-Agent requirements:\n+\n+- Inference (``/v1/messages`` via build_anthropic_client): requires the\n+  ``claude-code/`` UA + ``x-app: cli`` fingerprint, or requests get\n+  intermittent 500s. (issue #48534: ``claude-cli/`` is 404'd here.)\n+- OAuth token endpoint (``/v1/oauth/token`` login exchange + refresh):\n+  Anthropic now RATE-LIMITS (HTTP 429) any UA whose prefix is ``claude-code/``\n+  (or ``Mozilla/``). Verified empirically against platform.claude.com:\n+  ``claude-code/2.1.200`` -> 429; ``axios/*`` / ``node`` -> 400 (reached code\n+  validation). The token endpoint must therefore use a non-``claude-code/`` UA\n+  (we send ``axios/*``, matching the real Claude Code CLI's exchange client).\n \"\"\"\n \n from __future__ import annotations","@@ -13,10 +22,10 @@\n \n \n class TestOAuthUserAgentPrefix:\n-    \"\"\"All OAuth-related HTTP requests must use ``claude-code/`` UA, not ``claude-cli/``.\"\"\"\n+    \"\"\"Inference uses ``claude-code/``; the OAuth token endpoint must NOT.\"\"\"\n \n     def test_build_anthropic_client_oauth_ua(self):\n-        \"\"\"build_anthropic_client with OAuth token must use claude-code UA.\"\"\"\n+        \"\"\"build_anthropic_client (INFERENCE) with OAuth token must use claude-code UA.\"\"\"\n         from agent.anthropic_adapter import build_anthropic_client\n \n         mock_sdk = MagicMock()","@@ -47,33 +56,40 @@ def test_no_claude_cli_in_source(self):\n                     f\"Line {i}: claude-cli/ still used in User-Agent header: {stripped}\"\n                 )\n \n-    def test_token_exchange_ua_prefix(self):\n-        \"\"\"run_hermes_oauth_login_pure must not send claude-cli/ UA.\"\"\"\n+    def test_token_exchange_ua_not_throttled(self):\n+        \"\"\"run_hermes_oauth_login_pure must NOT send a throttled token-endpoint UA.\n+\n+        Anthropic 429s both ``claude-cli/`` and ``claude-code/`` UAs at the\n+        token endpoint. The login exchange must use the shared\n+        ``_OAUTH_TOKEN_USER_AGENT`` constant (a non-claude-code UA).\n+        \"\"\"\n         import inspect\n         import agent.anthropic_adapter as mod\n \n-        # Get the source of the exchange function\n         try:\n             source = inspect.getsource(mod.run_hermes_oauth_login_pure)\n         except AttributeError:\n             pytest.skip(\"run_hermes_oauth_login_pure not found\")\n \n-        # Only fail on claude-cli/ in an actual User-Agent header line — a\n-        # comment that references the old behavior (e.g. \"Anthropic blocks\n-        # claude-cli/ on the OAuth endpoint\") is allowed. Mirrors the\n-        # header-scoped check in test_no_claude_cli_in_source.\n         for i, line in enumerate(source.split(\"\\n\"), 1):\n             stripped = line.strip()\n-            if \"claude-cli/\" in stripped and (\"User-Agent\" in stripped or \"user-agent\" in stripped):\n+            if (\"User-Agent\" in stripped or \"user-agent\" in stripped) and (\n+                \"claude-cli/\" in stripped or \"claude-code/\" in stripped\n+            ):\n                 pytest.fail(\n-                    f\"Line {i}: run_hermes_oauth_login_pure still uses claude-cli/ UA header: {stripped}\"\n+                    f\"Line {i}: throttled UA in token-exchange header: {stripped}\"\n                 )\n-        assert \"claude-code/\" in source, (\n-            \"run_hermes_oauth_login_pure should use claude-code/ UA\"\n+        assert \"_OAUTH_TOKEN_USER_AGENT\" in source, (\n+            \"run_hermes_oauth_login_pure should send the shared \"\n+            \"_OAUTH_TOKEN_USER_AGENT (non-claude-code) on the token endpoint\"\n+        )\n+        assert not mod._OAUTH_TOKEN_USER_AGENT.startswith((\"claude-code/\", \"claude-cli/\")), (\n+            f\"_OAUTH_TOKEN_USER_AGENT must not be a throttled prefix: \"\n+            f\"{mod._OAUTH_TOKEN_USER_AGENT!r}\"\n         )\n \n-    def test_token_refresh_ua_prefix(self):\n-        \"\"\"refresh_anthropic_oauth_pure must not send claude-cli/ UA.\"\"\"\n+    def test_token_refresh_ua_not_throttled(self):\n+        \"\"\"refresh_anthropic_oauth_pure must NOT send a throttled token-endpoint UA.\"\"\"\n         import inspect\n         import agent.anthropic_adapter as mod\n ","@@ -82,13 +98,15 @@ def test_token_refresh_ua_prefix(self):\n             pytest.skip(\"refresh_anthropic_oauth_pure not found\")\n         source = inspect.getsource(func)\n \n-        # Header-scoped check (comments referencing claude-cli/ are allowed).\n         for i, line in enumerate(source.split(\"\\n\"), 1):\n             stripped = line.strip()\n-            if \"claude-cli/\" in stripped and (\"User-Agent\" in stripped or \"user-agent\" in stripped):\n+            if (\"User-Agent\" in stripped or \"user-agent\" in stripped) and (\n+                \"claude-cli/\" in stripped or \"claude-code/\" in stripped\n+            ):\n                 pytest.fail(\n-                    f\"Line {i}: refresh_anthropic_oauth_pure still uses claude-cli/ UA header: {stripped}\"\n+                    f\"Line {i}: throttled UA in refresh header: {stripped}\"\n                 )\n-        assert \"claude-code/\" in source, (\n-            \"refresh_anthropic_oauth_pure should use claude-code/ UA\"\n+        assert \"_OAUTH_TOKEN_USER_AGENT\" in source, (\n+            \"refresh_anthropic_oauth_pure should send the shared \"\n+            \"_OAUTH_TOKEN_USER_AGENT (non-claude-code) on the token endpoint\"\n         )\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}