import type { AIProvider } from '../services/aiService.js'; import type { ParsedTransaction } from '@icc/shared'; import pino from 'pino'; const logger = pino({ level: 'info' }); interface ProviderSlot { provider: AIProvider; cooldownUntil: number; // timestamp ms dailyRequests: number; dailyLimit: number; disabled: boolean; } function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); } export class AIProviderPool { private slots: ProviderSlot[]; private currentIndex = 0; constructor(providers: { provider: AIProvider; dailyLimit: number }[]) { this.slots = providers.map(({ provider, dailyLimit }) => ({ provider, cooldownUntil: 0, dailyRequests: 0, dailyLimit, disabled: false, })); } getCurrentSlotId(): string { return this.slots[this.currentIndex]?.provider.name ?? 'none'; } getStatus() { return this.slots.map((slot) => ({ name: slot.provider.name, available: !slot.disabled && Date.now() >= slot.cooldownUntil && slot.dailyRequests < slot.dailyLimit, dailyRequests: slot.dailyRequests, dailyLimit: slot.dailyLimit, cooldownRemaining: Math.max(0, slot.cooldownUntil - Date.now()), })); } private findAvailableSlot(): ProviderSlot | null { const now = Date.now(); // Always start from slot 0 (primary provider) to enforce priority ordering for (let i = 0; i < this.slots.length; i++) { const slot = this.slots[i]; if (!slot.disabled && now >= slot.cooldownUntil && slot.dailyRequests < slot.dailyLimit) { this.currentIndex = i; return slot; } } return null; } async parse(emailBody: string): Promise { const maxRetries = this.slots.length * 3; for (let attempt = 0; attempt < maxRetries; attempt++) { const slot = this.findAvailableSlot(); if (!slot) { // All slots exhausted — find the shortest cooldown and wait const now = Date.now(); const soonest = this.slots .filter((s) => !s.disabled && s.dailyRequests < s.dailyLimit) .reduce((min, s) => Math.min(min, s.cooldownUntil), Infinity); if (soonest === Infinity) { throw new Error('All AI providers exhausted for the day'); } const waitMs = Math.max(soonest - now, 1000); logger.warn(`All providers rate-limited. Waiting ${Math.round(waitMs / 1000)}s...`); await sleep(waitMs); continue; } try { logger.info(`Parsing with ${slot.provider.name} (attempt ${attempt + 1}/${maxRetries})`); const result = await slot.provider.parse(emailBody); slot.dailyRequests++; return result; } catch (error: any) { if (error.status === 429 || error.status === 413) { // Rate limited or request too large — set cooldown and try next const retryAfter = parseInt(error.headers?.['retry-after'] || '30'); slot.cooldownUntil = Date.now() + retryAfter * 1000; logger.info( `${error.status === 413 ? 'Request too large' : 'Rate limited'} on ${slot.provider.name}, cooldown ${retryAfter}s. Switching...` ); this.currentIndex = (this.currentIndex + 1) % this.slots.length; continue; } if (error.status === 400) { // 400 errors: invalid model, json_validate_failed, bad request const bodyStr = error.body ?? error.message ?? ''; const isInvalidModel = bodyStr.includes('invalid_model') || bodyStr.includes('Invalid model'); if (isInvalidModel) { slot.disabled = true; logger.warn(`Invalid model on ${slot.provider.name}, permanently disabled`); } else { // Cooldown to prevent retry loop (findAvailableSlot starts from 0) slot.cooldownUntil = Date.now() + 30_000; logger.warn(`400 error on ${slot.provider.name}, 30s cooldown. Trying next...`); } this.currentIndex = (this.currentIndex + 1) % this.slots.length; continue; } if (error.status === 403) { // 403: model blocked at org level or forbidden — permanently disable slot.disabled = true; logger.warn(`Forbidden (403) on ${slot.provider.name}, permanently disabled. Trying next...`); this.currentIndex = (this.currentIndex + 1) % this.slots.length; continue; } if (error.status === 404) { // 404: model does not exist on this provider — permanently disable the slot slot.disabled = true; logger.warn(`Model not found (404) on ${slot.provider.name}, permanently disabled. Trying next...`); this.currentIndex = (this.currentIndex + 1) % this.slots.length; continue; } if (error.status >= 500) { // Server error — 1 min cooldown slot.cooldownUntil = Date.now() + 60_000; logger.warn(`Server error on ${slot.provider.name}, 60s cooldown`); this.currentIndex = (this.currentIndex + 1) % this.slots.length; continue; } // Any other error (JSON parse, network, etc.) — cooldown and try next slot.cooldownUntil = Date.now() + 10_000; logger.warn(`Unexpected error on ${slot.provider.name}: ${error.message || error.status}. Trying next...`); this.currentIndex = (this.currentIndex + 1) % this.slots.length; continue; } } throw new Error('Max retries exceeded across all providers'); } /** Reset daily counters (call at midnight UTC) */ resetDaily() { for (const slot of this.slots) { slot.dailyRequests = 0; slot.disabled = false; slot.cooldownUntil = 0; } } }