2026-01-06 09:44:14 -05:00
import { Low } from "lowdb" ;
import { JSONFile } from "lowdb/node" ;
2026-02-21 02:36:06 -05:00
import { EventEmitter } from "events" ;
2026-01-06 09:44:14 -05:00
import path from "path" ;
import fs from "fs" ;
2026-04-15 23:58:35 -04:00
import { DATA _DIR } from "@/lib/dataDir.js" ;
2026-01-06 09:44:14 -05:00
2026-01-11 09:45:01 -05: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 09:44:14 -05:00
// Ensure data directory exists
2026-02-04 23:06:20 -05: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 09:44:14 -05:00
}
const defaultData = {
2026-03-22 22:14:01 -04:00
history : [ ] ,
2026-04-14 23:45:46 -04:00
totalRequestsLifetime : 0 ,
dailySummary : { } ,
2026-01-06 09:44:14 -05:00
} ;
2026-04-14 23:45:46 -04: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 09:44:14 -05:00
// Singleton instance
let dbInstance = null ;
2026-02-21 02:36:06 -05: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 04:42:46 -05: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 02:36:06 -05: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 13:41:53 -05:00
2026-04-04 03:40:49 -04: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 13:41:53 -05: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 04:42:46 -05:00
* @ param { boolean } [ error ] - true if ended with error
2026-01-06 13:41:53 -05:00
* /
2026-02-21 04:42:46 -05:00
export function trackPendingRequest ( model , provider , connectionId , started , error = false ) {
2026-01-06 13:41:53 -05:00
const modelKey = provider ? ` ${ model } ( ${ provider } ) ` : model ;
2026-04-04 03:40:49 -04:00
const timerKey = ` ${ connectionId } | ${ modelKey } ` ;
2026-01-06 13:41:53 -05: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 03:40:49 -04: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 13:41:53 -05:00
}
2026-02-21 02:36:06 -05:00
2026-02-21 04:42:46 -05:00
// Track error provider (auto-clears after 10s)
if ( ! started && error && provider ) {
lastErrorProvider . provider = provider . toLowerCase ( ) ;
lastErrorProvider . ts = Date . now ( ) ;
}
2026-03-01 21:31:16 -05: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 04:42:46 -05: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 13:41:53 -05:00
}
2026-01-06 09:44:14 -05:00
/ * *
* Get usage database instance ( singleton )
* /
export async function getUsageDb ( ) {
2026-01-11 09:45:01 -05: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 09:44:14 -05: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-14 23:45:46 -04:00
dbInstance . data = { ... defaultData } ;
2026-01-06 09:44:14 -05:00
await dbInstance . write ( ) ;
}
2026-04-14 23:45:46 -04: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 09:44:14 -05:00
}
return dbInstance ;
}
/ * *
* Save request usage
2026-02-11 03:44:08 -05:00
* @ param { object } entry - Usage entry { provider , model , tokens : { prompt _tokens , completion _tokens , ... } , connectionId ? , apiKey ? }
2026-01-06 09:44:14 -05:00
* /
export async function saveRequestUsage ( entry ) {
2026-01-11 09:45:01 -05:00
if ( isCloud ) return ; // Skip saving in Workers
2026-01-06 09:44:14 -05: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 22:14:01 -04:00
if ( typeof db . data . totalRequestsLifetime !== "number" ) {
db . data . totalRequestsLifetime = db . data . history . length ;
}
2026-01-06 09:44:14 -05:00
2026-02-21 02:36:06 -05:00
const entryCost = await calculateCost ( entry . provider , entry . model , entry . tokens ) ;
entry . cost = entryCost ;
2026-01-06 09:44:14 -05:00
db . data . history . push ( entry ) ;
2026-03-22 22:14:01 -04:00
db . data . totalRequestsLifetime += 1 ;
2026-01-06 09:44:14 -05:00
2026-04-14 23:45:46 -04:00
if ( ! db . data . dailySummary ) db . data . dailySummary = { } ;
aggregateEntryToDailySummary ( db . data . dailySummary , entry ) ;
2026-03-13 22:37:29 -04: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 09:44:14 -05:00
await db . write ( ) ;
2026-02-21 02:36:06 -05:00
statsEmitter . emit ( "update" ) ;
2026-01-06 09:44:14 -05: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 05:40:59 -05: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 09:45:01 -05:00
if ( isCloud ) return ; // Skip logging in Workers
2026-01-09 05:40:59 -05: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 00:39:03 -05: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 05:40:59 -05: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 09:45:01 -05:00
if ( isCloud ) return [ ] ; // Skip in Workers
2026-02-04 23:06:20 -05: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 05:40:59 -05: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-04 23:06:20 -05:00
console . error ( "[usageDb] Failed to read log.txt:" , error . message ) ;
console . error ( "[usageDb] LOG_FILE path:" , LOG _FILE ) ;
2026-01-09 05:40:59 -05:00
return [ ] ;
}
}
2026-01-06 16:59:49 -05: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 04:19:44 -05:00
const PERIOD _MS = { "24h" : 86400000 , "7d" : 604800000 , "30d" : 2592000000 , "60d" : 5184000000 } ;
2026-01-06 09:44:14 -05:00
/ * *
* Get aggregated usage stats
2026-03-03 04:19:44 -05:00
* @ param { "24h" | "7d" | "30d" | "60d" | "all" } period - Time period to filter
2026-01-06 09:44:14 -05:00
* /
2026-03-03 04:19:44 -05:00
export async function getUsageStats ( period = "all" ) {
2026-01-06 09:44:14 -05:00
const db = await getUsageDb ( ) ;
2026-04-14 23:45:46 -04:00
const history = db . data . history || [ ] ;
const dailySummary = db . data . dailySummary || { } ;
2026-01-06 09:44:14 -05:00
2026-02-27 22:04:57 -05:00
const { getProviderConnections , getApiKeys , getProviderNodes } = await import ( "@/lib/localDb.js" ) ;
2026-01-06 09:44:14 -05:00
let allConnections = [ ] ;
2026-04-14 23:45:46 -04:00
try { allConnections = await getProviderConnections ( ) ; } catch { }
2026-01-06 09:44:14 -05:00
const connectionMap = { } ;
for ( const conn of allConnections ) {
connectionMap [ conn . id ] = conn . name || conn . email || conn . id ;
}
2026-02-27 22:04:57 -05: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 03:44:08 -05:00
let allApiKeys = [ ] ;
2026-04-14 23:45:46 -04:00
try { allApiKeys = await getApiKeys ( ) ; } catch { }
2026-02-11 03:44:08 -05:00
const apiKeyMap = { } ;
for ( const key of allApiKeys ) {
2026-04-14 23:45:46 -04:00
apiKeyMap [ key . key ] = { name : key . name , id : key . id , createdAt : key . createdAt } ;
2026-02-11 03:44:08 -05:00
}
2026-04-14 23:45:46 -04:00
// Recent requests (always from live history)
2026-02-21 02:36:06 -05: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-14 23:45:46 -04: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 02:36:06 -05: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 22:14:01 -04:00
const lifetimeTotalRequests = typeof db . data . totalRequestsLifetime === "number"
? db . data . totalRequestsLifetime
: history . length ;
2026-01-06 09:44:14 -05:00
const stats = {
2026-03-22 22:14:01 -04:00
totalRequests : lifetimeTotalRequests ,
2026-04-14 23:45:46 -04:00
totalPromptTokens : 0 , totalCompletionTokens : 0 , totalCost : 0 ,
byProvider : { } , byModel : { } , byAccount : { } , byApiKey : { } , byEndpoint : { } ,
2026-01-06 13:41:53 -05:00
last10Minutes : [ ] ,
pending : pendingRequests ,
2026-02-21 02:36:06 -05:00
activeRequests : [ ] ,
recentRequests ,
2026-02-21 04:42:46 -05:00
errorProvider : ( Date . now ( ) - lastErrorProvider . ts < 10000 ) ? lastErrorProvider . provider : "" ,
2026-01-06 09:44:14 -05:00
} ;
2026-04-14 23:45:46 -04:00
// Active requests from pending
2026-01-06 13:41:53 -05: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-14 23:45:46 -04:00
model : match ? match [ 1 ] : modelKey ,
provider : match ? match [ 2 ] : "unknown" ,
account : accountName , count ,
2026-01-06 13:41:53 -05:00
} ) ;
}
}
}
2026-04-14 23:45:46 -04:00
// last10Minutes — always from live history
2026-01-06 09:44:14 -05: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-14 23:45:46 -04:00
const bucketKey = currentMinuteStart . getTime ( ) - ( 9 - i ) * 60 * 1000 ;
bucketMap [ bucketKey ] = { requests : 0 , promptTokens : 0 , completionTokens : 0 , cost : 0 } ;
2026-01-06 09:44:14 -05: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-14 23:45:46 -04:00
const pt = entry . tokens ? . prompt _tokens || 0 ;
const ct = entry . tokens ? . completion _tokens || 0 ;
2026-01-06 09:44:14 -05:00
bucketMap [ entryMinuteStart ] . requests ++ ;
2026-04-14 23:45:46 -04:00
bucketMap [ entryMinuteStart ] . promptTokens += pt ;
bucketMap [ entryMinuteStart ] . completionTokens += ct ;
bucketMap [ entryMinuteStart ] . cost += entry . cost || 0 ;
2026-01-06 09:44:14 -05:00
}
}
2026-04-14 23:45:46 -04:00
}
2026-01-06 09:44:14 -05:00
2026-04-14 23:45:46 -04:00
// Determine if we use dailySummary (7d/30d/60d/all) or live history (24h)
const useDailySummary = period !== "24h" ;
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 ;
} ) ;
for ( const dateKey of dateKeys ) {
const day = dailySummary [ dateKey ] ;
stats . totalPromptTokens += day . promptTokens || 0 ;
stats . totalCompletionTokens += day . completionTokens || 0 ;
stats . totalCost += day . cost || 0 ;
// 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 09:44:14 -05:00
2026-04-14 23:45:46 -04: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 ;
2026-01-06 09:44:14 -05:00
}
2026-04-14 23:45:46 -04:00
// 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 ;
2026-01-06 13:41:53 -05:00
}
2026-02-11 03:44:08 -05:00
2026-04-14 23:45:46 -04:00
// 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 ;
2026-02-11 03:44:08 -05:00
}
2026-04-14 23:45:46 -04:00
// 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-02-11 03:44:08 -05:00
}
2026-04-14 23:45:46 -04:00
}
2026-04-24 00:36:16 -04: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-14 23:45:46 -04: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 ) ;
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 ;
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 03:44:08 -05:00
}
2026-04-14 23:45:46 -04: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 ;
// 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 03:44:08 -05:00
}
2026-02-20 03:03:18 -05:00
2026-04-14 23:45:46 -04: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 ;
}
// 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 03:03:18 -05:00
}
2026-01-06 09:44:14 -05:00
}
return stats ;
}
Feature/ai observability dashboard (#79)
* feat: add AI request details feature with latency tracking
Add comprehensive request history and debugging capability to the Usage dashboard:
**Storage Layer** (usageDb.js):
- Add saveRequestDetail() for storing full request/response details
- Implement FIFO queue with 1000-record limit in request-details.json
- Auto-sanitize sensitive headers (authorization, api-key, cookie, token)
- Add getRequestDetails() with pagination and filtering support
- Add getRequestDetailById() for single record lookup
**Pipeline Integration** (chatCore.js):
- Track request start time and calculate total latency
- Record TTFT (Time To First Token) and total latency for all requests
- Capture full request details (messages, model, parameters)
- Save response content for non-streaming, mark streaming responses
- Handle error cases with detailed error information
- Async non-blocking saves to avoid impacting request performance
**API Layer** (/api/usage/request-details):
- GET endpoint with pagination (page, pageSize: 1-100)
- Filter by provider, model, connectionId, status, date range
- Returns { details: [...], pagination: {...} } format
**UI Components**:
- Drawer.js: Right slide-out panel with backdrop blur and ESC close
- Pagination.js: Full pagination with page size selector (10/20/50)
- RequestDetailsTab.js: Complete table view with filters and detail drawer
**Dashboard Integration**:
- Add "Details" tab to Usage page (4th tab after Overview/Logger/Limits)
- Table columns: Timestamp, Model, Provider, Input Tokens, Output Tokens, Latency (TTFT/Total), Action
- Provider filter dropdown (9 providers supported)
- Date range filters (start/end datetime)
- Click "Detail" button to view full request/response JSON in slide-out drawer
**Features**:
- Real-time latency monitoring (TTFT & Total)
- Complete request/response inspection for debugging
- Filterable and searchable request history
- Responsive design with mobile-friendly filters
- Data security with automatic header sanitization
- Performance: async saves don't block request pipeline
**Files Created/Modified**:
- src/lib/usageDb.js (modified)
- open-sse/handlers/chatCore.js (modified)
- src/app/api/usage/request-details/route.js (new)
- src/shared/components/Drawer.js (new)
- src/shared/components/Pagination.js (new)
- src/app/(dashboard)/dashboard/usage/components/RequestDetailsTab.js (new)
- src/app/(dashboard)/dashboard/usage/page.js (modified)
Closes: AI Observability Dashboard feature
* feat: enhance request details with full config and streaming content capture
Improve Request Details feature to capture comprehensive request parameters
and actual streaming response content:
**Request Configuration Enhancement** (chatCore.js):
- Add extractRequestConfig() helper function to capture all request parameters
- Include temperature controls: temperature, top_p, top_k
- Include token limits: max_tokens, max_completion_tokens
- Include thinking/reasoning modes: thinking, reasoning, enable_thinking
- Include OpenAI parameters: presence_penalty, frequency_penalty, seed, stop,
tools, tool_choice, response_format, n, logprobs, top_logprobs, logit_bias,
user, parallel_tool_calls, prediction, store, metadata
- Apply to all request types: non-streaming, streaming, and error cases
**Streaming Content Capture** (chatCore.js & stream.js):
- Add onStreamComplete callback mechanism to stream processors
- Accumulate content from all formats: OpenAI, Claude, Gemini
- Track content from delta.content, delta.reasoning_content, delta.text,
delta.thinking, and Gemini content.parts
- Save initial record with "[Streaming in progress...]" marker
- Update record with actual content when stream completes
- Include usage tokens when available from stream
**Files Modified**:
- open-sse/handlers/chatCore.js - extractRequestConfig() + streaming capture
- open-sse/utils/stream.js - onStreamComplete callback + content accumulation
**Benefits**:
- View complete request configuration in Request Details (thinking mode, etc.)
- See actual streaming response content instead of placeholder
- Better debugging and observability for AI requests
Refs: #request-details-enhancement
* feat: separate thinking/reasoning content from response content
Improve Request Details to display thinking process separately from final response:
**Backend Changes**:
- stream.js: Capture content and thinking separately in streaming mode
- Add accumulatedThinking variable alongside accumulatedContent
- Route delta.content to content, delta.reasoning_content to thinking
- Support OpenAI (reasoning_content), Claude (thinking), Gemini (part.thought)
- Update onStreamComplete callback to return { content, thinking } object
- chatCore.js: Update response structure to include thinking field
- Non-streaming: Extract thinking from reasoning_content field
- Streaming: Receive { content, thinking } from stream callback
- Error responses: Include thinking: null
- Initial streaming save: Include thinking: null
**Frontend Changes**:
- RequestDetailsTab.js: Display thinking and content in separate sections
- Add amber/yellow themed "Thinking Process" section with psychology icon
- Show "Final Response" label when thinking is present
- Use distinct visual styling for thinking (amber bg) vs content (gray bg)
- Only show thinking section when thinking content exists
**Benefits**:
- Users can clearly see model's reasoning process vs final answer
- Better debugging for models with thinking capabilities (Claude, o1, etc.)
- Visual distinction makes it easy to identify thinking vs response
Refs: #thinking-content-separation
* fix: map Claude thinking to reasoning_content field
Fix Claude thinking content to be properly captured as reasoning_content
instead of regular content, enabling separate display in Request Details:
**Changes**:
- claude-to-openai.js: Use reasoning_content field for thinking blocks
- thinking start: send { reasoning_content: "" } instead of { content: "```\n```" }
- thinking delta: map to reasoning_content instead of content
- thinking stop: send { reasoning_content: "" } instead of { content: "```\n```" }
**Why This Matters**:
- Previously Claude thinking was sent as `content` field, mixed with actual response
- Now thinking uses `reasoning_content` field, matching OpenAI's o1 format
- stream.js can now properly route thinking to accumulatedThinking variable
- Request Details UI will show Claude thinking in separate "Thinking Process" section
**Supported Thinking Formats**:
- OpenAI: delta.reasoning_content → thinking
- Claude: delta.thinking → reasoning_content (now fixed)
- Gemini: part.thought === true → thinking
Refs: #claude-thinking-fix
* feat(observability): capture and display full 4-layer request chain
Capture complete request/response chain in AI Request Details:
- Add providerRequest field (translated request sent to provider)
- Add providerResponse field (raw provider response, streaming indicator)
- Update chatCore.js at all 5 saveRequestDetail() call sites
- Reorganize UI into 4 collapsible sections with Material icons
- Preserve backward compatibility for old records
- Add distinct styling for streaming indicator
* fix(observability): resolve React duplicate key warning in request details table
- Use composite key (detail.id + index) to ensure unique keys
- Prevents React warnings when database contains duplicate IDs from old ID generation
* fix(observability): display actual content in streaming request details
Change providerResponse field for streaming requests from placeholder
"[Streaming - raw response not captured]" to actual final content.
This improves debugging experience by showing the real AI response
in the "Provider Response (Raw)" section instead of a confusing
placeholder message.
Files changed:
- open-sse/handlers/chatCore.js: Save contentObj.content to providerResponse
- src/app/.../RequestDetailsTab.js: Remove special handling for placeholder
* refactor(observability): migrate request details to SQLite for improved concurrency
- Replace LowDB JSON storage with better-sqlite3
- Enable WAL mode for true concurrent read/write support
- Add 5 indexes to accelerate queries (timestamp, provider, model, connection_id, status)
- Perform pagination at the database level to reduce memory footprint
- Maintain 1000 record limit with automatic cleanup of old data
- Ensure API compatibility via re-exports, requiring no caller changes
Performance improvements:
- Concurrent Writes: Lock-free WAL mode prevents data contention
- Query Efficiency: Index-based searches replace full dataset loading
- Data Integrity: Atomic operations prevent file corruption
* fix(observability): resolve pagination statistics display issues
- Fix issue where totalItems=0 showed 'Showing 1 to 0 of 0 results'
- Hide pagination controls when totalItems=0 or totalPages<=1
- Standardize API response fields: pagination.total -> pagination.totalItems
Before: Incorrect stats shown for empty data, and pager visible even for single-page results
After: Stats hidden for empty data, pager hidden when navigation is unnecessary
* feat(observability): display friendly provider names in request details
- Add /api/usage/providers endpoint to dynamically fetch provider list with names
- Replace hardcoded provider options with dynamic loading from database
- Display friendly provider names instead of IDs in both table and detail drawer
- Support custom provider nodes (e.g., OpenAI-compatible) with user-defined names
- Add provider name caching to optimize performance
* fix(observability): use INSERT OR REPLACE for request details to handle streaming updates
* fix(observability): resolve zero-token display issue by ensuring streaming usage capture and fixing key mismatch
* fix(observability): separate TTFT and total latency calculation for streaming requests
* feat(observability): implement SQLite write queue and JSON size limits
- Added in-memory buffer and batch writing for SQLite to prevent lock contention
- Implemented with configurable 1MB limit to prevent DB bloat
- Added dashboard UI for observability performance and data management settings
- Integrated graceful shutdown handlers to prevent data loss
* fix(observability): resolve ReferenceError by declaring dbInstance
2026-02-08 22:30:42 -05:00
2026-02-21 02:36:06 -05: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-14 23:45:46 -04:00
const dailySummary = db . data . dailySummary || { } ;
2026-02-21 02:36:06 -05:00
const now = Date . now ( ) ;
2026-04-14 23:45:46 -04:00
// 24h: bucket by hour from live history
2026-02-21 02:36:06 -05:00
if ( period === "24h" ) {
2026-04-14 23:45:46 -04: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 02:36:06 -05:00
}
2026-04-14 23:45:46 -04: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 02:36:06 -05:00
const buckets = Array . from ( { length : bucketCount } , ( _ , i ) => {
2026-04-14 23:45:46 -04: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 02:36:06 -05:00
} ) ;
2026-04-14 23:45:46 -04:00
return buckets ;
2026-02-21 02:36:06 -05:00
}
Feature/ai observability dashboard (#79)
* feat: add AI request details feature with latency tracking
Add comprehensive request history and debugging capability to the Usage dashboard:
**Storage Layer** (usageDb.js):
- Add saveRequestDetail() for storing full request/response details
- Implement FIFO queue with 1000-record limit in request-details.json
- Auto-sanitize sensitive headers (authorization, api-key, cookie, token)
- Add getRequestDetails() with pagination and filtering support
- Add getRequestDetailById() for single record lookup
**Pipeline Integration** (chatCore.js):
- Track request start time and calculate total latency
- Record TTFT (Time To First Token) and total latency for all requests
- Capture full request details (messages, model, parameters)
- Save response content for non-streaming, mark streaming responses
- Handle error cases with detailed error information
- Async non-blocking saves to avoid impacting request performance
**API Layer** (/api/usage/request-details):
- GET endpoint with pagination (page, pageSize: 1-100)
- Filter by provider, model, connectionId, status, date range
- Returns { details: [...], pagination: {...} } format
**UI Components**:
- Drawer.js: Right slide-out panel with backdrop blur and ESC close
- Pagination.js: Full pagination with page size selector (10/20/50)
- RequestDetailsTab.js: Complete table view with filters and detail drawer
**Dashboard Integration**:
- Add "Details" tab to Usage page (4th tab after Overview/Logger/Limits)
- Table columns: Timestamp, Model, Provider, Input Tokens, Output Tokens, Latency (TTFT/Total), Action
- Provider filter dropdown (9 providers supported)
- Date range filters (start/end datetime)
- Click "Detail" button to view full request/response JSON in slide-out drawer
**Features**:
- Real-time latency monitoring (TTFT & Total)
- Complete request/response inspection for debugging
- Filterable and searchable request history
- Responsive design with mobile-friendly filters
- Data security with automatic header sanitization
- Performance: async saves don't block request pipeline
**Files Created/Modified**:
- src/lib/usageDb.js (modified)
- open-sse/handlers/chatCore.js (modified)
- src/app/api/usage/request-details/route.js (new)
- src/shared/components/Drawer.js (new)
- src/shared/components/Pagination.js (new)
- src/app/(dashboard)/dashboard/usage/components/RequestDetailsTab.js (new)
- src/app/(dashboard)/dashboard/usage/page.js (modified)
Closes: AI Observability Dashboard feature
* feat: enhance request details with full config and streaming content capture
Improve Request Details feature to capture comprehensive request parameters
and actual streaming response content:
**Request Configuration Enhancement** (chatCore.js):
- Add extractRequestConfig() helper function to capture all request parameters
- Include temperature controls: temperature, top_p, top_k
- Include token limits: max_tokens, max_completion_tokens
- Include thinking/reasoning modes: thinking, reasoning, enable_thinking
- Include OpenAI parameters: presence_penalty, frequency_penalty, seed, stop,
tools, tool_choice, response_format, n, logprobs, top_logprobs, logit_bias,
user, parallel_tool_calls, prediction, store, metadata
- Apply to all request types: non-streaming, streaming, and error cases
**Streaming Content Capture** (chatCore.js & stream.js):
- Add onStreamComplete callback mechanism to stream processors
- Accumulate content from all formats: OpenAI, Claude, Gemini
- Track content from delta.content, delta.reasoning_content, delta.text,
delta.thinking, and Gemini content.parts
- Save initial record with "[Streaming in progress...]" marker
- Update record with actual content when stream completes
- Include usage tokens when available from stream
**Files Modified**:
- open-sse/handlers/chatCore.js - extractRequestConfig() + streaming capture
- open-sse/utils/stream.js - onStreamComplete callback + content accumulation
**Benefits**:
- View complete request configuration in Request Details (thinking mode, etc.)
- See actual streaming response content instead of placeholder
- Better debugging and observability for AI requests
Refs: #request-details-enhancement
* feat: separate thinking/reasoning content from response content
Improve Request Details to display thinking process separately from final response:
**Backend Changes**:
- stream.js: Capture content and thinking separately in streaming mode
- Add accumulatedThinking variable alongside accumulatedContent
- Route delta.content to content, delta.reasoning_content to thinking
- Support OpenAI (reasoning_content), Claude (thinking), Gemini (part.thought)
- Update onStreamComplete callback to return { content, thinking } object
- chatCore.js: Update response structure to include thinking field
- Non-streaming: Extract thinking from reasoning_content field
- Streaming: Receive { content, thinking } from stream callback
- Error responses: Include thinking: null
- Initial streaming save: Include thinking: null
**Frontend Changes**:
- RequestDetailsTab.js: Display thinking and content in separate sections
- Add amber/yellow themed "Thinking Process" section with psychology icon
- Show "Final Response" label when thinking is present
- Use distinct visual styling for thinking (amber bg) vs content (gray bg)
- Only show thinking section when thinking content exists
**Benefits**:
- Users can clearly see model's reasoning process vs final answer
- Better debugging for models with thinking capabilities (Claude, o1, etc.)
- Visual distinction makes it easy to identify thinking vs response
Refs: #thinking-content-separation
* fix: map Claude thinking to reasoning_content field
Fix Claude thinking content to be properly captured as reasoning_content
instead of regular content, enabling separate display in Request Details:
**Changes**:
- claude-to-openai.js: Use reasoning_content field for thinking blocks
- thinking start: send { reasoning_content: "" } instead of { content: "```\n```" }
- thinking delta: map to reasoning_content instead of content
- thinking stop: send { reasoning_content: "" } instead of { content: "```\n```" }
**Why This Matters**:
- Previously Claude thinking was sent as `content` field, mixed with actual response
- Now thinking uses `reasoning_content` field, matching OpenAI's o1 format
- stream.js can now properly route thinking to accumulatedThinking variable
- Request Details UI will show Claude thinking in separate "Thinking Process" section
**Supported Thinking Formats**:
- OpenAI: delta.reasoning_content → thinking
- Claude: delta.thinking → reasoning_content (now fixed)
- Gemini: part.thought === true → thinking
Refs: #claude-thinking-fix
* feat(observability): capture and display full 4-layer request chain
Capture complete request/response chain in AI Request Details:
- Add providerRequest field (translated request sent to provider)
- Add providerResponse field (raw provider response, streaming indicator)
- Update chatCore.js at all 5 saveRequestDetail() call sites
- Reorganize UI into 4 collapsible sections with Material icons
- Preserve backward compatibility for old records
- Add distinct styling for streaming indicator
* fix(observability): resolve React duplicate key warning in request details table
- Use composite key (detail.id + index) to ensure unique keys
- Prevents React warnings when database contains duplicate IDs from old ID generation
* fix(observability): display actual content in streaming request details
Change providerResponse field for streaming requests from placeholder
"[Streaming - raw response not captured]" to actual final content.
This improves debugging experience by showing the real AI response
in the "Provider Response (Raw)" section instead of a confusing
placeholder message.
Files changed:
- open-sse/handlers/chatCore.js: Save contentObj.content to providerResponse
- src/app/.../RequestDetailsTab.js: Remove special handling for placeholder
* refactor(observability): migrate request details to SQLite for improved concurrency
- Replace LowDB JSON storage with better-sqlite3
- Enable WAL mode for true concurrent read/write support
- Add 5 indexes to accelerate queries (timestamp, provider, model, connection_id, status)
- Perform pagination at the database level to reduce memory footprint
- Maintain 1000 record limit with automatic cleanup of old data
- Ensure API compatibility via re-exports, requiring no caller changes
Performance improvements:
- Concurrent Writes: Lock-free WAL mode prevents data contention
- Query Efficiency: Index-based searches replace full dataset loading
- Data Integrity: Atomic operations prevent file corruption
* fix(observability): resolve pagination statistics display issues
- Fix issue where totalItems=0 showed 'Showing 1 to 0 of 0 results'
- Hide pagination controls when totalItems=0 or totalPages<=1
- Standardize API response fields: pagination.total -> pagination.totalItems
Before: Incorrect stats shown for empty data, and pager visible even for single-page results
After: Stats hidden for empty data, pager hidden when navigation is unnecessary
* feat(observability): display friendly provider names in request details
- Add /api/usage/providers endpoint to dynamically fetch provider list with names
- Replace hardcoded provider options with dynamic loading from database
- Display friendly provider names instead of IDs in both table and detail drawer
- Support custom provider nodes (e.g., OpenAI-compatible) with user-defined names
- Add provider name caching to optimize performance
* fix(observability): use INSERT OR REPLACE for request details to handle streaming updates
* fix(observability): resolve zero-token display issue by ensuring streaming usage capture and fixing key mismatch
* fix(observability): separate TTFT and total latency calculation for streaming requests
* feat(observability): implement SQLite write queue and JSON size limits
- Added in-memory buffer and batch writing for SQLite to prevent lock contention
- Implemented with configurable 1MB limit to prevent DB bloat
- Added dashboard UI for observability performance and data management settings
- Integrated graceful shutdown handlers to prevent data loss
* fix(observability): resolve ReferenceError by declaring dbInstance
2026-02-08 22:30:42 -05:00
// Re-export request details functions from new SQLite-based module
export { saveRequestDetail , getRequestDetails , getRequestDetailById } from "./requestDetailsDb.js" ;