balance-dsh 1.2.2 → 1.3.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
@@ -175,14 +175,15 @@ function modelKey(model) {
175
175
  return "";
176
176
  }
177
177
 
178
- /** 解析价格:模型基价 + config.price 覆盖,config.peak: true 时按高峰价翻倍。
179
- * era = "legacy" 时取调价前的历史价(目前仅 flash 系列与现价不同)。 */
180
- function resolvePricing(config, model = "", era = "current") {
178
+ /** 目录价(**不含峰谷倍率**):模型基价 + 覆盖价(`config.priceByModel[模型]` 优先,其次 `config.price`)。
179
+ * era = "legacy" 时取调价前的历史价(目前仅 flash 系列与现价不同)。
180
+ * 注意:用户在配置界面填了覆盖价时,该价对所有计费时代生效(他是主动要改价,不是历史重算)。
181
+ * ⚠️ 精确计费(estimateCost)必须用这个**原价**,否则会与高峰倍率重复相乘(见 estimateCost 注释)。 */
182
+ function catalogPricing(config, model = "", era = "current") {
181
183
  const key = modelKey(model);
182
184
  const base = (era === "legacy" && LEGACY_MODEL_PRICING[key]) || MODEL_PRICING[key] || FALLBACK_PRICING;
183
- const price = config?.price ?? {};
184
- const multiplier = config?.peak === true ? 2 : 1;
185
- const pick = (name, fallback) => Number(price[name] ?? fallback) * multiplier;
185
+ const price = config?.priceByModel?.[key] ?? config?.price ?? {};
186
+ const pick = (name, fallback) => Number(price[name] ?? fallback);
186
187
  return {
187
188
  inputCnyPerMillion: pick("inputCnyPerMillion", base.inputCnyPerMillion),
188
189
  cacheReadCnyPerMillion: pick("cacheReadCnyPerMillion", base.cacheReadCnyPerMillion),
@@ -191,6 +192,26 @@ function resolvePricing(config, model = "", era = "current") {
191
192
  };
192
193
  }
193
194
 
195
+ /** 高峰时段价格倍率:`config.peakMultiplier`(旧的 `config.peak: true` 等价于 ×2)。 */
196
+ function peakMultiplierOf(config) {
197
+ const custom = Number(config?.peakMultiplier);
198
+ if (Number.isFinite(custom) && custom > 0) return custom;
199
+ return config?.peak === true ? 2 : 1;
200
+ }
201
+
202
+ /** 当前时段价 = 目录价 × 高峰倍率。**仅用于展示**(客户端按它估算"此刻"的单价);
203
+ * 精确计费请用 `catalogPricing` + `peakMultiplierOf` 组合,不要用这个函数的结果再乘倍率。 */
204
+ function resolvePricing(config, model = "", era = "current") {
205
+ const base = catalogPricing(config, model, era);
206
+ const multiplier = peakMultiplierOf(config);
207
+ return {
208
+ inputCnyPerMillion: base.inputCnyPerMillion * multiplier,
209
+ cacheReadCnyPerMillion: base.cacheReadCnyPerMillion * multiplier,
210
+ cacheWriteCnyPerMillion: base.cacheWriteCnyPerMillion * multiplier,
211
+ outputCnyPerMillion: base.outputCnyPerMillion * multiplier,
212
+ };
213
+ }
214
+
194
215
  /** 取会话事件数组。DSH 2.0.4 起 live session **不再有 `.events` 属性**,改用 `snapshotEvents()` 方法
195
216
  * (签名 `snapshotEvents(fromSeq = 0, toSeqExclusive = seq)`,可无参调用取全部事件);
196
217
  * 旧版仍读 `.events` 属性。两者都拿不到时返回空数组(由调用方降级)。 */
@@ -322,7 +343,7 @@ function formatBalance(balance) {
322
343
  // | { status:"failed" } (网络失败;官方明确“失败绝不变成 0 余额”)
323
344
  // client 是 AccountClientMetadata(version / locale / timezoneOffsetSeconds),
324
345
  // 缺字段会在平台侧组装请求头时抛错,所以三个字段都要给。
325
- const PLUGIN_VERSION = "1.2.2";
346
+ const PLUGIN_VERSION = "1.3.0";
326
347
 
327
348
  /**
328
349
  * 读取官方账号余额并归一化成与 normalizeBalance 相同的形状。
@@ -371,8 +392,7 @@ function normalizeAccountBalance(result) {
371
392
  }
372
393
 
373
394
  /** 取余额:官方账号优先,回退 DeepSeek API Key。返回 { balance, error },二者必有其一。 */
374
- async function resolveBalance(ctx, config, signal) {
375
- const fromAccount = await fetchAccountBalance(ctx);
395
+ async function resolveBalance(ctx, config, signal) { const fromAccount = await fetchAccountBalance(ctx);
376
396
  if (fromAccount !== null) return { balance: fromAccount, error: null };
377
397
  const apiKey = await resolveApiKey(ctx, config);
378
398
  if (apiKey === "") {
@@ -404,6 +424,25 @@ function collectModelSwitches(events) {
404
424
  return switches;
405
425
  }
406
426
 
427
+ /** 建立 `turn/step` → **请求发起时刻** 的映射(取自 step/start 事件)。
428
+ * 峰谷判定、模型归属、价格时代都应该看"发起时刻",而不是 assistant/message 的完成时刻:
429
+ * 请求跨越时段边界时(如 11:59 发起、12:01 完成),按发起时刻归属才符合"按请求到达时刻计费"。
430
+ * 实测平均请求耗时 5.6 秒、最长 127 秒,撞上边界的概率极低,但这样更严谨。
431
+ * 拿不到 step/start(旧数据、部分子代理会话)时由调用方回退到完成时刻。 */
432
+ function stepStartTimes(events) {
433
+ const starts = new Map();
434
+ for (const event of events ?? []) {
435
+ if (event.type !== "step/start") continue;
436
+ const turn = event.data?.turn;
437
+ const step = event.data?.step;
438
+ if (turn === void 0 || step === void 0) continue;
439
+ const time = Number(event.time);
440
+ if (!Number.isFinite(time)) continue;
441
+ starts.set(`${turn}/${step}`, time);
442
+ }
443
+ return starts;
444
+ }
445
+
407
446
  /** 找到 time 之前最近一次模型切换点的模型;无切换点(或早于第一个)返回 ""(调用方回退到后备模型)。 */
408
447
  function modelAtTime(switches, time) {
409
448
  let model = "";
@@ -418,6 +457,7 @@ function modelAtTime(switches, time) {
418
457
  * 供 live 会话与持久化(子代理)会话复用。返回按模型拆分的 byModel,及跨模型聚合字段。 */
419
458
  function sumEventsUsage(events, config, fallbackModel = "") {
420
459
  const switches = collectModelSwitches(events);
460
+ const starts = stepStartTimes(events);
421
461
  const fallback = fallbackModel !== "" ? fallbackModel : "deepseek-v4-flash";
422
462
  const latestByStep = new Map();
423
463
  for (const event of events ?? []) {
@@ -427,7 +467,9 @@ function sumEventsUsage(events, config, fallbackModel = "") {
427
467
  const turn = event.data?.turn;
428
468
  const step = event.data?.step;
429
469
  if (turn === void 0 || step === void 0) continue;
430
- latestByStep.set(`${turn}/${step}`, { usage, time: event.time ?? Date.now() });
470
+ // 时刻取"请求发起时刻"(step/start),拿不到时回退 assistant/message 的完成时刻
471
+ const time = starts.get(`${turn}/${step}`) ?? event.time ?? Date.now();
472
+ latestByStep.set(`${turn}/${step}`, { usage, time });
431
473
  }
432
474
  const add = (bucket, usage) => {
433
475
  bucket.inputTokens += usage.inputTokens ?? 0;
@@ -451,7 +493,7 @@ function sumEventsUsage(events, config, fallbackModel = "") {
451
493
  const offpeak = empty();
452
494
  const modelList = [];
453
495
  for (const e of byModel.values()) {
454
- modelList.push({ model: e.model, era: e.era, peak: e.peak, offpeak: e.offpeak, cost: estimateCost(e, resolvePricing(config, e.model, e.era)) });
496
+ modelList.push({ model: e.model, era: e.era, peak: e.peak, offpeak: e.offpeak, cost: estimateCost(e, catalogPricing(config, e.model, e.era), peakMultiplierOf(config)) });
455
497
  peak.inputTokens += e.peak.inputTokens;
456
498
  peak.outputTokens += e.peak.outputTokens;
457
499
  peak.cacheReadTokens += e.peak.cacheReadTokens;
@@ -485,22 +527,188 @@ function sumSessionUsage(session, config) {
485
527
  return sumEventsUsage(sessionEvents(session), config, detectModel(session));
486
528
  }
487
529
 
488
- function estimateCost(usage, pricing) {
489
- const costOf = (u) =>
490
- (u.inputTokens * pricing.inputCnyPerMillion +
530
+ /** 精确计费:高峰桶按**目录价 × 倍率**、空闲桶按目录价。
531
+ * ⚠️ `pricing` 必须是 `catalogPricing()` 的**原价**(不含倍率)。历史上这里收的是
532
+ * `resolvePricing()` 的结果(已含倍率),再乘一次 2 导致高峰 ×4、空闲 ×2 —— 金额虚高约 3 倍。 */
533
+ function estimateCost(usage, pricing, multiplier = 2) {
534
+ const costOf = (u, factor) =>
535
+ ((u.inputTokens * pricing.inputCnyPerMillion +
491
536
  u.cacheReadTokens * pricing.cacheReadCnyPerMillion +
492
537
  u.cacheWriteTokens * pricing.cacheWriteCnyPerMillion) /
493
538
  1_000_000 +
494
- (u.outputTokens * pricing.outputCnyPerMillion) / 1_000_000;
495
- // 跨时段精确计费:高峰桶按高峰价(= 目录价 × 2)、空闲桶按目录价。
496
- // 注意:若 config.peak:true(resolvePricing 已统一翻倍),高峰桶会再乘一次,两种方式不要同时用。
497
- return costOf(usage.peak) * 2 + costOf(usage.offpeak);
539
+ (u.outputTokens * pricing.outputCnyPerMillion) / 1_000_000) *
540
+ factor;
541
+ return costOf(usage.peak, multiplier) + costOf(usage.offpeak, 1);
498
542
  }
499
543
 
500
544
  const STATS_DAYS = 90;
501
545
  const STATS_MAX_SESSIONS = 200;
502
546
  const STATS_CACHE_MS = 5 * 60 * 1000;
503
547
 
548
+ // ───────────────────────────────────────────────────────────────────────────
549
+ // 插件设置:由 DSH「插件」页 → balance-dsh 详情页的配置卡片读写(slot
550
+ // `plugins.bundle.config`,见 lib/client.js),落在 <dshHome>/balance-dsh.json。
551
+ // 刻意不接 DSH 的 settings / storageDomain 服务——那些内部 API 无兼容承诺,
552
+ // 而这个插件已经被 DSH 升级打断过多次;普通 JSON 文件不会因升级失效。
553
+ // ───────────────────────────────────────────────────────────────────────────
554
+
555
+ const SETTINGS_FILENAME = "balance-dsh.json";
556
+
557
+ /** 出厂默认值;同时是字段白名单(不在这里的键一律丢弃)。 */
558
+ const DEFAULT_SETTINGS = {
559
+ // 余额
560
+ refreshIntervalSec: 60, // 余额轮询间隔(秒)
561
+ lowBalanceThreshold: 10, // 低于该金额用警示色
562
+ apiKey: "", // 官方账号未登录时的回退凭据
563
+ // 显示
564
+ showBalanceRow: true, // 展开态侧栏底部的余额行
565
+ showUsagePill: true, // 输入框下方「本会话消耗」胶囊
566
+ showPeriodLabel: true, // 高峰/空闲标签
567
+ showFab: true, // 收起态悬浮圆钮
568
+ // 收起态圆钮外观
569
+ fabSize: 60, // 直径(px)
570
+ fabLeft: 44, // 距窗口左缘(px)
571
+ // 统计
572
+ statsSessionLimit: STATS_MAX_SESSIONS, // 统计扫描的最近会话数
573
+ statsCacheSec: 300, // 统计结果缓存秒数
574
+ // 计费
575
+ peakMultiplier: 2, // 高峰时段倍率
576
+ priceFlash: null, // flash 覆盖价(元/百万 token),四项全填才生效
577
+ pricePro: null, // v4-pro 覆盖价
578
+ };
579
+
580
+ const PRICE_FIELDS = [
581
+ "inputCnyPerMillion",
582
+ "cacheReadCnyPerMillion",
583
+ "cacheWriteCnyPerMillion",
584
+ "outputCnyPerMillion",
585
+ ];
586
+
587
+ /** 夹到合法区间;非有限数回退默认值。 */
588
+ function clampNumber(value, min, max, fallback) {
589
+ const n = Number(value);
590
+ if (!Number.isFinite(n)) return fallback;
591
+ return Math.min(max, Math.max(min, n));
592
+ }
593
+
594
+ /** 一组覆盖价:逐个字段校验(有限非负数才收),不填的字段留空表示「用默认价」。
595
+ * 四个字段都没填则整组丢弃(等价于不覆盖)。 */
596
+ function normalizePriceOverride(raw) {
597
+ if (raw == null || typeof raw !== "object") return null;
598
+ const out = {};
599
+ for (const field of PRICE_FIELDS) {
600
+ const input = raw[field];
601
+ if (input == null || input === "") continue;
602
+ const n = Number(input);
603
+ if (!Number.isFinite(n) || n < 0) continue;
604
+ out[field] = n;
605
+ }
606
+ return Object.keys(out).length > 0 ? out : null;
607
+ }
608
+
609
+ /** 校验并补全一份设置:未知键丢弃、越界值夹紧、类型不符回退默认。 */
610
+ function normalizeSettings(raw) {
611
+ const src = raw != null && typeof raw === "object" ? raw : {};
612
+ const d = DEFAULT_SETTINGS;
613
+ const bool = (value, fallback) => (typeof value === "boolean" ? value : fallback);
614
+ return {
615
+ refreshIntervalSec: clampNumber(src.refreshIntervalSec, 5, 3600, d.refreshIntervalSec),
616
+ lowBalanceThreshold: clampNumber(src.lowBalanceThreshold, 0, 100000, d.lowBalanceThreshold),
617
+ apiKey: typeof src.apiKey === "string" ? src.apiKey.trim() : d.apiKey,
618
+ showBalanceRow: bool(src.showBalanceRow, d.showBalanceRow),
619
+ showUsagePill: bool(src.showUsagePill, d.showUsagePill),
620
+ showPeriodLabel: bool(src.showPeriodLabel, d.showPeriodLabel),
621
+ showFab: bool(src.showFab, d.showFab),
622
+ fabSize: clampNumber(src.fabSize, 32, 120, d.fabSize),
623
+ fabLeft: clampNumber(src.fabLeft, 0, 600, d.fabLeft),
624
+ statsSessionLimit: clampNumber(src.statsSessionLimit, 10, 2000, d.statsSessionLimit),
625
+ statsCacheSec: clampNumber(src.statsCacheSec, 0, 3600, d.statsCacheSec),
626
+ peakMultiplier: clampNumber(src.peakMultiplier, 1, 10, d.peakMultiplier),
627
+ priceFlash: normalizePriceOverride(src.priceFlash),
628
+ pricePro: normalizePriceOverride(src.pricePro),
629
+ };
630
+ }
631
+
632
+ /** 把设置折进 Cordis config,让既有的 resolvePricing / resolveApiKey / 统计逻辑无感读到。 */
633
+ function withSettings(config, settings) {
634
+ const s = settings ?? DEFAULT_SETTINGS;
635
+ const priceByModel = {};
636
+ if (s.priceFlash) priceByModel["deepseek-v4-flash"] = s.priceFlash;
637
+ if (s.pricePro) priceByModel["deepseek-v4-pro"] = s.pricePro;
638
+ return {
639
+ ...config,
640
+ apiKey: s.apiKey !== "" ? s.apiKey : config?.apiKey,
641
+ peakMultiplier: s.peakMultiplier,
642
+ priceByModel,
643
+ statsSessionLimit: s.statsSessionLimit,
644
+ statsCacheMs: s.statsCacheSec * 1000,
645
+ };
646
+ }
647
+
648
+ /** 设置文件路径:优先 DSH 自己的 home(dshHomePath 服务),再环境变量,最后 ~/.dsh。
649
+ * 返回空串表示定位不到,此时读写一律静默降级为「用默认值、不落盘」。 */
650
+ function settingsFilePath(ctx) {
651
+ let home = "";
652
+ try {
653
+ const svc = typeof ctx?.get === "function" ? ctx.get("dshHomePath") : null;
654
+ if (typeof svc === "string") home = svc;
655
+ else if (svc && typeof svc.path === "string") home = svc.path;
656
+ else if (svc && typeof svc.value === "string") home = svc.value;
657
+ } catch {
658
+ // 服务不存在或形状变了:走下面的兜底
659
+ }
660
+ if (home === "") home = String(process.env.DSH_HOME ?? "");
661
+ if (home === "") {
662
+ const base = process.env.USERPROFILE || process.env.HOME || "";
663
+ if (base !== "") home = `${base}${process.platform === "win32" ? "\\" : "/"}.dsh`;
664
+ }
665
+ if (home === "") return "";
666
+ const sep = home.includes("\\") && !home.includes("/") ? "\\" : "/";
667
+ return `${home.replace(/[\\/]+$/, "")}${sep}${SETTINGS_FILENAME}`;
668
+ }
669
+
670
+ /** 读设置文件;不存在 / JSON 损坏 / 无权限一律返回 null(调用方保持默认值)。 */
671
+ async function readSettingsFile(file) {
672
+ if (file === "") return null;
673
+ try {
674
+ const { readFile } = await import("node:fs/promises");
675
+ const text = await readFile(file, "utf8");
676
+ return normalizeSettings(JSON.parse(text));
677
+ } catch {
678
+ return null;
679
+ }
680
+ }
681
+
682
+ /** 写设置文件:先写临时文件再改名,避免写一半把配置写坏。 */
683
+ async function writeSettingsFile(file, settings) {
684
+ if (file === "") throw new Error("无法定位 DSH 配置目录");
685
+ const { mkdir, writeFile, rename } = await import("node:fs/promises");
686
+ const { dirname } = await import("node:path");
687
+ await mkdir(dirname(file), { recursive: true });
688
+ const tmp = `${file}.tmp`;
689
+ await writeFile(tmp, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
690
+ await rename(tmp, file);
691
+ }
692
+
693
+ /** 读 HTTP 请求体(限制大小,避免被超大 body 拖垮)。 */
694
+ function readRequestBody(req, limit = 64 * 1024) {
695
+ return new Promise((resolve, reject) => {
696
+ const chunks = [];
697
+ let size = 0;
698
+ req.on("data", (chunk) => {
699
+ size += chunk.length;
700
+ if (size > limit) {
701
+ reject(new Error("请求体过大"));
702
+ if (typeof req.destroy === "function") req.destroy();
703
+ return;
704
+ }
705
+ chunks.push(chunk);
706
+ });
707
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
708
+ req.on("error", reject);
709
+ });
710
+ }
711
+
504
712
  function beijingDayKey(timeMs) {
505
713
  return new Date(Number(timeMs) + 8 * 60 * 60 * 1000).toISOString().slice(0, 10);
506
714
  }
@@ -523,6 +731,7 @@ function tokenTotal(usage) {
523
731
  /** 按北京时间把一个会话的最终 assistant/message usage 聚合到每日统计。 */
524
732
  function collectDailyUsage(events, config, fallbackModel, cutoffMs, days, sessionId) {
525
733
  const switches = collectModelSwitches(events);
734
+ const starts = stepStartTimes(events);
526
735
  const fallback = fallbackModel || "deepseek-v4-flash";
527
736
  const latestByStep = new Map();
528
737
  for (const event of events ?? []) {
@@ -531,7 +740,8 @@ function collectDailyUsage(events, config, fallbackModel, cutoffMs, days, sessio
531
740
  const turn = event.data?.turn;
532
741
  const step = event.data?.step;
533
742
  if (usage == null || turn == null || step == null) continue;
534
- latestByStep.set(`${turn}/${step}`, { usage, time: Number(event.time ?? 0) });
743
+ // 与 sumEventsUsage 同口径:按"请求发起时刻"归属日期与峰谷,拿不到才回退完成时刻
744
+ latestByStep.set(`${turn}/${step}`, { usage, time: starts.get(`${turn}/${step}`) ?? Number(event.time ?? 0) });
535
745
  }
536
746
  for (const { usage, time } of latestByStep.values()) {
537
747
  if (!Number.isFinite(time) || time < cutoffMs) continue;
@@ -558,7 +768,7 @@ function collectDailyUsage(events, config, fallbackModel, cutoffMs, days, sessio
558
768
  // 分时段精确计费:高峰桶按高峰价(= 目录价 ×2)、空闲桶按目录价
559
769
  const bucket = { peak: emptyTokenBucket(), offpeak: emptyTokenBucket() };
560
770
  addTokenUsage(isPeak ? bucket.peak : bucket.offpeak, usage);
561
- const stepCost = estimateCost(bucket, resolvePricing(config, model, pricingEraAt(time)));
771
+ const stepCost = estimateCost(bucket, catalogPricing(config, model, pricingEraAt(time)), peakMultiplierOf(config));
562
772
 
563
773
  addTokenUsage(day, usage);
564
774
  const tokens = tokenTotal({
@@ -630,7 +840,7 @@ async function buildUsageStats(ctx, config) {
630
840
  }
631
841
  const selected = sessionIds
632
842
  .filter((id) => typeof id === "string" && id !== "")
633
- .slice(0, STATS_MAX_SESSIONS);
843
+ .slice(0, clampNumber(config?.statsSessionLimit, 10, 2000, STATS_MAX_SESSIONS));
634
844
  const daily = new Map();
635
845
  let cursor = 0;
636
846
  let scannedSessions = 0;
@@ -823,16 +1033,16 @@ async function sumSubagentUsage(ctx, sessionId, config) {
823
1033
  return { usage };
824
1034
  }
825
1035
 
826
- function formatUsage(usage, pricing, model = "") {
1036
+ function formatUsage(usage, pricing, multiplier = 2, model = "") {
827
1037
  // 优先按 byModel 逐段累加(每段用各自模型 + 价格时代的价格,历史消耗不因调价而变);
828
- // 无 byModel 时回退单一价格估算(兼容旧数据)。
1038
+ // 无 byModel 时回退单一价格估算(兼容旧数据)。pricing 是**目录价**,倍率单独传。
829
1039
  const cost =
830
1040
  Array.isArray(usage.byModel) && usage.byModel.length > 0
831
1041
  ? usage.byModel.reduce(
832
- (sum, b) => sum + (typeof b.cost === "number" && Number.isFinite(b.cost) ? b.cost : estimateCost(b, pricing)),
1042
+ (sum, b) => sum + (typeof b.cost === "number" && Number.isFinite(b.cost) ? b.cost : estimateCost(b, pricing, multiplier)),
833
1043
  0,
834
1044
  )
835
- : estimateCost(usage, pricing);
1045
+ : estimateCost(usage, pricing, multiplier);
836
1046
  const newTokens = usage.inputTokens + usage.outputTokens;
837
1047
  const peakTokens = usage.peak.inputTokens + usage.peak.cacheReadTokens + usage.peak.cacheWriteTokens + usage.peak.outputTokens;
838
1048
  const offpeakTokens = usage.offpeak.inputTokens + usage.offpeak.cacheReadTokens + usage.offpeak.cacheWriteTokens + usage.offpeak.outputTokens;
@@ -862,6 +1072,18 @@ function writeJson(res, status, payload) {
862
1072
 
863
1073
  export function apply(ctx, config = {}) {
864
1074
  let statsCache = { expiresAt: 0, value: null };
1075
+ // 用户设置(「插件」页配置卡片读写):先按出厂默认值跑,异步读到文件后热替换。
1076
+ // 读不到文件不是错误——全新安装就是没有文件,保持默认值即可。
1077
+ const settingsState = { value: { ...DEFAULT_SETTINGS }, file: "" };
1078
+ let effectiveConfig = withSettings(config, settingsState.value);
1079
+ void (async () => {
1080
+ settingsState.file = settingsFilePath(ctx);
1081
+ const loaded = await readSettingsFile(settingsState.file);
1082
+ if (loaded) {
1083
+ settingsState.value = loaded;
1084
+ effectiveConfig = withSettings(config, loaded);
1085
+ }
1086
+ })();
865
1087
  // DSH 热重载(reload)时会重新 apply 本插件;若旧实例注册的 /balance-api 路由
866
1088
  // 未被清理,新实例再次 register 就会抛出 duplicate route,导致整个 apply 失败、
867
1089
  // 余额不显示。这里收集每次 register 返回的 disposer,并在插件卸载(ctx.on("dispose"))
@@ -883,20 +1105,21 @@ export function apply(ctx, config = {}) {
883
1105
  name: "balance",
884
1106
  description: "查询 DeepSeek 余额与当前会话消耗",
885
1107
  handler: async ({ agent, signal }) => {
886
- const usage = sumSessionUsage(agent.session, config);
1108
+ const usage = sumSessionUsage(agent.session, effectiveConfig);
887
1109
  const model = detectModel(agent.session);
888
- const pricing = resolvePricing(config, model);
1110
+ const pricing = catalogPricing(effectiveConfig, model);
1111
+ const multiplier = peakMultiplierOf(effectiveConfig);
889
1112
  // 余额来源:官方账号优先(官方版内置登录,无需 API Key),回退 API Key。
890
- const { balance, error } = await resolveBalance(ctx, config, signal);
1113
+ const { balance, error } = await resolveBalance(ctx, effectiveConfig, signal);
891
1114
  if (balance === null) {
892
1115
  return {
893
1116
  kind: "error",
894
- text: `${error}\n\n${formatUsage(usage, pricing, model)}`,
1117
+ text: `${error}\n\n${formatUsage(usage, pricing, multiplier, model)}`,
895
1118
  };
896
1119
  }
897
1120
  return {
898
1121
  kind: "success",
899
- text: `${formatBalance(balance)}\n\n${formatUsage(usage, pricing, model)}`,
1122
+ text: `${formatBalance(balance)}\n\n${formatUsage(usage, pricing, multiplier, model)}`,
900
1123
  };
901
1124
  },
902
1125
  });
@@ -912,6 +1135,40 @@ export function apply(ctx, config = {}) {
912
1135
  kind: "exact",
913
1136
  path: "/balance-api",
914
1137
  handler: async (req, res) => {
1138
+ // 配置读写:GET /balance-api?config=1 读设置,POST 同路径写设置。
1139
+ // DSH「插件」页 → balance-dsh → 配置卡片(lib/client.js 注册的
1140
+ // plugins.bundle.config)就走这两个入口。
1141
+ let configParams = null;
1142
+ try {
1143
+ configParams = new URL(req.url, "http://localhost").searchParams;
1144
+ } catch {
1145
+ configParams = null;
1146
+ }
1147
+ if (configParams?.get("config") === "1") {
1148
+ try {
1149
+ if (req.method === "GET") {
1150
+ writeJson(res, 200, { ok: true, settings: settingsState.value, version: PLUGIN_VERSION });
1151
+ return;
1152
+ }
1153
+ if (req.method !== "POST") {
1154
+ writeJson(res, 405, { ok: false, error: "method not allowed" });
1155
+ return;
1156
+ }
1157
+ const body = await readRequestBody(req);
1158
+ const parsed = body.trim() === "" ? {} : JSON.parse(body);
1159
+ const patch = parsed != null && typeof parsed === "object" && parsed.settings != null ? parsed.settings : parsed;
1160
+ const next = normalizeSettings({ ...settingsState.value, ...(patch ?? {}) });
1161
+ await writeSettingsFile(settingsState.file || settingsFilePath(ctx), next);
1162
+ settingsState.value = next;
1163
+ effectiveConfig = withSettings(config, next);
1164
+ // 价格与扫描范围可能变了,作废统计缓存
1165
+ statsCache = { expiresAt: 0, value: null };
1166
+ writeJson(res, 200, { ok: true, settings: next, version: PLUGIN_VERSION });
1167
+ } catch (error) {
1168
+ writeJson(res, 400, { ok: false, error: error instanceof Error ? error.message : String(error) });
1169
+ }
1170
+ return;
1171
+ }
915
1172
  if (req.method !== "GET") {
916
1173
  writeJson(res, 405, { ok: false, error: "method not allowed" });
917
1174
  return;
@@ -937,8 +1194,8 @@ export function apply(ctx, config = {}) {
937
1194
  try {
938
1195
  if (statsCache.value == null || statsCache.expiresAt <= Date.now()) {
939
1196
  statsCache = {
940
- value: await buildUsageStats(httpCtx, config),
941
- expiresAt: Date.now() + STATS_CACHE_MS,
1197
+ value: await buildUsageStats(httpCtx, effectiveConfig),
1198
+ expiresAt: Date.now() + (effectiveConfig.statsCacheMs ?? STATS_CACHE_MS),
942
1199
  };
943
1200
  }
944
1201
  stats = statsCache.value;
@@ -957,7 +1214,7 @@ export function apply(ctx, config = {}) {
957
1214
  const sessions = ctx.get("sessions");
958
1215
  const session = sessions && typeof sessions.get === "function" ? sessions.get(sessionParam) : void 0;
959
1216
  if (session !== void 0) {
960
- usage = sumSessionUsage(session, config);
1217
+ usage = sumSessionUsage(session, effectiveConfig);
961
1218
  detectedModel = detectModel(session);
962
1219
  }
963
1220
  // DSH 2.0.4 起 live session(sessions.get)可能拿不到 events,导致 usage 全 0。
@@ -966,7 +1223,7 @@ export function apply(ctx, config = {}) {
966
1223
  try {
967
1224
  const persisted = await inspectPersistedSession(ctx, sessionParam);
968
1225
  if (Array.isArray(persisted?.events) && persisted.events.length > 0) {
969
- const persistedUsage = sumEventsUsage(persisted.events, config, detectedModel);
1226
+ const persistedUsage = sumEventsUsage(persisted.events, effectiveConfig, detectedModel);
970
1227
  if (persistedUsage.totalTokens > 0) {
971
1228
  usage = persistedUsage;
972
1229
  if (detectedModel === "") {
@@ -982,7 +1239,7 @@ export function apply(ctx, config = {}) {
982
1239
  // 仍无任何 token 就返回 null,让客户端回退本地投影估算(而不是当成 0 消耗显示 ¥0.0000)
983
1240
  if (usage != null && usage.totalTokens === 0) usage = null;
984
1241
  // 子代理枚举走持久化目录(不依赖 live 会话),即使父会话不在内存也能拿到
985
- const subRes = await sumSubagentUsage(httpCtx, sessionParam, config);
1242
+ const subRes = await sumSubagentUsage(httpCtx, sessionParam, effectiveConfig);
986
1243
  subagentUsage = subRes.usage;
987
1244
  } catch {
988
1245
  usage = null;
@@ -993,13 +1250,13 @@ export function apply(ctx, config = {}) {
993
1250
  const effectiveModel = model || detectedModel;
994
1251
  // 逐模型标注 cost(高峰期×2、空闲期×1),客户端直接按各模型 cost 累加,无需自己定价。
995
1252
  if (usage && Array.isArray(usage.byModel)) {
996
- usage.byModel = usage.byModel.map((b) => ({ ...b, cost: estimateCost(b, resolvePricing(config, b.model, b.era)) }));
1253
+ usage.byModel = usage.byModel.map((b) => ({ ...b, cost: estimateCost(b, catalogPricing(effectiveConfig, b.model, b.era), peakMultiplierOf(effectiveConfig)) }));
997
1254
  }
998
1255
  if (subagentUsage && Array.isArray(subagentUsage.byModel)) {
999
- subagentUsage.byModel = subagentUsage.byModel.map((b) => ({ ...b, cost: estimateCost(b, resolvePricing(config, b.model, b.era)) }));
1256
+ subagentUsage.byModel = subagentUsage.byModel.map((b) => ({ ...b, cost: estimateCost(b, catalogPricing(effectiveConfig, b.model, b.era), peakMultiplierOf(effectiveConfig)) }));
1000
1257
  }
1001
1258
  // 余额来源:官方账号优先(官方桌面版内置登录,无需 API Key),回退 DeepSeek API Key。
1002
- const { balance, error: balanceError } = await resolveBalance(ctx, config);
1259
+ const { balance, error: balanceError } = await resolveBalance(ctx, effectiveConfig);
1003
1260
  if (balance === null && !wantStats) {
1004
1261
  writeJson(res, 200, { ok: false, error: balanceError });
1005
1262
  return;
@@ -1008,7 +1265,9 @@ export function apply(ctx, config = {}) {
1008
1265
  ok: true,
1009
1266
  balance,
1010
1267
  balanceError: balance === null ? balanceError : null,
1011
- pricing: resolvePricing(config, effectiveModel),
1268
+ // 传给客户端的是**目录价**(不含峰谷倍率):客户端自己按高峰时段乘倍率,
1269
+ // 若这里给 resolvePricing(已乘倍率)客户端会再乘一次,金额虚高约 3 倍。
1270
+ pricing: catalogPricing(effectiveConfig, effectiveModel),
1012
1271
  model: effectiveModel,
1013
1272
  usage,
1014
1273
  subagentUsage,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "balance-dsh",
3
- "version": "1.2.2",
4
- "description": "查询 DeepSeek 余额并显示当前会话 token 消耗。DeepSeek 账户余额 + 会话 token/费用显示,含高峰/空闲峰谷计费。",
3
+ "version": "1.3.0",
4
+ "description": "在 DSH 中显示 DeepSeek 账户余额和当前会话 token/费用消耗(含高峰/空闲峰谷计费)。仅支持 DeepSeek 官方模型与账号,暂不支持其他大模型。",
5
5
  "author": "Rannist",
6
6
  "type": "module",
7
7
  "main": "lib/index.js",