oh-my-tps 0.1.0
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 +182 -0
- package/README.zh-CN.md +178 -0
- package/extensions/oh-my-tps.ts +209 -0
- package/extensions/shared/content.ts +26 -0
- package/extensions/shared/token-estimator.ts +6 -0
- package/package.json +49 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 EnderLiquid
|
|
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,182 @@
|
|
|
1
|
+
# Oh My TPS
|
|
2
|
+
|
|
3
|
+
English | [简体中文](./README.zh-CN.md)
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
### npm package
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pi install npm:oh-my-tps
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
### Git repository
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pi install git:github.com/EnderLiquid/oh-my-tps
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## What it does
|
|
20
|
+
|
|
21
|
+
`oh-my-tps` does one thing:
|
|
22
|
+
it adds a tiny live speed readout to the Pi TUI so you can see first-token latency and output speed while the model is responding.
|
|
23
|
+
|
|
24
|
+
- `τ`: TTFT, time to first token, in seconds
|
|
25
|
+
- `Δ`: TPS, tokens per second
|
|
26
|
+
|
|
27
|
+
What it looks like:
|
|
28
|
+
|
|
29
|
+
```text
|
|
30
|
+
τ0.8 Δ48.6
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
That's it.
|
|
34
|
+
Ten characters. It just works.
|
|
35
|
+
|
|
36
|
+
If you want, you can keep reading for the details—but at this point you already know how to use it.
|
|
37
|
+
|
|
38
|
+
## Reading the numbers
|
|
39
|
+
|
|
40
|
+
You will see readings like these in the TUI footer area:
|
|
41
|
+
|
|
42
|
+
```text
|
|
43
|
+
τ0.8 Δ48.6
|
|
44
|
+
τ1.1 Δ49.7L
|
|
45
|
+
τ0.8A Δ52.4A
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Suffixes:
|
|
49
|
+
|
|
50
|
+
- `A`: Average, the average final value across recent requests
|
|
51
|
+
- `L`: Last, the final value from the previous request
|
|
52
|
+
|
|
53
|
+
A quick way to read them:
|
|
54
|
+
|
|
55
|
+
- `τ0.8 Δ48.6`: the response is currently streaming; TTFT was about 0.8s and the current live TPS estimate is about 48.6
|
|
56
|
+
- `τ1.1 Δ49.7L`: the request has been sent, but streaming has not started yet; TTFT is still counting, so the extension shows the previous request's final TPS as a reference
|
|
57
|
+
- `τ0.8A Δ52.4A`: Pi is currently idle, so the extension shows the recent average performance
|
|
58
|
+
|
|
59
|
+
The live `Δ` shown during streaming is an estimate. The final `Δ` shown after the response ends is more trustworthy.
|
|
60
|
+
|
|
61
|
+
## How it works
|
|
62
|
+
|
|
63
|
+
This section is for people who want to know what the extension is actually measuring.
|
|
64
|
+
|
|
65
|
+
### State machine
|
|
66
|
+
|
|
67
|
+
Internally, the extension moves through four phases:
|
|
68
|
+
|
|
69
|
+
1. **waiting**: the request has been sent and is waiting for the first token
|
|
70
|
+
2. **streaming**: the assistant is actively streaming output
|
|
71
|
+
3. **settled**: the response has finished
|
|
72
|
+
4. **idle**: the turn is over and the extension is showing historical values
|
|
73
|
+
|
|
74
|
+
Example:
|
|
75
|
+
|
|
76
|
+
```text
|
|
77
|
+
idle τ… Δ? (before the very first request)
|
|
78
|
+
-> waiting(req1) τ0.2 Δ? (first request in the prompt, no usable average yet, τ updates every 200ms)
|
|
79
|
+
...
|
|
80
|
+
-> idle τ1.5A Δ50.0A (idle, with historical averages available)
|
|
81
|
+
-> waiting(req1) τ0.2 Δ50.0A (first request in the prompt, shows average as baseline while waiting)
|
|
82
|
+
-> streaming(req1) τ1.3 Δ51.0 (live Δ updates, τ is now locked)
|
|
83
|
+
-> settled(req1) τ1.3 Δ52.0 (final Δ locked, τ locked)
|
|
84
|
+
-> waiting(req2+) τ0.2 Δ52.0L (second or later request in the same prompt, uses last final value as baseline)
|
|
85
|
+
-> streaming(req2+) τ1.7 Δ49.0 (live Δ updates, τ locked)
|
|
86
|
+
-> settled(req2+) τ1.7 Δ49.5 (final Δ locked, τ locked)
|
|
87
|
+
-> idle τ1.5A Δ50.0A
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### Where `τ` comes from
|
|
91
|
+
|
|
92
|
+
`τ` is straightforward.
|
|
93
|
+
|
|
94
|
+
Once a provider request is sent, the extension enters `waiting` and refreshes the elapsed time every 200ms. The moment the first assistant streaming update arrives, that time delta is locked in as the TTFT for the request.
|
|
95
|
+
|
|
96
|
+
So in practice:
|
|
97
|
+
|
|
98
|
+
- during `waiting`, `τ` keeps increasing
|
|
99
|
+
- once `streaming` begins, `τ` stops changing
|
|
100
|
+
- the `τ` shown later in `settled`, the historical `τ` reused before the next request, and the `τ` that contributes to idle averages are all based on that final locked TTFT
|
|
101
|
+
|
|
102
|
+
### Where live `Δ` and final `Δ` come from
|
|
103
|
+
|
|
104
|
+
These two values come from different sources, and that distinction matters.
|
|
105
|
+
|
|
106
|
+
#### Live `Δ`
|
|
107
|
+
|
|
108
|
+
During streaming, the provider does not continuously tell Pi exactly how many new output tokens just arrived. That means live `Δ` has to be estimated locally.
|
|
109
|
+
|
|
110
|
+
The current implementation does this:
|
|
111
|
+
|
|
112
|
+
1. Take all assistant text that has streamed so far for the current response
|
|
113
|
+
2. Estimate how many tokens that text roughly corresponds to with [`tokenx`](https://github.com/johannschopplich/tokenx)
|
|
114
|
+
3. Divide that estimate by the elapsed streaming time
|
|
115
|
+
|
|
116
|
+
In other words, live `Δ` is essentially:
|
|
117
|
+
|
|
118
|
+
```text
|
|
119
|
+
estimated output tokens so far / elapsed streaming time so far
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
It is not the provider's real-time token truth. It is a local approximation meant for UI feedback.
|
|
123
|
+
|
|
124
|
+
#### Final settled `Δ`
|
|
125
|
+
|
|
126
|
+
When the response ends, if the provider returns `usage.output`, the extension uses that to compute the final TPS:
|
|
127
|
+
|
|
128
|
+
```text
|
|
129
|
+
final output tokens / total streaming time
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
This is usually more trustworthy than live `Δ`, because it is based on the provider's final reported output token count rather than a local estimate.
|
|
133
|
+
|
|
134
|
+
If a provider or a specific response does not return usable output token data, the extension falls back to the last live estimate as a best-effort display value.
|
|
135
|
+
|
|
136
|
+
### Why live and final values can differ
|
|
137
|
+
|
|
138
|
+
#### 1. Token estimation is heuristic
|
|
139
|
+
|
|
140
|
+
`tokenx` is not an exact tokenizer. It is a lightweight heuristic estimator. That is why it works well for fast UI updates: it is small, fast, and easy to run on every streaming update.
|
|
141
|
+
|
|
142
|
+
The tradeoff is obvious: it is not designed to match every model family exactly.
|
|
143
|
+
|
|
144
|
+
`tokenx` is designed and benchmarked closer to **GPT-style tokenization / English text**. When you use other model families or output that contains non-English text, the live estimate can drift further away from the final settled value.
|
|
145
|
+
|
|
146
|
+
#### 2. Streaming itself is uneven
|
|
147
|
+
|
|
148
|
+
Model output does not arrive in the UI as a perfectly uniform token-by-token stream. The observed readout is affected by things like:
|
|
149
|
+
|
|
150
|
+
- the provider's own SSE / chunk flush strategy
|
|
151
|
+
- how Pi receives and surfaces updates
|
|
152
|
+
- structural changes caused by thinking blocks, tool calls, and normal text appearing together
|
|
153
|
+
|
|
154
|
+
So live `Δ` typically behaves like this:
|
|
155
|
+
|
|
156
|
+
- unstable at first, then gradually settles
|
|
157
|
+
- often approaches the final settled value, but does not perfectly match it
|
|
158
|
+
|
|
159
|
+
### Average value `A`
|
|
160
|
+
|
|
161
|
+
In the current implementation, `A` means the average final performance across the most recent 5 provider requests.
|
|
162
|
+
|
|
163
|
+
- average `τ`: the average final TTFT across those recent requests
|
|
164
|
+
- average `Δ`: the average settled TPS across those recent requests
|
|
165
|
+
|
|
166
|
+
### How to interpret the data
|
|
167
|
+
|
|
168
|
+
A good rule of thumb is:
|
|
169
|
+
|
|
170
|
+
- `τ`: highly useful
|
|
171
|
+
- settled / average `Δ`: the most useful numbers when comparing results
|
|
172
|
+
- live `Δ`: reflects real-time trend and perceived speed
|
|
173
|
+
|
|
174
|
+
## Where it fits
|
|
175
|
+
|
|
176
|
+
- useful as a rough quantitative reference for LLM latency and speed
|
|
177
|
+
- useful for quickly spotting obviously slow requests in long Pi sessions
|
|
178
|
+
- not meant for strict model benchmarking
|
|
179
|
+
|
|
180
|
+
## License
|
|
181
|
+
|
|
182
|
+
MIT License
|
package/README.zh-CN.md
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# Oh My TPS
|
|
2
|
+
|
|
3
|
+
[English](./README.md) | 简体中文
|
|
4
|
+
|
|
5
|
+
## 安装
|
|
6
|
+
|
|
7
|
+
### npm package
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pi install npm:oh-my-tps
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
### Git repository
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pi install git:github.com/EnderLiquid/oh-my-tps
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## 功能速览
|
|
20
|
+
|
|
21
|
+
`oh-my-tps` 只做一件事:
|
|
22
|
+
给 Pi TUI 加一组实时速度读数,测量 LLM 首字延迟和输出速度。
|
|
23
|
+
|
|
24
|
+
- `τ`:TTFT,首个 token 到达前等了多久,单位秒
|
|
25
|
+
- `Δ`:TPS,每秒输出多少 token
|
|
26
|
+
|
|
27
|
+
显示效果:
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
τ0.8 Δ48.6
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
就这么多。
|
|
34
|
+
十个字符的空间,开箱即用的体验。
|
|
35
|
+
|
|
36
|
+
感兴趣可以继续往下看,但到这里你其实已经会用了。
|
|
37
|
+
|
|
38
|
+
## 读数详解
|
|
39
|
+
|
|
40
|
+
你会在 TUI 底部状态区域看到这样的读数:
|
|
41
|
+
|
|
42
|
+
```text
|
|
43
|
+
τ0.8 Δ48.6
|
|
44
|
+
τ1.1 Δ49.7L
|
|
45
|
+
τ0.8A Δ52.4A
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
这里后缀的含义:
|
|
49
|
+
|
|
50
|
+
- `A`:Average,最近几次请求的平均最终值
|
|
51
|
+
- `L`:Last,上一次请求的最终值
|
|
52
|
+
|
|
53
|
+
可以直接这样理解:
|
|
54
|
+
|
|
55
|
+
- `τ0.8 Δ48.6`:响应正在流式传输,首 token 大约等了 0.8 秒,现在的实时 TPS 大约是 48.6。
|
|
56
|
+
- `τ1.1 Δ49.7L`:请求发出去了,但流式传输还没开始。首 token 等待时间正在计时,先显示本轮上一次响应的 TPS。
|
|
57
|
+
- `τ0.8A Δ52.4A`:当前空闲,显示最近几次响应的平均表现。
|
|
58
|
+
|
|
59
|
+
流式传输时显示的实时 `Δ` 是估算值,而响应完成后显示的 `Δ` 则更可信。
|
|
60
|
+
|
|
61
|
+
## 原理说明
|
|
62
|
+
|
|
63
|
+
下面这部分面向希望了解插件原理的用户。
|
|
64
|
+
|
|
65
|
+
### 状态机
|
|
66
|
+
|
|
67
|
+
内部分四个阶段:
|
|
68
|
+
|
|
69
|
+
1. **waiting**:请求已发出,等待首 token
|
|
70
|
+
2. **streaming**:流式输出中
|
|
71
|
+
3. **settled**:响应结束
|
|
72
|
+
4. **idle**:轮次结束,空闲
|
|
73
|
+
|
|
74
|
+
示例:
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
idle τ… Δ? (第1轮对话未开始)
|
|
78
|
+
-> waiting(req1) τ0.2 Δ? (本轮第1次请求,Δ取avg(还不可用),每200ms更新τ)
|
|
79
|
+
...
|
|
80
|
+
-> idle τ1.5A Δ50.0A (空闲状态,已有历史平均值)
|
|
81
|
+
-> waiting(req1) τ0.2 Δ50.0A (本轮第1次请求,Δ取avg,每200ms更新τ)
|
|
82
|
+
-> streaming(req1) τ1.3 Δ51.0 (Δ实时更新,τ锁定)
|
|
83
|
+
-> settled(req1) τ1.3 Δ52.0 (Δ锁定,τ锁定)
|
|
84
|
+
-> waiting(req2+) τ0.2 Δ52.0L (本轮第2+次请求,Δ取last,每200ms更新τ)
|
|
85
|
+
-> streaming(req2+) τ1.7 Δ49.0 (Δ实时,τ锁定)
|
|
86
|
+
-> settled(req2+) τ1.7 Δ49.5 (Δ锁定,τ锁定)
|
|
87
|
+
-> idle τ1.5A Δ50.0A
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### `τ` 的来源
|
|
91
|
+
|
|
92
|
+
请求发出后,插件进入 `waiting` 状态,每 200ms 刷新一次等待时间;一旦收到第一条 assistant 流式更新,就把这一刻和请求发出时刻的时间差锁定下来,作为本轮的 TTFT。
|
|
93
|
+
|
|
94
|
+
所以:
|
|
95
|
+
|
|
96
|
+
- `waiting` 阶段的 `τ` 会一直增加
|
|
97
|
+
- 进入 `streaming`,`τ` 就锁定不再变化
|
|
98
|
+
- 后面的 `settled`、下一次请求开始前显示的历史值,以及 idle 阶段参与平均值计算的,都是这次请求的最终 TTFT。
|
|
99
|
+
|
|
100
|
+
### 实时 `Δ` 和最终 `Δ` 的来源
|
|
101
|
+
|
|
102
|
+
这两个值的来源不同,这一点非常重要。
|
|
103
|
+
|
|
104
|
+
#### 实时 `Δ`
|
|
105
|
+
|
|
106
|
+
流式传输过程中,provider 不会持续告诉 Pi “刚刚又生成了多少个 token”。所以实时 `Δ` 只能本地估算。
|
|
107
|
+
|
|
108
|
+
当前做法是:
|
|
109
|
+
|
|
110
|
+
1. 取当前这次响应到此刻为止已经流出的文本
|
|
111
|
+
2. 用 [`tokenx`](https://github.com/johannschopplich/tokenx) 估算这段文本大约有多少 token
|
|
112
|
+
3. 再除以从 `streaming` 开始到现在的时间
|
|
113
|
+
|
|
114
|
+
也就是说,实时 `Δ` 本质上是:
|
|
115
|
+
|
|
116
|
+
```text
|
|
117
|
+
当前累计估算 token / 当前累计流式时间
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
它不是 provider 的实时真值,只是一个本地估算的近似读数。
|
|
121
|
+
|
|
122
|
+
#### 最终 settled `Δ`
|
|
123
|
+
|
|
124
|
+
请求结束后,如果 provider 返回了 `usage.output`,插件会直接用它来计算最终 TPS:
|
|
125
|
+
|
|
126
|
+
```text
|
|
127
|
+
最终 output token / 总流式时间
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
这个值通常比本地估算的实时 `Δ` 更可信,因为它基于 provider 返回的真实 token 输出量。
|
|
131
|
+
|
|
132
|
+
如果某些 provider / 某些响应没有返回可用的 `output token`,插件才会退回到最后一次实时估算值作为兜底。
|
|
133
|
+
|
|
134
|
+
### 实时值与最终值存在偏差的原因
|
|
135
|
+
|
|
136
|
+
#### 1. token 估算是启发式的
|
|
137
|
+
|
|
138
|
+
`tokenx` 不是精确 tokenizer,而是一个轻量、偏启发式的估算库。它的优势是小而快,适合实时 UI 刷新。代价也很明确:它不是为“所有模型都精确对齐”设计的。
|
|
139
|
+
|
|
140
|
+
`tokenx` 设计与 benchmark 更偏向 **GPT tokenizer / 英文文本** 场景。当接入其他模型家族的 LLM,或者输出内容包含非英文字符时,偏差往往会更大一些。
|
|
141
|
+
|
|
142
|
+
#### 2. 流式输出节奏不均匀
|
|
143
|
+
|
|
144
|
+
模型输出不是严格按“每个 token 匀速到达”展示给 UI 的。实际过程中还会受到这些因素影响:
|
|
145
|
+
|
|
146
|
+
- provider 自己的 SSE / chunk 刷新策略
|
|
147
|
+
- Pi 收到事件的节奏
|
|
148
|
+
- thinking、tool call、正文混在一起时的内容结构变化
|
|
149
|
+
|
|
150
|
+
所以实时 `Δ` 通常会出现以下情况:
|
|
151
|
+
|
|
152
|
+
- 一开始不稳定,后面会慢慢收敛
|
|
153
|
+
- 和最终 settled 值接近,但不会完全重合
|
|
154
|
+
|
|
155
|
+
### 平均值 `A`
|
|
156
|
+
|
|
157
|
+
当前实现中,`A` 表示最近最多 5 次 provider 请求的平均最终表现。
|
|
158
|
+
|
|
159
|
+
- 平均 `τ`:最近这些请求的最终 TTFT 平均值
|
|
160
|
+
- 平均 `Δ`:最近这些请求的 settled TPS 平均值
|
|
161
|
+
|
|
162
|
+
### 数据参考指导
|
|
163
|
+
|
|
164
|
+
经验上可以这么看:
|
|
165
|
+
|
|
166
|
+
- `τ`:参考价值很高
|
|
167
|
+
- settled / 平均 `Δ`:最值得看,用于比较结果
|
|
168
|
+
- 实时 `Δ`:反映实时趋势和体感
|
|
169
|
+
|
|
170
|
+
## 插件适用范围
|
|
171
|
+
|
|
172
|
+
- 适用于为 LLM 速度与延迟提供粗略量化参考
|
|
173
|
+
- 适用于快速发现长会话中某次明显偏慢的请求
|
|
174
|
+
- 不适用于严格的模型性能对比与基准测试
|
|
175
|
+
|
|
176
|
+
## 许可证
|
|
177
|
+
|
|
178
|
+
MIT License
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { collectAssistantText, type AssistantContentBlock } from "./shared/content.js";
|
|
3
|
+
import { estimateTokens } from "./shared/token-estimator.js";
|
|
4
|
+
|
|
5
|
+
const STATUS_KEY = "oh-my-tps";
|
|
6
|
+
const WAITING_UPDATE_MS = 200;
|
|
7
|
+
const MIN_STREAM_SECONDS = 0.1;
|
|
8
|
+
const MAX_RECENT_SAMPLES = 5;
|
|
9
|
+
|
|
10
|
+
type RequestPhase = "idle" | "waiting" | "streaming" | "settled";
|
|
11
|
+
|
|
12
|
+
type RequestSample = {
|
|
13
|
+
tps: number;
|
|
14
|
+
ttft: number;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function formatNumber(value: number): string {
|
|
18
|
+
return value.toFixed(1);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function isFinitePositive(value: number | null | undefined): value is number {
|
|
22
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export default function ohMyTps(pi: ExtensionAPI): void {
|
|
26
|
+
let phase: RequestPhase = "idle";
|
|
27
|
+
let requestIndexInPrompt = 0;
|
|
28
|
+
let requestStartedAt = 0;
|
|
29
|
+
let streamStartedAt = 0;
|
|
30
|
+
let lockedTtft: number | null = null;
|
|
31
|
+
let lastLiveTps: number | null = null;
|
|
32
|
+
let lastFinalTps: number | null = null;
|
|
33
|
+
let recentSamples: RequestSample[] = [];
|
|
34
|
+
let waitingTimer: NodeJS.Timeout | undefined;
|
|
35
|
+
let waitingDeltaLabel = "Δ?";
|
|
36
|
+
let lastMessageText = "";
|
|
37
|
+
|
|
38
|
+
function stopWaitingTimer(): void {
|
|
39
|
+
if (waitingTimer) clearInterval(waitingTimer);
|
|
40
|
+
waitingTimer = undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function setStatus(ctx: ExtensionContext, text: string): void {
|
|
44
|
+
if (!ctx.hasUI) return;
|
|
45
|
+
ctx.ui.setStatus(STATUS_KEY, text);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function getAverageSample(): RequestSample | null {
|
|
49
|
+
if (recentSamples.length === 0) return null;
|
|
50
|
+
let totalTps = 0;
|
|
51
|
+
let totalTtft = 0;
|
|
52
|
+
for (const sample of recentSamples) {
|
|
53
|
+
totalTps += sample.tps;
|
|
54
|
+
totalTtft += sample.ttft;
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
tps: totalTps / recentSamples.length,
|
|
58
|
+
ttft: totalTtft / recentSamples.length,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function pushSample(sample: RequestSample): void {
|
|
63
|
+
recentSamples.push(sample);
|
|
64
|
+
if (recentSamples.length > MAX_RECENT_SAMPLES) {
|
|
65
|
+
recentSamples = recentSamples.slice(-MAX_RECENT_SAMPLES);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function renderIdle(ctx: ExtensionContext): void {
|
|
70
|
+
phase = "idle";
|
|
71
|
+
stopWaitingTimer();
|
|
72
|
+
const avg = getAverageSample();
|
|
73
|
+
if (!avg) {
|
|
74
|
+
setStatus(ctx, "τ… Δ?");
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
setStatus(ctx, `τ${formatNumber(avg.ttft)}A Δ${formatNumber(avg.tps)}A`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function selectWaitingDeltaLabel(): string {
|
|
81
|
+
if (requestIndexInPrompt <= 1) {
|
|
82
|
+
const avg = getAverageSample();
|
|
83
|
+
return avg ? `Δ${formatNumber(avg.tps)}A` : "Δ?";
|
|
84
|
+
}
|
|
85
|
+
if (isFinitePositive(lastFinalTps)) {
|
|
86
|
+
return `Δ${formatNumber(lastFinalTps)}L`;
|
|
87
|
+
}
|
|
88
|
+
const avg = getAverageSample();
|
|
89
|
+
return avg ? `Δ${formatNumber(avg.tps)}A` : "Δ?";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function renderWaiting(ctx: ExtensionContext): void {
|
|
93
|
+
stopWaitingTimer();
|
|
94
|
+
const update = () => {
|
|
95
|
+
const elapsed = Math.max(0, (performance.now() - requestStartedAt) / 1000);
|
|
96
|
+
setStatus(ctx, `τ${formatNumber(elapsed)} ${waitingDeltaLabel}`);
|
|
97
|
+
};
|
|
98
|
+
update();
|
|
99
|
+
waitingTimer = setInterval(update, WAITING_UPDATE_MS);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function renderStreaming(ctx: ExtensionContext, estimatedTps: number | null): void {
|
|
103
|
+
const ttftLabel = isFinitePositive(lockedTtft) ? `τ${formatNumber(lockedTtft)}` : "τ…";
|
|
104
|
+
const deltaLabel = isFinitePositive(estimatedTps) ? `Δ${formatNumber(estimatedTps)}` : waitingDeltaLabel;
|
|
105
|
+
setStatus(ctx, `${ttftLabel} ${deltaLabel}`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function beginWaiting(ctx: ExtensionContext): void {
|
|
109
|
+
requestIndexInPrompt += 1;
|
|
110
|
+
phase = "waiting";
|
|
111
|
+
requestStartedAt = performance.now();
|
|
112
|
+
streamStartedAt = 0;
|
|
113
|
+
lockedTtft = null;
|
|
114
|
+
lastLiveTps = null;
|
|
115
|
+
lastMessageText = "";
|
|
116
|
+
waitingDeltaLabel = selectWaitingDeltaLabel();
|
|
117
|
+
renderWaiting(ctx);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function beginStreaming(now: number): void {
|
|
121
|
+
phase = "streaming";
|
|
122
|
+
stopWaitingTimer();
|
|
123
|
+
streamStartedAt = now;
|
|
124
|
+
lockedTtft = requestStartedAt > 0 ? Math.max(0, (now - requestStartedAt) / 1000) : null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function finalizeRequest(ctx: ExtensionContext, outputTokens: number): void {
|
|
128
|
+
phase = "settled";
|
|
129
|
+
stopWaitingTimer();
|
|
130
|
+
|
|
131
|
+
const elapsed = streamStartedAt > 0 ? Math.max(0, (performance.now() - streamStartedAt) / 1000) : 0;
|
|
132
|
+
let finalTps: number | null = null;
|
|
133
|
+
if (elapsed > 0 && outputTokens > 0) {
|
|
134
|
+
finalTps = outputTokens / elapsed;
|
|
135
|
+
} else if (isFinitePositive(lastLiveTps)) {
|
|
136
|
+
finalTps = lastLiveTps;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (isFinitePositive(finalTps)) {
|
|
140
|
+
lastFinalTps = finalTps;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (isFinitePositive(finalTps) && isFinitePositive(lockedTtft)) {
|
|
144
|
+
pushSample({ tps: finalTps, ttft: lockedTtft });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const ttftLabel = isFinitePositive(lockedTtft) ? `τ${formatNumber(lockedTtft)}` : "τ…";
|
|
148
|
+
const deltaLabel = isFinitePositive(finalTps) ? `Δ${formatNumber(finalTps)}` : waitingDeltaLabel;
|
|
149
|
+
setStatus(ctx, `${ttftLabel} ${deltaLabel}`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
153
|
+
requestIndexInPrompt = 0;
|
|
154
|
+
renderIdle(ctx);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
pi.on("agent_start", async () => {
|
|
158
|
+
requestIndexInPrompt = 0;
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
pi.on("before_provider_request", async (_event, ctx) => {
|
|
162
|
+
beginWaiting(ctx);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
pi.on("message_update", async (event, ctx) => {
|
|
166
|
+
if (event.message.role !== "assistant") return;
|
|
167
|
+
|
|
168
|
+
const now = performance.now();
|
|
169
|
+
if (phase === "waiting") {
|
|
170
|
+
beginStreaming(now);
|
|
171
|
+
} else if (phase !== "streaming") {
|
|
172
|
+
if (requestStartedAt <= 0) requestStartedAt = now;
|
|
173
|
+
if (!isFinitePositive(lockedTtft)) lockedTtft = Math.max(0, (now - requestStartedAt) / 1000);
|
|
174
|
+
streamStartedAt = now;
|
|
175
|
+
phase = "streaming";
|
|
176
|
+
stopWaitingTimer();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const currentText = collectAssistantText(event.message as { content?: AssistantContentBlock[] });
|
|
180
|
+
lastMessageText = currentText;
|
|
181
|
+
|
|
182
|
+
const elapsed = streamStartedAt > 0 ? (now - streamStartedAt) / 1000 : 0;
|
|
183
|
+
let estimatedTps: number | null = null;
|
|
184
|
+
if (elapsed >= MIN_STREAM_SECONDS && lastMessageText.length > 0) {
|
|
185
|
+
const estimatedTokens = estimateTokens(lastMessageText);
|
|
186
|
+
estimatedTps = estimatedTokens / elapsed;
|
|
187
|
+
if (isFinitePositive(estimatedTps)) {
|
|
188
|
+
lastLiveTps = estimatedTps;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
renderStreaming(ctx, estimatedTps);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
pi.on("message_end", async (event, ctx) => {
|
|
196
|
+
if (event.message.role !== "assistant") return;
|
|
197
|
+
const outputTokens = event.message.usage?.output ?? 0;
|
|
198
|
+
finalizeRequest(ctx, outputTokens);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
pi.on("agent_end", async (_event, ctx) => {
|
|
202
|
+
renderIdle(ctx);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
pi.on("session_shutdown", async (_event, ctx) => {
|
|
206
|
+
stopWaitingTimer();
|
|
207
|
+
if (ctx.hasUI) ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
208
|
+
});
|
|
209
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export type AssistantContentBlock = {
|
|
2
|
+
type?: string;
|
|
3
|
+
text?: string;
|
|
4
|
+
thinking?: string;
|
|
5
|
+
name?: string;
|
|
6
|
+
args?: unknown;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export function collectAssistantText(message: { content?: AssistantContentBlock[] }): string {
|
|
10
|
+
let text = "";
|
|
11
|
+
for (const block of message.content ?? []) {
|
|
12
|
+
if (block.type === "text") {
|
|
13
|
+
text += block.text ?? "";
|
|
14
|
+
continue;
|
|
15
|
+
}
|
|
16
|
+
if (block.type === "thinking") {
|
|
17
|
+
text += block.thinking ?? "";
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
if (block.type === "toolCall") {
|
|
21
|
+
text += block.name ?? "";
|
|
22
|
+
text += JSON.stringify(block.args ?? "");
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return text;
|
|
26
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { estimateTokenCount, type TokenEstimationOptions } from "tokenx";
|
|
2
|
+
|
|
3
|
+
export function estimateTokens(text: string, options?: TokenEstimationOptions): number {
|
|
4
|
+
if (!text) return 0;
|
|
5
|
+
return options ? estimateTokenCount(text, options) : estimateTokenCount(text);
|
|
6
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "oh-my-tps",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Tiny live TTFT and TPS readouts for the Pi coding agent.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "EnderLiquid",
|
|
8
|
+
"keywords": [
|
|
9
|
+
"pi-package",
|
|
10
|
+
"pi-extension",
|
|
11
|
+
"pi-coding-agent",
|
|
12
|
+
"tps",
|
|
13
|
+
"ttft"
|
|
14
|
+
],
|
|
15
|
+
"files": [
|
|
16
|
+
"extensions",
|
|
17
|
+
"README.md",
|
|
18
|
+
"LICENSE"
|
|
19
|
+
],
|
|
20
|
+
"exports": {
|
|
21
|
+
".": "./extensions/oh-my-tps.ts"
|
|
22
|
+
},
|
|
23
|
+
"pi": {
|
|
24
|
+
"extensions": [
|
|
25
|
+
"./extensions"
|
|
26
|
+
]
|
|
27
|
+
},
|
|
28
|
+
"peerDependencies": {
|
|
29
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"tokenx": "^1.3.0"
|
|
33
|
+
},
|
|
34
|
+
"homepage": "https://github.com/EnderLiquid/oh-my-tps#readme",
|
|
35
|
+
"bugs": {
|
|
36
|
+
"url": "https://github.com/EnderLiquid/oh-my-tps/issues"
|
|
37
|
+
},
|
|
38
|
+
"repository": {
|
|
39
|
+
"type": "git",
|
|
40
|
+
"url": "git+https://github.com/EnderLiquid/oh-my-tps.git"
|
|
41
|
+
},
|
|
42
|
+
"scripts": {
|
|
43
|
+
"test:types": "tsc --noEmit"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@types/node": "^24.10.0",
|
|
47
|
+
"typescript": "^5.9.3"
|
|
48
|
+
}
|
|
49
|
+
}
|