diff --git a/CHANGELOG.md b/CHANGELOG.md index 50f833a32c..bacf7d271f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,18 @@ ## [Unreleased] -## [2.4.3] - 2026-03-14 +## [2.4.4] - 2026-03-14 + +> API Key Round-Robin support for multi-key provider setups, and confirmation of wildcard routing and quota window rolling already in place. + +### ✨ New Features + +- **API Key Round-Robin (T07)**: Provider connections can now hold multiple API keys (Edit Connection → Extra API Keys). Requests rotate round-robin between primary + extra keys via `providerSpecificData.extraApiKeys[]`. Keys are held in-memory indexed per connection — no DB schema changes required. + +### 📝 Already Implemented (confirmed in audit) + +- **Wildcard Model Routing (T13)**: `wildcardRouter.ts` with glob-style wildcard matching (`gpt*`, `claude-?-sonnet`, etc.) is already integrated into `model.ts` with specificity ranking. +- **Quota Window Rolling (T08)**: `accountFallback.ts:isModelLocked()` already auto-advances the window — if `Date.now() > entry.until`, lock is deleted immediately (no stale blocking). > UI polish, routing strategy additions, and graceful error handling for usage limits. diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 600cf0cbfa..6e52dab7bb 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: OmniRoute API - version: 2.4.3 + version: 2.4.4 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/base.ts b/open-sse/executors/base.ts index f607a17df1..65f30872c5 100644 --- a/open-sse/executors/base.ts +++ b/open-sse/executors/base.ts @@ -1,5 +1,6 @@ import { HTTP_STATUS, FETCH_TIMEOUT_MS } from "../config/constants.ts"; import { applyFingerprint, isCliCompatEnabled } from "../config/cliFingerprints.ts"; +import { getRotatingApiKey } from "../services/apiKeyRotator.ts"; type JsonRecord = Record; @@ -23,6 +24,7 @@ export type ProviderCredentials = { refreshToken?: string; apiKey?: string; expiresAt?: string; + connectionId?: string; // T07: used for API key rotation index providerSpecificData?: JsonRecord; }; @@ -131,7 +133,14 @@ export class BaseExecutor { if (credentials.accessToken) { headers["Authorization"] = `Bearer ${credentials.accessToken}`; } else if (credentials.apiKey) { - headers["Authorization"] = `Bearer ${credentials.apiKey}`; + // T07: rotate between primary + extra API keys when extraApiKeys is configured + const extraKeys = + (credentials.providerSpecificData?.extraApiKeys as string[] | undefined) ?? []; + const effectiveKey = + extraKeys.length > 0 && credentials.connectionId + ? getRotatingApiKey(credentials.connectionId, credentials.apiKey, extraKeys) + : credentials.apiKey; + headers["Authorization"] = `Bearer ${effectiveKey}`; } if (stream) { diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 74df50683a..afcc2bbc0e 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -94,6 +94,12 @@ export async function handleChatCore({ // Initialize rate limit settings from persisted DB (once, lazy) await initializeRateLimits(); + // T07: Inject connectionId into credentials so executors can rotate API keys + // using providerSpecificData.extraApiKeys (API Key Round-Robin feature) + if (connectionId && credentials && !credentials.connectionId) { + credentials.connectionId = connectionId; + } + const sourceFormat = detectFormat(body); const endpointPath = (clientRawRequest?.endpoint || "").toLowerCase(); const isResponsesEndpoint = endpointPath.endsWith("/responses"); diff --git a/open-sse/services/apiKeyRotator.ts b/open-sse/services/apiKeyRotator.ts new file mode 100644 index 0000000000..93ae5ac06e --- /dev/null +++ b/open-sse/services/apiKeyRotator.ts @@ -0,0 +1,63 @@ +/** + * apiKeyRotator.ts — T07: API Key Round-Robin + * + * Rotates between a primary API key and extra API keys stored in + * providerSpecificData.extraApiKeys[]. Uses round-robin by default. + * + * Extra keys are stored as plain strings in providerSpecificData.extraApiKeys. + * Example: { extraApiKeys: ["sk-abc...", "sk-def...", "sk-ghi..."] } + * + * The in-memory rotation index resets on process restart, which is intentional — + * it ensures even distribution across restarts without persistence overhead. + */ + +// In-memory round-robin index per connection +const _keyIndexes = new Map(); + +/** + * Get the next API key in round-robin rotation for a given connection. + * If no extra keys are configured, returns the primary key unchanged. + * + * @param connectionId - Unique connection identifier (for index isolation) + * @param primaryKey - The main api_key from the connection + * @param extraKeys - Additional API keys from providerSpecificData.extraApiKeys + * @returns The selected API key (may be primary or one of the extras) + */ +export function getRotatingApiKey( + connectionId: string, + primaryKey: string, + extraKeys: string[] = [] +): string { + const validExtras = extraKeys.filter((k) => typeof k === "string" && k.trim().length > 0); + + // Only 1 key available → no rotation needed + if (validExtras.length === 0) return primaryKey; + + const allKeys = [primaryKey, ...validExtras].filter(Boolean); + if (allKeys.length <= 1) return primaryKey; + + const current = _keyIndexes.get(connectionId) ?? 0; + const idx = current % allKeys.length; + _keyIndexes.set(connectionId, current + 1); + + return allKeys[idx]; +} + +/** + * Reset the rotation index for a connection. + * Call this when a key fails (401/403) to skip the bad key next time. + * + * @param connectionId - Connection to reset + */ +export function resetRotationIndex(connectionId: string): void { + _keyIndexes.delete(connectionId); +} + +/** + * Get the total number of API keys available for a connection. + * Used for logging/observability. + */ +export function getApiKeyCount(primaryKey: string, extraKeys: string[] = []): number { + const validExtras = extraKeys.filter((k) => typeof k === "string" && k.trim().length > 0); + return (primaryKey ? 1 : 0) + validExtras.length; +} diff --git a/package-lock.json b/package-lock.json index f5d9a1e670..af230a45b5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "omniroute", - "version": "2.4.2", + "version": "2.4.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "omniroute", - "version": "2.4.2", + "version": "2.4.3", "hasInstallScript": true, "license": "MIT", "workspaces": [ diff --git a/package.json b/package.json index 4d4809e99d..c0019995c5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "omniroute", - "version": "2.4.3", + "version": "2.4.4", "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/app/(dashboard)/dashboard/providers/[id]/page.tsx b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx index c44f8081bf..a96cf62888 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx @@ -2649,6 +2649,8 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }) { const [validating, setValidating] = useState(false); const [validationResult, setValidationResult] = useState(null); const [saving, setSaving] = useState(false); + const [extraApiKeys, setExtraApiKeys] = useState([]); + const [newExtraKey, setNewExtraKey] = useState(""); useEffect(() => { if (connection) { @@ -2658,6 +2660,10 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }) { apiKey: "", healthCheckInterval: connection.healthCheckInterval ?? 60, }); + // Load existing extra keys from providerSpecificData + const existing = connection.providerSpecificData?.extraApiKeys; + setExtraApiKeys(Array.isArray(existing) ? existing : []); + setNewExtraKey(""); setTestResult(null); setValidationResult(null); } @@ -2744,6 +2750,13 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }) { updates.rateLimitedUntil = null; } } + // Persist extra API keys in providerSpecificData + if (!isOAuth) { + updates.providerSpecificData = { + ...(connection.providerSpecificData || {}), + extraApiKeys: extraApiKeys.filter((k) => k.trim().length > 0), + }; + } await onSave(updates); } finally { setSaving(false); @@ -2828,6 +2841,68 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }) { )} + {/* T07: Extra API Keys for round-robin rotation */} + {!isOAuth && ( +
+ + {extraApiKeys.length > 0 && ( +
+ {extraApiKeys.map((key, idx) => ( +
+ + {`Key #${idx + 2}: ${key.slice(0, 6)}...${key.slice(-4)}`} + + +
+ ))} +
+ )} +
+ setNewExtraKey(e.target.value)} + placeholder="Add another API key..." + className="flex-1 text-sm bg-sidebar/50 border border-border rounded px-3 py-2 text-text-main placeholder:text-text-muted focus:ring-1 focus:ring-primary outline-none" + onKeyDown={(e) => { + if (e.key === "Enter" && newExtraKey.trim()) { + setExtraApiKeys([...extraApiKeys, newExtraKey.trim()]); + setNewExtraKey(""); + } + }} + /> + +
+ {extraApiKeys.length > 0 && ( +

+ {extraApiKeys.length + 1} keys total — rotating round-robin on each request. +

+ )} +
+ )} + {/* Test Connection */} {!isCompatible && (