@dougen/dsh-deepseek-usage 0.9.2 → 0.10.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
@@ -1,487 +1,267 @@
1
- /**
2
- * dsh-deepseek-usage — host half.
3
- *
4
- * 注册一个 HTTP 路由:
5
- *
6
- * GET /api/deepseek-usage[?lang=zh|en][&refresh=1]
7
- *
8
- * 返回(JSON):
9
- * {
10
- * ok: true,
11
- * balance: { isAvailable, total, currency, granted, toppedUp }, // 官方余额接口(API Key)
12
- * tier: { input, cacheRead, cacheWrite, output, model, known }, // 当前模型当前档单价
13
- * next: { peak, at, ms }, // 距下次峰谷切换(界面倒计时)
14
- * peak, peakHours, // 峰谷时段(北京时间)
15
- * updatedAt, errors,
16
- * pricing: { source, via, requested, used, syncOk, syncing, syncedAt, layerAt,
17
- * mode, file: { path, updatedAt, ... } }, // 【内部诊断】界面不再消费
18
- * }
19
- *
20
- * 界面只使用 balance / tier / next / peak / errors:插件内部的计价来源、同步状态、
21
- * 价格表位置属于诊断信息,保留在响应里供排查与测试,但不出现在 UI 上。
22
- * errors 只放"用户能理解且需要行动"的余额类失败,不再包含"模型不在价格表"这类内部细节。
23
- *
24
- * `lang` 参数(zh 默认)控制错误消息与峰谷文案的语言;UI 其余文案由
25
- * client 端(DSH locale 服务)本地化。
26
- *
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 自动重载。
33
- * - 默认从 DeepSeek 官方"模型 & 价格"页自动同步(lib/pricing-remote.js):
34
- * 每天首次成功同步一次,失败按 10 分钟退避重试;请求带 refresh=1(点击卡片)
35
- * 时立即重拉并等待结果,便于官方刚调价后手动跟新。同步成功后**合并回写**本地价格表
36
- * (官方页列出的模型覆盖同名条目,用户自加条目保留),下次重启即可直接用。
37
- * - 解析优先走"结构化表格"策略(对官方改版排版不敏感),失败再退回文本策略;
38
- * 两者都失败才使用上一层的价格(本地表 / 内置种子表 lib/pricing.js)。
39
- * - 拉取/解析失败不打扰用户:记录日志、保留上次成功数据或本地/内置表,并通过
40
- * pricing.syncOk / pricing.source 让界面提示"当前价格未同步",不静默展示旧价。
41
- * - 本地价格表里 autoUpdate=false 即"手动模式":完全不联网,档单价完全以该表为准。
42
- * - 官方改名的旧模型 id(如 deepseek-v4-flash)按 MODEL_ALIASES / 价格表 aliases 映射到
43
- * 现名计价。峰谷时段(周一至周五 9–12、14–18,北京时间)见 lib/pricing.js。
44
- *
45
- * 数据源:余额来自 DeepSeek 官方 GET /user/balance(API Key 经凭证服务解析,
46
- * 与 llm-deepseek 同一引用,不出本机)。本插件仅支持 DeepSeek 官方平台余额显示。
47
- *
48
- * 安全:路由仅接受本机 Host(127.0.0.1 / localhost / ::1),拦截 DNS
49
- * rebinding 与 0.0.0.0 绑定下的局域网访问。
50
- */
51
- import { credentialRef } from "@deepseek-ai/dsh-credentials";
52
- import {
53
- SHANGHAI_OFFSET_MS,
54
- DEFAULT_MODEL,
55
- PEAK_HOURS_TEXT,
56
- PEAK_HOURS_TEXT_EN,
57
- filePriceMeta,
58
- isPeak,
59
- nextChange,
60
- remotePriceMeta,
61
- seedAliases,
62
- seedPrices,
63
- setFilePrices,
64
- setRemotePrices,
65
- unitAt,
66
- } from "./pricing.js";
67
- import { loadPriceTable, priceTableStat, savePriceTable } from "./pricing-store.js";
68
- import { fetchOfficialPrices } from "./pricing-remote.js";
69
-
70
- const name = "dsh-deepseek-usage";
71
- const inject = ["credentials", "webServer"];
72
-
73
- const PUBLIC_BASE_URL = "https://api.deepseek.com";
74
- /** 与 llm-deepseek 适配器对齐的环境变量覆盖。注意:指向第三方网关时 API Key 会发给该地址。 */
75
- const BASE_URL_ENV = "DEEPSEEK_BASE_URL";
76
- const API_KEY_REF = credentialRef("DEEPSEEK_API_KEY");
77
- const BALANCE_PATH = "/user/balance";
78
- const ROUTE_PATH = "/api/deepseek-usage";
79
- const TIMEOUT_MS = 15000;
80
-
81
- /** 官方价格页抓取超时。 */
82
- const REMOTE_TIMEOUT_MS = 12000;
83
- /** 手动(点击)重拉的最小间隔,避免连点打爆官方文档站。 */
84
- const FORCED_GAP_MS = 30 * 1000;
85
- /** 自动同步失败后的重试间隔(成功后当天不再自动重试)。 */
86
- const RETRY_GAP_MS = 10 * 60 * 1000;
87
-
88
- const JSON_HEADERS = {
89
- "content-type": "application/json; charset=utf-8",
90
- "cache-control": "no-store",
91
- };
92
-
93
- /** 错误消息字典(zh/en)。只保留"余额为什么拿不到"这类用户能理解、且需要行动的信息;
94
- * 插件内部细节(计价来源、同步状态等)不再作为错误推给界面。 */
95
- const MESSAGES = {
96
- zh: {
97
- noKey: "未配置 DEEPSEEK_API_KEY",
98
- balanceHttp: (status) => `余额接口 HTTP ${status}`,
99
- balanceFail: (detail) => `余额获取失败: ${detail}`,
100
- },
101
- en: {
102
- noKey: "DEEPSEEK_API_KEY is not configured",
103
- balanceHttp: (status) => `Balance API HTTP ${status}`,
104
- balanceFail: (detail) => `Failed to fetch balance: ${detail}`,
105
- },
106
- };
107
-
108
- // ---- 官方价格自动同步状态(进程内缓存;每日首次成功一次,失败按间隔重试) ----
109
- let remoteInFlight = null; // 进行中的拉取 promise(合并并发)
110
- let remoteDay = ""; // 上次"自动同步成功"的北京日期键
111
- let lastAutoAt = 0; // 上次自动尝试时刻(失败后按 RETRY_GAP_MS 退避)
112
- let lastForcedAt = 0; // 上次手动重拉时刻
113
- /** 同步状态(透出到界面:同步失败时让用户看到"当前是内置/旧价",而不是静默展示)。 */
114
- let remoteStatus = { ok: false, at: 0, error: null, models: 0, strategy: null };
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
-
240
- /** 北京时间日期键(yyyy-mm-dd)。 */
241
- function beijingDayKey(ms) {
242
- const d = new Date(ms + SHANGHAI_OFFSET_MS);
243
- const m = `${d.getUTCMonth() + 1}`.padStart(2, "0");
244
- const day = `${d.getUTCDate()}`.padStart(2, "0");
245
- return `${d.getUTCFullYear()}-${m}-${day}`;
246
- }
247
-
248
- /**
249
- * 保证官方价格已同步:每天首次自动拉取一次,失败则按 RETRY_GAP_MS 退避重试;
250
- * force(点击卡片 refresh=1)时立即重拉并等待。
251
- * 拉取成功 → pricing.setRemotePrices() 注入 + 合并回写本地价格表;失败 → 记日志并记录
252
- * 状态,继续用本地表/上次数据,同时把失败状态透出到界面。
253
- * 注意:非强制(自动)路径不在请求内等待拉取结果,界面用 pricing.syncing 表示"正在同步",
254
- * 免得把一次正常的后台刷新误报成"未同步"。
255
- */
256
- async function refreshRemote(ctx, force) {
257
- if (!fileState.autoUpdate) return; // 手动模式:完全不联网
258
- const now = Date.now();
259
- if (!force) {
260
- if (remoteDay === beijingDayKey(now)) return; // 今天已成功同步过
261
- if (now - lastAutoAt < RETRY_GAP_MS) return; // 上次失败,退避中
262
- }
263
- if (force && now - lastForcedAt < FORCED_GAP_MS) return; // 防连点
264
- if (force) lastForcedAt = now;
265
- if (remoteInFlight) {
266
- if (force) { try { await remoteInFlight; } catch { /* 已吞错 */ } }
267
- return;
268
- }
269
- lastAutoAt = now;
270
- const task = (async () => {
271
- try {
272
- const { models, meta } = await fetchOfficialPrices({ timeoutMs: REMOTE_TIMEOUT_MS });
273
- setRemotePrices(models, meta);
274
- remoteDay = beijingDayKey(Date.now());
275
- remoteStatus = {
276
- ok: true,
277
- at: meta.fetchedAt || Date.now(),
278
- error: null,
279
- models: Object.keys(models).length,
280
- strategy: meta.strategy || null,
281
- };
282
- ctx.logger.info(
283
- `dsh-deepseek-usage: 官方价格已同步(${Object.keys(models).length} 个模型,策略 ${meta.strategy},来源 ${meta.source})`,
284
- );
285
- persistOfficialPrices(ctx, models, meta);
286
- } catch (error) {
287
- const message = error instanceof Error ? error.message : String(error);
288
- remoteStatus = { ...remoteStatus, ok: false, error: message };
289
- ctx.logger.warn(`dsh-deepseek-usage: 官方价格同步失败,沿用本地价格表/上次价格: ${message}`);
290
- } finally {
291
- remoteInFlight = null;
292
- }
293
- })();
294
- remoteInFlight = task;
295
- if (force) {
296
- try { await task; } catch { /* 已吞错 */ }
297
- }
298
- }
299
-
300
- function sendJson(res, status, body) {
301
- res.writeHead(status, JSON_HEADERS);
302
- res.end(JSON.stringify(body));
303
- }
304
-
305
- function balanceUrl() {
306
- const base = process.env[BASE_URL_ENV] ?? PUBLIC_BASE_URL;
307
- return `${base.replace(/\/+$/, "")}${BALANCE_PATH}`;
308
- }
309
-
310
- /** 容忍字符串/数字,返回有限数或 NaN。 */
311
- function toFinite(value) {
312
- if (typeof value === "number") return Number.isFinite(value) ? value : NaN;
313
- if (typeof value === "string" && value.trim() !== "") {
314
- const n = Number(value);
315
- return Number.isFinite(n) ? n : NaN;
316
- }
317
- return NaN;
318
- }
319
-
320
- /** 仅接受本机 Host(127.0.0.1 / localhost / ::1),拦截 DNS rebinding 与局域网访问。 */
321
- function isLocalHost(req) {
322
- const raw = req.headers.host;
323
- if (typeof raw !== "string" || raw === "") return false;
324
- let host = raw.toLowerCase();
325
- if (host.startsWith("[")) {
326
- const end = host.indexOf("]");
327
- if (end === -1) return false;
328
- host = host.slice(1, end);
329
- } else {
330
- const idx = host.lastIndexOf(":");
331
- if (idx !== -1) host = host.slice(0, idx);
332
- }
333
- return host === "127.0.0.1" || host === "localhost" || host === "::1";
334
- }
335
-
336
- /** 解析 ?lang= / ?refresh= 参数。 */
337
- function resolveQuery(req) {
338
- const url = req.url ?? "";
339
- const q = url.indexOf("?");
340
- const kv = q === -1 ? [] : url.slice(q + 1).split("&");
341
- let lang = "zh";
342
- let refresh = false;
343
- for (const item of kv) {
344
- if (item === "lang=en") lang = "en";
345
- else if (item === "refresh=1") refresh = true;
346
- }
347
- return { lang, refresh };
348
- }
349
-
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
-
358
- ctx.effect(
359
- () => ctx.webServer.register({
360
- kind: "exact",
361
- path: ROUTE_PATH,
362
- handler: async (req, res) => {
363
- try {
364
- // ---- 仅本机访问:拦截 DNS rebinding 与局域网直接访问 ----
365
- if (!isLocalHost(req)) {
366
- return sendJson(res, 403, { ok: false, error: "forbidden", message: "仅允许本机访问" });
367
- }
368
-
369
- const { lang, refresh } = resolveQuery(req);
370
- const msg = MESSAGES[lang] || MESSAGES.zh;
371
-
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
- // ---- 官方价格自动同步(每日首次后台拉取;点击卡片强制并等待;手动模式跳过)----
380
- await refreshRemote(ctx, refresh);
381
-
382
- // ---- 当前模型当前档单价(展示用)----
383
- const now = Date.now();
384
- let model = DEFAULT_MODEL;
385
- try {
386
- const adm = ctx.get("agentDefaultModel");
387
- const selection = adm && typeof adm.currentSelection === "function" ? adm.currentSelection() : void 0;
388
- if (selection && typeof selection.model === "string") model = selection.model;
389
- } catch {}
390
- const unit = unitAt(model, now);
391
-
392
- const errors = [];
393
-
394
- // ---- 余额(官方接口,API Key)----
395
- const balance = { isAvailable: null, total: null, currency: null, granted: null, toppedUp: null };
396
- try {
397
- const hit = await ctx.credentials.resolve(API_KEY_REF);
398
- if (hit === void 0) {
399
- errors.push(msg.noKey);
400
- } else {
401
- const response = await fetch(balanceUrl(), {
402
- headers: { Authorization: `Bearer ${hit.value}`, Accept: "application/json" },
403
- signal: AbortSignal.timeout(TIMEOUT_MS),
404
- });
405
- const text = await response.text();
406
- if (!response.ok) {
407
- errors.push(msg.balanceHttp(response.status));
408
- } else {
409
- const body = JSON.parse(text);
410
- const infos = body && Array.isArray(body.balance_infos) ? body.balance_infos : [];
411
- const first = infos[0] || {};
412
- balance.isAvailable = body?.is_available !== false;
413
- balance.total = toFinite(first.total_balance);
414
- balance.currency = typeof first.currency === "string" ? first.currency : null;
415
- balance.granted = toFinite(first.granted_balance);
416
- balance.toppedUp = toFinite(first.topped_up_balance);
417
- }
418
- }
419
- } catch (error) {
420
- errors.push(msg.balanceFail(error instanceof Error ? error.message : String(error)));
421
- }
422
-
423
- // ---- 档单价:未知模型静默回退 chat 档(属内部兜底,不再作为错误推给界面)----
424
- const next = nextChange(now);
425
-
426
- sendJson(res, 200, {
427
- ok: true,
428
- balance,
429
- peak: isPeak(now),
430
- peakHours: lang === "en" ? PEAK_HOURS_TEXT_EN : PEAK_HOURS_TEXT,
431
- // 距下一次峰谷切换的剩余时间(界面用它在指示灯旁显示倒计时):
432
- // peak = 切换后所处的时段,at = 切换时刻,ms = 剩余毫秒
433
- next,
434
- tier: {
435
- input: unit.input,
436
- cacheRead: unit.cacheRead,
437
- cacheWrite: unit.cacheWrite,
438
- output: unit.output,
439
- model: unit.model,
440
- known: unit.known,
441
- },
442
- // 【内部诊断】价格来源与同步状态:界面不再展示(卡片去技术化),保留字段
443
- // 供排查问题与回归测试使用。
444
- pricing: {
445
- source: unit.source, // remote=本次官方页同步 / file=本地价格表 / seed=内置兜底
446
- via: unit.via, // remote* | file* | alias | seed | fallback
447
- requested: unit.requested, // 当前启用的模型 id
448
- used: unit.model, // 实际取价使用的模型 id
449
- mode: fileState.autoUpdate ? "auto" : "manual", // manual=价格表 autoUpdate:false,不联网
450
- syncing: remoteInFlight !== null, // 后台正在拉官方页(别误报成"未同步")
451
- syncOk: remoteStatus.ok,
452
- syncedAt: remoteStatus.at || null,
453
- layerAt: unit.layerAt, // 当前这份单价所属层的时间戳(remote/file)
454
- strategy: remoteStatus.strategy, // table=结构化表格 / flat=文本兜底
455
- modelCount: remoteStatus.models,
456
- syncError: remoteStatus.error,
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,
468
- },
469
- updatedAt: now,
470
- errors,
471
- });
472
- } catch (error) {
473
- ctx.logger.warn("dsh-deepseek-usage: request failed");
474
- ctx.logger.warn(error);
475
- sendJson(res, 502, {
476
- ok: false,
477
- error: "internal",
478
- message: error instanceof Error ? error.message : String(error),
479
- });
480
- }
481
- },
482
- }),
483
- "dsh-deepseek-usage: route",
484
- );
485
- }
486
-
487
- export { name, inject, apply };
1
+ /**
2
+ * dsh-deepseek-usage — host half.
3
+ *
4
+ * 注册一个 HTTP 路由:
5
+ *
6
+ * GET /api/deepseek-usage[?lang=zh|en]
7
+ * GET /api/deepseek-usage/holidays
8
+ *
9
+ * 返回(JSON):
10
+ * {
11
+ * ok: true,
12
+ * balance: { isAvailable, total, currency, granted, toppedUp }, // 官方余额接口(API Key)
13
+ * tier: { input, cacheRead, cacheWrite, output, model, known }, // 当前模型当前档单价
14
+ * next: { peak, at, ms }, // 距下次峰谷切换(界面倒计时)
15
+ * peak, peakHours, // 峰谷时段(北京时间)
16
+ * updatedAt, errors,
17
+ * pricing: { via, requested, used, known }, // 【内部诊断】界面不再消费
18
+ * }
19
+ *
20
+ * 界面只使用 balance / tier / next / peak / errors:插件内部的取价路径属于诊断信息,
21
+ * 保留在响应里供排查与测试,但不出现在 UI 上。
22
+ * errors 只放"用户能理解且需要行动"的余额类失败,不再包含"模型不在价格表"这类内部细节。
23
+ *
24
+ * `lang` 参数(zh 默认)控制错误消息与峰谷文案的语言;UI 其余文案由
25
+ * client 端(DSH locale 服务)本地化。
26
+ *
27
+ * 单价数据源:lib/pricing.js 的内置 PRICE_TABLE(DeepSeek 官方峰谷定价快照)。
28
+ * 官方调价时随插件版本更新该表——不联网抓取、不读写本地价格配置文件。
29
+ * 官方改名的旧模型 id(如 deepseek-v4-flash)按 MODEL_ALIASES 映射到现名计价。
30
+ * 峰谷时段(周一至周五、不含法定节假日 9–12、14–18,北京时间)见 lib/pricing.js。
31
+ *
32
+ * 数据源:余额来自 DeepSeek 官方 GET /user/balance(API Key 经凭证服务解析,
33
+ * 与 llm-deepseek 同一引用,不出本机)。本插件仅支持 DeepSeek 官方平台余额显示。
34
+ *
35
+ * 安全:路由仅接受本机 Host(127.0.0.1 / localhost / ::1),拦截 DNS
36
+ * rebinding 与 0.0.0.0 绑定下的局域网访问。
37
+ */
38
+ import { credentialRef } from "@deepseek-ai/dsh-credentials";
39
+ import {
40
+ SHANGHAI_OFFSET_MS,
41
+ DEFAULT_MODEL,
42
+ PEAK_HOURS_TEXT,
43
+ PEAK_HOURS_TEXT_EN,
44
+ HOLIDAY_RANGES,
45
+ hasHolidayYear,
46
+ isPeak,
47
+ nextChange,
48
+ unitAt,
49
+ } from "./pricing.js";
50
+
51
+ const name = "dsh-deepseek-usage";
52
+ const inject = ["credentials", "webServer"];
53
+
54
+ const PUBLIC_BASE_URL = "https://api.deepseek.com";
55
+ /** 与 llm-deepseek 适配器对齐的环境变量覆盖。注意:指向第三方网关时 API Key 会发给该地址。 */
56
+ const BASE_URL_ENV = "DEEPSEEK_BASE_URL";
57
+ const API_KEY_REF = credentialRef("DEEPSEEK_API_KEY");
58
+ const BALANCE_PATH = "/user/balance";
59
+ /** 放假日期路由(浏览器侧启动拉一次,保证两边同一份日期表)。 */
60
+ const HOLIDAYS_PATH = "/api/deepseek-usage/holidays";
61
+ const TIMEOUT_MS = 15000;
62
+
63
+ const JSON_HEADERS = {
64
+ "content-type": "application/json; charset=utf-8",
65
+ "cache-control": "no-store",
66
+ };
67
+
68
+ /** 错误消息字典(zh/en)。只保留"余额为什么拿不到"这类用户能理解、且需要行动的信息;
69
+ * 插件内部细节(计价来源、同步状态等)不再作为错误推给界面。 */
70
+ const MESSAGES = {
71
+ zh: {
72
+ noKey: "未配置 DEEPSEEK_API_KEY",
73
+ balanceHttp: (status) => `余额接口 HTTP ${status}`,
74
+ balanceFail: (detail) => `余额获取失败: ${detail}`,
75
+ },
76
+ en: {
77
+ noKey: "DEEPSEEK_API_KEY is not configured",
78
+ balanceHttp: (status) => `Balance API HTTP ${status}`,
79
+ balanceFail: (detail) => `Failed to fetch balance: ${detail}`,
80
+ },
81
+ };
82
+
83
+ /** 北京时间年份。 */
84
+ function beijingYear(ms = Date.now()) {
85
+ return new Date(ms + SHANGHAI_OFFSET_MS).getUTCFullYear();
86
+ }
87
+
88
+ /**
89
+ * 放假日期表是否覆盖了今年:没覆盖就告警一次——否则节假日白天会被当成高峰时段,
90
+ * 卡片显示的高峰价比实际高,且无从察觉(见 lib/pricing.js 的 HOLIDAY_RANGES)。
91
+ */
92
+ function warnHolidayCoverage(ctx) {
93
+ const year = beijingYear();
94
+ if (hasHolidayYear(year)) return;
95
+ ctx.logger.warn(
96
+ `dsh-deepseek-usage: 内置放假日期表未覆盖 ${year} 年(仅收录到 ${HOLIDAY_RANGES.at(-1)?.[1] ?? "未知"})`
97
+ + `,${year} 年法定节假日白天会被当作高峰时段:请按国务院办公厅通知更新 lib/pricing.js 的 HOLIDAY_RANGES`
98
+ + `(并同步 lib/client.js 的镜像表)`,
99
+ );
100
+ }
101
+
102
+ function sendJson(res, status, body) {
103
+ res.writeHead(status, JSON_HEADERS);
104
+ res.end(JSON.stringify(body));
105
+ }
106
+
107
+ function balanceUrl() {
108
+ const base = process.env[BASE_URL_ENV] ?? PUBLIC_BASE_URL;
109
+ return `${base.replace(/\/+$/, "")}${BALANCE_PATH}`;
110
+ }
111
+
112
+ /** 容忍字符串/数字,返回有限数或 NaN。 */
113
+ function toFinite(value) {
114
+ if (typeof value === "number") return Number.isFinite(value) ? value : NaN;
115
+ if (typeof value === "string" && value.trim() !== "") {
116
+ const n = Number(value);
117
+ return Number.isFinite(n) ? n : NaN;
118
+ }
119
+ return NaN;
120
+ }
121
+
122
+ /** 仅接受本机 Host(127.0.0.1 / localhost / ::1),拦截 DNS rebinding 与局域网访问。 */
123
+ function isLocalHost(req) {
124
+ const raw = req.headers.host;
125
+ if (typeof raw !== "string" || raw === "") return false;
126
+ let host = raw.toLowerCase();
127
+ if (host.startsWith("[")) {
128
+ const end = host.indexOf("]");
129
+ if (end === -1) return false;
130
+ host = host.slice(1, end);
131
+ } else {
132
+ const idx = host.lastIndexOf(":");
133
+ if (idx !== -1) host = host.slice(0, idx);
134
+ }
135
+ return host === "127.0.0.1" || host === "localhost" || host === "::1";
136
+ }
137
+
138
+ /** 解析 ?lang= 参数。 */
139
+ function resolveQuery(req) {
140
+ const url = req.url ?? "";
141
+ const q = url.indexOf("?");
142
+ const kv = q === -1 ? [] : url.slice(q + 1).split("&");
143
+ let lang = "zh";
144
+ for (const item of kv) {
145
+ if (item === "lang=en") lang = "en";
146
+ }
147
+ return { lang };
148
+ }
149
+
150
+ function apply(ctx) {
151
+ try {
152
+ warnHolidayCoverage(ctx);
153
+ } catch { /* 告警失败不影响卡片 */ }
154
+
155
+ ctx.effect(
156
+ () => ctx.webServer.register({
157
+ kind: "exact",
158
+ path: HOLIDAYS_PATH,
159
+ handler: async (req, res) => {
160
+ if (!isLocalHost(req)) return sendJson(res, 403, { ok: false, error: "forbidden", message: "仅允许本机访问" });
161
+ sendJson(res, 200, { ok: true, ranges: HOLIDAY_RANGES });
162
+ },
163
+ }),
164
+ "dsh-deepseek-usage: holidays route",
165
+ );
166
+
167
+ ctx.effect(
168
+ () => ctx.webServer.register({
169
+ kind: "exact",
170
+ path: ROUTE_PATH,
171
+ handler: async (req, res) => {
172
+ try {
173
+ // ---- 仅本机访问:拦截 DNS rebinding 与局域网直接访问 ----
174
+ if (!isLocalHost(req)) {
175
+ return sendJson(res, 403, { ok: false, error: "forbidden", message: "仅允许本机访问" });
176
+ }
177
+
178
+ const { lang } = resolveQuery(req);
179
+ const msg = MESSAGES[lang] || MESSAGES.zh;
180
+
181
+ // ---- 当前模型当前档单价(展示用)----
182
+ const now = Date.now();
183
+ let model = DEFAULT_MODEL;
184
+ try {
185
+ const adm = ctx.get("agentDefaultModel");
186
+ const selection = adm && typeof adm.currentSelection === "function" ? adm.currentSelection() : void 0;
187
+ if (selection && typeof selection.model === "string") model = selection.model;
188
+ } catch {}
189
+ const unit = unitAt(model, now);
190
+
191
+ const errors = [];
192
+
193
+ // ---- 余额(官方接口,API Key)----
194
+ const balance = { isAvailable: null, total: null, currency: null, granted: null, toppedUp: null };
195
+ try {
196
+ const hit = await ctx.credentials.resolve(API_KEY_REF);
197
+ if (hit === void 0) {
198
+ errors.push(msg.noKey);
199
+ } else {
200
+ const response = await fetch(balanceUrl(), {
201
+ headers: { Authorization: `Bearer ${hit.value}`, Accept: "application/json" },
202
+ signal: AbortSignal.timeout(TIMEOUT_MS),
203
+ });
204
+ const text = await response.text();
205
+ if (!response.ok) {
206
+ errors.push(msg.balanceHttp(response.status));
207
+ } else {
208
+ const body = JSON.parse(text);
209
+ const infos = body && Array.isArray(body.balance_infos) ? body.balance_infos : [];
210
+ const first = infos[0] || {};
211
+ balance.isAvailable = body?.is_available !== false;
212
+ balance.total = toFinite(first.total_balance);
213
+ balance.currency = typeof first.currency === "string" ? first.currency : null;
214
+ balance.granted = toFinite(first.granted_balance);
215
+ balance.toppedUp = toFinite(first.topped_up_balance);
216
+ }
217
+ }
218
+ } catch (error) {
219
+ errors.push(msg.balanceFail(error instanceof Error ? error.message : String(error)));
220
+ }
221
+
222
+ // ---- 档单价:未知模型静默回退 chat 档(属内部兜底,不再作为错误推给界面)----
223
+ const next = nextChange(now);
224
+
225
+ sendJson(res, 200, {
226
+ ok: true,
227
+ balance,
228
+ peak: isPeak(now),
229
+ peakHours: lang === "en" ? PEAK_HOURS_TEXT_EN : PEAK_HOURS_TEXT,
230
+ // 距下一次峰谷切换的剩余时间(界面用它在指示灯旁显示倒计时):
231
+ // peak = 切换后所处的时段,at = 切换时刻,ms = 剩余毫秒
232
+ next,
233
+ tier: {
234
+ input: unit.input,
235
+ cacheRead: unit.cacheRead,
236
+ cacheWrite: unit.cacheWrite,
237
+ output: unit.output,
238
+ model: unit.model,
239
+ known: unit.known,
240
+ },
241
+ // 【内部诊断】取价路径:界面不再展示(卡片去技术化),保留字段
242
+ // 供排查问题与回归测试使用。
243
+ pricing: {
244
+ via: unit.via, // exact | alias | family | fallback
245
+ requested: unit.requested, // 当前启用的模型 id
246
+ used: unit.model, // 实际取价使用的模型 id
247
+ known: unit.known, // false = 不在内置表里,按 deepseek-chat 计价
248
+ },
249
+ updatedAt: now,
250
+ errors,
251
+ });
252
+ } catch (error) {
253
+ ctx.logger.warn("dsh-deepseek-usage: request failed");
254
+ ctx.logger.warn(error);
255
+ sendJson(res, 502, {
256
+ ok: false,
257
+ error: "internal",
258
+ message: error instanceof Error ? error.message : String(error),
259
+ });
260
+ }
261
+ },
262
+ }),
263
+ "dsh-deepseek-usage: route",
264
+ );
265
+ }
266
+
267
+ export { name, inject, apply };