HERMES-GIELIE/tests/test_issue1567_nous_picker_capacity_and_symmetry.py

557 lines
26 KiB
Python
Raw Normal View History

fix(picker): Nous Portal featured-set cap + endpoint symmetry (closes #1567) Two related dropdown bugs in one PR — same root shape (model-picker endpoints disagreeing about which Nous Portal models exist) plus the preemptive UX guard against the picker becoming unusable on large-tier Nous accounts. #1567 — Endpoint disagreement ============================= Reporter (Deor, Discord, May 03 2026) saw Settings → Providers card showing "Nous Portal — 396 models · OAuth" while the in-conversation picker dropdown listed only the four hardcoded curated entries. Two structural causes: 1. ``api/providers.py:get_providers`` iterates ALL OAuth providers regardless of authentication state and unconditionally live-fetches the catalog. 2. ``api/config.py:_build_available_models_uncached`` only iterates providers in ``detected_providers``, gated on ``hermes_cli.models.list_available_providers().authenticated``. That flag can disagree with ``get_auth_status(<id>).logged_in`` on some hermes_cli versions. When the disagreement happens for Nous, the picker silently falls through to the curated 4-entry static list while the providers card keeps showing the live catalog — exactly the asymmetry users report. Plus: the Nous live-fetch branch in `_build_available_models_uncached` fell back to the same curated 4-entry list when `provider_model_ids` returned an empty list (transient failure / OAuth refresh in flight), which doubles down on the disagreement instead of healing it. UX cap (the design concern Nathan flagged on triage) ==================================================== Even with the disagreement fixed, dumping a 397-model catalog into a flat dropdown is unusable. We trim the visible picker to a curated ~15-entry featured set when the catalog exceeds 25 models, and surface the rest under a new ``extra_models`` field so: - ``/model`` slash autocomplete (commands.js) covers the full catalog - ``_dynamicModelLabels`` (ui.js) hydrates from both lists, so a model selected from outside the featured slice still gets a proper label - The optgroup label gets ``" (15 of 397)"`` appended so the user understands the dropdown is intentionally trimmed, not broken - The providers card surfaces ``models_total`` separately so the header still reads "397 models · OAuth" - A small "+N more" disclosure pill appears at the end of the rendered pill list (only fires for non-OAuth providers — OAuth cards never render pills) with a tooltip pointing at the slash command Featured selection rules ------------------------ Deterministic; same algorithm runs in both `/api/models` and `/api/models/live` so background enrichment doesn't undo the trim: 1. Always include the user's currently-selected model (sticky — no orphan IDs in the dropdown after a refresh) 2. Always include every entry from the curated static ``_PROVIDER_MODELS["nous"]`` list whose id maps onto a live id 3. Top up to 15 by walking ``_NOUS_VENDOR_PRIORITY`` round-robin (one model per vendor each pass) so no vendor monopolises the slots Changes by file =============== api/config.py - New `_format_nous_label` neighbour: `_NOUS_FEATURED_THRESHOLD = 25`, `_NOUS_FEATURED_TARGET = 15`, `_NOUS_VENDOR_PRIORITY` tuple, `_build_nous_featured_set()` helper (~80 LOC) - `_build_available_models_uncached` Nous branch: - Apply featured-set cap with sticky-selection signal - Return `extra_models` alongside `models` for the catalog tail - Decorate optgroup label with truncation count - Drop stale-4 fallback when authenticated but live-fetch empty (omit the group entirely; truth lives in the providers card and the next cache rebuild will heal it) - Keep stale-4 fallback when hermes_cli is unavailable (test envs, package mismatches) — that's a different failure mode - Detection symmetry: explicit `get_auth_status("nous").logged_in` check after the existing `list_available_providers()` loop, so the picker matches the providers card on hermes_cli versions where the two signals disagree api/providers.py:get_providers - Apply same featured-set cap so card body doesn't render 397 pills - Add `models_total` field reporting full catalog size (used by frontend for the "N models · OAuth" header text) api/routes.py:_handle_live_models - Apply same featured-set cap for `/api/models/live` so background enrichment via `_fetchLiveModels()` doesn't undo the dropdown trim - Use sticky-selection from `cfg["model"]["model"]` matching the main endpoint's logic static/ui.js:populateModelDropdown - Hydrate `_dynamicModelLabels` from `g.extra_models` so a selection outside the visible dropdown still renders with its proper label static/commands.js:_loadSlashModelSubArgs - Iterate `group.extra_models` so `/model` autocomplete covers the full catalog (not just the trimmed featured slice) static/panels.js:_buildProviderCard - Header count uses `p.models_total` (full catalog size) instead of `p.models.length` (trimmed slice) - Render trailing "+N more" disclosure pill when `models.length < models_total` with a tooltip pointing at the slash command static/style.css - New `.provider-card-model-tag-more` rule (italic, dashed border, cursor:help, no select) — visually distinct from real model pills Tests ===== `tests/test_issue1567_nous_picker_capacity_and_symmetry.py` (20 tests): - TestBuildNousFeaturedSet (8): unit tests on the helper — small-catalog no-op, large-catalog cap to target, disjoint+complete invariants, priority-vendor round-robin guarantee, sticky selection with and without `@nous:` prefix, curated-flagship preservation, empty-catalog handling, determinism - TestApiModelsLargeCatalog (2): /api/models cap behavior end-to-end on a synthetic 397-model catalog vs a 20-model catalog - TestNousDetectionSymmetry (2): picker includes Nous when `get_auth_status` agrees but `list_available_providers` disagrees; picker omits Nous when both disagree - TestNousLiveFetchEmpty (2): authenticated + empty-fetch omits group; hermes_cli unavailable still falls back to static-4 - TestProvidersCardPickerSymmetry (1): both endpoints agree on exactly the same featured-set IDs + total catalog count - TestFrontendExtrasContract (4): static-source assertions pinning the JS contract for `extra_models`, `models_total`, and the "+N more" disclosure Verified live on port 8789 (30-model catalog): - /api/models Nous group: provider="Nous Portal (15 of 30)", 15 models, 15 extra_models - /api/models/live?provider=nous: 15 entries (matches main path) - /api/providers Nous card: models_total=30, models=15 - Browser dropdown after backfill: 15 options, 30 entries in _dynamicModelLabels - Sticky selection: Claude Opus 4.7 (the active model) in the featured slice as expected 4073 pytest passed (was 4053 → 4073, +20 from this PR). 3 CI test runs (3.11/3.12/3.13) green. QA harness 11/11 passed. Reporter: Deor (Discord #report-bugs, May 03 2026 14:15 PT) Relayed by: AvidFuturist
2026-05-03 21:44:22 +00:00
"""Regression tests for #1567 — Nous Portal picker capacity + endpoint symmetry.
Two issues addressed in one PR:
1. **Endpoint disagreement (the bug):** The Settings → Providers card and the
model picker dropdown returned different Nous catalogs because their
detection paths differ. ``api/providers.py:get_providers`` iterates ALL
OAuth providers regardless of `list_available_providers().authenticated`.
``api/config.py:_build_available_models_uncached`` only includes providers
in ``detected_providers``, which is gated on
``list_available_providers().authenticated``. On some hermes_cli versions
that flag disagrees with ``get_auth_status(<id>).logged_in``. Result: the
providers card shows the live catalog (e.g. 396 models) and the picker
shows nothing or the stale 4-entry static fallback.
2. **UX cap (the design concern):** Even with the disagreement fixed, dumping
a 397-model dropdown into the picker would be unusable. We cap the
dropdown at ~15 featured entries (deterministic vendor-priority sample,
sticky for the user's currently-selected model) and return the full
catalog under ``extra_models`` so /model autocomplete and the dynamic
label map still cover everything.
Tests in this file pin both invariants.
"""
from __future__ import annotations
import sys
import types
import api.config as config
import api.profiles as profiles
# Big catalog matches the shape of an enterprise Nous Portal account.
# Volume distribution mirrors what we saw on Nathan's machine (~30 models)
# extrapolated up to ~400 with the same vendor mix Deor reported.
_BIG_CATALOG_VENDORS = {
"anthropic": 8, "openai": 30, "google": 12, "moonshotai": 5, "z-ai": 15,
"minimax": 10, "qwen": 80, "x-ai": 8, "deepseek": 20, "stepfun": 10,
"xiaomi": 6, "tencent": 12, "nvidia": 25, "arcee-ai": 8,
"meta-llama": 50, "mistralai": 40, "cohere": 25, "databricks": 15, "lambda-ai": 18,
}
def _build_big_catalog() -> list[str]:
out = []
for v, n in _BIG_CATALOG_VENDORS.items():
for i in range(n):
out.append(f"{v}/model-{v}-{i:02d}")
return out
def _install_fake_hermes_cli(
monkeypatch,
*,
nous_ids: list[str] | None = None,
raise_on_lookup: bool = False,
list_authenticated: bool = True,
auth_status_logged_in: bool = True,
):
"""Install fake ``hermes_cli`` modules with controllable Nous behavior.
The two flags ``list_authenticated`` and ``auth_status_logged_in`` model
the divergence between ``hermes_cli.models.list_available_providers()``
and ``hermes_cli.auth.get_auth_status()`` that #1567 calls out as a
real-world pattern on some hermes_cli versions.
"""
fake_pkg = types.ModuleType("hermes_cli")
fake_pkg.__path__ = []
fake_models = types.ModuleType("hermes_cli.models")
fake_models.list_available_providers = lambda: [
{"id": "nous", "label": "Nous Portal", "aliases": [], "authenticated": list_authenticated},
]
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 _get_auth_status(pid):
if pid == "nous":
return {"logged_in": auth_status_logged_in, "key_source": "oauth"}
return {}
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):
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 doesn't leak unrelated keys."""
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",
2026-05-10 23:33:52 +00:00
"XIAOMI_API_KEY",
fix(picker): Nous Portal featured-set cap + endpoint symmetry (closes #1567) Two related dropdown bugs in one PR — same root shape (model-picker endpoints disagreeing about which Nous Portal models exist) plus the preemptive UX guard against the picker becoming unusable on large-tier Nous accounts. #1567 — Endpoint disagreement ============================= Reporter (Deor, Discord, May 03 2026) saw Settings → Providers card showing "Nous Portal — 396 models · OAuth" while the in-conversation picker dropdown listed only the four hardcoded curated entries. Two structural causes: 1. ``api/providers.py:get_providers`` iterates ALL OAuth providers regardless of authentication state and unconditionally live-fetches the catalog. 2. ``api/config.py:_build_available_models_uncached`` only iterates providers in ``detected_providers``, gated on ``hermes_cli.models.list_available_providers().authenticated``. That flag can disagree with ``get_auth_status(<id>).logged_in`` on some hermes_cli versions. When the disagreement happens for Nous, the picker silently falls through to the curated 4-entry static list while the providers card keeps showing the live catalog — exactly the asymmetry users report. Plus: the Nous live-fetch branch in `_build_available_models_uncached` fell back to the same curated 4-entry list when `provider_model_ids` returned an empty list (transient failure / OAuth refresh in flight), which doubles down on the disagreement instead of healing it. UX cap (the design concern Nathan flagged on triage) ==================================================== Even with the disagreement fixed, dumping a 397-model catalog into a flat dropdown is unusable. We trim the visible picker to a curated ~15-entry featured set when the catalog exceeds 25 models, and surface the rest under a new ``extra_models`` field so: - ``/model`` slash autocomplete (commands.js) covers the full catalog - ``_dynamicModelLabels`` (ui.js) hydrates from both lists, so a model selected from outside the featured slice still gets a proper label - The optgroup label gets ``" (15 of 397)"`` appended so the user understands the dropdown is intentionally trimmed, not broken - The providers card surfaces ``models_total`` separately so the header still reads "397 models · OAuth" - A small "+N more" disclosure pill appears at the end of the rendered pill list (only fires for non-OAuth providers — OAuth cards never render pills) with a tooltip pointing at the slash command Featured selection rules ------------------------ Deterministic; same algorithm runs in both `/api/models` and `/api/models/live` so background enrichment doesn't undo the trim: 1. Always include the user's currently-selected model (sticky — no orphan IDs in the dropdown after a refresh) 2. Always include every entry from the curated static ``_PROVIDER_MODELS["nous"]`` list whose id maps onto a live id 3. Top up to 15 by walking ``_NOUS_VENDOR_PRIORITY`` round-robin (one model per vendor each pass) so no vendor monopolises the slots Changes by file =============== api/config.py - New `_format_nous_label` neighbour: `_NOUS_FEATURED_THRESHOLD = 25`, `_NOUS_FEATURED_TARGET = 15`, `_NOUS_VENDOR_PRIORITY` tuple, `_build_nous_featured_set()` helper (~80 LOC) - `_build_available_models_uncached` Nous branch: - Apply featured-set cap with sticky-selection signal - Return `extra_models` alongside `models` for the catalog tail - Decorate optgroup label with truncation count - Drop stale-4 fallback when authenticated but live-fetch empty (omit the group entirely; truth lives in the providers card and the next cache rebuild will heal it) - Keep stale-4 fallback when hermes_cli is unavailable (test envs, package mismatches) — that's a different failure mode - Detection symmetry: explicit `get_auth_status("nous").logged_in` check after the existing `list_available_providers()` loop, so the picker matches the providers card on hermes_cli versions where the two signals disagree api/providers.py:get_providers - Apply same featured-set cap so card body doesn't render 397 pills - Add `models_total` field reporting full catalog size (used by frontend for the "N models · OAuth" header text) api/routes.py:_handle_live_models - Apply same featured-set cap for `/api/models/live` so background enrichment via `_fetchLiveModels()` doesn't undo the dropdown trim - Use sticky-selection from `cfg["model"]["model"]` matching the main endpoint's logic static/ui.js:populateModelDropdown - Hydrate `_dynamicModelLabels` from `g.extra_models` so a selection outside the visible dropdown still renders with its proper label static/commands.js:_loadSlashModelSubArgs - Iterate `group.extra_models` so `/model` autocomplete covers the full catalog (not just the trimmed featured slice) static/panels.js:_buildProviderCard - Header count uses `p.models_total` (full catalog size) instead of `p.models.length` (trimmed slice) - Render trailing "+N more" disclosure pill when `models.length < models_total` with a tooltip pointing at the slash command static/style.css - New `.provider-card-model-tag-more` rule (italic, dashed border, cursor:help, no select) — visually distinct from real model pills Tests ===== `tests/test_issue1567_nous_picker_capacity_and_symmetry.py` (20 tests): - TestBuildNousFeaturedSet (8): unit tests on the helper — small-catalog no-op, large-catalog cap to target, disjoint+complete invariants, priority-vendor round-robin guarantee, sticky selection with and without `@nous:` prefix, curated-flagship preservation, empty-catalog handling, determinism - TestApiModelsLargeCatalog (2): /api/models cap behavior end-to-end on a synthetic 397-model catalog vs a 20-model catalog - TestNousDetectionSymmetry (2): picker includes Nous when `get_auth_status` agrees but `list_available_providers` disagrees; picker omits Nous when both disagree - TestNousLiveFetchEmpty (2): authenticated + empty-fetch omits group; hermes_cli unavailable still falls back to static-4 - TestProvidersCardPickerSymmetry (1): both endpoints agree on exactly the same featured-set IDs + total catalog count - TestFrontendExtrasContract (4): static-source assertions pinning the JS contract for `extra_models`, `models_total`, and the "+N more" disclosure Verified live on port 8789 (30-model catalog): - /api/models Nous group: provider="Nous Portal (15 of 30)", 15 models, 15 extra_models - /api/models/live?provider=nous: 15 entries (matches main path) - /api/providers Nous card: models_total=30, models=15 - Browser dropdown after backfill: 15 options, 30 entries in _dynamicModelLabels - Sticky selection: Claude Opus 4.7 (the active model) in the featured slice as expected 4073 pytest passed (was 4053 → 4073, +20 from this PR). 3 CI test runs (3.11/3.12/3.13) green. QA harness 11/11 passed. Reporter: Deor (Discord #report-bugs, May 03 2026 14:15 PT) Relayed by: AvidFuturist
2026-05-03 21:44:22 +00:00
"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)
# ────────────────────────────────────────────────────────────────────────
# Section 1 — _build_nous_featured_set helper invariants
# ────────────────────────────────────────────────────────────────────────
class TestBuildNousFeaturedSet:
"""Unit tests for the deterministic featured-vs-extras split helper."""
def test_small_catalog_is_no_op(self):
from api.config import _build_nous_featured_set, _NOUS_FEATURED_THRESHOLD
# 20 entries — below the threshold, helper should return the input
# untouched and an empty extras list.
catalog = [f"vendor/model-{i:02d}" for i in range(20)]
assert len(catalog) <= _NOUS_FEATURED_THRESHOLD
featured, extras = _build_nous_featured_set(catalog)
assert featured == catalog
assert extras == []
def test_large_catalog_is_capped_to_target(self):
from api.config import _build_nous_featured_set, _NOUS_FEATURED_TARGET
catalog = _build_big_catalog()
assert len(catalog) > 100, "test fixture should produce a large catalog"
featured, extras = _build_nous_featured_set(catalog)
assert len(featured) == _NOUS_FEATURED_TARGET, (
f"Large catalog should produce exactly _NOUS_FEATURED_TARGET "
f"featured entries, got {len(featured)}."
)
assert len(extras) == len(catalog) - _NOUS_FEATURED_TARGET
def test_featured_and_extras_are_disjoint_and_complete(self):
from api.config import _build_nous_featured_set
catalog = _build_big_catalog()
featured, extras = _build_nous_featured_set(catalog)
assert set(featured) & set(extras) == set(), (
"featured and extras must be disjoint — every model belongs to "
"exactly one bucket."
)
assert set(featured) | set(extras) == set(catalog), (
"featured ∪ extras must equal the input catalog — no model "
"should be silently dropped."
)
def test_priority_vendors_get_picked_first(self):
from api.config import _build_nous_featured_set, _NOUS_VENDOR_PRIORITY
catalog = _build_big_catalog()
featured, _ = _build_nous_featured_set(catalog)
# Every priority vendor with ≥1 entry in the catalog must appear in
# featured (round-robin guarantee until we hit the slot budget).
featured_vendors = {m.split("/", 1)[0] for m in featured}
for v in _NOUS_VENDOR_PRIORITY:
if v in _BIG_CATALOG_VENDORS:
assert v in featured_vendors, (
f"Priority vendor {v!r} missing from featured set — "
f"round-robin guarantee violated."
)
def test_sticky_selection_is_preserved(self):
from api.config import _build_nous_featured_set
catalog = _build_big_catalog()
# Pick a model from a leftover (non-priority) vendor that wouldn't
# normally make the featured cut.
sticky = "lambda-ai/model-lambda-ai-15"
assert sticky in catalog
featured, extras = _build_nous_featured_set(catalog, selected_model_id=sticky)
assert sticky in featured, (
f"Sticky-selected model {sticky!r} must appear in featured — "
f"otherwise the user's choice gets orphaned out of the dropdown "
f"after a refresh."
)
assert sticky not in extras
def test_sticky_selection_handles_at_nous_prefix(self):
from api.config import _build_nous_featured_set
catalog = _build_big_catalog()
# The frontend stores selections as @nous:vendor/model — helper must
# strip the prefix to match against the bare-id catalog.
sticky_with_prefix = "@nous:lambda-ai/model-lambda-ai-15"
bare = "lambda-ai/model-lambda-ai-15"
featured, _ = _build_nous_featured_set(catalog, selected_model_id=sticky_with_prefix)
assert bare in featured
def test_curated_static_flagships_are_preserved(self):
from api.config import _build_nous_featured_set, _PROVIDER_MODELS
# Build a catalog that contains all the curated static IDs so the
# rule-2 path fires.
static_ids = []
for entry in _PROVIDER_MODELS.get("nous", []):
sid = entry["id"]
if sid.startswith("@nous:"):
sid = sid[len("@nous:"):]
static_ids.append(sid)
catalog = static_ids + [f"filler-vendor/filler-{i:03d}" for i in range(100)]
featured, _ = _build_nous_featured_set(catalog)
for sid in static_ids:
assert sid in featured, (
f"Curated static flagship {sid!r} dropped from featured set."
)
def test_empty_catalog_returns_empty(self):
from api.config import _build_nous_featured_set
f, e = _build_nous_featured_set([])
assert f == [] and e == []
def test_deterministic_across_calls(self):
from api.config import _build_nous_featured_set
catalog = _build_big_catalog()
f1, e1 = _build_nous_featured_set(catalog)
f2, e2 = _build_nous_featured_set(catalog)
assert f1 == f2 and e1 == e2, (
"Featured set must be deterministic — random/seeded selection "
"would cause cache thrash and dropdown flicker on every reload."
)
# ────────────────────────────────────────────────────────────────────────
# Section 2 — End-to-end /api/models behaviour with the cap applied
# ────────────────────────────────────────────────────────────────────────
class TestApiModelsLargeCatalog:
"""Wired-up test exercising the dispatch branch at config.py:2243."""
def test_picker_caps_large_catalog_and_exposes_extras(self, monkeypatch, tmp_path):
_scrub_provider_env(monkeypatch)
catalog = _build_big_catalog()
_install_fake_hermes_cli(monkeypatch, nous_ids=catalog)
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["groups"] if g["provider_id"] == "nous"]
assert len(nous_groups) == 1
grp = nous_groups[0]
from api.config import _NOUS_FEATURED_TARGET
assert len(grp["models"]) == _NOUS_FEATURED_TARGET, (
f"Picker should render {_NOUS_FEATURED_TARGET} featured entries "
f"on a {len(catalog)}-model catalog, got {len(grp['models'])}."
)
assert "extra_models" in grp, (
"Capped Nous group must include 'extra_models' so /model "
"autocomplete and the label map cover the full catalog."
)
assert len(grp["extra_models"]) == len(catalog) - _NOUS_FEATURED_TARGET
# Optgroup label is decorated with the truncation count so the user
# knows the dropdown is intentionally trimmed.
assert f"{_NOUS_FEATURED_TARGET} of {len(catalog)}" in grp["provider"], (
f"Provider label should include '({_NOUS_FEATURED_TARGET} of "
f"{len(catalog)})' for capped catalogs, got {grp['provider']!r}."
)
finally:
restore()
def test_picker_does_not_cap_small_catalog(self, monkeypatch, tmp_path):
_scrub_provider_env(monkeypatch)
# 20 models — below threshold, should pass through with no extras.
small_catalog = [f"vendor-{i % 4}/model-{i:02d}" for i in range(20)]
_install_fake_hermes_cli(monkeypatch, nous_ids=small_catalog)
monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path)
restore = _swap_in_test_config({"model": {"provider": "nous"}})
try:
data = config.get_available_models()
grp = next(g for g in data["groups"] if g["provider_id"] == "nous")
assert len(grp["models"]) == 20
assert "extra_models" not in grp or grp["extra_models"] == []
assert "of " not in grp["provider"], (
"Optgroup label should NOT include a truncation count when no "
"trimming happened, got " + repr(grp["provider"])
)
finally:
restore()
# ────────────────────────────────────────────────────────────────────────
# Section 3 — Auth-detection symmetry (#1567 part 1)
# ────────────────────────────────────────────────────────────────────────
class TestNousDetectionSymmetry:
"""The picker must include Nous whenever the providers card would —
fixes the asymmetric-detection bug at the heart of #1567."""
def test_picker_includes_nous_when_get_auth_status_logged_in(self, monkeypatch, tmp_path):
"""list_available_providers() reports authenticated=False but
get_auth_status('nous').logged_in=True. Picker must still show Nous."""
_scrub_provider_env(monkeypatch)
catalog = ["anthropic/claude-opus-4.7", "openai/gpt-5.5"]
_install_fake_hermes_cli(
monkeypatch,
nous_ids=catalog,
list_authenticated=False, # primary detection path says NO
auth_status_logged_in=True, # secondary detection path says YES
)
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["groups"] if g["provider_id"] == "nous"]
assert nous_groups, (
"Picker must include Nous group when get_auth_status reports "
"logged_in=True, even if list_available_providers disagrees. "
"This is the asymmetric-detection bug from #1567."
)
assert len(nous_groups[0]["models"]) == 2
finally:
restore()
def test_picker_omits_nous_when_both_auth_signals_false(self, monkeypatch, tmp_path):
"""When neither signal reports authenticated, Nous should NOT appear.
Previously the static 4-entry list could leak in via the fallback path
even for unauthenticated users — that fallback is now scoped to the
hermes_cli-unavailable case only."""
_scrub_provider_env(monkeypatch)
_install_fake_hermes_cli(
monkeypatch,
nous_ids=[], # no live catalog (also no auth)
list_authenticated=False,
auth_status_logged_in=False,
)
monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path)
restore = _swap_in_test_config({"model": {"provider": "anthropic"}})
try:
# Active provider is anthropic, not nous — so detected_providers
# only includes nous if the new auth-symmetry check fires.
data = config.get_available_models()
nous_groups = [g for g in data["groups"] if g["provider_id"] == "nous"]
assert not nous_groups, (
"Nous must NOT appear in picker when neither auth signal "
"reports authenticated. Got: " + str(nous_groups)
)
finally:
restore()
# ────────────────────────────────────────────────────────────────────────
# Section 4 — Live-fetch-empty handling (#1567 part 2)
# ────────────────────────────────────────────────────────────────────────
class TestNousLiveFetchEmpty:
"""When authenticated but live-fetch returns [] (transient hermes_cli
state, OAuth refresh in flight), DON'T fall back to the stale 4-entry
static list — that creates the providers-card-vs-picker disagreement
that #1567 reports. Omit the group entirely instead."""
def test_authenticated_empty_catalog_omits_nous_group(self, monkeypatch, tmp_path):
_scrub_provider_env(monkeypatch)
_install_fake_hermes_cli(
monkeypatch,
nous_ids=[], # live-fetch returns empty list (no exception)
auth_status_logged_in=True, # but user IS authenticated
)
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["groups"] if g["provider_id"] == "nous"]
assert not nous_groups, (
"Authenticated user with empty live-fetch should NOT see "
"the stale 4-entry static list — that's exactly the "
"providers-card-vs-picker disagreement #1567 reports. "
"Omit the Nous group entirely; it'll re-populate on the "
"next cache rebuild when the live-fetch returns something."
)
finally:
restore()
def test_hermes_cli_unavailable_falls_back_to_static_4(self, monkeypatch, tmp_path):
"""When hermes_cli is unavailable (raises) — distinct from returning [] —
we DO fall back to the static 4-entry list so the picker isn't empty
in that degraded environment. This preserves pre-#1538 behavior for
test envs without hermes_cli."""
_scrub_provider_env(monkeypatch)
_install_fake_hermes_cli(
monkeypatch,
raise_on_lookup=True,
auth_status_logged_in=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["groups"] if g["provider_id"] == "nous"]
assert nous_groups, (
"When hermes_cli raises, Nous group MUST still appear with "
"the curated static fallback so the picker isn't empty in "
"test envs that lack the agent package."
)
assert len(nous_groups[0]["models"]) == 4, (
"Static fallback should expose the curated 4-entry list "
"from _PROVIDER_MODELS['nous']."
)
finally:
restore()
# ────────────────────────────────────────────────────────────────────────
# Section 5 — Providers card ↔ picker symmetry
# ────────────────────────────────────────────────────────────────────────
class TestProvidersCardPickerSymmetry:
"""Both endpoints must report the same featured set + total count for
Nous Portal. This is the load-bearing invariant that ends the visual
disagreement #1567 reports."""
def test_providers_card_and_picker_agree_on_featured_set(self, monkeypatch, tmp_path):
_scrub_provider_env(monkeypatch)
catalog = _build_big_catalog()
_install_fake_hermes_cli(monkeypatch, nous_ids=catalog)
monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path)
restore = _swap_in_test_config({"model": {"provider": "nous"}})
try:
from api.providers import get_providers
from api.config import _NOUS_FEATURED_TARGET
providers = {p["id"]: p for p in get_providers()["providers"]}
picker = config.get_available_models()
picker_nous = next(g for g in picker["groups"] if g["provider_id"] == "nous")
card = providers["nous"]
# Both render exactly _NOUS_FEATURED_TARGET visible models.
assert len(card["models"]) == _NOUS_FEATURED_TARGET
assert len(picker_nous["models"]) == _NOUS_FEATURED_TARGET
# Both report the full catalog size somewhere.
assert card["models_total"] == len(catalog), (
f"Providers card models_total should match live catalog size, "
f"got {card['models_total']} vs catalog {len(catalog)}."
)
picker_total = len(picker_nous.get("models", [])) + len(
picker_nous.get("extra_models", [])
)
assert picker_total == len(catalog), (
f"Picker featured + extras must equal live catalog size, "
f"got {picker_total} vs {len(catalog)}."
)
# And they pick THE SAME featured set (not e.g. one's first-15
# and another's last-15).
card_ids = [m["id"] for m in card["models"]]
picker_ids = [m["id"] for m in picker_nous["models"]]
assert card_ids == picker_ids, (
f"Providers card and picker must show the SAME featured "
f"set so users see consistent labels in both places. "
f"Card: {card_ids}\nPicker: {picker_ids}"
)
finally:
restore()
# ────────────────────────────────────────────────────────────────────────
# Section 6 — Frontend contract (static-source assertions)
# ────────────────────────────────────────────────────────────────────────
class TestFrontendExtrasContract:
"""Pin the JS-side contract: dropdown reads `models`, slash command and
label map ALSO read `extra_models`. Without this, a model from the
catalog tail gets a bare-ID label or is invisible to /model autocomplete."""
def test_ui_js_hydrates_dynamic_labels_from_extra_models(self):
from pathlib import Path
src = (Path(__file__).resolve().parent.parent / "static" / "ui.js").read_text(encoding="utf-8")
# Find the populateModelDropdown function and check it consumes
# extra_models. Use a windowed substring search so the test stays
# robust against minor refactors of surrounding code.
idx = src.find("async function populateModelDropdown")
assert idx != -1
body = src[idx : idx + 3000]
assert "extra_models" in body, (
"populateModelDropdown must hydrate _dynamicModelLabels from "
"g.extra_models so a model selected outside the featured set "
"still gets a proper label. Without this, /model audio-lines "
"→ 'audio-lines' bare-ID display. (#1567)"
)
def test_commands_js_loads_slash_args_from_extra_models(self):
from pathlib import Path
src = (Path(__file__).resolve().parent.parent / "static" / "commands.js").read_text(encoding="utf-8")
idx = src.find("async function _loadSlashModelSubArgs")
assert idx != -1
body = src[idx : idx + 1500]
assert "extra_models" in body, (
"_loadSlashModelSubArgs must iterate group.extra_models so /model "
"autocomplete covers the full catalog, not just the dropdown's "
"featured subset. The slash command exists precisely so power "
"users can reach any model by typing its name. (#1567)"
)
def test_panels_js_uses_models_total_for_count(self):
from pathlib import Path
src = (Path(__file__).resolve().parent.parent / "static" / "panels.js").read_text(encoding="utf-8")
idx = src.find("function _buildProviderCard")
assert idx != -1
body = src[idx : idx + 1500]
assert "models_total" in body, (
"Provider card header should use p.models_total (full catalog "
"size) for the count, not p.models.length (which is now the "
"trimmed featured-set size). Without this, the header text says "
"'15 models' instead of '396 models' for capped catalogs. (#1567)"
)
def test_panels_js_renders_more_disclosure_pill(self):
from pathlib import Path
src = (Path(__file__).resolve().parent.parent / "static" / "panels.js").read_text(encoding="utf-8")
# The "+N more" disclosure must reference the difference between
# rendered count and total count somewhere in the providers-card
# rendering path.
assert "provider-card-model-tag-more" in src, (
"Provider card must render a '+N more' disclosure pill when "
"len(models) < models_total, so users know the dropdown is "
"intentionally capped and the rest is reachable via /model."
)