diff --git a/.gitignore b/.gitignore index fa02f28..7e6b631 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ packages/*/*.tsbuildinfo coverage/ .vscode/ .idea/ +.gstack/ diff --git a/package-lock.json b/package-lock.json index 762f526..60a2515 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "packages/*" ], "dependencies": { + "@lobehub/icons-static-svg": "^1.91.0", "ejs": "^3.1.10", "express": "^4.19.2", "js-yaml": "^4.1.0", @@ -730,6 +731,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@lobehub/icons-static-svg": { + "version": "1.91.0", + "resolved": "https://registry.npmjs.org/@lobehub/icons-static-svg/-/icons-static-svg-1.91.0.tgz", + "integrity": "sha512-ZDflEq0uUvAkH4WK4h3qNvvY09ts4OqUb5azD7A0xKfcuYhffGwB1Q/As2RguZYq4Gh4v925CJ8iodiClzc4zw==", + "license": "MIT" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", diff --git a/package.json b/package.json index 282ef6c..853a0a4 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "prepare": "husky || true" }, "dependencies": { + "@lobehub/icons-static-svg": "^1.91.0", "ejs": "^3.1.10", "express": "^4.19.2", "js-yaml": "^4.1.0", diff --git a/src/build/build.ts b/src/build/build.ts index 8b97c6d..fdbf367 100644 --- a/src/build/build.ts +++ b/src/build/build.ts @@ -21,6 +21,7 @@ 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"; +import { renderApiPage } from "./render-api.js"; import { renderGlossaryPage } from "./render-glossary.js"; import { renderModelPage } from "./render-model.js"; import { renderProviderPage } from "./render-provider.js"; @@ -93,6 +94,7 @@ async function writeHtmlPages(models: Model[]): Promise { } await fs.writeFile(path.join(DIST_DIR, "glossary.html"), await renderGlossaryPage(models), "utf8"); + await fs.writeFile(path.join(DIST_DIR, "api.html"), await renderApiPage(models), "utf8"); } async function writeApiIndex(modelCount: number): Promise { diff --git a/src/build/render-api.ts b/src/build/render-api.ts new file mode 100644 index 0000000..4a5797c --- /dev/null +++ b/src/build/render-api.ts @@ -0,0 +1,28 @@ +import path from "node:path"; +import ejs from "ejs"; +import { VIEWS_DIR } from "../data/paths.js"; +import { SITE_NAME, SITE_URL } from "../data/site.js"; +import { absolute } from "../data/urls.js"; +import { type Model } from "../schema/model.js"; +import { hubLinks, renderShell, viewHelpers } from "./render.js"; + +const API_TITLE = `API documentation · ${SITE_NAME}`; +const API_DESCRIPTION = + "How to use the modelparams.dev JSON API, npm package, and provider logos. Static, CORS-enabled, served from the edge."; + +export async function renderApiPage(allModels: Model[]): Promise { + const body = await ejs.renderFile(path.join(VIEWS_DIR, "api.ejs"), { + helpers: viewHelpers, + }); + + return renderShell( + { + title: API_TITLE, + description: API_DESCRIPTION, + canonicalUrl: absolute(SITE_URL, "/api"), + structuredData: "{}", + providerHubs: hubLinks(allModels), + }, + body, + ); +} diff --git a/src/build/render-model.ts b/src/build/render-model.ts index 1dfe134..b7b2090 100644 --- a/src/build/render-model.ts +++ b/src/build/render-model.ts @@ -55,6 +55,7 @@ export async function renderModelPage(model: Model, allModels: Model[]): Promise modelName: modelLabel(model), providerPath: providerPagePath(model.provider), jsonPath: modelJsonPath(model), + modelJson: JSON.stringify({ $schema: "https://modelparams.dev/api/v1/schema.json", ...model }, null, 2), isSubscription: model.authType === "subscription", }); diff --git a/src/build/render.ts b/src/build/render.ts index 1a8d6ee..1b280a8 100644 --- a/src/build/render.ts +++ b/src/build/render.ts @@ -6,6 +6,8 @@ import { authLabel, conditionIcon, modelLabel, + paramGroupColor, + paramGroupColor, paramGroupIcon, paramGroupLabel, paramLabel, @@ -28,6 +30,7 @@ export const viewHelpers = { modelLabel, providerLabel, authLabel, + paramGroupColor, paramGroupLabel, paramGroupIcon, paramLabel, @@ -42,6 +45,8 @@ export const viewHelpers = { export interface HubLink { href: string; label: string; + provider: string; + count: number; } /** Sitewide footer links to each provider hub, ordered by model count. */ @@ -49,6 +54,8 @@ export function hubLinks(models: Model[]): HubLink[] { return buildProviderFacets(models).map((facet) => ({ href: providerPagePath(facet.provider), label: providerLabel(facet.provider), + provider: facet.provider, + count: facet.count, })); } @@ -71,6 +78,7 @@ export async function renderShell(meta: ShellMeta, body: string): Promise - - + + + + + + + + + + + + + + + + + diff --git a/src/client/logo-full.svg b/src/client/logo-full.svg new file mode 100644 index 0000000..c0a1231 --- /dev/null +++ b/src/client/logo-full.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + modelparams.dev + diff --git a/src/client/logo.svg b/src/client/logo.svg new file mode 100644 index 0000000..df1af25 --- /dev/null +++ b/src/client/logo.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/client/logos/alibaba.svg b/src/client/logos/alibaba.svg new file mode 100644 index 0000000..56c2078 --- /dev/null +++ b/src/client/logos/alibaba.svg @@ -0,0 +1 @@ +AlibabaCloud \ No newline at end of file diff --git a/src/client/logos/anthropic.svg b/src/client/logos/anthropic.svg index aaa01fc..0ccb140 100644 --- a/src/client/logos/anthropic.svg +++ b/src/client/logos/anthropic.svg @@ -1,3 +1 @@ - - - \ No newline at end of file +Anthropic \ No newline at end of file diff --git a/src/client/logos/cohere.svg b/src/client/logos/cohere.svg index cfeaa60..94bcb82 100644 --- a/src/client/logos/cohere.svg +++ b/src/client/logos/cohere.svg @@ -1,5 +1 @@ - - - - - +Cohere \ No newline at end of file diff --git a/src/client/logos/deepseek.svg b/src/client/logos/deepseek.svg index 5d6efa9..3fc2302 100644 --- a/src/client/logos/deepseek.svg +++ b/src/client/logos/deepseek.svg @@ -1,3 +1 @@ - - - +DeepSeek \ No newline at end of file diff --git a/src/client/logos/google.svg b/src/client/logos/google.svg index 4ebfcfd..e8e0f86 100644 --- a/src/client/logos/google.svg +++ b/src/client/logos/google.svg @@ -1,3 +1 @@ - - - +Google \ No newline at end of file diff --git a/src/client/logos/meta.svg b/src/client/logos/meta.svg index 3053b25..01fed4c 100644 --- a/src/client/logos/meta.svg +++ b/src/client/logos/meta.svg @@ -1,3 +1 @@ - - - +Meta \ No newline at end of file diff --git a/src/client/logos/minimax.svg b/src/client/logos/minimax.svg index 76bb877..2a60bd4 100644 --- a/src/client/logos/minimax.svg +++ b/src/client/logos/minimax.svg @@ -1,3 +1 @@ - - - +Minimax \ No newline at end of file diff --git a/src/client/logos/mistral.svg b/src/client/logos/mistral.svg index 966e474..8e03e24 100644 --- a/src/client/logos/mistral.svg +++ b/src/client/logos/mistral.svg @@ -1,3 +1 @@ - - - +Mistral \ No newline at end of file diff --git a/src/client/logos/moonshot.svg b/src/client/logos/moonshot.svg index 3cdf7c8..fb56ac1 100644 --- a/src/client/logos/moonshot.svg +++ b/src/client/logos/moonshot.svg @@ -1,3 +1 @@ - - - +MoonshotAI \ No newline at end of file diff --git a/src/client/logos/openai.svg b/src/client/logos/openai.svg index 000f65c..318403d 100644 --- a/src/client/logos/openai.svg +++ b/src/client/logos/openai.svg @@ -1,3 +1 @@ - - - +OpenAI \ No newline at end of file diff --git a/src/client/logos/xai.svg b/src/client/logos/xai.svg index ccd2244..c61f570 100644 --- a/src/client/logos/xai.svg +++ b/src/client/logos/xai.svg @@ -1,3 +1 @@ - - - +Grok \ No newline at end of file diff --git a/src/client/main.ts b/src/client/main.ts index 2c9b1ae..95514f2 100644 --- a/src/client/main.ts +++ b/src/client/main.ts @@ -16,7 +16,7 @@ const state: FilterState = { auth: "all", providers: new Set(), capabilities: new Set(), - sort: "provider", + sort: "name", }; function setupHowToUseModal(): void { @@ -99,6 +99,24 @@ function setupCopyHowToUse(): void { }); } +function setupCopyNpm(): void { + const btn = document.querySelector("[data-copy-npm]"); + if (!btn) return; + const idle = btn.querySelector("[data-copy-npm-idle]"); + const done = btn.querySelector("[data-copy-npm-done]"); + let timer = 0; + btn.addEventListener("click", async () => { + await copyText("npm i modelparams"); + idle?.classList.add("hidden"); + done?.classList.remove("hidden"); + window.clearTimeout(timer); + timer = window.setTimeout(() => { + idle?.classList.remove("hidden"); + done?.classList.add("hidden"); + }, 2000); + }); +} + function setupThemeToggle(): void { const toggle = document.querySelector("[data-theme-toggle]"); if (!toggle) return; @@ -150,6 +168,7 @@ function applyFilters(): void { updateGroupHeaders(); syncFilterChrome(); + updateFilterCount(); } function setupSearch(): void { @@ -338,6 +357,29 @@ function setupModelLinks(): void { }); } +function setupScrollTopButton(): void { + const btn = document.querySelector("[data-scroll-top]"); + const list = document.querySelector("[data-model-list]"); + const searchBar = document.querySelector(".search-bar"); + if (!btn || !list) return; + + const observer = new IntersectionObserver( + ([entry]) => { + btn.classList.toggle("hidden", entry.isIntersecting); + btn.classList.toggle("flex", !entry.isIntersecting); + }, + { threshold: 0 }, + ); + + const firstRow = list.querySelector(".model"); + if (firstRow) observer.observe(firstRow); + + btn.addEventListener("click", () => { + const target = searchBar || list; + target.scrollIntoView({ behavior: "smooth", block: "start" }); + }); +} + function setupSearchShortcut(): void { const input = document.querySelector("[data-search]"); if (!input) return; @@ -351,27 +393,128 @@ function setupSearchShortcut(): void { }); } -function setupCapabilityCollapse(): void { - const bar = document.querySelector("[data-capability-bar]"); - const button = document.querySelector("[data-capability-expand]"); - if (!bar || !button) return; +function setupFilterPanel(): void { + const toggle = document.querySelector("[data-toggle-filters]"); + const panel = document.querySelector("[data-filter-panel]"); + const chevron = document.querySelector("[data-filter-chevron]"); + if (!toggle || !panel) return; + + toggle.addEventListener("click", () => { + const isClosed = panel.classList.toggle("filter-panel-closed"); + toggle.setAttribute("aria-expanded", String(!isClosed)); + chevron?.classList.toggle("rotate-180", !isClosed); + }); +} + +function updateFilterCount(): void { + const badge = document.querySelector("[data-active-filter-count]"); + if (!badge) return; + const count = state.providers.size + state.capabilities.size + (state.auth !== "all" ? 1 : 0); + if (count > 0) { + badge.textContent = String(count); + badge.classList.remove("hidden"); + } else { + badge.classList.add("hidden"); + } +} + +function setupViewModeToggle(): void { + const buttons = document.querySelectorAll("[data-view-mode]"); + const grid = document.querySelector("[data-provider-grid]"); + const list = document.querySelector("[data-provider-list]"); + if (!buttons.length || !grid || !list) return; + + buttons.forEach((btn) => { + btn.addEventListener("click", () => { + const mode = btn.dataset.viewMode; + buttons.forEach((b) => { + const active = b === btn; + b.setAttribute("data-active", String(active)); + b.setAttribute("aria-pressed", String(active)); + }); + grid.classList.toggle("hidden", mode === "list"); + list.classList.toggle("hidden", mode === "grid"); + list.classList.toggle("flex", mode === "list"); + }); + }); +} + +function setupJsonModal(): void { + const dialog = document.getElementById("json-modal") as HTMLDialogElement | null; + if (!dialog) return; - const LIMIT = 12; - const chips = Array.from(bar.querySelectorAll("[data-capability]")); - if (chips.length <= LIMIT) return; + const opener = document.querySelector("[data-open-json-modal]"); + const closer = document.querySelector("[data-close-json-modal]"); + const copyBtn = document.querySelector("[data-copy-json]"); + const jsonContent = document.getElementById("json-content"); - let expanded = false; - const render = (): void => { - chips.forEach((chip, i) => chip.classList.toggle("hidden", !expanded && i >= LIMIT)); - button.textContent = expanded ? "Show fewer" : `Show all ${chips.length} parameters`; - button.setAttribute("aria-expanded", String(expanded)); + opener?.addEventListener("click", () => { + dialog.showModal(); + document.documentElement.style.overflow = "hidden"; + }); + + const close = (): void => { + if (dialog.open) dialog.close(); + document.documentElement.style.overflow = ""; }; - button.classList.remove("hidden"); - render(); - button.addEventListener("click", () => { - expanded = !expanded; - render(); + closer?.addEventListener("click", close); + dialog.addEventListener("cancel", close); + dialog.addEventListener("close", () => { + document.documentElement.style.overflow = ""; + }); + dialog.addEventListener("click", (e) => { + if (e.target === dialog) close(); + }); + + if (copyBtn && jsonContent) { + const idle = copyBtn.querySelector("[data-copy-json-idle]"); + const done = copyBtn.querySelector("[data-copy-json-done]"); + const label = copyBtn.querySelector("[data-copy-json-label]"); + let timer = 0; + copyBtn.addEventListener("click", async () => { + await copyText(jsonContent.textContent?.trim() ?? ""); + idle?.classList.add("hidden"); + done?.classList.remove("hidden"); + if (label) label.textContent = "Copied"; + window.clearTimeout(timer); + timer = window.setTimeout(() => { + idle?.classList.remove("hidden"); + done?.classList.add("hidden"); + if (label) label.textContent = "Copy"; + }, 2000); + }); + } +} + +function setupProvidersMenu(): void { + const toggle = document.querySelector("[data-providers-toggle]"); + const menu = document.querySelector("[data-providers-menu]"); + const chevron = document.querySelector("[data-providers-chevron]"); + if (!toggle || !menu) return; + + toggle.addEventListener("click", (e) => { + e.stopPropagation(); + const hidden = menu.classList.toggle("providers-menu-hidden"); + chevron?.classList.toggle("rotate-180", !hidden); + }); + + document.addEventListener("click", (e) => { + if ( + !menu.classList.contains("providers-menu-hidden") && + !menu.contains(e.target as Node) && + !toggle.contains(e.target as Node) + ) { + menu.classList.add("providers-menu-hidden"); + chevron?.classList.remove("rotate-180"); + } + }); + + document.addEventListener("keydown", (e) => { + if (e.key === "Escape" && !menu.classList.contains("providers-menu-hidden")) { + menu.classList.add("providers-menu-hidden"); + chevron?.classList.remove("rotate-180"); + } }); } @@ -389,18 +532,24 @@ function setupMobileMenu(): void { document.addEventListener("DOMContentLoaded", () => { setupThemeToggle(); + setupCopyNpm(); setupHowToUseModal(); setupCopyHowToUse(); + setupProvidersMenu(); setupMobileMenu(); + setupJsonModal(); + setupViewModeToggle(); setupSearch(); setupAuthFilters(); setupToggleChips("[data-provider]", "provider", state.providers); setupToggleChips("[data-capability]", "capability", state.capabilities); - setupCapabilityCollapse(); + setupFilterPanel(); setupClearFilters(); setupSort(); + reorderList(); setupModelLinks(); setupSearchShortcut(); + setupScrollTopButton(); updateGroupHeaders(); syncFilterChrome(); setupWebMCP(); diff --git a/src/client/styles.css b/src/client/styles.css index ecf6cd6..d0b5c26 100644 --- a/src/client/styles.css +++ b/src/client/styles.css @@ -6,6 +6,58 @@ html { scroll-behavior: smooth; } + body { + font-size: 0.875rem; /* 14px base for body text */ + } + .bg-site { + background-color: #FEFDFB; + } + html.dark .bg-site { + background-color: #171717; + } + + /* ── Supabase-style dark palette ── */ + html.dark { + --dark-bg: #171717; + --dark-card: #1f1f1f; + --dark-border: hsla(60, 2%, 12%, 0.17); + --dark-hover: #2a2a2a; + } + + /* Override Tailwind dark defaults to warm neutrals */ + html.dark .dark\:bg-slate-900 { background-color: var(--dark-card) !important; } + html.dark .dark\:bg-slate-950 { background-color: var(--dark-bg) !important; } + html.dark .dark\:bg-slate-950\/90 { background-color: rgba(23, 23, 23, 0.9) !important; } + html.dark .dark\:bg-slate-800 { background-color: #262626 !important; } + html.dark .dark\:border-slate-800 { border-color: hsla(60, 2%, 12%, 0.17) !important; } + html.dark .dark\:border-slate-700 { border-color: hsla(60, 2%, 12%, 0.25) !important; } + html.dark .dark\:hover\:bg-slate-800:hover { background-color: var(--dark-hover) !important; } + html.dark .dark\:hover\:bg-slate-700:hover { background-color: #333 !important; } + html.dark .dark\:hover\:border-slate-700:hover { border-color: #444 !important; } + html.dark .dark\:hover\:border-slate-600:hover { border-color: #555 !important; } + + /* Lighten accent in dark mode for readability */ + html.dark .text-accent { color: #7b83ff !important; } + html.dark .hover\:text-accent-hover:hover { color: #9da3ff !important; } + + /* Active filter states must override dark border/bg overrides */ + html.dark [data-active="true"].dark\:data-\[active\=true\]\:bg-accent { + background-color: #2530F0 !important; + border-color: #2530F0 !important; + } + + /* ── Major Third type scale (1.250) — headings use Inria Serif ── */ + h1, h2, h3, h4, h5, h6 { + font-family: "Ovo", Georgia, Cambria, serif; + font-weight: 400; + } + h1 { font-size: 3.052rem; line-height: 1.1; } + h2 { font-size: 1.563rem; line-height: 1.25; } + h3 { font-size: 1.25rem; line-height: 1.3; } + h4 { font-size: 1.125rem; line-height: 1.35; } + h5 { font-size: 1rem; line-height: 1.4; } + h6 { font-size: 0.875rem; line-height: 1.4; } + details > summary::-webkit-details-marker { display: none; } @@ -18,30 +70,106 @@ @layer components { .provider-logo svg, - .group-heading svg, + .group-heading svg { + width: 100%; + height: 100%; + display: block; + } td .inline-flex > svg { width: 100%; height: 100%; display: block; } + } -/* Separate parameter groups with breathing room + a hairline divider. - First group sits flush with the table head; later groups get spaced. */ -table.params-table > tbody > tr.group-heading > th { - padding-top: 1.5rem; - padding-bottom: 0.5rem; + + + +/* ── Logo gradient (mesh-style) ── */ +.logo-gradient { + background-color: #3b3de8; + background-image: + radial-gradient(circle at 25% 110%, #e84393 0%, transparent 70%), + radial-gradient(circle at 105% 30%, #2ecc71 0%, transparent 65%), + radial-gradient(circle at 60% 40%, #8e44ad 0%, transparent 75%), + radial-gradient(circle at -10% -5%, #4285f4 0%, transparent 95%), + radial-gradient(circle at 80% 95%, #f39c12 0%, transparent 60%); } -table.params-table > tbody:first-of-type > tr.group-heading > th { - padding-top: 0.5rem; - border-top: none; + +/* ── Providers mega menu animation ── */ +[data-providers-menu] { + transition: opacity 0.2s ease, transform 0.2s ease; + opacity: 1; + transform: translateY(0); +} +[data-providers-menu].providers-menu-hidden { + opacity: 0; + transform: translateY(-4px); + pointer-events: none; } -table.params-table > tbody + tbody > tr.group-heading > th { - border-top: 1px solid rgb(226 232 240); /* slate-200 */ + +/* ── Collapsed borders on model cards (solid) ── */ +.model-list > .model + .model { + margin-top: -1px; } -html.dark table.params-table > tbody + tbody > tr.group-heading > th { - border-top-color: rgb(30 41 59); /* slate-800 */ +.model-list > .model:hover { + z-index: 1; +} + +/* Separate parameter groups with breathing room. */ +.group-section + .group-section > .group-heading { + padding-top: 1.5rem; } table.params-table > tbody > tr:last-child > td { padding-bottom: 0.75rem; } + +/* ── Clean parameter tables: grey header + thin row separators ── */ +table.params-table { + border-collapse: separate; + border-spacing: 0; + border: 1px solid rgb(241 245 249); /* slate-100 */ +} +html.dark table.params-table { + border-color: rgb(51 65 85); /* slate-700 */ +} +table.params-table > thead > tr > th { + padding: 0.5rem 0.75rem; + background: #f5f1eb; + border-bottom: 1px solid rgb(241 245 249); /* slate-100 */ +} +table.params-table > tbody > tr > td { + padding: 0.5rem 0.75rem; + border-bottom: 1px solid rgb(241 245 249); /* slate-100 */ +} +table.params-table > tbody > tr:last-child > td { + border-bottom: none; +} +html.dark table.params-table { + border-color: hsla(60, 2%, 12%, 0.17); +} +html.dark table.params-table > thead > tr > th { + background: #1a1a1a; + border-bottom-color: hsla(60, 2%, 12%, 0.17); +} +html.dark table.params-table > tbody > tr > td { + border-bottom-color: hsla(60, 2%, 12%, 0.12); +} + +/* ── Filter panel slide animation ── */ +[data-filter-panel] { + display: grid; + grid-template-rows: 1fr; + opacity: 1; + transition: grid-template-rows 0.25s ease, opacity 0.2s ease; +} +[data-filter-panel] > * { + overflow: hidden; +} +[data-filter-panel].filter-panel-closed { + grid-template-rows: 0fr; + opacity: 0; + border-color: transparent; + box-shadow: none; +} diff --git a/src/data/display.ts b/src/data/display.ts index c240940..73e4ed4 100644 --- a/src/data/display.ts +++ b/src/data/display.ts @@ -87,6 +87,16 @@ const PARAM_GROUP_ICONS: Record = { provider_metadata: ``, }; +const PARAM_GROUP_COLORS: Record = { + generation_length: "#BCD1C9", + sampling: "#CBCADB", + reasoning: "#eae0ce", + tooling: "#edd5d5", + output_format: "#daede1", + observability: "#edecda", + provider_metadata: "#dadaed", +}; + const CONDITION_ICONS = { only: ``, except: ``, @@ -156,6 +166,10 @@ export function paramGroupIcon(group: string): string { return PARAM_GROUP_ICONS[group] ?? ""; } +export function paramGroupColor(group: string): string { + return PARAM_GROUP_COLORS[group] ?? "#e2e8f0"; +} + export function conditionIcon(kind: "only" | "except"): string { return CONDITION_ICONS[kind]; } diff --git a/src/data/logos.ts b/src/data/logos.ts index f02994d..d176d0c 100644 --- a/src/data/logos.ts +++ b/src/data/logos.ts @@ -1,35 +1,97 @@ import fs from "node:fs"; import path from "node:path"; +import { createRequire } from "node:module"; import { CLIENT_DIR } from "./paths.js"; -const LOGO_DIR = path.join(CLIENT_DIR, "logos"); -const DEFAULT_SLUG = "_default"; +const require = createRequire(import.meta.url); +const LOBE_ICONS_DIR = path.join( + path.dirname(require.resolve("@lobehub/icons-static-svg/package.json")), + "icons", +); +const LOCAL_LOGO_DIR = path.join(CLIENT_DIR, "logos"); + +/** + * Map our provider slugs to lobe-icons filenames. + * Prefer `-color` variants when they exist for multi-color logos. + */ +const SLUG_TO_LOBE: Record = { + openai: "openai", + anthropic: "anthropic", + google: "google-color", + meta: "meta", + mistral: "mistral-color", + cohere: "cohere-color", + deepseek: "deepseek-color", + xai: "xai", + perplexity: "perplexity-color", + minimax: "minimax", + moonshot: "moonshot", + alibaba: "alibabacloud-color", + "z-ai": "zai", +}; const cache = new Map(); -function readLogoFile(slug: string): string | null { - if (cache.has(slug)) return cache.get(slug)!; - const file = path.join(LOGO_DIR, `${slug}.svg`); - let content: string | null = null; +function readSvg(filePath: string): string | null { try { - content = fs.readFileSync(file, "utf8").trim(); + return fs.readFileSync(filePath, "utf8").trim(); } catch { - content = null; + return null; } +} + +function readLogo(slug: string): string | null { + if (cache.has(slug)) return cache.get(slug)!; + + let content: string | null = null; + + // 1. Try lobe-icons mapping + const lobeName = SLUG_TO_LOBE[slug]; + if (lobeName) { + content = readSvg(path.join(LOBE_ICONS_DIR, `${lobeName}.svg`)); + } + + // 2. Fall back to local logos dir + if (!content) { + content = readSvg(path.join(LOCAL_LOGO_DIR, `${slug}.svg`)); + } + cache.set(slug, content); return content; } +let logoInstanceCounter = 0; + +/** Make gradient/filter IDs unique per SVG instance to avoid collisions. */ +function dedupeIds(svg: string): string { + const suffix = `_${++logoInstanceCounter}`; + const ids = new Set(); + // Collect all id="..." values + svg.replace(/\bid="([^"]+)"/g, (_, id) => { + ids.add(id); + return _; + }); + if (ids.size === 0) return svg; + let result = svg; + for (const id of ids) { + result = result.replaceAll(`id="${id}"`, `id="${id}${suffix}"`); + result = result.replaceAll(`#${id})`, `#${id}${suffix})`); + result = result.replaceAll(`"#${id}"`, `"#${id}${suffix}"`); + } + return result; +} + export function logoFor(provider: string): string | null { - return readLogoFile(provider) ?? readLogoFile(DEFAULT_SLUG); + const svg = readLogo(provider) ?? readLogo("_default"); + return svg ? dedupeIds(svg) : null; } export function listLogoFiles(): string[] { try { return fs - .readdirSync(LOGO_DIR) + .readdirSync(LOCAL_LOGO_DIR) .filter((f) => f.endsWith(".svg")) - .map((f) => path.join(LOGO_DIR, f)); + .map((f) => path.join(LOCAL_LOGO_DIR, f)); } catch { return []; } diff --git a/src/server/app.ts b/src/server/app.ts index b260b08..49d3c39 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -8,6 +8,7 @@ import { renderIndex } from "../build/render.js"; import { renderModelPage } from "../build/render-model.js"; import { renderProviderPage } from "../build/render-provider.js"; import { renderGlossaryPage } from "../build/render-glossary.js"; +import { renderApiPage } from "../build/render-api.js"; import { SITE_URL } from "../data/site.js"; import { modelId, type Model } from "../schema/model.js"; @@ -39,6 +40,16 @@ export function makeApp(loadModels: LoadModels): express.Express { } }); + app.get("/api", async (_req, res, next) => { + try { + const models = await loadModels(); + res.setHeader("Cache-Control", "no-store"); + res.type("html").send(await renderApiPage(models)); + } catch (err) { + next(err); + } + }); + app.get("/glossary", async (_req, res, next) => { try { const models = await loadModels(); diff --git a/src/views/api.ejs b/src/views/api.ejs new file mode 100644 index 0000000..047b6ca --- /dev/null +++ b/src/views/api.ejs @@ -0,0 +1,134 @@ +<%- include("partials/breadcrumbs", { items: [ + { name: "Home", href: "/" }, + { name: "API" } +] }) %> + +
+

How to use the API

+

+ The full catalog is available as static JSON, CORS-enabled, served from the edge. + You can also install the npm package or point your AI agent at /llms.txt. +

+ +
+ + +
+ +

+ Building with an AI agent? Point it at + /llms.txt + for a machine-readable overview, or copy any snippet below. +

+
+ + +
+

Full catalog

+

+ Returns every model in the catalog with all parameters, types, defaults, and ranges. +

+
+
+ GET +
+
curl https://modelparams.dev/api/v1/models.json
+
+

+ Each entry is keyed by provider/model for API-key variants. + Subscription variants append -subscription. +

+
+ + +
+

Single model

+

+ Fetch the full definition for a specific model, including its provider. +

+
+
+ GET +
+
curl https://modelparams.dev/api/v1/models/anthropic/claude-opus-4-7.json
+
+
+ + +
+

Parameters only

+

+ If you only need the parameters for a model without knowing the provider, use the providerless endpoint. +

+
+
+ GET +
+
curl https://modelparams.dev/api/v1/params/gpt-5.5.json
+
+
+ + +
+

JSON Schema

+

+ Every entry validates against a JSON Schema you can use in your editor or CI pipeline. +

+
+
+ GET +
+
curl https://modelparams.dev/api/v1/schema.json
+
+

+ Add this header to any YAML you author for autocomplete in VS Code: +

+
+
# yaml-language-server: $schema=https://modelparams.dev/api/v1/schema.json
+
+
+ + +
+

npm package

+

+ Install the package to query the catalog locally or in your build pipeline. +

+
+
npm i modelparams
+
+
+ + +
+

Logos

+

+ Provider logos are available as SVGs. They use currentColor so they inherit your text color. +

+
+
curl https://modelparams.dev/assets/logos/anthropic.svg
+
+

+ Replace anthropic with any provider slug: + openai, + google, + meta, + mistral, + deepseek, etc. +

+
+ + +
+

Contribute

+

+ The data lives in YAML under models/{provider}/{model}-{auth}.yaml + in the GitHub repo. + Open a PR, CI validates against the schema and rebuilds the site automatically. +

+
+ +
+
diff --git a/src/views/glossary.ejs b/src/views/glossary.ejs index 35e4dac..682e75d 100644 --- a/src/views/glossary.ejs +++ b/src/views/glossary.ejs @@ -4,13 +4,13 @@ ] }) %>
-

LLM parameter glossary

+

LLM parameter glossary

<%= intro %>

<% for (const groupItem of groups) { %>
-

+

<%= groupItem.label %>

@@ -19,14 +19,14 @@
<%= entry.label %> + <% for (const t of entry.types) { %> <%= t %><% } %> <%= entry.path %> - <%= entry.types.join(" / ") %> · <%= entry.modelCount %> model<%= entry.modelCount === 1 ? "" : "s" %>

<%= entry.description %>

-

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

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

@@ -35,6 +35,3 @@
<% } %> -
- Back to the full catalog -
diff --git a/src/views/index.ejs b/src/views/index.ejs index ffaa580..0dbda8f 100644 --- a/src/views/index.ejs +++ b/src/views/index.ejs @@ -1,49 +1,67 @@ -
-

- Every LLM parameter, for every model. -

-

- An open, community-maintained catalog of LLM model parameters. Search, - filter, and link straight to the knobs you can turn. API-key and subscription - variants of the same model are listed separately, because they behave - differently. -

+
+
+

+ Every LLM parameter,
for every model. +

+

+ An open, community-maintained catalog of LLM model parameters. + Browse the UI below, query the API, or install the npm package. +

- +
+ -
+
<% const byProvider = new Map(); for (const m of models) { @@ -190,35 +244,21 @@ data-sort class="rounded-md border border-slate-200 bg-white px-2 py-1 text-xs font-medium text-slate-600 focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-300" > - - -
+
<% for (const pf of providers) { %> <% const group = byProvider.get(pf.provider) || []; %> - <% const groupLogo = helpers.logoFor(pf.provider); %>

- <% if (groupLogo) { %><% } %> - <%= helpers.providerLabel(pf.provider) %> - <%= group.length %> + <%= helpers.providerLabel(pf.provider) %>

<% for (const model of group) { %><%- include("partials/model_row", { model: model, helpers: helpers }) %><% } %> <% } %> @@ -227,3 +267,14 @@ No models match your filters.

+ + diff --git a/src/views/layout.ejs b/src/views/layout.ejs index 5b02979..70779b6 100644 --- a/src/views/layout.ejs +++ b/src/views/layout.ejs @@ -25,7 +25,10 @@ - + + + + - - <%- include("partials/header") %> + + <%- include("partials/header", { providerHubs: providerHubs, helpers: helpers }) %>
<%- body %> diff --git a/src/views/model.ejs b/src/views/model.ejs index fa70ed0..2b582dc 100644 --- a/src/views/model.ejs +++ b/src/views/model.ejs @@ -9,18 +9,18 @@
- + <% if (providerLogo) { %> <% } %> <%= providerName %> - + <% if (isSubscription) { %> - <%= helpers.authLabel(model.authType) %> + <%= helpers.authLabel(model.authType) %> <% } %> <%= model.params.length %> param<%= model.params.length === 1 ? "" : "s" %>
-

+

<%= providerName %> <%= modelName %><% if (isSubscription) { %> (subscription)<% } %> parameters

@@ -30,36 +30,82 @@

<% 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 }) %> <% } %>
-
- This model as JSON - All <%= providerName %> models - Parameter glossary - Full catalog -
+

Resources

+ + + + +
+

<%= modelName %> — JSON

+ +
+
+

+ The full model definition as served by the API. Copy it or open the endpoint directly. +

+ +
+ +
<%= modelJson %>
+
+
+
<% if (siblings.length > 0) { %> -
-

+
+

Other <%= providerName %> models

-
+ diff --git a/src/views/partials/footer.ejs b/src/views/partials/footer.ejs index 6abffca..3176081 100644 --- a/src/views/partials/footer.ejs +++ b/src/views/partials/footer.ejs @@ -1,39 +1,44 @@ - diff --git a/src/views/partials/header.ejs b/src/views/partials/header.ejs index 19e62b5..1e581e4 100644 --- a/src/views/partials/header.ejs +++ b/src/views/partials/header.ejs @@ -1,37 +1,54 @@ -
+<% + const sortedProviders = (providerHubs || []).slice().sort((a, b) => a.label.localeCompare(b.label)); +%> +
modelparams.dev
+ + +
+
+

Providers

+
+ <% for (const hub of sortedProviders) { %> + <% const pLogo = helpers.logoFor(hub.provider); %> + + <% if (pLogo) { %><% } %> + <%= hub.label %> + + <% } %> +
+
+
diff --git a/src/views/partials/model_row.ejs b/src/views/partials/model_row.ejs index 5658cef..cb9faf4 100644 --- a/src/views/partials/model_row.ejs +++ b/src/views/partials/model_row.ejs @@ -7,7 +7,7 @@ const providerLogo = helpers.logoFor(model.provider); %>
+ <%= modelName %> + <% if (providerLogo) { %> - + <% } %> - <%= providerName %> + <%= providerName %> - <%= modelName %> <% if (isSubscription) { %> - <%= helpers.authLabel(model.authType) %> <% } %> - <%= model.params.length %> param<%= model.params.length === 1 ? "" : "s" %> -
+
<% if (model.params.length === 0) { %>

No parameters documented yet.

<% } else { %> <%- include("param_table", { params: model.params, helpers: helpers }) %> <% } %> diff --git a/src/views/partials/param_table.ejs b/src/views/partials/param_table.ejs index 1e33adb..91bc0bb 100644 --- a/src/views/partials/param_table.ejs +++ b/src/views/partials/param_table.ejs @@ -2,85 +2,85 @@ const groups = helpers.groupParams(params); %>
- - - - - - - - - - - <% for (const groupItem of groups) { %> - - - - - <% for (const param of groupItem.params) { %> - <% - const applicability = helpers.describeApplicability(param.applicability); - const hasCondition = applicability.only.length > 0 || applicability.except.length > 0; - let typeDetail = ""; - if (param.type === "enum") { - typeDetail = `(${param.values.join(" | ")})`; - } else if ((param.type === "number" || param.type === "integer") && param.range) { - const lo = param.range.min !== undefined ? param.range.min : "-∞"; - const hi = param.range.max !== undefined ? param.range.max : "+∞"; - typeDetail = `(${lo}…${hi}${param.range.step ? ` step ${param.range.step}` : ""})`; - } - %> - - - - - - + + <% } %> + +
ParameterTypeDefaultDescriptionCondition
- - - <%- helpers.paramGroupIcon(groupItem.group) %> - - <%= helpers.paramGroupLabel(groupItem.group) %> - - · <%= groupItem.params.length %> param<%= groupItem.params.length === 1 ? "" : "s" %> - - -
-
<%= helpers.paramLabel(param.path, param.label) %>
- <%= param.path %> -
- <%= param.type %> - <% if (typeDetail) { %><%= typeDetail %><% } %> - - <% if (param.default === undefined) { %> - - <% } else { %><%= JSON.stringify(param.default) %><% } %> - - <%= param.description %> - - <% if (!hasCondition) { %> - - <% } else { %> -
- <% if (applicability.only.length > 0) { %> - - <%- helpers.conditionIcon("only") %> - Only when <%= applicability.only.join(" and ") %> - + <% for (const groupItem of groups) { %> +
+
+ + + <%- helpers.paramGroupIcon(groupItem.group) %> + + <%= helpers.paramGroupLabel(groupItem.group) %> + + <%= groupItem.params.length %> param<%= groupItem.params.length === 1 ? "" : "s" %> + + +
+ + + + + + + + + + + + <% for (const param of groupItem.params) { %> + <% + const applicability = helpers.describeApplicability(param.applicability); + const hasCondition = applicability.only.length > 0 || applicability.except.length > 0; + let typeDetail = ""; + if (param.type === "enum") { + typeDetail = `(${param.values.join(" | ")})`; + } else if ((param.type === "number" || param.type === "integer") && param.range) { + const lo = param.range.min !== undefined ? param.range.min : "-∞"; + const hi = param.range.max !== undefined ? param.range.max : "+∞"; + typeDetail = `(${lo}…${hi}${param.range.step ? ` step ${param.range.step}` : ""})`; + } + %> + + + + + + - - <% } %> - - <% } %> -
ParameterTypeDefaultDescriptionCondition
+
<%= helpers.paramLabel(param.path, param.label) %>
+ <%= param.path %> +
+ <%= param.type %> + <% if (typeDetail) { %><%= typeDetail %><% } %> + + <% if (param.default === undefined) { %> + + <% } else { %><%= JSON.stringify(param.default) %><% } %> + + <%= param.description %> + + <% if (!hasCondition) { %> + + <% } else { %> +
+ <% if (applicability.only.length > 0) { %> + + <%- helpers.conditionIcon("only") %> + Only when <%= applicability.only.join(" and ") %> + + <% } %> + <% if (applicability.except.length > 0) { %> + + <%- helpers.conditionIcon("except") %> + Not when <%= applicability.except.join(" or ") %> + + <% } %> +
<% } %> - <% if (applicability.except.length > 0) { %> - - <%- helpers.conditionIcon("except") %> - Not when <%= applicability.except.join(" or ") %> - - <% } %> - - <% } %> -
+
+
+ <% } %>
diff --git a/src/views/provider.ejs b/src/views/provider.ejs index 0394daa..86930b0 100644 --- a/src/views/provider.ejs +++ b/src/views/provider.ejs @@ -7,52 +7,92 @@ ] }) %>
-
- - <% if (providerLogo) { %> - - <% } %> - <%= providerName %> - - <%= models.length %> model<%= models.length === 1 ? "" : "s" %> -
-

<%= providerName %> model parameters

+

<%= providerName %> model parameters

<%= intro %>

- +
+ + +
<% if (otherProviders.length > 0) { %> -

Other providers

-
+

Other providers

+
<% for (const hub of otherProviders) { %> - <%= hub.label %> + <% const hubLogo = helpers.logoFor(hub.provider); %> + + <% if (hubLogo) { %> + + <% } %> + <%= hub.label %> + <%= hub.count %> model<%= hub.count === 1 ? "" : "s" %> + <% } %>
<% } %> -
diff --git a/tailwind.config.ts b/tailwind.config.ts index e13a6b7..406194d 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -1,18 +1,41 @@ import type { Config } from "tailwindcss"; +/* ── Major Third scale (1.250) ────────────────────────────── + * base = 1rem (16px) + * sm = 1.250rem (20px) — h6 + * md = 1.563rem (25px) — h5 + * lg = 1.953rem (31.25px) — h4 + * xl = 2.441rem (39px) — h3 + * 2xl = 3.052rem (48.8px) — h2 + * 3xl = 3.815rem (61px) — h1 + */ + const config: Config = { content: ["./src/views/**/*.ejs", "./src/client/**/*.ts"], darkMode: "class", theme: { extend: { fontFamily: { - sans: ["Inter", "system-ui", "-apple-system", "sans-serif"], + sans: ["Outfit", "system-ui", "-apple-system", "sans-serif"], + serif: ["Ovo", "Georgia", "Cambria", "serif"], mono: ["JetBrains Mono", "ui-monospace", "SFMono-Regular", "monospace"], }, + fontSize: { + "scale-sm": ["1.25rem", { lineHeight: "1.3" }], + "scale-md": ["1.563rem", { lineHeight: "1.25" }], + "scale-lg": ["1.953rem", { lineHeight: "1.2" }], + "scale-xl": ["2.441rem", { lineHeight: "1.15" }], + "scale-2xl": ["3.052rem", { lineHeight: "1.1" }], + "scale-3xl": ["3.815rem", { lineHeight: "1.05" }], + }, colors: { accent: { - DEFAULT: "#6366f1", - hover: "#4f46e5", + DEFAULT: "#2530F0", + hover: "#1a22c4", + }, + warm: { + hover: "#e6e1d9", + tag: "#f0ece5", }, }, },