Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 49 additions & 8 deletions src/build/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
Expand Down Expand Up @@ -54,19 +63,32 @@ async function writeRobotsAndSitemap(models: Model[]): Promise<void> {
// 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 }) =>
` <url><loc>${SITE_URL}${loc}</loc><lastmod>${today}</lastmod><priority>${priority}</priority></url>`,
({ path: loc, priority, lastmod }) =>
` <url><loc>${SITE_URL}${loc}</loc><lastmod>${lastmod}</lastmod><priority>${priority}</priority></url>`,
)
.join("\n");
const sitemap = `<?xml version="1.0" encoding="UTF-8"?>
Expand Down Expand Up @@ -97,6 +119,24 @@ async function writeHtmlPages(models: Model[]): Promise<void> {
await fs.writeFile(path.join(DIST_DIR, "api.html"), await renderApiPage(models), "utf8");
}

async function writeParameterPages(models: Model[]): Promise<void> {
const details = buildParameterIndex(models);
const dir = path.join(DIST_DIR, "parameters");
await fs.mkdir(dir, { recursive: true });
const seen = new Set<string>();
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<void> {
const body = {
name: "modelparams.dev API",
Expand Down Expand Up @@ -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);
Expand Down
45 changes: 45 additions & 0 deletions src/build/lastmod.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// Per-file last-modified dates for the sitemap, read from git so each model URL's
// <lastmod> 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<string, string> {
const map = new Map<string, string>();
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<string, string>, fallback: string): string {
return dates.get(modelSourcePath(model)) ?? fallback;
}
4 changes: 2 additions & 2 deletions src/build/render-glossary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
Expand Down
41 changes: 39 additions & 2 deletions src/build/render-model.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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) {
Expand Down Expand Up @@ -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);
Expand Down
90 changes: 90 additions & 0 deletions src/build/render-parameter.ts
Original file line number Diff line number Diff line change
@@ -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<typeof d> => 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<string> {
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,
);
}
10 changes: 9 additions & 1 deletion src/build/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -38,6 +44,8 @@ export const viewHelpers = {
groupParams,
logoFor,
modelPagePath,
parameterPagePath,
parameterAnchorId,
providerPagePath,
};

Expand Down
Loading
Loading