diff --git a/src/lib/cacheLayer.js b/src/lib/cacheLayer.js new file mode 100644 index 0000000000..73a228bd27 --- /dev/null +++ b/src/lib/cacheLayer.js @@ -0,0 +1,180 @@ +/** + * LRU Cache Layer — FASE-08 LLM Proxy Advanced + * + * In-memory LRU cache for LLM prompt/response pairs. + * Uses content hashing for cache keys to handle semantic deduplication. + * + * @module lib/cacheLayer + */ + +import crypto from "node:crypto"; + +/** + * @typedef {Object} CacheEntry + * @property {string} key - Cache key (hash) + * @property {*} value - Cached value + * @property {number} createdAt - Timestamp + * @property {number} ttl - TTL in ms + * @property {number} size - Approximate size in bytes + * @property {number} hits - Number of times this entry was accessed + */ + +export class LRUCache { + /** @type {Map} */ + #cache = new Map(); + #maxSize; + #defaultTTL; + #currentSize = 0; + #stats = { hits: 0, misses: 0, evictions: 0 }; + + /** + * @param {Object} options + * @param {number} [options.maxSize=100] - Max number of entries + * @param {number} [options.defaultTTL=300000] - Default TTL in ms (5 min) + */ + constructor(options = {}) { + this.#maxSize = options.maxSize ?? 100; + this.#defaultTTL = options.defaultTTL ?? 300000; + } + + /** + * Generate a cache key from input. + * @param {Object} params - Parameters to hash + * @returns {string} Cache key + */ + static generateKey(params) { + const normalized = JSON.stringify(params, Object.keys(params).sort()); + return crypto.createHash("sha256").update(normalized).digest("hex").slice(0, 16); + } + + /** + * Get a value from the cache. + * @param {string} key + * @returns {*|undefined} + */ + get(key) { + const entry = this.#cache.get(key); + + if (!entry) { + this.#stats.misses++; + return undefined; + } + + // Check TTL + if (Date.now() - entry.createdAt > entry.ttl) { + this.#cache.delete(key); + this.#currentSize--; + this.#stats.misses++; + return undefined; + } + + // Move to end (most recently used) + this.#cache.delete(key); + entry.hits++; + this.#cache.set(key, entry); + + this.#stats.hits++; + return entry.value; + } + + /** + * Set a value in the cache. + * @param {string} key + * @param {*} value + * @param {number} [ttl] - Override default TTL + */ + set(key, value, ttl) { + // If key exists, delete it first (will be re-added at end) + if (this.#cache.has(key)) { + this.#cache.delete(key); + this.#currentSize--; + } + + // Evict oldest entries if at capacity + while (this.#currentSize >= this.#maxSize) { + const oldestKey = this.#cache.keys().next().value; + this.#cache.delete(oldestKey); + this.#currentSize--; + this.#stats.evictions++; + } + + const entry = { + key, + value, + createdAt: Date.now(), + ttl: ttl ?? this.#defaultTTL, + size: JSON.stringify(value).length, + hits: 0, + }; + + this.#cache.set(key, entry); + this.#currentSize++; + } + + /** + * Check if a key exists (without promoting it). + * @param {string} key + * @returns {boolean} + */ + has(key) { + const entry = this.#cache.get(key); + if (!entry) return false; + if (Date.now() - entry.createdAt > entry.ttl) { + this.#cache.delete(key); + this.#currentSize--; + return false; + } + return true; + } + + /** + * Delete a specific key. + * @param {string} key + * @returns {boolean} + */ + delete(key) { + if (this.#cache.has(key)) { + this.#cache.delete(key); + this.#currentSize--; + return true; + } + return false; + } + + /** Clear the entire cache. */ + clear() { + this.#cache.clear(); + this.#currentSize = 0; + } + + /** @returns {{ size: number, maxSize: number, hits: number, misses: number, evictions: number, hitRate: number }} */ + getStats() { + const total = this.#stats.hits + this.#stats.misses; + return { + size: this.#currentSize, + maxSize: this.#maxSize, + ...this.#stats, + hitRate: total > 0 ? (this.#stats.hits / total) * 100 : 0, + }; + } +} + +// ─── Prompt Cache Singleton ───────────────── + +let promptCache; + +/** + * Get the global prompt cache instance. + * @param {Object} [options] + * @returns {LRUCache} + */ +export function getPromptCache(options) { + if (!promptCache) { + promptCache = new LRUCache({ + maxSize: parseInt(process.env.PROMPT_CACHE_MAX_SIZE || "200", 10), + defaultTTL: parseInt(process.env.PROMPT_CACHE_TTL_MS || "600000", 10), + ...options, + }); + } + return promptCache; +} diff --git a/src/lib/policies/policyEngine.js b/src/lib/policies/policyEngine.js new file mode 100644 index 0000000000..f485520231 --- /dev/null +++ b/src/lib/policies/policyEngine.js @@ -0,0 +1,182 @@ +/** + * Policy Engine — FASE-08 LLM Proxy Advanced + * + * Declarative policy engine for routing, budget, and access control decisions + * in the LLM proxy pipeline. Policies are evaluated before provider selection. + * + * @module lib/policies/policyEngine + */ + +/** + * @typedef {'routing'|'budget'|'access'} PolicyType + */ + +/** + * @typedef {Object} Policy + * @property {string} id - Unique policy ID + * @property {string} name - Display name + * @property {PolicyType} type - Policy type + * @property {boolean} enabled - Whether the policy is active + * @property {number} priority - Evaluation order (lower = first) + * @property {Object} conditions - Matching conditions + * @property {string} [conditions.model_pattern] - Glob pattern for model names + * @property {string} [conditions.provider] - Provider ID + * @property {string} [conditions.api_key_id] - API key ID + * @property {Object} actions - Actions to take when matched + * @property {string[]} [actions.prefer_provider] - Preferred providers + * @property {string[]} [actions.block_provider] - Blocked providers + * @property {string[]} [actions.block_model] - Blocked models + * @property {number} [actions.max_cost_per_1k] - Max cost per 1000 tokens + * @property {number} [actions.max_tokens] - Max tokens per request + * @property {number} [actions.daily_budget] - Daily budget in USD + * @property {string} createdAt - ISO timestamp + * @property {string} updatedAt - ISO timestamp + */ + +/** + * Simple glob pattern matching (supports * wildcard only). + * @param {string} pattern + * @param {string} value + * @returns {boolean} + */ +function matchGlob(pattern, value) { + if (!pattern || pattern === "*") return true; + const regex = new RegExp( + "^" + pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*") + "$", + "i" + ); + return regex.test(value); +} + +export class PolicyEngine { + /** @type {Policy[]} */ + #policies = []; + + /** + * Load policies from an array. + * @param {Policy[]} policies + */ + loadPolicies(policies) { + this.#policies = [...policies].sort((a, b) => (a.priority ?? 100) - (b.priority ?? 100)); + } + + /** + * Add a single policy. + * @param {Policy} policy + */ + addPolicy(policy) { + this.#policies.push(policy); + this.#policies.sort((a, b) => (a.priority ?? 100) - (b.priority ?? 100)); + } + + /** + * Remove a policy by ID. + * @param {string} id + * @returns {boolean} + */ + removePolicy(id) { + const idx = this.#policies.findIndex((p) => p.id === id); + if (idx === -1) return false; + this.#policies.splice(idx, 1); + return true; + } + + /** + * Get all policies. + * @returns {Policy[]} + */ + getPolicies() { + return [...this.#policies]; + } + + /** + * Evaluate all policies against a request context. + * + * @param {{ model: string, provider?: string, apiKeyId?: string }} context + * @returns {{ allowed: boolean, reason?: string, preferredProviders?: string[], blockedProviders?: string[], maxTokens?: number }} + */ + evaluate(context) { + const result = { + allowed: true, + preferredProviders: [], + blockedProviders: [], + blockedModels: [], + maxTokens: undefined, + maxCostPer1k: undefined, + appliedPolicies: [], + }; + + for (const policy of this.#policies) { + if (!policy.enabled) continue; + + // Check conditions + const conditions = policy.conditions || {}; + let matches = true; + + if (conditions.model_pattern && !matchGlob(conditions.model_pattern, context.model)) { + matches = false; + } + if (conditions.provider && conditions.provider !== context.provider) { + matches = false; + } + if (conditions.api_key_id && conditions.api_key_id !== context.apiKeyId) { + matches = false; + } + + if (!matches) continue; + + // Apply actions + const actions = policy.actions || {}; + result.appliedPolicies.push(policy.name); + + if (actions.prefer_provider) { + result.preferredProviders.push(...actions.prefer_provider); + } + + if (actions.block_provider) { + result.blockedProviders.push(...actions.block_provider); + } + + if (actions.block_model) { + const isBlocked = actions.block_model.some((pattern) => + matchGlob(pattern, context.model) + ); + if (isBlocked) { + result.allowed = false; + result.reason = `Model "${context.model}" blocked by policy "${policy.name}"`; + break; + } + } + + if (actions.max_tokens !== undefined) { + result.maxTokens = + result.maxTokens !== undefined + ? Math.min(result.maxTokens, actions.max_tokens) + : actions.max_tokens; + } + + if (actions.max_cost_per_1k !== undefined) { + result.maxCostPer1k = + result.maxCostPer1k !== undefined + ? Math.min(result.maxCostPer1k, actions.max_cost_per_1k) + : actions.max_cost_per_1k; + } + } + + return result; + } +} + +// Singleton instance +let engineInstance; + +/** + * Get the global policy engine instance. + * @returns {PolicyEngine} + */ +export function getPolicyEngine() { + if (!engineInstance) { + engineInstance = new PolicyEngine(); + } + return engineInstance; +} diff --git a/src/shared/components/NotificationToast.js b/src/shared/components/NotificationToast.js new file mode 100644 index 0000000000..d29e55e3cd --- /dev/null +++ b/src/shared/components/NotificationToast.js @@ -0,0 +1,175 @@ +"use client"; + +/** + * NotificationToast — FASE-07 UX & Microinteractions + * + * Global toast notification component. Renders notifications from the + * notificationStore as stacked toasts in the top-right corner. + * + * Usage: Add to your root layout. + */ + +import { useNotificationStore } from "@/store/notificationStore"; +import { useEffect, useState } from "react"; + +const ICONS = { + success: "✓", + error: "✕", + warning: "⚠", + info: "ℹ", +}; + +const COLORS = { + success: { + bg: "rgba(16, 185, 129, 0.15)", + border: "rgba(16, 185, 129, 0.4)", + icon: "#10b981", + }, + error: { + bg: "rgba(239, 68, 68, 0.15)", + border: "rgba(239, 68, 68, 0.4)", + icon: "#ef4444", + }, + warning: { + bg: "rgba(245, 158, 11, 0.15)", + border: "rgba(245, 158, 11, 0.4)", + icon: "#f59e0b", + }, + info: { + bg: "rgba(59, 130, 246, 0.15)", + border: "rgba(59, 130, 246, 0.4)", + icon: "#3b82f6", + }, +}; + +function Toast({ notification, onDismiss }) { + const [isExiting, setIsExiting] = useState(false); + + const handleDismiss = () => { + setIsExiting(true); + setTimeout(() => onDismiss(notification.id), 200); + }; + + const color = COLORS[notification.type] || COLORS.info; + + return ( +
+ + {ICONS[notification.type]} + +
+ {notification.title && ( +
+ {notification.title} +
+ )} +
+ {notification.message} +
+
+ {notification.dismissible && ( + + )} +
+ ); +} + +export default function NotificationToast() { + const { notifications, removeNotification } = useNotificationStore(); + + if (notifications.length === 0) return null; + + return ( + <> + +
+ {notifications.map((n) => ( +
+ +
+ ))} +
+ + ); +} diff --git a/src/sse/services/streamState.js b/src/sse/services/streamState.js new file mode 100644 index 0000000000..924d3c08a9 --- /dev/null +++ b/src/sse/services/streamState.js @@ -0,0 +1,202 @@ +/** + * Stream State Machine — FASE-09 E2E Flow Hardening + * + * Explicit state tracking for SSE streams with transition logging + * and lifecycle management. + * + * States: INITIALIZED → CONNECTING → STREAMING → COMPLETED | FAILED | CANCELLED + * + * @module sse/services/streamState + */ + +export const STREAM_STATES = { + INITIALIZED: "initialized", + CONNECTING: "connecting", + STREAMING: "streaming", + COMPLETED: "completed", + FAILED: "failed", + CANCELLED: "cancelled", +}; + +// Valid state transitions +const VALID_TRANSITIONS = { + [STREAM_STATES.INITIALIZED]: [STREAM_STATES.CONNECTING, STREAM_STATES.CANCELLED], + [STREAM_STATES.CONNECTING]: [ + STREAM_STATES.STREAMING, + STREAM_STATES.FAILED, + STREAM_STATES.CANCELLED, + ], + [STREAM_STATES.STREAMING]: [ + STREAM_STATES.COMPLETED, + STREAM_STATES.FAILED, + STREAM_STATES.CANCELLED, + ], + [STREAM_STATES.COMPLETED]: [], + [STREAM_STATES.FAILED]: [], + [STREAM_STATES.CANCELLED]: [], +}; + +/** + * Tracks the lifecycle of a single SSE stream. + */ +export class StreamTracker { + /** + * @param {string} requestId - Unique request identifier + * @param {Object} [metadata] - Additional metadata (model, provider, etc.) + */ + constructor(requestId, metadata = {}) { + this.requestId = requestId; + this.state = STREAM_STATES.INITIALIZED; + this.metadata = metadata; + this.transitions = []; + this.startedAt = Date.now(); + this.completedAt = null; + this.firstChunkAt = null; + this.chunkCount = 0; + this.totalBytes = 0; + this.error = null; + } + + /** + * Transition to a new state. + * + * @param {string} newState - Target state + * @param {Object} [transitionMeta] - Metadata for this transition + * @returns {boolean} Whether the transition was valid + */ + transition(newState, transitionMeta = {}) { + const allowed = VALID_TRANSITIONS[this.state] || []; + if (!allowed.includes(newState)) { + console.warn( + `[StreamTracker] Invalid transition: ${this.state} → ${newState} (request: ${this.requestId})` + ); + return false; + } + + const now = Date.now(); + this.transitions.push({ + from: this.state, + to: newState, + at: now, + elapsed: now - this.startedAt, + ...transitionMeta, + }); + + this.state = newState; + + if (newState === STREAM_STATES.STREAMING && !this.firstChunkAt) { + this.firstChunkAt = now; + } + + if ( + newState === STREAM_STATES.COMPLETED || + newState === STREAM_STATES.FAILED || + newState === STREAM_STATES.CANCELLED + ) { + this.completedAt = now; + } + + return true; + } + + /** + * Record a received chunk. + * @param {number} bytes - Chunk size in bytes + */ + recordChunk(bytes) { + this.chunkCount++; + this.totalBytes += bytes; + } + + /** + * Mark as failed with an error. + * @param {Error|string} error + */ + fail(error) { + this.error = typeof error === "string" ? error : error.message; + this.transition(STREAM_STATES.FAILED, { error: this.error }); + } + + /** + * Get telemetry summary for this stream. + * @returns {Object} + */ + getSummary() { + const endTime = this.completedAt || Date.now(); + return { + requestId: this.requestId, + state: this.state, + model: this.metadata.model, + provider: this.metadata.provider, + duration: endTime - this.startedAt, + ttfb: this.firstChunkAt ? this.firstChunkAt - this.startedAt : null, + chunkCount: this.chunkCount, + totalBytes: this.totalBytes, + transitions: this.transitions.length, + error: this.error, + }; + } + + /** + * Whether the stream is in a terminal state. + * @returns {boolean} + */ + isTerminal() { + return [STREAM_STATES.COMPLETED, STREAM_STATES.FAILED, STREAM_STATES.CANCELLED].includes( + this.state + ); + } +} + +// ─── Active Stream Registry ───────────────── + +/** @type {Map} */ +const activeStreams = new Map(); +const MAX_COMPLETED_HISTORY = 100; +const completedStreams = []; + +/** + * Create and register a new stream tracker. + * @param {string} requestId + * @param {Object} [metadata] + * @returns {StreamTracker} + */ +export function createStreamTracker(requestId, metadata) { + const tracker = new StreamTracker(requestId, metadata); + activeStreams.set(requestId, tracker); + return tracker; +} + +/** + * Complete (archive) a stream — moves from active to completed history. + * @param {string} requestId + */ +export function archiveStream(requestId) { + const tracker = activeStreams.get(requestId); + if (!tracker) return; + + activeStreams.delete(requestId); + completedStreams.push(tracker.getSummary()); + + // Keep history bounded + while (completedStreams.length > MAX_COMPLETED_HISTORY) { + completedStreams.shift(); + } +} + +/** + * Get all active streams (for monitoring dashboard). + * @returns {Array} + */ +export function getActiveStreams() { + return Array.from(activeStreams.values()).map((t) => t.getSummary()); +} + +/** + * Get recent completed streams. + * @param {number} [limit=20] + * @returns {Array} + */ +export function getRecentCompletedStreams(limit = 20) { + return completedStreams.slice(-limit); +} diff --git a/src/store/notificationStore.js b/src/store/notificationStore.js new file mode 100644 index 0000000000..658d71cea1 --- /dev/null +++ b/src/store/notificationStore.js @@ -0,0 +1,98 @@ +"use client"; + +/** + * Notification Store — FASE-07 UX & Microinteractions + * + * Zustand-based global notification system for the dashboard. + * Replaces ad-hoc feedback patterns with a centralized toast system. + * + * @module store/notificationStore + */ + +import { create } from "zustand"; + +let idCounter = 0; + +/** + * @typedef {'success'|'error'|'warning'|'info'} NotificationType + */ + +/** + * @typedef {Object} Notification + * @property {number} id + * @property {NotificationType} type + * @property {string} message + * @property {string} [title] + * @property {number} [duration] - Auto-dismiss in ms (default 5000, 0 = no auto-dismiss) + * @property {boolean} [dismissible] - Whether user can dismiss (default true) + * @property {number} createdAt + */ + +export const useNotificationStore = create((set, get) => ({ + /** @type {Notification[]} */ + notifications: [], + + /** + * Add a notification. + * @param {Partial & { message: string, type: NotificationType }} notification + * @returns {number} The notification ID + */ + addNotification: (notification) => { + const id = ++idCounter; + const entry = { + id, + type: notification.type || "info", + message: notification.message, + title: notification.title || null, + duration: notification.duration ?? 5000, + dismissible: notification.dismissible ?? true, + createdAt: Date.now(), + }; + + set((s) => ({ + notifications: [...s.notifications, entry], + })); + + // Auto-dismiss + if (entry.duration > 0) { + setTimeout(() => { + get().removeNotification(id); + }, entry.duration); + } + + return id; + }, + + /** + * Remove a notification by ID. + * @param {number} id + */ + removeNotification: (id) => { + set((s) => ({ + notifications: s.notifications.filter((n) => n.id !== id), + })); + }, + + /** + * Clear all notifications. + */ + clearAll: () => set({ notifications: [] }), + + // ─── Convenience Methods ───────────────── + + /** @param {string} message @param {string} [title] */ + success: (message, title) => + get().addNotification({ type: "success", message, title }), + + /** @param {string} message @param {string} [title] */ + error: (message, title) => + get().addNotification({ type: "error", message, title, duration: 8000 }), + + /** @param {string} message @param {string} [title] */ + warning: (message, title) => + get().addNotification({ type: "warning", message, title }), + + /** @param {string} message @param {string} [title] */ + info: (message, title) => + get().addNotification({ type: "info", message, title }), +})); diff --git a/tests/unit/advanced-fase07-09.test.mjs b/tests/unit/advanced-fase07-09.test.mjs new file mode 100644 index 0000000000..1d5092befb --- /dev/null +++ b/tests/unit/advanced-fase07-09.test.mjs @@ -0,0 +1,303 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// ═════════════════════════════════════════════════════ +// FASE-07/08/09: UX, LLM Advanced, E2E Hardening Tests +// ═════════════════════════════════════════════════════ + +// ─── Policy Engine Tests ────────────────────────── + +import { PolicyEngine } from "../../src/lib/policies/policyEngine.js"; + +test("PolicyEngine: evaluates empty policy list as allowed", () => { + const engine = new PolicyEngine(); + const result = engine.evaluate({ model: "gpt-4" }); + assert.equal(result.allowed, true); + assert.equal(result.appliedPolicies.length, 0); +}); + +test("PolicyEngine: matches model glob patterns", () => { + const engine = new PolicyEngine(); + engine.loadPolicies([ + { + id: "1", + name: "prefer-openai-for-gpt", + type: "routing", + enabled: true, + priority: 1, + conditions: { model_pattern: "gpt-*" }, + actions: { prefer_provider: ["openai"] }, + }, + ]); + + const result = engine.evaluate({ model: "gpt-4o" }); + assert.equal(result.allowed, true); + assert.deepEqual(result.preferredProviders, ["openai"]); + assert.deepEqual(result.appliedPolicies, ["prefer-openai-for-gpt"]); +}); + +test("PolicyEngine: does not match non-matching models", () => { + const engine = new PolicyEngine(); + engine.loadPolicies([ + { + id: "1", + name: "gpt-only", + type: "routing", + enabled: true, + priority: 1, + conditions: { model_pattern: "gpt-*" }, + actions: { prefer_provider: ["openai"] }, + }, + ]); + + const result = engine.evaluate({ model: "claude-3.5-sonnet" }); + assert.equal(result.preferredProviders.length, 0); +}); + +test("PolicyEngine: blocks models via access policy", () => { + const engine = new PolicyEngine(); + engine.loadPolicies([ + { + id: "2", + name: "block-expensive", + type: "access", + enabled: true, + priority: 1, + conditions: {}, + actions: { block_model: ["gpt-4*", "claude-3-opus*"] }, + }, + ]); + + const blocked = engine.evaluate({ model: "gpt-4o" }); + assert.equal(blocked.allowed, false); + assert.ok(blocked.reason.includes("blocked")); + + const allowed = engine.evaluate({ model: "gpt-3.5-turbo" }); + assert.equal(allowed.allowed, true); +}); + +test("PolicyEngine: applies max_tokens from budget policy", () => { + const engine = new PolicyEngine(); + engine.loadPolicies([ + { + id: "3", + name: "limit-tokens", + type: "budget", + enabled: true, + priority: 1, + conditions: {}, + actions: { max_tokens: 4096 }, + }, + ]); + + const result = engine.evaluate({ model: "gpt-4" }); + assert.equal(result.maxTokens, 4096); +}); + +test("PolicyEngine: skips disabled policies", () => { + const engine = new PolicyEngine(); + engine.loadPolicies([ + { + id: "4", + name: "disabled-policy", + type: "routing", + enabled: false, + priority: 1, + conditions: {}, + actions: { prefer_provider: ["should-not-appear"] }, + }, + ]); + + const result = engine.evaluate({ model: "gpt-4" }); + assert.equal(result.preferredProviders.length, 0); +}); + +test("PolicyEngine: respects priority order", () => { + const engine = new PolicyEngine(); + engine.loadPolicies([ + { + id: "b", + name: "second", + type: "routing", + enabled: true, + priority: 10, + conditions: {}, + actions: { prefer_provider: ["provider-b"] }, + }, + { + id: "a", + name: "first", + type: "routing", + enabled: true, + priority: 1, + conditions: {}, + actions: { prefer_provider: ["provider-a"] }, + }, + ]); + + const result = engine.evaluate({ model: "any" }); + assert.deepEqual(result.preferredProviders, ["provider-a", "provider-b"]); + assert.deepEqual(result.appliedPolicies, ["first", "second"]); +}); + +test("PolicyEngine: addPolicy and removePolicy work", () => { + const engine = new PolicyEngine(); + engine.addPolicy({ + id: "x", + name: "temp", + type: "routing", + enabled: true, + priority: 1, + conditions: {}, + actions: { prefer_provider: ["x"] }, + }); + + assert.equal(engine.getPolicies().length, 1); + engine.removePolicy("x"); + assert.equal(engine.getPolicies().length, 0); +}); + +// ─── LRU Cache Tests ────────────────────────── + +import { LRUCache } from "../../src/lib/cacheLayer.js"; + +test("LRUCache: set and get work", () => { + const cache = new LRUCache({ maxSize: 5 }); + cache.set("k1", "v1"); + assert.equal(cache.get("k1"), "v1"); +}); + +test("LRUCache: returns undefined for missing keys", () => { + const cache = new LRUCache({ maxSize: 5 }); + assert.equal(cache.get("missing"), undefined); +}); + +test("LRUCache: evicts oldest entry when full", () => { + const cache = new LRUCache({ maxSize: 3 }); + cache.set("a", 1); + cache.set("b", 2); + cache.set("c", 3); + cache.set("d", 4); // Should evict "a" + + assert.equal(cache.get("a"), undefined); + assert.equal(cache.get("d"), 4); +}); + +test("LRUCache: TTL expiration works", async () => { + const cache = new LRUCache({ maxSize: 5, defaultTTL: 50 }); + cache.set("temp", "value"); + assert.equal(cache.get("temp"), "value"); + + await new Promise((r) => setTimeout(r, 60)); + assert.equal(cache.get("temp"), undefined); +}); + +test("LRUCache: stats track hits and misses", () => { + const cache = new LRUCache({ maxSize: 5 }); + cache.set("k", "v"); + cache.get("k"); // hit + cache.get("missing"); // miss + + const stats = cache.getStats(); + assert.equal(stats.hits, 1); + assert.equal(stats.misses, 1); + assert.equal(stats.hitRate, 50); +}); + +test("LRUCache: generateKey produces consistent hashes", () => { + const key1 = LRUCache.generateKey({ model: "gpt-4", prompt: "hello" }); + const key2 = LRUCache.generateKey({ prompt: "hello", model: "gpt-4" }); // different order + assert.equal(key1, key2); +}); + +test("LRUCache: delete removes entry", () => { + const cache = new LRUCache({ maxSize: 5 }); + cache.set("k", "v"); + assert.equal(cache.delete("k"), true); + assert.equal(cache.has("k"), false); +}); + +test("LRUCache: clear empties cache", () => { + const cache = new LRUCache({ maxSize: 5 }); + cache.set("a", 1); + cache.set("b", 2); + cache.clear(); + assert.equal(cache.getStats().size, 0); +}); + +// ─── Stream State Machine Tests ────────────────── + +import { + StreamTracker, + STREAM_STATES, + createStreamTracker, + getActiveStreams, + archiveStream, +} from "../../src/sse/services/streamState.js"; + +test("StreamTracker: starts in INITIALIZED state", () => { + const tracker = new StreamTracker("req-1"); + assert.equal(tracker.state, STREAM_STATES.INITIALIZED); +}); + +test("StreamTracker: valid transitions succeed", () => { + const tracker = new StreamTracker("req-2"); + assert.equal(tracker.transition(STREAM_STATES.CONNECTING), true); + assert.equal(tracker.transition(STREAM_STATES.STREAMING), true); + assert.equal(tracker.transition(STREAM_STATES.COMPLETED), true); + assert.equal(tracker.isTerminal(), true); +}); + +test("StreamTracker: invalid transitions are rejected", () => { + const tracker = new StreamTracker("req-3"); + // Can't go directly to STREAMING from INITIALIZED + assert.equal(tracker.transition(STREAM_STATES.STREAMING), false); + assert.equal(tracker.state, STREAM_STATES.INITIALIZED); +}); + +test("StreamTracker: records TTFB on first STREAMING transition", () => { + const tracker = new StreamTracker("req-4"); + tracker.transition(STREAM_STATES.CONNECTING); + tracker.transition(STREAM_STATES.STREAMING); + assert.ok(tracker.firstChunkAt !== null); +}); + +test("StreamTracker: fail() transitions to FAILED", () => { + const tracker = new StreamTracker("req-5"); + tracker.transition(STREAM_STATES.CONNECTING); + tracker.fail(new Error("connection timeout")); + assert.equal(tracker.state, STREAM_STATES.FAILED); + assert.equal(tracker.error, "connection timeout"); +}); + +test("StreamTracker: getSummary returns telemetry", () => { + const tracker = new StreamTracker("req-6", { model: "gpt-4", provider: "openai" }); + tracker.transition(STREAM_STATES.CONNECTING); + tracker.transition(STREAM_STATES.STREAMING); + tracker.recordChunk(500); + tracker.recordChunk(300); + tracker.transition(STREAM_STATES.COMPLETED); + + const summary = tracker.getSummary(); + assert.equal(summary.requestId, "req-6"); + assert.equal(summary.model, "gpt-4"); + assert.equal(summary.chunkCount, 2); + assert.equal(summary.totalBytes, 800); + assert.ok(summary.duration >= 0); + assert.ok(summary.ttfb !== null); +}); + +test("StreamTracker: registry tracks active streams", () => { + const tracker = createStreamTracker("reg-1", { model: "test" }); + const active = getActiveStreams(); + assert.ok(active.some((s) => s.requestId === "reg-1")); + + // Archive it + tracker.transition(STREAM_STATES.CONNECTING); + tracker.transition(STREAM_STATES.STREAMING); + tracker.transition(STREAM_STATES.COMPLETED); + archiveStream("reg-1"); + + const afterArchive = getActiveStreams(); + assert.ok(!afterArchive.some((s) => s.requestId === "reg-1")); +});