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 +93 -19
- package/lib/index.js +100 -27
- package/lib/types/client/format.d.ts +1 -1
- package/lib/types/client/session-usage-locales.d.ts +3 -0
- package/lib/types/client/use-session-detail.d.ts +4 -1
- package/lib/types/shared.d.ts +15 -0
- package/lib/types/wire.d.ts +5 -0
- package/package.json +1 -1
- package/src/client/SessionUsageView.module.css +10 -1
- package/src/client/SessionUsageView.tsx +26 -7
- package/src/client/format.ts +1 -1
- package/src/client/session-usage-locales.ts +3 -0
- package/src/client/use-session-detail.ts +16 -7
- package/src/model-names.ts +14 -4
- package/src/session-detail.ts +93 -25
- package/src/shared.ts +23 -0
- package/src/wire.ts +5 -0
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 =
|
|
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
|
-
|
|
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
|
|
33320
|
-
const avgRate =
|
|
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,
|
|
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:
|
|
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(
|
|
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
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
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:
|
|
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:
|
|
1860
|
-
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
|
-
|
|
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
|
};
|
package/lib/types/shared.d.ts
CHANGED
|
@@ -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.
|
package/lib/types/wire.d.ts
CHANGED
|
@@ -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
|
@@ -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
|
-
|
|
160
|
-
|
|
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
|
-
{
|
|
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,
|
|
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={
|
|
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
|
}
|
package/src/client/format.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
58
|
+
return { detail: current?.detail ?? null, loading: current === null, error: current?.error ?? false, refresh: () => refresh.current?.(), updatedAt: current?.updatedAt ?? null }
|
|
50
59
|
}
|
package/src/model-names.ts
CHANGED
|
@@ -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
|
|
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
|
}))
|
package/src/session-detail.ts
CHANGED
|
@@ -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
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
118
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
const
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|