395 lines
16 KiB
TypeScript
395 lines
16 KiB
TypeScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
|
|
// Isolated DATA_DIR set BEFORE importing anything that touches the DB
|
|
// (checkSemanticCache -> getCachedResponse reads the semantic_cache SQLite table).
|
|
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-sem-cache-"));
|
|
process.env.DATA_DIR = TEST_DATA_DIR;
|
|
|
|
const { checkSemanticCache } = await import("../../open-sse/handlers/chatCore/semanticCache.ts");
|
|
const core = await import("../../src/lib/db/core.ts");
|
|
// Seeding the real cache (no mock.module under the Stryker tap-runner) lets us drive the
|
|
// HIT branch deterministically: setCachedResponse populates the in-memory cache that
|
|
// getCachedResponse checks first, so the signature checkSemanticCache rebuilds resolves.
|
|
const { generateSignature, setCachedResponse, clearCache } = await import(
|
|
"../../src/lib/semanticCache.ts"
|
|
);
|
|
const { OMNIROUTE_RESPONSE_HEADERS } = await import("../../src/shared/constants/headers.ts");
|
|
const { calculateCost } = await import("../../src/lib/usage/costCalculator.ts");
|
|
const { formatOmniRouteCost } = await import("../../src/domain/omnirouteResponseMeta.ts");
|
|
|
|
test.after(() => {
|
|
core.resetDbInstance();
|
|
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
|
});
|
|
|
|
// A reusable persistAttemptLogs spy + base args. The functions below should NEVER be
|
|
// invoked on the guard-false / cache-miss paths (those only run on a HIT).
|
|
function makeBaseArgs(overrides: Record<string, unknown> = {}) {
|
|
const persistCalls: unknown[] = [];
|
|
const args = {
|
|
semanticCacheEnabled: false,
|
|
body: { model: "gpt-4o", messages: [{ role: "user", content: "hi" }], temperature: 0 },
|
|
clientRawRequest: { headers: {} },
|
|
model: "gpt-4o",
|
|
provider: "openai",
|
|
stream: false,
|
|
reqLogger: {
|
|
logConvertedResponse: () => {
|
|
throw new Error("logConvertedResponse should not run on guard-false / miss paths");
|
|
},
|
|
},
|
|
effectiveServiceTier: undefined,
|
|
connectionId: null as string | null,
|
|
startTime: Date.now(),
|
|
log: {
|
|
debug: () => {
|
|
throw new Error("log.debug should only fire on a cache HIT");
|
|
},
|
|
},
|
|
persistAttemptLogs: (a: unknown) => {
|
|
persistCalls.push(a);
|
|
},
|
|
apiKeyId: null as string | null,
|
|
...overrides,
|
|
};
|
|
return { args, persistCalls };
|
|
}
|
|
|
|
// ─── checkSemanticCache ──────────────────────────────────────────────────────
|
|
|
|
test("checkSemanticCache returns null when semanticCacheEnabled is false (outer guard short-circuit)", async () => {
|
|
const { args, persistCalls } = makeBaseArgs({ semanticCacheEnabled: false });
|
|
|
|
const result = await checkSemanticCache(args as Parameters<typeof checkSemanticCache>[0]);
|
|
|
|
assert.equal(result, null, "disabled cache -> no-op null result");
|
|
assert.equal(persistCalls.length, 0, "no logging side effects when the guard is false");
|
|
});
|
|
|
|
test("checkSemanticCache returns null when enabled but the body is NOT cacheable (temperature != 0)", async () => {
|
|
// isCacheableForRead requires temperature === 0. A non-zero temperature makes the guard
|
|
// false even with semanticCacheEnabled=true, so it short-circuits to the null no-op.
|
|
const { args, persistCalls } = makeBaseArgs({
|
|
semanticCacheEnabled: true,
|
|
body: { model: "gpt-4o", messages: [{ role: "user", content: "hi" }], temperature: 0.7 },
|
|
});
|
|
|
|
const result = await checkSemanticCache(args as Parameters<typeof checkSemanticCache>[0]);
|
|
|
|
assert.equal(result, null, "non-cacheable body -> guard false -> null");
|
|
assert.equal(persistCalls.length, 0);
|
|
});
|
|
|
|
test("checkSemanticCache returns null when the x-omniroute-no-cache header forces a bypass", async () => {
|
|
// The no-cache header makes isCacheableForRead return false even with temperature:0.
|
|
const { args } = makeBaseArgs({
|
|
semanticCacheEnabled: true,
|
|
body: { model: "gpt-4o", messages: [{ role: "user", content: "hi" }], temperature: 0 },
|
|
clientRawRequest: { headers: { "x-omniroute-no-cache": "true" } },
|
|
});
|
|
|
|
const result = await checkSemanticCache(args as Parameters<typeof checkSemanticCache>[0]);
|
|
|
|
assert.equal(result, null, "explicit no-cache header bypasses the cache read");
|
|
});
|
|
|
|
test("checkSemanticCache returns null on a cache MISS (enabled + cacheable body + empty cache)", async () => {
|
|
// semanticCacheEnabled=true AND temperature:0 (cacheable) -> enters the guard -> builds a
|
|
// signature -> getCachedResponse() finds nothing in the empty (fresh DATA_DIR) cache ->
|
|
// the `if (cached)` block is skipped -> the function falls through to `return null`.
|
|
const { args, persistCalls } = makeBaseArgs({
|
|
semanticCacheEnabled: true,
|
|
body: {
|
|
model: "gpt-4o",
|
|
messages: [{ role: "user", content: "a unique miss query " + Date.now() }],
|
|
temperature: 0,
|
|
},
|
|
});
|
|
|
|
const result = await checkSemanticCache(args as Parameters<typeof checkSemanticCache>[0]);
|
|
|
|
assert.equal(result, null, "empty cache -> MISS -> null (no HIT-path side effects)");
|
|
assert.equal(persistCalls.length, 0, "persistAttemptLogs only runs on a HIT");
|
|
});
|
|
|
|
test("checkSemanticCache MISS also works for the Responses-API `input` body shape", async () => {
|
|
// generateSignature falls back to body.input when body.messages is absent; the empty cache
|
|
// still yields a MISS, so the result is null.
|
|
const { args, persistCalls } = makeBaseArgs({
|
|
semanticCacheEnabled: true,
|
|
body: {
|
|
model: "gpt-4o",
|
|
input: [{ role: "user", content: "responses api miss " + Date.now() }],
|
|
temperature: 0,
|
|
},
|
|
stream: true,
|
|
});
|
|
|
|
const result = await checkSemanticCache(args as Parameters<typeof checkSemanticCache>[0]);
|
|
|
|
assert.equal(result, null);
|
|
assert.equal(persistCalls.length, 0);
|
|
});
|
|
|
|
// ─── HIT path ────────────────────────────────────────────────────────────────
|
|
// The guard-false / miss tests above only exercise the `return null` branches. These
|
|
// seed the real cache so the `if (cached)` block actually runs end-to-end, killing
|
|
// mutants in the HIT body (status 200 / "semantic" / "HIT" / the stream + content-type
|
|
// ternaries / the cost fallback / the side-effect calls).
|
|
|
|
// Recording (non-throwing) spies — the HIT path DOES invoke log.debug + logConvertedResponse.
|
|
function makeHitArgs(overrides: Record<string, unknown> = {}) {
|
|
const persistCalls: Record<string, unknown>[] = [];
|
|
const convertedCalls: unknown[] = [];
|
|
const debugCalls: unknown[][] = [];
|
|
const args = {
|
|
semanticCacheEnabled: true,
|
|
body: { model: "gpt-4o", messages: [{ role: "user", content: "cached query" }], temperature: 0 },
|
|
clientRawRequest: { headers: {} },
|
|
model: "gpt-4o",
|
|
provider: "openai",
|
|
stream: false,
|
|
reqLogger: {
|
|
logConvertedResponse: (r: unknown) => {
|
|
convertedCalls.push(r);
|
|
},
|
|
},
|
|
effectiveServiceTier: undefined,
|
|
connectionId: null as string | null,
|
|
startTime: Date.now() - 5,
|
|
log: {
|
|
debug: (...a: unknown[]) => {
|
|
debugCalls.push(a);
|
|
},
|
|
},
|
|
persistAttemptLogs: (a: unknown) => {
|
|
persistCalls.push(a as Record<string, unknown>);
|
|
},
|
|
apiKeyId: null as string | null,
|
|
...overrides,
|
|
};
|
|
return { args, persistCalls, convertedCalls, debugCalls };
|
|
}
|
|
|
|
// Seed the cache under the EXACT signature checkSemanticCache rebuilds for `args`.
|
|
function seedHit(args: ReturnType<typeof makeHitArgs>["args"], response: unknown) {
|
|
const signature = generateSignature(
|
|
args.model,
|
|
args.body.messages ?? (args.body as Record<string, unknown>).input,
|
|
args.body.temperature,
|
|
(args.body as Record<string, unknown>).top_p,
|
|
args.apiKeyId ?? undefined
|
|
);
|
|
setCachedResponse(signature, args.model, response);
|
|
return signature;
|
|
}
|
|
|
|
test("checkSemanticCache returns a non-streaming JSON HIT with cache headers + logging side effects", async () => {
|
|
clearCache();
|
|
const cached = {
|
|
id: "chatcmpl-cached-1",
|
|
choices: [
|
|
{ index: 0, message: { role: "assistant", content: "cached answer" }, finish_reason: "stop" },
|
|
],
|
|
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
|
|
};
|
|
const { args, persistCalls, convertedCalls, debugCalls } = makeHitArgs({
|
|
body: { model: "gpt-4o", messages: [{ role: "user", content: "hit query one" }], temperature: 0 },
|
|
stream: false,
|
|
});
|
|
seedHit(args, cached);
|
|
|
|
const result = await checkSemanticCache(args as Parameters<typeof checkSemanticCache>[0]);
|
|
|
|
assert.ok(result, "HIT -> non-null result");
|
|
assert.equal(result.success, true, "HIT result.success is true");
|
|
const res = result.response as Response;
|
|
assert.equal(res.headers.get(OMNIROUTE_RESPONSE_HEADERS.cache), "HIT", "X-OmniRoute-Cache: HIT");
|
|
assert.equal(
|
|
res.headers.get(OMNIROUTE_RESPONSE_HEADERS.cacheHit),
|
|
"true",
|
|
"cacheHit meta header is true"
|
|
);
|
|
assert.equal(
|
|
res.headers.get("Content-Type"),
|
|
"application/json",
|
|
"non-streaming HIT -> application/json"
|
|
);
|
|
const bodyText = await res.text();
|
|
assert.equal(bodyText, JSON.stringify(cached), "non-streaming HIT body is the cached JSON");
|
|
|
|
assert.equal(persistCalls.length, 1, "persistAttemptLogs runs exactly once on a HIT");
|
|
const logged = persistCalls[0];
|
|
assert.equal(logged.status, 200, "persisted status is 200");
|
|
assert.equal(logged.cacheSource, "semantic", "persisted cacheSource is 'semantic'");
|
|
assert.deepEqual(logged.responseBody, cached, "persisted responseBody is the cached object");
|
|
assert.deepEqual(logged.clientResponse, cached, "persisted clientResponse is the cached object");
|
|
assert.deepEqual(logged.tokens, cached.usage, "persisted tokens come from cached.usage");
|
|
assert.equal(logged.providerRequest, null);
|
|
assert.equal(logged.providerResponse, null);
|
|
|
|
assert.equal(convertedCalls.length, 1, "logConvertedResponse called once with the cached body");
|
|
assert.deepEqual(convertedCalls[0], cached);
|
|
assert.equal(debugCalls.length, 1, "log.debug fires exactly once on a HIT");
|
|
});
|
|
|
|
test("checkSemanticCache returns a streaming SSE HIT (text/event-stream) when stream=true", async () => {
|
|
clearCache();
|
|
const cached = {
|
|
id: "chatcmpl-cached-stream",
|
|
choices: [
|
|
{
|
|
index: 0,
|
|
message: { role: "assistant", content: "streamed cached answer" },
|
|
finish_reason: "stop",
|
|
},
|
|
],
|
|
usage: { prompt_tokens: 3, completion_tokens: 4, total_tokens: 7 },
|
|
};
|
|
const { args, persistCalls } = makeHitArgs({
|
|
body: { model: "gpt-4o", messages: [{ role: "user", content: "hit query stream" }], temperature: 0 },
|
|
stream: true,
|
|
});
|
|
seedHit(args, cached);
|
|
|
|
const result = await checkSemanticCache(args as Parameters<typeof checkSemanticCache>[0]);
|
|
|
|
assert.ok(result, "streaming HIT -> non-null result");
|
|
assert.equal(result.success, true);
|
|
const res = result.response as Response;
|
|
assert.equal(
|
|
res.headers.get("Content-Type"),
|
|
"text/event-stream",
|
|
"streaming HIT -> text/event-stream"
|
|
);
|
|
assert.equal(res.headers.get(OMNIROUTE_RESPONSE_HEADERS.cache), "HIT");
|
|
const bodyText = await res.text();
|
|
assert.ok(bodyText.includes("data: "), "SSE body contains data frames");
|
|
assert.ok(bodyText.includes("streamed cached answer"), "SSE body carries the cached content");
|
|
assert.ok(bodyText.trimEnd().endsWith("data: [DONE]"), "SSE body terminates with [DONE]");
|
|
assert.equal(persistCalls.length, 1, "persistAttemptLogs runs once on a streaming HIT too");
|
|
});
|
|
|
|
test("checkSemanticCache HITs even when the cached body has no usage (cost falls back to 0)", async () => {
|
|
clearCache();
|
|
const cached = {
|
|
id: "chatcmpl-cached-no-usage",
|
|
choices: [
|
|
{ index: 0, message: { role: "assistant", content: "no-usage answer" }, finish_reason: "stop" },
|
|
],
|
|
};
|
|
const { args, persistCalls } = makeHitArgs({
|
|
body: {
|
|
model: "gpt-4o",
|
|
messages: [{ role: "user", content: "hit query no usage" }],
|
|
temperature: 0,
|
|
},
|
|
stream: false,
|
|
});
|
|
seedHit(args, cached);
|
|
|
|
const result = await checkSemanticCache(args as Parameters<typeof checkSemanticCache>[0]);
|
|
|
|
assert.ok(result, "HIT with no usage -> non-null result");
|
|
assert.equal(result.success, true);
|
|
const res = result.response as Response;
|
|
assert.equal(res.headers.get(OMNIROUTE_RESPONSE_HEADERS.cache), "HIT");
|
|
// cachedUsage resolves to undefined -> cachedCost = 0 -> the zero-cost sentinel header.
|
|
assert.equal(
|
|
res.headers.get(OMNIROUTE_RESPONSE_HEADERS.responseCost),
|
|
"0.0000000000",
|
|
"no usage -> zero responseCost header"
|
|
);
|
|
assert.equal(persistCalls.length, 1);
|
|
assert.equal(persistCalls[0].tokens, undefined, "no usage -> persisted tokens is undefined");
|
|
});
|
|
|
|
// ─── Cache-HIT cost reporting (PRD-2026-06-19-cache-hit-cost-reporting) ───────
|
|
// A HIT does NOT call upstream, so the INCREMENTAL cost of serving it is ≈0. The
|
|
// X-OmniRoute-Response-Cost must therefore be 0 (so billing consumers don't charge
|
|
// for cache hits), while the original cost is surfaced via X-OmniRoute-Cost-Saved.
|
|
|
|
test("checkSemanticCache HIT bills 0 incremental cost and reports the original cost in X-OmniRoute-Cost-Saved", async () => {
|
|
clearCache();
|
|
const usage = { prompt_tokens: 1000, completion_tokens: 1000, total_tokens: 2000 };
|
|
const cached = {
|
|
id: "chatcmpl-cached-cost-saved",
|
|
choices: [
|
|
{ index: 0, message: { role: "assistant", content: "cached answer" }, finish_reason: "stop" },
|
|
],
|
|
usage,
|
|
};
|
|
const { args } = makeHitArgs({
|
|
body: {
|
|
model: "gpt-4o",
|
|
messages: [{ role: "user", content: "hit query cost-saved" }],
|
|
temperature: 0,
|
|
},
|
|
stream: false,
|
|
});
|
|
seedHit(args, cached);
|
|
|
|
// The original (would-have-been) cost — computed with the SAME calculator the handler
|
|
// uses, against the same fresh DATA_DIR, so the values match deterministically.
|
|
const expectedSaved = formatOmniRouteCost(
|
|
await calculateCost(args.provider, args.model, usage as Record<string, number>)
|
|
);
|
|
assert.notEqual(
|
|
expectedSaved,
|
|
"0.0000000000",
|
|
"sanity: gpt-4o must be priced for this regression to be meaningful"
|
|
);
|
|
|
|
const result = await checkSemanticCache(args as Parameters<typeof checkSemanticCache>[0]);
|
|
assert.ok(result, "HIT -> non-null result");
|
|
const res = result.response as Response;
|
|
|
|
assert.equal(res.headers.get(OMNIROUTE_RESPONSE_HEADERS.cache), "HIT");
|
|
// Incremental cost billed to the client on a HIT is 0 (no upstream call happened).
|
|
assert.equal(
|
|
res.headers.get(OMNIROUTE_RESPONSE_HEADERS.responseCost),
|
|
"0.0000000000",
|
|
"cache HIT must bill 0 incremental cost"
|
|
);
|
|
// The avoided cost is surfaced for cache analytics.
|
|
assert.equal(
|
|
res.headers.get(OMNIROUTE_RESPONSE_HEADERS.costSaved),
|
|
expectedSaved,
|
|
"X-OmniRoute-Cost-Saved reflects the original cost the cache avoided"
|
|
);
|
|
});
|
|
|
|
test("checkSemanticCache isolates HITs per apiKeyId (no cross-key cache sharing) [#3740 edge]", async () => {
|
|
clearCache();
|
|
const cached = {
|
|
id: "chatcmpl-cached-isolation",
|
|
choices: [
|
|
{ index: 0, message: { role: "assistant", content: "key A answer" }, finish_reason: "stop" },
|
|
],
|
|
usage: { prompt_tokens: 5, completion_tokens: 5, total_tokens: 10 },
|
|
};
|
|
const sharedBody = () => ({
|
|
model: "gpt-4o",
|
|
messages: [{ role: "user", content: "isolation probe" }],
|
|
temperature: 0,
|
|
});
|
|
|
|
// Seed the cache under apiKeyId "keyA".
|
|
const { args: argsA } = makeHitArgs({ body: sharedBody(), apiKeyId: "keyA" });
|
|
seedHit(argsA, cached);
|
|
|
|
// A different key with an IDENTICAL body must NOT see keyA's entry (namespaced signature).
|
|
const { args: argsB } = makeHitArgs({ body: sharedBody(), apiKeyId: "keyB" });
|
|
const missB = await checkSemanticCache(argsB as Parameters<typeof checkSemanticCache>[0]);
|
|
assert.equal(missB, null, "keyB must NOT resolve keyA's cached entry (per-key isolation)");
|
|
|
|
// keyA itself still gets a HIT.
|
|
const { args: argsA2 } = makeHitArgs({ body: sharedBody(), apiKeyId: "keyA" });
|
|
const hitA = await checkSemanticCache(argsA2 as Parameters<typeof checkSemanticCache>[0]);
|
|
assert.ok(hitA, "keyA must resolve its own cached entry");
|
|
});
|