@yalieny/pi-better-cost-display-footer 2.1.1 → 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`。
@@ -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
- 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;
173
187
  /** 最近一次事件的 ctx,footer 闭包读取;随 input/model_select 事件刷新 */
174
188
  let activeCtx: ExtensionContext | null = null;
175
189
  /** 当前生效配置,apply() 时刷新,footer 每帧直接读,避免频繁读盘 */
@@ -177,6 +191,17 @@ 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
+ /** 一次性提示: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
+
180
205
  /** 深合并:文件配置覆盖内置默认(全局 + provider + model 三级),部分配置文件也能生效 */
181
206
  function mergeConfig(base: DynamicConfig, override: DynamicConfig): DynamicConfig {
182
207
  const merged: DynamicConfig = { ...base, ...override };
@@ -443,23 +468,98 @@ function dimSkip(text: string, marker: string, theme: Theme): string {
443
468
  );
444
469
  }
445
470
 
471
+ /** 本插件视角下的模型对象子集(pi 的 Model 结构兼容,cost 只看四段扁平价) */
472
+ interface ModelLike {
473
+ provider: string;
474
+ id: string;
475
+ cost?: TierRates;
476
+ }
477
+
446
478
  /** 读取当前模型的生效 provider 配置;未配置 / 未生效 → null。 */
447
479
  function getActiveModelPricing(
448
480
  cfg: DynamicConfig | null,
449
- ctx: ExtensionContext | null,
481
+ model: ModelLike | undefined,
482
+ date: Date = new Date(),
450
483
  ): ProviderPricing | null {
451
- const model = ctx?.model;
452
- const provider = model && cfg?.providers?.[model.provider];
453
- 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;
454
486
  return provider;
455
487
  }
456
488
 
457
- /** 计算档位标签;当前模型无动态计价配置 / 配置缺失 / 未生效 / 档位未配置标签 → null */
458
- function computeFooterLabel(cfg: DynamicConfig | null, ctx: ExtensionContext | null): FooterLabel | null {
459
- const provider = getActiveModelPricing(cfg, ctx);
460
- if (!provider) return null;
461
- const tier = currentTier(provider);
462
- 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;
463
563
  }
464
564
 
465
565
  function installCustomFooter(ctx: ExtensionContext): void {
@@ -525,12 +625,21 @@ function installCustomFooter(ctx: ExtensionContext): void {
525
625
  // 订阅制 provider 无法从扩展读取 modelRuntime,退化为内置的特例
526
626
  const usingSubscription = c?.model?.provider === "kimi-coding";
527
627
  // 未配置模型只改 CH;金额符号和档位标签保持内置默认
528
- const customPricing = getActiveModelPricing(currentCfg, c);
628
+ const customPricing = getActiveModelPricing(currentCfg, c?.model);
529
629
  const labelInfo = customPricing ? footerLabel : null;
530
- 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) : "";
531
640
  if (totals.cost || usingSubscription) {
532
641
  const symbol = customPricing?.currencySymbol ?? "$";
533
- const costStr = `${symbol}${totals.cost.toFixed(3)}${usingSubscription ? " (sub)" : ""}`;
642
+ const costStr = `${drift ? "≈" : ""}${symbol}${totals.cost.toFixed(3)}${usingSubscription ? " (sub)" : ""}`;
534
643
  statsParts.push(costStr + labelStr);
535
644
  }
536
645
  statsParts.push(contextPercentStr);
@@ -596,29 +705,61 @@ function apply(pi: ExtensionAPI, ctx: ExtensionContext): void {
596
705
  activeCtx = ctx;
597
706
  const cfg = loadDynamicConfig();
598
707
  currentCfg = cfg;
599
- footerLabel = computeFooterLabel(cfg, ctx);
600
- 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;
601
721
  const providerTiers = Object.entries(cfg.providers || {}).map(([providerId, provider]) => {
602
722
  const tier = isEffective(provider) ? currentTier(provider) : null;
603
723
  return [providerId, tier] as const;
604
724
  });
605
725
  const key = `${JSON.stringify(cfg)}|${JSON.stringify(providerTiers)}`;
606
- if (key === lastAppliedKey) return;
607
- // 动态计价对所有配置了 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[] = [];
608
739
  for (const [providerId, tier] of providerTiers) {
609
740
  if (!tier) continue;
741
+ // key 变了:全部重注册(与旧版一致);只有漂移:只重注册当前模型的 provider
742
+ if (!keyChanged && providerId !== drift?.providerId) continue;
610
743
  try {
611
744
  const config = patchProviderConfig(cfg, providerId, tier);
745
+ // provider 不在 models.json 是永久配置态,不是瞬时失败,不阻塞守卫
612
746
  if (!config) continue;
613
747
  pi.registerProvider(providerId, config);
748
+ touched.push(`${providerId} → ${tier}`);
614
749
  console.log(`[pi-better-cost-display-footer] ${providerId} → ${tier}`);
615
750
  } catch (err) {
616
- console.error(
617
- `[pi-better-cost-display-footer] ${providerId} 注册失败: ${err instanceof Error ? err.message : String(err)}`,
618
- );
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}`);
619
755
  }
620
756
  }
621
- lastAppliedKey = key;
757
+ if (touched.length) lastRegisterResult = `${touched.join(", ")} @ 成功`;
758
+ // 失败不写守卫 → 下一轮自动重试(修 A);恢复后允许同类故障再次告警
759
+ if (allOk) {
760
+ appliedKey = key;
761
+ lastErrorText = null; // 恢复后允许同类故障再次告警
762
+ }
622
763
  }
623
764
 
624
765
  export default function (pi: ExtensionAPI): void {
@@ -662,6 +803,38 @@ export default function (pi: ExtensionAPI): void {
662
803
  );
663
804
  },
664
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
+ });
665
838
  pi.on("session_start", (_e, ctx) => {
666
839
  installCustomFooter(ctx);
667
840
  apply(pi, ctx);
@@ -673,7 +846,7 @@ export default function (pi: ExtensionAPI): void {
673
846
  pi.on("input", (_e, ctx) => apply(pi, ctx));
674
847
  // 切换模型时刷新档位标签(深色/深绿 ⇄ 其他模型)
675
848
  pi.on("model_select", (_e, ctx) => apply(pi, ctx));
676
- // 每个 LLM 调用(含工具循环内的每次调用)开始前按调用时刻重算档位,对齐按调用点计费;
677
- // lastAppliedKey 守卫保证档位未变时零开销。
849
+ // 每个 LLM 调用(含工具循环内的每次调用)开始前重算档位,对齐按调用点计费;
850
+ // 守卫 = 配置/档位 key 未变 且 实际 cost 与期望一致 → O(1) 返回,保证正常路径零开销。
678
851
  pi.on("turn_start", (_e, ctx) => apply(pi, ctx));
679
852
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yalieny/pi-better-cost-display-footer",
3
- "version": "2.1.1",
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",