@yalieny/pi-better-cost-display-footer 2.1.2 → 2.2.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/README.md CHANGED
@@ -6,6 +6,7 @@ pi 扩展:脚本化多段计价动态列表 + footer 增强。
6
6
  - **计费自愈**:每轮比对期望 cost(配置 × 当前档位)与实际参与计费的 `ctx.model.cost`,不一致则重注册纠正;连续 3 次未收敛则停止重试并在 footer 显式告警(金额前 `≈`、标签加 `?`),不再静默错价.
7
7
  - **档位标签**:价格后紧跟当前档位标签(如 `¥0.047(梁文峰)`),按档位 id 配置文案与颜色;未配置标签的档位不显示。
8
8
  - **CH 精度**:footer 缓存命中率小数位可配(默认 1 位)。
9
+ - **tok/s 速率(思考/正文分相位)**:footer 常显 —— 流式中只显示当前相位的实时值 `⚡45.2t/s`(思考中/出字中都是这个形式,无前缀,2s 滚动窗口);assistant 消息结束(工具执行前)后冻结,只在冻结态带前缀,固定三档 `⚡(175.1/r31.8/o613.4)t/s` = 总吞吐 / 思考(r标识) / 正文(o标识),无值打 `-` 占位:本条无思考就是 `⚡(334.8/r-/o336.2)t/s`(无思考时总与正文本就重合)。分母均从各相位首 delta 起算,不含首 token 延迟与工具执行时长。实时值为估算(字符折算 token),思考/正文各自的折算比由真实 `usage.output`/`usage.reasoning` EMA 自校准(初始 4,限幅 0.5~8);异常速率(>1000 t/s)丢弃。注:DeepSeek 在工具循环的续写消息里经常不吐思考,所以 r 档会时有时无(显示为 `-`)。
9
10
  - **货币符号**:按 provider 配置计费金额符号(默认 `$`)。
10
11
  - **配置升级**:旧版顶层计价配置与 `peakWindows` 首次启动自动迁移为 provider 分层 + `tierFn` 格式(语义不变)。
11
12
 
@@ -110,4 +111,5 @@ node --experimental-strip-types test/drift-probe.ts # 端到端:守卫重
110
111
 
111
112
  ## 变更记录
112
113
 
114
+ - **2.2.0** footer 新增 tok/s 速率(思考/正文分相位):流式中显示当前相位实时值 `⚡45.2t/s`(无前缀,2s 滚动窗口),assistant `message_end` 用真实 `usage` 冻结为固定三档 `⚡(175.1/r31.8/o613.4)t/s`(总/思考/正文,无值打 `-` 占位);分母从各相位首 delta 起算,不含首 token 延迟与工具执行时长(定稿用 message_end 而非 turn_end)。实时值为估算,思考/正文各自的折算比由真实 token EMA 自校准(初始 4,限幅 0.5~8);异常速率(>1000 t/s)丢弃;无效 `usage`(失败/中止)不覆盖定稿值。/cost-tier 新增两档折算比。
113
115
  - **2.1.2** 修复"档位标签正确但计费金额按错档结算":注册守卫改为按实际计费 cost 判定漂移(不再只看档位字符串),注册失败不再写入守卫(下一轮自动重试);漂移 3 次未收敛转为 footer 可见告警(`≈` + 标签 `?`);新增 `/cost-tier` 诊断命令与注册失败一次性提示。方案见 `docs/fix-plan-stale-cost-tier.md`。
@@ -194,6 +194,201 @@ let footerLabel: FooterLabel | null = null;
194
194
  /** ponytail: 漂移重试预算固定 3 次,不做指数退避;真出现抖动再把预算做成配置项 */
195
195
  const DRIFT_RETRY_LIMIT = 3;
196
196
 
197
+ /* ---------- tok/s 实时速率(思考 / 正文分相位) ---------- */
198
+
199
+ /** ponytail: 窗口与折算上限固定;窗口 2s 是 GUI 可读性的取舍,不做配置项 */
200
+ const SPEED_WINDOW_MS = 2000;
201
+ /** 初始“字符/token”比:与 pi 自身(pi-ai estimate)的 4 一致,随后用真实 token 校准 */
202
+ const SPEED_CHARS_PER_TOKEN_DEFAULT = 4;
203
+ /** 校准比限幅:0.5(CJK 密集)~ 8(思考/结构化短词) */
204
+ const SPEED_RATIO_MIN = 0.5;
205
+ const SPEED_RATIO_MAX = 8;
206
+ /** 校准 EMA 系数:每条消息往实测值靠 30%,数条内收敛 */
207
+ const SPEED_CALIBRATION_ALPHA = 0.3;
208
+ /** 超过该速率视为异常样本(t/s),丢弃并沿用上次值 */
209
+ const SPEED_MAX = 1000;
210
+
211
+ /** 相位:r = 思考(reasoning),o = 正文(output − reasoning,含工具调用参数) */
212
+ type SpeedPhaseKind = "r" | "o";
213
+
214
+ /** 单相位的 2s 滚动窗口累计 */
215
+ interface SpeedPhase {
216
+ /** 本相位首个 delta 时刻;null = 本相位无产出 */
217
+ firstMs: number | null;
218
+ /** 本相位最近一个 delta 时刻(相位时长 = last − first) */
219
+ lastMs: number;
220
+ chars: number;
221
+ /** 已计入窗口的估算 token(单调,与窗口裁剪解耦,避免裁剪后重复计数) */
222
+ tokens: number;
223
+ samples: { t: number; n: number }[];
224
+ /** 本相位最近一次实时速率 */
225
+ rate: number | null;
226
+ }
227
+
228
+ /** 定稿三档速率;null = 该档无法计算 */
229
+ interface SpeedFinal {
230
+ /** 总吞吐 = output / (首 delta → message_end) */
231
+ total: number | null;
232
+ /** 思考 = reasoning / 思考段时长 */
233
+ r: number | null;
234
+ /** 正文 = (output − reasoning) / 正文段时长 */
235
+ o: number | null;
236
+ }
237
+
238
+ function newPhase(): SpeedPhase {
239
+ return { firstMs: null, lastMs: 0, chars: 0, tokens: 0, samples: [], rate: null };
240
+ }
241
+
242
+ /** 各相位“字符/token”校准比:跨消息保留 */
243
+ const speedRatio: Record<SpeedPhaseKind, number> = {
244
+ r: SPEED_CHARS_PER_TOKEN_DEFAULT,
245
+ o: SPEED_CHARS_PER_TOKEN_DEFAULT,
246
+ };
247
+
248
+ /**
249
+ * 速率状态:每个 delta 按相位进各自的滚动窗口 → assistant message_end 用真实 usage
250
+ * (output / reasoning)定稿三档并校准折算比。流式中显示当前相位实时值,空闲显示定稿三档。
251
+ *
252
+ * 为何实时值只能估算:没有任何 provider 在流中回传逐块累计 output token(Anthropic 的
253
+ * message_delta / Bedrock 的 metadata / OpenAI 的 include_usage 都只在末尾给一次),
254
+ * 只能由文本长度推;真值用于定稿与校准。
255
+ *
256
+ * 为何用 message_end 而非 turn_end 定稿:agent-loop 顺序是
257
+ * turn_start → 流式 message_* → 执行工具 → turn_end,用 turn_end 会把工具执行时长算进分母
258
+ * (流 200 token 后跑 60s 构建 → 报 3 t/s)。message_end 就在流式结束、工具开始前。
259
+ *
260
+ * 为何分相位:两段速度差一个数量级(实测 deepseek-flash:思考 ~32 t/s、正文 ~613 t/s),
261
+ * 混在一起的平均既不代表“模型在跑”也不代表“字出来的速度”。
262
+ */
263
+ const speed = {
264
+ think: newPhase(),
265
+ text: newPhase(),
266
+ /** 当前正在产出的相位;null = 空闲(显示定稿值) */
267
+ active: null as SpeedPhaseKind | null,
268
+ /** 本条消息首 delta 时刻(总口径分母起点) */
269
+ firstMs: null as number | null,
270
+ /** 最近一条消息的定稿三档 */
271
+ final: null as SpeedFinal | null,
272
+ };
273
+
274
+ /** 速率合理区间(t/s);异常样本返回 null */
275
+ function reasonableRate(rate: number): number | null {
276
+ return Number.isFinite(rate) && rate > 0 && rate <= SPEED_MAX ? rate : null;
277
+ }
278
+
279
+ /** 清空单条消息的累计,保留定稿值与校准比(assistant 消息开始时调用) */
280
+ export function resetSpeedTurn(): void {
281
+ speed.think = newPhase();
282
+ speed.text = newPhase();
283
+ speed.active = null;
284
+ speed.firstMs = null;
285
+ }
286
+
287
+ /** 会话开始/切换:全部清空(含定稿值与校准比,避免沿用上个会话的数字) */
288
+ export function resetSpeedSession(): void {
289
+ speed.final = null;
290
+ speedRatio.r = SPEED_CHARS_PER_TOKEN_DEFAULT;
291
+ speedRatio.o = SPEED_CHARS_PER_TOKEN_DEFAULT;
292
+ resetSpeedTurn();
293
+ }
294
+
295
+ /** 相位实时速率:窗口内 token / 实际耗时(窗口不满时按相位首 delta 起算) */
296
+ function phaseLiveRate(p: SpeedPhase, nowMs: number): number | null {
297
+ if (p.firstMs === null) return null;
298
+ const cutoff = nowMs - SPEED_WINDOW_MS;
299
+ p.samples = p.samples.filter((s) => s.t >= cutoff);
300
+ const elapsedSec = (nowMs - Math.max(cutoff, p.firstMs)) / 1000;
301
+ if (elapsedSec <= 0) return null;
302
+ const tokens = p.samples.reduce((sum, s) => sum + s.n, 0);
303
+ const measured = reasonableRate(tokens / elapsedSec);
304
+ if (measured !== null) p.rate = measured;
305
+ return p.rate;
306
+ }
307
+
308
+ /**
309
+ * 消费一片流式文本(按相位累计),返回该相位的实时速率
310
+ * @param kind - "r" 思考 / "o" 正文(含工具调用参数)
311
+ */
312
+ export function pushSpeedDelta(kind: SpeedPhaseKind, delta: string, nowMs: number): number | null {
313
+ const p = kind === "r" ? speed.think : speed.text;
314
+ if (delta.length > 0) {
315
+ speed.active = kind;
316
+ if (speed.firstMs === null) speed.firstMs = nowMs;
317
+ if (p.firstMs === null) p.firstMs = nowMs;
318
+ p.lastMs = nowMs;
319
+ p.chars += delta.length;
320
+ // 字符按折算比换算 token,只把新增量入窗口(累计量单调,裁剪后不会重复计数)
321
+ const target = Math.round(p.chars / speedRatio[kind]);
322
+ if (target > p.tokens) {
323
+ p.samples.push({ t: nowMs, n: target - p.tokens });
324
+ p.tokens = target;
325
+ }
326
+ }
327
+ return phaseLiveRate(p, nowMs);
328
+ }
329
+
330
+ /** 用真实 token 校准某相位的“字符/token”比;样本无效时保持原值 */
331
+ function calibrate(kind: SpeedPhaseKind, chars: number, realTokens: number): void {
332
+ if (realTokens <= 0 || chars <= 0) return;
333
+ const measured = Math.min(SPEED_RATIO_MAX, Math.max(SPEED_RATIO_MIN, chars / realTokens));
334
+ speedRatio[kind] =
335
+ speedRatio[kind] * (1 - SPEED_CALIBRATION_ALPHA) + measured * SPEED_CALIBRATION_ALPHA;
336
+ }
337
+
338
+ /** 相位时长(首 → 末 delta,秒);样本不足返回 null */
339
+ function phaseSpanSec(p: SpeedPhase): number | null {
340
+ if (p.firstMs === null || p.lastMs <= p.firstMs) return null;
341
+ return (p.lastMs - p.firstMs) / 1000;
342
+ }
343
+
344
+ /**
345
+ * 消息结束(流式刚结束、工具还没开始):用真实 usage 定稿三档速率并校准折算比。
346
+ * 分母 = 相位首 delta → 相位末 delta(总口径为首 delta → message_end),
347
+ * 不含首 token 延迟与工具执行时长。
348
+ * @param output - usage.output(含思考)
349
+ * @param reasoning - usage.reasoning(思考 token,output 的子集;provider 不报时为 0)
350
+ */
351
+ export function finishSpeedTurn(output: number, nowMs: number, reasoning = 0): SpeedFinal | null {
352
+ const { think, text, firstMs } = speed;
353
+ const reasoningTokens = Math.max(0, Math.min(reasoning, output));
354
+ const textTokens = output - reasoningTokens;
355
+ calibrate("r", think.chars, reasoningTokens);
356
+ calibrate("o", text.chars, textTokens);
357
+ const totalSec = firstMs === null ? null : (nowMs - firstMs) / 1000;
358
+ const thinkSec = phaseSpanSec(think);
359
+ const textSec = phaseSpanSec(text);
360
+ const final: SpeedFinal = {
361
+ total: output > 0 && totalSec !== null && totalSec > 0 ? reasonableRate(output / totalSec) : null,
362
+ r: reasoningTokens > 0 && thinkSec !== null ? reasonableRate(reasoningTokens / thinkSec) : null,
363
+ o: textTokens > 0 && textSec !== null ? reasonableRate(textTokens / textSec) : null,
364
+ };
365
+ resetSpeedTurn();
366
+ // 有真值才覆盖:失败/中止的消息 usage 可能全 0
367
+ if (final.total !== null || final.r !== null || final.o !== null) speed.final = final;
368
+ return speed.final;
369
+ }
370
+
371
+ /**
372
+ * footer 的 tok/s 片段:流式中 `⚡12.3t/s`(当前相位实时值,不加前缀);空闲固定三档
373
+ * `⚡(175.1/r31.8/o613.4)t/s`(总/思考/正文,总档靠位置识别,r/o 带标识),无值打 `-` 占位
374
+ */
375
+ export function formatSpeedPart(): string {
376
+ if (speed.active !== null) {
377
+ const p = speed.active === "r" ? speed.think : speed.text;
378
+ if (p.rate !== null) return `⚡${p.rate.toFixed(1)}t/s`;
379
+ }
380
+ const f = speed.final;
381
+ if (!f) return "";
382
+ const cell = (tag: string, v: number | null) => `${tag}${v === null ? "-" : v.toFixed(1)}`;
383
+ const total = f.total === null ? "-" : f.total.toFixed(1);
384
+ return `⚡(${total}/${cell("r", f.r)}/${cell("o", f.o)})t/s`;
385
+ }
386
+
387
+ /** 当前折算比,供 /cost-tier 诊断 */
388
+ export function speedRatios(): { r: number; o: number } {
389
+ return { r: speedRatio.r, o: speedRatio.o };
390
+ }
391
+
197
392
  /** 一次性提示:TUI 下扩展的 console.error 不落盘也不可见,错误必须走 ui.notify */
198
393
  function notifyOnce(ctx: ExtensionContext, text: string): void {
199
394
  if (text === lastErrorText) return;
@@ -622,6 +817,9 @@ function installCustomFooter(ctx: ExtensionContext): void {
622
817
  const precision = currentCfg?.cacheHitRatePrecision ?? 1;
623
818
  statsParts.push(`CH${latestCacheHitRate.toFixed(precision)}%`);
624
819
  }
820
+ // tok/s:⚡ 常显;流式中为当前相位实时值(r 思考 / o 正文),空闲/工具执行时为定稿三档
821
+ const speedPart = formatSpeedPart();
822
+ if (speedPart) statsParts.push(speedPart);
625
823
  // 订阅制 provider 无法从扩展读取 modelRuntime,退化为内置的特例
626
824
  const usingSubscription = c?.model?.provider === "kimi-coding";
627
825
  // 未配置模型只改 CH;金额符号和档位标签保持内置默认
@@ -828,6 +1026,7 @@ export default function (pi: ExtensionAPI): void {
828
1026
  `tierFn: ${fnStatus}`,
829
1027
  `守卫: ${appliedKey ? "已置位(配置与档位未变则不重注册)" : "未置位(下一轮重注册)"}`,
830
1028
  `漂移重试: ${driftRetries}/${DRIFT_RETRY_LIMIT}`,
1029
+ `速率折算: 思考 ${speedRatios().r.toFixed(2)} / 正文 ${speedRatios().o.toFixed(2)} 字符/token(真实 token 校准,初始 4)`,
831
1030
  `最近注册: ${lastRegisterResult}`,
832
1031
  ];
833
1032
  const text = `[pi-better-cost-display-footer]\n${lines.join("\n")}`;
@@ -836,9 +1035,27 @@ export default function (pi: ExtensionAPI): void {
836
1035
  },
837
1036
  });
838
1037
  pi.on("session_start", (_e, ctx) => {
1038
+ resetSpeedSession();
839
1039
  installCustomFooter(ctx);
840
1040
  apply(pi, ctx);
841
1041
  });
1042
+ // 速率事件:message_start 清累计(一次 LLM 调用内的估算不跨消息),
1043
+ // message_update 累积流式增量,assistant message_end(工具执行前)用实际 output token 定稿
1044
+ pi.on("message_start", (e) => {
1045
+ if (e.message.role === "assistant") resetSpeedTurn();
1046
+ });
1047
+ // 定稿用 message_end(流式刚结束、工具还没跑):用 turn_end 会把工具执行时长算进分母
1048
+ pi.on("message_end", (e) => {
1049
+ if (e.message.role !== "assistant") return;
1050
+ const u = e.message.usage;
1051
+ finishSpeedTurn(u?.output ?? 0, Date.now(), u?.reasoning ?? 0);
1052
+ });
1053
+ pi.on("message_update", (e) => {
1054
+ const ev = e.assistantMessageEvent;
1055
+ if (e.message.role !== "assistant" || !("delta" in ev)) return;
1056
+ // 思考与正文分开入窗口:两段速度差一个数量级,混合平均没参考价值
1057
+ pushSpeedDelta(ev.type === "thinking_delta" ? "r" : "o", ev.delta ?? "", Date.now());
1058
+ });
842
1059
  pi.on("session_shutdown", () => {
843
1060
  // Pi invalidates session-bound ctx before the next footer render.
844
1061
  activeCtx = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yalieny/pi-better-cost-display-footer",
3
- "version": "2.1.2",
3
+ "version": "2.2.0",
4
4
  "description": "pi extension: scriptable peak/off-peak tier rules per provider with an enhanced footer (tier label, cache-hit-rate precision, custom currency symbol)",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -9,7 +9,7 @@
9
9
  "license": "MIT",
10
10
  "type": "module",
11
11
  "scripts": {
12
- "test": "node --test test/tier.test.ts"
12
+ "test": "node --test test/tier.test.ts test/speed.test.ts"
13
13
  },
14
14
  "peerDependencies": {
15
15
  "@earendil-works/pi-coding-agent": "*",