Custom HTTP headers support and propagation for providers
NousResearch/hermes-agent · cluster 6075 · agent
Data as of
5 open items. Fix merged in #6754. Check it covers your case.
The short answer
- Known?
- Yes. 28 related items grouped here, 8 issues already closed.
- Fix event
- A fix was merged to main in PR #6754. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PR #115812. That is separate from the fix event. - Still open
- 3 issues and 2 PRs.
Next step: read #6754, then check whether your report is one of the 3 still open.
Read #6754Pick up a Lab taskThese 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.
Open issues3
Open PRs2
Fixes on record
-
#6754 fix: fallback header preservation, kimi credential pool, TUI stale model display
files:
agent/auxiliary_client.py,agent/credential_pool.py,cli.py,hermes_cli/auth.py,run_agent.pyShow the diff
-
agent/auxiliary_client.py@@ -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 @@ -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 @@ -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 @@ -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 @@ -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@@ -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, @@ -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@@ -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] @@ -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@@ -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@@ -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
-
-
#41096 fix: honor model.default_headers for custom OpenAI-compatible providers, main + auxiliary clients (#40033)
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.pyShow 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 = [redacted:secret-assignment]"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."""
-
-
#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)
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)
12 PRs closed without merging
From the close comments: superseded by another PR 6 · merged via another change 3 · done elsewhere 2 · stale or abandoned 1
-
#6076 fix(fallback): preserve provider-specific headers when activating fallback · merged via another change →
#6754 -
#14314 feat: add custom header support for provider requests · superseded by another PR →
#9518,#56244,#50796 - #24295 fix(anthropic): override SDK User-Agent for third-party endpoints (#24293) · stale or abandoned
-
#24306 fix: add configurable User-Agent for custom providers · superseded by another PR →
#41096,#40033 -
#24308 Fix: Custom providers behind Cloudflare WAF blocked by SDK User-Agent header · superseded by another PR →
#41096,#40033
Show 7 more
- #24559 fix(anthropic_adapter): override User-Agent for third-party endpoints · done elsewhere
-
#25949 feat(agent): configurable default_headers for API requests · superseded by another PR →
#14314 -
#26505 feat(config): support custom provider headers · superseded by another PR →
#41096,#40033 -
#46002 fix(custom-providers): apply extra_headers to Anthropic clients · merged via another change →
cb062f7,#115812,#24293,#9721,#53099,#70566,#46002,#70567,#53114 -
#52687 fix(runtime): propagate named-provider default_headers to OpenAI client (#52686) · superseded by another PR →
#57086 - #57086 fix(config): accept provider default_headers alias · merged via another change
-
#64237 fix: reapply user-configured headers on switch_model (#64217) · done elsewhere →
0a4b4d6
Closed issues · 8
- Closed#6075[Bug]: Provider-specific headers (e.g. KimiCLI User-Agent) lost after fallback activation
- Closed#9721[Bug] Cannot set custom HTTP headers for custom providers — Cloudflare 403
- Closed#12785[Feature]: configurable default headers / User-Agent for third-party API
- Closed#24293Custom providers behind Cloudflare WAF return 403 due to SDK User-Agent
- Closed#25354[Bug] Volcengine Ark API fails with HTTP 400 due to User-Agent fingerprinting - need custom headers support for providers
- Closed#39889[Feature]: ZDR header support for privacy/data retention compliance
- Closed#52686[Bug]: providers.<name>.default_headers dropped on cron/CLI/oneshot agent init
- Closed#64217switch_model drops user-configured extra_headers, causing 403 from WAF-protected custom providers
How this cluster was grouped
-
Link evidence (
partly structural links): 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 item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means 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