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/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/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..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",
@@ -92,12 +99,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 +134,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(" ");
@@ -122,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/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 @@
+
+ Show all <%= capabilities.length %> parameters
+
<% } %>
-
+ <%
+ 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
-
No models match your filters.
+
+
+
+
+
+
+ Clear all filters
+
+
- <% 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) { %><%- groupLogo %><% } %>
+ <%= helpers.providerLabel(pf.provider) %>
+ <%= group.length %>
+
+ <% for (const model of group) { %><%- include("partials/model_row", { model: model, helpers: helpers }) %><% } %>
+ <% } %>
+
+ No models match your filters.
+
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
-
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..1e33adb 100644
--- a/src/views/partials/param_table.ejs
+++ b/src/views/partials/param_table.ejs
@@ -1,8 +1,8 @@
<%
const groups = helpers.groupParams(params);
%>
-
-
+
+
| Parameter |
@@ -42,7 +42,7 @@
%>
|
- <%= param.label %>
+ <%= helpers.paramLabel(param.path, param.label) %>
<%= param.path %>
|
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..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";
@@ -88,6 +88,25 @@ 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("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(
+ "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",
+ );
});
});
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");
+ });
});