@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/README.md +6 -5
- package/lib/client.js +808 -327
- package/lib/index.js +487 -53
- package/lib/types/aggregate.d.ts +43 -1
- package/lib/types/client/UsageBilling.d.ts +10 -0
- package/lib/types/client/locales.d.ts +1 -1
- package/lib/types/index.d.ts +24 -1
- package/lib/types/pricing-shared.d.ts +21 -0
- package/lib/types/relay.d.ts +61 -0
- package/package.json +1 -1
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$
|
|
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$
|
|
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$
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
|
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 };
|