@linxiraos/pi-stats 1.0.0

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.
Files changed (126) hide show
  1. package/CHANGELOG.md +276 -0
  2. package/README.md +82 -0
  3. package/build.ts +95 -0
  4. package/dist/client/index.css +1 -0
  5. package/dist/client/index.html +24 -0
  6. package/dist/client/index.js +257 -0
  7. package/dist/client/styles.css +1695 -0
  8. package/dist/types/aggregator.d.ts +98 -0
  9. package/dist/types/client/App.d.ts +1 -0
  10. package/dist/types/client/api.d.ts +22 -0
  11. package/dist/types/client/app/AppLayout.d.ts +16 -0
  12. package/dist/types/client/app/NavRail.d.ts +7 -0
  13. package/dist/types/client/app/RangeControl.d.ts +7 -0
  14. package/dist/types/client/app/SyncButton.d.ts +14 -0
  15. package/dist/types/client/app/ThemeToggle.d.ts +1 -0
  16. package/dist/types/client/app/TopBar.d.ts +15 -0
  17. package/dist/types/client/app/routes.d.ts +12 -0
  18. package/dist/types/client/components/AgentTokenShare.d.ts +5 -0
  19. package/dist/types/client/components/chart-shared.d.ts +173 -0
  20. package/dist/types/client/components/models-table-shared.d.ts +175 -0
  21. package/dist/types/client/components/range-meta.d.ts +21 -0
  22. package/dist/types/client/data/charts.d.ts +1 -0
  23. package/dist/types/client/data/formatters.d.ts +8 -0
  24. package/dist/types/client/data/useHashRoute.d.ts +8 -0
  25. package/dist/types/client/data/useResource.d.ts +13 -0
  26. package/dist/types/client/data/view-models.d.ts +74 -0
  27. package/dist/types/client/index.d.ts +2 -0
  28. package/dist/types/client/routes/BehaviorRoute.d.ts +7 -0
  29. package/dist/types/client/routes/CostsRoute.d.ts +7 -0
  30. package/dist/types/client/routes/ErrorsRoute.d.ts +8 -0
  31. package/dist/types/client/routes/GainRoute.d.ts +7 -0
  32. package/dist/types/client/routes/ModelsRoute.d.ts +7 -0
  33. package/dist/types/client/routes/OverviewRoute.d.ts +8 -0
  34. package/dist/types/client/routes/ProjectsRoute.d.ts +7 -0
  35. package/dist/types/client/routes/ProvidersRoute.d.ts +7 -0
  36. package/dist/types/client/routes/RequestsRoute.d.ts +8 -0
  37. package/dist/types/client/routes/ToolsRoute.d.ts +7 -0
  38. package/dist/types/client/routes/index.d.ts +10 -0
  39. package/dist/types/client/types.d.ts +63 -0
  40. package/dist/types/client/ui/AsyncBoundary.d.ts +12 -0
  41. package/dist/types/client/ui/DataTable.d.ts +17 -0
  42. package/dist/types/client/ui/EmptyState.d.ts +7 -0
  43. package/dist/types/client/ui/ErrorState.d.ts +6 -0
  44. package/dist/types/client/ui/JsonBlock.d.ts +7 -0
  45. package/dist/types/client/ui/MetricCluster.d.ts +5 -0
  46. package/dist/types/client/ui/Panel.d.ts +7 -0
  47. package/dist/types/client/ui/RequestDrawer.d.ts +5 -0
  48. package/dist/types/client/ui/SegmentedControl.d.ts +12 -0
  49. package/dist/types/client/ui/Skeleton.d.ts +8 -0
  50. package/dist/types/client/ui/StatusPill.d.ts +7 -0
  51. package/dist/types/client/ui/index.d.ts +11 -0
  52. package/dist/types/client/useSystemTheme.d.ts +11 -0
  53. package/dist/types/db.d.ts +159 -0
  54. package/dist/types/embedded-client.d.ts +18 -0
  55. package/dist/types/gain-aggregator.d.ts +26 -0
  56. package/dist/types/index.d.ts +7 -0
  57. package/dist/types/parser.d.ts +53 -0
  58. package/dist/types/port-conflict.d.ts +14 -0
  59. package/dist/types/server.d.ts +12 -0
  60. package/dist/types/shared-types.d.ts +403 -0
  61. package/dist/types/sync-worker.d.ts +31 -0
  62. package/dist/types/types.d.ts +164 -0
  63. package/dist/types/usage-windows.d.ts +45 -0
  64. package/dist/types/user-metrics.d.ts +81 -0
  65. package/package.json +94 -0
  66. package/src/aggregator.ts +615 -0
  67. package/src/client/App.tsx +112 -0
  68. package/src/client/api.ts +118 -0
  69. package/src/client/app/AppLayout.tsx +93 -0
  70. package/src/client/app/NavRail.tsx +44 -0
  71. package/src/client/app/RangeControl.tsx +39 -0
  72. package/src/client/app/SyncButton.tsx +75 -0
  73. package/src/client/app/ThemeToggle.tsx +37 -0
  74. package/src/client/app/TopBar.tsx +73 -0
  75. package/src/client/app/routes.ts +86 -0
  76. package/src/client/components/AgentTokenShare.tsx +68 -0
  77. package/src/client/components/chart-shared.tsx +257 -0
  78. package/src/client/components/models-table-shared.tsx +255 -0
  79. package/src/client/components/range-meta.ts +73 -0
  80. package/src/client/css.d.ts +1 -0
  81. package/src/client/data/charts.ts +14 -0
  82. package/src/client/data/formatters.ts +45 -0
  83. package/src/client/data/useHashRoute.ts +88 -0
  84. package/src/client/data/useResource.ts +154 -0
  85. package/src/client/data/view-models.ts +259 -0
  86. package/src/client/index.tsx +10 -0
  87. package/src/client/routes/BehaviorRoute.tsx +623 -0
  88. package/src/client/routes/CostsRoute.tsx +234 -0
  89. package/src/client/routes/ErrorsRoute.tsx +118 -0
  90. package/src/client/routes/GainRoute.tsx +226 -0
  91. package/src/client/routes/ModelsRoute.tsx +430 -0
  92. package/src/client/routes/OverviewRoute.tsx +342 -0
  93. package/src/client/routes/ProjectsRoute.tsx +163 -0
  94. package/src/client/routes/ProvidersRoute.tsx +492 -0
  95. package/src/client/routes/RequestsRoute.tsx +123 -0
  96. package/src/client/routes/ToolsRoute.tsx +463 -0
  97. package/src/client/routes/index.ts +10 -0
  98. package/src/client/styles.css +1384 -0
  99. package/src/client/types.ts +80 -0
  100. package/src/client/ui/AsyncBoundary.tsx +54 -0
  101. package/src/client/ui/DataTable.tsx +122 -0
  102. package/src/client/ui/EmptyState.tsx +16 -0
  103. package/src/client/ui/ErrorState.tsx +25 -0
  104. package/src/client/ui/JsonBlock.tsx +75 -0
  105. package/src/client/ui/MetricCluster.tsx +78 -0
  106. package/src/client/ui/Panel.tsx +24 -0
  107. package/src/client/ui/RequestDrawer.tsx +208 -0
  108. package/src/client/ui/SegmentedControl.tsx +36 -0
  109. package/src/client/ui/Skeleton.tsx +17 -0
  110. package/src/client/ui/StatusPill.tsx +15 -0
  111. package/src/client/ui/index.ts +11 -0
  112. package/src/client/useSystemTheme.ts +87 -0
  113. package/src/db.ts +1702 -0
  114. package/src/embedded-client.generated.txt +0 -0
  115. package/src/embedded-client.ts +26 -0
  116. package/src/gain-aggregator.ts +311 -0
  117. package/src/index.ts +194 -0
  118. package/src/parser.ts +499 -0
  119. package/src/port-conflict.ts +263 -0
  120. package/src/server.ts +396 -0
  121. package/src/shared-types.ts +432 -0
  122. package/src/sync-worker.ts +40 -0
  123. package/src/types.ts +171 -0
  124. package/src/usage-windows.ts +304 -0
  125. package/src/user-metrics.ts +695 -0
  126. package/tailwind.config.js +40 -0
@@ -0,0 +1,304 @@
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 "@linxiraos/pi-ai/auth-broker";
17
+ import { getAgentDbPath, logger } from "@linxiraos/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
+ db.run("PRAGMA busy_timeout = 5000");
63
+ const rows = db
64
+ .prepare(
65
+ `SELECT recorded_at, provider, account_key, email, account_id, limit_id, label, window_label, used_fraction, status
66
+ FROM usage_history
67
+ WHERE recorded_at >= ?
68
+ ORDER BY recorded_at ASC`,
69
+ )
70
+ .all(sinceMs) as Array<{
71
+ recorded_at: number;
72
+ provider: string;
73
+ account_key: string;
74
+ email: string | null;
75
+ account_id: string | null;
76
+ limit_id: string;
77
+ label: string;
78
+ window_label: string | null;
79
+ used_fraction: number | null;
80
+ status: string | null;
81
+ }>;
82
+ return rows.map(row => ({
83
+ recordedAt: row.recorded_at,
84
+ provider: row.provider,
85
+ accountKey: row.account_key,
86
+ email: row.email,
87
+ accountId: row.account_id,
88
+ limitId: row.limit_id,
89
+ label: row.label,
90
+ windowLabel: row.window_label,
91
+ usedFraction: row.used_fraction,
92
+ status: row.status,
93
+ }));
94
+ } catch (err) {
95
+ // Expected for fresh installs (no agent.db) or pre-usage-history schemas.
96
+ logger.debug("usage_history unavailable for provider stats", { dbPath, error: String(err) });
97
+ return [];
98
+ } finally {
99
+ db?.close();
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Fetch usage snapshots from wherever they actually accumulate: the auth
105
+ * broker's durable history when a broker is configured (the broker performs
106
+ * every upstream usage fetch in that mode, so the local `usage_history` stays
107
+ * frozen), else the local agent DB. Broker errors fall back to the local read
108
+ * so the dashboard degrades to stale-but-present data instead of failing.
109
+ */
110
+ export async function fetchUsageSnapshots(sinceMs: number): Promise<UsageSnapshotRow[]> {
111
+ try {
112
+ const brokerConfig = await resolveAuthBrokerConfig();
113
+ if (brokerConfig) {
114
+ const client = new AuthBrokerClient({ url: brokerConfig.url, token: brokerConfig.token });
115
+ const response = await client.fetchUsageHistory({ sinceMs });
116
+ return response.entries.map(entry => ({
117
+ recordedAt: entry.recordedAt,
118
+ provider: entry.provider,
119
+ accountKey: entry.accountKey,
120
+ email: entry.email ?? null,
121
+ accountId: entry.accountId ?? null,
122
+ limitId: entry.limitId,
123
+ label: entry.label,
124
+ windowLabel: entry.windowLabel ?? null,
125
+ usedFraction: entry.usedFraction ?? null,
126
+ status: entry.status ?? null,
127
+ }));
128
+ }
129
+ } catch (err) {
130
+ logger.debug("broker usage history unavailable, falling back to local", { error: String(err) });
131
+ }
132
+ return readUsageSnapshots(sinceMs);
133
+ }
134
+
135
+ /** True when a snapshot reports an exhausted window, by status or by fraction. */
136
+ function isExhausted(fraction: number | null, status: string | null): boolean {
137
+ if (status === "exhausted") return true;
138
+ return fraction !== null && fraction >= EXHAUSTED_FRACTION;
139
+ }
140
+
141
+ /**
142
+ * Reduce a point list to at most {@link MAX_SERIES_POINTS} by keeping the
143
+ * peak-fraction point per time bucket, so utilization peaks survive downsampling.
144
+ */
145
+ function downsamplePoints(points: UsageWindowPoint[]): UsageWindowPoint[] {
146
+ if (points.length <= MAX_SERIES_POINTS) return points;
147
+ const first = points[0].timestamp;
148
+ const span = points[points.length - 1].timestamp - first;
149
+ const bucketMs = Math.max(1, Math.ceil(span / MAX_SERIES_POINTS));
150
+ const out: UsageWindowPoint[] = [];
151
+ let bucket = -1;
152
+ for (const point of points) {
153
+ const b = Math.floor((point.timestamp - first) / bucketMs);
154
+ if (b !== bucket) {
155
+ out.push(point);
156
+ bucket = b;
157
+ continue;
158
+ }
159
+ const last = out[out.length - 1];
160
+ if ((point.usedFraction ?? -1) >= (last.usedFraction ?? -1)) out[out.length - 1] = point;
161
+ }
162
+ return out;
163
+ }
164
+
165
+ interface AccountSeries {
166
+ accountKey: string;
167
+ accountLabel: string;
168
+ rows: UsageSnapshotRow[];
169
+ }
170
+
171
+ interface WindowGroup {
172
+ provider: string;
173
+ windowKey: string;
174
+ windowLabel: string;
175
+ accounts: Map<string, AccountSeries>;
176
+ }
177
+
178
+ /**
179
+ * Derive utilization series and per-window insights from raw snapshots.
180
+ *
181
+ * `tokensByProvider` supplies each provider's token burn over the same time
182
+ * range (from the local message stats); it converts consumed window fraction
183
+ * into an estimated token capacity per window. Attribution note: tokens are
184
+ * per provider, not per account, so the estimate treats the account fleet as
185
+ * one pooled subscription — which is exactly how round-robin auth uses it.
186
+ */
187
+ export function computeUsageWindowStats(
188
+ rows: UsageSnapshotRow[],
189
+ tokensByProvider: ReadonlyMap<string, number>,
190
+ ): UsageWindowStats {
191
+ const groups = new Map<string, WindowGroup>();
192
+ for (const row of rows) {
193
+ const windowKey = row.windowLabel ?? row.limitId;
194
+ const groupKey = `${row.provider}\u0000${windowKey}`;
195
+ let group = groups.get(groupKey);
196
+ if (!group) {
197
+ group = { provider: row.provider, windowKey, windowLabel: row.windowLabel ?? row.label, accounts: new Map() };
198
+ groups.set(groupKey, group);
199
+ }
200
+ // Labels can change across snapshots (provider renames); latest wins.
201
+ group.windowLabel = row.windowLabel ?? row.label;
202
+ let account = group.accounts.get(row.accountKey);
203
+ if (!account) {
204
+ account = { accountKey: row.accountKey, accountLabel: row.email ?? row.accountId ?? row.accountKey, rows: [] };
205
+ group.accounts.set(row.accountKey, account);
206
+ }
207
+ if (row.email || row.accountId) account.accountLabel = row.email ?? row.accountId ?? row.accountKey;
208
+ account.rows.push(row);
209
+ }
210
+
211
+ const usageSeries: UsageWindowSeries[] = [];
212
+ const windowInsights: ProviderWindowInsight[] = [];
213
+
214
+ for (const group of groups.values()) {
215
+ let fractionConsumed = 0;
216
+ let cycles = 0;
217
+ let exhaustedEvents = 0;
218
+
219
+ for (const account of group.accounts.values()) {
220
+ const points: UsageWindowPoint[] = account.rows.map(row => ({
221
+ timestamp: row.recordedAt,
222
+ usedFraction: row.usedFraction,
223
+ exhausted: isExhausted(row.usedFraction, row.status),
224
+ }));
225
+ usageSeries.push({
226
+ provider: group.provider,
227
+ accountKey: account.accountKey,
228
+ accountLabel: account.accountLabel,
229
+ windowKey: group.windowKey,
230
+ windowLabel: group.windowLabel,
231
+ points: downsamplePoints(points),
232
+ });
233
+
234
+ let prevFraction: number | null = null;
235
+ let prevExhausted = false;
236
+ for (const row of account.rows) {
237
+ const exhausted = isExhausted(row.usedFraction, row.status);
238
+ if (exhausted && !prevExhausted) exhaustedEvents++;
239
+ prevExhausted = exhausted;
240
+ if (row.usedFraction === null) continue;
241
+ if (prevFraction !== null) {
242
+ const delta = row.usedFraction - prevFraction;
243
+ if (delta > 0) fractionConsumed += delta;
244
+ else if (delta < -RESET_DROP_THRESHOLD) cycles++;
245
+ }
246
+ prevFraction = row.usedFraction;
247
+ }
248
+ }
249
+
250
+ const providerTokens = tokensByProvider.get(group.provider) ?? 0;
251
+ const peak = peakConcurrentFraction(group);
252
+ windowInsights.push({
253
+ provider: group.provider,
254
+ windowKey: group.windowKey,
255
+ windowLabel: group.windowLabel,
256
+ accounts: group.accounts.size,
257
+ cycles,
258
+ fractionConsumed,
259
+ estTokensPerWindow:
260
+ providerTokens > 0 && fractionConsumed >= MIN_EXTRAPOLATION_FRACTION
261
+ ? Math.round(providerTokens / fractionConsumed)
262
+ : null,
263
+ peakConcurrentFraction: peak,
264
+ idealAccounts: Math.max(1, Math.ceil(peak / TARGET_PEAK_UTILIZATION)),
265
+ exhaustedEvents,
266
+ });
267
+ }
268
+
269
+ usageSeries.sort(
270
+ (a, b) =>
271
+ a.provider.localeCompare(b.provider) ||
272
+ a.windowKey.localeCompare(b.windowKey) ||
273
+ a.accountLabel.localeCompare(b.accountLabel),
274
+ );
275
+ windowInsights.sort((a, b) => a.provider.localeCompare(b.provider) || b.fractionConsumed - a.fractionConsumed);
276
+ return { usageSeries, windowInsights };
277
+ }
278
+
279
+ /**
280
+ * Peak of sum-across-accounts used fraction at any sampled instant: sweep all
281
+ * snapshot times, forward-filling each account's last known fraction. A peak
282
+ * of 1.7 means demand simultaneously held 1.7 windows' worth of quota.
283
+ */
284
+ function peakConcurrentFraction(group: WindowGroup): number {
285
+ type Event = { timestamp: number; account: string; fraction: number };
286
+ const events: Event[] = [];
287
+ for (const account of group.accounts.values()) {
288
+ for (const row of account.rows) {
289
+ if (row.usedFraction === null) continue;
290
+ events.push({ timestamp: row.recordedAt, account: account.accountKey, fraction: row.usedFraction });
291
+ }
292
+ }
293
+ events.sort((a, b) => a.timestamp - b.timestamp);
294
+
295
+ const current = new Map<string, number>();
296
+ let sum = 0;
297
+ let peak = 0;
298
+ for (const event of events) {
299
+ sum += event.fraction - (current.get(event.account) ?? 0);
300
+ current.set(event.account, event.fraction);
301
+ if (sum > peak) peak = sum;
302
+ }
303
+ return peak;
304
+ }