@dougen/dsh-deepseek-usage 0.6.1 → 0.8.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/lib/index.js CHANGED
@@ -10,24 +10,37 @@
10
10
  * ok: true,
11
11
  * balance: { isAvailable, total, currency, granted, toppedUp }, // 官方余额接口(API Key)
12
12
  * tier: { input, cacheRead, cacheWrite, output, model, known }, // 当前模型当前档单价
13
- * pricing: { source, via, requested, used, syncOk, syncedAt, ... }, // 价格来源/同步状态
13
+ * next: { peak, at, ms }, // 距下次峰谷切换(界面倒计时)
14
14
  * peak, peakHours, // 峰谷时段(北京时间)
15
- * updatedAt, errors
15
+ * updatedAt, errors,
16
+ * pricing: { source, via, requested, used, syncOk, syncing, syncedAt, layerAt,
17
+ * mode, file: { path, updatedAt, ... } }, // 【内部诊断】界面不再消费
16
18
  * }
17
19
  *
20
+ * 界面只使用 balance / tier / next / peak / errors:插件内部的计价来源、同步状态、
21
+ * 价格表位置属于诊断信息,保留在响应里供排查与测试,但不出现在 UI 上。
22
+ * errors 只放"用户能理解且需要行动"的余额类失败,不再包含"模型不在价格表"这类内部细节。
23
+ *
18
24
  * `lang` 参数(zh 默认)控制错误消息与峰谷文案的语言;UI 其余文案由
19
25
  * client 端(DSH locale 服务)本地化。
20
26
  *
21
- * 单价数据源(0.5.0 起):
27
+ * 单价数据源(0.7.0 起,三层:remote → file → seed):
28
+ * - 本地价格配置表(lib/pricing-store.js):`<DSH_HOME>/dsh-deepseek-usage/pricing.json`
29
+ * (可用 DEEPSEEK_USAGE_PRICE_FILE 覆盖)。插件启动时先读它并注入 pricing.setFilePrices(),
30
+ * 所以**重启后卡片立刻就有价格**(上次同步到的官方价 / 用户手改的价),不会再退回内置表。
31
+ * 首次运行会把内置快照写进该文件,方便用户直接编辑;文件被手改后无需重启,下次请求
32
+ * (点击卡片即可)会按 mtime 自动重载。
22
33
  * - 默认从 DeepSeek 官方"模型 & 价格"页自动同步(lib/pricing-remote.js):
23
34
  * 每天首次成功同步一次,失败按 10 分钟退避重试;请求带 refresh=1(点击卡片)
24
- * 时立即重拉并等待结果,便于官方刚调价后手动跟新。
35
+ * 时立即重拉并等待结果,便于官方刚调价后手动跟新。同步成功后**合并回写**本地价格表
36
+ * (官方页列出的模型覆盖同名条目,用户自加条目保留),下次重启即可直接用。
25
37
  * - 解析优先走"结构化表格"策略(对官方改版排版不敏感),失败再退回文本策略;
26
- * 两者都失败才使用内置种子表(lib/pricing.js)。
27
- * - 拉取/解析失败不打扰用户:记录日志、保留上次成功数据或内置表,并通过
38
+ * 两者都失败才使用上一层的价格(本地表 / 内置种子表 lib/pricing.js)。
39
+ * - 拉取/解析失败不打扰用户:记录日志、保留上次成功数据或本地/内置表,并通过
28
40
  * pricing.syncOk / pricing.source 让界面提示"当前价格未同步",不静默展示旧价。
29
- * - 官方改名的旧模型 id(如 deepseek-v4-flash)按 MODEL_ALIASES 映射到现名计价。
30
- * 峰谷时段(周一至周五 9–12、14–18,北京时间)见 lib/pricing.js。
41
+ * - 本地价格表里 autoUpdate=false 即"手动模式":完全不联网,档单价完全以该表为准。
42
+ * - 官方改名的旧模型 id(如 deepseek-v4-flash)按 MODEL_ALIASES / 价格表 aliases 映射到
43
+ * 现名计价。峰谷时段(周一至周五 9–12、14–18,北京时间)见 lib/pricing.js。
31
44
  *
32
45
  * 数据源:余额来自 DeepSeek 官方 GET /user/balance(API Key 经凭证服务解析,
33
46
  * 与 llm-deepseek 同一引用,不出本机)。本插件仅支持 DeepSeek 官方平台余额显示。
@@ -41,11 +54,17 @@ import {
41
54
  DEFAULT_MODEL,
42
55
  PEAK_HOURS_TEXT,
43
56
  PEAK_HOURS_TEXT_EN,
57
+ filePriceMeta,
44
58
  isPeak,
59
+ nextChange,
45
60
  remotePriceMeta,
61
+ seedAliases,
62
+ seedPrices,
63
+ setFilePrices,
46
64
  setRemotePrices,
47
65
  unitAt,
48
66
  } from "./pricing.js";
67
+ import { loadPriceTable, priceTableStat, savePriceTable } from "./pricing-store.js";
49
68
  import { fetchOfficialPrices } from "./pricing-remote.js";
50
69
 
51
70
  const name = "dsh-deepseek-usage";
@@ -71,19 +90,18 @@ const JSON_HEADERS = {
71
90
  "cache-control": "no-store",
72
91
  };
73
92
 
74
- /** 错误消息字典(zh/en)。 */
93
+ /** 错误消息字典(zh/en)。只保留"余额为什么拿不到"这类用户能理解、且需要行动的信息;
94
+ * 插件内部细节(计价来源、同步状态等)不再作为错误推给界面。 */
75
95
  const MESSAGES = {
76
96
  zh: {
77
97
  noKey: "未配置 DEEPSEEK_API_KEY",
78
98
  balanceHttp: (status) => `余额接口 HTTP ${status}`,
79
99
  balanceFail: (detail) => `余额获取失败: ${detail}`,
80
- modelUnknown: (model) => `当前模型 ${model} 不在价格表,档单价按 deepseek-chat 展示`,
81
100
  },
82
101
  en: {
83
102
  noKey: "DEEPSEEK_API_KEY is not configured",
84
103
  balanceHttp: (status) => `Balance API HTTP ${status}`,
85
104
  balanceFail: (detail) => `Failed to fetch balance: ${detail}`,
86
- modelUnknown: (model) => `Model ${model} is not in the price table, showing deepseek-chat pricing`,
87
105
  },
88
106
  };
89
107
 
@@ -95,6 +113,130 @@ let lastForcedAt = 0; // 上次手动重拉时刻
95
113
  /** 同步状态(透出到界面:同步失败时让用户看到"当前是内置/旧价",而不是静默展示)。 */
96
114
  let remoteStatus = { ok: false, at: 0, error: null, models: 0, strategy: null };
97
115
 
116
+ // ---- 本地价格配置表状态(进程内镜像;文件才是持久层,见 lib/pricing-store.js) ----
117
+ /** 本地价格表当前状态:路径 / 上次写入时间 / mtime(探测手改)/ 模型数 / 是否自动同步。 */
118
+ let fileState = { path: "", exists: false, updatedAt: null, updatedFrom: null, mtimeMs: 0, models: 0, autoUpdate: true };
119
+
120
+ /** 把一份读取结果注入 pricing 层,并刷新进程内镜像。 */
121
+ function applyPriceFile(loaded, mtimeMs) {
122
+ const table = loaded.table;
123
+ setFilePrices(
124
+ table.models,
125
+ {
126
+ path: loaded.path,
127
+ updatedAt: table.updatedAt,
128
+ source: table.updatedFrom,
129
+ strategy: table.strategy,
130
+ autoUpdate: table.autoUpdate,
131
+ version: table.version,
132
+ },
133
+ table.aliases,
134
+ );
135
+ fileState = {
136
+ path: loaded.path,
137
+ exists: true,
138
+ updatedAt: table.updatedAt,
139
+ updatedFrom: table.updatedFrom,
140
+ mtimeMs: mtimeMs ?? priceTableStat({ path: loaded.path }).mtimeMs,
141
+ models: Object.keys(table.models).length,
142
+ autoUpdate: table.autoUpdate !== false,
143
+ };
144
+ return fileState;
145
+ }
146
+
147
+ /**
148
+ * 启动时载入本地价格配置表:有 → 直接注入(重启后卡片立刻有价);没有 → 用内置快照
149
+ * 生成一份,让用户有个可直接编辑的起点;损坏 → 只记日志、不覆盖文件(避免毁掉用户
150
+ * 正在编辑的内容),内存里仍用内置快照兜底。
151
+ */
152
+ function initPriceFile(ctx) {
153
+ const loaded = loadPriceTable();
154
+ fileState.path = loaded.path;
155
+ for (const warning of loaded.warnings ?? []) ctx.logger.warn(`dsh-deepseek-usage: ${warning}`);
156
+
157
+ if (loaded.table) {
158
+ applyPriceFile(loaded);
159
+ if (loaded.table.autoUpdate === false) {
160
+ ctx.logger.info(`dsh-deepseek-usage: 已载入本地价格表 ${loaded.path}(${fileState.models} 个模型,手动模式:不再联网同步)`);
161
+ } else {
162
+ ctx.logger.info(
163
+ `dsh-deepseek-usage: 已载入本地价格表 ${loaded.path}(${fileState.models} 个模型,上次写入 ${fileState.updatedAt ? new Date(fileState.updatedAt).toISOString() : "未知"})`,
164
+ );
165
+ }
166
+ return;
167
+ }
168
+
169
+ const seed = { models: seedPrices(), aliases: seedAliases() };
170
+ if (loaded.exists) {
171
+ ctx.logger.warn(`dsh-deepseek-usage: 本地价格表无法解析(${loaded.error}),本次先用内置快照,文件未被覆盖:${loaded.path}`);
172
+ setFilePrices(seed.models, { path: loaded.path, updatedAt: null, source: "built-in", strategy: "seed", autoUpdate: true }, seed.aliases);
173
+ fileState = {
174
+ ...fileState,
175
+ exists: true,
176
+ updatedFrom: "built-in",
177
+ models: Object.keys(seed.models).length,
178
+ autoUpdate: true,
179
+ mtimeMs: priceTableStat({ path: loaded.path }).mtimeMs, // 用户改好文件后 mtime 变化即可自动重载
180
+ };
181
+ return;
182
+ }
183
+
184
+ try {
185
+ const written = savePriceTable({
186
+ models: seed.models,
187
+ aliases: seed.aliases,
188
+ updatedAt: Date.now(),
189
+ updatedFrom: "built-in",
190
+ strategy: "seed",
191
+ autoUpdate: true,
192
+ });
193
+ applyPriceFile({ path: written.path, table: written.doc });
194
+ ctx.logger.info(`dsh-deepseek-usage: 未找到本地价格表,已用内置快照生成 ${written.path}(可手动编辑,autoUpdate=false 可关闭自动同步)`);
195
+ } catch (error) {
196
+ ctx.logger.warn(`dsh-deepseek-usage: 无法写入本地价格表 ${loaded.path}(${error instanceof Error ? error.message : String(error)}),本次用内置快照`);
197
+ setFilePrices(seed.models, { path: loaded.path, updatedAt: null, source: "built-in", strategy: "seed", autoUpdate: true }, seed.aliases);
198
+ fileState = { ...fileState, updatedFrom: "built-in", models: Object.keys(seed.models).length };
199
+ }
200
+ }
201
+
202
+ /**
203
+ * 手改价格表后免重启生效:每次请求先比 mtime,变了就重载(一次 stat,成本可忽略)。
204
+ * 只重载"能解析"的文件;解析失败保持现状并记日志。
205
+ */
206
+ function reloadPriceFileIfChanged(ctx) {
207
+ if (!fileState.path) return;
208
+ const stat = priceTableStat({ path: fileState.path });
209
+ if (!stat.exists || stat.mtimeMs === fileState.mtimeMs) return;
210
+ const loaded = loadPriceTable({ path: fileState.path });
211
+ if (!loaded.table) {
212
+ fileState.mtimeMs = stat.mtimeMs; // 别每请求都重读坏文件
213
+ ctx.logger.warn(`dsh-deepseek-usage: 本地价格表改动后无法解析,继续用上一次的价格:${loaded.error}`);
214
+ return;
215
+ }
216
+ applyPriceFile(loaded, stat.mtimeMs);
217
+ for (const warning of loaded.warnings ?? []) ctx.logger.warn(`dsh-deepseek-usage: ${warning}`);
218
+ ctx.logger.info(`dsh-deepseek-usage: 本地价格表已重新载入 ${fileState.path}(${fileState.models} 个模型)`);
219
+ }
220
+
221
+ /**
222
+ * 把官方同步结果合并回写本地价格表(官方页列出的模型覆盖同名条目,用户自加条目保留),
223
+ * 并同步刷新内存里的 file 层——这样"这次同步失败"的后续请求也还能用上最新一份官方价。
224
+ */
225
+ function persistOfficialPrices(ctx, models, meta) {
226
+ try {
227
+ const written = savePriceTable({
228
+ models,
229
+ updatedAt: meta.fetchedAt || Date.now(),
230
+ updatedFrom: meta.source,
231
+ strategy: meta.strategy,
232
+ });
233
+ applyPriceFile({ path: written.path, table: written.doc }, priceTableStat({ path: written.path }).mtimeMs);
234
+ ctx.logger.info(`dsh-deepseek-usage: 官方价格已回写本地价格表 ${written.path}(共 ${fileState.models} 个模型)`);
235
+ } catch (error) {
236
+ ctx.logger.warn(`dsh-deepseek-usage: 官方价格回写本地价格表失败(本次仍按内存里的官方价显示):${error instanceof Error ? error.message : String(error)}`);
237
+ }
238
+ }
239
+
98
240
  /** 北京时间日期键(yyyy-mm-dd)。 */
99
241
  function beijingDayKey(ms) {
100
242
  const d = new Date(ms + SHANGHAI_OFFSET_MS);
@@ -106,10 +248,13 @@ function beijingDayKey(ms) {
106
248
  /**
107
249
  * 保证官方价格已同步:每天首次自动拉取一次,失败则按 RETRY_GAP_MS 退避重试;
108
250
  * force(点击卡片 refresh=1)时立即重拉并等待。
109
- * 拉取成功 → pricing.setRemotePrices() 注入;失败 → 记日志并记录状态,
110
- * 继续用内置/上次数据,同时把失败状态透出到界面。
251
+ * 拉取成功 → pricing.setRemotePrices() 注入 + 合并回写本地价格表;失败 → 记日志并记录
252
+ * 状态,继续用本地表/上次数据,同时把失败状态透出到界面。
253
+ * 注意:非强制(自动)路径不在请求内等待拉取结果,界面用 pricing.syncing 表示"正在同步",
254
+ * 免得把一次正常的后台刷新误报成"未同步"。
111
255
  */
112
256
  async function refreshRemote(ctx, force) {
257
+ if (!fileState.autoUpdate) return; // 手动模式:完全不联网
113
258
  const now = Date.now();
114
259
  if (!force) {
115
260
  if (remoteDay === beijingDayKey(now)) return; // 今天已成功同步过
@@ -137,10 +282,11 @@ async function refreshRemote(ctx, force) {
137
282
  ctx.logger.info(
138
283
  `dsh-deepseek-usage: 官方价格已同步(${Object.keys(models).length} 个模型,策略 ${meta.strategy},来源 ${meta.source})`,
139
284
  );
285
+ persistOfficialPrices(ctx, models, meta);
140
286
  } catch (error) {
141
287
  const message = error instanceof Error ? error.message : String(error);
142
288
  remoteStatus = { ...remoteStatus, ok: false, error: message };
143
- ctx.logger.warn(`dsh-deepseek-usage: 官方价格同步失败,沿用内置/上次价格: ${message}`);
289
+ ctx.logger.warn(`dsh-deepseek-usage: 官方价格同步失败,沿用本地价格表/上次价格: ${message}`);
144
290
  } finally {
145
291
  remoteInFlight = null;
146
292
  }
@@ -202,6 +348,13 @@ function resolveQuery(req) {
202
348
  }
203
349
 
204
350
  function apply(ctx) {
351
+ // ---- 本地价格配置表:启动即载入(重启后卡片立刻有价,不再退回内置表)----
352
+ try {
353
+ initPriceFile(ctx);
354
+ } catch (error) {
355
+ ctx.logger.warn(`dsh-deepseek-usage: 载入本地价格表失败,改用内置快照:${error instanceof Error ? error.message : String(error)}`);
356
+ }
357
+
205
358
  ctx.effect(
206
359
  () => ctx.webServer.register({
207
360
  kind: "exact",
@@ -216,7 +369,14 @@ function apply(ctx) {
216
369
  const { lang, refresh } = resolveQuery(req);
217
370
  const msg = MESSAGES[lang] || MESSAGES.zh;
218
371
 
219
- // ---- 官方价格自动同步(每日首次后台拉取;点击卡片强制并等待)----
372
+ // ---- 手改价格表免重启生效(mtime 变了才重读)----
373
+ try {
374
+ reloadPriceFileIfChanged(ctx);
375
+ } catch (error) {
376
+ ctx.logger.warn(`dsh-deepseek-usage: 重载本地价格表失败:${error instanceof Error ? error.message : String(error)}`);
377
+ }
378
+
379
+ // ---- 官方价格自动同步(每日首次后台拉取;点击卡片强制并等待;手动模式跳过)----
220
380
  await refreshRemote(ctx, refresh);
221
381
 
222
382
  // ---- 当前模型当前档单价(展示用)----
@@ -260,14 +420,17 @@ function apply(ctx) {
260
420
  errors.push(msg.balanceFail(error instanceof Error ? error.message : String(error)));
261
421
  }
262
422
 
263
- // ---- 档单价提示(未知模型回退 chat 档)----
264
- if (!unit.known) errors.push(msg.modelUnknown(model));
423
+ // ---- 档单价:未知模型静默回退 chat 档(属内部兜底,不再作为错误推给界面)----
424
+ const next = nextChange(now);
265
425
 
266
426
  sendJson(res, 200, {
267
427
  ok: true,
268
428
  balance,
269
429
  peak: isPeak(now),
270
430
  peakHours: lang === "en" ? PEAK_HOURS_TEXT_EN : PEAK_HOURS_TEXT,
431
+ // 距下一次峰谷切换的剩余时间(界面用它在指示灯旁显示倒计时):
432
+ // peak = 切换后所处的时段,at = 切换时刻,ms = 剩余毫秒
433
+ next,
271
434
  tier: {
272
435
  input: unit.input,
273
436
  cacheRead: unit.cacheRead,
@@ -276,19 +439,32 @@ function apply(ctx) {
276
439
  model: unit.model,
277
440
  known: unit.known,
278
441
  },
279
- // 价格来源与同步状态:让"官方页改版/网络失败 → 正在用内置旧价"可见,
280
- // 而不是静默按过期价格展示。
442
+ // 【内部诊断】价格来源与同步状态:界面不再展示(卡片去技术化),保留字段
443
+ // 供排查问题与回归测试使用。
281
444
  pricing: {
282
- source: unit.source, // remote=官方页同步 / seed=内置兜底
283
- via: unit.via, // remote | remote-alias | remote-family | alias | seed | fallback
445
+ source: unit.source, // remote=本次官方页同步 / file=本地价格表 / seed=内置兜底
446
+ via: unit.via, // remote* | file* | alias | seed | fallback
284
447
  requested: unit.requested, // 当前启用的模型 id
285
448
  used: unit.model, // 实际取价使用的模型 id
449
+ mode: fileState.autoUpdate ? "auto" : "manual", // manual=价格表 autoUpdate:false,不联网
450
+ syncing: remoteInFlight !== null, // 后台正在拉官方页(别误报成"未同步")
286
451
  syncOk: remoteStatus.ok,
287
452
  syncedAt: remoteStatus.at || null,
453
+ layerAt: unit.layerAt, // 当前这份单价所属层的时间戳(remote/file)
288
454
  strategy: remoteStatus.strategy, // table=结构化表格 / flat=文本兜底
289
455
  modelCount: remoteStatus.models,
290
456
  syncError: remoteStatus.error,
291
457
  remoteMeta: remotePriceMeta(),
458
+ // 本地价格配置表(用户可编辑;路径透出给界面,方便知道去哪儿改价)
459
+ file: fileState.path
460
+ ? {
461
+ path: fileState.path,
462
+ updatedAt: fileState.updatedAt,
463
+ updatedFrom: fileState.updatedFrom,
464
+ autoUpdate: fileState.autoUpdate,
465
+ models: fileState.models,
466
+ }
467
+ : null,
292
468
  },
293
469
  updatedAt: now,
294
470
  errors,
@@ -0,0 +1,253 @@
1
+ /**
2
+ * dsh-deepseek-usage — 本地价格配置表(磁盘文件,lib/pricing.js 的数据源之一)。
3
+ *
4
+ * 为什么有这一层:价格同步状态原来只活在进程内存里,dsh 一重启就没了,于是重启后
5
+ * 的第一张卡片只能用代码内置的价格表(表现为"价格未同步(用内置表)")。现在把
6
+ * "模型 → 档单价"从代码里抽出来存成配置文件:
7
+ *
8
+ * 启动 → 读本文件(上次同步到的官方价 / 用户手改的价)→ 卡片立刻有价
9
+ * 每天/点击 → 拉官方价格页 → 合并回写本文件(官方条目覆盖同名项,用户自加条目保留)
10
+ *
11
+ * 路径解析优先级:
12
+ * 1. 环境变量 DEEPSEEK_USAGE_PRICE_FILE(显式指定,测试/多实例用)
13
+ * 2. <DSH_HOME|~/.dsh>/dsh-deepseek-usage/pricing.json
14
+ *
15
+ * 文件格式(JSON;允许**整行** `//` 注释,方便手改):
16
+ * {
17
+ * "version": 1,
18
+ * "autoUpdate": true, // false = 手动模式:不再联网同步,直接用本表
19
+ * "updatedAt": 1789090582880, // 上次写入时间(ms;也接受 ISO 字符串)
20
+ * "updatedFrom": "https://api-docs.deepseek.com/zh-cn/quick_start/pricing/",
21
+ * "strategy": "table", // 官方页解析策略(table=结构化表格 / flat=文本兜底 / seed=内置快照)
22
+ * "aliases": { "deepseek-v4-flash": "deepseek-flash" },
23
+ * "models": {
24
+ * "deepseek-flash": { "input": [2, 1], "cacheRead": [0.04, 0.02], "cacheWrite": 0, "output": [8, 4] }
25
+ * }
26
+ * }
27
+ * 单价单位:元 / 百万 tokens;input / cacheRead / output 均为 [高峰, 空闲] 两档
28
+ * (cacheWrite 目前官方不计费,恒 0)。也接受"整个文件就是模型映射"的简写形式:
29
+ * { "deepseek-flash": { "input": [2, 1], ... } }
30
+ *
31
+ * 写入策略:读-改-写 + 原子替换(先写同目录 .tmp 再 rename),避免半截文件。
32
+ * 本文件不做任何网络访问,也不含运行时状态(解析 → 返回,交给调用方注入)。
33
+ */
34
+ import { mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
35
+ import { homedir } from "node:os";
36
+ import { dirname, join } from "node:path";
37
+
38
+ /** 价格表文件格式版本(结构不兼容时递增)。 */
39
+ export const PRICE_FILE_VERSION = 1;
40
+ /** 价格表路径覆盖(便于测试与多实例)。 */
41
+ const FILE_ENV = "DEEPSEEK_USAGE_PRICE_FILE";
42
+ /** DSH 家目录覆盖(与 dsh-home-paths 的约定一致)。 */
43
+ const HOME_ENV = "DSH_HOME";
44
+ /** 插件在 DSH 家目录下的子目录 / 文件名。 */
45
+ const DIR_NAME = "dsh-deepseek-usage";
46
+ const FILE_NAME = "pricing.json";
47
+
48
+ /** 写进文件的格式说明(JSON 无注释,只能占一个字段)。 */
49
+ const FILE_COMMENT =
50
+ "dsh-deepseek-usage 价格表:input/cacheRead/output 为 [高峰, 空闲] 两档单价,单位 元/百万 tokens;"
51
+ + "autoUpdate=true 时插件每天首次使用(或点击卡片)会拉取官方价格页并回写本文件——"
52
+ + "官方页列出的模型覆盖同名条目,你自己加的条目保留;autoUpdate=false 表示不再联网同步,完全以本表为准。"
53
+ + "允许整行 // 注释。";
54
+
55
+ /** 当前生效的价格表路径(env > DSH_HOME/dsh-deepseek-usage/pricing.json > ~/.dsh/...)。 */
56
+ export function defaultPriceFilePath() {
57
+ const fromEnv = process.env[FILE_ENV];
58
+ if (typeof fromEnv === "string" && fromEnv.trim() !== "") return fromEnv.trim();
59
+ const home = process.env[HOME_ENV];
60
+ const base = typeof home === "string" && home.trim() !== "" ? home.trim() : join(homedir(), ".dsh");
61
+ return join(base, DIR_NAME, FILE_NAME);
62
+ }
63
+
64
+ /** 只剥掉"整行 //"注释:URL 里的 `//` 在行中间,不会被误伤。 */
65
+ function stripLineComments(text) {
66
+ return text
67
+ .split(/\r?\n/)
68
+ .map((line) => (/^\s*\/\//.test(line) ? "" : line))
69
+ .join("\n");
70
+ }
71
+
72
+ /** [高峰, 空闲] 二元组校验。 */
73
+ const okPair = (v) => Array.isArray(v) && v.length === 2 && v.every((n) => Number.isFinite(n) && n >= 0);
74
+
75
+ /** 时间容忍数字(ms)/ ISO 字符串。 */
76
+ function toMs(value) {
77
+ if (typeof value === "number" && Number.isFinite(value) && value > 0) return value;
78
+ if (typeof value === "string") {
79
+ const n = Number(value);
80
+ if (Number.isFinite(n) && n > 0) return n;
81
+ const t = Date.parse(value);
82
+ if (Number.isFinite(t)) return t;
83
+ }
84
+ return null;
85
+ }
86
+
87
+ /** 清洗模型映射:非法条目丢弃并记入 dropped(缺失的档位回退到 input 档)。 */
88
+ function cleanModels(raw) {
89
+ const models = {};
90
+ const dropped = [];
91
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return { models, dropped };
92
+ for (const [id, entry] of Object.entries(raw)) {
93
+ if (typeof id !== "string" || id === "" || !entry || typeof entry !== "object" || Array.isArray(entry)) {
94
+ dropped.push(String(id));
95
+ continue;
96
+ }
97
+ const input = okPair(entry.input) ? entry.input.slice() : null;
98
+ if (!input) {
99
+ dropped.push(id);
100
+ continue;
101
+ }
102
+ const cacheWrite = Number(entry.cacheWrite);
103
+ models[id] = {
104
+ input,
105
+ cacheRead: okPair(entry.cacheRead) ? entry.cacheRead.slice() : input.slice(),
106
+ cacheWrite: Number.isFinite(cacheWrite) && cacheWrite >= 0 ? cacheWrite : 0,
107
+ output: okPair(entry.output) ? entry.output.slice() : input.slice(),
108
+ };
109
+ }
110
+ return { models, dropped };
111
+ }
112
+
113
+ /** 清洗别名映射(旧名 → 现名)。 */
114
+ function cleanAliases(raw) {
115
+ const aliases = {};
116
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return aliases;
117
+ for (const [from, to] of Object.entries(raw)) {
118
+ if (typeof from === "string" && from !== "" && typeof to === "string" && to !== "") aliases[from] = to;
119
+ }
120
+ return aliases;
121
+ }
122
+
123
+ /** 按键名排序(让文件 diff 稳定、便于人读)。 */
124
+ function sorted(obj) {
125
+ return Object.fromEntries(Object.entries(obj).sort(([a], [b]) => a.localeCompare(b)));
126
+ }
127
+
128
+ /**
129
+ * 解析价格表 JSON 文本。
130
+ * @throws 根节点/模型条目完全不合法时抛错(调用方据此判定"文件损坏")。
131
+ * @returns {{ doc: { version, autoUpdate, updatedAt, updatedFrom, strategy, aliases, models }, dropped: string[] }}
132
+ */
133
+ export function parsePriceTable(text) {
134
+ const raw = JSON.parse(stripLineComments(String(text)));
135
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("价格表根节点必须是对象");
136
+
137
+ // 显式结构(带 models/autoUpdate/... 字段)与简写结构(整个文件就是模型映射)都支持。
138
+ const explicit = ["models", "aliases", "autoUpdate", "updatedAt", "updatedFrom", "strategy", "version"].some(
139
+ (k) => raw[k] !== undefined,
140
+ );
141
+ const { models, dropped } = cleanModels(explicit ? raw.models : raw);
142
+ if (Object.keys(models).length === 0) {
143
+ throw new Error("价格表里没有可用模型条目(每项至少要有 input: [高峰, 空闲])");
144
+ }
145
+ return {
146
+ doc: {
147
+ version: Number.isFinite(Number(raw.version)) ? Number(raw.version) : PRICE_FILE_VERSION,
148
+ autoUpdate: raw.autoUpdate !== false,
149
+ updatedAt: toMs(raw.updatedAt),
150
+ updatedFrom: typeof raw.updatedFrom === "string" && raw.updatedFrom !== "" ? raw.updatedFrom : null,
151
+ strategy: typeof raw.strategy === "string" && raw.strategy !== "" ? raw.strategy : null,
152
+ aliases: explicit ? sorted(cleanAliases(raw.aliases)) : {},
153
+ models: sorted(models),
154
+ },
155
+ dropped,
156
+ };
157
+ }
158
+
159
+ /**
160
+ * 读取价格表文件。
161
+ * @returns {{ path: string, exists: boolean, error: string|null, table: object|null, warnings: string[] }}
162
+ * exists=false 表示文件不存在(首次运行,error 为 null);table=null 且 error 非空表示文件损坏。
163
+ */
164
+ export function loadPriceTable(options = {}) {
165
+ const path = options.path ?? defaultPriceFilePath();
166
+ let text;
167
+ try {
168
+ text = readFileSync(path, "utf8");
169
+ } catch (error) {
170
+ const code = error && typeof error === "object" ? error.code : void 0;
171
+ return {
172
+ path,
173
+ exists: false,
174
+ error: code === "ENOENT" ? null : error instanceof Error ? error.message : String(error),
175
+ table: null,
176
+ warnings: [],
177
+ };
178
+ }
179
+ try {
180
+ const { doc, dropped } = parsePriceTable(text);
181
+ return {
182
+ path,
183
+ exists: true,
184
+ error: null,
185
+ table: doc,
186
+ warnings: dropped.length > 0 ? [`忽略 ${dropped.length} 个无法解析的模型条目:${dropped.join(", ")}`] : [],
187
+ };
188
+ } catch (error) {
189
+ return {
190
+ path,
191
+ exists: true,
192
+ error: error instanceof Error ? error.message : String(error),
193
+ table: null,
194
+ warnings: [],
195
+ };
196
+ }
197
+ }
198
+
199
+ /** 文件状态(用于"手改价格表后免重启生效"的变更探测)。 */
200
+ export function priceTableStat(options = {}) {
201
+ const path = options.path ?? defaultPriceFilePath();
202
+ try {
203
+ const st = statSync(path);
204
+ return { path, exists: true, mtimeMs: st.mtimeMs, size: st.size };
205
+ } catch {
206
+ return { path, exists: false, mtimeMs: 0, size: 0 };
207
+ }
208
+ }
209
+
210
+ /**
211
+ * 合并写回价格表:patch.models / patch.aliases 覆盖同名项,其余条目(含用户自加模型)
212
+ * 保留;未给出的字段沿用磁盘上的旧值。写盘为"临时文件 + rename"原子替换。
213
+ * @returns {{ path: string, doc: object }}
214
+ */
215
+ export function savePriceTable(patch = {}, options = {}) {
216
+ const path = options.path ?? defaultPriceFilePath();
217
+ const base = loadPriceTable({ path }).table ?? {
218
+ version: PRICE_FILE_VERSION,
219
+ autoUpdate: true,
220
+ updatedAt: null,
221
+ updatedFrom: null,
222
+ strategy: null,
223
+ aliases: {},
224
+ models: {},
225
+ };
226
+ const { models } = cleanModels({ ...base.models, ...(patch.models ?? {}) });
227
+ const aliases = cleanAliases({ ...base.aliases, ...(patch.aliases ?? {}) });
228
+ const doc = {
229
+ $comment: FILE_COMMENT,
230
+ version: PRICE_FILE_VERSION,
231
+ autoUpdate: patch.autoUpdate ?? base.autoUpdate ?? true,
232
+ updatedAt: patch.updatedAt ?? Date.now(),
233
+ updatedFrom: patch.updatedFrom ?? base.updatedFrom ?? null,
234
+ strategy: patch.strategy ?? base.strategy ?? null,
235
+ aliases: sorted(aliases),
236
+ models: sorted(models),
237
+ };
238
+
239
+ mkdirSync(dirname(path), { recursive: true });
240
+ const tmp = `${path}.${process.pid}.tmp`;
241
+ writeFileSync(tmp, `${JSON.stringify(doc, null, 2)}\n`, "utf8");
242
+ try {
243
+ renameSync(tmp, path);
244
+ } catch (error) {
245
+ try {
246
+ rmSync(tmp, { force: true });
247
+ } catch {
248
+ /* 清理失败不影响报错 */
249
+ }
250
+ throw error;
251
+ }
252
+ return { path, doc };
253
+ }