@kenz1117/dsh-ui-usage-billing 1.5.0-alpha.2 → 1.5.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
@@ -2301,7 +2301,9 @@ const BUILTIN_MODEL_KEY_ALIASES = {
2301
2301
  "deepseek-v4.1-flash": "flash",
2302
2302
  "deepseek-v4.1-flash-expires-on-0910": "flash",
2303
2303
  "deepseek-flash": "flash",
2304
+ "deepseek-v4.1-flash-1017": "flash",
2304
2305
  "deepseek/deepseek-v4.1-flash": "flash",
2306
+ "deepseek/deepseek-v4.1-flash-1017": "flash",
2305
2307
  "deepseek/deepseek-flash": "flash",
2306
2308
  "deepseek/deepseek-v4-pro": "pro",
2307
2309
  "deepseek-v4-pro": "pro",
@@ -2733,6 +2735,57 @@ function workspaceNameOf(cwd) {
2733
2735
  if (cwd === void 0 || cwd === "") return "—";
2734
2736
  return cwd.split(/[\\/]/).filter(Boolean).at(-1) ?? "—";
2735
2737
  }
2738
+ /**
2739
+ * 从会话 header 提取子智能体归并标记(直接父会话 id):`origin: "subagent"`
2740
+ * (宿主的产品分类)或 `delegationDepth > 0`(持久化委派深度)任一命中即视为
2741
+ * 子会话。子会话但 header 无 parentSession(异常数据)返回 undefined——无法
2742
+ * 归位就按顶层会话处理,保守不标。导出供测试。
2743
+ */
2744
+ function subParentOf(meta) {
2745
+ if (meta.origin !== "subagent" && !((meta.delegationDepth ?? 0) > 0)) return void 0;
2746
+ const parent = meta.parentSession;
2747
+ return typeof parent === "string" && parent !== "" ? parent : void 0;
2748
+ }
2749
+ /**
2750
+ * 给会话行补后代合计(subCost/subCalls):按 parentId 建子索引,对每行递归
2751
+ * 汇总全部后代子会话(多级委派整链计入根对话)。纯展示层归因——不改任何
2752
+ * 总额桶(子会话费用本就在 total/byDay/byModel 里,行级只是拆出来给人看)。
2753
+ * 环引用(异常数据)按 visited 跳过,不死循环。原地修改传入行。导出供测试。
2754
+ */
2755
+ function foldSubagentFamilies(rows) {
2756
+ const subsOf = /* @__PURE__ */ new Map();
2757
+ for (const row of rows) {
2758
+ const parent = row.parentId;
2759
+ if (parent === void 0) continue;
2760
+ const list = subsOf.get(parent);
2761
+ if (list !== void 0) list.push(row);
2762
+ else subsOf.set(parent, [row]);
2763
+ }
2764
+ const familyOf = (id, visited) => {
2765
+ let cost = 0;
2766
+ let calls = 0;
2767
+ for (const child of subsOf.get(id) ?? []) {
2768
+ if (visited.has(child.id)) continue;
2769
+ visited.add(child.id);
2770
+ cost += child.cost;
2771
+ calls += child.calls;
2772
+ const nested = familyOf(child.id, visited);
2773
+ cost += nested.cost;
2774
+ calls += nested.calls;
2775
+ }
2776
+ return {
2777
+ cost,
2778
+ calls
2779
+ };
2780
+ };
2781
+ for (const row of rows) {
2782
+ const family = familyOf(row.id, /* @__PURE__ */ new Set([row.id]));
2783
+ if (family.calls > 0) {
2784
+ row.subCalls = family.calls;
2785
+ if (family.cost > 0) row.subCost = family.cost;
2786
+ }
2787
+ }
2788
+ }
2736
2789
  /** TTFT 尖峰阈值(毫秒):超过计为一次尖峰样本,用于定位服务端抖动。 */
2737
2790
  const PERF_SPIKE_MS = 1e4;
2738
2791
  /** 全零摘要(合并的恒等元)。 */
@@ -2876,6 +2929,46 @@ function perfDigestsOf(samples) {
2876
2929
  };
2877
2930
  }
2878
2931
  /**
2932
+ * 账本行谁更新:{@link UsageLedgerSession.foldedAt} 新者胜(缺失视为 0 =
2933
+ * 合并语义引入前的旧行);时刻相同比 {@link UsageLedgerSession.foldVersion}
2934
+ * (新算法胜);再相同视为等价,取 incoming(本进程刚折出的当前视图)。
2935
+ */
2936
+ function ledgerRowWins(incoming, existing) {
2937
+ const at = incoming.foldedAt ?? 0;
2938
+ const bt = existing.foldedAt ?? 0;
2939
+ if (at !== bt) return at > bt;
2940
+ return (incoming.foldVersion ?? 1) > (existing.foldVersion ?? 1);
2941
+ }
2942
+ /**
2943
+ * 多实例账本合并(自审计延期项:save 路径 last-writer-wins 丢写):同一
2944
+ * DSH_HOME 下多个宿主实例各自持有内存账本,整份覆盖式落盘会让后写者抹掉
2945
+ * 先写者刚保存的行。本进程要落的文档与磁盘现存文档按会话 id 行级合并:
2946
+ * 双方都有的行按「谁更新谁胜」,仅磁盘有的行(他实例写入、本进程内存没有)
2947
+ * 原样保留——账本行从不按 seen 剪除(见 aggregateOnce 尾部注释),合并不会
2948
+ * 复活已删会话。合并只发生在落盘边界:跨实例的即时可见性仍靠重启重载,
2949
+ * 此处保证的是磁盘上不丢数据(最终一致)。
2950
+ * @param document - 本进程刚聚合出的完整账本文档。
2951
+ * @param existing - 磁盘上现存(他实例可能写入过)的账本文档。
2952
+ * @returns 合并后的文档(sessions 行序:incoming 在前、existing 独有行按原序追加)。
2953
+ */
2954
+ function mergeUsageLedgerDocuments(document, existing) {
2955
+ const byId = /* @__PURE__ */ new Map();
2956
+ for (const row of existing.sessions) byId.set(row.id, row);
2957
+ const sessions = [];
2958
+ for (const row of document.sessions) {
2959
+ const prev = byId.get(row.id);
2960
+ sessions.push(prev !== void 0 && ledgerRowWins(prev, row) ? prev : row);
2961
+ byId.delete(row.id);
2962
+ }
2963
+ for (const row of byId.values()) sessions.push(row);
2964
+ const applied = document.appliedMigrations === void 0 && existing.appliedMigrations === void 0 ? void 0 : [.../* @__PURE__ */ new Set([...document.appliedMigrations ?? [], ...existing.appliedMigrations ?? []])];
2965
+ return {
2966
+ ...document,
2967
+ sessions,
2968
+ ...applied === void 0 ? {} : { appliedMigrations: applied }
2969
+ };
2970
+ }
2971
+ /**
2879
2972
  * 账本迁移注册表。首条迁移给 1.0.6 及更早的行回填 foldVersion = 1(它们全部出自
2880
2973
  * header 归因算法);perf 摘要化迁移把 v15 及更早行的逐样本 `perf` 数组转换为
2881
2974
  * `perfDigest`/`perfHourDigest` 摘要(日志已删的行不再被重折,迁移是它们唯一的
@@ -3236,20 +3329,20 @@ function foldInto(fold, machine, events, subscriptionProviders, officialProvider
3236
3329
  const unverifiedChannel = siteBucket === "unknown";
3237
3330
  const priced = !subscription && !unverifiedChannel && isPriced(modelKey);
3238
3331
  if (!subscription && !isPriced(modelKey)) fold.unpricedModels.add(modelKey);
3239
- const entry = priced ? modelOf(modelKey) : void 0;
3240
- const thisCost = entry === void 0 ? void 0 : computeCostAt(entry, {
3332
+ const entry = modelOf(modelKey);
3333
+ const thisCost = priced ? computeCostAt(entry, {
3241
3334
  input: (usage.cacheReadTokens ?? 0) + usage.inputTokens + (usage.cacheWriteTokens ?? 0),
3242
3335
  cacheHit: usage.cacheReadTokens ?? 0,
3243
3336
  cacheMiss: usage.inputTokens + (usage.cacheWriteTokens ?? 0),
3244
3337
  output: usage.outputTokens
3245
- }, event.time);
3338
+ }, event.time) : void 0;
3246
3339
  foldUsage(fold.total, usage, modelKey, subscription, event.time, official, thisCost, unverifiedChannel);
3247
3340
  foldUsage(usageCell(fold.byModel, modelKey), usage, modelKey, subscription, event.time, official, thisCost, unverifiedChannel);
3248
3341
  foldUsage(usageCell(fold.byDay, day), usage, modelKey, subscription, event.time, official, thisCost, unverifiedChannel);
3249
3342
  foldUsage(modelDayCell(fold.byDayModels, day, modelKey), usage, modelKey, subscription, event.time, official, thisCost, unverifiedChannel);
3250
3343
  foldUsage(modelDaySiteCell(fold.byDayModelsSite, day, modelKey, siteBucket), usage, modelKey, subscription, event.time, official, thisCost, unverifiedChannel);
3251
3344
  foldUsage(usageCell(fold.bySite, siteBucket), usage, modelKey, subscription, event.time, official, thisCost, unverifiedChannel);
3252
- foldUsage(usageCell(fold.byTier, tierAt(event.time)), usage, modelKey, subscription, event.time, official, thisCost, unverifiedChannel);
3345
+ if (entry.tierSemantics !== "latency") foldUsage(usageCell(fold.byTier, tierAt(event.time)), usage, modelKey, subscription, event.time, official, thisCost, unverifiedChannel);
3253
3346
  if (subscription) fold.planCalls.set(modelKey, (fold.planCalls.get(modelKey) ?? 0) + 1);
3254
3347
  if (subscription) {
3255
3348
  const sitePlanKey = planSiteKey(day, modelKey, siteBucket);
@@ -3410,19 +3503,21 @@ function hourPerfOf(digest) {
3410
3503
  * 订阅豁免),历史账本行立即失效并全量重折,配置变更即时生效(issue #37)。
3411
3504
  * 判定函数无法序列化,统一记为 `fn`(任何函数形态互视为同一指纹)。
3412
3505
  */
3413
- function configFingerprint(subscriptionMatcher, officialProviderIds, routeAliases, searchEstimate, modelKeyAliases = {}, registeredProviders = /* @__PURE__ */ new Set()) {
3506
+ function configFingerprint(subscriptionMatcher, officialProviderIds, routeAliases, searchEstimate, modelKeyAliases = {}, registeredProviders = /* @__PURE__ */ new Set(), routeOrigins = {}) {
3414
3507
  const subs = typeof subscriptionMatcher === "function" ? "fn" : [...subscriptionMatcher].sort().join(",");
3415
3508
  const officials = officialProviderIds === void 0 ? "" : [...officialProviderIds].sort().join(",");
3416
3509
  const aliases = Object.keys(routeAliases).sort().map((key) => `${key}=${routeAliases[key]}`).join(",");
3417
3510
  const modelKeys = Object.keys(modelKeyAliases).sort().map((key) => `${key}=${modelKeyAliases[key]}`).join(",");
3418
3511
  const registered = [...registeredProviders].sort().join(",");
3512
+ const origins = Object.keys(routeOrigins).sort().map((key) => `${key}=${routeOrigins[key]}`).join(",");
3419
3513
  return [
3420
3514
  subs,
3421
3515
  officials,
3422
3516
  aliases,
3423
3517
  String(searchEstimate),
3424
3518
  modelKeys,
3425
- registered
3519
+ registered,
3520
+ origins
3426
3521
  ].join("|");
3427
3522
  }
3428
3523
  /** 会话格式拒读(宿主 SessionFormatUnsupportedError):日志完好,仅本构建读不了。
@@ -3473,8 +3568,13 @@ function createUsageAggregator(persistence, options = {}) {
3473
3568
  return routes;
3474
3569
  };
3475
3570
  const searchEstimate = options.searchCallEstimateCny ?? .02;
3476
- const foldFingerprintOf = (registered) => configFingerprint(subscriptionMatcher, officialProviderIds, routeAliases, searchEstimate, options.modelKeyAliases, registered);
3477
- let foldFingerprint = foldFingerprintOf(/* @__PURE__ */ new Set());
3571
+ const routeOriginsOf = (routes) => {
3572
+ const origins = {};
3573
+ for (const [route, view] of Object.entries(routes)) if (view.baseURL !== void 0 && view.baseURL !== "") origins[route] = view.baseURL;
3574
+ return origins;
3575
+ };
3576
+ const foldFingerprintOf = (registered, routes) => configFingerprint(subscriptionMatcher, officialProviderIds, routeAliases, searchEstimate, options.modelKeyAliases, registered, routeOriginsOf(routes));
3577
+ let foldFingerprint = foldFingerprintOf(/* @__PURE__ */ new Set(), {});
3478
3578
  const FOLD_CHUNK_EVENTS = 8e3;
3479
3579
  /** 惰性失效(价目表时序):账本/缓存行若以「当时不可计价」的模型落盘,
3480
3580
  * 而价目表现在能计价了,该行必须重折——否则显示层按实时价算出的
@@ -3486,18 +3586,20 @@ function createUsageAggregator(persistence, options = {}) {
3486
3586
  * 价目表变化后未受影响行(无 unpriced 模型)依旧零读取复用。 */
3487
3587
  const foldNeedsReprice = (fold) => fold.unpricedModels.some((key) => isPriced(key));
3488
3588
  /** 把一份(全量或增量)折叠结果登记进 durable ledger;内容无变化时不落盘。 */
3489
- const recordLedger = (id, cwd, stamp, fold) => {
3589
+ const recordLedger = (id, cwd, stamp, fold, subOf) => {
3490
3590
  if (options.ledger === void 0) return;
3491
3591
  const entry = {
3492
3592
  id,
3493
3593
  ...cwd === void 0 ? {} : { cwd },
3594
+ ...subOf === void 0 ? {} : { subOf },
3494
3595
  ...stamp === null ? {} : { stamp },
3495
- foldVersion: 22,
3596
+ foldVersion: 23,
3496
3597
  fingerprint: foldFingerprint,
3598
+ foldedAt: Date.now(),
3497
3599
  fold: serializeFold(fold)
3498
3600
  };
3499
3601
  const row = ledger.get(id);
3500
- if (row === void 0 || row.stamp !== entry.stamp || row.cwd !== entry.cwd || row.foldVersion !== entry.foldVersion || row.fingerprint !== entry.fingerprint || stamp === null && JSON.stringify(row.fold) !== JSON.stringify(entry.fold) || stamp !== null && foldNeedsReprice(row.fold)) {
3602
+ if (row === void 0 || row.stamp !== entry.stamp || row.cwd !== entry.cwd || row.subOf !== entry.subOf || row.foldVersion !== entry.foldVersion || row.fingerprint !== entry.fingerprint || stamp === null && JSON.stringify(row.fold) !== JSON.stringify(entry.fold) || stamp !== null && foldNeedsReprice(row.fold)) {
3501
3603
  ledger.set(id, entry);
3502
3604
  ledgerNeedsSave = true;
3503
3605
  }
@@ -3557,8 +3659,8 @@ function createUsageAggregator(persistence, options = {}) {
3557
3659
  const now = Date.now();
3558
3660
  await ensureLedgerLoaded();
3559
3661
  const registeredProviders = options.resolveRegisteredProviders?.() ?? /* @__PURE__ */ new Set();
3560
- foldFingerprint = foldFingerprintOf(registeredProviders);
3561
3662
  const roundRoutes = routesOf(registeredProviders);
3663
+ foldFingerprint = foldFingerprintOf(registeredProviders, roundRoutes);
3562
3664
  let metas;
3563
3665
  try {
3564
3666
  metas = await persistence.list();
@@ -3589,12 +3691,14 @@ function createUsageAggregator(persistence, options = {}) {
3589
3691
  }
3590
3692
  try {
3591
3693
  const cwd = meta.cwd;
3694
+ const subOf = subParentOf(meta);
3592
3695
  const ledgerRow = ledger.get(id);
3593
- if (ledgerRow !== void 0 && ledgerRow.stamp !== void 0 && stamp !== null && ledgerRow.stamp === stamp && (ledgerRow.foldVersion ?? 1) === 22 && ledgerRow.fingerprint === foldFingerprint && !foldNeedsReprice(ledgerRow.fold)) {
3696
+ if (ledgerRow !== void 0 && ledgerRow.stamp !== void 0 && stamp !== null && ledgerRow.stamp === stamp && (ledgerRow.foldVersion ?? 1) === 23 && ledgerRow.fingerprint === foldFingerprint && !foldNeedsReprice(ledgerRow.fold)) {
3594
3697
  const fold = deserializedFoldOf(ledgerRow.fold);
3595
3698
  folds.push({
3596
3699
  id,
3597
3700
  ...cwd === void 0 ? {} : { cwd },
3701
+ ...subOf === void 0 ? {} : { subOf },
3598
3702
  fold
3599
3703
  });
3600
3704
  included.add(id);
@@ -3607,6 +3711,7 @@ function createUsageAggregator(persistence, options = {}) {
3607
3711
  folds.push({
3608
3712
  id,
3609
3713
  ...meta.cwd === void 0 ? {} : { cwd: meta.cwd },
3714
+ ...subOf === void 0 ? {} : { subOf },
3610
3715
  fold: hit.fold
3611
3716
  });
3612
3717
  included.add(id);
@@ -3635,10 +3740,11 @@ function createUsageAggregator(persistence, options = {}) {
3635
3740
  folds.push({
3636
3741
  id,
3637
3742
  ...cwd === void 0 ? {} : { cwd },
3743
+ ...subOf === void 0 ? {} : { subOf },
3638
3744
  fold: previous.fold
3639
3745
  });
3640
3746
  included.add(id);
3641
- recordLedger(id, cwd, stamp, previous.fold);
3747
+ recordLedger(id, cwd, stamp, previous.fold, subOf);
3642
3748
  continue;
3643
3749
  }
3644
3750
  const { events, inheritedEventCount } = await persistence.readFrom(meta.id, sessionLogOffsetOf(0));
@@ -3664,10 +3770,11 @@ function createUsageAggregator(persistence, options = {}) {
3664
3770
  folds.push({
3665
3771
  id,
3666
3772
  ...cwd === void 0 ? {} : { cwd },
3773
+ ...subOf === void 0 ? {} : { subOf },
3667
3774
  fold
3668
3775
  });
3669
3776
  included.add(id);
3670
- recordLedger(id, cwd, stamp, fold);
3777
+ recordLedger(id, cwd, stamp, fold, subOf);
3671
3778
  } catch (error) {
3672
3779
  skipped.push(id);
3673
3780
  const format = isFormatUnsupportedError(error);
@@ -3691,10 +3798,11 @@ function createUsageAggregator(persistence, options = {}) {
3691
3798
  for (const entry of ledger.values()) {
3692
3799
  if (included.has(entry.id)) continue;
3693
3800
  try {
3694
- const stale = (entry.foldVersion ?? 1) < 22 || entry.fingerprint !== foldFingerprint;
3801
+ const stale = (entry.foldVersion ?? 1) < 23 || entry.fingerprint !== foldFingerprint;
3695
3802
  folds.push({
3696
3803
  id: entry.id,
3697
3804
  ...entry.cwd === void 0 ? {} : { cwd: entry.cwd },
3805
+ ...entry.subOf === void 0 ? {} : { subOf: entry.subOf },
3698
3806
  ...stale ? { staleLedger: true } : {},
3699
3807
  fold: deserializedFoldOf(entry.fold)
3700
3808
  });
@@ -3731,7 +3839,7 @@ function createUsageAggregator(persistence, options = {}) {
3731
3839
  const perfHourModel = /* @__PURE__ */ new Map();
3732
3840
  const perfModelDigest = /* @__PURE__ */ new Map();
3733
3841
  const perfHourDigest = /* @__PURE__ */ new Map();
3734
- for (const { id: sessionId, cwd, fold, staleLedger } of folds) {
3842
+ for (const { id: sessionId, cwd, subOf, fold, staleLedger } of folds) {
3735
3843
  mergeUsageInto(total, fold.total);
3736
3844
  roles.userChars += fold.roles.userChars;
3737
3845
  roles.toolChars += fold.roles.toolChars;
@@ -3810,12 +3918,14 @@ function createUsageAggregator(persistence, options = {}) {
3810
3918
  id: sessionId,
3811
3919
  ...fold.title !== void 0 ? { title: fold.title } : {},
3812
3920
  ...cwd !== void 0 ? { cwd } : {},
3921
+ ...subOf !== void 0 ? { parentId: subOf } : {},
3813
3922
  ...staleLedger === true ? { stale: true } : {},
3814
3923
  calls: fold.total.calls,
3815
3924
  cost: fold.total.cost,
3816
3925
  lastActive: fold.lastActive
3817
3926
  });
3818
3927
  }
3928
+ foldSubagentFamilies(sessionRows);
3819
3929
  sessionRows.sort((a, b) => b.cost - a.cost || b.lastActive - a.lastActive);
3820
3930
  turnRows.sort((a, b) => b.startedAt - a.startedAt);
3821
3931
  const workspaces = [...workspaceMap.values()].sort((a, b) => b.cost - a.cost || b.lastActive - a.lastActive);
@@ -6933,15 +7043,21 @@ function createFileUsageLedgerStore(ledgerPath) {
6933
7043
  mode: 448
6934
7044
  });
6935
7045
  const commit = async () => {
7046
+ let existing;
7047
+ let existingDoc;
6936
7048
  try {
6937
- const existing = await readFile(ledgerPath, "utf8");
6938
- JSON.parse(existing);
6939
- await writeFileAtomic(`${ledgerPath}.bak`, existing, {
6940
- mode: 384,
6941
- dirMode: 448
6942
- });
6943
- } catch {}
6944
- await writeFileAtomic(ledgerPath, JSON.stringify(document), {
7049
+ existing = await readFile(ledgerPath, "utf8");
7050
+ const parsed = JSON.parse(existing);
7051
+ if (parsed.version === 1 && Array.isArray(parsed.sessions)) existingDoc = parsed;
7052
+ } catch {
7053
+ existing = void 0;
7054
+ }
7055
+ if (existing !== void 0) await writeFileAtomic(`${ledgerPath}.bak`, existing, {
7056
+ mode: 384,
7057
+ dirMode: 448
7058
+ });
7059
+ const merged = existingDoc === void 0 ? document : mergeUsageLedgerDocuments(document, existingDoc);
7060
+ await writeFileAtomic(ledgerPath, JSON.stringify(merged), {
6945
7061
  mode: 384,
6946
7062
  dirMode: 448
6947
7063
  });
@@ -7665,69 +7781,78 @@ function apply(ctx, config = {}) {
7665
7781
  at: 0,
7666
7782
  doc: { balances: [] }
7667
7783
  };
7784
+ let balanceInflight;
7785
+ const refreshBalance = async () => {
7786
+ const piProviders = await readPiAiProviders(ctx.settings);
7787
+ const providers = { ...piProviders };
7788
+ const deepseekKey = providers["deepseek"]?.apiKeyEnv;
7789
+ if (deepseekKey === void 0 || deepseekKey === "") providers["deepseek"] = {
7790
+ ...providers["deepseek"],
7791
+ apiKeyEnv: config.balanceApiKeyEnv ?? DEFAULT_BALANCE_API_KEY_ENV
7792
+ };
7793
+ const balances = await queryBalances(ctx, providers);
7794
+ const custom = await queryCustomBalances(ctx, config.customBalances ?? []);
7795
+ const declared = await queryDeclaredEndpoints(ctx, piProviders, config.declaredEndpoints ?? []);
7796
+ for (const row of [
7797
+ ...balances,
7798
+ ...custom,
7799
+ ...declared
7800
+ ]) if (row.error === "unauthorized") warnAuthOnce("balance", row.provider, row.displayName);
7801
+ const official = balances.find((row) => row.provider === "deepseek");
7802
+ let reconcile;
7803
+ const reconcilable = official !== void 0 && official.totalBalance !== void 0 && (official.currency === void 0 || official.currency.toUpperCase() === "CNY");
7804
+ if (official !== void 0 && reconcilable) {
7805
+ const now = Date.now();
7806
+ const today = dayStamp(now);
7807
+ let todayOfficialCost = 0;
7808
+ try {
7809
+ todayOfficialCost = (await aggregator.aggregate()).byDay[today]?.officialCost ?? 0;
7810
+ } catch {}
7811
+ const result = reconcileBalanceDelta(reconcileRef, official, todayOfficialCost, today, now);
7812
+ const refChanged = result.ref !== null && result.ref !== reconcileRef;
7813
+ reconcileRef = result.ref;
7814
+ if (result.event !== null && result.event.kind !== "flat") reconcile = {
7815
+ ...result.event,
7816
+ provider: official.displayName,
7817
+ ...official.currency === void 0 ? {} : { currency: official.currency }
7818
+ };
7819
+ if (refChanged) persistReconcileRef();
7820
+ }
7821
+ const doc = {
7822
+ balances: [
7823
+ ...balances,
7824
+ ...custom,
7825
+ ...declared
7826
+ ],
7827
+ ...reconcile === void 0 ? {} : { reconcile }
7828
+ };
7829
+ balanceCache = {
7830
+ at: Date.now(),
7831
+ doc
7832
+ };
7833
+ };
7834
+ const refreshBalanceOnce = () => {
7835
+ balanceInflight ??= refreshBalance().finally(() => {
7836
+ balanceInflight = void 0;
7837
+ });
7838
+ return balanceInflight;
7839
+ };
7668
7840
  ctx.effect(() => ctx.webServer.register({
7669
7841
  kind: "exact",
7670
7842
  path: "/api/billing/balance",
7671
7843
  handler: async (req, res) => {
7672
7844
  if (!guardLoopback(req, res, trustedHosts)) return;
7673
7845
  res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
7674
- const piProviders = await readPiAiProviders(ctx.settings);
7675
- const providers = { ...piProviders };
7676
- const deepseekKey = providers["deepseek"]?.apiKeyEnv;
7677
- if (deepseekKey === void 0 || deepseekKey === "") providers["deepseek"] = {
7678
- ...providers["deepseek"],
7679
- apiKeyEnv: config.balanceApiKeyEnv ?? DEFAULT_BALANCE_API_KEY_ENV
7680
- };
7681
7846
  if (Date.now() - balanceCache.at < BALANCE_CACHE_MS) {
7682
7847
  res.end(JSON.stringify(balanceCache.doc));
7683
7848
  return;
7684
7849
  }
7685
7850
  try {
7686
- const balances = await queryBalances(ctx, providers);
7687
- const custom = await queryCustomBalances(ctx, config.customBalances ?? []);
7688
- const declared = await queryDeclaredEndpoints(ctx, piProviders, config.declaredEndpoints ?? []);
7689
- for (const row of [
7690
- ...balances,
7691
- ...custom,
7692
- ...declared
7693
- ]) if (row.error === "unauthorized") warnAuthOnce("balance", row.provider, row.displayName);
7694
- const official = balances.find((row) => row.provider === "deepseek");
7695
- let reconcile;
7696
- const reconcilable = official !== void 0 && official.totalBalance !== void 0 && (official.currency === void 0 || official.currency.toUpperCase() === "CNY");
7697
- if (official !== void 0 && reconcilable) {
7698
- const now = Date.now();
7699
- const today = dayStamp(now);
7700
- let todayOfficialCost = 0;
7701
- try {
7702
- todayOfficialCost = (await aggregator.aggregate()).byDay[today]?.officialCost ?? 0;
7703
- } catch {}
7704
- const result = reconcileBalanceDelta(reconcileRef, official, todayOfficialCost, today, now);
7705
- const refChanged = result.ref !== null && result.ref !== reconcileRef;
7706
- reconcileRef = result.ref;
7707
- if (result.event !== null && result.event.kind !== "flat") reconcile = {
7708
- ...result.event,
7709
- provider: official.displayName,
7710
- ...official.currency === void 0 ? {} : { currency: official.currency }
7711
- };
7712
- if (refChanged) persistReconcileRef();
7713
- }
7714
- const doc = {
7715
- balances: [
7716
- ...balances,
7717
- ...custom,
7718
- ...declared
7719
- ],
7720
- ...reconcile === void 0 ? {} : { reconcile }
7721
- };
7722
- balanceCache = {
7723
- at: Date.now(),
7724
- doc
7725
- };
7726
- res.end(JSON.stringify(doc));
7851
+ await refreshBalanceOnce();
7727
7852
  } catch (error) {
7728
7853
  console.warn("[usage-billing] refreshBalances failed; serving cached balances:", error);
7729
- res.end(JSON.stringify(balanceCache.doc));
7730
7854
  }
7855
+ res.end(JSON.stringify(balanceCache.doc));
7731
7856
  }
7732
7857
  }), "usage-billing: balance route");
7733
7858
  ctx.effect(() => ctx.webServer.register({
@@ -7822,6 +7947,13 @@ function apply(ctx, config = {}) {
7822
7947
  quotas: rows
7823
7948
  };
7824
7949
  };
7950
+ let quotasInflight;
7951
+ const refreshQuotasOnce = () => {
7952
+ quotasInflight ??= refreshQuotas().finally(() => {
7953
+ quotasInflight = void 0;
7954
+ });
7955
+ return quotasInflight;
7956
+ };
7825
7957
  ctx.effect(() => ctx.webServer.register({
7826
7958
  kind: "exact",
7827
7959
  path: "/api/billing/subscriptions",
@@ -7829,7 +7961,7 @@ function apply(ctx, config = {}) {
7829
7961
  if (!guardLoopback(req, res, trustedHosts)) return;
7830
7962
  res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
7831
7963
  if (Date.now() - quotaCache.at >= SUBSCRIPTION_CACHE_MS) try {
7832
- await refreshQuotas();
7964
+ await refreshQuotasOnce();
7833
7965
  } catch (error) {
7834
7966
  console.warn("[usage-billing] refreshQuotas failed; serving cached quotas:", error);
7835
7967
  }
@@ -7858,6 +7990,13 @@ function apply(ctx, config = {}) {
7858
7990
  quotas: await queryRelayQuotas(ctx, routes)
7859
7991
  };
7860
7992
  };
7993
+ let relayInflight;
7994
+ const refreshRelayOnce = () => {
7995
+ relayInflight ??= refreshRelay().finally(() => {
7996
+ relayInflight = void 0;
7997
+ });
7998
+ return relayInflight;
7999
+ };
7861
8000
  ctx.effect(() => ctx.webServer.register({
7862
8001
  kind: "exact",
7863
8002
  path: "/api/billing/relay-quotas",
@@ -7865,7 +8004,7 @@ function apply(ctx, config = {}) {
7865
8004
  if (!guardLoopback(req, res, trustedHosts)) return;
7866
8005
  res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
7867
8006
  if (Date.now() - relayCache.at >= SUBSCRIPTION_CACHE_MS) try {
7868
- await refreshRelay();
8007
+ await refreshRelayOnce();
7869
8008
  } catch (error) {
7870
8009
  console.warn("[usage-billing] refreshRelay failed; serving cached relay quotas:", error);
7871
8010
  }
@@ -7880,6 +8019,7 @@ function apply(ctx, config = {}) {
7880
8019
  }));
7881
8020
  }
7882
8021
  }), "usage-billing: relay-quotas route");
8022
+ let statsBodyCache;
7883
8023
  ctx.effect(() => ctx.webServer.register({
7884
8024
  kind: "exact",
7885
8025
  path: "/api/billing/usage-stats",
@@ -7892,6 +8032,19 @@ function apply(ctx, config = {}) {
7892
8032
  ...config.monthlyBudget === void 0 ? {} : { budget: config.monthlyBudget },
7893
8033
  ...config.lowBalanceThreshold === void 0 ? {} : { lowBalanceThreshold: config.lowBalanceThreshold }
7894
8034
  });
8035
+ const statsBodyOf = (doc) => {
8036
+ const budget = config.monthlyBudget;
8037
+ const threshold = config.lowBalanceThreshold;
8038
+ if (statsBodyCache !== void 0 && statsBodyCache.doc === doc && statsBodyCache.budget === budget && statsBodyCache.threshold === threshold) return statsBodyCache.body;
8039
+ const body = JSON.stringify(decorate(doc));
8040
+ statsBodyCache = {
8041
+ doc,
8042
+ budget,
8043
+ threshold,
8044
+ body
8045
+ };
8046
+ return body;
8047
+ };
7895
8048
  const aggregating = aggregator.aggregate();
7896
8049
  aggregating.then((stats) => {
7897
8050
  persistSnapshot(decorate(stats));
@@ -7907,7 +8060,7 @@ function apply(ctx, config = {}) {
7907
8060
  console.error("[usage-billing] usage-stats aggregate failed, falling back to snapshot:", error);
7908
8061
  }
7909
8062
  if (fresh !== void 0) {
7910
- res.end(JSON.stringify(decorate(fresh)));
8063
+ res.end(statsBodyOf(fresh));
7911
8064
  return;
7912
8065
  }
7913
8066
  for (const candidate of candidates) {
@@ -389,6 +389,17 @@ export interface SessionUsageRow {
389
389
  title?: string;
390
390
  /** 会话创建时的工作目录(项目路径);未知时缺失。 */
391
391
  cwd?: string;
392
+ /**
393
+ * 直接父会话 id(仅子智能体会话携带):宿主 header 的 `origin: "subagent"`
394
+ * 会话经 parentSession 指向委派它的对话。fork/resume 的种子血缘
395
+ * (isSeeded)不满足子智能体门,不带本字段。旧宿主 header 无标记,
396
+ * 按顶层会话处理。
397
+ */
398
+ parentId?: string;
399
+ /** 全部后代子会话的费用合计(人民币元,多级委派整链递归);无后代或后代全免费时缺失。 */
400
+ subCost?: number;
401
+ /** 全部后代子会话的调用数;无后代时缺失。 */
402
+ subCalls?: number;
392
403
  calls: number;
393
404
  cost: number;
394
405
  /** 最后一个事件的时间戳(毫秒)。 */
@@ -396,6 +407,20 @@ export interface SessionUsageRow {
396
407
  /** 数据来自旧算法折叠的持久账本行(日志已删/不可读,无法重算);UI 据此标注置信度。 */
397
408
  stale?: boolean;
398
409
  }
410
+ /**
411
+ * 从会话 header 提取子智能体归并标记(直接父会话 id):`origin: "subagent"`
412
+ * (宿主的产品分类)或 `delegationDepth > 0`(持久化委派深度)任一命中即视为
413
+ * 子会话。子会话但 header 无 parentSession(异常数据)返回 undefined——无法
414
+ * 归位就按顶层会话处理,保守不标。导出供测试。
415
+ */
416
+ export declare function subParentOf(meta: SessionHeader): string | undefined;
417
+ /**
418
+ * 给会话行补后代合计(subCost/subCalls):按 parentId 建子索引,对每行递归
419
+ * 汇总全部后代子会话(多级委派整链计入根对话)。纯展示层归因——不改任何
420
+ * 总额桶(子会话费用本就在 total/byDay/byModel 里,行级只是拆出来给人看)。
421
+ * 环引用(异常数据)按 visited 跳过,不死循环。原地修改传入行。导出供测试。
422
+ */
423
+ export declare function foldSubagentFamilies(rows: SessionUsageRow[]): void;
399
424
  /** 每轮费用明细行:仪表盘「每轮费用」图的数据源。 */
400
425
  export interface TurnUsageRow {
401
426
  /** 会话 id(字符串形式):不同会话的轮次号相互独立,展示时需区分。 */
@@ -563,6 +588,12 @@ export interface SerializedSessionFold {
563
588
  export interface UsageLedgerSession {
564
589
  id: string;
565
590
  cwd?: string;
591
+ /**
592
+ * 子智能体归并标记(直接父会话 id):仅宿主 header 带 `origin: "subagent"`
593
+ * 的会话行携带。日志已删、只剩账本行时仍能归位到父对话;缺失 = 顶层会话
594
+ * (含 fork/resume 血缘)。可选字段,旧账本行缺失按顶层处理,无需迁移。
595
+ */
596
+ subOf?: string;
566
597
  /** Stable log stamp (mtime + size) when the persistence backend exposes it. */
567
598
  stamp?: string;
568
599
  /**
@@ -575,6 +606,12 @@ export interface UsageLedgerSession {
575
606
  * 更早写入的行(配置不参与复用判定);与当前指纹不一致的行不复用、全量重折。
576
607
  */
577
608
  fingerprint?: string;
609
+ /**
610
+ * 该行最近一次折叠落盘的时刻(epoch 毫秒)。多实例共享同一账本文件时,
611
+ * save 侧按它做行级合并(后折覆盖先折),防止整份覆盖式落盘丢掉另一实例
612
+ * 刚写入的行;缺失 = 合并语义引入前的旧行,视为最旧。
613
+ */
614
+ foldedAt?: number;
578
615
  fold: SerializedSessionFold;
579
616
  }
580
617
  /** On-disk durable usage ledger. Versioned independently from the dashboard document. */
@@ -585,6 +622,19 @@ export interface UsageLedgerDocument {
585
622
  /** 已应用的一次性配置迁移 id 列表(随文档落盘;缺省 = 尚未跑过任何迁移)。 */
586
623
  appliedMigrations?: string[];
587
624
  }
625
+ /**
626
+ * 多实例账本合并(自审计延期项:save 路径 last-writer-wins 丢写):同一
627
+ * DSH_HOME 下多个宿主实例各自持有内存账本,整份覆盖式落盘会让后写者抹掉
628
+ * 先写者刚保存的行。本进程要落的文档与磁盘现存文档按会话 id 行级合并:
629
+ * 双方都有的行按「谁更新谁胜」,仅磁盘有的行(他实例写入、本进程内存没有)
630
+ * 原样保留——账本行从不按 seen 剪除(见 aggregateOnce 尾部注释),合并不会
631
+ * 复活已删会话。合并只发生在落盘边界:跨实例的即时可见性仍靠重启重载,
632
+ * 此处保证的是磁盘上不丢数据(最终一致)。
633
+ * @param document - 本进程刚聚合出的完整账本文档。
634
+ * @param existing - 磁盘上现存(他实例可能写入过)的账本文档。
635
+ * @returns 合并后的文档(sessions 行序:incoming 在前、existing 独有行按原序追加)。
636
+ */
637
+ export declare function mergeUsageLedgerDocuments(document: UsageLedgerDocument, existing: UsageLedgerDocument): UsageLedgerDocument;
588
638
  /**
589
639
  * 折叠算法版本:归账语义变化时递增。v1 = 按 request/header 归账(稀疏 header 把
590
640
  * 两次 header 之间的用量串到上一个模型,订阅模型首当其冲,issue #14);v2 =
@@ -599,7 +649,7 @@ export interface UsageLedgerDocument {
599
649
  * 会话费用只剩最近一段,issue #29)。
600
650
  * 持久账本行据此区分新旧算法:日志已删/不可读而只能沿用旧行时,UI 标注置信度提示。
601
651
  */
602
- export declare const FOLD_VERSION = 22;
652
+ export declare const FOLD_VERSION = 23;
603
653
  /**
604
654
  * 一次性账本迁移:id 唯一,apply 在加载边界对原始文档执行,已应用过的跳过。
605
655
  * 未来账本/schema 字段变更(重命名、拆桶、语义调整)时,在此追加一条迁移并
@@ -695,7 +745,7 @@ export interface SessionUsageSummary {
695
745
  * 订阅豁免),历史账本行立即失效并全量重折,配置变更即时生效(issue #37)。
696
746
  * 判定函数无法序列化,统一记为 `fn`(任何函数形态互视为同一指纹)。
697
747
  */
698
- export declare function configFingerprint(subscriptionMatcher: SubscriptionMatcher, officialProviderIds: ReadonlySet<string> | undefined, routeAliases: Readonly<Record<string, string>>, searchEstimate: number, modelKeyAliases?: Readonly<Record<string, string>>, registeredProviders?: ReadonlySet<string>): string;
748
+ export declare function configFingerprint(subscriptionMatcher: SubscriptionMatcher, officialProviderIds: ReadonlySet<string> | undefined, routeAliases: Readonly<Record<string, string>>, searchEstimate: number, modelKeyAliases?: Readonly<Record<string, string>>, registeredProviders?: ReadonlySet<string>, routeOrigins?: Readonly<Record<string, string>>): string;
699
749
  /**
700
750
  * Create the incremental usage aggregator.
701
751
  * @param persistence - the session persistence service.
@@ -178,6 +178,15 @@ interface SessionBillingRow {
178
178
  id: string;
179
179
  title?: string;
180
180
  cwd?: string;
181
+ /**
182
+ * 直接父会话 id(仅子智能体会话携带):宿主把子智能体落成独立会话,
183
+ * header 的 origin: subagent + parentSession 指向委派它的对话。
184
+ */
185
+ parentId?: string;
186
+ /** 全部后代子会话费用合计(服务端递归算好);无后代时缺失。 */
187
+ subCost?: number;
188
+ /** 全部后代子会话调用数;无后代时缺失。 */
189
+ subCalls?: number;
181
190
  /** 数据来自旧算法折叠的持久账本行(原始日志已删,无法重算)。 */
182
191
  stale?: boolean;
183
192
  calls: number;
@@ -187,6 +196,8 @@ interface SessionBillingRow {
187
196
  mergedCount?: number;
188
197
  /** 合并组的段明细(展示层专用):一级行默认收起,展开后逐段显示。合并函数需 push,故非 readonly。 */
189
198
  children?: SessionBillingRow[];
199
+ /** 挂到本组下的子智能体行(展示层专用):与段明细并列展开,带子智能体徽标。 */
200
+ subs?: SessionBillingRow[];
190
201
  }
191
202
  /** Usage stats structure from `.dsh-usage-stats.json`. */
192
203
  export interface UsageStats {