@chenlongapps/opencode-token-usage 0.4.2 → 0.4.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 CHANGED
@@ -42,7 +42,7 @@ For remote sessions, add the package name to `plugins` in your local `~/.config/
42
42
 
43
43
  ### Detailed usage
44
44
 
45
- Enter `/usage` in a session to open a native dialog with the session tree's token totals and estimated cost by recorded model. Scroll with ↑/↓, Page Up/Down, Home/End, press `d` to switch between compact and detailed numbers, and close with Escape. The command does not send a prompt to the model.
45
+ Enter `/usage` in a session to open a native dialog with the session tree's token totals and estimated cost by recorded model. Scroll with ↑/↓, Page Up/Down, Home/End, press `d` to switch between compact and detailed numbers and reveal model rates, and close with Escape. The command does not send a prompt to the model.
46
46
 
47
47
  The subagent picker keeps the summary in the form `Token Usage · Context … · Total … · Cost … · TPS …`. Missing values remain unavailable rather than becoming zero, and the line does not advertise a keyboard shortcut. Clicking it opens a smaller dialog with the sidebar's exact rows (including Steps, TPS and TTFT) without reserving space for the full panel while closed.
48
48
 
@@ -54,11 +54,11 @@ The dialog is split into five sections with separate statistics:
54
54
  | Last Request | The five token categories and cache rate of the viewed session's most recent reported call |
55
55
  | Context Breakdown | Estimated prompt composition, largest first, with bars |
56
56
  | Session | Tree totals: steps, calls, tokens, cache rate, cost (single line, or per-category rows in detailed mode) |
57
- | By Model | Tokens, calls and cost per recorded model, highest cost first |
57
+ | By Model | Tokens, calls and cost per recorded model, highest cost first; detailed mode shows the rates used for each model's cost estimate |
58
58
 
59
59
  One line under the title names the session and its active model, and the body never repeats the model name. Compact mode uses `K`/`M` abbreviations (`812`, `139.4K`, `3.70M`), hides empty rows and merges the two tool families in Context Breakdown into a single `Tools` row. Detailed mode shows exact numbers, empty rows, and the `System Tools` / `MCP Tools` split. The sidebar keeps exact numbers and its own layout.
60
60
 
61
- Costs follow the sidebar's pricing and `partial`/unavailable/free conventions. Context sections are unavailable until the viewed session has reported usage and a known context limit.
61
+ Costs follow the sidebar's pricing and `partial`/unavailable/free conventions. In detailed mode, By Model shows the applied Input, Output, Reasoning, Cache Read and Cache Write rates in USD per million text tokens. Each rate group names its source (OpenCode or the built-in snapshot), incoming-token tier and call count; a model can have multiple groups. `Incoming` means Input + Cache Read + Cache Write. Missing rates show `—`, confirmed free rates show `$0.00`, and unpriced calls are counted separately. These are the current rates used to estimate recorded calls, not historical provider bills. Context sections are unavailable until the viewed session has reported usage and a known context limit.
62
62
 
63
63
  **Context Breakdown** separates Messages, System Tools, System Prompt, Skills, MCP Tools, and Other. It estimates the text and tool definitions in the viewed session's **latest assembled model request**; its percentages use the sum of those estimates. These are not provider-reported token counts and do not add up to the measured context window, which also includes output and reasoning. Media and provider-specific framing cannot be measured from the request. A server plugin records only aggregate estimates (not prompt text); sessions without a captured request or an available server RPC show “Source estimates unavailable”.
64
64
 
package/README.zh-CN.md CHANGED
@@ -42,7 +42,7 @@ opencode plugin add @chenlongapps/opencode-token-usage
42
42
 
43
43
  ### 详细用量
44
44
 
45
- 在会话中输入 `/usage`,可打开原生弹窗查看当前会话树的 token 总量,以及按消息实际模型汇总的估算费用。使用 ↑/↓、Page Up/Down、Home/End 滚动,按 `d` 切换紧凑/详细数字,Escape 关闭。该命令不会向模型发送消息。
45
+ 在会话中输入 `/usage`,可打开原生弹窗查看当前会话树的 token 总量,以及按消息实际模型汇总的估算费用。使用 ↑/↓、Page Up/Down、Home/End 滚动,按 `d` 切换紧凑/详细数字并查看模型费率,Escape 关闭。该命令不会向模型发送消息。
46
46
 
47
47
  子代理视图中的摘要格式为 `Token Usage · Context … · Total … · Cost … · TPS …`。缺失数据仍显示不可用,不会被当作零,摘要也不提示快捷键。点击后打开较小的弹窗,按侧边栏原有行序展示精确数值(包括 Steps、TPS 和 TTFT);关闭时不会为完整面板预留高度。
48
48
 
@@ -54,11 +54,11 @@ opencode plugin add @chenlongapps/opencode-token-usage
54
54
  | Last Request | 被查看会话最近一次已上报调用的五类 token 与缓存命中率 |
55
55
  | Context Breakdown | 提示词构成估算,按占比降序排列并带条形图 |
56
56
  | Session | 全树汇总:steps、calls、tokens、缓存率与费用(紧凑模式为单行,详细模式逐类展开) |
57
- | By Model | 各模型的 tokens、calls 与费用,按费用降序 |
57
+ | By Model | 各模型的 tokens、calls 与费用,按费用降序;详细模式显示用于估算各模型费用的费率 |
58
58
 
59
59
  标题下方一行注明会话与当前活动模型,正文不再重复模型名。紧凑模式使用 `K`/`M` 缩写(`812`、`139.4K`、`3.70M`),隐藏零值行,并把 Context Breakdown 中的两类工具合并为一行 `Tools`;详细模式显示精确数字、零值行及 `System Tools` / `MCP Tools` 拆分。侧边栏仍使用精确数字和原有布局。
60
60
 
61
- 费用沿用侧边栏的价格及 `partial`、不可定价、免费口径。当前会话尚未上报用量或上下文上限未知时,上下文相关区域显示不可用。
61
+ 费用沿用侧边栏的价格及 `partial`、不可定价、免费口径。详细模式下,By Model 显示 Input、Output、Reasoning、Cache Read、Cache Write 的美元/百万文本 token 费率。每组费率注明来源(OpenCode 或内置快照)、传入 token 档位和调用数;同一模型可能有多组。传入 token 为 Input + Cache Read + Cache Write。缺失费率显示 `—`,确认免费显示 `$0.00`,无法定价的调用单独计数。这些是当前用于估算已记录调用的费率,不是历史提供商账单。当前会话尚未上报用量或上下文上限未知时,上下文相关区域显示不可用。
62
62
 
63
63
  弹窗中的 **Context Breakdown** 将消息、系统工具、系统提示词、技能、MCP 工具及其他分开展示。它根据被查看会话**最近一次已组装的模型请求**中的文本和工具定义估算,占比的分母是六项估算值之和;这些不是提供商上报的精确 token,也不会与上方含输出、推理的实测上下文窗口相加一致。媒体内容与提供商额外包装无法准确计量。服务端插件仅保存分类数值,不保存提示词正文;未捕获到请求或服务端 RPC 不可用时显示“来源估算不可用”。
64
64
 
package/ROADMAP.md CHANGED
@@ -101,10 +101,19 @@ Steps 1,234
101
101
  * [x] 紧凑数字(K/M)、来源条形图、Session 单行汇总、`d` 详细模式切换
102
102
  * [x] 子代理视图保留 Context、Total、Cost、TPS 单行摘要,点击打开可关闭的原生居中统计弹窗
103
103
 
104
+ ## v0.4.3
105
+
106
+ * [x] 在 `/usage` 中显示模型的定价信息。
107
+
104
108
  ## 后续评估
105
109
 
106
110
  * [x] 首次 npm 发布:`@chenlongapps/opencode-token-usage@0.2.1`
107
111
 
108
112
  ## 待解决问题
109
113
 
110
- * [ ] TPS 的实时计算:当前为近似值,后续考虑通过更精确的算法计算
114
+ ### 后续评估与维护
115
+
116
+ * [ ] GPT-6 Luna 实际约为 30 tok/s,但在 实时统计中仅显示 ~1.5 tok/s,而且这个数字长期不变化。初步怀疑是 Responses API 的原因。
117
+ * [ ] TPS 的实时计算:当前为近似值,后续考虑通过更精确的算法计算。
118
+ * [ ] 在定时或发布前验证中运行打包产物的真实宿主 smoke 测试;升级 OpenCode/SDK 时重新核对消息排序、分叉副本、compaction、事件与价格语义。
119
+ * [ ] 同步仓库指南中的当前包版本与测试数量,避免 `AGENTS.md` 的状态说明落后于实际工程。
@@ -0,0 +1,43 @@
1
+ import type { SessionContext } from "@opencode/plugin/promise/session";
2
+ import type { Plugin } from "@opencode/plugin";
3
+ import type { ContextSources } from "./context-sources.js";
4
+ type Request = Pick<SessionContext, "sessionID" | "model" | "system" | "messages" | "tools">;
5
+ type Mcp = {
6
+ list(): Promise<{
7
+ data: readonly {
8
+ name: string;
9
+ }[];
10
+ }>;
11
+ };
12
+ type Storage = Pick<Plugin.Context["storage"], "get" | "set" | "remove">;
13
+ /** The hook awaits MCP metadata only on a cache miss; persistence follows the request. */
14
+ export declare class ContextCapture {
15
+ private readonly mcp;
16
+ private readonly storage;
17
+ private readonly ttl;
18
+ private readonly now;
19
+ private readonly elapsed;
20
+ private names;
21
+ private expiresAt;
22
+ private listing;
23
+ private writes;
24
+ private inflight;
25
+ private latestEstimate;
26
+ private deleted;
27
+ readonly timings: {
28
+ requests: number;
29
+ mcpReads: number;
30
+ mcpWaitMs: number;
31
+ storageWrites: number;
32
+ storageWriteMs: number;
33
+ };
34
+ constructor(mcp: Mcp, storage: Storage, ttl?: number, now?: () => number, elapsed?: () => number);
35
+ private servers;
36
+ invalidateMcp(): void;
37
+ capture(request: Request): Promise<void>;
38
+ private record;
39
+ latest(sessionID: string): Promise<ContextSources | undefined>;
40
+ remove(sessionID: string): Promise<void>;
41
+ flush(): Promise<void>;
42
+ }
43
+ export {};
@@ -0,0 +1,111 @@
1
+ import { estimateContextSources, parseContextSources } from "./context-sources.js";
2
+ /** The hook awaits MCP metadata only on a cache miss; persistence follows the request. */
3
+ export class ContextCapture {
4
+ mcp;
5
+ storage;
6
+ ttl;
7
+ now;
8
+ elapsed;
9
+ names;
10
+ expiresAt = 0;
11
+ listing;
12
+ writes = new Map();
13
+ inflight = new Map();
14
+ latestEstimate = new Map();
15
+ deleted = new Set();
16
+ timings = { requests: 0, mcpReads: 0, mcpWaitMs: 0, storageWrites: 0, storageWriteMs: 0 };
17
+ constructor(mcp, storage, ttl = 30_000, now = Date.now, elapsed = () => performance.now()) {
18
+ this.mcp = mcp;
19
+ this.storage = storage;
20
+ this.ttl = ttl;
21
+ this.now = now;
22
+ this.elapsed = elapsed;
23
+ }
24
+ async servers() {
25
+ if (this.names && this.now() < this.expiresAt)
26
+ return this.names;
27
+ if (!this.listing) {
28
+ this.timings.mcpReads++;
29
+ this.listing = this.mcp.list().then(result => {
30
+ this.names = result.data.map(server => server.name);
31
+ this.expiresAt = this.now() + this.ttl;
32
+ return this.names;
33
+ }).catch(error => {
34
+ if (this.names) {
35
+ this.expiresAt = this.now() + this.ttl;
36
+ return this.names;
37
+ }
38
+ throw error;
39
+ }).finally(() => { this.listing = undefined; });
40
+ }
41
+ return this.listing;
42
+ }
43
+ invalidateMcp() { this.expiresAt = 0; }
44
+ capture(request) {
45
+ const tasks = this.inflight.get(request.sessionID) ?? new Set();
46
+ this.inflight.set(request.sessionID, tasks);
47
+ const task = this.record(request);
48
+ tasks.add(task);
49
+ return task.finally(() => {
50
+ tasks.delete(task);
51
+ if (tasks.size === 0)
52
+ this.inflight.delete(request.sessionID);
53
+ });
54
+ }
55
+ async record(request) {
56
+ if (this.deleted.has(request.sessionID))
57
+ return;
58
+ const start = this.elapsed();
59
+ let names;
60
+ try {
61
+ names = await this.servers();
62
+ }
63
+ finally {
64
+ this.timings.requests++;
65
+ this.timings.mcpWaitMs += this.elapsed() - start;
66
+ }
67
+ if (this.deleted.has(request.sessionID))
68
+ return;
69
+ const value = estimateContextSources(request, names);
70
+ this.latestEstimate.set(request.sessionID, value);
71
+ const previous = this.writes.get(request.sessionID) ?? Promise.resolve();
72
+ const write = previous.catch(() => { }).then(async () => {
73
+ if (this.deleted.has(request.sessionID))
74
+ return;
75
+ const started = this.elapsed();
76
+ this.timings.storageWrites++;
77
+ try {
78
+ await this.storage.set(`context/${request.sessionID}`, {
79
+ capturedAt: value.capturedAt, model: value.model, tokens: { ...value.tokens },
80
+ });
81
+ }
82
+ finally {
83
+ this.timings.storageWriteMs += this.elapsed() - started;
84
+ }
85
+ });
86
+ this.writes.set(request.sessionID, write);
87
+ void write.catch(() => { }).finally(() => {
88
+ if (this.writes.get(request.sessionID) === write)
89
+ this.writes.delete(request.sessionID);
90
+ });
91
+ }
92
+ async latest(sessionID) {
93
+ if (this.deleted.has(sessionID))
94
+ return undefined;
95
+ return this.latestEstimate.get(sessionID)
96
+ ?? parseContextSources(await this.storage.get(`context/${sessionID}`));
97
+ }
98
+ async remove(sessionID) {
99
+ this.deleted.add(sessionID);
100
+ if (this.deleted.size > 256)
101
+ this.deleted.delete(this.deleted.values().next().value);
102
+ this.latestEstimate.delete(sessionID);
103
+ await Promise.allSettled(this.inflight.get(sessionID) ?? []);
104
+ await this.writes.get(sessionID)?.catch(() => { });
105
+ await this.storage.remove(`context/${sessionID}`);
106
+ }
107
+ async flush() {
108
+ await Promise.allSettled([...this.inflight.values()].flatMap(tasks => [...tasks]));
109
+ await Promise.allSettled(this.writes.values());
110
+ }
111
+ }
@@ -40,6 +40,9 @@ export declare class UsageController {
40
40
  private retry;
41
41
  private performanceTimer;
42
42
  private sessions;
43
+ private changed;
44
+ private fullScan;
45
+ private prepared;
43
46
  private pending;
44
47
  private disposed;
45
48
  private unsubscribe;
@@ -49,6 +52,7 @@ export declare class UsageController {
49
52
  constructor(source: UsageSource, subscribe: Subscribe, publish: (state: UsageState) => void, delay?: number, retryDelay?: number, performanceDelay?: number, performance?: PerformanceMonitor, detailed?: boolean);
50
53
  select(sessionID: string): void;
51
54
  refresh(): void;
55
+ private schedule;
52
56
  dispose(): void;
53
57
  private update;
54
58
  private refreshPerformance;
@@ -1,11 +1,12 @@
1
1
  import { loadSnapshot, uniqueMessages, viewedMessages } from "./source.js";
2
- import { PerformanceMonitor } from "./performance.js";
2
+ import { PerformanceMonitor, preparePerformance } from "./performance.js";
3
3
  import { contextDetails, summarize, summarizeModels } from "./usage.js";
4
4
  const sessionEvents = new Set([
5
5
  "session.created", "session.deleted", "session.forked", "session.moved",
6
6
  "session.model.selected", "session.agent.selected", "session.step.ended", "session.step.failed",
7
7
  "session.compaction.ended", "session.compaction.failed", "session.message.content.updated",
8
8
  "session.usage.recorded", "session.usage.updated", "session.revert.committed",
9
+ "session.revert.staged", "session.revert.cleared",
9
10
  "session.execution.succeeded", "session.execution.failed", "session.execution.interrupted",
10
11
  ]);
11
12
  const globalEvents = new Set([
@@ -29,6 +30,9 @@ export class UsageController {
29
30
  retry;
30
31
  performanceTimer;
31
32
  sessions = new Set();
33
+ changed = new Set();
34
+ fullScan = true;
35
+ prepared;
32
36
  pending = false;
33
37
  disposed = false;
34
38
  unsubscribe;
@@ -50,18 +54,27 @@ export class UsageController {
50
54
  });
51
55
  this.unsubscribe = subscribe(event => {
52
56
  const data = (event.data && typeof event.data === "object" ? event.data : {});
53
- if (event.type === "session.created" && data.sessionID && data.parentID && this.sessions.has(data.parentID)) {
54
- this.sessions.add(data.sessionID);
55
- }
56
- if (event.type === "session.deleted" && data.sessionID)
57
- this.sessions.delete(data.sessionID);
58
57
  if (globalEvents.has(event.type))
59
58
  return this.refresh();
60
59
  if (!sessionEvents.has(event.type))
61
60
  return;
62
- if (!this.snapshot || event.type === "session.created" || event.type === "session.forked"
63
- || data.sessionID === this.sessionID || (data.sessionID && this.snapshot.sessions.has(data.sessionID)))
64
- this.refresh();
61
+ if (event.type === "session.created") {
62
+ if (data.sessionID && data.parentID && (this.sessions.has(data.parentID) || data.parentID === this.sessionID)) {
63
+ this.sessions.add(data.sessionID); // Stream updates can precede discovery.
64
+ this.schedule(false);
65
+ }
66
+ else if (data.sessionID === this.sessionID)
67
+ this.schedule(false);
68
+ return;
69
+ }
70
+ const related = data.sessionID === this.sessionID || !!(data.sessionID && this.sessions.has(data.sessionID));
71
+ if (!related)
72
+ return;
73
+ if (event.type === "session.deleted" && data.sessionID)
74
+ this.sessions.delete(data.sessionID);
75
+ if (data.sessionID)
76
+ this.changed.add(data.sessionID);
77
+ this.schedule(event.type === "session.moved");
65
78
  });
66
79
  }
67
80
  select(sessionID) {
@@ -77,14 +90,22 @@ export class UsageController {
77
90
  this.performanceTimer = undefined;
78
91
  this.pending = false;
79
92
  this.snapshot = undefined;
93
+ this.prepared = undefined;
80
94
  this.sessions.clear();
95
+ this.changed.clear();
96
+ this.fullScan = true;
81
97
  this.sessionID = sessionID;
82
98
  this.update({ status: "loading" });
83
99
  void this.run();
84
100
  }
85
101
  refresh() {
102
+ this.schedule(true);
103
+ }
104
+ schedule(full) {
86
105
  if (this.disposed || !this.sessionID)
87
106
  return;
107
+ if (full)
108
+ this.fullScan = true;
88
109
  clearTimeout(this.retry);
89
110
  if (this.request) {
90
111
  this.pending = true;
@@ -117,7 +138,8 @@ export class UsageController {
117
138
  this.performanceTimer = undefined;
118
139
  if (this.disposed || !this.snapshot || !this.state.summary)
119
140
  return;
120
- this.update({ ...this.state, performance: this.performance.summary(uniqueMessages(this.snapshot), this.sessions) });
141
+ if (this.prepared)
142
+ this.update({ ...this.state, performance: this.performance.summaryPrepared(this.prepared, this.sessions) });
121
143
  }, this.performanceDelay);
122
144
  }
123
145
  async run() {
@@ -126,24 +148,29 @@ export class UsageController {
126
148
  const generation = this.generation;
127
149
  const request = new AbortController();
128
150
  this.request = request;
151
+ const full = this.fullScan;
152
+ const changed = new Set(this.changed);
153
+ this.fullScan = false;
154
+ this.changed.clear();
129
155
  try {
130
156
  const signal = AbortSignal.any([request.signal, AbortSignal.timeout(30_000)]);
131
- const snapshot = await loadSnapshot(this.source, this.sessionID, signal);
157
+ const snapshot = await loadSnapshot(this.source, this.sessionID, signal, full ? undefined : this.snapshot, full ? undefined : changed);
132
158
  if (this.disposed || generation !== this.generation)
133
159
  return;
134
160
  this.snapshot = snapshot;
135
161
  const messages = [...uniqueMessages(snapshot)];
136
162
  this.sessions = new Set(snapshot.sessions.keys());
137
163
  this.performance.reconcile(messages, this.sessions);
164
+ this.prepared = preparePerformance(messages);
138
165
  clearTimeout(this.performanceTimer);
139
166
  this.performanceTimer = undefined;
140
- const context = contextDetails(viewedMessages(snapshot), snapshot.model.context);
167
+ const context = contextDetails(viewedMessages(snapshot), snapshot.model.context, snapshot.sessions.get(snapshot.viewedID)?.revert?.messageID);
141
168
  this.update({
142
169
  status: "ready",
143
170
  summary: summarize(messages, snapshot.model.catalog),
144
171
  context: context?.usage,
145
172
  ...(this.detailed ? { details: { context, models: summarizeModels(messages, snapshot.model.catalog), sessionTitle: snapshot.sessions.get(snapshot.viewedID)?.title } } : {}),
146
- performance: this.performance.summary(messages, this.sessions),
173
+ performance: this.performance.summaryPrepared(this.prepared, this.sessions),
147
174
  model: snapshot.model.label,
148
175
  });
149
176
  // The server plugin may be absent. An optional RPC must not delay measured usage.
@@ -160,15 +187,16 @@ export class UsageController {
160
187
  catch {
161
188
  if (this.disposed || generation !== this.generation)
162
189
  return;
190
+ this.fullScan = true;
163
191
  this.update({ ...this.state, status: this.state.summary ? "stale" : "unavailable" });
164
- this.retry = setTimeout(() => this.refresh(), this.retryDelay);
192
+ this.retry = setTimeout(() => this.schedule(false), this.retryDelay);
165
193
  }
166
194
  finally {
167
195
  if (generation === this.generation && !this.disposed) {
168
196
  this.request = undefined;
169
197
  if (this.pending) {
170
198
  this.pending = false;
171
- this.refresh();
199
+ this.schedule(false);
172
200
  }
173
201
  }
174
202
  }
package/dist/index.js CHANGED
@@ -1,27 +1,48 @@
1
1
  import { Plugin } from "@opencode/plugin";
2
- import { estimateContextSources, parseContextSources } from "./context-sources.js";
2
+ import { ContextCapture } from "./context-capture.js";
3
3
  import { ContextSourceRpc } from "./context-rpc.js";
4
4
  export default Plugin.define({
5
5
  id: "opencode-token-usage",
6
6
  async setup(context) {
7
+ const capture = new ContextCapture(context.mcp, context.storage);
7
8
  const rpc = await context.rpc.register(ContextSourceRpc, {
8
9
  latest: async (input) => {
9
10
  const sessionID = input.sessionID;
10
- return { estimate: parseContextSources(await context.storage.get(`context/${sessionID}`)) ?? null };
11
+ return { estimate: await capture.latest(sessionID) ?? null };
11
12
  },
12
13
  });
13
14
  const hook = await context.session.hook("context", async (request) => {
14
15
  try {
15
- const servers = (await context.mcp.list()).data.map(server => server.name);
16
- const estimate = estimateContextSources(request, servers);
17
- await context.storage.set(`context/${request.sessionID}`, {
18
- capturedAt: estimate.capturedAt, model: estimate.model, tokens: { ...estimate.tokens },
19
- });
16
+ await capture.capture(request);
20
17
  }
21
18
  catch {
22
19
  // Instrumentation must not interrupt the model call.
23
20
  }
24
21
  });
25
- return async () => { await hook.dispose(); await rpc.dispose(); };
22
+ const stop = new AbortController();
23
+ const events = (async () => {
24
+ try {
25
+ for await (const event of context.event.subscribe({ signal: stop.signal })) {
26
+ if (event.type === "session.deleted") {
27
+ try {
28
+ await capture.remove(event.data.sessionID);
29
+ }
30
+ catch { /* Keep processing later events. */ }
31
+ }
32
+ if (event.type === "mcp.status.changed" || event.type === "mcp.resources.changed")
33
+ capture.invalidateMcp();
34
+ }
35
+ }
36
+ catch {
37
+ // A disconnect or shutdown must not affect model requests.
38
+ }
39
+ })();
40
+ return async () => {
41
+ stop.abort();
42
+ await hook.dispose();
43
+ await capture.flush();
44
+ await rpc.dispose();
45
+ void events;
46
+ };
26
47
  },
27
48
  });
@@ -17,10 +17,18 @@ export interface PerformanceEvent {
17
17
  id?: string;
18
18
  created?: number;
19
19
  }
20
+ export interface PreparedPerformance {
21
+ tps?: number;
22
+ ttftSum: number;
23
+ ttftCount: number;
24
+ historical: ReadonlyMap<string, number>;
25
+ }
20
26
  export type PerformanceSubscribe = (listener: (event: PerformanceEvent) => void) => () => void;
21
27
  export type PerformanceListener = (sessionID: string) => void;
22
28
  /** Exact completed-run TPS and the average of only measurable TTFT samples. */
23
29
  export declare function historicalPerformance(messages: Iterable<UsageMessage>, observations?: Iterable<FirstTokenObservation>): PerformanceSummary;
30
+ /** The immutable part of a snapshot is scanned once, then reused for stream updates. */
31
+ export declare function preparePerformance(messages: Iterable<UsageMessage>): PreparedPerformance;
24
32
  /** Captures streaming performance for every session for one plugin lifetime. */
25
33
  export declare class PerformanceMonitor {
26
34
  private readonly retention;
@@ -42,4 +50,5 @@ export declare class PerformanceMonitor {
42
50
  handle(event: PerformanceEvent): boolean;
43
51
  reconcile(messages: Iterable<UsageMessage>, sessionIDs: Iterable<string>): void;
44
52
  summary(messages: Iterable<UsageMessage>, sessionIDs: Iterable<string>): PerformanceSummary;
53
+ summaryPrepared(prepared: PreparedPerformance, sessionIDs: Iterable<string>): PerformanceSummary;
45
54
  }
@@ -25,20 +25,18 @@ function historicalFirst(message) {
25
25
  }
26
26
  /** Exact completed-run TPS and the average of only measurable TTFT samples. */
27
27
  export function historicalPerformance(messages, observations = []) {
28
- const observed = new Map();
29
- for (const sample of observations) {
30
- if (finite(sample.started) && finite(sample.first) && sample.first >= sample.started)
31
- observed.set(sample.messageID, sample);
32
- }
28
+ return performanceWithObservations(preparePerformance(messages), observations);
29
+ }
30
+ /** The immutable part of a snapshot is scanned once, then reused for stream updates. */
31
+ export function preparePerformance(messages) {
33
32
  let output = 0;
34
33
  let duration = 0;
35
- let ttft = 0;
36
- let ttftSamples = 0;
37
- const messagesSeen = new Set();
34
+ let ttftSum = 0;
35
+ let ttftCount = 0;
36
+ const historical = new Map();
38
37
  for (const message of messages) {
39
38
  if (message.type !== "assistant")
40
39
  continue;
41
- messagesSeen.add(message.id);
42
40
  const created = message.time?.created;
43
41
  const streamed = message.time?.streamed;
44
42
  const tokens = normalize(message.tokens);
@@ -47,23 +45,35 @@ export function historicalPerformance(messages, observations = []) {
47
45
  output += generated;
48
46
  duration += streamed - created;
49
47
  }
50
- const runtime = observed.get(message.id);
51
- const sample = runtime ? runtime.first - runtime.started : historicalFirst(message);
48
+ const sample = historicalFirst(message);
52
49
  if (sample !== undefined && finite(sample) && sample >= 0) {
53
- ttft += sample;
54
- ttftSamples++;
50
+ historical.set(message.id, sample);
51
+ ttftSum += sample;
52
+ ttftCount++;
55
53
  }
56
54
  }
57
- // A live first-token event can arrive before the refreshed message snapshot.
55
+ return { ...(duration > 0 ? { tps: output / (duration / 1_000) } : {}), ttftSum, ttftCount, historical };
56
+ }
57
+ function performanceWithObservations(prepared, observations) {
58
+ const observed = new Map();
59
+ for (const sample of observations) {
60
+ if (finite(sample.started) && finite(sample.first) && sample.first >= sample.started)
61
+ observed.set(sample.messageID, sample);
62
+ }
63
+ let ttft = prepared.ttftSum;
64
+ let ttftSamples = prepared.ttftCount;
58
65
  for (const sample of observed.values()) {
59
- if (messagesSeen.has(sample.messageID))
60
- continue;
66
+ const old = prepared.historical.get(sample.messageID);
67
+ if (old !== undefined) {
68
+ ttft -= old;
69
+ ttftSamples--;
70
+ }
61
71
  ttft += sample.first - sample.started;
62
72
  ttftSamples++;
63
73
  }
64
74
  const result = {};
65
- if (duration > 0)
66
- result.tps = output / (duration / 1_000);
75
+ if (prepared.tps !== undefined)
76
+ result.tps = prepared.tps;
67
77
  if (ttftSamples > 0)
68
78
  result.ttft = ttft / ttftSamples;
69
79
  return result;
@@ -117,7 +127,8 @@ export class PerformanceMonitor {
117
127
  const now = this.now();
118
128
  const changed = new Set();
119
129
  for (const [key, step] of this.active) {
120
- if (step.finishedAt !== undefined && step.finishedAt + this.retention <= now) {
130
+ if ((step.finishedAt ?? step.reconciledAt) !== undefined
131
+ && (step.finishedAt ?? step.reconciledAt) + this.retention <= now) {
121
132
  this.active.delete(key);
122
133
  changed.add(step.sessionID);
123
134
  }
@@ -136,8 +147,9 @@ export class PerformanceMonitor {
136
147
  return;
137
148
  let next = Infinity;
138
149
  for (const step of this.active.values()) {
139
- if (step.finishedAt !== undefined)
140
- next = Math.min(next, step.finishedAt + this.retention);
150
+ const settled = step.finishedAt ?? step.reconciledAt;
151
+ if (settled !== undefined)
152
+ next = Math.min(next, settled + this.retention);
141
153
  }
142
154
  for (const sample of this.observed.values()) {
143
155
  if (sample.expiresAt !== undefined)
@@ -195,6 +207,8 @@ export class PerformanceMonitor {
195
207
  const sample = this.observed.get(key);
196
208
  if (sample)
197
209
  sample.expiresAt = step.finishedAt + this.retention;
210
+ if (step.reconciledAt !== undefined)
211
+ this.active.delete(key);
198
212
  this.scheduleCleanup();
199
213
  return false;
200
214
  }
@@ -207,6 +221,14 @@ export class PerformanceMonitor {
207
221
  return false;
208
222
  if (event.id)
209
223
  step.events.add(event.id);
224
+ if (step.reconciledAt !== undefined) {
225
+ // A later delta means the snapshot did not represent the final stream.
226
+ delete step.reconciledAt;
227
+ const sample = this.observed.get(key);
228
+ if (sample)
229
+ delete sample.expiresAt;
230
+ this.scheduleCleanup();
231
+ }
210
232
  step.bytes += new TextEncoder().encode(data.delta).byteLength;
211
233
  step.last = Math.max(step.last ?? event.created, event.created);
212
234
  if (step.first === undefined) {
@@ -225,19 +247,32 @@ export class PerformanceMonitor {
225
247
  represented.add(message.id);
226
248
  }
227
249
  for (const [key, step] of this.active) {
228
- if (sessions.has(step.sessionID) && represented.has(step.messageID)
229
- && (step.finishedAt !== undefined || step.streamed !== undefined))
230
- this.active.delete(key);
250
+ if (sessions.has(step.sessionID) && represented.has(step.messageID)) {
251
+ if (step.finishedAt !== undefined)
252
+ this.active.delete(key);
253
+ else {
254
+ // A snapshot can arrive before step.ended. Retain the step to attach
255
+ // the real expiry later, and set a fallback if that event is lost.
256
+ step.reconciledAt ??= this.now();
257
+ const sample = this.observed.get(key);
258
+ if (sample)
259
+ sample.expiresAt ??= step.reconciledAt + this.retention;
260
+ }
261
+ }
231
262
  }
263
+ this.scheduleCleanup();
232
264
  }
233
265
  summary(messages, sessionIDs) {
266
+ return this.summaryPrepared(preparePerformance(messages), sessionIDs);
267
+ }
268
+ summaryPrepared(prepared, sessionIDs) {
234
269
  const sessions = new Set(sessionIDs);
235
270
  const samples = [...this.observed.values()].filter(sample => sessions.has(sample.sessionID));
236
- const result = historicalPerformance(messages, samples);
271
+ const result = performanceWithObservations(prepared, samples);
237
272
  let bytes = 0;
238
273
  let duration = 0;
239
274
  for (const step of this.active.values()) {
240
- if (!sessions.has(step.sessionID) || step.bytes <= 0)
275
+ if (!sessions.has(step.sessionID) || step.reconciledAt !== undefined || step.bytes <= 0)
241
276
  continue;
242
277
  const last = step.streamed ?? step.last;
243
278
  if (!finite(last) || last <= step.started)