mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-21 16:18:55 +00:00
feat(model-switch): excluded_providers config to hide providers from /model picker
This commit is contained in:
parent
1b56d0d1a2
commit
b239ee2123
7 changed files with 245 additions and 1 deletions
|
|
@ -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 ""
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
128
tests/hermes_cli/test_model_picker_excluded_providers.py
Normal file
128
tests/hermes_cli/test_model_picker_excluded_providers.py
Normal 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
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue