From 93ec97d7bbe0aa925ce868efed99819e33a6fe92 Mon Sep 17 00:00:00 2001 From: Craig French <18516125+craigdfrench@users.noreply.github.com> Date: Sun, 21 Jun 2026 21:40:59 -0400 Subject: [PATCH 1/2] feat(model-switch): excluded_providers config + shared-endpoint proxy grouping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reworks #28218 against current main per review feedback (hermes-sweeper, PRR_kwDOPRF1G88AAAABDBIirw). Two independent improvements to the /model picker substrate. ### 1. `model_catalog.excluded_providers` config key Hide specific providers from the /model picker even when valid credentials exist (e.g. a stale Copilot/OpenRouter token cached in auth.json or found via the `gh` CLI). Matched case-insensitively against every key a provider can surface under — hermes_id / mdev_id (section 1), pid / hermes_slug (section 2), canonical slug (section 2b) — so `copilot` hides the provider regardless of which section emits it. Wired through ALL picker/list call paths, not just the inventory substrate: - hermes_cli/inventory.py: ConfigContext.excluded_providers, read from `model_catalog.excluded_providers` in load_picker_context(); forwarded in build_models_payload() (TUI gateway picker surface). - hermes_cli/model_switch.py: list_authenticated_providers() and list_picker_providers() accept excluded_providers; prewarm_picker_cache forwards it so the warm cache matches what the picker will show. - gateway/slash_commands.py: the gateway /model path (both the list_picker_providers interactive picker at ~L1132 and the list_authenticated_providers text fallback at ~L1351) now reads model_catalog.excluded_providers from config and passes it through. ### 2. Fix custom-provider grouping for shared-endpoint proxies Problem: multiple custom_providers entries sharing the same base_url (e.g. an Aperture/LiteLLM proxy fronting cerebras, groq and perplexity at one URL) collapsed into a single picker row under the first provider's name. Per the review, the grouping key is EXTENDED rather than replaced. Current main groups by (api_url, credential_identity, api_mode); this adds the display-name prefix as a fourth dimension: (api_url, credential_identity, api_mode, display_prefix) so same-URL different-name proxies each get their own row, while: - same-host entries with different key_env / api_mode stay separate (credential_identity / api_mode preserved — no regression to test_..._same_url_different_key_env_and_api_mode_stay_separate); - per-model suffix entries sharing a prefix ("Ollama — A", "Ollama — B") still collapse into one row. The display-name prefix is computed once and reused as the row's display name (the prior inline suffix-stripping is deduplicated against it). ### Not carried over from the original PR (per review) - The original replaced the grouping key with (api_url, api_key, prefix), dropping credential_identity / api_mode — that would have regressed same-URL entries with different env-backed credentials or transports. - The original skipped live /models discovery whenever a `models:` dict was present, regressing the default Bifrost/gateway case where the live catalog should replace a stale configured subset. Current main already uses `discover_models: false` as the explicit scoped-subset opt-out, so the proxy-subset use case is handled by telling users to set `discover_models: false` on those entries. No change to should_probe. - The original's slug-assignment rework targeted an older base where section 4 reused current_provider as the slug; current main already uses custom_provider_slug(display_name) with no current_provider reuse, so that change is obsolete. ### Tests (tests/hermes_cli/test_model_switch_custom_providers.py) - test_excluded_providers_hides_builtin_row: openrouter row appears with OPENROUTER_API_KEY set, disappears with excluded_providers=["openrouter"]. - test_excluded_providers_empty_is_noop: [] does not change output. - test_shared_url_different_display_names_are_separate_rows: three entries sharing base_url+api_key+api_mode but different names → three rows. - test_shared_url_per_model_suffix_still_collapses: "Ollama — A"/"Ollama — B" still collapse into one "Ollama" row. ### Docs (website/docs/reference/model-catalog.md) Document `model_catalog.excluded_providers` under the Config section. ### Known limitation / follow-up The interactive `hermes model` CLI picker (hermes_cli/main.py:: select_provider_and_model) builds its provider rows through a separate code path that does not use list_authenticated_providers / build_models_payload, so excluded_providers does not yet hide rows there. Wiring it in is a larger, separate change and out of scope for this review rework. --- gateway/slash_commands.py | 6 + hermes_cli/inventory.py | 4 + hermes_cli/model_switch.py | 55 +++++--- .../test_model_switch_custom_providers.py | 127 ++++++++++++++++++ website/docs/reference/model-catalog.md | 14 ++ 5 files changed, 189 insertions(+), 17 deletions(-) diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index ca519413a07b..5227920f64a8 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -1077,6 +1077,7 @@ async def _handle_model_command(self, event: MessageEvent) -> Optional[str]: current_api_key = "" user_provs = None custom_provs = None + excluded_provs = [] config_path = _hermes_home / "config.yaml" try: cfg = _load_gateway_config() @@ -1092,6 +1093,9 @@ async def _handle_model_command(self, event: MessageEvent) -> Optional[str]: custom_provs = get_compatible_custom_providers(cfg) except Exception: custom_provs = cfg.get("custom_providers") + _excl = cfg.get("model_catalog", {}).get("excluded_providers") + if isinstance(_excl, list): + excluded_provs = _excl except Exception: pass @@ -1128,6 +1132,7 @@ async def _handle_model_command(self, event: MessageEvent) -> Optional[str]: user_providers=user_provs, custom_providers=custom_provs, max_models=50, + excluded_providers=excluded_provs, ) except Exception: providers = [] @@ -1346,6 +1351,7 @@ async def _on_model_selected( user_providers=user_provs, custom_providers=custom_provs, max_models=5, + excluded_providers=excluded_provs, ) for p in providers: tag = t("gateway.model.current_tag") if p["is_current"] else "" diff --git a/hermes_cli/inventory.py b/hermes_cli/inventory.py index 7f0d3d220e6c..366ae6f968ae 100644 --- a/hermes_cli/inventory.py +++ b/hermes_cli/inventory.py @@ -52,6 +52,7 @@ class ConfigContext: current_base_url: str user_providers: dict custom_providers: list + excluded_providers: list = None def with_overrides( self, @@ -96,12 +97,14 @@ def load_picker_context() -> ConfigContext: current_provider = "" current_base_url = "" raw = cfg.get("providers") + excluded = cfg.get("model_catalog", {}).get("excluded_providers") or [] return ConfigContext( current_provider=current_provider, current_model=current_model, current_base_url=current_base_url, user_providers=raw if isinstance(raw, dict) else {}, custom_providers=get_compatible_custom_providers(cfg), + excluded_providers=excluded if isinstance(excluded, list) else [], ) @@ -161,6 +164,7 @@ def build_models_payload( force_fresh_nous_tier=force_fresh_nous_tier, max_models=max_models, refresh=refresh, + excluded_providers=ctx.excluded_providers or [], ) # --- Deduplicate: remove models from aggregators that overlap with diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index 7f6fe70d90a1..2d379858828e 100644 --- a/hermes_cli/model_switch.py +++ b/hermes_cli/model_switch.py @@ -1230,6 +1230,7 @@ def _warm() -> None: current_model=ctx.current_model, user_providers=ctx.user_providers, custom_providers=ctx.custom_providers, + excluded_providers=ctx.excluded_providers or [], ) except Exception: # Best-effort warmup — never surface errors into the session. @@ -1250,6 +1251,7 @@ def list_authenticated_providers( max_models: int | None = None, current_model: str = "", refresh: bool = False, + excluded_providers: list | None = None, ) -> List[dict]: """Detect which providers have credentials and list their curated models. @@ -1305,6 +1307,11 @@ def list_authenticated_providers( results: List[dict] = [] seen_slugs: set = set() # lowercase-normalized to catch case variants (#9545) seen_mdev_ids: set = set() # prevent duplicate entries for aliases (e.g. kimi-coding + kimi-coding-cn) + # Normalize the excluded-providers list once for fast membership checks. + # Compared against hermes_id / mdev_id (section 1), pid / hermes_slug + # (section 2) and canonical slug (section 2b) so a single entry like + # ``copilot`` hides the provider regardless of which key it surfaces under. + _excluded: set = {str(p).strip().lower() for p in (excluded_providers or []) if p} # Effective base URLs of every built-in row we emit (normalized lower+rstrip). # Section 4 uses this to hide ``custom_providers`` entries that point at the # same endpoint as a built-in (e.g. a user-defined "my-dashscope" on @@ -1444,6 +1451,8 @@ def _has_aws_sdk_creds_for_listing(slug: str) -> bool: # The first one with valid credentials wins (#10526). if mdev_id in seen_mdev_ids: continue + if hermes_id.lower() in _excluded or mdev_id.lower() in _excluded: + continue pdata = data.get(mdev_id) if not isinstance(pdata, dict): continue @@ -1522,6 +1531,8 @@ def _has_aws_sdk_creds_for_listing(slug: str) -> bool: hermes_slug = _mdev_to_hermes.get(pid, pid) if hermes_slug.lower() in seen_slugs: continue + if pid.lower() in _excluded or hermes_slug.lower() in _excluded: + continue # Check if credentials exist has_creds = False @@ -1676,6 +1687,8 @@ def _has_aws_sdk_creds_for_listing(slug: str) -> bool: for _cp in _canon_provs: if _cp.slug.lower() in seen_slugs: continue + if _cp.slug.lower() in _excluded: + continue # Check credentials via PROVIDER_REGISTRY (auth.py) _cp_config = _auth_registry.get(_cp.slug) @@ -1892,11 +1905,17 @@ def _has_aws_sdk_creds_for_listing(slug: str) -> bool: if custom_providers and isinstance(custom_providers, list): from collections import OrderedDict - # Key by endpoint + credential identity + wire protocol instead of - # slug: names frequently differ per model ("Ollama — X") while the - # endpoint stays the same. Keep same-host providers with distinct - # env-backed credentials or API protocols separate so picker selection - # cannot route through the wrong credential/mode pair. + # Key by endpoint + credential identity + wire protocol + display + # prefix instead of slug: names frequently differ per model + # ("Ollama — X") while the endpoint stays the same. Keep same-host + # providers with distinct env-backed credentials or API protocols + # separate so picker selection cannot route through the wrong + # credential/mode pair. The display prefix (text before " — " / + # " - ") is included so intentionally distinct providers sharing an + # endpoint (e.g. a proxy fronting cerebras, groq and perplexity at + # a single base_url) each get their own picker row instead of + # collapsing into one. Per-model suffix entries that share the same + # prefix ("Ollama — A", "Ollama — B") still group together. groups: "OrderedDict[tuple, dict]" = OrderedDict() for entry in custom_providers: if not isinstance(entry, dict): @@ -1934,19 +1953,19 @@ def _has_aws_sdk_creds_for_listing(slug: str) -> bool: if isinstance(discover, str): discover = discover.lower() not in {"false", "no", "0"} - group_key = (api_url, credential_identity, api_mode) + # Display-name prefix (text before " — " / " - "), used both + # as a grouping dimension and to derive the row's display name. + _display_prefix = raw_name + for sep in ("—", " - "): + if sep in _display_prefix: + _display_prefix = _display_prefix.split(sep)[0].strip() + break + + group_key = (api_url, credential_identity, api_mode, _display_prefix.lower()) if group_key not in groups: - # Strip per-model suffix so "Ollama — GLM 5.1" becomes - # "Ollama" for the grouped row. Em dash is the convention - # Hermes's own writer uses; a hyphen variant is accepted - # for hand-edited configs. - display_name = raw_name - for sep in ("—", " - "): - if sep in display_name: - display_name = display_name.split(sep)[0].strip() - break - if not display_name: - display_name = raw_name + # Reuse the prefix computed above as the row display name; + # fall back to the raw name if stripping left it empty. + display_name = _display_prefix or raw_name slug = custom_provider_slug(display_name) groups[group_key] = { "slug": slug, @@ -2102,6 +2121,7 @@ def list_picker_providers( custom_providers: list | None = None, max_models: int | None = None, current_model: str = "", + excluded_providers: list | None = None, ) -> List[dict]: """Interactive-picker variant of :func:`list_authenticated_providers`. @@ -2131,6 +2151,7 @@ def list_picker_providers( custom_providers=custom_providers, max_models=max_models, current_model=current_model, + excluded_providers=excluded_providers, ) filtered: List[dict] = [] diff --git a/tests/hermes_cli/test_model_switch_custom_providers.py b/tests/hermes_cli/test_model_switch_custom_providers.py index 388c82bd3e61..a2c6a948c84a 100644 --- a/tests/hermes_cli/test_model_switch_custom_providers.py +++ b/tests/hermes_cli/test_model_switch_custom_providers.py @@ -774,3 +774,130 @@ def fake_fetch_api_models(api_key, base_url): assert gateway_prov is not None assert calls == [], "string 'false' must disable live discovery" assert gateway_prov["models"] == ["only-model"] + + +def test_excluded_providers_hides_builtin_row(monkeypatch): + """``excluded_providers`` must hide a built-in provider row that would + otherwise surface when its credentials are present.""" + monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {}) + monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {}) + monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-test") + + baseline = list_authenticated_providers( + current_provider="openrouter", + current_base_url="https://openrouter.ai/api/v1", + user_providers={}, + custom_providers=[], + max_models=50, + ) + assert any(p["slug"] == "openrouter" for p in baseline), ( + "sanity: openrouter row must appear when OPENROUTER_API_KEY is set" + ) + + filtered = list_authenticated_providers( + current_provider="openrouter", + current_base_url="https://openrouter.ai/api/v1", + user_providers={}, + custom_providers=[], + max_models=50, + excluded_providers=["openrouter"], + ) + assert not any(p["slug"] == "openrouter" for p in filtered), ( + "excluded_providers=['openrouter'] must hide the openrouter row" + ) + + +def test_excluded_providers_empty_is_noop(monkeypatch): + """An empty ``excluded_providers`` list must not change picker output.""" + monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {}) + monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {}) + monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-test") + + a = list_authenticated_providers( + current_provider="openrouter", + user_providers={}, + custom_providers=[], + max_models=50, + ) + b = list_authenticated_providers( + current_provider="openrouter", + user_providers={}, + custom_providers=[], + max_models=50, + excluded_providers=[], + ) + assert [p["slug"] for p in a] == [p["slug"] for p in b] + + +def test_shared_url_different_display_names_are_separate_rows(monkeypatch): + """Multiple custom_providers entries sharing base_url + api_key + api_mode + but with *different* display-name prefixes (e.g. a proxy fronting + cerebras, groq and perplexity at one URL) must each get their own picker + row, not collapse into one.""" + monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {}) + monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {}) + # Stub live discovery so the test is deterministic regardless of network. + monkeypatch.setattr( + "hermes_cli.models.fetch_api_models", + lambda api_key, base_url: [], + ) + + providers = list_authenticated_providers( + current_provider="openrouter", + current_base_url="https://openrouter.ai/api/v1", + user_providers={}, + custom_providers=[ + {"name": "Cerebras", "base_url": "https://proxy.example.com/v1", + "api_key": "proxy-key", "model": "llama-4-scout"}, + {"name": "Groq", "base_url": "https://proxy.example.com/v1", + "api_key": "proxy-key", "model": "llama-4-scout"}, + {"name": "Perplexity", "base_url": "https://proxy.example.com/v1", + "api_key": "proxy-key", "model": "sonar-pro"}, + ], + max_models=50, + ) + + custom = [p for p in providers if p.get("is_user_defined")] + names = sorted(p["name"] for p in custom) + assert names == ["Cerebras", "Groq", "Perplexity"], ( + f"expected three separate rows, got {names}" + ) + # Each row carries only its own model (no cross-contamination). + by_name = {p["name"]: p["models"] for p in custom} + assert by_name["Cerebras"] == ["llama-4-scout"] + assert by_name["Groq"] == ["llama-4-scout"] + assert by_name["Perplexity"] == ["sonar-pro"] + + +def test_shared_url_per_model_suffix_still_collapses(monkeypatch): + """Per-model suffix entries sharing the same display-name prefix (e.g. + "Ollama — A", "Ollama — B") must still collapse into one row even with + the display-prefix grouping dimension.""" + monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {}) + monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {}) + # Stub live discovery so a locally-running Ollama cannot override the + # static configured models and make the assertion flaky. + monkeypatch.setattr( + "hermes_cli.models.fetch_api_models", + lambda api_key, base_url: [], + ) + + providers = list_authenticated_providers( + current_provider="openrouter", + current_base_url="https://openrouter.ai/api/v1", + user_providers={}, + custom_providers=[ + {"name": "Ollama — GLM 5.1", "base_url": "http://localhost:11434/v1", + "api_key": "ollama", "model": "glm-5.1"}, + {"name": "Ollama — Qwen3-coder", "base_url": "http://localhost:11434/v1", + "api_key": "ollama", "model": "qwen3-coder"}, + ], + max_models=50, + ) + + custom = [p for p in providers if p.get("is_user_defined")] + assert len(custom) == 1, ( + f"expected one collapsed row, got {[p['name'] for p in custom]}" + ) + assert custom[0]["name"] == "Ollama" + assert set(custom[0]["models"]) == {"glm-5.1", "qwen3-coder"} diff --git a/website/docs/reference/model-catalog.md b/website/docs/reference/model-catalog.md index 4e44543354fd..a12fd4ddd5a1 100644 --- a/website/docs/reference/model-catalog.md +++ b/website/docs/reference/model-catalog.md @@ -88,6 +88,20 @@ model_catalog: The overriding manifest only needs to populate the provider block(s) it cares about. Other providers continue to resolve against the master URL. +### Hiding providers from the picker + +`excluded_providers` lets you hide specific providers from the `/model` picker even when valid credentials exist. Useful when credentials are present for legacy or testing providers that shouldn't appear in normal use (e.g. an old Copilot or OpenRouter token still cached in `auth.json` or discovered via the `gh` CLI). + +```yaml +model_catalog: + excluded_providers: + - copilot + - openrouter + - openai +``` + +The exclusion is matched case-insensitively against every key a provider can surface under — the Hermes id and models.dev id (built-in mapped providers), the overlay pid and resolved Hermes slug (overlay providers), and the canonical slug (canonical providers) — so a single entry like `copilot` hides the provider regardless of which section emits it. An empty list (or omitting the key) has no effect. + ## Updating the manifest Maintainers: From ab1fad51967555624b0ea6dbc9def1a69f231d5f Mon Sep 17 00:00:00 2001 From: Craig French <18516125+craigdfrench@users.noreply.github.com> Date: Sun, 21 Jun 2026 21:53:12 -0400 Subject: [PATCH 2/2] feat(model-switch): honor excluded_providers in the `hermes model` CLI picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the excluded_providers rework: wire the config through the interactive `hermes model` CLI picker (hermes_cli/main.py:: select_provider_and_model), which builds its provider menu from CANONICAL_PROVIDERS via group_providers — a separate code path from list_authenticated_providers / build_models_payload that the prior commit did not cover. A canonical provider is hidden from the CLI menu if its slug OR any of its aliases (_PROVIDER_ALIASES) appears in model_catalog.excluded_providers (case-insensitive), matching list_authenticated_providers' matching against hermes_id / alias / canonical slug. The filtered slug list is passed to group_providers, so excluded members also drop out of multi-member group rows. Custom providers are intentionally not filtered (parity with list_authenticated_providers, which does not exclude section-4 custom rows). Tests (tests/hermes_cli/test_model_picker_excluded_providers.py): - test_cli_picker_hides_excluded_provider: excluded_providers=["openrouter"] removes the OpenRouter row from the provider menu. - test_cli_picker_hides_excluded_provider_by_alias: excluding by an alias (not the canonical slug) also hides the provider. - test_cli_picker_empty_excluded_is_noop: [] does not change the menu. Docs: model-catalog.md updated to note every /model picker surface (gateway, TUI, and `hermes model` CLI) now honors the key. --- hermes_cli/main.py | 26 +++- .../test_model_picker_excluded_providers.py | 128 ++++++++++++++++++ website/docs/reference/model-catalog.md | 2 +- 3 files changed, 154 insertions(+), 2 deletions(-) create mode 100644 tests/hermes_cli/test_model_picker_excluded_providers.py diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 99c6c8d26952..d478d3e1cf7f 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -2946,6 +2946,7 @@ def _active_custom_key_from_base_url() -> str: from hermes_cli.models import ( CANONICAL_PROVIDERS, _PROVIDER_LABELS, + _PROVIDER_ALIASES, group_providers, provider_group_for_slug, ) @@ -2969,7 +2970,30 @@ def _active_custom_key_from_base_url() -> str: # resolves back to a concrete slug, so the dispatch chain below is # unchanged. Custom providers and the trailing actions stay flat. canonical_descs = {p.slug: p.tui_desc for p in CANONICAL_PROVIDERS} - grouped_rows = group_providers([p.slug for p in CANONICAL_PROVIDERS]) + # Honor ``model_catalog.excluded_providers`` so the CLI ``hermes model`` + # picker hides the same providers the gateway/TUI pickers do. A canonical + # provider is hidden if its slug OR any of its aliases appears in the + # exclusion list (case-insensitive), matching list_authenticated_providers' + # matching against hermes_id / alias / canonical slug. + _cli_excluded = { + str(p).strip().lower() + for p in (config.get("model_catalog", {}) or {}).get("excluded_providers") or [] + if p + } + if _cli_excluded: + _alias_to_canon = _PROVIDER_ALIASES + _names_for: dict[str, set[str]] = {} + for _p in CANONICAL_PROVIDERS: + _names_for[_p.slug] = {_p.slug.lower()} + for _alias, _canon in _alias_to_canon.items(): + _names_for.setdefault(_canon, {_canon.lower()}).add(_alias.lower()) + _visible_slugs = [ + p.slug for p in CANONICAL_PROVIDERS + if not _names_for.get(p.slug, {p.slug.lower()}) & _cli_excluded + ] + else: + _visible_slugs = [p.slug for p in CANONICAL_PROVIDERS] + grouped_rows = group_providers(_visible_slugs) # The group/slug that should be pre-selected: the active provider's group # if it's grouped, otherwise the active slug itself. diff --git a/tests/hermes_cli/test_model_picker_excluded_providers.py b/tests/hermes_cli/test_model_picker_excluded_providers.py new file mode 100644 index 000000000000..f7c781857cd6 --- /dev/null +++ b/tests/hermes_cli/test_model_picker_excluded_providers.py @@ -0,0 +1,128 @@ +"""Tests that ``model_catalog.excluded_providers`` hides providers from the +interactive ``hermes model`` CLI picker. + +The CLI picker (``hermes_cli.main.select_provider_and_model``) builds its +provider menu from ``CANONICAL_PROVIDERS`` via ``group_providers`` — a +separate code path from ``list_authenticated_providers``. These tests +verify the exclusion config is honored there too, matching the +gateway/TUI picker behavior. +""" + +from unittest.mock import patch + +import pytest + + +@pytest.fixture +def config_home(tmp_path, monkeypatch): + """Isolated HERMES_HOME with a minimal config.""" + home = tmp_path / "hermes" + home.mkdir() + config_yaml = home / "config.yaml" + config_yaml.write_text("model: old-model\ncustom_providers: []\n") + env_file = home / ".env" + env_file.write_text("") + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.delenv("HERMES_MODEL", raising=False) + monkeypatch.delenv("LLM_MODEL", raising=False) + monkeypatch.delenv("HERMES_INFERENCE_PROVIDER", raising=False) + monkeypatch.delenv("OPENAI_BASE_URL", raising=False) + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + return home + + +def _write_config(home, **top_level): + import yaml + cfg = {"model": "old-model", "custom_providers": []} + cfg.update(top_level) + (home / "config.yaml").write_text(yaml.safe_dump(cfg)) + + +def _capture_provider_labels(config_home): + """Drive ``select_provider_and_model`` and return the provider-menu labels + shown to the user (the first ``_prompt_provider_choice`` call). Cancels + immediately after capturing.""" + from hermes_cli.main import select_provider_and_model + + captured: dict = {} + + def _capture_and_cancel(labels, default=0, title=None): + # Only capture the top-level provider menu (the first call). + if "labels" not in captured: + captured["labels"] = list(labels) + return None # cancel + + with patch("hermes_cli.main._prompt_provider_choice", + side_effect=_capture_and_cancel), \ + patch("builtins.print"): + select_provider_and_model() + + return captured.get("labels", []) + + +def test_cli_picker_hides_excluded_provider(config_home): + """``excluded_providers: [openrouter]`` must remove the OpenRouter row + from the ``hermes model`` provider menu.""" + _write_config(config_home, **{"model_catalog": {"excluded_providers": ["openrouter"]}}) + + labels = _capture_provider_labels(config_home) + assert labels, "provider menu was empty" + assert not any("OpenRouter" in lbl for lbl in labels), ( + f"OpenRouter should be hidden by excluded_providers, got: {labels}" + ) + + +def test_cli_picker_hides_excluded_provider_by_alias(config_home): + """Exclusion by an alias (not the canonical slug) must also hide the + provider, matching ``list_authenticated_providers``' matching against + hermes_id / alias names.""" + # 'openai' is an alias-style hermes id; ensure excluding it hides the + # canonical openai provider row if present. Use the canonical slug's + # alias from _PROVIDER_ALIASES to stay robust to renames. + from hermes_cli.models import _PROVIDER_ALIASES, CANONICAL_PROVIDERS + + # Find a canonical provider that has at least one alias and is a leaf + # row (not folded into a multi-member group) so its label appears + # directly. Pick the first such provider. + target_slug = None + target_alias = None + for alias, canon in _PROVIDER_ALIASES.items(): + if canon and any(p.slug == canon for p in CANONICAL_PROVIDERS): + target_slug = canon + target_alias = alias + break + if target_slug is None: + pytest.skip("no aliased canonical provider available to test") + + from hermes_cli.models import _PROVIDER_LABELS + target_label_fragment = _PROVIDER_LABELS.get(target_slug, target_slug) + + # Baseline: the provider appears without exclusion. + _write_config(config_home) + baseline = _capture_provider_labels(config_home) + assert any(target_label_fragment in lbl for lbl in baseline), ( + f"sanity: {target_slug} ({target_label_fragment!r}) should appear by " + f"default; labels={baseline}" + ) + + # Excluding by alias hides it. + _write_config( + config_home, + **{"model_catalog": {"excluded_providers": [target_alias]}}, + ) + excluded_labels = _capture_provider_labels(config_home) + assert not any(target_label_fragment in lbl for lbl in excluded_labels), ( + f"excluding alias {target_alias!r} should hide {target_slug}; " + f"labels={excluded_labels}" + ) + + +def test_cli_picker_empty_excluded_is_noop(config_home): + """An empty ``excluded_providers`` list must not change the menu.""" + _write_config(config_home, **{"model_catalog": {"excluded_providers": []}}) + excluded_labels = _capture_provider_labels(config_home) + + _write_config(config_home) + baseline_labels = _capture_provider_labels(config_home) + + assert excluded_labels == baseline_labels diff --git a/website/docs/reference/model-catalog.md b/website/docs/reference/model-catalog.md index a12fd4ddd5a1..4155797b1447 100644 --- a/website/docs/reference/model-catalog.md +++ b/website/docs/reference/model-catalog.md @@ -100,7 +100,7 @@ model_catalog: - openai ``` -The exclusion is matched case-insensitively against every key a provider can surface under — the Hermes id and models.dev id (built-in mapped providers), the overlay pid and resolved Hermes slug (overlay providers), and the canonical slug (canonical providers) — so a single entry like `copilot` hides the provider regardless of which section emits it. An empty list (or omitting the key) has no effect. +The exclusion is matched case-insensitively against every key a provider can surface under — the Hermes id and models.dev id (built-in mapped providers), the overlay pid and resolved Hermes slug (overlay providers), and the canonical slug (canonical providers) — so a single entry like `copilot` hides the provider regardless of which section emits it. It is honored by every `/model` picker surface: the gateway interactive/text pickers, the TUI picker, and the interactive `hermes model` CLI picker. An empty list (or omitting the key) has no effect. ## Updating the manifest