/** * Responses API server-side conversation state. * * WHY: `/v1/responses` advertises OpenAI Responses compatibility, whose defining * feature is that the SERVER holds the conversation. A client sends only the new * turn plus `previous_response_id` and the server prepends everything before it. * * Before this module `previous_response_id` was never read (grep: zero hits in * handlers/responses.js). A chained client therefore reached the upstream with a * SINGLE message every turn — the model answered each turn blind, with no error * and no warning, producing fluent but context-free replies the caller had no way * to diagnose. That silent-wrong-answer mode is the thing this fixes. * * Design: * - Each stored entry holds the FULL accumulated message list for that turn * (input + the assistant output), not a parent pointer. Resolution is then a * single O(1) lookup instead of walking a chain, and a mid-chain eviction * cannot silently truncate history — the lookup either has everything or * fails loudly. * - Entries are scoped by callerKey. A response id minted for one caller MUST * NOT resolve for another, or tenant A could read tenant B's conversation by * replaying an id. Lookup mismatch fails closed (treated as not found). * - Bounded like every other in-memory table here: TTL, a hard cap, LRU * eviction with a per-tenant fair share so one caller cannot flush everyone * else's conversations (same lesson as account/sticky-session.js). * - `store: false` on the request skips persistence, per the OpenAI contract — * such a response cannot later be used as a `previous_response_id`. The value * is NORMALIZED here rather than trusted as a boolean; see wantsPersistence. * * Configure via env: * RESPONSE_STORE_ENABLED=0 — disable (default: on) * RESPONSE_STORE_TTL_MS=3600000 — IDLE timeout in ms (default: 1 hour) * RESPONSE_STORE_MAX_AGE_MS=86400000 — absolute retention bound (default: 24 hours) * RESPONSE_STORE_MAX=2000 — max stored responses (default: 2000) * RESPONSE_STORE_MAX_MESSAGES=400 — per-conversation message cap (default: 400) * RESPONSE_STORE_MAX_BYTES=128m — total byte budget (default: 128MB; b/k/kb/m/mb/g/gb) */ import { log } from './config.js'; const ENABLED = process.env.RESPONSE_STORE_ENABLED !== '0'; // Every numeric setting goes through this: parseInt alone accepts values that // round past Number.MAX_SAFE_INTEGER (and a 400-digit string becomes Infinity), so // a malformed setting could silently disable the bound it was meant to configure. // A malformed value falls back to the documented default instead of becoming an // unbounded limit. function positiveIntSetting(raw, fallback) { const n = parseInt(raw || '', 10); return Number.isSafeInteger(n) && n > 0 ? n : fallback; } // IDLE timeout: measured from lastAccess, which every successful read refreshes. // That refresh is deliberate — a chained agent loop touching the same id every few // seconds must not have its context expire underneath it — but it means TTL_MS on // its own bounds nothing: a client polling GET /v1/responses/{id} keeps an entry // alive forever (measured at RESPONSE_STORE_TTL_MS=120: polled every 40ms for // 1020ms, i.e. 8.5x the TTL, 25 successful reads, entry still alive). No floor // either — `n > 0` accepts 1ms, which is fine for tuning and useless as a bound. // MAX_AGE_MS below is the bound that does not depend on read traffic. const TTL_MS = (() => { const n = parseInt(process.env.RESPONSE_STORE_TTL_MS || '', 10); return Number.isSafeInteger(n) && n > 0 ? n : 60 * 60 * 1000; // 1 hour })(); // ABSOLUTE retention bound, measured from createdAt and NOT refreshed by reads. It // is what makes "retained for a limited time" true; the idle timeout above only // says "collected when abandoned". // // 24 hours, chosen against the workload this store exists to serve rather than // against the smallest defensible number. An agent loop can legitimately run for // hours, and dropping its context mid-run is a worse failure than over-retention: // the caller gets a 404 on a conversation it is actively using, with nothing to // recover from. Note the bound is per response id and createdAt survives a // same-id refresh, so a real chain is unaffected either way — every turn mints a // NEW id and starts its own 24 hours. What the bound actually kills is the entry // that is read but never superseded, which is exactly the shape that had no upper // limit at all. const MAX_AGE_MS = (() => { const n = parseInt(process.env.RESPONSE_STORE_MAX_AGE_MS || '', 10); return Number.isSafeInteger(n) && n > 0 ? n : 24 * 60 * 60 * 1000; // 24 hours })(); const MAX_ENTRIES = positiveIntSetting(process.env.RESPONSE_STORE_MAX, 2000); // A runaway agent loop would otherwise grow one conversation without bound. The // cap keeps the OLDEST turns (the system prompt and early context matter most for // coherence) — see truncateMessages. const MAX_MESSAGES = positiveIntSetting(process.env.RESPONSE_STORE_MAX_MESSAGES, 400); // Neither MAX_ENTRIES nor MAX_MESSAGES bounds actual MEMORY — they bound counts. // Measured on realistic agent-loop conversations (8KB system prompt + 200 // tool_call/tool_result pairs with 4KB outputs, truncated to MAX_MESSAGES): ~167KB // of heap per stored entry, so the default 2000 entries is ~327MB, and a // text-heavy shape measured ~518MB. On the small VPSes this project explicitly // targets (the README tells 2GB hosts to lower LS_MAX_INSTANCES) that is enough to // matter on its own. So the store also carries a BYTE budget and evicts on // whichever limit binds first. /** * Parse a byte-size setting like `128m` / `512kb` / `1g` / a bare byte count. * Exported so the suffix contract is tested against THIS implementation instead of * a copy — a test that re-derives the regex passes even when production breaks. * Returns null for anything unusable so the caller applies its default. * * The result must be a SAFE integer and must sit under an explicit operational * bound. `Number('9'.repeat(400))` is Infinity and a 17-digit value rounds to a * different integer; either one used to become MAX_BYTES and silently disabled * byte eviction (measured: `RESPONSE_STORE_MAX_BYTES=9…9` produced Infinity). */ const MAX_CONFIGURED_BYTES = 1024 ** 4; // 1 TiB — far above any real deployment export function parseByteSize(raw) { const m = String(raw ?? '').trim().match(/^(\d+)\s*(b|k|kb|m|mb|g|gb)?$/i); if (!m) return null; const mult = { b: 1, k: 1024, kb: 1024, m: 1048576, mb: 1048576, g: 1073741824, gb: 1073741824 }; const n = Number(m[1]) * (mult[(m[2] || 'b').toLowerCase()] || 1); if (!Number.isSafeInteger(n) || n <= 0 || n > MAX_CONFIGURED_BYTES) return null; return n; } const MAX_BYTES = parseByteSize(process.env.RESPONSE_STORE_MAX_BYTES) ?? 128 * 1024 * 1024; // 128MB — roughly 800 realistic conversations // A copy that allocates only where it must. `structuredClone` re-copies every // string it walks — a 4 MB base64 image in a stored turn cost ~2.9 ms per put and // ~2.6 ms per read — and it turns a Buffer into a Uint8Array, losing the type the // rest of the pipeline expects. Strings, numbers and booleans are immutable, so // they can be shared; only containers are re-created. Anything that is not plain // JSON-shaped (Date, Map, class instances, functions) keeps the old semantics by // falling back to structuredClone, including its throw on functions. // // `seen` makes the plain-object walk cycle-safe. Without it a self-referencing // message would recurse until the stack overflowed; with it the stored graph // mirrors the caller's shape and `retainedBytes` can meter the cycle too. function copyStoredValue(value, seen = new WeakMap(), depth = 0) { // structuredClone refuses to clone functions and symbols; keep that loud // rejection instead of quietly storing a live reference. if (typeof value === 'function' || typeof value === 'symbol') return structuredClone(value); if (value === null || typeof value !== 'object') return value; // S4-D14: a stored body nested deeper than the stack can clone used to escape // as a per-request RangeError; bound the recursion like retainedBytes. The // literal stays inline — s4-r6.test.js evaluates this function's TEXT with // no module scope, so a module-level const would be a ReferenceError there. if (depth > 64) throw new Error('copyStoredValue: value exceeds maximum nesting depth (64)'); if (seen.has(value)) return seen.get(value); if (Array.isArray(value)) { const out = new Array(value.length); seen.set(value, out); for (let i = 0; i < value.length; i++) { if (i in value) out[i] = copyStoredValue(value[i], seen, depth + 1); } return out; } if (Buffer.isBuffer(value)) return Buffer.from(value); if (ArrayBuffer.isView(value)) { // DataView's constructor takes a buffer, not another view. if (value instanceof DataView) { return new DataView(value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength)); } return new value.constructor(value); } const proto = Object.getPrototypeOf(value); if (proto === Object.prototype || proto === null) { const out = {}; seen.set(value, out); for (const key of Object.keys(value)) { // defineProperty, not assignment: an own `__proto__` key must survive as a // field instead of rewriting the copy's prototype. Object.defineProperty(out, key, { value: copyStoredValue(value[key], seen, depth + 1), enumerable: true, configurable: true, writable: true, }); } return out; } return structuredClone(value); } // Approximate retained size of a stored value. The estimate must describe the // ACTUAL retained graph, not a hand-picked subset: `copyStoredValue` keeps every // own enumerable message field (and object key), so a walker that only looked at // `content`/`tool_calls`/`tool_call_id` charged an entry holding a 100k-char extra // field 68 bytes and let the byte budget never fire. // // The estimate is deliberately conservative rather than an exact V8 heap // measurement: // - 2 bytes per character. V8 stores any non-Latin1 string as 2 bytes/char, so // `.length` undercounts CJK by exactly 2x; charging 2 everywhere overcounts // pure ASCII by 2x, which is the safe direction for a memory bound. // - container and per-value overhead is explicit (32/8 bytes). // - Buffer / typed array / ArrayBuffer payloads are charged their actual // `byteLength`; walking each byte as a JS number would inflate a 20KB Buffer // to ~160KB and evict conversations that comfortably fit. // - Map/Set/Date/RegExp/Error internal slots are charged too, because // `copyStoredValue` falls back to structuredClone for them and therefore // retains those fields. // - no depth is silently flattened to a stub. A pathologically deep value is // charged MAX_SAFE_INTEGER so admission rejects it instead of undercounting. const CONTAINER_BYTES = 32; const VALUE_SLOT_BYTES = 8; const MAX_RETAINED_DEPTH = 64; function retainedBytes(value, depth = 0, seen = new WeakSet()) { if (value === null || value === undefined) return 0; const type = typeof value; if (type === 'string') return value.length * 2; if (type === 'number' || type === 'boolean') return VALUE_SLOT_BYTES; if (type === 'bigint') return 16; if (type !== 'object') return 0; // function/symbol cannot be retained (copy throws) if (depth > MAX_RETAINED_DEPTH) return Number.MAX_SAFE_INTEGER; if (seen.has(value)) return CONTAINER_BYTES; seen.add(value); try { if (Buffer.isBuffer(value) || ArrayBuffer.isView(value)) { return CONTAINER_BYTES + value.byteLength; } if (value instanceof ArrayBuffer) return CONTAINER_BYTES + value.byteLength; if (typeof SharedArrayBuffer !== 'undefined' && value instanceof SharedArrayBuffer) { return CONTAINER_BYTES + value.byteLength; } if (Array.isArray(value)) { let n = CONTAINER_BYTES; for (let i = 0; i < value.length; i++) { n += VALUE_SLOT_BYTES + retainedBytes(value[i], depth + 1, seen); } return n; } if (value instanceof Map) { let n = CONTAINER_BYTES; for (const [k, v] of value) { n += 16 + retainedBytes(k, depth + 1, seen) + retainedBytes(v, depth + 1, seen); } return n; } if (value instanceof Set) { let n = CONTAINER_BYTES; for (const v of value) n += VALUE_SLOT_BYTES + retainedBytes(v, depth + 1, seen); return n; } if (value instanceof Date) return CONTAINER_BYTES; if (value instanceof RegExp) { return CONTAINER_BYTES + value.source.length * 2 + value.flags.length * 2; } if (typeof URL !== 'undefined' && value instanceof URL) { return CONTAINER_BYTES + String(value).length * 2; } if (typeof URLSearchParams !== 'undefined' && value instanceof URLSearchParams) { return CONTAINER_BYTES + value.toString().length * 2; } if (typeof Blob !== 'undefined' && value instanceof Blob) { return CONTAINER_BYTES + value.size; } if (value instanceof Error) { let n = CONTAINER_BYTES; n += String(value.message || '').length * 2; n += String(value.stack || '').length * 2; if (value.cause !== undefined) n += retainedBytes(value.cause, depth + 1, seen); return n; } let n = CONTAINER_BYTES; for (const key of Object.keys(value)) { n += key.length * 2 + VALUE_SLOT_BYTES; n += retainedBytes(value[key], depth + 1, seen); } return n; } finally { seen.delete(value); } } /** Accounted retained size of a conversation's message array. */ function approxBytes(messages) { return retainedBytes(messages); } // ── the `metadata` slot ───────────────────────────────────────────────────── // `metadata` is a top-level Responses parameter whose purpose is the ECHO: a // client tags a request (trace id, tenant) and reads the tag back off the // response object, including from a later GET. It is caller-controlled and stored // per response, so it must be bounded HERE even though handlers/responses.js // rejects a violating request with a 400 first: this module is a library with // other callers, and a slot whose size is whatever the request body said is a // memory amplifier the byte budget would never see if the field were excluded // from `bytes`. // // The caps are the OpenAI contract (16 pairs, keys <= 64 chars, values <= 512 // chars). They are what bounds the slot: 16 * (64 + 512) chars is 18KB of // accounted memory at retainedBytes' 2 bytes per char, and there is no shape a caller // can send that exceeds it. Entries over a cap are DROPPED, never truncated — a // truncated trace id is a wrong trace id, and the HTTP boundary refuses such a // request before it reaches this function. const MAX_METADATA_KEYS = 16; const MAX_METADATA_KEY_CHARS = 64; const MAX_METADATA_VALUE_CHARS = 512; /** * Copy `metadata` into the store's bounded slot, or return null. * * Only own enumerable STRING pairs are kept: a nested object or a number cannot * be echoed as the flat map the Responses contract defines, and coercing it would * change the caller's data. `__proto__` is skipped because the generic * copy-on-read walk (copyStoredValue) assigns into a plain object, where that key * would rewrite the prototype instead of surviving as a tag. */ function boundMetadata(metadata) { if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) return null; const out = {}; let kept = 0; for (const key of Object.keys(metadata)) { if (kept >= MAX_METADATA_KEYS) break; if (key === '__proto__') continue; const value = metadata[key]; if (typeof value !== 'string') continue; if (!key || key.length > MAX_METADATA_KEY_CHARS || value.length > MAX_METADATA_VALUE_CHARS) continue; Object.defineProperty(out, key, { value, enumerable: true, configurable: true, writable: true }); kept++; } return kept ? out : null; } // A stored entry is more than its messages: callerKey, responseId (the Map key), // model, status, incompleteReason and metadata are all retained alongside them. // Charging only `messages` let a 40KB model or a huge id ride for free, and let // metadata push an otherwise-trimmed entry back over the per-entry ceiling. const ENTRY_OVERHEAD_BYTES = 32; function entryFixedBytes({ responseId, callerKey, model, status, incompleteReason, metadata }) { return ENTRY_OVERHEAD_BYTES + retainedBytes(responseId) + retainedBytes(callerKey) + retainedBytes(model) + retainedBytes(status) + retainedBytes(incompleteReason) + retainedBytes(metadata); } // Map // Map iteration order is maintained as least-recently-used order: every read and // refresh re-inserts, so the first key is always the LRU victim (O(1) eviction). const _entries = new Map(); const _tenantCounts = new Map(); // Per-tenant BYTE totals. The byte-eviction loop needs a byte-denominated fair // share; comparing an entry COUNT against MAX_ENTRIES/tenants let a byte-bound // writer look under-share forever and evict strangers (see the loop below). const _tenantBytes = new Map(); const _stats = { stored: 0, hits: 0, misses: 0, expires: 0, evictions: 0, rejected: 0 }; let _bytes = 0; // running total of approxBytes across all entries export function isResponseStoreEnabled() { return ENABLED; } /** * Tenant identity for quota purposes: the API-key prefix of the callerKey, not * the whole callerKey. One API key can mint unlimited distinct callerKeys (a * fresh body.user per request), so quota'ing the full callerKey would not bound * anything. */ function tenantOf(callerKey) { const key = String(callerKey || ''); const i = key.indexOf(':'); if (i < 0) return key; const j = key.indexOf(':', i + 1); return j < 0 ? key : key.slice(0, j); } function trackInsert(callerKey, bytes = 0) { const t = tenantOf(callerKey); _tenantCounts.set(t, (_tenantCounts.get(t) || 0) + 1); _tenantBytes.set(t, (_tenantBytes.get(t) || 0) + bytes); } function dropEntry(id) { const entry = _entries.get(id); if (!entry) return false; _entries.delete(id); _bytes -= entry.bytes || 0; if (_bytes < 0) _bytes = 0; const t = tenantOf(entry.callerKey); const n = (_tenantCounts.get(t) || 1) - 1; const b = (_tenantBytes.get(t) || 0) - (entry.bytes || 0); if (n > 0) { _tenantCounts.set(t, n); _tenantBytes.set(t, b > 0 ? b : 0); } else { _tenantCounts.delete(t); _tenantBytes.delete(t); } return true; } function oldestId(tenant = null, exclude = null) { for (const [id, entry] of _entries) { if (id === exclude) continue; if (!tenant || tenantOf(entry.callerKey) === tenant) return id; } return null; } /** * Evict until the total byte budget holds. Returns false only when even evicting * every unprotected entry cannot bring `_bytes` under MAX_BYTES; the caller then * rolls the just-committed entry back and reports a rejected write. * * `protectedId` (the entry just written) is never a victim: returning true while * the caller's own id 404s is the bug this guard has always prevented. * * P-02's second half: the old loop `break`ed when an over-share tenant had no * other entry of its own, accepting an over-budget total. That exit is gone — if * the writer has nothing else to give, the global LRU scan evicts another victim. * The per-entry ceiling (MAX_ENTRY_BYTES <= MAX_BYTES) bounds what one writer can * force others to absorb. */ function enforceByteBudget(protectedId) { let guard = _entries.size + 1; while (_bytes > MAX_BYTES && _entries.size > 1 && guard-- > 0) { const own = _entries.get(protectedId); const tenant = own ? tenantOf(own.callerKey) : null; const byteShare = Math.max(MIN_TENANT_BYTES, MAX_BYTES / Math.max(1, _tenantBytes.size)); const overShare = tenant !== null && (_tenantBytes.get(tenant) || 0) > byteShare; const victim = (overShare ? oldestId(tenant, protectedId) : null) || oldestId(null, protectedId); if (!victim) break; dropEntry(victim); _stats.evictions++; } return _bytes <= MAX_BYTES; } // The two expiry predicates, named so the lookup path and the sweep cannot drift // apart. `lastAccess` is refreshed by every read (idle timeout); `createdAt` is // not (absolute bound). function isPastIdle(entry, now) { return now - entry.lastAccess > TTL_MS; } function isPastMaxAge(entry, now) { return now - entry.createdAt > MAX_AGE_MS; } // Periodic sweep so an idle process does not hold expired conversations. The // per-lookup TTL check is the primary enforcement; this is the safety net. let _sweepTimer = null; function ensureSweepTimer() { if (_sweepTimer) return; _sweepTimer = setInterval(() => { const now = Date.now(); for (const [id, entry] of [..._entries]) { // Both bounds, for the same reason getResponse checks both: an entry kept // warm by reads never trips the idle timeout, and one nobody ever reads // again would sit until the absolute bound if only that were checked here. if (isPastIdle(entry, now) || isPastMaxAge(entry, now)) { dropEntry(id); _stats.expires++; } } }, 5 * 60 * 1000).unref(); } /** * The one sentence a conversation carries when the store dropped turns from it. * * Dropping turns must never be SILENT: the next turn replays this list as context, * and a model handed a history that begins mid-stream cannot tell that earlier * turns existed — it answers fluently about a conversation it never saw. The * wording is load-bearing: handlers/responses.js strips exactly this shape * (`^\[\.\.\. \d+ earlier message\(s\) dropped by the response store[^\]]*\]\n\n`) * before serving an answer, so the notice stays in the CHAINED context without * being presented to the client as model output. * * `setting` names the limit that bound, so an operator reading the conversation * knows which knob to raise. */ function droppedTurnsNotice(count, setting) { return `[... ${count} earlier message(s) dropped by the response store to stay ` + `within ${setting} ...]\n\n`; } /** * Copy-on-write prepend of `note` to `messages[index]`. Returns a new array and a * new message object; a parts-array content gets a leading text block instead of * being flattened, so the turn keeps the structure the next replay expects. * * Prepending to an existing message rather than inserting one is deliberate: a new * message would change the conversation's role sequence, which the connect wire * layer is sensitive to. */ function prependNotice(messages, index, note) { const out = messages.slice(); const target = { ...out[index] }; if (typeof target.content === 'string') target.content = note + target.content; else if (Array.isArray(target.content)) target.content = [{ type: 'text', text: note }, ...target.content]; else target.content = note; out[index] = target; return out; } /** * Keep the conversation within MAX_MESSAGES, preserving the leading system * message(s) — dropping those loses the agent's instructions and changes * behaviour far more than dropping middle turns. * * A count-trimmed conversation is NOT the same conversation any more, so the * result carries a drop notice (see droppedTurnsNotice). Each limiter reports the * turns IT dropped: when the byte ceiling later drops further, the surviving * conversation carries that stage's notice instead. The count is a floor for the * total in that combined case, never a claim that nothing was lost. */ function truncateMessages(messages) { if (messages.length <= MAX_MESSAGES) return messages; let lead = 0; while (lead < messages.length && messages[lead]?.role === 'system') lead++; // The leading system block gets at most HALF the budget. Without a cap on the // head, `MAX_MESSAGES - lead` goes negative once the system block alone reaches // the budget, and `slice(-negative)` silently becomes `slice(positive)` — it // returns most of the array instead of the last few entries, so the result grew // INSTEAD of shrinking (measured: 501 messages in, 901 stored). Codex-style // clients that send AGENTS.md / environment context as many separate developer // items reach that shape with no adversarial intent. // Cap the head only where it MUST be capped. The negative-slice bug fired at // lead >= MAX_MESSAGES; a head budget of MAX/2 also changed the outcome for lead // in (MAX/2, MAX), a range where the old code was already correct and preserved // every leading system message. Clamping to MAX_MESSAGES - 1 fixes the bug while // leaving that range byte-identical, and still guarantees room for one real turn. const head = messages.slice(0, Math.min(lead, MAX_MESSAGES - 1)); const tailBudget = MAX_MESSAGES - head.length; const tail = tailBudget > 0 ? messages.slice(-tailBudget) : []; const out = [...head, ...tail]; const dropped = messages.length - out.length; // Unreachable while MAX_MESSAGES >= 1 (the head is clamped to MAX-1, so a tail // survives), but the guard keeps a future bound from turning this into a notice // that claims a drop it did not make. if (dropped <= 0) return out; // Where the notice goes: the message immediately AFTER the gap, i.e. the first // surviving turn. When that is also the LAST message (a huge system block leaves // exactly one turn of tail budget), prepending would rewrite the very turn the // store exists to preserve byte-identically, so the notice goes on the other side // of the seam — the last surviving head message, equally adjacent to the gap. const seam = head.length < out.length - 1 ? head.length : Math.max(0, head.length - 1); log.warn(`[response-store] dropped ${dropped} message(s) to fit RESPONSE_STORE_MAX_MESSAGES`); return prependNotice(out, seam, droppedTurnsNotice(dropped, 'RESPONSE_STORE_MAX_MESSAGES')); } // No single conversation may claim more than this share of the whole budget. // Without a per-entry ceiling the eviction loop cannot converge fairly: a tenant // whose one entry alone exceeds the budget has nothing of its OWN left to evict, // so it falls through to the global scan and flushes strangers — which is exactly // the cross-tenant DoS the byte-denominated fair share is meant to prevent. // The floor must never exceed a smaller configured total: at MAX_BYTES=1 the // old `max(64KiB, MAX_BYTES/4)` produced a 64KiB per-entry ceiling, so one entry // could be 64KiB against a 1-byte total budget. Clamp it to MAX_BYTES. const MAX_ENTRY_BYTES = Math.max(1, Math.min(MAX_BYTES, Math.max(64 * 1024, Math.floor(MAX_BYTES / 4)))); // Floor under the per-tenant byte share used by the eviction loop. `MAX_BYTES / // tenants` is the fair-share rule, but taken literally it self-destructs at scale: // past a few hundred tenants the share falls below one ordinary conversation, so // EVERY tenant reads as over-share, each one only ever evicts its own oldest, and // a tenant holding a single entry has nothing to evict at all — the loop breaks // with the budget still exceeded. 1/64th of the budget (min 256KB) is the point // below which a tenant is too small for self-eviction to bound anything, and the // global LRU scan takes over. Above the floor the fair-share behaviour that // prevents the cross-tenant DoS is unchanged. The floor is clamped to MAX_BYTES // so it can never exceed the whole configured budget. const MIN_TENANT_BYTES = Math.min(MAX_BYTES, Math.max(256 * 1024, Math.floor(MAX_BYTES / 64))); /** * Trim a conversation until it fits MAX_ENTRY_BYTES, dropping from the MIDDLE. * The leading system block and the most recent turns are what a next turn needs; * the middle is the cheapest thing to lose, and losing it is strictly better than * either refusing the write or evicting other tenants to make room. */ function capEntryBytes(messages, limit = MAX_ENTRY_BYTES) { if (approxBytes(messages) <= limit) return messages; let lead = 0; while (lead < messages.length && messages[lead]?.role === 'system') lead++; const head = messages.slice(0, Math.min(lead, 8)); let tailStart = messages.length; const out = []; // Walk backwards adding recent turns while they fit. let used = approxBytes(head); while (tailStart > lead) { const candidate = messages[tailStart - 1]; const cost = VALUE_SLOT_BYTES + retainedBytes(candidate); if (used + cost > limit && out.length) break; used += cost; out.unshift(candidate); tailStart--; if (used > limit) break; } const trimmed = [...head, ...out]; // Dropping messages must never be SILENT. The backward walk stops as soon as one // message has been kept and the next does not fit, so an over-cap message with // any smaller message after it is excluded entirely — and because `trimmed` then // fits, the content-level fallback below never runs and no marker is written. // Reproduced at RESPONSE_STORE_MAX_BYTES=8m: a 2MB user turn followed by a short // assistant reply stored as `["assistant"]` — the user's question, including a // pasted file, vanished with no marker and no log line. The next chained turn // then asked the model about content it was never shown, and answered 200 with a // confident context-free reply: exactly the silent-wrong-answer mode this module // exists to remove. Small-VPS operators are explicitly told to lower this budget, // so the shape is ordinary rather than adversarial. const dropped = messages.length - trimmed.length; // Prepended to the first SURVIVING message rather than added as a new one: // inserting a message would change the conversation's role sequence, which the // connect wire layer is sensitive to (see prependNotice). const marked = dropped > 0 && trimmed.length ? prependNotice(trimmed, 0, droppedTurnsNotice(dropped, 'RESPONSE_STORE_MAX_BYTES')) : trimmed; if (marked !== trimmed) { log.warn(`[response-store] dropped ${dropped} message(s) to fit RESPONSE_STORE_MAX_BYTES`); } // Last resort: a conversation whose SINGLE message already exceeds the ceiling // cannot be shrunk by dropping messages. Cut the text itself, otherwise the // store's one promise — total bytes stay within the budget — is false whenever // an operator configures a small budget (a 10MB request body, the server cap, // lands as ~20MB accounted). The marker keeps the truncation visible to whoever // reads the chained context instead of silently changing what the model saw. if (approxBytes(marked) > limit) return trimContentToFit(marked, limit); return marked; } const TRUNCATION_MARKER = '\n\n[... truncated by the response store to stay within RESPONSE_STORE_MAX_BYTES ...]'; // Keys whose STRING value must never be cut, because it is an identifier or a // discriminator rather than payload. `tool_call_id` and `id` pair an assistant // tool_call with its tool result — cutting either breaks the pairing and the // upstream rejects the whole conversation. `type` is the block discriminator the // wire layer dispatches on; `role` and `name` are likewise structural. const UNSHRINKABLE_KEYS = new Set(['type', 'role', 'id', 'tool_call_id', 'name']); // Tool-call arguments are JSON TEXT, not prose: `tool_calls[i].function.arguments` // in the Chat shape and `function_call.arguments` in the Responses shape are both // documents the next turn replays verbatim. Appending TRUNCATION_MARKER to one // produced `{"path":"/tmp/a","content":"xxxx[... truncated ...]"` — a string that // no longer parses, so the upstream rejects the conversation (or the model reasons // about garbage) with nothing in the stored history saying why. // // The whole value is replaced instead, by a marker that is ITSELF valid JSON. The // call keeps its id and function name, so the tool result still pairs; only the // payload is gone, and the loss is explicit in a field a client can parse. const ARGUMENTS_TRUNCATION_JSON = JSON.stringify({ __truncated__: '[... tool call arguments truncated by the response store to stay ' + 'within RESPONSE_STORE_MAX_BYTES ...]', }); // Cheap structural test, deliberately not JSON.parse: an arguments string can be // megabytes and every trim pass would re-pay the parse. Tool-call arguments are a // JSON object (the OpenAI contract), so the first non-space byte is the whole // question; anything else was never parseable JSON and keeps the prefix cut. function looksLikeJsonDocument(value) { for (let i = 0; i < value.length; i++) { const c = value.charCodeAt(i); // space, tab, LF, VT, FF, CR, BOM — whitespace JSON.stringify never emits but // a hand-written client might. if (c === 0x20 || c === 0x09 || c === 0x0a || c === 0x0b || c === 0x0c || c === 0x0d || c === 0xfeff) continue; return c === 0x7b /* { */ || c === 0x5b /* [ */; } return false; } /** * Every string in a message that MAY be shortened, as a path from the message * object (e.g. `['content', 2, 'text']`, `['tool_calls', 0, 'function', * 'arguments']`, or `['content']` for the plain-string shape). * * WHY a walk rather than reading `.content`: the previous version read * `typeof m.content === 'string' ? m.content.length : 0`, so for the ARRAY content * shape — which is the DEFAULT for the Responses API, since responsesToChat * normalizes every input item into typed blocks — no candidate was ever found, * `biggest` stayed -1 and the loop broke on its first iteration. MAX_ENTRY_BYTES * was therefore a no-op for the common case, while approxBytes/retainedBytes counted * those arrays correctly: the metering was fixed and the enforcement was not, * which is why the byte-budget tests passed with the cap doing nothing. * MEASURED at RESPONSE_STORE_MAX_BYTES=400k (MAX_ENTRY_BYTES 102400): * content as string, 200000 chars -> stored 50124 chars, marker present * content as array, 200000 chars -> stored 200000 chars, NO marker, 400104 bytes * i.e. ~3.9x the per-entry ceiling, which is enough for one tenant to LRU-evict * the whole store. * * The walk depth matches retainedBytes' depth: anything the metering charges for * is reachable here. Without that, a deeply nested string was counted by the * meter but invisible to the trimmer, so the entry could never be brought under * the ceiling. `seen` keeps a cyclic graph from looping forever. */ function shrinkableSlots(node, path = [], depth = 0, out = [], seen = new WeakSet()) { if (node == null || typeof node !== 'object') return out; if (depth > MAX_RETAINED_DEPTH || seen.has(node)) return out; if (Buffer.isBuffer(node) || ArrayBuffer.isView(node) || node instanceof ArrayBuffer) return out; seen.add(node); try { const entries = Array.isArray(node) ? node.map((v, i) => [i, v]) : Object.entries(node); for (const [key, value] of entries) { if (typeof value === 'string') { if (typeof key === 'string' && UNSHRINKABLE_KEYS.has(key)) continue; // `arguments` is a DOCUMENT, not prose: it gets the whole-value JSON // replacement instead of the prefix cut. The floor differs because the two // replacements differ, and a replacement that is not shorter would make the // trim loop grow the value it is trying to shrink. const json = key === 'arguments' && looksLikeJsonDocument(value); const floor = (json ? ARGUMENTS_TRUNCATION_JSON.length : TRUNCATION_MARKER.length) + 1; if (value.length <= floor) continue; out.push({ path: [...path, key], len: value.length, json }); } else if (value && typeof value === 'object') { shrinkableSlots(value, [...path, key], depth + 1, out, seen); } } } finally { seen.delete(node); } return out; } /** * Copy-on-write assignment along `path`. Copies every node it descends through so * the CALLER's message objects are never mutated — `messages.map(m => ({...m}))` * is a shallow copy, so a content array and its blocks are still shared with the * live request body, and writing through them would rewrite what the upstream is * about to be sent. */ function withStringAt(node, path, value) { const [key, ...rest] = path; const copy = Array.isArray(node) ? [...node] : { ...node }; Object.defineProperty(copy, key, { value: rest.length ? withStringAt(node[key], rest, value) : value, enumerable: true, configurable: true, writable: true, }); return copy; } /** * Cut the largest shrinkable strings until the entry fits `limit`. * * P-02: this used to stop after a fixed 64 halving passes. That is enough for a * handful of blocks but not for the ordinary 20-block shape: measured at * RESPONSE_STORE_MAX_BYTES=256k (limit 65,536), 20 x 60,000-char blocks were * stored at 276,744 bytes — 4.2x the ceiling — because each pass halves only ONE * string and 20 blocks need well over 64 passes to reach the target. The loop now * runs to CONVERGENCE: every pass strictly shortens the largest candidate, so it * terminates when the entry fits or when no string can shrink any further. If it * cannot fit, the caller rejects the write instead of storing an over-cap entry. * * The `keep` bound matters too: `floor(len/2) + marker` is LARGER than `len` when * a string is only slightly longer than the marker, so the old formula could grow * the value it was trying to shrink. `len - marker - 1` guarantees strict * progress every pass. */ function trimContentToFit(messages, limit = MAX_ENTRY_BYTES) { let out = messages.map(m => ({ ...m })); let compactedArguments = 0; // Shrink from the LARGEST string field down until the entry fits. The field may // live inside a content-block array or inside a tool_call, not only at // `m.content` — see shrinkableSlots. while (approxBytes(out) > limit) { let biggest = null; for (let i = 0; i < out.length; i++) { for (const slot of shrinkableSlots(out[i], [i])) { if (!biggest || slot.len > biggest.len) biggest = slot; } } // No candidate left: the remaining bytes are structural (ids, keys, // discriminators, typed arrays). The caller must reject, never store over-cap. if (!biggest) return null; const [msgIndex, ...rest] = biggest.path; const current = rest.reduce((n, k) => n[k], out[msgIndex]); let cut; if (biggest.json) { // Valid JSON in, valid JSON out. The old prefix cut with the plain-text // marker appended is not JSON any more, and the next turn replays it // verbatim as the tool call's arguments. cut = ARGUMENTS_TRUNCATION_JSON; compactedArguments++; } else { const keep = Math.max(1, Math.min( Math.floor(biggest.len / 2), biggest.len - TRUNCATION_MARKER.length - 1, )); if (keep >= biggest.len) return null; cut = current.slice(0, keep) + TRUNCATION_MARKER; } // Progress is what makes the loop terminate. shrinkableSlots' floor already // guarantees a shorter replacement; this is the belt to that suspenders, and it // turns any future candidate that cannot shrink into a rejected write instead of // a hang. if (typeof current !== 'string' || cut.length >= current.length) return null; // The block STRUCTURE is preserved: only the string at this path changes, so a // `{type:'text', text}` block stays a text block and the next turn can still be // replayed from the array. `rest` is never empty — shrinkableSlots always // appends at least one key to the message index. out[msgIndex] = withStringAt(out[msgIndex], rest, cut); } if (compactedArguments) { log.warn(`[response-store] replaced oversized tool call arguments in ${compactedArguments} slot(s)`); } return out; } // Tokens a JSON client uses to mean "no" in a field the schema types as boolean. const FALSY_STORE_TOKENS = new Set(['false', '0', 'no', 'off', '']); /** * Does this `store` value ask for persistence? * * The check used to be `opts.store === false` — strict, on a value that arrives * RAW off the request body (handlers/responses.js passes `store: body.store` with * no normalization). So only a real JSON `false` opted out. MEASURED against HEAD: * store=false -> not stored (correct) * store="false" / 0 / "no" / "off" / null / "" -> ALL stored and retrievable * A client that spells the flag as a string — form-encoded relays, shell wrappers * and hand-rolled clients all do — got retention it explicitly opted out of, and * had no way to tell. * * WHERE this belongs: here, not only at the HTTP boundary. The retention decision * is the store's own contract ("`store:false` means this response is not * retained"), and the store is what would have to be audited if the promise were * broken. A boundary-only fix leaves the strict comparison in place for every * other caller, present and future, and this call site was added by exactly that * kind of later wiring. The boundary is still where a MALFORMED request should be * rejected outright; normalizing here means the store never over-retains even when * it is not. * * `undefined` means the field was absent, and the OpenAI default is store=true — * so absent must NOT read as opted out. An explicit `null` does: it is a value the * client chose to send, and for a retention decision the safe reading of an * ambiguous explicit value is "do not retain" (the cost is a 404 on a later chain * attempt, which is diagnosable; the cost of the other reading is silent retention * of data the caller may believe was never kept). An unrecognized token * ("maybe", {}) is not an opt-out signal and keeps the documented default. */ export function wantsPersistence(store) { if (store === undefined) return true; if (store === null) return false; if (typeof store === 'boolean') return store; if (typeof store === 'number') return store !== 0; if (typeof store === 'string') return !FALSY_STORE_TOKENS.has(store.trim().toLowerCase()); return true; } /** * Store the accumulated conversation under a response id. * * @param {string} responseId the id handed back to the client * @param {Array} messages FULL conversation for this turn (input + output) * @param {string} callerKey tenant/identity scope * @param {object} [opts] { model?: string, store?: boolean|string|number|null, * metadata?: object, status?: string, incompleteReason?: string|null } * @returns {boolean} true when persisted */ export function putResponse(responseId, messages, callerKey, opts = {}) { if (!ENABLED || !responseId || !Array.isArray(messages) || !messages.length) return false; // The OpenAI contract: `store: false` means this response is not retained, so // it can never serve as a later previous_response_id. Normalized rather than // compared strictly — see wantsPersistence for what the strict check let through. if (!wantsPersistence(opts.store)) return false; // No caller scope means no way to isolate this conversation from another // tenant's — refuse rather than create a shared-readable entry. if (!callerKey) { _stats.rejected++; return false; } ensureSweepTimer(); // Ownership is enforced on the WRITE path too. Lookup and delete were scoped // from the start, but replacement was keyed on responseId alone: a second // caller putting an existing id inherited the owner's metadata/model/status/ // createdAt, then dropEntry() removed the owner's entry and stored the writer's. // A's next read became 'forbidden' for A's own id, and B read A's metadata. // Reject before eviction, inheritance, replacement or any accounting change; // the only counter that moves is `rejected`. const existing = _entries.get(responseId); if (existing && existing.callerKey !== callerKey) { _stats.rejected++; log.warn(`[response-store] rejected cross-caller overwrite of ${String(responseId).slice(0, 24)}`); return false; } // ── 1. validate + prepare — NO state mutation ──────────────────────────── // // A19: the count victim used to be dropped BEFORE `copyStoredValue`, which is // fallible (structuredClone refuses functions/symbols, and a getter can throw). // A failed new put therefore deleted an unrelated conversation even though it // stored nothing. Everything fallible now happens here: truncation, copy, // metadata binding and final size admission. `_entries`, the tenant maps and // every counter stay untouched until the commit below. // // Select — but do NOT drop — the entry-count victim. Selection is a pure read; // dropping it is part of the synchronous commit. let countVictim = null; if (_entries.size >= MAX_ENTRIES && !existing) { // Fair share: a tenant already at or above its slice evicts its OWN oldest // conversation instead of a stranger's. const tenant = tenantOf(callerKey); const fairShare = Math.max(1, Math.floor(MAX_ENTRIES / Math.max(1, _tenantCounts.size))); const overShare = (_tenantCounts.get(tenant) || 0) >= fairShare; countVictim = (overShare && oldestId(tenant)) || oldestId(); } const now = Date.now(); // The slot follows the same refresh rule as the other optional fields: an // explicit value wins, an absent one keeps what the entry already had. const metadata = opts.metadata === undefined ? (existing?.metadata ?? null) : boundMetadata(opts.metadata); const createdAt = existing?.createdAt || now; const model = opts.model || existing?.model || null; // The turn's terminal status, so a later retrieval reports what the CREATING // request reported. Without it GET had to hardcode 'completed' and contradicted // POST for the same id whenever the turn ended as `incomplete`. const status = opts.status || existing?.status || 'completed'; const incompleteReason = opts.incompleteReason ?? existing?.incompleteReason ?? null; // A20: meter every retained field, not just `messages`. The fixed part includes // responseId (the Map key), callerKey, model, status, incompleteReason and // metadata, so messages get only what remains of the per-entry ceiling. const fixedBytes = entryFixedBytes({ responseId, callerKey, model, status, incompleteReason, metadata }); const messageBudget = MAX_ENTRY_BYTES - fixedBytes; if (messageBudget <= 0) { _stats.rejected++; return false; } // Existing limiters are copy-on-write. Clone only the retained graph, so // discarded history is not copied and no stored object aliases the caller. const capped = capEntryBytes(truncateMessages(messages), messageBudget); if (!capped) { // Trimming hit structural data it must not cut (ids, discriminators, keys). // Reject rather than store an entry over the ceiling. _stats.rejected++; return false; } const kept = copyStoredValue(capped); const bytes = fixedBytes + retainedBytes(kept); // Final admission. Both limits are hard: an entry that cannot satisfy the // per-entry ceiling — and therefore can never satisfy the total budget — is // rejected without changing the old entry or any accounting. if (!Number.isFinite(bytes) || bytes > MAX_ENTRY_BYTES || bytes > MAX_BYTES) { _stats.rejected++; return false; } // ── 2. synchronous commit — no fallible operations below this line ──────── const countVictimEntry = countVictim ? _entries.get(countVictim) : null; if (countVictim) { dropEntry(countVictim); _stats.evictions++; } if (existing) dropEntry(responseId); trackInsert(callerKey, bytes); _entries.set(responseId, { messages: kept, callerKey, model, status, incompleteReason, metadata, createdAt, lastAccess: now, bytes, }); _bytes += bytes; // ── 3. post-commit cleanup — byte budget ────────────────────────────────── // // Entry/message counts bound cardinality, NOT memory, so evict until the byte // total fits as well. // // The fairness test must be BYTE-denominated. The first cut reused the count // test (`tenantCount >= MAX_ENTRIES / tenants`), which under any byte-bound // workload leaves the writer far below its entry share — so `overShare` was // always false, the loop fell through to the untenanted scan, and one caller // storing a large conversation flushed every other tenant's sessions. // Reproduced: 5 tenants x 5 conversations all readable, then a single 5MB write // evicted 25/25 of them. That is a cross-tenant DoS the count-only store never // had, so fairness here compares the tenant's BYTES against MAX_BYTES/tenants. // // `exclude: responseId` is load-bearing and NOT redundant with `size > 1`: the // Map-tail argument only holds for the untenanted scan. oldestId(tenant) can // return the entry just written when it is that tenant's oldest survivor, and // then putResponse returned true while the id 404'd immediately after // (reproduced with MAX=2, MAX_BYTES=200k). An earlier version dropped this // guard as dead code because the mutation test only exercised the untenanted // path. // // The share has a FLOOR. `MAX_BYTES / tenants` alone collapses as tenants grow: // at 600 tenants against the 2MB test budget the share is ~3.5KB, so every // tenant is over share, every iteration evicts the writer's own oldest, and once // the writer has nothing left of its own the old loop broke — with the budget // still blown. Reproduced: 600 tenants x one 20KB conversation → 5.88x the // budget and ZERO evictions. The floor keeps eviction working past that point, // and enforceByteBudget falls back to the global LRU instead of exiting over // budget (P-02). if (!enforceByteBudget(responseId)) { // Unreachable while MAX_ENTRY_BYTES <= MAX_BYTES and every entry was admitted // through the check above. Kept as a rollback so a future change cannot turn a // failed write into a deleted old entry. dropEntry(responseId); if (existing) { _entries.set(responseId, existing); _bytes += existing.bytes || 0; trackInsert(existing.callerKey, existing.bytes || 0); } if (countVictimEntry && !_entries.has(countVictim)) { _entries.set(countVictim, countVictimEntry); _bytes += countVictimEntry.bytes || 0; trackInsert(countVictimEntry.callerKey, countVictimEntry.bytes || 0); } _stats.rejected++; return false; } _stats.stored++; return true; } /** * Resolve a previous_response_id to its accumulated conversation. * * Returns a discriminated result so the caller can fail LOUDLY on a miss. Silently * proceeding with just the new turn is the bug this module exists to remove: the * model would answer with no context and the client could not tell. * * `createdAt` is returned so the retrieval endpoint can report the response's real * creation time instead of the time it happened to be read, and `metadata` so the * tag the creating request attached survives to a later GET. * * @returns {{ ok: true, messages: Array, model: string|null, createdAt: number, * metadata: object|null } * | { ok: false, reason: 'disabled'|'not_found'|'expired'|'forbidden' }} */ export function getResponse(responseId, callerKey) { if (!ENABLED) return { ok: false, reason: 'disabled' }; if (!responseId) return { ok: false, reason: 'not_found' }; ensureSweepTimer(); const entry = _entries.get(responseId); if (!entry) { _stats.misses++; return { ok: false, reason: 'not_found' }; } const now = Date.now(); // Ownership is settled BEFORE anything is dropped. This used to be the other way // round: the expiry branch called dropEntry() and returned nine lines above this // guard, so a foreign caller who guessed a valid id DELETED the owner's expired // entry. Probing was indeed closed (the guard's own promise), but the branch above // it had already acted. MEASURED at RESPONSE_STORE_TTL_MS=60, entry owned by A: // after expiry, B reads -> ok=false reason='expired' // then A reads -> ok=false reason='not_found' <- B's read reaped it // Reordering keeps both properties, and the reason each case returns is: // foreign caller, entry fresh OR expired -> 'forbidden', and NOTHING is dropped. // The outward result is identical to a miss (handlers/responses.js maps // 'forbidden', 'expired' and 'not_found' to the same 404 body, so the reason // was never the leak — the DELETION was), and the foreign caller now also // cannot distinguish an expired id from a fresh one by its side effect. // owner, entry expired -> 'expired', and the entry IS reaped, so the owner's // own dead conversation is still collected on the lookup path rather than // waiting for the 5-minute sweep. if (entry.callerKey !== callerKey) { _stats.rejected++; log.warn(`[response-store] rejected cross-caller lookup of ${String(responseId).slice(0, 24)}`); return { ok: false, reason: 'forbidden' }; } // Two independent bounds, both reported as 'expired' because the client-visible // meaning is the same ("this conversation is gone, send full context"): // the IDLE timeout, which a read refreshes, and the ABSOLUTE age, which it does // not. Without the second one a periodic GET kept an entry alive without limit. if (isPastIdle(entry, now) || isPastMaxAge(entry, now)) { dropEntry(responseId); _stats.expires++; return { ok: false, reason: 'expired' }; } entry.lastAccess = now; // Re-insert to keep Map order == LRU order. _entries.delete(responseId); _entries.set(responseId, entry); _stats.hits++; return { ok: true, // Each reader owns its result, including nested content/tool-call objects. messages: copyStoredValue(entry.messages), model: entry.model, createdAt: entry.createdAt, status: entry.status, incompleteReason: entry.incompleteReason, // The tag the CREATING request attached, so a later GET reports the same // metadata the response object carried. Copied like every other field a // reader can reach: the entry must not be mutable through the result. metadata: entry.metadata ? copyStoredValue(entry.metadata) : null, }; } /** Explicit deletion (DELETE /v1/responses/{id}). Scoped like the lookup. */ export function deleteResponse(responseId, callerKey) { if (!ENABLED || !responseId) return false; const entry = _entries.get(responseId); if (!entry) return false; if (entry.callerKey !== callerKey) { _stats.rejected++; return false; } return dropEntry(responseId); } export function getResponseStoreStats() { return { ..._stats, size: _entries.size, bytes: _bytes, maxBytes: MAX_BYTES, // Both time bounds, reported for the same reason maxBytes is: an operator // asking "how long is a conversation kept?" has two different answers, and the // idle one on its own does not bound retention at all. ttlMs: TTL_MS, maxAgeMs: MAX_AGE_MS, enabled: ENABLED, tenants: _tenantCounts.size, }; } /** Test/reset helper. */ export function resetResponseStore() { _entries.clear(); _tenantCounts.clear(); _tenantBytes.clear(); _bytes = 0; for (const k of Object.keys(_stats)) _stats[k] = 0; }