diff --git a/agent/agent_init.py b/agent/agent_init.py index 5bd15222a..045fcfc1e 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -976,15 +976,28 @@ def init_agent( try: from hermes_cli.config import ( + apply_custom_provider_extra_headers_to_client_kwargs, apply_custom_provider_tls_to_client_kwargs, get_compatible_custom_providers, load_config, ) + _cp_config = load_config() + _cp_entries = get_compatible_custom_providers(_cp_config) + _cp_base_url = str(client_kwargs.get("base_url") or agent.base_url or "") apply_custom_provider_tls_to_client_kwargs( client_kwargs, - str(client_kwargs.get("base_url") or agent.base_url or ""), - get_compatible_custom_providers(load_config()), + _cp_base_url, + _cp_entries, + ) + # Per-provider extra HTTP headers (providers..extra_headers / + # custom_providers[].extra_headers) — proxies, gateways, custom + # auth. Applied last so the most specific config level wins. + # SECURITY: values may carry credentials — never log them. + apply_custom_provider_extra_headers_to_client_kwargs( + client_kwargs, + _cp_base_url, + _cp_entries, ) except Exception: logger.debug("custom-provider TLS resolution skipped", exc_info=True) diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index bc2b7b15a..d3a0bfb52 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -485,7 +485,19 @@ def _apply_user_default_headers(headers: dict | None) -> dict | None: """ try: from hermes_cli.config import cfg_get, load_config - user_headers = cfg_get(load_config(), "model", "default_headers") + _cfg = load_config() + user_headers = cfg_get(_cfg, "model", "default_headers") + # ``model.extra_headers`` is an accepted alias (matches the + # per-provider ``extra_headers`` key on providers/custom_providers + # entries). When both are set they merge, with ``extra_headers`` + # winning. SECURITY: values may carry credentials — never log them. + alias_headers = cfg_get(_cfg, "model", "extra_headers") + if isinstance(alias_headers, dict) and alias_headers: + merged_user: dict = {} + if isinstance(user_headers, dict): + merged_user.update(user_headers) + merged_user.update(alias_headers) + user_headers = merged_user except Exception: return headers if not isinstance(user_headers, dict) or not user_headers: diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 8c60a5ea0..8b0769ead 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -85,6 +85,25 @@ model: # # default_headers: # User-Agent: "curl/8.7.1" + # + # extra_headers: accepted as an alias of default_headers (merged, with + # extra_headers winning when both are set) — matches the per-provider + # extra_headers key below. + # + # Per-provider variant: named providers / custom_providers entries accept an + # extra_headers dict scoped to that endpoint only — for reverse proxies, + # gateways, or custom auth (e.g. Cloudflare Access service tokens). + # Merged onto SDK/provider defaults with the entry's values winning. + # Header values are treated as secrets and are never logged. + # + # providers: + # my-proxy: + # base_url: "https://llm.internal.example.com/v1" + # key_env: "MY_PROXY_API_KEY" + # extra_headers: + # CF-Access-Client-Id: "xxxx.access" + # CF-Access-Client-Secret: "${CF_ACCESS_SECRET}" + # X-Client-Name: "hermes-agent" # Named provider overrides (optional) # Use this for per-provider request timeouts, non-stream stale timeouts, diff --git a/hermes_cli/config.py b/hermes_cli/config.py index cd3587468..8748db2cb 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -4489,7 +4489,8 @@ def _normalize_custom_provider_entry( "api_mode", "transport", "model", "default_model", "models", "context_length", "rate_limit_delay", "request_timeout_seconds", "stale_timeout_seconds", - "discover_models", "extra_body", "ssl_ca_cert", "ssl_verify", + "discover_models", "extra_body", "extra_headers", + "ssl_ca_cert", "ssl_verify", } for camel, snake in _CAMEL_ALIASES.items(): if camel in entry and snake not in entry: @@ -4596,6 +4597,15 @@ def _normalize_custom_provider_entry( if isinstance(extra_body, dict): normalized["extra_body"] = dict(extra_body) + # Per-provider extra HTTP headers (proxies, gateways, custom auth). + # Values may carry credentials (e.g. CF-Access-Client-Secret) — never + # log them anywhere downstream. + extra_headers = entry.get("extra_headers") + if isinstance(extra_headers, dict) and extra_headers: + normalized["extra_headers"] = { + str(k): str(v) for k, v in extra_headers.items() if v is not None + } + ssl_ca_cert = entry.get("ssl_ca_cert") if isinstance(ssl_ca_cert, str) and ssl_ca_cert.strip(): normalized["ssl_ca_cert"] = ssl_ca_cert.strip() @@ -4633,6 +4643,7 @@ def _custom_provider_entry_to_provider_config( "rate_limit_delay", "discover_models", "extra_body", + "extra_headers", "ssl_ca_cert", "ssl_verify", ): @@ -4776,6 +4787,69 @@ def apply_custom_provider_tls_to_client_kwargs( client_kwargs["ssl_verify"] = tls["ssl_verify"] +def get_custom_provider_extra_headers( + base_url: str, + custom_providers: Optional[List[Dict[str, Any]]] = None, + config: Optional[Dict[str, Any]] = None, +) -> Dict[str, str]: + """Return ``extra_headers`` from a matching ``providers`` / ``custom_providers`` entry. + + Matches the entry whose ``base_url`` equals *base_url* (trailing-slash and + case insensitive, mirroring :func:`get_custom_provider_tls_settings`) and + returns its ``extra_headers`` dict, or ``{}`` when no entry matches or the + entry declares none. + + SECURITY: header values routinely carry credentials (Cloudflare Access + service tokens, proxy auth, custom bearer schemes). Callers must never + log the returned values. + """ + if custom_providers is None: + try: + custom_providers = get_compatible_custom_providers(config) + except Exception: + custom_providers = [] + if not base_url or not isinstance(custom_providers, list): + return {} + + target_url = (base_url or "").rstrip("/").lower() + for entry in custom_providers: + if not isinstance(entry, dict): + continue + entry_url = (entry.get("base_url") or "").rstrip("/").lower() + if not entry_url or entry_url != target_url: + continue + extra_headers = entry.get("extra_headers") + if isinstance(extra_headers, dict) and extra_headers: + return { + str(k): str(v) for k, v in extra_headers.items() if v is not None + } + return {} + return {} + + +def apply_custom_provider_extra_headers_to_client_kwargs( + client_kwargs: Dict[str, Any], + base_url: str, + custom_providers: Optional[List[Dict[str, Any]]] = None, + config: Optional[Dict[str, Any]] = None, +) -> None: + """Merge per-provider ``extra_headers`` onto OpenAI client ``default_headers``. + + Provider-specific headers win over provider/SDK defaults already present in + ``client_kwargs`` — they are the most specific configuration level. No-op + when the base_url matches no ``providers`` / ``custom_providers`` entry or + the entry declares no headers. + + SECURITY: values may carry credentials — never log them. + """ + extra_headers = get_custom_provider_extra_headers(base_url, custom_providers, config) + if not extra_headers: + return + merged = dict(client_kwargs.get("default_headers") or {}) + merged.update(extra_headers) + client_kwargs["default_headers"] = merged + + def get_custom_provider_context_length( model: str, base_url: str, diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index 5e71be413..6ddcddab3 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -591,6 +591,19 @@ def _lift_max_output_tokens(entry: Dict[str, Any], result: Dict[str, Any]) -> No return +def _lift_extra_headers(entry: Dict[str, Any], result: Dict[str, Any]) -> None: + """Copy a validated ``extra_headers`` dict from a provider entry. + + SECURITY: header values routinely carry credentials (Cloudflare Access + service tokens, proxy auth, custom bearer schemes). Never log them. + """ + extra_headers = entry.get("extra_headers") + if isinstance(extra_headers, dict) and extra_headers: + result["extra_headers"] = { + str(k): str(v) for k, v in extra_headers.items() if v is not None + } + + def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, Any]]: requested_norm = _normalize_custom_provider_name(requested_provider or "") if not requested_norm: @@ -660,6 +673,7 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An extra_body = entry.get("extra_body") if isinstance(extra_body, dict): result["extra_body"] = dict(extra_body) + _lift_extra_headers(entry, result) # The v11→v12 migration writes the API mode under the new # ``transport`` field, but hand-edited configs may still # use the legacy ``api_mode`` spelling. Accept both — @@ -689,6 +703,7 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An extra_body = entry.get("extra_body") if isinstance(extra_body, dict): result["extra_body"] = dict(extra_body) + _lift_extra_headers(entry, result) api_mode = _parse_api_mode(entry.get("api_mode") or entry.get("transport")) if api_mode: result["api_mode"] = api_mode @@ -736,6 +751,7 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An extra_body = entry.get("extra_body") if isinstance(extra_body, dict): result["extra_body"] = dict(extra_body) + _lift_extra_headers(entry, result) api_mode = _parse_api_mode(entry.get("api_mode")) if api_mode: result["api_mode"] = api_mode @@ -971,6 +987,11 @@ def _resolve_named_custom_runtime( **dict(pool_result.get("request_overrides") or {}), **request_overrides, } + # Propagate extra_headers so custom-provider auth headers (e.g. + # Cloudflare Access service tokens) still apply with pooled + # credentials. NEVER log the values. + if custom_provider.get("extra_headers"): + pool_result["extra_headers"] = dict(custom_provider["extra_headers"]) return pool_result _cp_is_openai_url = base_url_host_matches(base_url, "openai.com") or base_url_host_matches(base_url, "openai.azure.com") @@ -1004,6 +1025,10 @@ def _resolve_named_custom_runtime( result["model"] = custom_provider["model"] if isinstance(custom_provider.get("max_output_tokens"), int): result["max_output_tokens"] = custom_provider["max_output_tokens"] + # Per-provider extra HTTP headers (proxies, gateways, custom auth). + # Values may carry credentials — NEVER log them. + if custom_provider.get("extra_headers"): + result["extra_headers"] = dict(custom_provider["extra_headers"]) request_overrides = _custom_provider_request_overrides(custom_provider) if request_overrides: result["request_overrides"] = request_overrides diff --git a/run_agent.py b/run_agent.py index 9b6485d52..7d4afad9a 100644 --- a/run_agent.py +++ b/run_agent.py @@ -4384,6 +4384,22 @@ class AIAgent: # first construction. self._apply_user_default_headers() + # Per-provider extra HTTP headers (providers..extra_headers / + # custom_providers[].extra_headers) — applied last so the most + # specific config level survives credential swaps and rebuilds too. + # SECURITY: values may carry credentials — never log them. + if self.api_mode not in ("anthropic_messages", "bedrock_converse"): + try: + from hermes_cli.config import ( + apply_custom_provider_extra_headers_to_client_kwargs, + ) + + apply_custom_provider_extra_headers_to_client_kwargs( + self._client_kwargs, base_url, + ) + except Exception: + logger.debug("custom-provider extra_headers skipped", exc_info=True) + def _apply_user_default_headers(self) -> None: """Merge user-configured request headers onto the OpenAI client. diff --git a/scripts/release.py b/scripts/release.py index a0b01aa69..b03fcb543 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -47,6 +47,7 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json" AUTHOR_MAP = { "louis@letsfive.io": "Mibayy", # PR #3243 salvage (/compact alias + preview/aggressive flags for /compress) "louis@letsfive.io": "Mibayy", # PR #3176 salvage (api-server: per-client model routing via model_routes) + "jneeee@outlook.com": "jneeee", # PR #3526 salvage (extra HTTP headers for LLM API calls via config.yaml) "ai-lab@foxmail.com": "CrazyBoyM", # PR #55828 salvage (image_gen openai-codex: wire image-to-image / reference-image editing via Codex Responses input_image parts; magic-byte + read-guard + 25MB-cap + clamp-to-16 hardening) "r0gersm1th@users.noreply.github.com": "r0gersm1th", # PR #3219 salvage (whatsapp bridge: resolve LID sender IDs to phone numbers in the message payload so phone-based allowlists match; commit authored by collaborator r0gersm1th, PR by @ajmeese7) "louis@letsfive.io": "Mibayy", # PR #3296 salvage (status: provider label honors config.yaml model.base_url, not just OPENAI_BASE_URL env) diff --git a/tests/hermes_cli/test_custom_provider_extra_headers.py b/tests/hermes_cli/test_custom_provider_extra_headers.py new file mode 100644 index 000000000..9f43e9406 --- /dev/null +++ b/tests/hermes_cli/test_custom_provider_extra_headers.py @@ -0,0 +1,127 @@ +"""Tests for per-provider ``extra_headers`` in providers / custom_providers config. + +PR #3526 salvage — user-configurable extra HTTP headers on LLM API calls +(reverse proxies, gateways, custom auth such as Cloudflare Access tokens). +""" + +from hermes_cli.config import ( + _normalize_custom_provider_entry, + apply_custom_provider_extra_headers_to_client_kwargs, + get_custom_provider_extra_headers, +) + + +def test_normalize_entry_keeps_extra_headers(): + normalized = _normalize_custom_provider_entry( + { + "name": "my-proxy", + "base_url": "https://llm.internal.example.com/v1", + "extra_headers": {"X-Custom-Auth": "tok", "X-Client-Name": "hermes"}, + } + ) + assert normalized is not None + assert normalized["extra_headers"] == { + "X-Custom-Auth": "tok", + "X-Client-Name": "hermes", + } + + +def test_normalize_entry_drops_invalid_extra_headers(): + for bad in ("not-a-dict", {}, 42, ["a"]): + normalized = _normalize_custom_provider_entry( + { + "name": "my-proxy", + "base_url": "https://llm.internal.example.com/v1", + "extra_headers": bad, + } + ) + assert normalized is not None + assert "extra_headers" not in normalized + + +def test_normalize_entry_stringifies_values_and_skips_none(): + normalized = _normalize_custom_provider_entry( + { + "name": "my-proxy", + "base_url": "https://llm.internal.example.com/v1", + "extra_headers": {"X-Int": 7, "X-None": None}, + } + ) + assert normalized is not None + assert normalized["extra_headers"] == {"X-Int": "7"} + + +def test_get_custom_provider_extra_headers_matches_base_url(): + providers = [ + { + "name": "my-proxy", + "base_url": "https://llm.internal.example.com/v1", + "extra_headers": {"CF-Access-Client-Id": "xxxx.access"}, + } + ] + # trailing-slash and case insensitive match, mirroring the TLS helper + headers = get_custom_provider_extra_headers( + "https://LLM.internal.example.com/v1/", + custom_providers=providers, + ) + assert headers == {"CF-Access-Client-Id": "xxxx.access"} + + +def test_get_custom_provider_extra_headers_no_match_returns_empty(): + providers = [ + { + "name": "my-proxy", + "base_url": "https://llm.internal.example.com/v1", + "extra_headers": {"X-Secret": "s"}, + } + ] + assert get_custom_provider_extra_headers( + "https://other.example.com/v1", custom_providers=providers, + ) == {} + # prefix look-alike host must not match (no substring bypass) + assert get_custom_provider_extra_headers( + "https://llm.internal.example.com.attacker.test/v1", + custom_providers=providers, + ) == {} + + +def test_apply_extra_headers_merges_onto_existing_defaults(): + client_kwargs = { + "api_key": "x", + "base_url": "https://llm.internal.example.com/v1", + "default_headers": {"User-Agent": "curl/8.7.1", "X-Keep": "1"}, + } + providers = [ + { + "name": "my-proxy", + "base_url": "https://llm.internal.example.com/v1", + "extra_headers": {"User-Agent": "override", "X-New": "2"}, + } + ] + apply_custom_provider_extra_headers_to_client_kwargs( + client_kwargs, + "https://llm.internal.example.com/v1", + custom_providers=providers, + ) + assert client_kwargs["default_headers"] == { + "User-Agent": "override", # provider-specific value wins + "X-Keep": "1", # untouched defaults preserved + "X-New": "2", + } + + +def test_apply_extra_headers_noop_without_match(): + client_kwargs = {"api_key": "x", "base_url": "https://other.example.com/v1"} + providers = [ + { + "name": "my-proxy", + "base_url": "https://llm.internal.example.com/v1", + "extra_headers": {"X-Secret": "s"}, + } + ] + apply_custom_provider_extra_headers_to_client_kwargs( + client_kwargs, + "https://other.example.com/v1", + custom_providers=providers, + ) + assert "default_headers" not in client_kwargs diff --git a/tests/hermes_cli/test_runtime_provider_resolution.py b/tests/hermes_cli/test_runtime_provider_resolution.py index c6743c23d..4923cd86b 100644 --- a/tests/hermes_cli/test_runtime_provider_resolution.py +++ b/tests/hermes_cli/test_runtime_provider_resolution.py @@ -3169,3 +3169,154 @@ def test_auto_provider_lookalike_cloud_host_does_not_bypass_to_cloud(monkeypatch f"Look-alike host must not be classified as Anthropic cloud: {resolved}" ) assert resolved["base_url"] == lookalike + + +# --------------------------------------------------------------------------- +# extra_headers support for named custom providers (#3526 salvage) +# --------------------------------------------------------------------------- + + +def test_named_custom_provider_with_extra_headers(monkeypatch): + """Custom providers with extra_headers surface them in the resolved runtime.""" + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + monkeypatch.setattr( + rp, + "load_config", + lambda: { + "custom_providers": [ + { + "name": "CustomHost", + "base_url": "https://custom.host.ai/v1", + "api_key": "custom-host-key", + "extra_headers": { + "X-Custom-Auth": "auth-123", + "X-Client-Name": "hermes-agent", + }, + } + ] + }, + ) + + resolved = rp.resolve_runtime_provider(requested="customhost") + + assert resolved["provider"] == "custom" + assert resolved["base_url"] == "https://custom.host.ai/v1" + assert resolved["api_key"] == "custom-host-key" + assert resolved["extra_headers"] == { + "X-Custom-Auth": "auth-123", + "X-Client-Name": "hermes-agent", + } + + +def test_named_custom_provider_without_extra_headers_omits_key(monkeypatch): + """No extra_headers configured → key absent from the resolved runtime.""" + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + monkeypatch.setattr( + rp, + "load_config", + lambda: { + "custom_providers": [ + { + "name": "PlainHost", + "base_url": "https://plain.host/v1", + "api_key": "plain-key", + } + ] + }, + ) + + resolved = rp.resolve_runtime_provider(requested="plainhost") + + assert resolved["provider"] == "custom" + assert "extra_headers" not in resolved + + +def test_named_custom_provider_non_dict_extra_headers_ignored(monkeypatch): + """Non-dict / empty extra_headers values are ignored, not propagated.""" + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + monkeypatch.setattr( + rp, + "load_config", + lambda: { + "custom_providers": [ + { + "name": "BadHeaders", + "base_url": "https://bad.host/v1", + "api_key": "key", + "extra_headers": "not-a-dict", + }, + { + "name": "EmptyHeaders", + "base_url": "https://empty.host/v1", + "api_key": "key", + "extra_headers": {}, + }, + ] + }, + ) + + assert "extra_headers" not in rp.resolve_runtime_provider(requested="badheaders") + assert "extra_headers" not in rp.resolve_runtime_provider(requested="emptyheaders") + + +def test_providers_dict_entry_surfaces_extra_headers(monkeypatch): + """New-style providers: dict entries also surface extra_headers.""" + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + monkeypatch.setattr( + rp, + "load_config", + lambda: { + "providers": { + "my-proxy": { + "base_url": "https://llm.internal.example.com/v1", + "api_key": "proxy-key", + "extra_headers": {"CF-Access-Client-Id": "xxxx.access"}, + } + } + }, + ) + + resolved = rp.resolve_runtime_provider(requested="my-proxy") + + assert resolved["provider"] == "custom" + assert resolved["extra_headers"] == {"CF-Access-Client-Id": "xxxx.access"} + + +def test_resolve_named_custom_runtime_pool_result_includes_extra_headers(monkeypatch): + """extra_headers must survive the credential-pool path too.""" + pool_return_value = { + "provider": "custom", + "api_mode": "chat_completions", + "base_url": "https://lmstudio.example.com/v1", + "api_key": "pooled-key", + "source": "pool:lmstudio-pool", + "credential_pool": "fake-pool", + } + monkeypatch.setattr(rp, "_try_resolve_from_custom_pool", lambda *a, **k: pool_return_value) + monkeypatch.setattr( + rp, + "_get_named_custom_provider", + lambda p: { + "name": "lmstudio", + "base_url": "https://lmstudio.example.com/v1", + "api_key": "not-used-when-pooled", + "extra_headers": { + "CF-Access-Client-Id": "xxx.access", + "CF-Access-Client-Secret": "yyy", + }, + }, + ) + + resolved = rp._resolve_named_custom_runtime(requested_provider="custom:lmstudio") + + assert resolved is not None + assert resolved["extra_headers"] == { + "CF-Access-Client-Id": "xxx.access", + "CF-Access-Client-Secret": "yyy", + } + assert resolved["api_key"] == "pooled-key" + assert resolved["source"] == "pool:lmstudio-pool" diff --git a/tests/run_agent/test_custom_provider_extra_headers_client.py b/tests/run_agent/test_custom_provider_extra_headers_client.py new file mode 100644 index 000000000..1f1ba898a --- /dev/null +++ b/tests/run_agent/test_custom_provider_extra_headers_client.py @@ -0,0 +1,119 @@ +"""Per-provider ``extra_headers`` applied to the OpenAI client (#3526 salvage). + +Custom providers (``providers`` / ``custom_providers`` in config.yaml) can +declare an ``extra_headers`` dict that must land on the OpenAI client's +``default_headers`` at construction and survive header re-application on +credential swaps / rebuilds. Values may carry credentials — the plumbing must +never log them. +""" +from unittest.mock import MagicMock, patch + +from run_agent import AIAgent + +_PROXY_URL = "https://llm.internal.example.com/v1" +_PROXY_CONFIG = { + "custom_providers": [ + { + "name": "my-proxy", + "base_url": _PROXY_URL, + "api_key": "proxy-key", + "extra_headers": { + "CF-Access-Client-Id": "xxxx.access", + "X-Client-Name": "hermes-agent", + }, + } + ] +} + + +@patch("run_agent.OpenAI") +def test_custom_provider_extra_headers_applied_at_construction(mock_openai): + mock_openai.return_value = MagicMock() + with patch("hermes_cli.config.load_config", return_value=_PROXY_CONFIG): + agent = AIAgent( + api_key="proxy-key", + base_url=_PROXY_URL, + model="my-model", + provider="custom", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + + headers = agent._client_kwargs["default_headers"] + assert headers["CF-Access-Client-Id"] == "xxxx.access" + assert headers["X-Client-Name"] == "hermes-agent" + + +@patch("run_agent.OpenAI") +def test_extra_headers_not_applied_for_other_base_url(mock_openai): + mock_openai.return_value = MagicMock() + with patch("hermes_cli.config.load_config", return_value=_PROXY_CONFIG): + agent = AIAgent( + api_key="other-key", + base_url="http://localhost:8080/v1", + model="my-model", + provider="custom", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + + headers = agent._client_kwargs.get("default_headers") or {} + assert "CF-Access-Client-Id" not in headers + assert "X-Client-Name" not in headers + + +@patch("run_agent.OpenAI") +def test_extra_headers_survive_header_reapplication(mock_openai): + """_apply_client_headers_for_base_url (credential swaps, rebuilds) must + re-apply per-provider extra_headers rather than dropping them.""" + mock_openai.return_value = MagicMock() + with patch("hermes_cli.config.load_config", return_value=_PROXY_CONFIG): + agent = AIAgent( + api_key="proxy-key", + base_url=_PROXY_URL, + model="my-model", + provider="custom", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + agent._client_kwargs.pop("default_headers", None) + agent._apply_client_headers_for_base_url(_PROXY_URL) + + headers = agent._client_kwargs["default_headers"] + assert headers["CF-Access-Client-Id"] == "xxxx.access" + + +@patch("run_agent.OpenAI") +def test_extra_headers_merge_with_global_default_headers(mock_openai): + """Per-provider extra_headers win over global model.default_headers on + key collisions; non-colliding globals are preserved.""" + mock_openai.return_value = MagicMock() + config = { + "model": {"default_headers": {"User-Agent": "curl/8.7.1", "X-Global": "1"}}, + "custom_providers": [ + { + "name": "my-proxy", + "base_url": _PROXY_URL, + "api_key": "proxy-key", + "extra_headers": {"User-Agent": "hermes-proxy", "X-Local": "2"}, + } + ], + } + with patch("hermes_cli.config.load_config", return_value=config): + agent = AIAgent( + api_key="proxy-key", + base_url=_PROXY_URL, + model="my-model", + provider="custom", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + + headers = agent._client_kwargs["default_headers"] + assert headers["User-Agent"] == "hermes-proxy" # per-provider wins + assert headers["X-Global"] == "1" + assert headers["X-Local"] == "2"