mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-19 09:44:07 +00:00
feat(cache): per-key isolation, cache_control bypass, chunked stream replay (#65)
* docs(governance): ADR 0005 (no multi-provider) + cache upgrade spec Governance prelude for the cache upgrade work: - docs/adr/0005-no-multi-provider.md — locks in the decision that OCP stays single-provider (Anthropic via cli.js spawn). Cache improvements are explicitly in scope (decision §3); multi-provider refactor is explicitly out of scope, with three documented trigger conditions for revisiting. - docs/adr/README.md — index updated to reference 0005. - docs/superpowers/specs/2026-05-07-cache-upgrade-design.md — design for the cache upgrade work split into PR-A (per-key isolation, cache_control bypass, chunked stream replay) and PR-B (singleflight stampede protection). Each design decision has a written rationale. server.mjs is not modified; this commit is doc-only and therefore exempt from the cli.js citation requirement (ALIGNMENT.md Rule 5 applies only to commits that touch server.mjs). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(cache): per-key isolation, cache_control bypass, chunked stream replay cli.js does not perform response caching at the proxy layer. OCP's response cache is a value-add operation internal to OCP, between the wire and the cli.js spawn. It does not introduce, rename, or alter any endpoint, header, request field, or response field that cli.js emits or expects — this change qualifies under ALIGNMENT.md Rule 2's value-add carve-out for non-wire- affecting proxy operations. no client-observable wire shape change. Spec: docs/superpowers/specs/2026-05-07-cache-upgrade-design.md D1 — Per-key cache isolation (cacheHash v2 format) keys.mjs: prepend `v2|k:<keyId or "anon">|` before existing hash fields. Backward-compatible: absent/null/empty keyId folds to "anon". v1-format rows in response_cache are abandoned naturally; TTL cleanup at server.mjs:185 reaps them within one window. No migration needed. server.mjs: pass keyId: req._authKeyId at the single cacheHash call site (line ~1221). D2 — cache_control bypass keys.mjs: export hasCacheControl(messages) — walks messages and nested content arrays for presence of cache_control field. server.mjs: if hasCacheControl(messages) is true, set req._cacheHash = null and log cache_skipped{reason: cache_control_present}; existing `if (CACHE_TTL > 0 && req._cacheHash)` guards on write-back handle the skip. D3 — Chunked stream replay (80 codepoints/chunk, no artificial delay) server.mjs: replace single-chunk cached.response emission with an Array.from(cached.response) loop in steps of CACHE_REPLAY_CHUNK_SIZE=80. Array.from ensures multibyte UTF-8 codepoints (e.g. CJK) are never split. Tests: 12 new cases in test-features.mjs (36 total, 0 failed). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: dtzp555 <dtzp555@gmail.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+102
-1
@@ -3,7 +3,8 @@
|
||||
* Integration test for Quota + Cache features.
|
||||
* Tests database layer functions directly — no server needed.
|
||||
*/
|
||||
import { getDb, createKey, listKeys, validateKey, recordUsage, checkQuota, updateKeyQuota, getKeyQuota, findKey, cacheHash, getCachedResponse, setCachedResponse, clearCache, getCacheStats, closeDb } from "./keys.mjs";
|
||||
import { getDb, createKey, listKeys, validateKey, recordUsage, checkQuota, updateKeyQuota, getKeyQuota, findKey, cacheHash, getCachedResponse, setCachedResponse, clearCache, getCacheStats, closeDb, hasCacheControl } from "./keys.mjs";
|
||||
import { createHash } from "node:crypto";
|
||||
import { strict as assert } from "node:assert";
|
||||
import { unlinkSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
@@ -253,6 +254,106 @@ test("clearCache with TTL only removes old entries", () => {
|
||||
clearCache();
|
||||
});
|
||||
|
||||
// ── PR-A: Per-key isolation (D1), cache_control bypass (D2), chunked replay (D3) ──
|
||||
console.log("\nPR-A Cache Upgrade:");
|
||||
|
||||
const msgsBase = [{ role: "user", content: "Shared prompt text" }];
|
||||
|
||||
test("D1: cacheHash with two distinct keyIds produces different hashes", () => {
|
||||
const h1 = cacheHash("sonnet", msgsBase, { keyId: "key-aaa" });
|
||||
const h2 = cacheHash("sonnet", msgsBase, { keyId: "key-bbb" });
|
||||
assert.notEqual(h1, h2);
|
||||
});
|
||||
|
||||
test("D1: cacheHash with keyId=undefined and keyId='anon' produce the same hash", () => {
|
||||
const hUndef = cacheHash("sonnet", msgsBase, { keyId: undefined });
|
||||
const hAnon = cacheHash("sonnet", msgsBase, { keyId: "anon" });
|
||||
assert.equal(hUndef, hAnon);
|
||||
});
|
||||
|
||||
test("D1: cacheHash with keyId=null and keyId='anon' produce the same hash", () => {
|
||||
const hNull = cacheHash("sonnet", msgsBase, { keyId: null });
|
||||
const hAnon = cacheHash("sonnet", msgsBase, { keyId: "anon" });
|
||||
assert.equal(hNull, hAnon);
|
||||
});
|
||||
|
||||
test("D1: v2 prefix — hash differs from a v1-style baseline (no prefix)", () => {
|
||||
// Reproduce a v1-style hash manually to confirm v2 differs
|
||||
const v1 = createHash("sha256")
|
||||
.update("sonnet")
|
||||
.update(msgsBase[0].role)
|
||||
.update(msgsBase[0].content)
|
||||
.digest("hex");
|
||||
const v2 = cacheHash("sonnet", msgsBase, { keyId: "anon" });
|
||||
assert.notEqual(v1, v2);
|
||||
});
|
||||
|
||||
test("D1: cacheHash is reproducible for same keyId (determinism)", () => {
|
||||
const h1 = cacheHash("sonnet", msgsBase, { keyId: "key-xyz" });
|
||||
const h2 = cacheHash("sonnet", msgsBase, { keyId: "key-xyz" });
|
||||
assert.equal(h1, h2);
|
||||
});
|
||||
|
||||
test("D2: hasCacheControl returns true for top-level cache_control on message", () => {
|
||||
const msgs = [{ role: "user", cache_control: { type: "ephemeral" }, content: "hello" }];
|
||||
assert.equal(hasCacheControl(msgs), true);
|
||||
});
|
||||
|
||||
test("D2: hasCacheControl returns true for nested cache_control in content array", () => {
|
||||
const msgs = [{ role: "user", content: [{ type: "text", text: "x", cache_control: { type: "ephemeral" } }] }];
|
||||
assert.equal(hasCacheControl(msgs), true);
|
||||
});
|
||||
|
||||
test("D2: hasCacheControl returns false for plain string content", () => {
|
||||
const msgs = [{ role: "user", content: "plain string" }];
|
||||
assert.equal(hasCacheControl(msgs), false);
|
||||
});
|
||||
|
||||
test("D2: hasCacheControl returns false for content array without cache_control", () => {
|
||||
const msgs = [{ role: "user", content: [{ type: "text", text: "x" }] }];
|
||||
assert.equal(hasCacheControl(msgs), false);
|
||||
});
|
||||
|
||||
test("D2: hasCacheControl handles null/empty input gracefully", () => {
|
||||
assert.equal(hasCacheControl(null), false);
|
||||
assert.equal(hasCacheControl([]), false);
|
||||
assert.equal(hasCacheControl([null, undefined]), false);
|
||||
});
|
||||
|
||||
// D3: chunked stream replay — verify the logic by simulating what server.mjs does
|
||||
test("D3: 160-char cached response produces 2 chunks at 80 codepoints/chunk", () => {
|
||||
const content = "a".repeat(160);
|
||||
const CACHE_REPLAY_CHUNK_SIZE = 80;
|
||||
const codepoints = Array.from(content);
|
||||
const chunks = [];
|
||||
for (let i = 0; i < codepoints.length; i += CACHE_REPLAY_CHUNK_SIZE) {
|
||||
chunks.push(codepoints.slice(i, i + CACHE_REPLAY_CHUNK_SIZE).join(""));
|
||||
}
|
||||
assert.equal(chunks.length, 2);
|
||||
assert.equal(chunks[0].length, 80);
|
||||
assert.equal(chunks[1].length, 80);
|
||||
});
|
||||
|
||||
test("D3: chunked replay uses Array.from — multibyte codepoints stay intact", () => {
|
||||
// Each Chinese character is 1 codepoint but 3 UTF-8 bytes
|
||||
const chinese = "你好世界".repeat(25); // 100 codepoints
|
||||
const CACHE_REPLAY_CHUNK_SIZE = 80;
|
||||
const codepoints = Array.from(chinese);
|
||||
const chunks = [];
|
||||
for (let i = 0; i < codepoints.length; i += CACHE_REPLAY_CHUNK_SIZE) {
|
||||
chunks.push(codepoints.slice(i, i + CACHE_REPLAY_CHUNK_SIZE).join(""));
|
||||
}
|
||||
assert.equal(chunks.length, 2);
|
||||
assert.equal(Array.from(chunks[0]).length, 80);
|
||||
assert.equal(Array.from(chunks[1]).length, 20);
|
||||
// Verify each character is a complete codepoint (no mojibake)
|
||||
for (const chunk of chunks) {
|
||||
for (const cp of Array.from(chunk)) {
|
||||
assert.equal(cp.length <= 2, true); // surrogate pairs are length 2, single chars length 1
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ── Cleanup ──
|
||||
closeDb();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user