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",
|
2026-07-21 07:00:52 +00:00
|
|
|
|
# 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 [] —
|
2026-06-20 21:22:53 +00:00
|
|
|
|
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."
|
|
|
|
|
|
)
|
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", [])}
|
2026-06-20 21:22:53 +00:00
|
|
|
|
_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), (
|
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)})"
|
2026-06-20 21:22:53 +00:00
|
|
|
|
)
|
2026-07-04 18:32:43 +00:00
|
|
|
|
assert len(nous_ids) >= 4, (
|
2026-06-20 21:22:53 +00:00
|
|
|
|
"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."
|
|
|
|
|
|
)
|