From d66280f45517a4929b7b405d97e52835e6838aa4 Mon Sep 17 00:00:00 2001 From: Allan Leinwand Date: Wed, 9 Sep 2026 13:28:26 -0700 Subject: [PATCH 1/8] [AI] Improve model catalog metadata and comparison --- bin/catalog-import-utils.node.test.ts | 35 +++ bin/catalog-import-utils.ts | 33 +++ bin/fetch-catalog-models.ts | 46 ++-- src/components/models/ModelCard.astro | 66 ++++- src/components/models/ModelCatalog.astro | 284 +++++++++++++++++++--- src/content.config.ts | 1 + src/util/models/index.ts | 1 + src/util/models/model-format.node.test.ts | 44 ++++ src/util/models/model-format.ts | 45 ++++ src/util/models/model-resolver.ts | 26 ++ src/util/models/model-types.ts | 2 + 11 files changed, 513 insertions(+), 70 deletions(-) create mode 100644 bin/catalog-import-utils.node.test.ts create mode 100644 bin/catalog-import-utils.ts create mode 100644 src/util/models/model-format.node.test.ts create mode 100644 src/util/models/model-format.ts diff --git a/bin/catalog-import-utils.node.test.ts b/bin/catalog-import-utils.node.test.ts new file mode 100644 index 00000000000..b253ddb4246 --- /dev/null +++ b/bin/catalog-import-utils.node.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { + hasMoreCatalogModels, + mapConcurrentOrdered, +} from "./catalog-import-utils"; + +describe("catalog import utilities", () => { + it("stops pagination after all API rows are consumed", () => { + expect(hasMoreCatalogModels(100, 101, 100)).toBe(true); + expect(hasMoreCatalogModels(101, 101, 1)).toBe(false); + expect(hasMoreCatalogModels(100, 101, 0)).toBe(false); + }); + + it("keeps workers busy and returns results in input order", async () => { + let active = 0; + let maxActive = 0; + const completed: number[] = []; + const results = await mapConcurrentOrdered( + [30, 5, 10, 1], + 2, + async (delay, index) => { + active++; + maxActive = Math.max(maxActive, active); + await new Promise((resolve) => setTimeout(resolve, delay)); + active--; + return index; + }, + (count) => completed.push(count), + ); + + expect(results).toEqual([0, 1, 2, 3]); + expect(maxActive).toBe(2); + expect(completed).toEqual([1, 2, 3, 4]); + }); +}); diff --git a/bin/catalog-import-utils.ts b/bin/catalog-import-utils.ts new file mode 100644 index 00000000000..7a7bcfb82a4 --- /dev/null +++ b/bin/catalog-import-utils.ts @@ -0,0 +1,33 @@ +export function hasMoreCatalogModels( + modelsSeen: number, + totalCount: number, + pageSize: number, +): boolean { + return pageSize > 0 && modelsSeen < totalCount; +} + +export async function mapConcurrentOrdered( + values: readonly T[], + concurrency: number, + mapper: (value: T, index: number) => Promise, + onComplete?: (completed: number) => void, +): Promise { + if (!Number.isInteger(concurrency) || concurrency < 1) + throw new RangeError("concurrency must be a positive integer"); + const results = new Array(values.length); + let nextIndex = 0; + let completed = 0; + + async function run(): Promise { + while (nextIndex < values.length) { + const index = nextIndex++; + results[index] = await mapper(values[index], index); + onComplete?.(++completed); + } + } + + await Promise.all( + Array.from({ length: Math.min(concurrency, values.length) }, run), + ); + return results; +} diff --git a/bin/fetch-catalog-models.ts b/bin/fetch-catalog-models.ts index 13b016d8d48..90a3f19191c 100644 --- a/bin/fetch-catalog-models.ts +++ b/bin/fetch-catalog-models.ts @@ -25,6 +25,10 @@ import fs from "node:fs"; import path from "node:path"; +import { + hasMoreCatalogModels, + mapConcurrentOrdered, +} from "./catalog-import-utils"; interface CatalogModel { model_id: string; @@ -80,8 +84,6 @@ interface CatalogModel { private?: boolean; created_at?: string; updated_at?: string; - // Returned by the catalog API but not consumed by the docs site. - // Stripped before writing to disk. pricing?: Record; } @@ -188,6 +190,7 @@ async function fetchModelList( const modelIds: string[] = []; let page = 1; let hasMore = true; + let modelsSeen = 0; console.log("Fetching model list from Unified Catalog API..."); console.log(` Base URL: ${API_BASE_URL}`); @@ -225,13 +228,14 @@ async function fetchModelList( } const { count, total_count } = data.result_info!; + modelsSeen += data.result.length; const privateNote = skippedPrivate > 0 ? ` (${skippedPrivate} private skipped)` : ""; console.log( - ` Page ${page}: ${count} models (${modelIds.length}/${total_count})${privateNote}`, + ` Page ${page}: ${count} models (${modelsSeen}/${total_count})${privateNote}`, ); - hasMore = modelIds.length < total_count; + hasMore = hasMoreCatalogModels(modelsSeen, total_count, data.result.length); page++; } @@ -289,26 +293,20 @@ async function fetchFromApi(): Promise { ); // Pass 2: fetch full details for each model + const results = await mapConcurrentOrdered( + modelIds, + CONCURRENCY, + (modelId) => fetchModelDetail(ACCOUNT_ID, API_TOKEN, modelId), + (fetched) => + process.stdout.write(`\r ${fetched}/${modelIds.length} models fetched`), + ); + const models: CatalogModel[] = []; const failed: string[] = []; - - for (let i = 0; i < modelIds.length; i += CONCURRENCY) { - const batch = modelIds.slice(i, i + CONCURRENCY); - const results = await Promise.all( - batch.map((id) => fetchModelDetail(ACCOUNT_ID, API_TOKEN, id)), - ); - - for (let j = 0; j < results.length; j++) { - const result = results[j]; - if (result) { - models.push(result); - } else { - failed.push(batch[j]); - } - } - - const fetched = Math.min(i + CONCURRENCY, modelIds.length); - process.stdout.write(`\r ${fetched}/${modelIds.length} models fetched`); + for (let index = 0; index < results.length; index++) { + const result = results[index]; + if (result) models.push(result); + else failed.push(modelIds[index]); } console.log(); @@ -433,10 +431,6 @@ function writeModels(models: CatalogModel[]): void { model.name = model.name.trim(); model.description = model.description.trim(); - // Drop the `pricing` field — it's returned by the catalog API but is - // not consumed by the docs site and isn't declared in the schema. - delete model.pricing; - // Strip credentials from any pre-signed URLs in the response. const redacted = redactCredentialUrls(model); diff --git a/src/components/models/ModelCard.astro b/src/components/models/ModelCard.astro index 028eeafea50..d3e01925088 100644 --- a/src/components/models/ModelCard.astro +++ b/src/components/models/ModelCard.astro @@ -15,6 +15,8 @@ import ModelBadges from "./ModelBadges.astro"; import { authorData } from "~/components/models/data"; import { facetValues, + formatCompactTokens, + formatModelPricing, pinnedModelNames, type ModelCardData, } from "~/util/models"; @@ -43,12 +45,20 @@ const pinnedIndex = pinnedModelNames.indexOf(model.name); const isPinned = pinnedIndex >= 0; // Author logo; initial-letter tile when the author id is unmapped. const authorLogo = authorData[model.author]?.logo; +const contextWindow = formatCompactTokens(model.properties.context_window); +const maxOutputTokens = formatCompactTokens(model.properties.max_output_tokens); +const pricing = formatModelPricing(model.pricing); +const decisionFacts = [ + contextWindow ? `${contextWindow} context` : null, + maxOutputTokens ? `${maxOutputTokens} output` : null, + pricing.length > 0 ? "Pricing listed" : null, +].filter((value): value is string => value !== null); ---
{ isPinned && ( @@ -103,9 +125,9 @@ const authorLogo = authorData[model.author]?.logo; ) } -
+

{model.shortName}

@@ -123,20 +145,46 @@ const authorLogo = authorData[model.author]?.logo;
- {model.authorName} - + + {model.authorName} + + {model.task}

{model.description}

- +
+ + { + decisionFacts.length > 0 && ( +
    + {decisionFacts.map((fact) => ( +
  • {fact}
  • + ))} +
+ ) + } +
+
diff --git a/src/components/models/ModelCatalog.astro b/src/components/models/ModelCatalog.astro index 8d08920d01a..ee0fc920165 100644 --- a/src/components/models/ModelCatalog.astro +++ b/src/components/models/ModelCatalog.astro @@ -107,6 +107,42 @@ const facetMatchMaps = Object.fromEntries(
+ + {/* Empty state — toggled by the script. */}
( + "[data-model-comparison]", + )!; + const comparisonHead = comparison.querySelector( + "[data-model-comparison-head]", + )!; + const comparisonBody = comparison.querySelector( + "[data-model-comparison-body]", + )!; + const comparisonClear = comparison.querySelector( + "[data-model-comparison-clear]", + )!; + const compareButtons = Array.from( + root.querySelectorAll("[data-model-compare]"), + ); + const compareButtonEntries = compareButtons.flatMap((button) => { + const cell = button.closest("[data-models-cell]"); + return cell === null ? [] : [{ button, cell }]; + }); + const compareButtonByCell = new Map( + compareButtonEntries.map(({ button, cell }) => [cell, button]), + ); + const selectedModels: HTMLElement[] = []; + const indexedModels = Array.from( + grid.querySelectorAll("[data-models-cell]"), + ).map((cell) => ({ + cell, + marks: cell.querySelector("[data-corner-marks]"), + search: cell.dataset.search ?? cell.dataset.name ?? "", + facets: Object.fromEntries( + facetKeys.map((key) => [ + key, + new Set( + (cell.dataset[datasetKey(key)] ?? "").split("|").filter(Boolean), + ), + ]), + ) as Record>, + pinnedIndex: Number(cell.dataset.pinnedIndex ?? -1), + date: Number(cell.dataset.date ?? 0), + })); const state = { search: "", @@ -169,10 +244,158 @@ const facetMatchMaps = Object.fromEntries( sort: "newest" as SortOrder, }; - function relayout(): number { - const cells = Array.from( - grid.querySelectorAll("[data-models-cell]"), + function tableCell(tag: "th" | "td", text: string): HTMLTableCellElement { + const cell = document.createElement(tag); + cell.className = + "border-border whitespace-pre-line border-b border-r px-4 py-3 text-left align-top last:border-r-0"; + cell.textContent = text; + return cell; + } + + function updateCompareButton( + button: HTMLButtonElement, + cell: HTMLElement, + ): void { + const selected = selectedModels.includes(cell); + const disabled = !selected && selectedModels.length >= 3; + if (button.ariaPressed !== String(selected)) + button.ariaPressed = String(selected); + const text = selected ? "Selected" : "Compare"; + if (button.textContent !== text) button.textContent = text; + if (button.disabled !== disabled) button.disabled = disabled; + button.setAttribute( + "aria-label", + `${selected ? "Remove" : "Add"} ${cell.dataset.modelLabel ?? "model"} ${selected ? "from" : "to"} comparison`, ); + } + + function renderComparison(): void { + comparison.hidden = selectedModels.length === 0; + comparisonHead.replaceChildren(); + comparisonBody.replaceChildren(); + + const header = document.createElement("tr"); + const corner = tableCell("td", ""); + corner.ariaHidden = "true"; + header.append(corner); + for (const model of selectedModels) { + const cell = tableCell("th", ""); + cell.scope = "col"; + const link = document.createElement("a"); + link.href = model.dataset.modelHref ?? "#"; + link.className = + "text-primary font-semibold no-underline hover:underline"; + link.textContent = + model.dataset.modelLabel ?? model.dataset.modelId ?? "Model"; + cell.append(link); + header.append(cell); + } + comparisonHead.append(header); + + const rows: Array<[string, (model: HTMLElement) => string]> = [ + ["Provider", (model) => model.dataset.modelProvider ?? "Not listed"], + ["Task", (model) => model.dataset.modelTask ?? "Not listed"], + [ + "Context window", + (model) => + model.dataset.modelContext + ? `${Number(model.dataset.modelContext).toLocaleString()} tokens` + : "Not listed", + ], + [ + "Maximum output", + (model) => + model.dataset.modelOutput + ? `${Number(model.dataset.modelOutput).toLocaleString()} tokens` + : "Not listed", + ], + ["Pricing", (model) => model.dataset.modelPricing || "Not listed"], + ["Best for", (model) => model.dataset.modelTags || "Not listed"], + [ + "Capabilities", + (model) => model.dataset.modelCapabilities || "Not listed", + ], + [ + "Delivery", + (model) => + model.dataset.modelHosting === "hosted" + ? "Cloudflare-hosted" + : "Third-party", + ], + ["Zero Data Retention", (model) => model.dataset.modelZdr ?? "No"], + ]; + for (const [label, value] of rows) { + const values = selectedModels.map(value); + if (values.every((entry) => entry === "Not listed")) continue; + const row = document.createElement("tr"); + const heading = tableCell("th", label); + heading.scope = "row"; + heading.classList.add("text-muted-foreground", "font-medium"); + row.append(heading); + for (const entry of values) row.append(tableCell("td", entry)); + comparisonBody.append(row); + } + } + + for (const button of compareButtons) { + button.addEventListener("click", () => { + const cell = button.closest("[data-models-cell]"); + if (cell === null) return; + const wasAtLimit = selectedModels.length >= 3; + const index = selectedModels.indexOf(cell); + if (index >= 0) selectedModels.splice(index, 1); + else if (selectedModels.length < 3) selectedModels.push(cell); + renderComparison(); + updateCompareButton(button, cell); + if (wasAtLimit !== selectedModels.length >= 3) { + for (const entry of compareButtonEntries) + updateCompareButton(entry.button, entry.cell); + } + }); + } + comparisonClear.addEventListener("click", () => { + const selected = [...selectedModels]; + selectedModels.length = 0; + renderComparison(); + for (const cell of selected) { + const button = compareButtonByCell.get(cell); + if (button) updateCompareButton(button, cell); + } + for (const entry of compareButtonEntries) { + if (entry.button.disabled) updateCompareButton(entry.button, entry.cell); + } + searchInput.focus(); + }); + + function compareModels( + a: (typeof indexedModels)[number], + b: (typeof indexedModels)[number], + dir: number, + ): number { + const aPinned = a.pinnedIndex >= 0; + const bPinned = b.pinnedIndex >= 0; + if (aPinned && !bPinned) return -1; + if (!aPinned && bPinned) return 1; + if (aPinned && bPinned) return a.pinnedIndex - b.pinnedIndex; + return a.date === b.date ? 0 : (a.date < b.date ? -1 : 1) * dir; + } + + const sortedModels: Record = { + newest: [...indexedModels].sort((a, b) => compareModels(a, b, -1)), + oldest: [...indexedModels].sort((a, b) => compareModels(a, b, 1)), + }; + let renderedSort: SortOrder | null = null; + let relayoutFrame = 0; + + function scheduleRelayout(): void { + if (relayoutFrame !== 0) return; + relayoutFrame = requestAnimationFrame(() => { + relayoutFrame = 0; + relayout(); + }); + } + + function relayout(): number { // Split the query into whitespace-separated tokens; each must appear // somewhere in the card's searchable text (name + shortName + // description). This lets "happy horse" match a "HappyHorse" model @@ -191,52 +414,43 @@ const facetMatchMaps = Object.fromEntries( ); } - const matches = cells.filter((cell) => { - const haystack = cell.dataset.search ?? cell.dataset.name ?? ""; + const orderedModels = sortedModels[state.sort]; + const matches = orderedModels.filter((model) => { const searchOk = queryTokens.length === 0 || - queryTokens.every((token) => haystack.includes(token)); + queryTokens.every((token) => model.search.includes(token)); if (!searchOk) return false; return facetKeys.every((key) => { const chosen = selMatch[key]; if (chosen.length === 0) return true; - const vals = (cell.dataset[datasetKey(key)] ?? "") - .split("|") - .filter(Boolean); - return chosen.some((c) => vals.includes(c)); + const values = model.facets[key]; + return chosen.some((value) => values.has(value)); }); }); - // Pinned-first sort; rest by date. - const dir = state.sort === "oldest" ? 1 : -1; - matches.sort((a, b) => { - const pa = Number(a.dataset.pinnedIndex ?? -1); - const pb = Number(b.dataset.pinnedIndex ?? -1); - const aPinned = pa >= 0; - const bPinned = pb >= 0; - if (aPinned && !bPinned) return -1; - if (!aPinned && bPinned) return 1; - if (aPinned && bPinned) return pa - pb; - const da = Number(a.dataset.date ?? 0); - const db = Number(b.dataset.date ?? 0); - return da === db ? 0 : (da < db ? -1 : 1) * dir; - }); - const lgCols = resolveCols(matches.length); - grid.className = LG_GRID_CLASS[lgCols] ?? LG_GRID_CLASS[1]; + const gridClass = LG_GRID_CLASS[lgCols] ?? LG_GRID_CLASS[1]; + if (grid.className !== gridClass) grid.className = gridClass; const cols = typeof window !== "undefined" && window.matchMedia("(min-width: 1024px)").matches ? lgCols : 1; - for (const cell of cells) cell.style.display = "none"; - matches.forEach((cell, i) => { - cell.style.display = ""; - cell.className = cellClass; - grid.appendChild(cell); - const marks = cell.querySelector("[data-corner-marks]"); - if (marks) marks.innerHTML = cornerSpansHTML(cornersFor(i, cols)); + if (renderedSort !== state.sort) { + for (const model of orderedModels) grid.appendChild(model.cell); + renderedSort = state.sort; + } + const visibleCells = new Set(matches.map((model) => model.cell)); + for (const model of indexedModels) { + const display = visibleCells.has(model.cell) ? "" : "none"; + if (model.cell.style.display !== display) + model.cell.style.display = display; + } + matches.forEach((model, i) => { + const markup = cornerSpansHTML(cornersFor(i, cols)); + if (model.marks && model.marks.innerHTML !== markup) + model.marks.innerHTML = markup; }); if (empty) empty.hidden = matches.length > 0; @@ -287,7 +501,7 @@ const facetMatchMaps = Object.fromEntries( // Event listeners. searchInput.addEventListener("input", () => { state.search = searchInput.value; - relayout(); + scheduleRelayout(); syncUrl(); }); diff --git a/src/content.config.ts b/src/content.config.ts index 28cb62de1a6..e4f1b03f5c0 100644 --- a/src/content.config.ts +++ b/src/content.config.ts @@ -417,6 +417,7 @@ export const collections = { // Capabilities context_length: z.number().nullable(), max_output_tokens: z.number().nullable(), + pricing: z.record(z.string(), z.unknown()).optional().default({}), supports_async: z.boolean(), // Zero Data Retention (optional — older API rows omit it). diff --git a/src/util/models/index.ts b/src/util/models/index.ts index 0e445d37f56..fc0c9cb4c71 100644 --- a/src/util/models/index.ts +++ b/src/util/models/index.ts @@ -3,6 +3,7 @@ */ export * from "./model-types"; export * from "./model-helpers"; +export * from "./model-format"; export * from "./model-properties"; export * from "./model-schema"; export * from "./model-resolver"; diff --git a/src/util/models/model-format.node.test.ts b/src/util/models/model-format.node.test.ts new file mode 100644 index 00000000000..f856f102a34 --- /dev/null +++ b/src/util/models/model-format.node.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { formatCompactTokens, formatModelPricing } from "./model-format"; + +describe("model display formatting", () => { + it("formats token counts compactly", () => { + expect(formatCompactTokens(200_000)).toBe("200K tokens"); + expect(formatCompactTokens(null)).toBeNull(); + }); + + it("formats supported pricing values and omits nested metadata", () => { + expect( + formatModelPricing({ + "Input tokens (per 1M)": 1.25, + "per M output tokens": 2.5, + "per M cached input tokens": 0.25, + plan: "included", + metadata: { currency: "USD" }, + }), + ).toEqual([ + "Input (per 1M tokens): $1.25", + "Output (per 1M tokens): $2.50", + "Cached input (per 1M tokens): $0.25", + "plan: included", + ]); + }); + + it("preserves non-token billing units", () => { + expect( + formatModelPricing({ + "per input 512x512 tile": 0.000059, + "per step": 0.01, + "per image MP": 0.02, + "per audio minute": 0.03, + "per request": 0.04, + }), + ).toEqual([ + "per input 512x512 tile: $0.000059", + "per step: $0.01", + "per image MP: $0.02", + "per audio minute: $0.03", + "per request: $0.04", + ]); + }); +}); diff --git a/src/util/models/model-format.ts b/src/util/models/model-format.ts new file mode 100644 index 00000000000..d02b9628df1 --- /dev/null +++ b/src/util/models/model-format.ts @@ -0,0 +1,45 @@ +const compactNumber = new Intl.NumberFormat("en-US", { + notation: "compact", + maximumFractionDigits: 1, +}); + +const currency = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + maximumFractionDigits: 10, +}); + +export function formatCompactTokens(value: unknown): string | null { + const count = Number(value); + return Number.isFinite(count) && count > 0 + ? `${compactNumber.format(count)} tokens` + : null; +} + +export function formatModelPricing( + pricing: Record | undefined, +): string[] { + return Object.entries(pricing ?? {}).flatMap(([label, value]) => { + const displayLabel = formatPricingLabel(label); + if (typeof value === "number" && Number.isFinite(value) && value >= 0) { + return [`${displayLabel}: ${currency.format(value)}`]; + } + if (typeof value === "string" && value.trim()) { + return [`${displayLabel}: ${value.trim()}`]; + } + return []; + }); +} + +function formatPricingLabel(label: string): string { + const normalized = label.replaceAll("_", " ").replace(/\s+/g, " ").trim(); + const lower = normalized.toLowerCase(); + const tokenPrice = + /^per (?:1)?m (cached )?(input|output) tokens$/.exec(lower) ?? + /^(cached )?(input|output) tokens \(per (?:1)?m\)$/.exec(lower); + if (tokenPrice) { + const direction = tokenPrice[2] === "input" ? "Input" : "Output"; + return `${tokenPrice[1] ? "Cached input" : direction} (per 1M tokens)`; + } + return normalized; +} diff --git a/src/util/models/model-resolver.ts b/src/util/models/model-resolver.ts index 1388d9aaa3e..ab75e7e77a5 100644 --- a/src/util/models/model-resolver.ts +++ b/src/util/models/model-resolver.ts @@ -29,6 +29,24 @@ const isTrue = (v: unknown): boolean => v === true || v === "true"; const authorDisplayName = (author: string): string => authorData[author]?.name ?? author; +function legacyPricing(value: unknown): Record { + if (!Array.isArray(value)) return {}; + return Object.fromEntries( + value.flatMap((entry) => { + if ( + typeof entry !== "object" || + entry === null || + !("unit" in entry) || + !("price" in entry) || + typeof entry.unit !== "string" + ) { + return []; + } + return [[entry.unit, entry.price]]; + }), + ); +} + function buildView(args: { id: string; name: string; @@ -39,6 +57,7 @@ function buildView(args: { hosting: "hosted" | "proxied"; task: string; description: string; + tags: string[]; properties: Record; propertiesList: { property_id: string; value: unknown }[]; schema: { input: Record; output: Record }; @@ -46,6 +65,7 @@ function buildView(args: { zdrComment?: string | null; modelId?: string; requestFormats?: string[] | null; + pricing?: Record; examples?: ModelExample[]; banner?: ModelBanner | null; digest?: number | string; @@ -68,6 +88,7 @@ function buildView(args: { source: args.source, task: args.task, description: args.description, + tags: args.tags, capabilities, beta: isTrue(args.properties.beta), createdAt: args.createdAt, @@ -75,6 +96,7 @@ function buildView(args: { propertiesList: args.propertiesList, modelId: args.modelId, requestFormats: args.requestFormats ?? null, + pricing: args.pricing, examples: args.examples, banner: args.banner ?? null, schema: args.schema, @@ -131,6 +153,7 @@ export function catalogToResolved(entry: CatalogEntry): ModelView { hosting: "proxied", task: model.task, description: model.description, + tags: Array.isArray(model.tags) ? model.tags : [], properties, propertiesList, schema, @@ -139,6 +162,7 @@ export function catalogToResolved(entry: CatalogEntry): ModelView { zdrComment: model.zdr_comment ?? null, modelId: model.model_id, requestFormats: (model.request_formats as string[] | undefined) ?? null, + pricing: (model.pricing as Record | undefined) ?? {}, examples: (model.examples as ModelExample[] | undefined) ?? [], banner: (model.banner as ModelBanner | null | undefined) ?? null, digest: entry.digest, @@ -168,6 +192,8 @@ export function legacyToResolved(entry: LegacyEntry): ModelView { hosting: "hosted", task: d.task.name, description: d.description, + tags: Array.isArray(d.tags) ? d.tags : [], + pricing: legacyPricing(properties.price), properties, propertiesList, schema: { diff --git a/src/util/models/model-types.ts b/src/util/models/model-types.ts index 1345748403e..a4392f20434 100644 --- a/src/util/models/model-types.ts +++ b/src/util/models/model-types.ts @@ -59,6 +59,7 @@ export interface ModelView { source: number; task: string; description: string; + tags: string[]; /** Derived capability labels (filter facet + badges). */ capabilities: string[]; beta: boolean; @@ -71,6 +72,7 @@ export interface ModelView { modelId?: string; /** Accepted request formats (e.g. `["responses","chat-completions"]`). Catalog-only. */ requestFormats?: string[] | null; + pricing?: Record; /** Usage (first) + Examples list (rest). Catalog-only. */ examples?: ModelExample[]; /** In-page notice. Catalog-only. */ From 320faaf23dd4ee7bdfdcf00371ad6ee2e9424f64 Mon Sep 17 00:00:00 2001 From: Allan Leinwand Date: Wed, 9 Sep 2026 17:53:20 -0700 Subject: [PATCH 2/8] fix: address AI model catalog review feedback - Preserve cached pricing direction. - Drain concurrent import work before surfacing failures. - Record transient detail fetch failures. - Preserve catalog focus and relayout consistency. --- bin/catalog-import-utils.node.test.ts | 20 ++++++++++++++++++++ bin/catalog-import-utils.ts | 14 +++++++++++--- bin/fetch-catalog-models.ts | 9 ++++++++- src/components/models/ModelCatalog.astro | 10 ++++++++-- src/content.config.ts | 2 +- src/util/models/model-format.node.test.ts | 2 ++ src/util/models/model-format.ts | 5 ++++- 7 files changed, 54 insertions(+), 8 deletions(-) diff --git a/bin/catalog-import-utils.node.test.ts b/bin/catalog-import-utils.node.test.ts index b253ddb4246..f6402936153 100644 --- a/bin/catalog-import-utils.node.test.ts +++ b/bin/catalog-import-utils.node.test.ts @@ -32,4 +32,24 @@ describe("catalog import utilities", () => { expect(maxActive).toBe(2); expect(completed).toEqual([1, 2, 3, 4]); }); + + it("stops dispatching and drains active work after a failure", async () => { + const started: number[] = []; + let releaseActive!: () => void; + const active = new Promise((resolve) => { + releaseActive = resolve; + }); + const operation = mapConcurrentOrdered([0, 1, 2], 2, async (_, index) => { + started.push(index); + if (index === 0) throw new Error("failed"); + await active; + return index; + }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(started).toEqual([0, 1]); + releaseActive(); + await expect(operation).rejects.toThrow("failed"); + expect(started).toEqual([0, 1]); + }); }); diff --git a/bin/catalog-import-utils.ts b/bin/catalog-import-utils.ts index 7a7bcfb82a4..06c02873b99 100644 --- a/bin/catalog-import-utils.ts +++ b/bin/catalog-import-utils.ts @@ -17,17 +17,25 @@ export async function mapConcurrentOrdered( const results = new Array(values.length); let nextIndex = 0; let completed = 0; + let failed = false; + let firstError: unknown; async function run(): Promise { - while (nextIndex < values.length) { + while (!failed && nextIndex < values.length) { const index = nextIndex++; - results[index] = await mapper(values[index], index); - onComplete?.(++completed); + try { + results[index] = await mapper(values[index], index); + onComplete?.(++completed); + } catch (error) { + if (!failed) firstError = error; + failed = true; + } } } await Promise.all( Array.from({ length: Math.min(concurrency, values.length) }, run), ); + if (failed) throw firstError; return results; } diff --git a/bin/fetch-catalog-models.ts b/bin/fetch-catalog-models.ts index 90a3f19191c..83b9e997eb0 100644 --- a/bin/fetch-catalog-models.ts +++ b/bin/fetch-catalog-models.ts @@ -296,7 +296,14 @@ async function fetchFromApi(): Promise { const results = await mapConcurrentOrdered( modelIds, CONCURRENCY, - (modelId) => fetchModelDetail(ACCOUNT_ID, API_TOKEN, modelId), + async (modelId) => { + try { + return await fetchModelDetail(ACCOUNT_ID, API_TOKEN, modelId); + } catch (error) { + console.error(` Failed to fetch ${modelId}:`, error); + return null; + } + }, (fetched) => process.stdout.write(`\r ${fetched}/${modelIds.length} models fetched`), ); diff --git a/src/components/models/ModelCatalog.astro b/src/components/models/ModelCatalog.astro index ee0fc920165..ff66101d2ad 100644 --- a/src/components/models/ModelCatalog.astro +++ b/src/components/models/ModelCatalog.astro @@ -355,6 +355,7 @@ const facetMatchMaps = Object.fromEntries( } comparisonClear.addEventListener("click", () => { const selected = [...selectedModels]; + const focusTarget = compareButtonByCell.get(selected[0]); selectedModels.length = 0; renderComparison(); for (const cell of selected) { @@ -364,7 +365,7 @@ const facetMatchMaps = Object.fromEntries( for (const entry of compareButtonEntries) { if (entry.button.disabled) updateCompareButton(entry.button, entry.cell); } - searchInput.focus(); + focusTarget?.focus(); }); function compareModels( @@ -396,6 +397,10 @@ const facetMatchMaps = Object.fromEntries( } function relayout(): number { + if (relayoutFrame !== 0) { + cancelAnimationFrame(relayoutFrame); + relayoutFrame = 0; + } // Split the query into whitespace-separated tokens; each must appear // somewhere in the card's searchable text (name + shortName + // description). This lets "happy horse" match a "HappyHorse" model @@ -501,7 +506,8 @@ const facetMatchMaps = Object.fromEntries( // Event listeners. searchInput.addEventListener("input", () => { state.search = searchInput.value; - scheduleRelayout(); + if (document.visibilityState === "hidden") relayout(); + else scheduleRelayout(); syncUrl(); }); diff --git a/src/content.config.ts b/src/content.config.ts index e4f1b03f5c0..4cc46382d4f 100644 --- a/src/content.config.ts +++ b/src/content.config.ts @@ -417,7 +417,7 @@ export const collections = { // Capabilities context_length: z.number().nullable(), max_output_tokens: z.number().nullable(), - pricing: z.record(z.string(), z.unknown()).optional().default({}), + pricing: z.record(z.string(), z.unknown()).default({}), supports_async: z.boolean(), // Zero Data Retention (optional — older API rows omit it). diff --git a/src/util/models/model-format.node.test.ts b/src/util/models/model-format.node.test.ts index f856f102a34..b5c9a294b09 100644 --- a/src/util/models/model-format.node.test.ts +++ b/src/util/models/model-format.node.test.ts @@ -13,6 +13,7 @@ describe("model display formatting", () => { "Input tokens (per 1M)": 1.25, "per M output tokens": 2.5, "per M cached input tokens": 0.25, + "cached output tokens (per 1m)": 0.5, plan: "included", metadata: { currency: "USD" }, }), @@ -20,6 +21,7 @@ describe("model display formatting", () => { "Input (per 1M tokens): $1.25", "Output (per 1M tokens): $2.50", "Cached input (per 1M tokens): $0.25", + "Cached output (per 1M tokens): $0.50", "plan: included", ]); }); diff --git a/src/util/models/model-format.ts b/src/util/models/model-format.ts index d02b9628df1..853d0fdcaa0 100644 --- a/src/util/models/model-format.ts +++ b/src/util/models/model-format.ts @@ -39,7 +39,10 @@ function formatPricingLabel(label: string): string { /^(cached )?(input|output) tokens \(per (?:1)?m\)$/.exec(lower); if (tokenPrice) { const direction = tokenPrice[2] === "input" ? "Input" : "Output"; - return `${tokenPrice[1] ? "Cached input" : direction} (per 1M tokens)`; + const displayDirection = tokenPrice[1] + ? `Cached ${direction.toLowerCase()}` + : direction; + return `${displayDirection} (per 1M tokens)`; } return normalized; } From 4744951131c7eb189271da10a6f978fedef95b06 Mon Sep 17 00:00:00 2001 From: Allan Leinwand Date: Wed, 9 Sep 2026 18:13:38 -0700 Subject: [PATCH 3/8] perf: cache model catalog corner layout Skip corner-mark generation when card position and column count are unchanged. Behavior-preserving. Full checks, lint, formatting, 165 tests, and build pass. --- src/components/models/ModelCatalog.astro | 52 ++++++------------------ 1 file changed, 12 insertions(+), 40 deletions(-) diff --git a/src/components/models/ModelCatalog.astro b/src/components/models/ModelCatalog.astro index ff66101d2ad..dddc1ecb9ef 100644 --- a/src/components/models/ModelCatalog.astro +++ b/src/components/models/ModelCatalog.astro @@ -1,20 +1,5 @@ --- -/** - * ModelCatalog — filterable AI model catalog, rendered inside the docs layout. - * - * The toolbar (search + Task Types / Capabilities / Providers? / Authors - * multi-selects + sort) and the "We found N models" count/clear live in small - * React islands (FilterDropdownWrapper / SortSelectWrapper) built on base-ui, - * styled with Nimbus design tokens. The islands fire CustomEvents; a vanilla - * JS ` From 0906eccb255054bd316f41856cb7440660c99dd6 Mon Sep 17 00:00:00 2001 From: Allan Leinwand Date: Wed, 9 Sep 2026 18:29:17 -0700 Subject: [PATCH 4/8] fix: address AI reviewer follow-up --- bin/catalog-import-utils.node.test.ts | 9 +++++++++ src/components/models/ModelCatalog.astro | 5 ++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/bin/catalog-import-utils.node.test.ts b/bin/catalog-import-utils.node.test.ts index f6402936153..811ab468c78 100644 --- a/bin/catalog-import-utils.node.test.ts +++ b/bin/catalog-import-utils.node.test.ts @@ -35,6 +35,7 @@ describe("catalog import utilities", () => { it("stops dispatching and drains active work after a failure", async () => { const started: number[] = []; + const finished: number[] = []; let releaseActive!: () => void; const active = new Promise((resolve) => { releaseActive = resolve; @@ -43,13 +44,21 @@ describe("catalog import utilities", () => { started.push(index); if (index === 0) throw new Error("failed"); await active; + finished.push(index); return index; }); + let settled = false; + void operation.then( + () => (settled = true), + () => (settled = true), + ); await new Promise((resolve) => setTimeout(resolve, 0)); expect(started).toEqual([0, 1]); + expect(settled).toBe(false); releaseActive(); await expect(operation).rejects.toThrow("failed"); expect(started).toEqual([0, 1]); + expect(finished).toEqual([1]); }); }); diff --git a/src/components/models/ModelCatalog.astro b/src/components/models/ModelCatalog.astro index dddc1ecb9ef..e2884a45528 100644 --- a/src/components/models/ModelCatalog.astro +++ b/src/components/models/ModelCatalog.astro @@ -334,7 +334,10 @@ const facetMatchMaps = Object.fromEntries( } comparisonClear.addEventListener("click", () => { const selected = [...selectedModels]; - const focusTarget = compareButtonByCell.get(selected[0]); + const visibleCell = selected.find((cell) => cell.style.display !== "none"); + const focusTarget = visibleCell + ? compareButtonByCell.get(visibleCell) + : searchInput; selectedModels.length = 0; renderComparison(); for (const cell of selected) { From f074c87922a7df2acf592571741e27d39e6fdcb7 Mon Sep 17 00:00:00 2001 From: Allan Leinwand Date: Thu, 10 Sep 2026 13:43:23 -0700 Subject: [PATCH 5/8] fix: address model catalog review suggestions --- src/components/models/ModelCard.astro | 4 ++-- src/components/models/ModelCatalog.astro | 17 +++++++++-------- src/components/models/ModelFeatures.astro | 11 ++++------- src/util/models/model-format.ts | 4 ++-- src/util/models/model-resolver.ts | 7 +++++-- 5 files changed, 22 insertions(+), 21 deletions(-) diff --git a/src/components/models/ModelCard.astro b/src/components/models/ModelCard.astro index d3e01925088..3528b5e89ef 100644 --- a/src/components/models/ModelCard.astro +++ b/src/components/models/ModelCard.astro @@ -49,8 +49,8 @@ const contextWindow = formatCompactTokens(model.properties.context_window); const maxOutputTokens = formatCompactTokens(model.properties.max_output_tokens); const pricing = formatModelPricing(model.pricing); const decisionFacts = [ - contextWindow ? `${contextWindow} context` : null, - maxOutputTokens ? `${maxOutputTokens} output` : null, + contextWindow ? `Context: ${contextWindow}` : null, + maxOutputTokens ? `Maximum output: ${maxOutputTokens}` : null, pricing.length > 0 ? "Pricing listed" : null, ].filter((value): value is string => value !== null); --- diff --git a/src/components/models/ModelCatalog.astro b/src/components/models/ModelCatalog.astro index e2884a45528..67f17d04c30 100644 --- a/src/components/models/ModelCatalog.astro +++ b/src/components/models/ModelCatalog.astro @@ -248,6 +248,13 @@ const facetMatchMaps = Object.fromEntries( ); } + function formatTokenCount(value: string | undefined): string { + const count = Number(value); + return Number.isFinite(count) && count > 0 + ? `${count.toLocaleString()} tokens` + : "Not listed"; + } + function renderComparison(): void { comparison.hidden = selectedModels.length === 0; comparisonHead.replaceChildren(); @@ -276,17 +283,11 @@ const facetMatchMaps = Object.fromEntries( ["Task", (model) => model.dataset.modelTask ?? "Not listed"], [ "Context window", - (model) => - model.dataset.modelContext - ? `${Number(model.dataset.modelContext).toLocaleString()} tokens` - : "Not listed", + (model) => formatTokenCount(model.dataset.modelContext), ], [ "Maximum output", - (model) => - model.dataset.modelOutput - ? `${Number(model.dataset.modelOutput).toLocaleString()} tokens` - : "Not listed", + (model) => formatTokenCount(model.dataset.modelOutput), ], ["Pricing", (model) => model.dataset.modelPricing || "Not listed"], ["Best for", (model) => model.dataset.modelTags || "Not listed"], diff --git a/src/components/models/ModelFeatures.astro b/src/components/models/ModelFeatures.astro index 36bbc6831c5..2b782c84f67 100644 --- a/src/components/models/ModelFeatures.astro +++ b/src/components/models/ModelFeatures.astro @@ -6,7 +6,7 @@ * beta → Batch → request formats → partner → realtime → unit price → * dashboard pricing link. */ -import type { ModelView } from "~/util/models"; +import { modelCurrencyFormatter, type ModelView } from "~/util/models"; interface Props { model: ModelView; @@ -15,11 +15,6 @@ interface Props { const { model } = Astro.props; const nf = new Intl.NumberFormat("en-US"); -const currencyFormatter = new Intl.NumberFormat("en-US", { - style: "currency", - currency: "USD", - maximumFractionDigits: 10, -}); // Flatten the property list into a lookup (values are strings). const properties: Record = {}; @@ -194,7 +189,9 @@ const price = Array.isArray(properties.price) Unit Pricing {price - .map((p) => `${currencyFormatter.format(p.price)} ${p.unit}`) + .map( + (p) => `${modelCurrencyFormatter.format(p.price)} ${p.unit}`, + ) .join(", ")} diff --git a/src/util/models/model-format.ts b/src/util/models/model-format.ts index 853d0fdcaa0..00be450ee08 100644 --- a/src/util/models/model-format.ts +++ b/src/util/models/model-format.ts @@ -3,7 +3,7 @@ const compactNumber = new Intl.NumberFormat("en-US", { maximumFractionDigits: 1, }); -const currency = new Intl.NumberFormat("en-US", { +export const modelCurrencyFormatter = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 10, @@ -22,7 +22,7 @@ export function formatModelPricing( return Object.entries(pricing ?? {}).flatMap(([label, value]) => { const displayLabel = formatPricingLabel(label); if (typeof value === "number" && Number.isFinite(value) && value >= 0) { - return [`${displayLabel}: ${currency.format(value)}`]; + return [`${displayLabel}: ${modelCurrencyFormatter.format(value)}`]; } if (typeof value === "string" && value.trim()) { return [`${displayLabel}: ${value.trim()}`]; diff --git a/src/util/models/model-resolver.ts b/src/util/models/model-resolver.ts index ab75e7e77a5..6facfd59a9a 100644 --- a/src/util/models/model-resolver.ts +++ b/src/util/models/model-resolver.ts @@ -38,7 +38,10 @@ function legacyPricing(value: unknown): Record { entry === null || !("unit" in entry) || !("price" in entry) || - typeof entry.unit !== "string" + typeof entry.unit !== "string" || + typeof entry.price !== "number" || + !Number.isFinite(entry.price) || + entry.price < 0 ) { return []; } @@ -162,7 +165,7 @@ export function catalogToResolved(entry: CatalogEntry): ModelView { zdrComment: model.zdr_comment ?? null, modelId: model.model_id, requestFormats: (model.request_formats as string[] | undefined) ?? null, - pricing: (model.pricing as Record | undefined) ?? {}, + pricing: model.pricing, examples: (model.examples as ModelExample[] | undefined) ?? [], banner: (model.banner as ModelBanner | null | undefined) ?? null, digest: entry.digest, From f1171e197c62d081407daefe56326335f3987642 Mon Sep 17 00:00:00 2001 From: Allan Leinwand Date: Thu, 10 Sep 2026 15:14:29 -0700 Subject: [PATCH 6/8] fix: normalize model token locale --- src/components/models/ModelCatalog.astro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/models/ModelCatalog.astro b/src/components/models/ModelCatalog.astro index 67f17d04c30..b472ca1f9ad 100644 --- a/src/components/models/ModelCatalog.astro +++ b/src/components/models/ModelCatalog.astro @@ -251,7 +251,7 @@ const facetMatchMaps = Object.fromEntries( function formatTokenCount(value: string | undefined): string { const count = Number(value); return Number.isFinite(count) && count > 0 - ? `${count.toLocaleString()} tokens` + ? `${count.toLocaleString("en-US")} tokens` : "Not listed"; } From a0d1a16bd45c96b5ff2c23b079eb4eda1f97b5bf Mon Sep 17 00:00:00 2001 From: Charlie Gleason Date: Fri, 11 Sep 2026 11:56:08 -0700 Subject: [PATCH 7/8] fix: refine model comparison UI --- src/components/models/ModelCard.astro | 42 +++++---- src/components/models/ModelCatalog.astro | 105 ++++++++++++++--------- 2 files changed, 89 insertions(+), 58 deletions(-) diff --git a/src/components/models/ModelCard.astro b/src/components/models/ModelCard.astro index 3528b5e89ef..2a5e090ee8b 100644 --- a/src/components/models/ModelCard.astro +++ b/src/components/models/ModelCard.astro @@ -71,9 +71,9 @@ const decisionFacts = [ data-model-id={model.modelId ?? model.name} data-model-label={model.displayName} data-model-href={href} - data-model-provider={model.authorName} + data-model-provider={model.hosting === "hosted" ? "Workers AI" : "AI Gateway"} + data-model-author={model.authorName} data-model-task={model.task} - data-model-hosting={model.hosting} data-model-context={model.properties.context_window ?? ""} data-model-output={model.properties.max_output_tokens ?? ""} data-model-pricing={pricing.join("\n")} @@ -167,24 +167,28 @@ const decisionFacts = [
- { - decisionFacts.length > 0 && ( -
    - {decisionFacts.map((fact) => ( -
  • {fact}
  • - ))} -
- ) - }
- + { + decisionFacts.length > 0 && ( +
    + {decisionFacts.map((fact) => ( +
  • {fact}
  • + ))} +
+ ) + } + +
diff --git a/src/components/models/ModelCatalog.astro b/src/components/models/ModelCatalog.astro index b472ca1f9ad..64fa1222c5a 100644 --- a/src/components/models/ModelCatalog.astro +++ b/src/components/models/ModelCatalog.astro @@ -3,6 +3,7 @@ import ModelCard from "./ModelCard.astro"; import { FilterDropdownWrapper } from "./FilterDropdownWrapper"; import { SortSelectWrapper } from "./SortSelectWrapper"; +import { Dialog, DialogClose, DialogContent } from "~/components/ui/dialog"; import { getFacets, type ModelCardData } from "~/util/models"; import { resolveCols, LG_GRID_CLASS } from "~/components/directory/grid"; @@ -87,41 +88,49 @@ const facetMatchMaps = Object.fromEntries( - +
+ + + +
+
+
+ +
+ +
+ +
@@ -175,9 +195,12 @@ const facetMatchMaps = Object.fromEntries( const countEl = document.getElementById("model-count")!; const countLabelEl = document.getElementById("model-count-label")!; const clearBtn = document.getElementById("clear-filters")!; - const comparison = root.querySelector( + const comparison = root.querySelector( "[data-model-comparison]", )!; + const comparisonTrigger = root.querySelector( + "[data-model-comparison-trigger]", + )!; const comparisonHead = comparison.querySelector( "[data-model-comparison-head]", )!; @@ -256,7 +279,13 @@ const facetMatchMaps = Object.fromEntries( } function renderComparison(): void { - comparison.hidden = selectedModels.length === 0; + comparisonTrigger.hidden = selectedModels.length === 0; + root.style.paddingBottom = + selectedModels.length === 0 + ? "" + : "calc(5rem + env(safe-area-inset-bottom))"; + comparisonTrigger.textContent = `Compare ${selectedModels.length} ${selectedModels.length === 1 ? "model" : "models"}`; + if (selectedModels.length === 0 && comparison.open) comparison.close(); comparisonHead.replaceChildren(); comparisonBody.replaceChildren(); @@ -280,6 +309,7 @@ const facetMatchMaps = Object.fromEntries( const rows: Array<[string, (model: HTMLElement) => string]> = [ ["Provider", (model) => model.dataset.modelProvider ?? "Not listed"], + ["Author", (model) => model.dataset.modelAuthor ?? "Not listed"], ["Task", (model) => model.dataset.modelTask ?? "Not listed"], [ "Context window", @@ -295,13 +325,6 @@ const facetMatchMaps = Object.fromEntries( "Capabilities", (model) => model.dataset.modelCapabilities || "Not listed", ], - [ - "Delivery", - (model) => - model.dataset.modelHosting === "hosted" - ? "Cloudflare-hosted" - : "Third-party", - ], ["Zero Data Retention", (model) => model.dataset.modelZdr ?? "No"], ]; for (const [label, value] of rows) { @@ -317,6 +340,10 @@ const facetMatchMaps = Object.fromEntries( } } + comparisonTrigger.addEventListener("click", () => { + if (selectedModels.length > 0) comparison.showModal(); + }); + for (const button of compareButtons) { button.addEventListener("click", () => { const cell = button.closest("[data-models-cell]"); From a3c80329b6371714e0f772af4fbb7907c33e19a2 Mon Sep 17 00:00:00 2001 From: Charlie Gleason Date: Fri, 11 Sep 2026 12:09:38 -0700 Subject: [PATCH 8/8] fix: adjust model card spacing --- src/components/models/ModelBadges.astro | 2 +- src/components/models/ModelCard.astro | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/components/models/ModelBadges.astro b/src/components/models/ModelBadges.astro index b0d15192dc3..c9032252d47 100644 --- a/src/components/models/ModelBadges.astro +++ b/src/components/models/ModelBadges.astro @@ -55,7 +55,7 @@ for (const { property_id, value } of model.propertiesList) { const badges: BadgeDef[] = [providerBadge, ...propertyBadges]; --- -