mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-08 13:12:08 +00:00
fix(anthropic): OAuth token endpoint UA must not be claude-code/ (login 429, #48534)
hermes auth add anthropic fails 100% at token exchange with HTTP 429 while Claude Code /login succeeds through the same client_id/redirect/scope. The discriminator is the User-Agent on the /v1/oauth/token request. Verified live against platform.claude.com (throwaway code, nothing burned): claude-code/2.1.200 (external, cli) -> 429 rate_limit (Hermes, blocked) Mozilla/5.0 -> 429 rate_limit axios/1.7.9 -> 400 invalid_grant (reached validation) node / empty / SDK-style UAs -> 400 invalid_grant Anthropic now rate-limits token-endpoint requests whose UA starts with claude-code/ (the anti-abuse net for Max-sub-as-API-key). This is the same prefix-block shape that #48534 first hit on claude-cli/, then #56263 dodged by switching to claude-code/ — which held ~2 weeks and is now blocked too. Bumping _CLAUDE_CODE_VERSION_FALLBACK cannot help; the gate is prefix-based. Fix: shared _OAUTH_TOKEN_USER_AGENT (axios/) on the token endpoint only — the two refresh POSTs (refresh_anthropic_oauth_pure) and the login exchange POST (run_hermes_oauth_login_pure). The real Claude Code CLI exchanges the auth code with a bare axios client, NOT its claude-code/ inference UA. The INFERENCE client (build_anthropic_kwargs, /v1/messages) is deliberately left on claude-code/ + x-app: cli — that fingerprint is required there and is NOT throttled on the messages API. Two endpoints, opposite UA requirements. Also isolate two _refresh_oauth_token tests from live ~/.claude creds and update the UA regression tests to assert the split (token endpoint uses a non-claude-code UA while inference keeps claude-code/). Verified E2E: Hermes' own login path now returns 400 (past the 429 wall) instead of 429, using the real _OAUTH_TOKEN_USER_AGENT constant against the live platform.claude.com token endpoint. Salvaged from #57922 (authorize-host + scope changes dropped as non-load-bearing; they only add a redirect hop back to claude.ai and the UA fix alone clears 429).
This commit is contained in:
parent
88f2c0caf6
commit
4c5b4417bb
3 changed files with 56 additions and 26 deletions
|
|
@ -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 @@ _OAUTH_TOKEN_URLS = [
|
|||
"https://console.anthropic.com/v1/oauth/token",
|
||||
]
|
||||
_OAUTH_TOKEN_URL = _OAUTH_TOKEN_URLS[0]
|
||||
# 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 = "axios/1.7.9"
|
||||
_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"
|
||||
|
|
@ -1488,7 +1498,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",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -507,7 +507,8 @@ class TestResolveAnthropicToken:
|
|||
|
||||
|
||||
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)
|
||||
creds = {"accessToken": "expired", "refreshToken": "", "expiresAt": 0}
|
||||
assert _refresh_oauth_token(creds) is None
|
||||
|
||||
|
|
@ -544,7 +545,8 @@ class TestRefreshOauthToken:
|
|||
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)
|
||||
creds = {
|
||||
"accessToken": "old",
|
||||
"refreshToken": "refresh-123",
|
||||
|
|
|
|||
|
|
@ -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 @@ import pytest
|
|||
|
||||
|
||||
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 @@ class TestOAuthUserAgentPrefix:
|
|||
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 @@ class TestOAuthUserAgentPrefix:
|
|||
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"
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue