9router/src/lib/usageDb.js

819 lines
27 KiB
JavaScript
Raw Normal View History

import { Low } from "lowdb";
import { JSONFile } from "lowdb/node";
2026-02-21 02:36:06 -05:00
import { EventEmitter } from "events";
import path from "path";
import os from "os";
import fs from "fs";
import { fileURLToPath } from "url";
const isCloud = typeof caches !== 'undefined' || typeof caches === 'object';
// Get app name from root package.json config
function getAppName() {
if (isCloud) return "9router"; // Skip file system access in Workers
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() {
if (isCloud) return "/tmp"; // Fallback for Workers
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");
}
}
// Data file path - stored in user home directory
const DATA_DIR = getUserDataDir();
const DB_FILE = isCloud ? null : path.join(DATA_DIR, "usage.json");
const LOG_FILE = isCloud ? null : path.join(DATA_DIR, "log.txt");
// 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);
}
}
// Default data structure
const defaultData = {
history: [],
totalRequestsLifetime: 0
};
// 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;
// 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;
/**
* Track a pending request
* @param {string} model
* @param {string} provider
* @param {string} connectionId
* @param {boolean} started - true if started, false if finished
* @param {boolean} [error] - true if ended with error
*/
export function trackPendingRequest(model, provider, connectionId, started, error = false) {
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;
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
// 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}`);
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 };
}
/**
* Get usage database instance (singleton)
*/
export async function getUsageDb() {
if (isCloud) {
// Return in-memory DB for Workers
if (!dbInstance) {
dbInstance = new Low({ read: async () => {}, write: async () => {} }, defaultData);
dbInstance.data = defaultData;
}
return dbInstance;
}
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
* @param {object} entry - Usage entry { provider, model, tokens: { prompt_tokens, completion_tokens, ... }, connectionId?, apiKey? }
*/
export async function saveRequestUsage(entry) {
if (isCloud) return; // Skip saving in Workers
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 = [];
}
if (typeof db.data.totalRequestsLifetime !== "number") {
db.data.totalRequestsLifetime = db.data.history.length;
}
2026-02-21 02:36:06 -05:00
const entryCost = await calculateCost(entry.provider, entry.model, entry.tokens);
entry.cost = entryCost;
db.data.history.push(entry);
db.data.totalRequestsLifetime += 1;
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);
}
await db.write();
2026-02-21 02:36:06 -05:00
statsEmitter.emit("update");
} 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;
}
/**
* 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 }) {
if (isCloud) return; // Skip logging in Workers
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");
}
} 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) {
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 [];
}
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);
return [];
}
}
/**
* 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;
}
}
const PERIOD_MS = { "24h": 86400000, "7d": 604800000, "30d": 2592000000, "60d": 5184000000 };
/**
* Get aggregated usage stats
* @param {"24h"|"7d"|"30d"|"60d"|"all"} period - Time period to filter
*/
export async function getUsageStats(period = "all") {
const db = await getUsageDb();
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);
}
// 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");
// 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 {}
// 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);
const lifetimeTotalRequests = typeof db.data.totalRequestsLifetime === "number"
? db.data.totalRequestsLifetime
: history.length;
const stats = {
totalRequests: lifetimeTotalRequests,
totalPromptTokens: 0,
totalCompletionTokens: 0,
totalCost: 0,
byProvider: {},
byModel: {},
byAccount: {},
byApiKey: {},
byEndpoint: {},
last10Minutes: [],
pending: pendingRequests,
2026-02-21 02:36:06 -05:00
activeRequests: [],
recentRequests,
errorProvider: (Date.now() - lastErrorProvider.ts < 10000) ? lastErrorProvider.provider : "",
};
// 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
});
}
}
}
// 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,
completionTokens: 0,
cost: 0
};
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);
// Use pre-stored cost (saved at request time), avoid recalculating
const entryCost = entry.cost || 0;
stats.totalPromptTokens += promptTokens;
stats.totalCompletionTokens += completionTokens;
stats.totalCost += entryCost;
// 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;
bucketMap[entryMinuteStart].cost += entryCost;
}
}
// By Provider
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;
// 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;
if (!stats.byModel[modelKey]) {
stats.byModel[modelKey] = {
requests: 0,
promptTokens: 0,
completionTokens: 0,
cost: 0,
rawModel: entry.model,
2026-02-27 22:04:57 -05:00
provider: providerDisplayName,
lastUsed: entry.timestamp
};
}
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;
}
// 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,
cost: 0,
rawModel: entry.model,
2026-02-27 22:04:57 -05:00
provider: providerDisplayName,
connectionId: entry.connectionId,
accountName: 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;
}
}
// 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,
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,
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;
}
}
// 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,
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;
}
}
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";