diff --git a/CHANGELOG.md b/CHANGELOG.md index 07cac81d9a..bad181bbb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,15 @@ - **feat(i18n):** finish Simplified Chinese (zh-CN) UI coverage with 377 translated entries. ([#2659](https://github.com/diegosouzapw/OmniRoute/pull/2659) — thanks @L-aros) - **feat(dashboard):** chat-first test slide-over layout — consolidates header controls (Model/Key selects, Clear button) into a unified toolbar, maximizes vertical conversation space, integrates live tailing of provider logs in Logs tab, and locks composer focus for keyboard-only convenience. ([#2660](https://github.com/diegosouzapw/OmniRoute/pull/2660) — thanks @mrmm) - **feat(cli):** desktop updates, autostart, and headless CLI modes — integrates native auto-updater checks, login autostart (Linux .desktop, macOS/Windows login items), and a background headless server CLI daemon mode (`--headless` or `OMNIROUTE_HEADLESS=true`) into the Electron app wrapper. ([#2662](https://github.com/diegosouzapw/OmniRoute/pull/2662) — thanks @benzntech) +- **feat(quota):** card-grid layout and provider group headers under quota management — replaces monolithic table with a beautiful 4-column card grid in limits. ([#2667](https://github.com/diegosouzapw/OmniRoute/pull/2667) — thanks @Gi99lin) +- **feat(dashboard):** real-time WebSocket live monitoring daemon — runs a Node.js WebSocket daemon sidecar on port `20129` to emit real-time events for request starts/completes/fails, combo attempts, and credential status in the dashboard logs. ([#2668](https://github.com/diegosouzapw/OmniRoute/pull/2668) — thanks @herjarsa) +- **feat(copilot):** AI assistant with CodeGraph + CLI + knowledge base — integrates a dashboard assistant with CodeGraph knowledge base access and CLI capabilities for app exploration. ([#2669](https://github.com/diegosouzapw/OmniRoute/pull/2669) — thanks @ovehbe / @herjarsa) +- **feat(pipeline):** pre-request middleware hooks — pipeline executing custom JS hooks before routing/combo logic to mutate headers/body or short-circuit requests. ([#2670](https://github.com/diegosouzapw/OmniRoute/pull/2670) — thanks @herjarsa) +- **feat(resilience):** credential health check + adaptive circuit breaker v2 — background connection health check scheduler with progressive circuit breaker adding DEGRADED state and HALF-OPEN recovery validation to avoid latency spikes. ([#2671](https://github.com/diegosouzapw/OmniRoute/pull/2671) — thanks @herjarsa) +- **feat(playground):** combo routing visual simulator — interactive route simulation page at `/dashboard/combos/playground` to showcase cascade hops, latency, and cost estimates. ([#2672](https://github.com/diegosouzapw/OmniRoute/pull/2672) — thanks @herjarsa) +- **feat(auth):** API key groups with model-level permissions — group definitions with model-level wildcards/denies where API keys inherit group-scoped restrictions. ([#2673](https://github.com/diegosouzapw/OmniRoute/pull/2673) — thanks @herjarsa) +- **feat(pwa):** enhanced manifest + push notification support — polishes offline shortcuts, screenshots, display metadata, and push service workers. ([#2674](https://github.com/diegosouzapw/OmniRoute/pull/2674) — thanks @herjarsa) +- **feat(proxy):** serverless relay proxy endpoints with rate limiting — public relay proxy endpoints with cost caps and rate limits, CRUD API, and dashboard usage tracking. ([#2675](https://github.com/diegosouzapw/OmniRoute/pull/2675) — thanks @herjarsa) ### 🔧 Bug Fixes @@ -49,6 +58,7 @@ - **fix(combo):** resolve pending request leaks on unresponsive combo targets — implements a default 60-second per-target timeout during combo routing loops to abort hanging upstream requests and release capacity limits. ([#2663](https://github.com/diegosouzapw/OmniRoute/pull/2663) — thanks @Chewji9875) - **fix(proxy):** save custom dashboard proxies directly in SQLite registry — writes new provider/account/global/combo custom proxies directly to the modern `proxy_registry` database and assigns them via `proxy_assignments` instead of creating duplicate configurations. ([#2661](https://github.com/diegosouzapw/OmniRoute/pull/2661) — thanks @terence71-glitch) - **fix(settings):** expand effortLevel enum to support xhigh and max reasoning efforts — adds `xhigh` and `max` levels to the updateThinkingBudgetSchema to resolve validation failures that silently discarded top-effort request payloads. ([#2666](https://github.com/diegosouzapw/OmniRoute/pull/2666) — thanks @mrmm) +- **fix(codex):** Codex OAuth refresh token reuse race condition under parallel requests. ([#2667](https://github.com/diegosouzapw/OmniRoute/pull/2667) — thanks @diegosouzapw) ### 📝 Maintenance diff --git a/open-sse/services/contextHandoff.ts b/open-sse/services/contextHandoff.ts index 086e848871..6accbccbf1 100644 --- a/open-sse/services/contextHandoff.ts +++ b/open-sse/services/contextHandoff.ts @@ -1,5 +1,6 @@ import { cleanupExpiredHandoffs, + getHandoff, hasActiveHandoff, type HandoffPayload, upsertHandoff, diff --git a/src/lib/db/middleware.ts b/src/lib/db/middleware.ts index 3c685f9ae7..1485196920 100644 --- a/src/lib/db/middleware.ts +++ b/src/lib/db/middleware.ts @@ -26,7 +26,7 @@ function rowToHookConfig(row: HookConfigRow): HookConfig { createdAt: row.created_at, updatedAt: row.updated_at, runCount: row.run_count, - lastError: row.last_error, + lastError: row.last_error || undefined, }; } diff --git a/src/lib/db/relayProxies.ts b/src/lib/db/relayProxies.ts index 8e156b856b..b88c6db945 100644 --- a/src/lib/db/relayProxies.ts +++ b/src/lib/db/relayProxies.ts @@ -110,60 +110,107 @@ export function createRelayToken(input: CreateRelayTokenInput): RelayTokenWithSe const prefix = "rl_" + rawToken.slice(6, 14); - db.prepare(` + db.prepare( + ` INSERT INTO relay_tokens (id, name, token_hash, token_prefix, description, combo_id, allowed_models, max_tokens_per_request, max_requests_per_minute, max_requests_per_day, max_cost_per_day, enabled, created_at, updated_at, expires_at, metadata) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?) - `).run( - id, input.name, tokenHash, prefix, input.description || "", input.comboId || null, + ` + ).run( + id, + input.name, + tokenHash, + prefix, + input.description || "", + input.comboId || null, JSON.stringify(input.allowedModels || ["*"]), input.maxTokensPerRequest || 128000, input.maxRequestsPerMinute || 60, input.maxRequestsPerDay || 10000, input.maxCostPerDay || 0, - now, now, input.expiresAt || null, - JSON.stringify(input.metadata || {}), + now, + now, + input.expiresAt || null, + JSON.stringify(input.metadata || {}) ); const token = db.prepare("SELECT * FROM relay_tokens WHERE id = ?").get(id) as RelayTokenRow; - return { ...rowToCamel(token), rawToken }; + return { ...(rowToCamel(token) as unknown as RelayToken), rawToken }; } export function getRelayTokens(): RelayToken[] { const db = getDbInstance(); - const rows = db.prepare("SELECT * FROM relay_tokens ORDER BY created_at DESC").all() as RelayTokenRow[]; - return rows.map((r) => ({ ...rowToCamel(r), enabled: r.enabled === 1 })); + const rows = db + .prepare("SELECT * FROM relay_tokens ORDER BY created_at DESC") + .all() as RelayTokenRow[]; + return rows.map((r) => ({ + ...(rowToCamel(r) as unknown as RelayToken), + enabled: r.enabled === 1, + })); } export function getRelayToken(id: string): RelayToken | null { const db = getDbInstance(); - const row = db.prepare("SELECT * FROM relay_tokens WHERE id = ?").get(id) as RelayTokenRow | undefined; + const row = db.prepare("SELECT * FROM relay_tokens WHERE id = ?").get(id) as + | RelayTokenRow + | undefined; if (!row) return null; - return { ...rowToCamel(row), enabled: row.enabled === 1 }; + return { ...(rowToCamel(row) as unknown as RelayToken), enabled: row.enabled === 1 }; } -export function getRelayTokenByHash(tokenHash: string): (RelayToken & { rawToken?: string }) | null { +export function getRelayTokenByHash( + tokenHash: string +): (RelayToken & { rawToken?: string }) | null { const db = getDbInstance(); - const row = db.prepare("SELECT * FROM relay_tokens WHERE token_hash = ? AND enabled = 1").get(tokenHash) as RelayTokenRow | undefined; + const row = db + .prepare("SELECT * FROM relay_tokens WHERE token_hash = ? AND enabled = 1") + .get(tokenHash) as RelayTokenRow | undefined; if (!row) return null; - return { ...rowToCamel(row), enabled: row.enabled === 1 }; + return { ...(rowToCamel(row) as unknown as RelayToken), enabled: row.enabled === 1 }; } -export function updateRelayToken(id: string, updates: Partial): RelayToken | null { +export function updateRelayToken( + id: string, + updates: Partial +): RelayToken | null { const db = getDbInstance(); const now = Math.floor(Date.now() / 1000); const sets: string[] = ["updated_at = ?"]; const params: unknown[] = [now]; - if (updates.name !== undefined) { sets.push("name = ?"); params.push(updates.name); } - if (updates.description !== undefined) { sets.push("description = ?"); params.push(updates.description); } - if (updates.comboId !== undefined) { sets.push("combo_id = ?"); params.push(updates.comboId); } - if (updates.allowedModels !== undefined) { sets.push("allowed_models = ?"); params.push(JSON.stringify(updates.allowedModels)); } - if (updates.maxTokensPerRequest !== undefined) { sets.push("max_tokens_per_request = ?"); params.push(updates.maxTokensPerRequest); } - if (updates.maxRequestsPerMinute !== undefined) { sets.push("max_requests_per_minute = ?"); params.push(updates.maxRequestsPerMinute); } - if (updates.maxRequestsPerDay !== undefined) { sets.push("max_requests_per_day = ?"); params.push(updates.maxRequestsPerDay); } - if (updates.maxCostPerDay !== undefined) { sets.push("max_cost_per_day = ?"); params.push(updates.maxCostPerDay); } + if (updates.name !== undefined) { + sets.push("name = ?"); + params.push(updates.name); + } + if (updates.description !== undefined) { + sets.push("description = ?"); + params.push(updates.description); + } + if (updates.comboId !== undefined) { + sets.push("combo_id = ?"); + params.push(updates.comboId); + } + if (updates.allowedModels !== undefined) { + sets.push("allowed_models = ?"); + params.push(JSON.stringify(updates.allowedModels)); + } + if (updates.maxTokensPerRequest !== undefined) { + sets.push("max_tokens_per_request = ?"); + params.push(updates.maxTokensPerRequest); + } + if (updates.maxRequestsPerMinute !== undefined) { + sets.push("max_requests_per_minute = ?"); + params.push(updates.maxRequestsPerMinute); + } + if (updates.maxRequestsPerDay !== undefined) { + sets.push("max_requests_per_day = ?"); + params.push(updates.maxRequestsPerDay); + } + if (updates.maxCostPerDay !== undefined) { + sets.push("max_cost_per_day = ?"); + params.push(updates.maxCostPerDay); + } params.push(id); db.prepare(`UPDATE relay_tokens SET ${sets.join(", ")} WHERE id = ?`).run(...params); @@ -178,15 +225,25 @@ export function deleteRelayToken(id: string): void { export function toggleRelayToken(id: string, enabled: boolean): RelayToken | null { const db = getDbInstance(); const now = Math.floor(Date.now() / 1000); - db.prepare("UPDATE relay_tokens SET enabled = ?, updated_at = ? WHERE id = ?").run(enabled ? 1 : 0, now, id); + db.prepare("UPDATE relay_tokens SET enabled = ?, updated_at = ? WHERE id = ?").run( + enabled ? 1 : 0, + now, + id + ); return getRelayToken(id); } // ── Usage / Rate Limit ─────────────────────────────────────────────────────── -export function checkRateLimit(tokenId: string): { allowed: boolean; remaining: number; resetIn: number } { +export function checkRateLimit(tokenId: string): { + allowed: boolean; + remaining: number; + resetIn: number; +} { const db = getDbInstance(); - const token = db.prepare("SELECT * FROM relay_tokens WHERE id = ?").get(tokenId) as RelayTokenRow | undefined; + const token = db.prepare("SELECT * FROM relay_tokens WHERE id = ?").get(tokenId) as + | RelayTokenRow + | undefined; if (!token) return { allowed: false, remaining: 0, resetIn: 0 }; const now = Math.floor(Date.now() / 1000); @@ -194,9 +251,11 @@ export function checkRateLimit(tokenId: string): { allowed: boolean; remaining: const dayWindow = Math.floor(now / 86400) * 86400; // Check minute rate - const minuteRow = db.prepare( - "SELECT request_count, cost FROM relay_rate_limits WHERE token_id = ? AND window_start = ?", - ).get(tokenId, minuteWindow) as { request_count: number; cost: number } | undefined; + const minuteRow = db + .prepare( + "SELECT request_count, cost FROM relay_rate_limits WHERE token_id = ? AND window_start = ?" + ) + .get(tokenId, minuteWindow) as { request_count: number; cost: number } | undefined; const minuteCount = minuteRow?.request_count || 0; if (minuteCount >= token.max_requests_per_minute) { @@ -204,9 +263,11 @@ export function checkRateLimit(tokenId: string): { allowed: boolean; remaining: } // Check daily rate - const dayRow = db.prepare( - "SELECT SUM(request_count) as total FROM relay_rate_limits WHERE token_id = ? AND window_start >= ?", - ).get(tokenId, dayWindow) as { total: number } | undefined; + const dayRow = db + .prepare( + "SELECT SUM(request_count) as total FROM relay_rate_limits WHERE token_id = ? AND window_start >= ?" + ) + .get(tokenId, dayWindow) as { total: number } | undefined; const dayCount = dayRow?.total || 0; if (dayCount >= token.max_requests_per_day) { @@ -215,7 +276,7 @@ export function checkRateLimit(tokenId: string): { allowed: boolean; remaining: const remaining = Math.min( token.max_requests_per_minute - minuteCount, - token.max_requests_per_day - dayCount, + token.max_requests_per_day - dayCount ); return { allowed: true, remaining, resetIn: 60 - (now % 60) }; @@ -234,30 +295,34 @@ export function recordRelayUsage( latencyMs?: number; clientIp?: string; userAgent?: string; - }, + } ): void { const db = getDbInstance(); const now = Math.floor(Date.now() / 1000); const minuteWindow = Math.floor(now / 60) * 60; // Update rate limit window - db.prepare(` + db.prepare( + ` INSERT INTO relay_rate_limits (token_id, window_start, request_count, cost) VALUES (?, ?, 1, ?) ON CONFLICT(token_id, window_start) DO UPDATE SET request_count = request_count + 1, cost = cost + ? - `).run(tokenId, minuteWindow, params.cost || 0, params.cost || 0); + ` + ).run(tokenId, minuteWindow, params.cost || 0, params.cost || 0); // Update last_used_at db.prepare("UPDATE relay_tokens SET last_used_at = ? WHERE id = ?").run(now, tokenId); // Insert log - db.prepare(` + db.prepare( + ` INSERT INTO relay_logs (token_id, request_id, model, prompt_tokens, completion_tokens, cost, status, status_code, latency_ms, client_ip, user_agent, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `).run( + ` + ).run( tokenId, params.requestId || null, params.model || null, @@ -269,26 +334,31 @@ export function recordRelayUsage( params.latencyMs || 0, params.clientIp || null, params.userAgent || null, - now, + now ); } -export function getRelayUsage(tokenId: string, since: number): { requestCount: number; totalCost: number } { +export function getRelayUsage( + tokenId: string, + since: number +): { requestCount: number; totalCost: number } { const db = getDbInstance(); - const row = db.prepare( - "SELECT COUNT(*) as request_count, COALESCE(SUM(cost), 0) as total_cost FROM relay_logs WHERE token_id = ? AND created_at >= ?", - ).get(tokenId, since) as { request_count: number; total_cost: number }; + const row = db + .prepare( + "SELECT COUNT(*) as request_count, COALESCE(SUM(cost), 0) as total_cost FROM relay_logs WHERE token_id = ? AND created_at >= ?" + ) + .get(tokenId, since) as { request_count: number; total_cost: number }; return { requestCount: row.request_count, totalCost: row.total_cost }; } export function getRelayLogs(tokenId?: string, limit = 50): RelayLogRow[] { const db = getDbInstance(); if (tokenId) { - return db.prepare( - "SELECT * FROM relay_logs WHERE token_id = ? ORDER BY created_at DESC LIMIT ?", - ).all(tokenId, limit) as RelayLogRow[]; + return db + .prepare("SELECT * FROM relay_logs WHERE token_id = ? ORDER BY created_at DESC LIMIT ?") + .all(tokenId, limit) as RelayLogRow[]; } - return db.prepare( - "SELECT * FROM relay_logs ORDER BY created_at DESC LIMIT ?", - ).all(limit) as RelayLogRow[]; + return db + .prepare("SELECT * FROM relay_logs ORDER BY created_at DESC LIMIT ?") + .all(limit) as RelayLogRow[]; } diff --git a/src/lib/middleware/types.ts b/src/lib/middleware/types.ts index 4e75708765..da83016e6f 100644 --- a/src/lib/middleware/types.ts +++ b/src/lib/middleware/types.ts @@ -98,13 +98,13 @@ export interface HookConfigRow { description: string; priority: number; scope_type: "global" | "combo"; - combo_id?: string; + combo_id?: string | null; enabled: number; code: string; created_at: string; updated_at: string; run_count: number; - last_error?: string; + last_error?: string | null; } /** API request body for creating/updating a hook */