Skip to content

Commit 24aaba2

Browse files
committed
refactor(web): the browser picker uses core, and stops being the worse one
Both UIs are React, so it is easy to assume they already share what matters. They did not. The Ink picker called `core/catalog`'s `filterModels` and `rank` and `core/format`'s helpers; the browser picker reimplemented filtering inline and skipped the rest. The copy was not merely duplicated, it was WORSE: filtering core.filterModels vs inline copy, no free-only filter sorting core.rank (benchmark) vs none — whatever order the registry happened to return a free model "free" vs "$0.00/M" a 1M-context model "1M" vs "1000K" The "$0.00" case is the one worth pausing on: that file's own header says "a catalog with no prices shows no prices, rather than '$0.00', which would read as free" — and eleven lines down it rendered exactly that. The rule was written once and enforced in one of the two places it applies. Root cause was a hand-written type. `web/src/api.ts` mirrored `NormalizedModel` as a narrower `CatalogModel` — id, name, context, pricing, tools — so the browser could not call `rank`, which sorts on `benchmarks`, a field the server was already sending and the type simply hid. Same for `CatalogCapabilities`, whose mirror had silently lost `requiresAuth`. Both are now type-only imports from `src/ports/`: they erase, cost the bundle nothing, and cannot drift. Verified in a browser against the live OpenRouter catalog: ranked by benchmark rather than registry order, "1.1M context" not "1100K", a working free-only filter (14 of 342), and free models rendering as "free". What stays local to each UI is presentation, which is the right seam: the browser keeps `priceLabel`, because `formatPrice` correctly returns "free" and appending "/M in" to that gives "free/M in", which is not a thing anyone says. 770 tests. Bundle unchanged at 69.6 kB — core is pure and tree-shakes. Signed-off-by: jellologic <31935831+jellologic@users.noreply.github.com>
1 parent 0e47540 commit 24aaba2

2 files changed

Lines changed: 51 additions & 27 deletions

File tree

web/src/api.ts

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -208,20 +208,26 @@ export type DoctorReport = {
208208
summary: { counts: Record<string, number>; exitCode: number }
209209
}
210210

211-
export type CatalogModel = {
212-
id: string
213-
name: string
214-
description?: string
215-
context: number | null
216-
pricing: { prompt: number; completion: number } | null
217-
/** TRI-STATE. null is UNKNOWN, false is CONFIRMED ABSENT. Do not collapse. */
218-
tools: boolean | null
219-
}
211+
/**
212+
* A catalog row, RE-EXPORTED FROM CORE rather than mirrored here.
213+
*
214+
* This used to be a hand-written subset — id, name, context, pricing, tools —
215+
* and the subset was the bug. The server sends the whole `NormalizedModel`
216+
* (the catalog endpoint spreads its result), so the browser was already
217+
* receiving `benchmarks` and simply could not see them: the picker could not
218+
* call core's `rank`, which sorts on the coding benchmark, and so showed models
219+
* in whatever order the provider happened to list them.
220+
*
221+
* A type-only import — it erases, costs the bundle nothing, and cannot drift.
222+
*/
223+
import type { CatalogCapabilities, NormalizedModel } from '../../src/ports/catalog'
224+
225+
export type CatalogModel = NormalizedModel
220226

221227
export type CatalogResult = {
222228
id: string
223229
label: string
224-
capabilities: { pricing: boolean; benchmarks: boolean; toolSupportKnown: boolean }
230+
capabilities: CatalogCapabilities
225231
models: CatalogModel[]
226232
fromCache: boolean
227233
stale: boolean

web/src/routes/ModelPicker.tsx

Lines changed: 35 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@ import { useEffect, useMemo, useState } from 'react'
22
import { css } from '../../styled-system/css'
33
import { ApiError, api, type CatalogModel, type CatalogResult } from '../api'
44
import { Banner, Button, inputStyle } from '../ui'
5+
// The SAME filtering, ranking and formatting the Ink picker uses. These are
6+
// properties of the data, not of the widget, and the browser had drifted:
7+
// its own copy dropped the free-only filter, never sorted at all, and rendered
8+
// a free model as "$0.00/M" — the exact thing this file's header forbids.
9+
import { filterModels, rank } from '../../../src/core/catalog'
10+
import { formatContext, formatPrice } from '../../../src/core/format'
511

612
/**
713
* The browsable model list, for providers that publish one.
@@ -15,6 +21,20 @@ import { Banner, Button, inputStyle } from '../ui'
1521
* * Nothing missing is rendered as a number. A catalog with no prices shows
1622
* no prices, rather than "$0.00", which would read as free.
1723
*/
24+
/**
25+
* The per-million price pair, or just "free".
26+
*
27+
* `formatPrice` already returns "free" for zero — appending "/M in" to that
28+
* gives "free/M in", which is not a thing anyone says. The FORMATTING is
29+
* core's; only this bit of phrasing is the browser's, which is exactly the
30+
* split that should exist between them.
31+
*/
32+
function priceLabel(pricing: { prompt: number; completion: number }): string {
33+
const input = formatPrice(pricing.prompt)
34+
const output = formatPrice(pricing.completion)
35+
return input === 'free' && output === 'free' ? 'free' : `${input}/M in · ${output}/M out`
36+
}
37+
1838
export function ModelPicker({
1939
catalogId,
2040
tier,
@@ -30,6 +50,7 @@ export function ModelPicker({
3050
const [error, setError] = useState<string | null>(null)
3151
const [query, setQuery] = useState('')
3252
const [toolsOnly, setToolsOnly] = useState(true)
53+
const [freeOnly, setFreeOnly] = useState(false)
3354

3455
useEffect(() => {
3556
let live = true
@@ -44,17 +65,12 @@ export function ModelPicker({
4465

4566
const rows = useMemo(() => {
4667
if (!data) return []
47-
const terms = query.toLowerCase().split(/\s+/).filter(Boolean)
48-
// The filter is inert when the catalog cannot speak to tool support, which
49-
// is what stops it hiding everything.
50-
const filterActive = toolsOnly && data.capabilities.toolSupportKnown
51-
return data.models.filter((m) => {
52-
if (filterActive && m.tools === false) return false
53-
if (terms.length === 0) return true
54-
const hay = `${m.id} ${m.name}`.toLowerCase()
55-
return terms.every((t) => hay.includes(t))
56-
})
57-
}, [data, query, toolsOnly])
68+
// `rank` before display, exactly as the terminal picker does: models with a
69+
// published coding benchmark first and best-first, then everything else
70+
// alphabetically. Registry order is arbitrary — it put the useful models
71+
// wherever the provider happened to list them.
72+
return filterModels(data.models, { query, toolsOnly, freeOnly }, data.capabilities).sort(rank)
73+
}, [data, query, toolsOnly, freeOnly])
5874

5975
return (
6076
<div
@@ -127,6 +143,12 @@ export function ModelPicker({
127143
tools only
128144
</label>
129145
) : null}
146+
{data?.capabilities.pricing ? (
147+
<label className={css({ display: 'flex', gap: '1.5', alignItems: 'center', cursor: 'pointer' })}>
148+
<input type="checkbox" checked={freeOnly} onChange={(e) => setFreeOnly(e.target.checked)} />
149+
free only
150+
</label>
151+
) : null}
130152
{data?.stale ? <span className={css({ color: 'warn' })}>stale cache</span> : null}
131153
{data?.fromCache && !data.stale ? <span>cached</span> : null}
132154
</div>
@@ -174,12 +196,8 @@ export function ModelPicker({
174196
<div className={css({ fontSize: '11.5px', color: 'faint', mt: '0.5' })}>
175197
{m.name}
176198
{/* Absent data stays absent. "$0.00" would read as free. */}
177-
{m.pricing
178-
? ` · $${(m.pricing.prompt * 1_000_000).toFixed(2)}/M in · $${(
179-
m.pricing.completion * 1_000_000
180-
).toFixed(2)}/M out`
181-
: ''}
182-
{m.context ? ` · ${Math.round(m.context / 1000)}K context` : ''}
199+
{m.pricing ? ` · ${priceLabel(m.pricing)}` : ''}
200+
{m.context ? ` · ${formatContext(m.context)} context` : ''}
183201
</div>
184202
</button>
185203
))}

0 commit comments

Comments
 (0)