HERMES-GIELIE/tests/test_issue1567_nous_picker_capacity_and_symmetry.py

626 lines
30 KiB
Python
Raw Permalink 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",
# Model-selection overrides: get_effective_default_model() treats these
# as the highest-priority default-model source, above config. A runner
# exporting HERMES_MODEL (e.g. a live agent session on "opus-4.8") injects
# that id into the picker/catalog and breaks capacity/symmetry assertions.
"HERMES_MODEL", "OPENAI_MODEL", "LLM_MODEL",
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
):
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."
)
Release PG (v0.51.446): model picker show-all overflow for large catalogs (#3691) (#4282) * stage-3712: model picker show-all overflow + fix per-row count leak (#3691) Self-rebased rodboev's #3712 onto master. FIX (Nathan-caught): the per-row provider chip stripped the '(N of M)' overflow count — that count belongs on the group heading only, and was reading as nonsense post-expand (row 30 showing '15 of 30'). Updated test to assert heading-carries-count + rows-do-not. Co-authored-by: rodboev <rodboev@users.noreply.github.com> * stage-3712: + dedupe overflow expand (Codex SILENT) — reuse existing injected option Codex caught: select hidden overflow model from search → reopen → Show all could duplicate the row (_appendOverflowOptionsToGroup blindly appended every extra model without checking the select for an already-injected option). Now moves/reuses an existing same-value option instead of re-creating it. * stage-3712: + fix expanded-heading double-count (Opus) — strip decoration, count rendered rows Opus caught: post-expand the heading showed 'Nous (2 of 4) (4)' (appended its own count onto the already-decorated backend label) and used modelCount (incl hoisted- configured) vs rendered rows. Now strips the '(N of M)' suffix when expanded + counts groupRows.length. Test asserts no double count on out[expanded]. * stage-3712: + include extra_models in Settings/Cron/Profile/Aux native selectors (Codex CORE) Codex caught: the server overflow split moves models>15 into extra_models for ALL providers, but the 4 native <select> builders in panels.js (settingsModel:6690, cronFormModel:1112, profileFormModel:5784, _auxProviders:8287) read g.models only — silently dropping the overflow tail for large providers. Now concat models+extra_models at all 4 sites. Test guard added. * Release PG: model picker show-all overflow for large provider catalogs (#3691) Self-rebased rodboev's #3712 onto v0.51.445; full browser-tested (desktop+mobile, before/after) + Nathan screenshot-approved. 4 gate-caught issues fixed inline: per-row count leak (Nathan), overflow-expand dedup (Codex), heading double-count (Opus), and native Settings/Cron/Profile/Aux selectors dropping the overflow tail (Codex CORE). Codex SAFE + Opus SHIP + 41 picker tests green. Co-authored-by: rodboev <rodboev@users.noreply.github.com> --------- Co-authored-by: nesquena-hermes <agent@nesquena-hermes> Co-authored-by: rodboev <rodboev@users.noreply.github.com>
2026-06-16 00:16:36 +00:00
class TestGenericPickerOverflowHelper:
"""The shared overflow splitter keeps the full catalog reachable."""
def test_large_non_nous_catalog_splits_without_dropping_tail(self):
from api.config import _split_picker_overflow_models, _MODEL_PICKER_VISIBLE_TARGET
ordered = [
{"id": f"provider/model-{i:02d}", "label": f"Model {i:02d}"}
for i in range(40)
]
visible, extras = _split_picker_overflow_models(ordered, provider_id="openai-codex")
assert len(visible) == _MODEL_PICKER_VISIBLE_TARGET
assert len(extras) == len(ordered) - _MODEL_PICKER_VISIBLE_TARGET
assert [m["id"] for m in visible + extras] == [m["id"] for m in ordered], (
"The generic overflow helper must preserve provider order when no "
"sticky selection needs to be surfaced."
)
def test_large_non_nous_catalog_keeps_selected_tail_visible(self):
from api.config import _split_picker_overflow_models
ordered = [
{"id": f"provider/model-{i:02d}", "label": f"Model {i:02d}"}
for i in range(40)
]
sticky = "provider/model-39"
visible, extras = _split_picker_overflow_models(
ordered,
selected_model_id=sticky,
provider_id="openai-codex",
)
visible_ids = {m["id"] for m in visible}
extra_ids = {m["id"] for m in extras}
assert sticky in visible_ids, "Sticky selection must stay visible after the shared overflow split."
assert sticky not in extra_ids, "Sticky selection cannot remain hidden in extra_models."
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
# ────────────────────────────────────────────────────────────────────────
# 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 list so the picker isn't empty in that
degraded environment. This preserves pre-#1538 behavior for test envs
without hermes_cli.
Note: _seed_provider_models_from_core() may have enriched the static
list at import time if hermes_cli was available then, so we assert the
original 4 curated entries are present (subset) rather than an exact
count (#4413)."""
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
_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."
)
fix(tests): eliminate the two chronic full-suite flakes (nous_picker + service_tier) Both fail ONLY in the full suite, not in isolation — they've been noise on every release gate. Root-caused and fixed at the source (no retries/skips): 1. test_issue1567 nous_picker (test_hermes_cli_unavailable_falls_back_to_static_4): NOT actually a flake — a real assertion that silently broke as the curated list grew. It asserted every curated @nous: id (now 28) appears in the picker group's visible 'models', but when the list exceeds _NOUS_FEATURED_THRESHOLD (25) the dropdown intentionally shows only the featured subset and stashes the rest under 'extra_models'. Fixed to assert the invariant that matters ('no curated entry is DROPPED') against the visible+extra_models union, so it tracks the real behavior instead of drifting to failure every time the catalog grows. 2. test_issue4536 service_tier (test_main_service_tier_roundtrip_via_auxiliary_endpoint): Classic test-isolation leak. get_auxiliary_models() reads the module-global config.cfg after reload_config(); if an EARLIER full-suite test rebinds config.cfg via monkeypatch and leaves 'cfg is not _cfg_cache', _cfg_has_in_memory_overrides() stays True and get_auxiliary_models() reads that stale dict instead of the freshly-reloaded temp config. Added an autouse fixture that re-aliases cfg to _cfg_cache and clears the override fingerprint before AND after each test in the class. PROVEN: reproduced the failure deterministically with a leak-probe test, confirmed the fixture neutralizes it (fixture off = FAIL, fixture on = PASS).
2026-07-04 18:32:43 +00:00
# Every curated @nous: entry must survive the static fallback — but
# when the curated list exceeds _NOUS_FEATURED_THRESHOLD (>25) the
# picker dropdown intentionally shows only the featured subset and
# stashes the rest under "extra_models" (for /model autocomplete).
# The invariant is "no curated entry is DROPPED", so check the full
# returned set (visible models + extra_models), not just the visible
# dropdown slice (which drifts to failure as the curated list grows
# past the threshold — the root cause of the pre-fix flake).
_nous_group = nous_groups[0]
nous_ids = {m["id"] for m in _nous_group.get("models", [])}
nous_ids |= {m["id"] for m in _nous_group.get("extra_models", [])}
_curated_nous_ids = {
m["id"] for m in config._PROVIDER_MODELS.get("nous", [])
if m["id"].startswith("@nous:")
}
assert _curated_nous_ids.issubset(nous_ids), (
fix(tests): eliminate the two chronic full-suite flakes (nous_picker + service_tier) Both fail ONLY in the full suite, not in isolation — they've been noise on every release gate. Root-caused and fixed at the source (no retries/skips): 1. test_issue1567 nous_picker (test_hermes_cli_unavailable_falls_back_to_static_4): NOT actually a flake — a real assertion that silently broke as the curated list grew. It asserted every curated @nous: id (now 28) appears in the picker group's visible 'models', but when the list exceeds _NOUS_FEATURED_THRESHOLD (25) the dropdown intentionally shows only the featured subset and stashes the rest under 'extra_models'. Fixed to assert the invariant that matters ('no curated entry is DROPPED') against the visible+extra_models union, so it tracks the real behavior instead of drifting to failure every time the catalog grows. 2. test_issue4536 service_tier (test_main_service_tier_roundtrip_via_auxiliary_endpoint): Classic test-isolation leak. get_auxiliary_models() reads the module-global config.cfg after reload_config(); if an EARLIER full-suite test rebinds config.cfg via monkeypatch and leaves 'cfg is not _cfg_cache', _cfg_has_in_memory_overrides() stays True and get_auxiliary_models() reads that stale dict instead of the freshly-reloaded temp config. Added an autouse fixture that re-aliases cfg to _cfg_cache and clears the override fingerprint before AND after each test in the class. PROVEN: reproduced the failure deterministically with a leak-probe test, confirmed the fixture neutralizes it (fixture off = FAIL, fixture on = PASS).
2026-07-04 18:32:43 +00:00
f"Static fallback dropped curated @nous: entries. "
f"Missing {_curated_nous_ids - nous_ids} "
f"(visible+extra_models total {len(nous_ids)})"
)
fix(tests): eliminate the two chronic full-suite flakes (nous_picker + service_tier) Both fail ONLY in the full suite, not in isolation — they've been noise on every release gate. Root-caused and fixed at the source (no retries/skips): 1. test_issue1567 nous_picker (test_hermes_cli_unavailable_falls_back_to_static_4): NOT actually a flake — a real assertion that silently broke as the curated list grew. It asserted every curated @nous: id (now 28) appears in the picker group's visible 'models', but when the list exceeds _NOUS_FEATURED_THRESHOLD (25) the dropdown intentionally shows only the featured subset and stashes the rest under 'extra_models'. Fixed to assert the invariant that matters ('no curated entry is DROPPED') against the visible+extra_models union, so it tracks the real behavior instead of drifting to failure every time the catalog grows. 2. test_issue4536 service_tier (test_main_service_tier_roundtrip_via_auxiliary_endpoint): Classic test-isolation leak. get_auxiliary_models() reads the module-global config.cfg after reload_config(); if an EARLIER full-suite test rebinds config.cfg via monkeypatch and leaves 'cfg is not _cfg_cache', _cfg_has_in_memory_overrides() stays True and get_auxiliary_models() reads that stale dict instead of the freshly-reloaded temp config. Added an autouse fixture that re-aliases cfg to _cfg_cache and clears the override fingerprint before AND after each test in the class. PROVEN: reproduced the failure deterministically with a leak-probe test, confirmed the fixture neutralizes it (fixture off = FAIL, fixture on = PASS).
2026-07-04 18:32:43 +00:00
assert len(nous_ids) >= 4, (
"Static fallback should expose at least the curated 4-entry "
"list from _PROVIDER_MODELS['nous']."
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
)
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."
)