diff --git a/lib/cache/keys.mjs b/lib/cache/keys.mjs new file mode 100644 index 0000000..297cfc1 --- /dev/null +++ b/lib/cache/keys.mjs @@ -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} messages + * @returns {Array} + */ +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} 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|undefined} toolCalls + * @returns {Array|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} messages + * @returns {Array} — 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'); +} diff --git a/lib/cache/store.mjs b/lib/cache/store.mjs new file mode 100644 index 0000000..745ca56 --- /dev/null +++ b/lib/cache/store.mjs @@ -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 + /** @type {Map>} */ + this._store = new Map(); + + // D4 singleflight: `${keyId}:${cacheKey}` → Promise + /** @type {Map>} */ + this._inflight = new Map(); + + // Stats per keyId + /** @type {Map} */ + this._stats = new Map(); + } + + // ── Internal helpers ────────────────────────────────────────────────── + + /** + * Returns or creates the inner Map for a given keyId. + * @param {string} keyId + * @returns {Map} + */ + _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} 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} + */ + 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} + */ + 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} + */ + 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} + */ + 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(); + } + } +} diff --git a/server.mjs b/server.mjs index 39eb816..e69fe36 100644 --- a/server.mjs +++ b/server.mjs @@ -6,6 +6,7 @@ * https://platform.openai.com/docs/api-reference/chat/create * Authority (IR): ADR 0003 * Authority (provider dispatch): ADR 0002 + * Authority (cache layer): ADR 0005 * * Design principles (OCP precedent, ESM/.mjs, http built-ins, no external deps): * - Node ESM, no build step, no bundler @@ -30,6 +31,8 @@ import { } from './lib/ir/ir-to-openai.mjs'; import { loadProviders, getProviderForModel, listAllProviderNames } from './lib/providers/index.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 ──────────────────────────────────────────────────────────────── @@ -45,6 +48,12 @@ const BODY_LIMIT = 5 * 1024 * 1024; // 5 MB // Empty config → empty loaded map → all POST /v1/chat/completions → 503. 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 ─────────────────────────────────────────────────────────────── function logEvent(level, event, data = {}) { @@ -125,21 +134,22 @@ function sendError(res, status, message, type) { * Returns the standard OLP diagnostic headers. * Per spec: X-OLP-Provider-Used, X-OLP-Model-Used, X-OLP-Fallback-Hops, * X-OLP-Cache, X-OLP-Latency-Ms. - * Fallback-Hops is always 0 at D3 (no fallback engine yet — ADR 0004). - * Cache is always 'miss' at D3 (no cache layer yet — ADR 0005). + * Fallback-Hops is always 0 at D5 (no fallback engine yet — ADR 0004). + * Cache reflects actual hit/miss/bypass status from the cache layer (ADR 0005). * * @param {object} opts * @param {string} opts.providerUsed * @param {string} opts.modelUsed * @param {number} opts.startMs + * @param {'hit'|'miss'|'bypass'} [opts.cacheStatus='miss'] * @returns {Record} */ -function olpHeaders({ providerUsed, modelUsed, startMs }) { +function olpHeaders({ providerUsed, modelUsed, startMs, cacheStatus = 'miss' }) { return { 'X-OLP-Provider-Used': providerUsed, 'X-OLP-Model-Used': modelUsed, 'X-OLP-Fallback-Hops': '0', - 'X-OLP-Cache': 'miss', + 'X-OLP-Cache': cacheStatus, 'X-OLP-Latency-Ms': String(Date.now() - startMs), }; } @@ -217,13 +227,88 @@ async function handleChatCompletions(req, res) { const { provider, name: providerName } = match; const requestId = generateRequestId(); - // Auth context is a stub at D3 — providers will populate this in Phase 1 Day 2+ - const authContext = {}; + // Auth context is null at D5 — providers fall back to their own credential + // 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) { // 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, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', @@ -232,35 +317,41 @@ async function handleChatCompletions(req, res) { ...headers, }); - try { - for await (const irChunk of provider.spawn(ir, authContext)) { - res.write(irChunkToOpenAISSE(irChunk, requestId, ir.model)); - 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 }); + // D3 simplified replay: emit collected chunks sequentially + for (const irChunk of chunks) { + res.write(irChunkToOpenAISSE(irChunk, requestId, ir.model)); + if (irChunk.type === 'stop' || irChunk.type === 'error') break; } + res.write(SSE_DONE); res.end(); } else { // Non-streaming response path + let cacheStatus; + let responseObj; + try { - const chunks = []; - for await (const irChunk of provider.spawn(ir, authContext)) { - chunks.push(irChunk); - if (irChunk.type === 'stop' || irChunk.type === 'error') break; + if (bypassCache) { + cacheStatus = 'bypass'; + const chunks = await collectAllChunks(); + 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) { logEvent('error', 'spawn_error', { provider: providerName, model: ir.model, error: e.message }); const status = e instanceof ProviderError ? 502 : 500; 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); } } diff --git a/test-features.mjs b/test-features.mjs index 88eecdc..1096556 100644 --- a/test-features.mjs +++ b/test-features.mjs @@ -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. * 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. + * D5 adds: Suite 9 (cache layer unit + HTTP integration), Suite 10 (Anthropic E2E gated) */ 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 { EventEmitter } from 'node:events'; import { homedir } from 'node:os'; +import { computeCacheKey, extractCacheControlMarkers, hasCacheControl } from './lib/cache/keys.mjs'; +import { CacheStore } from './lib/cache/store.mjs'; // ── Modules under test ──────────────────────────────────────────────────── @@ -1091,3 +1094,610 @@ describe('HTTP integration', () => { 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}`, + ); + }); +});