feat(providers): add `enabled: false` flag to hide a provider

A ``providers.<name>`` block in ``config.yaml`` can now opt out of being
listed anywhere by setting ``enabled: false`` — without removing the
block, so re-enabling it stays a one-line edit. Missing or ``true`` keeps
the previous behaviour (enabled), so this is fully backwards-compatible.

The flag is honoured in four places:

* ``hermes_cli/model_switch.py`` — model-override validation (the
  allow-list that the picker consults to accept a non-public model id)
  and the picker's own endpoint iteration. A disabled provider no longer
  appears as a row and its models can't be silently accepted via
  override.
* ``hermes_cli/runtime_provider.py`` — the runtime resolver skips
  disabled blocks, so an explicit ``--provider X`` against a disabled
  entry fails fast instead of using stale base_url / api_key from the
  ignored block.
* ``hermes_cli/doctor.py`` — the doctor's "configured providers" set
  excludes disabled entries, so health checks don't flag missing API
  keys for providers the user has turned off.

Motivation: when a user has 20+ providers wired up in ``config.yaml``
(many of them only used occasionally) the picker becomes noisy and the
runtime resolver may pick a suboptimal one on ambiguous --provider names.
There's currently no way to hide a provider short of deleting its block
— which loses the api_key + base_url + custom routing config the user
spent time wiring. ``enabled: false`` lets them keep the config but get
it out of the way.

The helper ``is_provider_enabled()`` in ``hermes_cli/config.py``
centralises the gate (and accepts YAML-stringified booleans like
``"false"`` for hand-edited configs). 17 unit tests cover the defaults
and edge cases.

A follow-up PR can wire ``hermes provider enable/disable <name>`` and a
dashboard toggle on top of this primitive — they reduce to mutating the
flag.
This commit is contained in:
nnnet 2026-06-02 13:02:08 +03:00 committed by Teknium
parent b239ee2123
commit 7de06f700e
5 changed files with 81 additions and 1 deletions

View file

@ -6963,6 +6963,31 @@ def _normalize_max_turns_config(config: Dict[str, Any]) -> Dict[str, Any]:
return config
def is_provider_enabled(provider_cfg: Optional[Dict[str, Any]]) -> bool:
"""Return whether a ``providers.<name>`` config block is enabled.
A provider is enabled by default. Only an explicit ``enabled: false`` in
the block hides it from the model picker, ``/models`` listings, the
runtime resolver and the doctor / status output.
Backward-compat: configs without the ``enabled`` key keep working as
before the default is ``True``.
Pass any non-dict (None, list, string) and you get ``True`` too, so
malformed entries don't disappear silently; they'll still be flagged
by the existing validation paths.
"""
if not isinstance(provider_cfg, dict):
return True
flag = provider_cfg.get("enabled", True)
if isinstance(flag, bool):
return flag
# YAML can produce strings for "true"/"false" depending on quoting.
if isinstance(flag, str):
return flag.strip().lower() not in {"false", "0", "no", "off"}
return bool(flag)
def cfg_get(cfg: Optional[Dict[str, Any]], *keys: str, default: Any = None) -> Any:
"""Traverse nested dict keys safely, returning ``default`` on any miss.

View file

@ -869,7 +869,12 @@ def run_doctor(args):
user_providers = cfg.get("providers")
if isinstance(user_providers, dict):
known_providers.update(str(name).strip().lower() for name in user_providers if str(name).strip())
from hermes_cli.config import is_provider_enabled
known_providers.update(
str(name).strip().lower()
for name, prov_cfg in user_providers.items()
if str(name).strip() and is_provider_enabled(prov_cfg)
)
for entry in custom_providers:
if not isinstance(entry, dict):
continue

View file

@ -1450,8 +1450,11 @@ def switch_model(
if not validation.get("accepted"):
override = False
if user_providers:
from hermes_cli.config import is_provider_enabled
# user_providers is a dict: {provider_slug: config_dict}
for slug, cfg in user_providers.items():
if not is_provider_enabled(cfg):
continue
if slug == target_provider:
if new_model in _declared_model_ids(cfg.get("models", {})):
override = True
@ -2228,10 +2231,16 @@ def list_authenticated_providers(
# the wire protocol differs.
from collections import OrderedDict as _OD3
from hermes_cli.config import is_provider_enabled
ep_groups: "_OD3[tuple, dict]" = _OD3()
for ep_name, ep_cfg in user_providers.items():
if not isinstance(ep_cfg, dict):
continue
# Honour explicit ``providers.<name>.enabled: false`` from
# config — these are hidden from the picker.
if not is_provider_enabled(ep_cfg):
continue
if ep_name.lower() in seen_slugs:
continue
display_name = ep_cfg.get("name", "") or ep_name

View file

@ -659,9 +659,16 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An
# First check providers: dict (new-style user-defined providers)
providers = config.get("providers")
if isinstance(providers, dict):
from hermes_cli.config import is_provider_enabled
for ep_name, entry in providers.items():
if not isinstance(entry, dict):
continue
# Skip providers the user explicitly disabled via
# ``providers.<name>.enabled: false``. They remain in config
# so re-enabling is a one-line edit, but the resolver pretends
# they're not configured.
if not is_provider_enabled(entry):
continue
# Match exact name or normalized name
name_norm = _normalize_custom_provider_name(ep_name)
# Resolve the API key from the env var name stored in key_env

View file

@ -15,6 +15,7 @@ from hermes_cli.config import (
get_compatible_custom_providers,
_explicit_config_paths,
_normalize_max_turns_config,
is_provider_enabled,
load_config,
load_env,
migrate_config,
@ -2240,3 +2241,36 @@ class TestCodexAppServerAutoConfig:
assert raw["compression"]["codex_app_server_auto"] == "hermes"
class TestIsProviderEnabled:
"""``is_provider_enabled`` gates ``providers.<name>`` blocks for the
model picker, ``/models`` listings and the runtime resolver. Default
must be ``True`` so existing configs keep working untouched."""
def test_missing_flag_defaults_to_enabled(self):
assert is_provider_enabled({"name": "Anthropic"}) is True
def test_empty_block_defaults_to_enabled(self):
assert is_provider_enabled({}) is True
def test_explicit_true_is_enabled(self):
assert is_provider_enabled({"enabled": True}) is True
def test_explicit_false_hides_it(self):
assert is_provider_enabled({"enabled": False}) is False
@pytest.mark.parametrize("raw", ["false", "False", "FALSE", "0", "no", "off"])
def test_yaml_string_falsy_values_hide_it(self, raw):
# YAML can hand us a string for a value when the user quotes it.
assert is_provider_enabled({"enabled": raw}) is False
@pytest.mark.parametrize("raw", ["true", "True", "yes", "on", "1", "anything-else"])
def test_yaml_string_truthy_values_keep_it_enabled(self, raw):
assert is_provider_enabled({"enabled": raw}) is True
def test_non_dict_input_defaults_to_enabled(self):
# Malformed entries (None, list, string) don't disappear silently —
# the gate stays open and the existing validation paths will flag
# them.
assert is_provider_enabled(None) is True
assert is_provider_enabled([]) is True
assert is_provider_enabled("oops") is True