From 305ecac8b263c56402f5f932ae0044f57c86c8f1 Mon Sep 17 00:00:00 2001 From: nnnet Date: Tue, 2 Jun 2026 14:08:39 +0300 Subject: [PATCH] feat(providers): extend ``enabled: false`` gate to built-in resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first commit's gate sat inside ``_get_named_custom_provider`` — which only handles user-defined custom blocks. Built-in provider names (``openai`` / ``anthropic`` / ``openrouter`` / ``gemini`` / ...) have their own resolution paths in ``resolve_runtime_provider`` (pool / explicit / generic / ``resolve_provider``) and bypass that gate. So a user who flipped ``providers.openrouter.enabled: false`` would still see OpenRouter resolved when something explicitly requested it (e.g. a fallback chain entry). That defeats the point of the flag. This commit moves the gate one level up: right after ``requested_provider`` is computed, before any custom / built-in / Azure short-circuit. It now raises a typed ``ValueError`` referencing the YAML path, so callers can recognise it and advance to the next fallback instead of silently using a disabled provider. 3 new tests cover: * disabled custom provider raises * disabled built-in provider raises * enabled provider doesn't hit the gate All 20 tests in the providers suite pass. --- hermes_cli/runtime_provider.py | 21 +++++++++ tests/hermes_cli/test_config.py | 81 +++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+) 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