From 15b583cdba8c7fb4fd6b9c389b10e1bb49eb7e97 Mon Sep 17 00:00:00 2001 From: Albert Latham Date: Thu, 12 Mar 2026 03:34:50 +0000 Subject: [PATCH 1/2] fix(custom provider): support custom providers in delegation runtime --- hermes_cli/runtime_provider.py | 43 ++++++++++++++++++ tests/test_runtime_provider_resolution.py | 53 ++++++++++++++++++++++- tests/tools/test_delegate.py | 26 +++++++++++ 3 files changed, 121 insertions(+), 1 deletion(-) diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index 4e6910dad..d104c3bc5 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -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") @@ -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, diff --git a/tests/test_runtime_provider_resolution.py b/tests/test_runtime_provider_resolution.py index 9ccd7c7ec..0ea20bc5c 100644 --- a/tests/test_runtime_provider_resolution.py +++ b/tests/test_runtime_provider_resolution.py @@ -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" diff --git a/tests/tools/test_delegate.py b/tests/tools/test_delegate.py index 113fe3dd7..030d85148 100644 --- a/tests/tools/test_delegate.py +++ b/tests/tools/test_delegate.py @@ -316,6 +316,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.""" From 303dba4cc3fc1d8b3c8c6ddf244a214f8d67a3d7 Mon Sep 17 00:00:00 2001 From: Albert Latham Date: Thu, 12 Mar 2026 05:41:50 +0000 Subject: [PATCH 2/2] fix(config): prevent app config pollution in test --- tests/tools/test_delegate.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/tools/test_delegate.py b/tests/tools/test_delegate.py index 030d85148..81686470f 100644 --- a/tests/tools/test_delegate.py +++ b/tests/tools/test_delegate.py @@ -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)