Skip to content

Commit a216ff8

Browse files
sanidhyasinkshitijk4poor
authored andcommitted
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.
1 parent f5c3fc3 commit a216ff8

4 files changed

Lines changed: 158 additions & 0 deletions

File tree

agent/agent_init.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -885,6 +885,14 @@ def init_agent(
885885
headers["x-anthropic-beta"] = _FINE_GRAINED
886886
client_kwargs["default_headers"] = headers
887887

888+
# User-configured request headers (model.default_headers in
889+
# config.yaml) override provider/SDK defaults. Lets custom
890+
# OpenAI-compatible endpoints behind a gateway/WAF that rejects the
891+
# OpenAI SDK's identifying headers swap in a plain User-Agent. (#40033)
892+
# client_kwargs is the same dict object as agent._client_kwargs, so
893+
# this mutation is reflected in the client built just below.
894+
agent._apply_user_default_headers()
895+
888896
agent.api_key = client_kwargs.get("api_key", "")
889897
agent.base_url = client_kwargs.get("base_url", agent.base_url)
890898
try:

cli-config.yaml.example

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,20 @@ model:
7272
#
7373
# max_tokens: 8192
7474

75+
# ── Custom request headers (optional) ─────────────────────────────────────
76+
#
77+
# default_headers: extra HTTP headers sent on every request to an
78+
# OpenAI-compatible endpoint. User values take precedence over the
79+
# provider/SDK defaults, so this is the supported way to override the
80+
# OpenAI Python SDK's identifying headers (User-Agent: OpenAI/Python ...,
81+
# X-Stainless-*) when a custom provider sits behind a gateway/WAF that
82+
# rejects them — e.g. an upstream that returns "502 Upstream access
83+
# forbidden" for the SDK default User-Agent but accepts a plain one.
84+
# Applies on the OpenAI wire only (not native Anthropic / Bedrock).
85+
#
86+
# default_headers:
87+
# User-Agent: "curl/8.7.1"
88+
7589
# Named provider overrides (optional)
7690
# Use this for per-provider request timeouts, non-stream stale timeouts,
7791
# and per-model exceptions.

run_agent.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3809,6 +3809,45 @@ def _apply_client_headers_for_base_url(self, base_url: str) -> None:
38093809
else:
38103810
self._client_kwargs.pop("default_headers", None)
38113811

3812+
# User-configured overrides win over URL/profile defaults — keep them
3813+
# applied across credential swaps and client rebuilds, not just at
3814+
# first construction.
3815+
self._apply_user_default_headers()
3816+
3817+
def _apply_user_default_headers(self) -> None:
3818+
"""Merge user-configured request headers onto the OpenAI client.
3819+
3820+
Reads ``model.default_headers`` from config.yaml and merges it onto
3821+
``self._client_kwargs["default_headers"]``, with user values taking
3822+
precedence over provider- and SDK-supplied defaults.
3823+
3824+
This exists for ``custom`` OpenAI-compatible endpoints sitting behind
3825+
a gateway/WAF that rejects the OpenAI Python SDK's identifying headers
3826+
(``User-Agent: OpenAI/Python ...``, ``X-Stainless-*``). Setting e.g.
3827+
``model.default_headers: {User-Agent: curl/8.7.1}`` lets the request
3828+
reach such an upstream instead of failing with an opaque 4xx/502 even
3829+
though the same body works under ``curl``. (#40033)
3830+
3831+
No-op for Anthropic/Bedrock modes, which don't use the OpenAI client,
3832+
and when no overrides are configured.
3833+
"""
3834+
if self.api_mode in ("anthropic_messages", "bedrock_converse"):
3835+
return
3836+
try:
3837+
from hermes_cli.config import cfg_get, load_config
3838+
user_headers = cfg_get(load_config(), "model", "default_headers")
3839+
except Exception:
3840+
return
3841+
if not isinstance(user_headers, dict) or not user_headers:
3842+
return
3843+
merged = dict(self._client_kwargs.get("default_headers") or {})
3844+
for key, value in user_headers.items():
3845+
if value is None:
3846+
continue
3847+
merged[str(key)] = str(value)
3848+
if merged:
3849+
self._client_kwargs["default_headers"] = merged
3850+
38123851
def _swap_credential(self, entry) -> None:
38133852
runtime_key = getattr(entry, "runtime_api_key", None) or getattr(entry, "access_token", "")
38143853
runtime_base = getattr(entry, "runtime_base_url", None) or getattr(entry, "base_url", None) or self.base_url

tests/run_agent/test_provider_attribution_headers.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,103 @@ def test_openrouter_headers_include_response_cache_when_enabled(mock_openai):
176176
assert headers["X-OpenRouter-Cache-TTL"] == "600"
177177

178178

179+
# ---------------------------------------------------------------------------
180+
# model.default_headers — user-configured overrides (#40033)
181+
# ---------------------------------------------------------------------------
182+
183+
184+
@patch("run_agent.OpenAI")
185+
def test_user_default_headers_override_sdk_user_agent(mock_openai):
186+
"""``model.default_headers`` lets a custom endpoint swap the OpenAI SDK
187+
User-Agent that some gateways/WAFs reject (the #40033 reproduction)."""
188+
mock_openai.return_value = MagicMock()
189+
agent = AIAgent(
190+
api_key="test-key",
191+
base_url="http://localhost:8080/v1",
192+
model="my-custom-model",
193+
provider="custom",
194+
quiet_mode=True,
195+
skip_context_files=True,
196+
skip_memory=True,
197+
)
198+
199+
with patch("hermes_cli.config.load_config", return_value={
200+
"model": {"default_headers": {"User-Agent": "curl/8.7.1", "X-Extra": "1"}},
201+
}):
202+
agent._apply_client_headers_for_base_url("http://localhost:8080/v1")
203+
204+
headers = agent._client_kwargs["default_headers"]
205+
assert headers["User-Agent"] == "curl/8.7.1"
206+
assert headers["X-Extra"] == "1"
207+
208+
209+
@patch("run_agent.OpenAI")
210+
def test_user_default_headers_win_over_provider_defaults(mock_openai):
211+
"""User headers take precedence but leave untouched provider defaults intact."""
212+
mock_openai.return_value = MagicMock()
213+
agent = AIAgent(
214+
api_key="test-key",
215+
base_url="https://openrouter.ai/api/v1",
216+
model="test/model",
217+
quiet_mode=True,
218+
skip_context_files=True,
219+
skip_memory=True,
220+
)
221+
222+
with patch("hermes_cli.config.load_config", return_value={
223+
"model": {"default_headers": {"X-Title": "MyApp"}},
224+
}):
225+
agent._apply_client_headers_for_base_url("https://openrouter.ai/api/v1")
226+
227+
headers = agent._client_kwargs["default_headers"]
228+
assert headers["X-Title"] == "MyApp" # user override wins
229+
assert headers["HTTP-Referer"] == "https://hermes-agent.nousresearch.com" # default preserved
230+
231+
232+
@patch("run_agent.OpenAI")
233+
def test_no_user_default_headers_leaves_provider_defaults_untouched(mock_openai):
234+
mock_openai.return_value = MagicMock()
235+
agent = AIAgent(
236+
api_key="test-key",
237+
base_url="https://openrouter.ai/api/v1",
238+
model="test/model",
239+
quiet_mode=True,
240+
skip_context_files=True,
241+
skip_memory=True,
242+
)
243+
244+
with patch("hermes_cli.config.load_config", return_value={"model": {}}):
245+
agent._apply_client_headers_for_base_url("https://openrouter.ai/api/v1")
246+
247+
headers = agent._client_kwargs["default_headers"]
248+
assert headers["HTTP-Referer"] == "https://hermes-agent.nousresearch.com"
249+
assert "User-Agent" not in headers # nothing injected when unconfigured
250+
251+
252+
@patch("run_agent.OpenAI")
253+
def test_user_default_headers_skipped_for_anthropic_mode(mock_openai):
254+
"""Anthropic/Bedrock modes don't use the OpenAI client — never touched."""
255+
mock_openai.return_value = MagicMock()
256+
agent = AIAgent(
257+
api_key="test-key",
258+
base_url="http://localhost:8080/v1",
259+
model="my-custom-model",
260+
provider="custom",
261+
quiet_mode=True,
262+
skip_context_files=True,
263+
skip_memory=True,
264+
)
265+
agent.api_mode = "anthropic_messages"
266+
agent._client_kwargs = {}
267+
268+
with patch("hermes_cli.config.load_config", return_value={
269+
"model": {"default_headers": {"User-Agent": "curl/8.7.1"}},
270+
}):
271+
agent._apply_user_default_headers()
272+
273+
assert "default_headers" not in agent._client_kwargs
274+
275+
179276
@patch("run_agent.OpenAI")
180277
def test_openrouter_headers_no_cache_when_disabled(mock_openai):
181278
"""When openrouter.response_cache is False, no cache headers are sent."""

0 commit comments

Comments
 (0)