diff --git a/CHANGELOG.md b/CHANGELOG.md index e47a51f..3b7d019 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 actually declares in the compiled CLI 2.1.220 registry. OCP never enforced this value; it is propagated to OpenClaw, where it **caps the outbound request**: `maxTokens = Math.min(baseMaxTokens + thinkingBudget, modelMaxTokens)`. OpenClaw's reasoning budgets are medium 8192 / high 16384, and when `maxTokens <= thinkingBudget` it clamps thinking to `maxTokens - 1024` — so a model declared at 16384 running at **high** reasoning spent ~15360 on thinking and left **~1024 tokens for the actual answer**. `ocp-connect`'s independent family table moves to the family floor (opus 64000, sonnet 32000, haiku 32000) since prefixes cannot distinguish versions. Expect longer answers and correspondingly higher per-request quota burn on long generations. + ## 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..2495491 100755 --- a/ocp-connect +++ b/ocp-connect @@ -129,10 +129,17 @@ 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 FAMILY FLOOR of the CLI registry max_output_tokens.default +# (opus family 64000; sonnet 32000 because sonnet-4-6 is 32000 while sonnet-5 is 64000; +# haiku 32000). Family prefixes cannot distinguish versions, so the 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): diff --git a/test-features.mjs b/test-features.mjs index 57c00f8..a932eef 100644 --- a/test-features.mjs +++ b/test-features.mjs @@ -4299,6 +4299,24 @@ test("models.json: every aliases value resolves to a real models[].id (referenti } }); +// maxTokens must leave room for a visible answer once a client's thinking budget is taken out +// (#195). OCP never enforces maxTokens itself — it is propagated to OpenClaw (setup.mjs, +// scripts/sync-openclaw.mjs) where it CAPS the outbound request: +// maxTokens = Math.min(baseMaxTokens + thinkingBudget, modelMaxTokens) +// OpenClaw's reasoning budgets are medium 8192 / high 16384, and when maxTokens <= thinkingBudget +// it clamps thinking to maxTokens - 1024. So a model declared at 16384 running at `high` spent +// ~15360 on thinking and left ~1024 for the actual answer — which is what this repo shipped until +// v3.25.0. Asserting the PRINCIPLE rather than pinning per-model numbers: a value test would need +// editing every time a model is added, and would not say why. +const _spotHighThinkingBudget = 16384; // OpenClaw's `high` reasoning budget +test("models.json: every maxTokens exceeds the high thinking budget, leaving room for output (#195)", () => { + for (const m of _spotModels.models) { + assert.ok(m.maxTokens > _spotHighThinkingBudget, + `${m.id} declares maxTokens=${m.maxTokens} <= ${_spotHighThinkingBudget}: at OpenClaw's 'high' reasoning ` + + `level the thinking budget would consume all but ~1024 tokens of the response`); + } +}); + 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)`);