{"version":3,"file":"credit-monitor.mjs","names":[],"sources":["../../../src/services/credit-monitor.ts"],"sourcesContent":["/**\n * Credit Monitor — proactive LLM credit balance monitoring.\n *\n * Periodically checks the Bankr LLM Gateway credit balance and emits\n * alerts when balance drops below configurable thresholds. Integrates\n * with the channel sender to notify users before credits run out.\n *\n * Architecture follows the HeartbeatMonitor pattern:\n * - Periodic timer with configurable interval\n * - Alert callbacks wired by index.ts to channel sender\n * - Event bus integration for credit_low events\n */\n\nimport { guardedFetch } from './endpoint-allowlist.js';\n\n// ─── Types ───────────────────────────────────────────────────────────────\n\nexport interface CreditMonitorConfig {\n  /** How often to check balance in ms. Default: 300_000 (5 minutes). */\n  intervalMs?: number;\n  /** Balance threshold in USD that triggers a warning. Default: 5. */\n  warningThresholdUsd?: number;\n  /** Balance threshold in USD that triggers a critical alert. Default: 1. */\n  criticalThresholdUsd?: number;\n  /** Whether the monitor is enabled. Default: true. */\n  enabled?: boolean;\n}\n\nexport interface CreditAlert {\n  severity: 'warning' | 'critical';\n  balance: number;\n  currency: string;\n  message: string;\n  timestamp: number;\n}\n\nexport type CreditAlertCallback = (alert: CreditAlert) => void | Promise<void>;\n\n// ─── Constants ───────────────────────────────────────────────────────────\n\nconst BANKR_BASE = 'https://llm.bankr.bot';\n\n/** Suppress duplicate alerts for this long after firing one. */\nconst ALERT_COOLDOWN_MS = 30 * 60_000; // 30 minutes\n\n// ─── Service ─────────────────────────────────────────────────────────────\n\nclass CreditMonitor {\n  private config: Required<CreditMonitorConfig>;\n  private timer: ReturnType<typeof setInterval> | null = null;\n  private running = false;\n  private callbacks: CreditAlertCallback[] = [];\n  private lastAlertTimestamp = 0;\n  private lastBalance: number | null = null;\n  private checkCount = 0;\n  private tickInProgress = false;\n\n  constructor(config: CreditMonitorConfig = {}) {\n    this.config = {\n      intervalMs: config.intervalMs ?? 300_000,\n      warningThresholdUsd: config.warningThresholdUsd ?? 5,\n      criticalThresholdUsd: config.criticalThresholdUsd ?? 1,\n      enabled: config.enabled ?? true,\n    };\n  }\n\n  /** Register an alert callback. */\n  onAlert(cb: CreditAlertCallback): void {\n    this.callbacks.push(cb);\n  }\n\n  /** Start periodic checking. */\n  start(): void {\n    if (this.running || !this.config.enabled) return;\n    this.running = true;\n    this.timer = setInterval(() => this.tick(), this.config.intervalMs);\n    if (this.timer && typeof this.timer.unref === 'function') {\n      this.timer.unref();\n    }\n    // Run first check after a short delay (don't block startup)\n    setTimeout(() => this.tick(), 10_000);\n  }\n\n  /** Stop periodic checking. */\n  stop(): void {\n    this.running = false;\n    if (this.timer) {\n      clearInterval(this.timer);\n      this.timer = null;\n    }\n  }\n\n  /** Get current state (for /llmcredits display). */\n  getState(): { lastBalance: number | null; checkCount: number; running: boolean } {\n    return {\n      lastBalance: this.lastBalance,\n      checkCount: this.checkCount,\n      running: this.running,\n    };\n  }\n\n  get isRunning(): boolean {\n    return this.running;\n  }\n\n  /** Run one check cycle. */\n  async tick(): Promise<void> {\n    if (this.tickInProgress) return; // Prevent overlapping checks\n    this.tickInProgress = true;\n\n    try {\n      const key = process.env.BANKR_LLM_KEY;\n      if (!key) return; // No key configured — skip\n\n      const balance = await this.fetchBalance(key);\n      if (balance === null) return; // API error — skip\n\n      this.lastBalance = balance;\n      this.checkCount++;\n\n      // Check thresholds\n      const now = Date.now();\n      const cooldownExpired = now - this.lastAlertTimestamp > ALERT_COOLDOWN_MS;\n\n      if (balance <= this.config.criticalThresholdUsd && cooldownExpired) {\n        this.lastAlertTimestamp = now;\n        this.emitAlert({\n          severity: 'critical',\n          balance,\n          currency: 'USD',\n          message: `LLM credits critically low: $${balance.toFixed(2)} remaining. Top up immediately with \\`/topup 25\\` or enable auto top-up with \\`/autotopup enable\\`.`,\n          timestamp: now,\n        });\n      } else if (balance <= this.config.warningThresholdUsd && cooldownExpired) {\n        this.lastAlertTimestamp = now;\n        this.emitAlert({\n          severity: 'warning',\n          balance,\n          currency: 'USD',\n          message: `LLM credits low: $${balance.toFixed(2)} remaining. Consider topping up with \\`/topup 25\\` or enable \\`/autotopup enable\\`.`,\n          timestamp: now,\n        });\n      }\n    } finally {\n      this.tickInProgress = false;\n    }\n  }\n\n  private async fetchBalance(key: string): Promise<number | null> {\n    try {\n      const res = await guardedFetch(`${BANKR_BASE}/v1/credits`, {\n        headers: { 'X-API-Key': key, 'Content-Type': 'application/json' },\n        signal: AbortSignal.timeout(10_000),\n      });\n      if (!res.ok) return null;\n      const data = await res.json() as any;\n      const balance = data?.balance ?? data?.credits ?? data?.remaining ?? null;\n      if (typeof balance !== 'number') return null;\n      return balance;\n    } catch {\n      return null;\n    }\n  }\n\n  private emitAlert(alert: CreditAlert): void {\n    for (const cb of this.callbacks) {\n      try {\n        const result = cb(alert);\n        if (result && typeof (result as Promise<void>).catch === 'function') {\n          (result as Promise<void>).catch(() => {});\n        }\n      } catch { /* swallow */ }\n    }\n  }\n}\n\n// ─── Singleton ───────────────────────────────────────────────────────────\n\nlet instance: CreditMonitor | null = null;\n\nexport function getCreditMonitor(config?: CreditMonitorConfig): CreditMonitor {\n  if (!instance) {\n    instance = new CreditMonitor(config);\n  }\n  return instance;\n}\n\nexport function resetCreditMonitor(): void {\n  instance?.stop();\n  instance = null;\n}\n"],"mappings":";;;;;;;;;;;;;;AAwCA,MAAM,aAAa;;AAGnB,MAAM,oBAAoB,KAAK;AAI/B,IAAM,gBAAN,MAAoB;CAClB;CACA,QAAuD;CACvD,UAAkB;CAClB,YAA2C,EAAE;CAC7C,qBAA6B;CAC7B,cAAqC;CACrC,aAAqB;CACrB,iBAAyB;CAEzB,YAAY,SAA8B,EAAE,EAAE;AAC5C,OAAK,SAAS;GACZ,YAAY,OAAO,cAAc;GACjC,qBAAqB,OAAO,uBAAuB;GACnD,sBAAsB,OAAO,wBAAwB;GACrD,SAAS,OAAO,WAAW;GAC5B;;;CAIH,QAAQ,IAA+B;AACrC,OAAK,UAAU,KAAK,GAAG;;;CAIzB,QAAc;AACZ,MAAI,KAAK,WAAW,CAAC,KAAK,OAAO,QAAS;AAC1C,OAAK,UAAU;AACf,OAAK,QAAQ,kBAAkB,KAAK,MAAM,EAAE,KAAK,OAAO,WAAW;AACnE,MAAI,KAAK,SAAS,OAAO,KAAK,MAAM,UAAU,WAC5C,MAAK,MAAM,OAAO;AAGpB,mBAAiB,KAAK,MAAM,EAAE,IAAO;;;CAIvC,OAAa;AACX,OAAK,UAAU;AACf,MAAI,KAAK,OAAO;AACd,iBAAc,KAAK,MAAM;AACzB,QAAK,QAAQ;;;;CAKjB,WAAiF;AAC/E,SAAO;GACL,aAAa,KAAK;GAClB,YAAY,KAAK;GACjB,SAAS,KAAK;GACf;;CAGH,IAAI,YAAqB;AACvB,SAAO,KAAK;;;CAId,MAAM,OAAsB;AAC1B,MAAI,KAAK,eAAgB;AACzB,OAAK,iBAAiB;AAEtB,MAAI;GACF,MAAM,MAAM,QAAQ,IAAI;AACxB,OAAI,CAAC,IAAK;GAEV,MAAM,UAAU,MAAM,KAAK,aAAa,IAAI;AAC5C,OAAI,YAAY,KAAM;AAEtB,QAAK,cAAc;AACnB,QAAK;GAGL,MAAM,MAAM,KAAK,KAAK;GACtB,MAAM,kBAAkB,MAAM,KAAK,qBAAqB;AAExD,OAAI,WAAW,KAAK,OAAO,wBAAwB,iBAAiB;AAClE,SAAK,qBAAqB;AAC1B,SAAK,UAAU;KACb,UAAU;KACV;KACA,UAAU;KACV,SAAS,gCAAgC,QAAQ,QAAQ,EAAE,CAAC;KAC5D,WAAW;KACZ,CAAC;cACO,WAAW,KAAK,OAAO,uBAAuB,iBAAiB;AACxE,SAAK,qBAAqB;AAC1B,SAAK,UAAU;KACb,UAAU;KACV;KACA,UAAU;KACV,SAAS,qBAAqB,QAAQ,QAAQ,EAAE,CAAC;KACjD,WAAW;KACZ,CAAC;;YAEI;AACR,QAAK,iBAAiB;;;CAI1B,MAAc,aAAa,KAAqC;AAC9D,MAAI;GACF,MAAM,MAAM,MAAM,aAAa,GAAG,WAAW,cAAc;IACzD,SAAS;KAAE,aAAa;KAAK,gBAAgB;KAAoB;IACjE,QAAQ,YAAY,QAAQ,IAAO;IACpC,CAAC;AACF,OAAI,CAAC,IAAI,GAAI,QAAO;GACpB,MAAM,OAAO,MAAM,IAAI,MAAM;GAC7B,MAAM,UAAU,MAAM,WAAW,MAAM,WAAW,MAAM,aAAa;AACrE,OAAI,OAAO,YAAY,SAAU,QAAO;AACxC,UAAO;UACD;AACN,UAAO;;;CAIX,UAAkB,OAA0B;AAC1C,OAAK,MAAM,MAAM,KAAK,UACpB,KAAI;GACF,MAAM,SAAS,GAAG,MAAM;AACxB,OAAI,UAAU,OAAQ,OAAyB,UAAU,WACtD,QAAyB,YAAY,GAAG;UAErC;;;AAOd,IAAI,WAAiC;AAErC,SAAgB,iBAAiB,QAA6C;AAC5E,KAAI,CAAC,SACH,YAAW,IAAI,cAAc,OAAO;AAEtC,QAAO;;AAGT,SAAgB,qBAA2B;AACzC,WAAU,MAAM;AAChB,YAAW"}