diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 391d85f1b52..6530c0da59e 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -2610,6 +2610,8 @@ def select_provider_and_model(args=None): "api_key": entry.get("api_key", ""), "key_env": entry.get("key_env", ""), "model": entry.get("model", ""), + "models": entry.get("models", {}), + "discover_models": entry.get("discover_models", True), "api_mode": entry.get("api_mode", ""), "provider_key": provider_key, "api_key_ref": _lookup_ref( @@ -4792,17 +4794,45 @@ def _model_flow_named_custom(config, provider_info): api_key = os.environ.get(key_env, "") config_api_key = _custom_provider_api_key_config_value(provider_info, api_key) + # Honor ``discover_models: false`` (default True) — when discovery is + # disabled, use the configured ``models:`` list verbatim and skip the + # live /models probe. This lets operators restrict the picker to the + # subset their plan actually serves instead of the endpoint's full + # catalog (#18726: Baidu Qianfan returns 100+ models for a 2-3 model + # plan). Same semantics as the slash-command picker (model_switch.py + # sections 3 & 4): default discovers, false keeps the explicit list. + discover = provider_info.get("discover_models", True) + if isinstance(discover, str): + discover = discover.lower() not in {"false", "no", "0"} + configured_models: list[str] = [] + cfg_models = provider_info.get("models", {}) + if isinstance(cfg_models, dict): + configured_models = [str(m) for m in cfg_models if str(m).strip()] + elif isinstance(cfg_models, list): + configured_models = [ + str(m) for m in cfg_models if isinstance(m, str) and m.strip() + ] + print(f" Provider: {name}") print(f" URL: {base_url}") if saved_model: print(f" Current: {saved_model}") print() - print("Fetching available models...") - fetch_kwargs = {"timeout": 8.0} - if api_mode: - fetch_kwargs["api_mode"] = api_mode - models = fetch_api_models(api_key, base_url, **fetch_kwargs) + if not discover and configured_models: + # Discovery disabled with an explicit list — use it verbatim, no probe. + print(f"Using configured models (discover_models: false): {len(configured_models)}") + models = configured_models + else: + print("Fetching available models...") + fetch_kwargs = {"timeout": 8.0} + if api_mode: + fetch_kwargs["api_mode"] = api_mode + models = fetch_api_models(api_key, base_url, **fetch_kwargs) + # If the probe came back empty but the operator configured an explicit + # list, fall back to it rather than forcing manual entry. + if not models and configured_models: + models = configured_models if models: default_idx = 0 diff --git a/tests/hermes_cli/test_custom_provider_model_switch.py b/tests/hermes_cli/test_custom_provider_model_switch.py index 45415f50fc6..4dfd019ed68 100644 --- a/tests/hermes_cli/test_custom_provider_model_switch.py +++ b/tests/hermes_cli/test_custom_provider_model_switch.py @@ -563,3 +563,133 @@ class TestCustomProviderModelSwitch: # clobber it via _preserve_env_ref_templates). assert entry["api_key"] == "${HERMES_CRS_HENKEE_KEY}" assert "cr_live_secret_xyz" not in saved_text + + +class TestCustomProviderDiscoverModels: + """#18726: honor ``discover_models: false`` in the terminal ``hermes model`` + named-custom flow so the picker shows the configured ``models:`` subset + instead of the endpoint's full live catalog.""" + + def test_discover_false_uses_configured_list_and_skips_probe(self, config_home): + """discover_models: false + configured models → no live probe, the + configured list is used verbatim.""" + from hermes_cli.main import _model_flow_named_custom + + provider_info = { + "name": "Baidu Coding", + "base_url": "https://qianfan.baidubce.com/v2/coding", + "api_key": "sk-test", + "discover_models": False, + "models": {"kimi-k2.5": {}, "glm-5": {}}, + "model": "kimi-k2.5", + } + + with patch("hermes_cli.models.fetch_api_models") as mock_fetch, \ + patch("hermes_cli.curses_ui.curses_radiolist", side_effect=ImportError), \ + patch("builtins.input", return_value="2"), \ + patch("builtins.print"): + _model_flow_named_custom({}, provider_info) + + # The live /models endpoint must NOT be probed when discovery is off. + mock_fetch.assert_not_called() + + def test_discover_false_saves_choice_from_configured_list(self, config_home): + """User picks the 2nd configured model; it persists, list-driven.""" + import yaml + from hermes_cli.main import _model_flow_named_custom + + provider_info = { + "name": "Baidu Coding", + "base_url": "https://qianfan.baidubce.com/v2/coding", + "api_key": "sk-test", + "discover_models": False, + "models": {"kimi-k2.5": {}, "glm-5": {}}, + "model": "kimi-k2.5", + } + + with patch("hermes_cli.models.fetch_api_models") as mock_fetch, \ + patch("hermes_cli.curses_ui.curses_radiolist", side_effect=ImportError), \ + patch("builtins.input", return_value="2"), \ + patch("builtins.print"): + _model_flow_named_custom({}, provider_info) + + mock_fetch.assert_not_called() + config = yaml.safe_load((config_home / "config.yaml").read_text()) or {} + model = config.get("model") + assert isinstance(model, dict) + assert model["default"] == "glm-5" + + def test_default_still_probes_when_discover_unset(self, config_home): + """Default (discover_models unset → True) keeps live-probe behaviour + even when a models: list is configured — Option B opt-out semantics.""" + from hermes_cli.main import _model_flow_named_custom + + provider_info = { + "name": "My Gateway", + "base_url": "https://gw.example.com/v1", + "api_key": "sk-test", + "models": {"subset-a": {}}, # configured, but discovery NOT disabled + "model": "subset-a", + } + + with patch( + "hermes_cli.models.fetch_api_models", + return_value=["live-a", "live-b", "live-c"], + ) as mock_fetch, \ + patch("hermes_cli.curses_ui.curses_radiolist", side_effect=ImportError), \ + patch("builtins.input", return_value="1"), \ + patch("builtins.print"): + _model_flow_named_custom({}, provider_info) + + # Probe MUST still run — configured models: alone does not whitelist. + mock_fetch.assert_called_once_with( + "sk-test", + "https://gw.example.com/v1", + timeout=8.0, + ) + + def test_probe_empty_falls_back_to_configured_list(self, config_home): + """When discovery is on but the probe returns nothing, fall back to the + configured models: list instead of forcing manual entry.""" + import yaml + from hermes_cli.main import _model_flow_named_custom + + provider_info = { + "name": "My Gateway", + "base_url": "https://gw.example.com/v1", + "api_key": "sk-test", + "models": {"fallback-a": {}, "fallback-b": {}}, + "model": "fallback-a", + } + + with patch("hermes_cli.models.fetch_api_models", return_value=[]), \ + patch("hermes_cli.curses_ui.curses_radiolist", side_effect=ImportError), \ + patch("builtins.input", return_value="2"), \ + patch("builtins.print"): + _model_flow_named_custom({}, provider_info) + + config = yaml.safe_load((config_home / "config.yaml").read_text()) or {} + model = config.get("model") + assert isinstance(model, dict) + assert model["default"] == "fallback-b" + + def test_discover_false_string_is_normalised(self, config_home): + """String 'false' (hand-edited configs) disables discovery too.""" + from hermes_cli.main import _model_flow_named_custom + + provider_info = { + "name": "Baidu Coding", + "base_url": "https://qianfan.baidubce.com/v2/coding", + "api_key": "sk-test", + "discover_models": "false", + "models": {"kimi-k2.5": {}, "glm-5": {}}, + "model": "kimi-k2.5", + } + + with patch("hermes_cli.models.fetch_api_models") as mock_fetch, \ + patch("hermes_cli.curses_ui.curses_radiolist", side_effect=ImportError), \ + patch("builtins.input", return_value="1"), \ + patch("builtins.print"): + _model_flow_named_custom({}, provider_info) + + mock_fetch.assert_not_called()