From ffe665277ccff676d313b0f0f37d2cb9775a6930 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Sun, 7 Jun 2026 13:50:06 +0530 Subject: [PATCH] fix(aux): honor model.default_headers on auxiliary client too (#40033) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The salvaged main-agent fix (sanidhyasin) applies model.default_headers to the primary OpenAI client, but the auxiliary client (title generation, context compression, vision routing) builds its own clients and did not read the override. For a `provider: custom` endpoint behind a gateway/WAF that rejects the OpenAI SDK's identifying headers, the main turn would succeed while auxiliary calls to the same endpoint still failed with the opaque 502/4xx from #40033. Add agent.auxiliary_client._apply_user_default_headers() (user values win over provider/SDK defaults; no-op when unconfigured) and apply it at every OpenAI-wire client construction site: - _try_custom_endpoint() — config-level `model.provider: custom` - the named custom-provider branch (custom_providers/providers entries), including the anthropic-SDK-missing OpenAI-wire fallback - the api-key-provider, async-conversion, and main resolve_provider_client fallback branches To prevent the two clients ever drifting on precedence/value handling, AIAgent._apply_user_default_headers (run_agent.py) now delegates the config read + merge to this shared helper (run_agent already imports from auxiliary_client). Native Anthropic/Bedrock branches are untouched (they don't use the OpenAI wire). 8 new tests (helper semantics + config-level custom + named custom); full aux + attribution header suites green (295). --- agent/auxiliary_client.py | 57 ++++++++ run_agent.py | 20 +-- .../test_auxiliary_user_default_headers.py | 137 ++++++++++++++++++ 3 files changed, 202 insertions(+), 12 deletions(-) create mode 100644 tests/agent/test_auxiliary_user_default_headers.py diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 2eb8e1c3030..79352e2fe3a 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -357,6 +357,35 @@ _OR_HEADERS_BASE = { _TRUTHY_ENV_VALUES = frozenset({"1", "true", "yes", "on"}) +def _apply_user_default_headers(headers: dict | None) -> dict | None: + """Merge user-configured ``model.default_headers`` onto resolved headers. + + User values take precedence over provider/SDK defaults, mirroring the main + agent client (``AIAgent._apply_user_default_headers``). This lets a + ``custom`` OpenAI-compatible endpoint behind a gateway/WAF that rejects the + OpenAI SDK's identifying headers (``User-Agent: OpenAI/Python ...``, + ``X-Stainless-*``) override them for auxiliary calls too — otherwise the + main turn would succeed but title/compression/vision calls to the same + endpoint would still fail. (#40033) + + Returns the merged dict, or the original ``headers`` (possibly ``None``) + when nothing is configured. No allocation when there are no overrides. + """ + try: + from hermes_cli.config import cfg_get, load_config + user_headers = cfg_get(load_config(), "model", "default_headers") + except Exception: + return headers + if not isinstance(user_headers, dict) or not user_headers: + return headers + merged = dict(headers or {}) + for key, value in user_headers.items(): + if value is None: + continue + merged[str(key)] = str(value) + return merged or headers + + def build_or_headers(or_config: dict | None = None) -> dict: """Build OpenRouter headers, optionally including response-cache headers. @@ -1495,6 +1524,9 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]: extra["default_headers"] = dict(_ph_aux.default_headers) except Exception: pass + _merged_aux = _apply_user_default_headers(extra.get("default_headers")) + if _merged_aux: + extra["default_headers"] = _merged_aux _client = OpenAI(api_key=api_key, base_url=base_url, **extra) _client = _maybe_wrap_anthropic(_client, model, api_key, raw_base_url) return _client, model @@ -1532,6 +1564,9 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]: extra["default_headers"] = dict(_ph_aux2.default_headers) except Exception: pass + _merged_aux2 = _apply_user_default_headers(extra.get("default_headers")) + if _merged_aux2: + extra["default_headers"] = _merged_aux2 _client = OpenAI(api_key=api_key, base_url=base_url, **extra) _client = _maybe_wrap_anthropic(_client, model, api_key, raw_base_url) return _client, model @@ -1922,6 +1957,13 @@ def _try_custom_endpoint() -> Tuple[Optional[Any], Optional[str]]: logger.debug("Auxiliary client: custom endpoint (%s, api_mode=%s)", model, custom_mode or "chat_completions") _clean_base, _dq = _extract_url_query_params(custom_base) _extra = {"default_query": _dq} if _dq else {} + # User-configured model.default_headers override the SDK's identifying + # headers (User-Agent: OpenAI/Python ..., X-Stainless-*) on this custom + # endpoint's auxiliary calls too — matching the main agent client so the + # whole session reaches a gateway/WAF that rejects the SDK fingerprint. (#40033) + _custom_headers = _apply_user_default_headers(None) + if _custom_headers: + _extra["default_headers"] = _custom_headers if custom_mode == "codex_responses": real_client = OpenAI(api_key=custom_key, base_url=_clean_base, **_extra) return CodexAuxiliaryClient(real_client, model), model @@ -3291,6 +3333,9 @@ def _to_async_client(sync_client, model: str, is_vision: bool = False): async_kwargs["default_headers"] = dict(_ph_async.default_headers) except Exception: pass + _merged_async = _apply_user_default_headers(async_kwargs.get("default_headers")) + if _merged_async: + async_kwargs["default_headers"] = _merged_async return AsyncOpenAI(**async_kwargs), model @@ -3578,6 +3623,9 @@ def resolve_provider_client( extra["default_headers"] = dict(_ph_custom.default_headers) except Exception: pass + _merged_custom = _apply_user_default_headers(extra.get("default_headers")) + if _merged_custom: + extra["default_headers"] = _merged_custom client = OpenAI(api_key=custom_key, base_url=_clean_base, **extra) client = _wrap_if_needed(client, final_model, custom_base, custom_key) return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode @@ -3654,6 +3702,9 @@ def resolve_provider_client( raw_base_for_wrap = custom_base _clean_base2, _dq2 = _extract_url_query_params(openai_base) _extra2 = {"default_query": _dq2} if _dq2 else {} + _headers2 = _apply_user_default_headers(_extra2.get("default_headers")) + if _headers2: + _extra2["default_headers"] = _headers2 logger.debug( "resolve_provider_client: named custom provider %r (%s, api_mode=%s)", provider, final_model, entry_api_mode or "chat_completions") @@ -3676,6 +3727,9 @@ def resolve_provider_client( _fallback_base = _to_openai_base_url(custom_base) _fb_clean, _fb_dq = _extract_url_query_params(_fallback_base) _fb_extra = {"default_query": _fb_dq} if _fb_dq else {} + _fb_headers = _apply_user_default_headers(_fb_extra.get("default_headers")) + if _fb_headers: + _fb_extra["default_headers"] = _fb_headers client = OpenAI(api_key=custom_key, base_url=_fb_clean, **_fb_extra) return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode else (client, final_model)) @@ -3824,6 +3878,9 @@ def resolve_provider_client( headers.update(_ph_main.default_headers) except Exception: pass + _merged_main = _apply_user_default_headers(headers) + if _merged_main: + headers = _merged_main client = OpenAI(api_key=api_key, base_url=base_url, **({"default_headers": headers} if headers else {})) diff --git a/run_agent.py b/run_agent.py index 24d4a19ea3d..81ce106428b 100644 --- a/run_agent.py +++ b/run_agent.py @@ -3828,23 +3828,19 @@ class AIAgent: reach such an upstream instead of failing with an opaque 4xx/502 even though the same body works under ``curl``. (#40033) + Delegates the config read + merge to + ``agent.auxiliary_client._apply_user_default_headers`` so the main and + auxiliary clients can never drift on precedence or value handling. + 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) + from agent.auxiliary_client import ( + _apply_user_default_headers as _merge_user_headers, + ) + merged = _merge_user_headers(self._client_kwargs.get("default_headers")) if merged: self._client_kwargs["default_headers"] = merged diff --git a/tests/agent/test_auxiliary_user_default_headers.py b/tests/agent/test_auxiliary_user_default_headers.py new file mode 100644 index 00000000000..c2038e5476f --- /dev/null +++ b/tests/agent/test_auxiliary_user_default_headers.py @@ -0,0 +1,137 @@ +"""Tests for user-configured ``model.default_headers`` in the auxiliary client. + +Companion to ``tests/run_agent/test_provider_attribution_headers.py`` (which +covers the main agent client). The main agent turn and the auxiliary client +(title generation, context compression, vision routing) build separate OpenAI +clients, so a ``custom`` endpoint behind a gateway/WAF that rejects the OpenAI +SDK's identifying headers needs the ``model.default_headers`` override applied +on BOTH paths — otherwise the main turn succeeds but auxiliary calls to the +same endpoint still fail with an opaque 4xx/502. (#40033) +""" + +from unittest.mock import patch, MagicMock + +import pytest + + +@pytest.fixture(autouse=True) +def _isolate(tmp_path, monkeypatch): + """Redirect HERMES_HOME so load_config() reads our test config.yaml.""" + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + (hermes_home / "config.yaml").write_text("model:\n default: test-model\n") + + +def _write_config(tmp_path, config_dict): + import yaml + (tmp_path / ".hermes" / "config.yaml").write_text(yaml.dump(config_dict)) + + +class TestApplyUserDefaultHeadersHelper: + """Direct unit tests for the merge helper.""" + + def test_user_headers_merged_and_win(self, tmp_path): + _write_config(tmp_path, { + "model": {"default": "m", "default_headers": {"User-Agent": "curl/8.7.1", "X-Extra": "1"}}, + }) + from agent.auxiliary_client import _apply_user_default_headers + merged = _apply_user_default_headers({"User-Agent": "OpenAI/Python 2.24.0"}) + assert merged["User-Agent"] == "curl/8.7.1" # user wins + assert merged["X-Extra"] == "1" + + def test_no_config_is_noop_returns_original(self, tmp_path): + _write_config(tmp_path, {"model": {"default": "m"}}) + from agent.auxiliary_client import _apply_user_default_headers + original = {"User-Agent": "OpenAI/Python"} + merged = _apply_user_default_headers(original) + assert merged == original + + def test_none_headers_with_config_creates_dict(self, tmp_path): + _write_config(tmp_path, { + "model": {"default": "m", "default_headers": {"User-Agent": "curl/8.7.1"}}, + }) + from agent.auxiliary_client import _apply_user_default_headers + merged = _apply_user_default_headers(None) + assert merged == {"User-Agent": "curl/8.7.1"} + + def test_none_headers_no_config_returns_none(self, tmp_path): + _write_config(tmp_path, {"model": {"default": "m"}}) + from agent.auxiliary_client import _apply_user_default_headers + assert _apply_user_default_headers(None) is None + + def test_none_values_skipped(self, tmp_path): + _write_config(tmp_path, { + "model": {"default": "m", "default_headers": {"User-Agent": "curl/8.7.1", "X-Drop": None}}, + }) + from agent.auxiliary_client import _apply_user_default_headers + merged = _apply_user_default_headers({}) + assert merged == {"User-Agent": "curl/8.7.1"} + assert "X-Drop" not in merged + + +class TestAuxClientHonorsUserDefaultHeaders: + """Integration: resolve_provider_client must pass overridden headers to OpenAI.""" + + def test_custom_provider_overrides_sdk_user_agent(self, tmp_path): + """The #40033 reproduction on the auxiliary path.""" + _write_config(tmp_path, { + "model": { + "default": "my-custom-model", + "provider": "custom", + "base_url": "http://localhost:8080/v1", + "default_headers": {"User-Agent": "curl/8.7.1", "X-Extra": "1"}, + }, + }) + with patch("agent.auxiliary_client.OpenAI") as mock_openai: + mock_openai.return_value = MagicMock() + from agent.auxiliary_client import resolve_provider_client + client, model = resolve_provider_client("main", "my-custom-model") + + assert client is not None + assert mock_openai.called + headers = mock_openai.call_args.kwargs.get("default_headers", {}) + assert headers.get("User-Agent") == "curl/8.7.1" + assert headers.get("X-Extra") == "1" + + def test_custom_provider_no_override_sends_no_user_agent(self, tmp_path): + """Without config, the aux client injects nothing — SDK defaults apply.""" + _write_config(tmp_path, { + "model": { + "default": "my-custom-model", + "provider": "custom", + "base_url": "http://localhost:8080/v1", + }, + }) + with patch("agent.auxiliary_client.OpenAI") as mock_openai: + mock_openai.return_value = MagicMock() + from agent.auxiliary_client import resolve_provider_client + client, model = resolve_provider_client("main", "my-custom-model") + + assert client is not None + headers = mock_openai.call_args.kwargs.get("default_headers", {}) or {} + assert "User-Agent" not in headers + + def test_named_custom_provider_honors_override(self, tmp_path): + """A `custom_providers:` entry's aux calls also honor model.default_headers. + + This is a distinct construction path (_extra2) from the config-level + `model.provider: custom` path — both must apply the global override. + """ + _write_config(tmp_path, { + "model": { + "default": "test-model", + "default_headers": {"User-Agent": "curl/8.7.1"}, + }, + "custom_providers": [ + {"name": "my-gw", "base_url": "http://my-gw.local/v1", "api_key": "k"}, + ], + }) + with patch("agent.auxiliary_client.OpenAI") as mock_openai: + mock_openai.return_value = MagicMock() + from agent.auxiliary_client import resolve_provider_client + client, model = resolve_provider_client("my-gw", "test-model") + + assert client is not None + headers = mock_openai.call_args.kwargs.get("default_headers", {}) or {} + assert headers.get("User-Agent") == "curl/8.7.1"