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

0open issues
0open PRs
7items grouped
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

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #56263 fix(anthropic): tool_use/tool_result adjacency + OAuth claude-code/ UA (salvage #52145 #51948)

    merged to main

    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)

    merged to main

    files: agent/anthropic_adapter.py, tests/agent/test_anthropic_adapter.py, tests/agent/test_anthropic_oauth_ua_prefix.py

    Show 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.00 means 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

At a glance

Subsystem
agent
Merged PRs
#56263, #58178
Closed issues
1
Attempts
4 PRs closed without merging
Fix link checked
4 Jul 2026

For your agent

The same facts, typed.