Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions gateway/slash_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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

Expand Down Expand Up @@ -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 = []
Expand Down Expand Up @@ -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 ""
Expand Down
4 changes: 4 additions & 0 deletions hermes_cli/inventory.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ class ConfigContext:
current_base_url: str
user_providers: dict
custom_providers: list
excluded_providers: list = None

def with_overrides(
self,
Expand Down Expand Up @@ -96,12 +97,14 @@ def load_picker_context() -> ConfigContext:
current_provider = ""

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This only feeds inventory consumers. The gateway /model path still calls list_picker_providers / list_authenticated_providers directly, so the new config would not hide providers on that picker surface unless those call paths also receive the exclusion list.

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 [],
)


Expand Down Expand Up @@ -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
Expand Down
26 changes: 25 additions & 1 deletion hermes_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand All @@ -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.
Expand Down
55 changes: 38 additions & 17 deletions hermes_cli/model_switch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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`.

Expand Down Expand Up @@ -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] = []
Expand Down
128 changes: 128 additions & 0 deletions tests/hermes_cli/test_model_picker_excluded_providers.py
Original file line number Diff line number Diff line change
@@ -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
Loading