7fa084f58e
OpenCode identifies clients by request headers, the same way OpenRouter does. Our opencode-zen and opencode-go profiles never set any, so every request went out with the OpenAI SDK default "OpenAI/Python x.y.z" User-Agent and OpenCode had no way to tell the traffic was Hermes Agent. Two changes: - Add HTTP-Referer, X-Title, and a HermesAgent User-Agent to both OpenCode profiles through profile.default_headers, the same path Fireworks uses. This covers chat_completions, codex_responses, auxiliary clients, model switches, and the models catalog fetch. - Merge the same headers in build_anthropic_client for opencode.ai base URLs. The Anthropic Messages route (Claude on Zen, MiniMax and Qwen on Go) builds its client there and never sees profile headers. Verified against the live Go relay with a real key. Both wire formats return HTTP 200 and the requests now carry X-Title "Hermes Agent", HTTP-Referer, and User-Agent HermesAgent/0.20.0.
284 lines
9.4 KiB
Python
284 lines
9.4 KiB
Python
"""Attribution default_headers applied per provider via base-URL detection.
|
|
|
|
Mirrors the OpenRouter pattern for the Vercel AI Gateway so that
|
|
referrerUrl / appName / User-Agent flow into gateway analytics.
|
|
"""
|
|
from types import SimpleNamespace
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from run_agent import AIAgent
|
|
|
|
|
|
@patch("run_agent.OpenAI")
|
|
def test_openrouter_base_url_applies_or_headers(mock_openai):
|
|
mock_openai.return_value = MagicMock()
|
|
agent = AIAgent(
|
|
api_key="test-key",
|
|
base_url="https://openrouter.ai/api/v1",
|
|
model="test/model",
|
|
quiet_mode=True,
|
|
skip_context_files=True,
|
|
skip_memory=True,
|
|
)
|
|
|
|
agent._apply_client_headers_for_base_url("https://openrouter.ai/api/v1")
|
|
|
|
headers = agent._client_kwargs["default_headers"]
|
|
assert headers["HTTP-Referer"] == "https://hermes-agent.nousresearch.com"
|
|
assert headers["X-Title"] == "Hermes Agent"
|
|
|
|
|
|
@patch("run_agent.OpenAI")
|
|
def test_ai_gateway_base_url_applies_attribution_headers(mock_openai):
|
|
mock_openai.return_value = MagicMock()
|
|
agent = AIAgent(
|
|
api_key="test-key",
|
|
base_url="https://openrouter.ai/api/v1",
|
|
model="test/model",
|
|
quiet_mode=True,
|
|
skip_context_files=True,
|
|
skip_memory=True,
|
|
)
|
|
|
|
agent._apply_client_headers_for_base_url("https://ai-gateway.vercel.sh/v1")
|
|
|
|
headers = agent._client_kwargs["default_headers"]
|
|
assert headers["HTTP-Referer"] == "https://hermes-agent.nousresearch.com"
|
|
assert headers["X-Title"] == "Hermes Agent"
|
|
assert headers["User-Agent"].startswith("HermesAgent/")
|
|
|
|
|
|
@patch("run_agent.OpenAI")
|
|
def test_routermint_base_url_applies_user_agent_header(mock_openai):
|
|
mock_openai.return_value = MagicMock()
|
|
agent = AIAgent(
|
|
api_key="test-key",
|
|
base_url="https://api.routermint.com/v1",
|
|
model="test/model",
|
|
quiet_mode=True,
|
|
skip_context_files=True,
|
|
skip_memory=True,
|
|
)
|
|
|
|
agent._apply_client_headers_for_base_url("https://api.routermint.com/v1")
|
|
|
|
headers = agent._client_kwargs["default_headers"]
|
|
assert headers["User-Agent"].startswith("HermesAgent/")
|
|
|
|
|
|
@patch("run_agent.OpenAI")
|
|
def test_nvidia_cloud_base_url_applies_billing_origin_header(mock_openai):
|
|
mock_openai.return_value = MagicMock()
|
|
agent = AIAgent(
|
|
api_key="test-key",
|
|
base_url="https://integrate.api.nvidia.com/v1",
|
|
model="nvidia/test-model",
|
|
provider="nvidia",
|
|
quiet_mode=True,
|
|
skip_context_files=True,
|
|
skip_memory=True,
|
|
)
|
|
|
|
assert agent._client_kwargs["default_headers"]["X-BILLING-INVOKE-ORIGIN"] == "HermesAgent"
|
|
|
|
agent._apply_client_headers_for_base_url("https://integrate.api.nvidia.com/v1")
|
|
|
|
headers = agent._client_kwargs["default_headers"]
|
|
assert headers["X-BILLING-INVOKE-ORIGIN"] == "HermesAgent"
|
|
|
|
|
|
@patch("run_agent.OpenAI")
|
|
def test_fireworks_applies_attribution_via_profile_fallback(mock_openai):
|
|
"""Fireworks has no host-specific branch — its attribution headers come
|
|
from the profile.default_headers fallback, the path a model switch
|
|
re-runs."""
|
|
mock_openai.return_value = MagicMock()
|
|
agent = AIAgent(
|
|
api_key="test-key",
|
|
base_url="https://api.fireworks.ai/inference/v1",
|
|
model="accounts/fireworks/models/kimi-k2p6",
|
|
provider="fireworks",
|
|
quiet_mode=True,
|
|
skip_context_files=True,
|
|
skip_memory=True,
|
|
)
|
|
|
|
agent._apply_client_headers_for_base_url("https://api.fireworks.ai/inference/v1")
|
|
|
|
headers = agent._client_kwargs["default_headers"]
|
|
assert headers["HTTP-Referer"] == "https://hermes-agent.nousresearch.com"
|
|
assert headers["X-Title"] == "Hermes Agent"
|
|
assert headers["User-Agent"].startswith("HermesAgent/")
|
|
|
|
|
|
@patch("run_agent.OpenAI")
|
|
def test_opencode_go_applies_attribution_via_profile_fallback(mock_openai):
|
|
"""OpenCode (Zen/Go) attributes traffic by header like OpenRouter does.
|
|
Without profile.default_headers the relay only sees the OpenAI SDK's
|
|
generic User-Agent and Hermes Agent traffic shows up unattributed."""
|
|
mock_openai.return_value = MagicMock()
|
|
agent = AIAgent(
|
|
api_key="test-key",
|
|
base_url="https://opencode.ai/zen/go/v1",
|
|
model="glm-5",
|
|
provider="opencode-go",
|
|
quiet_mode=True,
|
|
skip_context_files=True,
|
|
skip_memory=True,
|
|
)
|
|
|
|
agent._apply_client_headers_for_base_url("https://opencode.ai/zen/go/v1")
|
|
|
|
headers = agent._client_kwargs["default_headers"]
|
|
assert headers["HTTP-Referer"] == "https://hermes-agent.nousresearch.com"
|
|
assert headers["X-Title"] == "Hermes Agent"
|
|
assert headers["User-Agent"].startswith("HermesAgent/")
|
|
|
|
|
|
@patch("run_agent.OpenAI")
|
|
def test_opencode_zen_applies_attribution_via_profile_fallback(mock_openai):
|
|
mock_openai.return_value = MagicMock()
|
|
agent = AIAgent(
|
|
api_key="test-key",
|
|
base_url="https://opencode.ai/zen/v1",
|
|
model="claude-sonnet-4-5",
|
|
provider="opencode-zen",
|
|
quiet_mode=True,
|
|
skip_context_files=True,
|
|
skip_memory=True,
|
|
)
|
|
|
|
agent._apply_client_headers_for_base_url("https://opencode.ai/zen/v1")
|
|
|
|
headers = agent._client_kwargs["default_headers"]
|
|
assert headers["HTTP-Referer"] == "https://hermes-agent.nousresearch.com"
|
|
assert headers["X-Title"] == "Hermes Agent"
|
|
assert headers["User-Agent"].startswith("HermesAgent/")
|
|
|
|
|
|
@patch("run_agent.OpenAI")
|
|
def test_routed_client_preserves_openai_sdk_custom_headers(mock_openai):
|
|
mock_openai.return_value = MagicMock()
|
|
routed_client = SimpleNamespace(
|
|
api_key="test-key",
|
|
base_url="https://integrate.api.nvidia.com/v1",
|
|
_custom_headers={"X-BILLING-INVOKE-ORIGIN": "HermesAgent"},
|
|
)
|
|
|
|
with patch("agent.auxiliary_client.resolve_provider_client", return_value=(
|
|
routed_client,
|
|
"nvidia/test-model",
|
|
)):
|
|
agent = AIAgent(
|
|
provider="nvidia",
|
|
model="nvidia/test-model",
|
|
quiet_mode=True,
|
|
skip_context_files=True,
|
|
skip_memory=True,
|
|
)
|
|
|
|
headers = agent._client_kwargs["default_headers"]
|
|
assert headers["X-BILLING-INVOKE-ORIGIN"] == "HermesAgent"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@patch("run_agent.OpenAI")
|
|
def test_openrouter_headers_include_response_cache_when_enabled(mock_openai):
|
|
"""When openrouter.response_cache is True, the cache header is injected."""
|
|
mock_openai.return_value = MagicMock()
|
|
agent = AIAgent(
|
|
api_key="test-key",
|
|
base_url="https://openrouter.ai/api/v1",
|
|
model="test/model",
|
|
quiet_mode=True,
|
|
skip_context_files=True,
|
|
skip_memory=True,
|
|
)
|
|
|
|
with patch("hermes_cli.config.load_config", return_value={
|
|
"openrouter": {"response_cache": True, "response_cache_ttl": 600},
|
|
}), patch("hermes_cli.config.load_config_readonly", return_value={
|
|
"openrouter": {"response_cache": True, "response_cache_ttl": 600},
|
|
}):
|
|
agent._apply_client_headers_for_base_url("https://openrouter.ai/api/v1")
|
|
|
|
headers = agent._client_kwargs["default_headers"]
|
|
assert headers["HTTP-Referer"] == "https://hermes-agent.nousresearch.com"
|
|
assert headers["X-OpenRouter-Cache"] == "true"
|
|
assert headers["X-OpenRouter-Cache-TTL"] == "600"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# model.default_headers — user-configured overrides (#40033)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@patch("run_agent.OpenAI")
|
|
def test_user_default_headers_override_sdk_user_agent(mock_openai):
|
|
"""``model.default_headers`` lets a custom endpoint swap the OpenAI SDK
|
|
User-Agent that some gateways/WAFs reject (the #40033 reproduction)."""
|
|
mock_openai.return_value = MagicMock()
|
|
agent = AIAgent(
|
|
api_key="test-key",
|
|
base_url="http://localhost:8080/v1",
|
|
model="my-custom-model",
|
|
provider="custom",
|
|
quiet_mode=True,
|
|
skip_context_files=True,
|
|
skip_memory=True,
|
|
)
|
|
|
|
with patch("hermes_cli.config.load_config", return_value={
|
|
"model": {"default_headers": {"User-Agent": "curl/8.7.1", "X-Extra": "1"}},
|
|
}), patch("hermes_cli.config.load_config_readonly", return_value={
|
|
"model": {"default_headers": {"User-Agent": "curl/8.7.1", "X-Extra": "1"}},
|
|
}):
|
|
agent._apply_client_headers_for_base_url("http://localhost:8080/v1")
|
|
|
|
headers = agent._client_kwargs["default_headers"]
|
|
assert headers["User-Agent"] == "curl/8.7.1"
|
|
assert headers["X-Extra"] == "1"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@patch("run_agent.OpenAI")
|
|
def test_openrouter_headers_no_cache_when_disabled(mock_openai):
|
|
"""When openrouter.response_cache is False, no cache headers are sent."""
|
|
mock_openai.return_value = MagicMock()
|
|
agent = AIAgent(
|
|
api_key="test-key",
|
|
base_url="https://openrouter.ai/api/v1",
|
|
model="test/model",
|
|
quiet_mode=True,
|
|
skip_context_files=True,
|
|
skip_memory=True,
|
|
)
|
|
|
|
with patch("hermes_cli.config.load_config", return_value={
|
|
"openrouter": {"response_cache": False},
|
|
}), patch("hermes_cli.config.load_config_readonly", return_value={
|
|
"openrouter": {"response_cache": False},
|
|
}):
|
|
agent._apply_client_headers_for_base_url("https://openrouter.ai/api/v1")
|
|
|
|
headers = agent._client_kwargs["default_headers"]
|
|
assert headers["HTTP-Referer"] == "https://hermes-agent.nousresearch.com"
|
|
assert "X-OpenRouter-Cache" not in headers
|
|
assert "X-OpenRouter-Cache-TTL" not in headers
|
|
|
|
|