tokentracker-cli 0.95.2 → 0.96.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/dashboard/dist/assets/{AchievementBadge-DtB1MwFe.js → AchievementBadge-Bb2ljquW.js} +1 -1
- package/dashboard/dist/assets/{AchievementsPage--vddu0dC.js → AchievementsPage-ByUP2x8a.js} +2 -2
- package/dashboard/dist/assets/{AchievementsSection-CDl9hsbj.js → AchievementsSection-_L6sCLTw.js} +1 -1
- package/dashboard/dist/assets/{BadgeDetailModal-C7WsIRy9.js → BadgeDetailModal-B9n_-45B.js} +1 -1
- package/dashboard/dist/assets/{BotAnimated-CmXyzIhX.js → BotAnimated-lWmg-aZg.js} +1 -1
- package/dashboard/dist/assets/{Card-SUbC3VNS.js → Card-aSp259T0.js} +1 -1
- package/dashboard/dist/assets/{ClawdAnimated-BdEbs8Me.js → ClawdAnimated-Cy-XaTXW.js} +2 -2
- package/dashboard/dist/assets/{CommandPalette-D1RLLeHB.js → CommandPalette-CNwtKCUm.js} +1 -1
- package/dashboard/dist/assets/{CommunityStatsModal-5ESGJgZQ.js → CommunityStatsModal-DJSezaPV.js} +1 -1
- package/dashboard/dist/assets/{ConfirmModal-DsCd51b2.js → ConfirmModal-CVjmhZd6.js} +1 -1
- package/dashboard/dist/assets/{Controls-BVrLC_QD.js → Controls-BIj6yVxE.js} +1 -1
- package/dashboard/dist/assets/{DashboardPage-DtUOG6og.js → DashboardPage-BjYZLmqQ.js} +1 -1
- package/dashboard/dist/assets/{DevicePage-zYjj-m5V.js → DevicePage-ptsI2fZc.js} +1 -1
- package/dashboard/dist/assets/{DialogClose-D66yBqkd.js → DialogClose-D1pLoZ56.js} +1 -1
- package/dashboard/dist/assets/DialogDescription-KY7X94CG.js +1 -0
- package/dashboard/dist/assets/{DialogTitle-C4ia00lc.js → DialogTitle-DfSBUjWf.js} +1 -1
- package/dashboard/dist/assets/{FadeIn-DF2c8XaR.js → FadeIn-DBn4L_BV.js} +1 -1
- package/dashboard/dist/assets/{HeaderGithubStar-Cnr3aqcX.js → HeaderGithubStar-EJLBwEqp.js} +1 -1
- package/dashboard/dist/assets/{HoverTooltip-DBOpxlH7.js → HoverTooltip-DaSrZhYd.js} +1 -1
- package/dashboard/dist/assets/{IpCheckPage-CUY6KBaV.js → IpCheckPage-diQTVaYt.js} +1 -1
- package/dashboard/dist/assets/{LandingPage-DKL8hZEq.js → LandingPage-BqCxjwar.js} +2 -2
- package/dashboard/dist/assets/{LeaderboardAvatar-DRYeMNlq.js → LeaderboardAvatar-CQImCsPN.js} +1 -1
- package/dashboard/dist/assets/{LeaderboardPage-DpD9Az3k.js → LeaderboardPage-D2ZDUi_O.js} +2 -2
- package/dashboard/dist/assets/{LeaderboardProfileModal-Dfg0V13F.js → LeaderboardProfileModal-DHvADX1d.js} +3 -3
- package/dashboard/dist/assets/{LeaderboardProfilePage-DH-jcVDu.js → LeaderboardProfilePage-CJsedB28.js} +1 -1
- package/dashboard/dist/assets/LimitsPage-CAwff24b.js +6 -0
- package/dashboard/dist/assets/{LocalOnlyNotice-Bj_m0hw7.js → LocalOnlyNotice-Cmnvu9tI.js} +1 -1
- package/dashboard/dist/assets/{LoginCard-BVKvtx60.js → LoginCard-BQmKkD7X.js} +1 -1
- package/dashboard/dist/assets/{LoginModal-mUGiSIJN.js → LoginModal-2oPROePZ.js} +1 -1
- package/dashboard/dist/assets/{LoginPage-BORCuCUt.js → LoginPage-aqdUJoQK.js} +1 -1
- package/dashboard/dist/assets/{PetPage-D1JfEOVA.js → PetPage-Bn3p_o5r.js} +1 -1
- package/dashboard/dist/assets/{PopoverPopup-DK3et-HG.js → PopoverPopup-DXD_YdiB.js} +1 -1
- package/dashboard/dist/assets/{ProviderIcon-By8FAjxR.js → ProviderIcon-BZZ45-gY.js} +1 -1
- package/dashboard/dist/assets/{ResetPasswordPage-DPa81jqa.js → ResetPasswordPage-Bn5ITiHQ.js} +1 -1
- package/dashboard/dist/assets/{SegmentedControl-DLzS-2eZ.js → SegmentedControl-hGXboQZy.js} +1 -1
- package/dashboard/dist/assets/{Select-C05W6XEM.js → Select-BP8aVRZ1.js} +1 -1
- package/dashboard/dist/assets/{SelectItemText-CcfB3blP.js → SelectItemText-Odx47kND.js} +1 -1
- package/dashboard/dist/assets/{ServiceStatusPage-ByqLbHv1.js → ServiceStatusPage-DUueam0N.js} +1 -1
- package/dashboard/dist/assets/SessionsPage-BA9IFqQ1.js +2 -0
- package/dashboard/dist/assets/{SettingsPage-DAcndJZQ.js → SettingsPage-DLOHHB4f.js} +2 -2
- package/dashboard/dist/assets/{SkillsPage-BuuqQuNU.js → SkillsPage-Ds2vH_PG.js} +1 -1
- package/dashboard/dist/assets/{TokenGalaxy-DzccxkXW.js → TokenGalaxy-CWBR4jnB.js} +1 -1
- package/dashboard/dist/assets/{ToolbarRootContext-XUDMp5uy.js → ToolbarRootContext-Bmg56vYz.js} +1 -1
- package/dashboard/dist/assets/{TrendMonitor-B-yYabbz.js → TrendMonitor-BQzcllvg.js} +1 -1
- package/dashboard/dist/assets/{WidgetsPage-CwUDjEFo.js → WidgetsPage-Bu3tHDjO.js} +1 -1
- package/dashboard/dist/assets/{WrappedPage-Dr9ow3gH.js → WrappedPage-BrAMJhgy.js} +1 -1
- package/dashboard/dist/assets/{__vite-browser-external-DPOlUkcY.js → __vite-browser-external-4IGm4Djn.js} +12 -12
- package/dashboard/dist/assets/{agent-logos-Cqc_suYk.js → agent-logos-B8lT-HcI.js} +1 -1
- package/dashboard/dist/assets/{arrow-up-right-DSsR3Ntr.js → arrow-up-right-rpBCOTg2.js} +1 -1
- package/dashboard/dist/assets/{check-Cy35lAKh.js → check-DIKw7TYH.js} +1 -1
- package/dashboard/dist/assets/{composite-BDwOlhhX.js → composite-_NeXO0lv.js} +1 -1
- package/dashboard/dist/assets/{copy-4szu5cYh.js → copy-CsfAtL7P.js} +1 -1
- package/dashboard/dist/assets/{download-DdGW6Krz.js → download-DM1W9pIW.js} +1 -1
- package/dashboard/dist/assets/{external-link-DwSt83cq.js → external-link-CwbuMbeQ.js} +1 -1
- package/dashboard/dist/assets/{flame-DubukWqK.js → flame-DcYbLdhE.js} +1 -1
- package/dashboard/dist/assets/{format-tokens-B70EdQcN.js → format-tokens-CEvr3dNy.js} +1 -1
- package/dashboard/dist/assets/{icons-B86TLdAj.js → icons-DaGMVsJ5.js} +1 -1
- package/dashboard/dist/assets/{index-B_KMoBeK.js → index-BVVLPj3N.js} +1 -1
- package/dashboard/dist/assets/{index-DI-W-pun.js → index-ChZkJjpk.js} +1 -1
- package/dashboard/dist/assets/{index-DzEMcUwI.js → index-T7OjmEuX.js} +1 -1
- package/dashboard/dist/assets/{info-BzFpLbl6.js → info-CAjN3dX4.js} +1 -1
- package/dashboard/dist/assets/limits-providers-jn7Yln_V.js +1 -0
- package/dashboard/dist/assets/{link-2-BQp7-3T7.js → link-2-DaWH2Tyc.js} +1 -1
- package/dashboard/dist/assets/{loader-circle-lrDmbIg5.js → loader-circle-CnF8qPi1.js} +1 -1
- package/dashboard/dist/assets/{maximize-2-Cnd_Oth8.js → maximize-2-B-2Pnj6g.js} +1 -1
- package/dashboard/dist/assets/{modifiers.esm-CPTpZdHU.js → modifiers.esm-BoDOvUKl.js} +1 -1
- package/dashboard/dist/assets/{monitor-smartphone-C7UY1zRx.js → monitor-smartphone-DDgwxG_E.js} +1 -1
- package/dashboard/dist/assets/{plus-Bglp9N_7.js → plus-DIc1YUWB.js} +1 -1
- package/dashboard/dist/assets/{provider-display-CGsFySs6.js → provider-display-yeNsVCO_.js} +1 -1
- package/dashboard/dist/assets/{react-DKXriBSF.js → react-DZqS6YTX.js} +1 -1
- package/dashboard/dist/assets/{search-D_LLDRdU.js → search-D6tEppYJ.js} +1 -1
- package/dashboard/dist/assets/{skills-api-CJWjKIco.js → skills-api-vsA1rc1Z.js} +1 -1
- package/dashboard/dist/assets/{sortable.esm-zlY09tye.js → sortable.esm-DDCnS1HI.js} +1 -1
- package/dashboard/dist/assets/{store-8e16Ac2h.js → store-Crne4DqF.js} +1 -1
- package/dashboard/dist/assets/{terminal-CR228ekj.js → terminal-Ca7dkLMF.js} +1 -1
- package/dashboard/dist/assets/{trash-2-BjLWewOp.js → trash-2-CjEAS5ic.js} +1 -1
- package/dashboard/dist/assets/{use-community-stats-DhOJPaVg.js → use-community-stats-BHx2YxyK.js} +1 -1
- package/dashboard/dist/assets/{use-latest-request-guard-lcGhb-NC.js → use-latest-request-guard-BLGbXQdf.js} +1 -1
- package/dashboard/dist/assets/{use-limits-display-prefs-DJIImtLN.js → use-limits-display-prefs-DBkAp6ob.js} +1 -1
- package/dashboard/dist/assets/{use-ordered-list-BJZfZuoJ.js → use-ordered-list-BTfW5M9u.js} +1 -1
- package/dashboard/dist/assets/use-reduced-motion-D_kUnDs4.js +1 -0
- package/dashboard/dist/assets/{use-session-efficiency-pref-C-l4b1cD.js → use-session-efficiency-pref-DBmd-MqH.js} +1 -1
- package/dashboard/dist/assets/{use-transform-UjIt477N.js → use-transform-Bp5XrwaQ.js} +1 -1
- package/dashboard/dist/assets/{use-usage-limits-DqoTZrqT.js → use-usage-limits-BmqRcyhH.js} +1 -1
- package/dashboard/dist/assets/{useCurrency-CnM8aB4v.js → useCurrency-B0YsRDj-.js} +1 -1
- package/dashboard/dist/assets/{useTokenFormat-D4pAK2ld.js → useTokenFormat-PP3D0Ket.js} +1 -1
- package/dashboard/dist/assets/{zap-Du1IyEtj.js → zap-BVOm1aBH.js} +1 -1
- package/dashboard/dist/index.html +1 -1
- package/dashboard/dist/ip-check.html +1 -1
- package/dashboard/dist/leaderboard.html +1 -1
- package/dashboard/dist/share.html +1 -1
- package/package.json +1 -1
- package/src/lib/ark-agent-plan-limits.js +437 -0
- package/src/lib/ark-coding-plan-limits.js +104 -27
- package/src/lib/codex-model-attribution.js +128 -0
- package/src/lib/codex-rollout-parser.js +98 -4
- package/src/lib/pricing/curated-overrides.json +6 -6
- package/src/lib/pricing/index.js +38 -1
- package/src/lib/pricing/seed-snapshot.json +1 -1
- package/src/lib/rollout.js +50 -9
- package/src/lib/session-analytics.js +374 -110
- package/src/lib/usage-limits.js +19 -5
- package/dashboard/dist/assets/DialogDescription-BVlaoajd.js +0 -1
- package/dashboard/dist/assets/LimitsPage-CVCKPScf.js +0 -5
- package/dashboard/dist/assets/SessionsPage-CEDv1thj.js +0 -2
- package/dashboard/dist/assets/limits-providers-DxZOjiXQ.js +0 -1
- package/dashboard/dist/assets/use-reduced-motion-DdpxZQoh.js +0 -1
|
@@ -30,7 +30,7 @@ const path = require("node:path");
|
|
|
30
30
|
const readline = require("node:readline");
|
|
31
31
|
const { listClaudeProjectFiles, listRolloutFilesDeep, claudeMessageDedupKey } = require("./rollout");
|
|
32
32
|
const { parseCodexRolloutFile } = require("./codex-rollout-parser");
|
|
33
|
-
const { computeRowCost } = require("./pricing");
|
|
33
|
+
const { computeRowCost, getModelPricing } = require("./pricing");
|
|
34
34
|
const { USD_TICKS_PER_USD, normalizeGrokUsage } = require("./grok-usage");
|
|
35
35
|
const wsl = require("./wsl-probe");
|
|
36
36
|
|
|
@@ -57,9 +57,10 @@ const wsl = require("./wsl-probe");
|
|
|
57
57
|
// their union, deduping shared records while retaining divergent tails.
|
|
58
58
|
// v12 fixes Grok's mutually exclusive token columns, retains exact reported
|
|
59
59
|
// cost, and adds metadata-only usage diagnostics to the local session browser.
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
|
|
60
|
+
// v13 stores per-model Codex usage (including observed reroutes and the exact
|
|
61
|
+
// long-context subset) and folds delivery signals into the token parser's
|
|
62
|
+
// single pass instead of parsing every Codex file twice.
|
|
63
|
+
const SIDECAR_VERSION = 13;
|
|
63
64
|
const EDIT_TOOLS = new Set([
|
|
64
65
|
"apply_patch",
|
|
65
66
|
"edit",
|
|
@@ -118,6 +119,184 @@ function emptyTotals() {
|
|
|
118
119
|
return tokenTotals({});
|
|
119
120
|
}
|
|
120
121
|
|
|
122
|
+
const MODEL_USAGE_SUM_FIELDS = [
|
|
123
|
+
"input_tokens",
|
|
124
|
+
"cached_input_tokens",
|
|
125
|
+
"cache_creation_input_tokens",
|
|
126
|
+
"output_tokens",
|
|
127
|
+
"reasoning_output_tokens",
|
|
128
|
+
"total_tokens",
|
|
129
|
+
"long_context_input_tokens",
|
|
130
|
+
"long_context_cached_input_tokens",
|
|
131
|
+
"long_context_cache_creation_input_tokens",
|
|
132
|
+
"long_context_output_tokens",
|
|
133
|
+
"long_context_reasoning_output_tokens",
|
|
134
|
+
"usage_events",
|
|
135
|
+
"rerouted_usage_events",
|
|
136
|
+
"long_context_usage_events",
|
|
137
|
+
// Not a token counter: an edit turn is a turn, and turn_context both marks
|
|
138
|
+
// where a turn begins and names its model, so an edit turn has exactly one
|
|
139
|
+
// owner. Splitting it here keeps by_model.tokens_per_edit a ratio over one
|
|
140
|
+
// population instead of this model's tokens over the whole session's turns.
|
|
141
|
+
"edit_turns",
|
|
142
|
+
];
|
|
143
|
+
|
|
144
|
+
function normalizeModelUsageRows(rows) {
|
|
145
|
+
const byModel = new Map();
|
|
146
|
+
for (const value of Array.isArray(rows) ? rows : []) {
|
|
147
|
+
const model = normalizeSessionModel(value?.model) || "unknown";
|
|
148
|
+
let row = byModel.get(model);
|
|
149
|
+
if (!row) {
|
|
150
|
+
row = {
|
|
151
|
+
model,
|
|
152
|
+
...Object.fromEntries(MODEL_USAGE_SUM_FIELDS.map((field) => [field, 0])),
|
|
153
|
+
selected_models: new Set(),
|
|
154
|
+
reroute_reasons: new Set(),
|
|
155
|
+
model_attribution: "selected",
|
|
156
|
+
};
|
|
157
|
+
byModel.set(model, row);
|
|
158
|
+
}
|
|
159
|
+
for (const field of MODEL_USAGE_SUM_FIELDS) row[field] += finite(value?.[field]);
|
|
160
|
+
if (Array.isArray(value?.selected_models)) {
|
|
161
|
+
for (const selected of value.selected_models) {
|
|
162
|
+
const normalized = normalizeSessionModel(selected);
|
|
163
|
+
if (normalized) row.selected_models.add(normalized);
|
|
164
|
+
}
|
|
165
|
+
} else if (model !== "unknown") {
|
|
166
|
+
// serializeModelUsageRow() omits selected_models when it is just the
|
|
167
|
+
// effective model repeated. An absent field therefore means "selected
|
|
168
|
+
// == effective", which is the case for every non-rerouted row.
|
|
169
|
+
row.selected_models.add(model);
|
|
170
|
+
}
|
|
171
|
+
for (const reason of Array.isArray(value?.reroute_reasons) ? value.reroute_reasons : []) {
|
|
172
|
+
if (typeof reason === "string" && reason.trim()) row.reroute_reasons.add(reason.trim());
|
|
173
|
+
}
|
|
174
|
+
if (value?.model_attribution === "effective" || finite(value?.rerouted_usage_events) > 0) {
|
|
175
|
+
row.model_attribution = "effective";
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return [...byModel.values()]
|
|
179
|
+
.map((row) => ({
|
|
180
|
+
...row,
|
|
181
|
+
selected_models: [...row.selected_models].sort(),
|
|
182
|
+
reroute_reasons: [...row.reroute_reasons].sort(),
|
|
183
|
+
}))
|
|
184
|
+
.sort((a, b) => b.total_tokens - a.total_tokens || a.model.localeCompare(b.model));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// The sidecar is re-read on every dashboard request, so keep it small: omit
|
|
188
|
+
// the model_usage fields that normalizeModelUsageRows() reconstructs on read.
|
|
189
|
+
// All four omissions are lossless - zero counters default to 0, an absent
|
|
190
|
+
// selected_models re-seeds from `model`, model_attribution is derived from
|
|
191
|
+
// rerouted_usage_events, and cost_usd is always recomputed by
|
|
192
|
+
// repriceSessionRecord(). Worth ~19% of the file on a 6.8k-session history,
|
|
193
|
+
// most of it the long_context_* counters that stay at zero (see
|
|
194
|
+
// OPENAI_LONG_CONTEXT_INPUT_THRESHOLD in src/lib/pricing/index.js).
|
|
195
|
+
function serializeModelUsageRow(row) {
|
|
196
|
+
const out = { model: row.model };
|
|
197
|
+
for (const field of MODEL_USAGE_SUM_FIELDS) {
|
|
198
|
+
const value = finite(row[field]);
|
|
199
|
+
if (value !== 0) out[field] = value;
|
|
200
|
+
}
|
|
201
|
+
const selected = Array.isArray(row.selected_models) ? row.selected_models : [];
|
|
202
|
+
if (!(selected.length === 1 && selected[0] === row.model)) out.selected_models = selected;
|
|
203
|
+
if (Array.isArray(row.reroute_reasons) && row.reroute_reasons.length > 0) {
|
|
204
|
+
out.reroute_reasons = row.reroute_reasons;
|
|
205
|
+
}
|
|
206
|
+
if (row.model_attribution === "effective") out.model_attribution = "effective";
|
|
207
|
+
return out;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function serializeSessionRecord(row) {
|
|
211
|
+
if (!Array.isArray(row?.model_usage) || row.model_usage.length === 0) {
|
|
212
|
+
return JSON.stringify(row);
|
|
213
|
+
}
|
|
214
|
+
return JSON.stringify({ ...row, model_usage: row.model_usage.map(serializeModelUsageRow) });
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function repriceSessionRecord(record) {
|
|
218
|
+
const modelUsage = normalizeModelUsageRows(record?.model_usage);
|
|
219
|
+
if (modelUsage.length > 0) {
|
|
220
|
+
let hasUnpricedUsage = false;
|
|
221
|
+
for (const row of modelUsage) {
|
|
222
|
+
row.cost_usd = computeRowCost({ source: record.source, ...row });
|
|
223
|
+
if (record.source === "codex" && row.total_tokens > 0) {
|
|
224
|
+
const pricing = getModelPricing(row.model, { source: record.source });
|
|
225
|
+
if (![pricing.input, pricing.output, pricing.cache_read, pricing.cache_write]
|
|
226
|
+
.some((value) => finite(value) > 0)) {
|
|
227
|
+
hasUnpricedUsage = true;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
record.model_usage = modelUsage;
|
|
232
|
+
record.model = modelUsage.length > 1 ? "mixed" : modelUsage[0].model;
|
|
233
|
+
record.cost_usd = modelUsage.reduce((sum, row) => sum + finite(row.cost_usd), 0);
|
|
234
|
+
record.cost_source = "model_pricing";
|
|
235
|
+
// Internal labels such as `codex-auto-review` expose token usage but not
|
|
236
|
+
// an underlying public model/rate. Keep their tokens, leave their cost at
|
|
237
|
+
// zero, and explicitly mark the session estimate as a known lower bound.
|
|
238
|
+
record.cost_is_partial = hasUnpricedUsage;
|
|
239
|
+
} else {
|
|
240
|
+
const providerCost = Number(record.provider_cost_usd);
|
|
241
|
+
record.cost_usd = record.cost_source === "provider_reported"
|
|
242
|
+
&& Number.isFinite(providerCost)
|
|
243
|
+
&& providerCost >= 0
|
|
244
|
+
? providerCost
|
|
245
|
+
: computeRowCost({ source: record.source, model: record.model, ...record.tokens });
|
|
246
|
+
}
|
|
247
|
+
record.cost_per_edit = record.edit_turns > 0 ? record.cost_usd / record.edit_turns : null;
|
|
248
|
+
return record;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// The model_usage wire contract is DENSE: every field dashboard/src/lib/
|
|
252
|
+
// sessions-api.ts declares is present on every row. Storage is free to be
|
|
253
|
+
// sparse - serializeModelUsageRow() drops zero counters, and a record with no
|
|
254
|
+
// model_usage at all (every claude/grok session, plus codex sessions written
|
|
255
|
+
// by an older sidecar) has nothing to trim in the first place - but neither
|
|
256
|
+
// shape may leak into an API response, or the declared type describes a row
|
|
257
|
+
// that does not exist. Densify here, the one point both the by_model
|
|
258
|
+
// aggregation and toSessionBrowserRow() pass through.
|
|
259
|
+
function denseModelUsageRow(overrides = {}) {
|
|
260
|
+
return {
|
|
261
|
+
model: "unknown",
|
|
262
|
+
...Object.fromEntries(MODEL_USAGE_SUM_FIELDS.map((field) => [field, 0])),
|
|
263
|
+
selected_models: [],
|
|
264
|
+
reroute_reasons: [],
|
|
265
|
+
model_attribution: "selected",
|
|
266
|
+
cost_usd: 0,
|
|
267
|
+
...overrides,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function modelUsageForAggregation(record) {
|
|
272
|
+
const editTurns = finite(record?.edit_turns);
|
|
273
|
+
const rows = normalizeModelUsageRows(record?.model_usage);
|
|
274
|
+
if (rows.length === 0) {
|
|
275
|
+
return [denseModelUsageRow({
|
|
276
|
+
model: normalizeSessionModel(record?.model) || "unknown",
|
|
277
|
+
total_tokens: finite(record?.total_tokens || record?.tokens?.total_tokens),
|
|
278
|
+
cost_usd: finite(record?.cost_usd),
|
|
279
|
+
edit_turns: editTurns,
|
|
280
|
+
})];
|
|
281
|
+
}
|
|
282
|
+
const priced = rows.map((row) => denseModelUsageRow({
|
|
283
|
+
...row,
|
|
284
|
+
cost_usd: Number.isFinite(Number(row.cost_usd))
|
|
285
|
+
? Number(row.cost_usd)
|
|
286
|
+
: computeRowCost({ source: record.source, ...row }),
|
|
287
|
+
}));
|
|
288
|
+
// Sum(rows.edit_turns) has to equal record.edit_turns or the by_model column
|
|
289
|
+
// stops summing to summary.edit_turns. It can fall short two ways: a sidecar
|
|
290
|
+
// written before model_usage carried edit_turns, and an edit turn whose model
|
|
291
|
+
// produced no billable delta, leaving no usage row to attach to. Fold the
|
|
292
|
+
// residual into the busiest model - normalizeModelUsageRows() sorts by
|
|
293
|
+
// total_tokens - which is the same approximation `retries` uses.
|
|
294
|
+
const assigned = priced.reduce((sum, row) => sum + finite(row.edit_turns), 0);
|
|
295
|
+
const residual = editTurns - assigned;
|
|
296
|
+
if (residual > 0) priced[0].edit_turns = finite(priced[0].edit_turns) + residual;
|
|
297
|
+
return priced;
|
|
298
|
+
}
|
|
299
|
+
|
|
121
300
|
function safeTimestamp(value) {
|
|
122
301
|
if (typeof value !== "string" || !Number.isFinite(Date.parse(value))) return null;
|
|
123
302
|
return new Date(value).toISOString();
|
|
@@ -243,12 +422,7 @@ function finalizeRecord(record) {
|
|
|
243
422
|
record.active_ms = Math.max(0, finite(record.active_ms));
|
|
244
423
|
record.duration_ms = record.active_ms;
|
|
245
424
|
record.total_tokens = finite(record.total_tokens || record.tokens?.total_tokens);
|
|
246
|
-
|
|
247
|
-
record.cost_usd = record.cost_source === "provider_reported"
|
|
248
|
-
&& Number.isFinite(providerCost)
|
|
249
|
-
&& providerCost >= 0
|
|
250
|
-
? providerCost
|
|
251
|
-
: computeRowCost({ source: record.source, model: record.model, ...record.tokens });
|
|
425
|
+
repriceSessionRecord(record);
|
|
252
426
|
record.productive = record.edit_turns > 0;
|
|
253
427
|
// A first-pass delivery has exactly one user turn containing an observed
|
|
254
428
|
// edit and no repeated user request. The legacy one_shot field stays as an
|
|
@@ -409,8 +583,7 @@ async function scanClaudeSession(filePath) {
|
|
|
409
583
|
});
|
|
410
584
|
}
|
|
411
585
|
|
|
412
|
-
|
|
413
|
-
const filePaths = readableSessionPaths(filePath);
|
|
586
|
+
function createCodexDeliverySignalCollector() {
|
|
414
587
|
const bounds = emptyBounds();
|
|
415
588
|
let turns = 0;
|
|
416
589
|
let editTurns = 0;
|
|
@@ -422,95 +595,117 @@ async function scanCodexDeliverySignals(filePath) {
|
|
|
422
595
|
let lastPromptFingerprint = null;
|
|
423
596
|
let subagentCalls = 0;
|
|
424
597
|
const subagentTypes = new Map();
|
|
598
|
+
// The model the open turn belongs to, as decided by codex-model-attribution.
|
|
599
|
+
// The parser hands it in rather than this collector re-reading
|
|
600
|
+
// turn_context.payload.model, so there stays exactly one authority on which
|
|
601
|
+
// model owns a turn - the same one that bills the turn's tokens.
|
|
602
|
+
let currentTurnModel = null;
|
|
603
|
+
const editTurnsByModel = new Map();
|
|
425
604
|
|
|
426
605
|
function closeTurn() {
|
|
427
|
-
if (currentTurnOpen && currentHadEdit)
|
|
606
|
+
if (currentTurnOpen && currentHadEdit) {
|
|
607
|
+
editTurns += 1;
|
|
608
|
+
const key = currentTurnModel || "unknown";
|
|
609
|
+
editTurnsByModel.set(key, (editTurnsByModel.get(key) || 0) + 1);
|
|
610
|
+
}
|
|
428
611
|
currentHadEdit = false;
|
|
429
612
|
}
|
|
430
613
|
|
|
431
|
-
function beginTurn(key) {
|
|
614
|
+
function beginTurn(key, model) {
|
|
432
615
|
if (currentTurnOpen && key && currentTurnKey === key) return;
|
|
433
616
|
if (currentTurnOpen) closeTurn();
|
|
434
617
|
currentTurnOpen = true;
|
|
435
618
|
currentTurnKey = key || null;
|
|
619
|
+
currentTurnModel = model || null;
|
|
436
620
|
turns += 1;
|
|
437
621
|
}
|
|
438
622
|
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
if (!toolNames.length) continue;
|
|
471
|
-
if (!currentTurnOpen) beginTurn(String(obj.timestamp || turns + 1));
|
|
472
|
-
if (toolNames.some((name) => EDIT_TOOLS.has(name))) currentHadEdit = true;
|
|
473
|
-
for (const name of toolNames) {
|
|
474
|
-
if (!CODEX_SUBAGENT_TOOLS.has(name)) continue;
|
|
475
|
-
subagentCalls += 1;
|
|
476
|
-
const displayName = name === "multi_agent_v1__spawn_agent" ? "spawn_agent" : name;
|
|
477
|
-
subagentTypes.set(displayName, (subagentTypes.get(displayName) || 0) + 1);
|
|
478
|
-
}
|
|
479
|
-
}
|
|
480
|
-
scannedFiles += 1;
|
|
481
|
-
} catch (error) {
|
|
482
|
-
if (!isRecoverableSessionReadError(error)) throw error;
|
|
483
|
-
} finally {
|
|
484
|
-
for (const recordHash of currentRecordHashes) priorRecordHashes.add(recordHash);
|
|
623
|
+
function consume(obj, model = null) {
|
|
624
|
+
updateBounds(bounds, obj?.timestamp);
|
|
625
|
+
if (obj?.type === "turn_context") {
|
|
626
|
+
hasTurnContext = true;
|
|
627
|
+
beginTurn(String(obj.payload?.turn_id || obj.timestamp || turns + 1), model);
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
// Between two turn_context rows the attributed model only moves when a
|
|
631
|
+
// model/rerouted event fires mid-turn. recordModelUsage() bills the rest of
|
|
632
|
+
// the turn's tokens to the new model, so the turn's edits have to move with
|
|
633
|
+
// them - otherwise one turn's tokens and its edit count land on two
|
|
634
|
+
// different rows and by_model.tokens_per_edit divides across models.
|
|
635
|
+
if (currentTurnOpen && model && model !== currentTurnModel) currentTurnModel = model;
|
|
636
|
+
const prompt = extractCodexPrompt(obj);
|
|
637
|
+
if (prompt) {
|
|
638
|
+
const fingerprint = promptFingerprint(prompt);
|
|
639
|
+
if (lastPromptFingerprint && fingerprint === lastPromptFingerprint) retryTurns += 1;
|
|
640
|
+
lastPromptFingerprint = fingerprint;
|
|
641
|
+
if (!hasTurnContext) beginTurn(String(obj.timestamp || turns + 1), model);
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
if (obj?.type !== "response_item") return;
|
|
645
|
+
const toolNames = extractCodexSignalTools(obj.payload);
|
|
646
|
+
if (!toolNames.length) return;
|
|
647
|
+
if (!currentTurnOpen) beginTurn(String(obj.timestamp || turns + 1), model);
|
|
648
|
+
if (toolNames.some((name) => EDIT_TOOLS.has(name))) currentHadEdit = true;
|
|
649
|
+
for (const name of toolNames) {
|
|
650
|
+
if (!CODEX_SUBAGENT_TOOLS.has(name)) continue;
|
|
651
|
+
subagentCalls += 1;
|
|
652
|
+
const displayName = name === "multi_agent_v1__spawn_agent" ? "spawn_agent" : name;
|
|
653
|
+
subagentTypes.set(displayName, (subagentTypes.get(displayName) || 0) + 1);
|
|
485
654
|
}
|
|
486
655
|
}
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
656
|
+
|
|
657
|
+
function finish() {
|
|
658
|
+
closeTurn();
|
|
659
|
+
return {
|
|
660
|
+
bounds,
|
|
661
|
+
turns,
|
|
662
|
+
editTurns,
|
|
663
|
+
editTurnsByModel: Object.fromEntries(editTurnsByModel),
|
|
664
|
+
retryTurns,
|
|
665
|
+
subagentCalls,
|
|
666
|
+
subagentTypes: Object.fromEntries([...subagentTypes.entries()].sort()),
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
return { consume, finish };
|
|
497
671
|
}
|
|
498
672
|
|
|
499
673
|
async function scanCodexSession(filePath) {
|
|
500
674
|
const filePaths = readableSessionPaths(filePath);
|
|
501
675
|
const primaryFilePath = filePaths[0] || String(filePath || "");
|
|
502
|
-
const
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
676
|
+
const signalCollector = createCodexDeliverySignalCollector();
|
|
677
|
+
const parsed = await parseCodexRolloutFile(filePaths, {
|
|
678
|
+
seenTokenEvents: new Set(),
|
|
679
|
+
collectBreakdowns: false,
|
|
680
|
+
collectModelUsage: true,
|
|
681
|
+
onObject: signalCollector.consume,
|
|
682
|
+
});
|
|
683
|
+
const signals = signalCollector.finish();
|
|
684
|
+
// The collector keyed edit turns by the model the parser handed it, so the
|
|
685
|
+
// keys are the same raw model ids parsed.modelUsage rows carry. A model whose
|
|
686
|
+
// edit turns produced no billable delta has no row to land on;
|
|
687
|
+
// modelUsageForAggregation() folds that residual into the busiest model so
|
|
688
|
+
// the by_model column still sums to edit_turns.
|
|
689
|
+
const editTurnsByModel = signals.editTurnsByModel || {};
|
|
690
|
+
const modelUsage = (parsed.modelUsage || []).map((row) => ({
|
|
691
|
+
...row,
|
|
692
|
+
edit_turns: finite(editTurnsByModel[row.model]),
|
|
693
|
+
}));
|
|
506
694
|
const parsedModel = normalizeSessionModel(parsed.model);
|
|
507
695
|
const provider = normalizeSessionModel(parsed.provider);
|
|
508
696
|
// Older Codex rollouts can omit turn_context.model. The shared parser then
|
|
509
697
|
// falls back to model_provider (for example "openai"), which is provenance
|
|
510
698
|
// rather than a model and must not become a model-table row.
|
|
511
|
-
const
|
|
512
|
-
|
|
513
|
-
|
|
699
|
+
const observedModels = (parsed.modelUsage || [])
|
|
700
|
+
.map((row) => normalizeSessionModel(row.model))
|
|
701
|
+
.filter(Boolean);
|
|
702
|
+
const model = observedModels.length > 1
|
|
703
|
+
? "mixed"
|
|
704
|
+
: observedModels[0] || (
|
|
705
|
+
parsedModel && parsedModel.toLowerCase() !== provider?.toLowerCase()
|
|
706
|
+
? parsedModel
|
|
707
|
+
: "unknown"
|
|
708
|
+
);
|
|
514
709
|
// Codex's own thread title from session_index.jsonl (Codex-authored
|
|
515
710
|
// metadata, keyed by session id). Null when Codex never named the thread —
|
|
516
711
|
// the UI then falls back to the project name.
|
|
@@ -539,6 +734,7 @@ async function scanCodexSession(filePath) {
|
|
|
539
734
|
project_key: projectKey(parsed.cwd, primaryFilePath),
|
|
540
735
|
project_ref: parsed.cwd || null,
|
|
541
736
|
model,
|
|
737
|
+
model_usage: modelUsage,
|
|
542
738
|
...signals.bounds,
|
|
543
739
|
turns: signals.turns || finite(parsed.turnCount),
|
|
544
740
|
edit_turns: signals.editTurns,
|
|
@@ -1129,7 +1325,10 @@ function analyticsEntryStatKey(source, filePath) {
|
|
|
1129
1325
|
|
|
1130
1326
|
function readSidecar(sidecarPath) {
|
|
1131
1327
|
try {
|
|
1132
|
-
return fs.readFileSync(sidecarPath, "utf8")
|
|
1328
|
+
return fs.readFileSync(sidecarPath, "utf8")
|
|
1329
|
+
.split("\n")
|
|
1330
|
+
.filter(Boolean)
|
|
1331
|
+
.map((line) => repriceSessionRecord(JSON.parse(line)));
|
|
1133
1332
|
} catch { return []; }
|
|
1134
1333
|
}
|
|
1135
1334
|
|
|
@@ -1219,6 +1418,7 @@ async function buildSessionAnalyticsInternal({ home = os.homedir(), force = fals
|
|
|
1219
1418
|
}
|
|
1220
1419
|
}
|
|
1221
1420
|
if (!row) continue;
|
|
1421
|
+
repriceSessionRecord(row);
|
|
1222
1422
|
// A one-way file hash enables incremental reuse without persisting or
|
|
1223
1423
|
// exposing the user's local session path.
|
|
1224
1424
|
row._cache_key = cacheKey;
|
|
@@ -1226,7 +1426,7 @@ async function buildSessionAnalyticsInternal({ home = os.homedir(), force = fals
|
|
|
1226
1426
|
nextFiles[cacheKey] = { stat_key: statKey };
|
|
1227
1427
|
}
|
|
1228
1428
|
sessions.sort((a, b) => String(b.ended_at || "").localeCompare(String(a.ended_at || "")));
|
|
1229
|
-
const content = sessions.map(
|
|
1429
|
+
const content = sessions.map(serializeSessionRecord).join("\n") + (sessions.length ? "\n" : "");
|
|
1230
1430
|
await writeAtomic(sidecarPath, content);
|
|
1231
1431
|
const generatedAt = new Date().toISOString();
|
|
1232
1432
|
await writeAtomic(metaPath, `${JSON.stringify({
|
|
@@ -1290,32 +1490,85 @@ function summarizeSessions(sessions, { from = "", to = "", includeSessions = tru
|
|
|
1290
1490
|
.map((row) => ({ ...row })));
|
|
1291
1491
|
const byModel = new Map();
|
|
1292
1492
|
const subagents = new Map();
|
|
1493
|
+
const totals = {
|
|
1494
|
+
sessions: 0,
|
|
1495
|
+
productive_sessions: 0,
|
|
1496
|
+
one_shot_sessions: 0,
|
|
1497
|
+
edit_turns: 0,
|
|
1498
|
+
retries: 0,
|
|
1499
|
+
total_tokens: 0,
|
|
1500
|
+
cost_usd: 0,
|
|
1501
|
+
edit_tokens: 0,
|
|
1502
|
+
edit_cost_usd: 0,
|
|
1503
|
+
};
|
|
1293
1504
|
for (const row of filtered) {
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
retries: 0,
|
|
1302
|
-
total_tokens: 0,
|
|
1303
|
-
cost_usd: 0,
|
|
1304
|
-
edit_tokens: 0,
|
|
1305
|
-
edit_cost_usd: 0,
|
|
1306
|
-
};
|
|
1307
|
-
agg.sessions += 1;
|
|
1308
|
-
if (row.productive) agg.productive_sessions += 1;
|
|
1309
|
-
if (row.first_pass ?? row.one_shot) agg.one_shot_sessions += 1;
|
|
1310
|
-
agg.edit_turns += finite(row.edit_turns);
|
|
1311
|
-
agg.retries += finite(row.retry_turns);
|
|
1312
|
-
agg.total_tokens += finite(row.total_tokens);
|
|
1313
|
-
agg.cost_usd += finite(row.cost_usd);
|
|
1505
|
+
totals.sessions += 1;
|
|
1506
|
+
if (row.productive) totals.productive_sessions += 1;
|
|
1507
|
+
if (row.first_pass ?? row.one_shot) totals.one_shot_sessions += 1;
|
|
1508
|
+
totals.edit_turns += finite(row.edit_turns);
|
|
1509
|
+
totals.retries += finite(row.retry_turns);
|
|
1510
|
+
totals.total_tokens += finite(row.total_tokens);
|
|
1511
|
+
totals.cost_usd += finite(row.cost_usd);
|
|
1314
1512
|
if (row.productive) {
|
|
1315
|
-
|
|
1316
|
-
|
|
1513
|
+
totals.edit_tokens += finite(row.total_tokens);
|
|
1514
|
+
totals.edit_cost_usd += finite(row.cost_usd);
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
// One session contributes a row to EVERY model it used, so per-model
|
|
1518
|
+
// `sessions` counts "sessions that touched this model" and the column does
|
|
1519
|
+
// NOT sum to summary.sessions (a session that switched models mid-thread
|
|
1520
|
+
// is counted once per model). Token, cost, edit_turn and edit_token columns
|
|
1521
|
+
// DO sum exactly - only the session headcount overlaps. Read the session
|
|
1522
|
+
// total from summary.sessions / session_count, never by summing this column;
|
|
1523
|
+
// test/session-analytics.test.js locks both halves of that contract.
|
|
1524
|
+
const usageRows = modelUsageForAggregation(row);
|
|
1525
|
+
const primaryModel = usageRows[0]?.model || "unknown";
|
|
1526
|
+
for (const usage of usageRows) {
|
|
1527
|
+
const key = usage.model || "unknown";
|
|
1528
|
+
const agg = byModel.get(key) || {
|
|
1529
|
+
model: key,
|
|
1530
|
+
sessions: 0,
|
|
1531
|
+
productive_sessions: 0,
|
|
1532
|
+
one_shot_sessions: 0,
|
|
1533
|
+
edit_turns: 0,
|
|
1534
|
+
retries: 0,
|
|
1535
|
+
total_tokens: 0,
|
|
1536
|
+
cost_usd: 0,
|
|
1537
|
+
edit_tokens: 0,
|
|
1538
|
+
edit_cost_usd: 0,
|
|
1539
|
+
usage_events: 0,
|
|
1540
|
+
rerouted_usage_events: 0,
|
|
1541
|
+
long_context_usage_events: 0,
|
|
1542
|
+
model_attribution: "selected",
|
|
1543
|
+
};
|
|
1544
|
+
agg.sessions += 1;
|
|
1545
|
+
if (row.productive) agg.productive_sessions += 1;
|
|
1546
|
+
if (row.first_pass ?? row.one_shot) agg.one_shot_sessions += 1;
|
|
1547
|
+
agg.total_tokens += finite(usage.total_tokens);
|
|
1548
|
+
agg.cost_usd += finite(usage.cost_usd);
|
|
1549
|
+
agg.usage_events += finite(usage.usage_events);
|
|
1550
|
+
agg.rerouted_usage_events += finite(usage.rerouted_usage_events);
|
|
1551
|
+
agg.long_context_usage_events += finite(usage.long_context_usage_events);
|
|
1552
|
+
if (usage.model_attribution === "effective") agg.model_attribution = "effective";
|
|
1553
|
+
// Numerator and denominator of tokens_per_edit / cost_per_edit must come
|
|
1554
|
+
// from the same population, so both are per model: an edit turn is a
|
|
1555
|
+
// turn, and turn_context names the model that owns it. Giving one model
|
|
1556
|
+
// the whole session's edit_turns but only its own slice of the tokens
|
|
1557
|
+
// understated the ratio and broke sum(edit_tokens) == summary.edit_tokens.
|
|
1558
|
+
agg.edit_turns += finite(usage.edit_turns);
|
|
1559
|
+
if (row.productive) {
|
|
1560
|
+
agg.edit_tokens += finite(usage.total_tokens);
|
|
1561
|
+
agg.edit_cost_usd += finite(usage.cost_usd);
|
|
1562
|
+
}
|
|
1563
|
+
// Retries are deliberately NOT split. A retry is detected from the user
|
|
1564
|
+
// prompt, and user_message / turn_context ordering is not stable in Codex
|
|
1565
|
+
// rollouts (1332 one way vs 1139 the other across 400 local files), so a
|
|
1566
|
+
// per-model retry would name the wrong turn about as often as the right
|
|
1567
|
+
// one. Attributing them to the busiest model keeps the column summing to
|
|
1568
|
+
// summary.retries without claiming a precision the log cannot support.
|
|
1569
|
+
if (key === primaryModel) agg.retries += finite(row.retry_turns);
|
|
1570
|
+
byModel.set(key, agg);
|
|
1317
1571
|
}
|
|
1318
|
-
byModel.set(key, agg);
|
|
1319
1572
|
if (row.source === "codex" && row.parent_session_hash) {
|
|
1320
1573
|
const name = row.agent_role || row.agent_nickname || "subagent";
|
|
1321
1574
|
const sub = subagents.get(name) || {
|
|
@@ -1359,10 +1612,6 @@ function summarizeSessions(sessions, { from = "", to = "", includeSessions = tru
|
|
|
1359
1612
|
tokens_per_edit: row.edit_turns ? row.edit_tokens / row.edit_turns : null,
|
|
1360
1613
|
cost_per_edit: row.edit_turns ? row.edit_cost_usd / row.edit_turns : null,
|
|
1361
1614
|
})).sort((a, b) => b.edit_turns - a.edit_turns || b.productive_sessions - a.productive_sessions || b.sessions - a.sessions);
|
|
1362
|
-
const totals = by_model.reduce((acc, row) => {
|
|
1363
|
-
for (const key of ["sessions", "productive_sessions", "one_shot_sessions", "edit_turns", "retries", "total_tokens", "cost_usd", "edit_tokens", "edit_cost_usd"]) acc[key] += finite(row[key]);
|
|
1364
|
-
return acc;
|
|
1365
|
-
}, { sessions: 0, productive_sessions: 0, one_shot_sessions: 0, edit_turns: 0, retries: 0, total_tokens: 0, cost_usd: 0, edit_tokens: 0, edit_cost_usd: 0 });
|
|
1366
1615
|
return {
|
|
1367
1616
|
available: filtered.length > 0,
|
|
1368
1617
|
// Local filesystem paths and raw session ids are required internally for
|
|
@@ -1449,6 +1698,7 @@ function toSessionBrowserRow(row) {
|
|
|
1449
1698
|
// browser can show where a session ran and compose a resume command.
|
|
1450
1699
|
project_ref: row.project_ref || null,
|
|
1451
1700
|
model: row.model,
|
|
1701
|
+
model_usage: modelUsageForAggregation(row),
|
|
1452
1702
|
started_at: row.started_at || null,
|
|
1453
1703
|
ended_at: row.ended_at || null,
|
|
1454
1704
|
duration_ms: finite(row.duration_ms),
|
|
@@ -1506,6 +1756,7 @@ function mergeSessionFragments(rows) {
|
|
|
1506
1756
|
byHash.set(key, {
|
|
1507
1757
|
...row,
|
|
1508
1758
|
tokens: row.tokens && typeof row.tokens === "object" ? { ...row.tokens } : emptyTotals(),
|
|
1759
|
+
model_usage: normalizeModelUsageRows(row.model_usage),
|
|
1509
1760
|
_repr_tokens: finite(row.total_tokens),
|
|
1510
1761
|
});
|
|
1511
1762
|
continue;
|
|
@@ -1515,6 +1766,10 @@ function mergeSessionFragments(rows) {
|
|
|
1515
1766
|
cur.retry_turns = finite(cur.retry_turns) + finite(row.retry_turns);
|
|
1516
1767
|
cur.subagent_calls = finite(cur.subagent_calls) + finite(row.subagent_calls);
|
|
1517
1768
|
addTotals(cur.tokens, row.tokens);
|
|
1769
|
+
cur.model_usage = normalizeModelUsageRows([
|
|
1770
|
+
...(cur.model_usage || []),
|
|
1771
|
+
...(row.model_usage || []),
|
|
1772
|
+
]);
|
|
1518
1773
|
cur.total_tokens = finite(cur.total_tokens) + finite(row.total_tokens);
|
|
1519
1774
|
cur.cost_usd = finite(cur.cost_usd) + finite(row.cost_usd);
|
|
1520
1775
|
cur.provider_cost_usd = finite(cur.provider_cost_usd) + finite(row.provider_cost_usd);
|
|
@@ -1543,8 +1798,9 @@ function mergeSessionFragments(rows) {
|
|
|
1543
1798
|
if (!cur.forked_from_id && row.forked_from_id) cur.forked_from_id = row.forked_from_id;
|
|
1544
1799
|
if (!cur.agent_nickname && row.agent_nickname) cur.agent_nickname = row.agent_nickname;
|
|
1545
1800
|
if (!cur.agent_role && row.agent_role) cur.agent_role = row.agent_role;
|
|
1546
|
-
//
|
|
1547
|
-
//
|
|
1801
|
+
// Legacy records (including Claude) have no per-model usage, so retain
|
|
1802
|
+
// their busiest-fragment model as a display label. Codex model_usage is
|
|
1803
|
+
// merged independently and overrides this representative during repricing.
|
|
1548
1804
|
if (finite(row.total_tokens) > finite(cur._repr_tokens)) {
|
|
1549
1805
|
cur._repr_tokens = finite(row.total_tokens);
|
|
1550
1806
|
if (row.model && row.model !== "unknown") cur.model = row.model;
|
|
@@ -1569,6 +1825,11 @@ function mergeSessionFragments(rows) {
|
|
|
1569
1825
|
row.duration_ms = finite(row.active_ms);
|
|
1570
1826
|
row.first_pass = finite(row.edit_turns) === 1 && finite(row.retry_turns) === 0;
|
|
1571
1827
|
row.one_shot = row.first_pass;
|
|
1828
|
+
// Only a complete per-model breakdown can reprice merged usage safely.
|
|
1829
|
+
// Claude fragments may use different models: their summed cost is exact,
|
|
1830
|
+
// while charging all tokens at the representative model changes that sum.
|
|
1831
|
+
if (row.model_usage.length > 0) repriceSessionRecord(row);
|
|
1832
|
+
else row.cost_per_edit = row.edit_turns > 0 ? finite(row.cost_usd) / row.edit_turns : null;
|
|
1572
1833
|
merged.push(row);
|
|
1573
1834
|
}
|
|
1574
1835
|
return merged;
|
|
@@ -1714,9 +1975,12 @@ function listSessionsForBrowser(sessions, { from = "", to = "", limit = 0 } = {}
|
|
|
1714
1975
|
}
|
|
1715
1976
|
|
|
1716
1977
|
function sessionsToCsv(rows) {
|
|
1717
|
-
const columns = ["session_hash", "source", "project_key", "model", "started_at", "ended_at", "duration_ms", "turns", "edit_turns", "retry_turns", "subagent_calls", "total_tokens", "cost_usd", "productive", "first_pass", "one_shot"];
|
|
1978
|
+
const columns = ["session_hash", "source", "project_key", "model", "model_usage_json", "started_at", "ended_at", "duration_ms", "turns", "edit_turns", "retry_turns", "subagent_calls", "total_tokens", "cost_usd", "productive", "first_pass", "one_shot"];
|
|
1718
1979
|
const escape = (value) => `"${String(value ?? "").replaceAll('"', '""')}"`;
|
|
1719
|
-
|
|
1980
|
+
const valueFor = (row, key) => key === "model_usage_json"
|
|
1981
|
+
? JSON.stringify(row.model_usage || [])
|
|
1982
|
+
: row[key];
|
|
1983
|
+
return [columns.join(","), ...(rows || []).map((row) => columns.map((key) => escape(valueFor(row, key))).join(","))].join("\n") + "\n";
|
|
1720
1984
|
}
|
|
1721
1985
|
|
|
1722
1986
|
module.exports = {
|