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

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
@@ -68,7 +68,8 @@ const PLAN_KNOWLEDGE = {
68
68
  "ark-token-plan": { type: "code" },
69
69
  "doubao-token-plan": { type: "code" },
70
70
  "minimax": { type: "code" },
71
- "minimax-token-plan": { type: "code" }
71
+ "minimax-token-plan": { type: "code" },
72
+ "minimax-token-plan-cn": { type: "code" }
72
73
  };
73
74
  /**
74
75
  * 订阅/plan provider id 变体 → PLAN_KNOWLEDGE 规范键(引用 dsh-spend 的别名归一化)。
@@ -958,6 +959,46 @@ const DEFAULT_SUBSCRIPTION_PROVIDERS = [
958
959
  function isOfficialProvider(provider) {
959
960
  return /^deepseek(?:-[a-z0-9-]+)?$/i.test(provider.trim());
960
961
  }
962
+ /** 由 baseURL 归一化出站点 origin(协议 + 主机 + 端口);解析失败回退原值。 */
963
+ function siteOriginOf(baseURL) {
964
+ try {
965
+ return new URL(baseURL).origin;
966
+ } catch {
967
+ return baseURL;
968
+ }
969
+ }
970
+ /**
971
+ * 把一个 provider 路由归类为站点引用。判定顺序(与路由在 provider 配置里的状态一致):
972
+ * - 路由存在于当前配置且配了 baseURL → 中转站 `site`(按 origin 归组,同站多 key 合并);
973
+ * - 路由存在于当前配置但无 baseURL → 厂商直连 `direct`;
974
+ * - 路由不在当前配置里 → `unknown`(改过名 / 删除过,是「读不到」而非「直连」)。
975
+ * @param provider - 会话日志里的 provider 路由名(request/header 的 `config.provider`)。
976
+ * @param routes - 当前 provider 路由视图(来自 llm-pi-ai providers)。
977
+ */
978
+ function siteRefOf(provider, routes) {
979
+ const view = routes[provider];
980
+ if (view !== void 0) {
981
+ if (view.baseURL !== void 0) return {
982
+ kind: "site",
983
+ origin: siteOriginOf(view.baseURL),
984
+ provider
985
+ };
986
+ return {
987
+ kind: "direct",
988
+ provider
989
+ };
990
+ }
991
+ return {
992
+ kind: "unknown",
993
+ provider
994
+ };
995
+ }
996
+ /** 站点桶的稳定 key:`site:<origin>` 与 `direct:<provider>` 分开,`unknown` 单一桶。 */
997
+ function siteBucketKey(ref) {
998
+ if (ref.kind === "site") return `site:${ref.origin ?? ""}`;
999
+ if (ref.kind === "direct") return `direct:${ref.provider}`;
1000
+ return "unknown";
1001
+ }
961
1002
  /** Zeroed usage accumulator. */
962
1003
  function emptyUsage() {
963
1004
  return {
@@ -1081,12 +1122,13 @@ function turnState(turns, turn) {
1081
1122
  * (default: any `deepseek`-prefixed id). Others count as third-party.
1082
1123
  * @returns the per-session fold (cached by the incremental aggregator).
1083
1124
  */
1084
- function foldSession(events, subscriptionProviders, officialProviderIds) {
1125
+ function foldSession(events, subscriptionProviders, officialProviderIds, routes = {}) {
1085
1126
  const fold = {
1086
1127
  total: emptyUsage(),
1087
1128
  byModel: /* @__PURE__ */ new Map(),
1088
1129
  byDay: /* @__PURE__ */ new Map(),
1089
1130
  byDayModels: /* @__PURE__ */ new Map(),
1131
+ bySite: /* @__PURE__ */ new Map(),
1090
1132
  planCalls: /* @__PURE__ */ new Map(),
1091
1133
  turns: [],
1092
1134
  perf: [],
@@ -1101,6 +1143,7 @@ function foldSession(events, subscriptionProviders, officialProviderIds) {
1101
1143
  let key = "other";
1102
1144
  let subscription = false;
1103
1145
  let official = false;
1146
+ let siteBucket = "unknown";
1104
1147
  const turns = /* @__PURE__ */ new Map();
1105
1148
  const steps = /* @__PURE__ */ new Map();
1106
1149
  let lastOpenStepKey;
@@ -1145,6 +1188,7 @@ function foldSession(events, subscriptionProviders, officialProviderIds) {
1145
1188
  key = resolveCatalogKey(model);
1146
1189
  subscription = subscriptionProviders.has(provider);
1147
1190
  official = officialProviderIds === void 0 ? isOfficialProvider(provider) : officialProviderIds.has(provider);
1191
+ siteBucket = siteBucketKey(siteRefOf(provider, routes));
1148
1192
  if (lastOpenStepKey !== void 0) {
1149
1193
  const stepState = steps.get(lastOpenStepKey);
1150
1194
  if (stepState !== void 0 && stepState.requestTime === void 0) stepState.requestTime = event.time;
@@ -1174,6 +1218,7 @@ function foldSession(events, subscriptionProviders, officialProviderIds) {
1174
1218
  foldUsage(usageCell(fold.byModel, modelKey), usage, modelKey, subscription, event.time, official);
1175
1219
  foldUsage(usageCell(fold.byDay, day), usage, modelKey, subscription, event.time, official);
1176
1220
  foldUsage(modelDayCell(fold.byDayModels, day, modelKey), usage, modelKey, subscription, event.time, official);
1221
+ foldUsage(usageCell(fold.bySite, siteBucket), usage, modelKey, subscription, event.time, official);
1177
1222
  if (subscription) fold.planCalls.set(modelKey, (fold.planCalls.get(modelKey) ?? 0) + 1);
1178
1223
  const turn = event.data.turn ?? -1;
1179
1224
  const state = turnState(turns, turn);
@@ -1293,6 +1338,8 @@ function createUsageAggregator(persistence, options = {}) {
1293
1338
  const cache = /* @__PURE__ */ new Map();
1294
1339
  let lastDoc;
1295
1340
  let lastAt = 0;
1341
+ /** 每次聚合取最新的 provider 路由视图(中转站零配置发现);缺省按空处理(全部未知路由)。 */
1342
+ const routesOf = () => options.resolveRoutes?.() ?? {};
1296
1343
  /** 失效键:日志文件的 mtime+size;拿不到(后端无 locate / 文件丢失)时每次重折。 */
1297
1344
  const stampOf = async (meta) => {
1298
1345
  const location = persistence.locate?.(meta);
@@ -1329,7 +1376,7 @@ function createUsageAggregator(persistence, options = {}) {
1329
1376
  const { events } = await persistence.readFrom(meta.id, 0);
1330
1377
  const after = await stampOf(meta);
1331
1378
  if (stamp !== null && after !== stamp) continue;
1332
- const fold = foldSession(events, subscriptionProviders, officialProviderIds);
1379
+ const fold = foldSession(events, subscriptionProviders, officialProviderIds, routesOf());
1333
1380
  cache.set(id, {
1334
1381
  stamp,
1335
1382
  fold
@@ -1354,6 +1401,7 @@ function createUsageAggregator(persistence, options = {}) {
1354
1401
  const byModel = /* @__PURE__ */ new Map();
1355
1402
  const byDay = /* @__PURE__ */ new Map();
1356
1403
  const byDayModels = /* @__PURE__ */ new Map();
1404
+ const bySite = /* @__PURE__ */ new Map();
1357
1405
  const planCalls = /* @__PURE__ */ new Map();
1358
1406
  const sessionRows = [];
1359
1407
  const turnRows = [];
@@ -1376,6 +1424,7 @@ function createUsageAggregator(persistence, options = {}) {
1376
1424
  for (const [modelKey, cell] of fold.byModel) mergeUsageInto(usageCell(byModel, modelKey), cell);
1377
1425
  for (const [day, cell] of fold.byDay) mergeUsageInto(usageCell(byDay, day), cell);
1378
1426
  for (const [day, models] of fold.byDayModels) for (const [modelKey, cell] of models) mergeUsageInto(modelDayCell(byDayModels, day, modelKey), cell);
1427
+ for (const [siteKey, cell] of fold.bySite) mergeUsageInto(usageCell(bySite, siteKey), cell);
1379
1428
  for (const [modelKey, count] of fold.planCalls) planCalls.set(modelKey, (planCalls.get(modelKey) ?? 0) + count);
1380
1429
  for (const sample of fold.perf) {
1381
1430
  let modelAccum = perfModel.get(sample.model);
@@ -1407,7 +1456,7 @@ function createUsageAggregator(persistence, options = {}) {
1407
1456
  sessionId,
1408
1457
  ...row
1409
1458
  });
1410
- const wsName = workspaceNameOf(meta.cwd);
1459
+ const wsName = options.resolveWorkspaceTitle !== void 0 && meta.cwd !== void 0 ? options.resolveWorkspaceTitle(meta.cwd) ?? workspaceNameOf(meta.cwd) : workspaceNameOf(meta.cwd);
1411
1460
  const ws = workspaceMap.get(wsName) ?? {
1412
1461
  name: wsName,
1413
1462
  calls: 0,
@@ -1471,6 +1520,7 @@ function createUsageAggregator(persistence, options = {}) {
1471
1520
  bySession: sessionRows.slice(0, 100),
1472
1521
  byTurn: turnRows.slice(0, 200),
1473
1522
  byWorkspace: workspaces.slice(0, 100),
1523
+ ...bySite.size === 0 ? {} : { bySite: toRecord(bySite) },
1474
1524
  ...perf === void 0 ? {} : { perf },
1475
1525
  byRole: (() => {
1476
1526
  const chars = roles.userChars + roles.toolChars;
@@ -1576,7 +1626,7 @@ function createCooldownGate(options = {}) {
1576
1626
  * provider's key once and every surface reuses it.
1577
1627
  */
1578
1628
  /** Abort a balance fetch when the upstream hangs beyond this budget. */
1579
- const FETCH_TIMEOUT_MS$1 = 8e3;
1629
+ const FETCH_TIMEOUT_MS$2 = 8e3;
1580
1630
  /**
1581
1631
  * 每平台熔断门:单个 provider 连续可重试失败(网络波动 / 5xx / 429)达阈值后
1582
1632
  * 短路一段真实时间,避免 30 秒轮询在已不可用的上游上反复打满超时。
@@ -1601,6 +1651,8 @@ const STEPFUN_BALANCE_URL = "https://api.stepfun.com/v1/accounts";
1601
1651
  const SILICONFLOW_BALANCE_URL = "https://api.siliconflow.cn/v1/user/info";
1602
1652
  /** xAI 官方账单接口(docs.x.ai/developers/api/credits);total.val 为美分。 */
1603
1653
  const XAI_CREDITS_URL = "https://api.x.ai/v1/billing/credits";
1654
+ /** 智谱 GLM(大模型国内域)官方余额接口(open.bigmodel.cn/api/paas/v4/balance)。 */
1655
+ const ZHIPU_BALANCE_URL = "https://open.bigmodel.cn/api/paas/v4/balance";
1604
1656
  /** 数字归一化:接口返回的余额是字符串(如 `"110.00"`),统一转 number。 */
1605
1657
  function toNumber(value) {
1606
1658
  if (typeof value === "number" && Number.isFinite(value)) return value;
@@ -1635,7 +1687,7 @@ async function queryBearerBalance(ctx, url, apiKeyEnv, provider, displayName, pa
1635
1687
  };
1636
1688
  const doRequest = async () => {
1637
1689
  const controller = new AbortController();
1638
- const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS$1);
1690
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS$2);
1639
1691
  try {
1640
1692
  const response = await fetch(url, {
1641
1693
  headers: {
@@ -1791,6 +1843,27 @@ function queryXai(ctx, apiKeyEnv) {
1791
1843
  };
1792
1844
  });
1793
1845
  }
1846
+ /**
1847
+ * Query the Zhipu GLM / Z.ai (国内 bigmodel-cn 域) account balance.
1848
+ * 与订阅(zai-coding-cn 的 Coding Plan)互补:一个平台可同时有钱包余额与订阅
1849
+ * 套餐,两者各读各的(TokenLedger 同款双读姿态)。Z.ai global 域币种为 USD,
1850
+ * 本函数固定走国内 CNY 域(open.bigmodel.cn),币种不猜,仅覆盖国内域。
1851
+ * @param ctx - host context carrying the credentials seam.
1852
+ * @param apiKeyEnv - credential reference resolving the Zhipu API key.
1853
+ * @returns the balance row, or an error row when the key/endpoint misbehaves.
1854
+ */
1855
+ function queryZhipu(ctx, apiKeyEnv) {
1856
+ return queryBearerBalance(ctx, ZHIPU_BALANCE_URL, apiKeyEnv, "智谱 AI", "智谱 AI", (data) => {
1857
+ const doc = data;
1858
+ const totalBalance = toNumber(doc.balance?.available) ?? toNumber(doc.balance?.total);
1859
+ return {
1860
+ provider: "智谱 AI",
1861
+ displayName: "智谱 AI",
1862
+ currency: "CNY",
1863
+ ...totalBalance !== void 0 ? { totalBalance } : {}
1864
+ };
1865
+ });
1866
+ }
1794
1867
  const QUERIERS = [
1795
1868
  {
1796
1869
  route: "deepseek",
@@ -1816,6 +1889,16 @@ const QUERIERS = [
1816
1889
  route: "xai",
1817
1890
  displayName: "xAI",
1818
1891
  querier: queryXai
1892
+ },
1893
+ {
1894
+ route: "zhipu",
1895
+ displayName: "智谱 AI",
1896
+ querier: queryZhipu
1897
+ },
1898
+ {
1899
+ route: "zai-coding-cn",
1900
+ displayName: "智谱 AI",
1901
+ querier: queryZhipu
1819
1902
  }
1820
1903
  ];
1821
1904
  /**
@@ -1919,7 +2002,7 @@ async function queryCustomBalances(ctx, configs) {
1919
2002
  };
1920
2003
  const doRequest = async () => {
1921
2004
  const controller = new AbortController();
1922
- const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS$1);
2005
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS$2);
1923
2006
  try {
1924
2007
  const response = await fetch(config.url, {
1925
2008
  method: config.method ?? "GET",
@@ -1985,7 +2068,7 @@ async function queryCustomBalances(ctx, configs) {
1985
2068
  * catalog for the rest — a total outage answers `{ source: 'builtin' }`.
1986
2069
  */
1987
2070
  /** Abort a fetch when the upstream hangs beyond this budget. */
1988
- const FETCH_TIMEOUT_MS = 8e3;
2071
+ const FETCH_TIMEOUT_MS$1 = 8e3;
1989
2072
  /** 每平台熔断门:单个定价上游连续可重试失败(网络 / 5xx / 429)达阈值后短路,
1990
2073
  * 避免 6 小时刷新循环与每次启动在已故障的上游上反复打满超时。按 URL 独立。 */
1991
2074
  const pricingGate = createCooldownGate({
@@ -2079,7 +2162,7 @@ async function fetchText(url) {
2079
2162
  if (!pricingGate.check(url)) return null;
2080
2163
  const doFetch = async () => {
2081
2164
  const controller = new AbortController();
2082
- const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
2165
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS$1);
2083
2166
  try {
2084
2167
  const response = await fetch(url, { signal: controller.signal });
2085
2168
  if (!response.ok) {
@@ -2288,10 +2371,11 @@ const SUBSCRIPTION_DISPLAY_NAMES = {
2288
2371
  "wenxin": "百度文心 Plan",
2289
2372
  "minimax": "MiniMax Coding Plan",
2290
2373
  "minimax-token-plan": "MiniMax Token Plan",
2374
+ "minimax-token-plan-cn": "MiniMax Token Plan(国内)",
2291
2375
  "openrouter": "OpenRouter"
2292
2376
  };
2293
2377
  /** 订阅类 provider id 判定:带 coding / agent-plan / token-plan 后缀,或已知订阅通道。 */
2294
- const SUBSCRIPTION_ID_RE = /* @__PURE__ */ new RegExp("(?:^|-)(?:coding|agent[-_]?plan|token[-_]?plan)(?:$|-|_)|^(?:opencode|opencode-go|kimi-coding|zai-coding|minimax|minimax-token-plan|openrouter)", "i");
2378
+ const SUBSCRIPTION_ID_RE = /* @__PURE__ */ new RegExp("(?:^|-)(?:coding|agent[-_]?plan|token[-_]?plan)(?:$|-|_)|^(?:opencode|opencode-go|kimi-coding|zai-coding|minimax|minimax-token-plan|minimax-token-plan-cn|openrouter)", "i");
2295
2379
  /** 是否是订阅类 provider id(如 kimi-coding、xiaomi-token-plan-cn)。 */
2296
2380
  function isSubscriptionProviderId(providerId) {
2297
2381
  if (SUBSCRIPTION_ID_RE.test(providerId)) return true;
@@ -2305,6 +2389,7 @@ const SUBSCRIPTION_ADAPTERS = {
2305
2389
  "opencode-go": { collect: collectOpenCodeGo },
2306
2390
  "minimax": { collect: collectMiniMax },
2307
2391
  "minimax-token-plan": { collect: collectMiniMax },
2392
+ "minimax-token-plan-cn": { collect: collectMiniMax },
2308
2393
  "openrouter": { collect: collectOpenRouter }
2309
2394
  };
2310
2395
  /** 有额度适配器的 provider id 集合(识别用)。 */
@@ -2330,7 +2415,7 @@ function identifySubscriptionPlans(providers) {
2330
2415
  }
2331
2416
  const DEFAULT_TIMEOUT_MS = 15e3;
2332
2417
  /** Number, or null when the value is not a finite number (nor numeric string). */
2333
- function numberOrNull(value) {
2418
+ function numberOrNull$1(value) {
2334
2419
  if (typeof value === "number" && Number.isFinite(value)) return value;
2335
2420
  if (typeof value === "string" && value.trim() !== "") {
2336
2421
  const parsed = Number(value);
@@ -2339,11 +2424,11 @@ function numberOrNull(value) {
2339
2424
  return null;
2340
2425
  }
2341
2426
  /** Clamp a percentage to 0–100. */
2342
- function clampPercent(value) {
2427
+ function clampPercent$1(value) {
2343
2428
  return value === null ? null : Math.max(0, Math.min(100, value));
2344
2429
  }
2345
2430
  /** Round to one decimal. */
2346
- function round1(value) {
2431
+ function round1$1(value) {
2347
2432
  return Math.round(value * 10) / 10;
2348
2433
  }
2349
2434
  /** Number → ISO string (seconds treated as epoch seconds, ms as epoch ms). */
@@ -2357,7 +2442,7 @@ function toIso(value) {
2357
2442
  return Number.isNaN(date.getTime()) ? null : date.toISOString();
2358
2443
  }
2359
2444
  /** Map a fetch error to a stable status. */
2360
- function statusOf(error) {
2445
+ function statusOf$1(error) {
2361
2446
  if (error instanceof Error) {
2362
2447
  if (error.name === "TimeoutError" || error.name === "AbortError") return "unavailable";
2363
2448
  const status = error.httpStatus;
@@ -2392,21 +2477,21 @@ async function requestJson(url, init, timeoutMs) {
2392
2477
  function kimiWindow(value, kind) {
2393
2478
  if (value === null || typeof value !== "object") return null;
2394
2479
  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;
2480
+ const limit = numberOrNull$1(record.limit ?? record.total);
2481
+ const remaining = numberOrNull$1(record.remaining);
2482
+ if (remaining === null && limit === null && numberOrNull$1(record.percentage ?? record.usedPercent ?? record.used_percent) === null) return null;
2398
2483
  const hasLimit = limit !== null && limit > 0;
2399
2484
  let usedPercent;
2400
- if (hasLimit) usedPercent = round1(clampPercent((limit - (remaining ?? 0)) / limit * 100) ?? 0);
2485
+ if (hasLimit) usedPercent = round1$1(clampPercent$1((limit - (remaining ?? 0)) / limit * 100) ?? 0);
2401
2486
  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;
2487
+ const percent = numberOrNull$1(record.percentage ?? record.usedPercent ?? record.used_percent);
2488
+ usedPercent = percent !== null ? round1$1(clampPercent$1(percent) ?? 0) : remaining === null || remaining <= 0 ? 100 : 0;
2404
2489
  }
2405
2490
  const resetsAt = toIso(record.resetTime ?? record.reset_time ?? record.resetsAt);
2406
2491
  return {
2407
2492
  kind,
2408
2493
  usedPercent,
2409
- remainingPercent: round1(100 - usedPercent),
2494
+ remainingPercent: round1$1(100 - usedPercent),
2410
2495
  ...remaining !== null ? { remaining } : {},
2411
2496
  ...resetsAt === null ? {} : { resetsAt }
2412
2497
  };
@@ -2448,15 +2533,15 @@ async function collectKimi(keys, config, timeoutMs) {
2448
2533
  return {
2449
2534
  provider: config.provider,
2450
2535
  displayName: "Kimi For Coding",
2451
- status: statusOf(error),
2536
+ status: statusOf$1(error),
2452
2537
  windows: []
2453
2538
  };
2454
2539
  }
2455
2540
  }
2456
2541
  /** Window length in minutes for a Z.ai limit row; null when unknown. */
2457
2542
  function zaiWindowMinutes(limit) {
2458
- const unit = numberOrNull(limit.unit);
2459
- const number = numberOrNull(limit.number);
2543
+ const unit = numberOrNull$1(limit.unit);
2544
+ const number = numberOrNull$1(limit.number);
2460
2545
  if (unit === null || number === null || number <= 0) return null;
2461
2546
  if (unit === 5) return number;
2462
2547
  if (unit === 3) return number * 60;
@@ -2466,14 +2551,14 @@ function zaiWindowMinutes(limit) {
2466
2551
  }
2467
2552
  /** Used percent for a Z.ai limit row. */
2468
2553
  function zaiUsedPercent(limit) {
2469
- const total = numberOrNull(limit.usage);
2470
- const remaining = numberOrNull(limit.remaining);
2471
- const current = numberOrNull(limit.currentValue ?? limit.current_value);
2554
+ const total = numberOrNull$1(limit.usage);
2555
+ const remaining = numberOrNull$1(limit.remaining);
2556
+ const current = numberOrNull$1(limit.currentValue ?? limit.current_value);
2472
2557
  if (total !== null && total > 0) {
2473
2558
  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);
2559
+ if (used !== null) return clampPercent$1(Math.max(0, Math.min(total, used)) / total * 100);
2475
2560
  }
2476
- return clampPercent(numberOrNull(limit.percentage ?? limit.usedPercent ?? limit.used_percent));
2561
+ return clampPercent$1(numberOrNull$1(limit.percentage ?? limit.usedPercent ?? limit.used_percent));
2477
2562
  }
2478
2563
  /** One Z.ai quota window row. */
2479
2564
  function zaiWindow(limit, kind, fallbackReset = null) {
@@ -2482,8 +2567,8 @@ function zaiWindow(limit, kind, fallbackReset = null) {
2482
2567
  const resetsAt = toIso(limit.nextResetTime ?? limit.next_reset_time) ?? fallbackReset;
2483
2568
  return {
2484
2569
  kind,
2485
- usedPercent: round1(usedPercent),
2486
- remainingPercent: round1(100 - usedPercent),
2570
+ usedPercent: round1$1(usedPercent),
2571
+ remainingPercent: round1$1(100 - usedPercent),
2487
2572
  ...resetsAt === null ? {} : { resetsAt }
2488
2573
  };
2489
2574
  }
@@ -2568,7 +2653,7 @@ async function collectZai(keys, config, timeoutMs) {
2568
2653
  return {
2569
2654
  provider: config.provider,
2570
2655
  displayName: "Z.ai Coding Plan",
2571
- status: statusOf(error),
2656
+ status: statusOf$1(error),
2572
2657
  windows: []
2573
2658
  };
2574
2659
  }
@@ -2578,20 +2663,20 @@ function goWindow(value, kind) {
2578
2663
  if (value === null || typeof value !== "object") return null;
2579
2664
  const record = value;
2580
2665
  const percentSource = record.usagePercent ?? record.usedPercent ?? record.percentUsed ?? record.percentage ?? record.percent;
2581
- let usedPercent = clampPercent(numberOrNull(percentSource));
2666
+ let usedPercent = clampPercent$1(numberOrNull$1(percentSource));
2582
2667
  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);
2668
+ const used = numberOrNull$1(record.used ?? record.consumed);
2669
+ const limit = numberOrNull$1(record.limit ?? record.total ?? record.quota);
2670
+ if (used !== null && limit !== null && limit > 0) usedPercent = clampPercent$1(used / limit * 100);
2586
2671
  }
2587
2672
  if (usedPercent === null) return null;
2588
2673
  if (usedPercent <= 1 && usedPercent >= 0 && record.percent === void 0 && percentSource !== void 0) usedPercent *= 100;
2589
- const resetSeconds = numberOrNull(record.resetInSec ?? record.resetInSeconds ?? record.resetSeconds);
2674
+ const resetSeconds = numberOrNull$1(record.resetInSec ?? record.resetInSeconds ?? record.resetSeconds);
2590
2675
  const resetsAt = resetSeconds === null ? toIso(record.resetAt ?? record.resetsAt ?? record.nextReset) : new Date(Date.now() + Math.max(0, resetSeconds) * 1e3).toISOString();
2591
2676
  return {
2592
2677
  kind,
2593
- usedPercent: round1(clampPercent(usedPercent) ?? 0),
2594
- remainingPercent: round1(100 - (clampPercent(usedPercent) ?? 0)),
2678
+ usedPercent: round1$1(clampPercent$1(usedPercent) ?? 0),
2679
+ remainingPercent: round1$1(100 - (clampPercent$1(usedPercent) ?? 0)),
2595
2680
  ...resetsAt === null ? {} : { resetsAt }
2596
2681
  };
2597
2682
  }
@@ -2631,7 +2716,7 @@ async function collectOpenCodeGo(keys, config, timeoutMs) {
2631
2716
  return {
2632
2717
  provider: config.provider,
2633
2718
  displayName: "OpenCode Go",
2634
- status: statusOf(error),
2719
+ status: statusOf$1(error),
2635
2720
  windows: []
2636
2721
  };
2637
2722
  }
@@ -2648,14 +2733,14 @@ async function collectOpenCodeGo(keys, config, timeoutMs) {
2648
2733
  function minmaxWindow(record, kind, remainPctKey, statusKey, resetKey) {
2649
2734
  if (record === void 0) return null;
2650
2735
  if (Number(record[statusKey]) === 3) return null;
2651
- const remain = numberOrNull(record[remainPctKey]);
2736
+ const remain = numberOrNull$1(record[remainPctKey]);
2652
2737
  if (remain === null) return null;
2653
- const usedPercent = round1(clampPercent(100 - (remain <= 1 ? remain * 100 : remain)) ?? 0);
2738
+ const usedPercent = round1$1(clampPercent$1(100 - (remain <= 1 ? remain * 100 : remain)) ?? 0);
2654
2739
  const resetsAt = toIso(record[resetKey]);
2655
2740
  return {
2656
2741
  kind,
2657
2742
  usedPercent,
2658
- remainingPercent: round1(100 - usedPercent),
2743
+ remainingPercent: round1$1(100 - usedPercent),
2659
2744
  ...resetsAt === null ? {} : { resetsAt }
2660
2745
  };
2661
2746
  }
@@ -2677,13 +2762,29 @@ function parseMiniMaxRemains(body) {
2677
2762
  }) ?? rows[0] ?? void 0;
2678
2763
  return [minmaxWindow(record, "session", "current_interval_remaining_percent", "current_interval_status", "end_time"), minmaxWindow(record, "weekly", "current_weekly_remaining_percent", "current_weekly_status", "weekly_end_time")].filter((hit) => hit !== null);
2679
2764
  }
2680
- /** Collect the MiniMax Token Plan quota. */
2765
+ /**
2766
+ * Resolve the MiniMax API host based on the configured provider id.
2767
+ *
2768
+ * 国内开发者走 MiniMax(`api.minimaxi.com`),海外走 MiniMax(`minimaxi.com`)。
2769
+ * User-explicit `config.baseUrl` wins when set, so deployments in either
2770
+ * region can still override the auto-pick (e.g. proxies / staging).
2771
+ */
2772
+ function resolveMiniMaxBaseUrl(config) {
2773
+ if (typeof config.baseUrl === "string" && config.baseUrl.trim() !== "") return config.baseUrl;
2774
+ return config.provider === "minimax-token-plan-cn" ? "https://api.minimaxi.com" : "https://www.minimaxi.com";
2775
+ }
2776
+ /** Display name for a MiniMax quota row, aligned with the display-name map. */
2777
+ function minmaxDisplayName(provider) {
2778
+ return SUBSCRIPTION_DISPLAY_NAMES[provider] ?? "MiniMax Coding Plan";
2779
+ }
2780
+ /** Collect the MiniMax Token Plan quota (CN + INTL). */
2681
2781
  async function collectMiniMax(keys, config, timeoutMs) {
2682
2782
  const apiKey = keys.minmaxApiKey.trim();
2683
- const base = config.baseUrl ?? "https://www.minimaxi.com";
2783
+ const base = resolveMiniMaxBaseUrl(config);
2784
+ const displayName = minmaxDisplayName(config.provider);
2684
2785
  if (apiKey === "") return {
2685
2786
  provider: config.provider,
2686
- displayName: "MiniMax Coding Plan",
2787
+ displayName,
2687
2788
  status: "not-configured",
2688
2789
  windows: []
2689
2790
  };
@@ -2694,15 +2795,15 @@ async function collectMiniMax(keys, config, timeoutMs) {
2694
2795
  } }, timeoutMs));
2695
2796
  return {
2696
2797
  provider: config.provider,
2697
- displayName: "MiniMax Coding Plan",
2798
+ displayName,
2698
2799
  status: windows.length > 0 ? "ok" : "invalid-response",
2699
2800
  windows
2700
2801
  };
2701
2802
  } catch (error) {
2702
2803
  return {
2703
2804
  provider: config.provider,
2704
- displayName: "MiniMax Coding Plan",
2705
- status: statusOf(error),
2805
+ displayName,
2806
+ status: statusOf$1(error),
2706
2807
  windows: []
2707
2808
  };
2708
2809
  }
@@ -2716,15 +2817,15 @@ async function collectMiniMax(keys, config, timeoutMs) {
2716
2817
  function parseOpenRouterCredits(body) {
2717
2818
  const doc = body ?? {};
2718
2819
  const data = doc.data ?? doc;
2719
- const total = numberOrNull(data.total_credits ?? data.credits);
2720
- const used = numberOrNull(data.total_usage ?? data.usage);
2820
+ const total = numberOrNull$1(data.total_credits ?? data.credits);
2821
+ const used = numberOrNull$1(data.total_usage ?? data.usage);
2721
2822
  if (total === null || total <= 0 || used === null) return [];
2722
- const usedPercent = round1(clampPercent(used / total * 100) ?? 0);
2823
+ const usedPercent = round1$1(clampPercent$1(used / total * 100) ?? 0);
2723
2824
  const resetsAt = toIso(data.resets_at ?? data.next_reset_time);
2724
2825
  return [{
2725
2826
  kind: "billing",
2726
2827
  usedPercent,
2727
- remainingPercent: round1(100 - usedPercent),
2828
+ remainingPercent: round1$1(100 - usedPercent),
2728
2829
  ...resetsAt === null ? {} : { resetsAt }
2729
2830
  }];
2730
2831
  }
@@ -2753,7 +2854,7 @@ async function collectOpenRouter(keys, config, timeoutMs) {
2753
2854
  return {
2754
2855
  provider: config.provider,
2755
2856
  displayName: "OpenRouter",
2756
- status: statusOf(error),
2857
+ status: statusOf$1(error),
2757
2858
  windows: []
2758
2859
  };
2759
2860
  }
@@ -2789,6 +2890,262 @@ async function collectSubscriptions(keys, plans = [], timeoutMs = DEFAULT_TIMEOU
2789
2890
  }));
2790
2891
  }
2791
2892
  //#endregion
2893
+ //#region lib/types/relay.js
2894
+ /**
2895
+ * 中转站额度查询(node 半区):识别并读取 New API 系与 Sub2API 的「余额 / 额度窗口」。
2896
+ *
2897
+ * 适用场景:用户把某条 llm-pi-ai provider 路由的 `baseURL` 指向第三方中转站
2898
+ * (New API / One API / VoAPI / Sub2API 等)。这类站点不卖官方余额,卖的是
2899
+ * 按 key 的额度(used/total)或多个滚动窗口。本模块对**配了 baseURL 且有
2900
+ * apiKeyEnv** 的路由逐个探测两个已知端点,能解析出额度就返回;解析不出的
2901
+ * 静默标记 unavailable,绝不臆造金额(与 balance/subscriptions 一致的姿态)。
2902
+ *
2903
+ * 探测顺序:先 Sub2API `/v1/usage`(标准化程度高),再 New API `/api/status`;
2904
+ * 404 = 不是该套程序,继续试下一种;401/403 = 是但 key 不对(unauthorized);
2905
+ * 网络/5xx 走熔断门短路一段时间。同一站点多把 key 是独立额度,分别列出。
2906
+ */
2907
+ /** 单个中转站额度请求的熔断门:按 baseURL 独立熔断(各站点互不干扰)。 */
2908
+ const relayGate = createCooldownGate({
2909
+ failures: 3,
2910
+ cooldownMs: 6e4
2911
+ });
2912
+ /** Abort a relay fetch when the upstream hangs beyond this budget. */
2913
+ const FETCH_TIMEOUT_MS = 8e3;
2914
+ /** Number, or null when the value is not a finite number (nor numeric string). */
2915
+ function numberOrNull(value) {
2916
+ if (typeof value === "number" && Number.isFinite(value)) return value;
2917
+ if (typeof value === "string" && value.trim() !== "") {
2918
+ const parsed = Number(value);
2919
+ if (Number.isFinite(parsed)) return parsed;
2920
+ }
2921
+ return null;
2922
+ }
2923
+ /** Clamp a percentage to 0–100. */
2924
+ function clampPercent(value) {
2925
+ return value === null ? null : Math.max(0, Math.min(100, value));
2926
+ }
2927
+ /** Round to one decimal. */
2928
+ function round1(value) {
2929
+ return Math.round(value * 10) / 10;
2930
+ }
2931
+ /** Map a fetch error to a stable status (same taxonomy as subscriptions). */
2932
+ function statusOf(error) {
2933
+ if (error instanceof Error) {
2934
+ if (error.name === "TimeoutError" || error.name === "AbortError") return "unavailable";
2935
+ const status = error.httpStatus;
2936
+ if (status === 401 || status === 403) return "unauthorized";
2937
+ if (status === 429) return "rate-limited";
2938
+ if (status === 404) return "unavailable";
2939
+ }
2940
+ return "unavailable";
2941
+ }
2942
+ /**
2943
+ * GET 一个中转站端点并返回 JSON。可重试错误(网络 / 5xx / 429)退避重试一次;
2944
+ * 401/403/404 不重试。返回 `{ ok, status, data }`,由调用方区分"不是这套程序
2945
+ * (404)"与"是但读取失败(其他非 2xx)"。
2946
+ */
2947
+ async function fetchRelayJson(url, apiKey) {
2948
+ const doFetch = async () => {
2949
+ const response = await fetch(url, {
2950
+ headers: {
2951
+ accept: "application/json",
2952
+ authorization: `Bearer ${apiKey}`
2953
+ },
2954
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
2955
+ });
2956
+ if (!response.ok) {
2957
+ if (response.status === 404) return {
2958
+ ok: false,
2959
+ status: 404
2960
+ };
2961
+ const error = /* @__PURE__ */ new Error(`HTTP ${String(response.status)}`);
2962
+ error.httpStatus = response.status;
2963
+ throw error;
2964
+ }
2965
+ return {
2966
+ ok: true,
2967
+ status: response.status,
2968
+ data: await response.json()
2969
+ };
2970
+ };
2971
+ return await withRetry(doFetch, {
2972
+ retries: 1,
2973
+ baseDelayMs: 250,
2974
+ maxDelayMs: 2e3
2975
+ });
2976
+ }
2977
+ /** 新建一个额度窗口行(未解析出百分比时不产出)。 */
2978
+ function windowOf(kind, usedPercent, resetsAt) {
2979
+ const used = clampPercent(usedPercent);
2980
+ if (used === null) return null;
2981
+ return {
2982
+ kind,
2983
+ usedPercent: round1(used),
2984
+ remainingPercent: round1(Math.max(0, 100 - used)),
2985
+ ...resetsAt === void 0 ? {} : { resetsAt }
2986
+ };
2987
+ }
2988
+ /**
2989
+ * 解析 Sub2API `/v1/usage` 响应:能取到 balance 或 quota/used 就识别为 sub2api。
2990
+ * 三种形态(窗口 / 分组 / 钱包余额)都宽容处理:有 `quota/total` 给出窗口,
2991
+ * 有 `balance` 给出余额,两者可同时存在。
2992
+ * @param data - `/v1/usage` 的 JSON 响应。
2993
+ * @returns 解析结果;两者都取不到返回 null(不是 Sub2API 或响应漂移)。
2994
+ */
2995
+ function parseSub2ApiUsage(data) {
2996
+ if (data === null || typeof data !== "object") return null;
2997
+ const doc = data;
2998
+ const balance = numberOrNull(doc.balance);
2999
+ const total = numberOrNull(doc.quota ?? doc.total_quota ?? doc.limit);
3000
+ const used = numberOrNull(doc.used_quota ?? doc.usage);
3001
+ if (balance === null && total === null) return null;
3002
+ const windows = [];
3003
+ if (total !== null && used !== null) {
3004
+ const pct = used / total * 100;
3005
+ const window = windowOf("weekly", Number.isFinite(pct) ? pct : null);
3006
+ if (window !== null) windows.push(window);
3007
+ }
3008
+ return {
3009
+ ...balance !== null ? { balance } : {},
3010
+ ...windows.length === 0 ? {} : { windows }
3011
+ };
3012
+ }
3013
+ /**
3014
+ * 解析 New API `/api/status` 响应:New API 系(One API / VoAPI 分支)的额度是
3015
+ * 按记录行的 ratio(已用比例)。只给出窗口,不猜金额(币种防猜)。
3016
+ * @param data - `/api/status` 的 JSON 响应。
3017
+ * @returns 窗口;取不到比例返回 null(响应漂移)。
3018
+ */
3019
+ function parseNewApiStatus(data) {
3020
+ if (data === null || typeof data !== "object") return null;
3021
+ const inner = data.data;
3022
+ if (inner === null || typeof inner !== "object") return null;
3023
+ const ratio = numberOrNull(inner.ratio);
3024
+ const used = numberOrNull(inner.used_quota);
3025
+ const total = numberOrNull(inner.total_quota ?? inner.quota);
3026
+ let pct = null;
3027
+ if (ratio !== null) pct = ratio * 100;
3028
+ else if (total !== null && used !== null) pct = used / total * 100;
3029
+ if (pct === null) return null;
3030
+ const window = windowOf("billing", Number.isFinite(pct) ? pct : null);
3031
+ return window === null ? null : { windows: [window] };
3032
+ }
3033
+ /** 归一化站点 origin(与聚合层 `siteOriginOf` 同口径)。 */
3034
+ function originOf(baseURL) {
3035
+ try {
3036
+ return new URL(baseURL).origin;
3037
+ } catch {
3038
+ return baseURL;
3039
+ }
3040
+ }
3041
+ /** 构造端点 URL:`/v1/usage` 与 `/api/status` 都以 baseURL 为宿主解析。 */
3042
+ function endpointOf(baseURL, path) {
3043
+ return new URL(path, baseURL).toString();
3044
+ }
3045
+ /**
3046
+ * 查询单个中转站路由的额度。先试 Sub2API,再试 New API;任一读出额度即返回。
3047
+ * @param ctx - host context carrying the credentials seam.
3048
+ * @param route - 待探测的路由(baseURL + apiKeyEnv)。
3049
+ * @returns 该路由的一行额度结果(status 标记成败)。
3050
+ */
3051
+ async function queryRelayQuota(ctx, route) {
3052
+ const base = {
3053
+ route: route.route,
3054
+ origin: originOf(route.baseURL),
3055
+ displayName: route.displayName ?? route.route
3056
+ };
3057
+ if (!relayGate.check(route.baseURL)) return {
3058
+ ...base,
3059
+ kind: "unknown",
3060
+ status: "unavailable"
3061
+ };
3062
+ const hit = await ctx.credentials.resolve(credentialRef(route.apiKeyEnv));
3063
+ if (hit === void 0 || hit.value === "") return {
3064
+ ...base,
3065
+ kind: "unknown",
3066
+ status: "not-configured"
3067
+ };
3068
+ try {
3069
+ const sub2 = await fetchRelayJson(endpointOf(route.baseURL, "/v1/usage"), hit.value);
3070
+ if (sub2.ok) {
3071
+ const parsed = parseSub2ApiUsage(sub2.data);
3072
+ if (parsed !== null) {
3073
+ relayGate.success(route.baseURL);
3074
+ return {
3075
+ ...base,
3076
+ kind: "sub2api",
3077
+ status: "ok",
3078
+ ...parsed.balance !== void 0 ? { balance: parsed.balance } : {},
3079
+ ...parsed.windows !== void 0 ? { windows: parsed.windows } : {}
3080
+ };
3081
+ }
3082
+ relayGate.fail(route.baseURL);
3083
+ return {
3084
+ ...base,
3085
+ kind: "sub2api",
3086
+ status: "invalid-response"
3087
+ };
3088
+ }
3089
+ if (sub2.status === 401 || sub2.status === 403) {
3090
+ relayGate.fail(route.baseURL);
3091
+ return {
3092
+ ...base,
3093
+ kind: "unknown",
3094
+ status: "unauthorized"
3095
+ };
3096
+ }
3097
+ const na = await fetchRelayJson(endpointOf(route.baseURL, "/api/status"), hit.value);
3098
+ if (na.status === 401 || na.status === 403) {
3099
+ relayGate.fail(route.baseURL);
3100
+ return {
3101
+ ...base,
3102
+ kind: "unknown",
3103
+ status: "unauthorized"
3104
+ };
3105
+ }
3106
+ if (na.ok) {
3107
+ const parsed = parseNewApiStatus(na.data);
3108
+ if (parsed !== null) {
3109
+ relayGate.success(route.baseURL);
3110
+ return {
3111
+ ...base,
3112
+ kind: "new-api",
3113
+ status: "ok",
3114
+ ...parsed.windows !== void 0 ? { windows: parsed.windows } : {}
3115
+ };
3116
+ }
3117
+ relayGate.fail(route.baseURL);
3118
+ return {
3119
+ ...base,
3120
+ kind: "new-api",
3121
+ status: "invalid-response"
3122
+ };
3123
+ }
3124
+ relayGate.fail(route.baseURL);
3125
+ return {
3126
+ ...base,
3127
+ kind: "unknown",
3128
+ status: "unavailable"
3129
+ };
3130
+ } catch (error) {
3131
+ relayGate.fail(route.baseURL);
3132
+ return {
3133
+ ...base,
3134
+ kind: "unknown",
3135
+ status: statusOf(error)
3136
+ };
3137
+ }
3138
+ }
3139
+ /**
3140
+ * 批量查询多个中转站路由的额度(每个独立成败,互不影响)。
3141
+ * @param ctx - host context carrying the credentials seam.
3142
+ * @param routes - 配了 baseURL 且 apiKeyEnv 有值的路由列表。
3143
+ * @returns 每个路由一行的额度结果。
3144
+ */
3145
+ async function queryRelayQuotas(ctx, routes) {
3146
+ return await Promise.all(routes.map(async (route) => queryRelayQuota(ctx, route)));
3147
+ }
3148
+ //#endregion
2792
3149
  //#region lib/types/client/usage-billing-settings.js
2793
3150
  /**
2794
3151
  * usage-stats 工具开关的共享设置契约(node 与 client 两端共用)。
@@ -2889,20 +3246,81 @@ const SUBSCRIPTION_KEY_SOURCES = [
2889
3246
  key: "openrouterApiKey"
2890
3247
  }
2891
3248
  ];
2892
- /**
2893
- * 读取 llm-pi-ai 设置的 `providers` 字典(`<route> → { apiKeyEnv? }`)。
2894
- * 余额查询复用同一份来源:部署为某个 provider 配一次 key,多个 surface 共享。
3249
+ /** 读 llm-pi-ai 设置的 `providers` 字典(`<route> → { apiKeyEnv?, baseURL?, displayName? }`)。
3250
+ * 余额与订阅查询复用同一份来源:部署为某个 provider 配一次,多 surface 共享。
2895
3251
  * @param settings - the settings service (reads the llm-pi-ai namespace).
2896
3252
  * @returns the providers dict; empty when the namespace is unreadable.
2897
3253
  */
2898
3254
  async function readPiAiProviders(settings) {
2899
3255
  try {
2900
- return (settings.describe({ redactSecrets: true }).find((descriptor) => descriptor.ns === "llm-pi-ai")?.value)?.providers ?? {};
3256
+ const providers = (settings.describe({ redactSecrets: true }).find((descriptor) => descriptor.ns === "llm-pi-ai")?.value)?.providers;
3257
+ const out = {};
3258
+ for (const [route, entry] of Object.entries(providers ?? {})) {
3259
+ if (entry === null || typeof entry !== "object") continue;
3260
+ const { apiKeyEnv, baseURL, displayName } = entry;
3261
+ out[route] = {
3262
+ ...typeof apiKeyEnv === "string" ? { apiKeyEnv } : {},
3263
+ ...typeof baseURL === "string" ? { baseURL } : {},
3264
+ ...typeof displayName === "string" ? { displayName } : {}
3265
+ };
3266
+ }
3267
+ return out;
3268
+ } catch {
3269
+ return {};
3270
+ }
3271
+ }
3272
+ /** 同步读取 provider 路由的 baseURL 视图(中转站零配置发现来源)。
3273
+ * `settings.describe` 是同步调用,聚合器每次折叠取最新站点映射,无需缓存/过期。
3274
+ * 注意:返回**全部可读路由**(baseURL 可选),聚合层据此区分「路由存在但无
3275
+ * baseURL=直连」与「路由已删除=未知路由」两种不同归属。
3276
+ * @param settings - the settings service (reads the llm-pi-ai namespace).
3277
+ * @returns `<route> → { baseURL? }`;命名空间不可读时返回空。
3278
+ */
3279
+ function readPiAiProviderRoutes(settings) {
3280
+ try {
3281
+ const providers = (settings.describe({ redactSecrets: true }).find((descriptor) => descriptor.ns === "llm-pi-ai")?.value)?.providers;
3282
+ const out = {};
3283
+ for (const [route, entry] of Object.entries(providers ?? {})) {
3284
+ if (entry === null || typeof entry !== "object") continue;
3285
+ const baseURL = entry.baseURL;
3286
+ out[route] = typeof baseURL === "string" && baseURL !== "" ? { baseURL } : {};
3287
+ }
3288
+ return out;
2901
3289
  } catch {
2902
3290
  return {};
2903
3291
  }
2904
3292
  }
2905
3293
  /**
3294
+ * 构造「cwd → 工作区标题」解析器(host 的 `workspaceRegistry` 为可选依赖)。
3295
+ * 匹配与 TokenLedger 同口径:会话 cwd 等于某工作区 path、或位于其子目录时,用
3296
+ * 工作区标题命名该项目(子目录的会话也计入);否则返回 undefined(回退到目录名)。
3297
+ * registry 缺席/读取失败都返回 undefined,绝不抛错(可选依赖,不影响主流程)。
3298
+ * @param ctx - host context carrying the optional workspace registry.
3299
+ * @returns 标题解析函数;registry 不可用时 undefined。
3300
+ */
3301
+ function buildWorkspaceTitleResolver(ctx) {
3302
+ let registry;
3303
+ try {
3304
+ registry = ctx.get("workspaceRegistry");
3305
+ } catch {
3306
+ return;
3307
+ }
3308
+ if (registry === void 0 || typeof registry.list !== "function") return void 0;
3309
+ const reg = registry;
3310
+ return (cwd) => {
3311
+ if (cwd === "") return void 0;
3312
+ try {
3313
+ const records = reg.list() ?? [];
3314
+ const exact = records.find((record) => record.path === cwd);
3315
+ if (exact !== void 0) return exact.title;
3316
+ for (const record of records) if (record.path !== "" && cwd.startsWith(`${record.path}/`)) return record.title;
3317
+ return;
3318
+ } catch {
3319
+ return;
3320
+ }
3321
+ };
3322
+ }
3323
+ /**
2906
3324
  * 解析订阅适配器需要的 API Key:从 llm-pi-ai 设置的 `providers.<id>.apiKeyEnv`
2907
3325
  * 读引用(如 kimi-coding → KIMI_CODING_API_KEY),再经凭据 seam 解析成实际值。
2908
3326
  * 同时识别出用户配置了 key 的订阅套餐(供面板只显示已识别的)。
@@ -2941,7 +3359,12 @@ async function resolveSubscriptionKeys(settings, credentials) {
2941
3359
  */
2942
3360
  function apply(ctx, config = {}) {
2943
3361
  let usageSettingsScope;
2944
- const aggregator = createUsageAggregator(ctx.sessionPersistence, { ...config.subscriptionProviders === void 0 ? {} : { subscriptionProviders: config.subscriptionProviders } });
3362
+ const workspaceTitleResolver = buildWorkspaceTitleResolver(ctx);
3363
+ const aggregator = createUsageAggregator(ctx.sessionPersistence, {
3364
+ ...config.subscriptionProviders === void 0 ? {} : { subscriptionProviders: config.subscriptionProviders },
3365
+ resolveRoutes: () => readPiAiProviderRoutes(ctx.settings),
3366
+ ...workspaceTitleResolver === void 0 ? {} : { resolveWorkspaceTitle: workspaceTitleResolver }
3367
+ });
2945
3368
  const cwd = process.cwd();
2946
3369
  const snapshotPath = join(homedir(), ".dsh/.dsh-usage-stats.json");
2947
3370
  const candidates = [
@@ -3225,6 +3648,36 @@ function apply(ctx, config = {}) {
3225
3648
  res.end(JSON.stringify({ quotas: quotaCache.quotas }));
3226
3649
  }
3227
3650
  }), "usage-billing: subscriptions route");
3651
+ let relayCache = {
3652
+ at: 0,
3653
+ quotas: []
3654
+ };
3655
+ const refreshRelay = async () => {
3656
+ const providers = await readPiAiProviders(ctx.settings);
3657
+ const routes = [];
3658
+ for (const [route, entry] of Object.entries(providers)) {
3659
+ if (entry.baseURL === void 0 || entry.apiKeyEnv === void 0) continue;
3660
+ routes.push({
3661
+ route,
3662
+ baseURL: entry.baseURL,
3663
+ apiKeyEnv: entry.apiKeyEnv,
3664
+ ...entry.displayName === void 0 ? {} : { displayName: entry.displayName }
3665
+ });
3666
+ }
3667
+ relayCache = {
3668
+ at: Date.now(),
3669
+ quotas: await queryRelayQuotas(ctx, routes)
3670
+ };
3671
+ };
3672
+ ctx.effect(() => ctx.webServer.register({
3673
+ kind: "exact",
3674
+ path: "/api/billing/relay-quotas",
3675
+ handler: async (_req, res) => {
3676
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
3677
+ if (Date.now() - relayCache.at >= SUBSCRIPTION_CACHE_MS) await refreshRelay();
3678
+ res.end(JSON.stringify({ quotas: relayCache.quotas }));
3679
+ }
3680
+ }), "usage-billing: relay-quotas route");
3228
3681
  ctx.effect(() => ctx.webServer.register({
3229
3682
  kind: "exact",
3230
3683
  path: "/api/billing/usage-stats",
@@ -3261,4 +3714,4 @@ function apply(ctx, config = {}) {
3261
3714
  }), "usage-billing: usage-stats route");
3262
3715
  }
3263
3716
  //#endregion
3264
- export { apply, inject, resolveSubscriptionKeys };
3717
+ export { apply, inject, readPiAiProviderRoutes, resolveSubscriptionKeys };