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.
- package/README.md +283 -157
- package/bin/token-ledger-cache-data.mjs +492 -0
- package/bin/token-ledger-cache-image.mjs +7 -1147
- package/bin/token-ledger-cache-sections.mjs +848 -0
- package/bin/token-ledger-cost-terminal.mjs +234 -0
- package/bin/token-ledger-image-layout.mjs +20 -0
- package/bin/token-ledger-image-primitives.mjs +192 -0
- package/bin/token-ledger-report-data.mjs +1159 -0
- package/bin/token-ledger-source-status.mjs +31 -0
- package/bin/token-ledger-terminal.mjs +245 -69
- package/bin/token-ledger-trend-image.mjs +2025 -1724
- package/bin/token-ledger-trend-terminal.mjs +273 -150
- package/bin/token-ledger-trend.mjs +204 -198
- package/bin/token-ledger-tui.mjs +180 -51
- package/bin/token-ledger.mjs +735 -208
- package/docs/durable-ledger-operations.md +198 -0
- package/docs/release-notes-0.4.0.md +41 -0
- package/docs/token-ledger-report-7-day.png +0 -0
- package/lib/token-ledger-calendar.mjs +225 -0
- package/lib/token-ledger-collection.mjs +100 -0
- package/lib/token-ledger-importer.mjs +3329 -488
- package/lib/token-ledger-labels.mjs +66 -0
- package/lib/token-ledger-ledger.mjs +6056 -0
- package/lib/token-ledger-quota-contract.mjs +38 -0
- package/lib/token-ledger-range-analysis.mjs +120 -0
- package/lib/token-ledger-rates.mjs +330 -0
- package/lib/token-ledger-snapshot.mjs +336 -35
- package/lib/token-ledger-terminal-text.mjs +11 -0
- package/lib/token-ledger-usage.mjs +339 -33
- package/package.json +13 -10
- package/bin/token-ledger-rates.mjs +0 -65
|
@@ -0,0 +1,1159 @@
|
|
|
1
|
+
// Pure view model for the PNG trend report. Builds validated,
|
|
2
|
+
// reconciliation-safe report data from a snapshot and range bounds so the SVG
|
|
3
|
+
// renderer consumes one bounded event set instead of recalculating totals in
|
|
4
|
+
// every layout section. Contains no SVG, positions, colors, or formatted
|
|
5
|
+
// strings.
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
normalizeQuotaTimeline,
|
|
9
|
+
priorPeriodBounds,
|
|
10
|
+
trendModelLabel,
|
|
11
|
+
weeklyQuotaObservations,
|
|
12
|
+
} from "./token-ledger-trend.mjs";
|
|
13
|
+
import { buildRangeAnalysis } from "../lib/token-ledger-range-analysis.mjs";
|
|
14
|
+
import { historyScopeLabel } from "../lib/token-ledger-collection.mjs";
|
|
15
|
+
import {
|
|
16
|
+
CODEX_CREDIT_RATE_CARD_AS_OF,
|
|
17
|
+
isFastServiceTier,
|
|
18
|
+
} from "../lib/token-ledger-rates.mjs";
|
|
19
|
+
import {
|
|
20
|
+
MAX_SAFE_TOKEN_COUNT,
|
|
21
|
+
splitUsageBucketsAtBoundaries,
|
|
22
|
+
usageCallCount,
|
|
23
|
+
usageDetailedCallCount,
|
|
24
|
+
} from "../lib/token-ledger-usage.mjs";
|
|
25
|
+
import {
|
|
26
|
+
localDateBoundary,
|
|
27
|
+
localDateString,
|
|
28
|
+
shiftCalendarDate,
|
|
29
|
+
} from "../lib/token-ledger-calendar.mjs";
|
|
30
|
+
import { SOURCE_STATUSES } from "./token-ledger-source-status.mjs";
|
|
31
|
+
|
|
32
|
+
export { shiftCalendarDate, SOURCE_STATUSES };
|
|
33
|
+
|
|
34
|
+
const DAY_MS = 86_400_000;
|
|
35
|
+
const HOUR_MS = 3_600_000;
|
|
36
|
+
// Two readings this close in percent confirm a flat reported interval.
|
|
37
|
+
const METER_EQUAL_TOLERANCE = 0.05;
|
|
38
|
+
// Remaining percent at or below this reads as an exhausted meter.
|
|
39
|
+
const METER_EXHAUSTED_TOLERANCE = 0.05;
|
|
40
|
+
const RECONCILE_RELATIVE_TOLERANCE = 1e-6;
|
|
41
|
+
const RECONCILE_ABSOLUTE_TOLERANCE = 1.5;
|
|
42
|
+
|
|
43
|
+
// Fast mode is an overlapping usage property, not a separate model. Both
|
|
44
|
+
// recognized service-tier labels count. Keep normalTokens limited to the
|
|
45
|
+
// explicit standard/default tiers; a missing or unfamiliar tier is unknown.
|
|
46
|
+
const KNOWN_NORMAL_SERVICE_TIERS = new Set(["default", "standard"]);
|
|
47
|
+
|
|
48
|
+
function serviceTierClass(serviceTier) {
|
|
49
|
+
const tier = String(serviceTier ?? "")
|
|
50
|
+
.trim()
|
|
51
|
+
.toLowerCase()
|
|
52
|
+
.replace(/[\s_]+/g, "-");
|
|
53
|
+
if (isFastServiceTier(tier)) return "fast";
|
|
54
|
+
if (KNOWN_NORMAL_SERVICE_TIERS.has(tier)) return "normal";
|
|
55
|
+
return "unknown";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function isFastMode(serviceTier) {
|
|
59
|
+
return serviceTierClass(serviceTier) === "fast";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function finiteTimestamp(value) {
|
|
63
|
+
// Snapshot timestamps are serialized as ISO strings. Keep finite numeric
|
|
64
|
+
// epoch milliseconds for existing callers, but do not let Date coerce
|
|
65
|
+
// null, booleans, objects, or other non-timestamp values.
|
|
66
|
+
if (Number.isFinite(value)) {
|
|
67
|
+
const timestamp = new Date(value).getTime();
|
|
68
|
+
return Number.isFinite(timestamp) ? timestamp : null;
|
|
69
|
+
}
|
|
70
|
+
const text = primitiveString(value);
|
|
71
|
+
if (text === null) return null;
|
|
72
|
+
const timestamp = Date.parse(text);
|
|
73
|
+
return Number.isFinite(timestamp) ? timestamp : null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function primitiveString(value) {
|
|
77
|
+
try {
|
|
78
|
+
const text = String.prototype.valueOf.call(value);
|
|
79
|
+
return text === value ? text : null;
|
|
80
|
+
} catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function dateStringFromParts(year, month, day) {
|
|
86
|
+
return [year, month, day]
|
|
87
|
+
.map((value, index) => String(value).padStart(index === 0 ? 4 : 2, "0"))
|
|
88
|
+
.join("-");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function offsetAt(instant, timeZone) {
|
|
92
|
+
const parts = new Intl.DateTimeFormat("en-US", {
|
|
93
|
+
timeZone,
|
|
94
|
+
timeZoneName: "longOffset",
|
|
95
|
+
}).formatToParts(instant);
|
|
96
|
+
const value = parts.find((part) => part.type === "timeZoneName")?.value ?? "GMT";
|
|
97
|
+
if (value === "GMT") return 0;
|
|
98
|
+
const match = value.match(/^GMT([+-])(\d{2}):?(\d{2})?$/);
|
|
99
|
+
if (!match) return 0;
|
|
100
|
+
const minutes = Number(match[2]) * 60 + Number(match[3] || 0);
|
|
101
|
+
return (match[1] === "+" ? 1 : -1) * minutes * 60 * 1_000;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function localDateTimeParts(timestampMs, timeZone) {
|
|
105
|
+
const parts = new Intl.DateTimeFormat("en-US", {
|
|
106
|
+
timeZone,
|
|
107
|
+
year: "numeric",
|
|
108
|
+
month: "2-digit",
|
|
109
|
+
day: "2-digit",
|
|
110
|
+
hour: "2-digit",
|
|
111
|
+
minute: "2-digit",
|
|
112
|
+
second: "2-digit",
|
|
113
|
+
hourCycle: "h23",
|
|
114
|
+
}).formatToParts(new Date(timestampMs));
|
|
115
|
+
const values = Object.fromEntries(
|
|
116
|
+
parts
|
|
117
|
+
.filter((part) => part.type !== "literal")
|
|
118
|
+
.map((part) => [part.type, Number(part.value)]),
|
|
119
|
+
);
|
|
120
|
+
return {
|
|
121
|
+
dateString: dateStringFromParts(values.year, values.month, values.day),
|
|
122
|
+
hour: values.hour,
|
|
123
|
+
minute: values.minute,
|
|
124
|
+
second: values.second,
|
|
125
|
+
millisecond: new Date(timestampMs).getUTCMilliseconds(),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function zonedDateTime(dateString, timeZone, time = {}) {
|
|
130
|
+
const [year, month, day] = dateString.split("-").map(Number);
|
|
131
|
+
const utcGuess = Date.UTC(
|
|
132
|
+
year,
|
|
133
|
+
month - 1,
|
|
134
|
+
day,
|
|
135
|
+
time.hour ?? 0,
|
|
136
|
+
time.minute ?? 0,
|
|
137
|
+
time.second ?? 0,
|
|
138
|
+
time.millisecond ?? 0,
|
|
139
|
+
);
|
|
140
|
+
let instant = new Date(utcGuess - offsetAt(new Date(utcGuess), timeZone));
|
|
141
|
+
instant = new Date(utcGuess - offsetAt(instant, timeZone));
|
|
142
|
+
return instant;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function zonedMidnight(dateString, timeZone) {
|
|
146
|
+
return localDateBoundary(dateString, timeZone);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function elapsedHourIntervals(startMs, endMs) {
|
|
150
|
+
const intervals = [];
|
|
151
|
+
for (let cursor = startMs; cursor < endMs; cursor += HOUR_MS) {
|
|
152
|
+
intervals.push({
|
|
153
|
+
startMs: cursor,
|
|
154
|
+
endMs: Math.min(endMs, cursor + HOUR_MS),
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
return intervals;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function intervalIndexAt(intervals, timestampMs) {
|
|
161
|
+
let low = 0;
|
|
162
|
+
let high = intervals.length;
|
|
163
|
+
while (low < high) {
|
|
164
|
+
const middle = Math.floor((low + high) / 2);
|
|
165
|
+
if (intervals[middle].startMs <= timestampMs) low = middle + 1;
|
|
166
|
+
else high = middle;
|
|
167
|
+
}
|
|
168
|
+
const index = low - 1;
|
|
169
|
+
return index >= 0 && timestampMs < intervals[index].endMs ? index : -1;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Whether the stored input/output components of an event can be trusted.
|
|
173
|
+
// Mirrors the importer's breakdownAvailable rule for snapshots that predate
|
|
174
|
+
// the stored flag.
|
|
175
|
+
function usableComponents(event) {
|
|
176
|
+
if (event.breakdownAvailable === true) return true;
|
|
177
|
+
if (event.breakdownAvailable === false) return false;
|
|
178
|
+
const total = Math.max(0, Number(event.totalTokens) || 0);
|
|
179
|
+
const input = Math.max(0, Number(event.inputTokens) || 0);
|
|
180
|
+
const output = Math.max(0, Number(event.outputTokens) || 0);
|
|
181
|
+
if (total === 0) return input > 0 || output > 0;
|
|
182
|
+
return input + output === total && (input > 0 || output > 0);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// The moment the report's event window actually ends. A verified-current
|
|
186
|
+
// source may report through the wall clock; every other source status must
|
|
187
|
+
// stop at the snapshot's own capture time so the report never claims data it
|
|
188
|
+
// could not have seen.
|
|
189
|
+
export function resolveEffectiveEnd({
|
|
190
|
+
snapshot = {},
|
|
191
|
+
bounds,
|
|
192
|
+
reportTimeMs = null,
|
|
193
|
+
sourceStatus = "unchecked-cache",
|
|
194
|
+
}) {
|
|
195
|
+
const startMs = bounds.start.getTime();
|
|
196
|
+
const endMs = bounds.end.getTime();
|
|
197
|
+
const generatedAtMs = finiteTimestamp(snapshot.generatedAt);
|
|
198
|
+
const sourceCutoffAtMs = finiteTimestamp(
|
|
199
|
+
snapshot.provenance?.sourceCutoffAt,
|
|
200
|
+
);
|
|
201
|
+
const wallClockMs = Number.isFinite(reportTimeMs) ? reportTimeMs : endMs;
|
|
202
|
+
// The capture-time cutoff is inclusive: an event stamped exactly at
|
|
203
|
+
// generatedAt was part of the capture.
|
|
204
|
+
const cutoff = sourceStatus === "verified-current"
|
|
205
|
+
? wallClockMs
|
|
206
|
+
: (sourceCutoffAtMs ?? generatedAtMs) === null
|
|
207
|
+
? wallClockMs
|
|
208
|
+
: (sourceCutoffAtMs ?? generatedAtMs) + 1;
|
|
209
|
+
return Math.max(startMs, Math.min(endMs, cutoff));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function approxEqual(left, right) {
|
|
213
|
+
return (
|
|
214
|
+
Math.abs(left - right) <=
|
|
215
|
+
Math.max(
|
|
216
|
+
RECONCILE_ABSOLUTE_TOLERANCE,
|
|
217
|
+
RECONCILE_RELATIVE_TOLERANCE * Math.max(Math.abs(left), Math.abs(right)),
|
|
218
|
+
)
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function assertReconciles(label, left, right) {
|
|
223
|
+
if (!approxEqual(left, right)) {
|
|
224
|
+
throw new Error(
|
|
225
|
+
`Report reconciliation failed: ${label} (${left} vs ${right})`,
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function modelRowFor(map, model) {
|
|
231
|
+
const row = map.get(model) ?? {
|
|
232
|
+
model,
|
|
233
|
+
totalTokens: 0,
|
|
234
|
+
normalTokens: 0,
|
|
235
|
+
fastTokens: 0,
|
|
236
|
+
unknownTokens: 0,
|
|
237
|
+
cacheInputTokens: 0,
|
|
238
|
+
cachedInputTokens: 0,
|
|
239
|
+
uncachedInputTokens: 0,
|
|
240
|
+
estimated: false,
|
|
241
|
+
};
|
|
242
|
+
map.set(model, row);
|
|
243
|
+
return row;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function meterRemainingPercent(usedPercent) {
|
|
247
|
+
return Math.max(0, Math.min(100, 100 - usedPercent));
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// A compacted quota row represents the same provider reading from its first
|
|
251
|
+
// occurrence through lastSeenAt. Keep the distinction between the first
|
|
252
|
+
// timestamp (where a change may have happened) and the confirmed observation
|
|
253
|
+
// tail (where that unchanged value was still reported).
|
|
254
|
+
function boundedObservationThroughMs(observation, effectiveEndMs) {
|
|
255
|
+
const timestampMs = observation.timestampMs;
|
|
256
|
+
const observedThroughMs = Number.isFinite(observation.observedThroughMs)
|
|
257
|
+
? observation.observedThroughMs
|
|
258
|
+
: timestampMs;
|
|
259
|
+
return Math.min(
|
|
260
|
+
effectiveEndMs,
|
|
261
|
+
Math.max(timestampMs, observedThroughMs),
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function buildMeter({ snapshot, bounds, effectiveEndMs, sourceStatus, events }) {
|
|
266
|
+
const startMs = bounds.start.getTime();
|
|
267
|
+
const stale = sourceStatus === "stale-fallback";
|
|
268
|
+
// weeklyQuotaObservations owns quota identity and account-scope selection;
|
|
269
|
+
// this layer only applies the report-range cutoff to its selected meter.
|
|
270
|
+
const selected = weeklyQuotaObservations(snapshot);
|
|
271
|
+
const observationsAll = normalizeQuotaTimeline(selected).filter(
|
|
272
|
+
(observation) => observation.timestampMs < effectiveEndMs,
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
const empty = {
|
|
276
|
+
status: "unavailable",
|
|
277
|
+
stale,
|
|
278
|
+
remainingPercent: null,
|
|
279
|
+
lastObservedAtMs: null,
|
|
280
|
+
observedThroughMs: null,
|
|
281
|
+
firstExhaustedObservedAtMs: null,
|
|
282
|
+
resetsAtMs: null,
|
|
283
|
+
resetInMs: null,
|
|
284
|
+
cycleStartMs: null,
|
|
285
|
+
cycleBurnPercent: null,
|
|
286
|
+
burnPerDay: null,
|
|
287
|
+
runwayDays: null,
|
|
288
|
+
tokensPerMeterPoint: null,
|
|
289
|
+
observations: [],
|
|
290
|
+
segments: [],
|
|
291
|
+
resets: [],
|
|
292
|
+
};
|
|
293
|
+
if (!observationsAll.length) return empty;
|
|
294
|
+
|
|
295
|
+
const latest = observationsAll.at(-1);
|
|
296
|
+
const latestObservedThroughMs = boundedObservationThroughMs(
|
|
297
|
+
latest,
|
|
298
|
+
effectiveEndMs,
|
|
299
|
+
);
|
|
300
|
+
const remainingPercent = meterRemainingPercent(latest.normalizedUsedPercent);
|
|
301
|
+
|
|
302
|
+
// Points for the sampled meter line: real observations inside the range
|
|
303
|
+
// plus one carried anchor at the range start when an earlier reading
|
|
304
|
+
// exists. The anchor is not drawn as a dot.
|
|
305
|
+
const inRange = observationsAll.filter(
|
|
306
|
+
(observation) => observation.timestampMs >= startMs,
|
|
307
|
+
);
|
|
308
|
+
const before = observationsAll.filter(
|
|
309
|
+
(observation) => observation.timestampMs < startMs,
|
|
310
|
+
);
|
|
311
|
+
const pointRows = [];
|
|
312
|
+
const addPoint = (observation, timestampMs, observed) => {
|
|
313
|
+
if (timestampMs < startMs || timestampMs > effectiveEndMs) return;
|
|
314
|
+
pointRows.push({
|
|
315
|
+
timestampMs,
|
|
316
|
+
remainingPercent: meterRemainingPercent(observation.normalizedUsedPercent),
|
|
317
|
+
cycle: observation.cycle,
|
|
318
|
+
observed,
|
|
319
|
+
});
|
|
320
|
+
};
|
|
321
|
+
const anchor = before.at(-1);
|
|
322
|
+
if (anchor && (!inRange.length || inRange[0].cycle === anchor.cycle)) {
|
|
323
|
+
addPoint(anchor, startMs, false);
|
|
324
|
+
const anchorThroughMs = boundedObservationThroughMs(
|
|
325
|
+
anchor,
|
|
326
|
+
effectiveEndMs,
|
|
327
|
+
);
|
|
328
|
+
if (anchorThroughMs > startMs) addPoint(anchor, anchorThroughMs, true);
|
|
329
|
+
}
|
|
330
|
+
for (const observation of inRange) {
|
|
331
|
+
addPoint(observation, observation.timestampMs, true);
|
|
332
|
+
const observationThroughMs = boundedObservationThroughMs(
|
|
333
|
+
observation,
|
|
334
|
+
effectiveEndMs,
|
|
335
|
+
);
|
|
336
|
+
if (observationThroughMs > observation.timestampMs) {
|
|
337
|
+
addPoint(observation, observationThroughMs, true);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
const points = pointRows.sort(
|
|
341
|
+
(left, right) =>
|
|
342
|
+
left.timestampMs - right.timestampMs ||
|
|
343
|
+
Number(left.observed) - Number(right.observed),
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
// Straight segments between adjacent readings of one cycle. Repeated equal
|
|
347
|
+
// readings confirm a flat reported interval; a changed value means the
|
|
348
|
+
// movement happened somewhere unobserved, so the connector is a gap.
|
|
349
|
+
const segments = [];
|
|
350
|
+
for (let index = 0; index < points.length - 1; index += 1) {
|
|
351
|
+
const from = points[index];
|
|
352
|
+
const to = points[index + 1];
|
|
353
|
+
if (from.cycle !== to.cycle) continue;
|
|
354
|
+
if (to.timestampMs - from.timestampMs <= 0) continue;
|
|
355
|
+
segments.push({
|
|
356
|
+
fromMs: from.timestampMs,
|
|
357
|
+
toMs: to.timestampMs,
|
|
358
|
+
fromPercent: from.remainingPercent,
|
|
359
|
+
toPercent: to.remainingPercent,
|
|
360
|
+
cycle: from.cycle,
|
|
361
|
+
kind:
|
|
362
|
+
Math.abs(to.remainingPercent - from.remainingPercent) <=
|
|
363
|
+
METER_EQUAL_TOLERANCE
|
|
364
|
+
? "confirmed"
|
|
365
|
+
: "gap",
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Cycle resets that land inside the display window. The reset moment is
|
|
370
|
+
// derived from the window schedule, not directly observed.
|
|
371
|
+
const resets = [];
|
|
372
|
+
const seenCycles = new Set();
|
|
373
|
+
for (const observation of observationsAll) {
|
|
374
|
+
if (seenCycles.has(observation.cycle)) continue;
|
|
375
|
+
seenCycles.add(observation.cycle);
|
|
376
|
+
if (!observation.reset) continue;
|
|
377
|
+
const timestampMs = Math.min(
|
|
378
|
+
observation.cycleStartMs,
|
|
379
|
+
observation.timestampMs,
|
|
380
|
+
);
|
|
381
|
+
if (timestampMs < startMs || timestampMs >= effectiveEndMs) continue;
|
|
382
|
+
resets.push({
|
|
383
|
+
timestampMs,
|
|
384
|
+
observedAtMs: observation.timestampMs,
|
|
385
|
+
kind: observation.resetKind ?? "restart",
|
|
386
|
+
inferred: true,
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Pace uses only the active weekly cycle: observed burn between its first
|
|
391
|
+
// and last readings, never the whole report range.
|
|
392
|
+
const cycleObservations = observationsAll.filter(
|
|
393
|
+
(observation) => observation.cycle === latest.cycle,
|
|
394
|
+
);
|
|
395
|
+
const firstCycleObservation = cycleObservations[0];
|
|
396
|
+
const cycleBurnPercent =
|
|
397
|
+
latest.normalizedUsedPercent - firstCycleObservation.normalizedUsedPercent;
|
|
398
|
+
const observedCycleMs =
|
|
399
|
+
latestObservedThroughMs - firstCycleObservation.timestampMs;
|
|
400
|
+
const burnPerDay =
|
|
401
|
+
cycleBurnPercent > 0 && observedCycleMs > 0
|
|
402
|
+
? cycleBurnPercent / (observedCycleMs / DAY_MS)
|
|
403
|
+
: null;
|
|
404
|
+
const runwayDays =
|
|
405
|
+
burnPerDay !== null && burnPerDay > 0 ? remainingPercent / burnPerDay : null;
|
|
406
|
+
let cycleTokens = 0;
|
|
407
|
+
if (cycleBurnPercent > 0) {
|
|
408
|
+
for (const event of events) {
|
|
409
|
+
if (
|
|
410
|
+
event.timestampMs > firstCycleObservation.timestampMs &&
|
|
411
|
+
event.timestampMs <= latestObservedThroughMs
|
|
412
|
+
) {
|
|
413
|
+
cycleTokens += event.tokens;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
const tokensPerMeterPoint =
|
|
418
|
+
cycleBurnPercent > 0 && cycleTokens > 0
|
|
419
|
+
? cycleTokens / cycleBurnPercent
|
|
420
|
+
: null;
|
|
421
|
+
|
|
422
|
+
const firstExhausted = cycleObservations.find(
|
|
423
|
+
(observation) =>
|
|
424
|
+
100 - observation.normalizedUsedPercent <= METER_EXHAUSTED_TOLERANCE,
|
|
425
|
+
);
|
|
426
|
+
const resetsAtMs = Number.isFinite(latest.resetsAt)
|
|
427
|
+
? latest.resetsAt * 1_000
|
|
428
|
+
: null;
|
|
429
|
+
const resetInMs =
|
|
430
|
+
resetsAtMs !== null && resetsAtMs > effectiveEndMs
|
|
431
|
+
? resetsAtMs - effectiveEndMs
|
|
432
|
+
: null;
|
|
433
|
+
|
|
434
|
+
let status = "active";
|
|
435
|
+
if (remainingPercent <= METER_EXHAUSTED_TOLERANCE) {
|
|
436
|
+
status = "exhausted";
|
|
437
|
+
} else if (
|
|
438
|
+
runwayDays !== null &&
|
|
439
|
+
resetInMs !== null &&
|
|
440
|
+
runwayDays * DAY_MS < resetInMs
|
|
441
|
+
) {
|
|
442
|
+
status = "at-risk";
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
return {
|
|
446
|
+
status,
|
|
447
|
+
stale,
|
|
448
|
+
remainingPercent,
|
|
449
|
+
lastObservedAtMs: latestObservedThroughMs,
|
|
450
|
+
observedThroughMs: latestObservedThroughMs,
|
|
451
|
+
firstExhaustedObservedAtMs: firstExhausted?.timestampMs ?? null,
|
|
452
|
+
resetsAtMs,
|
|
453
|
+
resetInMs,
|
|
454
|
+
cycleStartMs: latest.cycleStartMs ?? null,
|
|
455
|
+
cycleBurnPercent,
|
|
456
|
+
burnPerDay,
|
|
457
|
+
runwayDays,
|
|
458
|
+
tokensPerMeterPoint,
|
|
459
|
+
observations: points,
|
|
460
|
+
segments,
|
|
461
|
+
resets,
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
export function buildTrendReportViewModel({
|
|
466
|
+
snapshot = {},
|
|
467
|
+
bounds,
|
|
468
|
+
days = null,
|
|
469
|
+
reportTimeMs = null,
|
|
470
|
+
sourceStatus = "unchecked-cache",
|
|
471
|
+
projectRows = null,
|
|
472
|
+
events = null,
|
|
473
|
+
priorEvents = null,
|
|
474
|
+
}) {
|
|
475
|
+
if (!SOURCE_STATUSES.includes(sourceStatus)) {
|
|
476
|
+
throw new Error(`Unknown report source status: ${sourceStatus}`);
|
|
477
|
+
}
|
|
478
|
+
const timeZone = bounds.timeZone;
|
|
479
|
+
const startMs = bounds.start.getTime();
|
|
480
|
+
const requestedEndMs = bounds.end.getTime();
|
|
481
|
+
const rangeDays = Number(days) || bounds.rangeDays || 7;
|
|
482
|
+
const effectiveEndMs = resolveEffectiveEnd({
|
|
483
|
+
snapshot,
|
|
484
|
+
bounds,
|
|
485
|
+
reportTimeMs,
|
|
486
|
+
sourceStatus,
|
|
487
|
+
});
|
|
488
|
+
const partialFinalDay = effectiveEndMs < requestedEndMs;
|
|
489
|
+
|
|
490
|
+
const defaultRangeAnalysis =
|
|
491
|
+
(!Array.isArray(events) || !Array.isArray(priorEvents)) &&
|
|
492
|
+
bounds.startDateString &&
|
|
493
|
+
bounds.endDateString &&
|
|
494
|
+
bounds.timeZone
|
|
495
|
+
? buildRangeAnalysis(snapshot, bounds, {
|
|
496
|
+
priorBounds: priorPeriodBounds(bounds, rangeDays),
|
|
497
|
+
})
|
|
498
|
+
: null;
|
|
499
|
+
|
|
500
|
+
const dayStrings = Array.from({ length: rangeDays }, (_, index) =>
|
|
501
|
+
shiftCalendarDate(bounds.startDateString, index),
|
|
502
|
+
);
|
|
503
|
+
const dayIndexByString = new Map(
|
|
504
|
+
dayStrings.map((dateString, index) => [dateString, index]),
|
|
505
|
+
);
|
|
506
|
+
const lastObservedMs = Math.max(startMs, effectiveEndMs - 1);
|
|
507
|
+
const lastObservedDateString = localDateString(lastObservedMs, timeZone);
|
|
508
|
+
const lastObservedDayIndex = dayIndexByString.get(lastObservedDateString);
|
|
509
|
+
const lastObservedDayEndMs =
|
|
510
|
+
lastObservedDayIndex === undefined
|
|
511
|
+
? null
|
|
512
|
+
: zonedMidnight(
|
|
513
|
+
shiftCalendarDate(lastObservedDateString, 1),
|
|
514
|
+
timeZone,
|
|
515
|
+
).getTime();
|
|
516
|
+
const partialDayIndex =
|
|
517
|
+
partialFinalDay &&
|
|
518
|
+
effectiveEndMs > startMs &&
|
|
519
|
+
lastObservedDayIndex !== undefined &&
|
|
520
|
+
effectiveEndMs !== lastObservedDayEndMs
|
|
521
|
+
? lastObservedDayIndex
|
|
522
|
+
: null;
|
|
523
|
+
const daily = dayStrings.map((dateString, index) => ({
|
|
524
|
+
dateString,
|
|
525
|
+
totalTokens: 0,
|
|
526
|
+
inputTokens: 0,
|
|
527
|
+
outputTokens: 0,
|
|
528
|
+
cachedInputTokens: 0,
|
|
529
|
+
uncachedInputTokens: 0,
|
|
530
|
+
cacheRatePercent: null,
|
|
531
|
+
modelCalls: 0,
|
|
532
|
+
estimated: false,
|
|
533
|
+
observed: !partialFinalDay ||
|
|
534
|
+
(lastObservedDayIndex !== undefined && index <= lastObservedDayIndex),
|
|
535
|
+
partial: partialDayIndex === index,
|
|
536
|
+
models: new Map(),
|
|
537
|
+
}));
|
|
538
|
+
const hourlyMode = rangeDays === 1;
|
|
539
|
+
// A 1d report is a selected local calendar day, but the chart follows
|
|
540
|
+
// elapsed one-hour intervals inside the captured portion of that day. This
|
|
541
|
+
// naturally yields 23/24/25 rows across DST transitions and avoids adding
|
|
542
|
+
// future zero-valued rows after a partial cutoff.
|
|
543
|
+
const hourlyIntervals = hourlyMode
|
|
544
|
+
? elapsedHourIntervals(startMs, requestedEndMs).filter(
|
|
545
|
+
(interval) => interval.startMs < effectiveEndMs,
|
|
546
|
+
)
|
|
547
|
+
: [];
|
|
548
|
+
const hourly = hourlyMode
|
|
549
|
+
? hourlyIntervals.map((interval) => ({
|
|
550
|
+
startMs: interval.startMs,
|
|
551
|
+
endMs: interval.endMs,
|
|
552
|
+
observedEndMs: Math.min(interval.endMs, effectiveEndMs),
|
|
553
|
+
dateString: localDateString(interval.startMs, timeZone),
|
|
554
|
+
totalTokens: 0,
|
|
555
|
+
inputTokens: 0,
|
|
556
|
+
outputTokens: 0,
|
|
557
|
+
cachedInputTokens: 0,
|
|
558
|
+
uncachedInputTokens: 0,
|
|
559
|
+
cacheRatePercent: null,
|
|
560
|
+
modelCalls: 0,
|
|
561
|
+
estimated: false,
|
|
562
|
+
observed: true,
|
|
563
|
+
unobserved: false,
|
|
564
|
+
partial: effectiveEndMs > interval.startMs && effectiveEndMs < interval.endMs,
|
|
565
|
+
models: new Map(),
|
|
566
|
+
}))
|
|
567
|
+
: null;
|
|
568
|
+
|
|
569
|
+
// One pass over the selected range classifies every event once; the bounded
|
|
570
|
+
// set feeds every panel so subtotals reconcile by construction. Callers that
|
|
571
|
+
// already built a shared range analysis may provide its split current and
|
|
572
|
+
// prior fragments so the image report uses the exact same allocations as
|
|
573
|
+
// the terminal and cache renderers.
|
|
574
|
+
const boundedEvents = [];
|
|
575
|
+
const priorStartMs = zonedMidnight(
|
|
576
|
+
shiftCalendarDate(bounds.startDateString, -rangeDays),
|
|
577
|
+
timeZone,
|
|
578
|
+
).getTime();
|
|
579
|
+
const effectiveLocal = localDateTimeParts(effectiveEndMs, timeZone);
|
|
580
|
+
const priorEndMs = zonedDateTime(
|
|
581
|
+
shiftCalendarDate(effectiveLocal.dateString, -rangeDays),
|
|
582
|
+
timeZone,
|
|
583
|
+
effectiveLocal,
|
|
584
|
+
).getTime();
|
|
585
|
+
let priorEquivalentTokens = 0;
|
|
586
|
+
let priorHasEvents = false;
|
|
587
|
+
let priorEquivalentEstimated = false;
|
|
588
|
+
|
|
589
|
+
const models = new Map();
|
|
590
|
+
let totalTokens = 0;
|
|
591
|
+
let inputTokens = 0;
|
|
592
|
+
let outputTokens = 0;
|
|
593
|
+
let cachedInputTokens = 0;
|
|
594
|
+
let fastTokens = 0;
|
|
595
|
+
let normalTokens = 0;
|
|
596
|
+
let unknownTokens = 0;
|
|
597
|
+
let detailedTokens = 0;
|
|
598
|
+
let detailedCalls = 0;
|
|
599
|
+
let modelCalls = 0;
|
|
600
|
+
|
|
601
|
+
const currentEventSource = Array.isArray(events)
|
|
602
|
+
? events
|
|
603
|
+
: defaultRangeAnalysis?.currentEvents ?? snapshot.events ?? [];
|
|
604
|
+
const comparisonEventSource = Array.isArray(priorEvents)
|
|
605
|
+
? priorEvents
|
|
606
|
+
: defaultRangeAnalysis?.priorEvents ?? snapshot.events ?? [];
|
|
607
|
+
const currentEvents = splitUsageBucketsAtBoundaries(
|
|
608
|
+
currentEventSource,
|
|
609
|
+
hourlyMode
|
|
610
|
+
? [
|
|
611
|
+
startMs,
|
|
612
|
+
effectiveEndMs,
|
|
613
|
+
...hourlyIntervals.flatMap(({ startMs: hourStartMs, endMs: hourEndMs }) => [
|
|
614
|
+
hourStartMs,
|
|
615
|
+
hourEndMs,
|
|
616
|
+
]),
|
|
617
|
+
]
|
|
618
|
+
: [startMs, effectiveEndMs],
|
|
619
|
+
);
|
|
620
|
+
const comparisonEvents = splitUsageBucketsAtBoundaries(
|
|
621
|
+
comparisonEventSource,
|
|
622
|
+
[priorStartMs, priorEndMs],
|
|
623
|
+
);
|
|
624
|
+
const rawTokenTotal = currentEvents.reduce((sum, event) => {
|
|
625
|
+
const tokens = Number(event?.totalTokens);
|
|
626
|
+
return Number.isFinite(tokens) && tokens > 0 ? sum + tokens : sum;
|
|
627
|
+
}, 0);
|
|
628
|
+
const tokenScale = Number.isFinite(rawTokenTotal) &&
|
|
629
|
+
rawTokenTotal > MAX_SAFE_TOKEN_COUNT
|
|
630
|
+
? rawTokenTotal / MAX_SAFE_TOKEN_COUNT
|
|
631
|
+
: 1;
|
|
632
|
+
const scaledTokens = (value) => {
|
|
633
|
+
const tokens = Number(value);
|
|
634
|
+
return Number.isFinite(tokens) && tokens > 0 ? tokens / tokenScale : 0;
|
|
635
|
+
};
|
|
636
|
+
const addUsageToRow = (
|
|
637
|
+
row,
|
|
638
|
+
{
|
|
639
|
+
tokens,
|
|
640
|
+
input,
|
|
641
|
+
output,
|
|
642
|
+
cached,
|
|
643
|
+
callCount,
|
|
644
|
+
model,
|
|
645
|
+
serviceTierClass: tierClass,
|
|
646
|
+
estimated,
|
|
647
|
+
},
|
|
648
|
+
) => {
|
|
649
|
+
if (!row) return;
|
|
650
|
+
row.totalTokens += tokens;
|
|
651
|
+
row.inputTokens += input;
|
|
652
|
+
row.outputTokens += output;
|
|
653
|
+
row.cachedInputTokens += cached;
|
|
654
|
+
row.modelCalls += callCount;
|
|
655
|
+
row.estimated ||= estimated;
|
|
656
|
+
const rowModel = row.models.get(model) ?? {
|
|
657
|
+
model,
|
|
658
|
+
totalTokens: 0,
|
|
659
|
+
normalTokens: 0,
|
|
660
|
+
fastTokens: 0,
|
|
661
|
+
unknownTokens: 0,
|
|
662
|
+
estimated: false,
|
|
663
|
+
};
|
|
664
|
+
rowModel.totalTokens += tokens;
|
|
665
|
+
if (tierClass === "fast") rowModel.fastTokens += tokens;
|
|
666
|
+
else if (tierClass === "normal") rowModel.normalTokens += tokens;
|
|
667
|
+
else rowModel.unknownTokens += tokens;
|
|
668
|
+
rowModel.estimated ||= estimated;
|
|
669
|
+
row.models.set(model, rowModel);
|
|
670
|
+
};
|
|
671
|
+
for (const event of comparisonEvents) {
|
|
672
|
+
const timestampMs = finiteTimestamp(event.timestamp);
|
|
673
|
+
if (timestampMs === null) continue;
|
|
674
|
+
const tokens = scaledTokens(event.totalTokens);
|
|
675
|
+
if (timestampMs >= priorStartMs && timestampMs < priorEndMs) {
|
|
676
|
+
priorEquivalentTokens += tokens;
|
|
677
|
+
priorHasEvents = true;
|
|
678
|
+
priorEquivalentEstimated ||=
|
|
679
|
+
tokens > 0 && event.rangeAllocationEstimated === true;
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
for (const event of currentEvents) {
|
|
683
|
+
const timestampMs = finiteTimestamp(event.timestamp);
|
|
684
|
+
if (timestampMs === null) continue;
|
|
685
|
+
if (timestampMs < startMs || timestampMs >= effectiveEndMs) continue;
|
|
686
|
+
|
|
687
|
+
const tokens = scaledTokens(event.totalTokens);
|
|
688
|
+
const model = trendModelLabel(event.model);
|
|
689
|
+
const tierClass = serviceTierClass(event.serviceTier);
|
|
690
|
+
const fast = tierClass === "fast";
|
|
691
|
+
const usable = usableComponents(event);
|
|
692
|
+
const input = usable ? scaledTokens(event.inputTokens) : 0;
|
|
693
|
+
const output = usable ? scaledTokens(event.outputTokens) : 0;
|
|
694
|
+
const cached = usable
|
|
695
|
+
? Math.min(input, scaledTokens(event.cachedInputTokens))
|
|
696
|
+
: 0;
|
|
697
|
+
const estimated = tokens > 0 && event.rangeAllocationEstimated === true;
|
|
698
|
+
const callCount = usageCallCount(event);
|
|
699
|
+
const detailedCallCount = usable
|
|
700
|
+
? usageDetailedCallCount(event)
|
|
701
|
+
: 0;
|
|
702
|
+
|
|
703
|
+
boundedEvents.push({ timestampMs, tokens, model, fast, event });
|
|
704
|
+
totalTokens += tokens;
|
|
705
|
+
modelCalls += callCount;
|
|
706
|
+
if (usable) {
|
|
707
|
+
detailedTokens += tokens;
|
|
708
|
+
detailedCalls += detailedCallCount;
|
|
709
|
+
}
|
|
710
|
+
inputTokens += input;
|
|
711
|
+
outputTokens += output;
|
|
712
|
+
cachedInputTokens += cached;
|
|
713
|
+
if (tierClass === "fast") fastTokens += tokens;
|
|
714
|
+
else if (tierClass === "normal") normalTokens += tokens;
|
|
715
|
+
else unknownTokens += tokens;
|
|
716
|
+
|
|
717
|
+
const modelRow = modelRowFor(models, model);
|
|
718
|
+
modelRow.totalTokens += tokens;
|
|
719
|
+
if (tierClass === "fast") modelRow.fastTokens += tokens;
|
|
720
|
+
else if (tierClass === "normal") modelRow.normalTokens += tokens;
|
|
721
|
+
else modelRow.unknownTokens += tokens;
|
|
722
|
+
modelRow.cacheInputTokens += input;
|
|
723
|
+
modelRow.cachedInputTokens += cached;
|
|
724
|
+
modelRow.estimated ||= estimated;
|
|
725
|
+
|
|
726
|
+
const rowValues = {
|
|
727
|
+
tokens,
|
|
728
|
+
input,
|
|
729
|
+
output,
|
|
730
|
+
cached,
|
|
731
|
+
callCount,
|
|
732
|
+
model,
|
|
733
|
+
serviceTierClass: tierClass,
|
|
734
|
+
estimated,
|
|
735
|
+
};
|
|
736
|
+
const dayRow = daily[dayIndexByString.get(localDateString(timestampMs, timeZone)) ?? -1];
|
|
737
|
+
addUsageToRow(dayRow, rowValues);
|
|
738
|
+
if (hourlyMode) {
|
|
739
|
+
addUsageToRow(
|
|
740
|
+
hourly[intervalIndexAt(hourlyIntervals, timestampMs)],
|
|
741
|
+
rowValues,
|
|
742
|
+
);
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
for (const row of daily) {
|
|
747
|
+
row.uncachedInputTokens = Math.max(0, row.inputTokens - row.cachedInputTokens);
|
|
748
|
+
row.cacheRatePercent =
|
|
749
|
+
row.inputTokens > 0
|
|
750
|
+
? (row.cachedInputTokens / row.inputTokens) * 100
|
|
751
|
+
: null;
|
|
752
|
+
row.models = [...row.models.values()].sort(
|
|
753
|
+
(left, right) => right.totalTokens - left.totalTokens,
|
|
754
|
+
);
|
|
755
|
+
}
|
|
756
|
+
for (const row of hourly ?? []) {
|
|
757
|
+
row.uncachedInputTokens = Math.max(0, row.inputTokens - row.cachedInputTokens);
|
|
758
|
+
row.cacheRatePercent =
|
|
759
|
+
row.inputTokens > 0
|
|
760
|
+
? (row.cachedInputTokens / row.inputTokens) * 100
|
|
761
|
+
: null;
|
|
762
|
+
row.models = [...row.models.values()].sort(
|
|
763
|
+
(left, right) => right.totalTokens - left.totalTokens,
|
|
764
|
+
);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
const uncachedInputTokens = Math.max(0, inputTokens - cachedInputTokens);
|
|
768
|
+
const modelRows = [...models.values()]
|
|
769
|
+
.map((row) => ({
|
|
770
|
+
...row,
|
|
771
|
+
sharePercent: totalTokens > 0 ? (row.totalTokens / totalTokens) * 100 : 0,
|
|
772
|
+
uncachedInputTokens: Math.max(
|
|
773
|
+
0,
|
|
774
|
+
row.cacheInputTokens - row.cachedInputTokens,
|
|
775
|
+
),
|
|
776
|
+
cacheRatePercent:
|
|
777
|
+
row.cacheInputTokens > 0
|
|
778
|
+
? (row.cachedInputTokens / row.cacheInputTokens) * 100
|
|
779
|
+
: null,
|
|
780
|
+
}))
|
|
781
|
+
.sort(
|
|
782
|
+
(left, right) =>
|
|
783
|
+
right.totalTokens - left.totalTokens ||
|
|
784
|
+
left.model.localeCompare(right.model),
|
|
785
|
+
);
|
|
786
|
+
|
|
787
|
+
// Projects: prefer sanitized rows aggregated by the caller from the same
|
|
788
|
+
// bounded event set; otherwise group locally by raw project label.
|
|
789
|
+
let allProjectRows;
|
|
790
|
+
if (projectRows) {
|
|
791
|
+
allProjectRows = projectRows.map((row) => ({
|
|
792
|
+
project: row.project,
|
|
793
|
+
displayProject: row.displayProject ?? row.project,
|
|
794
|
+
totalTokens: Math.max(0, Number(row.totalTokens) || 0),
|
|
795
|
+
estimated: row.estimated === true,
|
|
796
|
+
}));
|
|
797
|
+
} else {
|
|
798
|
+
const projectTotals = new Map();
|
|
799
|
+
for (const { tokens, event } of boundedEvents) {
|
|
800
|
+
if (!(tokens > 0)) continue;
|
|
801
|
+
const project =
|
|
802
|
+
String(event.project ?? "")
|
|
803
|
+
.replace(/[\t\r\n]+/g, " ")
|
|
804
|
+
.replace(/\s+/g, " ")
|
|
805
|
+
.trim() || "Unlabelled activity";
|
|
806
|
+
const row = projectTotals.get(project) ?? {
|
|
807
|
+
totalTokens: 0,
|
|
808
|
+
estimated: false,
|
|
809
|
+
};
|
|
810
|
+
row.totalTokens += tokens;
|
|
811
|
+
row.estimated ||= event.rangeAllocationEstimated === true;
|
|
812
|
+
projectTotals.set(project, row);
|
|
813
|
+
}
|
|
814
|
+
allProjectRows = [...projectTotals.entries()].map(
|
|
815
|
+
([project, row]) => ({
|
|
816
|
+
project,
|
|
817
|
+
displayProject: project,
|
|
818
|
+
totalTokens: row.totalTokens,
|
|
819
|
+
estimated: row.estimated,
|
|
820
|
+
}),
|
|
821
|
+
);
|
|
822
|
+
}
|
|
823
|
+
allProjectRows.sort(
|
|
824
|
+
(left, right) =>
|
|
825
|
+
right.totalTokens - left.totalTokens ||
|
|
826
|
+
left.project.localeCompare(right.project),
|
|
827
|
+
);
|
|
828
|
+
const topProjects = allProjectRows.slice(0, 4).map((row) => ({
|
|
829
|
+
...row,
|
|
830
|
+
sharePercent: totalTokens > 0 ? (row.totalTokens / totalTokens) * 100 : 0,
|
|
831
|
+
}));
|
|
832
|
+
const remainderRows = allProjectRows.slice(4);
|
|
833
|
+
const remainderTokens = remainderRows.reduce(
|
|
834
|
+
(sum, row) => sum + row.totalTokens,
|
|
835
|
+
0,
|
|
836
|
+
);
|
|
837
|
+
const projectRemainder = {
|
|
838
|
+
count: remainderRows.length,
|
|
839
|
+
totalTokens: remainderTokens,
|
|
840
|
+
sharePercent: totalTokens > 0 ? (remainderTokens / totalTokens) * 100 : 0,
|
|
841
|
+
estimated: remainderRows.some((row) => row.estimated),
|
|
842
|
+
};
|
|
843
|
+
const topFourProjectTokens = topProjects.reduce(
|
|
844
|
+
(sum, row) => sum + row.totalTokens,
|
|
845
|
+
0,
|
|
846
|
+
);
|
|
847
|
+
|
|
848
|
+
const meter = buildMeter({
|
|
849
|
+
snapshot,
|
|
850
|
+
bounds,
|
|
851
|
+
effectiveEndMs,
|
|
852
|
+
sourceStatus,
|
|
853
|
+
events: boundedEvents,
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
const durationMs = effectiveEndMs - startMs;
|
|
857
|
+
const totalDeltaPercent =
|
|
858
|
+
priorHasEvents && priorEquivalentTokens > 0 && durationMs > 0
|
|
859
|
+
? (totalTokens / priorEquivalentTokens - 1) * 100
|
|
860
|
+
: null;
|
|
861
|
+
const estimated = daily.some((row) => row.estimated);
|
|
862
|
+
const rawLegacySnapshotStatus = snapshot.coverage?.legacySnapshotStatus ??
|
|
863
|
+
snapshot.metadata?.durableLedger?.legacySnapshotStatus;
|
|
864
|
+
const legacySnapshotStatus = primitiveString(rawLegacySnapshotStatus);
|
|
865
|
+
const maximumEstimatedResolutionSeconds = boundedEvents.reduce(
|
|
866
|
+
(maximum, { tokens, event }) => {
|
|
867
|
+
if (!(tokens > 0) || event.rangeAllocationEstimated !== true) {
|
|
868
|
+
return maximum;
|
|
869
|
+
}
|
|
870
|
+
const resolutionSeconds = Number(event.resolutionSeconds);
|
|
871
|
+
return Number.isFinite(resolutionSeconds) && resolutionSeconds > 0
|
|
872
|
+
? Math.max(maximum, resolutionSeconds)
|
|
873
|
+
: maximum;
|
|
874
|
+
},
|
|
875
|
+
0,
|
|
876
|
+
);
|
|
877
|
+
|
|
878
|
+
const snapshotGeneratedAtMs = finiteTimestamp(snapshot.generatedAt);
|
|
879
|
+
const viewModel = {
|
|
880
|
+
meta: {
|
|
881
|
+
startMs,
|
|
882
|
+
requestedEndMs,
|
|
883
|
+
effectiveEndMs,
|
|
884
|
+
timeZone,
|
|
885
|
+
rangeDays,
|
|
886
|
+
granularity: hourlyMode ? "hour" : "day",
|
|
887
|
+
startDateString: bounds.startDateString,
|
|
888
|
+
endDateString: bounds.endDateString,
|
|
889
|
+
partialFinalDay,
|
|
890
|
+
observedThroughDateString:
|
|
891
|
+
lastObservedDayIndex === undefined ? null : lastObservedDateString,
|
|
892
|
+
reportThroughMs: effectiveEndMs,
|
|
893
|
+
meterObservedThroughMs: meter.observedThroughMs,
|
|
894
|
+
sourceStatus,
|
|
895
|
+
},
|
|
896
|
+
summary: {
|
|
897
|
+
totalTokens,
|
|
898
|
+
estimated,
|
|
899
|
+
priorEquivalentTokens: priorHasEvents ? priorEquivalentTokens : null,
|
|
900
|
+
priorEquivalentEstimated:
|
|
901
|
+
priorHasEvents && priorEquivalentEstimated,
|
|
902
|
+
totalDeltaPercent,
|
|
903
|
+
totalDeltaEstimated:
|
|
904
|
+
totalDeltaPercent !== null && (estimated || priorEquivalentEstimated),
|
|
905
|
+
inputTokens,
|
|
906
|
+
outputTokens,
|
|
907
|
+
cachedInputTokens,
|
|
908
|
+
uncachedInputTokens,
|
|
909
|
+
cacheRatePercent:
|
|
910
|
+
inputTokens > 0 ? (cachedInputTokens / inputTokens) * 100 : null,
|
|
911
|
+
fastTokens,
|
|
912
|
+
normalTokens,
|
|
913
|
+
unknownTokens,
|
|
914
|
+
fastEstimated: boundedEvents.some(
|
|
915
|
+
({ fast, tokens, event }) =>
|
|
916
|
+
fast && tokens > 0 && event.rangeAllocationEstimated === true,
|
|
917
|
+
),
|
|
918
|
+
fastSharePercent: totalTokens > 0 ? (fastTokens / totalTokens) * 100 : null,
|
|
919
|
+
unknownSharePercent:
|
|
920
|
+
totalTokens > 0 ? (unknownTokens / totalTokens) * 100 : null,
|
|
921
|
+
activeProjects: allProjectRows.length,
|
|
922
|
+
topFourProjectTokens,
|
|
923
|
+
topFourProjectSharePercent:
|
|
924
|
+
totalTokens > 0 ? (topFourProjectTokens / totalTokens) * 100 : null,
|
|
925
|
+
},
|
|
926
|
+
models: modelRows,
|
|
927
|
+
daily,
|
|
928
|
+
hourly,
|
|
929
|
+
meter,
|
|
930
|
+
projects: topProjects,
|
|
931
|
+
projectRemainder,
|
|
932
|
+
coverage: {
|
|
933
|
+
modelCalls,
|
|
934
|
+
detailedCalls,
|
|
935
|
+
detailedTokens,
|
|
936
|
+
componentCoveragePercent:
|
|
937
|
+
totalTokens > 0 ? (detailedTokens / totalTokens) * 100 : 100,
|
|
938
|
+
parseErrors: Math.max(0, Number(snapshot.coverage?.parseErrors) || 0),
|
|
939
|
+
invalidTokenRecords: Math.max(
|
|
940
|
+
0,
|
|
941
|
+
Number(snapshot.coverage?.invalidTokenRecords) || 0,
|
|
942
|
+
),
|
|
943
|
+
invalidQuotaRecords: Math.max(
|
|
944
|
+
0,
|
|
945
|
+
Number(snapshot.coverage?.invalidQuotaRecords) || 0,
|
|
946
|
+
),
|
|
947
|
+
sourceIncomplete: snapshot.coverage?.sourceIncomplete === true,
|
|
948
|
+
estimated,
|
|
949
|
+
estimatedBucketCount: daily.filter((row) => row.estimated).length,
|
|
950
|
+
maximumResolutionSeconds: maximumEstimatedResolutionSeconds || null,
|
|
951
|
+
legacySnapshotStatus,
|
|
952
|
+
},
|
|
953
|
+
provenance: {
|
|
954
|
+
localOnly: (snapshot.provenance?.kind ?? "codex-local-metadata") ===
|
|
955
|
+
"codex-local-metadata",
|
|
956
|
+
historyScope: historyScopeLabel(snapshot),
|
|
957
|
+
snapshotGeneratedAtMs,
|
|
958
|
+
rateCardAsOf: CODEX_CREDIT_RATE_CARD_AS_OF,
|
|
959
|
+
snapshotRateCardAsOf: snapshot.provenance?.rateCardAsOf ?? null,
|
|
960
|
+
},
|
|
961
|
+
};
|
|
962
|
+
|
|
963
|
+
validateReportViewModel(viewModel);
|
|
964
|
+
return viewModel;
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
function modelRowsFor(row) {
|
|
968
|
+
if (row?.models instanceof Map) return [...row.models.values()];
|
|
969
|
+
return Array.isArray(row?.models) ? row.models : [];
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
function assertServiceTierBuckets(label, row) {
|
|
973
|
+
for (const field of ["normalTokens", "fastTokens", "unknownTokens"]) {
|
|
974
|
+
if (!Number.isFinite(row[field]) || row[field] < 0) {
|
|
975
|
+
throw new Error(
|
|
976
|
+
`Report reconciliation failed: ${label} ${field} is invalid`,
|
|
977
|
+
);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
assertReconciles(
|
|
981
|
+
`${label} service-tier buckets must sum to total usage`,
|
|
982
|
+
row.normalTokens + row.fastTokens + row.unknownTokens,
|
|
983
|
+
row.totalTokens,
|
|
984
|
+
);
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
// Core reconciliation invariants from the report specification. A failure is
|
|
988
|
+
// a calculation bug, never something to render around, so it throws with a
|
|
989
|
+
// descriptive message.
|
|
990
|
+
export function validateReportViewModel(viewModel) {
|
|
991
|
+
const { summary, daily, hourly, models, projects, projectRemainder } = viewModel;
|
|
992
|
+
|
|
993
|
+
assertReconciles(
|
|
994
|
+
"daily totals must sum to total usage",
|
|
995
|
+
daily.reduce((sum, row) => sum + row.totalTokens, 0),
|
|
996
|
+
summary.totalTokens,
|
|
997
|
+
);
|
|
998
|
+
assertReconciles(
|
|
999
|
+
"model totals must sum to total usage",
|
|
1000
|
+
models.reduce((sum, row) => sum + row.totalTokens, 0),
|
|
1001
|
+
summary.totalTokens,
|
|
1002
|
+
);
|
|
1003
|
+
assertReconciles(
|
|
1004
|
+
"model normal tokens must sum to overall normal tokens",
|
|
1005
|
+
models.reduce((sum, row) => sum + row.normalTokens, 0),
|
|
1006
|
+
summary.normalTokens,
|
|
1007
|
+
);
|
|
1008
|
+
assertReconciles(
|
|
1009
|
+
"model fast tokens must sum to overall fast tokens",
|
|
1010
|
+
models.reduce((sum, row) => sum + row.fastTokens, 0),
|
|
1011
|
+
summary.fastTokens,
|
|
1012
|
+
);
|
|
1013
|
+
assertReconciles(
|
|
1014
|
+
"model unknown tokens must sum to overall unknown tokens",
|
|
1015
|
+
models.reduce((sum, row) => sum + row.unknownTokens, 0),
|
|
1016
|
+
summary.unknownTokens,
|
|
1017
|
+
);
|
|
1018
|
+
assertServiceTierBuckets("summary", summary);
|
|
1019
|
+
for (const row of models) {
|
|
1020
|
+
assertServiceTierBuckets(`model ${row.model}`, row);
|
|
1021
|
+
}
|
|
1022
|
+
assertReconciles(
|
|
1023
|
+
"project totals plus remainder must sum to total usage",
|
|
1024
|
+
projects.reduce((sum, row) => sum + row.totalTokens, 0) +
|
|
1025
|
+
projectRemainder.totalTokens,
|
|
1026
|
+
summary.totalTokens,
|
|
1027
|
+
);
|
|
1028
|
+
assertReconciles(
|
|
1029
|
+
"daily input must sum to overall input",
|
|
1030
|
+
daily.reduce((sum, row) => sum + row.inputTokens, 0),
|
|
1031
|
+
summary.inputTokens,
|
|
1032
|
+
);
|
|
1033
|
+
if (Array.isArray(hourly)) {
|
|
1034
|
+
assertReconciles(
|
|
1035
|
+
"hourly totals must sum to total usage",
|
|
1036
|
+
hourly.reduce((sum, row) => sum + row.totalTokens, 0),
|
|
1037
|
+
summary.totalTokens,
|
|
1038
|
+
);
|
|
1039
|
+
assertReconciles(
|
|
1040
|
+
"hourly model totals must sum to total usage",
|
|
1041
|
+
hourly.reduce(
|
|
1042
|
+
(sum, row) => sum + [...row.models.values()]
|
|
1043
|
+
.reduce((rowTotal, model) => rowTotal + model.totalTokens, 0),
|
|
1044
|
+
0,
|
|
1045
|
+
),
|
|
1046
|
+
summary.totalTokens,
|
|
1047
|
+
);
|
|
1048
|
+
assertReconciles(
|
|
1049
|
+
"hourly input must sum to overall input",
|
|
1050
|
+
hourly.reduce((sum, row) => sum + row.inputTokens, 0),
|
|
1051
|
+
summary.inputTokens,
|
|
1052
|
+
);
|
|
1053
|
+
assertReconciles(
|
|
1054
|
+
"hourly cached input must sum to overall cached input",
|
|
1055
|
+
hourly.reduce((sum, row) => sum + row.cachedInputTokens, 0),
|
|
1056
|
+
summary.cachedInputTokens,
|
|
1057
|
+
);
|
|
1058
|
+
assertReconciles(
|
|
1059
|
+
"hourly fast tokens must sum to overall fast tokens",
|
|
1060
|
+
hourly.reduce(
|
|
1061
|
+
(sum, row) => sum + [...row.models.values()]
|
|
1062
|
+
.reduce((rowTotal, model) => rowTotal + model.fastTokens, 0),
|
|
1063
|
+
0,
|
|
1064
|
+
),
|
|
1065
|
+
summary.fastTokens,
|
|
1066
|
+
);
|
|
1067
|
+
assertReconciles(
|
|
1068
|
+
"hourly normal tokens must sum to overall normal tokens",
|
|
1069
|
+
hourly.reduce(
|
|
1070
|
+
(sum, row) => sum + modelRowsFor(row)
|
|
1071
|
+
.reduce((rowTotal, model) => rowTotal + model.normalTokens, 0),
|
|
1072
|
+
0,
|
|
1073
|
+
),
|
|
1074
|
+
summary.normalTokens,
|
|
1075
|
+
);
|
|
1076
|
+
assertReconciles(
|
|
1077
|
+
"hourly unknown tokens must sum to overall unknown tokens",
|
|
1078
|
+
hourly.reduce(
|
|
1079
|
+
(sum, row) => sum + modelRowsFor(row)
|
|
1080
|
+
.reduce((rowTotal, model) => rowTotal + model.unknownTokens, 0),
|
|
1081
|
+
0,
|
|
1082
|
+
),
|
|
1083
|
+
summary.unknownTokens,
|
|
1084
|
+
);
|
|
1085
|
+
for (const row of hourly) {
|
|
1086
|
+
for (const model of modelRowsFor(row)) {
|
|
1087
|
+
assertServiceTierBuckets(`hour ${row.startMs} model ${model.model}`, model);
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
assertReconciles(
|
|
1092
|
+
"model cache input must sum to overall input",
|
|
1093
|
+
models.reduce((sum, row) => sum + row.cacheInputTokens, 0),
|
|
1094
|
+
summary.inputTokens,
|
|
1095
|
+
);
|
|
1096
|
+
if (summary.cachedInputTokens > summary.inputTokens) {
|
|
1097
|
+
throw new Error(
|
|
1098
|
+
"Report reconciliation failed: cached input exceeds input",
|
|
1099
|
+
);
|
|
1100
|
+
}
|
|
1101
|
+
assertReconciles(
|
|
1102
|
+
"uncached input must equal input minus cached input",
|
|
1103
|
+
summary.uncachedInputTokens,
|
|
1104
|
+
summary.inputTokens - summary.cachedInputTokens,
|
|
1105
|
+
);
|
|
1106
|
+
if (summary.fastTokens > summary.totalTokens) {
|
|
1107
|
+
throw new Error(
|
|
1108
|
+
"Report reconciliation failed: fast-mode tokens exceed total usage",
|
|
1109
|
+
);
|
|
1110
|
+
}
|
|
1111
|
+
if (summary.normalTokens > summary.totalTokens) {
|
|
1112
|
+
throw new Error(
|
|
1113
|
+
"Report reconciliation failed: known normal tokens exceed total usage",
|
|
1114
|
+
);
|
|
1115
|
+
}
|
|
1116
|
+
if (summary.unknownTokens > summary.totalTokens) {
|
|
1117
|
+
throw new Error(
|
|
1118
|
+
"Report reconciliation failed: unknown-tier tokens exceed total usage",
|
|
1119
|
+
);
|
|
1120
|
+
}
|
|
1121
|
+
for (const row of daily) {
|
|
1122
|
+
if (row.inputTokens > row.totalTokens) {
|
|
1123
|
+
throw new Error(
|
|
1124
|
+
`Report reconciliation failed: ${row.dateString} input exceeds its total`,
|
|
1125
|
+
);
|
|
1126
|
+
}
|
|
1127
|
+
if (row.cachedInputTokens > row.inputTokens) {
|
|
1128
|
+
throw new Error(
|
|
1129
|
+
`Report reconciliation failed: ${row.dateString} cached input exceeds its input`,
|
|
1130
|
+
);
|
|
1131
|
+
}
|
|
1132
|
+
for (const model of modelRowsFor(row)) {
|
|
1133
|
+
assertServiceTierBuckets(`day ${row.dateString} model ${model.model}`, model);
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
for (const row of models) {
|
|
1137
|
+
if (row.fastTokens > row.totalTokens) {
|
|
1138
|
+
throw new Error(
|
|
1139
|
+
`Report reconciliation failed: ${row.model} fast tokens exceed its total`,
|
|
1140
|
+
);
|
|
1141
|
+
}
|
|
1142
|
+
if (row.uncachedInputTokens < 0) {
|
|
1143
|
+
throw new Error(
|
|
1144
|
+
`Report reconciliation failed: ${row.model} uncached input is negative`,
|
|
1145
|
+
);
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
if (
|
|
1149
|
+
summary.inputTokens > 0 &&
|
|
1150
|
+
!approxEqual(
|
|
1151
|
+
summary.cacheRatePercent,
|
|
1152
|
+
(summary.cachedInputTokens / summary.inputTokens) * 100,
|
|
1153
|
+
)
|
|
1154
|
+
) {
|
|
1155
|
+
throw new Error(
|
|
1156
|
+
"Report reconciliation failed: cache rate must be input-token weighted",
|
|
1157
|
+
);
|
|
1158
|
+
}
|
|
1159
|
+
}
|