|
|
|
|
|
|
|
|
| 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 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()
|
|
|
|
|
|
|
| private static get LIMIT(): number {
|
| return 1000000
|
| }
|
|
|
| static async throttle(estimatedTokens: number): Promise<void> {
|
|
|
| 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) {
|
|
|
| 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...`
|
| )
|
|
|
|
|
| this.mutex.release()
|
| await sleep(Math.max(waitTime, 1000))
|
|
|
|
|
|
|
| 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
|
|
|
|
|
| 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
|
| }
|
|
|
|
|
| if (this.currentModel === 'openai/gpt-oss-120b') {
|
| completionParams.provider = {order: ['deallm', 'deepinfra']}
|
| }
|
|
|
|
|
| if (this.currentModel === 'openai/gpt-oss-120b:free') {
|
| completionParams.provider = {order: ['deepinfra']}
|
| }
|
|
|
|
|
|
|
| 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 {
|
|
|
| const limits = new TokenLimits(this.currentModel)
|
| const estimatedTokens = getTokenCount(message) + limits.responseTokens
|
| await TokenThrottler.throttle(estimatedTokens)
|
|
|
|
|
| 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))
|
|
|
|
|
| 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}`)
|
| }
|
| }
|
| }
|
|
|
|
|
| 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) {
|
|
|
| if (
|
| e?.status === 413 ||
|
| e?.message?.includes('Request Entity Too Large')
|
| ) {
|
| error(
|
| `413 Payload Too Large - NOT retrying. Message size: ${messageSize} chars`
|
| )
|
| throw e
|
| }
|
|
|
|
|
| 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`)
|
|
|
|
|
| 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]
|
| }
|
| }
|
|
|