/ src / lib / server / llm-response-cache.ts
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  }