@cad0p/pi-usage 0.11.4 → 0.11.5

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.
@@ -141,15 +141,19 @@ function compactDuration(ms) {
141
141
 
142
142
  // ----------------------------------------------------------------- accounts
143
143
 
144
- // One row per account of a multi-account record, every window kept.
144
+ // One row per account of a multi-account record, every window kept. The base
145
+ // auth.json entry has no number, so its id stays empty (a blank, aligned column)
146
+ // instead of an invented "#1"; a record cached before the fix is normalized.
145
147
  function accountRows(p) {
146
148
  var list = p && isList(p.accounts) ? p.accounts : []
147
149
  var rows = []
148
150
  for (var i = 0; i < list.length; i++) {
149
151
  var entry = list[i] || {}
150
152
  var windows = limitWindows(entry.limits)
153
+ var id = entry.id == null ? "" : String(entry.id)
154
+ if (id === "#1") id = ""
151
155
  rows.push({
152
- id: String(entry.id || ("#" + (i + 1))),
156
+ id: id,
153
157
  ok: entry.ok === true,
154
158
  status: String(entry.status || ""),
155
159
  windows: windows,
@@ -172,7 +176,8 @@ function failedAccountCount(p) {
172
176
  // family are used one after another (pi rotates to the next slot when one is
173
177
  // exhausted), so what stops the next prompt is the account with the most
174
178
  // room left: the healthy account whose own fullest window is the emptiest.
175
- // That is the "lowest" figure /usage prints for the family.
179
+ // /usage's panel lists every account instead of collapsing the family, so
180
+ // this headline is the bar's own summary of those rows.
176
181
  function headline(p) {
177
182
  if (!p) return null
178
183
  var rows = accountRows(p)
@@ -186,7 +191,7 @@ function headline(p) {
186
191
  best = row
187
192
  }
188
193
  if (best) return {
189
- title: best.id + " · " + best.binding.title,
194
+ title: (best.id === "" ? "" : best.id + " · ") + best.binding.title,
190
195
  percent: best.binding.percent,
191
196
  resetAt: best.binding.resetAt,
192
197
  accountId: best.id
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cad0p/pi-usage",
3
- "version": "0.11.4",
3
+ "version": "0.11.5",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
package/src/cli/args.js CHANGED
@@ -19,6 +19,7 @@
19
19
  * @property {boolean} status
20
20
  * @property {boolean} clearCache
21
21
  * @property {boolean} rebuildTotals
22
+ * @property {string | null} month `stats --month YYYY-MM`, validated.
22
23
  * @property {number} ttlMs
23
24
  * @property {number} timeoutMs
24
25
  * @property {string[]} unknown Flags this parser does not own.
@@ -72,6 +73,7 @@ export function parseArgs(argv, defaults = {}) {
72
73
  box: false,
73
74
  clearCache: false,
74
75
  rebuildTotals: false,
76
+ month: null,
75
77
  ttlMs: defaults.defaultTtlMs ?? 60_000,
76
78
  timeoutMs: defaults.defaultTimeoutMs ?? 15_000,
77
79
  unknown: [],
@@ -94,6 +96,17 @@ export function parseArgs(argv, defaults = {}) {
94
96
  continue;
95
97
  }
96
98
 
99
+ if (arg === "--month") {
100
+ const value = argv[index + 1];
101
+ if (!/^\d{4}-(0[1-9]|1[0-2])$/.test(value ?? "")) {
102
+ parsed.unknown.push(...flagWithValue(arg, value));
103
+ continue;
104
+ }
105
+ parsed.month = value;
106
+ index += 1;
107
+ continue;
108
+ }
109
+
97
110
  if (SUBCOMMAND_VALUE_FLAGS.has(arg)) {
98
111
  // The value is consumed here only so it cannot be mistaken for a
99
112
  // positional; the subcommand reads it from `raw` itself.
@@ -3,16 +3,156 @@
3
3
  *
4
4
  * The history is the one place tokens accumulate across runs; this command is
5
5
  * the read-only view of it and the recovery path for a lost `totals.json`.
6
+ * `--json` never takes the history lock and never writes: it lists the months
7
+ * that have a file, one month's normalized days and the all-time rollup, and
8
+ * reports missing, quarantined or unreadable files as notes.
6
9
  */
7
10
 
8
- import { acquireHistoryLock, loadTotals, rebuildTotalsFromMonths, releaseHistoryLock, resolveHistoryDir } from "../../local/history.js";
11
+ import { readFileSync } from "node:fs";
12
+
13
+ import {
14
+ acquireHistoryLock,
15
+ bucketMessages,
16
+ bucketTokens,
17
+ listHistoryMonths,
18
+ loadTotals,
19
+ parseTotals,
20
+ readMonthFile,
21
+ rebuildTotalsFromMonths,
22
+ releaseHistoryLock,
23
+ resolveHistoryDir,
24
+ resolveTotalsPath,
25
+ totalsToJson,
26
+ } from "../../local/history.js";
9
27
  import { resolveLocalStatsCachePath } from "../../local/pi-sessions.js";
10
28
  import { redact } from "../../http.js";
11
29
  import { err, out } from "../output.js";
12
30
 
31
+ /** The local month key of now, matching how the history files are named. */
32
+ function currentMonth(now = new Date()) {
33
+ return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
34
+ }
35
+
36
+ /**
37
+ * One month's day rows, normalized for consumers: per family and slot, the
38
+ * token and prompt totals plus the per-model split. Only aggregate counts.
39
+ *
40
+ * @param {Record<string, Record<string, any>>} days
41
+ * @returns {Record<string, Record<string, any>>}
42
+ */
43
+ function daysToJson(days) {
44
+ const result = {};
45
+ for (const [date, families] of Object.entries(days ?? {})) {
46
+ const day = {};
47
+ for (const [family, familyRow] of Object.entries(families ?? {})) {
48
+ const slots = {};
49
+ let tokens = 0;
50
+ let prompts = 0;
51
+ for (const [slot, slotRow] of Object.entries(familyRow?.slots ?? {})) {
52
+ const models = {};
53
+ let slotTokens = 0;
54
+ let slotPrompts = 0;
55
+ for (const [model, bucket] of Object.entries(slotRow?.models ?? {})) {
56
+ const modelTokens = bucketTokens(bucket);
57
+ const messages = bucketMessages(bucket);
58
+ models[model] = { tokens: modelTokens, messages };
59
+ slotTokens += modelTokens;
60
+ slotPrompts += messages;
61
+ }
62
+ slots[slot] = { sessions: Number(slotRow?.sessions) || 0, tokens: slotTokens, prompts: slotPrompts, models };
63
+ tokens += slotTokens;
64
+ prompts += slotPrompts;
65
+ }
66
+ day[family] = { sessions: Number(familyRow?.sessions) || 0, tokens, prompts, slots };
67
+ }
68
+ result[date] = day;
69
+ }
70
+ return result;
71
+ }
72
+
73
+ /**
74
+ * Read totals.json without ever moving, rebuilding or writing it: a corrupt
75
+ * file is reported as a note and left exactly where it is, because a read-only
76
+ * command must not take a lock or race the writer.
77
+ *
78
+ * @param {string} historyDir
79
+ * @param {string[]} notes
80
+ * @returns {Record<string, unknown> | null}
81
+ */
82
+ function readTotalsReadOnly(historyDir, notes) {
83
+ const path = resolveTotalsPath(historyDir);
84
+ let raw;
85
+ try {
86
+ raw = readFileSync(path, "utf-8");
87
+ } catch (error) {
88
+ if (/** @type {{ code?: string }} */ (error)?.code !== "ENOENT") {
89
+ notes.push(`could not read ${path} (${redact(/** @type {{ message?: string }} */ (error)?.message)}); it is left untouched and all-time totals are unavailable`);
90
+ } else {
91
+ notes.push(`no all-time totals at ${path} yet`);
92
+ }
93
+ return null;
94
+ }
95
+ try {
96
+ const totals = parseTotals(JSON.parse(raw));
97
+ if (!totals) {
98
+ notes.push(`${path} is unreadable or from another version; it is left untouched and all-time totals are unavailable`);
99
+ return null;
100
+ }
101
+ return totalsToJson(totals);
102
+ } catch {
103
+ notes.push(`${path} is not valid JSON; it is left untouched and all-time totals are unavailable`);
104
+ return null;
105
+ }
106
+ }
107
+
108
+ /**
109
+ * `stats --json`: the read-only history feed. Lists the browsable months, returns
110
+ * the requested (default: the live local month) month's days, the all-time
111
+ * rollup and the notes for anything missing or damaged.
112
+ *
113
+ * The live month is always in `months` and is the default, even when it has no
114
+ * file yet: its open day is not frozen, and on the 1st of a month — or on a
115
+ * fresh install with older files present — a files-only list would make the
116
+ * current month unreachable from the /usage History page.
117
+ *
118
+ * @param {{ historyDir: string, month?: string | null, now?: Date }} input
119
+ * @returns {Record<string, unknown>}
120
+ */
121
+ export function historyJson(input) {
122
+ const { historyDir } = input;
123
+ const notes = [];
124
+ const listed = listHistoryMonths(historyDir);
125
+ const requested = typeof input.month === "string" && input.month !== "" ? input.month : null;
126
+ const live = currentMonth(input.now instanceof Date ? input.now : new Date());
127
+ const months = Array.from(new Set([...listed.months, live])).sort();
128
+ const selected = requested ?? live;
129
+ const quarantinedRequest = requested === null ? null : listed.quarantined.find((entry) => entry.month === requested);
130
+ if (requested !== null && !months.includes(requested) && !quarantinedRequest) {
131
+ notes.push(`no frozen days for ${requested} in ${historyDir}`);
132
+ }
133
+ // One note per damaged month: readMonthFile explains the quarantine or the
134
+ // corruption once, in the reader's terms, instead of the writer's recovery
135
+ // instructions on top of a second note from here.
136
+ const month = readMonthFile(historyDir, selected, notes, { canWrite: false, concise: true });
137
+ return {
138
+ schemaVersion: 1,
139
+ historyDir,
140
+ generatedAt: new Date().toISOString(),
141
+ months,
142
+ quarantined: listed.quarantined.map((entry) => entry.month),
143
+ month: selected,
144
+ days: daysToJson(month.days),
145
+ totals: readTotalsReadOnly(historyDir, notes),
146
+ notes,
147
+ };
148
+ }
149
+
13
150
  /**
14
151
  * @param {{
15
152
  * rebuild?: boolean,
153
+ * json?: boolean,
154
+ * month?: string | null,
155
+ * now?: Date,
16
156
  * env?: Record<string, string | undefined>,
17
157
  * home?: string,
18
158
  * historyDir?: string,
@@ -26,6 +166,14 @@ export function runStatsCommand(options = {}) {
26
166
  /** @type {string[]} */
27
167
  const warnings = [];
28
168
 
169
+ if (options.json) {
170
+ // No lock, no writes: a concurrent scan may be freezing a day while this
171
+ // reads, and every history file is replaced atomically, so a reader only
172
+ // ever sees a whole file.
173
+ out(JSON.stringify(historyJson({ historyDir, month: options.month, now: options.now }), null, 2));
174
+ return 0;
175
+ }
176
+
29
177
  if (options.rebuild) {
30
178
  // The rebuild rewrites the same file a scan freezes into, so it takes the
31
179
  // same lock: a concurrent scan must not interleave its freeze with it.
package/src/engine.js CHANGED
@@ -149,6 +149,47 @@ export function resolveClaudeCredentials(input) {
149
149
  };
150
150
  }
151
151
 
152
+ /** Default number of accounts of one family queried at the same time. */
153
+ export const DEFAULT_ACCOUNT_CONCURRENCY = 8;
154
+
155
+ /**
156
+ * `PI_QUOTA_ACCOUNT_CONCURRENCY` (1-32) bounds the parallel account requests of
157
+ * one family; 1 restores the sequential behaviour.
158
+ *
159
+ * @param {Record<string, string | undefined>} env
160
+ * @returns {number}
161
+ */
162
+ export function accountConcurrency(env) {
163
+ const value = Number.parseInt(env.PI_QUOTA_ACCOUNT_CONCURRENCY ?? "", 10);
164
+ if (!Number.isFinite(value) || value < 1) return DEFAULT_ACCOUNT_CONCURRENCY;
165
+ return Math.min(32, value);
166
+ }
167
+
168
+ /**
169
+ * Map with at most `limit` calls in flight, results in input order.
170
+ *
171
+ * @template T, R
172
+ * @param {T[]} items
173
+ * @param {number} limit
174
+ * @param {(item: T, index: number) => Promise<R>} fn
175
+ * @returns {Promise<R[]>}
176
+ */
177
+ export async function mapBounded(items, limit, fn) {
178
+ /** @type {R[]} */
179
+ const results = new Array(items.length);
180
+ let next = 0;
181
+ const worker = async () => {
182
+ while (next < items.length) {
183
+ const index = next++;
184
+ results[index] = await fn(items[index], index);
185
+ }
186
+ };
187
+ // A non-numeric limit would otherwise start no worker and return holes.
188
+ const bounded = Number.isFinite(limit) ? Math.max(1, Math.floor(limit)) : 1;
189
+ await Promise.all(Array.from({ length: Math.min(bounded, items.length) }, worker));
190
+ return results;
191
+ }
192
+
152
193
  /** @type {Record<string, (credential: any, options?: any) => Promise<import("./model.js").QuotaResult>>} */
153
194
  const PROVIDERS = {
154
195
  claude: fetchClaude,
@@ -314,17 +355,18 @@ export async function collectQuota(options = {}) {
314
355
  return;
315
356
  }
316
357
 
317
- /** @type {import("./model.js").QuotaResult[]} */
318
- const results = [];
319
- const familyWarnList = [];
320
- for (const credential of credentials) {
358
+ // Accounts of one family are independent requests, so they run in
359
+ // parallel (bounded): with 14 OpenCode Go keys a sequential loop cost
360
+ // ~3.8 s, a parallel one costs about one round trip. Results keep the
361
+ // credential order, and the backoff bookkeeping is replayed in that same
362
+ // order afterwards, so the final backoff state is exactly what the
363
+ // sequential loop produced.
364
+ const fetchQuota = PROVIDERS[family];
365
+ const outcomes = await mapBounded(credentials, accountConcurrency(env), async (credential) => {
321
366
  const freshness = checkFreshness(credential, now);
322
- if (!freshness.fresh) {
323
- familyWarnList.push(
324
- `${family}: the ${expiredTokenName(credential)} expired ${Math.abs(freshness.expiresInMin ?? 0)}m ago; ${refreshHint(credential)}`,
325
- );
326
- }
327
- const fetchQuota = PROVIDERS[family];
367
+ const warning = freshness.fresh
368
+ ? null
369
+ : `${family}: the ${expiredTokenName(credential)} expired ${Math.abs(freshness.expiresInMin ?? 0)}m ago; ${refreshHint(credential)}`;
328
370
  try {
329
371
  const result = await fetchQuota(credential, {
330
372
  now,
@@ -341,18 +383,7 @@ export async function collectQuota(options = {}) {
341
383
  // The renderers group siblings by family; the record builder needs the
342
384
  // slot to label each account without re-parsing its display string.
343
385
  result.slot = credential.slot ?? null;
344
- if (isThrottled(result.error)) {
345
- const seconds = recordBackoff(family, {
346
- retryAfterSec: retryAfterFromError(result.error),
347
- now,
348
- env,
349
- home: options.home,
350
- }).seconds;
351
- familyWarnList.push(`${family}: throttled upstream; pausing that family for ${seconds}s`);
352
- } else if (result.ok || isAuthFailure(result.error)) {
353
- clearBackoff(family, { env, home: options.home });
354
- }
355
- results.push(result);
386
+ return { result, warning, crashed: false };
356
387
  } catch (error) {
357
388
  // A provider must never take the whole report down.
358
389
  const crashed = degradedResult({
@@ -365,8 +396,35 @@ export async function collectQuota(options = {}) {
365
396
  expiresInMin: freshness.expiresInMin,
366
397
  });
367
398
  crashed.slot = credential.slot ?? null;
368
- results.push(crashed);
399
+ return { result: crashed, warning, crashed: true };
400
+ }
401
+ });
402
+
403
+ /** @type {import("./model.js").QuotaResult[]} */
404
+ const results = [];
405
+ const familyWarnList = [];
406
+ for (const { result, warning, crashed } of outcomes) {
407
+ if (warning) familyWarnList.push(warning);
408
+ if (!crashed) {
409
+ // Bookkeeping must degrade like the provider call it follows: a
410
+ // failing backoff write is a warning, never a rejected family.
411
+ try {
412
+ if (isThrottled(result.error)) {
413
+ const seconds = recordBackoff(family, {
414
+ retryAfterSec: retryAfterFromError(result.error),
415
+ now,
416
+ env,
417
+ home: options.home,
418
+ }).seconds;
419
+ familyWarnList.push(`${family}: throttled upstream; pausing that family for ${seconds}s`);
420
+ } else if (result.ok || isAuthFailure(result.error)) {
421
+ clearBackoff(family, { env, home: options.home });
422
+ }
423
+ } catch (error) {
424
+ familyWarnList.push(`${family}: could not update the backoff state: ${/** @type {{ message?: string }} */ (error)?.message ?? String(error)}`);
425
+ }
369
426
  }
427
+ results.push(result);
370
428
  }
371
429
  byFamily[family] = results;
372
430
  familyWarnings[family] = familyWarnList;
package/src/http.js CHANGED
@@ -5,8 +5,41 @@
5
5
  * redaction of secrets and error normalization stay in one place.
6
6
  */
7
7
 
8
+ import { readFileSync } from "node:fs";
9
+
8
10
  const DEFAULT_TIMEOUT_MS = 15000;
9
11
 
12
+ /**
13
+ * The package's own User-Agent, sent on every request that does not set one.
14
+ *
15
+ * Without it Node's fetch announces itself as `node`, a generic HTTP-library
16
+ * name. OpenCode Go's terms ask clients for their own User-Agent and have warned
17
+ * and banned accounts over generic ones, and a usage poller that runs every few
18
+ * minutes against every key of a user is exactly the traffic that gets looked
19
+ * at. Providers that need a specific client string (Antigravity's CLI agent,
20
+ * the dashboard's browser agent) keep setting their own.
21
+ */
22
+ export const PACKAGE_USER_AGENT = (() => {
23
+ try {
24
+ const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf-8"));
25
+ return `pi-usage/${pkg.version} (+https://github.com/cad0p/pi-usage)`;
26
+ } catch {
27
+ return "pi-usage (+https://github.com/cad0p/pi-usage)";
28
+ }
29
+ })();
30
+
31
+ /**
32
+ * Copy `headers` and add `User-Agent` unless one is already set (any case).
33
+ *
34
+ * @param {Record<string, string> | undefined} headers
35
+ * @returns {Record<string, string>}
36
+ */
37
+ export function withUserAgent(headers) {
38
+ const out = { ...(headers ?? {}) };
39
+ if (!Object.keys(out).some((name) => name.toLowerCase() === "user-agent")) out["User-Agent"] = PACKAGE_USER_AGENT;
40
+ return out;
41
+ }
42
+
10
43
  /**
11
44
  * Redact credential-looking substrings from arbitrary text before it can reach
12
45
  * a log, a terminal or an error message.
@@ -96,7 +129,7 @@ export async function requestJson(url, options = {}) {
96
129
  try {
97
130
  const response = await fetchFn(url, {
98
131
  method: options.method ?? "GET",
99
- headers: options.headers,
132
+ headers: withUserAgent(options.headers),
100
133
  body: options.body,
101
134
  signal: controller.signal,
102
135
  });
@@ -151,6 +151,39 @@ function quarantinedMonthPath(historyDir, month) {
151
151
  }
152
152
  }
153
153
 
154
+ /**
155
+ * The months with a month file on disk, plus any quarantined copies, read-only:
156
+ * `stats --json` and the overlay use this to know what can be browsed without
157
+ * taking the history lock or touching a file.
158
+ *
159
+ * @param {string} historyDir
160
+ * @returns {{ months: string[], quarantined: Array<{ month: string, path: string }> }}
161
+ */
162
+ export function listHistoryMonths(historyDir) {
163
+ /** @type {string[]} */
164
+ let names;
165
+ try {
166
+ names = readdirSync(historyDir);
167
+ } catch {
168
+ return { months: [], quarantined: [] };
169
+ }
170
+ const months = new Set();
171
+ const quarantined = new Map();
172
+ for (const name of names) {
173
+ const month = MONTH_FILE.exec(name)?.[1];
174
+ if (month) {
175
+ months.add(month);
176
+ continue;
177
+ }
178
+ const backup = /^tokens-(\d{4}-\d{2})\.json\.corrupt-/.exec(name);
179
+ if (backup && !quarantined.has(backup[1])) quarantined.set(backup[1], join(historyDir, name));
180
+ }
181
+ return {
182
+ months: Array.from(months).sort(),
183
+ quarantined: Array.from(quarantined, ([month, path]) => ({ month, path })).sort((a, b) => a.month.localeCompare(b.month)),
184
+ };
185
+ }
186
+
154
187
  /**
155
188
  * Whether the history directory holds any frozen month file at all.
156
189
  *
@@ -167,10 +200,11 @@ export function hasMonthFiles(historyDir) {
167
200
 
168
201
  /**
169
202
  * @param {string} slot
170
- * @returns {string} "#1" for the base entry, "#<slot>" for a numbered sibling.
203
+ * @returns {string} "" for the base entry (it has no number), "#<slot>" for a
204
+ * real sibling slot.
171
205
  */
172
206
  export function accountLabel(slot) {
173
- return slot === "" ? "#1" : `#${slot}`;
207
+ return slot === "" || slot === null || slot === undefined ? "" : `#${slot}`;
174
208
  }
175
209
 
176
210
  /**
@@ -485,8 +519,11 @@ export function parseTotals(raw) {
485
519
  for (const day of entry.activeDates) if (typeof day === "string" && day !== "") familyTotals.activeDates.add(day);
486
520
  }
487
521
  if (entry.accounts && typeof entry.accounts === "object" && !Array.isArray(entry.accounts)) {
488
- for (const [label, accountValue] of Object.entries(entry.accounts)) {
522
+ for (const [rawLabel, accountValue] of Object.entries(entry.accounts)) {
489
523
  if (!accountValue || typeof accountValue !== "object") continue;
524
+ // Files written before the base entry lost its invented "#1" carry that
525
+ // key; normalize on read so one account never shows up twice.
526
+ const label = rawLabel === "#1" ? "" : rawLabel;
490
527
  const account = /** @type {Record<string, unknown>} */ (accountValue);
491
528
  familyTotals.accounts.set(label, {
492
529
  totalPrompts: count(account.totalPrompts),
@@ -805,13 +842,18 @@ export function loadTotals(input) {
805
842
  * daily detail stays missing until the quarantined copy is restored. The
806
843
  * all-time totals it already fed are untouched.
807
844
  *
845
+ * `concise` is for a read-only reader (`stats --json`): it gets one short note
846
+ * per damaged month, not the writer's recovery instructions.
847
+ *
808
848
  * @param {string} historyDir
809
849
  * @param {string} month
810
850
  * @param {string[]} warnings
851
+ * @param {{ canWrite?: boolean, concise?: boolean }} [options]
811
852
  * @returns {{ days: Record<string, DayRows>, writable: boolean, exists: boolean, path: string }}
812
853
  */
813
854
  export function readMonthFile(historyDir, month, warnings, options = {}) {
814
855
  const canWrite = options.canWrite !== false;
856
+ const concise = options.concise === true;
815
857
  const path = resolveMonthPath(historyDir, month);
816
858
  let raw;
817
859
  try {
@@ -821,8 +863,10 @@ export function readMonthFile(historyDir, month, warnings, options = {}) {
821
863
  const backup = quarantinedMonthPath(historyDir, month);
822
864
  if (backup) {
823
865
  warnings.push(
824
- `local token stats: ${path} was quarantined as ${backup}; its frozen days are missing from recentDays (shown as 0), ` +
825
- `while totals.json keeps the all-time counts. Copy ${backup} back to ${path} if recoverable, or delete that backup to abandon those counts; days after it stay open until then`,
866
+ concise
867
+ ? `local token stats: ${path} is quarantined as ${backup}; its frozen days are unavailable while totals.json keeps the all-time counts`
868
+ : `local token stats: ${path} was quarantined as ${backup}; its frozen days are missing from recentDays (shown as 0), ` +
869
+ `while totals.json keeps the all-time counts. Copy ${backup} back to ${path} if recoverable, or delete that backup to abandon those counts; days after it stay open until then`,
826
870
  );
827
871
  }
828
872
  return { days: {}, writable: true, exists: false, unreadable: false, quarantined: backup !== null, path };
@@ -848,8 +892,10 @@ export function readMonthFile(historyDir, month, warnings, options = {}) {
848
892
  if (!shapeOk) {
849
893
  if (!canWrite) {
850
894
  warnings.push(
851
- `local token stats: ${path} is corrupt or from another version; leaving it in place because this run does not hold the history lock. ` +
852
- "Its days are missing from recentDays (shown as 0), while totals.json keeps the all-time counts; a lock owner can quarantine it for restoration",
895
+ concise
896
+ ? `local token stats: ${path} is corrupt or from another version; it is left untouched and its frozen days are unavailable`
897
+ : `local token stats: ${path} is corrupt or from another version; leaving it in place because this run does not hold the history lock. ` +
898
+ "Its days are missing from recentDays (shown as 0), while totals.json keeps the all-time counts; a lock owner can quarantine it for restoration",
853
899
  );
854
900
  return { days: {}, writable: false, exists: true, unreadable: true, quarantined: false, path };
855
901
  }
@@ -58,6 +58,7 @@ import { dirname, join } from "node:path";
58
58
  import { FAMILY_BY_PROVIDER, resolveProviderSlot } from "../auth/pi-auth.js";
59
59
  import { redact } from "../http.js";
60
60
  import {
61
+ accountLabel,
61
62
  acquireHistoryLock,
62
63
  addToModels,
63
64
  applyDurable,
@@ -114,7 +115,7 @@ const RECENT_DAYS = 7;
114
115
  * @property {number} activeDays
115
116
  * @property {string[]} activeDates
116
117
  * @property {Record<string, { inputTokens: number, outputTokens: number, cacheReadInputTokens: number, cacheCreationInputTokens: number }>} modelUsage
117
- * @property {Record<string, { todayTokens: number, weekTokens: number, todayPrompts: number, totalPrompts: number, totalTokens: number }>} byAccount
118
+ * @property {Record<string, { todayTokens: number, weekTokens: number, todayPrompts: number, totalPrompts: number, totalTokens: number, todayTokensByModel: Record<string, number> }>} byAccount
118
119
  */
119
120
 
120
121
  /**
@@ -910,7 +911,9 @@ function tokensByModel(models) {
910
911
  * @returns {number}
911
912
  */
912
913
  function labelOrder(label) {
913
- if (label === "#1") return -1;
914
+ // "" is the base entry; "#1" is the old invented label for it, kept so a
915
+ // totals.json written before the fix still sorts first.
916
+ if (label === "" || label === "#1") return -1;
914
917
  const numeric = Number.parseInt(String(label).replace(/^#/, ""), 10);
915
918
  return Number.isFinite(numeric) ? numeric : Number.MAX_SAFE_INTEGER;
916
919
  }
@@ -959,7 +962,7 @@ export function buildLocalStats(input = {}) {
959
962
  openDates.add(day);
960
963
  openSessions += count(familyRow.sessions);
961
964
  for (const [slot, slotRow] of Object.entries(familyRow.slots ?? {})) {
962
- const label = slot === "" ? "#1" : `#${slot}`;
965
+ const label = accountLabel(slot);
963
966
  const account = openAccounts.get(label) ?? { prompts: 0, sessions: 0, models: new Map() };
964
967
  account.sessions += count(slotRow.sessions);
965
968
  for (const [model, bucket] of Object.entries(slotRow.models ?? {})) {
@@ -994,7 +997,7 @@ export function buildLocalStats(input = {}) {
994
997
  /** @type {Map<string, { tokens: number, prompts: number }>} */
995
998
  const slots = new Map();
996
999
  for (const [slot, slotRow] of Object.entries(familyRow.slots ?? {})) {
997
- const label = slot === "" ? "#1" : `#${slot}`;
1000
+ const label = accountLabel(slot);
998
1001
  let tokens = 0;
999
1002
  let prompts = 0;
1000
1003
  for (const [model, bucket] of Object.entries(slotRow.models ?? {})) {
@@ -1022,6 +1025,18 @@ export function buildLocalStats(input = {}) {
1022
1025
  todayModels.set(model, (todayModels.get(model) ?? 0) + bucketTokens(bucket));
1023
1026
  }
1024
1027
  }
1028
+ // The same split per account, so the History page can name the model behind
1029
+ // an account's live (unfrozen) today row instead of leaving it blank.
1030
+ /** @type {Map<string, Map<string, number>>} */
1031
+ const todayAccountModels = new Map();
1032
+ for (const [slot, slotRow] of Object.entries(todayRows?.slots ?? {})) {
1033
+ const label = accountLabel(slot);
1034
+ const models = todayAccountModels.get(label) ?? new Map();
1035
+ for (const [model, bucket] of Object.entries(slotRow.models ?? {})) {
1036
+ models.set(model, (models.get(model) ?? 0) + bucketTokens(bucket));
1037
+ }
1038
+ todayAccountModels.set(label, models);
1039
+ }
1025
1040
 
1026
1041
  /** @type {Map<string, number>} */
1027
1042
  const weekModels = new Map();
@@ -1057,6 +1072,7 @@ export function buildLocalStats(input = {}) {
1057
1072
  todayPrompts: daySlots.get(today)?.get(label)?.prompts ?? 0,
1058
1073
  totalPrompts: (historyAccount?.totalPrompts ?? 0) + (openAccount?.prompts ?? 0),
1059
1074
  totalTokens,
1075
+ todayTokensByModel: Object.fromEntries(todayAccountModels.get(label) ?? []),
1060
1076
  };
1061
1077
  }
1062
1078