mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-29 18:46:59 +00:00
Merge pull request #73172 from NousResearch/bb/featured-models
feat(picker): curated model defaults + collapsible providers + select-all in Edit Models
This commit is contained in:
commit
dcc543af9a
9 changed files with 427 additions and 41 deletions
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<DropdownMenuGroup className="py-0.5" key={slug}>
|
||||
<DropdownMenuItem
|
||||
className={cn(dropdownMenuSectionLabel, 'cursor-pointer hover:bg-(--ui-control-active-background)')}
|
||||
className="group/label flex w-full items-center gap-1 px-2 pb-0.5 pt-0.5 text-[0.625rem] font-semibold uppercase tracking-wider text-(--ui-text-tertiary) cursor-pointer !bg-transparent focus:!bg-transparent"
|
||||
onSelect={event => {
|
||||
event.preventDefault()
|
||||
toggleCollapsedProvider(slug)
|
||||
}}
|
||||
textValue=""
|
||||
>
|
||||
{collapsed ? (
|
||||
<ChevronRight className="size-2.5 shrink-0" />
|
||||
) : (
|
||||
<ChevronDown className="size-2.5 shrink-0" />
|
||||
)}
|
||||
{group.provider.name}
|
||||
<span className="truncate">{group.provider.name}</span>
|
||||
<DisclosureCaret className="shrink-0 text-(--ui-text-tertiary) opacity-0 transition group-hover/label:opacity-100" open={!collapsed} size="0.625rem" />
|
||||
</DropdownMenuItem>
|
||||
{!collapsed &&
|
||||
group.families.map(family => {
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
<DialogTitle className="text-[0.8125rem]">{copy.title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="px-3 py-1.5">
|
||||
<div className="flex items-center gap-1.5 px-3 py-1.5">
|
||||
<Search className="pointer-events-none size-3.5 shrink-0 text-muted-foreground/70" />
|
||||
<input
|
||||
autoFocus
|
||||
className="h-5 w-full bg-transparent text-xs text-foreground placeholder:text-(--ui-text-tertiary) focus:outline-none"
|
||||
|
|
@ -96,32 +107,49 @@ export function ModelVisibilityDialog({
|
|||
return null
|
||||
}
|
||||
|
||||
const allFamilies = collapseModelFamilies(provider.models ?? [])
|
||||
const onCount = allFamilies.filter(family =>
|
||||
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 (
|
||||
<div className="py-0.5" key={provider.slug}>
|
||||
<div className="px-3 pb-0.5 pt-1 text-[0.625rem] font-medium uppercase tracking-wide text-(--ui-text-tertiary)">
|
||||
{provider.name}
|
||||
<div className="flex items-center gap-2 px-3 pb-0.5 pt-1">
|
||||
<button
|
||||
className="group/label flex w-full items-center gap-1 pb-0.5 pt-0.5 text-left text-[0.625rem] font-semibold uppercase tracking-wider text-(--ui-text-tertiary) hover:bg-transparent"
|
||||
onClick={() => toggleCollapsedProvider(provider.slug)}
|
||||
type="button"
|
||||
>
|
||||
<span className="min-w-0 truncate">{provider.name}</span>
|
||||
<DisclosureCaret
|
||||
className="shrink-0 opacity-0 transition group-hover/label:opacity-100"
|
||||
open={!collapsed}
|
||||
size="0.625rem"
|
||||
/>
|
||||
</button>
|
||||
<Checkbox checked={checkState} onCheckedChange={next => toggleProvider(provider, next !== false)} />
|
||||
</div>
|
||||
{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 (
|
||||
<label
|
||||
className="flex cursor-pointer items-center gap-2 px-3 py-1 text-xs hover:bg-accent/50"
|
||||
key={key}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{name}
|
||||
{tag ? <span className="text-(--ui-text-tertiary)"> {tag}</span> : null}
|
||||
</span>
|
||||
<Switch
|
||||
checked={visible.has(key)}
|
||||
onCheckedChange={() => toggle(provider, family.id)}
|
||||
size="xs"
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
return (
|
||||
<label
|
||||
className="flex cursor-pointer items-center gap-2 px-3 py-1 text-xs"
|
||||
key={key}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{name}
|
||||
{tag ? <span className="text-(--ui-text-tertiary)"> {tag}</span> : null}
|
||||
</span>
|
||||
<Switch checked={visible.has(key)} onCheckedChange={() => toggle(provider, family.id)} size="xs" />
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxP
|
|||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
className={cn(
|
||||
'peer size-4 shrink-0 rounded-sm border border-input shadow-xs outline-none transition-shadow focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40',
|
||||
'group peer size-4 shrink-0 rounded-sm border border-input shadow-xs outline-none transition-shadow focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=indeterminate]:border-primary data-[state=indeterminate]:bg-primary data-[state=indeterminate]:text-primary-foreground aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40',
|
||||
className
|
||||
)}
|
||||
data-slot="checkbox"
|
||||
|
|
@ -18,7 +18,8 @@ function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxP
|
|||
className="flex items-center justify-center text-current"
|
||||
data-slot="checkbox-indicator"
|
||||
>
|
||||
<Codicon name="check" size="0.875rem" />
|
||||
<Codicon className="hidden group-data-[state=checked]:block" name="check" size="0.875rem" />
|
||||
<Codicon className="hidden group-data-[state=indeterminate]:block" name="dash" size="0.875rem" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<string>): 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<string> | null,
|
||||
providers: readonly ModelOptionProvider[],
|
||||
providerSlug: string,
|
||||
visible: boolean
|
||||
): Set<string> {
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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 ──────────────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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"] == []
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue