Compare commits

...
Author SHA1 Message Date
c998370b8a chore(release): v3.25.0 — Claude Opus 5, cache-key resolution, counter pairing (#201)
* chore(release): v3.25.0 — Claude Opus 5, cache-key resolution, counter pairing

package.json 3.24.0 -> 3.25.0. MINOR because the `opus` alias now resolves to a
different model: every request that names `opus` (and OpenClaw's opus entry)
silently moves from Opus 4.8 to Opus 5. Same $5/$25 per MTok, so no cost change,
but it is a user-visible default change and not a patch.

Consolidates six PRs. Three were already on main without CHANGELOG entries
(#187, #188, #191, merged after the v3.24.0 tag) and are covered here:

  #192  Claude Opus 5 + `opus` alias repoint
  #194  cache keys hash the resolved model; structured path gets its epoch
  #193  active-request counter paired to the process lifecycle
  #191  OCP_LOCAL_TOOLS wrapper no longer hard-codes a tool list
  #187  TUI --safe-mode so the host CLAUDE.md cannot leak
  #188  TUI accepts `shift+tab to cycle` as input-ready

release_kit walk (CLAUDE.md Iron Rule 5.5), every item checked rather than
assumed:
  - version_source package.json          -> bumped
  - changelog CHANGELOG.md               -> v3.25.0 section added
  - Available Models table               -> already 7 rows (landed in #192)
  - models.json source_of_truth          -> already updated (#192)
  - Environment Variables table          -> no new env var this cycle
  - API Endpoints table                  -> no new endpoint
  - new CLI subcommand / hook / file     -> none
  - bootstrap_quirk_policy               -> see below

Bootstrap quirk documented, per policy. #194 changes the cache key shape, so
alias-addressed rows orphan once and are reaped by the TTL cleanup within one
window — the same shape as the v3.13.0 v1->v2 hash upgrade already recorded in
README. Added to BOTH places the precedent lives: the Response Cache section in
README, and a dedicated entry in docs/troubleshooting.md alongside the existing
"OpenClaw shows old models after ocp update" one-time-quirk entry. Only affects
instances running with CLAUDE_CACHE_TTL > 0; it is off by default.

Verified: release.yml's awk extractor matches the `## v3.25.0 — 2026-07-27`
heading and pulls 22 lines (a mismatched heading would ship an empty release
body). Suite 457 passed, 0 failed.

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

* fix(release): correct four inaccuracies the reviewer found in the release notes

This PR's entire job is that the text is accurate, so these are not nits.

1. README + troubleshooting overstated the cache-key scope. "Rows keyed on a
   literal model id are unaffected" is FALSE for structured-output rows: #194
   also added `configEpoch` to the structured key, and keys.mjs:362 folds it
   into the digest, so EVERY structured row rekeys regardless of how it was
   addressed. Now states both scopes separately — normal cache: only
   alias-addressed rows; structured cache: all rows, plus why (the epoch was
   never in that key, so a CLAUDE_SYSTEM_PROMPT change did not invalidate
   structured answers either).

2. The bootstrap quirk was documented in README § Response Cache and in
   docs/troubleshooting.md, but NOT in the "Bootstrap quirks (one-time
   migrations)" bullet list at README:540 — which is the location the
   release_kit YAML actually names, and where both existing precedents live.
   Added there. My "every item checked, not assumed" claim was wrong on the one
   item that had a dedicated home.

3. #188's root cause was invented. I wrote "plan/auto mode renders a different
   idle footer than manual mode", which contradicts the landed code comment
   (lib/tui/session.mjs:184-189) and the test fixture: the two footers are
   across claude VERSIONS — the classic `? for shortcuts` vs `shift+tab to
   cycle` on newer 2.1.x — and the fixture is bypass-permissions mode, neither
   plan nor auto. Replaced with the contributor's own documented cause, plus
   the observable symptom (tui_pane_not_ready on every boot, every pre-boot
   failing with the warm pool on). Substituting my guess for an external
   contributor's evidenced diagnosis would have sent the next debugger looking
   for "plan mode".

4. "Suite: 449 → 457" used a mid-cycle baseline. Measured at the v3.24.0 tag:
   447. 449 is the count AFTER #187/#188 already landed — and this release
   claims those as part of the cycle. Corrected to 447 → 457 with the per-PR
   breakdown (#188 +1, #187 +1, #191 +0, #194 +4, #192 +3, #193 +1 = +10).

Also taken from the same review:
  - the Added bullet now cites #192 (every Fixed entry had a PR number; Added
    did not)
  - split out a `### Changed` section for the `opus` repoint, matching how
    v3.23.0 recorded #168's `sonnet` repoint — a behavior change should not sit
    under Added
  - CHANGELOG quoted `MODEL_MAP[model] || model`, the form review REJECTED;
    the landed code is the Object.hasOwn guard (a bare lookup returns a truthy
    object for "__proto__" and never reaches the `||` fallback)

Re-verified after the edits: release.yml's awk extractor yields 27 lines /
6277 bytes with all four sections; all six PR references present. Suite 457
passed, 0 failed.

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

* fix(release): qualify the normal-cache scope for OCP_LOCAL_TOOLS instances

Round-2 review. One finding accepted, one REJECTED with first-hand evidence.

ACCEPTED [MED] — my round-1 fix corrected the structured-cache scope but got
the normal-cache scope wrong in a new way. "Rows keyed on a literal model id
keep matching" is true for #194 in isolation, false for this RELEASE: #191
(a32bc9a, in this same release) reworded OCP_LOCAL_TOOLS_WRAPPER, the wrapper
text is one of CONFIG_EPOCH's four inputs (server.mjs:219), and every normal
cache key folds the epoch in. Verified by computing both digests:

  epoch with the OLD local-tools wrapper: 9a97a9dace3cadc2
  epoch with the NEW local-tools wrapper: 9f4526f7b146adfb

So an OCP_LOCAL_TOOLS=1 instance rekeys its ENTIRE normal cache once, literal
ids included. The doc heading is release-scoped while my claim was PR-scoped —
the same class of over-broad assertion as the original finding, which I then
reintroduced while fixing it. Both docs now carry the exception.

Also anchored the README bootstrap-quirk link at the specific troubleshooting
section (#cache-rekey-v3250) rather than the file, matching the existing
#tui-401 precedent.

REJECTED [claimed HIGH] — "Opus 5 is pricing:\"tier_10_50\" ($10/$50), so
'Pricing is unchanged' is false." It is not false. I re-extracted from the same
binary the review cites (claude 2.1.220), this time ANCHORED ON THE id FIELD
rather than on a bare id string:

  id:"claude-opus-5"    ... pricing:"tier_5_25"
  id:"claude-opus-4-8"  ... pricing:"tier_5_25"
  id:"claude-mythos-5"  ... pricing:"tier_10_50"   <- the real owner
  id:"claude-sonnet-5"  ... pricing:"tier_3_15"

`claude-opus-5` never appears within 200 chars before a tier_10_50 (grep -c: 0).
tier_10_50 belongs to claude-mythos-5. The review appears to have matched an
adjacent entry — the exact failure mode it accused this PR of, which is a fair
thing to have looked for; it just wasn't what happened here.

Worth recording WHY the original extraction was sound even though it looked
suspicious: the substring it matched, `claude-opus-5"},eager_input_streaming`,
is the tail of claude-opus-5's OWN provider_ids object (`gateway:"claude-opus-5"}`),
so the pricing that follows is its own. Ambiguous-looking, correct in fact —
which is why the id-anchored re-extraction was worth doing rather than assuming
either way.

The pricing claim therefore stands unchanged, on first-hand evidence.

Suite: 457 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>
2026-07-27 08:10:15 +10:00
f46756b906 fix(server): pair the active-request counter with the process lifecycle (supersedes #180) (#193)
* fix(server): pair the active-request counter with the process lifecycle (#180)

`stats.activeRequests` was incremented ~40 lines before the child process was
spawned, but its only decrement site — cleanup() — is wired to that process's
exit/close/error events. Any synchronous throw in the window between them
(buildCliArgs, env assembly, applying the spawn decision, or spawn() itself)
therefore leaked +1 permanently: the increment had happened, and cleanup() was
not yet attached to anything that could undo it. /health and /stats then
over-report in-flight work forever, and the drift is monotonic.

Fix: move the increment to immediately after `activeProcesses.add(proc)`, so
increment and decrement are structurally paired to exactly one process
lifecycle. No try/catch and no reconciliation pass is needed — there is no
longer a window in which the counter is incremented but unowned.

`stats.activeRequests` is observability-only, not a gate: the old
`if (activeRequests >= MAX_CONCURRENT) throw` admission check was removed in
favour of the releaseSlot semaphore (see the comment at server.mjs:1327), and
the only remaining readers are /health, /stats and the settings snapshot. So
deferring the increment past the spawn changes no admission decision.

Reproduced and verified on the real server (no test double). A deliberate
throw injected between the increment and the spawn, then three requests:

  before the fix : 3 requests -> http 500 -> stats.activeRequests == 3  (leaked)
  after the fix  : 3 requests -> http 500 -> stats.activeRequests == 0
  happy path     : real completion "OK", activeRequests 0, total 1, errors 0

The fault injection was removed before committing (no residue: `grep -rn
OCP_FAULT_INJECT` is clean) — it is documented in the PR so any reviewer can
re-run the same three commands.

cli.js citation: NOT APPLICABLE — this touches no cli.js-derived wire
behavior. Scope is justified under ALIGNMENT.md Rule 2 as OCP-owned internal
bookkeeping: the counter is OCP's own observability state, cli.js has no
equivalent operation, and the spawn arguments and protocol are untouched.

Supersedes the approach in #180, which additionally reconciled the counter to
`activeProcesses.size`. That reconciliation is racy: a concurrent request that
has incremented but not yet registered its process is invisible in
`activeProcesses`, so reconciling stomps it back down and under-reports live
work. Pairing the increment to the spawn removes the drift at its source, so
no reconciliation is required. Original report and diagnosis by @konceptnet.

Tests: 449 passed, 0 failed.

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

* fix(review): correct three wrong claims in this PR, and add the regression test

The diff was right; three things I wrote about it were not. An independent
reviewer falsified each one, and leaving a correct fix on an incorrect
rationale in git history is exactly what ALIGNMENT.md governance exists to
prevent. Corrections:

1. "No regression test is possible here" — WRONG, and the test is now here.
   Two compounding errors. `test-features.mjs:955` already has `ltBoot`, a
   real-server integration fixture (spawns server.mjs, fake claude binary,
   HTTP) that I had used myself in #191; I reasoned from "cannot import
   server.mjs" straight to "cannot test" without grepping for it. And the
   synchronous throw is reachable from ENV ALONE, needing no production hook:
   buildCliArgs does `args.push("--allowedTools", ...ALLOWED_TOOLS)`, and a
   spread of enough elements throws RangeError synchronously, inside the leak
   window. Measured, zero production changes:

     origin/main : 3 requests -> 500 x3 -> stats.activeRequests == 3
     this branch : 3 requests -> 500 x3 -> stats.activeRequests == 0

   The committed test DISCOVERS the element count rather than hard-coding it
   (the threshold is stack-size dependent, so a fixed number would silently
   stop triggering on another machine and pass vacuously), and asserts the
   requests actually 500 before trusting the counter. Mutation-proven: moving
   the increment back before the spawn fails it with "got 3".

2. My reason for rejecting #180's approach was WRONG. I claimed reconciling to
   activeProcesses.size is racy because a request that incremented but has not
   yet registered is invisible in the Set. There is no `await` anywhere in that
   window — it is fully synchronous, so on Node's single thread that state is
   unobservable and the race cannot occur. The real reason reconciliation is
   unsafe: the decrement is wired to 'exit' while activeProcesses.delete is
   wired to 'close', and exit precedes close (measured exit @2ms, close
   @2017ms with a grandchild holding stdout), so activeProcesses.size
   OVER-reports during that window — the opposite direction from what I wrote.
   Separately, a throw after activeProcesses.add leaves permanent Set residue
   that reconciliation would inherit. Corrected publicly on #180; the
   contributor was owed an accurate rejection.

3. The alignment citation named the wrong authority. ALIGNMENT.md:17 scopes
   Rules 1-5 to Class A only, and Rule 2 is a prohibition, not an
   authorization — citing it as justification is a category error. Every reader
   of stats.activeRequests is a grandfathered Class B.2 endpoint (/health,
   /status, /usage). Correct citation, per ALIGNMENT.md:142 and the in-repo
   precedent at server.mjs:3271-3276 (the F6 spawn.isolated fix):

     Authorized by ADR 0006 (grandfathered as of v3.16.4). The field SET is
     unchanged — no field added, removed or renamed — only the VALUE is made
     truthful. Behaviour-preserving; no new ADR required.

   The "cli.js: no hit, declared" part stands: cli.js has no equivalent
   operation, and spawn argv and protocol are byte-identical.

Also corrected the new comment at the increment: only 'exit' is wired inside
spawnClaudeProcess; 'close'/'error' are wired by each caller, and that is
REQUIRED because a failed spawn never emits 'exit'.

Note for a follow-up issue, not folded in (Iron Rule 11): stats.errors stays 0
across all of these 500s — synchronous throws from spawnClaudeProcess are never
counted. And CLAUDE.md hard-requirement #1 literally says "justify scope under
Rule 2", which is what I followed; CLAUDE.md predates ADR 0006's Class B
section and needs its own governance PR.

Tests: 450 passed, 0 failed.

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

* test: skip the leak probe where the OS rejects the trigger, instead of failing CI

The regression test I added broke CI with `spawn E2BIG`, and the reason matters
more than the fix: the env-only trigger is NOT portable, and the review that
proposed it was measured only on darwin (the reviewer said so).

Delivering the trigger needs a single env var of ~2n bytes where n is the
spread-throw threshold (~130k elements here). Linux caps ONE env string at
MAX_ARG_STRLEN = 32 * PAGE_SIZE = 131072 bytes, so execve rejects it outright.
Encoding tricks do not rescue it: empty items cost 1 byte each, but 120k empty
items do not reach the throw threshold, and the threshold itself moves with
stack size — the margin does not exist. buildCliArgs has exactly one spread
(verified), so there is no alternative env-only trigger.

So the test now runs where the OS allows the trigger (macOS/BSD) and SKIPS
LOUDLY where it does not, printing the platform and byte count. It never
passes vacuously: it already asserts the requests actually return 500 before
trusting the counter, and E2BIG is detected both as a synchronous throw and as
an async 'error' event.

Consequence, stated plainly rather than buried: CI runs on Linux, so CI does
NOT exercise this test. The fix is protected on macOS dev machines only. That
is weaker than I would like and is why #197 (portable in-process fault
injection) matters — it is the difference between this class of bug being
guarded and merely having been checked once by hand.

Verified after the change:
  macOS, fixed        -> test RUNS and passes, 450/0
  macOS, bug restored -> test goes RED ("got 3"), 449/1
  Linux CI            -> skips with a printed reason

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

* test: make the leak probe run on Linux CI — my "impossible" conclusion was wrong

f84d4c6 concluded the trigger could not be delivered on Linux and made the test
skip there. The reviewer refuted that and built the working version; this is it.
Three things I got wrong, all the same shape — treating a darwin measurement as
universal:

1. "empty items don't reach the throw threshold" — the REASON was wrong. The
   spread's cost is per ELEMENT, not per byte: empty and "x" have an identical
   threshold. What actually kills the empty-item encoding is `.filter(Boolean)`
   at server.mjs:355, which strips empty entries, so ALLOWED_TOOLS ends up empty
   and the spread branch is never entered. The new control mutation demonstrates
   exactly this: tools filtered to empty -> requests return 200, not 500.

2. "the threshold moves with stack size — the margin does not exist" — exactly
   backwards. The stack dependence is the LEVER, and ltBoot already spawns the
   server, so the test owns its argv. Under --stack-size=200 the threshold drops
   ~5x: measured 24069 elements, so 1.5x margin is 36104 elements = 72207 bytes,
   45% UNDER Linux's MAX_ARG_STRLEN of 131072.

3. "buildCliArgs has only one spread, so there is no alternative trigger" — the
   premise is true (verified) but irrelevant. The problem was never finding a
   second spread; it was making the one spread throw with fewer elements.

Test-side only; server.mjs is untouched by this commit.
  - ltBoot(env, dir, nodeArgs = []) — one line, so a caller can set V8 flags
  - the threshold is discovered in a CHILD under the same --stack-size (the old
    version measured it in the parent, whose stack is not the one that matters)
  - hard assert entryBytes <= MAX_ARG_STRLEN, so a future regression fails loudly
    instead of silently degrading to a skip
  - dynamic free port instead of a fixed 39370 (a flake was observed on the fixed
    port across back-to-back runs)
  - the 500s must carry "call stack size exceeded", so an unrelated fault that
    also left the counter at 0 cannot be mistaken for a pass

The Linux claim here is still ARITHMETIC (72207 < 131072), not a measurement —
neither I nor the reviewer has a Linux box. CI is the check: the log must show
this test RUNNING, not skipping.

Co-Authored-By: Hermes Agent <hermes-agent@users.noreply.github.com>
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: Hermes Agent <hermes-agent@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 07:39:33 +10:00
fdd6dadf96 feat(models): add Claude Opus 5 and repoint the opus alias to it (#192)
* feat(models): add Claude Opus 5 and repoint the `opus` alias to it

Adds `claude-opus-5` to models.json (the SPOT) and moves the `opus` alias
from `claude-opus-4-8` to it. `/v1/models` goes 6 -> 7; OpenClaw picks the
new entry up on the next `ocp update` via scripts/sync-openclaw.mjs.

server.mjs is NOT modified by this PR — models.json is the single source of
truth (ADR 0003) and every consumer (server.mjs MODEL_MAP, setup.mjs,
sync-openclaw.mjs, ocp-connect) derives from it. ocp-connect needs no change
either: its metadata table matches on the `claude-opus` FAMILY prefix, which
`claude-opus-5` hits (the guard added in PR #152 review).

Alignment evidence. The claim "the CLI itself defaults opus -> claude-opus-5"
is verified from the compiled CLI binary 2.1.220 via `strings`, the same
protocol used for #152/#168:

    latest_per_family:{fable:"claude-fable-5",opus:"claude-opus-5",
                       sonnet:"claude-sonnet-5",haiku:"claude-haiku-4-5"}

The same registry entry gives context:{window:1e6,native_1m:true},
max_output_tokens:{default:64000,upper:128000} and pricing:"tier_5_25" --
i.e. identical $5/$25 per MTok to Opus 4.8, so the alias repoint carries no
cost change. Availability confirmed with a live subscription-pool completion
(`claude -p --model claude-opus-5` -> "OK"). This mirrors #168 (sonnet ->
sonnet-5), which established the precedent that following the CLI's own
latest_per_family is the correct default behavior.

contextWindow is deliberately 200000, not the native 1M:

1. MAX_PROMPT_CHARS is a SINGLE GLOBAL budget, not per-model.
   derivePromptCharBudget (lib/prompt.mjs) returns
   `max(floor, max(...contextWindows) * 3)` across ALL entries, so a 1M entry
   would lift the truncation ceiling to 3,000,000 chars for
   claude-haiku-4-5-20251001 as well -- genuinely a 200k-native model --
   turning clean OCP-side truncation into an upstream API rejection.
2. OpenClaw scales its history budget linearly off this value:
   contextWindow * maxHistoryShare * SAFETY_MARGIN (= x0.6), plus an
   oversized-message threshold at x0.5 (compaction-planning, OpenClaw
   2026.7.1). Its own bundled registry hardcodes 200000 for Claude models,
   and the upstream request to raise it to 1M (openclaw#22979) was closed
   "not planned" -- 1M needs a beta header its compaction path omits.

Raising the window for real requires per-model budgets and is ADR-level;
a new regression test pins the invariant so that change has to be deliberate.

Tests: 452 passed, 0 failed. Three mutations verified to bite:
  - revert aliases.opus -> 4-8            => 1 failure (opus-alias SPOT)
  - delete the claude-opus-5 entry        => 2 failures (presence + dangling alias)
  - set its contextWindow to 1000000      => 2 failures (invariant + budget SPOT)

E2E on an isolated port (:3999, prod on :3456 untouched and verified so):
/v1/models returns 7 ids with claude-opus-5 first; a request with
model:"opus" logs event=claude_spawned model=claude-opus-5 tier=opus and
returns a real completion. Derived MAX_PROMPT_CHARS stays 600000.

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

* fix(docs,test): restore the dropped Opus 4.8 line; assert the window CEILING

Two review findings from the independent Iron-Rule-10 reviewer, both confirmed
before applying:

1. docs/lan-mode.md: the sample `ocp update` output lost `ocp/claude-opus-4-8`
   entirely — the edit substituted the id instead of inserting the new one, so
   the block listed 6 bullets while line 219 of the same document claims
   "7 models available". Self-contradictory. Restored; the block is 7 again.

2. test-features.mjs: "every contextWindow is 200000" was too rigid and
   contradicted ADR 0009, which states the budget "scales automatically — no
   code change". Asserting every entry turned that documented no-code-change
   path into a must-edit-tests path, and would have failed on a future entry
   with a legitimately SMALLER window. Now asserts the MAX instead: raising the
   ceiling (the actual hazard, since the budget is global) still fails, while a
   smaller-window model stays legal.

Verified the reformulation is strictly better, not just different:
  - raise a window to 1000000  -> 2 failures (ceiling + derivePromptCharBudget SPOT)
  - add a legitimate 128k model -> 452 passed, 0 failed   (old test would have failed here)

Tests: 452 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>
2026-07-27 07:37:31 +10:00
9f6707ee9d fix(server): hash the resolved model in cache keys; give the structured path its epoch (#194)
* fix(server): fold the alias map into CONFIG_EPOCH so alias repoints invalidate the cache

Cache keys hash the model string exactly as the client sent it — `const model =
parsed.model || modelsConfig.aliases.sonnet` (server.mjs:2806), which then goes
straight into `cacheHash(model, ...)`. A request for "opus" is therefore cached
under the literal string "opus", not under the canonical id it resolved to.

models.json is read once at boot, so repointing an alias only takes effect on
restart — and the response cache is SQLite-backed (keys.mjs getCachedResponse
queries the `response_cache` table), so it OUTLIVES that restart. CONFIG_EPOCH
folded four config values into every cache key for exactly this hazard (#176 /
#177) but omitted the alias map. Consequence: an operator running
CLAUDE_CACHE_TTL > 0 who repoints an alias keeps being served the OLD model's
answers under that alias until TTL expiry — silently defeating the repoint.

Fix: add `modelsConfig.aliases` to the epoch digest, so any alias change is an
instant whole-cache invalidation. This is the mechanism #177 already
established for the other config inputs, applied to the one it missed; it
covers the normal, structured and singleflight paths at once because they all
key off the same epoch, and it needs no change at the three call sites.

Reproduced end-to-end on the real server, both directions (NODE_ENV=test +
OCP_DIR_OVERRIDE, isolated SQLite store, CLAUDE_CACHE_TTL=600000):

  1. boot with aliases.opus = claude-opus-4-8, request model:"opus"
     -> claude_spawned model=claude-opus-4-8, response cached
  2. stop, repoint aliases.opus -> claude-sonnet-5, restart
  3. same request again:
       on origin/main : cache_hit    (stale Opus 4.8 answer, no spawn)
       on this branch : cache MISS -> claude_spawned model=claude-sonnet-5

Default CLAUDE_CACHE_TTL is 0 (caching off), so shipped default behavior is
unaffected; this bites only operators who deliberately enabled the cache.

cli.js citation: NOT APPLICABLE — no cli.js-derived wire behavior is touched.
Scope justified under ALIGNMENT.md Rule 2 as OCP-owned cache bookkeeping:
cli.js has no response cache and no equivalent operation, and the spawn
arguments and protocol are unchanged.

Found by an independent codex review (gpt-5.6-sol, reasoning=high) of the
Opus 5 alias repoint in #192; verified first-hand before applying rather than
taken on the reviewer's word.

Tests: 449 passed, 0 failed.

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

* fix(server): hash the RESOLVED model in cache keys; give the structured path its epoch

Supersedes the first attempt on this branch, which folded modelsConfig.aliases
into CONFIG_EPOCH. An independent reviewer falsified that approach with a live
repro: the structured-output cache key never reads CONFIG_EPOCH at all, so
adding inputs to the epoch could not possibly fix it. My PR body claimed it
covered "normal, structured and singleflight" — that claim was false.

Two defects, both now fixed:

1. Cache keys hashed the model string as the client sent it. `model` is
   whatever arrived — a canonical id, an alias ("opus"), or a legacyAlias
   ("claude-opus-4"); MODEL_MAP carries all three. models.json is read once at
   boot, so repointing an alias only takes effect on restart, while the SQLite
   response_cache outlives it — serving the OLD model's answers under that
   alias until TTL expiry. Now all three call sites hash
   `cacheModel = MODEL_MAP[model] || model`.

   Chosen over folding the alias map into CONFIG_EPOCH because it is strictly
   better on three counts: it fixes the normal, structured AND dedup keys at
   once (they all already pass `model`); it covers legacyAliases for free; and
   it invalidates PRECISELY — only entries for the repointed alias change key,
   where the epoch approach flushed the entire cache on any alias edit,
   including unrelated sonnet/haiku rows. Only the cache KEY is resolved;
   `model` is still echoed to the client verbatim, so the wire is unchanged.

2. The structured and dedup keys omitted `configEpoch` entirely, so #177 never
   actually covered the structured path: changing SYSTEM_PROMPT (the original
   #176 scenario) still served structured answers composed under the OLD
   config. Live-verified by the reviewer, independently reproduced here. Both
   now pass `configEpoch: CONFIG_EPOCH`. This has been latent for
   structured-output clients since #153 landed.

Tests: +3, all mutation-proven — the previous attempt shipped with ZERO
coverage (reverting it left the suite at 449/0), which the reviewer proved.
Built on the existing `ltBoot` child-process harness (test-features.mjs:987)
that I had wrongly claimed did not exist; a fake CLAUDE_BIN means zero quota
cost. Rather than mutate models.json mid-suite, they assert the equivalent
observable: an alias and its canonical target must land on the SAME cache slot,
which holds only if the key is resolved before hashing.

  - revert cacheModel -> model at the 3 sites  => 2 failures (both alias tests)
  - drop configEpoch from the structured hash  => 1 failure (#177-gap test)
  - restored                                   => 452 passed, 0 failed

Blast radius, previously undisclosed: on upgrade, alias-addressed cache rows
change key once and orphan, reaped by the TTL cleanup within one window — the
same shape as the v3.13.0 v1->v2 hash upgrade already documented in README.
Literal-id rows are unaffected. Default CLAUDE_CACHE_TTL is 0, so a default
deployment sees nothing.

cli.js citation: NOT APPLICABLE — no cli.js-derived wire behavior is touched.
Scope justified under ALIGNMENT.md Rule 2 as OCP-owned cache bookkeeping;
cli.js has no response cache. The endpoint is Class B.1 under ADR 0006, which
authorizes the OpenAI-compat surface this caching sits behind.

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

* harden: hasOwn lookup for cacheModel + pin legacyAlias coverage with a test

Two non-blocking nits from the approving review, both worth taking:

1. `MODEL_MAP` is a plain object, so a bare `MODEL_MAP[model]` returns an
   INHERITED function for "constructor"/"toString"/"valueOf". Unreachable
   today — the VALID_MODELS gate 400s first, and it is built from
   Object.keys() so it holds only own keys — but the binding sits before that
   gate, so a bare lookup would hand cacheHash a function the day anyone
   widens the gate or moves the binding. Now Object.hasOwn-guarded.

2. legacyAlias coverage was constructive (MODEL_MAP = models + aliases +
   legacyAliases) but untested; all three tests used a plain alias. Added an
   explicit claude-haiku-4-5 vs claude-haiku-4-5-20251001 same-slot assertion.

Declined the third nit (structuredHash and dedupKey compute the same value
twice): it is cosmetic, and collapsing them changes evaluation order in a path
that is currently correct — not worth the risk in a fix PR.

Tests: 453 passed, 0 failed. Mutation now bites 3 (was 2): reverting
`cacheHash(cacheModel,` -> `cacheHash(model,` fails the normal, structured AND
legacyAlias slot tests.

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>
2026-07-27 07:37:23 +10:00
dtzp555-maxGitHubtaodengClaude <claude-opus-4-8> <noreply@anthropic.com>
a32bc9af17 fix(prompt): OCP_LOCAL_TOOLS wrapper no longer hard-codes a tool list (closes #185) (#191)
The positive local-tools wrapper (server.mjs) claimed the model has "full access
to the local filesystem, working directory, and shell through your available
tools (Bash, Read, Write, Edit, Glob, Grep, etc.)". That over-promises when an
operator narrows CLAUDE_ALLOWED_TOOLS (e.g. to Read,Grep): the model is told it
has Bash/Write/shell it doesn't hold, then attempts a non-granted tool and gets
denied. The default set matches the text, so the primary/OpenClaw path was fine —
but the enumeration was a prompt-honesty mismatch (flagged in the #182 fact-check).

Reworded to flip the POSTURE only ("you may use your available local tools … do
not assume access beyond the tool set provided to you in this session") without
enumerating specific tools or asserting shell/write capability. The model learns
its real tools from the tool definitions claude is given (--allowedTools), so the
enumeration was redundant as well as fragile — now honest under any allowed set.

No const reordering / no CONFIG_EPOCH change (still a constant; the epoch-fold
test confirms toggling still invalidates the cache with the new text). Test
markers updated (selectPromptWrapper unit + the boot-server integration assertion
that the positive wrapper reaches the -p spawn). Suite 449/0.

Rule 2: OCP-owned prompt composition, no wire change, no cli.js citation.

Closes #185

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude <claude-opus-4-8> <noreply@anthropic.com>
2026-07-23 17:38:41 +10:00
8 changed files with 313 additions and 22 deletions
+27
View File
@@ -1,5 +1,32 @@
# Changelog
## 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.
Every code PR carried an independent fresh-context reviewer (Iron Rule 10); #192 additionally went through the external codex gate, which is what surfaced the cache-key defect. No new endpoint, no new env var, no new `cli.js` wire behavior.
### Added
- **Claude Opus 5 (`claude-opus-5`) (#192).** New `models.json` entry — `/v1/models` goes 6 → 7 and OpenClaw picks it up on the next `ocp update` (via `scripts/sync-openclaw.mjs`). Verified against the installed CLI rather than assumed: the compiled `claude` 2.1.220 bundle carries `latest_per_family:{fable:"claude-fable-5",opus:"claude-opus-5",sonnet:"claude-sonnet-5",haiku:"claude-haiku-4-5"}`. Availability confirmed with a live `claude -p --model claude-opus-5` completion on the subscription pool. `claude-opus-4-8` is retained for pinning.
- `contextWindow` is deliberately **200000**, not Opus 5's native 1M. Two reasons, both verified: (1) `MAX_PROMPT_CHARS` is a **single global** budget — `derivePromptCharBudget` takes `max(contextWindow) × 3` across *all* entries (`lib/prompt.mjs`), so a 1M entry would raise the truncation ceiling to 3,000,000 chars for `claude-haiku-4-5` too, which is genuinely 200k native, converting clean OCP-side truncation into an upstream API rejection; (2) OpenClaw scales its history budget linearly off this value (`contextWindow × maxHistoryShare × SAFETY_MARGIN` = `× 0.6`, plus an oversized-message threshold at `× 0.5`, per `compaction-planning` in OpenClaw 2026.7.1), and its own bundled registry hardcodes 200000 for Claude — the upstream request to raise it to 1M ([openclaw#22979](https://github.com/openclaw/openclaw/issues/22979)) was closed *not planned*. A new regression test pins the invariant so a future 1M entry has to be a deliberate, reviewed change. Raising it for real needs per-model budgets — tracked separately, ADR-level.
### Changed
- **The `opus` alias now resolves to `claude-opus-5` instead of `claude-opus-4-8` (#192).** Every request that names `opus` — and OpenClaw's opus entry — moves to Opus 5 on upgrade. This mirrors what the CLI itself defaults to (`latest_per_family.opus` above), the same reasoning as #168's `sonnet``claude-sonnet-5` repoint in v3.23.0. **Pricing is unchanged** ($5/$25 per MTok; CLI registry `pricing:"tier_5_25"` for both Opus 4.8 and Opus 5), so this carries no cost change. Pin `claude-opus-4-8` explicitly to stay on the previous model.
### Fixed
- **Cache keys hashed the alias, not the model it resolves to (#194).** `model` enters `cacheHash` exactly as the client sent it, so a request for `"opus"` was cached under the literal `"opus"` — and since `models.json` is read once at boot while the SQLite `response_cache` outlives a restart, repointing an alias kept serving the **old model's** answers under it until TTL expiry. That would have silently defeated this release's own `opus` repoint for anyone running with the cache on. All three call sites now hash `Object.hasOwn(MODEL_MAP, model) ? MODEL_MAP[model] : model`, which fixes the normal, structured **and** single-flight keys at once and covers `legacyAliases` for free, with precise invalidation (only the repointed alias's entries change key) rather than a whole-cache flush. (`hasOwn` rather than a bare lookup: `MODEL_MAP` is a plain object, so `MODEL_MAP["__proto__"]` would return a truthy *object* and not even fall through to the `|| model` guard.) **Also closes a latent gap from #177:** the structured and dedup keys never passed `configEpoch` at all, so a `CLAUDE_SYSTEM_PROMPT` change — the original #176 scenario — kept serving structured answers composed under the old config, live since #153. Found by the external codex review of #192.
- **In-flight request counter leaked permanently on a pre-spawn throw (#193, reported by @konceptnet in #180).** `stats.activeRequests` was incremented ~40 lines before the child spawn, while its only decrement is reached through that process's events — so any synchronous throw in between (`buildCliArgs`, env assembly, the spawn decision, or `spawn()` itself) leaked `+1` forever, and `/health` and `/status` over-reported in-flight work monotonically. The increment now sits immediately after `activeProcesses.add(proc)`, structurally pairing it with the decrement; no reconciliation pass and no try/catch. Observability-only field, so no admission decision changes.
- **TUI: the host `CLAUDE.md` could leak into proxied turns (#187, contributed by @sumlin).** The TUI pane now spawns with `--safe-mode`.
- **TUI: `shift+tab to cycle` is accepted as an input-ready marker (#188, contributed by @sumlin).** Claude renders one of two ready-state footers depending on the build — the classic `? for shortcuts` hint, or `shift+tab to cycle` (as part of `⏵⏵ bypass permissions on (shift+tab to cycle)`) on newer 2.1.x. The matcher only knew the classic string, so on those builds it silently reported "never ready": every boot timed out with `tui_pane_not_ready`, and with the warm pool on, every pre-boot failed.
- **`OCP_LOCAL_TOOLS` no longer hard-codes a tool list in its wrapper (#191, closes #185).** The positive wrapper claimed a fixed set of tools regardless of `CLAUDE_ALLOWED_TOOLS`, so a narrowed tool surface was described inaccurately to the model.
### Testing
- The response-cache and counter fixes ship with **mutation-proven integration tests** built on the existing `ltBoot` child-process fixture (real `server.mjs`, fake `claude` binary, so no quota cost). The counter test reaches a synchronous fault *inside* `spawnClaudeProcess` from environment alone — no production fault hook — by running the child under `--stack-size=200` to lower the spread-throw threshold enough to fit Linux's `MAX_ARG_STRLEN`. Suite: **447 → 457** across this release (per PR: #188 +1, #187 +1, #191 +0, #194 +4, #192 +3, #193 +1).
## v3.24.0 — 2026-07-21
Minor release. Headline: two long-requested **OpenAI-compat features** land — **multimodal vision** (`image_url` parts) and **structured outputs** (`response_format` / JSON schema). Also: the prompt-char budget now derives from the model SPOT instead of a hand-set constant, an agentic-turn bug that dropped the model's final answer is fixed, and `OCP_LOCAL_TOOLS` supports the OpenClaw-backend use case. Four of the six landed from external contributors (@vvlasy-openclaw). Every code PR carried a fresh-context reviewer (Iron Rule 10); no new endpoint, no new `cli.js` wire behavior.
+7 -5
View File
@@ -113,11 +113,11 @@ node setup.mjs
`setup.mjs` verifies the Claude CLI, starts the proxy on port 3456, and installs auto-start (launchd on macOS, systemd on Linux). The `ocp` CLI lands at `~/ocp/ocp` — symlink it onto your PATH (`sudo ln -sf ~/ocp/ocp /usr/local/bin/ocp`, or `ln -sf ~/ocp/ocp ~/.local/bin/ocp`) or alias it (`alias ocp=~/ocp/ocp`); the rest of the docs assume `ocp` is on your PATH.
**Verify** — should list 6 models:
**Verify** — should list 7 models:
```bash
curl http://127.0.0.1:3456/v1/models
# claude-opus-4-8, claude-opus-4-7, claude-opus-4-6, claude-sonnet-5, claude-sonnet-4-6, claude-haiku-4-5-20251001
# claude-opus-5, claude-opus-4-8, claude-opus-4-7, claude-opus-4-6, claude-sonnet-5, claude-sonnet-4-6, claude-haiku-4-5-20251001
```
**Connect one IDE** — point any OpenAI-compatible tool at the proxy, then reload your shell and start a tool (Cline / Continue / Cursor / OpenCode):
@@ -169,8 +169,9 @@ Any tool use happens server-side, under the `--allowedTools` set configured on t
| Model ID | Notes |
|----------|-------|
| `claude-opus-4-8` | Most capable (default for `opus` alias) |
| `claude-opus-4-7` | Previous Opus, retained for pinning |
| `claude-opus-5` | Most capable (default for `opus` alias) |
| `claude-opus-4-8` | Previous Opus, retained for pinning |
| `claude-opus-4-7` | Older Opus, retained for pinning |
| `claude-opus-4-6` | Older Opus, retained for pinning |
| `claude-sonnet-5` | Latest Sonnet (default for `sonnet` alias) |
| `claude-sonnet-4-6` | Previous Sonnet, retained for pinning |
@@ -387,7 +388,7 @@ curl -X DELETE http://127.0.0.1:3456/cache # clear all cached responses
ocp settings cacheTTL 0 # disable at runtime
```
Cache is **disabled by default** (`CLAUDE_CACHE_TTL=0`). All data is stored locally in `~/.ocp/ocp.db`. **Hash format upgrade in v3.13.0:** legacy `v1` cache rows don't match new `v2`-format lookups; they orphan and are reaped by the TTL cleanup interval within one window — no migration script required.
Cache is **disabled by default** (`CLAUDE_CACHE_TTL=0`). All data is stored locally in `~/.ocp/ocp.db`. **Cache keys resolve model aliases as of v3.25.0:** a request for an alias (`opus`, `sonnet`, `haiku`, or a legacy alias like `claude-haiku-4-5`) is now keyed on the canonical model it resolves to, not on the string the client sent. Two consequences, both one-time and self-healing: rows written before the upgrade don't match the new lookups, so they orphan and are reaped by the TTL cleanup interval within one window — no migration script required; and an alias now correctly shares a cache slot with its canonical id, since both produce an identical spawn. Scope: for the **normal** cache only alias-addressed rows rekey — rows keyed on a literal model id keep matching, *unless* you run `OCP_LOCAL_TOOLS=1`, in which case the whole normal cache rekeys once because v3.25.0 also reworded that wrapper and the wrapper text feeds the config epoch. **Every structured-output row rekeys regardless**, since the same change folds the config epoch into the structured key, which it previously omitted. This is what makes an alias repoint (such as v3.25.0's `opus` → `claude-opus-5`) take effect immediately instead of being masked by the cache until TTL expiry. **Hash format upgrade in v3.13.0:** legacy `v1` cache rows don't match new `v2`-format lookups; they orphan and are reaped by the TTL cleanup interval within one window — no migration script required.
## Structured Outputs (OpenAI `response_format`)
@@ -540,6 +541,7 @@ The simplest path: ask your AI — paste `Run `ocp doctor` and follow its `next_
- **A TUI session vanished right after upgrading OCP** — if a pre-3.21.1 and a post-3.21.1 instance ran on the same host at the same time during an upgrade, the new instance's one-time boot reap can, once, kill an old-format (`ocp-tui-<8hex>`) live TUI session belonging to the still-running old instance. Restart the affected session (`ocp restart` or re-run your TUI turn) and it returns under the new instance's port-scoped naming.
- **OpenClaw shows old models after `ocp update` (v3.10→v3.11 only)** — the running shell had the old `cmd_update` cached, so the sync hook doesn't fire on that single jump. Run once: `node ~/ocp/scripts/sync-openclaw.mjs && openclaw gateway restart`. Every future update syncs automatically.
- **Response-cache hit rate drops once after upgrading to v3.25.0** — only if you run with the cache on (`CLAUDE_CACHE_TTL > 0`; it is off by default). v3.25.0 keys the cache on the resolved model instead of the string the client sent, so alias-addressed rows (and *all* structured-output rows) orphan and are reaped by the TTL cleanup within one window. **No action required.** Details: [docs/troubleshooting.md#cache-rekey-v3250](docs/troubleshooting.md#cache-rekey-v3250).
Full manual — setup failures, env-var-not-taking-effect-after-restart (launchd bootout+bootstrap vs `kickstart -k`), stuck sessions, "OpenClaw registry out of sync", and the two-layer TUI-mode 401 root cause + fix: **[docs/troubleshooting.md](docs/troubleshooting.md)**.
+6 -5
View File
@@ -78,7 +78,7 @@ Run `ocp lan` to see your IP and ready-to-share instructions.
**Verify:**
```bash
curl http://127.0.0.1:3456/v1/models
# Returns: claude-opus-4-8, claude-opus-4-7, claude-opus-4-6, claude-sonnet-5, claude-sonnet-4-6, claude-haiku-4-5-20251001
# Returns: claude-opus-5, claude-opus-4-8, claude-opus-4-7, claude-opus-4-6, claude-sonnet-5, claude-sonnet-4-6, claude-haiku-4-5-20251001
```
### Headless install notes
@@ -114,7 +114,7 @@ Please follow https://github.com/dtzp555-max/ocp/blob/main/README.md
installed and logged in (`claude auth status`). Install missing pieces
using my system's package manager.
2. git clone the repo, cd in, and run `node setup.mjs`.
3. Verify with `curl http://127.0.0.1:3456/v1/models` (should list 6 models).
3. Verify with `curl http://127.0.0.1:3456/v1/models` (should list 7 models).
4. Add `export OPENAI_BASE_URL=http://127.0.0.1:3456/v1` to my shell rc.
5. Tell me to reload my shell and try a tool like Cline / Continue / Cursor.
@@ -141,7 +141,7 @@ Please follow https://github.com/dtzp555-max/ocp/blob/main/docs/lan-mode.md
5. Add OCP_ADMIN_KEY to my shell rc (~/.zshrc or ~/.bashrc).
6. Run `ocp lan` to show me the LAN IP and connect command.
7. Optionally create example keys: `ocp keys add laptop`, `ocp keys add tablet`.
8. Verify: `curl http://127.0.0.1:3456/v1/models` returns 6 models.
8. Verify: `curl http://127.0.0.1:3456/v1/models` returns 7 models.
Tell me each step before running it. On error, diagnose before retrying.
```
@@ -163,7 +163,7 @@ Please follow https://github.com/dtzp555-max/ocp/blob/main/docs/lan-mode.md
chmod +x ocp-connect
2. Run `./ocp-connect <SERVER_IP>` (add `--key <KEY>` if you have one).
3. Follow any IDE-specific manual hints it prints.
4. Verify: `curl http://<SERVER_IP>:3456/v1/models` returns 6 models.
4. Verify: `curl http://<SERVER_IP>:3456/v1/models` returns 7 models.
5. Tell me to reload my shell + restart any IDE that was already running.
Don't auto-retry on error. Tell me the failure mode first.
@@ -216,7 +216,7 @@ OCP Connect v1.3.0
(set by admin via PROXY_ANONYMOUS_KEY; see issue #12 §14 Path A)
Testing API access...
✓ API accessible (6 models available)
✓ API accessible (7 models available)
Shell config:
✓ .bashrc
@@ -246,6 +246,7 @@ OCP Connect v1.3.0
✓ OpenClaw configured
Provider: ocp
Models:
• ocp/claude-opus-5
• ocp/claude-opus-4-8
• ocp/claude-opus-4-7
• ocp/claude-opus-4-6
+16
View File
@@ -116,6 +116,22 @@ openclaw gateway restart # so OpenClaw re-reads the config
Future `ocp update` invocations sync automatically.
<a id="cache-rekey-v3250"></a>
### Response-cache hit rate drops once after upgrading to v3.25.0
Only affects instances running with the response cache **on** (`CLAUDE_CACHE_TTL > 0`); it is off by default, so most installs see nothing.
v3.25.0 keys the cache on the **resolved** model rather than on the string the client sent, so rows written for an alias (`opus`, `sonnet`, `haiku`, or a legacy alias like `claude-haiku-4-5`) no longer match. Those rows orphan and are reaped by the TTL cleanup interval within one window — **no migration script, no action required**; expect one window of extra misses and then normal hit rates.
Two different scopes, worth being precise about:
- **Normal cache** — only *alias-addressed* rows rekey. Rows written under a literal model id (`claude-sonnet-5`) keep matching — **unless the instance runs `OCP_LOCAL_TOOLS=1`**, in which case the entire normal cache rekeys once. That is a separate mechanism: v3.25.0 also reworded the local-tools wrapper, and the wrapper text is one of the four inputs to `CONFIG_EPOCH`, which every normal cache key folds in (established behavior since v3.23.0, not new here).
- **Structured-output cache****every** row rekeys, alias or literal. The same change also folds the config epoch into the structured key, which it had never included; that gap meant a `CLAUDE_SYSTEM_PROMPT` change did not invalidate structured answers either. Structured caching only exists from v3.24.0, so there is at most one release worth of rows to orphan.
This is deliberate, and it is what makes an alias repoint take effect. Before v3.25.0, changing where an alias pointed (v3.25.0 itself repoints `opus``claude-opus-5`) left the cache serving the **old** model's answers under that alias until TTL expiry, because `models.json` is read once at boot while the SQLite cache survives the restart. If you were running with the cache on and repointed an alias in an earlier version, that is why it appeared not to take.
A side effect worth knowing: an alias and its canonical id now **share** a cache slot, since both produce an identical spawn. That is a small hit-rate improvement in steady state.
<a id="tui-401"></a>
### TUI-mode returns a permanent `Please run /login` 401 (re-login doesn't stick)
+9 -1
View File
@@ -2,6 +2,14 @@
"$schema": "./models.schema.json",
"version": 1,
"models": [
{
"id": "claude-opus-5",
"displayName": "Claude Opus 5",
"openclawName": "Claude Opus 5 (via CLI)",
"reasoning": true,
"contextWindow": 200000,
"maxTokens": 16384
},
{
"id": "claude-opus-4-8",
"displayName": "Claude Opus 4.8",
@@ -52,7 +60,7 @@
}
],
"aliases": {
"opus": "claude-opus-4-8",
"opus": "claude-opus-5",
"sonnet": "claude-sonnet-5",
"haiku": "claude-haiku-4-5-20251001"
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "open-claude-proxy",
"version": "3.24.0",
"version": "3.25.0",
"description": "OCP (Open Claude Proxy) — use your Claude Pro/Max subscription as an OpenAI-compatible API for any IDE. Works with Cline, OpenCode, Aider, Continue.dev, OpenClaw, and more.",
"type": "module",
"bin": {
+29 -5
View File
@@ -204,7 +204,7 @@ const OCP_SYSTEM_PROMPT_WRAPPER = `You are accessed via the OCP HTTP proxy. You
// default wrapper above is byte-for-byte unchanged. Selecting the positive wrapper does NOT expand
// the tool surface (governed independently by --allowedTools/--disallowedTools) — it only changes the
// prompt — and is boot-gated below (multi/non-loopback/anon → refuse) mirroring OCP_TUI_FULL_TOOLS.
const OCP_LOCAL_TOOLS_WRAPPER = `You are accessed via the OCP HTTP proxy running on the operator's own machine. You have full access to the local filesystem, working directory, and shell through your available tools (Bash, Read, Write, Edit, Glob, Grep, etc.). Use them as needed to complete the operator's requests.`;
const OCP_LOCAL_TOOLS_WRAPPER = `You are accessed via the OCP HTTP proxy running on the operator's own machine. Unlike the shared-gateway posture, you may use your available local tools to act on the operator's machine as the task requires. Use only the tools you actually have — do not assume filesystem, shell, or other access beyond the tool set provided to you in this session.`;
// OCP_LOCAL_TOOLS is inert in TUI mode: the interactive (non-`-p`) path composes its own prompt via
// callClaudeTui/messagesToPrompt and never calls extractSystemPrompt, so the wrapper is only ever
@@ -1371,7 +1371,6 @@ function spawnClaudeProcess(model, messages, conversationId, keyName, releaseSlo
promptChars = stdinPayload.length;
}
stats.activeRequests++;
stats.totalRequests++;
stats.oneOffRequests++;
if (conversationId) {
@@ -1414,6 +1413,17 @@ function spawnClaudeProcess(model, messages, conversationId, keyName, releaseSlo
const proc = spawn(CLAUDE, cliArgs, spawnOpts);
activeProcesses.add(proc);
// Counter drift (#180, reported by @konceptnet): increment ONLY after the spawn has
// succeeded and the process is registered. Incrementing before the spawn (as this did) leaked
// +1 permanently on any synchronous throw in between — buildCliArgs, env assembly, the spawn
// decision, or spawn() itself — because nothing was yet attached that could undo it.
//
// cleanup() is the SOLE decrement site, but note how it is reached: only 'exit' is wired HERE
// (below); 'close' and 'error' are wired by each CALLER (callClaude / callClaudeStreaming).
// That caller wiring is REQUIRED, not belt-and-braces — a FAILED spawn emits 'error' and
// 'close' but never 'exit', so without it a spawn failure would never decrement. A future
// third caller of spawnClaudeProcess must wire them too.
stats.activeRequests++;
const t0 = Date.now();
let gotFirstByte = false;
@@ -2804,6 +2814,20 @@ async function handleChatCompletions(req, res) {
const messages = parsed.messages || parsed.input || [{ role: "user", content: parsed.prompt || "" }];
const model = parsed.model || modelsConfig.aliases.sonnet;
// Cache keys must hash the RESOLVED model, never the string the client happened to use.
// `model` is whatever was sent — a canonical id, an alias ("opus"), or a legacyAlias
// ("claude-opus-4"). MODEL_MAP carries all three, and models.json is read once at boot, so
// repointing an alias only takes effect on restart — while the SQLite response_cache outlives
// it. Hashing the raw string would therefore keep serving the OLD model's answers under that
// alias until TTL expiry, silently defeating the repoint (the #176 hazard, for aliases).
// Resolving first also means "opus" and "claude-opus-5" correctly share one slot: identical
// spawn, identical answer. Only the cache KEY is resolved — `model` is still echoed back to
// the client verbatim, so the wire response is unchanged.
// hasOwn, not a bare lookup: MODEL_MAP is a plain object, so `MODEL_MAP["constructor"]`
// would return an inherited FUNCTION. Unreachable today (the VALID_MODELS gate below 400s
// first, and it is built from Object.keys so it holds only own keys), but a bare lookup
// would hand cacheHash a function the day anyone widens that gate or moves this binding.
const cacheModel = Object.hasOwn(MODEL_MAP, model) ? MODEL_MAP[model] : model;
const stream = parsed.stream;
// Validate model against known models
@@ -2900,7 +2924,7 @@ async function handleChatCompletions(req, res) {
const promptCharsS = messages.reduce((a, m) => a + contentToText(m.content).length, 0);
let structuredHash = null;
if (CACHE_TTL > 0 && !conversationId && !hasCacheControl(messages)) {
structuredHash = cacheHash(model, messages, { keyId: req._authKeyId, temperature: parsed.temperature, max_tokens: parsed.max_tokens, top_p: parsed.top_p, structured });
structuredHash = cacheHash(cacheModel, messages, { keyId: req._authKeyId, temperature: parsed.temperature, max_tokens: parsed.max_tokens, top_p: parsed.top_p, structured, configEpoch: CONFIG_EPOCH });
try {
const cached = getCachedResponse(structuredHash, CACHE_TTL);
if (cached) {
@@ -2919,7 +2943,7 @@ async function handleChatCompletions(req, res) {
// one-off structured request (not stateful sessions / client-side prompt caching), independent of
// whether OCP response caching is enabled; when caching IS on, the same key gates cache read/write.
const dedupKey = (!conversationId && !hasCacheControl(messages))
? cacheHash(model, messages, { keyId: req._authKeyId, temperature: parsed.temperature, max_tokens: parsed.max_tokens, top_p: parsed.top_p, structured })
? cacheHash(cacheModel, messages, { keyId: req._authKeyId, temperature: parsed.temperature, max_tokens: parsed.max_tokens, top_p: parsed.top_p, structured, configEpoch: CONFIG_EPOCH })
: null;
const runStructured = async () => {
const c = await runStructuredCompletion(upstreamCall, model, messages, conversationId, req._authKeyName, res, structured);
@@ -2968,7 +2992,7 @@ async function handleChatCompletions(req, res) {
} else {
// D1: include keyId in hash to isolate per-key cache pools (v2 format).
// configEpoch (#176): any boot-config change that shapes answers invalidates the cache.
const hash = cacheHash(model, messages, { keyId: req._authKeyId, temperature: parsed.temperature, max_tokens: parsed.max_tokens, top_p: parsed.top_p, configEpoch: CONFIG_EPOCH });
const hash = cacheHash(cacheModel, messages, { keyId: req._authKeyId, temperature: parsed.temperature, max_tokens: parsed.max_tokens, top_p: parsed.top_p, configEpoch: CONFIG_EPOCH });
req._cacheHash = hash; // store for later write-back
try {
const cached = getCachedResponse(hash, CACHE_TTL);
+218 -5
View File
@@ -910,7 +910,7 @@ test("appendOperatorPrompt: operator value is trimmed before appending", () => {
console.log("\nOCP_LOCAL_TOOLS wrapper + safety gate:");
const NEG = "You do NOT have access to any local filesystem";
const POS = "You have full access to the local filesystem";
const POS = "you may use your available local tools";
test("selectPromptWrapper: default (disabled) returns the negative wrapper BYTE-IDENTICAL", () => {
// Mutation-proof: flip the ternary and the default path leaks the positive wrapper.
@@ -959,7 +959,8 @@ test("localToolsSafetyError: multi is checked before loopback/anon (most severe
// a request — and boot-gate refusals are asserted by the process exit code. Without these, the
// wiring (extractSystemPrompt using SYSTEM_PROMPT_WRAPPER, the boot gate, the epoch fold) can be
// silently reverted with the unit suite still green — the maintainer's #1 rejection pattern.
import { spawn as _ltSpawn } from "node:child_process";
import { spawn as _ltSpawn, execFileSync as _ltExecFile } from "node:child_process";
import { createServer as _ltNetServer } from "node:net";
import { writeFileSync as _ltWrite, chmodSync as _ltChmod, readFileSync as _ltRead, existsSync as _ltExists, rmSync as _ltRm, mkdtempSync as _ltMkdtemp } from "node:fs";
import { tmpdir as _ltTmp } from "node:os";
import { fileURLToPath as _ltF2P } from "node:url";
@@ -967,7 +968,7 @@ import { fileURLToPath as _ltF2P } from "node:url";
const LT_SERVER = _ltF2P(new URL("./server.mjs", import.meta.url));
const LT_POSIX = process.platform !== "win32"; // fake is a /bin/sh script; CI is POSIX
const LT_NEG_MARK = "You do NOT have access to any local filesystem";
const LT_POS_MARK = "You have full access to the local filesystem";
const LT_POS_MARK = "you may use your available local tools";
// Fake claude: record the --system-prompt it was spawned with, bump an optional spawn counter,
// then emit a minimal valid stream-json response so the request completes (and caches).
const LT_FAKE = `#!/bin/sh
@@ -984,8 +985,8 @@ exit 0
function ltMkdir() { return _ltMkdtemp(join(_ltTmp(), "ocp-lt-")); }
function ltFake(dir) { const p = join(dir, "claude"); _ltWrite(p, LT_FAKE); _ltChmod(p, 0o755); return p; }
function ltBoot(env, dir) {
const child = _ltSpawn(process.execPath, [LT_SERVER], {
function ltBoot(env, dir, nodeArgs = []) {
const child = _ltSpawn(process.execPath, [...nodeArgs, LT_SERVER], {
env: { ...process.env, NODE_ENV: "test", OCP_DIR_OVERRIDE: dir, OCP_SKIP_AUTH_TEST: "1",
CLAUDE_BIND: "127.0.0.1", CLAUDE_AUTH_MODE: "none", CLAUDE_CACHE_TTL: "0", CLAUDE_TIMEOUT: "4000", ...env },
stdio: ["ignore", "pipe", "pipe"],
@@ -1104,6 +1105,195 @@ test("integration: toggling OCP_LOCAL_TOOLS invalidates the standard response ca
} finally { _ltRm(dir, { recursive: true, force: true }); }
});
// ── active-request counter is paired to the process lifecycle (#180 / #193) ──
// The counter used to be incremented ~40 lines before the spawn, while its only decrement
// (cleanup()) is wired to that proc's events — so any SYNCHRONOUS throw in between leaked +1
// permanently. Driving that fault needs no production hook and no test double: buildCliArgs
// does `args.push("--allowedTools", ...ALLOWED_TOOLS)`, and a spread of enough elements throws
// RangeError synchronously, right inside the window.
//
// Getting there on Linux needs one more turn of the screw. The spread's cost is per ELEMENT,
// so the naive form needs ~124k elements ≈ 250KB in one env var — and Linux caps a single env
// string at MAX_ARG_STRLEN (32 * PAGE_SIZE = 131072 on x86-64), so execve rejects it (E2BIG).
// Encoding around it fails too: empty items are 1 byte each, but `.filter(Boolean)`
// (server.mjs:355) strips them, so ALLOWED_TOOLS ends up empty and the spread branch is never
// entered at all.
//
// The lever is the stack: the throw threshold scales with it, and ltBoot spawns the server, so
// the test owns its argv. Running the child under --stack-size=200 drops the threshold ~5x
// (~24k elements ≈ 48KB), which fits Linux's limit with room to spare.
//
// The threshold is DISCOVERED, in a child under the SAME --stack-size (measuring it in this
// process would report the parent's stack, which is not the one that matters), then taken with
// 1.5x margin and hard-asserted under MAX_ARG_STRLEN. A hard-coded count would silently stop
// triggering on another machine and the test would pass vacuously.
const LT_STACK = 200; // child V8 stack (KB); lowers the spread-throw threshold
const LT_MAX_ARG_STRLEN = 131072; // Linux, x86-64: 32 * 4096
function ltSpreadThrowCount(stackKb) {
// Binary-search the smallest element count whose spread throws, inside a child running with
// the stack the server will actually use.
const src = `const t=n=>{try{const a=[];a.push("--allowedTools",...Array(n).fill("x"));return false}catch{return true}};` +
`let lo=500,hi=400000;if(!t(hi)){console.log(0)}else{while(lo<hi){const m=(lo+hi)>>1;t(m)?hi=m:lo=m+1}console.log(lo)}`;
try {
return Number(String(_ltExecFile(process.execPath, [`--stack-size=${stackKb}`, "-e", src], { encoding: "utf8" })).trim()) || 0;
} catch { return 0; }
}
async function ltFreePort() {
const srv = _ltNetServer();
await new Promise(r => srv.listen(0, "127.0.0.1", r));
const p = srv.address().port;
await new Promise(r => srv.close(r));
return p;
}
async function ltPostStatus(port, body) {
try {
const r = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
});
return { status: r.status, text: await r.text() };
} catch { return { status: 0, text: "" }; }
}
console.log("\nactive-request counter pairing (#180 / #193):");
test("integration: a synchronous pre-spawn throw must not leak stats.activeRequests", async () => {
if (!LT_POSIX) return;
const thr = ltSpreadThrowCount(LT_STACK);
assert.ok(thr > 0, `no spread-throw threshold found under --stack-size=${LT_STACK}`);
const n = Math.ceil(thr * 1.5); // margin over the measured threshold
const entry = Array(n).fill("x").join(",");
const bytes = Buffer.byteLength(entry);
// Hard gate: if this ever stops fitting, fail loudly rather than regress to an E2BIG skip.
assert.ok(bytes <= LT_MAX_ARG_STRLEN,
`env entry ${bytes}B exceeds MAX_ARG_STRLEN ${LT_MAX_ARG_STRLEN}B — lower LT_STACK`);
const port = await ltFreePort();
const dir = ltMkdir(); const fake = ltFake(dir);
const { child, buf } = ltBoot({
CLAUDE_BIN: fake, CLAUDE_PROXY_PORT: String(port), CLAUDE_ALLOWED_TOOLS: entry,
}, dir, [`--stack-size=${LT_STACK}`]);
let spawnErr = null;
child.on("error", e => { spawnErr = e; });
try {
const up = await ltWait(() => buf.out.includes("listening on") || spawnErr, 20000);
assert.ok(up && !spawnErr, `did not start: ${spawnErr ? spawnErr.message : buf.err.slice(0, 300)}`);
const req = { model: "haiku", messages: [{ role: "user", content: "leak-probe" }] };
const res = [];
for (let i = 0; i < 3; i++) res.push(await ltPostStatus(port, req));
// Non-vacuous on two axes: the requests must actually fail, AND the failure must be the
// stack overflow from the --allowedTools spread — not some unrelated 500 that a small
// stack happened to produce. Without the second check a different fault would still leave
// the counter at 0 and the test would "pass" for the wrong reason.
assert.deepEqual(res.map(r => r.status), [500, 500, 500],
`expected the pre-spawn throw to surface as 500s, got ${res.map(r => r.status)}`);
assert.ok(res.every(r => /call stack size exceeded/i.test(r.text)),
`500s must come from the spread's RangeError; got: ${res[0].text.slice(0, 200)}`);
const r = await fetch(`http://127.0.0.1:${port}/status`);
const active = (await r.json()).requests.active;
assert.equal(active, 0,
`3 requests threw before their spawn; the counter must be back to 0, got ${active} (this is the #180 leak)`);
} finally { child.kill("SIGKILL"); _ltRm(dir, { recursive: true, force: true }); }
});
// ── Cache keys hash the RESOLVED model, not the alias string (#194) ──────────
// models.json is read once at boot, so repointing an alias only takes effect on restart —
// while the SQLite response_cache outlives it. Hashing the raw string would keep serving the
// OLD model's answers under that alias until TTL expiry. Rather than mutate models.json
// mid-suite, these assert the equivalent observable: an alias and its canonical target must
// land on the SAME cache slot, which is true only if the key is resolved before hashing.
// Mutation: change `cacheModel` back to `model` at the three cacheHash call sites in
// server.mjs and both tests go red (2 spawns instead of 1).
// Fake that emits schema-valid JSON, so the structured path caches a VALIDATED result
// (the stock LT_FAKE returns "OK", which fails validation → refusal → never cached).
const LT_FAKE_JSON = `#!/bin/sh
if [ -n "$SP_COUNTER" ]; then c=$(cat "$SP_COUNTER" 2>/dev/null || echo 0); echo $((c+1)) > "$SP_COUNTER"; fi
printf '%s\\n' '{"type":"assistant","message":{"content":[{"type":"text","text":"{\\"ok\\":true}"}]}}'
printf '%s\\n' '{"type":"result"}'
exit 0
`;
function ltFakeJson(dir) { const p = join(dir, "claude-json"); _ltWrite(p, LT_FAKE_JSON); _ltChmod(p, 0o755); return p; }
const LT_SCHEMA = { type: "object", properties: { ok: { type: "boolean" } }, required: ["ok"], additionalProperties: false };
console.log("\nCache key resolves the model alias (#194):");
test("integration: an alias and its canonical target share ONE cache slot (normal path)", async () => {
if (!LT_POSIX) return;
const dir = ltMkdir(); const fake = ltFake(dir); const counter = join(dir, "spawns.txt");
const { child, buf } = ltBoot({ CLAUDE_BIN: fake, CLAUDE_PROXY_PORT: "39360", CLAUDE_CACHE_TTL: "60000", SP_COUNTER: counter }, dir);
try {
assert.ok(await ltWait(() => buf.out.includes("listening on")), `did not start: ${buf.err.slice(0, 200)}`);
_ltWrite(counter, "0");
const msgs = [{ role: "user", content: "alias-resolution-probe" }];
await ltPost(39360, { model: "sonnet", messages: msgs }); // miss → spawn
await ltWait(() => (Number(_ltRead(counter, "utf8")) || 0) >= 1, 3000);
await ltPost(39360, { model: "claude-sonnet-5", messages: msgs }); // same resolved model → HIT
await new Promise(r => setTimeout(r, 600));
assert.equal(Number(_ltRead(counter, "utf8")) || 0, 1,
"the canonical id must hit the slot the alias populated — a 2nd spawn means the key still hashes the raw alias");
} finally { child.kill("SIGKILL"); _ltRm(dir, { recursive: true, force: true }); }
});
test("integration: an alias and its canonical target share ONE cache slot (STRUCTURED path)", async () => {
if (!LT_POSIX) return;
const dir = ltMkdir(); const fake = ltFakeJson(dir); const counter = join(dir, "spawns.txt");
const { child, buf } = ltBoot({ CLAUDE_BIN: fake, CLAUDE_PROXY_PORT: "39361", CLAUDE_CACHE_TTL: "60000", SP_COUNTER: counter }, dir);
try {
assert.ok(await ltWait(() => buf.out.includes("listening on")), `did not start: ${buf.err.slice(0, 200)}`);
_ltWrite(counter, "0");
const rf = { type: "json_schema", json_schema: { name: "probe", schema: LT_SCHEMA } };
const msgs = [{ role: "user", content: "structured-alias-probe" }];
await ltPost(39361, { model: "sonnet", messages: msgs, response_format: rf });
await ltWait(() => (Number(_ltRead(counter, "utf8")) || 0) >= 1, 4000);
await ltPost(39361, { model: "claude-sonnet-5", messages: msgs, response_format: rf });
await new Promise(r => setTimeout(r, 600));
assert.equal(Number(_ltRead(counter, "utf8")) || 0, 1,
"structured cache key must resolve the alias too — this is the path the epoch-only fix missed");
} finally { child.kill("SIGKILL"); _ltRm(dir, { recursive: true, force: true }); }
});
// MODEL_MAP is models[] + aliases + legacyAliases, so resolving covers legacyAliases for free.
// The three tests above all use `sonnet` (a plain alias); this pins the legacyAlias leg explicitly
// rather than leaving it covered only by construction.
test("integration: a legacyAlias shares ONE cache slot with its canonical target", async () => {
if (!LT_POSIX) return;
const dir = ltMkdir(); const fake = ltFake(dir); const counter = join(dir, "spawns.txt");
const { child, buf } = ltBoot({ CLAUDE_BIN: fake, CLAUDE_PROXY_PORT: "39364", CLAUDE_CACHE_TTL: "60000", SP_COUNTER: counter }, dir);
try {
assert.ok(await ltWait(() => buf.out.includes("listening on")), `did not start: ${buf.err.slice(0, 200)}`);
_ltWrite(counter, "0");
const msgs = [{ role: "user", content: "legacy-alias-probe" }];
await ltPost(39364, { model: "claude-haiku-4-5", messages: msgs }); // legacyAlias
await ltWait(() => (Number(_ltRead(counter, "utf8")) || 0) >= 1, 3000);
await ltPost(39364, { model: "claude-haiku-4-5-20251001", messages: msgs }); // canonical
await new Promise(r => setTimeout(r, 600));
assert.equal(Number(_ltRead(counter, "utf8")) || 0, 1,
"legacyAliases live in MODEL_MAP too — resolving must collapse them onto the canonical slot");
} finally { child.kill("SIGKILL"); _ltRm(dir, { recursive: true, force: true }); }
});
test("integration: a config change invalidates the STRUCTURED cache too (closes the #177 gap)", async () => {
if (!LT_POSIX) return;
const dir = ltMkdir(); const fake = ltFakeJson(dir); const counter = join(dir, "spawns.txt");
const rf = { type: "json_schema", json_schema: { name: "probe", schema: LT_SCHEMA } };
const req = { model: "sonnet", messages: [{ role: "user", content: "structured-epoch-probe" }], response_format: rf };
const bootOnce = async (env, port) => {
const { child, buf } = ltBoot({ CLAUDE_BIN: fake, CLAUDE_PROXY_PORT: String(port), CLAUDE_CACHE_TTL: "60000", SP_COUNTER: counter, ...env }, dir);
try {
assert.ok(await ltWait(() => buf.out.includes("listening on")), `did not start: ${buf.err.slice(0, 200)}`);
_ltWrite(counter, "0");
await ltPost(port, req);
await ltWait(() => (Number(_ltRead(counter, "utf8")) || 0) >= 1, 4000);
return Number(_ltRead(counter, "utf8")) || 0;
} finally { child.kill("SIGKILL"); }
};
try {
const off = await bootOnce({}, 39362); // caches under epoch(negative wrapper)
const on = await bootOnce({ OCP_LOCAL_TOOLS: "1" }, 39363); // same DB, epoch differs → must re-spawn
assert.equal(off, 1, "first structured request (cache empty) must spawn claude");
assert.equal(on, 1, "structured cache must honor CONFIG_EPOCH — before #194 it omitted the epoch entirely and served the stale answer");
} finally { _ltRm(dir, { recursive: true, force: true }); }
});
// ── Upgrade Tests ──
import { runUpgrade, postFlightOk } from "./scripts/upgrade.mjs";
@@ -4067,6 +4257,10 @@ test("models.json aliases.sonnet === 'claude-sonnet-5' (default-request-model SP
assert.equal(_spotModels.aliases.sonnet, "claude-sonnet-5");
});
test("models.json aliases.opus === 'claude-opus-5' (opus-alias SPOT)", () => {
assert.equal(_spotModels.aliases.opus, "claude-opus-5");
});
// ── 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
@@ -4080,6 +4274,25 @@ test("models.json: claude-sonnet-5 is present in models[] (the entry this PR add
assert.ok(_spotModelIds.has("claude-sonnet-5"), "claude-sonnet-5 must exist as a models[].id");
});
test("models.json: claude-opus-5 is present in models[] (the entry this PR adds)", () => {
assert.ok(_spotModelIds.has("claude-opus-5"), "claude-opus-5 must exist as a models[].id");
});
// The prompt-char budget is GLOBAL (max across every entry × 3 chars/token), not
// per-model — see lib/prompt.mjs derivePromptCharBudget. An entry declaring a native 1M
// window would therefore raise the truncation ceiling for claude-haiku-4-5 too (genuinely
// 200k), turning OCP-side truncation into an upstream API rejection.
//
// Asserts the MAX, deliberately, not every entry: ADR 0009 states the budget "scales
// automatically — no code change", so a future entry with a SMALLER window (say a 128k
// model) must stay legal and must not fail this suite. Only raising the ceiling is the
// hazard, and that is an ADR-level decision requiring per-model budgets first.
test("models.json: max contextWindow is 200000 (global prompt-budget ceiling)", () => {
const windows = _spotModels.models.map(m => m.contextWindow);
assert.equal(Math.max(...windows), 200000,
`max contextWindow re-scales MAX_PROMPT_CHARS for ALL models incl. the 200k-native haiku (see lib/prompt.mjs + ADR 0009)`);
});
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)`);