UNPKG

ai-functions

Version:

Core AI primitives for building intelligent applications

682 lines (582 loc) 17.5 kB
/** * Caching layer for embeddings and generations * * Provides content-addressable caching for embeddings and parameter-aware * caching for text/object generations with TTL support and LRU eviction. * * @packageDocumentation */ // ============================================================================ // Types // ============================================================================ /** * Cache entry with metadata for tracking and eviction */ export interface CacheEntry<T> { /** The cached value */ value: T /** When the entry was created */ createdAt: number /** When the entry was last accessed */ lastAccessedAt: number /** Number of times this entry has been accessed */ accessCount: number /** When the entry expires (if TTL is set) */ expiresAt?: number } /** * Options for cache operations */ export interface CacheOptions { /** Time-to-live in milliseconds */ ttl?: number /** Whether to bypass cache and force fresh result */ bypass?: boolean } /** * Cache statistics for monitoring */ export interface CacheStats { /** Number of cache hits */ hits: number /** Number of cache misses */ misses: number /** Hit rate (0-1) */ hitRate: number /** Current number of entries */ size: number } /** * Configuration options for MemoryCache */ export interface MemoryCacheOptions { /** Default TTL for entries in milliseconds */ defaultTTL?: number /** Maximum number of entries (enables LRU eviction) */ maxSize?: number /** Whether to refresh TTL on access (sliding window) */ slidingExpiration?: boolean /** Interval for cleanup of expired entries in milliseconds */ cleanupInterval?: number } // ============================================================================ // Cache Storage Interface // ============================================================================ /** * Abstract cache storage interface for pluggable backends */ export interface CacheStorage<T> { /** Get a value by key */ get(key: string): Promise<T | undefined> /** Set a value by key */ set(key: string, value: T, options?: CacheOptions): Promise<void> /** Check if a key exists */ has(key: string): Promise<boolean> /** Delete a key */ delete(key: string): Promise<void> /** Clear all entries */ clear(): Promise<void> /** Get the number of entries */ size(): Promise<number> /** Get all keys */ keys(): Promise<string[]> } // ============================================================================ // Memory Cache Implementation // ============================================================================ /** * In-memory cache implementation with TTL and LRU eviction support * * @deprecated Phase C Week 1 — `MemoryCache` has zero production callers in * primitives.org.ai (audited 2026-05-06; see `bd show aip-ibid`). New code * should use `cacheMiddleware` (for `wrapLanguageModel`) or * `embeddingCacheMiddleware` (for `wrapEmbeddingModel`) instead — both * compose with AI SDK 6's `wrapLanguageModel` / `wrapEmbeddingModel` and * carry per-call telemetry (TraceEvent emission) and budget tracking when * paired via `wrapForV3`. The `MemoryCache` class will be removed in the * Phase C semver bump alongside `EmbeddingCache` and `GenerationCache`. */ export class MemoryCache<T> implements CacheStorage<T> { private cache: Map<string, CacheEntry<T>> = new Map() private accessOrder: string[] = [] private options: MemoryCacheOptions private cleanupTimer?: ReturnType<typeof setInterval> constructor(options: MemoryCacheOptions = {}) { this.options = options // Start cleanup timer if interval is specified if (options.cleanupInterval && options.cleanupInterval > 0) { this.cleanupTimer = setInterval(() => { this.cleanup() }, options.cleanupInterval) } } /** * Get a value by key */ async get(key: string): Promise<T | undefined> { const entry = this.cache.get(key) if (!entry) { return undefined } // Check if expired if (entry.expiresAt && Date.now() > entry.expiresAt) { this.cache.delete(key) this.removeFromAccessOrder(key) return undefined } // Update access tracking entry.lastAccessedAt = Date.now() entry.accessCount++ // Update access order for LRU this.updateAccessOrder(key) // Sliding expiration - refresh TTL on access if (this.options.slidingExpiration && this.options.defaultTTL) { entry.expiresAt = Date.now() + this.options.defaultTTL } return entry.value } /** * Set a value by key */ async set(key: string, value: T, options?: CacheOptions): Promise<void> { const now = Date.now() const ttl = options?.ttl ?? this.options.defaultTTL const entry: CacheEntry<T> = { value, createdAt: now, lastAccessedAt: now, accessCount: 0, ...(ttl ? { expiresAt: now + ttl } : {}), } // Check if we need to evict (LRU) if (this.options.maxSize && this.cache.size >= this.options.maxSize && !this.cache.has(key)) { this.evictLRU() } this.cache.set(key, entry) this.updateAccessOrder(key) } /** * Check if a key exists */ async has(key: string): Promise<boolean> { const entry = this.cache.get(key) if (!entry) { return false } // Check if expired if (entry.expiresAt && Date.now() > entry.expiresAt) { this.cache.delete(key) this.removeFromAccessOrder(key) return false } return true } /** * Delete a key */ async delete(key: string): Promise<void> { this.cache.delete(key) this.removeFromAccessOrder(key) } /** * Clear all entries */ async clear(): Promise<void> { this.cache.clear() this.accessOrder = [] } /** * Get the number of entries (excluding expired) */ async size(): Promise<number> { await this.cleanup() return this.cache.size } /** * Get all keys */ async keys(): Promise<string[]> { await this.cleanup() return Array.from(this.cache.keys()) } /** * Get full entry with metadata */ async getEntry(key: string): Promise<CacheEntry<T> | undefined> { const entry = this.cache.get(key) if (!entry) { return undefined } // Check if expired if (entry.expiresAt && Date.now() > entry.expiresAt) { this.cache.delete(key) this.removeFromAccessOrder(key) return undefined } return entry } /** * Dispose cleanup timer */ dispose(): void { if (this.cleanupTimer) { clearInterval(this.cleanupTimer) delete this.cleanupTimer } } /** * Clean up expired entries */ private async cleanup(): Promise<void> { const now = Date.now() const keysToDelete: string[] = [] for (const [key, entry] of this.cache) { if (entry.expiresAt && now > entry.expiresAt) { keysToDelete.push(key) } } for (const key of keysToDelete) { this.cache.delete(key) this.removeFromAccessOrder(key) } } /** * Evict the least recently used entry */ private evictLRU(): void { if (this.accessOrder.length === 0) return const lruKey = this.accessOrder[0] if (lruKey) { this.cache.delete(lruKey) this.accessOrder.shift() } } /** * Update access order for LRU tracking */ private updateAccessOrder(key: string): void { this.removeFromAccessOrder(key) this.accessOrder.push(key) } /** * Remove a key from access order */ private removeFromAccessOrder(key: string): void { const index = this.accessOrder.indexOf(key) if (index > -1) { this.accessOrder.splice(index, 1) } } } // ============================================================================ // Hash / Key Generation // ============================================================================ /** * Generate a hash for cache keys * Uses a fast, non-cryptographic hash suitable for cache keys */ export function hashKey(input: unknown): string { const str = typeof input === 'string' ? input : stableStringify(input) // Simple djb2 hash let hash = 5381 for (let i = 0; i < str.length; i++) { hash = (hash << 5) + hash + str.charCodeAt(i) hash = hash & hash // Convert to 32bit integer } return (hash >>> 0).toString(36) } /** * Stringify an object with sorted keys for stable hashing */ function stableStringify(obj: unknown): string { if (obj === null || typeof obj !== 'object') { return JSON.stringify(obj) } if (Array.isArray(obj)) { return '[' + obj.map(stableStringify).join(',') + ']' } const keys = Object.keys(obj).sort() const pairs = keys.map((key) => { return JSON.stringify(key) + ':' + stableStringify((obj as Record<string, unknown>)[key]) }) return '{' + pairs.join(',') + '}' } /** * Cache key type */ export type CacheKeyType = 'embedding' | 'generation' /** * Create a cache key for a specific type and parameters */ export function createCacheKey(type: CacheKeyType, params: Record<string, unknown>): string { const hash = hashKey(params) return `${type}:${hash}` } // ============================================================================ // Embedding Cache // ============================================================================ /** * Options for embedding cache operations */ export interface EmbeddingCacheOptions { /** The embedding model used */ model: string } /** * Result from batch embedding cache lookup */ export interface BatchEmbeddingResult { /** Map of text to cached embedding */ hits: Record<string, number[]> /** Texts that were not in cache */ misses: string[] } /** * Specialized cache for embedding vectors * * @deprecated Phase C Week 1 — `EmbeddingCache` has zero production callers * in primitives.org.ai (audited 2026-05-06; see `bd show aip-ibid`). New * code should use `embeddingCacheMiddleware` (for `wrapEmbeddingModel`) — * it composes with AI SDK 6 directly and carries trace + budget telemetry * when paired via `wrapForV3`. Note: `embeddingCacheMiddleware` keys on the * whole batch, not per-text — callers wanting per-text caching should pass * stable per-text batches. Will be removed in the Phase C semver bump. */ export class EmbeddingCache { private storage: MemoryCache<number[]> private stats = { hits: 0, misses: 0 } constructor(options?: MemoryCacheOptions) { this.storage = new MemoryCache<number[]>(options) } /** * Get a cached embedding */ async get(content: string, options: EmbeddingCacheOptions): Promise<number[] | undefined> { const key = createCacheKey('embedding', { content, model: options.model }) const result = await this.storage.get(key) if (result) { this.stats.hits++ } else { this.stats.misses++ } return result } /** * Set a cached embedding */ async set(content: string, embedding: number[], options: EmbeddingCacheOptions): Promise<void> { const key = createCacheKey('embedding', { content, model: options.model }) await this.storage.set(key, embedding) } /** * Set multiple embeddings at once */ async setMany( texts: string[], embeddings: number[][], options: EmbeddingCacheOptions ): Promise<void> { for (let i = 0; i < texts.length; i++) { await this.set(texts[i]!, embeddings[i]!, options) } } /** * Get multiple embeddings, returning hits and misses */ async getMany(texts: string[], options: EmbeddingCacheOptions): Promise<BatchEmbeddingResult> { const hits: Record<string, number[]> = {} const misses: string[] = [] for (const text of texts) { const embedding = await this.get(text, options) if (embedding) { hits[text] = embedding // Note: hits counter already incremented in get() this.stats.hits-- // Avoid double counting } else { misses.push(text) this.stats.misses-- // Avoid double counting } } // Add back correct counts this.stats.hits += Object.keys(hits).length this.stats.misses += misses.length return { hits, misses } } /** * Get cache statistics */ getStats(): CacheStats { const total = this.stats.hits + this.stats.misses return { hits: this.stats.hits, misses: this.stats.misses, hitRate: total > 0 ? this.stats.hits / total : 0, size: this.storage['cache'].size, } } /** * Clear the cache */ async clear(): Promise<void> { await this.storage.clear() this.stats = { hits: 0, misses: 0 } } } // ============================================================================ // Generation Cache // ============================================================================ /** * Parameters for generation cache key */ export interface GenerationParams { /** The prompt text */ prompt: string /** The model to use */ model: string /** System prompt */ system?: string /** Temperature setting */ temperature?: number /** Schema version for structured outputs */ schemaVersion?: string } /** * Options for generation cache retrieval */ export interface GenerationCacheGetOptions { /** Bypass cache and return undefined */ bypass?: boolean } /** * Specialized cache for generation results * * @deprecated Phase C Week 1 — `GenerationCache` has zero production callers * in primitives.org.ai (audited 2026-05-06; see `bd show aip-ibid`). New * code should use `cacheMiddleware` (for `wrapLanguageModel`) — it composes * with AI SDK 6 directly and carries trace + budget telemetry when paired * via `wrapForV3`. Will be removed in the Phase C semver bump. */ export class GenerationCache { private storage: MemoryCache<unknown> private stats = { hits: 0, misses: 0 } constructor(options?: MemoryCacheOptions) { this.storage = new MemoryCache<unknown>(options) } /** * Get a cached generation result */ async get<T = unknown>( params: GenerationParams, options?: GenerationCacheGetOptions ): Promise<T | undefined> { if (options?.bypass) { return undefined } const key = this.createKey(params) const result = await this.storage.get(key) if (result) { this.stats.hits++ } else { this.stats.misses++ } return result as T | undefined } /** * Set a cached generation result */ async set<T = unknown>(params: GenerationParams, result: T): Promise<void> { const key = this.createKey(params) await this.storage.set(key, result) } /** * Get cache statistics */ getStats(): CacheStats { const total = this.stats.hits + this.stats.misses return { hits: this.stats.hits, misses: this.stats.misses, hitRate: total > 0 ? this.stats.hits / total : 0, size: this.storage['cache'].size, } } /** * Clear the cache */ async clear(): Promise<void> { await this.storage.clear() this.stats = { hits: 0, misses: 0 } } /** * Create a cache key from generation parameters */ private createKey(params: GenerationParams): string { const keyParams: Record<string, unknown> = { prompt: params.prompt, model: params.model, } if (params.system !== undefined) { keyParams['system'] = params.system } if (params.temperature !== undefined) { keyParams['temperature'] = params.temperature } if (params.schemaVersion !== undefined) { keyParams['schemaVersion'] = params.schemaVersion } return createCacheKey('generation', keyParams) } } // ============================================================================ // withCache Wrapper // ============================================================================ /** * Options for withCache wrapper */ export interface WithCacheOptions<TArgs extends unknown[]> { /** Function to generate cache key from arguments */ keyFn: (...args: TArgs) => string /** TTL for cached entries */ ttl?: number } /** * Cached function type with bypass support */ export interface CachedFunction<TArgs extends unknown[], TResult> { (...args: TArgs): Promise<TResult> /** Call with cache bypass (force fresh result) */ bypass: (...args: TArgs) => Promise<TResult> } /** * Wrap an async function with caching */ export function withCache<TArgs extends unknown[], TResult>( cache: CacheStorage<TResult>, fn: (...args: TArgs) => Promise<TResult>, options: WithCacheOptions<TArgs> ): CachedFunction<TArgs, TResult> { const { keyFn, ttl } = options const cachedFn = async (...args: TArgs): Promise<TResult> => { const key = keyFn(...args) // Check cache const cached = await cache.get(key) if (cached !== undefined) { return cached } // Execute function const result = await fn(...args) // Cache result (don't cache errors - they throw before reaching here) await cache.set(key, result, ttl !== undefined ? { ttl } : undefined) return result } // Add bypass method cachedFn.bypass = async (...args: TArgs): Promise<TResult> => { const key = keyFn(...args) // Execute function without checking cache const result = await fn(...args) // Update cache with new result await cache.set(key, result, ttl !== undefined ? { ttl } : undefined) return result } return cachedFn as CachedFunction<TArgs, TResult> }