@kenz1117/dsh-ui-usage-billing 1.5.0 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/lib/client.js +5 -5
- package/lib/index.js +830 -460
- package/lib/types/aggregate.d.ts +31 -0
- package/lib/types/client/TokenPanel.d.ts +1 -1
- package/lib/types/client/UsageBilling.d.ts +7 -336
- package/lib/types/client/api/balance.d.ts +24 -0
- package/lib/types/client/api/pricing.d.ts +26 -0
- package/lib/types/client/api/relay.d.ts +13 -0
- package/lib/types/client/api/subscriptions.d.ts +13 -0
- package/lib/types/client/api/usage-stats.d.ts +11 -0
- package/lib/types/client/api/usage-tool.d.ts +16 -0
- package/lib/types/client/budget-store.d.ts +6 -0
- package/lib/types/client/constants.d.ts +28 -0
- package/lib/types/client/domain/forecast.d.ts +24 -0
- package/lib/types/client/domain/host-version.d.ts +18 -0
- package/lib/types/client/domain/icons.d.ts +9 -0
- package/lib/types/client/domain/kpi.d.ts +34 -0
- package/lib/types/client/domain/model-budget.d.ts +13 -0
- package/lib/types/client/domain/notify.d.ts +14 -0
- package/lib/types/client/domain/provider-aliases.d.ts +28 -0
- package/lib/types/client/domain/provider-merge.d.ts +26 -0
- package/lib/types/client/domain/recharge.d.ts +15 -0
- package/lib/types/client/domain/recost.d.ts +10 -0
- package/lib/types/client/domain/session-viewer.d.ts +13 -0
- package/lib/types/client/domain/streak.d.ts +25 -0
- package/lib/types/client/domain/subscription-vendors.d.ts +11 -0
- package/lib/types/client/domain/types.d.ts +312 -0
- package/lib/types/client/domain/week.d.ts +35 -0
- package/lib/types/client/export.d.ts +7 -1
- package/lib/types/client/live-cost.d.ts +13 -3
- package/lib/types/client/locales.d.ts +1 -1
- package/lib/types/client/settings-search.d.ts +12 -0
- package/lib/types/client/usage-billing-settings.d.ts +3 -1
- package/lib/types/config.d.ts +79 -0
- package/lib/types/guard.d.ts +49 -0
- package/lib/types/index.d.ts +3 -100
- package/lib/types/routes/balance.d.ts +37 -0
- package/lib/types/routes/notify-claim.d.ts +24 -0
- package/lib/types/routes/pricing.d.ts +26 -0
- package/lib/types/routes/relay-quotas.d.ts +27 -0
- package/lib/types/routes/subscriptions.d.ts +28 -0
- package/lib/types/routes/usage-stats.d.ts +44 -0
- package/lib/types/routes/usage-tool.d.ts +27 -0
- package/lib/types/silent.d.ts +21 -0
- package/package.json +40 -34
package/lib/index.js
CHANGED
|
@@ -2301,7 +2301,9 @@ const BUILTIN_MODEL_KEY_ALIASES = {
|
|
|
2301
2301
|
"deepseek-v4.1-flash": "flash",
|
|
2302
2302
|
"deepseek-v4.1-flash-expires-on-0910": "flash",
|
|
2303
2303
|
"deepseek-flash": "flash",
|
|
2304
|
+
"deepseek-v4.1-flash-1017": "flash",
|
|
2304
2305
|
"deepseek/deepseek-v4.1-flash": "flash",
|
|
2306
|
+
"deepseek/deepseek-v4.1-flash-1017": "flash",
|
|
2305
2307
|
"deepseek/deepseek-flash": "flash",
|
|
2306
2308
|
"deepseek/deepseek-v4-pro": "pro",
|
|
2307
2309
|
"deepseek-v4-pro": "pro",
|
|
@@ -2733,6 +2735,57 @@ function workspaceNameOf(cwd) {
|
|
|
2733
2735
|
if (cwd === void 0 || cwd === "") return "—";
|
|
2734
2736
|
return cwd.split(/[\\/]/).filter(Boolean).at(-1) ?? "—";
|
|
2735
2737
|
}
|
|
2738
|
+
/**
|
|
2739
|
+
* 从会话 header 提取子智能体归并标记(直接父会话 id):`origin: "subagent"`
|
|
2740
|
+
* (宿主的产品分类)或 `delegationDepth > 0`(持久化委派深度)任一命中即视为
|
|
2741
|
+
* 子会话。子会话但 header 无 parentSession(异常数据)返回 undefined——无法
|
|
2742
|
+
* 归位就按顶层会话处理,保守不标。导出供测试。
|
|
2743
|
+
*/
|
|
2744
|
+
function subParentOf(meta) {
|
|
2745
|
+
if (meta.origin !== "subagent" && !((meta.delegationDepth ?? 0) > 0)) return void 0;
|
|
2746
|
+
const parent = meta.parentSession;
|
|
2747
|
+
return typeof parent === "string" && parent !== "" ? parent : void 0;
|
|
2748
|
+
}
|
|
2749
|
+
/**
|
|
2750
|
+
* 给会话行补后代合计(subCost/subCalls):按 parentId 建子索引,对每行递归
|
|
2751
|
+
* 汇总全部后代子会话(多级委派整链计入根对话)。纯展示层归因——不改任何
|
|
2752
|
+
* 总额桶(子会话费用本就在 total/byDay/byModel 里,行级只是拆出来给人看)。
|
|
2753
|
+
* 环引用(异常数据)按 visited 跳过,不死循环。原地修改传入行。导出供测试。
|
|
2754
|
+
*/
|
|
2755
|
+
function foldSubagentFamilies(rows) {
|
|
2756
|
+
const subsOf = /* @__PURE__ */ new Map();
|
|
2757
|
+
for (const row of rows) {
|
|
2758
|
+
const parent = row.parentId;
|
|
2759
|
+
if (parent === void 0) continue;
|
|
2760
|
+
const list = subsOf.get(parent);
|
|
2761
|
+
if (list !== void 0) list.push(row);
|
|
2762
|
+
else subsOf.set(parent, [row]);
|
|
2763
|
+
}
|
|
2764
|
+
const familyOf = (id, visited) => {
|
|
2765
|
+
let cost = 0;
|
|
2766
|
+
let calls = 0;
|
|
2767
|
+
for (const child of subsOf.get(id) ?? []) {
|
|
2768
|
+
if (visited.has(child.id)) continue;
|
|
2769
|
+
visited.add(child.id);
|
|
2770
|
+
cost += child.cost;
|
|
2771
|
+
calls += child.calls;
|
|
2772
|
+
const nested = familyOf(child.id, visited);
|
|
2773
|
+
cost += nested.cost;
|
|
2774
|
+
calls += nested.calls;
|
|
2775
|
+
}
|
|
2776
|
+
return {
|
|
2777
|
+
cost,
|
|
2778
|
+
calls
|
|
2779
|
+
};
|
|
2780
|
+
};
|
|
2781
|
+
for (const row of rows) {
|
|
2782
|
+
const family = familyOf(row.id, /* @__PURE__ */ new Set([row.id]));
|
|
2783
|
+
if (family.calls > 0) {
|
|
2784
|
+
row.subCalls = family.calls;
|
|
2785
|
+
if (family.cost > 0) row.subCost = family.cost;
|
|
2786
|
+
}
|
|
2787
|
+
}
|
|
2788
|
+
}
|
|
2736
2789
|
/** TTFT 尖峰阈值(毫秒):超过计为一次尖峰样本,用于定位服务端抖动。 */
|
|
2737
2790
|
const PERF_SPIKE_MS = 1e4;
|
|
2738
2791
|
/** 全零摘要(合并的恒等元)。 */
|
|
@@ -3533,11 +3586,12 @@ function createUsageAggregator(persistence, options = {}) {
|
|
|
3533
3586
|
* 价目表变化后未受影响行(无 unpriced 模型)依旧零读取复用。 */
|
|
3534
3587
|
const foldNeedsReprice = (fold) => fold.unpricedModels.some((key) => isPriced(key));
|
|
3535
3588
|
/** 把一份(全量或增量)折叠结果登记进 durable ledger;内容无变化时不落盘。 */
|
|
3536
|
-
const recordLedger = (id, cwd, stamp, fold) => {
|
|
3589
|
+
const recordLedger = (id, cwd, stamp, fold, subOf) => {
|
|
3537
3590
|
if (options.ledger === void 0) return;
|
|
3538
3591
|
const entry = {
|
|
3539
3592
|
id,
|
|
3540
3593
|
...cwd === void 0 ? {} : { cwd },
|
|
3594
|
+
...subOf === void 0 ? {} : { subOf },
|
|
3541
3595
|
...stamp === null ? {} : { stamp },
|
|
3542
3596
|
foldVersion: 23,
|
|
3543
3597
|
fingerprint: foldFingerprint,
|
|
@@ -3545,7 +3599,7 @@ function createUsageAggregator(persistence, options = {}) {
|
|
|
3545
3599
|
fold: serializeFold(fold)
|
|
3546
3600
|
};
|
|
3547
3601
|
const row = ledger.get(id);
|
|
3548
|
-
if (row === void 0 || row.stamp !== entry.stamp || row.cwd !== entry.cwd || row.foldVersion !== entry.foldVersion || row.fingerprint !== entry.fingerprint || stamp === null && JSON.stringify(row.fold) !== JSON.stringify(entry.fold) || stamp !== null && foldNeedsReprice(row.fold)) {
|
|
3602
|
+
if (row === void 0 || row.stamp !== entry.stamp || row.cwd !== entry.cwd || row.subOf !== entry.subOf || row.foldVersion !== entry.foldVersion || row.fingerprint !== entry.fingerprint || stamp === null && JSON.stringify(row.fold) !== JSON.stringify(entry.fold) || stamp !== null && foldNeedsReprice(row.fold)) {
|
|
3549
3603
|
ledger.set(id, entry);
|
|
3550
3604
|
ledgerNeedsSave = true;
|
|
3551
3605
|
}
|
|
@@ -3637,12 +3691,14 @@ function createUsageAggregator(persistence, options = {}) {
|
|
|
3637
3691
|
}
|
|
3638
3692
|
try {
|
|
3639
3693
|
const cwd = meta.cwd;
|
|
3694
|
+
const subOf = subParentOf(meta);
|
|
3640
3695
|
const ledgerRow = ledger.get(id);
|
|
3641
3696
|
if (ledgerRow !== void 0 && ledgerRow.stamp !== void 0 && stamp !== null && ledgerRow.stamp === stamp && (ledgerRow.foldVersion ?? 1) === 23 && ledgerRow.fingerprint === foldFingerprint && !foldNeedsReprice(ledgerRow.fold)) {
|
|
3642
3697
|
const fold = deserializedFoldOf(ledgerRow.fold);
|
|
3643
3698
|
folds.push({
|
|
3644
3699
|
id,
|
|
3645
3700
|
...cwd === void 0 ? {} : { cwd },
|
|
3701
|
+
...subOf === void 0 ? {} : { subOf },
|
|
3646
3702
|
fold
|
|
3647
3703
|
});
|
|
3648
3704
|
included.add(id);
|
|
@@ -3655,6 +3711,7 @@ function createUsageAggregator(persistence, options = {}) {
|
|
|
3655
3711
|
folds.push({
|
|
3656
3712
|
id,
|
|
3657
3713
|
...meta.cwd === void 0 ? {} : { cwd: meta.cwd },
|
|
3714
|
+
...subOf === void 0 ? {} : { subOf },
|
|
3658
3715
|
fold: hit.fold
|
|
3659
3716
|
});
|
|
3660
3717
|
included.add(id);
|
|
@@ -3683,10 +3740,11 @@ function createUsageAggregator(persistence, options = {}) {
|
|
|
3683
3740
|
folds.push({
|
|
3684
3741
|
id,
|
|
3685
3742
|
...cwd === void 0 ? {} : { cwd },
|
|
3743
|
+
...subOf === void 0 ? {} : { subOf },
|
|
3686
3744
|
fold: previous.fold
|
|
3687
3745
|
});
|
|
3688
3746
|
included.add(id);
|
|
3689
|
-
recordLedger(id, cwd, stamp, previous.fold);
|
|
3747
|
+
recordLedger(id, cwd, stamp, previous.fold, subOf);
|
|
3690
3748
|
continue;
|
|
3691
3749
|
}
|
|
3692
3750
|
const { events, inheritedEventCount } = await persistence.readFrom(meta.id, sessionLogOffsetOf(0));
|
|
@@ -3712,10 +3770,11 @@ function createUsageAggregator(persistence, options = {}) {
|
|
|
3712
3770
|
folds.push({
|
|
3713
3771
|
id,
|
|
3714
3772
|
...cwd === void 0 ? {} : { cwd },
|
|
3773
|
+
...subOf === void 0 ? {} : { subOf },
|
|
3715
3774
|
fold
|
|
3716
3775
|
});
|
|
3717
3776
|
included.add(id);
|
|
3718
|
-
recordLedger(id, cwd, stamp, fold);
|
|
3777
|
+
recordLedger(id, cwd, stamp, fold, subOf);
|
|
3719
3778
|
} catch (error) {
|
|
3720
3779
|
skipped.push(id);
|
|
3721
3780
|
const format = isFormatUnsupportedError(error);
|
|
@@ -3743,6 +3802,7 @@ function createUsageAggregator(persistence, options = {}) {
|
|
|
3743
3802
|
folds.push({
|
|
3744
3803
|
id: entry.id,
|
|
3745
3804
|
...entry.cwd === void 0 ? {} : { cwd: entry.cwd },
|
|
3805
|
+
...entry.subOf === void 0 ? {} : { subOf: entry.subOf },
|
|
3746
3806
|
...stale ? { staleLedger: true } : {},
|
|
3747
3807
|
fold: deserializedFoldOf(entry.fold)
|
|
3748
3808
|
});
|
|
@@ -3779,7 +3839,7 @@ function createUsageAggregator(persistence, options = {}) {
|
|
|
3779
3839
|
const perfHourModel = /* @__PURE__ */ new Map();
|
|
3780
3840
|
const perfModelDigest = /* @__PURE__ */ new Map();
|
|
3781
3841
|
const perfHourDigest = /* @__PURE__ */ new Map();
|
|
3782
|
-
for (const { id: sessionId, cwd, fold, staleLedger } of folds) {
|
|
3842
|
+
for (const { id: sessionId, cwd, subOf, fold, staleLedger } of folds) {
|
|
3783
3843
|
mergeUsageInto(total, fold.total);
|
|
3784
3844
|
roles.userChars += fold.roles.userChars;
|
|
3785
3845
|
roles.toolChars += fold.roles.toolChars;
|
|
@@ -3858,12 +3918,14 @@ function createUsageAggregator(persistence, options = {}) {
|
|
|
3858
3918
|
id: sessionId,
|
|
3859
3919
|
...fold.title !== void 0 ? { title: fold.title } : {},
|
|
3860
3920
|
...cwd !== void 0 ? { cwd } : {},
|
|
3921
|
+
...subOf !== void 0 ? { parentId: subOf } : {},
|
|
3861
3922
|
...staleLedger === true ? { stale: true } : {},
|
|
3862
3923
|
calls: fold.total.calls,
|
|
3863
3924
|
cost: fold.total.cost,
|
|
3864
3925
|
lastActive: fold.lastActive
|
|
3865
3926
|
});
|
|
3866
3927
|
}
|
|
3928
|
+
foldSubagentFamilies(sessionRows);
|
|
3867
3929
|
sessionRows.sort((a, b) => b.cost - a.cost || b.lastActive - a.lastActive);
|
|
3868
3930
|
turnRows.sort((a, b) => b.startedAt - a.startedAt);
|
|
3869
3931
|
const workspaces = [...workspaceMap.values()].sort((a, b) => b.cost - a.cost || b.lastActive - a.lastActive);
|
|
@@ -5095,7 +5157,11 @@ async function fetchDeclaredBody(url, raw, apiKey) {
|
|
|
5095
5157
|
if (!response.ok) throw new HttpStatusError("http-error", response.status);
|
|
5096
5158
|
const text = await response.text();
|
|
5097
5159
|
if (text.length > MAX_BODY_BYTES) throw new HttpStatusError("body-too-large", 413);
|
|
5098
|
-
|
|
5160
|
+
try {
|
|
5161
|
+
return JSON.parse(text);
|
|
5162
|
+
} catch (cause) {
|
|
5163
|
+
throw new HttpStatusError("parse-error", 0);
|
|
5164
|
+
}
|
|
5099
5165
|
} finally {
|
|
5100
5166
|
clearTimeout(timer);
|
|
5101
5167
|
}
|
|
@@ -5602,6 +5668,38 @@ async function fetchLivePricing() {
|
|
|
5602
5668
|
};
|
|
5603
5669
|
}
|
|
5604
5670
|
//#endregion
|
|
5671
|
+
//#region lib/types/silent.js
|
|
5672
|
+
/**
|
|
5673
|
+
* 静默吞错的结构化记录(S-6):「缺失=未配置」类 catch 的统一出口。
|
|
5674
|
+
*
|
|
5675
|
+
* 本仓 78 处 server 端 catch 多数是有意的静默(文件缺失=首次运行、凭据未
|
|
5676
|
+
* 配置=跳过该 provider),此前唯一载体是注释——用户报「X 不工作」时只能靠
|
|
5677
|
+
* 猜。本工具把这些静默升级为**节流的结构化日志**:同 reason 5 分钟最多一条
|
|
5678
|
+
* info,宿主日志可 grep `swallowed:`,诊断时看得到「哪个环节吞了什么」,
|
|
5679
|
+
* 而又不把已知常态刷成告警噪音。
|
|
5680
|
+
*
|
|
5681
|
+
* 不取代 console.warn——已知异常需要用户行动的(账本损坏、刷新失败)继续
|
|
5682
|
+
* warn;这里只收「确认无害的静默」。节流状态导出重置仅供测试。
|
|
5683
|
+
*/
|
|
5684
|
+
const THROTTLE_MS = 3e5;
|
|
5685
|
+
const lastLoggedAt = /* @__PURE__ */ new Map();
|
|
5686
|
+
/**
|
|
5687
|
+
* 记录一次有意的静默吞错。
|
|
5688
|
+
* @param reason - 稳定的短横线 reason 键(如 `reconcile-ref-missing`),节流以此为粒度。
|
|
5689
|
+
* @param error - 可选的原始异常(Error 打 message,其他值原样)。
|
|
5690
|
+
*/
|
|
5691
|
+
function swallowed(reason, error) {
|
|
5692
|
+
const now = Date.now();
|
|
5693
|
+
const last = lastLoggedAt.get(reason);
|
|
5694
|
+
if (last !== void 0 && now - last < THROTTLE_MS) return;
|
|
5695
|
+
lastLoggedAt.set(reason, now);
|
|
5696
|
+
if (error === void 0) {
|
|
5697
|
+
console.info(`[usage-billing] swallowed: ${reason}`);
|
|
5698
|
+
return;
|
|
5699
|
+
}
|
|
5700
|
+
console.info(`[usage-billing] swallowed: ${reason}`, error instanceof Error ? error.message : error);
|
|
5701
|
+
}
|
|
5702
|
+
//#endregion
|
|
5605
5703
|
//#region lib/types/subscriptions.js
|
|
5606
5704
|
/**
|
|
5607
5705
|
* Subscription-plan quota polling (node half): how much of each coding/token
|
|
@@ -6001,7 +6099,9 @@ async function collectZai(keys, config, timeoutMs) {
|
|
|
6001
6099
|
try {
|
|
6002
6100
|
subscription = await requestJson(`${host}/api/biz/subscription/list`, init, timeoutMs);
|
|
6003
6101
|
if (zaiEnvelopeStatus(subscription) !== void 0) subscription = null;
|
|
6004
|
-
} catch {
|
|
6102
|
+
} catch {
|
|
6103
|
+
swallowed("zai-plan-metadata-missing");
|
|
6104
|
+
}
|
|
6005
6105
|
const parsed = parseZai(quota, subscription);
|
|
6006
6106
|
return {
|
|
6007
6107
|
provider: config.provider,
|
|
@@ -6769,36 +6869,17 @@ async function queryRelayQuotas(ctx, routes) {
|
|
|
6769
6869
|
return await Promise.all(routes.map(async (route) => queryRelayQuota(ctx, route)));
|
|
6770
6870
|
}
|
|
6771
6871
|
//#endregion
|
|
6772
|
-
//#region lib/types/
|
|
6872
|
+
//#region lib/types/guard.js
|
|
6773
6873
|
/**
|
|
6774
|
-
*
|
|
6874
|
+
* 共享 loopback 守卫(v1.5.2 抽自 src/index.ts)。
|
|
6775
6875
|
*
|
|
6776
|
-
*
|
|
6777
|
-
*
|
|
6778
|
-
*
|
|
6779
|
-
*
|
|
6780
|
-
* 按宿主世代选择通道。缺省的默认行为是关闭——避免该工具默认占用模型每次请求
|
|
6781
|
-
* 的上下文(coding 场景通常在仪表盘看用量)。
|
|
6782
|
-
*/
|
|
6783
|
-
/** 设置命名空间 id(小写 kebab-case)。 */
|
|
6784
|
-
const BILLING_SETTINGS_NAMESPACE = "ui-usage-billing";
|
|
6785
|
-
/** 该命名空间下用户可编辑的字段名。 */
|
|
6786
|
-
const ENABLE_USAGE_STATS_TOOL_FIELD = "enableUsageStatsTool";
|
|
6787
|
-
//#endregion
|
|
6788
|
-
//#region lib/types/index.js
|
|
6789
|
-
/**
|
|
6790
|
-
* Usage billing surface plugin, node half.
|
|
6876
|
+
* 端点只供本机浏览器用,局域网/远端请求一律拒绝,避免面板数据(含中转站
|
|
6877
|
+
* origin 与余额)外泄。三层防御:peer socket(最可信)+ Host 头精确字面量
|
|
6878
|
+
* (防 `127.0.0.1.evil.com` 这类 DNS rebinding 域名)+ trustedHosts 白名单
|
|
6879
|
+
* (反向代理场景的精确主机名放行)。Origin 头独立校验(写操作用)。
|
|
6791
6880
|
*
|
|
6792
|
-
*
|
|
6793
|
-
*
|
|
6794
|
-
* instead of showing an empty snapshot. Aggregation is stale-while-revalidate:
|
|
6795
|
-
* requests get a short wait budget and fall back to the freshest persisted
|
|
6796
|
-
* snapshot while a slow full fold keeps running in the background (a heavy
|
|
6797
|
-
* user's first fold takes minutes and must not starve the single-process
|
|
6798
|
-
* host's RPC). When `sessionPersistence` is unavailable (or aggregation
|
|
6799
|
-
* fails), the configured `statsPath` / `DSH_USAGE_STATS` / conventional JSON
|
|
6800
|
-
* file is served as a fallback, and a missing file answers `{ error }` so the
|
|
6801
|
-
* dashboard shows zeros, never fabricated samples.
|
|
6881
|
+
* 设计要点:精确字面量匹配(`startsWith('127.')` 会被 `127.0.0.1.evil.com`
|
|
6882
|
+
* 穿透,必须正则)。IPv6 字面量带方括号(`[::1]:3080`)单独剥离。
|
|
6802
6883
|
*/
|
|
6803
6884
|
/** Peer 地址是否为回环(本地)。回环防护:本插件的端点只供本机浏览器用,
|
|
6804
6885
|
* 局域网/远端请求一律拒绝,避免面板数据(含中转站 origin 与余额)外泄。 */
|
|
@@ -6834,6 +6915,9 @@ function normalizeTrustedHosts(hosts) {
|
|
|
6834
6915
|
const names = hosts.filter((host) => typeof host === "string").map((host) => hostNameOf(host)).filter((host) => host !== "");
|
|
6835
6916
|
return new Set(names);
|
|
6836
6917
|
}
|
|
6918
|
+
/** 校验 Host 头是本机回环(精确 127.0.0.0/8 / ::1 / localhost 或空,供 curl 不带 Host 的极简请求)。
|
|
6919
|
+
* 拒绝 `127.0.0.1.attacker.com` 这类以 `127.` 开头但解析到外部的 DNS rebinding 域名:
|
|
6920
|
+
* 只用 `startsWith('127.')` 会被它穿透,必须精确匹配回环 IP 的字面量。 */
|
|
6837
6921
|
function isLoopbackHost(req, trustedHosts = /* @__PURE__ */ new Set()) {
|
|
6838
6922
|
const host = req.headers.host;
|
|
6839
6923
|
if (host === void 0 || host === "") return true;
|
|
@@ -6874,176 +6958,659 @@ function guardLoopback(req, res, trustedHosts = /* @__PURE__ */ new Set()) {
|
|
|
6874
6958
|
}
|
|
6875
6959
|
return true;
|
|
6876
6960
|
}
|
|
6961
|
+
//#endregion
|
|
6962
|
+
//#region lib/types/routes/notify-claim.js
|
|
6877
6963
|
/**
|
|
6878
|
-
*
|
|
6879
|
-
*
|
|
6880
|
-
*
|
|
6881
|
-
|
|
6882
|
-
|
|
6883
|
-
|
|
6884
|
-
|
|
6885
|
-
|
|
6886
|
-
|
|
6887
|
-
|
|
6888
|
-
|
|
6889
|
-
|
|
6890
|
-
/**
|
|
6891
|
-
const
|
|
6892
|
-
|
|
6893
|
-
|
|
6894
|
-
|
|
6895
|
-
|
|
6896
|
-
|
|
6897
|
-
|
|
6898
|
-
|
|
6899
|
-
|
|
6900
|
-
|
|
6901
|
-
|
|
6902
|
-
|
|
6903
|
-
|
|
6904
|
-
|
|
6905
|
-
|
|
6906
|
-
|
|
6907
|
-
|
|
6908
|
-
|
|
6909
|
-
|
|
6910
|
-
|
|
6911
|
-
|
|
6912
|
-
|
|
6913
|
-
|
|
6914
|
-
|
|
6915
|
-
|
|
6916
|
-
|
|
6917
|
-
|
|
6918
|
-
|
|
6919
|
-
|
|
6920
|
-
|
|
6921
|
-
const RESPONSE_BUDGET_MS = 1500;
|
|
6922
|
-
/** 鉴权失败告警冷却(毫秒):同一 provider 在窗口内只提示一次,避免 30 秒轮询刷屏。 */
|
|
6923
|
-
const AUTH_WARN_COOLDOWN_MS = 18e5;
|
|
6924
|
-
/**
|
|
6925
|
-
* 鉴权失败分类告警(P1-5):余额 / 订阅查询返回 unauthorized 时,按
|
|
6926
|
-
* `source:provider` 去重并冷却告警,提示检查 llm-pi-ai 里该 provider 的 apiKeyEnv。
|
|
6927
|
-
*/
|
|
6928
|
-
const authWarnedAt = /* @__PURE__ */ new Map();
|
|
6929
|
-
function warnAuthOnce(source, provider, displayName) {
|
|
6930
|
-
const key = `${source}:${provider}`;
|
|
6931
|
-
const now = Date.now();
|
|
6932
|
-
const last = authWarnedAt.get(key);
|
|
6933
|
-
if (last !== void 0 && now - last < AUTH_WARN_COOLDOWN_MS) return;
|
|
6934
|
-
authWarnedAt.set(key, now);
|
|
6935
|
-
console.warn(`[usage-billing] ${displayName}(${provider})鉴权失败:请检查 llm-pi-ai 设置中该 provider 的凭据环境变量是否正确/有效。`);
|
|
6936
|
-
}
|
|
6937
|
-
/** 锁等待上限:账本提交是毫秒级 read-render-commit,2s 已数倍于正常持锁时长。 */
|
|
6938
|
-
const LEDGER_LOCK_WAIT_MS = 2e3;
|
|
6939
|
-
/**
|
|
6940
|
-
* 锁年龄超过该值判为崩溃残留的孤儿锁。正常持锁是毫秒级文件提交,没有活跃
|
|
6941
|
-
* writer 会合法持有 60s;库层刻意不做年龄判定(它无法假设调用方的操作时长),
|
|
6942
|
-
* 本调用方知道自己操作的上界,可以给出有依据的清理阈值。
|
|
6943
|
-
*/
|
|
6944
|
-
const STALE_LEDGER_LOCK_MS = 6e4;
|
|
6945
|
-
/**
|
|
6946
|
-
* 锁等待超时后的孤儿锁自愈(issue #48):仅当锁文件年龄超过
|
|
6947
|
-
* STALE_LEDGER_LOCK_MS 才清理,避免误删慢 writer 的活跃锁。
|
|
6948
|
-
* @param ledgerPath - 账本文件路径(锁为其 `.lock` 兄弟文件)。
|
|
6949
|
-
* @returns 是否清理了孤儿锁;false = 锁已消失或太新鲜,调用方上抛原错误。
|
|
6950
|
-
*/
|
|
6951
|
-
async function reclaimStaleLedgerLock(ledgerPath) {
|
|
6952
|
-
let ageMs;
|
|
6953
|
-
try {
|
|
6954
|
-
ageMs = Date.now() - (await lstat(`${ledgerPath}.lock`)).mtimeMs;
|
|
6955
|
-
} catch {
|
|
6956
|
-
return false;
|
|
6957
|
-
}
|
|
6958
|
-
if (ageMs < STALE_LEDGER_LOCK_MS) return false;
|
|
6959
|
-
console.warn(`[usage-billing] 账本锁已存在 ${Math.round(ageMs / 1e3)}s(崩溃残留),清理后重试:${ledgerPath}.lock`);
|
|
6960
|
-
await rm(`${ledgerPath}.lock`, { force: true });
|
|
6961
|
-
return true;
|
|
6962
|
-
}
|
|
6963
|
-
/**
|
|
6964
|
-
* Create the atomic file-backed durable-ledger store. The previous complete file
|
|
6965
|
-
* is retained as `.bak`; a malformed/missing main file falls back to that backup.
|
|
6966
|
-
*/
|
|
6967
|
-
function createFileUsageLedgerStore(ledgerPath) {
|
|
6968
|
-
return {
|
|
6969
|
-
async load() {
|
|
6970
|
-
for (const path of [ledgerPath, `${ledgerPath}.bak`]) try {
|
|
6971
|
-
const parsed = JSON.parse(await readFile(path, "utf8"));
|
|
6972
|
-
if (parsed !== null && typeof parsed === "object") {
|
|
6973
|
-
const candidate = parsed;
|
|
6974
|
-
if (candidate.version === 1 && Array.isArray(candidate.sessions)) return parsed;
|
|
6964
|
+
* 路由:POST /api/billing/notify-claim
|
|
6965
|
+
*
|
|
6966
|
+
* 跨实例提醒认领(issue #44):多实例(CLI + 桌面等)合法共存,各自轮询
|
|
6967
|
+
* 会重复弹通知。两级去重——进程内 Set 挡同进程的并发/重渲染(trigger 与仪表盘
|
|
6968
|
+
* 是两个组件实例,effect 同周期并发时本地 store 标记读到的还是旧值);跨进程走
|
|
6969
|
+
* 认领端点(先到先得),另一实例刚发过则跳过。端点不可达(旧版本/网络故障)
|
|
6970
|
+
* 时放行,退化为原有 per-客户端去重。通知构造失败(平台限制)静默。
|
|
6971
|
+
*
|
|
6972
|
+
* 路由文件职责:handler + 模块级 `notifyClaims` 单例(与 `loadNotifications`
|
|
6973
|
+
* 共享内存状态——先到先得认领)。文件持久化(`writeFileAtomic`)由 index.ts 装配
|
|
6974
|
+
* 器通过 `claimsPath` 注入;handler 拿到路径后自行管理读写。
|
|
6975
|
+
*/
|
|
6976
|
+
/** 模块级认领表:loaded 表示是否已从磁盘反序列化;keys 是先到先得序。 */
|
|
6977
|
+
const notifyClaims = {
|
|
6978
|
+
loaded: false,
|
|
6979
|
+
keys: []
|
|
6980
|
+
};
|
|
6981
|
+
/** 注册路由到 cordis webServer effect;handler 闭包外只读 deps。 */
|
|
6982
|
+
function registerNotifyClaimRoute(ctx, deps) {
|
|
6983
|
+
ctx.effect(() => ctx.webServer.register({
|
|
6984
|
+
kind: "exact",
|
|
6985
|
+
path: "/api/billing/notify-claim",
|
|
6986
|
+
handler: async (req, res) => {
|
|
6987
|
+
if (!guardLoopback(req, res, deps.trustedHosts)) return;
|
|
6988
|
+
if (req.method !== "POST") {
|
|
6989
|
+
res.writeHead(405, { "content-type": "application/json; charset=utf-8" });
|
|
6990
|
+
res.end(JSON.stringify({ error: "method not allowed" }));
|
|
6991
|
+
return;
|
|
6992
|
+
}
|
|
6993
|
+
if (!isLoopbackOrigin(req.headers.origin) || !(req.headers["content-type"] ?? "").toLowerCase().includes("application/json")) {
|
|
6994
|
+
res.writeHead(403, { "content-type": "application/json; charset=utf-8" });
|
|
6995
|
+
res.end(JSON.stringify({ error: "forbidden: loopback only" }));
|
|
6996
|
+
return;
|
|
6997
|
+
}
|
|
6998
|
+
try {
|
|
6999
|
+
let body = "";
|
|
7000
|
+
for await (const chunk of req) {
|
|
7001
|
+
body += String(chunk);
|
|
7002
|
+
if (body.length > 2048) {
|
|
7003
|
+
res.writeHead(413, { "content-type": "application/json; charset=utf-8" });
|
|
7004
|
+
res.end(JSON.stringify({ error: "body too large" }));
|
|
7005
|
+
return;
|
|
7006
|
+
}
|
|
6975
7007
|
}
|
|
6976
|
-
|
|
6977
|
-
|
|
6978
|
-
|
|
6979
|
-
|
|
6980
|
-
|
|
6981
|
-
mode: 448
|
|
6982
|
-
});
|
|
6983
|
-
const commit = async () => {
|
|
6984
|
-
let existing;
|
|
6985
|
-
let existingDoc;
|
|
6986
|
-
try {
|
|
6987
|
-
existing = await readFile(ledgerPath, "utf8");
|
|
6988
|
-
const parsed = JSON.parse(existing);
|
|
6989
|
-
if (parsed.version === 1 && Array.isArray(parsed.sessions)) existingDoc = parsed;
|
|
6990
|
-
} catch {
|
|
6991
|
-
existing = void 0;
|
|
7008
|
+
const parsed = JSON.parse(body === "" ? "{}" : body);
|
|
7009
|
+
if (typeof parsed.key !== "string" || parsed.key === "" || parsed.key.length > 200) {
|
|
7010
|
+
res.writeHead(400, { "content-type": "application/json; charset=utf-8" });
|
|
7011
|
+
res.end(JSON.stringify({ error: "invalid key" }));
|
|
7012
|
+
return;
|
|
6992
7013
|
}
|
|
6993
|
-
if (
|
|
6994
|
-
|
|
6995
|
-
|
|
6996
|
-
|
|
6997
|
-
|
|
6998
|
-
|
|
7014
|
+
if (!notifyClaims.loaded) {
|
|
7015
|
+
notifyClaims.loaded = true;
|
|
7016
|
+
try {
|
|
7017
|
+
const stored = JSON.parse(await readFile(deps.claimsPath, "utf8"));
|
|
7018
|
+
if (Array.isArray(stored.keys)) notifyClaims.keys = stored.keys.filter((k) => typeof k === "string");
|
|
7019
|
+
} catch {
|
|
7020
|
+
swallowed("notify-claims-file-unreadable");
|
|
7021
|
+
}
|
|
7022
|
+
}
|
|
7023
|
+
if (notifyClaims.keys.includes(parsed.key)) {
|
|
7024
|
+
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
7025
|
+
res.end(JSON.stringify({ claimed: false }));
|
|
7026
|
+
return;
|
|
7027
|
+
}
|
|
7028
|
+
notifyClaims.keys.push(parsed.key);
|
|
7029
|
+
if (notifyClaims.keys.length > 400) notifyClaims.keys = notifyClaims.keys.slice(-200);
|
|
7030
|
+
writeFileAtomic(deps.claimsPath, JSON.stringify({ keys: notifyClaims.keys }), {
|
|
6999
7031
|
mode: 384,
|
|
7000
7032
|
dirMode: 448
|
|
7001
|
-
});
|
|
7002
|
-
|
|
7003
|
-
|
|
7004
|
-
|
|
7005
|
-
|
|
7006
|
-
|
|
7007
|
-
if (!await reclaimStaleLedgerLock(ledgerPath)) throw error;
|
|
7008
|
-
await withFileLock(ledgerPath, commit, { waitMs: LEDGER_LOCK_WAIT_MS });
|
|
7033
|
+
}).catch(() => {});
|
|
7034
|
+
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
7035
|
+
res.end(JSON.stringify({ claimed: true }));
|
|
7036
|
+
} catch {
|
|
7037
|
+
res.writeHead(400, { "content-type": "application/json; charset=utf-8" });
|
|
7038
|
+
res.end(JSON.stringify({ error: "invalid" }));
|
|
7009
7039
|
}
|
|
7010
7040
|
}
|
|
7011
|
-
};
|
|
7041
|
+
}), "usage-billing: notify-claim route");
|
|
7012
7042
|
}
|
|
7013
|
-
|
|
7014
|
-
|
|
7015
|
-
"webServer",
|
|
7016
|
-
"sessionPersistence",
|
|
7017
|
-
"credentials",
|
|
7018
|
-
"settings"
|
|
7019
|
-
];
|
|
7043
|
+
//#endregion
|
|
7044
|
+
//#region lib/types/routes/pricing.js
|
|
7020
7045
|
/**
|
|
7021
|
-
*
|
|
7022
|
-
*
|
|
7046
|
+
* 路由:GET /api/billing/pricing + POST /api/billing/pricing/refresh
|
|
7047
|
+
*
|
|
7048
|
+
* 实时定价(汇率 + OpenRouter 模型价 + models.dev 目录外补充)的 GET/POST 同
|
|
7049
|
+
* concern,合一个文件:
|
|
7050
|
+
* - GET 由前端 30s 轮询 + 弹窗初始拉取触发,返回完整价目表文档(live + 内置
|
|
7051
|
+
* 目录 + 别名表 + syncedAt)。
|
|
7052
|
+
* - POST 是费率条「立即同步」按钮触发,强制服务端重新拉取一次;写操作额外
|
|
7053
|
+
* 校验 Origin 与 Content-Type。
|
|
7054
|
+
*
|
|
7055
|
+
* 路由文件只关心 HTTP 层:响应体序列化与缓存(pricingBodyCache)由 index.ts
|
|
7056
|
+
* 装配器通过 `body()` 闭包注入;服务端重拉逻辑(refreshPricing + 价格目录代际
|
|
7057
|
+
* 变化触发 reprice)也在装配器里,路由只通过 `refresh()` 触发。
|
|
7023
7058
|
*/
|
|
7024
|
-
/**
|
|
7025
|
-
|
|
7026
|
-
{
|
|
7027
|
-
|
|
7028
|
-
|
|
7029
|
-
|
|
7030
|
-
|
|
7031
|
-
|
|
7032
|
-
|
|
7033
|
-
|
|
7034
|
-
|
|
7035
|
-
|
|
7036
|
-
|
|
7037
|
-
|
|
7038
|
-
{
|
|
7039
|
-
|
|
7040
|
-
|
|
7041
|
-
|
|
7042
|
-
|
|
7043
|
-
|
|
7044
|
-
|
|
7045
|
-
|
|
7046
|
-
|
|
7059
|
+
/** GET /api/billing/pricing。 */
|
|
7060
|
+
function registerGetRoute(ctx, deps) {
|
|
7061
|
+
ctx.effect(() => ctx.webServer.register({
|
|
7062
|
+
kind: "exact",
|
|
7063
|
+
path: "/api/billing/pricing",
|
|
7064
|
+
handler: async (req, res) => {
|
|
7065
|
+
if (!guardLoopback(req, res, deps.trustedHosts)) return;
|
|
7066
|
+
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
7067
|
+
res.end(deps.body());
|
|
7068
|
+
}
|
|
7069
|
+
}), "usage-billing: pricing route");
|
|
7070
|
+
}
|
|
7071
|
+
/** POST /api/billing/pricing/refresh。 */
|
|
7072
|
+
function registerRefreshRoute(ctx, deps) {
|
|
7073
|
+
ctx.effect(() => ctx.webServer.register({
|
|
7074
|
+
kind: "exact",
|
|
7075
|
+
path: "/api/billing/pricing/refresh",
|
|
7076
|
+
handler: async (req, res) => {
|
|
7077
|
+
if (!guardLoopback(req, res, deps.trustedHosts)) return;
|
|
7078
|
+
if (req.method !== "POST") {
|
|
7079
|
+
res.writeHead(405, { "content-type": "application/json; charset=utf-8" });
|
|
7080
|
+
res.end(JSON.stringify({ error: "method not allowed" }));
|
|
7081
|
+
return;
|
|
7082
|
+
}
|
|
7083
|
+
if (!isLoopbackOrigin(req.headers.origin)) {
|
|
7084
|
+
res.writeHead(403, { "content-type": "application/json; charset=utf-8" });
|
|
7085
|
+
res.end(JSON.stringify({ error: "forbidden: loopback only" }));
|
|
7086
|
+
return;
|
|
7087
|
+
}
|
|
7088
|
+
if (!(req.headers["content-type"] ?? "").toLowerCase().includes("application/json")) {
|
|
7089
|
+
res.writeHead(415, { "content-type": "application/json; charset=utf-8" });
|
|
7090
|
+
res.end(JSON.stringify({ error: "unsupported content-type" }));
|
|
7091
|
+
return;
|
|
7092
|
+
}
|
|
7093
|
+
await deps.refresh();
|
|
7094
|
+
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
7095
|
+
res.end(deps.body());
|
|
7096
|
+
}
|
|
7097
|
+
}), "usage-billing: pricing refresh route");
|
|
7098
|
+
}
|
|
7099
|
+
/** 一次性注册两条同 concern 路由。 */
|
|
7100
|
+
function registerPricingRoutes(ctx, deps) {
|
|
7101
|
+
registerGetRoute(ctx, deps);
|
|
7102
|
+
registerRefreshRoute(ctx, deps);
|
|
7103
|
+
}
|
|
7104
|
+
//#endregion
|
|
7105
|
+
//#region lib/types/routes/balance.js
|
|
7106
|
+
/**
|
|
7107
|
+
* 路由:GET /api/billing/balance
|
|
7108
|
+
*
|
|
7109
|
+
* 多平台余额(DeepSeek / Kimi / StepFun / SiliconFlow / xAI / 智谱 / 腾讯云
|
|
7110
|
+
* TokenHub 等)+ 用户自定义余额 + 声明端点 + 余额差对账。5 分钟缓存(避免
|
|
7111
|
+
* 每 30s 后台轮询打上游),in-flight 去重(并发请求共享同一次进行中的刷新)。
|
|
7112
|
+
*
|
|
7113
|
+
* 路由文件只关心 HTTP 缓存逻辑:5 分钟过期判定 + 失败时仍发旧缓存(不挂起
|
|
7114
|
+
* 已 writeHead 的响应)。上游查询(queryBalances / queryCustomBalances /
|
|
7115
|
+
* queryDeclaredEndpoints / reconcileBalanceDelta)在装配器里跑,路由只通过
|
|
7116
|
+
* `cached()` 与 `refreshOnce()` 拿到结果。
|
|
7117
|
+
*/
|
|
7118
|
+
function registerBalanceRoute(ctx, deps) {
|
|
7119
|
+
ctx.effect(() => ctx.webServer.register({
|
|
7120
|
+
kind: "exact",
|
|
7121
|
+
path: "/api/billing/balance",
|
|
7122
|
+
handler: async (req, res) => {
|
|
7123
|
+
if (!guardLoopback(req, res, deps.trustedHosts)) return;
|
|
7124
|
+
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
7125
|
+
if (Date.now() - deps.cached().at < deps.cacheMs) {
|
|
7126
|
+
res.end(JSON.stringify(deps.cached().doc));
|
|
7127
|
+
return;
|
|
7128
|
+
}
|
|
7129
|
+
try {
|
|
7130
|
+
await deps.refreshOnce();
|
|
7131
|
+
} catch (error) {
|
|
7132
|
+
console.warn("[usage-billing] refreshBalances failed; serving cached balances:", error);
|
|
7133
|
+
}
|
|
7134
|
+
res.end(JSON.stringify(deps.cached().doc));
|
|
7135
|
+
}
|
|
7136
|
+
}), "usage-billing: balance route");
|
|
7137
|
+
}
|
|
7138
|
+
//#endregion
|
|
7139
|
+
//#region lib/types/routes/usage-tool.js
|
|
7140
|
+
/**
|
|
7141
|
+
* 路由:GET/POST /api/billing/usage-tool
|
|
7142
|
+
*
|
|
7143
|
+
* usage_stats 工具开关:插件自带的读/写接口(不依赖宿主浏览器设置白名单)。
|
|
7144
|
+
* GET 返回当前是否注入;POST 写设置命名空间(重启应用后工具注入随新值生效)。
|
|
7145
|
+
*
|
|
7146
|
+
* 双世代宿主(issue #19 + #28 + v1.4.19)的接线在装配器里完成:
|
|
7147
|
+
* - 旧宿主 ≤0.1.6 走 `settings.register` 命名空间 scope。
|
|
7148
|
+
* - 宿主 0.1.7+ 注册面已移除,走 volatile config 投影。
|
|
7149
|
+
* 装配器提供 `read` / `write` 闭包,路由文件不感知双世代——`read()` 已内部分支
|
|
7150
|
+
* (scope 存在时读 scope,否则读 volatile + config base),`write(next)` 同理。
|
|
7151
|
+
* 工具注入由装配器的 `syncUsageTool` 驱动:写回后路由通过 `syncTool()` 显式调用一次
|
|
7152
|
+
* (幂等),覆盖「写回成功但引用未即时可见」的组合(测试替身不提交)。
|
|
7153
|
+
*/
|
|
7154
|
+
function registerUsageToolRoute(ctx, deps) {
|
|
7155
|
+
ctx.effect(() => ctx.webServer.register({
|
|
7156
|
+
kind: "exact",
|
|
7157
|
+
path: "/api/billing/usage-tool",
|
|
7158
|
+
handler: async (req, res) => {
|
|
7159
|
+
if (!guardLoopback(req, res, deps.trustedHosts)) return;
|
|
7160
|
+
if (req.method !== "GET" && req.method !== "POST") {
|
|
7161
|
+
res.writeHead(405, { "content-type": "application/json; charset=utf-8" });
|
|
7162
|
+
res.end(JSON.stringify({ error: "method not allowed" }));
|
|
7163
|
+
return;
|
|
7164
|
+
}
|
|
7165
|
+
if (req.method === "POST") {
|
|
7166
|
+
if (!isLoopbackOrigin(req.headers.origin)) {
|
|
7167
|
+
res.writeHead(403, { "content-type": "application/json; charset=utf-8" });
|
|
7168
|
+
res.end(JSON.stringify({ error: "forbidden: loopback only" }));
|
|
7169
|
+
return;
|
|
7170
|
+
}
|
|
7171
|
+
if (!(req.headers["content-type"] ?? "").toLowerCase().includes("application/json")) {
|
|
7172
|
+
res.writeHead(415, { "content-type": "application/json; charset=utf-8" });
|
|
7173
|
+
res.end(JSON.stringify({ error: "unsupported content-type" }));
|
|
7174
|
+
return;
|
|
7175
|
+
}
|
|
7176
|
+
}
|
|
7177
|
+
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
7178
|
+
if (req.method === "GET") {
|
|
7179
|
+
res.end(JSON.stringify({ enabled: deps.read() }));
|
|
7180
|
+
return;
|
|
7181
|
+
}
|
|
7182
|
+
try {
|
|
7183
|
+
let body = "";
|
|
7184
|
+
for await (const chunk of req) {
|
|
7185
|
+
body += String(chunk);
|
|
7186
|
+
if (body.length > 512) {
|
|
7187
|
+
res.writeHead(413, { "content-type": "application/json; charset=utf-8" });
|
|
7188
|
+
res.end(JSON.stringify({ error: "body too large" }));
|
|
7189
|
+
return;
|
|
7190
|
+
}
|
|
7191
|
+
}
|
|
7192
|
+
const next = JSON.parse(body === "" ? "{}" : body).enabled === true;
|
|
7193
|
+
await deps.write(next);
|
|
7194
|
+
deps.syncTool();
|
|
7195
|
+
res.end(JSON.stringify({
|
|
7196
|
+
ok: true,
|
|
7197
|
+
enabled: next
|
|
7198
|
+
}));
|
|
7199
|
+
} catch (error) {
|
|
7200
|
+
console.warn("[usage-billing] usage-tool toggle write-back failed:", error);
|
|
7201
|
+
res.end(JSON.stringify({ error: "invalid" }));
|
|
7202
|
+
}
|
|
7203
|
+
}
|
|
7204
|
+
}), "usage-billing: usage-tool route");
|
|
7205
|
+
}
|
|
7206
|
+
//#endregion
|
|
7207
|
+
//#region lib/types/routes/subscriptions.js
|
|
7208
|
+
/**
|
|
7209
|
+
* 路由:GET /api/billing/subscriptions
|
|
7210
|
+
*
|
|
7211
|
+
* 订阅套餐额度(Kimi / Z.ai Coding / OpenCode / MiniMax / OpenRouter / Claude /
|
|
7212
|
+
* CommandCode / 腾讯云 TokenHub 等)。5 分钟缓存 + in-flight 去重(与 balance
|
|
7213
|
+
* 同模式——并发请求共享同一次进行中的刷新)。
|
|
7214
|
+
*
|
|
7215
|
+
* 路由文件只关心 HTTP 缓存逻辑:超时或未命中时触发 refreshOnce,失败时仍用
|
|
7216
|
+
* 旧缓存(不挂起已 writeHead 的响应)。上游查询(collectSubscriptions +
|
|
7217
|
+
* resolveSubscriptionKeys + 适配器 + 套餐知识库)在装配器里跑。
|
|
7218
|
+
*/
|
|
7219
|
+
function registerSubscriptionsRoute(ctx, deps) {
|
|
7220
|
+
ctx.effect(() => ctx.webServer.register({
|
|
7221
|
+
kind: "exact",
|
|
7222
|
+
path: "/api/billing/subscriptions",
|
|
7223
|
+
handler: async (req, res) => {
|
|
7224
|
+
if (!guardLoopback(req, res, deps.trustedHosts)) return;
|
|
7225
|
+
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
7226
|
+
if (Date.now() - deps.cached().at >= deps.cacheMs) try {
|
|
7227
|
+
await deps.refreshOnce();
|
|
7228
|
+
} catch (error) {
|
|
7229
|
+
console.warn("[usage-billing] refreshQuotas failed; serving cached quotas:", error);
|
|
7230
|
+
}
|
|
7231
|
+
res.end(JSON.stringify({ quotas: deps.cached().quotas }));
|
|
7232
|
+
}
|
|
7233
|
+
}), "usage-billing: subscriptions route");
|
|
7234
|
+
}
|
|
7235
|
+
//#endregion
|
|
7236
|
+
//#region lib/types/routes/relay-quotas.js
|
|
7237
|
+
/**
|
|
7238
|
+
* 路由:GET /api/billing/relay-quotas
|
|
7239
|
+
*
|
|
7240
|
+
* 中转站额度(New API / Sub2API 余额 + 滚动窗口)。5 分钟缓存 + in-flight
|
|
7241
|
+
* 去重。诊断(每条路由的 origin / kind)随响应下发,供「我的中转站为什么不显
|
|
7242
|
+
* 示」自查。
|
|
7243
|
+
*
|
|
7244
|
+
* 路由文件只关心 HTTP 缓存 + 诊断序列化。上游查询(queryRelayQuotas +
|
|
7245
|
+
* 路由筛选 + isOfficialBaseUrl 排除)在装配器里跑。
|
|
7246
|
+
*/
|
|
7247
|
+
function registerRelayQuotasRoute(ctx, deps) {
|
|
7248
|
+
ctx.effect(() => ctx.webServer.register({
|
|
7249
|
+
kind: "exact",
|
|
7250
|
+
path: "/api/billing/relay-quotas",
|
|
7251
|
+
handler: async (req, res) => {
|
|
7252
|
+
if (!guardLoopback(req, res, deps.trustedHosts)) return;
|
|
7253
|
+
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
7254
|
+
if (Date.now() - deps.cached().at >= deps.cacheMs) try {
|
|
7255
|
+
await deps.refreshOnce();
|
|
7256
|
+
} catch (error) {
|
|
7257
|
+
console.warn("[usage-billing] refreshRelay failed; serving cached relay quotas:", error);
|
|
7258
|
+
}
|
|
7259
|
+
const diagnostics = deps.cached().quotas.map((row) => ({
|
|
7260
|
+
route: row.route,
|
|
7261
|
+
origin: row.origin,
|
|
7262
|
+
kind: row.kind
|
|
7263
|
+
}));
|
|
7264
|
+
res.end(JSON.stringify({
|
|
7265
|
+
quotas: deps.cached().quotas,
|
|
7266
|
+
diagnostics
|
|
7267
|
+
}));
|
|
7268
|
+
}
|
|
7269
|
+
}), "usage-billing: relay-quotas route");
|
|
7270
|
+
}
|
|
7271
|
+
//#endregion
|
|
7272
|
+
//#region lib/types/routes/usage-stats.js
|
|
7273
|
+
/**
|
|
7274
|
+
* 路由:GET /api/billing/usage-stats
|
|
7275
|
+
*
|
|
7276
|
+
* 实时用量聚合文档(byDay / byModel / bySession / bySite / byTurn / perf 等)。
|
|
7277
|
+
* stale-while-revalidate:聚合是全量扫描(重度用户上万条会话的冷启动折叠以
|
|
7278
|
+
* 分钟计),HTTP 请求绝不全程等它——只给 RESPONSE_BUDGET_MS 的等待预算,超
|
|
7279
|
+
* 预算立即回最近一次快照;后台折叠完成后快照随之刷新(挂在聚合 promise 上,
|
|
7280
|
+
* 与响应路径解耦)。宿主是单进程,慢响应期间事件循环被折叠占住,会话/模型
|
|
7281
|
+
* 列表 RPC 会一起饿死(用户反馈的启动卡顿)。
|
|
7282
|
+
*
|
|
7283
|
+
* 路由文件只关心 HTTP 拼装:
|
|
7284
|
+
* - decorate(注入 PACKAGE_VERSION / 预算 / 阈值)
|
|
7285
|
+
* - 聚合预算(Promise.race + setTimeout.unref)
|
|
7286
|
+
* - 响应体缓存(`statsBodyCache`:装饰 + 序列化结果按 doc 引用 + config 字段
|
|
7287
|
+
* 缓存,30s 轮询期间复用)
|
|
7288
|
+
* - 失败路径(聚合失败 → 读快照候选 → 仍无则 `{ error }`)
|
|
7289
|
+
*
|
|
7290
|
+
* 聚合器(`aggregator.aggregate()` / `sessionUsageOf()`)+ 快照读写 + 配置在
|
|
7291
|
+
* 装配器里跑,路由只通过 Deps 拿到回调。
|
|
7292
|
+
*/
|
|
7293
|
+
/** 聚合文档核心结构的 wire 校验(S-7 可观察性金丝雀)。
|
|
7294
|
+
*
|
|
7295
|
+
* 客户端 loadUsageStats 已有逐字段 typeof 守卫(渲染层兜底),本校验是
|
|
7296
|
+
* 服务端边界的漂移告警:聚合器字段改名/类型漂移时,30s 轮询的第一轮就在
|
|
7297
|
+
* 宿主日志留下 `[usage-billing]` 告警,而不是等用户面板渲染崩了报 issue。
|
|
7298
|
+
*
|
|
7299
|
+
* 语义是「告警不拦截」:校验失败仍照常下发(客户端自行兜底),绝不因
|
|
7300
|
+
* schema 漂移把仪表盘整个 500——与「degrade, never fabricate」同一姿态。
|
|
7301
|
+
*
|
|
7302
|
+
* 只校验 fresh 聚合路径(快照兜底路径不校验:旧快照本就可能缺新字段,
|
|
7303
|
+
* 校验它们只会产生永久的假阳性告警)。
|
|
7304
|
+
*
|
|
7305
|
+
* 实现说明:刻意手写 typeof 检查而不用 schemastery——schemastery 是 config
|
|
7306
|
+
* 库,object schema 对 null / 数组 / 标量输入走宽容强转(补默认值)而非抛错,
|
|
7307
|
+
* 无法表达「形状必须精确」的校验语义。手写检查只认精确类型,缺字段即漂移。
|
|
7308
|
+
* 导出供测试。 */
|
|
7309
|
+
const NUM = (v) => typeof v === "number" && Number.isFinite(v);
|
|
7310
|
+
/** 一个 ModelUsage 桶的核心数字字段(客户端渲染硬依赖)。
|
|
7311
|
+
* `withReasoning`:byModel / byDay / total 的桶含 reasoning;byDayModels 的
|
|
7312
|
+
* 三维格接口只有 6 个数字字段(无 reasoning),复用同一检查会产生假阳性。 */
|
|
7313
|
+
function modelUsageError(value, path, withReasoning = true) {
|
|
7314
|
+
if (value === null || typeof value !== "object") return `${path} 不是对象`;
|
|
7315
|
+
const keys = withReasoning ? [
|
|
7316
|
+
"calls",
|
|
7317
|
+
"input",
|
|
7318
|
+
"output",
|
|
7319
|
+
"cacheHit",
|
|
7320
|
+
"cacheMiss",
|
|
7321
|
+
"cost",
|
|
7322
|
+
"reasoning"
|
|
7323
|
+
] : [
|
|
7324
|
+
"calls",
|
|
7325
|
+
"input",
|
|
7326
|
+
"output",
|
|
7327
|
+
"cacheHit",
|
|
7328
|
+
"cacheMiss",
|
|
7329
|
+
"cost"
|
|
7330
|
+
];
|
|
7331
|
+
for (const key of keys) if (!NUM(value[key])) return `${path}.${key} 不是有限数字`;
|
|
7332
|
+
return null;
|
|
7333
|
+
}
|
|
7334
|
+
/** 校验一份聚合文档;返回漂移原因(null = 形状正常)。纯只读,不改 doc。 */
|
|
7335
|
+
function usageStatsShapeError(doc) {
|
|
7336
|
+
if (doc === null || typeof doc !== "object" || Array.isArray(doc)) return "文档不是对象";
|
|
7337
|
+
const d = doc;
|
|
7338
|
+
if (!NUM(d.version)) return "version 不是有限数字";
|
|
7339
|
+
if (!NUM(d.updatedAt)) return "updatedAt 不是有限数字";
|
|
7340
|
+
const total = modelUsageError(d.total, "total");
|
|
7341
|
+
if (total !== null) return total;
|
|
7342
|
+
for (const [field, cellCheck] of [
|
|
7343
|
+
["byModel", (cell, p) => modelUsageError(cell, p)],
|
|
7344
|
+
["byDay", (cell, p) => modelUsageError(cell, p)],
|
|
7345
|
+
["byDayModels", (cell, p) => {
|
|
7346
|
+
if (cell === null || typeof cell !== "object") return `${p} 不是对象`;
|
|
7347
|
+
for (const [modelKey, usage] of Object.entries(cell)) {
|
|
7348
|
+
const err = modelUsageError(usage, `${p}.${modelKey}`, false);
|
|
7349
|
+
if (err !== null) return err;
|
|
7350
|
+
}
|
|
7351
|
+
return null;
|
|
7352
|
+
}]
|
|
7353
|
+
]) {
|
|
7354
|
+
const bucket = d[field];
|
|
7355
|
+
if (bucket === void 0) return `${field} 缺失(聚合路径恒带;快照兜底才允许缺)`;
|
|
7356
|
+
if (bucket === null || typeof bucket !== "object") return `${field} 不是对象`;
|
|
7357
|
+
for (const [key, cell] of Object.entries(bucket)) {
|
|
7358
|
+
const err = cellCheck(cell, `${field}.${key}`);
|
|
7359
|
+
if (err !== null) return err;
|
|
7360
|
+
}
|
|
7361
|
+
}
|
|
7362
|
+
const bySession = d.bySession;
|
|
7363
|
+
if (!Array.isArray(bySession)) return "bySession 不是数组";
|
|
7364
|
+
for (const [index, row] of bySession.entries()) {
|
|
7365
|
+
if (row === null || typeof row !== "object") return `bySession[${index}] 不是对象`;
|
|
7366
|
+
const r = row;
|
|
7367
|
+
if (typeof r.id !== "string" || r.id === "") return `bySession[${index}].id 不是非空字符串`;
|
|
7368
|
+
if (!NUM(r.calls)) return `bySession[${index}].calls 不是有限数字`;
|
|
7369
|
+
if (!NUM(r.cost)) return `bySession[${index}].cost 不是有限数字`;
|
|
7370
|
+
if (!NUM(r.lastActive)) return `bySession[${index}].lastActive 不是有限数字`;
|
|
7371
|
+
}
|
|
7372
|
+
return null;
|
|
7373
|
+
}
|
|
7374
|
+
function registerUsageStatsRoute(ctx, deps) {
|
|
7375
|
+
let statsBodyCache;
|
|
7376
|
+
ctx.effect(() => ctx.webServer.register({
|
|
7377
|
+
kind: "exact",
|
|
7378
|
+
path: "/api/billing/usage-stats",
|
|
7379
|
+
handler: async (req, res) => {
|
|
7380
|
+
if (!guardLoopback(req, res, deps.trustedHosts)) return;
|
|
7381
|
+
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
7382
|
+
const decorate = (doc) => ({
|
|
7383
|
+
...doc,
|
|
7384
|
+
pluginVersion: deps.packageVersion,
|
|
7385
|
+
...deps.config.monthlyBudget === void 0 ? {} : { budget: deps.config.monthlyBudget },
|
|
7386
|
+
...deps.config.lowBalanceThreshold === void 0 ? {} : { lowBalanceThreshold: deps.config.lowBalanceThreshold }
|
|
7387
|
+
});
|
|
7388
|
+
const statsBodyOf = (doc) => {
|
|
7389
|
+
const budget = deps.config.monthlyBudget;
|
|
7390
|
+
const threshold = deps.config.lowBalanceThreshold;
|
|
7391
|
+
if (statsBodyCache !== void 0 && statsBodyCache.doc === doc && statsBodyCache.budget === budget && statsBodyCache.threshold === threshold) return statsBodyCache.body;
|
|
7392
|
+
const shapeError = usageStatsShapeError(doc);
|
|
7393
|
+
if (shapeError !== null) console.warn("[usage-billing] usage-stats document shape drift (serving anyway):", shapeError);
|
|
7394
|
+
const body = JSON.stringify(decorate(doc));
|
|
7395
|
+
statsBodyCache = {
|
|
7396
|
+
doc,
|
|
7397
|
+
budget,
|
|
7398
|
+
threshold,
|
|
7399
|
+
body
|
|
7400
|
+
};
|
|
7401
|
+
return body;
|
|
7402
|
+
};
|
|
7403
|
+
const aggregating = deps.aggregator.aggregate();
|
|
7404
|
+
aggregating.then((stats) => {
|
|
7405
|
+
deps.persistSnapshot(decorate(stats));
|
|
7406
|
+
}, () => {});
|
|
7407
|
+
let fresh;
|
|
7408
|
+
try {
|
|
7409
|
+
fresh = await Promise.race([aggregating.then((stats) => stats), new Promise((resolve) => {
|
|
7410
|
+
setTimeout(() => {
|
|
7411
|
+
resolve(void 0);
|
|
7412
|
+
}, deps.responseBudgetMs).unref();
|
|
7413
|
+
})]);
|
|
7414
|
+
} catch (error) {
|
|
7415
|
+
console.error("[usage-billing] usage-stats aggregate failed, falling back to snapshot:", error);
|
|
7416
|
+
}
|
|
7417
|
+
if (fresh !== void 0) {
|
|
7418
|
+
res.end(statsBodyOf(fresh));
|
|
7419
|
+
return;
|
|
7420
|
+
}
|
|
7421
|
+
for (const candidate of deps.candidates) {
|
|
7422
|
+
const doc = await deps.readSnapshot(candidate);
|
|
7423
|
+
if (doc === null) continue;
|
|
7424
|
+
res.end(JSON.stringify(decorate(doc)));
|
|
7425
|
+
return;
|
|
7426
|
+
}
|
|
7427
|
+
res.end(JSON.stringify({ error: "usage stats unavailable" }));
|
|
7428
|
+
}
|
|
7429
|
+
}), "usage-billing: usage-stats route");
|
|
7430
|
+
}
|
|
7431
|
+
//#endregion
|
|
7432
|
+
//#region lib/types/client/usage-billing-settings.js
|
|
7433
|
+
/** 设置命名空间 id(小写 kebab-case)。 */
|
|
7434
|
+
const BILLING_SETTINGS_NAMESPACE = "ui-usage-billing";
|
|
7435
|
+
/** 该命名空间下用户可编辑的字段名。 */
|
|
7436
|
+
const ENABLE_USAGE_STATS_TOOL_FIELD = "enableUsageStatsTool";
|
|
7437
|
+
//#endregion
|
|
7438
|
+
//#region lib/types/index.js
|
|
7439
|
+
/**
|
|
7440
|
+
* Usage billing surface plugin, node half.
|
|
7441
|
+
*
|
|
7442
|
+
* Serves `/api/billing/usage-stats`: real usage aggregated from every
|
|
7443
|
+
* persisted session log (see `aggregate.ts`) — the browser dashboard reads it
|
|
7444
|
+
* instead of showing an empty snapshot. Aggregation is stale-while-revalidate:
|
|
7445
|
+
* requests get a short wait budget and fall back to the freshest persisted
|
|
7446
|
+
* snapshot while a slow full fold keeps running in the background (a heavy
|
|
7447
|
+
* user's first fold takes minutes and must not starve the single-process
|
|
7448
|
+
* host's RPC). When `sessionPersistence` is unavailable (or aggregation
|
|
7449
|
+
* fails), the configured `statsPath` / `DSH_USAGE_STATS` / conventional JSON
|
|
7450
|
+
* file is served as a fallback, and a missing file answers `{ error }` so the
|
|
7451
|
+
* dashboard shows zeros, never fabricated samples.
|
|
7452
|
+
*/
|
|
7453
|
+
const SETTINGS_NAMESPACE_PATTERN = /^[a-z][a-z0-9-]*$/;
|
|
7454
|
+
/** 校验并透过合法命名空间 id;非法值 fail-loud(与上游原行为一致)。 */
|
|
7455
|
+
function validateSettingsNamespace(value) {
|
|
7456
|
+
if (!SETTINGS_NAMESPACE_PATTERN.test(value)) throw new TypeError(`settings namespace "${value}" must match ${String(SETTINGS_NAMESPACE_PATTERN)}`);
|
|
7457
|
+
return value;
|
|
7458
|
+
}
|
|
7459
|
+
/** usage_stats 工具开关的设置命名空间 id(下端与 node 共用同一常量)。 */
|
|
7460
|
+
const usageBillingSettingsNs = validateSettingsNamespace(BILLING_SETTINGS_NAMESPACE);
|
|
7461
|
+
/** 该命名空间的 wire schema:`enableUsageStatsTool` 布尔,默认关闭(issue 诉求)。 */
|
|
7462
|
+
const UsageBillingSettingsSchema = z.object({ [ENABLE_USAGE_STATS_TOOL_FIELD]: z.boolean().default(false) });
|
|
7463
|
+
/** 实时定价的后台刷新间隔(毫秒):汇率/模型价低频变化,6 小时一次足够。 */
|
|
7464
|
+
/** 实时定价的后台刷新间隔(毫秒):汇率/模型价低频变化,6 小时一次足够。 */
|
|
7465
|
+
const PRICING_REFRESH_INTERVAL_MS = 216e5;
|
|
7466
|
+
/** 历史回放预热的延迟:等宿主启动高峰(插件加载 / 路由挂载)过去再全量折叠,
|
|
7467
|
+
* 避免抢启动期的 CPU;纯延迟不阻塞任何请求,面板提前打开也只会提前聚合。 */
|
|
7468
|
+
const WARMUP_DELAY_MS = 3e3;
|
|
7469
|
+
/** 订阅套餐额度缓存时长(毫秒):上游配额 API 低频变化,5 分钟足够。 */
|
|
7470
|
+
const SUBSCRIPTION_CACHE_MS = 3e5;
|
|
7471
|
+
const BALANCE_CACHE_MS = 3e5;
|
|
7472
|
+
/** DeepSeek 余额查询的默认凭据引用(与 llm-deepseek 的默认引用一致)。 */
|
|
7473
|
+
const DEFAULT_BALANCE_API_KEY_ENV = "DEEPSEEK_API_KEY";
|
|
7474
|
+
/**
|
|
7475
|
+
* 本插件版本号:从包自身的 package.json 读取(单一来源),随 usage-stats
|
|
7476
|
+
* 下发,供「设置 → 插件信息卡」展示。发布版 lib/index.js 相对包根解析。
|
|
7477
|
+
*/
|
|
7478
|
+
const PACKAGE_VERSION = createRequire(import.meta.url)("../package.json").version ?? "0.0.0";
|
|
7479
|
+
/** 统计快照的落盘节流(毫秒):快照只服务聚合失败回退与重启首屏(展示级
|
|
7480
|
+
* 新鲜度即可),降到 5 分钟一次——原子写含上一版备份是两次全量 JSON 落盘,
|
|
7481
|
+
* 重度用户快照上百 KB,跟随前端轮询 30 秒写一次纯属写放大。 */
|
|
7482
|
+
const SNAPSHOT_INTERVAL_MS = 3e5;
|
|
7483
|
+
/** usage-stats 响应的等待预算(毫秒):聚合在此预算内完成就回实时数据,
|
|
7484
|
+
* 超预算立即回最近快照(stale-while-revalidate),后台折叠继续跑。
|
|
7485
|
+
* 轻量用户暖缓存下的聚合是毫秒级,预算几乎不会触发。 */
|
|
7486
|
+
const RESPONSE_BUDGET_MS = 1500;
|
|
7487
|
+
/** 鉴权失败告警冷却(毫秒):同一 provider 在窗口内只提示一次,避免 30 秒轮询刷屏。 */
|
|
7488
|
+
const AUTH_WARN_COOLDOWN_MS = 18e5;
|
|
7489
|
+
/**
|
|
7490
|
+
* 鉴权失败分类告警(P1-5):余额 / 订阅查询返回 unauthorized 时,按
|
|
7491
|
+
* `source:provider` 去重并冷却告警,提示检查 llm-pi-ai 里该 provider 的 apiKeyEnv。
|
|
7492
|
+
*/
|
|
7493
|
+
const authWarnedAt = /* @__PURE__ */ new Map();
|
|
7494
|
+
function warnAuthOnce(source, provider, displayName) {
|
|
7495
|
+
const key = `${source}:${provider}`;
|
|
7496
|
+
const now = Date.now();
|
|
7497
|
+
const last = authWarnedAt.get(key);
|
|
7498
|
+
if (last !== void 0 && now - last < AUTH_WARN_COOLDOWN_MS) return;
|
|
7499
|
+
authWarnedAt.set(key, now);
|
|
7500
|
+
console.warn(`[usage-billing] ${displayName}(${provider})鉴权失败:请检查 llm-pi-ai 设置中该 provider 的凭据环境变量是否正确/有效。`);
|
|
7501
|
+
}
|
|
7502
|
+
/** 锁等待上限:账本提交是毫秒级 read-render-commit,2s 已数倍于正常持锁时长。 */
|
|
7503
|
+
const LEDGER_LOCK_WAIT_MS = 2e3;
|
|
7504
|
+
/**
|
|
7505
|
+
* 锁年龄超过该值判为崩溃残留的孤儿锁。正常持锁是毫秒级文件提交,没有活跃
|
|
7506
|
+
* writer 会合法持有 60s;库层刻意不做年龄判定(它无法假设调用方的操作时长),
|
|
7507
|
+
* 本调用方知道自己操作的上界,可以给出有依据的清理阈值。
|
|
7508
|
+
*/
|
|
7509
|
+
const STALE_LEDGER_LOCK_MS = 6e4;
|
|
7510
|
+
/**
|
|
7511
|
+
* 锁等待超时后的孤儿锁自愈(issue #48):仅当锁文件年龄超过
|
|
7512
|
+
* STALE_LEDGER_LOCK_MS 才清理,避免误删慢 writer 的活跃锁。
|
|
7513
|
+
* @param ledgerPath - 账本文件路径(锁为其 `.lock` 兄弟文件)。
|
|
7514
|
+
* @returns 是否清理了孤儿锁;false = 锁已消失或太新鲜,调用方上抛原错误。
|
|
7515
|
+
*/
|
|
7516
|
+
async function reclaimStaleLedgerLock(ledgerPath) {
|
|
7517
|
+
let ageMs;
|
|
7518
|
+
try {
|
|
7519
|
+
ageMs = Date.now() - (await lstat(`${ledgerPath}.lock`)).mtimeMs;
|
|
7520
|
+
} catch {
|
|
7521
|
+
return false;
|
|
7522
|
+
}
|
|
7523
|
+
if (ageMs < STALE_LEDGER_LOCK_MS) return false;
|
|
7524
|
+
console.warn(`[usage-billing] 账本锁已存在 ${Math.round(ageMs / 1e3)}s(崩溃残留),清理后重试:${ledgerPath}.lock`);
|
|
7525
|
+
await rm(`${ledgerPath}.lock`, { force: true });
|
|
7526
|
+
return true;
|
|
7527
|
+
}
|
|
7528
|
+
/**
|
|
7529
|
+
* Create the atomic file-backed durable-ledger store. The previous complete file
|
|
7530
|
+
* is retained as `.bak`; a malformed/missing main file falls back to that backup.
|
|
7531
|
+
*/
|
|
7532
|
+
function createFileUsageLedgerStore(ledgerPath) {
|
|
7533
|
+
return {
|
|
7534
|
+
async load() {
|
|
7535
|
+
for (const path of [ledgerPath, `${ledgerPath}.bak`]) try {
|
|
7536
|
+
const parsed = JSON.parse(await readFile(path, "utf8"));
|
|
7537
|
+
if (parsed !== null && typeof parsed === "object") {
|
|
7538
|
+
const candidate = parsed;
|
|
7539
|
+
if (candidate.version === 1 && Array.isArray(candidate.sessions)) return parsed;
|
|
7540
|
+
}
|
|
7541
|
+
} catch {
|
|
7542
|
+
swallowed("ledger-main-unreadable");
|
|
7543
|
+
}
|
|
7544
|
+
},
|
|
7545
|
+
async save(document) {
|
|
7546
|
+
await mkdir(dirname(ledgerPath), {
|
|
7547
|
+
recursive: true,
|
|
7548
|
+
mode: 448
|
|
7549
|
+
});
|
|
7550
|
+
const commit = async () => {
|
|
7551
|
+
let existing;
|
|
7552
|
+
let existingDoc;
|
|
7553
|
+
try {
|
|
7554
|
+
existing = await readFile(ledgerPath, "utf8");
|
|
7555
|
+
const parsed = JSON.parse(existing);
|
|
7556
|
+
if (parsed.version === 1 && Array.isArray(parsed.sessions)) existingDoc = parsed;
|
|
7557
|
+
} catch {
|
|
7558
|
+
existing = void 0;
|
|
7559
|
+
}
|
|
7560
|
+
if (existing !== void 0) await writeFileAtomic(`${ledgerPath}.bak`, existing, {
|
|
7561
|
+
mode: 384,
|
|
7562
|
+
dirMode: 448
|
|
7563
|
+
});
|
|
7564
|
+
const merged = existingDoc === void 0 ? document : mergeUsageLedgerDocuments(document, existingDoc);
|
|
7565
|
+
await writeFileAtomic(ledgerPath, JSON.stringify(merged), {
|
|
7566
|
+
mode: 384,
|
|
7567
|
+
dirMode: 448
|
|
7568
|
+
});
|
|
7569
|
+
};
|
|
7570
|
+
try {
|
|
7571
|
+
await withFileLock(ledgerPath, commit, { waitMs: LEDGER_LOCK_WAIT_MS });
|
|
7572
|
+
} catch (error) {
|
|
7573
|
+
if (!(error instanceof Error) || !error.message.includes("timed out waiting for the writer lock")) throw error;
|
|
7574
|
+
if (!await reclaimStaleLedgerLock(ledgerPath)) throw error;
|
|
7575
|
+
await withFileLock(ledgerPath, commit, { waitMs: LEDGER_LOCK_WAIT_MS });
|
|
7576
|
+
}
|
|
7577
|
+
}
|
|
7578
|
+
};
|
|
7579
|
+
}
|
|
7580
|
+
/** Required services: the web server, the persisted session log store, and user settings. */
|
|
7581
|
+
const inject = [
|
|
7582
|
+
"webServer",
|
|
7583
|
+
"sessionPersistence",
|
|
7584
|
+
"credentials",
|
|
7585
|
+
"settings"
|
|
7586
|
+
];
|
|
7587
|
+
/**
|
|
7588
|
+
* 订阅 provider id(llm-pi-ai 设置键)→ billing 适配器 key 的映射。
|
|
7589
|
+
* 复用 dsh 既有的 llm-pi-ai provider 配置(apiKeyEnv 引用),不引入新配置面。
|
|
7590
|
+
*/
|
|
7591
|
+
/** key 只取字符串凭据字段:zaiRegion 是区域枚举,由下方区域逻辑单独赋值。 */
|
|
7592
|
+
const SUBSCRIPTION_KEY_SOURCES = [
|
|
7593
|
+
{
|
|
7594
|
+
provider: "kimi-coding",
|
|
7595
|
+
key: "kimiApiKey"
|
|
7596
|
+
},
|
|
7597
|
+
{
|
|
7598
|
+
provider: "zai-coding-cn",
|
|
7599
|
+
key: "zaiCnApiKey"
|
|
7600
|
+
},
|
|
7601
|
+
{
|
|
7602
|
+
provider: "zai-coding",
|
|
7603
|
+
key: "zaiApiKey"
|
|
7604
|
+
},
|
|
7605
|
+
{
|
|
7606
|
+
provider: "opencode",
|
|
7607
|
+
key: "opencodeApiKey"
|
|
7608
|
+
},
|
|
7609
|
+
{
|
|
7610
|
+
provider: "opencode-go",
|
|
7611
|
+
key: "opencodeApiKey"
|
|
7612
|
+
},
|
|
7613
|
+
{
|
|
7047
7614
|
provider: "minimax",
|
|
7048
7615
|
key: "minmaxApiKey"
|
|
7049
7616
|
},
|
|
@@ -7217,7 +7784,9 @@ async function resolveSubscriptionKeys(settings, credentials) {
|
|
|
7217
7784
|
try {
|
|
7218
7785
|
const resolved = await credentials.resolve(credentialRef(env));
|
|
7219
7786
|
if (resolved?.value !== void 0 && resolved.value !== "") keys[key] = resolved.value;
|
|
7220
|
-
} catch {
|
|
7787
|
+
} catch (error) {
|
|
7788
|
+
swallowed("credential-resolve-failed", error);
|
|
7789
|
+
}
|
|
7221
7790
|
}
|
|
7222
7791
|
if (providers?.["zai-coding-cn"]?.apiKeyEnv !== void 0 && keys.zaiCnApiKey !== "") keys.zaiRegion = "bigmodel-cn";
|
|
7223
7792
|
if (keys.opencodeApiKey === "") keys.opencodeApiKey = await readOpenCodeToken();
|
|
@@ -7243,7 +7812,9 @@ async function resolveSubscriptionKeys(settings, credentials) {
|
|
|
7243
7812
|
async function readOpenCodeToken() {
|
|
7244
7813
|
try {
|
|
7245
7814
|
return parseOpenCodeAuthDocument(JSON.parse(await readFile(join(homedir(), ".local", "share", "opencode", "auth.json"), "utf8")));
|
|
7246
|
-
} catch {
|
|
7815
|
+
} catch {
|
|
7816
|
+
swallowed("opencode-auth-missing");
|
|
7817
|
+
}
|
|
7247
7818
|
return "";
|
|
7248
7819
|
}
|
|
7249
7820
|
/**
|
|
@@ -7335,7 +7906,9 @@ function apply(ctx, config = {}) {
|
|
|
7335
7906
|
const candidate = ref;
|
|
7336
7907
|
if (typeof candidate.date === "string" && typeof candidate.total === "number" && typeof candidate.granted === "number" && typeof candidate.topped === "number" && typeof candidate.currency === "string") reconcileRef = candidate;
|
|
7337
7908
|
}
|
|
7338
|
-
} catch {
|
|
7909
|
+
} catch {
|
|
7910
|
+
swallowed("reconcile-ref-missing");
|
|
7911
|
+
}
|
|
7339
7912
|
})();
|
|
7340
7913
|
const persistReconcileRef = () => {
|
|
7341
7914
|
if (reconcileRef === null) return;
|
|
@@ -7404,83 +7977,32 @@ function apply(ctx, config = {}) {
|
|
|
7404
7977
|
mode: 384,
|
|
7405
7978
|
dirMode: 448
|
|
7406
7979
|
});
|
|
7407
|
-
} catch {
|
|
7980
|
+
} catch {
|
|
7981
|
+
swallowed("snapshot-bak-missing");
|
|
7982
|
+
}
|
|
7408
7983
|
try {
|
|
7409
7984
|
await writeFileAtomic(snapshotPath, payload, {
|
|
7410
7985
|
mode: 384,
|
|
7411
7986
|
dirMode: 448
|
|
7412
7987
|
});
|
|
7413
|
-
} catch {
|
|
7988
|
+
} catch {
|
|
7989
|
+
swallowed("snapshot-write-failed");
|
|
7990
|
+
}
|
|
7414
7991
|
})();
|
|
7415
7992
|
};
|
|
7416
7993
|
/** 读一个快照候选并解析成对象:主文件优先;主文件损坏时回退上一版 `.bak`(P0-3 崩溃恢复)。 */
|
|
7417
7994
|
const readSnapshot = async (candidate) => {
|
|
7418
7995
|
for (const path of [candidate, `${candidate}.bak`]) try {
|
|
7419
7996
|
return JSON.parse(await readFile(path, "utf8"));
|
|
7420
|
-
} catch {
|
|
7997
|
+
} catch {
|
|
7998
|
+
swallowed("snapshot-candidate-unreadable");
|
|
7999
|
+
}
|
|
7421
8000
|
return null;
|
|
7422
8001
|
};
|
|
7423
|
-
|
|
7424
|
-
|
|
7425
|
-
|
|
7426
|
-
|
|
7427
|
-
};
|
|
7428
|
-
ctx.effect(() => ctx.webServer.register({
|
|
7429
|
-
kind: "exact",
|
|
7430
|
-
path: "/api/billing/notify-claim",
|
|
7431
|
-
handler: async (req, res) => {
|
|
7432
|
-
if (!guardLoopback(req, res, trustedHosts)) return;
|
|
7433
|
-
if (req.method !== "POST") {
|
|
7434
|
-
res.writeHead(405, { "content-type": "application/json; charset=utf-8" });
|
|
7435
|
-
res.end(JSON.stringify({ error: "method not allowed" }));
|
|
7436
|
-
return;
|
|
7437
|
-
}
|
|
7438
|
-
if (!isLoopbackOrigin(req.headers.origin) || !(req.headers["content-type"] ?? "").toLowerCase().includes("application/json")) {
|
|
7439
|
-
res.writeHead(403, { "content-type": "application/json; charset=utf-8" });
|
|
7440
|
-
res.end(JSON.stringify({ error: "forbidden: loopback only" }));
|
|
7441
|
-
return;
|
|
7442
|
-
}
|
|
7443
|
-
try {
|
|
7444
|
-
let body = "";
|
|
7445
|
-
for await (const chunk of req) {
|
|
7446
|
-
body += String(chunk);
|
|
7447
|
-
if (body.length > 2048) {
|
|
7448
|
-
res.end(JSON.stringify({ error: "body too large" }));
|
|
7449
|
-
return;
|
|
7450
|
-
}
|
|
7451
|
-
}
|
|
7452
|
-
const parsed = JSON.parse(body === "" ? "{}" : body);
|
|
7453
|
-
if (typeof parsed.key !== "string" || parsed.key === "" || parsed.key.length > 200) {
|
|
7454
|
-
res.writeHead(400, { "content-type": "application/json; charset=utf-8" });
|
|
7455
|
-
res.end(JSON.stringify({ error: "invalid key" }));
|
|
7456
|
-
return;
|
|
7457
|
-
}
|
|
7458
|
-
if (!notifyClaims.loaded) {
|
|
7459
|
-
notifyClaims.loaded = true;
|
|
7460
|
-
try {
|
|
7461
|
-
const stored = JSON.parse(await readFile(notifyClaimsPath, "utf8"));
|
|
7462
|
-
if (Array.isArray(stored.keys)) notifyClaims.keys = stored.keys.filter((k) => typeof k === "string");
|
|
7463
|
-
} catch {}
|
|
7464
|
-
}
|
|
7465
|
-
if (notifyClaims.keys.includes(parsed.key)) {
|
|
7466
|
-
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
7467
|
-
res.end(JSON.stringify({ claimed: false }));
|
|
7468
|
-
return;
|
|
7469
|
-
}
|
|
7470
|
-
notifyClaims.keys.push(parsed.key);
|
|
7471
|
-
if (notifyClaims.keys.length > 400) notifyClaims.keys = notifyClaims.keys.slice(-200);
|
|
7472
|
-
writeFileAtomic(notifyClaimsPath, JSON.stringify({ keys: notifyClaims.keys }), {
|
|
7473
|
-
mode: 384,
|
|
7474
|
-
dirMode: 448
|
|
7475
|
-
}).catch(() => {});
|
|
7476
|
-
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
7477
|
-
res.end(JSON.stringify({ claimed: true }));
|
|
7478
|
-
} catch {
|
|
7479
|
-
res.writeHead(400, { "content-type": "application/json; charset=utf-8" });
|
|
7480
|
-
res.end(JSON.stringify({ error: "invalid" }));
|
|
7481
|
-
}
|
|
7482
|
-
}
|
|
7483
|
-
}), "usage-billing: notify-claim route");
|
|
8002
|
+
registerNotifyClaimRoute(ctx, {
|
|
8003
|
+
trustedHosts,
|
|
8004
|
+
claimsPath: `${snapshotPath}.notify-claims.json`
|
|
8005
|
+
});
|
|
7484
8006
|
let usageToolDisposer;
|
|
7485
8007
|
let usageToolRegister;
|
|
7486
8008
|
const syncUsageTool = () => {
|
|
@@ -7681,40 +8203,11 @@ function apply(ctx, config = {}) {
|
|
|
7681
8203
|
clearInterval(timer);
|
|
7682
8204
|
};
|
|
7683
8205
|
}, "usage-billing: pricing refresh timer");
|
|
7684
|
-
ctx
|
|
7685
|
-
|
|
7686
|
-
|
|
7687
|
-
|
|
7688
|
-
|
|
7689
|
-
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
7690
|
-
res.end(pricingBody());
|
|
7691
|
-
}
|
|
7692
|
-
}), "usage-billing: pricing route");
|
|
7693
|
-
ctx.effect(() => ctx.webServer.register({
|
|
7694
|
-
kind: "exact",
|
|
7695
|
-
path: "/api/billing/pricing/refresh",
|
|
7696
|
-
handler: async (req, res) => {
|
|
7697
|
-
if (!guardLoopback(req, res, trustedHosts)) return;
|
|
7698
|
-
if (req.method !== "POST") {
|
|
7699
|
-
res.writeHead(405, { "content-type": "application/json; charset=utf-8" });
|
|
7700
|
-
res.end(JSON.stringify({ error: "method not allowed" }));
|
|
7701
|
-
return;
|
|
7702
|
-
}
|
|
7703
|
-
if (!isLoopbackOrigin(req.headers.origin)) {
|
|
7704
|
-
res.writeHead(403, { "content-type": "application/json; charset=utf-8" });
|
|
7705
|
-
res.end(JSON.stringify({ error: "forbidden: loopback only" }));
|
|
7706
|
-
return;
|
|
7707
|
-
}
|
|
7708
|
-
if (!(req.headers["content-type"] ?? "").toLowerCase().includes("application/json")) {
|
|
7709
|
-
res.writeHead(415, { "content-type": "application/json; charset=utf-8" });
|
|
7710
|
-
res.end(JSON.stringify({ error: "unsupported content-type" }));
|
|
7711
|
-
return;
|
|
7712
|
-
}
|
|
7713
|
-
await refreshPricing();
|
|
7714
|
-
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
7715
|
-
res.end(pricingBody());
|
|
7716
|
-
}
|
|
7717
|
-
}), "usage-billing: pricing refresh route");
|
|
8206
|
+
registerPricingRoutes(ctx, {
|
|
8207
|
+
trustedHosts,
|
|
8208
|
+
body: pricingBody,
|
|
8209
|
+
refresh: refreshPricing
|
|
8210
|
+
});
|
|
7718
8211
|
let balanceCache = {
|
|
7719
8212
|
at: 0,
|
|
7720
8213
|
doc: { balances: [] }
|
|
@@ -7745,7 +8238,9 @@ function apply(ctx, config = {}) {
|
|
|
7745
8238
|
let todayOfficialCost = 0;
|
|
7746
8239
|
try {
|
|
7747
8240
|
todayOfficialCost = (await aggregator.aggregate()).byDay[today]?.officialCost ?? 0;
|
|
7748
|
-
} catch {
|
|
8241
|
+
} catch {
|
|
8242
|
+
swallowed("reconcile-aggregate-skipped");
|
|
8243
|
+
}
|
|
7749
8244
|
const result = reconcileBalanceDelta(reconcileRef, official, todayOfficialCost, today, now);
|
|
7750
8245
|
const refChanged = result.ref !== null && result.ref !== reconcileRef;
|
|
7751
8246
|
reconcileRef = result.ref;
|
|
@@ -7775,80 +8270,22 @@ function apply(ctx, config = {}) {
|
|
|
7775
8270
|
});
|
|
7776
8271
|
return balanceInflight;
|
|
7777
8272
|
};
|
|
7778
|
-
ctx
|
|
7779
|
-
|
|
7780
|
-
|
|
7781
|
-
|
|
7782
|
-
|
|
7783
|
-
|
|
7784
|
-
|
|
7785
|
-
|
|
7786
|
-
|
|
7787
|
-
|
|
7788
|
-
|
|
7789
|
-
|
|
7790
|
-
|
|
7791
|
-
|
|
7792
|
-
|
|
7793
|
-
|
|
7794
|
-
}
|
|
7795
|
-
}), "usage-billing: balance route");
|
|
7796
|
-
ctx.effect(() => ctx.webServer.register({
|
|
7797
|
-
kind: "exact",
|
|
7798
|
-
path: "/api/billing/usage-tool",
|
|
7799
|
-
handler: async (req, res) => {
|
|
7800
|
-
if (!guardLoopback(req, res, trustedHosts)) return;
|
|
7801
|
-
if (req.method !== "GET" && req.method !== "POST") {
|
|
7802
|
-
res.writeHead(405, { "content-type": "application/json; charset=utf-8" });
|
|
7803
|
-
res.end(JSON.stringify({ error: "method not allowed" }));
|
|
7804
|
-
return;
|
|
7805
|
-
}
|
|
7806
|
-
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
7807
|
-
const enabled = usageSettingsScope !== void 0 ? usageSettingsScope.get().enableUsageStatsTool : readUsageStatsToggle(config);
|
|
7808
|
-
if (req.method === "GET") {
|
|
7809
|
-
res.end(JSON.stringify({ enabled }));
|
|
7810
|
-
return;
|
|
7811
|
-
}
|
|
7812
|
-
if (!isLoopbackOrigin(req.headers.origin)) {
|
|
7813
|
-
res.writeHead(403, { "content-type": "application/json; charset=utf-8" });
|
|
7814
|
-
res.end(JSON.stringify({ error: "forbidden: loopback only" }));
|
|
7815
|
-
return;
|
|
7816
|
-
}
|
|
7817
|
-
if (!(req.headers["content-type"] ?? "").toLowerCase().includes("application/json")) {
|
|
7818
|
-
res.writeHead(415, { "content-type": "application/json; charset=utf-8" });
|
|
7819
|
-
res.end(JSON.stringify({ error: "unsupported content-type" }));
|
|
7820
|
-
return;
|
|
7821
|
-
}
|
|
7822
|
-
try {
|
|
7823
|
-
let body = "";
|
|
7824
|
-
for await (const chunk of req) {
|
|
7825
|
-
body += String(chunk);
|
|
7826
|
-
if (body.length > 512) {
|
|
7827
|
-
res.end(JSON.stringify({ error: "body too large" }));
|
|
7828
|
-
return;
|
|
7829
|
-
}
|
|
7830
|
-
}
|
|
7831
|
-
const next = JSON.parse(body === "" ? "{}" : body).enabled === true;
|
|
7832
|
-
if (usageSettingsScope !== void 0) {
|
|
7833
|
-
await usageSettingsScope.update({ enableUsageStatsTool: next });
|
|
7834
|
-
syncUsageTool();
|
|
7835
|
-
} else if (liveSettings !== void 0) {
|
|
7836
|
-
await liveSettings.update(liveSettings.ns, { [ENABLE_USAGE_STATS_TOOL_FIELD]: next });
|
|
7837
|
-
syncUsageTool();
|
|
7838
|
-
} else {
|
|
7839
|
-
res.end(JSON.stringify({ error: "settings unavailable" }));
|
|
7840
|
-
return;
|
|
7841
|
-
}
|
|
7842
|
-
res.end(JSON.stringify({
|
|
7843
|
-
ok: true,
|
|
7844
|
-
enabled: next
|
|
7845
|
-
}));
|
|
7846
|
-
} catch (error) {
|
|
7847
|
-
console.warn("[usage-billing] usage-tool toggle write-back failed:", error);
|
|
7848
|
-
res.end(JSON.stringify({ error: "invalid" }));
|
|
7849
|
-
}
|
|
7850
|
-
}
|
|
7851
|
-
}), "usage-billing: usage-tool route");
|
|
8273
|
+
registerBalanceRoute(ctx, {
|
|
8274
|
+
trustedHosts,
|
|
8275
|
+
cached: () => balanceCache,
|
|
8276
|
+
refreshOnce: refreshBalanceOnce,
|
|
8277
|
+
cacheMs: BALANCE_CACHE_MS
|
|
8278
|
+
});
|
|
8279
|
+
registerUsageToolRoute(ctx, {
|
|
8280
|
+
trustedHosts,
|
|
8281
|
+
read: () => usageSettingsScope !== void 0 ? usageSettingsScope.get().enableUsageStatsTool : readUsageStatsToggle(config),
|
|
8282
|
+
write: async (next) => {
|
|
8283
|
+
if (usageSettingsScope !== void 0) await usageSettingsScope.update({ enableUsageStatsTool: next });
|
|
8284
|
+
else if (liveSettings !== void 0) await liveSettings.update(liveSettings.ns, { [ENABLE_USAGE_STATS_TOOL_FIELD]: next });
|
|
8285
|
+
else throw new Error("settings unavailable");
|
|
8286
|
+
},
|
|
8287
|
+
syncTool: syncUsageTool
|
|
8288
|
+
});
|
|
7852
8289
|
let quotaCache = {
|
|
7853
8290
|
at: 0,
|
|
7854
8291
|
quotas: []
|
|
@@ -7892,20 +8329,12 @@ function apply(ctx, config = {}) {
|
|
|
7892
8329
|
});
|
|
7893
8330
|
return quotasInflight;
|
|
7894
8331
|
};
|
|
7895
|
-
ctx
|
|
7896
|
-
|
|
7897
|
-
|
|
7898
|
-
|
|
7899
|
-
|
|
7900
|
-
|
|
7901
|
-
if (Date.now() - quotaCache.at >= SUBSCRIPTION_CACHE_MS) try {
|
|
7902
|
-
await refreshQuotasOnce();
|
|
7903
|
-
} catch (error) {
|
|
7904
|
-
console.warn("[usage-billing] refreshQuotas failed; serving cached quotas:", error);
|
|
7905
|
-
}
|
|
7906
|
-
res.end(JSON.stringify({ quotas: quotaCache.quotas }));
|
|
7907
|
-
}
|
|
7908
|
-
}), "usage-billing: subscriptions route");
|
|
8332
|
+
registerSubscriptionsRoute(ctx, {
|
|
8333
|
+
trustedHosts,
|
|
8334
|
+
cached: () => quotaCache,
|
|
8335
|
+
refreshOnce: refreshQuotasOnce,
|
|
8336
|
+
cacheMs: SUBSCRIPTION_CACHE_MS
|
|
8337
|
+
});
|
|
7909
8338
|
let relayCache = {
|
|
7910
8339
|
at: 0,
|
|
7911
8340
|
quotas: []
|
|
@@ -7935,81 +8364,22 @@ function apply(ctx, config = {}) {
|
|
|
7935
8364
|
});
|
|
7936
8365
|
return relayInflight;
|
|
7937
8366
|
};
|
|
7938
|
-
ctx
|
|
7939
|
-
|
|
7940
|
-
|
|
7941
|
-
|
|
7942
|
-
|
|
7943
|
-
|
|
7944
|
-
|
|
7945
|
-
|
|
7946
|
-
|
|
7947
|
-
|
|
7948
|
-
|
|
7949
|
-
|
|
7950
|
-
|
|
7951
|
-
|
|
7952
|
-
|
|
7953
|
-
|
|
7954
|
-
res.end(JSON.stringify({
|
|
7955
|
-
quotas: relayCache.quotas,
|
|
7956
|
-
diagnostics
|
|
7957
|
-
}));
|
|
7958
|
-
}
|
|
7959
|
-
}), "usage-billing: relay-quotas route");
|
|
7960
|
-
let statsBodyCache;
|
|
7961
|
-
ctx.effect(() => ctx.webServer.register({
|
|
7962
|
-
kind: "exact",
|
|
7963
|
-
path: "/api/billing/usage-stats",
|
|
7964
|
-
handler: async (req, res) => {
|
|
7965
|
-
if (!guardLoopback(req, res, trustedHosts)) return;
|
|
7966
|
-
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
7967
|
-
const decorate = (doc) => ({
|
|
7968
|
-
...doc,
|
|
7969
|
-
pluginVersion: PACKAGE_VERSION,
|
|
7970
|
-
...config.monthlyBudget === void 0 ? {} : { budget: config.monthlyBudget },
|
|
7971
|
-
...config.lowBalanceThreshold === void 0 ? {} : { lowBalanceThreshold: config.lowBalanceThreshold }
|
|
7972
|
-
});
|
|
7973
|
-
const statsBodyOf = (doc) => {
|
|
7974
|
-
const budget = config.monthlyBudget;
|
|
7975
|
-
const threshold = config.lowBalanceThreshold;
|
|
7976
|
-
if (statsBodyCache !== void 0 && statsBodyCache.doc === doc && statsBodyCache.budget === budget && statsBodyCache.threshold === threshold) return statsBodyCache.body;
|
|
7977
|
-
const body = JSON.stringify(decorate(doc));
|
|
7978
|
-
statsBodyCache = {
|
|
7979
|
-
doc,
|
|
7980
|
-
budget,
|
|
7981
|
-
threshold,
|
|
7982
|
-
body
|
|
7983
|
-
};
|
|
7984
|
-
return body;
|
|
7985
|
-
};
|
|
7986
|
-
const aggregating = aggregator.aggregate();
|
|
7987
|
-
aggregating.then((stats) => {
|
|
7988
|
-
persistSnapshot(decorate(stats));
|
|
7989
|
-
}, () => {});
|
|
7990
|
-
let fresh;
|
|
7991
|
-
try {
|
|
7992
|
-
fresh = await Promise.race([aggregating.then((stats) => stats), new Promise((resolve) => {
|
|
7993
|
-
setTimeout(() => {
|
|
7994
|
-
resolve(void 0);
|
|
7995
|
-
}, RESPONSE_BUDGET_MS).unref();
|
|
7996
|
-
})]);
|
|
7997
|
-
} catch (error) {
|
|
7998
|
-
console.error("[usage-billing] usage-stats aggregate failed, falling back to snapshot:", error);
|
|
7999
|
-
}
|
|
8000
|
-
if (fresh !== void 0) {
|
|
8001
|
-
res.end(statsBodyOf(fresh));
|
|
8002
|
-
return;
|
|
8003
|
-
}
|
|
8004
|
-
for (const candidate of candidates) {
|
|
8005
|
-
const doc = await readSnapshot(candidate);
|
|
8006
|
-
if (doc === null) continue;
|
|
8007
|
-
res.end(JSON.stringify(decorate(doc)));
|
|
8008
|
-
return;
|
|
8009
|
-
}
|
|
8010
|
-
res.end(JSON.stringify({ error: "usage stats unavailable" }));
|
|
8011
|
-
}
|
|
8012
|
-
}), "usage-billing: usage-stats route");
|
|
8367
|
+
registerRelayQuotasRoute(ctx, {
|
|
8368
|
+
trustedHosts,
|
|
8369
|
+
cached: () => relayCache,
|
|
8370
|
+
refreshOnce: refreshRelayOnce,
|
|
8371
|
+
cacheMs: SUBSCRIPTION_CACHE_MS
|
|
8372
|
+
});
|
|
8373
|
+
registerUsageStatsRoute(ctx, {
|
|
8374
|
+
trustedHosts,
|
|
8375
|
+
aggregator,
|
|
8376
|
+
config,
|
|
8377
|
+
candidates,
|
|
8378
|
+
persistSnapshot,
|
|
8379
|
+
readSnapshot,
|
|
8380
|
+
packageVersion: PACKAGE_VERSION,
|
|
8381
|
+
responseBudgetMs: RESPONSE_BUDGET_MS
|
|
8382
|
+
});
|
|
8013
8383
|
}
|
|
8014
8384
|
//#endregion
|
|
8015
|
-
export {
|
|
8385
|
+
export { adaptSessionPersistence, apply, createFileUsageLedgerStore, guardLoopback, hostNameOf, inject, isLoopbackHost, isLoopbackOrigin, isLoopbackPeer, normalizeTrustedHosts, readPiAiProviderRoutes, readRegisteredProviderIds, resolveSubscriptionKeys };
|