mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-26 23:45:08 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae243ff66e | ||
|
|
544cc3dad9 |
@@ -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.).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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; it is propagated to OpenClaw (via setup.mjs / scripts/sync-openclaw.mjs) where it bounds request and compaction budgets."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
+110
-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 },
|
||||
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.stderr.on("data", d => { buf.err += d; });
|
||||
// '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. 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; });
|
||||
child.on("exit", code => { buf.exit = code; });
|
||||
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) {
|
||||
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`, {
|
||||
@@ -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 () => {
|
||||
if (!LT_POSIX) return; // sh fake — skip on Windows CI
|
||||
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: String(port), SP_CAPTURE: cap }, dir);
|
||||
const { child, buf } = ltBoot({ OCP_LOCAL_TOOLS: "1", CLAUDE_BIN: fake, CLAUDE_PROXY_PORT: "39321", 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(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");
|
||||
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"); _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 () => {
|
||||
if (!LT_POSIX) return;
|
||||
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: String(port), SP_CAPTURE: cap }, dir); // OCP_LOCAL_TOOLS unset
|
||||
const { child, buf } = ltBoot({ CLAUDE_BIN: fake, CLAUDE_PROXY_PORT: "39322", 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(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");
|
||||
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"); _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 () => {
|
||||
@@ -1082,35 +1049,25 @@ test("integration: boot gate REFUSES each unsafe config (multi / non-loopback /
|
||||
{ label: "anon", env: { PROXY_ANONYMOUS_KEY: "pub" } },
|
||||
];
|
||||
try {
|
||||
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);
|
||||
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);
|
||||
try {
|
||||
// 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)}`);
|
||||
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)}`);
|
||||
} 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 () => {
|
||||
if (!LT_POSIX) return;
|
||||
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: String(port) }, dir); // loopback + none
|
||||
const { child, buf } = ltBoot({ OCP_LOCAL_TOOLS: "1", CLAUDE_BIN: fake, CLAUDE_PROXY_PORT: "39340" }, dir); // loopback + none
|
||||
try {
|
||||
// Same race as #199, one line over: "Local tools: ON" is written 14 console.log calls AFTER
|
||||
// "listening on", so gating on the boot marker and then asserting the announcement can read a
|
||||
// buffer holding only the first chunk. Wait for the line actually under assertion. Measured by
|
||||
// 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); }
|
||||
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 }); }
|
||||
});
|
||||
|
||||
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);
|
||||
// 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 port = await ltFreePort();
|
||||
const { child, buf } = ltBoot({ OCP_LOCAL_TOOLS: "1", CLAUDE_TUI_MODE: "true", CLAUDE_BIN: fake, CLAUDE_PROXY_PORT: String(port) }, dir);
|
||||
const { child, buf } = ltBoot({ OCP_LOCAL_TOOLS: "1", CLAUDE_TUI_MODE: "true", CLAUDE_BIN: fake, CLAUDE_PROXY_PORT: "39341" }, dir);
|
||||
try {
|
||||
// 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); }
|
||||
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 }); }
|
||||
});
|
||||
|
||||
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"); }
|
||||
};
|
||||
try {
|
||||
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
|
||||
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
|
||||
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 { _ltRmRetry(dir); }
|
||||
} finally { _ltRm(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
// ── 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;
|
||||
} 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`, {
|
||||
@@ -1237,7 +1191,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"); _ltRmRetry(dir); }
|
||||
} finally { child.kill("SIGKILL"); _ltRm(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
// ── 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 () => {
|
||||
if (!LT_POSIX) return;
|
||||
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: String(port), CLAUDE_CACHE_TTL: "60000", SP_COUNTER: counter }, dir);
|
||||
const { child, buf } = ltBoot({ CLAUDE_BIN: fake, CLAUDE_PROXY_PORT: "39360", CLAUDE_CACHE_TTL: "60000", SP_COUNTER: counter }, dir);
|
||||
try {
|
||||
assert.ok(await ltWait(() => buf.out.includes("listening on")), `did not start: ${buf.err.slice(0, 200)}`);
|
||||
_ltWrite(counter, "0");
|
||||
const msgs = [{ role: "user", content: "alias-resolution-probe" }];
|
||||
await ltPost(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 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));
|
||||
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"); _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 () => {
|
||||
if (!LT_POSIX) return;
|
||||
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: String(port), CLAUDE_CACHE_TTL: "60000", SP_COUNTER: counter }, dir);
|
||||
const { child, buf } = ltBoot({ CLAUDE_BIN: fake, CLAUDE_PROXY_PORT: "39361", CLAUDE_CACHE_TTL: "60000", SP_COUNTER: counter }, dir);
|
||||
try {
|
||||
assert.ok(await ltWait(() => buf.out.includes("listening on")), `did not start: ${buf.err.slice(0, 200)}`);
|
||||
_ltWrite(counter, "0");
|
||||
const rf = { type: "json_schema", json_schema: { name: "probe", schema: LT_SCHEMA } };
|
||||
const msgs = [{ role: "user", content: "structured-alias-probe" }];
|
||||
await ltPost(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 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));
|
||||
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"); _ltRmRetry(dir); }
|
||||
} finally { child.kill("SIGKILL"); _ltRm(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
// 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 () => {
|
||||
if (!LT_POSIX) return;
|
||||
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: String(port), CLAUDE_CACHE_TTL: "60000", SP_COUNTER: counter }, dir);
|
||||
const { child, buf } = ltBoot({ CLAUDE_BIN: fake, CLAUDE_PROXY_PORT: "39364", CLAUDE_CACHE_TTL: "60000", SP_COUNTER: counter }, dir);
|
||||
try {
|
||||
assert.ok(await ltWait(() => buf.out.includes("listening on")), `did not start: ${buf.err.slice(0, 200)}`);
|
||||
_ltWrite(counter, "0");
|
||||
const msgs = [{ role: "user", content: "legacy-alias-probe" }];
|
||||
await ltPost(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 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));
|
||||
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"); _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 () => {
|
||||
@@ -1336,11 +1287,11 @@ test("integration: a config change invalidates the STRUCTURED cache too (closes
|
||||
} finally { child.kill("SIGKILL"); }
|
||||
};
|
||||
try {
|
||||
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
|
||||
const off = await bootOnce({}, 39362); // caches under epoch(negative wrapper)
|
||||
const on = await bootOnce({ OCP_LOCAL_TOOLS: "1" }, 39363); // same DB, epoch differs → must re-spawn
|
||||
assert.equal(off, 1, "first structured request (cache empty) must spawn claude");
|
||||
assert.equal(on, 1, "structured cache must honor CONFIG_EPOCH — before #194 it omitted the epoch entirely and served the stale answer");
|
||||
} finally { _ltRmRetry(dir); }
|
||||
} finally { _ltRm(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
// ── Upgrade Tests ──
|
||||
@@ -4354,6 +4305,67 @@ test("models.json: every legacyAliases value resolves to a real models[].id (ref
|
||||
}
|
||||
});
|
||||
|
||||
// ── 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 are unique, non-empty, and windows are positive (not schema-expressible)", () => {
|
||||
const ids = _spotModels.models.map(m => m.id);
|
||||
assert.equal(new Set(ids).size, ids.length, `duplicate models[].id: ${ids.filter((v, i) => ids.indexOf(v) !== i)}`);
|
||||
for (const m of _spotModels.models) {
|
||||
for (const f of ["id", "displayName", "openclawName"]) {
|
||||
assert.ok(typeof m[f] === "string" && m[f].trim().length > 0, `${m.id}: ${f} must be a non-empty string`);
|
||||
}
|
||||
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