2026-01-06 16:44:14 +02:00
import { Low } from "lowdb" ;
import { JSONFile } from "lowdb/node" ;
2026-02-21 14:36:06 +07:00
import { EventEmitter } from "events" ;
2026-01-06 16:44:14 +02:00
import path from "path" ;
import fs from "fs" ;
2026-04-16 10:58:35 +07:00
import { DATA_DIR } from "@/lib/dataDir.js" ;
2026-01-06 16:44:14 +02:00
2026-01-11 21:45:01 +07:00
const isCloud = typeof caches !== 'undefined' || typeof caches === 'object' ;
const DB_FILE = isCloud ? null : path . join ( DATA_DIR , "usage.json" );
const LOG_FILE = isCloud ? null : path . join ( DATA_DIR , "log.txt" );
2026-01-06 16:44:14 +02:00
// Ensure data directory exists
2026-02-05 11:06:20 +07:00
if ( ! isCloud && fs && typeof fs . existsSync === "function" ) {
try {
if ( ! fs . existsSync ( DATA_DIR )) {
fs . mkdirSync ( DATA_DIR , { recursive : true });
console . log ( `[usageDb] Created data directory: ${ DATA_DIR } ` );
}
} catch ( error ) {
console . error ( "[usageDb] Failed to create data directory:" , error . message );
}
2026-01-06 16:44:14 +02:00
}
const defaultData = {
2026-03-22 19:14:01 -07:00
history : [],
2026-04-15 10:45:46 +07:00
totalRequestsLifetime : 0 ,
dailySummary : {},
2026-01-06 16:44:14 +02:00
};
2026-04-15 10:45:46 +07:00
function getLocalDateKey ( timestamp ) {
const d = timestamp ? new Date ( timestamp ) : new Date ();
return ` ${ d . getFullYear () } - ${ String ( d . getMonth () + 1 ). padStart ( 2 , "0" ) } - ${ String ( d . getDate ()). padStart ( 2 , "0" ) } ` ;
}
function addToCounter ( target , key , values ) {
if ( ! target [ key ]) target [ key ] = { requests : 0 , promptTokens : 0 , completionTokens : 0 , cost : 0 };
target [ key ]. requests += values . requests || 1 ;
target [ key ]. promptTokens += values . promptTokens || 0 ;
target [ key ]. completionTokens += values . completionTokens || 0 ;
target [ key ]. cost += values . cost || 0 ;
if ( values . meta ) Object . assign ( target [ key ], values . meta );
}
function aggregateEntryToDailySummary ( dailySummary , entry ) {
const dateKey = getLocalDateKey ( entry . timestamp );
if ( ! dailySummary [ dateKey ]) {
dailySummary [ dateKey ] = {
requests : 0 , promptTokens : 0 , completionTokens : 0 , cost : 0 ,
byProvider : {}, byModel : {}, byAccount : {}, byApiKey : {}, byEndpoint : {},
};
}
const day = dailySummary [ dateKey ];
const promptTokens = entry . tokens ? . prompt_tokens || entry . tokens ? . input_tokens || 0 ;
const completionTokens = entry . tokens ? . completion_tokens || entry . tokens ? . output_tokens || 0 ;
const cost = entry . cost || 0 ;
const vals = { promptTokens , completionTokens , cost };
day . requests += 1 ;
day . promptTokens += promptTokens ;
day . completionTokens += completionTokens ;
day . cost += cost ;
if ( entry . provider ) addToCounter ( day . byProvider , entry . provider , vals );
const modelKey = entry . provider ? ` ${ entry . model } | ${ entry . provider } ` : entry . model ;
addToCounter ( day . byModel , modelKey , { ... vals , meta : { rawModel : entry . model , provider : entry . provider } });
if ( entry . connectionId ) {
addToCounter ( day . byAccount , entry . connectionId , { ... vals , meta : { rawModel : entry . model , provider : entry . provider } });
}
const apiKeyVal = entry . apiKey && typeof entry . apiKey === "string" ? entry . apiKey : "local-no-key" ;
const akModelKey = ` ${ apiKeyVal } | ${ entry . model } | ${ entry . provider || "unknown" } ` ;
addToCounter ( day . byApiKey , akModelKey , { ... vals , meta : { rawModel : entry . model , provider : entry . provider , apiKey : entry . apiKey || null } });
const endpoint = entry . endpoint || "Unknown" ;
const epKey = ` ${ endpoint } | ${ entry . model } | ${ entry . provider || "unknown" } ` ;
addToCounter ( day . byEndpoint , epKey , { ... vals , meta : { endpoint , rawModel : entry . model , provider : entry . provider } });
}
function migrateHistoryToDailySummary ( db ) {
const history = db . data . history || [];
if ( ! history . length ) return false ;
db . data . dailySummary = {};
for ( const entry of history ) {
aggregateEntryToDailySummary ( db . data . dailySummary , entry );
}
console . log ( `[usageDb] Migrated ${ history . length } history entries to dailySummary ( ${ Object . keys ( db . data . dailySummary ). length } days)` );
return true ;
}
2026-01-06 16:44:14 +02:00
// Singleton instance
let dbInstance = null ;
2026-02-21 14:36:06 +07:00
// Use global to share pending state across Next.js route modules
if ( ! global . _pendingRequests ) {
global . _pendingRequests = { byModel : {}, byAccount : {} };
}
const pendingRequests = global . _pendingRequests ;
2026-02-21 16:42:46 +07:00
// Track last error provider for UI edge coloring (auto-clears after 10s)
if ( ! global . _lastErrorProvider ) {
global . _lastErrorProvider = { provider : "" , ts : 0 };
}
const lastErrorProvider = global . _lastErrorProvider ;
2026-02-21 14:36:06 +07:00
// Use global to share singleton across Next.js route modules
if ( ! global . _statsEmitter ) {
global . _statsEmitter = new EventEmitter ();
global . _statsEmitter . setMaxListeners ( 50 );
}
export const statsEmitter = global . _statsEmitter ;
2026-01-06 20:41:53 +02:00
2026-04-04 14:40:49 +07:00
// Safety timers — force-clear pending counts after 1 min if END was never called
if ( ! global . _pendingTimers ) global . _pendingTimers = {};
const pendingTimers = global . _pendingTimers ;
const PENDING_TIMEOUT_MS = 60 * 1000 ; // 1 minute
2026-01-06 20:41:53 +02:00
/**
* Track a pending request
* @param {string} model
* @param {string} provider
* @param {string} connectionId
* @param {boolean} started - true if started, false if finished
2026-02-21 16:42:46 +07:00
* @param {boolean} [error] - true if ended with error
2026-01-06 20:41:53 +02:00
*/
2026-02-21 16:42:46 +07:00
export function trackPendingRequest ( model , provider , connectionId , started , error = false ) {
2026-01-06 20:41:53 +02:00
const modelKey = provider ? ` ${ model } ( ${ provider } )` : model ;
2026-04-04 14:40:49 +07:00
const timerKey = ` ${ connectionId } | ${ modelKey } ` ;
2026-01-06 20:41:53 +02:00
// Track by model
if ( ! pendingRequests . byModel [ modelKey ]) pendingRequests . byModel [ modelKey ] = 0 ;
pendingRequests . byModel [ modelKey ] = Math . max ( 0 , pendingRequests . byModel [ modelKey ] + ( started ? 1 : - 1 ));
// Track by account
if ( connectionId ) {
2026-04-04 14:40:49 +07:00
if ( ! pendingRequests . byAccount [ connectionId ]) pendingRequests . byAccount [ connectionId ] = {};
if ( ! pendingRequests . byAccount [ connectionId ][ modelKey ]) pendingRequests . byAccount [ connectionId ][ modelKey ] = 0 ;
pendingRequests . byAccount [ connectionId ][ modelKey ] = Math . max ( 0 , pendingRequests . byAccount [ connectionId ][ modelKey ] + ( started ? 1 : - 1 ));
}
if ( started ) {
// Safety timeout: force-clear if END is never called (client disconnect, crash, etc.)
clearTimeout ( pendingTimers [ timerKey ]);
pendingTimers [ timerKey ] = setTimeout (() => {
delete pendingTimers [ timerKey ];
if ( pendingRequests . byModel [ modelKey ] > 0 ) {
pendingRequests . byModel [ modelKey ] = 0 ;
}
if ( connectionId && pendingRequests . byAccount [ connectionId ] ? .[ modelKey ] > 0 ) {
pendingRequests . byAccount [ connectionId ][ modelKey ] = 0 ;
}
statsEmitter . emit ( "pending" );
}, PENDING_TIMEOUT_MS );
} else {
// END called normally — cancel the safety timer
clearTimeout ( pendingTimers [ timerKey ]);
delete pendingTimers [ timerKey ];
2026-01-06 20:41:53 +02:00
}
2026-02-21 14:36:06 +07:00
2026-02-21 16:42:46 +07:00
// Track error provider (auto-clears after 10s)
if ( ! started && error && provider ) {
lastErrorProvider . provider = provider . toLowerCase ();
lastErrorProvider . ts = Date . now ();
}
2026-03-02 09:31:16 +07:00
const t = new Date (). toLocaleTimeString ( "en-US" , { hour12 : false , hour : "2-digit" , minute : "2-digit" , second : "2-digit" });
console . log ( `[ ${ t } ] [PENDING] ${ started ? "START" : "END" }${ error ? " (ERROR)" : "" } | provider= ${ provider } | model= ${ model } ` );
2026-02-21 16:42:46 +07:00
statsEmitter . emit ( "pending" );
}
/**
* Lightweight: get only activeRequests + recentRequests without full stats recalc
*/
export async function getActiveRequests () {
const activeRequests = [];
// Build active requests from pending state
let connectionMap = {};
try {
const { getProviderConnections } = await import ( "@/lib/localDb.js" );
const allConnections = await getProviderConnections ();
for ( const conn of allConnections ) {
connectionMap [ conn . id ] = conn . name || conn . email || conn . id ;
}
} catch {}
for ( const [ connectionId , models ] of Object . entries ( pendingRequests . byAccount )) {
for ( const [ modelKey , count ] of Object . entries ( models )) {
if ( count > 0 ) {
const accountName = connectionMap [ connectionId ] || `Account ${ connectionId . slice ( 0 , 8 ) } ...` ;
const match = modelKey . match ( /^(.*) \((.*)\)$/ );
const modelName = match ? match [ 1 ] : modelKey ;
const providerName = match ? match [ 2 ] : "unknown" ;
activeRequests . push ({ model : modelName , provider : providerName , account : accountName , count });
}
}
}
// Get recent requests from history (re-read to get latest)
const db = await getUsageDb ();
await db . read ();
const history = db . data . history || [];
const seen = new Set ();
const recentRequests = [... history ]
. sort (( a , b ) => new Date ( b . timestamp ) - new Date ( a . timestamp ))
. map (( e ) => {
const t = e . tokens || {};
const promptTokens = t . prompt_tokens || t . input_tokens || 0 ;
const completionTokens = t . completion_tokens || t . output_tokens || 0 ;
return { timestamp : e . timestamp , model : e . model , provider : e . provider || "" , promptTokens , completionTokens , status : e . status || "ok" };
})
. filter (( e ) => {
if ( e . promptTokens === 0 && e . completionTokens === 0 ) return false ;
const minute = e . timestamp ? e . timestamp . slice ( 0 , 16 ) : "" ;
const key = ` ${ e . model } | ${ e . provider } | ${ e . promptTokens } | ${ e . completionTokens } | ${ minute } ` ;
if ( seen . has ( key )) return false ;
seen . add ( key );
return true ;
})
. slice ( 0 , 20 );
// Error provider (auto-clear after 10s)
const errorProvider = ( Date . now () - lastErrorProvider . ts < 10000 ) ? lastErrorProvider . provider : "" ;
return { activeRequests , recentRequests , errorProvider };
2026-01-06 20:41:53 +02:00
}
2026-01-06 16:44:14 +02:00
/**
* Get usage database instance (singleton)
*/
export async function getUsageDb () {
2026-01-11 21:45:01 +07:00
if ( isCloud ) {
// Return in-memory DB for Workers
if ( ! dbInstance ) {
dbInstance = new Low ({ read : async () => {}, write : async () => {} }, defaultData );
dbInstance . data = defaultData ;
}
return dbInstance ;
}
2026-01-06 16:44:14 +02:00
if ( ! dbInstance ) {
const adapter = new JSONFile ( DB_FILE );
dbInstance = new Low ( adapter , defaultData );
// Try to read DB with error recovery for corrupt JSON
try {
await dbInstance . read ();
} catch ( error ) {
if ( error instanceof SyntaxError ) {
console . warn ( '[DB] Corrupt Usage JSON detected, resetting to defaults...' );
dbInstance . data = defaultData ;
await dbInstance . write ();
} else {
throw error ;
}
}
if ( ! dbInstance . data ) {
2026-04-15 10:45:46 +07:00
dbInstance . data = { ... defaultData };
2026-01-06 16:44:14 +02:00
await dbInstance . write ();
}
2026-04-15 10:45:46 +07:00
// Migration: build dailySummary from existing history (one-time)
if ( ! dbInstance . data . dailySummary ) {
if ( migrateHistoryToDailySummary ( dbInstance )) {
await dbInstance . write ();
} else {
dbInstance . data . dailySummary = {};
}
}
2026-01-06 16:44:14 +02:00
}
return dbInstance ;
}
/**
* Save request usage
2026-02-11 15:44:08 +07:00
* @param {object} entry - Usage entry { provider, model, tokens: { prompt_tokens, completion_tokens, ... }, connectionId?, apiKey? }
2026-01-06 16:44:14 +02:00
*/
export async function saveRequestUsage ( entry ) {
2026-01-11 21:45:01 +07:00
if ( isCloud ) return ; // Skip saving in Workers
2026-01-06 16:44:14 +02:00
try {
const db = await getUsageDb ();
// Add timestamp if not present
if ( ! entry . timestamp ) {
entry . timestamp = new Date (). toISOString ();
}
// Ensure history array exists
if ( ! Array . isArray ( db . data . history )) {
db . data . history = [];
}
2026-03-22 19:14:01 -07:00
if ( typeof db . data . totalRequestsLifetime !== "number" ) {
db . data . totalRequestsLifetime = db . data . history . length ;
}
2026-01-06 16:44:14 +02:00
2026-02-21 14:36:06 +07:00
const entryCost = await calculateCost ( entry . provider , entry . model , entry . tokens );
entry . cost = entryCost ;
2026-01-06 16:44:14 +02:00
db . data . history . push ( entry );
2026-03-22 19:14:01 -07:00
db . data . totalRequestsLifetime += 1 ;
2026-01-06 16:44:14 +02:00
2026-04-15 10:45:46 +07:00
if ( ! db . data . dailySummary ) db . data . dailySummary = {};
aggregateEntryToDailySummary ( db . data . dailySummary , entry );
2026-03-14 09:37:29 +07:00
const MAX_HISTORY = 10000 ;
if ( db . data . history . length > MAX_HISTORY ) {
db . data . history . splice ( 0 , db . data . history . length - MAX_HISTORY );
}
2026-01-06 16:44:14 +02:00
await db . write ();
2026-02-21 14:36:06 +07:00
statsEmitter . emit ( "update" );
2026-01-06 16:44:14 +02:00
} catch ( error ) {
console . error ( "Failed to save usage stats:" , error );
}
}
/**
* Get usage history
* @param {object} filter - Filter criteria
*/
export async function getUsageHistory ( filter = {}) {
const db = await getUsageDb ();
let history = db . data . history || [];
// Apply filters
if ( filter . provider ) {
history = history . filter ( h => h . provider === filter . provider );
}
if ( filter . model ) {
history = history . filter ( h => h . model === filter . model );
}
if ( filter . startDate ) {
const start = new Date ( filter . startDate ). getTime ();
history = history . filter ( h => new Date ( h . timestamp ). getTime () >= start );
}
if ( filter . endDate ) {
const end = new Date ( filter . endDate ). getTime ();
history = history . filter ( h => new Date ( h . timestamp ). getTime () <= end );
}
return history ;
}
2026-01-09 17:40:59 +07:00
/**
* Format date as dd-mm-yyyy h:m:s
*/
function formatLogDate ( date = new Date ()) {
const pad = ( n ) => String ( n ). padStart ( 2 , "0" );
const d = pad ( date . getDate ());
const m = pad ( date . getMonth () + 1 );
const y = date . getFullYear ();
const h = pad ( date . getHours ());
const min = pad ( date . getMinutes ());
const s = pad ( date . getSeconds ());
return ` ${ d } - ${ m } - ${ y } ${ h } : ${ min } : ${ s } ` ;
}
/**
* Append to log.txt
* Format: datetime(dd-mm-yyyy h:m:s) | model | provider | account | tokens sent | tokens received | status
*/
export async function appendRequestLog ({ model , provider , connectionId , tokens , status }) {
2026-01-11 21:45:01 +07:00
if ( isCloud ) return ; // Skip logging in Workers
2026-01-09 17:40:59 +07:00
try {
const timestamp = formatLogDate ();
const p = provider ? . toUpperCase () || "-" ;
const m = model || "-" ;
// Resolve account name
let account = connectionId ? connectionId . slice ( 0 , 8 ) : "-" ;
try {
const { getProviderConnections } = await import ( "@/lib/localDb.js" );
const connections = await getProviderConnections ();
const conn = connections . find ( c => c . id === connectionId );
if ( conn ) {
account = conn . name || conn . email || account ;
}
} catch {}
const sent = tokens ? . prompt_tokens !== undefined ? tokens . prompt_tokens : "-" ;
const received = tokens ? . completion_tokens !== undefined ? tokens . completion_tokens : "-" ;
const line = ` ${ timestamp } | ${ m } | ${ p } | ${ account } | ${ sent } | ${ received } | ${ status } \n` ;
fs . appendFileSync ( LOG_FILE , line );
2026-01-16 12:39:03 +07:00
// Trim to keep only last 200 lines
const content = fs . readFileSync ( LOG_FILE , "utf-8" );
const lines = content . trim (). split ( "\n" );
if ( lines . length > 200 ) {
fs . writeFileSync ( LOG_FILE , lines . slice ( - 200 ). join ( "\n" ) + "\n" );
}
2026-01-09 17:40:59 +07:00
} catch ( error ) {
console . error ( "Failed to append to log.txt:" , error . message );
}
}
/**
* Get last N lines of log.txt
*/
export async function getRecentLogs ( limit = 200 ) {
2026-01-11 21:45:01 +07:00
if ( isCloud ) return []; // Skip in Workers
2026-02-05 11:06:20 +07:00
// Runtime check: ensure fs module is available
if ( ! fs || typeof fs . existsSync !== "function" ) {
console . error ( "[usageDb] fs module not available in this environment" );
return [];
}
if ( ! LOG_FILE ) {
console . error ( "[usageDb] LOG_FILE path not defined" );
return [];
}
if ( ! fs . existsSync ( LOG_FILE )) {
console . log ( `[usageDb] Log file does not exist: ${ LOG_FILE } ` );
return [];
}
2026-01-09 17:40:59 +07:00
try {
const content = fs . readFileSync ( LOG_FILE , "utf-8" );
const lines = content . trim (). split ( "\n" );
return lines . slice ( - limit ). reverse ();
} catch ( error ) {
2026-02-05 11:06:20 +07:00
console . error ( "[usageDb] Failed to read log.txt:" , error . message );
console . error ( "[usageDb] LOG_FILE path:" , LOG_FILE );
2026-01-09 17:40:59 +07:00
return [];
}
}
2026-01-06 23:59:49 +02:00
/**
* Calculate cost for a usage entry
* @param {string} provider - Provider ID
* @param {string} model - Model ID
* @param {object} tokens - Token counts
* @returns {number} Cost in dollars
*/
async function calculateCost ( provider , model , tokens ) {
if ( ! tokens || ! provider || ! model ) return 0 ;
try {
const { getPricingForModel } = await import ( "@/lib/localDb.js" );
const pricing = await getPricingForModel ( provider , model );
if ( ! pricing ) return 0 ;
let cost = 0 ;
// Input tokens (non-cached)
const inputTokens = tokens . prompt_tokens || tokens . input_tokens || 0 ;
const cachedTokens = tokens . cached_tokens || tokens . cache_read_input_tokens || 0 ;
const nonCachedInput = Math . max ( 0 , inputTokens - cachedTokens );
cost += ( nonCachedInput * ( pricing . input / 1000000 ));
// Cached tokens
if ( cachedTokens > 0 ) {
const cachedRate = pricing . cached || pricing . input ; // Fallback to input rate
cost += ( cachedTokens * ( cachedRate / 1000000 ));
}
// Output tokens
const outputTokens = tokens . completion_tokens || tokens . output_tokens || 0 ;
cost += ( outputTokens * ( pricing . output / 1000000 ));
// Reasoning tokens
const reasoningTokens = tokens . reasoning_tokens || 0 ;
if ( reasoningTokens > 0 ) {
const reasoningRate = pricing . reasoning || pricing . output ; // Fallback to output rate
cost += ( reasoningTokens * ( reasoningRate / 1000000 ));
}
// Cache creation tokens
const cacheCreationTokens = tokens . cache_creation_input_tokens || 0 ;
if ( cacheCreationTokens > 0 ) {
const cacheCreationRate = pricing . cache_creation || pricing . input ; // Fallback to input rate
cost += ( cacheCreationTokens * ( cacheCreationRate / 1000000 ));
}
return cost ;
} catch ( error ) {
console . error ( "Error calculating cost:" , error );
return 0 ;
}
}
2026-03-03 16:19:44 +07:00
const PERIOD_MS = { "24h" : 86400000 , "7d" : 604800000 , "30d" : 2592000000 , "60d" : 5184000000 };
2026-01-06 16:44:14 +02:00
/**
* Get aggregated usage stats
2026-03-03 16:19:44 +07:00
* @param {"24h"|"7d"|"30d"|"60d"|"all"} period - Time period to filter
2026-01-06 16:44:14 +02:00
*/
2026-03-03 16:19:44 +07:00
export async function getUsageStats ( period = "all" ) {
2026-01-06 16:44:14 +02:00
const db = await getUsageDb ();
2026-04-15 10:45:46 +07:00
const history = db . data . history || [];
const dailySummary = db . data . dailySummary || {};
2026-03-03 16:19:44 +07:00
2026-02-28 10:04:57 +07:00
const { getProviderConnections , getApiKeys , getProviderNodes } = await import ( "@/lib/localDb.js" );
2026-01-06 16:44:14 +02:00
let allConnections = [];
2026-04-15 10:45:46 +07:00
try { allConnections = await getProviderConnections (); } catch {}
2026-01-06 16:44:14 +02:00
const connectionMap = {};
for ( const conn of allConnections ) {
connectionMap [ conn . id ] = conn . name || conn . email || conn . id ;
}
2026-02-28 10:04:57 +07:00
const providerNodeNameMap = {};
try {
const nodes = await getProviderNodes ();
for ( const node of nodes ) {
if ( node . id && node . name ) providerNodeNameMap [ node . id ] = node . name ;
}
} catch {}
2026-02-11 15:44:08 +07:00
let allApiKeys = [];
2026-04-15 10:45:46 +07:00
try { allApiKeys = await getApiKeys (); } catch {}
2026-02-11 15:44:08 +07:00
const apiKeyMap = {};
for ( const key of allApiKeys ) {
2026-04-15 10:45:46 +07:00
apiKeyMap [ key . key ] = { name : key . name , id : key . id , createdAt : key . createdAt };
2026-02-11 15:44:08 +07:00
}
2026-04-15 10:45:46 +07:00
// Recent requests (always from live history)
2026-02-21 14:36:06 +07:00
const seen = new Set ();
const recentRequests = [... history ]
. sort (( a , b ) => new Date ( b . timestamp ) - new Date ( a . timestamp ))
. map (( e ) => {
const t = e . tokens || {};
return {
2026-04-15 10:45:46 +07:00
timestamp : e . timestamp , model : e . model , provider : e . provider || "" ,
promptTokens : t . prompt_tokens || t . input_tokens || 0 ,
completionTokens : t . completion_tokens || t . output_tokens || 0 ,
2026-02-21 14:36:06 +07:00
status : e . status || "ok" ,
};
})
. filter (( e ) => {
if ( e . promptTokens === 0 && e . completionTokens === 0 ) return false ;
const minute = e . timestamp ? e . timestamp . slice ( 0 , 16 ) : "" ;
const key = ` ${ e . model } | ${ e . provider } | ${ e . promptTokens } | ${ e . completionTokens } | ${ minute } ` ;
if ( seen . has ( key )) return false ;
seen . add ( key );
return true ;
})
. slice ( 0 , 20 );
2026-03-22 19:14:01 -07:00
const lifetimeTotalRequests = typeof db . data . totalRequestsLifetime === "number"
? db . data . totalRequestsLifetime
: history . length ;
2026-01-06 16:44:14 +02:00
const stats = {
2026-03-22 19:14:01 -07:00
totalRequests : lifetimeTotalRequests ,
2026-04-15 10:45:46 +07:00
totalPromptTokens : 0 , totalCompletionTokens : 0 , totalCost : 0 ,
byProvider : {}, byModel : {}, byAccount : {}, byApiKey : {}, byEndpoint : {},
2026-01-06 20:41:53 +02:00
last10Minutes : [],
pending : pendingRequests ,
2026-02-21 14:36:06 +07:00
activeRequests : [],
recentRequests ,
2026-02-21 16:42:46 +07:00
errorProvider : ( Date . now () - lastErrorProvider . ts < 10000 ) ? lastErrorProvider . provider : "" ,
2026-01-06 16:44:14 +02:00
};
2026-04-15 10:45:46 +07:00
// Active requests from pending
2026-01-06 20:41:53 +02:00
for ( const [ connectionId , models ] of Object . entries ( pendingRequests . byAccount )) {
for ( const [ modelKey , count ] of Object . entries ( models )) {
if ( count > 0 ) {
const accountName = connectionMap [ connectionId ] || `Account ${ connectionId . slice ( 0 , 8 ) } ...` ;
const match = modelKey . match ( /^(.*) \((.*)\)$/ );
stats . activeRequests . push ({
2026-04-15 10:45:46 +07:00
model : match ? match [ 1 ] : modelKey ,
provider : match ? match [ 2 ] : "unknown" ,
account : accountName , count ,
2026-01-06 20:41:53 +02:00
});
}
}
}
2026-04-15 10:45:46 +07:00
// last10Minutes — always from live history
2026-01-06 16:44:14 +02:00
const now = new Date ();
const currentMinuteStart = new Date ( Math . floor ( now . getTime () / 60000 ) * 60000 );
const tenMinutesAgo = new Date ( currentMinuteStart . getTime () - 9 * 60 * 1000 );
const bucketMap = {};
for ( let i = 0 ; i < 10 ; i ++ ) {
2026-04-15 10:45:46 +07:00
const bucketKey = currentMinuteStart . getTime () - ( 9 - i ) * 60 * 1000 ;
bucketMap [ bucketKey ] = { requests : 0 , promptTokens : 0 , completionTokens : 0 , cost : 0 };
2026-01-06 16:44:14 +02:00
stats . last10Minutes . push ( bucketMap [ bucketKey ]);
}
for ( const entry of history ) {
const entryTime = new Date ( entry . timestamp );
if ( entryTime >= tenMinutesAgo && entryTime <= now ) {
const entryMinuteStart = Math . floor ( entryTime . getTime () / 60000 ) * 60000 ;
if ( bucketMap [ entryMinuteStart ]) {
2026-04-15 10:45:46 +07:00
const pt = entry . tokens ? . prompt_tokens || 0 ;
const ct = entry . tokens ? . completion_tokens || 0 ;
2026-01-06 16:44:14 +02:00
bucketMap [ entryMinuteStart ]. requests ++ ;
2026-04-15 10:45:46 +07:00
bucketMap [ entryMinuteStart ]. promptTokens += pt ;
bucketMap [ entryMinuteStart ]. completionTokens += ct ;
bucketMap [ entryMinuteStart ]. cost += entry . cost || 0 ;
2026-01-06 16:44:14 +02:00
}
}
2026-04-15 10:45:46 +07:00
}
2026-01-06 16:44:14 +02:00
2026-04-15 10:45:46 +07:00
// Determine if we use dailySummary (7d/30d/60d/all) or live history (24h)
const useDailySummary = period !== "24h" ;
2026-01-06 16:44:14 +02:00
2026-04-15 10:45:46 +07:00
if ( useDailySummary ) {
// Collect relevant date keys
const periodDays = { "7d" : 7 , "30d" : 30 , "60d" : 60 };
const maxDays = periodDays [ period ] || null ; // null = all
const today = new Date ();
const dateKeys = Object . keys ( dailySummary ). filter (( dateKey ) => {
if ( ! maxDays ) return true ;
const parts = dateKey . split ( "-" );
const d = new Date ( Number ( parts [ 0 ]), Number ( parts [ 1 ]) - 1 , Number ( parts [ 2 ]));
const diffDays = Math . floor (( today . getTime () - d . getTime ()) / 86400000 );
return diffDays < maxDays ;
});
2026-01-06 16:44:14 +02:00
2026-04-15 10:45:46 +07:00
for ( const dateKey of dateKeys ) {
const day = dailySummary [ dateKey ];
stats . totalPromptTokens += day . promptTokens || 0 ;
stats . totalCompletionTokens += day . completionTokens || 0 ;
stats . totalCost += day . cost || 0 ;
2026-01-06 16:44:14 +02:00
2026-04-15 10:45:46 +07:00
// Merge byProvider
for ( const [ prov , pData ] of Object . entries ( day . byProvider || {})) {
if ( ! stats . byProvider [ prov ]) stats . byProvider [ prov ] = { requests : 0 , promptTokens : 0 , completionTokens : 0 , cost : 0 };
stats . byProvider [ prov ]. requests += pData . requests || 0 ;
stats . byProvider [ prov ]. promptTokens += pData . promptTokens || 0 ;
stats . byProvider [ prov ]. completionTokens += pData . completionTokens || 0 ;
stats . byProvider [ prov ]. cost += pData . cost || 0 ;
2026-01-06 16:44:14 +02:00
}
2026-04-15 10:45:46 +07:00
// Merge byModel (dailySummary key: "model|provider" → stats key: "model (provider)")
for ( const [ mk , mData ] of Object . entries ( day . byModel || {})) {
const rawModel = mData . rawModel || mk . split ( "|" )[ 0 ];
const provider = mData . provider || mk . split ( "|" )[ 1 ] || "" ;
const statsKey = provider ? ` ${ rawModel } ( ${ provider } )` : rawModel ;
const providerDisplayName = providerNodeNameMap [ provider ] || provider ;
if ( ! stats . byModel [ statsKey ]) {
stats . byModel [ statsKey ] = { requests : 0 , promptTokens : 0 , completionTokens : 0 , cost : 0 , rawModel , provider : providerDisplayName , lastUsed : dateKey };
}
stats . byModel [ statsKey ]. requests += mData . requests || 0 ;
stats . byModel [ statsKey ]. promptTokens += mData . promptTokens || 0 ;
stats . byModel [ statsKey ]. completionTokens += mData . completionTokens || 0 ;
stats . byModel [ statsKey ]. cost += mData . cost || 0 ;
if ( dateKey > ( stats . byModel [ statsKey ]. lastUsed || "" )) stats . byModel [ statsKey ]. lastUsed = dateKey ;
}
// Merge byAccount
for ( const [ connId , aData ] of Object . entries ( day . byAccount || {})) {
const accountName = connectionMap [ connId ] || `Account ${ connId . slice ( 0 , 8 ) } ...` ;
const rawModel = aData . rawModel || "" ;
const provider = aData . provider || "" ;
const providerDisplayName = providerNodeNameMap [ provider ] || provider ;
const accountKey = ` ${ rawModel } ( ${ provider } - ${ accountName } )` ;
if ( ! stats . byAccount [ accountKey ]) {
stats . byAccount [ accountKey ] = { requests : 0 , promptTokens : 0 , completionTokens : 0 , cost : 0 , rawModel , provider : providerDisplayName , connectionId : connId , accountName , lastUsed : dateKey };
}
stats . byAccount [ accountKey ]. requests += aData . requests || 0 ;
stats . byAccount [ accountKey ]. promptTokens += aData . promptTokens || 0 ;
stats . byAccount [ accountKey ]. completionTokens += aData . completionTokens || 0 ;
stats . byAccount [ accountKey ]. cost += aData . cost || 0 ;
if ( dateKey > ( stats . byAccount [ accountKey ]. lastUsed || "" )) stats . byAccount [ accountKey ]. lastUsed = dateKey ;
}
// Merge byApiKey
for ( const [ akKey , akData ] of Object . entries ( day . byApiKey || {})) {
const rawModel = akData . rawModel || "" ;
const provider = akData . provider || "" ;
const providerDisplayName = providerNodeNameMap [ provider ] || provider ;
const apiKeyVal = akData . apiKey ;
const keyInfo = apiKeyVal ? apiKeyMap [ apiKeyVal ] : null ;
const keyName = keyInfo ? . name || ( apiKeyVal ? apiKeyVal . slice ( 0 , 8 ) + "..." : "Local (No API Key)" );
const apiKeyKey = apiKeyVal || "local-no-key" ;
if ( ! stats . byApiKey [ akKey ]) {
stats . byApiKey [ akKey ] = { requests : 0 , promptTokens : 0 , completionTokens : 0 , cost : 0 , rawModel , provider : providerDisplayName , apiKey : apiKeyVal , keyName , apiKeyKey , lastUsed : dateKey };
}
stats . byApiKey [ akKey ]. requests += akData . requests || 0 ;
stats . byApiKey [ akKey ]. promptTokens += akData . promptTokens || 0 ;
stats . byApiKey [ akKey ]. completionTokens += akData . completionTokens || 0 ;
stats . byApiKey [ akKey ]. cost += akData . cost || 0 ;
if ( dateKey > ( stats . byApiKey [ akKey ]. lastUsed || "" )) stats . byApiKey [ akKey ]. lastUsed = dateKey ;
}
// Merge byEndpoint
for ( const [ epKey , epData ] of Object . entries ( day . byEndpoint || {})) {
const endpoint = epData . endpoint || epKey . split ( "|" )[ 0 ] || "Unknown" ;
const rawModel = epData . rawModel || "" ;
const provider = epData . provider || "" ;
const providerDisplayName = providerNodeNameMap [ provider ] || provider ;
if ( ! stats . byEndpoint [ epKey ]) {
stats . byEndpoint [ epKey ] = { requests : 0 , promptTokens : 0 , completionTokens : 0 , cost : 0 , endpoint , rawModel , provider : providerDisplayName , lastUsed : dateKey };
}
stats . byEndpoint [ epKey ]. requests += epData . requests || 0 ;
stats . byEndpoint [ epKey ]. promptTokens += epData . promptTokens || 0 ;
stats . byEndpoint [ epKey ]. completionTokens += epData . completionTokens || 0 ;
stats . byEndpoint [ epKey ]. cost += epData . cost || 0 ;
if ( dateKey > ( stats . byEndpoint [ epKey ]. lastUsed || "" )) stats . byEndpoint [ epKey ]. lastUsed = dateKey ;
2026-01-06 20:41:53 +02:00
}
2026-01-06 16:44:14 +02:00
}
2026-04-24 11:36:16 +07:00
// Overlay lastUsed with precise ISO timestamps from live history (dailySummary only has YYYY-MM-DD)
const overlayCutoff = maxDays ? Date . now () - maxDays * 86400000 : 0 ;
for ( const entry of history ) {
const ts = entry . timestamp ;
if ( ! ts || new Date ( ts ). getTime () < overlayCutoff ) continue ;
const modelKey = entry . provider ? ` ${ entry . model } ( ${ entry . provider } )` : entry . model ;
if ( stats . byModel [ modelKey ] && new Date ( ts ) > new Date ( stats . byModel [ modelKey ]. lastUsed )) {
stats . byModel [ modelKey ]. lastUsed = ts ;
}
if ( entry . connectionId ) {
const accountName = connectionMap [ entry . connectionId ] || `Account ${ entry . connectionId . slice ( 0 , 8 ) } ...` ;
const accountKey = ` ${ entry . model } ( ${ entry . provider } - ${ accountName } )` ;
if ( stats . byAccount [ accountKey ] && new Date ( ts ) > new Date ( stats . byAccount [ accountKey ]. lastUsed )) {
stats . byAccount [ accountKey ]. lastUsed = ts ;
}
}
const apiKeyKey = ( entry . apiKey && typeof entry . apiKey === "string" )
? ` ${ entry . apiKey } | ${ entry . model } | ${ entry . provider || "unknown" } `
: "local-no-key" ;
if ( stats . byApiKey [ apiKeyKey ] && new Date ( ts ) > new Date ( stats . byApiKey [ apiKeyKey ]. lastUsed )) {
stats . byApiKey [ apiKeyKey ]. lastUsed = ts ;
}
const endpoint = entry . endpoint || "Unknown" ;
const endpointKey = ` ${ endpoint } | ${ entry . model } | ${ entry . provider || "unknown" } ` ;
if ( stats . byEndpoint [ endpointKey ] && new Date ( ts ) > new Date ( stats . byEndpoint [ endpointKey ]. lastUsed )) {
stats . byEndpoint [ endpointKey ]. lastUsed = ts ;
}
}
2026-04-15 10:45:46 +07:00
} else {
// 24h: use live history (original logic)
const cutoff = Date . now () - PERIOD_MS [ "24h" ];
const filtered = history . filter (( e ) => new Date ( e . timestamp ). getTime () >= cutoff );
2026-02-11 15:44:08 +07:00
2026-04-15 10:45:46 +07:00
for ( const entry of filtered ) {
const promptTokens = entry . tokens ? . prompt_tokens || 0 ;
const completionTokens = entry . tokens ? . completion_tokens || 0 ;
const entryCost = entry . cost || 0 ;
const providerDisplayName = providerNodeNameMap [ entry . provider ] || entry . provider ;
2026-02-11 15:44:08 +07:00
2026-04-15 10:45:46 +07:00
stats . totalPromptTokens += promptTokens ;
stats . totalCompletionTokens += completionTokens ;
stats . totalCost += entryCost ;
// byProvider
if ( ! stats . byProvider [ entry . provider ]) stats . byProvider [ entry . provider ] = { requests : 0 , promptTokens : 0 , completionTokens : 0 , cost : 0 };
stats . byProvider [ entry . provider ]. requests ++ ;
stats . byProvider [ entry . provider ]. promptTokens += promptTokens ;
stats . byProvider [ entry . provider ]. completionTokens += completionTokens ;
stats . byProvider [ entry . provider ]. cost += entryCost ;
// byModel
const modelKey = entry . provider ? ` ${ entry . model } ( ${ entry . provider } )` : entry . model ;
if ( ! stats . byModel [ modelKey ]) {
stats . byModel [ modelKey ] = { requests : 0 , promptTokens : 0 , completionTokens : 0 , cost : 0 , rawModel : entry . model , provider : providerDisplayName , lastUsed : entry . timestamp };
2026-02-11 15:44:08 +07:00
}
2026-04-15 10:45:46 +07:00
stats . byModel [ modelKey ]. requests ++ ;
stats . byModel [ modelKey ]. promptTokens += promptTokens ;
stats . byModel [ modelKey ]. completionTokens += completionTokens ;
stats . byModel [ modelKey ]. cost += entryCost ;
if ( new Date ( entry . timestamp ) > new Date ( stats . byModel [ modelKey ]. lastUsed )) stats . byModel [ modelKey ]. lastUsed = entry . timestamp ;
2026-02-11 15:44:08 +07:00
2026-04-15 10:45:46 +07:00
// byAccount
if ( entry . connectionId ) {
const accountName = connectionMap [ entry . connectionId ] || `Account ${ entry . connectionId . slice ( 0 , 8 ) } ...` ;
const accountKey = ` ${ entry . model } ( ${ entry . provider } - ${ accountName } )` ;
if ( ! stats . byAccount [ accountKey ]) {
stats . byAccount [ accountKey ] = { requests : 0 , promptTokens : 0 , completionTokens : 0 , cost : 0 , rawModel : entry . model , provider : providerDisplayName , connectionId : entry . connectionId , accountName , lastUsed : entry . timestamp };
}
stats . byAccount [ accountKey ]. requests ++ ;
stats . byAccount [ accountKey ]. promptTokens += promptTokens ;
stats . byAccount [ accountKey ]. completionTokens += completionTokens ;
stats . byAccount [ accountKey ]. cost += entryCost ;
if ( new Date ( entry . timestamp ) > new Date ( stats . byAccount [ accountKey ]. lastUsed )) stats . byAccount [ accountKey ]. lastUsed = entry . timestamp ;
2026-02-11 15:44:08 +07:00
}
2026-04-15 10:45:46 +07:00
// byApiKey
if ( entry . apiKey && typeof entry . apiKey === "string" ) {
const keyInfo = apiKeyMap [ entry . apiKey ];
const keyName = keyInfo ? . name || entry . apiKey . slice ( 0 , 8 ) + "..." ;
const apiKeyModelKey = ` ${ entry . apiKey } | ${ entry . model } | ${ entry . provider || "unknown" } ` ;
if ( ! stats . byApiKey [ apiKeyModelKey ]) {
stats . byApiKey [ apiKeyModelKey ] = { requests : 0 , promptTokens : 0 , completionTokens : 0 , cost : 0 , rawModel : entry . model , provider : providerDisplayName , apiKey : entry . apiKey , keyName , apiKeyKey : entry . apiKey , lastUsed : entry . timestamp };
}
const ake = stats . byApiKey [ apiKeyModelKey ];
ake . requests ++ ; ake . promptTokens += promptTokens ; ake . completionTokens += completionTokens ; ake . cost += entryCost ;
if ( new Date ( entry . timestamp ) > new Date ( ake . lastUsed )) ake . lastUsed = entry . timestamp ;
} else {
if ( ! stats . byApiKey [ "local-no-key" ]) {
stats . byApiKey [ "local-no-key" ] = { requests : 0 , promptTokens : 0 , completionTokens : 0 , cost : 0 , rawModel : entry . model , provider : providerDisplayName , apiKey : null , keyName : "Local (No API Key)" , apiKeyKey : "local-no-key" , lastUsed : entry . timestamp };
}
const ake = stats . byApiKey [ "local-no-key" ];
ake . requests ++ ; ake . promptTokens += promptTokens ; ake . completionTokens += completionTokens ; ake . cost += entryCost ;
if ( new Date ( entry . timestamp ) > new Date ( ake . lastUsed )) ake . lastUsed = entry . timestamp ;
2026-02-11 15:44:08 +07:00
}
2026-02-20 15:03:18 +07:00
2026-04-15 10:45:46 +07:00
// byEndpoint
const endpoint = entry . endpoint || "Unknown" ;
const endpointModelKey = ` ${ endpoint } | ${ entry . model } | ${ entry . provider || "unknown" } ` ;
if ( ! stats . byEndpoint [ endpointModelKey ]) {
stats . byEndpoint [ endpointModelKey ] = { requests : 0 , promptTokens : 0 , completionTokens : 0 , cost : 0 , endpoint , rawModel : entry . model , provider : providerDisplayName , lastUsed : entry . timestamp };
}
const epe = stats . byEndpoint [ endpointModelKey ];
epe . requests ++ ; epe . promptTokens += promptTokens ; epe . completionTokens += completionTokens ; epe . cost += entryCost ;
if ( new Date ( entry . timestamp ) > new Date ( epe . lastUsed )) epe . lastUsed = entry . timestamp ;
2026-02-20 15:03:18 +07:00
}
2026-01-06 16:44:14 +02:00
}
return stats ;
}
2026-02-09 11:30:42 +08:00
2026-02-21 14:36:06 +07:00
/**
* Get time-series chart data for a given period
* @param {"24h"|"7d"|"30d"|"60d"} period
* @returns {Promise<Array<{label: string, tokens: number, cost: number}>>}
*/
export async function getChartData ( period = "7d" ) {
const db = await getUsageDb ();
const history = db . data . history || [];
2026-04-15 10:45:46 +07:00
const dailySummary = db . data . dailySummary || {};
2026-02-21 14:36:06 +07:00
const now = Date . now ();
2026-04-15 10:45:46 +07:00
// 24h: bucket by hour from live history
2026-02-21 14:36:06 +07:00
if ( period === "24h" ) {
2026-04-15 10:45:46 +07:00
const bucketCount = 24 ;
const bucketMs = 3600000 ;
const labelFn = ( ts ) => new Date ( ts ). toLocaleTimeString ( "en-US" , { hour : "2-digit" , minute : "2-digit" , hour12 : false });
const startTime = now - bucketCount * bucketMs ;
const buckets = Array . from ({ length : bucketCount }, ( _ , i ) => {
const ts = startTime + i * bucketMs ;
return { label : labelFn ( ts ), tokens : 0 , cost : 0 };
});
for ( const entry of history ) {
const entryTime = new Date ( entry . timestamp ). getTime ();
if ( entryTime < startTime || entryTime > now ) continue ;
const idx = Math . min ( Math . floor (( entryTime - startTime ) / bucketMs ), bucketCount - 1 );
buckets [ idx ]. tokens += ( entry . tokens ? . prompt_tokens || 0 ) + ( entry . tokens ? . completion_tokens || 0 );
buckets [ idx ]. cost += entry . cost || 0 ;
}
return buckets ;
2026-02-21 14:36:06 +07:00
}
2026-04-15 10:45:46 +07:00
// 7d/30d/60d: bucket by day from dailySummary (local dates)
const bucketCount = period === "7d" ? 7 : period === "30d" ? 30 : 60 ;
const today = new Date ();
const labelFn = ( d ) => d . toLocaleDateString ( "en-US" , { month : "short" , day : "numeric" });
2026-02-21 14:36:06 +07:00
const buckets = Array . from ({ length : bucketCount }, ( _ , i ) => {
2026-04-15 10:45:46 +07:00
const d = new Date ( today );
d . setDate ( d . getDate () - ( bucketCount - 1 - i ));
const dateKey = ` ${ d . getFullYear () } - ${ String ( d . getMonth () + 1 ). padStart ( 2 , "0" ) } - ${ String ( d . getDate ()). padStart ( 2 , "0" ) } ` ;
const dayData = dailySummary [ dateKey ];
return {
label : labelFn ( d ),
tokens : dayData ? ( dayData . promptTokens || 0 ) + ( dayData . completionTokens || 0 ) : 0 ,
cost : dayData ? ( dayData . cost || 0 ) : 0 ,
};
2026-02-21 14:36:06 +07:00
});
2026-04-15 10:45:46 +07:00
return buckets ;
2026-02-21 14:36:06 +07:00
}
2026-02-09 11:30:42 +08:00
// Re-export request details functions from new SQLite-based module
export { saveRequestDetail , getRequestDetails , getRequestDetailById } from "./requestDetailsDb.js" ;