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
58 changes: 58 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 Expand Up @@ -1540,6 +1565,39 @@ def resolve_runtime_provider(
)
return azure_runtime

# Vertex AI: OAuth2-token provider (Gemini via the OpenAI-compatible
# endpoint). Resolve BEFORE the custom-runtime / credential-pool / generic
# paths. The credential *path* (GOOGLE_APPLICATION_CREDENTIALS /
# VERTEX_CREDENTIALS_PATH) must never reach the credential pool or the
# generic api_key resolver — those would treat the file path as a static
# API key. Instead we mint a short-lived OAuth2 access token here and hand
# it to the standard OpenAI client as api_key, with base_url computed from
# the project ID + region. The token is re-minted per call (5-min refresh
# margin) by get_vertex_config(); mid-session expiry is additionally
# recovered on 401 by run_agent._try_refresh_vertex_client_credentials().
if requested_provider in ("vertex", "google-vertex", "vertex-ai", "gcp-vertex", "vertexai"):
from agent.vertex_adapter import get_vertex_config

token, base_url = get_vertex_config()
if not token or not base_url:
raise AuthError(
"Vertex AI credentials could not be resolved. Vertex uses "
"OAuth2 (not a static API key): provide a service-account JSON "
"via GOOGLE_APPLICATION_CREDENTIALS (or VERTEX_CREDENTIALS_PATH) "
"in ~/.hermes/.env, or run 'gcloud auth application-default "
"login' for ADC. Set the GCP project/region under vertex: in "
"config.yaml if they aren't embedded in the credentials. "
"Install the extra with: pip install 'hermes-agent[vertex]'."
)
return {
"provider": "vertex",
"api_mode": "chat_completions",
"base_url": base_url.rstrip("/"),
"api_key": token,
"source": "vertex-oauth",
"requested_provider": requested_provider,
}

custom_runtime = _resolve_named_custom_runtime(
requested_provider=requested_provider,
explicit_api_key=explicit_api_key,
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: 0 additions & 1 deletion scripts/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,6 @@
"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)
"me@keslerm.com": "keslerm", # PR #3459 salvage (gateway: 'log' tool_progress mode — silent in chat, tool calls appended to ~/.hermes/logs/tool_calls.log via rotating handler; duplicate of #3458 by @dlkakbs who submitted 4 min earlier — both credited)
"david.d.zhang@gmail.com": "Git-on-my-level", # PR #3659 salvage (gateway: persist per-session /model overrides across gateway restarts)
"tarunravi@gmail.com": "tarunravi", # PR #2696 salvage (api-server: inline MEDIA:<path> image tags as base64 data URLs in final responses so remote OpenAI-compatible frontends can render server-local screenshots; the PR's tool-progress-streaming and SSE-sentinel pieces were independently superseded on main)
"aqdrgg19@gmail.com": "VolodymyrBg", # PR #2861 salvage (webhook: drop the unused full request payload from retained _delivery_info entries — up to ~1MB dead weight per delivery for the 1h idempotency TTL)
"ohyes9711@gmail.com": "CharmingGroot", # PR #2794 salvage (email: guard msg_data[0][1] against malformed IMAP fetch structures so one bad response can't abort the batch and permanently lose seen-marked messages; Message-ID domain falls back to localhost when EMAIL_ADDRESS lacks '@')
Expand Down
Loading
Loading