@linxiraos/pi-stats 1.0.10 → 1.1.1

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.
package/src/index.ts CHANGED
@@ -39,10 +39,9 @@ export type {
39
39
  ToolUsageStats,
40
40
  } from "./types";
41
41
 
42
- /**
43
- * Format cost in dollars.
44
- */
45
- function formatCost(n: number): string {
42
+ /** Format an API-equivalent estimate in dollars, or N/A for unpriced usage. */
43
+ function formatCost(n: number, unpricedRequests = 0): string {
44
+ if (n === 0 && unpricedRequests > 0) return "N/A";
46
45
  if (n < 0.01) return `$${n.toFixed(4)}`;
47
46
  if (n < 1) return `$${n.toFixed(3)}`;
48
47
  return `$${n.toFixed(2)}`;
@@ -69,7 +68,7 @@ async function printStats(): Promise<void> {
69
68
  console.log(` Output Tokens: ${formatNumber(overall.totalOutputTokens)}`);
70
69
  console.log(` Cache Rate: ${formatPercent(overall.cacheRate)}`);
71
70
  console.log(` Cache Savings: ${formatPercent(overall.cacheSavings)}`);
72
- console.log(` Total Cost: ${formatCost(overall.totalCost)}`);
71
+ console.log(` API-equivalent estimate: ${formatCost(overall.totalCost, overall.unpricedRequests)}`);
73
72
  console.log(` Premium Requests: ${formatNumber(normalizePremiumRequests(overall.totalPremiumRequests ?? 0))}`);
74
73
  console.log(` Avg Duration: ${overall.avgDuration !== null ? formatDuration(overall.avgDuration) : "-"}`);
75
74
  console.log(` Avg TTFT: ${overall.avgTtft !== null ? formatDuration(overall.avgTtft) : "-"}`);
@@ -78,18 +77,20 @@ async function printStats(): Promise<void> {
78
77
  }
79
78
 
80
79
  if (byModel.length > 0) {
81
- console.log("\nBy Model:");
80
+ console.log("\nBy Model (API-equivalent estimates):");
82
81
  for (const m of byModel.slice(0, 10)) {
83
82
  console.log(
84
- ` ${m.model}: ${formatNumber(m.totalRequests)} reqs, ${formatCost(m.totalCost)}, ${formatPercent(m.cacheRate)} cache rate, ${formatPercent(m.cacheSavings)} cache savings`,
83
+ ` ${m.model}: ${formatNumber(m.totalRequests)} reqs, ${formatCost(m.totalCost, m.unpricedRequests)}, ${formatPercent(m.cacheRate)} cache rate, ${formatPercent(m.cacheSavings)} cache savings`,
85
84
  );
86
85
  }
87
86
  }
88
87
 
89
88
  if (byFolder.length > 0) {
90
- console.log("\nBy Folder:");
89
+ console.log("\nBy Folder (API-equivalent estimates):");
91
90
  for (const f of byFolder.slice(0, 10)) {
92
- console.log(` ${f.folder}: ${formatNumber(f.totalRequests)} reqs, ${formatCost(f.totalCost)}`);
91
+ console.log(
92
+ ` ${f.folder}: ${formatNumber(f.totalRequests)} reqs, ${formatCost(f.totalCost, f.unpricedRequests)}`,
93
+ );
93
94
  }
94
95
  }
95
96
 
package/src/server.ts CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  getBehaviorDashboardStats,
9
9
  getCostDashboardStats,
10
10
  getDashboardStats,
11
+ getFolderStats,
11
12
  getModelDashboardStats,
12
13
  getOverviewStats,
13
14
  getProviderDashboardStats,
@@ -253,8 +254,8 @@ export async function handleApi(req: Request): Promise<Response> {
253
254
  }
254
255
 
255
256
  if (path === "/api/stats/folders") {
256
- const stats = await getDashboardStats(range);
257
- return Response.json(stats.byFolder);
257
+ const stats = await getFolderStats(range);
258
+ return Response.json(stats);
258
259
  }
259
260
 
260
261
  if (path === "/api/stats/timeseries") {
@@ -34,6 +34,8 @@ export interface AggregatedStats {
34
34
  cacheSavings: number;
35
35
  /** Total cost */
36
36
  totalCost: number;
37
+ /** Requests with token usage but no public-equivalent subscription price. */
38
+ unpricedRequests: number;
37
39
  /** Total premium requests */
38
40
  totalPremiumRequests: number;
39
41
  /** Average duration in ms */
@@ -122,6 +124,8 @@ export interface CostTimeSeriesPoint {
122
124
  provider: string;
123
125
  /** Total cost for this bucket */
124
126
  cost: number;
127
+ /** Requests excluded because no public-equivalent subscription price exists. */
128
+ unpricedRequests: number;
125
129
  /** Cost breakdown */
126
130
  costInput: number;
127
131
  costOutput: number;
@@ -302,6 +306,8 @@ export interface ToolUsageStats {
302
306
  outputTokensShare: number;
303
307
  /** Cost (USD) of invoking turns, attributed per call share. */
304
308
  costShare: number;
309
+ /** Share of unpriced subscription requests attributed to this tool. */
310
+ unpricedRequestsShare: number;
305
311
  /** Unix ms of the most recent call in range. */
306
312
  lastUsed: number;
307
313
  }
@@ -343,6 +349,8 @@ export interface ProviderAggregate {
343
349
  /** Uncached input + cache reads + cache writes + output. */
344
350
  totalTokens: number;
345
351
  totalCost: number;
352
+ /** Requests excluded because no public-equivalent subscription price exists. */
353
+ unpricedRequests: number;
346
354
  totalPremiumRequests: number;
347
355
  avgTokensPerSecond: number | null;
348
356
  }
@@ -366,6 +374,8 @@ export interface ProviderTimeSeriesPoint {
366
374
  provider: string;
367
375
  totalTokens: number;
368
376
  cost: number;
377
+ /** Requests excluded because no public-equivalent subscription price exists. */
378
+ unpricedRequests: number;
369
379
  requests: number;
370
380
  }
371
381
 
@@ -386,8 +396,9 @@ export interface UsageWindowSeries {
386
396
  accountKey: string;
387
397
  /** Email/account id when known, else the stable account key. */
388
398
  accountLabel: string;
389
- /** Groups the same limit window across accounts (window label or limit id). */
399
+ /** Groups the same limit window across accounts (the provider limit id). */
390
400
  windowKey: string;
401
+ /** Human label of the limit (distinguishes same-duration windows). */
391
402
  windowLabel: string;
392
403
  points: UsageWindowPoint[];
393
404
  }
@@ -399,7 +410,9 @@ export interface UsageWindowSeries {
399
410
  */
400
411
  export interface ProviderWindowInsight {
401
412
  provider: string;
413
+ /** Groups the same limit window across accounts (the provider limit id). */
402
414
  windowKey: string;
415
+ /** Human label of the limit (distinguishes same-duration windows). */
403
416
  windowLabel: string;
404
417
  /** Accounts with at least one snapshot for this window in range. */
405
418
  accounts: number;
@@ -14,6 +14,7 @@
14
14
  */
15
15
  import { Database } from "bun:sqlite";
16
16
  import { AuthBrokerClient, resolveAuthBrokerConfig } from "@linxiraos/pi-ai/auth-broker";
17
+ import type { ClientUsageClientSummary } from "@linxiraos/pi-ai/usage";
17
18
  import { getAgentDbPath, logger } from "@linxiraos/pi-utils";
18
19
  import type { ProviderWindowInsight, UsageWindowPoint, UsageWindowSeries } from "./shared-types";
19
20
 
@@ -40,6 +41,19 @@ export interface UsageWindowStats {
40
41
  windowInsights: ProviderWindowInsight[];
41
42
  }
42
43
 
44
+ /** Usage snapshots plus, in broker mode, fleet-wide token burn per provider. */
45
+ export interface UsageDataSnapshot {
46
+ rows: UsageSnapshotRow[];
47
+ /**
48
+ * Total tokens (input + output + cache read/write) per provider summed
49
+ * across every install reporting to the auth broker, or `null` when no
50
+ * broker is configured or no client reports exist for the range. Matches
51
+ * the fleet-wide window fractions in `rows`, unlike local message stats
52
+ * which only see this install's burn.
53
+ */
54
+ fleetTokensByProvider: Map<string, number> | null;
55
+ }
56
+
43
57
  /** A used-fraction drop smaller than this is jitter, not a window reset. */
44
58
  const RESET_DROP_THRESHOLD = 0.05;
45
59
  /** Minimum window-equivalents consumed before extrapolating tokens/window. */
@@ -101,35 +115,74 @@ export function readUsageSnapshots(sinceMs: number, dbPath = getAgentDbPath()):
101
115
  }
102
116
 
103
117
  /**
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.
118
+ * Fetch usage data from wherever it actually accumulates: the auth broker's
119
+ * durable history plus per-client observed-usage reports when a broker is
120
+ * configured (the broker performs every upstream usage fetch in that mode, so
121
+ * the local `usage_history` stays frozen), else the local agent DB. Broker
122
+ * errors fall back to the local read so the dashboard degrades to
123
+ * stale-but-present data instead of failing.
109
124
  */
110
- export async function fetchUsageSnapshots(sinceMs: number): Promise<UsageSnapshotRow[]> {
125
+ export async function fetchUsageData(sinceMs: number): Promise<UsageDataSnapshot> {
111
126
  try {
112
127
  const brokerConfig = await resolveAuthBrokerConfig();
113
128
  if (brokerConfig) {
114
129
  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
- }));
130
+ const [response, fleetTokensByProvider] = await Promise.all([
131
+ client.fetchUsageHistory({ sinceMs }),
132
+ fetchFleetTokens(client, sinceMs),
133
+ ]);
134
+ return {
135
+ rows: response.entries.map(entry => ({
136
+ recordedAt: entry.recordedAt,
137
+ provider: entry.provider,
138
+ accountKey: entry.accountKey,
139
+ email: entry.email ?? null,
140
+ accountId: entry.accountId ?? null,
141
+ limitId: entry.limitId,
142
+ label: entry.label,
143
+ windowLabel: entry.windowLabel ?? null,
144
+ usedFraction: entry.usedFraction ?? null,
145
+ status: entry.status ?? null,
146
+ })),
147
+ fleetTokensByProvider,
148
+ };
128
149
  }
129
150
  } catch (err) {
130
151
  logger.debug("broker usage history unavailable, falling back to local", { error: String(err) });
131
152
  }
132
- return readUsageSnapshots(sinceMs);
153
+ return { rows: readUsageSnapshots(sinceMs), fleetTokensByProvider: null };
154
+ }
155
+
156
+ /**
157
+ * Sum broker-recorded client token burn per provider since `sinceMs`.
158
+ * Returns `null` on fetch failure or when no client has reported usage, so
159
+ * callers fall back to local message stats instead of zeroing estimates.
160
+ */
161
+ async function fetchFleetTokens(client: AuthBrokerClient, sinceMs: number): Promise<Map<string, number> | null> {
162
+ try {
163
+ const summary = await client.fetchClientUsageSummary({ sinceMs });
164
+ return sumFleetTokens(summary.clients);
165
+ } catch (err) {
166
+ logger.debug("broker client usage summary unavailable", { error: String(err) });
167
+ return null;
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Fold per-client provider aggregates into total tokens per provider
173
+ * (input + output + cache read/write, matching message-stat `totalTokens`).
174
+ * Returns `null` when no client reported anything, signalling "no data"
175
+ * rather than "zero burn".
176
+ */
177
+ export function sumFleetTokens(clients: ClientUsageClientSummary[]): Map<string, number> | null {
178
+ const tokens = new Map<string, number>();
179
+ for (const client of clients) {
180
+ for (const p of client.providers) {
181
+ const total = p.inputTokens + p.outputTokens + p.cacheReadTokens + p.cacheWriteTokens;
182
+ tokens.set(p.provider, (tokens.get(p.provider) ?? 0) + total);
183
+ }
184
+ }
185
+ return tokens.size > 0 ? tokens : null;
133
186
  }
134
187
 
135
188
  /** True when a snapshot reports an exhausted window, by status or by fraction. */
@@ -175,14 +228,33 @@ interface WindowGroup {
175
228
  accounts: Map<string, AccountSeries>;
176
229
  }
177
230
 
231
+ /**
232
+ * Display label for one limit window: the limit label, plus the window label
233
+ * when it adds information ("Usage (Google) · Daily"). The limit label alone
234
+ * distinguishes same-duration windows ("Claude 7 Day" vs "Claude 7 Day
235
+ * (Fable)"); the window-label suffix distinguishes same-named limits with
236
+ * different durations (Antigravity's daily vs weekly "Usage (Google)").
237
+ */
238
+ function windowDisplayLabel(row: UsageSnapshotRow): string {
239
+ const { label, windowLabel } = row;
240
+ if (!windowLabel || label.toLowerCase().includes(windowLabel.toLowerCase())) return label;
241
+ return `${label} · ${windowLabel}`;
242
+ }
243
+
178
244
  /**
179
245
  * Derive utilization series and per-window insights from raw snapshots.
180
246
  *
247
+ * Windows are grouped by `(provider, limitId)` — never by display label.
248
+ * Distinct limits can share a duration label (Anthropic's overall and
249
+ * model-scoped 7-day windows, Codex's base and Spark weeklies); merging them
250
+ * interleaves unrelated fractions per account and wildly inflates consumption.
251
+ *
181
252
  * `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.
253
+ * range (fleet-wide broker client reports when available, else local message
254
+ * stats); it converts consumed window fraction into an estimated token
255
+ * capacity per window. Attribution note: tokens are per provider, not per
256
+ * account, so the estimate treats the account fleet as one pooled
257
+ * subscription — which is exactly how round-robin auth uses it.
186
258
  */
187
259
  export function computeUsageWindowStats(
188
260
  rows: UsageSnapshotRow[],
@@ -190,15 +262,19 @@ export function computeUsageWindowStats(
190
262
  ): UsageWindowStats {
191
263
  const groups = new Map<string, WindowGroup>();
192
264
  for (const row of rows) {
193
- const windowKey = row.windowLabel ?? row.limitId;
194
- const groupKey = `${row.provider}\u0000${windowKey}`;
265
+ const groupKey = `${row.provider}\u0000${row.limitId}`;
195
266
  let group = groups.get(groupKey);
196
267
  if (!group) {
197
- group = { provider: row.provider, windowKey, windowLabel: row.windowLabel ?? row.label, accounts: new Map() };
268
+ group = {
269
+ provider: row.provider,
270
+ windowKey: row.limitId,
271
+ windowLabel: windowDisplayLabel(row),
272
+ accounts: new Map(),
273
+ };
198
274
  groups.set(groupKey, group);
199
275
  }
200
276
  // Labels can change across snapshots (provider renames); latest wins.
201
- group.windowLabel = row.windowLabel ?? row.label;
277
+ group.windowLabel = windowDisplayLabel(row);
202
278
  let account = group.accounts.get(row.accountKey);
203
279
  if (!account) {
204
280
  account = { accountKey: row.accountKey, accountLabel: row.email ?? row.accountId ?? row.accountKey, rows: [] };