| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import crypto from "node:crypto"; |
| import { LRUCache } from "./cacheLayer"; |
| import { getDbInstance } from "./db/core"; |
|
|
| |
|
|
| let memoryCache; |
| let stats = { hits: 0, misses: 0, tokensSaved: 0 }; |
|
|
| function getMemoryCache() { |
| if (!memoryCache) { |
| memoryCache = new LRUCache({ |
| maxSize: parseInt(process.env.SEMANTIC_CACHE_MAX_SIZE || "500", 10), |
| defaultTTL: parseInt(process.env.SEMANTIC_CACHE_TTL_MS || "3600000", 10), |
| }); |
| } |
| return memoryCache; |
| } |
|
|
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| export function generateSignature(model, messages, temperature = 0, topP = 1) { |
| const payload = JSON.stringify({ |
| model, |
| messages: normalizeMessages(messages), |
| temperature, |
| top_p: topP, |
| }); |
| return crypto.createHash("sha256").update(payload).digest("hex"); |
| } |
|
|
| |
| |
| |
| |
| function normalizeMessages(messages) { |
| if (!Array.isArray(messages)) return []; |
| return messages.map((m) => ({ |
| role: m.role || "user", |
| content: typeof m.content === "string" ? m.content : JSON.stringify(m.content), |
| })); |
| } |
|
|
| |
|
|
| |
| |
| |
| |
| |
| |
| export function getCachedResponse(signature) { |
| |
| const memResult = getMemoryCache().get(signature); |
| if (memResult) { |
| stats.hits++; |
| stats.tokensSaved += memResult.tokensSaved || 0; |
| return memResult.response; |
| } |
|
|
| |
| try { |
| const db = getDbInstance(); |
| const row = db |
| .prepare( |
| "SELECT response, tokens_saved FROM semantic_cache WHERE signature = ? AND expires_at > datetime('now')" |
| ) |
| .get(signature); |
|
|
| if (row) { |
| const parsed = JSON.parse(row.response); |
| |
| getMemoryCache().set(signature, { |
| response: parsed, |
| tokensSaved: row.tokens_saved, |
| }); |
| |
| db.prepare("UPDATE semantic_cache SET hit_count = hit_count + 1 WHERE signature = ?").run( |
| signature |
| ); |
|
|
| stats.hits++; |
| stats.tokensSaved += row.tokens_saved || 0; |
| return parsed; |
| } |
| } catch { |
| |
| } |
|
|
| stats.misses++; |
| return null; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| export function setCachedResponse(signature, model, response, tokensSaved = 0, ttlMs = 3600000) { |
| const ttl = parseInt(process.env.SEMANTIC_CACHE_TTL_MS || String(ttlMs), 10); |
|
|
| |
| getMemoryCache().set(signature, { response, tokensSaved }, ttl); |
|
|
| |
| try { |
| const db = getDbInstance(); |
| const id = crypto.randomUUID(); |
| const promptHash = signature.slice(0, 16); |
| const now = new Date().toISOString(); |
| const expiresAt = new Date(Date.now() + ttl).toISOString(); |
|
|
| db.prepare( |
| `INSERT OR REPLACE INTO semantic_cache (id, signature, model, prompt_hash, response, tokens_saved, hit_count, created_at, expires_at) |
| VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?)` |
| ).run(id, signature, model, promptHash, JSON.stringify(response), tokensSaved, now, expiresAt); |
| } catch { |
| |
| } |
| } |
|
|
| |
|
|
| |
| |
| |
| |
| export function cleanExpiredEntries() { |
| try { |
| const db = getDbInstance(); |
| const result = db |
| .prepare("DELETE FROM semantic_cache WHERE expires_at <= datetime('now')") |
| .run(); |
| return result.changes; |
| } catch { |
| return 0; |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| export function invalidateByModel(model: string): number { |
| getMemoryCache().clear(); |
| try { |
| const db = getDbInstance(); |
| const result = db.prepare("DELETE FROM semantic_cache WHERE model = ?").run(model); |
| return result.changes || 0; |
| } catch { |
| return 0; |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| export function invalidateBySignature(signature: string): boolean { |
| getMemoryCache().delete(signature); |
| try { |
| const db = getDbInstance(); |
| const result = db.prepare("DELETE FROM semantic_cache WHERE signature = ?").run(signature); |
| return (result.changes || 0) > 0; |
| } catch { |
| return false; |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| export function invalidateStale(maxAgeMs: number): number { |
| getMemoryCache().clear(); |
| try { |
| const db = getDbInstance(); |
| const cutoff = new Date(Date.now() - maxAgeMs).toISOString(); |
| const result = db.prepare("DELETE FROM semantic_cache WHERE created_at < ?").run(cutoff); |
| return result.changes || 0; |
| } catch { |
| return 0; |
| } |
| } |
|
|
| |
|
|
| let _cleanupTimer: ReturnType<typeof setInterval> | null = null; |
|
|
| |
| |
| |
| |
| export function startAutoCleanup(intervalMs = 300_000): void { |
| stopAutoCleanup(); |
| _cleanupTimer = setInterval(() => { |
| const removed = cleanExpiredEntries(); |
| if (removed > 0) { |
| console.log(`[SemanticCache] Auto-cleaned ${removed} expired entries`); |
| } |
| }, intervalMs); |
| } |
|
|
| |
| |
| |
| export function stopAutoCleanup(): void { |
| if (_cleanupTimer) { |
| clearInterval(_cleanupTimer); |
| _cleanupTimer = null; |
| } |
| } |
|
|
| |
| |
| |
| export function clearCache() { |
| getMemoryCache().clear(); |
| try { |
| const db = getDbInstance(); |
| db.prepare("DELETE FROM semantic_cache").run(); |
| } catch { |
| |
| } |
| stats = { hits: 0, misses: 0, tokensSaved: 0 }; |
| } |
|
|
| |
|
|
| |
| |
| |
| export function getCacheStats() { |
| const memStats = getMemoryCache().getStats(); |
| let dbSize = 0; |
| try { |
| const db = getDbInstance(); |
| const row = db |
| .prepare("SELECT COUNT(*) as count FROM semantic_cache WHERE expires_at > datetime('now')") |
| .get(); |
| dbSize = row?.count || 0; |
| } catch { |
| |
| } |
|
|
| const total = stats.hits + stats.misses; |
| return { |
| memoryEntries: memStats.size, |
| dbEntries: dbSize, |
| hits: stats.hits, |
| misses: stats.misses, |
| hitRate: total > 0 ? ((stats.hits / total) * 100).toFixed(1) : "0.0", |
| tokensSaved: stats.tokensSaved, |
| }; |
| } |
|
|
| |
| |
| |
| |
| export function isCacheable(body, headers) { |
| if (headers?.get?.("x-omniroute-no-cache") === "true") return false; |
| if (body.stream !== false) return false; |
| if ((body.temperature ?? 0) !== 0) return false; |
| return true; |
| } |
|
|