diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index ca519413a07b6..5227920f64a8c 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 7f0d3d220e6cd..366ae6f968ae1 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/main.py b/hermes_cli/main.py index 99c6c8d269525..d478d3e1cf7f7 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/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index 7f6fe70d90a1b..2d379858828e5 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_picker_excluded_providers.py b/tests/hermes_cli/test_model_picker_excluded_providers.py new file mode 100644 index 0000000000000..f7c781857cd69 --- /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/tests/hermes_cli/test_model_switch_custom_providers.py b/tests/hermes_cli/test_model_switch_custom_providers.py index 388c82bd3e614..a2c6a948c84a5 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 4e44543354fd8..4155797b14471 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. 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 Maintainers: