mirror of
https://github.com/Routstr/routstrd.git
synced 2026-07-30 15:46:14 +00:00
Merge pull request #45 from Routstr/pr-43
feat: server-side /usage/summary endpoint with npub filtering
This commit is contained in:
@@ -16,6 +16,7 @@ import {
|
||||
} from "../wallet/cocod-client";
|
||||
import { decodeCashuTokenAmount } from "../wallet";
|
||||
import { getClientsFromStore } from "../../utils/clients";
|
||||
import { getUsageSummary } from "./usage-summary";
|
||||
|
||||
type ClientMode = "xcashu" | "lazyrefund" | "apikeys";
|
||||
|
||||
@@ -1124,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 }));
|
||||
@@ -1135,6 +1144,24 @@ 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 npubFilter = url.searchParams.get("npub")?.trim();
|
||||
const clients = getClientsFromStore(deps.store);
|
||||
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) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname === "/usagePi") {
|
||||
try {
|
||||
const timestamp = (url.searchParams.get("timestamp") || "").trim();
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
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, __resetUsageSummaryCacheForTest } 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<UsageTrackingEntry, "id" | "timestamp" | "modelId" | "client" | "totalTokens"> = {
|
||||
baseUrl: "https://api.openai.com/",
|
||||
requestId: "req-1",
|
||||
cost: 0.01,
|
||||
satsCost: 10,
|
||||
promptTokens: 100,
|
||||
completionTokens: 50,
|
||||
};
|
||||
|
||||
function makeEntry(
|
||||
id: string,
|
||||
overrides: Partial<UsageTrackingEntry> & Pick<UsageTrackingEntry, "id" | "timestamp" | "modelId" | "totalTokens">,
|
||||
): 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<typeof createMemoryUsageTrackingDriver>;
|
||||
|
||||
beforeEach(async () => {
|
||||
// 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);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,361 @@
|
||||
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 };
|
||||
}
|
||||
|
||||
/** [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`. */
|
||||
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;
|
||||
|
||||
/** 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(
|
||||
driver: UsageTrackingDriver,
|
||||
clients: ClientEntry[],
|
||||
tzOffsetMinutes: number,
|
||||
/** If set, only include usage for these client IDs (e.g. from `?npub=` filtering). */
|
||||
clientFilter?: string[],
|
||||
): Promise<UsageSummary> {
|
||||
// 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 filterKey = clientFilter ? `:f:${clientFilter.sort().join(",")}` : "";
|
||||
const cacheKey = `${count}:${clientIdentity}:${tzOffsetMinutes}${filterKey}`;
|
||||
const now = Date.now();
|
||||
|
||||
if (
|
||||
_cache !== null &&
|
||||
_cache.key === cacheKey &&
|
||||
now - _cache.summary.generatedAt <= CACHE_TTL_MS
|
||||
) {
|
||||
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({ ...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({ ...baseFilter, groupBy: "modelId" });
|
||||
const models: ModelSummary[] = modelRows.map((r) => ({
|
||||
modelId: r.group ?? "unknown",
|
||||
...rowToStat(r),
|
||||
}));
|
||||
|
||||
// ── Providers ──────────────────────────────────────────────────────────────
|
||||
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({ ...baseFilter, 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({
|
||||
...baseFilter,
|
||||
groupBy: "modelId",
|
||||
client: clientId,
|
||||
});
|
||||
// Find matching ClientSummary and set topModels
|
||||
const summary = clientSummaries.find((c) => c.client === clientId);
|
||||
if (summary) {
|
||||
summary.topModels = topModelRows.slice(0, 5).map(rowToTopModel);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Npubs ──────────────────────────────────────────────────────────────────
|
||||
// Build clientId → ownerNpub lookup (only clients with ownerNpub)
|
||||
const clientToNpub = new Map<string, string>();
|
||||
for (const c of clients) {
|
||||
if (c.ownerNpub) {
|
||||
clientToNpub.set(c.clientId, c.ownerNpub);
|
||||
}
|
||||
}
|
||||
|
||||
// Fold client rows into per-npub sums
|
||||
const npubStats = new Map<string, StatRow>();
|
||||
const npubClientIds = new Map<string, string[]>();
|
||||
for (const r of clientRows) {
|
||||
if (r.group === null) continue;
|
||||
const npub = clientToNpub.get(r.group);
|
||||
if (!npub) continue;
|
||||
const existing = npubStats.get(npub);
|
||||
if (existing) {
|
||||
existing.requests += r.requests;
|
||||
existing.promptTokens += r.promptTokens;
|
||||
existing.completionTokens += r.completionTokens;
|
||||
existing.totalTokens += r.totalTokens;
|
||||
existing.cost += r.cost;
|
||||
existing.satsCost += r.satsCost;
|
||||
} else {
|
||||
npubStats.set(npub, { ...rowToStat(r) });
|
||||
}
|
||||
const ids = npubClientIds.get(npub) ?? [];
|
||||
ids.push(r.group);
|
||||
npubClientIds.set(npub, ids);
|
||||
}
|
||||
|
||||
// Sort npubs desc by satsCost
|
||||
const sortedNpubs = [...npubStats.entries()].sort(
|
||||
(a, b) => b[1].satsCost - a[1].satsCost,
|
||||
);
|
||||
|
||||
const npubs: NpubSummary[] = sortedNpubs.map(([npub, stat]) => ({
|
||||
npub,
|
||||
...stat,
|
||||
topModels: [],
|
||||
}));
|
||||
|
||||
// Fill topModels for top 5 npubs
|
||||
for (let i = 0; i < Math.min(5, npubs.length); i++) {
|
||||
const npubSummary = npubs[i]!;
|
||||
const ids = npubClientIds.get(npubSummary.npub) ?? [];
|
||||
if (ids.length > 0) {
|
||||
const topModelRows = await driver.aggregate({
|
||||
groupBy: "modelId",
|
||||
clients: ids,
|
||||
});
|
||||
npubSummary.topModels = topModelRows.slice(0, 5).map(rowToTopModel);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Days (last 30, most-recent-first) ─────────────────────────────────────
|
||||
const dayRows = await driver.aggregate({
|
||||
...baseFilter,
|
||||
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({
|
||||
...baseFilter,
|
||||
groupBy: "hour",
|
||||
tzOffsetMinutes,
|
||||
after: todayStartUtc - 1,
|
||||
});
|
||||
const hoursToday: HourSummary[] = hourRows.map((r) => ({
|
||||
hour: Number(r.group),
|
||||
...rowToStat(r),
|
||||
}));
|
||||
|
||||
// ── Size buckets ───────────────────────────────────────────────────────────
|
||||
// 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(baseFilter);
|
||||
const sizeBuckets = computeSizeBuckets(allEntries);
|
||||
|
||||
// ── Recent entries ─────────────────────────────────────────────────────────
|
||||
const recent = await driver.list({ ...baseFilter, limit: 50 });
|
||||
|
||||
const summary: UsageSummary = {
|
||||
generatedAt: now,
|
||||
totals,
|
||||
models,
|
||||
providers,
|
||||
clients: clientSummaries,
|
||||
npubs,
|
||||
days,
|
||||
hoursToday,
|
||||
sizeBuckets,
|
||||
recent,
|
||||
};
|
||||
|
||||
_cache = { key: cacheKey, summary };
|
||||
return summary;
|
||||
}
|
||||
@@ -5,6 +5,10 @@ import {
|
||||
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";
|
||||
|
||||
@@ -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, fetchUsageSummary, hasAnyNpubs, isDaemonRunning, type BalanceInfo, type ClientInfo, type StatusInfo } from "./data.ts";
|
||||
import {
|
||||
applyScrollToContent,
|
||||
exitSearchMode,
|
||||
@@ -89,7 +89,7 @@ export async function runUsageTui(): Promise<void> {
|
||||
const height = getHeight();
|
||||
|
||||
if (forceFetch || shouldUpdate) {
|
||||
stats = await fetchUsage(10000);
|
||||
stats = await fetchUsageSummary();
|
||||
balance = await fetchBalance();
|
||||
status = await fetchStatus();
|
||||
clients = await fetchClients();
|
||||
|
||||
+12
-181
@@ -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 { UsageStats, UsageSummary } from "./types.ts";
|
||||
|
||||
export { isDaemonRunning };
|
||||
|
||||
@@ -76,53 +76,31 @@ export async function fetchBalance(): Promise<BalanceInfo | null> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchUsage(limit = 10000): Promise<UsageStats | null> {
|
||||
export async function fetchUsageSummary(): Promise<UsageStats | null> {
|
||||
try {
|
||||
const running = await isDaemonRunning();
|
||||
if (!running) return null;
|
||||
|
||||
const result = await callDaemon(`/usage?limit=${limit}`);
|
||||
const tz = new Date().getTimezoneOffset();
|
||||
const result = await callDaemon(`/usage/summary?tz=${tz}`);
|
||||
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 },
|
||||
);
|
||||
const summary = result.output as UsageSummary | undefined;
|
||||
if (!summary || typeof summary.totals !== "object") return null;
|
||||
|
||||
return {
|
||||
entries: visibleEntries,
|
||||
totalEntries: visibleEntries.length,
|
||||
totalSatsCost: totals.satsCost,
|
||||
recentSatsCost: totals.satsCost, // For now, recent = total since we don't have time window
|
||||
limit,
|
||||
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();
|
||||
}
|
||||
|
||||
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");
|
||||
@@ -135,100 +113,6 @@ export function formatNumber(n: number): string {
|
||||
return n.toString();
|
||||
}
|
||||
|
||||
export function getDayStats(entries: UsageTrackingEntry[]): Map<string, DayStats> {
|
||||
const days = new Map<string, DayStats>();
|
||||
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<number, DayStats> {
|
||||
const todayStart = getTodayStart();
|
||||
const hours = new Map<number, DayStats>();
|
||||
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<string, ModelStats>();
|
||||
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<string, ProviderStats>();
|
||||
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<string, ClientStats>();
|
||||
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 {
|
||||
@@ -262,56 +146,3 @@ export async function fetchClients(): Promise<ClientInfo[]> {
|
||||
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<string, string>();
|
||||
for (const c of clients) {
|
||||
if (c.ownerNpub) {
|
||||
clientNpubMap.set(c.clientId, c.ownerNpub);
|
||||
}
|
||||
}
|
||||
|
||||
// Aggregate usage per npub
|
||||
const npubs = new Map<string, NpubStats>();
|
||||
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 }
|
||||
);
|
||||
}
|
||||
|
||||
+56
-106
@@ -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,8 +111,7 @@ 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);
|
||||
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;
|
||||
@@ -240,7 +230,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;
|
||||
if (modelStats.length > 0) {
|
||||
const maxCost = modelStats[0]!.satsCost;
|
||||
const totalCost = Math.max(totalVisibleCost, 1);
|
||||
@@ -259,7 +249,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;
|
||||
if (clientStats.length > 0) {
|
||||
const maxCost = clientStats[0]!.satsCost;
|
||||
const totalCost = Math.max(totalVisibleCost, 1);
|
||||
@@ -282,19 +272,28 @@ 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 };
|
||||
// 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).
|
||||
const _now = new Date();
|
||||
const todayDateStr = `${_now.getFullYear()}-${String(_now.getMonth() + 1).padStart(2, "0")}-${String(_now.getDate()).padStart(2, "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;
|
||||
}
|
||||
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<number, { requests: number; satsCost: number }>;
|
||||
|
||||
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 };
|
||||
// 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 }]));
|
||||
}
|
||||
|
||||
const summaryLines = [
|
||||
@@ -306,18 +305,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 +323,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 +344,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;
|
||||
if (modelStats.length === 0) return renderBox(["No model data available"], width, "Models");
|
||||
|
||||
// Use totalSatsCost (all-time) for percentage calculations to match header
|
||||
@@ -373,7 +371,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;
|
||||
if (providerStats.length === 0) return renderBox(["No provider data available"], width, "Providers");
|
||||
|
||||
const lines: string[] = [];
|
||||
@@ -389,8 +387,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;
|
||||
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)}`,
|
||||
@@ -410,23 +408,7 @@ 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 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 +416,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;
|
||||
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 +460,19 @@ export function renderClients(stats: UsageStats, width: number): string {
|
||||
endBarSection("client-detail");
|
||||
|
||||
let output = renderBox(lines, width, "Client Breakdown");
|
||||
const clientModelMap = new Map<string, Map<string, { requests: number; satsCost: number; tokens: number }>>();
|
||||
|
||||
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; 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) {
|
||||
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("");
|
||||
}
|
||||
clientModelLines.push("");
|
||||
}
|
||||
|
||||
if (clientModelLines.length > 0) {
|
||||
@@ -512,7 +482,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;
|
||||
if (npubStats.length === 0) return renderBox(["No npub data available"], width, "Npub Breakdown");
|
||||
|
||||
const totalCost = stats.totalSatsCost;
|
||||
@@ -557,38 +527,18 @@ 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<string, Map<string, { requests: number; satsCost: number; tokens: number }>>();
|
||||
const clientNpubMap = new Map<string, string>();
|
||||
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("");
|
||||
}
|
||||
npubModelLines.push("");
|
||||
}
|
||||
|
||||
if (npubModelLines.length > 0) {
|
||||
|
||||
+4
-45
@@ -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,42 +9,7 @@ export interface UsageStats {
|
||||
totalSatsCost: number;
|
||||
recentSatsCost: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
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;
|
||||
summary: UsageSummary;
|
||||
}
|
||||
|
||||
export type TabId = "overview" | "today" | "models" | "providers" | "tokens" | "clients" | "npubs" | "recent";
|
||||
@@ -52,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;
|
||||
|
||||
Reference in New Issue
Block a user