@kenz1117/dsh-ui-usage-billing 1.5.0 → 1.6.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.
Files changed (46) hide show
  1. package/README.md +2 -1
  2. package/lib/client.js +5 -5
  3. package/lib/index.js +830 -460
  4. package/lib/types/aggregate.d.ts +31 -0
  5. package/lib/types/client/TokenPanel.d.ts +1 -1
  6. package/lib/types/client/UsageBilling.d.ts +7 -336
  7. package/lib/types/client/api/balance.d.ts +24 -0
  8. package/lib/types/client/api/pricing.d.ts +26 -0
  9. package/lib/types/client/api/relay.d.ts +13 -0
  10. package/lib/types/client/api/subscriptions.d.ts +13 -0
  11. package/lib/types/client/api/usage-stats.d.ts +11 -0
  12. package/lib/types/client/api/usage-tool.d.ts +16 -0
  13. package/lib/types/client/budget-store.d.ts +6 -0
  14. package/lib/types/client/constants.d.ts +28 -0
  15. package/lib/types/client/domain/forecast.d.ts +24 -0
  16. package/lib/types/client/domain/host-version.d.ts +18 -0
  17. package/lib/types/client/domain/icons.d.ts +9 -0
  18. package/lib/types/client/domain/kpi.d.ts +34 -0
  19. package/lib/types/client/domain/model-budget.d.ts +13 -0
  20. package/lib/types/client/domain/notify.d.ts +14 -0
  21. package/lib/types/client/domain/provider-aliases.d.ts +28 -0
  22. package/lib/types/client/domain/provider-merge.d.ts +26 -0
  23. package/lib/types/client/domain/recharge.d.ts +15 -0
  24. package/lib/types/client/domain/recost.d.ts +10 -0
  25. package/lib/types/client/domain/session-viewer.d.ts +13 -0
  26. package/lib/types/client/domain/streak.d.ts +25 -0
  27. package/lib/types/client/domain/subscription-vendors.d.ts +11 -0
  28. package/lib/types/client/domain/types.d.ts +312 -0
  29. package/lib/types/client/domain/week.d.ts +35 -0
  30. package/lib/types/client/export.d.ts +7 -1
  31. package/lib/types/client/live-cost.d.ts +13 -3
  32. package/lib/types/client/locales.d.ts +1 -1
  33. package/lib/types/client/settings-search.d.ts +12 -0
  34. package/lib/types/client/usage-billing-settings.d.ts +3 -1
  35. package/lib/types/config.d.ts +79 -0
  36. package/lib/types/guard.d.ts +49 -0
  37. package/lib/types/index.d.ts +3 -100
  38. package/lib/types/routes/balance.d.ts +37 -0
  39. package/lib/types/routes/notify-claim.d.ts +24 -0
  40. package/lib/types/routes/pricing.d.ts +26 -0
  41. package/lib/types/routes/relay-quotas.d.ts +27 -0
  42. package/lib/types/routes/subscriptions.d.ts +28 -0
  43. package/lib/types/routes/usage-stats.d.ts +44 -0
  44. package/lib/types/routes/usage-tool.d.ts +27 -0
  45. package/lib/types/silent.d.ts +21 -0
  46. package/package.json +40 -34
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
  /** 全零摘要(合并的恒等元)。 */
@@ -3533,11 +3586,12 @@ function createUsageAggregator(persistence, options = {}) {
3533
3586
  * 价目表变化后未受影响行(无 unpriced 模型)依旧零读取复用。 */
3534
3587
  const foldNeedsReprice = (fold) => fold.unpricedModels.some((key) => isPriced(key));
3535
3588
  /** 把一份(全量或增量)折叠结果登记进 durable ledger;内容无变化时不落盘。 */
3536
- const recordLedger = (id, cwd, stamp, fold) => {
3589
+ const recordLedger = (id, cwd, stamp, fold, subOf) => {
3537
3590
  if (options.ledger === void 0) return;
3538
3591
  const entry = {
3539
3592
  id,
3540
3593
  ...cwd === void 0 ? {} : { cwd },
3594
+ ...subOf === void 0 ? {} : { subOf },
3541
3595
  ...stamp === null ? {} : { stamp },
3542
3596
  foldVersion: 23,
3543
3597
  fingerprint: foldFingerprint,
@@ -3545,7 +3599,7 @@ function createUsageAggregator(persistence, options = {}) {
3545
3599
  fold: serializeFold(fold)
3546
3600
  };
3547
3601
  const row = ledger.get(id);
3548
- 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)) {
3549
3603
  ledger.set(id, entry);
3550
3604
  ledgerNeedsSave = true;
3551
3605
  }
@@ -3637,12 +3691,14 @@ function createUsageAggregator(persistence, options = {}) {
3637
3691
  }
3638
3692
  try {
3639
3693
  const cwd = meta.cwd;
3694
+ const subOf = subParentOf(meta);
3640
3695
  const ledgerRow = ledger.get(id);
3641
3696
  if (ledgerRow !== void 0 && ledgerRow.stamp !== void 0 && stamp !== null && ledgerRow.stamp === stamp && (ledgerRow.foldVersion ?? 1) === 23 && ledgerRow.fingerprint === foldFingerprint && !foldNeedsReprice(ledgerRow.fold)) {
3642
3697
  const fold = deserializedFoldOf(ledgerRow.fold);
3643
3698
  folds.push({
3644
3699
  id,
3645
3700
  ...cwd === void 0 ? {} : { cwd },
3701
+ ...subOf === void 0 ? {} : { subOf },
3646
3702
  fold
3647
3703
  });
3648
3704
  included.add(id);
@@ -3655,6 +3711,7 @@ function createUsageAggregator(persistence, options = {}) {
3655
3711
  folds.push({
3656
3712
  id,
3657
3713
  ...meta.cwd === void 0 ? {} : { cwd: meta.cwd },
3714
+ ...subOf === void 0 ? {} : { subOf },
3658
3715
  fold: hit.fold
3659
3716
  });
3660
3717
  included.add(id);
@@ -3683,10 +3740,11 @@ function createUsageAggregator(persistence, options = {}) {
3683
3740
  folds.push({
3684
3741
  id,
3685
3742
  ...cwd === void 0 ? {} : { cwd },
3743
+ ...subOf === void 0 ? {} : { subOf },
3686
3744
  fold: previous.fold
3687
3745
  });
3688
3746
  included.add(id);
3689
- recordLedger(id, cwd, stamp, previous.fold);
3747
+ recordLedger(id, cwd, stamp, previous.fold, subOf);
3690
3748
  continue;
3691
3749
  }
3692
3750
  const { events, inheritedEventCount } = await persistence.readFrom(meta.id, sessionLogOffsetOf(0));
@@ -3712,10 +3770,11 @@ function createUsageAggregator(persistence, options = {}) {
3712
3770
  folds.push({
3713
3771
  id,
3714
3772
  ...cwd === void 0 ? {} : { cwd },
3773
+ ...subOf === void 0 ? {} : { subOf },
3715
3774
  fold
3716
3775
  });
3717
3776
  included.add(id);
3718
- recordLedger(id, cwd, stamp, fold);
3777
+ recordLedger(id, cwd, stamp, fold, subOf);
3719
3778
  } catch (error) {
3720
3779
  skipped.push(id);
3721
3780
  const format = isFormatUnsupportedError(error);
@@ -3743,6 +3802,7 @@ function createUsageAggregator(persistence, options = {}) {
3743
3802
  folds.push({
3744
3803
  id: entry.id,
3745
3804
  ...entry.cwd === void 0 ? {} : { cwd: entry.cwd },
3805
+ ...entry.subOf === void 0 ? {} : { subOf: entry.subOf },
3746
3806
  ...stale ? { staleLedger: true } : {},
3747
3807
  fold: deserializedFoldOf(entry.fold)
3748
3808
  });
@@ -3779,7 +3839,7 @@ function createUsageAggregator(persistence, options = {}) {
3779
3839
  const perfHourModel = /* @__PURE__ */ new Map();
3780
3840
  const perfModelDigest = /* @__PURE__ */ new Map();
3781
3841
  const perfHourDigest = /* @__PURE__ */ new Map();
3782
- for (const { id: sessionId, cwd, fold, staleLedger } of folds) {
3842
+ for (const { id: sessionId, cwd, subOf, fold, staleLedger } of folds) {
3783
3843
  mergeUsageInto(total, fold.total);
3784
3844
  roles.userChars += fold.roles.userChars;
3785
3845
  roles.toolChars += fold.roles.toolChars;
@@ -3858,12 +3918,14 @@ function createUsageAggregator(persistence, options = {}) {
3858
3918
  id: sessionId,
3859
3919
  ...fold.title !== void 0 ? { title: fold.title } : {},
3860
3920
  ...cwd !== void 0 ? { cwd } : {},
3921
+ ...subOf !== void 0 ? { parentId: subOf } : {},
3861
3922
  ...staleLedger === true ? { stale: true } : {},
3862
3923
  calls: fold.total.calls,
3863
3924
  cost: fold.total.cost,
3864
3925
  lastActive: fold.lastActive
3865
3926
  });
3866
3927
  }
3928
+ foldSubagentFamilies(sessionRows);
3867
3929
  sessionRows.sort((a, b) => b.cost - a.cost || b.lastActive - a.lastActive);
3868
3930
  turnRows.sort((a, b) => b.startedAt - a.startedAt);
3869
3931
  const workspaces = [...workspaceMap.values()].sort((a, b) => b.cost - a.cost || b.lastActive - a.lastActive);
@@ -5095,7 +5157,11 @@ async function fetchDeclaredBody(url, raw, apiKey) {
5095
5157
  if (!response.ok) throw new HttpStatusError("http-error", response.status);
5096
5158
  const text = await response.text();
5097
5159
  if (text.length > MAX_BODY_BYTES) throw new HttpStatusError("body-too-large", 413);
5098
- return JSON.parse(text);
5160
+ try {
5161
+ return JSON.parse(text);
5162
+ } catch (cause) {
5163
+ throw new HttpStatusError("parse-error", 0);
5164
+ }
5099
5165
  } finally {
5100
5166
  clearTimeout(timer);
5101
5167
  }
@@ -5602,6 +5668,38 @@ async function fetchLivePricing() {
5602
5668
  };
5603
5669
  }
5604
5670
  //#endregion
5671
+ //#region lib/types/silent.js
5672
+ /**
5673
+ * 静默吞错的结构化记录(S-6):「缺失=未配置」类 catch 的统一出口。
5674
+ *
5675
+ * 本仓 78 处 server 端 catch 多数是有意的静默(文件缺失=首次运行、凭据未
5676
+ * 配置=跳过该 provider),此前唯一载体是注释——用户报「X 不工作」时只能靠
5677
+ * 猜。本工具把这些静默升级为**节流的结构化日志**:同 reason 5 分钟最多一条
5678
+ * info,宿主日志可 grep `swallowed:`,诊断时看得到「哪个环节吞了什么」,
5679
+ * 而又不把已知常态刷成告警噪音。
5680
+ *
5681
+ * 不取代 console.warn——已知异常需要用户行动的(账本损坏、刷新失败)继续
5682
+ * warn;这里只收「确认无害的静默」。节流状态导出重置仅供测试。
5683
+ */
5684
+ const THROTTLE_MS = 3e5;
5685
+ const lastLoggedAt = /* @__PURE__ */ new Map();
5686
+ /**
5687
+ * 记录一次有意的静默吞错。
5688
+ * @param reason - 稳定的短横线 reason 键(如 `reconcile-ref-missing`),节流以此为粒度。
5689
+ * @param error - 可选的原始异常(Error 打 message,其他值原样)。
5690
+ */
5691
+ function swallowed(reason, error) {
5692
+ const now = Date.now();
5693
+ const last = lastLoggedAt.get(reason);
5694
+ if (last !== void 0 && now - last < THROTTLE_MS) return;
5695
+ lastLoggedAt.set(reason, now);
5696
+ if (error === void 0) {
5697
+ console.info(`[usage-billing] swallowed: ${reason}`);
5698
+ return;
5699
+ }
5700
+ console.info(`[usage-billing] swallowed: ${reason}`, error instanceof Error ? error.message : error);
5701
+ }
5702
+ //#endregion
5605
5703
  //#region lib/types/subscriptions.js
5606
5704
  /**
5607
5705
  * Subscription-plan quota polling (node half): how much of each coding/token
@@ -6001,7 +6099,9 @@ async function collectZai(keys, config, timeoutMs) {
6001
6099
  try {
6002
6100
  subscription = await requestJson(`${host}/api/biz/subscription/list`, init, timeoutMs);
6003
6101
  if (zaiEnvelopeStatus(subscription) !== void 0) subscription = null;
6004
- } catch {}
6102
+ } catch {
6103
+ swallowed("zai-plan-metadata-missing");
6104
+ }
6005
6105
  const parsed = parseZai(quota, subscription);
6006
6106
  return {
6007
6107
  provider: config.provider,
@@ -6769,36 +6869,17 @@ async function queryRelayQuotas(ctx, routes) {
6769
6869
  return await Promise.all(routes.map(async (route) => queryRelayQuota(ctx, route)));
6770
6870
  }
6771
6871
  //#endregion
6772
- //#region lib/types/client/usage-billing-settings.js
6872
+ //#region lib/types/guard.js
6773
6873
  /**
6774
- * usage-stats 工具开关的共享设置契约(node 与 client 两端共用)。
6874
+ * 共享 loopback 守卫(v1.5.2 抽自 src/index.ts)。
6775
6875
  *
6776
- * 旧宿主(≤0.1.6)把该开关存设置命名空间;宿主 0.1.7+ 起注册面移除,改存插件
6777
- * 条目 config 的 volatile 字段(Config schema 投影),写回经 settings.update,
6778
- * 提交后引用原位更新、`usage_stats` 工具即时注册/注销(无需重载应用)。
6779
- * client 半区在「设置」Tab 渲染开关并经 /api/billing/usage-tool 写入,node 半区
6780
- * 按宿主世代选择通道。缺省的默认行为是关闭——避免该工具默认占用模型每次请求
6781
- * 的上下文(coding 场景通常在仪表盘看用量)。
6782
- */
6783
- /** 设置命名空间 id(小写 kebab-case)。 */
6784
- const BILLING_SETTINGS_NAMESPACE = "ui-usage-billing";
6785
- /** 该命名空间下用户可编辑的字段名。 */
6786
- const ENABLE_USAGE_STATS_TOOL_FIELD = "enableUsageStatsTool";
6787
- //#endregion
6788
- //#region lib/types/index.js
6789
- /**
6790
- * Usage billing surface plugin, node half.
6876
+ * 端点只供本机浏览器用,局域网/远端请求一律拒绝,避免面板数据(含中转站
6877
+ * origin 与余额)外泄。三层防御:peer socket(最可信)+ Host 头精确字面量
6878
+ * (防 `127.0.0.1.evil.com` 这类 DNS rebinding 域名)+ trustedHosts 白名单
6879
+ * (反向代理场景的精确主机名放行)。Origin 头独立校验(写操作用)。
6791
6880
  *
6792
- * Serves `/api/billing/usage-stats`: real usage aggregated from every
6793
- * persisted session log (see `aggregate.ts`) — the browser dashboard reads it
6794
- * instead of showing an empty snapshot. Aggregation is stale-while-revalidate:
6795
- * requests get a short wait budget and fall back to the freshest persisted
6796
- * snapshot while a slow full fold keeps running in the background (a heavy
6797
- * user's first fold takes minutes and must not starve the single-process
6798
- * host's RPC). When `sessionPersistence` is unavailable (or aggregation
6799
- * fails), the configured `statsPath` / `DSH_USAGE_STATS` / conventional JSON
6800
- * file is served as a fallback, and a missing file answers `{ error }` so the
6801
- * dashboard shows zeros, never fabricated samples.
6881
+ * 设计要点:精确字面量匹配(`startsWith('127.')` 会被 `127.0.0.1.evil.com`
6882
+ * 穿透,必须正则)。IPv6 字面量带方括号(`[::1]:3080`)单独剥离。
6802
6883
  */
6803
6884
  /** Peer 地址是否为回环(本地)。回环防护:本插件的端点只供本机浏览器用,
6804
6885
  * 局域网/远端请求一律拒绝,避免面板数据(含中转站 origin 与余额)外泄。 */
@@ -6834,6 +6915,9 @@ function normalizeTrustedHosts(hosts) {
6834
6915
  const names = hosts.filter((host) => typeof host === "string").map((host) => hostNameOf(host)).filter((host) => host !== "");
6835
6916
  return new Set(names);
6836
6917
  }
6918
+ /** 校验 Host 头是本机回环(精确 127.0.0.0/8 / ::1 / localhost 或空,供 curl 不带 Host 的极简请求)。
6919
+ * 拒绝 `127.0.0.1.attacker.com` 这类以 `127.` 开头但解析到外部的 DNS rebinding 域名:
6920
+ * 只用 `startsWith('127.')` 会被它穿透,必须精确匹配回环 IP 的字面量。 */
6837
6921
  function isLoopbackHost(req, trustedHosts = /* @__PURE__ */ new Set()) {
6838
6922
  const host = req.headers.host;
6839
6923
  if (host === void 0 || host === "") return true;
@@ -6874,176 +6958,659 @@ function guardLoopback(req, res, trustedHosts = /* @__PURE__ */ new Set()) {
6874
6958
  }
6875
6959
  return true;
6876
6960
  }
6961
+ //#endregion
6962
+ //#region lib/types/routes/notify-claim.js
6877
6963
  /**
6878
- * 设置命名空间校验(上游 alpha.1 `settingsNamespace` 的本地化,issue #28):
6879
- * alpha.2 起该函数不再从 `@deepseek-ai/dsh-settings` 导出,但上游内部保留
6880
- * 同一 pattern——本地按同一规则校验,预览宿主 API 漂移不再影响插件装载。
6881
- */
6882
- const SETTINGS_NAMESPACE_PATTERN = /^[a-z][a-z0-9-]*$/;
6883
- /** 校验并透过合法命名空间 id;非法值 fail-loud(与上游原行为一致)。 */
6884
- function validateSettingsNamespace(value) {
6885
- if (!SETTINGS_NAMESPACE_PATTERN.test(value)) throw new TypeError(`settings namespace "${value}" must match ${String(SETTINGS_NAMESPACE_PATTERN)}`);
6886
- return value;
6887
- }
6888
- /** usage_stats 工具开关的设置命名空间 id(下端与 node 共用同一常量)。 */
6889
- const usageBillingSettingsNs = validateSettingsNamespace(BILLING_SETTINGS_NAMESPACE);
6890
- /** 该命名空间的 wire schema:`enableUsageStatsTool` 布尔,默认关闭(issue 诉求)。 */
6891
- const UsageBillingSettingsSchema = z.object({ [ENABLE_USAGE_STATS_TOOL_FIELD]: z.boolean().default(false) });
6892
- /**
6893
- * Loader 挂载时对条目 config 做校验的 Config 声明:`enableUsageStatsTool` 标记
6894
- * volatile,宿主 0.1.7+ 设置系统据此把它投影为可实时编辑的开关(describe/update)。
6895
- * schemastery 非严格 object 透传未声明键,其余 config 字段不受影响;未导出 Config
6896
- * 的旧宿主直接把原始 config 交给 apply,普通布尔形态由读取端兼容。
6897
- */
6898
- const Config = z.object({ [ENABLE_USAGE_STATS_TOOL_FIELD]: z.boolean().default(false).volatile() });
6899
- /** 实时定价的后台刷新间隔(毫秒):汇率/模型价低频变化,6 小时一次足够。 */
6900
- const PRICING_REFRESH_INTERVAL_MS = 216e5;
6901
- /** 历史回放预热的延迟:等宿主启动高峰(插件加载 / 路由挂载)过去再全量折叠,
6902
- * 避免抢启动期的 CPU;纯延迟不阻塞任何请求,面板提前打开也只会提前聚合。 */
6903
- const WARMUP_DELAY_MS = 3e3;
6904
- /** 订阅套餐额度缓存时长(毫秒):上游配额 API 低频变化,5 分钟足够。 */
6905
- const SUBSCRIPTION_CACHE_MS = 3e5;
6906
- const BALANCE_CACHE_MS = 3e5;
6907
- /** DeepSeek 余额查询的默认凭据引用(与 llm-deepseek 的默认引用一致)。 */
6908
- const DEFAULT_BALANCE_API_KEY_ENV = "DEEPSEEK_API_KEY";
6909
- /**
6910
- * 本插件版本号:从包自身的 package.json 读取(单一来源),随 usage-stats
6911
- * 下发,供「设置 → 插件信息卡」展示。发布版 lib/index.js 相对包根解析。
6912
- */
6913
- const PACKAGE_VERSION = createRequire(import.meta.url)("../package.json").version ?? "0.0.0";
6914
- /** 统计快照的落盘节流(毫秒):快照只服务聚合失败回退与重启首屏(展示级
6915
- * 新鲜度即可),降到 5 分钟一次——原子写含上一版备份是两次全量 JSON 落盘,
6916
- * 重度用户快照上百 KB,跟随前端轮询 30 秒写一次纯属写放大。 */
6917
- const SNAPSHOT_INTERVAL_MS = 3e5;
6918
- /** usage-stats 响应的等待预算(毫秒):聚合在此预算内完成就回实时数据,
6919
- * 超预算立即回最近快照(stale-while-revalidate),后台折叠继续跑。
6920
- * 轻量用户暖缓存下的聚合是毫秒级,预算几乎不会触发。 */
6921
- const RESPONSE_BUDGET_MS = 1500;
6922
- /** 鉴权失败告警冷却(毫秒):同一 provider 在窗口内只提示一次,避免 30 秒轮询刷屏。 */
6923
- const AUTH_WARN_COOLDOWN_MS = 18e5;
6924
- /**
6925
- * 鉴权失败分类告警(P1-5):余额 / 订阅查询返回 unauthorized 时,按
6926
- * `source:provider` 去重并冷却告警,提示检查 llm-pi-ai 里该 provider 的 apiKeyEnv。
6927
- */
6928
- const authWarnedAt = /* @__PURE__ */ new Map();
6929
- function warnAuthOnce(source, provider, displayName) {
6930
- const key = `${source}:${provider}`;
6931
- const now = Date.now();
6932
- const last = authWarnedAt.get(key);
6933
- if (last !== void 0 && now - last < AUTH_WARN_COOLDOWN_MS) return;
6934
- authWarnedAt.set(key, now);
6935
- console.warn(`[usage-billing] ${displayName}(${provider})鉴权失败:请检查 llm-pi-ai 设置中该 provider 的凭据环境变量是否正确/有效。`);
6936
- }
6937
- /** 锁等待上限:账本提交是毫秒级 read-render-commit,2s 已数倍于正常持锁时长。 */
6938
- const LEDGER_LOCK_WAIT_MS = 2e3;
6939
- /**
6940
- * 锁年龄超过该值判为崩溃残留的孤儿锁。正常持锁是毫秒级文件提交,没有活跃
6941
- * writer 会合法持有 60s;库层刻意不做年龄判定(它无法假设调用方的操作时长),
6942
- * 本调用方知道自己操作的上界,可以给出有依据的清理阈值。
6943
- */
6944
- const STALE_LEDGER_LOCK_MS = 6e4;
6945
- /**
6946
- * 锁等待超时后的孤儿锁自愈(issue #48):仅当锁文件年龄超过
6947
- * STALE_LEDGER_LOCK_MS 才清理,避免误删慢 writer 的活跃锁。
6948
- * @param ledgerPath - 账本文件路径(锁为其 `.lock` 兄弟文件)。
6949
- * @returns 是否清理了孤儿锁;false = 锁已消失或太新鲜,调用方上抛原错误。
6950
- */
6951
- async function reclaimStaleLedgerLock(ledgerPath) {
6952
- let ageMs;
6953
- try {
6954
- ageMs = Date.now() - (await lstat(`${ledgerPath}.lock`)).mtimeMs;
6955
- } catch {
6956
- return false;
6957
- }
6958
- if (ageMs < STALE_LEDGER_LOCK_MS) return false;
6959
- console.warn(`[usage-billing] 账本锁已存在 ${Math.round(ageMs / 1e3)}s(崩溃残留),清理后重试:${ledgerPath}.lock`);
6960
- await rm(`${ledgerPath}.lock`, { force: true });
6961
- return true;
6962
- }
6963
- /**
6964
- * Create the atomic file-backed durable-ledger store. The previous complete file
6965
- * is retained as `.bak`; a malformed/missing main file falls back to that backup.
6966
- */
6967
- function createFileUsageLedgerStore(ledgerPath) {
6968
- return {
6969
- async load() {
6970
- for (const path of [ledgerPath, `${ledgerPath}.bak`]) try {
6971
- const parsed = JSON.parse(await readFile(path, "utf8"));
6972
- if (parsed !== null && typeof parsed === "object") {
6973
- const candidate = parsed;
6974
- if (candidate.version === 1 && Array.isArray(candidate.sessions)) return parsed;
6964
+ * 路由:POST /api/billing/notify-claim
6965
+ *
6966
+ * 跨实例提醒认领(issue #44):多实例(CLI + 桌面等)合法共存,各自轮询
6967
+ * 会重复弹通知。两级去重——进程内 Set 挡同进程的并发/重渲染(trigger 与仪表盘
6968
+ * 是两个组件实例,effect 同周期并发时本地 store 标记读到的还是旧值);跨进程走
6969
+ * 认领端点(先到先得),另一实例刚发过则跳过。端点不可达(旧版本/网络故障)
6970
+ * 时放行,退化为原有 per-客户端去重。通知构造失败(平台限制)静默。
6971
+ *
6972
+ * 路由文件职责:handler + 模块级 `notifyClaims` 单例(与 `loadNotifications`
6973
+ * 共享内存状态——先到先得认领)。文件持久化(`writeFileAtomic`)由 index.ts 装配
6974
+ * 器通过 `claimsPath` 注入;handler 拿到路径后自行管理读写。
6975
+ */
6976
+ /** 模块级认领表:loaded 表示是否已从磁盘反序列化;keys 是先到先得序。 */
6977
+ const notifyClaims = {
6978
+ loaded: false,
6979
+ keys: []
6980
+ };
6981
+ /** 注册路由到 cordis webServer effect;handler 闭包外只读 deps。 */
6982
+ function registerNotifyClaimRoute(ctx, deps) {
6983
+ ctx.effect(() => ctx.webServer.register({
6984
+ kind: "exact",
6985
+ path: "/api/billing/notify-claim",
6986
+ handler: async (req, res) => {
6987
+ if (!guardLoopback(req, res, deps.trustedHosts)) return;
6988
+ if (req.method !== "POST") {
6989
+ res.writeHead(405, { "content-type": "application/json; charset=utf-8" });
6990
+ res.end(JSON.stringify({ error: "method not allowed" }));
6991
+ return;
6992
+ }
6993
+ if (!isLoopbackOrigin(req.headers.origin) || !(req.headers["content-type"] ?? "").toLowerCase().includes("application/json")) {
6994
+ res.writeHead(403, { "content-type": "application/json; charset=utf-8" });
6995
+ res.end(JSON.stringify({ error: "forbidden: loopback only" }));
6996
+ return;
6997
+ }
6998
+ try {
6999
+ let body = "";
7000
+ for await (const chunk of req) {
7001
+ body += String(chunk);
7002
+ if (body.length > 2048) {
7003
+ res.writeHead(413, { "content-type": "application/json; charset=utf-8" });
7004
+ res.end(JSON.stringify({ error: "body too large" }));
7005
+ return;
7006
+ }
6975
7007
  }
6976
- } catch {}
6977
- },
6978
- async save(document) {
6979
- await mkdir(dirname(ledgerPath), {
6980
- recursive: true,
6981
- mode: 448
6982
- });
6983
- const commit = async () => {
6984
- let existing;
6985
- let existingDoc;
6986
- try {
6987
- existing = await readFile(ledgerPath, "utf8");
6988
- const parsed = JSON.parse(existing);
6989
- if (parsed.version === 1 && Array.isArray(parsed.sessions)) existingDoc = parsed;
6990
- } catch {
6991
- existing = void 0;
7008
+ const parsed = JSON.parse(body === "" ? "{}" : body);
7009
+ if (typeof parsed.key !== "string" || parsed.key === "" || parsed.key.length > 200) {
7010
+ res.writeHead(400, { "content-type": "application/json; charset=utf-8" });
7011
+ res.end(JSON.stringify({ error: "invalid key" }));
7012
+ return;
6992
7013
  }
6993
- if (existing !== void 0) await writeFileAtomic(`${ledgerPath}.bak`, existing, {
6994
- mode: 384,
6995
- dirMode: 448
6996
- });
6997
- const merged = existingDoc === void 0 ? document : mergeUsageLedgerDocuments(document, existingDoc);
6998
- await writeFileAtomic(ledgerPath, JSON.stringify(merged), {
7014
+ if (!notifyClaims.loaded) {
7015
+ notifyClaims.loaded = true;
7016
+ try {
7017
+ const stored = JSON.parse(await readFile(deps.claimsPath, "utf8"));
7018
+ if (Array.isArray(stored.keys)) notifyClaims.keys = stored.keys.filter((k) => typeof k === "string");
7019
+ } catch {
7020
+ swallowed("notify-claims-file-unreadable");
7021
+ }
7022
+ }
7023
+ if (notifyClaims.keys.includes(parsed.key)) {
7024
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
7025
+ res.end(JSON.stringify({ claimed: false }));
7026
+ return;
7027
+ }
7028
+ notifyClaims.keys.push(parsed.key);
7029
+ if (notifyClaims.keys.length > 400) notifyClaims.keys = notifyClaims.keys.slice(-200);
7030
+ writeFileAtomic(deps.claimsPath, JSON.stringify({ keys: notifyClaims.keys }), {
6999
7031
  mode: 384,
7000
7032
  dirMode: 448
7001
- });
7002
- };
7003
- try {
7004
- await withFileLock(ledgerPath, commit, { waitMs: LEDGER_LOCK_WAIT_MS });
7005
- } catch (error) {
7006
- if (!(error instanceof Error) || !error.message.includes("timed out waiting for the writer lock")) throw error;
7007
- if (!await reclaimStaleLedgerLock(ledgerPath)) throw error;
7008
- await withFileLock(ledgerPath, commit, { waitMs: LEDGER_LOCK_WAIT_MS });
7033
+ }).catch(() => {});
7034
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
7035
+ res.end(JSON.stringify({ claimed: true }));
7036
+ } catch {
7037
+ res.writeHead(400, { "content-type": "application/json; charset=utf-8" });
7038
+ res.end(JSON.stringify({ error: "invalid" }));
7009
7039
  }
7010
7040
  }
7011
- };
7041
+ }), "usage-billing: notify-claim route");
7012
7042
  }
7013
- /** Required services: the web server, the persisted session log store, and user settings. */
7014
- const inject = [
7015
- "webServer",
7016
- "sessionPersistence",
7017
- "credentials",
7018
- "settings"
7019
- ];
7043
+ //#endregion
7044
+ //#region lib/types/routes/pricing.js
7020
7045
  /**
7021
- * 订阅 provider id(llm-pi-ai 设置键)→ billing 适配器 key 的映射。
7022
- * 复用 dsh 既有的 llm-pi-ai provider 配置(apiKeyEnv 引用),不引入新配置面。
7046
+ * 路由:GET /api/billing/pricing + POST /api/billing/pricing/refresh
7047
+ *
7048
+ * 实时定价(汇率 + OpenRouter 模型价 + models.dev 目录外补充)的 GET/POST 同
7049
+ * concern,合一个文件:
7050
+ * - GET 由前端 30s 轮询 + 弹窗初始拉取触发,返回完整价目表文档(live + 内置
7051
+ * 目录 + 别名表 + syncedAt)。
7052
+ * - POST 是费率条「立即同步」按钮触发,强制服务端重新拉取一次;写操作额外
7053
+ * 校验 Origin 与 Content-Type。
7054
+ *
7055
+ * 路由文件只关心 HTTP 层:响应体序列化与缓存(pricingBodyCache)由 index.ts
7056
+ * 装配器通过 `body()` 闭包注入;服务端重拉逻辑(refreshPricing + 价格目录代际
7057
+ * 变化触发 reprice)也在装配器里,路由只通过 `refresh()` 触发。
7023
7058
  */
7024
- /** key 只取字符串凭据字段:zaiRegion 是区域枚举,由下方区域逻辑单独赋值。 */
7025
- const SUBSCRIPTION_KEY_SOURCES = [
7026
- {
7027
- provider: "kimi-coding",
7028
- key: "kimiApiKey"
7029
- },
7030
- {
7031
- provider: "zai-coding-cn",
7032
- key: "zaiCnApiKey"
7033
- },
7034
- {
7035
- provider: "zai-coding",
7036
- key: "zaiApiKey"
7037
- },
7038
- {
7039
- provider: "opencode",
7040
- key: "opencodeApiKey"
7041
- },
7042
- {
7043
- provider: "opencode-go",
7044
- key: "opencodeApiKey"
7045
- },
7046
- {
7059
+ /** GET /api/billing/pricing。 */
7060
+ function registerGetRoute(ctx, deps) {
7061
+ ctx.effect(() => ctx.webServer.register({
7062
+ kind: "exact",
7063
+ path: "/api/billing/pricing",
7064
+ handler: async (req, res) => {
7065
+ if (!guardLoopback(req, res, deps.trustedHosts)) return;
7066
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
7067
+ res.end(deps.body());
7068
+ }
7069
+ }), "usage-billing: pricing route");
7070
+ }
7071
+ /** POST /api/billing/pricing/refresh。 */
7072
+ function registerRefreshRoute(ctx, deps) {
7073
+ ctx.effect(() => ctx.webServer.register({
7074
+ kind: "exact",
7075
+ path: "/api/billing/pricing/refresh",
7076
+ handler: async (req, res) => {
7077
+ if (!guardLoopback(req, res, deps.trustedHosts)) return;
7078
+ if (req.method !== "POST") {
7079
+ res.writeHead(405, { "content-type": "application/json; charset=utf-8" });
7080
+ res.end(JSON.stringify({ error: "method not allowed" }));
7081
+ return;
7082
+ }
7083
+ if (!isLoopbackOrigin(req.headers.origin)) {
7084
+ res.writeHead(403, { "content-type": "application/json; charset=utf-8" });
7085
+ res.end(JSON.stringify({ error: "forbidden: loopback only" }));
7086
+ return;
7087
+ }
7088
+ if (!(req.headers["content-type"] ?? "").toLowerCase().includes("application/json")) {
7089
+ res.writeHead(415, { "content-type": "application/json; charset=utf-8" });
7090
+ res.end(JSON.stringify({ error: "unsupported content-type" }));
7091
+ return;
7092
+ }
7093
+ await deps.refresh();
7094
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
7095
+ res.end(deps.body());
7096
+ }
7097
+ }), "usage-billing: pricing refresh route");
7098
+ }
7099
+ /** 一次性注册两条同 concern 路由。 */
7100
+ function registerPricingRoutes(ctx, deps) {
7101
+ registerGetRoute(ctx, deps);
7102
+ registerRefreshRoute(ctx, deps);
7103
+ }
7104
+ //#endregion
7105
+ //#region lib/types/routes/balance.js
7106
+ /**
7107
+ * 路由:GET /api/billing/balance
7108
+ *
7109
+ * 多平台余额(DeepSeek / Kimi / StepFun / SiliconFlow / xAI / 智谱 / 腾讯云
7110
+ * TokenHub 等)+ 用户自定义余额 + 声明端点 + 余额差对账。5 分钟缓存(避免
7111
+ * 每 30s 后台轮询打上游),in-flight 去重(并发请求共享同一次进行中的刷新)。
7112
+ *
7113
+ * 路由文件只关心 HTTP 缓存逻辑:5 分钟过期判定 + 失败时仍发旧缓存(不挂起
7114
+ * 已 writeHead 的响应)。上游查询(queryBalances / queryCustomBalances /
7115
+ * queryDeclaredEndpoints / reconcileBalanceDelta)在装配器里跑,路由只通过
7116
+ * `cached()` 与 `refreshOnce()` 拿到结果。
7117
+ */
7118
+ function registerBalanceRoute(ctx, deps) {
7119
+ ctx.effect(() => ctx.webServer.register({
7120
+ kind: "exact",
7121
+ path: "/api/billing/balance",
7122
+ handler: async (req, res) => {
7123
+ if (!guardLoopback(req, res, deps.trustedHosts)) return;
7124
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
7125
+ if (Date.now() - deps.cached().at < deps.cacheMs) {
7126
+ res.end(JSON.stringify(deps.cached().doc));
7127
+ return;
7128
+ }
7129
+ try {
7130
+ await deps.refreshOnce();
7131
+ } catch (error) {
7132
+ console.warn("[usage-billing] refreshBalances failed; serving cached balances:", error);
7133
+ }
7134
+ res.end(JSON.stringify(deps.cached().doc));
7135
+ }
7136
+ }), "usage-billing: balance route");
7137
+ }
7138
+ //#endregion
7139
+ //#region lib/types/routes/usage-tool.js
7140
+ /**
7141
+ * 路由:GET/POST /api/billing/usage-tool
7142
+ *
7143
+ * usage_stats 工具开关:插件自带的读/写接口(不依赖宿主浏览器设置白名单)。
7144
+ * GET 返回当前是否注入;POST 写设置命名空间(重启应用后工具注入随新值生效)。
7145
+ *
7146
+ * 双世代宿主(issue #19 + #28 + v1.4.19)的接线在装配器里完成:
7147
+ * - 旧宿主 ≤0.1.6 走 `settings.register` 命名空间 scope。
7148
+ * - 宿主 0.1.7+ 注册面已移除,走 volatile config 投影。
7149
+ * 装配器提供 `read` / `write` 闭包,路由文件不感知双世代——`read()` 已内部分支
7150
+ * (scope 存在时读 scope,否则读 volatile + config base),`write(next)` 同理。
7151
+ * 工具注入由装配器的 `syncUsageTool` 驱动:写回后路由通过 `syncTool()` 显式调用一次
7152
+ * (幂等),覆盖「写回成功但引用未即时可见」的组合(测试替身不提交)。
7153
+ */
7154
+ function registerUsageToolRoute(ctx, deps) {
7155
+ ctx.effect(() => ctx.webServer.register({
7156
+ kind: "exact",
7157
+ path: "/api/billing/usage-tool",
7158
+ handler: async (req, res) => {
7159
+ if (!guardLoopback(req, res, deps.trustedHosts)) return;
7160
+ if (req.method !== "GET" && req.method !== "POST") {
7161
+ res.writeHead(405, { "content-type": "application/json; charset=utf-8" });
7162
+ res.end(JSON.stringify({ error: "method not allowed" }));
7163
+ return;
7164
+ }
7165
+ if (req.method === "POST") {
7166
+ if (!isLoopbackOrigin(req.headers.origin)) {
7167
+ res.writeHead(403, { "content-type": "application/json; charset=utf-8" });
7168
+ res.end(JSON.stringify({ error: "forbidden: loopback only" }));
7169
+ return;
7170
+ }
7171
+ if (!(req.headers["content-type"] ?? "").toLowerCase().includes("application/json")) {
7172
+ res.writeHead(415, { "content-type": "application/json; charset=utf-8" });
7173
+ res.end(JSON.stringify({ error: "unsupported content-type" }));
7174
+ return;
7175
+ }
7176
+ }
7177
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
7178
+ if (req.method === "GET") {
7179
+ res.end(JSON.stringify({ enabled: deps.read() }));
7180
+ return;
7181
+ }
7182
+ try {
7183
+ let body = "";
7184
+ for await (const chunk of req) {
7185
+ body += String(chunk);
7186
+ if (body.length > 512) {
7187
+ res.writeHead(413, { "content-type": "application/json; charset=utf-8" });
7188
+ res.end(JSON.stringify({ error: "body too large" }));
7189
+ return;
7190
+ }
7191
+ }
7192
+ const next = JSON.parse(body === "" ? "{}" : body).enabled === true;
7193
+ await deps.write(next);
7194
+ deps.syncTool();
7195
+ res.end(JSON.stringify({
7196
+ ok: true,
7197
+ enabled: next
7198
+ }));
7199
+ } catch (error) {
7200
+ console.warn("[usage-billing] usage-tool toggle write-back failed:", error);
7201
+ res.end(JSON.stringify({ error: "invalid" }));
7202
+ }
7203
+ }
7204
+ }), "usage-billing: usage-tool route");
7205
+ }
7206
+ //#endregion
7207
+ //#region lib/types/routes/subscriptions.js
7208
+ /**
7209
+ * 路由:GET /api/billing/subscriptions
7210
+ *
7211
+ * 订阅套餐额度(Kimi / Z.ai Coding / OpenCode / MiniMax / OpenRouter / Claude /
7212
+ * CommandCode / 腾讯云 TokenHub 等)。5 分钟缓存 + in-flight 去重(与 balance
7213
+ * 同模式——并发请求共享同一次进行中的刷新)。
7214
+ *
7215
+ * 路由文件只关心 HTTP 缓存逻辑:超时或未命中时触发 refreshOnce,失败时仍用
7216
+ * 旧缓存(不挂起已 writeHead 的响应)。上游查询(collectSubscriptions +
7217
+ * resolveSubscriptionKeys + 适配器 + 套餐知识库)在装配器里跑。
7218
+ */
7219
+ function registerSubscriptionsRoute(ctx, deps) {
7220
+ ctx.effect(() => ctx.webServer.register({
7221
+ kind: "exact",
7222
+ path: "/api/billing/subscriptions",
7223
+ handler: async (req, res) => {
7224
+ if (!guardLoopback(req, res, deps.trustedHosts)) return;
7225
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
7226
+ if (Date.now() - deps.cached().at >= deps.cacheMs) try {
7227
+ await deps.refreshOnce();
7228
+ } catch (error) {
7229
+ console.warn("[usage-billing] refreshQuotas failed; serving cached quotas:", error);
7230
+ }
7231
+ res.end(JSON.stringify({ quotas: deps.cached().quotas }));
7232
+ }
7233
+ }), "usage-billing: subscriptions route");
7234
+ }
7235
+ //#endregion
7236
+ //#region lib/types/routes/relay-quotas.js
7237
+ /**
7238
+ * 路由:GET /api/billing/relay-quotas
7239
+ *
7240
+ * 中转站额度(New API / Sub2API 余额 + 滚动窗口)。5 分钟缓存 + in-flight
7241
+ * 去重。诊断(每条路由的 origin / kind)随响应下发,供「我的中转站为什么不显
7242
+ * 示」自查。
7243
+ *
7244
+ * 路由文件只关心 HTTP 缓存 + 诊断序列化。上游查询(queryRelayQuotas +
7245
+ * 路由筛选 + isOfficialBaseUrl 排除)在装配器里跑。
7246
+ */
7247
+ function registerRelayQuotasRoute(ctx, deps) {
7248
+ ctx.effect(() => ctx.webServer.register({
7249
+ kind: "exact",
7250
+ path: "/api/billing/relay-quotas",
7251
+ handler: async (req, res) => {
7252
+ if (!guardLoopback(req, res, deps.trustedHosts)) return;
7253
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
7254
+ if (Date.now() - deps.cached().at >= deps.cacheMs) try {
7255
+ await deps.refreshOnce();
7256
+ } catch (error) {
7257
+ console.warn("[usage-billing] refreshRelay failed; serving cached relay quotas:", error);
7258
+ }
7259
+ const diagnostics = deps.cached().quotas.map((row) => ({
7260
+ route: row.route,
7261
+ origin: row.origin,
7262
+ kind: row.kind
7263
+ }));
7264
+ res.end(JSON.stringify({
7265
+ quotas: deps.cached().quotas,
7266
+ diagnostics
7267
+ }));
7268
+ }
7269
+ }), "usage-billing: relay-quotas route");
7270
+ }
7271
+ //#endregion
7272
+ //#region lib/types/routes/usage-stats.js
7273
+ /**
7274
+ * 路由:GET /api/billing/usage-stats
7275
+ *
7276
+ * 实时用量聚合文档(byDay / byModel / bySession / bySite / byTurn / perf 等)。
7277
+ * stale-while-revalidate:聚合是全量扫描(重度用户上万条会话的冷启动折叠以
7278
+ * 分钟计),HTTP 请求绝不全程等它——只给 RESPONSE_BUDGET_MS 的等待预算,超
7279
+ * 预算立即回最近一次快照;后台折叠完成后快照随之刷新(挂在聚合 promise 上,
7280
+ * 与响应路径解耦)。宿主是单进程,慢响应期间事件循环被折叠占住,会话/模型
7281
+ * 列表 RPC 会一起饿死(用户反馈的启动卡顿)。
7282
+ *
7283
+ * 路由文件只关心 HTTP 拼装:
7284
+ * - decorate(注入 PACKAGE_VERSION / 预算 / 阈值)
7285
+ * - 聚合预算(Promise.race + setTimeout.unref)
7286
+ * - 响应体缓存(`statsBodyCache`:装饰 + 序列化结果按 doc 引用 + config 字段
7287
+ * 缓存,30s 轮询期间复用)
7288
+ * - 失败路径(聚合失败 → 读快照候选 → 仍无则 `{ error }`)
7289
+ *
7290
+ * 聚合器(`aggregator.aggregate()` / `sessionUsageOf()`)+ 快照读写 + 配置在
7291
+ * 装配器里跑,路由只通过 Deps 拿到回调。
7292
+ */
7293
+ /** 聚合文档核心结构的 wire 校验(S-7 可观察性金丝雀)。
7294
+ *
7295
+ * 客户端 loadUsageStats 已有逐字段 typeof 守卫(渲染层兜底),本校验是
7296
+ * 服务端边界的漂移告警:聚合器字段改名/类型漂移时,30s 轮询的第一轮就在
7297
+ * 宿主日志留下 `[usage-billing]` 告警,而不是等用户面板渲染崩了报 issue。
7298
+ *
7299
+ * 语义是「告警不拦截」:校验失败仍照常下发(客户端自行兜底),绝不因
7300
+ * schema 漂移把仪表盘整个 500——与「degrade, never fabricate」同一姿态。
7301
+ *
7302
+ * 只校验 fresh 聚合路径(快照兜底路径不校验:旧快照本就可能缺新字段,
7303
+ * 校验它们只会产生永久的假阳性告警)。
7304
+ *
7305
+ * 实现说明:刻意手写 typeof 检查而不用 schemastery——schemastery 是 config
7306
+ * 库,object schema 对 null / 数组 / 标量输入走宽容强转(补默认值)而非抛错,
7307
+ * 无法表达「形状必须精确」的校验语义。手写检查只认精确类型,缺字段即漂移。
7308
+ * 导出供测试。 */
7309
+ const NUM = (v) => typeof v === "number" && Number.isFinite(v);
7310
+ /** 一个 ModelUsage 桶的核心数字字段(客户端渲染硬依赖)。
7311
+ * `withReasoning`:byModel / byDay / total 的桶含 reasoning;byDayModels 的
7312
+ * 三维格接口只有 6 个数字字段(无 reasoning),复用同一检查会产生假阳性。 */
7313
+ function modelUsageError(value, path, withReasoning = true) {
7314
+ if (value === null || typeof value !== "object") return `${path} 不是对象`;
7315
+ const keys = withReasoning ? [
7316
+ "calls",
7317
+ "input",
7318
+ "output",
7319
+ "cacheHit",
7320
+ "cacheMiss",
7321
+ "cost",
7322
+ "reasoning"
7323
+ ] : [
7324
+ "calls",
7325
+ "input",
7326
+ "output",
7327
+ "cacheHit",
7328
+ "cacheMiss",
7329
+ "cost"
7330
+ ];
7331
+ for (const key of keys) if (!NUM(value[key])) return `${path}.${key} 不是有限数字`;
7332
+ return null;
7333
+ }
7334
+ /** 校验一份聚合文档;返回漂移原因(null = 形状正常)。纯只读,不改 doc。 */
7335
+ function usageStatsShapeError(doc) {
7336
+ if (doc === null || typeof doc !== "object" || Array.isArray(doc)) return "文档不是对象";
7337
+ const d = doc;
7338
+ if (!NUM(d.version)) return "version 不是有限数字";
7339
+ if (!NUM(d.updatedAt)) return "updatedAt 不是有限数字";
7340
+ const total = modelUsageError(d.total, "total");
7341
+ if (total !== null) return total;
7342
+ for (const [field, cellCheck] of [
7343
+ ["byModel", (cell, p) => modelUsageError(cell, p)],
7344
+ ["byDay", (cell, p) => modelUsageError(cell, p)],
7345
+ ["byDayModels", (cell, p) => {
7346
+ if (cell === null || typeof cell !== "object") return `${p} 不是对象`;
7347
+ for (const [modelKey, usage] of Object.entries(cell)) {
7348
+ const err = modelUsageError(usage, `${p}.${modelKey}`, false);
7349
+ if (err !== null) return err;
7350
+ }
7351
+ return null;
7352
+ }]
7353
+ ]) {
7354
+ const bucket = d[field];
7355
+ if (bucket === void 0) return `${field} 缺失(聚合路径恒带;快照兜底才允许缺)`;
7356
+ if (bucket === null || typeof bucket !== "object") return `${field} 不是对象`;
7357
+ for (const [key, cell] of Object.entries(bucket)) {
7358
+ const err = cellCheck(cell, `${field}.${key}`);
7359
+ if (err !== null) return err;
7360
+ }
7361
+ }
7362
+ const bySession = d.bySession;
7363
+ if (!Array.isArray(bySession)) return "bySession 不是数组";
7364
+ for (const [index, row] of bySession.entries()) {
7365
+ if (row === null || typeof row !== "object") return `bySession[${index}] 不是对象`;
7366
+ const r = row;
7367
+ if (typeof r.id !== "string" || r.id === "") return `bySession[${index}].id 不是非空字符串`;
7368
+ if (!NUM(r.calls)) return `bySession[${index}].calls 不是有限数字`;
7369
+ if (!NUM(r.cost)) return `bySession[${index}].cost 不是有限数字`;
7370
+ if (!NUM(r.lastActive)) return `bySession[${index}].lastActive 不是有限数字`;
7371
+ }
7372
+ return null;
7373
+ }
7374
+ function registerUsageStatsRoute(ctx, deps) {
7375
+ let statsBodyCache;
7376
+ ctx.effect(() => ctx.webServer.register({
7377
+ kind: "exact",
7378
+ path: "/api/billing/usage-stats",
7379
+ handler: async (req, res) => {
7380
+ if (!guardLoopback(req, res, deps.trustedHosts)) return;
7381
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
7382
+ const decorate = (doc) => ({
7383
+ ...doc,
7384
+ pluginVersion: deps.packageVersion,
7385
+ ...deps.config.monthlyBudget === void 0 ? {} : { budget: deps.config.monthlyBudget },
7386
+ ...deps.config.lowBalanceThreshold === void 0 ? {} : { lowBalanceThreshold: deps.config.lowBalanceThreshold }
7387
+ });
7388
+ const statsBodyOf = (doc) => {
7389
+ const budget = deps.config.monthlyBudget;
7390
+ const threshold = deps.config.lowBalanceThreshold;
7391
+ if (statsBodyCache !== void 0 && statsBodyCache.doc === doc && statsBodyCache.budget === budget && statsBodyCache.threshold === threshold) return statsBodyCache.body;
7392
+ const shapeError = usageStatsShapeError(doc);
7393
+ if (shapeError !== null) console.warn("[usage-billing] usage-stats document shape drift (serving anyway):", shapeError);
7394
+ const body = JSON.stringify(decorate(doc));
7395
+ statsBodyCache = {
7396
+ doc,
7397
+ budget,
7398
+ threshold,
7399
+ body
7400
+ };
7401
+ return body;
7402
+ };
7403
+ const aggregating = deps.aggregator.aggregate();
7404
+ aggregating.then((stats) => {
7405
+ deps.persistSnapshot(decorate(stats));
7406
+ }, () => {});
7407
+ let fresh;
7408
+ try {
7409
+ fresh = await Promise.race([aggregating.then((stats) => stats), new Promise((resolve) => {
7410
+ setTimeout(() => {
7411
+ resolve(void 0);
7412
+ }, deps.responseBudgetMs).unref();
7413
+ })]);
7414
+ } catch (error) {
7415
+ console.error("[usage-billing] usage-stats aggregate failed, falling back to snapshot:", error);
7416
+ }
7417
+ if (fresh !== void 0) {
7418
+ res.end(statsBodyOf(fresh));
7419
+ return;
7420
+ }
7421
+ for (const candidate of deps.candidates) {
7422
+ const doc = await deps.readSnapshot(candidate);
7423
+ if (doc === null) continue;
7424
+ res.end(JSON.stringify(decorate(doc)));
7425
+ return;
7426
+ }
7427
+ res.end(JSON.stringify({ error: "usage stats unavailable" }));
7428
+ }
7429
+ }), "usage-billing: usage-stats route");
7430
+ }
7431
+ //#endregion
7432
+ //#region lib/types/client/usage-billing-settings.js
7433
+ /** 设置命名空间 id(小写 kebab-case)。 */
7434
+ const BILLING_SETTINGS_NAMESPACE = "ui-usage-billing";
7435
+ /** 该命名空间下用户可编辑的字段名。 */
7436
+ const ENABLE_USAGE_STATS_TOOL_FIELD = "enableUsageStatsTool";
7437
+ //#endregion
7438
+ //#region lib/types/index.js
7439
+ /**
7440
+ * Usage billing surface plugin, node half.
7441
+ *
7442
+ * Serves `/api/billing/usage-stats`: real usage aggregated from every
7443
+ * persisted session log (see `aggregate.ts`) — the browser dashboard reads it
7444
+ * instead of showing an empty snapshot. Aggregation is stale-while-revalidate:
7445
+ * requests get a short wait budget and fall back to the freshest persisted
7446
+ * snapshot while a slow full fold keeps running in the background (a heavy
7447
+ * user's first fold takes minutes and must not starve the single-process
7448
+ * host's RPC). When `sessionPersistence` is unavailable (or aggregation
7449
+ * fails), the configured `statsPath` / `DSH_USAGE_STATS` / conventional JSON
7450
+ * file is served as a fallback, and a missing file answers `{ error }` so the
7451
+ * dashboard shows zeros, never fabricated samples.
7452
+ */
7453
+ const SETTINGS_NAMESPACE_PATTERN = /^[a-z][a-z0-9-]*$/;
7454
+ /** 校验并透过合法命名空间 id;非法值 fail-loud(与上游原行为一致)。 */
7455
+ function validateSettingsNamespace(value) {
7456
+ if (!SETTINGS_NAMESPACE_PATTERN.test(value)) throw new TypeError(`settings namespace "${value}" must match ${String(SETTINGS_NAMESPACE_PATTERN)}`);
7457
+ return value;
7458
+ }
7459
+ /** usage_stats 工具开关的设置命名空间 id(下端与 node 共用同一常量)。 */
7460
+ const usageBillingSettingsNs = validateSettingsNamespace(BILLING_SETTINGS_NAMESPACE);
7461
+ /** 该命名空间的 wire schema:`enableUsageStatsTool` 布尔,默认关闭(issue 诉求)。 */
7462
+ const UsageBillingSettingsSchema = z.object({ [ENABLE_USAGE_STATS_TOOL_FIELD]: z.boolean().default(false) });
7463
+ /** 实时定价的后台刷新间隔(毫秒):汇率/模型价低频变化,6 小时一次足够。 */
7464
+ /** 实时定价的后台刷新间隔(毫秒):汇率/模型价低频变化,6 小时一次足够。 */
7465
+ const PRICING_REFRESH_INTERVAL_MS = 216e5;
7466
+ /** 历史回放预热的延迟:等宿主启动高峰(插件加载 / 路由挂载)过去再全量折叠,
7467
+ * 避免抢启动期的 CPU;纯延迟不阻塞任何请求,面板提前打开也只会提前聚合。 */
7468
+ const WARMUP_DELAY_MS = 3e3;
7469
+ /** 订阅套餐额度缓存时长(毫秒):上游配额 API 低频变化,5 分钟足够。 */
7470
+ const SUBSCRIPTION_CACHE_MS = 3e5;
7471
+ const BALANCE_CACHE_MS = 3e5;
7472
+ /** DeepSeek 余额查询的默认凭据引用(与 llm-deepseek 的默认引用一致)。 */
7473
+ const DEFAULT_BALANCE_API_KEY_ENV = "DEEPSEEK_API_KEY";
7474
+ /**
7475
+ * 本插件版本号:从包自身的 package.json 读取(单一来源),随 usage-stats
7476
+ * 下发,供「设置 → 插件信息卡」展示。发布版 lib/index.js 相对包根解析。
7477
+ */
7478
+ const PACKAGE_VERSION = createRequire(import.meta.url)("../package.json").version ?? "0.0.0";
7479
+ /** 统计快照的落盘节流(毫秒):快照只服务聚合失败回退与重启首屏(展示级
7480
+ * 新鲜度即可),降到 5 分钟一次——原子写含上一版备份是两次全量 JSON 落盘,
7481
+ * 重度用户快照上百 KB,跟随前端轮询 30 秒写一次纯属写放大。 */
7482
+ const SNAPSHOT_INTERVAL_MS = 3e5;
7483
+ /** usage-stats 响应的等待预算(毫秒):聚合在此预算内完成就回实时数据,
7484
+ * 超预算立即回最近快照(stale-while-revalidate),后台折叠继续跑。
7485
+ * 轻量用户暖缓存下的聚合是毫秒级,预算几乎不会触发。 */
7486
+ const RESPONSE_BUDGET_MS = 1500;
7487
+ /** 鉴权失败告警冷却(毫秒):同一 provider 在窗口内只提示一次,避免 30 秒轮询刷屏。 */
7488
+ const AUTH_WARN_COOLDOWN_MS = 18e5;
7489
+ /**
7490
+ * 鉴权失败分类告警(P1-5):余额 / 订阅查询返回 unauthorized 时,按
7491
+ * `source:provider` 去重并冷却告警,提示检查 llm-pi-ai 里该 provider 的 apiKeyEnv。
7492
+ */
7493
+ const authWarnedAt = /* @__PURE__ */ new Map();
7494
+ function warnAuthOnce(source, provider, displayName) {
7495
+ const key = `${source}:${provider}`;
7496
+ const now = Date.now();
7497
+ const last = authWarnedAt.get(key);
7498
+ if (last !== void 0 && now - last < AUTH_WARN_COOLDOWN_MS) return;
7499
+ authWarnedAt.set(key, now);
7500
+ console.warn(`[usage-billing] ${displayName}(${provider})鉴权失败:请检查 llm-pi-ai 设置中该 provider 的凭据环境变量是否正确/有效。`);
7501
+ }
7502
+ /** 锁等待上限:账本提交是毫秒级 read-render-commit,2s 已数倍于正常持锁时长。 */
7503
+ const LEDGER_LOCK_WAIT_MS = 2e3;
7504
+ /**
7505
+ * 锁年龄超过该值判为崩溃残留的孤儿锁。正常持锁是毫秒级文件提交,没有活跃
7506
+ * writer 会合法持有 60s;库层刻意不做年龄判定(它无法假设调用方的操作时长),
7507
+ * 本调用方知道自己操作的上界,可以给出有依据的清理阈值。
7508
+ */
7509
+ const STALE_LEDGER_LOCK_MS = 6e4;
7510
+ /**
7511
+ * 锁等待超时后的孤儿锁自愈(issue #48):仅当锁文件年龄超过
7512
+ * STALE_LEDGER_LOCK_MS 才清理,避免误删慢 writer 的活跃锁。
7513
+ * @param ledgerPath - 账本文件路径(锁为其 `.lock` 兄弟文件)。
7514
+ * @returns 是否清理了孤儿锁;false = 锁已消失或太新鲜,调用方上抛原错误。
7515
+ */
7516
+ async function reclaimStaleLedgerLock(ledgerPath) {
7517
+ let ageMs;
7518
+ try {
7519
+ ageMs = Date.now() - (await lstat(`${ledgerPath}.lock`)).mtimeMs;
7520
+ } catch {
7521
+ return false;
7522
+ }
7523
+ if (ageMs < STALE_LEDGER_LOCK_MS) return false;
7524
+ console.warn(`[usage-billing] 账本锁已存在 ${Math.round(ageMs / 1e3)}s(崩溃残留),清理后重试:${ledgerPath}.lock`);
7525
+ await rm(`${ledgerPath}.lock`, { force: true });
7526
+ return true;
7527
+ }
7528
+ /**
7529
+ * Create the atomic file-backed durable-ledger store. The previous complete file
7530
+ * is retained as `.bak`; a malformed/missing main file falls back to that backup.
7531
+ */
7532
+ function createFileUsageLedgerStore(ledgerPath) {
7533
+ return {
7534
+ async load() {
7535
+ for (const path of [ledgerPath, `${ledgerPath}.bak`]) try {
7536
+ const parsed = JSON.parse(await readFile(path, "utf8"));
7537
+ if (parsed !== null && typeof parsed === "object") {
7538
+ const candidate = parsed;
7539
+ if (candidate.version === 1 && Array.isArray(candidate.sessions)) return parsed;
7540
+ }
7541
+ } catch {
7542
+ swallowed("ledger-main-unreadable");
7543
+ }
7544
+ },
7545
+ async save(document) {
7546
+ await mkdir(dirname(ledgerPath), {
7547
+ recursive: true,
7548
+ mode: 448
7549
+ });
7550
+ const commit = async () => {
7551
+ let existing;
7552
+ let existingDoc;
7553
+ try {
7554
+ existing = await readFile(ledgerPath, "utf8");
7555
+ const parsed = JSON.parse(existing);
7556
+ if (parsed.version === 1 && Array.isArray(parsed.sessions)) existingDoc = parsed;
7557
+ } catch {
7558
+ existing = void 0;
7559
+ }
7560
+ if (existing !== void 0) await writeFileAtomic(`${ledgerPath}.bak`, existing, {
7561
+ mode: 384,
7562
+ dirMode: 448
7563
+ });
7564
+ const merged = existingDoc === void 0 ? document : mergeUsageLedgerDocuments(document, existingDoc);
7565
+ await writeFileAtomic(ledgerPath, JSON.stringify(merged), {
7566
+ mode: 384,
7567
+ dirMode: 448
7568
+ });
7569
+ };
7570
+ try {
7571
+ await withFileLock(ledgerPath, commit, { waitMs: LEDGER_LOCK_WAIT_MS });
7572
+ } catch (error) {
7573
+ if (!(error instanceof Error) || !error.message.includes("timed out waiting for the writer lock")) throw error;
7574
+ if (!await reclaimStaleLedgerLock(ledgerPath)) throw error;
7575
+ await withFileLock(ledgerPath, commit, { waitMs: LEDGER_LOCK_WAIT_MS });
7576
+ }
7577
+ }
7578
+ };
7579
+ }
7580
+ /** Required services: the web server, the persisted session log store, and user settings. */
7581
+ const inject = [
7582
+ "webServer",
7583
+ "sessionPersistence",
7584
+ "credentials",
7585
+ "settings"
7586
+ ];
7587
+ /**
7588
+ * 订阅 provider id(llm-pi-ai 设置键)→ billing 适配器 key 的映射。
7589
+ * 复用 dsh 既有的 llm-pi-ai provider 配置(apiKeyEnv 引用),不引入新配置面。
7590
+ */
7591
+ /** key 只取字符串凭据字段:zaiRegion 是区域枚举,由下方区域逻辑单独赋值。 */
7592
+ const SUBSCRIPTION_KEY_SOURCES = [
7593
+ {
7594
+ provider: "kimi-coding",
7595
+ key: "kimiApiKey"
7596
+ },
7597
+ {
7598
+ provider: "zai-coding-cn",
7599
+ key: "zaiCnApiKey"
7600
+ },
7601
+ {
7602
+ provider: "zai-coding",
7603
+ key: "zaiApiKey"
7604
+ },
7605
+ {
7606
+ provider: "opencode",
7607
+ key: "opencodeApiKey"
7608
+ },
7609
+ {
7610
+ provider: "opencode-go",
7611
+ key: "opencodeApiKey"
7612
+ },
7613
+ {
7047
7614
  provider: "minimax",
7048
7615
  key: "minmaxApiKey"
7049
7616
  },
@@ -7217,7 +7784,9 @@ async function resolveSubscriptionKeys(settings, credentials) {
7217
7784
  try {
7218
7785
  const resolved = await credentials.resolve(credentialRef(env));
7219
7786
  if (resolved?.value !== void 0 && resolved.value !== "") keys[key] = resolved.value;
7220
- } catch {}
7787
+ } catch (error) {
7788
+ swallowed("credential-resolve-failed", error);
7789
+ }
7221
7790
  }
7222
7791
  if (providers?.["zai-coding-cn"]?.apiKeyEnv !== void 0 && keys.zaiCnApiKey !== "") keys.zaiRegion = "bigmodel-cn";
7223
7792
  if (keys.opencodeApiKey === "") keys.opencodeApiKey = await readOpenCodeToken();
@@ -7243,7 +7812,9 @@ async function resolveSubscriptionKeys(settings, credentials) {
7243
7812
  async function readOpenCodeToken() {
7244
7813
  try {
7245
7814
  return parseOpenCodeAuthDocument(JSON.parse(await readFile(join(homedir(), ".local", "share", "opencode", "auth.json"), "utf8")));
7246
- } catch {}
7815
+ } catch {
7816
+ swallowed("opencode-auth-missing");
7817
+ }
7247
7818
  return "";
7248
7819
  }
7249
7820
  /**
@@ -7335,7 +7906,9 @@ function apply(ctx, config = {}) {
7335
7906
  const candidate = ref;
7336
7907
  if (typeof candidate.date === "string" && typeof candidate.total === "number" && typeof candidate.granted === "number" && typeof candidate.topped === "number" && typeof candidate.currency === "string") reconcileRef = candidate;
7337
7908
  }
7338
- } catch {}
7909
+ } catch {
7910
+ swallowed("reconcile-ref-missing");
7911
+ }
7339
7912
  })();
7340
7913
  const persistReconcileRef = () => {
7341
7914
  if (reconcileRef === null) return;
@@ -7404,83 +7977,32 @@ function apply(ctx, config = {}) {
7404
7977
  mode: 384,
7405
7978
  dirMode: 448
7406
7979
  });
7407
- } catch {}
7980
+ } catch {
7981
+ swallowed("snapshot-bak-missing");
7982
+ }
7408
7983
  try {
7409
7984
  await writeFileAtomic(snapshotPath, payload, {
7410
7985
  mode: 384,
7411
7986
  dirMode: 448
7412
7987
  });
7413
- } catch {}
7988
+ } catch {
7989
+ swallowed("snapshot-write-failed");
7990
+ }
7414
7991
  })();
7415
7992
  };
7416
7993
  /** 读一个快照候选并解析成对象:主文件优先;主文件损坏时回退上一版 `.bak`(P0-3 崩溃恢复)。 */
7417
7994
  const readSnapshot = async (candidate) => {
7418
7995
  for (const path of [candidate, `${candidate}.bak`]) try {
7419
7996
  return JSON.parse(await readFile(path, "utf8"));
7420
- } catch {}
7997
+ } catch {
7998
+ swallowed("snapshot-candidate-unreadable");
7999
+ }
7421
8000
  return null;
7422
8001
  };
7423
- const notifyClaimsPath = `${snapshotPath}.notify-claims.json`;
7424
- const notifyClaims = {
7425
- loaded: false,
7426
- keys: []
7427
- };
7428
- ctx.effect(() => ctx.webServer.register({
7429
- kind: "exact",
7430
- path: "/api/billing/notify-claim",
7431
- handler: async (req, res) => {
7432
- if (!guardLoopback(req, res, trustedHosts)) return;
7433
- if (req.method !== "POST") {
7434
- res.writeHead(405, { "content-type": "application/json; charset=utf-8" });
7435
- res.end(JSON.stringify({ error: "method not allowed" }));
7436
- return;
7437
- }
7438
- if (!isLoopbackOrigin(req.headers.origin) || !(req.headers["content-type"] ?? "").toLowerCase().includes("application/json")) {
7439
- res.writeHead(403, { "content-type": "application/json; charset=utf-8" });
7440
- res.end(JSON.stringify({ error: "forbidden: loopback only" }));
7441
- return;
7442
- }
7443
- try {
7444
- let body = "";
7445
- for await (const chunk of req) {
7446
- body += String(chunk);
7447
- if (body.length > 2048) {
7448
- res.end(JSON.stringify({ error: "body too large" }));
7449
- return;
7450
- }
7451
- }
7452
- const parsed = JSON.parse(body === "" ? "{}" : body);
7453
- if (typeof parsed.key !== "string" || parsed.key === "" || parsed.key.length > 200) {
7454
- res.writeHead(400, { "content-type": "application/json; charset=utf-8" });
7455
- res.end(JSON.stringify({ error: "invalid key" }));
7456
- return;
7457
- }
7458
- if (!notifyClaims.loaded) {
7459
- notifyClaims.loaded = true;
7460
- try {
7461
- const stored = JSON.parse(await readFile(notifyClaimsPath, "utf8"));
7462
- if (Array.isArray(stored.keys)) notifyClaims.keys = stored.keys.filter((k) => typeof k === "string");
7463
- } catch {}
7464
- }
7465
- if (notifyClaims.keys.includes(parsed.key)) {
7466
- res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
7467
- res.end(JSON.stringify({ claimed: false }));
7468
- return;
7469
- }
7470
- notifyClaims.keys.push(parsed.key);
7471
- if (notifyClaims.keys.length > 400) notifyClaims.keys = notifyClaims.keys.slice(-200);
7472
- writeFileAtomic(notifyClaimsPath, JSON.stringify({ keys: notifyClaims.keys }), {
7473
- mode: 384,
7474
- dirMode: 448
7475
- }).catch(() => {});
7476
- res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
7477
- res.end(JSON.stringify({ claimed: true }));
7478
- } catch {
7479
- res.writeHead(400, { "content-type": "application/json; charset=utf-8" });
7480
- res.end(JSON.stringify({ error: "invalid" }));
7481
- }
7482
- }
7483
- }), "usage-billing: notify-claim route");
8002
+ registerNotifyClaimRoute(ctx, {
8003
+ trustedHosts,
8004
+ claimsPath: `${snapshotPath}.notify-claims.json`
8005
+ });
7484
8006
  let usageToolDisposer;
7485
8007
  let usageToolRegister;
7486
8008
  const syncUsageTool = () => {
@@ -7681,40 +8203,11 @@ function apply(ctx, config = {}) {
7681
8203
  clearInterval(timer);
7682
8204
  };
7683
8205
  }, "usage-billing: pricing refresh timer");
7684
- ctx.effect(() => ctx.webServer.register({
7685
- kind: "exact",
7686
- path: "/api/billing/pricing",
7687
- handler: async (req, res) => {
7688
- if (!guardLoopback(req, res, trustedHosts)) return;
7689
- res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
7690
- res.end(pricingBody());
7691
- }
7692
- }), "usage-billing: pricing route");
7693
- ctx.effect(() => ctx.webServer.register({
7694
- kind: "exact",
7695
- path: "/api/billing/pricing/refresh",
7696
- handler: async (req, res) => {
7697
- if (!guardLoopback(req, res, trustedHosts)) return;
7698
- if (req.method !== "POST") {
7699
- res.writeHead(405, { "content-type": "application/json; charset=utf-8" });
7700
- res.end(JSON.stringify({ error: "method not allowed" }));
7701
- return;
7702
- }
7703
- if (!isLoopbackOrigin(req.headers.origin)) {
7704
- res.writeHead(403, { "content-type": "application/json; charset=utf-8" });
7705
- res.end(JSON.stringify({ error: "forbidden: loopback only" }));
7706
- return;
7707
- }
7708
- if (!(req.headers["content-type"] ?? "").toLowerCase().includes("application/json")) {
7709
- res.writeHead(415, { "content-type": "application/json; charset=utf-8" });
7710
- res.end(JSON.stringify({ error: "unsupported content-type" }));
7711
- return;
7712
- }
7713
- await refreshPricing();
7714
- res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
7715
- res.end(pricingBody());
7716
- }
7717
- }), "usage-billing: pricing refresh route");
8206
+ registerPricingRoutes(ctx, {
8207
+ trustedHosts,
8208
+ body: pricingBody,
8209
+ refresh: refreshPricing
8210
+ });
7718
8211
  let balanceCache = {
7719
8212
  at: 0,
7720
8213
  doc: { balances: [] }
@@ -7745,7 +8238,9 @@ function apply(ctx, config = {}) {
7745
8238
  let todayOfficialCost = 0;
7746
8239
  try {
7747
8240
  todayOfficialCost = (await aggregator.aggregate()).byDay[today]?.officialCost ?? 0;
7748
- } catch {}
8241
+ } catch {
8242
+ swallowed("reconcile-aggregate-skipped");
8243
+ }
7749
8244
  const result = reconcileBalanceDelta(reconcileRef, official, todayOfficialCost, today, now);
7750
8245
  const refChanged = result.ref !== null && result.ref !== reconcileRef;
7751
8246
  reconcileRef = result.ref;
@@ -7775,80 +8270,22 @@ function apply(ctx, config = {}) {
7775
8270
  });
7776
8271
  return balanceInflight;
7777
8272
  };
7778
- ctx.effect(() => ctx.webServer.register({
7779
- kind: "exact",
7780
- path: "/api/billing/balance",
7781
- handler: async (req, res) => {
7782
- if (!guardLoopback(req, res, trustedHosts)) return;
7783
- res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
7784
- if (Date.now() - balanceCache.at < BALANCE_CACHE_MS) {
7785
- res.end(JSON.stringify(balanceCache.doc));
7786
- return;
7787
- }
7788
- try {
7789
- await refreshBalanceOnce();
7790
- } catch (error) {
7791
- console.warn("[usage-billing] refreshBalances failed; serving cached balances:", error);
7792
- }
7793
- res.end(JSON.stringify(balanceCache.doc));
7794
- }
7795
- }), "usage-billing: balance route");
7796
- ctx.effect(() => ctx.webServer.register({
7797
- kind: "exact",
7798
- path: "/api/billing/usage-tool",
7799
- handler: async (req, res) => {
7800
- if (!guardLoopback(req, res, trustedHosts)) return;
7801
- if (req.method !== "GET" && req.method !== "POST") {
7802
- res.writeHead(405, { "content-type": "application/json; charset=utf-8" });
7803
- res.end(JSON.stringify({ error: "method not allowed" }));
7804
- return;
7805
- }
7806
- res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
7807
- const enabled = usageSettingsScope !== void 0 ? usageSettingsScope.get().enableUsageStatsTool : readUsageStatsToggle(config);
7808
- if (req.method === "GET") {
7809
- res.end(JSON.stringify({ enabled }));
7810
- return;
7811
- }
7812
- if (!isLoopbackOrigin(req.headers.origin)) {
7813
- res.writeHead(403, { "content-type": "application/json; charset=utf-8" });
7814
- res.end(JSON.stringify({ error: "forbidden: loopback only" }));
7815
- return;
7816
- }
7817
- if (!(req.headers["content-type"] ?? "").toLowerCase().includes("application/json")) {
7818
- res.writeHead(415, { "content-type": "application/json; charset=utf-8" });
7819
- res.end(JSON.stringify({ error: "unsupported content-type" }));
7820
- return;
7821
- }
7822
- try {
7823
- let body = "";
7824
- for await (const chunk of req) {
7825
- body += String(chunk);
7826
- if (body.length > 512) {
7827
- res.end(JSON.stringify({ error: "body too large" }));
7828
- return;
7829
- }
7830
- }
7831
- const next = JSON.parse(body === "" ? "{}" : body).enabled === true;
7832
- if (usageSettingsScope !== void 0) {
7833
- await usageSettingsScope.update({ enableUsageStatsTool: next });
7834
- syncUsageTool();
7835
- } else if (liveSettings !== void 0) {
7836
- await liveSettings.update(liveSettings.ns, { [ENABLE_USAGE_STATS_TOOL_FIELD]: next });
7837
- syncUsageTool();
7838
- } else {
7839
- res.end(JSON.stringify({ error: "settings unavailable" }));
7840
- return;
7841
- }
7842
- res.end(JSON.stringify({
7843
- ok: true,
7844
- enabled: next
7845
- }));
7846
- } catch (error) {
7847
- console.warn("[usage-billing] usage-tool toggle write-back failed:", error);
7848
- res.end(JSON.stringify({ error: "invalid" }));
7849
- }
7850
- }
7851
- }), "usage-billing: usage-tool route");
8273
+ registerBalanceRoute(ctx, {
8274
+ trustedHosts,
8275
+ cached: () => balanceCache,
8276
+ refreshOnce: refreshBalanceOnce,
8277
+ cacheMs: BALANCE_CACHE_MS
8278
+ });
8279
+ registerUsageToolRoute(ctx, {
8280
+ trustedHosts,
8281
+ read: () => usageSettingsScope !== void 0 ? usageSettingsScope.get().enableUsageStatsTool : readUsageStatsToggle(config),
8282
+ write: async (next) => {
8283
+ if (usageSettingsScope !== void 0) await usageSettingsScope.update({ enableUsageStatsTool: next });
8284
+ else if (liveSettings !== void 0) await liveSettings.update(liveSettings.ns, { [ENABLE_USAGE_STATS_TOOL_FIELD]: next });
8285
+ else throw new Error("settings unavailable");
8286
+ },
8287
+ syncTool: syncUsageTool
8288
+ });
7852
8289
  let quotaCache = {
7853
8290
  at: 0,
7854
8291
  quotas: []
@@ -7892,20 +8329,12 @@ function apply(ctx, config = {}) {
7892
8329
  });
7893
8330
  return quotasInflight;
7894
8331
  };
7895
- ctx.effect(() => ctx.webServer.register({
7896
- kind: "exact",
7897
- path: "/api/billing/subscriptions",
7898
- handler: async (req, res) => {
7899
- if (!guardLoopback(req, res, trustedHosts)) return;
7900
- res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
7901
- if (Date.now() - quotaCache.at >= SUBSCRIPTION_CACHE_MS) try {
7902
- await refreshQuotasOnce();
7903
- } catch (error) {
7904
- console.warn("[usage-billing] refreshQuotas failed; serving cached quotas:", error);
7905
- }
7906
- res.end(JSON.stringify({ quotas: quotaCache.quotas }));
7907
- }
7908
- }), "usage-billing: subscriptions route");
8332
+ registerSubscriptionsRoute(ctx, {
8333
+ trustedHosts,
8334
+ cached: () => quotaCache,
8335
+ refreshOnce: refreshQuotasOnce,
8336
+ cacheMs: SUBSCRIPTION_CACHE_MS
8337
+ });
7909
8338
  let relayCache = {
7910
8339
  at: 0,
7911
8340
  quotas: []
@@ -7935,81 +8364,22 @@ function apply(ctx, config = {}) {
7935
8364
  });
7936
8365
  return relayInflight;
7937
8366
  };
7938
- ctx.effect(() => ctx.webServer.register({
7939
- kind: "exact",
7940
- path: "/api/billing/relay-quotas",
7941
- handler: async (req, res) => {
7942
- if (!guardLoopback(req, res, trustedHosts)) return;
7943
- res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
7944
- if (Date.now() - relayCache.at >= SUBSCRIPTION_CACHE_MS) try {
7945
- await refreshRelayOnce();
7946
- } catch (error) {
7947
- console.warn("[usage-billing] refreshRelay failed; serving cached relay quotas:", error);
7948
- }
7949
- const diagnostics = relayCache.quotas.map((row) => ({
7950
- route: row.route,
7951
- origin: row.origin,
7952
- kind: row.kind
7953
- }));
7954
- res.end(JSON.stringify({
7955
- quotas: relayCache.quotas,
7956
- diagnostics
7957
- }));
7958
- }
7959
- }), "usage-billing: relay-quotas route");
7960
- let statsBodyCache;
7961
- ctx.effect(() => ctx.webServer.register({
7962
- kind: "exact",
7963
- path: "/api/billing/usage-stats",
7964
- handler: async (req, res) => {
7965
- if (!guardLoopback(req, res, trustedHosts)) return;
7966
- res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
7967
- const decorate = (doc) => ({
7968
- ...doc,
7969
- pluginVersion: PACKAGE_VERSION,
7970
- ...config.monthlyBudget === void 0 ? {} : { budget: config.monthlyBudget },
7971
- ...config.lowBalanceThreshold === void 0 ? {} : { lowBalanceThreshold: config.lowBalanceThreshold }
7972
- });
7973
- const statsBodyOf = (doc) => {
7974
- const budget = config.monthlyBudget;
7975
- const threshold = config.lowBalanceThreshold;
7976
- if (statsBodyCache !== void 0 && statsBodyCache.doc === doc && statsBodyCache.budget === budget && statsBodyCache.threshold === threshold) return statsBodyCache.body;
7977
- const body = JSON.stringify(decorate(doc));
7978
- statsBodyCache = {
7979
- doc,
7980
- budget,
7981
- threshold,
7982
- body
7983
- };
7984
- return body;
7985
- };
7986
- const aggregating = aggregator.aggregate();
7987
- aggregating.then((stats) => {
7988
- persistSnapshot(decorate(stats));
7989
- }, () => {});
7990
- let fresh;
7991
- try {
7992
- fresh = await Promise.race([aggregating.then((stats) => stats), new Promise((resolve) => {
7993
- setTimeout(() => {
7994
- resolve(void 0);
7995
- }, RESPONSE_BUDGET_MS).unref();
7996
- })]);
7997
- } catch (error) {
7998
- console.error("[usage-billing] usage-stats aggregate failed, falling back to snapshot:", error);
7999
- }
8000
- if (fresh !== void 0) {
8001
- res.end(statsBodyOf(fresh));
8002
- return;
8003
- }
8004
- for (const candidate of candidates) {
8005
- const doc = await readSnapshot(candidate);
8006
- if (doc === null) continue;
8007
- res.end(JSON.stringify(decorate(doc)));
8008
- return;
8009
- }
8010
- res.end(JSON.stringify({ error: "usage stats unavailable" }));
8011
- }
8012
- }), "usage-billing: usage-stats route");
8367
+ registerRelayQuotasRoute(ctx, {
8368
+ trustedHosts,
8369
+ cached: () => relayCache,
8370
+ refreshOnce: refreshRelayOnce,
8371
+ cacheMs: SUBSCRIPTION_CACHE_MS
8372
+ });
8373
+ registerUsageStatsRoute(ctx, {
8374
+ trustedHosts,
8375
+ aggregator,
8376
+ config,
8377
+ candidates,
8378
+ persistSnapshot,
8379
+ readSnapshot,
8380
+ packageVersion: PACKAGE_VERSION,
8381
+ responseBudgetMs: RESPONSE_BUDGET_MS
8382
+ });
8013
8383
  }
8014
8384
  //#endregion
8015
- export { Config, adaptSessionPersistence, apply, createFileUsageLedgerStore, guardLoopback, inject, normalizeTrustedHosts, readPiAiProviderRoutes, readRegisteredProviderIds, resolveSubscriptionKeys };
8385
+ export { adaptSessionPersistence, apply, createFileUsageLedgerStore, guardLoopback, hostNameOf, inject, isLoopbackHost, isLoopbackOrigin, isLoopbackPeer, normalizeTrustedHosts, readPiAiProviderRoutes, readRegisteredProviderIds, resolveSubscriptionKeys };