diff --git a/src/client/webmcp.ts b/src/client/webmcp.ts index 15cc408..57ee7ce 100644 --- a/src/client/webmcp.ts +++ b/src/client/webmcp.ts @@ -46,7 +46,7 @@ interface ModelContext { provideContext?: (context: { tools: ToolDefinition[] }) => void; } -function modelId(model: CatalogModel): string { +export function modelId(model: CatalogModel): string { const suffix = model.authType === "subscription" ? "-subscription" : ""; return `${model.provider}/${model.model}${suffix}`; } @@ -113,7 +113,10 @@ function setToggleGroup(selector: string, key: string, wanted: string[]): void { }); } -function searchModels(catalog: Catalog, params: Record) { +// Pure catalog filter shared by the agent tool and exercised directly in tests. +// Side-effect free so the search contract agents depend on can be unit-tested +// without a DOM; the visible-filter mirroring lives in searchModels below. +export function searchCatalog(catalog: Catalog, params: Record) { const query = asString(params.query)?.toLowerCase(); const auth = asString(params.auth); const providers = asStringList(params.provider); @@ -132,8 +135,6 @@ function searchModels(catalog: Catalog, params: Record) { return true; }); - reflectInUi({ query: asString(params.query) ?? "", auth, providers, capabilities }); - return { total: matches.length, returned: Math.min(matches.length, limit), @@ -149,6 +150,17 @@ function searchModels(catalog: Catalog, params: Record) { }; } +function searchModels(catalog: Catalog, params: Record) { + const result = searchCatalog(catalog, params); + reflectInUi({ + query: asString(params.query) ?? "", + auth: asString(params.auth), + providers: asStringList(params.provider), + capabilities: asStringList(params.capability), + }); + return result; +} + 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." }); diff --git a/src/server/app.ts b/src/server/app.ts new file mode 100644 index 0000000..e99508a --- /dev/null +++ b/src/server/app.ts @@ -0,0 +1,133 @@ +import express from "express"; +import { buildCapabilityFacets, buildCatalog, buildProviderFacets } from "../data/catalog.js"; +import { buildLlmsFullTxt, buildLlmsTxt } from "../data/llms.js"; +import { DIST_ASSETS_DIR } from "../data/paths.js"; +import { buildModelJsonSchema } from "../schema/generate.js"; +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 { SITE_URL } from "../data/site.js"; +import { modelId, type Model } from "../schema/model.js"; + +/** + * Supplies the catalog to each request. The dev server passes a caching loader; + * tests pass a fixed array. Keeping the data source injectable lets the routes be + * exercised over real HTTP without booting the file watcher or the bundler. + */ +export type LoadModels = () => Promise; + +/** Build the HTTP app that serves the site, the JSON API, and the llms.txt feeds. */ +export function makeApp(loadModels: LoadModels): express.Express { + const app = express(); + app.disable("x-powered-by"); + + app.use("/assets", express.static(DIST_ASSETS_DIR, { maxAge: 0 })); + + app.get("/", async (_req, res, next) => { + try { + const models = await loadModels(); + const catalog = buildCatalog(models); + const capabilities = buildCapabilityFacets(models); + const providers = buildProviderFacets(models); + const html = await renderIndex({ catalog, capabilities, providers }); + res.setHeader("Cache-Control", "no-store"); + res.type("html").send(html); + } catch (err) { + next(err); + } + }); + + app.get("/glossary", async (_req, res, next) => { + try { + const models = await loadModels(); + res.setHeader("Cache-Control", "no-store"); + res.type("html").send(await renderGlossaryPage(models)); + } catch (err) { + next(err); + } + }); + + app.get("/providers/:provider", async (req, res, next) => { + try { + const models = await loadModels(); + const providerModels = models.filter((m) => m.provider === req.params.provider); + if (providerModels.length === 0) { + res.status(404).type("text/plain").send("Unknown provider"); + return; + } + res.setHeader("Cache-Control", "no-store"); + res.type("html").send(await renderProviderPage(req.params.provider, providerModels, models)); + } catch (err) { + next(err); + } + }); + + app.get("/models/:provider/:slug", async (req, res, next) => { + try { + const models = await loadModels(); + const wanted = `${req.params.provider}/${req.params.slug}`; + const model = models.find((m) => modelId(m) === wanted); + if (!model) { + res.status(404).type("text/plain").send("Unknown model"); + return; + } + res.setHeader("Cache-Control", "no-store"); + res.type("html").send(await renderModelPage(model, models)); + } catch (err) { + next(err); + } + }); + + app.get("/api/v1/models.json", async (_req, res, next) => { + try { + const models = await loadModels(); + res.json(buildCatalog(models)); + } catch (err) { + next(err); + } + }); + + app.get("/api/v1/schema.json", (_req, res) => { + res.json(buildModelJsonSchema()); + }); + + app.get("/llms.txt", async (_req, res, next) => { + try { + const models = await loadModels(); + 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 loadModels(); + 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 loadModels(); + const wanted = `${req.params.provider}/${req.params.slug}`; + const model = models.find((m) => modelId(m) === wanted); + if (!model) { + res.status(404).json({ error: "not_found", id: wanted }); + return; + } + res.json({ $schema: "https://modelparams.dev/api/v1/schema.json", ...model }); + } catch (err) { + next(err); + } + }); + + app.get("/healthz", (_req, res) => { + res.json({ ok: true }); + }); + + return app; +} diff --git a/src/server/dev.ts b/src/server/dev.ts index a9246e0..d2c96d7 100644 --- a/src/server/dev.ts +++ b/src/server/dev.ts @@ -1,18 +1,10 @@ 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"; +import { CLIENT_DIR, MODELS_DIR, VIEWS_DIR } from "../data/paths.js"; import { bundleClientScript, compileStyles, copyStaticAssets } from "../build/assets.js"; -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 { SITE_URL } from "../data/site.js"; -import { modelId, type Model } from "../schema/model.js"; +import { type Model } from "../schema/model.js"; +import { makeApp } from "./app.js"; const PORT = Number(process.env.PORT ?? 3000); @@ -74,126 +66,12 @@ async function rebuildClientAssets(): Promise { await Promise.all([bundleClientScript(false), compileStyles(), copyStaticAssets()]); } -function makeApp(): express.Express { - const app = express(); - app.disable("x-powered-by"); - - app.use("/assets", express.static(DIST_ASSETS_DIR, { maxAge: 0 })); - - app.get("/", async (_req, res, next) => { - try { - const { models } = await getCache(); - const catalog = buildCatalog(models); - const capabilities = buildCapabilityFacets(models); - const providers = buildProviderFacets(models); - const html = await renderIndex({ catalog, capabilities, providers }); - res.setHeader("Cache-Control", "no-store"); - res.type("html").send(html); - } catch (err) { - next(err); - } - }); - - app.get("/glossary", async (_req, res, next) => { - try { - const { models } = await getCache(); - res.setHeader("Cache-Control", "no-store"); - res.type("html").send(await renderGlossaryPage(models)); - } catch (err) { - next(err); - } - }); - - app.get("/providers/:provider", async (req, res, next) => { - try { - const { models } = await getCache(); - const providerModels = models.filter((m) => m.provider === req.params.provider); - if (providerModels.length === 0) { - res.status(404).type("text/plain").send("Unknown provider"); - return; - } - res.setHeader("Cache-Control", "no-store"); - res.type("html").send(await renderProviderPage(req.params.provider, providerModels, models)); - } catch (err) { - next(err); - } - }); - - app.get("/models/:provider/:slug", async (req, res, next) => { - try { - const { models } = await getCache(); - const wanted = `${req.params.provider}/${req.params.slug}`; - const model = models.find((m) => modelId(m) === wanted); - if (!model) { - res.status(404).type("text/plain").send("Unknown model"); - return; - } - res.setHeader("Cache-Control", "no-store"); - res.type("html").send(await renderModelPage(model, models)); - } catch (err) { - next(err); - } - }); - - app.get("/api/v1/models.json", async (_req, res, next) => { - try { - const { models } = await getCache(); - res.json(buildCatalog(models)); - } catch (err) { - next(err); - } - }); - - app.get("/api/v1/schema.json", (_req, res) => { - 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(); - const wanted = `${req.params.provider}/${req.params.slug}`; - const model = models.find((m) => modelId(m) === wanted); - if (!model) { - res.status(404).json({ error: "not_found", id: wanted }); - return; - } - res.json({ $schema: "https://modelparams.dev/api/v1/schema.json", ...model }); - } catch (err) { - next(err); - } - }); - - app.get("/healthz", (_req, res) => { - res.json({ ok: true }); - }); - - return app; -} - async function main(): Promise { console.log("[dev] bundling client assets..."); await rebuildClientAssets(); await refresh(); watch(); - const app = makeApp(); + const app = makeApp(async () => (await getCache()).models); app.listen(PORT, () => { console.log(`[dev] modelparams.dev → http://localhost:${PORT}`); }); diff --git a/tests/server.test.ts b/tests/server.test.ts new file mode 100644 index 0000000..10d043b --- /dev/null +++ b/tests/server.test.ts @@ -0,0 +1,191 @@ +import type { AddressInfo } from "node:net"; +import type { Server } from "node:http"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { makeApp } from "../src/server/app.js"; +import type { Model } from "../src/schema/model.js"; + +function makeModel(overrides: Partial = {}): Model { + return { + provider: "anthropic", + authType: "api_key", + model: "claude-opus-4-7", + params: [ + { + path: "temperature", + type: "number", + label: "Temperature", + description: "Sampling temperature.", + default: 1, + group: "sampling", + }, + { + path: "max_tokens", + type: "integer", + label: "Max tokens", + description: "Maximum output tokens.", + default: 4096, + range: { min: 1 }, + group: "generation_length", + }, + ], + ...overrides, + } as Model; +} + +const MODELS: Model[] = [ + makeModel(), + makeModel({ authType: "subscription" }), + makeModel({ + provider: "openai", + model: "gpt-4o", + params: [ + { path: "top_p", type: "number", label: "Top P", description: "Nucleus.", group: "sampling" }, + ], + }), +]; + +let server: Server; +let baseUrl: string; + +beforeAll(async () => { + const app = makeApp(async () => MODELS); + await new Promise((resolve) => { + server = app.listen(0, "127.0.0.1", resolve); + }); + const { port } = server.address() as AddressInfo; + baseUrl = `http://127.0.0.1:${port}`; +}); + +afterAll(async () => { + await new Promise((resolve, reject) => { + server.close((err) => (err ? reject(err) : resolve())); + }); +}); + +const get = (path: string): Promise => fetch(`${baseUrl}${path}`); + +describe("GET /healthz", () => { + it("reports ok", async () => { + const res = await get("/healthz"); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ ok: true }); + }); + + it("hides the x-powered-by header", async () => { + const res = await get("/healthz"); + expect(res.headers.get("x-powered-by")).toBeNull(); + }); +}); + +describe("GET /api/v1/models.json", () => { + it("returns the catalog with a count and a $schema", async () => { + const res = await get("/api/v1/models.json"); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("application/json"); + const body = await res.json(); + expect(body.count).toBe(MODELS.length); + expect(body.models).toHaveLength(MODELS.length); + expect(body.$schema).toBe("https://modelparams.dev/api/v1/schema.json"); + }); +}); + +describe("GET /api/v1/schema.json", () => { + it("returns the JSON Schema for a model", async () => { + const res = await get("/api/v1/schema.json"); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.$id).toBe("https://modelparams.dev/api/v1/schema.json"); + expect(body.title).toBe("modelparams.dev Model"); + expect(body.definitions?.Model).toBeTruthy(); + }); +}); + +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"); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.provider).toBe("anthropic"); + expect(body.model).toBe("claude-opus-4-7"); + expect(body.authType).toBe("api_key"); + expect(body.$schema).toBe("https://modelparams.dev/api/v1/schema.json"); + }); + + it("resolves the -subscription variant to the subscription model", async () => { + const res = await get("/api/v1/models/anthropic/claude-opus-4-7-subscription.json"); + expect(res.status).toBe(200); + expect((await res.json()).authType).toBe("subscription"); + }); + + it("404s with a JSON error for an unknown id", async () => { + const res = await get("/api/v1/models/anthropic/does-not-exist.json"); + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ error: "not_found", id: "anthropic/does-not-exist" }); + }); +}); + +describe("GET / (home)", () => { + it("renders HTML with no-store caching", async () => { + const res = await get("/"); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("text/html"); + expect(res.headers.get("cache-control")).toBe("no-store"); + const body = await res.text(); + expect(body).toContain(""); + expect(body).toContain("modelparams.dev"); + }); +}); + +describe("GET /glossary", () => { + it("renders the glossary page", async () => { + const res = await get("/glossary"); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("text/html"); + expect(await res.text()).toContain(""); + }); +}); + +describe("GET /providers/:provider", () => { + it("renders a hub for a known provider", async () => { + const res = await get("/providers/anthropic"); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("text/html"); + expect(await res.text()).toContain("Anthropic"); + }); + + it("404s for an unknown provider", async () => { + const res = await get("/providers/not-a-provider"); + expect(res.status).toBe(404); + expect(await res.text()).toBe("Unknown provider"); + }); +}); + +describe("GET /models/:provider/:slug", () => { + it("renders a model page for a known model", async () => { + const res = await get("/models/openai/gpt-4o"); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("text/html"); + expect(await res.text()).toContain(""); + }); + + it("404s for an unknown model", async () => { + const res = await get("/models/anthropic/does-not-exist"); + expect(res.status).toBe(404); + expect(await res.text()).toBe("Unknown model"); + }); +}); + +describe("llms.txt feeds", () => { + it("serves llms.txt as plain text", async () => { + const res = await get("/llms.txt"); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("text/plain"); + expect(await res.text()).toMatch(/^# modelparams\.dev/); + }); + + it("serves the full catalog dump", async () => { + const res = await get("/llms-full.txt"); + expect(res.status).toBe(200); + expect(await res.text()).toContain("# Full catalog"); + }); +}); diff --git a/tests/webmcp.test.ts b/tests/webmcp.test.ts new file mode 100644 index 0000000..82ca964 --- /dev/null +++ b/tests/webmcp.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vitest"; +import { modelId, searchCatalog } from "../src/client/webmcp.js"; + +type AuthType = "api_key" | "subscription"; + +function model(provider: string, name: string, authType: AuthType, paths: string[]) { + return { + provider, + model: name, + authType, + params: paths.map((path) => ({ + path, + type: "number", + group: "sampling", + description: "", + })), + }; +} + +function catalog(...models: ReturnType[]) { + return { count: models.length, models }; +} + +const CATALOG = catalog( + model("anthropic", "claude-opus-4-7", "api_key", ["temperature", "thinking.type", "max_tokens"]), + model("anthropic", "claude-opus-4-7", "subscription", ["temperature", "thinking.type"]), + model("openai", "gpt-4o", "api_key", ["temperature", "top_p"]), + model("deepseek", "deepseek-chat", "api_key", ["temperature"]), +); + +describe("modelId", () => { + it("leaves api_key models bare", () => { + expect(modelId(model("openai", "gpt-4o", "api_key", []))).toBe("openai/gpt-4o"); + }); + + it("suffixes subscription models", () => { + expect(modelId(model("anthropic", "claude-opus-4-7", "subscription", []))).toBe( + "anthropic/claude-opus-4-7-subscription", + ); + }); +}); + +describe("searchCatalog", () => { + it("returns every model when no filters are given", () => { + const result = searchCatalog(CATALOG, {}); + expect(result.total).toBe(4); + expect(result.returned).toBe(4); + expect(result.truncated).toBe(false); + }); + + it("matches free-text query case-insensitively on name, provider, or id", () => { + expect(searchCatalog(CATALOG, { query: "opus" }).total).toBe(2); + expect(searchCatalog(CATALOG, { query: "OPUS" }).total).toBe(2); + expect(searchCatalog(CATALOG, { query: "openai" }).total).toBe(1); + expect(searchCatalog(CATALOG, { query: "nonsense" }).total).toBe(0); + }); + + it("filters by provider slug", () => { + const result = searchCatalog(CATALOG, { provider: "anthropic" }); + expect(result.total).toBe(2); + expect(result.models.every((m) => m.provider === "anthropic")).toBe(true); + }); + + it("requires the model to expose every requested capability", () => { + expect(searchCatalog(CATALOG, { capability: "thinking.type" }).total).toBe(2); + expect(searchCatalog(CATALOG, { capability: ["thinking.type", "max_tokens"] }).total).toBe(1); + expect(searchCatalog(CATALOG, { capability: "does.not.exist" }).total).toBe(0); + }); + + it("filters by auth type and treats `all` as no filter", () => { + expect(searchCatalog(CATALOG, { auth: "subscription" }).total).toBe(1); + expect(searchCatalog(CATALOG, { auth: "api_key" }).total).toBe(3); + expect(searchCatalog(CATALOG, { auth: "all" }).total).toBe(4); + }); + + it("combines filters (AND semantics)", () => { + const result = searchCatalog(CATALOG, { provider: "anthropic", auth: "api_key" }); + expect(result.total).toBe(1); + expect(result.models[0]?.id).toBe("anthropic/claude-opus-4-7"); + }); + + it("caps results at the limit and flags truncation", () => { + const result = searchCatalog(CATALOG, { limit: 1 }); + expect(result.total).toBe(4); + expect(result.returned).toBe(1); + expect(result.truncated).toBe(true); + expect(result.models).toHaveLength(1); + }); + + it("clamps a non-positive limit to at least one result", () => { + expect(searchCatalog(CATALOG, { limit: 0 }).models).toHaveLength(1); + }); + + it("returns id, auth, and parameter paths for each match", () => { + const sub = searchCatalog(CATALOG, { auth: "subscription" }).models[0]; + expect(sub).toMatchObject({ + id: "anthropic/claude-opus-4-7-subscription", + provider: "anthropic", + authType: "subscription", + parameterCount: 2, + }); + expect(sub?.parameters).toEqual(["temperature", "thinking.type"]); + }); +});