feat(models): commit models.schema.json and validate the SPOT against it in CI (#196) (#205)

* feat(models): commit models.schema.json and validate the SPOT against it in CI (#196)

models.json has declared `"$schema": "./models.schema.json"` since v3.11.0, but
that file was never committed — `git log --all -- models.schema.json` is empty.
So the SPOT that ADR 0003 makes canonical had NO structural validation: a
missing contextWindow, a typo'd openclawName, or a boolean written as a string
would pass every existing test and only surface downstream — in OpenClaw's
registry, or silently inside a truncation budget.

Validated with the repo's OWN validator (lib/structured-output.mjs, shipped for
#153) rather than adding ajv. AGENTS.md requires minimal dependencies and the
repo currently has ZERO; this keeps it that way and exercises that validator on
a second real input. Capability-probed first rather than assumed: it enforces
type / required / enum / const / additionalProperties (both the boolean and the
schema form) / items / minItems, which covers everything the SPOT needs.

Three tests:
  - the $schema reference resolves to a committed file (the #196 bug itself)
  - models.json validates against models.schema.json, strict
  - the schema actually REJECTS 8 corruption shapes — missing required field,
    wrong scalar type, non-integer window, unknown extra field, alias mapped to
    a non-string, empty models array, wrong document version, unknown
    top-level key

That third test is the guard-on-the-guard: without it a vacuous schema (`{}` or
a typo'd `properties`) would make the second test pass on anything.

Mutation-proven in three directions:
  schema replaced with {}              -> "schema failed to reject: missing required field"
  additionalProperties:false removed   -> guard fails (loosened constraint caught)
  models.json corrupted (drop a field) -> strict validation fails

What the schema deliberately does NOT encode: referential integrity — that
every aliases/legacyAliases target exists as a models[].id. That is not a JSON
Schema concept; it is already covered by two dedicated tests, and the schema
says so in its description so the next reader doesn't assume it is covered.

The field descriptions carry the non-obvious consequences that have bitten this
repo before: contextWindow is a GLOBAL lever (max across all entries x 3 sets
MAX_PROMPT_CHARS for every model, ADR 0009), maxTokens is advertising only and
is enforced by OpenClaw rather than OCP, and models[] order is load-bearing
because ocp-connect uses model_ids[0] as a fallback.

Documented per release_kit (new file/SPOT/schema -> contributor section):
AGENTS.md "Key files to know" and README's Available Models section.

Tests: 460 passed, 0 failed (457 + 3).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gbqUZ8HfBZpjjbzQ85oH8

* fix(schema): warn that only a subset of draft 2020-12 is enforced; cover the gaps it cannot

Both review findings taken.

MED — the schema declared `$schema: draft/2020-12`, which promises full draft
semantics, while the validator that actually runs it enforces only a subset.
Someone adding `"minimum": 1` to contextWindow would get SILENT no-op with a
green suite — a trap made worse by the file looking authoritative. The
description now enumerates exactly what is enforced (type, required, const,
enum, additionalProperties in both forms, items, minItems/maxItems,
anyOf/allOf/oneOf, $ref, nullable) and names what is ignored (minimum,
maxLength, pattern, uniqueItems, propertyNames, not), with the instruction to
add such a constraint as a test instead.

LOW — three corruptions neither the schema nor any sibling test caught:
duplicate models[].id, empty-string id/displayName/openclawName, and
non-positive contextWindow/maxTokens. They are structurally NOT expressible
with the supported keyword set (no uniqueItems, no minLength, no minimum), so
they are asserted directly rather than by pretending the schema covers them.

Mutation-proven, all three previously slipped through:
  duplicate id       -> CAUGHT
  empty displayName  -> CAUGHT
  zero contextWindow -> CAUGHT

Tests: 461 passed, 0 failed (460 + 1).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gbqUZ8HfBZpjjbzQ85oH8

* test(schema): close the fourth gap — uniqueness on all three name fields, not just id

Review approved the delta and named this exact follow-up as non-blocking:
"extend the same test to assert uniqueness of displayName/openclawName and use
m[f] === m[f].trim()". Implemented as specified.

Why it matters rather than being tidiness: scripts/sync-openclaw.mjs maps
`claude-local/<id>` -> { alias: displayName } and writes openclawName as the
registry label, so a duplicate in EITHER field collapses two models onto one
OpenClaw entry — the same defect class as a duplicate id, which the previous
version covered while leaving its two siblings open.

The trim check also changed shape. It was `m[f].trim().length > 0`, which a
PADDED id passes by construction — and that id is handed VERBATIM to
`claude --model`, so " claude-opus-5" would have failed upstream instead of
here. Now asserts `m[f] === m[f].trim()`.

Mutation-proven, all three previously slipped through:
  duplicate displayName  -> CAUGHT
  duplicate openclawName -> CAUGHT
  whitespace-padded id   -> CAUGHT

Tests: 461 passed, 0 failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gbqUZ8HfBZpjjbzQ85oH8

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
dtzp555-max
2026-07-27 12:04:53 +10:00
committed by GitHub
co-authored by taodeng Claude Opus 5
parent a99395ed0c
commit f2f9058db4
4 changed files with 138 additions and 1 deletions
+1
View File
@@ -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.).
+1 -1
View File
@@ -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
+65
View File
@@ -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" }
}
}
}
+71
View File
@@ -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/<id>` -> { 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) {