Workstream B — Models (2026-09-11)
Model picker, badges, profile, catalog, public SEO pages, capability-aware settings and presets.
Everything derives from real registry data (PolyModel); nothing is invented — unknown release dates and
cutoffs render as "—".
Files
New — pure libraries (src/lib/models/*, client- and server-safe, unit-tested)
| File | Exports |
|---|---|
badges.ts |
buildBadgeContext(models, now) (pricing quantiles, "new" window), deriveBadges(model, allModels | ctx), lifecycleStatus(model, {connected, ctx, now}), isFastModel/isCheapModel/isCodingModel/isOpenWeightsModel/isLongContextModel/isNewModel, speedTier, blendedPrice, shutdownDateOf, retiresSoon, sortWeightOf, regexes FAST_RE/FRONTIER_RE/CODING_RE/OPEN_WEIGHTS_RE |
search.ts |
parseSearchQuery(q) → SearchIntent (brands, capabilities, min context, max $/M, open weights, coding, newest, quality, sort, chips), searchModels(models, q | intent, ctx) → SearchResult[] (hard filters + ranked text/intent score), isEmptyIntent |
slug.ts |
slugify, modelSlug, compareSlug, parseCompareSlug, findModelBySlug, resolveCompareSlug, MAX_COMPARE |
params.ts |
PARAM_DEFS (17 unified settings × group × supports(model) × detail), SETTINGS_GROUPS, groupsFor, supportsParam, isSetValue, unsupportedSettings, presetCompatibility, presetToSettings, pruneSettingsForModel |
sections.ts |
buildSelectorSections (Favorites, Recent, Recommended, Fast, Best reasoning, Cheapest, Largest context, Vision, Coding, Open source, New) and recommendedModels (wraps routeModels) |
format.ts |
formatPrice, formatPricePair, formatContext, formatIsoDate |
public-data.ts |
server-only: getPublicModels() (React cache, graceful ok:false when DB is down), flagshipModels, popularComparisons |
New — components
src/components/models/model-profile.tsx—ModelProfile(ResponsiveDialog, snap full): header + user label (set / remove), lifecycle & derived badges, 12 facts (context, max out, in/out/cached $, long-context tier, speed tier, release date, knowledge cutoff, provider listing date, first seen, prices as of), capability grid, supported parameters grouped with ranges/levels, lifecycle block, aliases/source. Footer: Compare (/app/models/compare?m=), Arena (/app/arena?models=key), Use in chat (or Connect).src/components/models/compare-table.tsx—CompareTable(server-safe, no hooks): header cards, spec rows with meters (context, max out, input/output $), capability matrix, parameter matrix. Desktop grid / phone stacked cards + compact check grid.src/components/models/meter.tsx— single-hue 4 px magnitude bar (values always printed beside it).src/components/models/badge-chips.tsx—BadgeChips,LifecycleChip(presentational, shared by app and public pages).
Rewritten
src/components/chat/model-selector.tsx— same export/props (value, onChange, className, size, allowDisconnected, multiple, selected, onToggle, buttonLabel) +showAuto?,trigger?,draft?. Phone: BottomSheet snap full with fixed search header; desktop: dialog (680 px). Sections + "All models" by provider, always virtualized (@tanstack/react-virtual, rows 56 px, dynamic measure). AUTO card with router modeSegmented(persistedlocalStorage["polyllm:router-mode"]throughuseLocalStorage→ JSON string, e.g."balanced"). Intent search with "Understood:" chips. Rows: provider icon, name + accent label, lifecycle chip, badges, ctx · $in/$out, capability glyphs, star (animate-pop), "…" menu (desktop DropdownMenu / phone long-press or "…" → ActionSheet): Favorite, Set/Change/Remove label (PromptDialog →store.setLabel), View profile, Compare. Keyboard ↑↓↵ (combobox pattern,aria-activedescendant). ⌘/ toggles.src/components/chat/model-badges.tsx—CAPABILITY_GLYPHS,CapabilityGlyphs,ModelBadges(NEW/REASONING/VISION/CODING/FAST/CHEAP/LONG CONTEXT),StatusBadge({ model, connected, ctx, showActive })(New / Active / Preview / Deprecated / Retiring < 90 d / Unavailable / Unverified),useBadgeContext(); keptcapabilityList,CapabilityBadges,PriceLabel,isFastModel,retiresSoonfor existing callers (empty-state, arena/types).src/components/chat/model-config.tsx— same props (model, settings, onChange, systemPrompt, onSystemPromptChange, trigger, allowSavePreset). ResponsiveDialog: phone full sheet, desktop right panel. Collapsible groups Generation / Reasoning / Output / Tools / Advanced built fromgroupsFor(model); sliders with numeric inputs + per-control reset; Segmented for small enums; Reset to defaults; Save preset (PromptDialog →POST /api/presets?kind=model); Apply preset (sheet listing presets, compatible first; incompatible ones list every reason frompresetCompatibility).countActiveSettingskept.src/app/app/models/page.tsx— catalog: mobile 48 px bar (hamburger, refresh), intent search, ChipRow filters (Reasoning, Vision, Tools, JSON, Web, PDF, Fast, Cheap, Open source, New), provider chips, status Segmented (Deprecated fetches/api/models?deprecated=1), Connected only / Favorites switches, sortable columns (Model, Provider, Input, Output, Context, Max out, Reasoning, Vision, Tools, Speed tier, Status;aria-sort), phone sort Select, virtualized rows (stacked belowmd, grid table frommd, extra columns fromlg), inline price/context meters, select up to 4 → compare bar →/app/models/compare?m=a,b,c, row →ModelProfile. Sync/refresh + favorites unchanged.src/app/app/presets/page.tsx— editor moved toResponsiveDialog(sheet on phones), pruning now usespruneSettingsForModel(effort-level aware).components/presets/*unchanged.
New — pages
src/app/app/models/compare/page.tsx— arbitrary sets (?m=key1,key2[,…], max 4), add/remove viaModelSelector multiple allowDisconnected,CompareTable, "Try in Arena".src/app/(marketing)/models/page.tsx— public catalog (server component,force-dynamic, no auth): per-provider tables (desktop) / rows (mobile), badges, lifecycle, popular comparisons, sign-up CTA, honest empty state when the registry is unreachable.src/app/(marketing)/compare/[slug]/page.tsx—gpt-5.5-vs-claude-sonnet-5(2–4 parts, ids or display names, provider-prefixed ids for gateways),generateMetadata(title/description/canonical/OG),notFound()on unknown/duplicate,CompareTable, related comparisons, "Try both in Arena" →/app/arena?models=….
Additive
src/lib/ai/registry/index.ts—rowToModelnow emitsmetadata.firstSeenAt(ISO) next tolastSeenAt; flows throughGET /api/modelsandlistRegistryModels()unchanged.
Tests (tests/unit/)
models-search.test.ts (intent parsing + ranking, 11), models-badges.test.ts (quantile badges, lifecycle, parameter sheet/presets, 9), models-slug.test.ts (4). pnpm test → 125/125.
API changes
GET /api/models— additive: every model'smetadata.firstSeenAt(ISO string). No new routes.- Presets: reuses
POST /api/presets?kind=model{ name, modelKey, systemPrompt, parameters, tools:{builtin} }from the settings panel.
Integration notes for other areas
- A (chat): pass
showAuto(and optionallydraft) toModelSelectoronce the composer handlesAUTO_MODEL_KEY;triggerlets the active-model pill open the picker. Router mode is inlocalStorage["polyllm:router-mode"](JSON string viauseLocalStorage; exportROUTER_MODE_KEYfrommodel-selector.tsx). "Compare" actions navigate to/app/models/compare?m=…; profile "Arena" links to/app/arena?models=key— C should read?models=(comma-separated keys). - F (sitemap): add
/modelsand the popular/compare/<slug>pages (popularComparisons()inlib/models/public-data.ts) toapp/sitemap.ts; consider linking "Models" in the marketing header/footer. - E (custom endpoints):
lib/models/*never branches on provider ids exceptparams.tscode-execution (OpenAI/Anthropic/Gemini, mirroring the previous UI) — setmetadata.codeExecutionexplicitly on custom models to override.
Heuristics worth knowing
- CHEAP = blended (input + output $/1M) ≤ 25th percentile of the models passed in; FAST = fast-tier name regex or output price ≤ min($2, 35th percentile); LONG CONTEXT ≥ 400K; CODING =
metadata.codingor codestral/coder/devstral/codex/"code"; OPEN SOURCE =metadata.openWeights/openSourceor a vendor/name regex (llama, qwen, gemma, gpt-oss, deepseek, kimi…). - NEW =
metadata.firstSeenAtwithin 30 days unless more than half the registry is that recent (fresh registry ⇒ falls back to providercreatedAt/releaseDateonly). - Lifecycle priority: Retiring (shutdown < 90 d) > Deprecated > Unavailable (no key) > Preview > New > Unverified > Active.
Coming soon / not done
- No
<ComingSoon />placeholders were needed; every control works. /models/compare?m=outside the app is not a route (the public URL form is/compare/[slug]; the app form is/app/models/compare?m=).- Public pages show list prices from the last catalog audit; there is no per-user pricing.
Visual QA checklist (375 / 390 / 393 / 430 / 1440)
- Chat header → model pill → sheet opens full height, search input fixed, keyboard does not cover the list; scroll the list, pull down while scrolled (must not dismiss), pull down at top (dismisses). Desktop: ⌘/ opens dialog, input focused, ↑↓↵ pick, Esc closes.
- Type "cheap vision", "1M context", "under $1/M", "fastest gemini", "json schema", "open source", "sonet": "Understood" chips appear, results ranked; empty state text on nonsense.
- Sections: Favorites / Recent appear only when non-empty; "Show all N" expands; "All models" grouped by provider; badges and status chips do not wrap rows (row stays 56 px).
- Star toggles with pop animation; long-press a row (touch) → ActionSheet; "…" on desktop → menu; Set label → accent label shows in row, trigger pill and profile; Remove label works.
- AUTO card (with
showAuto): Segmented modes scroll horizontally on phones, choice persists after reload; "Right now → model · reason" updates with mode. - Profile sheet: facts show "—" for missing release date / cutoff; capability grid, parameter groups, footer buttons on one row at 375 px (labels truncate, not overflow).
- Settings panel: phone sheet / desktop right panel; groups collapse; slider + numeric input stay in sync; Reset disabled at 0 settings; Save preset creates a row (check
/app/presets); Apply preset lists incompatible reasons and disables Apply. - Catalog: no horizontal overflow at 375; filters toggle; sort select on phone; desktop headers sort with arrows; select 2–4 → compare bar above bottom nav → compare page; Deprecated status loads extra models.
/app/models/compare?m=openai/gpt-5.5,anthropic/claude-sonnet-5: chips, add via picker (max 4), spec cards stacked on phone, grid on desktop, meters proportional.- Public
/modelsand/compare/gpt-5.5-vs-claude-sonnet-5unauthenticated: header/footer from the marketing layout, metadata title/description, 404 on/compare/foo-vs-bar, CTAs go to/signupand/app/arena?models=…. - Dark mode and
prefers-reduced-motion(pop animation disabled by the global rule) on all of the above.