From 49e129e4950d3b53ef8eea5c68b0ce7811f6f95c Mon Sep 17 00:00:00 2001 From: DhivinX <20087092+DhivinX@users.noreply.github.com> Date: Wed, 1 Jul 2026 15:18:46 +0530 Subject: [PATCH] fix(anthropic): use claude-code/ UA prefix for OAuth to avoid 404 (#48534) Anthropic's OAuth endpoints 404 for the claude-cli/ User-Agent prefix. Switch all three OAuth UA sites (build_anthropic_client, refresh_anthropic_oauth_pure, run_hermes_oauth_login_pure) to the claude-code/ prefix Anthropic expects. Salvaged from #51948. Co-authored-by: DhivinX <20087092+DhivinX@users.noreply.github.com> --- agent/anthropic_adapter.py | 8 +- tests/agent/test_anthropic_oauth_ua_prefix.py | 82 +++++++++++++++++++ 2 files changed, 87 insertions(+), 3 deletions(-) create mode 100644 tests/agent/test_anthropic_oauth_ua_prefix.py diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index 254d5e072..c124205c1 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -817,7 +817,7 @@ def build_anthropic_client( kwargs["auth_token"] = api_key kwargs["default_headers"] = { "anthropic-beta": ",".join(all_betas), - "user-agent": f"claude-cli/{_get_claude_code_version()} (external, cli)", + "user-agent": f"claude-code/{_get_claude_code_version()} (external, cli)", "x-app": "cli", } else: @@ -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-cli/{_get_claude_code_version()} (external, cli)", + "User-Agent": f"claude-code/{_get_claude_code_version()} (external, cli)", }, method="POST", ) @@ -1478,6 +1478,8 @@ 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). result = None last_error = None for endpoint in _OAUTH_TOKEN_URLS: @@ -1486,7 +1488,7 @@ def run_hermes_oauth_login_pure() -> Optional[Dict[str, Any]]: data=exchange_data, headers={ "Content-Type": "application/json", - "User-Agent": f"claude-cli/{_get_claude_code_version()} (external, cli)", + "User-Agent": f"claude-code/{_get_claude_code_version()} (external, cli)", }, method="POST", ) diff --git a/tests/agent/test_anthropic_oauth_ua_prefix.py b/tests/agent/test_anthropic_oauth_ua_prefix.py new file mode 100644 index 000000000..5e6ae887f --- /dev/null +++ b/tests/agent/test_anthropic_oauth_ua_prefix.py @@ -0,0 +1,82 @@ +"""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. +""" + +from __future__ import annotations + +import re +from unittest.mock import MagicMock, patch + +import pytest + + +class TestOAuthUserAgentPrefix: + """All OAuth-related HTTP requests must use ``claude-code/`` UA, not ``claude-cli/``.""" + + def test_build_anthropic_client_oauth_ua(self): + """build_anthropic_client with OAuth token must use claude-code UA.""" + from agent.anthropic_adapter import build_anthropic_client + + mock_sdk = MagicMock() + with patch("agent.anthropic_adapter._get_anthropic_sdk", return_value=mock_sdk): + build_anthropic_client("sk-ant-oauth-abc123", "https://api.anthropic.com") + + # Inspect the kwargs passed to Anthropic() + call_kwargs = mock_sdk.Anthropic.call_args[1] + headers = call_kwargs.get("default_headers", {}) + ua = headers.get("user-agent", "") or headers.get("User-Agent", "") + + assert "claude-code/" in ua, f"Expected claude-code/ in UA, got: {ua}" + assert "claude-cli/" not in ua, f"Must not use claude-cli/ prefix: {ua}" + + def test_no_claude_cli_in_source(self): + """Source file must not contain claude-cli/ UA pattern (blocks OAuth).""" + import inspect + import agent.anthropic_adapter as mod + + source = inspect.getsource(mod) + # Allow claude-cli in comments/strings that reference the old behavior + # but not in actual header assignments + lines = source.split("\n") + for i, line in enumerate(lines, 1): + stripped = line.strip() + if "claude-cli/" in stripped and ("User-Agent" in stripped or "user-agent" in stripped): + pytest.fail( + 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.""" + 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") + + assert "claude-cli/" not in source, ( + "run_hermes_oauth_login_pure still uses claude-cli/ UA" + ) + assert "claude-code/" in source, ( + "run_hermes_oauth_login_pure should use claude-code/ UA" + ) + + def test_token_refresh_ua_prefix(self): + """_refresh_oauth_token_raw must not send claude-cli/ UA.""" + import inspect + import agent.anthropic_adapter as mod + + # Find the function that does the actual refresh HTTP call + for name in ("_refresh_oauth_token_raw", "_do_token_refresh", "_refresh_oauth_token"): + func = getattr(mod, name, None) + if func and callable(func): + source = inspect.getsource(func) + if "urllib.request.Request" in source: + assert "claude-cli/" not in source, ( + f"{name} still uses claude-cli/ UA" + ) + break