@chenlongapps/opencode-token-usage 0.4.6 → 0.4.8
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 +15 -9
- package/README.zh-CN.md +15 -9
- package/ROADMAP.md +9 -1
- package/dist/context-capture.d.ts +5 -3
- package/dist/context-capture.js +31 -14
- package/dist/controller.d.ts +4 -2
- package/dist/controller.js +15 -7
- package/dist/performance.js +5 -3
- package/dist/runtime.d.ts +15 -0
- package/dist/runtime.js +72 -0
- package/dist/tui.js +279 -237
- package/dist/usage.d.ts +16 -5
- package/dist/usage.js +63 -10
- package/docs/verification.md +97 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -34,9 +34,9 @@ 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+ (see the OpenTUI engine note under [Development](#development)). OpenCode 2.0.9 and 2.0.10 have been verified in earlier releases.
|
|
37
|
+
> Requires Node.js 22+ (see the OpenTUI engine note under [Development](#development)). OpenCode 2.0.9 and 2.0.10 have been verified in earlier releases. SDK 2.0.24 integration checks have passed on OpenCode 2.0.11, 2.0.22, 2.0.24, and 2.0.26. Native turn durations were integration-tested on 2.0.26; the other listed host checks predate this addition.
|
|
38
38
|
|
|
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.
|
|
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, Time, 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
|
|
|
41
41
|
For remote sessions, add the package name to `plugins` in your local `~/.config/opencode/cli.json` to load only the terminal entry point. The configuration path follows `XDG_CONFIG_HOME`.
|
|
42
42
|
|
|
@@ -44,7 +44,7 @@ For remote sessions, add the package name to `plugins` in your local `~/.config/
|
|
|
44
44
|
|
|
45
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
|
-
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
|
|
47
|
+
The subagent picker keeps the summary in the form `Token Usage · Context … · Total … · Cost … · Time … · TPS …`. Time sums the viewed subagent's own native per-turn response durations; token totals and cost still cover the whole tree. Narrow terminals omit lower-priority fields, keeping Time visible without adding a second line. 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, TTFT and Run Time) without reserving space for the full panel while closed. Run Time is the last row in both the sidebar and this dialog.
|
|
48
48
|
|
|
49
49
|
The dialog is split into five sections with separate statistics:
|
|
50
50
|
|
|
@@ -53,14 +53,16 @@ The dialog is split into five sections with separate statistics:
|
|
|
53
53
|
| Context Window | Used / limit and a percentage of the active model's context limit, with a usage bar |
|
|
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
|
-
| Session | Tree totals: steps, calls, tokens, cache rate, cost (single line, or per-category rows in detailed mode) |
|
|
56
|
+
| Session | Tree totals: steps, calls, tokens, cache rate, cost (single line, or per-category rows in detailed mode); a separate `Run Time (this session)` row sums only the viewed session's native per-turn response durations |
|
|
57
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. 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.
|
|
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.
|
|
62
62
|
|
|
63
|
-
**Context
|
|
63
|
+
**Context Window** requires reported usage and a valid active-model context limit. **Last Request** shows the selected assistant call's reported tokens even when that limit is unknown or source estimates are unavailable. Its timestamp comes from that same message: stream end, completion, then creation time, using the first valid value and hiding the timestamp if none is available. Request selection follows the viewed session's revert cutoff and most recent completed compaction boundary.
|
|
64
|
+
|
|
65
|
+
**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. Its timestamp is the estimate's own capture time, which may refer to a different request than Last Request. 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
66
|
|
|
65
67
|
### Metrics
|
|
66
68
|
|
|
@@ -74,6 +76,7 @@ Costs follow the sidebar's pricing and `partial`/unavailable/free conventions. I
|
|
|
74
76
|
| `Context` | Latest context usage after the most recent completed compaction in the viewed session; not aggregated across the subtree |
|
|
75
77
|
| `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
78
|
| `Est. Cost` | Estimated cost across the tree, pricing each assistant and compaction call with its actual model |
|
|
79
|
+
| `Run Time` | Sum of the native response duration shown by OpenCode for each turn in the viewed session's retained history, taking only the final completed response per turn; never adds child-session durations |
|
|
77
80
|
| `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
81
|
| `TTFT` | Average time to first token across measurable assistant steps in the tree |
|
|
79
82
|
|
|
@@ -83,9 +86,12 @@ Costs follow the sidebar's pricing and `partial`/unavailable/free conventions. I
|
|
|
83
86
|
- A fork is a separate session tree. Inherited message copies are attributed only to their original source to prevent double counting.
|
|
84
87
|
- `Context` only searches messages after the most recent compaction with `status === "completed"` and is hidden when reliable usage or a model context limit is unavailable.
|
|
85
88
|
- `Steps` counts every assistant message in the tree, whether or not it reported usage, and reuses the fork-copy de-duplication so inherited history is never counted twice.
|
|
89
|
+
- `Run Time` follows OpenCode 2.0.26's native response footer: assistant completion time minus the first user/synthetic input's creation time after the previous idle marker. Older histories without idle markers use the nearest input; without any input, it uses the assistant's creation time. Each turn contributes only its final completed response duration, not the overlapping duration of every assistant step. Model calls, tools, retries and foreground waits within that span are included; idle time between turns is not. Waiting for a subagent is already part of the parent's span and is never added separately.
|
|
90
|
+
- `Run Time` updates when completed responses are reported; unfinished responses are not estimated with a live clock. Failed/interrupted responses count when they have a native completion timestamp. Totals use unrounded milliseconds before formatting (milliseconds below one second, one decimal below one minute, then minutes/hours), so they need not equal the sum of rounded footer labels. The existing message-history read restores totals after restarts, including sessions predating installation; no experimental execution-log API or server plugin is required. Missing historical timing data displays `—`, not zero. Read failures retain the last complete value and mark it `Not updated` (`stale` in the short summary).
|
|
91
|
+
- Runtime follows retained history: agent/model switches and compaction preserve past turns; fork copies never contribute to the fork's own time. A staged revert does not remove turns, but a committed revert deleting messages also removes their durations. Deleted historical timing cannot be reconstructed.
|
|
86
92
|
- `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
93
|
- 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
|
-
- Confirmed free usage displays `$0.00`;
|
|
94
|
+
- Confirmed free usage displays `$0.00`; positive estimates below `$0.001` display `<$0.001`, estimates through and including `$0.01` use three decimal places, and larger estimates use two. 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
95
|
- 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.
|
|
90
96
|
- Initial read failures display `Unavailable`. Later failures retain the last complete snapshot, display `Not updated`, and retry automatically.
|
|
91
97
|
|
|
@@ -103,11 +109,11 @@ The OpenCode SDK packages are pinned to `2.0.24`, with OpenTUI `0.5.14`. Clean i
|
|
|
103
109
|
|
|
104
110
|
The [price update workflow](.github/workflows/update-prices.yml) checks models.dev every day at 22:00 UTC (or on demand via **Run workflow** on `main`). No snapshot diff means no release. Changes within the reviewed first-party source allowlist pass typecheck, tests, build, and pack validation, then automatically bump the patch version (for example, `0.4.4` → `0.4.5`), commit only `src/prices.generated.ts`, `package.json`, and `package-lock.json`, and atomically push `main` and its version tag. The workflow creates a GitHub Release and explicitly dispatches [`publish.yml`](.github/workflows/publish.yml) on that tag to publish via npm Trusted Publishing/OIDC; no long-lived npm token is needed. GitHub Actions must be allowed to push to `main` and create tags, and npm must trust `publish.yml` with permission to run `npm publish`.
|
|
105
111
|
|
|
106
|
-
Failed validation never bumps the version. An unfinished tagged price release is retried with the same version before checking new prices; registry outages are not treated as missing versions. Publishing skips versions already on npm, prevents moving `latest` backwards, and
|
|
112
|
+
Failed validation never bumps the version. An unfinished tagged price release is retried with the same version before checking new prices; registry outages are not treated as missing versions. Publishing skips versions already on npm, prevents moving `latest` backwards, and polls registry visibility for up to 10 minutes after publication, logging progress. A verification timeout is not proof that `npm publish` failed: check the registry and retry the same tag without bumping the version; already-published versions are not republished. Manufacturer/source allowlist additions, aliases, and manually verified price exceptions still require human review. See the [release guide](docs/releasing.md) for setup, verification timing, and retries.
|
|
107
113
|
|
|
108
114
|
For a manual refresh, run `npm run prices:update` in a networked environment, review the generated diff and exceptions, then run the checks above. Builds and plugin refreshes do not contact models.dev.
|
|
109
115
|
|
|
110
|
-
`test:smoke` packages the real artifact and validates loading, refreshes, `/usage`, subagent aggregation, per-message pricing, first-party price fallback, model switching, TPS, and
|
|
116
|
+
`test:smoke` packages the real artifact and validates loading, refreshes, `/usage`, subagent aggregation, per-message pricing, first-party price fallback, model switching, TPS, TTFT, and session-local native turn durations (unfinished responses, idle gaps, interruption, message-history restoration, forks and narrow child views) 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.
|
|
111
117
|
|
|
112
118
|
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.
|
|
113
119
|
|
package/README.zh-CN.md
CHANGED
|
@@ -34,9 +34,9 @@ opencode plugin add @chenlongapps/opencode-token-usage
|
|
|
34
34
|
```
|
|
35
35
|
|
|
36
36
|
> [!NOTE]
|
|
37
|
-
> 需要 Node.js 22+(见[开发](#开发)中的 OpenTUI 引擎说明)。此前发布已验证 OpenCode 2.0.9 和 2.0.10
|
|
37
|
+
> 需要 Node.js 22+(见[开发](#开发)中的 OpenTUI 引擎说明)。此前发布已验证 OpenCode 2.0.9 和 2.0.10;SDK 2.0.24 集成检查已在 OpenCode 2.0.11、2.0.22、2.0.24 和 2.0.26 上通过。原生对话耗时的集成验证使用 2.0.26,其余列出的宿主检查早于该功能加入。
|
|
38
38
|
|
|
39
|
-
安装后重启 OpenCode。终端足够宽且 `session.sidebar` 设为 `auto` 时,面板会显示在原生侧边栏中。OpenCode 在子代理视图中隐藏侧边栏,因此插件会在输入区上方保留一行实时摘要,显示 Context、Total、Cost 和 TPS。点击摘要可在居中弹窗中查看完整统计;按 Escape 或点击 **esc** 即可关闭,关闭弹窗不会中断子代理。
|
|
39
|
+
安装后重启 OpenCode。终端足够宽且 `session.sidebar` 设为 `auto` 时,面板会显示在原生侧边栏中。OpenCode 在子代理视图中隐藏侧边栏,因此插件会在输入区上方保留一行实时摘要,显示 Context、Total、Cost、Time 和 TPS。点击摘要可在居中弹窗中查看完整统计;按 Escape 或点击 **esc** 即可关闭,关闭弹窗不会中断子代理。
|
|
40
40
|
|
|
41
41
|
连接远程服务器时,可将包名加入本机 `~/.config/opencode/cli.json` 的 `plugins`,仅加载终端入口;配置路径遵循 `XDG_CONFIG_HOME`。
|
|
42
42
|
|
|
@@ -44,7 +44,7 @@ opencode plugin add @chenlongapps/opencode-token-usage
|
|
|
44
44
|
|
|
45
45
|
在会话中输入 `/usage`,可打开原生弹窗查看当前会话树的 token 总量,以及按消息实际模型汇总的估算费用。使用 ↑/↓、Page Up/Down、Home/End 滚动,按 `d` 切换紧凑/详细数字并查看模型费率,Escape 关闭。该命令不会向模型发送消息。
|
|
46
46
|
|
|
47
|
-
子代理视图中的摘要格式为 `Token Usage · Context … · Total … · Cost … · TPS
|
|
47
|
+
子代理视图中的摘要格式为 `Token Usage · Context … · Total … · Cost … · Time … · TPS …`。Time 仅累计被查看子代理自身每轮原生显示的对话耗时,token 总量和费用仍统计整棵树。窄终端会省略优先级较低的字段,保留 Time 且不增加第二行。缺失数据仍显示不可用,不会被当作零,摘要也不提示快捷键。点击后打开较小的弹窗,按侧边栏原有行序展示精确数值(包括 Steps、TPS、TTFT 和 Run Time);关闭时不会为完整面板预留高度。Run Time 在侧边栏及该弹窗中均为最后一行。
|
|
48
48
|
|
|
49
49
|
弹窗分为五个区域,统计口径互相独立:
|
|
50
50
|
|
|
@@ -53,14 +53,16 @@ opencode plugin add @chenlongapps/opencode-token-usage
|
|
|
53
53
|
| Context Window | 已用 / 上限、占活动模型上下文上限的百分比,并带占用条 |
|
|
54
54
|
| Last Request | 被查看会话最近一次已上报调用的五类 token 与缓存命中率 |
|
|
55
55
|
| Context Breakdown | 提示词构成估算,按占比降序排列并带条形图 |
|
|
56
|
-
| Session | 全树汇总:steps、calls、tokens
|
|
56
|
+
| Session | 全树汇总:steps、calls、tokens、缓存率与费用(紧凑模式为单行,详细模式逐类展开);独立的 `Run Time (this session)` 行仅累计被查看会话每轮原生显示的对话耗时 |
|
|
57
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`、不可定价、免费口径。详细模式下,By Model 显示 Input、Output、Reasoning、Cache Read、Cache Write 的美元/百万文本 token 费率。每组费率注明来源(OpenCode 或内置快照)、传入 token 档位和调用数;同一模型可能有多组。传入 token 为 Input + Cache Read + Cache Write。缺失费率显示 `—`,确认免费显示 `$0.00
|
|
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 Window** 需要已上报用量和有效的活动模型上下文上限。**Last Request** 展示所选 assistant 调用的实测 token,上限未知或来源估算不可用时仍可显示。其时间取自同一条消息,依次选用有效的流式结束、完成、创建时间;均不可用时隐藏时间。请求选择遵循被查看会话的回退截断和最后一次已完成压缩边界。
|
|
64
|
+
|
|
65
|
+
弹窗中的 **Context Breakdown** 将消息、系统工具、系统提示词、技能、MCP 工具及其他分开展示。它根据被查看会话**最近一次已组装的模型请求**中的文本和工具定义估算,占比的分母是六项估算值之和。其时间为该估算自身的采样时间,可能与 Last Request 对应不同请求。这些不是提供商上报的精确 token,也不会与上方含输出、推理的实测上下文窗口相加一致。媒体内容与提供商额外包装无法准确计量。服务端插件仅保存分类数值,不保存提示词正文;未捕获到请求或服务端 RPC 不可用时显示“来源估算不可用”。
|
|
64
66
|
|
|
65
67
|
### 指标
|
|
66
68
|
|
|
@@ -74,6 +76,7 @@ opencode plugin add @chenlongapps/opencode-token-usage
|
|
|
74
76
|
| `Context` | 当前会话最后一次已完成压缩后的最新上下文用量,不随子树累计 |
|
|
75
77
|
| `Steps` | 全树(含子代理)的 assistant 消息数,口径与 OpenCode 自带统计一致,compaction 与用户消息不计为 step |
|
|
76
78
|
| `Est. Cost` | 按每条 assistant 与 compaction 消息实际模型估算的全树费用 |
|
|
79
|
+
| `Run Time` | 仅被查看会话自身的保留历史中,OpenCode 每轮原生显示的对话耗时之和,每轮只取最终已完成回答;不额外叠加子会话耗时 |
|
|
77
80
|
| `TPS` | 全树 `Output + Reasoning` 的生成吞吐。请求进行中时,插件根据首个流式增量之后最近的可观察文本、reasoning summary 和工具输入流(UTF-8 字节数 / 4)按短时间滑动窗口估算输出速度,不包含首 token 前等待时间,并以 `~` 标记。OpenAI Responses 等 API 不会实时暴露完整的隐藏 reasoning token,因此实时值不能表示隐藏推理吞吐。请求完成后,插件使用服务端上报的 Output 与 Reasoning token 及时间信息计算精确 TPS,并移除 `~` 标记 |
|
|
78
81
|
| `TTFT` | 全树中可测 assistant step 的平均首 token 时间 |
|
|
79
82
|
|
|
@@ -83,9 +86,12 @@ opencode plugin add @chenlongapps/opencode-token-usage
|
|
|
83
86
|
- 分叉会话是独立会话树。继承消息副本只归原始来源,避免重复计费。
|
|
84
87
|
- `Context` 仅搜索最后一次 `status === "completed"` 的 compaction 之后;没有可靠用量或模型上限时隐藏。
|
|
85
88
|
- `Steps` 统计树中全部 assistant 消息,无论是否已上报用量;复用分叉副本去重,继承历史不会重复计数。
|
|
89
|
+
- `Run Time` 沿用 OpenCode 2.0.26 的原生回答页脚口径:assistant 完成时间减去上一条 idle 标记之后首个 user/synthetic 输入的创建时间。没有 idle 标记的旧历史使用最近一条输入;没有输入则回退到 assistant 自身的创建时间。每轮只计最终已完成回答的耗时,不把各条 assistant step 的重叠时长重复相加。包含跨度内的模型调用、工具、重试和前台等待,不含轮次之间的空闲;父会话等待子代理的时间已在父会话跨度内,不再额外叠加子代理耗时。
|
|
90
|
+
- `Run Time` 在已完成回答上报后更新,不用实时计时器估算未完成回答。失败/中断回答有原生完成时间戳时同样累计。先累加未取整的毫秒值,再格式化(不足一秒显示毫秒,不足一分钟的秒值保留一位小数,其后显示分/小时),因此不一定等于相加各轮已取整的页脚标签。复用现有消息历史读取,重启后可恢复,也适用于安装之前的会话;不依赖实验性执行日志 API 或服务端插件。历史计时数据缺失时显示 `—`,不会显示伪零。读取失败保留上次完整值并标注 `Not updated`(短摘要中为 `stale`)。
|
|
91
|
+
- 耗时随保留历史变化:切换 agent/模型与压缩保留过去轮次;分叉继承副本不计入分叉自身耗时。暂存回退不会删除轮次,但已提交回退删除消息后,也会移除这些消息对应的耗时;已删除的历史计时无法恢复。
|
|
86
92
|
- `Est. Cost` 按每条消息记录的实际模型分别计算。OpenCode 当前解析出的完整非零价格优先;若 OpenCode 给出完整零价,且内置价格快照可以完整计价,则采用快照价格。价格不完整时整条消息回退,不混用两套费率。
|
|
87
93
|
- 内置回退快照从 [models.dev](https://models.dev/api.json) 生成,只采用审核过的原厂 Provider 与自家模型系列;少量经原厂核实的例外单独维护。快照覆盖有价格的文本模型,插件运行时不下载价格。网关模型通过精确原厂 ID、明确别名和已知包装格式匹配;只有终尾 `-free` 或 `:free` 会在再次精确查找前移除。
|
|
88
|
-
- 确认免费时显示 `$0.00
|
|
94
|
+
- 确认免费时显示 `$0.00`;正费用低于 `$0.001` 时显示 `<$0.001`,不超过 `$0.01` 的费用保留三位小数,更高费用保留两位。价格不可用时显示 `—`;只有部分消息可计算时在已知小计后标注 `partial`。快照不含网关加价、区域溢价、未列明的折扣、非文本计费、工具费和税费;Est. Cost 是估算而非账单。覆盖范围、来源和限制见[价格来源与限制](docs/pricing.md)。
|
|
89
95
|
- 带 `~` 的实时 `TPS` 是首个流式增量之后约 2 秒滑动窗口内可观察增量的估算值(带轻度平滑),不包含 TTFT 与隐藏推理等待;不带 `~` 时为服务端上报的 `Output + Reasoning` 精确吞吐。不做模型专属倍率,也不猜测隐藏 reasoning token。
|
|
90
96
|
- 首次读取失败显示 `Unavailable`;后续失败保留上次完整快照、标注 `Not updated` 并自动重试。
|
|
91
97
|
|
|
@@ -103,11 +109,11 @@ OpenCode SDK 包精确锁定为 `2.0.24`,OpenTUI 为 `0.5.14`。Node.js 22 下
|
|
|
103
109
|
|
|
104
110
|
[价格更新工作流](.github/workflows/update-prices.yml) 每天 UTC 22:00 检查 models.dev,也可在 `main` 上通过 **Run workflow** 手动触发。快照无变化就不发布。经审核的原厂来源白名单内有变化时,先通过类型检查、测试、构建和打包检查,再自动升级 patch 版本(例如 `0.4.4` → `0.4.5`),只提交 `src/prices.generated.ts`、`package.json` 和 `package-lock.json`,并原子推送 `main` 与版本标签。工作流创建 GitHub Release,显式触发该标签上的 [`publish.yml`](.github/workflows/publish.yml),通过 npm Trusted Publishing/OIDC 发布,无需长期 npm token。仓库必须允许 GitHub Actions 推送 `main` 和创建标签;npm 必须信任 `publish.yml` 并允许执行 `npm publish`。
|
|
105
111
|
|
|
106
|
-
验证失败不会升版本。已打标签但未完成的自动价格发布会优先重试同一版本,再检查新价格;registry 故障不会被当作版本不存在。发布时跳过 npm 已有版本、防止 `latest`
|
|
112
|
+
验证失败不会升版本。已打标签但未完成的自动价格发布会优先重试同一版本,再检查新价格;registry 故障不会被当作版本不存在。发布时跳过 npm 已有版本、防止 `latest` 倒退,并在发布后最多轮询 10 分钟核验 registry 可见性,输出进度。校验超时不代表 `npm publish` 失败:先检查 registry,再重试原标签,不另升版本;已发布版本不会重复发布。新增原厂/来源白名单、别名及人工核实的价格例外仍需人工审核。配置、校验时限和重试方法见[发布说明](docs/releasing.md)。
|
|
107
113
|
|
|
108
114
|
手动更新时,在联网的维护环境运行 `npm run prices:update`,审阅生成文件及例外差异,再执行上述检查。构建和插件刷新不会请求 models.dev。
|
|
109
115
|
|
|
110
|
-
`test:smoke` 会打包真实产物,并在隔离的 OpenCode 与本地模拟提供商中验证加载、刷新、`/usage`、子代理累计、逐消息计价、原厂价格补全、模型切换、TPS
|
|
116
|
+
`test:smoke` 会打包真实产物,并在隔离的 OpenCode 与本地模拟提供商中验证加载、刷新、`/usage`、子代理累计、逐消息计价、原厂价格补全、模型切换、TPS、TTFT,以及会话自身的原生对话耗时(未完成回答、空闲、中断、消息历史恢复、分叉和窄终端子代理视图)。它需要 Python 3、可用的本地端口和 npm 网络访问,不会修改现有 OpenCode 配置或调用付费模型。
|
|
111
117
|
|
|
112
118
|
默认使用 `PATH` 中的 `opencode`。OpenCode 2.0.9 及之后的稳定版 2.x 可以运行;尚未与当前 SDK 验证的宿主会输出警告,然后继续实际兼容性检查。仅通过版本检查不代表兼容性已验证。SDK 依赖仍独立锁定,不随本机 CLI 更新而自动升级。
|
|
113
119
|
|
package/ROADMAP.md
CHANGED
|
@@ -105,9 +105,17 @@ Steps 1,234
|
|
|
105
105
|
|
|
106
106
|
* [x] 在 `/usage` 中显示模型的定价信息。
|
|
107
107
|
|
|
108
|
+
## 开发中 — 会话累计对话耗时(未发布)
|
|
109
|
+
|
|
110
|
+
* [x] 按 OpenCode 2.0.26 原生回答页脚口径累计当前查看会话的全部保留轮次,每轮只取最终已完成回答
|
|
111
|
+
* [x] 不计轮次之间的空闲,不额外叠加子代理耗时,未完成回答不做实时估算
|
|
112
|
+
* [x] 复用完整消息历史,重开会话和插件重启后恢复累计值,不依赖执行日志 API
|
|
113
|
+
* [x] 侧边栏 `Run Time`、子代理单行 `Time`、`/usage` 的 `Run Time (this session)` 三处显示
|
|
114
|
+
* [x] 时间缺失显示不可用,读取失败保留旧值并标注未更新;分叉副本不继承耗时,已提交回退随删除历史扣除
|
|
115
|
+
* [x] 通过类型检查、160 项测试、构建及真实 OpenCode 2.0.26 打包集成验证
|
|
116
|
+
|
|
108
117
|
## 后续评估
|
|
109
118
|
|
|
110
119
|
* [x] 首次 npm 发布:`@chenlongapps/opencode-token-usage@0.2.1`
|
|
111
120
|
|
|
112
121
|
## 待解决问题
|
|
113
|
-
|
|
@@ -3,20 +3,21 @@ import type { Plugin } from "@opencode/plugin";
|
|
|
3
3
|
import type { ContextSources } from "./context-sources.js";
|
|
4
4
|
type Request = Pick<SessionContext, "sessionID" | "model" | "system" | "messages" | "tools">;
|
|
5
5
|
type Mcp = {
|
|
6
|
-
list(): Promise<{
|
|
6
|
+
list(...args: Parameters<Plugin.Context["mcp"]["list"]>): Promise<{
|
|
7
7
|
data: readonly {
|
|
8
8
|
name: string;
|
|
9
9
|
}[];
|
|
10
10
|
}>;
|
|
11
11
|
};
|
|
12
12
|
type Storage = Pick<Plugin.Context["storage"], "get" | "set" | "remove">;
|
|
13
|
-
/**
|
|
13
|
+
/** MCP metadata waits are bounded; persistence never delays the model request. */
|
|
14
14
|
export declare class ContextCapture {
|
|
15
15
|
private readonly mcp;
|
|
16
16
|
private readonly storage;
|
|
17
17
|
private readonly ttl;
|
|
18
18
|
private readonly now;
|
|
19
19
|
private readonly elapsed;
|
|
20
|
+
private readonly mcpTimeoutMs;
|
|
20
21
|
private names;
|
|
21
22
|
private expiresAt;
|
|
22
23
|
private listing;
|
|
@@ -31,8 +32,9 @@ export declare class ContextCapture {
|
|
|
31
32
|
storageWrites: number;
|
|
32
33
|
storageWriteMs: number;
|
|
33
34
|
};
|
|
34
|
-
constructor(mcp: Mcp, storage: Storage, ttl?: number, now?: () => number, elapsed?: () => number);
|
|
35
|
+
constructor(mcp: Mcp, storage: Storage, ttl?: number, now?: () => number, elapsed?: () => number, mcpTimeoutMs?: number);
|
|
35
36
|
private servers;
|
|
37
|
+
private readServers;
|
|
36
38
|
invalidateMcp(): void;
|
|
37
39
|
capture(request: Request): Promise<void>;
|
|
38
40
|
private record;
|
package/dist/context-capture.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { estimateContextSources, parseContextSources } from "./context-sources.js";
|
|
2
|
-
/**
|
|
2
|
+
/** MCP metadata waits are bounded; persistence never delays the model request. */
|
|
3
3
|
export class ContextCapture {
|
|
4
4
|
mcp;
|
|
5
5
|
storage;
|
|
6
6
|
ttl;
|
|
7
7
|
now;
|
|
8
8
|
elapsed;
|
|
9
|
+
mcpTimeoutMs;
|
|
9
10
|
names;
|
|
10
11
|
expiresAt = 0;
|
|
11
12
|
listing;
|
|
@@ -14,32 +15,48 @@ export class ContextCapture {
|
|
|
14
15
|
latestEstimate = new Map();
|
|
15
16
|
deleted = new Set();
|
|
16
17
|
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
|
+
constructor(mcp, storage, ttl = 30_000, now = Date.now, elapsed = () => performance.now(), mcpTimeoutMs = 100) {
|
|
18
19
|
this.mcp = mcp;
|
|
19
20
|
this.storage = storage;
|
|
20
21
|
this.ttl = ttl;
|
|
21
22
|
this.now = now;
|
|
22
23
|
this.elapsed = elapsed;
|
|
24
|
+
this.mcpTimeoutMs = mcpTimeoutMs;
|
|
23
25
|
}
|
|
24
26
|
async servers() {
|
|
25
27
|
if (this.names && this.now() < this.expiresAt)
|
|
26
28
|
return this.names;
|
|
27
29
|
if (!this.listing) {
|
|
28
30
|
this.timings.mcpReads++;
|
|
29
|
-
this.listing = this.
|
|
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; });
|
|
31
|
+
this.listing = this.readServers().finally(() => { this.listing = undefined; });
|
|
40
32
|
}
|
|
41
33
|
return this.listing;
|
|
42
34
|
}
|
|
35
|
+
async readServers() {
|
|
36
|
+
const controller = new AbortController();
|
|
37
|
+
let timer;
|
|
38
|
+
try {
|
|
39
|
+
const timeout = new Promise((_, reject) => {
|
|
40
|
+
timer = setTimeout(() => {
|
|
41
|
+
const error = new DOMException("MCP metadata read timed out", "TimeoutError");
|
|
42
|
+
reject(error);
|
|
43
|
+
controller.abort(error);
|
|
44
|
+
}, this.mcpTimeoutMs);
|
|
45
|
+
});
|
|
46
|
+
const result = await Promise.race([this.mcp.list(undefined, { signal: controller.signal }), timeout]);
|
|
47
|
+
// Only the bounded read may update the cache, even if cancellation is ignored.
|
|
48
|
+
this.names = result.data.map(server => server.name);
|
|
49
|
+
this.expiresAt = this.now() + this.ttl;
|
|
50
|
+
return this.names;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// Reuse stale metadata without renewing it, so the next request can retry.
|
|
54
|
+
return this.names;
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
clearTimeout(timer);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
43
60
|
invalidateMcp() { this.expiresAt = 0; }
|
|
44
61
|
capture(request) {
|
|
45
62
|
const tasks = this.inflight.get(request.sessionID) ?? new Set();
|
|
@@ -64,7 +81,7 @@ export class ContextCapture {
|
|
|
64
81
|
this.timings.requests++;
|
|
65
82
|
this.timings.mcpWaitMs += this.elapsed() - start;
|
|
66
83
|
}
|
|
67
|
-
if (this.deleted.has(request.sessionID))
|
|
84
|
+
if (!names || this.deleted.has(request.sessionID))
|
|
68
85
|
return;
|
|
69
86
|
const value = estimateContextSources(request, names);
|
|
70
87
|
this.latestEstimate.set(request.sessionID, value);
|
package/dist/controller.d.ts
CHANGED
|
@@ -1,19 +1,21 @@
|
|
|
1
1
|
import type { UsageSource } from "./source.js";
|
|
2
2
|
import { PerformanceMonitor } from "./performance.js";
|
|
3
3
|
import type { PerformanceSummary } from "./performance.js";
|
|
4
|
+
import type { RuntimeSummary } from "./runtime.js";
|
|
4
5
|
import type { ContextSources } from "./context-sources.js";
|
|
5
|
-
import type {
|
|
6
|
+
import type { ContextUsage, ModelCost, RequestDetails, Summary } from "./usage.js";
|
|
6
7
|
export interface UsageState {
|
|
7
8
|
status: "loading" | "ready" | "stale" | "unavailable";
|
|
8
9
|
summary?: Summary;
|
|
9
10
|
context?: ContextUsage | undefined;
|
|
10
11
|
details?: {
|
|
11
|
-
|
|
12
|
+
request?: RequestDetails | undefined;
|
|
12
13
|
models: readonly ModelCost[];
|
|
13
14
|
sources?: ContextSources | undefined;
|
|
14
15
|
sessionTitle?: string | undefined;
|
|
15
16
|
};
|
|
16
17
|
performance?: PerformanceSummary;
|
|
18
|
+
runtime?: RuntimeSummary;
|
|
17
19
|
model?: string;
|
|
18
20
|
}
|
|
19
21
|
export interface UsageEvent {
|
package/dist/controller.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { loadSnapshot, uniqueMessages, viewedMessages } from "./source.js";
|
|
2
2
|
import { PerformanceMonitor, preparePerformance } from "./performance.js";
|
|
3
|
-
import {
|
|
3
|
+
import { summarizeRuntime } from "./runtime.js";
|
|
4
|
+
import { contextUsage, requestDetails, summarize, summarizeModels } from "./usage.js";
|
|
4
5
|
const sessionEvents = new Set([
|
|
5
6
|
"session.created", "session.deleted", "session.forked", "session.moved",
|
|
6
7
|
"session.model.selected", "session.agent.selected", "session.step.ended", "session.step.failed",
|
|
@@ -95,7 +96,7 @@ export class UsageController {
|
|
|
95
96
|
this.changed.clear();
|
|
96
97
|
this.fullScan = true;
|
|
97
98
|
this.sessionID = sessionID;
|
|
98
|
-
this.update({ status: "loading" });
|
|
99
|
+
this.update({ status: "loading", runtime: { status: "loading" } });
|
|
99
100
|
void this.run();
|
|
100
101
|
}
|
|
101
102
|
refresh() {
|
|
@@ -164,18 +165,21 @@ export class UsageController {
|
|
|
164
165
|
this.prepared = preparePerformance(messages);
|
|
165
166
|
clearTimeout(this.performanceTimer);
|
|
166
167
|
this.performanceTimer = undefined;
|
|
167
|
-
const
|
|
168
|
+
const history = viewedMessages(snapshot);
|
|
169
|
+
const session = snapshot.sessions.get(snapshot.viewedID);
|
|
170
|
+
const revertMessageID = session.revert?.messageID;
|
|
168
171
|
this.update({
|
|
169
172
|
status: "ready",
|
|
170
173
|
summary: summarize(messages, snapshot.model.catalog),
|
|
171
|
-
context: context
|
|
172
|
-
...(this.detailed ? { details: {
|
|
174
|
+
context: contextUsage(history, snapshot.model.context, revertMessageID),
|
|
175
|
+
...(this.detailed ? { details: { request: requestDetails(history, revertMessageID), models: summarizeModels(messages, snapshot.model.catalog), sessionTitle: session.title } } : {}),
|
|
173
176
|
performance: this.performance.summaryPrepared(this.prepared, this.sessions),
|
|
177
|
+
runtime: summarizeRuntime(history, !!session.fork),
|
|
174
178
|
model: snapshot.model.label,
|
|
175
179
|
});
|
|
176
180
|
// The server plugin may be absent. An optional RPC must not delay measured usage.
|
|
177
181
|
if (this.detailed && this.source.composition) {
|
|
178
|
-
void this.source.composition(
|
|
182
|
+
void this.source.composition(session, AbortSignal.any([request.signal, AbortSignal.timeout(5_000)]))
|
|
179
183
|
.then(sources => {
|
|
180
184
|
if (this.disposed || generation !== this.generation || this.snapshot !== snapshot || this.state.status !== "ready" || !this.state.details)
|
|
181
185
|
return;
|
|
@@ -188,7 +192,11 @@ export class UsageController {
|
|
|
188
192
|
if (this.disposed || generation !== this.generation)
|
|
189
193
|
return;
|
|
190
194
|
this.fullScan = true;
|
|
191
|
-
this.update({
|
|
195
|
+
this.update({
|
|
196
|
+
...this.state, status: this.state.summary ? "stale" : "unavailable",
|
|
197
|
+
runtime: this.state.runtime?.milliseconds === undefined
|
|
198
|
+
? { status: "unavailable" } : { ...this.state.runtime, status: "stale" },
|
|
199
|
+
});
|
|
192
200
|
this.retry = setTimeout(() => this.schedule(false), this.retryDelay);
|
|
193
201
|
}
|
|
194
202
|
finally {
|
package/dist/performance.js
CHANGED
|
@@ -262,6 +262,10 @@ export class PerformanceMonitor {
|
|
|
262
262
|
const cutoff = event.created - LIVE_WINDOW_MS;
|
|
263
263
|
while (step.samples.length > 0 && step.samples[0].time < cutoff)
|
|
264
264
|
step.samples.shift();
|
|
265
|
+
// Advance smoothing once per accepted delta, independently of summary readers.
|
|
266
|
+
const raw = estimatedLiveTps(step.samples);
|
|
267
|
+
if (raw !== undefined)
|
|
268
|
+
step.liveTps = smoothLiveTps(step.liveTps, raw);
|
|
265
269
|
step.last = Math.max(step.last ?? event.created, event.created);
|
|
266
270
|
if (step.first === undefined) {
|
|
267
271
|
step.first = event.created;
|
|
@@ -309,15 +313,13 @@ export class PerformanceMonitor {
|
|
|
309
313
|
for (const step of this.active.values()) {
|
|
310
314
|
if (!sessions.has(step.sessionID) || step.reconciledAt !== undefined)
|
|
311
315
|
continue;
|
|
312
|
-
|
|
313
|
-
if (raw === undefined)
|
|
316
|
+
if (step.liveTps === undefined || step.samples.length < 2)
|
|
314
317
|
continue;
|
|
315
318
|
const first = step.samples[0];
|
|
316
319
|
const last = step.samples[step.samples.length - 1];
|
|
317
320
|
const duration = last.time - first.time;
|
|
318
321
|
if (!finite(duration) || duration <= 0)
|
|
319
322
|
continue;
|
|
320
|
-
step.liveTps = smoothLiveTps(step.liveTps, raw);
|
|
321
323
|
weighted += step.liveTps * duration;
|
|
322
324
|
durationTotal += duration;
|
|
323
325
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { UsageMessage } from "./usage.js";
|
|
2
|
+
export interface RuntimeSummary {
|
|
3
|
+
status: "loading" | "ready" | "stale" | "unavailable";
|
|
4
|
+
/** Sum of one native response duration per turn in the viewed session's retained history. */
|
|
5
|
+
milliseconds?: number | undefined;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* OpenCode 2.0.26's TUI turnDuration/inputIndex: completed assistant time minus
|
|
9
|
+
* the first user/synthetic input after the previous idle marker, or the nearest
|
|
10
|
+
* input in legacy histories with no idle markers. With no input it falls back
|
|
11
|
+
* to the assistant's creation time. Take only the final response in each turn:
|
|
12
|
+
* intermediate footer durations overlap and must never be added separately.
|
|
13
|
+
* No execution-log API, client clock, child history, or additional reads.
|
|
14
|
+
*/
|
|
15
|
+
export declare function summarizeRuntime(messages: readonly UsageMessage[], fork?: boolean): RuntimeSummary;
|
package/dist/runtime.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
const timestamp = (value) => typeof value === "number" && value >= 0
|
|
2
|
+
&& Number.isFinite(new Date(value).getTime());
|
|
3
|
+
/**
|
|
4
|
+
* OpenCode 2.0.26's TUI turnDuration/inputIndex: completed assistant time minus
|
|
5
|
+
* the first user/synthetic input after the previous idle marker, or the nearest
|
|
6
|
+
* input in legacy histories with no idle markers. With no input it falls back
|
|
7
|
+
* to the assistant's creation time. Take only the final response in each turn:
|
|
8
|
+
* intermediate footer durations overlap and must never be added separately.
|
|
9
|
+
* No execution-log API, client clock, child history, or additional reads.
|
|
10
|
+
*/
|
|
11
|
+
export function summarizeRuntime(messages, fork = false) {
|
|
12
|
+
// Even a copied marker identifies the host's turn semantics for an open fork.
|
|
13
|
+
const legacy = !messages.some(message => message.type === "idle");
|
|
14
|
+
// Fork copies belong to the original session, including copied turn boundaries.
|
|
15
|
+
const history = fork ? messages.filter(message => !/_\d+$/.test(message.id)) : messages;
|
|
16
|
+
let milliseconds = 0, unavailable = false;
|
|
17
|
+
let input;
|
|
18
|
+
let response, completedResponse;
|
|
19
|
+
const finish = (closed) => {
|
|
20
|
+
if (!response)
|
|
21
|
+
return;
|
|
22
|
+
let selected = response;
|
|
23
|
+
if (selected.message.time?.completed === undefined) {
|
|
24
|
+
// A live tail has no displayed duration yet. Missing closed-turn metadata
|
|
25
|
+
// is different: don't disguise an unknown historical subtotal as zero.
|
|
26
|
+
const message = selected.message;
|
|
27
|
+
if (closed || !timestamp(message.time?.created) || !timestamp(selected.created) || message.finish || message.error || message.retry) {
|
|
28
|
+
unavailable = true;
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
// A steer can start another response before idle. Keep the previously
|
|
32
|
+
// displayed footer until a newer completed response replaces it.
|
|
33
|
+
if (!completedResponse)
|
|
34
|
+
return;
|
|
35
|
+
selected = completedResponse;
|
|
36
|
+
}
|
|
37
|
+
const completed = selected.message.time?.completed;
|
|
38
|
+
const created = selected.created;
|
|
39
|
+
if (!timestamp(created) || !timestamp(completed)) {
|
|
40
|
+
unavailable = true;
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
milliseconds += Math.max(0, completed - created);
|
|
44
|
+
};
|
|
45
|
+
for (const message of history) {
|
|
46
|
+
if (message.type === "idle") {
|
|
47
|
+
finish(true);
|
|
48
|
+
input = undefined;
|
|
49
|
+
response = completedResponse = undefined;
|
|
50
|
+
}
|
|
51
|
+
else if (message.type === "user" || message.type === "synthetic") {
|
|
52
|
+
if (legacy) {
|
|
53
|
+
finish(true);
|
|
54
|
+
input = undefined;
|
|
55
|
+
response = completedResponse = undefined;
|
|
56
|
+
}
|
|
57
|
+
input ??= message;
|
|
58
|
+
}
|
|
59
|
+
else if (message.type === "assistant") {
|
|
60
|
+
// Matches native footer eligibility. Older imported messages can lack
|
|
61
|
+
// finish metadata; their completed timestamp still permits a duration.
|
|
62
|
+
if (!message.error && !message.retry && (message.finish === "tool-calls" || message.finish === "unknown"))
|
|
63
|
+
continue;
|
|
64
|
+
response = { message, created: input ? input.time?.created : message.time?.created };
|
|
65
|
+
if (message.time?.completed !== undefined)
|
|
66
|
+
completedResponse = response;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
finish(false);
|
|
70
|
+
return unavailable || !Number.isFinite(milliseconds)
|
|
71
|
+
? { status: "unavailable" } : { status: "ready", milliseconds };
|
|
72
|
+
}
|