mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-09 08:21:50 +00:00
fix(aux): honor model.default_headers on auxiliary client too (#40033)
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).
This commit is contained in:
parent
a216ff839b
commit
ffe665277c
3 changed files with 202 additions and 12 deletions
|
|
@ -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 {}))
|
||||
|
||||
|
|
|
|||
20
run_agent.py
20
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
|
||||
|
||||
|
|
|
|||
137
tests/agent/test_auxiliary_user_default_headers.py
Normal file
137
tests/agent/test_auxiliary_user_default_headers.py
Normal file
|
|
@ -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"
|
||||
Loading…
Add table
Add a link
Reference in a new issue