feat(config): extra HTTP headers for LLM API calls (#3526 salvage)

Named providers / custom_providers entries in config.yaml now accept an
extra_headers dict scoped to that endpoint — for reverse proxies, API
gateways, and custom auth schemes (e.g. Cloudflare Access service tokens).

- hermes_cli/config.py: normalize extra_headers on provider entries
  (_normalize_custom_provider_entry + providers-dict translation), add
  get_custom_provider_extra_headers /
  apply_custom_provider_extra_headers_to_client_kwargs helpers keyed on
  base_url (case/trailing-slash insensitive, no substring bypass —
  mirrors the TLS helpers)
- hermes_cli/runtime_provider.py: surface extra_headers in the resolved
  runtime for named custom providers (providers dict, legacy
  custom_providers list, and the credential-pool path)
- run_agent.py / agent/agent_init.py: merge per-provider extra_headers
  onto the OpenAI client default_headers at construction and on every
  _apply_client_headers_for_base_url re-application (credential swaps,
  rebuilds), most-specific level wins; OpenAI-wire only (native
  Anthropic/Bedrock scoped out)
- agent/auxiliary_client.py: accept model.extra_headers as an alias of
  model.default_headers for the global variant
- cli-config.yaml.example: documented commented example
- Header values are treated as secrets and never logged

Salvaged from PR #3526 by @jneeee, reimplemented against current main.

Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>
This commit is contained in:
Jneeee 2026-07-02 04:58:34 -07:00 committed by Teknium
parent 4a09b692ec
commit b98baa3039
10 changed files with 561 additions and 4 deletions

View file

@ -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.<name>.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)

View file

@ -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:

View file

@ -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,

View file

@ -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,

View file

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

View file

@ -4384,6 +4384,22 @@ class AIAgent:
# first construction.
self._apply_user_default_headers()
# Per-provider extra HTTP headers (providers.<name>.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.

View file

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

View file

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

View file

@ -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"

View file

@ -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"