{"repo":"NousResearch/hermes-agent","public_id":"6075","display_label":"Custom HTTP headers support and propagation for providers","summary":"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.","subsystem":"agent","counts":{"members":28,"open_issues":3,"open_issues_distinct":3,"open_prs":2,"closed_issues":8,"merged_prs":3,"attempt_prs":14},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":6754},"resolution":{"state":"partially_resolved","by":"pr-115812"},"freshness":{"state":"verified","last_verified_at":"2026-09-19T16:57:22+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.07079365079365077},"fix_edges":[{"issue_number":5796,"pr_number":6754,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":6075,"pr_number":6754,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":6398,"pr_number":6754,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":9721,"pr_number":41096,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":9721,"pr_number":115812,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":11301,"pr_number":41096,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":24293,"pr_number":41096,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":24293,"pr_number":115812,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":40033,"pr_number":41096,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":46000,"pr_number":115812,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":53099,"pr_number":115812,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":70566,"pr_number":115812,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":6075,"title":"[Bug]: Provider-specific headers (e.g. KimiCLI User-Agent) lost after fallback activation","url":null,"state":"closed"},{"kind":"issue","number":9398,"title":"[Feature]: Support custom HTTP headers via config.yaml","url":null,"state":"open"},{"kind":"issue","number":9721,"title":"[Bug] Cannot set custom HTTP headers for custom providers — Cloudflare 403","url":null,"state":"closed"},{"kind":"issue","number":12785,"title":"[Feature]: configurable default headers / User-Agent for third-party API","url":null,"state":"closed"},{"kind":"issue","number":24293,"title":"Custom providers behind Cloudflare WAF return 403 due to SDK User-Agent","url":null,"state":"closed"},{"kind":"issue","number":25354,"title":"[Bug] Volcengine Ark API fails with HTTP 400 due to User-Agent fingerprinting - need custom headers support for providers","url":null,"state":"closed"},{"kind":"issue","number":39889,"title":"[Feature]: ZDR header support for privacy/data retention compliance","url":null,"state":"closed"},{"kind":"issue","number":46000,"title":"Third-party Anthropic endpoints behind Cloudflare/WAF block SDK default User-Agent (HTTP 403)","url":null,"state":"open"},{"kind":"issue","number":46877,"title":"Support per-provider custom HTTP headers in providers: config block","url":null,"state":"open"},{"kind":"issue","number":52686,"title":"[Bug]: providers.<name>.default_headers dropped on cron/CLI/oneshot agent init","url":null,"state":"closed"},{"kind":"issue","number":64217,"title":"switch_model drops user-configured extra_headers, causing 403 from WAF-protected custom providers","url":null,"state":"closed"},{"kind":"pr","number":6076,"title":"fix(fallback): preserve provider-specific headers when activating fallback","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#6754","number":6754}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":6754,"title":"fix: fallback header preservation, kimi credential pool, TUI stale model display","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/auxiliary_client.py","agent/credential_pool.py","cli.py","hermes_cli/auth.py","run_agent.py"],"fix_hunks":[{"file":"agent/auxiliary_client.py","hunks":["@@ -702,7 +702,7 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:\n             logger.debug(\"Auxiliary text client: %s (%s) via pool\", pconfig.name, model)\n             extra = {}\n             if \"api.kimi.com\" in base_url.lower():\n-                extra[\"default_headers\"] = {\"User-Agent\": \"KimiCLI/1.0\"}\n+                extra[\"default_headers\"] = {\"User-Agent\": \"KimiCLI/1.3\"}\n             elif \"api.githubcopilot.com\" in base_url.lower():\n                 from hermes_cli.models import copilot_default_headers\n ","@@ -721,7 +721,7 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:\n         logger.debug(\"Auxiliary text client: %s (%s)\", pconfig.name, model)\n         extra = {}\n         if \"api.kimi.com\" in base_url.lower():\n-            extra[\"default_headers\"] = {\"User-Agent\": \"KimiCLI/1.0\"}\n+            extra[\"default_headers\"] = {\"User-Agent\": \"KimiCLI/1.3\"}\n         elif \"api.githubcopilot.com\" in base_url.lower():\n             from hermes_cli.models import copilot_default_headers\n ","@@ -1195,7 +1195,7 @@ def _to_async_client(sync_client, model: str):\n \n         async_kwargs[\"default_headers\"] = copilot_default_headers()\n     elif \"api.kimi.com\" in base_lower:\n-        async_kwargs[\"default_headers\"] = {\"User-Agent\": \"KimiCLI/1.0\"}\n+        async_kwargs[\"default_headers\"] = {\"User-Agent\": \"KimiCLI/1.3\"}\n     return AsyncOpenAI(**async_kwargs), model\n \n ","@@ -1315,7 +1315,13 @@ def resolve_provider_client(\n                 )\n                 return None, None\n             final_model = model or _read_main_model() or \"gpt-4o-mini\"\n-            client = OpenAI(api_key=[redacted:secret-assignment] base_url=custom_base)\n+            extra = {}\n+            if \"api.kimi.com\" in custom_base.lower():\n+                extra[\"default_headers\"] = {\"User-Agent\": \"KimiCLI/1.3\"}\n+            elif \"api.githubcopilot.com\" in custom_base.lower():\n+                from hermes_cli.models import copilot_default_headers\n+                extra[\"default_headers\"] = copilot_default_headers()\n+            client = OpenAI(api_key=[redacted:secret-assignment] base_url=custom_base, **extra)\n             return (_to_async_client(client, final_model) if async_mode\n                     else (client, final_model))\n         # Try custom first, then codex, then API-key providers","@@ -1394,7 +1400,7 @@ def resolve_provider_client(\n         # Provider-specific headers\n         headers = {}\n         if \"api.kimi.com\" in base_url.lower():\n-            headers[\"User-Agent\"] = \"KimiCLI/1.0\"\n+            headers[\"User-Agent\"] = \"KimiCLI/1.3\"\n         elif \"api.githubcopilot.com\" in base_url.lower():\n             from hermes_cli.models import copilot_default_headers\n "]},{"file":"agent/credential_pool.py","hunks":["@@ -18,12 +18,14 @@\n from hermes_cli.auth import (\n     CODEX_ACCESS_TOKEN_REFRESH_SKEW_SECONDS,\n     DEFAULT_AGENT_KEY_MIN_TTL_SECONDS,\n+    KIMI_CODE_BASE_URL,\n     PROVIDER_REGISTRY,\n     _codex_access_token_is_expiring,\n     _decode_jwt_claims,\n     _import_codex_cli_tokens,\n     _load_auth_store,\n     _load_provider_state,\n+    _resolve_kimi_base_url,\n     _resolve_zai_base_url,\n     read_credential_pool,\n     write_credential_pool,","@@ -1084,7 +1086,9 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool\n         active_sources.add(source)\n         auth_type = AUTH_TYPE_OAUTH if provider == \"anthropic\" and not token.startswith(\"sk-ant-api\") else AUTH_TYPE_API_KEY\n         base_url = env_url or pconfig.inference_base_url\n-        if provider == \"zai\":\n+        if provider == \"kimi-coding\":\n+            base_url = _resolve_kimi_base_url(token, pconfig.inference_base_url, env_url)\n+        elif provider == \"zai\":\n             base_url = _resolve_zai_base_url(token, pconfig.inference_base_url, env_url)\n         changed |= _upsert_entry(\n             entries,"]},{"file":"cli.py","hunks":["@@ -1603,7 +1603,12 @@ def _build_context_bar(self, percent_used: Optional[int], width: int = 10) -> st\n         return f\"[{('█' * filled) + ('░' * max(0, width - filled))}]\"\n \n     def _get_status_bar_snapshot(self) -> Dict[str, Any]:\n-        model_name = self.model or \"unknown\"\n+        # Prefer the agent's model name — it updates on fallback.\n+        # self.model reflects the originally configured model and never\n+        # changes mid-session, so the TUI would show a stale name after\n+        # _try_activate_fallback() switches provider/model.\n+        agent = getattr(self, \"agent\", None)\n+        model_name = (getattr(agent, \"model\", None) or self.model or \"unknown\")\n         model_short = model_name.split(\"/\")[-1] if \"/\" in model_name else model_name\n         if model_short.endswith(\".gguf\"):\n             model_short = model_short[:-5]","@@ -1629,7 +1634,6 @@ def _get_status_bar_snapshot(self) -> Dict[str, Any]:\n             \"compressions\": 0,\n         }\n \n-        agent = getattr(self, \"agent\", None)\n         if not agent:\n             return snapshot\n "]},{"file":"hermes_cli/auth.py","hunks":["@@ -250,7 +250,7 @@ class ProviderConfig:\n # Kimi Code Endpoint Detection\n # =============================================================================\n \n-# Kimi Code (platform.kimi.ai) issues keys prefixed \"sk-kimi-\" that only work\n+# Kimi Code (kimi.com/code) issues keys prefixed \"sk-kimi-\" that only work\n # on api.kimi.com/coding/v1.  Legacy keys from platform.moonshot.ai work on\n # api.moonshot.ai/v1 (the default).  Auto-detect when user hasn't set\n # KIMI_BASE_URL explicitly."]},{"file":"run_agent.py","hunks":["@@ -4968,9 +4968,21 @@ def _try_activate_fallback(self) -> bool:\n                 # Swap OpenAI client and config in-place\n                 self.api_key = [redacted:secret-assignment]\n                 self.client = fb_client\n+                # Preserve provider-specific headers that\n+                # resolve_provider_client() may have baked into\n+                # fb_client via the default_headers kwarg.  The OpenAI\n+                # SDK stores these in _custom_headers.  Without this,\n+                # subsequent request-client rebuilds (via\n+                # _create_request_openai_client) drop the headers,\n+                # causing 403s from providers like Kimi Coding that\n+                # require a User-Agent sentinel.\n+                fb_headers = getattr(fb_client, \"_custom_headers\", None)\n+                if not fb_headers:\n+                    fb_headers = getattr(fb_client, \"default_headers\", None)\n                 self._client_kwargs = {\n                     \"api_key\": fb_client.api_key,\n                     \"base_url\": fb_base_url,\n+                    **({\"default_headers\": dict(fb_headers)} if fb_headers else {}),\n                 }\n \n             # Re-evaluate prompt caching for the new provider/model\n"]}]},{"kind":"pr","number":9454,"title":"feat(config): support user-defined custom HTTP headers for LLM API requests","url":null,"state":"open","close_reason":{"refs":[{"kind":"pr","value":"#9518","number":9518},{"kind":"pr","value":"#9398","number":9398}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":9518,"title":"feat(config): support custom provider HTTP headers","url":null,"state":"open","close_reason":{"refs":[{"kind":"pr","value":"#14314","number":14314},{"kind":"pr","value":"#9398","number":9398},{"kind":"pr","value":"#14314","number":14314}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":14314,"title":"feat: add custom header support for provider requests","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#9518","number":9518},{"kind":"pr","value":"#56244","number":56244},{"kind":"pr","value":"#50796","number":50796}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":24295,"title":"fix(anthropic): override SDK User-Agent for third-party endpoints (#24293)","url":null,"state":"closed","close_reason":{"category":"stale_abandoned","implication":"active"},"merged":false},{"kind":"pr","number":24306,"title":"fix: add configurable User-Agent for custom providers","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#41096","number":41096},{"kind":"pr","value":"#40033","number":40033}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":24308,"title":"Fix: Custom providers behind Cloudflare WAF blocked by SDK User-Agent header","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#41096","number":41096},{"kind":"pr","value":"#40033","number":40033}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":24559,"title":"fix(anthropic_adapter): override User-Agent for third-party endpoints","url":null,"state":"closed","close_reason":{"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":25949,"title":"feat(agent): configurable default_headers for API requests","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#14314","number":14314}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":26505,"title":"feat(config): support custom provider headers","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#41096","number":41096},{"kind":"pr","value":"#40033","number":40033}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":41096,"title":"fix: honor model.default_headers for custom OpenAI-compatible providers, main + auxiliary clients (#40033)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["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":[{"file":"agent/agent_init.py","hunks":["@@ -885,6 +885,14 @@ def init_agent(\n                     headers[\"x-anthropic-beta\"] = _FINE_GRAINED\n                 client_kwargs[\"default_headers\"] = headers\n \n+        # User-configured request headers (model.default_headers in\n+        # config.yaml) override provider/SDK defaults. Lets custom\n+        # OpenAI-compatible endpoints behind a gateway/WAF that rejects the\n+        # OpenAI SDK's identifying headers swap in a plain User-Agent. (#40033)\n+        # client_kwargs is the same dict object as agent._client_kwargs, so\n+        # this mutation is reflected in the client built just below.\n+        agent._apply_user_default_headers()\n+\n         agent.api_key = [redacted:secret-assignment]\"api_key\", \"\")\n         agent.base_url = client_kwargs.get(\"base_url\", agent.base_url)\n         try:"]},{"file":"agent/auxiliary_client.py","hunks":["@@ -357,6 +357,35 @@ def _get_aux_model_for_provider(provider_id: str) -> str:\n _TRUTHY_ENV_VALUES = frozenset({\"1\", \"true\", \"yes\", \"on\"})\n \n \n+def _apply_user_default_headers(headers: dict | None) -> dict | None:\n+    \"\"\"Merge user-configured ``model.default_headers`` onto resolved headers.\n+\n+    User values take precedence over provider/SDK defaults, mirroring the main\n+    agent client (``AIAgent._apply_user_default_headers``). This lets a\n+    ``custom`` OpenAI-compatible endpoint behind a gateway/WAF that rejects the\n+    OpenAI SDK's identifying headers (``User-Agent: OpenAI/Python ...``,\n+    ``X-Stainless-*``) override them for auxiliary calls too — otherwise the\n+    main turn would succeed but title/compression/vision calls to the same\n+    endpoint would still fail. (#40033)\n+\n+    Returns the merged dict, or the original ``headers`` (possibly ``None``)\n+    when nothing is configured. No allocation when there are no overrides.\n+    \"\"\"\n+    try:\n+        from hermes_cli.config import cfg_get, load_config\n+        user_headers = cfg_get(load_config(), \"model\", \"default_headers\")\n+    except Exception:\n+        return headers\n+    if not isinstance(user_headers, dict) or not user_headers:\n+        return headers\n+    merged = dict(headers or {})\n+    for key, value in user_headers.items():\n+        if value is None:\n+            continue\n+        merged[str(key)] = str(value)\n+    return merged or headers\n+\n+\n def build_or_headers(or_config: dict | None = None) -> dict:\n     \"\"\"Build OpenRouter headers, optionally including response-cache headers.\n ","@@ -1495,6 +1524,9 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:\n                         extra[\"default_headers\"] = dict(_ph_aux.default_headers)\n                 except Exception:\n                     pass\n+            _merged_aux = _apply_user_default_headers(extra.get(\"default_headers\"))\n+            if _merged_aux:\n+                extra[\"default_headers\"] = _merged_aux\n             _client = OpenAI(api_key=[redacted:secret-assignment] base_url=base_url, **extra)\n             _client = _maybe_wrap_anthropic(_client, model, api_key, raw_base_url)\n             return _client, model","@@ -1532,6 +1564,9 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:\n                     extra[\"default_headers\"] = dict(_ph_aux2.default_headers)\n             except Exception:\n                 pass\n+        _merged_aux2 = _apply_user_default_headers(extra.get(\"default_headers\"))\n+        if _merged_aux2:\n+            extra[\"default_headers\"] = _merged_aux2\n         _client = OpenAI(api_key=[redacted:secret-assignment] base_url=base_url, **extra)\n         _client = _maybe_wrap_anthropic(_client, model, api_key, raw_base_url)\n         return _client, model","@@ -1922,6 +1957,13 @@ def _try_custom_endpoint() -> Tuple[Optional[Any], Optional[str]]:\n     logger.debug(\"Auxiliary client: custom endpoint (%s, api_mode=%s)\", model, custom_mode or \"chat_completions\")\n     _clean_base, _dq = _extract_url_query_params(custom_base)\n     _extra = {\"default_query\": _dq} if _dq else {}\n+    # User-configured model.default_headers override the SDK's identifying\n+    # headers (User-Agent: OpenAI/Python ..., X-Stainless-*) on this custom\n+    # endpoint's auxiliary calls too — matching the main agent client so the\n+    # whole session reaches a gateway/WAF that rejects the SDK fingerprint. (#40033)\n+    _custom_headers = _apply_user_default_headers(None)\n+    if _custom_headers:\n+        _extra[\"default_headers\"] = _custom_headers\n     if custom_mode == \"codex_responses\":\n         real_client = OpenAI(api_key=[redacted:secret-assignment] base_url=_clean_base, **_extra)\n         return CodexAuxiliaryClient(real_client, model), model","@@ -3291,6 +3333,9 @@ def _to_async_client(sync_client, model: str, is_vision: bool = False):\n                     async_kwargs[\"default_headers\"] = dict(_ph_async.default_headers)\n         except Exception:\n             pass\n+    _merged_async = _apply_user_default_headers(async_kwargs.get(\"default_headers\"))\n+    if _merged_async:\n+        async_kwargs[\"default_headers\"] = _merged_async\n     return AsyncOpenAI(**async_kwargs), model\n \n ","@@ -3578,6 +3623,9 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = \"\",\n                         extra[\"default_headers\"] = dict(_ph_custom.default_headers)\n                 except Exception:\n                     pass\n+            _merged_custom = _apply_user_default_headers(extra.get(\"default_headers\"))\n+            if _merged_custom:\n+                extra[\"default_headers\"] = _merged_custom\n             client = OpenAI(api_key=[redacted:secret-assignment] base_url=_clean_base, **extra)\n             client = _wrap_if_needed(client, final_model, custom_base, custom_key)\n             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 = \"\",\n                     raw_base_for_wrap = custom_base\n                 _clean_base2, _dq2 = _extract_url_query_params(openai_base)\n                 _extra2 = {\"default_query\": _dq2} if _dq2 else {}\n+                _headers2 = _apply_user_default_headers(_extra2.get(\"default_headers\"))\n+                if _headers2:\n+                    _extra2[\"default_headers\"] = _headers2\n                 logger.debug(\n                     \"resolve_provider_client: named custom provider %r (%s, api_mode=%s)\",\n                     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 = \"\",\n                         _fallback_base = _to_openai_base_url(custom_base)\n                         _fb_clean, _fb_dq = _extract_url_query_params(_fallback_base)\n                         _fb_extra = {\"default_query\": _fb_dq} if _fb_dq else {}\n+                        _fb_headers = _apply_user_default_headers(_fb_extra.get(\"default_headers\"))\n+                        if _fb_headers:\n+                            _fb_extra[\"default_headers\"] = _fb_headers\n                         client = OpenAI(api_key=[redacted:secret-assignment] base_url=_fb_clean, **_fb_extra)\n                         return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode\n                                 else (client, final_model))","@@ -3824,6 +3878,9 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = \"\",\n                     headers.update(_ph_main.default_headers)\n             except Exception:\n                 pass\n+        _merged_main = _apply_user_default_headers(headers)\n+        if _merged_main:\n+            headers = _merged_main\n         client = OpenAI(api_key=[redacted:secret-assignment] base_url=base_url,\n                         **({\"default_headers\": headers} if headers else {}))\n "]},{"file":"cli-config.yaml.example","hunks":["@@ -72,6 +72,20 @@ model:\n   #\n # max_tokens: 8192\n \n+  # ── Custom request headers (optional) ─────────────────────────────────────\n+  #\n+  # default_headers: extra HTTP headers sent on every request to an\n+  #   OpenAI-compatible endpoint. User values take precedence over the\n+  #   provider/SDK defaults, so this is the supported way to override the\n+  #   OpenAI Python SDK's identifying headers (User-Agent: OpenAI/Python ...,\n+  #   X-Stainless-*) when a custom provider sits behind a gateway/WAF that\n+  #   rejects them — e.g. an upstream that returns \"502 Upstream access\n+  #   forbidden\" for the SDK default User-Agent but accepts a plain one.\n+  #   Applies on the OpenAI wire only (not native Anthropic / Bedrock).\n+  #\n+  # default_headers:\n+  #   User-Agent: \"curl/8.7.1\"\n+\n # Named provider overrides (optional)\n # Use this for per-provider request timeouts, non-stream stale timeouts,\n # and per-model exceptions."]},{"file":"run_agent.py","hunks":["@@ -3809,6 +3809,41 @@ def _apply_client_headers_for_base_url(self, base_url: str) -> None:\n             else:\n                 self._client_kwargs.pop(\"default_headers\", None)\n \n+        # User-configured overrides win over URL/profile defaults — keep them\n+        # applied across credential swaps and client rebuilds, not just at\n+        # first construction.\n+        self._apply_user_default_headers()\n+\n+    def _apply_user_default_headers(self) -> None:\n+        \"\"\"Merge user-configured request headers onto the OpenAI client.\n+\n+        Reads ``model.default_headers`` from config.yaml and merges it onto\n+        ``self._client_kwargs[\"default_headers\"]``, with user values taking\n+        precedence over provider- and SDK-supplied defaults.\n+\n+        This exists for ``custom`` OpenAI-compatible endpoints sitting behind\n+        a gateway/WAF that rejects the OpenAI Python SDK's identifying headers\n+        (``User-Agent: OpenAI/Python ...``, ``X-Stainless-*``). Setting e.g.\n+        ``model.default_headers: {User-Agent: curl/8.7.1}`` lets the request\n+        reach such an upstream instead of failing with an opaque 4xx/502 even\n+        though the same body works under ``curl``. (#40033)\n+\n+        Delegates the config read + merge to\n+        ``agent.auxiliary_client._apply_user_default_headers`` so the main and\n+        auxiliary clients can never drift on precedence or value handling.\n+\n+        No-op for Anthropic/Bedrock modes, which don't use the OpenAI client,\n+        and when no overrides are configured.\n+        \"\"\"\n+        if self.api_mode in (\"anthropic_messages\", \"bedrock_converse\"):\n+            return\n+        from agent.auxiliary_client import (\n+            _apply_user_default_headers as _merge_user_headers,\n+        )\n+        merged = _merge_user_headers(self._client_kwargs.get(\"default_headers\"))\n+        if merged:\n+            self._client_kwargs[\"default_headers\"] = merged\n+\n     def _swap_credential(self, entry) -> None:\n         runtime_key = getattr(entry, \"runtime_api_key\", None) or getattr(entry, \"access_token\", \"\")\n         runtime_base = getattr(entry, \"runtime_base_url\", None) or getattr(entry, \"base_url\", None) or self.base_url"]},{"file":"tests/agent/test_auxiliary_user_default_headers.py","hunks":["@@ -0,0 +1,137 @@\n+\"\"\"Tests for user-configured ``model.default_headers`` in the auxiliary client.\n+\n+Companion to ``tests/run_agent/test_provider_attribution_headers.py`` (which\n+covers the main agent client). The main agent turn and the auxiliary client\n+(title generation, context compression, vision routing) build separate OpenAI\n+clients, so a ``custom`` endpoint behind a gateway/WAF that rejects the OpenAI\n+SDK's identifying headers needs the ``model.default_headers`` override applied\n+on BOTH paths — otherwise the main turn succeeds but auxiliary calls to the\n+same endpoint still fail with an opaque 4xx/502. (#40033)\n+\"\"\"\n+\n+from unittest.mock import patch, MagicMock\n+\n+import pytest\n+\n+\n[redacted:email](autouse=True)\n+def _isolate(tmp_path, monkeypatch):\n+    \"\"\"Redirect HERMES_HOME so load_config() reads our test config.yaml.\"\"\"\n+    hermes_home = tmp_path / \".hermes\"\n+    hermes_home.mkdir()\n+    monkeypatch.setenv(\"HERMES_HOME\", str(hermes_home))\n+    (hermes_home / \"config.yaml\").write_text(\"model:\\n  default: test-model\\n\")\n+\n+\n+def _write_config(tmp_path, config_dict):\n+    import yaml\n+    (tmp_path / \".hermes\" / \"config.yaml\").write_text(yaml.dump(config_dict))\n+\n+\n+class TestApplyUserDefaultHeadersHelper:\n+    \"\"\"Direct unit tests for the merge helper.\"\"\"\n+\n+    def test_user_headers_merged_and_win(self, tmp_path):\n+        _write_config(tmp_path, {\n+            \"model\": {\"default\": \"m\", \"default_headers\": {\"User-Agent\": \"curl/8.7.1\", \"X-Extra\": \"1\"}},\n+        })\n+        from agent.auxiliary_client import _apply_user_default_headers\n+        merged = _apply_user_default_headers({\"User-Agent\": \"OpenAI/Python 2.24.0\"})\n+        assert merged[\"User-Agent\"] == \"curl/8.7.1\"  # user wins\n+        assert merged[\"X-Extra\"] == \"1\"\n+\n+    def test_no_config_is_noop_returns_original(self, tmp_path):\n+        _write_config(tmp_path, {\"model\": {\"default\": \"m\"}})\n+        from agent.auxiliary_client import _apply_user_default_headers\n+        original = {\"User-Agent\": \"OpenAI/Python\"}\n+        merged = _apply_user_default_headers(original)\n+        assert merged == original\n+\n+    def test_none_headers_with_config_creates_dict(self, tmp_path):\n+        _write_config(tmp_path, {\n+            \"model\": {\"default\": \"m\", \"default_headers\": {\"User-Agent\": \"curl/8.7.1\"}},\n+        })\n+        from agent.auxiliary_client import _apply_user_default_headers\n+        merged = _apply_user_default_headers(None)\n+        assert merged == {\"User-Agent\": \"curl/8.7.1\"}\n+\n+    def test_none_headers_no_config_returns_none(self, tmp_path):\n+        _write_config(tmp_path, {\"model\": {\"default\": \"m\"}})\n+        from agent.auxiliary_client import _apply_user_default_headers\n+        assert _apply_user_default_headers(None) is None\n+\n+    def test_none_values_skipped(self, tmp_path):\n+        _write_config(tmp_path, {\n+            \"model\": {\"default\": \"m\", \"default_headers\": {\"User-Agent\": \"curl/8.7.1\", \"X-Drop\": None}},\n+        })\n+        from agent.auxiliary_client import _apply_user_default_headers\n+        merged = _apply_user_default_headers({})\n+        assert merged == {\"User-Agent\": \"curl/8.7.1\"}\n+        assert \"X-Drop\" not in merged\n+\n+\n+class TestAuxClientHonorsUserDefaultHeaders:\n+    \"\"\"Integration: resolve_provider_client must pass overridden headers to OpenAI.\"\"\"\n+\n+    def test_custom_provider_overrides_sdk_user_agent(self, tmp_path):\n+        \"\"\"The #40033 reproduction on the auxiliary path.\"\"\"\n+        _write_config(tmp_path, {\n+            \"model\": {\n+                \"default\": \"my-custom-model\",\n+                \"provider\": \"custom\",\n+                \"base_url\": \"http://localhost:8080/v1\",\n+                \"default_headers\": {\"User-Agent\": \"curl/8.7.1\", \"X-Extra\": \"1\"},\n+            },\n+        })\n+        with patch(\"agent.auxiliary_client.OpenAI\") as mock_openai:\n+            mock_openai.return_value = MagicMock()\n+            from agent.auxiliary_client import resolve_provider_client\n+            client, model = resolve_provider_client(\"main\", \"my-custom-model\")\n+\n+        assert client is not None\n+        assert mock_openai.called\n+        headers = mock_openai.call_args.kwargs.get(\"default_headers\", {})\n+        assert headers.get(\"User-Agent\") == \"curl/8.7.1\"\n+        assert headers.get(\"X-Extra\") == \"1\"\n+\n+    def test_custom_provider_no_override_sends_no_user_agent(self, tmp_path):\n+        \"\"\"Without config, the aux client injects nothing — SDK defaults apply.\"\"\"\n+        _write_config(tmp_path, {\n+            \"model\": {\n+                \"default\": \"my-custom-model\",\n+                \"provider\": \"custom\",\n+                \"base_url\": \"http://localhost:8080/v1\",\n+            },\n+        })\n+        with patch(\"agent.auxiliary_client.OpenAI\") as mock_openai:\n+            mock_openai.return_value = MagicMock()\n+            from agent.auxiliary_client import resolve_provider_client\n+            client, model = resolve_provider_client(\"main\", \"my-custom-model\")\n+\n+        assert client is not None\n+        headers = mock_openai.call_args.kwargs.get(\"default_headers\", {}) or {}\n+        assert \"User-Agent\" not in headers\n+\n+    def test_named_custom_provider_honors_override(self, tmp_path):\n+        \"\"\"A `custom_providers:` entry's aux calls also honor model.default_headers.\n+\n+        This is a distinct construction path (_extra2) from the config-level\n+        `model.provider: custom` path — both must apply the global override.\n+        \"\"\"\n+        _write_config(tmp_path, {\n+            \"model\": {\n+                \"default\": \"test-model\",\n+                \"default_headers\": {\"User-Agent\": \"curl/8.7.1\"},\n+            },\n+            \"custom_providers\": [\n+                {\"name\": \"my-gw\", \"base_url\": \"http://my-gw.local/v1\", \"api_key\": \"k\"},\n+            ],\n+        })\n+        with patch(\"agent.auxiliary_client.OpenAI\") as mock_openai:\n+            mock_openai.return_value = MagicMock()\n+            from agent.auxiliary_client import resolve_provider_client\n+            client, model = resolve_provider_client(\"my-gw\", \"test-model\")\n+\n+        assert client is not None\n+        headers = mock_openai.call_args.kwargs.get(\"default_headers\", {}) or {}\n+        assert headers.get(\"User-Agent\") == \"curl/8.7.1\""]},{"file":"tests/run_agent/test_provider_attribution_headers.py","hunks":["@@ -176,6 +176,103 @@ def test_openrouter_headers_include_response_cache_when_enabled(mock_openai):\n     assert headers[\"X-OpenRouter-Cache-TTL\"] == \"600\"\n \n \n+# ---------------------------------------------------------------------------\n+# model.default_headers — user-configured overrides (#40033)\n+# ---------------------------------------------------------------------------\n+\n+\n+@patch(\"run_agent.OpenAI\")\n+def test_user_default_headers_override_sdk_user_agent(mock_openai):\n+    \"\"\"``model.default_headers`` lets a custom endpoint swap the OpenAI SDK\n+    User-Agent that some gateways/WAFs reject (the #40033 reproduction).\"\"\"\n+    mock_openai.return_value = MagicMock()\n+    agent = AIAgent(\n+        api_key=\"[redacted:secret-assignment]\",\n+        base_url=\"http://localhost:8080/v1\",\n+        model=\"my-custom-model\",\n+        provider=\"custom\",\n+        quiet_mode=True,\n+        skip_context_files=True,\n+        skip_memory=True,\n+    )\n+\n+    with patch(\"hermes_cli.config.load_config\", return_value={\n+        \"model\": {\"default_headers\": {\"User-Agent\": \"curl/8.7.1\", \"X-Extra\": \"1\"}},\n+    }):\n+        agent._apply_client_headers_for_base_url(\"http://localhost:8080/v1\")\n+\n+    headers = agent._client_kwargs[\"default_headers\"]\n+    assert headers[\"User-Agent\"] == \"curl/8.7.1\"\n+    assert headers[\"X-Extra\"] == \"1\"\n+\n+\n+@patch(\"run_agent.OpenAI\")\n+def test_user_default_headers_win_over_provider_defaults(mock_openai):\n+    \"\"\"User headers take precedence but leave untouched provider defaults intact.\"\"\"\n+    mock_openai.return_value = MagicMock()\n+    agent = AIAgent(\n+        api_key=\"[redacted:secret-assignment]\",\n+        base_url=\"https://openrouter.ai/api/v1\",\n+        model=\"test/model\",\n+        quiet_mode=True,\n+        skip_context_files=True,\n+        skip_memory=True,\n+    )\n+\n+    with patch(\"hermes_cli.config.load_config\", return_value={\n+        \"model\": {\"default_headers\": {\"X-Title\": \"MyApp\"}},\n+    }):\n+        agent._apply_client_headers_for_base_url(\"https://openrouter.ai/api/v1\")\n+\n+    headers = agent._client_kwargs[\"default_headers\"]\n+    assert headers[\"X-Title\"] == \"MyApp\"  # user override wins\n+    assert headers[\"HTTP-Referer\"] == \"https://hermes-agent.nousresearch.com\"  # default preserved\n+\n+\n+@patch(\"run_agent.OpenAI\")\n+def test_no_user_default_headers_leaves_provider_defaults_untouched(mock_openai):\n+    mock_openai.return_value = MagicMock()\n+    agent = AIAgent(\n+        api_key=\"[redacted:secret-assignment]\",\n+        base_url=\"https://openrouter.ai/api/v1\",\n+        model=\"test/model\",\n+        quiet_mode=True,\n+        skip_context_files=True,\n+        skip_memory=True,\n+    )\n+\n+    with patch(\"hermes_cli.config.load_config\", return_value={\"model\": {}}):\n+        agent._apply_client_headers_for_base_url(\"https://openrouter.ai/api/v1\")\n+\n+    headers = agent._client_kwargs[\"default_headers\"]\n+    assert headers[\"HTTP-Referer\"] == \"https://hermes-agent.nousresearch.com\"\n+    assert \"User-Agent\" not in headers  # nothing injected when unconfigured\n+\n+\n+@patch(\"run_agent.OpenAI\")\n+def test_user_default_headers_skipped_for_anthropic_mode(mock_openai):\n+    \"\"\"Anthropic/Bedrock modes don't use the OpenAI client — never touched.\"\"\"\n+    mock_openai.return_value = MagicMock()\n+    agent = AIAgent(\n+        api_key=\"[redacted:secret-assignment]\",\n+        base_url=\"http://localhost:8080/v1\",\n+        model=\"my-custom-model\",\n+        provider=\"custom\",\n+        quiet_mode=True,\n+        skip_context_files=True,\n+        skip_memory=True,\n+    )\n+    agent.api_mode = \"anthropic_messages\"\n+    agent._client_kwargs = {}\n+\n+    with patch(\"hermes_cli.config.load_config\", return_value={\n+        \"model\": {\"default_headers\": {\"User-Agent\": \"curl/8.7.1\"}},\n+    }):\n+        agent._apply_user_default_headers()\n+\n+    assert \"default_headers\" not in agent._client_kwargs\n+\n+\n @patch(\"run_agent.OpenAI\")\n def test_openrouter_headers_no_cache_when_disabled(mock_openai):\n     \"\"\"When openrouter.response_cache is False, no cache headers are sent.\"\"\"\n"]}]},{"kind":"pr","number":46002,"title":"fix(custom-providers): apply extra_headers to Anthropic clients","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"cb062f7bd3d1","number":0},{"kind":"pr","value":"#115812","number":115812},{"kind":"pr","value":"#24293","number":24293},{"kind":"pr","value":"#9721","number":9721},{"kind":"pr","value":"#53099","number":53099},{"kind":"pr","value":"#70566","number":70566},{"kind":"pr","value":"#46002","number":46002},{"kind":"pr","value":"#70567","number":70567},{"kind":"pr","value":"#53114","number":53114}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":52687,"title":"fix(runtime): propagate named-provider default_headers to OpenAI client (#52686)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#57086","number":57086}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":57086,"title":"fix(config): accept provider default_headers alias","url":null,"state":"closed","close_reason":{"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":64237,"title":"fix: reapply user-configured headers on switch_model (#64217)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"0a4b4d6df52f9a7ba9ae5914c5035b171d129f47","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":115812,"title":"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)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["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","website/docs/user-guide/configuring-models.md"]}],"trust":{"default_tier":"verified","includes_suggested":false}}