import fs from 'fs'; import path from 'path'; import { USAGE_HISTORY_PATH } from '../constants.js'; import { logger } from '../utils/logger.js'; // Persistence path const HISTORY_FILE = USAGE_HISTORY_PATH; const DATA_DIR = path.dirname(HISTORY_FILE); const OLD_DATA_DIR = path.join(process.cwd(), 'data'); const OLD_HISTORY_FILE = path.join(OLD_DATA_DIR, 'usage-history.json'); // In-memory storage // Structure: { "YYYY-MM-DDTHH:00:00.000Z": { "claude": { "model-name": count, "_subtotal": count }, "_total": count } } let history = {}; let isDirty = false; /** * Extract model family from model ID * @param {string} modelId - The model identifier (e.g., "claude-opus-4-6-thinking") * @returns {string} The family name (claude, gemini, or other) */ function getFamily(modelId) { const lower = (modelId || '').toLowerCase(); if (lower.includes('claude')) return 'claude'; if (lower.includes('gemini')) return 'gemini'; return 'other'; } /** * Extract short model name (without family prefix) * @param {string} modelId - The model identifier * @param {string} family - The model family * @returns {string} Short model name */ function getShortName(modelId, family) { if (family === 'other') return modelId; // Remove family prefix (e.g., "claude-opus-4-6" -> "opus-4-6") return modelId.replace(new RegExp(`^${family}-`, 'i'), ''); } /** * Ensure data directory exists and load history. * Includes migration from legacy local data directory. */ function load() { try { // Migration logic: if old file exists and new one doesn't if (fs.existsSync(OLD_HISTORY_FILE) && !fs.existsSync(HISTORY_FILE)) { logger.info('[UsageStats] Migrating legacy usage data...'); if (!fs.existsSync(DATA_DIR)) { fs.mkdirSync(DATA_DIR, { recursive: true }); } fs.copyFileSync(OLD_HISTORY_FILE, HISTORY_FILE); // We keep the old file for safety initially, but could delete it logger.info(`[UsageStats] Migration complete: ${OLD_HISTORY_FILE} -> ${HISTORY_FILE}`); } if (!fs.existsSync(DATA_DIR)) { fs.mkdirSync(DATA_DIR, { recursive: true }); } if (fs.existsSync(HISTORY_FILE)) { const data = fs.readFileSync(HISTORY_FILE, 'utf8'); history = JSON.parse(data); } } catch (err) { logger.error('[UsageStats] Failed to load history:', err); history = {}; } } /** * Save history to disk */ function save() { if (!isDirty) return; try { fs.writeFileSync(HISTORY_FILE, JSON.stringify(history, null, 2)); isDirty = false; } catch (err) { logger.error('[UsageStats] Failed to save history:', err); } } /** * Prune old data (keep last 30 days) */ function prune() { const now = new Date(); const cutoff = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); let pruned = false; Object.keys(history).forEach(key => { if (new Date(key) < cutoff) { delete history[key]; pruned = true; } }); if (pruned) isDirty = true; } /** * Track a request by model ID using hierarchical structure * @param {string} modelId - The specific model identifier */ function track(modelId) { const now = new Date(); // Round down to nearest hour now.setMinutes(0, 0, 0); const key = now.toISOString(); if (!history[key]) { history[key] = { _total: 0 }; } const hourData = history[key]; const family = getFamily(modelId); const shortName = getShortName(modelId, family); // Initialize family object if needed if (!hourData[family]) { hourData[family] = { _subtotal: 0 }; } // Increment model-specific count hourData[family][shortName] = (hourData[family][shortName] || 0) + 1; // Increment family subtotal hourData[family]._subtotal = (hourData[family]._subtotal || 0) + 1; // Increment global total hourData._total = (hourData._total || 0) + 1; isDirty = true; } /** * Setup Express Middleware * @param {import('express').Application} app */ function setupMiddleware(app) { load(); // Auto-save every minute setInterval(() => { save(); prune(); }, 60 * 1000); // Save on exit process.on('SIGINT', () => { save(); process.exit(); }); process.on('SIGTERM', () => { save(); process.exit(); }); // Request interceptor // Track both Anthropic (/v1/messages) and OpenAI compatible (/v1/chat/completions) endpoints const TRACKED_PATHS = ['/v1/messages', '/v1/chat/completions']; app.use((req, res, next) => { if (req.method === 'POST' && TRACKED_PATHS.includes(req.path)) { const model = req.body?.model; if (model) { track(model); } } next(); }); } /** * Setup API Routes * @param {import('express').Application} app */ function setupRoutes(app) { app.get('/api/stats/history', (req, res) => { // Sort keys to ensure chronological order const sortedKeys = Object.keys(history).sort(); const sortedData = {}; sortedKeys.forEach(key => { sortedData[key] = history[key]; }); res.json(sortedData); }); } /** * Get usage history data * @returns {object} History data sorted by timestamp */ function getHistory() { const sortedKeys = Object.keys(history).sort(); const sortedData = {}; sortedKeys.forEach(key => { sortedData[key] = history[key]; }); return sortedData; } export default { setupMiddleware, setupRoutes, track, getFamily, getShortName, getHistory };