mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-27 16:05:07 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a17b6c0ad2 | ||
|
|
92787dcced | ||
|
|
3dc023e596 | ||
|
|
7f15921f69 | ||
|
|
c180987376 | ||
|
|
f2f9058db4 | ||
|
|
a99395ed0c |
@@ -0,0 +1,209 @@
|
||||
name: flake hunt (#203)
|
||||
|
||||
# Manual only. This never runs on push or pull_request, so it costs nothing until asked for.
|
||||
#
|
||||
# #203 has been seen four times, always on Linux CI, never on macOS. This is the instrument
|
||||
# for reproducing it deliberately instead of by chance on someone else's PR.
|
||||
#
|
||||
# WHAT IS AND IS NOT KNOWN ABOUT THE NOISE FLOOR — read before interpreting any result.
|
||||
# An earlier Linux VM attempt was reported as invalidated by Node 22's `node:sqlite`
|
||||
# ExperimentalWarning. That attribution was WRONG and is corrected here so nobody acts on it:
|
||||
# the boot predicate is `buf.out.includes("listening on") || buf.exit != null` — stdout only.
|
||||
# `buf.err` appears in the assertion MESSAGE, never in the condition, so a stderr warning
|
||||
# cannot fail that assertion. It was visible in the failure text and mistaken for the cause.
|
||||
# The real noise floor was pre-#204 fixed ports: that branch's control arm measured 246
|
||||
# EADDRINUSE and only 42/200 clean runs on unmodified main. #204 removed it. Node 22 is
|
||||
# therefore a PERFECTLY USABLE arm — the suite passes 462/0 under it — and is offered below.
|
||||
#
|
||||
# Concurrency is the knob, not round count: test() is fire-and-forget for async bodies, so one
|
||||
# suite run peaks at ~11-12 concurrent server.mjs children (15 total spawns; the gate test's
|
||||
# 3 cases and the 2 epoch boots are awaited serially, so they never overlap). Several suites
|
||||
# at once is what multiplies cross-process contention. See AGENTS.md § "Testing: reaching
|
||||
# faults inside server.mjs".
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: 'Branch, tag, or FULL 40-char SHA. Pre-#204 control: c180987376aecca9a90dcec3605cbe1abe48ebf0'
|
||||
default: ''
|
||||
node:
|
||||
description: 'Node major version'
|
||||
default: '24'
|
||||
type: choice
|
||||
options: ['24', '22', '25', '26']
|
||||
rounds:
|
||||
description: 'Rounds; each round runs <concurrency> suites at once and waits'
|
||||
default: '50'
|
||||
type: number
|
||||
concurrency:
|
||||
description: 'Concurrent suite processes per round (this is the knob that reproduces)'
|
||||
default: '4'
|
||||
type: number
|
||||
|
||||
# `ref` exists because a null result on current main is UNINTERPRETABLE. #204 may already have
|
||||
# fixed #203 — if it did, 0/200 on main cannot be told apart from "didn't hunt hard enough" or
|
||||
# "wrong configuration". The positive control is the commit BEFORE #204:
|
||||
#
|
||||
# c180987376aecca9a90dcec3605cbe1abe48ebf0 (7f15921^, i.e. #207)
|
||||
#
|
||||
# Reproducing there and not on main establishes both the mechanism and the fix. Hunt the
|
||||
# control first. NOTE: actions/checkout does NOT rev-parse — it takes a branch, a tag, or a
|
||||
# FULL 40-char SHA. `7f15921^` and even the abbreviated `7f15921` both fail to resolve
|
||||
# (`git ls-remote origin '7f15921^'` returns 0 rows), so the full SHA is spelled out above.
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Deliberately NOT keyed on github.ref: the whole point is comparing a control arm against main,
|
||||
# and two hunts running at once contend for the same runner class — the one variable this
|
||||
# experiment exists to hold still. A bare group serializes dispatches from any branch.
|
||||
concurrency:
|
||||
group: flake-hunt
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
hunt:
|
||||
name: 'hunt: node ${{ inputs.node }} x ${{ inputs.rounds }} x ${{ inputs.concurrency }}'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ inputs.node }}
|
||||
|
||||
- name: Record the environment
|
||||
run: |
|
||||
set +e -u -o pipefail
|
||||
{
|
||||
echo "ref $(git rev-parse HEAD) ($(git log -1 --format=%s | cut -c1-60))"
|
||||
echo "node $(node --version)"
|
||||
echo "kernel $(uname -srm)"
|
||||
echo "cpus $(nproc)"
|
||||
echo "ephemeral $(cat /proc/sys/net/ipv4/ip_local_port_range)"
|
||||
} | tee env.txt
|
||||
|
||||
- name: Hunt
|
||||
timeout-minutes: 50
|
||||
env:
|
||||
# Inputs go through env, never interpolated into the script body: GitHub documents
|
||||
# `inputs.*` as untrusted, and a free-text field spliced into shell is injectable.
|
||||
ROUNDS: ${{ inputs.rounds }}
|
||||
CONC: ${{ inputs.concurrency }}
|
||||
run: |
|
||||
set +e -u -o pipefail # a failing suite run is the DATA, not a step failure
|
||||
mkdir -p logs
|
||||
for r in $(seq 1 "$ROUNDS"); do
|
||||
for c in $(seq 1 "$CONC"); do
|
||||
# Per-run timeout: without it a single hung child blocks `wait` until the step
|
||||
# timeout and the whole hunt yields nothing.
|
||||
# -k follows SIGTERM with SIGKILL. Do NOT add --foreground: by default timeout
|
||||
# makes itself the process-group leader and signals the GROUP, so the node child
|
||||
# and the server.mjs grandchildren die with it; --foreground turns that off
|
||||
# ("children of COMMAND will not be timed out", coreutils). Measured, 3 runs each:
|
||||
# default -> 0 survivors; --foreground -> 2 survivors. Orphans would then keep
|
||||
# competing for the runner's 4 vCPUs and inflate the very contention this
|
||||
# experiment exists to hold still.
|
||||
timeout -k 10 300 npm test > "logs/r${r}c${c}.log" 2>&1 &
|
||||
done
|
||||
wait
|
||||
printf '.'
|
||||
done
|
||||
echo
|
||||
|
||||
- name: Classify
|
||||
if: always()
|
||||
run: |
|
||||
# `set +e` is load-bearing, NOT decoration. GitHub's default shell is `bash -e {0}`,
|
||||
# and `set -uo pipefail` does not turn errexit off — it only ADDS pipefail. Every
|
||||
# category that counts ZERO makes grep exit 1, pipefail propagates it, and errexit
|
||||
# kills the step before it writes any summary. The all-clean case — the one this
|
||||
# workflow most wants to report — dies first. Verified: identical script exits 1 with
|
||||
# 0 bytes of summary under `bash -e`, and 0 with a full summary under `set +e`.
|
||||
set +e -u -o pipefail
|
||||
|
||||
total=$(ls logs/*.log 2>/dev/null | wc -l | tr -d ' ')
|
||||
clean=$(grep -l ', 0 failed ===' logs/*.log 2>/dev/null | wc -l | tr -d ' ')
|
||||
# A round killed by the step timeout leaves truncated logs with no Results line. Those
|
||||
# count in `total` but can never be clean, which silently depresses the clean rate and
|
||||
# looks like a worse flake than reality. Report completions separately.
|
||||
completed=$(grep -l '=== Results:' logs/*.log 2>/dev/null | wc -l | tr -d ' ')
|
||||
|
||||
# #203's SIGNATURE, not just its test name. A CPU-starved runner (4 vCPU carrying
|
||||
# ~44 node processes at concurrency 4) can blow the 9s ltWait and produce the same
|
||||
# "✗ boot gate REFUSES" line with closed=false / (still open). That is contention,
|
||||
# not #203. The real one is: the child CLOSED, exited non-zero, and stderr was EMPTY.
|
||||
#
|
||||
# This pattern can only match a FUTURE reproduction. All four historic sightings predate
|
||||
# #204's ltDiag — their text is `expected a local-tools FATAL, got: ` with no `closed=`
|
||||
# and no `stderr(0B)` at all — so do NOT try to validate this grep against them and
|
||||
# conclude it is broken.
|
||||
# The `expected a local-tools FATAL` clause is not decoration: it pins the match to the
|
||||
# THIRD assertion in that test. Without it, `exit=0` is swallowed by `.*` and the SECOND
|
||||
# assertion ("must exit non-zero" — the gate did not refuse at all, the OPPOSITE failure)
|
||||
# scores as a #203 signature. Verified: the loose pattern matches both exit=0 and exit=1;
|
||||
# this one matches only exit=1.
|
||||
sig=$(grep -l '✗.*boot gate REFUSES.*expected a local-tools FATAL.*closed=true.*stderr(0B)' logs/*.log 2>/dev/null | wc -l | tr -d ' ')
|
||||
gate=$(grep -l '✗.*boot gate REFUSES' logs/*.log 2>/dev/null | wc -l | tr -d ' ')
|
||||
|
||||
{
|
||||
echo "## flake hunt — node ${{ inputs.node }}"
|
||||
echo
|
||||
echo '```'
|
||||
cat env.txt 2>/dev/null || echo "(env.txt missing — the Record step did not run)"
|
||||
echo '```'
|
||||
echo
|
||||
echo "| | runs |"
|
||||
echo "|---|---|"
|
||||
echo "| **clean (0 failed)** | **$clean / $completed completed** |"
|
||||
echo "| suites launched (a shortfall = step timeout, not a flake) | $total |"
|
||||
echo "| #203 **signature** (gate + closed=true + stderr 0B) | **$sig** |"
|
||||
echo "| gate test failed, any cause (includes contention) | $gate |"
|
||||
echo
|
||||
echo "\`sig\` is the number that answers #203. \`gate\` minus \`sig\` is runner contention."
|
||||
echo "A zero is not proof of absence — only a non-zero count is evidence."
|
||||
echo
|
||||
# Full histogram instead of a hand-maintained category list: a category table
|
||||
# silently drops every failure nobody thought to enumerate, and on a hunt the
|
||||
# unenumerated failure is exactly the interesting one.
|
||||
# Bucket on a fixed-width prefix of the test NAME, not on `sed 's/:.*//'` — test
|
||||
# names contain colons, so cutting at the first one collapses every
|
||||
# `localToolsSafetyError: <case>` into one useless bucket. 72 chars keeps the
|
||||
# cases apart while still stripping the per-run assertion detail.
|
||||
echo "### every failing assertion, by test"
|
||||
echo '```'
|
||||
grep -h '✗' logs/*.log 2>/dev/null | sed 's/^ *✗ //' | cut -c1-72 | sort | uniq -c | sort -rn | head -25
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# Prefer a log carrying the actual signature; fall back to any failing log.
|
||||
# INVARIANT for anyone extending this step: the `&&` list below returns 1 when $first
|
||||
# is non-empty, so it must never be the LAST command in the script or the step exits
|
||||
# non-zero. Today the `if` that follows returns 0 and absorbs it.
|
||||
first=$(grep -l '✗.*boot gate REFUSES.*expected a local-tools FATAL.*closed=true.*stderr(0B)' logs/*.log 2>/dev/null | head -1)
|
||||
[ -z "$first" ] && first=$(grep -L ', 0 failed ===' logs/*.log 2>/dev/null | head -1)
|
||||
if [ -n "$first" ]; then
|
||||
{
|
||||
echo "### $first"
|
||||
echo '```'
|
||||
grep -h '✗' "$first" | head -20
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
- name: Upload logs
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: flake-hunt-node${{ inputs.node }}-${{ github.run_attempt }}
|
||||
path: |
|
||||
logs/
|
||||
env.txt
|
||||
retention-days: 14
|
||||
|
||||
# This job does NOT fail on a reproduction. Reproducing is the goal, and a red X would
|
||||
# read as "the hunt is broken" rather than "the flake was caught".
|
||||
@@ -21,24 +21,31 @@ jobs:
|
||||
- name: Extract CHANGELOG section
|
||||
id: notes
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="${{ steps.ver.outputs.version }}"
|
||||
NOTES=/tmp/release-notes.md
|
||||
# Extract section for this version from CHANGELOG.md
|
||||
# Pattern: "## v${VERSION}" through the next "## " or EOF
|
||||
if [ ! -f CHANGELOG.md ]; then
|
||||
echo "No CHANGELOG.md found; using minimal release notes"
|
||||
echo "notes=Release v${VERSION}" >> $GITHUB_OUTPUT
|
||||
# MUST write the file, not just an output: the create step consumes a FILE, so an
|
||||
# early exit here used to leave --notes-file pointing at a path that never existed,
|
||||
# turning "degrade to minimal notes" into a failed release job (#202).
|
||||
echo "Release v${VERSION}" > "$NOTES"
|
||||
echo "notes_file=$NOTES" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
awk -v ver="v${VERSION}" '
|
||||
$0 ~ "^## " ver { found=1; print; next }
|
||||
found && /^## v/ { exit }
|
||||
found { print }
|
||||
' CHANGELOG.md > /tmp/release-notes.md
|
||||
if [ ! -s /tmp/release-notes.md ]; then
|
||||
' CHANGELOG.md > "$NOTES"
|
||||
if [ ! -s "$NOTES" ]; then
|
||||
echo "No matching section in CHANGELOG for v${VERSION}; using minimal notes"
|
||||
echo "Release v${VERSION}" > /tmp/release-notes.md
|
||||
echo "Release v${VERSION}" > "$NOTES"
|
||||
fi
|
||||
echo "notes_file=/tmp/release-notes.md" >> $GITHUB_OUTPUT
|
||||
echo "--- release notes (${VERSION}) ---"; cat "$NOTES"
|
||||
echo "notes_file=$NOTES" >> $GITHUB_OUTPUT
|
||||
- name: Create GitHub Release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -49,5 +56,5 @@ jobs:
|
||||
fi
|
||||
gh release create "v${{ steps.ver.outputs.version }}" \
|
||||
--title "v${{ steps.ver.outputs.version }}" \
|
||||
--notes-file /tmp/release-notes.md \
|
||||
--notes-file "${{ steps.notes.outputs.notes_file }}" \
|
||||
--latest
|
||||
|
||||
@@ -30,6 +30,7 @@ Runtime: Node.js (ESM, `.mjs` throughout). No build step. No bundler. `server.mj
|
||||
|
||||
- `server.mjs` — the proxy itself; every request path lives here. Governed by `ALIGNMENT.md`.
|
||||
- `models.json` — single source of truth for model IDs, aliases, and context windows. See ADR 0003.
|
||||
- `models.schema.json` — the schema `models.json` declares in its `$schema`. CI validates the SPOT against it (`test-features.mjs`) using the repo's own `validateJsonSchema`, so a malformed entry fails the build instead of surfacing downstream in OpenClaw.
|
||||
- `setup.mjs` — first-time installer; reads `models.json` to derive bootstrap config.
|
||||
- `scripts/sync-openclaw.mjs` — idempotent OpenClaw registry sync invoked by `ocp update`. See ADR 0004.
|
||||
- `ocp` — user-facing CLI (install, update, start, stop, status, logs, etc.).
|
||||
@@ -52,6 +53,22 @@ Runtime: Node.js (ESM, `.mjs` throughout). No build step. No bundler. `server.mj
|
||||
|
||||
---
|
||||
|
||||
## Testing: reaching faults inside `server.mjs`
|
||||
|
||||
`test-features.mjs` cannot `import` `server.mjs` (it calls `server.listen()` at top level), and that has twice led to the wrong conclusion that a class of bug is untestable. It isn't. Read this before writing "no regression test is possible here".
|
||||
|
||||
**There is a real live-server fixture.** `ltBoot(env, dir, nodeArgs)` (around `test-features.mjs:990`) spawns the actual `server.mjs` as a child with a **fake `claude` binary**, so integration tests cost no quota. `ltPost` / `ltPostStatus` / `ltWait` / `ltFreePort` round it out. It already covers boot gates, cache-epoch invalidation across two boots sharing one SQLite store, and system-prompt capture.
|
||||
|
||||
**`--stack-size` is a fault lever.** `ltBoot`'s third argument passes V8 flags to the child, which puts recursion- and argument-count-limited failures in reach at a much smaller input. `#193` needed a *synchronous throw* deep inside `spawnClaudeProcess`; `buildCliArgs` does `args.push("--allowedTools", ...ALLOWED_TOOLS)`, and under `--stack-size=200` that spread throws at ~24k elements instead of ~124k — which is what brings the trigger under Linux's `MAX_ARG_STRLEN` (131072 bytes for a single env string) so the test runs in CI rather than skipping. **No production fault hook was needed.**
|
||||
|
||||
Three rules that made it hold up, all learned the hard way:
|
||||
|
||||
- **Discover the threshold in a child under the same flags**, never in the test process — the parent's stack is not the one that matters.
|
||||
- **Assert that the fault actually fired**, not just that the outcome looks right. `#193` asserts HTTP 500 *and* that the body carries `call stack size exceeded`; a control mutation (trigger neutered, bug still present) proves the test fails rather than passing vacuously.
|
||||
- **Wait for the thing you are about to assert**, not a proxy for it. Waiting on `listening on` and then asserting a different line is a race (`#199`); waiting for the process to *exit* and then reading its `stderr` is another, because a terminated child's pipes may still hold unread data (`#203` — wait for the stdio to close, not for the exit).
|
||||
|
||||
Allocate ports with `ltFreePort()`. Fixed ports have caused at least one flake here.
|
||||
|
||||
## Release protocol
|
||||
|
||||
OCP follows the machine-readable `release_kit:` overlay in `CLAUDE.md` (Iron Rule 5.5). Before any version bump or tag push, re-read that YAML block and walk every item in `new_feature_doc_expectations` and `bootstrap_quirk_policy`. Tag push triggers `.github/workflows/release.yml`, which creates the GitHub Release automatically — do not create the release manually.
|
||||
|
||||
@@ -1,5 +1,33 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
## v3.26.0 — 2026-07-27
|
||||
|
||||
Maintenance release. One user-visible change — advertised `maxTokens` now tells the truth — and four rounds of repairs to the machinery that is supposed to catch mistakes: a schema for the model SPOT, a release-job bug that would have shipped an empty release body, and the integration-test harness that had been failing 4 runs in 5 without anyone noticing.
|
||||
|
||||
Every PR carried an independent fresh-context reviewer (Iron Rule 10). Two reviewers refuted the author's stated rationale while the change itself stood; in both cases the rationale was rewritten rather than the finding waved off, and one reviewer retracted its own earlier finding after the evidence was re-derived. `server.mjs` is untouched in this release, so no `cli.js` citation applies.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`maxTokens` now matches the CLI registry (#195).** The previous values were not uniform: six entries were 16384 and `claude-haiku-4-5-20251001` was 8192. Every Opus entry and `claude-sonnet-5` go to **64000**, `claude-sonnet-4-6` and `claude-haiku-4-5-20251001` to **32000** — the `max_output_tokens.default` each model declares in the compiled CLI 2.1.220 registry. Every model except `claude-sonnet-4-6` (1.95x) moves by the same 3.91x — haiku included, from its lower base. 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).
|
||||
|
||||
### Added
|
||||
|
||||
- **`models.schema.json` — the model SPOT now has a schema, enforced in CI (#196).** `models.json` declares it via `$schema`, and `test-features.mjs` validates the SPOT against it using the repo's **own** `validateJsonSchema` from `lib/structured-output.mjs` — no new dependency. A malformed entry now fails the build instead of surfacing downstream in OpenClaw. The schema's description names exactly which keywords that validator enforces and which it **silently ignores** (`minimum`, `maxLength`, `pattern`, `uniqueItems`, …), so nobody adds a constraint that buys nothing; those go in `test-features.mjs` instead. Guard tests cover 8 distinct corruptions plus uniqueness and whitespace on every name field.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`release.yml` would have produced an empty release body on any repo state without `CHANGELOG.md` (#202).** The no-changelog branch set an output pointing at a notes file it never wrote, and the create step then read a missing path. Rare, but it fails exactly when you least want it to — during a release. The branch now writes the file.
|
||||
- **The `ltBoot` integration harness was failing 4 runs in 5 (#199, #209).** Measured with a control arm — full suite × 4 concurrent × 50 rounds against unmodified `main` and against the fix — clean runs went **42/200 → 200/200**, with `EADDRINUSE` **246 → 0**. Four distinct races: two tests gated on a stdout marker and then asserted on a **stderr** line written 12 `console.log` calls later (different pipes, nothing ordering them); every fixed port replaced with `ltFreePort()` (the old 39321–39364 range sits *inside* Linux's default ephemeral range, so CI was more exposed than a Mac); `close` instead of `exit` where a test reads a buffer after termination; and retrying teardown against grandchildren still writing into the temp dir. **#203 is NOT fixed and remains open** — it has never reproduced on macOS (0 across both 200-run arms) and all four sightings are Linux CI, one of them on #205 inside this very release. #211 adds a manual `workflow_dispatch` harness to hunt it on Linux with a pre-#204 positive control. Diagnostics now print exit/signal/closed/elapsed/Node version plus a head+tail sample of both streams — sized so the sample provably reaches the one line that distinguishes "booted" from "refused", with a test pinning that budget so it cannot silently degrade.
|
||||
|
||||
### Internal
|
||||
|
||||
- **Guard comments on the asymmetric cache-key construction (#200)** — `structuredHash` and `dedupKey` carry deliberately different guards, and the resemblance invites a "cleanup" that would collapse them. Now documented at the site.
|
||||
- **`AGENTS.md` § "Testing: reaching faults inside `server.mjs`" (#197)** — writes up the fault-injection method these fixes needed, including the `--stack-size` lever and why running a flaky scenario *in isolation* removes the very concurrency that causes it.
|
||||
- **`ocp-connect` documentation corrections.** Its family table is the floor over each family's *current* `models.json` members — not over the registry family — and its unknown-id fallback stays at 8192, the registry's global minimum. Both had comments asserting otherwise. Its model table and fallback remain **untested**; tracked as #210.
|
||||
- **Known gap, deliberately not fixed here: `contextWindow` does *not* match the registry (#213).** The same CLI 2.1.220 bundle declares `window:1e6, native_1m:true` for `claude-opus-5`/`-4-8`/`-4-7` and `claude-sonnet-5`, while `models.json` says 200000. Unlike `maxTokens`, this cannot be corrected as metadata: `derivePromptCharBudget` takes `max(contextWindow) × 3` across **all** entries, so one 1M model would raise `MAX_PROMPT_CHARS` from 600k to 3M for every model — including `claude-haiku-4-5-20251001`, which really is 200k native — turning clean OCP-side truncation into upstream API rejections. Fixing it needs per-model prompt budgets (ADR-level). Recorded so this release's "the SPOT tells the truth" claim is not read as covering it.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -177,7 +177,7 @@ Any tool use happens server-side, under the `--allowedTools` set configured on t
|
||||
| `claude-sonnet-4-6` | Previous Sonnet, retained for pinning |
|
||||
| `claude-haiku-4-5-20251001` | Fastest, lightweight (default for `haiku` alias) |
|
||||
|
||||
The canonical list lives in [`models.json`](./models.json) — the single source of truth as of v3.11.0. Both `server.mjs` (the `/v1/models` endpoint) and `setup.mjs` (the OpenClaw registration) derive from it. Adding a new model is now a one-file edit:
|
||||
The canonical list lives in [`models.json`](./models.json) — the single source of truth as of v3.11.0, validated in CI against [`models.schema.json`](./models.schema.json). Both `server.mjs` (the `/v1/models` endpoint) and `setup.mjs` (the OpenClaw registration) derive from it. Adding a new model is now a one-file edit:
|
||||
|
||||
```bash
|
||||
# 1. Edit models.json — add an entry
|
||||
|
||||
+7
-7
@@ -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": {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/dtzp555-max/ocp/blob/main/models.schema.json",
|
||||
"title": "OCP models.json",
|
||||
"description": "Schema for models.json, the single source of truth for model metadata (ADR 0003). Enforced in CI by test-features.mjs, which validates models.json against this file using the repo's own validateJsonSchema (lib/structured-output.mjs) — no new dependency. ⚠ ONLY A SUBSET OF DRAFT 2020-12 IS ENFORCED: type, required, const, enum, additionalProperties (boolean and schema forms), items, minItems, maxItems, anyOf/allOf/oneOf, $ref, nullable. Anything else — minimum, maxLength, pattern, uniqueItems, propertyNames, not — is SILENTLY IGNORED by that validator, so adding one here buys nothing and misleads the next reader. Add such a constraint as a test in test-features.mjs instead. NOTE: referential integrity (every aliases/legacyAliases target must exist as a models[].id) is likewise not expressible here and is covered by separate tests.",
|
||||
"type": "object",
|
||||
"required": ["version", "models", "aliases", "legacyAliases"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"$schema": {
|
||||
"type": "string",
|
||||
"description": "Relative path to this file. Editors use it for completion; CI does not read it."
|
||||
},
|
||||
"version": {
|
||||
"const": 1,
|
||||
"description": "Schema version of this document. Bump only alongside a shape change and an ADR."
|
||||
},
|
||||
"models": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"description": "Every model OCP advertises on /v1/models, newest first. Order is load-bearing: ocp-connect uses model_ids[0] as a primary fallback.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["id", "displayName", "openclawName", "reasoning", "contextWindow", "maxTokens"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Canonical CLI model id, passed verbatim to `claude --model`. Must match the id in the compiled CLI registry."
|
||||
},
|
||||
"displayName": {
|
||||
"type": "string",
|
||||
"description": "Human label. Also used as the OpenClaw agent alias by scripts/sync-openclaw.mjs."
|
||||
},
|
||||
"openclawName": {
|
||||
"type": "string",
|
||||
"description": "Label written into OpenClaw's model registry."
|
||||
},
|
||||
"reasoning": {
|
||||
"type": "boolean",
|
||||
"description": "Whether OpenClaw should treat the model as reasoning-capable."
|
||||
},
|
||||
"contextWindow": {
|
||||
"type": "integer",
|
||||
"description": "Advertised context window. GLOBAL side effect: MAX_PROMPT_CHARS derives from max(contextWindow) x 3 across ALL entries (lib/prompt.mjs derivePromptCharBudget), so raising this on ONE model raises the truncation ceiling for EVERY model. See ADR 0009. Also feeds OpenClaw's compaction budget."
|
||||
},
|
||||
"maxTokens": {
|
||||
"type": "integer",
|
||||
"description": "Advertised output cap. OCP does not enforce it: buildCliArgs passes no output-token flag to the CLI. It is propagated to OpenClaw (via setup.mjs / scripts/sync-openclaw.mjs) where it bounds REQUEST budgets only. It does NOT affect compaction: both the compaction trigger and the summarisation chunk size derive from contextWindow (minus reserveTokensFloor / softThreshold), never from this field. Verified against OpenClaw 2026.7.1."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"aliases": {
|
||||
"type": "object",
|
||||
"description": "Short name -> canonical models[].id. Client-addressable, so a repoint changes routing for every request using the alias. Cache keys resolve these before hashing (server.mjs cacheModel).",
|
||||
"additionalProperties": { "type": "string" }
|
||||
},
|
||||
"legacyAliases": {
|
||||
"type": "object",
|
||||
"description": "Retired ids kept resolvable for backward compatibility -> canonical models[].id. Also client-addressable and also resolved in cache keys.",
|
||||
"additionalProperties": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
+25
-3
@@ -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:
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "open-claude-proxy",
|
||||
"version": "3.25.0",
|
||||
"version": "3.26.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": {
|
||||
|
||||
+12
@@ -2923,6 +2923,16 @@ async function handleChatCompletions(req, res) {
|
||||
const t0s = Date.now();
|
||||
const promptCharsS = messages.reduce((a, m) => a + contentToText(m.content).length, 0);
|
||||
let structuredHash = null;
|
||||
// DO NOT collapse this with `dedupKey` below (#200). The two cacheHash calls take IDENTICAL
|
||||
// arguments and look like obvious duplicate work — they are not interchangeable, because
|
||||
// their GUARDS differ: this one additionally requires CACHE_TTL > 0. CLAUDE_CACHE_TTL
|
||||
// DEFAULTS TO 0, so in the default configuration structuredHash is null while dedupKey must
|
||||
// still be computed — it drives #153's single-flight stampede protection, which is
|
||||
// deliberately independent of whether response caching is on. `dedupKey = structuredHash`
|
||||
// would therefore silently disable stampede protection by default, in exactly the
|
||||
// concurrent-AI-Task case it exists to bound. The duplicate call is the honest price of the
|
||||
// asymmetry. If you do deduplicate it, compute once under the WEAKER guard and derive the
|
||||
// cache lookup under the stronger one — and add a stampede test before you do.
|
||||
if (CACHE_TTL > 0 && !conversationId && !hasCacheControl(messages)) {
|
||||
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 {
|
||||
@@ -2942,6 +2952,8 @@ async function handleChatCompletions(req, res) {
|
||||
// firing several AI Tasks at once) must NOT each pay N× — they share one flight. We dedup every
|
||||
// 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.
|
||||
// Note the guard here is deliberately WEAKER than structuredHash's — no CACHE_TTL check. See the
|
||||
// do-not-collapse comment above (#200).
|
||||
const dedupKey = (!conversationId && !hasCacheControl(messages))
|
||||
? cacheHash(cacheModel, messages, { keyId: req._authKeyId, temperature: parsed.temperature, max_tokens: parsed.max_tokens, top_p: parsed.top_p, structured, configEpoch: CONFIG_EPOCH })
|
||||
: null;
|
||||
|
||||
+235
-49
@@ -991,17 +991,70 @@ function ltBoot(env, dir, nodeArgs = []) {
|
||||
CLAUDE_BIND: "127.0.0.1", CLAUDE_AUTH_MODE: "none", CLAUDE_CACHE_TTL: "0", CLAUDE_TIMEOUT: "4000", ...env },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
const buf = { out: "", err: "", exit: undefined };
|
||||
const buf = { out: "", err: "", exit: undefined, signal: undefined, closed: false, closeMs: undefined, spawnErr: null, t0: Date.now() };
|
||||
child.stdout.on("data", d => { buf.out += d; });
|
||||
child.stderr.on("data", d => { buf.err += d; });
|
||||
child.on("exit", code => { buf.exit = code; });
|
||||
// 'exit' fires when the process terminates, but its stdio pipes may still hold unread data —
|
||||
// 'close' is the one that guarantees both are drained. A test that terminates the child and
|
||||
// then asserts on buf.err/buf.out must wait for `closed`, not `exit != null`, or it can read
|
||||
// an empty buffer.
|
||||
child.on("exit", (code, signal) => { buf.exit = code; buf.signal = signal; });
|
||||
child.on("close", () => { buf.closed = true; buf.closeMs = Date.now() - buf.t0; });
|
||||
// Without a listener, a spawn 'error' is re-thrown as an uncaught exception and takes down the
|
||||
// whole runner instead of failing one test.
|
||||
child.on("error", e => { buf.spawnErr = e; });
|
||||
return { child, buf };
|
||||
}
|
||||
// child.kill("SIGKILL") kills server.mjs but NOT the fake `claude` grandchildren it spawned, and
|
||||
// those can still be writing sp.txt / spawns.txt into `dir` while rmSync walks it — which surfaced
|
||||
// as an intermittent ENOTEMPTY (4/200 in review). Node's own retry loop handles the window.
|
||||
function _ltRmRetry(dir) {
|
||||
try { _ltRm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); }
|
||||
catch (e) {
|
||||
// Never throw: this runs in a finally, so a throw here would REPLACE the real assertion
|
||||
// error and make a flake look like a regression. LT_DEBUG surfaces it without that risk.
|
||||
if (process.env.LT_DEBUG) console.warn(` [ltRmRetry] ${dir}: ${e.code || e.message}`);
|
||||
}
|
||||
}
|
||||
// Every ltBoot assertion failure should be self-diagnosing. The historical failure text was
|
||||
// `expected a local-tools FATAL, got: ` — an empty string, which says nothing about whether the
|
||||
// child never wrote, wrote to the other stream, died on a signal, or was never spawned.
|
||||
// stdout is sampled HEAD+TAIL, not tail-only. The decisive string for "it booted instead of
|
||||
// refusing" is "Local tools: ON", and it lives in the boot banner — a tail-only sample answered
|
||||
// the wrong question for exactly the tests this exists to diagnose.
|
||||
//
|
||||
// The head is sized to the BANNER, not picked round: measured on this tree, the banner runs 1118B
|
||||
// with "Local tools: ON" at byte 581, so 900 clears it with ~5 banner lines of margin. That sizing
|
||||
// is what makes it robust — the banner is emitted first and is bounded, so however much request
|
||||
// noise follows, byte 581 stays in the head. A head of 120 does NOT reach it (verified: the string
|
||||
// landed in the elided middle), which is why this is not the obvious small window.
|
||||
// stderr stays head-only: a fatal is the first thing it writes.
|
||||
function ltHeadTail(s, head = 900, tail = 160) {
|
||||
return s.length <= head + tail ? s : `${s.slice(0, head)}…[${s.length - head - tail}B]…${s.slice(-tail)}`;
|
||||
}
|
||||
function ltDiag(buf) {
|
||||
// closeMs disambiguates "died before reaching the gate" from "ran, then gated" — an exit=1
|
||||
// with empty stderr is equally consistent with both, and they have unrelated root causes.
|
||||
// node= is here because a Node-version-specific stderr warning (22's SQLite ExperimentalWarning)
|
||||
// once masqueraded as "server did not start" for ~23 of 50 runs on a Linux box.
|
||||
const ms = buf.closeMs !== undefined ? `${buf.closeMs}ms` : `${Date.now() - buf.t0}ms(still open)`;
|
||||
return `exit=${buf.exit} signal=${buf.signal} closed=${buf.closed} closeMs=${ms} node=${process.version}` +
|
||||
(buf.spawnErr ? ` spawnErr=${buf.spawnErr.code || buf.spawnErr.message}` : "") +
|
||||
` | stderr(${buf.err.length}B)=${JSON.stringify(buf.err.slice(0, 240))}` +
|
||||
` | stdout(${buf.out.length}B)=${JSON.stringify(ltHeadTail(buf.out))}`;
|
||||
}
|
||||
async function ltWait(cond, ms = 9000) {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < ms) { if (cond()) return true; await new Promise(r => setTimeout(r, 40)); }
|
||||
return false;
|
||||
}
|
||||
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 ltPost(port, body) {
|
||||
try {
|
||||
await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
|
||||
@@ -1015,29 +1068,31 @@ console.log("\nOCP_LOCAL_TOOLS integration (boot server.mjs):");
|
||||
test("integration: OCP_LOCAL_TOOLS=1 → the -p spawn receives the POSITIVE wrapper (kills the no-op mutation)", async () => {
|
||||
if (!LT_POSIX) return; // sh fake — skip on Windows CI
|
||||
const dir = ltMkdir(); const cap = join(dir, "sp.txt"); const fake = ltFake(dir);
|
||||
const { child, buf } = ltBoot({ OCP_LOCAL_TOOLS: "1", CLAUDE_BIN: fake, CLAUDE_PROXY_PORT: "39321", SP_CAPTURE: cap }, dir);
|
||||
const port = await ltFreePort();
|
||||
const { child, buf } = ltBoot({ OCP_LOCAL_TOOLS: "1", CLAUDE_BIN: fake, CLAUDE_PROXY_PORT: String(port), SP_CAPTURE: cap }, dir);
|
||||
try {
|
||||
assert.ok(await ltWait(() => buf.out.includes("listening on") || buf.exit != null), `server did not start: ${buf.err.slice(0,200)}`);
|
||||
await ltPost(39321, { model: "sonnet", messages: [{ role: "user", content: "hi" }] });
|
||||
await ltPost(port, { model: "sonnet", messages: [{ role: "user", content: "hi" }] });
|
||||
assert.ok(await ltWait(() => _ltExists(cap)), "fake claude was spawned and captured --system-prompt");
|
||||
const sp = _ltRead(cap, "utf8");
|
||||
assert.ok(sp.includes(LT_POS_MARK), `expected POSITIVE wrapper in --system-prompt, got: ${sp.slice(0,90)}`);
|
||||
assert.ok(!sp.includes(LT_NEG_MARK), "positive wrapper must REPLACE the negative one, not append");
|
||||
} finally { child.kill("SIGKILL"); _ltRm(dir, { recursive: true, force: true }); }
|
||||
} finally { child.kill("SIGKILL"); _ltRmRetry(dir); }
|
||||
});
|
||||
|
||||
test("integration: flag OFF → the -p spawn receives the EXACT negative wrapper (default path byte-for-byte)", async () => {
|
||||
if (!LT_POSIX) return;
|
||||
const dir = ltMkdir(); const cap = join(dir, "sp.txt"); const fake = ltFake(dir);
|
||||
const { child, buf } = ltBoot({ CLAUDE_BIN: fake, CLAUDE_PROXY_PORT: "39322", SP_CAPTURE: cap }, dir); // OCP_LOCAL_TOOLS unset
|
||||
const port = await ltFreePort();
|
||||
const { child, buf } = ltBoot({ CLAUDE_BIN: fake, CLAUDE_PROXY_PORT: String(port), SP_CAPTURE: cap }, dir); // OCP_LOCAL_TOOLS unset
|
||||
try {
|
||||
assert.ok(await ltWait(() => buf.out.includes("listening on") || buf.exit != null), `server did not start: ${buf.err.slice(0,200)}`);
|
||||
await ltPost(39322, { model: "sonnet", messages: [{ role: "user", content: "hi" }] });
|
||||
await ltPost(port, { model: "sonnet", messages: [{ role: "user", content: "hi" }] });
|
||||
assert.ok(await ltWait(() => _ltExists(cap)), "fake claude captured --system-prompt");
|
||||
const sp = _ltRead(cap, "utf8");
|
||||
// No system messages + no CLAUDE_SYSTEM_PROMPT → the wrapper is passed verbatim.
|
||||
assert.equal(sp, `You are accessed via the OCP HTTP proxy. You do NOT have access to any local filesystem, working directory, shell, git status, or machine environment. Do not infer or invent such information from any context you observe. Respond only based on the conversation provided.`);
|
||||
} finally { child.kill("SIGKILL"); _ltRm(dir, { recursive: true, force: true }); }
|
||||
} finally { child.kill("SIGKILL"); _ltRmRetry(dir); }
|
||||
});
|
||||
|
||||
test("integration: boot gate REFUSES each unsafe config (multi / non-loopback / anon key)", async () => {
|
||||
@@ -1049,25 +1104,47 @@ test("integration: boot gate REFUSES each unsafe config (multi / non-loopback /
|
||||
{ label: "anon", env: { PROXY_ANONYMOUS_KEY: "pub" } },
|
||||
];
|
||||
try {
|
||||
for (const [i, c] of cases.entries()) {
|
||||
const { child, buf } = ltBoot({ OCP_LOCAL_TOOLS: "1", CLAUDE_BIN: fake, CLAUDE_PROXY_PORT: String(39330 + i), ...c.env }, dir);
|
||||
for (const c of cases) {
|
||||
const port = await ltFreePort();
|
||||
const { child, buf } = ltBoot({ OCP_LOCAL_TOOLS: "1", CLAUDE_BIN: fake, CLAUDE_PROXY_PORT: String(port), ...c.env }, dir);
|
||||
try {
|
||||
assert.ok(await ltWait(() => buf.exit != null), `[${c.label}] expected the process to exit`);
|
||||
assert.notEqual(buf.exit, 0, `[${c.label}] must exit non-zero`);
|
||||
assert.ok(/FATAL[\s\S]*OCP_LOCAL_TOOLS/.test(buf.err), `[${c.label}] expected a local-tools FATAL, got: ${buf.err.slice(0,160)}`);
|
||||
// Wait for `closed`, not `exit`: the assertion below reads buf.err, and stderr is only
|
||||
// guaranteed drained at 'close'. This is the ordering #203 was filed for.
|
||||
assert.ok(await ltWait(() => buf.closed || buf.spawnErr), `[${c.label}] process never closed — ${ltDiag(buf)}`);
|
||||
assert.notEqual(buf.exit, 0, `[${c.label}] must exit non-zero — ${ltDiag(buf)}`);
|
||||
assert.ok(/FATAL[\s\S]*OCP_LOCAL_TOOLS/.test(buf.err), `[${c.label}] expected a local-tools FATAL — ${ltDiag(buf)}`);
|
||||
} finally { child.kill("SIGKILL"); }
|
||||
}
|
||||
} finally { _ltRm(dir, { recursive: true, force: true }); }
|
||||
} finally { _ltRmRetry(dir); }
|
||||
});
|
||||
|
||||
test("integration: safe single-user config BOOTS past the gate and announces local tools", async () => {
|
||||
if (!LT_POSIX) return;
|
||||
const dir = ltMkdir(); const fake = ltFake(dir);
|
||||
const { child, buf } = ltBoot({ OCP_LOCAL_TOOLS: "1", CLAUDE_BIN: fake, CLAUDE_PROXY_PORT: "39340" }, dir); // loopback + none
|
||||
const port = await ltFreePort();
|
||||
const { child, buf } = ltBoot({ OCP_LOCAL_TOOLS: "1", CLAUDE_BIN: fake, CLAUDE_PROXY_PORT: String(port) }, dir); // loopback + none
|
||||
try {
|
||||
assert.ok(await ltWait(() => buf.out.includes("listening on")), `safe config must boot, got: ${buf.err.slice(0,200)}`);
|
||||
assert.ok(buf.out.includes("Local tools: ON"), "startup must announce local tools when active");
|
||||
} finally { child.kill("SIGKILL"); _ltRm(dir, { recursive: true, force: true }); }
|
||||
// Same race as #199, one line over: "Local tools: ON" (server.mjs:3640) is written 12
|
||||
// console.log calls after "listening on" (:3627) — 10 of them in this env, since the
|
||||
// SYSTEM_PROMPT and MCP_CONFIG lines are conditional and unset here. Gating on the boot
|
||||
// marker and then asserting the announcement can therefore read a buffer holding only the
|
||||
// first chunk. Wait for the line actually under assertion. Measured by review at 8/200
|
||||
// before this change and 0/200 after — it was the suite's top flake.
|
||||
assert.ok(await ltWait(() => buf.out.includes("Local tools: ON") || buf.closed || buf.spawnErr),
|
||||
`startup must announce local tools when active — ${ltDiag(buf)}`);
|
||||
assert.ok(buf.out.includes("Local tools: ON"),
|
||||
`startup must announce local tools when active — ${ltDiag(buf)}`);
|
||||
// Nails ltHeadTail's head budget to the thing it exists to capture. Without this the
|
||||
// coupling is silent: every added banner line pushes "Local tools: ON" later (Models:
|
||||
// alone is ~18B per model), and the day it crosses 900 the diagnostic degrades back to
|
||||
// the exact blind spot this PR fixed — with no test going red. Measured offset here is
|
||||
// 581 of a 1118B banner, so the margin is ~17 more models.
|
||||
const _ltOffset = buf.out.indexOf("Local tools: ON");
|
||||
assert.ok(_ltOffset < 900,
|
||||
`ltHeadTail's head budget (900B) no longer reaches the local-tools announcement — it is ` +
|
||||
`now at byte ${_ltOffset}. The banner grew. Raise the head in ltHeadTail, or ltDiag will ` +
|
||||
`silently stop showing the one line that distinguishes "booted" from "refused".`);
|
||||
} finally { child.kill("SIGKILL"); _ltRmRetry(dir); }
|
||||
});
|
||||
|
||||
test("integration: TUI mode → flag is announced INERT (not 'ON'), boot not refused", async () => {
|
||||
@@ -1075,12 +1152,22 @@ test("integration: TUI mode → flag is announced INERT (not 'ON'), boot not ref
|
||||
const dir = ltMkdir(); const fake = ltFake(dir);
|
||||
// Non-loopback would normally trip the local-tools gate; under TUI the flag is inert so the
|
||||
// gate must NOT fire on its behalf. Use loopback here to isolate TUI's own guards from ours.
|
||||
const { child, buf } = ltBoot({ OCP_LOCAL_TOOLS: "1", CLAUDE_TUI_MODE: "true", CLAUDE_BIN: fake, CLAUDE_PROXY_PORT: "39341" }, dir);
|
||||
const port = await ltFreePort();
|
||||
const { child, buf } = ltBoot({ OCP_LOCAL_TOOLS: "1", CLAUDE_TUI_MODE: "true", CLAUDE_BIN: fake, CLAUDE_PROXY_PORT: String(port) }, dir);
|
||||
try {
|
||||
assert.ok(await ltWait(() => buf.out.includes("listening on") || buf.exit != null), `did not start: ${buf.err.slice(0,200)}`);
|
||||
assert.ok(!buf.out.includes("Local tools: ON"), "must NOT claim local tools are ON in TUI mode (the wrapper is unused there)");
|
||||
assert.ok(/ignored in TUI mode/.test(buf.out + buf.err), "must warn that OCP_LOCAL_TOOLS is inert under TUI");
|
||||
} finally { child.kill("SIGKILL"); _ltRm(dir, { recursive: true, force: true }); }
|
||||
// Wait for the line actually under assertion, not for a proxy signal. "listening on" and the
|
||||
// inert-flag warning are written independently, so gating on the former and then asserting
|
||||
// the latter is a race — the flake #199 was filed for. Still bounded by the same timeout, and
|
||||
// the boot markers are kept in the predicate so a failed boot ends the wait immediately
|
||||
// rather than burning it.
|
||||
const ready = await ltWait(() => /ignored in TUI mode/.test(buf.out + buf.err)
|
||||
|| buf.closed || buf.spawnErr);
|
||||
assert.ok(ready, `no inert-flag warning appeared — ${ltDiag(buf)}`);
|
||||
assert.ok(/ignored in TUI mode/.test(buf.out + buf.err),
|
||||
`must warn that OCP_LOCAL_TOOLS is inert under TUI — ${ltDiag(buf)}`);
|
||||
assert.ok(!buf.out.includes("Local tools: ON"),
|
||||
`must NOT claim local tools are ON in TUI mode (the wrapper is unused there) — ${ltDiag(buf)}`);
|
||||
} finally { child.kill("SIGKILL"); _ltRmRetry(dir); }
|
||||
});
|
||||
|
||||
test("integration: toggling OCP_LOCAL_TOOLS invalidates the standard response cache (epoch fold)", async () => {
|
||||
@@ -1098,11 +1185,11 @@ test("integration: toggling OCP_LOCAL_TOOLS invalidates the standard response ca
|
||||
} finally { child.kill("SIGKILL"); }
|
||||
};
|
||||
try {
|
||||
const off = await bootOnce({}, 39350); // caches "OK" under epoch(negative)
|
||||
const on = await bootOnce({ OCP_LOCAL_TOOLS: "1" }, 39351); // same DB, epoch(positive) → must MISS → re-spawn
|
||||
const off = await bootOnce({}, await ltFreePort()); // caches "OK" under epoch(negative)
|
||||
const on = await bootOnce({ OCP_LOCAL_TOOLS: "1" }, await ltFreePort()); // same DB, epoch(positive) → must MISS → re-spawn
|
||||
assert.equal(off, 1, "first request (cache empty) must spawn claude");
|
||||
assert.equal(on, 1, "after toggling the flag the identical request must NOT be served from the old cache (epoch differs → re-spawn)");
|
||||
} finally { _ltRm(dir, { recursive: true, force: true }); }
|
||||
} finally { _ltRmRetry(dir); }
|
||||
});
|
||||
|
||||
// ── active-request counter is paired to the process lifecycle (#180 / #193) ──
|
||||
@@ -1138,13 +1225,6 @@ function ltSpreadThrowCount(stackKb) {
|
||||
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`, {
|
||||
@@ -1191,7 +1271,7 @@ test("integration: a synchronous pre-spawn throw must not leak stats.activeReque
|
||||
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 }); }
|
||||
} finally { child.kill("SIGKILL"); _ltRmRetry(dir); }
|
||||
});
|
||||
|
||||
// ── Cache keys hash the RESOLVED model, not the alias string (#194) ──────────
|
||||
@@ -1219,36 +1299,38 @@ 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);
|
||||
const port = await ltFreePort();
|
||||
const { child, buf } = ltBoot({ CLAUDE_BIN: fake, CLAUDE_PROXY_PORT: String(port), 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 ltPost(port, { 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 ltPost(port, { 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 }); }
|
||||
} finally { child.kill("SIGKILL"); _ltRmRetry(dir); }
|
||||
});
|
||||
|
||||
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);
|
||||
const port = await ltFreePort();
|
||||
const { child, buf } = ltBoot({ CLAUDE_BIN: fake, CLAUDE_PROXY_PORT: String(port), 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 ltPost(port, { 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 ltPost(port, { 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 }); }
|
||||
} finally { child.kill("SIGKILL"); _ltRmRetry(dir); }
|
||||
});
|
||||
|
||||
// MODEL_MAP is models[] + aliases + legacyAliases, so resolving covers legacyAliases for free.
|
||||
@@ -1257,18 +1339,19 @@ test("integration: an alias and its canonical target share ONE cache slot (STRUC
|
||||
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);
|
||||
const port = await ltFreePort();
|
||||
const { child, buf } = ltBoot({ CLAUDE_BIN: fake, CLAUDE_PROXY_PORT: String(port), 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 ltPost(port, { 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 ltPost(port, { 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 }); }
|
||||
} finally { child.kill("SIGKILL"); _ltRmRetry(dir); }
|
||||
});
|
||||
|
||||
test("integration: a config change invalidates the STRUCTURED cache too (closes the #177 gap)", async () => {
|
||||
@@ -1287,11 +1370,11 @@ test("integration: a config change invalidates the STRUCTURED cache too (closes
|
||||
} 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
|
||||
const off = await bootOnce({}, await ltFreePort()); // caches under epoch(negative wrapper)
|
||||
const on = await bootOnce({ OCP_LOCAL_TOOLS: "1" }, await ltFreePort()); // 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 }); }
|
||||
} finally { _ltRmRetry(dir); }
|
||||
});
|
||||
|
||||
// ── Upgrade Tests ──
|
||||
@@ -4299,12 +4382,115 @@ 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:"<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)`);
|
||||
}
|
||||
});
|
||||
|
||||
// ── models.json validates against models.schema.json (#196) ─────────────────
|
||||
// models.json carried `"$schema": "./models.schema.json"` while that file had never been
|
||||
// committed, so the SPOT that ADR 0003 makes canonical had no structural validation at all —
|
||||
// a missing contextWindow or a typo'd openclawName would only surface downstream, in OpenClaw
|
||||
// or in a truncation budget. Validated with the repo's OWN validator (lib/structured-output.mjs,
|
||||
// shipped for #153) rather than a new dependency: zero deps added, and it exercises that
|
||||
// validator on a second real input.
|
||||
//
|
||||
// The schema deliberately does NOT try to express referential integrity (alias -> models[].id);
|
||||
// that is not a JSON Schema concept and is covered by the two tests directly above.
|
||||
import { validateJsonSchema as _spotValidate } from "./lib/structured-output.mjs";
|
||||
|
||||
const _spotSchema = JSON.parse(spotReadFileSync(spotJoin(_spotDir, "models.schema.json"), "utf8"));
|
||||
|
||||
test("models.json: the $schema reference resolves to a committed file", () => {
|
||||
assert.equal(_spotModels.$schema, "./models.schema.json", "models.json must point at the schema");
|
||||
assert.ok(_ltExists(spotJoin(_spotDir, "models.schema.json")),
|
||||
"models.schema.json must exist — a dangling $schema is what #196 was filed for");
|
||||
});
|
||||
|
||||
test("models.json validates against models.schema.json (strict)", () => {
|
||||
const errors = _spotValidate(_spotModels, _spotSchema, "$", true);
|
||||
assert.deepEqual(errors, [], `models.json violates its own schema:\n ${errors.join("\n ")}`);
|
||||
});
|
||||
|
||||
// Three corruptions the SCHEMA structurally cannot catch, asserted directly instead of pretending
|
||||
// the schema covers them: the validator has no uniqueItems, no minLength, and no minimum. Adding
|
||||
// those keywords to the schema would be silently ignored (see its description), so they live here.
|
||||
test("models.json: ids/names are unique, untrimmed-free, and windows are positive (not schema-expressible)", () => {
|
||||
// Uniqueness applies to all three name fields, not just id. scripts/sync-openclaw.mjs maps
|
||||
// `claude-local/<id>` -> { alias: displayName } and writes openclawName as the registry label,
|
||||
// so a duplicate in EITHER collapses two models onto one OpenClaw entry — the same defect class
|
||||
// as a duplicate id, which is why review flagged covering only id as a half-fix.
|
||||
for (const f of ["id", "displayName", "openclawName"]) {
|
||||
const vals = _spotModels.models.map(m => m[f]);
|
||||
const dupes = vals.filter((v, i) => vals.indexOf(v) !== i);
|
||||
assert.equal(new Set(vals).size, vals.length, `duplicate models[].${f}: ${[...new Set(dupes)]}`);
|
||||
}
|
||||
for (const m of _spotModels.models) {
|
||||
for (const f of ["id", "displayName", "openclawName"]) {
|
||||
assert.ok(typeof m[f] === "string" && m[f].length > 0, `${m.id}: ${f} must be a non-empty string`);
|
||||
// === trim(), not trim().length: a padded id passes a trimmed check but is handed VERBATIM
|
||||
// to `claude --model`, so " claude-opus-5" would fail upstream rather than here.
|
||||
assert.equal(m[f], m[f].trim(), `${m.id}: ${f} has leading/trailing whitespace`);
|
||||
}
|
||||
assert.ok(m.contextWindow > 0, `${m.id}: contextWindow must be positive`);
|
||||
assert.ok(m.maxTokens > 0, `${m.id}: maxTokens must be positive`);
|
||||
}
|
||||
});
|
||||
|
||||
// Guards the guard: if the schema were vacuous (e.g. `{}` or a typo'd `properties`), the test
|
||||
// above would pass on anything. Each corruption below must be caught.
|
||||
test("models.schema.json actually rejects malformed entries (guard is not vacuous)", () => {
|
||||
const clone = () => JSON.parse(JSON.stringify(_spotModels));
|
||||
const cases = [
|
||||
["missing required field", m => { delete m.models[0].contextWindow; }],
|
||||
["wrong scalar type", m => { m.models[0].reasoning = "yes"; }],
|
||||
["non-integer window", m => { m.models[0].contextWindow = 200000.5; }],
|
||||
["unknown extra field", m => { m.models[0].tokensPerSecond = 42; }],
|
||||
["alias mapped to non-string", m => { m.aliases.opus = { id: "x" }; }],
|
||||
["empty models array", m => { m.models = []; }],
|
||||
["wrong document version", m => { m.version = 2; }],
|
||||
["unknown top-level key", m => { m.providers = {}; }],
|
||||
];
|
||||
for (const [label, corrupt] of cases) {
|
||||
const bad = clone(); corrupt(bad);
|
||||
const errs = _spotValidate(bad, _spotSchema, "$", true);
|
||||
assert.ok(errs.length > 0, `schema failed to reject: ${label}`);
|
||||
}
|
||||
});
|
||||
|
||||
// ── escapeHtml + key-name validator (issue #114) ────────────────────────────
|
||||
// Replicated verbatim from dashboard.html so tests run without a browser.
|
||||
function escapeHtml(s) {
|
||||
|
||||
Reference in New Issue
Block a user