mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-29 23:41:35 +08:00
183 lines
7.3 KiB
Python
183 lines
7.3 KiB
Python
|
|
"""Tests for ``_is_anthropic_oauth`` guard against third-party Anthropic-compatible providers.
|
||
|
|
|
||
|
|
The invariant: ``self._is_anthropic_oauth`` must only ever be True when
|
||
|
|
``self.provider == 'anthropic'`` (native Anthropic). Third-party providers
|
||
|
|
that speak the Anthropic protocol (MiniMax, Zhipu GLM, Alibaba DashScope,
|
||
|
|
Kimi, LiteLLM proxies, etc.) must never trip OAuth code paths — doing so
|
||
|
|
injects Claude-Code identity headers and system prompts that cause
|
||
|
|
401/403 from those endpoints.
|
||
|
|
|
||
|
|
This test class covers all FIVE sites that assign ``_is_anthropic_oauth``:
|
||
|
|
|
||
|
|
1. ``AIAgent.__init__`` (line ~1022)
|
||
|
|
2. ``AIAgent.switch_model`` (line ~1832)
|
||
|
|
3. ``AIAgent._try_refresh_anthropic_client_credentials`` (line ~5335)
|
||
|
|
4. ``AIAgent._swap_credential`` (line ~5378)
|
||
|
|
5. ``AIAgent._try_activate_fallback`` (line ~6536)
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
from unittest.mock import MagicMock, patch
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
from run_agent import AIAgent
|
||
|
|
|
||
|
|
|
||
|
|
# A plausible-looking OAuth token (``sk-ant-`` without the ``-api`` suffix).
|
||
|
|
_OAUTH_LIKE_TOKEN = "sk-ant-oauth-example-1234567890abcdef"
|
||
|
|
_API_KEY_TOKEN = "sk-ant-api-abcdef1234567890"
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def agent():
|
||
|
|
"""Minimal AIAgent construction, skipping tool discovery."""
|
||
|
|
with (
|
||
|
|
patch("run_agent.get_tool_definitions", return_value=[]),
|
||
|
|
patch("run_agent.check_toolset_requirements", return_value={}),
|
||
|
|
patch("run_agent.OpenAI"),
|
||
|
|
):
|
||
|
|
a = AIAgent(
|
||
|
|
api_key="test-key-1234567890",
|
||
|
|
base_url="https://openrouter.ai/api/v1",
|
||
|
|
quiet_mode=True,
|
||
|
|
skip_context_files=True,
|
||
|
|
skip_memory=True,
|
||
|
|
)
|
||
|
|
a.client = MagicMock()
|
||
|
|
return a
|
||
|
|
|
||
|
|
|
||
|
|
class TestOAuthFlagOnRefresh:
|
||
|
|
"""Site 3 — _try_refresh_anthropic_client_credentials."""
|
||
|
|
|
||
|
|
def test_third_party_provider_refresh_is_noop(self, agent):
|
||
|
|
"""Refresh path returns False immediately when provider != anthropic — the
|
||
|
|
OAuth flag can never be mutated for third-party providers. Double-defended
|
||
|
|
by the per-assignment guard at line ~5393 so future refactors can't
|
||
|
|
reintroduce the bug."""
|
||
|
|
agent.api_mode = "anthropic_messages"
|
||
|
|
agent.provider = "minimax" # ← third-party
|
||
|
|
agent._anthropic_api_key = "***"
|
||
|
|
agent._anthropic_client = MagicMock()
|
||
|
|
agent._is_anthropic_oauth = False
|
||
|
|
|
||
|
|
with (
|
||
|
|
patch("agent.anthropic_adapter.resolve_anthropic_token",
|
||
|
|
return_value=_OAUTH_LIKE_TOKEN),
|
||
|
|
patch("agent.anthropic_adapter.build_anthropic_client",
|
||
|
|
return_value=MagicMock()),
|
||
|
|
):
|
||
|
|
result = agent._try_refresh_anthropic_client_credentials()
|
||
|
|
|
||
|
|
# The function short-circuits on non-anthropic providers.
|
||
|
|
assert result is False
|
||
|
|
# And the flag is untouched regardless.
|
||
|
|
assert agent._is_anthropic_oauth is False
|
||
|
|
|
||
|
|
def test_native_anthropic_preserves_existing_oauth_behaviour(self, agent):
|
||
|
|
"""Regression: native anthropic with OAuth token still flips flag to True."""
|
||
|
|
agent.api_mode = "anthropic_messages"
|
||
|
|
agent.provider = "anthropic"
|
||
|
|
agent._anthropic_api_key = "***"
|
||
|
|
agent._anthropic_client = MagicMock()
|
||
|
|
agent._is_anthropic_oauth = False
|
||
|
|
|
||
|
|
with (
|
||
|
|
patch("agent.anthropic_adapter.resolve_anthropic_token",
|
||
|
|
return_value=_OAUTH_LIKE_TOKEN),
|
||
|
|
patch("agent.anthropic_adapter.build_anthropic_client",
|
||
|
|
return_value=MagicMock()),
|
||
|
|
):
|
||
|
|
result = agent._try_refresh_anthropic_client_credentials()
|
||
|
|
|
||
|
|
assert result is True
|
||
|
|
assert agent._is_anthropic_oauth is True
|
||
|
|
|
||
|
|
|
||
|
|
class TestOAuthFlagOnCredentialSwap:
|
||
|
|
"""Site 4 — _swap_credential (credential pool rotation)."""
|
||
|
|
|
||
|
|
def test_pool_swap_on_third_party_never_flips_oauth(self, agent):
|
||
|
|
agent.api_mode = "anthropic_messages"
|
||
|
|
agent.provider = "glm" # ← Zhipu GLM via /anthropic
|
||
|
|
agent._anthropic_api_key = "old-key"
|
||
|
|
agent._anthropic_base_url = "https://open.bigmodel.cn/api/anthropic"
|
||
|
|
agent._anthropic_client = MagicMock()
|
||
|
|
agent._is_anthropic_oauth = False
|
||
|
|
|
||
|
|
entry = MagicMock()
|
||
|
|
entry.runtime_api_key = _OAUTH_LIKE_TOKEN
|
||
|
|
entry.runtime_base_url = "https://open.bigmodel.cn/api/anthropic"
|
||
|
|
|
||
|
|
with patch("agent.anthropic_adapter.build_anthropic_client",
|
||
|
|
return_value=MagicMock()):
|
||
|
|
agent._swap_credential(entry)
|
||
|
|
|
||
|
|
assert agent._is_anthropic_oauth is False
|
||
|
|
|
||
|
|
|
||
|
|
class TestOAuthFlagOnConstruction:
|
||
|
|
"""Site 1 — AIAgent.__init__ on a third-party anthropic_messages provider."""
|
||
|
|
|
||
|
|
def test_minimax_init_does_not_flip_oauth(self):
|
||
|
|
with (
|
||
|
|
patch("run_agent.get_tool_definitions", return_value=[]),
|
||
|
|
patch("run_agent.check_toolset_requirements", return_value={}),
|
||
|
|
patch("agent.anthropic_adapter.build_anthropic_client",
|
||
|
|
return_value=MagicMock()),
|
||
|
|
# Simulate a stale ANTHROPIC_TOKEN in the env — the init code
|
||
|
|
# MUST NOT fall back to it when provider != anthropic.
|
||
|
|
patch("agent.anthropic_adapter.resolve_anthropic_token",
|
||
|
|
return_value=_OAUTH_LIKE_TOKEN),
|
||
|
|
):
|
||
|
|
agent = AIAgent(
|
||
|
|
api_key="minimax-key-1234",
|
||
|
|
base_url="https://api.minimax.io/anthropic",
|
||
|
|
provider="minimax",
|
||
|
|
api_mode="anthropic_messages",
|
||
|
|
model="claude-sonnet-4-6",
|
||
|
|
quiet_mode=True,
|
||
|
|
skip_context_files=True,
|
||
|
|
skip_memory=True,
|
||
|
|
)
|
||
|
|
|
||
|
|
# The effective key should be the explicit minimax-key, not the
|
||
|
|
# stale Anthropic OAuth token, and the OAuth flag must be False.
|
||
|
|
assert agent._anthropic_api_key == "minimax-key-1234"
|
||
|
|
assert agent._is_anthropic_oauth is False
|
||
|
|
|
||
|
|
|
||
|
|
class TestOAuthFlagOnFallbackActivation:
|
||
|
|
"""Site 5 — _try_activate_fallback targeting a third-party Anthropic endpoint."""
|
||
|
|
|
||
|
|
def test_fallback_to_third_party_does_not_flip_oauth(self, agent):
|
||
|
|
"""Directly mimic the post-fallback assignment at line ~6537."""
|
||
|
|
from agent.anthropic_adapter import _is_oauth_token
|
||
|
|
|
||
|
|
# Emulate the relevant lines of _try_activate_fallback without
|
||
|
|
# running the entire recovery stack (which pulls in streaming,
|
||
|
|
# sessions, etc.).
|
||
|
|
fb_provider = "minimax"
|
||
|
|
effective_key = _OAUTH_LIKE_TOKEN
|
||
|
|
agent._is_anthropic_oauth = (
|
||
|
|
_is_oauth_token(effective_key) if fb_provider == "anthropic" else False
|
||
|
|
)
|
||
|
|
assert agent._is_anthropic_oauth is False
|
||
|
|
|
||
|
|
|
||
|
|
class TestApiKeyTokensAlwaysSafe:
|
||
|
|
"""Regression: plain API-key shapes must always resolve to non-OAuth, any provider."""
|
||
|
|
|
||
|
|
def test_native_anthropic_with_api_key_token(self):
|
||
|
|
from agent.anthropic_adapter import _is_oauth_token
|
||
|
|
assert _is_oauth_token(_API_KEY_TOKEN) is False
|
||
|
|
|
||
|
|
def test_third_party_key_shape(self):
|
||
|
|
from agent.anthropic_adapter import _is_oauth_token
|
||
|
|
# Third-party key shapes (MiniMax 'mxp-...', GLM 'glm.sess.', etc.)
|
||
|
|
# already return False from _is_oauth_token; the guard adds a second
|
||
|
|
# defense line in case future token formats accidentally look OAuth-y.
|
||
|
|
assert _is_oauth_token("mxp-abcdef123") is False
|