balance-dsh 1.0.8 → 1.0.10

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.
Files changed (2) hide show
  1. package/lib/index.js +99 -20
  2. package/package.json +2 -2
package/lib/index.js CHANGED
@@ -1,9 +1,55 @@
1
1
  // dsh 插件:余额与当前会话消耗
2
2
  // 服务端:注册 /balance 命令 + /balance-api HTTP 接口(供 Web UI 插件直接渲染)
3
- import { inspectApiRemoteSession } from "@deepseek-ai/dsh-api-remotes";
4
-
5
3
  export const name = "balance-dsh";
6
4
 
5
+ let legacySessionInspector;
6
+ let legacySessionInspectorLoaded = false;
7
+
8
+ /**
9
+ * 跨 DSH 版本读取会话,且不在模块加载阶段绑定可能被删除的命名导出。
10
+ * 新版优先使用 sessionController;其次直接使用 sessionQuery;旧版最后回退
11
+ * @deepseek-ai/dsh-api-remotes.inspectApiRemoteSession。接口全部不可用时抛错,
12
+ * 由子代理统计调用方跳过该会话,不影响余额插件本身启动。
13
+ */
14
+ async function inspectPersistedSession(ctx, sessionId) {
15
+ const getService = typeof ctx?.get === "function" ? ctx.get.bind(ctx) : null;
16
+
17
+ const sessionController = getService?.("sessionController");
18
+ if (typeof sessionController?.inspect === "function") {
19
+ return sessionController.inspect(sessionId);
20
+ }
21
+
22
+ const sessionQuery = getService?.("sessionQuery");
23
+ if (typeof sessionQuery?.observeSession === "function") {
24
+ const observation = await sessionQuery.observeSession(sessionId, { projectionMode: "none" });
25
+ try {
26
+ return {
27
+ meta: observation.header,
28
+ events: [...(observation.events ?? [])],
29
+ };
30
+ } finally {
31
+ observation?.[Symbol.dispose]?.();
32
+ }
33
+ }
34
+
35
+ if (!legacySessionInspectorLoaded) {
36
+ legacySessionInspectorLoaded = true;
37
+ try {
38
+ const remotes = await import("@deepseek-ai/dsh-api-remotes");
39
+ if (typeof remotes.inspectApiRemoteSession === "function") {
40
+ legacySessionInspector = remotes.inspectApiRemoteSession;
41
+ }
42
+ } catch {
43
+ // 新版可能不再提供旧模块或旧导出;由下方统一处理。
44
+ }
45
+ }
46
+ if (typeof legacySessionInspector === "function") {
47
+ return legacySessionInspector(ctx, sessionId);
48
+ }
49
+
50
+ throw new Error("当前 DSH 未提供兼容的持久化会话读取接口");
51
+ }
52
+
7
53
  const DEEPSEEK_BALANCE_URL = "https://api.deepseek.com/user/balance";
8
54
 
9
55
  // DeepSeek 目录价(人民币/百万 tokens),按模型区分。默认取 2026-08-17 生效的
@@ -355,16 +401,35 @@ function recentDayKeys(days, now = Date.now()) {
355
401
  }
356
402
 
357
403
  async function buildUsageStats(ctx, config) {
358
- const persistence = typeof ctx.get === "function" ? ctx.get("sessionPersistence") : void 0;
359
- if (persistence == null || typeof persistence.list !== "function" || typeof persistence.inspect !== "function") {
360
- throw new Error("会话持久化服务不可用");
361
- }
404
+ const getService = typeof ctx.get === "function" ? ctx.get.bind(ctx) : null;
405
+ const controller = getService?.("sessionController");
406
+ const persistence = getService?.("sessionPersistence");
362
407
  const now = Date.now();
363
408
  const cutoffMs = now - (STATS_DAYS - 1) * 24 * 60 * 60 * 1000;
364
- const headers = await persistence.list();
365
- const selected = [...headers]
366
- .filter((header) => header && typeof header.id === "string")
367
- .sort((a, b) => Number(b.createdAt ?? 0) - Number(a.createdAt ?? 0))
409
+ // 列举会话要跨 DSH 版本:新版 sessionController.list() 返回 { items:[{ sessionId, updatedAt }] }
410
+ // (已按活动降序,字段叫 sessionId);旧版 sessionPersistence.list() 返回 [{ id, createdAt }]
411
+ // (字段叫 id)。旧版 persistence 还提供 inspect(),新版改成 handle-based 的 open(),
412
+ // 所以读取一律走已做三级兼容的 inspectPersistedSession,不再直接调 persistence.inspect。
413
+ let sessionIds = [];
414
+ let availableSessions = 0;
415
+ if (typeof controller?.list === "function") {
416
+ const listed = await controller.list({});
417
+ const items = Array.isArray(listed) ? listed : (listed?.items ?? []);
418
+ sessionIds = items.map((item) => (item && typeof item === "object" ? item.sessionId ?? item.id : void 0));
419
+ availableSessions = sessionIds.length;
420
+ } else if (typeof persistence?.list === "function") {
421
+ const headers = await persistence.list();
422
+ const list = Array.isArray(headers) ? headers : [];
423
+ availableSessions = list.length;
424
+ sessionIds = [...list]
425
+ .filter((header) => header && typeof header.id === "string")
426
+ .sort((a, b) => Number(b.createdAt ?? 0) - Number(a.createdAt ?? 0))
427
+ .map((header) => header.id);
428
+ } else {
429
+ throw new Error("会话持久化服务不可用");
430
+ }
431
+ const selected = sessionIds
432
+ .filter((id) => typeof id === "string" && id !== "")
368
433
  .slice(0, STATS_MAX_SESSIONS);
369
434
  const daily = new Map();
370
435
  let cursor = 0;
@@ -372,16 +437,16 @@ async function buildUsageStats(ctx, config) {
372
437
  let failedSessions = 0;
373
438
  const worker = async () => {
374
439
  while (cursor < selected.length) {
375
- const header = selected[cursor++];
440
+ const sessionId = selected[cursor++];
376
441
  try {
377
- const inspected = await persistence.inspect(header.id);
442
+ const inspected = await inspectPersistedSession(ctx, sessionId);
378
443
  collectDailyUsage(
379
444
  inspected?.events ?? [],
380
445
  config,
381
446
  detectModel({ requestHeader: () => void 0, requestContext: () => void 0, events: inspected?.events ?? [] }),
382
447
  cutoffMs,
383
448
  daily,
384
- header.id,
449
+ sessionId,
385
450
  );
386
451
  scannedSessions++;
387
452
  } catch {
@@ -422,19 +487,19 @@ async function buildUsageStats(ctx, config) {
422
487
  rangeDays: STATS_DAYS,
423
488
  scannedSessions,
424
489
  failedSessions,
425
- availableSessions: headers.length,
426
- limited: headers.length > selected.length,
490
+ availableSessions,
491
+ limited: availableSessions > selected.length,
427
492
  generatedAt: now,
428
493
  };
429
494
  }
430
495
 
431
496
  /** 汇总某会话的所有子代理后代会话的 usage(跨时段精确计费,与主会话分开)。
432
- * @param ctx DSH 上下文(用于 subagents 服务 + inspectApiRemoteSession)
497
+ * @param ctx DSH 上下文(用于 subagents 服务 + 跨版本持久化会话读取)
433
498
  * @param sessionId 父会话 id
434
499
  * @param config 插件配置
435
500
  * 说明:子代理会话结束后会被从 live 会话 store 移除,故不能用 store.list() 枚举;
436
501
  * 这里用 subagents 服务的 listDescendants(含已结束/嵌套子代理)取后代 id,
437
- * 再对每个子代理用 inspectApiRemoteSession 读取持久化事件计算 usage。无子代理返回 {usage:null,debug}。 */
502
+ * 再对每个子代理用兼容读取器取得持久化事件并计算 usage。无子代理返回 {usage:null,debug}。 */
438
503
  async function sumSubagentUsage(ctx, sessionId, config) {
439
504
  if (ctx == null || sessionId === "") return { usage: null };
440
505
  // 用 ctx.get("subagents") 安全获取(非注入,可空);不要直接读 ctx.subagents 属性,
@@ -466,7 +531,7 @@ async function sumSubagentUsage(ctx, sessionId, config) {
466
531
  let count = 0;
467
532
  for (const id of childIds) {
468
533
  try {
469
- const inspected = await inspectApiRemoteSession(ctx, id);
534
+ const inspected = await inspectPersistedSession(ctx, id);
470
535
  const events = Array.isArray(inspected?.events) ? inspected.events : [];
471
536
  const u = sumEventsUsage(events, config);
472
537
  if (u === null || u === void 0) continue;
@@ -548,6 +613,11 @@ function writeJson(res, status, payload) {
548
613
 
549
614
  export function apply(ctx, config = {}) {
550
615
  let statsCache = { expiresAt: 0, value: null };
616
+ // DSH 热重载(reload)时会重新 apply 本插件;若旧实例注册的 /balance-api 路由
617
+ // 未被清理,新实例再次 register 就会抛出 duplicate route,导致整个 apply 失败、
618
+ // 余额不显示。这里收集每次 register 返回的 disposer,并在插件卸载(ctx.on("dispose"))
619
+ // 时统一清理,保证重复 apply 不冲突。
620
+ const disposers = [];
551
621
 
552
622
  // 1) /balance 命令:保留命令行查询入口
553
623
  ctx.inject(["commands"], (commandCtx) => {
@@ -591,7 +661,7 @@ export function apply(ctx, config = {}) {
591
661
  // /balance-api 不注册(404)。改为只声 inject ["webServer"],子代理在
592
662
  // sumSubagentUsage 内部用可空 ctx.get("subagents") 获取。
593
663
  ctx.inject(["webServer"], (httpCtx) => {
594
- httpCtx.webServer.register({
664
+ const balanceRouteDisposer = httpCtx.webServer.register({
595
665
  kind: "exact",
596
666
  path: "/balance-api",
597
667
  handler: async (req, res) => {
@@ -705,6 +775,15 @@ export function apply(ctx, config = {}) {
705
775
  }
706
776
  },
707
777
  });
708
-
778
+ if (typeof balanceRouteDisposer === "function") disposers.push(balanceRouteDisposer);
709
779
  });
780
+
781
+ // 插件卸载/dispose 时清理本实例注册的路由,避免 DSH 热重载时旧实例残留
782
+ // 的路由导致新实例 register /balance-api 抛 duplicate route、插件启动失败。
783
+ const release = () => {
784
+ for (const d of disposers) {
785
+ try { d(); } catch { /* ignore */ }
786
+ }
787
+ };
788
+ if (typeof ctx.on === "function") ctx.on("dispose", release);
710
789
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "balance-dsh",
3
- "version": "1.0.8",
3
+ "version": "1.0.10",
4
4
  "description": "查询 DeepSeek 余额并显示当前会话 token 消耗。DeepSeek 账户余额 + 会话 token/费用显示,含高峰/空闲峰谷计费。",
5
5
  "author": "Rannist",
6
6
  "type": "module",
@@ -26,10 +26,10 @@
26
26
  "patch": "./cordis.patch.yml"
27
27
  },
28
28
  "client": {
29
- "platform": "web",
30
29
  "inject": [
31
30
  "@deepseek-ai/dsh-client-runtime"
32
31
  ],
32
+ "platform": "web",
33
33
  "immediately": true
34
34
  }
35
35
  },