diff --git a/README.md b/README.md index e36c103..30393fb 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,8 @@ You can access this data through an API. ``` curl https://modelparams.dev/api/v1/models.json +curl https://modelparams.dev/api/v1/params/gpt-5.5.json +curl https://modelparams.dev/api/v1/params/gpt-5.5-subscription.json ``` The catalog follows the [Model Parameters convention](docs/model-parameters-schema.md). diff --git a/src/build/build.ts b/src/build/build.ts index ede9fd6..8b97c6d 100644 --- a/src/build/build.ts +++ b/src/build/build.ts @@ -8,6 +8,7 @@ import { } from "../data/catalog.js"; import { loadAllModels } from "../data/load.js"; import { buildLlmsFullTxt, buildLlmsTxt } from "../data/llms.js"; +import { listModelParamsResponses } from "../data/model-params.js"; import { DIST_API_DIR, DIST_ASSETS_DIR, @@ -28,6 +29,7 @@ async function cleanDist(): Promise { await fs.rm(DIST_DIR, { recursive: true, force: true }); await fs.mkdir(DIST_ASSETS_DIR, { recursive: true }); await fs.mkdir(path.join(DIST_API_DIR, "models"), { recursive: true }); + await fs.mkdir(path.join(DIST_API_DIR, "params"), { recursive: true }); } async function writeJson(file: string, payload: unknown): Promise { @@ -102,6 +104,8 @@ async function writeApiIndex(modelCount: number): Promise { schema: "/api/v1/schema.json", modelByIdApiKey: "/api/v1/models/{provider}/{model}.json", modelByIdSubscription: "/api/v1/models/{provider}/{model}-subscription.json", + paramsByModelApiKey: "/api/v1/params/{model}.json", + paramsByModelSubscription: "/api/v1/params/{model}-subscription.json", }, modelCount, docs: "https://github.com/mnfst/modelparams.dev#api", @@ -145,6 +149,10 @@ export async function build(): Promise<{ models: number }> { }); } + for (const params of listModelParamsResponses(models)) { + await writeJson(path.join(DIST_API_DIR, "params", `${params.model}.json`), params); + } + console.log("Bundling client + styles..."); await Promise.all([bundleClientScript(), compileStyles(), copyStaticAssets()]); diff --git a/src/data/llms.ts b/src/data/llms.ts index 2bd4b1b..c2ff5a9 100644 --- a/src/data/llms.ts +++ b/src/data/llms.ts @@ -46,6 +46,14 @@ function guideApi(siteUrl: string): string[] { "Each entry is keyed by `provider/model` for API-key variants; subscription variants", "append `-subscription`.", "", + "When you only need the parameter list for a model contract, use the providerless", + "params endpoint. Subscription contracts are model slugs with `-subscription`:", + "", + "```bash", + `curl ${siteUrl}/api/v1/params/gpt-5.5.json`, + `curl ${siteUrl}/api/v1/params/gpt-5.5-subscription.json`, + "```", + "", "## Single model", "", "```bash", @@ -153,6 +161,7 @@ export function buildLlmsTxt(siteUrl: string, models: Model[]): string { "", "## API", `- [Full catalog](${siteUrl}/api/v1/models.json): Every model and its parameters in one JSON file (${plural(models.length, "model")}).`, + `- [Providerless params](${siteUrl}/api/v1/params/gpt-5.5.json): Params for one model slug; append \`-subscription\` for subscription contracts.`, `- [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.`, "", diff --git a/src/data/model-params.ts b/src/data/model-params.ts new file mode 100644 index 0000000..f8f35b4 --- /dev/null +++ b/src/data/model-params.ts @@ -0,0 +1,41 @@ +import { authSuffix, type Model, type Parameter } from "../schema/model.js"; + +export interface ModelParamsResponse { + model: string; + params: Parameter[]; +} + +export function modelParamSlug(model: Pick): string { + return `${model.model}${authSuffix(model.authType)}`; +} + +export function modelParamsResponse(model: Model): ModelParamsResponse { + return { + model: modelParamSlug(model), + params: model.params, + }; +} + +export function listModelParamsResponses(models: Model[]): ModelParamsResponse[] { + const bySlug = new Map(); + const signatures = new Map(); + + for (const model of models) { + const response = modelParamsResponse(model); + const signature = JSON.stringify( + [...response.params].sort((a, b) => a.path.localeCompare(b.path)), + ); + const existing = signatures.get(response.model); + if (existing !== undefined && existing !== signature) { + throw new Error(`Conflicting params for providerless model slug "${response.model}"`); + } + signatures.set(response.model, signature); + bySlug.set(response.model, response); + } + + return [...bySlug.values()].sort((a, b) => a.model.localeCompare(b.model)); +} + +export function findModelParams(models: Model[], slug: string): ModelParamsResponse | null { + return listModelParamsResponses(models).find((model) => model.model === slug) ?? null; +} diff --git a/src/server/app.ts b/src/server/app.ts index e99508a..b260b08 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -1,6 +1,7 @@ import express from "express"; import { buildCapabilityFacets, buildCatalog, buildProviderFacets } from "../data/catalog.js"; import { buildLlmsFullTxt, buildLlmsTxt } from "../data/llms.js"; +import { findModelParams } from "../data/model-params.js"; import { DIST_ASSETS_DIR } from "../data/paths.js"; import { buildModelJsonSchema } from "../schema/generate.js"; import { renderIndex } from "../build/render.js"; @@ -92,6 +93,19 @@ export function makeApp(loadModels: LoadModels): express.Express { res.json(buildModelJsonSchema()); }); + app.get("/api/v1/params/:slug.json", async (req, res, next) => { + try { + const params = findModelParams(await loadModels(), req.params.slug); + if (!params) { + res.status(404).json({ error: "not_found", model: req.params.slug }); + return; + } + res.json(params); + } catch (err) { + next(err); + } + }); + app.get("/llms.txt", async (_req, res, next) => { try { const models = await loadModels(); diff --git a/src/views/partials/how_to_use.ejs b/src/views/partials/how_to_use.ejs index f8632b1..bde6dda 100644 --- a/src/views/partials/how_to_use.ejs +++ b/src/views/partials/how_to_use.ejs @@ -67,6 +67,11 @@

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

+

+ If you only need the params for one model contract, use the providerless endpoint. Subscription contracts are model slugs with -subscription. +

+
curl https://modelparams.dev/api/v1/params/gpt-5.5.json
+curl https://modelparams.dev/api/v1/params/gpt-5.5-subscription.json
diff --git a/tests/catalog.test.ts b/tests/catalog.test.ts index ad476b7..ff1af93 100644 --- a/tests/catalog.test.ts +++ b/tests/catalog.test.ts @@ -2,6 +2,12 @@ import { describe, it, expect } from "vitest"; import { buildCapabilityFacets, buildCatalog, uniqueProviders } from "../src/data/catalog.js"; import { describeApplicability } from "../src/data/applicability.js"; import { modelLabel, paramLabel, providerLabel } from "../src/data/display.js"; +import { + findModelParams, + listModelParamsResponses, + modelParamSlug, + modelParamsResponse, +} from "../src/data/model-params.js"; import { loadAllModels } from "../src/data/load.js"; import { modelId } from "../src/schema/model.js"; import type { Model } from "../src/schema/model.js"; @@ -72,6 +78,75 @@ describe("uniqueProviders", () => { }); }); +describe("providerless model params", () => { + it("uses -subscription as the model slug variant", () => { + expect(modelParamSlug(makeModel())).toBe("claude-opus-4-7"); + expect(modelParamSlug(makeModel({ authType: "subscription" }))).toBe( + "claude-opus-4-7-subscription", + ); + }); + + it("returns only model slug and params", () => { + const response = modelParamsResponse(makeModel()); + expect(response).toEqual({ + model: "claude-opus-4-7", + params: makeModel().params, + }); + }); + + it("finds api-key and subscription params by providerless slug", () => { + const apiKey = makeModel(); + const subscription = makeModel({ + authType: "subscription", + params: [ + { + path: "thinking.type", + type: "enum", + label: "Thinking", + description: "Thinking mode.", + values: ["disabled", "enabled"], + group: "reasoning", + }, + ], + }); + + expect(findModelParams([apiKey, subscription], "claude-opus-4-7")).toEqual( + modelParamsResponse(apiKey), + ); + expect(findModelParams([apiKey, subscription], "claude-opus-4-7-subscription")).toEqual( + modelParamsResponse(subscription), + ); + expect(findModelParams([apiKey, subscription], "unknown")).toBeNull(); + }); + + it("deduplicates identical providerless model param sets", () => { + const one = makeModel({ provider: "anthropic" }); + const two = makeModel({ provider: "openrouter" }); + + expect(listModelParamsResponses([one, two])).toEqual([modelParamsResponse(one)]); + }); + + it("rejects conflicting providerless model param sets", () => { + const one = makeModel({ provider: "anthropic" }); + const two = makeModel({ + provider: "openrouter", + params: [ + { + path: "top_p", + type: "number", + label: "Top P", + description: "Nucleus sampling.", + group: "sampling", + }, + ], + }); + + expect(() => listModelParamsResponses([one, two])).toThrow( + 'Conflicting params for providerless model slug "claude-opus-4-7"', + ); + }); +}); + describe("display helpers", () => { it("knows the canonical provider names", () => { expect(providerLabel("anthropic")).toBe("Anthropic"); diff --git a/tests/llms.test.ts b/tests/llms.test.ts index 98f7962..1dffaa9 100644 --- a/tests/llms.test.ts +++ b/tests/llms.test.ts @@ -39,6 +39,8 @@ describe("usageGuideMarkdown", () => { const md = usageGuideMarkdown(SITE); expect(md.startsWith("# How to use modelparams.dev")).toBe(true); expect(md).toContain(`curl ${SITE}/api/v1/models.json`); + expect(md).toContain(`curl ${SITE}/api/v1/params/gpt-5.5.json`); + expect(md).toContain(`curl ${SITE}/api/v1/params/gpt-5.5-subscription.json`); expect(md).toContain(`curl ${SITE}/api/v1/schema.json`); expect(md).toContain(`${SITE}/llms-full.txt`); expect(md).toContain("WebMCP"); @@ -47,6 +49,7 @@ describe("usageGuideMarkdown", () => { 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).toContain("curl http://localhost:3000/api/v1/params/gpt-5.5.json"); expect(md).not.toContain("https://modelparams.dev"); }); }); @@ -57,6 +60,7 @@ describe("buildLlmsTxt", () => { expect(txt.startsWith("# modelparams.dev")).toBe(true); expect(txt).toContain("\n> An open, community-maintained catalog"); expect(txt).toContain("## API"); + expect(txt).toContain(`${SITE}/api/v1/params/gpt-5.5.json`); expect(txt).toContain("## Models"); expect(txt).toContain("## Optional"); expect(txt).toContain( diff --git a/tests/server.test.ts b/tests/server.test.ts index 10d043b..0e591db 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -100,6 +100,32 @@ describe("GET /api/v1/schema.json", () => { }); }); +describe("GET /api/v1/params/:model.json", () => { + it("returns params for an api-key model slug without provider metadata", async () => { + const res = await get("/api/v1/params/claude-opus-4-7.json"); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toEqual({ + model: "claude-opus-4-7", + params: MODELS[0]!.params, + }); + }); + + it("returns params for the -subscription model variant", async () => { + const res = await get("/api/v1/params/claude-opus-4-7-subscription.json"); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.model).toBe("claude-opus-4-7-subscription"); + expect(body.params).toEqual(MODELS[1]!.params); + }); + + it("404s with a model-scoped JSON error for an unknown slug", async () => { + const res = await get("/api/v1/params/does-not-exist.json"); + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ error: "not_found", model: "does-not-exist" }); + }); +}); + describe("GET /api/v1/models/:provider/:slug.json", () => { it("returns the full model for a known id", async () => { const res = await get("/api/v1/models/anthropic/claude-opus-4-7.json");