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.
Files changed (107) hide show
  1. package/dashboard/dist/assets/{AchievementBadge-DtB1MwFe.js → AchievementBadge-Bb2ljquW.js} +1 -1
  2. package/dashboard/dist/assets/{AchievementsPage--vddu0dC.js → AchievementsPage-ByUP2x8a.js} +2 -2
  3. package/dashboard/dist/assets/{AchievementsSection-CDl9hsbj.js → AchievementsSection-_L6sCLTw.js} +1 -1
  4. package/dashboard/dist/assets/{BadgeDetailModal-C7WsIRy9.js → BadgeDetailModal-B9n_-45B.js} +1 -1
  5. package/dashboard/dist/assets/{BotAnimated-CmXyzIhX.js → BotAnimated-lWmg-aZg.js} +1 -1
  6. package/dashboard/dist/assets/{Card-SUbC3VNS.js → Card-aSp259T0.js} +1 -1
  7. package/dashboard/dist/assets/{ClawdAnimated-BdEbs8Me.js → ClawdAnimated-Cy-XaTXW.js} +2 -2
  8. package/dashboard/dist/assets/{CommandPalette-D1RLLeHB.js → CommandPalette-CNwtKCUm.js} +1 -1
  9. package/dashboard/dist/assets/{CommunityStatsModal-5ESGJgZQ.js → CommunityStatsModal-DJSezaPV.js} +1 -1
  10. package/dashboard/dist/assets/{ConfirmModal-DsCd51b2.js → ConfirmModal-CVjmhZd6.js} +1 -1
  11. package/dashboard/dist/assets/{Controls-BVrLC_QD.js → Controls-BIj6yVxE.js} +1 -1
  12. package/dashboard/dist/assets/{DashboardPage-DtUOG6og.js → DashboardPage-BjYZLmqQ.js} +1 -1
  13. package/dashboard/dist/assets/{DevicePage-zYjj-m5V.js → DevicePage-ptsI2fZc.js} +1 -1
  14. package/dashboard/dist/assets/{DialogClose-D66yBqkd.js → DialogClose-D1pLoZ56.js} +1 -1
  15. package/dashboard/dist/assets/DialogDescription-KY7X94CG.js +1 -0
  16. package/dashboard/dist/assets/{DialogTitle-C4ia00lc.js → DialogTitle-DfSBUjWf.js} +1 -1
  17. package/dashboard/dist/assets/{FadeIn-DF2c8XaR.js → FadeIn-DBn4L_BV.js} +1 -1
  18. package/dashboard/dist/assets/{HeaderGithubStar-Cnr3aqcX.js → HeaderGithubStar-EJLBwEqp.js} +1 -1
  19. package/dashboard/dist/assets/{HoverTooltip-DBOpxlH7.js → HoverTooltip-DaSrZhYd.js} +1 -1
  20. package/dashboard/dist/assets/{IpCheckPage-CUY6KBaV.js → IpCheckPage-diQTVaYt.js} +1 -1
  21. package/dashboard/dist/assets/{LandingPage-DKL8hZEq.js → LandingPage-BqCxjwar.js} +2 -2
  22. package/dashboard/dist/assets/{LeaderboardAvatar-DRYeMNlq.js → LeaderboardAvatar-CQImCsPN.js} +1 -1
  23. package/dashboard/dist/assets/{LeaderboardPage-DpD9Az3k.js → LeaderboardPage-D2ZDUi_O.js} +2 -2
  24. package/dashboard/dist/assets/{LeaderboardProfileModal-Dfg0V13F.js → LeaderboardProfileModal-DHvADX1d.js} +3 -3
  25. package/dashboard/dist/assets/{LeaderboardProfilePage-DH-jcVDu.js → LeaderboardProfilePage-CJsedB28.js} +1 -1
  26. package/dashboard/dist/assets/LimitsPage-CAwff24b.js +6 -0
  27. package/dashboard/dist/assets/{LocalOnlyNotice-Bj_m0hw7.js → LocalOnlyNotice-Cmnvu9tI.js} +1 -1
  28. package/dashboard/dist/assets/{LoginCard-BVKvtx60.js → LoginCard-BQmKkD7X.js} +1 -1
  29. package/dashboard/dist/assets/{LoginModal-mUGiSIJN.js → LoginModal-2oPROePZ.js} +1 -1
  30. package/dashboard/dist/assets/{LoginPage-BORCuCUt.js → LoginPage-aqdUJoQK.js} +1 -1
  31. package/dashboard/dist/assets/{PetPage-D1JfEOVA.js → PetPage-Bn3p_o5r.js} +1 -1
  32. package/dashboard/dist/assets/{PopoverPopup-DK3et-HG.js → PopoverPopup-DXD_YdiB.js} +1 -1
  33. package/dashboard/dist/assets/{ProviderIcon-By8FAjxR.js → ProviderIcon-BZZ45-gY.js} +1 -1
  34. package/dashboard/dist/assets/{ResetPasswordPage-DPa81jqa.js → ResetPasswordPage-Bn5ITiHQ.js} +1 -1
  35. package/dashboard/dist/assets/{SegmentedControl-DLzS-2eZ.js → SegmentedControl-hGXboQZy.js} +1 -1
  36. package/dashboard/dist/assets/{Select-C05W6XEM.js → Select-BP8aVRZ1.js} +1 -1
  37. package/dashboard/dist/assets/{SelectItemText-CcfB3blP.js → SelectItemText-Odx47kND.js} +1 -1
  38. package/dashboard/dist/assets/{ServiceStatusPage-ByqLbHv1.js → ServiceStatusPage-DUueam0N.js} +1 -1
  39. package/dashboard/dist/assets/SessionsPage-BA9IFqQ1.js +2 -0
  40. package/dashboard/dist/assets/{SettingsPage-DAcndJZQ.js → SettingsPage-DLOHHB4f.js} +2 -2
  41. package/dashboard/dist/assets/{SkillsPage-BuuqQuNU.js → SkillsPage-Ds2vH_PG.js} +1 -1
  42. package/dashboard/dist/assets/{TokenGalaxy-DzccxkXW.js → TokenGalaxy-CWBR4jnB.js} +1 -1
  43. package/dashboard/dist/assets/{ToolbarRootContext-XUDMp5uy.js → ToolbarRootContext-Bmg56vYz.js} +1 -1
  44. package/dashboard/dist/assets/{TrendMonitor-B-yYabbz.js → TrendMonitor-BQzcllvg.js} +1 -1
  45. package/dashboard/dist/assets/{WidgetsPage-CwUDjEFo.js → WidgetsPage-Bu3tHDjO.js} +1 -1
  46. package/dashboard/dist/assets/{WrappedPage-Dr9ow3gH.js → WrappedPage-BrAMJhgy.js} +1 -1
  47. package/dashboard/dist/assets/{__vite-browser-external-DPOlUkcY.js → __vite-browser-external-4IGm4Djn.js} +12 -12
  48. package/dashboard/dist/assets/{agent-logos-Cqc_suYk.js → agent-logos-B8lT-HcI.js} +1 -1
  49. package/dashboard/dist/assets/{arrow-up-right-DSsR3Ntr.js → arrow-up-right-rpBCOTg2.js} +1 -1
  50. package/dashboard/dist/assets/{check-Cy35lAKh.js → check-DIKw7TYH.js} +1 -1
  51. package/dashboard/dist/assets/{composite-BDwOlhhX.js → composite-_NeXO0lv.js} +1 -1
  52. package/dashboard/dist/assets/{copy-4szu5cYh.js → copy-CsfAtL7P.js} +1 -1
  53. package/dashboard/dist/assets/{download-DdGW6Krz.js → download-DM1W9pIW.js} +1 -1
  54. package/dashboard/dist/assets/{external-link-DwSt83cq.js → external-link-CwbuMbeQ.js} +1 -1
  55. package/dashboard/dist/assets/{flame-DubukWqK.js → flame-DcYbLdhE.js} +1 -1
  56. package/dashboard/dist/assets/{format-tokens-B70EdQcN.js → format-tokens-CEvr3dNy.js} +1 -1
  57. package/dashboard/dist/assets/{icons-B86TLdAj.js → icons-DaGMVsJ5.js} +1 -1
  58. package/dashboard/dist/assets/{index-B_KMoBeK.js → index-BVVLPj3N.js} +1 -1
  59. package/dashboard/dist/assets/{index-DI-W-pun.js → index-ChZkJjpk.js} +1 -1
  60. package/dashboard/dist/assets/{index-DzEMcUwI.js → index-T7OjmEuX.js} +1 -1
  61. package/dashboard/dist/assets/{info-BzFpLbl6.js → info-CAjN3dX4.js} +1 -1
  62. package/dashboard/dist/assets/limits-providers-jn7Yln_V.js +1 -0
  63. package/dashboard/dist/assets/{link-2-BQp7-3T7.js → link-2-DaWH2Tyc.js} +1 -1
  64. package/dashboard/dist/assets/{loader-circle-lrDmbIg5.js → loader-circle-CnF8qPi1.js} +1 -1
  65. package/dashboard/dist/assets/{maximize-2-Cnd_Oth8.js → maximize-2-B-2Pnj6g.js} +1 -1
  66. package/dashboard/dist/assets/{modifiers.esm-CPTpZdHU.js → modifiers.esm-BoDOvUKl.js} +1 -1
  67. package/dashboard/dist/assets/{monitor-smartphone-C7UY1zRx.js → monitor-smartphone-DDgwxG_E.js} +1 -1
  68. package/dashboard/dist/assets/{plus-Bglp9N_7.js → plus-DIc1YUWB.js} +1 -1
  69. package/dashboard/dist/assets/{provider-display-CGsFySs6.js → provider-display-yeNsVCO_.js} +1 -1
  70. package/dashboard/dist/assets/{react-DKXriBSF.js → react-DZqS6YTX.js} +1 -1
  71. package/dashboard/dist/assets/{search-D_LLDRdU.js → search-D6tEppYJ.js} +1 -1
  72. package/dashboard/dist/assets/{skills-api-CJWjKIco.js → skills-api-vsA1rc1Z.js} +1 -1
  73. package/dashboard/dist/assets/{sortable.esm-zlY09tye.js → sortable.esm-DDCnS1HI.js} +1 -1
  74. package/dashboard/dist/assets/{store-8e16Ac2h.js → store-Crne4DqF.js} +1 -1
  75. package/dashboard/dist/assets/{terminal-CR228ekj.js → terminal-Ca7dkLMF.js} +1 -1
  76. package/dashboard/dist/assets/{trash-2-BjLWewOp.js → trash-2-CjEAS5ic.js} +1 -1
  77. package/dashboard/dist/assets/{use-community-stats-DhOJPaVg.js → use-community-stats-BHx2YxyK.js} +1 -1
  78. package/dashboard/dist/assets/{use-latest-request-guard-lcGhb-NC.js → use-latest-request-guard-BLGbXQdf.js} +1 -1
  79. package/dashboard/dist/assets/{use-limits-display-prefs-DJIImtLN.js → use-limits-display-prefs-DBkAp6ob.js} +1 -1
  80. package/dashboard/dist/assets/{use-ordered-list-BJZfZuoJ.js → use-ordered-list-BTfW5M9u.js} +1 -1
  81. package/dashboard/dist/assets/use-reduced-motion-D_kUnDs4.js +1 -0
  82. package/dashboard/dist/assets/{use-session-efficiency-pref-C-l4b1cD.js → use-session-efficiency-pref-DBmd-MqH.js} +1 -1
  83. package/dashboard/dist/assets/{use-transform-UjIt477N.js → use-transform-Bp5XrwaQ.js} +1 -1
  84. package/dashboard/dist/assets/{use-usage-limits-DqoTZrqT.js → use-usage-limits-BmqRcyhH.js} +1 -1
  85. package/dashboard/dist/assets/{useCurrency-CnM8aB4v.js → useCurrency-B0YsRDj-.js} +1 -1
  86. package/dashboard/dist/assets/{useTokenFormat-D4pAK2ld.js → useTokenFormat-PP3D0Ket.js} +1 -1
  87. package/dashboard/dist/assets/{zap-Du1IyEtj.js → zap-BVOm1aBH.js} +1 -1
  88. package/dashboard/dist/index.html +1 -1
  89. package/dashboard/dist/ip-check.html +1 -1
  90. package/dashboard/dist/leaderboard.html +1 -1
  91. package/dashboard/dist/share.html +1 -1
  92. package/package.json +1 -1
  93. package/src/lib/ark-agent-plan-limits.js +437 -0
  94. package/src/lib/ark-coding-plan-limits.js +104 -27
  95. package/src/lib/codex-model-attribution.js +128 -0
  96. package/src/lib/codex-rollout-parser.js +98 -4
  97. package/src/lib/pricing/curated-overrides.json +6 -6
  98. package/src/lib/pricing/index.js +38 -1
  99. package/src/lib/pricing/seed-snapshot.json +1 -1
  100. package/src/lib/rollout.js +50 -9
  101. package/src/lib/session-analytics.js +374 -110
  102. package/src/lib/usage-limits.js +19 -5
  103. package/dashboard/dist/assets/DialogDescription-BVlaoajd.js +0 -1
  104. package/dashboard/dist/assets/LimitsPage-CVCKPScf.js +0 -5
  105. package/dashboard/dist/assets/SessionsPage-CEDv1thj.js +0 -2
  106. package/dashboard/dist/assets/limits-providers-DxZOjiXQ.js +0 -1
  107. 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
- // v12 retains Codex parent/subagent metadata locally and derives exact child
61
- // token totals from observed child rollouts instead of spawn-call estimates.
62
- const SIDECAR_VERSION = 12;
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
- const providerCost = Number(record.provider_cost_usd);
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
- async function scanCodexDeliverySignals(filePath) {
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) editTurns += 1;
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
- const priorRecordHashes = new Set();
440
- let scannedFiles = 0;
441
- for (const currentFilePath of filePaths) {
442
- const currentRecordHashes = new Set();
443
- try {
444
- const input = fs.createReadStream(currentFilePath, { encoding: "utf8" });
445
- const lines = readline.createInterface({ input, crlfDelay: Infinity });
446
- for await (const line of lines) {
447
- if (filePaths.length > 1) {
448
- const recordHash = crypto.createHash("sha256").update(line).digest("base64url");
449
- if (priorRecordHashes.has(recordHash)) continue;
450
- currentRecordHashes.add(recordHash);
451
- }
452
- let obj;
453
- try { obj = JSON.parse(line); } catch { continue; }
454
- updateBounds(bounds, obj.timestamp);
455
- if (obj.type === "turn_context") {
456
- hasTurnContext = true;
457
- beginTurn(String(obj.payload?.turn_id || obj.timestamp || turns + 1));
458
- continue;
459
- }
460
- const prompt = extractCodexPrompt(obj);
461
- if (prompt) {
462
- const fingerprint = promptFingerprint(prompt);
463
- if (lastPromptFingerprint && fingerprint === lastPromptFingerprint) retryTurns += 1;
464
- lastPromptFingerprint = fingerprint;
465
- if (!hasTurnContext) beginTurn(String(obj.timestamp || turns + 1));
466
- continue;
467
- }
468
- if (obj.type !== "response_item") continue;
469
- const toolNames = extractCodexSignalTools(obj.payload);
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
- if (!scannedFiles) throw new Error("all grouped Codex signal files failed during read");
488
- closeTurn();
489
- return {
490
- bounds,
491
- turns,
492
- editTurns,
493
- retryTurns,
494
- subagentCalls,
495
- subagentTypes: Object.fromEntries([...subagentTypes.entries()].sort()),
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 [parsed, signals] = await Promise.all([
503
- parseCodexRolloutFile(filePaths, { seenTokenEvents: new Set() }),
504
- scanCodexDeliverySignals(filePaths),
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 model = parsedModel && parsedModel.toLowerCase() !== provider?.toLowerCase()
512
- ? parsedModel
513
- : "unknown";
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").split("\n").filter(Boolean).map((line) => JSON.parse(line));
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((row) => JSON.stringify(row)).join("\n") + (sessions.length ? "\n" : "");
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
- const key = row.model || "unknown";
1295
- const agg = byModel.get(key) || {
1296
- model: key,
1297
- sessions: 0,
1298
- productive_sessions: 0,
1299
- one_shot_sessions: 0,
1300
- edit_turns: 0,
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
- agg.edit_tokens += finite(row.total_tokens);
1316
- agg.edit_cost_usd += finite(row.cost_usd);
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
- // Representative model/project comes from the busiest fragment (most
1547
- // tokens) so a tiny sidechain cannot overwrite the real coding model.
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
- return [columns.join(","), ...(rows || []).map((row) => columns.map((key) => escape(row[key])).join(","))].join("\n") + "\n";
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 = {