mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
WSL2 / Clash-style setups often export ALL_PROXY=socks://127.0.0.1:PORT. httpx and the Anthropic SDK reject that alias and expect socks5://, so agent startup failed early with "Unknown scheme for proxy URL" before any provider request could proceed. Add shared normalize_proxy_url()/normalize_proxy_env_vars() helpers in utils.py and route all proxy entry points through them: - run_agent._get_proxy_from_env - agent.auxiliary_client._validate_proxy_env_urls - agent.anthropic_adapter.build_anthropic_client - gateway.platforms.base.resolve_proxy_url Regression coverage: - run_agent proxy env resolution - auxiliary proxy env normalization - gateway proxy URL resolution Verified with: PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 /home/nonlinear/.hermes/hermes-agent/venv/bin/pytest -o addopts='' -p pytest_asyncio.plugin tests/run_agent/test_create_openai_client_proxy_env.py tests/agent/test_proxy_and_url_validation.py tests/gateway/test_proxy_mode.py 39 passed.
492 lines
18 KiB
Python
492 lines
18 KiB
Python
"""Tests for gateway proxy mode — forwarding messages to a remote API server."""
|
|
|
|
import asyncio
|
|
import json
|
|
import os
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from gateway.config import Platform, StreamingConfig
|
|
from gateway.platforms.base import resolve_proxy_url
|
|
from gateway.run import GatewayRunner
|
|
from gateway.session import SessionSource
|
|
|
|
|
|
def _make_runner(proxy_url=None):
|
|
"""Create a minimal GatewayRunner for proxy tests."""
|
|
runner = object.__new__(GatewayRunner)
|
|
runner.adapters = {}
|
|
runner.config = MagicMock()
|
|
runner.config.streaming = StreamingConfig()
|
|
runner._running_agents = {}
|
|
runner._session_run_generation = {}
|
|
runner._session_model_overrides = {}
|
|
runner._agent_cache = {}
|
|
runner._agent_cache_lock = None
|
|
return runner
|
|
|
|
|
|
def _make_source(platform=Platform.MATRIX):
|
|
return SessionSource(
|
|
platform=platform,
|
|
chat_id="!room:server.org",
|
|
chat_name="Test Room",
|
|
chat_type="group",
|
|
user_id="@user:server.org",
|
|
user_name="testuser",
|
|
thread_id=None,
|
|
)
|
|
|
|
|
|
class _FakeSSEResponse:
|
|
"""Simulates an aiohttp response with SSE streaming."""
|
|
|
|
def __init__(self, status=200, sse_chunks=None, error_text=""):
|
|
self.status = status
|
|
self._sse_chunks = sse_chunks or []
|
|
self._error_text = error_text
|
|
self.content = self
|
|
|
|
async def text(self):
|
|
return self._error_text
|
|
|
|
async def iter_any(self):
|
|
for chunk in self._sse_chunks:
|
|
if isinstance(chunk, str):
|
|
chunk = chunk.encode("utf-8")
|
|
yield chunk
|
|
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, *args):
|
|
pass
|
|
|
|
|
|
class _FakeSession:
|
|
"""Simulates an aiohttp.ClientSession with captured request args."""
|
|
|
|
def __init__(self, response):
|
|
self._response = response
|
|
self.captured_url = None
|
|
self.captured_json = None
|
|
self.captured_headers = None
|
|
|
|
def post(self, url, json=None, headers=None, **kwargs):
|
|
self.captured_url = url
|
|
self.captured_json = json
|
|
self.captured_headers = headers
|
|
return self._response
|
|
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, *args):
|
|
pass
|
|
|
|
|
|
def _patch_aiohttp(session):
|
|
"""Patch aiohttp.ClientSession to return our fake session."""
|
|
return patch(
|
|
"aiohttp.ClientSession",
|
|
return_value=session,
|
|
)
|
|
|
|
|
|
class TestGetProxyUrl:
|
|
"""Test _get_proxy_url() config resolution."""
|
|
|
|
def test_returns_none_when_not_configured(self, monkeypatch):
|
|
monkeypatch.delenv("GATEWAY_PROXY_URL", raising=False)
|
|
runner = _make_runner()
|
|
with patch("gateway.run._load_gateway_config", return_value={}):
|
|
assert runner._get_proxy_url() is None
|
|
|
|
def test_reads_from_env_var(self, monkeypatch):
|
|
monkeypatch.setenv("GATEWAY_PROXY_URL", "http://192.168.1.100:8642")
|
|
runner = _make_runner()
|
|
assert runner._get_proxy_url() == "http://192.168.1.100:8642"
|
|
|
|
def test_strips_trailing_slash(self, monkeypatch):
|
|
monkeypatch.setenv("GATEWAY_PROXY_URL", "http://host:8642/")
|
|
runner = _make_runner()
|
|
assert runner._get_proxy_url() == "http://host:8642"
|
|
|
|
def test_reads_from_config_yaml(self, monkeypatch):
|
|
monkeypatch.delenv("GATEWAY_PROXY_URL", raising=False)
|
|
runner = _make_runner()
|
|
cfg = {"gateway": {"proxy_url": "http://10.0.0.1:8642"}}
|
|
with patch("gateway.run._load_gateway_config", return_value=cfg):
|
|
assert runner._get_proxy_url() == "http://10.0.0.1:8642"
|
|
|
|
def test_env_var_overrides_config(self, monkeypatch):
|
|
monkeypatch.setenv("GATEWAY_PROXY_URL", "http://env-host:8642")
|
|
runner = _make_runner()
|
|
cfg = {"gateway": {"proxy_url": "http://config-host:8642"}}
|
|
with patch("gateway.run._load_gateway_config", return_value=cfg):
|
|
assert runner._get_proxy_url() == "http://env-host:8642"
|
|
|
|
def test_empty_string_treated_as_unset(self, monkeypatch):
|
|
monkeypatch.setenv("GATEWAY_PROXY_URL", " ")
|
|
runner = _make_runner()
|
|
with patch("gateway.run._load_gateway_config", return_value={}):
|
|
assert runner._get_proxy_url() is None
|
|
|
|
|
|
class TestResolveProxyUrl:
|
|
def test_normalizes_socks_alias_from_all_proxy(self, monkeypatch):
|
|
for key in ("HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY",
|
|
"https_proxy", "http_proxy", "all_proxy"):
|
|
monkeypatch.delenv(key, raising=False)
|
|
monkeypatch.setenv("ALL_PROXY", "socks://127.0.0.1:1080/")
|
|
assert resolve_proxy_url() == "socks5://127.0.0.1:1080/"
|
|
|
|
|
|
class TestRunAgentProxyDispatch:
|
|
"""Test that _run_agent() delegates to proxy when configured."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_agent_delegates_to_proxy(self, monkeypatch):
|
|
monkeypatch.setenv("GATEWAY_PROXY_URL", "http://host:8642")
|
|
runner = _make_runner()
|
|
source = _make_source()
|
|
|
|
expected_result = {
|
|
"final_response": "Hello from remote!",
|
|
"messages": [
|
|
{"role": "user", "content": "hi"},
|
|
{"role": "assistant", "content": "Hello from remote!"},
|
|
],
|
|
"api_calls": 1,
|
|
"tools": [],
|
|
}
|
|
|
|
runner._run_agent_via_proxy = AsyncMock(return_value=expected_result)
|
|
|
|
result = await runner._run_agent(
|
|
message="hi",
|
|
context_prompt="",
|
|
history=[],
|
|
source=source,
|
|
session_id="test-session-123",
|
|
session_key="test-key",
|
|
run_generation=7,
|
|
)
|
|
|
|
assert result["final_response"] == "Hello from remote!"
|
|
runner._run_agent_via_proxy.assert_called_once()
|
|
assert runner._run_agent_via_proxy.call_args.kwargs["run_generation"] == 7
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_agent_skips_proxy_when_not_configured(self, monkeypatch):
|
|
monkeypatch.delenv("GATEWAY_PROXY_URL", raising=False)
|
|
runner = _make_runner()
|
|
|
|
runner._run_agent_via_proxy = AsyncMock()
|
|
|
|
with patch("gateway.run._load_gateway_config", return_value={}):
|
|
try:
|
|
await runner._run_agent(
|
|
message="hi",
|
|
context_prompt="",
|
|
history=[],
|
|
source=_make_source(),
|
|
session_id="test-session",
|
|
)
|
|
except Exception:
|
|
pass # Expected — bare runner can't create a real agent
|
|
|
|
runner._run_agent_via_proxy.assert_not_called()
|
|
|
|
|
|
class TestRunAgentViaProxy:
|
|
"""Test the actual proxy HTTP forwarding logic."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_builds_correct_request(self, monkeypatch):
|
|
monkeypatch.setenv("GATEWAY_PROXY_URL", "http://host:8642")
|
|
monkeypatch.setenv("GATEWAY_PROXY_KEY", "test-key-123")
|
|
runner = _make_runner()
|
|
source = _make_source()
|
|
|
|
resp = _FakeSSEResponse(
|
|
status=200,
|
|
sse_chunks=[
|
|
'data: {"choices":[{"delta":{"content":"Hello"}}]}\n\n'
|
|
'data: {"choices":[{"delta":{"content":" world"}}]}\n\n'
|
|
"data: [DONE]\n\n"
|
|
],
|
|
)
|
|
session = _FakeSession(resp)
|
|
|
|
with patch("gateway.run._load_gateway_config", return_value={}):
|
|
with _patch_aiohttp(session):
|
|
with patch("aiohttp.ClientTimeout"):
|
|
result = await runner._run_agent_via_proxy(
|
|
message="How are you?",
|
|
context_prompt="You are helpful.",
|
|
history=[
|
|
{"role": "user", "content": "Hello"},
|
|
{"role": "assistant", "content": "Hi there!"},
|
|
],
|
|
source=source,
|
|
session_id="session-abc",
|
|
)
|
|
|
|
# Verify request URL
|
|
assert session.captured_url == "http://host:8642/v1/chat/completions"
|
|
|
|
# Verify auth header
|
|
assert session.captured_headers["Authorization"] == "Bearer test-key-123"
|
|
|
|
# Verify session ID header
|
|
assert session.captured_headers["X-Hermes-Session-Id"] == "session-abc"
|
|
|
|
# Verify messages include system, history, and current message
|
|
messages = session.captured_json["messages"]
|
|
assert messages[0] == {"role": "system", "content": "You are helpful."}
|
|
assert messages[1] == {"role": "user", "content": "Hello"}
|
|
assert messages[2] == {"role": "assistant", "content": "Hi there!"}
|
|
assert messages[3] == {"role": "user", "content": "How are you?"}
|
|
|
|
# Verify streaming is requested
|
|
assert session.captured_json["stream"] is True
|
|
|
|
# Verify response was assembled
|
|
assert result["final_response"] == "Hello world"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handles_http_error(self, monkeypatch):
|
|
monkeypatch.setenv("GATEWAY_PROXY_URL", "http://host:8642")
|
|
monkeypatch.delenv("GATEWAY_PROXY_KEY", raising=False)
|
|
runner = _make_runner()
|
|
source = _make_source()
|
|
|
|
resp = _FakeSSEResponse(status=401, error_text="Unauthorized: invalid API key")
|
|
session = _FakeSession(resp)
|
|
|
|
with patch("gateway.run._load_gateway_config", return_value={}):
|
|
with _patch_aiohttp(session):
|
|
with patch("aiohttp.ClientTimeout"):
|
|
result = await runner._run_agent_via_proxy(
|
|
message="hi",
|
|
context_prompt="",
|
|
history=[],
|
|
source=source,
|
|
session_id="test",
|
|
)
|
|
|
|
assert "Proxy error (401)" in result["final_response"]
|
|
assert result["api_calls"] == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handles_connection_error(self, monkeypatch):
|
|
monkeypatch.setenv("GATEWAY_PROXY_URL", "http://unreachable:8642")
|
|
monkeypatch.delenv("GATEWAY_PROXY_KEY", raising=False)
|
|
runner = _make_runner()
|
|
source = _make_source()
|
|
|
|
class _ErrorSession:
|
|
def post(self, *args, **kwargs):
|
|
raise ConnectionError("Connection refused")
|
|
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, *args):
|
|
pass
|
|
|
|
with patch("gateway.run._load_gateway_config", return_value={}):
|
|
with patch("aiohttp.ClientSession", return_value=_ErrorSession()):
|
|
with patch("aiohttp.ClientTimeout"):
|
|
result = await runner._run_agent_via_proxy(
|
|
message="hi",
|
|
context_prompt="",
|
|
history=[],
|
|
source=source,
|
|
session_id="test",
|
|
)
|
|
|
|
assert "Proxy connection error" in result["final_response"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_skips_tool_messages_in_history(self, monkeypatch):
|
|
monkeypatch.setenv("GATEWAY_PROXY_URL", "http://host:8642")
|
|
monkeypatch.delenv("GATEWAY_PROXY_KEY", raising=False)
|
|
runner = _make_runner()
|
|
source = _make_source()
|
|
|
|
resp = _FakeSSEResponse(
|
|
status=200,
|
|
sse_chunks=[b'data: {"choices":[{"delta":{"content":"ok"}}]}\n\ndata: [DONE]\n\n'],
|
|
)
|
|
session = _FakeSession(resp)
|
|
|
|
history = [
|
|
{"role": "user", "content": "search for X"},
|
|
{"role": "assistant", "content": None, "tool_calls": [{"id": "tc1"}]},
|
|
{"role": "tool", "content": "search results...", "tool_call_id": "tc1"},
|
|
{"role": "assistant", "content": "Found results."},
|
|
]
|
|
|
|
with patch("gateway.run._load_gateway_config", return_value={}):
|
|
with _patch_aiohttp(session):
|
|
with patch("aiohttp.ClientTimeout"):
|
|
await runner._run_agent_via_proxy(
|
|
message="tell me more",
|
|
context_prompt="",
|
|
history=history,
|
|
source=source,
|
|
session_id="test",
|
|
)
|
|
|
|
# Only user and assistant with content should be forwarded
|
|
messages = session.captured_json["messages"]
|
|
roles = [m["role"] for m in messages]
|
|
assert "tool" not in roles
|
|
# assistant with None content should be skipped
|
|
assert all(m.get("content") for m in messages)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_result_shape_matches_run_agent(self, monkeypatch):
|
|
monkeypatch.setenv("GATEWAY_PROXY_URL", "http://host:8642")
|
|
monkeypatch.delenv("GATEWAY_PROXY_KEY", raising=False)
|
|
runner = _make_runner()
|
|
source = _make_source()
|
|
|
|
resp = _FakeSSEResponse(
|
|
status=200,
|
|
sse_chunks=[b'data: {"choices":[{"delta":{"content":"answer"}}]}\n\ndata: [DONE]\n\n'],
|
|
)
|
|
session = _FakeSession(resp)
|
|
|
|
with patch("gateway.run._load_gateway_config", return_value={}):
|
|
with _patch_aiohttp(session):
|
|
with patch("aiohttp.ClientTimeout"):
|
|
result = await runner._run_agent_via_proxy(
|
|
message="hi",
|
|
context_prompt="",
|
|
history=[{"role": "user", "content": "prev"}, {"role": "assistant", "content": "ok"}],
|
|
source=source,
|
|
session_id="sess-123",
|
|
)
|
|
|
|
# Required keys that callers depend on
|
|
assert "final_response" in result
|
|
assert result["final_response"] == "answer"
|
|
assert "messages" in result
|
|
assert "api_calls" in result
|
|
assert "tools" in result
|
|
assert "history_offset" in result
|
|
assert result["history_offset"] == 2 # len(history)
|
|
assert "session_id" in result
|
|
assert result["session_id"] == "sess-123"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_proxy_stale_generation_returns_empty_result(self, monkeypatch):
|
|
monkeypatch.setenv("GATEWAY_PROXY_URL", "http://host:8642")
|
|
monkeypatch.delenv("GATEWAY_PROXY_KEY", raising=False)
|
|
runner = _make_runner()
|
|
source = _make_source()
|
|
runner._session_run_generation["test-key"] = 2
|
|
|
|
resp = _FakeSSEResponse(
|
|
status=200,
|
|
sse_chunks=[
|
|
'data: {"choices":[{"delta":{"content":"stale"}}]}\n\n',
|
|
"data: [DONE]\n\n",
|
|
],
|
|
)
|
|
session = _FakeSession(resp)
|
|
|
|
with patch("gateway.run._load_gateway_config", return_value={}):
|
|
with _patch_aiohttp(session):
|
|
with patch("aiohttp.ClientTimeout"):
|
|
result = await runner._run_agent_via_proxy(
|
|
message="hi",
|
|
context_prompt="",
|
|
history=[],
|
|
source=source,
|
|
session_id="sess-123",
|
|
session_key="test-key",
|
|
run_generation=1,
|
|
)
|
|
|
|
assert result["final_response"] == ""
|
|
assert result["messages"] == []
|
|
assert result["api_calls"] == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_auth_header_without_key(self, monkeypatch):
|
|
monkeypatch.setenv("GATEWAY_PROXY_URL", "http://host:8642")
|
|
monkeypatch.delenv("GATEWAY_PROXY_KEY", raising=False)
|
|
runner = _make_runner()
|
|
source = _make_source()
|
|
|
|
resp = _FakeSSEResponse(
|
|
status=200,
|
|
sse_chunks=[b'data: {"choices":[{"delta":{"content":"ok"}}]}\n\ndata: [DONE]\n\n'],
|
|
)
|
|
session = _FakeSession(resp)
|
|
|
|
with patch("gateway.run._load_gateway_config", return_value={}):
|
|
with _patch_aiohttp(session):
|
|
with patch("aiohttp.ClientTimeout"):
|
|
await runner._run_agent_via_proxy(
|
|
message="hi",
|
|
context_prompt="",
|
|
history=[],
|
|
source=source,
|
|
session_id="test",
|
|
)
|
|
|
|
assert "Authorization" not in session.captured_headers
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_system_message_when_context_empty(self, monkeypatch):
|
|
monkeypatch.setenv("GATEWAY_PROXY_URL", "http://host:8642")
|
|
monkeypatch.delenv("GATEWAY_PROXY_KEY", raising=False)
|
|
runner = _make_runner()
|
|
source = _make_source()
|
|
|
|
resp = _FakeSSEResponse(
|
|
status=200,
|
|
sse_chunks=[b'data: {"choices":[{"delta":{"content":"ok"}}]}\n\ndata: [DONE]\n\n'],
|
|
)
|
|
session = _FakeSession(resp)
|
|
|
|
with patch("gateway.run._load_gateway_config", return_value={}):
|
|
with _patch_aiohttp(session):
|
|
with patch("aiohttp.ClientTimeout"):
|
|
await runner._run_agent_via_proxy(
|
|
message="hello",
|
|
context_prompt="",
|
|
history=[],
|
|
source=source,
|
|
session_id="test",
|
|
)
|
|
|
|
# No system message should appear when context_prompt is empty
|
|
messages = session.captured_json["messages"]
|
|
assert len(messages) == 1
|
|
assert messages[0]["role"] == "user"
|
|
assert messages[0]["content"] == "hello"
|
|
|
|
|
|
class TestEnvVarRegistration:
|
|
"""Verify GATEWAY_PROXY_URL and GATEWAY_PROXY_KEY are registered."""
|
|
|
|
def test_proxy_url_in_optional_env_vars(self):
|
|
from hermes_cli.config import OPTIONAL_ENV_VARS
|
|
assert "GATEWAY_PROXY_URL" in OPTIONAL_ENV_VARS
|
|
info = OPTIONAL_ENV_VARS["GATEWAY_PROXY_URL"]
|
|
assert info["category"] == "messaging"
|
|
assert info["password"] is False
|
|
|
|
def test_proxy_key_in_optional_env_vars(self):
|
|
from hermes_cli.config import OPTIONAL_ENV_VARS
|
|
assert "GATEWAY_PROXY_KEY" in OPTIONAL_ENV_VARS
|
|
info = OPTIONAL_ENV_VARS["GATEWAY_PROXY_KEY"]
|
|
assert info["category"] == "messaging"
|
|
assert info["password"] is True
|