From e47740e02e9281fffdef0b02ad14791a86de6ba7 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Sun, 22 Mar 2026 23:17:52 -0300 Subject: [PATCH] feat: sub2api T05/T08/T09/T13/T14 + bump to 3.0.0-rc.7 --- CHANGELOG.md | 20 +++- docs/openapi.yaml | 2 +- open-sse/executors/codex.ts | 40 ++++++++ open-sse/services/sessionManager.ts | 63 +++++++++++++ package.json | 2 +- src/lib/db/apiKeys.ts | 26 +++++- src/lib/db/providers.ts | 95 +++++++++++++++++++ src/lib/localDb.ts | 9 ++ src/lib/proxyHealth.ts | 140 ++++++++++++++++++++++++++++ 9 files changed, 391 insertions(+), 6 deletions(-) create mode 100644 src/lib/proxyHealth.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5980144c9a..31e30d3dfe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,9 @@ --- -## [3.0.0-rc.6] — 2026-03-23 _(What's New vs v2.9.5 — will be released as v3.0.0)_ +## [3.0.0-rc.7] — 2026-03-23 _(What's New vs v2.9.5 — will be released as v3.0.0)_ -> **Upgrade from v2.9.5:** 16 issues resolved · 2 community PRs merged · 2 new providers · 7 new API endpoints · 3 new features · DB migration 008+009 · 832 tests passing · 10 sub2api gap improvements. +> **Upgrade from v2.9.5:** 16 issues resolved · 2 community PRs merged · 2 new providers · 7 new API endpoints · 3 new features · DB migration 008+009 · 832 tests passing · 15 sub2api gap improvements (T01–T15 complete). ### 🆕 New Providers @@ -111,6 +111,22 @@ OmniRoute now automatically refreshes model lists for connected providers every --- +## [3.0.0-rc.7] - 2026-03-23 + +### 🔧 Improvements (sub2api Gap Analysis — T05, T08, T09, T13, T14) + +- **T05** — Rate-limit DB persistence: `setConnectionRateLimitUntil()`, `isConnectionRateLimited()`, `getRateLimitedConnections()` in `providers.ts`. The existing `rate_limited_until` column is now exposed as a dedicated API — OAuth token refresh must NOT touch this field to prevent rate-limit loops. +- **T08** — Per-API-key session limit: `max_sessions INTEGER DEFAULT 0` added to `api_keys` via auto-migration. `sessionManager.ts` gains `registerKeySession()`, `unregisterKeySession()`, `checkSessionLimit()`, and `getActiveSessionCountForKey()`. Callers in `chatCore.js` can enforce the limit and decrement on `req.close`. +- **T09** — Codex vs Spark rate-limit scopes: `getCodexModelScope()` and `getCodexRateLimitKey()` in `codex.ts`. Standard models (`gpt-5.x-codex`, `codex-mini`) get scope `"codex"`; spark models (`codex-spark*`) get scope `"spark"`. Rate-limit keys should be `${accountId}:${scope}` so exhausting one pool doesn't block the other. +- **T13** — Stale quota display fix: `getEffectiveQuotaUsage(used, resetAt)` returns `0` when the reset window has passed; `formatResetCountdown(resetAt)` returns a human-readable countdown string (e.g. `"2h 35m"`). Both exported from `providers.ts` + `localDb.ts` for dashboard consumption. +- **T14** — Proxy fast-fail: new `src/lib/proxyHealth.ts` with `isProxyReachable(proxyUrl, timeoutMs=2000)` (TCP check, ≤2s instead of 30s timeout), `getCachedProxyHealth()`, `invalidateProxyHealth()`, and `getAllProxyHealthStatuses()`. Results cached 30s by default; configurable via `PROXY_FAST_FAIL_TIMEOUT_MS` / `PROXY_HEALTH_CACHE_TTL_MS`. + +### 🧪 Tests + +- Test suite: **832 tests, 0 failures** + +--- + ## [3.0.0-rc.6] - 2026-03-23 ### 🔧 Bug Fixes & Improvements (sub2api Gap Analysis — T01–T15) diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 16ef58c9f8..c1678e941c 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: OmniRoute API - version: 3.0.0-rc.6 + version: 3.0.0-rc.7 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, diff --git a/open-sse/executors/codex.ts b/open-sse/executors/codex.ts index 4dbaf170bf..4826278ce3 100644 --- a/open-sse/executors/codex.ts +++ b/open-sse/executors/codex.ts @@ -3,6 +3,46 @@ import { CODEX_DEFAULT_INSTRUCTIONS } from "../config/codexInstructions.ts"; import { PROVIDERS } from "../config/constants.ts"; import { refreshCodexToken } from "../services/tokenRefresh.ts"; +// ─── T09: Codex vs Spark Scope-Aware Rate Limiting ──────────────────────── +// Codex has two independent quota pools: "codex" (standard) and "spark" (premium). +// Exhausting one should NOT block requests to the other. +// Ref: sub2api PR #1129 (feat(openai): split codex spark rate limiting from codex) + +/** + * Maps model name substrings to their rate-limit scope. + * Checked in order — first match wins. + */ +const CODEX_SCOPE_PATTERNS: Array<{ pattern: string; scope: "codex" | "spark" }> = [ + { pattern: "codex-spark", scope: "spark" }, + { pattern: "spark", scope: "spark" }, + { pattern: "codex", scope: "codex" }, + { pattern: "gpt-5", scope: "codex" }, // gpt-5.2-codex, gpt-5.3-codex, etc. +]; + +/** + * T09: Determine the rate-limit scope for a Codex model. + * Use this key as the suffix for per-scope rate limit state: + * `${accountId}:${getModelScope(model)}` + * + * @param model - The Codex model ID (e.g. "gpt-5.3-codex", "codex-spark-mini") + * @returns "codex" | "spark" + */ +export function getCodexModelScope(model: string): "codex" | "spark" { + const lower = model.toLowerCase(); + for (const { pattern, scope } of CODEX_SCOPE_PATTERNS) { + if (lower.includes(pattern)) return scope; + } + return "codex"; // default scope +} + +/** + * T09: Get the scope-keyed rate limit identifier for an account+model combination. + * Use this as the key for rateLimitState maps to ensure scope isolation. + */ +export function getCodexRateLimitKey(accountId: string, model: string): string { + return `${accountId}:${getCodexModelScope(model)}`; +} + /** * T03: Parsed quota snapshot from Codex response headers. * Codex includes per-account usage windows that allow precise reset scheduling. diff --git a/open-sse/services/sessionManager.ts b/open-sse/services/sessionManager.ts index a196265ba3..ebf509b5ab 100644 --- a/open-sse/services/sessionManager.ts +++ b/open-sse/services/sessionManager.ts @@ -173,6 +173,69 @@ export function getActiveSessions(): Array +const activeSessionsByKey = new Map>(); + +/** + * T08: Get the number of currently active sessions for an API key. + * @param apiKeyId - The API key's UUID from the database + */ +export function getActiveSessionCountForKey(apiKeyId: string): number { + return activeSessionsByKey.get(apiKeyId)?.size ?? 0; +} + +/** + * T08: Register a session as belonging to an API key. + * Call this after session creation is allowed (i.e., limit check passed). + */ +export function registerKeySession(apiKeyId: string, sessionId: string): void { + if (!activeSessionsByKey.has(apiKeyId)) { + activeSessionsByKey.set(apiKeyId, new Set()); + } + activeSessionsByKey.get(apiKeyId)!.add(sessionId); +} + +/** + * T08: Unregister a session from an API key's active set. + * Call this when the request closes or the session TTL expires. + */ +export function unregisterKeySession(apiKeyId: string, sessionId: string): void { + activeSessionsByKey.get(apiKeyId)?.delete(sessionId); + // Clean up empty sets to avoid memory leaks + if (activeSessionsByKey.get(apiKeyId)?.size === 0) { + activeSessionsByKey.delete(apiKeyId); + } +} + +/** + * T08: Check whether adding a new session would exceed the key's max_sessions limit. + * Returns null if allowed, or an error object to return as a 429 response. + * + * @param apiKeyId - The API key's UUID + * @param maxSessions - The limit from the DB (0 = unlimited) + */ +export function checkSessionLimit( + apiKeyId: string, + maxSessions: number +): { code: "SESSION_LIMIT_EXCEEDED"; message: string; limit: number; current: number } | null { + if (!maxSessions || maxSessions <= 0) return null; // unlimited + const current = getActiveSessionCountForKey(apiKeyId); + if (current < maxSessions) return null; + return { + code: "SESSION_LIMIT_EXCEEDED", + message: + `You have reached the maximum number of active sessions (${maxSessions}). ` + + `Please close unused sessions or wait for them to expire.`, + limit: maxSessions, + current, + }; } /** diff --git a/package.json b/package.json index 0dc084811d..ca3d40449f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "omniroute", - "version": "3.0.0-rc.6", + "version": "3.0.0-rc.7", "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": { diff --git a/src/lib/db/apiKeys.ts b/src/lib/db/apiKeys.ts index 66fc036030..0b588cbcd7 100644 --- a/src/lib/db/apiKeys.ts +++ b/src/lib/db/apiKeys.ts @@ -40,6 +40,8 @@ interface ApiKeyMetadata { accessSchedule: AccessSchedule | null; maxRequestsPerDay: number | null; maxRequestsPerMinute: number | null; + // T08: Per-key max concurrent sticky sessions (0 = unlimited) + maxSessions: number; } interface ApiKeyRow extends JsonRecord { @@ -197,6 +199,11 @@ function ensureApiKeysColumns(db: ApiKeysDbLike) { db.exec("ALTER TABLE api_keys ADD COLUMN max_requests_per_minute INTEGER"); console.log("[DB] Added api_keys.max_requests_per_minute column"); } + // T08: max concurrent sticky sessions per key (0 = unlimited) + if (!columnNames.has("max_sessions")) { + db.exec("ALTER TABLE api_keys ADD COLUMN max_sessions INTEGER NOT NULL DEFAULT 0"); + console.log("[DB] Added api_keys.max_sessions column"); + } _schemaChecked = true; } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -222,7 +229,7 @@ function getPreparedStatements(db: ApiKeysDbLike): ApiKeysStatements { _stmtGetKeyById = db.prepare("SELECT * FROM api_keys WHERE id = ?"); _stmtValidateKey = db.prepare("SELECT 1 FROM api_keys WHERE key = ?"); _stmtGetKeyMetadata = db.prepare( - "SELECT id, name, machine_id, allowed_models, allowed_connections, no_log, auto_resolve, is_active, access_schedule, max_requests_per_day, max_requests_per_minute FROM api_keys WHERE key = ?" + "SELECT id, name, machine_id, allowed_models, allowed_connections, no_log, auto_resolve, is_active, access_schedule, max_requests_per_day, max_requests_per_minute, max_sessions FROM api_keys WHERE key = ?" ); _stmtInsertKey = db.prepare( "INSERT INTO api_keys (id, name, key, machine_id, allowed_models, no_log, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)" @@ -418,6 +425,8 @@ export async function updateApiKeyPermissions( accessSchedule?: AccessSchedule | null; maxRequestsPerDay?: number | null; maxRequestsPerMinute?: number | null; + // T08: max concurrent sessions for this key (0 = unlimited) + maxSessions?: number | null; } ) { const db = getDbInstance() as ApiKeysDbLike; @@ -436,6 +445,7 @@ export async function updateApiKeyPermissions( accessSchedule: update.accessSchedule, maxRequestsPerDay: update.maxRequestsPerDay, maxRequestsPerMinute: update.maxRequestsPerMinute, + maxSessions: (update as { maxSessions?: number | null }).maxSessions, }; if ( @@ -447,7 +457,8 @@ export async function updateApiKeyPermissions( normalized.isActive === undefined && normalized.accessSchedule === undefined && normalized.maxRequestsPerDay === undefined && - normalized.maxRequestsPerMinute === undefined + normalized.maxRequestsPerMinute === undefined && + (normalized as Record).maxSessions === undefined ) { return false; } @@ -464,6 +475,7 @@ export async function updateApiKeyPermissions( accessSchedule?: string | null; maxRequestsPerDay?: number | null; maxRequestsPerMinute?: number | null; + maxSessions?: number; } = { id }; if (normalized.name !== undefined) { @@ -514,6 +526,12 @@ export async function updateApiKeyPermissions( params.maxRequestsPerMinute = normalized.maxRequestsPerMinute; } + const maxSessionsUpdate = (normalized as Record).maxSessions; + if (maxSessionsUpdate !== undefined) { + updates.push("max_sessions = @maxSessions"); + params.maxSessions = typeof maxSessionsUpdate === "number" ? Math.max(0, maxSessionsUpdate) : 0; + } + const result = db.prepare(`UPDATE api_keys SET ${updates.join(", ")} WHERE id = @id`).run(params); if (result.changes === 0) return false; @@ -605,6 +623,8 @@ export async function getApiKeyMetadata( const rawMaxRPD = record.max_requests_per_day ?? record.maxRequestsPerDay; const rawMaxRPM = record.max_requests_per_minute ?? record.maxRequestsPerMinute; + const rawMaxSessions = record.max_sessions ?? record.maxSessions; + const metadata: ApiKeyMetadata = { id: metadataId, name: metadataName, @@ -619,6 +639,8 @@ export async function getApiKeyMetadata( accessSchedule: parseAccessSchedule(record.access_schedule ?? record.accessSchedule), maxRequestsPerDay: typeof rawMaxRPD === "number" && rawMaxRPD > 0 ? rawMaxRPD : null, maxRequestsPerMinute: typeof rawMaxRPM === "number" && rawMaxRPM > 0 ? rawMaxRPM : null, + // T08: max concurrent sessions; 0 = unlimited (default & backward-compatible) + maxSessions: typeof rawMaxSessions === "number" && rawMaxSessions > 0 ? rawMaxSessions : 0, }; if (!metadata.id) { diff --git a/src/lib/db/providers.ts b/src/lib/db/providers.ts index 4c40be4333..756634d870 100644 --- a/src/lib/db/providers.ts +++ b/src/lib/db/providers.ts @@ -513,3 +513,98 @@ export async function deleteProviderNode(id: string) { backupDbFile("pre-write"); return rowToCamel(existing); } + +// ──────────────── T05: Rate-Limit DB Persistence ────────────────────────── +// Allows rate-limit state to survive token refresh without being accidentally +// cleared. DB column rate_limited_until already exists in schema. +// Ref: sub2api PR #1218 (fix(openai): prevent rescheduling rate-limited accounts) + +/** + * T05: Persist when a connection is rate-limited, directly in DB. + * This survives token refresh — OAuth flows must NOT override this field. + * + * @param connectionId - The provider_connections.id + * @param until - Epoch ms when the rate limit expires (null to clear) + */ +export function setConnectionRateLimitUntil(connectionId: string, until: number | null): void { + const db = getDbInstance() as unknown as DbLike; + db.prepare( + "UPDATE provider_connections SET rate_limited_until = ?, updated_at = ? WHERE id = ?" + ).run(until, new Date().toISOString(), connectionId); + invalidateDbCache("connections"); +} + +/** + * T05: Check if a connection is currently rate-limited (DB-backed). + * Use this before account selection to skip transiently rate-limited accounts. + * + * @returns true if rate_limited_until is set and in the future + */ +export function isConnectionRateLimited(connectionId: string): boolean { + const db = getDbInstance() as unknown as DbLike; + const row = db + .prepare("SELECT rate_limited_until FROM provider_connections WHERE id = ?") + .get(connectionId) as { rate_limited_until?: number | null } | undefined; + if (!row?.rate_limited_until) return false; + return Date.now() < row.rate_limited_until; +} + +/** + * T05: Get all connections for a provider that are currently rate-limited. + * Returns an array of { id, rateLimitedUntil } for dashboard display. + */ +export function getRateLimitedConnections( + provider: string +): Array<{ id: string; rateLimitedUntil: number }> { + const db = getDbInstance() as unknown as DbLike; + const now = Date.now(); + const rows = db + .prepare( + "SELECT id, rate_limited_until FROM provider_connections WHERE provider = ? AND rate_limited_until > ?" + ) + .all(provider, now) as Array<{ id: string; rate_limited_until: number }>; + return rows.map((r) => ({ id: r.id, rateLimitedUntil: r.rate_limited_until })); +} + +// ──────────────── T13: Stale Quota Display Fix ───────────────────────────── +// Codex/Claude quotas display stale cumulative usage after the window resets. +// By comparing resetAt timestamp to now(), we can show 0 when window has passed. +// Ref: sub2api PR #1171 (fix: quota display shows stale cumulative usage after reset) + +/** + * T13: Get effective quota usage, zeroing it out if the window has already reset. + * + * @param used - Stored usage value (tokens used in the window) + * @param resetAt - ISO-8601 string or epoch ms when the window resets, or null + * @returns Effective usage: 0 if window expired, original value otherwise + */ +export function getEffectiveQuotaUsage( + used: number, + resetAt: string | number | null | undefined +): number { + if (!resetAt) return used; + const resetTime = typeof resetAt === "number" ? resetAt : new Date(resetAt).getTime(); + if (isNaN(resetTime)) return used; + // Window has passed — display should show 0 (pending next snapshot) + if (Date.now() >= resetTime) return 0; + return used; +} + +/** + * T13: Format a reset countdown as a human-readable string: "2h 35m" or "4m 30s". + * Returns null if resetAt is in the past or not set. + */ +export function formatResetCountdown(resetAt: string | number | null | undefined): string | null { + if (!resetAt) return null; + const resetTime = typeof resetAt === "number" ? resetAt : new Date(resetAt).getTime(); + if (isNaN(resetTime)) return null; + const diffMs = resetTime - Date.now(); + if (diffMs <= 0) return null; + const totalSeconds = Math.floor(diffMs / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + if (hours > 0) return `${hours}h ${minutes}m`; + if (minutes > 0) return `${minutes}m ${seconds}s`; + return `${seconds}s`; +} diff --git a/src/lib/localDb.ts b/src/lib/localDb.ts index 042b5851f0..6b8bc625c5 100644 --- a/src/lib/localDb.ts +++ b/src/lib/localDb.ts @@ -23,6 +23,15 @@ export { createProviderNode, updateProviderNode, deleteProviderNode, + + // T05: Rate-limit DB persistence (survives token refresh) + setConnectionRateLimitUntil, + isConnectionRateLimited, + getRateLimitedConnections, + + // T13: Stale quota display fix (zero out usage after window resets) + getEffectiveQuotaUsage, + formatResetCountdown, } from "./db/providers"; export { diff --git a/src/lib/proxyHealth.ts b/src/lib/proxyHealth.ts new file mode 100644 index 0000000000..0430c4c76f --- /dev/null +++ b/src/lib/proxyHealth.ts @@ -0,0 +1,140 @@ +/** + * T14: Proxy Fast-Fail — TCP health check with in-memory cache. + * + * When a configured HTTP/SOCKS5 proxy is unreachable, every request + * through OmniRoute used to wait for the full PROXY_TIMEOUT_MS (30s) + * before failing. This module detects dead proxies in <2s via a quick + * TCP connection check, caching the result to avoid overhead per request. + * + * Ref: sub2api PR #1167 (fix: proxy-fast-fail) + */ + +import { createConnection } from "node:net"; + +// Configurable via env vars +const FAST_FAIL_TIMEOUT_MS = parseInt(process.env.PROXY_FAST_FAIL_TIMEOUT_MS ?? "2000", 10); +const HEALTH_CACHE_TTL_MS = parseInt(process.env.PROXY_HEALTH_CACHE_TTL_MS ?? "30000", 10); + +interface ProxyHealthEntry { + healthy: boolean; + checkedAt: number; + ttlMs: number; +} + +// In-memory cache: proxyUrl → health entry +const proxyHealthCache = new Map(); + +/** + * T14: Perform a fast TCP check to see if a proxy host:port is reachable. + * Results are cached for `cacheTtlMs` (default 30s) to avoid checking every request. + * + * @param proxyUrl - Full proxy URL, e.g. http://user:pass@1.2.3.4:8080 + * @param timeoutMs - TCP connection timeout (default 2000ms) + * @param cacheTtlMs - How long to cache the health result (default 30000ms) + * @returns true if proxy TCP port is open, false otherwise + */ +export async function isProxyReachable( + proxyUrl: string, + timeoutMs = FAST_FAIL_TIMEOUT_MS, + cacheTtlMs = HEALTH_CACHE_TTL_MS +): Promise { + const cached = proxyHealthCache.get(proxyUrl); + if (cached && Date.now() - cached.checkedAt < cached.ttlMs) { + return cached.healthy; + } + + let url: URL; + try { + url = new URL(proxyUrl); + } catch { + // Malformed URL — treat as unreachable + proxyHealthCache.set(proxyUrl, { + healthy: false, + checkedAt: Date.now(), + ttlMs: cacheTtlMs, + }); + return false; + } + + const host = url.hostname; + const port = parseInt(url.port || defaultPortForScheme(url.protocol), 10); + + if (!host || isNaN(port)) { + proxyHealthCache.set(proxyUrl, { + healthy: false, + checkedAt: Date.now(), + ttlMs: cacheTtlMs, + }); + return false; + } + + const healthy = await tcpCheck(host, port, timeoutMs); + proxyHealthCache.set(proxyUrl, { healthy, checkedAt: Date.now(), ttlMs: cacheTtlMs }); + return healthy; +} + +/** + * Get the cached health status of a proxy without re-checking. + * Returns null if there is no cached entry. + */ +export function getCachedProxyHealth(proxyUrl: string): boolean | null { + const cached = proxyHealthCache.get(proxyUrl); + if (!cached) return null; + if (Date.now() - cached.checkedAt >= cached.ttlMs) return null; // stale + return cached.healthy; +} + +/** + * Invalidate the cached health for a proxy URL (force re-check on next call). + */ +export function invalidateProxyHealth(proxyUrl: string): void { + proxyHealthCache.delete(proxyUrl); +} + +/** + * Get all currently cached proxy health entries (for dashboard display). + */ +export function getAllProxyHealthStatuses(): Array<{ + proxyUrl: string; + healthy: boolean; + checkedAt: number; + stale: boolean; +}> { + const now = Date.now(); + return [...proxyHealthCache.entries()].map(([proxyUrl, entry]) => ({ + proxyUrl, + healthy: entry.healthy, + checkedAt: entry.checkedAt, + stale: now - entry.checkedAt >= entry.ttlMs, + })); +} + +// ─── Internals ──────────────────────────────────────────────────────────────── + +function defaultPortForScheme(protocol: string): string { + switch (protocol.replace(":", "").toLowerCase()) { + case "https": + return "443"; + case "socks5": + case "socks5h": + return "1080"; + case "http": + default: + return "8080"; + } +} + +function tcpCheck(host: string, port: number, timeoutMs: number): Promise { + return new Promise((resolve) => { + const socket = createConnection({ host, port }, () => { + socket.destroy(); + resolve(true); + }); + socket.setTimeout(timeoutMs); + socket.on("error", () => resolve(false)); + socket.on("timeout", () => { + socket.destroy(); + resolve(false); + }); + }); +}