From 940c9d3d22f485f802fa0ff1f0f56cb212d4a4ef Mon Sep 17 00:00:00 2001 From: Bruno Perez Date: Tue, 30 Jun 2026 19:50:57 +0200 Subject: [PATCH] feat: add per-parameter pages for long-tail search Each LLM parameter now gets its own page at /parameters/ with a by-model table of defaults, ranges, and conditions. The glossary becomes the hub that links to every parameter page. Model pages gain jump-nav, per-row anchors, links to each parameter page, and a plain-language parameter summary. Sitemap lastmod now reads each model's git commit date instead of the build date. Parameter pages are added to the sitemap and llms.txt, and carry DefinedTerm structured data. --- src/build/build.ts | 57 +++++++++-- src/build/lastmod.ts | 45 +++++++++ src/build/render-glossary.ts | 4 +- src/build/render-model.ts | 41 +++++++- src/build/render-parameter.ts | 90 +++++++++++++++++ src/build/render.ts | 10 +- src/build/structured-data.ts | 36 +++++++ src/data/llms.ts | 10 +- src/data/parameters.ts | 145 +++++++++++++++++++++++++++ src/data/urls.ts | 25 ++++- src/server/app.ts | 17 ++++ src/views/glossary.ejs | 4 +- src/views/model.ejs | 27 ++++- src/views/parameter.ejs | 156 +++++++++++++++++++++++++++++ src/views/partials/param_table.ejs | 11 +- tests/parameters.test.ts | 103 +++++++++++++++++++ tests/render-meta.test.ts | 41 +++++++- tests/server.test.ts | 17 ++++ tests/structured-data.test.ts | 17 ++++ 19 files changed, 835 insertions(+), 21 deletions(-) create mode 100644 src/build/lastmod.ts create mode 100644 src/build/render-parameter.ts create mode 100644 src/data/parameters.ts create mode 100644 src/views/parameter.ejs create mode 100644 tests/parameters.test.ts diff --git a/src/build/build.ts b/src/build/build.ts index fdbf367..42dda34 100644 --- a/src/build/build.ts +++ b/src/build/build.ts @@ -9,21 +9,30 @@ import { import { loadAllModels } from "../data/load.js"; import { buildLlmsFullTxt, buildLlmsTxt } from "../data/llms.js"; import { listModelParamsResponses } from "../data/model-params.js"; +import { buildParameterIndex } from "../data/parameters.js"; import { DIST_API_DIR, DIST_ASSETS_DIR, DIST_DIR, MODELS_DIR, + REPO_ROOT, } from "../data/paths.js"; import { SITE_URL } from "../data/site.js"; -import { GLOSSARY_PATH, modelPagePath, providerPagePath } from "../data/urls.js"; +import { + GLOSSARY_PATH, + modelPagePath, + parameterPagePath, + providerPagePath, +} from "../data/urls.js"; import { modelId, type Model } from "../schema/model.js"; import { buildModelJsonSchema } from "../schema/generate.js"; import { bundleClientScript, compileStyles, copyStaticAssets } from "./assets.js"; +import { gitLastmodMap, modelLastmod } from "./lastmod.js"; import { renderIndex } from "./render.js"; import { renderApiPage } from "./render-api.js"; import { renderGlossaryPage } from "./render-glossary.js"; import { renderModelPage } from "./render-model.js"; +import { renderParameterPage } from "./render-parameter.js"; import { renderProviderPage } from "./render-provider.js"; async function cleanDist(): Promise { @@ -54,19 +63,32 @@ async function writeRobotsAndSitemap(models: Model[]): Promise { // Sitemaps list canonical, indexable HTML pages only — the JSON API and the // .txt agent files are intentionally excluded (they're not search results). const today = new Date().toISOString().slice(0, 10); - const entries: { path: string; priority: string }[] = [ - { path: "/", priority: "1.0" }, - { path: GLOSSARY_PATH, priority: "0.7" }, + const dates = gitLastmodMap(REPO_ROOT); + // Aggregate pages (home, glossary, providers, parameters) track the build date; + // each model URL carries the commit date of its own YAML so lastmod stays honest. + const entries: { path: string; priority: string; lastmod: string }[] = [ + { path: "/", priority: "1.0", lastmod: today }, + { path: GLOSSARY_PATH, priority: "0.7", lastmod: today }, ...uniqueProviders(models).map((provider) => ({ path: providerPagePath(provider), priority: "0.8", + lastmod: today, + })), + ...buildParameterIndex(models).map((detail) => ({ + path: parameterPagePath(detail.path), + priority: "0.7", + lastmod: today, + })), + ...models.map((model) => ({ + path: modelPagePath(model), + priority: "0.6", + lastmod: modelLastmod(model, dates, today), })), - ...models.map((model) => ({ path: modelPagePath(model), priority: "0.6" })), ]; const body = entries .map( - ({ path: loc, priority }) => - ` ${SITE_URL}${loc}${today}${priority}`, + ({ path: loc, priority, lastmod }) => + ` ${SITE_URL}${loc}${lastmod}${priority}`, ) .join("\n"); const sitemap = ` @@ -97,6 +119,24 @@ async function writeHtmlPages(models: Model[]): Promise { await fs.writeFile(path.join(DIST_DIR, "api.html"), await renderApiPage(models), "utf8"); } +async function writeParameterPages(models: Model[]): Promise { + const details = buildParameterIndex(models); + const dir = path.join(DIST_DIR, "parameters"); + await fs.mkdir(dir, { recursive: true }); + const seen = new Set(); + for (const detail of details) { + if (seen.has(detail.slug)) { + throw new Error(`Duplicate parameter slug "${detail.slug}" (path "${detail.path}")`); + } + seen.add(detail.slug); + await fs.writeFile( + path.join(dir, `${detail.slug}.html`), + await renderParameterPage(detail, models), + "utf8", + ); + } +} + async function writeApiIndex(modelCount: number): Promise { const body = { name: "modelparams.dev API", @@ -158,8 +198,9 @@ export async function build(): Promise<{ models: number }> { console.log("Bundling client + styles..."); await Promise.all([bundleClientScript(), compileStyles(), copyStaticAssets()]); - console.log("Rendering model, provider, and glossary pages..."); + console.log("Rendering model, provider, parameter, and glossary pages..."); await writeHtmlPages(models); + await writeParameterPages(models); await writeLlmsFiles(models); await writeRobotsAndSitemap(models); diff --git a/src/build/lastmod.ts b/src/build/lastmod.ts new file mode 100644 index 0000000..f9ca721 --- /dev/null +++ b/src/build/lastmod.ts @@ -0,0 +1,45 @@ +// Per-file last-modified dates for the sitemap, read from git so each model URL's +// reflects when its YAML actually changed — not the build time. Falls back +// gracefully (empty map → callers use the build date) when git or history is absent, +// so the build never depends on a full clone. + +import { execFileSync } from "node:child_process"; +import { authSuffix, type Model } from "../schema/model.js"; + +/** Repo-relative path of a model's source YAML, e.g. models/openai/gpt-4o.yaml. */ +export function modelSourcePath(model: Model): string { + return `models/${model.provider}/${model.model}${authSuffix(model.authType)}.yaml`; +} + +/** + * Map of repo-relative path → ISO date (YYYY-MM-DD) of the most recent commit that + * touched it, for everything under models/. One `git log` call; empty on any failure. + */ +export function gitLastmodMap(repoRoot: string): Map { + const map = new Map(); + let out: string; + try { + out = execFileSync( + "git", + ["-C", repoRoot, "log", "--format=%cs", "--name-only", "--", "models"], + { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }, + ); + } catch { + return map; + } + let date = ""; + for (const line of out.split("\n")) { + const trimmed = line.trim(); + if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) { + date = trimmed; + } else if (trimmed && date && !map.has(trimmed)) { + map.set(trimmed, date); + } + } + return map; +} + +/** A model's git lastmod date, or the supplied fallback when unknown. */ +export function modelLastmod(model: Model, dates: Map, fallback: string): string { + return dates.get(modelSourcePath(model)) ?? fallback; +} diff --git a/src/build/render-glossary.ts b/src/build/render-glossary.ts index 66891e8..2a71c5b 100644 --- a/src/build/render-glossary.ts +++ b/src/build/render-glossary.ts @@ -11,11 +11,11 @@ import { hubLinks, renderShell, viewHelpers } from "./render.js"; const GLOSSARY_TITLE = `LLM parameter glossary · ${SITE_NAME}`; const GLOSSARY_DESCRIPTION = - "Every LLM API parameter in the catalog, defined: what temperature, top_p, max_tokens, reasoning effort and the rest do, with their types and which models support them."; + "Every LLM API parameter in the catalog, defined: what temperature, top_p, max_tokens, reasoning effort and the rest do. Open any parameter for its default and range on every model that accepts it."; function glossaryIntro(groups: GlossaryGroup[]): string { const total = groups.reduce((sum, groupItem) => sum + groupItem.entries.length, 0); - return `${total} parameters appear across the catalog. This page defines each one, grouped by what it controls, and notes its type and how many models expose it. Definitions come from the same community-maintained data as the JSON API.`; + return `${total} parameters appear across the catalog. This page defines each one, grouped by what it controls. Open any parameter for the full breakdown — its default, range, and conditions on every model that accepts it. Definitions come from the same community-maintained data as the JSON API.`; } export async function renderGlossaryPage(allModels: Model[]): Promise { diff --git a/src/build/render-model.ts b/src/build/render-model.ts index b7b2090..3195fa7 100644 --- a/src/build/render-model.ts +++ b/src/build/render-model.ts @@ -1,10 +1,12 @@ import path from "node:path"; import ejs from "ejs"; -import { modelLabel, providerLabel } from "../data/display.js"; +import { describeApplicability } from "../data/applicability.js"; +import { modelLabel, paramGroupLabel, providerLabel } from "../data/display.js"; +import { groupParams } from "../data/group.js"; import { VIEWS_DIR } from "../data/paths.js"; import { SITE_NAME, SITE_URL } from "../data/site.js"; import { absolute, modelJsonPath, modelPagePath, providerPagePath } from "../data/urls.js"; -import { modelId, type Model } from "../schema/model.js"; +import { modelId, type Model, type Parameter } from "../schema/model.js"; import { buildModelStructuredData } from "./structured-data.js"; import { hubLinks, renderShell, viewHelpers } from "./render.js"; @@ -29,6 +31,40 @@ export function modelPageDescription(model: Model): string { return `All ${count} for ${who}: ${sample}${more}. See each type, default, range, and the conditions that gate it.`; } +/** A short, factual clause describing a parameter's default, range, values, and gate. */ +function paramFact(param: Parameter): string { + const bits: string[] = []; + if (param.default !== undefined) bits.push(`defaults to ${JSON.stringify(param.default)}`); + if ((param.type === "integer" || param.type === "number") && param.range) { + const { min, max } = param.range; + if (min !== undefined && max !== undefined) bits.push(`range ${min}–${max}`); + else if (min !== undefined) bits.push(`minimum ${min}`); + else if (max !== undefined) bits.push(`maximum ${max}`); + } + if (param.type === "enum") { + const values = param.values.map((value) => JSON.stringify(value)); + const shown = values.slice(0, 6).join(", "); + bits.push(`accepts ${shown}${values.length > 6 ? ", …" : ""}`); + } + const applicability = describeApplicability(param.applicability); + if (applicability.only.length > 0) bits.push(`only when ${applicability.only.join(" and ")}`); + else if (applicability.except.length > 0) bits.push(`not when ${applicability.except.join(" or ")}`); + return bits.join(", "); +} + +export interface ParamProseGroup { + label: string; + clauses: { path: string; fact: string }[]; +} + +/** Per-group prose summary of the parameter set — readable, exact-match-friendly text. */ +export function modelParamProse(model: Model): ParamProseGroup[] { + return groupParams(model.params).map(({ group, params }) => ({ + label: paramGroupLabel(group), + clauses: params.map((param) => ({ path: param.path, fact: paramFact(param) })), + })); +} + export function modelIntro(model: Model): string { const who = `${providerLabel(model.provider)} ${modelLabel(model)}`; if (model.params.length === 0) { @@ -57,6 +93,7 @@ export async function renderModelPage(model: Model, allModels: Model[]): Promise jsonPath: modelJsonPath(model), modelJson: JSON.stringify({ $schema: "https://modelparams.dev/api/v1/schema.json", ...model }, null, 2), isSubscription: model.authType === "subscription", + proseGroups: modelParamProse(model), }); const description = modelPageDescription(model); diff --git a/src/build/render-parameter.ts b/src/build/render-parameter.ts new file mode 100644 index 0000000..3685dd3 --- /dev/null +++ b/src/build/render-parameter.ts @@ -0,0 +1,90 @@ +import path from "node:path"; +import ejs from "ejs"; +import { paramGroupLabel } from "../data/display.js"; +import { + buildParameterIndex, + modelsWithoutParameter, + type ParameterDetail, +} from "../data/parameters.js"; +import { VIEWS_DIR } from "../data/paths.js"; +import { SITE_NAME, SITE_URL } from "../data/site.js"; +import { GLOSSARY_PATH, absolute, parameterPagePath } from "../data/urls.js"; +import { type Model, type Parameter } from "../schema/model.js"; +import { buildParameterStructuredData } from "./structured-data.js"; +import { hubLinks, renderShell, viewHelpers } from "./render.js"; + +const RELATED_LIMIT = 12; + +export function parameterPageTitle(detail: ParameterDetail): string { + return `${detail.label} (${detail.path}) parameter — defaults & ranges · ${SITE_NAME}`; +} + +export function parameterPageDescription(detail: ParameterDetail): string { + const group = paramGroupLabel(detail.group).toLowerCase(); + const models = `${detail.modelCount} model${detail.modelCount === 1 ? "" : "s"}`; + return `${detail.label} (${detail.path}) is an LLM ${group} parameter. Compare its type, default, and valid range across the ${models} in the catalog that accept it.`; +} + +function rangeOf(param: Parameter): { min?: number; max?: number } | undefined { + return (param.type === "integer" || param.type === "number") && param.range + ? param.range + : undefined; +} + +/** Most common default across the models that set one, or a "varies" note. */ +function defaultSummary(detail: ParameterDetail): string { + const defaults = detail.usages + .map((u) => u.param.default) + .filter((d): d is NonNullable => d !== undefined) + .map((d) => JSON.stringify(d)); + if (defaults.length === 0) return "no default"; + const unique = [...new Set(defaults)]; + return unique.length === 1 ? unique[0]! : "varies by model"; +} + +/** Widest numeric span any model allows, e.g. "0 – 2". */ +function rangeSummary(detail: ParameterDetail): string { + let min: number | undefined; + let max: number | undefined; + for (const usage of detail.usages) { + const range = rangeOf(usage.param); + if (range?.min !== undefined) min = min === undefined ? range.min : Math.min(min, range.min); + if (range?.max !== undefined) max = max === undefined ? range.max : Math.max(max, range.max); + } + if (min === undefined && max === undefined) return ""; + return `${min ?? "−∞"} – ${max ?? "+∞"}`; +} + +function relatedParameters(detail: ParameterDetail, allModels: Model[]): ParameterDetail[] { + return buildParameterIndex(allModels) + .filter((other) => other.group === detail.group && other.path !== detail.path) + .slice(0, RELATED_LIMIT); +} + +export async function renderParameterPage( + detail: ParameterDetail, + allModels: Model[], +): Promise { + const description = parameterPageDescription(detail); + const body = await ejs.renderFile(path.join(VIEWS_DIR, "parameter.ejs"), { + detail, + helpers: viewHelpers, + groupLabel: paramGroupLabel(detail.group), + defaultSummary: defaultSummary(detail), + rangeSummary: rangeSummary(detail), + related: relatedParameters(detail, allModels), + notDocumented: modelsWithoutParameter(detail, allModels), + glossaryPath: GLOSSARY_PATH, + }); + + return renderShell( + { + title: parameterPageTitle(detail), + description, + canonicalUrl: absolute(SITE_URL, parameterPagePath(detail.path)), + structuredData: buildParameterStructuredData(detail, description, SITE_URL), + providerHubs: hubLinks(allModels), + }, + body, + ); +} diff --git a/src/build/render.ts b/src/build/render.ts index 7054cfe..168fcd7 100644 --- a/src/build/render.ts +++ b/src/build/render.ts @@ -17,7 +17,13 @@ import { usageGuideMarkdown } from "../data/llms.js"; import { logoFor } from "../data/logos.js"; import { VIEWS_DIR } from "../data/paths.js"; import { OG_IMAGE_PATH, SITE_DESCRIPTION, SITE_NAME, SITE_URL } from "../data/site.js"; -import { absolute, modelPagePath, providerPagePath } from "../data/urls.js"; +import { + absolute, + modelPagePath, + parameterAnchorId, + parameterPagePath, + providerPagePath, +} from "../data/urls.js"; import { modelId, type Catalog, type Model } from "../schema/model.js"; import { buildHomeStructuredData } from "./structured-data.js"; @@ -38,6 +44,8 @@ export const viewHelpers = { groupParams, logoFor, modelPagePath, + parameterPagePath, + parameterAnchorId, providerPagePath, }; diff --git a/src/build/structured-data.ts b/src/build/structured-data.ts index c428566..5069614 100644 --- a/src/build/structured-data.ts +++ b/src/build/structured-data.ts @@ -3,12 +3,14 @@ import { modelLabel, paramLabel, providerLabel } from "../data/display.js"; import type { GlossaryGroup } from "../data/glossary.js"; +import type { ParameterDetail } from "../data/parameters.js"; import { SITE_DESCRIPTION, SITE_NAME } from "../data/site.js"; import { GLOSSARY_PATH, absolute, modelJsonPath, modelPagePath, + parameterPagePath, providerPagePath, } from "../data/urls.js"; import { type Model } from "../schema/model.js"; @@ -162,6 +164,40 @@ export function buildProviderStructuredData( return graph([crumbs, itemList]); } +export function buildParameterStructuredData( + detail: ParameterDetail, + description: string, + siteUrl: string, +): string { + const pagePath = parameterPagePath(detail.path); + const definedTerm = { + "@type": "DefinedTerm", + "@id": `${siteUrl}${pagePath}#term`, + name: detail.label, + termCode: detail.path, + description, + url: absolute(siteUrl, pagePath), + inDefinedTermSet: `${siteUrl}${GLOSSARY_PATH}#termset`, + }; + const itemList = { + "@type": "ItemList", + name: `Models that support ${detail.path}`, + numberOfItems: detail.modelCount, + itemListElement: detail.usages.map((usage, index) => ({ + "@type": "ListItem", + position: index + 1, + url: absolute(siteUrl, modelPagePath(usage.model)), + name: `${usage.providerName} ${usage.modelName}`, + })), + }; + const crumbs = breadcrumb(siteUrl, [ + { name: "Home", path: "/" }, + { name: "Glossary", path: GLOSSARY_PATH }, + { name: detail.label, path: pagePath }, + ]); + return graph([crumbs, definedTerm, itemList]); +} + export function buildGlossaryStructuredData(groups: GlossaryGroup[], siteUrl: string): string { const terms = groups .flatMap((groupItem) => groupItem.entries) diff --git a/src/data/llms.ts b/src/data/llms.ts index c2ff5a9..4f248dc 100644 --- a/src/data/llms.ts +++ b/src/data/llms.ts @@ -2,6 +2,8 @@ import { describeApplicability } from "./applicability.js"; import { buildProviderFacets } from "./catalog.js"; import { authLabel, modelLabel, paramGroupLabel, providerLabel } from "./display.js"; import { groupParams } from "./group.js"; +import { buildParameterIndex } from "./parameters.js"; +import { parameterPagePath } from "./urls.js"; import { modelId, type Model, type Parameter } from "../schema/model.js"; const REPO_URL = "https://github.com/mnfst/modelparams.dev"; @@ -168,8 +170,14 @@ export function buildLlmsTxt(siteUrl: string, models: Model[]): string { "## Guides", `- [Usage guide + full parameter dump](${siteUrl}/llms-full.txt): How to call the API plus every model's parameters inline.`, "", - "## Models", + "## Parameters", ]; + for (const detail of buildParameterIndex(models)) { + lines.push( + `- [${detail.path}](${siteUrl}${parameterPagePath(detail.path)}): ${detail.label} — default, range, and the ${plural(detail.modelCount, "model")} that accept it.`, + ); + } + lines.push("", "## Models"); for (const model of sortById(models)) { lines.push( `- [${modelId(model)}](${modelJsonUrl(siteUrl, model)}): ${modelTitle(model)} — ${plural(model.params.length, "parameter")}.`, diff --git a/src/data/parameters.ts b/src/data/parameters.ts new file mode 100644 index 0000000..f6f3981 --- /dev/null +++ b/src/data/parameters.ts @@ -0,0 +1,145 @@ +// Per-parameter index: one detail object per unique parameter path, carrying the +// full list of models that expose it (with each model's own type, default, range +// and conditions). Powers the /parameters/ pages and their structured data. +// +// buildGlossary aggregates the same data down to one summary row per path for the +// /glossary hub; this keeps the per-model detail the dedicated pages need. + +import { modelLabel, paramLabel, providerLabel } from "./display.js"; +import { parameterSlug } from "./urls.js"; +import { ParameterGroup, modelId, type Model, type Parameter } from "../schema/model.js"; + +export interface ParameterUsage { + id: string; + provider: string; + providerName: string; + modelName: string; + model: Model; + param: Parameter; +} + +export interface ParameterDetail { + path: string; + slug: string; + label: string; + group: string; + description: string; + types: string[]; + providers: string[]; + modelCount: number; + usages: ParameterUsage[]; +} + +interface Bucket { + path: string; + group: string; + labels: Map; + descriptions: Map; + types: Set; + providers: Set; + usages: ParameterUsage[]; +} + +/** Pick the most frequent string, breaking ties toward the more detailed one. */ +function mostCommon(counts: Map): string { + return ( + [...counts.entries()].sort((a, b) => b[1] - a[1] || b[0].length - a[0].length)[0]?.[0] ?? "" + ); +} + +function aggregate(models: Model[]): Map { + const acc = new Map(); + for (const model of models) { + for (const param of model.params) { + let bucket = acc.get(param.path); + if (!bucket) { + bucket = { + path: param.path, + group: param.group, + labels: new Map(), + descriptions: new Map(), + types: new Set(), + providers: new Set(), + usages: [], + }; + acc.set(param.path, bucket); + } + bucket.labels.set(param.label, (bucket.labels.get(param.label) ?? 0) + 1); + bucket.descriptions.set( + param.description, + (bucket.descriptions.get(param.description) ?? 0) + 1, + ); + bucket.types.add(param.type); + bucket.providers.add(model.provider); + bucket.usages.push({ + id: modelId(model), + provider: model.provider, + providerName: providerLabel(model.provider), + modelName: modelLabel(model), + model, + param, + }); + } + } + return acc; +} + +function toDetail(bucket: Bucket): ParameterDetail { + const usages = [...bucket.usages].sort( + (a, b) => + a.providerName.localeCompare(b.providerName) || a.modelName.localeCompare(b.modelName), + ); + return { + path: bucket.path, + slug: parameterSlug(bucket.path), + label: paramLabel(bucket.path, mostCommon(bucket.labels)), + group: bucket.group, + description: mostCommon(bucket.descriptions), + types: [...bucket.types].sort(), + providers: [...bucket.providers].sort(), + modelCount: new Set(bucket.usages.map((u) => u.id)).size, + usages, + }; +} + +const GROUP_ORDER = new Map( + ParameterGroup.options.map((group, index) => [group, index]), +); + +/** + * Every unique parameter, ordered by schema group then by how many models expose + * it (most-supported first) — the same ordering the glossary uses, so the hub and + * the detail pages agree. + */ +export function buildParameterIndex(models: Model[]): ParameterDetail[] { + return [...aggregate(models).values()] + .map(toDetail) + .sort( + (a, b) => + (GROUP_ORDER.get(a.group) ?? 0) - (GROUP_ORDER.get(b.group) ?? 0) || + b.modelCount - a.modelCount || + a.path.localeCompare(b.path), + ); +} + +/** Models that don't (yet) document a given parameter path, grouped by provider. */ +export function modelsWithoutParameter( + detail: ParameterDetail, + allModels: Model[], +): { provider: string; providerName: string; models: Model[] }[] { + const has = new Set(detail.usages.map((u) => u.id)); + const byProvider = new Map(); + for (const model of allModels) { + if (has.has(modelId(model))) continue; + const list = byProvider.get(model.provider) ?? []; + list.push(model); + byProvider.set(model.provider, list); + } + return [...byProvider.entries()] + .map(([provider, models]) => ({ + provider, + providerName: providerLabel(provider), + models: models.sort((a, b) => modelLabel(a).localeCompare(modelLabel(b))), + })) + .sort((a, b) => a.providerName.localeCompare(b.providerName)); +} diff --git a/src/data/urls.ts b/src/data/urls.ts index a0bc35c..55e9463 100644 --- a/src/data/urls.ts +++ b/src/data/urls.ts @@ -16,9 +16,32 @@ export function providerPagePath(provider: string): string { return `/providers/${provider}`; } -/** Parameter glossary page. */ +/** Parameter glossary page — the hub that links out to each parameter page. */ export const GLOSSARY_PATH = "/glossary"; +/** + * URL-safe slug for a parameter path: lowercased, with nested-path dots turned into + * hyphens, e.g. `thinking.type` → `thinking-type`. Underscores are kept so that + * `top_p` stays `top_p` and a dotted path never collides with its snake_case twin + * (e.g. `reasoning.effort` → `reasoning-effort` vs `reasoning_effort`). + */ +export function parameterSlug(path: string): string { + return path + .toLowerCase() + .replace(/\./g, "-") + .replace(/^-+|-+$/g, ""); +} + +/** Canonical HTML page for a single parameter, e.g. /parameters/temperature. */ +export function parameterPagePath(path: string): string { + return `/parameters/${parameterSlug(path)}`; +} + +/** In-page anchor id for a parameter row on a model page, e.g. param-top-p. */ +export function parameterAnchorId(path: string): string { + return `param-${parameterSlug(path)}`; +} + /** Existing JSON API endpoint for a model (unchanged; referenced for linking). */ export function modelJsonPath(model: ModelRef): string { return `/api/v1/models/${modelId(model)}.json`; diff --git a/src/server/app.ts b/src/server/app.ts index 49d3c39..5ed6505 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -2,10 +2,12 @@ import express from "express"; import { buildCapabilityFacets, buildCatalog, buildProviderFacets } from "../data/catalog.js"; import { buildLlmsFullTxt, buildLlmsTxt } from "../data/llms.js"; import { findModelParams } from "../data/model-params.js"; +import { buildParameterIndex } from "../data/parameters.js"; import { DIST_ASSETS_DIR } from "../data/paths.js"; import { buildModelJsonSchema } from "../schema/generate.js"; import { renderIndex } from "../build/render.js"; import { renderModelPage } from "../build/render-model.js"; +import { renderParameterPage } from "../build/render-parameter.js"; import { renderProviderPage } from "../build/render-provider.js"; import { renderGlossaryPage } from "../build/render-glossary.js"; import { renderApiPage } from "../build/render-api.js"; @@ -60,6 +62,21 @@ export function makeApp(loadModels: LoadModels): express.Express { } }); + app.get("/parameters/:slug", async (req, res, next) => { + try { + const models = await loadModels(); + const detail = buildParameterIndex(models).find((d) => d.slug === req.params.slug); + if (!detail) { + res.status(404).type("text/plain").send("Unknown parameter"); + return; + } + res.setHeader("Cache-Control", "no-store"); + res.type("html").send(await renderParameterPage(detail, models)); + } catch (err) { + next(err); + } + }); + app.get("/providers/:provider", async (req, res, next) => { try { const models = await loadModels(); diff --git a/src/views/glossary.ejs b/src/views/glossary.ejs index 682e75d..ea0de79 100644 --- a/src/views/glossary.ejs +++ b/src/views/glossary.ejs @@ -18,14 +18,14 @@ <% for (const entry of groupItem.entries) { %>
- <%= entry.label %> + <%= entry.label %> <% for (const t of entry.types) { %> <%= t %><% } %> <%= entry.path %>

<%= entry.description %>

- <%= entry.modelCount %> model<%= entry.modelCount === 1 ? "" : "s" %> / + <%= entry.modelCount %> model<%= entry.modelCount === 1 ? "" : "s" %> / <% entry.providers.forEach(function (provider, i) { %><%= helpers.providerLabel(provider) %><%= i < entry.providers.length - 1 ? ", " : "" %><% }) %>

diff --git a/src/views/model.ejs b/src/views/model.ejs index 2b582dc..fdb4ca7 100644 --- a/src/views/model.ejs +++ b/src/views/model.ejs @@ -28,16 +28,41 @@

+<% if (model.params.length >= 2) { %> + +<% } %> +
<% if (model.params.length === 0) { %>

No parameters documented yet.

<% } else { %> - <%- include("partials/param_table", { params: model.params, helpers: helpers }) %> + <%- include("partials/param_table", { params: model.params, helpers: helpers, detail: true }) %> <% } %>
+<% if (proseGroups.length > 0) { %> +
+

<%= providerName %> <%= modelName %> parameters in brief

+

+ <%= providerName %> <%= modelName %><% if (isSubscription) { %> via subscription<% } %> documents <%= model.params.length %> API parameter<%= model.params.length === 1 ? "" : "s" %>, grouped by what they control: +

+
    + <% for (const grp of proseGroups) { %> +
  • + <%= grp.label %>: + <% grp.clauses.forEach(function (clause, i) { %><%= clause.path %><% if (clause.fact) { %> <%= clause.fact %><% } %><%= i < grp.clauses.length - 1 ? "; " : "." %> <% }) %> +
  • + <% } %> +
+
+<% } %> +

Resources