codexmeter 1.0.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 +60 -0
- package/bin/codexmeter.js +57 -0
- package/dist/assets/index-DJWqyRDh.css +1 -0
- package/dist/assets/index-DZKogILW.js +113 -0
- package/dist/index.html +16 -0
- package/package.json +43 -0
- package/server/aggregator.js +428 -0
- package/server/cost-catalog.js +85 -0
- package/server/day-key.js +16 -0
- package/server/index.js +134 -0
- package/server/ingest.js +595 -0
- package/server/live-state.js +464 -0
- package/server/normalize.js +72 -0
- package/server/pricing-fetch.js +59 -0
- package/server/rollout-reader.js +108 -0
- package/server/rollout-worker-pool.js +159 -0
- package/server/rollout-worker.js +21 -0
- package/server/sqlite-reader.js +59 -0
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
import { CACHE_ASSUMPTIONS } from './cost-catalog.js';
|
|
2
|
+
import { createDayKeyFormatter } from './day-key.js';
|
|
3
|
+
|
|
4
|
+
export function createLiveAggregateState(tz) {
|
|
5
|
+
const now = Date.now() / 1000;
|
|
6
|
+
return {
|
|
7
|
+
tz,
|
|
8
|
+
lowerBounds: {
|
|
9
|
+
total: 0,
|
|
10
|
+
d7: now - 7 * 86400,
|
|
11
|
+
d30: now - 30 * 86400,
|
|
12
|
+
},
|
|
13
|
+
overview: {
|
|
14
|
+
total: createOverviewBucket(),
|
|
15
|
+
d7: createOverviewBucket(),
|
|
16
|
+
d30: createOverviewBucket(),
|
|
17
|
+
},
|
|
18
|
+
repos: {
|
|
19
|
+
total: new Map(),
|
|
20
|
+
d7: new Map(),
|
|
21
|
+
d30: new Map(),
|
|
22
|
+
},
|
|
23
|
+
repoTopKeys: createTopKeyRanges(),
|
|
24
|
+
models: {
|
|
25
|
+
total: new Map(),
|
|
26
|
+
d7: new Map(),
|
|
27
|
+
d30: new Map(),
|
|
28
|
+
},
|
|
29
|
+
modelTopKeys: createTopKeyRanges(),
|
|
30
|
+
families: {
|
|
31
|
+
total: new Map(),
|
|
32
|
+
d7: new Map(),
|
|
33
|
+
d30: new Map(),
|
|
34
|
+
},
|
|
35
|
+
familyTopKeys: createTopKeyRanges(),
|
|
36
|
+
daily: new Map(),
|
|
37
|
+
heatmap: new Map(),
|
|
38
|
+
toDayKey: createDayKeyFormatter(tz),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function createEmptyLivePatch() {
|
|
43
|
+
return {
|
|
44
|
+
overview: new Set(),
|
|
45
|
+
repos: { total: new Set(), d7: new Set(), d30: new Set() },
|
|
46
|
+
models: { total: new Set(), d7: new Set(), d30: new Set() },
|
|
47
|
+
families: { total: new Set(), d7: new Set(), d30: new Set() },
|
|
48
|
+
daily: new Set(),
|
|
49
|
+
heatmap: new Set(),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function applySessionToLiveState(live, session, patch) {
|
|
54
|
+
const rootId = session.root_thread_id || session.thread_id;
|
|
55
|
+
|
|
56
|
+
for (const rangeKey of ['total', 'd7', 'd30']) {
|
|
57
|
+
if (!overlapsLowerBound(session, live.lowerBounds[rangeKey])) continue;
|
|
58
|
+
|
|
59
|
+
applyOverviewBucket(live.overview[rangeKey], session, rootId);
|
|
60
|
+
patch.overview.add(rangeKey);
|
|
61
|
+
|
|
62
|
+
applyRepoBucket(live.repos[rangeKey], live.repoTopKeys[rangeKey], session, patch.repos[rangeKey]);
|
|
63
|
+
applyModelBucket(live.models[rangeKey], live.modelTopKeys[rangeKey], session, patch.models[rangeKey]);
|
|
64
|
+
applyFamilyBucket(live.families[rangeKey], live.familyTopKeys[rangeKey], session, patch.families[rangeKey]);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
applyDailyBucket(live.daily, session, live.toDayKey, patch.daily);
|
|
68
|
+
applyHeatmapBucket(live.heatmap, session, live.toDayKey, patch.heatmap);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function buildLiveBootstrap(live) {
|
|
72
|
+
return {
|
|
73
|
+
overview: serializeOverview(live),
|
|
74
|
+
repos: serializeTopRanges(live.repos, live.repoTopKeys, serializeRepoSummary),
|
|
75
|
+
models: serializeTopRanges(live.models, live.modelTopKeys, serializeModelSummary),
|
|
76
|
+
families: serializeTopRanges(live.families, live.familyTopKeys, serializeFamilySummary),
|
|
77
|
+
daily: serializeDaily(live.daily),
|
|
78
|
+
heatmap: serializeHeatmap(live.heatmap),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function buildLivePatch(live, patch) {
|
|
83
|
+
return {
|
|
84
|
+
overview: Object.fromEntries(
|
|
85
|
+
[...patch.overview].map((rangeKey) => [rangeKey, serializeOverviewBucket(live.overview[rangeKey])])
|
|
86
|
+
),
|
|
87
|
+
repos: serializePatchedTopRanges(live.repos, live.repoTopKeys, patch.repos, serializeRepoSummary),
|
|
88
|
+
models: serializePatchedTopRanges(live.models, live.modelTopKeys, patch.models, serializeModelSummary),
|
|
89
|
+
families: serializePatchedTopRanges(live.families, live.familyTopKeys, patch.families, serializeFamilySummary),
|
|
90
|
+
daily: Object.fromEntries([...patch.daily].map((dayKey) => [dayKey, serializeDailyEntry(live.daily.get(dayKey))])),
|
|
91
|
+
heatmap: Object.fromEntries([...patch.heatmap].map((dayKey) => [dayKey, serializeHeatmapEntry(live.heatmap.get(dayKey))])),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function createTopKeyRanges() {
|
|
96
|
+
return {
|
|
97
|
+
total: [],
|
|
98
|
+
d7: [],
|
|
99
|
+
d30: [],
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function createOverviewBucket() {
|
|
104
|
+
return {
|
|
105
|
+
total_tokens: 0,
|
|
106
|
+
total_cost: 0,
|
|
107
|
+
total_elapsed_seconds: 0,
|
|
108
|
+
thread_rows: 0,
|
|
109
|
+
rootIds: new Set(),
|
|
110
|
+
repoSet: new Set(),
|
|
111
|
+
modelSet: new Set(),
|
|
112
|
+
enriched: 0,
|
|
113
|
+
priced: 0,
|
|
114
|
+
priced_exact: 0,
|
|
115
|
+
priced_fallback: 0,
|
|
116
|
+
unpriced: 0,
|
|
117
|
+
time_valid: 0,
|
|
118
|
+
earliest: Infinity,
|
|
119
|
+
latest: -Infinity,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function applyOverviewBucket(bucket, session, rootId) {
|
|
124
|
+
bucket.total_tokens += session.tokens_used || 0;
|
|
125
|
+
bucket.total_cost += session.cost || 0;
|
|
126
|
+
bucket.total_elapsed_seconds += session.elapsed_seconds || 0;
|
|
127
|
+
bucket.thread_rows += 1;
|
|
128
|
+
bucket.rootIds.add(rootId);
|
|
129
|
+
bucket.repoSet.add(session.repo_label || 'unknown');
|
|
130
|
+
if (session.model_name) {
|
|
131
|
+
bucket.modelSet.add(session.model_name);
|
|
132
|
+
bucket.enriched += 1;
|
|
133
|
+
}
|
|
134
|
+
if (session.cost !== null) {
|
|
135
|
+
bucket.priced += 1;
|
|
136
|
+
if (session.cost_source === 'exact') bucket.priced_exact += 1;
|
|
137
|
+
if (session.cost_source === 'heuristic') bucket.priced_fallback += 1;
|
|
138
|
+
} else {
|
|
139
|
+
bucket.unpriced += 1;
|
|
140
|
+
}
|
|
141
|
+
if (session.elapsed_seconds != null && session.elapsed_seconds > 0) bucket.time_valid += 1;
|
|
142
|
+
if (session.started_at && session.started_at < bucket.earliest) bucket.earliest = session.started_at;
|
|
143
|
+
if (session.ended_at && session.ended_at > bucket.latest) bucket.latest = session.ended_at;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function applyRepoBucket(repoMap, topKeys, session, dirtySet) {
|
|
147
|
+
const key = session.repo_label || 'unknown';
|
|
148
|
+
if (!repoMap.has(key)) {
|
|
149
|
+
repoMap.set(key, {
|
|
150
|
+
repo_key: session.repo_key,
|
|
151
|
+
repo_label: key,
|
|
152
|
+
tokens: 0,
|
|
153
|
+
cost: 0,
|
|
154
|
+
cost_known: 0,
|
|
155
|
+
exact_priced: 0,
|
|
156
|
+
heuristic_priced: 0,
|
|
157
|
+
sessions: 0,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
const repo = repoMap.get(key);
|
|
161
|
+
repo.tokens += session.tokens_used || 0;
|
|
162
|
+
repo.sessions += 1;
|
|
163
|
+
if (session.cost !== null) {
|
|
164
|
+
repo.cost += session.cost;
|
|
165
|
+
repo.cost_known += 1;
|
|
166
|
+
if (session.cost_source === 'exact') repo.exact_priced += 1;
|
|
167
|
+
if (session.cost_source === 'heuristic') repo.heuristic_priced += 1;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
updateTopKeys(repoMap, topKeys, key);
|
|
171
|
+
dirtySet.add(key);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function applyModelBucket(modelMap, topKeys, session, dirtySet) {
|
|
175
|
+
const key = session.model_name || 'unknown';
|
|
176
|
+
if (!modelMap.has(key)) {
|
|
177
|
+
modelMap.set(key, { model_name: key, tokens: 0, cost: 0, cost_known: 0, exact_priced: 0, heuristic_priced: 0, sessions: 0 });
|
|
178
|
+
}
|
|
179
|
+
const model = modelMap.get(key);
|
|
180
|
+
model.tokens += session.tokens_used || 0;
|
|
181
|
+
model.sessions += 1;
|
|
182
|
+
if (session.cost !== null) {
|
|
183
|
+
model.cost += session.cost;
|
|
184
|
+
model.cost_known += 1;
|
|
185
|
+
if (session.cost_source === 'exact') model.exact_priced += 1;
|
|
186
|
+
if (session.cost_source === 'heuristic') model.heuristic_priced += 1;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
updateTopKeys(modelMap, topKeys, key);
|
|
190
|
+
dirtySet.add(key);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function applyFamilyBucket(familyMap, topKeys, session, dirtySet) {
|
|
194
|
+
const key = session.agent_family || 'generic';
|
|
195
|
+
if (!familyMap.has(key)) {
|
|
196
|
+
familyMap.set(key, { family: key, tokens: 0, cost: 0, exact_priced: 0, heuristic_priced: 0, sessions: 0 });
|
|
197
|
+
}
|
|
198
|
+
const family = familyMap.get(key);
|
|
199
|
+
family.tokens += session.tokens_used || 0;
|
|
200
|
+
family.sessions += 1;
|
|
201
|
+
if (session.cost !== null) {
|
|
202
|
+
family.cost += session.cost;
|
|
203
|
+
if (session.cost_source === 'exact') family.exact_priced += 1;
|
|
204
|
+
if (session.cost_source === 'heuristic') family.heuristic_priced += 1;
|
|
205
|
+
}
|
|
206
|
+
updateTopKeys(familyMap, topKeys, key);
|
|
207
|
+
dirtySet.add(key);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function applyDailyBucket(dayMap, session, toDayKey, dirtySet) {
|
|
211
|
+
if (!session.started_at || !session.ended_at) return;
|
|
212
|
+
|
|
213
|
+
const startMs = session.started_at * 1000;
|
|
214
|
+
const endMs = session.ended_at * 1000;
|
|
215
|
+
const totalDur = endMs - startMs;
|
|
216
|
+
if (totalDur > 0) {
|
|
217
|
+
const startDay = toDayKey(startMs);
|
|
218
|
+
const endDay = toDayKey(endMs - 1);
|
|
219
|
+
|
|
220
|
+
if (startDay === endDay) {
|
|
221
|
+
addToDay(dayMap, startDay, session, 1.0);
|
|
222
|
+
dirtySet.add(startDay);
|
|
223
|
+
} else {
|
|
224
|
+
let cursor = dayStartMs(startDay);
|
|
225
|
+
while (cursor < endMs) {
|
|
226
|
+
const nextDay = cursor + 86400000;
|
|
227
|
+
const overlapStart = Math.max(cursor, startMs);
|
|
228
|
+
const overlapEnd = Math.min(nextDay, endMs);
|
|
229
|
+
const fraction = (overlapEnd - overlapStart) / totalDur;
|
|
230
|
+
if (fraction > 0) {
|
|
231
|
+
const dayKey = toDayKey(cursor);
|
|
232
|
+
addToDay(dayMap, dayKey, session, fraction);
|
|
233
|
+
dirtySet.add(dayKey);
|
|
234
|
+
}
|
|
235
|
+
cursor = nextDay;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (session.active_by_day) {
|
|
241
|
+
for (const [dayKey, seconds] of Object.entries(session.active_by_day)) {
|
|
242
|
+
const day = ensureDay(dayMap, dayKey);
|
|
243
|
+
day.elapsed_seconds += seconds || 0;
|
|
244
|
+
dirtySet.add(dayKey);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function applyHeatmapBucket(dayMap, session, toDayKey, dirtySet) {
|
|
250
|
+
if (session.started_at) {
|
|
251
|
+
const dayKey = toDayKey(session.started_at * 1000);
|
|
252
|
+
const day = ensureHeatmapDay(dayMap, dayKey);
|
|
253
|
+
day.tokens += session.tokens_used || 0;
|
|
254
|
+
if (session.cost !== null) day.cost += session.cost;
|
|
255
|
+
day.sessions += 1;
|
|
256
|
+
dirtySet.add(dayKey);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (session.active_by_day) {
|
|
260
|
+
for (const [dayKey, seconds] of Object.entries(session.active_by_day)) {
|
|
261
|
+
const day = ensureHeatmapDay(dayMap, dayKey);
|
|
262
|
+
day.elapsed += seconds || 0;
|
|
263
|
+
dirtySet.add(dayKey);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function addToDay(dayMap, dayKey, session, fraction) {
|
|
269
|
+
const day = ensureDay(dayMap, dayKey);
|
|
270
|
+
day.tokens += (session.tokens_used || 0) * fraction;
|
|
271
|
+
if (session.cost !== null) day.cost += session.cost * fraction;
|
|
272
|
+
if (fraction > 0.001) day.sessions += 1;
|
|
273
|
+
|
|
274
|
+
const modelKey = session.model_name || 'unknown';
|
|
275
|
+
if (!day.by_model[modelKey]) day.by_model[modelKey] = { tokens: 0, cost: 0, elapsed_seconds: 0 };
|
|
276
|
+
day.by_model[modelKey].tokens += (session.tokens_used || 0) * fraction;
|
|
277
|
+
if (session.cost !== null) day.by_model[modelKey].cost += session.cost * fraction;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function ensureDay(dayMap, dayKey) {
|
|
281
|
+
if (!dayMap.has(dayKey)) {
|
|
282
|
+
dayMap.set(dayKey, { tokens: 0, cost: 0, elapsed_seconds: 0, sessions: 0, by_model: {} });
|
|
283
|
+
}
|
|
284
|
+
return dayMap.get(dayKey);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function ensureHeatmapDay(dayMap, dayKey) {
|
|
288
|
+
if (!dayMap.has(dayKey)) {
|
|
289
|
+
dayMap.set(dayKey, { tokens: 0, cost: 0, elapsed: 0, sessions: 0 });
|
|
290
|
+
}
|
|
291
|
+
return dayMap.get(dayKey);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function serializeOverview(live) {
|
|
295
|
+
return {
|
|
296
|
+
total: serializeOverviewBucket(live.overview.total),
|
|
297
|
+
d7: serializeOverviewBucket(live.overview.d7),
|
|
298
|
+
d30: serializeOverviewBucket(live.overview.d30),
|
|
299
|
+
cost_assumptions: CACHE_ASSUMPTIONS,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function serializeOverviewBucket(bucket) {
|
|
304
|
+
return {
|
|
305
|
+
total_tokens: bucket.total_tokens,
|
|
306
|
+
total_cost: bucket.total_cost,
|
|
307
|
+
total_sessions: bucket.rootIds.size,
|
|
308
|
+
active_repos: bucket.repoSet.size,
|
|
309
|
+
active_models: bucket.modelSet.size,
|
|
310
|
+
total_elapsed_seconds: bucket.total_elapsed_seconds,
|
|
311
|
+
date_range: {
|
|
312
|
+
from: bucket.earliest === Infinity ? null : bucket.earliest,
|
|
313
|
+
to: bucket.latest === -Infinity ? null : bucket.latest,
|
|
314
|
+
},
|
|
315
|
+
coverage: {
|
|
316
|
+
total: bucket.thread_rows,
|
|
317
|
+
thread_rows: bucket.thread_rows,
|
|
318
|
+
root_sessions: bucket.rootIds.size,
|
|
319
|
+
enriched: bucket.enriched,
|
|
320
|
+
priced: bucket.priced,
|
|
321
|
+
priced_exact: bucket.priced_exact,
|
|
322
|
+
priced_fallback: bucket.priced_fallback,
|
|
323
|
+
unpriced: bucket.unpriced,
|
|
324
|
+
time_valid: bucket.time_valid,
|
|
325
|
+
},
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function serializeTopRanges(rangeMaps, topKeyRanges, projector) {
|
|
330
|
+
return {
|
|
331
|
+
total: serializeTopRange(rangeMaps.total, topKeyRanges.total, projector),
|
|
332
|
+
d7: serializeTopRange(rangeMaps.d7, topKeyRanges.d7, projector),
|
|
333
|
+
d30: serializeTopRange(rangeMaps.d30, topKeyRanges.d30, projector),
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function serializePatchedTopRanges(rangeMaps, topKeyRanges, dirtyRanges, projector) {
|
|
338
|
+
const out = {};
|
|
339
|
+
for (const rangeKey of ['total', 'd7', 'd30']) {
|
|
340
|
+
if (dirtyRanges[rangeKey].size > 0) {
|
|
341
|
+
out[rangeKey] = serializeTopRange(rangeMaps[rangeKey], topKeyRanges[rangeKey], projector);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return out;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function serializeTopRange(map, topKeys, projector) {
|
|
348
|
+
return topKeys
|
|
349
|
+
.map((key) => map.get(key))
|
|
350
|
+
.filter(Boolean)
|
|
351
|
+
.map(projector);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function serializeDaily(dayMap) {
|
|
355
|
+
return Object.fromEntries([...dayMap.entries()].map(([dayKey, value]) => [dayKey, serializeDailyEntry(value)]));
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function serializeDailyEntry(value) {
|
|
359
|
+
return {
|
|
360
|
+
tokens: Math.round(value?.tokens || 0),
|
|
361
|
+
cost: value?.cost || 0,
|
|
362
|
+
elapsed_seconds: Math.round(value?.elapsed_seconds || 0),
|
|
363
|
+
sessions: value?.sessions || 0,
|
|
364
|
+
by_model: deepRoundClone(value?.by_model || {}, ['tokens', 'cost', 'elapsed_seconds']),
|
|
365
|
+
approximate: true,
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function serializeHeatmap(dayMap) {
|
|
370
|
+
return Object.fromEntries([...dayMap.entries()].map(([dayKey, value]) => [dayKey, serializeHeatmapEntry(value)]));
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function serializeHeatmapEntry(value) {
|
|
374
|
+
return {
|
|
375
|
+
tokens: Math.round(value?.tokens || 0),
|
|
376
|
+
cost: value?.cost || 0,
|
|
377
|
+
elapsed: Math.round(value?.elapsed || 0),
|
|
378
|
+
sessions: value?.sessions || 0,
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function deepRoundClone(source, numericKeys) {
|
|
383
|
+
const out = {};
|
|
384
|
+
for (const [key, value] of Object.entries(source)) {
|
|
385
|
+
out[key] = {};
|
|
386
|
+
for (const numKey of numericKeys) {
|
|
387
|
+
const nextValue = value?.[numKey] || 0;
|
|
388
|
+
out[key][numKey] = numKey === 'cost' ? nextValue : Math.round(nextValue);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
return out;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function updateTopKeys(map, topKeys, key, limit = 6) {
|
|
395
|
+
if (!topKeys.includes(key)) {
|
|
396
|
+
if (topKeys.length < limit) {
|
|
397
|
+
topKeys.push(key);
|
|
398
|
+
} else {
|
|
399
|
+
const weakestKey = topKeys[topKeys.length - 1];
|
|
400
|
+
const weakestValue = map.get(weakestKey)?.tokens || 0;
|
|
401
|
+
const nextValue = map.get(key)?.tokens || 0;
|
|
402
|
+
if (nextValue <= weakestValue) return;
|
|
403
|
+
topKeys.push(key);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
topKeys.sort((a, b) => {
|
|
408
|
+
const aValue = map.get(a)?.tokens || 0;
|
|
409
|
+
const bValue = map.get(b)?.tokens || 0;
|
|
410
|
+
if (bValue !== aValue) return bValue - aValue;
|
|
411
|
+
return String(a).localeCompare(String(b));
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
if (topKeys.length > limit) {
|
|
415
|
+
topKeys.length = limit;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function serializeRepoSummary(value) {
|
|
420
|
+
return {
|
|
421
|
+
repo_key: value.repo_key,
|
|
422
|
+
repo_label: value.repo_label,
|
|
423
|
+
tokens: value.tokens,
|
|
424
|
+
cost: value.cost,
|
|
425
|
+
cost_known: value.cost_known,
|
|
426
|
+
exact_priced: value.exact_priced,
|
|
427
|
+
heuristic_priced: value.heuristic_priced,
|
|
428
|
+
sessions: value.sessions,
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function serializeModelSummary(value) {
|
|
433
|
+
return {
|
|
434
|
+
model_name: value.model_name,
|
|
435
|
+
tokens: value.tokens,
|
|
436
|
+
cost: value.cost,
|
|
437
|
+
cost_known: value.cost_known,
|
|
438
|
+
exact_priced: value.exact_priced,
|
|
439
|
+
heuristic_priced: value.heuristic_priced,
|
|
440
|
+
sessions: value.sessions,
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function serializeFamilySummary(value) {
|
|
445
|
+
return {
|
|
446
|
+
family: value.family,
|
|
447
|
+
tokens: value.tokens,
|
|
448
|
+
cost: value.cost,
|
|
449
|
+
exact_priced: value.exact_priced,
|
|
450
|
+
heuristic_priced: value.heuristic_priced,
|
|
451
|
+
sessions: value.sessions,
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function overlapsLowerBound(session, lowerBound) {
|
|
456
|
+
const startedAt = session.started_at || 0;
|
|
457
|
+
const endedAt = session.ended_at || startedAt;
|
|
458
|
+
return endedAt >= lowerBound;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function dayStartMs(dayKey) {
|
|
462
|
+
const [y, m, d] = dayKey.split('-').map(Number);
|
|
463
|
+
return new Date(y, m - 1, d).getTime();
|
|
464
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
const AGENT_FAMILY_MAP = {
|
|
2
|
+
review_plan: 'planning',
|
|
3
|
+
review_github: 'review',
|
|
4
|
+
'memory builder': 'memory',
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
const AGENT_FAMILY_PREFIX_RULES = [
|
|
8
|
+
{ prefix: 'review_benchmark_', family: 'review' },
|
|
9
|
+
{ prefix: 'review_', family: 'review' },
|
|
10
|
+
{ prefix: 'review', family: 'review' },
|
|
11
|
+
{ prefix: 'worker', family: 'generic' },
|
|
12
|
+
{ prefix: 'explorer', family: 'exploration' },
|
|
13
|
+
{ prefix: 'local_explorer', family: 'exploration' },
|
|
14
|
+
{ prefix: 'web_explorer', family: 'exploration' },
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
export function normalizeCwd(cwd) {
|
|
18
|
+
if (!cwd) return '';
|
|
19
|
+
let p = cwd;
|
|
20
|
+
p = p.replace(/^\\\\\?\\/, '');
|
|
21
|
+
p = p.replace(/\\/g, '/');
|
|
22
|
+
if (/^[A-Z]:/.test(p)) p = p[0].toLowerCase() + p.slice(1);
|
|
23
|
+
p = p.toLowerCase();
|
|
24
|
+
p = p.replace(/\/+$/, '');
|
|
25
|
+
return p;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function deriveRepoKey(normalizedCwd) {
|
|
29
|
+
const label = deriveRepoLabel(normalizedCwd);
|
|
30
|
+
return label === 'unknown' ? 'unknown' : `repo:${label}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function deriveRepoLabel(normalizedCwd) {
|
|
34
|
+
if (!normalizedCwd) return 'unknown';
|
|
35
|
+
const worktreeMatch = normalizedCwd.match(/\.codex\/worktrees\/[^/]+\/([^/]+)/);
|
|
36
|
+
if (worktreeMatch) return collapseWorktreeLabel(worktreeMatch[1]);
|
|
37
|
+
if (normalizedCwd.includes('.codex')) return '.codex';
|
|
38
|
+
const segments = normalizedCwd.split('/').filter(Boolean);
|
|
39
|
+
return collapseWorktreeLabel(segments[segments.length - 1] || 'unknown');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function collapseWorktreeLabel(label) {
|
|
43
|
+
return String(label || '')
|
|
44
|
+
.replace(/-wt-[a-z0-9._-]+$/i, '')
|
|
45
|
+
.replace(/-worktree-[a-z0-9._-]+$/i, '')
|
|
46
|
+
.replace(/-worktrees?-[a-z0-9._-]+$/i, '') || 'unknown';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function classifyAgentFamily(agentRole) {
|
|
50
|
+
if (!agentRole) return 'generic';
|
|
51
|
+
if (AGENT_FAMILY_MAP[agentRole]) return AGENT_FAMILY_MAP[agentRole];
|
|
52
|
+
for (const rule of AGENT_FAMILY_PREFIX_RULES) {
|
|
53
|
+
if (agentRole.startsWith(rule.prefix)) return rule.family;
|
|
54
|
+
}
|
|
55
|
+
if (agentRole === 'default' || agentRole === 'awaiter') return 'generic';
|
|
56
|
+
return 'generic';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function isSubagent(agentRole) {
|
|
60
|
+
return agentRole != null && agentRole !== 'default';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const MODEL_ALIASES = {
|
|
64
|
+
'codex-mini-latest': 'o4-mini',
|
|
65
|
+
'gpt-5.2': 'gpt-5.2-codex',
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export function normalizeModelName(rawModel) {
|
|
69
|
+
if (!rawModel) return null;
|
|
70
|
+
const lower = rawModel.toLowerCase().trim();
|
|
71
|
+
return MODEL_ALIASES[lower] || lower;
|
|
72
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Local pricing catalog for models used in codex-cli/codex app lifetime.
|
|
2
|
+
const FALLBACK = {
|
|
3
|
+
'gpt-5.4': { input: 2.50, output: 15.00, cached_input: 0.25 },
|
|
4
|
+
'gpt-5.3-codex': { input: 2.00, output: 10.00, cached_input: 0.20 },
|
|
5
|
+
'gpt-5.3-codex-spark': { input: 2.00, output: 10.00, cached_input: 0.20 },
|
|
6
|
+
'gpt-5.2-codex': { input: 2.00, output: 10.00, cached_input: 0.20 },
|
|
7
|
+
'gpt-5.2': { input: 2.00, output: 10.00, cached_input: 0.20 },
|
|
8
|
+
'gpt-5.1-codex-mini': { input: 0.25, output: 2.00, cached_input: 0.025},
|
|
9
|
+
'gpt-4.1': { input: 2.00, output: 8.00, cached_input: 0.50 },
|
|
10
|
+
'gpt-4.1-mini': { input: 0.40, output: 1.60, cached_input: 0.10 },
|
|
11
|
+
'gpt-4.1-nano': { input: 0.10, output: 0.40, cached_input: 0.025 },
|
|
12
|
+
'o3': { input: 2.00, output: 8.00, cached_input: 0.50 },
|
|
13
|
+
'o3-mini': { input: 1.10, output: 4.40, cached_input: 0.275},
|
|
14
|
+
'o4-mini': { input: 1.10, output: 4.40, cached_input: 0.275},
|
|
15
|
+
'gpt-4o': { input: 2.50, output: 10.00, cached_input: 1.25 },
|
|
16
|
+
'gpt-4o-mini': { input: 0.15, output: 0.60, cached_input: 0.075},
|
|
17
|
+
'gpt-4-turbo': { input: 10.00, output: 30.00, cached_input: 1.00 },
|
|
18
|
+
'gpt-4': { input: 30.00, output: 60.00, cached_input: 3.00 },
|
|
19
|
+
'gpt-3.5-turbo': { input: 0.50, output: 1.50, cached_input: 0.05 },
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const PRICING_URL = process.env.CODEXMETER_PRICING_URL || null;
|
|
23
|
+
|
|
24
|
+
function normalizeEntry(entry) {
|
|
25
|
+
if (!entry || typeof entry !== 'object' || entry.input == null || entry.output == null) return null;
|
|
26
|
+
const cached = entry.cached_input ?? entry.cachedInput ?? entry.input * 0.1;
|
|
27
|
+
return { input: entry.input, output: entry.output, cached_input: cached };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function normalizePricing(raw) {
|
|
31
|
+
const models = raw?.models && typeof raw.models === 'object' ? raw.models : raw;
|
|
32
|
+
if (!models || typeof models !== 'object') return null;
|
|
33
|
+
const out = {};
|
|
34
|
+
for (const [name, entry] of Object.entries(models)) {
|
|
35
|
+
const norm = normalizeEntry(entry);
|
|
36
|
+
if (norm) out[name] = norm;
|
|
37
|
+
}
|
|
38
|
+
return Object.keys(out).length > 0 ? out : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function fetchPricing() {
|
|
42
|
+
if (!PRICING_URL) return FALLBACK;
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
const ctrl = new AbortController();
|
|
46
|
+
const t = setTimeout(() => ctrl.abort(), 3000);
|
|
47
|
+
const res = await fetch(PRICING_URL, { signal: ctrl.signal });
|
|
48
|
+
clearTimeout(t);
|
|
49
|
+
if (!res.ok) return FALLBACK;
|
|
50
|
+
const data = await res.json();
|
|
51
|
+
const normalized = normalizePricing(data);
|
|
52
|
+
if (normalized) {
|
|
53
|
+
return { ...FALLBACK, ...normalized };
|
|
54
|
+
}
|
|
55
|
+
} catch {
|
|
56
|
+
// timeout, network error, invalid JSON - use fallback
|
|
57
|
+
}
|
|
58
|
+
return FALLBACK;
|
|
59
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { existsSync } from 'fs';
|
|
2
|
+
import { readFile } from 'fs/promises';
|
|
3
|
+
import { createDayKeyFormatter } from './day-key.js';
|
|
4
|
+
|
|
5
|
+
const ACTIVE_GAP_CAP_MS = 15 * 60 * 1000;
|
|
6
|
+
|
|
7
|
+
export async function enrichFromRollout(rolloutPath, opts = {}) {
|
|
8
|
+
if (!rolloutPath || !existsSync(rolloutPath)) {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const tz = opts.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
13
|
+
const toDayKey = opts.toDayKey || createDayKeyFormatter(tz);
|
|
14
|
+
const result = {
|
|
15
|
+
model_name: null,
|
|
16
|
+
reasoning_effort: null,
|
|
17
|
+
first_timestamp: null,
|
|
18
|
+
last_timestamp: null,
|
|
19
|
+
active_seconds: null,
|
|
20
|
+
active_by_day: null,
|
|
21
|
+
parent_thread_id: null,
|
|
22
|
+
usage_total: null,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
const content = await readFile(rolloutPath, 'utf8');
|
|
27
|
+
|
|
28
|
+
let prevTimestamp = null;
|
|
29
|
+
let activeMs = 0;
|
|
30
|
+
const activeByDay = new Map();
|
|
31
|
+
for (const line of content.split(/\r?\n/)) {
|
|
32
|
+
if (!line.trim()) continue;
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const obj = JSON.parse(line);
|
|
36
|
+
|
|
37
|
+
if (obj.timestamp) {
|
|
38
|
+
const ts = new Date(obj.timestamp).getTime();
|
|
39
|
+
if (!isNaN(ts)) {
|
|
40
|
+
if (!result.first_timestamp || ts < result.first_timestamp) {
|
|
41
|
+
result.first_timestamp = ts;
|
|
42
|
+
}
|
|
43
|
+
if (!result.last_timestamp || ts > result.last_timestamp) {
|
|
44
|
+
result.last_timestamp = ts;
|
|
45
|
+
}
|
|
46
|
+
if (prevTimestamp !== null && ts >= prevTimestamp) {
|
|
47
|
+
const deltaMs = Math.min(ts - prevTimestamp, ACTIVE_GAP_CAP_MS);
|
|
48
|
+
activeMs += deltaMs;
|
|
49
|
+
if (deltaMs > 0) {
|
|
50
|
+
const dayKey = toDayKey(prevTimestamp);
|
|
51
|
+
activeByDay.set(dayKey, (activeByDay.get(dayKey) || 0) + deltaMs);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
prevTimestamp = ts;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (obj.type === 'event_msg' && obj.payload?.type === 'token_count') {
|
|
59
|
+
const usage = obj.payload.info?.total_token_usage;
|
|
60
|
+
if (usage) {
|
|
61
|
+
result.usage_total = {
|
|
62
|
+
input_tokens: usage.input_tokens || 0,
|
|
63
|
+
cached_input_tokens: usage.cached_input_tokens || 0,
|
|
64
|
+
output_tokens: usage.output_tokens || 0,
|
|
65
|
+
reasoning_output_tokens: usage.reasoning_output_tokens || 0,
|
|
66
|
+
total_tokens: usage.total_tokens || 0,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (obj.type === 'turn_context' && obj.payload) {
|
|
72
|
+
const p = obj.payload;
|
|
73
|
+
if (p.model && !result.model_name) result.model_name = p.model;
|
|
74
|
+
if (p.effort && !result.reasoning_effort) result.reasoning_effort = p.effort;
|
|
75
|
+
if (p.collaboration_mode?.settings) {
|
|
76
|
+
const s = p.collaboration_mode.settings;
|
|
77
|
+
if (s.model && !result.model_name) result.model_name = s.model;
|
|
78
|
+
if (s.reasoning_effort && !result.reasoning_effort) result.reasoning_effort = s.reasoning_effort;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (obj.type === 'session_meta' && obj.payload) {
|
|
83
|
+
if (obj.payload.model && !result.model_name) result.model_name = obj.payload.model;
|
|
84
|
+
if (!result.parent_thread_id) {
|
|
85
|
+
result.parent_thread_id =
|
|
86
|
+
obj.payload?.source?.subagent?.thread_spawn?.parent_thread_id ||
|
|
87
|
+
obj.payload?.forked_from_id ||
|
|
88
|
+
null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
} catch {
|
|
92
|
+
// malformed line
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
}
|
|
96
|
+
if (activeMs > 0) {
|
|
97
|
+
const activeByDaySeconds = Object.fromEntries(
|
|
98
|
+
[...activeByDay.entries()].map(([dayKey, ms]) => [dayKey, Math.round(ms / 1000)])
|
|
99
|
+
);
|
|
100
|
+
result.active_by_day = activeByDaySeconds;
|
|
101
|
+
result.active_seconds = Object.values(activeByDaySeconds).reduce((sum, seconds) => sum + seconds, 0);
|
|
102
|
+
}
|
|
103
|
+
} catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return result;
|
|
108
|
+
}
|