balance-dsh 1.2.3 → 1.3.1

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.3";
346
+ const PLUGIN_VERSION = "1.3.1";
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 = "";
@@ -414,11 +453,75 @@ function modelAtTime(switches, time) {
414
453
  return model;
415
454
  }
416
455
 
456
+ /** 网页搜索 / 抓取工具按次计费(官方 2026-10-17 上线 Search/Fetch API 公告):
457
+ * · Search 与 Fetch 同价:**每千次 ¥5**(= ¥0.005 / 次),结果条数不影响价格;
458
+ * · 只有 Search 返回非空结果、Fetch 返回正文且 HTTP 200 才计费,失败/空结果/超时不计费;
459
+ * · **北京时间 2026-11-01 00:00 起开始收费**,此前的调用免费。
460
+ * 会话事件里 tool/call 带 { callId, name, arguments }(name = web_search / web_fetch),
461
+ * tool/result 带 { toolCallId, message: { isError, content } },按 callId 配对就能算钱。 */
462
+ const WEB_TOOL_BILLING_START = Date.parse("2026-11-01T00:00:00+08:00");
463
+ const WEB_TOOL_PRICE_PER_CALL = 5 / 1000;
464
+
465
+ /** 统计网页工具调用:次数、可计费次数与金额。
466
+ * 只认 tool/call 与 tool/result 配对成功、且 isError !== true 的调用(官方口径:失败不计费)。 */
467
+ function webToolUsage(events) {
468
+ const calls = new Map(); // callId -> { name, time }
469
+ for (const event of events ?? []) {
470
+ if (event.type !== "tool/call") continue;
471
+ const name = event.data?.name;
472
+ if (name !== "web_search" && name !== "web_fetch") continue;
473
+ const time = Number(event.time ?? 0);
474
+ if (!Number.isFinite(time)) continue;
475
+ const id = event.data?.callId;
476
+ if (id === void 0 || id === null) continue;
477
+ calls.set(String(id), { name, time });
478
+ }
479
+ let webSearchCalls = 0;
480
+ let webFetchCalls = 0;
481
+ let webSearchBillable = 0;
482
+ let webFetchBillable = 0;
483
+ let webSearchCost = 0;
484
+ let webFetchCost = 0;
485
+ const done = new Set();
486
+ for (const event of events ?? []) {
487
+ if (event.type !== "tool/result") continue;
488
+ const id = event.data?.toolCallId ?? event.data?.message?.toolCallId;
489
+ const key = id === void 0 || id === null ? "" : String(id);
490
+ const call = calls.get(key);
491
+ if (call === void 0 || done.has(key)) continue;
492
+ done.add(key);
493
+ if (event.data?.message?.isError === true) continue; // 失败不计费
494
+ if (call.name === "web_search") webSearchCalls += 1;
495
+ else webFetchCalls += 1;
496
+ if (call.time >= WEB_TOOL_BILLING_START) {
497
+ if (call.name === "web_search") {
498
+ webSearchBillable += 1;
499
+ webSearchCost += WEB_TOOL_PRICE_PER_CALL;
500
+ } else {
501
+ webFetchBillable += 1;
502
+ webFetchCost += WEB_TOOL_PRICE_PER_CALL;
503
+ }
504
+ }
505
+ }
506
+ return {
507
+ webSearchCalls,
508
+ webFetchCalls,
509
+ webSearchBillable,
510
+ webFetchBillable,
511
+ webSearchCost,
512
+ webFetchCost,
513
+ webToolCost: webSearchCost + webFetchCost,
514
+ };
515
+ }
516
+
417
517
  /** 从事件数组汇总 usage(按模型分段 + 跨时段分桶)。
418
518
  * 供 live 会话与持久化(子代理)会话复用。返回按模型拆分的 byModel,及跨模型聚合字段。 */
419
519
  function sumEventsUsage(events, config, fallbackModel = "") {
420
520
  const switches = collectModelSwitches(events);
521
+ const starts = stepStartTimes(events);
421
522
  const fallback = fallbackModel !== "" ? fallbackModel : "deepseek-v4-flash";
523
+ // 网页搜索 / 抓取:次数 + 按次计费金额(2026-11-01 起才收费)
524
+ const webTools = webToolUsage(events);
422
525
  const latestByStep = new Map();
423
526
  for (const event of events ?? []) {
424
527
  if (event.type !== "assistant/message") continue;
@@ -427,7 +530,9 @@ function sumEventsUsage(events, config, fallbackModel = "") {
427
530
  const turn = event.data?.turn;
428
531
  const step = event.data?.step;
429
532
  if (turn === void 0 || step === void 0) continue;
430
- latestByStep.set(`${turn}/${step}`, { usage, time: event.time ?? Date.now() });
533
+ // 时刻取"请求发起时刻"(step/start),拿不到时回退 assistant/message 的完成时刻
534
+ const time = starts.get(`${turn}/${step}`) ?? event.time ?? Date.now();
535
+ latestByStep.set(`${turn}/${step}`, { usage, time });
431
536
  }
432
537
  const add = (bucket, usage) => {
433
538
  bucket.inputTokens += usage.inputTokens ?? 0;
@@ -451,7 +556,7 @@ function sumEventsUsage(events, config, fallbackModel = "") {
451
556
  const offpeak = empty();
452
557
  const modelList = [];
453
558
  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)) });
559
+ 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
560
  peak.inputTokens += e.peak.inputTokens;
456
561
  peak.outputTokens += e.peak.outputTokens;
457
562
  peak.cacheReadTokens += e.peak.cacheReadTokens;
@@ -476,6 +581,7 @@ function sumEventsUsage(events, config, fallbackModel = "") {
476
581
  peak,
477
582
  offpeak,
478
583
  byModel: modelList,
584
+ ...webTools,
479
585
  };
480
586
  }
481
587
 
@@ -485,22 +591,188 @@ function sumSessionUsage(session, config) {
485
591
  return sumEventsUsage(sessionEvents(session), config, detectModel(session));
486
592
  }
487
593
 
488
- function estimateCost(usage, pricing) {
489
- const costOf = (u) =>
490
- (u.inputTokens * pricing.inputCnyPerMillion +
594
+ /** 精确计费:高峰桶按**目录价 × 倍率**、空闲桶按目录价。
595
+ * ⚠️ `pricing` 必须是 `catalogPricing()` 的**原价**(不含倍率)。历史上这里收的是
596
+ * `resolvePricing()` 的结果(已含倍率),再乘一次 2 导致高峰 ×4、空闲 ×2 —— 金额虚高约 3 倍。 */
597
+ function estimateCost(usage, pricing, multiplier = 2) {
598
+ const costOf = (u, factor) =>
599
+ ((u.inputTokens * pricing.inputCnyPerMillion +
491
600
  u.cacheReadTokens * pricing.cacheReadCnyPerMillion +
492
601
  u.cacheWriteTokens * pricing.cacheWriteCnyPerMillion) /
493
602
  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);
603
+ (u.outputTokens * pricing.outputCnyPerMillion) / 1_000_000) *
604
+ factor;
605
+ return costOf(usage.peak, multiplier) + costOf(usage.offpeak, 1);
498
606
  }
499
607
 
500
608
  const STATS_DAYS = 90;
501
609
  const STATS_MAX_SESSIONS = 200;
502
610
  const STATS_CACHE_MS = 5 * 60 * 1000;
503
611
 
612
+ // ───────────────────────────────────────────────────────────────────────────
613
+ // 插件设置:由 DSH「插件」页 → balance-dsh 详情页的配置卡片读写(slot
614
+ // `plugins.bundle.config`,见 lib/client.js),落在 <dshHome>/balance-dsh.json。
615
+ // 刻意不接 DSH 的 settings / storageDomain 服务——那些内部 API 无兼容承诺,
616
+ // 而这个插件已经被 DSH 升级打断过多次;普通 JSON 文件不会因升级失效。
617
+ // ───────────────────────────────────────────────────────────────────────────
618
+
619
+ const SETTINGS_FILENAME = "balance-dsh.json";
620
+
621
+ /** 出厂默认值;同时是字段白名单(不在这里的键一律丢弃)。 */
622
+ const DEFAULT_SETTINGS = {
623
+ // 余额
624
+ refreshIntervalSec: 60, // 余额轮询间隔(秒)
625
+ lowBalanceThreshold: 10, // 低于该金额用警示色
626
+ apiKey: "", // 官方账号未登录时的回退凭据
627
+ // 显示
628
+ showBalanceRow: true, // 展开态侧栏底部的余额行
629
+ showUsagePill: true, // 输入框下方「本会话消耗」胶囊
630
+ showPeriodLabel: true, // 高峰/空闲标签
631
+ showFab: true, // 收起态悬浮圆钮
632
+ // 收起态圆钮外观
633
+ fabSize: 60, // 直径(px)
634
+ fabLeft: 44, // 距窗口左缘(px)
635
+ // 统计
636
+ statsSessionLimit: STATS_MAX_SESSIONS, // 统计扫描的最近会话数
637
+ statsCacheSec: 300, // 统计结果缓存秒数
638
+ // 计费
639
+ peakMultiplier: 2, // 高峰时段倍率
640
+ priceFlash: null, // flash 覆盖价(元/百万 token),四项全填才生效
641
+ pricePro: null, // v4-pro 覆盖价
642
+ };
643
+
644
+ const PRICE_FIELDS = [
645
+ "inputCnyPerMillion",
646
+ "cacheReadCnyPerMillion",
647
+ "cacheWriteCnyPerMillion",
648
+ "outputCnyPerMillion",
649
+ ];
650
+
651
+ /** 夹到合法区间;非有限数回退默认值。 */
652
+ function clampNumber(value, min, max, fallback) {
653
+ const n = Number(value);
654
+ if (!Number.isFinite(n)) return fallback;
655
+ return Math.min(max, Math.max(min, n));
656
+ }
657
+
658
+ /** 一组覆盖价:逐个字段校验(有限非负数才收),不填的字段留空表示「用默认价」。
659
+ * 四个字段都没填则整组丢弃(等价于不覆盖)。 */
660
+ function normalizePriceOverride(raw) {
661
+ if (raw == null || typeof raw !== "object") return null;
662
+ const out = {};
663
+ for (const field of PRICE_FIELDS) {
664
+ const input = raw[field];
665
+ if (input == null || input === "") continue;
666
+ const n = Number(input);
667
+ if (!Number.isFinite(n) || n < 0) continue;
668
+ out[field] = n;
669
+ }
670
+ return Object.keys(out).length > 0 ? out : null;
671
+ }
672
+
673
+ /** 校验并补全一份设置:未知键丢弃、越界值夹紧、类型不符回退默认。 */
674
+ function normalizeSettings(raw) {
675
+ const src = raw != null && typeof raw === "object" ? raw : {};
676
+ const d = DEFAULT_SETTINGS;
677
+ const bool = (value, fallback) => (typeof value === "boolean" ? value : fallback);
678
+ return {
679
+ refreshIntervalSec: clampNumber(src.refreshIntervalSec, 5, 3600, d.refreshIntervalSec),
680
+ lowBalanceThreshold: clampNumber(src.lowBalanceThreshold, 0, 100000, d.lowBalanceThreshold),
681
+ apiKey: typeof src.apiKey === "string" ? src.apiKey.trim() : d.apiKey,
682
+ showBalanceRow: bool(src.showBalanceRow, d.showBalanceRow),
683
+ showUsagePill: bool(src.showUsagePill, d.showUsagePill),
684
+ showPeriodLabel: bool(src.showPeriodLabel, d.showPeriodLabel),
685
+ showFab: bool(src.showFab, d.showFab),
686
+ fabSize: clampNumber(src.fabSize, 32, 120, d.fabSize),
687
+ fabLeft: clampNumber(src.fabLeft, 0, 600, d.fabLeft),
688
+ statsSessionLimit: clampNumber(src.statsSessionLimit, 10, 2000, d.statsSessionLimit),
689
+ statsCacheSec: clampNumber(src.statsCacheSec, 0, 3600, d.statsCacheSec),
690
+ peakMultiplier: clampNumber(src.peakMultiplier, 1, 10, d.peakMultiplier),
691
+ priceFlash: normalizePriceOverride(src.priceFlash),
692
+ pricePro: normalizePriceOverride(src.pricePro),
693
+ };
694
+ }
695
+
696
+ /** 把设置折进 Cordis config,让既有的 resolvePricing / resolveApiKey / 统计逻辑无感读到。 */
697
+ function withSettings(config, settings) {
698
+ const s = settings ?? DEFAULT_SETTINGS;
699
+ const priceByModel = {};
700
+ if (s.priceFlash) priceByModel["deepseek-v4-flash"] = s.priceFlash;
701
+ if (s.pricePro) priceByModel["deepseek-v4-pro"] = s.pricePro;
702
+ return {
703
+ ...config,
704
+ apiKey: s.apiKey !== "" ? s.apiKey : config?.apiKey,
705
+ peakMultiplier: s.peakMultiplier,
706
+ priceByModel,
707
+ statsSessionLimit: s.statsSessionLimit,
708
+ statsCacheMs: s.statsCacheSec * 1000,
709
+ };
710
+ }
711
+
712
+ /** 设置文件路径:优先 DSH 自己的 home(dshHomePath 服务),再环境变量,最后 ~/.dsh。
713
+ * 返回空串表示定位不到,此时读写一律静默降级为「用默认值、不落盘」。 */
714
+ function settingsFilePath(ctx) {
715
+ let home = "";
716
+ try {
717
+ const svc = typeof ctx?.get === "function" ? ctx.get("dshHomePath") : null;
718
+ if (typeof svc === "string") home = svc;
719
+ else if (svc && typeof svc.path === "string") home = svc.path;
720
+ else if (svc && typeof svc.value === "string") home = svc.value;
721
+ } catch {
722
+ // 服务不存在或形状变了:走下面的兜底
723
+ }
724
+ if (home === "") home = String(process.env.DSH_HOME ?? "");
725
+ if (home === "") {
726
+ const base = process.env.USERPROFILE || process.env.HOME || "";
727
+ if (base !== "") home = `${base}${process.platform === "win32" ? "\\" : "/"}.dsh`;
728
+ }
729
+ if (home === "") return "";
730
+ const sep = home.includes("\\") && !home.includes("/") ? "\\" : "/";
731
+ return `${home.replace(/[\\/]+$/, "")}${sep}${SETTINGS_FILENAME}`;
732
+ }
733
+
734
+ /** 读设置文件;不存在 / JSON 损坏 / 无权限一律返回 null(调用方保持默认值)。 */
735
+ async function readSettingsFile(file) {
736
+ if (file === "") return null;
737
+ try {
738
+ const { readFile } = await import("node:fs/promises");
739
+ const text = await readFile(file, "utf8");
740
+ return normalizeSettings(JSON.parse(text));
741
+ } catch {
742
+ return null;
743
+ }
744
+ }
745
+
746
+ /** 写设置文件:先写临时文件再改名,避免写一半把配置写坏。 */
747
+ async function writeSettingsFile(file, settings) {
748
+ if (file === "") throw new Error("无法定位 DSH 配置目录");
749
+ const { mkdir, writeFile, rename } = await import("node:fs/promises");
750
+ const { dirname } = await import("node:path");
751
+ await mkdir(dirname(file), { recursive: true });
752
+ const tmp = `${file}.tmp`;
753
+ await writeFile(tmp, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
754
+ await rename(tmp, file);
755
+ }
756
+
757
+ /** 读 HTTP 请求体(限制大小,避免被超大 body 拖垮)。 */
758
+ function readRequestBody(req, limit = 64 * 1024) {
759
+ return new Promise((resolve, reject) => {
760
+ const chunks = [];
761
+ let size = 0;
762
+ req.on("data", (chunk) => {
763
+ size += chunk.length;
764
+ if (size > limit) {
765
+ reject(new Error("请求体过大"));
766
+ if (typeof req.destroy === "function") req.destroy();
767
+ return;
768
+ }
769
+ chunks.push(chunk);
770
+ });
771
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
772
+ req.on("error", reject);
773
+ });
774
+ }
775
+
504
776
  function beijingDayKey(timeMs) {
505
777
  return new Date(Number(timeMs) + 8 * 60 * 60 * 1000).toISOString().slice(0, 10);
506
778
  }
@@ -520,9 +792,39 @@ function tokenTotal(usage) {
520
792
  return usage.inputTokens + usage.outputTokens + usage.cacheReadTokens + usage.cacheWriteTokens;
521
793
  }
522
794
 
795
+ /** 取(或建)某天的统计桶。 */
796
+ function ensureDay(days, key) {
797
+ let day = days.get(key);
798
+ if (day == null) {
799
+ day = {
800
+ date: key,
801
+ ...emptyTokenBucket(),
802
+ totalTokens: 0,
803
+ cost: 0,
804
+ sessionIds: new Set(),
805
+ // —— 以下为细分维度,供统计面板展示;不改动上方总额口径 ——
806
+ byModel: new Map(), // modelKey -> { model, tokens..., totalTokens, cost }
807
+ peak: emptyTokenBucket(),
808
+ offpeak: emptyTokenBucket(),
809
+ peakCost: 0,
810
+ offpeakCost: 0,
811
+ // 网页搜索 / 抓取的工具调用次数与按次费用(官方 ¥5/千次,2026-11-01 起计费)
812
+ webSearchCalls: 0,
813
+ webFetchCalls: 0,
814
+ webSearchBillable: 0,
815
+ webFetchBillable: 0,
816
+ webSearchCost: 0,
817
+ webFetchCost: 0,
818
+ };
819
+ days.set(key, day);
820
+ }
821
+ return day;
822
+ }
823
+
523
824
  /** 按北京时间把一个会话的最终 assistant/message usage 聚合到每日统计。 */
524
825
  function collectDailyUsage(events, config, fallbackModel, cutoffMs, days, sessionId) {
525
826
  const switches = collectModelSwitches(events);
827
+ const starts = stepStartTimes(events);
526
828
  const fallback = fallbackModel || "deepseek-v4-flash";
527
829
  const latestByStep = new Map();
528
830
  for (const event of events ?? []) {
@@ -531,34 +833,61 @@ function collectDailyUsage(events, config, fallbackModel, cutoffMs, days, sessio
531
833
  const turn = event.data?.turn;
532
834
  const step = event.data?.step;
533
835
  if (usage == null || turn == null || step == null) continue;
534
- latestByStep.set(`${turn}/${step}`, { usage, time: Number(event.time ?? 0) });
836
+ // 与 sumEventsUsage 同口径:按"请求发起时刻"归属日期与峰谷,拿不到才回退完成时刻
837
+ latestByStep.set(`${turn}/${step}`, { usage, time: starts.get(`${turn}/${step}`) ?? Number(event.time ?? 0) });
535
838
  }
536
- for (const { usage, time } of latestByStep.values()) {
839
+ // 网页搜索 / 抓取:按调用发生的北京日期归日,金额按官方「每千次 ¥5」、且只在
840
+ // 2026-11-01 00:00(北京)之后计费。tool/call 与 tool/result 配对 + isError 过滤同 webToolUsage。
841
+ const webCalls = new Map(); // callId -> { name, time }
842
+ for (const event of events ?? []) {
843
+ if (event.type !== "tool/call") continue;
844
+ const name = event.data?.name;
845
+ if (name !== "web_search" && name !== "web_fetch") continue;
846
+ const time = Number(event.time ?? 0);
847
+ const id = event.data?.callId;
537
848
  if (!Number.isFinite(time) || time < cutoffMs) continue;
538
- const key = beijingDayKey(time);
539
- let day = days.get(key);
540
- if (day == null) {
541
- day = {
542
- date: key,
543
- ...emptyTokenBucket(),
544
- totalTokens: 0,
545
- cost: 0,
546
- sessionIds: new Set(),
547
- // —— 以下为细分维度,供统计面板展示;不改动上方总额口径 ——
548
- byModel: new Map(), // modelKey -> { model, tokens..., totalTokens, cost }
549
- peak: emptyTokenBucket(),
550
- offpeak: emptyTokenBucket(),
551
- peakCost: 0,
552
- offpeakCost: 0,
553
- };
554
- days.set(key, day);
849
+ if (id === void 0 || id === null) continue;
850
+ webCalls.set(String(id), { name, time });
851
+ }
852
+ if (webCalls.size > 0) {
853
+ const seen = new Set();
854
+ for (const event of events ?? []) {
855
+ if (event.type !== "tool/result") continue;
856
+ const id = event.data?.toolCallId ?? event.data?.message?.toolCallId;
857
+ const key = id === void 0 || id === null ? "" : String(id);
858
+ const call = webCalls.get(key);
859
+ if (call === void 0 || seen.has(key)) continue;
860
+ seen.add(key);
861
+ if (event.data?.message?.isError === true) continue;
862
+ const day = ensureDay(days, beijingDayKey(call.time));
863
+ const billable = call.time >= WEB_TOOL_BILLING_START;
864
+ if (call.name === "web_search") {
865
+ day.webSearchCalls += 1;
866
+ if (billable) {
867
+ day.webSearchBillable += 1;
868
+ day.webSearchCost += WEB_TOOL_PRICE_PER_CALL;
869
+ day.cost += WEB_TOOL_PRICE_PER_CALL;
870
+ }
871
+ } else {
872
+ day.webFetchCalls += 1;
873
+ if (billable) {
874
+ day.webFetchBillable += 1;
875
+ day.webFetchCost += WEB_TOOL_PRICE_PER_CALL;
876
+ day.cost += WEB_TOOL_PRICE_PER_CALL;
877
+ }
878
+ }
879
+ day.sessionIds.add(sessionId);
555
880
  }
881
+ }
882
+ for (const { usage, time } of latestByStep.values()) {
883
+ if (!Number.isFinite(time) || time < cutoffMs) continue;
884
+ const day = ensureDay(days, beijingDayKey(time));
556
885
  const model = modelAtTime(switches, time) || fallback;
557
886
  const isPeak = isPeakAt(time, config);
558
887
  // 分时段精确计费:高峰桶按高峰价(= 目录价 ×2)、空闲桶按目录价
559
888
  const bucket = { peak: emptyTokenBucket(), offpeak: emptyTokenBucket() };
560
889
  addTokenUsage(isPeak ? bucket.peak : bucket.offpeak, usage);
561
- const stepCost = estimateCost(bucket, resolvePricing(config, model, pricingEraAt(time)));
890
+ const stepCost = estimateCost(bucket, catalogPricing(config, model, pricingEraAt(time)), peakMultiplierOf(config));
562
891
 
563
892
  addTokenUsage(day, usage);
564
893
  const tokens = tokenTotal({
@@ -630,7 +959,7 @@ async function buildUsageStats(ctx, config) {
630
959
  }
631
960
  const selected = sessionIds
632
961
  .filter((id) => typeof id === "string" && id !== "")
633
- .slice(0, STATS_MAX_SESSIONS);
962
+ .slice(0, clampNumber(config?.statsSessionLimit, 10, 2000, STATS_MAX_SESSIONS));
634
963
  const daily = new Map();
635
964
  let cursor = 0;
636
965
  let scannedSessions = 0;
@@ -686,6 +1015,13 @@ async function buildUsageStats(ctx, config) {
686
1015
  offpeak: day.offpeak,
687
1016
  peakCost: day.peakCost,
688
1017
  offpeakCost: day.offpeakCost,
1018
+ webSearchCalls: day.webSearchCalls,
1019
+ webFetchCalls: day.webFetchCalls,
1020
+ webSearchBillable: day.webSearchBillable,
1021
+ webFetchBillable: day.webFetchBillable,
1022
+ webSearchCost: day.webSearchCost,
1023
+ webFetchCost: day.webFetchCost,
1024
+ webToolCost: day.webSearchCost + day.webFetchCost,
689
1025
  };
690
1026
  });
691
1027
  const total = rows.reduce((acc, day) => {
@@ -700,6 +1036,13 @@ async function buildUsageStats(ctx, config) {
700
1036
  addTokenUsage(acc.offpeak, day.offpeak);
701
1037
  acc.peakCost += day.peakCost;
702
1038
  acc.offpeakCost += day.offpeakCost;
1039
+ acc.webSearchCalls += day.webSearchCalls;
1040
+ acc.webFetchCalls += day.webFetchCalls;
1041
+ acc.webSearchBillable += day.webSearchBillable;
1042
+ acc.webFetchBillable += day.webFetchBillable;
1043
+ acc.webSearchCost += day.webSearchCost;
1044
+ acc.webFetchCost += day.webFetchCost;
1045
+ acc.webToolCost += day.webToolCost;
703
1046
  for (const item of day.byModel ?? []) {
704
1047
  let entry = acc.byModel.get(item.model);
705
1048
  if (entry == null) {
@@ -720,6 +1063,13 @@ async function buildUsageStats(ctx, config) {
720
1063
  offpeak: emptyTokenBucket(),
721
1064
  peakCost: 0,
722
1065
  offpeakCost: 0,
1066
+ webSearchCalls: 0,
1067
+ webFetchCalls: 0,
1068
+ webSearchBillable: 0,
1069
+ webFetchBillable: 0,
1070
+ webSearchCost: 0,
1071
+ webFetchCost: 0,
1072
+ webToolCost: 0,
723
1073
  });
724
1074
  return {
725
1075
  days: rows,
@@ -823,16 +1173,16 @@ async function sumSubagentUsage(ctx, sessionId, config) {
823
1173
  return { usage };
824
1174
  }
825
1175
 
826
- function formatUsage(usage, pricing, model = "") {
1176
+ function formatUsage(usage, pricing, multiplier = 2, model = "") {
827
1177
  // 优先按 byModel 逐段累加(每段用各自模型 + 价格时代的价格,历史消耗不因调价而变);
828
- // 无 byModel 时回退单一价格估算(兼容旧数据)。
1178
+ // 无 byModel 时回退单一价格估算(兼容旧数据)。pricing 是**目录价**,倍率单独传。
829
1179
  const cost =
830
1180
  Array.isArray(usage.byModel) && usage.byModel.length > 0
831
1181
  ? usage.byModel.reduce(
832
- (sum, b) => sum + (typeof b.cost === "number" && Number.isFinite(b.cost) ? b.cost : estimateCost(b, pricing)),
1182
+ (sum, b) => sum + (typeof b.cost === "number" && Number.isFinite(b.cost) ? b.cost : estimateCost(b, pricing, multiplier)),
833
1183
  0,
834
1184
  )
835
- : estimateCost(usage, pricing);
1185
+ : estimateCost(usage, pricing, multiplier);
836
1186
  const newTokens = usage.inputTokens + usage.outputTokens;
837
1187
  const peakTokens = usage.peak.inputTokens + usage.peak.cacheReadTokens + usage.peak.cacheWriteTokens + usage.peak.outputTokens;
838
1188
  const offpeakTokens = usage.offpeak.inputTokens + usage.offpeak.cacheReadTokens + usage.offpeak.cacheWriteTokens + usage.offpeak.outputTokens;
@@ -862,6 +1212,18 @@ function writeJson(res, status, payload) {
862
1212
 
863
1213
  export function apply(ctx, config = {}) {
864
1214
  let statsCache = { expiresAt: 0, value: null };
1215
+ // 用户设置(「插件」页配置卡片读写):先按出厂默认值跑,异步读到文件后热替换。
1216
+ // 读不到文件不是错误——全新安装就是没有文件,保持默认值即可。
1217
+ const settingsState = { value: { ...DEFAULT_SETTINGS }, file: "" };
1218
+ let effectiveConfig = withSettings(config, settingsState.value);
1219
+ void (async () => {
1220
+ settingsState.file = settingsFilePath(ctx);
1221
+ const loaded = await readSettingsFile(settingsState.file);
1222
+ if (loaded) {
1223
+ settingsState.value = loaded;
1224
+ effectiveConfig = withSettings(config, loaded);
1225
+ }
1226
+ })();
865
1227
  // DSH 热重载(reload)时会重新 apply 本插件;若旧实例注册的 /balance-api 路由
866
1228
  // 未被清理,新实例再次 register 就会抛出 duplicate route,导致整个 apply 失败、
867
1229
  // 余额不显示。这里收集每次 register 返回的 disposer,并在插件卸载(ctx.on("dispose"))
@@ -883,20 +1245,21 @@ export function apply(ctx, config = {}) {
883
1245
  name: "balance",
884
1246
  description: "查询 DeepSeek 余额与当前会话消耗",
885
1247
  handler: async ({ agent, signal }) => {
886
- const usage = sumSessionUsage(agent.session, config);
1248
+ const usage = sumSessionUsage(agent.session, effectiveConfig);
887
1249
  const model = detectModel(agent.session);
888
- const pricing = resolvePricing(config, model);
1250
+ const pricing = catalogPricing(effectiveConfig, model);
1251
+ const multiplier = peakMultiplierOf(effectiveConfig);
889
1252
  // 余额来源:官方账号优先(官方版内置登录,无需 API Key),回退 API Key。
890
- const { balance, error } = await resolveBalance(ctx, config, signal);
1253
+ const { balance, error } = await resolveBalance(ctx, effectiveConfig, signal);
891
1254
  if (balance === null) {
892
1255
  return {
893
1256
  kind: "error",
894
- text: `${error}\n\n${formatUsage(usage, pricing, model)}`,
1257
+ text: `${error}\n\n${formatUsage(usage, pricing, multiplier, model)}`,
895
1258
  };
896
1259
  }
897
1260
  return {
898
1261
  kind: "success",
899
- text: `${formatBalance(balance)}\n\n${formatUsage(usage, pricing, model)}`,
1262
+ text: `${formatBalance(balance)}\n\n${formatUsage(usage, pricing, multiplier, model)}`,
900
1263
  };
901
1264
  },
902
1265
  });
@@ -912,6 +1275,40 @@ export function apply(ctx, config = {}) {
912
1275
  kind: "exact",
913
1276
  path: "/balance-api",
914
1277
  handler: async (req, res) => {
1278
+ // 配置读写:GET /balance-api?config=1 读设置,POST 同路径写设置。
1279
+ // DSH「插件」页 → balance-dsh → 配置卡片(lib/client.js 注册的
1280
+ // plugins.bundle.config)就走这两个入口。
1281
+ let configParams = null;
1282
+ try {
1283
+ configParams = new URL(req.url, "http://localhost").searchParams;
1284
+ } catch {
1285
+ configParams = null;
1286
+ }
1287
+ if (configParams?.get("config") === "1") {
1288
+ try {
1289
+ if (req.method === "GET") {
1290
+ writeJson(res, 200, { ok: true, settings: settingsState.value, version: PLUGIN_VERSION });
1291
+ return;
1292
+ }
1293
+ if (req.method !== "POST") {
1294
+ writeJson(res, 405, { ok: false, error: "method not allowed" });
1295
+ return;
1296
+ }
1297
+ const body = await readRequestBody(req);
1298
+ const parsed = body.trim() === "" ? {} : JSON.parse(body);
1299
+ const patch = parsed != null && typeof parsed === "object" && parsed.settings != null ? parsed.settings : parsed;
1300
+ const next = normalizeSettings({ ...settingsState.value, ...(patch ?? {}) });
1301
+ await writeSettingsFile(settingsState.file || settingsFilePath(ctx), next);
1302
+ settingsState.value = next;
1303
+ effectiveConfig = withSettings(config, next);
1304
+ // 价格与扫描范围可能变了,作废统计缓存
1305
+ statsCache = { expiresAt: 0, value: null };
1306
+ writeJson(res, 200, { ok: true, settings: next, version: PLUGIN_VERSION });
1307
+ } catch (error) {
1308
+ writeJson(res, 400, { ok: false, error: error instanceof Error ? error.message : String(error) });
1309
+ }
1310
+ return;
1311
+ }
915
1312
  if (req.method !== "GET") {
916
1313
  writeJson(res, 405, { ok: false, error: "method not allowed" });
917
1314
  return;
@@ -937,8 +1334,8 @@ export function apply(ctx, config = {}) {
937
1334
  try {
938
1335
  if (statsCache.value == null || statsCache.expiresAt <= Date.now()) {
939
1336
  statsCache = {
940
- value: await buildUsageStats(httpCtx, config),
941
- expiresAt: Date.now() + STATS_CACHE_MS,
1337
+ value: await buildUsageStats(httpCtx, effectiveConfig),
1338
+ expiresAt: Date.now() + (effectiveConfig.statsCacheMs ?? STATS_CACHE_MS),
942
1339
  };
943
1340
  }
944
1341
  stats = statsCache.value;
@@ -957,7 +1354,7 @@ export function apply(ctx, config = {}) {
957
1354
  const sessions = ctx.get("sessions");
958
1355
  const session = sessions && typeof sessions.get === "function" ? sessions.get(sessionParam) : void 0;
959
1356
  if (session !== void 0) {
960
- usage = sumSessionUsage(session, config);
1357
+ usage = sumSessionUsage(session, effectiveConfig);
961
1358
  detectedModel = detectModel(session);
962
1359
  }
963
1360
  // DSH 2.0.4 起 live session(sessions.get)可能拿不到 events,导致 usage 全 0。
@@ -966,7 +1363,7 @@ export function apply(ctx, config = {}) {
966
1363
  try {
967
1364
  const persisted = await inspectPersistedSession(ctx, sessionParam);
968
1365
  if (Array.isArray(persisted?.events) && persisted.events.length > 0) {
969
- const persistedUsage = sumEventsUsage(persisted.events, config, detectedModel);
1366
+ const persistedUsage = sumEventsUsage(persisted.events, effectiveConfig, detectedModel);
970
1367
  if (persistedUsage.totalTokens > 0) {
971
1368
  usage = persistedUsage;
972
1369
  if (detectedModel === "") {
@@ -982,7 +1379,7 @@ export function apply(ctx, config = {}) {
982
1379
  // 仍无任何 token 就返回 null,让客户端回退本地投影估算(而不是当成 0 消耗显示 ¥0.0000)
983
1380
  if (usage != null && usage.totalTokens === 0) usage = null;
984
1381
  // 子代理枚举走持久化目录(不依赖 live 会话),即使父会话不在内存也能拿到
985
- const subRes = await sumSubagentUsage(httpCtx, sessionParam, config);
1382
+ const subRes = await sumSubagentUsage(httpCtx, sessionParam, effectiveConfig);
986
1383
  subagentUsage = subRes.usage;
987
1384
  } catch {
988
1385
  usage = null;
@@ -993,13 +1390,13 @@ export function apply(ctx, config = {}) {
993
1390
  const effectiveModel = model || detectedModel;
994
1391
  // 逐模型标注 cost(高峰期×2、空闲期×1),客户端直接按各模型 cost 累加,无需自己定价。
995
1392
  if (usage && Array.isArray(usage.byModel)) {
996
- usage.byModel = usage.byModel.map((b) => ({ ...b, cost: estimateCost(b, resolvePricing(config, b.model, b.era)) }));
1393
+ usage.byModel = usage.byModel.map((b) => ({ ...b, cost: estimateCost(b, catalogPricing(effectiveConfig, b.model, b.era), peakMultiplierOf(effectiveConfig)) }));
997
1394
  }
998
1395
  if (subagentUsage && Array.isArray(subagentUsage.byModel)) {
999
- subagentUsage.byModel = subagentUsage.byModel.map((b) => ({ ...b, cost: estimateCost(b, resolvePricing(config, b.model, b.era)) }));
1396
+ subagentUsage.byModel = subagentUsage.byModel.map((b) => ({ ...b, cost: estimateCost(b, catalogPricing(effectiveConfig, b.model, b.era), peakMultiplierOf(effectiveConfig)) }));
1000
1397
  }
1001
1398
  // 余额来源:官方账号优先(官方桌面版内置登录,无需 API Key),回退 DeepSeek API Key。
1002
- const { balance, error: balanceError } = await resolveBalance(ctx, config);
1399
+ const { balance, error: balanceError } = await resolveBalance(ctx, effectiveConfig);
1003
1400
  if (balance === null && !wantStats) {
1004
1401
  writeJson(res, 200, { ok: false, error: balanceError });
1005
1402
  return;
@@ -1008,7 +1405,9 @@ export function apply(ctx, config = {}) {
1008
1405
  ok: true,
1009
1406
  balance,
1010
1407
  balanceError: balance === null ? balanceError : null,
1011
- pricing: resolvePricing(config, effectiveModel),
1408
+ // 传给客户端的是**目录价**(不含峰谷倍率):客户端自己按高峰时段乘倍率,
1409
+ // 若这里给 resolvePricing(已乘倍率)客户端会再乘一次,金额虚高约 3 倍。
1410
+ pricing: catalogPricing(effectiveConfig, effectiveModel),
1012
1411
  model: effectiveModel,
1013
1412
  usage,
1014
1413
  subagentUsage,