llm-response-cache.ts
1 import crypto from 'node:crypto' 2 import { hmrSingleton } from '@/lib/shared-utils' 3 import type { AppSettings, Message } from '@/types' 4 5 export interface LlmResponseCacheConfig { 6 enabled: boolean 7 ttlMs: number 8 maxEntries: number 9 } 10 11 export interface LlmResponseCacheKeyInput { 12 provider: string 13 model: string 14 apiEndpoint?: string | null 15 systemPrompt?: string 16 message: string 17 imagePath?: string 18 imageUrl?: string 19 attachedFiles?: string[] 20 history: Message[] 21 } 22 23 export interface LlmResponseCacheHit { 24 key: string 25 text: string 26 provider: string 27 model: string 28 createdAt: number 29 ageMs: number 30 hits: number 31 } 32 33 interface LlmResponseCacheEntry { 34 key: string 35 text: string 36 provider: string 37 model: string 38 createdAt: number 39 expiresAt: number 40 hits: number 41 } 42 43 const DEFAULT_ENABLED = true 44 const DEFAULT_TTL_SEC = 15 * 60 45 const DEFAULT_MAX_ENTRIES = 500 46 47 const MIN_TTL_SEC = 5 48 const MAX_TTL_SEC = 7 * 24 * 3600 49 const MIN_ENTRIES = 1 50 const MAX_ENTRIES = 20_000 51 52 const responseCache = hmrSingleton('__swarmclaw_llm_response_cache__', () => new Map<string, LlmResponseCacheEntry>()) 53 54 function normalizeText(value: unknown): string { 55 return typeof value === 'string' ? value.replace(/\s+/g, ' ').trim() : '' 56 } 57 58 function normalizeList(value: unknown): string[] { 59 if (!Array.isArray(value)) return [] 60 return value 61 .filter((entry): entry is string => typeof entry === 'string') 62 .map((entry) => entry.trim()) 63 .filter(Boolean) 64 } 65 66 function normalizeInt(value: unknown, fallback: number, min: number, max: number): number { 67 const parsed = typeof value === 'number' 68 ? value 69 : typeof value === 'string' 70 ? Number.parseInt(value, 10) 71 : Number.NaN 72 if (!Number.isFinite(parsed)) return fallback 73 return Math.max(min, Math.min(max, Math.trunc(parsed))) 74 } 75 76 function normalizeBool(value: unknown, fallback: boolean): boolean { 77 if (typeof value === 'boolean') return value 78 if (typeof value === 'string') { 79 const normalized = value.trim().toLowerCase() 80 if (['1', 'true', 'yes', 'on'].includes(normalized)) return true 81 if (['0', 'false', 'no', 'off'].includes(normalized)) return false 82 } 83 return fallback 84 } 85 86 function stableStringify(value: unknown): string { 87 if (value === null) return 'null' 88 const kind = typeof value 89 if (kind === 'number' || kind === 'boolean') return JSON.stringify(value) 90 if (kind === 'string') return JSON.stringify(value) 91 if (Array.isArray(value)) return `[${value.map((entry) => stableStringify(entry)).join(',')}]` 92 if (kind === 'object') { 93 const entries = Object.entries(value as Record<string, unknown>) 94 .filter(([, v]) => v !== undefined) 95 .sort(([a], [b]) => a.localeCompare(b)) 96 return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`).join(',')}}` 97 } 98 return JSON.stringify(String(value)) 99 } 100 101 function normalizeHistory(history: Message[]): Array<Record<string, unknown>> { 102 return history.map((entry) => ({ 103 role: entry.role, 104 text: normalizeText(entry.text), 105 kind: entry.kind || null, 106 imagePath: entry.imagePath || null, 107 imageUrl: entry.imageUrl || null, 108 attachedFiles: normalizeList(entry.attachedFiles), 109 replyToId: entry.replyToId || null, 110 })) 111 } 112 113 function trimToCapacity(maxEntries: number): void { 114 while (responseCache.size > maxEntries) { 115 const oldestKey = responseCache.keys().next().value as string | undefined 116 if (!oldestKey) break 117 responseCache.delete(oldestKey) 118 } 119 } 120 121 function moveToMostRecent(key: string, entry: LlmResponseCacheEntry): void { 122 responseCache.delete(key) 123 responseCache.set(key, entry) 124 } 125 126 export function resolveLlmResponseCacheConfig( 127 settings?: AppSettings | Record<string, unknown> | null, 128 ): LlmResponseCacheConfig { 129 const raw = settings && typeof settings === 'object' ? settings as Record<string, unknown> : {} 130 const ttlSec = normalizeInt(raw.responseCacheTtlSec, DEFAULT_TTL_SEC, MIN_TTL_SEC, MAX_TTL_SEC) 131 const maxEntries = normalizeInt(raw.responseCacheMaxEntries, DEFAULT_MAX_ENTRIES, MIN_ENTRIES, MAX_ENTRIES) 132 const enabled = normalizeBool(raw.responseCacheEnabled, DEFAULT_ENABLED) 133 return { 134 enabled, 135 ttlMs: ttlSec * 1000, 136 maxEntries, 137 } 138 } 139 140 export function buildLlmResponseCacheKey(input: LlmResponseCacheKeyInput): string { 141 const payload = { 142 provider: normalizeText(input.provider).toLowerCase(), 143 model: normalizeText(input.model), 144 apiEndpoint: normalizeText(input.apiEndpoint || ''), 145 systemPrompt: normalizeText(input.systemPrompt || ''), 146 message: normalizeText(input.message), 147 imagePath: normalizeText(input.imagePath || ''), 148 imageUrl: normalizeText(input.imageUrl || ''), 149 attachedFiles: normalizeList(input.attachedFiles), 150 history: normalizeHistory(Array.isArray(input.history) ? input.history : []), 151 } 152 const stable = stableStringify(payload) 153 return crypto.createHash('sha256').update(stable).digest('hex') 154 } 155 156 export function getCachedLlmResponse( 157 input: LlmResponseCacheKeyInput, 158 config: LlmResponseCacheConfig, 159 now = Date.now(), 160 ): LlmResponseCacheHit | null { 161 if (!config.enabled) return null 162 const key = buildLlmResponseCacheKey(input) 163 const found = responseCache.get(key) 164 if (!found) return null 165 if (now >= found.expiresAt) { 166 responseCache.delete(key) 167 return null 168 } 169 const next = { ...found, hits: found.hits + 1 } 170 moveToMostRecent(key, next) 171 return { 172 key, 173 text: next.text, 174 provider: next.provider, 175 model: next.model, 176 createdAt: next.createdAt, 177 ageMs: Math.max(0, now - next.createdAt), 178 hits: next.hits, 179 } 180 } 181 182 export function setCachedLlmResponse( 183 input: LlmResponseCacheKeyInput, 184 text: string, 185 config: LlmResponseCacheConfig, 186 now = Date.now(), 187 ): void { 188 if (!config.enabled) return 189 const normalizedText = normalizeText(text) 190 if (!normalizedText) return 191 const key = buildLlmResponseCacheKey(input) 192 const existing = responseCache.get(key) 193 const createdAt = existing?.createdAt ?? now 194 const entry: LlmResponseCacheEntry = { 195 key, 196 text: normalizedText, 197 provider: normalizeText(input.provider).toLowerCase(), 198 model: normalizeText(input.model), 199 createdAt, 200 expiresAt: now + config.ttlMs, 201 hits: existing?.hits ?? 0, 202 } 203 moveToMostRecent(key, entry) 204 trimToCapacity(config.maxEntries) 205 } 206 207 export function getLlmResponseCacheStats(now = Date.now()): { 208 entries: number 209 expired: number 210 oldestAgeMs: number 211 } { 212 let expired = 0 213 let oldestCreatedAt = Number.POSITIVE_INFINITY 214 for (const entry of responseCache.values()) { 215 if (entry.expiresAt <= now) expired++ 216 oldestCreatedAt = Math.min(oldestCreatedAt, entry.createdAt) 217 } 218 const oldestAgeMs = Number.isFinite(oldestCreatedAt) ? Math.max(0, now - oldestCreatedAt) : 0 219 return { 220 entries: responseCache.size, 221 expired, 222 oldestAgeMs, 223 } 224 } 225 226 export function clearLlmResponseCache(): void { 227 responseCache.clear() 228 }