tledger 0.1.4 → 0.2.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 +122 -80
- package/bin/token-ledger-rates.mjs +62 -0
- package/bin/token-ledger-terminal.mjs +133 -257
- package/bin/token-ledger-trend-image.mjs +945 -0
- package/bin/token-ledger-trend-terminal.mjs +609 -0
- package/bin/token-ledger-trend.mjs +745 -0
- package/bin/token-ledger-tui.mjs +15 -21
- package/bin/token-ledger.mjs +408 -248
- package/lib/{token-ledger-collector.mjs → token-ledger-importer.mjs} +256 -409
- package/package.json +18 -14
- package/lib/token-ledger-models.mjs +0 -113
|
@@ -0,0 +1,745 @@
|
|
|
1
|
+
import {
|
|
2
|
+
creditsForUsage,
|
|
3
|
+
FAST_MODE_MULTIPLIER,
|
|
4
|
+
RATE_CARD_AS_OF,
|
|
5
|
+
} from "./token-ledger-rates.mjs";
|
|
6
|
+
|
|
7
|
+
const WEEK_MINUTES = 10_080;
|
|
8
|
+
const RESET_JITTER_SECONDS = 5 * 60;
|
|
9
|
+
// Meter observations more than this far apart get their burn spread across
|
|
10
|
+
// calendar days as an estimate rather than pinned to the observation day.
|
|
11
|
+
const LONG_GAP_MS = 36 * 60 * 60 * 1_000;
|
|
12
|
+
|
|
13
|
+
const MODEL_SORT_ORDER = new Map([
|
|
14
|
+
["Luna", 0],
|
|
15
|
+
["Sol", 1],
|
|
16
|
+
["Terra", 2],
|
|
17
|
+
["GPT-5.5", 3],
|
|
18
|
+
["GPT-5.4", 4],
|
|
19
|
+
["Daybreak", 5],
|
|
20
|
+
["Auto review", 6],
|
|
21
|
+
["Other", 7],
|
|
22
|
+
["Unknown", 8],
|
|
23
|
+
["Unattributed", 9],
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
function finiteTimestamp(value) {
|
|
27
|
+
const timestamp = new Date(value).getTime();
|
|
28
|
+
return Number.isFinite(timestamp) ? timestamp : null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function dateStringFromParts(parts) {
|
|
32
|
+
return [parts.year, parts.month, parts.day]
|
|
33
|
+
.map((value, index) =>
|
|
34
|
+
index === 0 ? String(value) : String(value).padStart(2, "0"),
|
|
35
|
+
)
|
|
36
|
+
.join("-");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function shiftCalendarDate(dateString, amount) {
|
|
40
|
+
const [year, month, day] = dateString.split("-").map(Number);
|
|
41
|
+
const date = new Date(Date.UTC(year, month - 1, day + amount));
|
|
42
|
+
return dateStringFromParts({
|
|
43
|
+
year: date.getUTCFullYear(),
|
|
44
|
+
month: date.getUTCMonth() + 1,
|
|
45
|
+
day: date.getUTCDate(),
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function offsetAt(instant, timeZone) {
|
|
50
|
+
const parts = new Intl.DateTimeFormat("en-US", {
|
|
51
|
+
timeZone,
|
|
52
|
+
timeZoneName: "longOffset",
|
|
53
|
+
}).formatToParts(instant);
|
|
54
|
+
const value = parts.find((part) => part.type === "timeZoneName")?.value ?? "GMT";
|
|
55
|
+
if (value === "GMT") return 0;
|
|
56
|
+
const match = value.match(/^GMT([+-])(\d{2}):?(\d{2})?$/);
|
|
57
|
+
if (!match) return 0;
|
|
58
|
+
const minutes = Number(match[2]) * 60 + Number(match[3] || 0);
|
|
59
|
+
return (match[1] === "+" ? 1 : -1) * minutes * 60 * 1_000;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function zonedMidnight(dateString, timeZone) {
|
|
63
|
+
const [year, month, day] = dateString.split("-").map(Number);
|
|
64
|
+
const utcGuess = Date.UTC(year, month - 1, day);
|
|
65
|
+
let instant = new Date(utcGuess - offsetAt(new Date(utcGuess), timeZone));
|
|
66
|
+
instant = new Date(utcGuess - offsetAt(instant, timeZone));
|
|
67
|
+
return instant;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function localDateString(timestampMs, timeZone) {
|
|
71
|
+
return new Intl.DateTimeFormat("en-CA", {
|
|
72
|
+
timeZone,
|
|
73
|
+
year: "numeric",
|
|
74
|
+
month: "2-digit",
|
|
75
|
+
day: "2-digit",
|
|
76
|
+
}).format(new Date(timestampMs));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function todayInTimeZone(timeZone) {
|
|
80
|
+
const parts = new Intl.DateTimeFormat("en-US", {
|
|
81
|
+
timeZone,
|
|
82
|
+
year: "numeric",
|
|
83
|
+
month: "2-digit",
|
|
84
|
+
day: "2-digit",
|
|
85
|
+
}).formatToParts(new Date());
|
|
86
|
+
const values = Object.fromEntries(
|
|
87
|
+
parts
|
|
88
|
+
.filter((part) => part.type !== "literal")
|
|
89
|
+
.map((part) => [part.type, Number(part.value)]),
|
|
90
|
+
);
|
|
91
|
+
return dateStringFromParts(values);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function multiDayBounds(value, timeZone, rangeDays) {
|
|
95
|
+
if (![7, 14, 30].includes(Number(rangeDays))) {
|
|
96
|
+
throw new Error("Trend range must be 7, 14, or 30 days.");
|
|
97
|
+
}
|
|
98
|
+
let endDateString = value;
|
|
99
|
+
if (!endDateString || endDateString === "today") {
|
|
100
|
+
endDateString = todayInTimeZone(timeZone);
|
|
101
|
+
} else if (endDateString === "yesterday") {
|
|
102
|
+
endDateString = shiftCalendarDate(todayInTimeZone(timeZone), -1);
|
|
103
|
+
}
|
|
104
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(endDateString)) {
|
|
105
|
+
throw new Error("Trend end date must be YYYY-MM-DD, today, or yesterday.");
|
|
106
|
+
}
|
|
107
|
+
const [year, month, day] = endDateString.split("-").map(Number);
|
|
108
|
+
const check = new Date(Date.UTC(year, month - 1, day));
|
|
109
|
+
if (
|
|
110
|
+
check.getUTCFullYear() !== year ||
|
|
111
|
+
check.getUTCMonth() + 1 !== month ||
|
|
112
|
+
check.getUTCDate() !== day
|
|
113
|
+
) {
|
|
114
|
+
throw new Error(`Invalid calendar day: ${endDateString}`);
|
|
115
|
+
}
|
|
116
|
+
const startDateString = shiftCalendarDate(endDateString, -Number(rangeDays) + 1);
|
|
117
|
+
return {
|
|
118
|
+
dateString: endDateString,
|
|
119
|
+
startDateString,
|
|
120
|
+
endDateString,
|
|
121
|
+
start: zonedMidnight(startDateString, timeZone),
|
|
122
|
+
end: zonedMidnight(shiftCalendarDate(endDateString, 1), timeZone),
|
|
123
|
+
timeZone,
|
|
124
|
+
rangeDays: Number(rangeDays),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function clampPercent(value) {
|
|
129
|
+
return Math.min(100, Math.max(0, Number(value) || 0));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function trendModelLabel(value) {
|
|
133
|
+
const model = String(value || "unknown").trim().toLowerCase();
|
|
134
|
+
if (model.includes("luna")) return "Luna";
|
|
135
|
+
if (model.includes("sol")) return "Sol";
|
|
136
|
+
if (model.includes("terra")) return "Terra";
|
|
137
|
+
if (model.includes("daybreak")) return "Daybreak";
|
|
138
|
+
if (model.includes("auto-review")) return "Auto review";
|
|
139
|
+
if (model === "gpt-5.5" || model.startsWith("gpt-5.5-")) return "GPT-5.5";
|
|
140
|
+
if (model === "gpt-5.4" || model.startsWith("gpt-5.4-")) return "GPT-5.4";
|
|
141
|
+
if (model === "unknown" || !model) return "Unknown";
|
|
142
|
+
return "Other";
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function weeklyQuotaObservations(snapshot = {}) {
|
|
146
|
+
let observations = (snapshot.quotaObservations ?? [])
|
|
147
|
+
.map((observation) => ({
|
|
148
|
+
...observation,
|
|
149
|
+
timestampMs: finiteTimestamp(observation.timestamp),
|
|
150
|
+
resetsAt: Number(observation.resetsAt),
|
|
151
|
+
usedPercent: Number(observation.usedPercent),
|
|
152
|
+
}))
|
|
153
|
+
.filter(
|
|
154
|
+
(observation) =>
|
|
155
|
+
Number(observation.windowMinutes) === WEEK_MINUTES &&
|
|
156
|
+
observation.timestampMs !== null &&
|
|
157
|
+
Number.isFinite(observation.resetsAt) &&
|
|
158
|
+
observation.resetsAt > 0 &&
|
|
159
|
+
Number.isFinite(observation.usedPercent),
|
|
160
|
+
)
|
|
161
|
+
.map((observation) => ({
|
|
162
|
+
...observation,
|
|
163
|
+
usedPercent: clampPercent(observation.usedPercent),
|
|
164
|
+
}));
|
|
165
|
+
|
|
166
|
+
// Keep exactly one meter: the account-wide weekly limit. Legacy snapshots
|
|
167
|
+
// tag it with scope: "account"; current snapshots carry a limitKey per
|
|
168
|
+
// limit bucket, where the account-wide bucket has no limitName. Named
|
|
169
|
+
// buckets (per-model limit pools) are separate meters and must not be
|
|
170
|
+
// stitched into this line.
|
|
171
|
+
const accountScoped = observations.filter(
|
|
172
|
+
(observation) => observation.scope === "account",
|
|
173
|
+
);
|
|
174
|
+
if (accountScoped.length) {
|
|
175
|
+
observations = accountScoped;
|
|
176
|
+
} else if (observations.some((observation) => observation.limitKey)) {
|
|
177
|
+
const groups = new Map();
|
|
178
|
+
for (const observation of observations) {
|
|
179
|
+
const key = observation.limitKey ?? "anonymous";
|
|
180
|
+
const group = groups.get(key) ?? [];
|
|
181
|
+
group.push(observation);
|
|
182
|
+
groups.set(key, group);
|
|
183
|
+
}
|
|
184
|
+
const accountWide = [...groups.values()].filter((group) =>
|
|
185
|
+
group.every((observation) => !observation.limitName),
|
|
186
|
+
);
|
|
187
|
+
const pool = accountWide.length ? accountWide : [...groups.values()];
|
|
188
|
+
observations = pool.sort((left, right) => right.length - left.length)[0];
|
|
189
|
+
} else {
|
|
190
|
+
const accountWide = observations.filter(
|
|
191
|
+
(observation) => !observation.limitName,
|
|
192
|
+
);
|
|
193
|
+
if (accountWide.length) observations = accountWide;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
observations.sort(
|
|
197
|
+
(left, right) =>
|
|
198
|
+
left.timestampMs - right.timestampMs || left.resetsAt - right.resetsAt,
|
|
199
|
+
);
|
|
200
|
+
return observations;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// The provider freezes resets_at for the lifetime of a limit window, so the
|
|
204
|
+
// reset timestamp is the window's identity. Cycles are therefore keyed by
|
|
205
|
+
// resets_at clusters instead of inferred from usage drops: refill events that
|
|
206
|
+
// start a fresh window days before the old one expires (limit restarts) and
|
|
207
|
+
// stale readings from sessions still reporting a superseded window would
|
|
208
|
+
// otherwise be fused into one line, producing meter drain that never happened.
|
|
209
|
+
export function normalizeQuotaTimeline(observations) {
|
|
210
|
+
if (!observations.length) return [];
|
|
211
|
+
|
|
212
|
+
const epochs = [];
|
|
213
|
+
for (const observation of observations) {
|
|
214
|
+
let epoch = epochs.find(
|
|
215
|
+
(candidate) =>
|
|
216
|
+
Math.abs(candidate.resetsAt - observation.resetsAt) <=
|
|
217
|
+
RESET_JITTER_SECONDS,
|
|
218
|
+
);
|
|
219
|
+
if (!epoch) {
|
|
220
|
+
epoch = { resetsAt: observation.resetsAt, observations: [] };
|
|
221
|
+
epochs.push(epoch);
|
|
222
|
+
}
|
|
223
|
+
epoch.resetsAt = Math.max(epoch.resetsAt, observation.resetsAt);
|
|
224
|
+
epoch.observations.push(observation);
|
|
225
|
+
}
|
|
226
|
+
for (const epoch of epochs) {
|
|
227
|
+
epoch.firstMs = epoch.observations[0].timestampMs;
|
|
228
|
+
epoch.lastMs = epoch.observations.at(-1).timestampMs;
|
|
229
|
+
}
|
|
230
|
+
epochs.sort(
|
|
231
|
+
(left, right) =>
|
|
232
|
+
left.firstMs - right.firstMs || left.resetsAt - right.resetsAt,
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
// A window with at most two readings sandwiched inside a longer-lived
|
|
236
|
+
// window's span is a transient branch (for example a single stale refresh),
|
|
237
|
+
// not a real refill.
|
|
238
|
+
const kept = epochs.filter((epoch) => {
|
|
239
|
+
if (epoch.observations.length > 2) return true;
|
|
240
|
+
return !epochs.some(
|
|
241
|
+
(other) =>
|
|
242
|
+
other !== epoch &&
|
|
243
|
+
other.observations.length > epoch.observations.length &&
|
|
244
|
+
other.firstMs < epoch.firstMs &&
|
|
245
|
+
other.lastMs > epoch.lastMs,
|
|
246
|
+
);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
const normalized = [];
|
|
250
|
+
let previousEpoch = null;
|
|
251
|
+
for (const [cycle, epoch] of kept.entries()) {
|
|
252
|
+
const nextFirstMs = kept[cycle + 1]?.firstMs ?? Infinity;
|
|
253
|
+
const resetKind =
|
|
254
|
+
previousEpoch === null
|
|
255
|
+
? "start"
|
|
256
|
+
: previousEpoch.resetsAt * 1_000 <=
|
|
257
|
+
epoch.firstMs + RESET_JITTER_SECONDS * 1_000
|
|
258
|
+
? "weekly-expiry"
|
|
259
|
+
: "restart";
|
|
260
|
+
let usedPercent = null;
|
|
261
|
+
let emitted = false;
|
|
262
|
+
for (const observation of epoch.observations) {
|
|
263
|
+
// Once a newer window starts reporting, remaining readings of this
|
|
264
|
+
// window are stale echoes from long-lived sessions.
|
|
265
|
+
if (observation.timestampMs >= nextFirstMs) continue;
|
|
266
|
+
// Usage is cumulative inside a window; clamp display-rounding dips.
|
|
267
|
+
usedPercent =
|
|
268
|
+
usedPercent === null
|
|
269
|
+
? observation.usedPercent
|
|
270
|
+
: Math.max(usedPercent, observation.usedPercent);
|
|
271
|
+
normalized.push({
|
|
272
|
+
...observation,
|
|
273
|
+
cycle,
|
|
274
|
+
reset: !emitted && previousEpoch !== null,
|
|
275
|
+
resetKind,
|
|
276
|
+
cycleStartMs: (epoch.resetsAt - WEEK_MINUTES * 60) * 1_000,
|
|
277
|
+
normalizedUsedPercent: usedPercent,
|
|
278
|
+
});
|
|
279
|
+
emitted = true;
|
|
280
|
+
}
|
|
281
|
+
if (emitted) previousEpoch = epoch;
|
|
282
|
+
}
|
|
283
|
+
return normalized;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function eventCredits(event) {
|
|
287
|
+
// Recompute from token components first so the current rate card applies;
|
|
288
|
+
// snapshots can carry credits stored under an outdated card. Fast-mode
|
|
289
|
+
// turns (service tier "priority") debit the limit at a higher rate.
|
|
290
|
+
const multiplier =
|
|
291
|
+
event.serviceTier === "priority" ? FAST_MODE_MULTIPLIER : 1;
|
|
292
|
+
const computed = creditsForUsage(event.model, event);
|
|
293
|
+
if (Number.isFinite(computed) && computed >= 0) return computed * multiplier;
|
|
294
|
+
const stored = Number(event.rateCardCredits);
|
|
295
|
+
if (event.rateCardCredits !== null && event.rateCardCredits !== undefined) {
|
|
296
|
+
// Stored credits from current snapshots already include the fast-mode
|
|
297
|
+
// multiplier.
|
|
298
|
+
if (Number.isFinite(stored) && stored >= 0) return stored;
|
|
299
|
+
}
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function eventWeight(event, fallbackCreditsPerToken) {
|
|
304
|
+
const credits = eventCredits(event);
|
|
305
|
+
if (Number.isFinite(credits) && credits > 0) return credits;
|
|
306
|
+
const tokens = Math.max(0, Number(event.totalTokens) || 0);
|
|
307
|
+
return fallbackCreditsPerToken > 0 ? tokens * fallbackCreditsPerToken : tokens;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function allocateBurn(delta, events, timeZone) {
|
|
311
|
+
if (!(delta > 0)) return { contributions: new Map(), method: "none" };
|
|
312
|
+
|
|
313
|
+
let ratedCredits = 0;
|
|
314
|
+
let ratedTokens = 0;
|
|
315
|
+
let hasUnrated = false;
|
|
316
|
+
for (const event of events) {
|
|
317
|
+
const credits = eventCredits(event);
|
|
318
|
+
const tokens = Math.max(0, Number(event.totalTokens) || 0);
|
|
319
|
+
if (Number.isFinite(credits) && credits > 0) {
|
|
320
|
+
ratedCredits += credits;
|
|
321
|
+
ratedTokens += tokens;
|
|
322
|
+
} else if (tokens > 0) {
|
|
323
|
+
hasUnrated = true;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const fallbackCreditsPerToken =
|
|
328
|
+
ratedCredits > 0 && ratedTokens > 0 ? ratedCredits / ratedTokens : 0;
|
|
329
|
+
const weights = new Map();
|
|
330
|
+
const dayWeights = new Map();
|
|
331
|
+
let totalWeight = 0;
|
|
332
|
+
for (const event of events) {
|
|
333
|
+
const weight = eventWeight(event, fallbackCreditsPerToken);
|
|
334
|
+
if (!(weight > 0)) continue;
|
|
335
|
+
const model = trendModelLabel(event.model);
|
|
336
|
+
weights.set(model, (weights.get(model) ?? 0) + weight);
|
|
337
|
+
if (timeZone) {
|
|
338
|
+
const day = localDateString(event.timestampMs, timeZone);
|
|
339
|
+
dayWeights.set(day, (dayWeights.get(day) ?? 0) + weight);
|
|
340
|
+
}
|
|
341
|
+
totalWeight += weight;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (!(totalWeight > 0)) {
|
|
345
|
+
return {
|
|
346
|
+
contributions: new Map([["Unattributed", delta]]),
|
|
347
|
+
method: "unattributed",
|
|
348
|
+
dayShares: new Map(),
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const contributions = new Map();
|
|
353
|
+
for (const [model, weight] of weights) {
|
|
354
|
+
contributions.set(model, (delta * weight) / totalWeight);
|
|
355
|
+
}
|
|
356
|
+
const dayShares = new Map();
|
|
357
|
+
for (const [day, weight] of dayWeights) {
|
|
358
|
+
dayShares.set(day, weight / totalWeight);
|
|
359
|
+
}
|
|
360
|
+
return {
|
|
361
|
+
contributions,
|
|
362
|
+
method:
|
|
363
|
+
ratedCredits > 0
|
|
364
|
+
? hasUnrated
|
|
365
|
+
? "mixed"
|
|
366
|
+
: "rate-card"
|
|
367
|
+
: "tokens",
|
|
368
|
+
dayShares,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Fractions of the span [startMs, endMs) falling on each local calendar day.
|
|
373
|
+
function durationDayShares(startMs, endMs, timeZone) {
|
|
374
|
+
if (!(endMs > startMs)) {
|
|
375
|
+
return new Map([[localDateString(endMs, timeZone), 1]]);
|
|
376
|
+
}
|
|
377
|
+
const shares = new Map();
|
|
378
|
+
let cursor = startMs;
|
|
379
|
+
while (cursor < endMs) {
|
|
380
|
+
const day = localDateString(cursor, timeZone);
|
|
381
|
+
const nextMidnightMs = zonedMidnight(
|
|
382
|
+
shiftCalendarDate(day, 1),
|
|
383
|
+
timeZone,
|
|
384
|
+
).getTime();
|
|
385
|
+
const sliceEnd = Math.min(endMs, Math.max(nextMidnightMs, cursor + 1));
|
|
386
|
+
shares.set(day, (shares.get(day) ?? 0) + (sliceEnd - cursor));
|
|
387
|
+
cursor = sliceEnd;
|
|
388
|
+
}
|
|
389
|
+
const total = [...shares.values()].reduce((sum, value) => sum + value, 0);
|
|
390
|
+
return new Map([...shares].map(([day, value]) => [day, value / total]));
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function tokenTotalsByModel(events) {
|
|
394
|
+
const totals = new Map();
|
|
395
|
+
for (const event of events) {
|
|
396
|
+
const tokens = Math.max(0, Number(event.totalTokens) || 0);
|
|
397
|
+
if (!(tokens > 0)) continue;
|
|
398
|
+
const model = trendModelLabel(event.model);
|
|
399
|
+
totals.set(model, (totals.get(model) ?? 0) + tokens);
|
|
400
|
+
}
|
|
401
|
+
return totals;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function modelSort(left, right) {
|
|
405
|
+
const leftOrder = MODEL_SORT_ORDER.get(left) ?? MODEL_SORT_ORDER.size;
|
|
406
|
+
const rightOrder = MODEL_SORT_ORDER.get(right) ?? MODEL_SORT_ORDER.size;
|
|
407
|
+
return leftOrder - rightOrder || left.localeCompare(right);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function cloneAllocations(allocations) {
|
|
411
|
+
return Object.fromEntries(
|
|
412
|
+
[...allocations.entries()].sort(([left], [right]) => modelSort(left, right)),
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function eventsInBounds(events, bounds) {
|
|
417
|
+
const startMs = bounds.start.getTime();
|
|
418
|
+
const endMs = bounds.end.getTime();
|
|
419
|
+
return events.filter((event) => {
|
|
420
|
+
const timestampMs = finiteTimestamp(event.timestamp);
|
|
421
|
+
return timestampMs !== null && timestampMs >= startMs && timestampMs < endMs;
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function buildModelStats(displayedEvents, intervals, bounds) {
|
|
426
|
+
const rows = new Map();
|
|
427
|
+
const rowFor = (model) => {
|
|
428
|
+
const row = rows.get(model) ?? {
|
|
429
|
+
model,
|
|
430
|
+
tokens: 0,
|
|
431
|
+
credits: 0,
|
|
432
|
+
ratedTokens: 0,
|
|
433
|
+
attributedTokens: 0,
|
|
434
|
+
burnPoints: 0,
|
|
435
|
+
efforts: new Map(),
|
|
436
|
+
};
|
|
437
|
+
rows.set(model, row);
|
|
438
|
+
return row;
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
for (const event of displayedEvents) {
|
|
442
|
+
const model = trendModelLabel(event.model);
|
|
443
|
+
const row = rowFor(model);
|
|
444
|
+
const tokens = Math.max(0, Number(event.totalTokens) || 0);
|
|
445
|
+
const credits = eventCredits(event);
|
|
446
|
+
row.tokens += tokens;
|
|
447
|
+
if (Number.isFinite(credits) && credits >= 0) {
|
|
448
|
+
row.credits += credits;
|
|
449
|
+
row.ratedTokens += tokens;
|
|
450
|
+
}
|
|
451
|
+
const effort = String(event.effort || "unknown").toLowerCase();
|
|
452
|
+
row.efforts.set(effort, (row.efforts.get(effort) ?? 0) + tokens);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const startMs = bounds.start.getTime();
|
|
456
|
+
const endMs = bounds.end.getTime();
|
|
457
|
+
for (const interval of intervals) {
|
|
458
|
+
if (interval.endMs < startMs || interval.endMs >= endMs) continue;
|
|
459
|
+
for (const [model, burnPoints] of interval.contributions) {
|
|
460
|
+
const row = rowFor(model);
|
|
461
|
+
row.burnPoints += burnPoints;
|
|
462
|
+
row.attributedTokens += interval.modelTokens.get(model) ?? 0;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
return [...rows.values()]
|
|
467
|
+
.map((row) => {
|
|
468
|
+
const effortEntries = [...row.efforts.entries()].sort(
|
|
469
|
+
(left, right) => right[1] - left[1],
|
|
470
|
+
);
|
|
471
|
+
const dominantEffort = effortEntries[0]?.[0] ?? "unknown";
|
|
472
|
+
const dominantEffortShare =
|
|
473
|
+
row.tokens > 0 ? (effortEntries[0]?.[1] ?? 0) / row.tokens : 0;
|
|
474
|
+
return {
|
|
475
|
+
...row,
|
|
476
|
+
efforts: Object.fromEntries(effortEntries),
|
|
477
|
+
dominantEffort,
|
|
478
|
+
dominantEffortShare,
|
|
479
|
+
tokensPerBurnPoint:
|
|
480
|
+
row.burnPoints > 0 && row.attributedTokens > 0
|
|
481
|
+
? row.attributedTokens / row.burnPoints
|
|
482
|
+
: null,
|
|
483
|
+
ratedPercent:
|
|
484
|
+
row.tokens > 0 ? (row.ratedTokens / row.tokens) * 100 : null,
|
|
485
|
+
};
|
|
486
|
+
})
|
|
487
|
+
.sort(
|
|
488
|
+
(left, right) =>
|
|
489
|
+
right.burnPoints - left.burnPoints ||
|
|
490
|
+
right.tokens - left.tokens ||
|
|
491
|
+
modelSort(left.model, right.model),
|
|
492
|
+
);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
export function buildUsageTrend(snapshot = {}, bounds) {
|
|
496
|
+
const startMs = bounds.start.getTime();
|
|
497
|
+
const endMs = bounds.end.getTime();
|
|
498
|
+
const displayedEvents = eventsInBounds(snapshot.events ?? [], bounds);
|
|
499
|
+
const observations = normalizeQuotaTimeline(
|
|
500
|
+
weeklyQuotaObservations(snapshot),
|
|
501
|
+
).filter((observation) => observation.timestampMs < endMs);
|
|
502
|
+
|
|
503
|
+
if (!observations.length) {
|
|
504
|
+
return {
|
|
505
|
+
available: false,
|
|
506
|
+
points: [],
|
|
507
|
+
resets: [],
|
|
508
|
+
burnIntervals: [],
|
|
509
|
+
models: buildModelStats(displayedEvents, [], bounds),
|
|
510
|
+
sampleCount: 0,
|
|
511
|
+
allocationMethod: "unavailable",
|
|
512
|
+
observedThroughMs: null,
|
|
513
|
+
rateCardAsOf: snapshot.provenance?.rateCardAsOf ?? RATE_CARD_AS_OF,
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const sortedEvents = (snapshot.events ?? [])
|
|
518
|
+
.map((event) => ({ ...event, timestampMs: finiteTimestamp(event.timestamp) }))
|
|
519
|
+
.filter((event) => event.timestampMs !== null && event.timestampMs < endMs)
|
|
520
|
+
.sort((left, right) => left.timestampMs - right.timestampMs);
|
|
521
|
+
const points = [];
|
|
522
|
+
const resets = [];
|
|
523
|
+
const intervals = [];
|
|
524
|
+
const methods = new Set();
|
|
525
|
+
let eventIndex = 0;
|
|
526
|
+
let activeCycle = null;
|
|
527
|
+
let previousObservationMs = null;
|
|
528
|
+
let previousUsedPercent = 0;
|
|
529
|
+
let intervalStartMs = null;
|
|
530
|
+
let pendingIntervalStartMs = null;
|
|
531
|
+
let pendingEvents = [];
|
|
532
|
+
let allocations = new Map();
|
|
533
|
+
|
|
534
|
+
for (const observation of observations) {
|
|
535
|
+
if (observation.cycle !== activeCycle) {
|
|
536
|
+
activeCycle = observation.cycle;
|
|
537
|
+
allocations = new Map();
|
|
538
|
+
previousUsedPercent = 0;
|
|
539
|
+
const inferredStartMs = observation.cycleStartMs;
|
|
540
|
+
intervalStartMs = Math.min(
|
|
541
|
+
observation.timestampMs,
|
|
542
|
+
Math.max(inferredStartMs, previousObservationMs ?? inferredStartMs),
|
|
543
|
+
);
|
|
544
|
+
pendingIntervalStartMs = intervalStartMs;
|
|
545
|
+
pendingEvents = [];
|
|
546
|
+
if (previousObservationMs !== null) {
|
|
547
|
+
resets.push({
|
|
548
|
+
timestampMs: inferredStartMs,
|
|
549
|
+
observedAtMs: observation.timestampMs,
|
|
550
|
+
cycle: observation.cycle,
|
|
551
|
+
kind: observation.resetKind ?? "restart",
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
while (
|
|
557
|
+
eventIndex < sortedEvents.length &&
|
|
558
|
+
sortedEvents[eventIndex].timestampMs <= intervalStartMs
|
|
559
|
+
) {
|
|
560
|
+
eventIndex += 1;
|
|
561
|
+
}
|
|
562
|
+
const intervalEvents = [];
|
|
563
|
+
while (
|
|
564
|
+
eventIndex < sortedEvents.length &&
|
|
565
|
+
sortedEvents[eventIndex].timestampMs <= observation.timestampMs
|
|
566
|
+
) {
|
|
567
|
+
intervalEvents.push(sortedEvents[eventIndex]);
|
|
568
|
+
eventIndex += 1;
|
|
569
|
+
}
|
|
570
|
+
pendingEvents.push(...intervalEvents);
|
|
571
|
+
|
|
572
|
+
const delta = Math.max(
|
|
573
|
+
0,
|
|
574
|
+
observation.normalizedUsedPercent - previousUsedPercent,
|
|
575
|
+
);
|
|
576
|
+
const allocation = allocateBurn(delta, pendingEvents, bounds.timeZone);
|
|
577
|
+
if (allocation.method !== "none" && observation.timestampMs >= startMs) {
|
|
578
|
+
methods.add(allocation.method);
|
|
579
|
+
}
|
|
580
|
+
for (const [model, burnPoints] of allocation.contributions) {
|
|
581
|
+
allocations.set(model, (allocations.get(model) ?? 0) + burnPoints);
|
|
582
|
+
}
|
|
583
|
+
if (delta > 0) {
|
|
584
|
+
intervals.push({
|
|
585
|
+
startMs: pendingIntervalStartMs,
|
|
586
|
+
endMs: observation.timestampMs,
|
|
587
|
+
cycle: observation.cycle,
|
|
588
|
+
contributions: allocation.contributions,
|
|
589
|
+
modelTokens: tokenTotalsByModel(pendingEvents),
|
|
590
|
+
method: allocation.method,
|
|
591
|
+
dayShares: allocation.dayShares?.size
|
|
592
|
+
? allocation.dayShares
|
|
593
|
+
: durationDayShares(
|
|
594
|
+
pendingIntervalStartMs,
|
|
595
|
+
observation.timestampMs,
|
|
596
|
+
bounds.timeZone,
|
|
597
|
+
),
|
|
598
|
+
spansLongGap:
|
|
599
|
+
observation.timestampMs - pendingIntervalStartMs > LONG_GAP_MS,
|
|
600
|
+
});
|
|
601
|
+
pendingEvents = [];
|
|
602
|
+
pendingIntervalStartMs = observation.timestampMs;
|
|
603
|
+
}
|
|
604
|
+
points.push({
|
|
605
|
+
timestampMs: observation.timestampMs,
|
|
606
|
+
cycle: observation.cycle,
|
|
607
|
+
usedPercent: observation.normalizedUsedPercent,
|
|
608
|
+
remainingPercent: 100 - observation.normalizedUsedPercent,
|
|
609
|
+
allocations: cloneAllocations(allocations),
|
|
610
|
+
observed: true,
|
|
611
|
+
});
|
|
612
|
+
previousUsedPercent = observation.normalizedUsedPercent;
|
|
613
|
+
previousObservationMs = observation.timestampMs;
|
|
614
|
+
intervalStartMs = observation.timestampMs;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
const displayPoints = [];
|
|
618
|
+
const beforeStart = [...points]
|
|
619
|
+
.reverse()
|
|
620
|
+
.find((point) => point.timestampMs <= startMs);
|
|
621
|
+
if (beforeStart) {
|
|
622
|
+
displayPoints.push({
|
|
623
|
+
...beforeStart,
|
|
624
|
+
timestampMs: startMs,
|
|
625
|
+
observed: beforeStart.timestampMs === startMs,
|
|
626
|
+
carried: beforeStart.timestampMs !== startMs,
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
displayPoints.push(
|
|
630
|
+
...points.filter(
|
|
631
|
+
(point) => point.timestampMs > startMs && point.timestampMs < endMs,
|
|
632
|
+
),
|
|
633
|
+
);
|
|
634
|
+
const lastPoint = displayPoints.at(-1);
|
|
635
|
+
if (lastPoint) {
|
|
636
|
+
displayPoints.push({
|
|
637
|
+
...lastPoint,
|
|
638
|
+
timestampMs: endMs,
|
|
639
|
+
observed: false,
|
|
640
|
+
carried: true,
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
const hasUnattributed = methods.has("unattributed");
|
|
645
|
+
let allocationMethod = "unavailable";
|
|
646
|
+
if (
|
|
647
|
+
methods.has("mixed") ||
|
|
648
|
+
(methods.has("rate-card") && methods.has("tokens"))
|
|
649
|
+
) {
|
|
650
|
+
allocationMethod = "mixed rate-card and token weights";
|
|
651
|
+
} else if (methods.has("rate-card")) {
|
|
652
|
+
allocationMethod = "rate-card weights";
|
|
653
|
+
} else if (methods.has("tokens")) {
|
|
654
|
+
allocationMethod = "token weights";
|
|
655
|
+
} else if (hasUnattributed) {
|
|
656
|
+
allocationMethod = "unattributed burn";
|
|
657
|
+
}
|
|
658
|
+
if (hasUnattributed && allocationMethod !== "unattributed burn") {
|
|
659
|
+
allocationMethod += " with unattributed gaps";
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
const burnIntervals = intervals
|
|
663
|
+
.filter((interval) => interval.endMs >= startMs && interval.endMs < endMs)
|
|
664
|
+
.map((interval) => ({
|
|
665
|
+
startMs: Math.max(startMs, interval.startMs),
|
|
666
|
+
endMs: interval.endMs,
|
|
667
|
+
cycle: interval.cycle,
|
|
668
|
+
contributions: cloneAllocations(interval.contributions),
|
|
669
|
+
modelTokens: Object.fromEntries(interval.modelTokens),
|
|
670
|
+
method: interval.method,
|
|
671
|
+
dayShares: Object.fromEntries(interval.dayShares),
|
|
672
|
+
spansLongGap: interval.spansLongGap,
|
|
673
|
+
}));
|
|
674
|
+
|
|
675
|
+
return {
|
|
676
|
+
available: displayPoints.length > 0,
|
|
677
|
+
points: displayPoints,
|
|
678
|
+
resets: resets.filter(
|
|
679
|
+
(reset) => reset.timestampMs >= startMs && reset.timestampMs < endMs,
|
|
680
|
+
),
|
|
681
|
+
burnIntervals,
|
|
682
|
+
models: buildModelStats(displayedEvents, intervals, bounds),
|
|
683
|
+
sampleCount: points.filter(
|
|
684
|
+
(point) => point.timestampMs >= startMs && point.timestampMs < endMs,
|
|
685
|
+
).length,
|
|
686
|
+
allocationMethod,
|
|
687
|
+
observedThroughMs:
|
|
688
|
+
[...points].reverse().find((point) => point.timestampMs < endMs)
|
|
689
|
+
?.timestampMs ?? null,
|
|
690
|
+
rateCardAsOf: snapshot.provenance?.rateCardAsOf ?? RATE_CARD_AS_OF,
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// Bin observed meter drain into calendar-day (or multi-day) columns in the
|
|
695
|
+
// same percent unit as the meter line. Daily totals are the meter's own
|
|
696
|
+
// observed drops; only the per-model split within a drop and the day
|
|
697
|
+
// placement across long observation gaps are estimated.
|
|
698
|
+
export function buildBurnDayBins(trend, bounds, { days, binSize = 1 } = {}) {
|
|
699
|
+
const rangeDays = Number(days) || bounds.rangeDays || 7;
|
|
700
|
+
const binCount = Math.ceil(rangeDays / binSize);
|
|
701
|
+
const bins = Array.from({ length: binCount }, (_, index) => ({
|
|
702
|
+
startDateString: shiftCalendarDate(bounds.startDateString, index * binSize),
|
|
703
|
+
endDateString: shiftCalendarDate(
|
|
704
|
+
bounds.startDateString,
|
|
705
|
+
Math.min(rangeDays, (index + 1) * binSize),
|
|
706
|
+
),
|
|
707
|
+
values: new Map(),
|
|
708
|
+
totalPercent: 0,
|
|
709
|
+
approximate: false,
|
|
710
|
+
}));
|
|
711
|
+
const dayIndexByString = new Map(
|
|
712
|
+
Array.from({ length: rangeDays }, (_, index) => [
|
|
713
|
+
shiftCalendarDate(bounds.startDateString, index),
|
|
714
|
+
index,
|
|
715
|
+
]),
|
|
716
|
+
);
|
|
717
|
+
|
|
718
|
+
for (const interval of trend?.burnIntervals ?? []) {
|
|
719
|
+
for (const [day, fraction] of Object.entries(interval.dayShares ?? {})) {
|
|
720
|
+
const dayIndex = dayIndexByString.get(day);
|
|
721
|
+
if (dayIndex === undefined) continue;
|
|
722
|
+
const bin = bins[Math.floor(dayIndex / binSize)];
|
|
723
|
+
if (!bin) continue;
|
|
724
|
+
if (interval.spansLongGap) bin.approximate = true;
|
|
725
|
+
for (const [model, burnPoints] of Object.entries(
|
|
726
|
+
interval.contributions ?? {},
|
|
727
|
+
)) {
|
|
728
|
+
const share = burnPoints * fraction;
|
|
729
|
+
if (!(share > 0)) continue;
|
|
730
|
+
bin.values.set(model, (bin.values.get(model) ?? 0) + share);
|
|
731
|
+
bin.totalPercent += share;
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
const totals = new Map();
|
|
737
|
+
let totalPercent = 0;
|
|
738
|
+
for (const bin of bins) {
|
|
739
|
+
for (const [model, value] of bin.values) {
|
|
740
|
+
totals.set(model, (totals.get(model) ?? 0) + value);
|
|
741
|
+
totalPercent += value;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
return { bins, totals, totalPercent, binSize, binCount };
|
|
745
|
+
}
|