@yalieny/pi-better-cost-display-footer 2.1.0 → 2.1.2

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
@@ -2,7 +2,8 @@
2
2
 
3
3
  pi 扩展:脚本化多段计价动态列表 + footer 增强。
4
4
 
5
- - **多段动态计价**:每个 provider 一个 `tierFn` 脚本函数(JS 表达式字符串)判定当前档位,**档位是任意字符串 id**(`peak` / `flat` / `valley` / `sharp`…),不绑定峰谷语义;在 `session_start` 和每次发送消息时重注册 provider 的 `cost`(仅影响 pi 本地用量统计)。
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 位)。
8
9
  - **货币符号**:按 provider 配置计费金额符号(默认 `$`)。
@@ -85,10 +86,19 @@ pi install npm:@yalieny/pi-better-cost-display-footer
85
86
 
86
87
  该命令把抓取官方价目页、解析价格、更新配置文件的完整任务交给 agent 执行(自然语言指令,无需手动维护价格表)。命令会告知 agent 配置文件位置与 JSON 格式;agent 抓取 [DeepSeek 官方价目页](https://api-docs.deepseek.com/zh-cn/quick_start/pricing) 后更新 `providers.deepseek.models` 下各模型的分档价格,其余字段不动。
87
88
 
89
+ ## 计价诊断
90
+
91
+ ```bash
92
+ /cost-tier
93
+ ```
94
+
95
+ 只读打印当前模型的档位状态:档位、期望费率 vs 实际计费费率、是否漂移、tierFn 编译状态、注册守卫与重试预算、最近一次注册结果。扩展的 `console.error` 在 TUI 下不落盘,排查"标签与金额不符"时用这条命令而不是猜。
96
+
88
97
  ## 测试
89
98
 
90
99
  ```bash
91
- npm install && npm test
100
+ npm install && npm test # 纯函数单测(档位判定 / 漂移判定)
101
+ node --experimental-strip-types test/drift-probe.ts # 端到端:守卫重试与自愈(用系统临时目录隔离配置)
92
102
  ```
93
103
 
94
104
  ## 说明
@@ -97,3 +107,7 @@ npm install && npm test
97
107
  - provider 必须在 `models.json` 中有定义;未列出的模型保持原价。
98
108
  - 模型缺少当前档位价格时保持原价并打日志。
99
109
  - cost 仅影响 pi 本地用量统计,不影响 API 实际账单。
110
+
111
+ ## 变更记录
112
+
113
+ - **2.1.2** 修复"档位标签正确但计费金额按错档结算":注册守卫改为按实际计费 cost 判定漂移(不再只看档位字符串),注册失败不再写入守卫(下一轮自动重试);漂移 3 次未收敛转为 footer 可见告警(`≈` + 标签 `?`);新增 `/cost-tier` 诊断命令与注册失败一次性提示。方案见 `docs/fix-plan-stale-cost-tier.md`。
@@ -3,8 +3,14 @@
3
3
  //
4
4
  // 配置驱动:读取 <configDir>/pi-better-cost-display-footer.json(configDir 默认 ~/.pi/agent,
5
5
  // 可用 PI_CODING_AGENT_DIR 覆盖)。配置文件不存在时自动生成默认配置(内置 DeepSeek 官方价目)落盘。
6
- // 在 session_start 和每次发送消息(input 事件)时,按各 provider 的 tierFn 脚本函数计算当前档位并重注册其 cost。
7
- // 跨时段边界的请求按发送时刻计费。effectiveFrom(含)之前不做任何事。
6
+ // 在 session_start、每次发送消息(input 事件)和每次 LLM 调用前(turn_start,含工具循环内
7
+ // 的每次调用)按各 provider 的 tierFn 脚本函数计算当前档位并重注册其 cost,对齐按调用时刻计费。
8
+ // effectiveFrom(含)之前不做任何事。
9
+ //
10
+ // 守卫按“状态”而非“档位字符串”判定:每轮比对期望 cost(配置 × 当前档位)与实际 cost
11
+ // (ctx.model.cost,真正参与计费的对象)。不一致 → 重注册当前 provider 自愈(最多 3 次),
12
+ // 重试用尽仍不符 → footer 显示 ≈ + 标签加 ? 显式告警。注册失败不写守卫,下一轮重试。
13
+ // /cost-tier 命令只读打印上述状态。
8
14
  //
9
15
  // 档位 = 任意字符串 id(如 "peak" / "flat" / "valley" / "sharp"),不绑定峰值/谷值语义:
10
16
  // {
@@ -168,7 +174,16 @@ const DEFAULT_CONFIG: DynamicConfig = {
168
174
  },
169
175
  };
170
176
 
171
- let lastAppliedKey: string | null = null;
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;
172
187
  /** 最近一次事件的 ctx,footer 闭包读取;随 input/model_select 事件刷新 */
173
188
  let activeCtx: ExtensionContext | null = null;
174
189
  /** 当前生效配置,apply() 时刷新,footer 每帧直接读,避免频繁读盘 */
@@ -176,6 +191,17 @@ let currentCfg: DynamicConfig | null = null;
176
191
  /** 当前档位标签(null = 不显示),apply() 时刷新 */
177
192
  let footerLabel: FooterLabel | null = null;
178
193
 
194
+ /** ponytail: 漂移重试预算固定 3 次,不做指数退避;真出现抖动再把预算做成配置项 */
195
+ const DRIFT_RETRY_LIMIT = 3;
196
+
197
+ /** 一次性提示:TUI 下扩展的 console.error 不落盘也不可见,错误必须走 ui.notify */
198
+ function notifyOnce(ctx: ExtensionContext, text: string): void {
199
+ if (text === lastErrorText) return;
200
+ lastErrorText = text;
201
+ if (ctx.mode === "tui") ctx.ui.notify(text, "warning");
202
+ else console.error(text);
203
+ }
204
+
179
205
  /** 深合并:文件配置覆盖内置默认(全局 + provider + model 三级),部分配置文件也能生效 */
180
206
  function mergeConfig(base: DynamicConfig, override: DynamicConfig): DynamicConfig {
181
207
  const merged: DynamicConfig = { ...base, ...override };
@@ -442,23 +468,98 @@ function dimSkip(text: string, marker: string, theme: Theme): string {
442
468
  );
443
469
  }
444
470
 
471
+ /** 本插件视角下的模型对象子集(pi 的 Model 结构兼容,cost 只看四段扁平价) */
472
+ interface ModelLike {
473
+ provider: string;
474
+ id: string;
475
+ cost?: TierRates;
476
+ }
477
+
445
478
  /** 读取当前模型的生效 provider 配置;未配置 / 未生效 → null。 */
446
479
  function getActiveModelPricing(
447
480
  cfg: DynamicConfig | null,
448
- ctx: ExtensionContext | null,
481
+ model: ModelLike | undefined,
482
+ date: Date = new Date(),
449
483
  ): ProviderPricing | null {
450
- const model = ctx?.model;
451
- const provider = model && cfg?.providers?.[model.provider];
452
- if (!model || !provider || !provider.models[model.id] || !isEffective(provider)) return null;
484
+ const provider = model ? cfg?.providers?.[model.provider] : undefined;
485
+ if (!model || !provider || !provider.models[model.id] || !isEffective(provider, date)) return null;
453
486
  return provider;
454
487
  }
455
488
 
456
- /** 计算档位标签;当前模型无动态计价配置 / 配置缺失 / 未生效 / 档位未配置标签 → null */
457
- function computeFooterLabel(cfg: DynamicConfig | null, ctx: ExtensionContext | null): FooterLabel | null {
458
- const provider = getActiveModelPricing(cfg, ctx);
459
- if (!provider) return null;
460
- const tier = currentTier(provider);
461
- return provider.labels?.[tier] ?? DEFAULT_LABELS[tier] ?? null;
489
+ /**
490
+ * 费率比较:浮点相对容差 1e-9,避免 JSON round-trip 造成误判漂移。
491
+ * ponytail: 只比四段扁平价。pi 的 ModelCost 还有请求级 tiers(本插件不产出该字段),
492
+ * 若将来支持分档价,这里要按 tier 逐段比较。
493
+ */
494
+ export function sameRates(a: TierRates, b: TierRates | undefined): boolean {
495
+ if (!b) return false;
496
+ return (["input", "output", "cacheRead", "cacheWrite"] as const).every((k) => {
497
+ const want = a[k];
498
+ const have = b[k] as number | undefined;
499
+ if (typeof have !== "number") return false;
500
+ return Math.abs(want - have) <= 1e-9 * Math.max(Math.abs(want), Math.abs(have), 1);
501
+ });
502
+ }
503
+
504
+ /** 计费与档位不符:期望 = 配置 × 当前档位,实际 = 会话模型对象的 cost(真正参与计费的那个) */
505
+ interface CostDrift {
506
+ providerId: string;
507
+ tier: Tier;
508
+ want: TierRates;
509
+ have: TierRates;
510
+ }
511
+
512
+ /** 当前模型的档位状态(一次 currentTier 判定,标签 / 漂移 / 诊断共用) */
513
+ interface PricingState {
514
+ /** 生效的 provider 配置;该模型不归本插件管 → null */
515
+ provider: ProviderPricing | null;
516
+ /** 期望档位;同上 → null */
517
+ tier: Tier | null;
518
+ /** 期望费率;该模型该档位未配价 → null(保持原价,不算漂移) */
519
+ rates: TierRates | null;
520
+ /** 实际计费 cost 与期望费率不符 → 详情 */
521
+ drift: CostDrift | null;
522
+ }
523
+
524
+ function pricingState(
525
+ cfg: DynamicConfig | null,
526
+ model: ModelLike | undefined,
527
+ date: Date = new Date(),
528
+ ): PricingState {
529
+ const provider = getActiveModelPricing(cfg, model, date);
530
+ if (!provider || !model) return { provider: null, tier: null, rates: null, drift: null };
531
+ const tier = currentTier(provider, date);
532
+ const want = provider.models[model.id]?.[tier];
533
+ const have = model.cost;
534
+ const drift = want && have && !sameRates(want, have)
535
+ ? { providerId: model.provider, tier, want, have }
536
+ : null;
537
+ return { provider, tier, rates: want ?? null, drift };
538
+ }
539
+
540
+ /** 当前模型应有的费率(provider 未配置 / 未生效 / 该模型或该档位缺价 → null) */
541
+ export function expectedRatesForModel(
542
+ cfg: DynamicConfig | null,
543
+ model: ModelLike | undefined,
544
+ date: Date = new Date(),
545
+ ): { tier: Tier; rates: TierRates } | null {
546
+ const st = pricingState(cfg, model, date);
547
+ return st.tier && st.rates ? { tier: st.tier, rates: st.rates } : null;
548
+ }
549
+
550
+ /** 漂移判定:有期望费率且与实际 cost 不等 → 详情;一致或不归本插件管 → null */
551
+ export function detectCostDrift(
552
+ cfg: DynamicConfig | null,
553
+ model: ModelLike | undefined,
554
+ date: Date = new Date(),
555
+ ): CostDrift | null {
556
+ return pricingState(cfg, model, date).drift;
557
+ }
558
+
559
+ /** 档位标签;当前模型无动态计价配置 / 该档位未配置标签 → null */
560
+ function computeFooterLabel(st: PricingState): FooterLabel | null {
561
+ if (!st.provider || !st.tier) return null;
562
+ return st.provider.labels?.[st.tier] ?? DEFAULT_LABELS[st.tier] ?? null;
462
563
  }
463
564
 
464
565
  function installCustomFooter(ctx: ExtensionContext): void {
@@ -524,12 +625,21 @@ function installCustomFooter(ctx: ExtensionContext): void {
524
625
  // 订阅制 provider 无法从扩展读取 modelRuntime,退化为内置的特例
525
626
  const usingSubscription = c?.model?.provider === "kimi-coding";
526
627
  // 未配置模型只改 CH;金额符号和档位标签保持内置默认
527
- const customPricing = getActiveModelPricing(currentCfg, c);
628
+ const customPricing = getActiveModelPricing(currentCfg, c?.model);
528
629
  const labelInfo = customPricing ? footerLabel : null;
529
- const labelStr = labelInfo ? colorize(theme, labelInfo.color, labelInfo.text) : "";
630
+ // 漂移时标签转 warning 并加问号,金额前缀 ≈:宁可显示“可疑”,不静默显示错数字
631
+ const drift = customPricing ? footerDrift : false;
632
+ const labelText = labelInfo
633
+ ? drift
634
+ ? labelInfo.text.endsWith(")")
635
+ ? `${labelInfo.text.slice(0, -1)}?)`
636
+ : `${labelInfo.text}?`
637
+ : labelInfo.text
638
+ : "";
639
+ const labelStr = labelInfo ? colorize(theme, drift ? "warning" : labelInfo.color, labelText) : "";
530
640
  if (totals.cost || usingSubscription) {
531
641
  const symbol = customPricing?.currencySymbol ?? "$";
532
- const costStr = `${symbol}${totals.cost.toFixed(3)}${usingSubscription ? " (sub)" : ""}`;
642
+ const costStr = `${drift ? "≈" : ""}${symbol}${totals.cost.toFixed(3)}${usingSubscription ? " (sub)" : ""}`;
533
643
  statsParts.push(costStr + labelStr);
534
644
  }
535
645
  statsParts.push(contextPercentStr);
@@ -595,29 +705,61 @@ function apply(pi: ExtensionAPI, ctx: ExtensionContext): void {
595
705
  activeCtx = ctx;
596
706
  const cfg = loadDynamicConfig();
597
707
  currentCfg = cfg;
598
- footerLabel = computeFooterLabel(cfg, ctx);
599
- if (!cfg) return;
708
+ if (!cfg) {
709
+ footerLabel = null;
710
+ footerDrift = false;
711
+ return;
712
+ }
713
+ // 核心:以“实际计费 cost”为准,而不是以“档位字符串有没有变”为准
714
+ // 注:不能就地把费率写回 ctx.model.cost —— pi 的 model 对象被 Object.freeze(实测
715
+ // "Cannot assign to read only property"),且事件 ctx 无 setModel(只读 model)。
716
+ // 写进“参与计费的那个对象”的唯一通道就是 registerProvider → _refreshCurrentModelFromRegistry()。
717
+ const state = pricingState(cfg, ctx.model);
718
+ footerLabel = computeFooterLabel(state);
719
+ footerDrift = state.drift !== null;
720
+ const drift = state.drift;
600
721
  const providerTiers = Object.entries(cfg.providers || {}).map(([providerId, provider]) => {
601
722
  const tier = isEffective(provider) ? currentTier(provider) : null;
602
723
  return [providerId, tier] as const;
603
724
  });
604
725
  const key = `${JSON.stringify(cfg)}|${JSON.stringify(providerTiers)}`;
605
- if (key === lastAppliedKey) return;
606
- // 动态计价对所有配置了 providers 的模型生效(不限定 deepseek)
726
+ const keyChanged = key !== appliedKey;
727
+ // 预算复位:配置/档位变了(新状态)或已一致(收敛)
728
+ if (keyChanged || !drift) driftRetries = 0;
729
+ if (!keyChanged) {
730
+ if (!drift) return; // 正常路径:档位未变且计费一致 → 零开销
731
+ // 同一漂移状态重试用尽 → 不再每轮抖,转由 footer 告警
732
+ if (driftRetries >= DRIFT_RETRY_LIMIT) return;
733
+ }
734
+ // 只统计“漂移驱动”的重试:key 变化那轮本就要全量重注册,不该占预算
735
+ if (!keyChanged && drift) driftRetries++;
736
+
737
+ let allOk = true;
738
+ const touched: string[] = [];
607
739
  for (const [providerId, tier] of providerTiers) {
608
740
  if (!tier) continue;
741
+ // key 变了:全部重注册(与旧版一致);只有漂移:只重注册当前模型的 provider
742
+ if (!keyChanged && providerId !== drift?.providerId) continue;
609
743
  try {
610
744
  const config = patchProviderConfig(cfg, providerId, tier);
745
+ // provider 不在 models.json 是永久配置态,不是瞬时失败,不阻塞守卫
611
746
  if (!config) continue;
612
747
  pi.registerProvider(providerId, config);
748
+ touched.push(`${providerId} → ${tier}`);
613
749
  console.log(`[pi-better-cost-display-footer] ${providerId} → ${tier}`);
614
750
  } catch (err) {
615
- console.error(
616
- `[pi-better-cost-display-footer] ${providerId} 注册失败: ${err instanceof Error ? err.message : String(err)}`,
617
- );
751
+ allOk = false;
752
+ const msg = `${providerId} 注册失败: ${err instanceof Error ? err.message : String(err)}`;
753
+ lastRegisterResult = msg;
754
+ notifyOnce(ctx, `[pi-better-cost-display-footer] ${msg}`);
618
755
  }
619
756
  }
620
- lastAppliedKey = key;
757
+ if (touched.length) lastRegisterResult = `${touched.join(", ")} @ 成功`;
758
+ // 失败不写守卫 → 下一轮自动重试(修 A);恢复后允许同类故障再次告警
759
+ if (allOk) {
760
+ appliedKey = key;
761
+ lastErrorText = null; // 恢复后允许同类故障再次告警
762
+ }
621
763
  }
622
764
 
623
765
  export default function (pi: ExtensionAPI): void {
@@ -661,6 +803,38 @@ export default function (pi: ExtensionAPI): void {
661
803
  );
662
804
  },
663
805
  });
806
+ // 只读诊断。本次排障最大成本是“扩展 stderr 无落盘 + 状态不可观测”,命令把它们摊开
807
+ pi.registerCommand("cost-tier", {
808
+ description: "打印当前模型的计价档位诊断:期望费率 vs 实际计费费率、是否漂移、最近注册结果",
809
+ handler: async (_args, ctx) => {
810
+ const cfg = currentCfg ?? loadDynamicConfig();
811
+ const m = ctx.model;
812
+ const st = pricingState(cfg, m);
813
+ const fmt = (r: TierRates | null | undefined) =>
814
+ r ? `${r.input}/${r.output}/${r.cacheRead}/${r.cacheWrite}` : "—";
815
+ const fnSrc = m ? cfg?.providers?.[m.provider]?.tierFn : undefined;
816
+ const fnStatus = !fnSrc
817
+ ? "未配置(走 peakWindows / defaultTier)"
818
+ : tierFnCache.get(fnSrc) === null
819
+ ? "编译失败 → 回退 defaultTier"
820
+ : "OK";
821
+ const lines = [
822
+ `模型: ${m ? `${m.provider}/${m.id}` : "无"}`,
823
+ `配置: ${existsSync(CONFIG_FILE) ? CONFIG_FILE : `${CONFIG_FILE}(不存在,用内置默认)`}`,
824
+ `档位: ${st.tier ?? "不归本插件管(未配置 / 未生效 / 该模型无价)"}`,
825
+ `期望费率 in/out/cR/cW: ${fmt(st.rates)}`,
826
+ `实际 cost in/out/cR/cW: ${fmt(m?.cost)}`,
827
+ `漂移: ${st.drift ? "是(计费与档位不符)" : m?.cost ? "否" : "—"}`,
828
+ `tierFn: ${fnStatus}`,
829
+ `守卫: ${appliedKey ? "已置位(配置与档位未变则不重注册)" : "未置位(下一轮重注册)"}`,
830
+ `漂移重试: ${driftRetries}/${DRIFT_RETRY_LIMIT}`,
831
+ `最近注册: ${lastRegisterResult}`,
832
+ ];
833
+ const text = `[pi-better-cost-display-footer]\n${lines.join("\n")}`;
834
+ if (ctx.mode === "tui") ctx.ui.notify(text, "info");
835
+ else console.log(text);
836
+ },
837
+ });
664
838
  pi.on("session_start", (_e, ctx) => {
665
839
  installCustomFooter(ctx);
666
840
  apply(pi, ctx);
@@ -672,4 +846,7 @@ export default function (pi: ExtensionAPI): void {
672
846
  pi.on("input", (_e, ctx) => apply(pi, ctx));
673
847
  // 切换模型时刷新档位标签(深色/深绿 ⇄ 其他模型)
674
848
  pi.on("model_select", (_e, ctx) => apply(pi, ctx));
849
+ // 每个 LLM 调用(含工具循环内的每次调用)开始前重算档位,对齐按调用点计费;
850
+ // 守卫 = 配置/档位 key 未变 且 实际 cost 与期望一致 → O(1) 返回,保证正常路径零开销。
851
+ pi.on("turn_start", (_e, ctx) => apply(pi, ctx));
675
852
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yalieny/pi-better-cost-display-footer",
3
- "version": "2.1.0",
3
+ "version": "2.1.2",
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",