Ctrl K
ComunidadGuías…

test/chat-cache-hit.test.js

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

Archivo original ↗Preguntar a KiBot
import { afterEach, describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { addAccountByKey, removeAccount } from '../src/auth.js';
import { cacheClear, cacheKey, cacheSet, cacheStats } from '../src/cache.js';
import { handleChatCompletions, __setConnectDeps, __resetConnectDeps } from '../src/handlers/chat.js';
import { stopLanguageServerAndWait } from '../src/langserver.js';

const createdAccountIds = [];
const originalResponseCacheEnabled = process.env.RESPONSE_CACHE_ENABLED;
const originalDevinConnect = process.env.DEVIN_CONNECT;

function fakeRes() {
  const listeners = new Map();
  return {
    body: '',
    writableEnded: false,
    write(chunk) {
      this.body += String(chunk);
      return true;
    },
    end(chunk) {
      if (chunk) this.write(chunk);
      this.writableEnded = true;
      for (const cb of listeners.get('close') || []) cb();
    },
    on(event, cb) {
      if (!listeners.has(event)) listeners.set(event, []);
      listeners.get(event).push(cb);
      return this;
    },
  };
}

function parseChatFrames(raw) {
  return raw
    .split('\n\n')
    .filter(Boolean)
    .filter(frame => !frame.startsWith(':'))
    .map(frame => {
      const dataLine = frame.split('\n').find(line => line.startsWith('data: '));
      const payload = dataLine?.slice(6) || '';
      return payload === '[DONE]' ? '[DONE]' : JSON.parse(payload);
    });
}

afterEach(async () => {
  // The Cascade test starts a real LS; a surviving child keeps the test runner
  // alive on hosts that have a language_server binary installed (HMC).
  await stopLanguageServerAndWait({ perProcessTimeoutMs: 500 }).catch(() => {});
  cacheClear();
  __resetConnectDeps();
  while (createdAccountIds.length) {
    removeAccount(createdAccountIds.pop());
  }
  if (originalResponseCacheEnabled === undefined) delete process.env.RESPONSE_CACHE_ENABLED;
  else process.env.RESPONSE_CACHE_ENABLED = originalResponseCacheEnabled;
  if (originalDevinConnect === undefined) delete process.env.DEVIN_CONNECT;
  else process.env.DEVIN_CONNECT = originalDevinConnect;
});

describe('chat cache-hit stream shape', () => {
  it('matches the live-stream finish chunk plus terminal usage chunk shape (include_usage:true)', async () => {
    const account = addAccountByKey(`cache-key-${Date.now()}`, 'cache-hit');
    createdAccountIds.push(account.id);

    const body = {
      model: 'gemini-2.5-flash',
      stream: true,
      // O1: the trailing usage frame is opt-in; this test asserts its shape, so
      // it explicitly opts in.
      stream_options: { include_usage: true },
      messages: [{ role: 'user', content: 'hi' }],
      // SEC-W2: the response cache only serves cross-request hits for a
      // trustworthy per-user scope. Give this cache-mechanics test a real
      // :user: scope (as a legit single user would) so it exercises the hit path.
      __callerKey: 'api:test:user:cachetester',
    };
    cacheSet(cacheKey(body, body.__callerKey), { text: 'cached answer', thinking: 'cached thinking' });

    const result = await handleChatCompletions(body);
    assert.equal(result.status, 200);
    assert.equal(result.stream, true);

    const res = fakeRes();
    await result.handler(res);
    const frames = parseChatFrames(res.body);
    const finishChunk = frames.at(-3);
    const usageChunk = frames.at(-2);

    assert.equal(finishChunk.choices[0].finish_reason, 'stop');
    assert.equal('usage' in finishChunk, false);
    assert.deepEqual(usageChunk.choices, []);
    assert.deepEqual(usageChunk.usage, {
      cached: true,
      prompt_tokens: 1,
      completion_tokens: 4,
      total_tokens: 5,
      input_tokens: 1,
      output_tokens: 4,
      prompt_tokens_details: { cached_tokens: 1 },
      completion_tokens_details: { reasoning_tokens: 0 },
    });
    assert.equal(frames.at(-1), '[DONE]');
  });

  it('O1: omits the trailing usage frame by default (no stream_options)', async () => {
    const account = addAccountByKey(`cache-key-${Date.now()}-nou`, 'cache-hit');
    createdAccountIds.push(account.id);

    const body = {
      model: 'gemini-2.5-flash',
      stream: true,
      messages: [{ role: 'user', content: 'hi' }],
      __callerKey: 'api:test:user:cachetester',  // SEC-W2: trusted scope for cache-hit path
    };
    cacheSet(cacheKey(body, body.__callerKey), { text: 'cached answer', thinking: 'cached thinking' });

    const result = await handleChatCompletions(body);
    assert.equal(result.status, 200);

    const res = fakeRes();
    await result.handler(res);
    const frames = parseChatFrames(res.body);

    // No frame carries a usage block, and the last real chunk is the finish
    // chunk (choices[0].finish_reason), immediately followed by [DONE].
    const usageFrames = frames.filter(f => f !== '[DONE]' && 'usage' in f);
    assert.deepEqual(usageFrames, [], 'no usage frame without include_usage');
    assert.equal(frames.at(-1), '[DONE]');
    const finishChunk = frames.at(-2);
    assert.equal(finishChunk.choices[0].finish_reason, 'stop');
  });

  it('O1: include_usage:false explicitly also omits the usage frame', async () => {
    const account = addAccountByKey(`cache-key-${Date.now()}-false`, 'cache-hit');
    createdAccountIds.push(account.id);

    const body = {
      model: 'gemini-2.5-flash',
      stream: true,
      stream_options: { include_usage: false },
      messages: [{ role: 'user', content: 'hi' }],
      __callerKey: 'api:test:user:cachetester',  // SEC-W2: trusted scope for cache-hit path
    };
    cacheSet(cacheKey(body, body.__callerKey), { text: 'cached answer', thinking: 'cached thinking' });

    const result = await handleChatCompletions(body);
    const res = fakeRes();
    await result.handler(res);
    const frames = parseChatFrames(res.body);

    const usageFrames = frames.filter(f => f !== '[DONE]' && 'usage' in f);
    assert.deepEqual(usageFrames, [], 'include_usage:false → no usage frame');
  });
});

// SEC-W2: cross-tenant cache isolation. A guessed `:client:<ip+ua>` bucket
// (shared API key behind a reverse proxy, no per-user signal) must NOT receive
// another caller's cached answer by default. This is the regression guard for
// the cross-tenant session-leak fix.
describe('SEC-W2 cross-tenant cache isolation', () => {
  it('a :client: (guessed) scope does NOT get a cross-request cache hit by default', async () => {
    const account = addAccountByKey(`cache-w2-${Date.now()}`, 'w2');
    createdAccountIds.push(account.id);

    // Pre-seed the cache under a :client: bucket, as if user A had populated it.
    const clientCaller = 'api:sharedkey:client:aabbccdd';
    const body = {
      model: 'gemini-2.5-flash',
      messages: [{ role: 'user', content: 'hi' }],
      __callerKey: clientCaller,
    };
    cacheSet(cacheKey(body, clientCaller), { text: 'user-A-secret-answer', thinking: '' });

    // User B, same collapsed :client: bucket, same question — must NOT be served
    // A's cached answer; the request proceeds to normal handling instead.
    const result = await handleChatCompletions(body, { callerKey: clientCaller });
    const served = result?.body?.choices?.[0]?.message?.content || '';
    assert.notEqual(served, 'user-A-secret-answer',
      ':client: bucket must not serve a cross-request cached answer by default');
  });
});

// PR #142 / RESPONSE_CACHE_ENABLED switch: the key-site half (s1-p1-cache-gate)
// asserts the chat key sites skip hashing when disabled. This drives the real
// handler with the switch off and proves the observable contract: duplicate
// requests both reach upstream and the cache records neither a store nor a hit.
describe('RESPONSE_CACHE_ENABLED=0', () => {
  it('sends two identical requests upstream and records no stores or hits', async () => {
    process.env.RESPONSE_CACHE_ENABLED = '0';
    process.env.DEVIN_CONNECT = '1';

    const account = addAccountByKey(
      `devin-session-token$cache-off-${Date.now()}-${Math.random().toString(16).slice(2)}`,
      'cache-off',
    );
    createdAccountIds.push(account.id);
    cacheClear(); // zero the counters after any setup activity

    let upstreamCalls = 0;
    __setConnectDeps({
      toChatCompletion: async () => {
        upstreamCalls++;
        return {
          status: 200,
          body: {
            id: 'chatcmpl-cache-off',
            object: 'chat.completion',
            created: 1,
            model: 'swe-1-6-slow',
            choices: [{
              index: 0,
              message: { role: 'assistant', content: `fresh answer ${upstreamCalls}` },
              finish_reason: 'stop',
            }],
            usage: { prompt_tokens: 2, completion_tokens: 2, total_tokens: 4 },
          },
        };
      },
    });

    const request = {
      model: 'swe-1-6-slow',
      messages: [{ role: 'user', content: 'hi' }],
      __callerKey: 'api:test:user:cache-off',
    };
    const first = await handleChatCompletions(request, { callerKey: request.__callerKey });
    const second = await handleChatCompletions(request, { callerKey: request.__callerKey });

    assert.equal(first.status, 200);
    assert.equal(second.status, 200);
    assert.equal(upstreamCalls, 2,
      'both identical requests must reach upstream when the switch is off');
    assert.notEqual(first.body.choices[0].message.content, second.body.choices[0].message.content,
      'the second response must be generated, not replayed from a cache slot');

    const stats = cacheStats();
    assert.equal(stats.enabled, false);
    assert.equal(stats.stores, 0, 'the disabled cache must never store a response');
    assert.equal(stats.hits, 0, 'the disabled cache must never serve a hit');
    assert.equal(stats.size, 0);
  });
});