feat(providers): extend `enabled: false` gate to built-in resolution

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.
This commit is contained in:
nnnet 2026-06-02 14:08:39 +03:00 committed by Teknium
parent 7de06f700e
commit 305ecac8b2
2 changed files with 102 additions and 0 deletions

View file

@ -1538,6 +1538,27 @@ def resolve_runtime_provider(
"""
requested_provider = resolve_requested_provider(requested)
# Honour ``providers.<name>.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",

View file

@ -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