dsh-token-total 1.0.4 → 1.0.6

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
@@ -1400,6 +1400,8 @@ var UsageCollector = class UsageCollector {
1400
1400
  const clean = !signal?.aborted && this.status.error === void 0;
1401
1401
  if ((target === "hours" || dayCursorWasEmpty) && clean) await this.store.markHoursReady();
1402
1402
  if ((target === "failures" || dayCursorWasEmpty) && clean) await this.store.markFailuresReady();
1403
+ } catch (err) {
1404
+ this.status.error = err instanceof Error ? err.message : String(err);
1403
1405
  } finally {
1404
1406
  this.status.running = false;
1405
1407
  }
@@ -1495,6 +1497,383 @@ async function resolveModelNames(modelRefs, llm) {
1495
1497
  return names;
1496
1498
  }
1497
1499
  //#endregion
1500
+ //#region src/session-detail.ts
1501
+ const zero = () => ({
1502
+ uncachedInputTokens: 0,
1503
+ outputTokens: 0,
1504
+ cacheReadTokens: 0,
1505
+ cacheWriteTokens: 0
1506
+ });
1507
+ function add$1(a, b) {
1508
+ a.uncachedInputTokens += b.uncachedInputTokens;
1509
+ a.outputTokens += b.outputTokens;
1510
+ a.cacheReadTokens += b.cacheReadTokens;
1511
+ a.cacheWriteTokens += b.cacheWriteTokens;
1512
+ }
1513
+ function usageOf(x) {
1514
+ const u = x?.usage ?? x;
1515
+ return {
1516
+ uncachedInputTokens: Number(u?.inputTokens) || 0,
1517
+ outputTokens: Number(u?.outputTokens) || 0,
1518
+ cacheReadTokens: Number(u?.cacheReadTokens) || 0,
1519
+ cacheWriteTokens: Number(u?.cacheWriteTokens) || 0
1520
+ };
1521
+ }
1522
+ function positive(u) {
1523
+ return u.uncachedInputTokens + u.outputTokens + u.cacheReadTokens + u.cacheWriteTokens > 0;
1524
+ }
1525
+ function routeOf(x) {
1526
+ const source = x?.message?.source ?? x?.source ?? x?.request ?? x?.context ?? x;
1527
+ return {
1528
+ provider: typeof source?.provider === "string" ? source.provider : "",
1529
+ model: typeof source?.model === "string" ? source.model : ""
1530
+ };
1531
+ }
1532
+ function streamUsage(data) {
1533
+ let firstTime;
1534
+ let usage;
1535
+ for (const r of Array.isArray(data?.stream) ? data.stream : []) {
1536
+ const t = typeof r?.time === "number" ? r.time : void 0;
1537
+ if (firstTime === void 0 && t !== void 0 && (r?.type === "chunk" || r?.chunk?.type === "text" || r?.chunk?.type === "content")) firstTime = t;
1538
+ if (r?.type === "chunk" && r?.chunk?.type === "usage") usage = usageOf(r.chunk);
1539
+ }
1540
+ return {
1541
+ usage,
1542
+ firstTime
1543
+ };
1544
+ }
1545
+ function headerOf$1(item) {
1546
+ const x = item;
1547
+ if (x?.header?.id && typeof x.header.id === "string") return x.header;
1548
+ return typeof x?.id === "string" && x.id ? x : null;
1549
+ }
1550
+ async function read(p, id, live) {
1551
+ if (live?.snapshotEvents && typeof live.inheritedEventCount === "number") return {
1552
+ events: live.snapshotEvents(),
1553
+ cut: Math.max(0, live.inheritedEventCount)
1554
+ };
1555
+ if (p.open) {
1556
+ const h = await p.open(id, "read");
1557
+ const out = [];
1558
+ try {
1559
+ for (let off = 0;;) {
1560
+ const page = await h.read(off, 500);
1561
+ if (!page.events.length) break;
1562
+ out.push(...page.events);
1563
+ off += page.events.length;
1564
+ }
1565
+ } finally {
1566
+ await h.close().catch(() => {});
1567
+ }
1568
+ return {
1569
+ events: out,
1570
+ cut: Math.max(0, Number(h.inheritedEventCount) || 0)
1571
+ };
1572
+ }
1573
+ if (p.inspect) {
1574
+ const x = await p.inspect(id);
1575
+ return {
1576
+ events: x.events,
1577
+ cut: Math.max(0, Number(x.inheritedEventCount) || 0)
1578
+ };
1579
+ }
1580
+ throw new Error("no readable persistence seam (need open or inspect)");
1581
+ }
1582
+ function nameOf(h, id) {
1583
+ const x = h;
1584
+ if (typeof x?.name === "string" && x.name) return x.name;
1585
+ if (typeof x?.title === "string" && x.title) return x.title;
1586
+ if (typeof x?.cwd === "string" && x.cwd) return x.cwd.split(/[\\/]/).filter(Boolean).pop() || id;
1587
+ return id;
1588
+ }
1589
+ async function one(p, id, header, live) {
1590
+ const { events, cut } = await read(p, id, live);
1591
+ const fold = new UsageFold();
1592
+ const totals = zero();
1593
+ const records = [];
1594
+ const byKey = /* @__PURE__ */ new Map();
1595
+ let route = live?.requestContext?.() ?? {};
1596
+ let ordinal = 0;
1597
+ let currentTurn = 0;
1598
+ let effort = "";
1599
+ let pendingTopic = "";
1600
+ const turnTopics = /* @__PURE__ */ new Map();
1601
+ const own = events.filter((e) => !(cut > 0 && typeof e.seq === "number" && e.seq < cut));
1602
+ let label = "", title = "";
1603
+ for (const ev of own) {
1604
+ const d = ev.data ?? {};
1605
+ if (!label && ev.type === "subagent/descriptor" && typeof d.label === "string" && d.label.trim()) label = d.label.trim();
1606
+ if (!title && ev.type === "session/title" && typeof d.title === "string" && d.title.trim()) title = d.title.trim();
1607
+ if (label && title) break;
1608
+ }
1609
+ const name = label || title || nameOf(header, id);
1610
+ for (const ev of own) {
1611
+ const d = ev.data ?? {};
1612
+ const turn = Number.isFinite(d.turn) ? d.turn : 0;
1613
+ const step = Number.isFinite(d.step) ? d.step : 0;
1614
+ if (ev.type === "request/context") {
1615
+ route = {
1616
+ ...route,
1617
+ ...routeOf(d)
1618
+ };
1619
+ continue;
1620
+ }
1621
+ const r = routeOf(d);
1622
+ if (r.provider || r.model) route = {
1623
+ ...route,
1624
+ ...r
1625
+ };
1626
+ if (ev.type === "turn/start") {
1627
+ currentTurn = turn;
1628
+ if (pendingTopic && !turnTopics.has(currentTurn)) {
1629
+ turnTopics.set(currentTurn, pendingTopic);
1630
+ pendingTopic = "";
1631
+ }
1632
+ } else if (ev.type === "user/message" && d.source?.kind === "user") {
1633
+ const block = (Array.isArray(d.content) ? d.content : []).find((b) => b?.type === "text" && typeof b.text === "string");
1634
+ const text = String(block?.text ?? "").replace(/\s+/g, " ").trim();
1635
+ if (text) {
1636
+ if (currentTurn > 0 && !turnTopics.has(currentTurn)) turnTopics.set(currentTurn, text);
1637
+ else pendingTopic = text;
1638
+ }
1639
+ } else if (ev.type === "request/header") {
1640
+ const e = d.header?.config?.reasoningEffort;
1641
+ if (typeof e === "string" && e) {
1642
+ effort = e;
1643
+ for (const x of records) if (x.status === "running") x.effort = e;
1644
+ }
1645
+ }
1646
+ let rec;
1647
+ if (ev.type === "step/start" || ev.type === "llm/retry-started") {
1648
+ const key = `${turn}:${step}`;
1649
+ if (ev.type === "llm/retry-started" || !byKey.has(key)) {
1650
+ const previous = byKey.get(key);
1651
+ if (previous && ev.type === "llm/retry-started" && previous.status === "running") {
1652
+ previous.status = "failed";
1653
+ previous.finishedAt = ev.time;
1654
+ }
1655
+ rec = {
1656
+ id: `${id}:${turn}:${step}:${ordinal++}`,
1657
+ sessionId: id,
1658
+ sessionName: name,
1659
+ turn,
1660
+ step,
1661
+ startedAt: ev.time,
1662
+ finishedAt: null,
1663
+ provider: r.provider || route.provider || "",
1664
+ model: r.model || route.model || "",
1665
+ status: "running",
1666
+ totals: zero(),
1667
+ firstTokenMs: null,
1668
+ tokensPerSecond: null,
1669
+ turnName: "",
1670
+ effort,
1671
+ _attempt: ev.type === "llm/retry-started" ? 1 : 0,
1672
+ _lastUsage: zero()
1673
+ };
1674
+ records.push(rec);
1675
+ byKey.set(key, rec);
1676
+ }
1677
+ } else if (ev.type === "assistant/chunk" || ev.type === "assistant/message" || ev.type === "assistant/attempt") {
1678
+ rec = byKey.get(`${turn}:${step}`);
1679
+ if (!rec) {
1680
+ rec = {
1681
+ id: `${id}:${turn}:${step}:${ordinal++}`,
1682
+ sessionId: id,
1683
+ sessionName: name,
1684
+ turn,
1685
+ step,
1686
+ startedAt: ev.time,
1687
+ finishedAt: null,
1688
+ provider: r.provider || route.provider || "",
1689
+ model: r.model || route.model || "",
1690
+ status: "running",
1691
+ totals: zero(),
1692
+ firstTokenMs: null,
1693
+ tokensPerSecond: null,
1694
+ turnName: "",
1695
+ effort,
1696
+ _attempt: 0,
1697
+ _lastUsage: zero()
1698
+ };
1699
+ records.push(rec);
1700
+ byKey.set(`${turn}:${step}`, rec);
1701
+ }
1702
+ if (r.provider) rec.provider = r.provider;
1703
+ if (r.model) rec.model = r.model;
1704
+ const su = streamUsage(d);
1705
+ 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;
1706
+ if (u) {
1707
+ rec._lastUsage = u;
1708
+ rec.totals = u;
1709
+ if (positive(u) && rec.firstTokenMs === null) {
1710
+ const rawFirst = su.firstTime ?? (typeof d.chunk?.time === "number" ? d.chunk.time : ev.time);
1711
+ rec.firstTokenMs = Math.max(0, rawFirst - rec.startedAt);
1712
+ }
1713
+ }
1714
+ if (ev.type !== "assistant/chunk") {
1715
+ rec.finishedAt = ev.time;
1716
+ rec.status = ev.type === "assistant/attempt" ? "failed" : "completed";
1717
+ }
1718
+ const elapsedMs = rec.finishedAt === null ? null : Math.max(0, rec.finishedAt - rec.startedAt);
1719
+ if (elapsedMs !== null && elapsedMs > 0 && rec.totals.outputTokens > 0) rec.tokensPerSecond = rec.totals.outputTokens / (elapsedMs / 1e3);
1720
+ } else if (ev.type === "turn/end") {
1721
+ const recs = records.filter((x) => x.turn === turn && x.status === "running");
1722
+ for (const x of recs) {
1723
+ x.status = "interrupted";
1724
+ x.finishedAt = ev.time;
1725
+ }
1726
+ } else if (ev.type === "compaction/summary") {
1727
+ const u = usageOf(d);
1728
+ rec = {
1729
+ id: `${id}:compaction:${ordinal++}`,
1730
+ sessionId: id,
1731
+ sessionName: name,
1732
+ turn: 0,
1733
+ step: 0,
1734
+ startedAt: ev.time,
1735
+ finishedAt: ev.time,
1736
+ provider: typeof d.provider === "string" ? d.provider : "",
1737
+ model: typeof d.model === "string" ? d.model : "",
1738
+ status: "completed",
1739
+ totals: u,
1740
+ firstTokenMs: null,
1741
+ tokensPerSecond: null,
1742
+ turnName: "",
1743
+ effort: "",
1744
+ _attempt: 0,
1745
+ _lastUsage: u
1746
+ };
1747
+ records.push(rec);
1748
+ }
1749
+ const sample = fold.fold(ev);
1750
+ if (sample && !sample.turn) add$1(totals, {
1751
+ uncachedInputTokens: sample.inputTokens,
1752
+ outputTokens: sample.outputTokens,
1753
+ cacheReadTokens: sample.cacheReadTokens,
1754
+ cacheWriteTokens: sample.cacheWriteTokens
1755
+ });
1756
+ }
1757
+ const requests = records.length;
1758
+ const firsts = records.map((x) => x.firstTokenMs).filter((x) => x !== null);
1759
+ const outputSeconds = records.reduce((n, x) => n + x.totals.outputTokens, 0);
1760
+ const elapsed = records.reduce((n, x) => n + (x.firstTokenMs ?? 0), 0);
1761
+ for (const x of records) {
1762
+ x.turnName = turnTopics.get(x.turn) ?? "";
1763
+ delete x._attempt;
1764
+ delete x._lastUsage;
1765
+ }
1766
+ return {
1767
+ id,
1768
+ name,
1769
+ header,
1770
+ totals,
1771
+ records,
1772
+ requests,
1773
+ averageFirstTokenMs: firsts.length ? firsts.reduce((a, b) => a + b, 0) / firsts.length : null,
1774
+ tokensPerSecond: elapsed > 0 ? outputSeconds / (elapsed / 1e3) : null
1775
+ };
1776
+ }
1777
+ async function buildSessionUsageDetail(persistence, sessions, rootId, ctx) {
1778
+ const headers = (await persistence.list(typeof persistence.open === "function" ? void 0 : void 0)).map(headerOf$1).filter((x) => x !== null);
1779
+ const live = new Map((sessions?.list() ?? []).map((x) => [x.id, x]));
1780
+ const children = /* @__PURE__ */ new Map();
1781
+ for (const h of headers) if (h.parentSession) children.set(h.parentSession, [...children.get(h.parentSession) ?? [], h.id]);
1782
+ for (const h of live.values()) {
1783
+ const p = h.header?.parentSession ?? h.parentSession;
1784
+ if (p) children.set(p, [...(children.get(p) ?? []).filter((x) => x !== h.id), h.id]);
1785
+ }
1786
+ const ids = [];
1787
+ const seen = /* @__PURE__ */ new Set([rootId]);
1788
+ const q = [rootId];
1789
+ while (q.length) for (const c of children.get(q.shift()) ?? []) if (!seen.has(c)) {
1790
+ seen.add(c);
1791
+ ids.push(c);
1792
+ q.push(c);
1793
+ }
1794
+ const all = [rootId, ...ids];
1795
+ const parts = await Promise.all(all.map((id) => one(persistence, id, headers.find((h) => h.id === id), live.get(id))));
1796
+ const root = parts[0];
1797
+ const totals = zero();
1798
+ const models = /* @__PURE__ */ new Map();
1799
+ const records = parts.flatMap((x) => x.records);
1800
+ for (const p of parts) add$1(totals, p.totals);
1801
+ for (const r of records) {
1802
+ const key = r.model || "(unknown)";
1803
+ const m = models.get(key) ?? {
1804
+ totals: zero(),
1805
+ requests: 0,
1806
+ providers: /* @__PURE__ */ new Set()
1807
+ };
1808
+ add$1(m.totals, r.totals);
1809
+ m.requests++;
1810
+ if (r.provider) m.providers.add(r.provider);
1811
+ models.set(key, m);
1812
+ }
1813
+ const subagents = parts.slice(1).map((p) => ({
1814
+ sessionId: p.id,
1815
+ name: p.name,
1816
+ totals: p.totals,
1817
+ requests: p.requests,
1818
+ averageFirstTokenMs: p.averageFirstTokenMs,
1819
+ tokensPerSecond: p.tokensPerSecond
1820
+ }));
1821
+ const routeMap = /* @__PURE__ */ new Map();
1822
+ for (const r of records) if (r.provider || r.model) {
1823
+ const id = `${r.provider}/${r.model}`;
1824
+ routeMap.set(id, {
1825
+ id,
1826
+ provider: r.provider,
1827
+ name: r.provider,
1828
+ model: r.model,
1829
+ status: r.status === "running" ? "running" : r.status === "failed" ? "failed" : "completed"
1830
+ });
1831
+ }
1832
+ const llm = ctx?.get?.("llm");
1833
+ for (const x of llm?.listProviders?.() ?? []) if (x?.id && !routeMap.has(x.id)) routeMap.set(x.id, {
1834
+ id: x.id,
1835
+ provider: x.id,
1836
+ name: x.name || x.id,
1837
+ model: "",
1838
+ status: "idle"
1839
+ });
1840
+ const providerNames = resolveProviderNames(llm);
1841
+ const refOf = (provider, model) => provider ? `${provider}/${model}` : model;
1842
+ const refs = [];
1843
+ for (const [model, m] of models) if (model !== "(unknown)") {
1844
+ if (m.providers.size) for (const provider of m.providers) refs.push(refOf(provider, model));
1845
+ else refs.push(refOf("", model));
1846
+ }
1847
+ const names = await resolveModelNames(refs, llm);
1848
+ const displayNameOf = (model, providers) => {
1849
+ const keys = providers.size ? [...providers].map((provider) => refOf(provider, model)) : [refOf("", model)];
1850
+ for (const key of keys) {
1851
+ const resolved = names.get(key)?.trim();
1852
+ if (resolved) return resolved;
1853
+ }
1854
+ };
1855
+ return {
1856
+ sessionId: rootId,
1857
+ totals,
1858
+ requests: parts.reduce((n, p) => n + p.requests, 0),
1859
+ averageFirstTokenMs: root.averageFirstTokenMs,
1860
+ tokensPerSecond: root.tokensPerSecond,
1861
+ subagents,
1862
+ models: [...models.entries()].map(([model, x]) => {
1863
+ const name = displayNameOf(model, x.providers);
1864
+ return {
1865
+ model,
1866
+ ...name ? { name } : {},
1867
+ providers: [...x.providers].map((provider) => providerNames[provider] || provider),
1868
+ totals: x.totals,
1869
+ requests: x.requests
1870
+ };
1871
+ }),
1872
+ records,
1873
+ routes: [...routeMap.values()]
1874
+ };
1875
+ }
1876
+ //#endregion
1498
1877
  //#region src/wire.ts
1499
1878
  /** Default cap for a JSON request body. The panel posts tiny objects
1500
1879
  * ({ range, from?, to? }); the cap only bounds a misbehaving trusted
@@ -1832,6 +2211,16 @@ function buildUsageRoute(ctx, deps) {
1832
2211
  });
1833
2212
  return;
1834
2213
  }
2214
+ if (method === "POST" && path === "/token-total/api/session-detail") {
2215
+ const body = await readJsonBody(req);
2216
+ const sessionId = typeof body.sessionId === "string" ? body.sessionId.trim() : "";
2217
+ if (sessionId === "" || sessionId.length > 200) throw new UsageError(400, "token total: session needs a valid sessionId");
2218
+ writeJson(res, {
2219
+ ok: true,
2220
+ value: await buildSessionUsageDetail(deps.persistence(), deps.sessions(), sessionId, ctx)
2221
+ });
2222
+ return;
2223
+ }
1835
2224
  if (method === "POST" && path === "/token-total/api/status") {
1836
2225
  writeJson(res, {
1837
2226
  ok: true,
@@ -2191,7 +2580,9 @@ function apply(ctx, config = {}) {
2191
2580
  await rescan();
2192
2581
  await ensureHourHistory();
2193
2582
  await ensureFailureHistory();
2194
- })();
2583
+ })().catch((err) => {
2584
+ ctx.logger?.warn(`[dsh-token-total] boot scan failed: ${err instanceof Error ? err.message : String(err)}`);
2585
+ });
2195
2586
  return () => {
2196
2587
  scanController?.abort();
2197
2588
  };
@@ -0,0 +1,18 @@
1
+ export interface ModelLogoProps {
2
+ /** Raw model id; may itself be a "provider/model" ref. */
3
+ model: string;
4
+ /** Resolved model display name (the by-model row's label) — matched with
5
+ * the raw id, so an opaque route id beside a brandful name still brands. */
6
+ name?: string;
7
+ /** Provider route id — a secondary match signal for opaque model ids. */
8
+ provider?: string;
9
+ /** Resolved provider display names (the by-model row's split), matched last. */
10
+ providers?: ReadonlyArray<string>;
11
+ /** Rendered square size; the record rows read at 13px. */
12
+ size?: number;
13
+ className?: string;
14
+ }
15
+ /** One model's brand logo inline. The markup is a bundled constant (never
16
+ * request data) rendered as-is so each brand keeps its own gradients,
17
+ * circles and multi-path fills; ids inside stay unique across brands. */
18
+ export declare function ModelLogo({ model, name, provider, providers, size, className }: ModelLogoProps): JSX.Element;
@@ -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. */
@@ -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[];
@@ -0,0 +1,11 @@
1
+ /** Brand ids in match order — every id here must ship markup. */
2
+ export declare const MODEL_LOGO_BRANDS: ReadonlyArray<string>;
3
+ /** Resolve the brand id for one usage record. The model's own naming (raw
4
+ * model id plus its resolved display name) decides the brand when it can;
5
+ * the provider fields (route id and provider display names) are the fallback
6
+ * for opaque model ids, so a Llama served through a vertex/google route keeps
7
+ * the Llama mark. Empty/undefined parts are ignored; null when no bundled
8
+ * brand matches. */
9
+ export declare function resolveModelLogo(provider: string | null | undefined, model: string | null | undefined, name?: string | null, providers?: ReadonlyArray<string>): string | null;
10
+ /** Inner SVG markup of a brand's logo, or undefined for an unknown id. */
11
+ export declare function modelLogoMarkup(id: string | null): string | undefined;
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Official per-brand logos for the usage panel: the inner markup of each
3
+ * brand's 24x24 SVG, extracted verbatim from @lobehub/icons-static-svg
4
+ * (MIT). Regenerate from .tmp/gen-logos.mjs after refreshing .tmp/logos/;
5
+ * brand colors are baked in, so no fill follows currentColor. Brand
6
+ * matching lives in model-logos-core.ts.
7
+ */
8
+ export declare const MODEL_LOGOS: Readonly<Record<string, 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>;
@@ -129,6 +129,61 @@ export interface SessionUsageTree {
129
129
  export interface SessionUsageRequest {
130
130
  sessionId: string;
131
131
  }
132
+ /** Detailed per-session usage view used by the Usage tab. */
133
+ export interface SessionUsageDetail {
134
+ sessionId: string;
135
+ totals: SessionUsageTotals;
136
+ requests: number;
137
+ averageFirstTokenMs: number | null;
138
+ tokensPerSecond: number | null;
139
+ subagents: Array<{
140
+ sessionId: string;
141
+ name: string;
142
+ totals: SessionUsageTotals;
143
+ requests: number;
144
+ averageFirstTokenMs: number | null;
145
+ tokensPerSecond: number | null;
146
+ }>;
147
+ models: Array<{
148
+ /** Raw model id from the historical route. Stays the row key and the
149
+ * fallback label (also the tooltip) when no display name is resolved. */
150
+ model: string;
151
+ /** The model selector's display name for this model, resolved host-side
152
+ * from the llm service metadata. Optional so an older host (or an
153
+ * unresolvable route) still serves a valid payload. */
154
+ name?: string;
155
+ /** Display names (raw ids when unknown) of every provider that served this
156
+ * model in the session, first-seen order. The by-model card shows these in
157
+ * gray when two rows end up sharing one display name. */
158
+ providers?: string[];
159
+ totals: SessionUsageTotals;
160
+ requests: number;
161
+ }>;
162
+ records: Array<{
163
+ id: string;
164
+ sessionId: string;
165
+ sessionName: string;
166
+ turn: number;
167
+ step: number;
168
+ startedAt: number;
169
+ finishedAt: number | null;
170
+ provider: string;
171
+ model: string;
172
+ status: 'running' | 'completed' | 'failed' | 'interrupted';
173
+ totals: SessionUsageTotals;
174
+ firstTokenMs: number | null;
175
+ tokensPerSecond: number | null;
176
+ turnName: string;
177
+ effort: string;
178
+ }>;
179
+ routes: Array<{
180
+ id: string;
181
+ provider: string;
182
+ name: string;
183
+ model: string;
184
+ status: 'idle' | 'running' | 'completed' | 'failed';
185
+ }>;
186
+ }
132
187
  /** The backfill (historical session scan) progress state. */
133
188
  export interface BackfillStatus {
134
189
  running: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-token-total",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
4
4
  "description": "DSH web plugin: token 用量统计看板 + 单会话(主智能体 + 子智能体)用量统计。提供今日/近3天/近7天/近1月/近3月/自定义筛选、六个汇总卡片、活跃热力图、缓存命中率折线与分模型柱状图、按模型/按供应商 Top5 列表。",
5
5
  "icon": "./icon.svg",
6
6
  "type": "module",