@yalieny/pi-better-cost-display-footer 1.0.2 → 2.0.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
@@ -1,11 +1,12 @@
1
1
  # @yalieny/pi-better-cost-display-footer
2
2
 
3
- pi 扩展:峰谷动态计价 + footer 增强。
3
+ pi 扩展:脚本化多段计价动态列表 + footer 增强。
4
4
 
5
- - **动态计价**:按配置时区与峰谷窗口,在 `session_start` 和每次发送消息时重注册 provider 的 `cost`(仅影响 pi 本地用量统计)。
6
- - **档位标签**:价格后紧跟峰值/谷值标签(如 `¥0.047(梁文峰)`),文案与颜色可配置。
5
+ - **多段动态计价**:每个 provider 一个 `tierFn` 脚本函数(JS 表达式字符串)判定当前档位,**档位是任意字符串 id**(`peak` / `flat` / `valley` / `sharp`…),不绑定峰谷语义;在 `session_start` 和每次发送消息时重注册 provider 的 `cost`(仅影响 pi 本地用量统计)。
6
+ - **档位标签**:价格后紧跟当前档位标签(如 `¥0.047(梁文峰)`),按档位 id 配置文案与颜色;未配置标签的档位不显示。
7
7
  - **CH 精度**:footer 缓存命中率小数位可配(默认 1 位)。
8
- - **货币符号**:计费金额符号可配(默认 `$`)。
8
+ - **货币符号**:按 provider 配置计费金额符号(默认 `$`)。
9
+ - **配置升级**:旧版顶层计价配置与 `peakWindows` 首次启动自动迁移为 provider 分层 + `tierFn` 格式(语义不变)。
9
10
 
10
11
  ## 安装
11
12
 
@@ -21,24 +22,22 @@ pi install npm:@yalieny/pi-better-cost-display-footer
21
22
 
22
23
  ```json
23
24
  {
24
- "timezone": "Asia/Shanghai",
25
- "effectiveFrom": "2026-08-17T00:00:00+08:00",
26
25
  "cacheHitRatePrecision": 3,
27
- "currencySymbol": "¥",
28
- "peakWindows": [
29
- { "start": "09:00", "end": "12:00" },
30
- { "start": "14:00", "end": "18:00" }
31
- ],
32
- "labels": {
33
- "peak": { "text": "(梁文峰)", "color": "error" },
34
- "offPeak": { "text": "(梁文谷)", "color": "success" }
35
- },
36
26
  "providers": {
37
27
  "deepseek": {
28
+ "timezone": "Asia/Shanghai",
29
+ "effectiveFrom": "2026-08-23T00:00:00+08:00",
30
+ "currencySymbol": "¥",
31
+ "tierFn": "(ctx) => ctx.isWeekend() ? 'offPeak' : (ctx.inWindow('09:00','12:00') || ctx.inWindow('14:00','18:00')) ? 'peak' : 'offPeak'",
32
+ "defaultTier": "offPeak",
33
+ "labels": {
34
+ "peak": { "text": "(梁文峰)", "color": "error" },
35
+ "offPeak": { "text": "(梁文谷)", "color": "success" }
36
+ },
38
37
  "models": {
39
38
  "deepseek-v4-flash": {
40
39
  "offPeak": { "input": 1.5, "output": 4.5, "cacheRead": 0.05, "cacheWrite": 0 },
41
- "peak": { "input": 3.0, "output": 9.0, "cacheRead": 0.1, "cacheWrite": 0 }
40
+ "peak": { "input": 3.0, "output": 9.0, "cacheRead": 0.1, "cacheWrite": 0 }
42
41
  }
43
42
  }
44
43
  }
@@ -46,7 +45,37 @@ pi install npm:@yalieny/pi-better-cost-display-footer
46
45
  }
47
46
  ```
48
47
 
49
- 价格单位:每百万 tokens(元),与 pi 的 `calculateCost` 约定一致。窗口含起点不含终点。`effectiveFrom`(含)之前不做任何事。
48
+ 价格单位:每百万 tokens(元),与 pi 的 `calculateCost` 约定一致。`effectiveFrom`(含)之前不做任何事。
49
+
50
+ ### 档位判定(tierFn)
51
+
52
+ `tierFn` 是 JS 函数表达式字符串(编译执行),返回**任意档位 id**(字符串)。上下文 `ctx` 均为配置时区本地值:
53
+
54
+ | 字段/方法 | 说明 |
55
+ | --- | --- |
56
+ | `ctx.now` | 当前时刻 `Date` |
57
+ | `ctx.timezone` | 计费时区 |
58
+ | `ctx.weekday` | 0=周日 … 6=周六 |
59
+ | `ctx.hour` / `ctx.minute` | 时区本地时钟 |
60
+ | `ctx.dateStr` | `YYYY-MM-DD`(如 `'2026-10-01'`,可做日期区间比较) |
61
+ | `ctx.isWeekend()` | 周六/周日 |
62
+ | `ctx.inWindow(start, end)` | `HH:mm` 窗口命中,含起点不含终点 |
63
+ | `ctx.afterISO(iso)` / `ctx.beforeISO(iso)` | ISO 日期比较(节假日窗口、调价日等) |
64
+
65
+ 任一声场(语法错误、执行异常、返回非字符串、未配置 `tierFn`)→ 回退 `defaultTier`(默认 `"offPeak"`),绝不崩溃。旧字段 `peakWindows` 首次加载自动迁移为等价 `tierFn` 并写回配置文件(语义不变)。
66
+
67
+ 示例(档位数不限):
68
+
69
+ - 工作日峰谷 + 周末全天低谷(DeepSeek 2026-08-23 起官方新规):
70
+ `"(ctx) => ctx.isWeekend() ? 'offPeak' : (ctx.inWindow('09:00','12:00') || ctx.inWindow('14:00','18:00')) ? 'peak' : 'offPeak'"`
71
+ - 三段计价(尖峰/高峰/平段):
72
+ `"(ctx) => ctx.inWindow('11:00','12:00') ? 'sharp' : ctx.inWindow('09:00','12:00') ? 'peak' : 'valley'"`
73
+ 对应每个模型三方价格 `"sharp": {...}, "peak": {...}, "valley": {...}`,`labels` 同样按档位 id 添加。
74
+ - 单一平价(官方取消峰谷):`"(ctx) => 'offPeak'"`
75
+ - 国庆节假日全天低谷:
76
+ `"(ctx) => ctx.dateStr >= '2026-10-01' && ctx.dateStr <= '2026-10-07' ? 'offPeak' : (ctx.inWindow('09:00','12:00') ? 'peak' : 'offPeak')"`
77
+
78
+ > `tierFn` 为本地执行脚本(`new Function` 编译),仅应来自受信任的配置文件。
50
79
 
51
80
  ## 一键更新官方定价
52
81
 
@@ -54,9 +83,17 @@ pi install npm:@yalieny/pi-better-cost-display-footer
54
83
  /deepseek-pricing-update
55
84
  ```
56
85
 
57
- 该命令把抓取官方价目页、解析价格、更新配置文件的完整任务交给 agent 执行(自然语言指令,无需手动维护价格表)。命令会告知 agent 配置文件位置与 JSON 格式;agent 抓取 [DeepSeek 官方价目页](https://api-docs.deepseek.com/zh-cn/quick_start/pricing) 后更新 `providers.deepseek.models` 下各模型的 offPeak/peak 价格,其余字段不动。
86
+ 该命令把抓取官方价目页、解析价格、更新配置文件的完整任务交给 agent 执行(自然语言指令,无需手动维护价格表)。命令会告知 agent 配置文件位置与 JSON 格式;agent 抓取 [DeepSeek 官方价目页](https://api-docs.deepseek.com/zh-cn/quick_start/pricing) 后更新 `providers.deepseek.models` 下各模型的分档价格,其余字段不动。
87
+
88
+ ## 测试
89
+
90
+ ```bash
91
+ npm install && npm test
92
+ ```
58
93
 
59
94
  ## 说明
60
95
 
96
+ - 顶层仅配置全局 `cacheHitRatePrecision`;`timezone`/`effectiveFrom`/`currencySymbol`/`tierFn`/`defaultTier`/`labels`/`models` 均配置在对应 provider 下。
61
97
  - provider 必须在 `models.json` 中有定义;未列出的模型保持原价。
98
+ - 模型缺少当前档位价格时保持原价并打日志。
62
99
  - cost 仅影响 pi 本地用量统计,不影响 API 实际账单。
@@ -1,55 +1,44 @@
1
- // pi-better-cost-display-footer — 峰谷动态计价 + footer 增强扩展(原 pi-dynamic-cost / pi-better-footer)
1
+ // pi-better-cost-display-footer — 脚本化多段计价 + footer 增强扩展(原 pi-dynamic-cost / pi-better-footer)
2
2
  // namespace: @YalienY
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 事件)时,
7
- // 按配置时区与峰谷窗口计算当前档位并重注册 provider 的 cost。
6
+ // 在 session_start 和每次发送消息(input 事件)时,按各 provider 的 tierFn 脚本函数计算当前档位并重注册其 cost。
8
7
  // 跨时段边界的请求按发送时刻计费。effectiveFrom(含)之前不做任何事。
9
8
  //
9
+ // 档位 = 任意字符串 id(如 "peak" / "flat" / "valley" / "sharp"),不绑定峰值/谷值语义:
10
+ // {
11
+ // "timezone": "Asia/Shanghai", // 计费时区,默认 Asia/Shanghai
12
+ // "effectiveFrom": "2026-08-17T00:00:00+08:00", // 可选,生效时间(含)
13
+ // "tierFn": "(ctx) => ctx.isWeekend() ? 'offPeak' : (ctx.inWindow('09:00','12:00') || ctx.inWindow('14:00','18:00')) ? 'peak' : 'offPeak'",
14
+ // "defaultTier": "offPeak", // 可选,tierFn 缺失/语法错误/异常/返回非字符串时的兜底档位
15
+ // "labels": { "peak": { "text": "(梁文峰)", "color": "error" } }, // 可选,各档位 footer 标签
16
+ // "currencySymbol": "¥"
17
+ // }
18
+ // tierFn 上下文 ctx(均为配置时区本地值):
19
+ // now / timezone / weekday(0=周日…6=周六)/ hour / minute / dateStr(YYYY-MM-DD)
20
+ // isWeekend() 周六/周日
21
+ // inWindow(start, end) HH:mm 窗口命中,含起点不含终点
22
+ // afterISO(iso) / beforeISO(iso) ISO 日期比较(节假日、调价日等)
23
+ // 价格按 models.<modelId>.<tierId> { input, output, cacheRead, cacheWrite } 配置,档位数不限。
24
+ // 旧字段 peakWindows 首次读取时自动迁移为等价 tierFn 并写回配置文件(语义不变,写回失败仍走兜底逻辑);
25
+ // 旧"offPeak/peak 两段"结构本身即是多段特例,无需迁移。
26
+ //
10
27
  // 生效范围:
11
28
  // - 动态计价与档位标签:所有在 providers 中配置了计价模型的 provider 生效
12
29
  // (不再限定 deepseek 官方,其余 provider 同样适用)。
13
- // - 自定义 footer 与 CH 精度:所有 provider 适用。
14
- //
15
- // 配置结构(pi-better-cost-display-footer.json):
16
- // {
17
- // "timezone": "Asia/Shanghai", // 计费时区,默认 Asia/Shanghai
18
- // "effectiveFrom": "2026-08-17T00:00:00+08:00", // 可选,生效时间(含)
19
- // "cacheHitRatePrecision": 3, // 可选,footer CH 命中率小数位,默认 1
20
- // "currencySymbol": "¥", // 可选,计费金额货币符号,默认 $
21
- // "peakWindows": [ // 高峰窗口,含起点不含终点
22
- // { "start": "09:00", "end": "12:00" },
23
- // { "start": "14:00", "end": "18:00" }
24
- // ],
25
- // "labels": { // 可选,档位标签文案与颜色
26
- // "peak": { "text": "(梁文峰)", "color": "error" }, // color: ThemeColor 名或 #rrggbb
27
- // "offPeak": { "text": "(梁文谷)", "color": "success" }
28
- // },
29
- // "providers": { // 每个 key 都是一个 provider id
30
- // "deepseek": {
31
- // "models": {
32
- // "<modelId>": {
33
- // "offPeak": { "input": 1.5, "output": 4.5, "cacheRead": 0.05, "cacheWrite": 0 },
34
- // "peak": { "input": 3.0, "output": 9.0, "cacheRead": 0.1, "cacheWrite": 0 }
35
- // }
36
- // }
37
- // },
38
- // "agent-plan": { "models": { ... } } // 同样生效
39
- // }
40
- // }
41
- // 价格单位:每百万 tokens(元),与 pi 的 calculateCost 约定一致(rate/1e6 × tokens),
42
- // 即官方价格表原值直接填入:如输入谷值 1.5 元/M → "input": 1.5。
30
+ // - 自定义 footer 与 CH 精度:所有 provider 适用;未配置模型仅修改 CH,其余保持内置默认。
43
31
  //
44
32
  // 计费显示:用 ctx.ui.setFooter 替换内置 footer(升级安全,扩展代码不随 pi 更新)。
45
33
  // 复刻内置 footer 渲染(footer.js),并加两处增强:
46
- // 1. 激活模型所属 provider 有动态计价配置时,价格后紧跟档位标签:
47
- // 峰值 →(梁文峰)默认红色,谷值 →(梁文谷)默认绿色,如 ¥0.047(梁文峰)。
48
- // 文案与颜色可由 labels 配置覆盖(color 支持主题色名或 #rrggbb)。
34
+ // 1. 激活模型有动态计价配置时,价格后紧跟档位标签(labels 按档位 id 查,未配置则不显示标签):
35
+ // 如 ¥0.047(梁文峰)。color 支持主题色名或 #rrggbb。
49
36
  // 2. CH 缓存命中率小数位由 cacheHitRatePrecision 控制(内置硬编码 1 位),所有 provider 适用。
50
- // 3. 计费金额符号由 currencySymbol 控制(内置硬编码 $)。
37
+ // 3. 计费金额符号由各 provider 的 currencySymbol 控制(内置硬编码 $)。
51
38
  //
52
39
  // 注意:
40
+ // - 旧版顶层计价字段与 provider 级 peakWindows 首次读取时自动迁移并写回配置文件。
41
+ // - tierFn 用 new Function 编译执行(本地受信任配置,无沙箱;语法错误启动时即报错并回退 defaultTier)。
53
42
  // - provider 必须在 models.json 中有定义:扩展读取其完整模型定义,仅替换 cost 后整体重注册
54
43
  // (registerProvider 的 models 是整体替换语义)。未列出的模型保持原价。
55
44
  // - cost 仅影响 pi 本地用量统计,不影响 API 实际账单。
@@ -73,24 +62,94 @@ import type {
73
62
  const CONFIG_DIR = process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
74
63
  const CONFIG_FILE = join(CONFIG_DIR, "pi-better-cost-display-footer.json");
75
64
 
76
- /** 档位标签默认文案/颜色(labels 未配置时兜底) */
77
- const DEFAULT_LABELS: Record<Tier, TierLabel> = {
65
+ /** 档位 id:任意字符串。内置默认仅用到 "peak"/"offPeak",但不绑定该语义 */
66
+ type Tier = string;
67
+
68
+ interface TierRates {
69
+ input: number;
70
+ output: number;
71
+ cacheRead: number;
72
+ cacheWrite: number;
73
+ }
74
+
75
+ interface TierLabel {
76
+ text: string;
77
+ /** ThemeColor 主题色名(如 "error")或 #rrggbb 十六进制 */
78
+ color: string;
79
+ }
80
+
81
+ /** 档位标签默认文案/颜色(labels 未配置时兜底;仅对旧 peak/offPeak 生效) */
82
+ const DEFAULT_LABELS: Record<string, TierLabel> = {
78
83
  peak: { text: "(梁文峰)", color: "error" },
79
84
  offPeak: { text: "(梁文谷)", color: "success" },
80
85
  };
81
86
 
87
+ /** tierFn 脚本函数运行上下文(全部为配置时区本地值) */
88
+ interface TierCtx {
89
+ now: Date;
90
+ timezone: string;
91
+ /** 0=周日 … 6=周六 */
92
+ weekday: number;
93
+ hour: number;
94
+ minute: number;
95
+ /** YYYY-MM-DD */
96
+ dateStr: string;
97
+ isWeekend(): boolean;
98
+ /** HH:mm 窗口命中,含起点不含终点 */
99
+ inWindow(start: string, end: string): boolean;
100
+ afterISO(iso: string): boolean;
101
+ beforeISO(iso: string): boolean;
102
+ }
103
+
104
+ type TierFn = (ctx: TierCtx) => unknown;
105
+
106
+ interface FooterLabel {
107
+ text: string;
108
+ color: string;
109
+ }
110
+
111
+ interface ProviderPricing {
112
+ timezone?: string;
113
+ effectiveFrom?: string;
114
+ currencySymbol?: string;
115
+ /** 档位判定脚本函数(JS 函数表达式字符串),返回任意档位 id */
116
+ tierFn?: string;
117
+ /** tierFn 缺失/语法错误/执行异常/返回非字符串时的兜底档位,默认 "offPeak" */
118
+ defaultTier?: Tier;
119
+ /** 旧格式峰值窗口(含起点不含终点);首次加载自动迁移为 tierFn 并写回 */
120
+ peakWindows?: { start: string; end: string }[];
121
+ /** 各档位 footer 标签(按档位 id 查找),未配置的档位不显示标签 */
122
+ labels?: Record<string, TierLabel>;
123
+ /** 每个模型的多段价格:模型 id → 档位 id → 价格 */
124
+ models: Record<string, Record<Tier, TierRates>>;
125
+ }
126
+
127
+ interface DynamicConfig {
128
+ cacheHitRatePrecision?: number;
129
+ providers?: Record<string, ProviderPricing>;
130
+ }
131
+
132
+ interface LegacyDynamicConfig extends DynamicConfig {
133
+ timezone?: string;
134
+ effectiveFrom?: string;
135
+ currencySymbol?: string;
136
+ peakWindows?: { start: string; end: string }[];
137
+ labels?: { peak?: TierLabel; offPeak?: TierLabel };
138
+ tierFn?: string;
139
+ defaultTier?: Tier;
140
+ }
141
+
82
142
  /** 内置默认配置:配置文件缺失/损坏时的兜底,开箱即用(DeepSeek 官方价目,2026-08 抓取) */
83
143
  const DEFAULT_CONFIG: DynamicConfig = {
84
- timezone: "Asia/Shanghai",
85
144
  cacheHitRatePrecision: 1,
86
- currencySymbol: "¥",
87
- peakWindows: [
88
- { start: "09:00", end: "12:00" },
89
- { start: "14:00", end: "18:00" },
90
- ],
91
- labels: DEFAULT_LABELS,
92
145
  providers: {
93
146
  deepseek: {
147
+ timezone: "Asia/Shanghai",
148
+ currencySymbol: "¥",
149
+ // 周末全天低谷(DeepSeek 2026-08-23 起官方新规)+ 工作日峰谷窗口
150
+ tierFn: "(ctx) => ctx.isWeekend() ? 'offPeak' : (ctx.inWindow('09:00','12:00') || ctx.inWindow('14:00','18:00')) ? 'peak' : 'offPeak'",
151
+ defaultTier: "offPeak",
152
+ labels: DEFAULT_LABELS,
94
153
  models: {
95
154
  "deepseek-v4-flash": {
96
155
  offPeak: { input: 1.5, output: 4.5, cacheRead: 0.05, cacheWrite: 0 },
@@ -105,7 +164,15 @@ const DEFAULT_CONFIG: DynamicConfig = {
105
164
  },
106
165
  };
107
166
 
108
- /** 深合并:文件配置覆盖内置默认(顶层 + provider + model 三级),部分配置文件也能生效 */
167
+ let lastAppliedKey: string | null = null;
168
+ /** 最近一次事件的 ctx,footer 闭包读取;随 input/model_select 事件刷新 */
169
+ let activeCtx: ExtensionContext | null = null;
170
+ /** 当前生效配置,apply() 时刷新,footer 每帧直接读,避免频繁读盘 */
171
+ let currentCfg: DynamicConfig | null = null;
172
+ /** 当前档位标签(null = 不显示),apply() 时刷新 */
173
+ let footerLabel: FooterLabel | null = null;
174
+
175
+ /** 深合并:文件配置覆盖内置默认(全局 + provider + model 三级),部分配置文件也能生效 */
109
176
  function mergeConfig(base: DynamicConfig, override: DynamicConfig): DynamicConfig {
110
177
  const merged: DynamicConfig = { ...base, ...override };
111
178
  const providers = { ...base.providers };
@@ -120,48 +187,35 @@ function mergeConfig(base: DynamicConfig, override: DynamicConfig): DynamicConfi
120
187
  return merged;
121
188
  }
122
189
 
123
- interface TierRates {
124
- input: number;
125
- output: number;
126
- cacheRead: number;
127
- cacheWrite: number;
128
- }
129
-
130
- interface TierLabel {
131
- text: string;
132
- /** ThemeColor 主题色名(如 "error")或 #rrggbb 十六进制 */
133
- color: string;
134
- }
135
-
136
- interface ProviderPricing {
137
- models: Record<string, { offPeak: TierRates; peak: TierRates }>;
138
- }
139
-
140
- interface DynamicConfig {
141
- timezone?: string;
142
- effectiveFrom?: string;
143
- cacheHitRatePrecision?: number;
144
- currencySymbol?: string;
145
- peakWindows?: { start: string; end: string }[];
146
- labels?: { peak?: TierLabel; offPeak?: TierLabel };
147
- providers?: Record<string, ProviderPricing>;
190
+ /** 旧格式字段;命中任一字段即执行一次磁盘配置升级。 */
191
+ function isLegacyConfig(fileCfg: LegacyDynamicConfig): boolean {
192
+ return ["timezone", "effectiveFrom", "currencySymbol", "peakWindows", "labels", "tierFn", "defaultTier"].some((key) =>
193
+ Object.prototype.hasOwnProperty.call(fileCfg, key),
194
+ );
148
195
  }
149
196
 
150
- type Tier = "peak" | "offPeak";
151
-
152
- interface FooterLabel {
153
- text: string;
154
- color: string;
197
+ /** 把旧格式顶层 provider 配置下沉到每个 provider。 */
198
+ function normalizeConfig(fileCfg: LegacyDynamicConfig): DynamicConfig {
199
+ const { timezone, effectiveFrom, currencySymbol, peakWindows, labels, tierFn, defaultTier, providers, cacheHitRatePrecision } = fileCfg;
200
+ const legacyProviderDefaults: Partial<ProviderPricing> = {};
201
+ if (timezone !== undefined) legacyProviderDefaults.timezone = timezone;
202
+ if (effectiveFrom !== undefined) legacyProviderDefaults.effectiveFrom = effectiveFrom;
203
+ if (currencySymbol !== undefined) legacyProviderDefaults.currencySymbol = currencySymbol;
204
+ if (peakWindows !== undefined) legacyProviderDefaults.peakWindows = peakWindows;
205
+ if (labels !== undefined) legacyProviderDefaults.labels = labels as Record<string, TierLabel>;
206
+ if (tierFn !== undefined) legacyProviderDefaults.tierFn = tierFn;
207
+ if (defaultTier !== undefined) legacyProviderDefaults.defaultTier = defaultTier;
208
+ return {
209
+ cacheHitRatePrecision,
210
+ providers: Object.fromEntries(
211
+ Object.entries(providers || {}).map(([providerId, provider]) => [
212
+ providerId,
213
+ { ...legacyProviderDefaults, ...provider },
214
+ ]),
215
+ ),
216
+ };
155
217
  }
156
218
 
157
- let lastAppliedKey: string | null = null;
158
- /** 最近一次事件的 ctx,footer 闭包读取;随 input/model_select 事件刷新 */
159
- let activeCtx: ExtensionContext | null = null;
160
- /** 当前生效配置,apply() 时刷新,footer 每帧直接读,避免频繁读盘 */
161
- let currentCfg: DynamicConfig | null = null;
162
- /** 当前档位标签(null = 不显示),apply() 时刷新 */
163
- let footerLabel: FooterLabel | null = null;
164
-
165
219
  export function loadDynamicConfig(): DynamicConfig | null {
166
220
  try {
167
221
  if (!existsSync(CONFIG_FILE)) {
@@ -171,8 +225,20 @@ export function loadDynamicConfig(): DynamicConfig | null {
171
225
  console.log(`[pi-better-cost-display-footer] 未找到配置文件,已生成默认配置 ${CONFIG_FILE}`);
172
226
  return DEFAULT_CONFIG;
173
227
  }
174
- const fileCfg = JSON.parse(readFileSync(CONFIG_FILE, "utf8")) as DynamicConfig;
175
- return mergeConfig(DEFAULT_CONFIG, fileCfg);
228
+ const fileCfg = JSON.parse(readFileSync(CONFIG_FILE, "utf8")) as LegacyDynamicConfig;
229
+ const normalizedCfg = normalizeConfig(fileCfg);
230
+ const migrated = migratePeakWindows(normalizedCfg);
231
+ if (isLegacyConfig(fileCfg) || migrated) {
232
+ try {
233
+ writeFileSync(CONFIG_FILE, JSON.stringify(normalizedCfg, null, 2) + "\n");
234
+ console.log(`[pi-better-cost-display-footer] 旧配置已自动升级(provider 分层 + peakWindows → tierFn)${CONFIG_FILE}`);
235
+ } catch (err) {
236
+ console.error(
237
+ `[pi-better-cost-display-footer] 旧配置升级写入失败:${err instanceof Error ? err.message : String(err)}`,
238
+ );
239
+ }
240
+ }
241
+ return mergeConfig(DEFAULT_CONFIG, normalizedCfg);
176
242
  } catch (err) {
177
243
  console.error(
178
244
  `[pi-better-cost-display-footer] 配置读取失败: ${err instanceof Error ? err.message : String(err)},回退内置默认配置`,
@@ -181,17 +247,35 @@ export function loadDynamicConfig(): DynamicConfig | null {
181
247
  }
182
248
  }
183
249
 
184
- /** 当前时刻(配置时区)的 { hour, minute },date 参数仅供测试 */
185
- export function nowClock(timezone: string, date: Date = new Date()): { hour: number; minute: number } {
250
+ /** 构建 tierFn 运行上下文(全部为配置时区本地值) */
251
+ export function buildTierCtx(timezone: string, date: Date = new Date()): TierCtx {
186
252
  const parts = new Intl.DateTimeFormat("en-US", {
187
253
  timeZone: timezone,
254
+ weekday: "short",
255
+ year: "numeric",
256
+ month: "2-digit",
257
+ day: "2-digit",
188
258
  hour: "2-digit",
189
259
  minute: "2-digit",
190
260
  hourCycle: "h23",
191
261
  }).formatToParts(date);
192
- const get = (t: Intl.DateTimeFormatPartTypes): string =>
193
- parts.find((p) => p.type === t)?.value ?? "00";
194
- return { hour: Number(get("hour")), minute: Number(get("minute")) };
262
+ const get = (t: string): string => parts.find((p) => p.type === t)?.value ?? "";
263
+ const weekdayMap: Record<string, number> = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
264
+ const weekday = weekdayMap[get("weekday")] ?? 0;
265
+ const hour = Number(get("hour"));
266
+ const minute = Number(get("minute"));
267
+ return {
268
+ now: date,
269
+ timezone,
270
+ weekday,
271
+ hour,
272
+ minute,
273
+ dateStr: `${get("year")}-${get("month")}-${get("day")}`,
274
+ isWeekend: () => weekday === 0 || weekday === 6,
275
+ inWindow: (start, end) => inWindow({ hour, minute }, { start, end }),
276
+ afterISO: (iso) => date.getTime() >= Date.parse(iso),
277
+ beforeISO: (iso) => date.getTime() < Date.parse(iso),
278
+ };
195
279
  }
196
280
 
197
281
  /** 窗口含起点不含终点,如 { start: "09:00", end: "12:00" } */
@@ -205,13 +289,80 @@ export function inWindow(
205
289
  return t >= sh * 60 + sm && t < eh * 60 + em;
206
290
  }
207
291
 
208
- /** 命中任一高峰窗口返回 "peak",否则 "offPeak" */
209
- export function currentTier(cfg: DynamicConfig, clock: { hour: number; minute: number }): Tier {
292
+ /** tierFn 编译缓存:源串 → 函数(null = 编译失败,仅报错一次) */
293
+ const tierFnCache = new Map<string, TierFn | null>();
294
+
295
+ function compileTierFn(src: string): TierFn | null {
296
+ try {
297
+ const fn = new Function(`return (${src})`)();
298
+ if (typeof fn === "function") return fn as TierFn;
299
+ } catch (err) {
300
+ console.error(
301
+ `[pi-better-cost-display-footer] tierFn 语法错误: ${err instanceof Error ? err.message : String(err)};tierFn: ${src.slice(0, 80)}`,
302
+ );
303
+ return null;
304
+ }
305
+ console.error(`[pi-better-cost-display-footer] tierFn 不是函数表达式: ${src.slice(0, 80)}`);
306
+ return null;
307
+ }
308
+
309
+ function getTierFn(src: string): TierFn | null {
310
+ let fn = tierFnCache.get(src);
311
+ if (fn === undefined) {
312
+ fn = compileTierFn(src);
313
+ tierFnCache.set(src, fn);
314
+ }
315
+ return fn;
316
+ }
317
+
318
+ /** 把旧 peakWindows 转成等价 tierFn 源码(语义不变,返回 "peak"/"offPeak") */
319
+ export function windowsToTierFn(windows: { start: string; end: string }[]): string {
320
+ if (!windows.length) return "(ctx) => 'offPeak'";
321
+ const conds = windows.map((w) => `ctx.inWindow('${w.start}','${w.end}')`);
322
+ return `(ctx) => ${conds.join(" || ")} ? 'peak' : 'offPeak'`;
323
+ }
324
+
325
+ /** 旧配置(provider 级 peakWindows)→ tierFn;返回是否有变更 */
326
+ export function migratePeakWindows(cfg: DynamicConfig): boolean {
327
+ let changed = false;
328
+ for (const provider of Object.values(cfg.providers || {})) {
329
+ if (provider.tierFn || !provider.peakWindows?.length) continue;
330
+ provider.tierFn = windowsToTierFn(provider.peakWindows);
331
+ delete provider.peakWindows;
332
+ changed = true;
333
+ }
334
+ return changed;
335
+ }
336
+
337
+ /**
338
+ * 计算当前档位(任意字符串 id)。优先级:tierFn 脚本 → 旧 peakWindows 兜底 → defaultTier。
339
+ * tierFn 抛出异常或返回非字符串(空串也算)时回退 defaultTier(默认 "offPeak")。
340
+ */
341
+ export function currentTier(cfg: ProviderPricing, date: Date = new Date()): Tier {
342
+ const defaultTier: Tier = typeof cfg.defaultTier === "string" && cfg.defaultTier ? cfg.defaultTier : "offPeak";
343
+ if (cfg.tierFn) {
344
+ const fn = getTierFn(cfg.tierFn);
345
+ if (!fn) return defaultTier;
346
+ try {
347
+ const result = fn(buildTierCtx(cfg.timezone || "Asia/Shanghai", date));
348
+ if (typeof result === "string" && result) return result;
349
+ if (result !== undefined && result !== null) {
350
+ console.warn(`[pi-better-cost-display-footer] tierFn 返回未知档位 "${String(result)}",回退 ${defaultTier}`);
351
+ }
352
+ return defaultTier;
353
+ } catch (err) {
354
+ console.error(
355
+ `[pi-better-cost-display-footer] tierFn 执行异常: ${err instanceof Error ? err.message : String(err)},回退 ${defaultTier}`,
356
+ );
357
+ return defaultTier;
358
+ }
359
+ }
360
+ const ctx = buildTierCtx(cfg.timezone || "Asia/Shanghai", date);
210
361
  const windows = cfg.peakWindows || [];
211
- return windows.some((w) => inWindow(clock, w)) ? "peak" : "offPeak";
362
+ return windows.some((w) => inWindow({ hour: ctx.hour, minute: ctx.minute }, w)) ? "peak" : "offPeak";
212
363
  }
213
364
 
214
- export function isEffective(cfg: DynamicConfig, date: Date = new Date()): boolean {
365
+ export function isEffective(cfg: Pick<ProviderPricing, "effectiveFrom">, date: Date = new Date()): boolean {
215
366
  if (!cfg.effectiveFrom) return true;
216
367
  return date.getTime() >= Date.parse(cfg.effectiveFrom);
217
368
  }
@@ -233,7 +384,11 @@ function patchProviderConfig(
233
384
  const rates = cfg.providers?.[providerId]?.models || {};
234
385
  const models = provider.models.map((m) => {
235
386
  const rate = rates[m.id]?.[tier];
236
- return rate ? { ...m, cost: rate } : m;
387
+ if (!rate) {
388
+ console.warn(`[pi-better-cost-display-footer] ${providerId}.${m.id} 没有档位 "${tier}" 的价格,保持原价`);
389
+ return m;
390
+ }
391
+ return { ...m, cost: rate };
237
392
  });
238
393
  return { ...provider, models };
239
394
  }
@@ -283,13 +438,23 @@ function dimSkip(text: string, marker: string, theme: Theme): string {
283
438
  );
284
439
  }
285
440
 
286
- /** 计算档位标签;当前 provider 无动态计价配置 / 配置缺失 / 未生效 → null */
441
+ /** 读取当前模型的生效 provider 配置;未配置 / 未生效 → null。 */
442
+ function getActiveModelPricing(
443
+ cfg: DynamicConfig | null,
444
+ ctx: ExtensionContext | null,
445
+ ): ProviderPricing | null {
446
+ const model = ctx?.model;
447
+ const provider = model && cfg?.providers?.[model.provider];
448
+ if (!model || !provider || !provider.models[model.id] || !isEffective(provider)) return null;
449
+ return provider;
450
+ }
451
+
452
+ /** 计算档位标签;当前模型无动态计价配置 / 配置缺失 / 未生效 / 档位未配置标签 → null */
287
453
  function computeFooterLabel(cfg: DynamicConfig | null, ctx: ExtensionContext | null): FooterLabel | null {
288
- if (!cfg || !isEffective(cfg)) return null;
289
- const providerId = ctx?.model?.provider;
290
- if (!providerId || !cfg.providers?.[providerId]) return null;
291
- const tier = currentTier(cfg, nowClock(cfg.timezone || "Asia/Shanghai"));
292
- return cfg.labels?.[tier] ?? DEFAULT_LABELS[tier];
454
+ const provider = getActiveModelPricing(cfg, ctx);
455
+ if (!provider) return null;
456
+ const tier = currentTier(provider);
457
+ return provider.labels?.[tier] ?? DEFAULT_LABELS[tier] ?? null;
293
458
  }
294
459
 
295
460
  function installCustomFooter(ctx: ExtensionContext): void {
@@ -354,11 +519,12 @@ function installCustomFooter(ctx: ExtensionContext): void {
354
519
  }
355
520
  // 订阅制 provider 无法从扩展读取 modelRuntime,退化为内置的特例
356
521
  const usingSubscription = c?.model?.provider === "kimi-coding";
357
- // 档位标签紧跟价格之后:峰值红(梁文峰)/ 谷值绿(梁文谷),如 ¥0.047(梁文峰)
358
- const labelInfo = footerLabel;
522
+ // 未配置模型只改 CH;金额符号和档位标签保持内置默认
523
+ const customPricing = getActiveModelPricing(currentCfg, c);
524
+ const labelInfo = customPricing ? footerLabel : null;
359
525
  const labelStr = labelInfo ? colorize(theme, labelInfo.color, labelInfo.text) : "";
360
526
  if (totals.cost || usingSubscription) {
361
- const symbol = currentCfg?.currencySymbol ?? "$";
527
+ const symbol = customPricing?.currencySymbol ?? "$";
362
528
  const costStr = `${symbol}${totals.cost.toFixed(3)}${usingSubscription ? " (sub)" : ""}`;
363
529
  statsParts.push(costStr + labelStr);
364
530
  }
@@ -426,12 +592,16 @@ function apply(pi: ExtensionAPI, ctx: ExtensionContext): void {
426
592
  const cfg = loadDynamicConfig();
427
593
  currentCfg = cfg;
428
594
  footerLabel = computeFooterLabel(cfg, ctx);
429
- if (!cfg || !isEffective(cfg)) return;
430
- const tier = currentTier(cfg, nowClock(cfg.timezone || "Asia/Shanghai"));
431
- const key = `${tier}|${JSON.stringify(cfg)}`;
595
+ if (!cfg) return;
596
+ const providerTiers = Object.entries(cfg.providers || {}).map(([providerId, provider]) => {
597
+ const tier = isEffective(provider) ? currentTier(provider) : null;
598
+ return [providerId, tier] as const;
599
+ });
600
+ const key = `${JSON.stringify(cfg)}|${JSON.stringify(providerTiers)}`;
432
601
  if (key === lastAppliedKey) return;
433
602
  // 动态计价对所有配置了 providers 的模型生效(不限定 deepseek)
434
- for (const providerId of Object.keys(cfg.providers || {})) {
603
+ for (const [providerId, tier] of providerTiers) {
604
+ if (!tier) continue;
435
605
  try {
436
606
  const config = patchProviderConfig(cfg, providerId, tier);
437
607
  if (!config) continue;
@@ -459,7 +629,8 @@ export default function (pi: ExtensionAPI): void {
459
629
  3. 配置 JSON 格式(只动 providers 部分):
460
630
  {
461
631
  "timezone": "Asia/Shanghai",
462
- "peakWindows": [{ "start": "09:00", "end": "12:00" }, { "start": "14:00", "end": "18:00" }],
632
+ "tierFn": "(ctx) => ctx.isWeekend() ? 'offPeak' : (ctx.inWindow('09:00','12:00') || ctx.inWindow('14:00','18:00')) ? 'peak' : 'offPeak'",
633
+ "defaultTier": "offPeak",
463
634
  "labels": { "peak": { "text": "(梁文峰)", "color": "error" }, "offPeak": { "text": "(梁文谷)", "color": "success" } },
464
635
  "providers": {
465
636
  "<providerId>": {
@@ -473,12 +644,15 @@ export default function (pi: ExtensionAPI): void {
473
644
  }
474
645
  }
475
646
  价格单位:元/百万tokens;cacheWrite 恒为 0。页面"空闲时段"价格 → offPeak,"高峰时段"价格 → peak。
476
- 4. 页面未列出的模型保持原样;配置文件中其他所有字段(timezone/peakWindows/labels/cacheHitRatePrecision/currencySymbol 及其他 provider)一律不动。
647
+ 档位是任意字符串 id,可多段:若官方页出现更多价格档(如 尖峰/平段),给对应模型每档都配价格,并在 tierFn 中按 ctx(isWeekend/inWindow/afterISO/beforeISO 等)判定返回哪个档位 id。
648
+ 4. 页面未列出的模型保持原样;配置文件中其他所有字段(timezone/tierFn/defaultTier/labels/cacheHitRatePrecision/currencySymbol 及其他 provider)一律不动。
477
649
  5. 若配置文件不存在:扩展在启动时会自动生成默认配置文件,一般不会出现该情况;若确实不存在,按默认格式新建一份即可。
478
650
  6. 用 edit 精确修改,不要整体重写文件;完成后核对一遍数字与官方页面一致,并简要报告改了什么。
479
- 7. 峰谷价取消处理(两种情况):
480
- a. 官方页取消峰谷价(不再区分空闲/高峰时段,只有单一价格)→ 各模型 offPeak 与 peak 填相同价格,cacheWrite 仍为 0,并把 peakWindows 清空为 [](扩展恒按 offPeak 计价 = 平价格,配置结构保持有效)。
481
- b. 用户要求彻底取消动态计价 → 直接删除该 provider 的整个 providers.<providerId> 配置项(扩展会回退 models.json 原始 cost,且不显示档位标签)。`,
651
+ 7. 峰谷价调整(两种情况):
652
+ a. 官方取消峰谷价(不再区分空闲/高峰时段,只有单一价格)→ 各模型只保留一个档位(如 offPeak 填该价格,其余档位删除),并把 tierFn 改为 "(ctx) => 'offPeak'"(恒按低谷价 = 平价格,配置结构保持有效)。
653
+ b. 用户要求彻底取消动态计价 → 直接删除该 provider 的整个 providers.<providerId> 配置项(扩展会回退 models.json 原始 cost,且不显示档位标签)。
654
+ 8. 档位判定 tierFn 是 JS 函数表达式字符串(JSON 内用单引号引字符串),上下文 ctx 提供:now / timezone / weekday(0=周日)/ hour / minute / dateStr / isWeekend() / inWindow(start,end) / afterISO(iso) / beforeISO(iso)。DeepSeek 2026-08-23 起周末全天低谷的示例:"(ctx) => ctx.isWeekend() ? 'offPeak' : (ctx.inWindow('09:00','12:00') || ctx.inWindow('14:00','18:00')) ? 'peak' : 'offPeak'"。
655
+ 注意:配置中已有 tierFn 时只更新价格,不要改 tierFn 与 defaultTier。`,
482
656
  { deliverAs: "followUp" },
483
657
  );
484
658
  },
package/package.json CHANGED
@@ -1,14 +1,20 @@
1
1
  {
2
2
  "name": "@yalieny/pi-better-cost-display-footer",
3
- "version": "1.0.2",
4
- "description": "pi extension: peak/off-peak dynamic pricing per provider with an enhanced footer (tier label, cache-hit-rate precision, custom currency symbol)",
3
+ "version": "2.0.0",
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": ["pi-package", "pi-extension"],
6
6
  "license": "MIT",
7
7
  "type": "module",
8
+ "scripts": {
9
+ "test": "node --test test/tier.test.ts"
10
+ },
8
11
  "peerDependencies": {
9
12
  "@earendil-works/pi-coding-agent": "*",
10
13
  "@earendil-works/pi-tui": "*"
11
14
  },
15
+ "devDependencies": {
16
+ "@earendil-works/pi-tui": "^0.80.10"
17
+ },
12
18
  "files": ["extensions", "README.md"],
13
19
  "pi": {
14
20
  "extensions": ["./extensions"]