Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 35 additions & 8 deletions packages/modelparams/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,18 +72,43 @@ if (thinking?.type === "enum") {
}
```

### Validate untrusted params at runtime

`ParamsOf<Id>` 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<Id>` | Optional parameters for model `Id`. The headline type. |
| `StrictParamsOf<Id>` | 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<Id>` | Optional parameters for model `Id`. The headline type. |
| `StrictParamsOf<Id>` | 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

Expand All @@ -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

Expand Down
17 changes: 16 additions & 1 deletion packages/modelparams/src/index.ts
Original file line number Diff line number Diff line change
@@ -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";
104 changes: 104 additions & 0 deletions packages/modelparams/src/parse.ts
Original file line number Diff line number Diff line change
@@ -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<string, JsonPrimitive> }
| { 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<Id>`,
* 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<string, Param>();
for (const param of getModel(id).params) defs.set(param.path, param);

const issues: ParamIssue[] = [];
const value: Record<string, JsonPrimitive> = {};

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<unknown, Record<string, JsonPrimitive>> {
return {
"~standard": {
version: 1,
vendor: "modelparams",
validate: (input) => {
const result = parseParams(id, input);
return result.success ? { value: result.value } : { issues: result.issues };
},
},
};
}
45 changes: 45 additions & 0 deletions packages/modelparams/src/standard-schema.ts
Original file line number Diff line number Diff line change
@@ -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<Input = unknown, Output = Input> {
readonly "~standard": StandardSchemaProps<Input, Output>;
}

export interface StandardSchemaProps<Input = unknown, Output = Input> {
readonly version: 1;
readonly vendor: string;
readonly validate: (
value: unknown,
) => StandardSchemaResult<Output> | Promise<StandardSchemaResult<Output>>;
readonly types?: StandardSchemaTypes<Input, Output>;
}

export type StandardSchemaResult<Output> = StandardSchemaSuccess<Output> | StandardSchemaFailure;

export interface StandardSchemaSuccess<Output> {
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<Input = unknown, Output = Input> {
readonly input: Input;
readonly output: Output;
}
49 changes: 49 additions & 0 deletions packages/modelparams/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,52 @@ export type ParamsOf<Id extends ModelId> = Partial<ParamsById[Id]>;
* through the type system.
*/
export type StrictParamsOf<Id extends ModelId> = 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<Id>` 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[];
}
21 changes: 20 additions & 1 deletion packages/modelparams/test-d/types.test-d.ts
Original file line number Diff line number Diff line change
@@ -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">;

Expand All @@ -24,3 +31,15 @@ expectError<Haiku>({ max_tokens: "lots" });
// All keys are optional (we use Partial)
const empty: Haiku = {};
expectType<Haiku>(empty);

// The precise catalog params assign to the loose `Param` type with no cast.
expectAssignable<readonly Param[]>(getModel("openai/gpt-4.1").params);

// parseParams returns the discriminated result and rejects unknown model ids.
expectType<ParseParamsResult>(parseParams("openai/gpt-4.1", {}));
expectError(parseParams("openai/not-a-real-model", {}));

// paramsSchema is a Standard Schema over a validated params record.
expectAssignable<StandardSchemaV1<unknown, Record<string, JsonPrimitive>>>(
paramsSchema("openai/gpt-4.1"),
);
Loading
Loading