@yalieny/pi-better-cost-display-footer 1.1.0 → 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,12 +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
- - **配置升级**:旧版顶层计价配置首次启动自动迁移到 provider 分层格式。
9
+ - **配置升级**:旧版顶层计价配置与 `peakWindows` 首次启动自动迁移为 provider 分层 + `tierFn` 格式(语义不变)。
10
10
 
11
11
  ## 安装
12
12
 
@@ -26,12 +26,10 @@ pi install npm:@yalieny/pi-better-cost-display-footer
26
26
  "providers": {
27
27
  "deepseek": {
28
28
  "timezone": "Asia/Shanghai",
29
- "effectiveFrom": "2026-08-17T00:00:00+08:00",
29
+ "effectiveFrom": "2026-08-23T00:00:00+08:00",
30
30
  "currencySymbol": "¥",
31
- "peakWindows": [
32
- { "start": "09:00", "end": "12:00" },
33
- { "start": "14:00", "end": "18:00" }
34
- ],
31
+ "tierFn": "(ctx) => ctx.isWeekend() ? 'offPeak' : (ctx.inWindow('09:00','12:00') || ctx.inWindow('14:00','18:00')) ? 'peak' : 'offPeak'",
32
+ "defaultTier": "offPeak",
35
33
  "labels": {
36
34
  "peak": { "text": "(梁文峰)", "color": "error" },
37
35
  "offPeak": { "text": "(梁文谷)", "color": "success" }
@@ -39,7 +37,7 @@ pi install npm:@yalieny/pi-better-cost-display-footer
39
37
  "models": {
40
38
  "deepseek-v4-flash": {
41
39
  "offPeak": { "input": 1.5, "output": 4.5, "cacheRead": 0.05, "cacheWrite": 0 },
42
- "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 }
43
41
  }
44
42
  }
45
43
  }
@@ -47,7 +45,37 @@ pi install npm:@yalieny/pi-better-cost-display-footer
47
45
  }
48
46
  ```
49
47
 
50
- 价格单位:每百万 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` 编译),仅应来自受信任的配置文件。
51
79
 
52
80
  ## 一键更新官方定价
53
81
 
@@ -55,10 +83,17 @@ pi install npm:@yalieny/pi-better-cost-display-footer
55
83
  /deepseek-pricing-update
56
84
  ```
57
85
 
58
- 该命令把抓取官方价目页、解析价格、更新配置文件的完整任务交给 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
+ ```
59
93
 
60
94
  ## 说明
61
95
 
62
- - 顶层仅配置全局 `cacheHitRatePrecision`;`timezone`/`effectiveFrom`/`currencySymbol`/`peakWindows`/`labels`/`models` 均配置在对应 provider 下。
96
+ - 顶层仅配置全局 `cacheHitRatePrecision`;`timezone`/`effectiveFrom`/`currencySymbol`/`tierFn`/`defaultTier`/`labels`/`models` 均配置在对应 provider 下。
63
97
  - provider 必须在 `models.json` 中有定义;未列出的模型保持原价。
98
+ - 模型缺少当前档位价格时保持原价并打日志。
64
99
  - cost 仅影响 pi 本地用量统计,不影响 API 实际账单。
@@ -1,56 +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
30
  // - 自定义 footer 与 CH 精度:所有 provider 适用;未配置模型仅修改 CH,其余保持内置默认。
14
31
  //
15
- // 配置结构(pi-better-cost-display-footer.json):
16
- // {
17
- // "cacheHitRatePrecision": 3, // 全局:footer CH 命中率小数位,默认 1
18
- // "providers": { // provider 特定配置
19
- // "deepseek": {
20
- // "timezone": "Asia/Shanghai", // 计费时区,默认 Asia/Shanghai
21
- // "effectiveFrom": "2026-08-17T00:00:00+08:00", // 可选,生效时间(含)
22
- // "currencySymbol": "¥", // 可选,计费金额货币符号,默认 $
23
- // "peakWindows": [ // 高峰窗口,含起点不含终点
24
- // { "start": "09:00", "end": "12:00" },
25
- // { "start": "14:00", "end": "18:00" }
26
- // ],
27
- // "labels": { // 可选,档位标签文案与颜色
28
- // "peak": { "text": "(梁文峰)", "color": "error" },
29
- // "offPeak": { "text": "(梁文谷)", "color": "success" }
30
- // },
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。
43
- //
44
32
  // 计费显示:用 ctx.ui.setFooter 替换内置 footer(升级安全,扩展代码不随 pi 更新)。
45
33
  // 复刻内置 footer 渲染(footer.js),并加两处增强:
46
- // 1. 激活模型有动态计价配置时,价格后紧跟档位标签:
47
- // 峰值 →(梁文峰)默认红色,谷值 →(梁文谷)默认绿色,如 ¥0.047(梁文峰)。
48
- // 文案与颜色可由 labels 配置覆盖(color 支持主题色名或 #rrggbb)。
34
+ // 1. 激活模型有动态计价配置时,价格后紧跟档位标签(labels 按档位 id 查,未配置则不显示标签):
35
+ // 如 ¥0.047(梁文峰)。color 支持主题色名或 #rrggbb。
49
36
  // 2. CH 缓存命中率小数位由 cacheHitRatePrecision 控制(内置硬编码 1 位),所有 provider 适用。
50
37
  // 3. 计费金额符号由各 provider 的 currencySymbol 控制(内置硬编码 $)。
51
38
  //
52
39
  // 注意:
53
- // - 旧版顶层计价字段首次读取时自动下沉到各 provider 并写回配置文件。
40
+ // - 旧版顶层计价字段与 provider 级 peakWindows 首次读取时自动迁移并写回配置文件。
41
+ // - tierFn 用 new Function 编译执行(本地受信任配置,无沙箱;语法错误启动时即报错并回退 defaultTier)。
54
42
  // - provider 必须在 models.json 中有定义:扩展读取其完整模型定义,仅替换 cost 后整体重注册
55
43
  // (registerProvider 的 models 是整体替换语义)。未列出的模型保持原价。
56
44
  // - cost 仅影响 pi 本地用量统计,不影响 API 实际账单。
@@ -74,12 +62,83 @@ import type {
74
62
  const CONFIG_DIR = process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
75
63
  const CONFIG_FILE = join(CONFIG_DIR, "pi-better-cost-display-footer.json");
76
64
 
77
- /** 档位标签默认文案/颜色(labels 未配置时兜底) */
78
- 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> = {
79
83
  peak: { text: "(梁文峰)", color: "error" },
80
84
  offPeak: { text: "(梁文谷)", color: "success" },
81
85
  };
82
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
+
83
142
  /** 内置默认配置:配置文件缺失/损坏时的兜底,开箱即用(DeepSeek 官方价目,2026-08 抓取) */
84
143
  const DEFAULT_CONFIG: DynamicConfig = {
85
144
  cacheHitRatePrecision: 1,
@@ -87,10 +146,9 @@ const DEFAULT_CONFIG: DynamicConfig = {
87
146
  deepseek: {
88
147
  timezone: "Asia/Shanghai",
89
148
  currencySymbol: "¥",
90
- peakWindows: [
91
- { start: "09:00", end: "12:00" },
92
- { start: "14:00", end: "18:00" },
93
- ],
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",
94
152
  labels: DEFAULT_LABELS,
95
153
  models: {
96
154
  "deepseek-v4-flash": {
@@ -106,6 +164,14 @@ const DEFAULT_CONFIG: DynamicConfig = {
106
164
  },
107
165
  };
108
166
 
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
+
109
175
  /** 深合并:文件配置覆盖内置默认(全局 + provider + model 三级),部分配置文件也能生效 */
110
176
  function mergeConfig(base: DynamicConfig, override: DynamicConfig): DynamicConfig {
111
177
  const merged: DynamicConfig = { ...base, ...override };
@@ -123,20 +189,22 @@ function mergeConfig(base: DynamicConfig, override: DynamicConfig): DynamicConfi
123
189
 
124
190
  /** 旧格式字段;命中任一字段即执行一次磁盘配置升级。 */
125
191
  function isLegacyConfig(fileCfg: LegacyDynamicConfig): boolean {
126
- return ["timezone", "effectiveFrom", "currencySymbol", "peakWindows", "labels"].some((key) =>
192
+ return ["timezone", "effectiveFrom", "currencySymbol", "peakWindows", "labels", "tierFn", "defaultTier"].some((key) =>
127
193
  Object.prototype.hasOwnProperty.call(fileCfg, key),
128
194
  );
129
195
  }
130
196
 
131
197
  /** 把旧格式顶层 provider 配置下沉到每个 provider。 */
132
198
  function normalizeConfig(fileCfg: LegacyDynamicConfig): DynamicConfig {
133
- const { timezone, effectiveFrom, currencySymbol, peakWindows, labels, providers, cacheHitRatePrecision } = fileCfg;
199
+ const { timezone, effectiveFrom, currencySymbol, peakWindows, labels, tierFn, defaultTier, providers, cacheHitRatePrecision } = fileCfg;
134
200
  const legacyProviderDefaults: Partial<ProviderPricing> = {};
135
201
  if (timezone !== undefined) legacyProviderDefaults.timezone = timezone;
136
202
  if (effectiveFrom !== undefined) legacyProviderDefaults.effectiveFrom = effectiveFrom;
137
203
  if (currencySymbol !== undefined) legacyProviderDefaults.currencySymbol = currencySymbol;
138
204
  if (peakWindows !== undefined) legacyProviderDefaults.peakWindows = peakWindows;
139
- if (labels !== undefined) legacyProviderDefaults.labels = labels;
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;
140
208
  return {
141
209
  cacheHitRatePrecision,
142
210
  providers: Object.fromEntries(
@@ -148,56 +216,6 @@ function normalizeConfig(fileCfg: LegacyDynamicConfig): DynamicConfig {
148
216
  };
149
217
  }
150
218
 
151
- interface TierRates {
152
- input: number;
153
- output: number;
154
- cacheRead: number;
155
- cacheWrite: number;
156
- }
157
-
158
- interface TierLabel {
159
- text: string;
160
- /** ThemeColor 主题色名(如 "error")或 #rrggbb 十六进制 */
161
- color: string;
162
- }
163
-
164
- interface ProviderPricing {
165
- timezone?: string;
166
- effectiveFrom?: string;
167
- currencySymbol?: string;
168
- peakWindows?: { start: string; end: string }[];
169
- labels?: { peak?: TierLabel; offPeak?: TierLabel };
170
- models: Record<string, { offPeak: TierRates; peak: TierRates }>;
171
- }
172
-
173
- interface DynamicConfig {
174
- cacheHitRatePrecision?: number;
175
- providers?: Record<string, ProviderPricing>;
176
- }
177
-
178
- interface LegacyDynamicConfig extends DynamicConfig {
179
- timezone?: string;
180
- effectiveFrom?: string;
181
- currencySymbol?: string;
182
- peakWindows?: { start: string; end: string }[];
183
- labels?: { peak?: TierLabel; offPeak?: TierLabel };
184
- }
185
-
186
- type Tier = "peak" | "offPeak";
187
-
188
- interface FooterLabel {
189
- text: string;
190
- color: string;
191
- }
192
-
193
- let lastAppliedKey: string | null = null;
194
- /** 最近一次事件的 ctx,footer 闭包读取;随 input/model_select 事件刷新 */
195
- let activeCtx: ExtensionContext | null = null;
196
- /** 当前生效配置,apply() 时刷新,footer 每帧直接读,避免频繁读盘 */
197
- let currentCfg: DynamicConfig | null = null;
198
- /** 当前档位标签(null = 不显示),apply() 时刷新 */
199
- let footerLabel: FooterLabel | null = null;
200
-
201
219
  export function loadDynamicConfig(): DynamicConfig | null {
202
220
  try {
203
221
  if (!existsSync(CONFIG_FILE)) {
@@ -209,10 +227,11 @@ export function loadDynamicConfig(): DynamicConfig | null {
209
227
  }
210
228
  const fileCfg = JSON.parse(readFileSync(CONFIG_FILE, "utf8")) as LegacyDynamicConfig;
211
229
  const normalizedCfg = normalizeConfig(fileCfg);
212
- if (isLegacyConfig(fileCfg)) {
230
+ const migrated = migratePeakWindows(normalizedCfg);
231
+ if (isLegacyConfig(fileCfg) || migrated) {
213
232
  try {
214
233
  writeFileSync(CONFIG_FILE, JSON.stringify(normalizedCfg, null, 2) + "\n");
215
- console.log(`[pi-better-cost-display-footer] 旧配置已自动升级为 provider 分层格式 ${CONFIG_FILE}`);
234
+ console.log(`[pi-better-cost-display-footer] 旧配置已自动升级(provider 分层 + peakWindows → tierFn)${CONFIG_FILE}`);
216
235
  } catch (err) {
217
236
  console.error(
218
237
  `[pi-better-cost-display-footer] 旧配置升级写入失败:${err instanceof Error ? err.message : String(err)}`,
@@ -228,17 +247,35 @@ export function loadDynamicConfig(): DynamicConfig | null {
228
247
  }
229
248
  }
230
249
 
231
- /** 当前时刻(配置时区)的 { hour, minute },date 参数仅供测试 */
232
- 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 {
233
252
  const parts = new Intl.DateTimeFormat("en-US", {
234
253
  timeZone: timezone,
254
+ weekday: "short",
255
+ year: "numeric",
256
+ month: "2-digit",
257
+ day: "2-digit",
235
258
  hour: "2-digit",
236
259
  minute: "2-digit",
237
260
  hourCycle: "h23",
238
261
  }).formatToParts(date);
239
- const get = (t: Intl.DateTimeFormatPartTypes): string =>
240
- parts.find((p) => p.type === t)?.value ?? "00";
241
- 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
+ };
242
279
  }
243
280
 
244
281
  /** 窗口含起点不含终点,如 { start: "09:00", end: "12:00" } */
@@ -252,13 +289,77 @@ export function inWindow(
252
289
  return t >= sh * 60 + sm && t < eh * 60 + em;
253
290
  }
254
291
 
255
- /** 命中任一高峰窗口返回 "peak",否则 "offPeak" */
256
- export function currentTier(
257
- cfg: Pick<ProviderPricing, "peakWindows">,
258
- clock: { hour: number; minute: number },
259
- ): 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);
260
361
  const windows = cfg.peakWindows || [];
261
- return windows.some((w) => inWindow(clock, w)) ? "peak" : "offPeak";
362
+ return windows.some((w) => inWindow({ hour: ctx.hour, minute: ctx.minute }, w)) ? "peak" : "offPeak";
262
363
  }
263
364
 
264
365
  export function isEffective(cfg: Pick<ProviderPricing, "effectiveFrom">, date: Date = new Date()): boolean {
@@ -283,7 +384,11 @@ function patchProviderConfig(
283
384
  const rates = cfg.providers?.[providerId]?.models || {};
284
385
  const models = provider.models.map((m) => {
285
386
  const rate = rates[m.id]?.[tier];
286
- 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 };
287
392
  });
288
393
  return { ...provider, models };
289
394
  }
@@ -344,12 +449,12 @@ function getActiveModelPricing(
344
449
  return provider;
345
450
  }
346
451
 
347
- /** 计算档位标签;当前模型无动态计价配置 / 配置缺失 / 未生效 → null */
452
+ /** 计算档位标签;当前模型无动态计价配置 / 配置缺失 / 未生效 / 档位未配置标签 → null */
348
453
  function computeFooterLabel(cfg: DynamicConfig | null, ctx: ExtensionContext | null): FooterLabel | null {
349
454
  const provider = getActiveModelPricing(cfg, ctx);
350
455
  if (!provider) return null;
351
- const tier = currentTier(provider, nowClock(provider.timezone || "Asia/Shanghai"));
352
- return provider.labels?.[tier] ?? DEFAULT_LABELS[tier];
456
+ const tier = currentTier(provider);
457
+ return provider.labels?.[tier] ?? DEFAULT_LABELS[tier] ?? null;
353
458
  }
354
459
 
355
460
  function installCustomFooter(ctx: ExtensionContext): void {
@@ -489,9 +594,7 @@ function apply(pi: ExtensionAPI, ctx: ExtensionContext): void {
489
594
  footerLabel = computeFooterLabel(cfg, ctx);
490
595
  if (!cfg) return;
491
596
  const providerTiers = Object.entries(cfg.providers || {}).map(([providerId, provider]) => {
492
- const tier = isEffective(provider)
493
- ? currentTier(provider, nowClock(provider.timezone || "Asia/Shanghai"))
494
- : null;
597
+ const tier = isEffective(provider) ? currentTier(provider) : null;
495
598
  return [providerId, tier] as const;
496
599
  });
497
600
  const key = `${JSON.stringify(cfg)}|${JSON.stringify(providerTiers)}`;
@@ -526,7 +629,8 @@ export default function (pi: ExtensionAPI): void {
526
629
  3. 配置 JSON 格式(只动 providers 部分):
527
630
  {
528
631
  "timezone": "Asia/Shanghai",
529
- "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",
530
634
  "labels": { "peak": { "text": "(梁文峰)", "color": "error" }, "offPeak": { "text": "(梁文谷)", "color": "success" } },
531
635
  "providers": {
532
636
  "<providerId>": {
@@ -540,12 +644,15 @@ export default function (pi: ExtensionAPI): void {
540
644
  }
541
645
  }
542
646
  价格单位:元/百万tokens;cacheWrite 恒为 0。页面"空闲时段"价格 → offPeak,"高峰时段"价格 → peak。
543
- 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)一律不动。
544
649
  5. 若配置文件不存在:扩展在启动时会自动生成默认配置文件,一般不会出现该情况;若确实不存在,按默认格式新建一份即可。
545
650
  6. 用 edit 精确修改,不要整体重写文件;完成后核对一遍数字与官方页面一致,并简要报告改了什么。
546
- 7. 峰谷价取消处理(两种情况):
547
- a. 官方页取消峰谷价(不再区分空闲/高峰时段,只有单一价格)→ 各模型 offPeak 与 peak 填相同价格,cacheWrite 仍为 0,并把 peakWindows 清空为 [](扩展恒按 offPeak 计价 = 平价格,配置结构保持有效)。
548
- 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。`,
549
656
  { deliverAs: "followUp" },
550
657
  );
551
658
  },
package/package.json CHANGED
@@ -1,14 +1,20 @@
1
1
  {
2
2
  "name": "@yalieny/pi-better-cost-display-footer",
3
- "version": "1.1.0",
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"]