Spaces:
Running
Running
| 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<ParsedTransaction> { | |
| 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; | |
| } | |
| } | |
| } | |