dsh-token-total 1.0.3 → 1.0.5

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
@@ -80,6 +80,7 @@ function aggregateSamples(samples, filter, granularity = "day") {
80
80
  const from = filter.from;
81
81
  const to = filter.to;
82
82
  const buckets = bucketsInRange(from, to, granularity);
83
+ const all = [...samples];
83
84
  const out = {
84
85
  from,
85
86
  to,
@@ -116,7 +117,8 @@ function aggregateSamples(samples, filter, granularity = "day") {
116
117
  const bucketTurns = /* @__PURE__ */ new Map();
117
118
  const bucketCacheHit = /* @__PURE__ */ new Map();
118
119
  const bucketCacheMiss = /* @__PURE__ */ new Map();
119
- for (const sample of samples) {
120
+ const modelAlias = aliasQualifiedModels(new Set(all.map((sample) => modelKeyOf(sample))));
121
+ for (const sample of all) {
120
122
  const bucket = bucketOf(sample, from, to, granularity);
121
123
  if (bucket === null) continue;
122
124
  if (sample.turn) {
@@ -142,9 +144,9 @@ function aggregateSamples(samples, filter, granularity = "day") {
142
144
  out.cacheHit += sample.cacheReadTokens;
143
145
  const missSide = sample.inputTokens + sample.cacheWriteTokens;
144
146
  out.cacheMiss += missSide;
145
- const modelRef = sample.model && sample.model !== "" ? sample.model : "(unknown)";
146
- const provider = providerOf(modelRef);
147
- const model = normalizeModelName(sample.modelName?.trim() || modelNameOf(modelRef));
147
+ const provider = providerOf(sample.model && sample.model !== "" ? sample.model : "(unknown)");
148
+ const modelBase = modelKeyOf(sample);
149
+ const model = modelAlias.get(modelBase) ?? modelBase;
148
150
  const sources = modelProviders.get(model) ?? /* @__PURE__ */ new Set();
149
151
  sources.add(provider);
150
152
  modelProviders.set(model, sources);
@@ -219,6 +221,47 @@ function aggregateSamples(samples, filter, granularity = "day") {
219
221
  }
220
222
  return out;
221
223
  }
224
+ /** The aggregation key of one sample: the resolved display name when the
225
+ * host resolved one, else the bare model ref — normalized like every
226
+ * display name so the key is stable across routes and refreshes. */
227
+ function modelKeyOf(sample) {
228
+ const modelRef = sample.model && sample.model !== "" ? sample.model : "(unknown)";
229
+ return normalizeModelName(sample.modelName?.trim() || modelNameOf(modelRef));
230
+ }
231
+ /**
232
+ * Qualified-name aliases: "workbuddy-ai-plugin/deepseek-v4.1-flash" and
233
+ * "deepseek-v4.1-flash" are the same model reached through a routing
234
+ * namespace, so a name containing "/" merges onto the LONGEST suffix after a
235
+ * "/" that is itself a model key in this dataset (chains like a/b/c → b/c → c
236
+ * resolve transitively). The merge is dataset-relative on purpose: a slash
237
+ * that names no bare model in the data ("GPT/6.1 sol" beside "gpt-6.1-sol")
238
+ * is a genuinely different name and stays its own row — see the
239
+ * distinct-slashes case in tests/model-aggregation.test.mjs.
240
+ */
241
+ function aliasQualifiedModels(names) {
242
+ const alias = /* @__PURE__ */ new Map();
243
+ for (const name of names) {
244
+ if (!name.includes("/")) continue;
245
+ const parts = name.split("/");
246
+ for (let i = 1; i < parts.length; i++) {
247
+ const suffix = parts.slice(i).join("/");
248
+ if (names.has(suffix)) {
249
+ alias.set(name, suffix);
250
+ break;
251
+ }
252
+ }
253
+ }
254
+ for (const name of [...alias.keys()]) {
255
+ let target = alias.get(name);
256
+ const seen = /* @__PURE__ */ new Set([name]);
257
+ while (alias.has(target) && !seen.has(target)) {
258
+ seen.add(target);
259
+ target = alias.get(target);
260
+ }
261
+ alias.set(name, target);
262
+ }
263
+ return alias;
264
+ }
222
265
  function modelsSorted(totals, cache, sources, splits) {
223
266
  const out = [];
224
267
  for (const [model, tokens] of totals) {
@@ -1452,6 +1495,383 @@ async function resolveModelNames(modelRefs, llm) {
1452
1495
  return names;
1453
1496
  }
1454
1497
  //#endregion
1498
+ //#region src/session-detail.ts
1499
+ const zero = () => ({
1500
+ uncachedInputTokens: 0,
1501
+ outputTokens: 0,
1502
+ cacheReadTokens: 0,
1503
+ cacheWriteTokens: 0
1504
+ });
1505
+ function add$1(a, b) {
1506
+ a.uncachedInputTokens += b.uncachedInputTokens;
1507
+ a.outputTokens += b.outputTokens;
1508
+ a.cacheReadTokens += b.cacheReadTokens;
1509
+ a.cacheWriteTokens += b.cacheWriteTokens;
1510
+ }
1511
+ function usageOf(x) {
1512
+ const u = x?.usage ?? x;
1513
+ return {
1514
+ uncachedInputTokens: Number(u?.inputTokens) || 0,
1515
+ outputTokens: Number(u?.outputTokens) || 0,
1516
+ cacheReadTokens: Number(u?.cacheReadTokens) || 0,
1517
+ cacheWriteTokens: Number(u?.cacheWriteTokens) || 0
1518
+ };
1519
+ }
1520
+ function positive(u) {
1521
+ return u.uncachedInputTokens + u.outputTokens + u.cacheReadTokens + u.cacheWriteTokens > 0;
1522
+ }
1523
+ function routeOf(x) {
1524
+ const source = x?.message?.source ?? x?.source ?? x?.request ?? x?.context ?? x;
1525
+ return {
1526
+ provider: typeof source?.provider === "string" ? source.provider : "",
1527
+ model: typeof source?.model === "string" ? source.model : ""
1528
+ };
1529
+ }
1530
+ function streamUsage(data) {
1531
+ let firstTime;
1532
+ let usage;
1533
+ for (const r of Array.isArray(data?.stream) ? data.stream : []) {
1534
+ const t = typeof r?.time === "number" ? r.time : void 0;
1535
+ if (firstTime === void 0 && t !== void 0 && (r?.type === "chunk" || r?.chunk?.type === "text" || r?.chunk?.type === "content")) firstTime = t;
1536
+ if (r?.type === "chunk" && r?.chunk?.type === "usage") usage = usageOf(r.chunk);
1537
+ }
1538
+ return {
1539
+ usage,
1540
+ firstTime
1541
+ };
1542
+ }
1543
+ function headerOf$1(item) {
1544
+ const x = item;
1545
+ if (x?.header?.id && typeof x.header.id === "string") return x.header;
1546
+ return typeof x?.id === "string" && x.id ? x : null;
1547
+ }
1548
+ async function read(p, id, live) {
1549
+ if (live?.snapshotEvents && typeof live.inheritedEventCount === "number") return {
1550
+ events: live.snapshotEvents(),
1551
+ cut: Math.max(0, live.inheritedEventCount)
1552
+ };
1553
+ if (p.open) {
1554
+ const h = await p.open(id, "read");
1555
+ const out = [];
1556
+ try {
1557
+ for (let off = 0;;) {
1558
+ const page = await h.read(off, 500);
1559
+ if (!page.events.length) break;
1560
+ out.push(...page.events);
1561
+ off += page.events.length;
1562
+ }
1563
+ } finally {
1564
+ await h.close().catch(() => {});
1565
+ }
1566
+ return {
1567
+ events: out,
1568
+ cut: Math.max(0, Number(h.inheritedEventCount) || 0)
1569
+ };
1570
+ }
1571
+ if (p.inspect) {
1572
+ const x = await p.inspect(id);
1573
+ return {
1574
+ events: x.events,
1575
+ cut: Math.max(0, Number(x.inheritedEventCount) || 0)
1576
+ };
1577
+ }
1578
+ throw new Error("no readable persistence seam (need open or inspect)");
1579
+ }
1580
+ function nameOf(h, id) {
1581
+ const x = h;
1582
+ if (typeof x?.name === "string" && x.name) return x.name;
1583
+ if (typeof x?.title === "string" && x.title) return x.title;
1584
+ if (typeof x?.cwd === "string" && x.cwd) return x.cwd.split(/[\\/]/).filter(Boolean).pop() || id;
1585
+ return id;
1586
+ }
1587
+ async function one(p, id, header, live) {
1588
+ const { events, cut } = await read(p, id, live);
1589
+ const fold = new UsageFold();
1590
+ const totals = zero();
1591
+ const records = [];
1592
+ const byKey = /* @__PURE__ */ new Map();
1593
+ let route = live?.requestContext?.() ?? {};
1594
+ let ordinal = 0;
1595
+ let currentTurn = 0;
1596
+ let effort = "";
1597
+ let pendingTopic = "";
1598
+ const turnTopics = /* @__PURE__ */ new Map();
1599
+ const own = events.filter((e) => !(cut > 0 && typeof e.seq === "number" && e.seq < cut));
1600
+ let label = "", title = "";
1601
+ for (const ev of own) {
1602
+ const d = ev.data ?? {};
1603
+ if (!label && ev.type === "subagent/descriptor" && typeof d.label === "string" && d.label.trim()) label = d.label.trim();
1604
+ if (!title && ev.type === "session/title" && typeof d.title === "string" && d.title.trim()) title = d.title.trim();
1605
+ if (label && title) break;
1606
+ }
1607
+ const name = label || title || nameOf(header, id);
1608
+ for (const ev of own) {
1609
+ const d = ev.data ?? {};
1610
+ const turn = Number.isFinite(d.turn) ? d.turn : 0;
1611
+ const step = Number.isFinite(d.step) ? d.step : 0;
1612
+ if (ev.type === "request/context") {
1613
+ route = {
1614
+ ...route,
1615
+ ...routeOf(d)
1616
+ };
1617
+ continue;
1618
+ }
1619
+ const r = routeOf(d);
1620
+ if (r.provider || r.model) route = {
1621
+ ...route,
1622
+ ...r
1623
+ };
1624
+ if (ev.type === "turn/start") {
1625
+ currentTurn = turn;
1626
+ if (pendingTopic && !turnTopics.has(currentTurn)) {
1627
+ turnTopics.set(currentTurn, pendingTopic);
1628
+ pendingTopic = "";
1629
+ }
1630
+ } else if (ev.type === "user/message" && d.source?.kind === "user") {
1631
+ const block = (Array.isArray(d.content) ? d.content : []).find((b) => b?.type === "text" && typeof b.text === "string");
1632
+ const text = String(block?.text ?? "").replace(/\s+/g, " ").trim();
1633
+ if (text) {
1634
+ if (currentTurn > 0 && !turnTopics.has(currentTurn)) turnTopics.set(currentTurn, text);
1635
+ else pendingTopic = text;
1636
+ }
1637
+ } else if (ev.type === "request/header") {
1638
+ const e = d.header?.config?.reasoningEffort;
1639
+ if (typeof e === "string" && e) {
1640
+ effort = e;
1641
+ for (const x of records) if (x.status === "running") x.effort = e;
1642
+ }
1643
+ }
1644
+ let rec;
1645
+ if (ev.type === "step/start" || ev.type === "llm/retry-started") {
1646
+ const key = `${turn}:${step}`;
1647
+ if (ev.type === "llm/retry-started" || !byKey.has(key)) {
1648
+ const previous = byKey.get(key);
1649
+ if (previous && ev.type === "llm/retry-started" && previous.status === "running") {
1650
+ previous.status = "failed";
1651
+ previous.finishedAt = ev.time;
1652
+ }
1653
+ rec = {
1654
+ id: `${id}:${turn}:${step}:${ordinal++}`,
1655
+ sessionId: id,
1656
+ sessionName: name,
1657
+ turn,
1658
+ step,
1659
+ startedAt: ev.time,
1660
+ finishedAt: null,
1661
+ provider: r.provider || route.provider || "",
1662
+ model: r.model || route.model || "",
1663
+ status: "running",
1664
+ totals: zero(),
1665
+ firstTokenMs: null,
1666
+ tokensPerSecond: null,
1667
+ turnName: "",
1668
+ effort,
1669
+ _attempt: ev.type === "llm/retry-started" ? 1 : 0,
1670
+ _lastUsage: zero()
1671
+ };
1672
+ records.push(rec);
1673
+ byKey.set(key, rec);
1674
+ }
1675
+ } else if (ev.type === "assistant/chunk" || ev.type === "assistant/message" || ev.type === "assistant/attempt") {
1676
+ rec = byKey.get(`${turn}:${step}`);
1677
+ if (!rec) {
1678
+ rec = {
1679
+ id: `${id}:${turn}:${step}:${ordinal++}`,
1680
+ sessionId: id,
1681
+ sessionName: name,
1682
+ turn,
1683
+ step,
1684
+ startedAt: ev.time,
1685
+ finishedAt: null,
1686
+ provider: r.provider || route.provider || "",
1687
+ model: r.model || route.model || "",
1688
+ status: "running",
1689
+ totals: zero(),
1690
+ firstTokenMs: null,
1691
+ tokensPerSecond: null,
1692
+ turnName: "",
1693
+ effort,
1694
+ _attempt: 0,
1695
+ _lastUsage: zero()
1696
+ };
1697
+ records.push(rec);
1698
+ byKey.set(`${turn}:${step}`, rec);
1699
+ }
1700
+ if (r.provider) rec.provider = r.provider;
1701
+ if (r.model) rec.model = r.model;
1702
+ const su = streamUsage(d);
1703
+ const u = ev.type === "assistant/message" ? d.usage ? usageOf(d) : su.usage : ev.type === "assistant/chunk" ? d.chunk?.type === "usage" ? usageOf(d.chunk) : void 0 : d.usage ? usageOf(d) : su.usage;
1704
+ if (u) {
1705
+ rec._lastUsage = u;
1706
+ rec.totals = u;
1707
+ if (positive(u) && rec.firstTokenMs === null) {
1708
+ const rawFirst = su.firstTime ?? (typeof d.chunk?.time === "number" ? d.chunk.time : ev.time);
1709
+ rec.firstTokenMs = Math.max(0, rawFirst - rec.startedAt);
1710
+ }
1711
+ }
1712
+ if (ev.type !== "assistant/chunk") {
1713
+ rec.finishedAt = ev.time;
1714
+ rec.status = ev.type === "assistant/attempt" ? "failed" : "completed";
1715
+ }
1716
+ const elapsedMs = rec.finishedAt === null ? null : Math.max(0, rec.finishedAt - rec.startedAt);
1717
+ if (elapsedMs !== null && elapsedMs > 0 && rec.totals.outputTokens > 0) rec.tokensPerSecond = rec.totals.outputTokens / (elapsedMs / 1e3);
1718
+ } else if (ev.type === "turn/end") {
1719
+ const recs = records.filter((x) => x.turn === turn && x.status === "running");
1720
+ for (const x of recs) {
1721
+ x.status = "interrupted";
1722
+ x.finishedAt = ev.time;
1723
+ }
1724
+ } else if (ev.type === "compaction/summary") {
1725
+ const u = usageOf(d);
1726
+ rec = {
1727
+ id: `${id}:compaction:${ordinal++}`,
1728
+ sessionId: id,
1729
+ sessionName: name,
1730
+ turn: 0,
1731
+ step: 0,
1732
+ startedAt: ev.time,
1733
+ finishedAt: ev.time,
1734
+ provider: typeof d.provider === "string" ? d.provider : "",
1735
+ model: typeof d.model === "string" ? d.model : "",
1736
+ status: "completed",
1737
+ totals: u,
1738
+ firstTokenMs: null,
1739
+ tokensPerSecond: null,
1740
+ turnName: "",
1741
+ effort: "",
1742
+ _attempt: 0,
1743
+ _lastUsage: u
1744
+ };
1745
+ records.push(rec);
1746
+ }
1747
+ const sample = fold.fold(ev);
1748
+ if (sample && !sample.turn) add$1(totals, {
1749
+ uncachedInputTokens: sample.inputTokens,
1750
+ outputTokens: sample.outputTokens,
1751
+ cacheReadTokens: sample.cacheReadTokens,
1752
+ cacheWriteTokens: sample.cacheWriteTokens
1753
+ });
1754
+ }
1755
+ const requests = records.length;
1756
+ const firsts = records.map((x) => x.firstTokenMs).filter((x) => x !== null);
1757
+ const outputSeconds = records.reduce((n, x) => n + x.totals.outputTokens, 0);
1758
+ const elapsed = records.reduce((n, x) => n + (x.firstTokenMs ?? 0), 0);
1759
+ for (const x of records) {
1760
+ x.turnName = turnTopics.get(x.turn) ?? "";
1761
+ delete x._attempt;
1762
+ delete x._lastUsage;
1763
+ }
1764
+ return {
1765
+ id,
1766
+ name,
1767
+ header,
1768
+ totals,
1769
+ records,
1770
+ requests,
1771
+ averageFirstTokenMs: firsts.length ? firsts.reduce((a, b) => a + b, 0) / firsts.length : null,
1772
+ tokensPerSecond: elapsed > 0 ? outputSeconds / (elapsed / 1e3) : null
1773
+ };
1774
+ }
1775
+ async function buildSessionUsageDetail(persistence, sessions, rootId, ctx) {
1776
+ const headers = (await persistence.list(typeof persistence.open === "function" ? void 0 : void 0)).map(headerOf$1).filter((x) => x !== null);
1777
+ const live = new Map((sessions?.list() ?? []).map((x) => [x.id, x]));
1778
+ const children = /* @__PURE__ */ new Map();
1779
+ for (const h of headers) if (h.parentSession) children.set(h.parentSession, [...children.get(h.parentSession) ?? [], h.id]);
1780
+ for (const h of live.values()) {
1781
+ const p = h.header?.parentSession ?? h.parentSession;
1782
+ if (p) children.set(p, [...(children.get(p) ?? []).filter((x) => x !== h.id), h.id]);
1783
+ }
1784
+ const ids = [];
1785
+ const seen = /* @__PURE__ */ new Set([rootId]);
1786
+ const q = [rootId];
1787
+ while (q.length) for (const c of children.get(q.shift()) ?? []) if (!seen.has(c)) {
1788
+ seen.add(c);
1789
+ ids.push(c);
1790
+ q.push(c);
1791
+ }
1792
+ const all = [rootId, ...ids];
1793
+ const parts = await Promise.all(all.map((id) => one(persistence, id, headers.find((h) => h.id === id), live.get(id))));
1794
+ const root = parts[0];
1795
+ const totals = zero();
1796
+ const models = /* @__PURE__ */ new Map();
1797
+ const records = parts.flatMap((x) => x.records);
1798
+ for (const p of parts) add$1(totals, p.totals);
1799
+ for (const r of records) {
1800
+ const key = r.model || "(unknown)";
1801
+ const m = models.get(key) ?? {
1802
+ totals: zero(),
1803
+ requests: 0,
1804
+ providers: /* @__PURE__ */ new Set()
1805
+ };
1806
+ add$1(m.totals, r.totals);
1807
+ m.requests++;
1808
+ if (r.provider) m.providers.add(r.provider);
1809
+ models.set(key, m);
1810
+ }
1811
+ const subagents = parts.slice(1).map((p) => ({
1812
+ sessionId: p.id,
1813
+ name: p.name,
1814
+ totals: p.totals,
1815
+ requests: p.requests,
1816
+ averageFirstTokenMs: p.averageFirstTokenMs,
1817
+ tokensPerSecond: p.tokensPerSecond
1818
+ }));
1819
+ const routeMap = /* @__PURE__ */ new Map();
1820
+ for (const r of records) if (r.provider || r.model) {
1821
+ const id = `${r.provider}/${r.model}`;
1822
+ routeMap.set(id, {
1823
+ id,
1824
+ provider: r.provider,
1825
+ name: r.provider,
1826
+ model: r.model,
1827
+ status: r.status === "running" ? "running" : r.status === "failed" ? "failed" : "completed"
1828
+ });
1829
+ }
1830
+ const llm = ctx?.get?.("llm");
1831
+ for (const x of llm?.listProviders?.() ?? []) if (x?.id && !routeMap.has(x.id)) routeMap.set(x.id, {
1832
+ id: x.id,
1833
+ provider: x.id,
1834
+ name: x.name || x.id,
1835
+ model: "",
1836
+ status: "idle"
1837
+ });
1838
+ const providerNames = resolveProviderNames(llm);
1839
+ const refOf = (provider, model) => provider ? `${provider}/${model}` : model;
1840
+ const refs = [];
1841
+ for (const [model, m] of models) if (model !== "(unknown)") {
1842
+ if (m.providers.size) for (const provider of m.providers) refs.push(refOf(provider, model));
1843
+ else refs.push(refOf("", model));
1844
+ }
1845
+ const names = await resolveModelNames(refs, llm);
1846
+ const displayNameOf = (model, providers) => {
1847
+ const keys = providers.size ? [...providers].map((provider) => refOf(provider, model)) : [refOf("", model)];
1848
+ for (const key of keys) {
1849
+ const resolved = names.get(key)?.trim();
1850
+ if (resolved) return resolved;
1851
+ }
1852
+ };
1853
+ return {
1854
+ sessionId: rootId,
1855
+ totals,
1856
+ requests: parts.reduce((n, p) => n + p.requests, 0),
1857
+ averageFirstTokenMs: root.averageFirstTokenMs,
1858
+ tokensPerSecond: root.tokensPerSecond,
1859
+ subagents,
1860
+ models: [...models.entries()].map(([model, x]) => {
1861
+ const name = displayNameOf(model, x.providers);
1862
+ return {
1863
+ model,
1864
+ ...name ? { name } : {},
1865
+ providers: [...x.providers].map((provider) => providerNames[provider] || provider),
1866
+ totals: x.totals,
1867
+ requests: x.requests
1868
+ };
1869
+ }),
1870
+ records,
1871
+ routes: [...routeMap.values()]
1872
+ };
1873
+ }
1874
+ //#endregion
1455
1875
  //#region src/wire.ts
1456
1876
  /** Default cap for a JSON request body. The panel posts tiny objects
1457
1877
  * ({ range, from?, to? }); the cap only bounds a misbehaving trusted
@@ -1789,6 +2209,16 @@ function buildUsageRoute(ctx, deps) {
1789
2209
  });
1790
2210
  return;
1791
2211
  }
2212
+ if (method === "POST" && path === "/token-total/api/session-detail") {
2213
+ const body = await readJsonBody(req);
2214
+ const sessionId = typeof body.sessionId === "string" ? body.sessionId.trim() : "";
2215
+ if (sessionId === "" || sessionId.length > 200) throw new UsageError(400, "token total: session needs a valid sessionId");
2216
+ writeJson(res, {
2217
+ ok: true,
2218
+ value: await buildSessionUsageDetail(deps.persistence(), deps.sessions(), sessionId, ctx)
2219
+ });
2220
+ return;
2221
+ }
1792
2222
  if (method === "POST" && path === "/token-total/api/status") {
1793
2223
  writeJson(res, {
1794
2224
  ok: true,
@@ -0,0 +1,14 @@
1
+ import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots';
2
+ import type { SessionUsageDetail } from '../wire.ts';
3
+ import { SESSION_USAGE_NS, type SessionUsageKey } from './session-usage-locales.ts';
4
+ type T = (key: SessionUsageKey) => string;
5
+ export type SessionUsageViewProps = PropsLocale<typeof SESSION_USAGE_NS> & {
6
+ sessionId: string;
7
+ };
8
+ /** Pure presentation export also used by offline visual regression fixtures. */
9
+ export declare function SessionUsageContent({ detail, t }: {
10
+ detail: SessionUsageDetail;
11
+ t: T;
12
+ }): JSX.Element;
13
+ export declare function SessionUsageView({ sessionId, t }: SessionUsageViewProps): JSX.Element;
14
+ export {};
@@ -11,7 +11,7 @@
11
11
  * prefix-stripping proxy; the host's own route key stays absolute
12
12
  * (`/token-total/api` in src/routes.ts), because only the browser half is relative.
13
13
  */
14
- import type { BackfillStatus, SessionUsageTree, TokenTotalRange, TokenTotalRequest } from '../wire.ts';
14
+ import type { BackfillStatus, SessionUsageDetail, SessionUsageTree, TokenTotalRange, TokenTotalRequest } from '../wire.ts';
15
15
  /** One wire failure. */
16
16
  export declare class UsageApiError extends Error {
17
17
  readonly code: string;
@@ -29,3 +29,5 @@ export declare function rebuildStats(): Promise<BackfillStatus>;
29
29
  * covers only the main agent, so this is what makes a delegating session's
30
30
  * cost visible. */
31
31
  export declare function fetchSessionUsage(sessionId: string): Promise<SessionUsageTree>;
32
+ /** Log-owned requests and live route states for the per-session Usage view. */
33
+ export declare function fetchSessionDetail(sessionId: string, signal?: AbortSignal): Promise<SessionUsageDetail>;
@@ -7,7 +7,7 @@
7
7
  * from the shared zero-dependency module so the host and client halves share
8
8
  * one implementation.
9
9
  */
10
- export { providerOf, modelNameOf, daysInRange as daysBetween } from '../shared.ts';
10
+ export { providerOf, modelNameOf, normalizeModelName, daysInRange as daysBetween } from '../shared.ts';
11
11
  /** Exact token counts with thousands separators — no 万/亿/k/M units, the
12
12
  * user reads the precise number (long values shrink to fit via FitText).
13
13
  * Compact units stay on the axis labels and card sub-values only. */
@@ -25,10 +25,14 @@ export declare function localDay(offsetDays: number): string;
25
25
  /** 0 = Monday … 6 = Sunday (GitHub-style heatmap rows). */
26
26
  export declare function indexOfDay(day: string): number;
27
27
  export declare function shortDay(day: string): string;
28
- /** X-axis label for a trend bucket key. Day mode keeps the short date; hour
29
- * mode names the day at 00:00 (so a three-day span keeps its day boundaries
30
- * readable) and the hour everywhere else. */
31
- export declare function bucketAxisLabel(key: string, granularity: 'day' | 'hour'): string;
28
+ /** X-axis label for a trend bucket key. `mode` is what the axis READS AS,
29
+ * chosen by the caller from the window it covers:
30
+ * - 'hour' — a single-day hourly span (今日): the date is constant, so the
31
+ * axis shows the bare clock ("8:00");
32
+ * - 'day' — anything spanning several days (3天 hourly, any daily range):
33
+ * the axis shows the short date ("10/22"); on multi-day hourly spans one
34
+ * label marks each day. The tooltip still carries the full date + hour. */
35
+ export declare function bucketAxisLabel(key: string, mode: 'day' | 'hour'): string;
32
36
  /** Tooltip title for a trend bucket key: the date in day mode, date + hour
33
37
  * in hour mode. */
34
38
  export declare function bucketTitle(key: string, granularity: 'day' | 'hour'): string;
@@ -21,6 +21,7 @@
21
21
  import type { Context, UsagePluginConfigOwnerProps } from '../context-types.ts';
22
22
  import type { PropsLocale, PropsRuntime, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots';
23
23
  import { LOCALE_NS, type TokenTotalKey } from './locales.ts';
24
+ import { type SessionUsageKey } from './session-usage-locales.ts';
24
25
  /** This bundle's npm package name — the key the Plugins page matches on. */
25
26
  export declare const BUNDLE_NAME = "dsh-token-total";
26
27
  /** The id shared by the sidebar row and the main panel it selects. */
@@ -36,6 +37,7 @@ export type TokenTotalPanelPageProps = PropsLocale<typeof LOCALE_NS> & {
36
37
  declare module '@deepseek-ai/dsh-client-ui-slots' {
37
38
  interface LocaleNamespaceMap {
38
39
  'tokenTotal': TokenTotalKey;
40
+ 'sessionUsage': SessionUsageKey;
39
41
  }
40
42
  }
41
43
  export declare const inject: string[];
@@ -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' | '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';
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' | 'dow.mon' | 'dow.tue' | 'dow.wed' | 'dow.thu' | 'dow.fri' | 'dow.sat' | 'dow.sun' | 'month.jan' | 'month.feb' | 'month.mar' | 'month.apr' | 'month.may' | 'month.jun' | 'month.jul' | 'month.aug' | 'month.sep' | 'month.oct' | 'month.nov' | 'month.dec' | '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>;
@@ -0,0 +1,62 @@
1
+ /** Session Usage view translations, independent of the global dashboard. */
2
+ export declare const SESSION_USAGE_NS = "sessionUsage";
3
+ export declare const usageZh: {
4
+ tab: string;
5
+ requests: string;
6
+ tokens: string;
7
+ hit: string;
8
+ first: string;
9
+ speed: string;
10
+ input: string;
11
+ output: string;
12
+ read: string;
13
+ write: string;
14
+ requestsCount: string;
15
+ inputFull: string;
16
+ outputFull: string;
17
+ readFull: string;
18
+ writeFull: string;
19
+ cacheHit: string;
20
+ compaction: string;
21
+ reqTimes: string;
22
+ children: string;
23
+ session: string;
24
+ models: string;
25
+ model: string;
26
+ records: string;
27
+ expand: string;
28
+ collapse: string;
29
+ loading: string;
30
+ empty: string;
31
+ noChildren: string;
32
+ noModels: string;
33
+ scope: string;
34
+ note: string;
35
+ failedLoad: string;
36
+ retry: string;
37
+ completed: string;
38
+ running: string;
39
+ failed: string;
40
+ interrupted: string;
41
+ main: string;
42
+ turn: string;
43
+ step: string;
44
+ started: string;
45
+ duration: string;
46
+ firstDetail: string;
47
+ provider: string;
48
+ status: string;
49
+ exact: string;
50
+ answering: string;
51
+ firstWait: string;
52
+ sec: string;
53
+ tokenWord: string;
54
+ perSec: string;
55
+ cacheShort: string;
56
+ avgCache: string;
57
+ avgRate: string;
58
+ inProgress: string;
59
+ };
60
+ export type SessionUsageKey = keyof typeof usageZh;
61
+ export declare const usageEn: Record<SessionUsageKey, string>;
62
+ export declare const usageZhTW: Record<SessionUsageKey, string>;
@@ -0,0 +1,8 @@
1
+ import type { SessionUsageDetail } from '../wire.ts';
2
+ export declare const DETAIL_POLL_MS = 1500;
3
+ export declare function useSessionDetail(sessionId: string): {
4
+ detail: SessionUsageDetail | null;
5
+ loading: boolean;
6
+ error: boolean;
7
+ retry: () => void;
8
+ };
@@ -15,7 +15,7 @@ import type { UsageWebRoute } from './context-types.ts';
15
15
  import type { UsageStore } from './store.ts';
16
16
  import type { UsageCollector } from './collector.ts';
17
17
  import type { Granularity } from './query.ts';
18
- import type { BackfillStatus, SessionUsageRequest, TokenTotalRange, TokenTotalRequest } from './wire.ts';
18
+ import type { BackfillStatus, SessionUsageDetail, SessionUsageRequest, TokenTotalRange, TokenTotalRequest } from './wire.ts';
19
19
  import type { SessionUsageService } from './session-usage.ts';
20
20
  import { type TrustInputs } from './trust-fence.ts';
21
21
  export interface RoutesDeps {
@@ -55,4 +55,4 @@ export declare function aggregateRange(store: UsageStore, from: string, to: stri
55
55
  * The host mounts it via `ctx.webServer.register` (which takes ONE WebRoute,
56
56
  * not an array 鈥?matching the real dsh-host-webserver contract). */
57
57
  export declare function buildUsageRoute(ctx: Context, deps: RoutesDeps): UsageWebRoute;
58
- export type { BackfillStatus, TokenTotalRange, TokenTotalRequest, SessionUsageRequest };
58
+ export type { BackfillStatus, TokenTotalRange, TokenTotalRequest, SessionUsageRequest, SessionUsageDetail };
@@ -0,0 +1,3 @@
1
+ import type { Context, UsageSessionPersistence, UsageSessionStore } from './context-types.ts';
2
+ import type { SessionUsageDetail } from './wire.ts';
3
+ export declare function buildSessionUsageDetail(persistence: UsageSessionPersistence, sessions: UsageSessionStore | undefined, rootId: string, ctx?: Context): Promise<SessionUsageDetail>;