mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-09 08:21:50 +00:00
feat(model): honor discover_models in terminal hermes model named-custom flow
The terminal `hermes model` wizard (_model_flow_named_custom) always live-probed a custom provider's /models endpoint, ignoring the configured `models:` list. For plans whose endpoint exposes a large catalog (e.g. Baidu Qianfan Coding Plan returns 100+ models for a 2-3 model plan) the picker flooded with models the user can't use. This wires `discover_models` (and the `models:` list) through _named_custom_provider_map into the flow and honors `discover_models: false` the same way the slash-command picker (model_switch.py sections 3 & 4) does: - Default stays True — live probe, no behaviour change. - discover_models: false → use the configured `models:` list verbatim, skip the probe (string 'false'/'no'/'0' normalised to False). - If the probe is on but returns empty, fall back to the configured list instead of forcing manual entry. Closes #18726
This commit is contained in:
parent
6f6eb871d8
commit
7ae8aac3b9
2 changed files with 165 additions and 5 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue