@oh-my-pi/omp-stats 17.1.0 → 17.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -321,3 +321,112 @@ export interface ToolDashboardStats {
321
321
  byToolModel: ToolModelStats[];
322
322
  series: ToolTimeSeriesPoint[];
323
323
  }
324
+
325
+ /**
326
+ * Aggregated request/token/cost totals for one provider over the active range.
327
+ */
328
+ export interface ProviderAggregate {
329
+ provider: string;
330
+ totalRequests: number;
331
+ failedRequests: number;
332
+ /** Distinct models used through this provider in the range. */
333
+ models: number;
334
+ totalInputTokens: number;
335
+ totalOutputTokens: number;
336
+ totalCacheReadTokens: number;
337
+ totalCacheWriteTokens: number;
338
+ /** Uncached input + cache reads + cache writes + output. */
339
+ totalTokens: number;
340
+ totalCost: number;
341
+ totalPremiumRequests: number;
342
+ avgTokensPerSecond: number | null;
343
+ }
344
+
345
+ /**
346
+ * Token burn attributed to one local hour-of-day (0-23) for one provider.
347
+ * Powers the "peak burn hours" histogram.
348
+ */
349
+ export interface ProviderHourlyPoint {
350
+ provider: string;
351
+ /** Local hour of day, 0-23. */
352
+ hour: number;
353
+ totalTokens: number;
354
+ outputTokens: number;
355
+ requests: number;
356
+ }
357
+
358
+ /** Provider token/cost time-series point (bucketed like the model series). */
359
+ export interface ProviderTimeSeriesPoint {
360
+ timestamp: number;
361
+ provider: string;
362
+ totalTokens: number;
363
+ cost: number;
364
+ requests: number;
365
+ }
366
+
367
+ /** One recorded usage-limit snapshot for an (account, window) series. */
368
+ export interface UsageWindowPoint {
369
+ timestamp: number;
370
+ /** Used fraction 0..1 (>1 = overage) when the provider reported one. */
371
+ usedFraction: number | null;
372
+ exhausted: boolean;
373
+ }
374
+
375
+ /**
376
+ * Utilization history for one (account, limit window) pair of a provider,
377
+ * sourced from the auth store's recorded usage-limit snapshots.
378
+ */
379
+ export interface UsageWindowSeries {
380
+ provider: string;
381
+ accountKey: string;
382
+ /** Email/account id when known, else the stable account key. */
383
+ accountLabel: string;
384
+ /** Groups the same limit window across accounts (window label or limit id). */
385
+ windowKey: string;
386
+ windowLabel: string;
387
+ points: UsageWindowPoint[];
388
+ }
389
+
390
+ /**
391
+ * Derived subscription insight for one provider limit window across all
392
+ * accounts: how much of the window was consumed, what one window is worth in
393
+ * tokens, and how many accounts peak demand would have needed.
394
+ */
395
+ export interface ProviderWindowInsight {
396
+ provider: string;
397
+ windowKey: string;
398
+ windowLabel: string;
399
+ /** Accounts with at least one snapshot for this window in range. */
400
+ accounts: number;
401
+ /** Window resets observed (drops in used fraction). */
402
+ cycles: number;
403
+ /**
404
+ * Subscription-window equivalents consumed in range: sum of positive
405
+ * used-fraction deltas across accounts (1.0 = one full window burned).
406
+ */
407
+ fractionConsumed: number;
408
+ /**
409
+ * Estimated tokens one full window buys: provider tokens burned in range
410
+ * divided by {@link fractionConsumed}. Null when too little of the window
411
+ * was consumed to extrapolate.
412
+ */
413
+ estTokensPerWindow: number | null;
414
+ /** Peak of sum-across-accounts used fraction at any sampled instant. */
415
+ peakConcurrentFraction: number;
416
+ /**
417
+ * Accounts needed to keep peak demand under 90% of fleet capacity:
418
+ * max(1, ceil(peakConcurrentFraction / 0.9)).
419
+ */
420
+ idealAccounts: number;
421
+ /** Transitions into an exhausted state observed in range. */
422
+ exhaustedEvents: number;
423
+ }
424
+
425
+ /** Complete providers dashboard payload. */
426
+ export interface ProviderDashboardStats {
427
+ providers: ProviderAggregate[];
428
+ hourly: ProviderHourlyPoint[];
429
+ series: ProviderTimeSeriesPoint[];
430
+ usageSeries: UsageWindowSeries[];
431
+ windowInsights: ProviderWindowInsight[];
432
+ }
@@ -0,0 +1,303 @@
1
+ /**
2
+ * Provider subscription-window analytics for the stats dashboard.
3
+ *
4
+ * The auth layer appends one row to `usage_history` in agent.db every time a
5
+ * provider usage report is fetched (see AuthStorage's usage recording). This
6
+ * module reads those snapshots read-only and derives:
7
+ * - utilization series (used fraction over time per account and limit window),
8
+ * - per-window subscription insights: window-equivalents consumed, an
9
+ * estimate of how many tokens one full window buys, peak concurrent
10
+ * utilization across accounts, and the account count that peak implies.
11
+ *
12
+ * A missing agent DB or `usage_history` table yields empty results — the
13
+ * dashboard must keep working for API-key-only setups that never record usage.
14
+ */
15
+ import { Database } from "bun:sqlite";
16
+ import { AuthBrokerClient, resolveAuthBrokerConfig } from "@oh-my-pi/pi-ai/auth-broker";
17
+ import { getAgentDbPath, logger } from "@oh-my-pi/pi-utils";
18
+ import type { ProviderWindowInsight, UsageWindowPoint, UsageWindowSeries } from "./shared-types";
19
+
20
+ /** Subset of a `usage_history` row consumed by the window analytics. */
21
+ export interface UsageSnapshotRow {
22
+ /** Epoch ms the report was fetched. */
23
+ recordedAt: number;
24
+ provider: string;
25
+ /** Stable credential identity key. */
26
+ accountKey: string;
27
+ email: string | null;
28
+ accountId: string | null;
29
+ limitId: string;
30
+ label: string;
31
+ windowLabel: string | null;
32
+ /** Used fraction (0..1, >1 = overage) when the provider reported one. */
33
+ usedFraction: number | null;
34
+ status: string | null;
35
+ }
36
+
37
+ /** Utilization series + derived insights for every provider window in range. */
38
+ export interface UsageWindowStats {
39
+ usageSeries: UsageWindowSeries[];
40
+ windowInsights: ProviderWindowInsight[];
41
+ }
42
+
43
+ /** A used-fraction drop smaller than this is jitter, not a window reset. */
44
+ const RESET_DROP_THRESHOLD = 0.05;
45
+ /** Minimum window-equivalents consumed before extrapolating tokens/window. */
46
+ const MIN_EXTRAPOLATION_FRACTION = 0.1;
47
+ /** Fleet-capacity headroom target: peak demand should stay under 90%. */
48
+ const TARGET_PEAK_UTILIZATION = 0.9;
49
+ /** Used fraction at or above this counts as exhausted even without a status. */
50
+ const EXHAUSTED_FRACTION = 0.999;
51
+ /** Utilization series are downsampled (peak per bucket) to at most this many points. */
52
+ const MAX_SERIES_POINTS = 400;
53
+
54
+ /**
55
+ * Read usage-limit snapshots recorded at or after `sinceMs`, oldest first.
56
+ * Opens the agent DB read-only; returns `[]` when the DB or table is absent.
57
+ */
58
+ export function readUsageSnapshots(sinceMs: number, dbPath = getAgentDbPath()): UsageSnapshotRow[] {
59
+ let db: Database | null = null;
60
+ try {
61
+ db = new Database(dbPath, { readonly: true });
62
+ const rows = db
63
+ .prepare(
64
+ `SELECT recorded_at, provider, account_key, email, account_id, limit_id, label, window_label, used_fraction, status
65
+ FROM usage_history
66
+ WHERE recorded_at >= ?
67
+ ORDER BY recorded_at ASC`,
68
+ )
69
+ .all(sinceMs) as Array<{
70
+ recorded_at: number;
71
+ provider: string;
72
+ account_key: string;
73
+ email: string | null;
74
+ account_id: string | null;
75
+ limit_id: string;
76
+ label: string;
77
+ window_label: string | null;
78
+ used_fraction: number | null;
79
+ status: string | null;
80
+ }>;
81
+ return rows.map(row => ({
82
+ recordedAt: row.recorded_at,
83
+ provider: row.provider,
84
+ accountKey: row.account_key,
85
+ email: row.email,
86
+ accountId: row.account_id,
87
+ limitId: row.limit_id,
88
+ label: row.label,
89
+ windowLabel: row.window_label,
90
+ usedFraction: row.used_fraction,
91
+ status: row.status,
92
+ }));
93
+ } catch (err) {
94
+ // Expected for fresh installs (no agent.db) or pre-usage-history schemas.
95
+ logger.debug("usage_history unavailable for provider stats", { dbPath, error: String(err) });
96
+ return [];
97
+ } finally {
98
+ db?.close();
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Fetch usage snapshots from wherever they actually accumulate: the auth
104
+ * broker's durable history when a broker is configured (the broker performs
105
+ * every upstream usage fetch in that mode, so the local `usage_history` stays
106
+ * frozen), else the local agent DB. Broker errors fall back to the local read
107
+ * so the dashboard degrades to stale-but-present data instead of failing.
108
+ */
109
+ export async function fetchUsageSnapshots(sinceMs: number): Promise<UsageSnapshotRow[]> {
110
+ try {
111
+ const brokerConfig = await resolveAuthBrokerConfig();
112
+ if (brokerConfig) {
113
+ const client = new AuthBrokerClient({ url: brokerConfig.url, token: brokerConfig.token });
114
+ const response = await client.fetchUsageHistory({ sinceMs });
115
+ return response.entries.map(entry => ({
116
+ recordedAt: entry.recordedAt,
117
+ provider: entry.provider,
118
+ accountKey: entry.accountKey,
119
+ email: entry.email ?? null,
120
+ accountId: entry.accountId ?? null,
121
+ limitId: entry.limitId,
122
+ label: entry.label,
123
+ windowLabel: entry.windowLabel ?? null,
124
+ usedFraction: entry.usedFraction ?? null,
125
+ status: entry.status ?? null,
126
+ }));
127
+ }
128
+ } catch (err) {
129
+ logger.debug("broker usage history unavailable, falling back to local", { error: String(err) });
130
+ }
131
+ return readUsageSnapshots(sinceMs);
132
+ }
133
+
134
+ /** True when a snapshot reports an exhausted window, by status or by fraction. */
135
+ function isExhausted(fraction: number | null, status: string | null): boolean {
136
+ if (status === "exhausted") return true;
137
+ return fraction !== null && fraction >= EXHAUSTED_FRACTION;
138
+ }
139
+
140
+ /**
141
+ * Reduce a point list to at most {@link MAX_SERIES_POINTS} by keeping the
142
+ * peak-fraction point per time bucket, so utilization peaks survive downsampling.
143
+ */
144
+ function downsamplePoints(points: UsageWindowPoint[]): UsageWindowPoint[] {
145
+ if (points.length <= MAX_SERIES_POINTS) return points;
146
+ const first = points[0].timestamp;
147
+ const span = points[points.length - 1].timestamp - first;
148
+ const bucketMs = Math.max(1, Math.ceil(span / MAX_SERIES_POINTS));
149
+ const out: UsageWindowPoint[] = [];
150
+ let bucket = -1;
151
+ for (const point of points) {
152
+ const b = Math.floor((point.timestamp - first) / bucketMs);
153
+ if (b !== bucket) {
154
+ out.push(point);
155
+ bucket = b;
156
+ continue;
157
+ }
158
+ const last = out[out.length - 1];
159
+ if ((point.usedFraction ?? -1) >= (last.usedFraction ?? -1)) out[out.length - 1] = point;
160
+ }
161
+ return out;
162
+ }
163
+
164
+ interface AccountSeries {
165
+ accountKey: string;
166
+ accountLabel: string;
167
+ rows: UsageSnapshotRow[];
168
+ }
169
+
170
+ interface WindowGroup {
171
+ provider: string;
172
+ windowKey: string;
173
+ windowLabel: string;
174
+ accounts: Map<string, AccountSeries>;
175
+ }
176
+
177
+ /**
178
+ * Derive utilization series and per-window insights from raw snapshots.
179
+ *
180
+ * `tokensByProvider` supplies each provider's token burn over the same time
181
+ * range (from the local message stats); it converts consumed window fraction
182
+ * into an estimated token capacity per window. Attribution note: tokens are
183
+ * per provider, not per account, so the estimate treats the account fleet as
184
+ * one pooled subscription — which is exactly how round-robin auth uses it.
185
+ */
186
+ export function computeUsageWindowStats(
187
+ rows: UsageSnapshotRow[],
188
+ tokensByProvider: ReadonlyMap<string, number>,
189
+ ): UsageWindowStats {
190
+ const groups = new Map<string, WindowGroup>();
191
+ for (const row of rows) {
192
+ const windowKey = row.windowLabel ?? row.limitId;
193
+ const groupKey = `${row.provider}\u0000${windowKey}`;
194
+ let group = groups.get(groupKey);
195
+ if (!group) {
196
+ group = { provider: row.provider, windowKey, windowLabel: row.windowLabel ?? row.label, accounts: new Map() };
197
+ groups.set(groupKey, group);
198
+ }
199
+ // Labels can change across snapshots (provider renames); latest wins.
200
+ group.windowLabel = row.windowLabel ?? row.label;
201
+ let account = group.accounts.get(row.accountKey);
202
+ if (!account) {
203
+ account = { accountKey: row.accountKey, accountLabel: row.email ?? row.accountId ?? row.accountKey, rows: [] };
204
+ group.accounts.set(row.accountKey, account);
205
+ }
206
+ if (row.email || row.accountId) account.accountLabel = row.email ?? row.accountId ?? row.accountKey;
207
+ account.rows.push(row);
208
+ }
209
+
210
+ const usageSeries: UsageWindowSeries[] = [];
211
+ const windowInsights: ProviderWindowInsight[] = [];
212
+
213
+ for (const group of groups.values()) {
214
+ let fractionConsumed = 0;
215
+ let cycles = 0;
216
+ let exhaustedEvents = 0;
217
+
218
+ for (const account of group.accounts.values()) {
219
+ const points: UsageWindowPoint[] = account.rows.map(row => ({
220
+ timestamp: row.recordedAt,
221
+ usedFraction: row.usedFraction,
222
+ exhausted: isExhausted(row.usedFraction, row.status),
223
+ }));
224
+ usageSeries.push({
225
+ provider: group.provider,
226
+ accountKey: account.accountKey,
227
+ accountLabel: account.accountLabel,
228
+ windowKey: group.windowKey,
229
+ windowLabel: group.windowLabel,
230
+ points: downsamplePoints(points),
231
+ });
232
+
233
+ let prevFraction: number | null = null;
234
+ let prevExhausted = false;
235
+ for (const row of account.rows) {
236
+ const exhausted = isExhausted(row.usedFraction, row.status);
237
+ if (exhausted && !prevExhausted) exhaustedEvents++;
238
+ prevExhausted = exhausted;
239
+ if (row.usedFraction === null) continue;
240
+ if (prevFraction !== null) {
241
+ const delta = row.usedFraction - prevFraction;
242
+ if (delta > 0) fractionConsumed += delta;
243
+ else if (delta < -RESET_DROP_THRESHOLD) cycles++;
244
+ }
245
+ prevFraction = row.usedFraction;
246
+ }
247
+ }
248
+
249
+ const providerTokens = tokensByProvider.get(group.provider) ?? 0;
250
+ const peak = peakConcurrentFraction(group);
251
+ windowInsights.push({
252
+ provider: group.provider,
253
+ windowKey: group.windowKey,
254
+ windowLabel: group.windowLabel,
255
+ accounts: group.accounts.size,
256
+ cycles,
257
+ fractionConsumed,
258
+ estTokensPerWindow:
259
+ providerTokens > 0 && fractionConsumed >= MIN_EXTRAPOLATION_FRACTION
260
+ ? Math.round(providerTokens / fractionConsumed)
261
+ : null,
262
+ peakConcurrentFraction: peak,
263
+ idealAccounts: Math.max(1, Math.ceil(peak / TARGET_PEAK_UTILIZATION)),
264
+ exhaustedEvents,
265
+ });
266
+ }
267
+
268
+ usageSeries.sort(
269
+ (a, b) =>
270
+ a.provider.localeCompare(b.provider) ||
271
+ a.windowKey.localeCompare(b.windowKey) ||
272
+ a.accountLabel.localeCompare(b.accountLabel),
273
+ );
274
+ windowInsights.sort((a, b) => a.provider.localeCompare(b.provider) || b.fractionConsumed - a.fractionConsumed);
275
+ return { usageSeries, windowInsights };
276
+ }
277
+
278
+ /**
279
+ * Peak of sum-across-accounts used fraction at any sampled instant: sweep all
280
+ * snapshot times, forward-filling each account's last known fraction. A peak
281
+ * of 1.7 means demand simultaneously held 1.7 windows' worth of quota.
282
+ */
283
+ function peakConcurrentFraction(group: WindowGroup): number {
284
+ type Event = { timestamp: number; account: string; fraction: number };
285
+ const events: Event[] = [];
286
+ for (const account of group.accounts.values()) {
287
+ for (const row of account.rows) {
288
+ if (row.usedFraction === null) continue;
289
+ events.push({ timestamp: row.recordedAt, account: account.accountKey, fraction: row.usedFraction });
290
+ }
291
+ }
292
+ events.sort((a, b) => a.timestamp - b.timestamp);
293
+
294
+ const current = new Map<string, number>();
295
+ let sum = 0;
296
+ let peak = 0;
297
+ for (const event of events) {
298
+ sum += event.fraction - (current.get(event.account) ?? 0);
299
+ current.set(event.account, event.fraction);
300
+ if (sum > peak) peak = sum;
301
+ }
302
+ return peak;
303
+ }