@kenz1117/dsh-ui-usage-billing 0.9.6 → 0.9.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -5,6 +5,8 @@ import { join } from "node:path";
5
5
  import { defineTool } from "@deepseek-ai/dsh-tools";
6
6
  import { writeFileAtomic } from "@deepseek-ai/dsh-atomic-write";
7
7
  import { credentialRef } from "@deepseek-ai/dsh-credentials";
8
+ import { settingsNamespace } from "@deepseek-ai/dsh-settings";
9
+ import z from "@deepseek-ai/schemastery";
8
10
  //#region lib/types/client/plan-knowledge.js
9
11
  /**
10
12
  * Plan-knowledge reference (adapted from dsh-spend's `knowledge.js`, MIT):
@@ -28,7 +30,11 @@ import { credentialRef } from "@deepseek-ai/dsh-credentials";
28
30
  const PLAN_KNOWLEDGE = {
29
31
  "opencode-go": {
30
32
  type: "code",
31
- subscriptionCny: 70,
33
+ subscription: {
34
+ amount: 10,
35
+ currency: "USD",
36
+ period: "month"
37
+ },
32
38
  tier: {
33
39
  amount: 10,
34
40
  currency: "USD",
@@ -38,7 +44,11 @@ const PLAN_KNOWLEDGE = {
38
44
  },
39
45
  opencode: {
40
46
  type: "code",
41
- subscriptionCny: 70,
47
+ subscription: {
48
+ amount: 10,
49
+ currency: "USD",
50
+ period: "month"
51
+ },
42
52
  tier: {
43
53
  amount: 10,
44
54
  currency: "USD",
@@ -46,67 +56,59 @@ const PLAN_KNOWLEDGE = {
46
56
  label: "周额度 $30"
47
57
  }
48
58
  },
49
- "kimi-coding": {
50
- type: "code",
51
- subscriptionCny: 0
52
- },
53
- "zai-coding-cn": {
54
- type: "code",
55
- subscriptionCny: 0
56
- },
57
- "zai-coding": {
58
- type: "code",
59
- subscriptionCny: 0
60
- },
61
- "qwen-token-plan": {
62
- type: "code",
63
- subscriptionCny: 0
64
- },
65
- "qwen-token-plan-cn": {
66
- type: "code",
67
- subscriptionCny: 0
68
- },
69
- "xiaomi-token-plan-ams": {
70
- type: "code",
71
- subscriptionCny: 0
72
- },
73
- "xiaomi-token-plan-cn": {
74
- type: "code",
75
- subscriptionCny: 0
76
- },
77
- "xiaomi-token-plan-sgp": {
78
- type: "code",
79
- subscriptionCny: 0
80
- },
81
- "volcengine-token-plan": {
82
- type: "code",
83
- subscriptionCny: 0
84
- },
85
- "ark-token-plan": {
86
- type: "code",
87
- subscriptionCny: 0
88
- },
89
- "doubao-token-plan": {
90
- type: "code",
91
- subscriptionCny: 0
92
- },
93
- "minimax": {
94
- type: "code",
95
- subscriptionCny: 0
96
- },
97
- "minimax-token-plan": {
98
- type: "code",
99
- subscriptionCny: 0
100
- }
59
+ "kimi-coding": { type: "code" },
60
+ "zai-coding-cn": { type: "code" },
61
+ "zai-coding": { type: "code" },
62
+ "qwen-token-plan": { type: "code" },
63
+ "qwen-token-plan-cn": { type: "code" },
64
+ "xiaomi-token-plan-ams": { type: "code" },
65
+ "xiaomi-token-plan-cn": { type: "code" },
66
+ "xiaomi-token-plan-sgp": { type: "code" },
67
+ "volcengine-token-plan": { type: "code" },
68
+ "ark-token-plan": { type: "code" },
69
+ "doubao-token-plan": { type: "code" },
70
+ "minimax": { type: "code" },
71
+ "minimax-token-plan": { type: "code" }
72
+ };
73
+ /**
74
+ * 订阅/plan provider id 变体 → PLAN_KNOWLEDGE 规范键(引用 dsh-spend 的别名归一化)。
75
+ * 部署配置的订阅 provider id 写法不一(glm/zhipu/bigmodel、ark/volcengine、
76
+ * kimi/moonshot、xiaomi…),先归一化再匹配,提升"自动识别"覆盖率。
77
+ * 注意:裸 qwen/dashscope/tongyi 等是按量 API(token 计费)而非订阅,不归一到
78
+ * 订阅键——只有显式 token-plan 后缀才由 SUBSCRIPTION_ID_RE 判定为订阅。
79
+ */
80
+ const PLAN_PROVIDER_ALIASES = {
81
+ "glm": "zai-coding-cn",
82
+ "bigmodel": "zai-coding-cn",
83
+ "zhipu": "zai-coding-cn",
84
+ "zhipuai": "zai-coding-cn",
85
+ "zai": "zai-coding",
86
+ "ark": "volcengine-token-plan",
87
+ "volcengine": "volcengine-token-plan",
88
+ "doubao": "doubao-token-plan",
89
+ "moonshot": "kimi-coding",
90
+ "kimi": "kimi-coding",
91
+ "xiaomi": "xiaomi-token-plan-cn",
92
+ "opencode": "opencode-go"
101
93
  };
94
+ /** 归一化订阅 provider id:别名命中则映射到规范键,否则原样返回。 */
95
+ function normalizePlanProvider(providerId) {
96
+ if (providerId === "") return providerId;
97
+ return PLAN_PROVIDER_ALIASES[providerId] ?? providerId;
98
+ }
102
99
  /** provider id(llm-pi-ai 设置键)→ plan 知识;未命中默认 token。 */
103
100
  function planTypeOf(providerId) {
104
- return PLAN_KNOWLEDGE[providerId]?.type ?? "token";
101
+ return PLAN_KNOWLEDGE[normalizePlanProvider(providerId)]?.type ?? "token";
105
102
  }
106
- /** 订阅月费(人民币元);非 code 或未配置时为 0。 */
107
- function subscriptionCnyOf(providerId) {
108
- const entry = PLAN_KNOWLEDGE[providerId];
109
- return entry?.type === "code" ? entry.subscriptionCny ?? 0 : 0;
103
+ /**
104
+ * 订阅月费折算为人民币:原生币 × 实时汇率(USD→CNY)。汇率缺失时返回 0,
105
+ * 避免用假设汇率造成失真(跨币种保护:金额统一折成 CNY 再相加)。
106
+ */
107
+ function subscriptionFeeCnyOf(providerId, rate) {
108
+ const mount = PLAN_KNOWLEDGE[normalizePlanProvider(providerId)]?.subscription;
109
+ if (mount === void 0) return 0;
110
+ if (mount.currency === "CNY") return mount.amount;
111
+ return rate !== void 0 && rate > 0 ? mount.amount * rate : 0;
110
112
  }
111
113
  const FALLBACK_RATES = [
112
114
  {
@@ -1287,6 +1289,7 @@ function percentile(values, p) {
1287
1289
  function createUsageAggregator(persistence, options = {}) {
1288
1290
  const subscriptionProviders = new Set(options.subscriptionProviders ?? DEFAULT_SUBSCRIPTION_PROVIDERS);
1289
1291
  const officialProviderIds = options.officialProviderIds === void 0 ? void 0 : new Set(options.officialProviderIds);
1292
+ const maxCacheSessions = options.maxCacheSessions ?? 400;
1290
1293
  const cache = /* @__PURE__ */ new Map();
1291
1294
  let lastDoc;
1292
1295
  let lastAt = 0;
@@ -1314,6 +1317,8 @@ function createUsageAggregator(persistence, options = {}) {
1314
1317
  const stamp = await stampOf(meta);
1315
1318
  const hit = cache.get(id);
1316
1319
  if (hit !== void 0 && stamp !== null && hit.stamp === stamp) {
1320
+ cache.delete(id);
1321
+ cache.set(id, hit);
1317
1322
  folds.push({
1318
1323
  meta,
1319
1324
  fold: hit.fold
@@ -1322,6 +1327,8 @@ function createUsageAggregator(persistence, options = {}) {
1322
1327
  }
1323
1328
  try {
1324
1329
  const { events } = await persistence.readFrom(meta.id, 0);
1330
+ const after = await stampOf(meta);
1331
+ if (stamp !== null && after !== stamp) continue;
1325
1332
  const fold = foldSession(events, subscriptionProviders, officialProviderIds);
1326
1333
  cache.set(id, {
1327
1334
  stamp,
@@ -1337,6 +1344,11 @@ function createUsageAggregator(persistence, options = {}) {
1337
1344
  }
1338
1345
  }
1339
1346
  for (const key of [...cache.keys()]) if (!seen.has(key)) cache.delete(key);
1347
+ while (cache.size > maxCacheSessions) {
1348
+ const oldest = cache.keys().next();
1349
+ if (oldest.done === true) break;
1350
+ cache.delete(oldest.value);
1351
+ }
1340
1352
  if (skipped.length > 0) console.warn(`[usage-billing] aggregated ${folds.length} sessions, skipped ${skipped.length} unreadable:`, skipped);
1341
1353
  const total = emptyUsage();
1342
1354
  const byModel = /* @__PURE__ */ new Map();
@@ -1475,17 +1487,89 @@ function createUsageAggregator(persistence, options = {}) {
1475
1487
  } };
1476
1488
  }
1477
1489
  //#endregion
1490
+ //#region lib/types/resilience.js
1491
+ /**
1492
+ * 上游网络请求的稳定性工具:有限重试(指数退避)与每平台熔断冷却门。
1493
+ *
1494
+ * 计费插件对三类上游做实时请求——pricing(汇率 / OpenRouter / models.dev)、
1495
+ * balance(各厂商余额)、subscriptions(各订阅额度)。单次失败会被调用方降级,
1496
+ * 但反复的瞬时失败(网络波动 / 5xx / 429)会让 30 秒轮询每次打满超时。这里提供
1497
+ * 两个纯工具:`withRetry` 对可重试错误做指数退避,`createCooldownGate` 在单一
1498
+ * 上游连续失败后短路一段时间,避免把请求打到已不可用的服务上。
1499
+ */
1500
+ /** 是否是可重试的错误:网络性失败(TypeError / Abort / Timeout)或 5xx / 429。
1501
+ * 401 / 403 鉴权失败与 404 不可重试——重试只放大错误、不会变好。 */
1502
+ function isRetryableError(error) {
1503
+ if (error instanceof Error) {
1504
+ if (error.name === "AbortError" || error.name === "TimeoutError") return true;
1505
+ if (error instanceof TypeError) return true;
1506
+ const status = error.httpStatus;
1507
+ if (typeof status === "number") return status === 429 || status >= 500;
1508
+ }
1509
+ return false;
1510
+ }
1511
+ /** 休眠指定毫秒(Promise 化 setTimeout)。 */
1512
+ function sleep(ms) {
1513
+ return new Promise((resolve) => setTimeout(resolve, ms));
1514
+ }
1515
+ /**
1516
+ * 对一次上游请求做有限重试:可重试错误时按指数退避(+同量级抖动)重试。
1517
+ * @param fn - 发起请求的异步函数。
1518
+ * @param options - 重试策略(见 {@link RetryOptions})。
1519
+ * @returns fn 的结果;重试耗尽后抛出最后一次错误。
1520
+ */
1521
+ async function withRetry(fn, options = {}) {
1522
+ const { retries = 1, baseDelayMs = 250, maxDelayMs = 2e3, shouldRetry = isRetryableError } = options;
1523
+ let lastError;
1524
+ for (let attempt = 0; attempt <= retries; attempt += 1) try {
1525
+ return await fn();
1526
+ } catch (error) {
1527
+ lastError = error;
1528
+ if (attempt >= retries || !shouldRetry(error)) throw error;
1529
+ const delay = Math.min(maxDelayMs, baseDelayMs * 2 ** attempt);
1530
+ await sleep(delay / 2 + Math.random() * (delay / 2));
1531
+ }
1532
+ throw lastError;
1533
+ }
1534
+ /** 每平台冷熔断:连续失败达阈值后短路一段真实时间,成功后复位。 */
1535
+ function createCooldownGate(options = {}) {
1536
+ const { failures = 3, cooldownMs = 3e4 } = options;
1537
+ const state = /* @__PURE__ */ new Map();
1538
+ return {
1539
+ check(key) {
1540
+ const s = state.get(key);
1541
+ if (s === void 0) return true;
1542
+ if (s.until > 0 && Date.now() < s.until) return false;
1543
+ return true;
1544
+ },
1545
+ fail(key) {
1546
+ const s = state.get(key) ?? {
1547
+ count: 0,
1548
+ until: 0
1549
+ };
1550
+ s.count += 1;
1551
+ if (s.count >= failures) {
1552
+ s.until = Date.now() + cooldownMs;
1553
+ s.count = 0;
1554
+ }
1555
+ state.set(key, s);
1556
+ },
1557
+ success(key) {
1558
+ state.delete(key);
1559
+ }
1560
+ };
1561
+ }
1562
+ //#endregion
1478
1563
  //#region lib/types/balance.js
1479
1564
  /**
1480
1565
  * Account-balance queries for the billing dashboard.
1481
1566
  *
1482
1567
  * Only providers with a public balance endpoint can report one. Today that is
1483
- * DeepSeek (`GET https://api.deepseek.com/user/balance`) and Moonshot/Kimi
1484
- * (`GET https://api.moonshot.cn/v1/users/me/balance`), both Bearer 鉴权 with a
1485
- * documented JSON shape; the other mainstream providers expose no standard
1486
- * balance API (or require a non-Bearer auth flow), so their rows in the model
1487
- * table show an unavailable state. The lookup map below is the extension point
1488
- * for future providers.
1568
+ * DeepSeek, Moonshot/Kimi, StepFun, SiliconFlow, and xAI (Grok) — all Bearer
1569
+ * 鉴权 with a documented JSON shape; the other mainstream providers expose no
1570
+ * standard balance API (or require a non-Bearer auth flow), so their rows in
1571
+ * the model table show an unavailable state. The lookup map below is the
1572
+ * extension point for future providers.
1489
1573
  *
1490
1574
  * API keys are read from the `llm-pi-ai` settings namespace (`providers.<id>.apiKeyEnv`),
1491
1575
  * the same source the subscription adapter uses, so a deployment configures a
@@ -1493,6 +1577,20 @@ function createUsageAggregator(persistence, options = {}) {
1493
1577
  */
1494
1578
  /** Abort a balance fetch when the upstream hangs beyond this budget. */
1495
1579
  const FETCH_TIMEOUT_MS$1 = 8e3;
1580
+ /**
1581
+ * 每平台熔断门:单个 provider 连续可重试失败(网络波动 / 5xx / 429)达阈值后
1582
+ * 短路一段真实时间,避免 30 秒轮询在已不可用的上游上反复打满超时。
1583
+ * 鉴权失败(unauthorized)是配置问题而非暂时故障,不计入熔断。
1584
+ */
1585
+ const balanceGate = createCooldownGate({
1586
+ failures: 3,
1587
+ cooldownMs: 6e4
1588
+ });
1589
+ /** 自定义 Provider 余额的熔断门:按端点 URL 独立熔断(各配置端点互不干扰)。 */
1590
+ const customGate = createCooldownGate({
1591
+ failures: 3,
1592
+ cooldownMs: 6e4
1593
+ });
1496
1594
  /** DeepSeek 官方余额接口(官方文档 api-docs.deepseek.com/api/get-user-balance)。 */
1497
1595
  const DEEPSEEK_BALANCE_URL = "https://api.deepseek.com/user/balance";
1498
1596
  /** Moonshot/Kimi 官方余额接口(platform.kimi.com/docs/api/balance)。 */
@@ -1501,6 +1599,8 @@ const MOONSHOT_BALANCE_URL = "https://api.moonshot.cn/v1/users/me/balance";
1501
1599
  const STEPFUN_BALANCE_URL = "https://api.stepfun.com/v1/accounts";
1502
1600
  /** 硅基流动 SiliconFlow 官方用户信息接口(docs.siliconflow.cn/cn/api-reference/user/query-user-info)。 */
1503
1601
  const SILICONFLOW_BALANCE_URL = "https://api.siliconflow.cn/v1/user/info";
1602
+ /** xAI 官方账单接口(docs.x.ai/developers/api/credits);total.val 为美分。 */
1603
+ const XAI_CREDITS_URL = "https://api.x.ai/v1/billing/credits";
1504
1604
  /** 数字归一化:接口返回的余额是字符串(如 `"110.00"`),统一转 number。 */
1505
1605
  function toNumber(value) {
1506
1606
  if (typeof value === "number" && Number.isFinite(value)) return value;
@@ -1528,35 +1628,60 @@ async function queryBearerBalance(ctx, url, apiKeyEnv, provider, displayName, pa
1528
1628
  displayName,
1529
1629
  error: "unconfigured"
1530
1630
  };
1531
- const controller = new AbortController();
1532
- const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS$1);
1631
+ if (!balanceGate.check(provider)) return {
1632
+ provider,
1633
+ displayName,
1634
+ error: "unreachable"
1635
+ };
1636
+ const doRequest = async () => {
1637
+ const controller = new AbortController();
1638
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS$1);
1639
+ try {
1640
+ const response = await fetch(url, {
1641
+ headers: {
1642
+ accept: "application/json",
1643
+ authorization: `Bearer ${hit.value}`
1644
+ },
1645
+ signal: controller.signal
1646
+ });
1647
+ if (response.status === 401 || response.status === 403) return {
1648
+ provider,
1649
+ displayName,
1650
+ error: "unauthorized"
1651
+ };
1652
+ if (!response.ok) {
1653
+ const error = /* @__PURE__ */ new Error(`HTTP ${String(response.status)}`);
1654
+ error.httpStatus = response.status;
1655
+ throw error;
1656
+ }
1657
+ const row = parse(await response.json());
1658
+ if (row.totalBalance === void 0 && row.grantedBalance === void 0 && row.toppedUpBalance === void 0 && row.isAvailable === void 0) {
1659
+ console.warn(`[usage-billing] balance response drifted for ${displayName}: no balance field parsed from ${url}`);
1660
+ return {
1661
+ ...row,
1662
+ error: "invalid"
1663
+ };
1664
+ }
1665
+ return row;
1666
+ } finally {
1667
+ clearTimeout(timer);
1668
+ }
1669
+ };
1533
1670
  try {
1534
- const response = await fetch(url, {
1535
- headers: {
1536
- accept: "application/json",
1537
- authorization: `Bearer ${hit.value}`
1538
- },
1539
- signal: controller.signal
1671
+ const row = await withRetry(doRequest, {
1672
+ retries: 1,
1673
+ baseDelayMs: 250,
1674
+ maxDelayMs: 2e3
1540
1675
  });
1541
- if (response.status === 401 || response.status === 403) return {
1542
- provider,
1543
- displayName,
1544
- error: "unauthorized"
1545
- };
1546
- if (!response.ok) return {
1547
- provider,
1548
- displayName,
1549
- error: "unreachable"
1550
- };
1551
- return parse(await response.json());
1676
+ balanceGate.success(provider);
1677
+ return row;
1552
1678
  } catch {
1679
+ balanceGate.fail(provider);
1553
1680
  return {
1554
1681
  provider,
1555
1682
  displayName,
1556
1683
  error: "unreachable"
1557
1684
  };
1558
- } finally {
1559
- clearTimeout(timer);
1560
1685
  }
1561
1686
  }
1562
1687
  /**
@@ -1648,6 +1773,24 @@ function querySiliconFlow(ctx, apiKeyEnv) {
1648
1773
  };
1649
1774
  });
1650
1775
  }
1776
+ /**
1777
+ * Query the xAI (Grok) credit balance.
1778
+ * @param ctx - host context carrying the credentials seam.
1779
+ * @param apiKeyEnv - credential reference resolving the xAI API key.
1780
+ * @returns the balance row, or an error row when the key/endpoint misbehaves.
1781
+ */
1782
+ function queryXai(ctx, apiKeyEnv) {
1783
+ return queryBearerBalance(ctx, XAI_CREDITS_URL, apiKeyEnv, "xAI", "xAI", (data) => {
1784
+ const cents = toNumber(data.total?.val);
1785
+ const totalBalance = cents === void 0 ? void 0 : Math.abs(cents) / 100;
1786
+ return {
1787
+ provider: "xAI",
1788
+ displayName: "xAI",
1789
+ currency: "USD",
1790
+ ...totalBalance !== void 0 ? { totalBalance } : {}
1791
+ };
1792
+ });
1793
+ }
1651
1794
  const QUERIERS = [
1652
1795
  {
1653
1796
  route: "deepseek",
@@ -1668,6 +1811,11 @@ const QUERIERS = [
1668
1811
  route: "siliconflow",
1669
1812
  displayName: "硅基流动",
1670
1813
  querier: querySiliconFlow
1814
+ },
1815
+ {
1816
+ route: "xai",
1817
+ displayName: "xAI",
1818
+ querier: queryXai
1671
1819
  }
1672
1820
  ];
1673
1821
  /**
@@ -1764,47 +1912,64 @@ async function queryCustomBalances(ctx, configs) {
1764
1912
  displayName,
1765
1913
  error: "unconfigured"
1766
1914
  };
1767
- const controller = new AbortController();
1768
- const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS$1);
1915
+ if (!customGate.check(config.url)) return {
1916
+ provider,
1917
+ displayName,
1918
+ error: "unreachable"
1919
+ };
1920
+ const doRequest = async () => {
1921
+ const controller = new AbortController();
1922
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS$1);
1923
+ try {
1924
+ const response = await fetch(config.url, {
1925
+ method: config.method ?? "GET",
1926
+ headers: {
1927
+ accept: "application/json",
1928
+ ...headers
1929
+ },
1930
+ signal: controller.signal
1931
+ });
1932
+ if (response.status === 401 || response.status === 403) return {
1933
+ provider,
1934
+ displayName,
1935
+ error: "unauthorized"
1936
+ };
1937
+ if (!response.ok) {
1938
+ const error = /* @__PURE__ */ new Error(`HTTP ${String(response.status)}`);
1939
+ error.httpStatus = response.status;
1940
+ throw error;
1941
+ }
1942
+ const remaining = evalExtract(config.extract.remaining, await response.json());
1943
+ if (remaining === void 0) return {
1944
+ provider,
1945
+ displayName,
1946
+ error: "invalid"
1947
+ };
1948
+ return {
1949
+ provider,
1950
+ displayName,
1951
+ currency: config.unit ?? "CNY",
1952
+ totalBalance: remaining
1953
+ };
1954
+ } finally {
1955
+ clearTimeout(timer);
1956
+ }
1957
+ };
1769
1958
  try {
1770
- const response = await fetch(config.url, {
1771
- method: config.method ?? "GET",
1772
- headers: {
1773
- accept: "application/json",
1774
- ...headers
1775
- },
1776
- signal: controller.signal
1959
+ const row = await withRetry(doRequest, {
1960
+ retries: 1,
1961
+ baseDelayMs: 250,
1962
+ maxDelayMs: 2e3
1777
1963
  });
1778
- if (response.status === 401 || response.status === 403) return {
1779
- provider,
1780
- displayName,
1781
- error: "unauthorized"
1782
- };
1783
- if (!response.ok) return {
1784
- provider,
1785
- displayName,
1786
- error: "unreachable"
1787
- };
1788
- const remaining = evalExtract(config.extract.remaining, await response.json());
1789
- if (remaining === void 0) return {
1790
- provider,
1791
- displayName,
1792
- error: "unreachable"
1793
- };
1794
- return {
1795
- provider,
1796
- displayName,
1797
- currency: config.unit ?? "CNY",
1798
- totalBalance: remaining
1799
- };
1964
+ customGate.success(config.url);
1965
+ return row;
1800
1966
  } catch {
1967
+ customGate.fail(config.url);
1801
1968
  return {
1802
1969
  provider,
1803
1970
  displayName,
1804
1971
  error: "unreachable"
1805
1972
  };
1806
- } finally {
1807
- clearTimeout(timer);
1808
1973
  }
1809
1974
  }));
1810
1975
  }
@@ -1821,6 +1986,12 @@ async function queryCustomBalances(ctx, configs) {
1821
1986
  */
1822
1987
  /** Abort a fetch when the upstream hangs beyond this budget. */
1823
1988
  const FETCH_TIMEOUT_MS = 8e3;
1989
+ /** 每平台熔断门:单个定价上游连续可重试失败(网络 / 5xx / 429)达阈值后短路,
1990
+ * 避免 6 小时刷新循环与每次启动在已故障的上游上反复打满超时。按 URL 独立。 */
1991
+ const pricingGate = createCooldownGate({
1992
+ failures: 3,
1993
+ cooldownMs: 6e4
1994
+ });
1824
1995
  /**
1825
1996
  * USD → CNY 汇率源,按顺序尝试:国内可达的腾讯财经行情(免 key、`~` 分隔
1826
1997
  * 第 4 个字段为价格)优先,国外 open.er-api.com 兜底。任一源失败自动落到
@@ -1903,18 +2074,35 @@ const ROUTER_ID_HINTS = {
1903
2074
  "llama": ["llama-4"],
1904
2075
  "kimi": ["kimi-k2"]
1905
2076
  };
1906
- /** GET a URL's text body with a hard timeout; null on any failure. */
2077
+ /** GET a URL's text body with a hard timeout and retry; null on any failure. */
1907
2078
  async function fetchText(url) {
1908
- const controller = new AbortController();
1909
- const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
2079
+ if (!pricingGate.check(url)) return null;
2080
+ const doFetch = async () => {
2081
+ const controller = new AbortController();
2082
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
2083
+ try {
2084
+ const response = await fetch(url, { signal: controller.signal });
2085
+ if (!response.ok) {
2086
+ const error = /* @__PURE__ */ new Error(`HTTP ${String(response.status)}`);
2087
+ error.httpStatus = response.status;
2088
+ throw error;
2089
+ }
2090
+ return await response.text();
2091
+ } finally {
2092
+ clearTimeout(timer);
2093
+ }
2094
+ };
1910
2095
  try {
1911
- const response = await fetch(url, { signal: controller.signal });
1912
- if (!response.ok) return null;
1913
- return await response.text();
2096
+ const text = await withRetry(doFetch, {
2097
+ retries: 1,
2098
+ baseDelayMs: 250,
2099
+ maxDelayMs: 2e3
2100
+ });
2101
+ pricingGate.success(url);
2102
+ return text;
1914
2103
  } catch {
2104
+ pricingGate.fail(url);
1915
2105
  return null;
1916
- } finally {
1917
- clearTimeout(timer);
1918
2106
  }
1919
2107
  }
1920
2108
  /** GET a JSON endpoint with a hard timeout; null on any failure. */
@@ -1941,7 +2129,10 @@ async function fetchRouterModels() {
1941
2129
  const data = await fetchJson(ROUTER_URL);
1942
2130
  if (data === null || typeof data !== "object") return void 0;
1943
2131
  const list = data.data;
1944
- if (!Array.isArray(list)) return void 0;
2132
+ if (!Array.isArray(list)) {
2133
+ console.warn("[usage-billing] openrouter models response drifted: expected a `data` array");
2134
+ return;
2135
+ }
1945
2136
  const models = [];
1946
2137
  for (const item of list) {
1947
2138
  if (item === null || typeof item !== "object") continue;
@@ -2061,6 +2252,13 @@ async function fetchLivePricing() {
2061
2252
  * "no quota API" marker rather than hidden. API keys come from the `llm-pi-ai`
2062
2253
  * settings namespace (`apiKeyEnv` refs) resolved through the credentials seam.
2063
2254
  */
2255
+ /** 每平台熔断门:某个订阅适配器连续可重试失败(网络 / 5xx / 429)达阈值后
2256
+ * 短路由不可用,避免 30 秒轮询对已故障的上游反复打满超时。鉴权失败(unauthorized)
2257
+ * 是配置问题,不计入熔断。 */
2258
+ const subscriptionGate = createCooldownGate({
2259
+ failures: 3,
2260
+ cooldownMs: 6e4
2261
+ });
2064
2262
  /** 空凭据:全部未配置时的初始值。 */
2065
2263
  const EMPTY_SUBSCRIPTION_KEYS = {
2066
2264
  kimiApiKey: "",
@@ -2169,18 +2367,26 @@ function statusOf(error) {
2169
2367
  }
2170
2368
  return "unavailable";
2171
2369
  }
2172
- /** One JSON fetch with a timeout, mapping HTTP failures to typed errors. */
2370
+ /** One JSON fetch with a timeout, mapping HTTP failures to typed errors.
2371
+ * 可重试错误(网络 / 5xx / 429)用指数退避重试一次;401/403/404 不重试。 */
2173
2372
  async function requestJson(url, init, timeoutMs) {
2174
- const response = await fetch(url, {
2175
- ...init,
2176
- signal: AbortSignal.timeout(timeoutMs)
2373
+ const doFetch = async () => {
2374
+ const response = await fetch(url, {
2375
+ ...init,
2376
+ signal: AbortSignal.timeout(timeoutMs)
2377
+ });
2378
+ if (!response.ok) {
2379
+ const error = /* @__PURE__ */ new Error(`HTTP ${String(response.status)}`);
2380
+ error.httpStatus = response.status;
2381
+ throw error;
2382
+ }
2383
+ return await response.json();
2384
+ };
2385
+ return await withRetry(doFetch, {
2386
+ retries: 1,
2387
+ baseDelayMs: 250,
2388
+ maxDelayMs: 2e3
2177
2389
  });
2178
- if (!response.ok) {
2179
- const error = /* @__PURE__ */ new Error(`HTTP ${String(response.status)}`);
2180
- error.httpStatus = response.status;
2181
- throw error;
2182
- }
2183
- return await response.json();
2184
2390
  }
2185
2391
  /** Parse one Kimi limit window entry. */
2186
2392
  function kimiWindow(value, kind) {
@@ -2562,18 +2768,41 @@ async function collectOpenRouter(keys, config, timeoutMs) {
2562
2768
  * @returns the quotas in plan order (unknown providers degrade to `unavailable`).
2563
2769
  */
2564
2770
  async function collectSubscriptions(keys, plans = [], timeoutMs = DEFAULT_TIMEOUT_MS) {
2565
- return await Promise.all(plans.map((plan) => {
2771
+ return await Promise.all(plans.map(async (plan) => {
2566
2772
  const adapter = SUBSCRIPTION_ADAPTERS[plan.provider];
2567
- if (adapter === void 0) return Promise.resolve({
2773
+ if (adapter === void 0) return {
2568
2774
  provider: plan.provider,
2569
2775
  displayName: plan.provider,
2570
2776
  status: "unavailable",
2571
2777
  windows: []
2572
- });
2573
- return adapter.collect(keys, plan, timeoutMs);
2778
+ };
2779
+ if (!subscriptionGate.check(plan.provider)) return {
2780
+ provider: plan.provider,
2781
+ displayName: plan.provider,
2782
+ status: "unavailable",
2783
+ windows: []
2784
+ };
2785
+ const quota = await adapter.collect(keys, plan, timeoutMs);
2786
+ if (quota.status === "unavailable" || quota.status === "rate-limited") subscriptionGate.fail(plan.provider);
2787
+ else subscriptionGate.success(plan.provider);
2788
+ return quota;
2574
2789
  }));
2575
2790
  }
2576
2791
  //#endregion
2792
+ //#region lib/types/client/usage-billing-settings.js
2793
+ /**
2794
+ * usage-stats 工具开关的共享设置契约(node 与 client 两端共用)。
2795
+ *
2796
+ * 宿主把已注册的设置命名空间服务给浏览器;node 半区在 apply 阶段读一次该值决定
2797
+ * 是否向模型注入 `usage_stats` 工具(工具注入是启动期决策,改开关后重载应用生效),
2798
+ * client 半区在「设置」Tab 渲染开关并写入同一命名空间。缺省的默认行为是关闭——
2799
+ * 避免该工具默认占用模型每次请求的上下文(coding 场景通常在仪表盘看用量)。
2800
+ */
2801
+ /** 设置命名空间 id(小写 kebab-case)。 */
2802
+ const BILLING_SETTINGS_NAMESPACE = "ui-usage-billing";
2803
+ /** 该命名空间下用户可编辑的字段名。 */
2804
+ const ENABLE_USAGE_STATS_TOOL_FIELD = "enableUsageStatsTool";
2805
+ //#endregion
2577
2806
  //#region lib/types/index.js
2578
2807
  /**
2579
2808
  * Usage billing surface plugin, node half.
@@ -2586,6 +2815,10 @@ async function collectSubscriptions(keys, plans = [], timeoutMs = DEFAULT_TIMEOU
2586
2815
  * missing file answers `{ error }` so the dashboard shows zeros, never
2587
2816
  * fabricated samples.
2588
2817
  */
2818
+ /** usage_stats 工具开关的设置命名空间 id(下端与 node 共用同一常量)。 */
2819
+ const usageBillingSettingsNs = settingsNamespace(BILLING_SETTINGS_NAMESPACE);
2820
+ /** 该命名空间的 wire schema:`enableUsageStatsTool` 布尔,默认关闭(issue 诉求)。 */
2821
+ const UsageBillingSettingsSchema = z.object({ [ENABLE_USAGE_STATS_TOOL_FIELD]: z.boolean().default(false) });
2589
2822
  /** 实时定价的后台刷新间隔(毫秒):汇率/模型价低频变化,6 小时一次足够。 */
2590
2823
  const PRICING_REFRESH_INTERVAL_MS = 360 * 60 * 1e3;
2591
2824
  /** 订阅套餐额度缓存时长(毫秒):上游配额 API 低频变化,5 分钟足够。 */
@@ -2599,6 +2832,21 @@ const DEFAULT_BALANCE_API_KEY_ENV = "DEEPSEEK_API_KEY";
2599
2832
  const PACKAGE_VERSION = createRequire(import.meta.url)("../package.json").version ?? "0.0.0";
2600
2833
  /** 统计快照的落盘节流(毫秒):前端 30 秒轮询,快照最多每 30 秒写一次。 */
2601
2834
  const SNAPSHOT_INTERVAL_MS = 3e4;
2835
+ /** 鉴权失败告警冷却(毫秒):同一 provider 在窗口内只提示一次,避免 30 秒轮询刷屏。 */
2836
+ const AUTH_WARN_COOLDOWN_MS = 1800 * 1e3;
2837
+ /**
2838
+ * 鉴权失败分类告警(P1-5):余额 / 订阅查询返回 unauthorized 时,按
2839
+ * `source:provider` 去重并冷却告警,提示检查 llm-pi-ai 里该 provider 的 apiKeyEnv。
2840
+ */
2841
+ const authWarnedAt = /* @__PURE__ */ new Map();
2842
+ function warnAuthOnce(source, provider, displayName) {
2843
+ const key = `${source}:${provider}`;
2844
+ const now = Date.now();
2845
+ const last = authWarnedAt.get(key);
2846
+ if (last !== void 0 && now - last < AUTH_WARN_COOLDOWN_MS) return;
2847
+ authWarnedAt.set(key, now);
2848
+ console.warn(`[usage-billing] ${displayName}(${provider})鉴权失败:请检查 llm-pi-ai 设置中该 provider 的 apiKeyEnv 凭据是否正确/有效。`);
2849
+ }
2602
2850
  /** Required services: the web server, the persisted session log store, and user settings. */
2603
2851
  const inject = [
2604
2852
  "webServer",
@@ -2692,6 +2940,7 @@ async function resolveSubscriptionKeys(settings, credentials) {
2692
2940
  * @param config - optional statsPath override.
2693
2941
  */
2694
2942
  function apply(ctx, config = {}) {
2943
+ let usageSettingsScope;
2695
2944
  const aggregator = createUsageAggregator(ctx.sessionPersistence, { ...config.subscriptionProviders === void 0 ? {} : { subscriptionProviders: config.subscriptionProviders } });
2696
2945
  const cwd = process.cwd();
2697
2946
  const snapshotPath = join(homedir(), ".dsh/.dsh-usage-stats.json");
@@ -2706,16 +2955,35 @@ function apply(ctx, config = {}) {
2706
2955
  const now = Date.now();
2707
2956
  if (now - lastSnapshotAt < SNAPSHOT_INTERVAL_MS) return;
2708
2957
  lastSnapshotAt = now;
2709
- writeFileAtomic(snapshotPath, JSON.stringify({
2958
+ const payload = JSON.stringify({
2710
2959
  ...doc,
2711
2960
  _writer: {
2712
2961
  pid: process.pid,
2713
2962
  at: now
2714
2963
  }
2715
- }), {
2716
- mode: 384,
2717
- dirMode: 448
2718
- }).catch(() => {});
2964
+ });
2965
+ (async () => {
2966
+ try {
2967
+ const existing = await readFile(snapshotPath, "utf8");
2968
+ await writeFileAtomic(`${snapshotPath}.bak`, existing, {
2969
+ mode: 384,
2970
+ dirMode: 448
2971
+ });
2972
+ } catch {}
2973
+ try {
2974
+ await writeFileAtomic(snapshotPath, payload, {
2975
+ mode: 384,
2976
+ dirMode: 448
2977
+ });
2978
+ } catch {}
2979
+ })();
2980
+ };
2981
+ /** 读一个快照候选并解析成对象:主文件优先;主文件损坏时回退上一版 `.bak`(P0-3 崩溃恢复)。 */
2982
+ const readSnapshot = async (candidate) => {
2983
+ for (const path of [candidate, `${candidate}.bak`]) try {
2984
+ return JSON.parse(await readFile(path, "utf8"));
2985
+ } catch {}
2986
+ return null;
2719
2987
  };
2720
2988
  (async () => {
2721
2989
  try {
@@ -2724,94 +2992,118 @@ function apply(ctx, config = {}) {
2724
2992
  if (writer?.pid !== void 0 && writer.pid !== process.pid && writer.at !== void 0 && Date.now() - writer.at < 6e4) console.warn(`[usage-billing] 检测到另一实例(pid ${writer.pid})正在提供用量统计,双实例可能导致提醒重复。`);
2725
2993
  } catch {}
2726
2994
  })();
2727
- ctx.inject(["tools"], (toolsCtx) => {
2728
- toolsCtx.tools.register(defineTool({
2729
- name: "usage_stats",
2730
- description: "查询本机 DeepSeek Harness 的模型用量与估算费用(人民币,按官方目录价估算,非账单)。range 取值:today=今天,month=本月,session=当前会话,all=累计。",
2731
- parameters: { range: {
2732
- type: "string",
2733
- enum: [
2734
- "today",
2735
- "month",
2736
- "session",
2737
- "all"
2738
- ],
2739
- required: true,
2740
- description: "统计范围:today / month / session / all"
2741
- } },
2742
- output: {
2743
- schema: {
2744
- type: "object",
2745
- additionalProperties: false,
2746
- properties: {
2747
- range: {
2748
- type: "string",
2749
- required: true
2750
- },
2751
- cost: {
2752
- type: "number",
2753
- required: true,
2754
- description: "估算费用(人民币元)"
2755
- },
2756
- calls: {
2757
- type: "number",
2758
- required: true
2759
- },
2760
- input: {
2761
- type: "number",
2762
- required: true,
2763
- description: "输入 tokens"
2764
- },
2765
- output: {
2766
- type: "number",
2767
- required: true,
2768
- description: "输出 tokens"
2995
+ ctx.inject(["settings"], (sctx) => {
2996
+ const scope = sctx.settings.register(usageBillingSettingsNs, UsageBillingSettingsSchema, { base: { enableUsageStatsTool: config.enableUsageStatsTool ?? false } });
2997
+ usageSettingsScope = scope;
2998
+ ctx.inject(["tools"], (toolsCtx) => {
2999
+ if (scope.get().enableUsageStatsTool) toolsCtx.tools.register(defineTool({
3000
+ name: "usage_stats",
3001
+ description: "查询本机 DeepSeek Harness 的模型用量与估算费用(人民币,按官方目录价估算,非账单)。range 取值:today=今天,month=本月,session=当前会话,all=累计。",
3002
+ parameters: { range: {
3003
+ type: "string",
3004
+ enum: [
3005
+ "today",
3006
+ "month",
3007
+ "session",
3008
+ "all"
3009
+ ],
3010
+ required: true,
3011
+ description: "统计范围:today / month / session / all"
3012
+ } },
3013
+ output: {
3014
+ schema: {
3015
+ type: "object",
3016
+ additionalProperties: false,
3017
+ properties: {
3018
+ range: {
3019
+ type: "string",
3020
+ required: true
3021
+ },
3022
+ cost: {
3023
+ type: "number",
3024
+ required: true,
3025
+ description: "估算费用(人民币元)"
3026
+ },
3027
+ calls: {
3028
+ type: "number",
3029
+ required: true
3030
+ },
3031
+ input: {
3032
+ type: "number",
3033
+ required: true,
3034
+ description: "输入 tokens"
3035
+ },
3036
+ output: {
3037
+ type: "number",
3038
+ required: true,
3039
+ description: "输出 tokens"
3040
+ }
2769
3041
  }
2770
- }
3042
+ },
3043
+ render: (_args, value) => [{
3044
+ type: "text",
3045
+ text: `用量(${value.range}):估算费用 ${formatMoney(value.cost)},调用 ${value.calls} 次,输入 ${formatTokens(value.input)} tokens,输出 ${formatTokens(value.output)} tokens`
3046
+ }]
2771
3047
  },
2772
- render: (_args, value) => [{
2773
- type: "text",
2774
- text: `用量(${value.range}):估算费用 ${formatMoney(value.cost)},调用 ${value.calls} 次,输入 ${formatTokens(value.input)} tokens,输出 ${formatTokens(value.output)} tokens`
2775
- }]
2776
- },
2777
- async execute(args, exec) {
2778
- const stats = await aggregator.aggregate();
2779
- const zero = {
2780
- range: args.range,
2781
- cost: 0,
2782
- calls: 0,
2783
- input: 0,
2784
- output: 0
2785
- };
2786
- if (args.range === "all") return {
2787
- range: args.range,
2788
- cost: stats.total.cost,
2789
- calls: stats.total.calls,
2790
- input: stats.total.input,
2791
- output: stats.total.output
2792
- };
2793
- if (args.range === "today") {
2794
- const day = stats.byDay[dayStamp(Date.now())];
2795
- return day === void 0 ? zero : {
3048
+ async execute(args, exec) {
3049
+ const stats = await aggregator.aggregate();
3050
+ const zero = {
2796
3051
  range: args.range,
2797
- cost: day.cost,
2798
- calls: day.calls,
2799
- input: day.input,
2800
- output: day.output
3052
+ cost: 0,
3053
+ calls: 0,
3054
+ input: 0,
3055
+ output: 0
2801
3056
  };
2802
- }
2803
- if (args.range === "month") {
2804
- const prefix = dayStamp(Date.now()).slice(0, 7);
3057
+ if (args.range === "all") return {
3058
+ range: args.range,
3059
+ cost: stats.total.cost,
3060
+ calls: stats.total.calls,
3061
+ input: stats.total.input,
3062
+ output: stats.total.output
3063
+ };
3064
+ if (args.range === "today") {
3065
+ const day = stats.byDay[dayStamp(Date.now())];
3066
+ return day === void 0 ? zero : {
3067
+ range: args.range,
3068
+ cost: day.cost,
3069
+ calls: day.calls,
3070
+ input: day.input,
3071
+ output: day.output
3072
+ };
3073
+ }
3074
+ if (args.range === "month") {
3075
+ const prefix = dayStamp(Date.now()).slice(0, 7);
3076
+ let cost = 0;
3077
+ let calls = 0;
3078
+ let input = 0;
3079
+ let output = 0;
3080
+ for (const [date, day] of Object.entries(stats.byDay)) {
3081
+ if (!date.startsWith(prefix)) continue;
3082
+ cost += day.cost;
3083
+ calls += day.calls;
3084
+ input += day.input;
3085
+ output += day.output;
3086
+ }
3087
+ return {
3088
+ range: args.range,
3089
+ cost,
3090
+ calls,
3091
+ input,
3092
+ output
3093
+ };
3094
+ }
3095
+ const sessionId = exec.agent?.id;
3096
+ if (sessionId === void 0) throw new Error("usage_stats 的 session 范围需要 agent 会话上下文");
2805
3097
  let cost = 0;
2806
3098
  let calls = 0;
2807
3099
  let input = 0;
2808
3100
  let output = 0;
2809
- for (const [date, day] of Object.entries(stats.byDay)) {
2810
- if (!date.startsWith(prefix)) continue;
2811
- cost += day.cost;
2812
- calls += day.calls;
2813
- input += day.input;
2814
- output += day.output;
3101
+ for (const turn of stats.byTurn ?? []) {
3102
+ if (turn.sessionId !== String(sessionId)) continue;
3103
+ cost += turn.cost;
3104
+ calls += 1;
3105
+ input += turn.input;
3106
+ output += turn.output;
2815
3107
  }
2816
3108
  return {
2817
3109
  range: args.range,
@@ -2821,28 +3113,8 @@ function apply(ctx, config = {}) {
2821
3113
  output
2822
3114
  };
2823
3115
  }
2824
- const sessionId = exec.agent?.id;
2825
- if (sessionId === void 0) throw new Error("usage_stats 的 session 范围需要 agent 会话上下文");
2826
- let cost = 0;
2827
- let calls = 0;
2828
- let input = 0;
2829
- let output = 0;
2830
- for (const turn of stats.byTurn ?? []) {
2831
- if (turn.sessionId !== String(sessionId)) continue;
2832
- cost += turn.cost;
2833
- calls += 1;
2834
- input += turn.input;
2835
- output += turn.output;
2836
- }
2837
- return {
2838
- range: args.range,
2839
- cost,
2840
- calls,
2841
- input,
2842
- output
2843
- };
2844
- }
2845
- }));
3116
+ }));
3117
+ });
2846
3118
  });
2847
3119
  let live = { source: "builtin" };
2848
3120
  const refreshPricing = async () => {
@@ -2875,9 +3147,44 @@ function apply(ctx, config = {}) {
2875
3147
  if (providers["deepseek"] === void 0) providers["deepseek"] = { apiKeyEnv: config.balanceApiKeyEnv ?? DEFAULT_BALANCE_API_KEY_ENV };
2876
3148
  const balances = await queryBalances(ctx, providers);
2877
3149
  const custom = await queryCustomBalances(ctx, config.customBalances ?? []);
3150
+ for (const row of [...balances, ...custom]) if (row.error === "unauthorized") warnAuthOnce("balance", row.provider, row.displayName);
2878
3151
  res.end(JSON.stringify({ balances: [...balances, ...custom] }));
2879
3152
  }
2880
3153
  }), "usage-billing: balance route");
3154
+ ctx.effect(() => ctx.webServer.register({
3155
+ kind: "exact",
3156
+ path: "/api/billing/usage-tool",
3157
+ handler: async (req, res) => {
3158
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
3159
+ const enabled = usageSettingsScope?.get().enableUsageStatsTool ?? false;
3160
+ if (req.method === "GET") {
3161
+ res.end(JSON.stringify({ enabled }));
3162
+ return;
3163
+ }
3164
+ if (req.method !== "POST") {
3165
+ res.writeHead(405, { "content-type": "application/json; charset=utf-8" });
3166
+ res.end(JSON.stringify({ error: "method not allowed" }));
3167
+ return;
3168
+ }
3169
+ try {
3170
+ let body = "";
3171
+ for await (const chunk of req) body += String(chunk);
3172
+ const parsed = JSON.parse(body === "" ? "{}" : body);
3173
+ if (usageSettingsScope === void 0) {
3174
+ res.end(JSON.stringify({ error: "settings unavailable" }));
3175
+ return;
3176
+ }
3177
+ const next = parsed.enabled === true;
3178
+ await usageSettingsScope.update({ enableUsageStatsTool: next });
3179
+ res.end(JSON.stringify({
3180
+ ok: true,
3181
+ enabled: next
3182
+ }));
3183
+ } catch {
3184
+ res.end(JSON.stringify({ error: "invalid" }));
3185
+ }
3186
+ }
3187
+ }), "usage-billing: usage-tool route");
2881
3188
  let quotaCache = {
2882
3189
  at: 0,
2883
3190
  quotas: []
@@ -2889,7 +3196,7 @@ function apply(ctx, config = {}) {
2889
3196
  ...item.region === void 0 ? {} : { region: item.region }
2890
3197
  })))].map((row) => {
2891
3198
  const planType = planTypeOf(row.provider);
2892
- const subscriptionAmount = subscriptionCnyOf(row.provider);
3199
+ const subscriptionAmount = subscriptionFeeCnyOf(row.provider, live.rate);
2893
3200
  return {
2894
3201
  ...row,
2895
3202
  planType,
@@ -2903,6 +3210,7 @@ function apply(ctx, config = {}) {
2903
3210
  windows: [],
2904
3211
  planType: planTypeOf(item.provider)
2905
3212
  });
3213
+ for (const row of rows) if (row.status === "unauthorized") warnAuthOnce("subscription", row.provider, row.displayName);
2906
3214
  quotaCache = {
2907
3215
  at: Date.now(),
2908
3216
  quotas: rows
@@ -2939,15 +3247,15 @@ function apply(ctx, config = {}) {
2939
3247
  } catch (error) {
2940
3248
  console.error("[usage-billing] usage-stats aggregate failed, falling back to snapshot:", error);
2941
3249
  }
2942
- for (const candidate of candidates) try {
2943
- const text = await readFile(candidate, "utf8");
2944
- const doc = JSON.parse(text);
3250
+ for (const candidate of candidates) {
3251
+ const doc = await readSnapshot(candidate);
3252
+ if (doc === null) continue;
2945
3253
  if (config.monthlyBudget !== void 0) doc["budget"] = config.monthlyBudget;
2946
3254
  if (config.lowBalanceThreshold !== void 0) doc["lowBalanceThreshold"] = config.lowBalanceThreshold;
2947
3255
  doc["pluginVersion"] = PACKAGE_VERSION;
2948
3256
  res.end(JSON.stringify(doc));
2949
3257
  return;
2950
- } catch {}
3258
+ }
2951
3259
  res.end(JSON.stringify({ error: "usage stats unavailable" }));
2952
3260
  }
2953
3261
  }), "usage-billing: usage-stats route");