diff --git a/CHANGELOG.md b/CHANGELOG.md index e47a51f..2efe66f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Changed + +- **`maxTokens` now matches the CLI registry instead of a uniform 16384 (#195).** Every Opus entry and `claude-sonnet-5` go to **64000**, `claude-sonnet-4-6` and `claude-haiku-4-5` to **32000** — the `max_output_tokens.default` each model declares in the compiled CLI 2.1.220 registry. This corrects **advertised metadata only**: `models.json` is the SPOT (ADR 0003) and every value in it should be the truth about the model. **It changes nothing about how OCP behaves.** OCP never enforces `maxTokens` — `buildCliArgs` passes no output-token flag to the CLI at all — and OpenClaw addresses a local OCP over `openai-completions`, whose request field (`max_completion_tokens`) appears nowhere in this repo. The value is consumed only by clients that choose to honour it, via `setup.mjs` / `scripts/sync-openclaw.mjs` / `ocp-connect`. **Expect no change in answer length or quota burn.** `ocp-connect`'s independent family table moves to the floor over each family's current `models.json` members (opus 64000, sonnet 32000, haiku 32000), since prefixes cannot distinguish versions. Its unknown-id fallback stays at **8192** — the registry's global minimum, held by `claude-3-5-haiku` and `claude-3-5-sonnet`, which are the only real ids that reach it (`claude-3-5-*` matches no family prefix). + ## v3.25.0 — 2026-07-27 Minor release. Headline: **Claude Opus 5** joins the model list and the `opus` alias now resolves to it. Alongside it, two `server.mjs` correctness fixes found by review rather than by users — a monotonic in-flight-counter leak, and cache keys that were hashing the alias string instead of the model it resolves to. The three TUI/prompt fixes that landed after v3.24.0 are included here too. diff --git a/models.json b/models.json index f3c13d6..0db0b7a 100644 --- a/models.json +++ b/models.json @@ -8,7 +8,7 @@ "openclawName": "Claude Opus 5 (via CLI)", "reasoning": true, "contextWindow": 200000, - "maxTokens": 16384 + "maxTokens": 64000 }, { "id": "claude-opus-4-8", @@ -16,7 +16,7 @@ "openclawName": "Claude Opus 4.8 (via CLI)", "reasoning": true, "contextWindow": 200000, - "maxTokens": 16384 + "maxTokens": 64000 }, { "id": "claude-opus-4-7", @@ -24,7 +24,7 @@ "openclawName": "Claude Opus 4.7 (via CLI)", "reasoning": true, "contextWindow": 200000, - "maxTokens": 16384 + "maxTokens": 64000 }, { "id": "claude-opus-4-6", @@ -32,7 +32,7 @@ "openclawName": "Claude Opus 4.6 (via CLI)", "reasoning": true, "contextWindow": 200000, - "maxTokens": 16384 + "maxTokens": 64000 }, { "id": "claude-sonnet-5", @@ -40,7 +40,7 @@ "openclawName": "Claude Sonnet 5 (via CLI)", "reasoning": true, "contextWindow": 200000, - "maxTokens": 16384 + "maxTokens": 64000 }, { "id": "claude-sonnet-4-6", @@ -48,7 +48,7 @@ "openclawName": "Claude Sonnet 4.6 (via CLI)", "reasoning": true, "contextWindow": 200000, - "maxTokens": 16384 + "maxTokens": 32000 }, { "id": "claude-haiku-4-5-20251001", @@ -56,7 +56,7 @@ "openclawName": "Claude Haiku 4.5 (via CLI)", "reasoning": false, "contextWindow": 200000, - "maxTokens": 8192 + "maxTokens": 32000 } ], "aliases": { diff --git a/ocp-connect b/ocp-connect index 0722806..7d1b0ad 100755 --- a/ocp-connect +++ b/ocp-connect @@ -129,10 +129,25 @@ provider = { # 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.) +# maxTokens values are the floor of the CLI registry's max_output_tokens.default over the +# family members CURRENTLY IN models.json (opus 64000; sonnet 32000, because sonnet-4-6 is +# 32000 while sonnet-5 is 64000; haiku 32000) — NOT over the whole registry family. The +# registry also holds claude-opus-4-0 / -4-5 at 32000, which this opus row would over-advertise +# 2x. What keeps that unreachable is NOT "the ids come from models.json" — the ids reaching +# get_model_meta have two sources that are not this checkout: /v1/models is fetched from a +# user-supplied base_url, so it is the REMOTE server's models.json (possibly a different OCP +# version or a fork), and on a JSON parse failure the ids come from the hardcoded list a few +# lines above instead. The invariant that actually holds is that no released OCP has ever +# served a legacy opus id. Adding one to models.json breaks it and this row must drop to 32000. +# Family prefixes cannot distinguish versions, so a floor is used deliberately: +# under-advertising caps a client lower than the model allows, which is safe, whereas +# over-advertising would promise capacity a member of the family does not have. +# models.json is authoritative per ADR 0003; this table only exists because /v1/models does +# not expose maxTokens. model_meta = { - "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}, + "claude-opus": {"name": "Claude Opus (OCP)", "reasoning": True, "maxTokens": 64000}, + "claude-sonnet": {"name": "Claude Sonnet (OCP)", "reasoning": True, "maxTokens": 32000}, + "claude-haiku": {"name": "Claude Haiku (OCP)", "reasoning": False, "maxTokens": 32000}, } def get_model_meta(mid): @@ -140,6 +155,13 @@ def get_model_meta(mid): for prefix, meta in sorted(model_meta.items(), key=lambda x: -len(x[0])): if mid.startswith(prefix): return meta + # Unknown id. 8192 is the GLOBAL MINIMUM max_output_tokens.default across all 17 records in + # the CLI 2.1.220 registry (distinct values: 8192 / 32000 / 64000). The two records holding + # it — claude-3-5-haiku and claude-3-5-sonnet, both default:8192, upper:8192 — are precisely + # the ones that reach this line, because "claude-3-5-*" matches none of the family prefixes + # above. Raising this to a family-table value would over-advertise those two by 4x. Keep 8192: + # under-advertising caps a client lower than a model allows, which is safe; over-advertising + # promises capacity that does not exist. return {"name": mid + " (OCP)", "reasoning": False, "maxTokens": 8192} for mid in model_ids: diff --git a/test-features.mjs b/test-features.mjs index 0721442..7cd44f5 100644 --- a/test-features.mjs +++ b/test-features.mjs @@ -4382,6 +4382,38 @@ test("models.json: every aliases value resolves to a real models[].id (referenti } }); +// maxTokens is ADVERTISED metadata, not an OCP-enforced limit (#195). OCP never reads it — +// buildCliArgs passes no output-token flag to the CLI — and OpenClaw reaches a local OCP over +// `openai-completions`, whose request field (max_completion_tokens) appears nowhere in this repo. +// It is consumed only by clients that choose to honour it, via setup.mjs / sync-openclaw.mjs / +// ocp-connect. So the invariant worth testing is simply that models.json tells the truth: each +// value must equal the model's max_output_tokens.default in the CLI registry. +// +// Pinned per model deliberately. A threshold assertion would let every entry sit at some arbitrary +// value above the bar and still call itself "registry-aligned" — which is the actual claim. Adding +// a model means adding a row here, and that is the point: the row is where you record what the +// registry said when you checked. +// Keys are models.json ids; values are the CLI 2.1.220 registry's max_output_tokens.default, +// each extracted id-anchored (grep 'id:""' + the following bytes) — never by bare-string +// search, which matches cross-references inside OTHER models' records and silently attributes +// the wrong number. ONE KEY IS NOT A REGISTRY ID: models.json carries the dated haiku id, but +// the registry record is id:"claude-haiku-4-5" (the dated string appears only as that record's +// provider_ids.first_party). Anchor the haiku row on the SHORT id; anchoring on the dated one +// returns nothing, which is what tempts the next reader back into a bare-string search. +const _spotRegistryMaxTokens = { + "claude-opus-5": 64000, "claude-opus-4-8": 64000, "claude-opus-4-7": 64000, "claude-opus-4-6": 64000, + "claude-sonnet-5": 64000, "claude-sonnet-4-6": 32000, + "claude-haiku-4-5-20251001": 32000, // registry id: claude-haiku-4-5 +}; +test("models.json: every maxTokens equals the CLI registry's max_output_tokens.default (#195)", () => { + for (const m of _spotModels.models) { + const want = _spotRegistryMaxTokens[m.id]; + assert.ok(want !== undefined, + `${m.id} has no recorded registry value — extract it id-anchored from the CLI binary and add a row`); + assert.equal(m.maxTokens, want, `${m.id}: models.json says ${m.maxTokens}, CLI registry says ${want}`); + } +}); + 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)`);