fix(agent): honor model.default_headers for custom OpenAI-compatible providers (#40033)
Custom OpenAI-compatible endpoints sitting behind a gateway/WAF can reject the OpenAI Python SDK's default identifying headers (User-Agent: OpenAI/Python, X-Stainless-*) and return an opaque 502/4xx even though the same request body succeeds under curl. There was no supported way to override those headers. Add a model.default_headers config key whose values are merged onto the OpenAI client's default_headers, taking precedence over provider- and SDK-supplied defaults. Applied at client construction and on every credential swap / client rebuild so the override survives reconnects. No-op for native Anthropic / Bedrock modes and when unconfigured.
This commit is contained in:
@@ -176,6 +176,103 @@ def test_openrouter_headers_include_response_cache_when_enabled(mock_openai):
|
||||
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"}},
|
||||
}):
|
||||
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_user_default_headers_win_over_provider_defaults(mock_openai):
|
||||
"""User headers take precedence but leave untouched provider defaults intact."""
|
||||
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={
|
||||
"model": {"default_headers": {"X-Title": "MyApp"}},
|
||||
}):
|
||||
agent._apply_client_headers_for_base_url("https://openrouter.ai/api/v1")
|
||||
|
||||
headers = agent._client_kwargs["default_headers"]
|
||||
assert headers["X-Title"] == "MyApp" # user override wins
|
||||
assert headers["HTTP-Referer"] == "https://hermes-agent.nousresearch.com" # default preserved
|
||||
|
||||
|
||||
@patch("run_agent.OpenAI")
|
||||
def test_no_user_default_headers_leaves_provider_defaults_untouched(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,
|
||||
)
|
||||
|
||||
with patch("hermes_cli.config.load_config", return_value={"model": {}}):
|
||||
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 "User-Agent" not in headers # nothing injected when unconfigured
|
||||
|
||||
|
||||
@patch("run_agent.OpenAI")
|
||||
def test_user_default_headers_skipped_for_anthropic_mode(mock_openai):
|
||||
"""Anthropic/Bedrock modes don't use the OpenAI client — never touched."""
|
||||
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,
|
||||
)
|
||||
agent.api_mode = "anthropic_messages"
|
||||
agent._client_kwargs = {}
|
||||
|
||||
with patch("hermes_cli.config.load_config", return_value={
|
||||
"model": {"default_headers": {"User-Agent": "curl/8.7.1"}},
|
||||
}):
|
||||
agent._apply_user_default_headers()
|
||||
|
||||
assert "default_headers" not in agent._client_kwargs
|
||||
|
||||
|
||||
@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."""
|
||||
|
||||
Reference in New Issue
Block a user