diff --git a/hermes_cli/config.py b/hermes_cli/config.py index cadf8decb88d..c1e49c754ba9 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -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.`` 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. diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index 490683f38d7f..68338bfe89e4 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -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 diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index 1f23a8ded926..f7009a38edab 100644 --- a/hermes_cli/model_switch.py +++ b/hermes_cli/model_switch.py @@ -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..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 diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index fba92e9b350d..4374f1dc0518 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -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..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 diff --git a/tests/hermes_cli/test_config.py b/tests/hermes_cli/test_config.py index 1d9c396fda60..fe5c41ae24ee 100644 --- a/tests/hermes_cli/test_config.py +++ b/tests/hermes_cli/test_config.py @@ -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.`` 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