dsh-token-total 1.0.5 → 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/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.
@@ -1400,6 +1414,8 @@ var UsageCollector = class UsageCollector {
1400
1414
  const clean = !signal?.aborted && this.status.error === void 0;
1401
1415
  if ((target === "hours" || dayCursorWasEmpty) && clean) await this.store.markHoursReady();
1402
1416
  if ((target === "failures" || dayCursorWasEmpty) && clean) await this.store.markFailuresReady();
1417
+ } catch (err) {
1418
+ this.status.error = err instanceof Error ? err.message : String(err);
1403
1419
  } finally {
1404
1420
  this.status.running = false;
1405
1421
  }
@@ -1409,6 +1425,9 @@ var UsageCollector = class UsageCollector {
1409
1425
  //#region src/model-names.ts
1410
1426
  const LOOKUP_CONCURRENCY = 4;
1411
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 {};
1412
1431
  /** Bound metadata queries even if an adapter fails to honor cancellation. */
1413
1432
  async function lookup(read, timeoutMs) {
1414
1433
  const controller = new AbortController();
@@ -1417,7 +1436,7 @@ async function lookup(read, timeoutMs) {
1417
1436
  return await Promise.race([Promise.resolve().then(() => read(controller.signal)), new Promise((_, reject) => {
1418
1437
  timer = setTimeout(() => {
1419
1438
  controller.abort();
1420
- reject(/* @__PURE__ */ new Error("model display-name lookup timed out"));
1439
+ reject(new LookupTimeoutError("model display-name lookup timed out"));
1421
1440
  }, timeoutMs);
1422
1441
  })]);
1423
1442
  } finally {
@@ -1468,7 +1487,9 @@ async function resolveModelNames(modelRefs, llm) {
1468
1487
  try {
1469
1488
  const info = await lookup((signal) => llm.resolveModelInfo(providerOf(ref), modelNameOf(ref), signal), remaining);
1470
1489
  if (info.name?.trim()) names.set(ref, info.name);
1471
- } catch {}
1490
+ } catch (error) {
1491
+ if (error instanceof LookupTimeoutError) break;
1492
+ }
1472
1493
  }
1473
1494
  }));
1474
1495
  }
@@ -1488,7 +1509,9 @@ async function resolveModelNames(modelRefs, llm) {
1488
1509
  const name = byId.get(modelNameOf(ref));
1489
1510
  if (name?.trim()) names.set(ref, name);
1490
1511
  }
1491
- } catch {}
1512
+ } catch (error) {
1513
+ if (error instanceof LookupTimeoutError) break;
1514
+ }
1492
1515
  }
1493
1516
  }));
1494
1517
  }
@@ -1517,9 +1540,6 @@ function usageOf(x) {
1517
1540
  cacheWriteTokens: Number(u?.cacheWriteTokens) || 0
1518
1541
  };
1519
1542
  }
1520
- function positive(u) {
1521
- return u.uncachedInputTokens + u.outputTokens + u.cacheReadTokens + u.cacheWriteTokens > 0;
1522
- }
1523
1543
  function routeOf(x) {
1524
1544
  const source = x?.message?.source ?? x?.source ?? x?.request ?? x?.context ?? x;
1525
1545
  return {
@@ -1527,19 +1547,65 @@ function routeOf(x) {
1527
1547
  model: typeof source?.model === "string" ? source.model : ""
1528
1548
  };
1529
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
+ }
1530
1590
  function streamUsage(data) {
1531
- let firstTime;
1532
1591
  let usage;
1533
- for (const r of Array.isArray(data?.stream) ? data.stream : []) {
1534
- const t = typeof r?.time === "number" ? r.time : void 0;
1535
- if (firstTime === void 0 && t !== void 0 && (r?.type === "chunk" || r?.chunk?.type === "text" || r?.chunk?.type === "content")) firstTime = t;
1536
- if (r?.type === "chunk" && r?.chunk?.type === "usage") usage = usageOf(r.chunk);
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;
1537
1596
  }
1538
1597
  return {
1539
1598
  usage,
1540
- firstTime
1599
+ raw
1541
1600
  };
1542
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
+ }
1543
1609
  function headerOf$1(item) {
1544
1610
  const x = item;
1545
1611
  if (x?.header?.id && typeof x.header.id === "string") return x.header;
@@ -1605,6 +1671,8 @@ async function one(p, id, header, live) {
1605
1671
  if (label && title) break;
1606
1672
  }
1607
1673
  const name = label || title || nameOf(header, id);
1674
+ const stepTokens = /* @__PURE__ */ new Map();
1675
+ const closedSteps = /* @__PURE__ */ new Set();
1608
1676
  for (const ev of own) {
1609
1677
  const d = ev.data ?? {};
1610
1678
  const turn = Number.isFinite(d.turn) ? d.turn : 0;
@@ -1663,6 +1731,7 @@ async function one(p, id, header, live) {
1663
1731
  status: "running",
1664
1732
  totals: zero(),
1665
1733
  firstTokenMs: null,
1734
+ decodeMs: null,
1666
1735
  tokensPerSecond: null,
1667
1736
  turnName: "",
1668
1737
  effort,
@@ -1673,7 +1742,8 @@ async function one(p, id, header, live) {
1673
1742
  byKey.set(key, rec);
1674
1743
  }
1675
1744
  } else if (ev.type === "assistant/chunk" || ev.type === "assistant/message" || ev.type === "assistant/attempt") {
1676
- rec = byKey.get(`${turn}:${step}`);
1745
+ const key = `${turn}:${step}`;
1746
+ rec = byKey.get(key);
1677
1747
  if (!rec) {
1678
1748
  rec = {
1679
1749
  id: `${id}:${turn}:${step}:${ordinal++}`,
@@ -1688,6 +1758,7 @@ async function one(p, id, header, live) {
1688
1758
  status: "running",
1689
1759
  totals: zero(),
1690
1760
  firstTokenMs: null,
1761
+ decodeMs: null,
1691
1762
  tokensPerSecond: null,
1692
1763
  turnName: "",
1693
1764
  effort,
@@ -1695,7 +1766,7 @@ async function one(p, id, header, live) {
1695
1766
  _lastUsage: zero()
1696
1767
  };
1697
1768
  records.push(rec);
1698
- byKey.set(`${turn}:${step}`, rec);
1769
+ byKey.set(key, rec);
1699
1770
  }
1700
1771
  if (r.provider) rec.provider = r.provider;
1701
1772
  if (r.model) rec.model = r.model;
@@ -1704,17 +1775,22 @@ async function one(p, id, header, live) {
1704
1775
  if (u) {
1705
1776
  rec._lastUsage = u;
1706
1777
  rec.totals = u;
1707
- if (positive(u) && rec.firstTokenMs === null) {
1708
- const rawFirst = su.firstTime ?? (typeof d.chunk?.time === "number" ? d.chunk.time : ev.time);
1709
- rec.firstTokenMs = Math.max(0, rawFirst - rec.startedAt);
1710
- }
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);
1711
1783
  }
1712
1784
  if (ev.type !== "assistant/chunk") {
1713
1785
  rec.finishedAt = ev.time;
1714
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
+ }
1715
1793
  }
1716
- const elapsedMs = rec.finishedAt === null ? null : Math.max(0, rec.finishedAt - rec.startedAt);
1717
- if (elapsedMs !== null && elapsedMs > 0 && rec.totals.outputTokens > 0) rec.tokensPerSecond = rec.totals.outputTokens / (elapsedMs / 1e3);
1718
1794
  } else if (ev.type === "turn/end") {
1719
1795
  const recs = records.filter((x) => x.turn === turn && x.status === "running");
1720
1796
  for (const x of recs) {
@@ -1736,6 +1812,7 @@ async function one(p, id, header, live) {
1736
1812
  status: "completed",
1737
1813
  totals: u,
1738
1814
  firstTokenMs: null,
1815
+ decodeMs: null,
1739
1816
  tokensPerSecond: null,
1740
1817
  turnName: "",
1741
1818
  effort: "",
@@ -1753,9 +1830,7 @@ async function one(p, id, header, live) {
1753
1830
  });
1754
1831
  }
1755
1832
  const requests = records.length;
1756
- const firsts = records.map((x) => x.firstTokenMs).filter((x) => x !== null);
1757
- const outputSeconds = records.reduce((n, x) => n + x.totals.outputTokens, 0);
1758
- const elapsed = records.reduce((n, x) => n + (x.firstTokenMs ?? 0), 0);
1833
+ const firsts = records.filter((x) => x.status === "completed").map((x) => x.firstTokenMs).filter((x) => x !== null);
1759
1834
  for (const x of records) {
1760
1835
  x.turnName = turnTopics.get(x.turn) ?? "";
1761
1836
  delete x._attempt;
@@ -1769,7 +1844,7 @@ async function one(p, id, header, live) {
1769
1844
  records,
1770
1845
  requests,
1771
1846
  averageFirstTokenMs: firsts.length ? firsts.reduce((a, b) => a + b, 0) / firsts.length : null,
1772
- tokensPerSecond: elapsed > 0 ? outputSeconds / (elapsed / 1e3) : null
1847
+ tokensPerSecond: decodeRateOf(records)
1773
1848
  };
1774
1849
  }
1775
1850
  async function buildSessionUsageDetail(persistence, sessions, rootId, ctx) {
@@ -1791,7 +1866,6 @@ async function buildSessionUsageDetail(persistence, sessions, rootId, ctx) {
1791
1866
  }
1792
1867
  const all = [rootId, ...ids];
1793
1868
  const parts = await Promise.all(all.map((id) => one(persistence, id, headers.find((h) => h.id === id), live.get(id))));
1794
- const root = parts[0];
1795
1869
  const totals = zero();
1796
1870
  const models = /* @__PURE__ */ new Map();
1797
1871
  const records = parts.flatMap((x) => x.records);
@@ -1850,12 +1924,13 @@ async function buildSessionUsageDetail(persistence, sessions, rootId, ctx) {
1850
1924
  if (resolved) return resolved;
1851
1925
  }
1852
1926
  };
1927
+ const firsts = records.filter((x) => x.status === "completed").map((x) => x.firstTokenMs).filter((x) => x !== null);
1853
1928
  return {
1854
1929
  sessionId: rootId,
1855
1930
  totals,
1856
1931
  requests: parts.reduce((n, p) => n + p.requests, 0),
1857
- averageFirstTokenMs: root.averageFirstTokenMs,
1858
- tokensPerSecond: root.tokensPerSecond,
1932
+ averageFirstTokenMs: firsts.length ? firsts.reduce((a, b) => a + b, 0) / firsts.length : null,
1933
+ tokensPerSecond: decodeRateOf(records),
1859
1934
  subagents,
1860
1935
  models: [...models.entries()].map(([model, x]) => {
1861
1936
  const name = displayNameOf(model, x.providers);
@@ -2578,7 +2653,9 @@ function apply(ctx, config = {}) {
2578
2653
  await rescan();
2579
2654
  await ensureHourHistory();
2580
2655
  await ensureFailureHistory();
2581
- })();
2656
+ })().catch((err) => {
2657
+ ctx.logger?.warn(`[dsh-token-total] boot scan failed: ${err instanceof Error ? err.message : String(err)}`);
2658
+ });
2582
2659
  return () => {
2583
2660
  scanController?.abort();
2584
2661
  };
@@ -0,0 +1,18 @@
1
+ export interface ModelLogoProps {
2
+ /** Raw model id; may itself be a "provider/model" ref. */
3
+ model: string;
4
+ /** Resolved model display name (the by-model row's label) — matched with
5
+ * the raw id, so an opaque route id beside a brandful name still brands. */
6
+ name?: string;
7
+ /** Provider route id — a secondary match signal for opaque model ids. */
8
+ provider?: string;
9
+ /** Resolved provider display names (the by-model row's split), matched last. */
10
+ providers?: ReadonlyArray<string>;
11
+ /** Rendered square size; the record rows read at 13px. */
12
+ size?: number;
13
+ className?: string;
14
+ }
15
+ /** One model's brand logo inline. The markup is a bundled constant (never
16
+ * request data) rendered as-is so each brand keeps its own gradients,
17
+ * circles and multi-path fills; ids inside stay unique across brands. */
18
+ export declare function ModelLogo({ model, name, provider, providers, size, className }: ModelLogoProps): JSX.Element;
@@ -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. */
@@ -0,0 +1,11 @@
1
+ /** Brand ids in match order — every id here must ship markup. */
2
+ export declare const MODEL_LOGO_BRANDS: ReadonlyArray<string>;
3
+ /** Resolve the brand id for one usage record. The model's own naming (raw
4
+ * model id plus its resolved display name) decides the brand when it can;
5
+ * the provider fields (route id and provider display names) are the fallback
6
+ * for opaque model ids, so a Llama served through a vertex/google route keeps
7
+ * the Llama mark. Empty/undefined parts are ignored; null when no bundled
8
+ * brand matches. */
9
+ export declare function resolveModelLogo(provider: string | null | undefined, model: string | null | undefined, name?: string | null, providers?: ReadonlyArray<string>): string | null;
10
+ /** Inner SVG markup of a brand's logo, or undefined for an unknown id. */
11
+ export declare function modelLogoMarkup(id: string | null): string | undefined;
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Official per-brand logos for the usage panel: the inner markup of each
3
+ * brand's 24x24 SVG, extracted verbatim from @lobehub/icons-static-svg
4
+ * (MIT). Regenerate from .tmp/gen-logos.mjs after refreshing .tmp/logos/;
5
+ * brand colors are baked in, so no fill follows currentColor. Brand
6
+ * matching lives in model-logos-core.ts.
7
+ */
8
+ export declare const MODEL_LOGOS: Readonly<Record<string, string>>;
@@ -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.5",
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",
@@ -0,0 +1,29 @@
1
+ import { modelLogoMarkup, resolveModelLogo } from './model-logos-core.ts'
2
+
3
+ /** Neutral glyph for models no bundled brand matches: a soft four-point AI
4
+ * spark in the panel's muted gray, so an unknown model reads as "unbranded"
5
+ * rather than as some other vendor. */
6
+ const FALLBACK = '<path d="M12 1.6l2.14 5.96a2 2 0 0 0 1.22 1.22l5.96 2.14-5.96 2.14a2 2 0 0 0-1.22 1.22L12 20.24l-2.14-5.96a2 2 0 0 0-1.22-1.22L2.68 10.92l5.96-2.14a2 2 0 0 0 1.22-1.22L12 1.6z" fill="#9aa3b3"/>'
7
+
8
+ export interface ModelLogoProps {
9
+ /** Raw model id; may itself be a "provider/model" ref. */
10
+ model: string
11
+ /** Resolved model display name (the by-model row's label) — matched with
12
+ * the raw id, so an opaque route id beside a brandful name still brands. */
13
+ name?: string
14
+ /** Provider route id — a secondary match signal for opaque model ids. */
15
+ provider?: string
16
+ /** Resolved provider display names (the by-model row's split), matched last. */
17
+ providers?: ReadonlyArray<string>
18
+ /** Rendered square size; the record rows read at 13px. */
19
+ size?: number
20
+ className?: string
21
+ }
22
+
23
+ /** One model's brand logo inline. The markup is a bundled constant (never
24
+ * request data) rendered as-is so each brand keeps its own gradients,
25
+ * circles and multi-path fills; ids inside stay unique across brands. */
26
+ export function ModelLogo({ model, name, provider, providers, size = 13, className }: ModelLogoProps): JSX.Element {
27
+ const markup = modelLogoMarkup(resolveModelLogo(provider, model, name, providers)) ?? FALLBACK
28
+ return <svg width={size} height={size} viewBox="0 0 24 24" aria-hidden="true" className={className} dangerouslySetInnerHTML={{ __html: markup }} />
29
+ }
@@ -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; }
@@ -40,6 +49,10 @@
40
49
  .modelRequests { width:11%; }
41
50
  .modelHit { width:16%; }
42
51
  .ellipsis { display:block; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:#394354; }
52
+ /* By-model rows lead with the model's brand logo; the name keeps truncating
53
+ * inside the flexible remainder of the cell. */
54
+ .modelNameCell { display:flex; align-items:center; gap:6px; min-width:0; }
55
+ .modelNameCell .ellipsis { flex:1; min-width:0; }
43
56
  /* Gray provider label under a model name shown by several rows (同名模型). */
44
57
  .modelProvider { display:block; margin-top:2px; font-size:10px; color:#939aaa; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
45
58
  .number { font-variant-numeric:tabular-nums; }
@@ -53,7 +66,7 @@
53
66
  .empty { margin:0; padding:28px 18px; color:#9aa2b1; font-size:12px; text-align:center; }
54
67
  .action { color:#818b9e; background:none; border:0; padding:3px 0; font:inherit; font-size:11px; cursor:pointer; }
55
68
  .action:hover { color:#279cb6; }
56
- .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; }
57
70
  .turnList { padding:0 14px 14px; }
58
71
  .turn { margin-top:10px; border:1px solid #e7eaf0; border-radius:9px; background:#fff; overflow:hidden; }
59
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; }
@@ -77,9 +90,10 @@
77
90
  .turnRequest:first-child { border-top:0; }
78
91
  .turnRequestHead { display:flex; align-items:center; gap:8px; font-size:11px; color:#616c7f; min-width:0; }
79
92
  .requestTime { color:#9aa3b3; font-variant-numeric:tabular-nums; flex-shrink:0; }
80
- .statusDot { width:7px; height:7px; border-radius:50%; background:currentColor; flex-shrink:0; }
81
- .routeDot { width:6px; height:6px; border-radius:50%; background:#21a17b; flex-shrink:0; }
82
- .whale { color:#4d6bfe; flex-shrink:0; }
93
+ .statusDot { width:5px; height:5px; border-radius:50%; background:currentColor; flex-shrink:0; }
94
+ /* Per-model brand logo: colors are baked into each logo's markup, only the
95
+ * layout contract lives here. Unknown models get the neutral gray fallback. */
96
+ .modelLogo { flex-shrink:0; }
83
97
  .recordModel { min-width:0; display:flex; align-items:center; gap:6px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; color:#394354; font-weight:500; }
84
98
  .routeText { white-space:nowrap; overflow:hidden; text-overflow:ellipsis; color:#8b93a4; flex-shrink:1; }
85
99
  .effort { white-space:nowrap; color:#9aa3b3; background:#f1f3f7; border-radius:5px; padding:1px 6px; }
@@ -2,9 +2,10 @@ 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
+ import { ModelLogo } from './ModelLogo.tsx'
8
9
  import css from './SessionUsageView.module.css'
9
10
 
10
11
  type T = (key: SessionUsageKey) => string
@@ -19,7 +20,6 @@ const tokShort = (n: number): string => n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` :
19
20
  const pctInt = (rate: number | null): string => rate === null ? '—' : `${Math.round(rate)}%`
20
21
  const speed = (n: number | null): string => n !== null && Number.isFinite(n) ? `${Math.round(n)} tok/s` : '—'
21
22
  const clock = (ms: number): string => new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
22
- const whale = 'M8 39.5C13.5 27.6 26.8 19.4 42.5 19.1c-1.6 2.4-2.3 5-2 7.9-5.6-1.7-11.7-.4-16.2 3.4 4.7 1.1 8.4 4.3 10.2 8.7 1.3-.4 2.7-1 4-1.9.4 2.1-.2 4.3-1.6 5.9-7.7.6-15.5-1.6-20.7-6.6-.7 1.9-.9 3.9-.6 5.9-3.1-1.2-5.6-3-7.6-2.9z'
23
23
  const modelShort = (ref: string): string => ref.includes('/') ? ref.slice(ref.indexOf('/') + 1) : ref
24
24
  const zero = (): SessionUsageTotals => ({ uncachedInputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 })
25
25
  const sum = (rows: Row[]): SessionUsageTotals => rows.reduce((acc, r) => ({
@@ -108,7 +108,7 @@ function Tables({ detail, t }: { detail: SessionUsageDetail; t: T }): JSX.Elemen
108
108
  const providers = row.providers ?? []
109
109
  const shared = (nameCounts.get(normalizeModelName(name)) ?? 0) > 1
110
110
  return <tr key={row.model}>
111
- <td><span className={css.ellipsis} title={row.model}>{name}</span>
111
+ <td><span className={css.modelNameCell}><ModelLogo model={row.model} name={row.name} providers={row.providers} className={css.modelLogo} /><span className={css.ellipsis} title={row.model}>{name}</span></span>
112
112
  {shared && providers.length > 0 ? <small className={css.modelProvider} title={providers.join(' / ')}>{providers.join(' / ')}</small> : null}
113
113
  </td>
114
114
  <td>{formatTokens(row.requests)}</td><td><Token n={row.totals.uncachedInputTokens} /></td><td><Token n={row.totals.outputTokens} /></td><td><Token n={row.totals.cacheReadTokens} /></td><td><Token n={row.totals.cacheWriteTokens} /></td><td><Hit totals={row.totals} /></td>
@@ -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)
@@ -187,11 +192,10 @@ function Records({ detail, t }: { detail: SessionUsageDetail; t: T }): JSX.Eleme
187
192
  <span className={css.requestTime}>{clock(record.startedAt)}</span>
188
193
  <div className={css.turnRequestHead}>
189
194
  <span className={`${css.statusDot} ${record.status === 'running' ? css.live : record.status === 'failed' ? css.bad : record.status === 'interrupted' ? css.muted : css.good}`} />
190
- <span className={css.recordModel} title={`${record.provider} / ${record.model}`}><svg className={css.whale} width="13" height="13" viewBox="0 0 64 64" aria-hidden="true"><path d={whale} fill="currentColor" /></svg>{record.model || '—'}</span>
191
- <span className={css.routeDot} />
195
+ <span className={css.recordModel} title={`${record.provider} / ${record.model}`}><ModelLogo provider={record.provider} model={record.model} className={css.modelLogo} />{record.model || '—'}</span>
192
196
  <span className={css.routeText} title={record.provider}>{providerNames.get(record.provider) || record.provider || '—'} · {modelShort(record.model) || '—'}</span>
193
- {statusText !== '' && <span className={record.status === 'running' ? css.live : record.status === 'failed' ? css.bad : css.muted}>{statusText}</span>}
194
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>}
195
199
  </div>
196
200
  {record.finishedAt !== null && <div className={css.recordMeta}>{meta.map((item, i) => <span key={i} title={item.title}>{i > 0 ? ' · ' : ''}{item.text}</span>)}</div>}
197
201
  </div>
@@ -208,10 +212,24 @@ export function SessionUsageContent({ detail, t }: { detail: SessionUsageDetail;
208
212
  }
209
213
 
210
214
  export function SessionUsageView({ sessionId, t }: SessionUsageViewProps): JSX.Element {
211
- const { detail, loading, error, retry } = useSessionDetail(sessionId)
215
+ const { detail, loading, error, refresh, updatedAt } = useSessionDetail(sessionId)
212
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>
213
231
  {loading && <p className={css.empty} role="status">{t('loading')}</p>}
214
- {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>}
215
233
  {detail && <SessionUsageContent detail={detail} t={t} />}
216
234
  </div></div>
217
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).