feat(model-switch): excluded_providers config to hide providers from /model picker

This commit is contained in:
Craig French 2026-07-19 19:34:00 -04:00 committed by Teknium
parent 1b56d0d1a2
commit b239ee2123
7 changed files with 245 additions and 1 deletions

View file

@ -1496,6 +1496,7 @@ class GatewaySlashCommandsMixin:
current_api_key = ""
user_provs = None
custom_provs = None
excluded_provs = []
config_path = (_command_profile_home or _hermes_home) / "config.yaml"
try:
cfg = _load_gateway_config()
@ -1511,6 +1512,9 @@ class GatewaySlashCommandsMixin:
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
@ -1554,6 +1558,7 @@ class GatewaySlashCommandsMixin:
custom_providers=custom_provs,
max_models=50,
include_moa=True,
excluded_providers=excluded_provs,
)
except Exception:
providers = []
@ -1833,6 +1838,7 @@ class GatewaySlashCommandsMixin:
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 ""

View file

@ -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 [],
)
@ -179,6 +182,7 @@ def build_models_payload(
refresh=refresh,
probe_custom_providers=probe_custom_providers,
probe_current_custom_provider=probe_current_custom_provider,
excluded_providers=ctx.excluded_providers or [],
)
moa_row = _moa_provider_row(ctx.current_provider)

View file

@ -3167,6 +3167,7 @@ def select_provider_and_model(args=None):
from hermes_cli.models import (
CANONICAL_PROVIDERS,
_PROVIDER_LABELS,
_PROVIDER_ALIASES,
group_providers,
provider_group_for_slug,
)
@ -3190,7 +3191,30 @@ def select_provider_and_model(args=None):
# 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.

View file

@ -1628,6 +1628,7 @@ def prewarm_picker_cache_async() -> Optional["_threading.Thread"]:
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.
@ -1651,6 +1652,7 @@ def list_authenticated_providers(
probe_custom_providers: bool = True,
probe_current_custom_provider: bool = False,
for_picker: bool = False,
excluded_providers: list | None = None,
) -> List[dict]:
"""Detect which providers have credentials and list their curated models.
@ -1722,6 +1724,11 @@ def list_authenticated_providers(
def _can_probe_custom_provider(*, row_is_current: bool) -> bool:
return bool(probe_custom_providers or (probe_current_custom_provider and row_is_current))
# 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
@ -1861,6 +1868,8 @@ def list_authenticated_providers(
# 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
@ -1962,6 +1971,8 @@ def list_authenticated_providers(
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
@ -2131,6 +2142,8 @@ def list_authenticated_providers(
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)
@ -2731,6 +2744,7 @@ def list_picker_providers(
max_models: int | None = None,
current_model: str = "",
include_moa: bool = False,
excluded_providers: list | None = None,
) -> List[dict]:
"""Interactive-picker variant of :func:`list_authenticated_providers`.
@ -2761,6 +2775,7 @@ def list_picker_providers(
max_models=max_models,
current_model=current_model,
for_picker=True,
excluded_providers=excluded_providers,
)
if include_moa:
providers = _prepend_moa_picker_provider(providers, current_provider=current_provider)

View file

@ -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

View file

@ -1638,3 +1638,56 @@ def test_shared_url_per_model_suffix_still_collapses(monkeypatch):
)
assert custom[0]["name"] == "Ollama"
assert set(custom[0]["models"]) == {"glm-5.1", "qwen3-coder"}
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]

View file

@ -91,6 +91,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: