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:
parent
4a09b692ec
commit
b98baa3039
10 changed files with 561 additions and 4 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
16
run_agent.py
16
run_agent.py
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
127
tests/hermes_cli/test_custom_provider_extra_headers.py
Normal file
127
tests/hermes_cli/test_custom_provider_extra_headers.py
Normal 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
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
119
tests/run_agent/test_custom_provider_extra_headers_client.py
Normal file
119
tests/run_agent/test_custom_provider_extra_headers_client.py
Normal 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"
|
||||
Loading…
Add table
Add a link
Reference in a new issue