PRININIT / src /bot.ts
Rachit-Tw's picture
Upload 183 files
47606a7 verified
Raw
History Blame Contribute Delete
11.8 kB
// 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<void> {
// 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<string, any> = {
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]
}
}