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

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
  {
@@ -956,6 +958,46 @@ const DEFAULT_SUBSCRIPTION_PROVIDERS = [
956
958
  function isOfficialProvider(provider) {
957
959
  return /^deepseek(?:-[a-z0-9-]+)?$/i.test(provider.trim());
958
960
  }
961
+ /** 由 baseURL 归一化出站点 origin(协议 + 主机 + 端口);解析失败回退原值。 */
962
+ function siteOriginOf(baseURL) {
963
+ try {
964
+ return new URL(baseURL).origin;
965
+ } catch {
966
+ return baseURL;
967
+ }
968
+ }
969
+ /**
970
+ * 把一个 provider 路由归类为站点引用。判定顺序(与路由在 provider 配置里的状态一致):
971
+ * - 路由存在于当前配置且配了 baseURL → 中转站 `site`(按 origin 归组,同站多 key 合并);
972
+ * - 路由存在于当前配置但无 baseURL → 厂商直连 `direct`;
973
+ * - 路由不在当前配置里 → `unknown`(改过名 / 删除过,是「读不到」而非「直连」)。
974
+ * @param provider - 会话日志里的 provider 路由名(request/header 的 `config.provider`)。
975
+ * @param routes - 当前 provider 路由视图(来自 llm-pi-ai providers)。
976
+ */
977
+ function siteRefOf(provider, routes) {
978
+ const view = routes[provider];
979
+ if (view !== void 0) {
980
+ if (view.baseURL !== void 0) return {
981
+ kind: "site",
982
+ origin: siteOriginOf(view.baseURL),
983
+ provider
984
+ };
985
+ return {
986
+ kind: "direct",
987
+ provider
988
+ };
989
+ }
990
+ return {
991
+ kind: "unknown",
992
+ provider
993
+ };
994
+ }
995
+ /** 站点桶的稳定 key:`site:<origin>` 与 `direct:<provider>` 分开,`unknown` 单一桶。 */
996
+ function siteBucketKey(ref) {
997
+ if (ref.kind === "site") return `site:${ref.origin ?? ""}`;
998
+ if (ref.kind === "direct") return `direct:${ref.provider}`;
999
+ return "unknown";
1000
+ }
959
1001
  /** Zeroed usage accumulator. */
960
1002
  function emptyUsage() {
961
1003
  return {
@@ -1079,12 +1121,13 @@ function turnState(turns, turn) {
1079
1121
  * (default: any `deepseek`-prefixed id). Others count as third-party.
1080
1122
  * @returns the per-session fold (cached by the incremental aggregator).
1081
1123
  */
1082
- function foldSession(events, subscriptionProviders, officialProviderIds) {
1124
+ function foldSession(events, subscriptionProviders, officialProviderIds, routes = {}) {
1083
1125
  const fold = {
1084
1126
  total: emptyUsage(),
1085
1127
  byModel: /* @__PURE__ */ new Map(),
1086
1128
  byDay: /* @__PURE__ */ new Map(),
1087
1129
  byDayModels: /* @__PURE__ */ new Map(),
1130
+ bySite: /* @__PURE__ */ new Map(),
1088
1131
  planCalls: /* @__PURE__ */ new Map(),
1089
1132
  turns: [],
1090
1133
  perf: [],
@@ -1099,6 +1142,7 @@ function foldSession(events, subscriptionProviders, officialProviderIds) {
1099
1142
  let key = "other";
1100
1143
  let subscription = false;
1101
1144
  let official = false;
1145
+ let siteBucket = "unknown";
1102
1146
  const turns = /* @__PURE__ */ new Map();
1103
1147
  const steps = /* @__PURE__ */ new Map();
1104
1148
  let lastOpenStepKey;
@@ -1143,6 +1187,7 @@ function foldSession(events, subscriptionProviders, officialProviderIds) {
1143
1187
  key = resolveCatalogKey(model);
1144
1188
  subscription = subscriptionProviders.has(provider);
1145
1189
  official = officialProviderIds === void 0 ? isOfficialProvider(provider) : officialProviderIds.has(provider);
1190
+ siteBucket = siteBucketKey(siteRefOf(provider, routes));
1146
1191
  if (lastOpenStepKey !== void 0) {
1147
1192
  const stepState = steps.get(lastOpenStepKey);
1148
1193
  if (stepState !== void 0 && stepState.requestTime === void 0) stepState.requestTime = event.time;
@@ -1172,6 +1217,7 @@ function foldSession(events, subscriptionProviders, officialProviderIds) {
1172
1217
  foldUsage(usageCell(fold.byModel, modelKey), usage, modelKey, subscription, event.time, official);
1173
1218
  foldUsage(usageCell(fold.byDay, day), usage, modelKey, subscription, event.time, official);
1174
1219
  foldUsage(modelDayCell(fold.byDayModels, day, modelKey), usage, modelKey, subscription, event.time, official);
1220
+ foldUsage(usageCell(fold.bySite, siteBucket), usage, modelKey, subscription, event.time, official);
1175
1221
  if (subscription) fold.planCalls.set(modelKey, (fold.planCalls.get(modelKey) ?? 0) + 1);
1176
1222
  const turn = event.data.turn ?? -1;
1177
1223
  const state = turnState(turns, turn);
@@ -1287,9 +1333,12 @@ function percentile(values, p) {
1287
1333
  function createUsageAggregator(persistence, options = {}) {
1288
1334
  const subscriptionProviders = new Set(options.subscriptionProviders ?? DEFAULT_SUBSCRIPTION_PROVIDERS);
1289
1335
  const officialProviderIds = options.officialProviderIds === void 0 ? void 0 : new Set(options.officialProviderIds);
1336
+ const maxCacheSessions = options.maxCacheSessions ?? 400;
1290
1337
  const cache = /* @__PURE__ */ new Map();
1291
1338
  let lastDoc;
1292
1339
  let lastAt = 0;
1340
+ /** 每次聚合取最新的 provider 路由视图(中转站零配置发现);缺省按空处理(全部未知路由)。 */
1341
+ const routesOf = () => options.resolveRoutes?.() ?? {};
1293
1342
  /** 失效键:日志文件的 mtime+size;拿不到(后端无 locate / 文件丢失)时每次重折。 */
1294
1343
  const stampOf = async (meta) => {
1295
1344
  const location = persistence.locate?.(meta);
@@ -1314,6 +1363,8 @@ function createUsageAggregator(persistence, options = {}) {
1314
1363
  const stamp = await stampOf(meta);
1315
1364
  const hit = cache.get(id);
1316
1365
  if (hit !== void 0 && stamp !== null && hit.stamp === stamp) {
1366
+ cache.delete(id);
1367
+ cache.set(id, hit);
1317
1368
  folds.push({
1318
1369
  meta,
1319
1370
  fold: hit.fold
@@ -1322,7 +1373,9 @@ function createUsageAggregator(persistence, options = {}) {
1322
1373
  }
1323
1374
  try {
1324
1375
  const { events } = await persistence.readFrom(meta.id, 0);
1325
- const fold = foldSession(events, subscriptionProviders, officialProviderIds);
1376
+ const after = await stampOf(meta);
1377
+ if (stamp !== null && after !== stamp) continue;
1378
+ const fold = foldSession(events, subscriptionProviders, officialProviderIds, routesOf());
1326
1379
  cache.set(id, {
1327
1380
  stamp,
1328
1381
  fold
@@ -1337,11 +1390,17 @@ function createUsageAggregator(persistence, options = {}) {
1337
1390
  }
1338
1391
  }
1339
1392
  for (const key of [...cache.keys()]) if (!seen.has(key)) cache.delete(key);
1393
+ while (cache.size > maxCacheSessions) {
1394
+ const oldest = cache.keys().next();
1395
+ if (oldest.done === true) break;
1396
+ cache.delete(oldest.value);
1397
+ }
1340
1398
  if (skipped.length > 0) console.warn(`[usage-billing] aggregated ${folds.length} sessions, skipped ${skipped.length} unreadable:`, skipped);
1341
1399
  const total = emptyUsage();
1342
1400
  const byModel = /* @__PURE__ */ new Map();
1343
1401
  const byDay = /* @__PURE__ */ new Map();
1344
1402
  const byDayModels = /* @__PURE__ */ new Map();
1403
+ const bySite = /* @__PURE__ */ new Map();
1345
1404
  const planCalls = /* @__PURE__ */ new Map();
1346
1405
  const sessionRows = [];
1347
1406
  const turnRows = [];
@@ -1364,6 +1423,7 @@ function createUsageAggregator(persistence, options = {}) {
1364
1423
  for (const [modelKey, cell] of fold.byModel) mergeUsageInto(usageCell(byModel, modelKey), cell);
1365
1424
  for (const [day, cell] of fold.byDay) mergeUsageInto(usageCell(byDay, day), cell);
1366
1425
  for (const [day, models] of fold.byDayModels) for (const [modelKey, cell] of models) mergeUsageInto(modelDayCell(byDayModels, day, modelKey), cell);
1426
+ for (const [siteKey, cell] of fold.bySite) mergeUsageInto(usageCell(bySite, siteKey), cell);
1367
1427
  for (const [modelKey, count] of fold.planCalls) planCalls.set(modelKey, (planCalls.get(modelKey) ?? 0) + count);
1368
1428
  for (const sample of fold.perf) {
1369
1429
  let modelAccum = perfModel.get(sample.model);
@@ -1395,7 +1455,7 @@ function createUsageAggregator(persistence, options = {}) {
1395
1455
  sessionId,
1396
1456
  ...row
1397
1457
  });
1398
- const wsName = workspaceNameOf(meta.cwd);
1458
+ const wsName = options.resolveWorkspaceTitle !== void 0 && meta.cwd !== void 0 ? options.resolveWorkspaceTitle(meta.cwd) ?? workspaceNameOf(meta.cwd) : workspaceNameOf(meta.cwd);
1399
1459
  const ws = workspaceMap.get(wsName) ?? {
1400
1460
  name: wsName,
1401
1461
  calls: 0,
@@ -1459,6 +1519,7 @@ function createUsageAggregator(persistence, options = {}) {
1459
1519
  bySession: sessionRows.slice(0, 100),
1460
1520
  byTurn: turnRows.slice(0, 200),
1461
1521
  byWorkspace: workspaces.slice(0, 100),
1522
+ ...bySite.size === 0 ? {} : { bySite: toRecord(bySite) },
1462
1523
  ...perf === void 0 ? {} : { perf },
1463
1524
  byRole: (() => {
1464
1525
  const chars = roles.userChars + roles.toolChars;
@@ -1475,24 +1536,110 @@ function createUsageAggregator(persistence, options = {}) {
1475
1536
  } };
1476
1537
  }
1477
1538
  //#endregion
1539
+ //#region lib/types/resilience.js
1540
+ /**
1541
+ * 上游网络请求的稳定性工具:有限重试(指数退避)与每平台熔断冷却门。
1542
+ *
1543
+ * 计费插件对三类上游做实时请求——pricing(汇率 / OpenRouter / models.dev)、
1544
+ * balance(各厂商余额)、subscriptions(各订阅额度)。单次失败会被调用方降级,
1545
+ * 但反复的瞬时失败(网络波动 / 5xx / 429)会让 30 秒轮询每次打满超时。这里提供
1546
+ * 两个纯工具:`withRetry` 对可重试错误做指数退避,`createCooldownGate` 在单一
1547
+ * 上游连续失败后短路一段时间,避免把请求打到已不可用的服务上。
1548
+ */
1549
+ /** 是否是可重试的错误:网络性失败(TypeError / Abort / Timeout)或 5xx / 429。
1550
+ * 401 / 403 鉴权失败与 404 不可重试——重试只放大错误、不会变好。 */
1551
+ function isRetryableError(error) {
1552
+ if (error instanceof Error) {
1553
+ if (error.name === "AbortError" || error.name === "TimeoutError") return true;
1554
+ if (error instanceof TypeError) return true;
1555
+ const status = error.httpStatus;
1556
+ if (typeof status === "number") return status === 429 || status >= 500;
1557
+ }
1558
+ return false;
1559
+ }
1560
+ /** 休眠指定毫秒(Promise 化 setTimeout)。 */
1561
+ function sleep(ms) {
1562
+ return new Promise((resolve) => setTimeout(resolve, ms));
1563
+ }
1564
+ /**
1565
+ * 对一次上游请求做有限重试:可重试错误时按指数退避(+同量级抖动)重试。
1566
+ * @param fn - 发起请求的异步函数。
1567
+ * @param options - 重试策略(见 {@link RetryOptions})。
1568
+ * @returns fn 的结果;重试耗尽后抛出最后一次错误。
1569
+ */
1570
+ async function withRetry(fn, options = {}) {
1571
+ const { retries = 1, baseDelayMs = 250, maxDelayMs = 2e3, shouldRetry = isRetryableError } = options;
1572
+ let lastError;
1573
+ for (let attempt = 0; attempt <= retries; attempt += 1) try {
1574
+ return await fn();
1575
+ } catch (error) {
1576
+ lastError = error;
1577
+ if (attempt >= retries || !shouldRetry(error)) throw error;
1578
+ const delay = Math.min(maxDelayMs, baseDelayMs * 2 ** attempt);
1579
+ await sleep(delay / 2 + Math.random() * (delay / 2));
1580
+ }
1581
+ throw lastError;
1582
+ }
1583
+ /** 每平台冷熔断:连续失败达阈值后短路一段真实时间,成功后复位。 */
1584
+ function createCooldownGate(options = {}) {
1585
+ const { failures = 3, cooldownMs = 3e4 } = options;
1586
+ const state = /* @__PURE__ */ new Map();
1587
+ return {
1588
+ check(key) {
1589
+ const s = state.get(key);
1590
+ if (s === void 0) return true;
1591
+ if (s.until > 0 && Date.now() < s.until) return false;
1592
+ return true;
1593
+ },
1594
+ fail(key) {
1595
+ const s = state.get(key) ?? {
1596
+ count: 0,
1597
+ until: 0
1598
+ };
1599
+ s.count += 1;
1600
+ if (s.count >= failures) {
1601
+ s.until = Date.now() + cooldownMs;
1602
+ s.count = 0;
1603
+ }
1604
+ state.set(key, s);
1605
+ },
1606
+ success(key) {
1607
+ state.delete(key);
1608
+ }
1609
+ };
1610
+ }
1611
+ //#endregion
1478
1612
  //#region lib/types/balance.js
1479
1613
  /**
1480
1614
  * Account-balance queries for the billing dashboard.
1481
1615
  *
1482
1616
  * 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.
1617
+ * DeepSeek, Moonshot/Kimi, StepFun, SiliconFlow, and xAI (Grok) — all Bearer
1618
+ * 鉴权 with a documented JSON shape; the other mainstream providers expose no
1619
+ * standard balance API (or require a non-Bearer auth flow), so their rows in
1620
+ * the model table show an unavailable state. The lookup map below is the
1621
+ * extension point for future providers.
1489
1622
  *
1490
1623
  * API keys are read from the `llm-pi-ai` settings namespace (`providers.<id>.apiKeyEnv`),
1491
1624
  * the same source the subscription adapter uses, so a deployment configures a
1492
1625
  * provider's key once and every surface reuses it.
1493
1626
  */
1494
1627
  /** Abort a balance fetch when the upstream hangs beyond this budget. */
1495
- const FETCH_TIMEOUT_MS$1 = 8e3;
1628
+ const FETCH_TIMEOUT_MS$2 = 8e3;
1629
+ /**
1630
+ * 每平台熔断门:单个 provider 连续可重试失败(网络波动 / 5xx / 429)达阈值后
1631
+ * 短路一段真实时间,避免 30 秒轮询在已不可用的上游上反复打满超时。
1632
+ * 鉴权失败(unauthorized)是配置问题而非暂时故障,不计入熔断。
1633
+ */
1634
+ const balanceGate = createCooldownGate({
1635
+ failures: 3,
1636
+ cooldownMs: 6e4
1637
+ });
1638
+ /** 自定义 Provider 余额的熔断门:按端点 URL 独立熔断(各配置端点互不干扰)。 */
1639
+ const customGate = createCooldownGate({
1640
+ failures: 3,
1641
+ cooldownMs: 6e4
1642
+ });
1496
1643
  /** DeepSeek 官方余额接口(官方文档 api-docs.deepseek.com/api/get-user-balance)。 */
1497
1644
  const DEEPSEEK_BALANCE_URL = "https://api.deepseek.com/user/balance";
1498
1645
  /** Moonshot/Kimi 官方余额接口(platform.kimi.com/docs/api/balance)。 */
@@ -1501,6 +1648,10 @@ const MOONSHOT_BALANCE_URL = "https://api.moonshot.cn/v1/users/me/balance";
1501
1648
  const STEPFUN_BALANCE_URL = "https://api.stepfun.com/v1/accounts";
1502
1649
  /** 硅基流动 SiliconFlow 官方用户信息接口(docs.siliconflow.cn/cn/api-reference/user/query-user-info)。 */
1503
1650
  const SILICONFLOW_BALANCE_URL = "https://api.siliconflow.cn/v1/user/info";
1651
+ /** xAI 官方账单接口(docs.x.ai/developers/api/credits);total.val 为美分。 */
1652
+ const XAI_CREDITS_URL = "https://api.x.ai/v1/billing/credits";
1653
+ /** 智谱 GLM(大模型国内域)官方余额接口(open.bigmodel.cn/api/paas/v4/balance)。 */
1654
+ const ZHIPU_BALANCE_URL = "https://open.bigmodel.cn/api/paas/v4/balance";
1504
1655
  /** 数字归一化:接口返回的余额是字符串(如 `"110.00"`),统一转 number。 */
1505
1656
  function toNumber(value) {
1506
1657
  if (typeof value === "number" && Number.isFinite(value)) return value;
@@ -1528,35 +1679,60 @@ async function queryBearerBalance(ctx, url, apiKeyEnv, provider, displayName, pa
1528
1679
  displayName,
1529
1680
  error: "unconfigured"
1530
1681
  };
1531
- const controller = new AbortController();
1532
- const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS$1);
1682
+ if (!balanceGate.check(provider)) return {
1683
+ provider,
1684
+ displayName,
1685
+ error: "unreachable"
1686
+ };
1687
+ const doRequest = async () => {
1688
+ const controller = new AbortController();
1689
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS$2);
1690
+ try {
1691
+ const response = await fetch(url, {
1692
+ headers: {
1693
+ accept: "application/json",
1694
+ authorization: `Bearer ${hit.value}`
1695
+ },
1696
+ signal: controller.signal
1697
+ });
1698
+ if (response.status === 401 || response.status === 403) return {
1699
+ provider,
1700
+ displayName,
1701
+ error: "unauthorized"
1702
+ };
1703
+ if (!response.ok) {
1704
+ const error = /* @__PURE__ */ new Error(`HTTP ${String(response.status)}`);
1705
+ error.httpStatus = response.status;
1706
+ throw error;
1707
+ }
1708
+ const row = parse(await response.json());
1709
+ if (row.totalBalance === void 0 && row.grantedBalance === void 0 && row.toppedUpBalance === void 0 && row.isAvailable === void 0) {
1710
+ console.warn(`[usage-billing] balance response drifted for ${displayName}: no balance field parsed from ${url}`);
1711
+ return {
1712
+ ...row,
1713
+ error: "invalid"
1714
+ };
1715
+ }
1716
+ return row;
1717
+ } finally {
1718
+ clearTimeout(timer);
1719
+ }
1720
+ };
1533
1721
  try {
1534
- const response = await fetch(url, {
1535
- headers: {
1536
- accept: "application/json",
1537
- authorization: `Bearer ${hit.value}`
1538
- },
1539
- signal: controller.signal
1722
+ const row = await withRetry(doRequest, {
1723
+ retries: 1,
1724
+ baseDelayMs: 250,
1725
+ maxDelayMs: 2e3
1540
1726
  });
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());
1727
+ balanceGate.success(provider);
1728
+ return row;
1552
1729
  } catch {
1730
+ balanceGate.fail(provider);
1553
1731
  return {
1554
1732
  provider,
1555
1733
  displayName,
1556
1734
  error: "unreachable"
1557
1735
  };
1558
- } finally {
1559
- clearTimeout(timer);
1560
1736
  }
1561
1737
  }
1562
1738
  /**
@@ -1648,6 +1824,45 @@ function querySiliconFlow(ctx, apiKeyEnv) {
1648
1824
  };
1649
1825
  });
1650
1826
  }
1827
+ /**
1828
+ * Query the xAI (Grok) credit balance.
1829
+ * @param ctx - host context carrying the credentials seam.
1830
+ * @param apiKeyEnv - credential reference resolving the xAI API key.
1831
+ * @returns the balance row, or an error row when the key/endpoint misbehaves.
1832
+ */
1833
+ function queryXai(ctx, apiKeyEnv) {
1834
+ return queryBearerBalance(ctx, XAI_CREDITS_URL, apiKeyEnv, "xAI", "xAI", (data) => {
1835
+ const cents = toNumber(data.total?.val);
1836
+ const totalBalance = cents === void 0 ? void 0 : Math.abs(cents) / 100;
1837
+ return {
1838
+ provider: "xAI",
1839
+ displayName: "xAI",
1840
+ currency: "USD",
1841
+ ...totalBalance !== void 0 ? { totalBalance } : {}
1842
+ };
1843
+ });
1844
+ }
1845
+ /**
1846
+ * Query the Zhipu GLM / Z.ai (国内 bigmodel-cn 域) account balance.
1847
+ * 与订阅(zai-coding-cn 的 Coding Plan)互补:一个平台可同时有钱包余额与订阅
1848
+ * 套餐,两者各读各的(TokenLedger 同款双读姿态)。Z.ai global 域币种为 USD,
1849
+ * 本函数固定走国内 CNY 域(open.bigmodel.cn),币种不猜,仅覆盖国内域。
1850
+ * @param ctx - host context carrying the credentials seam.
1851
+ * @param apiKeyEnv - credential reference resolving the Zhipu API key.
1852
+ * @returns the balance row, or an error row when the key/endpoint misbehaves.
1853
+ */
1854
+ function queryZhipu(ctx, apiKeyEnv) {
1855
+ return queryBearerBalance(ctx, ZHIPU_BALANCE_URL, apiKeyEnv, "智谱 AI", "智谱 AI", (data) => {
1856
+ const doc = data;
1857
+ const totalBalance = toNumber(doc.balance?.available) ?? toNumber(doc.balance?.total);
1858
+ return {
1859
+ provider: "智谱 AI",
1860
+ displayName: "智谱 AI",
1861
+ currency: "CNY",
1862
+ ...totalBalance !== void 0 ? { totalBalance } : {}
1863
+ };
1864
+ });
1865
+ }
1651
1866
  const QUERIERS = [
1652
1867
  {
1653
1868
  route: "deepseek",
@@ -1668,6 +1883,21 @@ const QUERIERS = [
1668
1883
  route: "siliconflow",
1669
1884
  displayName: "硅基流动",
1670
1885
  querier: querySiliconFlow
1886
+ },
1887
+ {
1888
+ route: "xai",
1889
+ displayName: "xAI",
1890
+ querier: queryXai
1891
+ },
1892
+ {
1893
+ route: "zhipu",
1894
+ displayName: "智谱 AI",
1895
+ querier: queryZhipu
1896
+ },
1897
+ {
1898
+ route: "zai-coding-cn",
1899
+ displayName: "智谱 AI",
1900
+ querier: queryZhipu
1671
1901
  }
1672
1902
  ];
1673
1903
  /**
@@ -1764,47 +1994,64 @@ async function queryCustomBalances(ctx, configs) {
1764
1994
  displayName,
1765
1995
  error: "unconfigured"
1766
1996
  };
1767
- const controller = new AbortController();
1768
- const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS$1);
1997
+ if (!customGate.check(config.url)) return {
1998
+ provider,
1999
+ displayName,
2000
+ error: "unreachable"
2001
+ };
2002
+ const doRequest = async () => {
2003
+ const controller = new AbortController();
2004
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS$2);
2005
+ try {
2006
+ const response = await fetch(config.url, {
2007
+ method: config.method ?? "GET",
2008
+ headers: {
2009
+ accept: "application/json",
2010
+ ...headers
2011
+ },
2012
+ signal: controller.signal
2013
+ });
2014
+ if (response.status === 401 || response.status === 403) return {
2015
+ provider,
2016
+ displayName,
2017
+ error: "unauthorized"
2018
+ };
2019
+ if (!response.ok) {
2020
+ const error = /* @__PURE__ */ new Error(`HTTP ${String(response.status)}`);
2021
+ error.httpStatus = response.status;
2022
+ throw error;
2023
+ }
2024
+ const remaining = evalExtract(config.extract.remaining, await response.json());
2025
+ if (remaining === void 0) return {
2026
+ provider,
2027
+ displayName,
2028
+ error: "invalid"
2029
+ };
2030
+ return {
2031
+ provider,
2032
+ displayName,
2033
+ currency: config.unit ?? "CNY",
2034
+ totalBalance: remaining
2035
+ };
2036
+ } finally {
2037
+ clearTimeout(timer);
2038
+ }
2039
+ };
1769
2040
  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
2041
+ const row = await withRetry(doRequest, {
2042
+ retries: 1,
2043
+ baseDelayMs: 250,
2044
+ maxDelayMs: 2e3
1777
2045
  });
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
- };
2046
+ customGate.success(config.url);
2047
+ return row;
1800
2048
  } catch {
2049
+ customGate.fail(config.url);
1801
2050
  return {
1802
2051
  provider,
1803
2052
  displayName,
1804
2053
  error: "unreachable"
1805
2054
  };
1806
- } finally {
1807
- clearTimeout(timer);
1808
2055
  }
1809
2056
  }));
1810
2057
  }
@@ -1820,7 +2067,13 @@ async function queryCustomBalances(ctx, configs) {
1820
2067
  * catalog for the rest — a total outage answers `{ source: 'builtin' }`.
1821
2068
  */
1822
2069
  /** Abort a fetch when the upstream hangs beyond this budget. */
1823
- const FETCH_TIMEOUT_MS = 8e3;
2070
+ const FETCH_TIMEOUT_MS$1 = 8e3;
2071
+ /** 每平台熔断门:单个定价上游连续可重试失败(网络 / 5xx / 429)达阈值后短路,
2072
+ * 避免 6 小时刷新循环与每次启动在已故障的上游上反复打满超时。按 URL 独立。 */
2073
+ const pricingGate = createCooldownGate({
2074
+ failures: 3,
2075
+ cooldownMs: 6e4
2076
+ });
1824
2077
  /**
1825
2078
  * USD → CNY 汇率源,按顺序尝试:国内可达的腾讯财经行情(免 key、`~` 分隔
1826
2079
  * 第 4 个字段为价格)优先,国外 open.er-api.com 兜底。任一源失败自动落到
@@ -1903,18 +2156,35 @@ const ROUTER_ID_HINTS = {
1903
2156
  "llama": ["llama-4"],
1904
2157
  "kimi": ["kimi-k2"]
1905
2158
  };
1906
- /** GET a URL's text body with a hard timeout; null on any failure. */
2159
+ /** GET a URL's text body with a hard timeout and retry; null on any failure. */
1907
2160
  async function fetchText(url) {
1908
- const controller = new AbortController();
1909
- const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
2161
+ if (!pricingGate.check(url)) return null;
2162
+ const doFetch = async () => {
2163
+ const controller = new AbortController();
2164
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS$1);
2165
+ try {
2166
+ const response = await fetch(url, { signal: controller.signal });
2167
+ if (!response.ok) {
2168
+ const error = /* @__PURE__ */ new Error(`HTTP ${String(response.status)}`);
2169
+ error.httpStatus = response.status;
2170
+ throw error;
2171
+ }
2172
+ return await response.text();
2173
+ } finally {
2174
+ clearTimeout(timer);
2175
+ }
2176
+ };
1910
2177
  try {
1911
- const response = await fetch(url, { signal: controller.signal });
1912
- if (!response.ok) return null;
1913
- return await response.text();
2178
+ const text = await withRetry(doFetch, {
2179
+ retries: 1,
2180
+ baseDelayMs: 250,
2181
+ maxDelayMs: 2e3
2182
+ });
2183
+ pricingGate.success(url);
2184
+ return text;
1914
2185
  } catch {
2186
+ pricingGate.fail(url);
1915
2187
  return null;
1916
- } finally {
1917
- clearTimeout(timer);
1918
2188
  }
1919
2189
  }
1920
2190
  /** GET a JSON endpoint with a hard timeout; null on any failure. */
@@ -1941,7 +2211,10 @@ async function fetchRouterModels() {
1941
2211
  const data = await fetchJson(ROUTER_URL);
1942
2212
  if (data === null || typeof data !== "object") return void 0;
1943
2213
  const list = data.data;
1944
- if (!Array.isArray(list)) return void 0;
2214
+ if (!Array.isArray(list)) {
2215
+ console.warn("[usage-billing] openrouter models response drifted: expected a `data` array");
2216
+ return;
2217
+ }
1945
2218
  const models = [];
1946
2219
  for (const item of list) {
1947
2220
  if (item === null || typeof item !== "object") continue;
@@ -2061,6 +2334,13 @@ async function fetchLivePricing() {
2061
2334
  * "no quota API" marker rather than hidden. API keys come from the `llm-pi-ai`
2062
2335
  * settings namespace (`apiKeyEnv` refs) resolved through the credentials seam.
2063
2336
  */
2337
+ /** 每平台熔断门:某个订阅适配器连续可重试失败(网络 / 5xx / 429)达阈值后
2338
+ * 短路由不可用,避免 30 秒轮询对已故障的上游反复打满超时。鉴权失败(unauthorized)
2339
+ * 是配置问题,不计入熔断。 */
2340
+ const subscriptionGate = createCooldownGate({
2341
+ failures: 3,
2342
+ cooldownMs: 6e4
2343
+ });
2064
2344
  /** 空凭据:全部未配置时的初始值。 */
2065
2345
  const EMPTY_SUBSCRIPTION_KEYS = {
2066
2346
  kimiApiKey: "",
@@ -2132,7 +2412,7 @@ function identifySubscriptionPlans(providers) {
2132
2412
  }
2133
2413
  const DEFAULT_TIMEOUT_MS = 15e3;
2134
2414
  /** Number, or null when the value is not a finite number (nor numeric string). */
2135
- function numberOrNull(value) {
2415
+ function numberOrNull$1(value) {
2136
2416
  if (typeof value === "number" && Number.isFinite(value)) return value;
2137
2417
  if (typeof value === "string" && value.trim() !== "") {
2138
2418
  const parsed = Number(value);
@@ -2141,11 +2421,11 @@ function numberOrNull(value) {
2141
2421
  return null;
2142
2422
  }
2143
2423
  /** Clamp a percentage to 0–100. */
2144
- function clampPercent(value) {
2424
+ function clampPercent$1(value) {
2145
2425
  return value === null ? null : Math.max(0, Math.min(100, value));
2146
2426
  }
2147
2427
  /** Round to one decimal. */
2148
- function round1(value) {
2428
+ function round1$1(value) {
2149
2429
  return Math.round(value * 10) / 10;
2150
2430
  }
2151
2431
  /** Number → ISO string (seconds treated as epoch seconds, ms as epoch ms). */
@@ -2159,7 +2439,7 @@ function toIso(value) {
2159
2439
  return Number.isNaN(date.getTime()) ? null : date.toISOString();
2160
2440
  }
2161
2441
  /** Map a fetch error to a stable status. */
2162
- function statusOf(error) {
2442
+ function statusOf$1(error) {
2163
2443
  if (error instanceof Error) {
2164
2444
  if (error.name === "TimeoutError" || error.name === "AbortError") return "unavailable";
2165
2445
  const status = error.httpStatus;
@@ -2169,38 +2449,46 @@ function statusOf(error) {
2169
2449
  }
2170
2450
  return "unavailable";
2171
2451
  }
2172
- /** One JSON fetch with a timeout, mapping HTTP failures to typed errors. */
2452
+ /** One JSON fetch with a timeout, mapping HTTP failures to typed errors.
2453
+ * 可重试错误(网络 / 5xx / 429)用指数退避重试一次;401/403/404 不重试。 */
2173
2454
  async function requestJson(url, init, timeoutMs) {
2174
- const response = await fetch(url, {
2175
- ...init,
2176
- signal: AbortSignal.timeout(timeoutMs)
2455
+ const doFetch = async () => {
2456
+ const response = await fetch(url, {
2457
+ ...init,
2458
+ signal: AbortSignal.timeout(timeoutMs)
2459
+ });
2460
+ if (!response.ok) {
2461
+ const error = /* @__PURE__ */ new Error(`HTTP ${String(response.status)}`);
2462
+ error.httpStatus = response.status;
2463
+ throw error;
2464
+ }
2465
+ return await response.json();
2466
+ };
2467
+ return await withRetry(doFetch, {
2468
+ retries: 1,
2469
+ baseDelayMs: 250,
2470
+ maxDelayMs: 2e3
2177
2471
  });
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
2472
  }
2185
2473
  /** Parse one Kimi limit window entry. */
2186
2474
  function kimiWindow(value, kind) {
2187
2475
  if (value === null || typeof value !== "object") return null;
2188
2476
  const record = value;
2189
- const limit = numberOrNull(record.limit ?? record.total);
2190
- const remaining = numberOrNull(record.remaining);
2191
- if (remaining === null && limit === null && numberOrNull(record.percentage ?? record.usedPercent ?? record.used_percent) === null) return null;
2477
+ const limit = numberOrNull$1(record.limit ?? record.total);
2478
+ const remaining = numberOrNull$1(record.remaining);
2479
+ if (remaining === null && limit === null && numberOrNull$1(record.percentage ?? record.usedPercent ?? record.used_percent) === null) return null;
2192
2480
  const hasLimit = limit !== null && limit > 0;
2193
2481
  let usedPercent;
2194
- if (hasLimit) usedPercent = round1(clampPercent((limit - (remaining ?? 0)) / limit * 100) ?? 0);
2482
+ if (hasLimit) usedPercent = round1$1(clampPercent$1((limit - (remaining ?? 0)) / limit * 100) ?? 0);
2195
2483
  else {
2196
- const percent = numberOrNull(record.percentage ?? record.usedPercent ?? record.used_percent);
2197
- usedPercent = percent !== null ? round1(clampPercent(percent) ?? 0) : remaining === null || remaining <= 0 ? 100 : 0;
2484
+ const percent = numberOrNull$1(record.percentage ?? record.usedPercent ?? record.used_percent);
2485
+ usedPercent = percent !== null ? round1$1(clampPercent$1(percent) ?? 0) : remaining === null || remaining <= 0 ? 100 : 0;
2198
2486
  }
2199
2487
  const resetsAt = toIso(record.resetTime ?? record.reset_time ?? record.resetsAt);
2200
2488
  return {
2201
2489
  kind,
2202
2490
  usedPercent,
2203
- remainingPercent: round1(100 - usedPercent),
2491
+ remainingPercent: round1$1(100 - usedPercent),
2204
2492
  ...remaining !== null ? { remaining } : {},
2205
2493
  ...resetsAt === null ? {} : { resetsAt }
2206
2494
  };
@@ -2242,15 +2530,15 @@ async function collectKimi(keys, config, timeoutMs) {
2242
2530
  return {
2243
2531
  provider: config.provider,
2244
2532
  displayName: "Kimi For Coding",
2245
- status: statusOf(error),
2533
+ status: statusOf$1(error),
2246
2534
  windows: []
2247
2535
  };
2248
2536
  }
2249
2537
  }
2250
2538
  /** Window length in minutes for a Z.ai limit row; null when unknown. */
2251
2539
  function zaiWindowMinutes(limit) {
2252
- const unit = numberOrNull(limit.unit);
2253
- const number = numberOrNull(limit.number);
2540
+ const unit = numberOrNull$1(limit.unit);
2541
+ const number = numberOrNull$1(limit.number);
2254
2542
  if (unit === null || number === null || number <= 0) return null;
2255
2543
  if (unit === 5) return number;
2256
2544
  if (unit === 3) return number * 60;
@@ -2260,14 +2548,14 @@ function zaiWindowMinutes(limit) {
2260
2548
  }
2261
2549
  /** Used percent for a Z.ai limit row. */
2262
2550
  function zaiUsedPercent(limit) {
2263
- const total = numberOrNull(limit.usage);
2264
- const remaining = numberOrNull(limit.remaining);
2265
- const current = numberOrNull(limit.currentValue ?? limit.current_value);
2551
+ const total = numberOrNull$1(limit.usage);
2552
+ const remaining = numberOrNull$1(limit.remaining);
2553
+ const current = numberOrNull$1(limit.currentValue ?? limit.current_value);
2266
2554
  if (total !== null && total > 0) {
2267
2555
  const used = remaining === null ? current : current === null ? total - remaining : Math.max(total - remaining, current);
2268
- if (used !== null) return clampPercent(Math.max(0, Math.min(total, used)) / total * 100);
2556
+ if (used !== null) return clampPercent$1(Math.max(0, Math.min(total, used)) / total * 100);
2269
2557
  }
2270
- return clampPercent(numberOrNull(limit.percentage ?? limit.usedPercent ?? limit.used_percent));
2558
+ return clampPercent$1(numberOrNull$1(limit.percentage ?? limit.usedPercent ?? limit.used_percent));
2271
2559
  }
2272
2560
  /** One Z.ai quota window row. */
2273
2561
  function zaiWindow(limit, kind, fallbackReset = null) {
@@ -2276,8 +2564,8 @@ function zaiWindow(limit, kind, fallbackReset = null) {
2276
2564
  const resetsAt = toIso(limit.nextResetTime ?? limit.next_reset_time) ?? fallbackReset;
2277
2565
  return {
2278
2566
  kind,
2279
- usedPercent: round1(usedPercent),
2280
- remainingPercent: round1(100 - usedPercent),
2567
+ usedPercent: round1$1(usedPercent),
2568
+ remainingPercent: round1$1(100 - usedPercent),
2281
2569
  ...resetsAt === null ? {} : { resetsAt }
2282
2570
  };
2283
2571
  }
@@ -2362,7 +2650,7 @@ async function collectZai(keys, config, timeoutMs) {
2362
2650
  return {
2363
2651
  provider: config.provider,
2364
2652
  displayName: "Z.ai Coding Plan",
2365
- status: statusOf(error),
2653
+ status: statusOf$1(error),
2366
2654
  windows: []
2367
2655
  };
2368
2656
  }
@@ -2372,20 +2660,20 @@ function goWindow(value, kind) {
2372
2660
  if (value === null || typeof value !== "object") return null;
2373
2661
  const record = value;
2374
2662
  const percentSource = record.usagePercent ?? record.usedPercent ?? record.percentUsed ?? record.percentage ?? record.percent;
2375
- let usedPercent = clampPercent(numberOrNull(percentSource));
2663
+ let usedPercent = clampPercent$1(numberOrNull$1(percentSource));
2376
2664
  if (usedPercent === null) {
2377
- const used = numberOrNull(record.used ?? record.consumed);
2378
- const limit = numberOrNull(record.limit ?? record.total ?? record.quota);
2379
- if (used !== null && limit !== null && limit > 0) usedPercent = clampPercent(used / limit * 100);
2665
+ const used = numberOrNull$1(record.used ?? record.consumed);
2666
+ const limit = numberOrNull$1(record.limit ?? record.total ?? record.quota);
2667
+ if (used !== null && limit !== null && limit > 0) usedPercent = clampPercent$1(used / limit * 100);
2380
2668
  }
2381
2669
  if (usedPercent === null) return null;
2382
2670
  if (usedPercent <= 1 && usedPercent >= 0 && record.percent === void 0 && percentSource !== void 0) usedPercent *= 100;
2383
- const resetSeconds = numberOrNull(record.resetInSec ?? record.resetInSeconds ?? record.resetSeconds);
2671
+ const resetSeconds = numberOrNull$1(record.resetInSec ?? record.resetInSeconds ?? record.resetSeconds);
2384
2672
  const resetsAt = resetSeconds === null ? toIso(record.resetAt ?? record.resetsAt ?? record.nextReset) : new Date(Date.now() + Math.max(0, resetSeconds) * 1e3).toISOString();
2385
2673
  return {
2386
2674
  kind,
2387
- usedPercent: round1(clampPercent(usedPercent) ?? 0),
2388
- remainingPercent: round1(100 - (clampPercent(usedPercent) ?? 0)),
2675
+ usedPercent: round1$1(clampPercent$1(usedPercent) ?? 0),
2676
+ remainingPercent: round1$1(100 - (clampPercent$1(usedPercent) ?? 0)),
2389
2677
  ...resetsAt === null ? {} : { resetsAt }
2390
2678
  };
2391
2679
  }
@@ -2425,7 +2713,7 @@ async function collectOpenCodeGo(keys, config, timeoutMs) {
2425
2713
  return {
2426
2714
  provider: config.provider,
2427
2715
  displayName: "OpenCode Go",
2428
- status: statusOf(error),
2716
+ status: statusOf$1(error),
2429
2717
  windows: []
2430
2718
  };
2431
2719
  }
@@ -2442,14 +2730,14 @@ async function collectOpenCodeGo(keys, config, timeoutMs) {
2442
2730
  function minmaxWindow(record, kind, remainPctKey, statusKey, resetKey) {
2443
2731
  if (record === void 0) return null;
2444
2732
  if (Number(record[statusKey]) === 3) return null;
2445
- const remain = numberOrNull(record[remainPctKey]);
2733
+ const remain = numberOrNull$1(record[remainPctKey]);
2446
2734
  if (remain === null) return null;
2447
- const usedPercent = round1(clampPercent(100 - (remain <= 1 ? remain * 100 : remain)) ?? 0);
2735
+ const usedPercent = round1$1(clampPercent$1(100 - (remain <= 1 ? remain * 100 : remain)) ?? 0);
2448
2736
  const resetsAt = toIso(record[resetKey]);
2449
2737
  return {
2450
2738
  kind,
2451
2739
  usedPercent,
2452
- remainingPercent: round1(100 - usedPercent),
2740
+ remainingPercent: round1$1(100 - usedPercent),
2453
2741
  ...resetsAt === null ? {} : { resetsAt }
2454
2742
  };
2455
2743
  }
@@ -2496,7 +2784,7 @@ async function collectMiniMax(keys, config, timeoutMs) {
2496
2784
  return {
2497
2785
  provider: config.provider,
2498
2786
  displayName: "MiniMax Coding Plan",
2499
- status: statusOf(error),
2787
+ status: statusOf$1(error),
2500
2788
  windows: []
2501
2789
  };
2502
2790
  }
@@ -2510,15 +2798,15 @@ async function collectMiniMax(keys, config, timeoutMs) {
2510
2798
  function parseOpenRouterCredits(body) {
2511
2799
  const doc = body ?? {};
2512
2800
  const data = doc.data ?? doc;
2513
- const total = numberOrNull(data.total_credits ?? data.credits);
2514
- const used = numberOrNull(data.total_usage ?? data.usage);
2801
+ const total = numberOrNull$1(data.total_credits ?? data.credits);
2802
+ const used = numberOrNull$1(data.total_usage ?? data.usage);
2515
2803
  if (total === null || total <= 0 || used === null) return [];
2516
- const usedPercent = round1(clampPercent(used / total * 100) ?? 0);
2804
+ const usedPercent = round1$1(clampPercent$1(used / total * 100) ?? 0);
2517
2805
  const resetsAt = toIso(data.resets_at ?? data.next_reset_time);
2518
2806
  return [{
2519
2807
  kind: "billing",
2520
2808
  usedPercent,
2521
- remainingPercent: round1(100 - usedPercent),
2809
+ remainingPercent: round1$1(100 - usedPercent),
2522
2810
  ...resetsAt === null ? {} : { resetsAt }
2523
2811
  }];
2524
2812
  }
@@ -2547,7 +2835,7 @@ async function collectOpenRouter(keys, config, timeoutMs) {
2547
2835
  return {
2548
2836
  provider: config.provider,
2549
2837
  displayName: "OpenRouter",
2550
- status: statusOf(error),
2838
+ status: statusOf$1(error),
2551
2839
  windows: []
2552
2840
  };
2553
2841
  }
@@ -2562,18 +2850,297 @@ async function collectOpenRouter(keys, config, timeoutMs) {
2562
2850
  * @returns the quotas in plan order (unknown providers degrade to `unavailable`).
2563
2851
  */
2564
2852
  async function collectSubscriptions(keys, plans = [], timeoutMs = DEFAULT_TIMEOUT_MS) {
2565
- return await Promise.all(plans.map((plan) => {
2853
+ return await Promise.all(plans.map(async (plan) => {
2566
2854
  const adapter = SUBSCRIPTION_ADAPTERS[plan.provider];
2567
- if (adapter === void 0) return Promise.resolve({
2855
+ if (adapter === void 0) return {
2568
2856
  provider: plan.provider,
2569
2857
  displayName: plan.provider,
2570
2858
  status: "unavailable",
2571
2859
  windows: []
2572
- });
2573
- return adapter.collect(keys, plan, timeoutMs);
2860
+ };
2861
+ if (!subscriptionGate.check(plan.provider)) return {
2862
+ provider: plan.provider,
2863
+ displayName: plan.provider,
2864
+ status: "unavailable",
2865
+ windows: []
2866
+ };
2867
+ const quota = await adapter.collect(keys, plan, timeoutMs);
2868
+ if (quota.status === "unavailable" || quota.status === "rate-limited") subscriptionGate.fail(plan.provider);
2869
+ else subscriptionGate.success(plan.provider);
2870
+ return quota;
2574
2871
  }));
2575
2872
  }
2576
2873
  //#endregion
2874
+ //#region lib/types/relay.js
2875
+ /**
2876
+ * 中转站额度查询(node 半区):识别并读取 New API 系与 Sub2API 的「余额 / 额度窗口」。
2877
+ *
2878
+ * 适用场景:用户把某条 llm-pi-ai provider 路由的 `baseURL` 指向第三方中转站
2879
+ * (New API / One API / VoAPI / Sub2API 等)。这类站点不卖官方余额,卖的是
2880
+ * 按 key 的额度(used/total)或多个滚动窗口。本模块对**配了 baseURL 且有
2881
+ * apiKeyEnv** 的路由逐个探测两个已知端点,能解析出额度就返回;解析不出的
2882
+ * 静默标记 unavailable,绝不臆造金额(与 balance/subscriptions 一致的姿态)。
2883
+ *
2884
+ * 探测顺序:先 Sub2API `/v1/usage`(标准化程度高),再 New API `/api/status`;
2885
+ * 404 = 不是该套程序,继续试下一种;401/403 = 是但 key 不对(unauthorized);
2886
+ * 网络/5xx 走熔断门短路一段时间。同一站点多把 key 是独立额度,分别列出。
2887
+ */
2888
+ /** 单个中转站额度请求的熔断门:按 baseURL 独立熔断(各站点互不干扰)。 */
2889
+ const relayGate = createCooldownGate({
2890
+ failures: 3,
2891
+ cooldownMs: 6e4
2892
+ });
2893
+ /** Abort a relay fetch when the upstream hangs beyond this budget. */
2894
+ const FETCH_TIMEOUT_MS = 8e3;
2895
+ /** Number, or null when the value is not a finite number (nor numeric string). */
2896
+ function numberOrNull(value) {
2897
+ if (typeof value === "number" && Number.isFinite(value)) return value;
2898
+ if (typeof value === "string" && value.trim() !== "") {
2899
+ const parsed = Number(value);
2900
+ if (Number.isFinite(parsed)) return parsed;
2901
+ }
2902
+ return null;
2903
+ }
2904
+ /** Clamp a percentage to 0–100. */
2905
+ function clampPercent(value) {
2906
+ return value === null ? null : Math.max(0, Math.min(100, value));
2907
+ }
2908
+ /** Round to one decimal. */
2909
+ function round1(value) {
2910
+ return Math.round(value * 10) / 10;
2911
+ }
2912
+ /** Map a fetch error to a stable status (same taxonomy as subscriptions). */
2913
+ function statusOf(error) {
2914
+ if (error instanceof Error) {
2915
+ if (error.name === "TimeoutError" || error.name === "AbortError") return "unavailable";
2916
+ const status = error.httpStatus;
2917
+ if (status === 401 || status === 403) return "unauthorized";
2918
+ if (status === 429) return "rate-limited";
2919
+ if (status === 404) return "unavailable";
2920
+ }
2921
+ return "unavailable";
2922
+ }
2923
+ /**
2924
+ * GET 一个中转站端点并返回 JSON。可重试错误(网络 / 5xx / 429)退避重试一次;
2925
+ * 401/403/404 不重试。返回 `{ ok, status, data }`,由调用方区分"不是这套程序
2926
+ * (404)"与"是但读取失败(其他非 2xx)"。
2927
+ */
2928
+ async function fetchRelayJson(url, apiKey) {
2929
+ const doFetch = async () => {
2930
+ const response = await fetch(url, {
2931
+ headers: {
2932
+ accept: "application/json",
2933
+ authorization: `Bearer ${apiKey}`
2934
+ },
2935
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
2936
+ });
2937
+ if (!response.ok) {
2938
+ if (response.status === 404) return {
2939
+ ok: false,
2940
+ status: 404
2941
+ };
2942
+ const error = /* @__PURE__ */ new Error(`HTTP ${String(response.status)}`);
2943
+ error.httpStatus = response.status;
2944
+ throw error;
2945
+ }
2946
+ return {
2947
+ ok: true,
2948
+ status: response.status,
2949
+ data: await response.json()
2950
+ };
2951
+ };
2952
+ return await withRetry(doFetch, {
2953
+ retries: 1,
2954
+ baseDelayMs: 250,
2955
+ maxDelayMs: 2e3
2956
+ });
2957
+ }
2958
+ /** 新建一个额度窗口行(未解析出百分比时不产出)。 */
2959
+ function windowOf(kind, usedPercent, resetsAt) {
2960
+ const used = clampPercent(usedPercent);
2961
+ if (used === null) return null;
2962
+ return {
2963
+ kind,
2964
+ usedPercent: round1(used),
2965
+ remainingPercent: round1(Math.max(0, 100 - used)),
2966
+ ...resetsAt === void 0 ? {} : { resetsAt }
2967
+ };
2968
+ }
2969
+ /**
2970
+ * 解析 Sub2API `/v1/usage` 响应:能取到 balance 或 quota/used 就识别为 sub2api。
2971
+ * 三种形态(窗口 / 分组 / 钱包余额)都宽容处理:有 `quota/total` 给出窗口,
2972
+ * 有 `balance` 给出余额,两者可同时存在。
2973
+ * @param data - `/v1/usage` 的 JSON 响应。
2974
+ * @returns 解析结果;两者都取不到返回 null(不是 Sub2API 或响应漂移)。
2975
+ */
2976
+ function parseSub2ApiUsage(data) {
2977
+ if (data === null || typeof data !== "object") return null;
2978
+ const doc = data;
2979
+ const balance = numberOrNull(doc.balance);
2980
+ const total = numberOrNull(doc.quota ?? doc.total_quota ?? doc.limit);
2981
+ const used = numberOrNull(doc.used_quota ?? doc.usage);
2982
+ if (balance === null && total === null) return null;
2983
+ const windows = [];
2984
+ if (total !== null && used !== null) {
2985
+ const pct = used / total * 100;
2986
+ const window = windowOf("weekly", Number.isFinite(pct) ? pct : null);
2987
+ if (window !== null) windows.push(window);
2988
+ }
2989
+ return {
2990
+ ...balance !== null ? { balance } : {},
2991
+ ...windows.length === 0 ? {} : { windows }
2992
+ };
2993
+ }
2994
+ /**
2995
+ * 解析 New API `/api/status` 响应:New API 系(One API / VoAPI 分支)的额度是
2996
+ * 按记录行的 ratio(已用比例)。只给出窗口,不猜金额(币种防猜)。
2997
+ * @param data - `/api/status` 的 JSON 响应。
2998
+ * @returns 窗口;取不到比例返回 null(响应漂移)。
2999
+ */
3000
+ function parseNewApiStatus(data) {
3001
+ if (data === null || typeof data !== "object") return null;
3002
+ const inner = data.data;
3003
+ if (inner === null || typeof inner !== "object") return null;
3004
+ const ratio = numberOrNull(inner.ratio);
3005
+ const used = numberOrNull(inner.used_quota);
3006
+ const total = numberOrNull(inner.total_quota ?? inner.quota);
3007
+ let pct = null;
3008
+ if (ratio !== null) pct = ratio * 100;
3009
+ else if (total !== null && used !== null) pct = used / total * 100;
3010
+ if (pct === null) return null;
3011
+ const window = windowOf("billing", Number.isFinite(pct) ? pct : null);
3012
+ return window === null ? null : { windows: [window] };
3013
+ }
3014
+ /** 归一化站点 origin(与聚合层 `siteOriginOf` 同口径)。 */
3015
+ function originOf(baseURL) {
3016
+ try {
3017
+ return new URL(baseURL).origin;
3018
+ } catch {
3019
+ return baseURL;
3020
+ }
3021
+ }
3022
+ /** 构造端点 URL:`/v1/usage` 与 `/api/status` 都以 baseURL 为宿主解析。 */
3023
+ function endpointOf(baseURL, path) {
3024
+ return new URL(path, baseURL).toString();
3025
+ }
3026
+ /**
3027
+ * 查询单个中转站路由的额度。先试 Sub2API,再试 New API;任一读出额度即返回。
3028
+ * @param ctx - host context carrying the credentials seam.
3029
+ * @param route - 待探测的路由(baseURL + apiKeyEnv)。
3030
+ * @returns 该路由的一行额度结果(status 标记成败)。
3031
+ */
3032
+ async function queryRelayQuota(ctx, route) {
3033
+ const base = {
3034
+ route: route.route,
3035
+ origin: originOf(route.baseURL),
3036
+ displayName: route.displayName ?? route.route
3037
+ };
3038
+ if (!relayGate.check(route.baseURL)) return {
3039
+ ...base,
3040
+ kind: "unknown",
3041
+ status: "unavailable"
3042
+ };
3043
+ const hit = await ctx.credentials.resolve(credentialRef(route.apiKeyEnv));
3044
+ if (hit === void 0 || hit.value === "") return {
3045
+ ...base,
3046
+ kind: "unknown",
3047
+ status: "not-configured"
3048
+ };
3049
+ try {
3050
+ const sub2 = await fetchRelayJson(endpointOf(route.baseURL, "/v1/usage"), hit.value);
3051
+ if (sub2.ok) {
3052
+ const parsed = parseSub2ApiUsage(sub2.data);
3053
+ if (parsed !== null) {
3054
+ relayGate.success(route.baseURL);
3055
+ return {
3056
+ ...base,
3057
+ kind: "sub2api",
3058
+ status: "ok",
3059
+ ...parsed.balance !== void 0 ? { balance: parsed.balance } : {},
3060
+ ...parsed.windows !== void 0 ? { windows: parsed.windows } : {}
3061
+ };
3062
+ }
3063
+ relayGate.fail(route.baseURL);
3064
+ return {
3065
+ ...base,
3066
+ kind: "sub2api",
3067
+ status: "invalid-response"
3068
+ };
3069
+ }
3070
+ if (sub2.status === 401 || sub2.status === 403) {
3071
+ relayGate.fail(route.baseURL);
3072
+ return {
3073
+ ...base,
3074
+ kind: "unknown",
3075
+ status: "unauthorized"
3076
+ };
3077
+ }
3078
+ const na = await fetchRelayJson(endpointOf(route.baseURL, "/api/status"), hit.value);
3079
+ if (na.status === 401 || na.status === 403) {
3080
+ relayGate.fail(route.baseURL);
3081
+ return {
3082
+ ...base,
3083
+ kind: "unknown",
3084
+ status: "unauthorized"
3085
+ };
3086
+ }
3087
+ if (na.ok) {
3088
+ const parsed = parseNewApiStatus(na.data);
3089
+ if (parsed !== null) {
3090
+ relayGate.success(route.baseURL);
3091
+ return {
3092
+ ...base,
3093
+ kind: "new-api",
3094
+ status: "ok",
3095
+ ...parsed.windows !== void 0 ? { windows: parsed.windows } : {}
3096
+ };
3097
+ }
3098
+ relayGate.fail(route.baseURL);
3099
+ return {
3100
+ ...base,
3101
+ kind: "new-api",
3102
+ status: "invalid-response"
3103
+ };
3104
+ }
3105
+ relayGate.fail(route.baseURL);
3106
+ return {
3107
+ ...base,
3108
+ kind: "unknown",
3109
+ status: "unavailable"
3110
+ };
3111
+ } catch (error) {
3112
+ relayGate.fail(route.baseURL);
3113
+ return {
3114
+ ...base,
3115
+ kind: "unknown",
3116
+ status: statusOf(error)
3117
+ };
3118
+ }
3119
+ }
3120
+ /**
3121
+ * 批量查询多个中转站路由的额度(每个独立成败,互不影响)。
3122
+ * @param ctx - host context carrying the credentials seam.
3123
+ * @param routes - 配了 baseURL 且 apiKeyEnv 有值的路由列表。
3124
+ * @returns 每个路由一行的额度结果。
3125
+ */
3126
+ async function queryRelayQuotas(ctx, routes) {
3127
+ return await Promise.all(routes.map(async (route) => queryRelayQuota(ctx, route)));
3128
+ }
3129
+ //#endregion
3130
+ //#region lib/types/client/usage-billing-settings.js
3131
+ /**
3132
+ * usage-stats 工具开关的共享设置契约(node 与 client 两端共用)。
3133
+ *
3134
+ * 宿主把已注册的设置命名空间服务给浏览器;node 半区在 apply 阶段读一次该值决定
3135
+ * 是否向模型注入 `usage_stats` 工具(工具注入是启动期决策,改开关后重载应用生效),
3136
+ * client 半区在「设置」Tab 渲染开关并写入同一命名空间。缺省的默认行为是关闭——
3137
+ * 避免该工具默认占用模型每次请求的上下文(coding 场景通常在仪表盘看用量)。
3138
+ */
3139
+ /** 设置命名空间 id(小写 kebab-case)。 */
3140
+ const BILLING_SETTINGS_NAMESPACE = "ui-usage-billing";
3141
+ /** 该命名空间下用户可编辑的字段名。 */
3142
+ const ENABLE_USAGE_STATS_TOOL_FIELD = "enableUsageStatsTool";
3143
+ //#endregion
2577
3144
  //#region lib/types/index.js
2578
3145
  /**
2579
3146
  * Usage billing surface plugin, node half.
@@ -2586,6 +3153,10 @@ async function collectSubscriptions(keys, plans = [], timeoutMs = DEFAULT_TIMEOU
2586
3153
  * missing file answers `{ error }` so the dashboard shows zeros, never
2587
3154
  * fabricated samples.
2588
3155
  */
3156
+ /** usage_stats 工具开关的设置命名空间 id(下端与 node 共用同一常量)。 */
3157
+ const usageBillingSettingsNs = settingsNamespace(BILLING_SETTINGS_NAMESPACE);
3158
+ /** 该命名空间的 wire schema:`enableUsageStatsTool` 布尔,默认关闭(issue 诉求)。 */
3159
+ const UsageBillingSettingsSchema = z.object({ [ENABLE_USAGE_STATS_TOOL_FIELD]: z.boolean().default(false) });
2589
3160
  /** 实时定价的后台刷新间隔(毫秒):汇率/模型价低频变化,6 小时一次足够。 */
2590
3161
  const PRICING_REFRESH_INTERVAL_MS = 360 * 60 * 1e3;
2591
3162
  /** 订阅套餐额度缓存时长(毫秒):上游配额 API 低频变化,5 分钟足够。 */
@@ -2599,6 +3170,21 @@ const DEFAULT_BALANCE_API_KEY_ENV = "DEEPSEEK_API_KEY";
2599
3170
  const PACKAGE_VERSION = createRequire(import.meta.url)("../package.json").version ?? "0.0.0";
2600
3171
  /** 统计快照的落盘节流(毫秒):前端 30 秒轮询,快照最多每 30 秒写一次。 */
2601
3172
  const SNAPSHOT_INTERVAL_MS = 3e4;
3173
+ /** 鉴权失败告警冷却(毫秒):同一 provider 在窗口内只提示一次,避免 30 秒轮询刷屏。 */
3174
+ const AUTH_WARN_COOLDOWN_MS = 1800 * 1e3;
3175
+ /**
3176
+ * 鉴权失败分类告警(P1-5):余额 / 订阅查询返回 unauthorized 时,按
3177
+ * `source:provider` 去重并冷却告警,提示检查 llm-pi-ai 里该 provider 的 apiKeyEnv。
3178
+ */
3179
+ const authWarnedAt = /* @__PURE__ */ new Map();
3180
+ function warnAuthOnce(source, provider, displayName) {
3181
+ const key = `${source}:${provider}`;
3182
+ const now = Date.now();
3183
+ const last = authWarnedAt.get(key);
3184
+ if (last !== void 0 && now - last < AUTH_WARN_COOLDOWN_MS) return;
3185
+ authWarnedAt.set(key, now);
3186
+ console.warn(`[usage-billing] ${displayName}(${provider})鉴权失败:请检查 llm-pi-ai 设置中该 provider 的 apiKeyEnv 凭据是否正确/有效。`);
3187
+ }
2602
3188
  /** Required services: the web server, the persisted session log store, and user settings. */
2603
3189
  const inject = [
2604
3190
  "webServer",
@@ -2641,19 +3227,80 @@ const SUBSCRIPTION_KEY_SOURCES = [
2641
3227
  key: "openrouterApiKey"
2642
3228
  }
2643
3229
  ];
2644
- /**
2645
- * 读取 llm-pi-ai 设置的 `providers` 字典(`<route> → { apiKeyEnv? }`)。
2646
- * 余额查询复用同一份来源:部署为某个 provider 配一次 key,多个 surface 共享。
3230
+ /** 读 llm-pi-ai 设置的 `providers` 字典(`<route> → { apiKeyEnv?, baseURL?, displayName? }`)。
3231
+ * 余额与订阅查询复用同一份来源:部署为某个 provider 配一次,多 surface 共享。
2647
3232
  * @param settings - the settings service (reads the llm-pi-ai namespace).
2648
3233
  * @returns the providers dict; empty when the namespace is unreadable.
2649
3234
  */
2650
3235
  async function readPiAiProviders(settings) {
2651
3236
  try {
2652
- return (settings.describe({ redactSecrets: true }).find((descriptor) => descriptor.ns === "llm-pi-ai")?.value)?.providers ?? {};
3237
+ const providers = (settings.describe({ redactSecrets: true }).find((descriptor) => descriptor.ns === "llm-pi-ai")?.value)?.providers;
3238
+ const out = {};
3239
+ for (const [route, entry] of Object.entries(providers ?? {})) {
3240
+ if (entry === null || typeof entry !== "object") continue;
3241
+ const { apiKeyEnv, baseURL, displayName } = entry;
3242
+ out[route] = {
3243
+ ...typeof apiKeyEnv === "string" ? { apiKeyEnv } : {},
3244
+ ...typeof baseURL === "string" ? { baseURL } : {},
3245
+ ...typeof displayName === "string" ? { displayName } : {}
3246
+ };
3247
+ }
3248
+ return out;
2653
3249
  } catch {
2654
3250
  return {};
2655
3251
  }
2656
3252
  }
3253
+ /** 同步读取 provider 路由的 baseURL 视图(中转站零配置发现来源)。
3254
+ * `settings.describe` 是同步调用,聚合器每次折叠取最新站点映射,无需缓存/过期。
3255
+ * 注意:返回**全部可读路由**(baseURL 可选),聚合层据此区分「路由存在但无
3256
+ * baseURL=直连」与「路由已删除=未知路由」两种不同归属。
3257
+ * @param settings - the settings service (reads the llm-pi-ai namespace).
3258
+ * @returns `<route> → { baseURL? }`;命名空间不可读时返回空。
3259
+ */
3260
+ function readPiAiProviderRoutes(settings) {
3261
+ try {
3262
+ const providers = (settings.describe({ redactSecrets: true }).find((descriptor) => descriptor.ns === "llm-pi-ai")?.value)?.providers;
3263
+ const out = {};
3264
+ for (const [route, entry] of Object.entries(providers ?? {})) {
3265
+ if (entry === null || typeof entry !== "object") continue;
3266
+ const baseURL = entry.baseURL;
3267
+ out[route] = typeof baseURL === "string" && baseURL !== "" ? { baseURL } : {};
3268
+ }
3269
+ return out;
3270
+ } catch {
3271
+ return {};
3272
+ }
3273
+ }
3274
+ /**
3275
+ * 构造「cwd → 工作区标题」解析器(host 的 `workspaceRegistry` 为可选依赖)。
3276
+ * 匹配与 TokenLedger 同口径:会话 cwd 等于某工作区 path、或位于其子目录时,用
3277
+ * 工作区标题命名该项目(子目录的会话也计入);否则返回 undefined(回退到目录名)。
3278
+ * registry 缺席/读取失败都返回 undefined,绝不抛错(可选依赖,不影响主流程)。
3279
+ * @param ctx - host context carrying the optional workspace registry.
3280
+ * @returns 标题解析函数;registry 不可用时 undefined。
3281
+ */
3282
+ function buildWorkspaceTitleResolver(ctx) {
3283
+ let registry;
3284
+ try {
3285
+ registry = ctx.get("workspaceRegistry");
3286
+ } catch {
3287
+ return;
3288
+ }
3289
+ if (registry === void 0 || typeof registry.list !== "function") return void 0;
3290
+ const reg = registry;
3291
+ return (cwd) => {
3292
+ if (cwd === "") return void 0;
3293
+ try {
3294
+ const records = reg.list() ?? [];
3295
+ const exact = records.find((record) => record.path === cwd);
3296
+ if (exact !== void 0) return exact.title;
3297
+ for (const record of records) if (record.path !== "" && cwd.startsWith(`${record.path}/`)) return record.title;
3298
+ return;
3299
+ } catch {
3300
+ return;
3301
+ }
3302
+ };
3303
+ }
2657
3304
  /**
2658
3305
  * 解析订阅适配器需要的 API Key:从 llm-pi-ai 设置的 `providers.<id>.apiKeyEnv`
2659
3306
  * 读引用(如 kimi-coding → KIMI_CODING_API_KEY),再经凭据 seam 解析成实际值。
@@ -2692,7 +3339,13 @@ async function resolveSubscriptionKeys(settings, credentials) {
2692
3339
  * @param config - optional statsPath override.
2693
3340
  */
2694
3341
  function apply(ctx, config = {}) {
2695
- const aggregator = createUsageAggregator(ctx.sessionPersistence, { ...config.subscriptionProviders === void 0 ? {} : { subscriptionProviders: config.subscriptionProviders } });
3342
+ let usageSettingsScope;
3343
+ const workspaceTitleResolver = buildWorkspaceTitleResolver(ctx);
3344
+ const aggregator = createUsageAggregator(ctx.sessionPersistence, {
3345
+ ...config.subscriptionProviders === void 0 ? {} : { subscriptionProviders: config.subscriptionProviders },
3346
+ resolveRoutes: () => readPiAiProviderRoutes(ctx.settings),
3347
+ ...workspaceTitleResolver === void 0 ? {} : { resolveWorkspaceTitle: workspaceTitleResolver }
3348
+ });
2696
3349
  const cwd = process.cwd();
2697
3350
  const snapshotPath = join(homedir(), ".dsh/.dsh-usage-stats.json");
2698
3351
  const candidates = [
@@ -2706,16 +3359,35 @@ function apply(ctx, config = {}) {
2706
3359
  const now = Date.now();
2707
3360
  if (now - lastSnapshotAt < SNAPSHOT_INTERVAL_MS) return;
2708
3361
  lastSnapshotAt = now;
2709
- writeFileAtomic(snapshotPath, JSON.stringify({
3362
+ const payload = JSON.stringify({
2710
3363
  ...doc,
2711
3364
  _writer: {
2712
3365
  pid: process.pid,
2713
3366
  at: now
2714
3367
  }
2715
- }), {
2716
- mode: 384,
2717
- dirMode: 448
2718
- }).catch(() => {});
3368
+ });
3369
+ (async () => {
3370
+ try {
3371
+ const existing = await readFile(snapshotPath, "utf8");
3372
+ await writeFileAtomic(`${snapshotPath}.bak`, existing, {
3373
+ mode: 384,
3374
+ dirMode: 448
3375
+ });
3376
+ } catch {}
3377
+ try {
3378
+ await writeFileAtomic(snapshotPath, payload, {
3379
+ mode: 384,
3380
+ dirMode: 448
3381
+ });
3382
+ } catch {}
3383
+ })();
3384
+ };
3385
+ /** 读一个快照候选并解析成对象:主文件优先;主文件损坏时回退上一版 `.bak`(P0-3 崩溃恢复)。 */
3386
+ const readSnapshot = async (candidate) => {
3387
+ for (const path of [candidate, `${candidate}.bak`]) try {
3388
+ return JSON.parse(await readFile(path, "utf8"));
3389
+ } catch {}
3390
+ return null;
2719
3391
  };
2720
3392
  (async () => {
2721
3393
  try {
@@ -2724,94 +3396,118 @@ function apply(ctx, config = {}) {
2724
3396
  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
3397
  } catch {}
2726
3398
  })();
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"
3399
+ ctx.inject(["settings"], (sctx) => {
3400
+ const scope = sctx.settings.register(usageBillingSettingsNs, UsageBillingSettingsSchema, { base: { enableUsageStatsTool: config.enableUsageStatsTool ?? false } });
3401
+ usageSettingsScope = scope;
3402
+ ctx.inject(["tools"], (toolsCtx) => {
3403
+ if (scope.get().enableUsageStatsTool) toolsCtx.tools.register(defineTool({
3404
+ name: "usage_stats",
3405
+ description: "查询本机 DeepSeek Harness 的模型用量与估算费用(人民币,按官方目录价估算,非账单)。range 取值:today=今天,month=本月,session=当前会话,all=累计。",
3406
+ parameters: { range: {
3407
+ type: "string",
3408
+ enum: [
3409
+ "today",
3410
+ "month",
3411
+ "session",
3412
+ "all"
3413
+ ],
3414
+ required: true,
3415
+ description: "统计范围:today / month / session / all"
3416
+ } },
3417
+ output: {
3418
+ schema: {
3419
+ type: "object",
3420
+ additionalProperties: false,
3421
+ properties: {
3422
+ range: {
3423
+ type: "string",
3424
+ required: true
3425
+ },
3426
+ cost: {
3427
+ type: "number",
3428
+ required: true,
3429
+ description: "估算费用(人民币元)"
3430
+ },
3431
+ calls: {
3432
+ type: "number",
3433
+ required: true
3434
+ },
3435
+ input: {
3436
+ type: "number",
3437
+ required: true,
3438
+ description: "输入 tokens"
3439
+ },
3440
+ output: {
3441
+ type: "number",
3442
+ required: true,
3443
+ description: "输出 tokens"
3444
+ }
2769
3445
  }
2770
- }
3446
+ },
3447
+ render: (_args, value) => [{
3448
+ type: "text",
3449
+ text: `用量(${value.range}):估算费用 ${formatMoney(value.cost)},调用 ${value.calls} 次,输入 ${formatTokens(value.input)} tokens,输出 ${formatTokens(value.output)} tokens`
3450
+ }]
2771
3451
  },
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 : {
3452
+ async execute(args, exec) {
3453
+ const stats = await aggregator.aggregate();
3454
+ const zero = {
2796
3455
  range: args.range,
2797
- cost: day.cost,
2798
- calls: day.calls,
2799
- input: day.input,
2800
- output: day.output
3456
+ cost: 0,
3457
+ calls: 0,
3458
+ input: 0,
3459
+ output: 0
2801
3460
  };
2802
- }
2803
- if (args.range === "month") {
2804
- const prefix = dayStamp(Date.now()).slice(0, 7);
3461
+ if (args.range === "all") return {
3462
+ range: args.range,
3463
+ cost: stats.total.cost,
3464
+ calls: stats.total.calls,
3465
+ input: stats.total.input,
3466
+ output: stats.total.output
3467
+ };
3468
+ if (args.range === "today") {
3469
+ const day = stats.byDay[dayStamp(Date.now())];
3470
+ return day === void 0 ? zero : {
3471
+ range: args.range,
3472
+ cost: day.cost,
3473
+ calls: day.calls,
3474
+ input: day.input,
3475
+ output: day.output
3476
+ };
3477
+ }
3478
+ if (args.range === "month") {
3479
+ const prefix = dayStamp(Date.now()).slice(0, 7);
3480
+ let cost = 0;
3481
+ let calls = 0;
3482
+ let input = 0;
3483
+ let output = 0;
3484
+ for (const [date, day] of Object.entries(stats.byDay)) {
3485
+ if (!date.startsWith(prefix)) continue;
3486
+ cost += day.cost;
3487
+ calls += day.calls;
3488
+ input += day.input;
3489
+ output += day.output;
3490
+ }
3491
+ return {
3492
+ range: args.range,
3493
+ cost,
3494
+ calls,
3495
+ input,
3496
+ output
3497
+ };
3498
+ }
3499
+ const sessionId = exec.agent?.id;
3500
+ if (sessionId === void 0) throw new Error("usage_stats 的 session 范围需要 agent 会话上下文");
2805
3501
  let cost = 0;
2806
3502
  let calls = 0;
2807
3503
  let input = 0;
2808
3504
  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;
3505
+ for (const turn of stats.byTurn ?? []) {
3506
+ if (turn.sessionId !== String(sessionId)) continue;
3507
+ cost += turn.cost;
3508
+ calls += 1;
3509
+ input += turn.input;
3510
+ output += turn.output;
2815
3511
  }
2816
3512
  return {
2817
3513
  range: args.range,
@@ -2821,28 +3517,8 @@ function apply(ctx, config = {}) {
2821
3517
  output
2822
3518
  };
2823
3519
  }
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
- }));
3520
+ }));
3521
+ });
2846
3522
  });
2847
3523
  let live = { source: "builtin" };
2848
3524
  const refreshPricing = async () => {
@@ -2875,9 +3551,44 @@ function apply(ctx, config = {}) {
2875
3551
  if (providers["deepseek"] === void 0) providers["deepseek"] = { apiKeyEnv: config.balanceApiKeyEnv ?? DEFAULT_BALANCE_API_KEY_ENV };
2876
3552
  const balances = await queryBalances(ctx, providers);
2877
3553
  const custom = await queryCustomBalances(ctx, config.customBalances ?? []);
3554
+ for (const row of [...balances, ...custom]) if (row.error === "unauthorized") warnAuthOnce("balance", row.provider, row.displayName);
2878
3555
  res.end(JSON.stringify({ balances: [...balances, ...custom] }));
2879
3556
  }
2880
3557
  }), "usage-billing: balance route");
3558
+ ctx.effect(() => ctx.webServer.register({
3559
+ kind: "exact",
3560
+ path: "/api/billing/usage-tool",
3561
+ handler: async (req, res) => {
3562
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
3563
+ const enabled = usageSettingsScope?.get().enableUsageStatsTool ?? false;
3564
+ if (req.method === "GET") {
3565
+ res.end(JSON.stringify({ enabled }));
3566
+ return;
3567
+ }
3568
+ if (req.method !== "POST") {
3569
+ res.writeHead(405, { "content-type": "application/json; charset=utf-8" });
3570
+ res.end(JSON.stringify({ error: "method not allowed" }));
3571
+ return;
3572
+ }
3573
+ try {
3574
+ let body = "";
3575
+ for await (const chunk of req) body += String(chunk);
3576
+ const parsed = JSON.parse(body === "" ? "{}" : body);
3577
+ if (usageSettingsScope === void 0) {
3578
+ res.end(JSON.stringify({ error: "settings unavailable" }));
3579
+ return;
3580
+ }
3581
+ const next = parsed.enabled === true;
3582
+ await usageSettingsScope.update({ enableUsageStatsTool: next });
3583
+ res.end(JSON.stringify({
3584
+ ok: true,
3585
+ enabled: next
3586
+ }));
3587
+ } catch {
3588
+ res.end(JSON.stringify({ error: "invalid" }));
3589
+ }
3590
+ }
3591
+ }), "usage-billing: usage-tool route");
2881
3592
  let quotaCache = {
2882
3593
  at: 0,
2883
3594
  quotas: []
@@ -2889,7 +3600,7 @@ function apply(ctx, config = {}) {
2889
3600
  ...item.region === void 0 ? {} : { region: item.region }
2890
3601
  })))].map((row) => {
2891
3602
  const planType = planTypeOf(row.provider);
2892
- const subscriptionAmount = subscriptionCnyOf(row.provider);
3603
+ const subscriptionAmount = subscriptionFeeCnyOf(row.provider, live.rate);
2893
3604
  return {
2894
3605
  ...row,
2895
3606
  planType,
@@ -2903,6 +3614,7 @@ function apply(ctx, config = {}) {
2903
3614
  windows: [],
2904
3615
  planType: planTypeOf(item.provider)
2905
3616
  });
3617
+ for (const row of rows) if (row.status === "unauthorized") warnAuthOnce("subscription", row.provider, row.displayName);
2906
3618
  quotaCache = {
2907
3619
  at: Date.now(),
2908
3620
  quotas: rows
@@ -2917,6 +3629,36 @@ function apply(ctx, config = {}) {
2917
3629
  res.end(JSON.stringify({ quotas: quotaCache.quotas }));
2918
3630
  }
2919
3631
  }), "usage-billing: subscriptions route");
3632
+ let relayCache = {
3633
+ at: 0,
3634
+ quotas: []
3635
+ };
3636
+ const refreshRelay = async () => {
3637
+ const providers = await readPiAiProviders(ctx.settings);
3638
+ const routes = [];
3639
+ for (const [route, entry] of Object.entries(providers)) {
3640
+ if (entry.baseURL === void 0 || entry.apiKeyEnv === void 0) continue;
3641
+ routes.push({
3642
+ route,
3643
+ baseURL: entry.baseURL,
3644
+ apiKeyEnv: entry.apiKeyEnv,
3645
+ ...entry.displayName === void 0 ? {} : { displayName: entry.displayName }
3646
+ });
3647
+ }
3648
+ relayCache = {
3649
+ at: Date.now(),
3650
+ quotas: await queryRelayQuotas(ctx, routes)
3651
+ };
3652
+ };
3653
+ ctx.effect(() => ctx.webServer.register({
3654
+ kind: "exact",
3655
+ path: "/api/billing/relay-quotas",
3656
+ handler: async (_req, res) => {
3657
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
3658
+ if (Date.now() - relayCache.at >= SUBSCRIPTION_CACHE_MS) await refreshRelay();
3659
+ res.end(JSON.stringify({ quotas: relayCache.quotas }));
3660
+ }
3661
+ }), "usage-billing: relay-quotas route");
2920
3662
  ctx.effect(() => ctx.webServer.register({
2921
3663
  kind: "exact",
2922
3664
  path: "/api/billing/usage-stats",
@@ -2939,18 +3681,18 @@ function apply(ctx, config = {}) {
2939
3681
  } catch (error) {
2940
3682
  console.error("[usage-billing] usage-stats aggregate failed, falling back to snapshot:", error);
2941
3683
  }
2942
- for (const candidate of candidates) try {
2943
- const text = await readFile(candidate, "utf8");
2944
- const doc = JSON.parse(text);
3684
+ for (const candidate of candidates) {
3685
+ const doc = await readSnapshot(candidate);
3686
+ if (doc === null) continue;
2945
3687
  if (config.monthlyBudget !== void 0) doc["budget"] = config.monthlyBudget;
2946
3688
  if (config.lowBalanceThreshold !== void 0) doc["lowBalanceThreshold"] = config.lowBalanceThreshold;
2947
3689
  doc["pluginVersion"] = PACKAGE_VERSION;
2948
3690
  res.end(JSON.stringify(doc));
2949
3691
  return;
2950
- } catch {}
3692
+ }
2951
3693
  res.end(JSON.stringify({ error: "usage stats unavailable" }));
2952
3694
  }
2953
3695
  }), "usage-billing: usage-stats route");
2954
3696
  }
2955
3697
  //#endregion
2956
- export { apply, inject, resolveSubscriptionKeys };
3698
+ export { apply, inject, readPiAiProviderRoutes, resolveSubscriptionKeys };