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/client.js +279 -37
- package/lib/index.js +105 -28
- package/lib/types/client/ModelLogo.d.ts +18 -0
- package/lib/types/client/format.d.ts +1 -1
- package/lib/types/client/model-logos-core.d.ts +11 -0
- package/lib/types/client/model-logos-data.d.ts +8 -0
- 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/ModelLogo.tsx +29 -0
- package/src/client/SessionUsageView.module.css +18 -4
- package/src/client/SessionUsageView.tsx +29 -11
- package/src/client/format.ts +1 -1
- package/src/client/model-logos-core.ts +101 -0
- package/src/client/model-logos-data.ts +58 -0
- package/src/client/session-usage-locales.ts +3 -0
- package/src/client/use-session-detail.ts +16 -7
- package/src/collector.ts +7 -0
- package/src/index.ts +8 -1
- 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/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(
|
|
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
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
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:
|
|
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:
|
|
1858
|
-
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
|
-
|
|
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
|
@@ -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:
|
|
81
|
-
|
|
82
|
-
.
|
|
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
|
-
|
|
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)
|
|
@@ -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}`}><
|
|
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,
|
|
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={
|
|
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
|
}
|
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).
|