+ -

Page not found

+

+ Page not found +

The page you're looking for doesn't exist or has been moved.

Go to Dashboard diff --git a/src/domain/comboResolver.ts b/src/domain/comboResolver.ts index 25b9d28c8d..d57e57d7b3 100644 --- a/src/domain/comboResolver.ts +++ b/src/domain/comboResolver.ts @@ -1,4 +1,3 @@ -// @ts-check /** * Combo Resolver โ€” FASE-09 Domain Extraction (T-46) * diff --git a/src/domain/costRules.ts b/src/domain/costRules.ts index 08714002d2..01f3cad2da 100644 --- a/src/domain/costRules.ts +++ b/src/domain/costRules.ts @@ -9,7 +9,6 @@ * @module domain/costRules */ -// @ts-check import { saveBudget, diff --git a/src/domain/fallbackPolicy.ts b/src/domain/fallbackPolicy.ts index 215162da9f..860d2316aa 100644 --- a/src/domain/fallbackPolicy.ts +++ b/src/domain/fallbackPolicy.ts @@ -10,7 +10,6 @@ * @module domain/fallbackPolicy */ -// @ts-check import { saveFallbackChain, diff --git a/src/domain/lockoutPolicy.ts b/src/domain/lockoutPolicy.ts index 558d85c275..752f9e4f45 100644 --- a/src/domain/lockoutPolicy.ts +++ b/src/domain/lockoutPolicy.ts @@ -1,4 +1,3 @@ -// @ts-check /** * Lockout Policy โ€” FASE-09 Domain Extraction (T-46) * diff --git a/src/domain/modelAvailability.ts b/src/domain/modelAvailability.ts index a735d9e906..a21a69b693 100644 --- a/src/domain/modelAvailability.ts +++ b/src/domain/modelAvailability.ts @@ -9,7 +9,6 @@ * @module domain/modelAvailability */ -// @ts-check /** * @typedef {Object} UnavailableEntry diff --git a/src/instrumentation.ts b/src/instrumentation.ts new file mode 100644 index 0000000000..7d35c420a0 --- /dev/null +++ b/src/instrumentation.ts @@ -0,0 +1,16 @@ +/** + * Next.js Instrumentation Hook + * + * Called once when the server starts (both dev and production). + * Used to initialize graceful shutdown handlers. + * + * @see https://nextjs.org/docs/app/building-your-application/optimizing/instrumentation + */ + +export async function register() { + // Only run on the server (not during build or in Edge runtime) + if (process.env.NEXT_RUNTIME === "nodejs") { + const { initGracefulShutdown } = await import("@/lib/gracefulShutdown"); + initGracefulShutdown(); + } +} diff --git a/src/lib/compliance/index.ts b/src/lib/compliance/index.ts index a6c8ec1d28..f3f7013a8f 100644 --- a/src/lib/compliance/index.ts +++ b/src/lib/compliance/index.ts @@ -9,7 +9,6 @@ * @module lib/compliance */ -// @ts-check import { getDbInstance } from "../db/core"; diff --git a/src/lib/container.ts b/src/lib/container.ts new file mode 100644 index 0000000000..13a1a5feec --- /dev/null +++ b/src/lib/container.ts @@ -0,0 +1,122 @@ +/** + * Simple DI Container โ€” Factory-pattern service locator + * + * Provides a lightweight dependency injection container using factory + * functions (no heavy frameworks). Services are lazily instantiated + * and cached as singletons. + * + * Usage: + * import { container } from '@/lib/container'; + * const settings = container.resolve('settings'); + * + * Registration: + * container.register('myService', () => new MyService()); + * + * @module lib/container + */ + +import { evaluateFirstAllowed, evaluateRequest, PolicyEngine } from "../domain/policyEngine"; +import { getDbInstance } from "./db/core"; +import { + decrypt, + decryptConnectionFields, + encrypt, + encryptConnectionFields, +} from "./db/encryption"; +import { getSettings } from "./localDb"; +import { getCircuitBreaker } from "../shared/utils/circuitBreaker"; +import { recordTelemetry, RequestTelemetry } from "../shared/utils/requestTelemetry"; + +type Factory = () => T; + +class Container { + private _factories = new Map(); + private _instances = new Map(); + + /** + * Register a factory for a service. Does NOT instantiate until resolve(). + */ + register(name: string, factory: Factory): void { + this._factories.set(name, factory); + // Clear cached instance if re-registering (useful for testing) + this._instances.delete(name); + } + + /** + * Resolve a service by name. Lazy-creates via factory on first call, + * then returns the cached singleton. + */ + resolve(name: string): T { + if (this._instances.has(name)) { + return this._instances.get(name) as T; + } + + const factory = this._factories.get(name); + if (!factory) { + throw new Error(`[Container] No factory registered for "${name}"`); + } + + const instance = factory(); + this._instances.set(name, instance); + return instance as T; + } + + /** + * Check if a service is registered (factory exists). + */ + has(name: string): boolean { + return this._factories.has(name); + } + + /** + * List all registered service names. + */ + list(): string[] { + return Array.from(this._factories.keys()); + } + + /** + * Reset all factories and instances (for testing). + */ + reset(): void { + this._factories.clear(); + this._instances.clear(); + } +} + +// โ”€โ”€ Singleton container instance โ”€โ”€ +export const container = new Container(); + +// โ”€โ”€ Default registrations โ”€โ”€ +// Services are still lazily instantiated on first resolve(). + +container.register("settings", () => { + return { get: getSettings }; +}); + +container.register("db", () => { + return getDbInstance(); +}); + +container.register("encryption", () => { + return { + encrypt, + decrypt, + encryptConnectionFields, + decryptConnectionFields, + }; +}); + +container.register("policyEngine", () => { + return { evaluateRequest, evaluateFirstAllowed, PolicyEngine }; +}); + +container.register("circuitBreaker", () => { + return { get: getCircuitBreaker }; +}); + +container.register("telemetry", () => { + return { RequestTelemetry, recordTelemetry }; +}); + +export default container; diff --git a/src/lib/db/core.ts b/src/lib/db/core.ts index 3d7b249424..ae766a3f9b 100644 --- a/src/lib/db/core.ts +++ b/src/lib/db/core.ts @@ -8,6 +8,7 @@ import Database from "better-sqlite3"; import path from "node:path"; import fs from "node:fs"; import { resolveDataDir, getLegacyDotDataDir } from "../dataPaths"; +import { runMigrations } from "./migrationRunner"; // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ Environment Detection โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -352,6 +353,20 @@ export function getDbInstance() { _db.exec(SCHEMA_SQL); ensureProviderConnectionsColumns(_db); + // โ”€โ”€ Versioned Migrations โ”€โ”€ + // Auto-seed 001 as applied (the inline SCHEMA_SQL already created these tables) + // then run any new migrations (002+) + _db.exec(` + CREATE TABLE IF NOT EXISTS _omniroute_migrations ( + version TEXT PRIMARY KEY, + name TEXT NOT NULL, + applied_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + INSERT OR IGNORE INTO _omniroute_migrations (version, name) + VALUES ('001', 'initial_schema'); + `); + runMigrations(_db); + // Auto-migrate from db.json if exists if (JSON_DB_FILE && fs.existsSync(JSON_DB_FILE)) { migrateFromJson(_db, JSON_DB_FILE); diff --git a/src/lib/db/encryption.ts b/src/lib/db/encryption.ts index 353f333103..15235c050b 100644 --- a/src/lib/db/encryption.ts +++ b/src/lib/db/encryption.ts @@ -1,4 +1,3 @@ -// @ts-check /** * Field-Level Encryption โ€” AES-256-GCM * @@ -7,8 +6,6 @@ * * If STORAGE_ENCRYPTION_KEY is not set, operates in passthrough mode * (stores plaintext for development convenience). - * - * @module lib/db/encryption */ import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "crypto"; @@ -18,15 +15,22 @@ const IV_LENGTH = 16; const KEY_LENGTH = 32; const PREFIX = "enc:v1:"; -/** @type {Buffer|null} */ -let _derivedKey = null; +let _derivedKey: Buffer | null = null; + +/** Connection object with potentially encrypted credential fields. */ +export interface ConnectionFields { + apiKey?: string | null; + accessToken?: string | null; + refreshToken?: string | null; + idToken?: string | null; + [key: string]: unknown; +} /** * Derive a 256-bit key from the env secret using scrypt. * Returns null if no encryption key is configured. - * @returns {Buffer|null} */ -function getKey() { +function getKey(): Buffer | null { if (_derivedKey !== null) return _derivedKey; const secret = process.env.STORAGE_ENCRYPTION_KEY; @@ -38,21 +42,16 @@ function getKey() { return _derivedKey; } -/** - * Check if encryption is enabled. - * @returns {boolean} - */ -export function isEncryptionEnabled() { +/** Check if encryption is enabled. */ +export function isEncryptionEnabled(): boolean { return !!process.env.STORAGE_ENCRYPTION_KEY; } /** * Encrypt a plaintext string. Returns ciphertext with prefix. * If encryption is not configured, returns plaintext unchanged. - * @param {string|null|undefined} plaintext - * @returns {string|null|undefined} */ -export function encrypt(plaintext) { +export function encrypt(plaintext: string | null | undefined): string | null | undefined { if (!plaintext || typeof plaintext !== "string") return plaintext; const key = getKey(); @@ -73,10 +72,8 @@ export function encrypt(plaintext) { /** * Decrypt a ciphertext string. If not encrypted (no prefix), returns as-is. - * @param {string|null|undefined} ciphertext - * @returns {string|null|undefined} */ -export function decrypt(ciphertext) { +export function decrypt(ciphertext: string | null | undefined): string | null | undefined { if (!ciphertext || typeof ciphertext !== "string") return ciphertext; // Not encrypted โ€” return as-is (legacy plaintext or passthrough mode) @@ -108,18 +105,18 @@ export function decrypt(ciphertext) { let decrypted = decipher.update(encryptedHex, "hex", "utf8"); decrypted += decipher.final("utf8"); return decrypted; - } catch (err) { - console.error("[Encryption] Decryption failed:", err.message); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + console.error("[Encryption] Decryption failed:", message); return ciphertext; } } /** * Encrypt sensitive fields in a connection object (mutates in-place). - * @param {object} conn - * @returns {object} The same object with encrypted fields + * Uses `any` because the DB layer returns untyped rows from rowToCamel/cleanNulls. */ -export function encryptConnectionFields(conn) { +export function encryptConnectionFields(conn: any): any { if (!isEncryptionEnabled()) return conn; if (conn.apiKey) conn.apiKey = encrypt(conn.apiKey); @@ -131,10 +128,9 @@ export function encryptConnectionFields(conn) { /** * Decrypt sensitive fields in a connection row (returns new object). - * @param {object|null} row - * @returns {object|null} + * Uses `any` because the DB layer returns untyped rows from rowToCamel/cleanNulls. */ -export function decryptConnectionFields(row) { +export function decryptConnectionFields(row: any): any { if (!row) return row; if (!isEncryptionEnabled()) return row; diff --git a/src/lib/db/migrationRunner.ts b/src/lib/db/migrationRunner.ts new file mode 100644 index 0000000000..d8fbe7a71b --- /dev/null +++ b/src/lib/db/migrationRunner.ts @@ -0,0 +1,126 @@ +/** + * Migration Runner โ€” Versioned SQL Migrations for SQLite + * + * Reads numbered `.sql` files from the migrations directory and applies + * them sequentially, tracking applied versions in a `schema_migrations` table. + * + * Naming convention: `NNN_description.sql` (e.g., `001_initial_schema.sql`) + * + * All migrations run within a single transaction โ€” all-or-nothing per file. + */ + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type Database from "better-sqlite3"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const MIGRATIONS_DIR = path.join(__dirname, "migrations"); + +/** + * Ensure the schema_migrations tracking table exists. + */ +function ensureMigrationsTable(db: Database.Database): void { + db.exec(` + CREATE TABLE IF NOT EXISTS _omniroute_migrations ( + version TEXT PRIMARY KEY, + name TEXT NOT NULL, + applied_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + `); +} + +/** + * Get all migration files sorted by version number. + */ +function getMigrationFiles(): Array<{ version: string; name: string; path: string }> { + if (!fs.existsSync(MIGRATIONS_DIR)) return []; + + return fs + .readdirSync(MIGRATIONS_DIR) + .filter((f) => f.endsWith(".sql")) + .sort() + .map((filename) => { + const match = filename.match(/^(\d+)_(.+)\.sql$/); + if (!match) return null; + return { + version: match[1], + name: match[2], + path: path.join(MIGRATIONS_DIR, filename), + }; + }) + .filter(Boolean) as Array<{ version: string; name: string; path: string }>; +} + +/** + * Get list of already-applied migration versions. + */ +function getAppliedVersions(db: Database.Database): Set { + const rows = db.prepare("SELECT version FROM _omniroute_migrations").all() as Array<{ + version: string; + }>; + return new Set(rows.map((r) => r.version)); +} + +/** + * Run all pending migrations in order. + * Returns the number of migrations applied. + */ +export function runMigrations(db: Database.Database): number { + ensureMigrationsTable(db); + + const files = getMigrationFiles(); + const applied = getAppliedVersions(db); + let count = 0; + + for (const migration of files) { + if (applied.has(migration.version)) continue; + + const sql = fs.readFileSync(migration.path, "utf-8"); + + const applyMigration = db.transaction(() => { + db.exec(sql); + db.prepare("INSERT INTO _omniroute_migrations (version, name) VALUES (?, ?)").run( + migration.version, + migration.name + ); + }); + + try { + applyMigration(); + count++; + console.log(`[Migration] Applied: ${migration.version}_${migration.name}`); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + console.error(`[Migration] FAILED: ${migration.version}_${migration.name} โ€” ${message}`); + throw err; // Re-throw to prevent DB from starting in inconsistent state + } + } + + if (count > 0) { + console.log(`[Migration] ${count} migration(s) applied successfully.`); + } + + return count; +} + +/** + * Get migration status for diagnostics. + */ +export function getMigrationStatus(db: Database.Database): { + applied: Array<{ version: string; name: string; applied_at: string }>; + pending: Array<{ version: string; name: string }>; +} { + ensureMigrationsTable(db); + + const appliedRows = db + .prepare("SELECT version, name, applied_at FROM _omniroute_migrations ORDER BY version") + .all() as Array<{ version: string; name: string; applied_at: string }>; + + const appliedVersions = new Set(appliedRows.map((r) => r.version)); + const allFiles = getMigrationFiles(); + const pending = allFiles.filter((f) => !appliedVersions.has(f.version)); + + return { applied: appliedRows, pending }; +} diff --git a/src/lib/db/migrations/001_initial_schema.sql b/src/lib/db/migrations/001_initial_schema.sql new file mode 100644 index 0000000000..44577de8ea --- /dev/null +++ b/src/lib/db/migrations/001_initial_schema.sql @@ -0,0 +1,202 @@ +-- 001_initial_schema.sql +-- Initial schema for OmniRoute SQLite database. +-- This migration is automatically marked as applied for existing databases +-- since the schema was previously applied via CREATE TABLE IF NOT EXISTS. + +CREATE TABLE IF NOT EXISTS provider_connections ( + id TEXT PRIMARY KEY, + provider TEXT NOT NULL, + auth_type TEXT, + name TEXT, + email TEXT, + priority INTEGER DEFAULT 0, + is_active INTEGER DEFAULT 1, + access_token TEXT, + refresh_token TEXT, + expires_at TEXT, + token_expires_at TEXT, + scope TEXT, + project_id TEXT, + test_status TEXT, + error_code TEXT, + last_error TEXT, + last_error_at TEXT, + last_error_type TEXT, + last_error_source TEXT, + backoff_level INTEGER DEFAULT 0, + rate_limited_until TEXT, + health_check_interval INTEGER, + last_health_check_at TEXT, + last_tested TEXT, + api_key TEXT, + id_token TEXT, + provider_specific_data TEXT, + expires_in INTEGER, + display_name TEXT, + global_priority INTEGER, + default_model TEXT, + token_type TEXT, + consecutive_use_count INTEGER DEFAULT 0, + rate_limit_protection INTEGER DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_pc_provider ON provider_connections(provider); +CREATE INDEX IF NOT EXISTS idx_pc_active ON provider_connections(is_active); +CREATE INDEX IF NOT EXISTS idx_pc_priority ON provider_connections(provider, priority); + +CREATE TABLE IF NOT EXISTS provider_nodes ( + id TEXT PRIMARY KEY, + type TEXT NOT NULL, + name TEXT NOT NULL, + prefix TEXT, + api_type TEXT, + base_url TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS key_value ( + namespace TEXT NOT NULL, + key TEXT NOT NULL, + value TEXT NOT NULL, + PRIMARY KEY (namespace, key) +); + +CREATE TABLE IF NOT EXISTS combos ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + data TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS api_keys ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + key TEXT NOT NULL UNIQUE, + machine_id TEXT, + created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_ak_key ON api_keys(key); + +CREATE TABLE IF NOT EXISTS db_meta ( + key TEXT PRIMARY KEY, + value TEXT +); + +CREATE TABLE IF NOT EXISTS usage_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + provider TEXT, + model TEXT, + connection_id TEXT, + api_key_id TEXT, + api_key_name TEXT, + tokens_input INTEGER DEFAULT 0, + tokens_output INTEGER DEFAULT 0, + tokens_cache_read INTEGER DEFAULT 0, + tokens_cache_creation INTEGER DEFAULT 0, + tokens_reasoning INTEGER DEFAULT 0, + status TEXT, + timestamp TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_uh_timestamp ON usage_history(timestamp); +CREATE INDEX IF NOT EXISTS idx_uh_provider ON usage_history(provider); +CREATE INDEX IF NOT EXISTS idx_uh_model ON usage_history(model); + +CREATE TABLE IF NOT EXISTS call_logs ( + id TEXT PRIMARY KEY, + timestamp TEXT NOT NULL, + method TEXT, + path TEXT, + status INTEGER, + model TEXT, + provider TEXT, + account TEXT, + connection_id TEXT, + duration INTEGER DEFAULT 0, + tokens_in INTEGER DEFAULT 0, + tokens_out INTEGER DEFAULT 0, + source_format TEXT, + target_format TEXT, + api_key_id TEXT, + api_key_name TEXT, + combo_name TEXT, + request_body TEXT, + response_body TEXT, + error TEXT +); +CREATE INDEX IF NOT EXISTS idx_cl_timestamp ON call_logs(timestamp); +CREATE INDEX IF NOT EXISTS idx_cl_status ON call_logs(status); + +CREATE TABLE IF NOT EXISTS proxy_logs ( + id TEXT PRIMARY KEY, + timestamp TEXT NOT NULL, + status TEXT, + proxy_type TEXT, + proxy_host TEXT, + proxy_port INTEGER, + level TEXT, + level_id TEXT, + provider TEXT, + target_url TEXT, + public_ip TEXT, + latency_ms INTEGER DEFAULT 0, + error TEXT, + connection_id TEXT, + combo_id TEXT, + account TEXT, + tls_fingerprint INTEGER DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_pl_timestamp ON proxy_logs(timestamp); +CREATE INDEX IF NOT EXISTS idx_pl_status ON proxy_logs(status); +CREATE INDEX IF NOT EXISTS idx_pl_provider ON proxy_logs(provider); + +CREATE TABLE IF NOT EXISTS domain_fallback_chains ( + model TEXT PRIMARY KEY, + chain TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS domain_budgets ( + api_key_id TEXT PRIMARY KEY, + daily_limit_usd REAL NOT NULL, + monthly_limit_usd REAL DEFAULT 0, + warning_threshold REAL DEFAULT 0.8 +); + +CREATE TABLE IF NOT EXISTS domain_cost_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + api_key_id TEXT NOT NULL, + cost REAL NOT NULL, + timestamp INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_dch_key ON domain_cost_history(api_key_id); +CREATE INDEX IF NOT EXISTS idx_dch_ts ON domain_cost_history(timestamp); + +CREATE TABLE IF NOT EXISTS domain_lockout_state ( + identifier TEXT PRIMARY KEY, + attempts TEXT NOT NULL, + locked_until INTEGER +); + +CREATE TABLE IF NOT EXISTS domain_circuit_breakers ( + name TEXT PRIMARY KEY, + state TEXT NOT NULL DEFAULT 'CLOSED', + failure_count INTEGER DEFAULT 0, + last_failure_time INTEGER, + options TEXT +); + +CREATE TABLE IF NOT EXISTS semantic_cache ( + id TEXT PRIMARY KEY, + signature TEXT NOT NULL UNIQUE, + model TEXT NOT NULL, + prompt_hash TEXT NOT NULL, + response TEXT NOT NULL, + tokens_saved INTEGER DEFAULT 0, + hit_count INTEGER DEFAULT 0, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_sc_sig ON semantic_cache(signature); +CREATE INDEX IF NOT EXISTS idx_sc_model ON semantic_cache(model); diff --git a/src/lib/db/prompts.ts b/src/lib/db/prompts.ts new file mode 100644 index 0000000000..b2a27f22ce --- /dev/null +++ b/src/lib/db/prompts.ts @@ -0,0 +1,241 @@ +/** + * Prompt Template Versioning โ€” L-6 + * + * SQLite-backed prompt template storage with version tracking. + * Each prompt has a unique `slug`, and every save creates a new version + * (content-addressed via SHA-256 hash). Previous versions are retained + * for rollback and audit. + * + * @module lib/db/prompts + */ + +import crypto from "node:crypto"; +import { getDbInstance } from "./core"; + +// โ”€โ”€ Schema (auto-created on first access) โ”€โ”€ + +const PROMPT_SCHEMA = ` + CREATE TABLE IF NOT EXISTS prompt_templates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + slug TEXT NOT NULL, + version INTEGER NOT NULL DEFAULT 1, + content TEXT NOT NULL, + content_hash TEXT NOT NULL, + variables TEXT, + description TEXT, + is_active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(slug, version) + ); + CREATE INDEX IF NOT EXISTS idx_pt_slug ON prompt_templates(slug); + CREATE INDEX IF NOT EXISTS idx_pt_active ON prompt_templates(slug, is_active); + CREATE INDEX IF NOT EXISTS idx_pt_hash ON prompt_templates(content_hash); +`; + +let _initialized = false; + +function ensureSchema(): void { + if (_initialized) return; + try { + const db = getDbInstance(); + db.exec(PROMPT_SCHEMA); + _initialized = true; + } catch { + // Schema creation is best-effort during build phase + } +} + +function hashContent(content: string): string { + return crypto.createHash("sha256").update(content).digest("hex").slice(0, 16); +} + +// โ”€โ”€ Public API โ”€โ”€ + +export interface PromptTemplate { + id: number; + slug: string; + version: number; + content: string; + contentHash: string; + variables: string[] | null; + description: string | null; + isActive: boolean; + createdAt: string; +} + +/** + * Save a prompt template. If the slug already exists and the content + * has changed, a new version is created. If content is identical, + * returns the existing version without duplicating. + */ +export function savePrompt( + slug: string, + content: string, + options: { variables?: string[]; description?: string } = {} +): PromptTemplate { + ensureSchema(); + const db = getDbInstance(); + const hash = hashContent(content); + + // Check if identical content already exists for this slug + const existing = db + .prepare("SELECT * FROM prompt_templates WHERE slug = ? AND content_hash = ?") + .get(slug, hash) as any; + + if (existing) { + return rowToPrompt(existing); + } + + // Deactivate previous active version + db.prepare("UPDATE prompt_templates SET is_active = 0 WHERE slug = ? AND is_active = 1").run( + slug + ); + + // Get next version number + const maxVersion = db + .prepare("SELECT MAX(version) as max_v FROM prompt_templates WHERE slug = ?") + .get(slug) as any; + const nextVersion = (maxVersion?.max_v || 0) + 1; + + // Insert new version + const result = db + .prepare( + `INSERT INTO prompt_templates (slug, version, content, content_hash, variables, description, is_active) + VALUES (?, ?, ?, ?, ?, ?, 1)` + ) + .run( + slug, + nextVersion, + content, + hash, + options.variables ? JSON.stringify(options.variables) : null, + options.description || null + ); + + return { + id: Number(result.lastInsertRowid), + slug, + version: nextVersion, + content, + contentHash: hash, + variables: options.variables || null, + description: options.description || null, + isActive: true, + createdAt: new Date().toISOString(), + }; +} + +/** + * Get the active (latest) version of a prompt by slug. + */ +export function getActivePrompt(slug: string): PromptTemplate | null { + ensureSchema(); + const db = getDbInstance(); + const row = db + .prepare("SELECT * FROM prompt_templates WHERE slug = ? AND is_active = 1") + .get(slug) as any; + return row ? rowToPrompt(row) : null; +} + +/** + * Get a specific version of a prompt. + */ +export function getPromptVersion(slug: string, version: number): PromptTemplate | null { + ensureSchema(); + const db = getDbInstance(); + const row = db + .prepare("SELECT * FROM prompt_templates WHERE slug = ? AND version = ?") + .get(slug, version) as any; + return row ? rowToPrompt(row) : null; +} + +/** + * List all versions of a prompt (newest first). + */ +export function listPromptVersions(slug: string): PromptTemplate[] { + ensureSchema(); + const db = getDbInstance(); + const rows = db + .prepare("SELECT * FROM prompt_templates WHERE slug = ? ORDER BY version DESC") + .all(slug) as any[]; + return rows.map(rowToPrompt); +} + +/** + * List all prompt slugs with their active version info. + */ +export function listPrompts(): Array<{ slug: string; activeVersion: number; totalVersions: number }> { + ensureSchema(); + const db = getDbInstance(); + const rows = db + .prepare( + `SELECT slug, + MAX(CASE WHEN is_active = 1 THEN version ELSE 0 END) as active_version, + COUNT(*) as total_versions + FROM prompt_templates + GROUP BY slug + ORDER BY slug` + ) + .all() as any[]; + + return rows.map((r) => ({ + slug: r.slug, + activeVersion: r.active_version, + totalVersions: r.total_versions, + })); +} + +/** + * Rollback to a previous version (makes it the active one). + */ +export function rollbackPrompt(slug: string, version: number): PromptTemplate | null { + ensureSchema(); + const db = getDbInstance(); + + const target = db + .prepare("SELECT * FROM prompt_templates WHERE slug = ? AND version = ?") + .get(slug, version) as any; + + if (!target) return null; + + const rollback = db.transaction(() => { + db.prepare("UPDATE prompt_templates SET is_active = 0 WHERE slug = ?").run(slug); + db.prepare("UPDATE prompt_templates SET is_active = 1 WHERE slug = ? AND version = ?").run( + slug, + version + ); + }); + rollback(); + + return rowToPrompt({ ...target, is_active: 1 }); +} + +/** + * Render a prompt template by substituting variables. + */ +export function renderPrompt(slug: string, vars: Record = {}): string | null { + const prompt = getActivePrompt(slug); + if (!prompt) return null; + + let content = prompt.content; + for (const [key, value] of Object.entries(vars)) { + content = content.replace(new RegExp(`\\{\\{${key}\\}\\}`, "g"), value); + } + return content; +} + +// โ”€โ”€ Internal โ”€โ”€ + +function rowToPrompt(row: any): PromptTemplate { + return { + id: row.id, + slug: row.slug, + version: row.version, + content: row.content, + contentHash: row.content_hash, + variables: row.variables ? JSON.parse(row.variables) : null, + description: row.description, + isActive: row.is_active === 1, + createdAt: row.created_at, + }; +} diff --git a/src/lib/evals/evalRunner.ts b/src/lib/evals/evalRunner.ts index bf8bba4975..9e61c3a0b8 100644 --- a/src/lib/evals/evalRunner.ts +++ b/src/lib/evals/evalRunner.ts @@ -8,7 +8,6 @@ * @module lib/evals/evalRunner */ -// @ts-check /** * @typedef {Object} EvalCase diff --git a/src/lib/evals/scheduler.ts b/src/lib/evals/scheduler.ts new file mode 100644 index 0000000000..1e1aed1bfc --- /dev/null +++ b/src/lib/evals/scheduler.ts @@ -0,0 +1,275 @@ +/** + * Eval Scheduler โ€” L-7 + * + * Cron-based scheduling for golden set evaluation runs. + * Uses a simple interval timer (no external cron dependency). + * Results are persisted to SQLite for trend tracking. + * + * @module lib/evals/scheduler + */ + +import { runSuite, listSuites, createScorecard, getSuite } from "./evalRunner"; + +// โ”€โ”€ Types โ”€โ”€ + +export interface ScheduledEval { + suiteId: string; + intervalMs: number; + lastRunAt: number | null; + nextRunAt: number; + enabled: boolean; +} + +export interface EvalRunResult { + suiteId: string; + suiteName: string; + timestamp: number; + passRate: number; + total: number; + passed: number; + failed: number; + results: any[]; +} + +// โ”€โ”€ State โ”€โ”€ + +const _schedules = new Map(); +const _timers = new Map(); +const _history: EvalRunResult[] = []; +let _outputProvider: ((suiteId: string, caseId: string) => Promise) | null = null; + +// โ”€โ”€ Configuration โ”€โ”€ + +/** + * Set the output provider function โ€” called to get actual LLM output + * for each eval case. This decouples the scheduler from the chat pipeline. + * + * @param fn - Async function(suiteId, caseId) โ†’ actual output string + */ +export function setOutputProvider( + fn: (suiteId: string, caseId: string) => Promise +): void { + _outputProvider = fn; +} + +// โ”€โ”€ Scheduling โ”€โ”€ + +/** + * Schedule a suite to run at a fixed interval. + * + * @param suiteId - ID of a registered eval suite + * @param intervalMs - Interval between runs in milliseconds (min 60000 = 1 min) + */ +export function schedule(suiteId: string, intervalMs: number): ScheduledEval { + const safeInterval = Math.max(intervalMs, 60_000); // Min 1 minute + const now = Date.now(); + + // Clear existing timer if re-scheduling + if (_timers.has(suiteId)) { + clearInterval(_timers.get(suiteId) as any); + } + + const entry: ScheduledEval = { + suiteId, + intervalMs: safeInterval, + lastRunAt: null, + nextRunAt: now + safeInterval, + enabled: true, + }; + + _schedules.set(suiteId, entry); + + const timer = setInterval(() => { + executeScheduledRun(suiteId).catch((err) => { + console.error(`[EvalScheduler] Failed to run suite ${suiteId}:`, err.message); + }); + }, safeInterval); + + _timers.set(suiteId, timer); + + console.log( + `[EvalScheduler] Scheduled "${suiteId}" every ${Math.round(safeInterval / 1000)}s` + ); + + return entry; +} + +/** + * Unschedule a suite. + */ +export function unschedule(suiteId: string): boolean { + const timer = _timers.get(suiteId); + if (timer) { + clearInterval(timer as any); + _timers.delete(suiteId); + } + return _schedules.delete(suiteId); +} + +/** + * Pause a scheduled suite without removing it. + */ +export function pause(suiteId: string): boolean { + const entry = _schedules.get(suiteId); + if (!entry) return false; + entry.enabled = false; + const timer = _timers.get(suiteId); + if (timer) { + clearInterval(timer as any); + _timers.delete(suiteId); + } + return true; +} + +/** + * Resume a paused scheduled suite. + */ +export function resume(suiteId: string): boolean { + const entry = _schedules.get(suiteId); + if (!entry) return false; + entry.enabled = true; + return !!schedule(suiteId, entry.intervalMs); +} + +// โ”€โ”€ Execution โ”€โ”€ + +/** + * Execute a scheduled run for a suite. + */ +async function executeScheduledRun(suiteId: string): Promise { + const entry = _schedules.get(suiteId); + if (!entry?.enabled) return null; + + if (!_outputProvider) { + console.warn(`[EvalScheduler] No output provider set โ€” skipping ${suiteId}`); + return null; + } + + console.log(`[EvalScheduler] Running suite: ${suiteId}`); + + try { + // Collect outputs for all cases in the suite + const suites = listSuites(); + const suiteInfo = suites.find((s) => s.id === suiteId); + if (!suiteInfo) { + console.warn(`[EvalScheduler] Suite not found: ${suiteId}`); + return null; + } + + // Get outputs from provider + const outputs: Record = {}; + // We use the suite's cases to get the case IDs + const suite = getSuite(suiteId); + if (!suite?.cases) return null; + + for (const evalCase of suite.cases) { + try { + outputs[evalCase.id] = await _outputProvider(suiteId, evalCase.id); + } catch (err: any) { + console.warn(`[EvalScheduler] Failed to get output for ${evalCase.id}: ${err.message}`); + outputs[evalCase.id] = `[ERROR] ${err.message}`; + } + } + + // Run evaluation + const result = runSuite(suiteId, outputs); + const now = Date.now(); + + const runResult: EvalRunResult = { + suiteId: result.suiteId, + suiteName: result.suiteName, + timestamp: now, + passRate: result.summary.passRate, + total: result.summary.total, + passed: result.summary.passed, + failed: result.summary.failed, + results: result.results, + }; + + // Update schedule state + entry.lastRunAt = now; + entry.nextRunAt = now + entry.intervalMs; + + // Store in history + _history.push(runResult); + // Keep last 100 runs + if (_history.length > 100) _history.shift(); + + console.log( + `[EvalScheduler] ${suiteId}: ${result.summary.passed}/${result.summary.total} passed (${(result.summary.passRate * 100).toFixed(1)}%)` + ); + + return runResult; + } catch (err: any) { + console.error(`[EvalScheduler] Error running ${suiteId}:`, err.message); + return null; + } +} + +/** + * Run a suite immediately (outside of schedule). + */ +export async function runNow(suiteId: string): Promise { + const entry = _schedules.get(suiteId) || { + suiteId, + intervalMs: 0, + lastRunAt: null, + nextRunAt: 0, + enabled: true, + }; + _schedules.set(suiteId, entry); + return executeScheduledRun(suiteId); +} + +// โ”€โ”€ Query โ”€โ”€ + +/** + * Get all scheduled suites and their status. + */ +export function getSchedules(): ScheduledEval[] { + return Array.from(_schedules.values()); +} + +/** + * Get run history for a suite (newest first). + */ +export function getHistory(suiteId?: string): EvalRunResult[] { + const filtered = suiteId ? _history.filter((r) => r.suiteId === suiteId) : _history; + return [...filtered].reverse(); +} + +/** + * Get a scorecard across all recent runs. + */ +export function getScorecard(): ReturnType | null { + if (_history.length === 0) return null; + + // Get latest run per suite + const latestBySuite = new Map(); + for (const run of _history) { + latestBySuite.set(run.suiteId, run); + } + + // Build scorecard from latest runs + const runs = Array.from(latestBySuite.values()).map((r) => ({ + suiteId: r.suiteId, + suiteName: r.suiteName, + results: r.results, + summary: { total: r.total, passed: r.passed, failed: r.failed, passRate: r.passRate }, + })); + + return createScorecard(runs); +} + +/** + * Stop all scheduled evaluations and clear state. + */ +export function stopAll(): void { + for (const timer of _timers.values()) { + clearInterval(timer as any); + } + _timers.clear(); + _schedules.clear(); + _history.length = 0; + _outputProvider = null; +} diff --git a/src/lib/gracefulShutdown.ts b/src/lib/gracefulShutdown.ts new file mode 100644 index 0000000000..c33a64acc4 --- /dev/null +++ b/src/lib/gracefulShutdown.ts @@ -0,0 +1,124 @@ +/** + * Graceful Shutdown โ€” E-2 Critical Fix + * + * Handles SIGTERM / SIGINT to drain in-flight requests before exit. + * Critical for Docker containers and Kubernetes pods where hard kills + * can drop active SSE streams. + * + * Usage: + * import { initGracefulShutdown } from "@/lib/gracefulShutdown"; + * initGracefulShutdown(); + * + * @module lib/gracefulShutdown + */ + +/** Whether we are currently shutting down */ +let isShuttingDown = false; + +/** Number of in-flight requests being tracked */ +let activeRequests = 0; + +/** Grace period before forced exit (default 30s, configurable) */ +const SHUTDOWN_TIMEOUT_MS = parseInt(process.env.SHUTDOWN_TIMEOUT_MS || "30000", 10); + +/** + * Check if the server is currently shutting down. + * Route handlers can use this to reject new requests. + */ +export function isDraining(): boolean { + return isShuttingDown; +} + +/** + * Track a new in-flight request. Call `done()` when it completes. + * Returns a done callback. + */ +export function trackRequest(): () => void { + activeRequests++; + let called = false; + return () => { + if (!called) { + called = true; + activeRequests--; + } + }; +} + +/** + * Get current active request count (for monitoring/health endpoints). + */ +export function getActiveRequestCount(): number { + return activeRequests; +} + +/** + * Wait for all in-flight requests to complete, with timeout. + */ +async function waitForDrain(): Promise { + const start = Date.now(); + const CHECK_INTERVAL_MS = 250; + + return new Promise((resolve) => { + const check = () => { + if (activeRequests <= 0) { + console.log("[Shutdown] All in-flight requests drained."); + resolve(); + return; + } + + if (Date.now() - start > SHUTDOWN_TIMEOUT_MS) { + console.warn( + `[Shutdown] Timeout after ${SHUTDOWN_TIMEOUT_MS}ms with ${activeRequests} active requests. Forcing exit.` + ); + resolve(); + return; + } + + console.log(`[Shutdown] Waiting for ${activeRequests} in-flight request(s)...`); + setTimeout(check, CHECK_INTERVAL_MS); + }; + + check(); + }); +} + +/** + * Perform cleanup: close DB connections, flush logs. + */ +async function cleanup(): Promise { + try { + // Close SQLite database โ€” import dynamically to avoid circular deps + const { getDbInstance } = await import("@/lib/db/core"); + const db = getDbInstance(); + if (db && typeof db.close === "function") { + db.close(); + console.log("[Shutdown] SQLite database closed."); + } + } catch (err) { + console.error("[Shutdown] Error during cleanup:", (err as Error).message); + } +} + +/** + * Initialize graceful shutdown handlers. + * Should be called once during server startup. + */ +export function initGracefulShutdown(): void { + const shutdown = async (signal: string) => { + if (isShuttingDown) return; // Prevent double-shutdown + isShuttingDown = true; + + console.log(`\n[Shutdown] Received ${signal}. Draining ${activeRequests} request(s)...`); + + await waitForDrain(); + await cleanup(); + + console.log("[Shutdown] Bye."); + process.exit(0); + }; + + process.on("SIGTERM", () => shutdown("SIGTERM")); + process.on("SIGINT", () => shutdown("SIGINT")); + + console.log("[Shutdown] Graceful shutdown handlers registered."); +} diff --git a/src/lib/piiSanitizer.ts b/src/lib/piiSanitizer.ts new file mode 100644 index 0000000000..dd439ace18 --- /dev/null +++ b/src/lib/piiSanitizer.ts @@ -0,0 +1,179 @@ +/** + * Output PII Sanitization โ€” L-3 + * + * Scans LLM response text for PII patterns and optionally redacts them. + * This is the OUTPUT-side counterpart to the input sanitizer. + * Configurable via environment variables: + * + * PII_RESPONSE_SANITIZATION=true|false (default: false) + * PII_RESPONSE_SANITIZATION_MODE=redact|warn|block (default: redact) + * + * @module lib/piiSanitizer + */ + +// โ”€โ”€ Configuration โ”€โ”€ + +const isEnabled = () => process.env.PII_RESPONSE_SANITIZATION === "true"; +const getMode = (): "redact" | "warn" | "block" => + (process.env.PII_RESPONSE_SANITIZATION_MODE as "redact" | "warn" | "block") || "redact"; + +// โ”€โ”€ PII Patterns โ”€โ”€ + +interface PIIPattern { + name: string; + regex: RegExp; + replacement: string; + severity: "high" | "medium" | "low"; +} + +const PII_PATTERNS: PIIPattern[] = [ + { + name: "email", + regex: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/g, + replacement: "[EMAIL_REDACTED]", + severity: "medium", + }, + { + name: "ssn", + regex: /\b\d{3}-\d{2}-\d{4}\b/g, + replacement: "[SSN_REDACTED]", + severity: "high", + }, + { + name: "credit_card", + regex: /\b(?:\d{4}[-\s]?){3}\d{4}\b/g, + replacement: "[CC_REDACTED]", + severity: "high", + }, + { + name: "phone_us", + regex: /\b(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b/g, + replacement: "[PHONE_REDACTED]", + severity: "medium", + }, + { + name: "phone_br", + regex: /\b(?:\+?55[-.\s]?)?\(?\d{2}\)?[-.\s]?\d{4,5}[-.\s]?\d{4}\b/g, + replacement: "[PHONE_REDACTED]", + severity: "medium", + }, + { + name: "cpf", + regex: /\b\d{3}\.\d{3}\.\d{3}-\d{2}\b/g, + replacement: "[CPF_REDACTED]", + severity: "high", + }, + { + name: "cnpj", + regex: /\b\d{2}\.\d{3}\.\d{3}\/\d{4}-\d{2}\b/g, + replacement: "[CNPJ_REDACTED]", + severity: "high", + }, + { + name: "ip_address", + regex: /\b(?:\d{1,3}\.){3}\d{1,3}\b/g, + replacement: "[IP_REDACTED]", + severity: "low", + }, + { + name: "aws_key", + regex: /\bAKIA[0-9A-Z]{16}\b/g, + replacement: "[AWS_KEY_REDACTED]", + severity: "high", + }, + { + name: "api_key_generic", + regex: /\b(?:sk|pk|api|key|token)[_-][a-zA-Z0-9]{20,}\b/gi, + replacement: "[API_KEY_REDACTED]", + severity: "high", + }, +]; + +// โ”€โ”€ Public API โ”€โ”€ + +export interface SanitizeResult { + text: string; + detections: Array<{ + pattern: string; + count: number; + severity: string; + }>; + redacted: boolean; +} + +/** + * Scan and optionally redact PII from LLM response text. + */ +export function sanitizePII(text: string): SanitizeResult { + if (!isEnabled() || !text || typeof text !== "string") { + return { text, detections: [], redacted: false }; + } + + const mode = getMode(); + const detections: SanitizeResult["detections"] = []; + let sanitized = text; + + for (const pattern of PII_PATTERNS) { + // Reset lastIndex for global regexes + pattern.regex.lastIndex = 0; + const matches = text.match(pattern.regex); + if (matches && matches.length > 0) { + detections.push({ + pattern: pattern.name, + count: matches.length, + severity: pattern.severity, + }); + + if (mode === "redact") { + pattern.regex.lastIndex = 0; + sanitized = sanitized.replace(pattern.regex, pattern.replacement); + } + } + } + + if (detections.length > 0 && mode === "warn") { + console.warn( + `[PII] Detected PII in response: ${detections.map((d) => `${d.pattern}(${d.count})`).join(", ")}` + ); + } + + return { + text: mode === "redact" ? sanitized : text, + detections, + redacted: mode === "redact" && detections.length > 0, + }; +} + +/** + * Sanitize a streaming chunk (text content only). + */ +export function sanitizePIIChunk(chunk: string): string { + if (!isEnabled()) return chunk; + const { text } = sanitizePII(chunk); + return text; +} + +/** + * Sanitize PII in a full response object (OpenAI-compatible format). + */ +export function sanitizePIIResponse(response: any): any { + if (!isEnabled() || !response) return response; + + try { + const choices = response.choices || []; + for (const choice of choices) { + if (choice.message?.content) { + const result = sanitizePII(choice.message.content); + choice.message.content = result.text; + } + if (choice.delta?.content) { + const result = sanitizePII(choice.delta.content); + choice.delta.content = result.text; + } + } + } catch { + // Fail open โ€” don't break the response + } + + return response; +} diff --git a/src/lib/plugins/index.ts b/src/lib/plugins/index.ts new file mode 100644 index 0000000000..492719bbf8 --- /dev/null +++ b/src/lib/plugins/index.ts @@ -0,0 +1,214 @@ +/** + * Plugin/Middleware Architecture โ€” L-8 + * + * Pre/post hooks on the request pipeline. Plugins are registered + * with a priority (lower = runs first) and can intercept requests + * before they reach the chat handler or modify responses after. + * + * Lifecycle: + * onRequest โ†’ runs BEFORE chat handler (can block/modify request) + * onResponse โ†’ runs AFTER chat handler (can modify/log response) + * onError โ†’ runs on handler errors (can recover or re-throw) + * + * @module lib/plugins + */ + +// โ”€โ”€ Types โ”€โ”€ + +export interface PluginContext { + /** Unique request ID */ + requestId: string; + /** Request body (parsed JSON) */ + body: any; + /** Model string */ + model: string; + /** Provider (if resolved) */ + provider?: string; + /** API key info */ + apiKeyInfo?: any; + /** Arbitrary metadata plugins can share */ + metadata: Record; +} + +export interface PluginResult { + /** If true, stop processing further plugins and return immediately */ + blocked?: boolean; + /** Optional response to return if blocked */ + response?: any; + /** Modified body (if any) */ + body?: any; + /** Modified metadata */ + metadata?: Record; +} + +export interface Plugin { + /** Unique plugin name */ + name: string; + /** Priority (lower = runs first, default 100) */ + priority?: number; + /** Whether the plugin is enabled */ + enabled?: boolean; + /** Called before the chat handler */ + onRequest?: (ctx: PluginContext) => Promise | PluginResult | void; + /** Called after the chat handler */ + onResponse?: ( + ctx: PluginContext, + response: any + ) => Promise | any | void; + /** Called on handler error */ + onError?: ( + ctx: PluginContext, + error: Error + ) => Promise | any | void; +} + +// โ”€โ”€ Registry โ”€โ”€ + +const _plugins: Plugin[] = []; + +/** + * Register a plugin. Plugins are sorted by priority on each registration. + */ +export function registerPlugin(plugin: Plugin): void { + // Set defaults + plugin.priority = plugin.priority ?? 100; + plugin.enabled = plugin.enabled ?? true; + + // Remove existing plugin with same name (re-registration) + const idx = _plugins.findIndex((p) => p.name === plugin.name); + if (idx !== -1) _plugins.splice(idx, 1); + + _plugins.push(plugin); + _plugins.sort((a, b) => (a.priority || 100) - (b.priority || 100)); + + console.log( + `[Plugins] Registered "${plugin.name}" (priority: ${plugin.priority}, enabled: ${plugin.enabled})` + ); +} + +/** + * Unregister a plugin by name. + */ +export function unregisterPlugin(name: string): boolean { + const idx = _plugins.findIndex((p) => p.name === name); + if (idx === -1) return false; + _plugins.splice(idx, 1); + return true; +} + +/** + * Enable/disable a plugin at runtime. + */ +export function setPluginEnabled(name: string, enabled: boolean): boolean { + const plugin = _plugins.find((p) => p.name === name); + if (!plugin) return false; + plugin.enabled = enabled; + return true; +} + +/** + * List all registered plugins. + */ +export function listPlugins(): Array<{ + name: string; + priority: number; + enabled: boolean; + hooks: string[]; +}> { + return _plugins.map((p) => ({ + name: p.name, + priority: p.priority || 100, + enabled: p.enabled !== false, + hooks: [ + p.onRequest ? "onRequest" : "", + p.onResponse ? "onResponse" : "", + p.onError ? "onError" : "", + ].filter(Boolean), + })); +} + +// โ”€โ”€ Execution โ”€โ”€ + +/** + * Run all onRequest hooks. Returns the (possibly modified) context, + * or a blocked response if any plugin blocked the request. + */ +export async function runOnRequest( + ctx: PluginContext +): Promise<{ blocked: boolean; response?: any; ctx: PluginContext }> { + let currentCtx = { ...ctx }; + + for (const plugin of _plugins) { + if (!plugin.enabled || !plugin.onRequest) continue; + + try { + const result = await plugin.onRequest(currentCtx); + if (result) { + if (result.blocked) { + console.log(`[Plugins] Request blocked by "${plugin.name}"`); + return { blocked: true, response: result.response, ctx: currentCtx }; + } + if (result.body) currentCtx.body = result.body; + if (result.metadata) { + currentCtx.metadata = { ...currentCtx.metadata, ...result.metadata }; + } + } + } catch (err: any) { + console.error(`[Plugins] onRequest error in "${plugin.name}": ${err.message}`); + // Plugin errors don't block the pipeline by default + } + } + + return { blocked: false, ctx: currentCtx }; +} + +/** + * Run all onResponse hooks. Returns the (possibly modified) response. + */ +export async function runOnResponse(ctx: PluginContext, response: any): Promise { + let currentResponse = response; + + for (const plugin of _plugins) { + if (!plugin.enabled || !plugin.onResponse) continue; + + try { + const modified = await plugin.onResponse(ctx, currentResponse); + if (modified !== undefined && modified !== null) { + currentResponse = modified; + } + } catch (err: any) { + console.error(`[Plugins] onResponse error in "${plugin.name}": ${err.message}`); + } + } + + return currentResponse; +} + +/** + * Run all onError hooks. Returns a recovery response if any plugin handles it, + * or null to let the error propagate. + */ +export async function runOnError(ctx: PluginContext, error: Error): Promise { + for (const plugin of _plugins) { + if (!plugin.enabled || !plugin.onError) continue; + + try { + const recovery = await plugin.onError(ctx, error); + if (recovery !== undefined && recovery !== null) { + console.log(`[Plugins] Error recovered by "${plugin.name}"`); + return recovery; + } + } catch (err: any) { + console.error(`[Plugins] onError error in "${plugin.name}": ${err.message}`); + } + } + + return null; // No recovery โ€” let error propagate +} + +/** + * Reset all plugins (for testing). + */ +export function resetPlugins(): void { + _plugins.length = 0; +} diff --git a/src/lib/semanticCache.ts b/src/lib/semanticCache.ts index 2ad92d86e2..d6ab80e3a3 100644 --- a/src/lib/semanticCache.ts +++ b/src/lib/semanticCache.ts @@ -160,6 +160,90 @@ export function cleanExpiredEntries() { } } +/** + * Invalidate cache entries by model name. + * Useful when a model is updated/changed and cached responses are stale. + * @param {string} model - Model name to invalidate (exact match) + * @returns {number} Number of entries removed + */ +export function invalidateByModel(model: string): number { + getMemoryCache().clear(); // Memory cache doesn't track model; full clear + try { + const db = getDbInstance(); + const result = db + .prepare("DELETE FROM semantic_cache WHERE model = ?") + .run(model); + return result.changes || 0; + } catch { + return 0; + } +} + +/** + * Invalidate a single cache entry by its signature. + * @param {string} signature - Cache signature to invalidate + * @returns {boolean} Whether the entry was found and removed + */ +export function invalidateBySignature(signature: string): boolean { + getMemoryCache().delete(signature); + try { + const db = getDbInstance(); + const result = db + .prepare("DELETE FROM semantic_cache WHERE signature = ?") + .run(signature); + return (result.changes || 0) > 0; + } catch { + return false; + } +} + +/** + * Invalidate entries older than a given age. + * @param {number} maxAgeMs - Maximum age in milliseconds + * @returns {number} Number of entries removed + */ +export function invalidateStale(maxAgeMs: number): number { + getMemoryCache().clear(); + try { + const db = getDbInstance(); + const cutoff = new Date(Date.now() - maxAgeMs).toISOString(); + const result = db + .prepare("DELETE FROM semantic_cache WHERE created_at < ?") + .run(cutoff); + return result.changes || 0; + } catch { + return 0; + } +} + +// โ”€โ”€ Auto-cleanup timer โ”€โ”€ + +let _cleanupTimer: ReturnType | null = null; + +/** + * Start periodic auto-cleanup of expired entries. + * @param {number} intervalMs - Cleanup interval (default: 5 minutes) + */ +export function startAutoCleanup(intervalMs = 300_000): void { + stopAutoCleanup(); + _cleanupTimer = setInterval(() => { + const removed = cleanExpiredEntries(); + if (removed > 0) { + console.log(`[SemanticCache] Auto-cleaned ${removed} expired entries`); + } + }, intervalMs); +} + +/** + * Stop periodic auto-cleanup. + */ +export function stopAutoCleanup(): void { + if (_cleanupTimer) { + clearInterval(_cleanupTimer); + _cleanupTimer = null; + } +} + /** * Clear all cache entries. */ diff --git a/src/lib/toolPolicy.ts b/src/lib/toolPolicy.ts new file mode 100644 index 0000000000..1197f764cb --- /dev/null +++ b/src/lib/toolPolicy.ts @@ -0,0 +1,150 @@ +/** + * Tool-Calling Policy โ€” L-4 + * + * Allowlist/denylist for tool (function) calling in LLM requests. + * Controls which tool names can be invoked, preventing dangerous + * tool use via prompt injection or misconfiguration. + * + * Configuration via environment variables: + * TOOL_POLICY_MODE=allowlist|denylist|disabled (default: disabled) + * TOOL_ALLOWLIST=tool1,tool2,tool3 + * TOOL_DENYLIST=dangerous_tool,exec_command + * + * @module lib/toolPolicy + */ + +// โ”€โ”€ Types โ”€โ”€ + +export interface ToolPolicyResult { + allowed: boolean; + denied: string[]; + reason?: string; +} + +type PolicyMode = "allowlist" | "denylist" | "disabled"; + +// โ”€โ”€ Configuration โ”€โ”€ + +function getMode(): PolicyMode { + return (process.env.TOOL_POLICY_MODE as PolicyMode) || "disabled"; +} + +function parseList(envKey: string): Set { + const raw = process.env[envKey]; + if (!raw) return new Set(); + return new Set( + raw + .split(",") + .map((s) => s.trim().toLowerCase()) + .filter(Boolean) + ); +} + +// โ”€โ”€ Runtime overrides (for dashboard/API configuration) โ”€โ”€ + +let _runtimeAllowlist: Set | null = null; +let _runtimeDenylist: Set | null = null; +let _runtimeMode: PolicyMode | null = null; + +/** + * Override the policy at runtime (e.g., from dashboard settings). + */ +export function setRuntimePolicy(config: { + mode?: PolicyMode; + allowlist?: string[]; + denylist?: string[]; +}): void { + if (config.mode) _runtimeMode = config.mode; + if (config.allowlist) _runtimeAllowlist = new Set(config.allowlist.map((s) => s.toLowerCase())); + if (config.denylist) _runtimeDenylist = new Set(config.denylist.map((s) => s.toLowerCase())); +} + +/** + * Reset runtime overrides. + */ +export function resetRuntimePolicy(): void { + _runtimeMode = null; + _runtimeAllowlist = null; + _runtimeDenylist = null; +} + +// โ”€โ”€ Core Logic โ”€โ”€ + +/** + * Evaluate a list of tool names against the policy. + */ +export function evaluateToolPolicy(toolNames: string[]): ToolPolicyResult { + const mode = _runtimeMode || getMode(); + + if (mode === "disabled" || !toolNames || toolNames.length === 0) { + return { allowed: true, denied: [] }; + } + + const normalizedNames = toolNames.map((n) => n.toLowerCase()); + + if (mode === "allowlist") { + const allowlist = _runtimeAllowlist || parseList("TOOL_ALLOWLIST"); + if (allowlist.size === 0) { + return { allowed: true, denied: [], reason: "Allowlist is empty โ€” all tools permitted" }; + } + + const denied = normalizedNames.filter((name) => !allowlist.has(name)); + return { + allowed: denied.length === 0, + denied, + reason: denied.length > 0 ? `Tools not in allowlist: ${denied.join(", ")}` : undefined, + }; + } + + if (mode === "denylist") { + const denylist = _runtimeDenylist || parseList("TOOL_DENYLIST"); + const denied = normalizedNames.filter((name) => denylist.has(name)); + return { + allowed: denied.length === 0, + denied, + reason: denied.length > 0 ? `Tools in denylist: ${denied.join(", ")}` : undefined, + }; + } + + return { allowed: true, denied: [] }; +} + +/** + * Extract tool names from an OpenAI-compatible request body. + */ +export function extractToolNames(body: any): string[] { + const tools: string[] = []; + + // tools array (new format) + if (Array.isArray(body?.tools)) { + for (const tool of body.tools) { + if (tool?.function?.name) { + tools.push(tool.function.name); + } + } + } + + // functions array (legacy format) + if (Array.isArray(body?.functions)) { + for (const fn of body.functions) { + if (fn?.name) { + tools.push(fn.name); + } + } + } + + // tool_choice (if specific tool is forced) + if (body?.tool_choice?.function?.name) { + tools.push(body.tool_choice.function.name); + } + + return tools; +} + +/** + * Convenience: validate an entire request body against the tool policy. + */ +export function validateToolsInRequest(body: any): ToolPolicyResult { + const toolNames = extractToolNames(body); + return evaluateToolPolicy(toolNames); +} diff --git a/src/lib/usage/callLogs.ts b/src/lib/usage/callLogs.ts index 4c87229378..500dbb5013 100644 --- a/src/lib/usage/callLogs.ts +++ b/src/lib/usage/callLogs.ts @@ -1,4 +1,3 @@ -// @ts-check /** * Call Logs โ€” extracted from usageDb.js (T-15) * diff --git a/src/lib/usage/costCalculator.ts b/src/lib/usage/costCalculator.ts index 76892e341a..ffa94301f6 100644 --- a/src/lib/usage/costCalculator.ts +++ b/src/lib/usage/costCalculator.ts @@ -1,4 +1,3 @@ -// @ts-check /** * Cost Calculator โ€” extracted from usageDb.js (T-15) * diff --git a/src/lib/usage/usageHistory.ts b/src/lib/usage/usageHistory.ts index 5457c7ebee..8e25baf9cc 100644 --- a/src/lib/usage/usageHistory.ts +++ b/src/lib/usage/usageHistory.ts @@ -1,4 +1,3 @@ -// @ts-check /** * Usage History โ€” extracted from usageDb.js (T-15) * diff --git a/src/mitm/server.ts b/src/mitm/server.ts index 3bb4b9de85..54c22fb03f 100644 --- a/src/mitm/server.ts +++ b/src/mitm/server.ts @@ -11,6 +11,9 @@ const LOCAL_PORT = 443; const ROUTER_URL = "http://localhost:20128/v1/chat/completions"; const API_KEY = process.env.ROUTER_API_KEY; const DB_FILE = path.join(os.homedir(), ".omniroute", "db.json"); +const SQLITE_FILE = path.join(os.homedir(), ".omniroute", "storage.sqlite"); + +let _sqliteDb = null; // Toggle logging (set true to enable file logging for debugging) const ENABLE_FILE_LOG = false; @@ -90,14 +93,56 @@ function extractModel(body) { } } +/** + * Get a lazy SQLite connection for reading MITM aliases. + * Falls back to null if better-sqlite3 is unavailable. + */ +function getSqliteDb() { + if (_sqliteDb) return _sqliteDb; + try { + const Database = require("better-sqlite3"); + if (fs.existsSync(SQLITE_FILE)) { + _sqliteDb = new Database(SQLITE_FILE, { readonly: true }); + return _sqliteDb; + } + } catch { + // better-sqlite3 not available in this process + } + return null; +} + function getMappedModel(model) { if (!model) return null; + + // Primary: read from SQLite key_value table try { - const db = JSON.parse(fs.readFileSync(DB_FILE, "utf-8")); - return db.mitmAlias?.antigravity?.[model] || null; + const db = getSqliteDb(); + if (db) { + const row = db + .prepare( + "SELECT value FROM key_value WHERE namespace = 'mitmAlias' AND key = 'antigravity'" + ) + .get(); + if (row) { + const mappings = JSON.parse(row.value); + return mappings[model] || null; + } + } } catch { - return null; + // Fall through to JSON fallback } + + // Fallback: read from db.json (legacy installs not yet migrated) + try { + if (fs.existsSync(DB_FILE)) { + const db = JSON.parse(fs.readFileSync(DB_FILE, "utf-8")); + return db.mitmAlias?.antigravity?.[model] || null; + } + } catch { + // Ignore + } + + return null; } async function passthrough(req, res, bodyBuffer) { diff --git a/src/proxy.ts b/src/proxy.ts index 2ecabc7f52..b56ce32aa2 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -3,6 +3,8 @@ import { jwtVerify } from "jose"; import { generateRequestId } from "./shared/utils/requestId"; import { getSettings } from "./lib/localDb"; import { isPublicRoute, verifyAuth, isAuthRequired } from "./shared/utils/apiAuth"; +import { checkBodySize, getBodySizeLimit } from "./shared/middleware/bodySizeGuard"; +import { isDraining } from "./lib/gracefulShutdown"; // FASE-01: Fail-fast โ€” no hardcoded fallback. Server must have JWT_SECRET configured. if (!process.env.JWT_SECRET) { @@ -19,6 +21,26 @@ export async function proxy(request) { const response = NextResponse.next(); response.headers.set("X-Request-Id", requestId); + // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ Pre-flight: Reject during shutdown drain โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + if (isDraining() && pathname.startsWith("/api/")) { + return NextResponse.json( + { + error: { + code: "SERVICE_UNAVAILABLE", + message: "Server is shutting down", + correlation_id: requestId, + }, + }, + { status: 503 } + ); + } + + // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ Pre-flight: Reject oversized bodies โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + if (pathname.startsWith("/api/") && request.method !== "GET" && request.method !== "OPTIONS") { + const bodySizeRejection = checkBodySize(request, getBodySizeLimit(pathname)); + if (bodySizeRejection) return bodySizeRejection; + } + // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ Protect Management API Routes โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ if (pathname.startsWith("/api/") && !pathname.startsWith("/api/v1/")) { // Allow public routes (login, logout, health, etc.) diff --git a/src/shared/middleware/bodySizeGuard.ts b/src/shared/middleware/bodySizeGuard.ts new file mode 100644 index 0000000000..f028484f0c --- /dev/null +++ b/src/shared/middleware/bodySizeGuard.ts @@ -0,0 +1,83 @@ +/** + * Body Size Guard โ€” E-1 Critical Fix + * + * Middleware helper that rejects oversized request bodies + * before they are parsed, preventing OOM from malicious payloads. + * + * Usage: + * import { checkBodySize, MAX_BODY_BYTES } from "@/shared/middleware/bodySizeGuard"; + * + * const rejection = checkBodySize(request); + * if (rejection) return rejection; + * + * @module shared/middleware/bodySizeGuard + */ + +/** Default maximum body size: 10 MB */ +const DEFAULT_MAX_BODY_BYTES = 10 * 1024 * 1024; + +/** Larger limit for backup/import routes: 100 MB */ +export const MAX_BODY_BYTES_IMPORT = 100 * 1024 * 1024; + +/** Larger limit for audio transcription uploads: 100 MB */ +export const MAX_BODY_BYTES_AUDIO = 100 * 1024 * 1024; + +/** Configured limit โ€” reads from env or falls back to 10 MB */ +export const MAX_BODY_BYTES = parseInt( + process.env.MAX_BODY_SIZE_BYTES || String(DEFAULT_MAX_BODY_BYTES), + 10 +); + +type BodySizeRule = { prefix: string; limit: number }; + +const ROUTE_LIMITS: BodySizeRule[] = [ + { prefix: "/api/db-backups/import", limit: MAX_BODY_BYTES_IMPORT }, + { prefix: "/api/v1/audio/transcriptions", limit: MAX_BODY_BYTES_AUDIO }, +]; + +/** + * Resolve the body size limit for a request path. + */ +export function getBodySizeLimit(pathname: string): number { + const customRule = ROUTE_LIMITS.find((rule) => pathname.startsWith(rule.prefix)); + return customRule?.limit ?? MAX_BODY_BYTES; +} + +/** + * Check Content-Length header against the configured limit. + * Returns a 413 Response if the body is too large, or null if OK. + */ +export function checkBodySize(request: Request, limit: number = MAX_BODY_BYTES): Response | null { + const contentLength = request.headers.get("content-length"); + + if (contentLength) { + const bytes = parseInt(contentLength, 10); + if (!Number.isNaN(bytes) && bytes > limit) { + return new Response( + JSON.stringify({ + error: { + message: `Request body too large. Maximum allowed: ${formatBytes(limit)}`, + type: "payload_too_large", + code: "PAYLOAD_TOO_LARGE", + }, + }), + { + status: 413, + headers: { + "Content-Type": "application/json", + "Access-Control-Allow-Origin": process.env.CORS_ORIGIN || "*", + }, + } + ); + } + } + + return null; +} + +/** Format bytes as human-readable string */ +function formatBytes(bytes: number): string { + if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(0)} MB`; + if (bytes >= 1024) return `${(bytes / 1024).toFixed(0)} KB`; + return `${bytes} bytes`; +} diff --git a/src/shared/schemas/validation.ts b/src/shared/schemas/validation.ts index cac82da2d0..447fbd83a0 100644 --- a/src/shared/schemas/validation.ts +++ b/src/shared/schemas/validation.ts @@ -1,4 +1,3 @@ -// @ts-check /** * Zod Validation Schemas โ€” Shared request schemas for API routes * diff --git a/src/shared/utils/a11yAudit.ts b/src/shared/utils/a11yAudit.ts index a3d44ba615..6f8738066e 100644 --- a/src/shared/utils/a11yAudit.ts +++ b/src/shared/utils/a11yAudit.ts @@ -1,4 +1,3 @@ -// @ts-check /** * a11y Audit โ€” Basic WCAG Accessibility Checker * diff --git a/src/shared/utils/apiAuth.ts b/src/shared/utils/apiAuth.ts index 71e99b75c4..937315b523 100644 --- a/src/shared/utils/apiAuth.ts +++ b/src/shared/utils/apiAuth.ts @@ -20,6 +20,7 @@ const PUBLIC_API_ROUTES = [ // Auth flow โ€” must be accessible to unauthenticated users "/api/auth/login", "/api/auth/logout", + "/api/auth/status", // Settings check โ€” used by login page / onboarding "/api/settings/require-login", diff --git a/src/shared/utils/circuitBreaker.ts b/src/shared/utils/circuitBreaker.ts index 7dac3f133e..daa917f4a6 100644 --- a/src/shared/utils/circuitBreaker.ts +++ b/src/shared/utils/circuitBreaker.ts @@ -1,4 +1,3 @@ -// @ts-check /** * Circuit Breaker โ€” FASE-04 Observability & Resilience * diff --git a/src/shared/utils/cors.ts b/src/shared/utils/cors.ts index de3b614c38..6e045c767d 100644 --- a/src/shared/utils/cors.ts +++ b/src/shared/utils/cors.ts @@ -15,7 +15,7 @@ * export function OPTIONS() { return handleCorsOptions(); } */ -const CORS_ORIGIN = process.env.CORS_ORIGIN || "*"; +export const CORS_ORIGIN = process.env.CORS_ORIGIN || "*"; /** * Standard CORS headers to spread into any Response. diff --git a/src/shared/utils/costEstimator.ts b/src/shared/utils/costEstimator.ts index 3aaefb65aa..dfc56e4ef7 100644 --- a/src/shared/utils/costEstimator.ts +++ b/src/shared/utils/costEstimator.ts @@ -1,4 +1,3 @@ -// @ts-check /** * Cost Estimator โ€” Pre-flight cost estimation for LLM requests * diff --git a/src/shared/utils/fetchTimeout.ts b/src/shared/utils/fetchTimeout.ts index e13752ef2f..f48e76be9b 100644 --- a/src/shared/utils/fetchTimeout.ts +++ b/src/shared/utils/fetchTimeout.ts @@ -7,7 +7,6 @@ * @module shared/utils/fetchTimeout */ -// @ts-check const DEFAULT_TIMEOUT_MS = 120000; // 2 minutes const FETCH_TIMEOUT_MS = parseInt(process.env.FETCH_TIMEOUT_MS || "", 10) || DEFAULT_TIMEOUT_MS; diff --git a/src/shared/utils/inputSanitizer.ts b/src/shared/utils/inputSanitizer.ts index a1638b5772..d98ae9a313 100644 --- a/src/shared/utils/inputSanitizer.ts +++ b/src/shared/utils/inputSanitizer.ts @@ -1,4 +1,3 @@ -// @ts-check /** * Input Sanitizer โ€” FASE-01 Security Hardening * diff --git a/src/shared/utils/requestId.ts b/src/shared/utils/requestId.ts index abf7d9b285..db99bbb619 100644 --- a/src/shared/utils/requestId.ts +++ b/src/shared/utils/requestId.ts @@ -10,7 +10,6 @@ * @module shared/utils/requestId */ -// @ts-check import { AsyncLocalStorage } from "node:async_hooks"; import { randomUUID } from "node:crypto"; diff --git a/src/shared/utils/streamTracker.ts b/src/shared/utils/streamTracker.ts index f0c68568a8..6f055bad00 100644 --- a/src/shared/utils/streamTracker.ts +++ b/src/shared/utils/streamTracker.ts @@ -1,4 +1,3 @@ -// @ts-check /** * Stream Tracker โ€” Unified SSE stream monitoring * diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index 214473f104..3ff2901338 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -212,9 +212,9 @@ export async function handleChat(request: any, clientRawRequest: any = null) { /** * Handle single model chat request * - * Refactored (T-28): model resolution, logging, and param building - * extracted to chatHelpers.js. This function now focuses on the - * credential retry loop. + * Refactored: model resolution, logging, pipeline gates, and chat execution + * extracted to focused helpers. This function orchestrates the credential + * retry loop. */ async function handleSingleModelChat( body: any, @@ -225,62 +225,26 @@ async function handleSingleModelChat( apiKeyInfo: any = null, telemetry: any = null ) { - // 1. Resolve model โ†’ provider/model (or return error) - const modelInfo = await getModelInfo(modelStr); - if (!modelInfo.provider) { - if ((modelInfo as any).errorType === "ambiguous_model") { - const message = - (modelInfo as any).errorMessage || - `Ambiguous model '${modelStr}'. Use provider/model prefix (ex: gh/${modelStr} or cc/${modelStr}).`; - log.warn("CHAT", message, { - model: modelStr, - candidates: (modelInfo as any).candidateAliases || (modelInfo as any).candidateProviders || [], - }); - return errorResponse(HTTP_STATUS.BAD_REQUEST, message); - } - log.warn("CHAT", "Invalid model format", { model: modelStr }); - return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid model format"); - } + // 1. Resolve model โ†’ provider/model + const resolved = await resolveModelOrError(modelStr, body); + if (resolved.error) return resolved.error; - const { provider, model } = modelInfo; - const sourceFormat = detectFormat(body); - const providerAlias = PROVIDER_ID_TO_ALIAS[provider] || provider; - const targetFormat = getModelTargetFormat(providerAlias, model) || getTargetFormat(provider); + const { provider, model, sourceFormat, targetFormat } = resolved; - if (modelStr !== `${provider}/${model}`) { - log.info("ROUTING", `${modelStr} โ†’ ${provider}/${model}`); - } else { - log.info("ROUTING", `Provider: ${provider}, Model: ${model}`); - } + // 2. Pipeline gates (availability + circuit breaker) + const gate = checkPipelineGates(provider, model); + if (gate) return gate; - // Pipeline: Check model availability (TTL cooldown) - if (!isModelAvailable(provider, model)) { - log.warn("AVAILABILITY", `${provider}/${model} is in cooldown, rejecting request`); - return (unavailableResponse as any)( - HTTP_STATUS.SERVICE_UNAVAILABLE, - `Model ${provider}/${model} is temporarily unavailable (cooldown)`, - 30 - ); - } - - // Pipeline: Check circuit breaker for this provider const breaker = getCircuitBreaker(provider, { failureThreshold: 5, resetTimeout: 30000, - onStateChange: (name: string, from: string, to: string) => log.info("CIRCUIT", `${name}: ${from} โ†’ ${to}`), + onStateChange: (name: string, from: string, to: string) => + log.info("CIRCUIT", `${name}: ${from} โ†’ ${to}`), }); - if (!breaker.canExecute()) { - log.warn("CIRCUIT", `Circuit breaker OPEN for ${provider}, rejecting request`); - return (unavailableResponse as any)( - HTTP_STATUS.SERVICE_UNAVAILABLE, - `Provider ${provider} circuit breaker is open`, - 30 - ); - } const userAgent = request?.headers?.get("user-agent") || ""; - // 2. Credential retry loop + // 3. Credential retry loop let excludeConnectionId = null; let lastError = null; let lastStatus = null; @@ -288,7 +252,6 @@ async function handleSingleModelChat( while (true) { const credentials = await getProviderCredentials(provider, excludeConnectionId); - // All accounts unavailable โ€” return error if (!credentials || credentials.allRateLimited) { return handleNoCredentials( credentials, @@ -307,63 +270,27 @@ async function handleSingleModelChat( const proxyInfo = await safeResolveProxy(credentials.connectionId); const proxyStartTime = Date.now(); - // 3. Execute chat via core (with circuit breaker) + // 4. Execute chat via core (with circuit breaker + optional TLS) if (telemetry) telemetry.startPhase("connect"); - let result; - let tlsFingerprintUsed = false; - try { - const chatFn = () => - runWithProxyContext(proxyInfo?.proxy || null, () => - (handleChatCore as any)({ - body: { ...body, model: `${provider}/${model}` }, - modelInfo: { provider, model }, - credentials: refreshedCredentials, - log, - clientRawRequest, - connectionId: credentials.connectionId, - apiKeyInfo, - userAgent, - comboName, - onCredentialsRefreshed: async (newCreds: any) => { - await updateProviderCredentials(credentials.connectionId, { - accessToken: newCreds.accessToken, - refreshToken: newCreds.refreshToken, - providerSpecificData: newCreds.providerSpecificData, - testStatus: "active", - }); - }, - onRequestSuccess: async () => { - await clearAccountError(credentials.connectionId, credentials); - }, - }) - ); - - // Wrap with TLS tracking when no proxy and TLS fingerprint is active - if (!proxyInfo?.proxy && isTlsFingerprintActive()) { - const tracked = await breaker.execute(async () => { - return await runWithTlsTracking(chatFn); - }); - result = tracked.result; - tlsFingerprintUsed = tracked.tlsFingerprintUsed; - } else { - result = await breaker.execute(chatFn); - } - } catch (cbErr) { - if (cbErr instanceof CircuitBreakerOpenError) { - log.warn("CIRCUIT", `${provider} circuit open during retry: ${cbErr.message}`); - return (unavailableResponse as any)( - HTTP_STATUS.SERVICE_UNAVAILABLE, - `Provider ${provider} circuit breaker is open`, - Math.ceil(cbErr.retryAfterMs / 1000) - ); - } - throw cbErr; - } + const { result, tlsFingerprintUsed } = await executeChatWithBreaker({ + breaker, + body, + provider, + model, + refreshedCredentials, + proxyInfo, + log, + clientRawRequest, + credentials, + apiKeyInfo, + userAgent, + comboName, + }); if (telemetry) telemetry.endPhase(); const proxyLatency = Date.now() - proxyStartTime; - // 4. Log proxy + translation events (fire-and-forget) + // 5. Log proxy + translation events safeLogEvents({ result, proxyInfo, @@ -379,21 +306,13 @@ async function handleSingleModelChat( }); if (result.success) { - // Pipeline: Record cost on success - if (apiKeyInfo?.id) { - try { - const usage = result.usage || {}; - const estimatedCost = - ((usage.prompt_tokens || 0) + (usage.completion_tokens || 0)) * 0.000001; // rough estimate - if (estimatedCost > 0) recordCost(apiKeyInfo.id, estimatedCost); - } catch {} - } + recordCostIfNeeded(apiKeyInfo, result); if (telemetry) telemetry.startPhase("finalize"); if (telemetry) telemetry.endPhase(); return result.response; } - // Pipeline: Mark model unavailable on repeated failures (429, 503) + // Pipeline: Mark model unavailable on repeated failures if (result.status === 429 || result.status === 503) { setModelUnavailable(provider, model, 60000, `HTTP ${result.status}`); log.info( @@ -402,7 +321,7 @@ async function handleSingleModelChat( ); } - // 5. Fallback to next account + // 6. Fallback to next account const { shouldFallback } = await markAccountUnavailable( credentials.connectionId, result.status, @@ -422,6 +341,162 @@ async function handleSingleModelChat( } } +// โ”€โ”€โ”€โ”€ Pipeline gate checks โ”€โ”€โ”€โ”€ + +/** + * Resolve model string to provider/model info, or return an error response. + */ +async function resolveModelOrError(modelStr: string, body: any) { + const modelInfo = await getModelInfo(modelStr); + if (!modelInfo.provider) { + if ((modelInfo as any).errorType === "ambiguous_model") { + const message = + (modelInfo as any).errorMessage || + `Ambiguous model '${modelStr}'. Use provider/model prefix (ex: gh/${modelStr} or cc/${modelStr}).`; + log.warn("CHAT", message, { + model: modelStr, + candidates: + (modelInfo as any).candidateAliases || (modelInfo as any).candidateProviders || [], + }); + return { error: errorResponse(HTTP_STATUS.BAD_REQUEST, message) }; + } + log.warn("CHAT", "Invalid model format", { model: modelStr }); + return { error: errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid model format") }; + } + + const { provider, model } = modelInfo; + const sourceFormat = detectFormat(body); + const providerAlias = PROVIDER_ID_TO_ALIAS[provider] || provider; + const targetFormat = getModelTargetFormat(providerAlias, model) || getTargetFormat(provider); + + if (modelStr !== `${provider}/${model}`) { + log.info("ROUTING", `${modelStr} โ†’ ${provider}/${model}`); + } else { + log.info("ROUTING", `Provider: ${provider}, Model: ${model}`); + } + + return { provider, model, sourceFormat, targetFormat }; +} + +/** + * Check pipeline gates: model availability + circuit breaker state. + * Returns an error Response if blocked, or null if OK to proceed. + */ +function checkPipelineGates(provider: string, model: string) { + if (!isModelAvailable(provider, model)) { + log.warn("AVAILABILITY", `${provider}/${model} is in cooldown, rejecting request`); + return (unavailableResponse as any)( + HTTP_STATUS.SERVICE_UNAVAILABLE, + `Model ${provider}/${model} is temporarily unavailable (cooldown)`, + 30 + ); + } + + const breaker = getCircuitBreaker(provider, { + failureThreshold: 5, + resetTimeout: 30000, + onStateChange: (name: string, from: string, to: string) => + log.info("CIRCUIT", `${name}: ${from} โ†’ ${to}`), + }); + if (!breaker.canExecute()) { + log.warn("CIRCUIT", `Circuit breaker OPEN for ${provider}, rejecting request`); + return (unavailableResponse as any)( + HTTP_STATUS.SERVICE_UNAVAILABLE, + `Provider ${provider} circuit breaker is open`, + 30 + ); + } + + return null; +} + +// โ”€โ”€โ”€โ”€ Chat execution with circuit breaker โ”€โ”€โ”€โ”€ + +/** + * Execute chat core wrapped in circuit breaker + optional TLS tracking. + */ +async function executeChatWithBreaker({ + breaker, + body, + provider, + model, + refreshedCredentials, + proxyInfo, + log: logger, + clientRawRequest, + credentials, + apiKeyInfo, + userAgent, + comboName, +}: any): Promise<{ result: any; tlsFingerprintUsed: boolean }> { + let tlsFingerprintUsed = false; + + try { + const chatFn = () => + runWithProxyContext(proxyInfo?.proxy || null, () => + (handleChatCore as any)({ + body: { ...body, model: `${provider}/${model}` }, + modelInfo: { provider, model }, + credentials: refreshedCredentials, + log: logger, + clientRawRequest, + connectionId: credentials.connectionId, + apiKeyInfo, + userAgent, + comboName, + onCredentialsRefreshed: async (newCreds: any) => { + await updateProviderCredentials(credentials.connectionId, { + accessToken: newCreds.accessToken, + refreshToken: newCreds.refreshToken, + providerSpecificData: newCreds.providerSpecificData, + testStatus: "active", + }); + }, + onRequestSuccess: async () => { + await clearAccountError(credentials.connectionId, credentials); + }, + }) + ); + + if (!proxyInfo?.proxy && isTlsFingerprintActive()) { + const tracked = await breaker.execute(async () => runWithTlsTracking(chatFn)); + return { result: tracked.result, tlsFingerprintUsed: tracked.tlsFingerprintUsed }; + } + + const result = await breaker.execute(chatFn); + return { result, tlsFingerprintUsed: false }; + } catch (cbErr) { + if (cbErr instanceof CircuitBreakerOpenError) { + log.warn("CIRCUIT", `${provider} circuit open during retry: ${cbErr.message}`); + return { + result: { + success: false, + response: (unavailableResponse as any)( + HTTP_STATUS.SERVICE_UNAVAILABLE, + `Provider ${provider} circuit breaker is open`, + Math.ceil(cbErr.retryAfterMs / 1000) + ), + status: HTTP_STATUS.SERVICE_UNAVAILABLE, + }, + tlsFingerprintUsed: false, + }; + } + throw cbErr; + } +} + +/** + * Record cost if API key has budget tracking enabled. + */ +function recordCostIfNeeded(apiKeyInfo: any, result: any) { + if (!apiKeyInfo?.id) return; + try { + const usage = result.usage || {}; + const estimatedCost = ((usage.prompt_tokens || 0) + (usage.completion_tokens || 0)) * 0.000001; + if (estimatedCost > 0) recordCost(apiKeyInfo.id, estimatedCost); + } catch {} +} + // โ”€โ”€โ”€โ”€ Extracted helpers (T-28) โ”€โ”€โ”€โ”€ function handleNoCredentials( diff --git a/tests/integration/proxy-pipeline.test.mjs b/tests/integration/proxy-pipeline.test.mjs new file mode 100644 index 0000000000..ac50a66d04 --- /dev/null +++ b/tests/integration/proxy-pipeline.test.mjs @@ -0,0 +1,399 @@ +/** + * Proxy Pipeline Integration Tests โ€” T-3 + * + * Tests the proxy pipeline wiring: format detection, credential retry loop, + * circuit breaker integration, and the new Phase 2 modules (DI container, + * prompt versioning, plugin architecture, eval scheduler). + * + * @module tests/integration/proxy-pipeline.test.mjs + */ + +import { describe, it, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync, existsSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(__dirname, "..", ".."); + +function readSrc(relPath) { + const full = join(ROOT, "src", relPath); + if (!existsSync(full)) return null; + return readFileSync(full, "utf8"); +} + +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +// 1. Chat Handler Pipeline Wiring +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + +describe("Chat Pipeline โ€” handleSingleModelChat decomposition", () => { + const src = readSrc("sse/handlers/chat.ts"); + + it("should define resolveModelOrError helper", () => { + assert.ok(src, "chat.ts should exist"); + assert.match(src, /function\s+resolveModelOrError/); + }); + + it("should define checkPipelineGates helper", () => { + assert.match(src, /function\s+checkPipelineGates/); + }); + + it("should define executeChatWithBreaker helper", () => { + assert.match(src, /function\s+executeChatWithBreaker/); + }); + + it("should define recordCostIfNeeded helper", () => { + assert.match(src, /function\s+recordCostIfNeeded/); + }); + + it("handleSingleModelChat should use resolveModelOrError", () => { + // Extract handleSingleModelChat body + assert.match(src, /resolveModelOrError\(modelStr/); + }); + + it("handleSingleModelChat should use checkPipelineGates", () => { + assert.match(src, /checkPipelineGates\(provider/); + }); + + it("handleSingleModelChat should use executeChatWithBreaker", () => { + assert.match(src, /executeChatWithBreaker\(/); + }); + + it("handleSingleModelChat should use recordCostIfNeeded", () => { + assert.match(src, /recordCostIfNeeded\(/); + }); +}); + +describe("Chat Pipeline โ€” combo fallback support", () => { + const src = readSrc("sse/handlers/chat.ts"); + + it("should import handleComboChat", () => { + assert.ok(src, "chat.ts should exist"); + assert.match(src, /handleComboChat/); + }); + + it("should delegate to handleSingleModelChat for each combo model", () => { + assert.match(src, /handleSingleModel.*handleSingleModelChat/s); + }); + + it("should check model availability before attempting combo models", () => { + assert.match(src, /isModelAvailable/); + }); +}); + +describe("Chat Pipeline โ€” circuit breaker integration", () => { + const src = readSrc("sse/handlers/chat.ts"); + + it("should import CircuitBreakerOpenError", () => { + assert.ok(src, "chat.ts should exist"); + assert.match(src, /CircuitBreakerOpenError/); + }); + + it("should handle CircuitBreakerOpenError with retry-after", () => { + assert.match(src, /retryAfterMs/); + }); + + it("should reject requests when circuit is open", () => { + assert.match(src, /circuit breaker is open/i); + }); +}); + +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +// 2. DI Container (A-5) +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + +describe("DI Container โ€” container.ts", () => { + let container; + + beforeEach(async () => { + const mod = await import("../../src/lib/container.ts"); + container = mod.container; + }); + + afterEach(() => { + // Don't reset โ€” keep default registrations + }); + + it("should export a container singleton", () => { + assert.ok(container); + assert.equal(typeof container.register, "function"); + assert.equal(typeof container.resolve, "function"); + assert.equal(typeof container.has, "function"); + }); + + it("should register and resolve a custom service", () => { + container.register("testService", () => ({ greeting: "hello" })); + const svc = container.resolve("testService"); + assert.deepEqual(svc, { greeting: "hello" }); + }); + + it("should return cached singleton on repeated resolve", () => { + let count = 0; + container.register("counterService", () => ({ value: ++count })); + const a = container.resolve("counterService"); + const b = container.resolve("counterService"); + assert.strictEqual(a, b); + assert.equal(a.value, 1); + }); + + it("should throw on resolving unregistered service", () => { + assert.throws(() => container.resolve("nonExistent"), /No factory registered/); + }); + + it("should have default registrations", () => { + const names = container.list(); + assert.ok(names.includes("settings"), "should have settings"); + assert.ok(names.includes("db"), "should have db"); + assert.ok(names.includes("encryption"), "should have encryption"); + assert.ok(names.includes("policyEngine"), "should have policyEngine"); + assert.ok(names.includes("circuitBreaker"), "should have circuitBreaker"); + assert.ok(names.includes("telemetry"), "should have telemetry"); + }); + + it("should support re-registration (overwrite)", () => { + container.register("testOverwrite", () => "v1"); + assert.equal(container.resolve("testOverwrite"), "v1"); + container.register("testOverwrite", () => "v2"); + assert.equal(container.resolve("testOverwrite"), "v2"); + }); +}); + +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +// 3. Plugin Architecture (L-8) +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + +describe("Plugin Architecture โ€” plugins/index.ts", () => { + let plugins; + + beforeEach(async () => { + plugins = await import("../../src/lib/plugins/index.ts"); + plugins.resetPlugins(); + }); + + afterEach(() => { + plugins.resetPlugins(); + }); + + it("should register and list plugins", () => { + plugins.registerPlugin({ + name: "test-logger", + priority: 10, + onRequest: () => {}, + }); + + const list = plugins.listPlugins(); + assert.equal(list.length, 1); + assert.equal(list[0].name, "test-logger"); + assert.equal(list[0].priority, 10); + assert.deepEqual(list[0].hooks, ["onRequest"]); + }); + + it("should sort plugins by priority", () => { + plugins.registerPlugin({ name: "low", priority: 200 }); + plugins.registerPlugin({ name: "high", priority: 1 }); + plugins.registerPlugin({ name: "mid", priority: 50 }); + + const list = plugins.listPlugins(); + assert.deepEqual( + list.map((p) => p.name), + ["high", "mid", "low"] + ); + }); + + it("should run onRequest hooks in order", async () => { + const order = []; + plugins.registerPlugin({ + name: "first", + priority: 1, + onRequest: () => { + order.push("first"); + }, + }); + plugins.registerPlugin({ + name: "second", + priority: 2, + onRequest: () => { + order.push("second"); + }, + }); + + const ctx = { requestId: "r1", body: {}, model: "test", metadata: {} }; + await plugins.runOnRequest(ctx); + assert.deepEqual(order, ["first", "second"]); + }); + + it("should support request blocking", async () => { + plugins.registerPlugin({ + name: "blocker", + priority: 1, + onRequest: () => ({ blocked: true, response: { error: "denied" } }), + }); + plugins.registerPlugin({ + name: "never-runs", + priority: 2, + onRequest: () => { + throw new Error("should not run"); + }, + }); + + const ctx = { requestId: "r2", body: {}, model: "test", metadata: {} }; + const result = await plugins.runOnRequest(ctx); + assert.equal(result.blocked, true); + assert.deepEqual(result.response, { error: "denied" }); + }); + + it("should enable/disable plugins at runtime", () => { + plugins.registerPlugin({ + name: "toggle-me", + onRequest: () => {}, + }); + + assert.ok(plugins.setPluginEnabled("toggle-me", false)); + const list = plugins.listPlugins(); + assert.equal(list[0].enabled, false); + }); + + it("should unregister plugins", () => { + plugins.registerPlugin({ name: "removable" }); + assert.equal(plugins.listPlugins().length, 1); + assert.ok(plugins.unregisterPlugin("removable")); + assert.equal(plugins.listPlugins().length, 0); + }); + + it("should run onResponse hooks", async () => { + plugins.registerPlugin({ + name: "response-modifier", + onResponse: (_ctx, response) => ({ ...response, modified: true }), + }); + + const ctx = { requestId: "r3", body: {}, model: "test", metadata: {} }; + const result = await plugins.runOnResponse(ctx, { data: "original" }); + assert.equal(result.modified, true); + assert.equal(result.data, "original"); + }); + + it("should run onError hooks and allow recovery", async () => { + plugins.registerPlugin({ + name: "error-handler", + onError: (_ctx, _error) => ({ recovered: true }), + }); + + const ctx = { requestId: "r4", body: {}, model: "test", metadata: {} }; + const result = await plugins.runOnError(ctx, new Error("test error")); + assert.deepEqual(result, { recovered: true }); + }); + + it("should return null from onError if no recovery", async () => { + const ctx = { requestId: "r5", body: {}, model: "test", metadata: {} }; + const result = await plugins.runOnError(ctx, new Error("unhandled")); + assert.equal(result, null); + }); +}); + +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +// 4. Prompt Template Versioning (L-6) +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + +describe("Prompt Template Versioning โ€” prompts.ts module existence", () => { + it("prompts.ts should exist", () => { + const full = join(ROOT, "src", "lib", "db", "prompts.ts"); + assert.ok(existsSync(full), "prompts.ts should exist"); + }); + + it("should export CRUD functions", () => { + const src = readFileSync(join(ROOT, "src", "lib", "db", "prompts.ts"), "utf8"); + assert.match(src, /export function savePrompt/); + assert.match(src, /export function getActivePrompt/); + assert.match(src, /export function getPromptVersion/); + assert.match(src, /export function listPromptVersions/); + assert.match(src, /export function listPrompts/); + assert.match(src, /export function rollbackPrompt/); + assert.match(src, /export function renderPrompt/); + }); + + it("should define PromptTemplate interface", () => { + const src = readFileSync(join(ROOT, "src", "lib", "db", "prompts.ts"), "utf8"); + assert.match(src, /export interface PromptTemplate/); + }); + + it("should use content hashing for deduplication", () => { + const src = readFileSync(join(ROOT, "src", "lib", "db", "prompts.ts"), "utf8"); + assert.match(src, /content_hash/); + assert.match(src, /sha256/); + }); +}); + +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +// 5. Eval Scheduler (L-7) +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + +describe("Eval Scheduler โ€” scheduler.ts module existence", () => { + it("scheduler.ts should exist", () => { + const full = join(ROOT, "src", "lib", "evals", "scheduler.ts"); + assert.ok(existsSync(full), "scheduler.ts should exist"); + }); + + it("should export scheduling functions", () => { + const src = readFileSync(join(ROOT, "src", "lib", "evals", "scheduler.ts"), "utf8"); + assert.match(src, /export function schedule/); + assert.match(src, /export function unschedule/); + assert.match(src, /export function pause/); + assert.match(src, /export function resume/); + assert.match(src, /export\s+(async\s+)?function\s+runNow/); + assert.match(src, /export function getSchedules/); + assert.match(src, /export function getHistory/); + assert.match(src, /export function stopAll/); + }); + + it("should define ScheduledEval and EvalRunResult types", () => { + const src = readFileSync(join(ROOT, "src", "lib", "evals", "scheduler.ts"), "utf8"); + assert.match(src, /export interface ScheduledEval/); + assert.match(src, /export interface EvalRunResult/); + }); + + it("should have pluggable output provider", () => { + const src = readFileSync(join(ROOT, "src", "lib", "evals", "scheduler.ts"), "utf8"); + assert.match(src, /export function setOutputProvider/); + }); +}); + +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +// 6. Migration Runner (E-5) +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + +describe("Migration System โ€” files exist", () => { + it("migrationRunner.ts should exist", () => { + const full = join(ROOT, "src", "lib", "db", "migrationRunner.ts"); + assert.ok(existsSync(full), "migrationRunner.ts should exist"); + }); + + it("001_initial_schema.sql should exist", () => { + const full = join(ROOT, "src", "lib", "db", "migrations", "001_initial_schema.sql"); + assert.ok(existsSync(full), "001_initial_schema.sql should exist"); + }); + + it("core.ts should reference migration runner", () => { + const src = readSrc("lib/db/core.ts"); + assert.ok(src); + assert.match(src, /runMigrations/); + assert.match(src, /_omniroute_migrations/); + }); +}); + +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +// 7. CORS Configuration (L-5) +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + +describe("CORS โ€” centralized configuration", () => { + it("shared/utils/cors.ts should exist", () => { + const full = join(ROOT, "src", "shared", "utils", "cors.ts"); + assert.ok(existsSync(full), "shared/utils/cors.ts should exist"); + }); + + it("should export CORS_HEADERS and CORS_ORIGIN", () => { + const src = readSrc("shared/utils/cors.ts"); + assert.match(src, /CORS_HEADERS/); + assert.match(src, /CORS_ORIGIN/); + }); +}); diff --git a/tests/load/proxy-load.js b/tests/load/proxy-load.js new file mode 100644 index 0000000000..c5391106d8 --- /dev/null +++ b/tests/load/proxy-load.js @@ -0,0 +1,151 @@ +/** + * OmniRoute โ€” k6 Load / Performance Test (T-5) + * + * Tests the proxy endpoint under sustained load to measure: + * - Request throughput (RPS) + * - Response latency (p50, p95, p99) + * - Error rate + * - Concurrent connection handling + * + * Usage: + * k6 run tests/load/proxy-load.js + * k6 run tests/load/proxy-load.js --env BASE_URL=https://llms.omniroute.online + * k6 run tests/load/proxy-load.js --env VUS=50 --env DURATION=120s + * + * Prerequisites: + * - k6 installed: https://grafana.com/docs/k6/latest/set-up/install-k6/ + * - OMNIROUTE_API_KEY env var or --env API_KEY=... set + */ + +import http from "k6/http"; +import { check, sleep } from "k6"; +import { Rate, Trend } from "k6/metrics"; + +// โ”€โ”€ Custom metrics โ”€โ”€ +const errorRate = new Rate("errors"); +const chatLatency = new Trend("chat_latency", true); // in ms +const healthLatency = new Trend("health_latency", true); + +// โ”€โ”€ Configuration โ”€โ”€ +const BASE_URL = __ENV.BASE_URL || "http://localhost:3000"; +const API_KEY = __ENV.API_KEY || __ENV.OMNIROUTE_API_KEY || "test-key"; +const VUS = parseInt(__ENV.VUS || "10", 10); +const DURATION = __ENV.DURATION || "60s"; + +export const options = { + scenarios: { + // Ramp-up scenario for stress testing + chat_stress: { + executor: "ramping-vus", + startVUs: 1, + stages: [ + { duration: "10s", target: VUS }, // Ramp up + { duration: DURATION, target: VUS }, // Sustained load + { duration: "10s", target: 0 }, // Ramp down + ], + exec: "chatCompletions", + }, + // Constant rate for health checks + health_check: { + executor: "constant-vus", + vus: 2, + duration: DURATION, + exec: "healthCheck", + }, + }, + thresholds: { + http_req_duration: ["p(95)<5000"], // 95% of requests < 5s + errors: ["rate<0.1"], // Error rate < 10% + chat_latency: ["p(50)<3000", "p(95)<8000"], + health_latency: ["p(95)<500"], + }, +}; + +// โ”€โ”€ Headers โ”€โ”€ +const headers = { + "Content-Type": "application/json", + Authorization: `Bearer ${API_KEY}`, +}; + +// โ”€โ”€ Scenarios โ”€โ”€ + +/** + * Chat Completions โ€” main proxy endpoint + * Sends a simple non-streaming chat request. + */ +export function chatCompletions() { + const payload = JSON.stringify({ + model: "gpt-4o-mini", + messages: [ + { role: "user", content: "Say hello in one word." }, + ], + temperature: 0, + max_tokens: 10, + stream: false, + }); + + const res = http.post(`${BASE_URL}/v1/chat/completions`, payload, { + headers, + timeout: "15s", + }); + + chatLatency.add(res.timings.duration); + + const passed = check(res, { + "status is 200": (r) => r.status === 200, + "has choices": (r) => { + try { + const body = JSON.parse(r.body); + return body.choices && body.choices.length > 0; + } catch { + return false; + } + }, + "response time < 10s": (r) => r.timings.duration < 10000, + }); + + errorRate.add(!passed); + sleep(0.5); +} + +/** + * Health Check โ€” lightweight endpoint to measure base latency. + */ +export function healthCheck() { + const res = http.get(`${BASE_URL}/api/health`, { + headers: { Authorization: `Bearer ${API_KEY}` }, + timeout: "5s", + }); + + healthLatency.add(res.timings.duration); + + const passed = check(res, { + "health status 200": (r) => r.status === 200, + "response time < 1s": (r) => r.timings.duration < 1000, + }); + + errorRate.add(!passed); + sleep(2); +} + +/** + * Summary handler โ€” outputs a custom summary. + */ +export function handleSummary(data) { + const summary = { + timestamp: new Date().toISOString(), + scenarios: Object.keys(options.scenarios), + metrics: { + http_reqs: data.metrics.http_reqs?.values?.count || 0, + avg_duration_ms: Math.round(data.metrics.http_req_duration?.values?.avg || 0), + p95_duration_ms: Math.round(data.metrics.http_req_duration?.values?.["p(95)"] || 0), + p99_duration_ms: Math.round(data.metrics.http_req_duration?.values?.["p(99)"] || 0), + error_rate: (data.metrics.errors?.values?.rate || 0).toFixed(4), + }, + }; + + return { + stdout: `\n๐Ÿ“Š Load Test Summary\n${JSON.stringify(summary, null, 2)}\n`, + "tests/load/results.json": JSON.stringify(summary, null, 2), + }; +} diff --git a/tsconfig.json b/tsconfig.json index 2162fa9a27..f07ffcea5f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,11 +1,7 @@ { "compilerOptions": { "target": "ES2022", - "lib": [ - "dom", - "dom.iterable", - "esnext" - ], + "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "checkJs": false, "skipLibCheck": true, @@ -14,21 +10,16 @@ "esModuleInterop": true, "module": "esnext", "moduleResolution": "bundler", + "allowImportingTsExtensions": true, "resolveJsonModule": true, "isolatedModules": true, "jsx": "react-jsx", "incremental": true, "forceConsistentCasingInFileNames": true, "paths": { - "@/*": [ - "./src/*" - ], - "@omniroute/open-sse": [ - "./open-sse" - ], - "@omniroute/open-sse/*": [ - "./open-sse/*" - ] + "@/*": ["./src/*"], + "@omniroute/open-sse": ["./open-sse"], + "@omniroute/open-sse/*": ["./open-sse/*"] }, "plugins": [ { @@ -45,9 +36,5 @@ ".next/types/**/*.ts", ".next/dev/types/**/*.ts" ], - "exclude": [ - "node_modules", - "open-sse", - "antigravity-manager-analysis" - ] + "exclude": ["node_modules", "open-sse", "antigravity-manager-analysis"] }