# NousResearch/hermes-agent · Custom HTTP headers support and propagation for providers

## Open now

- 3 issues, 2 PRs open on GitHub.
- #9398: \[Feature\]: Support custom HTTP headers via config.yaml
- #46000: Third-party Anthropic endpoints behind Cloudflare/WAF block SDK default User-Agent (HTTP 403)
- #46877: Support per-provider custom HTTP headers in providers: config block
- PR #9454: feat(config): support user-defined custom HTTP headers for LLM API requests
- PR #9518: feat(config): support custom provider HTTP headers

These items concern adding, configuring, and reliably propagating custom HTTP headers (including User-Agent overrides and extra headers) for LLM providers, with recurring focus on headers being dropped during model switching, fallback activation, and client initialization.

## TL;DR

- 28 member(s): 3 open issue(s), 8 closed issue(s), 2 open PR(s), 3 merged PR(s), 12 PR(s) closed without merging.
- A fix was merged to main in PR #6754. Verify it addresses your case.
- **Recommended:** read PR #6754 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-115812`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
- Members outside that structural core may belong to a different underlying problem.
- Pairwise link density `0.07`: the share of member 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.

## Open related issues

- #9398: \[Feature\]: Support custom HTTP headers via config.yaml
- #46000: Third-party Anthropic endpoints behind Cloudflare/WAF block SDK default User-Agent (HTTP 403)
- #46877: Support per-provider custom HTTP headers in providers: config block

## Closed / duplicate issues

- #6075: \[Bug\]: Provider-specific headers (e.g. KimiCLI User-Agent) lost after fallback activation
- #9721: \[Bug\] Cannot set custom HTTP headers for custom providers — Cloudflare 403
- #12785: \[Feature\]: configurable default headers / User-Agent for third-party API
- #24293: Custom providers behind Cloudflare WAF return 403 due to SDK User-Agent
- #25354: \[Bug\] Volcengine Ark API fails with HTTP 400 due to User-Agent fingerprinting - need custom headers support for providers
- #39889: \[Feature\]: ZDR header support for privacy/data retention compliance
- #52686: \[Bug\]: providers.<name>.default_headers dropped on cron/CLI/oneshot agent init
- #64217: switch_model drops user-configured extra_headers, causing 403 from WAF-protected custom providers

## Prior & competing PRs

- **#6754: fix: fallback header preservation, kimi credential pool, TUI stale model display**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `agent/auxiliary_client.py`, `agent/credential_pool.py`, `cli.py`, `hermes_cli/auth.py`, `run_agent.py`
  - fix hunks:
  - `agent/auxiliary_client.py`

    ```diff
    @@ -702,7 +702,7 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
                 logger.debug("Auxiliary text client: %s (%s) via pool", pconfig.name, model)
                 extra = {}
                 if "api.kimi.com" in base_url.lower():
    -                extra["default_headers"] = {"User-Agent": "KimiCLI/1.0"}
    +                extra["default_headers"] = {"User-Agent": "KimiCLI/1.3"}
                 elif "api.githubcopilot.com" in base_url.lower():
                     from hermes_cli.models import copilot_default_headers
     
    ```

    ```diff
    @@ -721,7 +721,7 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
             logger.debug("Auxiliary text client: %s (%s)", pconfig.name, model)
             extra = {}
             if "api.kimi.com" in base_url.lower():
    -            extra["default_headers"] = {"User-Agent": "KimiCLI/1.0"}
    +            extra["default_headers"] = {"User-Agent": "KimiCLI/1.3"}
             elif "api.githubcopilot.com" in base_url.lower():
                 from hermes_cli.models import copilot_default_headers
     
    ```

    ```diff
    @@ -1195,7 +1195,7 @@ def _to_async_client(sync_client, model: str):
     
             async_kwargs["default_headers"] = copilot_default_headers()
         elif "api.kimi.com" in base_lower:
    -        async_kwargs["default_headers"] = {"User-Agent": "KimiCLI/1.0"}
    +        async_kwargs["default_headers"] = {"User-Agent": "KimiCLI/1.3"}
         return AsyncOpenAI(**async_kwargs), model
     
     
    ```

    ```diff
    @@ -1315,7 +1315,13 @@ def resolve_provider_client(
                     )
                     return None, None
                 final_model = model or _read_main_model() or "gpt-4o-mini"
    -            client = OpenAI(api_key=[redacted:secret-assignment] base_url=custom_base)
    +            extra = {}
    +            if "api.kimi.com" in custom_base.lower():
    +                extra["default_headers"] = {"User-Agent": "KimiCLI/1.3"}
    +            elif "api.githubcopilot.com" in custom_base.lower():
    +                from hermes_cli.models import copilot_default_headers
    +                extra["default_headers"] = copilot_default_headers()
    +            client = OpenAI(api_key=[redacted:secret-assignment] base_url=custom_base, **extra)
                 return (_to_async_client(client, final_model) if async_mode
                         else (client, final_model))
             # Try custom first, then codex, then API-key providers
    ```

    ```diff
    @@ -1394,7 +1400,7 @@ def resolve_provider_client(
             # Provider-specific headers
             headers = {}
             if "api.kimi.com" in base_url.lower():
    -            headers["User-Agent"] = "KimiCLI/1.0"
    +            headers["User-Agent"] = "KimiCLI/1.3"
             elif "api.githubcopilot.com" in base_url.lower():
                 from hermes_cli.models import copilot_default_headers
     
    ```
  - `agent/credential_pool.py`

    ```diff
    @@ -18,12 +18,14 @@
     from hermes_cli.auth import (
         CODEX_ACCESS_TOKEN_REFRESH_SKEW_SECONDS,
         DEFAULT_AGENT_KEY_MIN_TTL_SECONDS,
    +    KIMI_CODE_BASE_URL,
         PROVIDER_REGISTRY,
         _codex_access_token_is_expiring,
         _decode_jwt_claims,
         _import_codex_cli_tokens,
         _load_auth_store,
         _load_provider_state,
    +    _resolve_kimi_base_url,
         _resolve_zai_base_url,
         read_credential_pool,
         write_credential_pool,
    ```

    ```diff
    @@ -1084,7 +1086,9 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool
             active_sources.add(source)
             auth_type = AUTH_TYPE_OAUTH if provider == "anthropic" and not token.startswith("sk-ant-api") else AUTH_TYPE_API_KEY
             base_url = env_url or pconfig.inference_base_url
    -        if provider == "zai":
    +        if provider == "kimi-coding":
    +            base_url = _resolve_kimi_base_url(token, pconfig.inference_base_url, env_url)
    +        elif provider == "zai":
                 base_url = _resolve_zai_base_url(token, pconfig.inference_base_url, env_url)
             changed |= _upsert_entry(
                 entries,
    ```
  - `cli.py`

    ```diff
    @@ -1603,7 +1603,12 @@ def _build_context_bar(self, percent_used: Optional[int], width: int = 10) -> st
             return f"[{('█' * filled) + ('░' * max(0, width - filled))}]"
     
         def _get_status_bar_snapshot(self) -> Dict[str, Any]:
    -        model_name = self.model or "unknown"
    +        # Prefer the agent's model name — it updates on fallback.
    +        # self.model reflects the originally configured model and never
    +        # changes mid-session, so the TUI would show a stale name after
    +        # _try_activate_fallback() switches provider/model.
    +        agent = getattr(self, "agent", None)
    +        model_name = (getattr(agent, "model", None) or self.model or "unknown")
             model_short = model_name.split("/")[-1] if "/" in model_name else model_name
             if model_short.endswith(".gguf"):
                 model_short = model_short[:-5]
    ```

    ```diff
    @@ -1629,7 +1634,6 @@ def _get_status_bar_snapshot(self) -> Dict[str, Any]:
                 "compressions": 0,
             }
     
    -        agent = getattr(self, "agent", None)
             if not agent:
                 return snapshot
     
    ```
  - `hermes_cli/auth.py`

    ```diff
    @@ -250,7 +250,7 @@ class ProviderConfig:
     # Kimi Code Endpoint Detection
     # =============================================================================
     
    -# Kimi Code (platform.kimi.ai) issues keys prefixed "sk-kimi-" that only work
    +# Kimi Code (kimi.com/code) issues keys prefixed "sk-kimi-" that only work
     # on api.kimi.com/coding/v1.  Legacy keys from platform.moonshot.ai work on
     # api.moonshot.ai/v1 (the default).  Auto-detect when user hasn't set
     # KIMI_BASE_URL explicitly.
    ```
  - `run_agent.py`

    ```diff
    @@ -4968,9 +4968,21 @@ def _try_activate_fallback(self) -> bool:
                     # Swap OpenAI client and config in-place
                     self.api_key = [redacted:secret-assignment]
                     self.client = fb_client
    +                # Preserve provider-specific headers that
    +                # resolve_provider_client() may have baked into
    +                # fb_client via the default_headers kwarg.  The OpenAI
    +                # SDK stores these in _custom_headers.  Without this,
    +                # subsequent request-client rebuilds (via
    +                # _create_request_openai_client) drop the headers,
    +                # causing 403s from providers like Kimi Coding that
    +                # require a User-Agent sentinel.
    +                fb_headers = getattr(fb_client, "_custom_headers", None)
    +                if not fb_headers:
    +                    fb_headers = getattr(fb_client, "default_headers", None)
                     self._client_kwargs = {
                         "api_key": fb_client.api_key,
                         "base_url": fb_base_url,
    +                    **({"default_headers": dict(fb_headers)} if fb_headers else {}),
                     }
     
                 # Re-evaluate prompt caching for the new provider/model
    ```
- **#24559: fix(anthropic_adapter): override User-Agent for third-party endpoints**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#25949: feat(agent): configurable default_headers for API requests**. closed without merge · fix tier `suggested`
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#14314`
- **#41096: fix: honor model.default_headers for custom OpenAI-compatible providers, main + auxiliary clients (#40033)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - 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`
  - fix hunks:
  - `agent/agent_init.py`

    ```diff
    @@ -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 = [redacted:secret-assignment]"api_key", "")
             agent.base_url = client_kwargs.get("base_url", agent.base_url)
             try:
    ```
  - `agent/auxiliary_client.py`

    ```diff
    @@ -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.
     
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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
     
     
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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")
    ```

    ```diff
    @@ -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))
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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."""
    ```
- **#52687: fix(runtime): propagate named-provider default_headers to OpenAI client (#52686)**. closed without merge · fix tier `suggested`
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#57086`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#64237: fix: reapply user-configured headers on switch_model (#64217)**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `0a4b4d6df52f9a7ba9ae5914c5035b171d129f47`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#115812: fix(providers): WAF 403s report as a firewall block, not a rejected key; anthropic_messages routes send custom extra_headers (#24293, #9721, #53099, #70566, salvage #46002 #70567 #53114)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `agent/agent_runtime_helpers.py`, `agent/anthropic_adapter.py`, `agent/error_classifier.py`, `agent/error_surface.py`, `agent/turn_failure_copy.py`, `agent/turn_recovery.py`, `apps/desktop/src/i18n/en.ts`, `apps/desktop/src/lib/error-surface.test.ts`, `apps/desktop/src/lib/error-surface.ts`, `cli.py`, `cron/scheduler_failure_copy.py`, `hermes_cli/cli_chat_error_copy.py`, `tests/agent/test_anthropic_adapter_extra_headers.py`, `tests/agent/test_error_classifier.py`, `tests/agent/test_error_classifier_upstream_blocked.py`, `tests/agent/test_run_agent_codex_responses.py`, `tests/cron/test_cron_failure_notice_copy.py`, `tests/hermes_cli/test_single_query_exit_contract.py`, `tui_gateway/user_messages.py`, `ui-tui/src/app/userMessages.ts` (+1 more)
- **#6076: fix(fallback): preserve provider-specific headers when activating fallback**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#6754`
- **#9454: feat(config): support user-defined custom HTTP headers for LLM API requests**. open
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#9518`, `#9398`
  - files: `agent/anthropic_adapter.py`, `agent/auxiliary_client.py`, `cli-config.yaml.example`, `hermes_cli/config.py`, `tests/agent/test_custom_headers.py`
- **#9518: feat(config): support custom provider HTTP headers**. open
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#14314`, `#9398`, `#14314`
  - files: `agent/anthropic_adapter.py`, `agent/auxiliary_client.py`, `agent/client_headers.py`, `cli-config.yaml.example`, `run_agent.py`, `tests/agent/test_anthropic_adapter.py`, `tests/agent/test_auxiliary_client.py`, `tests/agent/test_client_headers.py`
- **#14314: feat: add custom header support for provider requests**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#9518`, `#56244`, `#50796`
- **#24295: fix(anthropic): override SDK User-Agent for third-party endpoints (#24293)**. closed without merge
  - maintainer direction: category `stale_abandoned`; implication `active`
- **#24306: fix: add configurable User-Agent for custom providers**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#41096`, `#40033`
- **#24308: Fix: Custom providers behind Cloudflare WAF blocked by SDK User-Agent header**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#41096`, `#40033`
- **#26505: feat(config): support custom provider headers**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#41096`, `#40033`
- **#46002: fix(custom-providers): apply extra_headers to Anthropic clients**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `cb062f7bd3d1`, `#115812`, `#24293`, `#9721`, `#53099`, `#70566`, `#46002`, `#70567`, `#53114`
- **#57086: fix(config): accept provider default_headers alias**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`

## Fix status

- Merged to main in PR #6754 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-09-19`.
- Subsystem: `agent`
