/ src / utils / openrouter-monitor.js
openrouter-monitor.js
  1  /**
  2   * OpenRouter Credit Monitoring
  3   * Checks credit balance and usage stats via OpenRouter API
  4   * Provides alerting when credits drop below threshold
  5   */
  6  
  7  import axios from 'axios';
  8  import { run, getOne, getAll } from './db.js';
  9  import './load-env.js';
 10  
 11  const { OPENROUTER_API_KEY } = process.env;
 12  
 13  // Default threshold: $10 USD
 14  const DEFAULT_THRESHOLD = parseFloat(process.env.OPENROUTER_CREDIT_THRESHOLD || '10.0');
 15  
 16  /**
 17   * Check OpenRouter API key info including credits/balance
 18   * @returns {Promise<Object>} - API key details: {data: {label, usage, limit, is_free_tier, rate_limit}}
 19   */
 20  export async function checkCredits() {
 21    if (!OPENROUTER_API_KEY) {
 22      throw new Error('OPENROUTER_API_KEY not set in .env');
 23    }
 24  
 25    try {
 26      const response = await axios.get('https://openrouter.ai/api/v1/auth/key', {
 27        headers: {
 28          Authorization: `Bearer ${OPENROUTER_API_KEY}`,
 29          'HTTP-Referer': 'https://333method.local',
 30          'X-Title': '333 Method Automation',
 31          'Content-Type': 'application/json',
 32        },
 33        timeout: 30000,
 34      });
 35  
 36      return response.data;
 37    } catch (err) {
 38      if (err.response) {
 39        throw new Error(
 40          `OpenRouter API error (${err.response.status}): ${JSON.stringify(err.response.data)}`
 41        );
 42      }
 43      throw new Error(`OpenRouter API request failed: ${err.message}`);
 44    }
 45  }
 46  
 47  /**
 48   * Calculate remaining credits
 49   * @param {Object} keyInfo - Key info from checkCredits()
 50   * @returns {number|null} - Remaining credits in USD, or null if unlimited/unknown
 51   */
 52  export function getRemainingCredits(keyInfo) {
 53    const { data } = keyInfo;
 54  
 55    // Free tier or no limit
 56    if (!data.limit || data.is_free_tier) {
 57      return null;
 58    }
 59  
 60    // Calculate remaining: limit - usage
 61    const limit = parseFloat(data.limit) || 0;
 62    const usage = parseFloat(data.usage) || 0;
 63    const remaining = limit - usage;
 64  
 65    return Math.max(0, remaining);
 66  }
 67  
 68  /**
 69   * Log credit balance to database for historical tracking
 70   * @param {Object} keyInfo - Key info from checkCredits()
 71   */
 72  export async function logCreditBalance(keyInfo) {
 73    const { data } = keyInfo;
 74    const remaining = getRemainingCredits(keyInfo);
 75  
 76    await run(
 77      `INSERT INTO openrouter_credit_log (
 78        label, usage, credit_limit, remaining, is_free_tier,
 79        rate_limit, raw_response
 80      ) VALUES ($1, $2, $3, $4, $5, $6, $7)`,
 81      [
 82        data.label || null,
 83        parseFloat(data.usage) || 0,
 84        parseFloat(data.limit) || null,
 85        remaining,
 86        data.is_free_tier ? 1 : 0,
 87        JSON.stringify(data.rate_limit || {}),
 88        JSON.stringify(keyInfo),
 89      ]
 90    );
 91  }
 92  
 93  /**
 94   * Check if credits are below threshold and return alert message
 95   * @param {number|null} remaining - Remaining credits
 96   * @param {number} threshold - Alert threshold
 97   * @returns {Object|null} - Alert object {level: 'warning'|'critical', message: string} or null if OK
 98   */
 99  export function checkThreshold(remaining, threshold = DEFAULT_THRESHOLD) {
100    // No limit or free tier
101    if (remaining === null) {
102      return null;
103    }
104  
105    // Critical: Credits exhausted
106    if (remaining <= 0) {
107      return {
108        level: 'critical',
109        message: `OpenRouter credits exhausted! Balance: $${remaining.toFixed(2)}`,
110      };
111    }
112  
113    // Warning: Below threshold
114    if (remaining < threshold) {
115      return {
116        level: 'warning',
117        message: `OpenRouter credits low! Balance: $${remaining.toFixed(2)} (threshold: $${threshold})`,
118      };
119    }
120  
121    return null;
122  }
123  
124  /**
125   * Get credit usage history from database
126   * @param {number} days - Number of days to retrieve (default: 30)
127   * @returns {Promise<Array>} - Credit log entries [{timestamp, usage, remaining}]
128   */
129  export async function getCreditHistory(days = 30) {
130    return await getAll(
131      `SELECT
132        timestamp,
133        usage,
134        credit_limit,
135        remaining,
136        is_free_tier
137       FROM openrouter_credit_log
138       WHERE timestamp > NOW() - ($1 || ' days')::interval
139       ORDER BY timestamp DESC`,
140      [days]
141    );
142  }
143  
144  /**
145   * Calculate daily burn rate from recent history
146   * @param {number} days - Number of days to analyze (default: 7)
147   * @returns {Promise<number|null>} - Daily burn rate in USD, or null if insufficient data
148   */
149  export async function getDailyBurnRate(days = 7) {
150    const history = await getCreditHistory(days);
151  
152    if (history.length < 2) {
153      return null;
154    }
155  
156    // Get oldest and newest entries
157    const oldest = history[history.length - 1];
158    const newest = history[0];
159  
160    // Calculate time difference in days
161    const oldestTime = new Date(oldest.timestamp);
162    const newestTime = new Date(newest.timestamp);
163    const daysDiff = (newestTime - oldestTime) / (1000 * 60 * 60 * 24);
164  
165    if (daysDiff === 0) {
166      return null;
167    }
168  
169    // Calculate usage difference
170    const usageDiff = newest.usage - oldest.usage;
171  
172    return usageDiff / daysDiff;
173  }
174  
175  /**
176   * Estimate days until credits run out
177   * @param {number|null} remaining - Remaining credits
178   * @param {number} days - Days to analyze for burn rate (default: 7)
179   * @returns {Promise<number|null>} - Days until exhaustion, or null if unlimited/unknown
180   */
181  export async function getDaysUntilExhaustion(remaining, days = 7) {
182    if (remaining === null) {
183      return null;
184    }
185  
186    const burnRate = await getDailyBurnRate(days);
187  
188    if (!burnRate || burnRate <= 0) {
189      return null;
190    }
191  
192    return remaining / burnRate;
193  }
194  
195  /**
196   * Main monitoring function - checks credits, logs to DB, returns status
197   * @param {Object} options - Options: {threshold: number, alertOnly: boolean}
198   * @returns {Promise<Object>} - Status: {remaining, alert, burnRate, daysLeft, keyInfo}
199   */
200  export async function monitorCredits({ threshold = DEFAULT_THRESHOLD, alertOnly = false } = {}) {
201    const keyInfo = await checkCredits();
202    const remaining = getRemainingCredits(keyInfo);
203  
204    // Log to database (unless alertOnly mode)
205    if (!alertOnly) {
206      await logCreditBalance(keyInfo);
207    }
208  
209    // Check threshold
210    const alert = checkThreshold(remaining, threshold);
211  
212    // Calculate burn rate and days left
213    const burnRate = await getDailyBurnRate();
214    const daysLeft = await getDaysUntilExhaustion(remaining);
215  
216    return {
217      remaining,
218      alert,
219      burnRate,
220      daysLeft,
221      keyInfo,
222    };
223  }
224  
225  /**
226   * Display key information
227   * @param {Object} keyInfo - Key info from API
228   */
229  function displayKeyInfo(keyInfo) {
230    if (keyInfo?.data?.label) {
231      console.log(`API Key: ${keyInfo.data.label}`);
232    }
233  }
234  
235  /**
236   * Display balance information
237   * @param {number|null} remaining - Remaining credits
238   * @param {Object} keyInfo - Key info from API
239   */
240  function displayBalance(remaining, keyInfo) {
241    if (remaining === null) {
242      console.log('Balance: Unlimited (Free tier or no limit set)');
243    } else {
244      const usage = parseFloat(keyInfo.data.usage) || 0;
245      const limit = parseFloat(keyInfo.data.credit_limit) || 0;
246      console.log(`Balance: $${remaining.toFixed(2)} remaining`);
247      console.log(`Usage: $${usage.toFixed(2)} / $${limit.toFixed(2)}`);
248    }
249  }
250  
251  /**
252   * Display burn rate and forecast
253   * @param {number|null} burnRate - Daily burn rate
254   * @param {number|null} daysLeft - Days until exhaustion
255   */
256  function displayBurnRate(burnRate, daysLeft) {
257    if (burnRate !== null) {
258      console.log(`\nBurn Rate: $${burnRate.toFixed(2)}/day (7-day average)`);
259  
260      if (daysLeft !== null) {
261        console.log(`Days Left: ${daysLeft.toFixed(1)} days at current rate`);
262      }
263    }
264  }
265  
266  /**
267   * Display alert status
268   * @param {Object|null} alert - Alert object
269   * @param {number|null} remaining - Remaining credits
270   */
271  function displayAlert(alert, remaining) {
272    if (alert) {
273      console.log(`\n⚠️  ${alert.level.toUpperCase()}: ${alert.message}`);
274    } else if (remaining !== null) {
275      console.log('\n✓ Credits OK');
276    }
277  }
278  
279  /**
280   * CLI-friendly credit check with formatted output
281   * @param {Object} options - Options: {threshold: number, verbose: boolean}
282   */
283  export async function displayCredits({ threshold = DEFAULT_THRESHOLD, verbose = false } = {}) {
284    console.log('Checking OpenRouter credits...\n');
285  
286    const status = await monitorCredits({ threshold, alertOnly: !verbose });
287  
288    displayKeyInfo(status.keyInfo);
289    displayBalance(status.remaining, status.keyInfo);
290    displayBurnRate(status.burnRate, status.daysLeft);
291    displayAlert(status.alert, status.remaining);
292  
293    // Display rate limit info
294    if (verbose && status.keyInfo?.data?.rate_limit) {
295      console.log('\nRate Limits:');
296      console.log(JSON.stringify(status.keyInfo.data.rate_limit, null, 2));
297    }
298  
299    return status;
300  }
301  
302  // CLI entry point
303  if (import.meta.url === `file://${process.argv[1]}`) {
304    const args = process.argv.slice(2);
305    const verbose = args.includes('--verbose') || args.includes('-v');
306    const thresholdArg = args.find(arg => arg.startsWith('--threshold='));
307    const threshold = thresholdArg ? parseFloat(thresholdArg.split('=')[1]) : DEFAULT_THRESHOLD;
308  
309    displayCredits({ threshold, verbose })
310      .then(() => process.exit(0))
311      .catch(err => {
312        console.error('Error checking credits:', err.message);
313        process.exit(1);
314      });
315  }