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
3 changes: 3 additions & 0 deletions agent/smart_model_routing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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"),
Expand All @@ -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')})",
Expand Down
4 changes: 4 additions & 0 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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),
Expand All @@ -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"),
Expand Down
1 change: 1 addition & 0 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
}

Expand Down
4 changes: 4 additions & 0 deletions hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
7 changes: 7 additions & 0 deletions hermes_cli/runtime_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = [
Expand All @@ -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.
Expand Down
7 changes: 7 additions & 0 deletions run_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
):
"""
Expand Down Expand Up @@ -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()

Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down
171 changes: 171 additions & 0 deletions tests/hermes_cli/test_runtime_provider_resolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading