diff --git a/agent/smart_model_routing.py b/agent/smart_model_routing.py index 6d482be27051..e1c1a5ed879f 100644 --- a/agent/smart_model_routing.py +++ b/agent/smart_model_routing.py @@ -121,6 +121,7 @@ def resolve_turn_route(user_message: str, routing_config: Optional[Dict[str, Any "base_url": primary.get("base_url"), "provider": primary.get("provider"), "api_mode": primary.get("api_mode"), + "extra_headers": primary.get("extra_headers"), "command": primary.get("command"), "args": list(primary.get("args") or []), "credential_pool": primary.get("credential_pool"), @@ -157,6 +158,7 @@ def resolve_turn_route(user_message: str, routing_config: Optional[Dict[str, Any "base_url": primary.get("base_url"), "provider": primary.get("provider"), "api_mode": primary.get("api_mode"), + "extra_headers": primary.get("extra_headers"), "command": primary.get("command"), "args": list(primary.get("args") or []), "credential_pool": primary.get("credential_pool"), @@ -181,6 +183,7 @@ def resolve_turn_route(user_message: str, routing_config: Optional[Dict[str, Any "api_mode": runtime.get("api_mode"), "command": runtime.get("command"), "args": list(runtime.get("args") or []), + "extra_headers": runtime.get("extra_headers"), "credential_pool": runtime.get("credential_pool"), }, "label": f"smart route → {route.get('model')} ({runtime.get('provider')})", diff --git a/cli.py b/cli.py index 970c98b0606a..b8e44f5f6808 100644 --- a/cli.py +++ b/cli.py @@ -2727,6 +2727,7 @@ def _ensure_runtime_credentials(self) -> bool: self._provider_source = runtime.get("source") self.api_key = api_key self.base_url = base_url + self.extra_headers = runtime.get("extra_headers") # When a custom_provider entry carries an explicit `model` field, # use it as the effective model name. Without this, running @@ -2779,6 +2780,7 @@ def _resolve_turn_agent_config(self, user_message: str) -> dict: "base_url": self.base_url, "provider": self.provider, "api_mode": self.api_mode, + "extra_headers": getattr(self, "extra_headers", None), "command": self.acp_command, "args": list(self.acp_args or []), "credential_pool": getattr(self, "_credential_pool", None), @@ -2863,6 +2865,7 @@ def _init_agent(self, *, model_override: str = None, runtime_override: dict = No "base_url": self.base_url, "provider": self.provider, "api_mode": self.api_mode, + "extra_headers": getattr(self, "extra_headers", None), "command": self.acp_command, "args": list(self.acp_args or []), "credential_pool": getattr(self, "_credential_pool", None), @@ -2874,6 +2877,7 @@ def _init_agent(self, *, model_override: str = None, runtime_override: dict = No base_url=runtime.get("base_url"), provider=runtime.get("provider"), api_mode=runtime.get("api_mode"), + extra_headers=runtime.get("extra_headers"), acp_command=runtime.get("command"), acp_args=runtime.get("args"), credential_pool=runtime.get("credential_pool"), diff --git a/gateway/run.py b/gateway/run.py index c8c25256b8f2..9ce9c185a56d 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -337,6 +337,7 @@ def _resolve_runtime_agent_kwargs() -> dict: "api_mode": runtime.get("api_mode"), "command": runtime.get("command"), "args": list(runtime.get("args") or []), + "extra_headers": runtime.get("extra_headers"), "credential_pool": runtime.get("credential_pool"), } diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 78cc30157d93..ea2ef8358176 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1682,6 +1682,10 @@ def _normalize_custom_provider_entry( if isinstance(rate_limit_delay, (int, float)) and rate_limit_delay >= 0: normalized["rate_limit_delay"] = rate_limit_delay + extra_headers = entry.get("extra_headers") + if isinstance(extra_headers, dict) and extra_headers: + normalized["extra_headers"] = extra_headers + return normalized diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index b2dec61cdbf7..fbd229c76070 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -357,6 +357,9 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An api_mode = _parse_api_mode(entry.get("api_mode")) if api_mode: result["api_mode"] = api_mode + extra_headers = entry.get("extra_headers") + if isinstance(extra_headers, dict) and extra_headers: + result["extra_headers"] = extra_headers model_name = str(entry.get("model", "") or "").strip() if model_name: result["model"] = model_name @@ -390,6 +393,9 @@ def _resolve_named_custom_runtime( model_name = custom_provider.get("model") if model_name: pool_result["model"] = model_name + # Propagate extra_headers so custom provider auth headers are included + # even when using pooled credentials. + pool_result["extra_headers"] = custom_provider.get("extra_headers") return pool_result api_key_candidates = [ @@ -409,6 +415,7 @@ def _resolve_named_custom_runtime( "base_url": base_url, "api_key": api_key or "no-key-required", "source": f"custom_provider:{custom_provider.get('name', requested_provider)}", + "extra_headers": custom_provider.get("extra_headers"), } # Propagate the model name so callers can override self.model when the # provider name differs from the actual model string the API expects. diff --git a/run_agent.py b/run_agent.py index 626951b27644..82ec323f1c4c 100644 --- a/run_agent.py +++ b/run_agent.py @@ -603,6 +603,7 @@ def __init__( checkpoints_enabled: bool = False, checkpoint_max_snapshots: int = 50, pass_session_id: bool = False, + extra_headers: Dict[str, str] = None, persist_session: bool = True, ): """ @@ -643,6 +644,8 @@ def __init__( skip_context_files (bool): If True, skip auto-injection of SOUL.md, AGENTS.md, and .cursorrules into the system prompt. Use this for batch processing and data generation to avoid polluting trajectories with user-specific persona or project instructions. + extra_headers (Dict[str, str]): Custom HTTP headers to include with API requests (optional). + Used for custom providers that require additional headers for authentication or routing. """ _install_safe_stdio() @@ -666,6 +669,7 @@ def __init__( self.background_review_callback = None # Optional sync callback for gateway delivery self.skip_context_files = skip_context_files self.pass_session_id = pass_session_id + self.extra_headers = extra_headers # Custom headers for custom providers self.persist_session = persist_session self._credential_pool = credential_pool self.log_prefix_chars = log_prefix_chars @@ -916,6 +920,9 @@ def __init__( client_kwargs["default_headers"] = { "User-Agent": "KimiCLI/1.30.0", } + # Apply custom headers for named custom providers (from config.yaml) + if self.provider == "custom" and self.extra_headers: + client_kwargs["default_headers"] = self.extra_headers elif "portal.qwen.ai" in effective_base.lower(): client_kwargs["default_headers"] = _qwen_portal_headers() else: diff --git a/tests/hermes_cli/test_runtime_provider_resolution.py b/tests/hermes_cli/test_runtime_provider_resolution.py index c7510a55b8f0..f68e77a07381 100644 --- a/tests/hermes_cli/test_runtime_provider_resolution.py +++ b/tests/hermes_cli/test_runtime_provider_resolution.py @@ -1102,6 +1102,177 @@ def test_opencode_go_glm_defaults_to_chat_completions(monkeypatch): assert resolved["base_url"] == "https://opencode.ai/zen/go/v1" +# ------------------------------------------------------------------ +# extra_headers support for named custom providers +# ------------------------------------------------------------------ + + +def test_named_custom_provider_with_extra_headers(monkeypatch): + """Custom providers with extra_headers should have them resolved.""" + 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-host-head": "test-host-value", + "x-custom-auth": "auth-123", + }, + } + ] + }, + ) + + resolved = rp.resolve_runtime_provider(requested="customhost") + + assert resolved["provider"] == "custom" + assert resolved["extra_headers"] == { + "x-host-head": "test-host-value", + "x-custom-auth": "auth-123", + } + assert resolved["base_url"] == "https://custom.host.ai/v1" + assert resolved["api_key"] == "custom-host-key" + + +def test_named_custom_provider_extra_headers_empty_dict_ignored(monkeypatch): + """Custom providers with empty extra_headers dict should not include the key.""" + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + monkeypatch.setattr( + rp, + "load_config", + lambda: { + "custom_providers": [ + { + "name": "EmptyHeaders", + "base_url": "https://empty.host/v1", + "api_key": "key", + "extra_headers": {}, + } + ] + }, + ) + + resolved = rp.resolve_runtime_provider(requested="emptyheaders") + + # empty dict results in extra_headers being None (key exists but value is None) + assert "extra_headers" in resolved + assert resolved["extra_headers"] is None + + +def test_named_custom_provider_extra_headers_non_dict_ignored(monkeypatch): + """Custom providers with non-dict extra_headers should not include the key.""" + 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", + } + ] + }, + ) + + resolved = rp.resolve_runtime_provider(requested="badheaders") + + # non-dict results in extra_headers being None (key exists but value is None) + assert "extra_headers" in resolved + assert resolved["extra_headers"] is None + + +def test_resolve_named_custom_runtime_includes_extra_headers(monkeypatch): + """_resolve_named_custom_runtime should include extra_headers from the provider config.""" + monkeypatch.setattr( + rp, + "_get_named_custom_provider", + lambda p: { + "name": "MyHost", + "base_url": "https://myhost.example/v1", + "api_key": "host-key", + "extra_headers": {"x-host-head": "myhost-123"}, + }, + ) + + resolved = rp._resolve_named_custom_runtime(requested_provider="myhost") + + assert resolved is not None + assert resolved["extra_headers"] == {"x-host-head": "myhost-123"} + assert resolved["provider"] == "custom" + + +def test_resolve_named_custom_runtime_without_extra_headers(monkeypatch): + """_resolve_named_custom_runtime includes extra_headers as None when not configured.""" + monkeypatch.setattr( + rp, + "_get_named_custom_provider", + lambda p: { + "name": "PlainHost", + "base_url": "https://plain.host/v1", + "api_key": "plain-key", + }, + ) + + resolved = rp._resolve_named_custom_runtime(requested_provider="plainhost") + + assert resolved is not None + # extra_headers key is present but value is None when not configured + assert "extra_headers" in resolved + assert resolved["extra_headers"] is None + + +def test_resolve_named_custom_runtime_pool_result_includes_extra_headers(monkeypatch): + """When a credential pool exists, extra_headers from the custom provider config + must still be propagated to the pool result. + + Regression test for https://github.com/NousResearch/hermes-agent/pull/3526#issuecomment-4232793523 + """ + 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", + } + # Ensure the pool result fields are preserved + assert resolved["api_key"] == "pooled-key" + assert resolved["source"] == "pool:lmstudio-pool" + + def test_opencode_go_configured_api_mode_still_overrides_default(monkeypatch): monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "opencode-go") monkeypatch.setattr(