@kenz1117/dsh-ui-usage-billing 0.9.5 → 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
  {
@@ -965,6 +967,7 @@ function emptyUsage() {
965
967
  cacheHit: 0,
966
968
  cacheMiss: 0,
967
969
  cost: 0,
970
+ reasoning: 0,
968
971
  officialCalls: 0,
969
972
  officialCost: 0
970
973
  };
@@ -986,6 +989,7 @@ function foldUsage(acc, usage, key, subscription, timeMs, official = false) {
986
989
  acc.calls += 1;
987
990
  acc.input += usage.inputTokens + cacheHit + (usage.cacheWriteTokens ?? 0);
988
991
  acc.output += usage.outputTokens;
992
+ acc.reasoning += usage.reasoningTokens ?? 0;
989
993
  acc.cacheHit += cacheHit;
990
994
  acc.cacheMiss += cacheMiss;
991
995
  if (official) acc.officialCalls += 1;
@@ -1248,6 +1252,7 @@ function mergeUsageInto(acc, cell) {
1248
1252
  acc.calls += cell.calls;
1249
1253
  acc.input += cell.input;
1250
1254
  acc.output += cell.output;
1255
+ acc.reasoning += cell.reasoning;
1251
1256
  acc.cacheHit += cell.cacheHit;
1252
1257
  acc.cacheMiss += cell.cacheMiss;
1253
1258
  acc.cost += cell.cost;
@@ -1284,6 +1289,7 @@ function percentile(values, p) {
1284
1289
  function createUsageAggregator(persistence, options = {}) {
1285
1290
  const subscriptionProviders = new Set(options.subscriptionProviders ?? DEFAULT_SUBSCRIPTION_PROVIDERS);
1286
1291
  const officialProviderIds = options.officialProviderIds === void 0 ? void 0 : new Set(options.officialProviderIds);
1292
+ const maxCacheSessions = options.maxCacheSessions ?? 400;
1287
1293
  const cache = /* @__PURE__ */ new Map();
1288
1294
  let lastDoc;
1289
1295
  let lastAt = 0;
@@ -1311,6 +1317,8 @@ function createUsageAggregator(persistence, options = {}) {
1311
1317
  const stamp = await stampOf(meta);
1312
1318
  const hit = cache.get(id);
1313
1319
  if (hit !== void 0 && stamp !== null && hit.stamp === stamp) {
1320
+ cache.delete(id);
1321
+ cache.set(id, hit);
1314
1322
  folds.push({
1315
1323
  meta,
1316
1324
  fold: hit.fold
@@ -1319,6 +1327,8 @@ function createUsageAggregator(persistence, options = {}) {
1319
1327
  }
1320
1328
  try {
1321
1329
  const { events } = await persistence.readFrom(meta.id, 0);
1330
+ const after = await stampOf(meta);
1331
+ if (stamp !== null && after !== stamp) continue;
1322
1332
  const fold = foldSession(events, subscriptionProviders, officialProviderIds);
1323
1333
  cache.set(id, {
1324
1334
  stamp,
@@ -1334,6 +1344,11 @@ function createUsageAggregator(persistence, options = {}) {
1334
1344
  }
1335
1345
  }
1336
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
+ }
1337
1352
  if (skipped.length > 0) console.warn(`[usage-billing] aggregated ${folds.length} sessions, skipped ${skipped.length} unreadable:`, skipped);
1338
1353
  const total = emptyUsage();
1339
1354
  const byModel = /* @__PURE__ */ new Map();
@@ -1472,17 +1487,89 @@ function createUsageAggregator(persistence, options = {}) {
1472
1487
  } };
1473
1488
  }
1474
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
1475
1563
  //#region lib/types/balance.js
1476
1564
  /**
1477
1565
  * Account-balance queries for the billing dashboard.
1478
1566
  *
1479
1567
  * Only providers with a public balance endpoint can report one. Today that is
1480
- * DeepSeek (`GET https://api.deepseek.com/user/balance`) and Moonshot/Kimi
1481
- * (`GET https://api.moonshot.cn/v1/users/me/balance`), both Bearer 鉴权 with a
1482
- * documented JSON shape; the other mainstream providers expose no standard
1483
- * balance API (or require a non-Bearer auth flow), so their rows in the model
1484
- * table show an unavailable state. The lookup map below is the extension point
1485
- * 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.
1486
1573
  *
1487
1574
  * API keys are read from the `llm-pi-ai` settings namespace (`providers.<id>.apiKeyEnv`),
1488
1575
  * the same source the subscription adapter uses, so a deployment configures a
@@ -1490,6 +1577,20 @@ function createUsageAggregator(persistence, options = {}) {
1490
1577
  */
1491
1578
  /** Abort a balance fetch when the upstream hangs beyond this budget. */
1492
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
+ });
1493
1594
  /** DeepSeek 官方余额接口(官方文档 api-docs.deepseek.com/api/get-user-balance)。 */
1494
1595
  const DEEPSEEK_BALANCE_URL = "https://api.deepseek.com/user/balance";
1495
1596
  /** Moonshot/Kimi 官方余额接口(platform.kimi.com/docs/api/balance)。 */
@@ -1498,6 +1599,8 @@ const MOONSHOT_BALANCE_URL = "https://api.moonshot.cn/v1/users/me/balance";
1498
1599
  const STEPFUN_BALANCE_URL = "https://api.stepfun.com/v1/accounts";
1499
1600
  /** 硅基流动 SiliconFlow 官方用户信息接口(docs.siliconflow.cn/cn/api-reference/user/query-user-info)。 */
1500
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";
1501
1604
  /** 数字归一化:接口返回的余额是字符串(如 `"110.00"`),统一转 number。 */
1502
1605
  function toNumber(value) {
1503
1606
  if (typeof value === "number" && Number.isFinite(value)) return value;
@@ -1525,35 +1628,60 @@ async function queryBearerBalance(ctx, url, apiKeyEnv, provider, displayName, pa
1525
1628
  displayName,
1526
1629
  error: "unconfigured"
1527
1630
  };
1528
- const controller = new AbortController();
1529
- 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
+ };
1530
1670
  try {
1531
- const response = await fetch(url, {
1532
- headers: {
1533
- accept: "application/json",
1534
- authorization: `Bearer ${hit.value}`
1535
- },
1536
- signal: controller.signal
1671
+ const row = await withRetry(doRequest, {
1672
+ retries: 1,
1673
+ baseDelayMs: 250,
1674
+ maxDelayMs: 2e3
1537
1675
  });
1538
- if (response.status === 401 || response.status === 403) return {
1539
- provider,
1540
- displayName,
1541
- error: "unauthorized"
1542
- };
1543
- if (!response.ok) return {
1544
- provider,
1545
- displayName,
1546
- error: "unreachable"
1547
- };
1548
- return parse(await response.json());
1676
+ balanceGate.success(provider);
1677
+ return row;
1549
1678
  } catch {
1679
+ balanceGate.fail(provider);
1550
1680
  return {
1551
1681
  provider,
1552
1682
  displayName,
1553
1683
  error: "unreachable"
1554
1684
  };
1555
- } finally {
1556
- clearTimeout(timer);
1557
1685
  }
1558
1686
  }
1559
1687
  /**
@@ -1645,6 +1773,24 @@ function querySiliconFlow(ctx, apiKeyEnv) {
1645
1773
  };
1646
1774
  });
1647
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
+ }
1648
1794
  const QUERIERS = [
1649
1795
  {
1650
1796
  route: "deepseek",
@@ -1665,6 +1811,11 @@ const QUERIERS = [
1665
1811
  route: "siliconflow",
1666
1812
  displayName: "硅基流动",
1667
1813
  querier: querySiliconFlow
1814
+ },
1815
+ {
1816
+ route: "xai",
1817
+ displayName: "xAI",
1818
+ querier: queryXai
1668
1819
  }
1669
1820
  ];
1670
1821
  /**
@@ -1761,47 +1912,64 @@ async function queryCustomBalances(ctx, configs) {
1761
1912
  displayName,
1762
1913
  error: "unconfigured"
1763
1914
  };
1764
- const controller = new AbortController();
1765
- 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
+ };
1766
1958
  try {
1767
- const response = await fetch(config.url, {
1768
- method: config.method ?? "GET",
1769
- headers: {
1770
- accept: "application/json",
1771
- ...headers
1772
- },
1773
- signal: controller.signal
1959
+ const row = await withRetry(doRequest, {
1960
+ retries: 1,
1961
+ baseDelayMs: 250,
1962
+ maxDelayMs: 2e3
1774
1963
  });
1775
- if (response.status === 401 || response.status === 403) return {
1776
- provider,
1777
- displayName,
1778
- error: "unauthorized"
1779
- };
1780
- if (!response.ok) return {
1781
- provider,
1782
- displayName,
1783
- error: "unreachable"
1784
- };
1785
- const remaining = evalExtract(config.extract.remaining, await response.json());
1786
- if (remaining === void 0) return {
1787
- provider,
1788
- displayName,
1789
- error: "unreachable"
1790
- };
1791
- return {
1792
- provider,
1793
- displayName,
1794
- currency: config.unit ?? "CNY",
1795
- totalBalance: remaining
1796
- };
1964
+ customGate.success(config.url);
1965
+ return row;
1797
1966
  } catch {
1967
+ customGate.fail(config.url);
1798
1968
  return {
1799
1969
  provider,
1800
1970
  displayName,
1801
1971
  error: "unreachable"
1802
1972
  };
1803
- } finally {
1804
- clearTimeout(timer);
1805
1973
  }
1806
1974
  }));
1807
1975
  }
@@ -1818,6 +1986,12 @@ async function queryCustomBalances(ctx, configs) {
1818
1986
  */
1819
1987
  /** Abort a fetch when the upstream hangs beyond this budget. */
1820
1988
  const FETCH_TIMEOUT_MS = 8e3;
1989
+ /** 每平台熔断门:单个定价上游连续可重试失败(网络 / 5xx / 429)达阈值后短路,
1990
+ * 避免 6 小时刷新循环与每次启动在已故障的上游上反复打满超时。按 URL 独立。 */
1991
+ const pricingGate = createCooldownGate({
1992
+ failures: 3,
1993
+ cooldownMs: 6e4
1994
+ });
1821
1995
  /**
1822
1996
  * USD → CNY 汇率源,按顺序尝试:国内可达的腾讯财经行情(免 key、`~` 分隔
1823
1997
  * 第 4 个字段为价格)优先,国外 open.er-api.com 兜底。任一源失败自动落到
@@ -1900,18 +2074,35 @@ const ROUTER_ID_HINTS = {
1900
2074
  "llama": ["llama-4"],
1901
2075
  "kimi": ["kimi-k2"]
1902
2076
  };
1903
- /** 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. */
1904
2078
  async function fetchText(url) {
1905
- const controller = new AbortController();
1906
- 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
+ };
1907
2095
  try {
1908
- const response = await fetch(url, { signal: controller.signal });
1909
- if (!response.ok) return null;
1910
- 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;
1911
2103
  } catch {
2104
+ pricingGate.fail(url);
1912
2105
  return null;
1913
- } finally {
1914
- clearTimeout(timer);
1915
2106
  }
1916
2107
  }
1917
2108
  /** GET a JSON endpoint with a hard timeout; null on any failure. */
@@ -1938,7 +2129,10 @@ async function fetchRouterModels() {
1938
2129
  const data = await fetchJson(ROUTER_URL);
1939
2130
  if (data === null || typeof data !== "object") return void 0;
1940
2131
  const list = data.data;
1941
- 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
+ }
1942
2136
  const models = [];
1943
2137
  for (const item of list) {
1944
2138
  if (item === null || typeof item !== "object") continue;
@@ -2058,6 +2252,13 @@ async function fetchLivePricing() {
2058
2252
  * "no quota API" marker rather than hidden. API keys come from the `llm-pi-ai`
2059
2253
  * settings namespace (`apiKeyEnv` refs) resolved through the credentials seam.
2060
2254
  */
2255
+ /** 每平台熔断门:某个订阅适配器连续可重试失败(网络 / 5xx / 429)达阈值后
2256
+ * 短路由不可用,避免 30 秒轮询对已故障的上游反复打满超时。鉴权失败(unauthorized)
2257
+ * 是配置问题,不计入熔断。 */
2258
+ const subscriptionGate = createCooldownGate({
2259
+ failures: 3,
2260
+ cooldownMs: 6e4
2261
+ });
2061
2262
  /** 空凭据:全部未配置时的初始值。 */
2062
2263
  const EMPTY_SUBSCRIPTION_KEYS = {
2063
2264
  kimiApiKey: "",
@@ -2166,18 +2367,26 @@ function statusOf(error) {
2166
2367
  }
2167
2368
  return "unavailable";
2168
2369
  }
2169
- /** 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 不重试。 */
2170
2372
  async function requestJson(url, init, timeoutMs) {
2171
- const response = await fetch(url, {
2172
- ...init,
2173
- 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
2174
2389
  });
2175
- if (!response.ok) {
2176
- const error = /* @__PURE__ */ new Error(`HTTP ${String(response.status)}`);
2177
- error.httpStatus = response.status;
2178
- throw error;
2179
- }
2180
- return await response.json();
2181
2390
  }
2182
2391
  /** Parse one Kimi limit window entry. */
2183
2392
  function kimiWindow(value, kind) {
@@ -2559,18 +2768,41 @@ async function collectOpenRouter(keys, config, timeoutMs) {
2559
2768
  * @returns the quotas in plan order (unknown providers degrade to `unavailable`).
2560
2769
  */
2561
2770
  async function collectSubscriptions(keys, plans = [], timeoutMs = DEFAULT_TIMEOUT_MS) {
2562
- return await Promise.all(plans.map((plan) => {
2771
+ return await Promise.all(plans.map(async (plan) => {
2563
2772
  const adapter = SUBSCRIPTION_ADAPTERS[plan.provider];
2564
- if (adapter === void 0) return Promise.resolve({
2773
+ if (adapter === void 0) return {
2565
2774
  provider: plan.provider,
2566
2775
  displayName: plan.provider,
2567
2776
  status: "unavailable",
2568
2777
  windows: []
2569
- });
2570
- 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;
2571
2789
  }));
2572
2790
  }
2573
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
2574
2806
  //#region lib/types/index.js
2575
2807
  /**
2576
2808
  * Usage billing surface plugin, node half.
@@ -2583,6 +2815,10 @@ async function collectSubscriptions(keys, plans = [], timeoutMs = DEFAULT_TIMEOU
2583
2815
  * missing file answers `{ error }` so the dashboard shows zeros, never
2584
2816
  * fabricated samples.
2585
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) });
2586
2822
  /** 实时定价的后台刷新间隔(毫秒):汇率/模型价低频变化,6 小时一次足够。 */
2587
2823
  const PRICING_REFRESH_INTERVAL_MS = 360 * 60 * 1e3;
2588
2824
  /** 订阅套餐额度缓存时长(毫秒):上游配额 API 低频变化,5 分钟足够。 */
@@ -2596,6 +2832,21 @@ const DEFAULT_BALANCE_API_KEY_ENV = "DEEPSEEK_API_KEY";
2596
2832
  const PACKAGE_VERSION = createRequire(import.meta.url)("../package.json").version ?? "0.0.0";
2597
2833
  /** 统计快照的落盘节流(毫秒):前端 30 秒轮询,快照最多每 30 秒写一次。 */
2598
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
+ }
2599
2850
  /** Required services: the web server, the persisted session log store, and user settings. */
2600
2851
  const inject = [
2601
2852
  "webServer",
@@ -2689,6 +2940,7 @@ async function resolveSubscriptionKeys(settings, credentials) {
2689
2940
  * @param config - optional statsPath override.
2690
2941
  */
2691
2942
  function apply(ctx, config = {}) {
2943
+ let usageSettingsScope;
2692
2944
  const aggregator = createUsageAggregator(ctx.sessionPersistence, { ...config.subscriptionProviders === void 0 ? {} : { subscriptionProviders: config.subscriptionProviders } });
2693
2945
  const cwd = process.cwd();
2694
2946
  const snapshotPath = join(homedir(), ".dsh/.dsh-usage-stats.json");
@@ -2703,16 +2955,35 @@ function apply(ctx, config = {}) {
2703
2955
  const now = Date.now();
2704
2956
  if (now - lastSnapshotAt < SNAPSHOT_INTERVAL_MS) return;
2705
2957
  lastSnapshotAt = now;
2706
- writeFileAtomic(snapshotPath, JSON.stringify({
2958
+ const payload = JSON.stringify({
2707
2959
  ...doc,
2708
2960
  _writer: {
2709
2961
  pid: process.pid,
2710
2962
  at: now
2711
2963
  }
2712
- }), {
2713
- mode: 384,
2714
- dirMode: 448
2715
- }).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;
2716
2987
  };
2717
2988
  (async () => {
2718
2989
  try {
@@ -2721,94 +2992,118 @@ function apply(ctx, config = {}) {
2721
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})正在提供用量统计,双实例可能导致提醒重复。`);
2722
2993
  } catch {}
2723
2994
  })();
2724
- ctx.inject(["tools"], (toolsCtx) => {
2725
- toolsCtx.tools.register(defineTool({
2726
- name: "usage_stats",
2727
- description: "查询本机 DeepSeek Harness 的模型用量与估算费用(人民币,按官方目录价估算,非账单)。range 取值:today=今天,month=本月,session=当前会话,all=累计。",
2728
- parameters: { range: {
2729
- type: "string",
2730
- enum: [
2731
- "today",
2732
- "month",
2733
- "session",
2734
- "all"
2735
- ],
2736
- required: true,
2737
- description: "统计范围:today / month / session / all"
2738
- } },
2739
- output: {
2740
- schema: {
2741
- type: "object",
2742
- additionalProperties: false,
2743
- properties: {
2744
- range: {
2745
- type: "string",
2746
- required: true
2747
- },
2748
- cost: {
2749
- type: "number",
2750
- required: true,
2751
- description: "估算费用(人民币元)"
2752
- },
2753
- calls: {
2754
- type: "number",
2755
- required: true
2756
- },
2757
- input: {
2758
- type: "number",
2759
- required: true,
2760
- description: "输入 tokens"
2761
- },
2762
- output: {
2763
- type: "number",
2764
- required: true,
2765
- 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
+ }
2766
3041
  }
2767
- }
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
+ }]
2768
3047
  },
2769
- render: (_args, value) => [{
2770
- type: "text",
2771
- text: `用量(${value.range}):估算费用 ${formatMoney(value.cost)},调用 ${value.calls} 次,输入 ${formatTokens(value.input)} tokens,输出 ${formatTokens(value.output)} tokens`
2772
- }]
2773
- },
2774
- async execute(args, exec) {
2775
- const stats = await aggregator.aggregate();
2776
- const zero = {
2777
- range: args.range,
2778
- cost: 0,
2779
- calls: 0,
2780
- input: 0,
2781
- output: 0
2782
- };
2783
- if (args.range === "all") return {
2784
- range: args.range,
2785
- cost: stats.total.cost,
2786
- calls: stats.total.calls,
2787
- input: stats.total.input,
2788
- output: stats.total.output
2789
- };
2790
- if (args.range === "today") {
2791
- const day = stats.byDay[dayStamp(Date.now())];
2792
- return day === void 0 ? zero : {
3048
+ async execute(args, exec) {
3049
+ const stats = await aggregator.aggregate();
3050
+ const zero = {
2793
3051
  range: args.range,
2794
- cost: day.cost,
2795
- calls: day.calls,
2796
- input: day.input,
2797
- output: day.output
3052
+ cost: 0,
3053
+ calls: 0,
3054
+ input: 0,
3055
+ output: 0
2798
3056
  };
2799
- }
2800
- if (args.range === "month") {
2801
- 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 会话上下文");
2802
3097
  let cost = 0;
2803
3098
  let calls = 0;
2804
3099
  let input = 0;
2805
3100
  let output = 0;
2806
- for (const [date, day] of Object.entries(stats.byDay)) {
2807
- if (!date.startsWith(prefix)) continue;
2808
- cost += day.cost;
2809
- calls += day.calls;
2810
- input += day.input;
2811
- 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;
2812
3107
  }
2813
3108
  return {
2814
3109
  range: args.range,
@@ -2818,28 +3113,8 @@ function apply(ctx, config = {}) {
2818
3113
  output
2819
3114
  };
2820
3115
  }
2821
- const sessionId = exec.agent?.id;
2822
- if (sessionId === void 0) throw new Error("usage_stats 的 session 范围需要 agent 会话上下文");
2823
- let cost = 0;
2824
- let calls = 0;
2825
- let input = 0;
2826
- let output = 0;
2827
- for (const turn of stats.byTurn ?? []) {
2828
- if (turn.sessionId !== String(sessionId)) continue;
2829
- cost += turn.cost;
2830
- calls += 1;
2831
- input += turn.input;
2832
- output += turn.output;
2833
- }
2834
- return {
2835
- range: args.range,
2836
- cost,
2837
- calls,
2838
- input,
2839
- output
2840
- };
2841
- }
2842
- }));
3116
+ }));
3117
+ });
2843
3118
  });
2844
3119
  let live = { source: "builtin" };
2845
3120
  const refreshPricing = async () => {
@@ -2872,9 +3147,44 @@ function apply(ctx, config = {}) {
2872
3147
  if (providers["deepseek"] === void 0) providers["deepseek"] = { apiKeyEnv: config.balanceApiKeyEnv ?? DEFAULT_BALANCE_API_KEY_ENV };
2873
3148
  const balances = await queryBalances(ctx, providers);
2874
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);
2875
3151
  res.end(JSON.stringify({ balances: [...balances, ...custom] }));
2876
3152
  }
2877
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");
2878
3188
  let quotaCache = {
2879
3189
  at: 0,
2880
3190
  quotas: []
@@ -2886,7 +3196,7 @@ function apply(ctx, config = {}) {
2886
3196
  ...item.region === void 0 ? {} : { region: item.region }
2887
3197
  })))].map((row) => {
2888
3198
  const planType = planTypeOf(row.provider);
2889
- const subscriptionAmount = subscriptionCnyOf(row.provider);
3199
+ const subscriptionAmount = subscriptionFeeCnyOf(row.provider, live.rate);
2890
3200
  return {
2891
3201
  ...row,
2892
3202
  planType,
@@ -2900,6 +3210,7 @@ function apply(ctx, config = {}) {
2900
3210
  windows: [],
2901
3211
  planType: planTypeOf(item.provider)
2902
3212
  });
3213
+ for (const row of rows) if (row.status === "unauthorized") warnAuthOnce("subscription", row.provider, row.displayName);
2903
3214
  quotaCache = {
2904
3215
  at: Date.now(),
2905
3216
  quotas: rows
@@ -2936,15 +3247,15 @@ function apply(ctx, config = {}) {
2936
3247
  } catch (error) {
2937
3248
  console.error("[usage-billing] usage-stats aggregate failed, falling back to snapshot:", error);
2938
3249
  }
2939
- for (const candidate of candidates) try {
2940
- const text = await readFile(candidate, "utf8");
2941
- const doc = JSON.parse(text);
3250
+ for (const candidate of candidates) {
3251
+ const doc = await readSnapshot(candidate);
3252
+ if (doc === null) continue;
2942
3253
  if (config.monthlyBudget !== void 0) doc["budget"] = config.monthlyBudget;
2943
3254
  if (config.lowBalanceThreshold !== void 0) doc["lowBalanceThreshold"] = config.lowBalanceThreshold;
2944
3255
  doc["pluginVersion"] = PACKAGE_VERSION;
2945
3256
  res.end(JSON.stringify(doc));
2946
3257
  return;
2947
- } catch {}
3258
+ }
2948
3259
  res.end(JSON.stringify({ error: "usage stats unavailable" }));
2949
3260
  }
2950
3261
  }), "usage-billing: usage-stats route");