# NousResearch/hermes-agent · Anthropic OAuth token exchange User-Agent string disputes

## Open now

- 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.

## TL;DR

- 7 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 2 merged PR(s), 4 PR(s) closed without merging.
- A fix was merged to main in PR #56263. Verify it addresses your case.
- **Recommended:** read PR #56263 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `resolved` · deciding PR(s): `pr-58178`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.29`: the share of member 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.

## Closed / duplicate issues

- #48534: Anthropic Max OAuth fails: token exchange 404s because Anthropic now blocks the claude-cli/ User-Agent

## Prior & competing PRs

- **#56263: fix(anthropic): tool_use/tool_result adjacency + OAuth claude-code/ UA (salvage #52145 #51948)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - 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 · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `agent/anthropic_adapter.py`, `tests/agent/test_anthropic_adapter.py`, `tests/agent/test_anthropic_oauth_ua_prefix.py`
  - fix hunks:
  - `agent/anthropic_adapter.py`

    ```diff
    @@ -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",
             )
    ```

    ```diff
    @@ -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"
    ```

    ```diff
    @@ -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:
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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",
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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()
    ```

    ```diff
    @@ -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
     
    ```

    ```diff
    @@ -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"
             )
    ```
- **#48580: fix(anthropic): use claude-code/ UA prefix for OAuth to avoid 404 (#48534)**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#56263`, `#48534`, `#48534`
- **#48687: fix(oauth): change User-Agent for token exchange (#48534)**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#56263`, `#48534`, `#48534`
- **#51948: fix(anthropic): use claude-code/ UA prefix for OAuth to avoid 404 (#48534)**. closed without merge
  - maintainer direction: category `silent_close`; implication `active`
- **#57922: fix(anthropic): OAuth token endpoint UA must be axios/, not claude-code/ (login 429, #48534)**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#58178`, `#48534`

## Fix status

- Merged to main in PR #56263 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-07-04`.
- Subsystem: `agent`
