@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/pricing.js CHANGED
@@ -1,12 +1,16 @@
1
1
  /**
2
2
  * dsh-deepseek-usage — 定价表(元 / 百万 tokens)。
3
3
  *
4
- * 价格来源(0.5.0 起):
5
- * - 首选:DeepSeek 官方"模型 & 价格"页(lib/pricing-remote.js 抓取,每日首次
6
- * 访问/点击卡片时刷新),解析结果经 setRemotePrices() 注入本模块,作为 LIVE
7
- * 层按模型覆盖内置种子——官方调价只需页面更新,无需再改本文件。
8
- * - 兜底:下方 PRICE_TABLE 内置种子表——DeepSeek 官方峰谷定价快照
9
- * (2026-09-10,与当时官方价格页一致),仅当官方页抓取/解析失败时生效。
4
+ * 价格来源优先级(0.7.0 起,三层):
5
+ * - remote(进程内最新):DeepSeek 官方"模型 & 价格"页(lib/pricing-remote.js 抓取,
6
+ * 每日首次访问/点击卡片时刷新),解析结果经 setRemotePrices() 注入本模块——官方调价
7
+ * 只需页面更新,无需改本文件。
8
+ * - file(本地价格配置表):lib/pricing-store.js 维护的 pricing.json,内容是"上次同步到
9
+ * 的官方价 + 用户手改的价",经 setFilePrices() 注入。进程重启后 remote 层为空,卡片
10
+ * 直接用它,因此不会再退回内置表(也就不会再出现"价格未同步(用内置表)")。
11
+ * - seed(内置兜底):下方 PRICE_TABLE 内置种子表——DeepSeek 官方峰谷定价快照
12
+ * (2026-09-10,与当时官方价格页一致)。首次运行会拿它生成初始 pricing.json;
13
+ * 只有"本地价格表缺失/损坏且官方页也拿不到"时才会真正被展示。
10
14
  *
11
15
  * 模型改名(0.6.0 起):官方会把模型改名并把旧名保留为别名(如
12
16
  * deepseek-v4-flash / deepseek-v4-flash-vision-exp → deepseek-flash)。旧名若
@@ -19,7 +23,8 @@
19
23
  * 峰谷(官方口径):高峰时段为北京时间周一至周五 9:00–12:00、14:00–18:00,
20
24
  * 其余(含整个周末)为空闲时段;缓存写暂不计费(cacheWrite = 0)。
21
25
  *
22
- * 本文件用于卡片"当前档单价"展示与峰谷指示灯。
26
+ * 本文件用于卡片"当前档单价"展示、峰谷指示灯,以及 nextChange() 给出的
27
+ * 「距下次峰谷切换倒计时」(卡片上显示为 `2d 15h` / `5h 47m` 这类英文单位时长)。
23
28
  *
24
29
  * 时区(0.1.7 起):峰谷判定与"每日"边界均按北京时间(Asia/Shanghai,固定
25
30
  * UTC+8,无夏令时)计算,不随服务器本地时区变化——在任何时区部署的 DSH 上
@@ -32,7 +37,8 @@ export const SHANGHAI_OFFSET_MS = 8 * 60 * 60 * 1000;
32
37
  /**
33
38
  * 内置种子表:input(缓存未命中)/ cacheRead(缓存命中)/ output 均为
34
39
  * [高峰, 空闲] 二元组;cacheWrite 为常量 0。
35
- * 仅作离线兜底,正常情况会被官方价格页同步结果覆盖。
40
+ * 只用于两处:首次运行生成 pricing.json(lib/pricing-store.js),以及"价格表缺失/
41
+ * 损坏且官方页也拿不到"时的最后兜底;正常运行时都会被 file / remote 层覆盖。
36
42
  */
37
43
  const PRICE_TABLE = {
38
44
  'deepseek-flash': { input: [2, 1], cacheRead: [0.04, 0.02], cacheWrite: 0, output: [8, 4] },
@@ -55,6 +61,12 @@ export const MODEL_ALIASES = {
55
61
  let remoteTable = null;
56
62
  /** 远端同步元信息(解析策略 / 抓取时间 / 来源),供界面展示同步状态。 */
57
63
  let remoteMeta = null;
64
+ /** 本地价格配置表(setFilePrices 注入;重启后由它接管,见 lib/pricing-store.js)。 */
65
+ let fileTable = null;
66
+ /** 本地价格表元信息(路径 / 上次写入时间 / 来源)。 */
67
+ let fileMeta = null;
68
+ /** 本地价格表里的模型别名(旧名 → 现名),与内置 MODEL_ALIASES 合并使用。 */
69
+ let fileAliases = {};
58
70
 
59
71
  /** 默认模型(当 agentDefaultModel 不可用时的展示默认)。 */
60
72
  export const DEFAULT_MODEL = 'deepseek-flash';
@@ -84,15 +96,41 @@ export function isPeak(ms) {
84
96
  return PEAK_WINDOWS.some(([start, end]) => h >= start && h < end);
85
97
  }
86
98
 
87
- /** 注入远端官方价格(parseOfficialPrices 的结果);传 null/空表即清除回种子。 */
88
- export function setRemotePrices(table, meta) {
89
- if (!table || typeof table !== 'object') {
90
- remoteTable = null;
91
- remoteMeta = null;
92
- return;
99
+ /**
100
+ * 距下一个计费时段边界的剩余时间(供卡片显示倒计时)。
101
+ *
102
+ * 与 isPeak 同源同口径(北京时间、工作日、PEAK_WINDOWS 半开区间),只是换一个视图:
103
+ * 返回「下一次 isPeak 结果发生翻转」的时刻。注意空闲时段里的下一次切换**不等于**下一个
104
+ * 整点:
105
+ * · 12:00–14:00 午休 → 下一次切换是 14:00(高峰开始);
106
+ * · 周五 18:00 之后、整个周末 → 下一次切换是下周一 09:00(跨 2 天多)。
107
+ * 因此倒计时必须能显示「天」。
108
+ *
109
+ * 实现:逐分钟向前探测(上限 8 天),纯算术,无时区/日历依赖。
110
+ * 任何口径变更必须同时改 isPeak / PEAK_WINDOWS / PEAK_WEEKDAYS_ONLY,以及
111
+ * lib/client.js 里为浏览器侧镜像的同名常量(那边无法 import 本模块)。
112
+ *
113
+ * @returns {{ peak: boolean, at: number, ms: number }} peak=切换后所处的时段,
114
+ * at=切换发生的时刻(ms 时间戳,新时段在此刻生效),ms=剩余毫秒(不会为负)。
115
+ */
116
+ export function nextChange(ms) {
117
+ const now = Number.isFinite(ms) ? ms : Date.now();
118
+ const current = isPeak(now);
119
+ // 逐分钟探测(上限 8 天:覆盖"周五晚 → 下周一早"的最长空档)
120
+ for (let step = 1; step <= 8 * 24 * 60; step += 1) {
121
+ const at = now + step * 60 * 1000;
122
+ const peak = isPeak(at);
123
+ if (peak !== current) return { peak, at, ms: Math.max(0, at - now) };
93
124
  }
94
- const clean = {};
125
+ // 理论上不可达(不变量:一周内必有边界);兜底给"下一天 09:00"方向的最小值
126
+ return { peak: !current, at: now, ms: 0 };
127
+ }
128
+
129
+ /** 清洗一张价格表(形状校验 + 档位回退),供 remote / file 两层共用。 */
130
+ function cleanTable(table) {
131
+ if (!table || typeof table !== 'object') return null;
95
132
  const okPair = (v) => Array.isArray(v) && v.length === 2 && v.every((n) => Number.isFinite(n) && n >= 0);
133
+ const clean = {};
96
134
  for (const [id, e] of Object.entries(table)) {
97
135
  if (typeof id !== 'string' || id === '' || !e || typeof e !== 'object') continue;
98
136
  const input = okPair(e.input) ? e.input : null;
@@ -104,7 +142,12 @@ export function setRemotePrices(table, meta) {
104
142
  output: okPair(e.output) ? e.output : input,
105
143
  };
106
144
  }
107
- remoteTable = Object.keys(clean).length > 0 ? clean : null;
145
+ return Object.keys(clean).length > 0 ? clean : null;
146
+ }
147
+
148
+ /** 注入远端官方价格(parseOfficialPrices 的结果);传 null/空表即清除回下一层。 */
149
+ export function setRemotePrices(table, meta) {
150
+ remoteTable = cleanTable(table);
108
151
  remoteMeta = remoteTable && meta && typeof meta === 'object' ? { ...meta } : null;
109
152
  }
110
153
 
@@ -118,6 +161,38 @@ export function hasRemotePrices() {
118
161
  return remoteTable !== null;
119
162
  }
120
163
 
164
+ /**
165
+ * 注入本地价格配置表(lib/pricing-store.js 的读取结果)。
166
+ * @param table 模型映射(models 字段)
167
+ * @param meta 元信息(path / updatedAt / source / strategy / autoUpdate)
168
+ * @param aliases 文件里的模型别名(旧名 → 现名),与内置 MODEL_ALIASES 合并
169
+ */
170
+ export function setFilePrices(table, meta, aliases) {
171
+ fileTable = cleanTable(table);
172
+ fileMeta = fileTable && meta && typeof meta === 'object' ? { ...meta } : null;
173
+ fileAliases = aliases && typeof aliases === 'object' && !Array.isArray(aliases) ? { ...aliases } : {};
174
+ }
175
+
176
+ /** 本地价格表元信息(未载入时为 null)。 */
177
+ export function filePriceMeta() {
178
+ return fileMeta ? { ...fileMeta } : null;
179
+ }
180
+
181
+ /** 本地价格表是否可用。 */
182
+ export function hasFilePrices() {
183
+ return fileTable !== null;
184
+ }
185
+
186
+ /** 内置种子价格表快照(深拷贝;用于首次运行生成 pricing.json)。 */
187
+ export function seedPrices() {
188
+ return JSON.parse(JSON.stringify(PRICE_TABLE));
189
+ }
190
+
191
+ /** 内置模型改名映射快照(用于首次运行生成 pricing.json)。 */
192
+ export function seedAliases() {
193
+ return { ...MODEL_ALIASES };
194
+ }
195
+
121
196
  /** 模型家族名(用于官方改名后的兜底匹配)。 */
122
197
  function familyOf(id) {
123
198
  const m = /(flash|pro|chat|reasoner)/.exec(id);
@@ -126,41 +201,51 @@ function familyOf(id) {
126
201
 
127
202
  const has = (obj, key) => !!obj && Object.prototype.hasOwnProperty.call(obj, key);
128
203
 
129
- /** 在远端表里找模型:精确 → 别名 → 同家族唯一匹配。 */
130
- function remoteLookup(id) {
131
- if (!remoteTable || typeof id !== 'string' || id === '') return null;
132
- if (has(remoteTable, id)) return { id, via: 'remote' };
133
- const alias = MODEL_ALIASES[id];
134
- if (alias && has(remoteTable, alias)) return { id: alias, via: 'remote-alias' };
204
+ /** 别名(旧名 → 现名):本地价格表的 aliases 覆盖内置 MODEL_ALIASES。 */
205
+ function aliasOf(id) {
206
+ if (typeof id !== 'string' || id === '') return undefined;
207
+ return has(fileAliases, id) ? fileAliases[id] : MODEL_ALIASES[id];
208
+ }
209
+
210
+ /** 在某一层价格表里找模型:精确 → 别名 → 同家族唯一匹配。 */
211
+ function layerLookup(table, tag, id) {
212
+ if (!table || typeof id !== 'string' || id === '') return null;
213
+ if (has(table, id)) return { id, via: tag };
214
+ const alias = aliasOf(id);
215
+ if (alias && has(table, alias)) return { id: alias, via: `${tag}-alias` };
135
216
  const fam = familyOf(id);
136
217
  if (fam) {
137
- const hits = Object.keys(remoteTable).filter((k) => familyOf(k) === fam);
218
+ const hits = Object.keys(table).filter((k) => familyOf(k) === fam);
138
219
  // 仅在家族唯一时匹配,避免把 flash 档算成 pro 档。
139
- if (hits.length === 1) return { id: hits[0], via: 'remote-family' };
220
+ if (hits.length === 1) return { id: hits[0], via: `${tag}-family` };
140
221
  }
141
222
  return null;
142
223
  }
143
224
 
144
225
  /**
145
- * 解析模型的计价条目。
146
- * @returns {{ price: object, id: string, via: 'remote'|'remote-alias'|'remote-family'|'alias'|'seed'|'fallback', known: boolean }}
226
+ * 解析模型的计价条目(优先级:remote → file → seed → chat 兜底)。
227
+ * @returns {{ price: object, id: string, via: 'remote'|'remote-alias'|'remote-family'|'file'|'file-alias'|'file-family'|'alias'|'seed'|'fallback', source: 'remote'|'file'|'seed', known: boolean }}
147
228
  */
148
229
  function lookup(model) {
149
- const hit = remoteLookup(model);
150
- if (hit) return { price: remoteTable[hit.id], id: hit.id, via: hit.via, known: true };
230
+ const remote = layerLookup(remoteTable, 'remote', model);
231
+ if (remote) return { price: remoteTable[remote.id], id: remote.id, via: remote.via, source: 'remote', known: true };
232
+
233
+ // 进程重启后 remote 层为空 → 先用本地价格表(上次同步到的官方价 / 用户手改价)。
234
+ const file = layerLookup(fileTable, 'file', model);
235
+ if (file) return { price: fileTable[file.id], id: file.id, via: file.via, source: 'file', known: true };
151
236
 
152
- // 远端不可用(尚未同步/同步失败)或该模型确实不在官方页 → 内置表兜底。
153
- const alias = typeof model === 'string' ? MODEL_ALIASES[model] : undefined;
237
+ // 本地表也没有(首次运行、文件损坏、或模型确实不在表里)→ 内置种子兜底。
238
+ const alias = aliasOf(model);
154
239
  if (alias && has(PRICE_TABLE, alias)) {
155
- return { price: PRICE_TABLE[alias], id: alias, via: 'alias', known: true };
240
+ return { price: PRICE_TABLE[alias], id: alias, via: 'alias', source: 'seed', known: true };
156
241
  }
157
242
  if (has(PRICE_TABLE, model)) {
158
- return { price: PRICE_TABLE[model], id: model, via: 'seed', known: true };
243
+ return { price: PRICE_TABLE[model], id: model, via: 'seed', source: 'seed', known: true };
159
244
  }
160
- return { price: PRICE_TABLE['deepseek-chat'], id: 'deepseek-chat', via: 'fallback', known: false };
245
+ return { price: PRICE_TABLE['deepseek-chat'], id: 'deepseek-chat', via: 'fallback', source: 'seed', known: false };
161
246
  }
162
247
 
163
- /** 模型是否能解析出价格(远端精确/别名/家族,或内置表/别名)。 */
248
+ /** 模型是否能解析出价格(远端精确/别名/家族,或本地表/内置表)。 */
164
249
  export function isKnownModel(model) {
165
250
  return lookup(model).known;
166
251
  }
@@ -168,7 +253,9 @@ export function isKnownModel(model) {
168
253
  /**
169
254
  * 某模型在某一时刻的单位价格(元/百万 tokens)。
170
255
  * 未知模型回退 chat 档;known=false 供调用方提示,避免静默算错。
171
- * via 说明价格来源,供界面区分"官方页同步"与"内置兜底"。
256
+ * via 说明取价路径,source 说明价格来自哪一层(remote 官方页本次同步 / file 本地价格表 /
257
+ * seed 内置兜底);layerAt 是该层数据的时间戳(remote.fetchedAt / file.updatedAt),
258
+ * 供界面说明"这份价是什么时候同步的"。
172
259
  */
173
260
  export function unitAt(model, ms) {
174
261
  const found = lookup(model);
@@ -183,6 +270,12 @@ export function unitAt(model, ms) {
183
270
  requested: typeof model === 'string' ? model : '',
184
271
  known: found.known,
185
272
  via: found.via,
186
- source: found.via.startsWith('remote') ? 'remote' : 'seed',
273
+ source: found.source,
274
+ layerAt:
275
+ found.source === 'remote'
276
+ ? (remoteMeta && Number.isFinite(remoteMeta.fetchedAt) ? remoteMeta.fetchedAt : null)
277
+ : found.source === 'file'
278
+ ? (fileMeta && Number.isFinite(fileMeta.updatedAt) ? fileMeta.updatedAt : null)
279
+ : null,
187
280
  };
188
281
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dougen/dsh-deepseek-usage",
3
- "version": "0.6.1",
3
+ "version": "0.8.0",
4
4
  "description": "DeepSeek 余额侧边栏插件:余额(官方 API)、当前档单价与峰谷时段指示灯,支持中/英文界面。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",