diff --git a/AGENTS.md b/AGENTS.md index 008feb4..f17b645 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,6 +30,7 @@ Runtime: Node.js (ESM, `.mjs` throughout). No build step. No bundler. `server.mj - `server.mjs` — the proxy itself; every request path lives here. Governed by `ALIGNMENT.md`. - `models.json` — single source of truth for model IDs, aliases, and context windows. See ADR 0003. +- `models.schema.json` — the schema `models.json` declares in its `$schema`. CI validates the SPOT against it (`test-features.mjs`) using the repo's own `validateJsonSchema`, so a malformed entry fails the build instead of surfacing downstream in OpenClaw. - `setup.mjs` — first-time installer; reads `models.json` to derive bootstrap config. - `scripts/sync-openclaw.mjs` — idempotent OpenClaw registry sync invoked by `ocp update`. See ADR 0004. - `ocp` — user-facing CLI (install, update, start, stop, status, logs, etc.). diff --git a/README.md b/README.md index 3cc3d38..b79d648 100644 --- a/README.md +++ b/README.md @@ -177,7 +177,7 @@ Any tool use happens server-side, under the `--allowedTools` set configured on t | `claude-sonnet-4-6` | Previous Sonnet, retained for pinning | | `claude-haiku-4-5-20251001` | Fastest, lightweight (default for `haiku` alias) | -The canonical list lives in [`models.json`](./models.json) — the single source of truth as of v3.11.0. Both `server.mjs` (the `/v1/models` endpoint) and `setup.mjs` (the OpenClaw registration) derive from it. Adding a new model is now a one-file edit: +The canonical list lives in [`models.json`](./models.json) — the single source of truth as of v3.11.0, validated in CI against [`models.schema.json`](./models.schema.json). Both `server.mjs` (the `/v1/models` endpoint) and `setup.mjs` (the OpenClaw registration) derive from it. Adding a new model is now a one-file edit: ```bash # 1. Edit models.json — add an entry diff --git a/models.schema.json b/models.schema.json new file mode 100644 index 0000000..4b4e451 --- /dev/null +++ b/models.schema.json @@ -0,0 +1,65 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/dtzp555-max/ocp/blob/main/models.schema.json", + "title": "OCP models.json", + "description": "Schema for models.json, the single source of truth for model metadata (ADR 0003). Enforced in CI by test-features.mjs, which validates models.json against this file using the repo's own validateJsonSchema (lib/structured-output.mjs) — no new dependency. ⚠ ONLY A SUBSET OF DRAFT 2020-12 IS ENFORCED: type, required, const, enum, additionalProperties (boolean and schema forms), items, minItems, maxItems, anyOf/allOf/oneOf, $ref, nullable. Anything else — minimum, maxLength, pattern, uniqueItems, propertyNames, not — is SILENTLY IGNORED by that validator, so adding one here buys nothing and misleads the next reader. Add such a constraint as a test in test-features.mjs instead. NOTE: referential integrity (every aliases/legacyAliases target must exist as a models[].id) is likewise not expressible here and is covered by separate tests.", + "type": "object", + "required": ["version", "models", "aliases", "legacyAliases"], + "additionalProperties": false, + "properties": { + "$schema": { + "type": "string", + "description": "Relative path to this file. Editors use it for completion; CI does not read it." + }, + "version": { + "const": 1, + "description": "Schema version of this document. Bump only alongside a shape change and an ADR." + }, + "models": { + "type": "array", + "minItems": 1, + "description": "Every model OCP advertises on /v1/models, newest first. Order is load-bearing: ocp-connect uses model_ids[0] as a primary fallback.", + "items": { + "type": "object", + "required": ["id", "displayName", "openclawName", "reasoning", "contextWindow", "maxTokens"], + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "description": "Canonical CLI model id, passed verbatim to `claude --model`. Must match the id in the compiled CLI registry." + }, + "displayName": { + "type": "string", + "description": "Human label. Also used as the OpenClaw agent alias by scripts/sync-openclaw.mjs." + }, + "openclawName": { + "type": "string", + "description": "Label written into OpenClaw's model registry." + }, + "reasoning": { + "type": "boolean", + "description": "Whether OpenClaw should treat the model as reasoning-capable." + }, + "contextWindow": { + "type": "integer", + "description": "Advertised context window. GLOBAL side effect: MAX_PROMPT_CHARS derives from max(contextWindow) x 3 across ALL entries (lib/prompt.mjs derivePromptCharBudget), so raising this on ONE model raises the truncation ceiling for EVERY model. See ADR 0009. Also feeds OpenClaw's compaction budget." + }, + "maxTokens": { + "type": "integer", + "description": "Advertised output cap. OCP does not enforce it; it is propagated to OpenClaw (via setup.mjs / scripts/sync-openclaw.mjs) where it bounds request and compaction budgets." + } + } + } + }, + "aliases": { + "type": "object", + "description": "Short name -> canonical models[].id. Client-addressable, so a repoint changes routing for every request using the alias. Cache keys resolve these before hashing (server.mjs cacheModel).", + "additionalProperties": { "type": "string" } + }, + "legacyAliases": { + "type": "object", + "description": "Retired ids kept resolvable for backward compatibility -> canonical models[].id. Also client-addressable and also resolved in cache keys.", + "additionalProperties": { "type": "string" } + } + } +} diff --git a/test-features.mjs b/test-features.mjs index c157ffd..57c00f8 100644 --- a/test-features.mjs +++ b/test-features.mjs @@ -4305,6 +4305,77 @@ test("models.json: every legacyAliases value resolves to a real models[].id (ref } }); +// ── models.json validates against models.schema.json (#196) ───────────────── +// models.json carried `"$schema": "./models.schema.json"` while that file had never been +// committed, so the SPOT that ADR 0003 makes canonical had no structural validation at all — +// a missing contextWindow or a typo'd openclawName would only surface downstream, in OpenClaw +// or in a truncation budget. Validated with the repo's OWN validator (lib/structured-output.mjs, +// shipped for #153) rather than a new dependency: zero deps added, and it exercises that +// validator on a second real input. +// +// The schema deliberately does NOT try to express referential integrity (alias -> models[].id); +// that is not a JSON Schema concept and is covered by the two tests directly above. +import { validateJsonSchema as _spotValidate } from "./lib/structured-output.mjs"; + +const _spotSchema = JSON.parse(spotReadFileSync(spotJoin(_spotDir, "models.schema.json"), "utf8")); + +test("models.json: the $schema reference resolves to a committed file", () => { + assert.equal(_spotModels.$schema, "./models.schema.json", "models.json must point at the schema"); + assert.ok(_ltExists(spotJoin(_spotDir, "models.schema.json")), + "models.schema.json must exist — a dangling $schema is what #196 was filed for"); +}); + +test("models.json validates against models.schema.json (strict)", () => { + const errors = _spotValidate(_spotModels, _spotSchema, "$", true); + assert.deepEqual(errors, [], `models.json violates its own schema:\n ${errors.join("\n ")}`); +}); + +// Three corruptions the SCHEMA structurally cannot catch, asserted directly instead of pretending +// the schema covers them: the validator has no uniqueItems, no minLength, and no minimum. Adding +// those keywords to the schema would be silently ignored (see its description), so they live here. +test("models.json: ids/names are unique, untrimmed-free, and windows are positive (not schema-expressible)", () => { + // Uniqueness applies to all three name fields, not just id. scripts/sync-openclaw.mjs maps + // `claude-local/` -> { alias: displayName } and writes openclawName as the registry label, + // so a duplicate in EITHER collapses two models onto one OpenClaw entry — the same defect class + // as a duplicate id, which is why review flagged covering only id as a half-fix. + for (const f of ["id", "displayName", "openclawName"]) { + const vals = _spotModels.models.map(m => m[f]); + const dupes = vals.filter((v, i) => vals.indexOf(v) !== i); + assert.equal(new Set(vals).size, vals.length, `duplicate models[].${f}: ${[...new Set(dupes)]}`); + } + for (const m of _spotModels.models) { + for (const f of ["id", "displayName", "openclawName"]) { + assert.ok(typeof m[f] === "string" && m[f].length > 0, `${m.id}: ${f} must be a non-empty string`); + // === trim(), not trim().length: a padded id passes a trimmed check but is handed VERBATIM + // to `claude --model`, so " claude-opus-5" would fail upstream rather than here. + assert.equal(m[f], m[f].trim(), `${m.id}: ${f} has leading/trailing whitespace`); + } + assert.ok(m.contextWindow > 0, `${m.id}: contextWindow must be positive`); + assert.ok(m.maxTokens > 0, `${m.id}: maxTokens must be positive`); + } +}); + +// Guards the guard: if the schema were vacuous (e.g. `{}` or a typo'd `properties`), the test +// above would pass on anything. Each corruption below must be caught. +test("models.schema.json actually rejects malformed entries (guard is not vacuous)", () => { + const clone = () => JSON.parse(JSON.stringify(_spotModels)); + const cases = [ + ["missing required field", m => { delete m.models[0].contextWindow; }], + ["wrong scalar type", m => { m.models[0].reasoning = "yes"; }], + ["non-integer window", m => { m.models[0].contextWindow = 200000.5; }], + ["unknown extra field", m => { m.models[0].tokensPerSecond = 42; }], + ["alias mapped to non-string", m => { m.aliases.opus = { id: "x" }; }], + ["empty models array", m => { m.models = []; }], + ["wrong document version", m => { m.version = 2; }], + ["unknown top-level key", m => { m.providers = {}; }], + ]; + for (const [label, corrupt] of cases) { + const bad = clone(); corrupt(bad); + const errs = _spotValidate(bad, _spotSchema, "$", true); + assert.ok(errs.length > 0, `schema failed to reject: ${label}`); + } +}); + // ── escapeHtml + key-name validator (issue #114) ──────────────────────────── // Replicated verbatim from dashboard.html so tests run without a browser. function escapeHtml(s) {