From ce78965063794293bdc9ec3e32cd6095e9d52d4c Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Sat, 8 Aug 2026 09:08:47 -0300 Subject: [PATCH] =?UTF-8?q?fix(sse):=20preserve=20original=20body=20for=20?= =?UTF-8?q?semantic=20cache=20signature=20=E2=80=94=20fixes=200%=20hit=20r?= =?UTF-8?q?ate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The semantic cache signature (generateSignature) was computed over different bodies at read-time vs write-time in handleChatCore. The cache read at Phase 9.1 uses the original body, but the writes at Phase 9.1 (non-streaming) and Phase 9.2 (streaming) used the body after sanitizeChatRequestBody() and injectMemoryAndSkills() mutated messages. Since the digest includes messages, every request stored under a key no later request would look up — 0% hit rate, every request billed. Fix: snapshot bodyForCacheWrite right after the cache read and use it for both write paths, so the write-time signature equals the read-time one. TDD: tests/unit/cache-signature-roundtrip.test.ts proves the mutated body produces a different signature (bug) and the preserved snapshot produces an identical one (fix). --- open-sse/handlers/chatCore.ts | 11 +- tests/unit/cache-signature-roundtrip.test.ts | 100 +++++++++++++++++++ 2 files changed, 109 insertions(+), 2 deletions(-) create mode 100644 tests/unit/cache-signature-roundtrip.test.ts diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 2b110b47f4..586847130e 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -1051,6 +1051,13 @@ export async function handleChatCore({ log?.debug?.("FORMAT", `${sourceFormat} → ${targetFormat} | stream=${stream}`); + // Preserve original body for cache signature — the body variable is mutated + // multiple times below (sanitization, memory/skills injection) before the + // cache store path runs at Phase 9.1 (non-streaming) / Phase 9.2 (streaming). + // Without this snapshot, the write-time signature differs from the read-time + // one, producing 0% hit rate. (#cache-signature-asymmetry) + const bodyForCacheWrite = body; + // ── Phase 9.1: Semantic cache check (temp=0, any streaming mode) ── const cacheHit = await checkSemanticCache({ semanticCacheEnabled, @@ -4539,7 +4546,7 @@ export async function handleChatCore({ // ── Phase 9.1: Cache store (non-streaming, temp=0) ── storeSemanticCacheResponse({ enabled: semanticCacheEnabled, - body, + body: bodyForCacheWrite, headers: clientRawRequest?.headers, translatedResponse, model, @@ -4890,7 +4897,7 @@ export async function handleChatCore({ enabled: semanticCacheEnabled, streamStatus, streamResponseBody, - body, + body: bodyForCacheWrite, headers: clientRawRequest?.headers, model, apiKeyId: apiKeyInfo?.id ?? undefined, diff --git a/tests/unit/cache-signature-roundtrip.test.ts b/tests/unit/cache-signature-roundtrip.test.ts new file mode 100644 index 0000000000..4848adc603 --- /dev/null +++ b/tests/unit/cache-signature-roundtrip.test.ts @@ -0,0 +1,100 @@ +// TDD regression guard: semantic cache read-time vs write-time signature must match. +// Without bodyForCacheWrite, the body variable is mutated between the cache read +// (Phase 9.1, chatCore.ts:1055) and the cache write (Phase 9.1 non-streaming:4540 / +// Phase 9.2 streaming:4896) by sanitizeChatRequestBody() and injectMemoryAndSkills(). +// The digest includes messages, so mutations produce a different key → 0% hit rate. +import test from "node:test"; +import assert from "node:assert/strict"; + +const { generateSignature } = await import("../../src/lib/semanticCache.ts"); + +const BASE_BODY = { + model: "gpt-4o", + messages: [{ role: "user", content: "What is the capital of France?" }], + temperature: 0, + top_p: 1, +}; + +// Mutations that handleChatCore applies between cache read and cache write. +function simulateSanitizeChatRequestBody(body: Record): Record { + return { ...body, _sanitized: true }; +} + +function simulateInjectMemoryAndSkills(body: Record): Record { + return { + ...body, + messages: [ + { role: "system", content: "[Memory: user likes programming]" }, + ...(body.messages as Array), + ], + }; +} + +test("cache signature must be identical before and after body mutations", async () => { + // Compute signature at read-time (original body, line 1055). + const readSignature = await generateSignature( + BASE_BODY.model, + BASE_BODY.messages, + BASE_BODY.temperature, + BASE_BODY.top_p + ); + + // Simulate the mutations that happen between cache read and cache write. + let mutatedBody = simulateSanitizeChatRequestBody(BASE_BODY); + mutatedBody = simulateInjectMemoryAndSkills(mutatedBody); + + // Compute signature at write-time using the mutated body (the bug). + const writeSignatureMutated = await generateSignature( + mutatedBody.model, + mutatedBody.messages, + mutatedBody.temperature, + mutatedBody.top_p + ); + + // Compute signature at write-time using the preserved snapshot (the fix). + const writeSignaturePreserved = await generateSignature( + BASE_BODY.model, + BASE_BODY.messages, + BASE_BODY.temperature, + BASE_BODY.top_p + ); + + // The mutated signature MUST differ — this is the bug. + assert.notStrictEqual( + readSignature, + writeSignatureMutated, + "BUG: mutated body produces different signature — cache has 0% hit rate" + ); + + // The preserved snapshot MUST match — this is the fix. + assert.strictEqual( + readSignature, + writeSignaturePreserved, + "FIX: preserved snapshot must produce the same signature as read-time" + ); +}); + +test("cache signature with memory injection changes messages", async () => { + const bodyWithMemory = { + ...BASE_BODY, + messages: [ + { role: "system", content: "[Memory: context from previous conversation]" }, + ...BASE_BODY.messages, + ], + }; + + const sigOriginal = await generateSignature( + BASE_BODY.model, + BASE_BODY.messages, + BASE_BODY.temperature, + BASE_BODY.top_p + ); + const sigWithMemory = await generateSignature( + bodyWithMemory.model, + bodyWithMemory.messages, + bodyWithMemory.temperature, + bodyWithMemory.top_p + ); + + assert.notStrictEqual(sigOriginal, sigWithMemory); +});