| import { getDb } from '../db/index.js'; |
| import { getProvider } from '../providers/index.js'; |
| import { decrypt } from '../lib/crypto.js'; |
| import { canMakeRequest, canUseTokens, isOnCooldown } from './ratelimit.js'; |
| import type { BaseProvider } from '../providers/base.js'; |
|
|
| interface ModelRow { |
| id: number; |
| platform: string; |
| model_id: string; |
| display_name: string; |
| rpm_limit: number | null; |
| rpd_limit: number | null; |
| tpm_limit: number | null; |
| tpd_limit: number | null; |
| } |
|
|
| interface KeyRow { |
| id: number; |
| platform: string; |
| encrypted_key: string; |
| iv: string; |
| auth_tag: string; |
| status: string; |
| enabled: number; |
| } |
|
|
| interface FallbackRow { |
| model_db_id: number; |
| priority: number; |
| enabled: number; |
| } |
|
|
| export interface RouteResult { |
| provider: BaseProvider; |
| modelId: string; |
| modelDbId: number; |
| apiKey: string; |
| keyId: number; |
| platform: string; |
| displayName: string; |
| } |
|
|
| |
| const roundRobinIndex = new Map<string, number>(); |
|
|
| |
| |
| const rateLimitPenalties = new Map<number, { count: number; lastHit: number; penalty: number }>(); |
|
|
| |
| const PENALTY_PER_429 = 3; |
| const MAX_PENALTY = 10; |
| const DECAY_INTERVAL_MS = 2 * 60 * 1000; |
| const DECAY_AMOUNT = 1; |
|
|
| |
| |
| |
| export function recordRateLimitHit(modelDbId: number) { |
| const existing = rateLimitPenalties.get(modelDbId); |
| const now = Date.now(); |
| if (existing) { |
| existing.count++; |
| existing.lastHit = now; |
| existing.penalty = Math.min(existing.penalty + PENALTY_PER_429, MAX_PENALTY); |
| } else { |
| rateLimitPenalties.set(modelDbId, { count: 1, lastHit: now, penalty: PENALTY_PER_429 }); |
| } |
| } |
|
|
| |
| |
| |
| export function recordSuccess(modelDbId: number) { |
| const existing = rateLimitPenalties.get(modelDbId); |
| if (existing) { |
| existing.penalty = Math.max(0, existing.penalty - 1); |
| if (existing.penalty === 0) { |
| rateLimitPenalties.delete(modelDbId); |
| } |
| } |
| } |
|
|
| |
| |
| |
| function getPenalty(modelDbId: number): number { |
| const entry = rateLimitPenalties.get(modelDbId); |
| if (!entry) return 0; |
|
|
| |
| const now = Date.now(); |
| const elapsed = now - entry.lastHit; |
| const decaySteps = Math.floor(elapsed / DECAY_INTERVAL_MS); |
| if (decaySteps > 0) { |
| entry.penalty = Math.max(0, entry.penalty - (decaySteps * DECAY_AMOUNT)); |
| entry.lastHit = now; |
| if (entry.penalty === 0) { |
| rateLimitPenalties.delete(modelDbId); |
| return 0; |
| } |
| } |
|
|
| return entry.penalty; |
| } |
|
|
| |
| |
| |
| export function getAllPenalties(): Array<{ modelDbId: number; count: number; penalty: number }> { |
| const result: Array<{ modelDbId: number; count: number; penalty: number }> = []; |
| for (const [modelDbId, entry] of rateLimitPenalties) { |
| const penalty = getPenalty(modelDbId); |
| if (penalty > 0) { |
| result.push({ modelDbId, count: entry.count, penalty }); |
| } |
| } |
| return result.sort((a, b) => b.penalty - a.penalty); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export function routeRequest(estimatedTokens = 1000, skipKeys?: Set<string>, preferredModelDbId?: number): RouteResult { |
| const db = getDb(); |
|
|
| |
| const fallbackChain = db.prepare(` |
| SELECT fc.model_db_id, fc.priority, fc.enabled |
| FROM fallback_config fc |
| ORDER BY fc.priority ASC |
| `).all() as FallbackRow[]; |
|
|
| |
| const sortedChain = fallbackChain.map(entry => ({ |
| ...entry, |
| effectivePriority: entry.priority + getPenalty(entry.model_db_id), |
| })).sort((a, b) => a.effectivePriority - b.effectivePriority); |
|
|
| |
| if (preferredModelDbId) { |
| const idx = sortedChain.findIndex(e => e.model_db_id === preferredModelDbId); |
| if (idx > 0) { |
| const [preferred] = sortedChain.splice(idx, 1); |
| sortedChain.unshift(preferred); |
| } |
| } |
|
|
| for (const entry of sortedChain) { |
| if (!entry.enabled) continue; |
|
|
| |
| const model = db.prepare('SELECT * FROM models WHERE id = ? AND enabled = 1').get(entry.model_db_id) as ModelRow | undefined; |
| if (!model) continue; |
|
|
| |
| const provider = getProvider(model.platform as any); |
| if (!provider) continue; |
|
|
| |
| const keys = db.prepare( |
| "SELECT * FROM api_keys WHERE platform = ? AND enabled = 1 AND status IN ('healthy', 'unknown')" |
| ).all(model.platform) as KeyRow[]; |
|
|
| if (keys.length === 0) continue; |
|
|
| |
| const limits = { |
| rpm: model.rpm_limit, |
| rpd: model.rpd_limit, |
| tpm: model.tpm_limit, |
| tpd: model.tpd_limit, |
| }; |
|
|
| |
| const rrKey = `${model.platform}:${model.model_id}`; |
| let idx = roundRobinIndex.get(rrKey) ?? 0; |
|
|
| for (let attempt = 0; attempt < keys.length; attempt++) { |
| const key = keys[idx % keys.length]; |
| idx++; |
|
|
| const skipId = `${model.platform}:${model.model_id}:${key.id}`; |
| if (skipKeys?.has(skipId)) continue; |
|
|
| |
| if (isOnCooldown(model.platform, model.model_id, key.id)) continue; |
|
|
| if (!canMakeRequest(model.platform, model.model_id, key.id, limits)) continue; |
| if (!canUseTokens(model.platform, model.model_id, key.id, estimatedTokens, limits)) continue; |
|
|
| let decryptedKey: string; |
| try { |
| decryptedKey = decrypt(key.encrypted_key, key.iv, key.auth_tag); |
| } catch { |
| db.prepare("UPDATE api_keys SET status = 'error', last_checked_at = datetime('now') WHERE id = ?") |
| .run(key.id); |
| continue; |
| } |
|
|
| |
| roundRobinIndex.set(rrKey, idx); |
| return { |
| provider, |
| modelId: model.model_id, |
| modelDbId: model.id, |
| apiKey: decryptedKey, |
| keyId: key.id, |
| platform: model.platform, |
| displayName: model.display_name, |
| }; |
| } |
|
|
| |
| |
| roundRobinIndex.set(rrKey, idx); |
| |
| |
| |
| |
| } |
|
|
| const err = new Error('All models exhausted. Add more API keys or wait for rate limits to reset.') as any; |
| err.status = 429; |
| throw err; |
| } |
|
|