diff --git a/src/hooks/runner.ts b/src/hooks/runner.ts index 0a54de0..139a4ae 100644 --- a/src/hooks/runner.ts +++ b/src/hooks/runner.ts @@ -139,13 +139,15 @@ export class HookEngine { } ); - try { - child.stdin?.write(JSON.stringify(input)); - child.stdin?.end(); - } catch { - // EPIPE from a handler that never reads stdin — fine, decision comes - // from its exit code / stdout. - } + child.stdin?.on('error', err => { + // EPIPE from a handler that exits before reading stdin is fine; the + // decision still comes from exit code / stdout. + if ((err as NodeJS.ErrnoException).code !== 'EPIPE') { + logger.warn(`[hooks] ${path.basename(hook.sourceFile)} stdin write failed open: ${err.message.slice(0, 160)}`); + } + }); + child.stdin?.write(JSON.stringify(input)); + child.stdin?.end(); }); } } diff --git a/src/ui/app.tsx b/src/ui/app.tsx index 99adb70..b4b8341 100644 --- a/src/ui/app.tsx +++ b/src/ui/app.tsx @@ -18,6 +18,7 @@ import { renderMarkdown, renderMarkdownStreaming } from './markdown.js'; import { resolveModel, getPickerCategories, + getExpandedPickerCategories, PICKER_CATEGORIES, PICKER_MODELS_FLAT, type ModelCategory, @@ -788,13 +789,18 @@ function formatAgentErrorForDisplay(error: string): string { return out.join('\n'); } +function fitPickerText(value: string, width: number): string { + if (value.length <= width) return value.padEnd(width); + if (width <= 1) return value.slice(0, width); + return `${value.slice(0, width - 1)}…`; +} + // Picker model list is imported from ./model-picker.js (single source of truth // for curation). PICKER_CATEGORIES is the static editorial list, used as the -// initial paint and the offline fallback; getPickerCategories() reconciles it -// against the live gateway catalog (fresh labels/prices, retired rows dropped) -// and the result lands in `pickerCats` state. Render and pickerIdx navigation -// both read `pickerCats` / `pickerFlat` — never the static consts — so the -// cursor and the visible rows can't disagree after hydration. +// initial paint and the offline fallback; getPickerCategories() reconciles the +// curated list against the live gateway catalog. Ctrl+A expands the picker via +// getExpandedPickerCategories() to show every live chat model grouped by +// provider. interface ToolStatus { name: string; @@ -880,6 +886,7 @@ function RunCodeApp({ // replaced by the hydrated list once the catalog fetch lands. const [pickerCats, setPickerCats] = useState(PICKER_CATEGORIES); const [pickerMoreCount, setPickerMoreCount] = useState(0); + const [pickerExpanded, setPickerExpanded] = useState(false); const pickerFlat = useMemo(() => pickerCats.flatMap(c => c.models), [pickerCats]); // Track the live model without re-firing the hydration effect on every model // change — the effect should run when the picker opens, not when the model @@ -934,7 +941,7 @@ function RunCodeApp({ useEffect(() => { if (mode !== 'model-picker') return; let cancelled = false; - void getPickerCategories() + void (pickerExpanded ? getExpandedPickerCategories() : getPickerCategories()) .then(({ categories, moreCount, live }) => { if (cancelled || !live) return; setPickerCats(categories); @@ -948,7 +955,7 @@ function RunCodeApp({ }) .catch(() => { /* keep the static list — the picker must stay usable */ }); return () => { cancelled = true; }; - }, [mode]); + }, [mode, pickerExpanded]); const bellPlayedRef = useRef(false); useEffect(() => { @@ -1122,9 +1129,10 @@ function RunCodeApp({ } }, { isActive: !!permissionRequest }); - // Key handler for picker + esc + abort - const isPickerOrEsc = mode === 'model-picker' || (mode === 'input' && ready && !input) || !ready; - useInput((_ch, key) => { + // Key handler for picker + abort. Esc aborts active work or closes dialogs; + // it must not exit the app while idle — Ctrl+C and /exit are the exit paths. + const isPickerOrAbort = mode === 'model-picker' || !ready; + useInput((ch, key) => { // Escape during generation → abort current turn (skip if permission dialog open) if (key.escape && !ready && !permissionRequest) { onAbort(); @@ -1135,16 +1143,16 @@ function RunCodeApp({ return; } - // Esc to quit (only when input is empty and in input mode) - // In Vim mode: Esc goes to normal mode (handled by VimInput), only quit on Esc in normal mode with empty input - if (key.escape && mode === 'input' && ready && !input) { - if (vimEnabled && currentVimMode === 'insert') return; // Let VimInput handle Esc → normal - requestExit(false); - return; - } - // Arrow key navigation for model picker if (mode !== 'model-picker') return; + if (key.ctrl && ch === 'a') { + const nextExpanded = !pickerExpanded; + setPickerExpanded(nextExpanded); + if (!nextExpanded) setPickerCats(PICKER_CATEGORIES); + setPickerMoreCount(0); + setPickerIdx(0); + return; + } if (key.upArrow) setPickerIdx(i => Math.max(0, i - 1)); else if (key.downArrow) setPickerIdx(i => Math.min(pickerFlat.length - 1, i + 1)); else if (key.return) { @@ -1160,16 +1168,18 @@ function RunCodeApp({ // the picker closed, which is both confusing and a privacy risk. setInput(''); setHistoryIdx(-1); + setPickerExpanded(false); setMode('input'); setReady(true); } else if (key.escape) { setInput(''); setHistoryIdx(-1); + setPickerExpanded(false); setMode('input'); setReady(true); } - }, { isActive: isPickerOrEsc }); + }, { isActive: isPickerOrAbort }); // Tab key: toggle expand/collapse on the last completed tool useInput((_ch, key) => { @@ -1248,6 +1258,9 @@ function RunCodeApp({ // closing handlers clear input too, so both ends are covered. setInput(''); setHistoryIdx(-1); + setPickerExpanded(false); + setPickerCats(PICKER_CATEGORIES); + setPickerMoreCount(0); setMode('model-picker'); } return; @@ -2044,7 +2057,7 @@ function RunCodeApp({ Select a model - (↑↓ navigate, Enter select, Esc cancel) + (↑↓ navigate, Enter select, Ctrl+A {pickerExpanded ? 'curated' : 'all models'}, Esc cancel) {hiddenAbove > 0 && ( @@ -2073,9 +2086,9 @@ function RunCodeApp({ color={isSelected ? 'cyan' : isHighlight ? 'yellow' : undefined} bold={isSelected || isHighlight} > - {' '}{m.label.padEnd(26)}{' '} + {' '}{fitPickerText(m.label, 26)}{' '} - {m.shortcut.padEnd(14)} + {fitPickerText(m.shortcut, 14)} 0 && ( - + {pickerMoreCount} more on gateway — `franklin models` to list, or /model <id> to pick one + + {pickerMoreCount} more on gateway — Ctrl+A to show all, or /model <id> to pick one )} diff --git a/src/ui/model-picker.ts b/src/ui/model-picker.ts index 65fa435..2ec8222 100644 --- a/src/ui/model-picker.ts +++ b/src/ui/model-picker.ts @@ -19,12 +19,11 @@ export const MODEL_SHORTCUTS: Record = { smart: 'blockrun/auto', eco: 'blockrun/auto', premium: 'blockrun/auto', - // Anthropic — `sonnet`/`claude` follow the newest Sonnet (5); `fable` is the - // Mythos-class tier above Opus. + // Anthropic — `claude` follows Opus; `fable` is the Mythos-class tier above it. fable: 'anthropic/claude-fable-5', 'fable-5': 'anthropic/claude-fable-5', sonnet: 'anthropic/claude-sonnet-5', - claude: 'anthropic/claude-sonnet-5', + claude: 'anthropic/claude-opus-4.8', 'sonnet-5': 'anthropic/claude-sonnet-5', 'sonnet-4.6': 'anthropic/claude-sonnet-4.6', 'sonnet-4.5': 'anthropic/claude-sonnet-4.5', @@ -221,6 +220,30 @@ export interface ModelCategory { models: ModelEntry[]; } +const PROVIDER_ORDER = [ + 'anthropic', + 'openai', + 'google', + 'xai', + 'zai', + 'moonshot', + 'minimax', + 'deepseek', + 'nvidia', +]; + +const PROVIDER_LABELS: Record = { + anthropic: 'Anthropic / Claude', + openai: 'OpenAI / GPT', + google: 'Google / Gemini', + xai: 'xAI / Grok', + zai: 'Z.AI / GLM', + moonshot: 'Moonshot / Kimi', + minimax: 'MiniMax', + deepseek: 'DeepSeek', + nvidia: 'Free / NVIDIA', +}; + /** * Single source of truth for the /model picker. * ~30 models across 6 categories. Every ID here is present in src/pricing.ts @@ -333,6 +356,129 @@ function isSyntheticId(id: string): boolean { return id.startsWith('blockrun/'); } +function providerOf(id: string): string { + return id.split('/', 1)[0] || 'other'; +} + +function providerRank(provider: string): number { + const index = PROVIDER_ORDER.indexOf(provider); + return index >= 0 ? index : PROVIDER_ORDER.length; +} + +function providerHeading(provider: string): string { + return PROVIDER_LABELS[provider] || provider; +} + +function modelSuffix(id: string): string { + return id.includes('/') ? id.slice(id.indexOf('/') + 1) : id; +} + +function versionScoreFrom(match: RegExpMatchArray | null): number { + if (!match) return 0; + const parts = match[1].split('.').map(part => Number.parseInt(part, 10) || 0); + return parts.slice(0, 3).reduce((score, part, index) => score + part * [1_000_000, 100_000, 1_000][index], 0); +} + +function providerVersionScore(provider: string, suffix: string): number { + switch (provider) { + case 'anthropic': + return versionScoreFrom(suffix.match(/claude-(?:fable|opus|sonnet|haiku)-(\d+(?:\.\d+)*)/)); + case 'openai': + if (suffix.startsWith('gpt-4o')) return versionScoreFrom(['', '4.0'] as RegExpMatchArray); + return versionScoreFrom(suffix.match(/(?:gpt-|o)(\d+(?:\.\d+)*)/)); + case 'google': + return versionScoreFrom(suffix.match(/gemini-(\d+(?:\.\d+)*)/)); + case 'xai': + return versionScoreFrom(suffix.match(/grok-(\d+(?:\.\d+)*)/)); + case 'zai': + return versionScoreFrom(suffix.match(/glm-(\d+(?:\.\d+)*)/)); + case 'moonshot': + return versionScoreFrom(suffix.match(/kimi-k(\d+(?:\.\d+)*)/)); + case 'minimax': + return versionScoreFrom(suffix.match(/minimax-m(\d+(?:\.\d+)*)/)); + case 'deepseek': + return versionScoreFrom(suffix.match(/deepseek-v(\d+(?:\.\d+)*)/)); + case 'nvidia': + return versionScoreFrom( + suffix.match(/deepseek-v(\d+(?:\.\d+)*)/) ?? + suffix.match(/qwen(\d+(?:\.\d+)*)/) ?? + suffix.match(/step-(\d+(?:\.\d+)*)/) ?? + suffix.match(/nemotron-(\d+(?:\.\d+)*)/) ?? + suffix.match(/mistral-large-(\d+(?:\.\d+)*)/) ?? + suffix.match(/v(\d+(?:\.\d+)*)/), + ); + default: + return versionScoreFrom(suffix.match(/(?:^|[^\d])(\d+(?:\.\d+)*)(?:[^\d]|$)/)); + } +} + +function expandedModelRank(model: GatewayModel): number { + const suffix = modelSuffix(model.id).toLowerCase(); + const provider = providerOf(model.id); + let score = providerVersionScore(provider, suffix); + + switch (provider) { + case 'anthropic': + score += suffix.includes('fable') ? 90_000 : suffix.includes('opus') ? 80_000 : suffix.includes('sonnet') ? 70_000 : suffix.includes('haiku') ? 40_000 : 0; + break; + case 'openai': + score += suffix.startsWith('gpt-') ? 80_000 : suffix.startsWith('o') ? 65_000 : 0; + if (suffix.includes('codex')) score += 8_000; + if (suffix.includes('pro') || suffix.includes('sol')) score += 3_000; + if (suffix.includes('terra')) score += 2_000; + if (suffix.includes('luna')) score += 1_000; + if (suffix.includes('mini')) score -= 8_000; + if (suffix.includes('nano')) score -= 12_000; + break; + case 'google': + score += suffix.includes('gemini') ? 80_000 : 0; + if (suffix.includes('pro')) score += 5_000; + if (suffix.includes('flash')) score -= 5_000; + break; + case 'xai': + score += suffix.includes('grok') ? 80_000 : 0; + if (suffix.includes('fast')) score -= 4_000; + if (suffix.includes('build')) score -= 8_000; + break; + case 'zai': + score += suffix.includes('glm') ? 80_000 : 0; + if (suffix.includes('turbo')) score -= 5_000; + break; + case 'moonshot': + score += suffix.includes('kimi') ? 80_000 : 0; + break; + case 'minimax': + score += suffix.includes('minimax') ? 80_000 : 0; + break; + case 'deepseek': + score += suffix.includes('v4-pro') ? 90_000 : suffix.includes('reasoner') ? 75_000 : suffix.includes('chat') ? 70_000 : 0; + break; + case 'nvidia': + score += suffix.includes('qwen3-next') ? 80_000 : suffix.includes('qwen') ? 70_000 : suffix.includes('nemotron') ? 60_000 : 0; + break; + } + + return score; +} + +function cleanGatewayLabel(model: GatewayModel): string { + return (model.name || modelSuffix(model.id)) + .replace(/\s*\((?:free|paid)\)\s*$/i, '') + .trim(); +} + +function buildShortcutById(): Map { + const preferred = new Map(); + for (const row of PICKER_CATEGORIES.flatMap(category => category.models)) { + preferred.set(row.id, row.shortcut); + } + for (const [shortcut, id] of Object.entries(MODEL_SHORTCUTS)) { + const existing = preferred.get(id); + if (!existing || shortcut.length < existing.length) preferred.set(id, shortcut); + } + return preferred; +} + /** Render a gateway price object into the picker's display format. */ function formatPrice(m: GatewayModel): string { const p = m.pricing as unknown as Record; @@ -364,13 +510,12 @@ export interface HydratedPicker { * {@link PICKER_CATEGORIES} stays the editorial layer — which models are worth * featuring, in what order, under which heading, with which shortcut. The * gateway is the source of truth for everything factual: whether a model still - * exists, its display name, and its price. That split is deliberate: the - * gateway lists 57 chat models and the picker deliberately shows ~21 (see the - * v3.9.3 trim), so we can't just render the catalog. + * exists and its price. Ctrl+A uses {@link getExpandedPickerCategories} when the + * user explicitly wants the full gateway chat catalog. * * Reconciliation rules: - * - Curated row present in the catalog → refresh its label + price from the - * gateway, so a price change upstream can't leave a stale number on screen. + * - Curated row present in the catalog → keep its label/shortcut/highlight and + * refresh its price from the gateway. * - Curated row absent → drop it. This is the self-healing half: an id the * gateway retired (the `claude-haiku-4.5-20251001` case) stops being * offered without waiting on a Franklin release. Its MODEL_SHORTCUTS alias @@ -413,9 +558,7 @@ export function reconcilePicker(catalog: GatewayModel[]): HydratedPicker { if (!live) continue; // retired upstream — drop the row, keep the alias // Price comes from the gateway (it's factual, it drifts, and it's the // user's money). The label stays curated: gateway names are longer than - // the picker's tuned columns ("Qwen3-Next 80B Instruct (Free)" is 30 - // chars against a 26-wide field) and carry redundant "(Free)" suffixes - // that the 🆓 heading already says. + // the picker's tuned columns and often carry redundant suffixes. models.push({ ...row, price: formatPrice(live) }); } if (models.length > 0) categories.push({ ...cat, models }); @@ -428,6 +571,66 @@ export function reconcilePicker(catalog: GatewayModel[]): HydratedPicker { return { categories, moreCount, live: true }; } +export async function getExpandedPickerCategories(): Promise { + try { + return reconcileExpandedPicker(await getGatewayModels()); + } catch { + return { categories: PICKER_CATEGORIES, moreCount: 0, live: false }; + } +} + +export function reconcileExpandedPicker(catalog: GatewayModel[]): HydratedPicker { + const categories: ModelCategory[] = [{ ...PICKER_CATEGORIES[0], models: [...PICKER_CATEGORIES[0].models] }]; + const curated = new Set(); + const curatedRows = new Map(); + + for (const cat of PICKER_CATEGORIES) { + for (const row of cat.models) { + curated.add(row.id); + if (!isSyntheticId(row.id)) curatedRows.set(row.id, row); + } + } + + const shortcutById = buildShortcutById(); + const chatModels = catalog + .filter(m => m.categories?.includes('chat')) + .sort((a, b) => { + const providerDelta = providerRank(providerOf(a.id)) - providerRank(providerOf(b.id)); + if (providerDelta !== 0) return providerDelta; + const rankDelta = expandedModelRank(b) - expandedModelRank(a); + if (rankDelta !== 0) return rankDelta; + const curatedDelta = Number(!curated.has(a.id)) - Number(!curated.has(b.id)); + if (curatedDelta !== 0) return curatedDelta; + return modelSuffix(a.id).localeCompare(modelSuffix(b.id)); + }); + + const byProvider = new Map(); + for (const model of chatModels) { + const provider = providerOf(model.id); + const curatedRow = curatedRows.get(model.id); + const row: ModelEntry = curatedRow + ? { ...curatedRow, price: formatPrice(model) } + : { + id: model.id, + shortcut: shortcutById.get(model.id) || model.id, + label: cleanGatewayLabel(model), + price: formatPrice(model), + }; + const rows = byProvider.get(provider) ?? []; + rows.push(row); + byProvider.set(provider, rows); + } + + for (const [provider, models] of [...byProvider.entries()].sort((a, b) => { + const rankDelta = providerRank(a[0]) - providerRank(b[0]); + return rankDelta !== 0 ? rankDelta : a[0].localeCompare(b[0]); + })) { + categories.push({ category: providerHeading(provider), models }); + } + + return { categories, moreCount: 0, live: true }; +} + /** * Show interactive model picker. Returns the selected model ID. * Falls back to text input if terminal doesn't support raw mode. diff --git a/test/local.mjs b/test/local.mjs index 012894b..b08650e 100644 --- a/test/local.mjs +++ b/test/local.mjs @@ -6615,6 +6615,7 @@ test('picker trim: hidden entries are gone from the visible list', async () => { test('picker trim: shortcuts for hidden models still resolve (muscle-memory preserved)', async () => { const { resolveModel } = await import('../dist/ui/model-picker.js'); + assert.equal(resolveModel('claude'), 'anthropic/claude-opus-4.8'); assert.equal(resolveModel('opus-4.6'), 'anthropic/claude-opus-4.6'); assert.equal(resolveModel('gpt-5.4'), 'openai/gpt-5.4'); assert.equal(resolveModel('gpt-5.4-pro'), 'openai/gpt-5.4-pro'); @@ -6718,7 +6719,7 @@ test('reconcilePicker: refreshes price from the gateway but keeps the curated la assert.equal(row.label, opus.label, 'label stays curated — gateway names overflow the column'); }); -test('reconcilePicker: free models render FREE, and moreCount counts uncurated chat models', async () => { +test('reconcilePicker: free models render FREE, and uncurated chat models stay behind +more', async () => { const { reconcilePicker, PICKER_CATEGORIES } = await import('../dist/ui/model-picker.js'); const free = PICKER_CATEGORIES.flatMap((c) => c.models).find((m) => m.shortcut === 'free'); const catalog = [ @@ -6729,8 +6730,135 @@ test('reconcilePicker: free models render FREE, and moreCount counts uncurated c const { categories, moreCount } = reconcilePicker(catalog); const row = categories.flatMap((c) => c.models).find((m) => m.id === free.id); assert.equal(row.price, 'FREE'); - // Only the uncurated *chat* model counts — the image model isn't picker material. - assert.equal(moreCount, 1, 'moreCount should count uncurated chat models only'); + assert.equal(moreCount, 1, 'normal picker should count uncurated chat models behind +more'); + assert.ok( + !categories.flatMap((c) => c.models).some((m) => m.id === 'someprovider/brand-new-flagship'), + 'normal picker should stay curated until Ctrl+A expands it', + ); +}); + +test('reconcileExpandedPicker: uncurated chat models are provider-grouped', async () => { + const { reconcileExpandedPicker, PICKER_CATEGORIES } = await import('../dist/ui/model-picker.js'); + const free = PICKER_CATEGORIES.flatMap((c) => c.models).find((m) => m.shortcut === 'free'); + const catalog = [ + gwModel(free.id, {}), + gwModel('someprovider/brand-new-flagship', { input: 5, output: 25 }), + gwModel('someprovider/an-image-model', { per_image: 0.02 }, { categories: ['image'] }), + ]; + const { categories, moreCount } = reconcileExpandedPicker(catalog); + const row = categories.flatMap((c) => c.models).find((m) => m.id === free.id); + assert.equal(row.price, 'FREE'); + assert.equal(moreCount, 0, 'expanded picker shows uncurated chat models instead of +more'); + const providerSection = categories.find((c) => c.category === 'someprovider'); + assert.ok(providerSection, 'unknown providers should get their own section'); + assert.ok( + providerSection.models.some((m) => m.id === 'someprovider/brand-new-flagship'), + 'uncurated chat model should be selectable from the picker', + ); + assert.ok( + !categories.flatMap((c) => c.models).some((m) => m.id === 'someprovider/an-image-model'), + 'non-chat models should stay out of the model picker', + ); +}); + +test('reconcileExpandedPicker: live catalog is grouped by provider after Auto', async () => { + const { reconcileExpandedPicker } = await import('../dist/ui/model-picker.js'); + const catalog = [ + gwModel('google/gemini-test', { input: 1, output: 2 }), + gwModel('anthropic/claude-test', { input: 1, output: 2 }), + gwModel('openai/gpt-test', { input: 1, output: 2 }), + ]; + const { categories } = reconcileExpandedPicker(catalog); + assert.deepEqual( + categories.map((c) => c.category), + ['🧠 Smart routing (auto-pick)', 'Anthropic / Claude', 'OpenAI / GPT', 'Google / Gemini'], + ); +}); + +test('reconcileExpandedPicker: provider groups rank newest and strongest models first', async () => { + const { reconcileExpandedPicker } = await import('../dist/ui/model-picker.js'); + const catalog = [ + gwModel('anthropic/claude-haiku-4.5', { input: 1, output: 2 }), + gwModel('anthropic/claude-opus-4.8', { input: 1, output: 2 }), + gwModel('anthropic/claude-sonnet-4.6', { input: 1, output: 2 }), + gwModel('anthropic/claude-fable-5', { input: 1, output: 2 }), + gwModel('openai/gpt-4.1', { input: 1, output: 2 }), + gwModel('openai/gpt-5.2', { input: 1, output: 2 }), + gwModel('openai/gpt-5.3-codex', { input: 1, output: 2 }), + gwModel('openai/gpt-5.3', { input: 1, output: 2 }), + gwModel('openai/gpt-5.4-mini', { input: 1, output: 2 }), + gwModel('openai/gpt-5.4-nano', { input: 1, output: 2 }), + gwModel('openai/gpt-5.4-pro', { input: 1, output: 2 }), + gwModel('openai/gpt-5.5', { input: 1, output: 2 }), + gwModel('openai/gpt-5-mini', { input: 1, output: 2 }), + gwModel('openai/gpt-5.6-luna', { input: 1, output: 2 }), + gwModel('openai/gpt-5.6-sol', { input: 1, output: 2 }), + gwModel('openai/gpt-5.6-terra', { input: 1, output: 2 }), + gwModel('google/gemini-2.5-flash', { input: 1, output: 2 }), + gwModel('google/gemini-3-flash-preview', { input: 1, output: 2 }), + gwModel('google/gemini-3.1-flash-lite', { input: 1, output: 2 }), + gwModel('google/gemini-3.1-pro', { input: 1, output: 2 }), + gwModel('google/gemini-3.5-flash', { input: 1, output: 2 }), + gwModel('xai/grok-build-0.1', { input: 1, output: 2 }), + gwModel('xai/grok-4.3', { input: 1, output: 2 }), + gwModel('xai/grok-4.5', { input: 1, output: 2 }), + gwModel('zai/glm-5-turbo', { input: 1, output: 2 }), + gwModel('zai/glm-5.1', { input: 1, output: 2 }), + gwModel('zai/glm-5.2', { input: 1, output: 2 }), + gwModel('zai/glm-5', { input: 1, output: 2 }), + gwModel('minimax/minimax-m2.7', { input: 1, output: 2 }), + gwModel('minimax/minimax-m3', { input: 1, output: 2 }), + gwModel('deepseek/deepseek-chat', { input: 1, output: 2 }), + gwModel('deepseek/deepseek-v4-pro', { input: 1, output: 2 }), + ]; + const { categories } = reconcileExpandedPicker(catalog); + const idsFor = (category) => categories.find((c) => c.category === category).models.map((m) => m.id); + assert.deepEqual(idsFor('Anthropic / Claude'), [ + 'anthropic/claude-fable-5', + 'anthropic/claude-opus-4.8', + 'anthropic/claude-sonnet-4.6', + 'anthropic/claude-haiku-4.5', + ]); + assert.deepEqual(idsFor('OpenAI / GPT'), [ + 'openai/gpt-5.6-sol', + 'openai/gpt-5.6-terra', + 'openai/gpt-5.6-luna', + 'openai/gpt-5.5', + 'openai/gpt-5.4-pro', + 'openai/gpt-5.4-mini', + 'openai/gpt-5.4-nano', + 'openai/gpt-5.3-codex', + 'openai/gpt-5.3', + 'openai/gpt-5.2', + 'openai/gpt-5-mini', + 'openai/gpt-4.1', + ]); + assert.deepEqual(idsFor('Google / Gemini'), [ + 'google/gemini-3.5-flash', + 'google/gemini-3.1-pro', + 'google/gemini-3.1-flash-lite', + 'google/gemini-3-flash-preview', + 'google/gemini-2.5-flash', + ]); + assert.deepEqual(idsFor('xAI / Grok'), [ + 'xai/grok-4.5', + 'xai/grok-4.3', + 'xai/grok-build-0.1', + ]); + assert.deepEqual(idsFor('Z.AI / GLM'), [ + 'zai/glm-5.2', + 'zai/glm-5.1', + 'zai/glm-5', + 'zai/glm-5-turbo', + ]); + assert.deepEqual(idsFor('MiniMax'), [ + 'minimax/minimax-m3', + 'minimax/minimax-m2.7', + ]); + assert.deepEqual(idsFor('DeepSeek'), [ + 'deepseek/deepseek-v4-pro', + 'deepseek/deepseek-chat', + ]); }); // ─── franklin models: per-billing-mode rendering ──────────────────────────