dsh-token-total 1.0.0 → 1.0.1

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
@@ -104,6 +104,8 @@ function aggregateSamples(samples, filter, granularity = "day") {
104
104
  const providerTotals = /* @__PURE__ */ new Map();
105
105
  const modelCache = /* @__PURE__ */ new Map();
106
106
  const providerCache = /* @__PURE__ */ new Map();
107
+ const modelProviderTotals = /* @__PURE__ */ new Map();
108
+ const providerModelTotals = /* @__PURE__ */ new Map();
107
109
  const active = /* @__PURE__ */ new Set();
108
110
  const bucketByModel = /* @__PURE__ */ new Map();
109
111
  const bucketByProvider = /* @__PURE__ */ new Map();
@@ -141,6 +143,18 @@ function aggregateSamples(samples, filter, granularity = "day") {
141
143
  modelProviders.set(model, sources);
142
144
  modelTotals.set(model, (modelTotals.get(model) ?? 0) + total);
143
145
  providerTotals.set(provider, (providerTotals.get(provider) ?? 0) + total);
146
+ let modelSplitMap = modelProviderTotals.get(model);
147
+ if (!modelSplitMap) {
148
+ modelSplitMap = /* @__PURE__ */ new Map();
149
+ modelProviderTotals.set(model, modelSplitMap);
150
+ }
151
+ modelSplitMap.set(provider, (modelSplitMap.get(provider) ?? 0) + total);
152
+ let providerSplitMap = providerModelTotals.get(provider);
153
+ if (!providerSplitMap) {
154
+ providerSplitMap = /* @__PURE__ */ new Map();
155
+ providerModelTotals.set(provider, providerSplitMap);
156
+ }
157
+ providerSplitMap.set(model, (providerSplitMap.get(model) ?? 0) + total);
144
158
  const mc = modelCache.get(model) ?? {
145
159
  hit: 0,
146
160
  miss: 0
@@ -187,8 +201,8 @@ function aggregateSamples(samples, filter, granularity = "day") {
187
201
  cacheMiss: bucketCacheMiss.get(bucket) ?? 0
188
202
  });
189
203
  }
190
- out.models = modelsSorted(modelTotals, modelCache, modelProviders);
191
- out.providers = providersSorted(providerTotals, providerCache);
204
+ out.models = modelsSorted(modelTotals, modelCache, modelProviders, modelProviderTotals);
205
+ out.providers = providersSorted(providerTotals, providerCache, providerModelTotals);
192
206
  out.topModel = out.models[0]?.model ?? "";
193
207
  out.topProvider = out.providers[0]?.provider ?? "";
194
208
  if (out.tokens > 0) {
@@ -197,7 +211,7 @@ function aggregateSamples(samples, filter, granularity = "day") {
197
211
  }
198
212
  return out;
199
213
  }
200
- function modelsSorted(totals, cache, sources) {
214
+ function modelsSorted(totals, cache, sources, splits) {
201
215
  const out = [];
202
216
  for (const [model, tokens] of totals) {
203
217
  const c = cache.get(model);
@@ -206,6 +220,7 @@ function modelsSorted(totals, cache, sources) {
206
220
  model,
207
221
  provider: providers.length === 1 ? providers[0] : "",
208
222
  providers,
223
+ providerTokens: providerSplit(splits.get(model)),
209
224
  tokens,
210
225
  percent: 0,
211
226
  cacheHit: c?.hit ?? 0,
@@ -215,7 +230,7 @@ function modelsSorted(totals, cache, sources) {
215
230
  out.sort((a, b) => b.tokens - a.tokens || a.model.localeCompare(b.model));
216
231
  return out;
217
232
  }
218
- function providersSorted(totals, cache) {
233
+ function providersSorted(totals, cache, splits) {
219
234
  const out = [];
220
235
  for (const [provider, tokens] of totals) {
221
236
  const c = cache.get(provider);
@@ -224,12 +239,30 @@ function providersSorted(totals, cache) {
224
239
  tokens,
225
240
  percent: 0,
226
241
  cacheHit: c?.hit ?? 0,
227
- cacheMiss: c?.miss ?? 0
242
+ cacheMiss: c?.miss ?? 0,
243
+ modelTokens: modelSplit(splits.get(provider))
228
244
  });
229
245
  }
230
246
  out.sort((a, b) => b.tokens - a.tokens);
231
247
  return out;
232
248
  }
249
+ /** One model's per-provider split, largest first (ties by id for a stable
250
+ * order across refreshes). Empty when the response carries no cross data. */
251
+ function providerSplit(split) {
252
+ if (split === void 0) return [];
253
+ return [...split].map(([provider, tokens]) => ({
254
+ provider,
255
+ tokens
256
+ })).sort((a, b) => b.tokens - a.tokens || a.provider.localeCompare(b.provider));
257
+ }
258
+ /** One provider's per-model split, largest first (ties by name). */
259
+ function modelSplit(split) {
260
+ if (split === void 0) return [];
261
+ return [...split].map(([model, tokens]) => ({
262
+ model,
263
+ tokens
264
+ })).sort((a, b) => b.tokens - a.tokens || a.model.localeCompare(b.model));
265
+ }
233
266
  //#endregion
234
267
  //#region src/store.ts
235
268
  /**
@@ -725,6 +758,10 @@ var UsageFold = class {
725
758
  cacheWriteTokens: 0,
726
759
  turn: true
727
760
  };
761
+ if (ev.type === "llm/retry-started") {
762
+ const key = this.keyOf(ev);
763
+ if (key !== null) this.seen.delete(key);
764
+ }
728
765
  if (ev.type === "step/start" || ev.type === "llm/retry-started") return {
729
766
  day: dayKey(ev.time),
730
767
  hour: hourKey(ev.time),
@@ -739,9 +776,17 @@ var UsageFold = class {
739
776
  const usage = data.chunk?.type === "usage" ? data.chunk.usage : void 0;
740
777
  return usage ? this.replaceSample(ev, usage) : null;
741
778
  }
742
- if (ev.type === "assistant/message") {
779
+ if (ev.type === "assistant/message" || ev.type === "assistant/attempt") {
743
780
  const data = ev.data;
744
- return data.usage ? this.replaceSample(ev, data.usage) : null;
781
+ let usage = ev.type === "assistant/message" ? data.usage : void 0;
782
+ if (usage === void 0 && Array.isArray(data.stream)) for (let i = data.stream.length - 1; i >= 0; i--) {
783
+ const record = data.stream[i];
784
+ if (record?.type === "chunk" && record.chunk?.type === "usage") {
785
+ usage = record.chunk.usage;
786
+ break;
787
+ }
788
+ }
789
+ return usage ? this.replaceSample(ev, usage) : null;
745
790
  }
746
791
  if (ev.type === "compaction/summary") return this.compactionSample(ev);
747
792
  return null;
@@ -749,21 +794,38 @@ var UsageFold = class {
749
794
  replaceSample(ev, usage) {
750
795
  const usage2 = usage;
751
796
  if (!usage2) return null;
752
- if ((usage2.inputTokens ?? 0) + (usage2.outputTokens ?? 0) + (usage2.cacheReadTokens ?? 0) + (usage2.cacheWriteTokens ?? 0) <= 0) return null;
753
797
  const key = this.keyOf(ev);
754
798
  const sample = {
755
799
  day: dayKey(ev.time),
756
800
  hour: hourKey(ev.time),
757
- inputTokens: usage2.inputTokens,
758
- outputTokens: usage2.outputTokens,
801
+ inputTokens: usage2.inputTokens ?? 0,
802
+ outputTokens: usage2.outputTokens ?? 0,
759
803
  cacheReadTokens: usage2.cacheReadTokens ?? 0,
760
804
  cacheWriteTokens: usage2.cacheWriteTokens ?? 0
761
805
  };
806
+ const prev = key === null ? void 0 : this.seen.get(key);
807
+ if (!prev && sample.inputTokens + sample.outputTokens + sample.cacheReadTokens + sample.cacheWriteTokens <= 0) return null;
762
808
  if (key === null) return sample;
763
- const prev = this.seen.get(key);
764
809
  this.seen.set(key, sample);
765
- if (prev) return null;
766
- return { ...sample };
810
+ if (prev) {
811
+ const correction = {
812
+ day: prev.day,
813
+ hour: prev.hour,
814
+ model: prev.model,
815
+ inputTokens: sample.inputTokens - prev.inputTokens,
816
+ outputTokens: sample.outputTokens - prev.outputTokens,
817
+ cacheReadTokens: sample.cacheReadTokens - prev.cacheReadTokens,
818
+ cacheWriteTokens: sample.cacheWriteTokens - prev.cacheWriteTokens
819
+ };
820
+ sample.day = prev.day;
821
+ sample.hour = prev.hour;
822
+ sample.model = prev.model;
823
+ if (correction.inputTokens === 0 && correction.outputTokens === 0 && correction.cacheReadTokens === 0 && correction.cacheWriteTokens === 0) return null;
824
+ return correction;
825
+ }
826
+ const emitted = { ...sample };
827
+ this.seen.set(key, emitted);
828
+ return emitted;
767
829
  }
768
830
  /**
769
831
  * Fold one `compaction/summary` — the one place a provider call's usage
@@ -1167,6 +1229,34 @@ async function lookup(read, timeoutMs) {
1167
1229
  clearTimeout(timer);
1168
1230
  }
1169
1231
  }
1232
+ /** Resolve provider route ids to the labels the model selector shows. Both
1233
+ * catalogs are presentational and synchronous on the host runtime, so this is
1234
+ * a plain in-process read rather than a per-request lookup: a missing service,
1235
+ * a throwing adapter, or an unexpected promise all resolve to an empty map and
1236
+ * every surface falls back to the raw historical route id. */
1237
+ function resolveProviderNames(llm) {
1238
+ const names = Object.create(null);
1239
+ if (!llm) return names;
1240
+ try {
1241
+ for (const provider of catalog(llm.listProviders?.())) {
1242
+ const name = provider.name?.trim();
1243
+ if (provider.id && name) names[provider.id] = name;
1244
+ }
1245
+ } catch {}
1246
+ try {
1247
+ for (const entry of catalog(llm.listConfigurableProviders?.())) {
1248
+ const name = entry.displayName?.trim();
1249
+ if (entry.provider && name && names[entry.provider] === void 0) names[entry.provider] = name;
1250
+ }
1251
+ } catch {}
1252
+ return names;
1253
+ }
1254
+ /** Both catalogs are declared synchronous, but a host that publishes them over
1255
+ * a remote face would hand back a promise; treat anything that is not an array
1256
+ * as "no metadata" instead of throwing mid-query. */
1257
+ function catalog(value) {
1258
+ return Array.isArray(value) ? value : [];
1259
+ }
1170
1260
  /** Resolve each distinct route once per range query. Unknown/deleted providers
1171
1261
  * fall back to their bare historical model names in aggregateSamples(). */
1172
1262
  async function resolveModelNames(modelRefs, llm) {
@@ -1451,7 +1541,7 @@ function granularityOf(range) {
1451
1541
  async function aggregateRange(store, from, to, granularity = "day", llm) {
1452
1542
  const rows = granularity === "hour" ? await store.rangeHourRows(from, to) : await store.rangeRows(from, to);
1453
1543
  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* () {
1544
+ const stats = aggregateSamples((function* () {
1455
1545
  for (const row of rows) {
1456
1546
  const hour = "hour" in row ? row.hour : void 0;
1457
1547
  const day = hour === void 0 ? row.day : hour.slice(0, 10);
@@ -1489,6 +1579,14 @@ async function aggregateRange(store, from, to, granularity = "day", llm) {
1489
1579
  from,
1490
1580
  to
1491
1581
  }, granularity);
1582
+ const resolved = resolveProviderNames(llm);
1583
+ const providerNames = Object.create(null);
1584
+ for (const provider of stats.providers) {
1585
+ const label = resolved[provider.provider];
1586
+ if (label !== void 0) providerNames[provider.provider] = label;
1587
+ }
1588
+ if (Object.keys(providerNames).length > 0) stats.providerNames = providerNames;
1589
+ return stats;
1492
1590
  }
1493
1591
  /** Build the single fenced prefix route serving the /token-total/api JSON RPC.
1494
1592
  * The host mounts it via `ctx.webServer.register` (which takes ONE WebRoute,
@@ -1591,19 +1689,21 @@ function headerOf(item) {
1591
1689
  const row = item;
1592
1690
  return typeof row?.id === "string" && row.id !== "" ? row : null;
1593
1691
  }
1692
+ function inheritedCount(cut) {
1693
+ return typeof cut === "number" && Number.isSafeInteger(cut) && cut >= 0 ? cut : 0;
1694
+ }
1594
1695
  /** Read one stored session's events through whichever persistence seam the
1595
1696
  * host line exposes (alpha open/read/close wins; rc.1 inspect is the tail). */
1596
1697
  async function readEvents(persistence, id, signal) {
1597
1698
  if (typeof persistence.open === "function") {
1598
1699
  const handle = await persistence.open(id, "read", signal === void 0 ? void 0 : { signal });
1599
1700
  try {
1600
- const cut = handle.inheritedEventCount;
1601
- const inheritedCut = typeof cut === "number" && Number.isSafeInteger(cut) && cut >= 0 ? cut : 0;
1701
+ const inheritedCut = inheritedCount(handle.inheritedEventCount);
1602
1702
  const collected = [];
1603
1703
  let offset = 0;
1604
1704
  const PAGE = 500;
1605
1705
  for (;;) {
1606
- if (signal?.aborted) break;
1706
+ signal?.throwIfAborted();
1607
1707
  const slice = await handle.read(offset, PAGE, signal === void 0 ? void 0 : { signal });
1608
1708
  const page = slice && slice.events ? slice.events : [];
1609
1709
  if (page.length === 0) break;
@@ -1622,45 +1722,59 @@ async function readEvents(persistence, id, signal) {
1622
1722
  const inspection = await persistence.inspect(id, signal);
1623
1723
  return {
1624
1724
  events: inspection.events,
1625
- inheritedCut: inspection.inheritedEventCount ?? 0
1725
+ inheritedCut: inheritedCount(inspection.inheritedEventCount)
1626
1726
  };
1627
1727
  }
1628
1728
  throw new Error("no readable persistence seam (need open or inspect)");
1629
1729
  }
1630
1730
  /** 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);
1731
+ async function sumOneSession(persistence, id, signal, live) {
1732
+ signal?.throwIfAborted();
1733
+ const { events, inheritedCut } = typeof live?.snapshotEvents === "function" && typeof live.inheritedEventCount === "number" ? {
1734
+ events: live.snapshotEvents(),
1735
+ inheritedCut: inheritedCount(live.inheritedEventCount)
1736
+ } : await readEvents(persistence, id, signal);
1633
1737
  const fold = new UsageFold();
1634
1738
  const totals = { ...ZERO };
1739
+ const compaction = { ...ZERO };
1635
1740
  for (const ev of events) {
1636
1741
  if (inheritedCut > 0 && typeof ev.seq === "number" && ev.seq < inheritedCut) continue;
1637
1742
  const sample = fold.fold(ev);
1638
1743
  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;
1744
+ const buckets = {
1745
+ uncachedInputTokens: sample.inputTokens,
1746
+ outputTokens: sample.outputTokens,
1747
+ cacheReadTokens: sample.cacheReadTokens,
1748
+ cacheWriteTokens: sample.cacheWriteTokens
1749
+ };
1750
+ add(totals, buckets);
1751
+ if (ev.type === "compaction/summary") add(compaction, buckets);
1643
1752
  }
1644
- return totals;
1753
+ return {
1754
+ totals,
1755
+ compaction
1756
+ };
1645
1757
  }
1646
1758
  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. */
1759
+ /** Short-lived tree reads; any live event can invalidate tree membership. */
1650
1760
  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);
1761
+ /** Any event can introduce a new descendant while an async read is running. */
1762
+ generation = 0;
1763
+ /** Invalidate conservatively: an unseen session may be a newly-created child
1764
+ * of ANY cached root, so membership-only invalidation is not safe. */
1765
+ invalidate(_sessionId) {
1766
+ this.generation++;
1767
+ this.cache.clear();
1656
1768
  }
1657
1769
  /** Resolve the whole session tree rooted at `rootId` and sum its usage.
1658
1770
  * `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. */
1771
+ * the sum. Unreadable logs reject rather than publishing partial totals;
1772
+ * callers can retain their last good display while surfacing the error. */
1661
1773
  async tree(persistence, sessions, rootId, signal) {
1774
+ signal?.throwIfAborted();
1662
1775
  const hit = this.cache.get(rootId);
1663
1776
  if (hit !== void 0 && Date.now() - hit.at < CACHE_TTL_MS) return hit.value;
1777
+ const generation = this.generation;
1664
1778
  const headers = await this.listHeaders(persistence, signal);
1665
1779
  const children = /* @__PURE__ */ new Map();
1666
1780
  for (const header of headers) {
@@ -1670,10 +1784,10 @@ var SessionUsageService = class {
1670
1784
  if (bucket === void 0) children.set(parent, [header.id]);
1671
1785
  else bucket.push(header.id);
1672
1786
  }
1673
- for (const handle of sessions?.list() ?? []) {
1674
- const parent = handle.parentSession;
1787
+ const live = new Map((sessions?.list() ?? []).map((handle) => [handle.id, handle]));
1788
+ for (const handle of live.values()) {
1789
+ const parent = handle.header?.parentSession ?? handle.parentSession;
1675
1790
  if (typeof parent !== "string" || parent === "" || parent === handle.id) continue;
1676
- if (headers.some((h) => h.id === handle.id)) continue;
1677
1791
  const bucket = children.get(parent);
1678
1792
  if (bucket === void 0) children.set(parent, [handle.id]);
1679
1793
  else if (!bucket.includes(handle.id)) bucket.push(handle.id);
@@ -1690,23 +1804,25 @@ var SessionUsageService = class {
1690
1804
  queue.push(child);
1691
1805
  }
1692
1806
  }
1693
- const self = await sumOneSession(persistence, rootId, signal).catch(() => ({ ...ZERO }));
1807
+ const root = await sumOneSession(persistence, rootId, signal, live.get(rootId));
1808
+ const self = root.totals;
1694
1809
  const subagents = { ...ZERO };
1695
- for (const id of descendants) add(subagents, await sumOneSession(persistence, id, signal).catch(() => ({ ...ZERO })));
1810
+ for (const id of descendants) add(subagents, (await sumOneSession(persistence, id, signal, live.get(id))).totals);
1696
1811
  const total = { ...self };
1697
1812
  add(total, subagents);
1698
1813
  const value = {
1699
1814
  self,
1815
+ selfCompaction: root.compaction,
1700
1816
  subagents,
1701
1817
  total,
1702
1818
  subagentSessions: descendants.length
1703
1819
  };
1704
- if (signal?.aborted !== true) {
1820
+ signal?.throwIfAborted();
1821
+ if (generation === this.generation) {
1705
1822
  if (this.cache.size >= CACHE_MAX) this.cache.clear();
1706
1823
  this.cache.set(rootId, {
1707
1824
  at: Date.now(),
1708
- value,
1709
- members: /* @__PURE__ */ new Set([rootId, ...descendants])
1825
+ value
1710
1826
  });
1711
1827
  }
1712
1828
  return value;
@@ -0,0 +1,10 @@
1
+ /** Pure billing helpers shared by the session-tree hook and composer pills. */
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. */
5
+ export declare function combinedTotals(tree: SessionUsageTree, self?: SessionUsageTotals): SessionUsageTotals;
6
+ /** One consistent split for the pill, cache hit rate and every dialog row. */
7
+ export declare function sessionUsageForDisplay(native: SessionUsageTotals | undefined, tree: SessionUsageTree | null): SessionUsageTree;
8
+ export declare function hasAnyTokens(totals: SessionUsageTotals | undefined): boolean;
9
+ export declare function billedTotal(totals: SessionUsageTotals): number;
10
+ export declare function cacheHitPercentOf(totals: SessionUsageTotals): number | null;
@@ -1,24 +1,8 @@
1
1
  import type { SessionUsageTotals, SessionUsageTree } from '../wire.ts';
2
- /** Fold a tree into the flat shape the pill's dialog renders. */
3
- export declare function combinedTotals(tree: SessionUsageTree): SessionUsageTotals;
4
- /** Whether a totals record carries any billable token at all. */
5
- export declare function hasAnyTokens(totals: SessionUsageTotals | undefined): boolean;
6
- /** Total billed tokens of a flat totals record (prompt side + completion). */
7
- export declare function billedTotal(totals: SessionUsageTotals): number;
8
- /** Cache hit rate over the prompt side, or null when nothing was prompted. */
9
- export declare function cacheHitPercentOf(totals: SessionUsageTotals): number | null;
2
+ export { billedTotal, cacheHitPercentOf, combinedTotals, hasAnyTokens } from './session-usage-core.ts';
10
3
  export interface SessionTreeReading {
11
4
  tree: SessionUsageTree | null;
12
- /** main + subagents, or null before the first successful answer. */
5
+ /** Log-based main + subagents. The pill substitutes the live main projection. */
13
6
  totals: SessionUsageTotals | null;
14
7
  }
15
- /**
16
- * Subscribe to one session's whole-tree usage.
17
- *
18
- * `signature` is any string that changes when the native usage figure moves;
19
- * passing it in keeps this hook out of the projection seat and lets the caller
20
- * decide what "a new call settled" means for its own pill. A session that turns
21
- * out to have subagents additionally re-asks on a slow beat, because subagent
22
- * spend lands in a different session log and no native figure moves for it.
23
- */
24
8
  export declare function useSessionUsage(sessionId: string | undefined, signature: string): SessionTreeReading;
@@ -3,10 +3,10 @@
3
3
  * Two sources feed the same fold:
4
4
  *
5
5
  * - live: `session/event` subscription — every committed session event
6
- * reaches this plugin after the fact. `assistant/message` carries the
7
- * step's final `usage` (TokenUsage); `assistant/chunk` carries an early
8
- * `usage` sample from the streaming adapter; `request/context` carries the
9
- * provider/model route. A completed call's usage is attributed to the model
6
+ * reaches this plugin after the fact. Assistant messages expose final usage,
7
+ * and modern message/attempt streams retain raw usage even on failed calls.
8
+ * Legacy `assistant/chunk` reports an early sample; `request/context` carries
9
+ * the provider/model route. A completed call's usage is attributed to the model
10
10
  * the request used — in priority order: the message's own `source`
11
11
  * (authoritative per call), then the session's last-known route, which
12
12
  * comes from observed `request/context` events OR is lazily seeded from
@@ -27,11 +27,10 @@
27
27
  * >= 0.1.2-rc.1, 0 for a non-forked session), and the replay skips it —
28
28
  * the parent's own backfill already counted those events.
29
29
  *
30
- * Idempotence: within ONE session, the same (turn, step) may report usage
31
- * more than once (a streaming sample then the final assistant/message;
32
- * compaction replays). The fold emits each (turn, step) key exactly once and
33
- * keeps the newest values in its own state — see the replaceSample note for
34
- * why the store observably keeps the FIRST emission. Cross-session
30
+ * Idempotence: within ONE session, repeated reports for the same (turn, step)
31
+ * emit only signed bucket corrections: the accumulated usage is last-wins.
32
+ * A started retry closes that replacement slot because its predecessor was a
33
+ * separately billed attempt. Cross-session
35
34
  * (turn, step) collisions are NOT deduped: two sessions both own a turn 1 /
36
35
  * step 1, and each must count. The store's per-day rows accumulate, so
37
36
  * re-folding the same log must not add twice: live events are only folded
@@ -151,6 +151,22 @@ export interface UsageLlmService {
151
151
  id: string;
152
152
  name: string;
153
153
  }[]>;
154
+ /** The provider catalog: route id -> the display name the model selector
155
+ * shows. Synchronous on every host line this plugin supports (the llm
156
+ * runtime's own `listProviders`, mirrored by the dsh-llm service). Optional:
157
+ * a host without it leaves every ranked provider row labelled by its raw
158
+ * historical route id. */
159
+ listProviders?(): readonly {
160
+ id: string;
161
+ name: string;
162
+ }[];
163
+ /** The configured-provider view (the models page's own list), whose
164
+ * `displayName` is the same label. Read as a fallback for a route that
165
+ * `listProviders` omits. */
166
+ listConfigurableProviders?(): readonly {
167
+ provider: string;
168
+ displayName: string;
169
+ }[];
154
170
  }
155
171
  /** The route a session's log records for its model calls (mirror of
156
172
  * dsh-agent-loop RequestContext). */
@@ -167,8 +183,13 @@ export interface UsageSessionHandle {
167
183
  id: string;
168
184
  cwd?: string;
169
185
  createdAt?: number;
170
- /** The parent session a subagent session was delegated from (mirror of the
171
- * SessionHeader field; absent on a root session). */
186
+ /** Modern hosts retain creation metadata on Session.header, not as getters. */
187
+ header?: UsageSessionHeader;
188
+ /** Exact fork prefix, distinct from the resume/construction seed length. */
189
+ inheritedEventCount?: number;
190
+ /** Immutable current live log; avoids the persistence backend's batching lag. */
191
+ snapshotEvents?(): readonly UsageSessionEvent[];
192
+ /** Legacy direct parent; modern hosts expose header.parentSession instead. */
172
193
  parentSession?: string;
173
194
  /** 0 for a root session; the delegation depth for a subagent. */
174
195
  delegationDepth?: number;
@@ -2,6 +2,12 @@
2
2
  * This is query-time metadata: no store wipe/replay is needed, and a provider
3
3
  * rename applies to both the ranking and every trend bucket on refresh. */
4
4
  import type { UsageLlmService } from './context-types.ts';
5
+ /** Resolve provider route ids to the labels the model selector shows. Both
6
+ * catalogs are presentational and synchronous on the host runtime, so this is
7
+ * a plain in-process read rather than a per-request lookup: a missing service,
8
+ * a throwing adapter, or an unexpected promise all resolve to an empty map and
9
+ * every surface falls back to the raw historical route id. */
10
+ export declare function resolveProviderNames(llm?: UsageLlmService): Record<string, string>;
5
11
  /** Resolve each distinct route once per range query. Unknown/deleted providers
6
12
  * fall back to their bare historical model names in aggregateSamples(). */
7
13
  export declare function resolveModelNames(modelRefs: Iterable<string>, llm?: UsageLlmService): Promise<Map<string, string>>;
@@ -26,18 +26,17 @@ import type { UsageSessionPersistence, UsageSessionStore } from './context-types
26
26
  import type { SessionUsageTree } from './wire.ts';
27
27
  export type { SessionUsageTotals, SessionUsageTree } from './wire.ts';
28
28
  export declare class SessionUsageService {
29
- /** Cached trees, each remembering every session id it was built from, so a
30
- * live event on ANY of them (a subagent's own event changes its ancestors'
31
- * totals too) can invalidate exactly the affected trees. */
29
+ /** Short-lived tree reads; any live event can invalidate tree membership. */
32
30
  private cache;
33
- /** Drop every cached tree that counted `sessionId` (its own tree, or any
34
- * ancestor's tree that folded it in as a subagent). Called by the collector
35
- * on each live session event. */
36
- invalidate(sessionId: string): void;
31
+ /** Any event can introduce a new descendant while an async read is running. */
32
+ private generation;
33
+ /** Invalidate conservatively: an unseen session may be a newly-created child
34
+ * of ANY cached root, so membership-only invalidation is not safe. */
35
+ invalidate(_sessionId: string): void;
37
36
  /** Resolve the whole session tree rooted at `rootId` and sum its usage.
38
37
  * `self` is the root's own usage, `subagents` every descendant's, `total`
39
- * the sum. Unknown roots answer zeroed totals rather than throwing: the pill
40
- * must never break the composer. */
38
+ * the sum. Unreadable logs reject rather than publishing partial totals;
39
+ * callers can retain their last good display while surfacing the error. */
41
40
  tree(persistence: UsageSessionPersistence, sessions: UsageSessionStore | undefined, rootId: string, signal?: AbortSignal): Promise<SessionUsageTree>;
42
41
  private listHeaders;
43
42
  }
@@ -29,6 +29,13 @@ export interface ModelTokenUsage {
29
29
  provider: string;
30
30
  /** All source providers; optional for compatibility with older responses. */
31
31
  providers?: string[];
32
+ /** Per-provider token split of this one model, largest first — the ranked
33
+ * row's hover detail ("which providers served this model"). Optional so a
34
+ * pre-cross-breakdown host still renders. */
35
+ providerTokens?: Array<{
36
+ provider: string;
37
+ tokens: number;
38
+ }>;
32
39
  tokens: number;
33
40
  percent: number;
34
41
  /** Cached input tokens this model served (rate numerator). */
@@ -43,6 +50,13 @@ export interface ProviderTokenUsage {
43
50
  percent: number;
44
51
  cacheHit: number;
45
52
  cacheMiss: number;
53
+ /** Per-model token split this provider served, largest first — the ranked
54
+ * row's hover detail ("which models this provider served"). Optional so a
55
+ * pre-cross-breakdown host still renders. */
56
+ modelTokens?: Array<{
57
+ model: string;
58
+ tokens: number;
59
+ }>;
46
60
  }
47
61
  /** The full aggregate the panel renders for one time range. */
48
62
  export interface TokenTotalRange {
@@ -68,6 +82,11 @@ export interface TokenTotalRange {
68
82
  daily: DailyTokenUsage[];
69
83
  models: ModelTokenUsage[];
70
84
  providers: ProviderTokenUsage[];
85
+ /** Provider route id -> the display name the model selector shows. Filled by
86
+ * the host route from the llm service metadata; a provider missing here (a
87
+ * deleted route, an older host) renders as its raw id. Optional so a host
88
+ * that cannot resolve names still serves a valid payload. */
89
+ providerNames?: Record<string, string>;
71
90
  }
72
91
  /** The usage statistics panel aggregate request. */
73
92
  export interface TokenTotalRequest {
@@ -87,8 +106,11 @@ export interface SessionUsageTotals {
87
106
  * The composer stats row renders this so a delegating session shows its whole
88
107
  * spend (the native `tokenUsage` projection only covers the main agent). */
89
108
  export interface SessionUsageTree {
90
- /** The root session's own (main agent) usage. */
109
+ /** The root session's own usage, including logged compaction calls. */
91
110
  self: SessionUsageTotals;
111
+ /** Root-only compaction spend already included in `self`, but absent from
112
+ * the native `tokenUsage` projection. Optional for older host responses. */
113
+ selfCompaction?: SessionUsageTotals;
92
114
  /** Sum over every descendant (subagent) session. */
93
115
  subagents: SessionUsageTotals;
94
116
  /** self + subagents. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-token-total",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "DSH web plugin: token 用量统计看板 + 单会话(主智能体 + 子智能体)用量统计。提供今日/近3天/近7天/近1月/近3月/自定义筛选、六个汇总卡片、活跃热力图、缓存命中率折线与分模型柱状图、按模型/按供应商 Top5 列表。",
5
5
  "icon": "./icon.svg",
6
6
  "type": "module",