dsh-token-total 1.0.0 → 1.0.2

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 (37) hide show
  1. package/README.md +5 -4
  2. package/lib/client.js +566 -159
  3. package/lib/index.js +355 -54
  4. package/lib/types/client/ContextUsageEntry.d.ts +22 -0
  5. package/lib/types/client/context-meter-core.d.ts +26 -0
  6. package/lib/types/client/context-takeover.d.ts +4 -0
  7. package/lib/types/client/locales.d.ts +1 -1
  8. package/lib/types/client/session-usage-core.d.ts +9 -0
  9. package/lib/types/client/use-session-usage.d.ts +2 -18
  10. package/lib/types/collector.d.ts +27 -12
  11. package/lib/types/context-types.d.ts +23 -2
  12. package/lib/types/model-names.d.ts +6 -0
  13. package/lib/types/query.d.ts +8 -0
  14. package/lib/types/session-usage.d.ts +8 -9
  15. package/lib/types/store.d.ts +32 -0
  16. package/lib/types/wire.d.ts +30 -1
  17. package/package.json +1 -1
  18. package/src/client/ContextUsageEntry.module.css +20 -0
  19. package/src/client/ContextUsageEntry.tsx +89 -0
  20. package/src/client/StatsLineEnhanced.tsx +17 -35
  21. package/src/client/TokenTotalPanel.module.css +72 -5
  22. package/src/client/TokenTotalPanel.tsx +204 -56
  23. package/src/client/context-meter-core.ts +54 -0
  24. package/src/client/context-takeover.ts +35 -0
  25. package/src/client/index.tsx +21 -3
  26. package/src/client/locales.ts +4 -0
  27. package/src/client/session-usage-core.ts +54 -0
  28. package/src/client/use-session-usage.ts +45 -98
  29. package/src/collector.ts +138 -67
  30. package/src/context-types.ts +17 -2
  31. package/src/index.ts +29 -0
  32. package/src/model-names.ts +34 -0
  33. package/src/query.ts +84 -13
  34. package/src/routes.ts +20 -2
  35. package/src/session-usage.ts +50 -38
  36. package/src/store.ts +136 -0
  37. package/src/wire.ts +24 -1
package/lib/index.js CHANGED
@@ -89,6 +89,7 @@ function aggregateSamples(samples, filter, granularity = "day") {
89
89
  outputTokens: 0,
90
90
  cacheTokens: 0,
91
91
  requests: 0,
92
+ failures: 0,
92
93
  turns: 0,
93
94
  cacheHit: 0,
94
95
  cacheMiss: 0,
@@ -104,11 +105,14 @@ function aggregateSamples(samples, filter, granularity = "day") {
104
105
  const providerTotals = /* @__PURE__ */ new Map();
105
106
  const modelCache = /* @__PURE__ */ new Map();
106
107
  const providerCache = /* @__PURE__ */ new Map();
108
+ const modelProviderTotals = /* @__PURE__ */ new Map();
109
+ const providerModelTotals = /* @__PURE__ */ new Map();
107
110
  const active = /* @__PURE__ */ new Set();
108
111
  const bucketByModel = /* @__PURE__ */ new Map();
109
112
  const bucketByProvider = /* @__PURE__ */ new Map();
110
113
  const bucketTotals = /* @__PURE__ */ new Map();
111
114
  const bucketRequests = /* @__PURE__ */ new Map();
115
+ const bucketFailures = /* @__PURE__ */ new Map();
112
116
  const bucketTurns = /* @__PURE__ */ new Map();
113
117
  const bucketCacheHit = /* @__PURE__ */ new Map();
114
118
  const bucketCacheMiss = /* @__PURE__ */ new Map();
@@ -121,11 +125,16 @@ function aggregateSamples(samples, filter, granularity = "day") {
121
125
  continue;
122
126
  }
123
127
  const total = sample.inputTokens + sample.outputTokens + sample.cacheReadTokens + sample.cacheWriteTokens;
128
+ const hasTokenDelta = sample.inputTokens !== 0 || sample.outputTokens !== 0 || sample.cacheReadTokens !== 0 || sample.cacheWriteTokens !== 0;
129
+ if (sample.failed) {
130
+ out.failures++;
131
+ bucketFailures.set(bucket, (bucketFailures.get(bucket) ?? 0) + 1);
132
+ }
124
133
  if (sample.request) {
125
134
  out.requests++;
126
135
  bucketRequests.set(bucket, (bucketRequests.get(bucket) ?? 0) + 1);
127
- if (total <= 0) continue;
128
136
  }
137
+ if (!hasTokenDelta) continue;
129
138
  out.tokens += total;
130
139
  out.inputTokens += sample.inputTokens;
131
140
  out.outputTokens += sample.outputTokens;
@@ -141,6 +150,18 @@ function aggregateSamples(samples, filter, granularity = "day") {
141
150
  modelProviders.set(model, sources);
142
151
  modelTotals.set(model, (modelTotals.get(model) ?? 0) + total);
143
152
  providerTotals.set(provider, (providerTotals.get(provider) ?? 0) + total);
153
+ let modelSplitMap = modelProviderTotals.get(model);
154
+ if (!modelSplitMap) {
155
+ modelSplitMap = /* @__PURE__ */ new Map();
156
+ modelProviderTotals.set(model, modelSplitMap);
157
+ }
158
+ modelSplitMap.set(provider, (modelSplitMap.get(provider) ?? 0) + total);
159
+ let providerSplitMap = providerModelTotals.get(provider);
160
+ if (!providerSplitMap) {
161
+ providerSplitMap = /* @__PURE__ */ new Map();
162
+ providerModelTotals.set(provider, providerSplitMap);
163
+ }
164
+ providerSplitMap.set(model, (providerSplitMap.get(model) ?? 0) + total);
144
165
  const mc = modelCache.get(model) ?? {
145
166
  hit: 0,
146
167
  miss: 0
@@ -182,13 +203,14 @@ function aggregateSamples(samples, filter, granularity = "day") {
182
203
  byModel: byModelObj,
183
204
  byProvider: byProviderObj,
184
205
  requests: bucketRequests.get(bucket) ?? 0,
206
+ failures: bucketFailures.get(bucket) ?? 0,
185
207
  turns: bucketTurns.get(bucket) ?? 0,
186
208
  cacheHit: bucketCacheHit.get(bucket) ?? 0,
187
209
  cacheMiss: bucketCacheMiss.get(bucket) ?? 0
188
210
  });
189
211
  }
190
- out.models = modelsSorted(modelTotals, modelCache, modelProviders);
191
- out.providers = providersSorted(providerTotals, providerCache);
212
+ out.models = modelsSorted(modelTotals, modelCache, modelProviders, modelProviderTotals);
213
+ out.providers = providersSorted(providerTotals, providerCache, providerModelTotals);
192
214
  out.topModel = out.models[0]?.model ?? "";
193
215
  out.topProvider = out.providers[0]?.provider ?? "";
194
216
  if (out.tokens > 0) {
@@ -197,7 +219,7 @@ function aggregateSamples(samples, filter, granularity = "day") {
197
219
  }
198
220
  return out;
199
221
  }
200
- function modelsSorted(totals, cache, sources) {
222
+ function modelsSorted(totals, cache, sources, splits) {
201
223
  const out = [];
202
224
  for (const [model, tokens] of totals) {
203
225
  const c = cache.get(model);
@@ -206,6 +228,7 @@ function modelsSorted(totals, cache, sources) {
206
228
  model,
207
229
  provider: providers.length === 1 ? providers[0] : "",
208
230
  providers,
231
+ providerTokens: providerSplit(splits.get(model)),
209
232
  tokens,
210
233
  percent: 0,
211
234
  cacheHit: c?.hit ?? 0,
@@ -215,7 +238,7 @@ function modelsSorted(totals, cache, sources) {
215
238
  out.sort((a, b) => b.tokens - a.tokens || a.model.localeCompare(b.model));
216
239
  return out;
217
240
  }
218
- function providersSorted(totals, cache) {
241
+ function providersSorted(totals, cache, splits) {
219
242
  const out = [];
220
243
  for (const [provider, tokens] of totals) {
221
244
  const c = cache.get(provider);
@@ -224,12 +247,30 @@ function providersSorted(totals, cache) {
224
247
  tokens,
225
248
  percent: 0,
226
249
  cacheHit: c?.hit ?? 0,
227
- cacheMiss: c?.miss ?? 0
250
+ cacheMiss: c?.miss ?? 0,
251
+ modelTokens: modelSplit(splits.get(provider))
228
252
  });
229
253
  }
230
254
  out.sort((a, b) => b.tokens - a.tokens);
231
255
  return out;
232
256
  }
257
+ /** One model's per-provider split, largest first (ties by id for a stable
258
+ * order across refreshes). Empty when the response carries no cross data. */
259
+ function providerSplit(split) {
260
+ if (split === void 0) return [];
261
+ return [...split].map(([provider, tokens]) => ({
262
+ provider,
263
+ tokens
264
+ })).sort((a, b) => b.tokens - a.tokens || a.provider.localeCompare(b.provider));
265
+ }
266
+ /** One provider's per-model split, largest first (ties by name). */
267
+ function modelSplit(split) {
268
+ if (split === void 0) return [];
269
+ return [...split].map(([model, tokens]) => ({
270
+ model,
271
+ tokens
272
+ })).sort((a, b) => b.tokens - a.tokens || a.model.localeCompare(b.model));
273
+ }
233
274
  //#endregion
234
275
  //#region src/store.ts
235
276
  /**
@@ -260,6 +301,7 @@ const usageDayRowSchema = z.object({
260
301
  cacheReadTokens: z.number(),
261
302
  cacheWriteTokens: z.number(),
262
303
  requests: z.number(),
304
+ failures: z.number().default(0),
263
305
  turns: z.number(),
264
306
  lastSeen: z.number()
265
307
  });
@@ -272,6 +314,7 @@ const usageHourRowSchema = z.object({
272
314
  cacheReadTokens: z.number(),
273
315
  cacheWriteTokens: z.number(),
274
316
  requests: z.number(),
317
+ failures: z.number().default(0),
275
318
  turns: z.number(),
276
319
  lastSeen: z.number()
277
320
  });
@@ -297,13 +340,18 @@ const usageHistoryDomain = defineDomain({
297
340
  liveFirstSeq: z.record(z.string(), z.number()).optional(),
298
341
  hoursBackfilledSessions: z.array(z.string()).optional(),
299
342
  hoursLiveFirstSeq: z.record(z.string(), z.number()).optional(),
300
- hoursReady: z.boolean().optional()
343
+ hoursReady: z.boolean().optional(),
344
+ failuresBackfilledSessions: z.array(z.string()).optional(),
345
+ failuresLiveFirstSeq: z.record(z.string(), z.number()).optional(),
346
+ failuresReady: z.boolean().optional()
301
347
  }),
302
348
  initial: {
303
349
  backfilledSessions: [],
304
350
  liveFirstSeq: {},
305
351
  hoursBackfilledSessions: [],
306
- hoursLiveFirstSeq: {}
352
+ hoursLiveFirstSeq: {},
353
+ failuresBackfilledSessions: [],
354
+ failuresLiveFirstSeq: {}
307
355
  }
308
356
  },
309
357
  tables: {
@@ -505,6 +553,78 @@ var UsageStore = class {
505
553
  this.markChain = write.then(() => void 0, () => void 0);
506
554
  return write;
507
555
  }
556
+ /** Session ids already replayed into the FAILURE counter (third cursor
557
+ * pair, same reason as the hour pair: the day and hour cursors were
558
+ * already full when the counter arrived). */
559
+ async failuresSeenSessions() {
560
+ await this.ready;
561
+ return new Set(this.cursor()?.failuresBackfilledSessions ?? []);
562
+ }
563
+ /** Per-session seq of the first LIVE-observed event, failure-counter share. */
564
+ async failuresLiveSequences() {
565
+ await this.ready;
566
+ return new Map(Object.entries(this.cursor()?.failuresLiveFirstSeq ?? {}));
567
+ }
568
+ /** Persist failure-pass replay progress (merges; serialized on the same
569
+ * chain as every other cursor write). */
570
+ async markFailuresSeenSessions(ids) {
571
+ const write = this.markChain.then(async () => {
572
+ await this.ready;
573
+ const value = this.requireCursor();
574
+ const seen = new Set(value.failuresBackfilledSessions ?? []);
575
+ for (const id of ids) seen.add(id);
576
+ await this.domainHandle?.global?.set({
577
+ ...value,
578
+ backfilledSessions: value.backfilledSessions ?? [],
579
+ failuresBackfilledSessions: [...seen]
580
+ });
581
+ });
582
+ this.markChain = write.then(() => void 0, () => void 0);
583
+ return write;
584
+ }
585
+ /** Record the failure-pass live boundary per session (earliest wins). */
586
+ async markFailuresLiveSequences(entries) {
587
+ const write = this.markChain.then(async () => {
588
+ await this.ready;
589
+ const value = this.requireCursor();
590
+ const merged = { ...value.failuresLiveFirstSeq ?? {} };
591
+ let changed = false;
592
+ for (const [id, seq] of entries) {
593
+ const prev = merged[id];
594
+ if (prev === void 0 || seq < prev) {
595
+ merged[id] = seq;
596
+ changed = true;
597
+ }
598
+ }
599
+ if (!changed) return;
600
+ await this.domainHandle?.global?.set({
601
+ ...value,
602
+ backfilledSessions: value.backfilledSessions ?? [],
603
+ failuresLiveFirstSeq: merged
604
+ });
605
+ });
606
+ this.markChain = write.then(() => void 0, () => void 0);
607
+ return write;
608
+ }
609
+ /** Whether the failure counter covers every replayable session. */
610
+ async failuresReady() {
611
+ await this.ready;
612
+ return this.cursor()?.failuresReady === true;
613
+ }
614
+ /** Mark the failure history complete; the upgrade pass then never reruns. */
615
+ async markFailuresReady() {
616
+ const write = this.markChain.then(async () => {
617
+ await this.ready;
618
+ const value = this.requireCursor();
619
+ await this.domainHandle?.global?.set({
620
+ ...value,
621
+ backfilledSessions: value.backfilledSessions ?? [],
622
+ failuresReady: true
623
+ });
624
+ });
625
+ this.markChain = write.then(() => void 0, () => void 0);
626
+ return write;
627
+ }
508
628
  /** Drop every row and the whole cursor, re-bounding each named session at
509
629
  * its WIPE-TIME WATERMARK (the store-rebuild escape hatch for corrupted
510
630
  * history or attribution-logic upgrades).
@@ -532,7 +652,10 @@ var UsageStore = class {
532
652
  liveFirstSeq,
533
653
  hoursBackfilledSessions: [],
534
654
  hoursLiveFirstSeq: { ...liveFirstSeq },
535
- hoursReady: false
655
+ hoursReady: false,
656
+ failuresBackfilledSessions: [],
657
+ failuresLiveFirstSeq: { ...liveFirstSeq },
658
+ failuresReady: false
536
659
  });
537
660
  for (const key of [...table.keys()]) await table.delete(key);
538
661
  for (const key of [...hourTable.keys()]) await hourTable.delete(key);
@@ -589,6 +712,22 @@ var UsageStore = class {
589
712
  const target = this.targetsOf(sample);
590
713
  await this.recordInto(this.requireHourTable(), hourRowKey(hour, target.provider, target.model), emptyHourRow(hour, target.provider, target.model), sample);
591
714
  }
715
+ /** Fold ONLY the failure count of a sample into the day and hour rows. The
716
+ * failure upgrade pass uses this: its replay walks logs the token passes
717
+ * already consumed, so the tokens and the request marker must not be added
718
+ * a second time — only the failed-call counter may move. */
719
+ async recordFailure(sample) {
720
+ await this.record({
721
+ day: sample.day,
722
+ hour: sample.hour,
723
+ model: sample.model,
724
+ inputTokens: 0,
725
+ outputTokens: 0,
726
+ cacheReadTokens: 0,
727
+ cacheWriteTokens: 0,
728
+ failed: true
729
+ });
730
+ }
592
731
  /** The row identity a sample lands on: turn markers use the synthetic
593
732
  * (turns) model, unlabelled calls the (unknown) one. */
594
733
  targetsOf(sample) {
@@ -666,6 +805,7 @@ function emptyCounters(provider, model) {
666
805
  cacheReadTokens: 0,
667
806
  cacheWriteTokens: 0,
668
807
  requests: 0,
808
+ failures: 0,
669
809
  turns: 0,
670
810
  lastSeen: Date.now()
671
811
  };
@@ -686,6 +826,7 @@ function accumulate(base, sample) {
686
826
  return {
687
827
  ...base,
688
828
  requests: base.requests + (sample.request ? 1 : 0),
829
+ failures: (base.failures ?? 0) + (sample.failed ? 1 : 0),
689
830
  inputTokens: base.inputTokens + sample.inputTokens,
690
831
  outputTokens: base.outputTokens + sample.outputTokens,
691
832
  cacheReadTokens: base.cacheReadTokens + sample.cacheReadTokens,
@@ -725,6 +866,10 @@ var UsageFold = class {
725
866
  cacheWriteTokens: 0,
726
867
  turn: true
727
868
  };
869
+ if (ev.type === "llm/retry-started") {
870
+ const key = this.keyOf(ev);
871
+ if (key !== null) this.seen.delete(key);
872
+ }
728
873
  if (ev.type === "step/start" || ev.type === "llm/retry-started") return {
729
874
  day: dayKey(ev.time),
730
875
  hour: hourKey(ev.time),
@@ -739,9 +884,18 @@ var UsageFold = class {
739
884
  const usage = data.chunk?.type === "usage" ? data.chunk.usage : void 0;
740
885
  return usage ? this.replaceSample(ev, usage) : null;
741
886
  }
742
- if (ev.type === "assistant/message") {
887
+ if (ev.type === "assistant/message" || ev.type === "assistant/attempt") {
743
888
  const data = ev.data;
744
- return data.usage ? this.replaceSample(ev, data.usage) : null;
889
+ let usage = ev.type === "assistant/message" ? data.usage : void 0;
890
+ if (usage === void 0 && Array.isArray(data.stream)) for (let i = data.stream.length - 1; i >= 0; i--) {
891
+ const record = data.stream[i];
892
+ if (record?.type === "chunk" && record.chunk?.type === "usage") {
893
+ usage = record.chunk.usage;
894
+ break;
895
+ }
896
+ }
897
+ if (ev.type === "assistant/attempt") return this.attemptSample(ev, usage);
898
+ return usage ? this.replaceSample(ev, usage) : null;
745
899
  }
746
900
  if (ev.type === "compaction/summary") return this.compactionSample(ev);
747
901
  return null;
@@ -749,21 +903,64 @@ var UsageFold = class {
749
903
  replaceSample(ev, usage) {
750
904
  const usage2 = usage;
751
905
  if (!usage2) return null;
752
- if ((usage2.inputTokens ?? 0) + (usage2.outputTokens ?? 0) + (usage2.cacheReadTokens ?? 0) + (usage2.cacheWriteTokens ?? 0) <= 0) return null;
753
906
  const key = this.keyOf(ev);
754
907
  const sample = {
755
908
  day: dayKey(ev.time),
756
909
  hour: hourKey(ev.time),
757
- inputTokens: usage2.inputTokens,
758
- outputTokens: usage2.outputTokens,
910
+ inputTokens: usage2.inputTokens ?? 0,
911
+ outputTokens: usage2.outputTokens ?? 0,
759
912
  cacheReadTokens: usage2.cacheReadTokens ?? 0,
760
913
  cacheWriteTokens: usage2.cacheWriteTokens ?? 0
761
914
  };
915
+ const prev = key === null ? void 0 : this.seen.get(key);
916
+ if (!prev && sample.inputTokens + sample.outputTokens + sample.cacheReadTokens + sample.cacheWriteTokens <= 0) return null;
762
917
  if (key === null) return sample;
763
- const prev = this.seen.get(key);
764
918
  this.seen.set(key, sample);
765
- if (prev) return null;
766
- return { ...sample };
919
+ if (prev) {
920
+ const correction = {
921
+ day: prev.day,
922
+ hour: prev.hour,
923
+ model: prev.model,
924
+ inputTokens: sample.inputTokens - prev.inputTokens,
925
+ outputTokens: sample.outputTokens - prev.outputTokens,
926
+ cacheReadTokens: sample.cacheReadTokens - prev.cacheReadTokens,
927
+ cacheWriteTokens: sample.cacheWriteTokens - prev.cacheWriteTokens
928
+ };
929
+ sample.day = prev.day;
930
+ sample.hour = prev.hour;
931
+ sample.model = prev.model;
932
+ if (correction.inputTokens === 0 && correction.outputTokens === 0 && correction.cacheReadTokens === 0 && correction.cacheWriteTokens === 0) return null;
933
+ return correction;
934
+ }
935
+ const emitted = { ...sample };
936
+ this.seen.set(key, emitted);
937
+ return emitted;
938
+ }
939
+ /**
940
+ * Fold one `assistant/attempt` — a provider call that failed. Tokens follow
941
+ * the same last-report-wins rule as a committed message (the attempt's
942
+ * stream is the call's final word, so a usage chunk seen mid-stream is
943
+ * replaced or corrected, never added twice), but the call must ALWAYS be
944
+ * counted as one failure: `replaceSample` drops an all-zero first report,
945
+ * which is correct for the token buckets and would silently lose the
946
+ * failure. A failed call that reported no usage therefore contributes a
947
+ * zero-token failure marker instead of a null.
948
+ */
949
+ attemptSample(ev, usage) {
950
+ const sample = this.replaceSample(ev, usage);
951
+ if (sample) {
952
+ sample.failed = true;
953
+ return sample;
954
+ }
955
+ return {
956
+ day: dayKey(ev.time),
957
+ hour: hourKey(ev.time),
958
+ inputTokens: 0,
959
+ outputTokens: 0,
960
+ cacheReadTokens: 0,
961
+ cacheWriteTokens: 0,
962
+ failed: true
963
+ };
767
964
  }
768
965
  /**
769
966
  * Fold one `compaction/summary` — the one place a provider call's usage
@@ -906,6 +1103,9 @@ var UsageCollector = class UsageCollector {
906
1103
  this.store.markHoursLiveSequences(batch).catch(() => {
907
1104
  this.status.recordFailures++;
908
1105
  });
1106
+ this.store.markFailuresLiveSequences(batch).catch(() => {
1107
+ this.status.recordFailures++;
1108
+ });
909
1109
  }
910
1110
  });
911
1111
  }
@@ -1029,9 +1229,16 @@ var UsageCollector = class UsageCollector {
1029
1229
  async backfillHours(persistence, sessions, signal) {
1030
1230
  await this.scan(persistence, sessions, signal, "hours");
1031
1231
  }
1032
- /** Shared replay engine behind {@link backfill} and {@link backfillHours}.
1033
- * `target` selects the cursor pair, the write function, and the cursor
1034
- * the completed sessions land in. */
1232
+ /** The failure upgrade pass: replay every session the FAILURE cursors have
1233
+ * not seen, counting only failed calls. Neither the day nor the hour
1234
+ * cursor covers this counter (both were full when it arrived), and the
1235
+ * replay must not re-add tokens or requests. */
1236
+ async backfillFailures(persistence, sessions, signal) {
1237
+ await this.scan(persistence, sessions, signal, "failures");
1238
+ }
1239
+ /** Shared replay engine behind {@link backfill}, {@link backfillHours} and
1240
+ * {@link backfillFailures}. `target` selects the cursor pair, the write
1241
+ * function, and the cursor the completed sessions land in. */
1035
1242
  async scan(persistence, sessions, signal, target) {
1036
1243
  if (this.status.running) return;
1037
1244
  if (signal?.aborted) return;
@@ -1043,16 +1250,16 @@ var UsageCollector = class UsageCollector {
1043
1250
  if (snap !== null && typeof snap === "object" && "header" in snap && snap.header !== null && typeof snap.header === "object" && typeof snap.header.id === "string") return snap.header;
1044
1251
  return item;
1045
1252
  }).filter((h) => typeof h?.id === "string" && h.id !== "");
1046
- const seen = target === "hours" ? await this.store.hoursSeenSessions() : await this.store.seenSessions();
1253
+ const seen = target === "hours" ? await this.store.hoursSeenSessions() : target === "failures" ? await this.store.failuresSeenSessions() : await this.store.seenSessions();
1047
1254
  const dayCursorWasEmpty = seen.size === 0;
1048
- const liveSeq = target === "hours" ? await this.store.hoursLiveSequences() : await this.store.liveSequences();
1255
+ const liveSeq = target === "hours" ? await this.store.hoursLiveSequences() : target === "failures" ? await this.store.failuresLiveSequences() : await this.store.liveSequences();
1049
1256
  const targets = headers.filter((h) => !seen.has(h.id));
1050
1257
  this.status.total = targets.length;
1051
1258
  this.status.done = 0;
1052
1259
  const concurrency = this.options.backfillConcurrency ?? 4;
1053
1260
  let next = 0;
1054
1261
  const completed = [];
1055
- const writeSample = target === "hours" ? (sample) => this.store.recordHours(sample) : (sample) => this.store.record(sample);
1262
+ const writeSample = target === "hours" ? (sample) => this.store.recordHours(sample) : target === "failures" ? (sample) => this.store.recordFailure(sample) : (sample) => this.store.record(sample);
1056
1263
  const flushCompleted = async () => {
1057
1264
  if (completed.length === 0) return;
1058
1265
  const batch = completed.splice(0, completed.length);
@@ -1060,8 +1267,13 @@ var UsageCollector = class UsageCollector {
1060
1267
  await this.store.markHoursSeenSessions(batch);
1061
1268
  return;
1062
1269
  }
1270
+ if (target === "failures") {
1271
+ await this.store.markFailuresSeenSessions(batch);
1272
+ return;
1273
+ }
1063
1274
  await this.store.markSeenSessions(batch);
1064
1275
  await this.store.markHoursSeenSessions(batch);
1276
+ await this.store.markFailuresSeenSessions(batch);
1065
1277
  };
1066
1278
  const worker = async () => {
1067
1279
  for (;;) {
@@ -1142,7 +1354,9 @@ var UsageCollector = class UsageCollector {
1142
1354
  };
1143
1355
  await Promise.all(Array.from({ length: Math.min(concurrency, Math.max(1, targets.length)) }, worker));
1144
1356
  await flushCompleted();
1145
- if ((target === "hours" || dayCursorWasEmpty) && !signal?.aborted && this.status.error === void 0) await this.store.markHoursReady();
1357
+ const clean = !signal?.aborted && this.status.error === void 0;
1358
+ if ((target === "hours" || dayCursorWasEmpty) && clean) await this.store.markHoursReady();
1359
+ if ((target === "failures" || dayCursorWasEmpty) && clean) await this.store.markFailuresReady();
1146
1360
  } finally {
1147
1361
  this.status.running = false;
1148
1362
  }
@@ -1167,6 +1381,34 @@ async function lookup(read, timeoutMs) {
1167
1381
  clearTimeout(timer);
1168
1382
  }
1169
1383
  }
1384
+ /** Resolve provider route ids to the labels the model selector shows. Both
1385
+ * catalogs are presentational and synchronous on the host runtime, so this is
1386
+ * a plain in-process read rather than a per-request lookup: a missing service,
1387
+ * a throwing adapter, or an unexpected promise all resolve to an empty map and
1388
+ * every surface falls back to the raw historical route id. */
1389
+ function resolveProviderNames(llm) {
1390
+ const names = Object.create(null);
1391
+ if (!llm) return names;
1392
+ try {
1393
+ for (const provider of catalog(llm.listProviders?.())) {
1394
+ const name = provider.name?.trim();
1395
+ if (provider.id && name) names[provider.id] = name;
1396
+ }
1397
+ } catch {}
1398
+ try {
1399
+ for (const entry of catalog(llm.listConfigurableProviders?.())) {
1400
+ const name = entry.displayName?.trim();
1401
+ if (entry.provider && name && names[entry.provider] === void 0) names[entry.provider] = name;
1402
+ }
1403
+ } catch {}
1404
+ return names;
1405
+ }
1406
+ /** Both catalogs are declared synchronous, but a host that publishes them over
1407
+ * a remote face would hand back a promise; treat anything that is not an array
1408
+ * as "no metadata" instead of throwing mid-query. */
1409
+ function catalog(value) {
1410
+ return Array.isArray(value) ? value : [];
1411
+ }
1170
1412
  /** Resolve each distinct route once per range query. Unknown/deleted providers
1171
1413
  * fall back to their bare historical model names in aggregateSamples(). */
1172
1414
  async function resolveModelNames(modelRefs, llm) {
@@ -1451,7 +1693,7 @@ function granularityOf(range) {
1451
1693
  async function aggregateRange(store, from, to, granularity = "day", llm) {
1452
1694
  const rows = granularity === "hour" ? await store.rangeHourRows(from, to) : await store.rangeRows(from, to);
1453
1695
  const names = await resolveModelNames(rows.filter((row) => row.inputTokens + row.outputTokens + row.cacheReadTokens + row.cacheWriteTokens > 0).map((row) => row.model), llm);
1454
- return aggregateSamples((function* () {
1696
+ const stats = aggregateSamples((function* () {
1455
1697
  for (const row of rows) {
1456
1698
  const hour = "hour" in row ? row.hour : void 0;
1457
1699
  const day = hour === void 0 ? row.day : hour.slice(0, 10);
@@ -1475,6 +1717,16 @@ async function aggregateRange(store, from, to, granularity = "day", llm) {
1475
1717
  cacheWriteTokens: 0,
1476
1718
  request: true
1477
1719
  };
1720
+ for (let i = 0; i < (row.failures ?? 0); i++) yield {
1721
+ day,
1722
+ hour,
1723
+ model: row.model,
1724
+ inputTokens: 0,
1725
+ outputTokens: 0,
1726
+ cacheReadTokens: 0,
1727
+ cacheWriteTokens: 0,
1728
+ failed: true
1729
+ };
1478
1730
  for (let i = 0; i < row.turns; i++) yield {
1479
1731
  day,
1480
1732
  hour,
@@ -1489,6 +1741,14 @@ async function aggregateRange(store, from, to, granularity = "day", llm) {
1489
1741
  from,
1490
1742
  to
1491
1743
  }, granularity);
1744
+ const resolved = resolveProviderNames(llm);
1745
+ const providerNames = Object.create(null);
1746
+ for (const provider of stats.providers) {
1747
+ const label = resolved[provider.provider];
1748
+ if (label !== void 0) providerNames[provider.provider] = label;
1749
+ }
1750
+ if (Object.keys(providerNames).length > 0) stats.providerNames = providerNames;
1751
+ return stats;
1492
1752
  }
1493
1753
  /** Build the single fenced prefix route serving the /token-total/api JSON RPC.
1494
1754
  * The host mounts it via `ctx.webServer.register` (which takes ONE WebRoute,
@@ -1591,19 +1851,21 @@ function headerOf(item) {
1591
1851
  const row = item;
1592
1852
  return typeof row?.id === "string" && row.id !== "" ? row : null;
1593
1853
  }
1854
+ function inheritedCount(cut) {
1855
+ return typeof cut === "number" && Number.isSafeInteger(cut) && cut >= 0 ? cut : 0;
1856
+ }
1594
1857
  /** Read one stored session's events through whichever persistence seam the
1595
1858
  * host line exposes (alpha open/read/close wins; rc.1 inspect is the tail). */
1596
1859
  async function readEvents(persistence, id, signal) {
1597
1860
  if (typeof persistence.open === "function") {
1598
1861
  const handle = await persistence.open(id, "read", signal === void 0 ? void 0 : { signal });
1599
1862
  try {
1600
- const cut = handle.inheritedEventCount;
1601
- const inheritedCut = typeof cut === "number" && Number.isSafeInteger(cut) && cut >= 0 ? cut : 0;
1863
+ const inheritedCut = inheritedCount(handle.inheritedEventCount);
1602
1864
  const collected = [];
1603
1865
  let offset = 0;
1604
1866
  const PAGE = 500;
1605
1867
  for (;;) {
1606
- if (signal?.aborted) break;
1868
+ signal?.throwIfAborted();
1607
1869
  const slice = await handle.read(offset, PAGE, signal === void 0 ? void 0 : { signal });
1608
1870
  const page = slice && slice.events ? slice.events : [];
1609
1871
  if (page.length === 0) break;
@@ -1622,45 +1884,59 @@ async function readEvents(persistence, id, signal) {
1622
1884
  const inspection = await persistence.inspect(id, signal);
1623
1885
  return {
1624
1886
  events: inspection.events,
1625
- inheritedCut: inspection.inheritedEventCount ?? 0
1887
+ inheritedCut: inheritedCount(inspection.inheritedEventCount)
1626
1888
  };
1627
1889
  }
1628
1890
  throw new Error("no readable persistence seam (need open or inspect)");
1629
1891
  }
1630
1892
  /** Sum one session's own usage (its main agent) from its log. */
1631
- async function sumOneSession(persistence, id, signal) {
1632
- const { events, inheritedCut } = await readEvents(persistence, id, signal);
1893
+ async function sumOneSession(persistence, id, signal, live) {
1894
+ signal?.throwIfAborted();
1895
+ const { events, inheritedCut } = typeof live?.snapshotEvents === "function" && typeof live.inheritedEventCount === "number" ? {
1896
+ events: live.snapshotEvents(),
1897
+ inheritedCut: inheritedCount(live.inheritedEventCount)
1898
+ } : await readEvents(persistence, id, signal);
1633
1899
  const fold = new UsageFold();
1634
1900
  const totals = { ...ZERO };
1901
+ const compaction = { ...ZERO };
1635
1902
  for (const ev of events) {
1636
1903
  if (inheritedCut > 0 && typeof ev.seq === "number" && ev.seq < inheritedCut) continue;
1637
1904
  const sample = fold.fold(ev);
1638
1905
  if (sample === null || sample.turn) continue;
1639
- totals.uncachedInputTokens += sample.inputTokens;
1640
- totals.outputTokens += sample.outputTokens;
1641
- totals.cacheReadTokens += sample.cacheReadTokens;
1642
- totals.cacheWriteTokens += sample.cacheWriteTokens;
1906
+ const buckets = {
1907
+ uncachedInputTokens: sample.inputTokens,
1908
+ outputTokens: sample.outputTokens,
1909
+ cacheReadTokens: sample.cacheReadTokens,
1910
+ cacheWriteTokens: sample.cacheWriteTokens
1911
+ };
1912
+ add(totals, buckets);
1913
+ if (ev.type === "compaction/summary") add(compaction, buckets);
1643
1914
  }
1644
- return totals;
1915
+ return {
1916
+ totals,
1917
+ compaction
1918
+ };
1645
1919
  }
1646
1920
  var SessionUsageService = class {
1647
- /** Cached trees, each remembering every session id it was built from, so a
1648
- * live event on ANY of them (a subagent's own event changes its ancestors'
1649
- * totals too) can invalidate exactly the affected trees. */
1921
+ /** Short-lived tree reads; any live event can invalidate tree membership. */
1650
1922
  cache = /* @__PURE__ */ new Map();
1651
- /** Drop every cached tree that counted `sessionId` (its own tree, or any
1652
- * ancestor's tree that folded it in as a subagent). Called by the collector
1653
- * on each live session event. */
1654
- invalidate(sessionId) {
1655
- for (const [rootId, entry] of this.cache) if (entry.members.has(sessionId)) this.cache.delete(rootId);
1923
+ /** Any event can introduce a new descendant while an async read is running. */
1924
+ generation = 0;
1925
+ /** Invalidate conservatively: an unseen session may be a newly-created child
1926
+ * of ANY cached root, so membership-only invalidation is not safe. */
1927
+ invalidate(_sessionId) {
1928
+ this.generation++;
1929
+ this.cache.clear();
1656
1930
  }
1657
1931
  /** Resolve the whole session tree rooted at `rootId` and sum its usage.
1658
1932
  * `self` is the root's own usage, `subagents` every descendant's, `total`
1659
- * the sum. Unknown roots answer zeroed totals rather than throwing: the pill
1660
- * must never break the composer. */
1933
+ * the sum. Unreadable logs reject rather than publishing partial totals;
1934
+ * callers can retain their last good display while surfacing the error. */
1661
1935
  async tree(persistence, sessions, rootId, signal) {
1936
+ signal?.throwIfAborted();
1662
1937
  const hit = this.cache.get(rootId);
1663
1938
  if (hit !== void 0 && Date.now() - hit.at < CACHE_TTL_MS) return hit.value;
1939
+ const generation = this.generation;
1664
1940
  const headers = await this.listHeaders(persistence, signal);
1665
1941
  const children = /* @__PURE__ */ new Map();
1666
1942
  for (const header of headers) {
@@ -1670,10 +1946,10 @@ var SessionUsageService = class {
1670
1946
  if (bucket === void 0) children.set(parent, [header.id]);
1671
1947
  else bucket.push(header.id);
1672
1948
  }
1673
- for (const handle of sessions?.list() ?? []) {
1674
- const parent = handle.parentSession;
1949
+ const live = new Map((sessions?.list() ?? []).map((handle) => [handle.id, handle]));
1950
+ for (const handle of live.values()) {
1951
+ const parent = handle.header?.parentSession ?? handle.parentSession;
1675
1952
  if (typeof parent !== "string" || parent === "" || parent === handle.id) continue;
1676
- if (headers.some((h) => h.id === handle.id)) continue;
1677
1953
  const bucket = children.get(parent);
1678
1954
  if (bucket === void 0) children.set(parent, [handle.id]);
1679
1955
  else if (!bucket.includes(handle.id)) bucket.push(handle.id);
@@ -1690,23 +1966,25 @@ var SessionUsageService = class {
1690
1966
  queue.push(child);
1691
1967
  }
1692
1968
  }
1693
- const self = await sumOneSession(persistence, rootId, signal).catch(() => ({ ...ZERO }));
1969
+ const root = await sumOneSession(persistence, rootId, signal, live.get(rootId));
1970
+ const self = root.totals;
1694
1971
  const subagents = { ...ZERO };
1695
- for (const id of descendants) add(subagents, await sumOneSession(persistence, id, signal).catch(() => ({ ...ZERO })));
1972
+ for (const id of descendants) add(subagents, (await sumOneSession(persistence, id, signal, live.get(id))).totals);
1696
1973
  const total = { ...self };
1697
1974
  add(total, subagents);
1698
1975
  const value = {
1699
1976
  self,
1977
+ selfCompaction: root.compaction,
1700
1978
  subagents,
1701
1979
  total,
1702
1980
  subagentSessions: descendants.length
1703
1981
  };
1704
- if (signal?.aborted !== true) {
1982
+ signal?.throwIfAborted();
1983
+ if (generation === this.generation) {
1705
1984
  if (this.cache.size >= CACHE_MAX) this.cache.clear();
1706
1985
  this.cache.set(rootId, {
1707
1986
  at: Date.now(),
1708
- value,
1709
- members: /* @__PURE__ */ new Set([rootId, ...descendants])
1987
+ value
1710
1988
  });
1711
1989
  }
1712
1990
  return value;
@@ -1837,6 +2115,28 @@ function apply(ctx, config = {}) {
1837
2115
  scanController = controller;
1838
2116
  await collector.backfillHours(ctx.sessionPersistence, ctx.sessions, controller.signal);
1839
2117
  };
2118
+ const ensureFailureHistory = async () => {
2119
+ await store.readyPromise();
2120
+ if (await store.failuresReady()) return;
2121
+ if (!await store.hasAnyDayRows()) return;
2122
+ if (collector.status.error !== void 0) return;
2123
+ const previous = await store.failuresLiveSequences();
2124
+ const boundaries = /* @__PURE__ */ new Map();
2125
+ for (const handle of ctx.sessions.list()) {
2126
+ const seq = handle.seq;
2127
+ if (typeof seq === "number" && Number.isFinite(seq) && seq >= 0) {
2128
+ boundaries.set(handle.id, seq);
2129
+ continue;
2130
+ }
2131
+ const old = previous.get(handle.id);
2132
+ boundaries.set(handle.id, typeof old === "number" ? old : -1);
2133
+ }
2134
+ await store.markFailuresLiveSequences(boundaries);
2135
+ scanController?.abort();
2136
+ const controller = new AbortController();
2137
+ scanController = controller;
2138
+ await collector.backfillFailures(ctx.sessionPersistence, ctx.sessions, controller.signal);
2139
+ };
1840
2140
  ctx.effect(() => {
1841
2141
  collector.start();
1842
2142
  const onEvent = (session) => {
@@ -1847,6 +2147,7 @@ function apply(ctx, config = {}) {
1847
2147
  (async () => {
1848
2148
  await rescan();
1849
2149
  await ensureHourHistory();
2150
+ await ensureFailureHistory();
1850
2151
  })();
1851
2152
  return () => {
1852
2153
  scanController?.abort();