fix(desktop): GUI model picker shows curated Nous list in curated order

Two bugs made the GUI Nous model list diverge from the `hermes model` CLI picker:

1. Backend (model_switch.py): the Nous row in list_authenticated_providers
   fell through to cached_provider_model_ids("nous"), dumping the full live
   /v1/models catalog (~50 vendor-prefixed models, alphabetical). Now it uses
   the curated list AND applies the Portal free/paid recommendation union —
   exactly like _model_flow_nous in main.py — so newly-launched models such as
   stepfun/step-3.7-flash:free surface in curated order. Best-effort: falls
   back to the curated list alone if the Portal fetch fails.

2. Frontend (model-picker.tsx): cmdk's Command had shouldFilter on (default),
   which re-sorts items by fuzzy-match score (≈alphabetical) and ignores array
   order. Set shouldFilter={false} + own the search term and do an
   order-preserving substring filter, so the backend's curated order is shown
   verbatim.
This commit is contained in:
emozilla 2026-05-31 06:17:18 -04:00
parent 3c04527e1e
commit ac2e489074
2 changed files with 64 additions and 5 deletions

View file

@ -42,6 +42,12 @@ export function ModelPickerDialog({
contentClassName contentClassName
}: ModelPickerDialogProps) { }: ModelPickerDialogProps) {
const [persistGlobal, setPersistGlobal] = useState(!sessionId) const [persistGlobal, setPersistGlobal] = useState(!sessionId)
// Own the search term so we can filter manually. cmdk's built-in
// shouldFilter reorders items by its fuzzy-match score (≈alphabetical with
// an empty query), which destroys the backend's curated order. We disable
// it and do a plain substring filter that preserves array order — matching
// the `hermes model` CLI picker, which shows the curated list verbatim.
const [search, setSearch] = useState('')
const modelOptions = useQuery({ const modelOptions = useQuery({
queryKey: ['model-options', sessionId || 'global'], queryKey: ['model-options', sessionId || 'global'],
@ -88,8 +94,13 @@ export function ModelPickerDialog({
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<Command className="rounded-none bg-card"> <Command className="rounded-none bg-card" shouldFilter={false}>
<CommandInput autoFocus placeholder="Filter providers and models..." /> <CommandInput
autoFocus
onValueChange={setSearch}
placeholder="Filter providers and models..."
value={search}
/>
<CommandList className="max-h-96"> <CommandList className="max-h-96">
{!loading && !error && <CommandEmpty>No models found.</CommandEmpty>} {!loading && !error && <CommandEmpty>No models found.</CommandEmpty>}
<ModelResults <ModelResults
@ -99,6 +110,7 @@ export function ModelPickerDialog({
loading={loading} loading={loading}
onSelectModel={selectModel} onSelectModel={selectModel}
providers={providers} providers={providers}
search={search}
/> />
</CommandList> </CommandList>
</Command> </Command>
@ -128,7 +140,8 @@ function ModelResults({
providers, providers,
currentModel, currentModel,
currentProvider, currentProvider,
onSelectModel onSelectModel,
search
}: { }: {
loading: boolean loading: boolean
error: string | null error: string | null
@ -136,6 +149,7 @@ function ModelResults({
currentModel: string currentModel: string
currentProvider: string currentProvider: string
onSelectModel: (provider: ModelOptionProvider, model: string) => void onSelectModel: (provider: ModelOptionProvider, model: string) => void
search: string
}) { }) {
if (loading) { if (loading) {
return <LoadingResults /> return <LoadingResults />
@ -155,10 +169,18 @@ function ModelResults({
return <div className="px-4 py-6 text-sm text-muted-foreground">No authenticated providers.</div> return <div className="px-4 py-6 text-sm text-muted-foreground">No authenticated providers.</div>
} }
const q = search.trim().toLowerCase()
const matches = (provider: ModelOptionProvider, model: string) =>
!q ||
model.toLowerCase().includes(q) ||
provider.name.toLowerCase().includes(q) ||
provider.slug.toLowerCase().includes(q)
return ( return (
<> <>
{providers.map(provider => { {providers.map(provider => {
const models = provider.models ?? [] // Preserve the backend's curated order — filter in place, no re-sort.
const models = (provider.models ?? []).filter(m => matches(provider, m))
if (models.length === 0) { if (models.length === 0) {
return null return null
@ -195,7 +217,7 @@ function ModelResults({
onSelectModel(provider, model) onSelectModel(provider, model)
} }
}} }}
value={`${provider.name} ${provider.slug} ${model}`} value={`${provider.slug}:${model}`}
> >
<span className="min-w-0 flex-1 truncate">{model}</span> <span className="min-w-0 flex-1 truncate">{model}</span>
{locked && <span className="shrink-0 text-[0.62rem] uppercase tracking-wide opacity-80">Pro</span>} {locked && <span className="shrink-0 text-[0.62rem] uppercase tracking-wide opacity-80">Pro</span>}

View file

@ -1359,6 +1359,43 @@ def list_authenticated_providers(
model_ids = _ids if _ids else (curated.get(hermes_slug, []) or curated.get(pid, [])) model_ids = _ids if _ids else (curated.get(hermes_slug, []) or curated.get(pid, []))
except Exception: except Exception:
model_ids = curated.get(hermes_slug, []) or curated.get(pid, []) model_ids = curated.get(hermes_slug, []) or curated.get(pid, [])
elif hermes_slug == "nous":
# Nous serves a large live /v1/models catalog (vendor-prefixed
# models from many providers, returned alphabetically). The
# `hermes model` picker deliberately shows ONLY the curated agentic
# list — augmented with the Portal's free/paid recommendations so
# newly-launched models surface without a CLI release — in curated
# order. Mirror that exactly (see _model_flow_nous in main.py) so
# the GUI picker matches the CLI. Was: falling through to
# cached_provider_model_ids, which dumped the full alphabetical
# catalog; then: curated-only, which dropped the 4 Portal
# recommendations (e.g. stepfun/step-3.7-flash:free).
model_ids = curated.get("nous", [])
try:
from hermes_cli.models import (
get_pricing_for_provider as _nous_pricing,
check_nous_free_tier as _nous_free,
union_with_portal_free_recommendations as _union_free,
union_with_portal_paid_recommendations as _union_paid,
)
from hermes_cli.auth import get_provider_auth_state as _nous_state
_pricing = _nous_pricing("nous") or {}
_portal = ""
try:
_st = _nous_state("nous") or {}
_portal = _st.get("portal_base_url", "") or ""
except Exception:
_portal = ""
if _nous_free(force_fresh=True):
model_ids, _ = _union_free(model_ids, _pricing, _portal)
else:
model_ids, _ = _union_paid(model_ids, _pricing, _portal)
except Exception:
# Portal recommendation fetch failed — fall back to the
# curated list alone (still correct, just may lag newly
# launched models, exactly like an offline CLI run).
pass
else: else:
# Unified pathway — see Section 1 rationale. Fall back to the # Unified pathway — see Section 1 rationale. Fall back to the
# curated dict (with models.dev merge for preferred providers) # curated dict (with models.dev merge for preferred providers)