@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.
- package/CHANGELOG.md +21 -0
- package/README.md +85 -14
- package/bin/piquota.js +13 -2
- package/extensions/quota-panel.ts +1464 -136
- package/omarchy/cad0p.pi-usage/UsageLogic.js +9 -4
- package/package.json +1 -1
- package/src/cli/args.js +13 -0
- package/src/cli/commands/stats.js +149 -1
- package/src/engine.js +81 -23
- package/src/http.js +34 -1
- package/src/local/history.js +53 -7
- package/src/local/pi-sessions.js +20 -4
- package/src/omarchy/record.js +18 -11
- package/src/providers/opencode-go.js +4 -3
- package/src/render/panel.js +28 -7
|
@@ -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:
|
|
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
|
-
//
|
|
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
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 {
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
});
|
package/src/local/history.js
CHANGED
|
@@ -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} "
|
|
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 === "" ? "
|
|
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 [
|
|
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
|
-
|
|
825
|
-
`
|
|
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
|
-
|
|
852
|
-
|
|
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
|
}
|
package/src/local/pi-sessions.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
|