mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-26 23:45:08 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62c21d883a |
@@ -1,5 +1,11 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **`maxTokens` now matches the CLI registry instead of a uniform 16384 (#195).** Every Opus entry and `claude-sonnet-5` go to **64000**, `claude-sonnet-4-6` and `claude-haiku-4-5` to **32000** — the `max_output_tokens.default` each model actually declares in the compiled CLI 2.1.220 registry. OCP never enforced this value; it is propagated to OpenClaw, where it **caps the outbound request**: `maxTokens = Math.min(baseMaxTokens + thinkingBudget, modelMaxTokens)`. OpenClaw's reasoning budgets are medium 8192 / high 16384, and when `maxTokens <= thinkingBudget` it clamps thinking to `maxTokens - 1024` — so a model declared at 16384 running at **high** reasoning spent ~15360 on thinking and left **~1024 tokens for the actual answer**. `ocp-connect`'s independent family table moves to the family floor (opus 64000, sonnet 32000, haiku 32000) since prefixes cannot distinguish versions. Expect longer answers and correspondingly higher per-request quota burn on long generations.
|
||||||
|
|
||||||
## v3.25.0 — 2026-07-27
|
## 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.
|
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.
|
||||||
|
|||||||
+7
-7
@@ -8,7 +8,7 @@
|
|||||||
"openclawName": "Claude Opus 5 (via CLI)",
|
"openclawName": "Claude Opus 5 (via CLI)",
|
||||||
"reasoning": true,
|
"reasoning": true,
|
||||||
"contextWindow": 200000,
|
"contextWindow": 200000,
|
||||||
"maxTokens": 16384
|
"maxTokens": 64000
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "claude-opus-4-8",
|
"id": "claude-opus-4-8",
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
"openclawName": "Claude Opus 4.8 (via CLI)",
|
"openclawName": "Claude Opus 4.8 (via CLI)",
|
||||||
"reasoning": true,
|
"reasoning": true,
|
||||||
"contextWindow": 200000,
|
"contextWindow": 200000,
|
||||||
"maxTokens": 16384
|
"maxTokens": 64000
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "claude-opus-4-7",
|
"id": "claude-opus-4-7",
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
"openclawName": "Claude Opus 4.7 (via CLI)",
|
"openclawName": "Claude Opus 4.7 (via CLI)",
|
||||||
"reasoning": true,
|
"reasoning": true,
|
||||||
"contextWindow": 200000,
|
"contextWindow": 200000,
|
||||||
"maxTokens": 16384
|
"maxTokens": 64000
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "claude-opus-4-6",
|
"id": "claude-opus-4-6",
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
"openclawName": "Claude Opus 4.6 (via CLI)",
|
"openclawName": "Claude Opus 4.6 (via CLI)",
|
||||||
"reasoning": true,
|
"reasoning": true,
|
||||||
"contextWindow": 200000,
|
"contextWindow": 200000,
|
||||||
"maxTokens": 16384
|
"maxTokens": 64000
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "claude-sonnet-5",
|
"id": "claude-sonnet-5",
|
||||||
@@ -40,7 +40,7 @@
|
|||||||
"openclawName": "Claude Sonnet 5 (via CLI)",
|
"openclawName": "Claude Sonnet 5 (via CLI)",
|
||||||
"reasoning": true,
|
"reasoning": true,
|
||||||
"contextWindow": 200000,
|
"contextWindow": 200000,
|
||||||
"maxTokens": 16384
|
"maxTokens": 64000
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "claude-sonnet-4-6",
|
"id": "claude-sonnet-4-6",
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
"openclawName": "Claude Sonnet 4.6 (via CLI)",
|
"openclawName": "Claude Sonnet 4.6 (via CLI)",
|
||||||
"reasoning": true,
|
"reasoning": true,
|
||||||
"contextWindow": 200000,
|
"contextWindow": 200000,
|
||||||
"maxTokens": 16384
|
"maxTokens": 32000
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "claude-haiku-4-5-20251001",
|
"id": "claude-haiku-4-5-20251001",
|
||||||
@@ -56,7 +56,7 @@
|
|||||||
"openclawName": "Claude Haiku 4.5 (via CLI)",
|
"openclawName": "Claude Haiku 4.5 (via CLI)",
|
||||||
"reasoning": false,
|
"reasoning": false,
|
||||||
"contextWindow": 200000,
|
"contextWindow": 200000,
|
||||||
"maxTokens": 8192
|
"maxTokens": 32000
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"aliases": {
|
"aliases": {
|
||||||
|
|||||||
+10
-3
@@ -129,10 +129,17 @@ provider = {
|
|||||||
# re-trip it. Family prefixes classify any versioned ID correctly with no per-model
|
# 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
|
# edit. (ADR 0003: models.json is the SPOT for model existence; /v1/models does not
|
||||||
# expose reasoning/maxTokens, so family classification stays here.)
|
# expose reasoning/maxTokens, so family classification stays here.)
|
||||||
|
# maxTokens values are the FAMILY FLOOR of the CLI registry max_output_tokens.default
|
||||||
|
# (opus family 64000; sonnet 32000 because sonnet-4-6 is 32000 while sonnet-5 is 64000;
|
||||||
|
# haiku 32000). Family prefixes cannot distinguish versions, so the floor is used
|
||||||
|
# deliberately: under-advertising caps a client lower than the model allows, which is safe,
|
||||||
|
# whereas over-advertising would promise capacity a member of the family does not have.
|
||||||
|
# models.json is authoritative per ADR 0003; this table only exists because /v1/models does
|
||||||
|
# not expose maxTokens.
|
||||||
model_meta = {
|
model_meta = {
|
||||||
"claude-opus": {"name": "Claude Opus (OCP)", "reasoning": True, "maxTokens": 16384},
|
"claude-opus": {"name": "Claude Opus (OCP)", "reasoning": True, "maxTokens": 64000},
|
||||||
"claude-sonnet": {"name": "Claude Sonnet (OCP)", "reasoning": True, "maxTokens": 16384},
|
"claude-sonnet": {"name": "Claude Sonnet (OCP)", "reasoning": True, "maxTokens": 32000},
|
||||||
"claude-haiku": {"name": "Claude Haiku (OCP)", "reasoning": False, "maxTokens": 8192},
|
"claude-haiku": {"name": "Claude Haiku (OCP)", "reasoning": False, "maxTokens": 32000},
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_model_meta(mid):
|
def get_model_meta(mid):
|
||||||
|
|||||||
+67
-98
@@ -991,48 +991,17 @@ function ltBoot(env, dir, nodeArgs = []) {
|
|||||||
CLAUDE_BIND: "127.0.0.1", CLAUDE_AUTH_MODE: "none", CLAUDE_CACHE_TTL: "0", CLAUDE_TIMEOUT: "4000", ...env },
|
CLAUDE_BIND: "127.0.0.1", CLAUDE_AUTH_MODE: "none", CLAUDE_CACHE_TTL: "0", CLAUDE_TIMEOUT: "4000", ...env },
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
});
|
});
|
||||||
const buf = { out: "", err: "", exit: undefined, signal: undefined, closed: false, spawnErr: null };
|
const buf = { out: "", err: "", exit: undefined };
|
||||||
child.stdout.on("data", d => { buf.out += d; });
|
child.stdout.on("data", d => { buf.out += d; });
|
||||||
child.stderr.on("data", d => { buf.err += d; });
|
child.stderr.on("data", d => { buf.err += d; });
|
||||||
// 'exit' fires when the process terminates, but its stdio pipes may still hold unread data —
|
child.on("exit", code => { buf.exit = code; });
|
||||||
// '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. See ltAssertBoot below.
|
|
||||||
child.on("exit", (code, signal) => { buf.exit = code; buf.signal = signal; });
|
|
||||||
child.on("close", () => { buf.closed = true; });
|
|
||||||
// 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 };
|
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 { /* a leaked temp dir must never fail a test — the OS reaps it */ }
|
|
||||||
}
|
|
||||||
// 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.
|
|
||||||
function ltDiag(buf) {
|
|
||||||
return `exit=${buf.exit} signal=${buf.signal} closed=${buf.closed}` +
|
|
||||||
(buf.spawnErr ? ` spawnErr=${buf.spawnErr.code || buf.spawnErr.message}` : "") +
|
|
||||||
` | stderr(${buf.err.length}B)=${JSON.stringify(buf.err.slice(0, 200))}` +
|
|
||||||
` | stdout(${buf.out.length}B)=${JSON.stringify(buf.out.slice(-200))}`;
|
|
||||||
}
|
|
||||||
async function ltWait(cond, ms = 9000) {
|
async function ltWait(cond, ms = 9000) {
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
while (Date.now() - start < ms) { if (cond()) return true; await new Promise(r => setTimeout(r, 40)); }
|
while (Date.now() - start < ms) { if (cond()) return true; await new Promise(r => setTimeout(r, 40)); }
|
||||||
return false;
|
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) {
|
async function ltPost(port, body) {
|
||||||
try {
|
try {
|
||||||
await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
|
await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
|
||||||
@@ -1046,31 +1015,29 @@ 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 () => {
|
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
|
if (!LT_POSIX) return; // sh fake — skip on Windows CI
|
||||||
const dir = ltMkdir(); const cap = join(dir, "sp.txt"); const fake = ltFake(dir);
|
const dir = ltMkdir(); const cap = join(dir, "sp.txt"); const fake = ltFake(dir);
|
||||||
const port = await ltFreePort();
|
const { child, buf } = ltBoot({ OCP_LOCAL_TOOLS: "1", CLAUDE_BIN: fake, CLAUDE_PROXY_PORT: "39321", SP_CAPTURE: cap }, dir);
|
||||||
const { child, buf } = ltBoot({ OCP_LOCAL_TOOLS: "1", CLAUDE_BIN: fake, CLAUDE_PROXY_PORT: String(port), SP_CAPTURE: cap }, dir);
|
|
||||||
try {
|
try {
|
||||||
assert.ok(await ltWait(() => buf.out.includes("listening on") || buf.exit != null), `server did not start: ${buf.err.slice(0,200)}`);
|
assert.ok(await ltWait(() => buf.out.includes("listening on") || buf.exit != null), `server did not start: ${buf.err.slice(0,200)}`);
|
||||||
await ltPost(port, { model: "sonnet", messages: [{ role: "user", content: "hi" }] });
|
await ltPost(39321, { model: "sonnet", messages: [{ role: "user", content: "hi" }] });
|
||||||
assert.ok(await ltWait(() => _ltExists(cap)), "fake claude was spawned and captured --system-prompt");
|
assert.ok(await ltWait(() => _ltExists(cap)), "fake claude was spawned and captured --system-prompt");
|
||||||
const sp = _ltRead(cap, "utf8");
|
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_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");
|
assert.ok(!sp.includes(LT_NEG_MARK), "positive wrapper must REPLACE the negative one, not append");
|
||||||
} finally { child.kill("SIGKILL"); _ltRmRetry(dir); }
|
} finally { child.kill("SIGKILL"); _ltRm(dir, { recursive: true, force: true }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
test("integration: flag OFF → the -p spawn receives the EXACT negative wrapper (default path byte-for-byte)", async () => {
|
test("integration: flag OFF → the -p spawn receives the EXACT negative wrapper (default path byte-for-byte)", async () => {
|
||||||
if (!LT_POSIX) return;
|
if (!LT_POSIX) return;
|
||||||
const dir = ltMkdir(); const cap = join(dir, "sp.txt"); const fake = ltFake(dir);
|
const dir = ltMkdir(); const cap = join(dir, "sp.txt"); const fake = ltFake(dir);
|
||||||
const port = await ltFreePort();
|
const { child, buf } = ltBoot({ CLAUDE_BIN: fake, CLAUDE_PROXY_PORT: "39322", SP_CAPTURE: cap }, dir); // OCP_LOCAL_TOOLS unset
|
||||||
const { child, buf } = ltBoot({ CLAUDE_BIN: fake, CLAUDE_PROXY_PORT: String(port), SP_CAPTURE: cap }, dir); // OCP_LOCAL_TOOLS unset
|
|
||||||
try {
|
try {
|
||||||
assert.ok(await ltWait(() => buf.out.includes("listening on") || buf.exit != null), `server did not start: ${buf.err.slice(0,200)}`);
|
assert.ok(await ltWait(() => buf.out.includes("listening on") || buf.exit != null), `server did not start: ${buf.err.slice(0,200)}`);
|
||||||
await ltPost(port, { model: "sonnet", messages: [{ role: "user", content: "hi" }] });
|
await ltPost(39322, { model: "sonnet", messages: [{ role: "user", content: "hi" }] });
|
||||||
assert.ok(await ltWait(() => _ltExists(cap)), "fake claude captured --system-prompt");
|
assert.ok(await ltWait(() => _ltExists(cap)), "fake claude captured --system-prompt");
|
||||||
const sp = _ltRead(cap, "utf8");
|
const sp = _ltRead(cap, "utf8");
|
||||||
// No system messages + no CLAUDE_SYSTEM_PROMPT → the wrapper is passed verbatim.
|
// 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.`);
|
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"); _ltRmRetry(dir); }
|
} finally { child.kill("SIGKILL"); _ltRm(dir, { recursive: true, force: true }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
test("integration: boot gate REFUSES each unsafe config (multi / non-loopback / anon key)", async () => {
|
test("integration: boot gate REFUSES each unsafe config (multi / non-loopback / anon key)", async () => {
|
||||||
@@ -1082,35 +1049,25 @@ test("integration: boot gate REFUSES each unsafe config (multi / non-loopback /
|
|||||||
{ label: "anon", env: { PROXY_ANONYMOUS_KEY: "pub" } },
|
{ label: "anon", env: { PROXY_ANONYMOUS_KEY: "pub" } },
|
||||||
];
|
];
|
||||||
try {
|
try {
|
||||||
for (const c of cases) {
|
for (const [i, c] of cases.entries()) {
|
||||||
const port = await ltFreePort();
|
const { child, buf } = ltBoot({ OCP_LOCAL_TOOLS: "1", CLAUDE_BIN: fake, CLAUDE_PROXY_PORT: String(39330 + i), ...c.env }, dir);
|
||||||
const { child, buf } = ltBoot({ OCP_LOCAL_TOOLS: "1", CLAUDE_BIN: fake, CLAUDE_PROXY_PORT: String(port), ...c.env }, dir);
|
|
||||||
try {
|
try {
|
||||||
// Wait for `closed`, not `exit`: the assertion below reads buf.err, and stderr is only
|
assert.ok(await ltWait(() => buf.exit != null), `[${c.label}] expected the process to exit`);
|
||||||
// guaranteed drained at 'close'. This is the ordering #203 was filed for.
|
assert.notEqual(buf.exit, 0, `[${c.label}] must exit non-zero`);
|
||||||
assert.ok(await ltWait(() => buf.closed || buf.spawnErr), `[${c.label}] process never closed — ${ltDiag(buf)}`);
|
assert.ok(/FATAL[\s\S]*OCP_LOCAL_TOOLS/.test(buf.err), `[${c.label}] expected a local-tools FATAL, got: ${buf.err.slice(0,160)}`);
|
||||||
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 { child.kill("SIGKILL"); }
|
||||||
}
|
}
|
||||||
} finally { _ltRmRetry(dir); }
|
} finally { _ltRm(dir, { recursive: true, force: true }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
test("integration: safe single-user config BOOTS past the gate and announces local tools", async () => {
|
test("integration: safe single-user config BOOTS past the gate and announces local tools", async () => {
|
||||||
if (!LT_POSIX) return;
|
if (!LT_POSIX) return;
|
||||||
const dir = ltMkdir(); const fake = ltFake(dir);
|
const dir = ltMkdir(); const fake = ltFake(dir);
|
||||||
const port = await ltFreePort();
|
const { child, buf } = ltBoot({ OCP_LOCAL_TOOLS: "1", CLAUDE_BIN: fake, CLAUDE_PROXY_PORT: "39340" }, dir); // loopback + none
|
||||||
const { child, buf } = ltBoot({ OCP_LOCAL_TOOLS: "1", CLAUDE_BIN: fake, CLAUDE_PROXY_PORT: String(port) }, dir); // loopback + none
|
|
||||||
try {
|
try {
|
||||||
// Same race as #199, one line over: "Local tools: ON" is written 14 console.log calls AFTER
|
assert.ok(await ltWait(() => buf.out.includes("listening on")), `safe config must boot, got: ${buf.err.slice(0,200)}`);
|
||||||
// "listening on", so gating on the boot marker and then asserting the announcement can read a
|
assert.ok(buf.out.includes("Local tools: ON"), "startup must announce local tools when active");
|
||||||
// buffer holding only the first chunk. Wait for the line actually under assertion. Measured by
|
} finally { child.kill("SIGKILL"); _ltRm(dir, { recursive: true, force: true }); }
|
||||||
// review at 9/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)}`);
|
|
||||||
} finally { child.kill("SIGKILL"); _ltRmRetry(dir); }
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("integration: TUI mode → flag is announced INERT (not 'ON'), boot not refused", async () => {
|
test("integration: TUI mode → flag is announced INERT (not 'ON'), boot not refused", async () => {
|
||||||
@@ -1118,22 +1075,12 @@ test("integration: TUI mode → flag is announced INERT (not 'ON'), boot not ref
|
|||||||
const dir = ltMkdir(); const fake = ltFake(dir);
|
const dir = ltMkdir(); const fake = ltFake(dir);
|
||||||
// Non-loopback would normally trip the local-tools gate; under TUI the flag is inert so the
|
// 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.
|
// gate must NOT fire on its behalf. Use loopback here to isolate TUI's own guards from ours.
|
||||||
const port = await ltFreePort();
|
const { child, buf } = ltBoot({ OCP_LOCAL_TOOLS: "1", CLAUDE_TUI_MODE: "true", CLAUDE_BIN: fake, CLAUDE_PROXY_PORT: "39341" }, dir);
|
||||||
const { child, buf } = ltBoot({ OCP_LOCAL_TOOLS: "1", CLAUDE_TUI_MODE: "true", CLAUDE_BIN: fake, CLAUDE_PROXY_PORT: String(port) }, dir);
|
|
||||||
try {
|
try {
|
||||||
// Wait for the line actually under assertion, not for a proxy signal. "listening on" and the
|
assert.ok(await ltWait(() => buf.out.includes("listening on") || buf.exit != null), `did not start: ${buf.err.slice(0,200)}`);
|
||||||
// inert-flag warning are written independently, so gating on the former and then asserting
|
assert.ok(!buf.out.includes("Local tools: ON"), "must NOT claim local tools are ON in TUI mode (the wrapper is unused there)");
|
||||||
// the latter is a race — the flake #199 was filed for. Still bounded by the same timeout, and
|
assert.ok(/ignored in TUI mode/.test(buf.out + buf.err), "must warn that OCP_LOCAL_TOOLS is inert under TUI");
|
||||||
// the boot markers are kept in the predicate so a failed boot ends the wait immediately
|
} finally { child.kill("SIGKILL"); _ltRm(dir, { recursive: true, force: true }); }
|
||||||
// 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 () => {
|
test("integration: toggling OCP_LOCAL_TOOLS invalidates the standard response cache (epoch fold)", async () => {
|
||||||
@@ -1151,11 +1098,11 @@ test("integration: toggling OCP_LOCAL_TOOLS invalidates the standard response ca
|
|||||||
} finally { child.kill("SIGKILL"); }
|
} finally { child.kill("SIGKILL"); }
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
const off = await bootOnce({}, await ltFreePort()); // caches "OK" under epoch(negative)
|
const off = await bootOnce({}, 39350); // caches "OK" under epoch(negative)
|
||||||
const on = await bootOnce({ OCP_LOCAL_TOOLS: "1" }, await ltFreePort()); // same DB, epoch(positive) → must MISS → re-spawn
|
const on = await bootOnce({ OCP_LOCAL_TOOLS: "1" }, 39351); // same DB, epoch(positive) → must MISS → re-spawn
|
||||||
assert.equal(off, 1, "first request (cache empty) must spawn claude");
|
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)");
|
assert.equal(on, 1, "after toggling the flag the identical request must NOT be served from the old cache (epoch differs → re-spawn)");
|
||||||
} finally { _ltRmRetry(dir); }
|
} finally { _ltRm(dir, { recursive: true, force: true }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── active-request counter is paired to the process lifecycle (#180 / #193) ──
|
// ── active-request counter is paired to the process lifecycle (#180 / #193) ──
|
||||||
@@ -1191,6 +1138,13 @@ function ltSpreadThrowCount(stackKb) {
|
|||||||
return Number(String(_ltExecFile(process.execPath, [`--stack-size=${stackKb}`, "-e", src], { encoding: "utf8" })).trim()) || 0;
|
return Number(String(_ltExecFile(process.execPath, [`--stack-size=${stackKb}`, "-e", src], { encoding: "utf8" })).trim()) || 0;
|
||||||
} catch { return 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) {
|
async function ltPostStatus(port, body) {
|
||||||
try {
|
try {
|
||||||
const r = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
|
const r = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
|
||||||
@@ -1237,7 +1191,7 @@ test("integration: a synchronous pre-spawn throw must not leak stats.activeReque
|
|||||||
const active = (await r.json()).requests.active;
|
const active = (await r.json()).requests.active;
|
||||||
assert.equal(active, 0,
|
assert.equal(active, 0,
|
||||||
`3 requests threw before their spawn; the counter must be back to 0, got ${active} (this is the #180 leak)`);
|
`3 requests threw before their spawn; the counter must be back to 0, got ${active} (this is the #180 leak)`);
|
||||||
} finally { child.kill("SIGKILL"); _ltRmRetry(dir); }
|
} finally { child.kill("SIGKILL"); _ltRm(dir, { recursive: true, force: true }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Cache keys hash the RESOLVED model, not the alias string (#194) ──────────
|
// ── Cache keys hash the RESOLVED model, not the alias string (#194) ──────────
|
||||||
@@ -1265,38 +1219,36 @@ console.log("\nCache key resolves the model alias (#194):");
|
|||||||
test("integration: an alias and its canonical target share ONE cache slot (normal path)", async () => {
|
test("integration: an alias and its canonical target share ONE cache slot (normal path)", async () => {
|
||||||
if (!LT_POSIX) return;
|
if (!LT_POSIX) return;
|
||||||
const dir = ltMkdir(); const fake = ltFake(dir); const counter = join(dir, "spawns.txt");
|
const dir = ltMkdir(); const fake = ltFake(dir); const counter = join(dir, "spawns.txt");
|
||||||
const port = await ltFreePort();
|
const { child, buf } = ltBoot({ CLAUDE_BIN: fake, CLAUDE_PROXY_PORT: "39360", CLAUDE_CACHE_TTL: "60000", SP_COUNTER: counter }, dir);
|
||||||
const { child, buf } = ltBoot({ CLAUDE_BIN: fake, CLAUDE_PROXY_PORT: String(port), CLAUDE_CACHE_TTL: "60000", SP_COUNTER: counter }, dir);
|
|
||||||
try {
|
try {
|
||||||
assert.ok(await ltWait(() => buf.out.includes("listening on")), `did not start: ${buf.err.slice(0, 200)}`);
|
assert.ok(await ltWait(() => buf.out.includes("listening on")), `did not start: ${buf.err.slice(0, 200)}`);
|
||||||
_ltWrite(counter, "0");
|
_ltWrite(counter, "0");
|
||||||
const msgs = [{ role: "user", content: "alias-resolution-probe" }];
|
const msgs = [{ role: "user", content: "alias-resolution-probe" }];
|
||||||
await ltPost(port, { model: "sonnet", messages: msgs }); // miss → spawn
|
await ltPost(39360, { model: "sonnet", messages: msgs }); // miss → spawn
|
||||||
await ltWait(() => (Number(_ltRead(counter, "utf8")) || 0) >= 1, 3000);
|
await ltWait(() => (Number(_ltRead(counter, "utf8")) || 0) >= 1, 3000);
|
||||||
await ltPost(port, { model: "claude-sonnet-5", messages: msgs }); // same resolved model → HIT
|
await ltPost(39360, { model: "claude-sonnet-5", messages: msgs }); // same resolved model → HIT
|
||||||
await new Promise(r => setTimeout(r, 600));
|
await new Promise(r => setTimeout(r, 600));
|
||||||
assert.equal(Number(_ltRead(counter, "utf8")) || 0, 1,
|
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");
|
"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"); _ltRmRetry(dir); }
|
} 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 () => {
|
test("integration: an alias and its canonical target share ONE cache slot (STRUCTURED path)", async () => {
|
||||||
if (!LT_POSIX) return;
|
if (!LT_POSIX) return;
|
||||||
const dir = ltMkdir(); const fake = ltFakeJson(dir); const counter = join(dir, "spawns.txt");
|
const dir = ltMkdir(); const fake = ltFakeJson(dir); const counter = join(dir, "spawns.txt");
|
||||||
const port = await ltFreePort();
|
const { child, buf } = ltBoot({ CLAUDE_BIN: fake, CLAUDE_PROXY_PORT: "39361", CLAUDE_CACHE_TTL: "60000", SP_COUNTER: counter }, dir);
|
||||||
const { child, buf } = ltBoot({ CLAUDE_BIN: fake, CLAUDE_PROXY_PORT: String(port), CLAUDE_CACHE_TTL: "60000", SP_COUNTER: counter }, dir);
|
|
||||||
try {
|
try {
|
||||||
assert.ok(await ltWait(() => buf.out.includes("listening on")), `did not start: ${buf.err.slice(0, 200)}`);
|
assert.ok(await ltWait(() => buf.out.includes("listening on")), `did not start: ${buf.err.slice(0, 200)}`);
|
||||||
_ltWrite(counter, "0");
|
_ltWrite(counter, "0");
|
||||||
const rf = { type: "json_schema", json_schema: { name: "probe", schema: LT_SCHEMA } };
|
const rf = { type: "json_schema", json_schema: { name: "probe", schema: LT_SCHEMA } };
|
||||||
const msgs = [{ role: "user", content: "structured-alias-probe" }];
|
const msgs = [{ role: "user", content: "structured-alias-probe" }];
|
||||||
await ltPost(port, { model: "sonnet", messages: msgs, response_format: rf });
|
await ltPost(39361, { model: "sonnet", messages: msgs, response_format: rf });
|
||||||
await ltWait(() => (Number(_ltRead(counter, "utf8")) || 0) >= 1, 4000);
|
await ltWait(() => (Number(_ltRead(counter, "utf8")) || 0) >= 1, 4000);
|
||||||
await ltPost(port, { model: "claude-sonnet-5", messages: msgs, response_format: rf });
|
await ltPost(39361, { model: "claude-sonnet-5", messages: msgs, response_format: rf });
|
||||||
await new Promise(r => setTimeout(r, 600));
|
await new Promise(r => setTimeout(r, 600));
|
||||||
assert.equal(Number(_ltRead(counter, "utf8")) || 0, 1,
|
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");
|
"structured cache key must resolve the alias too — this is the path the epoch-only fix missed");
|
||||||
} finally { child.kill("SIGKILL"); _ltRmRetry(dir); }
|
} finally { child.kill("SIGKILL"); _ltRm(dir, { recursive: true, force: true }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
// MODEL_MAP is models[] + aliases + legacyAliases, so resolving covers legacyAliases for free.
|
// MODEL_MAP is models[] + aliases + legacyAliases, so resolving covers legacyAliases for free.
|
||||||
@@ -1305,19 +1257,18 @@ 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 () => {
|
test("integration: a legacyAlias shares ONE cache slot with its canonical target", async () => {
|
||||||
if (!LT_POSIX) return;
|
if (!LT_POSIX) return;
|
||||||
const dir = ltMkdir(); const fake = ltFake(dir); const counter = join(dir, "spawns.txt");
|
const dir = ltMkdir(); const fake = ltFake(dir); const counter = join(dir, "spawns.txt");
|
||||||
const port = await ltFreePort();
|
const { child, buf } = ltBoot({ CLAUDE_BIN: fake, CLAUDE_PROXY_PORT: "39364", CLAUDE_CACHE_TTL: "60000", SP_COUNTER: counter }, dir);
|
||||||
const { child, buf } = ltBoot({ CLAUDE_BIN: fake, CLAUDE_PROXY_PORT: String(port), CLAUDE_CACHE_TTL: "60000", SP_COUNTER: counter }, dir);
|
|
||||||
try {
|
try {
|
||||||
assert.ok(await ltWait(() => buf.out.includes("listening on")), `did not start: ${buf.err.slice(0, 200)}`);
|
assert.ok(await ltWait(() => buf.out.includes("listening on")), `did not start: ${buf.err.slice(0, 200)}`);
|
||||||
_ltWrite(counter, "0");
|
_ltWrite(counter, "0");
|
||||||
const msgs = [{ role: "user", content: "legacy-alias-probe" }];
|
const msgs = [{ role: "user", content: "legacy-alias-probe" }];
|
||||||
await ltPost(port, { model: "claude-haiku-4-5", messages: msgs }); // legacyAlias
|
await ltPost(39364, { model: "claude-haiku-4-5", messages: msgs }); // legacyAlias
|
||||||
await ltWait(() => (Number(_ltRead(counter, "utf8")) || 0) >= 1, 3000);
|
await ltWait(() => (Number(_ltRead(counter, "utf8")) || 0) >= 1, 3000);
|
||||||
await ltPost(port, { model: "claude-haiku-4-5-20251001", messages: msgs }); // canonical
|
await ltPost(39364, { model: "claude-haiku-4-5-20251001", messages: msgs }); // canonical
|
||||||
await new Promise(r => setTimeout(r, 600));
|
await new Promise(r => setTimeout(r, 600));
|
||||||
assert.equal(Number(_ltRead(counter, "utf8")) || 0, 1,
|
assert.equal(Number(_ltRead(counter, "utf8")) || 0, 1,
|
||||||
"legacyAliases live in MODEL_MAP too — resolving must collapse them onto the canonical slot");
|
"legacyAliases live in MODEL_MAP too — resolving must collapse them onto the canonical slot");
|
||||||
} finally { child.kill("SIGKILL"); _ltRmRetry(dir); }
|
} 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 () => {
|
test("integration: a config change invalidates the STRUCTURED cache too (closes the #177 gap)", async () => {
|
||||||
@@ -1336,11 +1287,11 @@ test("integration: a config change invalidates the STRUCTURED cache too (closes
|
|||||||
} finally { child.kill("SIGKILL"); }
|
} finally { child.kill("SIGKILL"); }
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
const off = await bootOnce({}, await ltFreePort()); // caches under epoch(negative wrapper)
|
const off = await bootOnce({}, 39362); // caches under epoch(negative wrapper)
|
||||||
const on = await bootOnce({ OCP_LOCAL_TOOLS: "1" }, await ltFreePort()); // same DB, epoch differs → must re-spawn
|
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(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");
|
assert.equal(on, 1, "structured cache must honor CONFIG_EPOCH — before #194 it omitted the epoch entirely and served the stale answer");
|
||||||
} finally { _ltRmRetry(dir); }
|
} finally { _ltRm(dir, { recursive: true, force: true }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Upgrade Tests ──
|
// ── Upgrade Tests ──
|
||||||
@@ -4348,6 +4299,24 @@ test("models.json: every aliases value resolves to a real models[].id (referenti
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// maxTokens must leave room for a visible answer once a client's thinking budget is taken out
|
||||||
|
// (#195). OCP never enforces maxTokens itself — it is propagated to OpenClaw (setup.mjs,
|
||||||
|
// scripts/sync-openclaw.mjs) where it CAPS the outbound request:
|
||||||
|
// maxTokens = Math.min(baseMaxTokens + thinkingBudget, modelMaxTokens)
|
||||||
|
// OpenClaw's reasoning budgets are medium 8192 / high 16384, and when maxTokens <= thinkingBudget
|
||||||
|
// it clamps thinking to maxTokens - 1024. So a model declared at 16384 running at `high` spent
|
||||||
|
// ~15360 on thinking and left ~1024 for the actual answer — which is what this repo shipped until
|
||||||
|
// v3.25.0. Asserting the PRINCIPLE rather than pinning per-model numbers: a value test would need
|
||||||
|
// editing every time a model is added, and would not say why.
|
||||||
|
const _spotHighThinkingBudget = 16384; // OpenClaw's `high` reasoning budget
|
||||||
|
test("models.json: every maxTokens exceeds the high thinking budget, leaving room for output (#195)", () => {
|
||||||
|
for (const m of _spotModels.models) {
|
||||||
|
assert.ok(m.maxTokens > _spotHighThinkingBudget,
|
||||||
|
`${m.id} declares maxTokens=${m.maxTokens} <= ${_spotHighThinkingBudget}: at OpenClaw's 'high' reasoning ` +
|
||||||
|
`level the thinking budget would consume all but ~1024 tokens of the response`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("models.json: every legacyAliases value resolves to a real models[].id (referential integrity)", () => {
|
test("models.json: every legacyAliases value resolves to a real models[].id (referential integrity)", () => {
|
||||||
for (const [name, target] of Object.entries(_spotModels.legacyAliases || {})) {
|
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)`);
|
assert.ok(_spotModelIds.has(target), `legacyAliases.${name} -> '${target}' is a dangling alias (no matching models[].id)`);
|
||||||
|
|||||||
Reference in New Issue
Block a user