mirror of
https://github.com/dtzp555-max/olp.git
synced 2026-07-22 05:25:09 +00:00
Phase 1 Day 3. ADR 0005 cache layer ships: content-hash key over (provider,
model, IR), D1 per-key isolation, D4 singleflight. server.mjs wires cache
into the dispatch path with X-OLP-Cache: hit|miss|bypass header. Suite 10
adds a gated real Anthropic spawn E2E (OLP_RUN_E2E=1) — orchestrator ran
it once on Mac mini: 7.2s wall-clock, real claude-haiku-4-5 spawn via
keychain OAuth, returned "OK", asserted X-OLP-Provider-Used: anthropic +
X-OLP-Cache: miss on first request. The plugin chain (IR to claude -p to
IR to OpenAI) now works end-to-end on a real binary.
Files:
NEW: lib/cache/keys.mjs (199 lines) — computeCacheKey + cache_control
extraction. sha256(stable-JSON(provider, model, normalized messages,
tools, temperature, response_format, cache_control_markers)).
NEW: lib/cache/store.mjs (348 lines, includes peek() fold-in) — in-memory
CacheStore with D1 per-key isolation (nested Maps), D4 singleflight
via getOrCompute(keyId, cacheKey, computeFn), TTL expiry,
injectable _nowFn for deterministic tests, stats reporting,
scoped clear(keyId?).
MOD: server.mjs (+136/-26 lines) — cache lookup before spawn, bypass on
cache_control markers, getOrCompute for D4 singleflight, header
annotation. authContext changed from {} to null so providers
correctly fall back to readAuthArtifact.
MOD: test-features.mjs (+614 lines, 30 new Suite 9 tests + 1 gated
Suite 10 E2E).
Test count: 98 → 128 (+30) at default. With OLP_RUN_E2E=1: 129/129
(verified by orchestrator on Mac mini, Node 25.8.0).
Authority citations:
Cache key composition: ADR 0005 § Cache key composition (v1.0). All 7
spec fields present (provider, model, normalized messages, tools,
temperature, response_format, cache_control markers).
Per-key isolation D1: ADR 0005 § D1 + OCP keys.mjs precedent (nested
Map keyId → cacheKey → entry).
Singleflight D4: ADR 0005 § D4 + OCP server.mjs inflight Promise
precedent. getOrCompute synchronously registers the inflight promise
BEFORE any await, so concurrent callers cannot observe an empty
inflight slot — JavaScript event-loop guarantee on this is the
correctness anchor.
cache_control bypass D2 basic structure: ADR 0005 § D2. Full marker-
strip-for-non-Anthropic-provider behaviour deferred (only anthropic
plugin exists at D4, so the marker-strip branch is unreachable).
Chunked stream replay D3 basic structure: ADR 0005 § D3. Full timing-
accurate replay deferred per orchestrator spec; D5 stores collected
chunks and replays sequentially without timing fidelity.
Architectural decisions:
1. In-memory cache at D5. ADR 0005 § Cache directory structure shows
~/.olp/cache/<keyId>/... as the eventual layout; D5 ships the
structural equivalent (nested Maps) in memory. File backing lands
in a later Phase. The Map structure is identical to the eventual
filesystem layout; migration is a serializer/deserializer pair.
2. keyId = '__anonymous__' at D5. Per-OLP-API-key namespacing infra
lands in Phase 2 multi-key. The constant is hardcoded in
server.mjs with a comment explaining the Phase 2 transition.
3. authContext changed from {} to null. {} ?? readAuthArtifact()
never falls back (empty object is truthy under ??); null
correctly triggers the fallback. Confirmed by D5 E2E test which
used the keychain OAuth path end-to-end.
4. cache_control side-channel via raw body. The IR translator
(lib/ir/openai-to-ir.mjs) strips cache_control because it is not
an IR v1.0 field. server.mjs bypass check uses both hasCacheControl
(ir) and extractCacheControlMarkers(body?.messages) to compensate.
The proper fix is an ADR 0003 amendment to preserve cache_control
in IR; tracked as a Phase 2 backlog item. Suite 9 Test 30 verifies
the dual-check works end-to-end over HTTP.
5. collectAllChunks throws ProviderError on type:error chunks. This
prevents cache_store.set() from being called on error-terminated
responses per ADR 0005 § Cache write conditions item 1. Anthropic
plugin currently never emits type:error chunks (throws instead),
so this is defensive code for future provider plugins.
Reviewer chain (Iron Rule 10):
Implementer: sonnet (general-purpose).
Fresh-context reviewer: opus (ecc:code-reviewer). Verdict
APPROVE_WITH_MINOR.
Reviewer ran npm test (128/128 pass with Suite 10 skipped), opened
ADR 0005 end-to-end, opened OCP keys.mjs + server.mjs to verify
singleflight precedent, verified the singleflight invariant by code
inspection (5-concurrent Test 23 plus event-loop guarantee proof).
Reviewer non-blocking findings folded in this commit:
1. peek() added to CacheStore — stats-neutral existence check.
server.mjs preCheck now uses peek() instead of has(), fixing the
hit/miss counter double-count bug. Documentation on has() updated
to point callers at peek() for stats-sensitive paths.
2. collectAllChunks now throws ProviderError on type:error chunks
instead of returning an error-terminated array, preventing cache
pollution per ADR 0005 § Cache write conditions item 1.
3. Cache key composition comment clarifies that cache_control slot is
forward-compat infrastructure for the future ADR 0003 amendment;
v1.0 IR strips cache_control so the slot is always null at the
key-composition site, with the D2 bypass side-channeling through
the raw body in server.mjs.
Reviewer findings deferred:
- IR amendment to preserve cache_control as first-class IR field
(would let server.mjs drop the dual-check). Tracked as Phase 2
backlog: "amend ADR 0003 to preserve cache_control markers in IR".
- LRU-by-linked-list eviction at maxEntriesPerKey scale. Current
O(n log n) sort-on-evict is fine at personal/family scale.
Tracked for revisit if cap is raised significantly.
Verification:
node --check on all touched files: clean.
npm test on Node 25.8.0: 128/128 pass in 210ms (default mode).
OLP_RUN_E2E=1 npm test on Mac mini: 129/129 pass in 7.4s (real
claude-haiku-4-5 spawn, "OK" response, all OLP headers correct).
hygiene grep: no personal names, no /Users literal paths, no real
OAuth tokens (test fixtures use "<fake-token>" placeholders).
Co-Authored-By: Claude Opus 4.7 (noreply@anthropic.com)
209 lines
7.6 KiB
JavaScript
209 lines
7.6 KiB
JavaScript
/**
|
|
* lib/cache/keys.mjs — Cache key generation for OLP
|
|
*
|
|
* Authority: ADR 0005 § Cache key composition (v1.0)
|
|
*
|
|
* Cache keys are computed over the IR (ADR 0003), not over the raw OpenAI
|
|
* request shape, so key stability is governed by OLP's own release cadence.
|
|
*
|
|
* Key composition:
|
|
* sha256(JSON.stringify({
|
|
* provider,
|
|
* model,
|
|
* messages: normalizeMessages(ir.messages),
|
|
* tools: ir.tools ?? null,
|
|
* temperature: ir.temperature ?? null,
|
|
* response_format: ir.response_format ?? null,
|
|
* cache_control: extractCacheControlMarkers(ir.messages),
|
|
* }))
|
|
*
|
|
* Per ADR 0005 § Per-model isolation: (provider, model) is part of the key.
|
|
* Cross-provider cache contamination is structurally impossible.
|
|
*
|
|
* Ported from OCP keys.mjs cacheHash() — generalized to include provider +
|
|
* model, uses JSON stable-key approach instead of incremental hash-update to
|
|
* support content arrays and tool definitions.
|
|
*/
|
|
|
|
import { createHash } from 'node:crypto';
|
|
|
|
// ── Message normalization ─────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Normalizes an IR message array for stable cache-key hashing.
|
|
* Strips unknown fields, normalizes content arrays to stable-keyed JSON.
|
|
*
|
|
* Fields kept per IR contract (ADR 0003):
|
|
* role, content, name, tool_calls, tool_call_id
|
|
*
|
|
* @param {Array<object>} messages
|
|
* @returns {Array<object>}
|
|
*/
|
|
function normalizeMessages(messages) {
|
|
if (!Array.isArray(messages)) return [];
|
|
|
|
return messages.map(msg => {
|
|
if (!msg || typeof msg !== 'object') return msg;
|
|
|
|
const norm = {};
|
|
|
|
// role is mandatory
|
|
if (msg.role !== undefined) norm.role = msg.role;
|
|
|
|
// content: string as-is; array → normalized recursive form
|
|
if (typeof msg.content === 'string') {
|
|
norm.content = msg.content;
|
|
} else if (Array.isArray(msg.content)) {
|
|
norm.content = normalizeContentArray(msg.content);
|
|
} else if (msg.content !== undefined) {
|
|
norm.content = msg.content;
|
|
}
|
|
|
|
// Optional fields carried in IR
|
|
if (msg.name !== undefined) norm.name = msg.name;
|
|
if (msg.tool_calls !== undefined) norm.tool_calls = normalizeToolCalls(msg.tool_calls);
|
|
if (msg.tool_call_id !== undefined) norm.tool_call_id = msg.tool_call_id;
|
|
|
|
return norm;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Normalizes a content array into stable-keyed JSON form.
|
|
* Each part is serialized with keys in sorted order so that
|
|
* equivalent parts are byte-identical regardless of property insertion order.
|
|
*
|
|
* @param {Array<object>} parts
|
|
* @returns {string} — JSON string with sorted keys per part
|
|
*/
|
|
function normalizeContentArray(parts) {
|
|
if (!Array.isArray(parts)) return parts;
|
|
return parts.map(part => {
|
|
if (!part || typeof part !== 'object') return part;
|
|
// Sort keys for stable serialization
|
|
return Object.fromEntries(
|
|
Object.keys(part).sort().map(k => [k, part[k]])
|
|
);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Normalizes tool_calls arrays, keeping only fields relevant to cache key.
|
|
*
|
|
* @param {Array<object>|undefined} toolCalls
|
|
* @returns {Array<object>|undefined}
|
|
*/
|
|
function normalizeToolCalls(toolCalls) {
|
|
if (!Array.isArray(toolCalls)) return toolCalls;
|
|
return toolCalls.map(tc => {
|
|
if (!tc || typeof tc !== 'object') return tc;
|
|
const norm = {};
|
|
if (tc.id !== undefined) norm.id = tc.id;
|
|
if (tc.type !== undefined) norm.type = tc.type;
|
|
if (tc.function !== undefined) norm.function = tc.function;
|
|
return norm;
|
|
});
|
|
}
|
|
|
|
// ── cache_control marker extraction ──────────────────────────────────────
|
|
|
|
/**
|
|
* Extracts Anthropic cache_control markers from an IR messages array.
|
|
* Used as a prerequisite for D2 (cache_control bypass).
|
|
*
|
|
* Searches messages and nested content arrays for cache_control objects.
|
|
* Per ADR 0005 § D2: Anthropic cache_control markers in the IR request
|
|
* bypass OLP's response cache when the active provider is Anthropic.
|
|
*
|
|
* @param {Array<object>} messages
|
|
* @returns {Array<object>} — array of found cache_control marker objects
|
|
*/
|
|
export function extractCacheControlMarkers(messages) {
|
|
const markers = [];
|
|
|
|
for (const msg of messages ?? []) {
|
|
if (!msg || typeof msg !== 'object') continue;
|
|
|
|
// Top-level cache_control on the message itself
|
|
if (msg.cache_control && typeof msg.cache_control === 'object') {
|
|
markers.push(msg.cache_control);
|
|
}
|
|
|
|
// Nested cache_control inside content array parts
|
|
if (Array.isArray(msg.content)) {
|
|
for (const part of msg.content) {
|
|
if (part && typeof part === 'object' && part.cache_control && typeof part.cache_control === 'object') {
|
|
markers.push(part.cache_control);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return markers;
|
|
}
|
|
|
|
/**
|
|
* Returns true if the IR request contains any Anthropic cache_control markers.
|
|
* Shortcut for the D2 bypass check in the server dispatch path.
|
|
*
|
|
* Ported from OCP keys.mjs hasCacheControl() — extended to check IR request
|
|
* shape (ir.messages) rather than raw OpenAI messages array.
|
|
*
|
|
* @param {object} ir — IR request object (ADR 0003)
|
|
* @returns {boolean}
|
|
*/
|
|
export function hasCacheControl(ir) {
|
|
if (!ir || !Array.isArray(ir.messages)) return false;
|
|
return extractCacheControlMarkers(ir.messages).length > 0;
|
|
}
|
|
|
|
// ── Cache key computation ─────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Computes a content-addressed SHA-256 cache key over the IR request.
|
|
*
|
|
* Per ADR 0005 § Cache key composition (v1.0):
|
|
* key = sha256(JSON.stringify({
|
|
* provider, model, messages, tools, temperature, response_format, cache_control
|
|
* }))
|
|
*
|
|
* The key is deterministic: same inputs → same key. No random, no timestamp.
|
|
* Numbers are included if non-default (null/undefined excluded to keep keys narrow).
|
|
*
|
|
* Cross-provider contamination is impossible: (provider, model) is part of every key.
|
|
*
|
|
* @param {string} provider — provider name, e.g. 'anthropic'
|
|
* @param {string} model — model string, e.g. 'claude-haiku-4-5'
|
|
* @param {object} ir — IR request (ADR 0003): { messages, tools?, temperature?, response_format? }
|
|
* @param {object} [_options] — reserved for future options (not used at D5)
|
|
* @returns {string} — 64-character hex SHA-256 digest
|
|
*/
|
|
export function computeCacheKey(provider, model, ir, _options = {}) {
|
|
const normalized = normalizeMessages(ir.messages ?? []);
|
|
const cacheControlMarkers = extractCacheControlMarkers(ir.messages ?? []);
|
|
|
|
// Only include fields that affect model output. Omit null/undefined to keep
|
|
// keys narrow (a request with no temperature and one with temperature=null
|
|
// produce the same key).
|
|
//
|
|
// Note on `cache_control`: this slot is in the key for forward-compatibility
|
|
// with a future ADR 0003 amendment that preserves cache_control markers in
|
|
// the IR. In v1.0 IR (the current schema), openAIToIR() strips cache_control
|
|
// because it's not an IR field, so this extractCacheControlMarkers(ir.messages)
|
|
// call always returns [] and the slot is always `null` here. The D2 bypass
|
|
// path in server.mjs side-channels through the raw body to compensate. Do
|
|
// NOT remove the slot — once IR carries cache_control, this key composition
|
|
// is already correct.
|
|
const keyObj = {
|
|
provider,
|
|
model,
|
|
messages: normalized,
|
|
tools: ir.tools ?? null,
|
|
temperature: ir.temperature ?? null,
|
|
response_format: ir.response_format ?? null,
|
|
cache_control: cacheControlMarkers.length > 0 ? cacheControlMarkers : null,
|
|
};
|
|
|
|
return createHash('sha256').update(JSON.stringify(keyObj)).digest('hex');
|
|
}
|