diff --git a/src/daemon/http/index.ts b/src/daemon/http/index.ts index 44ce3d7..28f3974 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"; @@ -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(); diff --git a/src/daemon/http/usage-summary.test.ts b/src/daemon/http/usage-summary.test.ts new file mode 100644 index 0000000..59f97df --- /dev/null +++ b/src/daemon/http/usage-summary.test.ts @@ -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 = { + 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 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); + }); +}); diff --git a/src/daemon/http/usage-summary.ts b/src/daemon/http/usage-summary.ts new file mode 100644 index 0000000..e909fa2 --- /dev/null +++ b/src/daemon/http/usage-summary.ts @@ -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 { + // 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(); + 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({ + ...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; +} diff --git a/src/daemon/index.ts b/src/daemon/index.ts index c940a00..bf26043 100644 --- a/src/daemon/index.ts +++ b/src/daemon/index.ts @@ -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"; diff --git a/src/tui/usage/app.ts b/src/tui/usage/app.ts index e2d5f63..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, 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 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 b5392c0..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 } from "./types.ts"; +import type { UsageStats, UsageSummary } from "./types.ts"; export { isDaemonRunning }; @@ -76,53 +76,31 @@ export async function fetchBalance(): Promise { } } -export async function fetchUsage(limit = 10000): Promise { +export async function fetchUsageSummary(): Promise { 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 { - 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 { @@ -262,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 46e6f39..2fe7b41 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,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; + + 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>(); - - 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>(); - 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(""); } - npubModelLines.push(""); } if (npubModelLines.length > 0) { diff --git a/src/tui/usage/types.ts b/src/tui/usage/types.ts index 7e204bd..2c0345d 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,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;