diff --git a/agent/agent_init.py b/agent/agent_init.py index 0439b5c9784..62de3f2c540 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -885,6 +885,14 @@ def init_agent( headers["x-anthropic-beta"] = _FINE_GRAINED client_kwargs["default_headers"] = headers + # User-configured request headers (model.default_headers in + # config.yaml) override provider/SDK defaults. Lets custom + # OpenAI-compatible endpoints behind a gateway/WAF that rejects the + # OpenAI SDK's identifying headers swap in a plain User-Agent. (#40033) + # client_kwargs is the same dict object as agent._client_kwargs, so + # this mutation is reflected in the client built just below. + agent._apply_user_default_headers() + agent.api_key = client_kwargs.get("api_key", "") agent.base_url = client_kwargs.get("base_url", agent.base_url) try: diff --git a/cli-config.yaml.example b/cli-config.yaml.example index bfecaed6a49..588f30a7d30 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -72,6 +72,20 @@ model: # # max_tokens: 8192 + # ── Custom request headers (optional) ───────────────────────────────────── + # + # default_headers: extra HTTP headers sent on every request to an + # OpenAI-compatible endpoint. User values take precedence over the + # provider/SDK defaults, so this is the supported way to override the + # OpenAI Python SDK's identifying headers (User-Agent: OpenAI/Python ..., + # X-Stainless-*) when a custom provider sits behind a gateway/WAF that + # rejects them — e.g. an upstream that returns "502 Upstream access + # forbidden" for the SDK default User-Agent but accepts a plain one. + # Applies on the OpenAI wire only (not native Anthropic / Bedrock). + # + # default_headers: + # User-Agent: "curl/8.7.1" + # Named provider overrides (optional) # Use this for per-provider request timeouts, non-stream stale timeouts, # and per-model exceptions. diff --git a/run_agent.py b/run_agent.py index 846b789139d..24d4a19ea3d 100644 --- a/run_agent.py +++ b/run_agent.py @@ -3809,6 +3809,45 @@ class AIAgent: else: self._client_kwargs.pop("default_headers", None) + # User-configured overrides win over URL/profile defaults — keep them + # applied across credential swaps and client rebuilds, not just at + # first construction. + self._apply_user_default_headers() + + def _apply_user_default_headers(self) -> None: + """Merge user-configured request headers onto the OpenAI client. + + Reads ``model.default_headers`` from config.yaml and merges it onto + ``self._client_kwargs["default_headers"]``, with user values taking + precedence over provider- and SDK-supplied defaults. + + This exists for ``custom`` OpenAI-compatible endpoints sitting behind + a gateway/WAF that rejects the OpenAI Python SDK's identifying headers + (``User-Agent: OpenAI/Python ...``, ``X-Stainless-*``). Setting e.g. + ``model.default_headers: {User-Agent: curl/8.7.1}`` lets the request + reach such an upstream instead of failing with an opaque 4xx/502 even + though the same body works under ``curl``. (#40033) + + No-op for Anthropic/Bedrock modes, which don't use the OpenAI client, + and when no overrides are configured. + """ + if self.api_mode in ("anthropic_messages", "bedrock_converse"): + return + try: + from hermes_cli.config import cfg_get, load_config + user_headers = cfg_get(load_config(), "model", "default_headers") + except Exception: + return + if not isinstance(user_headers, dict) or not user_headers: + return + merged = dict(self._client_kwargs.get("default_headers") or {}) + for key, value in user_headers.items(): + if value is None: + continue + merged[str(key)] = str(value) + if merged: + self._client_kwargs["default_headers"] = merged + def _swap_credential(self, entry) -> None: runtime_key = getattr(entry, "runtime_api_key", None) or getattr(entry, "access_token", "") runtime_base = getattr(entry, "runtime_base_url", None) or getattr(entry, "base_url", None) or self.base_url diff --git a/tests/run_agent/test_provider_attribution_headers.py b/tests/run_agent/test_provider_attribution_headers.py index 055c58a75ea..2784ba178d2 100644 --- a/tests/run_agent/test_provider_attribution_headers.py +++ b/tests/run_agent/test_provider_attribution_headers.py @@ -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."""