mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-15 14:22:43 +00:00
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).
112 lines
4.9 KiB
Python
112 lines
4.9 KiB
Python
"""Regression tests for the OAuth User-Agent header in anthropic_adapter.py.
|
|
|
|
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
|
|
|
|
import re
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
|
|
class TestOAuthUserAgentPrefix:
|
|
"""Inference uses ``claude-code/``; the OAuth token endpoint must NOT."""
|
|
|
|
def test_build_anthropic_client_oauth_ua(self):
|
|
"""build_anthropic_client (INFERENCE) 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_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
|
|
|
|
try:
|
|
source = inspect.getsource(mod.run_hermes_oauth_login_pure)
|
|
except AttributeError:
|
|
pytest.skip("run_hermes_oauth_login_pure not found")
|
|
|
|
for i, line in enumerate(source.split("\n"), 1):
|
|
stripped = line.strip()
|
|
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}: throttled UA in token-exchange header: {stripped}"
|
|
)
|
|
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_not_throttled(self):
|
|
"""refresh_anthropic_oauth_pure must NOT send a throttled token-endpoint UA."""
|
|
import inspect
|
|
import agent.anthropic_adapter as mod
|
|
|
|
func = getattr(mod, "refresh_anthropic_oauth_pure", None)
|
|
if func is None or not callable(func):
|
|
pytest.skip("refresh_anthropic_oauth_pure not found")
|
|
source = inspect.getsource(func)
|
|
|
|
for i, line in enumerate(source.split("\n"), 1):
|
|
stripped = line.strip()
|
|
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}: throttled UA in refresh header: {stripped}"
|
|
)
|
|
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"
|
|
)
|