dsh-token-total 1.0.6 → 1.0.7

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/client.js CHANGED
@@ -29650,6 +29650,20 @@ window.__ModuleLoader__.load({
29650
29650
  }
29651
29651
  return out;
29652
29652
  }
29653
+ /** Decode throughput across rows: Σ(output tokens) / Σ(decode window), matching
29654
+ * the `sessionStats` projection's decodeTokens/decodeMs rate that the composer
29655
+ * bottom bar shows. Rows without a decode window (failed attempts, cancelled
29656
+ * steps, running requests) are excluded, so the card, the turn summaries and
29657
+ * the bottom bar all report one speed. */
29658
+ function decodeRateOf(rows) {
29659
+ let ms = 0;
29660
+ let tokens = 0;
29661
+ for (const row of rows) if (typeof row.decodeMs === "number" && Number.isFinite(row.decodeMs)) {
29662
+ ms += row.decodeMs;
29663
+ tokens += row.totals.outputTokens;
29664
+ }
29665
+ return ms > 0 ? tokens / (ms / 1e3) : null;
29666
+ }
29653
29667
  /** Canonicalize a DISPLAY NAME, never a route id. Case and word separators
29654
29668
  * do not distinguish models, but versions, punctuation and suffixes do:
29655
29669
  * gpt-6.1-sol / GPT 6.1 sol -> gpt-6.1-sol, not gpt-6.1 or gpt-61-sol.
@@ -32638,6 +32652,7 @@ window.__ModuleLoader__.load({
32638
32652
  (0, react.useEffect)(() => {
32639
32653
  let active = true;
32640
32654
  let pending = false;
32655
+ let wantedNow = false;
32641
32656
  let timer;
32642
32657
  let controller;
32643
32658
  const request = () => {
@@ -32649,23 +32664,29 @@ window.__ModuleLoader__.load({
32649
32664
  if (active) setReading({
32650
32665
  sessionId,
32651
32666
  detail,
32652
- error: false
32667
+ error: false,
32668
+ updatedAt: Date.now()
32653
32669
  });
32654
32670
  }).catch(() => {
32655
32671
  if (active) setReading((previous) => ({
32656
32672
  sessionId,
32657
32673
  detail: previous?.sessionId === sessionId ? previous.detail : null,
32658
- error: true
32674
+ error: true,
32675
+ updatedAt: previous?.sessionId === sessionId ? previous.updatedAt : null
32659
32676
  }));
32660
32677
  }).finally(() => {
32661
32678
  pending = false;
32662
- if (active) timer = setTimeout(request, DETAIL_POLL_MS);
32679
+ if (active) timer = setTimeout(request, wantedNow ? 0 : DETAIL_POLL_MS);
32680
+ wantedNow = false;
32663
32681
  });
32664
32682
  };
32665
32683
  const wake = () => {
32666
32684
  if (document.visibilityState === "visible") request();
32667
32685
  };
32668
- refresh.current = request;
32686
+ refresh.current = () => {
32687
+ if (pending) wantedNow = true;
32688
+ else request();
32689
+ };
32669
32690
  document.addEventListener("visibilitychange", wake);
32670
32691
  request();
32671
32692
  return () => {
@@ -32681,7 +32702,8 @@ window.__ModuleLoader__.load({
32681
32702
  detail: current?.detail ?? null,
32682
32703
  loading: current === null,
32683
32704
  error: current?.error ?? false,
32684
- retry: () => refresh.current?.()
32705
+ refresh: () => refresh.current?.(),
32706
+ updatedAt: current?.updatedAt ?? null
32685
32707
  };
32686
32708
  }
32687
32709
  //#endregion
@@ -32854,7 +32876,7 @@ window.__ModuleLoader__.load({
32854
32876
  }
32855
32877
  //#endregion
32856
32878
  //#region \0token-total-css:/home/runner/work/dsh-token-total/dsh-token-total/src/client/SessionUsageView.module.css?inline
32857
- const css = "/* A light, self-contained Usage surface. Tables fit the available width:\n * no horizontal scroll containers, fixed column contracts, titles for names.\n * Stat cards follow the reference dashboard: tinted left edge + icon chip,\n * label, big value, small subtitle. Request log folds by conversation turn. */\n.scroll_900471699 { height:100%; min-height:0; overflow-y:auto; background:#ffffff; color:#252936; color-scheme:light; container-type:inline-size; }\n.page_900471699 { box-sizing:border-box; width:100%; max-width:1160px; margin:0 auto; padding:24px 26px 32px; font-family:system-ui,-apple-system,\"Segoe UI\",sans-serif; font-size:13px; }\n.page_900471699 * { box-sizing:border-box; }\n.scope_900471699 { color:#8790a3; font-size:11px; margin:0 0 14px; }\n.stats_900471699 { display:grid; gap:12px; margin-bottom:18px; }\n.statRow_900471699 { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:12px; }\n.stat_900471699 { min-width:0; position:relative; overflow:hidden; background:#fff; border:1px solid #e4e7ee; border-left:4px solid var(--tone); border-radius:12px; padding:12px 16px 11px; }\n.statHead_900471699 { display:flex; align-items:center; gap:8px; margin-bottom:8px; }\n.statIcon_900471699 { display:flex; align-items:center; justify-content:center; width:32px; height:32px; border-radius:10px; color:#fff; background:linear-gradient(135deg, var(--tone), var(--tone-soft)); flex-shrink:0; box-shadow:0 3px 8px var(--tone-soft); }\n.statMark_900471699 { position:absolute; right:-14px; bottom:-18px; width:92px; height:92px; color:var(--tone); pointer-events:none; }\n.statLabel_900471699 { font-size:12px; color:#7d8495; }\n.statValue_900471699 { display:block; font-size:25px; font-weight:650; letter-spacing:-0.5px; white-space:nowrap; font-variant-numeric:tabular-nums; }\n.statSub_900471699 { display:block; margin-top:5px; font-size:11px; color:#939aac; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }\n.toneBlue_900471699 { --tone:#4f79f6; --tone-soft:#eaf0fe; }\n.tonePurple_900471699 { --tone:#8b5cf6; --tone-soft:#f1ebfe; }\n.toneGreen_900471699 { --tone:#18a97b; --tone-soft:#e5f6f0; }\n.toneOrange_900471699 { --tone:#e8892c; --tone-soft:#fcf1e4; }\n.toneTeal_900471699 { --tone:#16a4b8; --tone-soft:#e4f5f8; }\n.tonePink_900471699 { --tone:#d9538c; --tone-soft:#fbeaf1; }\n.card_900471699 { min-width:0; background:#fff; border:1px solid #e4e7ee; border-radius:12px; margin-bottom:18px; overflow:hidden; }\n.cardHead_900471699 { display:flex; align-items:center; justify-content:space-between; gap:10px; min-height:54px; padding:15px 18px; border-bottom:1px solid #eef0f4; }\n.cardHead_900471699 h2 { font-size:14px; font-weight:600; margin:0; }\n.cardHead_900471699 > span { display:flex; gap:7px; align-items:center; font-size:11px; }\n.count_900471699 { display:inline-flex; align-items:center; justify-content:center; min-width:21px; padding:2px 6px; font-size:10px; color:#8b93a3; background:#f3f5f8; border-radius:5px; margin-left:5px; font-weight:500; }\n.mid_900471699 { display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr); gap:18px; align-items:stretch; }\n.mid_900471699 .card_900471699 { height:100%; margin-bottom:0; }\n.mid_900471699 { margin-bottom:18px; }\n.table_900471699 { border-collapse:collapse; width:100%; table-layout:fixed; font-size:11px; }\n.table_900471699 th { color:#929aaa; font-weight:500; padding:12px 5px; text-align:right; vertical-align:middle; line-height:1.3; overflow-wrap:anywhere; }\n.table_900471699 td { padding:13px 5px; text-align:right; border-top:1px solid #eef0f4; color:#626c80; font-variant-numeric:tabular-nums; white-space:nowrap; }\n.table_900471699 th:first-child,.table_900471699 td:first-child { text-align:left; padding-left:16px; }\n.table_900471699 th:last-child,.table_900471699 td:last-child { padding-right:16px; }\n.childName_900471699 { width:28%; }\n.childRequests_900471699 { width:11%; }\n.modelName_900471699 { width:30%; }\n.modelRequests_900471699 { width:11%; }\n.modelHit_900471699 { width:16%; }\n.ellipsis_900471699 { display:block; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:#394354; }\n/* By-model rows lead with the model's brand logo; the name keeps truncating\n * inside the flexible remainder of the cell. */\n.modelNameCell_900471699 { display:flex; align-items:center; gap:6px; min-width:0; }\n.modelNameCell_900471699 .ellipsis_900471699 { flex:1; min-width:0; }\n/* Gray provider label under a model name shown by several rows (同名模型). */\n.modelProvider_900471699 { display:block; margin-top:2px; font-size:10px; color:#939aaa; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }\n.number_900471699 { font-variant-numeric:tabular-nums; }\n.speedCell_900471699 { display:inline-flex; align-items:baseline; gap:3px; white-space:nowrap; }\n.speedCell_900471699 small { font-size:9px; color:#929aaa; }\n.good_900471699 { color:#21a17b; }\n.warn_900471699 { color:#d69a21; }\n.bad_900471699 { color:#e06571; }\n.live_900471699 { color:#169bb3; }\n.muted_900471699 { color:#939aaa; }\n.empty_900471699 { margin:0; padding:28px 18px; color:#9aa2b1; font-size:12px; text-align:center; }\n.action_900471699 { color:#818b9e; background:none; border:0; padding:3px 0; font:inherit; font-size:11px; cursor:pointer; }\n.action_900471699:hover { color:#279cb6; }\n.action_900471699:focus-visible,.turnHead_900471699:focus-visible { outline:2px solid #2ab1c6; outline-offset:-2px; }\n.turnList_900471699 { padding:0 14px 14px; }\n.turn_900471699 { margin-top:10px; border:1px solid #e7eaf0; border-radius:9px; background:#fff; overflow:hidden; }\n.turnHead_900471699 { display:grid; grid-template-columns:12px minmax(0,1fr) auto; align-items:center; gap:12px; padding:13px 14px; width:100%; text-align:left; background:#f8f9fc; border:0; cursor:pointer; color:#616c7f; font:inherit; font-size:11px; }\n.chevron_900471699 { color:#99a1af; transition:transform 0.2s; }\n.rotated_900471699 { transform:rotate(90deg); }\n.turnTitle_900471699 { display:flex; align-items:center; gap:6px; min-width:0; font-size:12px; font-weight:600; color:#394354; }\n/* Turn topic is the group title now (the page header owns the session name),\n * so it carries the primary text color and truncates before the metrics. */\n.turnTopic_900471699 { min-width:0; overflow:hidden; white-space:nowrap; text-overflow:ellipsis; }\n/* A subagent's turns look like this session's turns apart from the agent tag;\n * the tag keeps the full session name in its tooltip. */\n.turnAgent_900471699 { flex:none; padding:1px 6px; border-radius:5px; background:#e9edf4; color:#6f7889; font-size:10px; font-weight:500; }\n.turnSummary_900471699 { display:flex; gap:5px; align-items:center; white-space:nowrap; }\n.turnSummary_900471699 > span:last-child { margin-left:8px; }\n.turnBody_900471699 { border-top:1px solid #eef0f4; }\n.turnBody_900471699[hidden] { display:none; }\n/* A request row is one line: time, what answered, and its metrics pinned to the\n * row end. The metrics never wrap; they truncate with a tooltip before the head\n * gives up its model name. */\n.turnRequest_900471699 { display:grid; grid-template-columns:64px minmax(0,1fr) auto; align-items:center; gap:12px; padding:10px 16px; border-top:1px solid #f3f5f9; }\n.turnRequest_900471699:first-child { border-top:0; }\n.turnRequestHead_900471699 { display:flex; align-items:center; gap:8px; font-size:11px; color:#616c7f; min-width:0; }\n.requestTime_900471699 { color:#9aa3b3; font-variant-numeric:tabular-nums; flex-shrink:0; }\n.statusDot_900471699 { width:5px; height:5px; border-radius:50%; background:currentColor; flex-shrink:0; }\n/* Per-model brand logo: colors are baked into each logo's markup, only the\n * layout contract lives here. Unknown models get the neutral gray fallback. */\n.modelLogo_900471699 { flex-shrink:0; }\n.recordModel_900471699 { min-width:0; display:flex; align-items:center; gap:6px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; color:#394354; font-weight:500; }\n.routeText_900471699 { white-space:nowrap; overflow:hidden; text-overflow:ellipsis; color:#8b93a4; flex-shrink:1; }\n.effort_900471699 { white-space:nowrap; color:#9aa3b3; background:#f1f3f7; border-radius:5px; padding:1px 6px; }\n.recordMeta_900471699 { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; text-align:right; font-size:10px; color:#949cab; font-variant-numeric:tabular-nums; }\n.note_900471699 { font-size:10px; line-height:1.7; color:#9aa3b3; margin:0; }\n.error_900471699 { background:#fff4f1; color:#b86751; border:1px solid #f4d8ce; border-radius:9px; padding:12px 16px; margin-bottom:16px; font-size:12px; display:flex; gap:16px; justify-content:space-between; }\n@container (max-width:1050px) {\n .mid_900471699 { grid-template-columns:minmax(0,1fr); }\n .statRow_900471699 { grid-template-columns:repeat(2,minmax(0,1fr)); }\n .statValue_900471699 { font-size:23px; }\n .table_900471699 { font-size:12px; }\n .childName_900471699 { width:34%; }\n}\n@container (max-width:700px) {\n .page_900471699 { padding:18px 14px 24px; }\n .stat_900471699 { padding:12px 12px 11px; }\n .statValue_900471699 { font-size:20px; }\n .table_900471699 { font-size:10px; }\n .table_900471699 th,.table_900471699 td { padding:10px 3px; }\n .table_900471699 th:first-child,.table_900471699 td:first-child { padding-left:10px; }\n .table_900471699 th:last-child,.table_900471699 td:last-child { padding-right:10px; }\n .childName_900471699 { width:25%; }\n .childRequests_900471699 { width:11%; }\n .modelName_900471699 { width:25%; }\n .modelRequests_900471699 { width:11%; }\n .modelHit_900471699 { width:17%; }\n .turnHead_900471699 { gap:8px; padding:11px 10px; }\n .turnSummary_900471699 { grid-column:2; font-size:10px; }\n .turnRequest_900471699 { grid-template-columns:56px minmax(120px,1fr) auto; gap:8px; padding:10px; }\n .turnRequestHead_900471699 { gap:6px; }\n .routeText_900471699 { display:none; }\n}\n@media (prefers-reduced-motion:reduce) {\n .chevron_900471699 { transition:none; }\n}\n";
32879
+ const css = "/* A light, self-contained Usage surface. Tables fit the available width:\n * no horizontal scroll containers, fixed column contracts, titles for names.\n * Stat cards follow the reference dashboard: tinted left edge + icon chip,\n * label, big value, small subtitle. Request log folds by conversation turn. */\n.scroll_900471699 { height:100%; min-height:0; overflow-y:auto; background:#ffffff; color:#252936; color-scheme:light; container-type:inline-size; }\n.page_900471699 { box-sizing:border-box; width:100%; max-width:1160px; margin:0 auto; padding:24px 26px 32px; font-family:system-ui,-apple-system,\"Segoe UI\",sans-serif; font-size:13px; }\n.page_900471699 * { box-sizing:border-box; }\n/* Freshness header: live badge + last-read stamp on the left, manual refresh\n * pinned to the right edge. */\n.head_900471699 { display:flex; align-items:center; justify-content:space-between; gap:12px; margin:0 0 12px; }\n.headStatus_900471699 { display:flex; align-items:center; gap:7px; min-width:0; font-size:11px; color:#8790a3; }\n.liveDot_900471699 { width:6px; height:6px; border-radius:50%; background:#18a97b; box-shadow:0 0 0 3px #e5f6f0; flex-shrink:0; }\n.asOf_900471699 { color:#9aa3b3; font-variant-numeric:tabular-nums; white-space:nowrap; }\n.refresh_900471699 { display:inline-flex; align-items:center; gap:5px; flex:none; color:#6f7a8d; background:#fff; border:1px solid #e4e7ee; border-radius:8px; padding:5px 11px; font:inherit; font-size:11px; cursor:pointer; }\n.refresh_900471699:hover { color:#279cb6; border-color:#cbd3e0; }\n.refresh_900471699:disabled { opacity:.55; cursor:default; }\n.scope_900471699 { color:#8790a3; font-size:11px; margin:0 0 14px; }\n.stats_900471699 { display:grid; gap:12px; margin-bottom:18px; }\n.statRow_900471699 { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:12px; }\n.stat_900471699 { min-width:0; position:relative; overflow:hidden; background:#fff; border:1px solid #e4e7ee; border-left:4px solid var(--tone); border-radius:12px; padding:12px 16px 11px; }\n.statHead_900471699 { display:flex; align-items:center; gap:8px; margin-bottom:8px; }\n.statIcon_900471699 { display:flex; align-items:center; justify-content:center; width:32px; height:32px; border-radius:10px; color:#fff; background:linear-gradient(135deg, var(--tone), var(--tone-soft)); flex-shrink:0; box-shadow:0 3px 8px var(--tone-soft); }\n.statMark_900471699 { position:absolute; right:-14px; bottom:-18px; width:92px; height:92px; color:var(--tone); pointer-events:none; }\n.statLabel_900471699 { font-size:12px; color:#7d8495; }\n.statValue_900471699 { display:block; font-size:25px; font-weight:650; letter-spacing:-0.5px; white-space:nowrap; font-variant-numeric:tabular-nums; }\n.statSub_900471699 { display:block; margin-top:5px; font-size:11px; color:#939aac; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }\n.toneBlue_900471699 { --tone:#4f79f6; --tone-soft:#eaf0fe; }\n.tonePurple_900471699 { --tone:#8b5cf6; --tone-soft:#f1ebfe; }\n.toneGreen_900471699 { --tone:#18a97b; --tone-soft:#e5f6f0; }\n.toneOrange_900471699 { --tone:#e8892c; --tone-soft:#fcf1e4; }\n.toneTeal_900471699 { --tone:#16a4b8; --tone-soft:#e4f5f8; }\n.tonePink_900471699 { --tone:#d9538c; --tone-soft:#fbeaf1; }\n.card_900471699 { min-width:0; background:#fff; border:1px solid #e4e7ee; border-radius:12px; margin-bottom:18px; overflow:hidden; }\n.cardHead_900471699 { display:flex; align-items:center; justify-content:space-between; gap:10px; min-height:54px; padding:15px 18px; border-bottom:1px solid #eef0f4; }\n.cardHead_900471699 h2 { font-size:14px; font-weight:600; margin:0; }\n.cardHead_900471699 > span { display:flex; gap:7px; align-items:center; font-size:11px; }\n.count_900471699 { display:inline-flex; align-items:center; justify-content:center; min-width:21px; padding:2px 6px; font-size:10px; color:#8b93a3; background:#f3f5f8; border-radius:5px; margin-left:5px; font-weight:500; }\n.mid_900471699 { display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr); gap:18px; align-items:stretch; }\n.mid_900471699 .card_900471699 { height:100%; margin-bottom:0; }\n.mid_900471699 { margin-bottom:18px; }\n.table_900471699 { border-collapse:collapse; width:100%; table-layout:fixed; font-size:11px; }\n.table_900471699 th { color:#929aaa; font-weight:500; padding:12px 5px; text-align:right; vertical-align:middle; line-height:1.3; overflow-wrap:anywhere; }\n.table_900471699 td { padding:13px 5px; text-align:right; border-top:1px solid #eef0f4; color:#626c80; font-variant-numeric:tabular-nums; white-space:nowrap; }\n.table_900471699 th:first-child,.table_900471699 td:first-child { text-align:left; padding-left:16px; }\n.table_900471699 th:last-child,.table_900471699 td:last-child { padding-right:16px; }\n.childName_900471699 { width:28%; }\n.childRequests_900471699 { width:11%; }\n.modelName_900471699 { width:30%; }\n.modelRequests_900471699 { width:11%; }\n.modelHit_900471699 { width:16%; }\n.ellipsis_900471699 { display:block; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:#394354; }\n/* By-model rows lead with the model's brand logo; the name keeps truncating\n * inside the flexible remainder of the cell. */\n.modelNameCell_900471699 { display:flex; align-items:center; gap:6px; min-width:0; }\n.modelNameCell_900471699 .ellipsis_900471699 { flex:1; min-width:0; }\n/* Gray provider label under a model name shown by several rows (同名模型). */\n.modelProvider_900471699 { display:block; margin-top:2px; font-size:10px; color:#939aaa; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }\n.number_900471699 { font-variant-numeric:tabular-nums; }\n.speedCell_900471699 { display:inline-flex; align-items:baseline; gap:3px; white-space:nowrap; }\n.speedCell_900471699 small { font-size:9px; color:#929aaa; }\n.good_900471699 { color:#21a17b; }\n.warn_900471699 { color:#d69a21; }\n.bad_900471699 { color:#e06571; }\n.live_900471699 { color:#169bb3; }\n.muted_900471699 { color:#939aaa; }\n.empty_900471699 { margin:0; padding:28px 18px; color:#9aa2b1; font-size:12px; text-align:center; }\n.action_900471699 { color:#818b9e; background:none; border:0; padding:3px 0; font:inherit; font-size:11px; cursor:pointer; }\n.action_900471699:hover { color:#279cb6; }\n.action_900471699:focus-visible,.turnHead_900471699:focus-visible,.refresh_900471699:focus-visible { outline:2px solid #2ab1c6; outline-offset:-2px; }\n.turnList_900471699 { padding:0 14px 14px; }\n.turn_900471699 { margin-top:10px; border:1px solid #e7eaf0; border-radius:9px; background:#fff; overflow:hidden; }\n.turnHead_900471699 { display:grid; grid-template-columns:12px minmax(0,1fr) auto; align-items:center; gap:12px; padding:13px 14px; width:100%; text-align:left; background:#f8f9fc; border:0; cursor:pointer; color:#616c7f; font:inherit; font-size:11px; }\n.chevron_900471699 { color:#99a1af; transition:transform 0.2s; }\n.rotated_900471699 { transform:rotate(90deg); }\n.turnTitle_900471699 { display:flex; align-items:center; gap:6px; min-width:0; font-size:12px; font-weight:600; color:#394354; }\n/* Turn topic is the group title now (the page header owns the session name),\n * so it carries the primary text color and truncates before the metrics. */\n.turnTopic_900471699 { min-width:0; overflow:hidden; white-space:nowrap; text-overflow:ellipsis; }\n/* A subagent's turns look like this session's turns apart from the agent tag;\n * the tag keeps the full session name in its tooltip. */\n.turnAgent_900471699 { flex:none; padding:1px 6px; border-radius:5px; background:#e9edf4; color:#6f7889; font-size:10px; font-weight:500; }\n.turnSummary_900471699 { display:flex; gap:5px; align-items:center; white-space:nowrap; }\n.turnSummary_900471699 > span:last-child { margin-left:8px; }\n.turnBody_900471699 { border-top:1px solid #eef0f4; }\n.turnBody_900471699[hidden] { display:none; }\n/* A request row is one line: time, what answered, and its metrics pinned to the\n * row end. The metrics never wrap; they truncate with a tooltip before the head\n * gives up its model name. */\n.turnRequest_900471699 { display:grid; grid-template-columns:64px minmax(0,1fr) auto; align-items:center; gap:12px; padding:10px 16px; border-top:1px solid #f3f5f9; }\n.turnRequest_900471699:first-child { border-top:0; }\n.turnRequestHead_900471699 { display:flex; align-items:center; gap:8px; font-size:11px; color:#616c7f; min-width:0; }\n.requestTime_900471699 { color:#9aa3b3; font-variant-numeric:tabular-nums; flex-shrink:0; }\n.statusDot_900471699 { width:5px; height:5px; border-radius:50%; background:currentColor; flex-shrink:0; }\n/* Per-model brand logo: colors are baked into each logo's markup, only the\n * layout contract lives here. Unknown models get the neutral gray fallback. */\n.modelLogo_900471699 { flex-shrink:0; }\n.recordModel_900471699 { min-width:0; display:flex; align-items:center; gap:6px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; color:#394354; font-weight:500; }\n.routeText_900471699 { white-space:nowrap; overflow:hidden; text-overflow:ellipsis; color:#8b93a4; flex-shrink:1; }\n.effort_900471699 { white-space:nowrap; color:#9aa3b3; background:#f1f3f7; border-radius:5px; padding:1px 6px; }\n.recordMeta_900471699 { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; text-align:right; font-size:10px; color:#949cab; font-variant-numeric:tabular-nums; }\n.note_900471699 { font-size:10px; line-height:1.7; color:#9aa3b3; margin:0; }\n.error_900471699 { background:#fff4f1; color:#b86751; border:1px solid #f4d8ce; border-radius:9px; padding:12px 16px; margin-bottom:16px; font-size:12px; display:flex; gap:16px; justify-content:space-between; }\n@container (max-width:1050px) {\n .mid_900471699 { grid-template-columns:minmax(0,1fr); }\n .statRow_900471699 { grid-template-columns:repeat(2,minmax(0,1fr)); }\n .statValue_900471699 { font-size:23px; }\n .table_900471699 { font-size:12px; }\n .childName_900471699 { width:34%; }\n}\n@container (max-width:700px) {\n .page_900471699 { padding:18px 14px 24px; }\n .stat_900471699 { padding:12px 12px 11px; }\n .statValue_900471699 { font-size:20px; }\n .table_900471699 { font-size:10px; }\n .table_900471699 th,.table_900471699 td { padding:10px 3px; }\n .table_900471699 th:first-child,.table_900471699 td:first-child { padding-left:10px; }\n .table_900471699 th:last-child,.table_900471699 td:last-child { padding-right:10px; }\n .childName_900471699 { width:25%; }\n .childRequests_900471699 { width:11%; }\n .modelName_900471699 { width:25%; }\n .modelRequests_900471699 { width:11%; }\n .modelHit_900471699 { width:17%; }\n .turnHead_900471699 { gap:8px; padding:11px 10px; }\n .turnSummary_900471699 { grid-column:2; font-size:10px; }\n .turnRequest_900471699 { grid-template-columns:56px minmax(120px,1fr) auto; gap:8px; padding:10px; }\n .turnRequestHead_900471699 { gap:6px; }\n .routeText_900471699 { display:none; }\n}\n@media (prefers-reduced-motion:reduce) {\n .chevron_900471699 { transition:none; }\n}\n";
32858
32880
  const tagId = "dsh-token-total/SessionUsageView.module.css";
32859
32881
  if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=\"" + tagId + "\"]") === null) {
32860
32882
  const tag = document.createElement("style");
@@ -32866,6 +32888,11 @@ window.__ModuleLoader__.load({
32866
32888
  var SessionUsageView_module_default = {
32867
32889
  "scroll": "scroll_900471699",
32868
32890
  "page": "page_900471699",
32891
+ "head": "head_900471699",
32892
+ "headStatus": "headStatus_900471699",
32893
+ "liveDot": "liveDot_900471699",
32894
+ "asOf": "asOf_900471699",
32895
+ "refresh": "refresh_900471699",
32869
32896
  "scope": "scope_900471699",
32870
32897
  "stats": "stats_900471699",
32871
32898
  "statRow": "statRow_900471699",
@@ -33316,8 +33343,8 @@ window.__ModuleLoader__.load({
33316
33343
  const totals = sum(group.rows);
33317
33344
  const billed = billedTotal(totals);
33318
33345
  const running = group.rows.filter((row) => row.status === "running").length;
33319
- const rates = group.rows.map((row) => row.tokensPerSecond).filter((n) => n !== null);
33320
- const avgRate = rates.length ? Math.round(rates.reduce((a, b) => a + b, 0) / rates.length) : null;
33346
+ const rate = decodeRateOf(group.rows);
33347
+ const avgRate = rate === null ? null : Math.round(rate);
33321
33348
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("article", {
33322
33349
  className: SessionUsageView_module_default.turn,
33323
33350
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
@@ -33388,7 +33415,7 @@ window.__ModuleLoader__.load({
33388
33415
  id: `usage-turn-${encodeURIComponent(group.key)}`,
33389
33416
  className: SessionUsageView_module_default.turnBody,
33390
33417
  hidden: !open,
33391
- children: group.rows.map((record) => {
33418
+ children: group.rows.slice().reverse().map((record) => {
33392
33419
  const duration = record.finishedAt !== null ? record.finishedAt - record.startedAt : null;
33393
33420
  const billed = billedTotal(record.totals);
33394
33421
  const hit = cacheHitPercentOf(record.totals);
@@ -33431,14 +33458,14 @@ window.__ModuleLoader__.load({
33431
33458
  modelShort(record.model) || "—"
33432
33459
  ]
33433
33460
  }),
33434
- statusText !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
33435
- className: record.status === "running" ? SessionUsageView_module_default.live : record.status === "failed" ? SessionUsageView_module_default.bad : SessionUsageView_module_default.muted,
33436
- children: statusText
33437
- }),
33438
33461
  record.effort ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
33439
33462
  className: SessionUsageView_module_default.effort,
33440
33463
  children: ["· ", record.effort]
33441
- }) : null
33464
+ }) : null,
33465
+ statusText !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
33466
+ className: record.status === "running" ? SessionUsageView_module_default.live : record.status === "failed" ? SessionUsageView_module_default.bad : SessionUsageView_module_default.muted,
33467
+ children: statusText
33468
+ })
33442
33469
  ]
33443
33470
  }),
33444
33471
  record.finishedAt !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
@@ -33488,13 +33515,51 @@ window.__ModuleLoader__.load({
33488
33515
  ] });
33489
33516
  }
33490
33517
  function SessionUsageView({ sessionId, t }) {
33491
- const { detail, loading, error, retry } = useSessionDetail(sessionId);
33518
+ const { detail, loading, error, refresh, updatedAt } = useSessionDetail(sessionId);
33492
33519
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
33493
33520
  className: SessionUsageView_module_default.scroll,
33494
33521
  "data-dsh-session-usage": true,
33495
33522
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
33496
33523
  className: SessionUsageView_module_default.page,
33497
33524
  children: [
33525
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("header", {
33526
+ className: SessionUsageView_module_default.head,
33527
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
33528
+ className: SessionUsageView_module_default.headStatus,
33529
+ children: [
33530
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
33531
+ className: SessionUsageView_module_default.liveDot,
33532
+ "aria-hidden": "true"
33533
+ }),
33534
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("live") }),
33535
+ updatedAt !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
33536
+ className: SessionUsageView_module_default.asOf,
33537
+ children: [
33538
+ t("asOf"),
33539
+ " ",
33540
+ clock(updatedAt)
33541
+ ]
33542
+ })
33543
+ ]
33544
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
33545
+ type: "button",
33546
+ className: SessionUsageView_module_default.refresh,
33547
+ onClick: refresh,
33548
+ disabled: loading,
33549
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
33550
+ width: "12",
33551
+ height: "12",
33552
+ viewBox: "0 0 24 24",
33553
+ fill: "none",
33554
+ stroke: "currentColor",
33555
+ strokeWidth: "2",
33556
+ strokeLinecap: "round",
33557
+ strokeLinejoin: "round",
33558
+ "aria-hidden": "true",
33559
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M21 3v5h-5" })]
33560
+ }), t("refresh")]
33561
+ })]
33562
+ }),
33498
33563
  loading && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
33499
33564
  className: SessionUsageView_module_default.empty,
33500
33565
  role: "status",
@@ -33506,7 +33571,7 @@ window.__ModuleLoader__.load({
33506
33571
  children: [t("failedLoad"), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
33507
33572
  type: "button",
33508
33573
  className: SessionUsageView_module_default.action,
33509
- onClick: retry,
33574
+ onClick: refresh,
33510
33575
  children: t("retry")
33511
33576
  })]
33512
33577
  }),
@@ -33577,7 +33642,10 @@ window.__ModuleLoader__.load({
33577
33642
  cacheShort: "缓存",
33578
33643
  avgCache: "平均缓存",
33579
33644
  avgRate: "平均速率",
33580
- inProgress: "个进行中"
33645
+ inProgress: "个进行中",
33646
+ live: "实时更新",
33647
+ asOf: "统计截至",
33648
+ refresh: "刷新"
33581
33649
  };
33582
33650
  const usageEn = {
33583
33651
  tab: "Usage",
@@ -33634,7 +33702,10 @@ window.__ModuleLoader__.load({
33634
33702
  cacheShort: "cache",
33635
33703
  avgCache: "avg cache",
33636
33704
  avgRate: "avg rate",
33637
- inProgress: "in progress"
33705
+ inProgress: "in progress",
33706
+ live: "Live",
33707
+ asOf: "as of",
33708
+ refresh: "Refresh"
33638
33709
  };
33639
33710
  const usageZhTW = {
33640
33711
  ...usageZh,
@@ -33691,7 +33762,10 @@ window.__ModuleLoader__.load({
33691
33762
  cacheShort: "快取",
33692
33763
  avgCache: "平均快取",
33693
33764
  avgRate: "平均速率",
33694
- inProgress: "個進行中"
33765
+ inProgress: "個進行中",
33766
+ live: "即時更新",
33767
+ asOf: "統計截至",
33768
+ refresh: "重新整理"
33695
33769
  };
33696
33770
  //#endregion
33697
33771
  //#region src/client/index.tsx
package/lib/index.js CHANGED
@@ -47,6 +47,20 @@ function modelNameOf(modelRef) {
47
47
  if (i > 0) return modelRef.slice(i + 1);
48
48
  return modelRef;
49
49
  }
50
+ /** Decode throughput across rows: Σ(output tokens) / Σ(decode window), matching
51
+ * the `sessionStats` projection's decodeTokens/decodeMs rate that the composer
52
+ * bottom bar shows. Rows without a decode window (failed attempts, cancelled
53
+ * steps, running requests) are excluded, so the card, the turn summaries and
54
+ * the bottom bar all report one speed. */
55
+ function decodeRateOf(rows) {
56
+ let ms = 0;
57
+ let tokens = 0;
58
+ for (const row of rows) if (typeof row.decodeMs === "number" && Number.isFinite(row.decodeMs)) {
59
+ ms += row.decodeMs;
60
+ tokens += row.totals.outputTokens;
61
+ }
62
+ return ms > 0 ? tokens / (ms / 1e3) : null;
63
+ }
50
64
  /** Canonicalize a DISPLAY NAME, never a route id. Case and word separators
51
65
  * do not distinguish models, but versions, punctuation and suffixes do:
52
66
  * gpt-6.1-sol / GPT 6.1 sol -> gpt-6.1-sol, not gpt-6.1 or gpt-61-sol.
@@ -1411,6 +1425,9 @@ var UsageCollector = class UsageCollector {
1411
1425
  //#region src/model-names.ts
1412
1426
  const LOOKUP_CONCURRENCY = 4;
1413
1427
  const LOOKUP_TIMEOUT_MS = 2e3;
1428
+ /** Raised when a lookup's own timer fires — i.e. the shared phase budget is
1429
+ * spent. Workers stop on this instead of trusting the wall clock again. */
1430
+ var LookupTimeoutError = class extends Error {};
1414
1431
  /** Bound metadata queries even if an adapter fails to honor cancellation. */
1415
1432
  async function lookup(read, timeoutMs) {
1416
1433
  const controller = new AbortController();
@@ -1419,7 +1436,7 @@ async function lookup(read, timeoutMs) {
1419
1436
  return await Promise.race([Promise.resolve().then(() => read(controller.signal)), new Promise((_, reject) => {
1420
1437
  timer = setTimeout(() => {
1421
1438
  controller.abort();
1422
- reject(/* @__PURE__ */ new Error("model display-name lookup timed out"));
1439
+ reject(new LookupTimeoutError("model display-name lookup timed out"));
1423
1440
  }, timeoutMs);
1424
1441
  })]);
1425
1442
  } finally {
@@ -1470,7 +1487,9 @@ async function resolveModelNames(modelRefs, llm) {
1470
1487
  try {
1471
1488
  const info = await lookup((signal) => llm.resolveModelInfo(providerOf(ref), modelNameOf(ref), signal), remaining);
1472
1489
  if (info.name?.trim()) names.set(ref, info.name);
1473
- } catch {}
1490
+ } catch (error) {
1491
+ if (error instanceof LookupTimeoutError) break;
1492
+ }
1474
1493
  }
1475
1494
  }));
1476
1495
  }
@@ -1490,7 +1509,9 @@ async function resolveModelNames(modelRefs, llm) {
1490
1509
  const name = byId.get(modelNameOf(ref));
1491
1510
  if (name?.trim()) names.set(ref, name);
1492
1511
  }
1493
- } catch {}
1512
+ } catch (error) {
1513
+ if (error instanceof LookupTimeoutError) break;
1514
+ }
1494
1515
  }
1495
1516
  }));
1496
1517
  }
@@ -1519,9 +1540,6 @@ function usageOf(x) {
1519
1540
  cacheWriteTokens: Number(u?.cacheWriteTokens) || 0
1520
1541
  };
1521
1542
  }
1522
- function positive(u) {
1523
- return u.uncachedInputTokens + u.outputTokens + u.cacheReadTokens + u.cacheWriteTokens > 0;
1524
- }
1525
1543
  function routeOf(x) {
1526
1544
  const source = x?.message?.source ?? x?.source ?? x?.request ?? x?.context ?? x;
1527
1545
  return {
@@ -1529,19 +1547,65 @@ function routeOf(x) {
1529
1547
  model: typeof source?.model === "string" ? source.model : ""
1530
1548
  };
1531
1549
  }
1550
+ /** Whether one stream chunk carries the model's first output token. Mirrors
1551
+ * `isTokenDelta` in @deepseek-ai/dsh-llm so the decode window starts on the
1552
+ * same chunk the official `sessionStats` projection starts it on. */
1553
+ function isTokenDelta(chunk) {
1554
+ switch (chunk?.type) {
1555
+ case "text-delta":
1556
+ case "reasoning-delta": return chunk.text !== "";
1557
+ case "tool-call-delta": return chunk.argumentsDelta !== "" || chunk.name !== void 0;
1558
+ default: return false;
1559
+ }
1560
+ }
1561
+ /** Reconstructed time of the first token in one packed delta run (mirrors
1562
+ * `runFirstTokenTime`): a name-bearing tool-call run starts at its first
1563
+ * member, every other run at its first non-empty fragment. */
1564
+ function runFirstTokenTime(run) {
1565
+ if (run?.type === "tool-call-chunks" && run.name !== void 0) return run.time0;
1566
+ const fragments = run?.type === "tool-call-chunks" ? run.args : run?.texts;
1567
+ if (!Array.isArray(fragments)) return void 0;
1568
+ let time = Number(run.time0);
1569
+ for (let i = 0; i < fragments.length; i++) {
1570
+ if (i > 0) time += Number(run.dt?.[i - 1]) || 0;
1571
+ if (fragments[i] !== "") return time;
1572
+ }
1573
+ }
1574
+ /** Time of the first token in one compact attempt stream (mirrors
1575
+ * `assistantStreamFirstTokenTime`). */
1576
+ function streamFirstTokenTime(stream) {
1577
+ for (const r of Array.isArray(stream) ? stream : []) {
1578
+ const t = r?.type === "chunk" ? isTokenDelta(r.chunk) ? r.time : void 0 : runFirstTokenTime(r);
1579
+ if (typeof t === "number" && Number.isFinite(t)) return t;
1580
+ }
1581
+ }
1582
+ /** Time of the first token carried by one live `assistant/chunk` payload. */
1583
+ function chunkFirstTokenTime(data, fallback) {
1584
+ const c = data?.chunk;
1585
+ if (!c) return void 0;
1586
+ const timed = c.chunk && typeof c.time === "number" ? c : null;
1587
+ if (!isTokenDelta(timed ? timed.chunk : c)) return void 0;
1588
+ return timed ? timed.time : typeof c.time === "number" ? c.time : fallback;
1589
+ }
1532
1590
  function streamUsage(data) {
1533
- let firstTime;
1534
1591
  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);
1592
+ let raw;
1593
+ for (const r of Array.isArray(data?.stream) ? data.stream : []) if (r?.type === "chunk" && r?.chunk?.type === "usage") {
1594
+ usage = usageOf(r.chunk);
1595
+ raw = r.chunk.usage ?? r.chunk;
1539
1596
  }
1540
1597
  return {
1541
1598
  usage,
1542
- firstTime
1599
+ raw
1543
1600
  };
1544
1601
  }
1602
+ /** Provider-reported completion tokens, or null when unreported or invalid:
1603
+ * an invalid count must not enter the decode quotient (mirrors
1604
+ * `usageOutputTokens` in the sessionStats projection). */
1605
+ function usageOutputTokens(x) {
1606
+ const value = (x?.usage ?? x)?.outputTokens;
1607
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
1608
+ }
1545
1609
  function headerOf$1(item) {
1546
1610
  const x = item;
1547
1611
  if (x?.header?.id && typeof x.header.id === "string") return x.header;
@@ -1607,6 +1671,8 @@ async function one(p, id, header, live) {
1607
1671
  if (label && title) break;
1608
1672
  }
1609
1673
  const name = label || title || nameOf(header, id);
1674
+ const stepTokens = /* @__PURE__ */ new Map();
1675
+ const closedSteps = /* @__PURE__ */ new Set();
1610
1676
  for (const ev of own) {
1611
1677
  const d = ev.data ?? {};
1612
1678
  const turn = Number.isFinite(d.turn) ? d.turn : 0;
@@ -1665,6 +1731,7 @@ async function one(p, id, header, live) {
1665
1731
  status: "running",
1666
1732
  totals: zero(),
1667
1733
  firstTokenMs: null,
1734
+ decodeMs: null,
1668
1735
  tokensPerSecond: null,
1669
1736
  turnName: "",
1670
1737
  effort,
@@ -1675,7 +1742,8 @@ async function one(p, id, header, live) {
1675
1742
  byKey.set(key, rec);
1676
1743
  }
1677
1744
  } else if (ev.type === "assistant/chunk" || ev.type === "assistant/message" || ev.type === "assistant/attempt") {
1678
- rec = byKey.get(`${turn}:${step}`);
1745
+ const key = `${turn}:${step}`;
1746
+ rec = byKey.get(key);
1679
1747
  if (!rec) {
1680
1748
  rec = {
1681
1749
  id: `${id}:${turn}:${step}:${ordinal++}`,
@@ -1690,6 +1758,7 @@ async function one(p, id, header, live) {
1690
1758
  status: "running",
1691
1759
  totals: zero(),
1692
1760
  firstTokenMs: null,
1761
+ decodeMs: null,
1693
1762
  tokensPerSecond: null,
1694
1763
  turnName: "",
1695
1764
  effort,
@@ -1697,7 +1766,7 @@ async function one(p, id, header, live) {
1697
1766
  _lastUsage: zero()
1698
1767
  };
1699
1768
  records.push(rec);
1700
- byKey.set(`${turn}:${step}`, rec);
1769
+ byKey.set(key, rec);
1701
1770
  }
1702
1771
  if (r.provider) rec.provider = r.provider;
1703
1772
  if (r.model) rec.model = r.model;
@@ -1706,17 +1775,22 @@ async function one(p, id, header, live) {
1706
1775
  if (u) {
1707
1776
  rec._lastUsage = u;
1708
1777
  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
- }
1778
+ }
1779
+ const tokenAt = streamFirstTokenTime(d.stream) ?? chunkFirstTokenTime(d, ev.time);
1780
+ if (tokenAt !== void 0) {
1781
+ if (!stepTokens.has(key)) stepTokens.set(key, tokenAt);
1782
+ if (rec.firstTokenMs === null) rec.firstTokenMs = Math.max(0, tokenAt - rec.startedAt);
1713
1783
  }
1714
1784
  if (ev.type !== "assistant/chunk") {
1715
1785
  rec.finishedAt = ev.time;
1716
1786
  rec.status = ev.type === "assistant/attempt" ? "failed" : "completed";
1787
+ const stepFirst = stepTokens.get(key);
1788
+ if (rec.status === "completed" && !closedSteps.has(key) && stepFirst !== void 0 && usageOutputTokens(d.usage) !== null) {
1789
+ closedSteps.add(key);
1790
+ rec.decodeMs = Math.max(0, rec.finishedAt - stepFirst);
1791
+ if (rec.decodeMs > 0 && rec.totals.outputTokens > 0) rec.tokensPerSecond = rec.totals.outputTokens / (rec.decodeMs / 1e3);
1792
+ }
1717
1793
  }
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
1794
  } else if (ev.type === "turn/end") {
1721
1795
  const recs = records.filter((x) => x.turn === turn && x.status === "running");
1722
1796
  for (const x of recs) {
@@ -1738,6 +1812,7 @@ async function one(p, id, header, live) {
1738
1812
  status: "completed",
1739
1813
  totals: u,
1740
1814
  firstTokenMs: null,
1815
+ decodeMs: null,
1741
1816
  tokensPerSecond: null,
1742
1817
  turnName: "",
1743
1818
  effort: "",
@@ -1755,9 +1830,7 @@ async function one(p, id, header, live) {
1755
1830
  });
1756
1831
  }
1757
1832
  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);
1833
+ const firsts = records.filter((x) => x.status === "completed").map((x) => x.firstTokenMs).filter((x) => x !== null);
1761
1834
  for (const x of records) {
1762
1835
  x.turnName = turnTopics.get(x.turn) ?? "";
1763
1836
  delete x._attempt;
@@ -1771,7 +1844,7 @@ async function one(p, id, header, live) {
1771
1844
  records,
1772
1845
  requests,
1773
1846
  averageFirstTokenMs: firsts.length ? firsts.reduce((a, b) => a + b, 0) / firsts.length : null,
1774
- tokensPerSecond: elapsed > 0 ? outputSeconds / (elapsed / 1e3) : null
1847
+ tokensPerSecond: decodeRateOf(records)
1775
1848
  };
1776
1849
  }
1777
1850
  async function buildSessionUsageDetail(persistence, sessions, rootId, ctx) {
@@ -1793,7 +1866,6 @@ async function buildSessionUsageDetail(persistence, sessions, rootId, ctx) {
1793
1866
  }
1794
1867
  const all = [rootId, ...ids];
1795
1868
  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
1869
  const totals = zero();
1798
1870
  const models = /* @__PURE__ */ new Map();
1799
1871
  const records = parts.flatMap((x) => x.records);
@@ -1852,12 +1924,13 @@ async function buildSessionUsageDetail(persistence, sessions, rootId, ctx) {
1852
1924
  if (resolved) return resolved;
1853
1925
  }
1854
1926
  };
1927
+ const firsts = records.filter((x) => x.status === "completed").map((x) => x.firstTokenMs).filter((x) => x !== null);
1855
1928
  return {
1856
1929
  sessionId: rootId,
1857
1930
  totals,
1858
1931
  requests: parts.reduce((n, p) => n + p.requests, 0),
1859
- averageFirstTokenMs: root.averageFirstTokenMs,
1860
- tokensPerSecond: root.tokensPerSecond,
1932
+ averageFirstTokenMs: firsts.length ? firsts.reduce((a, b) => a + b, 0) / firsts.length : null,
1933
+ tokensPerSecond: decodeRateOf(records),
1861
1934
  subagents,
1862
1935
  models: [...models.entries()].map(([model, x]) => {
1863
1936
  const name = displayNameOf(model, x.providers);
@@ -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, normalizeModelName, daysInRange as daysBetween } from '../shared.ts';
10
+ export { providerOf, modelNameOf, normalizeModelName, daysInRange as daysBetween, decodeRateOf } 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. */
@@ -56,6 +56,9 @@ export declare const usageZh: {
56
56
  avgCache: string;
57
57
  avgRate: string;
58
58
  inProgress: string;
59
+ live: string;
60
+ asOf: string;
61
+ refresh: string;
59
62
  };
60
63
  export type SessionUsageKey = keyof typeof usageZh;
61
64
  export declare const usageEn: Record<SessionUsageKey, string>;
@@ -4,5 +4,8 @@ export declare function useSessionDetail(sessionId: string): {
4
4
  detail: SessionUsageDetail | null;
5
5
  loading: boolean;
6
6
  error: boolean;
7
- retry: () => void;
7
+ /** Manual refresh: runs now when idle, or right after an in-flight poll lands. */
8
+ refresh: () => void;
9
+ /** Wall-clock time of the last successful read, for the "as of" stamp. */
10
+ updatedAt: number | null;
8
11
  };
@@ -18,6 +18,21 @@ export declare function providerOf(modelRef: string): string;
18
18
  /** The bare model id/name after a provider prefix. This does NOT resolve the
19
19
  * human-readable display name; the host model metadata does that. */
20
20
  export declare function modelNameOf(modelRef: string): string;
21
+ /** The decode accounting one request row must expose: the wall window from the
22
+ * step's first token to its assembled assistant message, plus the output tokens
23
+ * that window produced. */
24
+ export interface DecodeRowLike {
25
+ decodeMs: number | null;
26
+ totals: {
27
+ outputTokens: number;
28
+ };
29
+ }
30
+ /** Decode throughput across rows: Σ(output tokens) / Σ(decode window), matching
31
+ * the `sessionStats` projection's decodeTokens/decodeMs rate that the composer
32
+ * bottom bar shows. Rows without a decode window (failed attempts, cancelled
33
+ * steps, running requests) are excluded, so the card, the turn summaries and
34
+ * the bottom bar all report one speed. */
35
+ export declare function decodeRateOf(rows: readonly DecodeRowLike[]): number | null;
21
36
  /** Canonicalize a DISPLAY NAME, never a route id. Case and word separators
22
37
  * do not distinguish models, but versions, punctuation and suffixes do:
23
38
  * gpt-6.1-sol / GPT 6.1 sol -> gpt-6.1-sol, not gpt-6.1 or gpt-61-sol.
@@ -172,6 +172,11 @@ export interface SessionUsageDetail {
172
172
  status: 'running' | 'completed' | 'failed' | 'interrupted';
173
173
  totals: SessionUsageTotals;
174
174
  firstTokenMs: number | null;
175
+ /** Wall window from this step's first token to the assembled assistant
176
+ * message — the same decode window the `sessionStats` projection counts
177
+ * (decodeMs). Null when the request never assembled a message or reported
178
+ * no usage, so it stays out of every throughput quotient. */
179
+ decodeMs: number | null;
175
180
  tokensPerSecond: number | null;
176
181
  turnName: string;
177
182
  effort: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-token-total",
3
- "version": "1.0.6",
3
+ "version": "1.0.7",
4
4
  "description": "DSH web plugin: token 用量统计看板 + 单会话(主智能体 + 子智能体)用量统计。提供今日/近3天/近7天/近1月/近3月/自定义筛选、六个汇总卡片、活跃热力图、缓存命中率折线与分模型柱状图、按模型/按供应商 Top5 列表。",
5
5
  "icon": "./icon.svg",
6
6
  "type": "module",
@@ -5,6 +5,15 @@
5
5
  .scroll { height:100%; min-height:0; overflow-y:auto; background:#ffffff; color:#252936; color-scheme:light; container-type:inline-size; }
6
6
  .page { box-sizing:border-box; width:100%; max-width:1160px; margin:0 auto; padding:24px 26px 32px; font-family:system-ui,-apple-system,"Segoe UI",sans-serif; font-size:13px; }
7
7
  .page * { box-sizing:border-box; }
8
+ /* Freshness header: live badge + last-read stamp on the left, manual refresh
9
+ * pinned to the right edge. */
10
+ .head { display:flex; align-items:center; justify-content:space-between; gap:12px; margin:0 0 12px; }
11
+ .headStatus { display:flex; align-items:center; gap:7px; min-width:0; font-size:11px; color:#8790a3; }
12
+ .liveDot { width:6px; height:6px; border-radius:50%; background:#18a97b; box-shadow:0 0 0 3px #e5f6f0; flex-shrink:0; }
13
+ .asOf { color:#9aa3b3; font-variant-numeric:tabular-nums; white-space:nowrap; }
14
+ .refresh { display:inline-flex; align-items:center; gap:5px; flex:none; color:#6f7a8d; background:#fff; border:1px solid #e4e7ee; border-radius:8px; padding:5px 11px; font:inherit; font-size:11px; cursor:pointer; }
15
+ .refresh:hover { color:#279cb6; border-color:#cbd3e0; }
16
+ .refresh:disabled { opacity:.55; cursor:default; }
8
17
  .scope { color:#8790a3; font-size:11px; margin:0 0 14px; }
9
18
  .stats { display:grid; gap:12px; margin-bottom:18px; }
10
19
  .statRow { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:12px; }
@@ -57,7 +66,7 @@
57
66
  .empty { margin:0; padding:28px 18px; color:#9aa2b1; font-size:12px; text-align:center; }
58
67
  .action { color:#818b9e; background:none; border:0; padding:3px 0; font:inherit; font-size:11px; cursor:pointer; }
59
68
  .action:hover { color:#279cb6; }
60
- .action:focus-visible,.turnHead:focus-visible { outline:2px solid #2ab1c6; outline-offset:-2px; }
69
+ .action:focus-visible,.turnHead:focus-visible,.refresh:focus-visible { outline:2px solid #2ab1c6; outline-offset:-2px; }
61
70
  .turnList { padding:0 14px 14px; }
62
71
  .turn { margin-top:10px; border:1px solid #e7eaf0; border-radius:9px; background:#fff; overflow:hidden; }
63
72
  .turnHead { display:grid; grid-template-columns:12px minmax(0,1fr) auto; align-items:center; gap:12px; padding:13px 14px; width:100%; text-align:left; background:#f8f9fc; border:0; cursor:pointer; color:#616c7f; font:inherit; font-size:11px; }
@@ -2,7 +2,7 @@ import { useState } from 'react'
2
2
  import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
3
3
  import type { SessionUsageDetail, SessionUsageTotals } from '../wire.ts'
4
4
  import { billedTotal, cacheHitPercentOf } from './session-usage-core.ts'
5
- import { formatCompact, formatTokens, normalizeModelName } from './format.ts'
5
+ import { formatCompact, formatTokens, normalizeModelName, decodeRateOf } from './format.ts'
6
6
  import { SESSION_USAGE_NS, type SessionUsageKey } from './session-usage-locales.ts'
7
7
  import { useSessionDetail } from './use-session-detail.ts'
8
8
  import { ModelLogo } from './ModelLogo.tsx'
@@ -156,8 +156,11 @@ function Records({ detail, t }: { detail: SessionUsageDetail; t: T }): JSX.Eleme
156
156
  const totals = sum(group.rows)
157
157
  const billed = billedTotal(totals)
158
158
  const running = group.rows.filter(row => row.status === 'running').length
159
- const rates = group.rows.map(row => row.tokensPerSecond).filter((n): n is number => n !== null)
160
- const avgRate = rates.length ? Math.round(rates.reduce((a, b) => a + b, 0) / rates.length) : null
159
+ // Same decode based rate as the card and the composer bottom bar: the
160
+ // turn's output tokens over its summed decode windows, never the mean of
161
+ // independently rounded per-request rates.
162
+ const rate = decodeRateOf(group.rows)
163
+ const avgRate = rate === null ? null : Math.round(rate)
161
164
  return <article key={group.key} className={css.turn}>
162
165
  <button type="button" className={css.turnHead} onClick={() => toggle(group.key)} aria-expanded={open} aria-controls={`usage-turn-${encodeURIComponent(group.key)}`}>
163
166
  <svg className={`${css.chevron} ${open ? css.rotated : ''}`} width="12" height="12" viewBox="0 0 12 12" aria-hidden="true"><path d="m4 2 4 4-4 4" stroke="currentColor" strokeWidth="1.5" fill="none" /></svg>
@@ -172,7 +175,9 @@ function Records({ detail, t }: { detail: SessionUsageDetail; t: T }): JSX.Eleme
172
175
  </span>
173
176
  </button>
174
177
  <div id={`usage-turn-${encodeURIComponent(group.key)}`} className={css.turnBody} hidden={!open}>
175
- {group.rows.map(record => {
178
+ {/* Requests arrive in event order, so every turn lists its newest
179
+ request first; turn groups themselves keep chronological order. */}
180
+ {group.rows.slice().reverse().map(record => {
176
181
  const duration = record.finishedAt !== null ? record.finishedAt - record.startedAt : null
177
182
  const billed = billedTotal(record.totals)
178
183
  const hit = cacheHitPercentOf(record.totals)
@@ -189,8 +194,8 @@ function Records({ detail, t }: { detail: SessionUsageDetail; t: T }): JSX.Eleme
189
194
  <span className={`${css.statusDot} ${record.status === 'running' ? css.live : record.status === 'failed' ? css.bad : record.status === 'interrupted' ? css.muted : css.good}`} />
190
195
  <span className={css.recordModel} title={`${record.provider} / ${record.model}`}><ModelLogo provider={record.provider} model={record.model} className={css.modelLogo} />{record.model || '—'}</span>
191
196
  <span className={css.routeText} title={record.provider}>{providerNames.get(record.provider) || record.provider || '—'} · {modelShort(record.model) || '—'}</span>
192
- {statusText !== '' && <span className={record.status === 'running' ? css.live : record.status === 'failed' ? css.bad : css.muted}>{statusText}</span>}
193
197
  {record.effort ? <span className={css.effort}>· {record.effort}</span> : null}
198
+ {statusText !== '' && <span className={record.status === 'running' ? css.live : record.status === 'failed' ? css.bad : css.muted}>{statusText}</span>}
194
199
  </div>
195
200
  {record.finishedAt !== null && <div className={css.recordMeta}>{meta.map((item, i) => <span key={i} title={item.title}>{i > 0 ? ' · ' : ''}{item.text}</span>)}</div>}
196
201
  </div>
@@ -207,10 +212,24 @@ export function SessionUsageContent({ detail, t }: { detail: SessionUsageDetail;
207
212
  }
208
213
 
209
214
  export function SessionUsageView({ sessionId, t }: SessionUsageViewProps): JSX.Element {
210
- const { detail, loading, error, retry } = useSessionDetail(sessionId)
215
+ const { detail, loading, error, refresh, updatedAt } = useSessionDetail(sessionId)
211
216
  return <div className={css.scroll} data-dsh-session-usage><div className={css.page}>
217
+ {/* Freshness header: the view polls continuously, so it always shows a live
218
+ badge with the time of the last successful read, plus a manual refresh
219
+ that refetches immediately. */}
220
+ <header className={css.head}>
221
+ <span className={css.headStatus}>
222
+ <span className={css.liveDot} aria-hidden="true" />
223
+ <span>{t('live')}</span>
224
+ {updatedAt !== null && <span className={css.asOf}>{t('asOf')} {clock(updatedAt)}</span>}
225
+ </span>
226
+ <button type="button" className={css.refresh} onClick={refresh} disabled={loading}>
227
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8" /><path d="M21 3v5h-5" /></svg>
228
+ {t('refresh')}
229
+ </button>
230
+ </header>
212
231
  {loading && <p className={css.empty} role="status">{t('loading')}</p>}
213
- {error && <div className={css.error} role="status">{t('failedLoad')}<button type="button" className={css.action} onClick={retry}>{t('retry')}</button></div>}
232
+ {error && <div className={css.error} role="status">{t('failedLoad')}<button type="button" className={css.action} onClick={refresh}>{t('retry')}</button></div>}
214
233
  {detail && <SessionUsageContent detail={detail} t={t} />}
215
234
  </div></div>
216
235
  }
@@ -10,7 +10,7 @@
10
10
 
11
11
  // Shared with the host half (src/shared.ts has no Node/DOM types, so the
12
12
  // client bundle may import it at runtime).
13
- export { providerOf, modelNameOf, normalizeModelName, daysInRange as daysBetween } from '../shared.ts'
13
+ export { providerOf, modelNameOf, normalizeModelName, daysInRange as daysBetween, decodeRateOf } from '../shared.ts'
14
14
 
15
15
  /** Exact token counts with thousands separators — no 万/亿/k/M units, the
16
16
  * user reads the precise number (long values shrink to fit via FitText).
@@ -15,6 +15,7 @@ export const usageZh = {
15
15
  provider: '供应商', status: '状态', exact: '完整 token 数',
16
16
  answering: '正在回答...', firstWait: '首字耗时', sec: '秒', tokenWord: 'token', perSec: 'token/秒',
17
17
  cacheShort: '缓存', avgCache: '平均缓存', avgRate: '平均速率', inProgress: '个进行中',
18
+ live: '实时更新', asOf: '统计截至', refresh: '刷新',
18
19
  }
19
20
  export type SessionUsageKey = keyof typeof usageZh
20
21
  export const usageEn: Record<SessionUsageKey, string> = {
@@ -32,6 +33,7 @@ export const usageEn: Record<SessionUsageKey, string> = {
32
33
  provider: 'Provider', status: 'Status', exact: 'Exact token count',
33
34
  answering: 'answering...', firstWait: 'first token', sec: 's', tokenWord: 'token', perSec: 'token/s',
34
35
  cacheShort: 'cache', avgCache: 'avg cache', avgRate: 'avg rate', inProgress: 'in progress',
36
+ live: 'Live', asOf: 'as of', refresh: 'Refresh',
35
37
  }
36
38
  export const usageZhTW: Record<SessionUsageKey, string> = {
37
39
  ...usageZh,
@@ -47,4 +49,5 @@ export const usageZhTW: Record<SessionUsageKey, string> = {
47
49
  main: '主會話', turn: '輪', step: '步', started: '開始', duration: '耗時', firstDetail: '首 token', provider: '供應商', status: '狀態', exact: '完整 token 數',
48
50
  answering: '正在回答...', firstWait: '首字耗時', sec: '秒', tokenWord: 'token', perSec: 'token/秒',
49
51
  cacheShort: '快取', avgCache: '平均快取', avgRate: '平均速率', inProgress: '個進行中',
52
+ live: '即時更新', asOf: '統計截至', refresh: '重新整理',
50
53
  }
@@ -10,13 +10,17 @@ export function useSessionDetail(sessionId: string): {
10
10
  detail: SessionUsageDetail | null
11
11
  loading: boolean
12
12
  error: boolean
13
- retry: () => void
13
+ /** Manual refresh: runs now when idle, or right after an in-flight poll lands. */
14
+ refresh: () => void
15
+ /** Wall-clock time of the last successful read, for the "as of" stamp. */
16
+ updatedAt: number | null
14
17
  } {
15
- const [reading, setReading] = useState<{ sessionId: string; detail: SessionUsageDetail | null; error: boolean } | null>(null)
18
+ const [reading, setReading] = useState<{ sessionId: string; detail: SessionUsageDetail | null; error: boolean; updatedAt: number | null } | null>(null)
16
19
  const refresh = useRef<(() => void) | null>(null)
17
20
  useEffect(() => {
18
21
  let active = true
19
22
  let pending = false
23
+ let wantedNow = false
20
24
  let timer: ReturnType<typeof setTimeout> | undefined
21
25
  let controller: AbortController | undefined
22
26
  const request = (): void => {
@@ -25,16 +29,21 @@ export function useSessionDetail(sessionId: string): {
25
29
  pending = true
26
30
  controller = new AbortController()
27
31
  void fetchSessionDetail(sessionId, controller.signal).then(detail => {
28
- if (active) setReading({ sessionId, detail, error: false })
32
+ if (active) setReading({ sessionId, detail, error: false, updatedAt: Date.now() })
29
33
  }).catch(() => {
30
- if (active) setReading(previous => ({ sessionId, detail: previous?.sessionId === sessionId ? previous.detail : null, error: true }))
34
+ if (active) setReading(previous => ({ sessionId, detail: previous?.sessionId === sessionId ? previous.detail : null, error: true, updatedAt: previous?.sessionId === sessionId ? previous.updatedAt : null }))
31
35
  }).finally(() => {
32
36
  pending = false
33
- if (active) timer = setTimeout(request, DETAIL_POLL_MS)
37
+ // A refresh pressed mid-poll skips the regular interval and refetches
38
+ // as soon as the in-flight answer lands, so the click never stalls.
39
+ if (active) timer = setTimeout(request, wantedNow ? 0 : DETAIL_POLL_MS)
40
+ wantedNow = false
34
41
  })
35
42
  }
36
43
  const wake = (): void => { if (document.visibilityState === 'visible') request() }
37
- refresh.current = request
44
+ // Manual refresh: fire immediately when idle; when a poll is already in
45
+ // flight, mark it and refetch right after it settles.
46
+ refresh.current = () => { if (pending) wantedNow = true; else request() }
38
47
  document.addEventListener('visibilitychange', wake)
39
48
  request()
40
49
  return () => {
@@ -46,5 +55,5 @@ export function useSessionDetail(sessionId: string): {
46
55
  }
47
56
  }, [sessionId])
48
57
  const current = reading?.sessionId === sessionId ? reading : null
49
- return { detail: current?.detail ?? null, loading: current === null, error: current?.error ?? false, retry: () => refresh.current?.() }
58
+ return { detail: current?.detail ?? null, loading: current === null, error: current?.error ?? false, refresh: () => refresh.current?.(), updatedAt: current?.updatedAt ?? null }
50
59
  }
@@ -7,6 +7,10 @@ import { modelNameOf, providerOf } from './shared.ts'
7
7
  const LOOKUP_CONCURRENCY = 4
8
8
  const LOOKUP_TIMEOUT_MS = 2000
9
9
 
10
+ /** Raised when a lookup's own timer fires — i.e. the shared phase budget is
11
+ * spent. Workers stop on this instead of trusting the wall clock again. */
12
+ class LookupTimeoutError extends Error {}
13
+
10
14
  /** Bound metadata queries even if an adapter fails to honor cancellation. */
11
15
  async function lookup<T>(read: (signal: AbortSignal) => Promise<T>, timeoutMs: number): Promise<T> {
12
16
  const controller = new AbortController()
@@ -17,7 +21,7 @@ async function lookup<T>(read: (signal: AbortSignal) => Promise<T>, timeoutMs: n
17
21
  new Promise<never>((_, reject) => {
18
22
  timer = setTimeout(() => {
19
23
  controller.abort()
20
- reject(new Error('model display-name lookup timed out'))
24
+ reject(new LookupTimeoutError('model display-name lookup timed out'))
21
25
  }, timeoutMs)
22
26
  }),
23
27
  ])
@@ -81,8 +85,12 @@ export async function resolveModelNames(
81
85
  try {
82
86
  const info = await lookup((signal) => llm.resolveModelInfo!(providerOf(ref), modelNameOf(ref), signal), remaining)
83
87
  if (info.name?.trim()) names.set(ref, info.name)
84
- } catch {
88
+ } catch (error) {
85
89
  // Metadata is auxiliary: never lose usage because a route is gone.
90
+ // The shared budget is spent once our own timer fires; stop instead
91
+ // of trusting the clock again (setTimeout vs Date.now can disagree
92
+ // by ~1ms and would otherwise queue extra lookups past the deadline).
93
+ if (error instanceof LookupTimeoutError) break
86
94
  }
87
95
  }
88
96
  }))
@@ -105,8 +113,10 @@ export async function resolveModelNames(
105
113
  const name = byId.get(modelNameOf(ref))
106
114
  if (name?.trim()) names.set(ref, name)
107
115
  }
108
- } catch {
109
- // A dormant provider must not prevent historical totals from loading.
116
+ } catch (error) {
117
+ // A dormant provider must not prevent historical totals from loading;
118
+ // a fired timer still ends the whole catalog phase (see above).
119
+ if (error instanceof LookupTimeoutError) break
110
120
  }
111
121
  }
112
122
  }))
@@ -9,6 +9,7 @@ import type {
9
9
  } from './context-types.ts'
10
10
  import { UsageFold } from './collector.ts'
11
11
  import { resolveModelNames, resolveProviderNames } from './model-names.ts'
12
+ import { decodeRateOf } from './shared.ts'
12
13
  import type { SessionUsageDetail, SessionUsageTotals } from './wire.ts'
13
14
 
14
15
  const zero = (): SessionUsageTotals => ({ uncachedInputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 })
@@ -20,20 +21,65 @@ function usageOf(x: any): SessionUsageTotals {
20
21
  const u = x?.usage ?? x
21
22
  return { uncachedInputTokens: Number(u?.inputTokens) || 0, outputTokens: Number(u?.outputTokens) || 0, cacheReadTokens: Number(u?.cacheReadTokens) || 0, cacheWriteTokens: Number(u?.cacheWriteTokens) || 0 }
22
23
  }
23
- function positive(u: SessionUsageTotals): boolean { return u.uncachedInputTokens + u.outputTokens + u.cacheReadTokens + u.cacheWriteTokens > 0 }
24
24
  function routeOf(x: any): { provider: string; model: string } {
25
25
  const source = x?.message?.source ?? x?.source ?? x?.request ?? x?.context ?? x
26
26
  return { provider: typeof source?.provider === 'string' ? source.provider : '', model: typeof source?.model === 'string' ? source.model : '' }
27
27
  }
28
- function streamUsage(data: any): { usage?: SessionUsageTotals; firstTime?: number } {
29
- let firstTime: number | undefined
28
+ /** Whether one stream chunk carries the model's first output token. Mirrors
29
+ * `isTokenDelta` in @deepseek-ai/dsh-llm so the decode window starts on the
30
+ * same chunk the official `sessionStats` projection starts it on. */
31
+ function isTokenDelta(chunk: any): boolean {
32
+ switch (chunk?.type) {
33
+ case 'text-delta': case 'reasoning-delta': return chunk.text !== ''
34
+ case 'tool-call-delta': return chunk.argumentsDelta !== '' || chunk.name !== undefined
35
+ default: return false
36
+ }
37
+ }
38
+ /** Reconstructed time of the first token in one packed delta run (mirrors
39
+ * `runFirstTokenTime`): a name-bearing tool-call run starts at its first
40
+ * member, every other run at its first non-empty fragment. */
41
+ function runFirstTokenTime(run: any): number | undefined {
42
+ if (run?.type === 'tool-call-chunks' && run.name !== undefined) return run.time0
43
+ const fragments = run?.type === 'tool-call-chunks' ? run.args : run?.texts
44
+ if (!Array.isArray(fragments)) return undefined
45
+ let time = Number(run.time0)
46
+ for (let i = 0; i < fragments.length; i++) {
47
+ if (i > 0) time += Number(run.dt?.[i - 1]) || 0
48
+ if (fragments[i] !== '') return time
49
+ }
50
+ return undefined
51
+ }
52
+ /** Time of the first token in one compact attempt stream (mirrors
53
+ * `assistantStreamFirstTokenTime`). */
54
+ function streamFirstTokenTime(stream: any): number | undefined {
55
+ for (const r of Array.isArray(stream) ? stream : []) {
56
+ const t = r?.type === 'chunk' ? (isTokenDelta(r.chunk) ? r.time : undefined) : runFirstTokenTime(r)
57
+ if (typeof t === 'number' && Number.isFinite(t)) return t
58
+ }
59
+ return undefined
60
+ }
61
+ /** Time of the first token carried by one live `assistant/chunk` payload. */
62
+ function chunkFirstTokenTime(data: any, fallback: number): number | undefined {
63
+ const c = data?.chunk
64
+ if (!c) return undefined
65
+ const timed = c.chunk && typeof c.time === 'number' ? c : null
66
+ if (!isTokenDelta(timed ? timed.chunk : c)) return undefined
67
+ return timed ? timed.time : typeof c.time === 'number' ? c.time : fallback
68
+ }
69
+ function streamUsage(data: any): { usage?: SessionUsageTotals; raw?: any } {
30
70
  let usage: SessionUsageTotals | undefined
71
+ let raw: any
31
72
  for (const r of Array.isArray(data?.stream) ? data.stream : []) {
32
- const t = typeof r?.time === 'number' ? r.time : undefined
33
- if (firstTime === undefined && t !== undefined && (r?.type === 'chunk' || r?.chunk?.type === 'text' || r?.chunk?.type === 'content')) firstTime = t
34
- if (r?.type === 'chunk' && r?.chunk?.type === 'usage') usage = usageOf(r.chunk)
73
+ if (r?.type === 'chunk' && r?.chunk?.type === 'usage') { usage = usageOf(r.chunk); raw = r.chunk.usage ?? r.chunk }
35
74
  }
36
- return { usage, firstTime }
75
+ return { usage, raw }
76
+ }
77
+ /** Provider-reported completion tokens, or null when unreported or invalid:
78
+ * an invalid count must not enter the decode quotient (mirrors
79
+ * `usageOutputTokens` in the sessionStats projection). */
80
+ function usageOutputTokens(x: any): number | null {
81
+ const value = (x?.usage ?? x)?.outputTokens
82
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null
37
83
  }
38
84
  function headerOf(item: UsageSessionHeader | UsageSessionSnapshot): UsageSessionHeader | null {
39
85
  const x: any = item
@@ -83,6 +129,12 @@ async function one(p: UsageSessionPersistence, id: string, header: UsageSessionH
83
129
  if (label && title) break
84
130
  }
85
131
  const name = label || title || nameOf(header, id)
132
+ // Decode accounting mirrors the official `sessionStats` projection: a step
133
+ // records its first token once (the earliest attempt wins, surviving an
134
+ // in-step retry), and only the assembled `assistant/message` closes the
135
+ // decode window, so failed attempts and cancelled steps stay uncounted.
136
+ const stepTokens = new Map<string, number>()
137
+ const closedSteps = new Set<string>()
86
138
  for (const ev of own) {
87
139
  const d: any = ev.data ?? {}; const turn = Number.isFinite(d.turn) ? d.turn : 0; const step = Number.isFinite(d.step) ? d.step : 0
88
140
  if (ev.type === 'request/context') { route = { ...route, ...routeOf(d) }; continue }
@@ -110,28 +162,41 @@ async function one(p: UsageSessionPersistence, id: string, header: UsageSessionH
110
162
  if (ev.type === 'llm/retry-started' || !byKey.has(key)) {
111
163
  const previous = byKey.get(key)
112
164
  if (previous && ev.type === 'llm/retry-started' && previous.status === 'running') { previous.status = 'failed'; previous.finishedAt = ev.time }
113
- rec = { id: `${id}:${turn}:${step}:${ordinal++}`, sessionId: id, sessionName: name, turn, step, startedAt: ev.time, finishedAt: null, provider: r.provider || route.provider || '', model: r.model || route.model || '', status: 'running', totals: zero(), firstTokenMs: null, tokensPerSecond: null, turnName: '', effort, _attempt: ev.type === 'llm/retry-started' ? 1 : 0, _lastUsage: zero() }
165
+ rec = { id: `${id}:${turn}:${step}:${ordinal++}`, sessionId: id, sessionName: name, turn, step, startedAt: ev.time, finishedAt: null, provider: r.provider || route.provider || '', model: r.model || route.model || '', status: 'running', totals: zero(), firstTokenMs: null, decodeMs: null, tokensPerSecond: null, turnName: '', effort, _attempt: ev.type === 'llm/retry-started' ? 1 : 0, _lastUsage: zero() }
114
166
  records.push(rec); byKey.set(key, rec)
115
167
  }
116
168
  } else if (ev.type === 'assistant/chunk' || ev.type === 'assistant/message' || ev.type === 'assistant/attempt') {
117
- rec = byKey.get(`${turn}:${step}`)
118
- if (!rec) { rec = { id: `${id}:${turn}:${step}:${ordinal++}`, sessionId: id, sessionName: name, turn, step, startedAt: ev.time, finishedAt: null, provider: r.provider || route.provider || '', model: r.model || route.model || '', status: 'running', totals: zero(), firstTokenMs: null, tokensPerSecond: null, turnName: '', effort, _attempt: 0, _lastUsage: zero() }; records.push(rec); byKey.set(`${turn}:${step}`, rec) }
169
+ const key = `${turn}:${step}`
170
+ rec = byKey.get(key)
171
+ if (!rec) { rec = { id: `${id}:${turn}:${step}:${ordinal++}`, sessionId: id, sessionName: name, turn, step, startedAt: ev.time, finishedAt: null, provider: r.provider || route.provider || '', model: r.model || route.model || '', status: 'running', totals: zero(), firstTokenMs: null, decodeMs: null, tokensPerSecond: null, turnName: '', effort, _attempt: 0, _lastUsage: zero() }; records.push(rec); byKey.set(key, rec) }
119
172
  if (r.provider) rec.provider = r.provider; if (r.model) rec.model = r.model
120
173
  const su = streamUsage(d); const u = ev.type === 'assistant/message' ? (d.usage ? usageOf(d) : su.usage) : ev.type === 'assistant/chunk' ? (d.chunk?.type === 'usage' ? usageOf(d.chunk) : undefined) : (d.usage ? usageOf(d) : su.usage)
121
- if (u) {
122
- rec._lastUsage = u; rec.totals = u
123
- if (positive(u) && rec.firstTokenMs === null) {
124
- const rawFirst = su.firstTime ?? (typeof d.chunk?.time === 'number' ? d.chunk.time : ev.time)
125
- rec.firstTokenMs = Math.max(0, rawFirst - rec.startedAt)
174
+ if (u) { rec._lastUsage = u; rec.totals = u }
175
+ // The first token opens both this row's first-token wait and the step's
176
+ // decode window; the earliest attempt keeps it across an in-step retry.
177
+ const tokenAt = streamFirstTokenTime(d.stream) ?? chunkFirstTokenTime(d, ev.time)
178
+ if (tokenAt !== undefined) {
179
+ if (!stepTokens.has(key)) stepTokens.set(key, tokenAt)
180
+ if (rec.firstTokenMs === null) rec.firstTokenMs = Math.max(0, tokenAt - rec.startedAt)
181
+ }
182
+ if (ev.type !== 'assistant/chunk') {
183
+ rec.finishedAt = ev.time; rec.status = ev.type === 'assistant/attempt' ? 'failed' : 'completed'
184
+ // Only an assembled message closes the decode window, and only its own
185
+ // top-level `usage` counts, exactly as the official sessionStats
186
+ // projection counts it: a failed attempt, a cancelled step or a
187
+ // stream-only usage sample contributes no decode time, so the card, the
188
+ // turn summaries and the composer bottom bar all quote one speed.
189
+ const stepFirst = stepTokens.get(key)
190
+ if (rec.status === 'completed' && !closedSteps.has(key) && stepFirst !== undefined && usageOutputTokens(d.usage) !== null) {
191
+ closedSteps.add(key)
192
+ rec.decodeMs = Math.max(0, rec.finishedAt - stepFirst)
193
+ if (rec.decodeMs > 0 && rec.totals.outputTokens > 0) rec.tokensPerSecond = rec.totals.outputTokens / (rec.decodeMs / 1000)
126
194
  }
127
195
  }
128
- if (ev.type !== 'assistant/chunk') { rec.finishedAt = ev.time; rec.status = ev.type === 'assistant/attempt' ? 'failed' : 'completed' }
129
- const elapsedMs = rec.finishedAt === null ? null : Math.max(0, rec.finishedAt - rec.startedAt)
130
- if (elapsedMs !== null && elapsedMs > 0 && rec.totals.outputTokens > 0) rec.tokensPerSecond = rec.totals.outputTokens / (elapsedMs / 1000)
131
196
  } else if (ev.type === 'turn/end') {
132
197
  const recs = records.filter(x => x.turn === turn && x.status === 'running'); for (const x of recs) { x.status = 'interrupted'; x.finishedAt = ev.time }
133
198
  } else if (ev.type === 'compaction/summary') {
134
- const u = usageOf(d); rec = { id: `${id}:compaction:${ordinal++}`, sessionId: id, sessionName: name, turn: 0, step: 0, startedAt: ev.time, finishedAt: ev.time, provider: typeof d.provider === 'string' ? d.provider : '', model: typeof d.model === 'string' ? d.model : '', status: 'completed', totals: u, firstTokenMs: null, tokensPerSecond: null, turnName: '', effort: '', _attempt: 0, _lastUsage: u }; records.push(rec)
199
+ const u = usageOf(d); rec = { id: `${id}:compaction:${ordinal++}`, sessionId: id, sessionName: name, turn: 0, step: 0, startedAt: ev.time, finishedAt: ev.time, provider: typeof d.provider === 'string' ? d.provider : '', model: typeof d.model === 'string' ? d.model : '', status: 'completed', totals: u, firstTokenMs: null, decodeMs: null, tokensPerSecond: null, turnName: '', effort: '', _attempt: 0, _lastUsage: u }; records.push(rec)
135
200
  }
136
201
  const sample = fold.fold(ev); if (sample && !sample.turn) add(totals, { uncachedInputTokens: sample.inputTokens, outputTokens: sample.outputTokens, cacheReadTokens: sample.cacheReadTokens, cacheWriteTokens: sample.cacheWriteTokens })
137
202
  }
@@ -139,11 +204,11 @@ async function one(p: UsageSessionPersistence, id: string, header: UsageSessionH
139
204
  // a stream sample disappear, so reconcile aggregate totals from the records only
140
205
  // for presentation while `totals` remains the authoritative UsageFold result.
141
206
  const requests = records.length
142
- const firsts = records.map(x => x.firstTokenMs).filter((x): x is number => x !== null)
143
- const outputSeconds = records.reduce((n, x) => n + x.totals.outputTokens, 0)
144
- const elapsed = records.reduce((n, x) => n + (x.firstTokenMs ?? 0), 0)
207
+ // The first-token average quotes answered requests only: a failed attempt or
208
+ // a cancelled step is not a wait the reader experienced as an answer.
209
+ const firsts = records.filter(x => x.status === 'completed').map(x => x.firstTokenMs).filter((x): x is number => x !== null)
145
210
  for (const x of records) { x.turnName = turnTopics.get(x.turn) ?? ''; delete (x as any)._attempt; delete (x as any)._lastUsage }
146
- return { id, name, header, totals, records: records as SessionUsageDetail['records'], requests, averageFirstTokenMs: firsts.length ? firsts.reduce((a, b) => a + b, 0) / firsts.length : null, tokensPerSecond: elapsed > 0 ? outputSeconds / (elapsed / 1000) : null }
211
+ return { id, name, header, totals, records: records as SessionUsageDetail['records'], requests, averageFirstTokenMs: firsts.length ? firsts.reduce((a, b) => a + b, 0) / firsts.length : null, tokensPerSecond: decodeRateOf(records) }
147
212
  }
148
213
 
149
214
  export async function buildSessionUsageDetail(persistence: UsageSessionPersistence, sessions: UsageSessionStore | undefined, rootId: string, ctx?: Context): Promise<SessionUsageDetail> {
@@ -155,7 +220,7 @@ export async function buildSessionUsageDetail(persistence: UsageSessionPersisten
155
220
  const ids: string[] = []; const seen = new Set([rootId]); const q = [rootId]
156
221
  while (q.length) for (const c of children.get(q.shift()!) ?? []) if (!seen.has(c)) { seen.add(c); ids.push(c); q.push(c) }
157
222
  const all = [rootId, ...ids]; const parts = await Promise.all(all.map(id => one(persistence, id, headers.find(h => h.id === id), live.get(id))))
158
- const root = parts[0]; const totals = zero(); const models = new Map<string, { totals: SessionUsageTotals; requests: number; providers: Set<string> }>(); const records = parts.flatMap(x => x.records); for (const p of parts) add(totals, p.totals)
223
+ const totals = zero(); const models = new Map<string, { totals: SessionUsageTotals; requests: number; providers: Set<string> }>(); const records = parts.flatMap(x => x.records); for (const p of parts) add(totals, p.totals)
159
224
  for (const r of records) { const key = r.model || '(unknown)'; const m = models.get(key) ?? { totals: zero(), requests: 0, providers: new Set<string>() }; add(m.totals, r.totals); m.requests++; if (r.provider) m.providers.add(r.provider); models.set(key, m) }
160
225
  const subagents = parts.slice(1).map(p => ({ sessionId: p.id, name: p.name, totals: p.totals, requests: p.requests, averageFirstTokenMs: p.averageFirstTokenMs, tokensPerSecond: p.tokensPerSecond }))
161
226
  const routeMap = new Map<string, SessionUsageDetail['routes'][number]>()
@@ -178,5 +243,8 @@ export async function buildSessionUsageDetail(persistence: UsageSessionPersisten
178
243
  for (const key of keys) { const resolved = names.get(key)?.trim(); if (resolved) return resolved }
179
244
  return undefined
180
245
  }
181
- return { sessionId: rootId, totals, requests: parts.reduce((n, p) => n + p.requests, 0), averageFirstTokenMs: root.averageFirstTokenMs, tokensPerSecond: root.tokensPerSecond, subagents, models: [...models.entries()].map(([model, x]) => { const name = displayNameOf(model, x.providers); return { model, ...(name ? { name } : {}), providers: [...x.providers].map(provider => providerNames[provider] || provider), totals: x.totals, requests: x.requests } }), records, routes: [...routeMap.values()] }
246
+ // The card is scoped to this session and every subagent, so its speed and
247
+ // first-token average fold the same records its token totals do.
248
+ const firsts = records.filter(x => x.status === 'completed').map(x => x.firstTokenMs).filter((x): x is number => x !== null)
249
+ return { sessionId: rootId, totals, requests: parts.reduce((n, p) => n + p.requests, 0), averageFirstTokenMs: firsts.length ? firsts.reduce((a, b) => a + b, 0) / firsts.length : null, tokensPerSecond: decodeRateOf(records), subagents, models: [...models.entries()].map(([model, x]) => { const name = displayNameOf(model, x.providers); return { model, ...(name ? { name } : {}), providers: [...x.providers].map(provider => providerNames[provider] || provider), totals: x.totals, requests: x.requests } }), records, routes: [...routeMap.values()] }
182
250
  }
package/src/shared.ts CHANGED
@@ -53,6 +53,29 @@ export function modelNameOf(modelRef: string): string {
53
53
  return modelRef
54
54
  }
55
55
 
56
+ /** The decode accounting one request row must expose: the wall window from the
57
+ * step's first token to its assembled assistant message, plus the output tokens
58
+ * that window produced. */
59
+ export interface DecodeRowLike {
60
+ decodeMs: number | null
61
+ totals: { outputTokens: number }
62
+ }
63
+
64
+ /** Decode throughput across rows: Σ(output tokens) / Σ(decode window), matching
65
+ * the `sessionStats` projection's decodeTokens/decodeMs rate that the composer
66
+ * bottom bar shows. Rows without a decode window (failed attempts, cancelled
67
+ * steps, running requests) are excluded, so the card, the turn summaries and
68
+ * the bottom bar all report one speed. */
69
+ export function decodeRateOf(rows: readonly DecodeRowLike[]): number | null {
70
+ let ms = 0
71
+ let tokens = 0
72
+ // typeof, not `!== null`: a bundle newer than the payload it is handed (a
73
+ // cached detail, an older server) leaves `decodeMs` absent, and one absent
74
+ // field must not turn the whole sum into NaN.
75
+ for (const row of rows) if (typeof row.decodeMs === 'number' && Number.isFinite(row.decodeMs)) { ms += row.decodeMs; tokens += row.totals.outputTokens }
76
+ return ms > 0 ? tokens / (ms / 1000) : null
77
+ }
78
+
56
79
  /** Canonicalize a DISPLAY NAME, never a route id. Case and word separators
57
80
  * do not distinguish models, but versions, punctuation and suffixes do:
58
81
  * gpt-6.1-sol / GPT 6.1 sol -> gpt-6.1-sol, not gpt-6.1 or gpt-61-sol.
package/src/wire.ts CHANGED
@@ -179,6 +179,11 @@ export interface SessionUsageDetail {
179
179
  status: 'running' | 'completed' | 'failed' | 'interrupted'
180
180
  totals: SessionUsageTotals
181
181
  firstTokenMs: number | null
182
+ /** Wall window from this step's first token to the assembled assistant
183
+ * message — the same decode window the `sessionStats` projection counts
184
+ * (decodeMs). Null when the request never assembled a message or reported
185
+ * no usage, so it stays out of every throughput quotient. */
186
+ decodeMs: number | null
182
187
  tokensPerSecond: number | null
183
188
  turnName: string
184
189
  effort: string