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 }