@kenz1117/dsh-ui-usage-billing 0.9.6 → 0.9.7
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 +2 -1
- package/lib/client.js +370 -295
- package/lib/index.js +562 -254
- package/lib/types/aggregate.d.ts +5 -0
- 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 +107 -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 +4 -1
- package/lib/types/pricing-shared.d.ts +4 -2
- 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
|
{
|
|
@@ -1287,6 +1289,7 @@ function percentile(values, p) {
|
|
|
1287
1289
|
function createUsageAggregator(persistence, options = {}) {
|
|
1288
1290
|
const subscriptionProviders = new Set(options.subscriptionProviders ?? DEFAULT_SUBSCRIPTION_PROVIDERS);
|
|
1289
1291
|
const officialProviderIds = options.officialProviderIds === void 0 ? void 0 : new Set(options.officialProviderIds);
|
|
1292
|
+
const maxCacheSessions = options.maxCacheSessions ?? 400;
|
|
1290
1293
|
const cache = /* @__PURE__ */ new Map();
|
|
1291
1294
|
let lastDoc;
|
|
1292
1295
|
let lastAt = 0;
|
|
@@ -1314,6 +1317,8 @@ function createUsageAggregator(persistence, options = {}) {
|
|
|
1314
1317
|
const stamp = await stampOf(meta);
|
|
1315
1318
|
const hit = cache.get(id);
|
|
1316
1319
|
if (hit !== void 0 && stamp !== null && hit.stamp === stamp) {
|
|
1320
|
+
cache.delete(id);
|
|
1321
|
+
cache.set(id, hit);
|
|
1317
1322
|
folds.push({
|
|
1318
1323
|
meta,
|
|
1319
1324
|
fold: hit.fold
|
|
@@ -1322,6 +1327,8 @@ function createUsageAggregator(persistence, options = {}) {
|
|
|
1322
1327
|
}
|
|
1323
1328
|
try {
|
|
1324
1329
|
const { events } = await persistence.readFrom(meta.id, 0);
|
|
1330
|
+
const after = await stampOf(meta);
|
|
1331
|
+
if (stamp !== null && after !== stamp) continue;
|
|
1325
1332
|
const fold = foldSession(events, subscriptionProviders, officialProviderIds);
|
|
1326
1333
|
cache.set(id, {
|
|
1327
1334
|
stamp,
|
|
@@ -1337,6 +1344,11 @@ function createUsageAggregator(persistence, options = {}) {
|
|
|
1337
1344
|
}
|
|
1338
1345
|
}
|
|
1339
1346
|
for (const key of [...cache.keys()]) if (!seen.has(key)) cache.delete(key);
|
|
1347
|
+
while (cache.size > maxCacheSessions) {
|
|
1348
|
+
const oldest = cache.keys().next();
|
|
1349
|
+
if (oldest.done === true) break;
|
|
1350
|
+
cache.delete(oldest.value);
|
|
1351
|
+
}
|
|
1340
1352
|
if (skipped.length > 0) console.warn(`[usage-billing] aggregated ${folds.length} sessions, skipped ${skipped.length} unreadable:`, skipped);
|
|
1341
1353
|
const total = emptyUsage();
|
|
1342
1354
|
const byModel = /* @__PURE__ */ new Map();
|
|
@@ -1475,17 +1487,89 @@ function createUsageAggregator(persistence, options = {}) {
|
|
|
1475
1487
|
} };
|
|
1476
1488
|
}
|
|
1477
1489
|
//#endregion
|
|
1490
|
+
//#region lib/types/resilience.js
|
|
1491
|
+
/**
|
|
1492
|
+
* 上游网络请求的稳定性工具:有限重试(指数退避)与每平台熔断冷却门。
|
|
1493
|
+
*
|
|
1494
|
+
* 计费插件对三类上游做实时请求——pricing(汇率 / OpenRouter / models.dev)、
|
|
1495
|
+
* balance(各厂商余额)、subscriptions(各订阅额度)。单次失败会被调用方降级,
|
|
1496
|
+
* 但反复的瞬时失败(网络波动 / 5xx / 429)会让 30 秒轮询每次打满超时。这里提供
|
|
1497
|
+
* 两个纯工具:`withRetry` 对可重试错误做指数退避,`createCooldownGate` 在单一
|
|
1498
|
+
* 上游连续失败后短路一段时间,避免把请求打到已不可用的服务上。
|
|
1499
|
+
*/
|
|
1500
|
+
/** 是否是可重试的错误:网络性失败(TypeError / Abort / Timeout)或 5xx / 429。
|
|
1501
|
+
* 401 / 403 鉴权失败与 404 不可重试——重试只放大错误、不会变好。 */
|
|
1502
|
+
function isRetryableError(error) {
|
|
1503
|
+
if (error instanceof Error) {
|
|
1504
|
+
if (error.name === "AbortError" || error.name === "TimeoutError") return true;
|
|
1505
|
+
if (error instanceof TypeError) return true;
|
|
1506
|
+
const status = error.httpStatus;
|
|
1507
|
+
if (typeof status === "number") return status === 429 || status >= 500;
|
|
1508
|
+
}
|
|
1509
|
+
return false;
|
|
1510
|
+
}
|
|
1511
|
+
/** 休眠指定毫秒(Promise 化 setTimeout)。 */
|
|
1512
|
+
function sleep(ms) {
|
|
1513
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1514
|
+
}
|
|
1515
|
+
/**
|
|
1516
|
+
* 对一次上游请求做有限重试:可重试错误时按指数退避(+同量级抖动)重试。
|
|
1517
|
+
* @param fn - 发起请求的异步函数。
|
|
1518
|
+
* @param options - 重试策略(见 {@link RetryOptions})。
|
|
1519
|
+
* @returns fn 的结果;重试耗尽后抛出最后一次错误。
|
|
1520
|
+
*/
|
|
1521
|
+
async function withRetry(fn, options = {}) {
|
|
1522
|
+
const { retries = 1, baseDelayMs = 250, maxDelayMs = 2e3, shouldRetry = isRetryableError } = options;
|
|
1523
|
+
let lastError;
|
|
1524
|
+
for (let attempt = 0; attempt <= retries; attempt += 1) try {
|
|
1525
|
+
return await fn();
|
|
1526
|
+
} catch (error) {
|
|
1527
|
+
lastError = error;
|
|
1528
|
+
if (attempt >= retries || !shouldRetry(error)) throw error;
|
|
1529
|
+
const delay = Math.min(maxDelayMs, baseDelayMs * 2 ** attempt);
|
|
1530
|
+
await sleep(delay / 2 + Math.random() * (delay / 2));
|
|
1531
|
+
}
|
|
1532
|
+
throw lastError;
|
|
1533
|
+
}
|
|
1534
|
+
/** 每平台冷熔断:连续失败达阈值后短路一段真实时间,成功后复位。 */
|
|
1535
|
+
function createCooldownGate(options = {}) {
|
|
1536
|
+
const { failures = 3, cooldownMs = 3e4 } = options;
|
|
1537
|
+
const state = /* @__PURE__ */ new Map();
|
|
1538
|
+
return {
|
|
1539
|
+
check(key) {
|
|
1540
|
+
const s = state.get(key);
|
|
1541
|
+
if (s === void 0) return true;
|
|
1542
|
+
if (s.until > 0 && Date.now() < s.until) return false;
|
|
1543
|
+
return true;
|
|
1544
|
+
},
|
|
1545
|
+
fail(key) {
|
|
1546
|
+
const s = state.get(key) ?? {
|
|
1547
|
+
count: 0,
|
|
1548
|
+
until: 0
|
|
1549
|
+
};
|
|
1550
|
+
s.count += 1;
|
|
1551
|
+
if (s.count >= failures) {
|
|
1552
|
+
s.until = Date.now() + cooldownMs;
|
|
1553
|
+
s.count = 0;
|
|
1554
|
+
}
|
|
1555
|
+
state.set(key, s);
|
|
1556
|
+
},
|
|
1557
|
+
success(key) {
|
|
1558
|
+
state.delete(key);
|
|
1559
|
+
}
|
|
1560
|
+
};
|
|
1561
|
+
}
|
|
1562
|
+
//#endregion
|
|
1478
1563
|
//#region lib/types/balance.js
|
|
1479
1564
|
/**
|
|
1480
1565
|
* Account-balance queries for the billing dashboard.
|
|
1481
1566
|
*
|
|
1482
1567
|
* Only providers with a public balance endpoint can report one. Today that is
|
|
1483
|
-
* DeepSeek
|
|
1484
|
-
*
|
|
1485
|
-
*
|
|
1486
|
-
*
|
|
1487
|
-
*
|
|
1488
|
-
* for future providers.
|
|
1568
|
+
* DeepSeek, Moonshot/Kimi, StepFun, SiliconFlow, and xAI (Grok) — all Bearer
|
|
1569
|
+
* 鉴权 with a documented JSON shape; the other mainstream providers expose no
|
|
1570
|
+
* standard balance API (or require a non-Bearer auth flow), so their rows in
|
|
1571
|
+
* the model table show an unavailable state. The lookup map below is the
|
|
1572
|
+
* extension point for future providers.
|
|
1489
1573
|
*
|
|
1490
1574
|
* API keys are read from the `llm-pi-ai` settings namespace (`providers.<id>.apiKeyEnv`),
|
|
1491
1575
|
* the same source the subscription adapter uses, so a deployment configures a
|
|
@@ -1493,6 +1577,20 @@ function createUsageAggregator(persistence, options = {}) {
|
|
|
1493
1577
|
*/
|
|
1494
1578
|
/** Abort a balance fetch when the upstream hangs beyond this budget. */
|
|
1495
1579
|
const FETCH_TIMEOUT_MS$1 = 8e3;
|
|
1580
|
+
/**
|
|
1581
|
+
* 每平台熔断门:单个 provider 连续可重试失败(网络波动 / 5xx / 429)达阈值后
|
|
1582
|
+
* 短路一段真实时间,避免 30 秒轮询在已不可用的上游上反复打满超时。
|
|
1583
|
+
* 鉴权失败(unauthorized)是配置问题而非暂时故障,不计入熔断。
|
|
1584
|
+
*/
|
|
1585
|
+
const balanceGate = createCooldownGate({
|
|
1586
|
+
failures: 3,
|
|
1587
|
+
cooldownMs: 6e4
|
|
1588
|
+
});
|
|
1589
|
+
/** 自定义 Provider 余额的熔断门:按端点 URL 独立熔断(各配置端点互不干扰)。 */
|
|
1590
|
+
const customGate = createCooldownGate({
|
|
1591
|
+
failures: 3,
|
|
1592
|
+
cooldownMs: 6e4
|
|
1593
|
+
});
|
|
1496
1594
|
/** DeepSeek 官方余额接口(官方文档 api-docs.deepseek.com/api/get-user-balance)。 */
|
|
1497
1595
|
const DEEPSEEK_BALANCE_URL = "https://api.deepseek.com/user/balance";
|
|
1498
1596
|
/** Moonshot/Kimi 官方余额接口(platform.kimi.com/docs/api/balance)。 */
|
|
@@ -1501,6 +1599,8 @@ const MOONSHOT_BALANCE_URL = "https://api.moonshot.cn/v1/users/me/balance";
|
|
|
1501
1599
|
const STEPFUN_BALANCE_URL = "https://api.stepfun.com/v1/accounts";
|
|
1502
1600
|
/** 硅基流动 SiliconFlow 官方用户信息接口(docs.siliconflow.cn/cn/api-reference/user/query-user-info)。 */
|
|
1503
1601
|
const SILICONFLOW_BALANCE_URL = "https://api.siliconflow.cn/v1/user/info";
|
|
1602
|
+
/** xAI 官方账单接口(docs.x.ai/developers/api/credits);total.val 为美分。 */
|
|
1603
|
+
const XAI_CREDITS_URL = "https://api.x.ai/v1/billing/credits";
|
|
1504
1604
|
/** 数字归一化:接口返回的余额是字符串(如 `"110.00"`),统一转 number。 */
|
|
1505
1605
|
function toNumber(value) {
|
|
1506
1606
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
@@ -1528,35 +1628,60 @@ async function queryBearerBalance(ctx, url, apiKeyEnv, provider, displayName, pa
|
|
|
1528
1628
|
displayName,
|
|
1529
1629
|
error: "unconfigured"
|
|
1530
1630
|
};
|
|
1531
|
-
|
|
1532
|
-
|
|
1631
|
+
if (!balanceGate.check(provider)) return {
|
|
1632
|
+
provider,
|
|
1633
|
+
displayName,
|
|
1634
|
+
error: "unreachable"
|
|
1635
|
+
};
|
|
1636
|
+
const doRequest = async () => {
|
|
1637
|
+
const controller = new AbortController();
|
|
1638
|
+
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS$1);
|
|
1639
|
+
try {
|
|
1640
|
+
const response = await fetch(url, {
|
|
1641
|
+
headers: {
|
|
1642
|
+
accept: "application/json",
|
|
1643
|
+
authorization: `Bearer ${hit.value}`
|
|
1644
|
+
},
|
|
1645
|
+
signal: controller.signal
|
|
1646
|
+
});
|
|
1647
|
+
if (response.status === 401 || response.status === 403) return {
|
|
1648
|
+
provider,
|
|
1649
|
+
displayName,
|
|
1650
|
+
error: "unauthorized"
|
|
1651
|
+
};
|
|
1652
|
+
if (!response.ok) {
|
|
1653
|
+
const error = /* @__PURE__ */ new Error(`HTTP ${String(response.status)}`);
|
|
1654
|
+
error.httpStatus = response.status;
|
|
1655
|
+
throw error;
|
|
1656
|
+
}
|
|
1657
|
+
const row = parse(await response.json());
|
|
1658
|
+
if (row.totalBalance === void 0 && row.grantedBalance === void 0 && row.toppedUpBalance === void 0 && row.isAvailable === void 0) {
|
|
1659
|
+
console.warn(`[usage-billing] balance response drifted for ${displayName}: no balance field parsed from ${url}`);
|
|
1660
|
+
return {
|
|
1661
|
+
...row,
|
|
1662
|
+
error: "invalid"
|
|
1663
|
+
};
|
|
1664
|
+
}
|
|
1665
|
+
return row;
|
|
1666
|
+
} finally {
|
|
1667
|
+
clearTimeout(timer);
|
|
1668
|
+
}
|
|
1669
|
+
};
|
|
1533
1670
|
try {
|
|
1534
|
-
const
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
},
|
|
1539
|
-
signal: controller.signal
|
|
1671
|
+
const row = await withRetry(doRequest, {
|
|
1672
|
+
retries: 1,
|
|
1673
|
+
baseDelayMs: 250,
|
|
1674
|
+
maxDelayMs: 2e3
|
|
1540
1675
|
});
|
|
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());
|
|
1676
|
+
balanceGate.success(provider);
|
|
1677
|
+
return row;
|
|
1552
1678
|
} catch {
|
|
1679
|
+
balanceGate.fail(provider);
|
|
1553
1680
|
return {
|
|
1554
1681
|
provider,
|
|
1555
1682
|
displayName,
|
|
1556
1683
|
error: "unreachable"
|
|
1557
1684
|
};
|
|
1558
|
-
} finally {
|
|
1559
|
-
clearTimeout(timer);
|
|
1560
1685
|
}
|
|
1561
1686
|
}
|
|
1562
1687
|
/**
|
|
@@ -1648,6 +1773,24 @@ function querySiliconFlow(ctx, apiKeyEnv) {
|
|
|
1648
1773
|
};
|
|
1649
1774
|
});
|
|
1650
1775
|
}
|
|
1776
|
+
/**
|
|
1777
|
+
* Query the xAI (Grok) credit balance.
|
|
1778
|
+
* @param ctx - host context carrying the credentials seam.
|
|
1779
|
+
* @param apiKeyEnv - credential reference resolving the xAI API key.
|
|
1780
|
+
* @returns the balance row, or an error row when the key/endpoint misbehaves.
|
|
1781
|
+
*/
|
|
1782
|
+
function queryXai(ctx, apiKeyEnv) {
|
|
1783
|
+
return queryBearerBalance(ctx, XAI_CREDITS_URL, apiKeyEnv, "xAI", "xAI", (data) => {
|
|
1784
|
+
const cents = toNumber(data.total?.val);
|
|
1785
|
+
const totalBalance = cents === void 0 ? void 0 : Math.abs(cents) / 100;
|
|
1786
|
+
return {
|
|
1787
|
+
provider: "xAI",
|
|
1788
|
+
displayName: "xAI",
|
|
1789
|
+
currency: "USD",
|
|
1790
|
+
...totalBalance !== void 0 ? { totalBalance } : {}
|
|
1791
|
+
};
|
|
1792
|
+
});
|
|
1793
|
+
}
|
|
1651
1794
|
const QUERIERS = [
|
|
1652
1795
|
{
|
|
1653
1796
|
route: "deepseek",
|
|
@@ -1668,6 +1811,11 @@ const QUERIERS = [
|
|
|
1668
1811
|
route: "siliconflow",
|
|
1669
1812
|
displayName: "硅基流动",
|
|
1670
1813
|
querier: querySiliconFlow
|
|
1814
|
+
},
|
|
1815
|
+
{
|
|
1816
|
+
route: "xai",
|
|
1817
|
+
displayName: "xAI",
|
|
1818
|
+
querier: queryXai
|
|
1671
1819
|
}
|
|
1672
1820
|
];
|
|
1673
1821
|
/**
|
|
@@ -1764,47 +1912,64 @@ async function queryCustomBalances(ctx, configs) {
|
|
|
1764
1912
|
displayName,
|
|
1765
1913
|
error: "unconfigured"
|
|
1766
1914
|
};
|
|
1767
|
-
|
|
1768
|
-
|
|
1915
|
+
if (!customGate.check(config.url)) return {
|
|
1916
|
+
provider,
|
|
1917
|
+
displayName,
|
|
1918
|
+
error: "unreachable"
|
|
1919
|
+
};
|
|
1920
|
+
const doRequest = async () => {
|
|
1921
|
+
const controller = new AbortController();
|
|
1922
|
+
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS$1);
|
|
1923
|
+
try {
|
|
1924
|
+
const response = await fetch(config.url, {
|
|
1925
|
+
method: config.method ?? "GET",
|
|
1926
|
+
headers: {
|
|
1927
|
+
accept: "application/json",
|
|
1928
|
+
...headers
|
|
1929
|
+
},
|
|
1930
|
+
signal: controller.signal
|
|
1931
|
+
});
|
|
1932
|
+
if (response.status === 401 || response.status === 403) return {
|
|
1933
|
+
provider,
|
|
1934
|
+
displayName,
|
|
1935
|
+
error: "unauthorized"
|
|
1936
|
+
};
|
|
1937
|
+
if (!response.ok) {
|
|
1938
|
+
const error = /* @__PURE__ */ new Error(`HTTP ${String(response.status)}`);
|
|
1939
|
+
error.httpStatus = response.status;
|
|
1940
|
+
throw error;
|
|
1941
|
+
}
|
|
1942
|
+
const remaining = evalExtract(config.extract.remaining, await response.json());
|
|
1943
|
+
if (remaining === void 0) return {
|
|
1944
|
+
provider,
|
|
1945
|
+
displayName,
|
|
1946
|
+
error: "invalid"
|
|
1947
|
+
};
|
|
1948
|
+
return {
|
|
1949
|
+
provider,
|
|
1950
|
+
displayName,
|
|
1951
|
+
currency: config.unit ?? "CNY",
|
|
1952
|
+
totalBalance: remaining
|
|
1953
|
+
};
|
|
1954
|
+
} finally {
|
|
1955
|
+
clearTimeout(timer);
|
|
1956
|
+
}
|
|
1957
|
+
};
|
|
1769
1958
|
try {
|
|
1770
|
-
const
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
...headers
|
|
1775
|
-
},
|
|
1776
|
-
signal: controller.signal
|
|
1959
|
+
const row = await withRetry(doRequest, {
|
|
1960
|
+
retries: 1,
|
|
1961
|
+
baseDelayMs: 250,
|
|
1962
|
+
maxDelayMs: 2e3
|
|
1777
1963
|
});
|
|
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
|
-
};
|
|
1964
|
+
customGate.success(config.url);
|
|
1965
|
+
return row;
|
|
1800
1966
|
} catch {
|
|
1967
|
+
customGate.fail(config.url);
|
|
1801
1968
|
return {
|
|
1802
1969
|
provider,
|
|
1803
1970
|
displayName,
|
|
1804
1971
|
error: "unreachable"
|
|
1805
1972
|
};
|
|
1806
|
-
} finally {
|
|
1807
|
-
clearTimeout(timer);
|
|
1808
1973
|
}
|
|
1809
1974
|
}));
|
|
1810
1975
|
}
|
|
@@ -1821,6 +1986,12 @@ async function queryCustomBalances(ctx, configs) {
|
|
|
1821
1986
|
*/
|
|
1822
1987
|
/** Abort a fetch when the upstream hangs beyond this budget. */
|
|
1823
1988
|
const FETCH_TIMEOUT_MS = 8e3;
|
|
1989
|
+
/** 每平台熔断门:单个定价上游连续可重试失败(网络 / 5xx / 429)达阈值后短路,
|
|
1990
|
+
* 避免 6 小时刷新循环与每次启动在已故障的上游上反复打满超时。按 URL 独立。 */
|
|
1991
|
+
const pricingGate = createCooldownGate({
|
|
1992
|
+
failures: 3,
|
|
1993
|
+
cooldownMs: 6e4
|
|
1994
|
+
});
|
|
1824
1995
|
/**
|
|
1825
1996
|
* USD → CNY 汇率源,按顺序尝试:国内可达的腾讯财经行情(免 key、`~` 分隔
|
|
1826
1997
|
* 第 4 个字段为价格)优先,国外 open.er-api.com 兜底。任一源失败自动落到
|
|
@@ -1903,18 +2074,35 @@ const ROUTER_ID_HINTS = {
|
|
|
1903
2074
|
"llama": ["llama-4"],
|
|
1904
2075
|
"kimi": ["kimi-k2"]
|
|
1905
2076
|
};
|
|
1906
|
-
/** GET a URL's text body with a hard timeout; null on any failure. */
|
|
2077
|
+
/** GET a URL's text body with a hard timeout and retry; null on any failure. */
|
|
1907
2078
|
async function fetchText(url) {
|
|
1908
|
-
|
|
1909
|
-
const
|
|
2079
|
+
if (!pricingGate.check(url)) return null;
|
|
2080
|
+
const doFetch = async () => {
|
|
2081
|
+
const controller = new AbortController();
|
|
2082
|
+
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
2083
|
+
try {
|
|
2084
|
+
const response = await fetch(url, { signal: controller.signal });
|
|
2085
|
+
if (!response.ok) {
|
|
2086
|
+
const error = /* @__PURE__ */ new Error(`HTTP ${String(response.status)}`);
|
|
2087
|
+
error.httpStatus = response.status;
|
|
2088
|
+
throw error;
|
|
2089
|
+
}
|
|
2090
|
+
return await response.text();
|
|
2091
|
+
} finally {
|
|
2092
|
+
clearTimeout(timer);
|
|
2093
|
+
}
|
|
2094
|
+
};
|
|
1910
2095
|
try {
|
|
1911
|
-
const
|
|
1912
|
-
|
|
1913
|
-
|
|
2096
|
+
const text = await withRetry(doFetch, {
|
|
2097
|
+
retries: 1,
|
|
2098
|
+
baseDelayMs: 250,
|
|
2099
|
+
maxDelayMs: 2e3
|
|
2100
|
+
});
|
|
2101
|
+
pricingGate.success(url);
|
|
2102
|
+
return text;
|
|
1914
2103
|
} catch {
|
|
2104
|
+
pricingGate.fail(url);
|
|
1915
2105
|
return null;
|
|
1916
|
-
} finally {
|
|
1917
|
-
clearTimeout(timer);
|
|
1918
2106
|
}
|
|
1919
2107
|
}
|
|
1920
2108
|
/** GET a JSON endpoint with a hard timeout; null on any failure. */
|
|
@@ -1941,7 +2129,10 @@ async function fetchRouterModels() {
|
|
|
1941
2129
|
const data = await fetchJson(ROUTER_URL);
|
|
1942
2130
|
if (data === null || typeof data !== "object") return void 0;
|
|
1943
2131
|
const list = data.data;
|
|
1944
|
-
if (!Array.isArray(list))
|
|
2132
|
+
if (!Array.isArray(list)) {
|
|
2133
|
+
console.warn("[usage-billing] openrouter models response drifted: expected a `data` array");
|
|
2134
|
+
return;
|
|
2135
|
+
}
|
|
1945
2136
|
const models = [];
|
|
1946
2137
|
for (const item of list) {
|
|
1947
2138
|
if (item === null || typeof item !== "object") continue;
|
|
@@ -2061,6 +2252,13 @@ async function fetchLivePricing() {
|
|
|
2061
2252
|
* "no quota API" marker rather than hidden. API keys come from the `llm-pi-ai`
|
|
2062
2253
|
* settings namespace (`apiKeyEnv` refs) resolved through the credentials seam.
|
|
2063
2254
|
*/
|
|
2255
|
+
/** 每平台熔断门:某个订阅适配器连续可重试失败(网络 / 5xx / 429)达阈值后
|
|
2256
|
+
* 短路由不可用,避免 30 秒轮询对已故障的上游反复打满超时。鉴权失败(unauthorized)
|
|
2257
|
+
* 是配置问题,不计入熔断。 */
|
|
2258
|
+
const subscriptionGate = createCooldownGate({
|
|
2259
|
+
failures: 3,
|
|
2260
|
+
cooldownMs: 6e4
|
|
2261
|
+
});
|
|
2064
2262
|
/** 空凭据:全部未配置时的初始值。 */
|
|
2065
2263
|
const EMPTY_SUBSCRIPTION_KEYS = {
|
|
2066
2264
|
kimiApiKey: "",
|
|
@@ -2169,18 +2367,26 @@ function statusOf(error) {
|
|
|
2169
2367
|
}
|
|
2170
2368
|
return "unavailable";
|
|
2171
2369
|
}
|
|
2172
|
-
/** One JSON fetch with a timeout, mapping HTTP failures to typed errors.
|
|
2370
|
+
/** One JSON fetch with a timeout, mapping HTTP failures to typed errors.
|
|
2371
|
+
* 可重试错误(网络 / 5xx / 429)用指数退避重试一次;401/403/404 不重试。 */
|
|
2173
2372
|
async function requestJson(url, init, timeoutMs) {
|
|
2174
|
-
const
|
|
2175
|
-
|
|
2176
|
-
|
|
2373
|
+
const doFetch = async () => {
|
|
2374
|
+
const response = await fetch(url, {
|
|
2375
|
+
...init,
|
|
2376
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
2377
|
+
});
|
|
2378
|
+
if (!response.ok) {
|
|
2379
|
+
const error = /* @__PURE__ */ new Error(`HTTP ${String(response.status)}`);
|
|
2380
|
+
error.httpStatus = response.status;
|
|
2381
|
+
throw error;
|
|
2382
|
+
}
|
|
2383
|
+
return await response.json();
|
|
2384
|
+
};
|
|
2385
|
+
return await withRetry(doFetch, {
|
|
2386
|
+
retries: 1,
|
|
2387
|
+
baseDelayMs: 250,
|
|
2388
|
+
maxDelayMs: 2e3
|
|
2177
2389
|
});
|
|
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
2390
|
}
|
|
2185
2391
|
/** Parse one Kimi limit window entry. */
|
|
2186
2392
|
function kimiWindow(value, kind) {
|
|
@@ -2562,18 +2768,41 @@ async function collectOpenRouter(keys, config, timeoutMs) {
|
|
|
2562
2768
|
* @returns the quotas in plan order (unknown providers degrade to `unavailable`).
|
|
2563
2769
|
*/
|
|
2564
2770
|
async function collectSubscriptions(keys, plans = [], timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
2565
|
-
return await Promise.all(plans.map((plan) => {
|
|
2771
|
+
return await Promise.all(plans.map(async (plan) => {
|
|
2566
2772
|
const adapter = SUBSCRIPTION_ADAPTERS[plan.provider];
|
|
2567
|
-
if (adapter === void 0) return
|
|
2773
|
+
if (adapter === void 0) return {
|
|
2568
2774
|
provider: plan.provider,
|
|
2569
2775
|
displayName: plan.provider,
|
|
2570
2776
|
status: "unavailable",
|
|
2571
2777
|
windows: []
|
|
2572
|
-
}
|
|
2573
|
-
|
|
2778
|
+
};
|
|
2779
|
+
if (!subscriptionGate.check(plan.provider)) return {
|
|
2780
|
+
provider: plan.provider,
|
|
2781
|
+
displayName: plan.provider,
|
|
2782
|
+
status: "unavailable",
|
|
2783
|
+
windows: []
|
|
2784
|
+
};
|
|
2785
|
+
const quota = await adapter.collect(keys, plan, timeoutMs);
|
|
2786
|
+
if (quota.status === "unavailable" || quota.status === "rate-limited") subscriptionGate.fail(plan.provider);
|
|
2787
|
+
else subscriptionGate.success(plan.provider);
|
|
2788
|
+
return quota;
|
|
2574
2789
|
}));
|
|
2575
2790
|
}
|
|
2576
2791
|
//#endregion
|
|
2792
|
+
//#region lib/types/client/usage-billing-settings.js
|
|
2793
|
+
/**
|
|
2794
|
+
* usage-stats 工具开关的共享设置契约(node 与 client 两端共用)。
|
|
2795
|
+
*
|
|
2796
|
+
* 宿主把已注册的设置命名空间服务给浏览器;node 半区在 apply 阶段读一次该值决定
|
|
2797
|
+
* 是否向模型注入 `usage_stats` 工具(工具注入是启动期决策,改开关后重载应用生效),
|
|
2798
|
+
* client 半区在「设置」Tab 渲染开关并写入同一命名空间。缺省的默认行为是关闭——
|
|
2799
|
+
* 避免该工具默认占用模型每次请求的上下文(coding 场景通常在仪表盘看用量)。
|
|
2800
|
+
*/
|
|
2801
|
+
/** 设置命名空间 id(小写 kebab-case)。 */
|
|
2802
|
+
const BILLING_SETTINGS_NAMESPACE = "ui-usage-billing";
|
|
2803
|
+
/** 该命名空间下用户可编辑的字段名。 */
|
|
2804
|
+
const ENABLE_USAGE_STATS_TOOL_FIELD = "enableUsageStatsTool";
|
|
2805
|
+
//#endregion
|
|
2577
2806
|
//#region lib/types/index.js
|
|
2578
2807
|
/**
|
|
2579
2808
|
* Usage billing surface plugin, node half.
|
|
@@ -2586,6 +2815,10 @@ async function collectSubscriptions(keys, plans = [], timeoutMs = DEFAULT_TIMEOU
|
|
|
2586
2815
|
* missing file answers `{ error }` so the dashboard shows zeros, never
|
|
2587
2816
|
* fabricated samples.
|
|
2588
2817
|
*/
|
|
2818
|
+
/** usage_stats 工具开关的设置命名空间 id(下端与 node 共用同一常量)。 */
|
|
2819
|
+
const usageBillingSettingsNs = settingsNamespace(BILLING_SETTINGS_NAMESPACE);
|
|
2820
|
+
/** 该命名空间的 wire schema:`enableUsageStatsTool` 布尔,默认关闭(issue 诉求)。 */
|
|
2821
|
+
const UsageBillingSettingsSchema = z.object({ [ENABLE_USAGE_STATS_TOOL_FIELD]: z.boolean().default(false) });
|
|
2589
2822
|
/** 实时定价的后台刷新间隔(毫秒):汇率/模型价低频变化,6 小时一次足够。 */
|
|
2590
2823
|
const PRICING_REFRESH_INTERVAL_MS = 360 * 60 * 1e3;
|
|
2591
2824
|
/** 订阅套餐额度缓存时长(毫秒):上游配额 API 低频变化,5 分钟足够。 */
|
|
@@ -2599,6 +2832,21 @@ const DEFAULT_BALANCE_API_KEY_ENV = "DEEPSEEK_API_KEY";
|
|
|
2599
2832
|
const PACKAGE_VERSION = createRequire(import.meta.url)("../package.json").version ?? "0.0.0";
|
|
2600
2833
|
/** 统计快照的落盘节流(毫秒):前端 30 秒轮询,快照最多每 30 秒写一次。 */
|
|
2601
2834
|
const SNAPSHOT_INTERVAL_MS = 3e4;
|
|
2835
|
+
/** 鉴权失败告警冷却(毫秒):同一 provider 在窗口内只提示一次,避免 30 秒轮询刷屏。 */
|
|
2836
|
+
const AUTH_WARN_COOLDOWN_MS = 1800 * 1e3;
|
|
2837
|
+
/**
|
|
2838
|
+
* 鉴权失败分类告警(P1-5):余额 / 订阅查询返回 unauthorized 时,按
|
|
2839
|
+
* `source:provider` 去重并冷却告警,提示检查 llm-pi-ai 里该 provider 的 apiKeyEnv。
|
|
2840
|
+
*/
|
|
2841
|
+
const authWarnedAt = /* @__PURE__ */ new Map();
|
|
2842
|
+
function warnAuthOnce(source, provider, displayName) {
|
|
2843
|
+
const key = `${source}:${provider}`;
|
|
2844
|
+
const now = Date.now();
|
|
2845
|
+
const last = authWarnedAt.get(key);
|
|
2846
|
+
if (last !== void 0 && now - last < AUTH_WARN_COOLDOWN_MS) return;
|
|
2847
|
+
authWarnedAt.set(key, now);
|
|
2848
|
+
console.warn(`[usage-billing] ${displayName}(${provider})鉴权失败:请检查 llm-pi-ai 设置中该 provider 的 apiKeyEnv 凭据是否正确/有效。`);
|
|
2849
|
+
}
|
|
2602
2850
|
/** Required services: the web server, the persisted session log store, and user settings. */
|
|
2603
2851
|
const inject = [
|
|
2604
2852
|
"webServer",
|
|
@@ -2692,6 +2940,7 @@ async function resolveSubscriptionKeys(settings, credentials) {
|
|
|
2692
2940
|
* @param config - optional statsPath override.
|
|
2693
2941
|
*/
|
|
2694
2942
|
function apply(ctx, config = {}) {
|
|
2943
|
+
let usageSettingsScope;
|
|
2695
2944
|
const aggregator = createUsageAggregator(ctx.sessionPersistence, { ...config.subscriptionProviders === void 0 ? {} : { subscriptionProviders: config.subscriptionProviders } });
|
|
2696
2945
|
const cwd = process.cwd();
|
|
2697
2946
|
const snapshotPath = join(homedir(), ".dsh/.dsh-usage-stats.json");
|
|
@@ -2706,16 +2955,35 @@ function apply(ctx, config = {}) {
|
|
|
2706
2955
|
const now = Date.now();
|
|
2707
2956
|
if (now - lastSnapshotAt < SNAPSHOT_INTERVAL_MS) return;
|
|
2708
2957
|
lastSnapshotAt = now;
|
|
2709
|
-
|
|
2958
|
+
const payload = JSON.stringify({
|
|
2710
2959
|
...doc,
|
|
2711
2960
|
_writer: {
|
|
2712
2961
|
pid: process.pid,
|
|
2713
2962
|
at: now
|
|
2714
2963
|
}
|
|
2715
|
-
})
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2964
|
+
});
|
|
2965
|
+
(async () => {
|
|
2966
|
+
try {
|
|
2967
|
+
const existing = await readFile(snapshotPath, "utf8");
|
|
2968
|
+
await writeFileAtomic(`${snapshotPath}.bak`, existing, {
|
|
2969
|
+
mode: 384,
|
|
2970
|
+
dirMode: 448
|
|
2971
|
+
});
|
|
2972
|
+
} catch {}
|
|
2973
|
+
try {
|
|
2974
|
+
await writeFileAtomic(snapshotPath, payload, {
|
|
2975
|
+
mode: 384,
|
|
2976
|
+
dirMode: 448
|
|
2977
|
+
});
|
|
2978
|
+
} catch {}
|
|
2979
|
+
})();
|
|
2980
|
+
};
|
|
2981
|
+
/** 读一个快照候选并解析成对象:主文件优先;主文件损坏时回退上一版 `.bak`(P0-3 崩溃恢复)。 */
|
|
2982
|
+
const readSnapshot = async (candidate) => {
|
|
2983
|
+
for (const path of [candidate, `${candidate}.bak`]) try {
|
|
2984
|
+
return JSON.parse(await readFile(path, "utf8"));
|
|
2985
|
+
} catch {}
|
|
2986
|
+
return null;
|
|
2719
2987
|
};
|
|
2720
2988
|
(async () => {
|
|
2721
2989
|
try {
|
|
@@ -2724,94 +2992,118 @@ function apply(ctx, config = {}) {
|
|
|
2724
2992
|
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
2993
|
} catch {}
|
|
2726
2994
|
})();
|
|
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
|
-
|
|
2995
|
+
ctx.inject(["settings"], (sctx) => {
|
|
2996
|
+
const scope = sctx.settings.register(usageBillingSettingsNs, UsageBillingSettingsSchema, { base: { enableUsageStatsTool: config.enableUsageStatsTool ?? false } });
|
|
2997
|
+
usageSettingsScope = scope;
|
|
2998
|
+
ctx.inject(["tools"], (toolsCtx) => {
|
|
2999
|
+
if (scope.get().enableUsageStatsTool) toolsCtx.tools.register(defineTool({
|
|
3000
|
+
name: "usage_stats",
|
|
3001
|
+
description: "查询本机 DeepSeek Harness 的模型用量与估算费用(人民币,按官方目录价估算,非账单)。range 取值:today=今天,month=本月,session=当前会话,all=累计。",
|
|
3002
|
+
parameters: { range: {
|
|
3003
|
+
type: "string",
|
|
3004
|
+
enum: [
|
|
3005
|
+
"today",
|
|
3006
|
+
"month",
|
|
3007
|
+
"session",
|
|
3008
|
+
"all"
|
|
3009
|
+
],
|
|
3010
|
+
required: true,
|
|
3011
|
+
description: "统计范围:today / month / session / all"
|
|
3012
|
+
} },
|
|
3013
|
+
output: {
|
|
3014
|
+
schema: {
|
|
3015
|
+
type: "object",
|
|
3016
|
+
additionalProperties: false,
|
|
3017
|
+
properties: {
|
|
3018
|
+
range: {
|
|
3019
|
+
type: "string",
|
|
3020
|
+
required: true
|
|
3021
|
+
},
|
|
3022
|
+
cost: {
|
|
3023
|
+
type: "number",
|
|
3024
|
+
required: true,
|
|
3025
|
+
description: "估算费用(人民币元)"
|
|
3026
|
+
},
|
|
3027
|
+
calls: {
|
|
3028
|
+
type: "number",
|
|
3029
|
+
required: true
|
|
3030
|
+
},
|
|
3031
|
+
input: {
|
|
3032
|
+
type: "number",
|
|
3033
|
+
required: true,
|
|
3034
|
+
description: "输入 tokens"
|
|
3035
|
+
},
|
|
3036
|
+
output: {
|
|
3037
|
+
type: "number",
|
|
3038
|
+
required: true,
|
|
3039
|
+
description: "输出 tokens"
|
|
3040
|
+
}
|
|
2769
3041
|
}
|
|
2770
|
-
}
|
|
3042
|
+
},
|
|
3043
|
+
render: (_args, value) => [{
|
|
3044
|
+
type: "text",
|
|
3045
|
+
text: `用量(${value.range}):估算费用 ${formatMoney(value.cost)},调用 ${value.calls} 次,输入 ${formatTokens(value.input)} tokens,输出 ${formatTokens(value.output)} tokens`
|
|
3046
|
+
}]
|
|
2771
3047
|
},
|
|
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 : {
|
|
3048
|
+
async execute(args, exec) {
|
|
3049
|
+
const stats = await aggregator.aggregate();
|
|
3050
|
+
const zero = {
|
|
2796
3051
|
range: args.range,
|
|
2797
|
-
cost:
|
|
2798
|
-
calls:
|
|
2799
|
-
input:
|
|
2800
|
-
output:
|
|
3052
|
+
cost: 0,
|
|
3053
|
+
calls: 0,
|
|
3054
|
+
input: 0,
|
|
3055
|
+
output: 0
|
|
2801
3056
|
};
|
|
2802
|
-
|
|
2803
|
-
|
|
2804
|
-
|
|
3057
|
+
if (args.range === "all") return {
|
|
3058
|
+
range: args.range,
|
|
3059
|
+
cost: stats.total.cost,
|
|
3060
|
+
calls: stats.total.calls,
|
|
3061
|
+
input: stats.total.input,
|
|
3062
|
+
output: stats.total.output
|
|
3063
|
+
};
|
|
3064
|
+
if (args.range === "today") {
|
|
3065
|
+
const day = stats.byDay[dayStamp(Date.now())];
|
|
3066
|
+
return day === void 0 ? zero : {
|
|
3067
|
+
range: args.range,
|
|
3068
|
+
cost: day.cost,
|
|
3069
|
+
calls: day.calls,
|
|
3070
|
+
input: day.input,
|
|
3071
|
+
output: day.output
|
|
3072
|
+
};
|
|
3073
|
+
}
|
|
3074
|
+
if (args.range === "month") {
|
|
3075
|
+
const prefix = dayStamp(Date.now()).slice(0, 7);
|
|
3076
|
+
let cost = 0;
|
|
3077
|
+
let calls = 0;
|
|
3078
|
+
let input = 0;
|
|
3079
|
+
let output = 0;
|
|
3080
|
+
for (const [date, day] of Object.entries(stats.byDay)) {
|
|
3081
|
+
if (!date.startsWith(prefix)) continue;
|
|
3082
|
+
cost += day.cost;
|
|
3083
|
+
calls += day.calls;
|
|
3084
|
+
input += day.input;
|
|
3085
|
+
output += day.output;
|
|
3086
|
+
}
|
|
3087
|
+
return {
|
|
3088
|
+
range: args.range,
|
|
3089
|
+
cost,
|
|
3090
|
+
calls,
|
|
3091
|
+
input,
|
|
3092
|
+
output
|
|
3093
|
+
};
|
|
3094
|
+
}
|
|
3095
|
+
const sessionId = exec.agent?.id;
|
|
3096
|
+
if (sessionId === void 0) throw new Error("usage_stats 的 session 范围需要 agent 会话上下文");
|
|
2805
3097
|
let cost = 0;
|
|
2806
3098
|
let calls = 0;
|
|
2807
3099
|
let input = 0;
|
|
2808
3100
|
let output = 0;
|
|
2809
|
-
for (const
|
|
2810
|
-
if (
|
|
2811
|
-
cost +=
|
|
2812
|
-
calls +=
|
|
2813
|
-
input +=
|
|
2814
|
-
output +=
|
|
3101
|
+
for (const turn of stats.byTurn ?? []) {
|
|
3102
|
+
if (turn.sessionId !== String(sessionId)) continue;
|
|
3103
|
+
cost += turn.cost;
|
|
3104
|
+
calls += 1;
|
|
3105
|
+
input += turn.input;
|
|
3106
|
+
output += turn.output;
|
|
2815
3107
|
}
|
|
2816
3108
|
return {
|
|
2817
3109
|
range: args.range,
|
|
@@ -2821,28 +3113,8 @@ function apply(ctx, config = {}) {
|
|
|
2821
3113
|
output
|
|
2822
3114
|
};
|
|
2823
3115
|
}
|
|
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
|
-
}));
|
|
3116
|
+
}));
|
|
3117
|
+
});
|
|
2846
3118
|
});
|
|
2847
3119
|
let live = { source: "builtin" };
|
|
2848
3120
|
const refreshPricing = async () => {
|
|
@@ -2875,9 +3147,44 @@ function apply(ctx, config = {}) {
|
|
|
2875
3147
|
if (providers["deepseek"] === void 0) providers["deepseek"] = { apiKeyEnv: config.balanceApiKeyEnv ?? DEFAULT_BALANCE_API_KEY_ENV };
|
|
2876
3148
|
const balances = await queryBalances(ctx, providers);
|
|
2877
3149
|
const custom = await queryCustomBalances(ctx, config.customBalances ?? []);
|
|
3150
|
+
for (const row of [...balances, ...custom]) if (row.error === "unauthorized") warnAuthOnce("balance", row.provider, row.displayName);
|
|
2878
3151
|
res.end(JSON.stringify({ balances: [...balances, ...custom] }));
|
|
2879
3152
|
}
|
|
2880
3153
|
}), "usage-billing: balance route");
|
|
3154
|
+
ctx.effect(() => ctx.webServer.register({
|
|
3155
|
+
kind: "exact",
|
|
3156
|
+
path: "/api/billing/usage-tool",
|
|
3157
|
+
handler: async (req, res) => {
|
|
3158
|
+
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
3159
|
+
const enabled = usageSettingsScope?.get().enableUsageStatsTool ?? false;
|
|
3160
|
+
if (req.method === "GET") {
|
|
3161
|
+
res.end(JSON.stringify({ enabled }));
|
|
3162
|
+
return;
|
|
3163
|
+
}
|
|
3164
|
+
if (req.method !== "POST") {
|
|
3165
|
+
res.writeHead(405, { "content-type": "application/json; charset=utf-8" });
|
|
3166
|
+
res.end(JSON.stringify({ error: "method not allowed" }));
|
|
3167
|
+
return;
|
|
3168
|
+
}
|
|
3169
|
+
try {
|
|
3170
|
+
let body = "";
|
|
3171
|
+
for await (const chunk of req) body += String(chunk);
|
|
3172
|
+
const parsed = JSON.parse(body === "" ? "{}" : body);
|
|
3173
|
+
if (usageSettingsScope === void 0) {
|
|
3174
|
+
res.end(JSON.stringify({ error: "settings unavailable" }));
|
|
3175
|
+
return;
|
|
3176
|
+
}
|
|
3177
|
+
const next = parsed.enabled === true;
|
|
3178
|
+
await usageSettingsScope.update({ enableUsageStatsTool: next });
|
|
3179
|
+
res.end(JSON.stringify({
|
|
3180
|
+
ok: true,
|
|
3181
|
+
enabled: next
|
|
3182
|
+
}));
|
|
3183
|
+
} catch {
|
|
3184
|
+
res.end(JSON.stringify({ error: "invalid" }));
|
|
3185
|
+
}
|
|
3186
|
+
}
|
|
3187
|
+
}), "usage-billing: usage-tool route");
|
|
2881
3188
|
let quotaCache = {
|
|
2882
3189
|
at: 0,
|
|
2883
3190
|
quotas: []
|
|
@@ -2889,7 +3196,7 @@ function apply(ctx, config = {}) {
|
|
|
2889
3196
|
...item.region === void 0 ? {} : { region: item.region }
|
|
2890
3197
|
})))].map((row) => {
|
|
2891
3198
|
const planType = planTypeOf(row.provider);
|
|
2892
|
-
const subscriptionAmount =
|
|
3199
|
+
const subscriptionAmount = subscriptionFeeCnyOf(row.provider, live.rate);
|
|
2893
3200
|
return {
|
|
2894
3201
|
...row,
|
|
2895
3202
|
planType,
|
|
@@ -2903,6 +3210,7 @@ function apply(ctx, config = {}) {
|
|
|
2903
3210
|
windows: [],
|
|
2904
3211
|
planType: planTypeOf(item.provider)
|
|
2905
3212
|
});
|
|
3213
|
+
for (const row of rows) if (row.status === "unauthorized") warnAuthOnce("subscription", row.provider, row.displayName);
|
|
2906
3214
|
quotaCache = {
|
|
2907
3215
|
at: Date.now(),
|
|
2908
3216
|
quotas: rows
|
|
@@ -2939,15 +3247,15 @@ function apply(ctx, config = {}) {
|
|
|
2939
3247
|
} catch (error) {
|
|
2940
3248
|
console.error("[usage-billing] usage-stats aggregate failed, falling back to snapshot:", error);
|
|
2941
3249
|
}
|
|
2942
|
-
for (const candidate of candidates)
|
|
2943
|
-
const
|
|
2944
|
-
|
|
3250
|
+
for (const candidate of candidates) {
|
|
3251
|
+
const doc = await readSnapshot(candidate);
|
|
3252
|
+
if (doc === null) continue;
|
|
2945
3253
|
if (config.monthlyBudget !== void 0) doc["budget"] = config.monthlyBudget;
|
|
2946
3254
|
if (config.lowBalanceThreshold !== void 0) doc["lowBalanceThreshold"] = config.lowBalanceThreshold;
|
|
2947
3255
|
doc["pluginVersion"] = PACKAGE_VERSION;
|
|
2948
3256
|
res.end(JSON.stringify(doc));
|
|
2949
3257
|
return;
|
|
2950
|
-
}
|
|
3258
|
+
}
|
|
2951
3259
|
res.end(JSON.stringify({ error: "usage stats unavailable" }));
|
|
2952
3260
|
}
|
|
2953
3261
|
}), "usage-billing: usage-stats route");
|