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/README.md +3 -3
- package/lib/client.js +273 -147
- package/lib/index.js +159 -43
- package/lib/types/client/session-usage-core.d.ts +10 -0
- package/lib/types/client/use-session-usage.d.ts +2 -18
- package/lib/types/collector.d.ts +8 -9
- package/lib/types/context-types.d.ts +23 -2
- package/lib/types/model-names.d.ts +6 -0
- package/lib/types/session-usage.d.ts +8 -9
- package/lib/types/wire.d.ts +23 -1
- package/package.json +1 -1
- package/src/client/StatsLineEnhanced.tsx +15 -33
- package/src/client/TokenTotalPanel.module.css +72 -5
- package/src/client/TokenTotalPanel.tsx +199 -53
- package/src/client/session-usage-core.ts +58 -0
- package/src/client/use-session-usage.ts +45 -98
- package/src/collector.ts +57 -51
- package/src/context-types.ts +17 -2
- package/src/model-names.ts +34 -0
- package/src/query.ts +61 -5
- package/src/routes.ts +14 -2
- package/src/session-usage.ts +50 -38
- package/src/wire.ts +17 -1
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
|
-
|
|
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)
|
|
766
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
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
|
|
1753
|
+
return {
|
|
1754
|
+
totals,
|
|
1755
|
+
compaction
|
|
1756
|
+
};
|
|
1645
1757
|
}
|
|
1646
1758
|
var SessionUsageService = class {
|
|
1647
|
-
/**
|
|
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
|
-
/**
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
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.
|
|
1660
|
-
*
|
|
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
|
-
|
|
1674
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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;
|
package/lib/types/collector.d.ts
CHANGED
|
@@ -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.
|
|
7
|
-
*
|
|
8
|
-
* `
|
|
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)
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
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
|
-
/**
|
|
171
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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.
|
|
40
|
-
*
|
|
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
|
}
|
package/lib/types/wire.d.ts
CHANGED
|
@@ -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
|
|
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