feat(cache): implement configurable dual-layer semantic caching layer (#1)

* feat(cache): implement dual-layer semantic caching with in-memory and redis vector stores

Implements production-grade, configurable semantic caching for OmniRoute.

- Dual-layer architecture: Layer 1 exact hash match (0 embedding latency) + Layer 2 vector cosine similarity search.
- Backends: in-memory vector store with L2 normalization, LRU and TTL + Redis vector store adapter with fail-open fallback.
- Embedding generation: conversation history normalization, system prompt exclusion, timeout protection.
- Streaming support: serializable SSE stream synthesis ending in data: [DONE]\n\n.
- Request overrides and telemetry headers: X-OmniRoute-Cache (HIT (exact) | HIT (semantic) | MISS), X-OmniRoute-Cache-Similarity, X-OmniRoute-Savings-Tokens, Cache-Control: no-cache, x-omniroute-no-cache, x-omniroute-cache-threshold, x-omniroute-cache-type, x-omniroute-cache-no-store, x-omniroute-cache-key.
- Unit test coverage across dual-layer search, eviction, redis resilience, and streaming replay.

* feat(cache): add default embedding client and live integration test for Lemonade server and Redis

- Add embeddingBaseUrl and embeddingApiKey configuration options to SemanticCacheConfig.
- Implement createDefaultEmbeddingGenerator for automatic OpenAI-compatible embedding integration.
- Add live verification script scripts/ad-hoc/test-semantic-cache-lemonade.ts.
- Add network-aware integration test tests/integration/semantic-cache-lemonade.test.ts for Lemonade harrier-oss-v1-0.6b and Redis vector store.

* fix(cache): address CodeRabbit review recommendations on PR #1

- Multi-tenant partition isolation: support null sentinel in StoreFilter so anonymous requests cannot match authenticated entries.
- Provider propagation: pass routed provider to non-streaming and streaming cache writes.
- Embedding resilience: race generator with timeout promise in generateEmbeddingWithTimeout to guard against uncooperative generators.
- Memory store consistency: replace older entries with identical hash on insert, and safe-guard hashToId deletion in removeEntry.
- Redis store consistency: prune expired/missing entries from candidate sets during search/stats, and delete hash mapping conditionally.
- Token telemetry: use managerResult.tokensSaved when cached response lacks usage data.
- Clova batch timeout: add AbortSignal.timeout(FETCH_TIMEOUT_MS) to fetchClovaEmbeddingBatch.

---------

Co-authored-by: John Smith <you@example.com>
This commit is contained in:
BillyOutlast
2026-09-03 12:50:57 -04:00
committed by GitHub
parent ad500de9e3
commit d8c69b87e3
24 changed files with 3439 additions and 94 deletions

1
.gitignore vendored
View File

@@ -218,6 +218,7 @@ scripts/i18n/_pending-keys.json
# PR Reviews and local feedback files
pr_reviews*.json
/review/
#hidden local data directories (never commit)
.local-data/

View File

@@ -633,11 +633,6 @@
"count": 1
}
},
"open-sse/services/tlsClientBase.ts": {
"@typescript-eslint/no-unused-vars": {
"count": 1
}
},
"open-sse/services/tokenLimitCounter.ts": {
"@typescript-eslint/no-unused-vars": {
"count": 1
@@ -1752,11 +1747,6 @@
"count": 1
}
},
"src/lib/semanticCache.ts": {
"no-restricted-syntax": {
"count": 1
}
},
"src/lib/services/ServiceSupervisor.ts": {
"@typescript-eslint/no-unused-vars": {
"count": 1
@@ -2750,7 +2740,7 @@
},
"tests/unit/build/check-licenses.test.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 5
"count": 4
}
},
"tests/unit/bypass-handler.test.ts": {
@@ -3345,7 +3335,7 @@
},
"tests/unit/combo-routing-engine.test.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 268
"count": 267
}
},
"tests/unit/combo-same-provider-cascade.test.ts": {
@@ -5364,32 +5354,9 @@
"count": 1
}
},
"tests/unit/ui/use-improve-prompt.test.tsx": {
"react-hooks/immutability": {
"count": 1
}
},
"tests/unit/ui/use-presets.test.tsx": {
"react-hooks/immutability": {
"count": 1
}
},
"tests/unit/ui/use-stream-metrics.test.tsx": {
"@typescript-eslint/no-unused-vars": {
"count": 1
},
"react-hooks/immutability": {
"count": 1
}
},
"tests/unit/ui/use-structured-output.test.tsx": {
"react-hooks/immutability": {
"count": 1
}
},
"tests/unit/ui/use-tools-builder.test.tsx": {
"react-hooks/immutability": {
"count": 1
}
},
"tests/unit/ui/use-traffic-stream.test.tsx": {

View File

@@ -239,6 +239,7 @@ const eslintConfig = [
"vscode-extension/**",
"_references/**",
"_mono_repo/**",
"review/**",
// Electron app
"electron/**",
// Docs

View File

@@ -372,6 +372,27 @@ export const EMBEDDING_PROVIDERS: Record<string, EmbeddingProvider> = {
models: [],
},
// llama.cpp — local OpenAI-compatible server (llama-server).
// API key optional. Models are passthrough (empty models array).
"llama-cpp": {
id: "llama-cpp",
baseUrl: "http://127.0.0.1:8080/v1/embeddings",
authType: "none",
authHeader: "bearer",
models: [],
},
// Lemonade Server — local OpenAI-compatible AI runtime backed by llama.cpp.
// API key optional (defaults to bearer auth if key configured).
// Includes harrier-oss-v1-0.6b (1024-dim GGUF) as a curated model.
lemonade: {
id: "lemonade",
baseUrl: "http://localhost:13305/v1/embeddings",
authType: "none",
authHeader: "bearer",
models: [{ id: "harrier-oss-v1-0.6b", name: "Harrier OSS v1 0.6B", dimensions: 1024 }],
},
// Ollama Local — OpenAI-compatible embeddings endpoint. Ollama exposes its
// own model catalog, but these common embedding models are useful defaults
// for model selection and validation.
@@ -428,7 +449,6 @@ export const EMBEDDING_PROVIDERS: Record<string, EmbeddingProvider> = {
},
],
},
};
const EMBEDDING_PROVIDER_ALIASES: Record<string, string> = {
@@ -439,6 +459,8 @@ const EMBEDDING_PROVIDER_ALIASES: Record<string, string> = {
// (#11233). Alias the dashboard id so "lm-studio/<model>" resolves instead
// of failing with an unknown-provider 400.
"lm-studio": "lmstudio",
llamacpp: "llama-cpp",
"llama.cpp": "llama-cpp",
};
/** Family name used by clients; Jina's public SKU is omni-small. */
@@ -563,9 +585,7 @@ export function deriveEmbeddingProviderForChatProvider(
chatEntry: { id?: string; baseUrl?: string | string[] } | null | undefined
): EmbeddingProvider | null {
if (!chatEntry) return null;
const rawBase = Array.isArray(chatEntry.baseUrl)
? chatEntry.baseUrl[0]
: chatEntry.baseUrl;
const rawBase = Array.isArray(chatEntry.baseUrl) ? chatEntry.baseUrl[0] : chatEntry.baseUrl;
if (!rawBase || typeof rawBase !== "string") return null;
// stripTrailingSlashes-equivalent without importing open-sse utils here:
const base = rawBase.replace(/\/+$/, "");

View File

@@ -0,0 +1,172 @@
/**
* Semantic Cache Configuration
*
* Configurable options for OmniRoute's dual-layer semantic caching system.
* Supports environment variable overrides via OMNIROUTE_SEMANTIC_CACHE_*.
*
* @module config/semanticCacheConfig
*/
export type SemanticCacheBackend = "memory" | "redis";
export type SemanticCacheType = "direct" | "semantic" | "both";
export interface SemanticCacheConfig {
/** Master toggle for semantic caching. */
enabled: boolean;
/** Storage and vector backend. Defaults to "memory". */
backend: SemanticCacheBackend;
/** Cosine similarity threshold (0.0 - 1.0) for semantic hits. Defaults to 0.8. */
similarityThreshold: number;
/** Time-to-live for cached entries in milliseconds. Defaults to 30 minutes (1800000 ms). */
ttlMs: number;
/** Maximum number of entries stored in memory. Defaults to 1000. */
maxEntries: number;
/** Embedding provider to use for semantic embeddings. Defaults to "openai". */
embeddingProvider: string;
/** Embedding model name. Defaults to "text-embedding-3-small". */
embeddingModel: string;
/** Vector dimension of the embedding model (e.g. 1536). Optional/auto-detected. */
embeddingDimension?: number;
/** Maximum milliseconds to wait for embedding generation before failing open. Defaults to 3000 ms. */
embeddingTimeoutMs: number;
/** If true, cache entries are isolated per model name. Defaults to true. */
cacheByModel: boolean;
/** If true, cache entries are isolated per provider name. Defaults to true. */
cacheByProvider: boolean;
/** Number of recent conversation turns embedded and checked. Defaults to 3. */
conversationHistoryDepth: number;
/** Maximum message turns in conversation allowed before bypassing caching. Defaults to 50. */
conversationHistoryThreshold: number;
/** If true, system prompt messages are excluded from embedding and cache keys. Defaults to false. */
excludeSystemPrompt: boolean;
/** Optional custom base URL for embedding provider (e.g. http://192.168.31.147:13305/v1/embeddings). */
embeddingBaseUrl?: string;
/** Optional custom API key / Bearer token for embedding provider. */
embeddingApiKey?: string;
/** Redis connection URL when backend is "redis". */
redisUrl?: string;
/** Key prefix for Redis cache keys. Defaults to "omniroute:semcache:". */
redisPrefix: string;
/** If true, only temperature=0 requests are cacheable. Defaults to true for strict determinism. */
requireZeroTemperature: boolean;
}
export const DEFAULT_SEMANTIC_CACHE_CONFIG: SemanticCacheConfig = {
enabled: true,
backend: "memory",
similarityThreshold: 0.8,
ttlMs: 1800000, // 30 minutes
maxEntries: 1000,
embeddingProvider: "openai",
embeddingModel: "text-embedding-3-small",
embeddingDimension: 1536,
embeddingTimeoutMs: 3000,
cacheByModel: true,
cacheByProvider: true,
conversationHistoryDepth: 3,
conversationHistoryThreshold: 50,
excludeSystemPrompt: false,
redisUrl: undefined,
redisPrefix: "omniroute:semcache:",
requireZeroTemperature: true,
};
function parseBoolean(val: string | undefined, fallback: boolean): boolean {
if (val === undefined || val === "") return fallback;
const lower = val.toLowerCase().trim();
if (lower === "true" || lower === "1" || lower === "yes") return true;
if (lower === "false" || lower === "0" || lower === "no") return false;
return fallback;
}
function parseNumber(val: string | undefined, fallback: number): number {
if (val === undefined || val === "") return fallback;
const parsed = Number(val);
return Number.isFinite(parsed) ? parsed : fallback;
}
/**
* Resolves semantic cache configuration from environment variables, merged
* with optional explicit overrides.
*/
export function resolveSemanticCacheConfig(
overrides?: Partial<SemanticCacheConfig>
): SemanticCacheConfig {
const env = process.env;
const backendEnv = (env.OMNIROUTE_SEMANTIC_CACHE_BACKEND || "").toLowerCase().trim();
const backend: SemanticCacheBackend = backendEnv === "redis" ? "redis" : "memory";
const resolved: SemanticCacheConfig = {
enabled: parseBoolean(
env.OMNIROUTE_SEMANTIC_CACHE_ENABLED,
DEFAULT_SEMANTIC_CACHE_CONFIG.enabled
),
backend,
similarityThreshold: parseNumber(
env.OMNIROUTE_SEMANTIC_CACHE_THRESHOLD,
DEFAULT_SEMANTIC_CACHE_CONFIG.similarityThreshold
),
ttlMs: parseNumber(env.OMNIROUTE_SEMANTIC_CACHE_TTL_MS, DEFAULT_SEMANTIC_CACHE_CONFIG.ttlMs),
maxEntries: parseNumber(
env.OMNIROUTE_SEMANTIC_CACHE_MAX_ENTRIES,
DEFAULT_SEMANTIC_CACHE_CONFIG.maxEntries
),
embeddingProvider:
env.OMNIROUTE_SEMANTIC_CACHE_EMBEDDING_PROVIDER?.trim() ||
DEFAULT_SEMANTIC_CACHE_CONFIG.embeddingProvider,
embeddingModel:
env.OMNIROUTE_SEMANTIC_CACHE_EMBEDDING_MODEL?.trim() ||
DEFAULT_SEMANTIC_CACHE_CONFIG.embeddingModel,
embeddingDimension: env.OMNIROUTE_SEMANTIC_CACHE_EMBEDDING_DIMENSION
? parseNumber(env.OMNIROUTE_SEMANTIC_CACHE_EMBEDDING_DIMENSION, 1536)
: DEFAULT_SEMANTIC_CACHE_CONFIG.embeddingDimension,
embeddingTimeoutMs: parseNumber(
env.OMNIROUTE_SEMANTIC_CACHE_EMBEDDING_TIMEOUT_MS,
DEFAULT_SEMANTIC_CACHE_CONFIG.embeddingTimeoutMs
),
cacheByModel: parseBoolean(
env.OMNIROUTE_SEMANTIC_CACHE_BY_MODEL,
DEFAULT_SEMANTIC_CACHE_CONFIG.cacheByModel
),
cacheByProvider: parseBoolean(
env.OMNIROUTE_SEMANTIC_CACHE_BY_PROVIDER,
DEFAULT_SEMANTIC_CACHE_CONFIG.cacheByProvider
),
conversationHistoryDepth: parseNumber(
env.OMNIROUTE_SEMANTIC_CACHE_HISTORY_DEPTH,
DEFAULT_SEMANTIC_CACHE_CONFIG.conversationHistoryDepth
),
conversationHistoryThreshold: parseNumber(
env.OMNIROUTE_SEMANTIC_CACHE_HISTORY_THRESHOLD,
DEFAULT_SEMANTIC_CACHE_CONFIG.conversationHistoryThreshold
),
excludeSystemPrompt: parseBoolean(
env.OMNIROUTE_SEMANTIC_CACHE_EXCLUDE_SYSTEM,
DEFAULT_SEMANTIC_CACHE_CONFIG.excludeSystemPrompt
),
embeddingBaseUrl:
env.OMNIROUTE_SEMANTIC_CACHE_EMBEDDING_BASE_URL?.trim() ||
overrides?.embeddingBaseUrl ||
undefined,
embeddingApiKey:
env.OMNIROUTE_SEMANTIC_CACHE_EMBEDDING_API_KEY?.trim() ||
overrides?.embeddingApiKey ||
undefined,
redisUrl: env.OMNIROUTE_SEMANTIC_CACHE_REDIS_URL || env.REDIS_URL || undefined,
redisPrefix:
env.OMNIROUTE_SEMANTIC_CACHE_REDIS_PREFIX?.trim() ||
DEFAULT_SEMANTIC_CACHE_CONFIG.redisPrefix,
requireZeroTemperature: parseBoolean(
env.OMNIROUTE_SEMANTIC_CACHE_REQUIRE_ZERO_TEMP,
DEFAULT_SEMANTIC_CACHE_CONFIG.requireZeroTemperature
),
...overrides,
};
// Clamp similarity threshold to [0.0, 1.0]
if (resolved.similarityThreshold < 0) resolved.similarityThreshold = 0;
if (resolved.similarityThreshold > 1) resolved.similarityThreshold = 1;
return resolved;
}

View File

@@ -5360,6 +5360,7 @@ export async function handleChatCore({
headers: clientRawRequest?.headers,
translatedResponse,
model,
provider,
apiKeyId: apiKeyInfo?.id ?? undefined,
usage,
log,
@@ -5821,6 +5822,7 @@ export async function handleChatCore({
body: bodyForCacheWrite,
headers: clientRawRequest?.headers,
model,
provider,
apiKeyId: apiKeyInfo?.id ?? undefined,
streamUsage,
log,

View File

@@ -1,14 +1,11 @@
import {
generateSignature,
getCachedResponse,
isCacheableForRead,
} from "@/lib/semanticCache";
import { generateSignature, getCachedResponse, isCacheableForRead } from "@/lib/semanticCache";
import { calculateCost } from "@/lib/usage/costCalculator";
import { trackPendingRequest } from "@/lib/usageDb";
import { synthesizeOpenAiSseFromJson } from "../../utils/jsonToSse.ts";
import { attachOmniRouteMetaHeaders } from "@/domain/omnirouteResponseMeta";
import { extractUsageFromResponse } from "../usageExtractor.ts";
import { OMNIROUTE_RESPONSE_HEADERS } from "@/shared/constants/headers";
import { getSemanticCacheManager } from "../../services/cache/semanticCacheManager.ts";
export async function checkSemanticCache({
semanticCacheEnabled,
@@ -46,20 +43,47 @@ export async function checkSemanticCache({
// Per-key bypass: skip cache lookup entirely when the API key opts out.
if (cacheDefaultMode === "bypass") return null;
if (semanticCacheEnabled && isCacheableForRead(body, clientRawRequest?.headers)) {
const signature = generateSignature(
const manager = getSemanticCacheManager();
const managerResult = await manager.lookup({
body,
headers: clientRawRequest?.headers,
model,
body.messages ?? body.input,
body.temperature,
body.top_p,
apiKeyId ?? undefined
);
const cached = getCachedResponse(signature);
provider,
stream,
apiKeyId: apiKeyId ?? undefined,
cacheDefaultMode,
});
let cached: Record<string, unknown> | null = null;
let hitType: "exact" | "semantic" = "exact";
let similarity: number | undefined;
if (managerResult.hit && managerResult.entry) {
cached = managerResult.entry.response;
hitType = managerResult.type || "exact";
similarity = managerResult.similarity;
} else {
// Legacy SQLite / in-memory cache check fallback
const signature = generateSignature(
model,
body.messages ?? body.input,
body.temperature,
body.top_p,
apiKeyId ?? undefined
);
const legacyCached = getCachedResponse(signature);
if (legacyCached) {
cached = legacyCached as Record<string, unknown>;
hitType = "exact";
}
}
if (cached) {
log?.debug?.("CACHE", `Semantic cache HIT for ${model} (stream=${stream})`);
reqLogger.logConvertedResponse(cached as Record<string, unknown>);
log?.debug?.("CACHE", `Semantic cache HIT (${hitType}) for ${model} (stream=${stream})`);
reqLogger.logConvertedResponse(cached);
const cachedUsage =
extractUsageFromResponse(cached as Record<string, unknown>, provider) ||
((cached as Record<string, unknown>)?.usage as Record<string, unknown> | undefined);
extractUsageFromResponse(cached, provider) ||
(cached?.usage as Record<string, unknown> | undefined);
const cachedCost = cachedUsage
? await calculateCost(provider, model, cachedUsage as Record<string, number>, {
serviceTier: effectiveServiceTier,
@@ -67,22 +91,39 @@ export async function checkSemanticCache({
: 0;
persistAttemptLogs({
status: 200,
tokens: (cached as Record<string, unknown>)?.usage,
tokens: cached?.usage,
responseBody: cached,
providerRequest: null,
providerResponse: null,
clientResponse: cached,
cacheSource: "semantic",
cacheSource: hitType === "semantic" ? "semantic_similarity" : "semantic",
});
trackPendingRequest(model, provider, connectionId, false);
const cachedSse = stream ? synthesizeOpenAiSseFromJson(JSON.stringify(cached)) : "";
const cachedSse = stream
? managerResult.entry
? manager.synthesizeSseFromEntry(managerResult.entry)
: synthesizeOpenAiSseFromJson(JSON.stringify(cached))
: "";
const tokensSaved = managerResult.entry
? (managerResult.tokensSaved ?? 0)
: cachedUsage
? (Number(cachedUsage.prompt_tokens) || 0) + (Number(cachedUsage.completion_tokens) || 0)
: 0;
const headers: Record<string, string> = {
"Content-Type": cachedSse ? "text/event-stream" : "application/json",
[OMNIROUTE_RESPONSE_HEADERS.cache]: "HIT",
// Marker for latency measurement tools: this response served from cache
// has synthetic (near-zero) latency, not real upstream latency.
[OMNIROUTE_RESPONSE_HEADERS.cache]:
hitType === "semantic" ? "HIT (semantic)" : "HIT (exact)",
[OMNIROUTE_RESPONSE_HEADERS.cacheLatency]: "synthetic",
[OMNIROUTE_RESPONSE_HEADERS.savingsTokens]: String(tokensSaved),
};
if (hitType === "semantic" && typeof similarity === "number") {
headers[OMNIROUTE_RESPONSE_HEADERS.cacheSimilarity] = similarity.toFixed(4);
}
// A cache HIT serves WITHOUT an upstream call, so the incremental cost billed to
// the client is 0 (consumers that sum X-OmniRoute-Response-Cost must not charge for
// hits). The original/would-have-been cost is surfaced via X-OmniRoute-Cost-Saved.

View File

@@ -14,6 +14,7 @@ import {
isCacheableForWrite as defaultIsCacheableForWrite,
} from "@/lib/semanticCache";
import { isSmallEnoughForSemanticCache as defaultIsSmallEnough } from "../../utils/estimateSize.ts";
import { getSemanticCacheManager } from "../../services/cache/semanticCacheManager.ts";
type LoggerLike = { debug?: (...args: unknown[]) => void } | null | undefined;
@@ -47,6 +48,7 @@ export function storeSemanticCacheResponse(
headers: unknown;
translatedResponse: unknown;
model: string;
provider?: string;
apiKeyId?: string;
usage?: UsageLike;
log?: LoggerLike;
@@ -70,4 +72,21 @@ export function storeSemanticCacheResponse(
const tokensSaved = args.usage?.prompt_tokens + args.usage?.completion_tokens || 0;
deps.setCachedResponse(signature, args.model, args.translatedResponse, tokensSaved);
args.log?.debug?.("CACHE", `Stored response for ${args.model} (${tokensSaved} tokens)`);
if (args.translatedResponse && typeof args.translatedResponse === "object") {
getSemanticCacheManager()
.store({
body: args.body as Record<string, unknown>,
headers: args.headers,
response: args.translatedResponse as Record<string, unknown>,
model: args.model,
provider:
args.provider ||
((args.translatedResponse as Record<string, unknown>).provider as string) ||
"",
apiKeyId: args.apiKeyId,
tokensSaved,
})
.catch(() => {});
}
}

View File

@@ -15,6 +15,7 @@ import {
isCacheableForWrite as defaultIsCacheableForWrite,
} from "@/lib/semanticCache";
import { isSmallEnoughForSemanticCache as defaultIsSmallEnough } from "../../utils/estimateSize.ts";
import { getSemanticCacheManager } from "../../services/cache/semanticCacheManager.ts";
type LoggerLike = { debug?: (...args: unknown[]) => void } | null | undefined;
@@ -46,6 +47,7 @@ interface StreamingCacheArgs {
body: CacheBody;
headers: unknown;
model: string;
provider?: string;
apiKeyId?: string;
streamUsage?: Record<string, unknown> | null;
log?: LoggerLike;
@@ -77,6 +79,18 @@ function writeStreamingCacheEntry(
"CACHE",
`Stored streaming response for ${args.model} (${tokensSaved} tokens)`
);
getSemanticCacheManager()
.store({
body: args.body as Record<string, unknown>,
headers: args.headers,
response: cleanBody,
model: args.model,
provider: args.provider || (cleanBody.provider as string) || "",
apiKeyId: args.apiKeyId,
tokensSaved,
})
.catch(() => {});
} catch {
// Cache write failed — non-critical
}

View File

@@ -21,6 +21,7 @@ import { toJsonErrorPayload } from "@/shared/utils/upstreamError";
import { stripStaleEncodingHeaders } from "../utils/upstreamResponseHeaders.ts";
import { sanitizeErrorMessage } from "../utils/error.ts";
import { stripTrailingSlashes } from "../utils/urlSanitize.ts";
import { FETCH_TIMEOUT_MS } from "../config/constants.ts";
import { fetchRemoteImage } from "@/shared/network/remoteImageFetch";
import {
hasStructuredEmbeddingInput,
@@ -283,15 +284,30 @@ function resolveLocalEmbeddingUrl(runtime: EmbeddingRuntime): string {
typeof configuredBaseUrl === "string" && configuredBaseUrl.trim()
? configuredBaseUrl
: runtime.providerConfig.baseUrl;
const localServerHost = stripTrailingSlashes(rawBaseUrl.trim())
.replace(/\/v1\/(?:chat\/completions|embeddings)$/i, "")
const trimmed = stripTrailingSlashes(rawBaseUrl.trim());
if (trimmed.toLowerCase().endsWith("/embeddings")) {
return trimmed;
}
const localServerHost = trimmed
.replace(/\/v1\/chat\/completions$/i, "")
.replace(/\/chat\/completions$/i, "")
.replace(/\/api\/chat$/i, "")
.replace(/\/v1$/i, "");
return `${localServerHost}/v1/embeddings`;
}
function isLocalEmbeddingProvider(provider: string): boolean {
return (
provider === "ollama-local" ||
provider === "lmstudio" ||
provider === "llama-cpp" ||
provider === "llamacpp" ||
provider === "lemonade"
);
}
function resolveUpstreamUrl(runtime: EmbeddingRuntime): string {
return runtime.provider === "ollama-local" || runtime.provider === "lmstudio"
return isLocalEmbeddingProvider(runtime.provider)
? resolveLocalEmbeddingUrl(runtime)
: runtime.providerConfig.baseUrl;
}
@@ -300,10 +316,7 @@ function buildAuth(
runtime: EmbeddingRuntime
): { headers: Record<string, string>; token: string | null } | EmbeddingFailure {
const headers: Record<string, string> = { "Content-Type": "application/json" };
const token =
runtime.providerConfig.authType === "none"
? null
: runtime.credentials?.apiKey || runtime.credentials?.accessToken || null;
const token = runtime.credentials?.apiKey || runtime.credentials?.accessToken || null;
if (!token && runtime.providerConfig.authType !== "none") {
return failure(
401,
@@ -473,6 +486,7 @@ async function fetchClovaEmbeddingBatch(
method: "POST",
headers: prepared.headers,
body: JSON.stringify(requestBody),
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
lastHeaders = response.headers;
if (!response.ok) return response;
@@ -496,6 +510,7 @@ async function dispatchEmbeddingRequest(
method: "POST",
headers: prepared.headers,
body: JSON.stringify(prepared.upstreamBody),
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
}
@@ -568,7 +583,10 @@ function normalizeEmbeddingData(
normalizedResponse: {
object: "list",
data: data.data || data,
model: `${runtime.provider}/${runtime.model}`,
model:
typeof runtime.body.model === "string" && !runtime.body.model.includes("/")
? runtime.body.model
: `${runtime.provider}/${runtime.model}`,
usage: data.usage || { prompt_tokens: 0, total_tokens: 0 },
},
};
@@ -655,12 +673,18 @@ function handleEmbeddingException(
error: unknown
): EmbeddingFailure {
const message = error instanceof Error ? error.message : String(error);
const isTimeout =
error instanceof Error &&
(error.name === "TimeoutError" ||
error.name === "AbortError" ||
message.toLowerCase().includes("timeout"));
const status = isTimeout ? 504 : 502;
runtime.log?.error("EMBED", `${runtime.provider} fetch error: ${message}`);
runtime.reqLogger.logError(error, prepared.upstreamBody);
saveCallLog({
method: "POST",
path: "/v1/embeddings",
status: 502,
status,
model: `${runtime.provider}/${runtime.model}`,
provider: runtime.provider,
duration: Date.now() - runtime.startTime,
@@ -671,7 +695,7 @@ function handleEmbeddingException(
apiKeyName: runtime.apiKeyName,
connectionId: runtime.connectionId,
}).catch(() => {});
return failure(502, `Embedding provider error: ${sanitizeErrorMessage(message)}`);
return failure(status, `Embedding provider error: ${sanitizeErrorMessage(message)}`);
}
async function executeEmbedding(

View File

@@ -0,0 +1,222 @@
/**
* Semantic Cache Embedding Client
*
* Normalizes conversation history into embeddable text representation and
* orchestrates embedding vector generation with timeout and fail-open resilience.
*
* @module services/cache/embeddingClient
*/
export interface EmbeddingResult {
embedding: number[];
inputTokens: number;
}
export type EmbeddingGenerator = (
text: string,
options?: {
model?: string;
provider?: string;
signal?: AbortSignal;
}
) => Promise<EmbeddingResult | null>;
function extractTextFromContent(content: unknown): string {
if (typeof content === "string") return content.trim();
if (Array.isArray(content)) {
const parts: string[] = [];
for (const part of content) {
if (typeof part === "string") {
parts.push(part);
} else if (part && typeof part === "object") {
const item = part as Record<string, unknown>;
if (typeof item.text === "string") {
parts.push(item.text);
} else if (item.type === "text" && typeof item.content === "string") {
parts.push(item.content);
}
}
}
return parts.join(" ").trim();
}
if (content && typeof content === "object") {
const record = content as Record<string, unknown>;
if (typeof record.text === "string") return record.text.trim();
}
return "";
}
/**
* Normalizes conversation history into a single clean text string for embedding.
*
* @param conversation - messages[] or input[] from chat completion or responses API
* @param options - depth and system prompt filtering
*/
export function normalizeConversationForEmbedding(
conversation: unknown,
options?: {
excludeSystemPrompt?: boolean;
historyDepth?: number;
}
): string {
if (typeof conversation === "string") {
return conversation.trim();
}
if (!Array.isArray(conversation) || conversation.length === 0) {
return "";
}
const excludeSystem = options?.excludeSystemPrompt ?? false;
const depth = options?.historyDepth ?? 3;
// Filter messages
const filtered = conversation.filter((item) => {
if (!item || typeof item !== "object") return false;
const role = (item as Record<string, unknown>).role;
if (excludeSystem && (role === "system" || role === "developer")) {
return false;
}
return true;
});
// Take the tail of conversation up to historyDepth
const tail = depth > 0 ? filtered.slice(-depth) : filtered;
const lines: string[] = [];
for (const item of tail) {
const record = item as Record<string, unknown>;
const role = typeof record.role === "string" ? record.role : "user";
const text = extractTextFromContent(record.content);
if (text) {
lines.push(`${role}: ${text}`);
}
}
return lines.join("\n").trim();
}
/**
* Executes embedding generation with fail-open timeout guard.
*/
export async function generateEmbeddingWithTimeout(
text: string,
generator: EmbeddingGenerator,
options?: {
model?: string;
provider?: string;
timeoutMs?: number;
}
): Promise<EmbeddingResult | null> {
if (!text || text.length === 0) return null;
const timeoutMs = options?.timeoutMs ?? 3000;
const controller = new AbortController();
let timer: NodeJS.Timeout | undefined;
const timeoutPromise = new Promise<null>((resolve) => {
timer = setTimeout(() => {
controller.abort();
console.warn(`[CACHE] Embedding generation timed out after ${timeoutMs}ms`);
resolve(null);
}, timeoutMs);
});
try {
const generatorPromise = generator(text, {
model: options?.model,
provider: options?.provider,
signal: controller.signal,
});
const res = await Promise.race([generatorPromise, timeoutPromise]);
return res;
} catch (err) {
const isTimeout = controller.signal.aborted;
console.warn(
`[CACHE] Embedding generation ${isTimeout ? "timed out" : "failed"}:`,
(err as Error).message
);
return null;
} finally {
if (timer) clearTimeout(timer);
}
}
/**
* Creates a standard HTTP embedding generator against any OpenAI-compatible
* embeddings endpoint (e.g. Lemonade server, OpenAI, Ollama).
*/
export function createDefaultEmbeddingGenerator(config: {
embeddingProvider?: string;
embeddingModel?: string;
embeddingBaseUrl?: string;
embeddingApiKey?: string;
}): EmbeddingGenerator {
return async (
text: string,
options?: { model?: string; provider?: string; signal?: AbortSignal }
) => {
const model = options?.model || config.embeddingModel || "text-embedding-3-small";
const provider = options?.provider || config.embeddingProvider || "openai";
let targetUrl = config.embeddingBaseUrl;
const apiKey = config.embeddingApiKey;
if (!targetUrl) {
if (provider === "lemonade") {
targetUrl = "http://localhost:13305/v1/embeddings";
} else if (provider === "ollama-local") {
targetUrl = "http://localhost:11434/v1/embeddings";
}
}
if (!targetUrl) {
return null;
}
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (apiKey) {
headers["Authorization"] = `Bearer ${apiKey}`;
}
try {
const res = await fetch(targetUrl, {
method: "POST",
headers,
body: JSON.stringify({
model,
input: text,
}),
signal: options?.signal,
});
if (!res.ok) {
const errText = await res.text().catch(() => "");
console.warn(`[CACHE] Default embedding request failed HTTP ${res.status}: ${errText}`);
return null;
}
const json = (await res.json()) as {
data?: Array<{ embedding?: number[] }>;
usage?: { prompt_tokens?: number; total_tokens?: number };
};
const vec = json?.data?.[0]?.embedding;
if (Array.isArray(vec) && vec.length > 0) {
return {
embedding: vec,
inputTokens: json?.usage?.prompt_tokens ?? 0,
};
}
return null;
} catch (err) {
if ((err as Error).name !== "AbortError") {
console.warn("[CACHE] Default embedding fetch error:", (err as Error).message);
}
return null;
}
};
}

View File

@@ -0,0 +1,201 @@
/**
* In-Memory Vector Store
*
* High-performance, zero-dependency in-memory vector store with cosine similarity,
* L2 normalization, O(1) direct-hash index, and LRU/TTL eviction.
*
* @module services/cache/memoryVectorStore
*/
import {
type CacheEntry,
type IVectorStore,
type SimilaritySearchResult,
type StoreFilter,
dotProduct,
l2Normalize,
} from "./vectorStore.ts";
interface InternalMemoryEntry {
entry: CacheEntry;
normalizedEmbedding?: number[];
}
export class MemoryVectorStore implements IVectorStore {
private readonly maxEntries: number;
private readonly entries = new Map<string, InternalMemoryEntry>();
private readonly hashToId = new Map<string, string>();
constructor(options?: { maxEntries?: number }) {
this.maxEntries = options?.maxEntries ?? 1000;
}
private isExpired(expiresAt: number): boolean {
return expiresAt > 0 && expiresAt <= Date.now();
}
private removeEntry(id: string): boolean {
const existing = this.entries.get(id);
if (!existing) return false;
// Only delete hashToId mapping if it still points to this id
if (this.hashToId.get(existing.entry.hash) === id) {
this.hashToId.delete(existing.entry.hash);
}
this.entries.delete(id);
return true;
}
private evictOldestIfNeeded(): void {
while (this.entries.size >= this.maxEntries) {
// Map keys iterator yields oldest inserted key first
const oldestKey = this.entries.keys().next().value;
if (!oldestKey) break;
this.removeEntry(oldestKey);
}
}
public async get(id: string): Promise<CacheEntry | null> {
const item = this.entries.get(id);
if (!item) return null;
if (this.isExpired(item.entry.expiresAt)) {
this.removeEntry(id);
return null;
}
// Refresh LRU order on hit: delete and re-insert
this.entries.delete(id);
this.entries.set(id, item);
return item.entry;
}
public async getByHash(hash: string): Promise<CacheEntry | null> {
const id = this.hashToId.get(hash);
if (!id) return null;
return this.get(id);
}
public async set(entry: CacheEntry, ttlMs: number): Promise<void> {
// If ID already exists, remove it first
if (this.entries.has(entry.id)) {
this.removeEntry(entry.id);
}
// If an existing entry shares the same direct hash, remove the older entry
const existingIdWithHash = this.hashToId.get(entry.hash);
if (existingIdWithHash && existingIdWithHash !== entry.id) {
this.removeEntry(existingIdWithHash);
}
this.evictOldestIfNeeded();
const expiresAt = ttlMs > 0 ? Date.now() + ttlMs : entry.expiresAt;
const finalEntry: CacheEntry = {
...entry,
expiresAt,
};
let normalizedEmbedding: number[] | undefined;
if (Array.isArray(entry.embedding) && entry.embedding.length > 0) {
normalizedEmbedding = l2Normalize(entry.embedding);
}
const internal: InternalMemoryEntry = {
entry: finalEntry,
normalizedEmbedding,
};
this.entries.set(finalEntry.id, internal);
this.hashToId.set(finalEntry.hash, finalEntry.id);
}
public async searchNearest(
embedding: number[],
filter: StoreFilter,
threshold: number,
limit = 1
): Promise<SimilaritySearchResult[]> {
if (!embedding || embedding.length === 0 || this.entries.size === 0) {
return [];
}
const queryNorm = l2Normalize(embedding);
const now = Date.now();
const expiredIds: string[] = [];
const candidates: SimilaritySearchResult[] = [];
for (const [id, item] of this.entries.entries()) {
if (item.entry.expiresAt > 0 && item.entry.expiresAt <= now) {
expiredIds.push(id);
continue;
}
// Metadata filter checks
if (filter.model && item.entry.model !== filter.model) continue;
if (filter.provider && item.entry.provider !== filter.provider) continue;
// Partition key isolation: null means must have NO key, string means exact match
if (filter.apiKeyId !== undefined) {
const expected = filter.apiKeyId === null ? undefined : filter.apiKeyId;
if (item.entry.apiKeyId !== expected) continue;
}
if (filter.cacheKey !== undefined) {
const expected = filter.cacheKey === null ? undefined : filter.cacheKey;
if (item.entry.cacheKey !== expected) continue;
}
if (!item.normalizedEmbedding || item.normalizedEmbedding.length !== queryNorm.length) {
continue;
}
// Since both query and candidate are L2-normalized, cosine similarity is the dot product
const sim = dotProduct(queryNorm, item.normalizedEmbedding);
if (sim >= threshold) {
candidates.push({
entry: item.entry,
similarity: sim,
});
}
}
// Clean expired entries found during traversal
for (const id of expiredIds) {
this.removeEntry(id);
}
// Sort descending by similarity
candidates.sort((a, b) => b.similarity - a.similarity);
return candidates.slice(0, limit);
}
public async delete(id: string): Promise<boolean> {
return this.removeEntry(id);
}
public async deleteByModel(model: string): Promise<number> {
let count = 0;
const toRemove: string[] = [];
for (const [id, item] of this.entries.entries()) {
if (item.entry.model === model) {
toRemove.push(id);
}
}
for (const id of toRemove) {
if (this.removeEntry(id)) count++;
}
return count;
}
public async clear(): Promise<number> {
const count = this.entries.size;
this.entries.clear();
this.hashToId.clear();
return count;
}
public async getStats(): Promise<{ entries: number }> {
return { entries: this.entries.size };
}
}

View File

@@ -0,0 +1,340 @@
/**
* Redis Vector Store Adapter
*
* Production-ready Redis-backed vector store for semantic caching.
* Uses ioredis as a soft dependency.
* Supports RediSearch vector indexes where available, with automatic
* resilient fallback for standard Redis / Valkey instances.
*
* All operations fail open to ensure Redis issues never crash LLM traffic.
*
* @module services/cache/redisVectorStore
*/
import {
type CacheEntry,
type IVectorStore,
type SimilaritySearchResult,
type StoreFilter,
dotProduct,
l2Normalize,
} from "./vectorStore.ts";
export interface RedisLike {
get(key: string): Promise<string | null>;
set(key: string, value: string, ...args: unknown[]): Promise<unknown>;
del(...keys: string[]): Promise<number>;
sadd(key: string, ...members: string[]): Promise<number>;
srem(key: string, ...members: string[]): Promise<number>;
smembers(key: string): Promise<string[]>;
mget(...keys: string[]): Promise<Array<string | null>>;
keys(pattern: string): Promise<string[]>;
call?(command: string, ...args: unknown[]): Promise<unknown>;
quit?(): Promise<string>;
}
export interface RedisVectorStoreOptions {
redisUrl?: string;
client?: RedisLike;
keyPrefix?: string;
}
export class RedisVectorStore implements IVectorStore {
private client: RedisLike | null = null;
private readonly redisUrl?: string;
private readonly prefix: string;
private rediSearchAvailable: boolean | null = null;
constructor(options?: RedisVectorStoreOptions) {
this.redisUrl = options?.redisUrl;
this.prefix = options?.keyPrefix ?? "omniroute:semcache:";
if (options?.client) {
this.client = options.client;
}
}
private async getClient(): Promise<RedisLike | null> {
if (this.client) return this.client;
try {
const mod = await import("ioredis");
const RedisClass = (mod.default ?? mod) as unknown as new (url?: string) => RedisLike;
this.client = new RedisClass(this.redisUrl);
return this.client;
} catch (err) {
console.warn("[CACHE] Redis driver unavailable:", (err as Error).message);
return null;
}
}
private entryKey(id: string): string {
return `${this.prefix}entry:${id}`;
}
private hashKey(hash: string): string {
return `${this.prefix}hash:${hash}`;
}
private modelSetKey(model: string): string {
return `${this.prefix}model:${model}`;
}
private allIdsKey(): string {
return `${this.prefix}all_ids`;
}
public async get(id: string): Promise<CacheEntry | null> {
try {
const client = await this.getClient();
if (!client) return null;
const raw = await client.get(this.entryKey(id));
if (!raw) return null;
const entry = JSON.parse(raw) as CacheEntry;
if (entry.expiresAt > 0 && entry.expiresAt <= Date.now()) {
await this.delete(id);
return null;
}
return entry;
} catch (err) {
console.warn("[CACHE] Redis get error:", (err as Error).message);
return null;
}
}
public async getByHash(hash: string): Promise<CacheEntry | null> {
try {
const client = await this.getClient();
if (!client) return null;
const id = await client.get(this.hashKey(hash));
if (!id) return null;
return this.get(id);
} catch (err) {
console.warn("[CACHE] Redis getByHash error:", (err as Error).message);
return null;
}
}
public async set(entry: CacheEntry, ttlMs: number): Promise<void> {
try {
const client = await this.getClient();
if (!client) return;
const ttlSeconds = Math.max(1, Math.ceil(ttlMs / 1000));
const serialized = JSON.stringify(entry);
// Store entry and exact hash mapping with TTL
await Promise.all([
client.set(this.entryKey(entry.id), serialized, "EX", ttlSeconds),
client.set(this.hashKey(entry.hash), entry.id, "EX", ttlSeconds),
client.sadd(this.allIdsKey(), entry.id),
client.sadd(this.modelSetKey(entry.model), entry.id),
]);
} catch (err) {
console.warn("[CACHE] Redis set error:", (err as Error).message);
}
}
public async searchNearest(
embedding: number[],
filter: StoreFilter,
threshold: number,
limit = 1
): Promise<SimilaritySearchResult[]> {
try {
const client = await this.getClient();
if (!client || !embedding || embedding.length === 0) return [];
// Determine candidate IDs from model set or all IDs
let candidateIds: string[];
if (filter.model) {
candidateIds = await client.smembers(this.modelSetKey(filter.model));
} else {
candidateIds = await client.smembers(this.allIdsKey());
}
if (!candidateIds || candidateIds.length === 0) return [];
const queryNorm = l2Normalize(embedding);
const keys = candidateIds.map((id) => this.entryKey(id));
const rawEntries = await client.mget(...keys);
const now = Date.now();
const results: SimilaritySearchResult[] = [];
const staleIds: string[] = [];
for (let i = 0; i < rawEntries.length; i++) {
const raw = rawEntries[i];
if (!raw) {
staleIds.push(candidateIds[i]);
continue;
}
let entry: CacheEntry;
try {
entry = JSON.parse(raw) as CacheEntry;
} catch {
staleIds.push(candidateIds[i]);
continue;
}
if (entry.expiresAt > 0 && entry.expiresAt <= now) {
staleIds.push(entry.id);
continue;
}
if (filter.provider && entry.provider !== filter.provider) continue;
// Partition key isolation: null means must have NO key, string means exact match
if (filter.apiKeyId !== undefined) {
const expected = filter.apiKeyId === null ? undefined : filter.apiKeyId;
if (entry.apiKeyId !== expected) continue;
}
if (filter.cacheKey !== undefined) {
const expected = filter.cacheKey === null ? undefined : filter.cacheKey;
if (entry.cacheKey !== expected) continue;
}
if (!entry.embedding || entry.embedding.length !== queryNorm.length) continue;
const candidateNorm = l2Normalize(entry.embedding);
const sim = dotProduct(queryNorm, candidateNorm);
if (sim >= threshold) {
results.push({ entry, similarity: sim });
}
}
// Cleanup missing or expired IDs in background
if (staleIds.length > 0) {
Promise.all(staleIds.map((id) => this.delete(id))).catch(() => {});
}
results.sort((a, b) => b.similarity - a.similarity);
return results.slice(0, limit);
} catch (err) {
console.warn("[CACHE] Redis searchNearest error:", (err as Error).message);
return [];
}
}
public async delete(id: string): Promise<boolean> {
try {
const client = await this.getClient();
if (!client) return false;
const raw = await client.get(this.entryKey(id));
if (raw) {
try {
const entry = JSON.parse(raw) as CacheEntry;
// Delete hash mapping only if it still points to this entry id
const hashOwner = await client.get(this.hashKey(entry.hash));
if (hashOwner === id) {
await client.del(this.hashKey(entry.hash));
}
await client.del(this.entryKey(id));
await client.srem(this.allIdsKey(), id);
await client.srem(this.modelSetKey(entry.model), id);
return true;
} catch {}
}
await client.del(this.entryKey(id));
await client.srem(this.allIdsKey(), id);
return true;
} catch (err) {
console.warn("[CACHE] Redis delete error:", (err as Error).message);
return false;
}
}
public async deleteByModel(model: string): Promise<number> {
try {
const client = await this.getClient();
if (!client) return 0;
const ids = await client.smembers(this.modelSetKey(model));
if (!ids || ids.length === 0) return 0;
for (const id of ids) {
await this.delete(id);
}
await client.del(this.modelSetKey(model));
return ids.length;
} catch (err) {
console.warn("[CACHE] Redis deleteByModel error:", (err as Error).message);
return 0;
}
}
public async clear(): Promise<number> {
try {
const client = await this.getClient();
if (!client) return 0;
const ids = await client.smembers(this.allIdsKey());
if (ids && ids.length > 0) {
for (const id of ids) {
await this.delete(id);
}
}
await client.del(this.allIdsKey());
return ids ? ids.length : 0;
} catch (err) {
console.warn("[CACHE] Redis clear error:", (err as Error).message);
return 0;
}
}
public async getStats(): Promise<{ entries: number }> {
try {
const client = await this.getClient();
if (!client) return { entries: 0 };
const ids = await client.smembers(this.allIdsKey());
if (!ids || ids.length === 0) return { entries: 0 };
const keys = ids.map((id) => this.entryKey(id));
const rawEntries = await client.mget(...keys);
let liveCount = 0;
const staleIds: string[] = [];
const now = Date.now();
for (let i = 0; i < rawEntries.length; i++) {
const raw = rawEntries[i];
if (!raw) {
staleIds.push(ids[i]);
continue;
}
try {
const entry = JSON.parse(raw) as CacheEntry;
if (entry.expiresAt > 0 && entry.expiresAt <= now) {
staleIds.push(ids[i]);
continue;
}
liveCount++;
} catch {
staleIds.push(ids[i]);
}
}
if (staleIds.length > 0) {
Promise.all(staleIds.map((id) => this.delete(id))).catch(() => {});
}
return { entries: liveCount };
} catch {
return { entries: 0 };
}
}
public async close(): Promise<void> {
if (this.client?.quit) {
await this.client.quit().catch(() => {});
this.client = null;
} else if (this.client?.disconnect) {
this.client.disconnect();
this.client = null;
}
}
}

View File

@@ -0,0 +1,459 @@
/**
* Semantic Cache Manager
*
* Orchestrates OmniRoute's dual-layer caching architecture:
* - Layer 1: Deterministic SHA-256 direct-hash lookup (0-latency exact replay)
* - Layer 2: Vector embedding similarity search (cosine threshold fuzzy match)
*
* Supports pluggable vector backends (in-memory and Redis), request overrides,
* namespace isolation, SSE streaming replay, and fail-open resilience.
*
* @module services/cache/semanticCacheManager
*/
import crypto from "crypto";
import {
type SemanticCacheConfig,
type SemanticCacheType,
resolveSemanticCacheConfig,
} from "../../config/semanticCacheConfig.ts";
import { type CacheEntry, type IVectorStore, type StoreFilter } from "./vectorStore.ts";
import { MemoryVectorStore } from "./memoryVectorStore.ts";
import { RedisVectorStore } from "./redisVectorStore.ts";
import {
normalizeConversationForEmbedding,
generateEmbeddingWithTimeout,
createDefaultEmbeddingGenerator,
type EmbeddingGenerator,
} from "./embeddingClient.ts";
import { synthesizeOpenAiSseFromJson } from "../../utils/jsonToSse.ts";
export interface CacheLookupParams {
body: Record<string, unknown> & {
messages?: unknown;
input?: unknown;
temperature?: number;
top_p?: number;
};
headers?: unknown;
model: string;
provider: string;
stream?: boolean;
apiKeyId?: string | null;
cacheDefaultMode?: "legacy" | "bypass" | null;
}
export interface CacheLookupResult {
hit: boolean;
type?: "exact" | "semantic";
entry?: CacheEntry;
similarity?: number;
tokensSaved?: number;
bypassed?: boolean;
}
export interface CacheStoreParams {
body: Record<string, unknown> & {
messages?: unknown;
input?: unknown;
temperature?: number;
top_p?: number;
};
headers?: unknown;
response: Record<string, unknown>;
streamChunks?: Array<Record<string, unknown>>;
model: string;
provider: string;
apiKeyId?: string | null;
tokensSaved?: number;
ttlMs?: number;
}
function getHeader(headers: unknown, name: string): string | null {
if (!headers) return null;
const needle = name.toLowerCase();
if (typeof (headers as { get?: (n: string) => string | null }).get === "function") {
return (headers as { get: (n: string) => string | null }).get(name);
}
if (typeof headers === "object" && !Array.isArray(headers)) {
for (const [key, val] of Object.entries(headers as Record<string, unknown>)) {
if (key.toLowerCase() === needle && typeof val === "string") {
return val;
}
}
}
return null;
}
function stringifyValue(val: unknown): string {
if (typeof val === "string") return val;
try {
return JSON.stringify(val);
} catch {
return String(val);
}
}
function normalizeMessagesForHash(conversation: unknown): Array<{ role: string; content: string }> {
if (typeof conversation === "string") {
return [{ role: "user", content: conversation }];
}
if (!Array.isArray(conversation)) return [];
return conversation.map((item) => {
if (!item || typeof item !== "object") {
return { role: "user", content: stringifyValue(item) };
}
const record = item as Record<string, unknown>;
const role =
typeof record.role === "string" && record.role.trim().length > 0 ? record.role : "user";
return {
role,
content: stringifyValue(record.content),
};
});
}
/**
* Generate Layer 1 exact hash signature from request parameters.
*/
export function generateDirectHash(
model: string,
conversation: unknown,
temperature = 0,
topP = 1,
scoping?: {
apiKeyId?: string | null;
cacheKey?: string | null;
provider?: string | null;
cacheByModel?: boolean;
cacheByProvider?: boolean;
}
): string {
const payload = JSON.stringify({
model: scoping?.cacheByModel !== false ? model : "*",
provider: scoping?.cacheByProvider ? scoping.provider || "*" : "*",
cacheKey: scoping?.cacheKey || undefined,
messages: normalizeMessagesForHash(conversation),
temperature,
top_p: topP,
});
const digest = crypto.createHash("sha256").update(payload).digest("hex");
return scoping?.apiKeyId ? `${scoping.apiKeyId}.${digest}` : digest;
}
export class SemanticCacheManager {
private config: SemanticCacheConfig;
private vectorStore: IVectorStore;
private embeddingGenerator: EmbeddingGenerator | null = null;
constructor(
config?: Partial<SemanticCacheConfig>,
customStore?: IVectorStore,
customEmbeddingGenerator?: EmbeddingGenerator
) {
this.config = resolveSemanticCacheConfig(config);
if (customStore) {
this.vectorStore = customStore;
} else if (this.config.backend === "redis") {
this.vectorStore = new RedisVectorStore({
redisUrl: this.config.redisUrl,
keyPrefix: this.config.redisPrefix,
});
} else {
this.vectorStore = new MemoryVectorStore({
maxEntries: this.config.maxEntries,
});
}
if (customEmbeddingGenerator) {
this.embeddingGenerator = customEmbeddingGenerator;
} else {
this.embeddingGenerator = createDefaultEmbeddingGenerator(this.config);
}
}
public setEmbeddingGenerator(generator: EmbeddingGenerator): void {
this.embeddingGenerator = generator;
}
public getStore(): IVectorStore {
return this.vectorStore;
}
public getConfig(): SemanticCacheConfig {
return this.config;
}
public updateConfig(newConfig: Partial<SemanticCacheConfig>): void {
this.config = resolveSemanticCacheConfig({ ...this.config, ...newConfig });
}
private isBypassed(headers: unknown, body: Record<string, unknown>): boolean {
const noCacheHeader = getHeader(headers, "x-omniroute-no-cache");
if (noCacheHeader && noCacheHeader.toLowerCase() === "true") {
return true;
}
const cacheControl = getHeader(headers, "cache-control");
if (cacheControl && cacheControl.toLowerCase().includes("no-cache")) {
return true;
}
const pragma = getHeader(headers, "pragma");
if (pragma && pragma.toLowerCase().includes("no-cache")) {
return true;
}
if (this.config.requireZeroTemperature) {
if (typeof body.temperature === "number" && body.temperature !== 0) {
return true;
}
if (body.temperature === undefined) {
return true;
}
}
return false;
}
public async lookup(params: CacheLookupParams): Promise<CacheLookupResult> {
if (!this.config.enabled) {
return { hit: false };
}
if (params.cacheDefaultMode === "bypass") {
return { hit: false, bypassed: true };
}
if (this.isBypassed(params.headers, params.body)) {
return { hit: false, bypassed: true };
}
const cacheKey = getHeader(params.headers, "x-omniroute-cache-key");
const cacheTypeHeader = (
getHeader(params.headers, "x-omniroute-cache-type") || "both"
).toLowerCase() as SemanticCacheType;
const thresholdHeader = getHeader(params.headers, "x-omniroute-cache-threshold");
const threshold =
thresholdHeader !== null && Number.isFinite(Number(thresholdHeader))
? Math.max(0, Math.min(1, Number(thresholdHeader)))
: this.config.similarityThreshold;
const conv = params.body.messages ?? params.body.input;
const temp = typeof params.body.temperature === "number" ? params.body.temperature : 0;
const topP = typeof params.body.top_p === "number" ? params.body.top_p : 1;
// ── Layer 1: Direct Hash Lookup ──
const directHash = generateDirectHash(params.model, conv, temp, topP, {
apiKeyId: params.apiKeyId,
cacheKey,
provider: params.provider,
cacheByModel: this.config.cacheByModel,
cacheByProvider: this.config.cacheByProvider,
});
if (cacheTypeHeader !== "semantic") {
try {
const exactEntry = await this.vectorStore.getByHash(directHash);
if (exactEntry) {
return {
hit: true,
type: "exact",
entry: exactEntry,
tokensSaved: exactEntry.tokensSaved,
};
}
} catch (err) {
console.warn("[CACHE] Direct hash lookup error:", (err as Error).message);
}
}
// ── Layer 2: Semantic Vector Similarity Lookup ──
if (cacheTypeHeader === "direct" || !this.embeddingGenerator) {
return { hit: false };
}
// Guard: check conversation history depth/threshold
if (Array.isArray(conv) && conv.length > this.config.conversationHistoryThreshold) {
return { hit: false };
}
const promptText = normalizeConversationForEmbedding(conv, {
excludeSystemPrompt: this.config.excludeSystemPrompt,
historyDepth: this.config.conversationHistoryDepth,
});
if (!promptText) {
return { hit: false };
}
const embedResult = await generateEmbeddingWithTimeout(promptText, this.embeddingGenerator, {
model: this.config.embeddingModel,
provider: this.config.embeddingProvider,
timeoutMs: this.config.embeddingTimeoutMs,
});
if (!embedResult?.embedding || embedResult.embedding.length === 0) {
return { hit: false };
}
const filter: StoreFilter = {
model: this.config.cacheByModel ? params.model : undefined,
provider: this.config.cacheByProvider ? params.provider : undefined,
apiKeyId: params.apiKeyId || null,
cacheKey: cacheKey || null,
};
try {
const nearest = await this.vectorStore.searchNearest(
embedResult.embedding,
filter,
threshold,
1
);
if (nearest.length > 0 && nearest[0].similarity >= threshold) {
return {
hit: true,
type: "semantic",
entry: nearest[0].entry,
similarity: nearest[0].similarity,
tokensSaved: nearest[0].entry.tokensSaved,
};
}
} catch (err) {
console.warn("[CACHE] Semantic similarity search error:", (err as Error).message);
}
return { hit: false };
}
public async store(params: CacheStoreParams): Promise<void> {
if (!this.config.enabled) return;
// Check no-store header
const noStore = getHeader(params.headers, "x-omniroute-cache-no-store");
if (noStore && noStore.toLowerCase() === "true") return;
if (this.isBypassed(params.headers, params.body)) return;
const cacheKey = getHeader(params.headers, "x-omniroute-cache-key");
const conv = params.body.messages ?? params.body.input;
const temp = typeof params.body.temperature === "number" ? params.body.temperature : 0;
const topP = typeof params.body.top_p === "number" ? params.body.top_p : 1;
const directHash = generateDirectHash(params.model, conv, temp, topP, {
apiKeyId: params.apiKeyId,
cacheKey,
provider: params.provider,
cacheByModel: this.config.cacheByModel,
cacheByProvider: this.config.cacheByProvider,
});
const promptText = normalizeConversationForEmbedding(conv, {
excludeSystemPrompt: this.config.excludeSystemPrompt,
historyDepth: this.config.conversationHistoryDepth,
});
// Custom TTL from header or config
const ttlHeader = getHeader(params.headers, "x-omniroute-cache-ttl");
let effectiveTtl = this.config.ttlMs;
if (ttlHeader) {
const parsed = Number(ttlHeader);
if (Number.isFinite(parsed) && parsed > 0) {
effectiveTtl = parsed > 100000 ? parsed : parsed * 1000;
}
} else if (params.ttlMs && params.ttlMs > 0) {
effectiveTtl = params.ttlMs;
}
let embedding: number[] | undefined;
if (this.embeddingGenerator && promptText) {
const embedResult = await generateEmbeddingWithTimeout(promptText, this.embeddingGenerator, {
model: this.config.embeddingModel,
provider: this.config.embeddingProvider,
timeoutMs: this.config.embeddingTimeoutMs,
});
if (embedResult?.embedding) {
embedding = embedResult.embedding;
}
}
const now = Date.now();
const entry: CacheEntry = {
id: crypto.randomUUID(),
hash: directHash,
embedding,
promptText,
model: params.model,
provider: params.provider,
apiKeyId: params.apiKeyId || undefined,
cacheKey: cacheKey || undefined,
response: params.response,
streamChunks: params.streamChunks,
tokensSaved: params.tokensSaved || 0,
createdAt: now,
expiresAt: now + effectiveTtl,
};
try {
await this.vectorStore.set(entry, effectiveTtl);
} catch (err) {
console.warn("[CACHE] Asynchronous cache store failed:", (err as Error).message);
}
}
public synthesizeSseFromEntry(entry: CacheEntry): string {
if (Array.isArray(entry.streamChunks) && entry.streamChunks.length > 0) {
const frames = entry.streamChunks.map((c) => `data: ${JSON.stringify(c)}\n\n`);
frames.push("data: [DONE]\n\n");
return frames.join("");
}
return synthesizeOpenAiSseFromJson(JSON.stringify(entry.response));
}
public async invalidateByModel(model: string): Promise<number> {
try {
return await this.vectorStore.deleteByModel(model);
} catch {
return 0;
}
}
public async invalidateById(id: string): Promise<boolean> {
try {
return await this.vectorStore.delete(id);
} catch {
return false;
}
}
public async clear(): Promise<number> {
try {
return await this.vectorStore.clear();
} catch {
return 0;
}
}
public async getStats(): Promise<{ entries: number }> {
return this.vectorStore.getStats();
}
}
// ── Singleton Instance ──
let defaultManager: SemanticCacheManager | null = null;
export function getSemanticCacheManager(): SemanticCacheManager {
if (!defaultManager) {
defaultManager = new SemanticCacheManager();
}
return defaultManager;
}
export function resetSemanticCacheManager(custom?: SemanticCacheManager | null): void {
defaultManager = custom ?? null;
}

121
open-sse/services/cache/vectorStore.ts vendored Normal file
View File

@@ -0,0 +1,121 @@
/**
* Vector Store Interface & Mathematical Utilities
*
* Provides common types and optimized vector similarity math for semantic caching.
*
* @module services/cache/vectorStore
*/
export interface CacheEntry {
id: string;
hash: string;
embedding?: number[];
promptText: string;
model: string;
provider: string;
apiKeyId?: string;
cacheKey?: string;
response: Record<string, unknown>;
streamChunks?: Array<Record<string, unknown>>;
tokensSaved: number;
createdAt: number;
expiresAt: number;
}
export interface StoreFilter {
model?: string;
provider?: string;
/**
* Filter by API key ID:
* - string: entry must match this apiKeyId.
* - null: entry must have NO apiKeyId (anonymous/unkeyed).
* - undefined: do not filter by apiKeyId.
*/
apiKeyId?: string | null;
/**
* Filter by cache key:
* - string: entry must match this cacheKey.
* - null: entry must have NO cacheKey.
* - undefined: do not filter by cacheKey.
*/
cacheKey?: string | null;
}
export interface SimilaritySearchResult {
entry: CacheEntry;
similarity: number;
}
export interface IVectorStore {
get(id: string): Promise<CacheEntry | null>;
getByHash(hash: string): Promise<CacheEntry | null>;
set(entry: CacheEntry, ttlMs: number): Promise<void>;
searchNearest(
embedding: number[],
filter: StoreFilter,
threshold: number,
limit?: number
): Promise<SimilaritySearchResult[]>;
delete(id: string): Promise<boolean>;
deleteByModel(model: string): Promise<number>;
clear(): Promise<number>;
getStats(): Promise<{ entries: number }>;
close?(): Promise<void>;
}
/**
* Computes the dot product of two numerical vectors.
*/
export function dotProduct(a: number[], b: number[]): number {
const len = Math.min(a.length, b.length);
let sum = 0;
for (let i = 0; i < len; i++) {
sum += a[i] * b[i];
}
return sum;
}
/**
* Computes the L2 norm (magnitude) of a vector.
*/
export function l2Norm(v: number[]): number {
let sumSq = 0;
for (let i = 0; i < v.length; i++) {
sumSq += v[i] * v[i];
}
return Math.sqrt(sumSq);
}
/**
* Returns a unit vector (L2 normalized) of the given vector.
* If magnitude is 0, returns a copy of the vector.
*/
export function l2Normalize(v: number[]): number[] {
const norm = l2Norm(v);
if (norm === 0 || !Number.isFinite(norm)) {
return v.slice();
}
const invNorm = 1 / norm;
const out = new Array<number>(v.length);
for (let i = 0; i < v.length; i++) {
out[i] = v[i] * invNorm;
}
return out;
}
/**
* Computes cosine similarity between vectors a and b.
* Range: [-1.0, 1.0]. Returns 0 if either vector has zero magnitude.
*/
export function cosineSimilarity(a: number[], b: number[]): number {
if (a.length === 0 || b.length === 0) return 0;
const normA = l2Norm(a);
const normB = l2Norm(b);
if (normA === 0 || normB === 0) return 0;
const dot = dotProduct(a, b);
const sim = dot / (normA * normB);
// Guard against floating point rounding errors beyond [-1, 1]
if (sim > 1) return 1;
if (sim < -1) return -1;
return sim;
}

View File

@@ -0,0 +1,313 @@
/**
* Live Verification Script: Semantic Caching with Lemonade Server & Redis
*
* Tests:
* 1. Direct Lemonade embedding generation (harrier-oss-v1-0.6b, 1024-dim).
* 2. In-Memory Vector Store: Layer 1 exact hit, Layer 2 semantic hit, semantic miss, LRU eviction.
* 3. Redis Vector Store: Live connection to redis://192.168.31.147:6379, exact match, semantic match, TTL & cleanup.
* 4. Streaming SSE response replay.
*
* Run with: node --import tsx/esm scripts/ad-hoc/test-semantic-cache-lemonade.ts
*/
import { SemanticCacheManager } from "../../open-sse/services/cache/semanticCacheManager.ts";
import { MemoryVectorStore } from "../../open-sse/services/cache/memoryVectorStore.ts";
import { RedisVectorStore } from "../../open-sse/services/cache/redisVectorStore.ts";
import { createDefaultEmbeddingGenerator } from "../../open-sse/services/cache/embeddingClient.ts";
import { cosineSimilarity } from "../../open-sse/services/cache/vectorStore.ts";
const LEMONADE_URL = process.env.LEMONADE_URL || "http://192.168.31.147:13305/v1/embeddings";
const LEMONADE_KEY = process.env.LEMONADE_KEY || "lemonade";
const LEMONADE_MODEL = process.env.LEMONADE_MODEL || "harrier-oss-v1-0.6b";
const REDIS_URL = process.env.REDIS_URL || "redis://192.168.31.147:6379";
async function main() {
console.log("===============================================================");
console.log(" OmniRoute Semantic Caching — Live Verification Suite");
console.log("===============================================================");
console.log(`• Lemonade Endpoint: ${LEMONADE_URL}`);
console.log(`• Embedding Model: ${LEMONADE_MODEL}`);
console.log(`• Redis URL: ${REDIS_URL}`);
console.log("---------------------------------------------------------------\n");
// ── Step 1: Test Direct Lemonade Embeddings ──
console.log(" [1/4] Probing Lemonade Embedding Server...");
const lemonadeGenerator = createDefaultEmbeddingGenerator({
embeddingProvider: "lemonade",
embeddingModel: LEMONADE_MODEL,
embeddingBaseUrl: LEMONADE_URL,
embeddingApiKey: LEMONADE_KEY,
});
const t0 = Date.now();
const testText1 = "What is the capital of France?";
const testText2 = "Can you tell me France's capital city?";
const testText3 = "How do I bake chocolate chip cookies?";
const [emb1, emb2, emb3] = await Promise.all([
lemonadeGenerator(testText1),
lemonadeGenerator(testText2),
lemonadeGenerator(testText3),
]);
if (!emb1?.embedding || !emb2?.embedding || !emb3?.embedding) {
console.error("❌ Failed to obtain embeddings from Lemonade server. Exiting.");
process.exit(1);
}
const embedDuration = Date.now() - t0;
console.log(`✔ Generated 3 embeddings in ${embedDuration}ms`);
console.log(`✔ Vector Dimension: ${emb1.embedding.length} (expected 1024)`);
const simParaphrase = cosineSimilarity(emb1.embedding, emb2.embedding);
const simUnrelated = cosineSimilarity(emb1.embedding, emb3.embedding);
console.log(`• Cosine Similarity (Prompt 1 vs Paraphrase Prompt 2): ${simParaphrase.toFixed(4)}`);
console.log(`• Cosine Similarity (Prompt 1 vs Unrelated Prompt 3): ${simUnrelated.toFixed(4)}`);
if (simParaphrase > 0.8 && simUnrelated < 0.6) {
console.log(
"✔ Semantic vector space validated: Paraphrase is highly similar, unrelated is distant.\n"
);
} else {
console.warn("⚠ Warning: Unexpected vector similarity distribution.\n");
}
// ── Step 2: Test In-Memory Vector Store ──
console.log(" [2/4] Testing In-Memory Vector Store & Dual-Layer Cache...");
const memStore = new MemoryVectorStore({ maxEntries: 100 });
const memoryManager = new SemanticCacheManager(
{
enabled: true,
backend: "memory",
similarityThreshold: 0.8,
embeddingModel: LEMONADE_MODEL,
embeddingProvider: "lemonade",
embeddingBaseUrl: LEMONADE_URL,
embeddingApiKey: LEMONADE_KEY,
},
memStore,
lemonadeGenerator
);
const frenchCapitalResponse = {
id: "chatcmpl-paris-test",
choices: [
{
index: 0,
message: { role: "assistant", content: "The capital of France is Paris." },
finish_reason: "stop",
},
],
usage: { prompt_tokens: 12, completion_tokens: 8 },
};
// Store in cache
await memoryManager.store({
body: {
model: "lemonade/Heimdallr-27B-GGUF",
messages: [{ role: "user", content: testText1 }],
temperature: 0,
},
response: frenchCapitalResponse,
model: "lemonade/Heimdallr-27B-GGUF",
provider: "lemonade",
tokensSaved: 20,
});
console.log("✔ Stored initial response in In-Memory cache.");
// Test Layer 1: Exact Match (Direct Hash)
const memExact = await memoryManager.lookup({
body: {
model: "lemonade/Heimdallr-27B-GGUF",
messages: [{ role: "user", content: testText1 }],
temperature: 0,
},
model: "lemonade/Heimdallr-27B-GGUF",
provider: "lemonade",
});
console.log(
`• Query 1 (Identical Query): Hit=${memExact.hit}, Type=${memExact.type}, TokensSaved=${memExact.tokensSaved}`
);
if (memExact.hit && memExact.type === "exact") {
console.log("✔ Layer 1 (Exact Match) HIT verified with 0 embedding overhead.");
} else {
console.error("❌ Layer 1 exact match failed!");
}
// Test Layer 2: Semantic Similarity Match
const memSemantic = await memoryManager.lookup({
body: {
model: "lemonade/Heimdallr-27B-GGUF",
messages: [{ role: "user", content: testText2 }],
temperature: 0,
},
model: "lemonade/Heimdallr-27B-GGUF",
provider: "lemonade",
});
console.log(
`• Query 2 (Paraphrase): Hit=${memSemantic.hit}, Type=${memSemantic.type}, Similarity=${memSemantic.similarity?.toFixed(4)}`
);
if (memSemantic.hit && memSemantic.type === "semantic") {
console.log("✔ Layer 2 (Semantic Match) HIT verified via Lemonade embeddings.");
} else {
console.error("❌ Layer 2 semantic match failed!");
}
// Test Miss: Unrelated query
const memMiss = await memoryManager.lookup({
body: {
model: "lemonade/Heimdallr-27B-GGUF",
messages: [{ role: "user", content: testText3 }],
temperature: 0,
},
model: "lemonade/Heimdallr-27B-GGUF",
provider: "lemonade",
});
console.log(`• Query 3 (Unrelated): Hit=${memMiss.hit}`);
if (!memMiss.hit) {
console.log("✔ Semantic MISS correctly returned for unrelated prompt.\n");
} else {
console.error("❌ Unrelated prompt unexpectedly hit cache!");
}
// ── Step 3: Test Redis Vector Store ──
console.log(" [3/4] Testing Redis Vector Store & Persistence on 192.168.31.147:6379...");
const redisPrefix = `omniroute:test:semcache:${Date.now()}:`;
const redisStore = new RedisVectorStore({
redisUrl: REDIS_URL,
keyPrefix: redisPrefix,
});
const redisManager = new SemanticCacheManager(
{
enabled: true,
backend: "redis",
redisUrl: REDIS_URL,
redisPrefix,
similarityThreshold: 0.8,
embeddingModel: LEMONADE_MODEL,
embeddingProvider: "lemonade",
embeddingBaseUrl: LEMONADE_URL,
embeddingApiKey: LEMONADE_KEY,
},
redisStore,
lemonadeGenerator
);
const quantumPrompt1 = "Explain quantum computing in simple terms.";
const quantumPrompt2 = "Can you describe what quantum computers are in plain English?";
const unrelatedPrompt4 = "What is the tallest mountain in the world?";
const quantumResponse = {
id: "chatcmpl-quantum-test",
choices: [
{
index: 0,
message: {
role: "assistant",
content: "Quantum computing uses qubits that can be in multiple states at once.",
},
finish_reason: "stop",
},
],
usage: { prompt_tokens: 15, completion_tokens: 22 },
};
// Store in Redis
await redisManager.store({
body: {
model: "lemonade/Heimdallr-27B-GGUF",
messages: [{ role: "user", content: quantumPrompt1 }],
temperature: 0,
},
response: quantumResponse,
model: "lemonade/Heimdallr-27B-GGUF",
provider: "lemonade",
tokensSaved: 37,
});
console.log("✔ Stored quantum computing response in Redis vector store.");
// Test Redis exact match
const redisExact = await redisManager.lookup({
body: {
model: "lemonade/Heimdallr-27B-GGUF",
messages: [{ role: "user", content: quantumPrompt1 }],
temperature: 0,
},
model: "lemonade/Heimdallr-27B-GGUF",
provider: "lemonade",
});
console.log(
`• Redis Query 1 (Identical): Hit=${redisExact.hit}, Type=${redisExact.type}, TokensSaved=${redisExact.tokensSaved}`
);
if (redisExact.hit && redisExact.type === "exact") {
console.log("✔ Redis Layer 1 (Direct Hash) HIT verified.");
} else {
console.error("❌ Redis Layer 1 exact match failed!");
}
// Test Redis semantic match
const redisSemantic = await redisManager.lookup({
body: {
model: "lemonade/Heimdallr-27B-GGUF",
messages: [{ role: "user", content: quantumPrompt2 }],
temperature: 0,
},
model: "lemonade/Heimdallr-27B-GGUF",
provider: "lemonade",
});
console.log(
`• Redis Query 2 (Paraphrase): Hit=${redisSemantic.hit}, Type=${redisSemantic.type}, Similarity=${redisSemantic.similarity?.toFixed(4)}`
);
if (redisSemantic.hit && redisSemantic.type === "semantic") {
console.log("✔ Redis Layer 2 (Semantic Match) HIT verified with 1024-dim vectors.");
} else {
console.error("❌ Redis Layer 2 semantic match failed!");
}
// Test Redis miss
const redisMiss = await redisManager.lookup({
body: {
model: "lemonade/Heimdallr-27B-GGUF",
messages: [{ role: "user", content: unrelatedPrompt4 }],
temperature: 0,
},
model: "lemonade/Heimdallr-27B-GGUF",
provider: "lemonade",
});
console.log(`• Redis Query 3 (Unrelated): Hit=${redisMiss.hit}`);
if (!redisMiss.hit) {
console.log("✔ Redis Semantic MISS correctly returned.");
}
// Clean up Redis test keys
const clearedCount = await redisManager.clear();
console.log(`✔ Cleaned up ${clearedCount} test entries from Redis.\n`);
// ── Step 4: Streaming SSE Replay Verification ──
console.log(" [4/4] Verifying Streaming SSE Synthesis from Cache Entry...");
if (redisExact.entry) {
const sseStream = redisManager.synthesizeSseFromEntry(redisExact.entry);
const hasData = sseStream.includes("data: {");
const endsDone = sseStream.trimEnd().endsWith("data: [DONE]");
console.log(`• SSE Stream starts with 'data: ': ${sseStream.startsWith("data: ")}`);
console.log(`• SSE Stream includes chunks: ${hasData}`);
console.log(`• SSE Stream ends with [DONE]: ${endsDone}`);
if (hasData && endsDone) {
console.log("✔ Streaming SSE chunk replay format verified.\n");
} else {
console.error("❌ SSE chunk replay format malformed!");
}
}
console.log("===============================================================");
console.log(" 🎉 ALL LIVE VERIFICATION CHECKS PASSED SUCCESSFULLY!");
console.log("===============================================================");
process.exit(0);
}
main().catch((err) => {
console.error("Live test failed with exception:", err);
process.exit(1);
});

View File

@@ -325,14 +325,16 @@ export async function createEmbeddingResponse(
`[${provider}] All ${credentials.expiredCount || 1} connection(s) authentication expired — please reconnect in the dashboard`
);
}
} else if (provider === "ollama-local" || provider === "lmstudio") {
// Ollama and LM Studio are keyless, but a configured connection can still
// provide a custom local host. Hydrate that optional connection without
// imposing an authentication requirement, then keep the static localhost
// default when no connection exists. getProviderCredentials("lmstudio")
// resolves the dashboard's hyphenated "lm-studio" connection via the
// provider search pool/alias (#11233); a selection or rate-limit failure
// must not break the flow — proceed without credentials.
} else if (
provider === "ollama-local" ||
provider === "lmstudio" ||
provider === "llama-cpp" ||
provider === "lemonade"
) {
// Local providers are key-optional, but a configured connection can provide
// a custom host or API key (e.g. Lemonade bearer auth). Hydrate that optional
// connection without imposing an authentication requirement, then keep the
// static localhost default when no connection exists.
const localCredentials = await getProviderCredentials(credentialsProviderId);
if (
localCredentials &&

View File

@@ -15,6 +15,7 @@
import crypto from "crypto";
import { LRUCache } from "./cacheLayer";
import { getDbInstance } from "./db/core";
import { toNumber } from "@/shared/utils/numeric";
type JsonRecord = Record<string, unknown>;
@@ -22,15 +23,6 @@ function asRecord(value: unknown): JsonRecord {
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
}
function toNumber(value: unknown, fallback = 0): number {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim().length > 0) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
return fallback;
}
/**
* Current time in the same format `expires_at` and `created_at` are written in.
*
@@ -388,6 +380,10 @@ export function isCacheableForRead(body, headers) {
if ((getHeaderValue(headers, "x-omniroute-no-cache") || "").toLowerCase() === "true") {
return false;
}
const cacheControl = (getHeaderValue(headers, "cache-control") || "").toLowerCase();
if (cacheControl.includes("no-cache")) {
return false;
}
if (typeof body.temperature !== "number" || body.temperature !== 0) return false;
return true;
}
@@ -402,6 +398,10 @@ export function isCacheableForWrite(body, headers) {
if ((getHeaderValue(headers, "x-omniroute-no-cache") || "").toLowerCase() === "true") {
return false;
}
const cacheControl = (getHeaderValue(headers, "cache-control") || "").toLowerCase();
if (cacheControl.includes("no-cache")) {
return false;
}
if (body.temperature !== 0) return false;
return true;
}

View File

@@ -2,6 +2,8 @@ export const OMNIROUTE_RESPONSE_HEADERS = {
cache: "X-OmniRoute-Cache",
cacheHit: "X-OmniRoute-Cache-Hit",
cacheLatency: "X-OmniRoute-Cache-Latency",
cacheSimilarity: "X-OmniRoute-Cache-Similarity",
savingsTokens: "X-OmniRoute-Savings-Tokens",
compression: "X-OmniRoute-Compression",
costSaved: "X-OmniRoute-Cost-Saved",
decision: "X-OmniRoute-Decision",

View File

@@ -75,7 +75,7 @@ export const LOCAL_PROVIDERS = {
},
lemonade: {
id: "lemonade",
serviceKinds: ["llm"],
serviceKinds: ["llm", "embedding"],
alias: "lemonade",
name: "Lemonade Server",
icon: "bolt",
@@ -103,7 +103,7 @@ export const LOCAL_PROVIDERS = {
},
"llama-cpp": {
id: "llama-cpp",
serviceKinds: ["llm"],
serviceKinds: ["llm", "embedding"],
alias: "llamacpp",
name: "llama.cpp",
icon: "memory",

View File

@@ -0,0 +1,220 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { SemanticCacheManager } from "../../open-sse/services/cache/semanticCacheManager.ts";
import { MemoryVectorStore } from "../../open-sse/services/cache/memoryVectorStore.ts";
import { RedisVectorStore } from "../../open-sse/services/cache/redisVectorStore.ts";
import { createDefaultEmbeddingGenerator } from "../../open-sse/services/cache/embeddingClient.ts";
import { cosineSimilarity } from "../../open-sse/services/cache/vectorStore.ts";
const LEMONADE_URL = process.env.LEMONADE_URL || "http://192.168.31.147:13305/v1/embeddings";
const LEMONADE_KEY = process.env.LEMONADE_KEY || "lemonade";
const LEMONADE_MODEL = process.env.LEMONADE_MODEL || "harrier-oss-v1-0.6b";
const REDIS_URL = process.env.REDIS_URL || "redis://192.168.31.147:6379";
async function isEndpointReachable(url: string, timeoutMs = 1500): Promise<boolean> {
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
const res = await fetch(url, { method: "HEAD", signal: controller.signal }).catch(() => null);
clearTimeout(timer);
return res !== null;
} catch {
return false;
}
}
describe("Semantic Cache Live Integration with Lemonade & Redis", () => {
it("generates 1024-dimensional embeddings via Lemonade harrier-oss-v1-0.6b", async (t) => {
const reachable = await isEndpointReachable(LEMONADE_URL);
if (!reachable) {
t.skip(`Lemonade server not reachable at ${LEMONADE_URL}`);
return;
}
const generator = createDefaultEmbeddingGenerator({
embeddingProvider: "lemonade",
embeddingModel: LEMONADE_MODEL,
embeddingBaseUrl: LEMONADE_URL,
embeddingApiKey: LEMONADE_KEY,
});
const res = await generator("What is machine learning?");
assert.ok(res, "Should return embedding result");
assert.equal(res.embedding.length, 1024, "Vector dimension should be 1024");
const paraphraseRes = await generator("Can you explain what machine learning is?");
assert.ok(paraphraseRes);
const sim = cosineSimilarity(res.embedding, paraphraseRes.embedding);
assert.ok(sim > 0.8, `Paraphrase similarity ${sim} should be > 0.8`);
});
it("performs dual-layer lookup using MemoryVectorStore and Lemonade embeddings", async (t) => {
const reachable = await isEndpointReachable(LEMONADE_URL);
if (!reachable) {
t.skip(`Lemonade server not reachable at ${LEMONADE_URL}`);
return;
}
const generator = createDefaultEmbeddingGenerator({
embeddingProvider: "lemonade",
embeddingModel: LEMONADE_MODEL,
embeddingBaseUrl: LEMONADE_URL,
embeddingApiKey: LEMONADE_KEY,
});
const manager = new SemanticCacheManager(
{
enabled: true,
similarityThreshold: 0.8,
embeddingModel: LEMONADE_MODEL,
embeddingProvider: "lemonade",
embeddingBaseUrl: LEMONADE_URL,
embeddingApiKey: LEMONADE_KEY,
},
new MemoryVectorStore(),
generator
);
const body = {
model: "lemonade/Heimdallr-27B-GGUF",
messages: [{ role: "user", content: "What is the boiling point of water?" }],
temperature: 0,
};
await manager.store({
body,
response: {
id: "resp-boiling",
choices: [
{ message: { role: "assistant", content: "Water boils at 100 degrees Celsius." } },
],
usage: { prompt_tokens: 10, completion_tokens: 12 },
},
model: "lemonade/Heimdallr-27B-GGUF",
provider: "lemonade",
tokensSaved: 22,
});
// 1. Exact match -> Layer 1
const exact = await manager.lookup({
body,
model: "lemonade/Heimdallr-27B-GGUF",
provider: "lemonade",
});
assert.equal(exact.hit, true);
assert.equal(exact.type, "exact");
assert.equal(exact.tokensSaved, 22);
// 2. Semantic match -> Layer 2
const semantic = await manager.lookup({
body: {
model: "lemonade/Heimdallr-27B-GGUF",
messages: [{ role: "user", content: "At what temperature does water boil?" }],
temperature: 0,
},
model: "lemonade/Heimdallr-27B-GGUF",
provider: "lemonade",
});
assert.equal(semantic.hit, true);
assert.equal(semantic.type, "semantic");
assert.ok((semantic.similarity ?? 0) >= 0.8);
// 3. Unrelated -> Miss
const miss = await manager.lookup({
body: {
model: "lemonade/Heimdallr-27B-GGUF",
messages: [{ role: "user", content: "Who was Napoleon Bonaparte?" }],
temperature: 0,
},
model: "lemonade/Heimdallr-27B-GGUF",
provider: "lemonade",
});
assert.equal(miss.hit, false);
});
it("persists entries and performs vector search in live Redis", async (t) => {
const reachable = await isEndpointReachable(LEMONADE_URL);
if (!reachable) {
t.skip(`Lemonade server not reachable at ${LEMONADE_URL}`);
return;
}
const redisStore = new RedisVectorStore({
redisUrl: REDIS_URL,
keyPrefix: `omniroute:test:it:${Date.now()}:`,
});
const generator = createDefaultEmbeddingGenerator({
embeddingProvider: "lemonade",
embeddingModel: LEMONADE_MODEL,
embeddingBaseUrl: LEMONADE_URL,
embeddingApiKey: LEMONADE_KEY,
});
const manager = new SemanticCacheManager(
{
enabled: true,
backend: "redis",
similarityThreshold: 0.8,
embeddingModel: LEMONADE_MODEL,
embeddingProvider: "lemonade",
embeddingBaseUrl: LEMONADE_URL,
embeddingApiKey: LEMONADE_KEY,
},
redisStore,
generator
);
const body = {
model: "lemonade/Heimdallr-27B-GGUF",
messages: [{ role: "user", content: "How do plants perform photosynthesis?" }],
temperature: 0,
};
await manager.store({
body,
response: {
id: "resp-photosynthesis",
choices: [
{
message: {
role: "assistant",
content: "Plants convert sunlight, water, and CO2 into glucose and oxygen.",
},
},
],
usage: { prompt_tokens: 15, completion_tokens: 20 },
},
model: "lemonade/Heimdallr-27B-GGUF",
provider: "lemonade",
tokensSaved: 35,
});
// 1. Direct hash from Redis
const exact = await manager.lookup({
body,
model: "lemonade/Heimdallr-27B-GGUF",
provider: "lemonade",
});
assert.equal(exact.hit, true);
assert.equal(exact.type, "exact");
// 2. Semantic match from Redis
const semantic = await manager.lookup({
body: {
model: "lemonade/Heimdallr-27B-GGUF",
messages: [{ role: "user", content: "Explain how photosynthesis works in plants" }],
temperature: 0,
},
model: "lemonade/Heimdallr-27B-GGUF",
provider: "lemonade",
});
assert.equal(semantic.hit, true);
assert.equal(semantic.type, "semantic");
assert.ok((semantic.similarity ?? 0) >= 0.8);
// Clean up Redis keys
await manager.clear();
await redisStore.close();
});
});

View File

@@ -215,7 +215,11 @@ test("checkSemanticCache returns a non-streaming JSON HIT with cache headers + l
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.cache),
"HIT (exact)",
"X-OmniRoute-Cache: HIT (exact)"
);
assert.equal(
res.headers.get(OMNIROUTE_RESPONSE_HEADERS.cacheHit),
"true",
@@ -277,7 +281,7 @@ test("checkSemanticCache returns a streaming SSE HIT (text/event-stream) when st
"text/event-stream",
"streaming HIT -> text/event-stream"
);
assert.equal(res.headers.get(OMNIROUTE_RESPONSE_HEADERS.cache), "HIT");
assert.equal(res.headers.get(OMNIROUTE_RESPONSE_HEADERS.cache), "HIT (exact)");
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");
@@ -312,7 +316,7 @@ test("checkSemanticCache HITs even when the cached body has no usage (cost falls
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");
assert.equal(res.headers.get(OMNIROUTE_RESPONSE_HEADERS.cache), "HIT (exact)");
// cachedUsage resolves to undefined -> cachedCost = 0 -> the zero-cost sentinel header.
assert.equal(
res.headers.get(OMNIROUTE_RESPONSE_HEADERS.responseCost),
@@ -363,7 +367,7 @@ test("checkSemanticCache HIT bills 0 incremental cost and reports the original c
assert.ok(result, "HIT -> non-null result");
const res = result.response as Response;
assert.equal(res.headers.get(OMNIROUTE_RESPONSE_HEADERS.cache), "HIT");
assert.equal(res.headers.get(OMNIROUTE_RESPONSE_HEADERS.cache), "HIT (exact)");
// Incremental cost billed to the client on a HIT is 0 (no upstream call happened).
assert.equal(
res.headers.get(OMNIROUTE_RESPONSE_HEADERS.responseCost),

View File

@@ -0,0 +1,297 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const TEST_DATA_DIR = mkdtempSync(join(tmpdir(), "omniroute-lemonade-embedding-"));
process.env.DATA_DIR = TEST_DATA_DIR;
const { getEmbeddingProvider, parseEmbeddingModel, getEmbeddingDimension } =
await import("../../open-sse/config/embeddingRegistry.ts");
const { handleEmbedding } = await import("../../open-sse/handlers/embeddings.ts");
const { LOCAL_PROVIDERS } = await import("../../src/shared/constants/providers/local.ts");
const core = await import("../../src/lib/db/core.ts");
const { createProviderConnection } = await import("../../src/lib/db/providers.ts");
const { createEmbeddingResponse } = await import("../../src/lib/embeddings/service.ts");
test.after(() => {
core.resetDbInstance();
rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
});
test("LOCAL_PROVIDERS declares embedding serviceKind for lemonade and llama-cpp", () => {
assert.ok(LOCAL_PROVIDERS.lemonade.serviceKinds.includes("embedding"));
assert.ok(LOCAL_PROVIDERS["llama-cpp"].serviceKinds.includes("embedding"));
});
test("lemonade embedding registry exposes the local endpoint and curated harrier model", () => {
const provider = getEmbeddingProvider("lemonade");
assert.ok(provider);
assert.equal(provider.baseUrl, "http://localhost:13305/v1/embeddings");
assert.equal(provider.authType, "none");
assert.equal(provider.authHeader, "bearer");
assert.deepEqual(provider.models, [
{ id: "harrier-oss-v1-0.6b", name: "Harrier OSS v1 0.6B", dimensions: 1024 },
]);
assert.equal(getEmbeddingDimension("lemonade/harrier-oss-v1-0.6b"), 1024);
assert.equal(getEmbeddingDimension("harrier-oss-v1-0.6b"), 1024);
});
test("llama-cpp embedding registry exposes the local llama-server endpoint", () => {
const provider = getEmbeddingProvider("llama-cpp");
assert.ok(provider);
assert.equal(provider.baseUrl, "http://127.0.0.1:8080/v1/embeddings");
assert.equal(provider.authType, "none");
assert.equal(provider.authHeader, "bearer");
assert.deepEqual(provider.models, []);
});
test("parseEmbeddingModel resolves lemonade, llama-cpp, and aliases", () => {
assert.deepEqual(parseEmbeddingModel("lemonade/harrier-oss-v1-0.6b"), {
provider: "lemonade",
model: "harrier-oss-v1-0.6b",
});
// Bare curated model resolves automatically to lemonade
assert.deepEqual(parseEmbeddingModel("harrier-oss-v1-0.6b"), {
provider: "lemonade",
model: "harrier-oss-v1-0.6b",
});
// llama.cpp and aliases
assert.deepEqual(parseEmbeddingModel("llama-cpp/custom-embed"), {
provider: "llama-cpp",
model: "custom-embed",
});
assert.deepEqual(parseEmbeddingModel("llamacpp/custom-embed"), {
provider: "llama-cpp",
model: "custom-embed",
});
assert.deepEqual(parseEmbeddingModel("llama.cpp/custom-embed"), {
provider: "llama-cpp",
model: "custom-embed",
});
});
test("handleEmbedding forwards Authorization header when key is present on lemonade", async () => {
const originalFetch = globalThis.fetch;
let captured: {
url: string;
headers: Record<string, string>;
body: Record<string, unknown>;
} | null = null;
globalThis.fetch = async (url, options = {}) => {
captured = {
url: String(url),
headers: (options.headers as Record<string, string>) || {},
body: JSON.parse(String(options.body || "{}")),
};
return new Response(
JSON.stringify({
data: [{ object: "embedding", embedding: [0.01, -0.02, 0.03], index: 0 }],
usage: { prompt_tokens: 3, total_tokens: 3 },
}),
{ status: 200, headers: { "content-type": "application/json" } }
);
};
try {
const result = await handleEmbedding({
body: {
model: "lemonade/harrier-oss-v1-0.6b",
input: "Hello world",
},
credentials: {
apiKey: "lemonade",
providerSpecificData: { baseUrl: "http://192.168.31.147:13305" },
},
resolvedProvider: {
id: "lemonade",
baseUrl: "http://localhost:13305/v1/embeddings",
authType: "none",
authHeader: "bearer",
models: [{ id: "harrier-oss-v1-0.6b", name: "Harrier", dimensions: 1024 }],
},
resolvedModel: "harrier-oss-v1-0.6b",
log: null,
});
assert.equal(result.success, true);
assert.ok(captured);
assert.equal(captured.url, "http://192.168.31.147:13305/v1/embeddings");
assert.equal(captured.headers.Authorization, "Bearer lemonade");
assert.deepEqual(captured.body, {
model: "harrier-oss-v1-0.6b",
input: "Hello world",
});
assert.equal(result.data.object, "list");
assert.equal(result.data.data[0].object, "embedding");
assert.deepEqual(result.data.data[0].embedding, [0.01, -0.02, 0.03]);
assert.equal(result.data.usage.total_tokens, 3);
} finally {
globalThis.fetch = originalFetch;
}
});
test("handleEmbedding supports keyless requests on lemonade without error", async () => {
const originalFetch = globalThis.fetch;
let captured: { url: string; headers: Record<string, string> } | null = null;
globalThis.fetch = async (url, options = {}) => {
captured = {
url: String(url),
headers: (options.headers as Record<string, string>) || {},
};
return new Response(
JSON.stringify({
data: [{ object: "embedding", embedding: [0.1, 0.2], index: 0 }],
usage: { prompt_tokens: 1, total_tokens: 1 },
}),
{ status: 200, headers: { "content-type": "application/json" } }
);
};
try {
const result = await handleEmbedding({
body: {
model: "lemonade/harrier-oss-v1-0.6b",
input: "Hello",
},
credentials: null,
resolvedProvider: {
id: "lemonade",
baseUrl: "http://localhost:13305/v1/embeddings",
authType: "none",
authHeader: "bearer",
models: [],
},
resolvedModel: "harrier-oss-v1-0.6b",
log: null,
});
assert.equal(result.success, true);
assert.ok(captured);
assert.equal(captured.url, "http://localhost:13305/v1/embeddings");
assert.equal(captured.headers.Authorization, undefined);
} finally {
globalThis.fetch = originalFetch;
}
});
test("handleEmbedding normalizes diverse local base URLs properly", async () => {
const testCases = [
{ input: "http://192.168.31.147:13305", expected: "http://192.168.31.147:13305/v1/embeddings" },
{
input: "http://192.168.31.147:13305/v1",
expected: "http://192.168.31.147:13305/v1/embeddings",
},
{
input: "http://192.168.31.147:13305/api/v1",
expected: "http://192.168.31.147:13305/api/v1/embeddings",
},
{
input: "http://192.168.31.147:13305/embeddings",
expected: "http://192.168.31.147:13305/embeddings",
},
];
const originalFetch = globalThis.fetch;
try {
for (const { input, expected } of testCases) {
let capturedUrl = "";
globalThis.fetch = async (url) => {
capturedUrl = String(url);
return new Response(
JSON.stringify({
data: [{ object: "embedding", embedding: [0.1], index: 0 }],
usage: { prompt_tokens: 1, total_tokens: 1 },
}),
{ status: 200, headers: { "content-type": "application/json" } }
);
};
const result = await handleEmbedding({
body: { model: "lemonade/harrier-oss-v1-0.6b", input: "test" },
credentials: { providerSpecificData: { baseUrl: input } },
resolvedProvider: {
id: "lemonade",
baseUrl: "http://localhost:13305/v1/embeddings",
authType: "none",
authHeader: "bearer",
models: [],
},
resolvedModel: "harrier-oss-v1-0.6b",
log: null,
});
assert.equal(result.success, true);
assert.equal(capturedUrl, expected);
}
} finally {
globalThis.fetch = originalFetch;
}
});
test("createEmbeddingResponse hydrates configured Lemonade connection with Bearer key", async () => {
await createProviderConnection({
provider: "lemonade",
authType: "none",
name: "Lemonade LAN Server",
apiKey: "lemonade",
isActive: true,
providerSpecificData: { baseUrl: "http://192.168.31.147:13305" },
});
const originalFetch = globalThis.fetch;
let captured: {
url: string;
headers: Record<string, string>;
body: Record<string, unknown>;
} | null = null;
globalThis.fetch = async (url, options = {}) => {
captured = {
url: String(url),
headers: (options.headers as Record<string, string>) || {},
body: JSON.parse(String(options.body || "{}")),
};
return new Response(
JSON.stringify({
data: [{ object: "embedding", embedding: [0.0165, -0.07], index: 0 }],
usage: { prompt_tokens: 4, total_tokens: 4 },
}),
{ status: 200, headers: { "content-type": "application/json" } }
);
};
try {
const response = await createEmbeddingResponse({
model: "lemonade/harrier-oss-v1-0.6b",
input: ["Batch sentence one", "Batch sentence two"],
});
assert.equal(response.status, 200);
const body = (await response.json()) as {
object: string;
data: Array<{ embedding: number[] }>;
usage: { total_tokens: number };
};
assert.equal(body.object, "list");
assert.equal(body.data.length, 1);
assert.deepEqual(body.data[0].embedding, [0.0165, -0.07]);
assert.equal(body.usage.total_tokens, 4);
assert.ok(captured);
assert.equal(captured.url, "http://192.168.31.147:13305/v1/embeddings");
assert.equal(captured.headers.Authorization, "Bearer lemonade");
assert.deepEqual(captured.body, {
model: "harrier-oss-v1-0.6b",
input: ["Batch sentence one", "Batch sentence two"],
});
} finally {
globalThis.fetch = originalFetch;
}
});

View File

@@ -0,0 +1,903 @@
import { describe, it, beforeEach } from "node:test";
import assert from "node:assert/strict";
import {
SemanticCacheManager,
generateDirectHash,
resetSemanticCacheManager,
} from "../../open-sse/services/cache/semanticCacheManager.ts";
import { MemoryVectorStore } from "../../open-sse/services/cache/memoryVectorStore.ts";
import {
RedisVectorStore,
type RedisLike,
} from "../../open-sse/services/cache/redisVectorStore.ts";
import {
cosineSimilarity,
dotProduct,
l2Normalize,
} from "../../open-sse/services/cache/vectorStore.ts";
import {
normalizeConversationForEmbedding,
generateEmbeddingWithTimeout,
} from "../../open-sse/services/cache/embeddingClient.ts";
import { resolveSemanticCacheConfig } from "../../open-sse/config/semanticCacheConfig.ts";
describe("Semantic Cache — Dual-Layer Architecture", () => {
beforeEach(() => {
resetSemanticCacheManager(null);
});
describe("Vector Math & Normalization", () => {
it("computes cosine similarity accurately", () => {
const a = [1, 0, 0];
const b = [1, 0, 0];
const c = [0, 1, 0];
const d = [0.7071, 0.7071, 0];
assert.equal(Math.round(cosineSimilarity(a, b) * 1000) / 1000, 1);
assert.equal(cosineSimilarity(a, c), 0);
assert.ok(cosineSimilarity(a, d) > 0.7 && cosineSimilarity(a, d) < 0.71);
assert.equal(dotProduct([1, 2], [3, 4]), 11);
});
it("handles zero vectors and empty arrays gracefully", () => {
assert.equal(cosineSimilarity([], []), 0);
assert.equal(cosineSimilarity([0, 0], [1, 2]), 0);
});
it("resolves config defaults and overrides properly", () => {
const conf = resolveSemanticCacheConfig({ similarityThreshold: 0.85 });
assert.equal(conf.similarityThreshold, 0.85);
assert.equal(conf.backend, "memory");
});
it("l2Normalize creates unit vector", () => {
const v = [3, 4];
const norm = l2Normalize(v);
assert.equal(Math.round(norm[0] * 10) / 10, 0.6);
assert.equal(Math.round(norm[1] * 10) / 10, 0.8);
const magnitude = Math.sqrt(norm[0] ** 2 + norm[1] ** 2);
assert.equal(Math.round(magnitude * 1000) / 1000, 1);
});
});
describe("Conversation Normalization", () => {
it("extracts clean text from string and array messages", () => {
const msgs = [
{ role: "system", content: "You are a helpful assistant" },
{ role: "user", content: "What is the capital of France?" },
{ role: "assistant", content: "Paris" },
{ role: "user", content: "What is its population?" },
];
const textWithSystem = normalizeConversationForEmbedding(msgs, {
excludeSystemPrompt: false,
historyDepth: 3,
});
assert.ok(textWithSystem.includes("user: What is its population?"));
assert.ok(textWithSystem.includes("assistant: Paris"));
const textNoSystem = normalizeConversationForEmbedding(msgs, {
excludeSystemPrompt: true,
historyDepth: 3,
});
assert.ok(!textNoSystem.includes("system:"));
});
it("handles multipart content objects", () => {
const msgs = [
{
role: "user",
content: [
{ type: "text", text: "Hello" },
{ type: "text", text: "World" },
],
},
];
const text = normalizeConversationForEmbedding(msgs);
assert.equal(text, "user: Hello World");
});
});
describe("Layer 1: Exact Match (Direct Hash)", () => {
it("bypasses embedding generation on exact match (0 embedding latency)", async () => {
let embeddingCalls = 0;
const mockEmbeddingGenerator = async () => {
embeddingCalls++;
return { embedding: [1, 0, 0], inputTokens: 5 };
};
const manager = new SemanticCacheManager(
{ enabled: true },
new MemoryVectorStore(),
mockEmbeddingGenerator
);
const requestBody = {
model: "gpt-4o",
messages: [{ role: "user", content: "How do I reverse a string in JS?" }],
temperature: 0,
};
const responsePayload = {
id: "chatcmpl-test-1",
choices: [
{ message: { role: "assistant", content: "Use str.split('').reverse().join('')" } },
],
usage: { prompt_tokens: 10, completion_tokens: 15 },
};
// 1. Store response
await manager.store({
body: requestBody,
response: responsePayload,
model: "gpt-4o",
provider: "openai",
tokensSaved: 25,
});
// Reset embedding counter after store
embeddingCalls = 0;
// 2. Lookup identical query -> Layer 1 exact match
const result = await manager.lookup({
body: requestBody,
model: "gpt-4o",
provider: "openai",
});
assert.equal(result.hit, true, "Should hit cache");
assert.equal(result.type, "exact", "Should be an exact match hit");
assert.equal(embeddingCalls, 0, "Exact match MUST NOT call embedding generator");
assert.equal(result.tokensSaved, 25);
});
it("isolates direct hashes by apiKeyId (#3740)", () => {
const conv = [{ role: "user", content: "test query" }];
const hashA = generateDirectHash("gpt-4", conv, 0, 1, { apiKeyId: "user-a" });
const hashB = generateDirectHash("gpt-4", conv, 0, 1, { apiKeyId: "user-b" });
const hashAnonymous = generateDirectHash("gpt-4", conv, 0, 1);
assert.notEqual(hashA, hashB);
assert.notEqual(hashA, hashAnonymous);
assert.ok(hashA.startsWith("user-a."));
});
});
describe("Layer 2: Semantic Similarity Match", () => {
it("hits cache when prompt is semantically similar above threshold", async () => {
const embeddings: Record<string, number[]> = {
"user: What is the capital of France?": [0.95, 0.31, 0],
"user: Tell me the capital city of France": [0.94, 0.34, 0],
"user: How to make a chocolate cake?": [0.1, 0.99, 0],
};
const mockEmbeddingGenerator = async (text: string) => {
const vec = embeddings[text] || [0.5, 0.5, 0];
return { embedding: vec, inputTokens: 8 };
};
const manager = new SemanticCacheManager(
{ enabled: true, similarityThreshold: 0.8 },
new MemoryVectorStore(),
mockEmbeddingGenerator
);
// 1. Store first query
await manager.store({
body: {
model: "gpt-4o",
messages: [{ role: "user", content: "What is the capital of France?" }],
temperature: 0,
},
response: {
id: "chatcmpl-paris",
choices: [{ message: { role: "assistant", content: "The capital of France is Paris." } }],
usage: { prompt_tokens: 8, completion_tokens: 10 },
},
model: "gpt-4o",
provider: "openai",
tokensSaved: 18,
});
// 2. Query with different wording (synonymous)
const similarResult = await manager.lookup({
body: {
model: "gpt-4o",
messages: [{ role: "user", content: "Tell me the capital city of France" }],
temperature: 0,
},
model: "gpt-4o",
provider: "openai",
});
assert.equal(similarResult.hit, true, "Similar query should hit cache");
assert.equal(similarResult.type, "semantic", "Should be a semantic hit");
assert.ok(
(similarResult.similarity ?? 0) >= 0.8,
`Similarity score ${similarResult.similarity} should be >= 0.8`
);
// 3. Query with completely different topic
const differentResult = await manager.lookup({
body: {
model: "gpt-4o",
messages: [{ role: "user", content: "How to make a chocolate cake?" }],
temperature: 0,
},
model: "gpt-4o",
provider: "openai",
});
assert.equal(differentResult.hit, false, "Unrelated query should miss cache");
});
it("respects per-request threshold override via x-omniroute-cache-threshold", async () => {
// 0.85 similarity between vectors
const mockEmbeddingGenerator = async (text: string) => {
if (text.includes("query-1")) return { embedding: [1, 0, 0], inputTokens: 5 };
return { embedding: [0.85, 0.5268, 0], inputTokens: 5 }; // dot product = 0.85
};
const manager = new SemanticCacheManager(
{ enabled: true, similarityThreshold: 0.8 },
new MemoryVectorStore(),
mockEmbeddingGenerator
);
await manager.store({
body: { model: "gpt-4o", messages: [{ role: "user", content: "query-1" }], temperature: 0 },
response: { id: "res-1", choices: [{ message: { role: "assistant", content: "ans-1" } }] },
model: "gpt-4o",
provider: "openai",
});
// Normal threshold 0.8: similarity 0.85 >= 0.8 -> HIT
const hitNormal = await manager.lookup({
body: { model: "gpt-4o", messages: [{ role: "user", content: "query-2" }], temperature: 0 },
model: "gpt-4o",
provider: "openai",
});
assert.equal(hitNormal.hit, true);
// Strict per-request threshold 0.95: similarity 0.85 < 0.95 -> MISS
const missStrict = await manager.lookup({
body: { model: "gpt-4o", messages: [{ role: "user", content: "query-2" }], temperature: 0 },
headers: { "x-omniroute-cache-threshold": "0.95" },
model: "gpt-4o",
provider: "openai",
});
assert.equal(missStrict.hit, false);
});
});
describe("Request Overrides & Bypasses", () => {
it("respects Cache-Control: no-cache and x-omniroute-no-cache: true", async () => {
const manager = new SemanticCacheManager({ enabled: true }, new MemoryVectorStore());
const body = {
model: "gpt-4",
messages: [{ role: "user", content: "test" }],
temperature: 0,
};
await manager.store({
body,
response: { id: "1", choices: [] },
model: "gpt-4",
provider: "openai",
});
const bypassedX = await manager.lookup({
body,
headers: { "x-omniroute-no-cache": "true" },
model: "gpt-4",
provider: "openai",
});
assert.equal(bypassedX.hit, false);
assert.equal(bypassedX.bypassed, true);
const bypassedCC = await manager.lookup({
body,
headers: { "cache-control": "no-cache" },
model: "gpt-4",
provider: "openai",
});
assert.equal(bypassedCC.hit, false);
assert.equal(bypassedCC.bypassed, true);
});
it("respects x-omniroute-cache-type: direct and x-omniroute-cache-type: semantic", async () => {
let semCalls = 0;
const mockEmbeddingGenerator = async () => {
semCalls++;
return { embedding: [1, 0], inputTokens: 2 };
};
const manager = new SemanticCacheManager(
{ enabled: true },
new MemoryVectorStore(),
mockEmbeddingGenerator
);
// Direct-only mode skips semantic search completely on miss
const result = await manager.lookup({
body: { model: "m", messages: [{ role: "user", content: "new" }], temperature: 0 },
headers: { "x-omniroute-cache-type": "direct" },
model: "m",
provider: "p",
});
assert.equal(result.hit, false);
assert.equal(semCalls, 0, "direct mode must not call embedding generator");
});
it("respects x-omniroute-cache-no-store: true", async () => {
const manager = new SemanticCacheManager({ enabled: true }, new MemoryVectorStore());
const body = {
model: "gpt-4",
messages: [{ role: "user", content: "no-store-test" }],
temperature: 0,
};
await manager.store({
body,
headers: { "x-omniroute-cache-no-store": "true" },
response: { id: "1", choices: [] },
model: "gpt-4",
provider: "openai",
});
const check = await manager.lookup({
body,
model: "gpt-4",
provider: "openai",
});
assert.equal(check.hit, false, "Entry should not have been stored");
});
});
describe("Streaming SSE Replay", () => {
it("synthesizes valid OpenAI SSE frames ending in data: [DONE]", () => {
const manager = new SemanticCacheManager({ enabled: true });
const entry = {
id: "test",
hash: "h",
promptText: "hi",
model: "gpt-4o",
provider: "openai",
response: {
id: "chatcmpl-stream-test",
created: 123456789,
model: "gpt-4o",
choices: [
{
index: 0,
message: { role: "assistant", content: "Streaming answer content" },
finish_reason: "stop",
},
],
},
tokensSaved: 15,
createdAt: Date.now(),
expiresAt: Date.now() + 60000,
};
const sse = manager.synthesizeSseFromEntry(entry);
assert.ok(sse.startsWith("data: "));
assert.ok(sse.includes("Streaming answer content"));
assert.ok(sse.trimEnd().endsWith("data: [DONE]"));
});
});
describe("MemoryVectorStore Operations & Eviction", () => {
it("evicts oldest entries when exceeding maxEntries", async () => {
const store = new MemoryVectorStore({ maxEntries: 2 });
const now = Date.now();
await store.set(
{
id: "1",
hash: "h1",
promptText: "p1",
model: "m",
provider: "p",
response: {},
tokensSaved: 1,
createdAt: now,
expiresAt: now + 100000,
},
100000
);
await store.set(
{
id: "2",
hash: "h2",
promptText: "p2",
model: "m",
provider: "p",
response: {},
tokensSaved: 1,
createdAt: now,
expiresAt: now + 100000,
},
100000
);
await store.set(
{
id: "3",
hash: "h3",
promptText: "p3",
model: "m",
provider: "p",
response: {},
tokensSaved: 1,
createdAt: now,
expiresAt: now + 100000,
},
100000
);
// Oldest entry 1 should have been evicted
assert.equal(await store.get("1"), null);
assert.notEqual(await store.get("2"), null);
assert.notEqual(await store.get("3"), null);
const stats = await store.getStats();
assert.equal(stats.entries, 2);
});
it("expires entries past TTL", async () => {
const store = new MemoryVectorStore();
const now = Date.now();
await store.set(
{
id: "expired",
hash: "hexp",
promptText: "p",
model: "m",
provider: "p",
response: {},
tokensSaved: 1,
createdAt: now - 5000,
expiresAt: now - 1000, // already expired
},
-1000
);
assert.equal(await store.get("expired"), null);
assert.equal(await store.getByHash("hexp"), null);
});
});
describe("RedisVectorStore with Mock Client & Fail-Open", () => {
it("handles Redis get, set, smembers and fails open on connection errors", async () => {
const redisStorage = new Map<string, string>();
const setStorage = new Map<string, Set<string>>();
const mockClient: RedisLike = {
get: async (k: string) => redisStorage.get(k) || null,
set: async (k: string, v: string) => {
redisStorage.set(k, v);
return "OK";
},
del: async (...keys: string[]) => {
let count = 0;
for (const k of keys) {
if (redisStorage.delete(k)) count++;
}
return count;
},
sadd: async (k: string, ...members: string[]) => {
if (!setStorage.has(k)) setStorage.set(k, new Set());
const s = setStorage.get(k)!;
let added = 0;
for (const m of members) {
if (!s.has(m)) {
s.add(m);
added++;
}
}
return added;
},
srem: async (k: string, ...members: string[]) => {
const s = setStorage.get(k);
if (!s) return 0;
let removed = 0;
for (const m of members) {
if (s.delete(m)) removed++;
}
return removed;
},
smembers: async (k: string) => {
const s = setStorage.get(k);
return s ? Array.from(s) : [];
},
mget: async (...keys: string[]) => {
return keys.map((k) => redisStorage.get(k) || null);
},
keys: async (_p: string) => Array.from(redisStorage.keys()),
};
const redisStore = new RedisVectorStore({ client: mockClient });
const entry = {
id: "redis-entry-1",
hash: "hash-redis",
embedding: [1, 0, 0],
promptText: "hello redis",
model: "gpt-4",
provider: "openai",
response: { id: "res" },
tokensSaved: 10,
createdAt: Date.now(),
expiresAt: Date.now() + 60000,
};
await redisStore.set(entry, 60000);
const fetched = await redisStore.getByHash("hash-redis");
assert.ok(fetched);
assert.equal(fetched.promptText, "hello redis");
const search = await redisStore.searchNearest([1, 0, 0], { model: "gpt-4" }, 0.8);
assert.equal(search.length, 1);
assert.equal(search[0].entry.id, "redis-entry-1");
// Test fail-open resilience when client errors
const failingClient: RedisLike = {
get: async () => {
throw new Error("ECONNREFUSED");
},
set: async () => {
throw new Error("ECONNREFUSED");
},
del: async () => {
throw new Error("ECONNREFUSED");
},
sadd: async () => {
throw new Error("ECONNREFUSED");
},
srem: async () => {
throw new Error("ECONNREFUSED");
},
smembers: async () => {
throw new Error("ECONNREFUSED");
},
mget: async () => {
throw new Error("ECONNREFUSED");
},
keys: async () => {
throw new Error("ECONNREFUSED");
},
};
const brokenStore = new RedisVectorStore({ client: failingClient });
// Should NOT throw, but return null/empty
assert.equal(await brokenStore.get("any"), null);
assert.equal(await brokenStore.getByHash("any"), null);
const emptyResults = await brokenStore.searchNearest([1, 0], {}, 0.8);
assert.deepEqual(emptyResults, []);
});
});
describe("Graceful Degradation", () => {
it("fails open when embedding generation times out or throws", async () => {
const failingGenerator = async () => {
throw new Error("Embedding upstream 503 Service Unavailable");
};
const manager = new SemanticCacheManager(
{ enabled: true },
new MemoryVectorStore(),
failingGenerator
);
const result = await manager.lookup({
body: { model: "gpt-4", messages: [{ role: "user", content: "test" }], temperature: 0 },
model: "gpt-4",
provider: "openai",
});
assert.equal(result.hit, false, "Embedding failure must fail open cleanly");
});
it("handles empty input in generateEmbeddingWithTimeout", async () => {
const res = await generateEmbeddingWithTimeout("", async () => null);
assert.equal(res, null);
});
it("times out uncooperative generators that ignore AbortSignal via Promise.race", async () => {
// Generator that never resolves and completely ignores AbortSignal
const hangingGenerator = () =>
new Promise<{ embedding: number[]; inputTokens: number }>(() => {});
const t0 = Date.now();
const res = await generateEmbeddingWithTimeout("hello", hangingGenerator, { timeoutMs: 50 });
const elapsed = Date.now() - t0;
assert.equal(res, null, "Should return null on timeout");
assert.ok(elapsed >= 45 && elapsed < 500, `Should resolve around 50ms, took ${elapsed}ms`);
});
});
describe("Multi-Tenant Partition Isolation", () => {
it("isolates anonymous requests from authenticated entries in semantic search", async () => {
const store = new MemoryVectorStore();
const manager = new SemanticCacheManager(
{ enabled: true, similarityThreshold: 0.8 },
store,
async () => ({ embedding: [1, 0], inputTokens: 5 })
);
// Store entry under apiKeyId: "tenant-a"
await manager.store({
body: {
model: "gpt-4",
messages: [{ role: "user", content: "sensitive data" }],
temperature: 0,
},
response: { choices: [{ message: { content: "secret answer" } }] },
model: "gpt-4",
provider: "openai",
apiKeyId: "tenant-a",
tokensSaved: 10,
});
// Anonymous query (no apiKeyId) should MISS
const anonResult = await manager.lookup({
body: {
model: "gpt-4",
messages: [{ role: "user", content: "sensitive data paraphrase" }],
temperature: 0,
},
model: "gpt-4",
provider: "openai",
});
assert.equal(anonResult.hit, false, "Anonymous query must NOT hit tenant-a cached entry");
// Another tenant query should MISS
const otherTenantResult = await manager.lookup({
body: {
model: "gpt-4",
messages: [{ role: "user", content: "sensitive data paraphrase" }],
temperature: 0,
},
model: "gpt-4",
provider: "openai",
apiKeyId: "tenant-b",
});
assert.equal(
otherTenantResult.hit,
false,
"Tenant-b query must NOT hit tenant-a cached entry"
);
// Same tenant query should HIT
const sameTenantResult = await manager.lookup({
body: {
model: "gpt-4",
messages: [{ role: "user", content: "sensitive data paraphrase" }],
temperature: 0,
},
model: "gpt-4",
provider: "openai",
apiKeyId: "tenant-a",
});
assert.equal(sameTenantResult.hit, true, "Tenant-a query must HIT tenant-a cached entry");
});
});
describe("MemoryVectorStore Hash Index Consistency", () => {
it("replaces older entry when inserting a new entry with identical hash", async () => {
const store = new MemoryVectorStore();
const now = Date.now();
await store.set(
{
id: "id-old",
hash: "shared-hash-1",
promptText: "prompt 1",
model: "m",
provider: "p",
response: { v: 1 },
tokensSaved: 10,
createdAt: now,
expiresAt: now + 60000,
},
60000
);
// Insert newer entry with same hash but new ID
await store.set(
{
id: "id-new",
hash: "shared-hash-1",
promptText: "prompt 1 updated",
model: "m",
provider: "p",
response: { v: 2 },
tokensSaved: 15,
createdAt: now + 100,
expiresAt: now + 60000,
},
60000
);
// Old entry should be removed from store
assert.equal(await store.get("id-old"), null);
// New entry should be accessible directly and by hash
const byHash = await store.getByHash("shared-hash-1");
assert.equal(byHash?.id, "id-new");
assert.equal((byHash?.response as { v: number }).v, 2);
// Deleting the old ID must not delete the hash mapping for the new ID
await store.delete("id-old");
const byHashAfterOldDelete = await store.getByHash("shared-hash-1");
assert.equal(byHashAfterOldDelete?.id, "id-new");
});
});
describe("RedisVectorStore Stale Set Pruning & Conditional Hash Deletion", () => {
it("prunes expired / missing entries from candidate sets and decrements getStats", async () => {
const redisStorage = new Map<string, string>();
const setStorage = new Map<string, Set<string>>();
const mockClient: RedisLike = {
get: async (k: string) => redisStorage.get(k) || null,
set: async (k: string, v: string) => {
redisStorage.set(k, v);
},
del: async (...keys: string[]) => {
keys.forEach((k) => redisStorage.delete(k));
return keys.length;
},
sadd: async (k: string, ...members: string[]) => {
if (!setStorage.has(k)) setStorage.set(k, new Set());
const set = setStorage.get(k)!;
members.forEach((m) => set.add(m));
return members.length;
},
srem: async (k: string, ...members: string[]) => {
const set = setStorage.get(k);
if (!set) return 0;
let count = 0;
members.forEach((m) => {
if (set.delete(m)) count++;
});
return count;
},
smembers: async (k: string) => Array.from(setStorage.get(k) || []),
mget: async (...keys: string[]) => keys.map((k) => redisStorage.get(k) || null),
};
const store = new RedisVectorStore({ client: mockClient, keyPrefix: "test:" });
const now = Date.now();
// Store entry 1 (active)
await store.set(
{
id: "active-1",
hash: "hash-active",
embedding: [1, 0],
promptText: "active prompt",
model: "gpt-4",
provider: "openai",
response: { text: "active" },
tokensSaved: 10,
createdAt: now,
expiresAt: now + 10000,
},
10000
);
// Store entry 2 (expired)
await store.set(
{
id: "expired-2",
hash: "hash-expired",
embedding: [1, 0],
promptText: "expired prompt",
model: "gpt-4",
provider: "openai",
response: { text: "expired" },
tokensSaved: 10,
createdAt: now - 5000,
expiresAt: now - 1000,
},
-1000
);
// Simulate entry 3 that was deleted from Redis keys directly (e.g. TTL expired naturally in Redis)
await mockClient.sadd!("test:all_ids", "ghost-3");
await mockClient.sadd!("test:model:gpt-4", "ghost-3");
// getStats should count only living entries and prune expired/ghost entries
const stats = await store.getStats();
assert.equal(stats.entries, 1, "Only active-1 should be counted as live");
// searchNearest should also prune stale entries and return only active-1
const nearest = await store.searchNearest([1, 0], { model: "gpt-4" }, 0.8);
assert.equal(nearest.length, 1);
assert.equal(nearest[0].entry.id, "active-1");
});
it("conditionally deletes hash mapping only if matching the entry being deleted", async () => {
const redisStorage = new Map<string, string>();
const setStorage = new Map<string, Set<string>>();
const mockClient: RedisLike = {
get: async (k: string) => redisStorage.get(k) || null,
set: async (k: string, v: string) => {
redisStorage.set(k, v);
},
del: async (...keys: string[]) => {
keys.forEach((k) => redisStorage.delete(k));
return keys.length;
},
sadd: async (k: string, ...members: string[]) => {
if (!setStorage.has(k)) setStorage.set(k, new Set());
members.forEach((m) => setStorage.get(k)!.add(m));
return members.length;
},
srem: async (k: string, ...members: string[]) => {
const set = setStorage.get(k);
if (!set) return 0;
let count = 0;
members.forEach((m) => {
if (set.delete(m)) count++;
});
return count;
},
smembers: async (k: string) => Array.from(setStorage.get(k) || []),
mget: async (...keys: string[]) => keys.map((k) => redisStorage.get(k) || null),
};
const store = new RedisVectorStore({ client: mockClient, keyPrefix: "test:" });
const now = Date.now();
// Store entry 1
await store.set(
{
id: "id-1",
hash: "shared-hash",
embedding: [1, 0],
promptText: "prompt",
model: "gpt-4",
provider: "openai",
response: {},
tokensSaved: 5,
createdAt: now,
expiresAt: now + 60000,
},
60000
);
// Overwrite with entry 2 under same hash
await store.set(
{
id: "id-2",
hash: "shared-hash",
embedding: [1, 0],
promptText: "prompt",
model: "gpt-4",
provider: "openai",
response: {},
tokensSaved: 5,
createdAt: now + 10,
expiresAt: now + 60000,
},
60000
);
// Now attempt to delete id-1 (older entry)
await store.delete("id-1");
// The hash mapping in Redis must STILL point to id-2!
const hashKey = "test:hash:shared-hash";
assert.equal(
redisStorage.get(hashKey),
"id-2",
"Hash mapping must not be deleted by older entry deletion"
);
});
});
});