@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 +55 -18
- package/extensions/pi-better-cost-display-footer.ts +289 -115
- package/package.json +8 -2
package/README.md
CHANGED
|
@@ -1,11 +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
|
+
- **配置升级**:旧版顶层计价配置与 `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":
|
|
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`
|
|
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`
|
|
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 —
|
|
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.
|
|
47
|
-
//
|
|
48
|
-
// 文案与颜色可由 labels 配置覆盖(color 支持主题色名或 #rrggbb)。
|
|
34
|
+
// 1. 激活模型有动态计价配置时,价格后紧跟档位标签(labels 按档位 id 查,未配置则不显示标签):
|
|
35
|
+
// 如 ¥0.047(梁文峰)。color 支持主题色名或 #rrggbb。
|
|
49
36
|
// 2. CH 缓存命中率小数位由 cacheHitRatePrecision 控制(内置硬编码 1 位),所有 provider 适用。
|
|
50
|
-
// 3.
|
|
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
|
-
/**
|
|
77
|
-
|
|
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
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
|
175
|
-
|
|
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
|
-
/**
|
|
185
|
-
export function
|
|
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:
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
/**
|
|
209
|
-
|
|
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(
|
|
362
|
+
return windows.some((w) => inWindow({ hour: ctx.hour, minute: ctx.minute }, w)) ? "peak" : "offPeak";
|
|
212
363
|
}
|
|
213
364
|
|
|
214
|
-
export function isEffective(cfg:
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
//
|
|
358
|
-
const
|
|
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 =
|
|
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
|
|
430
|
-
const
|
|
431
|
-
|
|
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
|
|
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
|
-
"
|
|
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
|
-
|
|
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.
|
|
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": "
|
|
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"]
|