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