diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index 4374f1dc051..6fee9b013a9 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -1538,6 +1538,27 @@ def resolve_runtime_provider( """ requested_provider = resolve_requested_provider(requested) + # Honour ``providers..enabled: false`` for BOTH user-defined + # custom providers and the built-in ones (openai / anthropic / + # openrouter / gemini / ...). The earlier ``_get_named_custom_provider`` + # gate only covers custom blocks — built-in resolution paths + # (``resolve_provider`` + pool / explicit / generic runtime) walk + # their own short-circuits and would otherwise return stale config + # for a provider the user explicitly turned off. + # + # Fail fast with a typed error so the fallback chain can advance to + # the next provider instead of using a disabled one. + from hermes_cli.config import is_provider_enabled, load_config + _full_cfg = load_config() + _provs_cfg = _full_cfg.get("providers") if isinstance(_full_cfg, dict) else None + if isinstance(_provs_cfg, dict): + _block = _provs_cfg.get(requested_provider) + if isinstance(_block, dict) and not is_provider_enabled(_block): + raise ValueError( + f"provider {requested_provider!r} is disabled in config " + f"(providers.{requested_provider}.enabled: false)" + ) + if requested_provider == "moa": return { "provider": "moa", diff --git a/tests/hermes_cli/test_config.py b/tests/hermes_cli/test_config.py index fe5c41ae24e..c7e48dab2d3 100644 --- a/tests/hermes_cli/test_config.py +++ b/tests/hermes_cli/test_config.py @@ -2274,3 +2274,84 @@ class TestIsProviderEnabled: assert is_provider_enabled(None) is True assert is_provider_enabled([]) is True assert is_provider_enabled("oops") is True + + +class TestProviderEnabledRuntimeGate: + """Verify ``resolve_runtime_provider`` honours ``enabled: false`` for + both custom-defined and built-in provider names. Smoke test only — + full runtime resolution has its own fixture-heavy tests; here we + only assert the early-exit raises a typed error.""" + + def test_disabled_custom_provider_raises_valueerror(self, tmp_path, monkeypatch): + cfg = { + "model": {"default": "claude-sonnet-4-6", "provider": "claude-agent-sdk"}, + "providers": { + "my-fork": { + "name": "my-fork", + "base_url": "http://127.0.0.1:9999", + "api_key": "not-needed", + "enabled": False, + }, + }, + } + config_path = tmp_path / "config.yaml" + config_path.write_text(yaml.safe_dump(cfg)) + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + # Bust the in-process config cache so the override picks up. + from hermes_cli import config as cfg_mod + cfg_mod._cached_config = None # type: ignore[attr-defined] + + from hermes_cli.runtime_provider import resolve_runtime_provider + with pytest.raises(ValueError, match="disabled"): + resolve_runtime_provider(requested="my-fork") + + def test_disabled_builtin_provider_raises_valueerror(self, tmp_path, monkeypatch): + # `openrouter` is a built-in name with its own resolution path — + # the gate must fire BEFORE that path runs. + cfg = { + "model": {"default": "claude-sonnet-4-6", "provider": "claude-agent-sdk"}, + "providers": { + "openrouter": { + "name": "OpenRouter", + "base_url": "https://openrouter.ai/api/v1", + "enabled": False, + }, + }, + } + config_path = tmp_path / "config.yaml" + config_path.write_text(yaml.safe_dump(cfg)) + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + from hermes_cli import config as cfg_mod + cfg_mod._cached_config = None # type: ignore[attr-defined] + + from hermes_cli.runtime_provider import resolve_runtime_provider + with pytest.raises(ValueError, match="disabled"): + resolve_runtime_provider(requested="openrouter") + + def test_enabled_provider_does_not_raise(self, tmp_path, monkeypatch): + cfg = { + "model": {"default": "claude-sonnet-4-6", "provider": "claude-agent-sdk"}, + "providers": { + "claude-agent-sdk": { + "name": "Claude Agent SDK", + "base_url": "http://127.0.0.1:3456", + "api_key": "not-needed", + "enabled": True, + }, + }, + } + config_path = tmp_path / "config.yaml" + config_path.write_text(yaml.safe_dump(cfg)) + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + from hermes_cli import config as cfg_mod + cfg_mod._cached_config = None # type: ignore[attr-defined] + + # Don't assert success — built-in resolution needs more state. + # We only assert this path doesn't hit the disabled-gate. + from hermes_cli.runtime_provider import resolve_runtime_provider + try: + resolve_runtime_provider(requested="claude-agent-sdk") + except ValueError as e: + assert "disabled" not in str(e).lower() + except Exception: + pass # any non-ValueError is fine; we only gate the disabled path