tledger 0.3.0 → 0.3.1
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 +3 -1
- package/bin/token-ledger-trend-image.mjs +181 -42
- package/bin/token-ledger-trend-terminal.mjs +1 -1
- package/bin/token-ledger-trend.mjs +61 -15
- package/bin/token-ledger.mjs +30 -4
- package/lib/token-ledger-importer.mjs +18 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -159,7 +159,9 @@ named pools are not stitched into that meter. Remaining percentage,
|
|
|
159
159
|
observation time, and the selected window are local observations; the reset
|
|
160
160
|
type (weekly expiry versus restart) is derived by comparing the prior window's
|
|
161
161
|
reset timestamp with the first reading of the new window. None of this is an
|
|
162
|
-
official account-wide quota or billing record.
|
|
162
|
+
official account-wide quota or billing record. The percentage comes from the
|
|
163
|
+
newest OpenAI reading recorded in a completed local Codex response; Token
|
|
164
|
+
Ledger does not invent a newer percentage from token counts.
|
|
163
165
|
|
|
164
166
|
The exported snapshot contains token metadata, model/use-type labels, project
|
|
165
167
|
labels, and display titles. It omits message bodies, reasoning text, tool
|
|
@@ -74,6 +74,7 @@ const FONT_FAMILY = "system-ui, -apple-system, 'Segoe UI', sans-serif";
|
|
|
74
74
|
const MONO_FAMILY = "ui-monospace, Menlo, monospace";
|
|
75
75
|
const FAST_MODE_LABEL_COLOR = "#a78bfa";
|
|
76
76
|
const MIN_BAR_WIDTH = 26;
|
|
77
|
+
const METER_PANEL_HEADING = "WEEKLY LIMIT · PACE & RUNWAY";
|
|
77
78
|
|
|
78
79
|
export function escapeXml(value) {
|
|
79
80
|
return String(value)
|
|
@@ -198,7 +199,7 @@ function zonedMidnight(dateString, timeZone) {
|
|
|
198
199
|
const [year, month, day] = dateParts(dateString);
|
|
199
200
|
const utcGuess = Date.UTC(year, month - 1, day);
|
|
200
201
|
const first = new Date(utcGuess - timeZoneOffsetMs(new Date(utcGuess), timeZone));
|
|
201
|
-
return new Date(
|
|
202
|
+
return new Date(utcGuess - timeZoneOffsetMs(first, timeZone));
|
|
202
203
|
}
|
|
203
204
|
|
|
204
205
|
function localDateLabel(dateString, timeZone) {
|
|
@@ -225,6 +226,26 @@ function timestampDateLabel(timestampMs, timeZone) {
|
|
|
225
226
|
}).format(new Date(timestampMs));
|
|
226
227
|
}
|
|
227
228
|
|
|
229
|
+
function timestampReadLabel(timestampMs, timeZone) {
|
|
230
|
+
if (!Number.isFinite(timestampMs)) return "unknown";
|
|
231
|
+
return new Intl.DateTimeFormat("en-US", {
|
|
232
|
+
timeZone,
|
|
233
|
+
month: "short",
|
|
234
|
+
day: "numeric",
|
|
235
|
+
hour: "numeric",
|
|
236
|
+
minute: "2-digit",
|
|
237
|
+
}).format(new Date(timestampMs));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function timestampTimeLabel(timestampMs, timeZone) {
|
|
241
|
+
if (!Number.isFinite(timestampMs)) return "unknown";
|
|
242
|
+
return new Intl.DateTimeFormat("en-US", {
|
|
243
|
+
timeZone,
|
|
244
|
+
hour: "numeric",
|
|
245
|
+
minute: "2-digit",
|
|
246
|
+
}).format(new Date(timestampMs));
|
|
247
|
+
}
|
|
248
|
+
|
|
228
249
|
function binDateLabel(bin, timeZone) {
|
|
229
250
|
const start = localDateLabel(bin.startDateString, timeZone);
|
|
230
251
|
const lastDate = shiftCalendarDate(bin.endDateString, -1);
|
|
@@ -409,7 +430,7 @@ export function renderTrendImage({
|
|
|
409
430
|
const maxBar = niceCeiling(
|
|
410
431
|
bars.reduce((maximum, bin) => Math.max(maximum, binTotalOf(bin)), 0),
|
|
411
432
|
);
|
|
412
|
-
const hasLine = Boolean(trend.available && (trend.points ?? []).length >
|
|
433
|
+
const hasLine = Boolean(trend.available && (trend.points ?? []).length > 0);
|
|
413
434
|
|
|
414
435
|
const totalTokens = [...actual.totals.values()].reduce((sum, value) => sum + value, 0);
|
|
415
436
|
const fastTokens = [...(actual.fastTotals?.values() ?? [])].reduce(
|
|
@@ -441,7 +462,9 @@ export function renderTrendImage({
|
|
|
441
462
|
}).totals;
|
|
442
463
|
|
|
443
464
|
const latestQuotaPoint = [...(trend.points ?? [])]
|
|
444
|
-
.filter(
|
|
465
|
+
.filter(
|
|
466
|
+
(point) => point.observed && point.timestampMs <= bounds.end.getTime(),
|
|
467
|
+
)
|
|
445
468
|
.at(-1);
|
|
446
469
|
const latestQuotaReadMs = Number.isFinite(trend.observedThroughMs)
|
|
447
470
|
? trend.observedThroughMs
|
|
@@ -610,6 +633,57 @@ export function renderTrendImage({
|
|
|
610
633
|
120,
|
|
611
634
|
);
|
|
612
635
|
const height = bottomTop + bottomBlockHeight + 34;
|
|
636
|
+
const rangeStartMs = bounds.start.getTime();
|
|
637
|
+
const rangeEndMs = bounds.end.getTime();
|
|
638
|
+
const requestedReportTimeMs = Number.isFinite(options.reportTimeMs)
|
|
639
|
+
? options.reportTimeMs
|
|
640
|
+
: generatedAtMs;
|
|
641
|
+
const reportTimeMs = Number.isFinite(requestedReportTimeMs) &&
|
|
642
|
+
requestedReportTimeMs > rangeStartMs && requestedReportTimeMs < rangeEndMs
|
|
643
|
+
? requestedReportTimeMs
|
|
644
|
+
: null;
|
|
645
|
+
const slotWidth = plotWidth / binCount;
|
|
646
|
+
const binTimeRanges = actual.bins.map((bin) => ({
|
|
647
|
+
startMs: zonedMidnight(bin.startDateString, bounds.timeZone).getTime(),
|
|
648
|
+
endMs: zonedMidnight(bin.endDateString, bounds.timeZone).getTime(),
|
|
649
|
+
}));
|
|
650
|
+
const finalBinTimeRange = binTimeRanges.at(-1);
|
|
651
|
+
const partialFinalBin = Boolean(
|
|
652
|
+
reportTimeMs !== null &&
|
|
653
|
+
finalBinTimeRange &&
|
|
654
|
+
reportTimeMs > finalBinTimeRange.startMs &&
|
|
655
|
+
reportTimeMs < finalBinTimeRange.endMs,
|
|
656
|
+
);
|
|
657
|
+
// The x axis is made of equal calendar-period slots. On an incomplete final
|
|
658
|
+
// day, stretch only the elapsed part of that slot so report time lands on
|
|
659
|
+
// the right edge instead of reserving space for hours that have not happened.
|
|
660
|
+
const xForTimestamp = (timestampMs) => {
|
|
661
|
+
if (!(timestampMs > rangeStartMs)) return plotLeft;
|
|
662
|
+
if (timestampMs >= (partialFinalBin ? reportTimeMs : rangeEndMs)) {
|
|
663
|
+
return plotRight;
|
|
664
|
+
}
|
|
665
|
+
let binIndex = binTimeRanges.findIndex(
|
|
666
|
+
(range) => timestampMs >= range.startMs && timestampMs < range.endMs,
|
|
667
|
+
);
|
|
668
|
+
if (binIndex < 0) {
|
|
669
|
+
binIndex = timestampMs < rangeStartMs ? 0 : binCount - 1;
|
|
670
|
+
}
|
|
671
|
+
const range = binTimeRanges[binIndex];
|
|
672
|
+
const effectiveEndMs = partialFinalBin && binIndex === binCount - 1
|
|
673
|
+
? reportTimeMs
|
|
674
|
+
: range.endMs;
|
|
675
|
+
const span = Math.max(1, effectiveEndMs - range.startMs);
|
|
676
|
+
const ratio = Math.max(
|
|
677
|
+
0,
|
|
678
|
+
Math.min(1, (timestampMs - range.startMs) / span),
|
|
679
|
+
);
|
|
680
|
+
return plotLeft + (binIndex + ratio) * slotWidth;
|
|
681
|
+
};
|
|
682
|
+
const yForRemaining = (value) =>
|
|
683
|
+
plotTop + (1 - Math.max(0, Math.min(100, value)) / 100) * plotHeight;
|
|
684
|
+
const reportTimeX = reportTimeMs === null
|
|
685
|
+
? null
|
|
686
|
+
: xForTimestamp(reportTimeMs);
|
|
613
687
|
|
|
614
688
|
const yearLabel = bounds.endDateString.slice(0, 4);
|
|
615
689
|
const title = percentMode
|
|
@@ -617,11 +691,11 @@ export function renderTrendImage({
|
|
|
617
691
|
: `TOKEN LEDGER · ${days}-DAY TREND`;
|
|
618
692
|
const subtitle = `${localDateLabel(bounds.startDateString, bounds.timeZone)} – ${localDateLabel(bounds.endDateString, bounds.timeZone)}, ${yearLabel} · ${bounds.timeZone}`;
|
|
619
693
|
const description = percentMode
|
|
620
|
-
? "Dark report card: compact actual-token stat cards beside pace and runway, stacked columns of observed weekly-meter drain with an explicitly estimated per-model split, the
|
|
621
|
-
: "Dark report card: compact model stat cards with week-over-week delta chips beside pace and runway, stacked columns of local token volume by model with fast-mode usage in a darker shade, the
|
|
694
|
+
? "Dark report card: compact actual-token stat cards beside pace and runway, stacked columns of observed weekly-meter drain with an explicitly estimated per-model split, the OpenAI-reported weekly limit remaining as an amber line, a partial final day ending at report time, a compressed cache-rate-by-period strip, and top projects beside per-model cache rates."
|
|
695
|
+
: "Dark report card: compact model stat cards with week-over-week delta chips beside pace and runway, stacked columns of local token volume by model with fast-mode usage in a darker shade, the OpenAI-reported weekly limit remaining as a smoothed amber line, a partial final day ending at report time, a compressed cache-rate-by-period strip, and top projects beside per-model cache rates.";
|
|
622
696
|
|
|
623
697
|
const elements = [
|
|
624
|
-
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" role="img" aria-labelledby="trend-title trend-description" data-report-mode="${percentMode ? "meter-drain" : "actual-tokens"}">`,
|
|
698
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" role="img" aria-labelledby="trend-title trend-description" data-report-mode="${percentMode ? "meter-drain" : "actual-tokens"}" data-time-domain="${partialFinalBin ? "through-report" : "full-range"}">`,
|
|
625
699
|
`<title id="trend-title">${escapeXml(percentMode ? `Token Ledger · ${days}-day meter drain` : `Token Ledger · ${days}-day trend`)}</title>`,
|
|
626
700
|
`<desc id="trend-description">${escapeXml(description)}</desc>`,
|
|
627
701
|
`<defs><clipPath id="trend-plot-clip"><rect x="${plotLeft}" y="${plotTop}" width="${plotWidth}" height="${plotHeight}"/></clipPath></defs>`,
|
|
@@ -719,13 +793,27 @@ export function renderTrendImage({
|
|
|
719
793
|
if (hasLine && latestQuotaPoint) {
|
|
720
794
|
const lastReset = resetsInRange.at(-1);
|
|
721
795
|
const resetCaption = lastReset
|
|
722
|
-
? `reset ${timestampDateLabel(lastReset.timestampMs, bounds.timeZone)}`
|
|
796
|
+
? `last reset ${timestampDateLabel(lastReset.timestampMs, bounds.timeZone)}`
|
|
723
797
|
: latestResetsAtSec
|
|
724
|
-
? `
|
|
798
|
+
? `next reset ${timestampDateLabel(latestResetsAtSec * 1_000, bounds.timeZone)}`
|
|
725
799
|
: "no reset in range";
|
|
800
|
+
const meterCaption = (() => {
|
|
801
|
+
if (latestQuotaReadMs === null) return resetCaption;
|
|
802
|
+
const candidates = [
|
|
803
|
+
`${resetCaption} · OpenAI reading ${timestampReadLabel(latestQuotaReadMs, bounds.timeZone)}`,
|
|
804
|
+
`OpenAI reading · ${timestampReadLabel(latestQuotaReadMs, bounds.timeZone)}`,
|
|
805
|
+
`OpenAI reading · ${timestampTimeLabel(latestQuotaReadMs, bounds.timeZone)}`,
|
|
806
|
+
];
|
|
807
|
+
const available = Math.max(
|
|
808
|
+
80,
|
|
809
|
+
paceInnerWidth - 15 - textWidth(METER_PANEL_HEADING, 10.5) - 18,
|
|
810
|
+
);
|
|
811
|
+
return candidates.find((candidate) => textWidth(candidate, 10.5) <= available) ??
|
|
812
|
+
`reported ${timestampTimeLabel(latestQuotaReadMs, bounds.timeZone)}`;
|
|
813
|
+
})();
|
|
726
814
|
meterCard = ({
|
|
727
815
|
swatch: COLORS.line,
|
|
728
|
-
label: "Weekly
|
|
816
|
+
label: "Weekly limit",
|
|
729
817
|
labelColor: COLORS.meterAxis,
|
|
730
818
|
value: meterLabel(latestQuotaPoint.remainingPercent),
|
|
731
819
|
valueColor: COLORS.line,
|
|
@@ -734,9 +822,7 @@ export function renderTrendImage({
|
|
|
734
822
|
track: "rgba(246,183,60,.2)",
|
|
735
823
|
fill: COLORS.line,
|
|
736
824
|
barPercent: latestQuotaPoint.remainingPercent,
|
|
737
|
-
caption:
|
|
738
|
-
? resetCaption
|
|
739
|
-
: `${resetCaption} · read ${timestampDateLabel(latestQuotaReadMs, bounds.timeZone)}`,
|
|
825
|
+
caption: meterCaption,
|
|
740
826
|
captionShort: resetCaption,
|
|
741
827
|
panel: COLORS.meterPanel,
|
|
742
828
|
border: COLORS.meterPanelBorder,
|
|
@@ -870,7 +956,7 @@ export function renderTrendImage({
|
|
|
870
956
|
elements.push(svgText({
|
|
871
957
|
x: paceTextX + (meterCard ? 15 : 0),
|
|
872
958
|
y: cardTop + 23,
|
|
873
|
-
value: meterCard ?
|
|
959
|
+
value: meterCard ? METER_PANEL_HEADING : "PACE & RUNWAY",
|
|
874
960
|
fill: meterCard ? meterCard.labelColor : COLORS.muted,
|
|
875
961
|
size: 10.5,
|
|
876
962
|
spacing: "1.2",
|
|
@@ -1060,7 +1146,7 @@ export function renderTrendImage({
|
|
|
1060
1146
|
elements.push(svgText({
|
|
1061
1147
|
x: plotRight,
|
|
1062
1148
|
y: chartBlockTop + 18,
|
|
1063
|
-
value: "WEEKLY
|
|
1149
|
+
value: "WEEKLY LIMIT · OPENAI REPORTED",
|
|
1064
1150
|
fill: COLORS.meterAxis,
|
|
1065
1151
|
size: 11.5,
|
|
1066
1152
|
anchor: "end",
|
|
@@ -1069,7 +1155,6 @@ export function renderTrendImage({
|
|
|
1069
1155
|
}
|
|
1070
1156
|
|
|
1071
1157
|
// ---- Bars ----
|
|
1072
|
-
const slotWidth = plotWidth / binCount;
|
|
1073
1158
|
const barWidth = Math.min(74, Math.max(MIN_BAR_WIDTH, slotWidth * 0.6));
|
|
1074
1159
|
const barGeometry = bars.map((bin, binIndex) => {
|
|
1075
1160
|
const centerX = plotLeft + (binIndex + 0.5) * slotWidth;
|
|
@@ -1137,17 +1222,10 @@ export function renderTrendImage({
|
|
|
1137
1222
|
}
|
|
1138
1223
|
|
|
1139
1224
|
// ---- Meter line: per-cycle smoothed segments with reset breaks ----
|
|
1140
|
-
const yForRemaining = (value) =>
|
|
1141
|
-
plotTop + (1 - Math.max(0, Math.min(100, value)) / 100) * plotHeight;
|
|
1142
|
-
const xForTimestamp = (timestampMs) => {
|
|
1143
|
-
const span = bounds.end.getTime() - bounds.start.getTime();
|
|
1144
|
-
const ratio = span > 0 ? (timestampMs - bounds.start.getTime()) / span : 0;
|
|
1145
|
-
return plotLeft + Math.max(0, Math.min(1, ratio)) * plotWidth;
|
|
1146
|
-
};
|
|
1147
|
-
|
|
1148
1225
|
let resetMarks = [];
|
|
1149
1226
|
let binDots = [];
|
|
1150
1227
|
let pills = [];
|
|
1228
|
+
let hasHeldSegment = false;
|
|
1151
1229
|
const lineSegments = [];
|
|
1152
1230
|
if (hasLine) {
|
|
1153
1231
|
const cycles = new Map();
|
|
@@ -1223,24 +1301,22 @@ export function renderTrendImage({
|
|
|
1223
1301
|
});
|
|
1224
1302
|
}
|
|
1225
1303
|
|
|
1226
|
-
//
|
|
1227
|
-
//
|
|
1228
|
-
// marker, leaving a visible gap that looked like a broken series.
|
|
1304
|
+
// Keep a visual connection to a known reset boundary, but render the
|
|
1305
|
+
// unsampled hold as dashed instead of making it look observed.
|
|
1229
1306
|
const nextCycleId = orderedCycles[cycleIndex + 1]?.[0];
|
|
1230
1307
|
const nextReset = resetByCycle.get(nextCycleId);
|
|
1231
1308
|
const lastPoint = points.at(-1);
|
|
1232
|
-
|
|
1309
|
+
const resetCarry =
|
|
1233
1310
|
nextReset &&
|
|
1234
1311
|
lastPoint &&
|
|
1235
1312
|
lastPoint.timestampMs < nextReset.timestampMs
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
}
|
|
1313
|
+
? [lastPoint, {
|
|
1314
|
+
...lastPoint,
|
|
1315
|
+
x: nextReset.x,
|
|
1316
|
+
timestampMs: nextReset.timestampMs,
|
|
1317
|
+
carriedToReset: true,
|
|
1318
|
+
}]
|
|
1319
|
+
: null;
|
|
1244
1320
|
|
|
1245
1321
|
// Thin to at most one point per 2px so the path stays light while the
|
|
1246
1322
|
// spline still follows every meaningful movement.
|
|
@@ -1264,6 +1340,37 @@ export function renderTrendImage({
|
|
|
1264
1340
|
elements.push(`<path d="${path}" fill="none" stroke="${COLORS.line}" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" clip-path="url(#trend-plot-clip)" data-series="weekly-meter" data-cycle="${escapeXml(cycleId)}"/>`);
|
|
1265
1341
|
lineSegments.push(thinned);
|
|
1266
1342
|
}
|
|
1343
|
+
if (resetCarry) {
|
|
1344
|
+
const [from, to] = resetCarry;
|
|
1345
|
+
const heldPath = `M ${from.x.toFixed(2)} ${from.y.toFixed(2)} L ${to.x.toFixed(2)} ${to.y.toFixed(2)}`;
|
|
1346
|
+
elements.push(`<path d="${heldPath}" fill="none" stroke="${COLORS.background}" stroke-width="5.5" stroke-linecap="round" opacity=".72" clip-path="url(#trend-plot-clip)"/>`);
|
|
1347
|
+
elements.push(`<path d="${heldPath}" fill="none" stroke="${COLORS.line}" stroke-width="2.25" stroke-linecap="round" stroke-dasharray="5 6" opacity=".7" clip-path="url(#trend-plot-clip)" data-series="weekly-meter-held" data-reason="reset" data-cycle="${escapeXml(cycleId)}"/>`);
|
|
1348
|
+
lineSegments.push(resetCarry);
|
|
1349
|
+
hasHeldSegment = true;
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
const latestObservedPoint = [...(trend.points ?? [])]
|
|
1354
|
+
.filter(
|
|
1355
|
+
(point) =>
|
|
1356
|
+
point.observed &&
|
|
1357
|
+
reportTimeMs !== null &&
|
|
1358
|
+
point.timestampMs <= reportTimeMs,
|
|
1359
|
+
)
|
|
1360
|
+
.at(-1);
|
|
1361
|
+
if (latestObservedPoint && reportTimeMs !== null) {
|
|
1362
|
+
const from = {
|
|
1363
|
+
x: xForTimestamp(latestObservedPoint.timestampMs),
|
|
1364
|
+
y: yForRemaining(latestObservedPoint.remainingPercent),
|
|
1365
|
+
};
|
|
1366
|
+
const to = { x: xForTimestamp(reportTimeMs), y: from.y };
|
|
1367
|
+
if (to.x - from.x >= 2) {
|
|
1368
|
+
const heldPath = `M ${from.x.toFixed(2)} ${from.y.toFixed(2)} L ${to.x.toFixed(2)} ${to.y.toFixed(2)}`;
|
|
1369
|
+
elements.push(`<path d="${heldPath}" fill="none" stroke="${COLORS.background}" stroke-width="5.5" stroke-linecap="round" opacity=".72" clip-path="url(#trend-plot-clip)"/>`);
|
|
1370
|
+
elements.push(`<path d="${heldPath}" fill="none" stroke="${COLORS.line}" stroke-width="2.25" stroke-linecap="round" stroke-dasharray="5 6" opacity=".7" clip-path="url(#trend-plot-clip)" data-series="weekly-meter-held" data-reason="report-time"/>`);
|
|
1371
|
+
lineSegments.push([from, to]);
|
|
1372
|
+
hasHeldSegment = true;
|
|
1373
|
+
}
|
|
1267
1374
|
}
|
|
1268
1375
|
|
|
1269
1376
|
// One dot per labeled column: the last observation inside that column.
|
|
@@ -1391,6 +1498,20 @@ export function renderTrendImage({
|
|
|
1391
1498
|
});
|
|
1392
1499
|
}
|
|
1393
1500
|
|
|
1501
|
+
if (reportTimeX !== null) {
|
|
1502
|
+
elements.push(`<line x1="${reportTimeX.toFixed(2)}" y1="${plotTop}" x2="${reportTimeX.toFixed(2)}" y2="${plotBottom}" stroke="${COLORS.muted}" stroke-width="1.25" stroke-dasharray="4 5" opacity=".72" data-marker="report-time"/>`);
|
|
1503
|
+
elements.push(svgText({
|
|
1504
|
+
x: reportTimeX - 7,
|
|
1505
|
+
y: plotTop - 8,
|
|
1506
|
+
value: `AS OF ${timestampTimeLabel(reportTimeMs, bounds.timeZone).toUpperCase()}`,
|
|
1507
|
+
fill: COLORS.muted,
|
|
1508
|
+
size: 10.5,
|
|
1509
|
+
weight: 700,
|
|
1510
|
+
anchor: "end",
|
|
1511
|
+
spacing: ".55",
|
|
1512
|
+
}));
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1394
1515
|
// ---- Bar totals and day labels (drawn over the line like the labels) ----
|
|
1395
1516
|
elements.push(...segmentLabels);
|
|
1396
1517
|
// Where the line passes through a horizontal span at band height, from the
|
|
@@ -1466,6 +1587,18 @@ export function renderTrendImage({
|
|
|
1466
1587
|
size: 15,
|
|
1467
1588
|
anchor: "middle",
|
|
1468
1589
|
}));
|
|
1590
|
+
if (partialFinalBin && binIndex === binCount - 1) {
|
|
1591
|
+
elements.push(svgText({
|
|
1592
|
+
x: centerX,
|
|
1593
|
+
y: plotBottom + 70,
|
|
1594
|
+
value: `PARTIAL · THROUGH ${timestampTimeLabel(reportTimeMs, bounds.timeZone).toUpperCase()}`,
|
|
1595
|
+
fill: COLORS.muted,
|
|
1596
|
+
size: 10.5,
|
|
1597
|
+
weight: 700,
|
|
1598
|
+
anchor: "middle",
|
|
1599
|
+
spacing: ".45",
|
|
1600
|
+
}));
|
|
1601
|
+
}
|
|
1469
1602
|
}
|
|
1470
1603
|
}
|
|
1471
1604
|
for (const pill of pills) {
|
|
@@ -1523,13 +1656,19 @@ export function renderTrendImage({
|
|
|
1523
1656
|
);
|
|
1524
1657
|
}
|
|
1525
1658
|
if (hasLine) {
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1659
|
+
if (hasHeldSegment) {
|
|
1660
|
+
legendItem(
|
|
1661
|
+
`<line x1="${legendX}" y1="${legendBaseline - 5}" x2="${legendX + 17}" y2="${legendBaseline - 5}" stroke="${COLORS.line}" stroke-width="3"/><line x1="${legendX + 25}" y1="${legendBaseline - 5}" x2="${legendX + 42}" y2="${legendBaseline - 5}" stroke="${COLORS.line}" stroke-width="2.25" stroke-dasharray="5 5" opacity=".7"/>`,
|
|
1662
|
+
42,
|
|
1663
|
+
"Limit: reported / awaiting update",
|
|
1664
|
+
);
|
|
1665
|
+
} else {
|
|
1666
|
+
legendItem(
|
|
1667
|
+
svgRect(legendX, legendBaseline - 6, 20, 3, { fill: COLORS.line }),
|
|
1668
|
+
20,
|
|
1669
|
+
"OpenAI weekly-limit reading",
|
|
1670
|
+
);
|
|
1671
|
+
}
|
|
1533
1672
|
}
|
|
1534
1673
|
|
|
1535
1674
|
// ---- Cache rate by period (compressed strip) ----
|
|
@@ -166,7 +166,7 @@ function zonedMidnight(
|
|
|
166
166
|
const [year, month, day] = dateParts(dateString);
|
|
167
167
|
const utcGuess = Date.UTC(year, month - 1, day);
|
|
168
168
|
const first = new Date(utcGuess - timeZoneOffsetMs(new Date(utcGuess), formatter));
|
|
169
|
-
return new Date(
|
|
169
|
+
return new Date(utcGuess - timeZoneOffsetMs(first, formatter));
|
|
170
170
|
}
|
|
171
171
|
|
|
172
172
|
function localDateString(
|
|
@@ -151,12 +151,20 @@ export function trendModelLabel(value) {
|
|
|
151
151
|
|
|
152
152
|
export function weeklyQuotaObservations(snapshot = {}) {
|
|
153
153
|
let observations = (snapshot.quotaObservations ?? [])
|
|
154
|
-
.map((observation) =>
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
154
|
+
.map((observation) => {
|
|
155
|
+
const timestampMs = finiteTimestamp(observation.timestamp);
|
|
156
|
+
const lastSeenAtMs = finiteTimestamp(observation.lastSeenAt);
|
|
157
|
+
return {
|
|
158
|
+
...observation,
|
|
159
|
+
timestampMs,
|
|
160
|
+
observedThroughMs:
|
|
161
|
+
timestampMs === null
|
|
162
|
+
? lastSeenAtMs
|
|
163
|
+
: Math.max(timestampMs, lastSeenAtMs ?? timestampMs),
|
|
164
|
+
resetsAt: Number(observation.resetsAt),
|
|
165
|
+
usedPercent: Number(observation.usedPercent),
|
|
166
|
+
};
|
|
167
|
+
})
|
|
160
168
|
.filter(
|
|
161
169
|
(observation) =>
|
|
162
170
|
Number(observation.windowMinutes) === WEEK_MINUTES &&
|
|
@@ -277,6 +285,12 @@ export function normalizeQuotaTimeline(observations) {
|
|
|
277
285
|
: Math.max(usedPercent, observation.usedPercent);
|
|
278
286
|
normalized.push({
|
|
279
287
|
...observation,
|
|
288
|
+
observedThroughMs: Math.min(
|
|
289
|
+
Number.isFinite(observation.observedThroughMs)
|
|
290
|
+
? observation.observedThroughMs
|
|
291
|
+
: observation.timestampMs,
|
|
292
|
+
nextFirstMs,
|
|
293
|
+
),
|
|
280
294
|
cycle,
|
|
281
295
|
reset: !emitted && previousEpoch !== null,
|
|
282
296
|
resetKind,
|
|
@@ -617,6 +631,7 @@ export function buildUsageTrend(snapshot = {}, bounds) {
|
|
|
617
631
|
}
|
|
618
632
|
points.push({
|
|
619
633
|
timestampMs: observation.timestampMs,
|
|
634
|
+
observedThroughMs: observation.observedThroughMs,
|
|
620
635
|
cycle: observation.cycle,
|
|
621
636
|
usedPercent: observation.normalizedUsedPercent,
|
|
622
637
|
remainingPercent: 100 - observation.normalizedUsedPercent,
|
|
@@ -645,15 +660,46 @@ export function buildUsageTrend(snapshot = {}, bounds) {
|
|
|
645
660
|
(point) => point.timestampMs > startMs && point.timestampMs < endMs,
|
|
646
661
|
),
|
|
647
662
|
);
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
663
|
+
|
|
664
|
+
// Repeated equal meter readings are compacted into an observed span. Extend
|
|
665
|
+
// each displayed cycle only through its last real sample; never synthesize a
|
|
666
|
+
// flat line through the unobserved remainder of the report range.
|
|
667
|
+
const sourcePointsByCycle = new Map();
|
|
668
|
+
for (const point of points) {
|
|
669
|
+
const cyclePoints = sourcePointsByCycle.get(point.cycle) ?? [];
|
|
670
|
+
cyclePoints.push(point);
|
|
671
|
+
sourcePointsByCycle.set(point.cycle, cyclePoints);
|
|
656
672
|
}
|
|
673
|
+
const displayedCycles = new Set(displayPoints.map((point) => point.cycle));
|
|
674
|
+
for (const cycle of displayedCycles) {
|
|
675
|
+
const cyclePoints = sourcePointsByCycle.get(cycle) ?? [];
|
|
676
|
+
const displayedCyclePoints = displayPoints.filter(
|
|
677
|
+
(point) => point.cycle === cycle,
|
|
678
|
+
);
|
|
679
|
+
const lastPoint = displayedCyclePoints.at(-1);
|
|
680
|
+
if (!lastPoint || !cyclePoints.length) continue;
|
|
681
|
+
const observedThroughMs = Math.max(
|
|
682
|
+
...cyclePoints.map((point) => point.observedThroughMs),
|
|
683
|
+
);
|
|
684
|
+
const nextResetMs = resets.find((reset) => reset.cycle === cycle + 1)
|
|
685
|
+
?.timestampMs;
|
|
686
|
+
const crossesNextReset = Number.isFinite(nextResetMs) &&
|
|
687
|
+
observedThroughMs >= nextResetMs;
|
|
688
|
+
const endpointMs = Math.min(observedThroughMs, endMs);
|
|
689
|
+
if (!crossesNextReset && endpointMs > lastPoint.timestampMs) {
|
|
690
|
+
displayPoints.push({
|
|
691
|
+
...lastPoint,
|
|
692
|
+
timestampMs: endpointMs,
|
|
693
|
+
observedThroughMs: endpointMs,
|
|
694
|
+
observed: true,
|
|
695
|
+
carried: false,
|
|
696
|
+
confirmation: true,
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
displayPoints.sort(
|
|
701
|
+
(left, right) => left.timestampMs - right.timestampMs || left.cycle - right.cycle,
|
|
702
|
+
);
|
|
657
703
|
|
|
658
704
|
const hasUnattributed = methods.has("unattributed");
|
|
659
705
|
let allocationMethod = "unavailable";
|
|
@@ -699,7 +745,7 @@ export function buildUsageTrend(snapshot = {}, bounds) {
|
|
|
699
745
|
).length,
|
|
700
746
|
allocationMethod,
|
|
701
747
|
observedThroughMs:
|
|
702
|
-
[...
|
|
748
|
+
[...displayPoints].reverse().find((point) => point.observed)
|
|
703
749
|
?.timestampMs ?? null,
|
|
704
750
|
rateCardAsOf: snapshot.provenance?.rateCardAsOf ?? RATE_CARD_AS_OF,
|
|
705
751
|
};
|
package/bin/token-ledger.mjs
CHANGED
|
@@ -947,6 +947,18 @@ export function snapshotCacheIsFresh(
|
|
|
947
947
|
);
|
|
948
948
|
}
|
|
949
949
|
|
|
950
|
+
export function shouldCheckSourceFreshness(
|
|
951
|
+
options = {},
|
|
952
|
+
snapshotMtimeMs,
|
|
953
|
+
nowMs = Date.now(),
|
|
954
|
+
) {
|
|
955
|
+
// PNG reports are expected to reflect every completed local call. Checking
|
|
956
|
+
// source mtimes is much cheaper than parsing the rollouts again; the full
|
|
957
|
+
// collector only runs when one of those sources actually changed.
|
|
958
|
+
return Boolean(options.view === "trend" && options.image) ||
|
|
959
|
+
!snapshotCacheIsFresh(snapshotMtimeMs, nowMs);
|
|
960
|
+
}
|
|
961
|
+
|
|
950
962
|
function snapshotAgeLabel(ageMs) {
|
|
951
963
|
if (ageMs < 60 * 1_000) return "now";
|
|
952
964
|
const minutes = Math.floor(ageMs / (60 * 1_000));
|
|
@@ -1005,7 +1017,7 @@ async function loadSnapshot(options) {
|
|
|
1005
1017
|
`Could not inspect snapshot ${safeDisplayLabel(options.input, "snapshot")}: ${safeErrorMessage(error, [options.input])}`,
|
|
1006
1018
|
);
|
|
1007
1019
|
}
|
|
1008
|
-
if (
|
|
1020
|
+
if (!shouldCheckSourceFreshness(options, snapshotStat.mtimeMs)) {
|
|
1009
1021
|
return readSnapshot(options.input);
|
|
1010
1022
|
}
|
|
1011
1023
|
|
|
@@ -1027,7 +1039,16 @@ async function loadSnapshot(options) {
|
|
|
1027
1039
|
return readSnapshot(options.input);
|
|
1028
1040
|
}
|
|
1029
1041
|
|
|
1030
|
-
function render(
|
|
1042
|
+
function render(
|
|
1043
|
+
options,
|
|
1044
|
+
snapshot,
|
|
1045
|
+
bounds,
|
|
1046
|
+
events,
|
|
1047
|
+
rows,
|
|
1048
|
+
allRows,
|
|
1049
|
+
freshness,
|
|
1050
|
+
reportTimeMs,
|
|
1051
|
+
) {
|
|
1031
1052
|
if (options.view === "trend") {
|
|
1032
1053
|
if (options.image && options.cacheRate) {
|
|
1033
1054
|
return renderCacheReportImage({
|
|
@@ -1044,7 +1065,7 @@ function render(options, snapshot, bounds, events, rows, allRows, freshness) {
|
|
|
1044
1065
|
bounds,
|
|
1045
1066
|
trend,
|
|
1046
1067
|
days: options.trendDays,
|
|
1047
|
-
options,
|
|
1068
|
+
options: { ...options, reportTimeMs },
|
|
1048
1069
|
projectRows: allRows,
|
|
1049
1070
|
});
|
|
1050
1071
|
}
|
|
@@ -1169,6 +1190,10 @@ export async function run(options, { nowMs } = {}) {
|
|
|
1169
1190
|
if (writingImage) {
|
|
1170
1191
|
process.stderr.write(`Token Ledger: generating ${imageLabel} PNG…\n`);
|
|
1171
1192
|
}
|
|
1193
|
+
const reportTimeMs = hasInjectedNow ? now.getTime() : Date.now();
|
|
1194
|
+
const verifiedSourceTimeMs = options.autoRefresh && !options.inputExplicit
|
|
1195
|
+
? reportTimeMs
|
|
1196
|
+
: undefined;
|
|
1172
1197
|
const output = render(
|
|
1173
1198
|
options,
|
|
1174
1199
|
snapshot,
|
|
@@ -1178,8 +1203,9 @@ export async function run(options, { nowMs } = {}) {
|
|
|
1178
1203
|
allRows,
|
|
1179
1204
|
snapshotFreshness(
|
|
1180
1205
|
snapshot,
|
|
1181
|
-
|
|
1206
|
+
reportTimeMs,
|
|
1182
1207
|
),
|
|
1208
|
+
verifiedSourceTimeMs,
|
|
1183
1209
|
);
|
|
1184
1210
|
if (writingImage) {
|
|
1185
1211
|
await mkdir(dirname(outputPath), { recursive: true });
|
|
@@ -739,7 +739,10 @@ function rememberQuota(quotaMap, rateLimits, occurrence) {
|
|
|
739
739
|
].join("|");
|
|
740
740
|
const candidate = {
|
|
741
741
|
id: `quota-${hash(key)}`,
|
|
742
|
+
// Keep the first and last occurrence of an unchanged reading without
|
|
743
|
+
// storing every repeated provider sample in the snapshot.
|
|
742
744
|
timestamp: occurrence.timestamp,
|
|
745
|
+
lastSeenAt: occurrence.timestamp,
|
|
743
746
|
usedPercent,
|
|
744
747
|
windowMinutes,
|
|
745
748
|
resetsAt,
|
|
@@ -753,11 +756,21 @@ function rememberQuota(quotaMap, rateLimits, occurrence) {
|
|
|
753
756
|
const current = quotaMap.get(key);
|
|
754
757
|
if (
|
|
755
758
|
!current ||
|
|
756
|
-
(!current.originalLikely && candidate.originalLikely)
|
|
757
|
-
(current.originalLikely === candidate.originalLikely &&
|
|
758
|
-
candidate.timestamp < current.timestamp)
|
|
759
|
+
(!current.originalLikely && candidate.originalLikely)
|
|
759
760
|
) {
|
|
760
761
|
quotaMap.set(key, candidate);
|
|
762
|
+
} else if (current.originalLikely === candidate.originalLikely) {
|
|
763
|
+
quotaMap.set(key, {
|
|
764
|
+
...current,
|
|
765
|
+
timestamp:
|
|
766
|
+
candidate.timestamp < current.timestamp
|
|
767
|
+
? candidate.timestamp
|
|
768
|
+
: current.timestamp,
|
|
769
|
+
lastSeenAt:
|
|
770
|
+
candidate.timestamp > current.lastSeenAt
|
|
771
|
+
? candidate.timestamp
|
|
772
|
+
: current.lastSeenAt,
|
|
773
|
+
});
|
|
761
774
|
}
|
|
762
775
|
}
|
|
763
776
|
}
|
|
@@ -1204,7 +1217,7 @@ function buildSnapshot(context, options, titles) {
|
|
|
1204
1217
|
})
|
|
1205
1218
|
.filter((quota) => {
|
|
1206
1219
|
if (!options.since) return true;
|
|
1207
|
-
return Date.parse(quota.
|
|
1220
|
+
return Date.parse(quota.lastSeenAt) >= sinceMs;
|
|
1208
1221
|
})
|
|
1209
1222
|
.sort(
|
|
1210
1223
|
(left, right) => Date.parse(left.timestamp) - Date.parse(right.timestamp),
|
|
@@ -1230,7 +1243,7 @@ function buildSnapshot(context, options, titles) {
|
|
|
1230
1243
|
...(accountWideWeekly.length ? accountWideWeekly : weeklyCandidates),
|
|
1231
1244
|
]
|
|
1232
1245
|
.sort(
|
|
1233
|
-
(left, right) => Date.parse(right.
|
|
1246
|
+
(left, right) => Date.parse(right.lastSeenAt) - Date.parse(left.lastSeenAt),
|
|
1234
1247
|
)[0];
|
|
1235
1248
|
const weeklyStart = weekly
|
|
1236
1249
|
? (weekly.resetsAt - weekly.windowMinutes * 60) * 1_000
|