TypeScript 97.4%
SQL 1%
JavaScript 0.9%
CSS 0.6%
1# Workstream B — Models (2026-09-11)23Model picker, badges, profile, catalog, public SEO pages, capability-aware settings and presets.4Everything derives from real registry data (`PolyModel`); nothing is invented — unknown release dates and5cutoffs render as "—".67## Files89### New — pure libraries (`src/lib/models/*`, client- and server-safe, unit-tested)10| File | Exports |11| --- | --- |12| `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` |13| `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` |14| `slug.ts` | `slugify`, `modelSlug`, `compareSlug`, `parseCompareSlug`, `findModelBySlug`, `resolveCompareSlug`, `MAX_COMPARE` |15| `params.ts` | `PARAM_DEFS` (17 unified settings × group × `supports(model)` × detail), `SETTINGS_GROUPS`, `groupsFor`, `supportsParam`, `isSetValue`, `unsupportedSettings`, `presetCompatibility`, `presetToSettings`, `pruneSettingsForModel` |16| `sections.ts` | `buildSelectorSections` (Favorites, Recent, Recommended, Fast, Best reasoning, Cheapest, Largest context, Vision, Coding, Open source, New) and `recommendedModels` (wraps `routeModels`) |17| `format.ts` | `formatPrice`, `formatPricePair`, `formatContext`, `formatIsoDate` |18| `public-data.ts` | `server-only`: `getPublicModels()` (React `cache`, graceful `ok:false` when DB is down), `flagshipModels`, `popularComparisons` |1920### New — components21- `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).22- `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.23- `src/components/models/meter.tsx` — single-hue 4 px magnitude bar (values always printed beside it).24- `src/components/models/badge-chips.tsx` — `BadgeChips`, `LifecycleChip` (presentational, shared by app and public pages).2526### Rewritten27- `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 mode `Segmented` (persisted `localStorage["polyllm:router-mode"]` through `useLocalStorage` → 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.28- `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()`; kept `capabilityList`, `CapabilityBadges`, `PriceLabel`, `isFastModel`, `retiresSoon` for existing callers (empty-state, arena/types).29- `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 from `groupsFor(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 from `presetCompatibility`). `countActiveSettings` kept.30- `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 below `md`, grid table from `md`, extra columns from `lg`), inline price/context meters, select up to 4 → compare bar → `/app/models/compare?m=a,b,c`, row → `ModelProfile`. Sync/refresh + favorites unchanged.31- `src/app/app/presets/page.tsx` — editor moved to `ResponsiveDialog` (sheet on phones), pruning now uses `pruneSettingsForModel` (effort-level aware). `components/presets/*` unchanged.3233### New — pages34- `src/app/app/models/compare/page.tsx` — arbitrary sets (`?m=key1,key2[,…]`, max 4), add/remove via `ModelSelector multiple allowDisconnected`, `CompareTable`, "Try in Arena".35- `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.36- `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=…`.3738### Additive39- `src/lib/ai/registry/index.ts` — `rowToModel` now emits `metadata.firstSeenAt` (ISO) next to `lastSeenAt`; flows through `GET /api/models` and `listRegistryModels()` unchanged.4041### Tests (`tests/unit/`)42`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.4344## API changes45- `GET /api/models` — **additive**: every model's `metadata.firstSeenAt` (ISO string). No new routes.46- Presets: reuses `POST /api/presets?kind=model` `{ name, modelKey, systemPrompt, parameters, tools:{builtin} }` from the settings panel.4748## Integration notes for other areas49- **A (chat):** pass `showAuto` (and optionally `draft`) to `ModelSelector` once the composer handles `AUTO_MODEL_KEY`; `trigger` lets the active-model pill open the picker. Router mode is in `localStorage["polyllm:router-mode"]` (JSON string via `useLocalStorage`; export `ROUTER_MODE_KEY` from `model-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).50- **F (sitemap):** add `/models` and the popular `/compare/<slug>` pages (`popularComparisons()` in `lib/models/public-data.ts`) to `app/sitemap.ts`; consider linking "Models" in the marketing header/footer.51- **E (custom endpoints):** `lib/models/*` never branches on provider ids except `params.ts` code-execution (OpenAI/Anthropic/Gemini, mirroring the previous UI) — set `metadata.codeExecution` explicitly on custom models to override.5253## Heuristics worth knowing54- 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.coding` or codestral/coder/devstral/codex/"code"; OPEN SOURCE = `metadata.openWeights`/`openSource` or a vendor/name regex (llama, qwen, gemma, gpt-oss, deepseek, kimi…).55- NEW = `metadata.firstSeenAt` within 30 days **unless** more than half the registry is that recent (fresh registry ⇒ falls back to provider `createdAt`/`releaseDate` only).56- Lifecycle priority: Retiring (shutdown < 90 d) > Deprecated > Unavailable (no key) > Preview > New > Unverified > Active.5758## Coming soon / not done59- No `<ComingSoon />` placeholders were needed; every control works.60- `/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=`).61- Public pages show list prices from the last catalog audit; there is no per-user pricing.6263## Visual QA checklist (375 / 390 / 393 / 430 / 1440)641. 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.652. 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.663. 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).674. 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.685. AUTO card (with `showAuto`): Segmented modes scroll horizontally on phones, choice persists after reload; "Right now → model · reason" updates with mode.696. 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).707. 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.718. 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.729. `/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.7310. Public `/models` and `/compare/gpt-5.5-vs-claude-sonnet-5` unauthenticated: header/footer from the marketing layout, metadata title/description, 404 on `/compare/foo-vs-bar`, CTAs go to `/signup` and `/app/arena?models=…`.7411. Dark mode and `prefers-reduced-motion` (pop animation disabled by the global rule) on all of the above.75