@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/README.en.md +6 -4
- package/README.md +7 -6
- package/lib/client.js +810 -325
- package/lib/index.js +513 -60
- 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
|
@@ -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$
|
|
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$
|
|
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$
|
|
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
|
-
/**
|
|
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
|
|
2783
|
+
const base = resolveMiniMaxBaseUrl(config);
|
|
2784
|
+
const displayName = minmaxDisplayName(config.provider);
|
|
2684
2785
|
if (apiKey === "") return {
|
|
2685
2786
|
provider: config.provider,
|
|
2686
|
-
displayName
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
|
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 };
|