// Node 20 includes native fetch support // AI Client for Groq with Failover Support + Circuit Breaker // Fallback shims for Probot runtime /* eslint-disable no-console */ import { ChatGPTAPI as AIClient, ChatGPTError as AIError, ChatMessage, SendMessageOptions } from 'chatgpt' import pRetry from 'p-retry' import CircuitBreaker from 'opossum' import {AIOptions, Options} from './options' import {TokenLimits} from './limits' import pino from 'pino' const logger = pino({ level: process.env.LOG_LEVEL || 'info' }) // Circuit breaker configuration const CIRCUIT_BREAKER_OPTIONS = { timeout: 60000, // 60 seconds errorThresholdPercentage: 50, resetTimeout: 30000, volumeThreshold: 5, } const logInfo = (msg: string) => logger.info(msg) const logWarning = (msg: string) => logger.warn(msg) const logError = (msg: string) => logger.error(msg) const setFailed = (msg: string) => { logger.error(msg) process.exit(1) } export interface Ids { parentMessageId?: string conversationId?: string } export class Bot { private api: AIClient | null = null private readonly options: Options private readonly aiOptions: AIOptions private currentModel: string private fallbackModels: string[] private circuitBreaker: CircuitBreaker | null = null constructor(options: Options, aiOptions: AIOptions) { this.options = options this.aiOptions = aiOptions this.currentModel = aiOptions.model // Setup fallback chain based on tier if (Options.HEAVY_MODELS.includes(this.currentModel)) { this.fallbackModels = [...Options.HEAVY_MODELS] } else { this.fallbackModels = [...Options.LIGHT_MODELS] } // Remove current model from fallbacks to avoid immediate repeat this.fallbackModels = this.fallbackModels.filter( m => m !== this.currentModel ) this.initClient() this.initCircuitBreaker() } private initCircuitBreaker(): void { if (!this.api) return // Wrap the sendMessage method with circuit breaker this.circuitBreaker = new CircuitBreaker( async ({ message, opts }: { message: string; opts: SendMessageOptions }) => { return this.api!.sendMessage(message, opts) }, CIRCUIT_BREAKER_OPTIONS ) // Circuit breaker event handlers this.circuitBreaker.on('open', () => { logWarning(`Circuit breaker opened for model: ${this.currentModel}`) }) this.circuitBreaker.on('halfOpen', () => { logInfo(`Circuit breaker half-open for model: ${this.currentModel}`) }) this.circuitBreaker.on('close', () => { logInfo(`Circuit breaker closed for model: ${this.currentModel}`) }) this.circuitBreaker.on('fallback', (_result: unknown) => { logWarning(`Circuit breaker fallback executed for model: ${this.currentModel}`) }) } private initClient(): void { const apiKey = process.env.GROQ_API_KEY || process.env.AI_API_KEY if (apiKey) { const currentDate = new Date().toISOString().split('T')[0] const limits = new TokenLimits(this.currentModel) const systemMessage = `${this.options.systemMessage} Knowledge cutoff: ${limits.knowledgeCutOff} Current date: ${currentDate} IMPORTANT: Entire response must be in ISO code: ${this.options.language} ` this.api = new AIClient({ apiBaseUrl: this.options.apiBaseUrl, systemMessage, apiKey, apiOrg: process.env.AI_API_ORG || undefined, debug: this.options.debug, maxModelTokens: limits.maxTokens, maxResponseTokens: limits.responseTokens, completionParams: { temperature: this.options.modelTemperature, model: this.currentModel } }) logInfo(`Initialized AI client with model: ${this.currentModel}`) } else { throw new Error("Missing 'GROQ_API_KEY' or 'AI_API_KEY'") } } chat = async (message: string, ids: Ids): Promise<[string, Ids]> => { let res: [string, Ids] = ['', {}] try { res = await this.chat_(message, ids) return res } catch (e: unknown) { if (e instanceof AIError) { logWarning(`AI communication error: ${e.message}`) } return res } } private readonly chat_ = async ( message: string, ids: Ids ): Promise<[string, Ids]> => { const start = Date.now() if (!message) return ['', {}] let response: ChatMessage | undefined if (this.api != null) { const opts: SendMessageOptions = { timeoutMs: this.options.timeoutMS } if (ids.parentMessageId) { opts.parentMessageId = ids.parentMessageId } try { // Check if circuit breaker is open if (this.circuitBreaker?.opened) { logWarning(`Circuit breaker is OPEN for ${this.currentModel}, attempting fallback...`) if (this.fallbackModels.length > 0) { const nextModel = this.fallbackModels.shift() if (nextModel) { this.currentModel = nextModel this.initClient() this.initCircuitBreaker() // Recursive retry with new model return this.chat_(message, ids) } } throw new Error(`Circuit breaker open and no fallback models available for ${this.currentModel}`) } const result = await pRetry( async () => { if (!this.circuitBreaker) { throw new Error('Circuit breaker not initialized') } try { const messageResult = await this.circuitBreaker.fire({ message, opts }) return messageResult as ChatMessage } catch (e: any) { // Check if circuit breaker opened if (this.circuitBreaker.opened && this.fallbackModels.length > 0) { const nextModel = this.fallbackModels.shift() if (nextModel) { logWarning( `Circuit breaker opened for ${this.currentModel}. Switching to fallback: ${nextModel}` ) this.currentModel = nextModel this.initClient() this.initCircuitBreaker() // Throw to trigger retry with new model throw new Error(`Model switched to ${nextModel}, retrying...`) } } // Handle rate limiting / context size if ((e?.status === 429 || e?.status === 413) && this.fallbackModels.length > 0) { const nextModel = this.fallbackModels.shift() if (nextModel) { logWarning( `Rate limit or context size error (${e.status}) hit for ${this.currentModel}. Switching to fallback: ${nextModel}` ) this.currentModel = nextModel this.initClient() this.initCircuitBreaker() throw new Error(`Model switched to ${nextModel}, retrying...`) } } throw e } }, { retries: this.options.retries, minTimeout: 20000, // 20s delay to allow TPM refresh onFailedAttempt: (err: any) => { logInfo( `Attempt ${err.attemptNumber} failed. ${err.retriesLeft} retries left. Error: ${err.message}` ) } } ) response = result } catch (e: unknown) { if (e instanceof AIError) { logWarning( `AI Error after retries: ${e.message} (Last model used: ${this.currentModel})` ) } else if (e instanceof Error) { logError(`Failed to get AI response: ${e.message}`) } } const end = Date.now() logInfo(`AI response time: ${end - start} ms`) } else { setFailed('AI client is not initialized') } let responseText = '' if (response != null) { responseText = response.text } const newIds: Ids = { parentMessageId: response?.id, conversationId: response?.conversationId } return [responseText, newIds] } }