From 1e917c1b85a072d1fd6541a47697ef5a8f3a90e1 Mon Sep 17 00:00:00 2001 From: Bruno Perez Date: Thu, 21 May 2026 11:51:44 +0200 Subject: [PATCH] feat: make the catalog easy for AI agents to use Add a Copy button to the "How to use" modal that yields the guide as Markdown, plus agent-discovery surfaces so the catalog is usable without scraping HTML. - Copy button copies the usage guide as Markdown (clipboard + execCommand fallback) - /llms.txt and /llms-full.txt generated from the YAML catalog at build time - WebMCP tools on navigator.modelContext: search, model params, providers, parameters, usage guide - Structured data: WebSite + SearchAction + Dataset alongside the ItemList - /?q= deep-links search; robots.txt + sitemap point at the new files; CORS on the .txt files - Fix dev watcher: scope dotfile-ignore to basename so a checkout under ~/.paseo no longer disables it; poll by default --- src/build/build.ts | 31 +++- src/build/render.ts | 37 ++++- src/client/main.ts | 63 +++++++ src/client/webmcp.ts | 267 ++++++++++++++++++++++++++++++ src/data/llms.ts | 219 ++++++++++++++++++++++++ src/server/dev.ts | 44 ++++- src/views/layout.ejs | 3 +- src/views/partials/how_to_use.ejs | 51 ++++-- tests/llms.test.ts | 96 +++++++++++ vercel.json | 8 + 10 files changed, 796 insertions(+), 23 deletions(-) create mode 100644 src/client/webmcp.ts create mode 100644 src/data/llms.ts create mode 100644 tests/llms.test.ts diff --git a/src/build/build.ts b/src/build/build.ts index e49e012..407973d 100644 --- a/src/build/build.ts +++ b/src/build/build.ts @@ -6,13 +6,14 @@ import { buildProviderFacets, } from "../data/catalog.js"; import { loadAllModels } from "../data/load.js"; +import { buildLlmsFullTxt, buildLlmsTxt } from "../data/llms.js"; import { DIST_API_DIR, DIST_ASSETS_DIR, DIST_DIR, MODELS_DIR, } from "../data/paths.js"; -import { modelId } from "../schema/model.js"; +import { modelId, type Model } from "../schema/model.js"; import { buildModelJsonSchema } from "../schema/generate.js"; import { bundleClientScript, compileStyles, copyStaticAssets } from "./assets.js"; import { renderIndex } from "./render.js"; @@ -30,15 +31,36 @@ async function writeJson(file: string, payload: unknown): Promise { await fs.writeFile(file, JSON.stringify(payload, null, 2) + "\n", "utf8"); } +async function writeLlmsFiles(models: Model[]): Promise { + await fs.writeFile(path.join(DIST_DIR, "llms.txt"), buildLlmsTxt(SITE_URL, models), "utf8"); + await fs.writeFile( + path.join(DIST_DIR, "llms-full.txt"), + buildLlmsFullTxt(SITE_URL, models), + "utf8", + ); +} + async function writeRobotsAndSitemap(): Promise { - const robots = `User-agent: *\nAllow: /\nSitemap: ${SITE_URL}/sitemap.xml\n`; + const robots = `# AI agents welcome. Machine-readable overview: ${SITE_URL}/llms.txt\nUser-agent: *\nAllow: /\nSitemap: ${SITE_URL}/sitemap.xml\n`; await fs.writeFile(path.join(DIST_DIR, "robots.txt"), robots, "utf8"); const today = new Date().toISOString().slice(0, 10); + const urls = [ + { loc: `${SITE_URL}/`, priority: "1.0" }, + { loc: `${SITE_URL}/llms.txt`, priority: "0.8" }, + { loc: `${SITE_URL}/llms-full.txt`, priority: "0.7" }, + { loc: `${SITE_URL}/api/v1/models.json`, priority: "0.5" }, + { loc: `${SITE_URL}/api/v1/schema.json`, priority: "0.5" }, + ]; + const body = urls + .map( + ({ loc, priority }) => + ` ${loc}${today}${priority}`, + ) + .join("\n"); const sitemap = ` - ${SITE_URL}/${today}1.0 - ${SITE_URL}/api/v1/models.json${today}0.5 +${body} `; await fs.writeFile(path.join(DIST_DIR, "sitemap.xml"), sitemap, "utf8"); @@ -99,6 +121,7 @@ export async function build(): Promise<{ models: number }> { console.log("Bundling client + styles..."); await Promise.all([bundleClientScript(), compileStyles(), copyStaticAssets()]); + await writeLlmsFiles(models); await writeRobotsAndSitemap(); const elapsed = ((Date.now() - startedAt) / 1000).toFixed(2); diff --git a/src/build/render.ts b/src/build/render.ts index d4379ca..6f21499 100644 --- a/src/build/render.ts +++ b/src/build/render.ts @@ -14,6 +14,7 @@ import { providerLabel, } from "../data/display.js"; 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 { modelId, type Catalog, type Model } from "../schema/model.js"; @@ -30,8 +31,37 @@ export interface RenderOptions { } function buildStructuredData(models: Model[]): string { - const data = { - "@context": "https://schema.org", + const website = { + "@type": "WebSite", + "@id": `${SITE_URL}/#website`, + url: `${SITE_URL}/`, + name: "modelparameters.dev", + description: SITE_DESCRIPTION, + potentialAction: { + "@type": "SearchAction", + target: { + "@type": "EntryPoint", + urlTemplate: `${SITE_URL}/?q={search_term_string}`, + }, + "query-input": "required name=search_term_string", + }, + }; + const dataset = { + "@type": "Dataset", + "@id": `${SITE_URL}/#dataset`, + name: "modelparameters.dev catalog", + description: SITE_DESCRIPTION, + url: `${SITE_URL}/`, + license: "https://opensource.org/licenses/MIT", + isAccessibleForFree: true, + creator: { "@type": "Organization", name: "modelparameters.dev", url: `${SITE_URL}/` }, + distribution: { + "@type": "DataDownload", + encodingFormat: "application/json", + contentUrl: `${SITE_URL}/api/v1/models.json`, + }, + }; + const itemList = { "@type": "ItemList", name: "modelparameters.dev catalog", description: SITE_DESCRIPTION, @@ -48,7 +78,7 @@ function buildStructuredData(models: Model[]): string { }, })), }; - return JSON.stringify(data); + return JSON.stringify({ "@context": "https://schema.org", "@graph": [website, dataset, itemList] }); } export async function renderIndex(opts: RenderOptions): Promise { @@ -80,6 +110,7 @@ export async function renderIndex(opts: RenderOptions): Promise { canonicalUrl: SITE_URL, initialThemeClass: opts.initialThemeClass ?? "", structuredData: buildStructuredData(opts.catalog.models), + usageGuide: usageGuideMarkdown(SITE_URL), body, }); diff --git a/src/client/main.ts b/src/client/main.ts index 14f2e1b..a53994f 100644 --- a/src/client/main.ts +++ b/src/client/main.ts @@ -1,3 +1,5 @@ +import { setupWebMCP } from "./webmcp.js"; + type AuthFilter = "all" | "api_key" | "subscription"; interface FilterState { @@ -43,6 +45,57 @@ function setupHowToUseModal(): void { }); } +async function copyText(text: string): Promise { + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + return true; + } + } catch { + /* fall through to the execCommand path below */ + } + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.setAttribute("readonly", ""); + textarea.style.position = "fixed"; + textarea.style.opacity = "0"; + document.body.appendChild(textarea); + textarea.select(); + let copied = false; + try { + copied = document.execCommand("copy"); + } catch { + copied = false; + } + document.body.removeChild(textarea); + return copied; +} + +function setupCopyHowToUse(): void { + const button = document.querySelector("[data-copy-how-to-use]"); + const source = document.getElementById("how-to-use-md"); + if (!button || !source) return; + + const label = button.querySelector("[data-copy-label]"); + const idleIcon = button.querySelector("[data-copy-idle]"); + const doneIcon = button.querySelector("[data-copy-done]"); + const idleText = label?.textContent ?? "Copy"; + let resetTimer = 0; + + button.addEventListener("click", async () => { + const copied = await copyText((source.textContent ?? "").trim()); + if (label) label.textContent = copied ? "Copied" : "Press ⌘C"; + idleIcon?.classList.toggle("hidden", copied); + doneIcon?.classList.toggle("hidden", !copied); + window.clearTimeout(resetTimer); + resetTimer = window.setTimeout(() => { + if (label) label.textContent = idleText; + idleIcon?.classList.remove("hidden"); + doneIcon?.classList.add("hidden"); + }, 2000); + }); +} + function setupThemeToggle(): void { const toggle = document.querySelector("[data-theme-toggle]"); if (!toggle) return; @@ -100,6 +153,14 @@ function setupSearch(): void { state.query = input.value.trim().toLowerCase(); applyFilters(); }); + + // Deep link: /?q=opus pre-fills the search (backs the schema.org SearchAction). + const initial = new URLSearchParams(window.location.search).get("q"); + if (initial) { + input.value = initial; + state.query = initial.trim().toLowerCase(); + applyFilters(); + } } function setupAuthFilters(): void { @@ -136,8 +197,10 @@ function setupToggleChips(selector: string, datasetKey: string, bucket: Set { setupThemeToggle(); setupHowToUseModal(); + setupCopyHowToUse(); setupSearch(); setupAuthFilters(); setupToggleChips("[data-provider]", "provider", state.providers); setupToggleChips("[data-capability]", "capability", state.capabilities); + setupWebMCP(); }); diff --git a/src/client/webmcp.ts b/src/client/webmcp.ts new file mode 100644 index 0000000..043ee05 --- /dev/null +++ b/src/client/webmcp.ts @@ -0,0 +1,267 @@ +// Exposes the catalog to in-browser agents via the WebMCP API +// (window.navigator.modelContext). See https://github.com/webmachinelearning/webmcp. +// Standalone by design: it talks to the JSON API and the DOM, so it pulls no +// server-side modules (and no zod) into the client bundle. + +type AuthType = "api_key" | "subscription"; + +interface CatalogParam { + path: string; + type: string; + group: string; + description: string; + default?: unknown; + values?: unknown[]; +} + +interface CatalogModel { + provider: string; + authType: AuthType; + model: string; + params: CatalogParam[]; +} + +interface Catalog { + count: number; + generatedAt?: string; + models: CatalogModel[]; +} + +interface ToolResponse { + content: Array<{ type: "text"; text: string }>; +} + +interface ToolDefinition { + name: string; + description: string; + inputSchema: { + type: "object"; + properties: Record; + required?: string[]; + }; + execute: (params: Record) => Promise | ToolResponse; +} + +interface ModelContext { + provideContext?: (context: { tools: ToolDefinition[] }) => void; +} + +function modelId(model: CatalogModel): string { + const suffix = model.authType === "subscription" ? "-subscription" : ""; + return `${model.provider}/${model.model}${suffix}`; +} + +let catalogPromise: Promise | null = null; + +function getCatalog(): Promise { + if (!catalogPromise) { + catalogPromise = fetch("/api/v1/models.json") + .then((res) => { + if (!res.ok) throw new Error(`catalog fetch failed (${res.status})`); + return res.json() as Promise; + }) + .catch((err) => { + catalogPromise = null; + throw err; + }); + } + return catalogPromise; +} + +function ok(payload: unknown): ToolResponse { + return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] }; +} + +function asString(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function asStringList(value: unknown): string[] { + if (Array.isArray(value)) return value.filter((v): v is string => typeof v === "string"); + const single = asString(value); + return single ? [single] : []; +} + +// Mirror the agent's query onto the visible filter controls so a human watching +// the page sees what the agent searched for. Best-effort: it drives the same +// buttons/inputs a person would, and silently skips controls that aren't present. +function reflectInUi(filters: { + query?: string; + auth?: string; + providers: string[]; + capabilities: string[]; +}): void { + const search = document.querySelector("[data-search]"); + if (search && filters.query !== undefined) { + search.value = filters.query; + search.dispatchEvent(new Event("input", { bubbles: true })); + } + if (filters.auth) { + document.querySelector(`[data-auth-filter="${filters.auth}"]`)?.click(); + } + setToggleGroup("[data-provider]", "provider", filters.providers); + setToggleGroup("[data-capability]", "capability", filters.capabilities); +} + +function setToggleGroup(selector: string, key: string, wanted: string[]): void { + if (wanted.length === 0) return; + const want = new Set(wanted); + document.querySelectorAll(selector).forEach((btn) => { + const value = btn.dataset[key] ?? ""; + const active = btn.dataset.active === "true"; + if (want.has(value) !== active) btn.click(); + }); +} + +function searchModels(catalog: Catalog, params: Record) { + const query = asString(params.query)?.toLowerCase(); + const auth = asString(params.auth); + const providers = asStringList(params.provider); + const capabilities = asStringList(params.capability); + const limit = typeof params.limit === "number" ? Math.max(1, Math.floor(params.limit)) : 25; + + const matches = catalog.models.filter((model) => { + if (auth && auth !== "all" && model.authType !== auth) return false; + if (providers.length > 0 && !providers.includes(model.provider)) return false; + const paths = new Set(model.params.map((p) => p.path)); + if (!capabilities.every((cap) => paths.has(cap))) return false; + if (query) { + const haystack = `${model.model} ${model.provider} ${modelId(model)}`.toLowerCase(); + if (!haystack.includes(query)) return false; + } + return true; + }); + + reflectInUi({ query: asString(params.query) ?? "", auth, providers, capabilities }); + + return { + total: matches.length, + returned: Math.min(matches.length, limit), + truncated: matches.length > limit, + models: matches.slice(0, limit).map((model) => ({ + id: modelId(model), + provider: model.provider, + model: model.model, + authType: model.authType, + parameterCount: model.params.length, + parameters: model.params.map((p) => p.path), + })), + }; +} + +async function getModelParameters(params: Record): Promise { + const id = asString(params.id); + if (!id) return ok({ error: "Provide an `id` such as anthropic/claude-opus-4-7." }); + const res = await fetch(`/api/v1/models/${id}.json`); + if (!res.ok) return ok({ error: `No model with id "${id}" (HTTP ${res.status}).` }); + return ok(await res.json()); +} + +function usageGuideText(): string { + const embedded = document.getElementById("how-to-use-md")?.textContent?.trim(); + return embedded && embedded.length > 0 + ? embedded + : "Usage guide unavailable on this page; fetch /llms-full.txt instead."; +} + +function buildTools(): ToolDefinition[] { + return [ + { + name: "search_models", + description: + "Search the LLM parameter catalog. Filter by free-text query, provider slug, " + + "required parameter path(s), and auth type (api_key | subscription | all). Also " + + "mirrors the query onto the page's visible filters. Returns matching model ids.", + inputSchema: { + type: "object", + properties: { + query: { type: "string", description: "Free-text match on model name, provider, or id." }, + provider: { + type: "string", + description: "Provider slug, e.g. anthropic, openai, deepseek.", + }, + capability: { + type: "array", + items: { type: "string" }, + description: "Parameter path(s) the model must support, e.g. thinking.type.", + }, + auth: { + type: "string", + enum: ["all", "api_key", "subscription"], + description: "Auth variant to include. Defaults to all.", + }, + limit: { type: "number", description: "Max models to return (default 25)." }, + }, + }, + execute: async (params) => ok(searchModels(await getCatalog(), params)), + }, + { + name: "get_model_parameters", + description: + "Fetch the full parameter set for one model by id (e.g. anthropic/claude-opus-4-7, " + + "or append -subscription for the subscription variant). Returns every parameter with " + + "type, default, range, allowed values, and applicability conditions.", + inputSchema: { + type: "object", + properties: { + id: { type: "string", description: "Model id: provider/model[-subscription]." }, + }, + required: ["id"], + }, + execute: getModelParameters, + }, + { + name: "list_providers", + description: "List every provider in the catalog with its model count.", + inputSchema: { type: "object", properties: {} }, + execute: async () => { + const catalog = await getCatalog(); + const counts = new Map(); + for (const model of catalog.models) { + counts.set(model.provider, (counts.get(model.provider) ?? 0) + 1); + } + const providers = [...counts.entries()] + .map(([provider, count]) => ({ provider, count })) + .sort((a, b) => b.count - a.count || a.provider.localeCompare(b.provider)); + return ok({ total: providers.length, providers }); + }, + }, + { + name: "list_parameters", + description: + "List every parameter path documented across the catalog with how many models expose it.", + inputSchema: { type: "object", properties: {} }, + execute: async () => { + const catalog = await getCatalog(); + const counts = new Map(); + for (const model of catalog.models) { + for (const path of new Set(model.params.map((p) => p.path))) { + counts.set(path, (counts.get(path) ?? 0) + 1); + } + } + const parameters = [...counts.entries()] + .map(([path, count]) => ({ path, count })) + .sort((a, b) => b.count - a.count || a.path.localeCompare(b.path)); + return ok({ total: parameters.length, parameters }); + }, + }, + { + name: "get_usage_guide", + description: + "Return the modelparameters.dev usage guide as Markdown: how to call the API, the " + + "JSON Schema, logos, and how to contribute. Hand this to a coding agent verbatim.", + inputSchema: { type: "object", properties: {} }, + execute: () => ({ content: [{ type: "text", text: usageGuideText() }] }), + }, + ]; +} + +export function setupWebMCP(): void { + const context = (window.navigator as Navigator & { modelContext?: ModelContext }).modelContext; + if (!context || typeof context.provideContext !== "function") return; + try { + context.provideContext({ tools: buildTools() }); + } catch { + /* a stricter host may reject the registration; degrade to plain HTML/JSON */ + } +} diff --git a/src/data/llms.ts b/src/data/llms.ts new file mode 100644 index 0000000..cc99c14 --- /dev/null +++ b/src/data/llms.ts @@ -0,0 +1,219 @@ +import { describeApplicability } from "./applicability.js"; +import { buildProviderFacets } from "./catalog.js"; +import { authLabel, modelLabel, paramGroupLabel, providerLabel } from "./display.js"; +import { groupParams } from "./group.js"; +import { modelId, type Model, type Parameter } from "../schema/model.js"; + +const REPO_URL = "https://github.com/mnfst/modelparameters.dev"; + +function modelJsonUrl(siteUrl: string, model: Model): string { + return `${siteUrl}/api/v1/models/${modelId(model)}.json`; +} + +function modelTitle(model: Model): string { + return `${providerLabel(model.provider)} ${modelLabel(model)} (${authLabel(model.authType)})`; +} + +function plural(n: number, word: string): string { + return `${n} ${word}${n === 1 ? "" : "s"}`; +} + +function guideIntro(siteUrl: string): string[] { + return [ + "# How to use modelparameters.dev", + "", + `[modelparameters.dev](${siteUrl}) is an open, community-maintained catalog of LLM model`, + "parameters. Each entry shows the knobs you can turn — type, default, range, and the", + "conditions that gate it.", + "", + "The same model accessed via an **API key** and via a **subscription** usually exposes a", + "different set of parameters. We list both as separate entries so the data stays honest.", + "", + ]; +} + +function guideApi(siteUrl: string): string[] { + return [ + "## Catalog API", + "", + "The full catalog is static JSON, CORS-enabled, served from the edge:", + "", + "```bash", + `curl ${siteUrl}/api/v1/models.json`, + "```", + "", + "Each entry is keyed by `provider/model` for API-key variants; subscription variants", + "append `-subscription`.", + "", + "## Single model", + "", + "```bash", + `curl ${siteUrl}/api/v1/models/anthropic/claude-opus-4-7.json`, + `curl ${siteUrl}/api/v1/models/anthropic/claude-opus-4-7-subscription.json`, + "```", + "", + "## JSON Schema", + "", + "Every entry validates against a JSON Schema you can use in your editor or pipeline:", + "", + "```bash", + `curl ${siteUrl}/api/v1/schema.json`, + "```", + "", + "Add this header to any YAML you author for autocomplete in VS Code:", + "", + "```yaml", + `# yaml-language-server: $schema=${siteUrl}/api/v1/schema.json`, + "```", + "", + "## Logos", + "", + "Provider logos are at `/assets/logos/{provider}.svg`. They use `currentColor`, so they", + "inherit your text color:", + "", + "```bash", + `curl ${siteUrl}/assets/logos/anthropic.svg`, + "```", + "", + ]; +} + +function guideAgents(siteUrl: string): string[] { + return [ + "## Contribute", + "", + "The data lives in YAML under `models/{provider}/{model}-{auth}.yaml` in the", + `[GitHub repo](${REPO_URL}). Open a PR; CI validates against the schema and rebuilds.`, + "", + "## For agents", + "", + `- Machine-readable site overview: ${siteUrl}/llms.txt`, + `- Full usage guide plus every parameter inline: ${siteUrl}/llms-full.txt`, + "- When your browser supports it, this page registers in-browser **WebMCP** tools on", + " `navigator.modelContext`: `search_models`, `get_model_parameters`, `list_providers`,", + " `list_parameters`, and `get_usage_guide`.", + "", + ]; +} + +/** + * The "How to use" guide as Markdown. This is the canonical agent-facing doc: + * it backs the modal's Copy button and the body of /llms-full.txt. + */ +export function usageGuideMarkdown(siteUrl: string): string { + return [...guideIntro(siteUrl), ...guideApi(siteUrl), ...guideAgents(siteUrl)].join("\n"); +} + +function fmtValue(value: unknown): string { + if (value === null) return "null"; + if (typeof value === "string") return `"${value}"`; + return String(value); +} + +function paramConstraints(param: Parameter): string { + const bits: string[] = []; + if (param.default !== undefined) bits.push(`default: ${fmtValue(param.default)}`); + if (param.type === "enum") bits.push(`values: ${param.values.map(fmtValue).join(", ")}`); + if ((param.type === "integer" || param.type === "number") && param.range) { + const range: string[] = []; + if (param.range.min !== undefined) range.push(`min ${param.range.min}`); + if (param.range.max !== undefined) range.push(`max ${param.range.max}`); + if (param.range.step !== undefined) range.push(`step ${param.range.step}`); + if (range.length > 0) bits.push(`range: ${range.join(", ")}`); + } + const applicability = describeApplicability(param.applicability); + if (applicability.only.length > 0) bits.push(`only when ${applicability.only.join("; ")}`); + if (applicability.except.length > 0) bits.push(`except when ${applicability.except.join("; ")}`); + return bits.length > 0 ? ` [${bits.join("] [")}]` : ""; +} + +function paramLine(param: Parameter): string { + return `- \`${param.path}\` (${param.type}, ${paramGroupLabel(param.group)}) — ${param.description}${paramConstraints(param)}`; +} + +function sortById(models: Model[]): Model[] { + return [...models].sort((a, b) => modelId(a).localeCompare(modelId(b))); +} + +/** + * Concise, link-first overview following the llms.txt convention (llmstxt.org): + * H1, blockquote summary, then sectioned lists of links agents can fetch. + */ +export function buildLlmsTxt(siteUrl: string, models: Model[]): string { + const lines: string[] = [ + "# modelparameters.dev", + "", + "> An open, community-maintained catalog of LLM model parameters — every knob you can", + "> turn, for every model, with API-key and subscription variants tracked separately.", + "", + "All data is machine-readable: CORS-enabled static JSON validated against a published JSON", + "Schema, served from the edge under `/api/v1/`. IDs are `provider/model` for API-key", + "variants and `provider/model-subscription` for subscription variants.", + "", + "## API", + `- [Full catalog](${siteUrl}/api/v1/models.json): Every model and its parameters in one JSON file (${plural(models.length, "model")}).`, + `- [JSON Schema](${siteUrl}/api/v1/schema.json): Validates every entry; use it for editor autocomplete or CI checks.`, + `- [API index](${siteUrl}/api/v1/index.json): Endpoint map and live model count.`, + "", + "## Guides", + `- [Usage guide + full parameter dump](${siteUrl}/llms-full.txt): How to call the API plus every model's parameters inline.`, + "", + "## Models", + ]; + for (const model of sortById(models)) { + lines.push( + `- [${modelId(model)}](${modelJsonUrl(siteUrl, model)}): ${modelTitle(model)} — ${plural(model.params.length, "parameter")}.`, + ); + } + lines.push( + "", + "## Optional", + `- [GitHub repository](${REPO_URL}): Source data, JSON Schema, and contribution guide.`, + `- [Web catalog](${siteUrl}/): Searchable HTML view that also exposes WebMCP tools to in-browser agents.`, + "", + ); + return lines.join("\n"); +} + +function modelSection(siteUrl: string, model: Model): string[] { + const lines = [ + `### ${modelId(model)}`, + "", + `${modelTitle(model)} · JSON: ${modelJsonUrl(siteUrl, model)}`, + "", + ]; + if (model.params.length === 0) { + lines.push("_No parameters documented yet._", ""); + return lines; + } + for (const { params } of groupParams(model.params)) { + for (const param of params) lines.push(paramLine(param)); + } + lines.push(""); + return lines; +} + +/** + * The complete agent payload: the usage guide followed by every model's parameters + * inline, grouped by provider. Served at /llms-full.txt. + */ +export function buildLlmsFullTxt(siteUrl: string, models: Model[]): string { + const lines: string[] = [ + usageGuideMarkdown(siteUrl).trimEnd(), + "", + "---", + "", + "# Full catalog", + "", + `${plural(models.length, "model")}, grouped by provider. Each line reads: \`path\` (type,`, + "group) — description, then defaults, ranges, allowed values, and applicability conditions", + "in brackets.", + "", + ]; + for (const facet of buildProviderFacets(models)) { + lines.push(`## ${providerLabel(facet.provider)}`, ""); + const group = sortById(models.filter((model) => model.provider === facet.provider)); + for (const model of group) lines.push(...modelSection(siteUrl, model)); + } + return lines.join("\n"); +} diff --git a/src/server/dev.ts b/src/server/dev.ts index 190dd6b..c841269 100644 --- a/src/server/dev.ts +++ b/src/server/dev.ts @@ -2,6 +2,7 @@ import path from "node:path"; import chokidar from "chokidar"; import express from "express"; import { buildCapabilityFacets, buildCatalog, buildProviderFacets } from "../data/catalog.js"; +import { buildLlmsFullTxt, buildLlmsTxt } from "../data/llms.js"; import { loadAllModels } from "../data/load.js"; import { CLIENT_DIR, DIST_ASSETS_DIR, MODELS_DIR, VIEWS_DIR } from "../data/paths.js"; import { buildModelJsonSchema } from "../schema/generate.js"; @@ -10,6 +11,7 @@ import { renderIndex } from "../build/render.js"; import { modelId, type Model } from "../schema/model.js"; const PORT = Number(process.env.PORT ?? 3000); +const SITE_URL = process.env.SITE_URL ?? "https://modelparameters.dev"; interface CacheEntry { models: Model[]; @@ -33,18 +35,32 @@ async function getCache(): Promise { return cache ?? (await refresh()); } +// Ignore dotfiles by basename only. The previous regex (/(^|[\\/])\../) tested the +// full absolute path, so a dot-segment in an ancestor dir — e.g. a checkout under +// ~/.paseo/... — matched and silently disabled the entire watch. +const ignoreDotfiles = (target: string): boolean => path.basename(target).startsWith("."); + +// Poll by default so the watch is reliable on container, overlay, and network +// mounts where native FS events don't propagate; set CHOKIDAR_USEPOLLING=false to +// use native events on a normal local disk. awaitWriteFinish avoids half-written reads. +const WATCH_OPTIONS = { + ignoreInitial: true, + ignored: ignoreDotfiles, + usePolling: process.env.CHOKIDAR_USEPOLLING !== "false", + interval: 300, + binaryInterval: 600, + awaitWriteFinish: { stabilityThreshold: 200, pollInterval: 50 }, +}; + function watch(): void { - const watcher = chokidar.watch([MODELS_DIR, VIEWS_DIR], { - ignoreInitial: true, - ignored: /(^|[\\/])\../, - }); + const watcher = chokidar.watch([MODELS_DIR, VIEWS_DIR], WATCH_OPTIONS); watcher.on("all", async (event, file) => { console.log(`[dev] ${event} ${path.relative(process.cwd(), file)} — refreshing`); cache = null; await refresh(); }); - const clientWatcher = chokidar.watch(CLIENT_DIR, { ignoreInitial: true }); + const clientWatcher = chokidar.watch(CLIENT_DIR, WATCH_OPTIONS); clientWatcher.on("all", async () => { console.log("[dev] client changed — rebundling"); await rebuildClientAssets(); @@ -88,6 +104,24 @@ function makeApp(): express.Express { res.json(buildModelJsonSchema()); }); + app.get("/llms.txt", async (_req, res, next) => { + try { + const { models } = await getCache(); + res.type("text/plain; charset=utf-8").send(buildLlmsTxt(SITE_URL, models)); + } catch (err) { + next(err); + } + }); + + app.get("/llms-full.txt", async (_req, res, next) => { + try { + const { models } = await getCache(); + res.type("text/plain; charset=utf-8").send(buildLlmsFullTxt(SITE_URL, models)); + } catch (err) { + next(err); + } + }); + app.get("/api/v1/models/:provider/:slug.json", async (req, res, next) => { try { const { models } = await getCache(); diff --git a/src/views/layout.ejs b/src/views/layout.ejs index 078076f..6e57360 100644 --- a/src/views/layout.ejs +++ b/src/views/layout.ejs @@ -6,6 +6,7 @@ <%= title %> + @@ -39,7 +40,7 @@ <%- include("partials/footer") %> - <%- include("partials/how_to_use") %> + <%- include("partials/how_to_use", { usageGuide: usageGuide }) %> diff --git a/tests/llms.test.ts b/tests/llms.test.ts new file mode 100644 index 0000000..e6150c7 --- /dev/null +++ b/tests/llms.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect } from "vitest"; +import { buildLlmsFullTxt, buildLlmsTxt, usageGuideMarkdown } from "../src/data/llms.js"; +import type { Model } from "../src/schema/model.js"; + +const SITE = "https://modelparameters.dev"; + +function makeModel(overrides: Partial = {}): Model { + return { + provider: "anthropic", + authType: "api_key", + model: "claude-opus-4-7", + params: [ + { + path: "max_tokens", + type: "integer", + label: "Max tokens", + description: "Maximum output tokens.", + default: 4096, + range: { min: 1 }, + group: "generation_length", + }, + { + path: "thinking.display", + type: "enum", + label: "Thinking display", + description: "Whether thinking is summarized.", + default: "omitted", + values: ["summarized", "omitted"], + group: "reasoning", + applicability: { only: { "thinking.type": ["adaptive"] } }, + }, + ], + ...overrides, + } as Model; +} + +describe("usageGuideMarkdown", () => { + it("renders the guide as Markdown with the catalog endpoints", () => { + const md = usageGuideMarkdown(SITE); + expect(md.startsWith("# How to use modelparameters.dev")).toBe(true); + expect(md).toContain(`curl ${SITE}/api/v1/models.json`); + expect(md).toContain(`curl ${SITE}/api/v1/schema.json`); + expect(md).toContain(`${SITE}/llms-full.txt`); + expect(md).toContain("WebMCP"); + }); + + it("threads the provided site url through every reference", () => { + const md = usageGuideMarkdown("http://localhost:3000"); + expect(md).toContain("curl http://localhost:3000/api/v1/models.json"); + expect(md).not.toContain("https://modelparameters.dev"); + }); +}); + +describe("buildLlmsTxt", () => { + it("follows the llms.txt shape: H1, summary, sections, model links", () => { + const txt = buildLlmsTxt(SITE, [makeModel(), makeModel({ authType: "subscription" })]); + expect(txt.startsWith("# modelparameters.dev")).toBe(true); + expect(txt).toContain("\n> An open, community-maintained catalog"); + expect(txt).toContain("## API"); + expect(txt).toContain("## Models"); + expect(txt).toContain("## Optional"); + expect(txt).toContain( + `- [anthropic/claude-opus-4-7](${SITE}/api/v1/models/anthropic/claude-opus-4-7.json):`, + ); + expect(txt).toContain( + `- [anthropic/claude-opus-4-7-subscription](${SITE}/api/v1/models/anthropic/claude-opus-4-7-subscription.json):`, + ); + expect(txt).toContain("2 parameters."); + }); + + it("singularizes a one-parameter model", () => { + const txt = buildLlmsTxt(SITE, [makeModel({ params: [makeModel().params[0]!] })]); + expect(txt).toContain("1 parameter."); + }); +}); + +describe("buildLlmsFullTxt", () => { + it("embeds the usage guide and dumps each parameter with constraints", () => { + const full = buildLlmsFullTxt(SITE, [makeModel()]); + expect(full).toContain("# How to use modelparameters.dev"); + expect(full).toContain("# Full catalog"); + expect(full).toContain("## Anthropic"); + expect(full).toContain("### anthropic/claude-opus-4-7"); + expect(full).toContain( + "- `max_tokens` (integer, Length) — Maximum output tokens. [default: 4096] [range: min 1]", + ); + expect(full).toContain( + '- `thinking.display` (enum, Reasoning) — Whether thinking is summarized. [default: "omitted"] [values: "summarized", "omitted"] [only when thinking.type = "adaptive"]', + ); + }); + + it("notes models that have no parameters yet", () => { + const full = buildLlmsFullTxt(SITE, [makeModel({ params: [] })]); + expect(full).toContain("_No parameters documented yet._"); + }); +}); diff --git a/vercel.json b/vercel.json index 5ec5ee2..73ff89d 100644 --- a/vercel.json +++ b/vercel.json @@ -13,6 +13,14 @@ { "key": "Access-Control-Allow-Origin", "value": "*" }, { "key": "Cache-Control", "value": "public, max-age=300, s-maxage=3600" } ] + }, + { + "source": "/(llms.txt|llms-full.txt)", + "headers": [ + { "key": "Access-Control-Allow-Origin", "value": "*" }, + { "key": "Content-Type", "value": "text/plain; charset=utf-8" }, + { "key": "Cache-Control", "value": "public, max-age=300, s-maxage=3600" } + ] } ] }