Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions agent/agent_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -976,15 +976,28 @@ def _moa_reference_relay(event: str, **kwargs: Any) -> None:

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)
Expand Down
14 changes: 13 additions & 1 deletion agent/auxiliary_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
19 changes: 19 additions & 0 deletions cli-config.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
76 changes: 75 additions & 1 deletion hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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",
):
Expand Down Expand Up @@ -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,
Expand Down
25 changes: 25 additions & 0 deletions hermes_cli/runtime_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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 —
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions run_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -4384,6 +4384,22 @@ def _apply_client_headers_for_base_url(self, base_url: str) -> None:
# 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.

Expand Down
1 change: 1 addition & 0 deletions scripts/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
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)
Expand Down
Loading
Loading