NousResearch/hermes-agent

Custom OpenAI-compatible providers and model.default_headers support

Cluster 40033. Data as of

Nothing open on GitHub. Fix merged in #41096 on 7 Jun 2026. Check it covers your case.

The short answer

0open issues
0open PRs
4items grouped
Known?
Yes. 4 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #41096. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this resolved, deciding PR #41094. That is separate from the fix event.
Still open
Nothing open on GitHub.

Most members concern honoring a model.default_headers setting for custom OpenAI-compatible providers, including a report that upstreams may block the OpenAI Python SDK's default headers, and two fix pull requests referencing #40033. One member is a release-mapping chore for a contributor email, which appears unrelated to the header topic.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #41096 fix: honor model.default_headers for custom OpenAI-compatible providers, main + auxiliary clients (#40033)

    merged to main

    files: agent/agent_init.py, agent/auxiliary_client.py, cli-config.yaml.example, run_agent.py, tests/agent/test_auxiliary_user_default_headers.py, tests/run_agent/test_provider_attribution_headers.py

    Show the diff
    • 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:
    • agent/auxiliary_client.py
      @@ -357,6 +357,35 @@ def _get_aux_model_for_provider(provider_id: str) -> str:
       _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=[redacted:secret-assignment] 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=[redacted:secret-assignment] 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=[redacted:secret-assignment] 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 _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "",
                               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=[redacted:secret-assignment] 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 _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "",
                           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 _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "",
                               _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=[redacted:secret-assignment] 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 _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "",
                           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=[redacted:secret-assignment] base_url=base_url,
                               **({"default_headers": headers} if headers else {}))
       
    • 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.
    • run_agent.py
      @@ -3809,6 +3809,41 @@ def _apply_client_headers_for_base_url(self, base_url: str) -> None:
                   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)
      +
      +        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
      +        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
      +
           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
    • 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
      +
      +
      [redacted:email](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"
    • 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="[redacted:secret-assignment]",
      +        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="[redacted:secret-assignment]",
      +        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="[redacted:secret-assignment]",
      +        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="[redacted:secret-assignment]",
      +        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."""
  • #41094 chore(release): map [redacted:email] to sanidhyasin

    merged to main

    files: scripts/release.py

1 PR closed without merging

From the close comments: merged via another change 1

  • #40403 fix(agent): honor model.default_headers for custom OpenAI-compatible providers (#40033) · merged via another change → a216ff8, #41096
Closed issues · 1
  • Closed#40033Custom OpenAI-compatible provider can fail when upstream blocks OpenAI Python SDK default headers
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.50: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Merged PRs
#41096, #41094
Closed issues
1
Attempts
1 PR closed without merging
Fix link checked
7 Jun 2026

For your agent

The same facts, typed.