Files
hermes-webui/tests/test_issue1538_nous_live_catalog.py
T
nesquena-hermes bff8cb2b58 fix: Nous Portal full live catalog + dropdown cache invalidation on provider remove
Closes #1538, #1539. Two related dropdown-staleness bugs reported by Deor
(Discord, May 03 2026).

#1538 — Nous Portal picker showed only 4 hardcoded models
=========================================================
The Settings → Default Model picker, the composer model dropdown, the
/model slash command, and the Settings → Providers card all showed only
four Nous models (Claude Opus 4.6, Claude Sonnet 4.6, GPT-5.4 Mini, Gemini
3.1 Pro Preview) because `_PROVIDER_MODELS["nous"]` had four hardcoded
entries and `_build_available_models_uncached()` fell through to the
generic `pid in _PROVIDER_MODELS` branch.

The actual Nous Portal catalog has 30 models live — Claude Opus 4.7, GPT-5.5,
Kimi K2.6, MiniMax M2.7, Gemini 3.1 Pro/Flash, several Xiaomi/Tencent/StepFun
entries, and more.

Fix:
- New `_format_nous_label()` helper in `api/config.py` — reuses the
  `_format_ollama_label()` token rules, drops the vendor namespace, and
  appends ` (via Nous)` so labels disambiguate from same-named direct-
  provider entries (e.g. "Claude Opus 4.7" via direct Anthropic).
- New `elif pid == "nous":` branch in `_build_available_models_uncached()`
  mirroring the Ollama Cloud pattern: live-fetch through
  `hermes_cli.models.provider_model_ids("nous")`, prefix every id with
  `@nous:` (matches the existing routing convention from PR-era #854 and
  pinned in tests/test_nous_portal_routing.py), fall back to the curated
  4-entry static list when hermes_cli is unavailable.
- Same fix applied to `api/providers.py:get_providers()` — that's the
  separate code path that builds Settings → Providers card models, and
  it had the identical bug shape.

#1539 — Removed provider lingered in dropdowns until restart
============================================================
After Settings → Providers → Remove, the provider's models still appeared
in every model dropdown until the page was reloaded. The server-side
TTL cache was correctly flushed (`set_provider_key()` calls
`invalidate_models_cache()` on both add and remove) but JS-side caches
were never dropped:

- `_slashModelCache` / `_slashModelCachePromise` (commands.js) — feeds
  the `/model` slash-command suggestions.
- `_dynamicModelLabels` / `window._configuredModelBadges` (ui.js) —
  populated by `populateModelDropdown()` on app boot and profile switch.

Pre-fix, `_removeProviderKey()` only called `loadProvidersPanel()`
which refreshed the providers card list but never asked any consumer
to re-fetch /api/models.

Fix:
- `static/commands.js`: new `_invalidateSlashModelCache()` helper that
  nulls both cache slots, exposed on `window` (typeof-guarded so the
  module remains importable in headless vm contexts — needed by the
  existing tests/test_cli_only_slash_commands.py harness).
- `static/panels.js`: new `_refreshModelDropdownsAfterProviderChange()`
  helper that calls the invalidator + `populateModelDropdown()`, wrapped
  in try/catch so the providers panel update never breaks if a
  downstream module hasn't loaded yet. Both `_saveProviderKey` and
  `_removeProviderKey` invoke it (defense-in-depth: same staleness shape
  applies to the add path too).

Tests
-----
- `tests/test_issue1538_nous_live_catalog.py` (12 tests): live-fetch
  surfaces ≥20 entries, every id starts with `@nous:`, every label ends
  with ` (via Nous)`, recent flagships (Opus 4.7, GPT-5.5, Kimi K2.6,
  Gemini 3.1 Pro, MiniMax M2.7) reach the dropdown, static fallback
  works when hermes_cli raises, label formatter unit tests (vendor
  namespace stripping, variant rendering, MiniMax mixed-case), the
  curated static list and its routing invariants are preserved.
- `tests/test_issue1539_provider_removal_dropdown_invalidation.py`
  (11 tests): invalidator helper exists and clears both cache slots,
  exposed on window with typeof guard, both save and remove paths
  invoke the dropdown flush, helper calls both invalidator and
  populateModelDropdown, helper is resilient to missing modules,
  helper does not block panel refresh, server-side
  `set_provider_key → invalidate_models_cache` invariant pinned.

Verified live on port 8789: `/api/models` Nous group returns 30
models (was 4); browser `document.getElementById('modelSelect')`
exposes 30 options under the "Nous Portal" group; the dropdown-flush
helper is callable from the browser and round-trip rebuild keeps the
dropdown at 30 options.

Test counts:
- Full pytest: 4013 passed, 2 skipped, 3 xpassed, 0 failures
  (was 3990 → 4013, +23 from this PR).
- QA harness pytest: 20 passed.
- Browser API sanity: 11/11 passed.
- Agent Browser CDP: 21/23 passed (the 2 SSE liveness failures
  reproduce on master and are unrelated to this PR).
2026-05-03 18:12:01 +00:00

314 lines
13 KiB
Python

"""Regression tests for #1538 — Nous Portal model picker should live-fetch
the full catalog (~30 models) instead of returning the four-entry static list.
Background
----------
Settings → Default Model showed only four Nous models (Claude Opus 4.6, Claude
Sonnet 4.6, GPT-5.4 Mini, Gemini 3.1 Pro Preview) because
``_build_available_models_uncached()`` fell through to the generic
``pid in _PROVIDER_MODELS`` branch and returned ``copy.deepcopy(_PROVIDER_MODELS["nous"])``.
The actual Nous Portal catalog has ~30 models live — including the latest
Anthropic 4.7 family, GPT-5.5, Gemini 3.1 Pro/Flash, Kimi K2.6, MiniMax M2.7,
several Xiaomi/Tencent/StepFun entries.
Fix
---
A dedicated ``elif pid == "nous":`` branch in ``_build_available_models_uncached()``
mirroring the Ollama Cloud pattern: live-fetch via
``hermes_cli.models.provider_model_ids("nous")``, prefix every id with ``@nous:``
to match the existing routing convention, fall back to the curated static
list when ``hermes_cli`` is unavailable.
"""
from __future__ import annotations
import sys
import types
import api.config as config
import api.profiles as profiles
# Sample Nous catalog used in the live-fetch test. Mirrors the shape returned
# by hermes_cli.models.provider_model_ids("nous") (see #1538 issue body).
SAMPLE_NOUS_LIVE_IDS = [
"moonshotai/kimi-k2.6",
"xiaomi/mimo-v2.5-pro",
"anthropic/claude-opus-4.7",
"anthropic/claude-opus-4.6",
"anthropic/claude-sonnet-4.6",
"anthropic/claude-haiku-4.5",
"openai/gpt-5.5",
"openai/gpt-5.4-mini",
"openai/gpt-5.3-codex",
"google/gemini-3-pro-preview",
"google/gemini-3.1-pro-preview",
"google/gemini-3.1-flash-lite-preview",
"qwen/qwen3.5-plus-02-15",
"minimax/minimax-m2.7",
"z-ai/glm-5.1",
"x-ai/grok-4.20-beta",
"tencent/hy3-preview",
"stepfun/step-3.5-flash",
"nvidia/nemotron-3-super-120b-a12b",
"arcee-ai/trinity-large-thinking",
]
def _install_fake_hermes_cli(monkeypatch, *, nous_ids=None, raise_on_lookup=False):
"""Install fake ``hermes_cli`` modules so detection sees Nous as authenticated
and ``provider_model_ids("nous")`` returns the desired catalog.
Mirrors :func:`tests.test_issue1420_lmstudio_provider_env_var._install_fake_hermes_cli`
but specialised for Nous detection (Nous is OAuth so the env-var path
is not used — we drive detection via ``hermes_cli.auth.list_auth_providers``).
"""
fake_pkg = types.ModuleType("hermes_cli")
fake_pkg.__path__ = []
fake_models = types.ModuleType("hermes_cli.models")
fake_models.list_available_providers = lambda: []
if raise_on_lookup:
def _raise(_pid):
raise RuntimeError("simulated hermes_cli failure")
fake_models.provider_model_ids = _raise
else:
ids = list(nous_ids) if nous_ids is not None else []
fake_models.provider_model_ids = lambda pid: ids if pid == "nous" else []
fake_auth = types.ModuleType("hermes_cli.auth")
def _list_auth_providers():
return [{"id": "nous", "authenticated": True}]
def _get_auth_status(pid):
return {"logged_in": True, "key_source": ""} if pid == "nous" else {}
fake_auth.list_auth_providers = _list_auth_providers
fake_auth.get_auth_status = _get_auth_status
monkeypatch.setitem(sys.modules, "hermes_cli", fake_pkg)
monkeypatch.setitem(sys.modules, "hermes_cli.models", fake_models)
monkeypatch.setitem(sys.modules, "hermes_cli.auth", fake_auth)
monkeypatch.delitem(sys.modules, "agent.credential_pool", raising=False)
monkeypatch.delitem(sys.modules, "agent", raising=False)
config.invalidate_models_cache()
def _swap_in_test_config(extra_cfg):
"""Snapshot config.cfg, replace with a minimal test config; return restore-fn."""
old_cfg = dict(config.cfg)
old_mtime = config._cfg_mtime
config.cfg.clear()
config.cfg["model"] = {}
config.cfg.update(extra_cfg)
try:
config._cfg_mtime = config.Path(config._get_config_path()).stat().st_mtime
except Exception:
config._cfg_mtime = 0.0
def _restore():
config.cfg.clear()
config.cfg.update(old_cfg)
config._cfg_mtime = old_mtime
return _restore
def _scrub_provider_env(monkeypatch):
"""Drop every provider env var so detection only sees what we install
via the fake hermes_cli stubs (not unrelated keys leaked from the runner)."""
for var in (
"ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GOOGLE_API_KEY", "GEMINI_API_KEY",
"DEEPSEEK_API_KEY", "XAI_API_KEY", "GROQ_API_KEY",
"MISTRAL_API_KEY", "OPENROUTER_API_KEY",
"OLLAMA_CLOUD_API_KEY", "OLLAMA_API_KEY",
"GLM_API_KEY", "KIMI_API_KEY", "MOONSHOT_API_KEY",
"MINIMAX_API_KEY", "MINIMAX_CN_API_KEY",
"OPENCODE_ZEN_API_KEY", "OPENCODE_GO_API_KEY",
"NOUS_API_KEY", "NVIDIA_API_KEY", "LM_API_KEY", "LMSTUDIO_API_KEY",
):
monkeypatch.delenv(var, raising=False)
class TestNousLiveCatalog:
"""When the Nous live catalog is available, the dropdown must surface it
in full (>=20 entries) — not the four-entry static fallback (#1538)."""
def test_nous_models_live_fetch_when_hermes_cli_available(self, monkeypatch, tmp_path):
_scrub_provider_env(monkeypatch)
_install_fake_hermes_cli(monkeypatch, nous_ids=SAMPLE_NOUS_LIVE_IDS)
monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path)
restore = _swap_in_test_config({"model": {"provider": "nous"}})
try:
data = config.get_available_models()
nous_groups = [g for g in data.get("groups", []) if g.get("provider_id") == "nous"]
assert len(nous_groups) == 1, (
f"Expected exactly one Nous group, got {len(nous_groups)}: "
f"{[g.get('provider_id') for g in data.get('groups', [])]}"
)
models = nous_groups[0]["models"]
assert len(models) >= 20, (
f"Live-fetched Nous catalog should expose >=20 entries, got "
f"{len(models)}. The dispatch branch fell through to the four-entry "
f"static list — pre-#1538 behaviour."
)
finally:
restore()
def test_nous_model_ids_carry_at_nous_prefix(self, monkeypatch, tmp_path):
_scrub_provider_env(monkeypatch)
_install_fake_hermes_cli(monkeypatch, nous_ids=SAMPLE_NOUS_LIVE_IDS)
monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path)
restore = _swap_in_test_config({"model": {"provider": "nous"}})
try:
data = config.get_available_models()
nous_group = next(g for g in data["groups"] if g["provider_id"] == "nous")
for m in nous_group["models"]:
assert m["id"].startswith("@nous:"), (
f"Every Nous model id must start with '@nous:' so "
f"resolve_model_provider routes through the explicit-provider-hint "
f"branch (matches the static-list invariant from "
f"tests/test_nous_portal_routing.py). Got: {m['id']!r}"
)
finally:
restore()
def test_nous_labels_carry_via_nous_suffix(self, monkeypatch, tmp_path):
_scrub_provider_env(monkeypatch)
_install_fake_hermes_cli(monkeypatch, nous_ids=SAMPLE_NOUS_LIVE_IDS)
monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path)
restore = _swap_in_test_config({"model": {"provider": "nous"}})
try:
data = config.get_available_models()
nous_group = next(g for g in data["groups"] if g["provider_id"] == "nous")
for m in nous_group["models"]:
assert m["label"].endswith(" (via Nous)"), (
f"Every Nous live-fetched label must end with ' (via Nous)' so "
f"the user can distinguish them from same-named direct-provider "
f"entries (e.g. 'Claude Opus 4.7' via direct Anthropic). "
f"Got: {m['label']!r}"
)
finally:
restore()
def test_nous_live_catalog_includes_recent_models(self, monkeypatch, tmp_path):
"""Sanity: the recent-flagship models from the user's bug report
(Claude Opus 4.7, GPT-5.5, Kimi K2.6) must reach the dropdown."""
_scrub_provider_env(monkeypatch)
_install_fake_hermes_cli(monkeypatch, nous_ids=SAMPLE_NOUS_LIVE_IDS)
monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path)
restore = _swap_in_test_config({"model": {"provider": "nous"}})
try:
data = config.get_available_models()
nous_group = next(g for g in data["groups"] if g["provider_id"] == "nous")
ids = {m["id"] for m in nous_group["models"]}
for required in (
"@nous:anthropic/claude-opus-4.7",
"@nous:openai/gpt-5.5",
"@nous:moonshotai/kimi-k2.6",
"@nous:google/gemini-3.1-pro-preview",
"@nous:minimax/minimax-m2.7",
):
assert required in ids, (
f"{required} missing from live-fetched Nous catalog. Either "
f"the hermes_cli dispatch is broken or the @nous: prefix is "
f"missing."
)
finally:
restore()
class TestNousStaticFallback:
"""When ``hermes_cli`` is not importable or its lookup raises, we fall back
to the curated four-entry static list — never empty."""
def test_static_fallback_when_hermes_cli_raises(self, monkeypatch, tmp_path):
_scrub_provider_env(monkeypatch)
_install_fake_hermes_cli(monkeypatch, raise_on_lookup=True)
monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path)
restore = _swap_in_test_config({"model": {"provider": "nous"}})
try:
data = config.get_available_models()
nous_groups = [g for g in data.get("groups", []) if g.get("provider_id") == "nous"]
assert nous_groups, (
"Nous group must still appear when hermes_cli fails — the "
"branch should fall back to the curated static list."
)
models = nous_groups[0]["models"]
assert len(models) == 4, (
f"Static fallback should expose exactly the four curated entries "
f"in _PROVIDER_MODELS['nous']. Got {len(models)}: "
f"{[m['id'] for m in models]}"
)
for m in models:
assert m["id"].startswith("@nous:"), m["id"]
finally:
restore()
class TestFormatNousLabel:
"""Unit tests for the label formatter helper."""
def test_strips_vendor_namespace(self):
from api.config import _format_nous_label
assert _format_nous_label("anthropic/claude-opus-4.7") == "Claude Opus 4.7 (via Nous)"
assert _format_nous_label("openai/gpt-5.4-mini") == "GPT 5.4 Mini (via Nous)"
def test_handles_missing_vendor(self):
from api.config import _format_nous_label
# Defensive: id without slash should still render a sane label.
assert _format_nous_label("kimi-k2.6") == "Kimi K2.6 (via Nous)"
def test_handles_variant_after_colon(self):
from api.config import _format_nous_label
# Variant rendered in parentheses, mirroring _format_ollama_label.
out = _format_nous_label("minimax/minimax-m2.5:free")
assert out.endswith(" (via Nous)")
assert "Free" in out
assert "MiniMax M2.5" in out
def test_minimax_renders_mixed_case(self):
from api.config import _format_nous_label
# Live wire returns lowercase 'minimax/minimax-...' but the curated
# convention is mixed-case 'MiniMax'.
assert _format_nous_label("minimax/minimax-m2.7").startswith("MiniMax M2.7")
def test_label_always_ends_with_via_nous_suffix(self):
from api.config import _format_nous_label
for sample in [
"anthropic/claude-opus-4.7",
"openai/gpt-5.5",
"google/gemini-3.1-pro-preview",
"moonshotai/kimi-k2.6",
"z-ai/glm-5.1",
"stepfun/step-3.5-flash",
]:
assert _format_nous_label(sample).endswith(" (via Nous)"), sample
class TestStaticListPreservedAsFallback:
"""The curated ``_PROVIDER_MODELS['nous']`` entry stays as the static
fallback; existing routing invariants from
:mod:`tests.test_nous_portal_routing` must remain valid."""
def test_static_list_present(self):
from api.config import _PROVIDER_MODELS
assert _PROVIDER_MODELS.get("nous"), (
"The curated static Nous list must remain in _PROVIDER_MODELS as "
"a fallback for environments where hermes_cli is unavailable."
)
def test_static_list_keeps_at_nous_prefix(self):
# Keep parity with tests/test_nous_portal_routing.py — ensures the
# static fallback path produces correctly-routable ids when used.
from api.config import _PROVIDER_MODELS
for m in _PROVIDER_MODELS["nous"]:
assert m["id"].startswith("@nous:"), m["id"]