From c5a93c69c17ee5a7e128b2492e551bbfff9c525f Mon Sep 17 00:00:00 2001 From: Bruno Perez Date: Tue, 26 May 2026 08:19:43 -0700 Subject: [PATCH 1/2] feat: improve catalog browsing on mobile and at scale Mobile: - Collapse header nav into a menu so it no longer overflows at 375px - Let the parameter table scroll horizontally instead of crushing columns Browsing: - Pin the search/filter bar while scrolling the full model list - Group models by provider with a sort control (provider / name / params) - Collapse the 36 parameter chips behind a "show all" expander - Add a clear-all-filters control, a search clear button, and "/" to focus search Fixes: - Stop date stamps fusing onto versions (e.g. "Claude Opus 4 20250514") - Render hyphenated dates cleanly ("Gpt 4 Turbo 2024-04-09") - Distinguish "Max completion tokens" from "Max tokens" in the glossary Also: aria-pressed on filter toggles, provider-card chevrons, direct links on model rows, and a dist/ mkdir so a clean npm run dev works. --- models/moonshot/kimi-k2.5.yaml | 2 +- models/moonshot/kimi-k2.6.yaml | 2 +- models/moonshot/moonshot-v1-128k.yaml | 2 +- models/moonshot/moonshot-v1-32k.yaml | 2 +- models/moonshot/moonshot-v1-8k.yaml | 2 +- models/openai/gpt-5-chat-latest.yaml | 2 +- models/openai/gpt-5-mini.yaml | 2 +- models/openai/gpt-5-nano.yaml | 2 +- models/openai/gpt-5.1.yaml | 2 +- models/openai/gpt-5.2.yaml | 2 +- models/openai/gpt-5.4-mini.yaml | 2 +- models/openai/gpt-5.4.yaml | 2 +- models/openai/gpt-5.5.yaml | 2 +- models/openai/gpt-5.yaml | 2 +- models/openai/o1.yaml | 2 +- models/openai/o3-mini.yaml | 2 +- models/openai/o3.yaml | 2 +- models/openai/o4-mini.yaml | 2 +- src/build/assets.ts | 1 + src/client/main.ts | 207 +++++++++++++++++++++++++- src/client/styles.css | 5 + src/data/display.ts | 27 +++- src/views/index.ejs | 183 ++++++++++++++++------- src/views/partials/header.ejs | 83 ++++++++--- src/views/partials/model_row.ejs | 7 +- src/views/partials/param_table.ejs | 4 +- src/views/provider.ejs | 3 + tests/catalog.test.ts | 14 ++ 28 files changed, 466 insertions(+), 104 deletions(-) diff --git a/models/moonshot/kimi-k2.5.yaml b/models/moonshot/kimi-k2.5.yaml index 9bb3ada..fcd15c8 100644 --- a/models/moonshot/kimi-k2.5.yaml +++ b/models/moonshot/kimi-k2.5.yaml @@ -5,7 +5,7 @@ model: kimi-k2.5 params: - path: max_completion_tokens type: integer - label: Max tokens + label: Max completion tokens description: Maximum number of tokens to generate in the chat completion. range: min: 1 diff --git a/models/moonshot/kimi-k2.6.yaml b/models/moonshot/kimi-k2.6.yaml index 9bb30a1..04cca02 100644 --- a/models/moonshot/kimi-k2.6.yaml +++ b/models/moonshot/kimi-k2.6.yaml @@ -5,7 +5,7 @@ model: kimi-k2.6 params: - path: max_completion_tokens type: integer - label: Max tokens + label: Max completion tokens description: Maximum number of tokens to generate in the chat completion. range: min: 1 diff --git a/models/moonshot/moonshot-v1-128k.yaml b/models/moonshot/moonshot-v1-128k.yaml index f9290cf..9d1fea1 100644 --- a/models/moonshot/moonshot-v1-128k.yaml +++ b/models/moonshot/moonshot-v1-128k.yaml @@ -5,7 +5,7 @@ model: moonshot-v1-128k params: - path: max_completion_tokens type: integer - label: Max tokens + label: Max completion tokens description: Maximum number of tokens to generate in the chat completion. range: min: 1 diff --git a/models/moonshot/moonshot-v1-32k.yaml b/models/moonshot/moonshot-v1-32k.yaml index a0c3df4..cec1226 100644 --- a/models/moonshot/moonshot-v1-32k.yaml +++ b/models/moonshot/moonshot-v1-32k.yaml @@ -5,7 +5,7 @@ model: moonshot-v1-32k params: - path: max_completion_tokens type: integer - label: Max tokens + label: Max completion tokens description: Maximum number of tokens to generate in the chat completion. range: min: 1 diff --git a/models/moonshot/moonshot-v1-8k.yaml b/models/moonshot/moonshot-v1-8k.yaml index 3d09595..5f3ef9b 100644 --- a/models/moonshot/moonshot-v1-8k.yaml +++ b/models/moonshot/moonshot-v1-8k.yaml @@ -5,7 +5,7 @@ model: moonshot-v1-8k params: - path: max_completion_tokens type: integer - label: Max tokens + label: Max completion tokens description: Maximum number of tokens to generate in the chat completion. range: min: 1 diff --git a/models/openai/gpt-5-chat-latest.yaml b/models/openai/gpt-5-chat-latest.yaml index f26cce5..0a734af 100644 --- a/models/openai/gpt-5-chat-latest.yaml +++ b/models/openai/gpt-5-chat-latest.yaml @@ -5,7 +5,7 @@ model: gpt-5-chat-latest params: - path: max_completion_tokens type: integer - label: Max tokens + label: Max completion tokens description: Maximum number of output tokens the model may generate. default: 4096 range: diff --git a/models/openai/gpt-5-mini.yaml b/models/openai/gpt-5-mini.yaml index 8e9dc1c..1eb5c7f 100644 --- a/models/openai/gpt-5-mini.yaml +++ b/models/openai/gpt-5-mini.yaml @@ -5,7 +5,7 @@ model: gpt-5-mini params: - path: max_completion_tokens type: integer - label: Max tokens + label: Max completion tokens description: Maximum number of output tokens the model may generate. default: 4096 range: diff --git a/models/openai/gpt-5-nano.yaml b/models/openai/gpt-5-nano.yaml index 2ee2928..e5f39bf 100644 --- a/models/openai/gpt-5-nano.yaml +++ b/models/openai/gpt-5-nano.yaml @@ -5,7 +5,7 @@ model: gpt-5-nano params: - path: max_completion_tokens type: integer - label: Max tokens + label: Max completion tokens description: Maximum number of output tokens the model may generate. default: 4096 range: diff --git a/models/openai/gpt-5.1.yaml b/models/openai/gpt-5.1.yaml index 97b0626..7e6e08d 100644 --- a/models/openai/gpt-5.1.yaml +++ b/models/openai/gpt-5.1.yaml @@ -5,7 +5,7 @@ model: gpt-5.1 params: - path: max_completion_tokens type: integer - label: Max tokens + label: Max completion tokens description: Maximum number of output tokens the model may generate. default: 4096 range: diff --git a/models/openai/gpt-5.2.yaml b/models/openai/gpt-5.2.yaml index fac9a8e..c5210cd 100644 --- a/models/openai/gpt-5.2.yaml +++ b/models/openai/gpt-5.2.yaml @@ -5,7 +5,7 @@ model: gpt-5.2 params: - path: max_completion_tokens type: integer - label: Max tokens + label: Max completion tokens description: Maximum number of output tokens the model may generate. default: 4096 range: diff --git a/models/openai/gpt-5.4-mini.yaml b/models/openai/gpt-5.4-mini.yaml index bc1feaf..52ac17c 100644 --- a/models/openai/gpt-5.4-mini.yaml +++ b/models/openai/gpt-5.4-mini.yaml @@ -5,7 +5,7 @@ model: gpt-5.4-mini params: - path: max_completion_tokens type: integer - label: Max tokens + label: Max completion tokens description: Maximum number of output tokens the model may generate. default: 4096 range: diff --git a/models/openai/gpt-5.4.yaml b/models/openai/gpt-5.4.yaml index faddab5..014b531 100644 --- a/models/openai/gpt-5.4.yaml +++ b/models/openai/gpt-5.4.yaml @@ -5,7 +5,7 @@ model: gpt-5.4 params: - path: max_completion_tokens type: integer - label: Max tokens + label: Max completion tokens description: Maximum number of output tokens the model may generate. default: 4096 range: diff --git a/models/openai/gpt-5.5.yaml b/models/openai/gpt-5.5.yaml index 14f123d..d1194e4 100644 --- a/models/openai/gpt-5.5.yaml +++ b/models/openai/gpt-5.5.yaml @@ -5,7 +5,7 @@ model: gpt-5.5 params: - path: max_completion_tokens type: integer - label: Max tokens + label: Max completion tokens description: Maximum number of output tokens the model may generate. default: 4096 range: diff --git a/models/openai/gpt-5.yaml b/models/openai/gpt-5.yaml index 5bb0bd4..fd4cad0 100644 --- a/models/openai/gpt-5.yaml +++ b/models/openai/gpt-5.yaml @@ -5,7 +5,7 @@ model: gpt-5 params: - path: max_completion_tokens type: integer - label: Max tokens + label: Max completion tokens description: Maximum number of output tokens the model may generate. default: 4096 range: diff --git a/models/openai/o1.yaml b/models/openai/o1.yaml index e6d4c04..ee73459 100644 --- a/models/openai/o1.yaml +++ b/models/openai/o1.yaml @@ -5,7 +5,7 @@ model: o1 params: - path: max_completion_tokens type: integer - label: Max tokens + label: Max completion tokens description: Maximum number of output tokens the model may generate. default: 4096 range: diff --git a/models/openai/o3-mini.yaml b/models/openai/o3-mini.yaml index 33051a1..66ed740 100644 --- a/models/openai/o3-mini.yaml +++ b/models/openai/o3-mini.yaml @@ -5,7 +5,7 @@ model: o3-mini params: - path: max_completion_tokens type: integer - label: Max tokens + label: Max completion tokens description: Maximum number of output tokens the model may generate. default: 4096 range: diff --git a/models/openai/o3.yaml b/models/openai/o3.yaml index f13640d..c7614ed 100644 --- a/models/openai/o3.yaml +++ b/models/openai/o3.yaml @@ -5,7 +5,7 @@ model: o3 params: - path: max_completion_tokens type: integer - label: Max tokens + label: Max completion tokens description: Maximum number of output tokens the model may generate. default: 4096 range: diff --git a/models/openai/o4-mini.yaml b/models/openai/o4-mini.yaml index 069916d..2278fd4 100644 --- a/models/openai/o4-mini.yaml +++ b/models/openai/o4-mini.yaml @@ -5,7 +5,7 @@ model: o4-mini params: - path: max_completion_tokens type: integer - label: Max tokens + label: Max completion tokens description: Maximum number of output tokens the model may generate. default: 4096 range: diff --git a/src/build/assets.ts b/src/build/assets.ts index 6be9664..d8cf2bf 100644 --- a/src/build/assets.ts +++ b/src/build/assets.ts @@ -29,6 +29,7 @@ export async function compileStyles(): Promise { } export async function copyStaticAssets(): Promise { + await fs.mkdir(DIST_ASSETS_DIR, { recursive: true }); await Promise.all( ["favicon.svg", "og.png", "apple-touch-icon.png"].map((name) => fs.copyFile(path.join(CLIENT_DIR, name), path.join(DIST_ASSETS_DIR, name)), diff --git a/src/client/main.ts b/src/client/main.ts index a53994f..2c9b1ae 100644 --- a/src/client/main.ts +++ b/src/client/main.ts @@ -1,12 +1,14 @@ import { setupWebMCP } from "./webmcp.js"; type AuthFilter = "all" | "api_key" | "subscription"; +type SortMode = "provider" | "name" | "params"; interface FilterState { query: string; auth: AuthFilter; providers: Set; capabilities: Set; + sort: SortMode; } const state: FilterState = { @@ -14,6 +16,7 @@ const state: FilterState = { auth: "all", providers: new Set(), capabilities: new Set(), + sort: "provider", }; function setupHowToUseModal(): void { @@ -144,6 +147,9 @@ function applyFilters(): void { const empty = document.querySelector("[data-empty-state]"); if (empty) empty.classList.toggle("hidden", visible !== 0); + + updateGroupHeaders(); + syncFilterChrome(); } function setupSearch(): void { @@ -154,6 +160,14 @@ function setupSearch(): void { applyFilters(); }); + const clear = document.querySelector("[data-search-clear]"); + clear?.addEventListener("click", () => { + input.value = ""; + state.query = ""; + applyFilters(); + input.focus(); + }); + // Deep link: /?q=opus pre-fills the search (backs the schema.org SearchAction). const initial = new URLSearchParams(window.location.search).get("q"); if (initial) { @@ -163,6 +177,11 @@ function setupSearch(): void { } } +function setActive(el: Element, active: boolean): void { + el.setAttribute("data-active", String(active)); + el.setAttribute("aria-pressed", String(active)); +} + function setupAuthFilters(): void { const buttons = document.querySelectorAll("[data-auth-filter]"); buttons.forEach((btn) => { @@ -170,7 +189,7 @@ function setupAuthFilters(): void { const auth = btn.dataset.authFilter as AuthFilter | undefined; if (!auth) return; state.auth = auth; - buttons.forEach((b) => b.setAttribute("data-active", String(b === btn))); + buttons.forEach((b) => setActive(b, b === btn)); applyFilters(); }); }); @@ -184,23 +203,205 @@ function setupToggleChips(selector: string, datasetKey: string, bucket: Set 0 || + state.capabilities.size > 0 + ); +} + +function syncFilterChrome(): void { + const clear = document.querySelector("[data-clear-filters]"); + if (clear) clear.classList.toggle("hidden", !hasActiveFilters()); + + const searchClear = document.querySelector("[data-search-clear]"); + if (searchClear) searchClear.classList.toggle("hidden", state.query === ""); + + // The "/" hint and the clear button share the same slot; show one at a time. + const hint = document.querySelector("[data-search-hint]"); + if (hint) hint.style.display = state.query === "" ? "" : "none"; +} + +function setupClearFilters(): void { + const button = document.querySelector("[data-clear-filters]"); + if (!button) return; + button.addEventListener("click", () => { + state.query = ""; + state.auth = "all"; + state.providers.clear(); + state.capabilities.clear(); + + const input = document.querySelector("[data-search]"); + if (input) input.value = ""; + + document + .querySelectorAll("[data-auth-filter]") + .forEach((b) => setActive(b, b.dataset.authFilter === "all")); + document + .querySelectorAll("[data-provider], [data-capability]") + .forEach((c) => setActive(c, false)); + + applyFilters(); + }); +} + +const modelList = (): HTMLElement | null => document.querySelector("[data-model-list]"); + +// Snapshot of the server-rendered order (headers + rows), used to restore grouping. +let originalOrder: Element[] = []; + +function updateGroupHeaders(): void { + const list = modelList(); + if (!list) return; + const headers = list.querySelectorAll("[data-group-header]"); + + // Provider headers only make sense in the grouped (provider) ordering. + if (state.sort !== "provider") { + headers.forEach((h) => h.classList.add("hidden")); + return; + } + + // Show a header only when its group has at least one visible row. + let current: HTMLElement | null = null; + let hasVisible = false; + const finalize = (): void => { + if (current) current.classList.toggle("hidden", !hasVisible); + }; + for (const child of Array.from(list.children) as HTMLElement[]) { + if (child.matches("[data-group-header]")) { + finalize(); + current = child; + hasVisible = false; + } else if (child.classList.contains("model") && !child.classList.contains("hidden")) { + hasVisible = true; + } + } + finalize(); +} + +function reorderList(): void { + const list = modelList(); + if (!list) return; + if (originalOrder.length === 0) originalOrder = Array.from(list.children); + + if (state.sort === "provider") { + originalOrder.forEach((el) => list.appendChild(el)); + return; + } + + const rows = Array.from(list.querySelectorAll(".model")); + rows.sort((a, b) => { + const nameA = a.dataset.modelName ?? ""; + const nameB = b.dataset.modelName ?? ""; + if (state.sort === "name") return nameA.localeCompare(nameB); + const byParams = Number(b.dataset.modelParams ?? 0) - Number(a.dataset.modelParams ?? 0); + return byParams !== 0 ? byParams : nameA.localeCompare(nameB); + }); + rows.forEach((row) => list.appendChild(row)); +} + +function setupSort(): void { + const select = document.querySelector("[data-sort]"); + if (!select) return; + originalOrder = Array.from(modelList()?.children ?? []); + select.addEventListener("change", () => { + state.sort = (select.value as SortMode) || "provider"; + reorderList(); + applyFilters(); + }); +} + +function setupModelLinks(): void { + document.querySelectorAll("[data-model-link]").forEach((link) => { + link.addEventListener("click", (event) => { + // Leave modified clicks (open in new tab, etc.) to the browser. + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0) { + return; + } + // A plain click navigates to the model page rather than toggling the row. + event.preventDefault(); + const href = link.getAttribute("href"); + if (href) window.location.href = href; + }); + }); +} + +function setupSearchShortcut(): void { + const input = document.querySelector("[data-search]"); + if (!input) return; + document.addEventListener("keydown", (event) => { + if (event.key !== "/" || event.metaKey || event.ctrlKey || event.altKey) return; + const target = event.target as HTMLElement | null; + const tag = target?.tagName.toLowerCase(); + if (tag === "input" || tag === "textarea" || target?.isContentEditable) return; + event.preventDefault(); + input.focus(); + }); +} + +function setupCapabilityCollapse(): void { + const bar = document.querySelector("[data-capability-bar]"); + const button = document.querySelector("[data-capability-expand]"); + if (!bar || !button) return; + + const LIMIT = 12; + const chips = Array.from(bar.querySelectorAll("[data-capability]")); + if (chips.length <= LIMIT) return; + + let expanded = false; + const render = (): void => { + chips.forEach((chip, i) => chip.classList.toggle("hidden", !expanded && i >= LIMIT)); + button.textContent = expanded ? "Show fewer" : `Show all ${chips.length} parameters`; + button.setAttribute("aria-expanded", String(expanded)); + }; + + button.classList.remove("hidden"); + render(); + button.addEventListener("click", () => { + expanded = !expanded; + render(); + }); +} + +function setupMobileMenu(): void { + const menu = document.querySelector("[data-mobile-menu]"); + if (!menu) return; + // Close after a selection or when clicking outside. + menu + .querySelectorAll("a, [data-open-how-to-use]") + .forEach((el) => el.addEventListener("click", () => menu.removeAttribute("open"))); + document.addEventListener("click", (event) => { + if (menu.open && !menu.contains(event.target as Node)) menu.removeAttribute("open"); + }); +} + document.addEventListener("DOMContentLoaded", () => { setupThemeToggle(); setupHowToUseModal(); setupCopyHowToUse(); + setupMobileMenu(); setupSearch(); setupAuthFilters(); setupToggleChips("[data-provider]", "provider", state.providers); setupToggleChips("[data-capability]", "capability", state.capabilities); + setupCapabilityCollapse(); + setupClearFilters(); + setupSort(); + setupModelLinks(); + setupSearchShortcut(); + updateGroupHeaders(); + syncFilterChrome(); setupWebMCP(); }); diff --git a/src/client/styles.css b/src/client/styles.css index bfa8ef5..ecf6cd6 100644 --- a/src/client/styles.css +++ b/src/client/styles.css @@ -9,6 +9,11 @@ details > summary::-webkit-details-marker { display: none; } + /* Hide the native clear control on type=search; we render our own button. */ + input[type="search"]::-webkit-search-cancel-button { + -webkit-appearance: none; + appearance: none; + } } @layer components { diff --git a/src/data/display.ts b/src/data/display.ts index 01c58a9..62795d8 100644 --- a/src/data/display.ts +++ b/src/data/display.ts @@ -92,12 +92,27 @@ function titleCase(slug: string): string { .join(" "); } -function mergeAdjacentNumbers(parts: string[]): string[] { +// Collapses numeric slug segments into readable versions and dates: +// "4" "1" → "4.1" (short version segments join with dots) +// "2024" "11" "20" → "2024-11-20" (a 4-digit year + month/day reads as a date) +// "20250514" → "20250514" (8-digit date stamp is left intact) +// This keeps a trailing date from fusing onto a version, e.g. +// "claude-opus-4-20250514" → "Claude Opus 4 20250514", not "4.20250514". +function formatNumericRuns(parts: string[]): string[] { const out: string[] = []; - for (const part of parts) { - const prev = out[out.length - 1]; - if (prev !== undefined && /^\d+$/.test(prev) && /^\d+$/.test(part)) { - out[out.length - 1] = `${prev}.${part}`; + for (let i = 0; i < parts.length; i++) { + const part = parts[i]!; + if (/^\d{4}$/.test(part)) { + const date = [part]; + while (date.length < 3 && /^\d{2}$/.test(parts[i + 1] ?? "")) date.push(parts[++i]!); + out.push(date.join("-")); + } else if (/^\d{1,2}$/.test(part)) { + const prev = out[out.length - 1]; + if (prev !== undefined && /^\d{1,2}(\.\d{1,2})*$/.test(prev)) { + out[out.length - 1] = `${prev}.${part}`; + } else { + out.push(part); + } } else { out.push(part); } @@ -112,7 +127,7 @@ export function providerLabel(provider: string): string { export function modelLabel(model: Pick): string { const key = `${model.provider}/${model.model}`; if (MODEL_LABEL_OVERRIDES[key]) return MODEL_LABEL_OVERRIDES[key]; - const parts = mergeAdjacentNumbers(model.model.split("-")); + const parts = formatNumericRuns(model.model.split("-")); return parts .map((part) => (/^\d+(\.\d+)?$/.test(part) ? part : part[0]!.toUpperCase() + part.slice(1))) .join(" "); diff --git a/src/views/index.ejs b/src/views/index.ejs index 4638373..df633a0 100644 --- a/src/views/index.ejs +++ b/src/views/index.ejs @@ -14,59 +14,84 @@

-
+ +
<% if (providers.length > 0) { %> -
+

Filter by provider

@@ -76,6 +101,7 @@ <% } %>
+
<% } %>
-
+ <% + const byProvider = new Map(); + for (const m of models) { + const list = byProvider.get(m.provider) || []; + list.push(m); + byProvider.set(m.provider, list); + } + %> +

<%= models.length %> of <%= models.length %> models

- +
+ + +
- <% for (const model of models) { %> <%- include("partials/model_row", { model: model, helpers: helpers }) %> <% } %> + <% for (const pf of providers) { %> + <% const group = byProvider.get(pf.provider) || []; %> + <% const groupLogo = helpers.logoFor(pf.provider); %> +

+ <% if (groupLogo) { %><% } %> + <%= helpers.providerLabel(pf.provider) %> + <%= group.length %> +

+ <% for (const model of group) { %><%- include("partials/model_row", { model: model, helpers: helpers }) %><% } %> + <% } %>
+
diff --git a/src/views/partials/header.ejs b/src/views/partials/header.ejs index 43c0232..19e62b5 100644 --- a/src/views/partials/header.ejs +++ b/src/views/partials/header.ejs @@ -13,32 +13,34 @@ modelparams.dev - +
+ + + + +
+ + + API + + + GitHub + +
+
+ diff --git a/src/views/partials/model_row.ejs b/src/views/partials/model_row.ejs index dc189bc..55ac1ff 100644 --- a/src/views/partials/model_row.ejs +++ b/src/views/partials/model_row.ejs @@ -17,6 +17,7 @@ data-model-provider="<%= model.provider %>" data-model-auth="<%= model.authType %>" data-model-capabilities="<%= capabilities %>" + data-model-params="<%= model.params.length %>" id="<%= id.replace('/', '--') %>" > <%= providerName %> - <%= modelName %> + <%= modelName %> <%= authName %> diff --git a/src/views/partials/param_table.ejs b/src/views/partials/param_table.ejs index 05b1002..b581f94 100644 --- a/src/views/partials/param_table.ejs +++ b/src/views/partials/param_table.ejs @@ -1,8 +1,8 @@ <% const groups = helpers.groupParams(params); %> -
- +
+
diff --git a/src/views/provider.ejs b/src/views/provider.ejs index cbadd92..f255e9d 100644 --- a/src/views/provider.ejs +++ b/src/views/provider.ejs @@ -38,6 +38,9 @@ <%= helpers.modelLabel(model) %><%= authName %><%= model.params.length %> param<%= model.params.length === 1 ? "" : "s" %> + <% } %> diff --git a/tests/catalog.test.ts b/tests/catalog.test.ts index fcdfae3..b885168 100644 --- a/tests/catalog.test.ts +++ b/tests/catalog.test.ts @@ -88,6 +88,20 @@ describe("display helpers", () => { expect(modelLabel({ provider: "anthropic", model: "claude-sonnet-4-6" })).toBe( "Claude Sonnet 4.6", ); + expect(modelLabel({ provider: "anthropic", model: "claude-opus-4-1-20250805" })).toBe( + "Claude Opus 4.1 20250805", + ); + }); + + it("keeps date stamps separate from version numbers", () => { + // An 8-digit date stamp must not fuse onto the version (the "4.20250514" bug). + expect(modelLabel({ provider: "anthropic", model: "claude-opus-4-20250514" })).toBe( + "Claude Opus 4 20250514", + ); + // A hyphenated YYYY-MM-DD date reads as a date, not a dotted version. + expect(modelLabel({ provider: "openai", model: "gpt-4-turbo-2024-04-09" })).toBe( + "Gpt 4 Turbo 2024-04-09", + ); }); }); From c61d6ef9f668528ba024524cdc954eb84c773dff Mon Sep 17 00:00:00 2001 From: Bruno Perez Date: Tue, 26 May 2026 08:37:20 -0700 Subject: [PATCH 2/2] refactor: dedupe parameter labels in the app, not the data Revert the per-model YAML label edits and instead normalize max_completion_tokens to "Max completion tokens" at render time (glossary, model pages, JSON-LD). The catalog data and JSON API stay faithful to source, mirroring how model slugs are prettified for display only. --- models/moonshot/kimi-k2.5.yaml | 2 +- models/moonshot/kimi-k2.6.yaml | 2 +- models/moonshot/moonshot-v1-128k.yaml | 2 +- models/moonshot/moonshot-v1-32k.yaml | 2 +- models/moonshot/moonshot-v1-8k.yaml | 2 +- models/openai/gpt-5-chat-latest.yaml | 2 +- models/openai/gpt-5-mini.yaml | 2 +- models/openai/gpt-5-nano.yaml | 2 +- models/openai/gpt-5.1.yaml | 2 +- models/openai/gpt-5.2.yaml | 2 +- models/openai/gpt-5.4-mini.yaml | 2 +- models/openai/gpt-5.4.yaml | 2 +- models/openai/gpt-5.5.yaml | 2 +- models/openai/gpt-5.yaml | 2 +- models/openai/o1.yaml | 2 +- models/openai/o3-mini.yaml | 2 +- models/openai/o3.yaml | 2 +- models/openai/o4-mini.yaml | 2 +- src/build/render.ts | 2 ++ src/build/structured-data.ts | 4 ++-- src/data/display.ts | 11 +++++++++++ src/data/glossary.ts | 4 ++-- src/views/partials/param_table.ejs | 2 +- tests/catalog.test.ts | 7 ++++++- tests/glossary.test.ts | 16 ++++++++++++++++ 25 files changed, 58 insertions(+), 24 deletions(-) diff --git a/models/moonshot/kimi-k2.5.yaml b/models/moonshot/kimi-k2.5.yaml index fcd15c8..9bb3ada 100644 --- a/models/moonshot/kimi-k2.5.yaml +++ b/models/moonshot/kimi-k2.5.yaml @@ -5,7 +5,7 @@ model: kimi-k2.5 params: - path: max_completion_tokens type: integer - label: Max completion tokens + label: Max tokens description: Maximum number of tokens to generate in the chat completion. range: min: 1 diff --git a/models/moonshot/kimi-k2.6.yaml b/models/moonshot/kimi-k2.6.yaml index 04cca02..9bb30a1 100644 --- a/models/moonshot/kimi-k2.6.yaml +++ b/models/moonshot/kimi-k2.6.yaml @@ -5,7 +5,7 @@ model: kimi-k2.6 params: - path: max_completion_tokens type: integer - label: Max completion tokens + label: Max tokens description: Maximum number of tokens to generate in the chat completion. range: min: 1 diff --git a/models/moonshot/moonshot-v1-128k.yaml b/models/moonshot/moonshot-v1-128k.yaml index 9d1fea1..f9290cf 100644 --- a/models/moonshot/moonshot-v1-128k.yaml +++ b/models/moonshot/moonshot-v1-128k.yaml @@ -5,7 +5,7 @@ model: moonshot-v1-128k params: - path: max_completion_tokens type: integer - label: Max completion tokens + label: Max tokens description: Maximum number of tokens to generate in the chat completion. range: min: 1 diff --git a/models/moonshot/moonshot-v1-32k.yaml b/models/moonshot/moonshot-v1-32k.yaml index cec1226..a0c3df4 100644 --- a/models/moonshot/moonshot-v1-32k.yaml +++ b/models/moonshot/moonshot-v1-32k.yaml @@ -5,7 +5,7 @@ model: moonshot-v1-32k params: - path: max_completion_tokens type: integer - label: Max completion tokens + label: Max tokens description: Maximum number of tokens to generate in the chat completion. range: min: 1 diff --git a/models/moonshot/moonshot-v1-8k.yaml b/models/moonshot/moonshot-v1-8k.yaml index 5f3ef9b..3d09595 100644 --- a/models/moonshot/moonshot-v1-8k.yaml +++ b/models/moonshot/moonshot-v1-8k.yaml @@ -5,7 +5,7 @@ model: moonshot-v1-8k params: - path: max_completion_tokens type: integer - label: Max completion tokens + label: Max tokens description: Maximum number of tokens to generate in the chat completion. range: min: 1 diff --git a/models/openai/gpt-5-chat-latest.yaml b/models/openai/gpt-5-chat-latest.yaml index 0a734af..f26cce5 100644 --- a/models/openai/gpt-5-chat-latest.yaml +++ b/models/openai/gpt-5-chat-latest.yaml @@ -5,7 +5,7 @@ model: gpt-5-chat-latest params: - path: max_completion_tokens type: integer - label: Max completion tokens + label: Max tokens description: Maximum number of output tokens the model may generate. default: 4096 range: diff --git a/models/openai/gpt-5-mini.yaml b/models/openai/gpt-5-mini.yaml index 1eb5c7f..8e9dc1c 100644 --- a/models/openai/gpt-5-mini.yaml +++ b/models/openai/gpt-5-mini.yaml @@ -5,7 +5,7 @@ model: gpt-5-mini params: - path: max_completion_tokens type: integer - label: Max completion tokens + label: Max tokens description: Maximum number of output tokens the model may generate. default: 4096 range: diff --git a/models/openai/gpt-5-nano.yaml b/models/openai/gpt-5-nano.yaml index e5f39bf..2ee2928 100644 --- a/models/openai/gpt-5-nano.yaml +++ b/models/openai/gpt-5-nano.yaml @@ -5,7 +5,7 @@ model: gpt-5-nano params: - path: max_completion_tokens type: integer - label: Max completion tokens + label: Max tokens description: Maximum number of output tokens the model may generate. default: 4096 range: diff --git a/models/openai/gpt-5.1.yaml b/models/openai/gpt-5.1.yaml index 7e6e08d..97b0626 100644 --- a/models/openai/gpt-5.1.yaml +++ b/models/openai/gpt-5.1.yaml @@ -5,7 +5,7 @@ model: gpt-5.1 params: - path: max_completion_tokens type: integer - label: Max completion tokens + label: Max tokens description: Maximum number of output tokens the model may generate. default: 4096 range: diff --git a/models/openai/gpt-5.2.yaml b/models/openai/gpt-5.2.yaml index c5210cd..fac9a8e 100644 --- a/models/openai/gpt-5.2.yaml +++ b/models/openai/gpt-5.2.yaml @@ -5,7 +5,7 @@ model: gpt-5.2 params: - path: max_completion_tokens type: integer - label: Max completion tokens + label: Max tokens description: Maximum number of output tokens the model may generate. default: 4096 range: diff --git a/models/openai/gpt-5.4-mini.yaml b/models/openai/gpt-5.4-mini.yaml index 52ac17c..bc1feaf 100644 --- a/models/openai/gpt-5.4-mini.yaml +++ b/models/openai/gpt-5.4-mini.yaml @@ -5,7 +5,7 @@ model: gpt-5.4-mini params: - path: max_completion_tokens type: integer - label: Max completion tokens + label: Max tokens description: Maximum number of output tokens the model may generate. default: 4096 range: diff --git a/models/openai/gpt-5.4.yaml b/models/openai/gpt-5.4.yaml index 014b531..faddab5 100644 --- a/models/openai/gpt-5.4.yaml +++ b/models/openai/gpt-5.4.yaml @@ -5,7 +5,7 @@ model: gpt-5.4 params: - path: max_completion_tokens type: integer - label: Max completion tokens + label: Max tokens description: Maximum number of output tokens the model may generate. default: 4096 range: diff --git a/models/openai/gpt-5.5.yaml b/models/openai/gpt-5.5.yaml index d1194e4..14f123d 100644 --- a/models/openai/gpt-5.5.yaml +++ b/models/openai/gpt-5.5.yaml @@ -5,7 +5,7 @@ model: gpt-5.5 params: - path: max_completion_tokens type: integer - label: Max completion tokens + label: Max tokens description: Maximum number of output tokens the model may generate. default: 4096 range: diff --git a/models/openai/gpt-5.yaml b/models/openai/gpt-5.yaml index fd4cad0..5bb0bd4 100644 --- a/models/openai/gpt-5.yaml +++ b/models/openai/gpt-5.yaml @@ -5,7 +5,7 @@ model: gpt-5 params: - path: max_completion_tokens type: integer - label: Max completion tokens + label: Max tokens description: Maximum number of output tokens the model may generate. default: 4096 range: diff --git a/models/openai/o1.yaml b/models/openai/o1.yaml index ee73459..e6d4c04 100644 --- a/models/openai/o1.yaml +++ b/models/openai/o1.yaml @@ -5,7 +5,7 @@ model: o1 params: - path: max_completion_tokens type: integer - label: Max completion tokens + label: Max tokens description: Maximum number of output tokens the model may generate. default: 4096 range: diff --git a/models/openai/o3-mini.yaml b/models/openai/o3-mini.yaml index 66ed740..33051a1 100644 --- a/models/openai/o3-mini.yaml +++ b/models/openai/o3-mini.yaml @@ -5,7 +5,7 @@ model: o3-mini params: - path: max_completion_tokens type: integer - label: Max completion tokens + label: Max tokens description: Maximum number of output tokens the model may generate. default: 4096 range: diff --git a/models/openai/o3.yaml b/models/openai/o3.yaml index c7614ed..f13640d 100644 --- a/models/openai/o3.yaml +++ b/models/openai/o3.yaml @@ -5,7 +5,7 @@ model: o3 params: - path: max_completion_tokens type: integer - label: Max completion tokens + label: Max tokens description: Maximum number of output tokens the model may generate. default: 4096 range: diff --git a/models/openai/o4-mini.yaml b/models/openai/o4-mini.yaml index 2278fd4..069916d 100644 --- a/models/openai/o4-mini.yaml +++ b/models/openai/o4-mini.yaml @@ -5,7 +5,7 @@ model: o4-mini params: - path: max_completion_tokens type: integer - label: Max completion tokens + label: Max tokens description: Maximum number of output tokens the model may generate. default: 4096 range: diff --git a/src/build/render.ts b/src/build/render.ts index 64fc300..1a8d6ee 100644 --- a/src/build/render.ts +++ b/src/build/render.ts @@ -8,6 +8,7 @@ import { modelLabel, paramGroupIcon, paramGroupLabel, + paramLabel, providerLabel, } from "../data/display.js"; import { groupParams } from "../data/group.js"; @@ -29,6 +30,7 @@ export const viewHelpers = { authLabel, paramGroupLabel, paramGroupIcon, + paramLabel, conditionIcon, describeApplicability, groupParams, diff --git a/src/build/structured-data.ts b/src/build/structured-data.ts index 9a3cfd9..c428566 100644 --- a/src/build/structured-data.ts +++ b/src/build/structured-data.ts @@ -1,7 +1,7 @@ // JSON-LD builders for every page type. Pure functions of (data, siteUrl) so // they can be unit-tested without touching the filesystem or the renderer. -import { modelLabel, providerLabel } from "../data/display.js"; +import { modelLabel, paramLabel, providerLabel } from "../data/display.js"; import type { GlossaryGroup } from "../data/glossary.js"; import { SITE_DESCRIPTION, SITE_NAME } from "../data/site.js"; import { @@ -125,7 +125,7 @@ export function buildModelStructuredData( variableMeasured: model.params.map((param) => ({ "@type": "PropertyValue", name: param.path, - alternateName: param.label, + alternateName: paramLabel(param.path, param.label), description: param.description, })), }; diff --git a/src/data/display.ts b/src/data/display.ts index 62795d8..c240940 100644 --- a/src/data/display.ts +++ b/src/data/display.ts @@ -57,6 +57,13 @@ const AUTH_LABELS: Record = { subscription: "Subscription", }; +// Canonical display labels for parameters whose per-model YAML labels drift. +// The raw data (and JSON API) stay untouched; this only normalizes what the +// site renders, the same way MODEL_LABEL_OVERRIDES prettifies model slugs. +const PARAM_LABEL_OVERRIDES: Record = { + max_completion_tokens: "Max completion tokens", +}; + const PARAM_GROUP_LABELS: Record = { generation_length: "Length", sampling: "Sampling", @@ -137,6 +144,10 @@ export function authLabel(authType: AuthType): string { return AUTH_LABELS[authType]; } +export function paramLabel(path: string, fallback: string): string { + return PARAM_LABEL_OVERRIDES[path] ?? fallback; +} + export function paramGroupLabel(group: string): string { return PARAM_GROUP_LABELS[group] ?? titleCase(group.replace(/_/g, "-")); } diff --git a/src/data/glossary.ts b/src/data/glossary.ts index 40168e5..36abe34 100644 --- a/src/data/glossary.ts +++ b/src/data/glossary.ts @@ -2,7 +2,7 @@ // unique parameter path, grouped by parameter group. Powers the /glossary page // and its DefinedTermSet structured data. -import { paramGroupLabel } from "./display.js"; +import { paramGroupLabel, paramLabel } from "./display.js"; import { ParameterGroup, modelId, type Model } from "../schema/model.js"; export interface GlossaryEntry { @@ -69,7 +69,7 @@ function aggregate(models: Model[]): Map { function toEntry(path: string, bucket: Bucket): GlossaryEntry { return { path, - label: mostCommon(bucket.labels), + label: paramLabel(path, mostCommon(bucket.labels)), group: bucket.group, types: [...bucket.types].sort(), modelCount: bucket.models.size, diff --git a/src/views/partials/param_table.ejs b/src/views/partials/param_table.ejs index b581f94..1e33adb 100644 --- a/src/views/partials/param_table.ejs +++ b/src/views/partials/param_table.ejs @@ -42,7 +42,7 @@ %> diff --git a/tests/catalog.test.ts b/tests/catalog.test.ts index b885168..ad476b7 100644 --- a/tests/catalog.test.ts +++ b/tests/catalog.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from "vitest"; import { buildCapabilityFacets, buildCatalog, uniqueProviders } from "../src/data/catalog.js"; import { describeApplicability } from "../src/data/applicability.js"; -import { modelLabel, providerLabel } from "../src/data/display.js"; +import { modelLabel, paramLabel, providerLabel } from "../src/data/display.js"; import { loadAllModels } from "../src/data/load.js"; import { modelId } from "../src/schema/model.js"; import type { Model } from "../src/schema/model.js"; @@ -93,6 +93,11 @@ describe("display helpers", () => { ); }); + it("normalizes drifting parameter labels at display time", () => { + expect(paramLabel("max_completion_tokens", "Max tokens")).toBe("Max completion tokens"); + expect(paramLabel("temperature", "Temperature")).toBe("Temperature"); + }); + it("keeps date stamps separate from version numbers", () => { // An 8-digit date stamp must not fuse onto the version (the "4.20250514" bug). expect(modelLabel({ provider: "anthropic", model: "claude-opus-4-20250514" })).toBe( diff --git a/tests/glossary.test.ts b/tests/glossary.test.ts index 9668e8b..49e84a5 100644 --- a/tests/glossary.test.ts +++ b/tests/glossary.test.ts @@ -44,4 +44,20 @@ describe("buildGlossary", () => { expect(groups).toHaveLength(1); expect(groups[0]?.group).toBe("generation_length"); }); + + it("normalizes max_completion_tokens so it is distinct from max_tokens", () => { + const maxCompletion = { + path: "max_completion_tokens", + type: "integer", + label: "Max tokens", + description: "Output cap.", + group: "generation_length", + } as Model["params"][number]; + + const groups = buildGlossary([model("openai", "gpt", [maxCompletion])]); + const entry = groups + .find((g) => g.group === "generation_length") + ?.entries.find((e) => e.path === "max_completion_tokens"); + expect(entry?.label).toBe("Max completion tokens"); + }); });
Parameter
-
<%= param.label %>
+
<%= helpers.paramLabel(param.path, param.label) %>
<%= param.path %>