mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-01 12:52:11 +03:00
feat(core): implement 26 action items from critical analysis + bump v0.4.0
- Security: AES-256-GCM encryption for API keys/tokens, CI security audit - Accessibility: ARIA labels, aria-live regions, skip-to-content, contrast utility - Components: Tooltip, CloudSyncStatus, SystemMonitor, StreamTracker - Utils: costEstimator, promptInjectionGuard middleware, Zod validation schemas - Docs: openapi.yaml +9 routes, API_REFERENCE internal APIs, version bumps - Quality: coverage thresholds 60/50/50, error handling improvements
This commit is contained in:
15
.github/workflows/ci.yml
vendored
15
.github/workflows/ci.yml
vendored
@@ -23,6 +23,21 @@ jobs:
|
||||
- run: npm ci
|
||||
- run: npm run lint
|
||||
|
||||
security:
|
||||
name: Security Audit
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: Dependency audit
|
||||
run: npm audit --audit-level=high --omit=dev
|
||||
- name: Check for known vulnerabilities
|
||||
run: npx is-my-node-vulnerable || true
|
||||
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -258,10 +258,10 @@ Write unit tests in `tests/unit/` covering at minimum:
|
||||
|
||||
## Releasing
|
||||
|
||||
When a new GitHub Release is created (e.g. `v0.3.0`), the package is **automatically published to npm** via GitHub Actions:
|
||||
When a new GitHub Release is created (e.g. `v0.4.0`), the package is **automatically published to npm** via GitHub Actions:
|
||||
|
||||
```bash
|
||||
gh release create v0.3.0 --title "v0.3.0" --generate-notes
|
||||
gh release create v0.4.0 --title "v0.4.0" --generate-notes
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -189,7 +189,7 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines.
|
||||
|
||||
```bash
|
||||
# Create a release — npm publish happens automatically
|
||||
gh release create v0.3.0 --title "v0.3.0" --generate-notes
|
||||
gh release create v0.4.0 --title "v0.4.0" --generate-notes
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -20,8 +20,9 @@ If you discover a security vulnerability in OmniRoute, please report it responsi
|
||||
|
||||
| Version | Support Status |
|
||||
| ------- | -------------- |
|
||||
| 0.2.x | ✅ Active |
|
||||
| < 0.2.0 | ❌ Unsupported |
|
||||
| 0.4.x | ✅ Active |
|
||||
| 0.3.x | ✅ Active |
|
||||
| < 0.3.0 | ❌ Unsupported |
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: OmniRoute API
|
||||
version: 0.0.1
|
||||
version: 0.4.0
|
||||
description: |
|
||||
OmniRoute is a local-first AI API proxy router. It provides an OpenAI-compatible
|
||||
endpoint that routes requests to multiple AI providers with load balancing,
|
||||
@@ -641,11 +641,12 @@ paths:
|
||||
|
||||
/api/tags:
|
||||
get:
|
||||
tags: [Settings]
|
||||
summary: List all tags
|
||||
tags: [System]
|
||||
summary: List Ollama-compatible model tags
|
||||
description: Returns models in Ollama /api/tags format for Ollama client compatibility
|
||||
responses:
|
||||
"200":
|
||||
description: Tag list
|
||||
description: Ollama model tags
|
||||
|
||||
# ─── Usage & Analytics ─────────────────────────────────────────
|
||||
|
||||
@@ -1017,6 +1018,173 @@ paths:
|
||||
"200":
|
||||
description: Sync initialized
|
||||
|
||||
# ─── Resilience & Monitoring ────────────────────────────────────
|
||||
|
||||
/api/resilience:
|
||||
get:
|
||||
tags: [System]
|
||||
summary: Get resilience profiles and circuit breaker states
|
||||
responses:
|
||||
"200":
|
||||
description: Resilience configuration and current states
|
||||
put:
|
||||
tags: [System]
|
||||
summary: Update resilience profiles
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
responses:
|
||||
"200":
|
||||
description: Updated resilience profiles
|
||||
|
||||
/api/resilience/reset:
|
||||
post:
|
||||
tags: [System]
|
||||
summary: Reset circuit breakers
|
||||
responses:
|
||||
"200":
|
||||
description: Circuit breakers reset
|
||||
|
||||
/api/monitoring/health:
|
||||
get:
|
||||
tags: [System]
|
||||
summary: System health check
|
||||
description: Returns system health including uptime, memory, circuit breakers, rate limits
|
||||
responses:
|
||||
"200":
|
||||
description: Health status
|
||||
|
||||
/api/rate-limits:
|
||||
get:
|
||||
tags: [System]
|
||||
summary: Get per-account rate limit status
|
||||
responses:
|
||||
"200":
|
||||
description: Rate limit status by account
|
||||
|
||||
/api/sessions:
|
||||
get:
|
||||
tags: [System]
|
||||
summary: Get active sessions
|
||||
responses:
|
||||
"200":
|
||||
description: Active session list
|
||||
|
||||
/api/cache:
|
||||
get:
|
||||
tags: [System]
|
||||
summary: Get cache statistics
|
||||
responses:
|
||||
"200":
|
||||
description: Semantic cache and idempotency stats
|
||||
delete:
|
||||
tags: [System]
|
||||
summary: Clear all caches
|
||||
responses:
|
||||
"200":
|
||||
description: Caches cleared
|
||||
|
||||
# ─── Evals & Policies ──────────────────────────────────────────
|
||||
|
||||
/api/evals:
|
||||
get:
|
||||
tags: [System]
|
||||
summary: List eval suites
|
||||
responses:
|
||||
"200":
|
||||
description: Eval suite list
|
||||
post:
|
||||
tags: [System]
|
||||
summary: Run evaluation
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
responses:
|
||||
"200":
|
||||
description: Eval results
|
||||
|
||||
/api/policies:
|
||||
get:
|
||||
tags: [System]
|
||||
summary: List routing policies
|
||||
responses:
|
||||
"200":
|
||||
description: Policy list
|
||||
post:
|
||||
tags: [System]
|
||||
summary: Create routing policy
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
responses:
|
||||
"201":
|
||||
description: Created policy
|
||||
delete:
|
||||
tags: [System]
|
||||
summary: Delete routing policy
|
||||
responses:
|
||||
"200":
|
||||
description: Policy deleted
|
||||
|
||||
/api/compliance/audit-log:
|
||||
get:
|
||||
tags: [System]
|
||||
summary: Get compliance audit log
|
||||
parameters:
|
||||
- name: limit
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
default: 100
|
||||
responses:
|
||||
"200":
|
||||
description: Audit log entries
|
||||
|
||||
# ─── v1beta (Gemini-Compatible) ─────────────────────────────────
|
||||
|
||||
/api/v1beta/models:
|
||||
get:
|
||||
tags: [Models]
|
||||
summary: List models (Gemini format)
|
||||
description: Returns models in Gemini v1beta format for native SDK compatibility
|
||||
security:
|
||||
- BearerAuth: []
|
||||
responses:
|
||||
"200":
|
||||
description: Model list in Gemini format
|
||||
|
||||
/api/v1beta/models/{path}:
|
||||
post:
|
||||
tags: [Models]
|
||||
summary: Gemini generateContent
|
||||
description: Gemini-compatible generateContent endpoint
|
||||
security:
|
||||
- BearerAuth: []
|
||||
parameters:
|
||||
- name: path
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
responses:
|
||||
"200":
|
||||
description: Generated content
|
||||
|
||||
components:
|
||||
securitySchemes:
|
||||
BearerAuth:
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "0.2.0",
|
||||
"version": "0.4.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "omniroute",
|
||||
"version": "0.2.0",
|
||||
"version": "0.4.0",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"open-sse"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "0.2.0",
|
||||
"version": "0.4.0",
|
||||
"description": "Smart AI Router with auto fallback — route to FREE & cheap models, zero downtime. Works with Cursor, Cline, Claude Desktop, Codex, and any OpenAI-compatible tool.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
@@ -53,7 +53,7 @@
|
||||
"test:fixes": "node --test tests/unit/fixes-p1.test.mjs",
|
||||
"test:security": "node --test tests/unit/security-fase01.test.mjs",
|
||||
"test:e2e": "npx playwright test",
|
||||
"test:coverage": "npx c8 --check-coverage --lines 40 --functions 30 --branches 30 node --test tests/unit/*.test.mjs",
|
||||
"test:coverage": "npx c8 --check-coverage --lines 60 --functions 50 --branches 50 node --test tests/unit/*.test.mjs",
|
||||
"test:all": "npm run test:unit && npm run test:e2e",
|
||||
"check": "npm run lint && npm run test",
|
||||
"prepublishOnly": "npm run build:cli",
|
||||
|
||||
@@ -118,6 +118,8 @@ export default function HealthPage() {
|
||||
|
||||
{/* Status Banner */}
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className={`rounded-xl p-4 flex items-center gap-3 ${
|
||||
data.status === "healthy"
|
||||
? "bg-green-500/10 border border-green-500/20"
|
||||
@@ -190,7 +192,7 @@ export default function HealthPage() {
|
||||
</div>
|
||||
|
||||
{/* Provider Health */}
|
||||
<Card className="p-5">
|
||||
<Card className="p-5" role="region" aria-label="Provider health status">
|
||||
<h2 className="text-lg font-semibold text-text-main mb-4 flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[20px] text-primary">
|
||||
health_and_safety
|
||||
|
||||
@@ -212,6 +212,7 @@ export default function ProvidersPage() {
|
||||
: "bg-bg-subtle border-border text-text-muted hover:text-text-primary hover:border-primary/40"
|
||||
}`}
|
||||
title="Test all OAuth connections"
|
||||
aria-label="Test all OAuth connections"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">
|
||||
{testingMode === "oauth" ? "sync" : "play_arrow"}
|
||||
@@ -244,6 +245,7 @@ export default function ProvidersPage() {
|
||||
: "bg-bg-subtle border-border text-text-muted hover:text-text-primary hover:border-primary/40"
|
||||
}`}
|
||||
title="Test all Free connections"
|
||||
aria-label="Test all Free provider connections"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">
|
||||
{testingMode === "free" ? "sync" : "play_arrow"}
|
||||
@@ -276,6 +278,7 @@ export default function ProvidersPage() {
|
||||
: "bg-bg-subtle border-border text-text-muted hover:text-text-primary hover:border-primary/40"
|
||||
}`}
|
||||
title="Test all API Key connections"
|
||||
aria-label="Test all API Key connections"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">
|
||||
{testingMode === "apikey" ? "sync" : "play_arrow"}
|
||||
@@ -386,6 +389,7 @@ export default function ProvidersPage() {
|
||||
<button
|
||||
onClick={() => setTestResults(null)}
|
||||
className="p-1 rounded-lg hover:bg-bg-subtle text-text-muted hover:text-text-primary transition-colors"
|
||||
aria-label="Close test results"
|
||||
>
|
||||
<span className="material-symbols-outlined text-lg">close</span>
|
||||
</button>
|
||||
|
||||
@@ -25,10 +25,7 @@ export default function GlobalError({ error, reset }) {
|
||||
)}
|
||||
<button
|
||||
onClick={reset}
|
||||
className="px-8 py-3 rounded-[10px] text-white border-none text-sm font-semibold cursor-pointer transition-transform duration-200 shadow-[0_4px_16px_rgba(99,102,241,0.3)] hover:-translate-y-0.5"
|
||||
style={{
|
||||
background: "linear-gradient(135deg, #6366f1, #8b5cf6)",
|
||||
}}
|
||||
className="px-8 py-3 rounded-[10px] text-white border-none text-sm font-semibold cursor-pointer transition-transform duration-200 shadow-[0_4px_16px_rgba(99,102,241,0.3)] hover:-translate-y-0.5 bg-gradient-to-br from-[#6366f1] to-[#8b5cf6]"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
|
||||
@@ -30,6 +30,12 @@ export default function RootLayout({ children }) {
|
||||
/>
|
||||
</head>
|
||||
<body className={`${inter.variable} font-sans antialiased`} suppressHydrationWarning>
|
||||
<a
|
||||
href="#main-content"
|
||||
className="sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-50 focus:px-4 focus:py-2 focus:bg-[#6366f1] focus:text-white focus:rounded-lg focus:text-sm focus:font-semibold focus:shadow-lg"
|
||||
>
|
||||
Skip to content
|
||||
</a>
|
||||
<ThemeProvider>{children}</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
148
src/lib/db/encryption.js
Normal file
148
src/lib/db/encryption.js
Normal file
@@ -0,0 +1,148 @@
|
||||
// @ts-check
|
||||
/**
|
||||
* Field-Level Encryption — AES-256-GCM
|
||||
*
|
||||
* Encrypts/decrypts sensitive fields (API keys, tokens) stored in SQLite.
|
||||
* Format: `enc:v1:<iv_hex>:<ciphertext_hex>:<authTag_hex>`
|
||||
*
|
||||
* 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";
|
||||
|
||||
const ALGORITHM = "aes-256-gcm";
|
||||
const IV_LENGTH = 16;
|
||||
const KEY_LENGTH = 32;
|
||||
const PREFIX = "enc:v1:";
|
||||
|
||||
/** @type {Buffer|null} */
|
||||
let _derivedKey = null;
|
||||
|
||||
/**
|
||||
* Derive a 256-bit key from the env secret using scrypt.
|
||||
* Returns null if no encryption key is configured.
|
||||
* @returns {Buffer|null}
|
||||
*/
|
||||
function getKey() {
|
||||
if (_derivedKey !== null) return _derivedKey;
|
||||
|
||||
const secret = process.env.STORAGE_ENCRYPTION_KEY;
|
||||
if (!secret) return null;
|
||||
|
||||
// Fixed salt derived from app name — deterministic so same key always produces same derived key
|
||||
const salt = "omniroute-field-encryption-v1";
|
||||
_derivedKey = scryptSync(secret, salt, KEY_LENGTH);
|
||||
return _derivedKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if encryption is enabled.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isEncryptionEnabled() {
|
||||
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) {
|
||||
if (!plaintext || typeof plaintext !== "string") return plaintext;
|
||||
|
||||
const key = getKey();
|
||||
if (!key) return plaintext; // passthrough mode
|
||||
|
||||
// Already encrypted — don't double-encrypt
|
||||
if (plaintext.startsWith(PREFIX)) return plaintext;
|
||||
|
||||
const iv = randomBytes(IV_LENGTH);
|
||||
const cipher = createCipheriv(ALGORITHM, key, iv);
|
||||
|
||||
let encrypted = cipher.update(plaintext, "utf8", "hex");
|
||||
encrypted += cipher.final("hex");
|
||||
const authTag = cipher.getAuthTag().toString("hex");
|
||||
|
||||
return `${PREFIX}${iv.toString("hex")}:${encrypted}:${authTag}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
if (!ciphertext || typeof ciphertext !== "string") return ciphertext;
|
||||
|
||||
// Not encrypted — return as-is (legacy plaintext or passthrough mode)
|
||||
if (!ciphertext.startsWith(PREFIX)) return ciphertext;
|
||||
|
||||
const key = getKey();
|
||||
if (!key) {
|
||||
console.warn(
|
||||
"[Encryption] Found encrypted data but STORAGE_ENCRYPTION_KEY is not set. Cannot decrypt."
|
||||
);
|
||||
return ciphertext;
|
||||
}
|
||||
|
||||
const body = ciphertext.slice(PREFIX.length);
|
||||
const parts = body.split(":");
|
||||
if (parts.length !== 3) {
|
||||
console.error("[Encryption] Malformed encrypted value");
|
||||
return ciphertext;
|
||||
}
|
||||
|
||||
const [ivHex, encryptedHex, authTagHex] = parts;
|
||||
|
||||
try {
|
||||
const iv = Buffer.from(ivHex, "hex");
|
||||
const authTag = Buffer.from(authTagHex, "hex");
|
||||
const decipher = createDecipheriv(ALGORITHM, key, iv);
|
||||
decipher.setAuthTag(authTag);
|
||||
|
||||
let decrypted = decipher.update(encryptedHex, "hex", "utf8");
|
||||
decrypted += decipher.final("utf8");
|
||||
return decrypted;
|
||||
} catch (err) {
|
||||
console.error("[Encryption] Decryption failed:", err.message);
|
||||
return ciphertext;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt sensitive fields in a connection object (mutates in-place).
|
||||
* @param {object} conn
|
||||
* @returns {object} The same object with encrypted fields
|
||||
*/
|
||||
export function encryptConnectionFields(conn) {
|
||||
if (!isEncryptionEnabled()) return conn;
|
||||
|
||||
if (conn.apiKey) conn.apiKey = encrypt(conn.apiKey);
|
||||
if (conn.accessToken) conn.accessToken = encrypt(conn.accessToken);
|
||||
if (conn.refreshToken) conn.refreshToken = encrypt(conn.refreshToken);
|
||||
if (conn.idToken) conn.idToken = encrypt(conn.idToken);
|
||||
return conn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt sensitive fields in a connection row (returns new object).
|
||||
* @param {object|null} row
|
||||
* @returns {object|null}
|
||||
*/
|
||||
export function decryptConnectionFields(row) {
|
||||
if (!row) return row;
|
||||
if (!isEncryptionEnabled()) return row;
|
||||
|
||||
return {
|
||||
...row,
|
||||
apiKey: decrypt(row.apiKey),
|
||||
accessToken: decrypt(row.accessToken),
|
||||
refreshToken: decrypt(row.refreshToken),
|
||||
idToken: decrypt(row.idToken),
|
||||
};
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { getDbInstance, rowToCamel, cleanNulls } from "./core.js";
|
||||
import { backupDbFile } from "./backup.js";
|
||||
import { encryptConnectionFields, decryptConnectionFields } from "./encryption.js";
|
||||
|
||||
// ──────────────── Provider Connections ────────────────
|
||||
|
||||
@@ -29,13 +30,13 @@ export async function getProviderConnections(filter = {}) {
|
||||
sql += " ORDER BY priority ASC, updated_at DESC";
|
||||
|
||||
const rows = db.prepare(sql).all(params);
|
||||
return rows.map((r) => cleanNulls(rowToCamel(r)));
|
||||
return rows.map((r) => decryptConnectionFields(cleanNulls(rowToCamel(r))));
|
||||
}
|
||||
|
||||
export async function getProviderConnectionById(id) {
|
||||
const db = getDbInstance();
|
||||
const row = db.prepare("SELECT * FROM provider_connections WHERE id = ?").get(id);
|
||||
return row ? cleanNulls(rowToCamel(row)) : null;
|
||||
return row ? decryptConnectionFields(cleanNulls(rowToCamel(row))) : null;
|
||||
}
|
||||
|
||||
export async function createProviderConnection(data) {
|
||||
@@ -143,7 +144,7 @@ export async function createProviderConnection(data) {
|
||||
connection.providerSpecificData = data.providerSpecificData;
|
||||
}
|
||||
|
||||
_insertConnectionRow(db, connection);
|
||||
_insertConnectionRow(db, encryptConnectionFields({ ...connection }));
|
||||
_reorderConnections(db, data.provider);
|
||||
backupDbFile("pre-write");
|
||||
|
||||
@@ -285,7 +286,7 @@ export async function updateProviderConnection(id, data) {
|
||||
if (!existing) return null;
|
||||
|
||||
const merged = { ...rowToCamel(existing), ...data, updatedAt: new Date().toISOString() };
|
||||
_updateConnectionRow(db, id, merged);
|
||||
_updateConnectionRow(db, id, encryptConnectionFields({ ...merged }));
|
||||
backupDbFile("pre-write");
|
||||
|
||||
if (data.priority !== undefined) {
|
||||
|
||||
@@ -17,6 +17,6 @@ export async function ensureCloudSyncInitialized() {
|
||||
}
|
||||
|
||||
// Auto-initialize when module loads
|
||||
ensureCloudSyncInitialized().catch(console.log);
|
||||
ensureCloudSyncInitialized().catch((err) => console.error("[CloudSync] ensure failed:", err));
|
||||
|
||||
export default ensureCloudSyncInitialized;
|
||||
|
||||
118
src/middleware/promptInjectionGuard.js
Normal file
118
src/middleware/promptInjectionGuard.js
Normal file
@@ -0,0 +1,118 @@
|
||||
// @ts-check
|
||||
/**
|
||||
* Prompt Injection Guard — Express/Next.js middleware
|
||||
*
|
||||
* Wraps the inputSanitizer module as middleware for API routes.
|
||||
* Blocks or warns on detected prompt injection attempts.
|
||||
*
|
||||
* @module middleware/promptInjectionGuard
|
||||
*/
|
||||
|
||||
import { sanitizeRequest } from "../shared/utils/inputSanitizer.js";
|
||||
|
||||
/**
|
||||
* @typedef {Object} GuardOptions
|
||||
* @property {"block"|"warn"|"log"} [mode="warn"] - Action on detection
|
||||
* @property {Object} [logger] - Logger instance (defaults to console)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Create a prompt injection guard middleware.
|
||||
*
|
||||
* @param {GuardOptions} [options={}]
|
||||
* @returns {(req: Request) => { blocked: boolean, result: Object }|null}
|
||||
*/
|
||||
export function createInjectionGuard(options = {}) {
|
||||
const mode = options.mode || process.env.INJECTION_GUARD_MODE || "warn";
|
||||
const logger = options.logger || console;
|
||||
|
||||
/**
|
||||
* Check a request body for prompt injection.
|
||||
*
|
||||
* @param {Object} body - The parsed request body
|
||||
* @returns {{ blocked: boolean, result: import("../shared/utils/inputSanitizer.js").SanitizeResult }}
|
||||
*/
|
||||
return function guardRequest(body) {
|
||||
if (!body || typeof body !== "object") {
|
||||
return { blocked: false, result: { flagged: false, detections: [], piiDetections: [] } };
|
||||
}
|
||||
|
||||
const result = sanitizeRequest(body, logger);
|
||||
|
||||
if (!result.flagged) {
|
||||
return { blocked: false, result };
|
||||
}
|
||||
|
||||
const highSeverity = result.detections.filter((d) => d.severity === "high");
|
||||
|
||||
if (mode === "block" && highSeverity.length > 0) {
|
||||
logger.warn("[InjectionGuard] Blocked request with high-severity injection:", {
|
||||
detections: result.detections.map((d) => ({ pattern: d.pattern, severity: d.severity })),
|
||||
});
|
||||
return { blocked: true, result };
|
||||
}
|
||||
|
||||
if (mode === "warn" || mode === "log") {
|
||||
logger[mode === "warn" ? "warn" : "info"](
|
||||
"[InjectionGuard] Detected potential injection patterns:",
|
||||
{
|
||||
detections: result.detections.map((d) => ({ pattern: d.pattern, severity: d.severity })),
|
||||
pii: result.piiDetections.length,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return { blocked: false, result };
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Next.js API route handler wrapper for injection guarding.
|
||||
*
|
||||
* @param {Function} handler - Original route handler
|
||||
* @param {GuardOptions} [options={}]
|
||||
* @returns {Function} Wrapped handler
|
||||
*/
|
||||
export function withInjectionGuard(handler, options = {}) {
|
||||
const guard = createInjectionGuard(options);
|
||||
|
||||
return async function guardedHandler(request, context) {
|
||||
// Only apply to POST/PUT/PATCH
|
||||
if (!["POST", "PUT", "PATCH"].includes(request.method)) {
|
||||
return handler(request, context);
|
||||
}
|
||||
|
||||
try {
|
||||
// Clone request so body can still be read by handler
|
||||
const cloned = request.clone();
|
||||
const body = await cloned.json().catch(() => null);
|
||||
|
||||
if (body) {
|
||||
const { blocked, result } = guard(body);
|
||||
|
||||
if (blocked) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
message: "Request blocked: potential prompt injection detected",
|
||||
type: "injection_detected",
|
||||
detections: result.detections.length,
|
||||
},
|
||||
}),
|
||||
{ status: 400, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
// Attach sanitization result as header for downstream handlers
|
||||
if (result.flagged) {
|
||||
request.headers.set("X-Injection-Flagged", "true");
|
||||
request.headers.set("X-Injection-Detections", String(result.detections.length));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Don't block on guard errors — fail open
|
||||
}
|
||||
|
||||
return handler(request, context);
|
||||
};
|
||||
}
|
||||
77
src/shared/components/CloudSyncStatus.js
Normal file
77
src/shared/components/CloudSyncStatus.js
Normal file
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* CloudSyncStatus — Compact sync status indicator for the sidebar
|
||||
*
|
||||
* Shows cloud sync connection state with a small icon + label.
|
||||
* Fetches status from /api/sync/cloud periodically.
|
||||
*
|
||||
* @module shared/components/CloudSyncStatus
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
|
||||
const STATUS_CONFIG = {
|
||||
connected: { icon: "cloud_done", color: "text-green-500", label: "Synced" },
|
||||
syncing: { icon: "cloud_sync", color: "text-blue-400 animate-pulse", label: "Syncing..." },
|
||||
disconnected: { icon: "cloud_off", color: "text-text-muted", label: "Offline" },
|
||||
error: { icon: "cloud_off", color: "text-red-400", label: "Error" },
|
||||
disabled: { icon: "cloud_off", color: "text-text-muted/50", label: "Disabled" },
|
||||
};
|
||||
|
||||
export default function CloudSyncStatus({ collapsed = false }) {
|
||||
const [status, setStatus] = useState("disabled");
|
||||
const [lastSync, setLastSync] = useState(null);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
|
||||
async function poll() {
|
||||
try {
|
||||
const res = await fetch("/api/sync/cloud");
|
||||
if (!mountedRef.current) return;
|
||||
if (!res.ok) {
|
||||
setStatus("disconnected");
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
if (!mountedRef.current) return;
|
||||
|
||||
if (!data.enabled) setStatus("disabled");
|
||||
else if (data.syncing) setStatus("syncing");
|
||||
else if (data.connected || data.lastSync) {
|
||||
setStatus("connected");
|
||||
if (data.lastSync) setLastSync(new Date(data.lastSync));
|
||||
} else setStatus("disconnected");
|
||||
} catch {
|
||||
if (mountedRef.current) setStatus("disconnected");
|
||||
}
|
||||
}
|
||||
|
||||
poll();
|
||||
const interval = setInterval(poll, 30000);
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Don't render if cloud sync is disabled
|
||||
if (status === "disabled") return null;
|
||||
|
||||
const config = STATUS_CONFIG[status];
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-2 px-3 py-1.5 text-xs rounded-lg hover:bg-white/5 transition-colors cursor-default"
|
||||
title={lastSync ? `Last sync: ${lastSync.toLocaleTimeString()}` : config.label}
|
||||
aria-label={`Cloud sync status: ${config.label}`}
|
||||
>
|
||||
<span className={`material-symbols-outlined text-[16px] ${config.color}`} aria-hidden="true">
|
||||
{config.icon}
|
||||
</span>
|
||||
{!collapsed && <span className="text-text-muted truncate">{config.label}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { cn } from "@/shared/utils/cn";
|
||||
import { APP_CONFIG } from "@/shared/constants/config";
|
||||
import Button from "./Button";
|
||||
import { ConfirmModal } from "./Modal";
|
||||
import CloudSyncStatus from "./CloudSyncStatus";
|
||||
|
||||
const navItems = [
|
||||
{ href: "/dashboard/endpoint", label: "Endpoint", icon: "api" },
|
||||
@@ -244,6 +245,9 @@ export default function Sidebar({ onClose, collapsed = false, onToggleCollapse }
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Cloud sync status indicator */}
|
||||
<CloudSyncStatus collapsed={collapsed} />
|
||||
|
||||
{/* Footer — Shutdown + Restart */}
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
176
src/shared/components/SystemMonitor.js
Normal file
176
src/shared/components/SystemMonitor.js
Normal file
@@ -0,0 +1,176 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* SystemMonitor — Real-time system metrics widget
|
||||
*
|
||||
* Displays CPU/memory/uptime and token throughput metrics.
|
||||
* Fetches from /api/monitoring/health periodically.
|
||||
*
|
||||
* @module shared/components/SystemMonitor
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import Card from "./Card";
|
||||
|
||||
const REFRESH_INTERVAL = 10000; // 10s
|
||||
|
||||
function formatUptime(seconds) {
|
||||
if (!seconds) return "N/A";
|
||||
const d = Math.floor(seconds / 86400);
|
||||
const h = Math.floor((seconds % 86400) / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
if (d > 0) return `${d}d ${h}h`;
|
||||
if (h > 0) return `${h}h ${m}m`;
|
||||
return `${m}m`;
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (!bytes) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB"];
|
||||
let i = 0;
|
||||
let val = bytes;
|
||||
while (val >= 1024 && i < units.length - 1) {
|
||||
val /= 1024;
|
||||
i++;
|
||||
}
|
||||
return `${val.toFixed(1)} ${units[i]}`;
|
||||
}
|
||||
|
||||
function MetricRow({ icon, label, value, color = "text-text-main" }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-1.5">
|
||||
<div className="flex items-center gap-2 text-text-muted">
|
||||
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">
|
||||
{icon}
|
||||
</span>
|
||||
<span className="text-sm">{label}</span>
|
||||
</div>
|
||||
<span className={`text-sm font-mono font-medium ${color}`}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SystemMonitor({ compact = false }) {
|
||||
const [metrics, setMetrics] = useState(null);
|
||||
const [error, setError] = useState(false);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
|
||||
async function poll() {
|
||||
try {
|
||||
const res = await fetch("/api/monitoring/health");
|
||||
if (!mountedRef.current) return;
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
if (!mountedRef.current) return;
|
||||
setMetrics(data);
|
||||
setError(false);
|
||||
} catch {
|
||||
if (mountedRef.current) setError(true);
|
||||
}
|
||||
}
|
||||
|
||||
poll();
|
||||
const interval = setInterval(poll, REFRESH_INTERVAL);
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (error && !metrics) {
|
||||
return (
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-2 text-text-muted text-sm">
|
||||
<span className="material-symbols-outlined text-[18px] text-red-400">error</span>
|
||||
<span>Unable to load system metrics</span>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!metrics) {
|
||||
return (
|
||||
<Card className="p-4 animate-pulse">
|
||||
<div className="h-24 bg-surface rounded-lg" />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const memUsed = metrics.memory?.heapUsed || metrics.memoryUsage?.heapUsed || 0;
|
||||
const memTotal = metrics.memory?.heapTotal || metrics.memoryUsage?.heapTotal || 1;
|
||||
const memPercent = Math.round((memUsed / memTotal) * 100);
|
||||
const memColor =
|
||||
memPercent > 80 ? "text-red-400" : memPercent > 60 ? "text-amber-400" : "text-green-400";
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-4 text-xs text-text-muted"
|
||||
role="status"
|
||||
aria-label="System metrics"
|
||||
>
|
||||
<span title={`Memory: ${memPercent}%`}>
|
||||
<span className={`font-mono font-medium ${memColor}`}>{memPercent}%</span> mem
|
||||
</span>
|
||||
<span title={`Uptime: ${formatUptime(metrics.uptime)}`}>
|
||||
⏱ {formatUptime(metrics.uptime)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="p-4" role="region" aria-label="System monitoring">
|
||||
<h3 className="text-sm font-semibold text-text-main mb-3 flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[18px] text-primary" aria-hidden="true">
|
||||
monitoring
|
||||
</span>
|
||||
System Monitor
|
||||
</h3>
|
||||
|
||||
<div className="space-y-0.5">
|
||||
<MetricRow icon="timer" label="Uptime" value={formatUptime(metrics.uptime)} />
|
||||
<MetricRow
|
||||
icon="memory"
|
||||
label="Memory"
|
||||
value={`${formatBytes(memUsed)} / ${formatBytes(memTotal)} (${memPercent}%)`}
|
||||
color={memColor}
|
||||
/>
|
||||
{metrics.version && (
|
||||
<MetricRow icon="info" label="Version" value={metrics.version} color="text-primary" />
|
||||
)}
|
||||
{metrics.activeConnections !== undefined && (
|
||||
<MetricRow icon="lan" label="Connections" value={String(metrics.activeConnections)} />
|
||||
)}
|
||||
{metrics.circuitBreakers && (
|
||||
<MetricRow
|
||||
icon="health_and_safety"
|
||||
label="Circuit Breakers"
|
||||
value={`${metrics.circuitBreakers.open || 0} open`}
|
||||
color={metrics.circuitBreakers.open > 0 ? "text-amber-400" : "text-green-400"}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Memory bar */}
|
||||
<div className="mt-3">
|
||||
<div className="h-1.5 bg-surface rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-500 ${
|
||||
memPercent > 80 ? "bg-red-400" : memPercent > 60 ? "bg-amber-400" : "bg-green-400"
|
||||
}`}
|
||||
style={{ width: `${memPercent}%` }}
|
||||
role="progressbar"
|
||||
aria-valuenow={memPercent}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-label={`Memory usage: ${memPercent}%`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
61
src/shared/components/Tooltip.js
Normal file
61
src/shared/components/Tooltip.js
Normal file
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Tooltip — Lightweight CSS-only Tooltip Component
|
||||
*
|
||||
* Uses the `title` attribute enhanced with custom CSS positioning.
|
||||
* Renders a positioned tooltip on hover with smooth fade-in.
|
||||
*
|
||||
* @module shared/components/Tooltip
|
||||
*/
|
||||
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
|
||||
/**
|
||||
* @param {object} props
|
||||
* @param {React.ReactNode} props.children - Element to wrap
|
||||
* @param {string} props.content - Tooltip text
|
||||
* @param {"top"|"bottom"|"left"|"right"} [props.position="top"] - Position
|
||||
* @param {string} [props.className] - Additional className for wrapper
|
||||
*/
|
||||
export default function Tooltip({ children, content, position = "top", className = "" }) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const timeoutRef = useRef(null);
|
||||
|
||||
const show = useCallback(() => {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = setTimeout(() => setVisible(true), 200);
|
||||
}, []);
|
||||
|
||||
const hide = useCallback(() => {
|
||||
clearTimeout(timeoutRef.current);
|
||||
setVisible(false);
|
||||
}, []);
|
||||
|
||||
const positionClasses = {
|
||||
top: "bottom-full left-1/2 -translate-x-1/2 mb-2",
|
||||
bottom: "top-full left-1/2 -translate-x-1/2 mt-2",
|
||||
left: "right-full top-1/2 -translate-y-1/2 mr-2",
|
||||
right: "left-full top-1/2 -translate-y-1/2 ml-2",
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`relative inline-flex ${className}`}
|
||||
onMouseEnter={show}
|
||||
onMouseLeave={hide}
|
||||
onFocus={show}
|
||||
onBlur={hide}
|
||||
>
|
||||
{children}
|
||||
{visible && content && (
|
||||
<span
|
||||
role="tooltip"
|
||||
className={`absolute z-50 px-2.5 py-1.5 text-xs font-medium text-white bg-gray-900/95 rounded-md shadow-lg whitespace-nowrap pointer-events-none animate-in fade-in duration-150 border border-white/10 ${positionClasses[position] || positionClasses.top}`}
|
||||
>
|
||||
{content}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,16 @@
|
||||
/**
|
||||
* Analytics Charts — Barrel export
|
||||
*
|
||||
* TODO (#5): Split charts.js into individual component files:
|
||||
* - StatCard.js, ActivityHeatmap.js, DailyTrendChart.js
|
||||
* - AccountDonut.js, ApiKeyDonut.js, ProviderCostDonut.js
|
||||
* - ApiKeyTable.js, ModelTable.js
|
||||
* - WeeklyPattern.js, MostActiveDay7d.js, WeeklySquares7d.js
|
||||
* - UsageDetail.js, CostTooltip.js, DarkTooltip.js, SortIndicator.js
|
||||
*/
|
||||
export {
|
||||
DarkTooltip,
|
||||
CostTooltip,
|
||||
SortIndicator,
|
||||
StatCard,
|
||||
ActivityHeatmap,
|
||||
|
||||
145
src/shared/schemas/validation.js
Normal file
145
src/shared/schemas/validation.js
Normal file
@@ -0,0 +1,145 @@
|
||||
// @ts-check
|
||||
/**
|
||||
* Zod Validation Schemas — Shared request schemas for API routes
|
||||
*
|
||||
* Provides runtime input validation for provider, combo, and settings APIs.
|
||||
*
|
||||
* @module shared/schemas/validation
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
// ─── Provider Connection ──────────────────────────────────────────
|
||||
|
||||
export const providerConnectionSchema = z.object({
|
||||
provider: z.string().min(1, "Provider name is required"),
|
||||
authType: z.enum(["oauth", "apikey", "free"]).optional(),
|
||||
name: z.string().optional(),
|
||||
email: z.string().email().optional().or(z.literal("")),
|
||||
apiKey: z.string().optional(),
|
||||
accessToken: z.string().optional(),
|
||||
refreshToken: z.string().optional(),
|
||||
isActive: z.boolean().default(true),
|
||||
priority: z.number().int().min(0).default(0),
|
||||
defaultModel: z.string().optional(),
|
||||
globalPriority: z.number().int().min(0).optional().nullable(),
|
||||
rateLimitProtection: z.boolean().default(false),
|
||||
displayName: z.string().optional(),
|
||||
});
|
||||
|
||||
// ─── Combo / Routing Rule ─────────────────────────────────────────
|
||||
|
||||
export const comboNodeSchema = z.object({
|
||||
connectionId: z.string().uuid("Invalid connection ID"),
|
||||
weight: z.number().int().min(0).max(100).default(1),
|
||||
priority: z.number().int().min(0).default(0),
|
||||
});
|
||||
|
||||
export const comboSchema = z.object({
|
||||
name: z.string().min(1, "Combo name is required").max(100),
|
||||
model: z.string().min(1, "Model pattern is required"),
|
||||
strategy: z.enum(["priority", "weighted", "round-robin", "cost-optimized"]).default("priority"),
|
||||
nodes: z.array(comboNodeSchema).min(1, "At least one node is required"),
|
||||
isActive: z.boolean().default(true),
|
||||
maxRetries: z.number().int().min(0).max(10).default(2),
|
||||
retryDelay: z.number().int().min(0).max(30000).default(1000),
|
||||
});
|
||||
|
||||
// ─── API Key ──────────────────────────────────────────────────────
|
||||
|
||||
export const apiKeyCreateSchema = z.object({
|
||||
label: z.string().min(1, "Label is required").max(64),
|
||||
});
|
||||
|
||||
// ─── Settings ─────────────────────────────────────────────────────
|
||||
|
||||
export const settingsSchema = z
|
||||
.object({
|
||||
requireLogin: z.boolean().optional(),
|
||||
password: z.string().min(6, "Password must be at least 6 characters").optional(),
|
||||
defaultModel: z.string().optional(),
|
||||
enableRequestLogs: z.boolean().optional(),
|
||||
maxLogRetention: z.number().int().min(1).max(365).optional(),
|
||||
rateLimitEnabled: z.boolean().optional(),
|
||||
rateLimitPerMinute: z.number().int().min(0).optional(),
|
||||
})
|
||||
.partial();
|
||||
|
||||
// ─── Proxy Settings ───────────────────────────────────────────────
|
||||
|
||||
export const proxySettingsSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
url: z.string().url("Invalid proxy URL").optional().or(z.literal("")),
|
||||
username: z.string().optional(),
|
||||
password: z.string().optional(),
|
||||
bypassList: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
// ─── Resilience Profile ───────────────────────────────────────────
|
||||
|
||||
export const resilienceProfileSchema = z.object({
|
||||
provider: z.string().min(1),
|
||||
circuitBreaker: z
|
||||
.object({
|
||||
failureThreshold: z.number().int().min(1).max(100).default(5),
|
||||
resetTimeoutMs: z.number().int().min(1000).max(600000).default(30000),
|
||||
halfOpenMax: z.number().int().min(1).max(10).default(1),
|
||||
})
|
||||
.optional(),
|
||||
backoff: z
|
||||
.object({
|
||||
initialDelayMs: z.number().int().min(100).max(60000).default(1000),
|
||||
maxDelayMs: z.number().int().min(1000).max(600000).default(60000),
|
||||
multiplier: z.number().min(1).max(10).default(2),
|
||||
})
|
||||
.optional(),
|
||||
rateLimit: z
|
||||
.object({
|
||||
requestsPerMinute: z.number().int().min(0).optional(),
|
||||
tokensPerMinute: z.number().int().min(0).optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
// ─── Chat Completion Request (basic validation) ───────────────────
|
||||
|
||||
export const chatCompletionSchema = z.object({
|
||||
model: z.string().min(1, "Model is required"),
|
||||
messages: z
|
||||
.array(
|
||||
z.object({
|
||||
role: z.enum(["system", "user", "assistant", "tool"]),
|
||||
content: z.union([z.string(), z.array(z.any())]),
|
||||
})
|
||||
)
|
||||
.min(1, "At least one message is required"),
|
||||
stream: z.boolean().optional(),
|
||||
temperature: z.number().min(0).max(2).optional(),
|
||||
max_tokens: z.number().int().min(1).optional(),
|
||||
top_p: z.number().min(0).max(1).optional(),
|
||||
});
|
||||
|
||||
// ─── Helper ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Validate data against a schema and format errors for API responses.
|
||||
*
|
||||
* @template T
|
||||
* @param {z.ZodSchema<T>} schema
|
||||
* @param {unknown} data
|
||||
* @returns {{ success: true, data: T } | { success: false, errors: Array<{ path: string, message: string }> }}
|
||||
*/
|
||||
export function validateSchema(schema, data) {
|
||||
const result = schema.safeParse(data);
|
||||
if (result.success) {
|
||||
return { success: true, data: result.data };
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
errors: result.error.issues.map((issue) => ({
|
||||
path: issue.path.join("."),
|
||||
message: issue.message,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -25,7 +25,7 @@ export async function initializeCloudSync() {
|
||||
|
||||
// For development/testing purposes
|
||||
if (typeof require !== "undefined" && require.main === module) {
|
||||
initializeCloudSync().catch(console.log);
|
||||
initializeCloudSync().catch((err) => console.error("[CloudSync] init failed:", err));
|
||||
}
|
||||
|
||||
export default initializeCloudSync;
|
||||
|
||||
@@ -67,6 +67,82 @@ export function auditHTML(html) {
|
||||
return violations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a hex color string to RGB components.
|
||||
* @param {string} hex - Color in #RGB, #RRGGBB, or #RRGGBBAA format
|
||||
* @returns {{ r: number, g: number, b: number }|null}
|
||||
*/
|
||||
function parseHexColor(hex) {
|
||||
if (!hex || typeof hex !== "string") return null;
|
||||
const clean = hex.replace(/^#/, "");
|
||||
|
||||
let r, g, b;
|
||||
if (clean.length === 3) {
|
||||
r = parseInt(clean[0] + clean[0], 16);
|
||||
g = parseInt(clean[1] + clean[1], 16);
|
||||
b = parseInt(clean[2] + clean[2], 16);
|
||||
} else if (clean.length === 6 || clean.length === 8) {
|
||||
r = parseInt(clean.slice(0, 2), 16);
|
||||
g = parseInt(clean.slice(2, 4), 16);
|
||||
b = parseInt(clean.slice(4, 6), 16);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { r, g, b };
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute relative luminance per WCAG 2.x specification.
|
||||
* @param {{ r: number, g: number, b: number }} rgb
|
||||
* @returns {number} Relative luminance (0..1)
|
||||
*/
|
||||
function relativeLuminance({ r, g, b }) {
|
||||
const [sR, sG, sB] = [r, g, b].map((c) => {
|
||||
const v = c / 255;
|
||||
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
|
||||
});
|
||||
return 0.2126 * sR + 0.7152 * sG + 0.0722 * sB;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the contrast ratio between two hex colors.
|
||||
* @param {string} fgHex - Foreground color (#RRGGBB)
|
||||
* @param {string} bgHex - Background color (#RRGGBB)
|
||||
* @returns {number} Contrast ratio (1..21)
|
||||
*/
|
||||
export function getContrastRatio(fgHex, bgHex) {
|
||||
const fg = parseHexColor(fgHex);
|
||||
const bg = parseHexColor(bgHex);
|
||||
if (!fg || !bg) return 0;
|
||||
|
||||
const l1 = relativeLuminance(fg);
|
||||
const l2 = relativeLuminance(bg);
|
||||
const lighter = Math.max(l1, l2);
|
||||
const darker = Math.min(l1, l2);
|
||||
return (lighter + 0.05) / (darker + 0.05);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check WCAG AA contrast compliance between foreground and background colors.
|
||||
*
|
||||
* @param {string} fgHex - Foreground color (#RRGGBB)
|
||||
* @param {string} bgHex - Background color (#RRGGBB)
|
||||
* @param {{ largeText?: boolean }} [options={}] - Options
|
||||
* @returns {{ ratio: number, aa: boolean, aaa: boolean }}
|
||||
*/
|
||||
export function checkContrast(fgHex, bgHex, options = {}) {
|
||||
const ratio = getContrastRatio(fgHex, bgHex);
|
||||
const minAA = options.largeText ? 3 : 4.5;
|
||||
const minAAA = options.largeText ? 4.5 : 7;
|
||||
|
||||
return {
|
||||
ratio: Math.round(ratio * 100) / 100,
|
||||
aa: ratio >= minAA,
|
||||
aaa: ratio >= minAAA,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a summary report from a list of violations.
|
||||
*
|
||||
|
||||
133
src/shared/utils/costEstimator.js
Normal file
133
src/shared/utils/costEstimator.js
Normal file
@@ -0,0 +1,133 @@
|
||||
// @ts-check
|
||||
/**
|
||||
* Cost Estimator — Pre-flight cost estimation for LLM requests
|
||||
*
|
||||
* Estimates token-based costs before routing to a provider.
|
||||
* Uses pricing data from the dashboard/database.
|
||||
*
|
||||
* @module shared/utils/costEstimator
|
||||
*/
|
||||
|
||||
/**
|
||||
* Default pricing per 1M tokens (fallback when no pricing config exists).
|
||||
* Values in USD.
|
||||
*/
|
||||
const DEFAULT_PRICING = {
|
||||
"gpt-4o": { input: 2.5, output: 10.0 },
|
||||
"gpt-4o-mini": { input: 0.15, output: 0.6 },
|
||||
"gpt-4.1": { input: 2.0, output: 8.0 },
|
||||
"gpt-4.1-mini": { input: 0.4, output: 1.6 },
|
||||
"gpt-4.1-nano": { input: 0.1, output: 0.4 },
|
||||
o3: { input: 2.0, output: 8.0 },
|
||||
"o4-mini": { input: 1.1, output: 4.4 },
|
||||
"claude-sonnet-4-5-20250514": { input: 3.0, output: 15.0 },
|
||||
"claude-3-5-haiku-20241022": { input: 0.8, output: 4.0 },
|
||||
"gemini-2.5-pro": { input: 1.25, output: 10.0 },
|
||||
"gemini-2.5-flash": { input: 0.15, output: 0.6 },
|
||||
};
|
||||
|
||||
/**
|
||||
* Rough token estimation from text.
|
||||
* Uses ~4 chars per token approximation (GPT-family average).
|
||||
*
|
||||
* @param {string} text
|
||||
* @returns {number} Estimated token count
|
||||
*/
|
||||
export function estimateTokens(text) {
|
||||
if (!text || typeof text !== "string") return 0;
|
||||
return Math.ceil(text.length / 4);
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate input tokens from a chat completion request body.
|
||||
*
|
||||
* @param {Object} body - Request body
|
||||
* @param {Array<{role: string, content: string|Array<{type: string, text?: string}>}>} [body.messages]
|
||||
* @param {string} [body.system]
|
||||
* @returns {number} Estimated input token count
|
||||
*/
|
||||
export function estimateInputTokens(body) {
|
||||
if (!body) return 0;
|
||||
let total = 0;
|
||||
|
||||
if (body.system) total += estimateTokens(body.system);
|
||||
|
||||
if (Array.isArray(body.messages)) {
|
||||
for (const msg of body.messages) {
|
||||
if (typeof msg.content === "string") {
|
||||
total += estimateTokens(msg.content);
|
||||
} else if (Array.isArray(msg.content)) {
|
||||
for (const part of msg.content) {
|
||||
if (part.type === "text" && typeof part.text === "string") {
|
||||
total += estimateTokens(part.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Add ~4 tokens overhead per message (role, separators)
|
||||
total += 4;
|
||||
}
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate the cost of a request given a model.
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {string} params.model - Model identifier
|
||||
* @param {number} params.inputTokens - Estimated input tokens
|
||||
* @param {number} [params.maxOutputTokens=1000] - Max output tokens
|
||||
* @param {Object} [params.pricingOverrides] - Custom pricing { input, output } per 1M tokens
|
||||
* @returns {{ inputCost: number, outputCost: number, totalCost: number, model: string, inputTokens: number, outputTokens: number }}
|
||||
*/
|
||||
export function estimateCost({ model, inputTokens, maxOutputTokens = 1000, pricingOverrides }) {
|
||||
// Find matching pricing (exact match or prefix match)
|
||||
let pricing = pricingOverrides;
|
||||
if (!pricing) {
|
||||
const key = Object.keys(DEFAULT_PRICING).find((k) => model === k || model.startsWith(k));
|
||||
pricing = key ? DEFAULT_PRICING[key] : { input: 1.0, output: 3.0 }; // conservative fallback
|
||||
}
|
||||
|
||||
const inputCost = (inputTokens / 1_000_000) * pricing.input;
|
||||
const outputCost = (maxOutputTokens / 1_000_000) * pricing.output;
|
||||
const totalCost = inputCost + outputCost;
|
||||
|
||||
return {
|
||||
model,
|
||||
inputTokens,
|
||||
outputTokens: maxOutputTokens,
|
||||
inputCost: Math.round(inputCost * 1_000_000) / 1_000_000,
|
||||
outputCost: Math.round(outputCost * 1_000_000) / 1_000_000,
|
||||
totalCost: Math.round(totalCost * 1_000_000) / 1_000_000,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a cost value for display.
|
||||
* @param {number} usd
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatCost(usd) {
|
||||
if (usd < 0.01) return `$${(usd * 100).toFixed(4)}¢`;
|
||||
return `$${usd.toFixed(4)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick pre-flight estimate: given a request body and model, return estimated cost.
|
||||
*
|
||||
* @param {Object} body - Chat completion request body
|
||||
* @param {string} model - Target model
|
||||
* @param {Object} [pricingOverrides] - Optional pricing overrides
|
||||
* @returns {{ inputCost: number, outputCost: number, totalCost: number, formatted: string }}
|
||||
*/
|
||||
export function preflightEstimate(body, model, pricingOverrides) {
|
||||
const inputTokens = estimateInputTokens(body);
|
||||
const maxOutput = body.max_tokens || body.maxOutputTokens || 1000;
|
||||
const result = estimateCost({ model, inputTokens, maxOutputTokens: maxOutput, pricingOverrides });
|
||||
|
||||
return {
|
||||
...result,
|
||||
formatted: formatCost(result.totalCost),
|
||||
};
|
||||
}
|
||||
173
src/shared/utils/streamTracker.js
Normal file
173
src/shared/utils/streamTracker.js
Normal file
@@ -0,0 +1,173 @@
|
||||
// @ts-check
|
||||
/**
|
||||
* Stream Tracker — Unified SSE stream monitoring
|
||||
*
|
||||
* Tracks token counts, latency, and errors during streaming responses.
|
||||
* Emits periodic progress callbacks for real-time monitoring.
|
||||
*
|
||||
* @module shared/utils/streamTracker
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} StreamMetrics
|
||||
* @property {number} startTime - Timestamp when stream started
|
||||
* @property {number} firstTokenTime - Time to first token (ms)
|
||||
* @property {number} totalTokens - Total tokens received
|
||||
* @property {number} totalChunks - Total SSE chunks received
|
||||
* @property {number} elapsedMs - Total elapsed time (ms)
|
||||
* @property {number} tokensPerSecond - Current throughput
|
||||
* @property {boolean} complete - Whether stream is complete
|
||||
* @property {string|null} error - Error message if any
|
||||
* @property {string|null} finishReason - Stop reason from provider
|
||||
*/
|
||||
|
||||
export class StreamTracker {
|
||||
/** @param {{ onProgress?: (metrics: StreamMetrics) => void, progressIntervalMs?: number }} [options={}] */
|
||||
constructor(options = {}) {
|
||||
this._onProgress = options.onProgress || null;
|
||||
this._progressIntervalMs = options.progressIntervalMs || 500;
|
||||
|
||||
this._startTime = Date.now();
|
||||
this._firstTokenTime = 0;
|
||||
this._totalTokens = 0;
|
||||
this._totalChunks = 0;
|
||||
this._complete = false;
|
||||
this._error = null;
|
||||
this._finishReason = null;
|
||||
this._lastProgressAt = 0;
|
||||
this._buffer = "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an incoming SSE chunk.
|
||||
* @param {string|Object} chunk - Raw SSE text or parsed data
|
||||
*/
|
||||
onChunk(chunk) {
|
||||
this._totalChunks++;
|
||||
|
||||
if (this._totalChunks === 1) {
|
||||
this._firstTokenTime = Date.now() - this._startTime;
|
||||
}
|
||||
|
||||
// Try to extract token count from chunk
|
||||
let data = chunk;
|
||||
if (typeof chunk === "string") {
|
||||
// Parse SSE if formatted
|
||||
if (chunk.startsWith("data: ")) {
|
||||
const payload = chunk.slice(6).trim();
|
||||
if (payload === "[DONE]") {
|
||||
this._complete = true;
|
||||
this._emitProgress();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
data = JSON.parse(payload);
|
||||
} catch {
|
||||
data = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (data && typeof data === "object") {
|
||||
// OpenAI format: choices[0].delta.content
|
||||
const content = data.choices?.[0]?.delta?.content;
|
||||
if (content) {
|
||||
// Rough token estimate (~4 chars per token)
|
||||
this._totalTokens += Math.ceil(content.length / 4);
|
||||
}
|
||||
|
||||
// Check for finish reason
|
||||
const reason = data.choices?.[0]?.finish_reason;
|
||||
if (reason) {
|
||||
this._finishReason = reason;
|
||||
}
|
||||
|
||||
// Usage in final chunk (OpenAI includes this)
|
||||
if (data.usage?.completion_tokens) {
|
||||
this._totalTokens = data.usage.completion_tokens;
|
||||
}
|
||||
}
|
||||
|
||||
this._maybeEmitProgress();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark stream as errored.
|
||||
* @param {string|Error} error
|
||||
*/
|
||||
onError(error) {
|
||||
this._error = typeof error === "string" ? error : error.message;
|
||||
this._complete = true;
|
||||
this._emitProgress();
|
||||
}
|
||||
|
||||
/** Mark stream as complete. */
|
||||
onComplete() {
|
||||
this._complete = true;
|
||||
this._emitProgress();
|
||||
}
|
||||
|
||||
/** @returns {StreamMetrics} Current metrics */
|
||||
getMetrics() {
|
||||
const elapsedMs = Date.now() - this._startTime;
|
||||
const tokensPerSecond = elapsedMs > 0 ? this._totalTokens / (elapsedMs / 1000) : 0;
|
||||
|
||||
return {
|
||||
startTime: this._startTime,
|
||||
firstTokenTime: this._firstTokenTime,
|
||||
totalTokens: this._totalTokens,
|
||||
totalChunks: this._totalChunks,
|
||||
elapsedMs,
|
||||
tokensPerSecond: Math.round(tokensPerSecond * 10) / 10,
|
||||
complete: this._complete,
|
||||
error: this._error,
|
||||
finishReason: this._finishReason,
|
||||
};
|
||||
}
|
||||
|
||||
/** @private */
|
||||
_maybeEmitProgress() {
|
||||
const now = Date.now();
|
||||
if (now - this._lastProgressAt >= this._progressIntervalMs) {
|
||||
this._emitProgress();
|
||||
}
|
||||
}
|
||||
|
||||
/** @private */
|
||||
_emitProgress() {
|
||||
this._lastProgressAt = Date.now();
|
||||
if (this._onProgress) {
|
||||
this._onProgress(this.getMetrics());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a TransformStream that tracks SSE progress.
|
||||
*
|
||||
* @param {{ onProgress?: (metrics: StreamMetrics) => void }} [options={}]
|
||||
* @returns {{ stream: TransformStream, tracker: StreamTracker }}
|
||||
*/
|
||||
export function createStreamTracker(options = {}) {
|
||||
const tracker = new StreamTracker(options);
|
||||
|
||||
const stream = new TransformStream({
|
||||
transform(chunk, controller) {
|
||||
const text = typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk);
|
||||
const lines = text.split("\n");
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("data: ")) {
|
||||
tracker.onChunk(line);
|
||||
}
|
||||
}
|
||||
|
||||
controller.enqueue(chunk);
|
||||
},
|
||||
flush() {
|
||||
tracker.onComplete();
|
||||
},
|
||||
});
|
||||
|
||||
return { stream, tracker };
|
||||
}
|
||||
Reference in New Issue
Block a user