@chenlongapps/opencode-token-usage 0.4.2 → 0.4.4

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/dist/usage.js CHANGED
@@ -10,10 +10,13 @@ export function normalize(tokens) {
10
10
  export const incoming = (t) => t.input + t.cache.read + t.cache.write;
11
11
  export const total = (t) => incoming(t) + t.output + t.reasoning;
12
12
  // Matches OpenCode 2.0.11 SessionUsage.calculateCost: thresholds are exclusive.
13
- export function estimate(tokens, prices = []) {
13
+ function selectedPrice(tokens, prices) {
14
14
  const tier = prices.filter(p => p.tier && incoming(tokens) > p.tier.size)
15
15
  .sort((a, b) => b.tier.size - a.tier.size)[0];
16
- const price = tier ?? prices.find(p => !p.tier);
16
+ return tier ?? prices.find(p => !p.tier);
17
+ }
18
+ export function estimate(tokens, prices = []) {
19
+ const price = selectedPrice(tokens, prices);
17
20
  const rates = [price?.input, price?.output, price?.reasoning ?? price?.output, price?.cache?.read, price?.cache?.write];
18
21
  const quantities = [tokens.input, tokens.output, tokens.reasoning, tokens.cache.read, tokens.cache.write];
19
22
  return {
@@ -25,13 +28,41 @@ export function estimate(tokens, prices = []) {
25
28
  function priceMessage(tokens, model, catalog) {
26
29
  if (!model)
27
30
  return undefined;
28
- let value = estimate(tokens, catalog.get(modelKey(model)));
31
+ let prices = catalog.get(modelKey(model)) ?? [];
32
+ let source = "OpenCode";
33
+ let value = estimate(tokens, prices);
29
34
  if (value.defaultPrice || value.cost === 0) {
30
- const snapshot = estimate(tokens, officialPrice(model)?.prices);
31
- if (!snapshot.defaultPrice)
35
+ const fallback = officialPrice(model)?.prices ?? [];
36
+ const snapshot = estimate(tokens, fallback);
37
+ if (!snapshot.defaultPrice) {
32
38
  value = snapshot;
39
+ prices = fallback;
40
+ source = "Built-in snapshot";
41
+ }
33
42
  }
34
- return value.defaultPrice ? undefined : value.cost;
43
+ const price = selectedPrice(tokens, prices);
44
+ return value.defaultPrice || !price ? undefined : { cost: value.cost, source, price, prices };
45
+ }
46
+ const validRate = (value) => value !== undefined && Number.isFinite(value) && value >= 0 ? value : undefined;
47
+ function appliedRates(value) {
48
+ const lowerExclusive = value.price.tier?.size;
49
+ const next = value.prices.reduce((size, price) => {
50
+ const threshold = price.tier?.size;
51
+ return threshold !== undefined && threshold > (lowerExclusive ?? -Infinity) && threshold < size ? threshold : size;
52
+ }, Infinity);
53
+ return {
54
+ source: value.source,
55
+ lowerExclusive,
56
+ upperInclusive: Number.isFinite(next) ? next : undefined,
57
+ calls: 1,
58
+ rates: {
59
+ input: validRate(value.price.input),
60
+ output: validRate(value.price.output),
61
+ reasoning: validRate(value.price.reasoning ?? value.price.output),
62
+ cacheRead: validRate(value.price.cache?.read),
63
+ cacheWrite: validRate(value.price.cache?.write),
64
+ },
65
+ };
35
66
  }
36
67
  function costStatus(known, missing) {
37
68
  if (missing > 0)
@@ -69,7 +100,7 @@ export function summarize(messages, catalog = new Map()) {
69
100
  if (value === undefined)
70
101
  missingCosts++;
71
102
  else {
72
- cost += value;
103
+ cost += value.cost;
73
104
  knownCosts++;
74
105
  }
75
106
  }
@@ -89,21 +120,33 @@ export function summarizeModels(messages, catalog = new Map()) {
89
120
  if (count === 0)
90
121
  continue;
91
122
  const key = message.model && modelKey(message.model);
92
- const group = groups.get(key) ?? { calls: 0, tokens: 0, cost: 0, known: 0, missing: 0 };
123
+ const group = groups.get(key) ?? { calls: 0, tokens: 0, cost: 0, known: 0, missing: 0, rates: new Map() };
93
124
  group.calls++;
94
125
  group.tokens += count;
95
126
  const value = priceMessage(tokens, message.model, catalog);
96
127
  if (value === undefined)
97
128
  group.missing++;
98
129
  else {
99
- group.cost += value;
130
+ group.cost += value.cost;
100
131
  group.known++;
132
+ const applied = appliedRates(value);
133
+ const rateKey = JSON.stringify([applied.source, applied.lowerExclusive, applied.upperInclusive, ...Object.values(applied.rates)]);
134
+ const existing = group.rates.get(rateKey);
135
+ if (existing)
136
+ existing.calls++;
137
+ else
138
+ group.rates.set(rateKey, applied);
101
139
  }
102
140
  groups.set(key, group);
103
141
  }
104
142
  return [...groups].map(([model, group]) => ({
105
143
  model: model ?? "Unknown model", calls: group.calls, tokens: group.tokens,
106
144
  cost: group.cost, costStatus: costStatus(group.known, group.missing),
145
+ appliedRates: [...group.rates.values()].sort((a, b) => {
146
+ const lowerA = a.lowerExclusive ?? -Infinity, lowerB = b.lowerExclusive ?? -Infinity;
147
+ return lowerA === lowerB ? a.source.localeCompare(b.source) : lowerA - lowerB;
148
+ }),
149
+ unpricedCalls: group.missing,
107
150
  })).sort((a, b) => b.cost - a.cost || a.model.localeCompare(b.model));
108
151
  }
109
152
  const usableLimit = (value) => value !== undefined && Number.isFinite(value) && value > 0;
@@ -111,18 +154,25 @@ const usableLimit = (value) => value !== undefined && Number.isFinite(value) &&
111
154
  // the last completed compaction carries the current context size. Running or failed
112
155
  // compactions do not reset it, and a last message without usage hides the row entirely
113
156
  // instead of falling back to pre-compaction history.
114
- export function contextDetails(messages, limit) {
157
+ export function contextDetails(messages, limit, revertMessageID) {
115
158
  if (!usableLimit(limit))
116
159
  return undefined;
160
+ // The staged revert excludes its boundary message and every later message.
161
+ // Session.message.list is ascending; an absent marker can occur during a
162
+ // concurrent commit, in which case the ordered ID still gives the cutoff.
163
+ const found = revertMessageID ? messages.findIndex(message => message.id === revertMessageID) : -1;
164
+ const next = revertMessageID && found < 0 ? messages.findIndex(message => message.id > revertMessageID) : -1;
165
+ const end = !revertMessageID ? messages.length : found >= 0 ? found
166
+ : next >= 0 ? next : messages.length;
117
167
  let boundary = -1;
118
- for (let index = messages.length - 1; index >= 0; index--) {
168
+ for (let index = end - 1; index >= 0; index--) {
119
169
  const message = messages[index];
120
170
  if (message.type === "compaction" && message.status === "completed") {
121
171
  boundary = index;
122
172
  break;
123
173
  }
124
174
  }
125
- for (let index = messages.length - 1; index > boundary; index--) {
175
+ for (let index = end - 1; index > boundary; index--) {
126
176
  const message = messages[index];
127
177
  if (message.type !== "assistant" || !message.tokens)
128
178
  continue;
@@ -132,8 +182,8 @@ export function contextDetails(messages, limit) {
132
182
  }
133
183
  return undefined;
134
184
  }
135
- export function contextUsage(messages, limit) {
136
- return contextDetails(messages, limit)?.usage;
185
+ export function contextUsage(messages, limit, revertMessageID) {
186
+ return contextDetails(messages, limit, revertMessageID)?.usage;
137
187
  }
138
188
  export function formatTokens(value) {
139
189
  return Math.round(safe(value)).toLocaleString("en-US");
@@ -146,6 +196,33 @@ export function formatEstimatedCost(cost, status) {
146
196
  return "—";
147
197
  return `${formatCost(cost)}${status === "partial" ? " · partial" : ""}`;
148
198
  }
199
+ /** Per-million rates need more precision than a rounded total cost. */
200
+ export function formatRate(value) {
201
+ if (value === undefined || !Number.isFinite(value) || value < 0)
202
+ return "—";
203
+ if (value === 0)
204
+ return "$0.00";
205
+ const decimal = value.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 12 });
206
+ return `$${decimal === "0.00" ? value.toExponential(2) : decimal}`;
207
+ }
208
+ export function formatRateTier(value) {
209
+ const lower = value.lowerExclusive, upper = value.upperInclusive;
210
+ if (lower === undefined && upper === undefined)
211
+ return "all incoming sizes";
212
+ if (lower === undefined)
213
+ return `≤${formatTokens(upper)} incoming`;
214
+ if (upper === undefined)
215
+ return `>${formatTokens(lower)} incoming`;
216
+ return `>${formatTokens(lower)} to ≤${formatTokens(upper)} incoming`;
217
+ }
218
+ export function rateRows(value) {
219
+ const rates = value.rates;
220
+ return [
221
+ ["Input", formatRate(rates.input)], ["Output", formatRate(rates.output)],
222
+ ["Reasoning", formatRate(rates.reasoning)], ["Cache Read", formatRate(rates.cacheRead)],
223
+ ["Cache Write", formatRate(rates.cacheWrite)],
224
+ ];
225
+ }
149
226
  /** Compact display for scan-first views: 812, 139.4K, 3.70M. Detailed views keep formatTokens. */
150
227
  export function formatCompact(value) {
151
228
  const n = safe(value);
package/docs/pricing.md CHANGED
@@ -24,6 +24,7 @@ npm run build
24
24
  - 每条 assistant / compaction 消息使用自身记录的模型和五类 token 用量计价。OpenCode 当前解析出的完整非零价格优先;价格不完整时,整条消息回退到快照;完整零价只有在快照能覆盖该消息实际使用的类别时才被替换。两套价格不混用。
25
25
  - 上下文档位按该条消息的 `input + cache read + cache write` 选择,档位阈值为**严格大于**。输出与推理分开统计;原厂明确提供不同 `reasoning` 单价时分别计费,否则沿用输出单价。
26
26
  - 缺少实际用到的 token 类别费率就不能按该来源完整计价,不把缺价填作零。无法计价显示 `—`,部分可计价显示 `partial`,明确零价显示 `$0.00`。
27
+ - `/usage` 的 By Model 详细模式仅列出有用量模型在**当前估算**中实际采用的费率,按来源与上下文档位归组,并显示每组调用数;无法定价的调用单独计数。五类费率均为美元/百万文本 token,缺少某类价格显示 `—`,Reasoning 未单列价格时沿用 Output。这里使用当前解析出的模型目录或打包快照重新估算历史消息,不代表消息生成时的实际价格或提供商账单。
27
28
  - 网关仅通过精确原厂 ID、唯一的完整裸 ID、明确别名及已知包装格式匹配。终尾 `-free` / `:free` 可在精确匹配失败后再去掉;不剥离 `-fast`、`-pro` 等其他后缀。Fast 模式只匹配支持它的原厂 Provider;不会把普通费率借给网关 Fast 变体。
28
29
  - 导入文本输入与文本输出的 token 费率;图片、音频、视频、存储时长、搜索和工具调用等独立计费项无法由五类 token 可靠还原。音频输出另有不同价格的型号不会导入;其余多模态请求仍可能超出此文本估算范围。
29
30
 
@@ -1,5 +1,59 @@
1
1
  # 验证记录
2
2
 
3
+ ## 2026-10-02 · smoke 宿主版本策略与兼容性
4
+
5
+ 环境:macOS、Node.js v22.23.2、插件包 0.4.3。OpenCode SDK 仍精确锁定 2.0.11,未随本机 CLI 升级;本机默认宿主为 Homebrew 安装的 OpenCode 2.0.22。旧版基准使用校验 npm SHA-512 完整性的官方 `@opencode/cli-darwin-arm64@2.0.11` 独立二进制,没有降级或修改日常安装。
6
+
7
+ | 检查 | 结果 |
8
+ | --- | --- |
9
+ | `npm run typecheck` | 通过 |
10
+ | `npm test` | 85 项通过,含 6 项 smoke 版本与二进制选择回归测试 |
11
+ | `npm run build` | 通过,两版 smoke 的 `npm pack` 均执行真实构建 |
12
+ | `npm run test:smoke`,默认宿主 2.0.22 | 完整通过 |
13
+ | `OPENCODE_BIN=… npm run test:smoke`,独立宿主 2.0.11 | 完整通过 |
14
+
15
+ 版本检查不再把补丁版本列为白名单:允许稳定版 `>=2.0.9 <3.0.0` 运行实际集成检查。尚未与当前 SDK 验证的宿主会输出警告,但不被提前拒绝;当前已验证列表为 2.0.11、2.0.22。低于最低版本、其他主版本以及无法解析的稳定版输出仍会失败。版本检查通过本身不代表兼容性已验证。`OPENCODE_BIN` 统一用于版本探测、隔离服务端与 TUI;相对路径在切换到临时项目之前解析,结果文件记录实际宿主版本与所选二进制。
16
+
17
+ 本轮保留全部原有 token、费用、上下文、布局、子代理、TPS 与 TTFT 断言,并修正测试夹具的时序:实时场景提供多段、300ms 间隔的模拟文本,避免短片段被宿主批处理合并或跨出 2s TPS 窗口;模拟流在 TUI 观察到实时指标后才结束。隔离 CLI 配置关闭持久标签页,避免不同 TUI 进程复用其他会话的焦点;详细模式滚动持续等待真实目标行可见,避免旧宿主在重新排版前处理 End。压缩模拟响应使用宿主要求的结构化摘要格式。现有 OpenCode 配置未修改,也未调用付费模型。
18
+
19
+ 两版真实宿主新增验证:分叉为独立根,继承 assistant ID 保留 `_序号` 后缀;继承消息仍占被查看会话的 Context,但不重复累计用量或 Steps;分叉自己的调用单独计价。真实压缩投影为 `status === "completed"`,`order: "asc"` 的完整历史仍保留压缩前消息,压缩后没有新 assistant 时隐藏旧 Context,新消息恢复 Context,compaction 不计为 assistant step。原有全树 Total 5,080、成本 `$0.00504`、上下文上限 128,000 → 32,000 与原厂回退价 `$0.000149` 均保持通过。
20
+
21
+ 验证产物:
22
+ - OpenCode 2.0.22:`/private/var/folders/rw/bmx6c8hd737brl55m0_ff_b80000gn/T/opencode/token-usage-smoke-93KAN2`。
23
+ - OpenCode 2.0.11:`/private/var/folders/rw/bmx6c8hd737brl55m0_ff_b80000gn/T/opencode/token-usage-smoke-p0SsRF`。
24
+
25
+ 产物包含 `result.json`、终端捕获、已隐藏服务器密码的日志,以及流式事件、生成中的消息和压缩消息诊断文件。2.0.9、2.0.10 的历史记录保留在下方,本轮未重新执行这两个宿主;后续版本仍需实际运行 smoke,不能据版本范围宣称兼容。
26
+
27
+ ## v0.4.3 开发验证(`/usage` 模型费率展示)
28
+
29
+ 验证日期:2026-09-29。Node.js v22.23.2;SDK 2.0.11;真实宿主使用隔离的 `@opencode/cli-darwin-arm64@2.0.11`,包版本仍为 0.4.2。
30
+
31
+ | 检查 | 结果 |
32
+ | --- | --- |
33
+ | `npm run typecheck` | 通过 |
34
+ | `npm test` | 69 项通过 |
35
+ | `npm run build` | 通过 |
36
+ | `npm run test:smoke` | 通过,打包产物 + 真实 OpenCode 2.0.11 + 本地模拟提供商 |
37
+
38
+ `/usage` 的 By Model 详细模式按逐消息估算实际采用的 OpenCode/内置快照价格及上下文档位列出五类 token 费率和调用数;紧凑模式不显示费率。自动化测试覆盖同模型跨来源/档位、Reasoning 沿用 Output、零价、缺价和 `partial`,并核对逐模型与全树费用。真实宿主测试验证 160 × 54 与 100 × 28 终端的展开/收起、48 × 28 终端的单列费率,以及内置快照回退来源;未向付费模型发送请求。测试产物保存在 `/var/folders/rw/bmx6c8hd737brl55m0_ff_b80000gn/T/token-usage-smoke-4RnnLf`。
39
+
40
+ ## v0.4.2(正确性与刷新性能)
41
+
42
+ 验证日期:2026-09-26。Node.js v22.23.2;SDK 2.0.11;真实宿主使用校验 npm SHA-512 完整性的 `@opencode/cli-darwin-arm64@2.0.11` 隔离二进制。
43
+
44
+ | 检查 | 结果 |
45
+ | --- | --- |
46
+ | `npm run typecheck` | 通过 |
47
+ | `npm test` | 67 项通过 |
48
+ | `npm run build` | 通过 |
49
+ | `npm run test:smoke` | 通过,打包产物 + 真实 OpenCode 2.0.11 + 本地模拟提供商;新增删除会话后的 context 清理断言 |
50
+
51
+ TTFT 的快照先到、step 结束先到,以及缺少结束事件三种情况都有保留期清理测试;快照后继续收到 delta 时恢复实时估算,不提前丢失 TTFT。无关会话的创建与分叉不触发当前树刷新;新子代理仍立即纳入,切换会话仍隔离旧请求。流式性能更新使用每次完整快照预计算的历史基线,不再为每个 delta 遍历全部历史。
52
+
53
+ 大会话树测试包含根会话和 80 个子会话:首次完整读取 161 个消息页;单个子会话变化后的完整快照只重读该会话的 1 页,跨会话读取同时最多 4 个。每次仍重新枚举子会话,以发现新增与删除;分页失败或请求超时不会替换上一次完整快照,重试后恢复。被查看会话的 Context 按 `SessionInfo.revert.messageID` 截断有效消息,再应用最后一次已完成 compaction 边界;测试覆盖回退、重新生成和压缩,阶段性回退不会扣除已产生的全树费用。
54
+
55
+ `context` 钩子累计记录 MCP 等待与存储写入耗时;MCP 名称缓存 30 秒,并在连接状态变化时失效。存储写入按会话顺序异步执行,模型请求不等待持久化完成;删除事件会等待该会话未完成的写入后清理 `context/<sessionID>`。自动化测试验证缓存、写入顺序、延迟与删除竞争;打包集成测试确认真实宿主删除会话后 RPC 返回空估算。
56
+
3
57
  ## v0.4.0(子代理用量浮窗)
4
58
 
5
59
  验证日期:2026-09-24。Node.js v22.23.2;SDK 2.0.11,隔离的真实 OpenCode 2.0.11 宿主。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chenlongapps/opencode-token-usage",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "description": "Session-tree token usage, live performance, context window and estimated cost for OpenCode 2",
5
5
  "keywords": [
6
6
  "opencode",
@@ -47,7 +47,7 @@
47
47
  "scripts": {
48
48
  "build": "node scripts/build.mjs",
49
49
  "typecheck": "tsc --noEmit",
50
- "test": "tsx --test test/*.test.ts",
50
+ "test": "tsx --test test/*.test.ts test/*.test.mjs",
51
51
  "test:smoke": "node scripts/smoke.mjs",
52
52
  "prices:update": "tsx scripts/update-prices.ts",
53
53
  "release:patch": "npm version patch",