Files
Moltbot/src/gateway/server-methods/usage.ts
Harold Hunt 844d84a7f5 Issue 17774 - Usage - Local - Show data from midnight to midnight of selected dates for browser time zone (AI assisted) (openclaw#19357) thanks @huntharo
Verified:
- pnpm install --frozen-lockfile
- pnpm build
- pnpm check
- pnpm test:macmini (override approved by Tak for this run; local baseline failures outside PR scope)

Co-authored-by: huntharo <5617868+huntharo@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-02-20 20:09:03 -06:00

945 lines
29 KiB
TypeScript

import fs from "node:fs";
import { loadConfig } from "../../config/config.js";
import {
resolveSessionFilePath,
resolveSessionFilePathOptions,
} from "../../config/sessions/paths.js";
import type { SessionEntry, SessionSystemPromptReport } from "../../config/sessions/types.js";
import { loadProviderUsageSummary } from "../../infra/provider-usage.js";
import type {
CostUsageSummary,
SessionCostSummary,
SessionDailyLatency,
SessionDailyModelUsage,
SessionMessageCounts,
SessionLatencyStats,
SessionModelUsage,
SessionToolUsage,
} from "../../infra/session-cost-usage.js";
import {
loadCostUsageSummary,
loadSessionCostSummary,
loadSessionUsageTimeSeries,
discoverAllSessions,
type DiscoveredSession,
} from "../../infra/session-cost-usage.js";
import { parseAgentSessionKey } from "../../routing/session-key.js";
import { buildUsageAggregateTail } from "../../shared/usage-aggregates.js";
import {
ErrorCodes,
errorShape,
formatValidationErrors,
validateSessionsUsageParams,
} from "../protocol/index.js";
import {
listAgentsForGateway,
loadCombinedSessionStoreForGateway,
loadSessionEntry,
} from "../session-utils.js";
import type { GatewayRequestHandlers, RespondFn } from "./types.js";
const COST_USAGE_CACHE_TTL_MS = 30_000;
const DAY_MS = 24 * 60 * 60 * 1000;
type DateRange = { startMs: number; endMs: number };
type DateInterpretation =
| { mode: "utc" | "gateway" }
| { mode: "specific"; utcOffsetMinutes: number };
type CostUsageCacheEntry = {
summary?: CostUsageSummary;
updatedAt?: number;
inFlight?: Promise<CostUsageSummary>;
};
const costUsageCache = new Map<string, CostUsageCacheEntry>();
function resolveSessionUsageFileOrRespond(
key: string,
respond: RespondFn,
): {
config: ReturnType<typeof loadConfig>;
entry: SessionEntry | undefined;
agentId: string | undefined;
sessionId: string;
sessionFile: string;
} | null {
const config = loadConfig();
const { entry, storePath } = loadSessionEntry(key);
// For discovered sessions (not in store), try using key as sessionId directly
const parsed = parseAgentSessionKey(key);
const agentId = parsed?.agentId;
const rawSessionId = parsed?.rest ?? key;
const sessionId = entry?.sessionId ?? rawSessionId;
let sessionFile: string;
try {
const pathOpts = resolveSessionFilePathOptions({ storePath, agentId });
sessionFile = resolveSessionFilePath(sessionId, entry, pathOpts);
} catch {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, `Invalid session key: ${key}`),
);
return null;
}
return { config, entry, agentId, sessionId, sessionFile };
}
const parseDateParts = (
raw: unknown,
): { year: number; monthIndex: number; day: number } | undefined => {
if (typeof raw !== "string" || !raw.trim()) {
return undefined;
}
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(raw.trim());
if (!match) {
return undefined;
}
const [, yearStr, monthStr, dayStr] = match;
const year = Number(yearStr);
const monthIndex = Number(monthStr) - 1;
const day = Number(dayStr);
if (!Number.isFinite(year) || !Number.isFinite(monthIndex) || !Number.isFinite(day)) {
return undefined;
}
return { year, monthIndex, day };
};
/**
* Parse a UTC offset string in the format UTC+H, UTC-H, UTC+HH, UTC-HH, UTC+H:MM, UTC-HH:MM.
* Returns the UTC offset in minutes (east-positive), or undefined if invalid.
*/
const parseUtcOffsetToMinutes = (raw: unknown): number | undefined => {
if (typeof raw !== "string" || !raw.trim()) {
return undefined;
}
const match = /^UTC([+-])(\d{1,2})(?::([0-5]\d))?$/.exec(raw.trim());
if (!match) {
return undefined;
}
const sign = match[1] === "+" ? 1 : -1;
const hours = Number(match[2]);
const minutes = Number(match[3] ?? "0");
if (!Number.isInteger(hours) || !Number.isInteger(minutes)) {
return undefined;
}
if (hours > 14 || (hours === 14 && minutes !== 0)) {
return undefined;
}
const totalMinutes = sign * (hours * 60 + minutes);
if (totalMinutes < -12 * 60 || totalMinutes > 14 * 60) {
return undefined;
}
return totalMinutes;
};
const resolveDateInterpretation = (params: {
mode?: unknown;
utcOffset?: unknown;
}): DateInterpretation => {
if (params.mode === "gateway") {
return { mode: "gateway" };
}
if (params.mode === "specific") {
const utcOffsetMinutes = parseUtcOffsetToMinutes(params.utcOffset);
if (utcOffsetMinutes !== undefined) {
return { mode: "specific", utcOffsetMinutes };
}
}
// Backward compatibility: when mode is missing (or invalid), keep current UTC interpretation.
return { mode: "utc" };
};
/**
* Parse a date string (YYYY-MM-DD) to start-of-day timestamp based on interpretation mode.
* Returns undefined if invalid.
*/
const parseDateToMs = (
raw: unknown,
interpretation: DateInterpretation = { mode: "utc" },
): number | undefined => {
const parts = parseDateParts(raw);
if (!parts) {
return undefined;
}
const { year, monthIndex, day } = parts;
if (interpretation.mode === "gateway") {
const ms = new Date(year, monthIndex, day).getTime();
return Number.isNaN(ms) ? undefined : ms;
}
if (interpretation.mode === "specific") {
const ms = Date.UTC(year, monthIndex, day) - interpretation.utcOffsetMinutes * 60 * 1000;
return Number.isNaN(ms) ? undefined : ms;
}
const ms = Date.UTC(year, monthIndex, day);
return Number.isNaN(ms) ? undefined : ms;
};
const getTodayStartMs = (now: Date, interpretation: DateInterpretation): number => {
if (interpretation.mode === "gateway") {
return new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
}
if (interpretation.mode === "specific") {
const shifted = new Date(now.getTime() + interpretation.utcOffsetMinutes * 60 * 1000);
return (
Date.UTC(shifted.getUTCFullYear(), shifted.getUTCMonth(), shifted.getUTCDate()) -
interpretation.utcOffsetMinutes * 60 * 1000
);
}
return Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
};
const parseDays = (raw: unknown): number | undefined => {
if (typeof raw === "number" && Number.isFinite(raw)) {
return Math.floor(raw);
}
if (typeof raw === "string" && raw.trim() !== "") {
const parsed = Number(raw);
if (Number.isFinite(parsed)) {
return Math.floor(parsed);
}
}
return undefined;
};
/**
* Get date range from params (startDate/endDate or days).
* Falls back to last 30 days if not provided.
*/
const parseDateRange = (params: {
startDate?: unknown;
endDate?: unknown;
days?: unknown;
mode?: unknown;
utcOffset?: unknown;
}): DateRange => {
const now = new Date();
const interpretation = resolveDateInterpretation(params);
const todayStartMs = getTodayStartMs(now, interpretation);
const todayEndMs = todayStartMs + DAY_MS - 1;
const startMs = parseDateToMs(params.startDate, interpretation);
const endMs = parseDateToMs(params.endDate, interpretation);
if (startMs !== undefined && endMs !== undefined) {
// endMs should be end of day
return { startMs, endMs: endMs + DAY_MS - 1 };
}
const days = parseDays(params.days);
if (days !== undefined) {
const clampedDays = Math.max(1, days);
const start = todayStartMs - (clampedDays - 1) * DAY_MS;
return { startMs: start, endMs: todayEndMs };
}
// Default to last 30 days
const defaultStartMs = todayStartMs - 29 * DAY_MS;
return { startMs: defaultStartMs, endMs: todayEndMs };
};
type DiscoveredSessionWithAgent = DiscoveredSession & { agentId: string };
function buildStoreBySessionId(
store: Record<string, SessionEntry>,
): Map<string, { key: string; entry: SessionEntry }> {
const storeBySessionId = new Map<string, { key: string; entry: SessionEntry }>();
for (const [key, entry] of Object.entries(store)) {
if (entry?.sessionId) {
storeBySessionId.set(entry.sessionId, { key, entry });
}
}
return storeBySessionId;
}
async function discoverAllSessionsForUsage(params: {
config: ReturnType<typeof loadConfig>;
startMs: number;
endMs: number;
}): Promise<DiscoveredSessionWithAgent[]> {
const agents = listAgentsForGateway(params.config).agents;
const results = await Promise.all(
agents.map(async (agent) => {
const sessions = await discoverAllSessions({
agentId: agent.id,
startMs: params.startMs,
endMs: params.endMs,
});
return sessions.map((session) => ({ ...session, agentId: agent.id }));
}),
);
return results.flat().toSorted((a, b) => b.mtime - a.mtime);
}
async function loadCostUsageSummaryCached(params: {
startMs: number;
endMs: number;
config: ReturnType<typeof loadConfig>;
}): Promise<CostUsageSummary> {
const cacheKey = `${params.startMs}-${params.endMs}`;
const now = Date.now();
const cached = costUsageCache.get(cacheKey);
if (cached?.summary && cached.updatedAt && now - cached.updatedAt < COST_USAGE_CACHE_TTL_MS) {
return cached.summary;
}
if (cached?.inFlight) {
if (cached.summary) {
return cached.summary;
}
return await cached.inFlight;
}
const entry: CostUsageCacheEntry = cached ?? {};
const inFlight = loadCostUsageSummary({
startMs: params.startMs,
endMs: params.endMs,
config: params.config,
})
.then((summary) => {
costUsageCache.set(cacheKey, { summary, updatedAt: Date.now() });
return summary;
})
.catch((err) => {
if (entry.summary) {
return entry.summary;
}
throw err;
})
.finally(() => {
const current = costUsageCache.get(cacheKey);
if (current?.inFlight === inFlight) {
current.inFlight = undefined;
costUsageCache.set(cacheKey, current);
}
});
entry.inFlight = inFlight;
costUsageCache.set(cacheKey, entry);
if (entry.summary) {
return entry.summary;
}
return await inFlight;
}
// Exposed for unit tests (kept as a single export to avoid widening the public API surface).
export const __test = {
parseDateParts,
parseUtcOffsetToMinutes,
resolveDateInterpretation,
parseDateToMs,
getTodayStartMs,
parseDays,
parseDateRange,
discoverAllSessionsForUsage,
loadCostUsageSummaryCached,
costUsageCache,
};
export type SessionUsageEntry = {
key: string;
label?: string;
sessionId?: string;
updatedAt?: number;
agentId?: string;
channel?: string;
chatType?: string;
origin?: {
label?: string;
provider?: string;
surface?: string;
chatType?: string;
from?: string;
to?: string;
accountId?: string;
threadId?: string | number;
};
modelOverride?: string;
providerOverride?: string;
modelProvider?: string;
model?: string;
usage: SessionCostSummary | null;
contextWeight?: SessionSystemPromptReport | null;
};
export type SessionsUsageAggregates = {
messages: SessionMessageCounts;
tools: SessionToolUsage;
byModel: SessionModelUsage[];
byProvider: SessionModelUsage[];
byAgent: Array<{ agentId: string; totals: CostUsageSummary["totals"] }>;
byChannel: Array<{ channel: string; totals: CostUsageSummary["totals"] }>;
latency?: SessionLatencyStats;
dailyLatency?: SessionDailyLatency[];
modelDaily?: SessionDailyModelUsage[];
daily: Array<{
date: string;
tokens: number;
cost: number;
messages: number;
toolCalls: number;
errors: number;
}>;
};
export type SessionsUsageResult = {
updatedAt: number;
startDate: string;
endDate: string;
sessions: SessionUsageEntry[];
totals: CostUsageSummary["totals"];
aggregates: SessionsUsageAggregates;
};
export const usageHandlers: GatewayRequestHandlers = {
"usage.status": async ({ respond }) => {
const summary = await loadProviderUsageSummary();
respond(true, summary, undefined);
},
"usage.cost": async ({ respond, params }) => {
const config = loadConfig();
const { startMs, endMs } = parseDateRange({
startDate: params?.startDate,
endDate: params?.endDate,
days: params?.days,
mode: params?.mode,
utcOffset: params?.utcOffset,
});
const summary = await loadCostUsageSummaryCached({ startMs, endMs, config });
respond(true, summary, undefined);
},
"sessions.usage": async ({ respond, params }) => {
if (!validateSessionsUsageParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid sessions.usage params: ${formatValidationErrors(validateSessionsUsageParams.errors)}`,
),
);
return;
}
const p = params;
const config = loadConfig();
const { startMs, endMs } = parseDateRange({
startDate: p.startDate,
endDate: p.endDate,
mode: p.mode,
utcOffset: p.utcOffset,
});
const limit = typeof p.limit === "number" && Number.isFinite(p.limit) ? p.limit : 50;
const includeContextWeight = p.includeContextWeight ?? false;
const specificKey = typeof p.key === "string" ? p.key.trim() : null;
// Load session store for named sessions
const { storePath, store } = loadCombinedSessionStoreForGateway(config);
const now = Date.now();
// Merge discovered sessions with store entries
type MergedEntry = {
key: string;
sessionId: string;
sessionFile: string;
label?: string;
updatedAt: number;
storeEntry?: SessionEntry;
firstUserMessage?: string;
};
const mergedEntries: MergedEntry[] = [];
// Optimization: If a specific key is requested, skip full directory scan
if (specificKey) {
const parsed = parseAgentSessionKey(specificKey);
const agentIdFromKey = parsed?.agentId;
const keyRest = parsed?.rest ?? specificKey;
// Prefer the store entry when available, even if the caller provides a discovered key
// (`agent:<id>:<sessionId>`) for a session that now has a canonical store key.
const storeBySessionId = buildStoreBySessionId(store);
const storeMatch = store[specificKey]
? { key: specificKey, entry: store[specificKey] }
: null;
const storeByIdMatch = storeBySessionId.get(keyRest) ?? null;
const resolvedStoreKey = storeMatch?.key ?? storeByIdMatch?.key ?? specificKey;
const storeEntry = storeMatch?.entry ?? storeByIdMatch?.entry;
const sessionId = storeEntry?.sessionId ?? keyRest;
// Resolve the session file path
let sessionFile: string;
try {
const pathOpts = resolveSessionFilePathOptions({
storePath: storePath !== "(multiple)" ? storePath : undefined,
agentId: agentIdFromKey,
});
sessionFile = resolveSessionFilePath(sessionId, storeEntry, pathOpts);
} catch {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, `Invalid session reference: ${specificKey}`),
);
return;
}
try {
const stats = fs.statSync(sessionFile);
if (stats.isFile()) {
mergedEntries.push({
key: resolvedStoreKey,
sessionId,
sessionFile,
label: storeEntry?.label,
updatedAt: storeEntry?.updatedAt ?? stats.mtimeMs,
storeEntry,
});
}
} catch {
// File doesn't exist - no results for this key
}
} else {
// Full discovery for list view
const discoveredSessions = await discoverAllSessionsForUsage({
config,
startMs,
endMs,
});
// Build a map of sessionId -> store entry for quick lookup
const storeBySessionId = buildStoreBySessionId(store);
for (const discovered of discoveredSessions) {
const storeMatch = storeBySessionId.get(discovered.sessionId);
if (storeMatch) {
// Named session from store
mergedEntries.push({
key: storeMatch.key,
sessionId: discovered.sessionId,
sessionFile: discovered.sessionFile,
label: storeMatch.entry.label,
updatedAt: storeMatch.entry.updatedAt ?? discovered.mtime,
storeEntry: storeMatch.entry,
});
} else {
// Unnamed session - use session ID as key, no label
mergedEntries.push({
// Keep agentId in the key so the dashboard can attribute sessions and later fetch logs.
key: `agent:${discovered.agentId}:${discovered.sessionId}`,
sessionId: discovered.sessionId,
sessionFile: discovered.sessionFile,
label: undefined, // No label for unnamed sessions
updatedAt: discovered.mtime,
});
}
}
}
// Sort by most recent first
mergedEntries.sort((a, b) => b.updatedAt - a.updatedAt);
// Apply limit
const limitedEntries = mergedEntries.slice(0, limit);
// Load usage for each session
const sessions: SessionUsageEntry[] = [];
const aggregateTotals = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
totalCost: 0,
inputCost: 0,
outputCost: 0,
cacheReadCost: 0,
cacheWriteCost: 0,
missingCostEntries: 0,
};
const aggregateMessages: SessionMessageCounts = {
total: 0,
user: 0,
assistant: 0,
toolCalls: 0,
toolResults: 0,
errors: 0,
};
const toolAggregateMap = new Map<string, number>();
const byModelMap = new Map<string, SessionModelUsage>();
const byProviderMap = new Map<string, SessionModelUsage>();
const byAgentMap = new Map<string, CostUsageSummary["totals"]>();
const byChannelMap = new Map<string, CostUsageSummary["totals"]>();
const dailyAggregateMap = new Map<
string,
{
date: string;
tokens: number;
cost: number;
messages: number;
toolCalls: number;
errors: number;
}
>();
const latencyTotals = {
count: 0,
sum: 0,
min: Number.POSITIVE_INFINITY,
max: 0,
p95Max: 0,
};
const dailyLatencyMap = new Map<
string,
{ date: string; count: number; sum: number; min: number; max: number; p95Max: number }
>();
const modelDailyMap = new Map<string, SessionDailyModelUsage>();
const emptyTotals = (): CostUsageSummary["totals"] => ({
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
totalCost: 0,
inputCost: 0,
outputCost: 0,
cacheReadCost: 0,
cacheWriteCost: 0,
missingCostEntries: 0,
});
const mergeTotals = (
target: CostUsageSummary["totals"],
source: CostUsageSummary["totals"],
) => {
target.input += source.input;
target.output += source.output;
target.cacheRead += source.cacheRead;
target.cacheWrite += source.cacheWrite;
target.totalTokens += source.totalTokens;
target.totalCost += source.totalCost;
target.inputCost += source.inputCost;
target.outputCost += source.outputCost;
target.cacheReadCost += source.cacheReadCost;
target.cacheWriteCost += source.cacheWriteCost;
target.missingCostEntries += source.missingCostEntries;
};
for (const merged of limitedEntries) {
const agentId = parseAgentSessionKey(merged.key)?.agentId;
const usage = await loadSessionCostSummary({
sessionId: merged.sessionId,
sessionEntry: merged.storeEntry,
sessionFile: merged.sessionFile,
config,
agentId,
startMs,
endMs,
});
if (usage) {
aggregateTotals.input += usage.input;
aggregateTotals.output += usage.output;
aggregateTotals.cacheRead += usage.cacheRead;
aggregateTotals.cacheWrite += usage.cacheWrite;
aggregateTotals.totalTokens += usage.totalTokens;
aggregateTotals.totalCost += usage.totalCost;
aggregateTotals.inputCost += usage.inputCost;
aggregateTotals.outputCost += usage.outputCost;
aggregateTotals.cacheReadCost += usage.cacheReadCost;
aggregateTotals.cacheWriteCost += usage.cacheWriteCost;
aggregateTotals.missingCostEntries += usage.missingCostEntries;
}
const channel = merged.storeEntry?.channel ?? merged.storeEntry?.origin?.provider;
const chatType = merged.storeEntry?.chatType ?? merged.storeEntry?.origin?.chatType;
if (usage) {
if (usage.messageCounts) {
aggregateMessages.total += usage.messageCounts.total;
aggregateMessages.user += usage.messageCounts.user;
aggregateMessages.assistant += usage.messageCounts.assistant;
aggregateMessages.toolCalls += usage.messageCounts.toolCalls;
aggregateMessages.toolResults += usage.messageCounts.toolResults;
aggregateMessages.errors += usage.messageCounts.errors;
}
if (usage.toolUsage) {
for (const tool of usage.toolUsage.tools) {
toolAggregateMap.set(tool.name, (toolAggregateMap.get(tool.name) ?? 0) + tool.count);
}
}
if (usage.modelUsage) {
for (const entry of usage.modelUsage) {
const modelKey = `${entry.provider ?? "unknown"}::${entry.model ?? "unknown"}`;
const modelExisting =
byModelMap.get(modelKey) ??
({
provider: entry.provider,
model: entry.model,
count: 0,
totals: emptyTotals(),
} as SessionModelUsage);
modelExisting.count += entry.count;
mergeTotals(modelExisting.totals, entry.totals);
byModelMap.set(modelKey, modelExisting);
const providerKey = entry.provider ?? "unknown";
const providerExisting =
byProviderMap.get(providerKey) ??
({
provider: entry.provider,
model: undefined,
count: 0,
totals: emptyTotals(),
} as SessionModelUsage);
providerExisting.count += entry.count;
mergeTotals(providerExisting.totals, entry.totals);
byProviderMap.set(providerKey, providerExisting);
}
}
if (usage.latency) {
const { count, avgMs, minMs, maxMs, p95Ms } = usage.latency;
if (count > 0) {
latencyTotals.count += count;
latencyTotals.sum += avgMs * count;
latencyTotals.min = Math.min(latencyTotals.min, minMs);
latencyTotals.max = Math.max(latencyTotals.max, maxMs);
latencyTotals.p95Max = Math.max(latencyTotals.p95Max, p95Ms);
}
}
if (usage.dailyLatency) {
for (const day of usage.dailyLatency) {
const existing = dailyLatencyMap.get(day.date) ?? {
date: day.date,
count: 0,
sum: 0,
min: Number.POSITIVE_INFINITY,
max: 0,
p95Max: 0,
};
existing.count += day.count;
existing.sum += day.avgMs * day.count;
existing.min = Math.min(existing.min, day.minMs);
existing.max = Math.max(existing.max, day.maxMs);
existing.p95Max = Math.max(existing.p95Max, day.p95Ms);
dailyLatencyMap.set(day.date, existing);
}
}
if (usage.dailyModelUsage) {
for (const entry of usage.dailyModelUsage) {
const key = `${entry.date}::${entry.provider ?? "unknown"}::${entry.model ?? "unknown"}`;
const existing =
modelDailyMap.get(key) ??
({
date: entry.date,
provider: entry.provider,
model: entry.model,
tokens: 0,
cost: 0,
count: 0,
} as SessionDailyModelUsage);
existing.tokens += entry.tokens;
existing.cost += entry.cost;
existing.count += entry.count;
modelDailyMap.set(key, existing);
}
}
if (agentId) {
const agentTotals = byAgentMap.get(agentId) ?? emptyTotals();
mergeTotals(agentTotals, usage);
byAgentMap.set(agentId, agentTotals);
}
if (channel) {
const channelTotals = byChannelMap.get(channel) ?? emptyTotals();
mergeTotals(channelTotals, usage);
byChannelMap.set(channel, channelTotals);
}
if (usage.dailyBreakdown) {
for (const day of usage.dailyBreakdown) {
const daily = dailyAggregateMap.get(day.date) ?? {
date: day.date,
tokens: 0,
cost: 0,
messages: 0,
toolCalls: 0,
errors: 0,
};
daily.tokens += day.tokens;
daily.cost += day.cost;
dailyAggregateMap.set(day.date, daily);
}
}
if (usage.dailyMessageCounts) {
for (const day of usage.dailyMessageCounts) {
const daily = dailyAggregateMap.get(day.date) ?? {
date: day.date,
tokens: 0,
cost: 0,
messages: 0,
toolCalls: 0,
errors: 0,
};
daily.messages += day.total;
daily.toolCalls += day.toolCalls;
daily.errors += day.errors;
dailyAggregateMap.set(day.date, daily);
}
}
}
sessions.push({
key: merged.key,
label: merged.label,
sessionId: merged.sessionId,
updatedAt: merged.updatedAt,
agentId,
channel,
chatType,
origin: merged.storeEntry?.origin,
modelOverride: merged.storeEntry?.modelOverride,
providerOverride: merged.storeEntry?.providerOverride,
modelProvider: merged.storeEntry?.modelProvider,
model: merged.storeEntry?.model,
usage,
contextWeight: includeContextWeight
? (merged.storeEntry?.systemPromptReport ?? null)
: undefined,
});
}
// Format dates back to YYYY-MM-DD strings
const formatDateStr = (ms: number) => {
const d = new Date(ms);
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")}`;
};
const tail = buildUsageAggregateTail({
byChannelMap: byChannelMap,
latencyTotals,
dailyLatencyMap,
modelDailyMap,
dailyMap: dailyAggregateMap,
});
const aggregates: SessionsUsageAggregates = {
messages: aggregateMessages,
tools: {
totalCalls: Array.from(toolAggregateMap.values()).reduce((sum, count) => sum + count, 0),
uniqueTools: toolAggregateMap.size,
tools: Array.from(toolAggregateMap.entries())
.map(([name, count]) => ({ name, count }))
.toSorted((a, b) => b.count - a.count),
},
byModel: Array.from(byModelMap.values()).toSorted((a, b) => {
const costDiff = b.totals.totalCost - a.totals.totalCost;
if (costDiff !== 0) {
return costDiff;
}
return b.totals.totalTokens - a.totals.totalTokens;
}),
byProvider: Array.from(byProviderMap.values()).toSorted((a, b) => {
const costDiff = b.totals.totalCost - a.totals.totalCost;
if (costDiff !== 0) {
return costDiff;
}
return b.totals.totalTokens - a.totals.totalTokens;
}),
byAgent: Array.from(byAgentMap.entries())
.map(([id, totals]) => ({ agentId: id, totals }))
.toSorted((a, b) => b.totals.totalCost - a.totals.totalCost),
...tail,
};
const result: SessionsUsageResult = {
updatedAt: now,
startDate: formatDateStr(startMs),
endDate: formatDateStr(endMs),
sessions,
totals: aggregateTotals,
aggregates,
};
respond(true, result, undefined);
},
"sessions.usage.timeseries": async ({ respond, params }) => {
const key = typeof params?.key === "string" ? params.key.trim() : null;
if (!key) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "key is required for timeseries"),
);
return;
}
const resolved = resolveSessionUsageFileOrRespond(key, respond);
if (!resolved) {
return;
}
const { config, entry, agentId, sessionId, sessionFile } = resolved;
const timeseries = await loadSessionUsageTimeSeries({
sessionId,
sessionEntry: entry,
sessionFile,
config,
agentId,
maxPoints: 200,
});
if (!timeseries) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, `No transcript found for session: ${key}`),
);
return;
}
respond(true, timeseries, undefined);
},
"sessions.usage.logs": async ({ respond, params }) => {
const key = typeof params?.key === "string" ? params.key.trim() : null;
if (!key) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "key is required for logs"));
return;
}
const limit =
typeof params?.limit === "number" && Number.isFinite(params.limit)
? Math.min(params.limit, 1000)
: 200;
const resolved = resolveSessionUsageFileOrRespond(key, respond);
if (!resolved) {
return;
}
const { config, entry, agentId, sessionId, sessionFile } = resolved;
const { loadSessionLogs } = await import("../../infra/session-cost-usage.js");
const logs = await loadSessionLogs({
sessionId,
sessionEntry: entry,
sessionFile,
config,
agentId,
limit,
});
respond(true, { logs: logs ?? [] }, undefined);
},
};