@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 +48 -13
- package/extensions/pi-better-cost-display-footer.ts +227 -120
- package/package.json +8 -2
package/README.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# @yalieny/pi-better-cost-display-footer
|
|
2
2
|
|
|
3
|
-
pi
|
|
3
|
+
pi 扩展:脚本化多段计价动态列表 + footer 增强。
|
|
4
4
|
|
|
5
|
-
-
|
|
6
|
-
-
|
|
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
|
-
-
|
|
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-
|
|
29
|
+
"effectiveFrom": "2026-08-23T00:00:00+08:00",
|
|
30
30
|
"currencySymbol": "¥",
|
|
31
|
-
"
|
|
32
|
-
|
|
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":
|
|
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`
|
|
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`
|
|
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`/`
|
|
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 —
|
|
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
|
-
//
|
|
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
|
-
// -
|
|
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
|
-
/**
|
|
78
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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]
|
|
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
|
-
/**
|
|
232
|
-
export function
|
|
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:
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
/**
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
"
|
|
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
|
-
|
|
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.
|
|
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": "
|
|
4
|
-
"description": "pi extension: peak/off-peak
|
|
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"]
|