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.
@@ -0,0 +1,1150 @@
1
+ import {
2
+ multiDayBounds,
3
+ trendModelLabel,
4
+ } from "./token-ledger-trend.mjs";
5
+ import {
6
+ compact,
7
+ escapeXml,
8
+ shiftCalendarDate,
9
+ svgRect,
10
+ svgText,
11
+ textWidth,
12
+ TREND_IMAGE_MODEL_COLORS,
13
+ } from "./token-ledger-trend-image.mjs";
14
+ import { chooseBinSize } from "./token-ledger-trend-terminal.mjs";
15
+ import {
16
+ splitUsageBucketsAtBoundaries,
17
+ usageBuckets,
18
+ usageBucketsInRange,
19
+ usageCallCount,
20
+ usageDetailedCallCount,
21
+ usageInputCallCount,
22
+ } from "../lib/token-ledger-usage.mjs";
23
+
24
+ const COLORS = {
25
+ background: "#0e1420",
26
+ ink: "#f2f5fa",
27
+ secondary: "#aeb8c9",
28
+ muted: "#77839a",
29
+ grid: "#1c2534",
30
+ baseline: "#33405a",
31
+ track: "#202a3a",
32
+ cached: "#2ec4a1",
33
+ uncached: "#d88362",
34
+ volume: "#64748b",
35
+ weighted: "#c7d2e8",
36
+ rule: "rgba(255,255,255,.1)",
37
+ };
38
+
39
+ const MIN_BIN_WIDTH = 34;
40
+ const MAX_MODEL_ROWS = 6;
41
+ const MAX_FINITE_NUMBER = Number.MAX_VALUE;
42
+ const SCALE_HEADROOM = 1 - Number.EPSILON;
43
+
44
+ function percent(value) {
45
+ if (!Number.isFinite(value)) return "—";
46
+ return `${value.toFixed(value >= 10 ? 1 : 2)}%`;
47
+ }
48
+
49
+ function rateFor(inputTokens, cachedInputTokens) {
50
+ return inputTokens > 0 ? (cachedInputTokens / inputTokens) * 100 : null;
51
+ }
52
+
53
+ function localDateFormatter(timeZone) {
54
+ return new Intl.DateTimeFormat("en-US", {
55
+ timeZone,
56
+ timeZoneName: "longOffset",
57
+ year: "numeric",
58
+ month: "2-digit",
59
+ day: "2-digit",
60
+ });
61
+ }
62
+
63
+ function localDateString(timestampMs, formatter) {
64
+ const parts = formatter.formatToParts(new Date(timestampMs));
65
+ const values = Object.fromEntries(
66
+ parts
67
+ .filter((part) => part.type !== "literal")
68
+ .map((part) => [part.type, part.value]),
69
+ );
70
+ return `${values.year}-${values.month}-${values.day}`;
71
+ }
72
+
73
+ function timeZoneOffsetMs(timestampMs, formatter) {
74
+ const parts = formatter.formatToParts(new Date(timestampMs));
75
+ const value = parts.find((part) => part.type === "timeZoneName")?.value ?? "GMT";
76
+ if (value === "GMT") return 0;
77
+ const match = value.match(/^GMT([+-])(\d{2}):?(\d{2})?$/);
78
+ if (!match) return 0;
79
+ const minutes = Number(match[2]) * 60 + Number(match[3] || 0);
80
+ return (match[1] === "+" ? 1 : -1) * minutes * 60 * 1_000;
81
+ }
82
+
83
+ function zonedMidnightMs(dateString, formatter) {
84
+ const [year, month, day] = dateString.split("-").map(Number);
85
+ const utcGuess = Date.UTC(year, month - 1, day);
86
+ const first = utcGuess - timeZoneOffsetMs(utcGuess, formatter);
87
+ return utcGuess - timeZoneOffsetMs(first, formatter);
88
+ }
89
+
90
+ function calendarDate(dateString) {
91
+ return new Date(`${dateString}T00:00:00.000Z`);
92
+ }
93
+
94
+ function shortDateLabel(dateString) {
95
+ return new Intl.DateTimeFormat("en-US", {
96
+ timeZone: "UTC",
97
+ month: "short",
98
+ day: "numeric",
99
+ }).format(calendarDate(dateString));
100
+ }
101
+
102
+ function weekdayLabel(dateString) {
103
+ return new Intl.DateTimeFormat("en-US", {
104
+ timeZone: "UTC",
105
+ weekday: "short",
106
+ }).format(calendarDate(dateString)).toUpperCase();
107
+ }
108
+
109
+ function periodLabel(bounds) {
110
+ const startYear = bounds.startDateString.slice(0, 4);
111
+ const endYear = bounds.endDateString.slice(0, 4);
112
+ const start = shortDateLabel(bounds.startDateString);
113
+ const end = shortDateLabel(bounds.endDateString);
114
+ return startYear === endYear
115
+ ? `${start} – ${end}, ${endYear}`
116
+ : `${start}, ${startYear} – ${end}, ${endYear}`;
117
+ }
118
+
119
+ function wrapFooterText(value, maxWidth, size = 12) {
120
+ const words = String(value).split(/\s+/).filter(Boolean);
121
+ const lines = [];
122
+ let current = "";
123
+ for (const word of words) {
124
+ const candidate = current ? `${current} ${word}` : word;
125
+ if (current && textWidth(candidate, size) > maxWidth) {
126
+ lines.push(current);
127
+ current = word;
128
+ } else {
129
+ current = candidate;
130
+ }
131
+ }
132
+ if (current) lines.push(current);
133
+ return lines;
134
+ }
135
+
136
+ function footerQualifierLines(value, fallbackLines, maxWidth) {
137
+ if (textWidth(value, 12) <= maxWidth) return [value];
138
+ return fallbackLines.flatMap((line) => wrapFooterText(line, maxWidth));
139
+ }
140
+
141
+ function binDateLabel(bin) {
142
+ const finalDate = shiftCalendarDate(bin.endDateString, -1);
143
+ if (finalDate === bin.startDateString) return shortDateLabel(bin.startDateString);
144
+ return `${shortDateLabel(bin.startDateString)}–${shortDateLabel(finalDate)}`;
145
+ }
146
+
147
+ function primitiveString(value) {
148
+ try {
149
+ const text = String.prototype.valueOf.call(value);
150
+ return text === value ? text : null;
151
+ } catch {
152
+ return null;
153
+ }
154
+ }
155
+
156
+ function primitiveNumber(value) {
157
+ try {
158
+ const number = Number.prototype.valueOf.call(value);
159
+ return number === value ? number : null;
160
+ } catch {
161
+ return null;
162
+ }
163
+ }
164
+
165
+ function finiteTimestamp(value) {
166
+ const text = primitiveString(value);
167
+ if (text === null) return null;
168
+ const timestampMs = Date.parse(text);
169
+ return Number.isFinite(timestampMs) ? timestampMs : null;
170
+ }
171
+
172
+ function generatedAtLabel(value, timeZone) {
173
+ const timestampMs = finiteTimestamp(value);
174
+ if (timestampMs === null) return "unknown";
175
+ return new Intl.DateTimeFormat("en-US", {
176
+ timeZone,
177
+ month: "short",
178
+ day: "numeric",
179
+ year: "numeric",
180
+ hour: "numeric",
181
+ minute: "2-digit",
182
+ }).format(new Date(timestampMs));
183
+ }
184
+
185
+ function parsedNonNegativeFiniteNumber(value) {
186
+ const primitive = primitiveNumber(value);
187
+ const text = primitive === null ? primitiveString(value) : null;
188
+ const number = primitive ?? (
189
+ text === null || text.trim() === "" ? NaN : Number(text)
190
+ );
191
+ return Number.isFinite(number) && number >= 0 ? number : null;
192
+ }
193
+
194
+ function scaleToFiniteSum(values) {
195
+ // Values are non-negative and finite; return one common factor for them.
196
+ const ratio = values.reduce(
197
+ (sum, value) => sum + value / MAX_FINITE_NUMBER,
198
+ 0,
199
+ );
200
+ const sum = values.reduce((total, value) => total + value, 0);
201
+ return ratio > 1 || !Number.isFinite(sum)
202
+ ? SCALE_HEADROOM / Math.max(1, ratio)
203
+ : 1;
204
+ }
205
+
206
+ function scaleInputTokens(target, factor) {
207
+ target.inputTokens *= factor;
208
+ target.cachedInputTokens *= factor;
209
+ target.uncachedInputTokens *= factor;
210
+ }
211
+
212
+ function scaleAggregateTokens(target, factor) {
213
+ if (Number.isFinite(target.totalTokens)) target.totalTokens *= factor;
214
+ if (Number.isFinite(target.detailedTokens)) {
215
+ target.detailedTokens *= factor;
216
+ }
217
+ scaleInputTokens(target, factor);
218
+ }
219
+
220
+ function scaleBreakdown(breakdown, factor) {
221
+ return {
222
+ ...breakdown,
223
+ totalTokens: breakdown.totalTokens * factor,
224
+ inputTokens: breakdown.inputTokens * factor,
225
+ cachedInputTokens: breakdown.cachedInputTokens * factor,
226
+ uncachedInputTokens: breakdown.uncachedInputTokens * factor,
227
+ };
228
+ }
229
+
230
+ function tokenAdditionRatio(target, totalContribution, inputContribution) {
231
+ const totalRatio = Number.isFinite(target.totalTokens)
232
+ ? target.totalTokens / MAX_FINITE_NUMBER +
233
+ totalContribution / MAX_FINITE_NUMBER
234
+ : 0;
235
+ const inputRatio = target.inputTokens / MAX_FINITE_NUMBER +
236
+ inputContribution / MAX_FINITE_NUMBER;
237
+ return Math.max(totalRatio, inputRatio);
238
+ }
239
+
240
+ function tokenAdditionOverflows(target, totalContribution, inputContribution) {
241
+ return (
242
+ (Number.isFinite(target.totalTokens) &&
243
+ !Number.isFinite(target.totalTokens + totalContribution)) ||
244
+ !Number.isFinite(target.inputTokens + inputContribution)
245
+ );
246
+ }
247
+
248
+ function safeModelLabel(value) {
249
+ const model = primitiveString(value);
250
+ return model === null ? "Unknown" : trendModelLabel(model);
251
+ }
252
+
253
+ function cacheBreakdown(event) {
254
+ const parsedReportedTotalTokens = parsedNonNegativeFiniteNumber(
255
+ event.totalTokens,
256
+ );
257
+ const parsedInputTokens = parsedNonNegativeFiniteNumber(event.inputTokens);
258
+ const parsedCachedInputTokens = parsedNonNegativeFiniteNumber(
259
+ event.cachedInputTokens,
260
+ );
261
+ const parsedOutputTokens = parsedNonNegativeFiniteNumber(event.outputTokens);
262
+ const reportedTotalTokens = parsedReportedTotalTokens ?? 0;
263
+ const rawInputTokens = parsedInputTokens ?? 0;
264
+ const outputTokens = parsedOutputTokens ?? 0;
265
+ const rawCachedInputTokens = Math.min(
266
+ rawInputTokens,
267
+ parsedCachedInputTokens ?? 0,
268
+ );
269
+ const componentOverflowed = !Number.isFinite(rawInputTokens + outputTokens);
270
+ const componentScale = scaleToFiniteSum([rawInputTokens, outputTokens]);
271
+ const inputTokens = rawInputTokens * componentScale;
272
+ const componentOutputTokens = outputTokens * componentScale;
273
+ const cachedInputTokens = Math.min(
274
+ inputTokens,
275
+ rawCachedInputTokens * componentScale,
276
+ );
277
+ const componentTotalTokens = Math.min(
278
+ MAX_FINITE_NUMBER,
279
+ inputTokens + componentOutputTokens,
280
+ );
281
+ const totalTokens = reportedTotalTokens > 0
282
+ ? reportedTotalTokens
283
+ : componentTotalTokens;
284
+ const hasComponents = inputTokens > 0 || outputTokens > 0;
285
+ const hasReconciledBreakdown = hasComponents && (
286
+ reportedTotalTokens === 0 ||
287
+ componentTotalTokens === reportedTotalTokens ||
288
+ (componentOverflowed && reportedTotalTokens === MAX_FINITE_NUMBER)
289
+ );
290
+ const hasExplicitReconciledZeroBreakdown =
291
+ event.breakdownAvailable === true &&
292
+ parsedReportedTotalTokens === 0 &&
293
+ parsedInputTokens === 0 &&
294
+ parsedCachedInputTokens === 0 &&
295
+ parsedOutputTokens === 0;
296
+ const detailed = event.breakdownAvailable !== false && (
297
+ hasReconciledBreakdown || hasExplicitReconciledZeroBreakdown
298
+ );
299
+ return {
300
+ totalTokens,
301
+ inputTokens,
302
+ cachedInputTokens,
303
+ uncachedInputTokens: Math.max(0, inputTokens - cachedInputTokens),
304
+ detailed,
305
+ };
306
+ }
307
+
308
+ function parseCacheEvent(value) {
309
+ if (value == null) return null;
310
+ try {
311
+ const timestampMs = finiteTimestamp(value.timestamp);
312
+ if (timestampMs === null) return null;
313
+ return {
314
+ timestampMs,
315
+ model: safeModelLabel(value.model),
316
+ breakdown: cacheBreakdown(value),
317
+ };
318
+ } catch {
319
+ return null;
320
+ }
321
+ }
322
+
323
+ function emptyAggregate() {
324
+ return {
325
+ eventCount: 0,
326
+ detailedEventCount: 0,
327
+ inputEventCount: 0,
328
+ totalTokens: 0,
329
+ detailedTokens: 0,
330
+ inputTokens: 0,
331
+ cachedInputTokens: 0,
332
+ uncachedInputTokens: 0,
333
+ };
334
+ }
335
+
336
+ function addInput(target, breakdown, inputCallCount) {
337
+ const factor = scaleToFiniteSum([
338
+ target.inputTokens,
339
+ breakdown.inputTokens,
340
+ ]);
341
+ if (factor < 1) {
342
+ scaleInputTokens(target, factor);
343
+ breakdown = scaleBreakdown(breakdown, factor);
344
+ }
345
+ target.inputTokens += breakdown.inputTokens;
346
+ target.cachedInputTokens += breakdown.cachedInputTokens;
347
+ target.cachedInputTokens = Math.min(
348
+ target.inputTokens,
349
+ target.cachedInputTokens,
350
+ );
351
+ target.uncachedInputTokens = Math.max(
352
+ 0,
353
+ target.inputTokens - target.cachedInputTokens,
354
+ );
355
+ target.inputEventCount += inputCallCount;
356
+ }
357
+
358
+ function finalizeAggregate(aggregate) {
359
+ const uncachedInputTokens = Math.max(
360
+ 0,
361
+ aggregate.inputTokens - aggregate.cachedInputTokens,
362
+ );
363
+ const measurementCoveragePercent = aggregate.totalTokens > 0
364
+ ? (aggregate.detailedTokens / aggregate.totalTokens) * 100
365
+ : aggregate.eventCount > 0
366
+ ? (aggregate.detailedEventCount / aggregate.eventCount) * 100
367
+ : null;
368
+ return {
369
+ ...aggregate,
370
+ uncachedInputTokens,
371
+ rate: rateFor(aggregate.inputTokens, aggregate.cachedInputTokens),
372
+ measurementCoveragePercent,
373
+ };
374
+ }
375
+
376
+ function accumulateRange(snapshot, bounds, bins = null, dateIndexByString = null) {
377
+ const startMs = bounds.start.getTime();
378
+ const endMs = bounds.end.getTime();
379
+ const totals = emptyAggregate();
380
+ const modelTotals = new Map();
381
+ // One shared scale keeps rates, shares, and coverage proportional everywhere.
382
+ const tokenScale = { value: 1 };
383
+ const dateFormatter = bins === null
384
+ ? null
385
+ : localDateFormatter(bounds.timeZone);
386
+
387
+ const events = bins === null
388
+ ? usageBucketsInRange(snapshot, startMs, endMs)
389
+ : splitUsageBucketsAtBoundaries(
390
+ usageBuckets(snapshot),
391
+ [
392
+ startMs,
393
+ ...bins.map((bin) =>
394
+ zonedMidnightMs(bin.endDateString, dateFormatter)),
395
+ endMs,
396
+ ],
397
+ );
398
+ for (const event of events) {
399
+ const parsed = parseCacheEvent(event);
400
+ if (
401
+ parsed === null ||
402
+ parsed.timestampMs < startMs ||
403
+ parsed.timestampMs >= endMs
404
+ ) {
405
+ continue;
406
+ }
407
+ let { breakdown } = parsed;
408
+ const dateString = dateFormatter === null
409
+ ? null
410
+ : localDateString(parsed.timestampMs, dateFormatter);
411
+ const binIndex = dateString === null ? null : dateIndexByString.get(dateString);
412
+ const bin = binIndex === undefined || binIndex === null ? null : bins[binIndex];
413
+ const existingModelAggregate = breakdown.detailed && breakdown.inputTokens > 0
414
+ ? modelTotals.get(parsed.model)
415
+ : null;
416
+ const targets = [totals];
417
+ if (bin) targets.push(bin);
418
+ if (existingModelAggregate) targets.push(existingModelAggregate);
419
+ const scale = tokenScale.value;
420
+ const inputContribution = breakdown.detailed ? breakdown.inputTokens : 0;
421
+ const totalContribution = breakdown.totalTokens * scale;
422
+ const scaledInputContribution = inputContribution * scale;
423
+ const overflowRatio = Math.max(
424
+ ...targets.map((target) =>
425
+ tokenAdditionRatio(
426
+ target,
427
+ totalContribution,
428
+ scaledInputContribution,
429
+ )),
430
+ );
431
+ const directOverflow = targets.some((target) =>
432
+ tokenAdditionOverflows(
433
+ target,
434
+ totalContribution,
435
+ scaledInputContribution,
436
+ ));
437
+ if (overflowRatio > 1 || directOverflow) {
438
+ const factor = SCALE_HEADROOM / Math.max(1, overflowRatio);
439
+ scaleAggregateTokens(totals, factor);
440
+ for (const bin of bins ?? []) scaleAggregateTokens(bin, factor);
441
+ for (const model of modelTotals.values()) {
442
+ scaleAggregateTokens(model, factor);
443
+ }
444
+ tokenScale.value *= factor;
445
+ }
446
+ breakdown = scaleBreakdown(breakdown, tokenScale.value);
447
+
448
+ const callCount = usageCallCount(event);
449
+ const detailedCallCount = usageDetailedCallCount(event);
450
+ const inputCallCount = usageInputCallCount(event);
451
+ totals.eventCount += callCount;
452
+ totals.totalTokens += breakdown.totalTokens;
453
+ if (bin) {
454
+ bin.eventCount += callCount;
455
+ bin.totalTokens += breakdown.totalTokens;
456
+ }
457
+ if (!breakdown.detailed) continue;
458
+
459
+ totals.detailedEventCount += detailedCallCount;
460
+ totals.detailedTokens += breakdown.totalTokens;
461
+ if (bin) {
462
+ bin.detailedEventCount += detailedCallCount;
463
+ bin.detailedTokens += breakdown.totalTokens;
464
+ }
465
+ if (!(breakdown.inputTokens > 0)) continue;
466
+
467
+ addInput(totals, breakdown, inputCallCount);
468
+ if (bin) addInput(bin, breakdown, inputCallCount);
469
+ const model = parsed.model;
470
+ const modelAggregate = modelTotals.get(model) ?? {
471
+ model,
472
+ inputTokens: 0,
473
+ cachedInputTokens: 0,
474
+ uncachedInputTokens: 0,
475
+ inputEventCount: 0,
476
+ };
477
+ addInput(modelAggregate, breakdown, inputCallCount);
478
+ modelTotals.set(model, modelAggregate);
479
+ }
480
+
481
+ const summary = finalizeAggregate(totals);
482
+ summary.models = [...modelTotals.values()]
483
+ .map((model) => finalizeAggregate(model))
484
+ .sort(
485
+ (left, right) =>
486
+ right.inputTokens - left.inputTokens || left.model.localeCompare(right.model),
487
+ );
488
+ return summary;
489
+ }
490
+
491
+ export function aggregateCacheRange(snapshot, bounds) {
492
+ return accumulateRange(snapshot, bounds);
493
+ }
494
+
495
+ export function buildCacheReportData(
496
+ snapshot,
497
+ bounds,
498
+ days,
499
+ plotWidth,
500
+ binSizeOverride = null,
501
+ ) {
502
+ const rangeDays = Math.max(1, Number(days) || Number(bounds.rangeDays) || 7);
503
+ // The combined report passes the trend chart's bin size so both charts'
504
+ // columns stay vertically aligned.
505
+ const binSize = binSizeOverride ?? chooseBinSize(rangeDays, plotWidth, {
506
+ minBinWidth: MIN_BIN_WIDTH,
507
+ preferDaily: true,
508
+ });
509
+ const binCount = Math.ceil(rangeDays / binSize);
510
+ const bins = Array.from({ length: binCount }, (_, index) => ({
511
+ ...emptyAggregate(),
512
+ startDateString: shiftCalendarDate(
513
+ bounds.startDateString,
514
+ index * binSize,
515
+ ),
516
+ endDateString: shiftCalendarDate(
517
+ bounds.startDateString,
518
+ Math.min(rangeDays, (index + 1) * binSize),
519
+ ),
520
+ }));
521
+ const dateIndexByString = new Map(
522
+ Array.from({ length: rangeDays }, (_, index) => [
523
+ shiftCalendarDate(bounds.startDateString, index),
524
+ Math.floor(index / binSize),
525
+ ]),
526
+ );
527
+ const summary = accumulateRange(snapshot, bounds, bins, dateIndexByString);
528
+ return {
529
+ ...summary,
530
+ bins: bins.map((bin) => finalizeAggregate(bin)),
531
+ binSize,
532
+ binCount,
533
+ };
534
+ }
535
+
536
+ function combinedModelRows(models) {
537
+ if (models.length <= MAX_MODEL_ROWS) return models;
538
+ const visible = models.slice(0, MAX_MODEL_ROWS - 1);
539
+ const remainder = models.slice(MAX_MODEL_ROWS - 1).reduce(
540
+ (row, model) => {
541
+ row.inputTokens += model.inputTokens;
542
+ row.cachedInputTokens += model.cachedInputTokens;
543
+ row.inputEventCount += model.inputEventCount;
544
+ row.uncachedInputTokens = Math.max(
545
+ 0,
546
+ row.inputTokens - row.cachedInputTokens,
547
+ );
548
+ return row;
549
+ },
550
+ {
551
+ model: "Other models",
552
+ inputTokens: 0,
553
+ cachedInputTokens: 0,
554
+ uncachedInputTokens: 0,
555
+ inputEventCount: 0,
556
+ },
557
+ );
558
+ return [...visible, finalizeAggregate(remainder)];
559
+ }
560
+
561
+ function modelColor(model) {
562
+ return TREND_IMAGE_MODEL_COLORS[model] ?? TREND_IMAGE_MODEL_COLORS.Other;
563
+ }
564
+
565
+ function priorPeriodSummary(snapshot, bounds, days) {
566
+ const priorEndDate = shiftCalendarDate(bounds.startDateString, -1);
567
+ const priorBounds = multiDayBounds(priorEndDate, bounds.timeZone, days);
568
+ return aggregateCacheRange(snapshot, priorBounds);
569
+ }
570
+
571
+ function periodComparison(currentRate, priorRate) {
572
+ if (!Number.isFinite(currentRate)) return "No current-period input";
573
+ if (!Number.isFinite(priorRate)) return "No prior-period input";
574
+ const delta = currentRate - priorRate;
575
+ const deltaLabel = Math.abs(delta) < 0.05
576
+ ? "flat"
577
+ : `${delta >= 0 ? "+" : "−"}${Math.abs(delta).toFixed(1)} pp`;
578
+ return `Prior ${percent(priorRate)} · ${deltaLabel}`;
579
+ }
580
+
581
+ const AXIS_LABEL_GAP = 12;
582
+
583
+ function labelEvery(bins, slotWidth) {
584
+ if (bins.length === 0 || slotWidth <= 0) return 1;
585
+ const widestLabel = Math.max(
586
+ ...bins.map((bin) => textWidth(binDateLabel(bin), 13)),
587
+ );
588
+ return Math.max(1, Math.ceil((widestLabel + AXIS_LABEL_GAP) / slotWidth));
589
+ }
590
+
591
+ function pushLegendItem(elements, { x, y, color, label, line = false }) {
592
+ if (line) {
593
+ elements.push(
594
+ `<line x1="${x}" y1="${y - 4}" x2="${x + 22}" y2="${y - 4}" stroke="${color}" stroke-width="2" stroke-dasharray="5 4"/>`,
595
+ );
596
+ } else {
597
+ elements.push(svgRect(x, y - 12, 14, 11, { rx: 2, fill: color }));
598
+ }
599
+ elements.push(svgText({
600
+ x: x + (line ? 31 : 23),
601
+ y,
602
+ value: label,
603
+ fill: COLORS.secondary,
604
+ size: 13,
605
+ }));
606
+ }
607
+
608
+ export function renderCacheReportImage({
609
+ snapshot,
610
+ bounds,
611
+ days = bounds.rangeDays ?? 7,
612
+ options = {},
613
+ }) {
614
+ const width = Math.max(900, Math.min(2_400, Number(options.imageWidth) || 1_280));
615
+ const outer = 32;
616
+ const contentRight = width - outer;
617
+ const plotLeft = 82;
618
+ const plotRight = width - 94;
619
+ const plotWidth = plotRight - plotLeft;
620
+ const data = buildCacheReportData(snapshot, bounds, days, plotWidth);
621
+ const prior = priorPeriodSummary(snapshot, bounds, days);
622
+ const models = combinedModelRows(data.models);
623
+ const columnWidth = (contentRight - outer) / 3;
624
+ const qualifierWidth = columnWidth - 22;
625
+ const measurementCounts = `${data.detailedEventCount.toLocaleString("en-US")} of ${data.eventCount.toLocaleString("en-US")} calls`;
626
+ const measurementQualifier = `${measurementCounts} include component detail`;
627
+ const dataAsOfQualifier = `${bounds.timeZone} · ${days}-day calendar window`;
628
+ const footerItems = [
629
+ {
630
+ label: "RATE DEFINITION",
631
+ value: "cached input ÷ measured input",
632
+ qualifiers: ["weighted by input tokens, not daily averages"],
633
+ },
634
+ {
635
+ label: "MEASUREMENT COVERAGE",
636
+ value: Number.isFinite(data.measurementCoveragePercent)
637
+ ? `${percent(data.measurementCoveragePercent)} of ${data.totalTokens > 0 ? "token volume" : "calls"}`
638
+ : "unknown",
639
+ qualifiers: footerQualifierLines(
640
+ measurementQualifier,
641
+ [measurementCounts, "include component detail"],
642
+ qualifierWidth,
643
+ ),
644
+ },
645
+ {
646
+ label: "DATA AS OF",
647
+ value: generatedAtLabel(snapshot.generatedAt, bounds.timeZone),
648
+ qualifiers: footerQualifierLines(
649
+ dataAsOfQualifier,
650
+ [bounds.timeZone, `${days}-day calendar window`],
651
+ qualifierWidth,
652
+ ),
653
+ },
654
+ ];
655
+ const qualifierLineCount = Math.max(
656
+ ...footerItems.map((item) => item.qualifiers.length),
657
+ );
658
+ const headerTitle = "TOKEN LEDGER · CACHE REPORT";
659
+ const headerMetadata = `${periodLabel(bounds)} · ${bounds.timeZone}`;
660
+ const headerTitleWidth = textWidth(headerTitle, 27, 800) -
661
+ 0.27 * (headerTitle.length - 1);
662
+ const headerMetadataFits = headerTitleWidth + textWidth(headerMetadata, 14) + 24 <=
663
+ contentRight - outer;
664
+
665
+ const ratePlotTop = 320;
666
+ const ratePlotHeight = 270;
667
+ const ratePlotBottom = ratePlotTop + ratePlotHeight;
668
+ const volumeTop = 635;
669
+ const volumeHeight = 55;
670
+ const volumeBottom = volumeTop + volumeHeight;
671
+ const legendBaseline = 770;
672
+ const modelRuleY = 800;
673
+ const modelHeaderBaseline = 830;
674
+ const modelRowsTop = 858;
675
+ const modelRowHeight = 44;
676
+ const modelRowCount = Math.max(1, models.length);
677
+ const footerRuleY = modelRowsTop + modelRowCount * modelRowHeight + 28;
678
+ const height = footerRuleY + 118 + Math.max(0, qualifierLineCount - 2) * 16;
679
+
680
+ const description =
681
+ "Dark cache report with a weighted cached-versus-uncached input split, normalized cache-rate columns with input-volume context, and a secondary model-level cache-rate breakout.";
682
+ const elements = [
683
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" role="img" aria-labelledby="cache-title cache-description">`,
684
+ `<title id="cache-title">${escapeXml(`Token Ledger · ${days}-day cache report`)}</title>`,
685
+ `<desc id="cache-description">${escapeXml(description)}</desc>`,
686
+ `<rect width="100%" height="100%" fill="${COLORS.background}"/>`,
687
+ svgText({
688
+ x: outer,
689
+ y: 53,
690
+ value: headerTitle,
691
+ size: 27,
692
+ weight: 800,
693
+ spacing: "-0.27",
694
+ }),
695
+ svgText({
696
+ x: contentRight,
697
+ y: headerMetadataFits ? 53 : 77,
698
+ value: headerMetadata,
699
+ fill: COLORS.muted,
700
+ size: 14,
701
+ anchor: "end",
702
+ }),
703
+ svgText({
704
+ x: outer,
705
+ y: 97,
706
+ value: "WEIGHTED INPUT CACHE RATE",
707
+ fill: COLORS.muted,
708
+ size: 12,
709
+ spacing: "1.32",
710
+ }),
711
+ ];
712
+
713
+ const summaryValue = Number.isFinite(data.rate)
714
+ ? `${percent(data.rate)} cached`
715
+ : "No measured input";
716
+ elements.push(svgText({
717
+ x: outer,
718
+ y: 135,
719
+ value: summaryValue,
720
+ size: 29,
721
+ weight: 800,
722
+ spacing: "-0.58",
723
+ }));
724
+ elements.push(svgText({
725
+ x: contentRight,
726
+ y: 133,
727
+ value: periodComparison(data.rate, prior.rate),
728
+ fill: COLORS.secondary,
729
+ size: 14,
730
+ weight: 600,
731
+ anchor: "end",
732
+ }));
733
+
734
+ const railTop = 153;
735
+ const railHeight = 52;
736
+ const railWidth = contentRight - outer;
737
+ elements.push(
738
+ `<defs><clipPath id="cache-coverage-clip">${svgRect(outer, railTop, railWidth, railHeight, { rx: 8 })}</clipPath></defs>`,
739
+ );
740
+ elements.push(`<g clip-path="url(#cache-coverage-clip)">`);
741
+ elements.push(svgRect(outer, railTop, railWidth, railHeight, {
742
+ fill: Number.isFinite(data.rate) ? COLORS.uncached : COLORS.track,
743
+ }));
744
+ const cachedRailWidth = Number.isFinite(data.rate)
745
+ ? railWidth * (data.rate / 100)
746
+ : 0;
747
+ if (cachedRailWidth > 0) {
748
+ elements.push(svgRect(outer, railTop, cachedRailWidth, railHeight, {
749
+ fill: COLORS.cached,
750
+ }));
751
+ }
752
+ elements.push("</g>");
753
+ if (cachedRailWidth >= 150) {
754
+ elements.push(svgText({
755
+ x: outer + cachedRailWidth / 2,
756
+ y: railTop + 32,
757
+ value: `CACHED · ${compact(data.cachedInputTokens)}`,
758
+ fill: COLORS.background,
759
+ size: 13,
760
+ weight: 800,
761
+ anchor: "middle",
762
+ spacing: ".65",
763
+ }));
764
+ }
765
+ const uncachedRailWidth = railWidth - cachedRailWidth;
766
+ if (Number.isFinite(data.rate) && uncachedRailWidth >= 150) {
767
+ elements.push(svgText({
768
+ x: outer + cachedRailWidth + uncachedRailWidth / 2,
769
+ y: railTop + 32,
770
+ value: `UNCACHED · ${compact(data.uncachedInputTokens)}`,
771
+ fill: COLORS.background,
772
+ size: 13,
773
+ weight: 800,
774
+ anchor: "middle",
775
+ spacing: ".65",
776
+ }));
777
+ }
778
+ elements.push(svgText({
779
+ x: outer,
780
+ y: 230,
781
+ value: Number.isFinite(data.rate)
782
+ ? `${compact(data.cachedInputTokens)} cached · ${compact(data.uncachedInputTokens)} uncached · ${compact(data.inputTokens)} total input`
783
+ : "No events with a usable input-token breakdown in this range",
784
+ fill: COLORS.secondary,
785
+ size: 14,
786
+ }));
787
+ elements.push(svgText({
788
+ x: contentRight,
789
+ y: 230,
790
+ value: `${data.inputEventCount.toLocaleString("en-US")} measured input-bearing ${data.inputEventCount === 1 ? "call" : "calls"}`,
791
+ fill: COLORS.muted,
792
+ size: 13,
793
+ anchor: "end",
794
+ }));
795
+
796
+ elements.push(svgText({
797
+ x: outer,
798
+ y: 283,
799
+ value: "CACHE RATE BY PERIOD",
800
+ fill: COLORS.muted,
801
+ size: 12,
802
+ spacing: "1.32",
803
+ }));
804
+ elements.push(svgText({
805
+ x: contentRight,
806
+ y: 283,
807
+ value: "Rate bars are normalized to 100% · input volume below",
808
+ fill: COLORS.muted,
809
+ size: 12.5,
810
+ anchor: "end",
811
+ }));
812
+
813
+ for (const value of [100, 75, 50, 25, 0]) {
814
+ const y = ratePlotBottom - (value / 100) * ratePlotHeight;
815
+ elements.push(
816
+ `<line x1="${plotLeft}" y1="${y.toFixed(2)}" x2="${plotRight}" y2="${y.toFixed(2)}" stroke="${value === 0 ? COLORS.baseline : COLORS.grid}" stroke-width="1"/>`,
817
+ );
818
+ elements.push(svgText({
819
+ x: plotLeft - 13,
820
+ y: y + 4,
821
+ value: `${value}%`,
822
+ fill: COLORS.muted,
823
+ size: 12.5,
824
+ anchor: "end",
825
+ mono: true,
826
+ }));
827
+ }
828
+
829
+ const slotWidth = plotWidth / data.binCount;
830
+ const barWidth = Math.min(72, Math.max(16, slotWidth * 0.58));
831
+ const observedMaxInput = Math.max(
832
+ 0,
833
+ ...data.bins.map((bin) => bin.inputTokens),
834
+ );
835
+ const maxInput = Math.max(1, observedMaxInput);
836
+ const dateStep = labelEvery(data.bins, slotWidth);
837
+ data.bins.forEach((bin, index) => {
838
+ const centerX = plotLeft + (index + 0.5) * slotWidth;
839
+ const barX = centerX - barWidth / 2;
840
+ if (Number.isFinite(bin.rate)) {
841
+ elements.push(svgRect(barX, ratePlotTop, barWidth, ratePlotHeight, {
842
+ rx: 4,
843
+ fill: COLORS.uncached,
844
+ opacity: ".88",
845
+ }));
846
+ const cachedHeight = ratePlotHeight * (bin.rate / 100);
847
+ if (cachedHeight > 0) {
848
+ elements.push(svgRect(
849
+ barX,
850
+ ratePlotBottom - cachedHeight,
851
+ barWidth,
852
+ cachedHeight,
853
+ { rx: 2, fill: COLORS.cached },
854
+ ));
855
+ }
856
+ if (slotWidth >= 50 && data.binCount <= 20) {
857
+ elements.push(svgText({
858
+ x: centerX,
859
+ y: ratePlotTop - 10,
860
+ value: percent(bin.rate),
861
+ fill: COLORS.secondary,
862
+ size: 12.5,
863
+ weight: 700,
864
+ anchor: "middle",
865
+ mono: true,
866
+ }));
867
+ }
868
+ } else {
869
+ elements.push(svgRect(barX, ratePlotTop, barWidth, ratePlotHeight, {
870
+ rx: 4,
871
+ fill: COLORS.track,
872
+ stroke: COLORS.baseline,
873
+ "stroke-width": 1,
874
+ }));
875
+ elements.push(
876
+ `<line x1="${barX + 5}" y1="${ratePlotTop + ratePlotHeight / 2}" x2="${barX + barWidth - 5}" y2="${ratePlotTop + ratePlotHeight / 2}" stroke="${COLORS.muted}" stroke-width="1"/>`,
877
+ );
878
+ }
879
+
880
+ const volumeBarHeight = (bin.inputTokens / maxInput) * volumeHeight;
881
+ if (volumeBarHeight > 0) {
882
+ elements.push(svgRect(
883
+ barX,
884
+ volumeBottom - volumeBarHeight,
885
+ barWidth,
886
+ volumeBarHeight,
887
+ { rx: 2, fill: COLORS.volume },
888
+ ));
889
+ }
890
+ const finalBinIndex = data.binCount - 1;
891
+ const showDateLabel = index === finalBinIndex || (
892
+ index % dateStep === 0 && finalBinIndex - index >= dateStep
893
+ );
894
+ if (showDateLabel) {
895
+ const daily = data.binSize === 1;
896
+ if (daily) {
897
+ elements.push(svgText({
898
+ x: centerX,
899
+ y: 718,
900
+ value: weekdayLabel(bin.startDateString),
901
+ fill: COLORS.muted,
902
+ size: 11.5,
903
+ anchor: "middle",
904
+ spacing: "1.15",
905
+ }));
906
+ }
907
+ elements.push(svgText({
908
+ x: centerX,
909
+ y: daily ? 739 : 730,
910
+ value: binDateLabel(bin),
911
+ fill: COLORS.secondary,
912
+ size: 13,
913
+ anchor: "middle",
914
+ }));
915
+ }
916
+ });
917
+
918
+ elements.push(svgText({
919
+ x: plotLeft - 13,
920
+ y: volumeTop + 4,
921
+ value: observedMaxInput > 0 ? compact(observedMaxInput) : "—",
922
+ fill: COLORS.muted,
923
+ size: 11.5,
924
+ anchor: "end",
925
+ mono: true,
926
+ }));
927
+ elements.push(svgText({
928
+ x: plotLeft - 13,
929
+ y: volumeBottom + 4,
930
+ value: "0",
931
+ fill: COLORS.muted,
932
+ size: 11.5,
933
+ anchor: "end",
934
+ mono: true,
935
+ }));
936
+ elements.push(svgText({
937
+ x: outer,
938
+ y: volumeTop - 10,
939
+ value: "INPUT",
940
+ fill: COLORS.muted,
941
+ size: 10.5,
942
+ spacing: "1.1",
943
+ }));
944
+
945
+ if (Number.isFinite(data.rate)) {
946
+ const lineY = ratePlotBottom - (data.rate / 100) * ratePlotHeight;
947
+ elements.push(
948
+ `<line x1="${plotLeft}" y1="${lineY.toFixed(2)}" x2="${plotRight}" y2="${lineY.toFixed(2)}" stroke="${COLORS.weighted}" stroke-width="1.6" stroke-dasharray="6 5"/>`,
949
+ );
950
+ elements.push(svgText({
951
+ x: plotRight + 8,
952
+ y: lineY + 4,
953
+ value: percent(data.rate),
954
+ fill: COLORS.weighted,
955
+ size: 11.5,
956
+ weight: 700,
957
+ mono: true,
958
+ }));
959
+ }
960
+
961
+ pushLegendItem(elements, {
962
+ x: outer,
963
+ y: legendBaseline,
964
+ color: COLORS.cached,
965
+ label: "Cached input",
966
+ });
967
+ pushLegendItem(elements, {
968
+ x: outer + 150,
969
+ y: legendBaseline,
970
+ color: COLORS.uncached,
971
+ label: "Uncached input",
972
+ });
973
+ pushLegendItem(elements, {
974
+ x: outer + 320,
975
+ y: legendBaseline,
976
+ color: COLORS.volume,
977
+ label: "Input volume",
978
+ });
979
+ pushLegendItem(elements, {
980
+ x: outer + 465,
981
+ y: legendBaseline,
982
+ color: COLORS.weighted,
983
+ label: "Weighted rate",
984
+ line: true,
985
+ });
986
+
987
+ elements.push(
988
+ `<line x1="${outer}" y1="${modelRuleY}" x2="${contentRight}" y2="${modelRuleY}" stroke="${COLORS.rule}" stroke-width="1"/>`,
989
+ );
990
+ elements.push(svgText({
991
+ x: outer,
992
+ y: modelHeaderBaseline,
993
+ value: "MODEL",
994
+ fill: COLORS.muted,
995
+ size: 11,
996
+ spacing: "1.1",
997
+ }));
998
+
999
+ const rateRight = outer + 170;
1000
+ const modelBarLeft = outer + 188;
1001
+ const modelBarRight = width - 330;
1002
+ const modelBarWidth = modelBarRight - modelBarLeft;
1003
+ const inputRight = width - 135;
1004
+ const shareRight = contentRight;
1005
+ elements.push(svgText({
1006
+ x: rateRight,
1007
+ y: modelHeaderBaseline,
1008
+ value: "CACHE RATE",
1009
+ fill: COLORS.muted,
1010
+ size: 11,
1011
+ anchor: "end",
1012
+ spacing: "1.1",
1013
+ }));
1014
+ elements.push(svgText({
1015
+ x: modelBarLeft,
1016
+ y: modelHeaderBaseline,
1017
+ value: "CACHED / UNCACHED INPUT",
1018
+ fill: COLORS.muted,
1019
+ size: 11,
1020
+ spacing: "1.1",
1021
+ }));
1022
+ elements.push(svgText({
1023
+ x: inputRight,
1024
+ y: modelHeaderBaseline,
1025
+ value: "INPUT",
1026
+ fill: COLORS.muted,
1027
+ size: 11,
1028
+ anchor: "end",
1029
+ spacing: "1.1",
1030
+ }));
1031
+ elements.push(svgText({
1032
+ x: shareRight,
1033
+ y: modelHeaderBaseline,
1034
+ value: "SHARE",
1035
+ fill: COLORS.muted,
1036
+ size: 11,
1037
+ anchor: "end",
1038
+ spacing: "1.1",
1039
+ }));
1040
+
1041
+ if (models.length === 0) {
1042
+ elements.push(svgText({
1043
+ x: outer,
1044
+ y: modelRowsTop + 27,
1045
+ value: "No measured input tokens to break out by model.",
1046
+ fill: COLORS.secondary,
1047
+ size: 14,
1048
+ }));
1049
+ }
1050
+ models.forEach((model, index) => {
1051
+ const centerY = modelRowsTop + index * modelRowHeight + 23;
1052
+ const modelShare = data.inputTokens > 0
1053
+ ? (model.inputTokens / data.inputTokens) * 100
1054
+ : 0;
1055
+ elements.push(
1056
+ `<circle cx="${outer + 5}" cy="${centerY - 4}" r="4.5" fill="${modelColor(model.model)}"/>`,
1057
+ );
1058
+ elements.push(svgText({
1059
+ x: outer + 18,
1060
+ y: centerY,
1061
+ value: model.model,
1062
+ fill: COLORS.ink,
1063
+ size: 14,
1064
+ weight: 700,
1065
+ }));
1066
+ elements.push(svgText({
1067
+ x: rateRight,
1068
+ y: centerY,
1069
+ value: percent(model.rate),
1070
+ fill: COLORS.secondary,
1071
+ size: 13,
1072
+ weight: 700,
1073
+ anchor: "end",
1074
+ mono: true,
1075
+ }));
1076
+ elements.push(svgRect(modelBarLeft, centerY - 11, modelBarWidth, 11, {
1077
+ rx: 3,
1078
+ fill: COLORS.uncached,
1079
+ opacity: ".7",
1080
+ }));
1081
+ const fillWidth = modelBarWidth * (model.rate / 100);
1082
+ if (fillWidth > 0) {
1083
+ elements.push(svgRect(modelBarLeft, centerY - 11, fillWidth, 11, {
1084
+ rx: 3,
1085
+ fill: COLORS.cached,
1086
+ }));
1087
+ }
1088
+ elements.push(svgText({
1089
+ x: inputRight,
1090
+ y: centerY,
1091
+ value: compact(model.inputTokens),
1092
+ fill: COLORS.secondary,
1093
+ size: 13,
1094
+ weight: 700,
1095
+ anchor: "end",
1096
+ mono: true,
1097
+ }));
1098
+ elements.push(svgText({
1099
+ x: shareRight,
1100
+ y: centerY,
1101
+ value: percent(modelShare),
1102
+ fill: COLORS.muted,
1103
+ size: 13,
1104
+ anchor: "end",
1105
+ mono: true,
1106
+ }));
1107
+ });
1108
+
1109
+ elements.push(
1110
+ `<line x1="${outer}" y1="${footerRuleY}" x2="${contentRight}" y2="${footerRuleY}" stroke="${COLORS.rule}" stroke-width="1"/>`,
1111
+ );
1112
+ const footerTop = footerRuleY + 22;
1113
+ footerItems.forEach((item, index) => {
1114
+ const columnX = outer + index * columnWidth;
1115
+ const x = index === 0 ? columnX : columnX + 22;
1116
+ if (index > 0) {
1117
+ elements.push(
1118
+ `<line x1="${columnX.toFixed(2)}" y1="${footerTop}" x2="${columnX.toFixed(2)}" y2="${footerTop + 53 + (qualifierLineCount - 1) * 16}" stroke="${COLORS.rule}" stroke-width="1"/>`,
1119
+ );
1120
+ }
1121
+ elements.push(svgText({
1122
+ x,
1123
+ y: footerTop + 10,
1124
+ value: item.label,
1125
+ fill: COLORS.muted,
1126
+ size: 11,
1127
+ spacing: "1.1",
1128
+ }));
1129
+ elements.push(svgText({
1130
+ x,
1131
+ y: footerTop + 33,
1132
+ value: item.value,
1133
+ fill: COLORS.ink,
1134
+ size: 15,
1135
+ weight: 700,
1136
+ }));
1137
+ item.qualifiers.forEach((qualifier, qualifierIndex) => {
1138
+ elements.push(svgText({
1139
+ x,
1140
+ y: footerTop + 53 + qualifierIndex * 16,
1141
+ value: qualifier,
1142
+ fill: COLORS.muted,
1143
+ size: 12,
1144
+ }));
1145
+ });
1146
+ });
1147
+
1148
+ elements.push("</svg>");
1149
+ return elements.join("\n");
1150
+ }