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
43 changes: 43 additions & 0 deletions hermes_cli/runtime_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,45 @@
from hermes_constants import OPENROUTER_BASE_URL


def _normalize_custom_provider_name(value: Optional[str]) -> str:
return (value or "").strip().lower().replace(" ", "-")


def _resolve_custom_provider(requested: Optional[str]) -> Optional[Dict[str, Any]]:
requested_norm = _normalize_custom_provider_name(requested)
if not requested_norm or requested_norm == "auto":
return None

config = load_config()
custom_providers = config.get("custom_providers") or []
if not isinstance(custom_providers, list):
return None

for entry in custom_providers:
if not isinstance(entry, dict):
continue
name = entry.get("name")
base_url = (entry.get("base_url") or "").strip().rstrip("/")
if not name or not base_url:
continue

custom_name = _normalize_custom_provider_name(str(name))
if requested_norm not in {custom_name, f"custom:{custom_name}"}:
continue

api_mode = "codex_responses" if "chatgpt.com/backend-api/codex" in base_url.lower() else "chat_completions"
return {
"provider": custom_name,
"api_mode": api_mode,
"base_url": base_url,
"api_key": (entry.get("api_key") or "").strip(),
"source": "config.custom_providers",
"requested_provider": requested_norm,
}

return None


def _get_model_config() -> Dict[str, Any]:
config = load_config()
model_cfg = config.get("model")
Expand Down Expand Up @@ -120,6 +159,10 @@ def resolve_runtime_provider(
"""Resolve runtime provider credentials for agent execution."""
requested_provider = resolve_requested_provider(requested)

custom_runtime = _resolve_custom_provider(requested_provider)
if custom_runtime is not None:
return custom_runtime

provider = resolve_provider(
requested_provider,
explicit_api_key=explicit_api_key,
Expand Down
53 changes: 52 additions & 1 deletion tests/test_runtime_provider_resolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,58 @@ def test_explicit_openrouter_skips_openai_base_url(monkeypatch):
assert resolved["api_key"] == "or-test-key"


def test_resolve_runtime_provider_custom_provider_from_config(monkeypatch):
monkeypatch.setattr(
rp,
"load_config",
lambda: {
"custom_providers": [
{
"name": "local",
"base_url": "http://0.0.0.0:8080/v1/",
"api_key": "***",
"model": "qwen-36b-q4_k_xl",
}
]
},
)

resolved = rp.resolve_runtime_provider(requested="local")

assert resolved["provider"] == "local"
assert resolved["api_mode"] == "chat_completions"
assert resolved["base_url"] == "http://0.0.0.0:8080/v1"
assert resolved["api_key"] == "***"
assert resolved["requested_provider"] == "local"
assert resolved["source"] == "config.custom_providers"


def test_resolve_runtime_provider_custom_provider_with_non_fqdn_hostnames(monkeypatch):
monkeypatch.setattr(
rp,
"load_config",
lambda: {
"custom_providers": [
{"name": "localhost", "base_url": "http://localhost:11434/v1/", "api_key": "", "model": "llama"},
{"name": "lab-box", "base_url": "http://lab-box:8080/v1/", "api_key": "token", "model": "qwen"},
{"name": "mesh", "base_url": "http://llm-node.local:9000/v1/", "api_key": "mesh-key", "model": "phi"},
]
},
)

localhost = rp.resolve_runtime_provider(requested="localhost")
bare_host = rp.resolve_runtime_provider(requested="lab-box")
mdns = rp.resolve_runtime_provider(requested="mesh")

assert localhost["base_url"] == "http://localhost:11434/v1"
assert localhost["provider"] == "localhost"
assert bare_host["base_url"] == "http://lab-box:8080/v1"
assert bare_host["provider"] == "lab-box"
assert mdns["base_url"] == "http://llm-node.local:9000/v1"
assert mdns["provider"] == "mesh"


def test_resolve_requested_provider_precedence(monkeypatch):
monkeypatch.setenv("HERMES_INFERENCE_PROVIDER", "nous")

monkeypatch.setattr(rp, "_get_model_config", lambda: {"provider": "openai-codex"})
assert rp.resolve_requested_provider("openrouter") == "openrouter"
35 changes: 35 additions & 0 deletions tests/tools/test_delegate.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,15 @@ def test_empty_input(self):


class TestDelegateTask(unittest.TestCase):
def setUp(self):
# Keep these unit tests isolated from any real ~/.hermes delegation config
# on the machine running the suite.
self._load_config_patcher = patch("tools.delegate_tool._load_config", return_value={})
self._load_config_patcher.start()

def tearDown(self):
self._load_config_patcher.stop()

def test_no_parent_agent(self):
result = json.loads(delegate_task(goal="test"))
self.assertIn("error", result)
Expand Down Expand Up @@ -316,6 +325,32 @@ def test_nous_provider_resolves_nous_credentials(self, mock_resolve):
self.assertEqual(creds["api_key"], "nous-agent-key-xyz")
mock_resolve.assert_called_once_with(requested="nous")

def test_custom_provider_resolves_from_config(self):
"""Custom providers defined in config.yaml should be valid delegation targets."""
parent = _make_mock_parent(depth=0)
cfg = {"model": "zh-qwen27-thinking", "provider": "local"}

with patch(
"hermes_cli.runtime_provider.load_config",
lambda: {
"custom_providers": [
{
"name": "local",
"base_url": "http://100.100.0.11:8931/v1/",
"api_key": "no-api-key",
"model": "zh-qwen27-thinking",
}
]
},
):
creds = _resolve_delegation_credentials(cfg, parent)

self.assertEqual(creds["model"], "zh-qwen27-thinking")
self.assertEqual(creds["provider"], "local")
self.assertEqual(creds["base_url"], "http://100.100.0.11:8931/v1")
self.assertEqual(creds["api_key"], "no-api-key")
self.assertEqual(creds["api_mode"], "chat_completions")

@patch("hermes_cli.runtime_provider.resolve_runtime_provider")
def test_provider_resolution_failure_raises_valueerror(self, mock_resolve):
"""When provider resolution fails, ValueError is raised with helpful message."""
Expand Down