Spaces:
Paused
Paused
| // 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<ChatMessage>( | |
| 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] | |
| } | |
| } | |