@yalieny/pi-better-cost-display-footer 1.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 +62 -0
- package/extensions/pi-better-cost-display-footer.ts +493 -0
- package/package.json +16 -0
package/README.md
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# @yalieny/pi-better-cost-display-footer
|
|
2
|
+
|
|
3
|
+
pi 扩展:峰谷动态计价 + footer 增强。
|
|
4
|
+
|
|
5
|
+
- **动态计价**:按配置时区与峰谷窗口,在 `session_start` 和每次发送消息时重注册 provider 的 `cost`(仅影响 pi 本地用量统计)。
|
|
6
|
+
- **档位标签**:价格后紧跟峰值/谷值标签(如 `¥0.047(梁文峰)`),文案与颜色可配置。
|
|
7
|
+
- **CH 精度**:footer 缓存命中率小数位可配(默认 1 位)。
|
|
8
|
+
- **货币符号**:计费金额符号可配(默认 `$`)。
|
|
9
|
+
|
|
10
|
+
## 安装
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pi install npm:@yalieny/pi-better-cost-display-footer
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
或本地加载:`pi -e npm:@yalieny/pi-better-cost-display-footer`
|
|
17
|
+
|
|
18
|
+
## 配置
|
|
19
|
+
|
|
20
|
+
扩展读取 `<configDir>/pi-better-cost-display-footer.json`(configDir 默认 `~/.pi/agent`,可用 `PI_CODING_AGENT_DIR` 覆盖)。**配置文件不存在时自动生成默认配置并落盘**(DeepSeek 官方价目,开箱即用,生成后可直接编辑);配置文件存在时按字段覆盖默认值(provider/model 三级深合并)。
|
|
21
|
+
|
|
22
|
+
```json
|
|
23
|
+
{
|
|
24
|
+
"timezone": "Asia/Shanghai",
|
|
25
|
+
"effectiveFrom": "2026-08-17T00:00:00+08:00",
|
|
26
|
+
"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
|
+
"providers": {
|
|
37
|
+
"deepseek": {
|
|
38
|
+
"models": {
|
|
39
|
+
"deepseek-v4-flash": {
|
|
40
|
+
"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 }
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
价格单位:每百万 tokens(元),与 pi 的 `calculateCost` 约定一致。窗口含起点不含终点。`effectiveFrom`(含)之前不做任何事。
|
|
50
|
+
|
|
51
|
+
## 一键更新官方定价
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
/deepseek-pricing-update
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
该命令把抓取官方价目页、解析价格、更新配置文件的完整任务交给 agent 执行(自然语言指令,无需手动维护价格表)。命令会告知 agent 配置文件位置与 JSON 格式;agent 抓取 [DeepSeek 官方价目页](https://api-docs.deepseek.com/zh-cn/quick_start/pricing) 后更新 `providers.deepseek.models` 下各模型的 offPeak/peak 价格,其余字段不动。
|
|
58
|
+
|
|
59
|
+
## 说明
|
|
60
|
+
|
|
61
|
+
- provider 必须在 `models.json` 中有定义;未列出的模型保持原价。
|
|
62
|
+
- cost 仅影响 pi 本地用量统计,不影响 API 实际账单。
|
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
// pi-better-cost-display-footer — 峰谷动态计价 + footer 增强扩展(原 pi-dynamic-cost / pi-better-footer)
|
|
2
|
+
// namespace: @YalienY
|
|
3
|
+
//
|
|
4
|
+
// 配置驱动:读取 <configDir>/pi-better-cost-display-footer.json(configDir 默认 ~/.pi/agent,
|
|
5
|
+
// 可用 PI_CODING_AGENT_DIR 覆盖)。配置文件不存在时自动生成默认配置(内置 DeepSeek 官方价目)落盘。
|
|
6
|
+
// 在 session_start 和每次发送消息(input 事件)时,
|
|
7
|
+
// 按配置时区与峰谷窗口计算当前档位并重注册 provider 的 cost。
|
|
8
|
+
// 跨时段边界的请求按发送时刻计费。effectiveFrom(含)之前不做任何事。
|
|
9
|
+
//
|
|
10
|
+
// 生效范围:
|
|
11
|
+
// - 动态计价与档位标签:所有在 providers 中配置了计价模型的 provider 生效
|
|
12
|
+
// (不再限定 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。
|
|
43
|
+
//
|
|
44
|
+
// 计费显示:用 ctx.ui.setFooter 替换内置 footer(升级安全,扩展代码不随 pi 更新)。
|
|
45
|
+
// 复刻内置 footer 渲染(footer.js),并加两处增强:
|
|
46
|
+
// 1. 激活模型所属 provider 有动态计价配置时,价格后紧跟档位标签:
|
|
47
|
+
// 峰值 →(梁文峰)默认红色,谷值 →(梁文谷)默认绿色,如 ¥0.047(梁文峰)。
|
|
48
|
+
// 文案与颜色可由 labels 配置覆盖(color 支持主题色名或 #rrggbb)。
|
|
49
|
+
// 2. CH 缓存命中率小数位由 cacheHitRatePrecision 控制(内置硬编码 1 位),所有 provider 适用。
|
|
50
|
+
// 3. 计费金额符号由 currencySymbol 控制(内置硬编码 $)。
|
|
51
|
+
//
|
|
52
|
+
// 注意:
|
|
53
|
+
// - provider 必须在 models.json 中有定义:扩展读取其完整模型定义,仅替换 cost 后整体重注册
|
|
54
|
+
// (registerProvider 的 models 是整体替换语义)。未列出的模型保持原价。
|
|
55
|
+
// - cost 仅影响 pi 本地用量统计,不影响 API 实际账单。
|
|
56
|
+
// - ponytail: 自定义 footer 是内置 modes/interactive/components/footer.js 的复刻,
|
|
57
|
+
// 全部数据来自公开 API(ctx.sessionManager / ctx.getContextUsage / ctx.model /
|
|
58
|
+
// footerData),内置的 xp 实验标记与 auto-compact 开关状态未复刻。
|
|
59
|
+
// 若 pi 调整 footer 布局需对照同步;上限 = 内置无"统计段追加"扩展点,
|
|
60
|
+
// 升级路径 = 给内置 footer 增加 stats 追加 hook。
|
|
61
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
62
|
+
import { join, relative, resolve, sep, isAbsolute } from "node:path";
|
|
63
|
+
import { homedir } from "node:os";
|
|
64
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
65
|
+
import type {
|
|
66
|
+
ExtensionAPI,
|
|
67
|
+
ExtensionContext,
|
|
68
|
+
ProviderConfig,
|
|
69
|
+
Theme,
|
|
70
|
+
ThemeColor,
|
|
71
|
+
} from "@earendil-works/pi-coding-agent";
|
|
72
|
+
|
|
73
|
+
const CONFIG_DIR = process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
|
|
74
|
+
const CONFIG_FILE = join(CONFIG_DIR, "pi-better-cost-display-footer.json");
|
|
75
|
+
|
|
76
|
+
/** 档位标签默认文案/颜色(labels 未配置时兜底) */
|
|
77
|
+
const DEFAULT_LABELS: Record<Tier, TierLabel> = {
|
|
78
|
+
peak: { text: "(梁文峰)", color: "error" },
|
|
79
|
+
offPeak: { text: "(梁文谷)", color: "success" },
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/** 内置默认配置:配置文件缺失/损坏时的兜底,开箱即用(DeepSeek 官方价目,2026-08 抓取) */
|
|
83
|
+
const DEFAULT_CONFIG: DynamicConfig = {
|
|
84
|
+
timezone: "Asia/Shanghai",
|
|
85
|
+
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
|
+
providers: {
|
|
93
|
+
deepseek: {
|
|
94
|
+
models: {
|
|
95
|
+
"deepseek-v4-flash": {
|
|
96
|
+
offPeak: { input: 1.5, output: 4.5, cacheRead: 0.05, cacheWrite: 0 },
|
|
97
|
+
peak: { input: 3.0, output: 9.0, cacheRead: 0.1, cacheWrite: 0 },
|
|
98
|
+
},
|
|
99
|
+
"deepseek-v4-pro": {
|
|
100
|
+
offPeak: { input: 4.5, output: 13.5, cacheRead: 0.15, cacheWrite: 0 },
|
|
101
|
+
peak: { input: 9.0, output: 27.0, cacheRead: 0.3, cacheWrite: 0 },
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/** 深合并:文件配置覆盖内置默认(顶层 + provider + model 三级),部分配置文件也能生效 */
|
|
109
|
+
function mergeConfig(base: DynamicConfig, override: DynamicConfig): DynamicConfig {
|
|
110
|
+
const merged: DynamicConfig = { ...base, ...override };
|
|
111
|
+
const providers = { ...base.providers };
|
|
112
|
+
for (const [pid, pc] of Object.entries(override.providers || {})) {
|
|
113
|
+
const models = { ...providers[pid]?.models };
|
|
114
|
+
for (const [mid, rates] of Object.entries(pc.models || {})) {
|
|
115
|
+
models[mid] = { ...models[mid], ...rates };
|
|
116
|
+
}
|
|
117
|
+
providers[pid] = { ...providers[pid], ...pc, models };
|
|
118
|
+
}
|
|
119
|
+
merged.providers = providers;
|
|
120
|
+
return merged;
|
|
121
|
+
}
|
|
122
|
+
|
|
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>;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
type Tier = "peak" | "offPeak";
|
|
151
|
+
|
|
152
|
+
interface FooterLabel {
|
|
153
|
+
text: string;
|
|
154
|
+
color: string;
|
|
155
|
+
}
|
|
156
|
+
|
|
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
|
+
export function loadDynamicConfig(): DynamicConfig | null {
|
|
166
|
+
try {
|
|
167
|
+
if (!existsSync(CONFIG_FILE)) {
|
|
168
|
+
// 首次运行:把内置默认配置落盘,用户可直接编辑;写入失败则内存兜底
|
|
169
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
170
|
+
writeFileSync(CONFIG_FILE, JSON.stringify(DEFAULT_CONFIG, null, 2) + "\n");
|
|
171
|
+
console.log(`[pi-better-cost-display-footer] 未找到配置文件,已生成默认配置 ${CONFIG_FILE}`);
|
|
172
|
+
return DEFAULT_CONFIG;
|
|
173
|
+
}
|
|
174
|
+
const fileCfg = JSON.parse(readFileSync(CONFIG_FILE, "utf8")) as DynamicConfig;
|
|
175
|
+
return mergeConfig(DEFAULT_CONFIG, fileCfg);
|
|
176
|
+
} catch (err) {
|
|
177
|
+
console.error(
|
|
178
|
+
`[pi-better-cost-display-footer] 配置读取失败: ${err instanceof Error ? err.message : String(err)},回退内置默认配置`,
|
|
179
|
+
);
|
|
180
|
+
return DEFAULT_CONFIG;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** 当前时刻(配置时区)的 { hour, minute },date 参数仅供测试 */
|
|
185
|
+
export function nowClock(timezone: string, date: Date = new Date()): { hour: number; minute: number } {
|
|
186
|
+
const parts = new Intl.DateTimeFormat("en-US", {
|
|
187
|
+
timeZone: timezone,
|
|
188
|
+
hour: "2-digit",
|
|
189
|
+
minute: "2-digit",
|
|
190
|
+
hourCycle: "h23",
|
|
191
|
+
}).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")) };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** 窗口含起点不含终点,如 { start: "09:00", end: "12:00" } */
|
|
198
|
+
export function inWindow(
|
|
199
|
+
clock: { hour: number; minute: number },
|
|
200
|
+
win: { start: string; end: string },
|
|
201
|
+
): boolean {
|
|
202
|
+
const [sh, sm] = win.start.split(":").map(Number);
|
|
203
|
+
const [eh, em] = win.end.split(":").map(Number);
|
|
204
|
+
const t = clock.hour * 60 + clock.minute;
|
|
205
|
+
return t >= sh * 60 + sm && t < eh * 60 + em;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** 命中任一高峰窗口返回 "peak",否则 "offPeak" */
|
|
209
|
+
export function currentTier(cfg: DynamicConfig, clock: { hour: number; minute: number }): Tier {
|
|
210
|
+
const windows = cfg.peakWindows || [];
|
|
211
|
+
return windows.some((w) => inWindow(clock, w)) ? "peak" : "offPeak";
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function isEffective(cfg: DynamicConfig, date: Date = new Date()): boolean {
|
|
215
|
+
if (!cfg.effectiveFrom) return true;
|
|
216
|
+
return date.getTime() >= Date.parse(cfg.effectiveFrom);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** 读取 models.json 中该 provider 的完整配置,仅替换配置中列出的模型 cost */
|
|
220
|
+
function patchProviderConfig(
|
|
221
|
+
cfg: DynamicConfig,
|
|
222
|
+
providerId: string,
|
|
223
|
+
tier: Tier,
|
|
224
|
+
): ProviderConfig | null {
|
|
225
|
+
const root = JSON.parse(readFileSync(join(CONFIG_DIR, "models.json"), "utf8")) as {
|
|
226
|
+
providers?: Record<string, ProviderConfig>;
|
|
227
|
+
};
|
|
228
|
+
const provider = root.providers?.[providerId];
|
|
229
|
+
if (!provider?.models?.length) {
|
|
230
|
+
console.error(`[pi-better-cost-display-footer] models.json 中没有 provider "${providerId}",跳过`);
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
const rates = cfg.providers?.[providerId]?.models || {};
|
|
234
|
+
const models = provider.models.map((m) => {
|
|
235
|
+
const rate = rates[m.id]?.[tier];
|
|
236
|
+
return rate ? { ...m, cost: rate } : m;
|
|
237
|
+
});
|
|
238
|
+
return { ...provider, models };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ---------- 自定义 footer:复刻内置 footer.js + 档位标签 + CH 精度 ----------
|
|
242
|
+
|
|
243
|
+
function formatTokens(count: number): string {
|
|
244
|
+
if (count < 1000) return count.toString();
|
|
245
|
+
if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
|
|
246
|
+
if (count < 1000000) return `${Math.round(count / 1000)}k`;
|
|
247
|
+
if (count < 10000000) return `${(count / 1000000).toFixed(1)}M`;
|
|
248
|
+
return `${Math.round(count / 1000000)}M`;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function formatCwdForFooter(cwd: string, home: string | undefined): string {
|
|
252
|
+
if (!home) return cwd;
|
|
253
|
+
const rel = relative(resolve(home), resolve(cwd));
|
|
254
|
+
const inside =
|
|
255
|
+
rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
|
|
256
|
+
return inside ? (rel === "" ? "~" : `~${sep}${rel}`) : cwd;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function sanitizeStatusText(text: string): string {
|
|
260
|
+
return text.replace(/[\r\n\t]/g, " ").replace(/ +/g, " ").trim();
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** 标签着色:ThemeColor 名走主题,#rrggbb 走 truecolor 直出(SGR 38;2;r;g;b) */
|
|
264
|
+
export function colorize(theme: Theme, color: string, text: string): string {
|
|
265
|
+
const hex = /^#([0-9a-f]{6})$/i.exec(color);
|
|
266
|
+
if (hex) {
|
|
267
|
+
const n = parseInt(hex[1], 16);
|
|
268
|
+
return `\x1b[38;2;${(n >> 16) & 255};${(n >> 8) & 255};${n & 255}m${text}\x1b[39m`;
|
|
269
|
+
}
|
|
270
|
+
return theme.fg(color as ThemeColor, text);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** 整段包 dim,但跳过 marker(档位标签)保持原色;未找到 marker 时整段 dim */
|
|
274
|
+
function dimSkip(text: string, marker: string, theme: Theme): string {
|
|
275
|
+
if (!marker) return theme.fg("dim", text);
|
|
276
|
+
const idx = text.indexOf(marker);
|
|
277
|
+
if (idx < 0) return theme.fg("dim", text);
|
|
278
|
+
return (
|
|
279
|
+
theme.fg("dim", text.slice(0, idx)) +
|
|
280
|
+
"\x1b[22m" + // 重置亮度,标签不被 dim 压暗
|
|
281
|
+
marker +
|
|
282
|
+
theme.fg("dim", text.slice(idx + marker.length))
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** 计算档位标签;当前 provider 无动态计价配置 / 配置缺失 / 未生效 → null */
|
|
287
|
+
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];
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function installCustomFooter(ctx: ExtensionContext): void {
|
|
296
|
+
if (ctx.mode !== "tui") return; // 非 TUI(rpc/json)无 footer 可替换
|
|
297
|
+
ctx.ui.setFooter((_tui, theme, footerData) => ({
|
|
298
|
+
invalidate() {},
|
|
299
|
+
render(width: number) {
|
|
300
|
+
const c = activeCtx;
|
|
301
|
+
const sm = c?.sessionManager;
|
|
302
|
+
// 累计用量:镜像内置 addUsageToTotals 的入口(assistant / toolResult / 摘要与压缩)
|
|
303
|
+
const totals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
|
|
304
|
+
let latestCacheHitRate: number | undefined;
|
|
305
|
+
for (const entry of sm?.getEntries() ?? []) {
|
|
306
|
+
let usage;
|
|
307
|
+
if (entry.type === "message" && entry.message.role === "assistant") {
|
|
308
|
+
usage = entry.message.usage;
|
|
309
|
+
const promptTokens = usage.input + usage.cacheRead + usage.cacheWrite;
|
|
310
|
+
latestCacheHitRate =
|
|
311
|
+
promptTokens > 0 ? (usage.cacheRead / promptTokens) * 100 : undefined;
|
|
312
|
+
} else if (entry.type === "message" && entry.message.role === "toolResult" && entry.message.usage) {
|
|
313
|
+
usage = entry.message.usage;
|
|
314
|
+
} else if ((entry.type === "branch_summary" || entry.type === "compaction") && entry.usage) {
|
|
315
|
+
usage = entry.usage;
|
|
316
|
+
}
|
|
317
|
+
if (!usage) continue;
|
|
318
|
+
totals.input += usage.input;
|
|
319
|
+
totals.output += usage.output;
|
|
320
|
+
totals.cacheRead += usage.cacheRead;
|
|
321
|
+
totals.cacheWrite += usage.cacheWrite;
|
|
322
|
+
totals.cost += usage.cost.total;
|
|
323
|
+
}
|
|
324
|
+
// 上下文占用(内置逻辑:压缩后 tokens 未知 → "?")
|
|
325
|
+
const contextUsage = c?.getContextUsage();
|
|
326
|
+
const contextWindow = contextUsage?.contextWindow ?? c?.model?.contextWindow ?? 0;
|
|
327
|
+
const contextPercentValue = contextUsage?.percent ?? 0;
|
|
328
|
+
const contextPercent =
|
|
329
|
+
contextUsage?.percent == null ? "?" : contextPercentValue.toFixed(1);
|
|
330
|
+
const autoIndicator = " (auto)"; // ponytail: 内置 auto-compact 关闭时此处仍显示
|
|
331
|
+
const contextPercentDisplay =
|
|
332
|
+
contextPercent === "?"
|
|
333
|
+
? `?/${formatTokens(contextWindow)}${autoIndicator}`
|
|
334
|
+
: `${contextPercent}%/${formatTokens(contextWindow)}${autoIndicator}`;
|
|
335
|
+
let contextPercentStr: string;
|
|
336
|
+
if (contextPercentValue > 90) contextPercentStr = theme.fg("error", contextPercentDisplay);
|
|
337
|
+
else if (contextPercentValue > 70) contextPercentStr = theme.fg("warning", contextPercentDisplay);
|
|
338
|
+
else contextPercentStr = contextPercentDisplay;
|
|
339
|
+
// pwd + git 分支 + 会话名
|
|
340
|
+
let pwd = formatCwdForFooter(sm?.getCwd() ?? "", process.env.HOME || process.env.USERPROFILE);
|
|
341
|
+
const branch = footerData.getGitBranch();
|
|
342
|
+
if (branch) pwd = `${pwd} (${branch})`;
|
|
343
|
+
const sessionName = sm?.getSessionName();
|
|
344
|
+
if (sessionName) pwd = `${pwd} • ${sessionName}`;
|
|
345
|
+
// 统计段
|
|
346
|
+
const statsParts: string[] = [];
|
|
347
|
+
if (totals.input) statsParts.push(`↑${formatTokens(totals.input)}`);
|
|
348
|
+
if (totals.output) statsParts.push(`↓${formatTokens(totals.output)}`);
|
|
349
|
+
if (totals.cacheRead) statsParts.push(`R${formatTokens(totals.cacheRead)}`);
|
|
350
|
+
if (totals.cacheWrite) statsParts.push(`W${formatTokens(totals.cacheWrite)}`);
|
|
351
|
+
if ((totals.cacheRead > 0 || totals.cacheWrite > 0) && latestCacheHitRate !== undefined) {
|
|
352
|
+
const precision = currentCfg?.cacheHitRatePrecision ?? 1;
|
|
353
|
+
statsParts.push(`CH${latestCacheHitRate.toFixed(precision)}%`);
|
|
354
|
+
}
|
|
355
|
+
// 订阅制 provider 无法从扩展读取 modelRuntime,退化为内置的特例
|
|
356
|
+
const usingSubscription = c?.model?.provider === "kimi-coding";
|
|
357
|
+
// 档位标签紧跟价格之后:峰值红(梁文峰)/ 谷值绿(梁文谷),如 ¥0.047(梁文峰)
|
|
358
|
+
const labelInfo = footerLabel;
|
|
359
|
+
const labelStr = labelInfo ? colorize(theme, labelInfo.color, labelInfo.text) : "";
|
|
360
|
+
if (totals.cost || usingSubscription) {
|
|
361
|
+
const symbol = currentCfg?.currencySymbol ?? "$";
|
|
362
|
+
const costStr = `${symbol}${totals.cost.toFixed(3)}${usingSubscription ? " (sub)" : ""}`;
|
|
363
|
+
statsParts.push(costStr + labelStr);
|
|
364
|
+
}
|
|
365
|
+
statsParts.push(contextPercentStr);
|
|
366
|
+
// 兜底:无计费段时标签置于统计段末尾
|
|
367
|
+
if (!(totals.cost || usingSubscription) && labelStr) statsParts.push(labelStr);
|
|
368
|
+
let statsLeft = statsParts.join(" ");
|
|
369
|
+
// 右侧:模型名 + thinking 档位 + 多 provider 前缀
|
|
370
|
+
const modelName = c?.model?.id || "no-model";
|
|
371
|
+
let statsLeftWidth = visibleWidth(statsLeft);
|
|
372
|
+
if (statsLeftWidth > width) {
|
|
373
|
+
statsLeft = truncateToWidth(statsLeft, width, "...");
|
|
374
|
+
statsLeftWidth = visibleWidth(statsLeft);
|
|
375
|
+
}
|
|
376
|
+
const minPadding = 2;
|
|
377
|
+
let rightSide: string = modelName;
|
|
378
|
+
if (c?.model?.reasoning) {
|
|
379
|
+
const thinkingLevel = c.thinkingLevel || "off";
|
|
380
|
+
rightSide =
|
|
381
|
+
thinkingLevel === "off" ? `${modelName} • thinking off` : `${modelName} • ${thinkingLevel}`;
|
|
382
|
+
}
|
|
383
|
+
if (footerData.getAvailableProviderCount() > 1 && c?.model) {
|
|
384
|
+
const withProvider = `(${c.model.provider}) ${rightSide}`;
|
|
385
|
+
if (statsLeftWidth + minPadding + visibleWidth(withProvider) <= width) rightSide = withProvider;
|
|
386
|
+
}
|
|
387
|
+
const rightSideWidth = visibleWidth(rightSide);
|
|
388
|
+
let statsLine: string;
|
|
389
|
+
if (statsLeftWidth + minPadding + rightSideWidth <= width) {
|
|
390
|
+
const padding = " ".repeat(width - statsLeftWidth - rightSideWidth);
|
|
391
|
+
statsLine = statsLeft + padding + rightSide;
|
|
392
|
+
} else {
|
|
393
|
+
const availableForRight = width - statsLeftWidth - minPadding;
|
|
394
|
+
if (availableForRight > 0) {
|
|
395
|
+
const truncatedRight = truncateToWidth(rightSide, availableForRight, "");
|
|
396
|
+
const padding = " ".repeat(Math.max(0, width - statsLeftWidth - visibleWidth(truncatedRight)));
|
|
397
|
+
statsLine = statsLeft + padding + truncatedRight;
|
|
398
|
+
} else {
|
|
399
|
+
statsLine = statsLeft;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
// 弱化:与内置一致,统计段与右侧分别包 dim;标签段用 \x1b[22m 退出 dim 保持原色
|
|
403
|
+
// ponytail: 依赖 SGR 22 重置亮度,若主题实现变化需同步
|
|
404
|
+
const dimStatsLeft = dimSkip(statsLeft, labelStr, theme);
|
|
405
|
+
const remainder = statsLine.slice(statsLeft.length);
|
|
406
|
+
const dimRemainder = theme.fg("dim", remainder);
|
|
407
|
+
const statsLineFinal = dimStatsLeft + dimRemainder;
|
|
408
|
+
const pwdLine = truncateToWidth(theme.fg("dim", pwd), width, theme.fg("dim", "..."));
|
|
409
|
+
const lines = [pwdLine, statsLineFinal];
|
|
410
|
+
// 扩展状态行(MCP 等),按键名排序
|
|
411
|
+
const extensionStatuses = footerData.getExtensionStatuses();
|
|
412
|
+
if (extensionStatuses.size > 0) {
|
|
413
|
+
const statusLine = Array.from(extensionStatuses.entries())
|
|
414
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
415
|
+
.map(([, text]) => sanitizeStatusText(text))
|
|
416
|
+
.join(" ");
|
|
417
|
+
lines.push(truncateToWidth(statusLine, width, theme.fg("dim", "...")));
|
|
418
|
+
}
|
|
419
|
+
return lines;
|
|
420
|
+
},
|
|
421
|
+
}));
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function apply(pi: ExtensionAPI, ctx: ExtensionContext): void {
|
|
425
|
+
activeCtx = ctx;
|
|
426
|
+
const cfg = loadDynamicConfig();
|
|
427
|
+
currentCfg = cfg;
|
|
428
|
+
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)}`;
|
|
432
|
+
if (key === lastAppliedKey) return;
|
|
433
|
+
// 动态计价对所有配置了 providers 的模型生效(不限定 deepseek)
|
|
434
|
+
for (const providerId of Object.keys(cfg.providers || {})) {
|
|
435
|
+
try {
|
|
436
|
+
const config = patchProviderConfig(cfg, providerId, tier);
|
|
437
|
+
if (!config) continue;
|
|
438
|
+
pi.registerProvider(providerId, config);
|
|
439
|
+
console.log(`[pi-better-cost-display-footer] ${providerId} → ${tier}`);
|
|
440
|
+
} catch (err) {
|
|
441
|
+
console.error(
|
|
442
|
+
`[pi-better-cost-display-footer] ${providerId} 注册失败: ${err instanceof Error ? err.message : String(err)}`,
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
lastAppliedKey = key;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
export default function (pi: ExtensionAPI): void {
|
|
450
|
+
// 一键更新 DeepSeek 官方定价:发自然语言任务给 agent,由 agent 抓价目页并更新配置文件
|
|
451
|
+
pi.registerCommand("deepseek-pricing-update", {
|
|
452
|
+
description: "抓取 DeepSeek 官方价目页,更新 pi-better-cost-display-footer 配置中的模型价格",
|
|
453
|
+
handler: async (_args, ctx) => {
|
|
454
|
+
ctx.ui.notify("已排队:更新 DeepSeek 官方定价…", "info");
|
|
455
|
+
pi.sendUserMessage(
|
|
456
|
+
`任务:更新 DeepSeek 官方计价配置。
|
|
457
|
+
1. 抓取 https://api-docs.deepseek.com/zh-cn/quick_start/pricing ,读取"模型 & 价格"表格中的模型价格(单位为 元/百万tokens)。
|
|
458
|
+
2. 配置文件位置:${CONFIG_FILE} 。
|
|
459
|
+
3. 配置 JSON 格式(只动 providers 部分):
|
|
460
|
+
{
|
|
461
|
+
"timezone": "Asia/Shanghai",
|
|
462
|
+
"peakWindows": [{ "start": "09:00", "end": "12:00" }, { "start": "14:00", "end": "18:00" }],
|
|
463
|
+
"labels": { "peak": { "text": "(梁文峰)", "color": "error" }, "offPeak": { "text": "(梁文谷)", "color": "success" } },
|
|
464
|
+
"providers": {
|
|
465
|
+
"<providerId>": {
|
|
466
|
+
"models": {
|
|
467
|
+
"<modelId>": {
|
|
468
|
+
"offPeak": { "input": 1.5, "output": 4.5, "cacheRead": 0.05, "cacheWrite": 0 },
|
|
469
|
+
"peak": { "input": 3.0, "output": 9.0, "cacheRead": 0.10, "cacheWrite": 0 }
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
价格单位:元/百万tokens;cacheWrite 恒为 0。页面"空闲时段"价格 → offPeak,"高峰时段"价格 → peak。
|
|
476
|
+
4. 页面未列出的模型保持原样;配置文件中其他所有字段(timezone/peakWindows/labels/cacheHitRatePrecision/currencySymbol 及其他 provider)一律不动。
|
|
477
|
+
5. 若配置文件不存在:扩展在启动时会自动生成默认配置文件,一般不会出现该情况;若确实不存在,按默认格式新建一份即可。
|
|
478
|
+
6. 用 edit 精确修改,不要整体重写文件;完成后核对一遍数字与官方页面一致,并简要报告改了什么。
|
|
479
|
+
7. 峰谷价取消处理(两种情况):
|
|
480
|
+
a. 官方页取消峰谷价(不再区分空闲/高峰时段,只有单一价格)→ 各模型 offPeak 与 peak 填相同价格,cacheWrite 仍为 0,并把 peakWindows 清空为 [](扩展恒按 offPeak 计价 = 平价格,配置结构保持有效)。
|
|
481
|
+
b. 用户要求彻底取消动态计价 → 直接删除该 provider 的整个 providers.<providerId> 配置项(扩展会回退 models.json 原始 cost,且不显示档位标签)。`,
|
|
482
|
+
{ deliverAs: "followUp" },
|
|
483
|
+
);
|
|
484
|
+
},
|
|
485
|
+
});
|
|
486
|
+
pi.on("session_start", (_e, ctx) => {
|
|
487
|
+
installCustomFooter(ctx);
|
|
488
|
+
apply(pi, ctx);
|
|
489
|
+
});
|
|
490
|
+
pi.on("input", (_e, ctx) => apply(pi, ctx));
|
|
491
|
+
// 切换模型时刷新档位标签(深色/深绿 ⇄ 其他模型)
|
|
492
|
+
pi.on("model_select", (_e, ctx) => apply(pi, ctx));
|
|
493
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@yalieny/pi-better-cost-display-footer",
|
|
3
|
+
"version": "1.0.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)",
|
|
5
|
+
"keywords": ["pi-package", "pi-extension"],
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"peerDependencies": {
|
|
9
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
10
|
+
"@earendil-works/pi-tui": "*"
|
|
11
|
+
},
|
|
12
|
+
"files": ["extensions", "README.md"],
|
|
13
|
+
"pi": {
|
|
14
|
+
"extensions": ["./extensions"]
|
|
15
|
+
}
|
|
16
|
+
}
|