@kenz1117/dsh-ui-usage-billing 0.9.7 → 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
@@ -958,6 +958,46 @@ const DEFAULT_SUBSCRIPTION_PROVIDERS = [
958
958
  function isOfficialProvider(provider) {
959
959
  return /^deepseek(?:-[a-z0-9-]+)?$/i.test(provider.trim());
960
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
+ }
961
1001
  /** Zeroed usage accumulator. */
962
1002
  function emptyUsage() {
963
1003
  return {
@@ -1081,12 +1121,13 @@ function turnState(turns, turn) {
1081
1121
  * (default: any `deepseek`-prefixed id). Others count as third-party.
1082
1122
  * @returns the per-session fold (cached by the incremental aggregator).
1083
1123
  */
1084
- function foldSession(events, subscriptionProviders, officialProviderIds) {
1124
+ function foldSession(events, subscriptionProviders, officialProviderIds, routes = {}) {
1085
1125
  const fold = {
1086
1126
  total: emptyUsage(),
1087
1127
  byModel: /* @__PURE__ */ new Map(),
1088
1128
  byDay: /* @__PURE__ */ new Map(),
1089
1129
  byDayModels: /* @__PURE__ */ new Map(),
1130
+ bySite: /* @__PURE__ */ new Map(),
1090
1131
  planCalls: /* @__PURE__ */ new Map(),
1091
1132
  turns: [],
1092
1133
  perf: [],
@@ -1101,6 +1142,7 @@ function foldSession(events, subscriptionProviders, officialProviderIds) {
1101
1142
  let key = "other";
1102
1143
  let subscription = false;
1103
1144
  let official = false;
1145
+ let siteBucket = "unknown";
1104
1146
  const turns = /* @__PURE__ */ new Map();
1105
1147
  const steps = /* @__PURE__ */ new Map();
1106
1148
  let lastOpenStepKey;
@@ -1145,6 +1187,7 @@ function foldSession(events, subscriptionProviders, officialProviderIds) {
1145
1187
  key = resolveCatalogKey(model);
1146
1188
  subscription = subscriptionProviders.has(provider);
1147
1189
  official = officialProviderIds === void 0 ? isOfficialProvider(provider) : officialProviderIds.has(provider);
1190
+ siteBucket = siteBucketKey(siteRefOf(provider, routes));
1148
1191
  if (lastOpenStepKey !== void 0) {
1149
1192
  const stepState = steps.get(lastOpenStepKey);
1150
1193
  if (stepState !== void 0 && stepState.requestTime === void 0) stepState.requestTime = event.time;
@@ -1174,6 +1217,7 @@ function foldSession(events, subscriptionProviders, officialProviderIds) {
1174
1217
  foldUsage(usageCell(fold.byModel, modelKey), usage, modelKey, subscription, event.time, official);
1175
1218
  foldUsage(usageCell(fold.byDay, day), usage, modelKey, subscription, event.time, official);
1176
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);
1177
1221
  if (subscription) fold.planCalls.set(modelKey, (fold.planCalls.get(modelKey) ?? 0) + 1);
1178
1222
  const turn = event.data.turn ?? -1;
1179
1223
  const state = turnState(turns, turn);
@@ -1293,6 +1337,8 @@ function createUsageAggregator(persistence, options = {}) {
1293
1337
  const cache = /* @__PURE__ */ new Map();
1294
1338
  let lastDoc;
1295
1339
  let lastAt = 0;
1340
+ /** 每次聚合取最新的 provider 路由视图(中转站零配置发现);缺省按空处理(全部未知路由)。 */
1341
+ const routesOf = () => options.resolveRoutes?.() ?? {};
1296
1342
  /** 失效键:日志文件的 mtime+size;拿不到(后端无 locate / 文件丢失)时每次重折。 */
1297
1343
  const stampOf = async (meta) => {
1298
1344
  const location = persistence.locate?.(meta);
@@ -1329,7 +1375,7 @@ function createUsageAggregator(persistence, options = {}) {
1329
1375
  const { events } = await persistence.readFrom(meta.id, 0);
1330
1376
  const after = await stampOf(meta);
1331
1377
  if (stamp !== null && after !== stamp) continue;
1332
- const fold = foldSession(events, subscriptionProviders, officialProviderIds);
1378
+ const fold = foldSession(events, subscriptionProviders, officialProviderIds, routesOf());
1333
1379
  cache.set(id, {
1334
1380
  stamp,
1335
1381
  fold
@@ -1354,6 +1400,7 @@ function createUsageAggregator(persistence, options = {}) {
1354
1400
  const byModel = /* @__PURE__ */ new Map();
1355
1401
  const byDay = /* @__PURE__ */ new Map();
1356
1402
  const byDayModels = /* @__PURE__ */ new Map();
1403
+ const bySite = /* @__PURE__ */ new Map();
1357
1404
  const planCalls = /* @__PURE__ */ new Map();
1358
1405
  const sessionRows = [];
1359
1406
  const turnRows = [];
@@ -1376,6 +1423,7 @@ function createUsageAggregator(persistence, options = {}) {
1376
1423
  for (const [modelKey, cell] of fold.byModel) mergeUsageInto(usageCell(byModel, modelKey), cell);
1377
1424
  for (const [day, cell] of fold.byDay) mergeUsageInto(usageCell(byDay, day), cell);
1378
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);
1379
1427
  for (const [modelKey, count] of fold.planCalls) planCalls.set(modelKey, (planCalls.get(modelKey) ?? 0) + count);
1380
1428
  for (const sample of fold.perf) {
1381
1429
  let modelAccum = perfModel.get(sample.model);
@@ -1407,7 +1455,7 @@ function createUsageAggregator(persistence, options = {}) {
1407
1455
  sessionId,
1408
1456
  ...row
1409
1457
  });
1410
- 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);
1411
1459
  const ws = workspaceMap.get(wsName) ?? {
1412
1460
  name: wsName,
1413
1461
  calls: 0,
@@ -1471,6 +1519,7 @@ function createUsageAggregator(persistence, options = {}) {
1471
1519
  bySession: sessionRows.slice(0, 100),
1472
1520
  byTurn: turnRows.slice(0, 200),
1473
1521
  byWorkspace: workspaces.slice(0, 100),
1522
+ ...bySite.size === 0 ? {} : { bySite: toRecord(bySite) },
1474
1523
  ...perf === void 0 ? {} : { perf },
1475
1524
  byRole: (() => {
1476
1525
  const chars = roles.userChars + roles.toolChars;
@@ -1576,7 +1625,7 @@ function createCooldownGate(options = {}) {
1576
1625
  * provider's key once and every surface reuses it.
1577
1626
  */
1578
1627
  /** Abort a balance fetch when the upstream hangs beyond this budget. */
1579
- const FETCH_TIMEOUT_MS$1 = 8e3;
1628
+ const FETCH_TIMEOUT_MS$2 = 8e3;
1580
1629
  /**
1581
1630
  * 每平台熔断门:单个 provider 连续可重试失败(网络波动 / 5xx / 429)达阈值后
1582
1631
  * 短路一段真实时间,避免 30 秒轮询在已不可用的上游上反复打满超时。
@@ -1601,6 +1650,8 @@ const STEPFUN_BALANCE_URL = "https://api.stepfun.com/v1/accounts";
1601
1650
  const SILICONFLOW_BALANCE_URL = "https://api.siliconflow.cn/v1/user/info";
1602
1651
  /** xAI 官方账单接口(docs.x.ai/developers/api/credits);total.val 为美分。 */
1603
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";
1604
1655
  /** 数字归一化:接口返回的余额是字符串(如 `"110.00"`),统一转 number。 */
1605
1656
  function toNumber(value) {
1606
1657
  if (typeof value === "number" && Number.isFinite(value)) return value;
@@ -1635,7 +1686,7 @@ async function queryBearerBalance(ctx, url, apiKeyEnv, provider, displayName, pa
1635
1686
  };
1636
1687
  const doRequest = async () => {
1637
1688
  const controller = new AbortController();
1638
- const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS$1);
1689
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS$2);
1639
1690
  try {
1640
1691
  const response = await fetch(url, {
1641
1692
  headers: {
@@ -1791,6 +1842,27 @@ function queryXai(ctx, apiKeyEnv) {
1791
1842
  };
1792
1843
  });
1793
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
+ }
1794
1866
  const QUERIERS = [
1795
1867
  {
1796
1868
  route: "deepseek",
@@ -1816,6 +1888,16 @@ const QUERIERS = [
1816
1888
  route: "xai",
1817
1889
  displayName: "xAI",
1818
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
1819
1901
  }
1820
1902
  ];
1821
1903
  /**
@@ -1919,7 +2001,7 @@ async function queryCustomBalances(ctx, configs) {
1919
2001
  };
1920
2002
  const doRequest = async () => {
1921
2003
  const controller = new AbortController();
1922
- const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS$1);
2004
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS$2);
1923
2005
  try {
1924
2006
  const response = await fetch(config.url, {
1925
2007
  method: config.method ?? "GET",
@@ -1985,7 +2067,7 @@ async function queryCustomBalances(ctx, configs) {
1985
2067
  * catalog for the rest — a total outage answers `{ source: 'builtin' }`.
1986
2068
  */
1987
2069
  /** Abort a fetch when the upstream hangs beyond this budget. */
1988
- const FETCH_TIMEOUT_MS = 8e3;
2070
+ const FETCH_TIMEOUT_MS$1 = 8e3;
1989
2071
  /** 每平台熔断门:单个定价上游连续可重试失败(网络 / 5xx / 429)达阈值后短路,
1990
2072
  * 避免 6 小时刷新循环与每次启动在已故障的上游上反复打满超时。按 URL 独立。 */
1991
2073
  const pricingGate = createCooldownGate({
@@ -2079,7 +2161,7 @@ async function fetchText(url) {
2079
2161
  if (!pricingGate.check(url)) return null;
2080
2162
  const doFetch = async () => {
2081
2163
  const controller = new AbortController();
2082
- const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
2164
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS$1);
2083
2165
  try {
2084
2166
  const response = await fetch(url, { signal: controller.signal });
2085
2167
  if (!response.ok) {
@@ -2330,7 +2412,7 @@ function identifySubscriptionPlans(providers) {
2330
2412
  }
2331
2413
  const DEFAULT_TIMEOUT_MS = 15e3;
2332
2414
  /** Number, or null when the value is not a finite number (nor numeric string). */
2333
- function numberOrNull(value) {
2415
+ function numberOrNull$1(value) {
2334
2416
  if (typeof value === "number" && Number.isFinite(value)) return value;
2335
2417
  if (typeof value === "string" && value.trim() !== "") {
2336
2418
  const parsed = Number(value);
@@ -2339,11 +2421,11 @@ function numberOrNull(value) {
2339
2421
  return null;
2340
2422
  }
2341
2423
  /** Clamp a percentage to 0–100. */
2342
- function clampPercent(value) {
2424
+ function clampPercent$1(value) {
2343
2425
  return value === null ? null : Math.max(0, Math.min(100, value));
2344
2426
  }
2345
2427
  /** Round to one decimal. */
2346
- function round1(value) {
2428
+ function round1$1(value) {
2347
2429
  return Math.round(value * 10) / 10;
2348
2430
  }
2349
2431
  /** Number → ISO string (seconds treated as epoch seconds, ms as epoch ms). */
@@ -2357,7 +2439,7 @@ function toIso(value) {
2357
2439
  return Number.isNaN(date.getTime()) ? null : date.toISOString();
2358
2440
  }
2359
2441
  /** Map a fetch error to a stable status. */
2360
- function statusOf(error) {
2442
+ function statusOf$1(error) {
2361
2443
  if (error instanceof Error) {
2362
2444
  if (error.name === "TimeoutError" || error.name === "AbortError") return "unavailable";
2363
2445
  const status = error.httpStatus;
@@ -2392,21 +2474,21 @@ async function requestJson(url, init, timeoutMs) {
2392
2474
  function kimiWindow(value, kind) {
2393
2475
  if (value === null || typeof value !== "object") return null;
2394
2476
  const record = value;
2395
- const limit = numberOrNull(record.limit ?? record.total);
2396
- const remaining = numberOrNull(record.remaining);
2397
- 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;
2398
2480
  const hasLimit = limit !== null && limit > 0;
2399
2481
  let usedPercent;
2400
- 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);
2401
2483
  else {
2402
- const percent = numberOrNull(record.percentage ?? record.usedPercent ?? record.used_percent);
2403
- 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;
2404
2486
  }
2405
2487
  const resetsAt = toIso(record.resetTime ?? record.reset_time ?? record.resetsAt);
2406
2488
  return {
2407
2489
  kind,
2408
2490
  usedPercent,
2409
- remainingPercent: round1(100 - usedPercent),
2491
+ remainingPercent: round1$1(100 - usedPercent),
2410
2492
  ...remaining !== null ? { remaining } : {},
2411
2493
  ...resetsAt === null ? {} : { resetsAt }
2412
2494
  };
@@ -2448,15 +2530,15 @@ async function collectKimi(keys, config, timeoutMs) {
2448
2530
  return {
2449
2531
  provider: config.provider,
2450
2532
  displayName: "Kimi For Coding",
2451
- status: statusOf(error),
2533
+ status: statusOf$1(error),
2452
2534
  windows: []
2453
2535
  };
2454
2536
  }
2455
2537
  }
2456
2538
  /** Window length in minutes for a Z.ai limit row; null when unknown. */
2457
2539
  function zaiWindowMinutes(limit) {
2458
- const unit = numberOrNull(limit.unit);
2459
- const number = numberOrNull(limit.number);
2540
+ const unit = numberOrNull$1(limit.unit);
2541
+ const number = numberOrNull$1(limit.number);
2460
2542
  if (unit === null || number === null || number <= 0) return null;
2461
2543
  if (unit === 5) return number;
2462
2544
  if (unit === 3) return number * 60;
@@ -2466,14 +2548,14 @@ function zaiWindowMinutes(limit) {
2466
2548
  }
2467
2549
  /** Used percent for a Z.ai limit row. */
2468
2550
  function zaiUsedPercent(limit) {
2469
- const total = numberOrNull(limit.usage);
2470
- const remaining = numberOrNull(limit.remaining);
2471
- 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);
2472
2554
  if (total !== null && total > 0) {
2473
2555
  const used = remaining === null ? current : current === null ? total - remaining : Math.max(total - remaining, current);
2474
- 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);
2475
2557
  }
2476
- return clampPercent(numberOrNull(limit.percentage ?? limit.usedPercent ?? limit.used_percent));
2558
+ return clampPercent$1(numberOrNull$1(limit.percentage ?? limit.usedPercent ?? limit.used_percent));
2477
2559
  }
2478
2560
  /** One Z.ai quota window row. */
2479
2561
  function zaiWindow(limit, kind, fallbackReset = null) {
@@ -2482,8 +2564,8 @@ function zaiWindow(limit, kind, fallbackReset = null) {
2482
2564
  const resetsAt = toIso(limit.nextResetTime ?? limit.next_reset_time) ?? fallbackReset;
2483
2565
  return {
2484
2566
  kind,
2485
- usedPercent: round1(usedPercent),
2486
- remainingPercent: round1(100 - usedPercent),
2567
+ usedPercent: round1$1(usedPercent),
2568
+ remainingPercent: round1$1(100 - usedPercent),
2487
2569
  ...resetsAt === null ? {} : { resetsAt }
2488
2570
  };
2489
2571
  }
@@ -2568,7 +2650,7 @@ async function collectZai(keys, config, timeoutMs) {
2568
2650
  return {
2569
2651
  provider: config.provider,
2570
2652
  displayName: "Z.ai Coding Plan",
2571
- status: statusOf(error),
2653
+ status: statusOf$1(error),
2572
2654
  windows: []
2573
2655
  };
2574
2656
  }
@@ -2578,20 +2660,20 @@ function goWindow(value, kind) {
2578
2660
  if (value === null || typeof value !== "object") return null;
2579
2661
  const record = value;
2580
2662
  const percentSource = record.usagePercent ?? record.usedPercent ?? record.percentUsed ?? record.percentage ?? record.percent;
2581
- let usedPercent = clampPercent(numberOrNull(percentSource));
2663
+ let usedPercent = clampPercent$1(numberOrNull$1(percentSource));
2582
2664
  if (usedPercent === null) {
2583
- const used = numberOrNull(record.used ?? record.consumed);
2584
- const limit = numberOrNull(record.limit ?? record.total ?? record.quota);
2585
- 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);
2586
2668
  }
2587
2669
  if (usedPercent === null) return null;
2588
2670
  if (usedPercent <= 1 && usedPercent >= 0 && record.percent === void 0 && percentSource !== void 0) usedPercent *= 100;
2589
- const resetSeconds = numberOrNull(record.resetInSec ?? record.resetInSeconds ?? record.resetSeconds);
2671
+ const resetSeconds = numberOrNull$1(record.resetInSec ?? record.resetInSeconds ?? record.resetSeconds);
2590
2672
  const resetsAt = resetSeconds === null ? toIso(record.resetAt ?? record.resetsAt ?? record.nextReset) : new Date(Date.now() + Math.max(0, resetSeconds) * 1e3).toISOString();
2591
2673
  return {
2592
2674
  kind,
2593
- usedPercent: round1(clampPercent(usedPercent) ?? 0),
2594
- remainingPercent: round1(100 - (clampPercent(usedPercent) ?? 0)),
2675
+ usedPercent: round1$1(clampPercent$1(usedPercent) ?? 0),
2676
+ remainingPercent: round1$1(100 - (clampPercent$1(usedPercent) ?? 0)),
2595
2677
  ...resetsAt === null ? {} : { resetsAt }
2596
2678
  };
2597
2679
  }
@@ -2631,7 +2713,7 @@ async function collectOpenCodeGo(keys, config, timeoutMs) {
2631
2713
  return {
2632
2714
  provider: config.provider,
2633
2715
  displayName: "OpenCode Go",
2634
- status: statusOf(error),
2716
+ status: statusOf$1(error),
2635
2717
  windows: []
2636
2718
  };
2637
2719
  }
@@ -2648,14 +2730,14 @@ async function collectOpenCodeGo(keys, config, timeoutMs) {
2648
2730
  function minmaxWindow(record, kind, remainPctKey, statusKey, resetKey) {
2649
2731
  if (record === void 0) return null;
2650
2732
  if (Number(record[statusKey]) === 3) return null;
2651
- const remain = numberOrNull(record[remainPctKey]);
2733
+ const remain = numberOrNull$1(record[remainPctKey]);
2652
2734
  if (remain === null) return null;
2653
- 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);
2654
2736
  const resetsAt = toIso(record[resetKey]);
2655
2737
  return {
2656
2738
  kind,
2657
2739
  usedPercent,
2658
- remainingPercent: round1(100 - usedPercent),
2740
+ remainingPercent: round1$1(100 - usedPercent),
2659
2741
  ...resetsAt === null ? {} : { resetsAt }
2660
2742
  };
2661
2743
  }
@@ -2702,7 +2784,7 @@ async function collectMiniMax(keys, config, timeoutMs) {
2702
2784
  return {
2703
2785
  provider: config.provider,
2704
2786
  displayName: "MiniMax Coding Plan",
2705
- status: statusOf(error),
2787
+ status: statusOf$1(error),
2706
2788
  windows: []
2707
2789
  };
2708
2790
  }
@@ -2716,15 +2798,15 @@ async function collectMiniMax(keys, config, timeoutMs) {
2716
2798
  function parseOpenRouterCredits(body) {
2717
2799
  const doc = body ?? {};
2718
2800
  const data = doc.data ?? doc;
2719
- const total = numberOrNull(data.total_credits ?? data.credits);
2720
- 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);
2721
2803
  if (total === null || total <= 0 || used === null) return [];
2722
- const usedPercent = round1(clampPercent(used / total * 100) ?? 0);
2804
+ const usedPercent = round1$1(clampPercent$1(used / total * 100) ?? 0);
2723
2805
  const resetsAt = toIso(data.resets_at ?? data.next_reset_time);
2724
2806
  return [{
2725
2807
  kind: "billing",
2726
2808
  usedPercent,
2727
- remainingPercent: round1(100 - usedPercent),
2809
+ remainingPercent: round1$1(100 - usedPercent),
2728
2810
  ...resetsAt === null ? {} : { resetsAt }
2729
2811
  }];
2730
2812
  }
@@ -2753,7 +2835,7 @@ async function collectOpenRouter(keys, config, timeoutMs) {
2753
2835
  return {
2754
2836
  provider: config.provider,
2755
2837
  displayName: "OpenRouter",
2756
- status: statusOf(error),
2838
+ status: statusOf$1(error),
2757
2839
  windows: []
2758
2840
  };
2759
2841
  }
@@ -2789,6 +2871,262 @@ async function collectSubscriptions(keys, plans = [], timeoutMs = DEFAULT_TIMEOU
2789
2871
  }));
2790
2872
  }
2791
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
2792
3130
  //#region lib/types/client/usage-billing-settings.js
2793
3131
  /**
2794
3132
  * usage-stats 工具开关的共享设置契约(node 与 client 两端共用)。
@@ -2889,19 +3227,80 @@ const SUBSCRIPTION_KEY_SOURCES = [
2889
3227
  key: "openrouterApiKey"
2890
3228
  }
2891
3229
  ];
2892
- /**
2893
- * 读取 llm-pi-ai 设置的 `providers` 字典(`<route> → { apiKeyEnv? }`)。
2894
- * 余额查询复用同一份来源:部署为某个 provider 配一次 key,多个 surface 共享。
3230
+ /** 读 llm-pi-ai 设置的 `providers` 字典(`<route> → { apiKeyEnv?, baseURL?, displayName? }`)。
3231
+ * 余额与订阅查询复用同一份来源:部署为某个 provider 配一次,多 surface 共享。
2895
3232
  * @param settings - the settings service (reads the llm-pi-ai namespace).
2896
3233
  * @returns the providers dict; empty when the namespace is unreadable.
2897
3234
  */
2898
3235
  async function readPiAiProviders(settings) {
2899
3236
  try {
2900
- 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;
2901
3249
  } catch {
2902
3250
  return {};
2903
3251
  }
2904
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
+ }
2905
3304
  /**
2906
3305
  * 解析订阅适配器需要的 API Key:从 llm-pi-ai 设置的 `providers.<id>.apiKeyEnv`
2907
3306
  * 读引用(如 kimi-coding → KIMI_CODING_API_KEY),再经凭据 seam 解析成实际值。
@@ -2941,7 +3340,12 @@ async function resolveSubscriptionKeys(settings, credentials) {
2941
3340
  */
2942
3341
  function apply(ctx, config = {}) {
2943
3342
  let usageSettingsScope;
2944
- const aggregator = createUsageAggregator(ctx.sessionPersistence, { ...config.subscriptionProviders === void 0 ? {} : { subscriptionProviders: config.subscriptionProviders } });
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
+ });
2945
3349
  const cwd = process.cwd();
2946
3350
  const snapshotPath = join(homedir(), ".dsh/.dsh-usage-stats.json");
2947
3351
  const candidates = [
@@ -3225,6 +3629,36 @@ function apply(ctx, config = {}) {
3225
3629
  res.end(JSON.stringify({ quotas: quotaCache.quotas }));
3226
3630
  }
3227
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");
3228
3662
  ctx.effect(() => ctx.webServer.register({
3229
3663
  kind: "exact",
3230
3664
  path: "/api/billing/usage-stats",
@@ -3261,4 +3695,4 @@ function apply(ctx, config = {}) {
3261
3695
  }), "usage-billing: usage-stats route");
3262
3696
  }
3263
3697
  //#endregion
3264
- export { apply, inject, resolveSubscriptionKeys };
3698
+ export { apply, inject, readPiAiProviderRoutes, resolveSubscriptionKeys };