feat(models): add Claude Sonnet 5 to models.json SPOT (#152)

* feat(models): add Claude Sonnet 5 to models.json SPOT

Adds `claude-sonnet-5` (the latest Sonnet, supported by claude CLI >= 2.1.206)
to models.json — the single source of truth (ADR 0003). Both the /v1/models
endpoint and setup.mjs OpenClaw registration derive from it automatically.

- New model entry `claude-sonnet-5` (reasoning, 200k ctx, 16k max tokens),
  mirroring the existing Sonnet entry shape.
- Point the `sonnet` alias at `claude-sonnet-5` (newest Sonnet), consistent
  with `opus` -> `claude-opus-4-8`. Previous `claude-sonnet-4-6` is retained
  for pinning.
- README "Available Models" table updated (release-kit 5.3).
- Update the aliases.sonnet SPOT test to the new default.

Endpoint class: B.1 (/v1/models), data-only via the models.json SPOT.
Authorized by ADR 0006 (OpenAI shim scope) + ADR 0003 (models.json SPOT).
Verified: `claude --model claude-sonnet-5 -p` returns a valid response on a
current subscription CLI (2.1.206); npm test green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(models): make PR #152 purely additive + close ocp-connect drift + real SPOT test

Addresses the maintainer's review. Rescopes this PR to the additive change only —
adding claude-sonnet-5 to models.json — and defers the `sonnet` alias repoint to
its own PR per Iron Rule 11 (the alias is the default for every request that omits
`model`; repointing it is a behavior change that deserves separate review + a
CHANGELOG entry). No server.mjs change, so no cli.js citation required.

Metadata confirmed unchanged: contextWindow 200000 / maxTokens 16384 stay, per the
maintainer's correction (OCP truncates at MAX_PROMPT_CHARS, and contextWindow feeds
OpenClaw's compaction budget — advertising a larger window than OCP delivers just
makes OpenClaw overshoot).

Fixes vs review:

1. Reverted `aliases.sonnet` back to claude-sonnet-4-6 — this PR only *adds* the
   model; the repoint ships separately. README updated to match (5 is available by
   full ID; 4-6 remains the alias default).

2. Replaced the tautological SPOT test. The old assertion read a literal out of
   models.json and asserted it equalled the same literal — it passed even with a
   dangling alias. Added referential-integrity tests: every aliases/legacyAliases
   value must resolve to a real models[].id, plus an explicit assertion that
   claude-sonnet-5 exists in models[]. This is the guard that actually catches an
   alias pointing at a non-existent model (VALID_MODELS keys on alias names, never
   targets, so nothing else checks this).

3. Fixed ocp-connect classification drift. Its prefix table pinned "claude-sonnet-4",
   which misses "claude-sonnet-5" and falls through to the non-reasoning / 8k-output
   default. Broadened both the model_meta and alias_prefixes tables to family
   prefixes (claude-opus / claude-sonnet / claude-haiku) so any future versioned ID
   classifies correctly with no per-model edit. /v1/models does not expose
   reasoning/maxTokens (OpenAI /v1/models schema has no such fields — adding them
   would be a Rule 2 invention), so family classification stays in ocp-connect.
   primary_model stays claude-sonnet-4-6, matching the (unchanged) sonnet alias — it
   moves with the alias in the repoint PR.

Tests: 266 passed, 0 failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: vvlasy-openclaw <vvlasy@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
openclaw.vvlasy.cz
2026-07-17 07:35:42 +10:00
committed by GitHub
co-authored by vvlasy-openclaw Claude Opus 4.8
parent d501e786b8
commit 27216646c8
4 changed files with 48 additions and 8 deletions
+1
View File
@@ -716,6 +716,7 @@ Any tool use happens server-side, under the `--allowedTools` set configured on t
| `claude-opus-4-8` | Most capable (default for `opus` alias) |
| `claude-opus-4-7` | Previous Opus, retained for pinning |
| `claude-opus-4-6` | Older Opus, retained for pinning |
| `claude-sonnet-5` | Latest Sonnet (available by full ID; `sonnet` alias repoint tracked separately) |
| `claude-sonnet-4-6` | Good balance of speed/quality (default for `sonnet` alias) |
| `claude-haiku-4-5-20251001` | Fastest, lightweight (default for `haiku` alias) |
+8
View File
@@ -26,6 +26,14 @@
"contextWindow": 200000,
"maxTokens": 16384
},
{
"id": "claude-sonnet-5",
"displayName": "Claude Sonnet 5",
"openclawName": "Claude Sonnet 5 (via CLI)",
"reasoning": true,
"contextWindow": 200000,
"maxTokens": 16384
},
{
"id": "claude-sonnet-4-6",
"displayName": "Claude Sonnet 4.6",
+14 -8
View File
@@ -122,11 +122,17 @@ provider = {
"models": []
}
# Model metadata mapping (prefix match for versioned IDs like claude-haiku-4-5-20251001)
# Model metadata mapping. Prefix match on the model FAMILY (claude-opus / -sonnet /
# -haiku), not a pinned version. A version-pinned prefix like "claude-sonnet-4"
# silently misses "claude-sonnet-5" and falls through to the non-reasoning /
# 8k-output default (PR #152 review) — every future Sonnet/Opus/Haiku bump would
# re-trip it. Family prefixes classify any versioned ID correctly with no per-model
# edit. (ADR 0003: models.json is the SPOT for model existence; /v1/models does not
# expose reasoning/maxTokens, so family classification stays here.)
model_meta = {
"claude-opus-4": {"name": "Claude Opus (OCP)", "reasoning": True, "maxTokens": 16384},
"claude-sonnet-4": {"name": "Claude Sonnet (OCP)", "reasoning": True, "maxTokens": 16384},
"claude-haiku-4": {"name": "Claude Haiku (OCP)", "reasoning": False, "maxTokens": 8192},
"claude-opus": {"name": "Claude Opus (OCP)", "reasoning": True, "maxTokens": 16384},
"claude-sonnet": {"name": "Claude Sonnet (OCP)", "reasoning": True, "maxTokens": 16384},
"claude-haiku": {"name": "Claude Haiku (OCP)", "reasoning": False, "maxTokens": 8192},
}
def get_model_meta(mid):
@@ -178,11 +184,11 @@ config.setdefault("agents", {})
config["agents"].setdefault("defaults", {})
config["agents"]["defaults"].setdefault("models", {})
# Build alias map (prefix match)
# Build alias map (family prefix match — version-agnostic, see model_meta note)
alias_prefixes = {
"claude-opus-4": "Claude Opus",
"claude-sonnet-4": "Claude Sonnet",
"claude-haiku-4": "Claude Haiku",
"claude-opus": "Claude Opus",
"claude-sonnet": "Claude Sonnet",
"claude-haiku": "Claude Haiku",
}
for mid in model_ids:
+25
View File
@@ -3326,6 +3326,31 @@ test("models.json aliases.sonnet === 'claude-sonnet-4-6' (default-request-model
assert.equal(_spotModels.aliases.sonnet, "claude-sonnet-4-6");
});
// ── Referential integrity (PR #152 review) ──────────────────────────────────
// The value-mirror assertions above only prove the alias equals a string literal —
// they pass even if that literal points at a model that does not exist in
// models[]. A one-line slip (edit an alias, forget the models[] entry) would leave
// /v1/models missing the model while every `model: "<alias>"` request passes
// validation and then fails at CLI spawn. VALID_MODELS keys on alias *names*, so
// nothing else checks alias *targets*. This is the guard with teeth.
const _spotModelIds = new Set(_spotModels.models.map(m => m.id));
test("models.json: claude-sonnet-5 is present in models[] (the entry this PR adds)", () => {
assert.ok(_spotModelIds.has("claude-sonnet-5"), "claude-sonnet-5 must exist as a models[].id");
});
test("models.json: every aliases value resolves to a real models[].id (referential integrity)", () => {
for (const [name, target] of Object.entries(_spotModels.aliases)) {
assert.ok(_spotModelIds.has(target), `aliases.${name} -> '${target}' is a dangling alias (no matching models[].id)`);
}
});
test("models.json: every legacyAliases value resolves to a real models[].id (referential integrity)", () => {
for (const [name, target] of Object.entries(_spotModels.legacyAliases || {})) {
assert.ok(_spotModelIds.has(target), `legacyAliases.${name} -> '${target}' is a dangling alias (no matching models[].id)`);
}
});
// ── escapeHtml + key-name validator (issue #114) ────────────────────────────
// Replicated verbatim from dashboard.html so tests run without a browser.
function escapeHtml(s) {