mirror of
https://github.com/Routstr/routstrd.git
synced 2026-07-30 15:46:14 +00:00
feat(daemon): add /usage/summary aggregated endpoint
This commit is contained in:
@@ -16,6 +16,7 @@ import {
|
||||
} from "../wallet/cocod-client";
|
||||
import { decodeCashuTokenAmount } from "../wallet";
|
||||
import { getClientsFromStore } from "../../utils/clients";
|
||||
import { getUsageSummary } from "./usage-summary";
|
||||
|
||||
type ClientMode = "xcashu" | "lazyrefund" | "apikeys";
|
||||
|
||||
@@ -1123,6 +1124,18 @@ export function createDaemonRequestHandler(deps: {
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname === "/usage/summary") {
|
||||
try {
|
||||
const tz = Number.parseInt(url.searchParams.get("tz") || "0", 10) || 0;
|
||||
const clients = getClientsFromStore(deps.store);
|
||||
const summary = await getUsageSummary(deps.usageTrackingDriver, clients, tz);
|
||||
sendJson(res, 200, { output: summary });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { error: toErrorMessage(error) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname === "/usagePi") {
|
||||
try {
|
||||
const timestamp = (url.searchParams.get("timestamp") || "").trim();
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
import type {
|
||||
UsageAggregateRow,
|
||||
UsageTrackingDriver,
|
||||
UsageTrackingEntry,
|
||||
} from "@routstr/sdk/storage";
|
||||
import type { ClientEntry } from "../../utils/clients";
|
||||
|
||||
// ─── Public shape ────────────────────────────────────────────────────────────
|
||||
|
||||
export interface StatRow {
|
||||
requests: number;
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
cost: number;
|
||||
satsCost: number;
|
||||
}
|
||||
|
||||
export interface ModelSummary extends StatRow {
|
||||
modelId: string;
|
||||
}
|
||||
|
||||
export interface ProviderSummary extends StatRow {
|
||||
baseUrl: string;
|
||||
}
|
||||
|
||||
export interface TopModel {
|
||||
modelId: string;
|
||||
requests: number;
|
||||
satsCost: number;
|
||||
totalTokens: number;
|
||||
}
|
||||
|
||||
export interface ClientSummary extends StatRow {
|
||||
client: string;
|
||||
topModels: TopModel[];
|
||||
}
|
||||
|
||||
export interface NpubSummary extends StatRow {
|
||||
npub: string;
|
||||
topModels: TopModel[];
|
||||
}
|
||||
|
||||
export interface DaySummary extends StatRow {
|
||||
date: string; // "YYYY-MM-DD"
|
||||
}
|
||||
|
||||
export interface HourSummary extends StatRow {
|
||||
hour: number; // 0..23
|
||||
}
|
||||
|
||||
export interface SizeBucket {
|
||||
count: number;
|
||||
cost: number; // summed satsCost
|
||||
}
|
||||
|
||||
export interface UsageSummary {
|
||||
generatedAt: number;
|
||||
totals: StatRow;
|
||||
models: ModelSummary[];
|
||||
providers: ProviderSummary[];
|
||||
clients: ClientSummary[];
|
||||
npubs: NpubSummary[];
|
||||
days: DaySummary[];
|
||||
hoursToday: HourSummary[];
|
||||
sizeBuckets: {
|
||||
tiny: SizeBucket;
|
||||
small: SizeBucket;
|
||||
medium: SizeBucket;
|
||||
large: SizeBucket;
|
||||
huge: SizeBucket;
|
||||
};
|
||||
recent: UsageTrackingEntry[];
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function rowToStat(r: UsageAggregateRow): StatRow {
|
||||
return {
|
||||
requests: r.requests,
|
||||
promptTokens: r.promptTokens,
|
||||
completionTokens: r.completionTokens,
|
||||
totalTokens: r.totalTokens,
|
||||
cost: r.cost,
|
||||
satsCost: r.satsCost,
|
||||
};
|
||||
}
|
||||
|
||||
function rowToTopModel(r: UsageAggregateRow): TopModel {
|
||||
return {
|
||||
modelId: r.group ?? "unknown",
|
||||
requests: r.requests,
|
||||
satsCost: r.satsCost,
|
||||
totalTokens: r.totalTokens,
|
||||
};
|
||||
}
|
||||
|
||||
function emptyBucket(): SizeBucket {
|
||||
return { count: 0, cost: 0 };
|
||||
}
|
||||
|
||||
function bucketFromRow(r: UsageAggregateRow | undefined): SizeBucket {
|
||||
if (!r) return emptyBucket();
|
||||
return { count: r.requests, cost: r.satsCost };
|
||||
}
|
||||
|
||||
/** Returns the UTC ms for the start of the local day containing `now`. */
|
||||
function startOfLocalDayUtc(now: number, tzOffsetMinutes: number): number {
|
||||
return (
|
||||
Math.floor((now - tzOffsetMinutes * 60000) / 86400000) * 86400000 +
|
||||
tzOffsetMinutes * 60000
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Module-level memo cache ─────────────────────────────────────────────────
|
||||
|
||||
interface CacheEntry {
|
||||
key: string;
|
||||
summary: UsageSummary;
|
||||
}
|
||||
|
||||
let _cache: CacheEntry | null = null;
|
||||
const CACHE_TTL_MS = 60_000;
|
||||
|
||||
// ─── Main builder ─────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getUsageSummary(
|
||||
driver: UsageTrackingDriver,
|
||||
clients: ClientEntry[],
|
||||
tzOffsetMinutes: number,
|
||||
): Promise<UsageSummary> {
|
||||
// Cheap cache key: total row count + clients length + tz
|
||||
const count = await driver.count();
|
||||
const cacheKey = `${count}:${clients.length}:${tzOffsetMinutes}`;
|
||||
const now = Date.now();
|
||||
|
||||
if (
|
||||
_cache !== null &&
|
||||
_cache.key === cacheKey &&
|
||||
now - _cache.summary.generatedAt <= CACHE_TTL_MS
|
||||
) {
|
||||
return _cache.summary;
|
||||
}
|
||||
|
||||
// ── Totals ─────────────────────────────────────────────────────────────────
|
||||
const [totalsRow] = await driver.aggregate({});
|
||||
const totals: StatRow = totalsRow ? rowToStat(totalsRow) : {
|
||||
requests: 0, promptTokens: 0, completionTokens: 0,
|
||||
totalTokens: 0, cost: 0, satsCost: 0,
|
||||
};
|
||||
|
||||
// ── Models ─────────────────────────────────────────────────────────────────
|
||||
const modelRows = await driver.aggregate({ groupBy: "modelId" });
|
||||
const models: ModelSummary[] = modelRows.map((r) => ({
|
||||
modelId: r.group ?? "unknown",
|
||||
...rowToStat(r),
|
||||
}));
|
||||
|
||||
// ── Providers ──────────────────────────────────────────────────────────────
|
||||
const providerRows = await driver.aggregate({ groupBy: "baseUrl" });
|
||||
const providers: ProviderSummary[] = providerRows.map((r) => ({
|
||||
baseUrl: r.group ?? "unknown",
|
||||
...rowToStat(r),
|
||||
}));
|
||||
|
||||
// ── Clients ────────────────────────────────────────────────────────────────
|
||||
const clientRows = await driver.aggregate({ groupBy: "client" });
|
||||
const clientSummaries: ClientSummary[] = clientRows.map((r) => ({
|
||||
client: r.group ?? "unknown",
|
||||
...rowToStat(r),
|
||||
topModels: [],
|
||||
}));
|
||||
|
||||
// Fill topModels for the top 3 non-null client rows
|
||||
const topClientRows = clientRows
|
||||
.filter((r) => r.group !== null)
|
||||
.slice(0, 3);
|
||||
for (let i = 0; i < topClientRows.length; i++) {
|
||||
const clientId = topClientRows[i]!.group!;
|
||||
const topModelRows = await driver.aggregate({
|
||||
groupBy: "modelId",
|
||||
client: clientId,
|
||||
});
|
||||
// Find matching ClientSummary and set topModels
|
||||
const summary = clientSummaries.find((c) => c.client === clientId);
|
||||
if (summary) {
|
||||
summary.topModels = topModelRows.slice(0, 5).map(rowToTopModel);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Npubs ──────────────────────────────────────────────────────────────────
|
||||
// Build clientId → ownerNpub lookup (only clients with ownerNpub)
|
||||
const clientToNpub = new Map<string, string>();
|
||||
for (const c of clients) {
|
||||
if (c.ownerNpub) {
|
||||
clientToNpub.set(c.clientId, c.ownerNpub);
|
||||
}
|
||||
}
|
||||
|
||||
// Fold client rows into per-npub sums
|
||||
const npubStats = new Map<string, StatRow>();
|
||||
const npubClientIds = new Map<string, string[]>();
|
||||
for (const r of clientRows) {
|
||||
if (r.group === null) continue;
|
||||
const npub = clientToNpub.get(r.group);
|
||||
if (!npub) continue;
|
||||
const existing = npubStats.get(npub);
|
||||
if (existing) {
|
||||
existing.requests += r.requests;
|
||||
existing.promptTokens += r.promptTokens;
|
||||
existing.completionTokens += r.completionTokens;
|
||||
existing.totalTokens += r.totalTokens;
|
||||
existing.cost += r.cost;
|
||||
existing.satsCost += r.satsCost;
|
||||
} else {
|
||||
npubStats.set(npub, { ...rowToStat(r) });
|
||||
}
|
||||
const ids = npubClientIds.get(npub) ?? [];
|
||||
ids.push(r.group);
|
||||
npubClientIds.set(npub, ids);
|
||||
}
|
||||
|
||||
// Sort npubs desc by satsCost
|
||||
const sortedNpubs = [...npubStats.entries()].sort(
|
||||
(a, b) => b[1].satsCost - a[1].satsCost,
|
||||
);
|
||||
|
||||
const npubs: NpubSummary[] = sortedNpubs.map(([npub, stat]) => ({
|
||||
npub,
|
||||
...stat,
|
||||
topModels: [],
|
||||
}));
|
||||
|
||||
// Fill topModels for top 5 npubs
|
||||
for (let i = 0; i < Math.min(5, npubs.length); i++) {
|
||||
const npubSummary = npubs[i]!;
|
||||
const ids = npubClientIds.get(npubSummary.npub) ?? [];
|
||||
if (ids.length > 0) {
|
||||
const topModelRows = await driver.aggregate({
|
||||
groupBy: "modelId",
|
||||
clients: ids,
|
||||
});
|
||||
npubSummary.topModels = topModelRows.slice(0, 5).map(rowToTopModel);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Days (last 30, most-recent-first) ─────────────────────────────────────
|
||||
const dayRows = await driver.aggregate({
|
||||
groupBy: "day",
|
||||
tzOffsetMinutes,
|
||||
after: now - 30 * 86400000,
|
||||
});
|
||||
const days: DaySummary[] = dayRows
|
||||
.map((r) => ({ date: r.group!, ...rowToStat(r) }))
|
||||
.reverse(); // aggregate returns ascending; we want most-recent-first
|
||||
|
||||
// ── Hours today ────────────────────────────────────────────────────────────
|
||||
const todayStartUtc = startOfLocalDayUtc(now, tzOffsetMinutes);
|
||||
const hourRows = await driver.aggregate({
|
||||
groupBy: "hour",
|
||||
tzOffsetMinutes,
|
||||
after: todayStartUtc - 1,
|
||||
});
|
||||
const hoursToday: HourSummary[] = hourRows.map((r) => ({
|
||||
hour: Number(r.group),
|
||||
...rowToStat(r),
|
||||
}));
|
||||
|
||||
// ── Size buckets ───────────────────────────────────────────────────────────
|
||||
const [tinyRow] = await driver.aggregate({ minTotalTokens: 0, maxTotalTokens: 1000 });
|
||||
const [smallRow] = await driver.aggregate({ minTotalTokens: 1000, maxTotalTokens: 10000 });
|
||||
const [mediumRow] = await driver.aggregate({ minTotalTokens: 10000, maxTotalTokens: 50000 });
|
||||
const [largeRow] = await driver.aggregate({ minTotalTokens: 50000, maxTotalTokens: 100000 });
|
||||
const [hugeRow] = await driver.aggregate({ minTotalTokens: 100000 });
|
||||
|
||||
const sizeBuckets = {
|
||||
tiny: bucketFromRow(tinyRow),
|
||||
small: bucketFromRow(smallRow),
|
||||
medium: bucketFromRow(mediumRow),
|
||||
large: bucketFromRow(largeRow),
|
||||
huge: bucketFromRow(hugeRow),
|
||||
};
|
||||
|
||||
// ── Recent entries ─────────────────────────────────────────────────────────
|
||||
const recent = await driver.list({ limit: 50 });
|
||||
|
||||
const summary: UsageSummary = {
|
||||
generatedAt: now,
|
||||
totals,
|
||||
models,
|
||||
providers,
|
||||
clients: clientSummaries,
|
||||
npubs,
|
||||
days,
|
||||
hoursToday,
|
||||
sizeBuckets,
|
||||
recent,
|
||||
};
|
||||
|
||||
_cache = { key: cacheKey, summary };
|
||||
return summary;
|
||||
}
|
||||
Reference in New Issue
Block a user