diff --git a/agent/agent_init.py b/agent/agent_init.py index 6cfcb9f640b4..ddd054af40eb 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -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, @@ -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: diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 233c299758c1..a335740e8df1 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -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") @@ -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)) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 54d3d960fb70..364fcb6cbef9 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -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", } @@ -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: @@ -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 @@ -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", diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index c40316e02ccf..01677ef31588 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -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 — @@ -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 @@ -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 @@ -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( diff --git a/tests/agent/test_auxiliary_client.py b/tests/agent/test_auxiliary_client.py index 7e4ddcae133e..2f193e2f27e5 100644 --- a/tests/agent/test_auxiliary_client.py +++ b/tests/agent/test_auxiliary_client.py @@ -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.""" diff --git a/tests/hermes_cli/test_runtime_provider_resolution.py b/tests/hermes_cli/test_runtime_provider_resolution.py index 129c21f04b20..fbe836f962b9 100644 --- a/tests/hermes_cli/test_runtime_provider_resolution.py +++ b/tests/hermes_cli/test_runtime_provider_resolution.py @@ -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")