// Node 20 includes native fetch support // AI Client with Failover Support // Fallback shims for Probot runtime const info = console.log const warning = console.warn const error = console.error const debug: (...args: any[]) => void = process.env.LOG_LEVEL === 'debug' ? console.log.bind(console) : () => {} const setFailed = (msg: string) => { // Throw instead of process.exit to avoid killing the Probot server throw new Error(msg) } import { ChatGPTAPI as AIClient, ChatGPTError as AIError, ChatMessage, SendMessageOptions } from 'chatgpt' import pRetry from 'p-retry' import {AIOptions, Options} from './options' import {TokenLimits} from './limits' import {getTokenCount} from './tokenizer' import {isGeneratedFile, logIgnoredFile} from './generated-filter' import {sleep} from './utils' import {Mutex} from 'async-mutex' export interface Ids { parentMessageId?: string conversationId?: string } class TokenThrottler { private static usage: {timestamp: number; tokens: number}[] = [] private static readonly WINDOW_MS = 60000 private static readonly mutex = new Mutex() // Increased TPM limit to prevent self-throttling // Previous limit of 30,000 was too conservative for actual usage private static get LIMIT(): number { return 1000000 // 1M tokens per minute - much more realistic } static async throttle(estimatedTokens: number): Promise { // CRITICAL: Wrap all state mutation in mutex to prevent race conditions await this.mutex.runExclusive(async () => { const now = Date.now() this.usage = this.usage.filter(u => now - u.timestamp < this.WINDOW_MS) const currentTokens = this.usage.reduce((sum, u) => sum + u.tokens, 0) const limit = this.LIMIT if (currentTokens + estimatedTokens > limit) { // Calculate wait time based on oldest entry const oldestTimestamp = this.usage.length > 0 ? this.usage[0].timestamp : now const waitTime = this.WINDOW_MS - (now - oldestTimestamp) + 1000 info( `TPM Limit approaching (${currentTokens}/${ limit } tokens used). Throttling for ${Math.round(waitTime / 1000)}s...` ) // Release mutex while sleeping, then re-acquire this.mutex.release() await sleep(Math.max(waitTime, 1000)) // Recursive call to re-check limits after sleep // No need to re-acquire as recursive call will acquire its own lock await this.throttle(estimatedTokens) return } this.usage.push({timestamp: Date.now(), tokens: estimatedTokens}) }) } } export class AllModelsFailedError extends Error { constructor() { super('All AI models failed after exhausting fallback chain') this.name = 'AllModelsFailedError' } } export class Bot { private api: AIClient | null = null private readonly options: Options private readonly aiOptions: AIOptions private currentModel: string private fallbackModels: string[] constructor( options: Options, aiOptions: AIOptions, fallbackModels?: string[] ) { this.options = options this.aiOptions = aiOptions this.currentModel = aiOptions.model // Setup fallback chain based on tier if (fallbackModels) { this.fallbackModels = fallbackModels.filter(m => m !== this.currentModel) } else if (Options.FREE_TIER_MODELS.includes(this.currentModel)) { this.fallbackModels = Options.FREE_TIER_MODELS.filter( m => m !== this.currentModel ) } else if (Options.HEAVY_MODELS.includes(this.currentModel)) { this.fallbackModels = Options.HEAVY_MODELS.filter( m => m !== this.currentModel ) } else { this.fallbackModels = Options.LIGHT_MODELS.filter( m => m !== this.currentModel ) } this.initClient() } getCurrentModel(): string { return this.currentModel } private initClient(): void { const apiKey = process.env.OPENROUTER_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} Cutoff: ${limits.knowledgeCutOff} Date: ${currentDate} Language: ${this.options.language}` const completionParams: Record = { temperature: this.options.modelTemperature, model: this.currentModel } // Route paid OSS 120b through Deallm (primary) → DeepInfra (fallback) if (this.currentModel === 'openai/gpt-oss-120b') { completionParams.provider = {order: ['deallm', 'deepinfra']} } // Route free OSS 120b through DeepInfra only if (this.currentModel === 'openai/gpt-oss-120b:free') { completionParams.provider = {order: ['deepinfra']} } // Route qwen3 235b through DeepInfra (primary) with WandB as fallback. // If BOTH providers fail, OpenRouter returns an error → we switch to next model (oss 120b) if (this.currentModel === 'qwen/qwen3-235b-a22b-2507') { completionParams.provider = {order: ['deepinfra', 'wandb']} } 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: Math.min(1800, limits.responseTokens), completionParams }) info(`Initialized AI client with model: ${this.currentModel}`) } else { throw new Error("Missing 'OPENROUTER_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) { warning(`AI communication error: ${e.message}`) } if (e instanceof AllModelsFailedError) { throw e } 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 { // Bug #2 Fix: use known limits instead of accessing private library field const limits = new TokenLimits(this.currentModel) const estimatedTokens = getTokenCount(message) + limits.responseTokens await TokenThrottler.throttle(estimatedTokens) // Log payload size before request to detect hidden context const messageSize = message.length debug('MESSAGE_SIZE_CHARS', messageSize) debug('MESSAGE_SIZE_KB', messageSize / 1024) debug('HAS_PARENT_MESSAGE_ID', !!opts.parentMessageId) debug('HAS_CONVERSATION_ID', !!ids.conversationId) debug('MESSAGE_PREVIEW', message.substring(0, 500)) // CRITICAL: Detect if message contains generated file patterns (safety check) const generatedPatterns = [ 'node_modules/', 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml', '.next/', 'dist/', 'build/', '.git/' ] const hasGeneratedContent = generatedPatterns.some(pattern => message.includes(pattern) ) if (hasGeneratedContent) { console.warn( 'WARNING: Message may contain generated file content - upstream filtering should have removed this' ) for (const pattern of generatedPatterns) { if (message.includes(pattern)) { console.warn(` Found pattern: ${pattern}`) } } } // HARD PAYLOAD LIMIT - fail early before sending giant requests const MAX_PAYLOAD_CHARS = 120000 if (messageSize > MAX_PAYLOAD_CHARS) { error( `PAYLOAD_TOO_LARGE_PREVENTED: Message is ${messageSize} chars, limit is ${MAX_PAYLOAD_CHARS}` ) throw new Error( `PAYLOAD_TOO_LARGE_PREVENTED: Message is ${messageSize} chars, limit is ${MAX_PAYLOAD_CHARS}` ) } const controller = new AbortController() const timeoutId = setTimeout( () => controller.abort(), this.options.timeoutMS ) try { response = await pRetry( async () => { if (controller.signal.aborted) throw new Error('Request timed out') try { return await this.api!.sendMessage(message, opts) } catch (e: any) { // CRITICAL: 413 errors should NEVER retry - they waste tokens and time if ( e?.status === 413 || e?.message?.includes('Request Entity Too Large') ) { error( `413 Payload Too Large - NOT retrying. Message size: ${messageSize} chars` ) throw e // Re-throw to fail immediately without retry } // Any error: try fallback model if available (cycles through chain) if (this.fallbackModels.length > 0) { const nextModel = this.fallbackModels.shift() if (nextModel) { warning( `Model ${this.currentModel} failed (${e?.status || e?.message}). Switching to fallback: ${nextModel}` ) this.currentModel = nextModel this.initClient() delete opts.parentMessageId throw new AllModelsFailedError() } } throw e } }, { retries: this.options.retries, onFailedAttempt: error => { info( `Attempt ${error.attemptNumber} failed. ${error.retriesLeft} retries left.` ) } } ) } finally { clearTimeout(timeoutId) } } catch (e: unknown) { if (e instanceof AIError) { info(`AI Error: ${e.message} (Last model used: ${this.currentModel})`) } else if (e instanceof Error) { error( `Non-AI error in chat: ${e.message} (Last model used: ${this.currentModel})` ) } } const end = Date.now() info(`AI response time: ${end - start} ms`) // Token usage logging if (response != null) { const inputTokens = getTokenCount(message) const outputTokens = getTokenCount(response.text) const totalTokens = inputTokens + outputTokens info( `Token usage: ${inputTokens}+${outputTokens}=${totalTokens} tokens (${this.currentModel})` ) } } 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] } }