@yalieny/pi-better-cost-display-footer 2.1.1 → 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 +18 -2
- package/extensions/pi-better-cost-display-footer.ts +414 -24
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
pi 扩展:脚本化多段计价动态列表 + footer 增强。
|
|
4
4
|
|
|
5
|
-
- **多段动态计价**:每个 provider 一个 `tierFn` 脚本函数(JS 表达式字符串)判定当前档位,**档位是任意字符串 id**(`peak` / `flat` / `valley` / `sharp`…),不绑定峰谷语义;在 `session_start
|
|
5
|
+
- **多段动态计价**:每个 provider 一个 `tierFn` 脚本函数(JS 表达式字符串)判定当前档位,**档位是任意字符串 id**(`peak` / `flat` / `valley` / `sharp`…),不绑定峰谷语义;在 `session_start`、每次发送消息、每次 LLM 调用前重注册 provider 的 `cost`(仅影响 pi 本地用量统计)。
|
|
6
|
+
- **计费自愈**:每轮比对期望 cost(配置 × 当前档位)与实际参与计费的 `ctx.model.cost`,不一致则重注册纠正;连续 3 次未收敛则停止重试并在 footer 显式告警(金额前 `≈`、标签加 `?`),不再静默错价.
|
|
6
7
|
- **档位标签**:价格后紧跟当前档位标签(如 `¥0.047(梁文峰)`),按档位 id 配置文案与颜色;未配置标签的档位不显示。
|
|
7
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 档会时有时无(显示为 `-`)。
|
|
8
10
|
- **货币符号**:按 provider 配置计费金额符号(默认 `$`)。
|
|
9
11
|
- **配置升级**:旧版顶层计价配置与 `peakWindows` 首次启动自动迁移为 provider 分层 + `tierFn` 格式(语义不变)。
|
|
10
12
|
|
|
@@ -85,10 +87,19 @@ pi install npm:@yalieny/pi-better-cost-display-footer
|
|
|
85
87
|
|
|
86
88
|
该命令把抓取官方价目页、解析价格、更新配置文件的完整任务交给 agent 执行(自然语言指令,无需手动维护价格表)。命令会告知 agent 配置文件位置与 JSON 格式;agent 抓取 [DeepSeek 官方价目页](https://api-docs.deepseek.com/zh-cn/quick_start/pricing) 后更新 `providers.deepseek.models` 下各模型的分档价格,其余字段不动。
|
|
87
89
|
|
|
90
|
+
## 计价诊断
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
/cost-tier
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
只读打印当前模型的档位状态:档位、期望费率 vs 实际计费费率、是否漂移、tierFn 编译状态、注册守卫与重试预算、最近一次注册结果。扩展的 `console.error` 在 TUI 下不落盘,排查"标签与金额不符"时用这条命令而不是猜。
|
|
97
|
+
|
|
88
98
|
## 测试
|
|
89
99
|
|
|
90
100
|
```bash
|
|
91
|
-
npm install && npm test
|
|
101
|
+
npm install && npm test # 纯函数单测(档位判定 / 漂移判定)
|
|
102
|
+
node --experimental-strip-types test/drift-probe.ts # 端到端:守卫重试与自愈(用系统临时目录隔离配置)
|
|
92
103
|
```
|
|
93
104
|
|
|
94
105
|
## 说明
|
|
@@ -97,3 +108,8 @@ npm install && npm test
|
|
|
97
108
|
- provider 必须在 `models.json` 中有定义;未列出的模型保持原价。
|
|
98
109
|
- 模型缺少当前档位价格时保持原价并打日志。
|
|
99
110
|
- cost 仅影响 pi 本地用量统计,不影响 API 实际账单。
|
|
111
|
+
|
|
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 新增两档折算比。
|
|
115
|
+
- **2.1.2** 修复"档位标签正确但计费金额按错档结算":注册守卫改为按实际计费 cost 判定漂移(不再只看档位字符串),注册失败不再写入守卫(下一轮自动重试);漂移 3 次未收敛转为 footer 可见告警(`≈` + 标签 `?`);新增 `/cost-tier` 诊断命令与注册失败一次性提示。方案见 `docs/fix-plan-stale-cost-tier.md`。
|
|
@@ -7,6 +7,11 @@
|
|
|
7
7
|
// 的每次调用)按各 provider 的 tierFn 脚本函数计算当前档位并重注册其 cost,对齐按调用时刻计费。
|
|
8
8
|
// effectiveFrom(含)之前不做任何事。
|
|
9
9
|
//
|
|
10
|
+
// 守卫按“状态”而非“档位字符串”判定:每轮比对期望 cost(配置 × 当前档位)与实际 cost
|
|
11
|
+
// (ctx.model.cost,真正参与计费的对象)。不一致 → 重注册当前 provider 自愈(最多 3 次),
|
|
12
|
+
// 重试用尽仍不符 → footer 显示 ≈ + 标签加 ? 显式告警。注册失败不写守卫,下一轮重试。
|
|
13
|
+
// /cost-tier 命令只读打印上述状态。
|
|
14
|
+
//
|
|
10
15
|
// 档位 = 任意字符串 id(如 "peak" / "flat" / "valley" / "sharp"),不绑定峰值/谷值语义:
|
|
11
16
|
// {
|
|
12
17
|
// "timezone": "Asia/Shanghai", // 计费时区,默认 Asia/Shanghai
|
|
@@ -169,7 +174,16 @@ const DEFAULT_CONFIG: DynamicConfig = {
|
|
|
169
174
|
},
|
|
170
175
|
};
|
|
171
176
|
|
|
172
|
-
|
|
177
|
+
/** 注册守卫:只在全部注册成功时写入,失败保持旧值 → 下一轮 key 不等自动重试 */
|
|
178
|
+
let appliedKey: string | null = null;
|
|
179
|
+
/** 同一漂移状态的连续重注册次数,超 DRIFT_RETRY_LIMIT 后只告警不再重试 */
|
|
180
|
+
let driftRetries = 0;
|
|
181
|
+
/** 最近一次提醒的失败文案,变化时才弹,避免每轮抖动 */
|
|
182
|
+
let lastErrorText: string | null = null;
|
|
183
|
+
/** 最近一次注册结果,供 /cost-tier 诊断 */
|
|
184
|
+
let lastRegisterResult = "尚未注册";
|
|
185
|
+
/** 当前模型计费 cost 与档位不符,footer 降级显示(≈ + 标签加 ?) */
|
|
186
|
+
let footerDrift = false;
|
|
173
187
|
/** 最近一次事件的 ctx,footer 闭包读取;随 input/model_select 事件刷新 */
|
|
174
188
|
let activeCtx: ExtensionContext | null = null;
|
|
175
189
|
/** 当前生效配置,apply() 时刷新,footer 每帧直接读,避免频繁读盘 */
|
|
@@ -177,6 +191,212 @@ let currentCfg: DynamicConfig | null = null;
|
|
|
177
191
|
/** 当前档位标签(null = 不显示),apply() 时刷新 */
|
|
178
192
|
let footerLabel: FooterLabel | null = null;
|
|
179
193
|
|
|
194
|
+
/** ponytail: 漂移重试预算固定 3 次,不做指数退避;真出现抖动再把预算做成配置项 */
|
|
195
|
+
const DRIFT_RETRY_LIMIT = 3;
|
|
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
|
+
|
|
392
|
+
/** 一次性提示:TUI 下扩展的 console.error 不落盘也不可见,错误必须走 ui.notify */
|
|
393
|
+
function notifyOnce(ctx: ExtensionContext, text: string): void {
|
|
394
|
+
if (text === lastErrorText) return;
|
|
395
|
+
lastErrorText = text;
|
|
396
|
+
if (ctx.mode === "tui") ctx.ui.notify(text, "warning");
|
|
397
|
+
else console.error(text);
|
|
398
|
+
}
|
|
399
|
+
|
|
180
400
|
/** 深合并:文件配置覆盖内置默认(全局 + provider + model 三级),部分配置文件也能生效 */
|
|
181
401
|
function mergeConfig(base: DynamicConfig, override: DynamicConfig): DynamicConfig {
|
|
182
402
|
const merged: DynamicConfig = { ...base, ...override };
|
|
@@ -443,23 +663,98 @@ function dimSkip(text: string, marker: string, theme: Theme): string {
|
|
|
443
663
|
);
|
|
444
664
|
}
|
|
445
665
|
|
|
666
|
+
/** 本插件视角下的模型对象子集(pi 的 Model 结构兼容,cost 只看四段扁平价) */
|
|
667
|
+
interface ModelLike {
|
|
668
|
+
provider: string;
|
|
669
|
+
id: string;
|
|
670
|
+
cost?: TierRates;
|
|
671
|
+
}
|
|
672
|
+
|
|
446
673
|
/** 读取当前模型的生效 provider 配置;未配置 / 未生效 → null。 */
|
|
447
674
|
function getActiveModelPricing(
|
|
448
675
|
cfg: DynamicConfig | null,
|
|
449
|
-
|
|
676
|
+
model: ModelLike | undefined,
|
|
677
|
+
date: Date = new Date(),
|
|
450
678
|
): ProviderPricing | null {
|
|
451
|
-
const
|
|
452
|
-
|
|
453
|
-
if (!model || !provider || !provider.models[model.id] || !isEffective(provider)) return null;
|
|
679
|
+
const provider = model ? cfg?.providers?.[model.provider] : undefined;
|
|
680
|
+
if (!model || !provider || !provider.models[model.id] || !isEffective(provider, date)) return null;
|
|
454
681
|
return provider;
|
|
455
682
|
}
|
|
456
683
|
|
|
457
|
-
/**
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
684
|
+
/**
|
|
685
|
+
* 费率比较:浮点相对容差 1e-9,避免 JSON round-trip 造成误判漂移。
|
|
686
|
+
* ponytail: 只比四段扁平价。pi 的 ModelCost 还有请求级 tiers(本插件不产出该字段),
|
|
687
|
+
* 若将来支持分档价,这里要按 tier 逐段比较。
|
|
688
|
+
*/
|
|
689
|
+
export function sameRates(a: TierRates, b: TierRates | undefined): boolean {
|
|
690
|
+
if (!b) return false;
|
|
691
|
+
return (["input", "output", "cacheRead", "cacheWrite"] as const).every((k) => {
|
|
692
|
+
const want = a[k];
|
|
693
|
+
const have = b[k] as number | undefined;
|
|
694
|
+
if (typeof have !== "number") return false;
|
|
695
|
+
return Math.abs(want - have) <= 1e-9 * Math.max(Math.abs(want), Math.abs(have), 1);
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/** 计费与档位不符:期望 = 配置 × 当前档位,实际 = 会话模型对象的 cost(真正参与计费的那个) */
|
|
700
|
+
interface CostDrift {
|
|
701
|
+
providerId: string;
|
|
702
|
+
tier: Tier;
|
|
703
|
+
want: TierRates;
|
|
704
|
+
have: TierRates;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/** 当前模型的档位状态(一次 currentTier 判定,标签 / 漂移 / 诊断共用) */
|
|
708
|
+
interface PricingState {
|
|
709
|
+
/** 生效的 provider 配置;该模型不归本插件管 → null */
|
|
710
|
+
provider: ProviderPricing | null;
|
|
711
|
+
/** 期望档位;同上 → null */
|
|
712
|
+
tier: Tier | null;
|
|
713
|
+
/** 期望费率;该模型该档位未配价 → null(保持原价,不算漂移) */
|
|
714
|
+
rates: TierRates | null;
|
|
715
|
+
/** 实际计费 cost 与期望费率不符 → 详情 */
|
|
716
|
+
drift: CostDrift | null;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function pricingState(
|
|
720
|
+
cfg: DynamicConfig | null,
|
|
721
|
+
model: ModelLike | undefined,
|
|
722
|
+
date: Date = new Date(),
|
|
723
|
+
): PricingState {
|
|
724
|
+
const provider = getActiveModelPricing(cfg, model, date);
|
|
725
|
+
if (!provider || !model) return { provider: null, tier: null, rates: null, drift: null };
|
|
726
|
+
const tier = currentTier(provider, date);
|
|
727
|
+
const want = provider.models[model.id]?.[tier];
|
|
728
|
+
const have = model.cost;
|
|
729
|
+
const drift = want && have && !sameRates(want, have)
|
|
730
|
+
? { providerId: model.provider, tier, want, have }
|
|
731
|
+
: null;
|
|
732
|
+
return { provider, tier, rates: want ?? null, drift };
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/** 当前模型应有的费率(provider 未配置 / 未生效 / 该模型或该档位缺价 → null) */
|
|
736
|
+
export function expectedRatesForModel(
|
|
737
|
+
cfg: DynamicConfig | null,
|
|
738
|
+
model: ModelLike | undefined,
|
|
739
|
+
date: Date = new Date(),
|
|
740
|
+
): { tier: Tier; rates: TierRates } | null {
|
|
741
|
+
const st = pricingState(cfg, model, date);
|
|
742
|
+
return st.tier && st.rates ? { tier: st.tier, rates: st.rates } : null;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/** 漂移判定:有期望费率且与实际 cost 不等 → 详情;一致或不归本插件管 → null */
|
|
746
|
+
export function detectCostDrift(
|
|
747
|
+
cfg: DynamicConfig | null,
|
|
748
|
+
model: ModelLike | undefined,
|
|
749
|
+
date: Date = new Date(),
|
|
750
|
+
): CostDrift | null {
|
|
751
|
+
return pricingState(cfg, model, date).drift;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/** 档位标签;当前模型无动态计价配置 / 该档位未配置标签 → null */
|
|
755
|
+
function computeFooterLabel(st: PricingState): FooterLabel | null {
|
|
756
|
+
if (!st.provider || !st.tier) return null;
|
|
757
|
+
return st.provider.labels?.[st.tier] ?? DEFAULT_LABELS[st.tier] ?? null;
|
|
463
758
|
}
|
|
464
759
|
|
|
465
760
|
function installCustomFooter(ctx: ExtensionContext): void {
|
|
@@ -522,15 +817,27 @@ function installCustomFooter(ctx: ExtensionContext): void {
|
|
|
522
817
|
const precision = currentCfg?.cacheHitRatePrecision ?? 1;
|
|
523
818
|
statsParts.push(`CH${latestCacheHitRate.toFixed(precision)}%`);
|
|
524
819
|
}
|
|
820
|
+
// tok/s:⚡ 常显;流式中为当前相位实时值(r 思考 / o 正文),空闲/工具执行时为定稿三档
|
|
821
|
+
const speedPart = formatSpeedPart();
|
|
822
|
+
if (speedPart) statsParts.push(speedPart);
|
|
525
823
|
// 订阅制 provider 无法从扩展读取 modelRuntime,退化为内置的特例
|
|
526
824
|
const usingSubscription = c?.model?.provider === "kimi-coding";
|
|
527
825
|
// 未配置模型只改 CH;金额符号和档位标签保持内置默认
|
|
528
|
-
const customPricing = getActiveModelPricing(currentCfg, c);
|
|
826
|
+
const customPricing = getActiveModelPricing(currentCfg, c?.model);
|
|
529
827
|
const labelInfo = customPricing ? footerLabel : null;
|
|
530
|
-
|
|
828
|
+
// 漂移时标签转 warning 并加问号,金额前缀 ≈:宁可显示“可疑”,不静默显示错数字
|
|
829
|
+
const drift = customPricing ? footerDrift : false;
|
|
830
|
+
const labelText = labelInfo
|
|
831
|
+
? drift
|
|
832
|
+
? labelInfo.text.endsWith(")")
|
|
833
|
+
? `${labelInfo.text.slice(0, -1)}?)`
|
|
834
|
+
: `${labelInfo.text}?`
|
|
835
|
+
: labelInfo.text
|
|
836
|
+
: "";
|
|
837
|
+
const labelStr = labelInfo ? colorize(theme, drift ? "warning" : labelInfo.color, labelText) : "";
|
|
531
838
|
if (totals.cost || usingSubscription) {
|
|
532
839
|
const symbol = customPricing?.currencySymbol ?? "$";
|
|
533
|
-
const costStr = `${symbol}${totals.cost.toFixed(3)}${usingSubscription ? " (sub)" : ""}`;
|
|
840
|
+
const costStr = `${drift ? "≈" : ""}${symbol}${totals.cost.toFixed(3)}${usingSubscription ? " (sub)" : ""}`;
|
|
534
841
|
statsParts.push(costStr + labelStr);
|
|
535
842
|
}
|
|
536
843
|
statsParts.push(contextPercentStr);
|
|
@@ -596,29 +903,61 @@ function apply(pi: ExtensionAPI, ctx: ExtensionContext): void {
|
|
|
596
903
|
activeCtx = ctx;
|
|
597
904
|
const cfg = loadDynamicConfig();
|
|
598
905
|
currentCfg = cfg;
|
|
599
|
-
|
|
600
|
-
|
|
906
|
+
if (!cfg) {
|
|
907
|
+
footerLabel = null;
|
|
908
|
+
footerDrift = false;
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
911
|
+
// 核心:以“实际计费 cost”为准,而不是以“档位字符串有没有变”为准
|
|
912
|
+
// 注:不能就地把费率写回 ctx.model.cost —— pi 的 model 对象被 Object.freeze(实测
|
|
913
|
+
// "Cannot assign to read only property"),且事件 ctx 无 setModel(只读 model)。
|
|
914
|
+
// 写进“参与计费的那个对象”的唯一通道就是 registerProvider → _refreshCurrentModelFromRegistry()。
|
|
915
|
+
const state = pricingState(cfg, ctx.model);
|
|
916
|
+
footerLabel = computeFooterLabel(state);
|
|
917
|
+
footerDrift = state.drift !== null;
|
|
918
|
+
const drift = state.drift;
|
|
601
919
|
const providerTiers = Object.entries(cfg.providers || {}).map(([providerId, provider]) => {
|
|
602
920
|
const tier = isEffective(provider) ? currentTier(provider) : null;
|
|
603
921
|
return [providerId, tier] as const;
|
|
604
922
|
});
|
|
605
923
|
const key = `${JSON.stringify(cfg)}|${JSON.stringify(providerTiers)}`;
|
|
606
|
-
|
|
607
|
-
//
|
|
924
|
+
const keyChanged = key !== appliedKey;
|
|
925
|
+
// 预算复位:配置/档位变了(新状态)或已一致(收敛)
|
|
926
|
+
if (keyChanged || !drift) driftRetries = 0;
|
|
927
|
+
if (!keyChanged) {
|
|
928
|
+
if (!drift) return; // 正常路径:档位未变且计费一致 → 零开销
|
|
929
|
+
// 同一漂移状态重试用尽 → 不再每轮抖,转由 footer 告警
|
|
930
|
+
if (driftRetries >= DRIFT_RETRY_LIMIT) return;
|
|
931
|
+
}
|
|
932
|
+
// 只统计“漂移驱动”的重试:key 变化那轮本就要全量重注册,不该占预算
|
|
933
|
+
if (!keyChanged && drift) driftRetries++;
|
|
934
|
+
|
|
935
|
+
let allOk = true;
|
|
936
|
+
const touched: string[] = [];
|
|
608
937
|
for (const [providerId, tier] of providerTiers) {
|
|
609
938
|
if (!tier) continue;
|
|
939
|
+
// key 变了:全部重注册(与旧版一致);只有漂移:只重注册当前模型的 provider
|
|
940
|
+
if (!keyChanged && providerId !== drift?.providerId) continue;
|
|
610
941
|
try {
|
|
611
942
|
const config = patchProviderConfig(cfg, providerId, tier);
|
|
943
|
+
// provider 不在 models.json 是永久配置态,不是瞬时失败,不阻塞守卫
|
|
612
944
|
if (!config) continue;
|
|
613
945
|
pi.registerProvider(providerId, config);
|
|
946
|
+
touched.push(`${providerId} → ${tier}`);
|
|
614
947
|
console.log(`[pi-better-cost-display-footer] ${providerId} → ${tier}`);
|
|
615
948
|
} catch (err) {
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
949
|
+
allOk = false;
|
|
950
|
+
const msg = `${providerId} 注册失败: ${err instanceof Error ? err.message : String(err)}`;
|
|
951
|
+
lastRegisterResult = msg;
|
|
952
|
+
notifyOnce(ctx, `[pi-better-cost-display-footer] ${msg}`);
|
|
619
953
|
}
|
|
620
954
|
}
|
|
621
|
-
|
|
955
|
+
if (touched.length) lastRegisterResult = `${touched.join(", ")} @ 成功`;
|
|
956
|
+
// 失败不写守卫 → 下一轮自动重试(修 A);恢复后允许同类故障再次告警
|
|
957
|
+
if (allOk) {
|
|
958
|
+
appliedKey = key;
|
|
959
|
+
lastErrorText = null; // 恢复后允许同类故障再次告警
|
|
960
|
+
}
|
|
622
961
|
}
|
|
623
962
|
|
|
624
963
|
export default function (pi: ExtensionAPI): void {
|
|
@@ -662,10 +1001,61 @@ export default function (pi: ExtensionAPI): void {
|
|
|
662
1001
|
);
|
|
663
1002
|
},
|
|
664
1003
|
});
|
|
1004
|
+
// 只读诊断。本次排障最大成本是“扩展 stderr 无落盘 + 状态不可观测”,命令把它们摊开
|
|
1005
|
+
pi.registerCommand("cost-tier", {
|
|
1006
|
+
description: "打印当前模型的计价档位诊断:期望费率 vs 实际计费费率、是否漂移、最近注册结果",
|
|
1007
|
+
handler: async (_args, ctx) => {
|
|
1008
|
+
const cfg = currentCfg ?? loadDynamicConfig();
|
|
1009
|
+
const m = ctx.model;
|
|
1010
|
+
const st = pricingState(cfg, m);
|
|
1011
|
+
const fmt = (r: TierRates | null | undefined) =>
|
|
1012
|
+
r ? `${r.input}/${r.output}/${r.cacheRead}/${r.cacheWrite}` : "—";
|
|
1013
|
+
const fnSrc = m ? cfg?.providers?.[m.provider]?.tierFn : undefined;
|
|
1014
|
+
const fnStatus = !fnSrc
|
|
1015
|
+
? "未配置(走 peakWindows / defaultTier)"
|
|
1016
|
+
: tierFnCache.get(fnSrc) === null
|
|
1017
|
+
? "编译失败 → 回退 defaultTier"
|
|
1018
|
+
: "OK";
|
|
1019
|
+
const lines = [
|
|
1020
|
+
`模型: ${m ? `${m.provider}/${m.id}` : "无"}`,
|
|
1021
|
+
`配置: ${existsSync(CONFIG_FILE) ? CONFIG_FILE : `${CONFIG_FILE}(不存在,用内置默认)`}`,
|
|
1022
|
+
`档位: ${st.tier ?? "不归本插件管(未配置 / 未生效 / 该模型无价)"}`,
|
|
1023
|
+
`期望费率 in/out/cR/cW: ${fmt(st.rates)}`,
|
|
1024
|
+
`实际 cost in/out/cR/cW: ${fmt(m?.cost)}`,
|
|
1025
|
+
`漂移: ${st.drift ? "是(计费与档位不符)" : m?.cost ? "否" : "—"}`,
|
|
1026
|
+
`tierFn: ${fnStatus}`,
|
|
1027
|
+
`守卫: ${appliedKey ? "已置位(配置与档位未变则不重注册)" : "未置位(下一轮重注册)"}`,
|
|
1028
|
+
`漂移重试: ${driftRetries}/${DRIFT_RETRY_LIMIT}`,
|
|
1029
|
+
`速率折算: 思考 ${speedRatios().r.toFixed(2)} / 正文 ${speedRatios().o.toFixed(2)} 字符/token(真实 token 校准,初始 4)`,
|
|
1030
|
+
`最近注册: ${lastRegisterResult}`,
|
|
1031
|
+
];
|
|
1032
|
+
const text = `[pi-better-cost-display-footer]\n${lines.join("\n")}`;
|
|
1033
|
+
if (ctx.mode === "tui") ctx.ui.notify(text, "info");
|
|
1034
|
+
else console.log(text);
|
|
1035
|
+
},
|
|
1036
|
+
});
|
|
665
1037
|
pi.on("session_start", (_e, ctx) => {
|
|
1038
|
+
resetSpeedSession();
|
|
666
1039
|
installCustomFooter(ctx);
|
|
667
1040
|
apply(pi, ctx);
|
|
668
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
|
+
});
|
|
669
1059
|
pi.on("session_shutdown", () => {
|
|
670
1060
|
// Pi invalidates session-bound ctx before the next footer render.
|
|
671
1061
|
activeCtx = null;
|
|
@@ -673,7 +1063,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
673
1063
|
pi.on("input", (_e, ctx) => apply(pi, ctx));
|
|
674
1064
|
// 切换模型时刷新档位标签(深色/深绿 ⇄ 其他模型)
|
|
675
1065
|
pi.on("model_select", (_e, ctx) => apply(pi, ctx));
|
|
676
|
-
// 每个 LLM
|
|
677
|
-
//
|
|
1066
|
+
// 每个 LLM 调用(含工具循环内的每次调用)开始前重算档位,对齐按调用点计费;
|
|
1067
|
+
// 守卫 = 配置/档位 key 未变 且 实际 cost 与期望一致 → O(1) 返回,保证正常路径零开销。
|
|
678
1068
|
pi.on("turn_start", (_e, ctx) => apply(pi, ctx));
|
|
679
1069
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yalieny/pi-better-cost-display-footer",
|
|
3
|
-
"version": "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": "*",
|