| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import crypto from "crypto"; |
| import { LRUCache } from "./cacheLayer"; |
| import { getDbInstance } from "./db/core"; |
|
|
| type JsonRecord = Record<string, unknown>; |
|
|
| 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; |
| } |
|
|
| function ensureCacheMetricsTable() { |
| try { |
| const db = getDbInstance(); |
| db.prepare( |
| `CREATE TABLE IF NOT EXISTS cache_metrics ( |
| key TEXT PRIMARY KEY, |
| value INTEGER NOT NULL DEFAULT 0, |
| updated_at TEXT NOT NULL DEFAULT (datetime('now')) |
| )` |
| ).run(); |
| db.prepare( |
| `INSERT OR IGNORE INTO cache_metrics (key, value) VALUES ('hits', 0), ('misses', 0), ('tokens_saved', 0)` |
| ).run(); |
| } catch { |
| |
| } |
| } |
|
|
| function incrementMetric(metric: "hits" | "misses" | "tokens_saved", amount = 1) { |
| try { |
| const db = getDbInstance(); |
| db.prepare( |
| `UPDATE cache_metrics SET value = value + ?, updated_at = datetime('now') WHERE key = ?` |
| ).run(amount, metric); |
| } catch { |
| |
| } |
| } |
|
|
| function getMetricValue(metric: string): number { |
| try { |
| const db = getDbInstance(); |
| const row = db.prepare(`SELECT value FROM cache_metrics WHERE key = ?`).get(metric); |
| return row ? toNumber(asRecord(row).value, 0) : 0; |
| } catch { |
| return 0; |
| } |
| } |
|
|
| function getHeaderValue( |
| headers: { get?: (name: string) => string | null } | Record<string, unknown> | null | undefined, |
| name: string |
| ): string | null { |
| if (!headers) return null; |
|
|
| if (typeof headers.get === "function") { |
| return headers.get(name); |
| } |
|
|
| const needle = name.toLowerCase(); |
| for (const [key, value] of Object.entries(headers)) { |
| if (key.toLowerCase() !== needle) continue; |
| return typeof value === "string" ? value : null; |
| } |
|
|
| return null; |
| } |
|
|
| |
|
|
| let memoryCache: LRUCache | null = null; |
|
|
| function getMemoryCache() { |
| if (!memoryCache) { |
| memoryCache = new LRUCache({ |
| maxSize: parseInt(process.env.SEMANTIC_CACHE_MAX_SIZE || "100", 10), |
| maxBytes: parseInt(process.env.SEMANTIC_CACHE_MAX_BYTES || String(4 * 1024 * 1024), 10), |
| defaultTTL: parseInt(process.env.SEMANTIC_CACHE_TTL_MS || "1800000", 10), |
| }); |
| ensureCacheMetricsTable(); |
| } |
| 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) { |
| incrementMetric("hits"); |
| incrementMetric("tokens_saved", 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 record = asRecord(row); |
| const responsePayload = typeof record.response === "string" ? record.response : null; |
| if (!responsePayload) { |
| incrementMetric("misses"); |
| return null; |
| } |
| const parsed = JSON.parse(responsePayload); |
| const tokensSaved = toNumber(record.tokens_saved, 0); |
| |
| getMemoryCache().set(signature, { |
| response: parsed, |
| tokensSaved, |
| }); |
| |
| db.prepare("UPDATE semantic_cache SET hit_count = hit_count + 1 WHERE signature = ?").run( |
| signature |
| ); |
|
|
| incrementMetric("hits"); |
| incrementMetric("tokens_saved", tokensSaved); |
| return parsed; |
| } |
| } catch { |
| |
| } |
|
|
| incrementMetric("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); |
| if (_cleanupTimer && typeof _cleanupTimer === "object" && "unref" in _cleanupTimer) { |
| (_cleanupTimer as { unref?: () => void }).unref?.(); |
| } |
| } |
|
|
| |
| |
| |
| export function stopAutoCleanup(): void { |
| if (_cleanupTimer) { |
| clearInterval(_cleanupTimer); |
| _cleanupTimer = null; |
| } |
| } |
|
|
| export function cleanOldMetrics(retentionDays = 90): number { |
| try { |
| const db = getDbInstance(); |
| const cutoff = new Date(Date.now() - retentionDays * 86400000).toISOString(); |
| const result = db.prepare("DELETE FROM semantic_cache WHERE created_at < ?").run(cutoff); |
| return result.changes || 0; |
| } catch { |
| return 0; |
| } |
| } |
|
|
| |
| |
| |
| export function clearCache() { |
| getMemoryCache().clear(); |
| try { |
| const db = getDbInstance(); |
| db.prepare("DELETE FROM semantic_cache").run(); |
| db.prepare("UPDATE cache_metrics SET value = 0").run(); |
| } catch { |
| |
| } |
| } |
|
|
| 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 = toNumber(asRecord(row).count, 0); |
| } catch { |
| |
| } |
|
|
| const hits = getMetricValue("hits"); |
| const misses = getMetricValue("misses"); |
| const tokensSaved = getMetricValue("tokens_saved"); |
| const total = hits + misses; |
|
|
| return { |
| memoryEntries: memStats.size, |
| dbEntries: dbSize, |
| hits, |
| misses, |
| hitRate: total > 0 ? ((hits / total) * 100).toFixed(1) : "0.0", |
| tokensSaved, |
| }; |
| } |
|
|
| |
| |
| |
| |
| export function isCacheable(body, headers) { |
| if ((getHeaderValue(headers, "x-omniroute-no-cache") || "").toLowerCase() === "true") { |
| return false; |
| } |
| if (body.stream !== false) return false; |
| if ((body.temperature ?? 0) !== 0) return false; |
| return true; |
| } |
|
|