diff --git a/packages/modelparams/README.md b/packages/modelparams/README.md index 78a299c..3e8d41c 100644 --- a/packages/modelparams/README.md +++ b/packages/modelparams/README.md @@ -72,18 +72,43 @@ if (thinking?.type === "enum") { } ``` +### Validate untrusted params at runtime + +`ParamsOf` is compile-time only — it can't help against a JSON request body. `parseParams` validates an untrusted object against the catalog (unknown keys, numeric ranges, enum values): + +```ts +import { parseParams } from "modelparams"; + +app.post("/chat", (req, res) => { + const result = parseParams("openai/gpt-4.1", req.body.params); + if (!result.success) return res.status(422).json({ issues: result.issues }); + openai.chat.completions.create({ model: "gpt-4.1", messages, ...result.value }); +}); +``` + +Prefer a schema? `paramsSchema(id)` returns a [Standard Schema](https://standardschema.dev), so it drops into tRPC, Hono, TanStack Form, and anything else that speaks the spec: + +```ts +import { paramsSchema } from "modelparams"; + +app.post("/chat", validator("json", paramsSchema("openai/gpt-4.1")), handler); +``` + ## API ### Types -| Type | Description | -| -------------------- | ------------------------------------------------------------------------- | -| `ParamsOf` | Optional parameters for model `Id`. The headline type. | -| `StrictParamsOf` | Same shape, every field required. | -| `ModelId` | Union of all `"provider/model"` ids (including `-subscription` variants). | -| `Provider` | Union of provider slugs (`"anthropic"`, `"openai"`, …). | -| `ParamsById` | Mapped type: `{ [Id in ModelId]: ParamsByIdMap[Id] }`. | -| `CatalogEntry` | The full catalog object for one model. | +| Type | Description | +| -------------------- | --------------------------------------------------------------------------------------------------------------------- | +| `ParamsOf` | Optional parameters for model `Id`. The headline type. | +| `StrictParamsOf` | Same shape, every field required. | +| `ModelId` | Union of all `"provider/model"` ids (including `-subscription` variants). | +| `Provider` | Union of provider slugs (`"anthropic"`, `"openai"`, …). | +| `ParamsById` | Mapped type: `{ [Id in ModelId]: ParamsByIdMap[Id] }`. | +| `CatalogEntry` | The full catalog object for one model. | +| `Param` | A parameter definition in a loose, iterable shape — `getModel(id).params` assigns to `readonly Param[]` with no cast. | +| `ParseParamsResult` | The discriminated result of `parseParams`. | +| `StandardSchemaV1` | The [Standard Schema](https://standardschema.dev) interface `paramsSchema` returns. | ### Functions @@ -94,6 +119,8 @@ if (thinking?.type === "enum") { | `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`. | ### Constants diff --git a/packages/modelparams/src/index.ts b/packages/modelparams/src/index.ts index 7cfae9b..f25a433 100644 --- a/packages/modelparams/src/index.ts +++ b/packages/modelparams/src/index.ts @@ -1,10 +1,25 @@ -export type { ParamsOf, StrictParamsOf } from "./types.js"; +export type { + ParamsOf, + StrictParamsOf, + Param, + ParamType, + ParamGroup, + ParamRange, + JsonPrimitive, +} from "./types.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"; +export type { ParamIssue, ParseParamsResult } from "./parse.js"; +export type { + StandardSchemaV1, + StandardSchemaResult, + StandardSchemaIssue, +} from "./standard-schema.js"; export { MODEL_IDS, PROVIDERS } from "./generated/model-ids.js"; export { DEFAULTS } from "./generated/defaults.js"; export { CATALOG, BY_ID } from "./generated/data.js"; export { getModel, getDefaults, listModels, getParam, listAllModels } from "./helpers.js"; +export { parseParams, paramsSchema } from "./parse.js"; diff --git a/packages/modelparams/src/parse.ts b/packages/modelparams/src/parse.ts new file mode 100644 index 0000000..854bccb --- /dev/null +++ b/packages/modelparams/src/parse.ts @@ -0,0 +1,104 @@ +import type { ModelId } from "./generated/model-ids.js"; +import { getModel } from "./helpers.js"; +import type { JsonPrimitive, Param } from "./types.js"; +import type { StandardSchemaV1 } from "./standard-schema.js"; + +/** A single problem found while validating a params object. */ +export interface ParamIssue { + readonly message: string; + /** Location of the problem: `[]` for the whole object, `[key]` for one param. */ + readonly path: readonly PropertyKey[]; +} + +/** The result of {@link parseParams}: validated params, or the issues found. */ +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. + * + * @example + * const result = parseParams("openai/gpt-4.1", req.body.params); + * if (!result.success) return res.status(422).json({ issues: result.issues }); + * await openai.chat.completions.create({ model: "gpt-4.1", messages, ...result.value }); + */ +export function parseParams(id: ModelId, input: unknown): ParseParamsResult { + if (typeof input !== "object" || input === null || Array.isArray(input)) { + return { success: false, issues: [{ message: "params must be an object", path: [] }] }; + } + + const defs = new Map(); + for (const param of getModel(id).params) defs.set(param.path, param); + + const issues: ParamIssue[] = []; + const value: Record = {}; + + for (const [key, raw] of Object.entries(input)) { + const def = defs.get(key); + if (!def) { + const allowed = [...defs.keys()].join(", ") || "(none)"; + issues.push({ message: `unknown parameter for ${id}; allowed: ${allowed}`, path: [key] }); + continue; + } + const problem = checkValue(def, raw); + if (problem) { + issues.push({ message: `"${key}" ${problem}`, path: [key] }); + continue; + } + value[key] = raw as JsonPrimitive; + } + + return issues.length > 0 ? { success: false, issues } : { success: true, value }; +} + +/** + * A Standard Schema (https://standardschema.dev) that validates a params object + * for `id`. Plugs into any Standard-Schema-aware library (tRPC, Hono, …). + * + * @example + * import { paramsSchema } from "modelparams"; + * app.post("/chat", validator("json", paramsSchema("openai/gpt-4.1")), handler); + */ +export function paramsSchema( + id: ModelId, +): StandardSchemaV1> { + return { + "~standard": { + version: 1, + vendor: "modelparams", + validate: (input) => { + const result = parseParams(id, input); + return result.success ? { value: result.value } : { issues: result.issues }; + }, + }, + }; +} diff --git a/packages/modelparams/src/standard-schema.ts b/packages/modelparams/src/standard-schema.ts new file mode 100644 index 0000000..42cee21 --- /dev/null +++ b/packages/modelparams/src/standard-schema.ts @@ -0,0 +1,45 @@ +/** + * The Standard Schema v1 contract (https://standardschema.dev), vendored as + * plain types so `paramsSchema(id)` plugs into any Standard-Schema-aware library + * (tRPC, Hono, TanStack Form, …) with zero runtime dependencies. + * + * The contract is structural: a value is a valid Standard Schema as long as its + * `~standard` property has `{ version: 1, vendor, validate }`. + */ +export interface StandardSchemaV1 { + readonly "~standard": StandardSchemaProps; +} + +export interface StandardSchemaProps { + readonly version: 1; + readonly vendor: string; + readonly validate: ( + value: unknown, + ) => StandardSchemaResult | Promise>; + readonly types?: StandardSchemaTypes; +} + +export type StandardSchemaResult = StandardSchemaSuccess | StandardSchemaFailure; + +export interface StandardSchemaSuccess { + readonly value: Output; + readonly issues?: undefined; +} + +export interface StandardSchemaFailure { + readonly issues: readonly StandardSchemaIssue[]; +} + +export interface StandardSchemaIssue { + readonly message: string; + readonly path?: readonly (PropertyKey | StandardSchemaPathSegment)[] | undefined; +} + +export interface StandardSchemaPathSegment { + readonly key: PropertyKey; +} + +export interface StandardSchemaTypes { + readonly input: Input; + readonly output: Output; +} diff --git a/packages/modelparams/src/types.ts b/packages/modelparams/src/types.ts index 19569ee..e856285 100644 --- a/packages/modelparams/src/types.ts +++ b/packages/modelparams/src/types.ts @@ -23,3 +23,52 @@ export type ParamsOf = Partial; * through the type system. */ export type StrictParamsOf = ParamsById[Id]; + +/** A JSON-primitive parameter value. */ +export type JsonPrimitive = string | number | boolean | null; + +/** The kind of a parameter's value. */ +export type ParamType = "boolean" | "enum" | "integer" | "number" | "string"; + +/** The semantic group a parameter belongs to (drives grouped settings UIs). */ +export type ParamGroup = + | "generation_length" + | "sampling" + | "reasoning" + | "tooling" + | "output_format" + | "observability" + | "provider_metadata"; + +/** Numeric bounds for an `integer` / `number` parameter. */ +export interface ParamRange { + readonly min?: number; + readonly max?: number; + readonly step?: number; +} + +/** + * A single parameter definition in a loose, easy-to-iterate shape — the runtime + * counterpart to the precise per-model `ParamsOf` types. + * + * The precise `getModel(id).params` / `getParam(...)` values assign to `Param` + * without a cast, so you can annotate a loop variable as `Param` and read + * `range` / `values` uniformly instead of narrowing a deep `as const` union. + * + * @example + * import { getModel, type Param } from "modelparams"; + * const params: readonly Param[] = getModel("openai/gpt-4.1").params; + * for (const p of params) renderControl(p.path, p.type, p.range, p.values); + */ +export interface Param { + readonly path: string; + readonly label: string; + readonly description: string; + readonly group: ParamGroup; + readonly type: ParamType; + readonly default?: JsonPrimitive; + /** Present on `integer` / `number` params. */ + readonly range?: ParamRange; + /** Present on `enum` params. */ + readonly values?: readonly JsonPrimitive[]; +} diff --git a/packages/modelparams/test-d/types.test-d.ts b/packages/modelparams/test-d/types.test-d.ts index de81786..ad6d6b0 100644 --- a/packages/modelparams/test-d/types.test-d.ts +++ b/packages/modelparams/test-d/types.test-d.ts @@ -1,5 +1,12 @@ import { expectAssignable, expectError, expectType } from "tsd"; -import type { ParamsOf } from "../dist/index.js"; +import { getModel, parseParams, paramsSchema } from "../dist/index.js"; +import type { + JsonPrimitive, + Param, + ParamsOf, + ParseParamsResult, + StandardSchemaV1, +} from "../dist/index.js"; type Haiku = ParamsOf<"anthropic/claude-haiku-4-5-20251001">; @@ -24,3 +31,15 @@ expectError({ max_tokens: "lots" }); // All keys are optional (we use Partial) const empty: Haiku = {}; expectType(empty); + +// The precise catalog params assign to the loose `Param` type with no cast. +expectAssignable(getModel("openai/gpt-4.1").params); + +// parseParams returns the discriminated result and rejects unknown model ids. +expectType(parseParams("openai/gpt-4.1", {})); +expectError(parseParams("openai/not-a-real-model", {})); + +// paramsSchema is a Standard Schema over a validated params record. +expectAssignable>>( + paramsSchema("openai/gpt-4.1"), +); diff --git a/packages/modelparams/tests/parse.test.ts b/packages/modelparams/tests/parse.test.ts new file mode 100644 index 0000000..81705ce --- /dev/null +++ b/packages/modelparams/tests/parse.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; +import { parseParams, paramsSchema } from "../src/index.js"; + +const GPT = "openai/gpt-4.1" as const; // max_tokens (int, min 1), temperature (0..2), top_p (0..1) +const HAIKU = "anthropic/claude-haiku-4-5-20251001" as const; // has enum "thinking.type" + +describe("parseParams", () => { + it("accepts valid params and echoes them back", () => { + const r = parseParams(GPT, { temperature: 0.5, max_tokens: 100 }); + expect(r).toEqual({ success: true, value: { temperature: 0.5, max_tokens: 100 } }); + }); + + it("accepts an empty object", () => { + expect(parseParams(GPT, {})).toEqual({ success: true, value: {} }); + }); + + it("rejects an unknown parameter and lists the allowed ones", () => { + const r = parseParams(GPT, { frequency_penalty: 0.5 }); + expect(r.success).toBe(false); + if (!r.success) { + expect(r.issues).toHaveLength(1); + expect(r.issues[0]?.path).toEqual(["frequency_penalty"]); + expect(r.issues[0]?.message).toContain("unknown parameter"); + expect(r.issues[0]?.message).toContain("temperature"); + } + }); + + it("enforces numeric ranges", () => { + const hi = parseParams(GPT, { temperature: 5 }); + expect(hi.success).toBe(false); + if (!hi.success) expect(hi.issues[0]?.message).toContain("<= 2"); + + const lo = parseParams(GPT, { temperature: -1 }); + expect(lo.success).toBe(false); + if (!lo.success) expect(lo.issues[0]?.message).toContain(">= 0"); + }); + + it("requires integers for integer params", () => { + const r = parseParams(GPT, { max_tokens: 1.5 }); + expect(r.success).toBe(false); + if (!r.success) expect(r.issues[0]?.message).toContain("integer"); + }); + + it("rejects the wrong primitive type", () => { + const r = parseParams(GPT, { temperature: "warm" }); + expect(r.success).toBe(false); + if (!r.success) expect(r.issues[0]?.message).toContain("number"); + }); + + it("validates enum membership", () => { + expect(parseParams(HAIKU, { "thinking.type": "enabled" }).success).toBe(true); + const bad = parseParams(HAIKU, { "thinking.type": "off" }); + expect(bad.success).toBe(false); + if (!bad.success) expect(bad.issues[0]?.message).toContain("must be one of"); + }); + + it("collects every issue, not just the first", () => { + const r = parseParams(GPT, { temperature: 5, nope: 1 }); + expect(r.success).toBe(false); + if (!r.success) expect(r.issues).toHaveLength(2); + }); + + it("rejects non-object input", () => { + for (const bad of [null, 42, "x", [1, 2]]) { + const r = parseParams(GPT, bad); + expect(r.success).toBe(false); + if (!r.success) expect(r.issues[0]?.path).toEqual([]); + } + }); +}); + +describe("paramsSchema (Standard Schema)", () => { + it("exposes the v1 contract", () => { + const schema = paramsSchema(GPT); + expect(schema["~standard"].version).toBe(1); + expect(schema["~standard"].vendor).toBe("modelparams"); + expect(typeof schema["~standard"].validate).toBe("function"); + }); + + it("validate() returns { value } on success", () => { + const out = paramsSchema(GPT)["~standard"].validate({ temperature: 0.2 }); + expect(out).toEqual({ value: { temperature: 0.2 } }); + }); + + it("validate() returns { issues } on failure", () => { + const out = paramsSchema(GPT)["~standard"].validate({ temperature: 99 }); + expect(out).toHaveProperty("issues"); + }); +});