diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 39007d4..0deac33 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,3 +82,41 @@ jobs: - name: Type-level tests (tsd) run: npm run test:types --workspace=modelparams + + modelparams-mcp-pkg: + name: modelparams-mcp package (build + tests) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + + - name: Install dependencies + run: npm ci + + # The MCP server imports the built catalog from the sibling package. + - name: Build modelparams + run: npm run build --workspace=modelparams + + - name: Typecheck package + run: npm run typecheck --workspace=modelparams-mcp + + - name: Build package + run: npm run build --workspace=modelparams-mcp + + - name: Tests + run: npm test --workspace=modelparams-mcp + + - name: Server starts and answers over stdio + run: | + printf '%s\n' \ + '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"ci","version":"1"}}}' \ + '{"jsonrpc":"2.0","method":"notifications/initialized"}' \ + '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \ + | node packages/modelparams-mcp/dist/index.js 2>/dev/null \ + | grep -q 'validate_model_params' \ + || { echo "::error::MCP server did not advertise its tools over stdio"; exit 1; } diff --git a/.github/workflows/release-modelparams.yml b/.github/workflows/release-modelparams.yml index c451648..93086ab 100644 --- a/.github/workflows/release-modelparams.yml +++ b/.github/workflows/release-modelparams.yml @@ -1,12 +1,23 @@ name: Release modelparams -# Publishes the `modelparams` npm package when the catalog or codegen pipeline -# changes on main. Versioning is driven by the same diff classifier -# (`findRemovedParams`) that `param-guard.yml` uses on PRs: +# Publishes the `modelparams` and `modelparams-mcp` npm packages when the catalog +# or codegen pipeline changes on main. Versioning is driven by the same diff +# classifier (`findRemovedParams`) that `param-guard.yml` uses on PRs: # • any param removed on a still-existing model → MAJOR # • any other catalog change → PATCH # • no semantic catalog change → skipped # +# Both packages ship in LOCKSTEP at the same version, and `modelparams-mcp` pins +# an exact `modelparams` dependency. That's deliberate: the MCP server answers +# from the catalog compiled into its dependency, so "which catalog does +# modelparams-mcp@x.y.z carry?" has to have one answer. It also avoids a caret +# trap — for 0.0.x versions `^0.0.1` means `>=0.0.1 <0.0.2`, which would freeze +# the server on the first catalog forever. +# +# Publishing both from one workflow (rather than two triggered by the same push) +# keeps the order deterministic: `modelparams` goes out before the package that +# depends on it. +# # Tag-only release: the workflow never pushes to the (protected) main branch. # The published version is the latest `modelparams@x.y.z` git tag, bumped by the # classifier; the first release seeds from packages/modelparams/package.json. @@ -14,9 +25,11 @@ name: Release modelparams # the git tags are the source of truth for the published version. # # Auth: npm OIDC trusted publishing (no token). Requires npm >= 11.5.1, which -# ships with Node 24. Configure the trusted publisher for the `modelparams` -# package on npmjs.com (Settings → Trusted Publishers → GitHub Actions → -# org=mnfst, repo=modelparams.dev, workflow=release-modelparams.yml). +# ships with Node 24. Each package needs its OWN trusted publisher on npmjs.com +# (Settings → Trusted Publishers → GitHub Actions → org=mnfst, +# repo=modelparams.dev, workflow=release-modelparams.yml) — configure it for +# `modelparams-mcp` as well as `modelparams`, both pointing at this filename. +# Renaming this file breaks trusted publishing for both. on: push: @@ -24,6 +37,7 @@ on: paths: - "models/**" - "packages/modelparams/**" + - "packages/modelparams-mcp/**" - "src/schema/model.ts" - "src/data/load.ts" - "src/data/removals.ts" @@ -84,27 +98,55 @@ jobs: - name: Type-level tests (tsd) run: npm run test:types --workspace=modelparams + - name: Build MCP server + run: npm run build --workspace=modelparams-mcp + + - name: MCP server tests + run: npm test --workspace=modelparams-mcp + + # The catalog classifier only sees `models/**`. An MCP-only change is real + # work that still needs shipping, so force a patch when its source moved. + - name: Detect MCP-only changes + id: mcp + run: | + if git diff --quiet HEAD~1 HEAD -- packages/modelparams-mcp; then + echo "changed=false" >> "$GITHUB_OUTPUT" + else + echo "changed=true" >> "$GITHUB_OUTPUT" + echo "::notice::modelparams-mcp source changed — forcing at least a patch release." + fi + - name: Compute next version id: bump run: npx tsx packages/modelparams/scripts/compute-version.ts env: BASE_REF: "HEAD~1" - FORCE_LEVEL: ${{ inputs.force_level }} + FORCE_LEVEL: ${{ inputs.force_level || (steps.mcp.outputs.changed == 'true' && 'patch' || '') }} - name: Skip publish (no semantic change) if: steps.bump.outputs.next == '' run: echo "::notice::No semantic catalog change since the last release — nothing to publish." - - name: Set package version (no commit) + - name: Set package versions (no commit) if: steps.bump.outputs.next != '' env: NEXT: ${{ steps.bump.outputs.next }} - run: npm version "$NEXT" --no-git-tag-version --allow-same-version --workspace=modelparams - - - name: Publish to npm + run: | + npm version "$NEXT" --no-git-tag-version --allow-same-version --workspace=modelparams + npm version "$NEXT" --no-git-tag-version --allow-same-version --workspace=modelparams-mcp + # Pin the exact catalog this server was built and tested against. + npm pkg set "dependencies.modelparams=$NEXT" --workspace=modelparams-mcp + echo "modelparams-mcp depends on modelparams@$(npm pkg get dependencies.modelparams --workspace=modelparams-mcp)" + + - name: Publish modelparams to npm if: steps.bump.outputs.next != '' run: npm publish --workspace=modelparams --provenance --access public + # Second, so the exact dependency it pins already resolves on the registry. + - name: Publish modelparams-mcp to npm + if: steps.bump.outputs.next != '' + run: npm publish --workspace=modelparams-mcp --provenance --access public + - name: Create GitHub release + tag if: steps.bump.outputs.next != '' uses: softprops/action-gh-release@v2 @@ -112,3 +154,7 @@ jobs: tag_name: "modelparams@${{ steps.bump.outputs.next }}" name: "modelparams@${{ steps.bump.outputs.next }}" generate_release_notes: true + body: | + Published to npm: + - `modelparams@${{ steps.bump.outputs.next }}` + - `modelparams-mcp@${{ steps.bump.outputs.next }}` (pins `modelparams@${{ steps.bump.outputs.next }}`) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 32bec9d..f8dff9a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -48,6 +48,13 @@ You don't need to know the schema to file one. A link to the official docs is th - You can also use `{ not: }` to say "any value except this one". - See the [schema doc](docs/model-parameters-schema.md#applicability) for the exact rule syntax and evaluation semantics. + These rules are **enforced at runtime**, not just rendered on the site. The + `modelparams` package, the `/api/v1/validate` endpoint, and the MCP server all + reject parameter combinations your rules forbid. A rule that's too strict makes + people's valid requests fail validation, so check it against the provider's docs. + When a rule references a parameter the request didn't set, evaluation falls back + to that parameter's `default` — which is what the provider applies in its place. + 6. **Auth-type rules of thumb:** - **`api_key`:** list parameters from the official API reference. Don't invent ones the API doesn't accept. - **`subscription`:** list user-facing toggles and presets the consumer can actually set. Skip implementation details. @@ -173,12 +180,25 @@ The website code lives under `src/`: - `src/build/` — SSG pipeline (renders pages, compiles assets, emits JSON API, generates a social card per page). - `src/server/` — Express dev server. +Everything the catalog ships beyond the static site: + +- `api/` — Vercel Functions. Currently `POST /api/v1/validate`. These serve paths the + static build doesn't emit; the rest of `/api/v1/*` stays static JSON from `dist/`. +- `packages/modelparams/` — the npm package: generated types plus the runtime + validation helpers. `src/generated/` is committed and CI checks it against the YAML, + so run `npm run codegen --workspace=modelparams` after changing the catalog. +- `packages/modelparams-mcp/` — the MCP server. Ships in lockstep with `modelparams` + and pins it exactly. +- `skills/` — agent skills, installable with `npx skills add mnfst/modelparams.dev`. + Conventions: - TypeScript, ES modules, strict mode. - No file over 300 lines, no function over 50 lines. - Format with Prettier, lint with ESLint. `npm run format` and `npm run lint` will set you straight. -- Tests live under `tests/` and run with Vitest. +- Tests live under `tests/` and run with Vitest. Each package has its own suite; + `npm test --workspaces` runs them. +- `npm run typecheck` covers the site and `api/` (via `tsconfig.api.json`). ## Pull requests diff --git a/README.md b/README.md index 016d4c3..0e8a1e5 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,40 @@ curl https://modelparams.dev/api/v1/params/gpt-5.5.json Schema at `https://modelparams.dev/api/v1/schema.json`, per the [Model Parameters convention](docs/model-parameters-schema.md). +### Validate a request + +POST the parameters you're about to send. You get back what's wrong — including combinations the provider rejects — and a corrected payload. + +```bash +curl -s https://modelparams.dev/api/v1/validate \ + -H 'Content-Type: application/json' \ + -d '{"model":"claude-3-opus-20240229","params":{"temperature":0.5,"top_p":0.9}}' +``` + +```json +{ + "valid": false, + "issues": [ + { + "path": "top_p", + "code": "not_applicable", + "message": "top_p does not apply when temperature ≠ 1", + "conflictsWith": ["temperature"] + } + ], + "safeParams": { "temperature": 0.5 } +} +``` + +## Agents + +```bash +npx -y modelparams-mcp # MCP server: 4 tools, stdio, no network needed +npx skills add mnfst/modelparams.dev # the companion agent skill +``` + +The MCP server exposes `validate_model_params`, `get_model_params`, `list_models`, and `find_models_supporting`. Details in the [package README](packages/modelparams-mcp/README.md). There's also [llms.txt](https://modelparams.dev/llms.txt) if you'd rather just point an agent at a URL. + ## Adding a model Drop a YAML file in `models//`, open a PR, and CI validates it against the schema. Details in [CONTRIBUTING.md](CONTRIBUTING.md). Can't open a PR? [File an issue](https://github.com/mnfst/modelparams.dev/issues/new/choose) with a link to the docs. @@ -58,7 +92,8 @@ npm install npm run dev # http://localhost:3000 npm run build # → dist/ npm run validate # check every YAML -npm test +npm test # site tests, including the /api/v1/validate function +npm test --workspaces # + both published packages ``` ## License diff --git a/api/v1/validate.ts b/api/v1/validate.ts new file mode 100644 index 0000000..47beae5 --- /dev/null +++ b/api/v1/validate.ts @@ -0,0 +1,162 @@ +// POST /api/v1/validate — check a params object against a model's catalog entry. +// +// Stateless: the catalog is compiled into the bundle at build time from the same +// generated data the npm package ships, so there is no filesystem read, no +// network call, and nothing to keep in sync at runtime. +import { + dropUnsupported, + getModel, + resolveModelId, + type DroppedParam, +} from "../../packages/modelparams/src/index.js"; + +const MAX_BODY_BYTES = 64 * 1024; + +const CORS = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", + "Access-Control-Max-Age": "86400", +} as const; + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body, null, 2) + "\n", { + status, + headers: { + "Content-Type": "application/json; charset=utf-8", + // A validation result is a pure function of the request body, but bodies + // vary per caller — caching it at the edge would serve one caller's + // verdict to another. + "Cache-Control": "no-store", + ...CORS, + }, + }); +} + +function fail(error: string, detail: Record, status: number): Response { + return json({ error, ...detail }, status); +} + +const USAGE = { + endpoint: "POST /api/v1/validate", + description: + "Validate a params object against a model. Reports unknown parameters, out-of-range values, " + + "and combinations the provider rejects, and returns a corrected payload.", + request: { + model: "provider/model id, or a bare model slug when it is unambiguous", + params: "object of provider-native parameter paths to values", + }, + example: { + model: "anthropic/claude-3-opus-20240229", + params: { temperature: 0.5, top_p: 0.9, max_tokens: 1024 }, + }, + docs: "https://modelparams.dev/api", +} as const; + +function toIssue(dropped: DroppedParam) { + return { + path: dropped.path, + code: dropped.code, + message: dropped.reason, + ...(dropped.conflictsWith ? { conflictsWith: dropped.conflictsWith } : {}), + }; +} + +async function readBody(request: Request): Promise> { + const raw = await request.text(); + if (raw.length > MAX_BODY_BYTES) { + throw new RangeError(`request body exceeds ${MAX_BODY_BYTES} bytes`); + } + if (raw.trim() === "") throw new SyntaxError("request body is empty"); + const parsed: unknown = JSON.parse(raw); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new SyntaxError("request body must be a JSON object"); + } + return parsed as Record; +} + +export default { + async fetch(request: Request): Promise { + if (request.method === "OPTIONS") { + return new Response(null, { status: 204, headers: CORS }); + } + + // A GET here is almost always someone exploring the API by hand or an agent + // probing the URL, so answer with the contract instead of a bare 405. + if (request.method === "GET") return json(USAGE); + + if (request.method !== "POST") { + return fail("method_not_allowed", { allowed: ["POST", "GET", "OPTIONS"], usage: USAGE }, 405); + } + + let body: Record; + try { + body = await readBody(request); + } catch (err) { + const status = err instanceof RangeError ? 413 : 400; + return fail( + "invalid_request_body", + { message: (err as Error).message, usage: USAGE }, + status, + ); + } + + const { model, params } = body; + if (typeof model !== "string" || model.trim() === "") { + return fail( + "invalid_request_body", + { message: "`model` must be a non-empty string", usage: USAGE }, + 400, + ); + } + + const resolved = resolveModelId(model); + if (!resolved.ok) { + return resolved.reason === "ambiguous" + ? fail( + "ambiguous_model", + { + message: `"${model}" is published by more than one provider; qualify it with a provider prefix`, + matches: resolved.matches, + }, + 400, + ) + : fail( + "unknown_model", + { + message: `"${model}" is not in the catalog`, + suggestions: resolved.suggestions, + catalog: "https://modelparams.dev/api/v1/models.json", + }, + 404, + ); + } + + if ( + params !== undefined && + (typeof params !== "object" || params === null || Array.isArray(params)) + ) { + return fail( + "invalid_request_body", + { message: "`params` must be an object", usage: USAGE }, + 400, + ); + } + + const supplied = (params ?? {}) as Record; + const { params: safeParams, dropped } = dropUnsupported(resolved.id, supplied); + const entry = getModel(resolved.id); + + return json({ + model: resolved.id, + provider: entry.provider, + authType: entry.authType, + valid: dropped.length === 0, + issues: dropped.map(toIssue), + // Always a payload that would pass validation — callers that just want to + // make the call work can spread this and move on. + safeParams, + docs: `https://modelparams.dev/models/${resolved.id}`, + }); + }, +}; diff --git a/package-lock.json b/package-lock.json index a316e72..cb50102 100644 --- a/package-lock.json +++ b/package-lock.json @@ -618,6 +618,18 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@hono/node-server": { + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.0.12.tgz", + "integrity": "sha512-eWpQYr67tqJLeaSUl0Q+TquuYfUdTibpOJlUMV2FfUP7+KqCC5TufnwnlXL6mobZBJbGAYRd7ZvEBDCbLInjhg==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", @@ -738,6 +750,379 @@ "integrity": "sha512-ZDflEq0uUvAkH4WK4h3qNvvY09ts4OqUb5azD7A0xKfcuYhffGwB1Q/As2RguZYq4Gh4v925CJ8iodiClzc4zw==", "license": "MIT" }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", + "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9 || ^2.0.5", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1898,6 +2283,45 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/ansi-escapes": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", @@ -2491,11 +2915,27 @@ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "license": "MIT" }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -2523,7 +2963,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -3147,6 +3586,27 @@ "dev": true, "license": "MIT" }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/execa": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", @@ -3217,6 +3677,25 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.6.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.1.tgz", + "integrity": "sha512-0D493aP61w0TJ2A0wy27riRsO7FMQ7FK+KUHOKCSfPvYo0R55aiC6emCVgFUeShH0fq0ICPVzNcgoS+BsbXQCA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/express/node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -3236,7 +3715,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-glob": { @@ -3270,6 +3748,22 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -3694,6 +4188,15 @@ "node": ">= 0.4" } }, + "node_modules/hono": { + "version": "4.12.32", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.32.tgz", + "integrity": "sha512-XcuyW9qE2kJn07PkecMOBd5Vq/hMy7mmGw+idz1yblbg9N17ijJODrvPkn7/dwL3Kulj8LcRJ69DLOWf91dRUg==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/hosted-git-info": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", @@ -3830,6 +4333,15 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.3.1.tgz", + "integrity": "sha512-1e9d3kb97NHJTIJDZW9rKqW2h6+dFa50Dy0fpPSMQp2ADje5gvKsXmdiK6dwY5t76TaTt5+P5N1Y/LoToIxP6g==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -3951,6 +4463,12 @@ "node": ">=0.10.0" } }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, "node_modules/is-stream": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", @@ -3981,7 +4499,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, "node_modules/jake": { @@ -4037,6 +4554,15 @@ "jiti": "bin/jiti.js" } }, + "node_modules/jose": { + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.4.tgz", + "integrity": "sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", @@ -4077,6 +4603,12 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -4626,6 +5158,10 @@ "resolved": "packages/modelparams", "link": true }, + "node_modules/modelparams-mcp": { + "resolved": "packages/modelparams-mcp", + "link": true + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -4745,7 +5281,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -4789,7 +5324,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -4936,7 +5470,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -5034,6 +5567,15 @@ "node": ">= 6" } }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/pkg-types": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", @@ -5563,6 +6105,15 @@ "node": ">=8" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -5715,6 +6266,32 @@ "dev": true, "license": "MIT" }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/router/node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -5842,7 +6419,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -5855,7 +6431,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -7255,7 +7830,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -7358,7 +7932,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, "license": "ISC" }, "node_modules/yallist": { @@ -7434,6 +8007,21 @@ "engines": { "node": ">=18" } + }, + "packages/modelparams-mcp": { + "version": "0.0.1", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.30.0", + "modelparams": "^0.0.1", + "zod": "^3.25.0" + }, + "bin": { + "modelparams-mcp": "dist/index.js" + }, + "engines": { + "node": ">=20" + } } } } diff --git a/package.json b/package.json index ba8c062..22a597d 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "dev": "tsx watch src/server/dev.ts", "validate": "tsx src/data/validate.ts", "guard:params": "tsx src/data/check-removals.ts", - "typecheck": "tsc --noEmit", + "typecheck": "tsc --noEmit && tsc -p tsconfig.api.json", "lint": "eslint . --ext .ts,.tsx", "format": "prettier --write .", "test": "vitest run", diff --git a/packages/modelparams-mcp/LICENSE b/packages/modelparams-mcp/LICENSE new file mode 100644 index 0000000..ca8c3f8 --- /dev/null +++ b/packages/modelparams-mcp/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 modelparams.dev contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/modelparams-mcp/README.md b/packages/modelparams-mcp/README.md new file mode 100644 index 0000000..032c12e --- /dev/null +++ b/packages/modelparams-mcp/README.md @@ -0,0 +1,97 @@ +# modelparams-mcp + +> **MCP server for the [modelparams.dev](https://modelparams.dev) catalog.** Let an agent check which parameters a model accepts — before it calls one. + +```bash +npx -y modelparams-mcp +``` + +Model parameters aren't uniform and don't hold still. `gpt-5.5` has no `temperature`. Claude Opus 4.7 dropped it. Anthropic rejects `top_p` unless `temperature` is 1. Reasoning models reject sampling knobs outright. An agent writing LLM code from training data gets this wrong constantly, and the failure is either a 400 or — worse — a silently ignored parameter and drifting evals. + +This server gives the agent a way to look it up instead. + +## Install + +The server speaks MCP over stdio and bundles the catalog, so it needs no network access and no API key. + +**Claude Code** + +```bash +claude mcp add modelparams -- npx -y modelparams-mcp +``` + +**Any client with a JSON config** (Claude Desktop, Cursor, Windsurf, Zed, …) + +```json +{ + "mcpServers": { + "modelparams": { + "command": "npx", + "args": ["-y", "modelparams-mcp"] + } + } +} +``` + +## Tools + +### `validate_model_params` + +The one that earns its keep. Give it a model and the params you're about to send: + +```json +{ "model": "claude-3-opus-20240229", "params": { "temperature": 0.5, "top_p": 0.9 } } +``` + +```json +{ + "model": "anthropic/claude-3-opus-20240229", + "valid": false, + "summary": "1 parameter(s) would be rejected or silently ignored by anthropic/claude-3-opus-20240229.", + "issues": [ + { + "path": "top_p", + "code": "not_applicable", + "message": "top_p does not apply when temperature ≠ 1", + "conflictsWith": ["temperature"] + } + ], + "safeParams": { "temperature": 0.5 } +} +``` + +`safeParams` is always a payload that would pass validation, so an agent that doesn't want to reason about the rules can take it and move on. + +Issue codes: `unknown_parameter` (no such knob on this model), `invalid_value` (out of range or not in the enum), `not_applicable` (the knob exists but conflicts with another value in the same request). + +### `get_model_params` + +Every parameter for one model — type, range, enum values, default, and the conditional rules that gate it. + +### `list_models` + +Model ids, filtered by provider or substring. Use it to resolve the exact catalog id. + +### `find_models_supporting` + +Which models expose a given parameter — `reasoning_effort`, `thinking.budget_tokens`, `top_k`. Answers "which models let me set X". Returns near-miss suggestions when nothing matches, since callers usually have the provider's spelling rather than the catalog's. + +## Model ids + +Ids are `provider/model`. Subscription contracts append `-subscription`, because the same model often exposes different parameters via an API key than via a subscription. Every tool also accepts a bare model slug when only one provider publishes it. + +## Related + +- **[modelparams](https://www.npmjs.com/package/modelparams)** — the TypeScript package: `ParamsOf` for compile-time safety, `parseParams` and `dropUnsupported` at runtime. +- **[HTTP API](https://modelparams.dev/api)** — same data as CORS-enabled static JSON, plus `POST /api/v1/validate`. +- **[Catalog](https://modelparams.dev)** — browse it, or open a PR against the YAML. + +## How it's built + +The catalog is compiled in at publish time from the YAML source of truth at [github.com/mnfst/modelparams.dev](https://github.com/mnfst/modelparams.dev/tree/main/models). No runtime fetch, no cache to invalidate — update the package to get new models. + +This package and [`modelparams`](https://www.npmjs.com/package/modelparams) ship in lockstep at the same version, and this one pins an exact `modelparams` dependency. So `modelparams-mcp@x.y.z` always carries exactly one known catalog — run `npm update modelparams-mcp` (or `npx -y modelparams-mcp@latest`) to pick up newly added models and corrected parameters. + +## License + +MIT diff --git a/packages/modelparams-mcp/package.json b/packages/modelparams-mcp/package.json new file mode 100644 index 0000000..627c30b --- /dev/null +++ b/packages/modelparams-mcp/package.json @@ -0,0 +1,59 @@ +{ + "name": "modelparams-mcp", + "version": "0.0.1", + "description": "MCP server for the modelparams.dev catalog — let an agent check which parameters a model accepts before it calls one.", + "keywords": [ + "mcp", + "model-context-protocol", + "llm", + "agent", + "openai", + "anthropic", + "model-parameters", + "validation", + "tools" + ], + "license": "MIT", + "author": "modelparams.dev contributors", + "homepage": "https://modelparams.dev", + "repository": { + "type": "git", + "url": "git+https://github.com/mnfst/modelparams.dev.git", + "directory": "packages/modelparams-mcp" + }, + "bugs": { + "url": "https://github.com/mnfst/modelparams.dev/issues" + }, + "type": "module", + "bin": { + "modelparams-mcp": "./dist/index.js" + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "engines": { + "node": ">=20" + }, + "scripts": { + "build": "tsc -p tsconfig.build.json && chmod +x dist/index.js", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "prepublishOnly": "npm run build && npm run test" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.30.0", + "zod": "^3.25.0", + "modelparams": "0.0.1" + } +} diff --git a/packages/modelparams-mcp/src/index.ts b/packages/modelparams-mcp/src/index.ts new file mode 100644 index 0000000..b0e4166 --- /dev/null +++ b/packages/modelparams-mcp/src/index.ts @@ -0,0 +1,24 @@ +#!/usr/bin/env node +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { createServer } from "./server.js"; + +export { createServer } from "./server.js"; +export * from "./tools.js"; + +async function main(): Promise { + const server = createServer(); + // stdout is the transport — anything written there that isn't a protocol + // message corrupts the session, so diagnostics go to stderr. + await server.connect(new StdioServerTransport()); + console.error("modelparams MCP server ready on stdio"); +} + +const isDirectRun = + process.argv[1] !== undefined && import.meta.url === `file://${process.argv[1]}`; + +if (isDirectRun) { + main().catch((err) => { + console.error("modelparams MCP server failed to start:", err); + process.exit(1); + }); +} diff --git a/packages/modelparams-mcp/src/server.ts b/packages/modelparams-mcp/src/server.ts new file mode 100644 index 0000000..870b7f7 --- /dev/null +++ b/packages/modelparams-mcp/src/server.ts @@ -0,0 +1,117 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { + findModelsSupporting, + getModelParams, + listCatalogModels, + validateModelParams, + type ToolPayload, +} from "./tools.js"; + +/** Every tool here is a pure read over data compiled into the package. */ +const READ_ONLY = { readOnlyHint: true, idempotentHint: true, openWorldHint: false } as const; + +function reply(payload: ToolPayload) { + return { content: [{ type: "text" as const, text: JSON.stringify(payload, null, 2) }] }; +} + +/** + * Build the server. Kept separate from the stdio entry point so tests can drive + * it over an in-memory transport. + */ +export function createServer(): McpServer { + const server = new McpServer( + { name: "modelparams", version: "0.0.1" }, + { + instructions: + "Answers what parameters an LLM accepts, using the modelparams.dev catalog of " + + "239 models. Call validate_model_params before issuing any LLM request that sets " + + "non-default parameters, and when diagnosing a 400 from a provider — it catches " + + "unsupported parameters and invalid combinations that a provider would reject or " + + "silently ignore.", + }, + ); + + server.registerTool( + "validate_model_params", + { + title: "Validate model parameters", + description: + "Check a set of LLM request parameters against what a model actually accepts, before " + + "calling the provider. Catches unknown parameters, out-of-range values, and " + + "combinations the provider rejects — such as top_p alongside a non-default " + + "temperature on Anthropic models, or sampling knobs on reasoning models. Returns a " + + "corrected `safeParams` payload that is guaranteed to validate. Use this whenever " + + "writing or debugging code that calls an LLM API with non-default parameters.", + inputSchema: { + model: z + .string() + .describe('Catalog id ("anthropic/claude-opus-4-7") or bare model slug ("gpt-5.5").'), + params: z + .record(z.unknown()) + .optional() + .describe( + 'Provider-native parameter paths to values, e.g. {"temperature": 0.7, "thinking.type": "enabled"}.', + ), + }, + annotations: READ_ONLY, + }, + async (args) => reply(validateModelParams(args)), + ); + + server.registerTool( + "get_model_params", + { + title: "Get model parameters", + description: + "List every parameter a model accepts, with its type, allowed range or enum values, " + + "default, and any conditional rules governing when it applies. Use before writing " + + "code that calls a model, or when building a model settings UI.", + inputSchema: { + model: z.string().describe('Catalog id ("openai/gpt-5.5") or bare model slug ("gpt-5.5").'), + }, + annotations: READ_ONLY, + }, + async (args) => reply(getModelParams(args)), + ); + + server.registerTool( + "list_models", + { + title: "List catalog models", + description: + "List model ids in the modelparams.dev catalog, optionally filtered by provider or a " + + "substring. Use to discover the exact id the other tools expect.", + inputSchema: { + provider: z + .string() + .optional() + .describe('Restrict to one provider slug, e.g. "anthropic", "openai", "google".'), + query: z.string().optional().describe("Case-insensitive substring match on the model id."), + limit: z.number().int().positive().max(1000).optional().describe("Default 100."), + }, + annotations: READ_ONLY, + }, + async (args) => reply(listCatalogModels(args)), + ); + + server.registerTool( + "find_models_supporting", + { + title: "Find models supporting a parameter", + description: + 'Find which models accept a given parameter, e.g. "reasoning_effort", ' + + '"thinking.budget_tokens", or "top_k". Use to answer "which models support X" or to ' + + "pick a model that exposes a knob you need.", + inputSchema: { + parameter: z.string().describe('Exact parameter path, e.g. "top_k" or "thinking.type".'), + provider: z.string().optional().describe("Restrict to one provider slug."), + limit: z.number().int().positive().max(1000).optional().describe("Default 100."), + }, + annotations: READ_ONLY, + }, + async (args) => reply(findModelsSupporting(args)), + ); + + return server; +} diff --git a/packages/modelparams-mcp/src/tools.ts b/packages/modelparams-mcp/src/tools.ts new file mode 100644 index 0000000..6d47785 --- /dev/null +++ b/packages/modelparams-mcp/src/tools.ts @@ -0,0 +1,194 @@ +import { + CATALOG, + dropUnsupported, + getDefaults, + getModel, + listModels, + PROVIDERS, + resolveModelId, + type ModelId, + type Param, + type Provider, +} from "modelparams"; + +/** Everything a tool returns: a JSON-serialisable payload the agent can act on. */ +export type ToolPayload = Record; + +function modelIdOf(entry: (typeof CATALOG)[number]): ModelId { + const suffix = entry.authType === "api_key" ? "" : "-subscription"; + return `${entry.provider}/${entry.model}${suffix}` as ModelId; +} + +/** + * Turn an unresolvable model reference into a payload that tells the agent how + * to recover, rather than a bare error it will retry verbatim. + */ +function unresolved( + input: string, + result: Exclude, { ok: true }>, +): ToolPayload { + if (result.reason === "ambiguous") { + return { + error: "ambiguous_model", + message: `"${input}" is published by more than one provider. Retry with one of the qualified ids below.`, + matches: result.matches, + }; + } + return { + error: "unknown_model", + message: `"${input}" is not in the catalog. Call list_models to browse, or retry with one of the suggestions below.`, + suggestions: result.suggestions, + }; +} + +function describeParam(param: Param): ToolPayload { + return { + path: param.path, + label: param.label, + type: param.type, + group: param.group, + description: param.description, + ...(param.default !== undefined ? { default: param.default } : {}), + ...(param.range ? { range: param.range } : {}), + ...(param.values ? { values: param.values } : {}), + ...(param.applicability ? { appliesOnlyWhen: param.applicability } : {}), + }; +} + +/** + * The hero tool: does this request survive contact with the provider? + * + * Returns the diagnosis and a corrected payload, so an agent that doesn't want + * to reason about the rules can just take `safeParams` and proceed. + */ +export function validateModelParams(input: { + model: string; + params?: Record; +}): ToolPayload { + const resolved = resolveModelId(input.model); + if (!resolved.ok) return unresolved(input.model, resolved); + + const supplied = input.params ?? {}; + const { params: safeParams, dropped } = dropUnsupported(resolved.id, supplied); + const entry = getModel(resolved.id); + + return { + model: resolved.id, + provider: entry.provider, + authType: entry.authType, + valid: dropped.length === 0, + summary: + dropped.length === 0 + ? `All ${Object.keys(supplied).length} parameter(s) are accepted by ${resolved.id}.` + : `${dropped.length} parameter(s) would be rejected or silently ignored by ${resolved.id}.`, + issues: dropped.map((d) => ({ + path: d.path, + code: d.code, + message: d.reason, + ...(d.conflictsWith ? { conflictsWith: d.conflictsWith } : {}), + })), + safeParams, + docs: `https://modelparams.dev/models/${resolved.id}`, + }; +} + +/** Full parameter surface for one model, including conditional rules. */ +export function getModelParams(input: { model: string }): ToolPayload { + const resolved = resolveModelId(input.model); + if (!resolved.ok) return unresolved(input.model, resolved); + + const entry = getModel(resolved.id); + const params = entry.params as readonly Param[]; + return { + model: resolved.id, + provider: entry.provider, + authType: entry.authType, + parameterCount: params.length, + params: params.map(describeParam), + defaults: getDefaults(resolved.id), + docs: `https://modelparams.dev/models/${resolved.id}`, + }; +} + +/** Browse the catalog to find the exact id the other tools expect. */ +export function listCatalogModels(input: { + provider?: string; + query?: string; + limit?: number; +}): ToolPayload { + const { provider, query, limit = 100 } = input; + + if (provider && !PROVIDERS.includes(provider as Provider)) { + return { + error: "unknown_provider", + message: `"${provider}" is not a provider in the catalog.`, + providers: PROVIDERS, + }; + } + + let ids = provider ? listModels({ provider: provider as Provider }) : listModels(); + if (query) { + const needle = query.toLowerCase(); + ids = ids.filter((id) => id.toLowerCase().includes(needle)); + } + + return { + total: ids.length, + returned: Math.min(ids.length, limit), + truncated: ids.length > limit, + providers: provider ? [provider] : PROVIDERS, + models: ids.slice(0, limit), + }; +} + +/** Which models expose a given knob — the "can I use X anywhere?" question. */ +export function findModelsSupporting(input: { + parameter: string; + provider?: string; + limit?: number; +}): ToolPayload { + const { parameter, provider, limit = 100 } = input; + const needle = parameter.toLowerCase(); + + const matches: { model: ModelId; param: Param }[] = []; + for (const entry of CATALOG) { + if (provider && entry.provider !== provider) continue; + const param = (entry.params as readonly Param[]).find((p) => p.path.toLowerCase() === needle); + if (param) matches.push({ model: modelIdOf(entry), param }); + } + + if (matches.length === 0) { + // A near-miss list beats an empty result — the caller usually has the + // provider's spelling of the parameter, not the catalog's. + const known = new Set(); + for (const entry of CATALOG) { + for (const p of entry.params as readonly Param[]) { + if (p.path.toLowerCase().includes(needle) || needle.includes(p.path.toLowerCase())) { + known.add(p.path); + } + } + } + return { + parameter, + total: 0, + message: `No model in the catalog accepts "${parameter}".`, + similarParameters: [...known].sort().slice(0, 10), + }; + } + + return { + parameter, + total: matches.length, + returned: Math.min(matches.length, limit), + truncated: matches.length > limit, + models: matches.slice(0, limit).map(({ model, param }) => ({ + model, + type: param.type, + ...(param.default !== undefined ? { default: param.default } : {}), + ...(param.range ? { range: param.range } : {}), + ...(param.values ? { values: param.values } : {}), + ...(param.applicability ? { appliesOnlyWhen: param.applicability } : {}), + })), + docs: `https://modelparams.dev/parameters/${parameter.replace(/\./g, "-")}`, + }; +} diff --git a/packages/modelparams-mcp/tests/server.test.ts b/packages/modelparams-mcp/tests/server.test.ts new file mode 100644 index 0000000..517e359 --- /dev/null +++ b/packages/modelparams-mcp/tests/server.test.ts @@ -0,0 +1,205 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { createServer } from "../src/server.js"; + +let client: Client; + +interface ParamInfo { + path: string; + type: string; + values?: string[]; + appliesOnlyWhen?: unknown; +} + +interface ValidateResult { + model: string; + provider: string; + valid: boolean; + summary: string; + issues: { path: string; code: string; message: string; conflictsWith?: string[] }[]; + safeParams: Record; + error?: string; + suggestions?: string[]; +} + +interface ModelParamsResult { + model: string; + parameterCount: number; + params: ParamInfo[]; +} + +interface ListResult { + total: number; + truncated: boolean; + models: string[]; + providers: string[]; + error?: string; +} + +interface FindResult { + parameter: string; + total: number; + models: { model: string; type: string }[]; + similarParameters?: string[]; +} + +/** Parse the JSON payload a tool replies with. */ +async function call(name: string, args: Record): Promise { + const result = (await client.callTool({ name, arguments: args })) as { + content: { type: string; text: string }[]; + }; + return JSON.parse(result.content[0]!.text) as T; +} + +beforeEach(async () => { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + client = new Client({ name: "test", version: "0.0.0" }); + await Promise.all([createServer().connect(serverTransport), client.connect(clientTransport)]); +}); + +afterEach(async () => { + await client.close(); +}); + +describe("tool discovery", () => { + it("advertises the four catalog tools", async () => { + const { tools } = await client.listTools(); + expect(tools.map((t) => t.name).sort()).toEqual([ + "find_models_supporting", + "get_model_params", + "list_models", + "validate_model_params", + ]); + }); + + it("marks every tool read-only", async () => { + const { tools } = await client.listTools(); + for (const tool of tools) { + expect(tool.annotations?.readOnlyHint, tool.name).toBe(true); + } + }); + + it("describes each tool well enough for an agent to choose it", async () => { + const { tools } = await client.listTools(); + for (const tool of tools) { + expect(tool.description!.length, tool.name).toBeGreaterThan(80); + } + }); +}); + +describe("validate_model_params", () => { + it("passes a valid request", async () => { + const out = await call("validate_model_params", { + model: "anthropic/claude-3-opus-20240229", + params: { max_tokens: 1024, temperature: 1 }, + }); + expect(out.valid).toBe(true); + expect(out.issues).toEqual([]); + }); + + it("catches a conditional conflict and returns a corrected payload", async () => { + const out = await call("validate_model_params", { + model: "anthropic/claude-3-opus-20240229", + params: { temperature: 0.5, top_p: 0.9, max_tokens: 1024 }, + }); + expect(out.valid).toBe(false); + expect(out.issues[0]!.path).toBe("top_p"); + expect(out.issues[0]!.code).toBe("not_applicable"); + expect(out.safeParams).toEqual({ temperature: 0.5, max_tokens: 1024 }); + }); + + it("catches a parameter the model does not expose", async () => { + const out = await call("validate_model_params", { + model: "openai/gpt-5.5", + params: { temperature: 0.7 }, + }); + expect(out.valid).toBe(false); + expect(out.issues[0]!.code).toBe("unknown_parameter"); + }); + + it("treats an omitted params object as an empty request", async () => { + const out = await call("validate_model_params", { model: "openai/gpt-5.5" }); + expect(out.valid).toBe(true); + }); + + it("guides recovery from an unknown model", async () => { + const out = await call("validate_model_params", { + model: "claude-opus-9", + params: {}, + }); + expect(out.error).toBe("unknown_model"); + expect(Array.isArray(out.suggestions)).toBe(true); + }); +}); + +describe("get_model_params", () => { + it("returns typed parameters and defaults", async () => { + const out = await call("get_model_params", { model: "gpt-5.5" }); + expect(out.model).toBe("openai/gpt-5.5"); + expect(out.parameterCount).toBeGreaterThan(0); + const effort = out.params.find((p) => p.path === "reasoning_effort"); + expect(effort!.type).toBe("enum"); + expect(effort!.values).toContain("low"); + }); + + it("surfaces conditional rules", async () => { + const out = await call("get_model_params", { + model: "anthropic/claude-opus-4-7", + }); + const display = out.params.find((p) => p.path === "thinking.display"); + expect(display!.appliesOnlyWhen).toBeDefined(); + }); +}); + +describe("list_models", () => { + it("lists the whole catalog", async () => { + const out = await call("list_models", {}); + expect(out.total).toBeGreaterThan(200); + }); + + it("filters by provider", async () => { + const out = await call("list_models", { provider: "anthropic" }); + expect(out.total).toBeGreaterThan(0); + expect(out.models.every((m) => m.startsWith("anthropic/"))).toBe(true); + }); + + it("filters by substring", async () => { + const out = await call("list_models", { query: "opus" }); + expect(out.models.every((m) => m.includes("opus"))).toBe(true); + }); + + it("reports an unknown provider with the valid set", async () => { + const out = await call("list_models", { provider: "nope" }); + expect(out.error).toBe("unknown_provider"); + expect(out.providers).toContain("openai"); + }); + + it("flags truncation rather than silently cutting off", async () => { + const out = await call("list_models", { limit: 5 }); + expect(out.models).toHaveLength(5); + expect(out.truncated).toBe(true); + }); +}); + +describe("find_models_supporting", () => { + it("finds models exposing a parameter", async () => { + const out = await call("find_models_supporting", { parameter: "top_k" }); + expect(out.total).toBeGreaterThan(0); + expect(out.models[0]!.model).toBeTruthy(); + }); + + it("scopes to a provider", async () => { + const out = await call("find_models_supporting", { + parameter: "top_k", + provider: "anthropic", + }); + expect(out.models.every((m) => m.model.startsWith("anthropic/"))).toBe(true); + }); + + it("suggests near misses when nothing matches", async () => { + const out = await call("find_models_supporting", { parameter: "temperatur" }); + expect(out.total).toBe(0); + expect(out.similarParameters).toContain("temperature"); + }); +}); diff --git a/packages/modelparams-mcp/tsconfig.build.json b/packages/modelparams-mcp/tsconfig.build.json new file mode 100644 index 0000000..cc5774c --- /dev/null +++ b/packages/modelparams-mcp/tsconfig.build.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false + } +} diff --git a/packages/modelparams-mcp/tsconfig.json b/packages/modelparams-mcp/tsconfig.json new file mode 100644 index 0000000..37a155e --- /dev/null +++ b/packages/modelparams-mcp/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "composite": false, + "moduleResolution": "NodeNext", + "module": "NodeNext", + "paths": {} + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "tests"] +} diff --git a/packages/modelparams-mcp/vitest.config.ts b/packages/modelparams-mcp/vitest.config.ts new file mode 100644 index 0000000..ad1485e --- /dev/null +++ b/packages/modelparams-mcp/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["tests/**/*.test.ts"], + globals: false, + reporters: "default", + }, +}); diff --git a/packages/modelparams/README.md b/packages/modelparams/README.md index c629368..7ec3221 100644 --- a/packages/modelparams/README.md +++ b/packages/modelparams/README.md @@ -96,6 +96,41 @@ import { paramsSchema } from "modelparams"; app.post("/chat", validator("json", paramsSchema("openai/gpt-4.1")), handler); ``` +### Conflicting parameters + +Some parameters are only accepted for certain values of _other_ parameters. Anthropic rejects `top_p` unless `temperature` is 1; a thinking budget does nothing unless thinking is on. `parseParams` enforces these rules, and `checkApplicability` reports them on their own: + +```ts +import { checkApplicability } from "modelparams"; + +checkApplicability("anthropic/claude-3-opus-20240229", { temperature: 0.5, top_p: 0.9 }); +// [{ path: "top_p", +// message: "top_p does not apply when temperature ≠ 1", +// conflictsWith: ["temperature"] }] +``` + +A parameter you never set still counts, because the provider applies its own default in your place. + +### Drop what won't fly + +`dropUnsupported` is the catalog-driven equivalent of LiteLLM's `drop_params`, extended to conditional conflicts. It returns a payload that is always safe to spread into a provider call, plus what it removed and why: + +```ts +import { dropUnsupported } from "modelparams"; + +const { params, dropped } = dropUnsupported("openai/gpt-5.5", { + temperature: 0.7, // gpt-5.5 exposes no temperature + reasoning_effort: "low", +}); + +// params → { reasoning_effort: "low" } +// dropped → [{ path: "temperature", code: "unknown_parameter", reason: "…" }] + +await openai.chat.completions.create({ model: "gpt-5.5", messages, ...params }); +``` + +Each dropped entry carries a `code`: `unknown_parameter`, `invalid_value`, or `not_applicable`. + ## API ### Types @@ -114,15 +149,19 @@ app.post("/chat", validator("json", paramsSchema("openai/gpt-4.1")), handler); ### Functions -| Function | Description | -| -------------------------- | ----------------------------------------------------------- | -| `getModel(id)` | The full catalog entry for a model id. | -| `getDefaults(id)` | The catalog-declared defaults. | -| `getParam(id, path)` | A single parameter's definition (range, enum values, etc.). | -| `listModels({ provider })` | List model ids, optionally filtered by provider. | -| `listAllModels()` | The full `CATALOG` array. | -| `parseParams(id, input)` | Validate an untrusted params object against the catalog. | -| `paramsSchema(id)` | A Standard Schema that validates a params object for `id`. | +| Function | Description | +| -------------------------------- | ----------------------------------------------------------- | +| `getModel(id)` | The full catalog entry for a model id. | +| `getDefaults(id)` | The catalog-declared defaults. | +| `getParam(id, path)` | A single parameter's definition (range, enum values, etc.). | +| `listModels({ provider })` | List model ids, optionally filtered by provider. | +| `listAllModels()` | The full `CATALOG` array. | +| `parseParams(id, input)` | Validate an untrusted params object against the catalog. | +| `paramsSchema(id)` | A Standard Schema that validates a params object for `id`. | +| `checkApplicability(id, params)` | Report parameters that conflict with others in the request. | +| `isApplicable(id, path, params)` | Is one parameter accepted alongside the rest? | +| `dropUnsupported(id, params)` | Strip everything the model won't accept. | +| `resolveModelId(input)` | Resolve a bare model slug or full id to a catalog id. | ### Constants diff --git a/packages/modelparams/src/applicability.ts b/packages/modelparams/src/applicability.ts new file mode 100644 index 0000000..65a3643 --- /dev/null +++ b/packages/modelparams/src/applicability.ts @@ -0,0 +1,269 @@ +import { checkValue } from "./check-value.js"; +import type { ModelId } from "./generated/model-ids.js"; +import { getModel } from "./helpers.js"; +import type { + ApplicabilityCondition, + ApplicabilityRule, + ApplicabilityValue, + JsonPrimitive, + Param, +} from "./types.js"; + +/** A parameter that was supplied but isn't accepted alongside the other values in the request. */ +export interface ApplicabilityIssue { + /** Dot path of the parameter that doesn't apply. */ + readonly path: string; + /** Human-readable cause, e.g. `top_p does not apply when temperature ≠ 1`. */ + readonly message: string; + /** Dot paths of the parameters whose values caused the conflict. */ + readonly conflictsWith: readonly string[]; +} + +function toRules( + input: ApplicabilityRule | readonly ApplicabilityRule[] | undefined, +): readonly ApplicabilityRule[] { + if (!input) return []; + return Array.isArray(input) + ? (input as readonly ApplicabilityRule[]) + : [input as ApplicabilityRule]; +} + +function isCondition(value: ApplicabilityValue): value is ApplicabilityCondition { + return typeof value === "object" && value !== null && !Array.isArray(value) && "not" in value; +} + +/** + * Does the observed value satisfy one entry of a rule? An absent value never + * satisfies a condition — we only report a conflict on positive evidence, so a + * parameter the request never mentions and the model never defaults can't + * trigger one. + */ +function conditionHolds( + observed: JsonPrimitive | undefined, + expected: ApplicabilityValue, +): boolean { + if (observed === undefined) return false; + if (isCondition(expected)) { + const { not } = expected; + return Array.isArray(not) ? !not.includes(observed) : observed !== not; + } + if (Array.isArray(expected)) return expected.includes(observed); + return observed === expected; +} + +function formatPrimitive(value: JsonPrimitive): string { + return typeof value === "string" ? `"${value}"` : String(value); +} + +function formatExpectation(path: string, expected: ApplicabilityValue): string { + if (isCondition(expected)) { + const { not } = expected; + return Array.isArray(not) + ? `${path} is not one of ${not.map(formatPrimitive).join(", ")}` + : `${path} ≠ ${formatPrimitive(not as JsonPrimitive)}`; + } + if (Array.isArray(expected)) { + return expected.length === 1 + ? `${path} = ${formatPrimitive(expected[0]!)}` + : `${path} is one of ${expected.map(formatPrimitive).join(", ")}`; + } + return `${path} = ${formatPrimitive(expected as JsonPrimitive)}`; +} + +function describeRule(rule: ApplicabilityRule): string { + return Object.entries(rule) + .map(([path, expected]) => formatExpectation(path, expected)) + .join(" and "); +} + +/** Every key must hold for the rule to match. */ +function ruleMatches( + rule: ApplicabilityRule, + resolve: (path: string) => JsonPrimitive | undefined, +): boolean { + return Object.entries(rule).every(([path, expected]) => conditionHolds(resolve(path), expected)); +} + +function anyRuleMatches( + rules: readonly ApplicabilityRule[], + resolve: (path: string) => JsonPrimitive | undefined, +): ApplicabilityRule | undefined { + return rules.find((rule) => ruleMatches(rule, resolve)); +} + +/** + * Build the effective view of a request: what the provider will actually see. + * An explicitly supplied value wins; otherwise the catalog default stands in, + * because that is what the provider applies when the key is omitted. + */ +function effectiveValues( + params: readonly Param[], + supplied: Readonly>, +): (path: string) => JsonPrimitive | undefined { + const defaults = new Map(); + for (const param of params) { + if (param.default !== undefined) defaults.set(param.path, param.default); + } + return (path) => { + if (Object.prototype.hasOwnProperty.call(supplied, path)) { + return supplied[path] as JsonPrimitive; + } + return defaults.get(path); + }; +} + +/** + * Is `path` accepted for this model given the rest of the request? + * + * Returns `true` for parameters with no applicability rules, and for parameters + * the model doesn't declare at all (that's `parseParams`' job to report, not + * this one's). + * + * @example + * isApplicable("anthropic/claude-3-opus-20240229", "top_p", { temperature: 0.5 }); + * // false — Anthropic rejects top_p unless temperature is 1 + */ +export function isApplicable( + id: ModelId, + path: string, + params: Readonly> = {}, +): boolean { + const all = getModel(id).params as readonly Param[]; + const param = all.find((p) => p.path === path); + if (!param?.applicability) return true; + const resolve = effectiveValues(all, params); + const { only, except } = param.applicability; + if (only && !anyRuleMatches(toRules(only), resolve)) return false; + if (except && anyRuleMatches(toRules(except), resolve)) return false; + return true; +} + +/** + * Report every supplied parameter that the model won't accept alongside the + * others. This is the check that catches the errors a per-parameter validator + * cannot see — `temperature and top_p cannot both be specified`, thinking-mode + * knobs on a non-thinking request, and so on. + * + * Only parameters present in `params` are reported; unknown parameters are left + * to {@link parseParams}. + * + * @example + * checkApplicability("anthropic/claude-3-opus-20240229", { temperature: 0.5, top_p: 0.9 }); + * // [{ path: "top_p", message: "top_p does not apply when temperature ≠ 1", conflictsWith: ["temperature"] }] + */ +export function checkApplicability( + id: ModelId, + params: Readonly>, +): readonly ApplicabilityIssue[] { + const all = getModel(id).params as readonly Param[]; + const resolve = effectiveValues(all, params); + const issues: ApplicabilityIssue[] = []; + + for (const param of all) { + if (!param.applicability) continue; + if (!Object.prototype.hasOwnProperty.call(params, param.path)) continue; + + const { only, except } = param.applicability; + + const onlyRules = toRules(only); + if (onlyRules.length > 0 && !anyRuleMatches(onlyRules, resolve)) { + issues.push({ + path: param.path, + message: `${param.path} only applies when ${onlyRules.map(describeRule).join(", or when ")}`, + conflictsWith: [...new Set(onlyRules.flatMap((rule) => Object.keys(rule)))], + }); + continue; + } + + const matchedExcept = anyRuleMatches(toRules(except), resolve); + if (matchedExcept) { + issues.push({ + path: param.path, + message: `${param.path} does not apply when ${describeRule(matchedExcept)}`, + conflictsWith: Object.keys(matchedExcept), + }); + } + } + + return issues; +} + +/** Why a parameter was removed: not in the catalog, bad value, or a conflict. */ +export type DropCode = "unknown_parameter" | "invalid_value" | "not_applicable"; + +/** One parameter removed by {@link dropUnsupported}, with the reason why. */ +export interface DroppedParam { + readonly path: string; + readonly value: unknown; + readonly code: DropCode; + readonly reason: string; + /** For `not_applicable`: the parameters whose values caused the conflict. */ + readonly conflictsWith?: readonly string[]; +} + +/** The result of {@link dropUnsupported}: what's safe to send, and what was removed. */ +export interface DropResult { + readonly params: Record; + readonly dropped: readonly DroppedParam[]; +} + +/** + * Strip everything the model won't accept and return a params object that is + * safe to spread into a provider call — the catalog-driven equivalent of + * LiteLLM's `drop_params`, extended to conditional conflicts. + * + * Drops unknown parameters, values that fail their type/range/enum constraint, + * and parameters that don't apply alongside the rest of the request. + * + * @example + * const { params, dropped } = dropUnsupported("openai/gpt-5.5", { + * temperature: 0.7, // dropped: gpt-5.5 exposes no temperature + * reasoning_effort: "low", + * }); + * await openai.chat.completions.create({ model: "gpt-5.5", messages, ...params }); + */ +export function dropUnsupported(id: ModelId, input: Readonly>): DropResult { + const all = getModel(id).params as readonly Param[]; + const defs = new Map(all.map((param) => [param.path, param])); + const dropped: DroppedParam[] = []; + const kept: Record = {}; + + for (const [path, value] of Object.entries(input)) { + const def = defs.get(path); + if (!def) { + dropped.push({ + path, + value, + code: "unknown_parameter", + reason: `${id} does not accept ${path}`, + }); + continue; + } + const problem = checkValue(def, value); + if (problem) { + dropped.push({ path, value, code: "invalid_value", reason: `${path} ${problem}` }); + continue; + } + kept[path] = value as JsonPrimitive; + } + + // Applicability is judged against the surviving request, then re-judged after + // each removal: dropping one parameter can open a gate another parameter's + // rule depended on. Each pass removes at least one key, so this terminates. + for (;;) { + const issues = checkApplicability(id, kept); + if (issues.length === 0) break; + for (const issue of issues) { + dropped.push({ + path: issue.path, + value: kept[issue.path], + code: "not_applicable", + reason: issue.message, + conflictsWith: issue.conflictsWith, + }); + delete kept[issue.path]; + } + } + + return { params: kept, dropped }; +} diff --git a/packages/modelparams/src/check-value.ts b/packages/modelparams/src/check-value.ts new file mode 100644 index 0000000..e1b1040 --- /dev/null +++ b/packages/modelparams/src/check-value.ts @@ -0,0 +1,30 @@ +import type { JsonPrimitive, Param } from "./types.js"; + +/** + * Validate one value against a single parameter definition — type, numeric + * range, and enum membership. Returns an error message, or null if the value is + * acceptable in isolation. + * + * Cross-parameter conflicts are not this function's concern; see + * `checkApplicability`. + */ +export function checkValue(def: Param, value: unknown): string | null { + if (def.type === "boolean") { + return typeof value === "boolean" ? null : "must be a boolean"; + } + if (def.type === "string") { + return typeof value === "string" ? null : "must be a string"; + } + if (def.type === "enum") { + const values = def.values ?? []; + if (values.includes(value as JsonPrimitive)) return null; + return `must be one of ${values.map((v) => JSON.stringify(v)).join(", ")}`; + } + // "integer" | "number" + if (typeof value !== "number" || Number.isNaN(value)) return "must be a number"; + if (def.type === "integer" && !Number.isInteger(value)) return "must be an integer"; + const { min, max } = def.range ?? {}; + if (min !== undefined && value < min) return `must be >= ${min}`; + if (max !== undefined && value > max) return `must be <= ${max}`; + return null; +} diff --git a/packages/modelparams/src/index.ts b/packages/modelparams/src/index.ts index f25a433..6bc8823 100644 --- a/packages/modelparams/src/index.ts +++ b/packages/modelparams/src/index.ts @@ -6,7 +6,12 @@ export type { ParamGroup, ParamRange, JsonPrimitive, + Applicability, + ApplicabilityCondition, + ApplicabilityRule, + ApplicabilityValue, } from "./types.js"; +export type { ApplicabilityIssue, DropCode, DroppedParam, DropResult } from "./applicability.js"; export type { ModelId, Provider } from "./generated/model-ids.js"; export type { ParamsById } from "./generated/params-by-id.js"; export type { CatalogEntry } from "./generated/data.js"; @@ -23,3 +28,6 @@ export { CATALOG, BY_ID } from "./generated/data.js"; export { getModel, getDefaults, listModels, getParam, listAllModels } from "./helpers.js"; export { parseParams, paramsSchema } from "./parse.js"; +export { checkApplicability, dropUnsupported, isApplicable } from "./applicability.js"; +export { resolveModelId } from "./resolve.js"; +export type { ResolveResult } from "./resolve.js"; diff --git a/packages/modelparams/src/parse.ts b/packages/modelparams/src/parse.ts index 854bccb..3f09d7c 100644 --- a/packages/modelparams/src/parse.ts +++ b/packages/modelparams/src/parse.ts @@ -1,3 +1,5 @@ +import { checkApplicability } from "./applicability.js"; +import { checkValue } from "./check-value.js"; import type { ModelId } from "./generated/model-ids.js"; import { getModel } from "./helpers.js"; import type { JsonPrimitive, Param } from "./types.js"; @@ -15,36 +17,17 @@ export type ParseParamsResult = | { readonly success: true; readonly value: Record } | { readonly success: false; readonly issues: readonly ParamIssue[] }; -/** Validate one value against a parameter definition. Returns an error message, or null if ok. */ -function checkValue(def: Param, value: unknown): string | null { - if (def.type === "boolean") { - return typeof value === "boolean" ? null : "must be a boolean"; - } - if (def.type === "string") { - return typeof value === "string" ? null : "must be a string"; - } - if (def.type === "enum") { - const values = def.values ?? []; - if (values.includes(value as JsonPrimitive)) return null; - return `must be one of ${values.map((v) => JSON.stringify(v)).join(", ")}`; - } - // "integer" | "number" - if (typeof value !== "number" || Number.isNaN(value)) return "must be a number"; - if (def.type === "integer" && !Number.isInteger(value)) return "must be an integer"; - const { min, max } = def.range ?? {}; - if (min !== undefined && value < min) return `must be >= ${min}`; - if (max !== undefined && value > max) return `must be <= ${max}`; - return null; -} - /** * Validate an untrusted params object (e.g. an HTTP request body) against a * model's catalog. Unknown keys, wrong types, out-of-range numbers and invalid * enum values are reported. This is the runtime complement to `ParamsOf`, * which only constrains params known at compile time. * - * Note: parameters are validated independently; cross-parameter `applicability` - * rules (e.g. a knob that only applies when another is set) are not yet enforced. + * Cross-parameter `applicability` rules are enforced too, so combinations the + * provider rejects at runtime — `temperature` alongside a thinking budget, say — + * surface here rather than as a 400 from the provider. Every parameter is + * checked in isolation first; conflicts are only reported once each value is + * individually valid. * * @example * const result = parseParams("openai/gpt-4.1", req.body.params); @@ -77,7 +60,23 @@ export function parseParams(id: ModelId, input: unknown): ParseParamsResult { value[key] = raw as JsonPrimitive; } - return issues.length > 0 ? { success: false, issues } : { success: true, value }; + if (issues.length > 0) return { success: false, issues }; + + // Only worth asking once every value is individually sound — an out-of-range + // temperature would otherwise produce a confusing conflict report on top of + // the range error. + const conflicts = checkApplicability(id, value); + if (conflicts.length > 0) { + return { + success: false, + issues: conflicts.map((conflict) => ({ + message: conflict.message, + path: [conflict.path], + })), + }; + } + + return { success: true, value }; } /** diff --git a/packages/modelparams/src/resolve.ts b/packages/modelparams/src/resolve.ts new file mode 100644 index 0000000..0b5dad5 --- /dev/null +++ b/packages/modelparams/src/resolve.ts @@ -0,0 +1,58 @@ +import { MODEL_IDS, type ModelId } from "./generated/model-ids.js"; + +/** The outcome of {@link resolveModelId}. */ +export type ResolveResult = + | { readonly ok: true; readonly id: ModelId } + | { readonly ok: false; readonly reason: "not_found"; readonly suggestions: readonly ModelId[] } + | { readonly ok: false; readonly reason: "ambiguous"; readonly matches: readonly ModelId[] }; + +function scoreCandidate(candidate: string, query: string): number { + if (candidate.includes(query)) return 2; + // Cheap fuzzy fallback: reward shared prefix length so a typo still surfaces + // near neighbours rather than an empty list. + let shared = 0; + while ( + shared < candidate.length && + shared < query.length && + candidate[shared] === query[shared] + ) { + shared += 1; + } + return shared >= 4 ? 1 : 0; +} + +/** + * Resolve a user-supplied model reference to a catalog id. + * + * Accepts a full `provider/model` id, or a bare provider-native model slug + * (`claude-opus-4-7`) when exactly one provider publishes it. A bare slug that + * several providers publish is reported as ambiguous rather than guessed at. + * + * @example + * resolveModelId("gpt-5.5"); // → { ok: true, id: "openai/gpt-5.5" } + * resolveModelId("anthropic/claude-opus-4-7"); // → { ok: true, id: "anthropic/claude-opus-4-7" } + */ +export function resolveModelId(input: string): ResolveResult { + const query = input.trim(); + if (MODEL_IDS.includes(query as ModelId)) { + return { ok: true, id: query as ModelId }; + } + + if (!query.includes("/")) { + const matches = MODEL_IDS.filter((id) => id.slice(id.indexOf("/") + 1) === query); + if (matches.length === 1) return { ok: true, id: matches[0]! }; + if (matches.length > 1) return { ok: false, reason: "ambiguous", matches }; + } + + const needle = query.toLowerCase(); + const suggestions = MODEL_IDS.map((id) => ({ + id, + score: scoreCandidate(id.toLowerCase(), needle), + })) + .filter((c) => c.score > 0) + .sort((a, b) => b.score - a.score || a.id.localeCompare(b.id)) + .slice(0, 5) + .map((c) => c.id); + + return { ok: false, reason: "not_found", suggestions }; +} diff --git a/packages/modelparams/src/types.ts b/packages/modelparams/src/types.ts index e856285..b739cab 100644 --- a/packages/modelparams/src/types.ts +++ b/packages/modelparams/src/types.ts @@ -47,6 +47,35 @@ export interface ParamRange { readonly step?: number; } +/** Negated match: the referenced parameter must not equal (or be among) `not`. */ +export interface ApplicabilityCondition { + readonly not: JsonPrimitive | readonly JsonPrimitive[]; +} + +/** + * What another parameter must be for a rule to match: an exact value, one of a + * set of values, or anything but a value (`{ not: ... }`). + */ +export type ApplicabilityValue = JsonPrimitive | readonly JsonPrimitive[] | ApplicabilityCondition; + +/** + * One condition set, keyed by the dot path of the parameter it constrains. + * Every entry must hold for the rule to match (AND). + */ +export type ApplicabilityRule = { readonly [path: string]: ApplicabilityValue }; + +/** + * When a parameter is accepted, expressed in terms of the other parameters in + * the same request. A list of rules matches if any one of them matches (OR). + * + * `only` — the parameter applies *only* while a rule matches. + * `except` — the parameter does *not* apply while a rule matches. + */ +export interface Applicability { + readonly only?: ApplicabilityRule | readonly ApplicabilityRule[]; + readonly except?: ApplicabilityRule | readonly ApplicabilityRule[]; +} + /** * A single parameter definition in a loose, easy-to-iterate shape — the runtime * counterpart to the precise per-model `ParamsOf` types. @@ -71,4 +100,6 @@ export interface Param { readonly range?: ParamRange; /** Present on `enum` params. */ readonly values?: readonly JsonPrimitive[]; + /** When set, the parameter is only accepted for some values of its siblings. */ + readonly applicability?: Applicability; } diff --git a/packages/modelparams/tests/applicability.test.ts b/packages/modelparams/tests/applicability.test.ts new file mode 100644 index 0000000..5b37f24 --- /dev/null +++ b/packages/modelparams/tests/applicability.test.ts @@ -0,0 +1,222 @@ +import { describe, expect, it } from "vitest"; +import { + checkApplicability, + dropUnsupported, + getDefaults, + getModel, + getParam, + isApplicable, + MODEL_IDS, + parseParams, + type Param, +} from "../src/index.js"; + +// These fixtures are real catalog entries, picked because they carry the rule +// shapes the engine has to handle. If the catalog changes shape under them the +// assertions below should fail loudly rather than silently stop testing +// anything — hence the guards. +const OPUS_3 = "anthropic/claude-3-opus-20240229"; +const OPUS_47 = "anthropic/claude-opus-4-7"; + +describe("catalog fixtures still carry the rules under test", () => { + it("claude-3-opus top_p is gated on temperature", () => { + const top_p = getParam(OPUS_3, "top_p") as Param | undefined; + expect(top_p?.applicability?.except).toBeDefined(); + }); + + it("claude-opus-4-7 thinking.display is gated on thinking.type", () => { + const display = getParam(OPUS_47, "thinking.display") as Param | undefined; + expect(display?.applicability?.only).toBeDefined(); + }); +}); + +describe("checkApplicability", () => { + it("flags top_p when temperature is moved off its default", () => { + const issues = checkApplicability(OPUS_3, { temperature: 0.5, top_p: 0.9 }); + expect(issues.map((i) => i.path)).toContain("top_p"); + const conflict = issues.find((i) => i.path === "top_p"); + expect(conflict?.conflictsWith).toContain("temperature"); + expect(conflict?.message).toMatch(/temperature/); + }); + + it("allows top_p when temperature is left at its default", () => { + expect(checkApplicability(OPUS_3, { top_p: 0.9 })).toEqual([]); + }); + + it("allows top_p when temperature is explicitly set to the permitted value", () => { + expect(checkApplicability(OPUS_3, { temperature: 1, top_p: 0.9 })).toEqual([]); + }); + + it("ignores parameters the request never supplied", () => { + // temperature conflicts with top_p, but top_p isn't in the request. + expect(checkApplicability(OPUS_3, { temperature: 0.5 })).toEqual([]); + }); + + it("reports an `only` rule when the gating parameter has the wrong value", () => { + const display = getParam(OPUS_47, "thinking.display") as Param; + const allowed = (display.applicability?.only as { "thinking.type": string[] })["thinking.type"]; + const thinkingType = getParam(OPUS_47, "thinking.type") as Param; + const disallowed = (thinkingType.values ?? []).find((v) => !allowed.includes(v as string)); + expect(disallowed).toBeDefined(); + + const issues = checkApplicability(OPUS_47, { + "thinking.type": disallowed, + "thinking.display": display.values?.[0], + }); + expect(issues.map((i) => i.path)).toContain("thinking.display"); + expect(issues.find((i) => i.path === "thinking.display")?.message).toMatch(/only applies when/); + }); + + it("accepts an `only` rule when the gating parameter matches", () => { + const display = getParam(OPUS_47, "thinking.display") as Param; + const allowed = (display.applicability?.only as { "thinking.type": string[] })["thinking.type"]; + const issues = checkApplicability(OPUS_47, { + "thinking.type": allowed[0], + "thinking.display": display.values?.[0], + }); + expect(issues.map((i) => i.path)).not.toContain("thinking.display"); + }); + + it("returns nothing for a model with no applicability rules", () => { + const plain = getModel("openai/gpt-5.5").params as readonly Param[]; + expect(plain.every((p) => !p.applicability)).toBe(true); + expect(checkApplicability("openai/gpt-5.5", { reasoning_effort: "low" })).toEqual([]); + }); +}); + +describe("isApplicable", () => { + it("is true for a parameter with no rules", () => { + expect(isApplicable(OPUS_3, "max_tokens", {})).toBe(true); + }); + + it("is true for a parameter the model doesn't declare", () => { + expect(isApplicable(OPUS_3, "frequency_penalty", {})).toBe(true); + }); + + it("tracks the surrounding request", () => { + expect(isApplicable(OPUS_3, "top_p", { temperature: 1 })).toBe(true); + expect(isApplicable(OPUS_3, "top_p", { temperature: 0.2 })).toBe(false); + }); + + it("defaults to the catalog value when the gating parameter is absent", () => { + // claude-3-opus defaults temperature to 1, which is what the rule permits. + expect(getParam(OPUS_3, "temperature")?.default).toBe(1); + expect(isApplicable(OPUS_3, "top_p", {})).toBe(true); + }); +}); + +describe("parseParams enforces applicability", () => { + it("rejects a conflicting combination", () => { + const result = parseParams(OPUS_3, { temperature: 0.5, top_p: 0.9 }); + expect(result.success).toBe(false); + if (result.success) return; + expect(result.issues.some((i) => i.path[0] === "top_p")).toBe(true); + }); + + it("still accepts a valid combination", () => { + const result = parseParams(OPUS_3, { temperature: 1, top_p: 0.9 }); + expect(result.success).toBe(true); + }); + + it("reports the range error rather than a conflict when a value is out of range", () => { + const result = parseParams(OPUS_3, { temperature: 99, top_p: 0.9 }); + expect(result.success).toBe(false); + if (result.success) return; + expect(result.issues).toHaveLength(1); + expect(result.issues[0]?.path[0]).toBe("temperature"); + }); +}); + +describe("dropUnsupported", () => { + it("drops a parameter the model doesn't accept", () => { + const { params, dropped } = dropUnsupported("openai/gpt-5.5", { + temperature: 0.7, + reasoning_effort: "low", + }); + expect(params).toEqual({ reasoning_effort: "low" }); + expect(dropped.map((d) => d.path)).toEqual(["temperature"]); + }); + + it("drops the conflicting parameter, keeping the rest of the request", () => { + const { params, dropped } = dropUnsupported(OPUS_3, { + temperature: 0.5, + top_p: 0.9, + max_tokens: 1024, + }); + expect(params).toEqual({ temperature: 0.5, max_tokens: 1024 }); + expect(dropped.map((d) => d.path)).toEqual(["top_p"]); + expect(dropped[0]?.value).toBe(0.9); + expect(dropped[0]?.reason).toMatch(/temperature/); + }); + + it("drops values that fail their own constraint", () => { + const { params, dropped } = dropUnsupported(OPUS_3, { temperature: 99, max_tokens: 512 }); + expect(params).toEqual({ max_tokens: 512 }); + expect(dropped.map((d) => d.path)).toEqual(["temperature"]); + }); + + it("leaves a already-valid request untouched", () => { + const input = { max_tokens: 1024, temperature: 1 }; + const { params, dropped } = dropUnsupported(OPUS_3, input); + expect(params).toEqual(input); + expect(dropped).toEqual([]); + }); + + it("always returns a request that parses clean", () => { + const { params } = dropUnsupported(OPUS_3, { + temperature: 0.5, + top_p: 0.9, + top_k: 40, + nonsense: true, + }); + expect(parseParams(OPUS_3, params).success).toBe(true); + }); +}); + +describe("a gated parameter set without opening its gate", () => { + // The catalog gives thinking.budget_tokens a default *and* gates it on + // thinking.type — the default describes the budget used once thinking is on, + // not a value that is always live. Passing the budget without enabling + // thinking is a silent no-op at the provider, so it should be reported. + const GATED = "anthropic/claude-3-7-sonnet-20250219"; + + it("is reported", () => { + const issues = checkApplicability(GATED, { "thinking.budget_tokens": 8000 }); + expect(issues.map((i) => i.path)).toContain("thinking.budget_tokens"); + }); + + it("is accepted once the gate is open", () => { + const issues = checkApplicability(GATED, { + "thinking.type": "enabled", + "thinking.budget_tokens": 8000, + }); + expect(issues).toEqual([]); + }); +}); + +describe("the whole catalog", () => { + it("converges: dropUnsupported always yields a request that parses clean", () => { + // Guards the fixed-point loop in dropUnsupported against both + // non-termination and emitting output it would itself reject. + for (const id of MODEL_IDS) { + const { params } = dropUnsupported(id, { ...getDefaults(id) }); + const result = parseParams(id, params); + expect( + result.success, + `${id}: ${result.success ? "" : result.issues.map((i) => i.message).join("; ")}`, + ).toBe(true); + } + }); + + it("never drops a parameter it considers applicable", () => { + for (const id of MODEL_IDS) { + const { params, dropped } = dropUnsupported(id, { ...getDefaults(id) }); + for (const path of Object.keys(params)) { + expect(isApplicable(id, path, params), `${id} kept inapplicable ${path}`).toBe(true); + } + for (const d of dropped) { + expect(Object.keys(params)).not.toContain(d.path); + } + } + }); +}); diff --git a/skills/llm-model-parameters/SKILL.md b/skills/llm-model-parameters/SKILL.md new file mode 100644 index 0000000..66c301a --- /dev/null +++ b/skills/llm-model-parameters/SKILL.md @@ -0,0 +1,157 @@ +--- +name: llm-model-parameters +description: Look up and validate the parameters an LLM accepts before calling it — temperature, top_p, top_k, reasoning_effort, thinking budgets — including which combinations a provider rejects. Use when writing or reviewing code that calls an LLM API with non-default parameters, when picking a model by the knobs it exposes, when building a model settings UI or an LLM router, or when debugging a provider 400 such as "unsupported parameter", "unrecognized request argument", "unsupported value", or "temperature and top_p cannot both be specified". Backed by the open modelparams.dev catalog. +--- + +# LLM model parameters + +Model parameters are not uniform and not stable. `gpt-5.5` has no `temperature`. +Claude Opus 4.7 dropped it. Anthropic rejects `top_p` unless `temperature` is 1. +Reasoning models reject sampling knobs entirely. Training data goes stale on all +of this, so **look it up rather than recalling it.** + +## When to use this skill + +- Writing or editing code that passes parameters to an LLM API. +- Debugging a 400 from a provider, or output that ignores a parameter you set. +- Choosing a model based on a knob you need (a thinking budget, a seed, `top_k`). +- Building a model picker, settings UI, eval harness, gateway, or router. +- Reviewing a diff that hardcodes parameters across multiple models. + +## Pick an access path + +| Situation | Use | +| ------------------------------------ | ---------------------------------------------------- | +| An MCP server is available to you | The `modelparams` MCP tools — no network, no parsing | +| Any agent or shell, one-off question | The HTTP API (below) | +| You're writing TypeScript that ships | The `modelparams` npm package — compile-time safety | + +### MCP tools + +If the `modelparams` MCP server is connected, prefer these — they need no network access: + +- `validate_model_params` — the one to reach for. Give it a model and a params + object; it returns what's wrong and a corrected `safeParams` payload. +- `get_model_params` — every parameter for one model, with types, ranges, + defaults, and conditional rules. +- `list_models` — find the exact catalog id, filtered by provider or substring. +- `find_models_supporting` — which models expose a given parameter. + +Not connected? Install it: + +```bash +npx -y modelparams-mcp # stdio server; add to your MCP client config +``` + +### HTTP API + +CORS-enabled, no key, no rate limit. + +```bash +# Validate a request before you send it — the highest-value call +curl -s https://modelparams.dev/api/v1/validate \ + -H 'Content-Type: application/json' \ + -d '{"model":"claude-3-opus-20240229","params":{"temperature":0.5,"top_p":0.9}}' +``` + +```jsonc +{ + "model": "anthropic/claude-3-opus-20240229", + "valid": false, + "issues": [ + { + "path": "top_p", + "code": "not_applicable", // or unknown_parameter | invalid_value + "message": "top_p does not apply when temperature ≠ 1", + "conflictsWith": ["temperature"], + }, + ], + "safeParams": { "temperature": 0.5 }, // always safe to send as-is +} +``` + +Other endpoints: + +```bash +curl https://modelparams.dev/api/v1/params/gpt-5.5.json # one model's params +curl https://modelparams.dev/api/v1/models/anthropic/claude-opus-4-7.json +curl https://modelparams.dev/api/v1/models.json # full catalog +curl https://modelparams.dev/api/v1/index.json # endpoint map + live count +``` + +Ids are `provider/model`; subscription contracts append `-subscription`. The +validate endpoint also accepts a bare slug when only one provider publishes it. + +### npm package + +```bash +npm i modelparams +``` + +```ts +import { dropUnsupported, parseParams, type ParamsOf } from "modelparams"; + +// Compile-time: passing a parameter the model doesn't have won't build. +const params: ParamsOf<"openai/gpt-4.1"> = { max_tokens: 1024, temperature: 0.7 }; + +// Runtime, for untrusted input — enforces conflicts too. +const result = parseParams("openai/gpt-4.1", req.body.params); +if (!result.success) return res.status(422).json({ issues: result.issues }); + +// Or strip whatever won't fly and proceed (like LiteLLM's drop_params, +// extended to conditional conflicts). +const { params: safe, dropped } = dropUnsupported("openai/gpt-5.5", userParams); +``` + +## Workflow: writing code that calls an LLM + +1. Resolve the exact catalog id (`list_models`, or `/api/v1/models.json`). +2. Fetch the parameter list for that model — do not assume it matches a sibling + model or an earlier version. +3. Validate the params object you intend to send. +4. If invalid, use `safeParams` or fix the call. Do not silently drop the + parameter without telling the user which one went and why. + +## Workflow: debugging a provider 400 + +1. Extract the model id and the params object from the failing call. +2. Run `validate_model_params` (or POST to `/api/v1/validate`). +3. Match the `code`: + - `unknown_parameter` — the model has no such knob. Remove it. + - `invalid_value` — right knob, out of range or not in the enum. + - `not_applicable` — the knob exists but conflicts with another value in the + same request. Check `conflictsWith`; change one or drop the other. +4. If validation passes, the problem is not parameter shape — look at auth, + the endpoint, or the message payload instead. + +## Conditional rules + +The catalog's distinguishing data is _applicability_: when a parameter is +accepted, expressed in terms of the others in the same request. + +- `appliesOnlyWhen: { only: {...} }` — accepted only while that condition holds. +- `appliesOnlyWhen: { except: {...} }` — rejected while that condition holds. + +A parameter you never set still counts, because the provider applies its +default. Setting `thinking.budget_tokens` without `thinking.type: "enabled"` is +a silent no-op, and validation reports it. + +## Gotchas worth checking explicitly + +- Reasoning models (GPT-5.x, o-series, Claude with thinking on) reject or ignore + `temperature` and `top_p`. This is the single most common source of 400s. +- Anthropic rejects `top_p` alongside a non-default `temperature`. +- OpenAI reasoning models take `max_completion_tokens`, not `max_tokens`. +- Google nests everything under `generationConfig.*`. +- Some providers accept an unsupported parameter and ignore it silently — no + error, just drifting evals. Validate rather than trusting a 200. +- API-key and subscription contracts can expose different parameters for the + same model. Check the `-subscription` variant if that's how you authenticate. + +## Contributing a correction + +The catalog is open and community-maintained. If a model is missing or a +parameter is wrong, the data is YAML at +`models/{provider}/{model}.yaml` in +[github.com/mnfst/modelparams.dev](https://github.com/mnfst/modelparams.dev) — +one file, one PR, CI validates it against the published JSON Schema. diff --git a/src/build/build.ts b/src/build/build.ts index ffb4ad5..88253f1 100644 --- a/src/build/build.ts +++ b/src/build/build.ts @@ -156,8 +156,10 @@ async function writeApiIndex(modelCount: number): Promise { modelByIdSubscription: "/api/v1/models/{provider}/{model}-subscription.json", paramsByModelApiKey: "/api/v1/params/{model}.json", paramsByModelSubscription: "/api/v1/params/{model}-subscription.json", + validate: "POST /api/v1/validate", }, modelCount, + mcp: "npx -y modelparams-mcp", docs: "https://github.com/mnfst/modelparams.dev#api", }; await writeJson(path.join(DIST_API_DIR, "index.json"), body); diff --git a/src/data/llms.ts b/src/data/llms.ts index 63cb02d..bd3ef9c 100644 --- a/src/data/llms.ts +++ b/src/data/llms.ts @@ -63,6 +63,35 @@ function guideApi(siteUrl: string): string[] { `curl ${siteUrl}/api/v1/models/anthropic/claude-opus-4-7-subscription.json`, "```", "", + "## Validate a request before you send it", + "", + "POST a model and a params object; the response reports unknown parameters, values", + "outside their range, and combinations the provider rejects — plus a corrected payload", + "under `safeParams` that is always safe to send as-is:", + "", + "```bash", + `curl -s ${siteUrl}/api/v1/validate \\`, + ` -H 'Content-Type: application/json' \\`, + ` -d '{"model":"claude-3-opus-20240229","params":{"temperature":0.5,"top_p":0.9}}'`, + "```", + "", + "Each issue carries a `code`: `unknown_parameter` (no such knob on this model),", + "`invalid_value` (out of range or not in the enum), or `not_applicable` (the knob", + "exists but conflicts with another value in the same request, listed in", + "`conflictsWith`).", + "", + "## MCP server", + "", + "Agents can query the catalog over the Model Context Protocol instead of HTTP. The", + "server runs on stdio and bundles the catalog, so it needs no network access:", + "", + "```bash", + "npx -y modelparams-mcp", + "```", + "", + "Tools: `validate_model_params`, `get_model_params`, `list_models`,", + "`find_models_supporting`.", + "", "## JSON Schema", "", "Every entry validates against a JSON Schema you can use in your editor or pipeline:", diff --git a/src/views/api.ejs b/src/views/api.ejs index 047b6ca..0f41f87 100644 --- a/src/views/api.ejs +++ b/src/views/api.ejs @@ -70,6 +70,68 @@ + +
+

Validate a request

+

+ POST a model and the parameters you intend to send. The response reports unknown parameters, values outside + their range, and combinations the provider rejects — plus a corrected payload you can send as-is. +

+
+
+ POST +
+
curl -s https://modelparams.dev/api/v1/validate \
+  -H 'Content-Type: application/json' \
+  -d '{"model":"claude-3-opus-20240229","params":{"temperature":0.5,"top_p":0.9}}'
+
+
+
{
+  "model": "anthropic/claude-3-opus-20240229",
+  "valid": false,
+  "issues": [
+    {
+      "path": "top_p",
+      "code": "not_applicable",
+      "message": "top_p does not apply when temperature ≠ 1",
+      "conflictsWith": ["temperature"]
+    }
+  ],
+  "safeParams": { "temperature": 0.5 }
+}
+
+

+ Every issue carries a + code: + unknown_parameter, + invalid_value, or + not_applicable for a conflict with another value in the same request. +

+
+ + +
+

MCP server

+

+ Let a coding agent check the catalog itself. The server speaks the Model Context Protocol over stdio and bundles + the catalog, so it works with no network access. +

+
+
npx -y modelparams-mcp
+
+

+ Tools: + validate_model_params, + get_model_params, + list_models, + find_models_supporting. +

+

+ Using a coding agent that supports skills? Install the companion skill with + npx skills add mnfst/modelparams.dev. +

+
+

JSON Schema

diff --git a/tests/validate-endpoint.test.ts b/tests/validate-endpoint.test.ts new file mode 100644 index 0000000..1d8bea7 --- /dev/null +++ b/tests/validate-endpoint.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from "vitest"; +import handler from "../api/v1/validate.js"; + +interface ValidateBody { + model: string; + provider: string; + authType: string; + valid: boolean; + issues: { path: string; code: string; message: string; conflictsWith?: string[] }[]; + safeParams: Record; + error?: string; + suggestions?: string[]; + matches?: string[]; +} + +async function post(body: unknown): Promise<{ status: number; body: ValidateBody }> { + const response = await handler.fetch( + new Request("https://modelparams.dev/api/v1/validate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: typeof body === "string" ? body : JSON.stringify(body), + }), + ); + return { status: response.status, body: (await response.json()) as ValidateBody }; +} + +describe("POST /api/v1/validate", () => { + it("accepts a valid params object", async () => { + const { status, body } = await post({ + model: "anthropic/claude-3-opus-20240229", + params: { max_tokens: 1024, temperature: 1 }, + }); + expect(status).toBe(200); + expect(body.valid).toBe(true); + expect(body.issues).toEqual([]); + expect(body.safeParams).toEqual({ max_tokens: 1024, temperature: 1 }); + }); + + it("reports a conditional conflict and returns a corrected payload", async () => { + const { status, body } = await post({ + model: "anthropic/claude-3-opus-20240229", + params: { temperature: 0.5, top_p: 0.9, max_tokens: 1024 }, + }); + expect(status).toBe(200); + expect(body.valid).toBe(false); + expect(body.issues).toHaveLength(1); + expect(body.issues[0]!.path).toBe("top_p"); + expect(body.issues[0]!.code).toBe("not_applicable"); + expect(body.issues[0]!.conflictsWith).toContain("temperature"); + expect(body.safeParams).toEqual({ temperature: 0.5, max_tokens: 1024 }); + }); + + it("reports an unknown parameter", async () => { + const { body } = await post({ + model: "openai/gpt-5.5", + params: { temperature: 0.7 }, + }); + expect(body.valid).toBe(false); + expect(body.issues[0]!).toMatchObject({ path: "temperature", code: "unknown_parameter" }); + expect(body.safeParams).toEqual({}); + }); + + it("reports an out-of-range value", async () => { + const { body } = await post({ + model: "anthropic/claude-3-opus-20240229", + params: { temperature: 42 }, + }); + expect(body.valid).toBe(false); + expect(body.issues[0]!).toMatchObject({ path: "temperature", code: "invalid_value" }); + }); + + it("resolves a bare model slug", async () => { + const { status, body } = await post({ model: "gpt-5.5", params: {} }); + expect(status).toBe(200); + expect(body.model).toBe("openai/gpt-5.5"); + expect(body.provider).toBe("openai"); + }); + + it("treats a missing params object as an empty request", async () => { + const { status, body } = await post({ model: "openai/gpt-5.5" }); + expect(status).toBe(200); + expect(body.valid).toBe(true); + expect(body.safeParams).toEqual({}); + }); + + it("404s an unknown model with suggestions", async () => { + const { status, body } = await post({ model: "gpt-5.5-turbo-ultra", params: {} }); + expect(status).toBe(404); + expect(body.error).toBe("unknown_model"); + expect(Array.isArray(body.suggestions)).toBe(true); + }); + + it("400s a malformed body", async () => { + const { status, body } = await post("{not json"); + expect(status).toBe(400); + expect(body.error).toBe("invalid_request_body"); + }); + + it("400s a missing model", async () => { + const { status, body } = await post({ params: { temperature: 1 } }); + expect(status).toBe(400); + expect(body.error).toBe("invalid_request_body"); + }); + + it("400s a non-object params", async () => { + const { status } = await post({ model: "openai/gpt-5.5", params: [1, 2] }); + expect(status).toBe(400); + }); + + it("never caches a verdict at the edge", async () => { + const response = await handler.fetch( + new Request("https://modelparams.dev/api/v1/validate", { + method: "POST", + body: JSON.stringify({ model: "openai/gpt-5.5", params: {} }), + }), + ); + expect(response.headers.get("Cache-Control")).toBe("no-store"); + }); +}); + +describe("other methods on /api/v1/validate", () => { + it("answers GET with the endpoint contract", async () => { + const response = await handler.fetch( + new Request("https://modelparams.dev/api/v1/validate", { method: "GET" }), + ); + expect(response.status).toBe(200); + expect(((await response.json()) as { endpoint: string }).endpoint).toBe( + "POST /api/v1/validate", + ); + }); + + it("answers a CORS preflight", async () => { + const response = await handler.fetch( + new Request("https://modelparams.dev/api/v1/validate", { method: "OPTIONS" }), + ); + expect(response.status).toBe(204); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*"); + expect(response.headers.get("Access-Control-Allow-Methods")).toContain("POST"); + }); + + it("405s anything else", async () => { + const response = await handler.fetch( + new Request("https://modelparams.dev/api/v1/validate", { method: "DELETE" }), + ); + expect(response.status).toBe(405); + }); +}); diff --git a/tsconfig.api.json b/tsconfig.api.json new file mode 100644 index 0000000..9679d27 --- /dev/null +++ b/tsconfig.api.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "noEmit": true + }, + "include": ["api/**/*.ts", "packages/modelparams/src/**/*.ts"] +} diff --git a/vercel.json b/vercel.json index c22c3bf..653f28c 100644 --- a/vercel.json +++ b/vercel.json @@ -18,6 +18,14 @@ "source": "/api/v1/(.*)", "headers": [{ "key": "X-Robots-Tag", "value": "noindex" }] }, + { + "source": "/api/v1/validate", + "headers": [ + { "key": "Cache-Control", "value": "no-store" }, + { "key": "Access-Control-Allow-Methods", "value": "POST, GET, OPTIONS" }, + { "key": "Access-Control-Allow-Headers", "value": "Content-Type" } + ] + }, { "source": "/(llms.txt|llms-full.txt)", "headers": [