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..3db19e6 --- /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. NOTE: referential integrity (every aliases/legacyAliases target must exist as a models[].id) is 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..e4b0797 100644 --- a/test-features.mjs +++ b/test-features.mjs @@ -4305,6 +4305,52 @@ 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 ")}`); +}); + +// 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) {