diff --git a/apps/desktop/src/app/shell/model-menu-panel.test.tsx b/apps/desktop/src/app/shell/model-menu-panel.test.tsx index e68d3b99a077..7da7c4af3d32 100644 --- a/apps/desktop/src/app/shell/model-menu-panel.test.tsx +++ b/apps/desktop/src/app/shell/model-menu-panel.test.tsx @@ -178,7 +178,7 @@ describe('ModelMenuPanel provider collapse', () => { }) }) - it('auto-expands the active provider even when collapsed', async () => { + it('collapses the active provider too (no forced auto-expand)', async () => { $currentProvider.set('deepseek') $currentModel.set('deepseek-v4-pro') const { content } = renderPanel() @@ -186,8 +186,11 @@ describe('ModelMenuPanel provider collapse', () => { const header = await content.findByText('DeepSeek') fireEvent.click(header) - // Should still show models because it's the active provider - expect(content.queryByText('Deepseek V4 Pro')).not.toBeNull() + // The current provider is collapsible like any other — clicking its header + // hides its models rather than forcing them to stay open. + await vi.waitFor(() => { + expect(content.queryByText('Deepseek V4 Pro')).toBeNull() + }) }) it('bypasses collapse when search is active', async () => { diff --git a/apps/desktop/src/app/shell/model-menu-panel.tsx b/apps/desktop/src/app/shell/model-menu-panel.tsx index 883b9c18063e..5fd4c9691c8a 100644 --- a/apps/desktop/src/app/shell/model-menu-panel.tsx +++ b/apps/desktop/src/app/shell/model-menu-panel.tsx @@ -4,6 +4,7 @@ import { createContext, useContext, useMemo, useState } from 'react' import { useSessionView } from '@/app/chat/session-view' import { Codicon } from '@/components/ui/codicon' +import { DisclosureCaret } from '@/components/ui/disclosure-caret' import { DropdownMenuGroup, DropdownMenuItem, @@ -18,7 +19,6 @@ import { import { Skeleton } from '@/components/ui/skeleton' import type { HermesGateway } from '@/hermes' import { useI18n } from '@/i18n' -import { ChevronDown, ChevronRight } from '@/lib/icons' import { modelOptionsQueryKey, requestModelOptions } from '@/lib/model-options' import { currentPickerSelection, displayModelName, modelDisplayParts } from '@/lib/model-status-label' import { DEFAULT_REASONING_EFFORT, reasoningEffortLabel } from '@/lib/reasoning-effort' @@ -244,25 +244,22 @@ export function ModelMenuPanel({ gateway, onSelectModel, profile = 'default', re {groups.map(group => { const slug = group.provider.slug - // Collapsed when stored + no active search + not the current provider. - const collapsed = collapsedProviders.includes(slug) && !search && slug !== optionsProvider + // Collapsed when the user stored it (and not while searching, which + // spans every model regardless of collapse state). + const collapsed = collapsedProviders.includes(slug) && !search return ( { event.preventDefault() toggleCollapsedProvider(slug) }} textValue="" > - {collapsed ? ( - - ) : ( - - )} - {group.provider.name} + {group.provider.name} + {!collapsed && group.families.map(family => { diff --git a/apps/desktop/src/components/model-visibility-dialog.tsx b/apps/desktop/src/components/model-visibility-dialog.tsx index 764239021e84..995e1d9adbda 100644 --- a/apps/desktop/src/components/model-visibility-dialog.tsx +++ b/apps/desktop/src/components/model-visibility-dialog.tsx @@ -3,11 +3,14 @@ import { useQuery } from '@tanstack/react-query' import { useMemo, useState } from 'react' import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { DisclosureCaret } from '@/components/ui/disclosure-caret' import { GlyphSpinner } from '@/components/ui/glyph-spinner' import { Switch } from '@/components/ui/switch' import type { HermesGateway } from '@/hermes' import { useI18n } from '@/i18n' +import { Search } from '@/lib/icons' import { modelOptionsQueryKey, requestModelOptions } from '@/lib/model-options' import { displayModelName, modelDisplayParts } from '@/lib/model-status-label' import { normalize } from '@/lib/text' @@ -16,9 +19,11 @@ import { collapseModelFamilies, effectiveVisibleKeys, modelVisibilityKey, + setProviderVisibility, setVisibleModels, toggleModelVisibility } from '@/store/model-visibility' +import { $collapsedProviders, toggleCollapsedProvider } from '@/store/provider-collapse' import type { ModelOptionProvider, ModelOptionsResponse } from '@/types/hermes' interface ModelVisibilityDialogProps { @@ -42,6 +47,7 @@ export function ModelVisibilityDialog({ const copy = t.modelVisibility const [search, setSearch] = useState('') const stored = useStore($visibleModels) + const collapsedProviders = useStore($collapsedProviders) const modelOptions = useQuery({ queryKey: modelOptionsQueryKey(profile, sessionId), @@ -60,6 +66,10 @@ export function ModelVisibilityDialog({ setVisibleModels(toggleModelVisibility($visibleModels.get(), providers, provider.slug, model)) } + const toggleProvider = (provider: ModelOptionProvider, next: boolean) => { + setVisibleModels(setProviderVisibility($visibleModels.get(), providers, provider.slug, next)) + } + const q = normalize(search) const matches = (provider: ModelOptionProvider, model: string) => @@ -72,7 +82,8 @@ export function ModelVisibilityDialog({ {copy.title} -
+
+ + visible.has(modelVisibilityKey(provider.slug, family.id)) + ).length + const checkState = onCount === 0 ? false : onCount === allFamilies.length ? true : 'indeterminate' + + const collapsed = collapsedProviders.includes(provider.slug) && !q + return (
-
- {provider.name} +
+ + toggleProvider(provider, next !== false)} />
- {models.map(family => { - const { name, tag } = modelDisplayParts(family.id) - const key = modelVisibilityKey(provider.slug, family.id) + {!collapsed && + models.map(family => { + const { name, tag } = modelDisplayParts(family.id) + const key = modelVisibilityKey(provider.slug, family.id) - return ( - - ) - })} + return ( + + ) + })}
) }) diff --git a/apps/desktop/src/components/ui/checkbox.tsx b/apps/desktop/src/components/ui/checkbox.tsx index 2e6b24256b25..357560fd2cb8 100644 --- a/apps/desktop/src/components/ui/checkbox.tsx +++ b/apps/desktop/src/components/ui/checkbox.tsx @@ -8,7 +8,7 @@ function Checkbox({ className, ...props }: React.ComponentProps - + + ) diff --git a/apps/desktop/src/store/model-visibility.test.ts b/apps/desktop/src/store/model-visibility.test.ts index 042e2d4279ca..a63aede8aaf4 100644 --- a/apps/desktop/src/store/model-visibility.test.ts +++ b/apps/desktop/src/store/model-visibility.test.ts @@ -10,6 +10,7 @@ import { isProviderSentinel, modelVisibilityKey, resolveVisibleKeys, + setProviderVisibility, toggleModelVisibility } from './model-visibility' @@ -224,3 +225,105 @@ describe('resolveVisibleKeys', () => { expect([...resolveVisibleKeys(new Set(), providers)]).toEqual([]) }) }) + +describe('featured defaults', () => { + const featuredProvider = (slug: string, models: string[], featured_models: string[]): ModelOptionProvider => ({ + featured_models, + models, + name: slug, + slug + }) + + it('defaults to the featured shortlist when a provider publishes one', () => { + const nous = featuredProvider( + 'nous', + ['anthropic/opus', 'anthropic/haiku', 'google/gemini', 'x-ai/grok'], + ['anthropic/opus', 'google/gemini', 'x-ai/grok'] + ) + + const visible = defaultVisibleKeys([nous]) + + // Featured are visible; the non-featured model is hidden by default. + expect(visible.has(modelVisibilityKey('nous', 'anthropic/opus'))).toBe(true) + expect(visible.has(modelVisibilityKey('nous', 'google/gemini'))).toBe(true) + expect(visible.has(modelVisibilityKey('nous', 'x-ai/grok'))).toBe(true) + expect(visible.has(modelVisibilityKey('nous', 'anthropic/haiku'))).toBe(false) + }) + + it('falls back to top-N when a provider ships no featured list', () => { + const plain = provider('ollama', ['qwen3:latest', 'llama3.2:latest']) + + const visible = defaultVisibleKeys([plain]) + + // No featured_models → every model stays a default (top-N, N ≫ 2 here). + expect(visible.has(modelVisibilityKey('ollama', 'qwen3:latest'))).toBe(true) + expect(visible.has(modelVisibilityKey('ollama', 'llama3.2:latest'))).toBe(true) + }) + + it('ignores an empty featured list and falls back to top-N', () => { + const plain = featuredProvider('ollama', ['qwen3:latest', 'llama3.2:latest'], []) + + const visible = defaultVisibleKeys([plain]) + + expect(visible.has(modelVisibilityKey('ollama', 'qwen3:latest'))).toBe(true) + expect(visible.has(modelVisibilityKey('ollama', 'llama3.2:latest'))).toBe(true) + }) +}) + +describe('setProviderVisibility', () => { + const providers = [provider('openai', ['gpt-a', 'gpt-b']), provider('nous', ['hermes-x', 'hermes-y'])] + + it('enabling a provider makes every one of its models visible', () => { + // Start from a hidden-all openai; flip it on. + const stored = new Set([emptyProviderSentinelKey('openai')]) + + const next = setProviderVisibility(stored, providers, 'openai', true) + + const visible = effectiveVisibleKeys(next, providers) + expect(visible.has(modelVisibilityKey('openai', 'gpt-a'))).toBe(true) + expect(visible.has(modelVisibilityKey('openai', 'gpt-b'))).toBe(true) + // Sentinel is cleared. + expect(next.has(emptyProviderSentinelKey('openai'))).toBe(false) + }) + + it('disabling a provider hides all its models and records the sentinel', () => { + const next = setProviderVisibility(null, providers, 'openai', false) + + expect(next.has(emptyProviderSentinelKey('openai'))).toBe(true) + const visible = effectiveVisibleKeys(next, providers) + expect(visible.has(modelVisibilityKey('openai', 'gpt-a'))).toBe(false) + expect(visible.has(modelVisibilityKey('openai', 'gpt-b'))).toBe(false) + }) + + it('leaves other providers untouched (their sentinels survive)', () => { + const stored = new Set([emptyProviderSentinelKey('nous')]) + + // Turn openai fully on; nous must stay hidden. + const next = setProviderVisibility(stored, providers, 'openai', true) + + expect(next.has(emptyProviderSentinelKey('nous'))).toBe(true) + const visible = effectiveVisibleKeys(next, providers) + expect(visible.has(modelVisibilityKey('nous', 'hermes-x'))).toBe(false) + expect(visible.has(modelVisibilityKey('openai', 'gpt-a'))).toBe(true) + }) + + it('round-trips: enable then disable returns to a clean hidden-all', () => { + const enabled = setProviderVisibility(null, providers, 'openai', true) + const disabled = setProviderVisibility(enabled, providers, 'openai', false) + + expect(disabled.has(emptyProviderSentinelKey('openai'))).toBe(true) + // No stray real keys left for the provider. + expect([...disabled].some(k => k.startsWith('openai::') && !isProviderSentinel(k))).toBe(false) + }) + + it('collapses model families to one key per family when enabling', () => { + // A base + its -fast sibling collapse to a single family row/key. + const ps = [provider('nous', ['model', 'model-fast'])] + + const next = setProviderVisibility(null, ps, 'nous', true) + + expect(next.has(modelVisibilityKey('nous', 'model'))).toBe(true) + // The -fast sibling is represented by its base family, not its own key. + expect(next.has(modelVisibilityKey('nous', 'model-fast'))).toBe(false) + }) +}) diff --git a/apps/desktop/src/store/model-visibility.ts b/apps/desktop/src/store/model-visibility.ts index a6fd9a40a18f..0fd02dcbac65 100644 --- a/apps/desktop/src/store/model-visibility.ts +++ b/apps/desktop/src/store/model-visibility.ts @@ -111,13 +111,21 @@ export function defaultVisibleKeys(providers: readonly ModelOptionProvider[]): S return keys } -/** Add a provider's curated default model keys (top-N collapsed families) to - * `target`. Shared by `defaultVisibleKeys` and `resolveVisibleKeys` so the +/** Add a provider's curated default model keys to `target`. Prefers the + * backend's `featured_models` shortlist (one flagship per lab) for aggregator + * providers that would otherwise flood the default view with dozens of models; + * falls back to the top-N collapsed families when a provider ships no featured + * list. Shared by `defaultVisibleKeys` and `resolveVisibleKeys` so the * expansion rule lives in exactly one place. */ function expandProviderDefaults(provider: ModelOptionProvider, target: Set): void { const families = collapseModelFamilies(provider.models ?? []) - for (const family of families.slice(0, DEFAULT_VISIBLE_PER_PROVIDER)) { + const featured = provider.featured_models ?? [] + const defaults = featured.length + ? families.filter(family => featured.includes(family.id)) + : families.slice(0, DEFAULT_VISIBLE_PER_PROVIDER) + + for (const family of defaults) { target.add(modelVisibilityKey(provider.slug, family.id)) } } @@ -209,3 +217,45 @@ export function toggleModelVisibility( return next } + +/** Compute the next persisted visibility set when a provider's master switch is + * flipped. `visible=true` enables every one of the provider's collapsed model + * families (and clears its hide-all sentinel); `visible=false` removes them all + * and records the sentinel so the defaults are not silently re-expanded. + * Seeds from `resolveVisibleKeys` so other providers' state (including their + * sentinels) survives the persist, mirroring `toggleModelVisibility`. */ +export function setProviderVisibility( + stored: Set | null, + providers: readonly ModelOptionProvider[], + providerSlug: string, + visible: boolean +): Set { + const next = resolveVisibleKeys(stored, providers) + const sentinel = emptyProviderSentinelKey(providerSlug) + const provider = providers.find(p => p.slug === providerSlug) + const families = collapseModelFamilies(provider?.models ?? []) + + // Drop every existing entry for this provider (real keys + sentinel); we + // rebuild its state from scratch below. + for (const key of [...next]) { + if (key.startsWith(`${providerSlug}::`)) { + next.delete(key) + } + } + + if (visible) { + for (const family of families) { + next.add(modelVisibilityKey(providerSlug, family.id)) + } + + // A provider with zero models can't be "all on" — leave it empty rather + // than stranding a sentinel that reads as an explicit hide-all. + if (families.length === 0) { + next.delete(sentinel) + } + } else { + next.add(sentinel) + } + + return next +} diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts index 6d0b0992576b..accbe39d999d 100644 --- a/apps/desktop/src/types/hermes.ts +++ b/apps/desktop/src/types/hermes.ts @@ -363,6 +363,11 @@ export interface ModelOptionProvider { slug: string total_models?: number warning?: string + /** Curated shortlist (one flagship per lab) the picker shows by default for + * aggregator providers that serve dozens of models across many labs. Empty + * for providers with no manifest entry — the picker falls back to top-N. + * The rest of `models` stays reachable via search / Edit Models. */ + featured_models?: string[] /** True when the provider has usable credentials. False for canonical * providers surfaced by `include_unconfigured` that the user hasn't set up * yet — render these with a setup affordance instead of hiding them. */ diff --git a/hermes_cli/inventory.py b/hermes_cli/inventory.py index e1b9a6616666..4e95665d481f 100644 --- a/hermes_cli/inventory.py +++ b/hermes_cli/inventory.py @@ -120,6 +120,7 @@ def build_models_payload( canonical_order: bool = False, pricing: bool = False, capabilities: bool = False, + featured: bool = False, force_fresh_nous_tier: bool = False, refresh: bool = False, probe_custom_providers: bool = True, @@ -152,6 +153,13 @@ def build_models_payload( ``{model: {fast, reasoning}}`` so pickers can gate the model-options controls (fast toggle / reasoning) to what each model actually supports, instead of offering knobs the backend would reject. + - ``featured``: add a per-row ``featured_models`` list — the newest few + models per lab (by models.dev release_date, ranked within the row's own + models; see ``_FEATURED_PER_LAB``) for aggregator providers that serve + dozens of models across many labs. Pickers default their visible set to + these; the rest of ``models`` stays reachable via search / show-all. Empty + for single-lab providers (callers fall back to top-N). Derived live from + models.dev — no allowlist. - ``force_fresh_nous_tier``: bypass the short Nous free-tier cache when selecting Portal-recommended Nous models and applying tier gating. Keep this false for UI picker opens; explicit auth/model flows can opt in @@ -262,6 +270,8 @@ def build_models_payload( _apply_pricing(rows, force_fresh_nous_tier=force_fresh_nous_tier) if capabilities: _apply_capabilities(rows) + if featured: + _apply_featured(rows) return { "providers": rows, @@ -296,6 +306,7 @@ def build_model_options_payload( canonical_order=True, pricing=True, capabilities=True, + featured=True, refresh=refresh, probe_custom_providers=refresh, probe_current_custom_provider=not refresh, @@ -428,6 +439,72 @@ def _apply_capabilities(rows: list[dict]) -> None: row["capabilities"] = caps +# How many models per lab the picker features by default. Aggregator rows keep +# the newest N of each lab (by models.dev release_date) and hide the older tail +# behind search / show-all. 5 keeps a lab's current headliners without letting a +# prolific vendor (OpenAI's gpt-5.6-* family) flood the default view. +_FEATURED_PER_LAB = 5 + + +def _apply_featured(rows: list[dict]) -> None: + """Attach a ``featured_models`` shortlist to each aggregator provider row. + + Aggregator providers (nous, openrouter) serve dozens of models across many + labs, so a flat "top-N" default would drop whole labs from the picker. + Instead we surface the ``_FEATURED_PER_LAB`` newest models per lab (the + vendor segment of a ``vendor/model`` id), ranked by models.dev + ``release_date`` among that row's OWN models — never against the current + date, so the choice is stable as models age. Same-date ties (and labs whose + models lack a date) fall back to the row's curated order, which is already + flagship-first, so a lab keeps its headliners rather than an arbitrary slice. + + Derived live from the models.dev catalog already loaded on this path (same + source as pricing/capabilities) — there is no hand-maintained allowlist to + keep in sync. Non-aggregator providers (a single lab, local endpoints, + custom proxies) get an empty list and callers fall back to their existing + top-N behaviour; splitting one lab into a shortlist would just hide models. + """ + try: + from agent.models_dev import get_model_info + except Exception: + get_model_info = None # type: ignore[assignment] + + for row in rows: + slug = str(row.get("slug") or "").strip().lower() + models = row.get("models") or [] + + # Group models by lab; only multi-lab aggregators get a shortlist. + by_lab: dict[str, list[tuple[int, str, str]]] = {} + for pos, model in enumerate(models): + lab = model.split("/", 1)[0] if "/" in model else "" + if not lab: + # No vendor prefix → single-namespace provider, not an + # aggregator. Bail on the whole row (see below). + by_lab = {} + break + date = "" + if get_model_info is not None: + info = get_model_info(slug, model) or get_model_info("openrouter", model) + date = getattr(info, "release_date", "") if info else "" + by_lab.setdefault(lab, []).append((pos, date, model)) + + # A shortlist only makes sense when the row spans several labs. + if len(by_lab) < 2: + row["featured_models"] = [] + continue + + featured: list[str] = [] + for entries in by_lab.values(): + # Newest release_date first; earlier list position breaks ties and + # is the sole key when a lab has no dated models (all ""). Keep the + # newest _FEATURED_PER_LAB of each lab. + ranked = sorted(entries, key=lambda e: (e[1], -e[0]), reverse=True) + featured.extend(model for _pos, _date, model in ranked[:_FEATURED_PER_LAB]) + # Preserve the row's model order for stable rendering. + order = {m: i for i, m in enumerate(models)} + row["featured_models"] = sorted(featured, key=lambda m: order[m]) + + # ─── Internal: row post-processing ────────────────────────────────────── diff --git a/tests/hermes_cli/test_inventory.py b/tests/hermes_cli/test_inventory.py index 6c262180b9d8..0a1314c426c3 100644 --- a/tests/hermes_cli/test_inventory.py +++ b/tests/hermes_cli/test_inventory.py @@ -1031,3 +1031,125 @@ def test_list_authenticated_providers_refresh_busts_cache(): assert clear.call_count == 0 model_switch.list_authenticated_providers(refresh=True) assert clear.call_count == 1 + + +# ─── _apply_featured (one-flagship-per-lab shortlist) ────────────────── + + +class _FakeInfo: + def __init__(self, release_date: str) -> None: + self.release_date = release_date + + +def _apply_featured_with_dates(rows, dates: dict[str, str]): + """Run _apply_featured with a deterministic models.dev stub.""" + from hermes_cli import inventory + + def _fake_get_model_info(provider, model): + return _FakeInfo(dates[model]) if model in dates else None + + with patch("agent.models_dev.get_model_info", side_effect=_fake_get_model_info): + inventory._apply_featured(rows) + + +def test_apply_featured_keeps_newest_n_per_lab(): + """Each lab keeps its newest _FEATURED_PER_LAB models by release_date; the + older tail is dropped. Uses a lab with more than N models to exercise the + cut.""" + from hermes_cli.inventory import _FEATURED_PER_LAB + + # One lab ("a") with N+2 dated models, plus a second lab so the row counts + # as a multi-lab aggregator. + a_models = [f"a/m{i}" for i in range(_FEATURED_PER_LAB + 2)] + rows = [{"slug": "nous", "models": [*a_models, "b/solo"]}] + # m0 newest … m{N+1} oldest (descending dates), b/solo dated in the middle. + dates = {f"a/m{i}": f"2026-{12 - i:02d}-01" for i in range(_FEATURED_PER_LAB + 2)} + dates["b/solo"] = "2026-01-01" + _apply_featured_with_dates(rows, dates) + + featured = rows[0]["featured_models"] + # Lab "a" keeps its newest N (m0..m{N-1}); the two oldest drop. "b" keeps its one. + assert featured == [*a_models[:_FEATURED_PER_LAB], "b/solo"] + assert f"a/m{_FEATURED_PER_LAB}" not in featured + assert f"a/m{_FEATURED_PER_LAB + 1}" not in featured + + +def test_apply_featured_keeps_whole_lab_when_under_the_cap(): + """A lab with <= _FEATURED_PER_LAB models keeps all of them.""" + rows = [ + { + "slug": "nous", + "models": ["anthropic/opus", "anthropic/haiku", "google/gemini"], + } + ] + _apply_featured_with_dates( + rows, + { + "anthropic/opus": "2026-07-01", + "anthropic/haiku": "2026-03-01", + "google/gemini": "2026-05-01", + }, + ) + # Both anthropic models (2 <= 5) and the single google model survive, in + # the row's original order. + assert rows[0]["featured_models"] == [ + "anthropic/opus", + "anthropic/haiku", + "google/gemini", + ] + + +def test_apply_featured_ranks_within_list_not_against_now(): + """The kept models are the newest *in the list*, even if every model is old + — the current date never enters the comparison.""" + rows = [{"slug": "nous", "models": ["a/one", "a/two", "b/three"]}] + _apply_featured_with_dates( + rows, + {"a/one": "2019-01-01", "a/two": "2020-01-01", "b/three": "2018-06-01"}, + ) + # Both "a" models kept (2 <= 5), ordered by the row; "b" kept. + assert rows[0]["featured_models"] == ["a/one", "a/two", "b/three"] + + +def test_apply_featured_tie_breaks_on_list_order(): + """Same release_date within a lab falls back to curated (earliest) order + when the cut has to choose.""" + from hermes_cli.inventory import _FEATURED_PER_LAB + + # N+1 same-dated models in lab "x" so exactly one must be dropped; the LAST + # one in list order loses the tie. + x_models = [f"x/m{i}" for i in range(_FEATURED_PER_LAB + 1)] + rows = [{"slug": "nous", "models": [*x_models, "y/solo"]}] + dates = {m: "2026-07-09" for m in x_models} + dates["y/solo"] = "2026-01-01" + _apply_featured_with_dates(rows, dates) + + featured = rows[0]["featured_models"] + # Earliest N in list order survive the tie; the last is dropped. + assert featured == [*x_models[:_FEATURED_PER_LAB], "y/solo"] + assert x_models[_FEATURED_PER_LAB] not in featured + + +def test_apply_featured_undated_lab_falls_back_to_list_order(): + """A lab whose models have no models.dev date keeps them in list order + (undated sorts last, ties broken by position), up to the per-lab cap.""" + rows = [{"slug": "nous", "models": ["a/first", "a/second", "b/only"]}] + _apply_featured_with_dates(rows, {"b/only": "2026-01-01"}) # a/* undated + # Both undated "a" models kept (2 <= 5), in list order; "b" kept. + assert rows[0]["featured_models"] == ["a/first", "a/second", "b/only"] + + +def test_apply_featured_empty_for_single_lab_provider(): + """A provider serving one lab is not an aggregator — no shortlist, so the + caller falls back to top-N instead of hiding models.""" + rows = [{"slug": "deepseek", "models": ["deepseek-v4-pro", "deepseek-v4-flash"]}] + _apply_featured_with_dates(rows, {}) + assert rows[0]["featured_models"] == [] + + +def test_apply_featured_empty_for_prefixless_models(): + """Models with no vendor/ prefix (ollama, custom endpoints) get no + shortlist — there are no labs to split on.""" + rows = [{"slug": "ollama", "models": ["qwen3:latest", "llama3.2:latest"]}] + _apply_featured_with_dates(rows, {}) + assert rows[0]["featured_models"] == []