dsh-token-total 1.0.1 → 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.
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,
@@ -111,6 +112,7 @@ function aggregateSamples(samples, filter, granularity = "day") {
111
112
  const bucketByProvider = /* @__PURE__ */ new Map();
112
113
  const bucketTotals = /* @__PURE__ */ new Map();
113
114
  const bucketRequests = /* @__PURE__ */ new Map();
115
+ const bucketFailures = /* @__PURE__ */ new Map();
114
116
  const bucketTurns = /* @__PURE__ */ new Map();
115
117
  const bucketCacheHit = /* @__PURE__ */ new Map();
116
118
  const bucketCacheMiss = /* @__PURE__ */ new Map();
@@ -123,11 +125,16 @@ function aggregateSamples(samples, filter, granularity = "day") {
123
125
  continue;
124
126
  }
125
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
+ }
126
133
  if (sample.request) {
127
134
  out.requests++;
128
135
  bucketRequests.set(bucket, (bucketRequests.get(bucket) ?? 0) + 1);
129
- if (total <= 0) continue;
130
136
  }
137
+ if (!hasTokenDelta) continue;
131
138
  out.tokens += total;
132
139
  out.inputTokens += sample.inputTokens;
133
140
  out.outputTokens += sample.outputTokens;
@@ -196,6 +203,7 @@ function aggregateSamples(samples, filter, granularity = "day") {
196
203
  byModel: byModelObj,
197
204
  byProvider: byProviderObj,
198
205
  requests: bucketRequests.get(bucket) ?? 0,
206
+ failures: bucketFailures.get(bucket) ?? 0,
199
207
  turns: bucketTurns.get(bucket) ?? 0,
200
208
  cacheHit: bucketCacheHit.get(bucket) ?? 0,
201
209
  cacheMiss: bucketCacheMiss.get(bucket) ?? 0
@@ -293,6 +301,7 @@ const usageDayRowSchema = z.object({
293
301
  cacheReadTokens: z.number(),
294
302
  cacheWriteTokens: z.number(),
295
303
  requests: z.number(),
304
+ failures: z.number().default(0),
296
305
  turns: z.number(),
297
306
  lastSeen: z.number()
298
307
  });
@@ -305,6 +314,7 @@ const usageHourRowSchema = z.object({
305
314
  cacheReadTokens: z.number(),
306
315
  cacheWriteTokens: z.number(),
307
316
  requests: z.number(),
317
+ failures: z.number().default(0),
308
318
  turns: z.number(),
309
319
  lastSeen: z.number()
310
320
  });
@@ -330,13 +340,18 @@ const usageHistoryDomain = defineDomain({
330
340
  liveFirstSeq: z.record(z.string(), z.number()).optional(),
331
341
  hoursBackfilledSessions: z.array(z.string()).optional(),
332
342
  hoursLiveFirstSeq: z.record(z.string(), z.number()).optional(),
333
- 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()
334
347
  }),
335
348
  initial: {
336
349
  backfilledSessions: [],
337
350
  liveFirstSeq: {},
338
351
  hoursBackfilledSessions: [],
339
- hoursLiveFirstSeq: {}
352
+ hoursLiveFirstSeq: {},
353
+ failuresBackfilledSessions: [],
354
+ failuresLiveFirstSeq: {}
340
355
  }
341
356
  },
342
357
  tables: {
@@ -538,6 +553,78 @@ var UsageStore = class {
538
553
  this.markChain = write.then(() => void 0, () => void 0);
539
554
  return write;
540
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
+ }
541
628
  /** Drop every row and the whole cursor, re-bounding each named session at
542
629
  * its WIPE-TIME WATERMARK (the store-rebuild escape hatch for corrupted
543
630
  * history or attribution-logic upgrades).
@@ -565,7 +652,10 @@ var UsageStore = class {
565
652
  liveFirstSeq,
566
653
  hoursBackfilledSessions: [],
567
654
  hoursLiveFirstSeq: { ...liveFirstSeq },
568
- hoursReady: false
655
+ hoursReady: false,
656
+ failuresBackfilledSessions: [],
657
+ failuresLiveFirstSeq: { ...liveFirstSeq },
658
+ failuresReady: false
569
659
  });
570
660
  for (const key of [...table.keys()]) await table.delete(key);
571
661
  for (const key of [...hourTable.keys()]) await hourTable.delete(key);
@@ -622,6 +712,22 @@ var UsageStore = class {
622
712
  const target = this.targetsOf(sample);
623
713
  await this.recordInto(this.requireHourTable(), hourRowKey(hour, target.provider, target.model), emptyHourRow(hour, target.provider, target.model), sample);
624
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
+ }
625
731
  /** The row identity a sample lands on: turn markers use the synthetic
626
732
  * (turns) model, unlabelled calls the (unknown) one. */
627
733
  targetsOf(sample) {
@@ -699,6 +805,7 @@ function emptyCounters(provider, model) {
699
805
  cacheReadTokens: 0,
700
806
  cacheWriteTokens: 0,
701
807
  requests: 0,
808
+ failures: 0,
702
809
  turns: 0,
703
810
  lastSeen: Date.now()
704
811
  };
@@ -719,6 +826,7 @@ function accumulate(base, sample) {
719
826
  return {
720
827
  ...base,
721
828
  requests: base.requests + (sample.request ? 1 : 0),
829
+ failures: (base.failures ?? 0) + (sample.failed ? 1 : 0),
722
830
  inputTokens: base.inputTokens + sample.inputTokens,
723
831
  outputTokens: base.outputTokens + sample.outputTokens,
724
832
  cacheReadTokens: base.cacheReadTokens + sample.cacheReadTokens,
@@ -786,6 +894,7 @@ var UsageFold = class {
786
894
  break;
787
895
  }
788
896
  }
897
+ if (ev.type === "assistant/attempt") return this.attemptSample(ev, usage);
789
898
  return usage ? this.replaceSample(ev, usage) : null;
790
899
  }
791
900
  if (ev.type === "compaction/summary") return this.compactionSample(ev);
@@ -828,6 +937,32 @@ var UsageFold = class {
828
937
  return emitted;
829
938
  }
830
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
+ };
964
+ }
965
+ /**
831
966
  * Fold one `compaction/summary` — the one place a provider call's usage
832
967
  * reaches the log OUTSIDE the agent loop. The summarization call produces no
833
968
  * `assistant/message` (its summary enters the surface as a `user/message`,
@@ -968,6 +1103,9 @@ var UsageCollector = class UsageCollector {
968
1103
  this.store.markHoursLiveSequences(batch).catch(() => {
969
1104
  this.status.recordFailures++;
970
1105
  });
1106
+ this.store.markFailuresLiveSequences(batch).catch(() => {
1107
+ this.status.recordFailures++;
1108
+ });
971
1109
  }
972
1110
  });
973
1111
  }
@@ -1091,9 +1229,16 @@ var UsageCollector = class UsageCollector {
1091
1229
  async backfillHours(persistence, sessions, signal) {
1092
1230
  await this.scan(persistence, sessions, signal, "hours");
1093
1231
  }
1094
- /** Shared replay engine behind {@link backfill} and {@link backfillHours}.
1095
- * `target` selects the cursor pair, the write function, and the cursor
1096
- * 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. */
1097
1242
  async scan(persistence, sessions, signal, target) {
1098
1243
  if (this.status.running) return;
1099
1244
  if (signal?.aborted) return;
@@ -1105,16 +1250,16 @@ var UsageCollector = class UsageCollector {
1105
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;
1106
1251
  return item;
1107
1252
  }).filter((h) => typeof h?.id === "string" && h.id !== "");
1108
- 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();
1109
1254
  const dayCursorWasEmpty = seen.size === 0;
1110
- 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();
1111
1256
  const targets = headers.filter((h) => !seen.has(h.id));
1112
1257
  this.status.total = targets.length;
1113
1258
  this.status.done = 0;
1114
1259
  const concurrency = this.options.backfillConcurrency ?? 4;
1115
1260
  let next = 0;
1116
1261
  const completed = [];
1117
- 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);
1118
1263
  const flushCompleted = async () => {
1119
1264
  if (completed.length === 0) return;
1120
1265
  const batch = completed.splice(0, completed.length);
@@ -1122,8 +1267,13 @@ var UsageCollector = class UsageCollector {
1122
1267
  await this.store.markHoursSeenSessions(batch);
1123
1268
  return;
1124
1269
  }
1270
+ if (target === "failures") {
1271
+ await this.store.markFailuresSeenSessions(batch);
1272
+ return;
1273
+ }
1125
1274
  await this.store.markSeenSessions(batch);
1126
1275
  await this.store.markHoursSeenSessions(batch);
1276
+ await this.store.markFailuresSeenSessions(batch);
1127
1277
  };
1128
1278
  const worker = async () => {
1129
1279
  for (;;) {
@@ -1204,7 +1354,9 @@ var UsageCollector = class UsageCollector {
1204
1354
  };
1205
1355
  await Promise.all(Array.from({ length: Math.min(concurrency, Math.max(1, targets.length)) }, worker));
1206
1356
  await flushCompleted();
1207
- 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();
1208
1360
  } finally {
1209
1361
  this.status.running = false;
1210
1362
  }
@@ -1565,6 +1717,16 @@ async function aggregateRange(store, from, to, granularity = "day", llm) {
1565
1717
  cacheWriteTokens: 0,
1566
1718
  request: true
1567
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
+ };
1568
1730
  for (let i = 0; i < row.turns; i++) yield {
1569
1731
  day,
1570
1732
  hour,
@@ -1953,6 +2115,28 @@ function apply(ctx, config = {}) {
1953
2115
  scanController = controller;
1954
2116
  await collector.backfillHours(ctx.sessionPersistence, ctx.sessions, controller.signal);
1955
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
+ };
1956
2140
  ctx.effect(() => {
1957
2141
  collector.start();
1958
2142
  const onEvent = (session) => {
@@ -1963,6 +2147,7 @@ function apply(ctx, config = {}) {
1963
2147
  (async () => {
1964
2148
  await rescan();
1965
2149
  await ensureHourHistory();
2150
+ await ensureFailureHistory();
1966
2151
  })();
1967
2152
  return () => {
1968
2153
  scanController?.abort();
@@ -0,0 +1,22 @@
1
+ import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots';
2
+ import { type ContextBreakdownLike, type ContextPressureLike } from './context-meter-core.ts';
3
+ export declare const CONTEXT_LOCALE_NS = "tokenTotalContext";
4
+ declare const keys: readonly ["label", "used", "system", "tools", "messages", "aria", "thousand", "million"];
5
+ export type ContextLocaleKey = typeof keys[number];
6
+ export declare const CONTEXT_LOCALES: Record<string, Record<ContextLocaleKey, string>>;
7
+ type ProjectionMap = {
8
+ contextPressure: ContextPressureLike;
9
+ contextBreakdown: ContextBreakdownLike;
10
+ };
11
+ type UseContextProjection = <K extends keyof ProjectionMap & string>(key: K) => ProjectionMap[K] | undefined;
12
+ export interface ContextUsageEntryProps extends PropsLocale<typeof CONTEXT_LOCALE_NS> {
13
+ useProjection: UseContextProjection;
14
+ installContextTakeover?: (anchor: HTMLElement) => () => void;
15
+ }
16
+ export declare function ContextUsageEntry({ useProjection, t, installContextTakeover }: ContextUsageEntryProps): JSX.Element | null;
17
+ declare module '@deepseek-ai/dsh-client-ui-slots' {
18
+ interface LocaleNamespaceMap {
19
+ tokenTotalContext: ContextLocaleKey;
20
+ }
21
+ }
22
+ export {};
@@ -0,0 +1,26 @@
1
+ export interface ContextPressureLike {
2
+ projectedTokens?: number;
3
+ pressureTokens?: number;
4
+ contextWindow?: number;
5
+ }
6
+ export interface ContextBreakdownLike {
7
+ systemTokens: number;
8
+ toolsTokens: number;
9
+ messageTokens: number;
10
+ }
11
+ export interface ContextOccupancy {
12
+ percent: number;
13
+ usedTokens: number;
14
+ contextWindow: number;
15
+ }
16
+ /** Mirrors the host meter's occupancy calculation exactly. */
17
+ export declare function contextOccupancy(pressure: ContextPressureLike | undefined): ContextOccupancy | null;
18
+ export declare function contextBreakdownTotal(breakdown: ContextBreakdownLike | undefined): number;
19
+ export declare function contextBreakdownSegments(percent: number, breakdown: ContextBreakdownLike | undefined): Array<{
20
+ key: string;
21
+ width: number;
22
+ }>;
23
+ export declare function formatContextTokens(value: number, units: {
24
+ thousand: string;
25
+ million: string;
26
+ }): string;
@@ -0,0 +1,4 @@
1
+ /** The host ContextMeter is hard-coded beside composer.dock, not a slot.
2
+ * Suppress only that recognizable sibling while our replacement is mounted;
3
+ * never hide a whole dock, slot wrapper, or another plugin's readout. */
4
+ export declare function installContextTakeover(anchor: HTMLElement): () => void;
@@ -7,7 +7,7 @@
7
7
  /** The locale namespace this plugin owns. */
8
8
  export declare const LOCALE_NS = "tokenTotal";
9
9
  /** The typed translator key union for the panel's locale seat. */
10
- export type TokenTotalKey = 'nav' | 'back' | 'range' | 'rangePreset.today' | 'rangePreset.3' | 'rangePreset.7' | 'rangePreset.30' | 'rangePreset.90' | 'rangeCustom' | 'from' | 'to' | 'refresh' | 'loading' | 'tokens' | 'tokensHint' | 'tokensUnit' | 'inputTokens' | 'outputTokens' | 'cacheTokens' | 'cachedTokens' | 'sessions' | 'requests' | 'activeDays' | 'cacheRate' | 'cacheRateHint' | 'cacheHitRate' | 'hitRateLegend' | 'topModel' | 'topModelHint' | 'heatmap' | 'heatLess' | 'heatMore' | 'dailyTrend' | 'hourlyTrend' | 'trendLimited' | 'usageRanking' | 'byModel' | 'byProvider' | 'tokensColumn' | 'cacheRateColumn' | 'providerModels' | 'other' | 'total' | 'percent' | 'asOf' | 'empty' | 'stats.counts' | 'stats.tokensPerSecond' | 'stats.cacheHit' | 'stats.cacheMiss' | 'stats.dialog.title' | 'stats.dialog.usageTitle' | 'stats.dialog.llmTime' | 'stats.dialog.toolTime' | 'stats.dialog.ttft' | 'stats.dialog.speed' | 'stats.dialog.main' | 'stats.dialog.subagents' | 'stats.dialog.subagentCount' | 'stats.dialog.treeTotal' | 'message.turnUsage.count' | 'message.turnUsage.cacheHit' | 'message.turnUsage.input' | 'message.turnUsage.cacheRead' | 'message.turnUsage.cacheWrite' | 'message.turnUsage.output';
10
+ export type TokenTotalKey = 'nav' | 'back' | 'range' | 'rangePreset.today' | 'rangePreset.3' | 'rangePreset.7' | 'rangePreset.30' | 'rangePreset.90' | 'rangeCustom' | 'from' | 'to' | 'refresh' | 'loading' | 'tokens' | 'tokensHint' | 'tokensUnit' | 'inputTokens' | 'outputTokens' | 'cacheTokens' | 'cachedTokens' | 'sessions' | 'requests' | 'requestsFailed' | 'activeDays' | 'cacheRate' | 'cacheRateHint' | 'cacheHitRate' | 'hitRateLegend' | 'topModel' | 'topModelHint' | 'heatmap' | 'heatLess' | 'heatMore' | 'dailyTrend' | 'hourlyTrend' | 'trendLimited' | 'usageRanking' | 'byModel' | 'byProvider' | 'tokensColumn' | 'cacheRateColumn' | 'providerModels' | 'other' | 'total' | 'percent' | 'asOf' | 'empty' | 'stats.counts' | 'stats.tokensPerSecond' | 'stats.cacheHit' | 'stats.cacheMiss' | 'stats.dialog.title' | 'stats.dialog.usageTitle' | 'stats.dialog.llmTime' | 'stats.dialog.toolTime' | 'stats.dialog.ttft' | 'stats.dialog.speed' | 'stats.dialog.main' | 'stats.dialog.subagents' | 'stats.dialog.subagentCount' | 'stats.dialog.treeTotal' | 'message.turnUsage.count' | 'message.turnUsage.cacheHit' | 'message.turnUsage.input' | 'message.turnUsage.cacheRead' | 'message.turnUsage.cacheWrite' | 'message.turnUsage.output';
11
11
  export declare const en: Record<TokenTotalKey, string>;
12
12
  export declare const zh: Record<TokenTotalKey, string>;
13
13
  export declare const zhTW: Record<TokenTotalKey, string>;
@@ -1,7 +1,6 @@
1
1
  /** Pure billing helpers shared by the session-tree hook and composer pills. */
2
2
  import type { SessionUsageTotals, SessionUsageTree } from '../wire.ts';
3
- /** Use the live main-agent projection when available. Never add tree.total to
4
- * it: that would count the main agent twice. The log is only the fallback. */
3
+ /** Add the root and descendants once; tree.total is already that sum. */
5
4
  export declare function combinedTotals(tree: SessionUsageTree, self?: SessionUsageTotals): SessionUsageTotals;
6
5
  /** One consistent split for the pill, cache hit rate and every dialog row. */
7
6
  export declare function sessionUsageForDisplay(native: SessionUsageTotals | undefined, tree: SessionUsageTree | null): SessionUsageTree;
@@ -2,7 +2,7 @@ import type { SessionUsageTotals, SessionUsageTree } from '../wire.ts';
2
2
  export { billedTotal, cacheHitPercentOf, combinedTotals, hasAnyTokens } from './session-usage-core.ts';
3
3
  export interface SessionTreeReading {
4
4
  tree: SessionUsageTree | null;
5
- /** Log-based main + subagents. The pill substitutes the live main projection. */
5
+ /** Authoritative log-based main + all descendant usage after the tree loads. */
6
6
  totals: SessionUsageTotals | null;
7
7
  }
8
8
  export declare function useSessionUsage(sessionId: string | undefined, signature: string): SessionTreeReading;
@@ -74,6 +74,17 @@ export declare class UsageFold {
74
74
  * carries no (new) usage or turn marker. */
75
75
  fold(ev: UsageSessionEvent): UsageSample | null;
76
76
  private replaceSample;
77
+ /**
78
+ * Fold one `assistant/attempt` — a provider call that failed. Tokens follow
79
+ * the same last-report-wins rule as a committed message (the attempt's
80
+ * stream is the call's final word, so a usage chunk seen mid-stream is
81
+ * replaced or corrected, never added twice), but the call must ALWAYS be
82
+ * counted as one failure: `replaceSample` drops an all-zero first report,
83
+ * which is correct for the token buckets and would silently lose the
84
+ * failure. A failed call that reported no usage therefore contributes a
85
+ * zero-token failure marker instead of a null.
86
+ */
87
+ private attemptSample;
77
88
  /**
78
89
  * Fold one `compaction/summary` — the one place a provider call's usage
79
90
  * reaches the log OUTSIDE the agent loop. The summarization call produces no
@@ -200,8 +211,13 @@ export declare class UsageCollector {
200
211
  * untouched, so a user who upgraded never loses day history because a
201
212
  * session log was pruned. */
202
213
  backfillHours(persistence: UsageSessionPersistence, sessions: UsageSessionStore, signal?: AbortSignal): Promise<void>;
203
- /** Shared replay engine behind {@link backfill} and {@link backfillHours}.
204
- * `target` selects the cursor pair, the write function, and the cursor
205
- * the completed sessions land in. */
214
+ /** The failure upgrade pass: replay every session the FAILURE cursors have
215
+ * not seen, counting only failed calls. Neither the day nor the hour
216
+ * cursor covers this counter (both were full when it arrived), and the
217
+ * replay must not re-add tokens or requests. */
218
+ backfillFailures(persistence: UsageSessionPersistence, sessions: UsageSessionStore, signal?: AbortSignal): Promise<void>;
219
+ /** Shared replay engine behind {@link backfill}, {@link backfillHours} and
220
+ * {@link backfillFailures}. `target` selects the cursor pair, the write
221
+ * function, and the cursor the completed sessions land in. */
206
222
  private scan;
207
223
  }
@@ -41,6 +41,14 @@ export interface UsageSample {
41
41
  cacheWriteTokens: number;
42
42
  turn?: boolean;
43
43
  request?: boolean;
44
+ /** True when this sample also counts one FAILED provider call. The host
45
+ * appends `assistant/attempt` (instead of `assistant/message`) when a
46
+ * call's stream ends with an error or is cancelled before any content is
47
+ * committed, so that event is the exact boundary of a failed call. The
48
+ * marker is ORTHOGONAL to `request` and to the buckets: a failed call was
49
+ * still opened by its own `step/start` (so it is already one request) and
50
+ * a stream that died mid-flight may still report usage. */
51
+ failed?: boolean;
44
52
  }
45
53
  export interface RangeFilter {
46
54
  from: string;
@@ -23,6 +23,11 @@ export interface UsageCounters {
23
23
  cacheReadTokens: number;
24
24
  cacheWriteTokens: number;
25
25
  requests: number;
26
+ /** Provider calls in the bucket that FAILED (error / cancelled before any
27
+ * content): a subset of `requests`, never larger than it. Rows persisted
28
+ * before the counter existed may lack it; the schema normalizes that to 0
29
+ * and every subsequent fold writes the number back. */
30
+ failures: number;
26
31
  turns: number;
27
32
  lastSeen: number;
28
33
  }
@@ -43,6 +48,7 @@ export declare const usageDayRowSchema: z.ZodObject<{
43
48
  cacheReadTokens: z.ZodNumber;
44
49
  cacheWriteTokens: z.ZodNumber;
45
50
  requests: z.ZodNumber;
51
+ failures: z.ZodDefault<z.ZodNumber>;
46
52
  turns: z.ZodNumber;
47
53
  lastSeen: z.ZodNumber;
48
54
  }, z.core.$strip>;
@@ -55,6 +61,7 @@ export declare const usageHourRowSchema: z.ZodObject<{
55
61
  cacheReadTokens: z.ZodNumber;
56
62
  cacheWriteTokens: z.ZodNumber;
57
63
  requests: z.ZodNumber;
64
+ failures: z.ZodDefault<z.ZodNumber>;
58
65
  turns: z.ZodNumber;
59
66
  lastSeen: z.ZodNumber;
60
67
  }, z.core.$strip>;
@@ -81,12 +88,17 @@ export declare const usageHistoryDomain: {
81
88
  hoursBackfilledSessions: z.ZodOptional<z.ZodArray<z.ZodString>>;
82
89
  hoursLiveFirstSeq: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodNumber>>;
83
90
  hoursReady: z.ZodOptional<z.ZodBoolean>;
91
+ failuresBackfilledSessions: z.ZodOptional<z.ZodArray<z.ZodString>>;
92
+ failuresLiveFirstSeq: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodNumber>>;
93
+ failuresReady: z.ZodOptional<z.ZodBoolean>;
84
94
  }, z.core.$strip>;
85
95
  initial: {
86
96
  backfilledSessions: string[];
87
97
  liveFirstSeq: {};
88
98
  hoursBackfilledSessions: string[];
89
99
  hoursLiveFirstSeq: {};
100
+ failuresBackfilledSessions: string[];
101
+ failuresLiveFirstSeq: {};
90
102
  };
91
103
  };
92
104
  tables: {
@@ -160,6 +172,21 @@ export declare class UsageStore {
160
172
  hoursReady(): Promise<boolean>;
161
173
  /** Mark the hour history complete; the upgrade pass then never reruns. */
162
174
  markHoursReady(): Promise<void>;
175
+ /** Session ids already replayed into the FAILURE counter (third cursor
176
+ * pair, same reason as the hour pair: the day and hour cursors were
177
+ * already full when the counter arrived). */
178
+ failuresSeenSessions(): Promise<Set<string>>;
179
+ /** Per-session seq of the first LIVE-observed event, failure-counter share. */
180
+ failuresLiveSequences(): Promise<Map<string, number>>;
181
+ /** Persist failure-pass replay progress (merges; serialized on the same
182
+ * chain as every other cursor write). */
183
+ markFailuresSeenSessions(ids: Iterable<string>): Promise<void>;
184
+ /** Record the failure-pass live boundary per session (earliest wins). */
185
+ markFailuresLiveSequences(entries: Iterable<readonly [string, number]>): Promise<void>;
186
+ /** Whether the failure counter covers every replayable session. */
187
+ failuresReady(): Promise<boolean>;
188
+ /** Mark the failure history complete; the upgrade pass then never reruns. */
189
+ markFailuresReady(): Promise<void>;
163
190
  /** Drop every row and the whole cursor, re-bounding each named session at
164
191
  * its WIPE-TIME WATERMARK (the store-rebuild escape hatch for corrupted
165
192
  * history or attribution-logic upgrades).
@@ -198,6 +225,11 @@ export declare class UsageStore {
198
225
  * day cursors are already advanced, so replaying through `record()` would
199
226
  * double every day row. */
200
227
  recordHours(sample: UsageSample): Promise<void>;
228
+ /** Fold ONLY the failure count of a sample into the day and hour rows. The
229
+ * failure upgrade pass uses this: its replay walks logs the token passes
230
+ * already consumed, so the tokens and the request marker must not be added
231
+ * a second time — only the failed-call counter may move. */
232
+ recordFailure(sample: UsageSample): Promise<void>;
201
233
  /** The row identity a sample lands on: turn markers use the synthetic
202
234
  * (turns) model, unlabelled calls the (unknown) one. */
203
235
  private targetsOf;
@@ -17,6 +17,7 @@ export interface DailyTokenUsage {
17
17
  byModel: Record<string, number>;
18
18
  byProvider: Record<string, number>;
19
19
  requests: number;
20
+ failures: number;
20
21
  turns: number;
21
22
  cacheHit: number;
22
23
  cacheMiss: number;
@@ -70,6 +71,12 @@ export interface TokenTotalRange {
70
71
  /** Cached prompt tokens: cache reads + cache writes (the cache bucket of `tokens`). */
71
72
  cacheTokens: number;
72
73
  requests: number;
74
+ /** Provider calls in the range that FAILED: the stream ended with an error
75
+ * or the call was cancelled before any content was committed. A subset of
76
+ * `requests` (a failed call was still one call), and — like `requests` —
77
+ * independent of the token buckets: a stream that died mid-flight may
78
+ * still have reported usage. */
79
+ failures: number;
73
80
  turns: number;
74
81
  cacheHit: number;
75
82
  cacheMiss: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-token-total",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "description": "DSH web plugin: token 用量统计看板 + 单会话(主智能体 + 子智能体)用量统计。提供今日/近3天/近7天/近1月/近3月/自定义筛选、六个汇总卡片、活跃热力图、缓存命中率折线与分模型柱状图、按模型/按供应商 Top5 列表。",
5
5
  "icon": "./icon.svg",
6
6
  "type": "module",
@@ -0,0 +1,20 @@
1
+ .root { display: inline-flex; align-items: center; min-width: 0; font: inherit; font-variant-numeric: tabular-nums; line-height: inherit; }
2
+ .trigger { display: inline-flex; align-items: center; gap: 6px; box-sizing: border-box; max-width: 100%; padding: 1px 8px; border: 0; border-radius: 24px; background: transparent; color: var(--dsw-alias-label-tertiary); font: inherit; line-height: inherit; white-space: nowrap; cursor: pointer; }
3
+ .trigger svg { flex: none; }
4
+ .track { fill: none; stroke: var(--dsw-alias-separator-primary); stroke-width: 1.5; }
5
+ .fill { fill: none; stroke: var(--dsw-alias-label-tertiary); stroke-width: 2; stroke-linecap: round; }
6
+ .trigger:hover, .trigger[aria-expanded='true'] { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-secondary); }
7
+ .panel { position: fixed; z-index: 1100; box-sizing: border-box; width: max-content; min-width: min(300px, calc(100vw - 24px)); max-width: min(440px, calc(100vw - 24px)); padding: 16px; border-radius: 12px; background: var(--dsw-specific-menu); backdrop-filter: var(--dsw-menu-backdrop-filter); box-shadow: var(--dsw-elevation-prominent); color: var(--dsw-alias-label-secondary); font-size: 12px; line-height: 18px; }
8
+ .header { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; color: var(--dsw-alias-label-primary); font-weight: 500; }
9
+ .percent { font-variant-numeric: tabular-nums; }
10
+ .figures { margin-left: auto; color: var(--dsw-alias-label-tertiary); font-variant-numeric: tabular-nums; white-space: nowrap; }
11
+ .bar { display: flex; width: 100%; height: 8px; overflow: hidden; border-radius: 4px; background: var(--dsw-alias-separator-primary); }
12
+ .segment { height: 100%; }
13
+ .systemTokens { background: var(--dsw-alias-label-primary); }
14
+ .toolsTokens { background: var(--dsw-alias-label-secondary); }
15
+ .messageTokens { background: var(--dsw-alias-label-tertiary); }
16
+ .rows { display: grid; gap: 6px; margin: 12px 0 0; color: var(--dsw-alias-label-tertiary); }
17
+ .row { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
18
+ .row dt { display: flex; align-items: center; gap: 8px; }
19
+ .row dd { margin: 0; color: var(--dsw-alias-label-secondary); font-variant-numeric: tabular-nums; }
20
+ .swatch { width: 8px; height: 8px; border-radius: 2px; }