Skip to content
Closed
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
22 changes: 22 additions & 0 deletions agent/agent_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,27 @@ def _merge_custom_provider_extra_body(agent, custom_providers: List[Dict[str, An
agent.request_overrides = overrides


def _merge_custom_provider_default_headers(agent, custom_providers: List[Dict[str, Any]]) -> None:
from hermes_cli.runtime_provider import _custom_provider_request_overrides
from agent.context_compressor import _match_custom_provider_entry

match = _match_custom_provider_entry(
provider=agent.provider,
model=agent.model,
base_url=agent.base_url,
custom_providers=custom_providers,
)
if not match:
return
request_overrides = _custom_provider_request_overrides(match)
default_headers = request_overrides.get("default_headers")
if not isinstance(default_headers, dict) or not default_headers:
return
headers = dict(agent._client_kwargs.get("default_headers") or {})
headers.update(default_headers)
agent._client_kwargs["default_headers"] = headers


def init_agent(
agent,
base_url: str = None,
Expand Down Expand Up @@ -1320,6 +1341,7 @@ def init_agent(
# compression model context-length detection needs the same list).
agent._custom_providers = _custom_providers
_merge_custom_provider_extra_body(agent, _custom_providers)
_merge_custom_provider_default_headers(agent, _custom_providers)

# Check custom_providers per-model context_length
if _config_context_length is None and _custom_providers:
Expand Down
11 changes: 11 additions & 0 deletions agent/auxiliary_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -3437,6 +3437,15 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "",
raw_base_for_wrap = custom_base
_clean_base2, _dq2 = _extract_url_query_params(openai_base)
_extra2 = {"default_query": _dq2} if _dq2 else {}
_request_overrides = {}
try:
from hermes_cli.runtime_provider import _custom_provider_request_overrides
_request_overrides = _custom_provider_request_overrides(custom_entry)
except Exception:
_request_overrides = {}
_default_headers = _request_overrides.get("default_headers")
if isinstance(_default_headers, dict) and _default_headers:
_extra2["default_headers"] = dict(_default_headers)
logger.debug(
"resolve_provider_client: named custom provider %r (%s, api_mode=%s)",
provider, final_model, entry_api_mode or "chat_completions")
Expand All @@ -3459,6 +3468,8 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "",
_fallback_base = _to_openai_base_url(custom_base)
_fb_clean, _fb_dq = _extract_url_query_params(_fallback_base)
_fb_extra = {"default_query": _fb_dq} if _fb_dq else {}
if isinstance(_default_headers, dict) and _default_headers:
_fb_extra["default_headers"] = dict(_default_headers)
client = OpenAI(api_key=custom_key, base_url=_fb_clean, **_fb_extra)
return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode
else (client, final_model))
Expand Down
9 changes: 7 additions & 2 deletions hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -3182,6 +3182,7 @@ def _normalize_custom_provider_entry(
"keyEnv": "key_env",
"apiKeyEnv": "key_env", # alias — OpenClaw-compatible + docs variant
"defaultModel": "default_model",
"defaultHeaders": "default_headers",
"contextLength": "context_length",
"rateLimitDelay": "rate_limit_delay",
}
Expand All @@ -3195,7 +3196,7 @@ 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",
"discover_models", "extra_body", "default_headers",
}
for camel, snake in _CAMEL_ALIASES.items():
if camel in entry and snake not in entry:
Expand Down Expand Up @@ -3294,6 +3295,10 @@ def _normalize_custom_provider_entry(
if isinstance(extra_body, dict):
normalized["extra_body"] = dict(extra_body)

default_headers = entry.get("default_headers")
if isinstance(default_headers, dict):
normalized["default_headers"] = dict(default_headers)

return normalized


Expand Down Expand Up @@ -3454,7 +3459,7 @@ def check_config_version() -> Tuple[int, int]:
# Valid fields inside a custom_providers list entry
_VALID_CUSTOM_PROVIDER_FIELDS = {
"name", "base_url", "api_key", "api_mode", "model", "models",
"context_length", "rate_limit_delay", "extra_body",
"context_length", "rate_limit_delay", "extra_body", "default_headers",
# key_env is read at runtime by runtime_provider.py and auxiliary_client.py
# — include it here so the set accurately describes the supported schema.
"key_env",
Expand Down
30 changes: 27 additions & 3 deletions hermes_cli/runtime_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,9 @@ 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)
default_headers = entry.get("default_headers")
if isinstance(default_headers, dict):
result["default_headers"] = dict(default_headers)
# 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 @@ -559,6 +562,9 @@ 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)
default_headers = entry.get("default_headers")
if isinstance(default_headers, dict):
result["default_headers"] = dict(default_headers)
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 @@ -605,6 +611,9 @@ 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)
default_headers = entry.get("default_headers")
if isinstance(default_headers, dict):
result["default_headers"] = dict(default_headers)
api_mode = _parse_api_mode(entry.get("api_mode"))
if api_mode:
result["api_mode"] = api_mode
Expand All @@ -617,10 +626,25 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An


def _custom_provider_request_overrides(custom_provider: Dict[str, Any]) -> Dict[str, Any]:
overrides: Dict[str, Any] = {}
extra_body = custom_provider.get("extra_body")
if not isinstance(extra_body, dict) or not extra_body:
return {}
return {"extra_body": dict(extra_body)}
if isinstance(extra_body, dict) and extra_body:
overrides["extra_body"] = dict(extra_body)

default_headers = custom_provider.get("default_headers")
if isinstance(default_headers, dict) and default_headers:
headers: Dict[str, str] = {}
for key, value in default_headers.items():
if key is None or value is None:
continue
key_s = str(key).strip()
if not key_s:
continue
headers[key_s] = str(value)
if headers:
overrides["default_headers"] = headers

return overrides


def _resolve_named_custom_runtime(
Expand Down
31 changes: 31 additions & 0 deletions tests/agent/test_auxiliary_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -578,6 +578,37 @@ def test_explicit_model_takes_precedence_over_fallbacks(self):
mock_read_main.assert_not_called()
assert mock_build.call_args.args[0] == "grok-4.20-multi-agent"

def test_named_custom_provider_passes_default_headers(self, monkeypatch):
"""Named custom providers can configure OpenAI SDK default headers."""
import agent.auxiliary_client as aux

captured = {}

class _Client:
def __init__(self, **kwargs):
captured.update(kwargs)
self.api_key = kwargs.get("api_key")
self.base_url = kwargs.get("base_url")

monkeypatch.setattr(
"hermes_cli.runtime_provider._get_named_custom_provider",
lambda provider: {
"name": "my-gateway",
"base_url": "https://gateway.example/v1",
"api_key": "sk-test",
"model": "gpt-5.5",
"api_mode": "chat_completions",
"default_headers": {"User-Agent": "curl/8.7.1"},
},
)
monkeypatch.setattr(aux, "OpenAI", _Client)

client, model = resolve_provider_client("my-gateway", "")

assert client is not None
assert model == "gpt-5.5"
assert captured["default_headers"] == {"User-Agent": "curl/8.7.1"}


class TestExpiredCodexFallback:
"""Test that expired Codex tokens don't block the auto chain."""
Expand Down
50 changes: 50 additions & 0 deletions tests/hermes_cli/test_runtime_provider_resolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -1614,6 +1614,56 @@ def test_named_custom_runtime_propagates_extra_body_direct_path(monkeypatch):
}


def test_named_custom_runtime_propagates_default_headers(monkeypatch):
"""Custom provider default_headers should become runtime request_overrides."""
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "my-gemma")
monkeypatch.setattr(
rp, "_get_named_custom_provider",
lambda p: {
"name": "my-gemma",
"base_url": "http://localhost:8000/v1",
"api_key": "test-key",
"model": "google/gemma-4-31b-it",
"default_headers": {
"User-Agent": "curl/8.7.1",
"X-Custom-Gateway": "test",
},
},
)
monkeypatch.setattr(rp, "_try_resolve_from_custom_pool", lambda *a, **k: None)

resolved = rp.resolve_runtime_provider(requested="my-gemma")
assert resolved["request_overrides"] == {
"default_headers": {
"User-Agent": "curl/8.7.1",
"X-Custom-Gateway": "test",
}
}


def test_custom_provider_config_normalizes_default_headers(monkeypatch, tmp_path):
"""default_headers from config.yaml should survive runtime normalization."""
cfg = {
"custom_providers": [
{
"name": "my-gateway",
"base_url": "https://gateway.example/v1",
"api_key": "sk-test",
"model": "gpt-5.5",
"default_headers": {"User-Agent": "curl/8.7.1"},
}
],
}
monkeypatch.setattr(rp, "load_config", lambda: cfg)
monkeypatch.setattr(rp, "_try_resolve_from_custom_pool", lambda *a, **k: None)

resolved = rp.resolve_runtime_provider(requested="my-gateway")

assert resolved["request_overrides"] == {
"default_headers": {"User-Agent": "curl/8.7.1"}
}


def test_named_custom_runtime_propagates_model_pool_path(monkeypatch):
"""Model should propagate even when credential pool handles credentials."""
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "my-server")
Expand Down