Ctrl K
ComunidadGuías…

test/cache.test.js

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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');
  });
});