dsh-token-total 1.1.0 → 1.1.3
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/README.md +7 -4
- package/lib/client.js +423 -524
- package/lib/index.js +209 -6
- package/lib/types/client/UsageCards.d.ts +28 -0
- package/lib/types/client/context-window-core.d.ts +9 -0
- package/lib/types/client/session-usage-locales.d.ts +17 -0
- package/lib/types/context-window.d.ts +5 -0
- package/lib/types/usage-buckets.d.ts +14 -0
- package/lib/types/usage-composition.d.ts +12 -0
- package/lib/types/wire.d.ts +30 -0
- package/package.json +1 -1
- package/src/client/SessionUsageView.tsx +6 -6
- package/src/client/StatsLineEnhanced.tsx +59 -29
- package/src/client/UsageCards.module.css +74 -0
- package/src/client/UsageCards.tsx +244 -0
- package/src/client/context-window-core.ts +39 -0
- package/src/client/session-usage-locales.ts +27 -3
- package/src/client/stats-line-core.ts +14 -5
- package/src/context-window.ts +15 -5
- package/src/session-detail.ts +3 -2
- package/src/usage-buckets.ts +27 -0
- package/src/usage-composition.ts +203 -0
- package/src/wire.ts +37 -0
package/lib/index.js
CHANGED
|
@@ -1620,6 +1620,16 @@ function promptTokensOf(usage) {
|
|
|
1620
1620
|
const cached = Number(record?.cacheReadTokens) || 0;
|
|
1621
1621
|
return input + cached > 0 ? input + cached : null;
|
|
1622
1622
|
}
|
|
1623
|
+
/** Prompt tokens one tool definition costs: its name, description and JSON
|
|
1624
|
+
* schema, plus the framing every schema node pays. 0 for a nameless entry.
|
|
1625
|
+
* Shared with the spend composition, which replays the same catalogue on
|
|
1626
|
+
* every request. */
|
|
1627
|
+
function toolTokens(tool) {
|
|
1628
|
+
const record = tool;
|
|
1629
|
+
const name = String(record?.name ?? "");
|
|
1630
|
+
if (name === "") return 0;
|
|
1631
|
+
return estimateTokens(name) + valueTokens(record?.description) + estimateTokens(JSON.stringify(record?.parameters ?? {})) + TOOL_SCHEMA_OVERHEAD_TOKENS;
|
|
1632
|
+
}
|
|
1623
1633
|
/** Fold one category's rows: keep the biggest until KEEP_SHARE is covered (and
|
|
1624
1634
|
* never more than MAX_ROWS_PER_CATEGORY), then merge the tail into one row so
|
|
1625
1635
|
* a session with 200 tool calls still yields a readable treemap. */
|
|
@@ -1774,10 +1784,10 @@ function buildSessionContextWindow(events) {
|
|
|
1774
1784
|
}
|
|
1775
1785
|
for (const summary of summaries) if (summary.tokens > 0) add("memory", "", summary.tokens, "compaction-summary");
|
|
1776
1786
|
for (const tool of tools) {
|
|
1777
|
-
const
|
|
1778
|
-
const name = String(
|
|
1779
|
-
if (name === "") continue;
|
|
1780
|
-
add("tools", name,
|
|
1787
|
+
const tokens = toolTokens(tool);
|
|
1788
|
+
const name = String(tool?.name ?? "");
|
|
1789
|
+
if (name === "" || tokens === 0) continue;
|
|
1790
|
+
add("tools", name, tokens);
|
|
1781
1791
|
}
|
|
1782
1792
|
const items = foldDrafts([...drafts.values()]);
|
|
1783
1793
|
return {
|
|
@@ -1793,6 +1803,197 @@ function buildSessionContextWindow(events) {
|
|
|
1793
1803
|
};
|
|
1794
1804
|
}
|
|
1795
1805
|
//#endregion
|
|
1806
|
+
//#region src/usage-buckets.ts
|
|
1807
|
+
const USAGE_BUCKET_ORDER = [
|
|
1808
|
+
"systemPrompt",
|
|
1809
|
+
"user",
|
|
1810
|
+
"skill",
|
|
1811
|
+
"toolResult",
|
|
1812
|
+
"toolDef",
|
|
1813
|
+
"injected",
|
|
1814
|
+
"assistant",
|
|
1815
|
+
"output"
|
|
1816
|
+
];
|
|
1817
|
+
USAGE_BUCKET_ORDER.filter((key) => key !== "output");
|
|
1818
|
+
//#endregion
|
|
1819
|
+
//#region src/usage-composition.ts
|
|
1820
|
+
/**
|
|
1821
|
+
* Spend composition: what the session's requests actually paid for, split by
|
|
1822
|
+
* what filled each prompt.
|
|
1823
|
+
*
|
|
1824
|
+
* The context-window snapshot answers "what is in the window NOW". This module
|
|
1825
|
+
* answers the other half of the picture: every provider request replays the
|
|
1826
|
+
* whole message list, so a session that made 300 calls paid for the system
|
|
1827
|
+
* prompt 300 times. The number the Token stats card shows per bucket is
|
|
1828
|
+
* therefore the sum of that bucket's size AT EVERY REQUEST — not its current
|
|
1829
|
+
* size, which would always favour whatever arrived last.
|
|
1830
|
+
*
|
|
1831
|
+
* The naive way to get that is to rebuild the replayed list per request, which
|
|
1832
|
+
* is O(requests × events) and far too slow for a view that polls every 1.5 s
|
|
1833
|
+
* over a long session. Instead this walks the log ONCE and keeps a live block
|
|
1834
|
+
* map with per-bucket subtotals: a request snapshots those subtotals (8 adds)
|
|
1835
|
+
* plus the tool catalogue total, and shadowed sequences subtract their block
|
|
1836
|
+
* the moment compaction prunes them. That is O(events + requests × 8).
|
|
1837
|
+
*
|
|
1838
|
+
* Token sizes are ESTIMATES from the same character-count estimator as
|
|
1839
|
+
* `context-window.ts` (the provider never reports a per-block number), so the
|
|
1840
|
+
* card labels them approximate. The billed total, cache split and request
|
|
1841
|
+
* count come straight from `usage` records and are exact.
|
|
1842
|
+
*/
|
|
1843
|
+
/** Shadowed sequences a compaction event removed from the replay list. */
|
|
1844
|
+
function shadowedSeqs(data) {
|
|
1845
|
+
const seqs = [];
|
|
1846
|
+
for (const seq of Array.isArray(data?.shadowedSeqs) ? data.shadowedSeqs : []) seqs.push(Number(seq));
|
|
1847
|
+
const range = data?.shadowedRange;
|
|
1848
|
+
if (typeof range?.start === "number" && typeof range?.end === "number") for (let seq = range.start; seq <= range.end; seq++) seqs.push(seq);
|
|
1849
|
+
return seqs;
|
|
1850
|
+
}
|
|
1851
|
+
/**
|
|
1852
|
+
* Accumulate one session's own events into the eight legend buckets.
|
|
1853
|
+
*
|
|
1854
|
+
* Returns null for an empty log or a log whose requests never reported usage
|
|
1855
|
+
* (an older host writing no usage records) so the card can hide itself rather
|
|
1856
|
+
* than draw an empty ring.
|
|
1857
|
+
*/
|
|
1858
|
+
function buildSessionUsageComposition(events) {
|
|
1859
|
+
if (events.length === 0) return null;
|
|
1860
|
+
const ordered = [...events].sort((a, b) => a.seq - b.seq);
|
|
1861
|
+
/** Blocks still replayed by the next request, keyed by their event sequence. */
|
|
1862
|
+
const live = /* @__PURE__ */ new Map();
|
|
1863
|
+
/** Current size of the replay list per bucket — what one request would send. */
|
|
1864
|
+
const liveByBucket = /* @__PURE__ */ new Map();
|
|
1865
|
+
/** What every request so far sent, summed per bucket. */
|
|
1866
|
+
const acc = /* @__PURE__ */ new Map();
|
|
1867
|
+
const totals = {
|
|
1868
|
+
uncachedInputTokens: 0,
|
|
1869
|
+
outputTokens: 0,
|
|
1870
|
+
cacheReadTokens: 0,
|
|
1871
|
+
cacheWriteTokens: 0
|
|
1872
|
+
};
|
|
1873
|
+
let billed = 0;
|
|
1874
|
+
let requests = 0;
|
|
1875
|
+
/** Prompt tokens the newest request/header's tool catalogue costs. */
|
|
1876
|
+
let catalogue = 0;
|
|
1877
|
+
const addBlock = (seq, bucket, tokens) => {
|
|
1878
|
+
if (tokens <= 0) return;
|
|
1879
|
+
live.set(seq, {
|
|
1880
|
+
bucket,
|
|
1881
|
+
tokens
|
|
1882
|
+
});
|
|
1883
|
+
liveByBucket.set(bucket, (liveByBucket.get(bucket) ?? 0) + tokens);
|
|
1884
|
+
};
|
|
1885
|
+
const dropBlock = (seq) => {
|
|
1886
|
+
const block = live.get(seq);
|
|
1887
|
+
if (block === void 0) return;
|
|
1888
|
+
live.delete(seq);
|
|
1889
|
+
liveByBucket.set(block.bucket, (liveByBucket.get(block.bucket) ?? 0) - block.tokens);
|
|
1890
|
+
};
|
|
1891
|
+
/** Charge one request for everything it replayed. */
|
|
1892
|
+
const snapshot = () => {
|
|
1893
|
+
for (const [bucket, tokens] of liveByBucket) if (tokens > 0) acc.set(bucket, (acc.get(bucket) ?? 0) + tokens);
|
|
1894
|
+
if (catalogue > 0) acc.set("toolDef", (acc.get("toolDef") ?? 0) + catalogue);
|
|
1895
|
+
};
|
|
1896
|
+
/** Fold the provider's exact report into the billed split. */
|
|
1897
|
+
const account = (usage) => {
|
|
1898
|
+
const input = Number(usage?.inputTokens) || 0;
|
|
1899
|
+
const cacheRead = Number(usage?.cacheReadTokens) || 0;
|
|
1900
|
+
const cacheWrite = Number(usage?.cacheWriteTokens) || 0;
|
|
1901
|
+
const output = Number(usage?.outputTokens) || 0;
|
|
1902
|
+
totals.uncachedInputTokens += input;
|
|
1903
|
+
totals.cacheReadTokens += cacheRead;
|
|
1904
|
+
totals.cacheWriteTokens += cacheWrite;
|
|
1905
|
+
totals.outputTokens += output;
|
|
1906
|
+
billed += input + cacheRead + cacheWrite + output;
|
|
1907
|
+
requests++;
|
|
1908
|
+
if (output > 0) acc.set("output", (acc.get("output") ?? 0) + output);
|
|
1909
|
+
};
|
|
1910
|
+
for (const event of ordered) {
|
|
1911
|
+
const data = event.data ?? {};
|
|
1912
|
+
if (event.type === "compaction/prune" || event.type === "compaction/summary") {
|
|
1913
|
+
if (event.type === "compaction/summary" && data.usage !== void 0 && data.usage !== null) {
|
|
1914
|
+
snapshot();
|
|
1915
|
+
account(data.usage);
|
|
1916
|
+
}
|
|
1917
|
+
for (const seq of shadowedSeqs(data)) dropBlock(seq);
|
|
1918
|
+
if (event.type === "compaction/summary") addBlock(event.seq, "injected", messageTokens({ content: data.summary }));
|
|
1919
|
+
continue;
|
|
1920
|
+
}
|
|
1921
|
+
if (event.type === "request/header") {
|
|
1922
|
+
const header = data.header ?? {};
|
|
1923
|
+
if (Array.isArray(header.tools)) {
|
|
1924
|
+
catalogue = 0;
|
|
1925
|
+
for (const tool of header.tools) catalogue += toolTokens(tool);
|
|
1926
|
+
}
|
|
1927
|
+
continue;
|
|
1928
|
+
}
|
|
1929
|
+
if (event.type === "assistant/message") {
|
|
1930
|
+
if (data.usage !== void 0 && data.usage !== null) {
|
|
1931
|
+
snapshot();
|
|
1932
|
+
account(data.usage);
|
|
1933
|
+
}
|
|
1934
|
+
addBlock(event.seq, "assistant", messageTokens(data.message ?? { content: data.content }));
|
|
1935
|
+
continue;
|
|
1936
|
+
}
|
|
1937
|
+
if (event.type === "system/message") {
|
|
1938
|
+
const bucket = String(data.message?.source?.kind ?? data.source?.kind ?? "system-prompt") === "system-prompt" ? "systemPrompt" : "injected";
|
|
1939
|
+
addBlock(event.seq, bucket, messageTokens(data.message ?? { content: data.content }));
|
|
1940
|
+
continue;
|
|
1941
|
+
}
|
|
1942
|
+
if (event.type === "user/message") {
|
|
1943
|
+
const kind = String(data.source?.kind ?? data.message?.source?.kind ?? "user");
|
|
1944
|
+
const bucket = kind === "user" ? "user" : kind === "skill-catalog" ? "skill" : "injected";
|
|
1945
|
+
addBlock(event.seq, bucket, messageTokens(data.message ?? { content: data.content }));
|
|
1946
|
+
continue;
|
|
1947
|
+
}
|
|
1948
|
+
if (event.type === "tool/result") {
|
|
1949
|
+
addBlock(event.seq, "toolResult", messageTokens(data.message ?? {}));
|
|
1950
|
+
continue;
|
|
1951
|
+
}
|
|
1952
|
+
}
|
|
1953
|
+
if (requests === 0) return null;
|
|
1954
|
+
return {
|
|
1955
|
+
buckets: USAGE_BUCKET_ORDER.map((key) => ({
|
|
1956
|
+
key,
|
|
1957
|
+
tokens: Math.max(0, Math.round(acc.get(key) ?? 0))
|
|
1958
|
+
})),
|
|
1959
|
+
billedTokens: billed,
|
|
1960
|
+
totals,
|
|
1961
|
+
requests
|
|
1962
|
+
};
|
|
1963
|
+
}
|
|
1964
|
+
/** Add one tree's compositions into the root's, matching its other aggregates. */
|
|
1965
|
+
function mergeUsageCompositions(parts) {
|
|
1966
|
+
const present = parts.filter((part) => part !== null && part !== void 0);
|
|
1967
|
+
if (present.length === 0) return null;
|
|
1968
|
+
const acc = /* @__PURE__ */ new Map();
|
|
1969
|
+
const totals = {
|
|
1970
|
+
uncachedInputTokens: 0,
|
|
1971
|
+
outputTokens: 0,
|
|
1972
|
+
cacheReadTokens: 0,
|
|
1973
|
+
cacheWriteTokens: 0
|
|
1974
|
+
};
|
|
1975
|
+
let billed = 0;
|
|
1976
|
+
let requests = 0;
|
|
1977
|
+
for (const part of present) {
|
|
1978
|
+
for (const bucket of part.buckets) acc.set(bucket.key, (acc.get(bucket.key) ?? 0) + bucket.tokens);
|
|
1979
|
+
totals.uncachedInputTokens += part.totals.uncachedInputTokens;
|
|
1980
|
+
totals.cacheReadTokens += part.totals.cacheReadTokens;
|
|
1981
|
+
totals.cacheWriteTokens += part.totals.cacheWriteTokens;
|
|
1982
|
+
totals.outputTokens += part.totals.outputTokens;
|
|
1983
|
+
billed += part.billedTokens;
|
|
1984
|
+
requests += part.requests;
|
|
1985
|
+
}
|
|
1986
|
+
return {
|
|
1987
|
+
buckets: USAGE_BUCKET_ORDER.map((key) => ({
|
|
1988
|
+
key,
|
|
1989
|
+
tokens: acc.get(key) ?? 0
|
|
1990
|
+
})),
|
|
1991
|
+
billedTokens: billed,
|
|
1992
|
+
totals,
|
|
1993
|
+
requests
|
|
1994
|
+
};
|
|
1995
|
+
}
|
|
1996
|
+
//#endregion
|
|
1796
1997
|
//#region src/session-detail.ts
|
|
1797
1998
|
const zero = () => ({
|
|
1798
1999
|
uncachedInputTokens: 0,
|
|
@@ -2120,7 +2321,8 @@ async function one(p, id, header, live) {
|
|
|
2120
2321
|
requests,
|
|
2121
2322
|
averageFirstTokenMs: firsts.length ? firsts.reduce((a, b) => a + b, 0) / firsts.length : null,
|
|
2122
2323
|
tokensPerSecond: decodeRateOf(records),
|
|
2123
|
-
context: buildSessionContextWindow(own)
|
|
2324
|
+
context: buildSessionContextWindow(own),
|
|
2325
|
+
composition: buildSessionUsageComposition(own)
|
|
2124
2326
|
};
|
|
2125
2327
|
}
|
|
2126
2328
|
async function buildSessionUsageDetail(persistence, sessions, rootId, ctx) {
|
|
@@ -2220,7 +2422,8 @@ async function buildSessionUsageDetail(persistence, sessions, rootId, ctx) {
|
|
|
2220
2422
|
}),
|
|
2221
2423
|
records,
|
|
2222
2424
|
routes: [...routeMap.values()],
|
|
2223
|
-
context: parts[0]?.context ?? null
|
|
2425
|
+
context: parts[0]?.context ?? null,
|
|
2426
|
+
composition: mergeUsageCompositions(parts.map((p) => p.composition))
|
|
2224
2427
|
};
|
|
2225
2428
|
}
|
|
2226
2429
|
//#endregion
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two cards that replaced the context-window panel.
|
|
3
|
+
*
|
|
4
|
+
* Left: what the window holds RIGHT NOW (the context snapshot). Right: what the
|
|
5
|
+
* session's requests actually spent, split by content (the spend composition).
|
|
6
|
+
* They answer two different questions — "what is in the prompt" and "what did
|
|
7
|
+
* the whole session pay for" — so they sit side by side at equal weight rather
|
|
8
|
+
* than one above the other.
|
|
9
|
+
*
|
|
10
|
+
* Everything is drawn from the detail payload the view already polls every
|
|
11
|
+
* 1.5 s (`useSessionDetail`), so both cards refresh with no extra timer. The
|
|
12
|
+
* charts are hand-rolled SVG/CSS: the plugin ships no chart dependency, and a
|
|
13
|
+
* ring plus a stacked bar is little enough markup to keep it that way.
|
|
14
|
+
*
|
|
15
|
+
* The palette is the plugin's tone scale, matching the stat cards above and the
|
|
16
|
+
* dashboard charts; dark values flip under `body[data-ds-dark-theme]` (the
|
|
17
|
+
* host's own theme switch), and every surface uses the host's semantic tokens
|
|
18
|
+
* so the cards sit on the same canvas as the tables.
|
|
19
|
+
*/
|
|
20
|
+
import type { SessionUsageDetail } from '../wire.ts';
|
|
21
|
+
import type { SessionUsageKey } from './session-usage-locales.ts';
|
|
22
|
+
type T = (key: SessionUsageKey, params?: Record<string, string>) => string;
|
|
23
|
+
/** The pair, rendered where the context-window panel used to sit. */
|
|
24
|
+
export declare function SessionUsageCards({ detail, t }: {
|
|
25
|
+
detail: SessionUsageDetail;
|
|
26
|
+
t: T;
|
|
27
|
+
}): JSX.Element | null;
|
|
28
|
+
export {};
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* separate from the component so both are testable without a DOM.
|
|
5
5
|
*/
|
|
6
6
|
import type { SessionContextCategory, SessionContextItem, SessionContextWindow } from '../wire.ts';
|
|
7
|
+
import type { SessionUsageBucket } from '../wire.ts';
|
|
7
8
|
/** Legend order, also the color assignment order (see the CSS module). */
|
|
8
9
|
export declare const CONTEXT_CATEGORY_ORDER: readonly SessionContextCategory[];
|
|
9
10
|
/** One positioned treemap block in container pixels. */
|
|
@@ -19,6 +20,14 @@ export declare function contextCategoryTotals(items: readonly SessionContextItem
|
|
|
19
20
|
key: SessionContextCategory;
|
|
20
21
|
tokens: number;
|
|
21
22
|
}>;
|
|
23
|
+
/** The live window folded into the same legend the spend card prints, in
|
|
24
|
+
* legend order and keeping zero buckets so both cards stay aligned. */
|
|
25
|
+
export declare function contextBucketTotals(items: readonly SessionContextItem[]): Array<{
|
|
26
|
+
key: SessionUsageBucket;
|
|
27
|
+
tokens: number;
|
|
28
|
+
}>;
|
|
29
|
+
/** What a share of the whole is, for the legend column. */
|
|
30
|
+
export declare function bucketPercent(tokens: number, total: number): number;
|
|
22
31
|
/** Window occupancy in whole percent, or null when the host never reported the
|
|
23
32
|
* model's window (the panel then says "unknown" instead of guessing one). */
|
|
24
33
|
export declare function contextWindowPercent(usedTokens: number, contextWindow: number | null): number | null;
|
|
@@ -60,6 +60,7 @@ export declare const usageZh: {
|
|
|
60
60
|
asOf: string;
|
|
61
61
|
refresh: string;
|
|
62
62
|
contextTitle: string;
|
|
63
|
+
currentContextTitle: string;
|
|
63
64
|
contextUsed: string;
|
|
64
65
|
contextLive: string;
|
|
65
66
|
contextWindowKnown: string;
|
|
@@ -90,6 +91,22 @@ export declare const usageZh: {
|
|
|
90
91
|
contextTipShare: string;
|
|
91
92
|
contextTipWindow: string;
|
|
92
93
|
contextEstimateNote: string;
|
|
94
|
+
statsTitle: string;
|
|
95
|
+
statsTotal: string;
|
|
96
|
+
statsCacheHit: string;
|
|
97
|
+
statsIncludesThinking: string;
|
|
98
|
+
statsEmpty: string;
|
|
99
|
+
contextWindowTotal: string;
|
|
100
|
+
contextWindowTotalUnknown: string;
|
|
101
|
+
contextUsedShort: string;
|
|
102
|
+
bucketSystemPrompt: string;
|
|
103
|
+
bucketUser: string;
|
|
104
|
+
bucketSkill: string;
|
|
105
|
+
bucketToolResult: string;
|
|
106
|
+
bucketToolDef: string;
|
|
107
|
+
bucketInjected: string;
|
|
108
|
+
bucketAssistant: string;
|
|
109
|
+
bucketOutput: string;
|
|
93
110
|
};
|
|
94
111
|
export type SessionUsageKey = keyof typeof usageZh;
|
|
95
112
|
export declare const usageEn: Record<SessionUsageKey, string>;
|
|
@@ -31,6 +31,11 @@ import type { SessionContextWindow } from './wire.ts';
|
|
|
31
31
|
export declare function estimateTokens(text: string): number;
|
|
32
32
|
/** Approximate tokens of one replayed message (content + framing). */
|
|
33
33
|
export declare function messageTokens(message: unknown): number;
|
|
34
|
+
/** Prompt tokens one tool definition costs: its name, description and JSON
|
|
35
|
+
* schema, plus the framing every schema node pays. 0 for a nameless entry.
|
|
36
|
+
* Shared with the spend composition, which replays the same catalogue on
|
|
37
|
+
* every request. */
|
|
38
|
+
export declare function toolTokens(tool: unknown): number;
|
|
34
39
|
/** Build the context-window snapshot from one session's own events. Returns
|
|
35
40
|
* null for an empty log so the panel can hide itself. */
|
|
36
41
|
export declare function buildSessionContextWindow(events: readonly UsageSessionEvent[]): SessionContextWindow | null;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The legend order of the Token stats card, shared by the host accumulator and
|
|
3
|
+
* the client card so the two halves can never disagree about which bucket sits
|
|
4
|
+
* where. Left column first (what filled the prompt), right column second: the
|
|
5
|
+
* layout mirrors the reference design — system/user/skill/results on the left,
|
|
6
|
+
* tool definitions/injected/assistant/output on the right.
|
|
7
|
+
*
|
|
8
|
+
* Kept in its own dependency-free module: the host builds the numbers and the
|
|
9
|
+
* client paints them, and neither should pull the other's graph in for this.
|
|
10
|
+
*/
|
|
11
|
+
import type { SessionUsageBucket } from './wire.ts';
|
|
12
|
+
export declare const USAGE_BUCKET_ORDER: readonly SessionUsageBucket[];
|
|
13
|
+
/** The buckets that describe the live window (output never occupies it). */
|
|
14
|
+
export declare const CONTEXT_BUCKET_ORDER: readonly SessionUsageBucket[];
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { UsageSessionEvent } from './context-types.ts';
|
|
2
|
+
import type { SessionUsageComposition } from './wire.ts';
|
|
3
|
+
/**
|
|
4
|
+
* Accumulate one session's own events into the eight legend buckets.
|
|
5
|
+
*
|
|
6
|
+
* Returns null for an empty log or a log whose requests never reported usage
|
|
7
|
+
* (an older host writing no usage records) so the card can hide itself rather
|
|
8
|
+
* than draw an empty ring.
|
|
9
|
+
*/
|
|
10
|
+
export declare function buildSessionUsageComposition(events: readonly UsageSessionEvent[]): SessionUsageComposition | null;
|
|
11
|
+
/** Add one tree's compositions into the root's, matching its other aggregates. */
|
|
12
|
+
export declare function mergeUsageCompositions(parts: ReadonlyArray<SessionUsageComposition | null | undefined>): SessionUsageComposition | null;
|
package/lib/types/wire.d.ts
CHANGED
|
@@ -191,6 +191,36 @@ export interface SessionUsageDetail {
|
|
|
191
191
|
/** The root session's live context composition, rendered above the cards.
|
|
192
192
|
* Null when the log has no readable events (or on an older host). */
|
|
193
193
|
context?: SessionContextWindow | null;
|
|
194
|
+
/** What the session's requests spent, split by what filled the prompt.
|
|
195
|
+
* Null when the log has no readable usage (or on an older host). */
|
|
196
|
+
composition?: SessionUsageComposition | null;
|
|
197
|
+
}
|
|
198
|
+
/** The eight buckets the Token stats card names, in legend order: the left
|
|
199
|
+
* column first (prompt composition), then the right column. */
|
|
200
|
+
export type SessionUsageBucket = 'systemPrompt' | 'user' | 'skill' | 'toolResult' | 'toolDef' | 'injected' | 'assistant' | 'output';
|
|
201
|
+
/** One session tree's spend split by prompt content.
|
|
202
|
+
*
|
|
203
|
+
* Every provider request replays the whole message list, so a bucket's number
|
|
204
|
+
* is what that bucket contributed ACROSS ALL REQUESTS, not its size in the
|
|
205
|
+
* window today. The replay figures are ESTIMATES (character counts, the same
|
|
206
|
+
* estimator the context-window snapshot uses) because the provider only bills
|
|
207
|
+
* a whole prompt; the billed total and its cache split are exact and drive
|
|
208
|
+
* the headline and the cache-hit badge. */
|
|
209
|
+
export interface SessionUsageComposition {
|
|
210
|
+
/** Estimated replay tokens per bucket, in legend order (kept at 0 when a
|
|
211
|
+
* bucket never appeared so the card can keep a stable order). */
|
|
212
|
+
buckets: Array<{
|
|
213
|
+
key: SessionUsageBucket;
|
|
214
|
+
tokens: number;
|
|
215
|
+
}>;
|
|
216
|
+
/** Exact provider-billed total across the same requests (prompt including
|
|
217
|
+
* cached tokens, plus output). */
|
|
218
|
+
billedTokens: number;
|
|
219
|
+
/** The same spend split into the four billing buckets, for the cache badge
|
|
220
|
+
* and any future detail row. */
|
|
221
|
+
totals: SessionUsageTotals;
|
|
222
|
+
/** How many provider answers the accumulation covers (compaction included). */
|
|
223
|
+
requests: number;
|
|
194
224
|
}
|
|
195
225
|
/** The six buckets the context-window panel paints, in legend order. They are
|
|
196
226
|
* the buckets a reader thinks in: the harness prompt, the tool catalogue, the
|
package/package.json
CHANGED
|
@@ -5,7 +5,7 @@ import { billedTotal, cacheHitPercentOf } from './session-usage-core.ts'
|
|
|
5
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 {
|
|
8
|
+
import { SessionUsageCards } from './UsageCards.tsx'
|
|
9
9
|
import { ModelLogo } from './ModelLogo.tsx'
|
|
10
10
|
import css from './SessionUsageView.module.css'
|
|
11
11
|
|
|
@@ -206,11 +206,11 @@ function Records({ detail, t }: { detail: SessionUsageDetail; t: T }): JSX.Eleme
|
|
|
206
206
|
export function SessionUsageContent({ detail, t, updatedAt }: { detail: SessionUsageDetail; t: T; updatedAt?: number | null }): JSX.Element {
|
|
207
207
|
return <>
|
|
208
208
|
<p className={css.scope}>{t('scope')}</p><Metrics detail={detail} t={t} /><Tables detail={detail} t={t} />
|
|
209
|
-
{/* The
|
|
210
|
-
request records:
|
|
211
|
-
|
|
212
|
-
per-turn detail. */}
|
|
213
|
-
|
|
209
|
+
{/* The two live cards sit between the per-subagent/model tables and the
|
|
210
|
+
request records: the left one describes what the next request carries
|
|
211
|
+
right now, the right one what the whole session spent, while everything
|
|
212
|
+
above is spent-token history and everything below is per-turn detail. */}
|
|
213
|
+
<SessionUsageCards detail={detail} t={t} />
|
|
214
214
|
<Records key={detail.sessionId} detail={detail} t={t} /><p className={css.note}>{t('note')}</p>
|
|
215
215
|
</>
|
|
216
216
|
}
|
|
@@ -31,9 +31,14 @@
|
|
|
31
31
|
* cannot see.
|
|
32
32
|
*
|
|
33
33
|
* Streaming throughput replaces the gauge pill's speed
|
|
34
|
-
* figure with a live estimate
|
|
35
|
-
* the
|
|
36
|
-
*
|
|
34
|
+
* figure with a live estimate of what is happening now, and hands the slot back
|
|
35
|
+
* to the session's decode average when nothing is streaming. While a step
|
|
36
|
+
* streams the live rate is the only reading that can match the pace of the
|
|
37
|
+
* answer on screen; once the step settles it becomes a figure from a window
|
|
38
|
+
* nobody is watching any more, so the pill returns to the same quotient the
|
|
39
|
+
* usage view quotes (ΣoutputTokens / ΣdecodeMs) instead of freezing on the last
|
|
40
|
+
* measurement. That subscription lives in its own child component, so stream
|
|
41
|
+
* deltas never re-render the pills. The live
|
|
37
42
|
* figure itself is measured by a bounded sliding window over observed token
|
|
38
43
|
* growth (stats-line-core's StreamingRateSampler): the earlier cumulative
|
|
39
44
|
* quotient charged a whole step's text to a window that had just opened, so a
|
|
@@ -176,29 +181,43 @@ const LIVE_SPEED_REFRESH_MS = 1_000
|
|
|
176
181
|
* The gauge pill's always-on streaming speed slot.
|
|
177
182
|
*
|
|
178
183
|
* It subscribes to `legacy.partial` itself so per-frame deltas re-render only
|
|
179
|
-
* this child, not the surrounding pills.
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
184
|
+
* this child, not the surrounding pills. Two figures share the slot, chosen by
|
|
185
|
+
* whether a step is streaming right now:
|
|
186
|
+
*
|
|
187
|
+
* - while one is (`partial != null`), the measured rate of THAT stream — a
|
|
188
|
+
* bounded sliding window over observed token growth (stats-line-core's
|
|
189
|
+
* StreamingRateSampler), the only reading that can track the pace of the
|
|
190
|
+
* answer currently on screen;
|
|
191
|
+
* - otherwise the session's own decode average, ΣoutputTokens / ΣdecodeMs —
|
|
192
|
+
* the same quotient `decodeRateOf` quotes in the usage view. An idle composer
|
|
193
|
+
* must not freeze on whatever the last window happened to measure: a settled
|
|
194
|
+
* thirty-step turn kept showing its final second-long reading (57 tok/s)
|
|
195
|
+
* beside the usage view's 156 tok/s for the same work, and the two read as a
|
|
196
|
+
* contradiction rather than as two scopes of one number.
|
|
197
|
+
*
|
|
198
|
+
* The measured reading carries the step it was taken in, so a new step cannot
|
|
199
|
+
* inherit the previous one's figure before its own window publishes; nothing is
|
|
200
|
+
* held across the gap — the gap is exactly when the average applies.
|
|
183
201
|
*
|
|
184
202
|
* One window covers a whole step, across every output kind the accumulator
|
|
185
203
|
* carries — reasoning, answer text, and Tool-call arguments — because the
|
|
186
204
|
* provider bills all three as completion tokens and a step emits them in
|
|
187
|
-
* sequence.
|
|
188
|
-
* settles it and the next step has not begun), so Tool execution shows the
|
|
189
|
-
* official figure until the next step opens its own window.
|
|
205
|
+
* sequence.
|
|
190
206
|
*
|
|
191
207
|
* Sampling happens in a layout effect and the figure it yields is published as
|
|
192
208
|
* state, so no render reads a clock or mutates the window: a render React starts
|
|
193
209
|
* and then discards must not decide what the next committed one displays. The
|
|
194
210
|
* rate itself comes from stats-line-core's StreamingRateSampler — a bounded
|
|
195
|
-
* window over observed token growth, smoothed, and held while nothing arrives
|
|
211
|
+
* window over observed token growth, smoothed, and held while nothing arrives
|
|
212
|
+
* inside the step it belongs to.
|
|
196
213
|
*/
|
|
197
214
|
function LiveSpeed({ useChat, scale, fallback, t }: {
|
|
198
215
|
useChat: ChatSnapshotSelectorHook
|
|
199
216
|
/** Correction measured from this session's settled steps (1 = raw prior). */
|
|
200
217
|
scale: number
|
|
201
|
-
/** The
|
|
218
|
+
/** The session's own decode average (ΣoutputTokens / ΣdecodeMs): what the
|
|
219
|
+
* slot shows whenever no step is streaming, and before the streaming step's
|
|
220
|
+
* window has published its first reading. */
|
|
202
221
|
fallback: string | null
|
|
203
222
|
t: T
|
|
204
223
|
}) {
|
|
@@ -216,19 +235,22 @@ function LiveSpeed({ useChat, scale, fallback, t }: {
|
|
|
216
235
|
sampler.current = { key, rate: new StreamingRateSampler() }
|
|
217
236
|
}
|
|
218
237
|
const rate = sampler.current.rate
|
|
219
|
-
// The
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
rate: null,
|
|
226
|
-
})
|
|
238
|
+
// The figure last measured, tagged with the step it came from. The tag is
|
|
239
|
+
// what retires it: a reading taken in 3:4 never shows during 3:5, and with
|
|
240
|
+
// no step streaming (`key === null`) no tag can match at all, so the slot
|
|
241
|
+
// falls through to the session average on its own — no timer, no clearing
|
|
242
|
+
// effect, and no stale figure left standing after a turn settles.
|
|
243
|
+
const [reading, setReading] = useState<{ key: string; value: number } | null>(null)
|
|
227
244
|
const scaleRef = useRef(scale)
|
|
228
245
|
// Every delta re-runs this (its dependencies move), and the beat re-runs it once
|
|
229
246
|
// a second so a step that has gone quiet still advances its window.
|
|
230
247
|
const [beat, setBeat] = useState(0)
|
|
231
248
|
useLayoutEffect(() => {
|
|
249
|
+
// Nothing is streaming: the slot shows the session average, and there is no
|
|
250
|
+
// window to advance. Skipping the observation also keeps a settled step's
|
|
251
|
+
// sampler from being fed a zero estimate, which would re-anchor it against
|
|
252
|
+
// text that is already counted.
|
|
253
|
+
if (key === null) return
|
|
232
254
|
// A correction that moved re-prices the whole estimate, so growth already
|
|
233
255
|
// counted under the old one must not be measured against the new one: that
|
|
234
256
|
// would read as a burst the stream never produced.
|
|
@@ -237,10 +259,15 @@ function LiveSpeed({ useChat, scale, fallback, t }: {
|
|
|
237
259
|
rate.reset()
|
|
238
260
|
}
|
|
239
261
|
rate.observe(tokens, Date.now())
|
|
262
|
+
// A null here means the fresh window has not measured anything yet (a step
|
|
263
|
+
// that just opened, or one that re-anchored); the average stays up until
|
|
264
|
+
// this step's own window publishes something.
|
|
240
265
|
const next = rate.ratePerSecond()
|
|
241
|
-
|
|
242
|
-
previous
|
|
243
|
-
|
|
266
|
+
if (next !== null) {
|
|
267
|
+
setReading(previous => (previous !== null && previous.key === key && previous.value === next
|
|
268
|
+
? previous
|
|
269
|
+
: { key, value: next }))
|
|
270
|
+
}
|
|
244
271
|
}, [tokens, key, beat, rate, scale])
|
|
245
272
|
// Keyed on "a step is streaming", not on the step identity: switching steps
|
|
246
273
|
// must not tear the beat down and defer the next one by a full period.
|
|
@@ -250,10 +277,12 @@ function LiveSpeed({ useChat, scale, fallback, t }: {
|
|
|
250
277
|
const handle = setInterval(() => { setBeat(Date.now()) }, LIVE_SPEED_REFRESH_MS)
|
|
251
278
|
return () => { clearInterval(handle) }
|
|
252
279
|
}, [streaming])
|
|
253
|
-
|
|
254
|
-
|
|
280
|
+
// The tag test is the whole selection rule: a reading only shows while the
|
|
281
|
+
// step that produced it is the step streaming now.
|
|
282
|
+
const measured = reading !== null && reading.key === key ? reading.value : null
|
|
283
|
+
return <SpeedFigure text={measured === null
|
|
255
284
|
? fallback
|
|
256
|
-
: t('stats.tokensPerSecond', { throughput: formatTokensPerSecond(
|
|
285
|
+
: t('stats.tokensPerSecond', { throughput: formatTokensPerSecond(measured) })} />
|
|
257
286
|
}
|
|
258
287
|
|
|
259
288
|
function TimePill({ stats, t, dialog, useChat, scale, dock }: {
|
|
@@ -270,9 +299,10 @@ function TimePill({ stats, t, dialog, useChat, scale, dock }: {
|
|
|
270
299
|
}) {
|
|
271
300
|
const { open, setOpen, rootRef, panelRef, pos } = useStatDialog(dialog)
|
|
272
301
|
const counts = t('stats.counts', { turns: stats.turns, steps: stats.steps })
|
|
273
|
-
// The
|
|
274
|
-
// gets the durable session reading rather than a value that
|
|
275
|
-
// second while a step streams
|
|
302
|
+
// The session figure now serves both jobs: the pill's accessible name (a
|
|
303
|
+
// screen reader gets the durable session reading rather than a value that
|
|
304
|
+
// changes every second while a step streams) and the visible figure whenever
|
|
305
|
+
// no step is streaming.
|
|
276
306
|
const officialTps = stats.decodeMs > 0
|
|
277
307
|
? t('stats.tokensPerSecond', {
|
|
278
308
|
throughput: formatTokensPerSecond(stats.decodeTokens / (stats.decodeMs / 1_000)),
|