@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/README.md +16 -5
- package/README.zh-CN.md +16 -5
- package/ROADMAP.md +4 -1
- package/dist/context-capture.d.ts +43 -0
- package/dist/context-capture.js +111 -0
- package/dist/controller.d.ts +4 -0
- package/dist/controller.js +43 -15
- package/dist/index.js +29 -8
- package/dist/performance.d.ts +19 -0
- package/dist/performance.js +112 -36
- package/dist/prices.generated.d.ts +338 -15
- package/dist/prices.generated.js +32 -10
- package/dist/pricing.d.ts +1 -1
- package/dist/source.d.ts +3 -2
- package/dist/source.js +31 -16
- package/dist/tui.js +209 -153
- package/dist/usage.d.ts +22 -2
- package/dist/usage.js +91 -14
- package/docs/pricing.md +1 -0
- package/docs/verification.md +54 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -34,7 +34,7 @@ Alternatively, add the package to your project's `opencode.json` or `opencode.js
|
|
|
34
34
|
```
|
|
35
35
|
|
|
36
36
|
> [!NOTE]
|
|
37
|
-
> Requires Node.js 22+. OpenCode 2.0.9, 2.0.10, and 2.0.11 have been verified across project releases. The current SDK 2.0.11 build has been reverified on OpenCode 2.0.11.
|
|
37
|
+
> Requires Node.js 22+. OpenCode 2.0.9, 2.0.10, and 2.0.11 have been verified across project releases. The current SDK 2.0.11 build has been reverified on OpenCode 2.0.11 and 2.0.22.
|
|
38
38
|
|
|
39
39
|
Restart OpenCode after installation. The panel appears in the native sidebar when `session.sidebar` is set to `auto` and the terminal is wide enough. OpenCode hides the sidebar in subagent views, so the plugin keeps one live summary line above the composer with Context, Total, Cost, and TPS. Click the line to open the full statistics in a centered dialog. Press Escape or click **esc** to close it; closing the dialog does not interrupt the subagent.
|
|
40
40
|
|
|
@@ -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
|
|
|
@@ -74,7 +74,7 @@ Costs follow the sidebar's pricing and `partial`/unavailable/free conventions. C
|
|
|
74
74
|
| `Context` | Latest context usage after the most recent completed compaction in the viewed session; not aggregated across the subtree |
|
|
75
75
|
| `Steps` | Assistant message count across the session tree, including subagents; follows OpenCode's own stats definition, so compaction and user messages are not steps |
|
|
76
76
|
| `Est. Cost` | Estimated cost across the tree, pricing each assistant and compaction call with its actual model |
|
|
77
|
-
| `TPS` | Generation throughput for `Output + Reasoning` across the tree
|
|
77
|
+
| `TPS` | Generation throughput for `Output + Reasoning` across the tree. While streaming, it estimates recent observable text, reasoning-summary and tool-input deltas (UTF-8 bytes / 4) over a short sliding window after the first delta, excluding pre-first-token waiting, and marks the value with `~`. APIs such as OpenAI Responses do not expose complete hidden reasoning tokens in real time, so live values cannot represent hidden reasoning throughput. After completion it uses provider-reported Output and Reasoning tokens and removes `~` |
|
|
78
78
|
| `TTFT` | Average time to first token across measurable assistant steps in the tree |
|
|
79
79
|
|
|
80
80
|
#### Behavior
|
|
@@ -86,6 +86,7 @@ Costs follow the sidebar's pricing and `partial`/unavailable/free conventions. C
|
|
|
86
86
|
- `Est. Cost` prices every message with its recorded model. A complete non-zero price resolved by OpenCode takes precedence. If OpenCode reports a complete zero price, a complete first-party snapshot price overrides it; incomplete prices fall back for the whole message without mixing rates.
|
|
87
87
|
- The checked-in fallback snapshot is generated from [models.dev](https://models.dev/api.json) using only reviewed first-party provider/model families; a small set of manufacturer-verified exceptions is kept separately. It covers priced text models, without downloading prices while the plugin runs. Gateway models match exact manufacturer IDs, documented aliases, and known wrappers; only a terminal `-free` or `:free` can be removed for a second exact lookup.
|
|
88
88
|
- Confirmed free usage displays `$0.00`; unavailable prices display `—`; known subtotals with unpriced messages are marked `partial`. The snapshot excludes gateway markups, regional premiums, unlisted discounts, non-text billing, tool fees, and taxes, so Est. Cost is not a provider bill. See [price sources and limitations](docs/pricing.md).
|
|
89
|
+
- Live `TPS` with `~` is an estimate from recent observable deltas after the first delta over a ~2s sliding window with light smoothing; it excludes TTFT and hidden-reasoning waits. Without `~` it is exact provider-reported `Output + Reasoning` throughput. No model-specific multipliers or hidden-reasoning guesses are applied.
|
|
89
90
|
- Initial read failures display `Unavailable`. Later failures retain the last complete snapshot, display `Not updated`, and retry automatically.
|
|
90
91
|
|
|
91
92
|
### Development
|
|
@@ -104,6 +105,16 @@ For a manual refresh, run `npm run prices:update` in a networked environment, re
|
|
|
104
105
|
|
|
105
106
|
`test:smoke` packages the real artifact and validates loading, refreshes, `/usage`, subagent aggregation, per-message pricing, first-party price fallback, model switching, TPS, and TTFT against an isolated OpenCode instance and a local mock provider. It requires Python 3, an available local port, and npm network access. It never modifies your existing OpenCode configuration or calls paid models.
|
|
106
107
|
|
|
108
|
+
By default, smoke uses `opencode` from `PATH`. Stable OpenCode 2 versions from 2.0.9 onward can run; a host not yet verified with the current SDK prints a warning and continues through the actual compatibility checks. Passing the version check alone does not establish compatibility. The SDK dependencies remain pinned independently of your local CLI updates.
|
|
109
|
+
|
|
110
|
+
To reproduce a baseline with a separately installed binary, without downgrading your normal installation:
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
OPENCODE_BIN=/absolute/path/to/opencode-2.0.11 npm run test:smoke
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
`OPENCODE_BIN` selects the same executable for the version check, isolated server, and TUI. Relative paths resolve from the directory where you run the command. The host version and selected binary are recorded in the smoke output and `result.json`; see the [verification record](docs/verification.md) for tested combinations.
|
|
117
|
+
|
|
107
118
|
To load the plugin from source, build the project and add the repository's absolute path to `plugins` in the target project.
|
|
108
119
|
|
|
109
120
|
### Documentation
|
package/README.zh-CN.md
CHANGED
|
@@ -34,7 +34,7 @@ opencode plugin add @chenlongapps/opencode-token-usage
|
|
|
34
34
|
```
|
|
35
35
|
|
|
36
36
|
> [!NOTE]
|
|
37
|
-
> 需要 Node.js 22+。项目历次发布已验证 OpenCode 2.0.9、2.0.10 和 2.0.11;当前 SDK 2.0.11 构建已在 OpenCode 2.0.11 上重新验证。
|
|
37
|
+
> 需要 Node.js 22+。项目历次发布已验证 OpenCode 2.0.9、2.0.10 和 2.0.11;当前 SDK 2.0.11 构建已在 OpenCode 2.0.11 和 2.0.22 上重新验证。
|
|
38
38
|
|
|
39
39
|
安装后重启 OpenCode。终端足够宽且 `session.sidebar` 设为 `auto` 时,面板会显示在原生侧边栏中。OpenCode 在子代理视图中隐藏侧边栏,因此插件会在输入区上方保留一行实时摘要,显示 Context、Total、Cost 和 TPS。点击摘要可在居中弹窗中查看完整统计;按 Escape 或点击 **esc** 即可关闭,关闭弹窗不会中断子代理。
|
|
40
40
|
|
|
@@ -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`
|
|
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
|
|
|
@@ -74,7 +74,7 @@ opencode plugin add @chenlongapps/opencode-token-usage
|
|
|
74
74
|
| `Context` | 当前会话最后一次已完成压缩后的最新上下文用量,不随子树累计 |
|
|
75
75
|
| `Steps` | 全树(含子代理)的 assistant 消息数,口径与 OpenCode 自带统计一致,compaction 与用户消息不计为 step |
|
|
76
76
|
| `Est. Cost` | 按每条 assistant 与 compaction 消息实际模型估算的全树费用 |
|
|
77
|
-
| `TPS` | 全树 `Output + Reasoning`
|
|
77
|
+
| `TPS` | 全树 `Output + Reasoning` 的生成吞吐。请求进行中时,插件根据首个流式增量之后最近的可观察文本、reasoning summary 和工具输入流(UTF-8 字节数 / 4)按短时间滑动窗口估算输出速度,不包含首 token 前等待时间,并以 `~` 标记。OpenAI Responses 等 API 不会实时暴露完整的隐藏 reasoning token,因此实时值不能表示隐藏推理吞吐。请求完成后,插件使用服务端上报的 Output 与 Reasoning token 及时间信息计算精确 TPS,并移除 `~` 标记 |
|
|
78
78
|
| `TTFT` | 全树中可测 assistant step 的平均首 token 时间 |
|
|
79
79
|
|
|
80
80
|
#### 行为
|
|
@@ -86,6 +86,7 @@ opencode plugin add @chenlongapps/opencode-token-usage
|
|
|
86
86
|
- `Est. Cost` 按每条消息记录的实际模型分别计算。OpenCode 当前解析出的完整非零价格优先;若 OpenCode 给出完整零价,且内置价格快照可以完整计价,则采用快照价格。价格不完整时整条消息回退,不混用两套费率。
|
|
87
87
|
- 内置回退快照从 [models.dev](https://models.dev/api.json) 生成,只采用审核过的原厂 Provider 与自家模型系列;少量经原厂核实的例外单独维护。快照覆盖有价格的文本模型,插件运行时不下载价格。网关模型通过精确原厂 ID、明确别名和已知包装格式匹配;只有终尾 `-free` 或 `:free` 会在再次精确查找前移除。
|
|
88
88
|
- 确认免费时显示 `$0.00`;价格不可用时显示 `—`;只有部分消息可计算时在已知小计后标注 `partial`。快照不含网关加价、区域溢价、未列明的折扣、非文本计费、工具费和税费;Est. Cost 是估算而非账单。覆盖范围、来源和限制见[价格来源与限制](docs/pricing.md)。
|
|
89
|
+
- 带 `~` 的实时 `TPS` 是首个流式增量之后约 2 秒滑动窗口内可观察增量的估算值(带轻度平滑),不包含 TTFT 与隐藏推理等待;不带 `~` 时为服务端上报的 `Output + Reasoning` 精确吞吐。不做模型专属倍率,也不猜测隐藏 reasoning token。
|
|
89
90
|
- 首次读取失败显示 `Unavailable`;后续失败保留上次完整快照、标注 `Not updated` 并自动重试。
|
|
90
91
|
|
|
91
92
|
### 开发
|
|
@@ -104,6 +105,16 @@ npm run test:smoke
|
|
|
104
105
|
|
|
105
106
|
`test:smoke` 会打包真实产物,并在隔离的 OpenCode 与本地模拟提供商中验证加载、刷新、`/usage`、子代理累计、逐消息计价、原厂价格补全、模型切换、TPS 和 TTFT。它需要 Python 3、可用的本地端口和 npm 网络访问,不会修改现有 OpenCode 配置或调用付费模型。
|
|
106
107
|
|
|
108
|
+
默认使用 `PATH` 中的 `opencode`。OpenCode 2.0.9 及之后的稳定版 2.x 可以运行;尚未与当前 SDK 验证的宿主会输出警告,然后继续实际兼容性检查。仅通过版本检查不代表兼容性已验证。SDK 依赖仍独立锁定,不随本机 CLI 更新而自动升级。
|
|
109
|
+
|
|
110
|
+
如需用独立安装的二进制复现基准,而不降级日常使用的安装:
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
OPENCODE_BIN=/absolute/path/to/opencode-2.0.11 npm run test:smoke
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
`OPENCODE_BIN` 为版本检查、隔离服务端和 TUI 统一选择同一个可执行文件。相对路径以执行命令时的目录为基准解析。smoke 输出和 `result.json` 会记录宿主版本与所选二进制;已测试组合见[验证记录](docs/verification.md)。
|
|
117
|
+
|
|
107
118
|
从源码加载时,先构建项目,再将仓库绝对路径加入目标项目的 `plugins`。
|
|
108
119
|
|
|
109
120
|
### 文档
|
package/ROADMAP.md
CHANGED
|
@@ -101,10 +101,13 @@ 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 的实时计算:当前为近似值,后续考虑通过更精确的算法计算
|
|
@@ -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
|
+
}
|
package/dist/controller.d.ts
CHANGED
|
@@ -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;
|
package/dist/controller.js
CHANGED
|
@@ -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 (
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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 {
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
});
|
package/dist/performance.d.ts
CHANGED
|
@@ -17,10 +17,28 @@ export interface PerformanceEvent {
|
|
|
17
17
|
id?: string;
|
|
18
18
|
created?: number;
|
|
19
19
|
}
|
|
20
|
+
export interface StreamSample {
|
|
21
|
+
time: number;
|
|
22
|
+
bytes: number;
|
|
23
|
+
}
|
|
24
|
+
export declare const LIVE_WINDOW_MS = 2000;
|
|
25
|
+
export declare const LIVE_EWMA_ALPHA = 0.35;
|
|
26
|
+
export declare const LIVE_BYTES_PER_TOKEN = 4;
|
|
27
|
+
/** Raw live throughput from one step's recent observable deltas. */
|
|
28
|
+
export declare function estimatedLiveTps(samples: readonly StreamSample[]): number | undefined;
|
|
29
|
+
export declare function smoothLiveTps(previous: number | undefined, raw: number): number;
|
|
30
|
+
export interface PreparedPerformance {
|
|
31
|
+
tps?: number;
|
|
32
|
+
ttftSum: number;
|
|
33
|
+
ttftCount: number;
|
|
34
|
+
historical: ReadonlyMap<string, number>;
|
|
35
|
+
}
|
|
20
36
|
export type PerformanceSubscribe = (listener: (event: PerformanceEvent) => void) => () => void;
|
|
21
37
|
export type PerformanceListener = (sessionID: string) => void;
|
|
22
38
|
/** Exact completed-run TPS and the average of only measurable TTFT samples. */
|
|
23
39
|
export declare function historicalPerformance(messages: Iterable<UsageMessage>, observations?: Iterable<FirstTokenObservation>): PerformanceSummary;
|
|
40
|
+
/** The immutable part of a snapshot is scanned once, then reused for stream updates. */
|
|
41
|
+
export declare function preparePerformance(messages: Iterable<UsageMessage>): PreparedPerformance;
|
|
24
42
|
/** Captures streaming performance for every session for one plugin lifetime. */
|
|
25
43
|
export declare class PerformanceMonitor {
|
|
26
44
|
private readonly retention;
|
|
@@ -42,4 +60,5 @@ export declare class PerformanceMonitor {
|
|
|
42
60
|
handle(event: PerformanceEvent): boolean;
|
|
43
61
|
reconcile(messages: Iterable<UsageMessage>, sessionIDs: Iterable<string>): void;
|
|
44
62
|
summary(messages: Iterable<UsageMessage>, sessionIDs: Iterable<string>): PerformanceSummary;
|
|
63
|
+
summaryPrepared(prepared: PreparedPerformance, sessionIDs: Iterable<string>): PerformanceSummary;
|
|
45
64
|
}
|