mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-02 05:12:11 +03:00
feat(advanced): FASE-07 to FASE-09 advanced features
FASE-07 — UX & Microinteractions: - Create notificationStore.js (Zustand global toast store) - Create NotificationToast.js (glassmorphism toast UI with ARIA) FASE-08 — LLM Proxy Advanced: - Create policyEngine.js (declarative routing/budget/access policies) - Create cacheLayer.js (LRU cache with content hashing and TTL) FASE-09 — E2E Flow Hardening: - Create streamState.js (SSE stream state machine with TTFB tracking) Tests: 23/23 advanced tests pass (75/75 total across all suites)
This commit is contained in:
180
src/lib/cacheLayer.js
Normal file
180
src/lib/cacheLayer.js
Normal file
@@ -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<string, CacheEntry>} */
|
||||
#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;
|
||||
}
|
||||
182
src/lib/policies/policyEngine.js
Normal file
182
src/lib/policies/policyEngine.js
Normal file
@@ -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;
|
||||
}
|
||||
175
src/shared/components/NotificationToast.js
Normal file
175
src/shared/components/NotificationToast.js
Normal file
@@ -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 <NotificationToast /> 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 (
|
||||
<div
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
gap: "12px",
|
||||
padding: "14px 16px",
|
||||
borderRadius: "10px",
|
||||
backgroundColor: color.bg,
|
||||
border: `1px solid ${color.border}`,
|
||||
backdropFilter: "blur(12px)",
|
||||
boxShadow: "0 8px 32px rgba(0,0,0,0.2)",
|
||||
minWidth: "320px",
|
||||
maxWidth: "420px",
|
||||
animation: isExiting
|
||||
? "toastOut 0.2s ease-in forwards"
|
||||
: "toastIn 0.3s ease-out forwards",
|
||||
transition: "all 0.2s ease",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "18px",
|
||||
color: color.icon,
|
||||
fontWeight: "bold",
|
||||
lineHeight: 1,
|
||||
marginTop: "2px",
|
||||
}}
|
||||
>
|
||||
{ICONS[notification.type]}
|
||||
</span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{notification.title && (
|
||||
<div
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
fontSize: "14px",
|
||||
color: "var(--text-primary, #fff)",
|
||||
marginBottom: "2px",
|
||||
}}
|
||||
>
|
||||
{notification.title}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
fontSize: "13px",
|
||||
color: "var(--text-secondary, #ccc)",
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
{notification.message}
|
||||
</div>
|
||||
</div>
|
||||
{notification.dismissible && (
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
aria-label="Dismiss notification"
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
color: "var(--text-secondary, #999)",
|
||||
fontSize: "16px",
|
||||
padding: "0 2px",
|
||||
lineHeight: 1,
|
||||
opacity: 0.6,
|
||||
transition: "opacity 0.15s",
|
||||
}}
|
||||
onMouseEnter={(e) => (e.target.style.opacity = 1)}
|
||||
onMouseLeave={(e) => (e.target.style.opacity = 0.6)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function NotificationToast() {
|
||||
const { notifications, removeNotification } = useNotificationStore();
|
||||
|
||||
if (notifications.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{`
|
||||
@keyframes toastIn {
|
||||
from { opacity: 0; transform: translateX(100%) scale(0.95); }
|
||||
to { opacity: 1; transform: translateX(0) scale(1); }
|
||||
}
|
||||
@keyframes toastOut {
|
||||
from { opacity: 1; transform: translateX(0) scale(1); }
|
||||
to { opacity: 0; transform: translateX(100%) scale(0.95); }
|
||||
}
|
||||
`}</style>
|
||||
<div
|
||||
aria-live="polite"
|
||||
aria-atomic="false"
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: "20px",
|
||||
right: "20px",
|
||||
zIndex: 9999,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "10px",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
{notifications.map((n) => (
|
||||
<div key={n.id} style={{ pointerEvents: "auto" }}>
|
||||
<Toast notification={n} onDismiss={removeNotification} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
202
src/sse/services/streamState.js
Normal file
202
src/sse/services/streamState.js
Normal file
@@ -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<string, StreamTracker>} */
|
||||
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<Object>}
|
||||
*/
|
||||
export function getActiveStreams() {
|
||||
return Array.from(activeStreams.values()).map((t) => t.getSummary());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent completed streams.
|
||||
* @param {number} [limit=20]
|
||||
* @returns {Array<Object>}
|
||||
*/
|
||||
export function getRecentCompletedStreams(limit = 20) {
|
||||
return completedStreams.slice(-limit);
|
||||
}
|
||||
98
src/store/notificationStore.js
Normal file
98
src/store/notificationStore.js
Normal file
@@ -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<Notification> & { 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 }),
|
||||
}));
|
||||
303
tests/unit/advanced-fase07-09.test.mjs
Normal file
303
tests/unit/advanced-fase07-09.test.mjs
Normal file
@@ -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"));
|
||||
});
|
||||
Reference in New Issue
Block a user