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: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
8 changes: 8 additions & 0 deletions src/build/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -28,6 +29,7 @@ async function cleanDist(): Promise<void> {
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<void> {
Expand Down Expand Up @@ -102,6 +104,8 @@ async function writeApiIndex(modelCount: number): Promise<void> {
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",
Expand Down Expand Up @@ -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()]);

Expand Down
9 changes: 9 additions & 0 deletions src/data/llms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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.`,
"",
Expand Down
41 changes: 41 additions & 0 deletions src/data/model-params.ts
Original file line number Diff line number Diff line change
@@ -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<Model, "model" | "authType">): 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<string, ModelParamsResponse>();
const signatures = new Map<string, string>();

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;
}
14 changes: 14 additions & 0 deletions src/server/app.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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();
Expand Down
5 changes: 5 additions & 0 deletions src/views/partials/how_to_use.ejs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,11 @@
<p>
Each entry is keyed by <code class="rounded bg-slate-100 px-1.5 py-0.5 font-mono text-xs dark:bg-slate-800">provider/model</code> for API-key variants; subscription variants append <code class="rounded bg-slate-100 px-1.5 py-0.5 font-mono text-xs dark:bg-slate-800">-subscription</code>.
</p>
<p>
If you only need the params for one model contract, use the providerless endpoint. Subscription contracts are model slugs with <code class="rounded bg-slate-100 px-1.5 py-0.5 font-mono text-xs dark:bg-slate-800">-subscription</code>.
</p>
<pre class="overflow-x-auto rounded-lg bg-slate-100 px-3 py-2 font-mono text-xs text-slate-800 dark:bg-slate-800 dark:text-slate-200"><code>curl <a class="underline decoration-dotted underline-offset-2 hover:text-accent" href="/api/v1/params/gpt-5.5.json">https://modelparams.dev/api/v1/params/gpt-5.5.json</a>
curl <a class="underline decoration-dotted underline-offset-2 hover:text-accent" href="/api/v1/params/gpt-5.5-subscription.json">https://modelparams.dev/api/v1/params/gpt-5.5-subscription.json</a></code></pre>
</section>

<section class="space-y-3">
Expand Down
75 changes: 75 additions & 0 deletions tests/catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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");
Expand Down
4 changes: 4 additions & 0 deletions tests/llms.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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");
});
});
Expand All @@ -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(
Expand Down
26 changes: 26 additions & 0 deletions tests/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Loading