From 0c382209af7f87eb1e7d3303b18a2906b9820ae7 Mon Sep 17 00:00:00 2001 From: Bilthon Date: Tue, 2 Jun 2026 14:43:35 -0500 Subject: [PATCH 01/17] refactor: import bun sqlite drivers from @routstr/sdk/storage/bun --- src/daemon/index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/daemon/index.ts b/src/daemon/index.ts index 8cf95fd..0c1b16d 100644 --- a/src/daemon/index.ts +++ b/src/daemon/index.ts @@ -28,6 +28,8 @@ import { ensureDirs, loadDaemonConfig, loadDaemonConfigSync, saveDaemonConfig } import { createBunSqliteDriver, createBunSqliteUsageTrackingDriver, +} from "@routstr/sdk/storage/bun"; +import { createShardedDiscoveryAdapter, createProviderRegistryFromDiscoveryAdapter, } from "@routstr/sdk/storage"; @@ -54,10 +56,8 @@ async function main(): Promise { const sqliteDriver = await createBunSqliteDriver(DB_PATH, { logger: daemonSdkLogger }); const { store, hydrate } = createSdkStore({ driver: sqliteDriver }); await hydrate; - const { Database } = await import("bun:sqlite"); - const usageTrackingDriver = createBunSqliteUsageTrackingDriver({ + const usageTrackingDriver = await createBunSqliteUsageTrackingDriver({ dbPath: DB_PATH, - sqlite: { Database }, legacyStorageDriver: sqliteDriver, }); From 1b99aa166017a60f4ac5a68b22c7677821f156aa Mon Sep 17 00:00:00 2001 From: Bilthon Date: Tue, 2 Jun 2026 14:43:42 -0500 Subject: [PATCH 02/17] chore(deps): require @routstr/sdk ^0.4.0 for usage aggregate --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5423e26..eddba2d 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ }, "dependencies": { "@cashu/cashu-ts": "^4.3.0", - "@routstr/sdk": "^0.3.9", + "@routstr/sdk": "^0.4.0", "applesauce-core": "^5.1.0", "applesauce-relay": "^5.1.0", "applesauce-wallet-connect": "^6.0.0", From 8f77da3911857060a591b0ac640b8836aabd47ba Mon Sep 17 00:00:00 2001 From: Bilthon Date: Tue, 2 Jun 2026 14:45:06 -0500 Subject: [PATCH 03/17] feat(daemon): add /usage/summary aggregated endpoint --- src/daemon/http/index.ts | 13 ++ src/daemon/http/usage-summary.ts | 302 +++++++++++++++++++++++++++++++ 2 files changed, 315 insertions(+) create mode 100644 src/daemon/http/usage-summary.ts diff --git a/src/daemon/http/index.ts b/src/daemon/http/index.ts index 1ed8a3e..417e0ee 100644 --- a/src/daemon/http/index.ts +++ b/src/daemon/http/index.ts @@ -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(); diff --git a/src/daemon/http/usage-summary.ts b/src/daemon/http/usage-summary.ts new file mode 100644 index 0000000..ccd3023 --- /dev/null +++ b/src/daemon/http/usage-summary.ts @@ -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 { + // 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(); + 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(); + const npubClientIds = new Map(); + 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; +} From 21eb88734638228ba54b851967e31092ea2601f3 Mon Sep 17 00:00:00 2001 From: Bilthon Date: Tue, 2 Jun 2026 14:51:02 -0500 Subject: [PATCH 04/17] feat(monitor): consume /usage/summary with legacy fallback --- src/tui/usage/app.ts | 4 +- src/tui/usage/data.ts | 27 ++++- src/tui/usage/render.ts | 228 ++++++++++++++++++++++++---------------- src/tui/usage/types.ts | 5 + 4 files changed, 169 insertions(+), 95 deletions(-) diff --git a/src/tui/usage/app.ts b/src/tui/usage/app.ts index e2d5f63..99faa77 100644 --- a/src/tui/usage/app.ts +++ b/src/tui/usage/app.ts @@ -1,6 +1,6 @@ import { getVisibleTabs } from "./constants.ts"; import type { Tab } from "./types.ts"; -import { fetchBalance, fetchClients, fetchStatus, fetchUsage, hasAnyNpubs, isDaemonRunning, type BalanceInfo, type ClientInfo, type StatusInfo } from "./data.ts"; +import { fetchBalance, fetchClients, fetchStatus, fetchUsage, fetchUsageSummary, hasAnyNpubs, isDaemonRunning, type BalanceInfo, type ClientInfo, type StatusInfo } from "./data.ts"; import { applyScrollToContent, exitSearchMode, @@ -89,7 +89,7 @@ export async function runUsageTui(): Promise { const height = getHeight(); if (forceFetch || shouldUpdate) { - stats = await fetchUsage(10000); + stats = await fetchUsageSummary() ?? await fetchUsage(10000); balance = await fetchBalance(); status = await fetchStatus(); clients = await fetchClients(); diff --git a/src/tui/usage/data.ts b/src/tui/usage/data.ts index 5d342f2..53f3d32 100644 --- a/src/tui/usage/data.ts +++ b/src/tui/usage/data.ts @@ -1,6 +1,6 @@ import type { UsageTrackingEntry } from "../../daemon/types.ts"; import { callDaemon, isDaemonRunning } from "../../utils/daemon-client.ts"; -import type { ClientStats, DayStats, ModelStats, NpubStats, ProviderStats, UsageStats } from "./types.ts"; +import type { ClientStats, DayStats, ModelStats, NpubStats, ProviderStats, UsageStats, UsageSummary } from "./types.ts"; export { isDaemonRunning }; @@ -112,6 +112,31 @@ export async function fetchUsage(limit = 10000): Promise { } } +export async function fetchUsageSummary(): Promise { + try { + const running = await isDaemonRunning(); + if (!running) return null; + + const tz = new Date().getTimezoneOffset(); + const result = await callDaemon(`/usage/summary?tz=${tz}`); + if (result.error) return null; + + const summary = result.output as UsageSummary | undefined; + if (!summary || typeof summary.totals !== "object") return null; + + return { + entries: summary.recent, + totalEntries: summary.totals.requests, + totalSatsCost: summary.totals.satsCost, + recentSatsCost: summary.totals.satsCost, + limit: 50, + summary, + }; + } catch { + return null; + } +} + export function getTodayStart(): number { const now = new Date(); return new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); diff --git a/src/tui/usage/render.ts b/src/tui/usage/render.ts index e22cfad..0a84823 100644 --- a/src/tui/usage/render.ts +++ b/src/tui/usage/render.ts @@ -240,7 +240,7 @@ export function renderOverview(stats: UsageStats, balance: BalanceInfo | null, s output = renderBox(statusLines, width, "System Status") + "\n" + output; } - const modelStats = getModelStats(stats.entries); + const modelStats = stats.summary?.models ?? getModelStats(stats.entries); if (modelStats.length > 0) { const maxCost = modelStats[0]!.satsCost; const totalCost = Math.max(totalVisibleCost, 1); @@ -259,7 +259,7 @@ export function renderOverview(stats: UsageStats, balance: BalanceInfo | null, s output += "\n" + renderBox(modelLines, width, "Top Models by Cost"); } - const clientStats = getClientStats(stats.entries); + const clientStats = stats.summary?.clients ?? getClientStats(stats.entries); if (clientStats.length > 0) { const maxCost = clientStats[0]!.satsCost; const totalCost = Math.max(totalVisibleCost, 1); @@ -282,19 +282,40 @@ export function renderOverview(stats: UsageStats, balance: BalanceInfo | null, s } export function renderToday(stats: UsageStats, width: number): string { - const hourly = getHourlyToday(stats.entries); - const todayStart = getTodayStart(); const currentHour = new Date().getHours(); - const todayStats = { date: formatDate(Date.now()), requests: 0, satsCost: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0 }; + const todayDateStr = formatDate(Date.now()); - for (const entry of stats.entries) { - if (entry.timestamp >= todayStart) { - todayStats.requests++; - todayStats.satsCost += entry.satsCost; - todayStats.promptTokens += entry.promptTokens; - todayStats.completionTokens += entry.completionTokens; - todayStats.totalTokens += entry.totalTokens; + let todayStats: { date: string; requests: number; satsCost: number; promptTokens: number; completionTokens: number; totalTokens: number }; + let recentDays: Array<{ date: string; requests: number; satsCost: number; totalTokens: number; promptTokens: number; completionTokens: number }>; + let hourlyMap: Map; + + if (stats.summary) { + // Use server-aggregated data + const { days, hoursToday } = stats.summary; + // days[0] is most-recent-first; check if it's today + const todayDayStat = days[0]?.date === todayDateStr ? days[0] : undefined; + todayStats = todayDayStat + ? { date: todayDayStat.date, requests: todayDayStat.requests, satsCost: todayDayStat.satsCost, promptTokens: todayDayStat.promptTokens, completionTokens: todayDayStat.completionTokens, totalTokens: todayDayStat.totalTokens } + : { date: todayDateStr, requests: 0, satsCost: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0 }; + recentDays = days.slice(1, 7); + hourlyMap = new Map(hoursToday.map((h) => [h.hour, { requests: h.requests, satsCost: h.satsCost }])); + } else { + // Fallback: compute from raw entries + const todayStart = getTodayStart(); + todayStats = { date: todayDateStr, requests: 0, satsCost: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0 }; + for (const entry of stats.entries) { + if (entry.timestamp >= todayStart) { + todayStats.requests++; + todayStats.satsCost += entry.satsCost; + todayStats.promptTokens += entry.promptTokens; + todayStats.completionTokens += entry.completionTokens; + todayStats.totalTokens += entry.totalTokens; + } } + recentDays = Array.from(getDayStats(stats.entries).values()).slice(1, 7); + hourlyMap = new Map( + Array.from(getHourlyToday(stats.entries).entries()).map(([h, s]) => [h, { requests: s.requests, satsCost: s.satsCost }]) + ); } const summaryLines = [ @@ -306,18 +327,17 @@ export function renderToday(stats: UsageStats, width: number): string { let output = renderBox(summaryLines, width, "Today"); - const days = Array.from(getDayStats(stats.entries).values()).slice(0, 7); - if (days.length > 1) { - const dayLines = days.slice(1).map((d) => `${d.date}: ${formatReqs(d.requests)} req, ${formatCost(d.satsCost)} sats, ${formatNumber(d.totalTokens)} tokens`); + if (recentDays.length > 0) { + const dayLines = recentDays.map((d) => `${d.date}: ${formatReqs(d.requests)} req, ${formatCost(d.satsCost)} sats, ${formatNumber(d.totalTokens)} tokens`); output += "\n" + renderBox(dayLines, width, "Recent Days"); } const hourLines: string[] = []; - const maxHourCost = Math.max(...Array.from(hourly.values()).map((h) => h.satsCost), 1); + const maxHourCost = Math.max(...Array.from(hourlyMap.values()).map((h) => h.satsCost), 1); const totalTodayCost = Math.max(todayStats.satsCost, 1); const hourLabels: string[] = []; for (let h = currentHour; h >= 0; h--) { - const hStat = hourly.get(h); + const hStat = hourlyMap.get(h); const reqs = hStat?.requests || 0; const cost = hStat?.satsCost || 0; hourLabels.push(`${h.toString().padStart(2, "0")}:00 (${formatReqs(reqs)} req, ${formatCost(cost)} sats) `); @@ -325,7 +345,7 @@ export function renderToday(stats: UsageStats, width: number): string { const maxHourLabel = Math.max(...hourLabels.map((l) => l.length)); startBarSection("hourly", maxHourLabel); for (let i = currentHour; i >= 0; i--) { - const hStat = hourly.get(i); + const hStat = hourlyMap.get(i); const reqs = hStat?.requests || 0; const cost = hStat?.satsCost || 0; hourLines.push(renderBarChart( @@ -346,7 +366,7 @@ export function renderToday(stats: UsageStats, width: number): string { } export function renderModels(stats: UsageStats, width: number): string { - const modelStats = getModelStats(stats.entries); + const modelStats = stats.summary?.models ?? getModelStats(stats.entries); if (modelStats.length === 0) return renderBox(["No model data available"], width, "Models"); // Use totalSatsCost (all-time) for percentage calculations to match header @@ -373,7 +393,7 @@ export function renderModels(stats: UsageStats, width: number): string { } export function renderProviders(stats: UsageStats, width: number): string { - const providerStats = getProviderStats(stats.entries); + const providerStats = stats.summary?.providers ?? getProviderStats(stats.entries); if (providerStats.length === 0) return renderBox(["No provider data available"], width, "Providers"); const lines: string[] = []; @@ -389,8 +409,8 @@ export function renderProviders(stats: UsageStats, width: number): string { } export function renderTokens(stats: UsageStats, width: number): string { - const totals = getTotals(stats.entries); - const modelStats = getModelStats(stats.entries); + const totals = stats.summary?.totals ?? getTotals(stats.entries); + const modelStats = stats.summary?.models ?? getModelStats(stats.entries); const summaryLines = [ `${COLORS.bold}Total Prompt Tokens:${COLORS.reset} ${formatNumber(totals.promptTokens)}`, `${COLORS.bold}Total Completion Tokens:${COLORS.reset} ${formatNumber(totals.completionTokens)}`, @@ -410,23 +430,25 @@ export function renderTokens(stats: UsageStats, width: number): string { output += "\n" + renderBox(tokenLines, width, "Tokens by Model"); } - const sizeBuckets = { - tiny: { min: 0, max: 1000, count: 0, cost: 0 }, - small: { min: 1000, max: 10000, count: 0, cost: 0 }, - medium: { min: 10000, max: 50000, count: 0, cost: 0 }, - large: { min: 50000, max: 100000, count: 0, cost: 0 }, - huge: { min: 100000, max: Infinity, count: 0, cost: 0 }, - }; - - for (const entry of stats.entries) { - for (const bucket of Object.values(sizeBuckets)) { - if (entry.totalTokens >= bucket.min && entry.totalTokens < bucket.max) { - bucket.count++; - bucket.cost += entry.satsCost; - break; + const sizeBuckets = stats.summary?.sizeBuckets ?? (() => { + const b = { + tiny: { min: 0, max: 1000, count: 0, cost: 0 }, + small: { min: 1000, max: 10000, count: 0, cost: 0 }, + medium: { min: 10000, max: 50000, count: 0, cost: 0 }, + large: { min: 50000, max: 100000, count: 0, cost: 0 }, + huge: { min: 100000, max: Infinity, count: 0, cost: 0 }, + }; + for (const entry of stats.entries) { + for (const bucket of Object.values(b)) { + if (entry.totalTokens >= bucket.min && entry.totalTokens < bucket.max) { + bucket.count++; + bucket.cost += entry.satsCost; + break; + } } } - } + return b; + })(); const sizeLines = Object.entries(sizeBuckets).map(([name, bucket]) => `${name.padEnd(6)}: ${formatReqs(bucket.count).padStart(5)} reqs, ${formatCost(bucket.cost)} sats`); output += "\n" + renderBox(sizeLines, width, "Request Size Distribution"); @@ -434,7 +456,7 @@ export function renderTokens(stats: UsageStats, width: number): string { } export function renderClients(stats: UsageStats, width: number): string { - const clientStats = getClientStats(stats.entries); + const clientStats = stats.summary?.clients ?? getClientStats(stats.entries); if (clientStats.length === 0) return renderBox(["No client data available (API key auth not used)"], width, "Client Breakdown"); // Use totalSatsCost (all-time) for percentage calculations to match header @@ -478,31 +500,43 @@ export function renderClients(stats: UsageStats, width: number): string { endBarSection("client-detail"); let output = renderBox(lines, width, "Client Breakdown"); - const clientModelMap = new Map>(); - - for (const entry of stats.entries) { - const client = entry.client || "unknown"; - const model = entry.modelId; - if (!clientModelMap.has(client)) clientModelMap.set(client, new Map()); - const modelMap = clientModelMap.get(client)!; - const existing = modelMap.get(model) || { requests: 0, satsCost: 0, tokens: 0 }; - modelMap.set(model, { - requests: existing.requests + 1, - satsCost: existing.satsCost + entry.satsCost, - tokens: existing.tokens + entry.totalTokens, - }); - } - const clientModelLines: string[] = []; - for (const topClient of clientStats.slice(0, 3)) { - const modelMap = clientModelMap.get(topClient.client); - if (!modelMap) continue; - const models = Array.from(modelMap.entries()).sort((a, b) => b[1].satsCost - a[1].satsCost).slice(0, 5); - clientModelLines.push(`${COLORS.bold}${topClient.client}${COLORS.reset} (${formatReqs(topClient.requests)} reqs, ${formatCost(topClient.satsCost)} sats)`); - for (const [model, data] of models) { - clientModelLines.push(` ${(MODEL_COLORS[model] || MODEL_COLORS.default)}${model.padEnd(18)}${COLORS.reset} ${formatNumber(data.tokens).padEnd(8)} tokens ${formatCost(data.satsCost)} sats`); + + if (stats.summary) { + // Use pre-aggregated topModels from summary + for (const topClient of stats.summary.clients.slice(0, 3)) { + if (topClient.topModels.length === 0) continue; + clientModelLines.push(`${COLORS.bold}${topClient.client}${COLORS.reset} (${formatReqs(topClient.requests)} reqs, ${formatCost(topClient.satsCost)} sats)`); + for (const m of topClient.topModels) { + clientModelLines.push(` ${(MODEL_COLORS[m.modelId] || MODEL_COLORS.default)}${m.modelId.padEnd(18)}${COLORS.reset} ${formatNumber(m.totalTokens).padEnd(8)} tokens ${formatCost(m.satsCost)} sats`); + } + clientModelLines.push(""); + } + } else { + // Fallback: compute from raw entries + const clientModelMap = new Map>(); + for (const entry of stats.entries) { + const client = entry.client || "unknown"; + const model = entry.modelId; + if (!clientModelMap.has(client)) clientModelMap.set(client, new Map()); + const modelMap = clientModelMap.get(client)!; + const existing = modelMap.get(model) || { requests: 0, satsCost: 0, tokens: 0 }; + modelMap.set(model, { + requests: existing.requests + 1, + satsCost: existing.satsCost + entry.satsCost, + tokens: existing.tokens + entry.totalTokens, + }); + } + for (const topClient of clientStats.slice(0, 3)) { + const modelMap = clientModelMap.get(topClient.client); + if (!modelMap) continue; + const models = Array.from(modelMap.entries()).sort((a, b) => b[1].satsCost - a[1].satsCost).slice(0, 5); + clientModelLines.push(`${COLORS.bold}${topClient.client}${COLORS.reset} (${formatReqs(topClient.requests)} reqs, ${formatCost(topClient.satsCost)} sats)`); + for (const [model, data] of models) { + clientModelLines.push(` ${(MODEL_COLORS[model] || MODEL_COLORS.default)}${model.padEnd(18)}${COLORS.reset} ${formatNumber(data.tokens).padEnd(8)} tokens ${formatCost(data.satsCost)} sats`); + } + clientModelLines.push(""); } - clientModelLines.push(""); } if (clientModelLines.length > 0) { @@ -512,7 +546,7 @@ export function renderClients(stats: UsageStats, width: number): string { } export function renderNpubs(stats: UsageStats, clients: ClientInfo[], width: number): string { - const npubStats = getNpubStats(stats.entries, clients); + const npubStats = stats.summary?.npubs ?? getNpubStats(stats.entries, clients); if (npubStats.length === 0) return renderBox(["No npub data available"], width, "Npub Breakdown"); const totalCost = stats.totalSatsCost; @@ -557,38 +591,48 @@ export function renderNpubs(stats: UsageStats, clients: ClientInfo[], width: num endBarSection("npub-detail"); let output = renderBox(lines, width, "Npub Breakdown"); - - // Top models per npub (same pattern as clients tab) - const npubModelMap = new Map>(); - const clientNpubMap = new Map(); - for (const c of clients) { - if (c.ownerNpub) clientNpubMap.set(c.clientId, c.ownerNpub); - } - - for (const entry of stats.entries) { - const npub = clientNpubMap.get(entry.client || ""); - if (!npub) continue; - const model = entry.modelId; - if (!npubModelMap.has(npub)) npubModelMap.set(npub, new Map()); - const modelMap = npubModelMap.get(npub)!; - const existing = modelMap.get(model) || { requests: 0, satsCost: 0, tokens: 0 }; - modelMap.set(model, { - requests: existing.requests + 1, - satsCost: existing.satsCost + entry.satsCost, - tokens: existing.tokens + entry.totalTokens, - }); - } - const npubModelLines: string[] = []; - for (const topNpub of npubStats.slice(0, 5)) { - const modelMap = npubModelMap.get(topNpub.npub); - if (!modelMap) continue; - const models = Array.from(modelMap.entries()).sort((a, b) => b[1].satsCost - a[1].satsCost).slice(0, 5); - npubModelLines.push(`${COLORS.bold}${truncateNpub(topNpub.npub)}${COLORS.reset} (${formatReqs(topNpub.requests)} reqs, ${formatCost(topNpub.satsCost)} sats)`); - for (const [model, data] of models) { - npubModelLines.push(` ${(MODEL_COLORS[model] || MODEL_COLORS.default)}${model.padEnd(18)}${COLORS.reset} ${formatNumber(data.tokens).padEnd(8)} tokens ${formatCost(data.satsCost)} sats`); + + if (stats.summary) { + // Use pre-aggregated topModels from summary + for (const topNpub of stats.summary.npubs.slice(0, 5)) { + if (topNpub.topModels.length === 0) continue; + npubModelLines.push(`${COLORS.bold}${truncateNpub(topNpub.npub)}${COLORS.reset} (${formatReqs(topNpub.requests)} reqs, ${formatCost(topNpub.satsCost)} sats)`); + for (const m of topNpub.topModels) { + npubModelLines.push(` ${(MODEL_COLORS[m.modelId] || MODEL_COLORS.default)}${m.modelId.padEnd(18)}${COLORS.reset} ${formatNumber(m.totalTokens).padEnd(8)} tokens ${formatCost(m.satsCost)} sats`); + } + npubModelLines.push(""); + } + } else { + // Fallback: compute from raw entries + const npubModelMap = new Map>(); + const clientNpubMap = new Map(); + for (const c of clients) { + if (c.ownerNpub) clientNpubMap.set(c.clientId, c.ownerNpub); + } + for (const entry of stats.entries) { + const npub = clientNpubMap.get(entry.client || ""); + if (!npub) continue; + const model = entry.modelId; + if (!npubModelMap.has(npub)) npubModelMap.set(npub, new Map()); + const modelMap = npubModelMap.get(npub)!; + const existing = modelMap.get(model) || { requests: 0, satsCost: 0, tokens: 0 }; + modelMap.set(model, { + requests: existing.requests + 1, + satsCost: existing.satsCost + entry.satsCost, + tokens: existing.tokens + entry.totalTokens, + }); + } + for (const topNpub of npubStats.slice(0, 5)) { + const modelMap = npubModelMap.get(topNpub.npub); + if (!modelMap) continue; + const models = Array.from(modelMap.entries()).sort((a, b) => b[1].satsCost - a[1].satsCost).slice(0, 5); + npubModelLines.push(`${COLORS.bold}${truncateNpub(topNpub.npub)}${COLORS.reset} (${formatReqs(topNpub.requests)} reqs, ${formatCost(topNpub.satsCost)} sats)`); + for (const [model, data] of models) { + npubModelLines.push(` ${(MODEL_COLORS[model] || MODEL_COLORS.default)}${model.padEnd(18)}${COLORS.reset} ${formatNumber(data.tokens).padEnd(8)} tokens ${formatCost(data.satsCost)} sats`); + } + npubModelLines.push(""); } - npubModelLines.push(""); } if (npubModelLines.length > 0) { diff --git a/src/tui/usage/types.ts b/src/tui/usage/types.ts index 7e204bd..d49476d 100644 --- a/src/tui/usage/types.ts +++ b/src/tui/usage/types.ts @@ -1,4 +1,7 @@ import type { UsageTrackingEntry } from "../../daemon/types.ts"; +import type { UsageSummary } from "../../daemon/http/usage-summary.ts"; + +export type { UsageSummary }; export interface UsageStats { entries: UsageTrackingEntry[]; @@ -6,6 +9,8 @@ export interface UsageStats { totalSatsCost: number; recentSatsCost: number; limit: number; + /** Present when data was fetched via /usage/summary (server-aggregated path). */ + summary?: UsageSummary; } export interface DayStats { From f0978f22c1b718bd5a50b1f9f7e278c79e95b6c3 Mon Sep 17 00:00:00 2001 From: Bilthon Date: Tue, 2 Jun 2026 14:53:38 -0500 Subject: [PATCH 05/17] test: cover /usage/summary builder --- src/daemon/http/usage-summary.test.ts | 257 ++++++++++++++++++++++++++ 1 file changed, 257 insertions(+) create mode 100644 src/daemon/http/usage-summary.test.ts diff --git a/src/daemon/http/usage-summary.test.ts b/src/daemon/http/usage-summary.test.ts new file mode 100644 index 0000000..08af1a8 --- /dev/null +++ b/src/daemon/http/usage-summary.test.ts @@ -0,0 +1,257 @@ +import { describe, it, expect, beforeEach } from "bun:test"; +import { createMemoryUsageTrackingDriver } from "@routstr/sdk/storage"; +import type { UsageTrackingEntry } from "@routstr/sdk/storage"; +import type { ClientEntry } from "../../utils/clients"; +import { getUsageSummary } from "./usage-summary"; + +// ─── Test fixtures ──────────────────────────────────────────────────────────── +// +// All timestamps fall within 30 days of 2026-06-02 (today as of this writing). +// tz = 300 (UTC-5 / EST). After shifting by -300min, UTC dates become: +// d1: 2026-05-20T10:00Z → shifted 2026-05-20T05:00Z → local day "2026-05-20", hour 05 +// d2: 2026-05-21T03:00Z → shifted 2026-05-20T22:00Z → local day "2026-05-20", hour 22 +// d3: 2026-05-21T06:00Z → shifted 2026-05-21T01:00Z → local day "2026-05-21", hour 01 + +const D1 = new Date("2026-05-20T10:00:00Z").getTime(); // local 2026-05-20, hour 05 +const D2 = new Date("2026-05-21T03:00:00Z").getTime(); // local 2026-05-20, hour 22 +const D3 = new Date("2026-05-21T06:00:00Z").getTime(); // local 2026-05-21, hour 01 + +const BASE_ENTRY: Omit = { + baseUrl: "https://api.openai.com/", + requestId: "req-1", + cost: 0.01, + satsCost: 10, + promptTokens: 100, + completionTokens: 50, +}; + +function makeEntry( + id: string, + overrides: Partial & Pick, +): UsageTrackingEntry { + return { + ...BASE_ENTRY, + ...overrides, + id, + }; +} + +// Seed entries: +// - 2 entries for model-a, client-1 (owned by npub1) +// - 1 entry for model-b, client-2 (no npub) +// - 1 entry for model-a, no client (null client) +// - totalTokens spread across tiny (< 1000) and small (1000-10000) buckets + +const ENTRIES: UsageTrackingEntry[] = [ + // model-a, client-1, day 2026-05-20 (D1), totalTokens=500 (tiny) + makeEntry("e1", { + id: "e1", + timestamp: D1, + modelId: "model-a", + client: "client-1", + totalTokens: 500, + promptTokens: 350, + completionTokens: 150, + satsCost: 10, + cost: 0.01, + }), + // model-a, client-1, day 2026-05-20 (D2), totalTokens=2000 (small) + makeEntry("e2", { + id: "e2", + timestamp: D2, + modelId: "model-a", + client: "client-1", + totalTokens: 2000, + promptTokens: 1400, + completionTokens: 600, + satsCost: 20, + cost: 0.02, + }), + // model-b, client-2, day 2026-05-21 (D3), totalTokens=800 (tiny) + makeEntry("e3", { + id: "e3", + timestamp: D3, + modelId: "model-b", + client: "client-2", + totalTokens: 800, + promptTokens: 600, + completionTokens: 200, + satsCost: 8, + cost: 0.008, + }), + // model-a, no client, day 2026-05-21 (D3), totalTokens=5000 (small) + makeEntry("e4", { + id: "e4", + timestamp: D3 + 1000, + modelId: "model-a", + client: undefined, + totalTokens: 5000, + promptTokens: 3500, + completionTokens: 1500, + satsCost: 50, + cost: 0.05, + }), +]; + +const CLIENTS: ClientEntry[] = [ + { + clientId: "client-1", + name: "Client One", + apiKey: "sk-client1", + createdAt: D1 - 86400000, + ownerNpub: "npub1abc", + }, + { + clientId: "client-2", + name: "Client Two", + apiKey: "sk-client2", + createdAt: D1 - 86400000, + // no ownerNpub + }, +]; + +const TZ = 300; // UTC-5 / EST + +describe("getUsageSummary", () => { + let driver: ReturnType; + + beforeEach(async () => { + // Reset module-level cache between tests by creating a fresh driver with a + // distinct entry count each time — the cache key changes. + driver = createMemoryUsageTrackingDriver(ENTRIES); + }); + + it("returns correct totals", async () => { + const summary = await getUsageSummary(driver, CLIENTS, TZ); + + expect(summary.totals.requests).toBe(4); + expect(summary.totals.totalTokens).toBe(500 + 2000 + 800 + 5000); + expect(summary.totals.promptTokens).toBe(350 + 1400 + 600 + 3500); + expect(summary.totals.completionTokens).toBe(150 + 600 + 200 + 1500); + expect(summary.totals.satsCost).toBe(10 + 20 + 8 + 50); + expect(summary.totals.cost).toBeCloseTo(0.01 + 0.02 + 0.008 + 0.05, 5); + }); + + it("returns models sorted desc by satsCost", async () => { + const summary = await getUsageSummary(driver, CLIENTS, TZ); + const { models } = summary; + + // model-a: 3 entries (e1, e2, e4) total satsCost = 10+20+50 = 80 + // model-b: 1 entry (e3) satsCost = 8 + expect(models).toHaveLength(2); + expect(models[0]!.modelId).toBe("model-a"); + expect(models[0]!.satsCost).toBe(80); + expect(models[0]!.requests).toBe(3); + expect(models[1]!.modelId).toBe("model-b"); + expect(models[1]!.satsCost).toBe(8); + }); + + it("returns days most-recent-first with correct tz-bucketing", async () => { + const summary = await getUsageSummary(driver, CLIENTS, TZ); + const { days } = summary; + + // D1 (10:00Z) → local 2026-05-20, D2 (03:00Z) → local 2026-05-20, D3 (06:00Z) → local 2026-05-21 + // So: day 2026-05-20 has e1+e2, day 2026-05-21 has e3+e4 + expect(days.length).toBeGreaterThanOrEqual(2); + + // Most-recent-first: 2026-05-21 first + expect(days[0]!.date).toBe("2026-05-21"); + expect(days[0]!.requests).toBe(2); // e3, e4 + expect(days[0]!.satsCost).toBe(8 + 50); + + expect(days[1]!.date).toBe("2026-05-20"); + expect(days[1]!.requests).toBe(2); // e1, e2 + expect(days[1]!.satsCost).toBe(10 + 20); + }); + + it("returns empty hoursToday (test entries are not today)", async () => { + const summary = await getUsageSummary(driver, CLIENTS, TZ); + // Test entries are in May 2026, not today (June 2026) + expect(summary.hoursToday).toHaveLength(0); + }); + + it("returns correct sizeBuckets", async () => { + const summary = await getUsageSummary(driver, CLIENTS, TZ); + const { sizeBuckets } = summary; + + // tiny [0, 1000): e1 (500) + e3 (800) = 2 entries + expect(sizeBuckets.tiny.count).toBe(2); + expect(sizeBuckets.tiny.cost).toBe(10 + 8); + + // small [1000, 10000): e2 (2000) + e4 (5000) = 2 entries + expect(sizeBuckets.small.count).toBe(2); + expect(sizeBuckets.small.cost).toBe(20 + 50); + + // medium, large, huge should be 0 + expect(sizeBuckets.medium.count).toBe(0); + expect(sizeBuckets.large.count).toBe(0); + expect(sizeBuckets.huge.count).toBe(0); + }); + + it("returns per-client topModels for top non-null clients", async () => { + const summary = await getUsageSummary(driver, CLIENTS, TZ); + const { clients } = summary; + + // client-1: e1 + e2 (model-a), satsCost = 30 + // client-2: e3 (model-b), satsCost = 8 + // unknown (null client): e4 (model-a), satsCost = 50 + // Sorted desc by satsCost: unknown (50), client-1 (30), client-2 (8) + + const c1 = clients.find((c) => c.client === "client-1"); + expect(c1).toBeDefined(); + expect(c1!.requests).toBe(2); + expect(c1!.satsCost).toBe(30); + // client-1 is in top 3 non-null clients, should have topModels + expect(c1!.topModels.length).toBeGreaterThan(0); + expect(c1!.topModels[0]!.modelId).toBe("model-a"); + + const c2 = clients.find((c) => c.client === "client-2"); + expect(c2).toBeDefined(); + expect(c2!.requests).toBe(1); + expect(c2!.satsCost).toBe(8); + + // unknown client (null group) should have empty topModels + const unknown = clients.find((c) => c.client === "unknown"); + expect(unknown).toBeDefined(); + expect(unknown!.topModels).toHaveLength(0); + }); + + it("folds client rows into npubs correctly", async () => { + const summary = await getUsageSummary(driver, CLIENTS, TZ); + const { npubs } = summary; + + // Only client-1 has ownerNpub = "npub1abc" + // npub1abc: e1 + e2 = requests 2, satsCost 30 + expect(npubs).toHaveLength(1); + expect(npubs[0]!.npub).toBe("npub1abc"); + expect(npubs[0]!.requests).toBe(2); + expect(npubs[0]!.satsCost).toBe(30); + // Top models for npub1abc (clients: ["client-1"]): only model-a + expect(npubs[0]!.topModels.length).toBeGreaterThan(0); + expect(npubs[0]!.topModels[0]!.modelId).toBe("model-a"); + }); + + it("returns recent entries (up to 50)", async () => { + const summary = await getUsageSummary(driver, CLIENTS, TZ); + expect(summary.recent).toHaveLength(4); // only 4 entries in driver + // Should be sorted desc by timestamp + expect(summary.recent[0]!.timestamp).toBeGreaterThanOrEqual(summary.recent[1]!.timestamp); + }); + + it("returns providers correctly", async () => { + const summary = await getUsageSummary(driver, CLIENTS, TZ); + const { providers } = summary; + + // All entries use the same baseUrl + expect(providers).toHaveLength(1); + expect(providers[0]!.baseUrl).toBe("https://api.openai.com/"); + expect(providers[0]!.requests).toBe(4); + }); + + it("caches results for same key within TTL", async () => { + const summary1 = await getUsageSummary(driver, CLIENTS, TZ); + const summary2 = await getUsageSummary(driver, CLIENTS, TZ); + // Same object reference means it was served from cache + expect(summary1).toBe(summary2); + }); +}); From b60b810dc8cb7dfc4fee534e220a1c9f1f534a3a Mon Sep 17 00:00:00 2001 From: Bilthon Date: Tue, 2 Jun 2026 15:26:14 -0500 Subject: [PATCH 06/17] fix(monitor): match summary today bucket to local day --- src/tui/usage/render.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/tui/usage/render.ts b/src/tui/usage/render.ts index 0a84823..7061499 100644 --- a/src/tui/usage/render.ts +++ b/src/tui/usage/render.ts @@ -283,7 +283,12 @@ export function renderOverview(stats: UsageStats, balance: BalanceInfo | null, s export function renderToday(stats: UsageStats, width: number): string { const currentHour = new Date().getHours(); - const todayDateStr = formatDate(Date.now()); + // Build today's date string from LOCAL date components so it matches the + // server's tz-bucketed day keys (which use the client's tzOffsetMinutes). + // formatDate() returns a UTC date via toISOString() and would mismatch for + // non-UTC users. + const _now = new Date(); + const todayDateStr = `${_now.getFullYear()}-${String(_now.getMonth() + 1).padStart(2, "0")}-${String(_now.getDate()).padStart(2, "0")}`; let todayStats: { date: string; requests: number; satsCost: number; promptTokens: number; completionTokens: number; totalTokens: number }; let recentDays: Array<{ date: string; requests: number; satsCost: number; totalTokens: number; promptTokens: number; completionTokens: number }>; From c08f64941e141066b9ccf5052d01030d657fb950 Mon Sep 17 00:00:00 2001 From: Bilthon Date: Tue, 2 Jun 2026 15:26:43 -0500 Subject: [PATCH 07/17] fix(monitor): align top-models-per-client across summary and legacy --- src/tui/usage/render.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/tui/usage/render.ts b/src/tui/usage/render.ts index 7061499..2da00bc 100644 --- a/src/tui/usage/render.ts +++ b/src/tui/usage/render.ts @@ -508,8 +508,9 @@ export function renderClients(stats: UsageStats, width: number): string { const clientModelLines: string[] = []; if (stats.summary) { - // Use pre-aggregated topModels from summary - for (const topClient of stats.summary.clients.slice(0, 3)) { + // Use pre-aggregated topModels from summary; exclude the "unknown" bucket + // (null client rows have no meaningful model attribution to display here). + for (const topClient of stats.summary.clients.filter((c) => c.client !== "unknown").slice(0, 3)) { if (topClient.topModels.length === 0) continue; clientModelLines.push(`${COLORS.bold}${topClient.client}${COLORS.reset} (${formatReqs(topClient.requests)} reqs, ${formatCost(topClient.satsCost)} sats)`); for (const m of topClient.topModels) { @@ -518,7 +519,8 @@ export function renderClients(stats: UsageStats, width: number): string { clientModelLines.push(""); } } else { - // Fallback: compute from raw entries + // Fallback: compute from raw entries; exclude the "unknown" bucket to match + // the summary path. const clientModelMap = new Map>(); for (const entry of stats.entries) { const client = entry.client || "unknown"; @@ -532,7 +534,7 @@ export function renderClients(stats: UsageStats, width: number): string { tokens: existing.tokens + entry.totalTokens, }); } - for (const topClient of clientStats.slice(0, 3)) { + for (const topClient of clientStats.filter((c) => c.client !== "unknown").slice(0, 3)) { const modelMap = clientModelMap.get(topClient.client); if (!modelMap) continue; const models = Array.from(modelMap.entries()).sort((a, b) => b[1].satsCost - a[1].satsCost).slice(0, 5); From bc8611ba7f4bf467db76194076b89e579ef09abc Mon Sep 17 00:00:00 2001 From: Bilthon Date: Tue, 2 Jun 2026 15:26:55 -0500 Subject: [PATCH 08/17] fix(daemon): include client identity in usage-summary cache key --- src/daemon/http/usage-summary.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/daemon/http/usage-summary.ts b/src/daemon/http/usage-summary.ts index ccd3023..5e164e6 100644 --- a/src/daemon/http/usage-summary.ts +++ b/src/daemon/http/usage-summary.ts @@ -129,9 +129,12 @@ export async function getUsageSummary( clients: ClientEntry[], tzOffsetMinutes: number, ): Promise { - // Cheap cache key: total row count + clients length + tz + // Cache key: total row count + per-client identity (id + ownerNpub) + tz. + // Including ownerNpub ensures a client assignment change invalidates the cache + // even though the row count doesn't change. const count = await driver.count(); - const cacheKey = `${count}:${clients.length}:${tzOffsetMinutes}`; + const clientIdentity = clients.map((c) => `${c.clientId}:${c.ownerNpub ?? ""}`).join(","); + const cacheKey = `${count}:${clientIdentity}:${tzOffsetMinutes}`; const now = Date.now(); if ( From c47145137d7a26e05915021605eb814e90df2f73 Mon Sep 17 00:00:00 2001 From: Bilthon Date: Tue, 2 Jun 2026 15:27:19 -0500 Subject: [PATCH 09/17] fix(test): export reset hook and call it in beforeEach for cache isolation --- src/daemon/http/usage-summary.test.ts | 8 +++++--- src/daemon/http/usage-summary.ts | 5 +++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/daemon/http/usage-summary.test.ts b/src/daemon/http/usage-summary.test.ts index 08af1a8..59f97df 100644 --- a/src/daemon/http/usage-summary.test.ts +++ b/src/daemon/http/usage-summary.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach } from "bun:test"; import { createMemoryUsageTrackingDriver } from "@routstr/sdk/storage"; import type { UsageTrackingEntry } from "@routstr/sdk/storage"; import type { ClientEntry } from "../../utils/clients"; -import { getUsageSummary } from "./usage-summary"; +import { getUsageSummary, __resetUsageSummaryCacheForTest } from "./usage-summary"; // ─── Test fixtures ──────────────────────────────────────────────────────────── // @@ -116,8 +116,10 @@ describe("getUsageSummary", () => { let driver: ReturnType; beforeEach(async () => { - // Reset module-level cache between tests by creating a fresh driver with a - // distinct entry count each time — the cache key changes. + // Reset the module-level memo cache explicitly so each test starts cold. + // Without this, tests 2-10 would share the cached object from test 1 + // (all use the same fixtures → identical cache key → same TTL window). + __resetUsageSummaryCacheForTest(); driver = createMemoryUsageTrackingDriver(ENTRIES); }); diff --git a/src/daemon/http/usage-summary.ts b/src/daemon/http/usage-summary.ts index 5e164e6..a17460f 100644 --- a/src/daemon/http/usage-summary.ts +++ b/src/daemon/http/usage-summary.ts @@ -122,6 +122,11 @@ interface CacheEntry { let _cache: CacheEntry | null = null; const CACHE_TTL_MS = 60_000; +/** Clears the module-level memo cache. Intended for use in unit tests only. */ +export function __resetUsageSummaryCacheForTest(): void { + _cache = null; +} + // ─── Main builder ───────────────────────────────────────────────────────────── export async function getUsageSummary( From 6b746debede605b5a3518bf9848d98a7895b16f1 Mon Sep 17 00:00:00 2001 From: Bilthon Date: Tue, 2 Jun 2026 22:03:56 -0500 Subject: [PATCH 10/17] fix(monitor): use all-time summary totals in overview and exclude today from recent days --- src/tui/usage/render.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/tui/usage/render.ts b/src/tui/usage/render.ts index 2da00bc..5d6a365 100644 --- a/src/tui/usage/render.ts +++ b/src/tui/usage/render.ts @@ -120,8 +120,10 @@ export function renderBarChart( export function renderOverview(stats: UsageStats, balance: BalanceInfo | null, status: StatusInfo | null, width: number): string { - // Use the server-calculated totals (all entries) instead of summing limited entries - const totals = getTotals(stats.entries); + // Token totals must come from the all-time summary (when present) to stay on + // the same horizon as totalRequests/totalSatsCost below — stats.entries holds + // only the latest 50 in summary mode. Fall back to entries for the legacy path. + const totals = stats.summary?.totals ?? getTotals(stats.entries); const totalRequests = stats.totalEntries; // Use server's total count, not entries.length const totalVisibleCost = stats.totalSatsCost; // <-- Use server's total, not client-side sum of limited entries const avgCost = totalRequests > 0 ? totalVisibleCost / totalRequests : 0; @@ -302,7 +304,9 @@ export function renderToday(stats: UsageStats, width: number): string { todayStats = todayDayStat ? { date: todayDayStat.date, requests: todayDayStat.requests, satsCost: todayDayStat.satsCost, promptTokens: todayDayStat.promptTokens, completionTokens: todayDayStat.completionTokens, totalTokens: todayDayStat.totalTokens } : { date: todayDateStr, requests: 0, satsCost: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0 }; - recentDays = days.slice(1, 7); + // Exclude today by date (it has its own box) rather than by position — + // days[0] is only today when there was activity today. + recentDays = days.filter((d) => d.date !== todayDateStr).slice(0, 6); hourlyMap = new Map(hoursToday.map((h) => [h.hour, { requests: h.requests, satsCost: h.satsCost }])); } else { // Fallback: compute from raw entries From bc7ac9ff01ce56f7865bbb1b58d4b24025865c5c Mon Sep 17 00:00:00 2001 From: Bilthon Date: Wed, 3 Jun 2026 11:39:16 -0500 Subject: [PATCH 11/17] fix(daemon): import bun ModelManager for event persistence --- src/daemon/index.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/daemon/index.ts b/src/daemon/index.ts index 0c1b16d..c10e6f4 100644 --- a/src/daemon/index.ts +++ b/src/daemon/index.ts @@ -1,11 +1,15 @@ import { createServer } from "http"; import { existsSync } from "fs"; import { - ModelManager, ProviderManager, createStorageAdapterFromStore, createSdkStore, } from "@routstr/sdk"; +// ModelManager must come from the bun entrypoint so persistent Nostr event +// storage (eventStoreDbPath) gets its SQLite-backed factory. The default +// "@routstr/sdk" export is browser-safe and throws without that factory +// (SDK 0.3.7+ browser-safe entrypoint split). +import { ModelManager } from "@routstr/sdk/bun"; import type { SdkLogger } from "@routstr/sdk"; import { CONFIG_DIR, DB_PATH, SOCKET_PATH, PID_FILE } from "../utils/config"; import { logger } from "../utils/logger"; From a637ecf8041fd4f7f3c1f3d03b4e09087ef15aad Mon Sep 17 00:00:00 2001 From: Bilthon Date: Thu, 4 Jun 2026 00:46:52 -0500 Subject: [PATCH 12/17] bench: add in-process /usage/summary vs legacy benchmark --- scripts/bench-usage.ts | 377 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 377 insertions(+) create mode 100644 scripts/bench-usage.ts diff --git a/scripts/bench-usage.ts b/scripts/bench-usage.ts new file mode 100644 index 0000000..cc8a540 --- /dev/null +++ b/scripts/bench-usage.ts @@ -0,0 +1,377 @@ +/** + * bench-usage.ts — In-process, no-network benchmark (Option B). + * + * Compares the NEW server-aggregated summary path against the LEGACY + * "fetch-everything-then-re-aggregate" path, in terms of TIME, highlighting + * the daemon-side cache that lives in `getUsageSummary`. + * + * It deliberately SKIPS the HTTP layer. In production: + * - `fetchUsageSummary()` HTTP-calls the daemon, which runs `getUsageSummary()`. + * - `fetchUsage(N)` HTTP-calls the daemon, which runs `driver.list({limit:N})`. + * The real localhost socket round-trip (~1-3 ms) is roughly CONSTANT for both + * paths, so excluding it isolates the actual computational difference — which is + * the whole point. See the takeaway printed at the end. + * + * Run from the repo root: bun scripts/bench-usage.ts [--json] + * + */ + +// @ts-ignore — bun:sqlite is a Bun built-in module (matches storage/bun.ts). +import { Database } from "bun:sqlite"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { rmSync } from "node:fs"; + +import { createBunSqliteUsageTrackingDriver } from "@routstr/sdk/storage/bun"; +import { + getUsageSummary, + __resetUsageSummaryCacheForTest, +} from "../src/daemon/http/usage-summary.ts"; +import { + getTotals, + getModelStats, + getProviderStats, + getClientStats, + getDayStats, + getHourlyToday, + getNpubStats, +} from "../src/tui/usage/data.ts"; +import type { UsageTrackingEntry } from "../src/daemon/types.ts"; + +// ─── Config ────────────────────────────────────────────────────────────────── + +const SIZES = [100, 1000, 2500, 5000, 10000, 20000]; +const SAMPLES = 25; // median of 25, after a warmup +const STEADY_REFRESHES = 10; // the monitor's ~2s refresh loop +const TZ_OFFSET = 300; // synthetic UTC-5; fixed so results are reproducible +const DAY_SPREAD = 45; // spread timestamps over ~45 days + +const jsonMode = process.argv.includes("--json"); + +// ─── Synthetic, sanitized templates (no real DB, no real identifiers) ───────── + +interface Template { + modelId: string; + baseUrl: string; + client: string; + cost: number; + satsCost: number; + promptTokens: number; + completionTokens: number; +} + +const PROVIDER_A = "https://api.example-provider-a.com/"; +const PROVIDER_B = "https://api.example-provider-b.com/"; + +const TEMPLATES: Template[] = [ + { modelId: "minimax-m3", baseUrl: PROVIDER_A, client: "client-a", cost: 0.0021, satsCost: 4, promptTokens: 320, completionTokens: 180 }, + { modelId: "kimi-k2.6", baseUrl: PROVIDER_A, client: "client-b", cost: 0.0140, satsCost: 27, promptTokens: 5200, completionTokens: 2400 }, + { modelId: "deepseek-v4-flash", baseUrl: PROVIDER_B, client: "client-c", cost: 0.0008, satsCost: 1, promptTokens: 90, completionTokens: 60 }, + { modelId: "deepseek-v4-flash", baseUrl: PROVIDER_B, client: "client-a", cost: 0.0420, satsCost: 81, promptTokens: 24000, completionTokens: 13000 }, + { modelId: "kimi-k2.6", baseUrl: PROVIDER_B, client: "client-b", cost: 0.0065, satsCost: 12, promptTokens: 1800, completionTokens: 950 }, +]; + +// Clients array (shape works for both getUsageSummary's ClientEntry and +// data.ts's ClientInfo — both only read clientId / ownerNpub). ~half get a +// synthetic ownerNpub so the npub aggregation path is exercised. +const CLIENT_IDS = [...new Set(TEMPLATES.map((t) => t.client))]; +const clients = CLIENT_IDS.map((id, i) => ({ + clientId: id, + name: id, + apiKey: `apikey-${id}`, + createdAt: 0, + ownerNpub: i % 2 === 0 ? `npub1bench${id.replace(/[^a-z]/g, "")}` : undefined, +})); + +// ─── Row generation ──────────────────────────────────────────────────────────── + +function generateRows(n: number, now: number): UsageTrackingEntry[] { + const rows: UsageTrackingEntry[] = new Array(n); + const spreadMs = DAY_SPREAD * 86_400_000; + for (let i = 0; i < n; i++) { + const t = TEMPLATES[i % TEMPLATES.length]!; + // Spread timestamps backwards from `now` over DAY_SPREAD days, with a + // little jitter so multiple buckets/hours are populated. + const jitter = (i * 97) % 86_400_000; + const timestamp = now - Math.floor((i / n) * spreadMs) - jitter; + const promptTokens = t.promptTokens + (i % 17); + const completionTokens = t.completionTokens + (i % 13); + rows[i] = { + id: `bench-${i}`, + timestamp, + modelId: t.modelId, + baseUrl: t.baseUrl, + requestId: `req-${i}`, + cost: t.cost, + satsCost: t.satsCost, + promptTokens, + completionTokens, + totalTokens: promptTokens + completionTokens, + client: t.client, + sessionId: `sess-${i % 50}`, + tags: [], + }; + } + return rows; +} + +// ─── Seeding (raw transactional insert — fast) ──────────────────────────────── + +const TABLE = "usage_tracking"; + +function seedRawDb(dbPath: string, rows: UsageTrackingEntry[]): void { + const db = new Database(dbPath); + // Match the SDK schema exactly (copied from storage/bun.ts). + db.run(` + CREATE TABLE IF NOT EXISTS ${TABLE} ( + id TEXT PRIMARY KEY, + timestamp INTEGER NOT NULL, + model_id TEXT NOT NULL, + base_url TEXT NOT NULL, + request_id TEXT NOT NULL, + cost REAL NOT NULL, + sats_cost REAL NOT NULL, + prompt_tokens INTEGER NOT NULL, + completion_tokens INTEGER NOT NULL, + total_tokens INTEGER NOT NULL, + client TEXT, + session_id TEXT, + tags TEXT + ) + `); + db.run(`CREATE INDEX IF NOT EXISTS idx_${TABLE}_timestamp ON ${TABLE}(timestamp)`); + db.run(`CREATE INDEX IF NOT EXISTS idx_${TABLE}_model_id ON ${TABLE}(model_id)`); + db.run(`CREATE INDEX IF NOT EXISTS idx_${TABLE}_base_url ON ${TABLE}(base_url)`); + + const insert = db.query(` + INSERT OR REPLACE INTO ${TABLE} ( + id, timestamp, model_id, base_url, request_id, + cost, sats_cost, prompt_tokens, completion_tokens, total_tokens, + client, session_id, tags + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + const seed = db.transaction((batch: UsageTrackingEntry[]) => { + for (const r of batch) { + insert.run( + r.id, r.timestamp, r.modelId, r.baseUrl, r.requestId, + r.cost, r.satsCost, r.promptTokens, r.completionTokens, r.totalTokens, + r.client ?? null, r.sessionId ?? null, JSON.stringify(r.tags ?? []), + ); + } + }); + seed(rows); + db.close(); +} + +// ─── Timing helpers ──────────────────────────────────────────────────────────── + +async function timeMedian(fn: () => Promise, samples = SAMPLES): Promise { + await fn(); // warmup (discarded) + const times: number[] = []; + for (let i = 0; i < samples; i++) { + const start = performance.now(); + await fn(); + times.push(performance.now() - start); + } + times.sort((a, b) => a - b); + return times[Math.floor(times.length / 2)]!; +} + +function runLegacyAggregation(rows: UsageTrackingEntry[]): void { + // Mirror exactly what the monitor's render path does on raw entries. + getTotals(rows); + getModelStats(rows); + getProviderStats(rows); + getClientStats(rows); + getDayStats(rows); + getHourlyToday(rows); + getNpubStats(rows, clients); +} + +// ─── Bench per size ────────────────────────────────────────────────────────── + +interface SizeResult { + size: number; + summaryColdMs: number; + summaryWarmMs: number; + legacyMs: number; + summaryKB: number; + legacyKB: number; + reductionX: number; + steadySummaryMs: number; + steadyLegacyMs: number; + speedupX: number; +} + +async function benchSize(size: number): Promise { + const now = Date.now(); + const dbPath = join(tmpdir(), `routstrd-bench-${size}-${process.pid}-${Date.now()}.db`); + const rows = generateRows(size, now); + seedRawDb(dbPath, rows); + + const driver = await createBunSqliteUsageTrackingDriver({ dbPath }); + + try { + // ── summary cold: reset cache before every timed call ────────────────── + const summaryColdMs = await timeMedian(async () => { + __resetUsageSummaryCacheForTest(); + await getUsageSummary(driver, clients, TZ_OFFSET); + }); + + // ── summary warm: cache already populated (warmup call is itself a hit) ─ + __resetUsageSummaryCacheForTest(); + await getUsageSummary(driver, clients, TZ_OFFSET); // populate once + const summaryWarmMs = await timeMedian(async () => { + await getUsageSummary(driver, clients, TZ_OFFSET); + }); + + // ── legacy: list(limit:N) + full client-side re-aggregation, no cache ── + const legacyMs = await timeMedian(async () => { + const listed = await driver.list({ limit: size }); + runLegacyAggregation(listed); + }); + + // ── payload bytes ────────────────────────────────────────────────────── + __resetUsageSummaryCacheForTest(); + const summaryResult = await getUsageSummary(driver, clients, TZ_OFFSET); + const listedRows = await driver.list({ limit: size }); + const summaryBytes = JSON.stringify(summaryResult).length; + const legacyBytes = JSON.stringify(listedRows).length; + + // ── steady state (the 2s refresh loop, STEADY_REFRESHES iterations) ──── + // summary: 1 cold + (N-1) warm; legacy: N × full cost (no cache). + const steadySummaryMs = + summaryColdMs + (STEADY_REFRESHES - 1) * summaryWarmMs; + const steadyLegacyMs = STEADY_REFRESHES * legacyMs; + + return { + size, + summaryColdMs, + summaryWarmMs, + legacyMs, + summaryKB: summaryBytes / 1024, + legacyKB: legacyBytes / 1024, + reductionX: legacyBytes / summaryBytes, + steadySummaryMs, + steadyLegacyMs, + speedupX: steadyLegacyMs / steadySummaryMs, + }; + } finally { + // driver holds the only open handle now; closing the file by removing it. + try { + rmSync(dbPath, { force: true }); + rmSync(`${dbPath}-shm`, { force: true }); + rmSync(`${dbPath}-wal`, { force: true }); + } catch { /* ignore */ } + } +} + +// ─── Output ────────────────────────────────────────────────────────────────── + +function fmt(n: number, dp = 2): string { + return n.toFixed(dp); +} + +function pad(s: string, width: number): string { + return s.padStart(width); +} + +function printTable(results: SizeResult[]): void { + console.log(""); + console.log("Routstr usage path benchmark — TIME (in-process, no network)"); + console.log("Daemon-side summary cache vs. legacy fetch-all-and-re-aggregate.\n"); + + // ── Payload table (per request) ────────────────────────────── + console.log("Payload per request:"); + const payHeader = [ + pad("size", 7), + pad("summary", 11), + pad("legacy", 11), + pad("reduction", 11), + ].join(" "); + console.log(payHeader); + console.log("-".repeat(payHeader.length)); + for (const r of results) { + console.log([ + pad(String(r.size), 7), + pad(fmt(r.summaryKB, 1) + " KB", 11), + pad(fmt(r.legacyKB, 1) + " KB", 11), + pad(fmt(r.reductionX, 1) + "x", 11), + ].join(" ")); + } + + // ── Time table (ms) ────────────────────────────────────────── + console.log(""); + console.log("Time (ms):"); + const timeHeader = [ + pad("size", 7), + pad("sum cold", 10), + pad("sum warm", 10), + pad("legacy", 10), + pad("steady sum", 12), + pad("steady leg", 12), + pad("speedup", 9), + ].join(" "); + console.log(timeHeader); + console.log("-".repeat(timeHeader.length)); + for (const r of results) { + console.log([ + pad(String(r.size), 7), + pad(fmt(r.summaryColdMs) + "ms", 10), + pad(fmt(r.summaryWarmMs) + "ms", 10), + pad(fmt(r.legacyMs) + "ms", 10), + pad(fmt(r.steadySummaryMs) + "ms", 12), + pad(fmt(r.steadyLegacyMs) + "ms", 12), + pad(fmt(r.speedupX, 1) + "x", 9), + ].join(" ")); + } + + console.log(""); + console.log("Legend (in-process; times = medians of " + SAMPLES + " samples, warmup discarded;"); + console.log("excludes the ~constant localhost HTTP round-trip both paths would add):"); + console.log(" size seeded usage rows in the DB"); + console.log(""); + console.log(" Payload table:"); + console.log(" summary JSON bytes of the /usage/summary response (aggregates + 50 recent rows)"); + console.log(" legacy JSON bytes of the /usage response (all rows)"); + console.log(" reduction legacy / summary"); + console.log(""); + console.log(" Time table:"); + console.log(" sum cold summary build, COLD cache (cache miss: ~15-query rebuild + assemble)"); + console.log(" sum warm summary from WARM cache (count() + return the memoized result)"); + console.log(" legacy list all rows + the client-side re-aggregation the monitor does (no cache)"); + console.log(" steady sum total ms the SUMMARY path spends over " + STEADY_REFRESHES + " refreshes of the monitor's ~2s loop,"); + console.log(" assuming data is unchanged across the window (cache stays valid):"); + console.log(" 1 cold build + " + (STEADY_REFRESHES - 1) + " warm cache hits = cold + " + (STEADY_REFRESHES - 1) + "xwarm. Since warm is ~0.01ms,"); + console.log(" this is in practice ~one cold build."); + console.log(" steady leg same " + STEADY_REFRESHES + " refreshes for LEGACY = legacy x" + STEADY_REFRESHES + " (no cache, full cost each time)"); + console.log(" speedup steady leg / steady sum"); +} + +// ─── Main ──────────────────────────────────────────────────────────────────── + +async function main(): Promise { + const results: SizeResult[] = []; + for (const size of SIZES) { + results.push(await benchSize(size)); + } + + if (jsonMode) { + console.log(JSON.stringify( + { + config: { sizes: SIZES, samples: SAMPLES, steadyRefreshes: STEADY_REFRESHES, tzOffset: TZ_OFFSET }, + note: "In-process; HTTP round-trip (~1-3ms localhost, ~constant for both) excluded.", + results, + }, + null, + 2, + )); + } else { + printTable(results); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); From ae91c46f01b8f3f8adeb1b0e7c8d32c57cf5edd4 Mon Sep 17 00:00:00 2001 From: redshift <213178690+1ftredsh@users.noreply.github.com> Date: Wed, 10 Jun 2026 17:29:19 +0800 Subject: [PATCH 13/17] fix(usage-summary): compute size buckets in JS after SDK removed token-range filters The SDK dropped minTotalTokens/maxTotalTokens from AggregateUsageOptions (routstr-sdk c98de6b), so replace the five aggregate() calls with a single list() and bucket entries in-process. --- src/daemon/http/usage-summary.ts | 51 ++++++++++++++++++++++---------- 1 file changed, 35 insertions(+), 16 deletions(-) diff --git a/src/daemon/http/usage-summary.ts b/src/daemon/http/usage-summary.ts index a17460f..ddf7b5e 100644 --- a/src/daemon/http/usage-summary.ts +++ b/src/daemon/http/usage-summary.ts @@ -99,9 +99,36 @@ 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 }; +/** [minInclusive, maxExclusive) token bounds for each size bucket. */ +const SIZE_BUCKET_BOUNDS = { + tiny: [0, 1000], + small: [1000, 10000], + medium: [10000, 50000], + large: [50000, 100000], + huge: [100000, Infinity], +} as const; + +function computeSizeBuckets( + entries: UsageTrackingEntry[], +): UsageSummary["sizeBuckets"] { + const buckets = { + tiny: emptyBucket(), + small: emptyBucket(), + medium: emptyBucket(), + large: emptyBucket(), + huge: emptyBucket(), + }; + for (const entry of entries) { + for (const [name, [min, max]] of Object.entries(SIZE_BUCKET_BOUNDS)) { + if (entry.totalTokens >= min && entry.totalTokens < max) { + const bucket = buckets[name as keyof typeof buckets]; + bucket.count++; + bucket.cost += entry.satsCost; + break; + } + } + } + return buckets; } /** Returns the UTC ms for the start of the local day containing `now`. */ @@ -275,19 +302,11 @@ export async function getUsageSummary( })); // ── 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), - }; + // The SDK's aggregate() no longer supports token-range filters + // (minTotalTokens/maxTotalTokens were removed in SDK pr-8), so bucket in + // JS from a single list() call instead of five aggregate() queries. + const allEntries = await driver.list(); + const sizeBuckets = computeSizeBuckets(allEntries); // ── Recent entries ───────────────────────────────────────────────────────── const recent = await driver.list({ limit: 50 }); From 829681ffca013fddb4980bf62de8fe499445ce3a Mon Sep 17 00:00:00 2001 From: redshift <213178690+1ftredsh@users.noreply.github.com> Date: Thu, 11 Jun 2026 08:33:15 +0800 Subject: [PATCH 14/17] removed legacy usage support --- src/tui/usage/app.ts | 4 +- src/tui/usage/data.ts | 36 ------------ src/tui/usage/render.ts | 123 ++++------------------------------------ src/tui/usage/types.ts | 2 +- 4 files changed, 13 insertions(+), 152 deletions(-) diff --git a/src/tui/usage/app.ts b/src/tui/usage/app.ts index 99faa77..8d95c96 100644 --- a/src/tui/usage/app.ts +++ b/src/tui/usage/app.ts @@ -1,6 +1,6 @@ import { getVisibleTabs } from "./constants.ts"; import type { Tab } from "./types.ts"; -import { fetchBalance, fetchClients, fetchStatus, fetchUsage, fetchUsageSummary, hasAnyNpubs, isDaemonRunning, type BalanceInfo, type ClientInfo, type StatusInfo } from "./data.ts"; +import { fetchBalance, fetchClients, fetchStatus, fetchUsageSummary, hasAnyNpubs, isDaemonRunning, type BalanceInfo, type ClientInfo, type StatusInfo } from "./data.ts"; import { applyScrollToContent, exitSearchMode, @@ -89,7 +89,7 @@ export async function runUsageTui(): Promise { const height = getHeight(); if (forceFetch || shouldUpdate) { - stats = await fetchUsageSummary() ?? await fetchUsage(10000); + stats = await fetchUsageSummary(); balance = await fetchBalance(); status = await fetchStatus(); clients = await fetchClients(); diff --git a/src/tui/usage/data.ts b/src/tui/usage/data.ts index b7dde44..b3e6681 100644 --- a/src/tui/usage/data.ts +++ b/src/tui/usage/data.ts @@ -76,42 +76,6 @@ export async function fetchBalance(): Promise { } } -export async function fetchUsage(limit = 10000): Promise { - try { - const running = await isDaemonRunning(); - if (!running) return null; - - const result = await callDaemon(`/usage?limit=${limit}`); - if (result.error) return null; - - // The auth proxy filters usage to the authenticated npub's clients and - // returns client IDs without the owner suffix. - const entries = result.output as UsageTrackingEntry[] | undefined; - const visibleEntries = Array.isArray(entries) ? entries : []; - - // Calculate totals from visible entries - const totals = visibleEntries.reduce( - (acc, entry) => ({ - promptTokens: acc.promptTokens + entry.promptTokens, - completionTokens: acc.completionTokens + entry.completionTokens, - totalTokens: acc.totalTokens + entry.totalTokens, - satsCost: acc.satsCost + entry.satsCost, - }), - { promptTokens: 0, completionTokens: 0, totalTokens: 0, satsCost: 0 }, - ); - - return { - entries: visibleEntries, - totalEntries: visibleEntries.length, - totalSatsCost: totals.satsCost, - recentSatsCost: totals.satsCost, // For now, recent = total since we don't have time window - limit, - }; - } catch { - return null; - } -} - export async function fetchUsageSummary(): Promise { try { const running = await isDaemonRunning(); diff --git a/src/tui/usage/render.ts b/src/tui/usage/render.ts index 6026e69..c5a6432 100644 --- a/src/tui/usage/render.ts +++ b/src/tui/usage/render.ts @@ -1,17 +1,8 @@ import { CLIENT_COLORS, COLORS, MODEL_COLORS } from "./constants.ts"; import type { Tab } from "./types.ts"; import { - formatDate, formatNumber, formatTime, - getClientStats, - getDayStats, - getHourlyToday, - getModelStats, - getNpubStats, - getProviderStats, - getTodayStart, - getTotals, type ClientInfo, } from "./data.ts"; import { vimState } from "./state.ts"; @@ -120,10 +111,7 @@ export function renderBarChart( export function renderOverview(stats: UsageStats, balance: BalanceInfo | null, status: StatusInfo | null, width: number): string { - // Token totals must come from the all-time summary (when present) to stay on - // the same horizon as totalRequests/totalSatsCost below — stats.entries holds - // only the latest 50 in summary mode. Fall back to entries for the legacy path. - const totals = stats.summary?.totals ?? getTotals(stats.entries); + const totals = stats.summary.totals; const totalRequests = stats.totalEntries; // Use server's total count, not entries.length const totalVisibleCost = stats.totalSatsCost; // <-- Use server's total, not client-side sum of limited entries const avgCost = totalRequests > 0 ? totalVisibleCost / totalRequests : 0; @@ -242,7 +230,7 @@ export function renderOverview(stats: UsageStats, balance: BalanceInfo | null, s output = renderBox(statusLines, width, "System Status") + "\n" + output; } - const modelStats = stats.summary?.models ?? getModelStats(stats.entries); + const modelStats = stats.summary.models; if (modelStats.length > 0) { const maxCost = modelStats[0]!.satsCost; const totalCost = Math.max(totalVisibleCost, 1); @@ -261,7 +249,7 @@ export function renderOverview(stats: UsageStats, balance: BalanceInfo | null, s output += "\n" + renderBox(modelLines, width, "Top Models by Cost"); } - const clientStats = stats.summary?.clients ?? getClientStats(stats.entries); + const clientStats = stats.summary.clients; if (clientStats.length > 0) { const maxCost = clientStats[0]!.satsCost; const totalCost = Math.max(totalVisibleCost, 1); @@ -308,23 +296,6 @@ export function renderToday(stats: UsageStats, width: number): string { // days[0] is only today when there was activity today. recentDays = days.filter((d) => d.date !== todayDateStr).slice(0, 6); hourlyMap = new Map(hoursToday.map((h) => [h.hour, { requests: h.requests, satsCost: h.satsCost }])); - } else { - // Fallback: compute from raw entries - const todayStart = getTodayStart(); - todayStats = { date: todayDateStr, requests: 0, satsCost: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0 }; - for (const entry of stats.entries) { - if (entry.timestamp >= todayStart) { - todayStats.requests++; - todayStats.satsCost += entry.satsCost; - todayStats.promptTokens += entry.promptTokens; - todayStats.completionTokens += entry.completionTokens; - todayStats.totalTokens += entry.totalTokens; - } - } - recentDays = Array.from(getDayStats(stats.entries).values()).slice(1, 7); - hourlyMap = new Map( - Array.from(getHourlyToday(stats.entries).entries()).map(([h, s]) => [h, { requests: s.requests, satsCost: s.satsCost }]) - ); } const summaryLines = [ @@ -375,7 +346,7 @@ export function renderToday(stats: UsageStats, width: number): string { } export function renderModels(stats: UsageStats, width: number): string { - const modelStats = stats.summary?.models ?? getModelStats(stats.entries); + const modelStats = stats.summary.models; if (modelStats.length === 0) return renderBox(["No model data available"], width, "Models"); // Use totalSatsCost (all-time) for percentage calculations to match header @@ -402,7 +373,7 @@ export function renderModels(stats: UsageStats, width: number): string { } export function renderProviders(stats: UsageStats, width: number): string { - const providerStats = stats.summary?.providers ?? getProviderStats(stats.entries); + const providerStats = stats.summary.providers; if (providerStats.length === 0) return renderBox(["No provider data available"], width, "Providers"); const lines: string[] = []; @@ -418,8 +389,8 @@ export function renderProviders(stats: UsageStats, width: number): string { } export function renderTokens(stats: UsageStats, width: number): string { - const totals = stats.summary?.totals ?? getTotals(stats.entries); - const modelStats = stats.summary?.models ?? getModelStats(stats.entries); + const totals = stats.summary.totals; + const modelStats = stats.summary.models; const summaryLines = [ `${COLORS.bold}Total Prompt Tokens:${COLORS.reset} ${formatNumber(totals.promptTokens)}`, `${COLORS.bold}Total Completion Tokens:${COLORS.reset} ${formatNumber(totals.completionTokens)}`, @@ -439,25 +410,7 @@ export function renderTokens(stats: UsageStats, width: number): string { output += "\n" + renderBox(tokenLines, width, "Tokens by Model"); } - const sizeBuckets = stats.summary?.sizeBuckets ?? (() => { - const b = { - tiny: { min: 0, max: 1000, count: 0, cost: 0 }, - small: { min: 1000, max: 10000, count: 0, cost: 0 }, - medium: { min: 10000, max: 50000, count: 0, cost: 0 }, - large: { min: 50000, max: 100000, count: 0, cost: 0 }, - huge: { min: 100000, max: Infinity, count: 0, cost: 0 }, - }; - for (const entry of stats.entries) { - for (const bucket of Object.values(b)) { - if (entry.totalTokens >= bucket.min && entry.totalTokens < bucket.max) { - bucket.count++; - bucket.cost += entry.satsCost; - break; - } - } - } - return b; - })(); + const sizeBuckets = stats.summary.sizeBuckets; const sizeLines = Object.entries(sizeBuckets).map(([name, bucket]) => `${name.padEnd(6)}: ${formatReqs(bucket.count).padStart(5)} reqs, ${formatCost(bucket.cost)} sats`); output += "\n" + renderBox(sizeLines, width, "Request Size Distribution"); @@ -465,7 +418,7 @@ export function renderTokens(stats: UsageStats, width: number): string { } export function renderClients(stats: UsageStats, width: number): string { - const clientStats = stats.summary?.clients ?? getClientStats(stats.entries); + const clientStats = stats.summary.clients; if (clientStats.length === 0) return renderBox(["No client data available (API key auth not used)"], width, "Client Breakdown"); // Use totalSatsCost (all-time) for percentage calculations to match header @@ -522,32 +475,6 @@ export function renderClients(stats: UsageStats, width: number): string { } clientModelLines.push(""); } - } else { - // Fallback: compute from raw entries; exclude the "unknown" bucket to match - // the summary path. - const clientModelMap = new Map>(); - for (const entry of stats.entries) { - const client = entry.client || "unknown"; - const model = entry.modelId; - if (!clientModelMap.has(client)) clientModelMap.set(client, new Map()); - const modelMap = clientModelMap.get(client)!; - const existing = modelMap.get(model) || { requests: 0, satsCost: 0, tokens: 0 }; - modelMap.set(model, { - requests: existing.requests + 1, - satsCost: existing.satsCost + entry.satsCost, - tokens: existing.tokens + entry.totalTokens, - }); - } - for (const topClient of clientStats.filter((c) => c.client !== "unknown").slice(0, 3)) { - const modelMap = clientModelMap.get(topClient.client); - if (!modelMap) continue; - const models = Array.from(modelMap.entries()).sort((a, b) => b[1].satsCost - a[1].satsCost).slice(0, 5); - clientModelLines.push(`${COLORS.bold}${topClient.client}${COLORS.reset} (${formatReqs(topClient.requests)} reqs, ${formatCost(topClient.satsCost)} sats)`); - for (const [model, data] of models) { - clientModelLines.push(` ${(MODEL_COLORS[model] || MODEL_COLORS.default)}${model.padEnd(18)}${COLORS.reset} ${formatNumber(data.tokens).padEnd(8)} tokens ${formatCost(data.satsCost)} sats`); - } - clientModelLines.push(""); - } } if (clientModelLines.length > 0) { @@ -557,7 +484,7 @@ export function renderClients(stats: UsageStats, width: number): string { } export function renderNpubs(stats: UsageStats, clients: ClientInfo[], width: number): string { - const npubStats = stats.summary?.npubs ?? getNpubStats(stats.entries, clients); + const npubStats = stats.summary.npubs; if (npubStats.length === 0) return renderBox(["No npub data available"], width, "Npub Breakdown"); const totalCost = stats.totalSatsCost; @@ -614,36 +541,6 @@ export function renderNpubs(stats: UsageStats, clients: ClientInfo[], width: num } npubModelLines.push(""); } - } else { - // Fallback: compute from raw entries - const npubModelMap = new Map>(); - const clientNpubMap = new Map(); - for (const c of clients) { - if (c.ownerNpub) clientNpubMap.set(c.clientId, c.ownerNpub); - } - for (const entry of stats.entries) { - const npub = clientNpubMap.get(entry.client || ""); - if (!npub) continue; - const model = entry.modelId; - if (!npubModelMap.has(npub)) npubModelMap.set(npub, new Map()); - const modelMap = npubModelMap.get(npub)!; - const existing = modelMap.get(model) || { requests: 0, satsCost: 0, tokens: 0 }; - modelMap.set(model, { - requests: existing.requests + 1, - satsCost: existing.satsCost + entry.satsCost, - tokens: existing.tokens + entry.totalTokens, - }); - } - for (const topNpub of npubStats.slice(0, 5)) { - const modelMap = npubModelMap.get(topNpub.npub); - if (!modelMap) continue; - const models = Array.from(modelMap.entries()).sort((a, b) => b[1].satsCost - a[1].satsCost).slice(0, 5); - npubModelLines.push(`${COLORS.bold}${truncateNpub(topNpub.npub)}${COLORS.reset} (${formatReqs(topNpub.requests)} reqs, ${formatCost(topNpub.satsCost)} sats)`); - for (const [model, data] of models) { - npubModelLines.push(` ${(MODEL_COLORS[model] || MODEL_COLORS.default)}${model.padEnd(18)}${COLORS.reset} ${formatNumber(data.tokens).padEnd(8)} tokens ${formatCost(data.satsCost)} sats`); - } - npubModelLines.push(""); - } } if (npubModelLines.length > 0) { diff --git a/src/tui/usage/types.ts b/src/tui/usage/types.ts index d49476d..3db517f 100644 --- a/src/tui/usage/types.ts +++ b/src/tui/usage/types.ts @@ -10,7 +10,7 @@ export interface UsageStats { recentSatsCost: number; limit: number; /** Present when data was fetched via /usage/summary (server-aggregated path). */ - summary?: UsageSummary; + summary: UsageSummary; } export interface DayStats { From 764d2dd8269d55b020872e268535e7eef2dad942 Mon Sep 17 00:00:00 2001 From: redshift <213178690+1ftredsh@users.noreply.github.com> Date: Thu, 11 Jun 2026 08:49:15 +0800 Subject: [PATCH 15/17] removed old functions with benchmark which is clearly not needed --- scripts/bench-usage.ts | 377 ---------------------------------------- src/tui/usage/data.ts | 160 +---------------- src/tui/usage/render.ts | 2 - src/tui/usage/types.ts | 46 ----- 4 files changed, 1 insertion(+), 584 deletions(-) delete mode 100644 scripts/bench-usage.ts diff --git a/scripts/bench-usage.ts b/scripts/bench-usage.ts deleted file mode 100644 index cc8a540..0000000 --- a/scripts/bench-usage.ts +++ /dev/null @@ -1,377 +0,0 @@ -/** - * bench-usage.ts — In-process, no-network benchmark (Option B). - * - * Compares the NEW server-aggregated summary path against the LEGACY - * "fetch-everything-then-re-aggregate" path, in terms of TIME, highlighting - * the daemon-side cache that lives in `getUsageSummary`. - * - * It deliberately SKIPS the HTTP layer. In production: - * - `fetchUsageSummary()` HTTP-calls the daemon, which runs `getUsageSummary()`. - * - `fetchUsage(N)` HTTP-calls the daemon, which runs `driver.list({limit:N})`. - * The real localhost socket round-trip (~1-3 ms) is roughly CONSTANT for both - * paths, so excluding it isolates the actual computational difference — which is - * the whole point. See the takeaway printed at the end. - * - * Run from the repo root: bun scripts/bench-usage.ts [--json] - * - */ - -// @ts-ignore — bun:sqlite is a Bun built-in module (matches storage/bun.ts). -import { Database } from "bun:sqlite"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { rmSync } from "node:fs"; - -import { createBunSqliteUsageTrackingDriver } from "@routstr/sdk/storage/bun"; -import { - getUsageSummary, - __resetUsageSummaryCacheForTest, -} from "../src/daemon/http/usage-summary.ts"; -import { - getTotals, - getModelStats, - getProviderStats, - getClientStats, - getDayStats, - getHourlyToday, - getNpubStats, -} from "../src/tui/usage/data.ts"; -import type { UsageTrackingEntry } from "../src/daemon/types.ts"; - -// ─── Config ────────────────────────────────────────────────────────────────── - -const SIZES = [100, 1000, 2500, 5000, 10000, 20000]; -const SAMPLES = 25; // median of 25, after a warmup -const STEADY_REFRESHES = 10; // the monitor's ~2s refresh loop -const TZ_OFFSET = 300; // synthetic UTC-5; fixed so results are reproducible -const DAY_SPREAD = 45; // spread timestamps over ~45 days - -const jsonMode = process.argv.includes("--json"); - -// ─── Synthetic, sanitized templates (no real DB, no real identifiers) ───────── - -interface Template { - modelId: string; - baseUrl: string; - client: string; - cost: number; - satsCost: number; - promptTokens: number; - completionTokens: number; -} - -const PROVIDER_A = "https://api.example-provider-a.com/"; -const PROVIDER_B = "https://api.example-provider-b.com/"; - -const TEMPLATES: Template[] = [ - { modelId: "minimax-m3", baseUrl: PROVIDER_A, client: "client-a", cost: 0.0021, satsCost: 4, promptTokens: 320, completionTokens: 180 }, - { modelId: "kimi-k2.6", baseUrl: PROVIDER_A, client: "client-b", cost: 0.0140, satsCost: 27, promptTokens: 5200, completionTokens: 2400 }, - { modelId: "deepseek-v4-flash", baseUrl: PROVIDER_B, client: "client-c", cost: 0.0008, satsCost: 1, promptTokens: 90, completionTokens: 60 }, - { modelId: "deepseek-v4-flash", baseUrl: PROVIDER_B, client: "client-a", cost: 0.0420, satsCost: 81, promptTokens: 24000, completionTokens: 13000 }, - { modelId: "kimi-k2.6", baseUrl: PROVIDER_B, client: "client-b", cost: 0.0065, satsCost: 12, promptTokens: 1800, completionTokens: 950 }, -]; - -// Clients array (shape works for both getUsageSummary's ClientEntry and -// data.ts's ClientInfo — both only read clientId / ownerNpub). ~half get a -// synthetic ownerNpub so the npub aggregation path is exercised. -const CLIENT_IDS = [...new Set(TEMPLATES.map((t) => t.client))]; -const clients = CLIENT_IDS.map((id, i) => ({ - clientId: id, - name: id, - apiKey: `apikey-${id}`, - createdAt: 0, - ownerNpub: i % 2 === 0 ? `npub1bench${id.replace(/[^a-z]/g, "")}` : undefined, -})); - -// ─── Row generation ──────────────────────────────────────────────────────────── - -function generateRows(n: number, now: number): UsageTrackingEntry[] { - const rows: UsageTrackingEntry[] = new Array(n); - const spreadMs = DAY_SPREAD * 86_400_000; - for (let i = 0; i < n; i++) { - const t = TEMPLATES[i % TEMPLATES.length]!; - // Spread timestamps backwards from `now` over DAY_SPREAD days, with a - // little jitter so multiple buckets/hours are populated. - const jitter = (i * 97) % 86_400_000; - const timestamp = now - Math.floor((i / n) * spreadMs) - jitter; - const promptTokens = t.promptTokens + (i % 17); - const completionTokens = t.completionTokens + (i % 13); - rows[i] = { - id: `bench-${i}`, - timestamp, - modelId: t.modelId, - baseUrl: t.baseUrl, - requestId: `req-${i}`, - cost: t.cost, - satsCost: t.satsCost, - promptTokens, - completionTokens, - totalTokens: promptTokens + completionTokens, - client: t.client, - sessionId: `sess-${i % 50}`, - tags: [], - }; - } - return rows; -} - -// ─── Seeding (raw transactional insert — fast) ──────────────────────────────── - -const TABLE = "usage_tracking"; - -function seedRawDb(dbPath: string, rows: UsageTrackingEntry[]): void { - const db = new Database(dbPath); - // Match the SDK schema exactly (copied from storage/bun.ts). - db.run(` - CREATE TABLE IF NOT EXISTS ${TABLE} ( - id TEXT PRIMARY KEY, - timestamp INTEGER NOT NULL, - model_id TEXT NOT NULL, - base_url TEXT NOT NULL, - request_id TEXT NOT NULL, - cost REAL NOT NULL, - sats_cost REAL NOT NULL, - prompt_tokens INTEGER NOT NULL, - completion_tokens INTEGER NOT NULL, - total_tokens INTEGER NOT NULL, - client TEXT, - session_id TEXT, - tags TEXT - ) - `); - db.run(`CREATE INDEX IF NOT EXISTS idx_${TABLE}_timestamp ON ${TABLE}(timestamp)`); - db.run(`CREATE INDEX IF NOT EXISTS idx_${TABLE}_model_id ON ${TABLE}(model_id)`); - db.run(`CREATE INDEX IF NOT EXISTS idx_${TABLE}_base_url ON ${TABLE}(base_url)`); - - const insert = db.query(` - INSERT OR REPLACE INTO ${TABLE} ( - id, timestamp, model_id, base_url, request_id, - cost, sats_cost, prompt_tokens, completion_tokens, total_tokens, - client, session_id, tags - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `); - const seed = db.transaction((batch: UsageTrackingEntry[]) => { - for (const r of batch) { - insert.run( - r.id, r.timestamp, r.modelId, r.baseUrl, r.requestId, - r.cost, r.satsCost, r.promptTokens, r.completionTokens, r.totalTokens, - r.client ?? null, r.sessionId ?? null, JSON.stringify(r.tags ?? []), - ); - } - }); - seed(rows); - db.close(); -} - -// ─── Timing helpers ──────────────────────────────────────────────────────────── - -async function timeMedian(fn: () => Promise, samples = SAMPLES): Promise { - await fn(); // warmup (discarded) - const times: number[] = []; - for (let i = 0; i < samples; i++) { - const start = performance.now(); - await fn(); - times.push(performance.now() - start); - } - times.sort((a, b) => a - b); - return times[Math.floor(times.length / 2)]!; -} - -function runLegacyAggregation(rows: UsageTrackingEntry[]): void { - // Mirror exactly what the monitor's render path does on raw entries. - getTotals(rows); - getModelStats(rows); - getProviderStats(rows); - getClientStats(rows); - getDayStats(rows); - getHourlyToday(rows); - getNpubStats(rows, clients); -} - -// ─── Bench per size ────────────────────────────────────────────────────────── - -interface SizeResult { - size: number; - summaryColdMs: number; - summaryWarmMs: number; - legacyMs: number; - summaryKB: number; - legacyKB: number; - reductionX: number; - steadySummaryMs: number; - steadyLegacyMs: number; - speedupX: number; -} - -async function benchSize(size: number): Promise { - const now = Date.now(); - const dbPath = join(tmpdir(), `routstrd-bench-${size}-${process.pid}-${Date.now()}.db`); - const rows = generateRows(size, now); - seedRawDb(dbPath, rows); - - const driver = await createBunSqliteUsageTrackingDriver({ dbPath }); - - try { - // ── summary cold: reset cache before every timed call ────────────────── - const summaryColdMs = await timeMedian(async () => { - __resetUsageSummaryCacheForTest(); - await getUsageSummary(driver, clients, TZ_OFFSET); - }); - - // ── summary warm: cache already populated (warmup call is itself a hit) ─ - __resetUsageSummaryCacheForTest(); - await getUsageSummary(driver, clients, TZ_OFFSET); // populate once - const summaryWarmMs = await timeMedian(async () => { - await getUsageSummary(driver, clients, TZ_OFFSET); - }); - - // ── legacy: list(limit:N) + full client-side re-aggregation, no cache ── - const legacyMs = await timeMedian(async () => { - const listed = await driver.list({ limit: size }); - runLegacyAggregation(listed); - }); - - // ── payload bytes ────────────────────────────────────────────────────── - __resetUsageSummaryCacheForTest(); - const summaryResult = await getUsageSummary(driver, clients, TZ_OFFSET); - const listedRows = await driver.list({ limit: size }); - const summaryBytes = JSON.stringify(summaryResult).length; - const legacyBytes = JSON.stringify(listedRows).length; - - // ── steady state (the 2s refresh loop, STEADY_REFRESHES iterations) ──── - // summary: 1 cold + (N-1) warm; legacy: N × full cost (no cache). - const steadySummaryMs = - summaryColdMs + (STEADY_REFRESHES - 1) * summaryWarmMs; - const steadyLegacyMs = STEADY_REFRESHES * legacyMs; - - return { - size, - summaryColdMs, - summaryWarmMs, - legacyMs, - summaryKB: summaryBytes / 1024, - legacyKB: legacyBytes / 1024, - reductionX: legacyBytes / summaryBytes, - steadySummaryMs, - steadyLegacyMs, - speedupX: steadyLegacyMs / steadySummaryMs, - }; - } finally { - // driver holds the only open handle now; closing the file by removing it. - try { - rmSync(dbPath, { force: true }); - rmSync(`${dbPath}-shm`, { force: true }); - rmSync(`${dbPath}-wal`, { force: true }); - } catch { /* ignore */ } - } -} - -// ─── Output ────────────────────────────────────────────────────────────────── - -function fmt(n: number, dp = 2): string { - return n.toFixed(dp); -} - -function pad(s: string, width: number): string { - return s.padStart(width); -} - -function printTable(results: SizeResult[]): void { - console.log(""); - console.log("Routstr usage path benchmark — TIME (in-process, no network)"); - console.log("Daemon-side summary cache vs. legacy fetch-all-and-re-aggregate.\n"); - - // ── Payload table (per request) ────────────────────────────── - console.log("Payload per request:"); - const payHeader = [ - pad("size", 7), - pad("summary", 11), - pad("legacy", 11), - pad("reduction", 11), - ].join(" "); - console.log(payHeader); - console.log("-".repeat(payHeader.length)); - for (const r of results) { - console.log([ - pad(String(r.size), 7), - pad(fmt(r.summaryKB, 1) + " KB", 11), - pad(fmt(r.legacyKB, 1) + " KB", 11), - pad(fmt(r.reductionX, 1) + "x", 11), - ].join(" ")); - } - - // ── Time table (ms) ────────────────────────────────────────── - console.log(""); - console.log("Time (ms):"); - const timeHeader = [ - pad("size", 7), - pad("sum cold", 10), - pad("sum warm", 10), - pad("legacy", 10), - pad("steady sum", 12), - pad("steady leg", 12), - pad("speedup", 9), - ].join(" "); - console.log(timeHeader); - console.log("-".repeat(timeHeader.length)); - for (const r of results) { - console.log([ - pad(String(r.size), 7), - pad(fmt(r.summaryColdMs) + "ms", 10), - pad(fmt(r.summaryWarmMs) + "ms", 10), - pad(fmt(r.legacyMs) + "ms", 10), - pad(fmt(r.steadySummaryMs) + "ms", 12), - pad(fmt(r.steadyLegacyMs) + "ms", 12), - pad(fmt(r.speedupX, 1) + "x", 9), - ].join(" ")); - } - - console.log(""); - console.log("Legend (in-process; times = medians of " + SAMPLES + " samples, warmup discarded;"); - console.log("excludes the ~constant localhost HTTP round-trip both paths would add):"); - console.log(" size seeded usage rows in the DB"); - console.log(""); - console.log(" Payload table:"); - console.log(" summary JSON bytes of the /usage/summary response (aggregates + 50 recent rows)"); - console.log(" legacy JSON bytes of the /usage response (all rows)"); - console.log(" reduction legacy / summary"); - console.log(""); - console.log(" Time table:"); - console.log(" sum cold summary build, COLD cache (cache miss: ~15-query rebuild + assemble)"); - console.log(" sum warm summary from WARM cache (count() + return the memoized result)"); - console.log(" legacy list all rows + the client-side re-aggregation the monitor does (no cache)"); - console.log(" steady sum total ms the SUMMARY path spends over " + STEADY_REFRESHES + " refreshes of the monitor's ~2s loop,"); - console.log(" assuming data is unchanged across the window (cache stays valid):"); - console.log(" 1 cold build + " + (STEADY_REFRESHES - 1) + " warm cache hits = cold + " + (STEADY_REFRESHES - 1) + "xwarm. Since warm is ~0.01ms,"); - console.log(" this is in practice ~one cold build."); - console.log(" steady leg same " + STEADY_REFRESHES + " refreshes for LEGACY = legacy x" + STEADY_REFRESHES + " (no cache, full cost each time)"); - console.log(" speedup steady leg / steady sum"); -} - -// ─── Main ──────────────────────────────────────────────────────────────────── - -async function main(): Promise { - const results: SizeResult[] = []; - for (const size of SIZES) { - results.push(await benchSize(size)); - } - - if (jsonMode) { - console.log(JSON.stringify( - { - config: { sizes: SIZES, samples: SAMPLES, steadyRefreshes: STEADY_REFRESHES, tzOffset: TZ_OFFSET }, - note: "In-process; HTTP round-trip (~1-3ms localhost, ~constant for both) excluded.", - results, - }, - null, - 2, - )); - } else { - printTable(results); - } -} - -main().catch((err) => { - console.error(err); - process.exit(1); -}); diff --git a/src/tui/usage/data.ts b/src/tui/usage/data.ts index b3e6681..66821af 100644 --- a/src/tui/usage/data.ts +++ b/src/tui/usage/data.ts @@ -1,6 +1,6 @@ import type { UsageTrackingEntry } from "../../daemon/types.ts"; import { callDaemon, isDaemonRunning } from "../../utils/daemon-client.ts"; -import type { ClientStats, DayStats, ModelStats, NpubStats, ProviderStats, UsageStats, UsageSummary } from "./types.ts"; +import type { UsageStats, UsageSummary } from "./types.ts"; export { isDaemonRunning }; @@ -101,17 +101,6 @@ export async function fetchUsageSummary(): Promise { } } -export function getTodayStart(): number { - const now = new Date(); - return new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); -} - -export function formatDate(timestamp: number): string { - const d = new Date(timestamp); - const p = (n: number) => String(n).padStart(2, "0"); - return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`; -} - export function formatTime(timestamp: number): string { const d = new Date(timestamp); const p = (n: number) => String(n).padStart(2, "0"); @@ -124,100 +113,6 @@ export function formatNumber(n: number): string { return n.toString(); } -export function getDayStats(entries: UsageTrackingEntry[]): Map { - const days = new Map(); - for (const entry of entries) { - const date = formatDate(entry.timestamp); - const existing = days.get(date) || { date, requests: 0, satsCost: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0 }; - days.set(date, { - ...existing, - requests: existing.requests + 1, - satsCost: existing.satsCost + entry.satsCost, - promptTokens: existing.promptTokens + entry.promptTokens, - completionTokens: existing.completionTokens + entry.completionTokens, - totalTokens: existing.totalTokens + entry.totalTokens, - }); - } - return days; -} - -export function getHourlyToday(entries: UsageTrackingEntry[]): Map { - const todayStart = getTodayStart(); - const hours = new Map(); - for (const entry of entries) { - if (entry.timestamp < todayStart) continue; - const hour = new Date(entry.timestamp).getHours(); - const existing = hours.get(hour) || { - date: formatDate(entry.timestamp), requests: 0, satsCost: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0, - }; - hours.set(hour, { - ...existing, - requests: existing.requests + 1, - satsCost: existing.satsCost + entry.satsCost, - promptTokens: existing.promptTokens + entry.promptTokens, - completionTokens: existing.completionTokens + entry.completionTokens, - totalTokens: existing.totalTokens + entry.totalTokens, - }); - } - return hours; -} - -export function getModelStats(entries: UsageTrackingEntry[]): ModelStats[] { - const models = new Map(); - for (const entry of entries) { - const existing = models.get(entry.modelId) || { - modelId: entry.modelId, requests: 0, satsCost: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0, - }; - models.set(entry.modelId, { - ...existing, - requests: existing.requests + 1, - satsCost: existing.satsCost + entry.satsCost, - promptTokens: existing.promptTokens + entry.promptTokens, - completionTokens: existing.completionTokens + entry.completionTokens, - totalTokens: existing.totalTokens + entry.totalTokens, - }); - } - return Array.from(models.values()).sort((a, b) => b.satsCost - a.satsCost); -} - -export function getProviderStats(entries: UsageTrackingEntry[]): ProviderStats[] { - const providers = new Map(); - for (const entry of entries) { - const url = entry.baseUrl || "unknown"; - const existing = providers.get(url) || { - baseUrl: url, requests: 0, satsCost: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0, - }; - providers.set(url, { - ...existing, - requests: existing.requests + 1, - satsCost: existing.satsCost + entry.satsCost, - promptTokens: existing.promptTokens + entry.promptTokens, - completionTokens: existing.completionTokens + entry.completionTokens, - totalTokens: existing.totalTokens + entry.totalTokens, - }); - } - return Array.from(providers.values()).sort((a, b) => b.satsCost - a.satsCost); -} - -export function getClientStats(entries: UsageTrackingEntry[]): ClientStats[] { - const clients = new Map(); - for (const entry of entries) { - const client = entry.client || "unknown"; - const existing = clients.get(client) || { - client, requests: 0, satsCost: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0, - }; - clients.set(client, { - ...existing, - requests: existing.requests + 1, - satsCost: existing.satsCost + entry.satsCost, - promptTokens: existing.promptTokens + entry.promptTokens, - completionTokens: existing.completionTokens + entry.completionTokens, - totalTokens: existing.totalTokens + entry.totalTokens, - }); - } - return Array.from(clients.values()).sort((a, b) => b.satsCost - a.satsCost); -} - // ─── Client / Npub helpers ──────────────────────────────────────────── export interface ClientInfo { @@ -251,56 +146,3 @@ export async function fetchClients(): Promise { export function hasAnyNpubs(clients: ClientInfo[]): boolean { return clients.some((c) => !!c.ownerNpub); } - -export function getNpubStats(entries: UsageTrackingEntry[], clients: ClientInfo[]): NpubStats[] { - // Build client-id → ownerNpub lookup - const clientNpubMap = new Map(); - for (const c of clients) { - if (c.ownerNpub) { - clientNpubMap.set(c.clientId, c.ownerNpub); - } - } - - // Aggregate usage per npub - const npubs = new Map(); - for (const entry of entries) { - const npub = clientNpubMap.get(entry.client || ""); - if (!npub) continue; // skip entries whose client has no ownerNpub - - const existing = npubs.get(npub) || { - npub, - requests: 0, - satsCost: 0, - promptTokens: 0, - completionTokens: 0, - totalTokens: 0, - }; - npubs.set(npub, { - ...existing, - requests: existing.requests + 1, - satsCost: existing.satsCost + entry.satsCost, - promptTokens: existing.promptTokens + entry.promptTokens, - completionTokens: existing.completionTokens + entry.completionTokens, - totalTokens: existing.totalTokens + entry.totalTokens, - }); - } - - return Array.from(npubs.values()).sort((a, b) => b.satsCost - a.satsCost); -} - -// ─── Totals ─────────────────────────────────────────────────────────── - -export function getTotals(entries: UsageTrackingEntry[] | undefined) { - if (!entries || !Array.isArray(entries)) { - return { promptTokens: 0, completionTokens: 0, totalTokens: 0, satsCost: 0 }; - } - return entries.reduce( - (acc, entry) => ({ - promptTokens: acc.promptTokens + entry.promptTokens, - completionTokens: acc.completionTokens + entry.completionTokens, - totalTokens: acc.totalTokens + entry.totalTokens, - satsCost: acc.satsCost + entry.satsCost, - }), - { promptTokens: 0, completionTokens: 0, totalTokens: 0, satsCost: 0 } - ); -} diff --git a/src/tui/usage/render.ts b/src/tui/usage/render.ts index c5a6432..2fe7b41 100644 --- a/src/tui/usage/render.ts +++ b/src/tui/usage/render.ts @@ -275,8 +275,6 @@ export function renderToday(stats: UsageStats, width: number): string { const currentHour = new Date().getHours(); // Build today's date string from LOCAL date components so it matches the // server's tz-bucketed day keys (which use the client's tzOffsetMinutes). - // formatDate() returns a UTC date via toISOString() and would mismatch for - // non-UTC users. const _now = new Date(); const todayDateStr = `${_now.getFullYear()}-${String(_now.getMonth() + 1).padStart(2, "0")}-${String(_now.getDate()).padStart(2, "0")}`; diff --git a/src/tui/usage/types.ts b/src/tui/usage/types.ts index 3db517f..2c0345d 100644 --- a/src/tui/usage/types.ts +++ b/src/tui/usage/types.ts @@ -9,46 +9,9 @@ export interface UsageStats { totalSatsCost: number; recentSatsCost: number; limit: number; - /** Present when data was fetched via /usage/summary (server-aggregated path). */ summary: UsageSummary; } -export interface DayStats { - date: string; - requests: number; - satsCost: number; - promptTokens: number; - completionTokens: number; - totalTokens: number; -} - -export interface ModelStats { - modelId: string; - requests: number; - satsCost: number; - promptTokens: number; - completionTokens: number; - totalTokens: number; -} - -export interface ProviderStats { - baseUrl: string; - requests: number; - satsCost: number; - promptTokens: number; - completionTokens: number; - totalTokens: number; -} - -export interface ClientStats { - client: string; - requests: number; - satsCost: number; - promptTokens: number; - completionTokens: number; - totalTokens: number; -} - export type TabId = "overview" | "today" | "models" | "providers" | "tokens" | "clients" | "npubs" | "recent"; export interface Tab { @@ -57,15 +20,6 @@ export interface Tab { key: string; } -export interface NpubStats { - npub: string; - requests: number; - satsCost: number; - promptTokens: number; - completionTokens: number; - totalTokens: number; -} - export interface VimState { scrollPos: number; searchQuery: string; From 61acc0b5fb996ce5ced2b7ff2a1d7216f9971404 Mon Sep 17 00:00:00 2001 From: redshift <213178690+1ftredsh@users.noreply.github.com> Date: Thu, 11 Jun 2026 09:17:13 +0800 Subject: [PATCH 16/17] added npub based filter! --- src/daemon/http/index.ts | 8 ++++- src/daemon/http/usage-summary.ts | 54 +++++++++++++++++++++++++------- 2 files changed, 50 insertions(+), 12 deletions(-) diff --git a/src/daemon/http/index.ts b/src/daemon/http/index.ts index 7132315..89d453c 100644 --- a/src/daemon/http/index.ts +++ b/src/daemon/http/index.ts @@ -1139,8 +1139,14 @@ export function createDaemonRequestHandler(deps: { if (req.method === "GET" && url.pathname === "/usage/summary") { try { const tz = Number.parseInt(url.searchParams.get("tz") || "0", 10) || 0; + const npubFilter = url.searchParams.get("npub")?.trim(); const clients = getClientsFromStore(deps.store); - const summary = await getUsageSummary(deps.usageTrackingDriver, clients, tz); + const clientFilter = npubFilter + ? clients + .filter((c) => c.ownerNpub === npubFilter) + .map((c) => c.clientId) + : undefined; + const summary = await getUsageSummary(deps.usageTrackingDriver, clients, tz, clientFilter); sendJson(res, 200, { output: summary }); } catch (error) { sendJson(res, 500, { error: toErrorMessage(error) }); diff --git a/src/daemon/http/usage-summary.ts b/src/daemon/http/usage-summary.ts index ddf7b5e..e909fa2 100644 --- a/src/daemon/http/usage-summary.ts +++ b/src/daemon/http/usage-summary.ts @@ -160,13 +160,14 @@ export async function getUsageSummary( driver: UsageTrackingDriver, clients: ClientEntry[], tzOffsetMinutes: number, + /** If set, only include usage for these client IDs (e.g. from `?npub=` filtering). */ + clientFilter?: string[], ): Promise { - // Cache key: total row count + per-client identity (id + ownerNpub) + tz. - // Including ownerNpub ensures a client assignment change invalidates the cache - // even though the row count doesn't change. - const count = await driver.count(); + // Cache key: total row count + per-client identity + filter + tz. + const count = await driver.count(clientFilter ? { clients: clientFilter } : {}); const clientIdentity = clients.map((c) => `${c.clientId}:${c.ownerNpub ?? ""}`).join(","); - const cacheKey = `${count}:${clientIdentity}:${tzOffsetMinutes}`; + const filterKey = clientFilter ? `:f:${clientFilter.sort().join(",")}` : ""; + const cacheKey = `${count}:${clientIdentity}:${tzOffsetMinutes}${filterKey}`; const now = Date.now(); if ( @@ -177,29 +178,57 @@ export async function getUsageSummary( return _cache.summary; } + // Base filter applied to every aggregate/list/count call + const baseFilter = clientFilter ? { clients: clientFilter } as const : {}; + + // Short-circuit: if the filter yields no rows, return zeroed summary + if (count === 0) { + const zeroStat: StatRow = { + requests: 0, promptTokens: 0, completionTokens: 0, + totalTokens: 0, cost: 0, satsCost: 0, + }; + const zeroSummary: UsageSummary = { + generatedAt: now, + totals: zeroStat, + models: [], + providers: [], + clients: [], + npubs: [], + days: [], + hoursToday: [], + sizeBuckets: { + tiny: emptyBucket(), small: emptyBucket(), medium: emptyBucket(), + large: emptyBucket(), huge: emptyBucket(), + }, + recent: [], + }; + _cache = { key: cacheKey, summary: zeroSummary }; + return zeroSummary; + } + // ── Totals ───────────────────────────────────────────────────────────────── - const [totalsRow] = await driver.aggregate({}); + const [totalsRow] = await driver.aggregate({ ...baseFilter }); 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 modelRows = await driver.aggregate({ ...baseFilter, groupBy: "modelId" }); const models: ModelSummary[] = modelRows.map((r) => ({ modelId: r.group ?? "unknown", ...rowToStat(r), })); // ── Providers ────────────────────────────────────────────────────────────── - const providerRows = await driver.aggregate({ groupBy: "baseUrl" }); + const providerRows = await driver.aggregate({ ...baseFilter, groupBy: "baseUrl" }); const providers: ProviderSummary[] = providerRows.map((r) => ({ baseUrl: r.group ?? "unknown", ...rowToStat(r), })); // ── Clients ──────────────────────────────────────────────────────────────── - const clientRows = await driver.aggregate({ groupBy: "client" }); + const clientRows = await driver.aggregate({ ...baseFilter, groupBy: "client" }); const clientSummaries: ClientSummary[] = clientRows.map((r) => ({ client: r.group ?? "unknown", ...rowToStat(r), @@ -213,6 +242,7 @@ export async function getUsageSummary( for (let i = 0; i < topClientRows.length; i++) { const clientId = topClientRows[i]!.group!; const topModelRows = await driver.aggregate({ + ...baseFilter, groupBy: "modelId", client: clientId, }); @@ -281,6 +311,7 @@ export async function getUsageSummary( // ── Days (last 30, most-recent-first) ───────────────────────────────────── const dayRows = await driver.aggregate({ + ...baseFilter, groupBy: "day", tzOffsetMinutes, after: now - 30 * 86400000, @@ -292,6 +323,7 @@ export async function getUsageSummary( // ── Hours today ──────────────────────────────────────────────────────────── const todayStartUtc = startOfLocalDayUtc(now, tzOffsetMinutes); const hourRows = await driver.aggregate({ + ...baseFilter, groupBy: "hour", tzOffsetMinutes, after: todayStartUtc - 1, @@ -305,11 +337,11 @@ export async function getUsageSummary( // The SDK's aggregate() no longer supports token-range filters // (minTotalTokens/maxTotalTokens were removed in SDK pr-8), so bucket in // JS from a single list() call instead of five aggregate() queries. - const allEntries = await driver.list(); + const allEntries = await driver.list(baseFilter); const sizeBuckets = computeSizeBuckets(allEntries); // ── Recent entries ───────────────────────────────────────────────────────── - const recent = await driver.list({ limit: 50 }); + const recent = await driver.list({ ...baseFilter, limit: 50 }); const summary: UsageSummary = { generatedAt: now, From 8c52c90f94477a21f7396bd7eda354696e16b229 Mon Sep 17 00:00:00 2001 From: redshift <213178690+1ftredsh@users.noreply.github.com> Date: Thu, 11 Jun 2026 09:31:32 +0800 Subject: [PATCH 17/17] addeed npub filtering for /usage as well --- src/daemon/http/index.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/daemon/http/index.ts b/src/daemon/http/index.ts index 89d453c..28f3974 100644 --- a/src/daemon/http/index.ts +++ b/src/daemon/http/index.ts @@ -1125,8 +1125,16 @@ export function createDaemonRequestHandler(deps: { if (req.method === "GET" && url.pathname === "/usage") { try { + const npubFilter = url.searchParams.get("npub")?.trim(); + const clients = npubFilter ? getClientsFromStore(deps.store) : undefined; + const clientFilter = npubFilter + ? clients! + .filter((c) => c.ownerNpub === npubFilter) + .map((c) => c.clientId) + : undefined; const output = await deps.usageTrackingDriver.list({ limit: parseLimit(url.searchParams.get("limit")), + ...(clientFilter ? { clients: clientFilter } : {}), }); res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ output }));