tledger 0.2.0 → 0.3.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.
- package/README.md +152 -116
- package/bin/token-ledger-cache-image.mjs +1150 -0
- package/bin/token-ledger-controls.mjs +24 -0
- package/bin/token-ledger-rates.mjs +4 -1
- package/bin/token-ledger-terminal.mjs +143 -39
- package/bin/token-ledger-trend-image.mjs +1527 -584
- package/bin/token-ledger-trend-terminal.mjs +73 -28
- package/bin/token-ledger-trend.mjs +25 -11
- package/bin/token-ledger-tui.mjs +20 -14
- package/bin/token-ledger.mjs +536 -137
- package/docs/token-ledger-cli-week.png +0 -0
- package/docs/token-ledger-report-7-day.png +0 -0
- package/lib/token-ledger-importer.mjs +589 -279
- package/lib/token-ledger-snapshot.mjs +267 -0
- package/lib/token-ledger-usage.mjs +524 -0
- package/package.json +11 -5
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
const MINUTE_MS = 60 * 1_000;
|
|
2
|
+
const HOUR_MS = 60 * MINUTE_MS;
|
|
3
|
+
const DAY_MS = 24 * HOUR_MS;
|
|
4
|
+
|
|
5
|
+
export const SNAPSHOT_SCHEMA_VERSION = 2;
|
|
6
|
+
export const ADAPTIVE_USAGE_RESOLUTIONS_SECONDS = Object.freeze([
|
|
7
|
+
5 * 60,
|
|
8
|
+
15 * 60,
|
|
9
|
+
60 * 60,
|
|
10
|
+
6 * 60 * 60,
|
|
11
|
+
24 * 60 * 60,
|
|
12
|
+
7 * 24 * 60 * 60,
|
|
13
|
+
30 * 24 * 60 * 60,
|
|
14
|
+
]);
|
|
15
|
+
export const DEFAULT_USAGE_RESOLUTION_POLICY = Object.freeze([
|
|
16
|
+
Object.freeze({ maximumAgeMs: 2 * DAY_MS, resolutionMs: 0 }),
|
|
17
|
+
Object.freeze({ maximumAgeMs: 30 * DAY_MS, resolutionMs: MINUTE_MS }),
|
|
18
|
+
Object.freeze({ maximumAgeMs: 365 * DAY_MS, resolutionMs: HOUR_MS }),
|
|
19
|
+
Object.freeze({ maximumAgeMs: Infinity, resolutionMs: DAY_MS }),
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
const COMPACT_DURING_BUILD_BUCKET_COUNT = 50_000;
|
|
23
|
+
const MAX_BUILD_BUCKET_COUNT = 100_000;
|
|
24
|
+
|
|
25
|
+
const NUMERIC_USAGE_FIELDS = Object.freeze([
|
|
26
|
+
"inputTokens",
|
|
27
|
+
"cachedInputTokens",
|
|
28
|
+
"cacheWriteInputTokens",
|
|
29
|
+
"outputTokens",
|
|
30
|
+
"reasoningTokens",
|
|
31
|
+
"totalTokens",
|
|
32
|
+
"toolCalls",
|
|
33
|
+
]);
|
|
34
|
+
const FRACTIONAL_COUNT_FIELDS = Object.freeze([
|
|
35
|
+
"callCount",
|
|
36
|
+
"detailedCallCount",
|
|
37
|
+
"inputCallCount",
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
function finiteTimestamp(value) {
|
|
41
|
+
try {
|
|
42
|
+
const timestampMs = Date.parse(String(value ?? ""));
|
|
43
|
+
return Number.isFinite(timestampMs) ? timestampMs : null;
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function nonNegativeNumber(value) {
|
|
50
|
+
try {
|
|
51
|
+
const number = Number(value);
|
|
52
|
+
return Number.isFinite(number) && number >= 0 ? number : 0;
|
|
53
|
+
} catch {
|
|
54
|
+
return 0;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function positiveSafeInteger(value) {
|
|
59
|
+
try {
|
|
60
|
+
const number = Number(value);
|
|
61
|
+
return Number.isSafeInteger(number) && number > 0 ? number : 0;
|
|
62
|
+
} catch {
|
|
63
|
+
return 0;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function positiveNumber(value) {
|
|
68
|
+
const number = nonNegativeNumber(value);
|
|
69
|
+
return number > 0 ? number : 0;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function primitiveString(value) {
|
|
73
|
+
try {
|
|
74
|
+
const text = String.prototype.valueOf.call(value);
|
|
75
|
+
return text === value ? text : null;
|
|
76
|
+
} catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function rowThreadIds(row) {
|
|
82
|
+
if (Array.isArray(row?.threadIds)) {
|
|
83
|
+
return row.threadIds
|
|
84
|
+
.map(primitiveString)
|
|
85
|
+
.filter(Boolean);
|
|
86
|
+
}
|
|
87
|
+
const threadId = primitiveString(row?.threadId);
|
|
88
|
+
return threadId ? [threadId] : [];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function resolutionForAge(ageMs, policy) {
|
|
92
|
+
for (const tier of policy) {
|
|
93
|
+
if (ageMs <= tier.maximumAgeMs) return tier.resolutionMs;
|
|
94
|
+
}
|
|
95
|
+
return policy.at(-1)?.resolutionMs ?? DAY_MS;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function groupingKey(row, bucketIndex, uniqueKey = "") {
|
|
99
|
+
return JSON.stringify([
|
|
100
|
+
bucketIndex,
|
|
101
|
+
uniqueKey,
|
|
102
|
+
String(row.project ?? "Unlabelled activity"),
|
|
103
|
+
String(row.model ?? "unknown"),
|
|
104
|
+
String(row.effort ?? "unknown"),
|
|
105
|
+
String(row.source ?? "unknown"),
|
|
106
|
+
String(row.useType ?? "unknown"),
|
|
107
|
+
row.serviceTier == null ? null : String(row.serviceTier),
|
|
108
|
+
row.breakdownAvailable === true,
|
|
109
|
+
row.rateCardCredits == null,
|
|
110
|
+
]);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function newAggregate(row, timestampMs, resolutionMs) {
|
|
114
|
+
const rateCardCredits = row.rateCardCredits == null
|
|
115
|
+
? null
|
|
116
|
+
: 0;
|
|
117
|
+
const callCount = positiveSafeInteger(row.callCount) || 1;
|
|
118
|
+
const aggregate = {
|
|
119
|
+
timestampMeanMs: timestampMs,
|
|
120
|
+
timestampWeight: callCount,
|
|
121
|
+
startMs: finiteTimestamp(row.startAt) ?? timestampMs,
|
|
122
|
+
endMs: finiteTimestamp(row.endAt) ?? timestampMs,
|
|
123
|
+
project: String(row.project ?? "Unlabelled activity"),
|
|
124
|
+
model: String(row.model ?? "unknown"),
|
|
125
|
+
effort: String(row.effort ?? "unknown"),
|
|
126
|
+
source: String(row.source ?? "unknown"),
|
|
127
|
+
useType: String(row.useType ?? "unknown"),
|
|
128
|
+
serviceTier: row.serviceTier == null ? null : String(row.serviceTier),
|
|
129
|
+
breakdownAvailable: row.breakdownAvailable === true,
|
|
130
|
+
rateCardCredits,
|
|
131
|
+
inputTokens: 0,
|
|
132
|
+
cachedInputTokens: 0,
|
|
133
|
+
cacheWriteInputTokens: 0,
|
|
134
|
+
outputTokens: 0,
|
|
135
|
+
reasoningTokens: 0,
|
|
136
|
+
totalTokens: 0,
|
|
137
|
+
toolCalls: 0,
|
|
138
|
+
callCount: 0,
|
|
139
|
+
detailedCallCount: 0,
|
|
140
|
+
inputCallCount: 0,
|
|
141
|
+
resolutionSeconds: Math.max(
|
|
142
|
+
nonNegativeNumber(row.resolutionSeconds),
|
|
143
|
+
resolutionMs / 1_000,
|
|
144
|
+
),
|
|
145
|
+
threadIds: new Set(),
|
|
146
|
+
};
|
|
147
|
+
addToAggregate(aggregate, row, timestampMs);
|
|
148
|
+
return aggregate;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function addToAggregate(aggregate, row, timestampMs) {
|
|
152
|
+
const callCount = positiveSafeInteger(row.callCount) || 1;
|
|
153
|
+
if (aggregate.callCount > 0) {
|
|
154
|
+
const combinedWeight = aggregate.timestampWeight + callCount;
|
|
155
|
+
aggregate.timestampMeanMs +=
|
|
156
|
+
((timestampMs - aggregate.timestampMeanMs) * callCount) / combinedWeight;
|
|
157
|
+
aggregate.timestampWeight = combinedWeight;
|
|
158
|
+
}
|
|
159
|
+
aggregate.startMs = Math.min(
|
|
160
|
+
aggregate.startMs,
|
|
161
|
+
finiteTimestamp(row.startAt) ?? timestampMs,
|
|
162
|
+
);
|
|
163
|
+
aggregate.endMs = Math.max(
|
|
164
|
+
aggregate.endMs,
|
|
165
|
+
finiteTimestamp(row.endAt) ?? timestampMs,
|
|
166
|
+
);
|
|
167
|
+
for (const field of NUMERIC_USAGE_FIELDS) {
|
|
168
|
+
aggregate[field] += nonNegativeNumber(row[field]);
|
|
169
|
+
}
|
|
170
|
+
aggregate.callCount += callCount;
|
|
171
|
+
const detailedCallCount = positiveSafeInteger(row.detailedCallCount);
|
|
172
|
+
aggregate.detailedCallCount += detailedCallCount || (
|
|
173
|
+
row.breakdownAvailable === true ? callCount : 0
|
|
174
|
+
);
|
|
175
|
+
const inputCallCount = positiveSafeInteger(row.inputCallCount);
|
|
176
|
+
aggregate.inputCallCount += inputCallCount || (
|
|
177
|
+
nonNegativeNumber(row.inputTokens) > 0 ? callCount : 0
|
|
178
|
+
);
|
|
179
|
+
if (aggregate.rateCardCredits !== null) {
|
|
180
|
+
aggregate.rateCardCredits += nonNegativeNumber(row.rateCardCredits);
|
|
181
|
+
}
|
|
182
|
+
aggregate.resolutionSeconds = Math.max(
|
|
183
|
+
aggregate.resolutionSeconds,
|
|
184
|
+
nonNegativeNumber(row.resolutionSeconds),
|
|
185
|
+
);
|
|
186
|
+
for (const threadId of rowThreadIds(row)) aggregate.threadIds.add(threadId);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function finishAggregate(aggregate) {
|
|
190
|
+
return {
|
|
191
|
+
timestamp: new Date(Math.round(aggregate.timestampMeanMs)).toISOString(),
|
|
192
|
+
startAt: new Date(aggregate.startMs).toISOString(),
|
|
193
|
+
endAt: new Date(aggregate.endMs).toISOString(),
|
|
194
|
+
project: aggregate.project,
|
|
195
|
+
model: aggregate.model,
|
|
196
|
+
effort: aggregate.effort,
|
|
197
|
+
source: aggregate.source,
|
|
198
|
+
useType: aggregate.useType,
|
|
199
|
+
serviceTier: aggregate.serviceTier,
|
|
200
|
+
inputTokens: aggregate.inputTokens,
|
|
201
|
+
cachedInputTokens: aggregate.cachedInputTokens,
|
|
202
|
+
cacheWriteInputTokens: aggregate.cacheWriteInputTokens,
|
|
203
|
+
outputTokens: aggregate.outputTokens,
|
|
204
|
+
reasoningTokens: aggregate.reasoningTokens,
|
|
205
|
+
totalTokens: aggregate.totalTokens,
|
|
206
|
+
toolCalls: aggregate.toolCalls,
|
|
207
|
+
rateCardCredits: aggregate.rateCardCredits,
|
|
208
|
+
breakdownAvailable: aggregate.breakdownAvailable,
|
|
209
|
+
callCount: aggregate.callCount,
|
|
210
|
+
detailedCallCount: aggregate.detailedCallCount,
|
|
211
|
+
inputCallCount: aggregate.inputCallCount,
|
|
212
|
+
threadIds: [...aggregate.threadIds].sort(),
|
|
213
|
+
resolutionSeconds: aggregate.resolutionSeconds,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function addRowToGroups(
|
|
218
|
+
groups,
|
|
219
|
+
row,
|
|
220
|
+
timestampMs,
|
|
221
|
+
resolutionMs,
|
|
222
|
+
uniqueKey = "",
|
|
223
|
+
) {
|
|
224
|
+
const bucketIndex = resolutionMs > 0
|
|
225
|
+
? Math.floor(timestampMs / resolutionMs)
|
|
226
|
+
: timestampMs;
|
|
227
|
+
const key = groupingKey(row, bucketIndex, uniqueKey);
|
|
228
|
+
const existing = groups.get(key);
|
|
229
|
+
if (existing) addToAggregate(existing, row, timestampMs);
|
|
230
|
+
else groups.set(key, newAggregate(row, timestampMs, resolutionMs));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function aggregateRows(rows, resolutionForRow) {
|
|
234
|
+
const groups = new Map();
|
|
235
|
+
let index = 0;
|
|
236
|
+
for (const row of rows) {
|
|
237
|
+
const timestampMs = finiteTimestamp(row?.timestamp);
|
|
238
|
+
if (timestampMs === null) {
|
|
239
|
+
index += 1;
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
const resolutionMs = Math.max(0, Number(resolutionForRow(row, timestampMs)) || 0);
|
|
243
|
+
addRowToGroups(
|
|
244
|
+
groups,
|
|
245
|
+
row,
|
|
246
|
+
timestampMs,
|
|
247
|
+
resolutionMs,
|
|
248
|
+
resolutionMs === 0 ? String(index) : "",
|
|
249
|
+
);
|
|
250
|
+
index += 1;
|
|
251
|
+
}
|
|
252
|
+
return [...groups.values()]
|
|
253
|
+
.map(finishAggregate)
|
|
254
|
+
.sort((left, right) => Date.parse(left.timestamp) - Date.parse(right.timestamp));
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function regroupAggregates(groups, minimumResolutionMs) {
|
|
258
|
+
const regrouped = new Map();
|
|
259
|
+
for (const aggregate of groups.values()) {
|
|
260
|
+
const row = finishAggregate(aggregate);
|
|
261
|
+
const timestampMs = Math.round(aggregate.timestampMeanMs);
|
|
262
|
+
const resolutionMs = Math.max(
|
|
263
|
+
minimumResolutionMs,
|
|
264
|
+
nonNegativeNumber(row.resolutionSeconds) * 1_000,
|
|
265
|
+
);
|
|
266
|
+
addRowToGroups(regrouped, row, timestampMs, resolutionMs);
|
|
267
|
+
}
|
|
268
|
+
groups.clear();
|
|
269
|
+
return regrouped;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function usageBucketLimitError() {
|
|
273
|
+
const error = new Error(
|
|
274
|
+
`Usage history still requires more than ${MAX_BUILD_BUCKET_COUNT.toLocaleString()} buckets at the maximum storage resolution. Use --since or --no-archived to reduce the source history.`,
|
|
275
|
+
);
|
|
276
|
+
error.code = "ERR_SNAPSHOT_SIZE_LIMIT";
|
|
277
|
+
return error;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export function buildUsageBuckets(
|
|
281
|
+
events,
|
|
282
|
+
{ latestTimestampMs, policy = DEFAULT_USAGE_RESOLUTION_POLICY } = {},
|
|
283
|
+
) {
|
|
284
|
+
const rows = events?.[Symbol.iterator] ? events : [];
|
|
285
|
+
const latest = Number.isFinite(latestTimestampMs)
|
|
286
|
+
? latestTimestampMs
|
|
287
|
+
: Array.isArray(rows) ? rows.reduce(
|
|
288
|
+
(maximum, row) => Math.max(maximum, finiteTimestamp(row?.timestamp) ?? 0),
|
|
289
|
+
0,
|
|
290
|
+
) : 0;
|
|
291
|
+
let groups = new Map();
|
|
292
|
+
let minimumResolutionMs = 0;
|
|
293
|
+
let nextResolutionIndex = 0;
|
|
294
|
+
let index = 0;
|
|
295
|
+
for (const row of rows) {
|
|
296
|
+
const timestampMs = finiteTimestamp(row?.timestamp);
|
|
297
|
+
if (timestampMs === null) {
|
|
298
|
+
index += 1;
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
const resolutionMs = Math.max(
|
|
302
|
+
minimumResolutionMs,
|
|
303
|
+
resolutionForAge(Math.max(0, latest - timestampMs), policy),
|
|
304
|
+
nonNegativeNumber(row?.resolutionSeconds) * 1_000,
|
|
305
|
+
);
|
|
306
|
+
addRowToGroups(
|
|
307
|
+
groups,
|
|
308
|
+
row,
|
|
309
|
+
timestampMs,
|
|
310
|
+
resolutionMs,
|
|
311
|
+
resolutionMs === 0 ? String(index) : "",
|
|
312
|
+
);
|
|
313
|
+
index += 1;
|
|
314
|
+
|
|
315
|
+
while (
|
|
316
|
+
groups.size > COMPACT_DURING_BUILD_BUCKET_COUNT &&
|
|
317
|
+
nextResolutionIndex < ADAPTIVE_USAGE_RESOLUTIONS_SECONDS.length
|
|
318
|
+
) {
|
|
319
|
+
minimumResolutionMs =
|
|
320
|
+
ADAPTIVE_USAGE_RESOLUTIONS_SECONDS[nextResolutionIndex] * 1_000;
|
|
321
|
+
nextResolutionIndex += 1;
|
|
322
|
+
groups = regroupAggregates(groups, minimumResolutionMs);
|
|
323
|
+
}
|
|
324
|
+
if (
|
|
325
|
+
groups.size > MAX_BUILD_BUCKET_COUNT &&
|
|
326
|
+
nextResolutionIndex === ADAPTIVE_USAGE_RESOLUTIONS_SECONDS.length
|
|
327
|
+
) {
|
|
328
|
+
throw usageBucketLimitError();
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return [...groups.values()]
|
|
332
|
+
.map(finishAggregate)
|
|
333
|
+
.sort((left, right) => Date.parse(left.timestamp) - Date.parse(right.timestamp));
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export function coarsenUsageBuckets(buckets, resolutionSeconds) {
|
|
337
|
+
const resolution = positiveSafeInteger(resolutionSeconds);
|
|
338
|
+
if (!resolution) {
|
|
339
|
+
throw new Error("Usage-bucket resolution must be a positive integer.");
|
|
340
|
+
}
|
|
341
|
+
const resolutionMs = resolution * 1_000;
|
|
342
|
+
return aggregateRows(
|
|
343
|
+
Array.isArray(buckets) ? buckets : [],
|
|
344
|
+
(row) => Math.max(
|
|
345
|
+
resolutionMs,
|
|
346
|
+
nonNegativeNumber(row?.resolutionSeconds) * 1_000,
|
|
347
|
+
),
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export function usageBuckets(snapshot = {}) {
|
|
352
|
+
return Array.isArray(snapshot.events) ? snapshot.events : [];
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function usageBucketInterval(bucket) {
|
|
356
|
+
const timestampMs = finiteTimestamp(bucket?.timestamp);
|
|
357
|
+
if (timestampMs === null) return null;
|
|
358
|
+
const startAtMs = finiteTimestamp(bucket?.startAt) ?? timestampMs;
|
|
359
|
+
const endAtMs = finiteTimestamp(bucket?.endAt) ?? timestampMs;
|
|
360
|
+
const startMs = Math.min(timestampMs, startAtMs, endAtMs);
|
|
361
|
+
const inclusiveEndMs = Math.max(timestampMs, startAtMs, endAtMs);
|
|
362
|
+
return {
|
|
363
|
+
startMs,
|
|
364
|
+
endMs: Math.max(startMs + 1, inclusiveEndMs + 1),
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function firstBoundaryAfter(boundaries, value) {
|
|
369
|
+
let low = 0;
|
|
370
|
+
let high = boundaries.length;
|
|
371
|
+
while (low < high) {
|
|
372
|
+
const middle = Math.floor((low + high) / 2);
|
|
373
|
+
if (boundaries[middle] <= value) low = middle + 1;
|
|
374
|
+
else high = middle;
|
|
375
|
+
}
|
|
376
|
+
return low;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function sliceUsageBucket(bucket, startMs, endMs, fraction) {
|
|
380
|
+
const fragment = {
|
|
381
|
+
...bucket,
|
|
382
|
+
timestamp: new Date(Math.round(startMs + (endMs - startMs - 1) / 2))
|
|
383
|
+
.toISOString(),
|
|
384
|
+
startAt: new Date(startMs).toISOString(),
|
|
385
|
+
endAt: new Date(endMs - 1).toISOString(),
|
|
386
|
+
rangeAllocationEstimated: true,
|
|
387
|
+
rangeAllocationFraction:
|
|
388
|
+
(positiveNumber(bucket?.rangeAllocationFraction) || 1) * fraction,
|
|
389
|
+
};
|
|
390
|
+
for (const field of NUMERIC_USAGE_FIELDS) {
|
|
391
|
+
fragment[field] = nonNegativeNumber(bucket?.[field]) * fraction;
|
|
392
|
+
}
|
|
393
|
+
if (bucket?.rateCardCredits != null) {
|
|
394
|
+
fragment.rateCardCredits = nonNegativeNumber(bucket.rateCardCredits) * fraction;
|
|
395
|
+
}
|
|
396
|
+
for (const field of FRACTIONAL_COUNT_FIELDS) {
|
|
397
|
+
const value = field === "callCount"
|
|
398
|
+
? usageCallCount(bucket)
|
|
399
|
+
: field === "detailedCallCount"
|
|
400
|
+
? usageDetailedCallCount(bucket)
|
|
401
|
+
: usageInputCallCount(bucket);
|
|
402
|
+
fragment[field] = value * fraction;
|
|
403
|
+
}
|
|
404
|
+
return fragment;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Compacted buckets retain their first and last observed timestamps, but not
|
|
408
|
+
// every timestamp inside the bucket. Split any bucket that crosses a report
|
|
409
|
+
// boundary and allocate its additive values by overlap duration. This keeps
|
|
410
|
+
// adjacent ranges additive instead of assigning the entire bucket by its mean
|
|
411
|
+
// timestamp. The fragment marker lets renderers disclose the approximation.
|
|
412
|
+
export function splitUsageBucketsAtBoundaries(buckets, boundaryValues) {
|
|
413
|
+
const boundaries = [...new Set(
|
|
414
|
+
(Array.isArray(boundaryValues) ? boundaryValues : [])
|
|
415
|
+
.map(Number)
|
|
416
|
+
.filter(Number.isFinite),
|
|
417
|
+
)].sort((left, right) => left - right);
|
|
418
|
+
if (boundaries.length === 0) return Array.isArray(buckets) ? [...buckets] : [];
|
|
419
|
+
|
|
420
|
+
const fragments = [];
|
|
421
|
+
for (const bucket of Array.isArray(buckets) ? buckets : []) {
|
|
422
|
+
const interval = usageBucketInterval(bucket);
|
|
423
|
+
if (interval === null) {
|
|
424
|
+
fragments.push(bucket);
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
let boundaryIndex = firstBoundaryAfter(boundaries, interval.startMs);
|
|
428
|
+
if (
|
|
429
|
+
boundaryIndex >= boundaries.length ||
|
|
430
|
+
boundaries[boundaryIndex] >= interval.endMs
|
|
431
|
+
) {
|
|
432
|
+
fragments.push(bucket);
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const durationMs = interval.endMs - interval.startMs;
|
|
437
|
+
let fragmentStartMs = interval.startMs;
|
|
438
|
+
while (
|
|
439
|
+
boundaryIndex < boundaries.length &&
|
|
440
|
+
boundaries[boundaryIndex] < interval.endMs
|
|
441
|
+
) {
|
|
442
|
+
const fragmentEndMs = boundaries[boundaryIndex];
|
|
443
|
+
fragments.push(sliceUsageBucket(
|
|
444
|
+
bucket,
|
|
445
|
+
fragmentStartMs,
|
|
446
|
+
fragmentEndMs,
|
|
447
|
+
(fragmentEndMs - fragmentStartMs) / durationMs,
|
|
448
|
+
));
|
|
449
|
+
fragmentStartMs = fragmentEndMs;
|
|
450
|
+
boundaryIndex += 1;
|
|
451
|
+
}
|
|
452
|
+
fragments.push(sliceUsageBucket(
|
|
453
|
+
bucket,
|
|
454
|
+
fragmentStartMs,
|
|
455
|
+
interval.endMs,
|
|
456
|
+
(interval.endMs - fragmentStartMs) / durationMs,
|
|
457
|
+
));
|
|
458
|
+
}
|
|
459
|
+
return fragments;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
export function usageBucketsInRange(snapshot, startValue, endValue) {
|
|
463
|
+
const startMs = Number(startValue);
|
|
464
|
+
const endMs = Number(endValue);
|
|
465
|
+
if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs <= startMs) {
|
|
466
|
+
return [];
|
|
467
|
+
}
|
|
468
|
+
return splitUsageBucketsAtBoundaries(
|
|
469
|
+
usageBuckets(snapshot),
|
|
470
|
+
[startMs, endMs],
|
|
471
|
+
).filter((bucket) => {
|
|
472
|
+
const timestampMs = finiteTimestamp(bucket?.timestamp);
|
|
473
|
+
return timestampMs !== null && timestampMs >= startMs && timestampMs < endMs;
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
export function usageCallCount(bucket) {
|
|
478
|
+
if (bucket == null || Object(bucket) !== bucket) return 0;
|
|
479
|
+
const stored = bucket.rangeAllocationEstimated === true
|
|
480
|
+
? positiveNumber(bucket.callCount)
|
|
481
|
+
: positiveSafeInteger(bucket.callCount);
|
|
482
|
+
return stored || 1;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
export function usageDetailedCallCount(bucket) {
|
|
486
|
+
const callCount = usageCallCount(bucket);
|
|
487
|
+
const stored = bucket?.rangeAllocationEstimated === true
|
|
488
|
+
? positiveNumber(bucket.detailedCallCount)
|
|
489
|
+
: positiveSafeInteger(bucket?.detailedCallCount);
|
|
490
|
+
return Math.min(
|
|
491
|
+
callCount,
|
|
492
|
+
stored || (bucket?.breakdownAvailable === false ? 0 : callCount),
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
export function usageInputCallCount(bucket) {
|
|
497
|
+
const callCount = usageCallCount(bucket);
|
|
498
|
+
const stored = bucket?.rangeAllocationEstimated === true
|
|
499
|
+
? positiveNumber(bucket.inputCallCount)
|
|
500
|
+
: positiveSafeInteger(bucket?.inputCallCount);
|
|
501
|
+
return Math.min(
|
|
502
|
+
callCount,
|
|
503
|
+
stored || (nonNegativeNumber(bucket?.inputTokens) > 0 ? callCount : 0),
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
export function usageThreadIds(bucket) {
|
|
508
|
+
return rowThreadIds(bucket);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
export function usageBucketStats(buckets) {
|
|
512
|
+
const rows = Array.isArray(buckets) ? buckets : [];
|
|
513
|
+
return {
|
|
514
|
+
bucketCount: rows.length,
|
|
515
|
+
callCount: rows.reduce((sum, row) => sum + usageCallCount(row), 0),
|
|
516
|
+
maximumResolutionSeconds: rows.reduce(
|
|
517
|
+
(maximum, row) => Math.max(
|
|
518
|
+
maximum,
|
|
519
|
+
nonNegativeNumber(row?.resolutionSeconds),
|
|
520
|
+
),
|
|
521
|
+
0,
|
|
522
|
+
),
|
|
523
|
+
};
|
|
524
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tledger",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "A local-only terminal dashboard for Codex token usage",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"keywords": [
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
"files": [
|
|
29
29
|
"bin",
|
|
30
30
|
"lib",
|
|
31
|
+
"docs",
|
|
31
32
|
"README.md"
|
|
32
33
|
],
|
|
33
34
|
"bin": {
|
|
@@ -36,14 +37,19 @@
|
|
|
36
37
|
"scripts": {
|
|
37
38
|
"test": "node --test tests/*.test.mjs",
|
|
38
39
|
"cli:test": "node --test tests/token-ledger-cli.test.mjs",
|
|
39
|
-
"lint": "eslint .",
|
|
40
|
-
"usage:snapshot": "node lib/token-ledger-importer.mjs --output outputs/token-ledger-snapshot.json",
|
|
40
|
+
"lint": "eslint . && oxlint",
|
|
41
|
+
"usage:snapshot": "node lib/token-ledger-importer.mjs --output outputs/token-ledger-snapshot-v2.json.gz",
|
|
41
42
|
"usage:day": "node bin/token-ledger.mjs day",
|
|
42
43
|
"usage:week": "node bin/token-ledger.mjs week",
|
|
43
|
-
"
|
|
44
|
+
"verify:release": "node tools/verify-release.mjs",
|
|
45
|
+
"prepublishOnly": "npm test && npm run verify:release",
|
|
46
|
+
"lint:eslint": "eslint .",
|
|
47
|
+
"lint:oxlint": "oxlint"
|
|
44
48
|
},
|
|
45
49
|
"devDependencies": {
|
|
46
|
-
"
|
|
50
|
+
"@oxlint/plugins": "1.79.0",
|
|
51
|
+
"eslint": "9.39.4",
|
|
52
|
+
"oxlint": "1.79.0"
|
|
47
53
|
},
|
|
48
54
|
"type": "module",
|
|
49
55
|
"dependencies": {
|