mirror of
https://github.com/dtzp555-max/olp.git
synced 2026-07-21 21:15:10 +00:00
feat(phase-1): land cache layer (D1+D4) + Anthropic E2E gate (D5)
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)
This commit is contained in:
co-authored by
Claude Opus 4.7 (noreply@anthropic.com)
parent
c175e8994c
commit
8dd02e77ac
Vendored
+208
@@ -0,0 +1,208 @@
|
|||||||
|
/**
|
||||||
|
* 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');
|
||||||
|
}
|
||||||
Vendored
+339
@@ -0,0 +1,339 @@
|
|||||||
|
/**
|
||||||
|
* lib/cache/store.mjs — In-process cache store with per-key isolation + singleflight
|
||||||
|
*
|
||||||
|
* Authority: ADR 0005 § D1 (per-key isolation) + D4 (singleflight)
|
||||||
|
*
|
||||||
|
* This module implements the OLP response cache. At D5 the backing store is
|
||||||
|
* an in-memory Map (not file-backed). File backing lands in a later Phase.
|
||||||
|
* The architecture mirrors OCP v3.13.0 (D1+D4) generalized to OLP's
|
||||||
|
* (provider, model) keyed cache key space.
|
||||||
|
*
|
||||||
|
* D1 — Per-key isolation:
|
||||||
|
* Outer Map: keyId → inner Map
|
||||||
|
* Inner Map: cacheKey (SHA-256 hex) → CacheEntry
|
||||||
|
*
|
||||||
|
* keyId is the OLP API key identifier (Phase 2 multi-key). At D5, the server
|
||||||
|
* passes '__anonymous__' as keyId for all requests.
|
||||||
|
*
|
||||||
|
* D4 — Singleflight:
|
||||||
|
* Concurrent requests with the same (keyId, cacheKey) share one compute
|
||||||
|
* invocation. The first caller triggers computeFn(); subsequent callers
|
||||||
|
* await the same Promise. After completion, all callers receive the same
|
||||||
|
* value, and the result is cached for future callers.
|
||||||
|
*
|
||||||
|
* D2/D3 are placeholders at D5:
|
||||||
|
* D2 (cache_control bypass) — checked in server.mjs before entering getOrCompute.
|
||||||
|
* D3 (chunked stream replay with timing fidelity) — at D5 the cache stores
|
||||||
|
* full collected chunks and replays them sequentially without timing.
|
||||||
|
* Full D3 (timing-accurate replay) lands in a later Phase.
|
||||||
|
*
|
||||||
|
* Per OCP learning (MEMORY.md learnings/concurrency_dedup_test_signals.md):
|
||||||
|
* Singleflight is verified by observing that N concurrent requests for the
|
||||||
|
* same key return within ~0ms of each other (not by counting spawn log lines).
|
||||||
|
* The content-hash for the cache key must NOT include randomUUID or any
|
||||||
|
* per-request random component — only the semantic content that affects output.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ── Types ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} CacheEntry
|
||||||
|
* @property {*} value - the cached response (IR chunk array for streaming, response object for non-streaming)
|
||||||
|
* @property {number} createdAt - epoch ms (Date.now()) when the entry was created
|
||||||
|
* @property {number} ttlMs - time-to-live in milliseconds
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} CacheStats
|
||||||
|
* @property {number} hits
|
||||||
|
* @property {number} misses
|
||||||
|
* @property {number} size
|
||||||
|
* @property {number} inflightCount
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ── CacheStore ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export class CacheStore {
|
||||||
|
/**
|
||||||
|
* @param {object} [opts]
|
||||||
|
* @param {number} [opts.maxEntriesPerKey=1000] — evict oldest entries when exceeded
|
||||||
|
* @param {number} [opts.maxAgeMs=86400000] — default TTL: 24 hours
|
||||||
|
* @param {() => number} [opts._nowFn=Date.now] — injectable for TTL testing
|
||||||
|
*/
|
||||||
|
constructor(opts = {}) {
|
||||||
|
this._maxEntriesPerKey = opts.maxEntriesPerKey ?? 1000;
|
||||||
|
this._maxAgeMs = opts.maxAgeMs ?? 24 * 60 * 60 * 1000; // 24 hours
|
||||||
|
this._nowFn = opts._nowFn ?? (() => Date.now());
|
||||||
|
|
||||||
|
// D1 per-key isolation: keyId → Map<cacheKey, CacheEntry>
|
||||||
|
/** @type {Map<string, Map<string, CacheEntry>>} */
|
||||||
|
this._store = new Map();
|
||||||
|
|
||||||
|
// D4 singleflight: `${keyId}:${cacheKey}` → Promise<value>
|
||||||
|
/** @type {Map<string, Promise<*>>} */
|
||||||
|
this._inflight = new Map();
|
||||||
|
|
||||||
|
// Stats per keyId
|
||||||
|
/** @type {Map<string, { hits: number, misses: number }>} */
|
||||||
|
this._stats = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Internal helpers ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns or creates the inner Map for a given keyId.
|
||||||
|
* @param {string} keyId
|
||||||
|
* @returns {Map<string, CacheEntry>}
|
||||||
|
*/
|
||||||
|
_getNamespace(keyId) {
|
||||||
|
if (!this._store.has(keyId)) {
|
||||||
|
this._store.set(keyId, new Map());
|
||||||
|
}
|
||||||
|
return this._store.get(keyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns or creates the stats object for a given keyId.
|
||||||
|
* @param {string} keyId
|
||||||
|
* @returns {{ hits: number, misses: number }}
|
||||||
|
*/
|
||||||
|
_getStats(keyId) {
|
||||||
|
if (!this._stats.has(keyId)) {
|
||||||
|
this._stats.set(keyId, { hits: 0, misses: 0 });
|
||||||
|
}
|
||||||
|
return this._stats.get(keyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if entry has not yet expired.
|
||||||
|
* @param {CacheEntry} entry
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
_isAlive(entry) {
|
||||||
|
const age = this._nowFn() - entry.createdAt;
|
||||||
|
return age < entry.ttlMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evicts oldest entries in a namespace when it exceeds maxEntriesPerKey.
|
||||||
|
* @param {Map<string, CacheEntry>} ns
|
||||||
|
*/
|
||||||
|
_evictIfNeeded(ns) {
|
||||||
|
if (ns.size <= this._maxEntriesPerKey) return;
|
||||||
|
|
||||||
|
// Collect entries sorted by createdAt ascending (oldest first)
|
||||||
|
const entries = [...ns.entries()].sort((a, b) => a[1].createdAt - b[1].createdAt);
|
||||||
|
const toEvict = ns.size - this._maxEntriesPerKey;
|
||||||
|
for (let i = 0; i < toEvict; i++) {
|
||||||
|
ns.delete(entries[i][0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public API ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves a cached entry. Returns null on miss or TTL expiry.
|
||||||
|
* Updates stats.
|
||||||
|
*
|
||||||
|
* @param {string} keyId - OLP API key namespace (use '__anonymous__' at D5)
|
||||||
|
* @param {string} cacheKey - SHA-256 hex from computeCacheKey()
|
||||||
|
* @returns {Promise<CacheEntry|null>}
|
||||||
|
*/
|
||||||
|
async get(keyId, cacheKey) {
|
||||||
|
const ns = this._getNamespace(keyId);
|
||||||
|
const entry = ns.get(cacheKey);
|
||||||
|
|
||||||
|
if (!entry) {
|
||||||
|
this._getStats(keyId).misses++;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this._isAlive(entry)) {
|
||||||
|
ns.delete(cacheKey);
|
||||||
|
this._getStats(keyId).misses++;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._getStats(keyId).hits++;
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores a value in the cache.
|
||||||
|
*
|
||||||
|
* @param {string} keyId
|
||||||
|
* @param {string} cacheKey
|
||||||
|
* @param {*} value
|
||||||
|
* @param {number} [ttlMs] - overrides default maxAgeMs
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async set(keyId, cacheKey, value, ttlMs) {
|
||||||
|
const ns = this._getNamespace(keyId);
|
||||||
|
const entry = {
|
||||||
|
value,
|
||||||
|
createdAt: this._nowFn(),
|
||||||
|
ttlMs: ttlMs ?? this._maxAgeMs,
|
||||||
|
};
|
||||||
|
ns.set(cacheKey, entry);
|
||||||
|
this._evictIfNeeded(ns);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if a valid (non-expired) entry exists for (keyId, cacheKey).
|
||||||
|
*
|
||||||
|
* Stats side-effect: this method calls get(), which increments hit/miss
|
||||||
|
* counters. Callers that need to peek WITHOUT touching stats should use
|
||||||
|
* peek() instead. Server-side cache-status reporting MUST use peek() to
|
||||||
|
* avoid double-counting when getOrCompute() is called immediately after.
|
||||||
|
*
|
||||||
|
* @param {string} keyId
|
||||||
|
* @param {string} cacheKey
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
async has(keyId, cacheKey) {
|
||||||
|
const entry = await this.get(keyId, cacheKey);
|
||||||
|
return entry !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stats-neutral cache peek. Returns true if a valid entry exists without
|
||||||
|
* incrementing hit/miss counters. Use this from server.mjs for pre-check
|
||||||
|
* cache-status reporting; the subsequent getOrCompute() call is the one
|
||||||
|
* that increments the canonical stats counter at the semantic boundary.
|
||||||
|
*
|
||||||
|
* Fixes the codex-flagged double-count bug where has() + getOrCompute()
|
||||||
|
* both incremented the same counter on the same logical request.
|
||||||
|
*
|
||||||
|
* @param {string} keyId
|
||||||
|
* @param {string} cacheKey
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
async peek(keyId, cacheKey) {
|
||||||
|
const ns = this._getNamespace(keyId);
|
||||||
|
const entry = ns.get(cacheKey);
|
||||||
|
if (!entry) return false;
|
||||||
|
if (!this._isAlive(entry)) {
|
||||||
|
ns.delete(cacheKey);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* D4 singleflight: get from cache or compute exactly once for concurrent callers.
|
||||||
|
*
|
||||||
|
* - Cache hit: return cached value immediately (no computeFn invocation).
|
||||||
|
* - Inflight hit: await the existing inflight Promise (singleflight — computeFn
|
||||||
|
* runs exactly once for N concurrent callers with the same key).
|
||||||
|
* - Miss: register inflight promise, invoke computeFn(), cache the result,
|
||||||
|
* release the inflight promise, return the value.
|
||||||
|
* - On computeFn error: release the inflight promise (so subsequent calls retry),
|
||||||
|
* rethrow the error to all awaiting callers.
|
||||||
|
*
|
||||||
|
* Per OCP MEMORY.md learning (concurrency_dedup_test_signals.md):
|
||||||
|
* Singleflight is verified by timing: N concurrent calls return within ~ms
|
||||||
|
* of each other, and computeFn is called exactly once.
|
||||||
|
*
|
||||||
|
* @param {string} keyId
|
||||||
|
* @param {string} cacheKey
|
||||||
|
* @param {() => Promise<*>} computeFn - async function producing the value
|
||||||
|
* @param {number} [ttlMs]
|
||||||
|
* @returns {Promise<*>}
|
||||||
|
*/
|
||||||
|
async getOrCompute(keyId, cacheKey, computeFn, ttlMs) {
|
||||||
|
// 1. Cache hit — return immediately, no singleflight overhead
|
||||||
|
const ns = this._getNamespace(keyId);
|
||||||
|
const existing = ns.get(cacheKey);
|
||||||
|
if (existing && this._isAlive(existing)) {
|
||||||
|
this._getStats(keyId).hits++;
|
||||||
|
return existing.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Inflight hit — singleflight: await the in-progress compute
|
||||||
|
const inflightKey = `${keyId}:${cacheKey}`;
|
||||||
|
const inFlight = this._inflight.get(inflightKey);
|
||||||
|
if (inFlight) {
|
||||||
|
// Another caller is already computing this key. Await their result.
|
||||||
|
// The first caller will cache the result; we return the same value.
|
||||||
|
return inFlight;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Miss — we are the first caller; register inflight promise
|
||||||
|
const computePromise = (async () => {
|
||||||
|
try {
|
||||||
|
const value = await computeFn();
|
||||||
|
// Cache the result so future calls are cache hits
|
||||||
|
await this.set(keyId, cacheKey, value, ttlMs);
|
||||||
|
this._getStats(keyId).misses++;
|
||||||
|
return value;
|
||||||
|
} finally {
|
||||||
|
// Always release the inflight slot, even on error
|
||||||
|
this._inflight.delete(inflightKey);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
this._inflight.set(inflightKey, computePromise);
|
||||||
|
|
||||||
|
return computePromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns stats for a specific keyId, or aggregate stats across all keyIds.
|
||||||
|
*
|
||||||
|
* @param {string} [keyId] - if omitted, returns aggregate across all keys
|
||||||
|
* @returns {CacheStats}
|
||||||
|
*/
|
||||||
|
stats(keyId) {
|
||||||
|
if (keyId !== undefined) {
|
||||||
|
const s = this._stats.get(keyId) ?? { hits: 0, misses: 0 };
|
||||||
|
const ns = this._store.get(keyId);
|
||||||
|
const size = ns ? ns.size : 0;
|
||||||
|
return {
|
||||||
|
hits: s.hits,
|
||||||
|
misses: s.misses,
|
||||||
|
size,
|
||||||
|
inflightCount: this._inflight.size,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aggregate
|
||||||
|
let totalHits = 0;
|
||||||
|
let totalMisses = 0;
|
||||||
|
let totalSize = 0;
|
||||||
|
for (const s of this._stats.values()) {
|
||||||
|
totalHits += s.hits;
|
||||||
|
totalMisses += s.misses;
|
||||||
|
}
|
||||||
|
for (const ns of this._store.values()) {
|
||||||
|
totalSize += ns.size;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
hits: totalHits,
|
||||||
|
misses: totalMisses,
|
||||||
|
size: totalSize,
|
||||||
|
inflightCount: this._inflight.size,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears cache entries (and stats) for a specific keyId, or ALL entries.
|
||||||
|
*
|
||||||
|
* @param {string} [keyId] - if omitted, clears all namespaces
|
||||||
|
*/
|
||||||
|
clear(keyId) {
|
||||||
|
if (keyId !== undefined) {
|
||||||
|
this._store.delete(keyId);
|
||||||
|
this._stats.delete(keyId);
|
||||||
|
// Also remove any inflight entries for this keyId
|
||||||
|
for (const k of this._inflight.keys()) {
|
||||||
|
if (k.startsWith(`${keyId}:`)) {
|
||||||
|
this._inflight.delete(k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this._store.clear();
|
||||||
|
this._stats.clear();
|
||||||
|
this._inflight.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+116
-25
@@ -6,6 +6,7 @@
|
|||||||
* https://platform.openai.com/docs/api-reference/chat/create
|
* https://platform.openai.com/docs/api-reference/chat/create
|
||||||
* Authority (IR): ADR 0003
|
* Authority (IR): ADR 0003
|
||||||
* Authority (provider dispatch): ADR 0002
|
* Authority (provider dispatch): ADR 0002
|
||||||
|
* Authority (cache layer): ADR 0005
|
||||||
*
|
*
|
||||||
* Design principles (OCP precedent, ESM/.mjs, http built-ins, no external deps):
|
* Design principles (OCP precedent, ESM/.mjs, http built-ins, no external deps):
|
||||||
* - Node ESM, no build step, no bundler
|
* - Node ESM, no build step, no bundler
|
||||||
@@ -30,6 +31,8 @@ import {
|
|||||||
} from './lib/ir/ir-to-openai.mjs';
|
} from './lib/ir/ir-to-openai.mjs';
|
||||||
import { loadProviders, getProviderForModel, listAllProviderNames } from './lib/providers/index.mjs';
|
import { loadProviders, getProviderForModel, listAllProviderNames } from './lib/providers/index.mjs';
|
||||||
import { ProviderError } from './lib/providers/base.mjs';
|
import { ProviderError } from './lib/providers/base.mjs';
|
||||||
|
import { computeCacheKey, hasCacheControl, extractCacheControlMarkers } from './lib/cache/keys.mjs';
|
||||||
|
import { CacheStore } from './lib/cache/store.mjs';
|
||||||
|
|
||||||
// ── Config ────────────────────────────────────────────────────────────────
|
// ── Config ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -45,6 +48,12 @@ const BODY_LIMIT = 5 * 1024 * 1024; // 5 MB
|
|||||||
// Empty config → empty loaded map → all POST /v1/chat/completions → 503.
|
// Empty config → empty loaded map → all POST /v1/chat/completions → 503.
|
||||||
const loadedProviders = loadProviders({ enabled: {} });
|
const loadedProviders = loadProviders({ enabled: {} });
|
||||||
|
|
||||||
|
// ── Cache layer ───────────────────────────────────────────────────────────
|
||||||
|
// D1 per-key isolation + D4 singleflight per ADR 0005.
|
||||||
|
// keyId: '__anonymous__' at D5 — Phase 2 multi-key infrastructure wires in
|
||||||
|
// the real OLP API key ID here.
|
||||||
|
export const cacheStore = new CacheStore();
|
||||||
|
|
||||||
// ── Logging ───────────────────────────────────────────────────────────────
|
// ── Logging ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function logEvent(level, event, data = {}) {
|
function logEvent(level, event, data = {}) {
|
||||||
@@ -125,21 +134,22 @@ function sendError(res, status, message, type) {
|
|||||||
* Returns the standard OLP diagnostic headers.
|
* Returns the standard OLP diagnostic headers.
|
||||||
* Per spec: X-OLP-Provider-Used, X-OLP-Model-Used, X-OLP-Fallback-Hops,
|
* Per spec: X-OLP-Provider-Used, X-OLP-Model-Used, X-OLP-Fallback-Hops,
|
||||||
* X-OLP-Cache, X-OLP-Latency-Ms.
|
* X-OLP-Cache, X-OLP-Latency-Ms.
|
||||||
* Fallback-Hops is always 0 at D3 (no fallback engine yet — ADR 0004).
|
* Fallback-Hops is always 0 at D5 (no fallback engine yet — ADR 0004).
|
||||||
* Cache is always 'miss' at D3 (no cache layer yet — ADR 0005).
|
* Cache reflects actual hit/miss/bypass status from the cache layer (ADR 0005).
|
||||||
*
|
*
|
||||||
* @param {object} opts
|
* @param {object} opts
|
||||||
* @param {string} opts.providerUsed
|
* @param {string} opts.providerUsed
|
||||||
* @param {string} opts.modelUsed
|
* @param {string} opts.modelUsed
|
||||||
* @param {number} opts.startMs
|
* @param {number} opts.startMs
|
||||||
|
* @param {'hit'|'miss'|'bypass'} [opts.cacheStatus='miss']
|
||||||
* @returns {Record<string,string>}
|
* @returns {Record<string,string>}
|
||||||
*/
|
*/
|
||||||
function olpHeaders({ providerUsed, modelUsed, startMs }) {
|
function olpHeaders({ providerUsed, modelUsed, startMs, cacheStatus = 'miss' }) {
|
||||||
return {
|
return {
|
||||||
'X-OLP-Provider-Used': providerUsed,
|
'X-OLP-Provider-Used': providerUsed,
|
||||||
'X-OLP-Model-Used': modelUsed,
|
'X-OLP-Model-Used': modelUsed,
|
||||||
'X-OLP-Fallback-Hops': '0',
|
'X-OLP-Fallback-Hops': '0',
|
||||||
'X-OLP-Cache': 'miss',
|
'X-OLP-Cache': cacheStatus,
|
||||||
'X-OLP-Latency-Ms': String(Date.now() - startMs),
|
'X-OLP-Latency-Ms': String(Date.now() - startMs),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -217,13 +227,88 @@ async function handleChatCompletions(req, res) {
|
|||||||
const { provider, name: providerName } = match;
|
const { provider, name: providerName } = match;
|
||||||
const requestId = generateRequestId();
|
const requestId = generateRequestId();
|
||||||
|
|
||||||
// Auth context is a stub at D3 — providers will populate this in Phase 1 Day 2+
|
// Auth context is null at D5 — providers fall back to their own credential
|
||||||
const authContext = {};
|
// discovery (env var, keychain, credentials file). Phase 2 multi-key
|
||||||
|
// infrastructure will pass a real authContext carrying the per-key OLP token.
|
||||||
|
const authContext = null;
|
||||||
|
|
||||||
const headers = olpHeaders({ providerUsed: providerName, modelUsed: ir.model, startMs });
|
// ── Cache layer (ADR 0005) ──────────────────────────────────────────────
|
||||||
|
// keyId: '__anonymous__' at D5. Phase 2 multi-key infrastructure wires the
|
||||||
|
// real OLP API key ID here for D1 per-key isolation.
|
||||||
|
const keyId = '__anonymous__';
|
||||||
|
const cacheKey = computeCacheKey(providerName, ir.model, ir);
|
||||||
|
|
||||||
|
// D2 bypass: if the request contains Anthropic cache_control markers,
|
||||||
|
// skip OLP's response cache entirely (the prompt cache lives at Anthropic's
|
||||||
|
// side; double-caching would shadow Anthropic's TTLs per ADR 0005 § D2).
|
||||||
|
//
|
||||||
|
// Note: hasCacheControl() checks the IR (ADR 0003). Since openAIToIR() does not
|
||||||
|
// preserve cache_control fields from the raw OpenAI message shape (those fields
|
||||||
|
// are Anthropic-specific extensions, not part of the IR schema), we also check
|
||||||
|
// the raw body.messages directly via extractCacheControlMarkers. This ensures
|
||||||
|
// D2 bypass is triggered even though the IR translator strips the field.
|
||||||
|
const bypassCache = hasCacheControl(ir) || extractCacheControlMarkers(body?.messages ?? []).length > 0;
|
||||||
|
|
||||||
|
if (bypassCache) {
|
||||||
|
logEvent('debug', 'cache_bypass', { provider: providerName, model: ir.model, reason: 'cache_control_markers' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Collect chunks helper (used by both streaming and non-streaming paths) ──
|
||||||
|
// collectAllChunks wraps provider.spawn() to collect all IR chunks into an
|
||||||
|
// array. Used as the computeFn for getOrCompute (D4 singleflight).
|
||||||
|
//
|
||||||
|
// Error semantics: if the provider emits a `type: 'error'` chunk, we throw
|
||||||
|
// a ProviderError instead of returning the chunk array. This prevents
|
||||||
|
// cache_store.set() from being called on an error-terminated response, per
|
||||||
|
// ADR 0005 § "Cache write conditions" item 1 ("The response completed
|
||||||
|
// successfully (no truncation, no error mid-stream)"). The thrown error
|
||||||
|
// propagates to the catch block at the call site, which returns 502 to
|
||||||
|
// the client without writing to cache.
|
||||||
|
async function collectAllChunks() {
|
||||||
|
const chunks = [];
|
||||||
|
for await (const irChunk of provider.spawn(ir, authContext)) {
|
||||||
|
chunks.push(irChunk);
|
||||||
|
if (irChunk.type === 'error') {
|
||||||
|
throw new ProviderError(
|
||||||
|
irChunk.error ?? 'Provider emitted error chunk',
|
||||||
|
'SPAWN_FAILED',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (irChunk.type === 'stop') break;
|
||||||
|
}
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-check: stats-neutral peek before calling getOrCompute so we can
|
||||||
|
// reliably report hit/miss in the response header. peek() does NOT touch
|
||||||
|
// hit/miss counters (fix for codex-flagged double-count); getOrCompute()
|
||||||
|
// increments at the semantic boundary.
|
||||||
|
const preCheckHit = bypassCache ? false : await cacheStore.peek(keyId, cacheKey);
|
||||||
|
|
||||||
if (ir.stream) {
|
if (ir.stream) {
|
||||||
// Streaming response path
|
// Streaming response path
|
||||||
|
// D5 simplified D3: cache stores full collected chunks, replays them one
|
||||||
|
// at a time without timing fidelity. Full D3 (timing-accurate replay) lands
|
||||||
|
// in a later Phase per ADR 0005 § D3.
|
||||||
|
let chunks;
|
||||||
|
let cacheStatus;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (bypassCache) {
|
||||||
|
cacheStatus = 'bypass';
|
||||||
|
chunks = await collectAllChunks();
|
||||||
|
} else {
|
||||||
|
// D4 singleflight + D1 per-key isolation
|
||||||
|
chunks = await cacheStore.getOrCompute(keyId, cacheKey, collectAllChunks);
|
||||||
|
cacheStatus = preCheckHit ? 'hit' : 'miss';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logEvent('error', 'spawn_error', { provider: providerName, model: ir.model, error: e.message });
|
||||||
|
sendError(res, e instanceof ProviderError ? 502 : 500, e.message ?? 'Provider error', 'provider_error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = olpHeaders({ providerUsed: providerName, modelUsed: ir.model, startMs, cacheStatus });
|
||||||
res.writeHead(200, {
|
res.writeHead(200, {
|
||||||
'Content-Type': 'text/event-stream',
|
'Content-Type': 'text/event-stream',
|
||||||
'Cache-Control': 'no-cache',
|
'Cache-Control': 'no-cache',
|
||||||
@@ -232,35 +317,41 @@ async function handleChatCompletions(req, res) {
|
|||||||
...headers,
|
...headers,
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
// D3 simplified replay: emit collected chunks sequentially
|
||||||
for await (const irChunk of provider.spawn(ir, authContext)) {
|
for (const irChunk of chunks) {
|
||||||
res.write(irChunkToOpenAISSE(irChunk, requestId, ir.model));
|
res.write(irChunkToOpenAISSE(irChunk, requestId, ir.model));
|
||||||
if (irChunk.type === 'stop' || irChunk.type === 'error') break;
|
if (irChunk.type === 'stop' || irChunk.type === 'error') break;
|
||||||
}
|
|
||||||
res.write(SSE_DONE);
|
|
||||||
} catch (e) {
|
|
||||||
// Best-effort error reporting in the stream
|
|
||||||
const errChunk = { type: 'error', error: e.message ?? 'Provider spawn failed' };
|
|
||||||
res.write(irChunkToOpenAISSE(errChunk, requestId, ir.model));
|
|
||||||
res.write(SSE_DONE);
|
|
||||||
logEvent('error', 'spawn_error', { provider: providerName, model: ir.model, error: e.message });
|
|
||||||
}
|
}
|
||||||
|
res.write(SSE_DONE);
|
||||||
res.end();
|
res.end();
|
||||||
} else {
|
} else {
|
||||||
// Non-streaming response path
|
// Non-streaming response path
|
||||||
|
let cacheStatus;
|
||||||
|
let responseObj;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const chunks = [];
|
if (bypassCache) {
|
||||||
for await (const irChunk of provider.spawn(ir, authContext)) {
|
cacheStatus = 'bypass';
|
||||||
chunks.push(irChunk);
|
const chunks = await collectAllChunks();
|
||||||
if (irChunk.type === 'stop' || irChunk.type === 'error') break;
|
responseObj = irResponseToOpenAINonStream(chunks, requestId, ir.model);
|
||||||
|
} else {
|
||||||
|
// D4 singleflight: concurrent identical requests share one spawn.
|
||||||
|
// We cache the full IR chunk array and assemble the OpenAI response on
|
||||||
|
// each retrieval so the requestId is fresh per request (per OpenAI spec
|
||||||
|
// each response has a unique id).
|
||||||
|
const chunks = await cacheStore.getOrCompute(keyId, cacheKey, collectAllChunks);
|
||||||
|
cacheStatus = preCheckHit ? 'hit' : 'miss';
|
||||||
|
responseObj = irResponseToOpenAINonStream(chunks, requestId, ir.model);
|
||||||
}
|
}
|
||||||
const response = irResponseToOpenAINonStream(chunks, requestId, ir.model);
|
|
||||||
sendJSON(res, 200, response, headers);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logEvent('error', 'spawn_error', { provider: providerName, model: ir.model, error: e.message });
|
logEvent('error', 'spawn_error', { provider: providerName, model: ir.model, error: e.message });
|
||||||
const status = e instanceof ProviderError ? 502 : 500;
|
const status = e instanceof ProviderError ? 502 : 500;
|
||||||
sendError(res, status, e.message ?? 'Provider error', 'provider_error');
|
sendError(res, status, e.message ?? 'Provider error', 'provider_error');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const headers = olpHeaders({ providerUsed: providerName, modelUsed: ir.model, startMs, cacheStatus });
|
||||||
|
sendJSON(res, 200, responseObj, headers);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+612
-2
@@ -1,11 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* test-features.mjs — OLP D4 test suite (extends D3)
|
* test-features.mjs — OLP D5 test suite (extends D4)
|
||||||
*
|
*
|
||||||
* Uses Node's built-in node:test runner. No external dependencies.
|
* Uses Node's built-in node:test runner. No external dependencies.
|
||||||
* Run: node test-features.mjs (or: npm test)
|
* Run: node test-features.mjs (or: npm test)
|
||||||
*
|
*
|
||||||
* Authority: ADR 0002 (provider contract), ADR 0003 (IR v1.0)
|
* Authority: ADR 0002 (provider contract), ADR 0003 (IR v1.0), ADR 0005 (cache layer)
|
||||||
* D4 adds: Anthropic plugin conformance, IR translation, mock-spawn behaviour.
|
* D4 adds: Anthropic plugin conformance, IR translation, mock-spawn behaviour.
|
||||||
|
* D5 adds: Suite 9 (cache layer unit + HTTP integration), Suite 10 (Anthropic E2E gated)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, before, after } from 'node:test';
|
import { describe, it, before, after } from 'node:test';
|
||||||
@@ -13,6 +14,8 @@ import assert from 'node:assert/strict';
|
|||||||
import { request as httpRequest } from 'node:http';
|
import { request as httpRequest } from 'node:http';
|
||||||
import { EventEmitter } from 'node:events';
|
import { EventEmitter } from 'node:events';
|
||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
|
import { computeCacheKey, extractCacheControlMarkers, hasCacheControl } from './lib/cache/keys.mjs';
|
||||||
|
import { CacheStore } from './lib/cache/store.mjs';
|
||||||
|
|
||||||
// ── Modules under test ────────────────────────────────────────────────────
|
// ── Modules under test ────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -1091,3 +1094,610 @@ describe('HTTP integration', () => {
|
|||||||
assert.equal(result.status, 415);
|
assert.equal(result.status, 415);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Suite 8: Suite 8 formerly counted as D3/D4 base; suites renumber here ──
|
||||||
|
// (No new Suite 8 — the numbering skips from 7 to 9 to match D5 spec.)
|
||||||
|
|
||||||
|
// ── Suite 9: Cache layer ──────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Unit tests + HTTP integration tests for ADR 0005 (D1 + D4).
|
||||||
|
// No real `claude` binary invoked. Mock spawn injected via __setSpawnImpl.
|
||||||
|
// Authority: ADR 0005 § Cache key composition, D1 per-key isolation, D4 singleflight.
|
||||||
|
|
||||||
|
describe('Cache layer — computeCacheKey (Suite 9)', () => {
|
||||||
|
|
||||||
|
// ── Test 1: Determinism ───────────────────────────────────────────────
|
||||||
|
it('computeCacheKey is deterministic: same inputs → same key', () => {
|
||||||
|
const ir = makeIR({ model: 'claude-haiku-4-5', messages: [{ role: 'user', content: 'hello' }] });
|
||||||
|
const k1 = computeCacheKey('anthropic', 'claude-haiku-4-5', ir);
|
||||||
|
const k2 = computeCacheKey('anthropic', 'claude-haiku-4-5', ir);
|
||||||
|
assert.equal(k1, k2);
|
||||||
|
assert.equal(typeof k1, 'string');
|
||||||
|
assert.equal(k1.length, 64); // SHA-256 hex
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Test 2: Provider distinguishes ───────────────────────────────────
|
||||||
|
it('computeCacheKey distinguishes different providers', () => {
|
||||||
|
const ir = makeIR({ model: 'model-x', messages: [{ role: 'user', content: 'hi' }] });
|
||||||
|
const k1 = computeCacheKey('anthropic', 'model-x', ir);
|
||||||
|
const k2 = computeCacheKey('openai', 'model-x', ir);
|
||||||
|
assert.notEqual(k1, k2);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Test 3: Model distinguishes ───────────────────────────────────────
|
||||||
|
it('computeCacheKey distinguishes different models', () => {
|
||||||
|
const ir = makeIR({ messages: [{ role: 'user', content: 'hi' }] });
|
||||||
|
const k1 = computeCacheKey('anthropic', 'claude-sonnet-4-6', ir);
|
||||||
|
const k2 = computeCacheKey('anthropic', 'claude-haiku-4-5', ir);
|
||||||
|
assert.notEqual(k1, k2);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Test 4: Messages distinguishes ───────────────────────────────────
|
||||||
|
it('computeCacheKey distinguishes different messages', () => {
|
||||||
|
const ir1 = makeIR({ messages: [{ role: 'user', content: 'hello' }] });
|
||||||
|
const ir2 = makeIR({ messages: [{ role: 'user', content: 'world' }] });
|
||||||
|
const k1 = computeCacheKey('anthropic', 'claude-haiku-4-5', ir1);
|
||||||
|
const k2 = computeCacheKey('anthropic', 'claude-haiku-4-5', ir2);
|
||||||
|
assert.notEqual(k1, k2);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Test 5: Tools distinguishes ───────────────────────────────────────
|
||||||
|
it('computeCacheKey distinguishes requests with vs without tools', () => {
|
||||||
|
const base = makeIR({ messages: [{ role: 'user', content: 'search' }] });
|
||||||
|
const withTools = makeIR({
|
||||||
|
messages: [{ role: 'user', content: 'search' }],
|
||||||
|
tools: [{ type: 'function', function: { name: 'search', description: 'web search', parameters: {} } }],
|
||||||
|
});
|
||||||
|
const k1 = computeCacheKey('anthropic', 'claude-haiku-4-5', base);
|
||||||
|
const k2 = computeCacheKey('anthropic', 'claude-haiku-4-5', withTools);
|
||||||
|
assert.notEqual(k1, k2);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Test 6: Temperature distinguishes ────────────────────────────────
|
||||||
|
it('computeCacheKey distinguishes different temperature values', () => {
|
||||||
|
const ir1 = makeIR({ messages: [{ role: 'user', content: 'x' }], temperature: 0.0 });
|
||||||
|
const ir2 = makeIR({ messages: [{ role: 'user', content: 'x' }], temperature: 1.0 });
|
||||||
|
const k1 = computeCacheKey('anthropic', 'claude-haiku-4-5', ir1);
|
||||||
|
const k2 = computeCacheKey('anthropic', 'claude-haiku-4-5', ir2);
|
||||||
|
assert.notEqual(k1, k2);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Test 7: response_format distinguishes ────────────────────────────
|
||||||
|
it('computeCacheKey distinguishes different response_format values', () => {
|
||||||
|
const ir1 = makeIR({ messages: [{ role: 'user', content: 'x' }] });
|
||||||
|
const ir2 = makeIR({ messages: [{ role: 'user', content: 'x' }], response_format: { type: 'json_object' } });
|
||||||
|
const k1 = computeCacheKey('anthropic', 'claude-haiku-4-5', ir1);
|
||||||
|
const k2 = computeCacheKey('anthropic', 'claude-haiku-4-5', ir2);
|
||||||
|
assert.notEqual(k1, k2);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Test 8: Content array property order stability ───────────────────
|
||||||
|
it('computeCacheKey is stable for content arrays with same properties in different insertion order', () => {
|
||||||
|
const ir1 = makeIR({
|
||||||
|
messages: [{ role: 'user', content: [{ type: 'text', text: 'hi', extra: 1 }] }],
|
||||||
|
});
|
||||||
|
const ir2 = makeIR({
|
||||||
|
messages: [{ role: 'user', content: [{ extra: 1, text: 'hi', type: 'text' }] }],
|
||||||
|
});
|
||||||
|
const k1 = computeCacheKey('anthropic', 'claude-haiku-4-5', ir1);
|
||||||
|
const k2 = computeCacheKey('anthropic', 'claude-haiku-4-5', ir2);
|
||||||
|
assert.equal(k1, k2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Cache layer — extractCacheControlMarkers + hasCacheControl (Suite 9 cont.)', () => {
|
||||||
|
|
||||||
|
// ── Test 9: extractCacheControlMarkers — top-level ───────────────────
|
||||||
|
it('extractCacheControlMarkers finds cache_control at message top level', () => {
|
||||||
|
const messages = [
|
||||||
|
{ role: 'user', content: 'hi', cache_control: { type: 'ephemeral' } },
|
||||||
|
];
|
||||||
|
const markers = extractCacheControlMarkers(messages);
|
||||||
|
assert.equal(markers.length, 1);
|
||||||
|
assert.deepEqual(markers[0], { type: 'ephemeral' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Test 10: extractCacheControlMarkers — nested in content array ─────
|
||||||
|
it('extractCacheControlMarkers finds cache_control nested in content array', () => {
|
||||||
|
const messages = [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: [
|
||||||
|
{ type: 'text', text: 'hello', cache_control: { type: 'ephemeral' } },
|
||||||
|
{ type: 'text', text: 'world' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const markers = extractCacheControlMarkers(messages);
|
||||||
|
assert.equal(markers.length, 1);
|
||||||
|
assert.deepEqual(markers[0], { type: 'ephemeral' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Test 11: extractCacheControlMarkers — no markers ─────────────────
|
||||||
|
it('extractCacheControlMarkers returns [] when no cache_control markers present', () => {
|
||||||
|
const messages = [
|
||||||
|
{ role: 'user', content: 'hello' },
|
||||||
|
{ role: 'assistant', content: 'world' },
|
||||||
|
];
|
||||||
|
assert.deepEqual(extractCacheControlMarkers(messages), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Test 12: hasCacheControl — true ──────────────────────────────────
|
||||||
|
it('hasCacheControl returns true when cache_control markers present', () => {
|
||||||
|
const ir = makeIR({
|
||||||
|
messages: [{ role: 'user', content: 'hi', cache_control: { type: 'ephemeral' } }],
|
||||||
|
});
|
||||||
|
assert.equal(hasCacheControl(ir), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Test 13: hasCacheControl — false ─────────────────────────────────
|
||||||
|
it('hasCacheControl returns false when no cache_control markers', () => {
|
||||||
|
const ir = makeIR({ messages: [{ role: 'user', content: 'hi' }] });
|
||||||
|
assert.equal(hasCacheControl(ir), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Test 14: hasCacheControl — null/undefined safety ─────────────────
|
||||||
|
it('hasCacheControl returns false for null/undefined ir', () => {
|
||||||
|
assert.equal(hasCacheControl(null), false);
|
||||||
|
assert.equal(hasCacheControl(undefined), false);
|
||||||
|
assert.equal(hasCacheControl({}), false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Cache layer — CacheStore unit tests (Suite 9 cont.)', () => {
|
||||||
|
|
||||||
|
// ── Test 15: set/get round-trip ───────────────────────────────────────
|
||||||
|
it('CacheStore.set/get round-trips a value', async () => {
|
||||||
|
const store = new CacheStore();
|
||||||
|
await store.set('keyA', 'hash1', [{ type: 'stop', finish_reason: 'stop' }]);
|
||||||
|
const entry = await store.get('keyA', 'hash1');
|
||||||
|
assert.ok(entry !== null);
|
||||||
|
assert.deepEqual(entry.value, [{ type: 'stop', finish_reason: 'stop' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Test 16: get returns null for missing key ─────────────────────────
|
||||||
|
it('CacheStore.get returns null for missing (keyId, cacheKey)', async () => {
|
||||||
|
const store = new CacheStore();
|
||||||
|
const entry = await store.get('keyA', 'nonexistent-hash');
|
||||||
|
assert.equal(entry, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Test 17: Per-key isolation ────────────────────────────────────────
|
||||||
|
it('CacheStore per-key isolation: keyId1 entries invisible to keyId2', async () => {
|
||||||
|
const store = new CacheStore();
|
||||||
|
await store.set('keyA', 'hash1', 'value-for-keyA');
|
||||||
|
const fromKeyA = await store.get('keyA', 'hash1');
|
||||||
|
const fromKeyB = await store.get('keyB', 'hash1');
|
||||||
|
assert.ok(fromKeyA !== null);
|
||||||
|
assert.equal(fromKeyA.value, 'value-for-keyA');
|
||||||
|
assert.equal(fromKeyB, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Test 18: has ─────────────────────────────────────────────────────
|
||||||
|
it('CacheStore.has returns true for existing entry, false for missing', async () => {
|
||||||
|
const store = new CacheStore();
|
||||||
|
await store.set('keyA', 'hash1', 'val');
|
||||||
|
assert.equal(await store.has('keyA', 'hash1'), true);
|
||||||
|
assert.equal(await store.has('keyA', 'hash-missing'), false);
|
||||||
|
assert.equal(await store.has('keyB', 'hash1'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Test 19: TTL expiry ───────────────────────────────────────────────
|
||||||
|
it('CacheStore respects TTL: expired entries return null', async () => {
|
||||||
|
// Inject a _nowFn to control time without sleeping.
|
||||||
|
// First call: "now" = 0 (entry creation time).
|
||||||
|
// Second call: "now" = 2000 (2 seconds later; entry has 1s TTL → expired).
|
||||||
|
let fakeNow = 0;
|
||||||
|
const store = new CacheStore({ _nowFn: () => fakeNow });
|
||||||
|
await store.set('keyA', 'hash1', 'val', 1000); // 1000ms TTL
|
||||||
|
// Entry should be alive at t=0
|
||||||
|
const entry1 = await store.get('keyA', 'hash1');
|
||||||
|
assert.ok(entry1 !== null, 'Expected entry to be alive at t=0');
|
||||||
|
// Advance time past TTL
|
||||||
|
fakeNow = 2000;
|
||||||
|
const entry2 = await store.get('keyA', 'hash1');
|
||||||
|
assert.equal(entry2, null, 'Expected entry to be expired at t=2000');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Test 20: stats reports hits/misses ────────────────────────────────
|
||||||
|
it('CacheStore.stats reports hits and misses', async () => {
|
||||||
|
const store = new CacheStore();
|
||||||
|
await store.set('keyA', 'hash1', 'val');
|
||||||
|
await store.get('keyA', 'hash1'); // hit
|
||||||
|
await store.get('keyA', 'hash1'); // hit
|
||||||
|
await store.get('keyA', 'missing'); // miss
|
||||||
|
const s = store.stats('keyA');
|
||||||
|
assert.ok(s.hits >= 2, `Expected hits >= 2, got ${s.hits}`);
|
||||||
|
assert.ok(s.misses >= 1, `Expected misses >= 1, got ${s.misses}`);
|
||||||
|
assert.ok(typeof s.size === 'number');
|
||||||
|
assert.ok(typeof s.inflightCount === 'number');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Test 21: getOrCompute returns computed value on miss ──────────────
|
||||||
|
it('getOrCompute returns computed value on miss and caches it', async () => {
|
||||||
|
const store = new CacheStore();
|
||||||
|
let callCount = 0;
|
||||||
|
const computeFn = async () => { callCount++; return [{ type: 'delta', content: 'hello' }]; };
|
||||||
|
const v1 = await store.getOrCompute('keyA', 'hash1', computeFn);
|
||||||
|
assert.deepEqual(v1, [{ type: 'delta', content: 'hello' }]);
|
||||||
|
assert.equal(callCount, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Test 22: getOrCompute returns cached value on hit (no recomputation) ──
|
||||||
|
it('getOrCompute returns cached value on hit — computeFn not called again', async () => {
|
||||||
|
const store = new CacheStore();
|
||||||
|
let callCount = 0;
|
||||||
|
const computeFn = async () => { callCount++; return 'computed-value'; };
|
||||||
|
await store.getOrCompute('keyA', 'hash1', computeFn);
|
||||||
|
const v2 = await store.getOrCompute('keyA', 'hash1', computeFn);
|
||||||
|
assert.equal(v2, 'computed-value');
|
||||||
|
assert.equal(callCount, 1, 'computeFn should only be called once on cache hit');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Test 23: Singleflight — the key D4 test ───────────────────────────
|
||||||
|
// 5 concurrent getOrCompute calls with same key + slow computeFn (50ms).
|
||||||
|
// Verifies: computeFn called exactly once; all 5 callers receive same value;
|
||||||
|
// all 5 return within a tight time window (singleflight working).
|
||||||
|
it('getOrCompute singleflight: 5 concurrent callers → computeFn called exactly once', async () => {
|
||||||
|
const store = new CacheStore();
|
||||||
|
let callCount = 0;
|
||||||
|
|
||||||
|
const slowCompute = async () => {
|
||||||
|
callCount++;
|
||||||
|
// Simulate a slow provider spawn (50ms)
|
||||||
|
await new Promise(r => setTimeout(r, 50));
|
||||||
|
return [{ type: 'delta', content: 'singleflight-result' }, { type: 'stop', finish_reason: 'stop' }];
|
||||||
|
};
|
||||||
|
|
||||||
|
const t0 = Date.now();
|
||||||
|
// Launch 5 concurrent callers simultaneously
|
||||||
|
const results = await Promise.all([
|
||||||
|
store.getOrCompute('keyA', 'sf-hash', slowCompute),
|
||||||
|
store.getOrCompute('keyA', 'sf-hash', slowCompute),
|
||||||
|
store.getOrCompute('keyA', 'sf-hash', slowCompute),
|
||||||
|
store.getOrCompute('keyA', 'sf-hash', slowCompute),
|
||||||
|
store.getOrCompute('keyA', 'sf-hash', slowCompute),
|
||||||
|
]);
|
||||||
|
const elapsed = Date.now() - t0;
|
||||||
|
|
||||||
|
// Singleflight invariant: computeFn called exactly once
|
||||||
|
assert.equal(callCount, 1, `Expected computeFn called 1 time, got ${callCount}`);
|
||||||
|
|
||||||
|
// All 5 callers receive the same result
|
||||||
|
for (const result of results) {
|
||||||
|
assert.deepEqual(result, [{ type: 'delta', content: 'singleflight-result' }, { type: 'stop', finish_reason: 'stop' }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// All 5 callers return within a tight window (not 5 * 50ms = 250ms).
|
||||||
|
// Allow generous margin (3x the slow compute time) for CI variance.
|
||||||
|
assert.ok(elapsed < 250, `Expected singleflight to complete in < 250ms, took ${elapsed}ms`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Test 24: getOrCompute releases inflight on completion ────────────
|
||||||
|
it('getOrCompute: subsequent calls after completion hit cache (no re-compute)', async () => {
|
||||||
|
const store = new CacheStore();
|
||||||
|
let callCount = 0;
|
||||||
|
const computeFn = async () => { callCount++; return 'done'; };
|
||||||
|
// First call — computes and caches
|
||||||
|
await store.getOrCompute('keyA', 'hash1', computeFn);
|
||||||
|
assert.equal(callCount, 1);
|
||||||
|
// Verify inflight is released: calling again should hit cache
|
||||||
|
await store.getOrCompute('keyA', 'hash1', computeFn);
|
||||||
|
assert.equal(callCount, 1, 'computeFn should not be called again after completion');
|
||||||
|
assert.equal(store.stats('keyA').inflightCount, 0, 'No inflight entries after completion');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Test 25: getOrCompute releases inflight on error ─────────────────
|
||||||
|
it('getOrCompute: inflight released when computeFn throws; subsequent calls retry', async () => {
|
||||||
|
const store = new CacheStore();
|
||||||
|
let callCount = 0;
|
||||||
|
let shouldFail = true;
|
||||||
|
const computeFn = async () => {
|
||||||
|
callCount++;
|
||||||
|
if (shouldFail) throw new Error('provider error');
|
||||||
|
return 'success';
|
||||||
|
};
|
||||||
|
|
||||||
|
// First call — throws
|
||||||
|
await assert.rejects(() => store.getOrCompute('keyA', 'hash1', computeFn), /provider error/);
|
||||||
|
assert.equal(callCount, 1);
|
||||||
|
// Inflight must be released even after error
|
||||||
|
assert.equal(store.stats('keyA').inflightCount, 0, 'Inflight must be released after error');
|
||||||
|
|
||||||
|
// Second call — now succeeds (verify re-try works)
|
||||||
|
shouldFail = false;
|
||||||
|
const v = await store.getOrCompute('keyA', 'hash1', computeFn);
|
||||||
|
assert.equal(v, 'success');
|
||||||
|
assert.equal(callCount, 2, 'computeFn should be retried after error');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Test 26: clear(keyId) clears only that namespace ─────────────────
|
||||||
|
it('CacheStore.clear(keyId) clears only that namespace', async () => {
|
||||||
|
const store = new CacheStore();
|
||||||
|
await store.set('keyA', 'hash1', 'val-a');
|
||||||
|
await store.set('keyB', 'hash1', 'val-b');
|
||||||
|
store.clear('keyA');
|
||||||
|
assert.equal(await store.has('keyA', 'hash1'), false, 'keyA should be cleared');
|
||||||
|
assert.equal(await store.has('keyB', 'hash1'), true, 'keyB should remain');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Test 27: clear() with no args clears all ──────────────────────────
|
||||||
|
it('CacheStore.clear() with no argument clears all namespaces', async () => {
|
||||||
|
const store = new CacheStore();
|
||||||
|
await store.set('keyA', 'hash1', 'val-a');
|
||||||
|
await store.set('keyB', 'hash1', 'val-b');
|
||||||
|
store.clear();
|
||||||
|
assert.equal(await store.has('keyA', 'hash1'), false, 'keyA should be cleared');
|
||||||
|
assert.equal(await store.has('keyB', 'hash1'), false, 'keyB should be cleared');
|
||||||
|
assert.equal(store.stats().size, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Suite 9 (HTTP integration — cache) ───────────────────────────────────
|
||||||
|
//
|
||||||
|
// Tests cache miss / hit / bypass paths via HTTP integration with a mock
|
||||||
|
// provider. Anthropic provider is enabled with:
|
||||||
|
// 1. CLAUDE_CODE_OAUTH_TOKEN set to a fake value to bypass auth check
|
||||||
|
// (the mock spawn never actually uses the token)
|
||||||
|
// 2. Mock spawn injected via __setSpawnImpl so no real claude binary runs
|
||||||
|
//
|
||||||
|
// This tests the full HTTP → server.mjs → cache layer → provider dispatch
|
||||||
|
// path end-to-end, with the spawn binary call itself mocked out.
|
||||||
|
|
||||||
|
describe('Cache layer — HTTP integration (Suite 9 cont.)', () => {
|
||||||
|
let serverInstance9;
|
||||||
|
let port9;
|
||||||
|
let savedOAuthToken;
|
||||||
|
let serverMod9;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
// Inject a fake OAuth token so auth check passes without a real token.
|
||||||
|
// The mock spawn ignores this value entirely.
|
||||||
|
savedOAuthToken = process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
||||||
|
process.env.CLAUDE_CODE_OAUTH_TOKEN = 'test-fake-oauth-token-for-cache-tests';
|
||||||
|
|
||||||
|
// Set mock spawn that returns a proper response (delta + stop chunks via
|
||||||
|
// raw text output — anthropic.mjs treats each stdout data chunk as raw text).
|
||||||
|
__setSpawnImpl(makeMockSpawn(['mock-cache-content']));
|
||||||
|
|
||||||
|
// Import the server module (already cached by Node module system — same instance
|
||||||
|
// as Suite 7). Mutate the loadedProviders map to add anthropic.
|
||||||
|
serverMod9 = await import('./server.mjs');
|
||||||
|
const { createOlpServer, loadedProviders: lp } = serverMod9;
|
||||||
|
|
||||||
|
// Wire anthropic into the loaded providers map
|
||||||
|
const testProviders = loadProviders({ enabled: { anthropic: true } });
|
||||||
|
for (const [name, p] of testProviders) {
|
||||||
|
lp.set(name, p);
|
||||||
|
}
|
||||||
|
|
||||||
|
port9 = parseInt(
|
||||||
|
process.env.OLP_TEST_PORT
|
||||||
|
? String(parseInt(process.env.OLP_TEST_PORT) + 5000)
|
||||||
|
: String(18456 + Math.floor(Math.random() * 1000)),
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
|
||||||
|
serverInstance9 = createOlpServer();
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
serverInstance9.listen(port9, '127.0.0.1', resolve);
|
||||||
|
serverInstance9.once('error', async (e) => {
|
||||||
|
if (e.code === 'EADDRINUSE') {
|
||||||
|
port9++;
|
||||||
|
serverInstance9.listen(port9, '127.0.0.1', resolve);
|
||||||
|
serverInstance9.once('error', reject);
|
||||||
|
} else reject(e);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
after(() => {
|
||||||
|
// Restore OAuth token
|
||||||
|
if (savedOAuthToken !== undefined) {
|
||||||
|
process.env.CLAUDE_CODE_OAUTH_TOKEN = savedOAuthToken;
|
||||||
|
} else {
|
||||||
|
delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
||||||
|
}
|
||||||
|
__resetSpawnImpl();
|
||||||
|
return new Promise(r => serverInstance9.close(r));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Test 28: cache miss path ──────────────────────────────────────────
|
||||||
|
// First request with a unique message → cache miss (not yet in cache).
|
||||||
|
it('HTTP: first request returns X-OLP-Cache: miss', async () => {
|
||||||
|
// Unique content ensures this test doesn't collide with other tests' cached entries
|
||||||
|
const testMsg = `http-cache-miss-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||||
|
__setSpawnImpl(makeMockSpawn([`response-for-${testMsg}`]));
|
||||||
|
|
||||||
|
const r = await fetch({
|
||||||
|
port: port9,
|
||||||
|
method: 'POST',
|
||||||
|
path: '/v1/chat/completions',
|
||||||
|
body: {
|
||||||
|
model: 'claude-haiku-4-5',
|
||||||
|
messages: [{ role: 'user', content: testMsg }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(r.status, 200, `Expected 200, got ${r.status}: ${r.body.slice(0, 200)}`);
|
||||||
|
assert.equal(r.headers['x-olp-cache'], 'miss', `Expected miss, got: ${r.headers['x-olp-cache']}`);
|
||||||
|
assert.equal(r.headers['x-olp-provider-used'], 'anthropic');
|
||||||
|
assert.equal(r.headers['x-olp-model-used'], 'claude-haiku-4-5');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Test 29: cache hit path ───────────────────────────────────────────
|
||||||
|
// Two identical requests: first → miss, second → hit (same content served from cache).
|
||||||
|
it('HTTP: second identical request returns X-OLP-Cache: hit with same content', async () => {
|
||||||
|
const testMsg = `http-cache-hit-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||||
|
const mockResponse = `hit-response-${testMsg}`;
|
||||||
|
__setSpawnImpl(makeMockSpawn([mockResponse]));
|
||||||
|
|
||||||
|
const reqParams = {
|
||||||
|
port: port9,
|
||||||
|
method: 'POST',
|
||||||
|
path: '/v1/chat/completions',
|
||||||
|
body: {
|
||||||
|
model: 'claude-haiku-4-5',
|
||||||
|
messages: [{ role: 'user', content: testMsg }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// First request — miss, spawns real (mock) provider
|
||||||
|
const r1 = await fetch(reqParams);
|
||||||
|
assert.equal(r1.status, 200, `First request failed: ${r1.status} ${r1.body.slice(0, 200)}`);
|
||||||
|
assert.equal(r1.headers['x-olp-cache'], 'miss', `First request should be miss`);
|
||||||
|
const body1 = JSON.parse(r1.body);
|
||||||
|
const content1 = body1?.choices?.[0]?.message?.content ?? '';
|
||||||
|
|
||||||
|
// Second request — replace spawn with a failing mock to prove spawn is NOT called
|
||||||
|
// (if spawn were called, this would produce a 502 error)
|
||||||
|
__setSpawnImpl(makeMockSpawn([], 1)); // exit code 1 = ProviderError on spawn
|
||||||
|
|
||||||
|
const r2 = await fetch(reqParams);
|
||||||
|
assert.equal(r2.status, 200, `Second request (cache hit) should be 200, got ${r2.status}: ${r2.body.slice(0, 200)}`);
|
||||||
|
assert.equal(r2.headers['x-olp-cache'], 'hit', `Second request should be cache hit`);
|
||||||
|
|
||||||
|
// Content should be identical (replayed from cache)
|
||||||
|
const body2 = JSON.parse(r2.body);
|
||||||
|
const content2 = body2?.choices?.[0]?.message?.content ?? '';
|
||||||
|
assert.equal(content2, content1, `Cache hit content should match original`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Test 30: cache bypass path (cache_control marker) ────────────────
|
||||||
|
// Request with cache_control marker → X-OLP-Cache: bypass (no OLP caching).
|
||||||
|
it('HTTP: request with cache_control marker returns X-OLP-Cache: bypass', async () => {
|
||||||
|
const testMsg = `http-cache-bypass-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||||
|
__setSpawnImpl(makeMockSpawn([`bypass-response-${testMsg}`]));
|
||||||
|
|
||||||
|
const r = await fetch({
|
||||||
|
port: port9,
|
||||||
|
method: 'POST',
|
||||||
|
path: '/v1/chat/completions',
|
||||||
|
body: {
|
||||||
|
model: 'claude-haiku-4-5',
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: testMsg,
|
||||||
|
cache_control: { type: 'ephemeral' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(r.status, 200, `Expected 200, got ${r.status}: ${r.body.slice(0, 200)}`);
|
||||||
|
assert.equal(r.headers['x-olp-cache'], 'bypass', `Expected bypass header, got: ${r.headers['x-olp-cache']}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Suite 10: Anthropic E2E (GATED) ──────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Run with: OLP_RUN_E2E=1 npm test
|
||||||
|
// Skipped by default; consumes real Anthropic tokens (~200 tokens per run,
|
||||||
|
// est. <$0.001 at haiku rates). Requires `claude` binary + keychain OAuth
|
||||||
|
// token. The orchestrator runs this once per D5 verification.
|
||||||
|
//
|
||||||
|
// This suite is NOT run in CI (CI does not set OLP_RUN_E2E). The skip notice
|
||||||
|
// is emitted as a console message so CI logs show the gated test exists.
|
||||||
|
//
|
||||||
|
// Tests:
|
||||||
|
// 1. Start OLP server with anthropic enabled.
|
||||||
|
// 2. POST minimal request to claude-haiku-4-5.
|
||||||
|
// 3. Assert 200, response contains "OK", correct provider/model headers.
|
||||||
|
// 4. Send same request again → assert X-OLP-Cache: hit, identical content.
|
||||||
|
// 5. Assert X-OLP-Fallback-Hops: 0.
|
||||||
|
//
|
||||||
|
// Model: claude-haiku-4-5 (cheapest). Prompt: "Reply with exactly the word OK
|
||||||
|
// and nothing else." max_tokens: 10. Target: < 200 tokens per run.
|
||||||
|
//
|
||||||
|
// Do NOT include any real OAuth tokens or API keys in this test. Auth is read
|
||||||
|
// from keychain / CLAUDE_CODE_OAUTH_TOKEN env / ~/.claude/.credentials.json
|
||||||
|
// by readAuthArtifact() inside the anthropic plugin at spawn time.
|
||||||
|
|
||||||
|
const RUN_E2E = process.env.OLP_RUN_E2E === '1';
|
||||||
|
|
||||||
|
if (!RUN_E2E) {
|
||||||
|
// Emit a skip notice to CI logs without failing the suite
|
||||||
|
process.stdout.write('::notice::Suite 10 (Anthropic E2E) skipped — set OLP_RUN_E2E=1 to run. Requires claude binary + keychain OAuth token. ~200 tokens per run.\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Anthropic E2E — real claude spawn (Suite 10)', { skip: !RUN_E2E }, () => {
|
||||||
|
let e2eServer;
|
||||||
|
let e2ePort;
|
||||||
|
let e2eLoadedProviders;
|
||||||
|
let e2eCacheStore;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
if (!RUN_E2E) return;
|
||||||
|
|
||||||
|
__resetSpawnImpl(); // ensure real spawn is active
|
||||||
|
|
||||||
|
const { createOlpServer, loadedProviders: lp, cacheStore: cs } = await import('./server.mjs');
|
||||||
|
e2eLoadedProviders = lp;
|
||||||
|
e2eCacheStore = cs;
|
||||||
|
|
||||||
|
// Enable anthropic provider for E2E
|
||||||
|
const testProviders = loadProviders({ enabled: { anthropic: true } });
|
||||||
|
for (const [name, p] of testProviders) {
|
||||||
|
lp.set(name, p);
|
||||||
|
}
|
||||||
|
|
||||||
|
e2ePort = parseInt(process.env.OLP_E2E_PORT ?? String(19456 + Math.floor(Math.random() * 500)), 10);
|
||||||
|
e2eServer = createOlpServer();
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
e2eServer.listen(e2ePort, '127.0.0.1', resolve);
|
||||||
|
e2eServer.once('error', async (e) => {
|
||||||
|
if (e.code === 'EADDRINUSE') {
|
||||||
|
e2ePort++;
|
||||||
|
e2eServer.listen(e2ePort, '127.0.0.1', resolve);
|
||||||
|
e2eServer.once('error', reject);
|
||||||
|
} else reject(e);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
after(() => {
|
||||||
|
if (!e2eServer) return;
|
||||||
|
return new Promise(r => e2eServer.close(r));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('E2E: POST claude-haiku-4-5 with minimal prompt → 200 + "OK" content + correct headers', async () => {
|
||||||
|
const body = {
|
||||||
|
model: 'claude-haiku-4-5',
|
||||||
|
messages: [{ role: 'user', content: 'Reply with exactly the word OK and nothing else.' }],
|
||||||
|
max_tokens: 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
const r = await fetch({ port: e2ePort, method: 'POST', path: '/v1/chat/completions', body });
|
||||||
|
assert.equal(r.status, 200, `Expected 200, got ${r.status}: ${r.body}`);
|
||||||
|
|
||||||
|
const respBody = JSON.parse(r.body);
|
||||||
|
const content = respBody?.choices?.[0]?.message?.content ?? '';
|
||||||
|
assert.ok(
|
||||||
|
content.toLowerCase().includes('ok'),
|
||||||
|
`Expected response to contain "OK", got: ${content}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Provider/model headers
|
||||||
|
assert.equal(r.headers['x-olp-provider-used'], 'anthropic', `Expected anthropic provider`);
|
||||||
|
assert.equal(r.headers['x-olp-model-used'], 'claude-haiku-4-5', `Expected haiku model`);
|
||||||
|
|
||||||
|
// First request is always a miss
|
||||||
|
assert.equal(r.headers['x-olp-cache'], 'miss', `First request should be cache miss`);
|
||||||
|
|
||||||
|
// Fallback hops
|
||||||
|
assert.equal(r.headers['x-olp-fallback-hops'], '0', `Expected 0 fallback hops`);
|
||||||
|
|
||||||
|
// Second request — should hit cache, content identical
|
||||||
|
const r2 = await fetch({ port: e2ePort, method: 'POST', path: '/v1/chat/completions', body });
|
||||||
|
assert.equal(r2.status, 200, `Cache hit request should be 200`);
|
||||||
|
assert.equal(r2.headers['x-olp-cache'], 'hit', `Second request should be cache hit`);
|
||||||
|
|
||||||
|
const resp2Body = JSON.parse(r2.body);
|
||||||
|
const content2 = resp2Body?.choices?.[0]?.message?.content ?? '';
|
||||||
|
assert.ok(
|
||||||
|
content2.toLowerCase().includes('ok'),
|
||||||
|
`Cache hit response should also contain "OK", got: ${content2}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user