@kenz1117/dsh-ui-usage-billing 1.5.1 → 1.6.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.
Files changed (43) hide show
  1. package/README.en.md +1 -1
  2. package/README.md +1 -1
  3. package/lib/client.js +5 -5
  4. package/lib/index.js +728 -420
  5. package/lib/types/client/TokenPanel.d.ts +1 -1
  6. package/lib/types/client/UsageBilling.d.ts +7 -347
  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/locales.d.ts +1 -1
  31. package/lib/types/client/settings-search.d.ts +12 -0
  32. package/lib/types/config.d.ts +79 -0
  33. package/lib/types/guard.d.ts +49 -0
  34. package/lib/types/index.d.ts +3 -100
  35. package/lib/types/routes/balance.d.ts +37 -0
  36. package/lib/types/routes/notify-claim.d.ts +24 -0
  37. package/lib/types/routes/pricing.d.ts +26 -0
  38. package/lib/types/routes/relay-quotas.d.ts +27 -0
  39. package/lib/types/routes/subscriptions.d.ts +28 -0
  40. package/lib/types/routes/usage-stats.d.ts +44 -0
  41. package/lib/types/routes/usage-tool.d.ts +27 -0
  42. package/lib/types/silent.d.ts +21 -0
  43. package/package.json +14 -7
package/lib/index.js CHANGED
@@ -5157,7 +5157,11 @@ async function fetchDeclaredBody(url, raw, apiKey) {
5157
5157
  if (!response.ok) throw new HttpStatusError("http-error", response.status);
5158
5158
  const text = await response.text();
5159
5159
  if (text.length > MAX_BODY_BYTES) throw new HttpStatusError("body-too-large", 413);
5160
- return JSON.parse(text);
5160
+ try {
5161
+ return JSON.parse(text);
5162
+ } catch (cause) {
5163
+ throw new HttpStatusError("parse-error", 0);
5164
+ }
5161
5165
  } finally {
5162
5166
  clearTimeout(timer);
5163
5167
  }
@@ -5664,6 +5668,38 @@ async function fetchLivePricing() {
5664
5668
  };
5665
5669
  }
5666
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
5667
5703
  //#region lib/types/subscriptions.js
5668
5704
  /**
5669
5705
  * Subscription-plan quota polling (node half): how much of each coding/token
@@ -6063,7 +6099,9 @@ async function collectZai(keys, config, timeoutMs) {
6063
6099
  try {
6064
6100
  subscription = await requestJson(`${host}/api/biz/subscription/list`, init, timeoutMs);
6065
6101
  if (zaiEnvelopeStatus(subscription) !== void 0) subscription = null;
6066
- } catch {}
6102
+ } catch {
6103
+ swallowed("zai-plan-metadata-missing");
6104
+ }
6067
6105
  const parsed = parseZai(quota, subscription);
6068
6106
  return {
6069
6107
  provider: config.provider,
@@ -6831,36 +6869,17 @@ async function queryRelayQuotas(ctx, routes) {
6831
6869
  return await Promise.all(routes.map(async (route) => queryRelayQuota(ctx, route)));
6832
6870
  }
6833
6871
  //#endregion
6834
- //#region lib/types/client/usage-billing-settings.js
6872
+ //#region lib/types/guard.js
6835
6873
  /**
6836
- * usage-stats 工具开关的共享设置契约(node 与 client 两端共用)。
6874
+ * 共享 loopback 守卫(v1.5.2 抽自 src/index.ts)。
6837
6875
  *
6838
- * 旧宿主(≤0.1.6)把该开关存设置命名空间;宿主 0.1.7+ 起注册面移除,改存插件
6839
- * 条目 config 的 volatile 字段(Config schema 投影),写回经 settings.update,
6840
- * 提交后引用原位更新、`usage_stats` 工具即时注册/注销(无需重载应用)。
6841
- * client 半区在「设置」Tab 渲染开关并经 /api/billing/usage-tool 写入,node 半区
6842
- * 按宿主世代选择通道。缺省的默认行为是关闭——避免该工具默认占用模型每次请求
6843
- * 的上下文(coding 场景通常在仪表盘看用量)。
6844
- */
6845
- /** 设置命名空间 id(小写 kebab-case)。 */
6846
- const BILLING_SETTINGS_NAMESPACE = "ui-usage-billing";
6847
- /** 该命名空间下用户可编辑的字段名。 */
6848
- const ENABLE_USAGE_STATS_TOOL_FIELD = "enableUsageStatsTool";
6849
- //#endregion
6850
- //#region lib/types/index.js
6851
- /**
6852
- * Usage billing surface plugin, node half.
6876
+ * 端点只供本机浏览器用,局域网/远端请求一律拒绝,避免面板数据(含中转站
6877
+ * origin 与余额)外泄。三层防御:peer socket(最可信)+ Host 头精确字面量
6878
+ * (防 `127.0.0.1.evil.com` 这类 DNS rebinding 域名)+ trustedHosts 白名单
6879
+ * (反向代理场景的精确主机名放行)。Origin 头独立校验(写操作用)。
6853
6880
  *
6854
- * Serves `/api/billing/usage-stats`: real usage aggregated from every
6855
- * persisted session log (see `aggregate.ts`) — the browser dashboard reads it
6856
- * instead of showing an empty snapshot. Aggregation is stale-while-revalidate:
6857
- * requests get a short wait budget and fall back to the freshest persisted
6858
- * snapshot while a slow full fold keeps running in the background (a heavy
6859
- * user's first fold takes minutes and must not starve the single-process
6860
- * host's RPC). When `sessionPersistence` is unavailable (or aggregation
6861
- * fails), the configured `statsPath` / `DSH_USAGE_STATS` / conventional JSON
6862
- * file is served as a fallback, and a missing file answers `{ error }` so the
6863
- * dashboard shows zeros, never fabricated samples.
6881
+ * 设计要点:精确字面量匹配(`startsWith('127.')` 会被 `127.0.0.1.evil.com`
6882
+ * 穿透,必须正则)。IPv6 字面量带方括号(`[::1]:3080`)单独剥离。
6864
6883
  */
6865
6884
  /** Peer 地址是否为回环(本地)。回环防护:本插件的端点只供本机浏览器用,
6866
6885
  * 局域网/远端请求一律拒绝,避免面板数据(含中转站 origin 与余额)外泄。 */
@@ -6896,6 +6915,9 @@ function normalizeTrustedHosts(hosts) {
6896
6915
  const names = hosts.filter((host) => typeof host === "string").map((host) => hostNameOf(host)).filter((host) => host !== "");
6897
6916
  return new Set(names);
6898
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 的字面量。 */
6899
6921
  function isLoopbackHost(req, trustedHosts = /* @__PURE__ */ new Set()) {
6900
6922
  const host = req.headers.host;
6901
6923
  if (host === void 0 || host === "") return true;
@@ -6936,144 +6958,627 @@ function guardLoopback(req, res, trustedHosts = /* @__PURE__ */ new Set()) {
6936
6958
  }
6937
6959
  return true;
6938
6960
  }
6961
+ //#endregion
6962
+ //#region lib/types/routes/notify-claim.js
6939
6963
  /**
6940
- * 设置命名空间校验(上游 alpha.1 `settingsNamespace` 的本地化,issue #28):
6941
- * alpha.2 起该函数不再从 `@deepseek-ai/dsh-settings` 导出,但上游内部保留
6942
- * 同一 pattern——本地按同一规则校验,预览宿主 API 漂移不再影响插件装载。
6943
- */
6944
- const SETTINGS_NAMESPACE_PATTERN = /^[a-z][a-z0-9-]*$/;
6945
- /** 校验并透过合法命名空间 id;非法值 fail-loud(与上游原行为一致)。 */
6946
- function validateSettingsNamespace(value) {
6947
- if (!SETTINGS_NAMESPACE_PATTERN.test(value)) throw new TypeError(`settings namespace "${value}" must match ${String(SETTINGS_NAMESPACE_PATTERN)}`);
6948
- return value;
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
+ }
7007
+ }
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;
7013
+ }
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 }), {
7031
+ mode: 384,
7032
+ dirMode: 448
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" }));
7039
+ }
7040
+ }
7041
+ }), "usage-billing: notify-claim route");
6949
7042
  }
6950
- /** usage_stats 工具开关的设置命名空间 id(下端与 node 共用同一常量)。 */
6951
- const usageBillingSettingsNs = validateSettingsNamespace(BILLING_SETTINGS_NAMESPACE);
6952
- /** 该命名空间的 wire schema:`enableUsageStatsTool` 布尔,默认关闭(issue 诉求)。 */
6953
- const UsageBillingSettingsSchema = z.object({ [ENABLE_USAGE_STATS_TOOL_FIELD]: z.boolean().default(false) });
6954
- /**
6955
- * Loader 挂载时对条目 config 做校验的 Config 声明:`enableUsageStatsTool` 标记
6956
- * volatile,宿主 0.1.7+ 设置系统据此把它投影为可实时编辑的开关(describe/update)。
6957
- * schemastery 非严格 object 透传未声明键,其余 config 字段不受影响;未导出 Config
6958
- * 的旧宿主直接把原始 config 交给 apply,普通布尔形态由读取端兼容。
6959
- */
6960
- const Config = z.object({ [ENABLE_USAGE_STATS_TOOL_FIELD]: z.boolean().default(false).volatile() });
6961
- /** 实时定价的后台刷新间隔(毫秒):汇率/模型价低频变化,6 小时一次足够。 */
6962
- const PRICING_REFRESH_INTERVAL_MS = 216e5;
6963
- /** 历史回放预热的延迟:等宿主启动高峰(插件加载 / 路由挂载)过去再全量折叠,
6964
- * 避免抢启动期的 CPU;纯延迟不阻塞任何请求,面板提前打开也只会提前聚合。 */
6965
- const WARMUP_DELAY_MS = 3e3;
6966
- /** 订阅套餐额度缓存时长(毫秒):上游配额 API 低频变化,5 分钟足够。 */
6967
- const SUBSCRIPTION_CACHE_MS = 3e5;
6968
- const BALANCE_CACHE_MS = 3e5;
6969
- /** DeepSeek 余额查询的默认凭据引用(与 llm-deepseek 的默认引用一致)。 */
6970
- const DEFAULT_BALANCE_API_KEY_ENV = "DEEPSEEK_API_KEY";
6971
- /**
6972
- * 本插件版本号:从包自身的 package.json 读取(单一来源),随 usage-stats
6973
- * 下发,供「设置 → 插件信息卡」展示。发布版 lib/index.js 相对包根解析。
6974
- */
6975
- const PACKAGE_VERSION = createRequire(import.meta.url)("../package.json").version ?? "0.0.0";
6976
- /** 统计快照的落盘节流(毫秒):快照只服务聚合失败回退与重启首屏(展示级
6977
- * 新鲜度即可),降到 5 分钟一次——原子写含上一版备份是两次全量 JSON 落盘,
6978
- * 重度用户快照上百 KB,跟随前端轮询 30 秒写一次纯属写放大。 */
6979
- const SNAPSHOT_INTERVAL_MS = 3e5;
6980
- /** usage-stats 响应的等待预算(毫秒):聚合在此预算内完成就回实时数据,
6981
- * 超预算立即回最近快照(stale-while-revalidate),后台折叠继续跑。
6982
- * 轻量用户暖缓存下的聚合是毫秒级,预算几乎不会触发。 */
6983
- const RESPONSE_BUDGET_MS = 1500;
6984
- /** 鉴权失败告警冷却(毫秒):同一 provider 在窗口内只提示一次,避免 30 秒轮询刷屏。 */
6985
- const AUTH_WARN_COOLDOWN_MS = 18e5;
7043
+ //#endregion
7044
+ //#region lib/types/routes/pricing.js
6986
7045
  /**
6987
- * 鉴权失败分类告警(P1-5):余额 / 订阅查询返回 unauthorized 时,按
6988
- * `source:provider` 去重并冷却告警,提示检查 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()` 触发。
6989
7058
  */
6990
- const authWarnedAt = /* @__PURE__ */ new Map();
6991
- function warnAuthOnce(source, provider, displayName) {
6992
- const key = `${source}:${provider}`;
6993
- const now = Date.now();
6994
- const last = authWarnedAt.get(key);
6995
- if (last !== void 0 && now - last < AUTH_WARN_COOLDOWN_MS) return;
6996
- authWarnedAt.set(key, now);
6997
- console.warn(`[usage-billing] ${displayName}(${provider})鉴权失败:请检查 llm-pi-ai 设置中该 provider 的凭据环境变量是否正确/有效。`);
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");
6998
7070
  }
6999
- /** 锁等待上限:账本提交是毫秒级 read-render-commit,2s 已数倍于正常持锁时长。 */
7000
- const LEDGER_LOCK_WAIT_MS = 2e3;
7001
- /**
7002
- * 锁年龄超过该值判为崩溃残留的孤儿锁。正常持锁是毫秒级文件提交,没有活跃
7003
- * writer 会合法持有 60s;库层刻意不做年龄判定(它无法假设调用方的操作时长),
7004
- * 本调用方知道自己操作的上界,可以给出有依据的清理阈值。
7005
- */
7006
- const STALE_LEDGER_LOCK_MS = 6e4;
7007
- /**
7008
- * 锁等待超时后的孤儿锁自愈(issue #48):仅当锁文件年龄超过
7009
- * STALE_LEDGER_LOCK_MS 才清理,避免误删慢 writer 的活跃锁。
7010
- * @param ledgerPath - 账本文件路径(锁为其 `.lock` 兄弟文件)。
7011
- * @returns 是否清理了孤儿锁;false = 锁已消失或太新鲜,调用方上抛原错误。
7012
- */
7013
- async function reclaimStaleLedgerLock(ledgerPath) {
7014
- let ageMs;
7015
- try {
7016
- ageMs = Date.now() - (await lstat(`${ledgerPath}.lock`)).mtimeMs;
7017
- } catch {
7018
- return false;
7019
- }
7020
- if (ageMs < STALE_LEDGER_LOCK_MS) return false;
7021
- console.warn(`[usage-billing] 账本锁已存在 ${Math.round(ageMs / 1e3)}s(崩溃残留),清理后重试:${ledgerPath}.lock`);
7022
- await rm(`${ledgerPath}.lock`, { force: true });
7023
- return true;
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);
7024
7103
  }
7104
+ //#endregion
7105
+ //#region lib/types/routes/balance.js
7025
7106
  /**
7026
- * Create the atomic file-backed durable-ledger store. The previous complete file
7027
- * is retained as `.bak`; a malformed/missing main file falls back to that backup.
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()` 拿到结果。
7028
7117
  */
7029
- function createFileUsageLedgerStore(ledgerPath) {
7030
- return {
7031
- async load() {
7032
- for (const path of [ledgerPath, `${ledgerPath}.bak`]) try {
7033
- const parsed = JSON.parse(await readFile(path, "utf8"));
7034
- if (parsed !== null && typeof parsed === "object") {
7035
- const candidate = parsed;
7036
- if (candidate.version === 1 && Array.isArray(candidate.sessions)) return parsed;
7037
- }
7038
- } catch {}
7039
- },
7040
- async save(document) {
7041
- await mkdir(dirname(ledgerPath), {
7042
- recursive: true,
7043
- mode: 448
7044
- });
7045
- const commit = async () => {
7046
- let existing;
7047
- let existingDoc;
7048
- try {
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), {
7061
- mode: 384,
7062
- dirMode: 448
7063
- });
7064
- };
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
+ }
7065
7129
  try {
7066
- await withFileLock(ledgerPath, commit, { waitMs: LEDGER_LOCK_WAIT_MS });
7130
+ await deps.refreshOnce();
7067
7131
  } catch (error) {
7068
- if (!(error instanceof Error) || !error.message.includes("timed out waiting for the writer lock")) throw error;
7069
- if (!await reclaimStaleLedgerLock(ledgerPath)) throw error;
7070
- await withFileLock(ledgerPath, commit, { waitMs: LEDGER_LOCK_WAIT_MS });
7132
+ console.warn("[usage-billing] refreshBalances failed; serving cached balances:", error);
7071
7133
  }
7134
+ res.end(JSON.stringify(deps.cached().doc));
7072
7135
  }
7073
- };
7136
+ }), "usage-billing: balance route");
7074
7137
  }
7075
- /** Required services: the web server, the persisted session log store, and user settings. */
7076
- const inject = [
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 = [
7077
7582
  "webServer",
7078
7583
  "sessionPersistence",
7079
7584
  "credentials",
@@ -7279,7 +7784,9 @@ async function resolveSubscriptionKeys(settings, credentials) {
7279
7784
  try {
7280
7785
  const resolved = await credentials.resolve(credentialRef(env));
7281
7786
  if (resolved?.value !== void 0 && resolved.value !== "") keys[key] = resolved.value;
7282
- } catch {}
7787
+ } catch (error) {
7788
+ swallowed("credential-resolve-failed", error);
7789
+ }
7283
7790
  }
7284
7791
  if (providers?.["zai-coding-cn"]?.apiKeyEnv !== void 0 && keys.zaiCnApiKey !== "") keys.zaiRegion = "bigmodel-cn";
7285
7792
  if (keys.opencodeApiKey === "") keys.opencodeApiKey = await readOpenCodeToken();
@@ -7305,7 +7812,9 @@ async function resolveSubscriptionKeys(settings, credentials) {
7305
7812
  async function readOpenCodeToken() {
7306
7813
  try {
7307
7814
  return parseOpenCodeAuthDocument(JSON.parse(await readFile(join(homedir(), ".local", "share", "opencode", "auth.json"), "utf8")));
7308
- } catch {}
7815
+ } catch {
7816
+ swallowed("opencode-auth-missing");
7817
+ }
7309
7818
  return "";
7310
7819
  }
7311
7820
  /**
@@ -7397,7 +7906,9 @@ function apply(ctx, config = {}) {
7397
7906
  const candidate = ref;
7398
7907
  if (typeof candidate.date === "string" && typeof candidate.total === "number" && typeof candidate.granted === "number" && typeof candidate.topped === "number" && typeof candidate.currency === "string") reconcileRef = candidate;
7399
7908
  }
7400
- } catch {}
7909
+ } catch {
7910
+ swallowed("reconcile-ref-missing");
7911
+ }
7401
7912
  })();
7402
7913
  const persistReconcileRef = () => {
7403
7914
  if (reconcileRef === null) return;
@@ -7466,83 +7977,32 @@ function apply(ctx, config = {}) {
7466
7977
  mode: 384,
7467
7978
  dirMode: 448
7468
7979
  });
7469
- } catch {}
7980
+ } catch {
7981
+ swallowed("snapshot-bak-missing");
7982
+ }
7470
7983
  try {
7471
7984
  await writeFileAtomic(snapshotPath, payload, {
7472
7985
  mode: 384,
7473
7986
  dirMode: 448
7474
7987
  });
7475
- } catch {}
7988
+ } catch {
7989
+ swallowed("snapshot-write-failed");
7990
+ }
7476
7991
  })();
7477
7992
  };
7478
7993
  /** 读一个快照候选并解析成对象:主文件优先;主文件损坏时回退上一版 `.bak`(P0-3 崩溃恢复)。 */
7479
7994
  const readSnapshot = async (candidate) => {
7480
7995
  for (const path of [candidate, `${candidate}.bak`]) try {
7481
7996
  return JSON.parse(await readFile(path, "utf8"));
7482
- } catch {}
7997
+ } catch {
7998
+ swallowed("snapshot-candidate-unreadable");
7999
+ }
7483
8000
  return null;
7484
8001
  };
7485
- const notifyClaimsPath = `${snapshotPath}.notify-claims.json`;
7486
- const notifyClaims = {
7487
- loaded: false,
7488
- keys: []
7489
- };
7490
- ctx.effect(() => ctx.webServer.register({
7491
- kind: "exact",
7492
- path: "/api/billing/notify-claim",
7493
- handler: async (req, res) => {
7494
- if (!guardLoopback(req, res, trustedHosts)) return;
7495
- if (req.method !== "POST") {
7496
- res.writeHead(405, { "content-type": "application/json; charset=utf-8" });
7497
- res.end(JSON.stringify({ error: "method not allowed" }));
7498
- return;
7499
- }
7500
- if (!isLoopbackOrigin(req.headers.origin) || !(req.headers["content-type"] ?? "").toLowerCase().includes("application/json")) {
7501
- res.writeHead(403, { "content-type": "application/json; charset=utf-8" });
7502
- res.end(JSON.stringify({ error: "forbidden: loopback only" }));
7503
- return;
7504
- }
7505
- try {
7506
- let body = "";
7507
- for await (const chunk of req) {
7508
- body += String(chunk);
7509
- if (body.length > 2048) {
7510
- res.end(JSON.stringify({ error: "body too large" }));
7511
- return;
7512
- }
7513
- }
7514
- const parsed = JSON.parse(body === "" ? "{}" : body);
7515
- if (typeof parsed.key !== "string" || parsed.key === "" || parsed.key.length > 200) {
7516
- res.writeHead(400, { "content-type": "application/json; charset=utf-8" });
7517
- res.end(JSON.stringify({ error: "invalid key" }));
7518
- return;
7519
- }
7520
- if (!notifyClaims.loaded) {
7521
- notifyClaims.loaded = true;
7522
- try {
7523
- const stored = JSON.parse(await readFile(notifyClaimsPath, "utf8"));
7524
- if (Array.isArray(stored.keys)) notifyClaims.keys = stored.keys.filter((k) => typeof k === "string");
7525
- } catch {}
7526
- }
7527
- if (notifyClaims.keys.includes(parsed.key)) {
7528
- res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
7529
- res.end(JSON.stringify({ claimed: false }));
7530
- return;
7531
- }
7532
- notifyClaims.keys.push(parsed.key);
7533
- if (notifyClaims.keys.length > 400) notifyClaims.keys = notifyClaims.keys.slice(-200);
7534
- writeFileAtomic(notifyClaimsPath, JSON.stringify({ keys: notifyClaims.keys }), {
7535
- mode: 384,
7536
- dirMode: 448
7537
- }).catch(() => {});
7538
- res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
7539
- res.end(JSON.stringify({ claimed: true }));
7540
- } catch {
7541
- res.writeHead(400, { "content-type": "application/json; charset=utf-8" });
7542
- res.end(JSON.stringify({ error: "invalid" }));
7543
- }
7544
- }
7545
- }), "usage-billing: notify-claim route");
8002
+ registerNotifyClaimRoute(ctx, {
8003
+ trustedHosts,
8004
+ claimsPath: `${snapshotPath}.notify-claims.json`
8005
+ });
7546
8006
  let usageToolDisposer;
7547
8007
  let usageToolRegister;
7548
8008
  const syncUsageTool = () => {
@@ -7743,40 +8203,11 @@ function apply(ctx, config = {}) {
7743
8203
  clearInterval(timer);
7744
8204
  };
7745
8205
  }, "usage-billing: pricing refresh timer");
7746
- ctx.effect(() => ctx.webServer.register({
7747
- kind: "exact",
7748
- path: "/api/billing/pricing",
7749
- handler: async (req, res) => {
7750
- if (!guardLoopback(req, res, trustedHosts)) return;
7751
- res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
7752
- res.end(pricingBody());
7753
- }
7754
- }), "usage-billing: pricing route");
7755
- ctx.effect(() => ctx.webServer.register({
7756
- kind: "exact",
7757
- path: "/api/billing/pricing/refresh",
7758
- handler: async (req, res) => {
7759
- if (!guardLoopback(req, res, trustedHosts)) return;
7760
- if (req.method !== "POST") {
7761
- res.writeHead(405, { "content-type": "application/json; charset=utf-8" });
7762
- res.end(JSON.stringify({ error: "method not allowed" }));
7763
- return;
7764
- }
7765
- if (!isLoopbackOrigin(req.headers.origin)) {
7766
- res.writeHead(403, { "content-type": "application/json; charset=utf-8" });
7767
- res.end(JSON.stringify({ error: "forbidden: loopback only" }));
7768
- return;
7769
- }
7770
- if (!(req.headers["content-type"] ?? "").toLowerCase().includes("application/json")) {
7771
- res.writeHead(415, { "content-type": "application/json; charset=utf-8" });
7772
- res.end(JSON.stringify({ error: "unsupported content-type" }));
7773
- return;
7774
- }
7775
- await refreshPricing();
7776
- res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
7777
- res.end(pricingBody());
7778
- }
7779
- }), "usage-billing: pricing refresh route");
8206
+ registerPricingRoutes(ctx, {
8207
+ trustedHosts,
8208
+ body: pricingBody,
8209
+ refresh: refreshPricing
8210
+ });
7780
8211
  let balanceCache = {
7781
8212
  at: 0,
7782
8213
  doc: { balances: [] }
@@ -7807,7 +8238,9 @@ function apply(ctx, config = {}) {
7807
8238
  let todayOfficialCost = 0;
7808
8239
  try {
7809
8240
  todayOfficialCost = (await aggregator.aggregate()).byDay[today]?.officialCost ?? 0;
7810
- } catch {}
8241
+ } catch {
8242
+ swallowed("reconcile-aggregate-skipped");
8243
+ }
7811
8244
  const result = reconcileBalanceDelta(reconcileRef, official, todayOfficialCost, today, now);
7812
8245
  const refChanged = result.ref !== null && result.ref !== reconcileRef;
7813
8246
  reconcileRef = result.ref;
@@ -7837,80 +8270,22 @@ function apply(ctx, config = {}) {
7837
8270
  });
7838
8271
  return balanceInflight;
7839
8272
  };
7840
- ctx.effect(() => ctx.webServer.register({
7841
- kind: "exact",
7842
- path: "/api/billing/balance",
7843
- handler: async (req, res) => {
7844
- if (!guardLoopback(req, res, trustedHosts)) return;
7845
- res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
7846
- if (Date.now() - balanceCache.at < BALANCE_CACHE_MS) {
7847
- res.end(JSON.stringify(balanceCache.doc));
7848
- return;
7849
- }
7850
- try {
7851
- await refreshBalanceOnce();
7852
- } catch (error) {
7853
- console.warn("[usage-billing] refreshBalances failed; serving cached balances:", error);
7854
- }
7855
- res.end(JSON.stringify(balanceCache.doc));
7856
- }
7857
- }), "usage-billing: balance route");
7858
- ctx.effect(() => ctx.webServer.register({
7859
- kind: "exact",
7860
- path: "/api/billing/usage-tool",
7861
- handler: async (req, res) => {
7862
- if (!guardLoopback(req, res, trustedHosts)) return;
7863
- if (req.method !== "GET" && req.method !== "POST") {
7864
- res.writeHead(405, { "content-type": "application/json; charset=utf-8" });
7865
- res.end(JSON.stringify({ error: "method not allowed" }));
7866
- return;
7867
- }
7868
- res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
7869
- const enabled = usageSettingsScope !== void 0 ? usageSettingsScope.get().enableUsageStatsTool : readUsageStatsToggle(config);
7870
- if (req.method === "GET") {
7871
- res.end(JSON.stringify({ enabled }));
7872
- return;
7873
- }
7874
- if (!isLoopbackOrigin(req.headers.origin)) {
7875
- res.writeHead(403, { "content-type": "application/json; charset=utf-8" });
7876
- res.end(JSON.stringify({ error: "forbidden: loopback only" }));
7877
- return;
7878
- }
7879
- if (!(req.headers["content-type"] ?? "").toLowerCase().includes("application/json")) {
7880
- res.writeHead(415, { "content-type": "application/json; charset=utf-8" });
7881
- res.end(JSON.stringify({ error: "unsupported content-type" }));
7882
- return;
7883
- }
7884
- try {
7885
- let body = "";
7886
- for await (const chunk of req) {
7887
- body += String(chunk);
7888
- if (body.length > 512) {
7889
- res.end(JSON.stringify({ error: "body too large" }));
7890
- return;
7891
- }
7892
- }
7893
- const next = JSON.parse(body === "" ? "{}" : body).enabled === true;
7894
- if (usageSettingsScope !== void 0) {
7895
- await usageSettingsScope.update({ enableUsageStatsTool: next });
7896
- syncUsageTool();
7897
- } else if (liveSettings !== void 0) {
7898
- await liveSettings.update(liveSettings.ns, { [ENABLE_USAGE_STATS_TOOL_FIELD]: next });
7899
- syncUsageTool();
7900
- } else {
7901
- res.end(JSON.stringify({ error: "settings unavailable" }));
7902
- return;
7903
- }
7904
- res.end(JSON.stringify({
7905
- ok: true,
7906
- enabled: next
7907
- }));
7908
- } catch (error) {
7909
- console.warn("[usage-billing] usage-tool toggle write-back failed:", error);
7910
- res.end(JSON.stringify({ error: "invalid" }));
7911
- }
7912
- }
7913
- }), "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
+ });
7914
8289
  let quotaCache = {
7915
8290
  at: 0,
7916
8291
  quotas: []
@@ -7954,20 +8329,12 @@ function apply(ctx, config = {}) {
7954
8329
  });
7955
8330
  return quotasInflight;
7956
8331
  };
7957
- ctx.effect(() => ctx.webServer.register({
7958
- kind: "exact",
7959
- path: "/api/billing/subscriptions",
7960
- handler: async (req, res) => {
7961
- if (!guardLoopback(req, res, trustedHosts)) return;
7962
- res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
7963
- if (Date.now() - quotaCache.at >= SUBSCRIPTION_CACHE_MS) try {
7964
- await refreshQuotasOnce();
7965
- } catch (error) {
7966
- console.warn("[usage-billing] refreshQuotas failed; serving cached quotas:", error);
7967
- }
7968
- res.end(JSON.stringify({ quotas: quotaCache.quotas }));
7969
- }
7970
- }), "usage-billing: subscriptions route");
8332
+ registerSubscriptionsRoute(ctx, {
8333
+ trustedHosts,
8334
+ cached: () => quotaCache,
8335
+ refreshOnce: refreshQuotasOnce,
8336
+ cacheMs: SUBSCRIPTION_CACHE_MS
8337
+ });
7971
8338
  let relayCache = {
7972
8339
  at: 0,
7973
8340
  quotas: []
@@ -7997,81 +8364,22 @@ function apply(ctx, config = {}) {
7997
8364
  });
7998
8365
  return relayInflight;
7999
8366
  };
8000
- ctx.effect(() => ctx.webServer.register({
8001
- kind: "exact",
8002
- path: "/api/billing/relay-quotas",
8003
- handler: async (req, res) => {
8004
- if (!guardLoopback(req, res, trustedHosts)) return;
8005
- res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
8006
- if (Date.now() - relayCache.at >= SUBSCRIPTION_CACHE_MS) try {
8007
- await refreshRelayOnce();
8008
- } catch (error) {
8009
- console.warn("[usage-billing] refreshRelay failed; serving cached relay quotas:", error);
8010
- }
8011
- const diagnostics = relayCache.quotas.map((row) => ({
8012
- route: row.route,
8013
- origin: row.origin,
8014
- kind: row.kind
8015
- }));
8016
- res.end(JSON.stringify({
8017
- quotas: relayCache.quotas,
8018
- diagnostics
8019
- }));
8020
- }
8021
- }), "usage-billing: relay-quotas route");
8022
- let statsBodyCache;
8023
- ctx.effect(() => ctx.webServer.register({
8024
- kind: "exact",
8025
- path: "/api/billing/usage-stats",
8026
- handler: async (req, res) => {
8027
- if (!guardLoopback(req, res, trustedHosts)) return;
8028
- res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
8029
- const decorate = (doc) => ({
8030
- ...doc,
8031
- pluginVersion: PACKAGE_VERSION,
8032
- ...config.monthlyBudget === void 0 ? {} : { budget: config.monthlyBudget },
8033
- ...config.lowBalanceThreshold === void 0 ? {} : { lowBalanceThreshold: config.lowBalanceThreshold }
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
- };
8048
- const aggregating = aggregator.aggregate();
8049
- aggregating.then((stats) => {
8050
- persistSnapshot(decorate(stats));
8051
- }, () => {});
8052
- let fresh;
8053
- try {
8054
- fresh = await Promise.race([aggregating.then((stats) => stats), new Promise((resolve) => {
8055
- setTimeout(() => {
8056
- resolve(void 0);
8057
- }, RESPONSE_BUDGET_MS).unref();
8058
- })]);
8059
- } catch (error) {
8060
- console.error("[usage-billing] usage-stats aggregate failed, falling back to snapshot:", error);
8061
- }
8062
- if (fresh !== void 0) {
8063
- res.end(statsBodyOf(fresh));
8064
- return;
8065
- }
8066
- for (const candidate of candidates) {
8067
- const doc = await readSnapshot(candidate);
8068
- if (doc === null) continue;
8069
- res.end(JSON.stringify(decorate(doc)));
8070
- return;
8071
- }
8072
- res.end(JSON.stringify({ error: "usage stats unavailable" }));
8073
- }
8074
- }), "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
+ });
8075
8383
  }
8076
8384
  //#endregion
8077
- 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 };