@chenlongapps/opencode-token-usage 0.2.1
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/LICENSE +21 -0
- package/README.md +133 -0
- package/ROADMAP.md +77 -0
- package/dist/controller.d.ts +44 -0
- package/dist/controller.js +152 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +5 -0
- package/dist/performance.d.ts +33 -0
- package/dist/performance.js +172 -0
- package/dist/source.d.ts +35 -0
- package/dist/source.js +95 -0
- package/dist/tui.d.ts +3 -0
- package/dist/tui.js +146 -0
- package/dist/usage.d.ts +92 -0
- package/dist/usage.js +89 -0
- package/docs/verification.md +114 -0
- package/index.js +2 -0
- package/package.json +81 -0
- package/tui.js +1 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 opencode-token-usage contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# opencode-token-usage
|
|
2
|
+
|
|
3
|
+
面向 **OpenCode 2** 的 token 用量插件,当前版本 **0.2.1**。SDK 依赖固定为 2.0.9,宿主已验证 2.0.9、2.0.10 和 2.0.11。在原生 TUI 侧边栏追加用量面板,统计当前会话所在的整棵会话树,包括未打开的子代理,并显示性能指标与当前查看会话的上下文占用。
|
|
4
|
+
|
|
5
|
+
## 预览
|
|
6
|
+
|
|
7
|
+
```text
|
|
8
|
+
Token Usage
|
|
9
|
+
Context 9,810 / 128,000 (7.7%)
|
|
10
|
+
Input 54,200
|
|
11
|
+
Output 6,800
|
|
12
|
+
Reasoning 4,100
|
|
13
|
+
Cache Read 31,700
|
|
14
|
+
Cache Write 1,300
|
|
15
|
+
Cache Rate 36.4%
|
|
16
|
+
Total 98,100
|
|
17
|
+
Cost $0.21
|
|
18
|
+
|
|
19
|
+
TPS 48.7 tok/s
|
|
20
|
+
TTFT 2.7s
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
示例费用使用每百万 token 的 Input / Output / Cache Read / Cache Write 单价 $2 / $8 / $0.2 / $3。实际价格与上下文上限取自当前查看会话的活动模型。
|
|
24
|
+
|
|
25
|
+
## 安装
|
|
26
|
+
|
|
27
|
+
需要 Node.js 22+ 和 OpenCode **2.0.9、2.0.10 或 2.0.11**。更高版本的 OpenCode 尚未验证;npm SDK 依赖仍锁定为 2.0.9。
|
|
28
|
+
|
|
29
|
+
将 npm 包加入项目的 `opencode.json` 或 `opencode.jsonc`,保留已有插件:
|
|
30
|
+
|
|
31
|
+
```json
|
|
32
|
+
{
|
|
33
|
+
"plugins": ["@chenlongapps/opencode-token-usage"]
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
OpenCode 会下载并加载主插件与 TUI 入口。需要固定版本时可写成 `@chenlongapps/opencode-token-usage@0.2.1`。重启 OpenCode 并进入会话后,面板会追加到原生侧边栏;侧边栏的显示与宽度由宿主管理,终端需要足够宽,并且 CLI 设置 `session.sidebar` 为 `auto`。
|
|
38
|
+
|
|
39
|
+
OpenCode 2.0.9–2.0.11 在子代理视图中强制隐藏侧边栏,因此插件通过 `session.composer.top` 在输入区上方显示同一完整面板。进入子代理仍统计整棵会话树,费用、上下文用量与上限改用该子代理的消息和活动模型。
|
|
40
|
+
|
|
41
|
+
连接远程服务器时,可把相同 npm 包名加入本机 `~/.config/opencode/cli.json` 的 `plugins`,仅加载终端入口。配置路径遵循 `XDG_CONFIG_HOME`,没有项目级 `cli.json`。
|
|
42
|
+
|
|
43
|
+
### 从源码运行
|
|
44
|
+
|
|
45
|
+
在此仓库中执行:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
npm ci
|
|
49
|
+
npm run typecheck
|
|
50
|
+
npm test
|
|
51
|
+
npm run build
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
将仓库的绝对路径加入目标项目的 `opencode.json` 或 `opencode.jsonc`,保留已有插件:
|
|
55
|
+
|
|
56
|
+
```json
|
|
57
|
+
{
|
|
58
|
+
"plugins": ["/absolute/path/opencode-token-usage"]
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
然后在目标项目运行 `opencode`。根目录的 `index.js` 和 `tui.js` 转发到编译产物,适配 2.0.9–2.0.11 的本地目录发现方式。也可先验证独立打包产物:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
npm pack
|
|
66
|
+
npm install --prefix /absolute/path/local-plugins ./chenlongapps-opencode-token-usage-0.2.1.tgz
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
此时配置的插件路径为 `/absolute/path/local-plugins/node_modules/@chenlongapps/opencode-token-usage`。包包含编译后的 ESM 和类型声明,无需在使用时编译 JSX。
|
|
70
|
+
|
|
71
|
+
## 统计口径
|
|
72
|
+
|
|
73
|
+
- 沿当前会话的 `parentID` 找到根,再分页读取所有后代及其完整消息。进入子代理仍显示同一棵树的累计用量。
|
|
74
|
+
- 统计服务端上报的 assistant 和 compaction 消息,包括压缩前历史及失败消息中已记录的用量。不根据文本长度估算 token,不计入没有关联消息的标题生成用量。
|
|
75
|
+
- 按会话和消息 ID 保存快照,每次成功刷新替换旧值,重复事件不会累加两次。分叉会话属于独立会话树;继承历史只归原始来源,其副本不重复计费。分叉副本识别依据 OpenCode 2.0.9 的 ID 生成规则。
|
|
76
|
+
- `Input` 不含缓存读取或写入;`Output` 不含 `Reasoning`。
|
|
77
|
+
- `Total = Input + Output + Reasoning + Cache Read + Cache Write`。
|
|
78
|
+
- `Cache Rate = Cache Read ÷ (Input + Cache Read + Cache Write)`;保留一位小数,分母为零时显示 `0.0%`。
|
|
79
|
+
- `Cache Write` 和 `Cost` 仅在值大于零时显示。空会话保留其他统计行并显示零;首次读取显示 `Loading…` 和 `—`,首次失败显示 `Unavailable`。刷新失败保留上次完整结果,标注 `Not updated` 并自动重试。
|
|
80
|
+
|
|
81
|
+
### 性能指标
|
|
82
|
+
|
|
83
|
+
`TPS` 统计整棵会话树的模型生成速度,token 口径为 Output + Reasoning。已完成消息使用服务端记录的 `time.created → time.streamed` 总时长和真实 token 加权计算,即 `Σ(Output + Reasoning) ÷ Σ流式时长`。有活动生成时优先显示实时估算:合并所有活动流的 UTF-8 增量,按约 4 字节/token 换算并以 `~` 标记;完成刷新后自动替换为精确值。流式增量不会触发完整历史扫描。
|
|
84
|
+
|
|
85
|
+
`TTFT` 是整棵会话树中可测 assistant step 从请求发出到首个模型输出的算术平均值。首输出包括文本、推理和工具输入增量。插件运行期间使用实时事件精确记录;加载历史时仅使用带可靠创建时间的首个 reasoning 或 tool 内容。纯文本历史没有首 token 时间戳,因此不以完成时间伪造样本,也不会拉低平均值。没有可用样本时隐藏对应性能行。
|
|
86
|
+
|
|
87
|
+
### 上下文用量
|
|
88
|
+
|
|
89
|
+
`Context` 是面板的第一行(状态提示之后),只反映**当前查看会话本身**,不随子树累计:取该会话中最后一条带 `tokens` 的 assistant 消息的五类 token 之和,并且只搜索最后一次 `completed` 压缩之后的消息——与 OpenCode 侧边栏显示的口径一致。正在运行或失败的压缩不重置窗口;压缩刚结束而尚无新回复时隐藏该行,不回退到压缩前的数值。正在生成、尚无用量的最后一条消息会被跳过,沿用上一条有上报的消息。
|
|
90
|
+
|
|
91
|
+
上限取当前查看会话活动模型的 `limit.context`,与 `Cost` 使用同一解析规则,因此切换模型后百分比立即更新。模型没有可用上限(缺失、为零、非有限值)或尚无用量的会话隐藏 `Context` 行。行内格式为 `已用 / 上限 (百分比%)`,百分比保留一位小数,用量超过上限时显示真实值。当前版本不显示进度条。
|
|
92
|
+
|
|
93
|
+
### 估算费用
|
|
94
|
+
|
|
95
|
+
`Cost` 是按**当前查看会话的活动模型**重新估算的全树费用,不是提供商账单,也不累加历史消息中保存的 `cost`。它不使用每条消息自身记录的模型:同一会话里即使多条消息由不同模型生成,也统一按查看时活动模型的价格档位推算,因此混合模型会话的金额**不等于**按各消息实际模型计费之和——它回答的是"把这棵树按当前模型重跑一遍会花多少"。未选定模型时使用该会话位置的默认模型,切换模型后重新计算。
|
|
96
|
+
|
|
97
|
+
每条消息分别使用其传入 token 数(Input + Cache Read + Cache Write)选择价格档位:严格超过阈值才进入该档,多个档位适用时取最高阈值,否则用基础档。Output 和 Reasoning 都使用输出单价,最后合计各条消息的费用。
|
|
98
|
+
|
|
99
|
+
没有模型价格或适用档位时按 OpenCode 官方计费实现回退为 **0**。合法的免费模型也按其实际价格计算;价格请求失败会显示未更新状态。这里的“默认价格”并非内置的公开价格目录。
|
|
100
|
+
|
|
101
|
+
数字使用千位逗号分组并显示为四舍五入后的整数;例如 `1200 → 1,200`、`125000 → 125,000`、`1500000 → 1,500,000`。美元保留两位小数,正数不足一美分显示 `<$0.01`。
|
|
102
|
+
|
|
103
|
+
## 开发与验证
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
npm run typecheck
|
|
107
|
+
npm test
|
|
108
|
+
npm run build
|
|
109
|
+
npm run test:smoke
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
`test:smoke` 需要本机 OpenCode 2.0.9、2.0.10 或 2.0.11、Python 3、可用的本地端口及 npm 网络访问。它打包并安装真实产物,在临时目录启动隔离的 OpenCode 服务和真实 TUI,通过带延迟的本地模拟提供商检查加载、实时 TPS 与 TTFT、完成后精确 TPS、用量与上下文刷新、真实子代理累计及模型切换。不会修改现有 OpenCode 配置或使用付费模型。终端捕获和测试记录保留在输出的临时路径。
|
|
113
|
+
|
|
114
|
+
维护者的首次发布、Trusted Publishing 和后续版本流程见 [npm 发布说明](https://github.com/chenlongapps/opencode-token-usage/blob/main/docs/releasing.md)。
|
|
115
|
+
|
|
116
|
+
2026-09-20 已通过类型检查、29 项自动化测试、构建和上述真实集成验证,详见 [验证记录](docs/verification.md)。
|
|
117
|
+
|
|
118
|
+
架构:`usage.ts` 处理统计、上下文与格式化,`performance.ts` 处理 TPS、TTFT 与流式事件,`source.ts` 对接 v2 客户端和分页,`controller.ts` 合并刷新、隔离旧请求与处理失败,`tui.tsx` 渲染侧边栏。流式增量只更新内存中的性能指标;用量、会话和模型事件仍通过合并刷新重新分页扫描会话树,以保证历史完整性。超大历史的增量读取优化留待后续版本。
|
|
119
|
+
|
|
120
|
+
紧凑模式、`/usage` 和公开价格目录尚未实现,详见 [ROADMAP.md](ROADMAP.md)。
|
|
121
|
+
|
|
122
|
+
## 接口依据
|
|
123
|
+
|
|
124
|
+
- [OpenCode 2 CLI 插件接口](https://opencode.ai/v2/docs/build/plugins/cli/)
|
|
125
|
+
- [OpenCode 2 插件加载](https://opencode.ai/v2/docs/build/plugins/)
|
|
126
|
+
- [CLI 设置](https://opencode.ai/v2/docs/cli/config/)
|
|
127
|
+
- [TokenUsage 定义](https://github.com/anomalyco/opencode/blob/v2/packages/schema/src/token-usage.ts)
|
|
128
|
+
- [官方计费实现](https://github.com/anomalyco/opencode/blob/v2/packages/core/src/session/usage.ts)
|
|
129
|
+
- [分叉历史投影](https://github.com/anomalyco/opencode/blob/v2/packages/core/src/session/projector.ts)
|
|
130
|
+
|
|
131
|
+
## 许可证
|
|
132
|
+
|
|
133
|
+
[MIT](LICENSE)
|
package/ROADMAP.md
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# opencode-token-usage 路线图
|
|
2
|
+
|
|
3
|
+
为 **OpenCode 2** 提供轻量级、实时的 token 用量监视器。勾选项表示已实现且通过相应验证;未来版本的示例仅用于说明设计。
|
|
4
|
+
|
|
5
|
+
## v0.1.0 — 核心用量与基础侧边栏
|
|
6
|
+
|
|
7
|
+
目标:提供可本地安装的完整会话树用量面板,不包含 npm 发布。
|
|
8
|
+
|
|
9
|
+
* [x] OpenCode 2.0.9 主插件与 `./tui` 入口集成
|
|
10
|
+
* [x] 向原生 `sidebar.content` 追加基础面板,保留宿主内容
|
|
11
|
+
* [x] 子代理视图使用 `session.composer.top` 显示完整面板,适配宿主隐藏侧边栏的行为
|
|
12
|
+
* [x] 检测当前会话,沿 `parentID` 汇总根会话及所有后代
|
|
13
|
+
* [x] 分页读取完整消息,包括压缩前历史和未打开的子代理
|
|
14
|
+
* [x] 快照替换与继承历史去重,防止重复累计
|
|
15
|
+
* [x] 显示 Input、Output、Reasoning、Cache Read、Cache Write
|
|
16
|
+
* [x] 显示缓存命中率 Cache Rate
|
|
17
|
+
* [x] 显示五类 token 的总量 Total
|
|
18
|
+
* [x] 按当前查看会话的活动模型估算 Cost,支持模型切换
|
|
19
|
+
* [x] 未选模型时使用所在位置的默认模型;缺失适用价格按 0 并标注
|
|
20
|
+
* [x] 按每条消息的传入 token 数选择价格阶梯
|
|
21
|
+
* [x] 数字使用千位逗号分组:`1200 → 1,200`、`125000 → 125,000`、`1500000 → 1,500,000`
|
|
22
|
+
* [x] 空会话保留基础统计行并显示零;零值 Cache Write 与 Cost 隐藏
|
|
23
|
+
* [x] 消息用量上报后合并刷新,切换会话时隔离旧请求
|
|
24
|
+
* [x] 加载、不可用、未更新与失败恢复状态
|
|
25
|
+
* [x] 实际打包产物在 OpenCode 2.0.9 和 2.0.10 中验证加载、显示、刷新与子代理累计
|
|
26
|
+
|
|
27
|
+
预览及安装说明见 [README.md](README.md),验证结果见 [验证记录](docs/verification.md)。
|
|
28
|
+
|
|
29
|
+
## v0.2.0 — 上下文用量
|
|
30
|
+
|
|
31
|
+
目标:让上下文消耗情况清晰可见。
|
|
32
|
+
|
|
33
|
+
* [x] 读取活动模型的上下文窗口大小
|
|
34
|
+
* [x] 显示已用 / 最大上下文
|
|
35
|
+
* [x] 显示上下文用量百分比
|
|
36
|
+
* [x] 处理上下文限制未知的模型
|
|
37
|
+
* [x] 在模型切换后同步更新上下文指标
|
|
38
|
+
|
|
39
|
+
```text
|
|
40
|
+
Context 72,400 / 128,000 (56.6%)
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
上下文行显示在面板第一行;百分比与用量同行,进度条留待后续版本。
|
|
44
|
+
|
|
45
|
+
## v0.2.1 — TPS、TTFT的显示
|
|
46
|
+
|
|
47
|
+
目标:提供更直观的性能指标。
|
|
48
|
+
|
|
49
|
+
* [x] 显示实时 TPS(每秒 token 处理量)
|
|
50
|
+
* [x] 显示平均 TTFT(首 token 处理时间)
|
|
51
|
+
|
|
52
|
+
```text
|
|
53
|
+
TPS 48.7 tok/s
|
|
54
|
+
TTFT 2.7s
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## v0.3.0 — 公开价格目录
|
|
58
|
+
|
|
59
|
+
目标:提供可查询的价格目录,便于用户了解不同模型的计费档位。
|
|
60
|
+
|
|
61
|
+
* [ ] 构建公开价格目录,包含模型、价格档位及阈值
|
|
62
|
+
<!-- TODO -->
|
|
63
|
+
|
|
64
|
+
## v0.4.0 — 用量命令
|
|
65
|
+
|
|
66
|
+
目标:按需提供比侧边栏更详细的 token 信息。
|
|
67
|
+
|
|
68
|
+
* [ ] 添加 `/usage`
|
|
69
|
+
* [ ] 显示当前会话及会话树统计
|
|
70
|
+
* [ ] 显示 token 明细与上下文用量
|
|
71
|
+
* [ ] 显示模型和价格信息
|
|
72
|
+
* [ ] 显示估算费用明细
|
|
73
|
+
|
|
74
|
+
## 后续评估
|
|
75
|
+
|
|
76
|
+
* [ ] 公开价格目录与更新策略
|
|
77
|
+
* [ ] 首次 npm 发布(包元数据、CI、OIDC 发布工作流与本地验证已完成,待账号认证并发布到 registry)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { UsageSource } from "./source.js";
|
|
2
|
+
import type { PerformanceSummary } from "./performance.js";
|
|
3
|
+
import type { ContextUsage, Summary } from "./usage.js";
|
|
4
|
+
export interface UsageState {
|
|
5
|
+
status: "loading" | "ready" | "stale" | "unavailable";
|
|
6
|
+
summary?: Summary;
|
|
7
|
+
context?: ContextUsage | undefined;
|
|
8
|
+
performance?: PerformanceSummary;
|
|
9
|
+
model?: string;
|
|
10
|
+
}
|
|
11
|
+
export interface UsageEvent {
|
|
12
|
+
type: string;
|
|
13
|
+
data: unknown;
|
|
14
|
+
id?: string;
|
|
15
|
+
created?: number;
|
|
16
|
+
}
|
|
17
|
+
export type Subscribe = (listener: (event: UsageEvent) => void) => () => void;
|
|
18
|
+
/** Owns snapshot refreshes and throttled in-memory performance updates for one mounted panel. */
|
|
19
|
+
export declare class UsageController {
|
|
20
|
+
private readonly source;
|
|
21
|
+
private readonly publish;
|
|
22
|
+
private readonly delay;
|
|
23
|
+
private readonly retryDelay;
|
|
24
|
+
private readonly performanceDelay;
|
|
25
|
+
private state;
|
|
26
|
+
private snapshot;
|
|
27
|
+
private sessionID;
|
|
28
|
+
private generation;
|
|
29
|
+
private request;
|
|
30
|
+
private timer;
|
|
31
|
+
private retry;
|
|
32
|
+
private performanceTimer;
|
|
33
|
+
private pending;
|
|
34
|
+
private disposed;
|
|
35
|
+
private unsubscribe;
|
|
36
|
+
private readonly performance;
|
|
37
|
+
constructor(source: UsageSource, subscribe: Subscribe, publish: (state: UsageState) => void, delay?: number, retryDelay?: number, performanceDelay?: number);
|
|
38
|
+
select(sessionID: string): void;
|
|
39
|
+
refresh(): void;
|
|
40
|
+
dispose(): void;
|
|
41
|
+
private update;
|
|
42
|
+
private refreshPerformance;
|
|
43
|
+
private run;
|
|
44
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { loadSnapshot, uniqueMessages, viewedMessages } from "./source.js";
|
|
2
|
+
import { PerformanceTracker } from "./performance.js";
|
|
3
|
+
import { contextUsage, summarize } from "./usage.js";
|
|
4
|
+
const performanceEvents = new Set([
|
|
5
|
+
"session.step.started", "session.step.streamed", "session.step.ended", "session.step.failed",
|
|
6
|
+
"session.text.delta", "session.reasoning.delta", "session.tool.input.delta",
|
|
7
|
+
]);
|
|
8
|
+
const sessionEvents = new Set([
|
|
9
|
+
"session.created", "session.deleted", "session.forked", "session.moved",
|
|
10
|
+
"session.model.selected", "session.agent.selected", "session.step.ended", "session.step.failed",
|
|
11
|
+
"session.compaction.ended", "session.compaction.failed", "session.message.content.updated",
|
|
12
|
+
"session.usage.recorded", "session.usage.updated", "session.revert.committed",
|
|
13
|
+
"session.execution.succeeded", "session.execution.failed", "session.execution.interrupted",
|
|
14
|
+
]);
|
|
15
|
+
const globalEvents = new Set([
|
|
16
|
+
"server.connected", "model.updated", "provider.updated", "models-dev.refreshed",
|
|
17
|
+
"config.updated", "location.shutdown",
|
|
18
|
+
]);
|
|
19
|
+
/** Owns snapshot refreshes and throttled in-memory performance updates for one mounted panel. */
|
|
20
|
+
export class UsageController {
|
|
21
|
+
source;
|
|
22
|
+
publish;
|
|
23
|
+
delay;
|
|
24
|
+
retryDelay;
|
|
25
|
+
performanceDelay;
|
|
26
|
+
state = { status: "loading" };
|
|
27
|
+
snapshot;
|
|
28
|
+
sessionID;
|
|
29
|
+
generation = 0;
|
|
30
|
+
request;
|
|
31
|
+
timer;
|
|
32
|
+
retry;
|
|
33
|
+
performanceTimer;
|
|
34
|
+
pending = false;
|
|
35
|
+
disposed = false;
|
|
36
|
+
unsubscribe;
|
|
37
|
+
performance = new PerformanceTracker();
|
|
38
|
+
constructor(source, subscribe, publish, delay = 80, retryDelay = 3_000, performanceDelay = 100) {
|
|
39
|
+
this.source = source;
|
|
40
|
+
this.publish = publish;
|
|
41
|
+
this.delay = delay;
|
|
42
|
+
this.retryDelay = retryDelay;
|
|
43
|
+
this.performanceDelay = performanceDelay;
|
|
44
|
+
this.unsubscribe = subscribe(event => {
|
|
45
|
+
const data = (event.data && typeof event.data === "object" ? event.data : {});
|
|
46
|
+
if (event.type === "session.created")
|
|
47
|
+
this.performance.addSession(data.sessionID, data.parentID);
|
|
48
|
+
if (performanceEvents.has(event.type) && this.performance.handle(event))
|
|
49
|
+
this.refreshPerformance();
|
|
50
|
+
if (globalEvents.has(event.type))
|
|
51
|
+
return this.refresh();
|
|
52
|
+
if (!sessionEvents.has(event.type))
|
|
53
|
+
return;
|
|
54
|
+
if (!this.snapshot || event.type === "session.created" || event.type === "session.forked"
|
|
55
|
+
|| data.sessionID === this.sessionID || (data.sessionID && this.snapshot.sessions.has(data.sessionID)))
|
|
56
|
+
this.refresh();
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
select(sessionID) {
|
|
60
|
+
if (this.disposed || sessionID === this.sessionID)
|
|
61
|
+
return;
|
|
62
|
+
this.generation++;
|
|
63
|
+
this.request?.abort();
|
|
64
|
+
this.request = undefined;
|
|
65
|
+
clearTimeout(this.timer);
|
|
66
|
+
clearTimeout(this.retry);
|
|
67
|
+
clearTimeout(this.performanceTimer);
|
|
68
|
+
this.timer = undefined;
|
|
69
|
+
this.performanceTimer = undefined;
|
|
70
|
+
this.pending = false;
|
|
71
|
+
this.snapshot = undefined;
|
|
72
|
+
this.sessionID = sessionID;
|
|
73
|
+
this.update({ status: "loading" });
|
|
74
|
+
void this.run();
|
|
75
|
+
}
|
|
76
|
+
refresh() {
|
|
77
|
+
if (this.disposed || !this.sessionID)
|
|
78
|
+
return;
|
|
79
|
+
clearTimeout(this.retry);
|
|
80
|
+
if (this.request) {
|
|
81
|
+
this.pending = true;
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
// Fixed window, rather than resetting on every event: streams cannot starve refreshes.
|
|
85
|
+
if (this.timer)
|
|
86
|
+
return;
|
|
87
|
+
this.timer = setTimeout(() => { this.timer = undefined; void this.run(); }, this.delay);
|
|
88
|
+
}
|
|
89
|
+
dispose() {
|
|
90
|
+
if (this.disposed)
|
|
91
|
+
return;
|
|
92
|
+
this.disposed = true;
|
|
93
|
+
this.generation++;
|
|
94
|
+
this.request?.abort();
|
|
95
|
+
clearTimeout(this.timer);
|
|
96
|
+
clearTimeout(this.retry);
|
|
97
|
+
clearTimeout(this.performanceTimer);
|
|
98
|
+
this.unsubscribe();
|
|
99
|
+
}
|
|
100
|
+
update(state) { this.state = state; this.publish(state); }
|
|
101
|
+
refreshPerformance() {
|
|
102
|
+
if (this.disposed || !this.snapshot || this.performanceTimer)
|
|
103
|
+
return;
|
|
104
|
+
this.performanceTimer = setTimeout(() => {
|
|
105
|
+
this.performanceTimer = undefined;
|
|
106
|
+
if (this.disposed || !this.snapshot || !this.state.summary)
|
|
107
|
+
return;
|
|
108
|
+
this.update({ ...this.state, performance: this.performance.summary(uniqueMessages(this.snapshot)) });
|
|
109
|
+
}, this.performanceDelay);
|
|
110
|
+
}
|
|
111
|
+
async run() {
|
|
112
|
+
if (this.disposed || !this.sessionID)
|
|
113
|
+
return;
|
|
114
|
+
const generation = this.generation;
|
|
115
|
+
const request = new AbortController();
|
|
116
|
+
this.request = request;
|
|
117
|
+
try {
|
|
118
|
+
const signal = AbortSignal.any([request.signal, AbortSignal.timeout(30_000)]);
|
|
119
|
+
const snapshot = await loadSnapshot(this.source, this.sessionID, signal);
|
|
120
|
+
if (this.disposed || generation !== this.generation)
|
|
121
|
+
return;
|
|
122
|
+
this.snapshot = snapshot;
|
|
123
|
+
const messages = [...uniqueMessages(snapshot)];
|
|
124
|
+
this.performance.setTree(snapshot.rootID, snapshot.sessions.keys());
|
|
125
|
+
this.performance.reconcile(messages);
|
|
126
|
+
clearTimeout(this.performanceTimer);
|
|
127
|
+
this.performanceTimer = undefined;
|
|
128
|
+
this.update({
|
|
129
|
+
status: "ready",
|
|
130
|
+
summary: summarize(messages, snapshot.model.prices),
|
|
131
|
+
context: contextUsage(viewedMessages(snapshot), snapshot.model.context),
|
|
132
|
+
performance: this.performance.summary(messages),
|
|
133
|
+
model: snapshot.model.label,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
if (this.disposed || generation !== this.generation)
|
|
138
|
+
return;
|
|
139
|
+
this.update({ ...this.state, status: this.state.summary ? "stale" : "unavailable" });
|
|
140
|
+
this.retry = setTimeout(() => this.refresh(), this.retryDelay);
|
|
141
|
+
}
|
|
142
|
+
finally {
|
|
143
|
+
if (generation === this.generation && !this.disposed) {
|
|
144
|
+
this.request = undefined;
|
|
145
|
+
if (this.pending) {
|
|
146
|
+
this.pending = false;
|
|
147
|
+
this.refresh();
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { UsageMessage } from "./usage.js";
|
|
2
|
+
export interface PerformanceSummary {
|
|
3
|
+
tps?: number;
|
|
4
|
+
tpsEstimated?: boolean;
|
|
5
|
+
/** Average time to first token, in milliseconds. */
|
|
6
|
+
ttft?: number;
|
|
7
|
+
}
|
|
8
|
+
export interface FirstTokenObservation {
|
|
9
|
+
messageID: string;
|
|
10
|
+
sessionID: string;
|
|
11
|
+
started: number;
|
|
12
|
+
first: number;
|
|
13
|
+
}
|
|
14
|
+
export interface PerformanceEvent {
|
|
15
|
+
type: string;
|
|
16
|
+
data: unknown;
|
|
17
|
+
id?: string;
|
|
18
|
+
created?: number;
|
|
19
|
+
}
|
|
20
|
+
/** Exact completed-run TPS and the average of only measurable TTFT samples. */
|
|
21
|
+
export declare function historicalPerformance(messages: Iterable<UsageMessage>, observations?: Iterable<FirstTokenObservation>): PerformanceSummary;
|
|
22
|
+
/** Tracks ephemeral streaming events without fetching the full session tree. */
|
|
23
|
+
export declare class PerformanceTracker {
|
|
24
|
+
private rootID;
|
|
25
|
+
private sessions;
|
|
26
|
+
private active;
|
|
27
|
+
private observed;
|
|
28
|
+
setTree(rootID: string, sessionIDs: Iterable<string>): void;
|
|
29
|
+
addSession(sessionID: string | undefined, parentID: string | undefined): void;
|
|
30
|
+
handle(event: PerformanceEvent): boolean;
|
|
31
|
+
reconcile(messages: Iterable<UsageMessage>): void;
|
|
32
|
+
summary(messages: Iterable<UsageMessage>): PerformanceSummary;
|
|
33
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { normalize } from "./usage.js";
|
|
2
|
+
const finite = (value) => typeof value === "number" && Number.isFinite(value);
|
|
3
|
+
function historicalFirst(message) {
|
|
4
|
+
const started = message.time?.created;
|
|
5
|
+
if (!finite(started))
|
|
6
|
+
return undefined;
|
|
7
|
+
for (const part of message.content ?? []) {
|
|
8
|
+
if (part.type === "text") {
|
|
9
|
+
if (part.text)
|
|
10
|
+
return undefined;
|
|
11
|
+
continue;
|
|
12
|
+
}
|
|
13
|
+
if (part.type === "reasoning") {
|
|
14
|
+
if (!part.text)
|
|
15
|
+
continue;
|
|
16
|
+
const first = part.time?.created;
|
|
17
|
+
return finite(first) && first >= started ? first - started : undefined;
|
|
18
|
+
}
|
|
19
|
+
if (part.type === "tool") {
|
|
20
|
+
const first = part.time?.created;
|
|
21
|
+
return finite(first) && first >= started ? first - started : undefined;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
/** Exact completed-run TPS and the average of only measurable TTFT samples. */
|
|
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
|
+
}
|
|
33
|
+
let output = 0;
|
|
34
|
+
let duration = 0;
|
|
35
|
+
let ttft = 0;
|
|
36
|
+
let ttftSamples = 0;
|
|
37
|
+
const messagesSeen = new Set();
|
|
38
|
+
for (const message of messages) {
|
|
39
|
+
if (message.type !== "assistant")
|
|
40
|
+
continue;
|
|
41
|
+
messagesSeen.add(message.id);
|
|
42
|
+
const created = message.time?.created;
|
|
43
|
+
const streamed = message.time?.streamed;
|
|
44
|
+
const tokens = normalize(message.tokens);
|
|
45
|
+
const generated = tokens.output + tokens.reasoning;
|
|
46
|
+
if (finite(created) && finite(streamed) && streamed > created && generated > 0) {
|
|
47
|
+
output += generated;
|
|
48
|
+
duration += streamed - created;
|
|
49
|
+
}
|
|
50
|
+
const runtime = observed.get(message.id);
|
|
51
|
+
const sample = runtime ? runtime.first - runtime.started : historicalFirst(message);
|
|
52
|
+
if (sample !== undefined && finite(sample) && sample >= 0) {
|
|
53
|
+
ttft += sample;
|
|
54
|
+
ttftSamples++;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
// A live first-token event can arrive before the refreshed message snapshot.
|
|
58
|
+
for (const sample of observed.values()) {
|
|
59
|
+
if (messagesSeen.has(sample.messageID))
|
|
60
|
+
continue;
|
|
61
|
+
ttft += sample.first - sample.started;
|
|
62
|
+
ttftSamples++;
|
|
63
|
+
}
|
|
64
|
+
const result = {};
|
|
65
|
+
if (duration > 0)
|
|
66
|
+
result.tps = output / (duration / 1_000);
|
|
67
|
+
if (ttftSamples > 0)
|
|
68
|
+
result.ttft = ttft / ttftSamples;
|
|
69
|
+
return result;
|
|
70
|
+
}
|
|
71
|
+
/** Tracks ephemeral streaming events without fetching the full session tree. */
|
|
72
|
+
export class PerformanceTracker {
|
|
73
|
+
rootID;
|
|
74
|
+
sessions = new Set();
|
|
75
|
+
active = new Map();
|
|
76
|
+
observed = new Map();
|
|
77
|
+
setTree(rootID, sessionIDs) {
|
|
78
|
+
if (this.rootID !== rootID) {
|
|
79
|
+
this.active.clear();
|
|
80
|
+
this.observed.clear();
|
|
81
|
+
this.rootID = rootID;
|
|
82
|
+
}
|
|
83
|
+
this.sessions = new Set(sessionIDs);
|
|
84
|
+
for (const [id, step] of this.active)
|
|
85
|
+
if (!this.sessions.has(step.sessionID))
|
|
86
|
+
this.active.delete(id);
|
|
87
|
+
for (const [id, sample] of this.observed)
|
|
88
|
+
if (!this.sessions.has(sample.sessionID))
|
|
89
|
+
this.observed.delete(id);
|
|
90
|
+
}
|
|
91
|
+
addSession(sessionID, parentID) {
|
|
92
|
+
if (sessionID && parentID && this.sessions.has(parentID))
|
|
93
|
+
this.sessions.add(sessionID);
|
|
94
|
+
}
|
|
95
|
+
handle(event) {
|
|
96
|
+
const data = (event.data && typeof event.data === "object" ? event.data : {});
|
|
97
|
+
const sessionID = typeof data.sessionID === "string" ? data.sessionID : undefined;
|
|
98
|
+
const messageID = typeof data.assistantMessageID === "string" ? data.assistantMessageID : undefined;
|
|
99
|
+
if (!sessionID || !messageID || !this.sessions.has(sessionID))
|
|
100
|
+
return false;
|
|
101
|
+
if (event.type === "session.step.started") {
|
|
102
|
+
if (!finite(data.started))
|
|
103
|
+
return false;
|
|
104
|
+
if (this.active.has(messageID))
|
|
105
|
+
return false;
|
|
106
|
+
this.active.set(messageID, {
|
|
107
|
+
messageID, sessionID, started: data.started, bytes: 0, events: new Set(),
|
|
108
|
+
});
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
const step = this.active.get(messageID);
|
|
112
|
+
if (!step)
|
|
113
|
+
return false;
|
|
114
|
+
if (event.type === "session.step.streamed") {
|
|
115
|
+
if (!finite(event.created) || event.created < step.started)
|
|
116
|
+
return false;
|
|
117
|
+
step.streamed = event.created;
|
|
118
|
+
return step.bytes > 0;
|
|
119
|
+
}
|
|
120
|
+
if (event.type === "session.step.ended" || event.type === "session.step.failed") {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
if (event.type !== "session.text.delta" && event.type !== "session.reasoning.delta"
|
|
124
|
+
&& event.type !== "session.tool.input.delta")
|
|
125
|
+
return false;
|
|
126
|
+
if (typeof data.delta !== "string" || data.delta.length === 0 || !finite(event.created) || event.created < step.started)
|
|
127
|
+
return false;
|
|
128
|
+
if (event.id && step.events.has(event.id))
|
|
129
|
+
return false;
|
|
130
|
+
if (event.id)
|
|
131
|
+
step.events.add(event.id);
|
|
132
|
+
step.bytes += new TextEncoder().encode(data.delta).byteLength;
|
|
133
|
+
step.last = Math.max(step.last ?? event.created, event.created);
|
|
134
|
+
if (step.first === undefined) {
|
|
135
|
+
step.first = event.created;
|
|
136
|
+
this.observed.set(messageID, { messageID, sessionID, started: step.started, first: event.created });
|
|
137
|
+
}
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
reconcile(messages) {
|
|
141
|
+
const represented = new Set();
|
|
142
|
+
for (const message of messages) {
|
|
143
|
+
if (message.type === "assistant" && (message.tokens || message.time?.streamed !== undefined
|
|
144
|
+
|| message.time?.completed !== undefined))
|
|
145
|
+
represented.add(message.id);
|
|
146
|
+
}
|
|
147
|
+
for (const [id, step] of this.active) {
|
|
148
|
+
if (represented.has(id))
|
|
149
|
+
this.active.delete(id);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
summary(messages) {
|
|
153
|
+
const samples = [...this.observed.values()].filter(sample => this.sessions.has(sample.sessionID));
|
|
154
|
+
const result = historicalPerformance(messages, samples);
|
|
155
|
+
let bytes = 0;
|
|
156
|
+
let duration = 0;
|
|
157
|
+
for (const step of this.active.values()) {
|
|
158
|
+
if (!this.sessions.has(step.sessionID) || step.bytes <= 0)
|
|
159
|
+
continue;
|
|
160
|
+
const last = step.streamed ?? step.last;
|
|
161
|
+
if (!finite(last) || last <= step.started)
|
|
162
|
+
continue;
|
|
163
|
+
bytes += step.bytes;
|
|
164
|
+
duration += last - step.started;
|
|
165
|
+
}
|
|
166
|
+
if (bytes > 0 && duration > 0) {
|
|
167
|
+
result.tps = (bytes / 4) / (duration / 1_000);
|
|
168
|
+
result.tpsEstimated = true;
|
|
169
|
+
}
|
|
170
|
+
return result;
|
|
171
|
+
}
|
|
172
|
+
}
|
package/dist/source.d.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { OpenCodeClient, SessionInfo } from "@opencode/client";
|
|
2
|
+
import type { Price, UsageMessage } from "./usage.js";
|
|
3
|
+
export type Session = Pick<SessionInfo, "id" | "parentID" | "fork" | "model" | "location">;
|
|
4
|
+
export interface Page<T> {
|
|
5
|
+
data: T[];
|
|
6
|
+
cursor: {
|
|
7
|
+
next?: string | null;
|
|
8
|
+
previous?: string | null;
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
/** Active model of the viewed session: price tiers, display label and context window. */
|
|
12
|
+
export interface ActiveModel {
|
|
13
|
+
label: string;
|
|
14
|
+
prices: readonly Price[];
|
|
15
|
+
context?: number | undefined;
|
|
16
|
+
}
|
|
17
|
+
export interface UsageSource {
|
|
18
|
+
session(id: string, signal: AbortSignal): Promise<Session>;
|
|
19
|
+
children(id: string, cursor: string | undefined, signal: AbortSignal): Promise<Page<Session>>;
|
|
20
|
+
messages(id: string, cursor: string | undefined, signal: AbortSignal): Promise<Page<UsageMessage>>;
|
|
21
|
+
model(session: Session, signal: AbortSignal): Promise<ActiveModel>;
|
|
22
|
+
}
|
|
23
|
+
export declare function createSource(client: OpenCodeClient): UsageSource;
|
|
24
|
+
export declare function pages<T>(read: (cursor?: string) => Promise<Page<T>>, signal: AbortSignal): Promise<T[]>;
|
|
25
|
+
export interface Snapshot {
|
|
26
|
+
rootID: string;
|
|
27
|
+
viewedID: string;
|
|
28
|
+
sessions: Map<string, Session>;
|
|
29
|
+
messages: Map<string, Map<string, UsageMessage>>;
|
|
30
|
+
model: ActiveModel;
|
|
31
|
+
}
|
|
32
|
+
export declare function loadSnapshot(source: UsageSource, sessionID: string, signal: AbortSignal): Promise<Snapshot>;
|
|
33
|
+
/** History of the viewed session only, in API order (`order: "asc"`). */
|
|
34
|
+
export declare function viewedMessages(snapshot: Snapshot): readonly UsageMessage[];
|
|
35
|
+
export declare function uniqueMessages(snapshot: Snapshot): Iterable<UsageMessage>;
|
package/dist/source.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
export function createSource(client) {
|
|
2
|
+
return {
|
|
3
|
+
session: (sessionID, signal) => client.session.get({ sessionID }, { signal }),
|
|
4
|
+
children: (parentID, cursor, signal) => client.session.list(cursor ? { cursor } : { parentID, order: "asc", limit: 100 }, { signal }),
|
|
5
|
+
// Unlike session.context(), message.list() includes pre-compaction history.
|
|
6
|
+
messages: (sessionID, cursor, signal) => client.message.list(cursor ? { sessionID, cursor } : { sessionID, order: "asc", limit: 100 }, { signal }),
|
|
7
|
+
async model(session, signal) {
|
|
8
|
+
const input = { location: session.location };
|
|
9
|
+
const selected = session.model;
|
|
10
|
+
const model = selected
|
|
11
|
+
? (await client.model.list(input, { signal })).data.find(m => m.providerID === selected.providerID && m.id === selected.id)
|
|
12
|
+
: (await client.model.default(input, { signal })).data;
|
|
13
|
+
return {
|
|
14
|
+
label: model ? `${model.providerID}/${model.id}` : selected ? `${selected.providerID}/${selected.id}` : "Default model",
|
|
15
|
+
prices: model?.cost ?? [],
|
|
16
|
+
context: model?.limit?.context,
|
|
17
|
+
};
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
export async function pages(read, signal) {
|
|
22
|
+
const result = [];
|
|
23
|
+
const seen = new Set();
|
|
24
|
+
let cursor;
|
|
25
|
+
do {
|
|
26
|
+
signal.throwIfAborted();
|
|
27
|
+
const page = await read(cursor);
|
|
28
|
+
signal.throwIfAborted();
|
|
29
|
+
result.push(...page.data);
|
|
30
|
+
cursor = page.cursor.next ?? undefined;
|
|
31
|
+
if (cursor && seen.has(cursor))
|
|
32
|
+
throw new Error("Repeated pagination cursor");
|
|
33
|
+
if (cursor)
|
|
34
|
+
seen.add(cursor);
|
|
35
|
+
} while (cursor);
|
|
36
|
+
return result;
|
|
37
|
+
}
|
|
38
|
+
export async function loadSnapshot(source, sessionID, signal) {
|
|
39
|
+
const viewed = await source.session(sessionID, signal);
|
|
40
|
+
let root = viewed;
|
|
41
|
+
const ancestors = new Set();
|
|
42
|
+
while (root.parentID) {
|
|
43
|
+
if (ancestors.has(root.id))
|
|
44
|
+
throw new Error("Cyclic session ancestry");
|
|
45
|
+
ancestors.add(root.id);
|
|
46
|
+
signal.throwIfAborted();
|
|
47
|
+
root = await source.session(root.parentID, signal);
|
|
48
|
+
}
|
|
49
|
+
const sessions = new Map([[root.id, root]]);
|
|
50
|
+
const messages = new Map();
|
|
51
|
+
// Breadth-first discovery includes unopened descendants, with bounded parallelism.
|
|
52
|
+
const queue = [root];
|
|
53
|
+
for (let index = 0; index < queue.length; index++) {
|
|
54
|
+
const session = queue[index];
|
|
55
|
+
const [children, history] = await Promise.all([
|
|
56
|
+
pages(cursor => source.children(session.id, cursor, signal), signal),
|
|
57
|
+
pages(cursor => source.messages(session.id, cursor, signal), signal),
|
|
58
|
+
]);
|
|
59
|
+
messages.set(session.id, new Map(history.map(message => [message.id, message])));
|
|
60
|
+
for (const child of children) {
|
|
61
|
+
if (child.parentID !== session.id)
|
|
62
|
+
throw new Error("Unexpected child session");
|
|
63
|
+
if (!sessions.has(child.id)) {
|
|
64
|
+
sessions.set(child.id, child);
|
|
65
|
+
queue.push(child);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (!sessions.has(viewed.id))
|
|
70
|
+
throw new Error("Session tree changed during refresh");
|
|
71
|
+
const model = await source.model(viewed, signal);
|
|
72
|
+
signal.throwIfAborted();
|
|
73
|
+
return { rootID: root.id, viewedID: viewed.id, sessions, messages, model };
|
|
74
|
+
}
|
|
75
|
+
/** History of the viewed session only, in API order (`order: "asc"`). */
|
|
76
|
+
export function viewedMessages(snapshot) {
|
|
77
|
+
return [...(snapshot.messages.get(snapshot.viewedID)?.values() ?? [])];
|
|
78
|
+
}
|
|
79
|
+
export function* uniqueMessages(snapshot) {
|
|
80
|
+
const seen = new Set();
|
|
81
|
+
for (const [sessionID, messages] of snapshot.messages) {
|
|
82
|
+
const session = snapshot.sessions.get(sessionID);
|
|
83
|
+
for (const message of messages.values()) {
|
|
84
|
+
// 2.0.9 projectFork copies history with IDs `${forkMessageID}_${sourceSeq}`.
|
|
85
|
+
// Those calls belong to the source session, even though the copy has a new ID.
|
|
86
|
+
// A fork is a separate root (fork.sessionID is not parentID).
|
|
87
|
+
if (session.fork && /_\d+$/.test(message.id))
|
|
88
|
+
continue;
|
|
89
|
+
if (seen.has(message.id))
|
|
90
|
+
continue;
|
|
91
|
+
seen.add(message.id);
|
|
92
|
+
yield message;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
package/dist/tui.d.ts
ADDED
package/dist/tui.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { createComponent as _$createComponent } from "@opentui/solid";
|
|
2
|
+
import { effect as _$effect } from "@opentui/solid";
|
|
3
|
+
import { insert as _$insert } from "@opentui/solid";
|
|
4
|
+
import { createTextNode as _$createTextNode } from "@opentui/solid";
|
|
5
|
+
import { insertNode as _$insertNode } from "@opentui/solid";
|
|
6
|
+
import { setProp as _$setProp } from "@opentui/solid";
|
|
7
|
+
import { createElement as _$createElement } from "@opentui/solid";
|
|
8
|
+
import { Plugin } from "@opencode/plugin/tui";
|
|
9
|
+
import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js";
|
|
10
|
+
import { UsageController } from "./controller.js";
|
|
11
|
+
import { createSource } from "./source.js";
|
|
12
|
+
import { usageRows } from "./usage.js";
|
|
13
|
+
function UsagePanel(props) {
|
|
14
|
+
const [state, setState] = createSignal({
|
|
15
|
+
status: "loading"
|
|
16
|
+
});
|
|
17
|
+
const controller = new UsageController(createSource(props.context.client), listener => props.context.data.listen(({
|
|
18
|
+
details
|
|
19
|
+
}) => listener(details)), setState);
|
|
20
|
+
const unregister = props.register(controller);
|
|
21
|
+
createEffect(() => controller.select(props.sessionID));
|
|
22
|
+
onCleanup(() => {
|
|
23
|
+
controller.dispose();
|
|
24
|
+
unregister();
|
|
25
|
+
});
|
|
26
|
+
const rows = createMemo(() => usageRows(state().summary, state().context, state().performance));
|
|
27
|
+
const performanceStart = createMemo(() => rows().findIndex(([label]) => label === "TPS" || label === "TTFT"));
|
|
28
|
+
const status = () => ({
|
|
29
|
+
loading: "Loading…",
|
|
30
|
+
ready: "",
|
|
31
|
+
stale: "Not updated · retrying…",
|
|
32
|
+
unavailable: "Unavailable · retrying…"
|
|
33
|
+
})[state().status];
|
|
34
|
+
return (() => {
|
|
35
|
+
var _el$ = _$createElement("box"),
|
|
36
|
+
_el$2 = _$createElement("text"),
|
|
37
|
+
_el$3 = _$createElement("b");
|
|
38
|
+
_$insertNode(_el$, _el$2);
|
|
39
|
+
_$setProp(_el$, "flexDirection", "column");
|
|
40
|
+
_$setProp(_el$, "marginTop", 1);
|
|
41
|
+
_$setProp(_el$, "flexShrink", 0);
|
|
42
|
+
_$insertNode(_el$2, _el$3);
|
|
43
|
+
_$insertNode(_el$3, _$createTextNode(`Token Usage`));
|
|
44
|
+
_$insert(_el$, _$createComponent(Show, {
|
|
45
|
+
get when() {
|
|
46
|
+
return state().status !== "ready";
|
|
47
|
+
},
|
|
48
|
+
get children() {
|
|
49
|
+
var _el$5 = _$createElement("text");
|
|
50
|
+
_$insert(_el$5, status);
|
|
51
|
+
_$effect(_$p => _$setProp(_el$5, "fg", props.context.theme.text.muted, _$p));
|
|
52
|
+
return _el$5;
|
|
53
|
+
}
|
|
54
|
+
}), null);
|
|
55
|
+
_$insert(_el$, _$createComponent(For, {
|
|
56
|
+
get each() {
|
|
57
|
+
return rows();
|
|
58
|
+
},
|
|
59
|
+
children: (row, index) => (() => {
|
|
60
|
+
var _el$6 = _$createElement("box"),
|
|
61
|
+
_el$7 = _$createElement("text"),
|
|
62
|
+
_el$8 = _$createElement("text");
|
|
63
|
+
_$insertNode(_el$6, _el$7);
|
|
64
|
+
_$insertNode(_el$6, _el$8);
|
|
65
|
+
_$setProp(_el$6, "flexDirection", "row");
|
|
66
|
+
_$setProp(_el$6, "justifyContent", "space-between");
|
|
67
|
+
_$insert(_el$7, () => row[0]);
|
|
68
|
+
_$insert(_el$8, () => row[1]);
|
|
69
|
+
_$effect(_p$ => {
|
|
70
|
+
var _v$ = index() === performanceStart() ? 1 : 0,
|
|
71
|
+
_v$2 = props.context.theme.text.muted,
|
|
72
|
+
_v$3 = props.context.theme.text.muted;
|
|
73
|
+
_v$ !== _p$.e && (_p$.e = _$setProp(_el$6, "marginTop", _v$, _p$.e));
|
|
74
|
+
_v$2 !== _p$.t && (_p$.t = _$setProp(_el$7, "fg", _v$2, _p$.t));
|
|
75
|
+
_v$3 !== _p$.a && (_p$.a = _$setProp(_el$8, "fg", _v$3, _p$.a));
|
|
76
|
+
return _p$;
|
|
77
|
+
}, {
|
|
78
|
+
e: undefined,
|
|
79
|
+
t: undefined,
|
|
80
|
+
a: undefined
|
|
81
|
+
});
|
|
82
|
+
return _el$6;
|
|
83
|
+
})()
|
|
84
|
+
}), null);
|
|
85
|
+
_$effect(_$p => _$setProp(_el$2, "fg", props.context.theme.text.base, _$p));
|
|
86
|
+
return _el$;
|
|
87
|
+
})();
|
|
88
|
+
}
|
|
89
|
+
export default Plugin.define({
|
|
90
|
+
id: "opencode-token-usage.tui",
|
|
91
|
+
setup(context) {
|
|
92
|
+
const controllers = new Set();
|
|
93
|
+
const register = controller => {
|
|
94
|
+
controllers.add(controller);
|
|
95
|
+
return () => {
|
|
96
|
+
controllers.delete(controller);
|
|
97
|
+
};
|
|
98
|
+
};
|
|
99
|
+
const remove = context.ui.slot({
|
|
100
|
+
append: "sidebar.content",
|
|
101
|
+
render: props => _$createComponent(UsagePanel, {
|
|
102
|
+
context: context,
|
|
103
|
+
get sessionID() {
|
|
104
|
+
return props.sessionID;
|
|
105
|
+
},
|
|
106
|
+
register: register
|
|
107
|
+
})
|
|
108
|
+
});
|
|
109
|
+
// 2.0.9 SessionFrame never mounts the sidebar for a child session.
|
|
110
|
+
// Keep the same full panel visible through the supported composer slot.
|
|
111
|
+
const removeChild = context.ui.slot({
|
|
112
|
+
append: "session.composer.top",
|
|
113
|
+
render: props => _$createComponent(Show, {
|
|
114
|
+
get when() {
|
|
115
|
+
return context.data.session.get(props.sessionID)?.parentID;
|
|
116
|
+
},
|
|
117
|
+
get children() {
|
|
118
|
+
var _el$9 = _$createElement("box"),
|
|
119
|
+
_el$0 = _$createElement("box");
|
|
120
|
+
_$insertNode(_el$9, _el$0);
|
|
121
|
+
_$setProp(_el$9, "flexDirection", "row");
|
|
122
|
+
_$setProp(_el$9, "justifyContent", "flex-end");
|
|
123
|
+
_$setProp(_el$9, "paddingRight", 2);
|
|
124
|
+
_$setProp(_el$9, "flexShrink", 0);
|
|
125
|
+
_$setProp(_el$0, "width", 36);
|
|
126
|
+
_$setProp(_el$0, "maxWidth", "100%");
|
|
127
|
+
_$setProp(_el$0, "flexDirection", "column");
|
|
128
|
+
_$insert(_el$0, _$createComponent(UsagePanel, {
|
|
129
|
+
context: context,
|
|
130
|
+
get sessionID() {
|
|
131
|
+
return props.sessionID;
|
|
132
|
+
},
|
|
133
|
+
register: register
|
|
134
|
+
}));
|
|
135
|
+
return _el$9;
|
|
136
|
+
}
|
|
137
|
+
})
|
|
138
|
+
});
|
|
139
|
+
return () => {
|
|
140
|
+
for (const controller of controllers) controller.dispose();
|
|
141
|
+
controllers.clear();
|
|
142
|
+
remove();
|
|
143
|
+
removeChild();
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
});
|
package/dist/usage.d.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { PerformanceSummary } from "./performance.js";
|
|
2
|
+
export interface Tokens {
|
|
3
|
+
input: number;
|
|
4
|
+
output: number;
|
|
5
|
+
reasoning: number;
|
|
6
|
+
cache: {
|
|
7
|
+
read: number;
|
|
8
|
+
write: number;
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
export interface TokenInput {
|
|
12
|
+
input?: number;
|
|
13
|
+
output?: number;
|
|
14
|
+
reasoning?: number;
|
|
15
|
+
cache?: {
|
|
16
|
+
read?: number;
|
|
17
|
+
write?: number;
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
export interface Price {
|
|
21
|
+
tier?: {
|
|
22
|
+
type: "context";
|
|
23
|
+
size: number;
|
|
24
|
+
};
|
|
25
|
+
input?: number;
|
|
26
|
+
output?: number;
|
|
27
|
+
cache?: {
|
|
28
|
+
read?: number;
|
|
29
|
+
write?: number;
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
export interface UsageMessage {
|
|
33
|
+
id: string;
|
|
34
|
+
type: string;
|
|
35
|
+
status?: string;
|
|
36
|
+
tokens?: TokenInput;
|
|
37
|
+
time?: {
|
|
38
|
+
created: number;
|
|
39
|
+
streamed?: number;
|
|
40
|
+
completed?: number;
|
|
41
|
+
};
|
|
42
|
+
content?: readonly UsageContent[];
|
|
43
|
+
}
|
|
44
|
+
export type UsageContent = {
|
|
45
|
+
type: "text";
|
|
46
|
+
text: string;
|
|
47
|
+
} | {
|
|
48
|
+
type: "reasoning";
|
|
49
|
+
text: string;
|
|
50
|
+
time?: {
|
|
51
|
+
created: number;
|
|
52
|
+
completed?: number;
|
|
53
|
+
};
|
|
54
|
+
} | {
|
|
55
|
+
type: "tool";
|
|
56
|
+
time: {
|
|
57
|
+
created: number;
|
|
58
|
+
ran?: number;
|
|
59
|
+
completed?: number;
|
|
60
|
+
};
|
|
61
|
+
} | {
|
|
62
|
+
type: string;
|
|
63
|
+
text?: string;
|
|
64
|
+
time?: {
|
|
65
|
+
created: number;
|
|
66
|
+
completed?: number;
|
|
67
|
+
};
|
|
68
|
+
};
|
|
69
|
+
export declare function normalize(tokens?: TokenInput): Tokens;
|
|
70
|
+
export declare const incoming: (t: Tokens) => number;
|
|
71
|
+
export declare const total: (t: Tokens) => number;
|
|
72
|
+
export declare function estimate(tokens: Tokens, prices?: readonly Price[]): {
|
|
73
|
+
cost: number;
|
|
74
|
+
defaultPrice: boolean;
|
|
75
|
+
};
|
|
76
|
+
export declare function summarize(messages: Iterable<UsageMessage>, prices?: readonly Price[]): {
|
|
77
|
+
tokens: Tokens;
|
|
78
|
+
total: number;
|
|
79
|
+
cacheRate: number;
|
|
80
|
+
cost: number;
|
|
81
|
+
defaultPrice: boolean;
|
|
82
|
+
};
|
|
83
|
+
export type Summary = ReturnType<typeof summarize>;
|
|
84
|
+
export interface ContextUsage {
|
|
85
|
+
used: number;
|
|
86
|
+
limit: number;
|
|
87
|
+
percent: number;
|
|
88
|
+
}
|
|
89
|
+
export declare function contextUsage(messages: readonly UsageMessage[], limit?: number): ContextUsage | undefined;
|
|
90
|
+
export declare function formatTokens(value: number): string;
|
|
91
|
+
export declare const formatCost: (value: number) => string;
|
|
92
|
+
export declare function usageRows(summary?: Summary, context?: ContextUsage, performance?: PerformanceSummary): readonly (readonly [string, string])[];
|
package/dist/usage.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
const safe = (n) => n !== undefined && Number.isFinite(n) ? Math.max(0, n) : 0;
|
|
2
|
+
export function normalize(tokens) {
|
|
3
|
+
return {
|
|
4
|
+
input: safe(tokens?.input), output: safe(tokens?.output), reasoning: safe(tokens?.reasoning),
|
|
5
|
+
cache: { read: safe(tokens?.cache?.read), write: safe(tokens?.cache?.write) },
|
|
6
|
+
};
|
|
7
|
+
}
|
|
8
|
+
export const incoming = (t) => t.input + t.cache.read + t.cache.write;
|
|
9
|
+
export const total = (t) => incoming(t) + t.output + t.reasoning;
|
|
10
|
+
// Matches OpenCode 2.0.9 SessionUsage.calculateCost: thresholds are exclusive.
|
|
11
|
+
export function estimate(tokens, prices = []) {
|
|
12
|
+
const tier = prices.filter(p => p.tier && incoming(tokens) > p.tier.size)
|
|
13
|
+
.sort((a, b) => b.tier.size - a.tier.size)[0];
|
|
14
|
+
const price = tier ?? prices.find(p => !p.tier);
|
|
15
|
+
const rates = [price?.input, price?.output, price?.cache?.read, price?.cache?.write];
|
|
16
|
+
return {
|
|
17
|
+
cost: (tokens.input * safe(rates[0]) + (tokens.output + tokens.reasoning) * safe(rates[1])
|
|
18
|
+
+ tokens.cache.read * safe(rates[2]) + tokens.cache.write * safe(rates[3])) / 1_000_000,
|
|
19
|
+
defaultPrice: rates.some(rate => rate === undefined || !Number.isFinite(rate) || rate < 0),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
export function summarize(messages, prices = []) {
|
|
23
|
+
const tokens = normalize();
|
|
24
|
+
let cost = 0;
|
|
25
|
+
let defaultPrice = prices.length === 0;
|
|
26
|
+
for (const message of messages) {
|
|
27
|
+
if ((message.type !== "assistant" && message.type !== "compaction") || !message.tokens)
|
|
28
|
+
continue;
|
|
29
|
+
const t = normalize(message.tokens);
|
|
30
|
+
tokens.input += t.input;
|
|
31
|
+
tokens.output += t.output;
|
|
32
|
+
tokens.reasoning += t.reasoning;
|
|
33
|
+
tokens.cache.read += t.cache.read;
|
|
34
|
+
tokens.cache.write += t.cache.write;
|
|
35
|
+
const value = estimate(t, prices);
|
|
36
|
+
cost += value.cost;
|
|
37
|
+
defaultPrice ||= value.defaultPrice;
|
|
38
|
+
}
|
|
39
|
+
return { tokens, total: total(tokens), cacheRate: incoming(tokens) ? tokens.cache.read / incoming(tokens) : 0, cost, defaultPrice };
|
|
40
|
+
}
|
|
41
|
+
const usableLimit = (value) => value !== undefined && Number.isFinite(value) && value > 0;
|
|
42
|
+
// Matches the OpenCode 2.0.10 sidebar: the last assistant message with tokens that follows
|
|
43
|
+
// the last completed compaction carries the current context size. Running or failed
|
|
44
|
+
// compactions do not reset it, and a last message without usage hides the row entirely
|
|
45
|
+
// instead of falling back to pre-compaction history.
|
|
46
|
+
export function contextUsage(messages, limit) {
|
|
47
|
+
if (!usableLimit(limit))
|
|
48
|
+
return undefined;
|
|
49
|
+
let boundary = -1;
|
|
50
|
+
for (let index = messages.length - 1; index >= 0; index--) {
|
|
51
|
+
const message = messages[index];
|
|
52
|
+
if (message.type === "compaction" && message.status === "completed") {
|
|
53
|
+
boundary = index;
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
for (let index = messages.length - 1; index > boundary; index--) {
|
|
58
|
+
const message = messages[index];
|
|
59
|
+
if (message.type !== "assistant" || !message.tokens)
|
|
60
|
+
continue;
|
|
61
|
+
const used = total(normalize(message.tokens));
|
|
62
|
+
return used > 0 ? { used, limit, percent: used / limit * 100 } : undefined;
|
|
63
|
+
}
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
export function formatTokens(value) {
|
|
67
|
+
return Math.round(safe(value)).toLocaleString("en-US");
|
|
68
|
+
}
|
|
69
|
+
export const formatCost = (value) => value > 0 && value < 0.01 ? "<$0.01" : `$${safe(value).toFixed(2)}`;
|
|
70
|
+
export function usageRows(summary, context, performance) {
|
|
71
|
+
const t = summary?.tokens;
|
|
72
|
+
const number = (value) => value === undefined ? "—" : formatTokens(value);
|
|
73
|
+
const rows = [];
|
|
74
|
+
if (context)
|
|
75
|
+
rows.push(["Context", `${formatTokens(context.used)} / ${formatTokens(context.limit)} (${context.percent.toFixed(1)}%)`]);
|
|
76
|
+
rows.push(["Input", number(t?.input)], ["Output", number(t?.output)], ["Reasoning", number(t?.reasoning)], ["Cache Read", number(t?.cache.read)]);
|
|
77
|
+
if (t && t.cache.write > 0)
|
|
78
|
+
rows.push(["Cache Write", number(t.cache.write)]);
|
|
79
|
+
rows.push(["Cache Rate", summary ? `${(summary.cacheRate * 100).toFixed(1)}%` : "—"], ["Total", number(summary?.total)]);
|
|
80
|
+
if (summary && summary.cost > 0)
|
|
81
|
+
rows.push(["Cost", formatCost(summary.cost)]);
|
|
82
|
+
if (performance?.tps !== undefined && Number.isFinite(performance.tps)) {
|
|
83
|
+
rows.push(["TPS", `${performance.tpsEstimated ? "~" : ""}${Math.max(0, performance.tps).toFixed(1)} tok/s`]);
|
|
84
|
+
}
|
|
85
|
+
if (performance?.ttft !== undefined && Number.isFinite(performance.ttft)) {
|
|
86
|
+
rows.push(["TTFT", `${(Math.max(0, performance.ttft) / 1_000).toFixed(1)}s`]);
|
|
87
|
+
}
|
|
88
|
+
return rows;
|
|
89
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# 验证记录
|
|
2
|
+
|
|
3
|
+
## v0.2.1(TPS 与 TTFT)
|
|
4
|
+
|
|
5
|
+
验证日期:2026-09-20。环境:macOS、Node.js v22.23.2、npm 10.9.8、OpenCode v2.0.11;插件 SDK 依赖为 2.0.9。
|
|
6
|
+
|
|
7
|
+
### 自动化检查
|
|
8
|
+
|
|
9
|
+
| 检查 | 结果 |
|
|
10
|
+
| --- | --- |
|
|
11
|
+
| `npm run typecheck` | 通过 |
|
|
12
|
+
| `npm test` | 29 项通过 |
|
|
13
|
+
| `npm run build` | 通过,输出 ESM 与类型声明,包括 `dist/performance.js` |
|
|
14
|
+
| `npm pack --dry-run` | 通过,`@chenlongapps/opencode-token-usage@0.2.1` 包含 19 个文件,压缩后约 20 KB |
|
|
15
|
+
| `npm publish --dry-run` | 通过,完整执行 `prepublishOnly`、`prepack` 并确认 public access;未写入 registry |
|
|
16
|
+
| `npm run test:smoke` | 通过,使用实际 `chenlongapps-opencode-token-usage-0.2.1.tgz` 安装产物 |
|
|
17
|
+
|
|
18
|
+
npm 发布前检查确认无作用域名称 `opencode-token-usage` 已由其他作者占用,因此本项目使用尚未注册且与 GitHub 仓库所有者一致的 `@chenlongapps/opencode-token-usage`。包元数据、公开 registry、GitHub Actions CI、Trusted Publishing/OIDC 发布工作流与首次发布说明已配置;首次实际发布仍需 `chenlongapps` npm scope 的账号权限与双因素认证。`npm audit` 报告 2 个 low、11 个 moderate、0 个 high、0 个 critical,均来自固定的 OpenCode 2.0.9 / OpenTUI / OpenTelemetry 依赖链,当前锁定版本下没有可直接应用的完整修复。
|
|
19
|
+
|
|
20
|
+
新增测试覆盖:已完成 assistant 的 Output + Reasoning / 流式总时长加权 TPS;无效时间、零输出与 compaction 排除;reasoning/tool 历史 TTFT、纯文本历史不伪造样本、运行期样本覆盖历史;文本、推理、工具输入三类流式增量;UTF-8 字节估算、重复事件去重、并发活动流合并;无关会话忽略、新建子代理即时纳入、切换会话树清理;流式更新不读取数据源,完成后从估算 TPS 收敛到精确 TPS;性能行位于用量与费用之后,与前一组间隔一行,并保持格式与缺失隐藏。
|
|
21
|
+
|
|
22
|
+
### 真实 OpenCode 集成
|
|
23
|
+
|
|
24
|
+
`scripts/smoke.mjs` 使用带固定延迟的本地 OpenAI 兼容流,在真实 OpenCode 服务和 160 × 54 TUI 中验证:
|
|
25
|
+
|
|
26
|
+
- 空会话隐藏 TPS、TTFT、Context、Cache Write 与 Cost,不显示伪零性能值。
|
|
27
|
+
- 第一段文本到达时,面板显示 `TPS ~2.4 tok/s` 与 `TTFT 0.4s`;`~` 明确表示按 UTF-8 字节估算的活动流速度。
|
|
28
|
+
- assistant 完成并刷新快照后,TPS 自动变为无 `~` 的 `62.9 tok/s`。打包产物的 `historicalPerformance` 返回 62.893… tok/s,且同屏 OpenCode 自带消息状态也显示 62.9 tok/s。
|
|
29
|
+
- 行顺序为 Context、五类 token、Cache Rate、Total、Cost、空行、TPS、TTFT;Cost 隐藏时仍在最后一条用量行后空一行。原有上下文、真实子代理全树累计与模型切换测试继续通过。
|
|
30
|
+
- 安装包包含 `dist/performance.js` 及其类型声明;结果文件记录宿主版本 2.0.11、Total 5,080 和性能值。
|
|
31
|
+
- scoped 包安装在 `node_modules/@chenlongapps/opencode-token-usage` 后,根入口、TUI 入口和所有运行时断言均通过。
|
|
32
|
+
|
|
33
|
+
### 口径与验证边界
|
|
34
|
+
|
|
35
|
+
- 精确 TPS 沿用 OpenCode 2 当前口径:全树可用 assistant 样本的 `Σ(Output + Reasoning) ÷ Σ(time.streamed - time.created)`;compaction 不参与。活动生成优先显示 UTF-8 增量按 4 字节/token 换算的估计值,多个活动流先合并 token 与时长再相除,最多每 100ms 发布一次且不触发完整快照读取。
|
|
36
|
+
- TTFT 从 `session.step.started.data.started` 到首个 `session.text.delta`、`session.reasoning.delta` 或 `session.tool.input.delta`。运行期样本可精确测量;历史 reasoning/tool 内容可使用自身 `time.created`,纯文本历史缺少首 token 时间,故不纳入平均。
|
|
37
|
+
- 2.0.11 的事件字段、流式显示、精确 TPS、子代理事件归属与打包加载已通过上述真实测试。SDK 仍固定 2.0.9;升级 SDK 或宿主到更高版本时仍需重新核对 step/delta 时间语义、compaction 排序和分叉副本规则。
|
|
38
|
+
- 实时 TPS 是近似展示,中文、emoji、工具 JSON 等不同 UTF-8 字节分布会影响 4 字节/token 的误差;完成后以服务端真实 token 与时间替换。
|
|
39
|
+
|
|
40
|
+
## v0.2.0(上下文用量)
|
|
41
|
+
|
|
42
|
+
验证日期:2026-09-20。环境:macOS、Node.js v22.23.2、npm 10.9.8、OpenCode v2.0.10;插件 SDK 依赖为 2.0.9。
|
|
43
|
+
|
|
44
|
+
### 自动化检查
|
|
45
|
+
|
|
46
|
+
| 检查 | 结果 |
|
|
47
|
+
| --- | --- |
|
|
48
|
+
| `npm run typecheck` | 通过 |
|
|
49
|
+
| `npm test` | 23 项通过 |
|
|
50
|
+
| `npm run build` | 通过,输出 ESM 与类型声明 |
|
|
51
|
+
| `npm run test:smoke` | 通过,使用实际 `.tgz` 安装产物 |
|
|
52
|
+
|
|
53
|
+
新增测试覆盖:上下文取五类 token 之和、压缩边界(`completed` 重置窗口,`running` 与 `failed` 不重置)、跳过没有上报用量的消息、未知上限(缺失、零、负数、NaN、Infinity)隐藏、百分比超过 100%、上下文行位于面板第一行且百分比同行;视图消息顺序与被查看会话隔离;模型切换后重新读取上限;刷新失败时保留上次上下文并标注未更新。
|
|
54
|
+
|
|
55
|
+
### 真实 OpenCode 集成
|
|
56
|
+
|
|
57
|
+
`scripts/smoke.mjs` 沿用 v0.1.0 的隔离方式,并新增上下文断言:
|
|
58
|
+
|
|
59
|
+
- 空会话:面板保持 Input、Output、Reasoning、Cache Read、Cache Rate、Total 六行零值,不出现 `/ 128,000`、Context 行、Cache Write 与 Cost。
|
|
60
|
+
- 首条 assistant 完成后:`Context 1,270 / 128,000 (1.0%)`,且该行出现在 `Input` 行之上(终端逐行比对行号)。同一时刻宿主自带侧边栏为 `Context 1,270 tokens 1% used`,用量与插件一致(宿主百分比取整,插件保留一位小数)。
|
|
61
|
+
- 子代理视图:树的累计 `Total 5,080`,而上下文仍为 `1,270 / 128,000 (1.0%)`,确认上下文只反映被查看会话、不随子树累计。
|
|
62
|
+
- 切换到 `usage-test/large`(夹具上限 32,000)后无需新消息即同步:`Context 1,270 / 32,000 (4.0%)`。
|
|
63
|
+
- 打包产物导出的 `contextUsage` / `viewedMessages` 直接断言为 `used 1270`、`limit 128000`、`percent 1.0`。
|
|
64
|
+
|
|
65
|
+
### 接口适配与验证边界
|
|
66
|
+
|
|
67
|
+
- 上下文口径参照 OpenCode 2.0.10 打包产物的侧边栏实现(`Ws`/`S6`):最后一次 `completed` 压缩之后、最后一条带 `tokens` 的 assistant 消息的五类 token 之和。SDK 依赖仍为 2.0.9,2.0.9 宿主未单独核对这一显示算法。
|
|
68
|
+
- 宿主 `Ws` 还使用 `session.revert` 作为搜索上界;本版本不读取 revert 状态,回退会话的取值可能与宿主不同。
|
|
69
|
+
- 分母使用被查看会话的**活动模型**,而不是产生该用量的消息自身记录的模型;会话中途切换模型后两者可能不同。
|
|
70
|
+
- Cost 与上下文上限使用同一活动模型解析规则,同样不使用消息自身记录的 `model` 字段:混合模型会话的 Cost 按查看时活动模型统一重算,不等于各模型实际账单之和。真实集成只验证了单夹具模型下的价格重算,混合模型的失真未单独断言。
|
|
71
|
+
- 不显示进度条,上下文百分比与用量同行;行值加标签约 31 字符,窄侧边栏可能换行,响应式布局留待 v0.3.0。
|
|
72
|
+
- 夹具 `usage-test/large` 的上下文上限改为 32,000 以便观测模型切换,`usage-test/small` 保持 128,000。
|
|
73
|
+
|
|
74
|
+
## v0.1.0
|
|
75
|
+
|
|
76
|
+
验证日期:2026-09-19。环境:macOS、Node.js v22.23.2、npm 10.9.8、OpenCode v2.0.10;插件 SDK 依赖为 2.0.9。
|
|
77
|
+
|
|
78
|
+
### 自动化检查
|
|
79
|
+
|
|
80
|
+
| 检查 | 结果 |
|
|
81
|
+
| --- | --- |
|
|
82
|
+
| `npm run typecheck` | 通过 |
|
|
83
|
+
| `npm test` | 18 项通过 |
|
|
84
|
+
| `npm run build` | 通过,输出 ESM 与类型声明 |
|
|
85
|
+
| `npm run test:smoke` | 通过,使用实际 `.tgz` 安装产物 |
|
|
86
|
+
|
|
87
|
+
单元及组件逻辑测试覆盖五类 token、零值和缺失字段、缓存命中率、逗号分组与四舍五入边界、普通及缓存价格、推理费用、价格阶梯的严格阈值、免费模型、缺失价格、模型重算、多层后代、历史分页、compaction、继承历史去重、重复事件、请求隔离、卸载、读取失败与恢复。
|
|
88
|
+
|
|
89
|
+
游标适配还有独立回归测试:后续消息页不得重新传入 `order`;子会话游标自身包含 `parentID` 过滤条件。
|
|
90
|
+
|
|
91
|
+
### 真实 OpenCode 集成
|
|
92
|
+
|
|
93
|
+
`scripts/smoke.mjs` 在独立临时目录中执行 `npm pack` 与 `npm install`,使用安装后的根目录入口加载插件。真实 OpenCode 服务通过本地 OpenAI 兼容模拟端点生成消息,并调用内置 subagent 工具创建子代理。推理返回是固定测试数据,不是付费模型响应。
|
|
94
|
+
|
|
95
|
+
Python 3 为真实 TUI 提供 160 × 54 的伪终端,终端输出经 xterm 解析后进行断言并保存文本和 ANSI 捕获。
|
|
96
|
+
|
|
97
|
+
已验证:
|
|
98
|
+
|
|
99
|
+
- 主插件激活、TUI 插件加载;安装包包含根目录 `index.js` / `tui.js`、编译产物及类型声明。
|
|
100
|
+
- 空会话隐藏零值的 Cache Write 与 Cost;基础统计行、宿主标题、Context 与费用区继续保留。
|
|
101
|
+
- 首条 assistant 完成后无需重新进入会话即可刷新:Input 100、Output 50、Reasoning 20、Cache Read 1,000、Cache Write 100,Total 1,270。
|
|
102
|
+
- 父会话三条 assistant 消息和一个真实子代理的一条消息共同累计为 Total 5,080,显示为 `5,080`。
|
|
103
|
+
- 父视图与子代理视图都显示 Input 400、Output 200、Reasoning 80、Cache Read 4,000、Cache Write 400、Cache Rate 83.3%。
|
|
104
|
+
- 切换根会话活动模型后,重新读取 `usage-test/large` 的价格并重算费用;模型切换费用重算另有数值单元断言。
|
|
105
|
+
|
|
106
|
+
### 接口适配与验证边界
|
|
107
|
+
|
|
108
|
+
- 2.0.10 的本地目录加载器通过根部 `index` / `tui` 文件发现入口,仅有 package exports 不足以加载本地目录,因此包包含转发入口;2.0.9 兼容性也已验证。
|
|
109
|
+
- 2.0.10 在子代理视图中不挂载侧边栏;通过官方 `session.composer.top` 插槽显示同一完整面板;2.0.9 兼容性也已验证。
|
|
110
|
+
- 完整历史使用 `message.list`,不使用会在压缩后截断历史的 `session.context`。
|
|
111
|
+
- 分叉副本识别依赖 2.0.9 的消息 ID 后缀约定;更高 OpenCode 版本尚未验证。
|
|
112
|
+
- 多层分页、压缩与分叉去重、故障恢复由自动化逻辑测试覆盖;真实 TUI 验证覆盖加载、正常消息、一级真实子代理和模型切换。
|
|
113
|
+
|
|
114
|
+
重现时运行 README 中的检查命令。烟雾测试会输出临时产物目录,包含 `result.json`、`plugins.json`、各阶段终端捕获和已隐藏服务器密码的日志。
|
package/index.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@chenlongapps/opencode-token-usage",
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "Session-tree token usage, live performance, context window and estimated cost for OpenCode 2",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"opencode",
|
|
7
|
+
"opencode-plugin",
|
|
8
|
+
"token-usage",
|
|
9
|
+
"context-window",
|
|
10
|
+
"tps",
|
|
11
|
+
"ttft",
|
|
12
|
+
"cost"
|
|
13
|
+
],
|
|
14
|
+
"homepage": "https://github.com/chenlongapps/opencode-token-usage#readme",
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/chenlongapps/opencode-token-usage/issues"
|
|
17
|
+
},
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/chenlongapps/opencode-token-usage.git"
|
|
21
|
+
},
|
|
22
|
+
"type": "module",
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=22"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"index.js",
|
|
29
|
+
"tui.js",
|
|
30
|
+
"dist",
|
|
31
|
+
"README.md",
|
|
32
|
+
"ROADMAP.md",
|
|
33
|
+
"docs/verification.md",
|
|
34
|
+
"LICENSE"
|
|
35
|
+
],
|
|
36
|
+
"exports": {
|
|
37
|
+
".": {
|
|
38
|
+
"types": "./dist/index.d.ts",
|
|
39
|
+
"import": "./dist/index.js"
|
|
40
|
+
},
|
|
41
|
+
"./tui": {
|
|
42
|
+
"types": "./dist/tui.d.ts",
|
|
43
|
+
"import": "./dist/tui.js"
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
"scripts": {
|
|
47
|
+
"build": "node scripts/build.mjs",
|
|
48
|
+
"typecheck": "tsc --noEmit",
|
|
49
|
+
"test": "tsx --test test/*.test.ts",
|
|
50
|
+
"test:smoke": "node scripts/smoke.mjs",
|
|
51
|
+
"prepublishOnly": "npm run typecheck && npm test",
|
|
52
|
+
"prepack": "npm run build"
|
|
53
|
+
},
|
|
54
|
+
"publishConfig": {
|
|
55
|
+
"access": "public",
|
|
56
|
+
"registry": "https://registry.npmjs.org/"
|
|
57
|
+
},
|
|
58
|
+
"dependencies": {
|
|
59
|
+
"@opencode/plugin": "2.0.9"
|
|
60
|
+
},
|
|
61
|
+
"peerDependencies": {
|
|
62
|
+
"@opentui/core": "0.5.10",
|
|
63
|
+
"@opentui/solid": "0.5.10",
|
|
64
|
+
"solid-js": ">=1.9.0"
|
|
65
|
+
},
|
|
66
|
+
"devDependencies": {
|
|
67
|
+
"@babel/core": "^7.28.0",
|
|
68
|
+
"@opencode/client": "2.0.9",
|
|
69
|
+
"@opencode/schema": "2.0.9",
|
|
70
|
+
"@opencode/theme": "2.0.9",
|
|
71
|
+
"@opentui/core": "0.5.10",
|
|
72
|
+
"@opentui/solid": "0.5.10",
|
|
73
|
+
"@types/node": "^22.0.0",
|
|
74
|
+
"@xterm/headless": "6.0.0",
|
|
75
|
+
"babel-preset-solid": "1.9.12",
|
|
76
|
+
"solid-js": "1.9.12",
|
|
77
|
+
"tsx": "^4.20.0",
|
|
78
|
+
"typescript": "^5.9.0"
|
|
79
|
+
},
|
|
80
|
+
"packageManager": "npm@10.9.8"
|
|
81
|
+
}
|
package/tui.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "./dist/tui.js";
|