dsh-token-total 1.1.0 → 1.1.2
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 +407 -523
- 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 +30 -22
- 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,8 +31,13 @@
|
|
|
31
31
|
* cannot see.
|
|
32
32
|
*
|
|
33
33
|
* Streaming throughput replaces the gauge pill's speed
|
|
34
|
-
* figure with a live estimate
|
|
35
|
-
*
|
|
34
|
+
* figure with a live estimate of what is happening now. The figure is the
|
|
35
|
+
* streaming step's measured rate while one streams and the LAST measured rate
|
|
36
|
+
* once it settles — a session average describes the whole session's past, and
|
|
37
|
+
* it cannot match the pace of the answer on screen, which is what users read
|
|
38
|
+
* as the pill disagreeing with the stream in front of them. The official
|
|
39
|
+
* session figure only shows on a session that has never streamed. That
|
|
40
|
+
* subscription lives in
|
|
36
41
|
* its own child component so stream 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
|
|
@@ -176,17 +181,19 @@ 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. The figure it carries is the CURRENT
|
|
185
|
+
* stream's measured rate, and it stays that rate once the step settles:
|
|
186
|
+
* tool execution and the gap between steps happen inside the same answer, and
|
|
187
|
+
* the session average this slot used to fall back to there is a figure from
|
|
188
|
+
* the whole session's past — a number that cannot match the pace of the answer
|
|
189
|
+
* on screen, which is what reads as "not the real-time speed". The official
|
|
190
|
+
* session figure is only a session's opening state, shown until the first
|
|
191
|
+
* window measures anything.
|
|
183
192
|
*
|
|
184
193
|
* One window covers a whole step, across every output kind the accumulator
|
|
185
194
|
* carries — reasoning, answer text, and Tool-call arguments — because the
|
|
186
195
|
* 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.
|
|
196
|
+
* sequence.
|
|
190
197
|
*
|
|
191
198
|
* Sampling happens in a layout effect and the figure it yields is published as
|
|
192
199
|
* state, so no render reads a clock or mutates the window: a render React starts
|
|
@@ -198,7 +205,7 @@ function LiveSpeed({ useChat, scale, fallback, t }: {
|
|
|
198
205
|
useChat: ChatSnapshotSelectorHook
|
|
199
206
|
/** Correction measured from this session's settled steps (1 = raw prior). */
|
|
200
207
|
scale: number
|
|
201
|
-
/** The official session figure, shown until the
|
|
208
|
+
/** The official session figure, shown until the slot has measured its own. */
|
|
202
209
|
fallback: string | null
|
|
203
210
|
t: T
|
|
204
211
|
}) {
|
|
@@ -216,14 +223,13 @@ function LiveSpeed({ useChat, scale, fallback, t }: {
|
|
|
216
223
|
sampler.current = { key, rate: new StreamingRateSampler() }
|
|
217
224
|
}
|
|
218
225
|
const rate = sampler.current.rate
|
|
219
|
-
// The
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
})
|
|
226
|
+
// The latest figure this slot has measured, held across step boundaries. A
|
|
227
|
+
// settled step's rate is still the conversation's current speed until the
|
|
228
|
+
// next step measures its own, so the display keeps it instead of switching to
|
|
229
|
+
// a session average the moment the stream goes quiet — holding it is the
|
|
230
|
+
// point, not a stale value to guard against, which is why no step tag rides
|
|
231
|
+
// along any more.
|
|
232
|
+
const [reading, setReading] = useState<number | null>(null)
|
|
227
233
|
const scaleRef = useRef(scale)
|
|
228
234
|
// Every delta re-runs this (its dependencies move), and the beat re-runs it once
|
|
229
235
|
// a second so a step that has gone quiet still advances its window.
|
|
@@ -237,10 +243,13 @@ function LiveSpeed({ useChat, scale, fallback, t }: {
|
|
|
237
243
|
rate.reset()
|
|
238
244
|
}
|
|
239
245
|
rate.observe(tokens, Date.now())
|
|
246
|
+
// A null here means the fresh window has not measured anything yet (a step
|
|
247
|
+
// that just opened, or one that re-anchored); the figure already on screen
|
|
248
|
+
// is still the best current reading, so it is left alone.
|
|
240
249
|
const next = rate.ratePerSecond()
|
|
241
|
-
|
|
242
|
-
previous
|
|
243
|
-
|
|
250
|
+
if (next !== null) {
|
|
251
|
+
setReading(previous => (previous === next ? previous : next))
|
|
252
|
+
}
|
|
244
253
|
}, [tokens, key, beat, rate, scale])
|
|
245
254
|
// Keyed on "a step is streaming", not on the step identity: switching steps
|
|
246
255
|
// must not tear the beat down and defer the next one by a full period.
|
|
@@ -250,7 +259,6 @@ function LiveSpeed({ useChat, scale, fallback, t }: {
|
|
|
250
259
|
const handle = setInterval(() => { setBeat(Date.now()) }, LIVE_SPEED_REFRESH_MS)
|
|
251
260
|
return () => { clearInterval(handle) }
|
|
252
261
|
}, [streaming])
|
|
253
|
-
const reading = published.key === key ? published.rate : null
|
|
254
262
|
return <SpeedFigure text={reading === null
|
|
255
263
|
? fallback
|
|
256
264
|
: t('stats.tokensPerSecond', { throughput: formatTokensPerSecond(reading) })} />
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/* The two live cards that replaced the context-window panel: the window's
|
|
2
|
+
* current composition on the left, the session's spend composition on the
|
|
3
|
+
* right. Surfaces ride the host semantic tokens (`--dsw-alias-*`) like every
|
|
4
|
+
* other card on this page, so light/dark flip for free; only the eight bucket
|
|
5
|
+
* hues are this plugin's own (they match the reference design and the
|
|
6
|
+
* dashboard's chart accents). Each bucket sets `--bucket`, and the swatch, ring
|
|
7
|
+
* segment, bar segment and value text all read that one variable. */
|
|
8
|
+
.grid { display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr); gap:18px; margin:0 0 18px; align-items:stretch; }
|
|
9
|
+
.card { min-width:0; display:flex; flex-direction:column; background:var(--dsw-alias-bg-layer-1); border:1px solid var(--dsw-alias-border-l2); border-radius:12px; overflow:hidden; }
|
|
10
|
+
.head { display:flex; align-items:center; justify-content:space-between; gap:10px; min-height:42px; padding:12px 16px 0; }
|
|
11
|
+
.title { margin:0; font-size:13px; font-weight:600; color:var(--dsw-alias-label-primary); white-space:nowrap; }
|
|
12
|
+
.model { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:11px; color:var(--dsw-alias-label-tertiary); }
|
|
13
|
+
.cacheBadge { flex:none; font-size:11px; color:var(--dsw-alias-label-tertiary); white-space:nowrap; }
|
|
14
|
+
.cacheBadge b { margin-left:2px; font-weight:600; color:var(--dsw-alias-state-success-primary); font-variant-numeric:tabular-nums; }
|
|
15
|
+
.body { flex:1 1 auto; padding:12px 16px 16px; }
|
|
16
|
+
/* ── Left card: the live window ─────────────────────────────────────────── */
|
|
17
|
+
.totalRow { display:flex; align-items:baseline; justify-content:space-between; gap:12px; }
|
|
18
|
+
.total { font-size:20px; font-weight:650; letter-spacing:-.3px; color:var(--dsw-alias-label-primary); font-variant-numeric:tabular-nums; white-space:nowrap; }
|
|
19
|
+
.totalShare { flex:none; font-size:11px; color:var(--dsw-alias-label-tertiary); white-space:nowrap; }
|
|
20
|
+
.totalShare b { color:var(--dsw-alias-label-primary-dimmed); font-weight:600; font-variant-numeric:tabular-nums; }
|
|
21
|
+
/* A stacked share bar over the drawn content; the empty state is the same
|
|
22
|
+
* diagonal hatch the old panel used for "no data yet". */
|
|
23
|
+
.bar { display:flex; width:100%; height:10px; margin:12px 0 14px; border-radius:999px; overflow:hidden; background:var(--dsw-alias-border-l1); }
|
|
24
|
+
.barSeg { height:100%; flex:none; background:var(--bucket); transition:width .35s ease; }
|
|
25
|
+
.barEmpty { display:block; width:100%; height:100%; background:repeating-linear-gradient(90deg,var(--dsw-alias-border-l2) 0 4px,transparent 4px 8px); }
|
|
26
|
+
/* ── Right card: the spend composition ──────────────────────────────────── */
|
|
27
|
+
.statsMain { display:flex; align-items:center; gap:16px; }
|
|
28
|
+
.ringWrap { position:relative; flex:none; width:116px; height:116px; }
|
|
29
|
+
.ring { display:block; }
|
|
30
|
+
.ringTrack { stroke:var(--dsw-alias-border-l1); }
|
|
31
|
+
.ringCenter { position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:1px; text-align:center; pointer-events:none; }
|
|
32
|
+
.ringTotal { font-size:17px; font-weight:650; letter-spacing:-.3px; color:var(--dsw-alias-label-primary); font-variant-numeric:tabular-nums; }
|
|
33
|
+
.ringCaption { font-size:10px; color:var(--dsw-alias-label-tertiary); }
|
|
34
|
+
/* Legend: two columns, column-major (left column is the first four buckets,
|
|
35
|
+
* the right column the rest), so the reading order matches the ring. */
|
|
36
|
+
.legend { flex:1 1 auto; min-width:0; display:grid; grid-template-rows:repeat(4,auto); grid-auto-flow:column; gap:7px 16px; margin:0; padding:0; list-style:none; }
|
|
37
|
+
.legendRow { display:grid; grid-template-columns:7px minmax(0,1fr) auto auto; align-items:center; gap:6px; min-width:0; font-size:11px; }
|
|
38
|
+
.swatch { width:7px; height:7px; border-radius:50%; background:var(--bucket); }
|
|
39
|
+
.legendName { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:var(--dsw-alias-label-secondary); }
|
|
40
|
+
.legendPercent { color:var(--dsw-alias-label-tertiary); font-variant-numeric:tabular-nums; text-align:right; white-space:nowrap; }
|
|
41
|
+
.legendValue { color:var(--bucket); font-variant-numeric:tabular-nums; font-weight:500; text-align:right; white-space:nowrap; }
|
|
42
|
+
.legendSuffix { color:var(--dsw-alias-label-tertiary); font-weight:400; }
|
|
43
|
+
.empty { margin:16px 0 18px; text-align:center; font-size:12px; color:var(--dsw-alias-label-tertiary); }
|
|
44
|
+
/* ── Bucket palette ─────────────────────────────────────────────────────── */
|
|
45
|
+
.toneSystemPrompt { --bucket:#6f6bf5; }
|
|
46
|
+
.toneUser { --bucket:#2bb673; }
|
|
47
|
+
.toneSkill { --bucket:#16b8a6; }
|
|
48
|
+
.toneToolResult { --bucket:#4d6bfe; }
|
|
49
|
+
.toneToolDef { --bucket:#f0a53a; }
|
|
50
|
+
.toneInjected { --bucket:#a75ff0; }
|
|
51
|
+
.toneAssistant { --bucket:#7c8cff; }
|
|
52
|
+
.toneOutput { --bucket:#ef5da8; }
|
|
53
|
+
/* Narrow surfaces: one card per row, and the legends go back to wrapping rows
|
|
54
|
+
* (a two-column legend cannot keep 11 px labels readable under ~340 px). */
|
|
55
|
+
@container (max-width:880px) {
|
|
56
|
+
.grid { grid-template-columns:minmax(0,1fr); }
|
|
57
|
+
}
|
|
58
|
+
@container (max-width:520px) {
|
|
59
|
+
.statsMain { flex-wrap:wrap; justify-content:center; }
|
|
60
|
+
.legend { grid-template-rows:none; grid-auto-flow:row; grid-template-columns:minmax(0,1fr); width:100%; }
|
|
61
|
+
}
|
|
62
|
+
@media (prefers-reduced-motion:reduce) {
|
|
63
|
+
.barSeg { transition:none; }
|
|
64
|
+
}
|
|
65
|
+
/* Dark theme: the host re-resolves its aliases; only the bucket hues need a
|
|
66
|
+
* brighter variant so chips and ring segments hold their weight. */
|
|
67
|
+
body[data-ds-dark-theme] .toneSystemPrompt { --bucket:#9b96ff; }
|
|
68
|
+
body[data-ds-dark-theme] .toneUser { --bucket:#3ed08a; }
|
|
69
|
+
body[data-ds-dark-theme] .toneSkill { --bucket:#2fd6c2; }
|
|
70
|
+
body[data-ds-dark-theme] .toneToolResult { --bucket:#7d95ff; }
|
|
71
|
+
body[data-ds-dark-theme] .toneToolDef { --bucket:#f5b861; }
|
|
72
|
+
body[data-ds-dark-theme] .toneInjected { --bucket:#c184ff; }
|
|
73
|
+
body[data-ds-dark-theme] .toneAssistant { --bucket:#9aabff; }
|
|
74
|
+
body[data-ds-dark-theme] .toneOutput { --bucket:#ff85c0; }
|