LibreChat stopped tagging agent transactions with model: agent_xxx around 2026-03; new agent transactions record the underlying LLM and link to the message via messageId. Aggregate from messages -> transactions and union with the legacy path so historical and current data both show. Also create createdAt / messageId / (createdAt, model) indexes on startup. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
537 lines
17 KiB
JavaScript
537 lines
17 KiB
JavaScript
const { getModelPricing } = require('../config/pricing');
|
|
|
|
// Cache agents collection in memory, refresh every 5 min
|
|
let agentsCache = new Map();
|
|
let agentsCacheTime = 0;
|
|
const CACHE_TTL = 5 * 60 * 1000;
|
|
|
|
async function refreshAgentsCache(db) {
|
|
if (Date.now() - agentsCacheTime < CACHE_TTL && agentsCache.size > 0) return;
|
|
try {
|
|
const agents = await db.collection('agents').find({}).toArray();
|
|
agentsCache = new Map();
|
|
for (const a of agents) {
|
|
agentsCache.set(a.id, { name: a.name, model: a.model, provider: a.provider });
|
|
}
|
|
agentsCacheTime = Date.now();
|
|
} catch (e) {
|
|
console.error('Failed to refresh agents cache:', e.message);
|
|
}
|
|
}
|
|
|
|
function resolveModel(model) {
|
|
if (model && model.startsWith('agent_')) {
|
|
const agent = agentsCache.get(model);
|
|
return agent ? agent.model : model;
|
|
}
|
|
return model;
|
|
}
|
|
|
|
function getDateRange(query) {
|
|
const { period, start, end } = query;
|
|
const now = new Date();
|
|
let startDate, endDate;
|
|
|
|
if (period === 'custom' && start && end) {
|
|
startDate = new Date(start);
|
|
endDate = new Date(end);
|
|
endDate.setHours(23, 59, 59, 999);
|
|
} else {
|
|
endDate = now;
|
|
switch (period) {
|
|
case '7d': startDate = new Date(now - 7 * 86400000); break;
|
|
case '30d': startDate = new Date(now - 30 * 86400000); break;
|
|
case '24h':
|
|
default: startDate = new Date(now - 24 * 3600000); break;
|
|
}
|
|
}
|
|
return { startDate, endDate };
|
|
}
|
|
|
|
async function getSummary(db, query) {
|
|
await refreshAgentsCache(db);
|
|
const { startDate, endDate } = getDateRange(query);
|
|
|
|
const [tokenResult, userCount, convCount, visits, uniqueUsers] = await Promise.all([
|
|
db.collection('transactions').aggregate([
|
|
{ $match: { createdAt: { $gte: startDate, $lte: endDate } } },
|
|
{
|
|
$group: {
|
|
_id: null,
|
|
totalTokens: { $sum: { $abs: '$rawAmount' } },
|
|
totalCost: { $sum: { $abs: '$tokenValue' } },
|
|
}
|
|
}
|
|
]).toArray(),
|
|
db.collection('transactions').distinct('user', {
|
|
createdAt: { $gte: startDate, $lte: endDate }
|
|
}),
|
|
db.collection('transactions').distinct('conversationId', {
|
|
createdAt: { $gte: startDate, $lte: endDate }
|
|
}),
|
|
db.collection('conversations').countDocuments({
|
|
createdAt: { $gte: startDate, $lte: endDate }
|
|
}),
|
|
db.collection('conversations').distinct('user', {
|
|
createdAt: { $gte: startDate, $lte: endDate }
|
|
}),
|
|
]);
|
|
|
|
const t = tokenResult[0] || { totalTokens: 0, totalCost: 0 };
|
|
return {
|
|
totalTokens: t.totalTokens,
|
|
totalCost: t.totalCost / 1_000_000,
|
|
activeUsers: userCount.length,
|
|
conversations: convCount.length,
|
|
visits: visits,
|
|
uniqueUsers: uniqueUsers.length,
|
|
};
|
|
}
|
|
|
|
async function getTopUsers(db, query, limit = 10) {
|
|
const { startDate, endDate } = getDateRange(query);
|
|
|
|
const results = await db.collection('transactions').aggregate([
|
|
{ $match: { createdAt: { $gte: startDate, $lte: endDate } } },
|
|
{
|
|
$group: {
|
|
_id: '$user',
|
|
totalTokens: { $sum: { $abs: '$rawAmount' } },
|
|
totalCost: { $sum: { $abs: '$tokenValue' } },
|
|
conversations: { $addToSet: '$conversationId' },
|
|
}
|
|
},
|
|
{ $sort: { totalCost: -1 } },
|
|
{ $limit: limit },
|
|
{
|
|
$lookup: {
|
|
from: 'users',
|
|
localField: '_id',
|
|
foreignField: '_id',
|
|
as: 'userInfo'
|
|
}
|
|
},
|
|
{ $unwind: { path: '$userInfo', preserveNullAndEmptyArrays: true } },
|
|
{
|
|
$project: {
|
|
name: { $ifNull: ['$userInfo.name', 'Unknown'] },
|
|
email: { $ifNull: ['$userInfo.email', ''] },
|
|
totalTokens: 1,
|
|
totalCost: { $divide: ['$totalCost', 1_000_000] },
|
|
conversationCount: { $size: '$conversations' },
|
|
}
|
|
}
|
|
]).toArray();
|
|
|
|
return results;
|
|
}
|
|
|
|
async function getTopModels(db, query, limit = 10) {
|
|
await refreshAgentsCache(db);
|
|
const { startDate, endDate } = getDateRange(query);
|
|
|
|
const raw = await db.collection('transactions').aggregate([
|
|
{ $match: { createdAt: { $gte: startDate, $lte: endDate } } },
|
|
{
|
|
$group: {
|
|
_id: { model: '$model', tokenType: '$tokenType' },
|
|
totalTokens: { $sum: { $abs: '$rawAmount' } },
|
|
totalCost: { $sum: { $abs: '$tokenValue' } },
|
|
}
|
|
}
|
|
]).toArray();
|
|
|
|
// Resolve agents to underlying LLM and re-aggregate
|
|
const modelMap = new Map();
|
|
for (const r of raw) {
|
|
const resolvedModel = resolveModel(r._id.model);
|
|
const key = `${resolvedModel}::${r._id.tokenType}`;
|
|
if (!modelMap.has(key)) {
|
|
modelMap.set(key, { model: resolvedModel, tokenType: r._id.tokenType, totalTokens: 0, totalCost: 0 });
|
|
}
|
|
const entry = modelMap.get(key);
|
|
entry.totalTokens += r.totalTokens;
|
|
entry.totalCost += r.totalCost;
|
|
}
|
|
|
|
// Pivot into per-model summary
|
|
const models = new Map();
|
|
for (const entry of modelMap.values()) {
|
|
if (!models.has(entry.model)) {
|
|
models.set(entry.model, { model: entry.model, promptTokens: 0, completionTokens: 0, promptCost: 0, completionCost: 0, totalCost: 0 });
|
|
}
|
|
const m = models.get(entry.model);
|
|
if (entry.tokenType === 'prompt') {
|
|
m.promptTokens += entry.totalTokens;
|
|
m.promptCost += entry.totalCost / 1_000_000;
|
|
} else {
|
|
m.completionTokens += entry.totalTokens;
|
|
m.completionCost += entry.totalCost / 1_000_000;
|
|
}
|
|
m.totalCost = m.promptCost + m.completionCost;
|
|
}
|
|
|
|
return Array.from(models.values())
|
|
.sort((a, b) => b.totalCost - a.totalCost)
|
|
.slice(0, limit);
|
|
}
|
|
|
|
async function getTopAgents(db, query, limit = 10) {
|
|
await refreshAgentsCache(db);
|
|
const { startDate, endDate } = getDateRange(query);
|
|
|
|
// LibreChat changed transaction shape around 2026-03: agent transactions used
|
|
// to record `model: agent_xxx` directly; newer ones record the underlying LLM
|
|
// (e.g. gpt-5.2) and link back to a message via `messageId`. Sum across both.
|
|
|
|
const agentMsgs = await db.collection('messages').find(
|
|
{ createdAt: { $gte: startDate, $lte: endDate }, model: { $regex: /^agent_/ } },
|
|
{ projection: { messageId: 1, model: 1, conversationId: 1 } }
|
|
).toArray();
|
|
const msgToAgent = new Map(agentMsgs.map(m => [m.messageId, m.model]));
|
|
|
|
const newPath = msgToAgent.size === 0 ? [] : await db.collection('transactions').aggregate([
|
|
{ $match: {
|
|
messageId: { $in: [...msgToAgent.keys()] },
|
|
createdAt: { $gte: startDate, $lte: endDate },
|
|
} },
|
|
{ $group: {
|
|
_id: '$messageId',
|
|
totalTokens: { $sum: { $abs: '$rawAmount' } },
|
|
totalCost: { $sum: { $abs: '$tokenValue' } },
|
|
conversationId: { $first: '$conversationId' },
|
|
} }
|
|
]).toArray();
|
|
|
|
const legacy = await db.collection('transactions').aggregate([
|
|
{ $match: { createdAt: { $gte: startDate, $lte: endDate }, model: { $regex: /^agent_/ } } },
|
|
{ $group: {
|
|
_id: '$model',
|
|
totalTokens: { $sum: { $abs: '$rawAmount' } },
|
|
totalCost: { $sum: { $abs: '$tokenValue' } },
|
|
conversations: { $addToSet: '$conversationId' },
|
|
} }
|
|
]).toArray();
|
|
|
|
const totals = new Map();
|
|
const bump = (agentId, tokens, cost, convs) => {
|
|
if (!agentId) return;
|
|
if (!totals.has(agentId)) {
|
|
totals.set(agentId, { agentId, totalTokens: 0, totalCost: 0, convs: new Set() });
|
|
}
|
|
const a = totals.get(agentId);
|
|
a.totalTokens += tokens;
|
|
a.totalCost += cost;
|
|
for (const c of convs) if (c) a.convs.add(c);
|
|
};
|
|
for (const t of newPath) bump(msgToAgent.get(t._id), t.totalTokens, t.totalCost, [t.conversationId]);
|
|
for (const t of legacy) bump(t._id, t.totalTokens, t.totalCost, t.conversations);
|
|
|
|
return [...totals.values()]
|
|
.sort((a, b) => b.totalCost - a.totalCost)
|
|
.slice(0, limit)
|
|
.map(a => {
|
|
const meta = agentsCache.get(a.agentId);
|
|
return {
|
|
agentId: a.agentId,
|
|
totalTokens: a.totalTokens,
|
|
totalCost: a.totalCost / 1_000_000,
|
|
conversationCount: a.convs.size,
|
|
agentName: meta ? meta.name : a.agentId,
|
|
underlyingModel: meta ? meta.model : 'Unknown',
|
|
provider: meta ? meta.provider : 'Unknown',
|
|
};
|
|
});
|
|
}
|
|
|
|
async function getCostBreakdown(db, query) {
|
|
await refreshAgentsCache(db);
|
|
const { startDate, endDate } = getDateRange(query);
|
|
|
|
const raw = await db.collection('transactions').aggregate([
|
|
{ $match: { createdAt: { $gte: startDate, $lte: endDate } } },
|
|
{
|
|
$group: {
|
|
_id: { model: '$model', tokenType: '$tokenType' },
|
|
totalCost: { $sum: { $abs: '$tokenValue' } },
|
|
}
|
|
}
|
|
]).toArray();
|
|
|
|
const models = new Map();
|
|
for (const r of raw) {
|
|
const resolved = resolveModel(r._id.model);
|
|
if (!models.has(resolved)) {
|
|
models.set(resolved, { model: resolved, inputCost: 0, outputCost: 0 });
|
|
}
|
|
const m = models.get(resolved);
|
|
if (r._id.tokenType === 'prompt') {
|
|
m.inputCost += r.totalCost / 1_000_000;
|
|
} else {
|
|
m.outputCost += r.totalCost / 1_000_000;
|
|
}
|
|
}
|
|
|
|
return Array.from(models.values())
|
|
.map(m => ({ ...m, totalCost: m.inputCost + m.outputCost }))
|
|
.sort((a, b) => b.totalCost - a.totalCost);
|
|
}
|
|
|
|
async function getUsageOverTime(db, query) {
|
|
const { startDate, endDate } = getDateRange(query);
|
|
const diffMs = endDate - startDate;
|
|
const diffHours = diffMs / 3600000;
|
|
|
|
// Use hourly buckets for <=48h, daily for longer
|
|
let dateFormat, bucketLabel;
|
|
if (diffHours <= 48) {
|
|
dateFormat = { $dateToString: { format: '%Y-%m-%dT%H:00', date: '$createdAt' } };
|
|
bucketLabel = 'hour';
|
|
} else {
|
|
dateFormat = { $dateToString: { format: '%Y-%m-%d', date: '$createdAt' } };
|
|
bucketLabel = 'day';
|
|
}
|
|
|
|
const results = await db.collection('transactions').aggregate([
|
|
{ $match: { createdAt: { $gte: startDate, $lte: endDate } } },
|
|
{
|
|
$group: {
|
|
_id: dateFormat,
|
|
totalTokens: { $sum: { $abs: '$rawAmount' } },
|
|
totalCost: { $sum: { $abs: '$tokenValue' } },
|
|
}
|
|
},
|
|
{ $sort: { _id: 1 } },
|
|
]).toArray();
|
|
|
|
return {
|
|
bucketType: bucketLabel,
|
|
data: results.map(r => ({
|
|
time: r._id,
|
|
tokens: r.totalTokens,
|
|
cost: r.totalCost / 1_000_000,
|
|
})),
|
|
};
|
|
}
|
|
|
|
async function getTopConversations(db, query, limit = 20, userId = null) {
|
|
await refreshAgentsCache(db);
|
|
const { startDate, endDate } = getDateRange(query);
|
|
|
|
const match = { createdAt: { $gte: startDate, $lte: endDate } };
|
|
if (userId) match.user = new (require('mongodb').ObjectId)(userId);
|
|
|
|
const results = await db.collection('transactions').aggregate([
|
|
{ $match: match },
|
|
{
|
|
$group: {
|
|
_id: '$conversationId',
|
|
totalTokens: { $sum: { $abs: '$rawAmount' } },
|
|
totalCost: { $sum: { $abs: '$tokenValue' } },
|
|
models: { $addToSet: '$model' },
|
|
user: { $first: '$user' },
|
|
promptTokens: {
|
|
$sum: { $cond: [{ $eq: ['$tokenType', 'prompt'] }, { $abs: '$rawAmount' }, 0] }
|
|
},
|
|
completionTokens: {
|
|
$sum: { $cond: [{ $eq: ['$tokenType', 'completion'] }, { $abs: '$rawAmount' }, 0] }
|
|
},
|
|
}
|
|
},
|
|
{ $sort: { totalCost: -1 } },
|
|
{ $limit: limit },
|
|
{
|
|
$lookup: {
|
|
from: 'users',
|
|
localField: 'user',
|
|
foreignField: '_id',
|
|
as: 'userInfo'
|
|
}
|
|
},
|
|
{ $unwind: { path: '$userInfo', preserveNullAndEmptyArrays: true } },
|
|
{
|
|
$lookup: {
|
|
from: 'conversations',
|
|
localField: '_id',
|
|
foreignField: 'conversationId',
|
|
as: 'convInfo'
|
|
}
|
|
},
|
|
{ $unwind: { path: '$convInfo', preserveNullAndEmptyArrays: true } },
|
|
{
|
|
$project: {
|
|
conversationId: '$_id',
|
|
title: { $ifNull: ['$convInfo.title', 'Unknown'] },
|
|
userName: { $ifNull: ['$userInfo.name', 'Unknown'] },
|
|
userEmail: { $ifNull: ['$userInfo.email', ''] },
|
|
models: 1,
|
|
totalTokens: 1,
|
|
promptTokens: 1,
|
|
completionTokens: 1,
|
|
totalCost: { $divide: ['$totalCost', 1_000_000] },
|
|
}
|
|
}
|
|
]).toArray();
|
|
|
|
// Resolve agent model names
|
|
return results.map(r => ({
|
|
...r,
|
|
models: r.models.map(m => {
|
|
if (m && m.startsWith('agent_')) {
|
|
const agent = agentsCache.get(m);
|
|
return agent ? `${agent.name} (${agent.model})` : m;
|
|
}
|
|
return m;
|
|
}),
|
|
}));
|
|
}
|
|
|
|
async function searchUsers(db, searchTerm, limit = 10) {
|
|
const regex = new RegExp(searchTerm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i');
|
|
return db.collection('users').find({
|
|
$or: [{ name: regex }, { email: regex }]
|
|
}, { projection: { _id: 1, name: 1, email: 1 } })
|
|
.limit(limit)
|
|
.toArray();
|
|
}
|
|
|
|
async function getUserDetail(db, query, userId) {
|
|
await refreshAgentsCache(db);
|
|
const { startDate, endDate } = getDateRange(query);
|
|
const { ObjectId } = require('mongodb');
|
|
const userOid = new ObjectId(userId);
|
|
|
|
// Summary aggregation
|
|
const [summaryResult, visitCount, convos, modelBreakdown] = await Promise.all([
|
|
db.collection('transactions').aggregate([
|
|
{ $match: { user: userOid, createdAt: { $gte: startDate, $lte: endDate } } },
|
|
{
|
|
$group: {
|
|
_id: null,
|
|
totalTokens: { $sum: { $abs: '$rawAmount' } },
|
|
totalCost: { $sum: { $abs: '$tokenValue' } },
|
|
promptTokens: {
|
|
$sum: { $cond: [{ $eq: ['$tokenType', 'prompt'] }, { $abs: '$rawAmount' }, 0] }
|
|
},
|
|
completionTokens: {
|
|
$sum: { $cond: [{ $eq: ['$tokenType', 'completion'] }, { $abs: '$rawAmount' }, 0] }
|
|
},
|
|
conversations: { $addToSet: '$conversationId' },
|
|
}
|
|
}
|
|
]).toArray(),
|
|
|
|
db.collection('conversations').countDocuments({
|
|
user: userId,
|
|
createdAt: { $gte: startDate, $lte: endDate }
|
|
}),
|
|
|
|
// Conversations breakdown
|
|
db.collection('transactions').aggregate([
|
|
{ $match: { user: userOid, createdAt: { $gte: startDate, $lte: endDate } } },
|
|
{
|
|
$group: {
|
|
_id: '$conversationId',
|
|
totalCost: { $sum: { $abs: '$tokenValue' } },
|
|
models: { $addToSet: '$model' },
|
|
promptTokens: {
|
|
$sum: { $cond: [{ $eq: ['$tokenType', 'prompt'] }, { $abs: '$rawAmount' }, 0] }
|
|
},
|
|
completionTokens: {
|
|
$sum: { $cond: [{ $eq: ['$tokenType', 'completion'] }, { $abs: '$rawAmount' }, 0] }
|
|
},
|
|
}
|
|
},
|
|
{ $sort: { totalCost: -1 } },
|
|
{ $limit: 100 },
|
|
{
|
|
$lookup: {
|
|
from: 'conversations',
|
|
localField: '_id',
|
|
foreignField: 'conversationId',
|
|
as: 'convInfo'
|
|
}
|
|
},
|
|
{ $unwind: { path: '$convInfo', preserveNullAndEmptyArrays: true } },
|
|
{
|
|
$project: {
|
|
conversationId: '$_id',
|
|
title: { $ifNull: ['$convInfo.title', 'Unknown'] },
|
|
models: 1,
|
|
promptTokens: 1,
|
|
completionTokens: 1,
|
|
totalCost: { $divide: ['$totalCost', 1_000_000] },
|
|
}
|
|
}
|
|
]).toArray(),
|
|
|
|
// Model breakdown
|
|
db.collection('transactions').aggregate([
|
|
{ $match: { user: userOid, createdAt: { $gte: startDate, $lte: endDate } } },
|
|
{
|
|
$group: {
|
|
_id: { model: '$model', tokenType: '$tokenType' },
|
|
totalTokens: { $sum: { $abs: '$rawAmount' } },
|
|
totalCost: { $sum: { $abs: '$tokenValue' } },
|
|
}
|
|
}
|
|
]).toArray(),
|
|
]);
|
|
|
|
const s = summaryResult[0] || { totalTokens: 0, totalCost: 0, promptTokens: 0, completionTokens: 0, conversations: [] };
|
|
|
|
// Resolve agent models in conversations
|
|
const resolvedConvos = convos.map(r => ({
|
|
...r,
|
|
models: r.models.map(m => {
|
|
if (m && m.startsWith('agent_')) {
|
|
const agent = agentsCache.get(m);
|
|
return agent ? `${agent.name} (${agent.model})` : m;
|
|
}
|
|
return m;
|
|
}),
|
|
}));
|
|
|
|
// Pivot model breakdown and resolve agents
|
|
const models = new Map();
|
|
for (const entry of modelBreakdown) {
|
|
const resolvedModel = resolveModel(entry._id.model);
|
|
if (!models.has(resolvedModel)) {
|
|
models.set(resolvedModel, { model: resolvedModel, promptTokens: 0, completionTokens: 0, promptCost: 0, completionCost: 0, totalCost: 0 });
|
|
}
|
|
const m = models.get(resolvedModel);
|
|
if (entry._id.tokenType === 'prompt') {
|
|
m.promptTokens += entry.totalTokens;
|
|
m.promptCost += entry.totalCost / 1_000_000;
|
|
} else {
|
|
m.completionTokens += entry.totalTokens;
|
|
m.completionCost += entry.totalCost / 1_000_000;
|
|
}
|
|
m.totalCost = m.promptCost + m.completionCost;
|
|
}
|
|
|
|
return {
|
|
summary: {
|
|
totalCost: s.totalCost / 1_000_000,
|
|
totalTokens: s.totalTokens,
|
|
promptTokens: s.promptTokens,
|
|
completionTokens: s.completionTokens,
|
|
conversationCount: s.conversations.length,
|
|
visitCount: visitCount,
|
|
},
|
|
conversations: resolvedConvos,
|
|
models: Array.from(models.values()).sort((a, b) => b.totalCost - a.totalCost),
|
|
};
|
|
}
|
|
|
|
module.exports = {
|
|
getSummary,
|
|
getTopUsers,
|
|
getTopModels,
|
|
getTopAgents,
|
|
getCostBreakdown,
|
|
getUsageOverTime,
|
|
getTopConversations,
|
|
searchUsers,
|
|
getUserDetail,
|
|
};
|