tledger 0.3.1 → 0.4.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.
@@ -0,0 +1,492 @@
1
+ // Cache report analysis. This module owns snapshot parsing and aggregation but
2
+ // has no dependency on either image renderer.
3
+
4
+ import {
5
+ multiDayBounds,
6
+ trendModelLabel,
7
+ } from "./token-ledger-trend.mjs";
8
+ import { chooseBinSize } from "./token-ledger-image-layout.mjs";
9
+ import {
10
+ createTimeZoneFormatter,
11
+ localDateBoundary,
12
+ localDateString,
13
+ shiftCalendarDate,
14
+ } from "../lib/token-ledger-calendar.mjs";
15
+ import {
16
+ MAX_SAFE_TOKEN_COUNT,
17
+ checkedTokenAdd,
18
+ checkedTokenPartitionAdd,
19
+ splitUsageBucketsAtBoundaries,
20
+ usageBuckets,
21
+ usageBucketsInRange,
22
+ usageCallCount,
23
+ usageDetailedCallCount,
24
+ usageInputCallCount,
25
+ } from "../lib/token-ledger-usage.mjs";
26
+
27
+ const MIN_BIN_WIDTH = 34;
28
+ const MAX_MODEL_ROWS = 6;
29
+ const MAX_FINITE_NUMBER = Number.MAX_VALUE;
30
+ const SCALE_HEADROOM = 1 - Number.EPSILON;
31
+
32
+ function rateFor(inputTokens, cachedInputTokens) {
33
+ return inputTokens > 0 ? (cachedInputTokens / inputTokens) * 100 : null;
34
+ }
35
+
36
+ function primitiveString(value) {
37
+ try {
38
+ const text = String.prototype.valueOf.call(value);
39
+ return text === value ? text : null;
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
44
+
45
+ function primitiveNumber(value) {
46
+ try {
47
+ const number = Number.prototype.valueOf.call(value);
48
+ return number === value ? number : null;
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
53
+
54
+ function finiteTimestamp(value) {
55
+ const text = primitiveString(value);
56
+ if (text === null) return null;
57
+ const timestampMs = Date.parse(text);
58
+ return Number.isFinite(timestampMs) ? timestampMs : null;
59
+ }
60
+
61
+ function parsedNonNegativeFiniteNumber(value) {
62
+ const primitive = primitiveNumber(value);
63
+ const text = primitive === null ? primitiveString(value) : null;
64
+ const number = primitive ?? (
65
+ text === null || text.trim() === "" ? NaN : Number(text)
66
+ );
67
+ return Number.isFinite(number) && number >= 0 ? number : null;
68
+ }
69
+
70
+ function scaleToFiniteSum(values) {
71
+ // Values are non-negative and finite; return one common factor for them.
72
+ const ratio = values.reduce(
73
+ (sum, value) => sum + value / MAX_FINITE_NUMBER,
74
+ 0,
75
+ );
76
+ const sum = values.reduce((total, value) => total + value, 0);
77
+ return ratio > 1 || !Number.isFinite(sum)
78
+ ? SCALE_HEADROOM / Math.max(1, ratio)
79
+ : 1;
80
+ }
81
+
82
+ function safeModelLabel(value) {
83
+ const model = primitiveString(value);
84
+ return model === null ? "Unknown" : trendModelLabel(model);
85
+ }
86
+
87
+ function cacheBreakdown(event) {
88
+ const parsedReportedTotalTokens = parsedNonNegativeFiniteNumber(
89
+ event.totalTokens,
90
+ );
91
+ const parsedInputTokens = parsedNonNegativeFiniteNumber(event.inputTokens);
92
+ const parsedCachedInputTokens = parsedNonNegativeFiniteNumber(
93
+ event.cachedInputTokens,
94
+ );
95
+ const parsedOutputTokens = parsedNonNegativeFiniteNumber(event.outputTokens);
96
+ const reportedTotalTokens = parsedReportedTotalTokens ?? 0;
97
+ const rawInputTokens = parsedInputTokens ?? 0;
98
+ const outputTokens = parsedOutputTokens ?? 0;
99
+ const rawCachedInputTokens = Math.min(
100
+ rawInputTokens,
101
+ parsedCachedInputTokens ?? 0,
102
+ );
103
+ const componentOverflowed = !Number.isFinite(rawInputTokens + outputTokens);
104
+ const componentScale = scaleToFiniteSum([rawInputTokens, outputTokens]);
105
+ const inputTokens = rawInputTokens * componentScale;
106
+ const componentOutputTokens = outputTokens * componentScale;
107
+ const cachedInputTokens = Math.min(
108
+ inputTokens,
109
+ rawCachedInputTokens * componentScale,
110
+ );
111
+ const componentTotalTokens = Math.min(
112
+ MAX_FINITE_NUMBER,
113
+ inputTokens + componentOutputTokens,
114
+ );
115
+ const totalTokens = reportedTotalTokens > 0
116
+ ? reportedTotalTokens
117
+ : componentTotalTokens;
118
+ const hasComponents = inputTokens > 0 || outputTokens > 0;
119
+ const hasReconciledBreakdown = hasComponents && (
120
+ reportedTotalTokens === 0 ||
121
+ componentTotalTokens === reportedTotalTokens ||
122
+ (componentOverflowed && reportedTotalTokens === MAX_FINITE_NUMBER)
123
+ );
124
+ const hasExplicitReconciledZeroBreakdown =
125
+ event.breakdownAvailable === true &&
126
+ parsedReportedTotalTokens === 0 &&
127
+ parsedInputTokens === 0 &&
128
+ parsedCachedInputTokens === 0 &&
129
+ parsedOutputTokens === 0;
130
+ const detailed = event.breakdownAvailable !== false && (
131
+ hasReconciledBreakdown || hasExplicitReconciledZeroBreakdown
132
+ );
133
+ return {
134
+ totalTokens,
135
+ inputTokens,
136
+ cachedInputTokens,
137
+ uncachedInputTokens: Math.max(0, inputTokens - cachedInputTokens),
138
+ detailed,
139
+ };
140
+ }
141
+
142
+ function parseCacheEvent(value) {
143
+ if (value == null) return null;
144
+ try {
145
+ const timestampMs = finiteTimestamp(value.timestamp);
146
+ if (timestampMs === null) return null;
147
+ return {
148
+ timestampMs,
149
+ model: safeModelLabel(value.model),
150
+ breakdown: cacheBreakdown(value),
151
+ };
152
+ } catch {
153
+ return null;
154
+ }
155
+ }
156
+
157
+ function emptyAggregate() {
158
+ return {
159
+ eventCount: 0,
160
+ detailedEventCount: 0,
161
+ inputEventCount: 0,
162
+ totalTokens: 0,
163
+ detailedTokens: 0,
164
+ unknownBreakdownTokens: 0,
165
+ inputTokens: 0,
166
+ cachedInputTokens: 0,
167
+ uncachedInputTokens: 0,
168
+ };
169
+ }
170
+
171
+ const INPUT_SCALE = Symbol("cacheInputScale");
172
+
173
+ function inputScale(target) {
174
+ return Number.isFinite(target[INPUT_SCALE]) && target[INPUT_SCALE] >= 1
175
+ ? target[INPUT_SCALE]
176
+ : 1;
177
+ }
178
+
179
+ function setInputScale(target, scale) {
180
+ Object.defineProperty(target, INPUT_SCALE, {
181
+ configurable: true,
182
+ enumerable: false,
183
+ value: scale,
184
+ writable: true,
185
+ });
186
+ }
187
+
188
+ function addInputTotals(target, inputTokens, cachedInputTokens, sourceScale = 1) {
189
+ const targetScale = inputScale(target);
190
+ const normalizedSourceScale = Number.isFinite(sourceScale) && sourceScale >= 1
191
+ ? sourceScale
192
+ : 1;
193
+ const commonScale = Math.max(targetScale, normalizedSourceScale);
194
+ const targetRatio = targetScale / commonScale;
195
+ const sourceRatio = normalizedSourceScale / commonScale;
196
+ const input = Number.isFinite(inputTokens) && inputTokens >= 0
197
+ ? inputTokens
198
+ : 0;
199
+ const cached = Math.min(
200
+ input,
201
+ Number.isFinite(cachedInputTokens) && cachedInputTokens >= 0
202
+ ? cachedInputTokens
203
+ : 0,
204
+ );
205
+ const nextInput = target.inputTokens * targetRatio + input * sourceRatio;
206
+ const nextCached =
207
+ target.cachedInputTokens * targetRatio + cached * sourceRatio;
208
+ const scaleFactor = Math.max(
209
+ 1,
210
+ nextInput / MAX_SAFE_TOKEN_COUNT,
211
+ nextCached / MAX_SAFE_TOKEN_COUNT,
212
+ );
213
+ target.inputTokens = nextInput / scaleFactor;
214
+ target.cachedInputTokens = nextCached / scaleFactor;
215
+ target.uncachedInputTokens = Math.max(
216
+ 0,
217
+ target.inputTokens - target.cachedInputTokens,
218
+ );
219
+ setInputScale(target, commonScale * scaleFactor);
220
+ }
221
+
222
+ function alignInputScale(target, commonScale) {
223
+ const currentScale = inputScale(target);
224
+ if (currentScale === commonScale) return;
225
+ const ratio = currentScale / commonScale;
226
+ target.inputTokens *= ratio;
227
+ target.cachedInputTokens *= ratio;
228
+ target.uncachedInputTokens = Math.max(
229
+ 0,
230
+ target.inputTokens - target.cachedInputTokens,
231
+ );
232
+ setInputScale(target, commonScale);
233
+ }
234
+
235
+ function boundedTokenValue(value) {
236
+ return Number.isFinite(value) && value >= 0
237
+ ? Math.min(value, MAX_SAFE_TOKEN_COUNT)
238
+ : 0;
239
+ }
240
+
241
+ function addBoundedTokens(current, contribution) {
242
+ const sum = boundedTokenValue(current) + boundedTokenValue(contribution);
243
+ return Number.isFinite(sum) && sum <= MAX_SAFE_TOKEN_COUNT
244
+ ? sum
245
+ : MAX_SAFE_TOKEN_COUNT;
246
+ }
247
+
248
+ function addInput(target, breakdown, inputCallCount) {
249
+ addInputTotals(
250
+ target,
251
+ breakdown.inputTokens,
252
+ breakdown.cachedInputTokens,
253
+ );
254
+ target.inputEventCount = checkedTokenAdd(
255
+ target.inputEventCount,
256
+ inputCallCount,
257
+ { allowFractional: true },
258
+ );
259
+ }
260
+
261
+ function finalizeAggregate(aggregate) {
262
+ const uncachedInputTokens = Math.max(
263
+ 0,
264
+ aggregate.inputTokens - aggregate.cachedInputTokens,
265
+ );
266
+ const measurementCoveragePercent = aggregate.totalTokens > 0
267
+ ? (aggregate.detailedTokens /
268
+ (aggregate.detailedTokens + aggregate.unknownBreakdownTokens)) *
269
+ 100
270
+ : aggregate.eventCount > 0
271
+ ? (aggregate.detailedEventCount / aggregate.eventCount) * 100
272
+ : null;
273
+ const finalized = {
274
+ ...aggregate,
275
+ uncachedInputTokens,
276
+ rate: rateFor(aggregate.inputTokens, aggregate.cachedInputTokens),
277
+ measurementCoveragePercent,
278
+ };
279
+ setInputScale(finalized, inputScale(aggregate));
280
+ return finalized;
281
+ }
282
+
283
+ function accumulateRange(
284
+ snapshot,
285
+ bounds,
286
+ bins = null,
287
+ dateIndexByString = null,
288
+ sourceEvents = null,
289
+ ) {
290
+ const startMs = bounds.start.getTime();
291
+ const endMs = bounds.end.getTime();
292
+ const totals = emptyAggregate();
293
+ const modelTotals = new Map();
294
+ const dateFormatter = bins === null
295
+ ? null
296
+ : createTimeZoneFormatter(bounds.timeZone);
297
+
298
+ const boundaries = [
299
+ startMs,
300
+ ...((bins ?? []).map((bin) =>
301
+ localDateBoundary(
302
+ bin.endDateString,
303
+ bounds.timeZone,
304
+ dateFormatter,
305
+ ).getTime())),
306
+ endMs,
307
+ ];
308
+ const events = sourceEvents === null
309
+ ? bins === null
310
+ ? usageBucketsInRange(snapshot, startMs, endMs)
311
+ : splitUsageBucketsAtBoundaries(usageBuckets(snapshot), boundaries)
312
+ : bins === null
313
+ ? sourceEvents
314
+ : splitUsageBucketsAtBoundaries(sourceEvents, boundaries);
315
+ for (const event of events) {
316
+ const parsed = parseCacheEvent(event);
317
+ if (
318
+ parsed === null ||
319
+ parsed.timestampMs < startMs ||
320
+ parsed.timestampMs >= endMs
321
+ ) {
322
+ continue;
323
+ }
324
+ const { breakdown } = parsed;
325
+ const dateString = dateFormatter === null
326
+ ? null
327
+ : localDateString(parsed.timestampMs, bounds.timeZone, dateFormatter);
328
+ const binIndex = dateString === null ? null : dateIndexByString.get(dateString);
329
+ const bin = binIndex === undefined || binIndex === null ? null : bins[binIndex];
330
+ const callCount = usageCallCount(event);
331
+ const detailedCallCount = usageDetailedCallCount(event);
332
+ const inputCallCount = usageInputCallCount(event);
333
+ totals.eventCount = checkedTokenAdd(totals.eventCount, callCount, {
334
+ allowFractional: true,
335
+ });
336
+ totals.totalTokens = addBoundedTokens(
337
+ totals.totalTokens,
338
+ breakdown.totalTokens,
339
+ );
340
+ if (bin) {
341
+ bin.eventCount = checkedTokenAdd(bin.eventCount, callCount, {
342
+ allowFractional: true,
343
+ });
344
+ bin.totalTokens = addBoundedTokens(bin.totalTokens, breakdown.totalTokens);
345
+ }
346
+ checkedTokenPartitionAdd(
347
+ totals,
348
+ boundedTokenValue(breakdown.totalTokens),
349
+ { detailed: breakdown.detailed },
350
+ );
351
+ if (bin) {
352
+ checkedTokenPartitionAdd(
353
+ bin,
354
+ boundedTokenValue(breakdown.totalTokens),
355
+ { detailed: breakdown.detailed },
356
+ );
357
+ }
358
+ if (!breakdown.detailed) continue;
359
+
360
+ totals.detailedEventCount = checkedTokenAdd(
361
+ totals.detailedEventCount,
362
+ detailedCallCount,
363
+ { allowFractional: true },
364
+ );
365
+ if (bin) {
366
+ bin.detailedEventCount = checkedTokenAdd(
367
+ bin.detailedEventCount,
368
+ detailedCallCount,
369
+ { allowFractional: true },
370
+ );
371
+ }
372
+ if (!(breakdown.inputTokens > 0)) continue;
373
+
374
+ addInput(totals, breakdown, inputCallCount);
375
+ if (bin) addInput(bin, breakdown, inputCallCount);
376
+ const model = parsed.model;
377
+ const modelAggregate = modelTotals.get(model) ?? {
378
+ model,
379
+ inputTokens: 0,
380
+ cachedInputTokens: 0,
381
+ uncachedInputTokens: 0,
382
+ inputEventCount: 0,
383
+ };
384
+ addInput(modelAggregate, breakdown, inputCallCount);
385
+ modelTotals.set(model, modelAggregate);
386
+ }
387
+
388
+ const commonScale = Math.max(
389
+ inputScale(totals),
390
+ ...[...modelTotals.values()].map(inputScale),
391
+ ...(bins ?? []).map(inputScale),
392
+ );
393
+ alignInputScale(totals, commonScale);
394
+ for (const model of modelTotals.values()) {
395
+ alignInputScale(model, commonScale);
396
+ }
397
+ for (const bin of bins ?? []) {
398
+ alignInputScale(bin, commonScale);
399
+ }
400
+
401
+ const summary = finalizeAggregate(totals);
402
+ summary.models = [...modelTotals.values()]
403
+ .map((model) => finalizeAggregate(model))
404
+ .sort(
405
+ (left, right) =>
406
+ right.inputTokens - left.inputTokens || left.model.localeCompare(right.model),
407
+ );
408
+ return summary;
409
+ }
410
+
411
+ export function aggregateCacheRange(snapshot, bounds, { events = null } = {}) {
412
+ return accumulateRange(snapshot, bounds, null, null, events);
413
+ }
414
+
415
+ export function buildCacheReportData(
416
+ snapshot,
417
+ bounds,
418
+ days,
419
+ plotWidth,
420
+ binSizeOverride = null,
421
+ events = null,
422
+ ) {
423
+ const rangeDays = Math.max(1, Number(days) || Number(bounds.rangeDays) || 7);
424
+ // The combined report passes the trend chart's bin size so both charts'
425
+ // columns stay vertically aligned.
426
+ const binSize = binSizeOverride ?? chooseBinSize(rangeDays, plotWidth, {
427
+ minBinWidth: MIN_BIN_WIDTH,
428
+ preferDaily: true,
429
+ });
430
+ const binCount = Math.ceil(rangeDays / binSize);
431
+ const bins = Array.from({ length: binCount }, (_, index) => ({
432
+ ...emptyAggregate(),
433
+ startDateString: shiftCalendarDate(
434
+ bounds.startDateString,
435
+ index * binSize,
436
+ ),
437
+ endDateString: shiftCalendarDate(
438
+ bounds.startDateString,
439
+ Math.min(rangeDays, (index + 1) * binSize),
440
+ ),
441
+ }));
442
+ const dateIndexByString = new Map(
443
+ Array.from({ length: rangeDays }, (_, index) => [
444
+ shiftCalendarDate(bounds.startDateString, index),
445
+ Math.floor(index / binSize),
446
+ ]),
447
+ );
448
+ const summary = accumulateRange(
449
+ snapshot,
450
+ bounds,
451
+ bins,
452
+ dateIndexByString,
453
+ events,
454
+ );
455
+ return {
456
+ ...summary,
457
+ bins: bins.map((bin) => finalizeAggregate(bin)),
458
+ binSize,
459
+ binCount,
460
+ };
461
+ }
462
+
463
+ export function combinedModelRows(models) {
464
+ if (models.length <= MAX_MODEL_ROWS) return models;
465
+ const visible = models.slice(0, MAX_MODEL_ROWS - 1);
466
+ const remainder = models.slice(MAX_MODEL_ROWS - 1).reduce(
467
+ (row, model) => {
468
+ row.inputTokens += model.inputTokens;
469
+ row.cachedInputTokens += model.cachedInputTokens;
470
+ row.inputEventCount += model.inputEventCount;
471
+ row.uncachedInputTokens = Math.max(
472
+ 0,
473
+ row.inputTokens - row.cachedInputTokens,
474
+ );
475
+ return row;
476
+ },
477
+ {
478
+ model: "Other models",
479
+ inputTokens: 0,
480
+ cachedInputTokens: 0,
481
+ uncachedInputTokens: 0,
482
+ inputEventCount: 0,
483
+ },
484
+ );
485
+ return [...visible, finalizeAggregate(remainder)];
486
+ }
487
+
488
+ export function priorPeriodSummary(snapshot, bounds, days, events = null) {
489
+ const priorEndDate = shiftCalendarDate(bounds.startDateString, -1);
490
+ const priorBounds = multiDayBounds(priorEndDate, bounds.timeZone, days);
491
+ return aggregateCacheRange(snapshot, priorBounds, { events });
492
+ }