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 os from "os";
|
|
|
|
|
import fs from "fs";
|
|
|
|
|
import { fileURLToPath } from "url";
|
|
|
|
|
|
2026-01-11 09:45:01 -05:00
|
|
|
const isCloud = typeof caches !== 'undefined' || typeof caches === 'object';
|
|
|
|
|
|
2026-01-06 09:44:14 -05:00
|
|
|
// Get app name from root package.json config
|
|
|
|
|
function getAppName() {
|
2026-01-11 09:45:01 -05:00
|
|
|
if (isCloud) return "9router"; // Skip file system access in Workers
|
|
|
|
|
|
2026-01-06 09:44:14 -05:00
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
|
|
|
// Look for root package.json (monorepo root)
|
|
|
|
|
const rootPkgPath = path.resolve(__dirname, "../../../package.json");
|
|
|
|
|
try {
|
|
|
|
|
const pkg = JSON.parse(fs.readFileSync(rootPkgPath, "utf-8"));
|
|
|
|
|
return pkg.config?.appName || "9router";
|
|
|
|
|
} catch {
|
|
|
|
|
return "9router";
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Get user data directory based on platform
|
|
|
|
|
function getUserDataDir() {
|
2026-01-11 09:45:01 -05:00
|
|
|
if (isCloud) return "/tmp"; // Fallback for Workers
|
|
|
|
|
|
2026-02-18 01:53:31 -05:00
|
|
|
if (process.env.DATA_DIR) return process.env.DATA_DIR;
|
|
|
|
|
|
2026-02-04 23:06:20 -05:00
|
|
|
try {
|
|
|
|
|
const platform = process.platform;
|
|
|
|
|
const homeDir = os.homedir();
|
|
|
|
|
const appName = getAppName();
|
|
|
|
|
|
|
|
|
|
if (platform === "win32") {
|
|
|
|
|
return path.join(process.env.APPDATA || path.join(homeDir, "AppData", "Roaming"), appName);
|
|
|
|
|
} else {
|
|
|
|
|
// macOS & Linux: ~/.{appName}
|
|
|
|
|
return path.join(homeDir, `.${appName}`);
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("[usageDb] Failed to get user data directory:", error.message);
|
|
|
|
|
// Fallback to cwd if homedir fails
|
|
|
|
|
return path.join(process.cwd(), ".9router");
|
2026-01-06 09:44:14 -05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Data file path - stored in user home directory
|
|
|
|
|
const DATA_DIR = getUserDataDir();
|
2026-01-11 09:45:01 -05:00
|
|
|
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
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Default data structure
|
|
|
|
|
const defaultData = {
|
2026-03-22 22:14:01 -04:00
|
|
|
history: [],
|
|
|
|
|
totalRequestsLifetime: 0
|
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
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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;
|
|
|
|
|
|
|
|
|
|
// 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-02-21 02:36:06 -05:00
|
|
|
const accountKey = connectionId;
|
2026-01-06 13:41:53 -05:00
|
|
|
if (!pendingRequests.byAccount[accountKey]) pendingRequests.byAccount[accountKey] = {};
|
|
|
|
|
if (!pendingRequests.byAccount[accountKey][modelKey]) pendingRequests.byAccount[accountKey][modelKey] = 0;
|
|
|
|
|
pendingRequests.byAccount[accountKey][modelKey] = Math.max(0, pendingRequests.byAccount[accountKey][modelKey] + (started ? 1 : -1));
|
|
|
|
|
}
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Initialize with default data if empty
|
|
|
|
|
if (!dbInstance.data) {
|
|
|
|
|
dbInstance.data = defaultData;
|
|
|
|
|
await dbInstance.write();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
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-03-13 22:37:29 -04:00
|
|
|
// Cap history to prevent unbounded memory/disk growth
|
|
|
|
|
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-03-03 04:19:44 -05:00
|
|
|
let history = db.data.history || [];
|
|
|
|
|
|
|
|
|
|
// Filter history by period
|
|
|
|
|
if (period && PERIOD_MS[period]) {
|
|
|
|
|
const cutoff = Date.now() - PERIOD_MS[period];
|
|
|
|
|
history = history.filter((e) => new Date(e.timestamp).getTime() >= cutoff);
|
|
|
|
|
}
|
2026-01-06 09:44:14 -05:00
|
|
|
|
2026-02-11 03:44:08 -05:00
|
|
|
// Import localDb to get provider connection names and API keys
|
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
|
|
|
|
|
|
|
|
// Fetch all provider connections to get account names
|
|
|
|
|
let allConnections = [];
|
|
|
|
|
try {
|
|
|
|
|
allConnections = await getProviderConnections();
|
|
|
|
|
} catch (error) {
|
|
|
|
|
// If localDb is not available (e.g., in some environments), continue without account names
|
|
|
|
|
console.warn("Could not fetch provider connections for usage stats:", error.message);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Create a map from connectionId to account name
|
|
|
|
|
const connectionMap = {};
|
|
|
|
|
for (const conn of allConnections) {
|
|
|
|
|
connectionMap[conn.id] = conn.name || conn.email || conn.id;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-27 22:04:57 -05:00
|
|
|
// Build map from compatible provider ID → friendly name (from providerNodes)
|
|
|
|
|
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
|
|
|
// Fetch all API keys to get key names
|
|
|
|
|
let allApiKeys = [];
|
|
|
|
|
try {
|
|
|
|
|
allApiKeys = await getApiKeys();
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.warn("Could not fetch API keys for usage stats:", error.message);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Create a map from API key to key info
|
|
|
|
|
const apiKeyMap = {};
|
|
|
|
|
for (const key of allApiKeys) {
|
|
|
|
|
apiKeyMap[key.key] = {
|
|
|
|
|
name: key.name,
|
|
|
|
|
id: key.id,
|
|
|
|
|
createdAt: key.createdAt
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 02:36:06 -05:00
|
|
|
// 20 most recent requests from history (always in sync with SSE emit)
|
|
|
|
|
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;
|
|
|
|
|
// Deduplicate: same model+provider+tokens within same minute
|
|
|
|
|
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-01-06 09:44:14 -05:00
|
|
|
totalPromptTokens: 0,
|
|
|
|
|
totalCompletionTokens: 0,
|
2026-02-11 03:44:08 -05:00
|
|
|
totalCost: 0,
|
2026-01-06 09:44:14 -05:00
|
|
|
byProvider: {},
|
|
|
|
|
byModel: {},
|
|
|
|
|
byAccount: {},
|
2026-02-11 03:44:08 -05:00
|
|
|
byApiKey: {},
|
2026-02-20 03:03:18 -05:00
|
|
|
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-01-06 13:41:53 -05:00
|
|
|
// Build active requests list from pending counts
|
|
|
|
|
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)}...`;
|
|
|
|
|
// modelKey is "model (provider)"
|
|
|
|
|
const match = modelKey.match(/^(.*) \((.*)\)$/);
|
|
|
|
|
const modelName = match ? match[1] : modelKey;
|
|
|
|
|
const providerName = match ? match[2] : "unknown";
|
|
|
|
|
|
|
|
|
|
stats.activeRequests.push({
|
|
|
|
|
model: modelName,
|
|
|
|
|
provider: providerName,
|
|
|
|
|
account: accountName,
|
|
|
|
|
count
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-06 09:44:14 -05:00
|
|
|
// Initialize 10-minute buckets using stable minute boundaries
|
|
|
|
|
const now = new Date();
|
|
|
|
|
// Floor to the start of the current minute
|
|
|
|
|
const currentMinuteStart = new Date(Math.floor(now.getTime() / 60000) * 60000);
|
|
|
|
|
const tenMinutesAgo = new Date(currentMinuteStart.getTime() - 9 * 60 * 1000);
|
|
|
|
|
|
|
|
|
|
// Create buckets keyed by minute timestamp for stable lookups
|
|
|
|
|
const bucketMap = {};
|
|
|
|
|
for (let i = 0; i < 10; i++) {
|
|
|
|
|
const bucketTime = new Date(currentMinuteStart.getTime() - (9 - i) * 60 * 1000);
|
|
|
|
|
const bucketKey = bucketTime.getTime();
|
|
|
|
|
bucketMap[bucketKey] = {
|
|
|
|
|
requests: 0,
|
|
|
|
|
promptTokens: 0,
|
2026-01-06 16:59:49 -05:00
|
|
|
completionTokens: 0,
|
|
|
|
|
cost: 0
|
2026-01-06 09:44:14 -05:00
|
|
|
};
|
|
|
|
|
stats.last10Minutes.push(bucketMap[bucketKey]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const entry of history) {
|
|
|
|
|
const promptTokens = entry.tokens?.prompt_tokens || 0;
|
|
|
|
|
const completionTokens = entry.tokens?.completion_tokens || 0;
|
|
|
|
|
const entryTime = new Date(entry.timestamp);
|
|
|
|
|
|
2026-03-03 04:19:44 -05:00
|
|
|
// Use pre-stored cost (saved at request time), avoid recalculating
|
|
|
|
|
const entryCost = entry.cost || 0;
|
2026-01-06 16:59:49 -05:00
|
|
|
|
2026-01-06 09:44:14 -05:00
|
|
|
stats.totalPromptTokens += promptTokens;
|
|
|
|
|
stats.totalCompletionTokens += completionTokens;
|
2026-01-06 16:59:49 -05:00
|
|
|
stats.totalCost += entryCost;
|
2026-01-06 09:44:14 -05:00
|
|
|
|
|
|
|
|
// Last 10 minutes aggregation - floor entry time to its minute
|
|
|
|
|
if (entryTime >= tenMinutesAgo && entryTime <= now) {
|
|
|
|
|
const entryMinuteStart = Math.floor(entryTime.getTime() / 60000) * 60000;
|
|
|
|
|
if (bucketMap[entryMinuteStart]) {
|
|
|
|
|
bucketMap[entryMinuteStart].requests++;
|
|
|
|
|
bucketMap[entryMinuteStart].promptTokens += promptTokens;
|
|
|
|
|
bucketMap[entryMinuteStart].completionTokens += completionTokens;
|
2026-01-06 16:59:49 -05:00
|
|
|
bucketMap[entryMinuteStart].cost += entryCost;
|
2026-01-06 09:44:14 -05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// By Provider
|
|
|
|
|
if (!stats.byProvider[entry.provider]) {
|
|
|
|
|
stats.byProvider[entry.provider] = {
|
|
|
|
|
requests: 0,
|
|
|
|
|
promptTokens: 0,
|
2026-01-06 16:59:49 -05:00
|
|
|
completionTokens: 0,
|
|
|
|
|
cost: 0
|
2026-01-06 09:44:14 -05:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
stats.byProvider[entry.provider].requests++;
|
|
|
|
|
stats.byProvider[entry.provider].promptTokens += promptTokens;
|
|
|
|
|
stats.byProvider[entry.provider].completionTokens += completionTokens;
|
2026-01-06 16:59:49 -05:00
|
|
|
stats.byProvider[entry.provider].cost += entryCost;
|
2026-01-06 09:44:14 -05:00
|
|
|
|
|
|
|
|
// By Model
|
|
|
|
|
// Format: "modelName (provider)" if provider is known
|
|
|
|
|
const modelKey = entry.provider ? `${entry.model} (${entry.provider})` : entry.model;
|
2026-02-27 22:04:57 -05:00
|
|
|
// Resolve friendly name for compatible providers
|
|
|
|
|
const providerDisplayName = providerNodeNameMap[entry.provider] || entry.provider;
|
2026-01-06 09:44:14 -05:00
|
|
|
|
|
|
|
|
if (!stats.byModel[modelKey]) {
|
|
|
|
|
stats.byModel[modelKey] = {
|
|
|
|
|
requests: 0,
|
|
|
|
|
promptTokens: 0,
|
|
|
|
|
completionTokens: 0,
|
2026-01-06 16:59:49 -05:00
|
|
|
cost: 0,
|
2026-01-06 09:44:14 -05:00
|
|
|
rawModel: entry.model,
|
2026-02-27 22:04:57 -05:00
|
|
|
provider: providerDisplayName,
|
2026-01-06 13:41:53 -05:00
|
|
|
lastUsed: entry.timestamp
|
2026-01-06 09:44:14 -05:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
stats.byModel[modelKey].requests++;
|
|
|
|
|
stats.byModel[modelKey].promptTokens += promptTokens;
|
|
|
|
|
stats.byModel[modelKey].completionTokens += completionTokens;
|
2026-01-06 16:59:49 -05:00
|
|
|
stats.byModel[modelKey].cost += entryCost;
|
2026-01-06 13:41:53 -05:00
|
|
|
if (new Date(entry.timestamp) > new Date(stats.byModel[modelKey].lastUsed)) {
|
|
|
|
|
stats.byModel[modelKey].lastUsed = entry.timestamp;
|
|
|
|
|
}
|
2026-01-06 09:44:14 -05:00
|
|
|
|
|
|
|
|
// By Account (model + oauth account)
|
|
|
|
|
// Use connectionId if available, otherwise fallback to provider name
|
|
|
|
|
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,
|
2026-01-06 16:59:49 -05:00
|
|
|
cost: 0,
|
2026-01-06 09:44:14 -05:00
|
|
|
rawModel: entry.model,
|
2026-02-27 22:04:57 -05:00
|
|
|
provider: providerDisplayName,
|
2026-01-06 09:44:14 -05:00
|
|
|
connectionId: entry.connectionId,
|
2026-01-06 13:41:53 -05:00
|
|
|
accountName: accountName,
|
|
|
|
|
lastUsed: entry.timestamp
|
2026-01-06 09:44:14 -05:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
stats.byAccount[accountKey].requests++;
|
|
|
|
|
stats.byAccount[accountKey].promptTokens += promptTokens;
|
|
|
|
|
stats.byAccount[accountKey].completionTokens += completionTokens;
|
2026-01-06 16:59:49 -05:00
|
|
|
stats.byAccount[accountKey].cost += entryCost;
|
2026-01-06 13:41:53 -05:00
|
|
|
if (new Date(entry.timestamp) > new Date(stats.byAccount[accountKey].lastUsed)) {
|
|
|
|
|
stats.byAccount[accountKey].lastUsed = entry.timestamp;
|
|
|
|
|
}
|
2026-01-06 09:44:14 -05:00
|
|
|
}
|
2026-02-11 03:44:08 -05:00
|
|
|
|
|
|
|
|
// Handle requests with API key
|
|
|
|
|
if (entry.apiKey && typeof entry.apiKey === "string") {
|
|
|
|
|
const keyInfo = apiKeyMap[entry.apiKey];
|
|
|
|
|
const keyName = keyInfo?.name || entry.apiKey.slice(0, 8) + "...";
|
|
|
|
|
// Use full API key to avoid collisions (keys with same prefix)
|
|
|
|
|
const apiKeyKey = entry.apiKey;
|
|
|
|
|
// Group by API Key + Model + Provider combination to track different models used with the same key
|
|
|
|
|
const apiKeyModelKey = `${apiKeyKey}|${entry.model}|${entry.provider || 'unknown'}`;
|
|
|
|
|
|
|
|
|
|
if (!stats.byApiKey[apiKeyModelKey]) {
|
|
|
|
|
stats.byApiKey[apiKeyModelKey] = {
|
|
|
|
|
requests: 0,
|
|
|
|
|
promptTokens: 0,
|
|
|
|
|
completionTokens: 0,
|
|
|
|
|
cost: 0,
|
|
|
|
|
rawModel: entry.model,
|
2026-02-27 22:04:57 -05:00
|
|
|
provider: providerDisplayName,
|
2026-02-11 03:44:08 -05:00
|
|
|
apiKey: entry.apiKey,
|
|
|
|
|
keyName: keyName,
|
|
|
|
|
apiKeyKey: apiKeyKey,
|
|
|
|
|
lastUsed: entry.timestamp
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
const apiKeyEntry = stats.byApiKey[apiKeyModelKey];
|
|
|
|
|
apiKeyEntry.requests++;
|
|
|
|
|
apiKeyEntry.promptTokens += promptTokens;
|
|
|
|
|
apiKeyEntry.completionTokens += completionTokens;
|
|
|
|
|
apiKeyEntry.cost += entryCost;
|
|
|
|
|
if (new Date(entry.timestamp) > new Date(apiKeyEntry.lastUsed)) {
|
|
|
|
|
apiKeyEntry.lastUsed = entry.timestamp;
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
const apiKeyKey = "local-no-key";
|
|
|
|
|
const keyName = "Local (No API Key)";
|
|
|
|
|
|
|
|
|
|
if (!stats.byApiKey[apiKeyKey]) {
|
|
|
|
|
stats.byApiKey[apiKeyKey] = {
|
|
|
|
|
requests: 0,
|
|
|
|
|
promptTokens: 0,
|
|
|
|
|
completionTokens: 0,
|
|
|
|
|
cost: 0,
|
|
|
|
|
rawModel: entry.model,
|
2026-02-27 22:04:57 -05:00
|
|
|
provider: providerDisplayName,
|
2026-02-11 03:44:08 -05:00
|
|
|
apiKey: null,
|
|
|
|
|
keyName: keyName,
|
|
|
|
|
apiKeyKey: apiKeyKey,
|
|
|
|
|
lastUsed: entry.timestamp
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
const apiKeyEntry = stats.byApiKey[apiKeyKey];
|
|
|
|
|
apiKeyEntry.requests++;
|
|
|
|
|
apiKeyEntry.promptTokens += promptTokens;
|
|
|
|
|
apiKeyEntry.completionTokens += completionTokens;
|
|
|
|
|
apiKeyEntry.cost += entryCost;
|
|
|
|
|
if (new Date(entry.timestamp) > new Date(apiKeyEntry.lastUsed)) {
|
|
|
|
|
apiKeyEntry.lastUsed = entry.timestamp;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-20 03:03:18 -05:00
|
|
|
|
|
|
|
|
// By Endpoint (endpoint + model + provider combination)
|
|
|
|
|
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: endpoint,
|
|
|
|
|
rawModel: entry.model,
|
2026-02-27 22:04:57 -05:00
|
|
|
provider: providerDisplayName,
|
2026-02-20 03:03:18 -05:00
|
|
|
lastUsed: entry.timestamp
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
const endpointEntry = stats.byEndpoint[endpointModelKey];
|
|
|
|
|
endpointEntry.requests++;
|
|
|
|
|
endpointEntry.promptTokens += promptTokens;
|
|
|
|
|
endpointEntry.completionTokens += completionTokens;
|
|
|
|
|
endpointEntry.cost += entryCost;
|
|
|
|
|
if (new Date(entry.timestamp) > new Date(endpointEntry.lastUsed)) {
|
|
|
|
|
endpointEntry.lastUsed = entry.timestamp;
|
|
|
|
|
}
|
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 || [];
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
|
|
|
|
|
let bucketCount, bucketMs, labelFn;
|
|
|
|
|
if (period === "24h") {
|
|
|
|
|
bucketCount = 24;
|
|
|
|
|
bucketMs = 3600000; // 1 hour
|
|
|
|
|
labelFn = (ts) => new Date(ts).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false });
|
|
|
|
|
} else if (period === "7d") {
|
|
|
|
|
bucketCount = 7;
|
|
|
|
|
bucketMs = 86400000;
|
|
|
|
|
labelFn = (ts) => new Date(ts).toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
|
|
|
|
} else if (period === "30d") {
|
|
|
|
|
bucketCount = 30;
|
|
|
|
|
bucketMs = 86400000;
|
|
|
|
|
labelFn = (ts) => new Date(ts).toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
|
|
|
|
} else {
|
|
|
|
|
bucketCount = 60;
|
|
|
|
|
bucketMs = 86400000;
|
|
|
|
|
labelFn = (ts) => new Date(ts).toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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, _ts: ts };
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
const promptTokens = entry.tokens?.prompt_tokens || 0;
|
|
|
|
|
const completionTokens = entry.tokens?.completion_tokens || 0;
|
|
|
|
|
buckets[idx].tokens += promptTokens + completionTokens;
|
|
|
|
|
// Use pre-stored cost if available, else 0
|
|
|
|
|
buckets[idx].cost += entry.cost || 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return buckets.map(({ label, tokens, cost }) => ({ label, tokens, cost }));
|
|
|
|
|
}
|
|
|
|
|
|
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";
|