import { describe, it, beforeEach } from 'node:test'; import assert from 'node:assert/strict'; import { cacheKey, cacheGet, cacheSet, cacheClear, cacheStats } from '../src/cache.js'; beforeEach(() => { delete process.env.RESPONSE_CACHE_MAX_BYTES; delete process.env.WINDSURFAPI_RESPONSE_CACHE_MAX_BYTES; cacheClear(); }); describe('cacheKey', () => { it('produces deterministic keys', () => { const body = { model: 'gpt-4o', messages: [{ role: 'user', content: 'hi' }] }; assert.equal(cacheKey(body), cacheKey(body)); }); it('differs for different models', () => { const a = { model: 'gpt-4o', messages: [{ role: 'user', content: 'hi' }] }; const b = { model: 'claude-4.5-sonnet', messages: [{ role: 'user', content: 'hi' }] }; assert.notEqual(cacheKey(a), cacheKey(b)); }); it('ignores stream flag', () => { const a = { model: 'gpt-4o', messages: [{ role: 'user', content: 'hi' }], stream: true }; const b = { model: 'gpt-4o', messages: [{ role: 'user', content: 'hi' }], stream: false }; assert.equal(cacheKey(a), cacheKey(b)); }); it('includes base64 image fingerprints in key', () => { const withImage = { model: 'gpt-4o', messages: [{ role: 'user', content: [ { type: 'text', text: 'describe this' }, { type: 'image_url', image_url: { url: 'data:image/png;base64,iVBORw0KGgo' + 'A'.repeat(10000) } }, ]}], }; const withDifferentImage = { model: 'gpt-4o', messages: [{ role: 'user', content: [ { type: 'text', text: 'describe this' }, { type: 'image_url', image_url: { url: 'data:image/png;base64,DIFFERENT' + 'B'.repeat(10000) } }, ]}], }; assert.notEqual(cacheKey(withImage), cacheKey(withDifferentImage)); }); it('matches identical image content', () => { const image = 'data:image/png;base64,' + Buffer.from('same-image').toString('base64'); const a = { model: 'gpt-4o', messages: [{ role: 'user', content: [{ type: 'text', text: 'describe' }, { type: 'image_url', image_url: { url: image } }] }] }; const b = { model: 'gpt-4o', messages: [{ role: 'user', content: [{ type: 'text', text: 'describe' }, { type: 'image_url', image_url: { url: image } }] }] }; assert.equal(cacheKey(a), cacheKey(b)); }); it('separates thinking settings', () => { const base = { model: 'gpt-4o', messages: [{ role: 'user', content: 'hi' }] }; assert.notEqual( cacheKey({ ...base, thinking: { type: 'enabled' } }), cacheKey({ ...base, thinking: { type: 'disabled' } }) ); }); it('cache-01: nested reasoning.effort keys apart (different tiers must not collide)', () => { // chat.js merges the effort into the model id via mergeReasoningEffortIntoModel // (reasoning_effort || reasoning.effort) BEFORE routing, but does NOT mutate // body.model — so cacheKey never saw the nested form. Two requests differing // ONLY in nested reasoning.effort would collide and the second gets the // first's wrong-tier answer. const base = { model: 'gpt-5.5', messages: [{ role: 'user', content: 'hi' }] }; assert.notEqual( cacheKey({ ...base, reasoning: { effort: 'xhigh' } }), cacheKey({ ...base, reasoning: { effort: 'none' } }) ); // presence vs absence also keys apart assert.notEqual(cacheKey({ ...base, reasoning: { effort: 'high' } }), cacheKey(base)); }); it('cache-01: flat reasoning_effort and equal nested reasoning.effort collapse to one slot', () => { // The two spellings are the same generation (chat.js resolves flat-then-nested), // so they must share a cache key. const base = { model: 'gpt-5.5', messages: [{ role: 'user', content: 'hi' }] }; assert.equal( cacheKey({ ...base, reasoning_effort: 'high' }), cacheKey({ ...base, reasoning: { effort: 'high' } }) ); }); it('cache-01: flat reasoning_effort takes precedence over nested in the key', () => { // Both present → flat wins, matching mergeReasoningEffortIntoModel // (body.reasoning_effort || body.reasoning?.effort). const base = { model: 'gpt-5.5', messages: [{ role: 'user', content: 'hi' }] }; assert.equal( cacheKey({ ...base, reasoning_effort: 'high', reasoning: { effort: 'none' } }), cacheKey({ ...base, reasoning_effort: 'high' }) ); }); it('separates output-affecting params (stop, seed)', () => { const base = { model: 'gpt-4o', messages: [{ role: 'user', content: 'hi' }] }; assert.notEqual(cacheKey({ ...base, stop: ['END'] }), cacheKey(base)); assert.notEqual(cacheKey({ ...base, seed: 1 }), cacheKey({ ...base, seed: 2 })); }); it('audit #9: top_k is part of the key (a live DEVIN_CONNECT sampling knob)', () => { // Two requests differing only in top_k are different generations; without // top_k in the key the second would get a reply sampled under the first's. const base = { model: 'gpt-4o', messages: [{ role: 'user', content: 'hi' }] }; assert.notEqual(cacheKey({ ...base, top_k: 10 }), cacheKey({ ...base, top_k: 40 })); assert.notEqual(cacheKey({ ...base, top_k: 10 }), cacheKey(base)); // identical top_k still collapses to one slot assert.equal(cacheKey({ ...base, top_k: 20 }), cacheKey({ ...base, top_k: 20 })); }); it('O3: max_completion_tokens and equal max_tokens collapse to one slot', () => { // The two spellings are the same generation, so they must share a cache key — // otherwise a client migrating from max_tokens to max_completion_tokens would // never hit the warm cache for an identical request. const base = { model: 'gpt-4o', messages: [{ role: 'user', content: 'hi' }] }; assert.equal( cacheKey({ ...base, max_completion_tokens: 256 }), cacheKey({ ...base, max_tokens: 256 }) ); }); it('O3: differing output caps still key apart', () => { const base = { model: 'gpt-4o', messages: [{ role: 'user', content: 'hi' }] }; assert.notEqual( cacheKey({ ...base, max_completion_tokens: 256 }), cacheKey({ ...base, max_completion_tokens: 512 }) ); }); it('O3: max_completion_tokens takes precedence over max_tokens in the key', () => { // Both present → the modern field wins, matching handleChatCompletions. So a // body with max_completion_tokens:500 keys the same regardless of max_tokens. const base = { model: 'gpt-4o', messages: [{ role: 'user', content: 'hi' }] }; assert.equal( cacheKey({ ...base, max_completion_tokens: 500, max_tokens: 100 }), cacheKey({ ...base, max_completion_tokens: 500 }) ); }); it('audit #10: logit_bias key order does not split the cache slot', () => { // Same token-id→bias map, different key insertion order. JSON.stringify // preserves insertion order, so without stable-key normalization these two // structurally-identical requests hashed to different keys and missed each // other's cached response (never wrong data, just a wasted slot + halved // hit rate). stableClone() sorts object keys before serialization. const base = { model: 'gpt-4o', messages: [{ role: 'user', content: 'hi' }] }; const a = { ...base, logit_bias: { '100': 1, '50256': -100, '9': 5 } }; const b = { ...base, logit_bias: { '50256': -100, '9': 5, '100': 1 } }; assert.equal(cacheKey(a), cacheKey(b), 'permuted-but-equal logit_bias must share a slot'); }); it('tool schema key order does not split the cache slot (same fix as logit_bias)', () => { // A tool with the same schema but different key order in `parameters` (two // clients / SDKs serialize object keys differently) must share a cache slot, // not split it. Regression for the tools/tool_choice/response_format/thinking // /stream_options fields that were passed through by reference (not sorted). const base = { model: 'gpt-4o', messages: [{ role: 'user', content: 'hi' }] }; const a = { ...base, tools: [{ type: 'function', function: { name: 'q', parameters: { type: 'object', properties: { a: { type: 'string' }, b: { type: 'integer' } } } } }] }; const b = { ...base, tools: [{ type: 'function', function: { name: 'q', parameters: { properties: { b: { type: 'integer' }, a: { type: 'string' } }, type: 'object' } } }] }; assert.equal(cacheKey(a), cacheKey(b), 'permuted-but-equal tool schema must share a slot'); }); it('genuinely different tool schema still keys apart', () => { const base = { model: 'gpt-4o', messages: [{ role: 'user', content: 'hi' }] }; assert.notEqual( cacheKey({ ...base, tools: [{ type: 'function', function: { name: 'q', parameters: { type: 'object', properties: { a: { type: 'string' } } } } }] }), cacheKey({ ...base, tools: [{ type: 'function', function: { name: 'q', parameters: { type: 'object', properties: { a: { type: 'number' } } } } }] }), 'different param type is a different tool → must key apart', ); }); it('audit #10: genuinely different logit_bias still keys apart', () => { // The normalization must not over-collapse — a different bias value is a // different generation and must still miss. const base = { model: 'gpt-4o', messages: [{ role: 'user', content: 'hi' }] }; assert.notEqual( cacheKey({ ...base, logit_bias: { '100': 1 } }), cacheKey({ ...base, logit_bias: { '100': 2 } }) ); // presence vs absence also keys apart assert.notEqual(cacheKey({ ...base, logit_bias: { '100': 1 } }), cacheKey(base)); }); }); describe('cacheGet / cacheSet', () => { it('returns null on miss', () => { assert.equal(cacheGet('nonexistent'), null); }); it('stores and retrieves values', () => { const value = { text: 'hello', thinking: null }; cacheSet('key1', value); const got = cacheGet('key1'); assert.deepEqual(got, value); }); it('does not cache empty values', () => { cacheSet('empty', null); assert.equal(cacheGet('empty'), null); cacheSet('empty2', { text: '', chunks: [] }); assert.equal(cacheGet('empty2'), null); }); it('skips entries larger than the configured byte limit', () => { process.env.RESPONSE_CACHE_MAX_BYTES = '80'; cacheSet('too-large', { text: 'x'.repeat(200) }); assert.equal(cacheGet('too-large'), null); const stats = cacheStats(); assert.equal(stats.size, 0); assert.equal(stats.skips, 1); assert.equal(stats.maxBytes, 80); }); it('accepts byte units for the configured byte limit', () => { process.env.RESPONSE_CACHE_MAX_BYTES = '0.25kb'; cacheSet('unit-sized', { text: 'x'.repeat(200) }); assert.deepEqual(cacheGet('unit-sized'), { text: 'x'.repeat(200) }); assert.equal(cacheStats().maxBytes, 256); }); it('replaces an existing key with no cache entry when the new value is too large', () => { process.env.RESPONSE_CACHE_MAX_BYTES = '80'; cacheSet('same', { text: 'small' }); cacheSet('same', { text: 'x'.repeat(200) }); assert.equal(cacheGet('same'), null); assert.equal(cacheStats().skips, 1); }); it('evicts oldest entries to stay under the configured byte limit', () => { process.env.RESPONSE_CACHE_MAX_BYTES = '120'; cacheSet('first', { text: 'a'.repeat(40) }); cacheSet('second', { text: 'b'.repeat(40) }); cacheSet('third', { text: 'c'.repeat(40) }); assert.equal(cacheGet('first'), null); assert.deepEqual(cacheGet('second'), { text: 'b'.repeat(40) }); assert.deepEqual(cacheGet('third'), { text: 'c'.repeat(40) }); const stats = cacheStats(); assert.equal(stats.size, 2); assert.ok(stats.bytes <= stats.maxBytes); assert.equal(stats.evictions, 1); }); }); describe('S4-D14: cacheKey refuses pathologically deep bodies', () => { it('throws a bounded error instead of overflowing the stack', () => { let deep = { leaf: true }; for (let i = 0; i < 200; i++) deep = { nested: deep }; const body = { model: 'm', messages: [], tools: [deep] }; assert.throws(() => cacheKey(body), /nesting depth|RangeError/, 'an uncapped recursive clone used to die as a per-request RangeError'); }); });