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
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions packages/modelparams/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ npm install modelparams

Stop guessing which knobs each model accepts. Get autocomplete on every parameter, compile-time errors on typos and unsupported settings, and the catalog's defaults at runtime — for every provider in one tiny zero-dependency package.

**Catalog & API:** [browse the catalog](https://modelparams.dev) · [JSON API](https://modelparams.dev/api/v1/models.json) · [API docs](https://modelparams.dev/api) · [llms.txt](https://modelparams.dev/llms.txt)

## Why

You're calling `claude-opus-4-7` with `frequency_penalty` set. TypeScript doesn't tell you the param doesn't exist. The provider silently ignores it. Your evals drift. Multiply by every model in your router.
Expand Down
5 changes: 4 additions & 1 deletion src/build/render-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import path from "node:path";
import ejs from "ejs";
import { describeApplicability } from "../data/applicability.js";
import { modelLabel, paramGroupLabel, providerLabel } from "../data/display.js";
import { modelFaq } from "../data/faq.js";
import { groupParams } from "../data/group.js";
import { VIEWS_DIR } from "../data/paths.js";
import { SITE_NAME, SITE_URL } from "../data/site.js";
Expand Down Expand Up @@ -82,10 +83,12 @@ export async function renderModelPage(model: Model, allModels: Model[]): Promise
.filter((other) => other.provider === model.provider && modelId(other) !== modelId(model))
.sort((a, b) => modelLabel(a).localeCompare(modelLabel(b)));

const faqs = modelFaq(model);
const body = await ejs.renderFile(path.join(VIEWS_DIR, "model.ejs"), {
model,
helpers: viewHelpers,
siblings,
faqs,
intro: modelIntro(model),
providerName: providerLabel(model.provider),
modelName: modelLabel(model),
Expand All @@ -102,7 +105,7 @@ export async function renderModelPage(model: Model, allModels: Model[]): Promise
title: modelPageTitle(model),
description,
canonicalUrl: absolute(SITE_URL, modelPagePath(model)),
structuredData: buildModelStructuredData(model, description, SITE_URL),
structuredData: buildModelStructuredData(model, description, SITE_URL, faqs),
providerHubs: hubLinks(allModels),
},
body,
Expand Down
26 changes: 23 additions & 3 deletions src/build/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { groupParams } from "../data/group.js";
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 { OG_IMAGE_PATH, SITE_NAME, SITE_URL } from "../data/site.js";
import {
absolute,
modelPagePath,
Expand Down Expand Up @@ -102,6 +102,25 @@ export interface RenderOptions {
analytics?: boolean;
}

/** Concrete, query-shaped homepage title — names the surface, carries the live model count. */
export function homeTitle(modelCount: number): string {
return `Compare model parameters across ${modelCount} models · ${SITE_NAME}`;
}

/**
* Benefit-first homepage description that names real parameters (the ones users
* actually search) plus live counts, instead of the generic site blurb.
*/
export function homeDescription(
modelCount: number,
providerCount: number,
sampleParams: string[],
): string {
const lead =
sampleParams.length > 0 ? `Compare ${sampleParams.join(", ")}, and every other ` : "Compare every ";
return `${lead}API parameter — defaults, ranges, and the conditions that gate each — across ${modelCount} models from ${providerCount} providers. An open, community-maintained catalog.`;
}

export async function renderIndex(opts: RenderOptions): Promise<string> {
const body = await ejs.renderFile(path.join(VIEWS_DIR, "index.ejs"), {
models: opts.catalog.models,
Expand All @@ -110,10 +129,11 @@ export async function renderIndex(opts: RenderOptions): Promise<string> {
helpers: viewHelpers,
});

const sampleParams = opts.capabilities.slice(0, 3).map((cap) => cap.path);
return renderShell(
{
title: `${SITE_NAME} — Open catalog of model parameters`,
description: SITE_DESCRIPTION,
title: homeTitle(opts.catalog.models.length),
description: homeDescription(opts.catalog.models.length, opts.providers.length, sampleParams),
canonicalUrl: `${SITE_URL}/`,
structuredData: buildHomeStructuredData(
opts.catalog.models,
Expand Down
36 changes: 34 additions & 2 deletions src/build/structured-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// they can be unit-tested without touching the filesystem or the renderer.

import { modelLabel, paramLabel, providerLabel } from "../data/display.js";
import type { ModelFaq } from "../data/faq.js";
import type { GlossaryGroup } from "../data/glossary.js";
import type { ParameterDetail } from "../data/parameters.js";
import { SITE_DESCRIPTION, SITE_NAME } from "../data/site.js";
Expand All @@ -15,6 +16,9 @@ import {
} from "../data/urls.js";
import { type Model } from "../schema/model.js";

const REPO_URL = "https://github.com/mnfst/modelparams.dev";
const NPM_URL = "https://www.npmjs.com/package/modelparams";

interface Crumb {
name: string;
path: string;
Expand All @@ -36,13 +40,25 @@ function graph(nodes: unknown[]): string {
return JSON.stringify({ "@context": "https://schema.org", "@graph": nodes });
}

function organizationNode(siteUrl: string) {
return {
"@type": "Organization",
"@id": `${siteUrl}/#org`,
name: SITE_NAME,
url: `${siteUrl}/`,
description: SITE_DESCRIPTION,
sameAs: [REPO_URL, NPM_URL],
};
}

function homeWebsiteNode(siteUrl: string) {
return {
"@type": "WebSite",
"@id": `${siteUrl}/#website`,
url: `${siteUrl}/`,
name: SITE_NAME,
description: SITE_DESCRIPTION,
publisher: { "@id": `${siteUrl}/#org` },
potentialAction: {
"@type": "SearchAction",
target: { "@type": "EntryPoint", urlTemplate: `${siteUrl}/?q={search_term_string}` },
Expand All @@ -61,7 +77,7 @@ function homeDatasetNode(siteUrl: string, imageUrl: string) {
image: imageUrl,
license: "https://opensource.org/licenses/MIT",
isAccessibleForFree: true,
creator: { "@type": "Organization", name: SITE_NAME, url: `${siteUrl}/` },
creator: { "@id": `${siteUrl}/#org` },
distribution: {
"@type": "DataDownload",
encodingFormat: "application/json",
Expand Down Expand Up @@ -97,16 +113,30 @@ export function buildHomeStructuredData(
imageUrl: string,
): string {
return graph([
organizationNode(siteUrl),
homeWebsiteNode(siteUrl),
homeDatasetNode(siteUrl, imageUrl),
homeItemListNode(models, siteUrl),
]);
}

function faqPageNode(faqs: ModelFaq[], model: Model, siteUrl: string) {
return {
"@type": "FAQPage",
"@id": `${siteUrl}${modelPagePath(model)}#faq`,
mainEntity: faqs.map((faq) => ({
"@type": "Question",
name: faq.question,
acceptedAnswer: { "@type": "Answer", text: faq.answer },
})),
};
}

export function buildModelStructuredData(
model: Model,
description: string,
siteUrl: string,
faqs: ModelFaq[] = [],
): string {
const name = `${providerLabel(model.provider)} ${modelLabel(model)} parameters`;
const dataset = {
Expand Down Expand Up @@ -136,7 +166,9 @@ export function buildModelStructuredData(
{ name: providerLabel(model.provider), path: providerPagePath(model.provider) },
{ name: modelLabel(model), path: modelPagePath(model) },
]);
return graph([crumbs, dataset]);
const nodes: unknown[] = [crumbs, dataset];
if (faqs.length > 0) nodes.push(faqPageNode(faqs, model, siteUrl));
return graph(nodes);
}

export function buildProviderStructuredData(
Expand Down
60 changes: 60 additions & 0 deletions src/data/faq.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// Data-driven FAQ for a model page. Pure function of the catalog entry so it can
// back both the visible Q&A section and the FAQPage JSON-LD without duplicating
// logic. The questions mirror real long-tail queries ("what is the default
// temperature for <model>") and every answer is derived from the tracked data.

import { modelLabel, providerLabel } from "./display.js";
import type { Model, Parameter } from "../schema/model.js";

export interface ModelFaq {
question: string;
answer: string;
}

/** Widely-searched sampling knobs, in the order we surface them when present. */
const FAQ_PARAMS = ["temperature", "top_p", "max_tokens", "top_k"];
const MAX_FAQS = 5;

function subjectOf(model: Model): string {
const auth = model.authType === "subscription" ? " (subscription)" : "";
return `${providerLabel(model.provider)} ${modelLabel(model)}${auth}`;
}

function defaultAnswer(param: Parameter, subject: string): string {
let answer = `The default ${param.path} for ${subject} is ${JSON.stringify(param.default)}`;
if ((param.type === "integer" || param.type === "number") && param.range) {
const { min, max } = param.range;
if (min !== undefined && max !== undefined)
answer += `, within a valid range of ${min} to ${max}`;
else if (min !== undefined) answer += `, with a minimum of ${min}`;
else if (max !== undefined) answer += `, with a maximum of ${max}`;
}
return `${answer}.`;
}

/** Up to five Q&A pairs for a model, or none when it has no documented parameters. */
export function modelFaq(model: Model): ModelFaq[] {
if (model.params.length === 0) return [];
const subject = subjectOf(model);
const paths = model.params.map((param) => param.path);
const faqs: ModelFaq[] = [
{
question: `How many parameters does ${subject} accept?`,
answer: `${subject} accepts ${model.params.length} API parameter${
model.params.length === 1 ? "" : "s"
}: ${paths.slice(0, 6).join(", ")}${paths.length > 6 ? ", and more" : ""}.`,
},
];
const byPath = new Map(model.params.map((param) => [param.path, param]));
for (const path of FAQ_PARAMS) {
if (faqs.length >= MAX_FAQS) break;
const param = byPath.get(path);
if (param && param.default !== undefined) {
faqs.push({
question: `What is the default ${path} for ${subject}?`,
answer: defaultAnswer(param, subject),
});
}
}
return faqs;
}
23 changes: 23 additions & 0 deletions src/views/index.ejs
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,29 @@
</p>
</section>

<% if (capabilities.length > 0) { %>
<section class="mt-12 border-t border-slate-200 pt-8 dark:border-slate-800" aria-label="Browse by parameter">
<div class="flex items-baseline justify-between gap-3">
<h2 class="text-slate-900 dark:text-slate-100">Browse by parameter</h2>
<a href="/glossary" class="shrink-0 text-sm font-medium text-accent hover:text-accent-hover">All parameters →</a>
</div>
<p class="mt-2 max-w-2xl text-sm text-slate-600 dark:text-slate-300">
Open any parameter to see its default, range, and every model that accepts it.
</p>
<div class="mt-4 flex flex-wrap gap-2">
<% for (const cap of capabilities) { %>
<a
href="<%= helpers.parameterPagePath(cap.path) %>"
class="inline-flex items-center gap-1.5 rounded-full border border-slate-200 bg-white px-2.5 py-1 font-mono text-xs text-slate-600 hover:border-slate-300 hover:text-accent dark:border-slate-800 dark:bg-slate-900 dark:text-slate-300 dark:hover:border-slate-700"
>
<%= cap.path %>
<span class="tabular-nums text-slate-400 dark:text-slate-500"><%= cap.count %></span>
</a>
<% } %>
</div>
</section>
<% } %>

<button
type="button"
data-scroll-top
Expand Down
16 changes: 15 additions & 1 deletion src/views/model.ejs
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@

<% if (proseGroups.length > 0) { %>
<section class="mt-10" aria-label="Parameter summary">
<h2 class="text-slate-900 dark:text-slate-100"><%= providerName %> <%= modelName %> parameters in brief</h2>
<h2 class="text-slate-900 dark:text-slate-100"><%= providerName %> <%= modelName %> API parameters in brief</h2>
<p class="mt-2 max-w-2xl text-sm text-slate-600 dark:text-slate-300">
<%= providerName %> <%= modelName %><% if (isSubscription) { %> via subscription<% } %> documents <%= model.params.length %> API parameter<%= model.params.length === 1 ? "" : "s" %>, grouped by what they control:
</p>
Expand All @@ -63,6 +63,20 @@
</section>
<% } %>

<% if (faqs.length > 0) { %>
<section class="mt-10" aria-label="Frequently asked questions">
<h2 class="text-slate-900 dark:text-slate-100">Frequently asked questions</h2>
<dl class="mt-3 max-w-2xl space-y-4">
<% for (const faq of faqs) { %>
<div>
<dt class="font-medium text-slate-900 dark:text-slate-100"><%= faq.question %></dt>
<dd class="mt-1 text-sm text-slate-600 dark:text-slate-300"><%= faq.answer %></dd>
</div>
<% } %>
</dl>
</section>
<% } %>

<h2 class="mt-8 text-slate-900 dark:text-slate-100">Resources</h2>
<div class="mt-3 grid grid-cols-1 text-sm sm:grid-cols-2 lg:grid-cols-4">
<button type="button" data-open-json-modal class="inline-flex items-center justify-center gap-2 border border-slate-200 px-4 py-3 text-accent hover:bg-warm-hover hover:text-accent-hover dark:border-slate-800 dark:hover:bg-slate-900 sm:-mr-px sm:-mb-px lg:-mb-0">
Expand Down
78 changes: 78 additions & 0 deletions tests/faq.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { describe, it, expect } from "vitest";
import { modelFaq } from "../src/data/faq.js";
import type { Model } from "../src/schema/model.js";

function model(over: Partial<Model> = {}): Model {
return {
provider: "anthropic",
authType: "api_key",
model: "claude-opus-4-7",
params: [
{
path: "temperature",
type: "number",
label: "Temperature",
description: "Sampling temperature.",
default: 1,
range: { min: 0, max: 2 },
group: "sampling",
},
{
path: "max_tokens",
type: "integer",
label: "Max tokens",
description: "Maximum output tokens.",
default: 4096,
range: { min: 1 },
group: "generation_length",
},
],
...over,
} as Model;
}

describe("modelFaq", () => {
it("leads with the parameter count and names the model", () => {
const faqs = modelFaq(model());
expect(faqs[0]!.question).toBe("How many parameters does Anthropic Claude Opus 4.7 accept?");
expect(faqs[0]!.answer).toContain("2 API parameters");
expect(faqs[0]!.answer).toContain("temperature");
});

it("answers default questions with the value and range from the data", () => {
const faqs = modelFaq(model());
const temp = faqs.find((f) => f.question.includes("default temperature"));
expect(temp?.answer).toBe(
"The default temperature for Anthropic Claude Opus 4.7 is 1, within a valid range of 0 to 2.",
);
const maxTokens = faqs.find((f) => f.question.includes("default max_tokens"));
expect(maxTokens?.answer).toContain("with a minimum of 1");
});

it("marks the subscription variant in the subject", () => {
const faqs = modelFaq(model({ authType: "subscription" }));
expect(faqs[0]!.question).toContain("Anthropic Claude Opus 4.7 (subscription)");
});

it("skips parameters without a documented default", () => {
const faqs = modelFaq(
model({
params: [
{
path: "top_p",
type: "number",
label: "Top P",
description: "Nucleus.",
group: "sampling",
},
],
}),
);
expect(faqs).toHaveLength(1);
expect(faqs[0]!.question).toContain("How many parameters");
});

it("returns nothing for a model with no parameters", () => {
expect(modelFaq(model({ params: [] }))).toEqual([]);
});
});
Loading
Loading