@kenz1117/dsh-ui-usage-billing 1.5.0-alpha.2 → 1.5.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/lib/client.js +3 -3
- package/lib/index.js +163 -72
- package/lib/types/aggregate.d.ts +21 -2
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -2876,6 +2876,46 @@ function perfDigestsOf(samples) {
|
|
|
2876
2876
|
};
|
|
2877
2877
|
}
|
|
2878
2878
|
/**
|
|
2879
|
+
* 账本行谁更新:{@link UsageLedgerSession.foldedAt} 新者胜(缺失视为 0 =
|
|
2880
|
+
* 合并语义引入前的旧行);时刻相同比 {@link UsageLedgerSession.foldVersion}
|
|
2881
|
+
* (新算法胜);再相同视为等价,取 incoming(本进程刚折出的当前视图)。
|
|
2882
|
+
*/
|
|
2883
|
+
function ledgerRowWins(incoming, existing) {
|
|
2884
|
+
const at = incoming.foldedAt ?? 0;
|
|
2885
|
+
const bt = existing.foldedAt ?? 0;
|
|
2886
|
+
if (at !== bt) return at > bt;
|
|
2887
|
+
return (incoming.foldVersion ?? 1) > (existing.foldVersion ?? 1);
|
|
2888
|
+
}
|
|
2889
|
+
/**
|
|
2890
|
+
* 多实例账本合并(自审计延期项:save 路径 last-writer-wins 丢写):同一
|
|
2891
|
+
* DSH_HOME 下多个宿主实例各自持有内存账本,整份覆盖式落盘会让后写者抹掉
|
|
2892
|
+
* 先写者刚保存的行。本进程要落的文档与磁盘现存文档按会话 id 行级合并:
|
|
2893
|
+
* 双方都有的行按「谁更新谁胜」,仅磁盘有的行(他实例写入、本进程内存没有)
|
|
2894
|
+
* 原样保留——账本行从不按 seen 剪除(见 aggregateOnce 尾部注释),合并不会
|
|
2895
|
+
* 复活已删会话。合并只发生在落盘边界:跨实例的即时可见性仍靠重启重载,
|
|
2896
|
+
* 此处保证的是磁盘上不丢数据(最终一致)。
|
|
2897
|
+
* @param document - 本进程刚聚合出的完整账本文档。
|
|
2898
|
+
* @param existing - 磁盘上现存(他实例可能写入过)的账本文档。
|
|
2899
|
+
* @returns 合并后的文档(sessions 行序:incoming 在前、existing 独有行按原序追加)。
|
|
2900
|
+
*/
|
|
2901
|
+
function mergeUsageLedgerDocuments(document, existing) {
|
|
2902
|
+
const byId = /* @__PURE__ */ new Map();
|
|
2903
|
+
for (const row of existing.sessions) byId.set(row.id, row);
|
|
2904
|
+
const sessions = [];
|
|
2905
|
+
for (const row of document.sessions) {
|
|
2906
|
+
const prev = byId.get(row.id);
|
|
2907
|
+
sessions.push(prev !== void 0 && ledgerRowWins(prev, row) ? prev : row);
|
|
2908
|
+
byId.delete(row.id);
|
|
2909
|
+
}
|
|
2910
|
+
for (const row of byId.values()) sessions.push(row);
|
|
2911
|
+
const applied = document.appliedMigrations === void 0 && existing.appliedMigrations === void 0 ? void 0 : [.../* @__PURE__ */ new Set([...document.appliedMigrations ?? [], ...existing.appliedMigrations ?? []])];
|
|
2912
|
+
return {
|
|
2913
|
+
...document,
|
|
2914
|
+
sessions,
|
|
2915
|
+
...applied === void 0 ? {} : { appliedMigrations: applied }
|
|
2916
|
+
};
|
|
2917
|
+
}
|
|
2918
|
+
/**
|
|
2879
2919
|
* 账本迁移注册表。首条迁移给 1.0.6 及更早的行回填 foldVersion = 1(它们全部出自
|
|
2880
2920
|
* header 归因算法);perf 摘要化迁移把 v15 及更早行的逐样本 `perf` 数组转换为
|
|
2881
2921
|
* `perfDigest`/`perfHourDigest` 摘要(日志已删的行不再被重折,迁移是它们唯一的
|
|
@@ -3236,20 +3276,20 @@ function foldInto(fold, machine, events, subscriptionProviders, officialProvider
|
|
|
3236
3276
|
const unverifiedChannel = siteBucket === "unknown";
|
|
3237
3277
|
const priced = !subscription && !unverifiedChannel && isPriced(modelKey);
|
|
3238
3278
|
if (!subscription && !isPriced(modelKey)) fold.unpricedModels.add(modelKey);
|
|
3239
|
-
const entry =
|
|
3240
|
-
const thisCost =
|
|
3279
|
+
const entry = modelOf(modelKey);
|
|
3280
|
+
const thisCost = priced ? computeCostAt(entry, {
|
|
3241
3281
|
input: (usage.cacheReadTokens ?? 0) + usage.inputTokens + (usage.cacheWriteTokens ?? 0),
|
|
3242
3282
|
cacheHit: usage.cacheReadTokens ?? 0,
|
|
3243
3283
|
cacheMiss: usage.inputTokens + (usage.cacheWriteTokens ?? 0),
|
|
3244
3284
|
output: usage.outputTokens
|
|
3245
|
-
}, event.time);
|
|
3285
|
+
}, event.time) : void 0;
|
|
3246
3286
|
foldUsage(fold.total, usage, modelKey, subscription, event.time, official, thisCost, unverifiedChannel);
|
|
3247
3287
|
foldUsage(usageCell(fold.byModel, modelKey), usage, modelKey, subscription, event.time, official, thisCost, unverifiedChannel);
|
|
3248
3288
|
foldUsage(usageCell(fold.byDay, day), usage, modelKey, subscription, event.time, official, thisCost, unverifiedChannel);
|
|
3249
3289
|
foldUsage(modelDayCell(fold.byDayModels, day, modelKey), usage, modelKey, subscription, event.time, official, thisCost, unverifiedChannel);
|
|
3250
3290
|
foldUsage(modelDaySiteCell(fold.byDayModelsSite, day, modelKey, siteBucket), usage, modelKey, subscription, event.time, official, thisCost, unverifiedChannel);
|
|
3251
3291
|
foldUsage(usageCell(fold.bySite, siteBucket), usage, modelKey, subscription, event.time, official, thisCost, unverifiedChannel);
|
|
3252
|
-
foldUsage(usageCell(fold.byTier, tierAt(event.time)), usage, modelKey, subscription, event.time, official, thisCost, unverifiedChannel);
|
|
3292
|
+
if (entry.tierSemantics !== "latency") foldUsage(usageCell(fold.byTier, tierAt(event.time)), usage, modelKey, subscription, event.time, official, thisCost, unverifiedChannel);
|
|
3253
3293
|
if (subscription) fold.planCalls.set(modelKey, (fold.planCalls.get(modelKey) ?? 0) + 1);
|
|
3254
3294
|
if (subscription) {
|
|
3255
3295
|
const sitePlanKey = planSiteKey(day, modelKey, siteBucket);
|
|
@@ -3410,19 +3450,21 @@ function hourPerfOf(digest) {
|
|
|
3410
3450
|
* 订阅豁免),历史账本行立即失效并全量重折,配置变更即时生效(issue #37)。
|
|
3411
3451
|
* 判定函数无法序列化,统一记为 `fn`(任何函数形态互视为同一指纹)。
|
|
3412
3452
|
*/
|
|
3413
|
-
function configFingerprint(subscriptionMatcher, officialProviderIds, routeAliases, searchEstimate, modelKeyAliases = {}, registeredProviders = /* @__PURE__ */ new Set()) {
|
|
3453
|
+
function configFingerprint(subscriptionMatcher, officialProviderIds, routeAliases, searchEstimate, modelKeyAliases = {}, registeredProviders = /* @__PURE__ */ new Set(), routeOrigins = {}) {
|
|
3414
3454
|
const subs = typeof subscriptionMatcher === "function" ? "fn" : [...subscriptionMatcher].sort().join(",");
|
|
3415
3455
|
const officials = officialProviderIds === void 0 ? "" : [...officialProviderIds].sort().join(",");
|
|
3416
3456
|
const aliases = Object.keys(routeAliases).sort().map((key) => `${key}=${routeAliases[key]}`).join(",");
|
|
3417
3457
|
const modelKeys = Object.keys(modelKeyAliases).sort().map((key) => `${key}=${modelKeyAliases[key]}`).join(",");
|
|
3418
3458
|
const registered = [...registeredProviders].sort().join(",");
|
|
3459
|
+
const origins = Object.keys(routeOrigins).sort().map((key) => `${key}=${routeOrigins[key]}`).join(",");
|
|
3419
3460
|
return [
|
|
3420
3461
|
subs,
|
|
3421
3462
|
officials,
|
|
3422
3463
|
aliases,
|
|
3423
3464
|
String(searchEstimate),
|
|
3424
3465
|
modelKeys,
|
|
3425
|
-
registered
|
|
3466
|
+
registered,
|
|
3467
|
+
origins
|
|
3426
3468
|
].join("|");
|
|
3427
3469
|
}
|
|
3428
3470
|
/** 会话格式拒读(宿主 SessionFormatUnsupportedError):日志完好,仅本构建读不了。
|
|
@@ -3473,8 +3515,13 @@ function createUsageAggregator(persistence, options = {}) {
|
|
|
3473
3515
|
return routes;
|
|
3474
3516
|
};
|
|
3475
3517
|
const searchEstimate = options.searchCallEstimateCny ?? .02;
|
|
3476
|
-
const
|
|
3477
|
-
|
|
3518
|
+
const routeOriginsOf = (routes) => {
|
|
3519
|
+
const origins = {};
|
|
3520
|
+
for (const [route, view] of Object.entries(routes)) if (view.baseURL !== void 0 && view.baseURL !== "") origins[route] = view.baseURL;
|
|
3521
|
+
return origins;
|
|
3522
|
+
};
|
|
3523
|
+
const foldFingerprintOf = (registered, routes) => configFingerprint(subscriptionMatcher, officialProviderIds, routeAliases, searchEstimate, options.modelKeyAliases, registered, routeOriginsOf(routes));
|
|
3524
|
+
let foldFingerprint = foldFingerprintOf(/* @__PURE__ */ new Set(), {});
|
|
3478
3525
|
const FOLD_CHUNK_EVENTS = 8e3;
|
|
3479
3526
|
/** 惰性失效(价目表时序):账本/缓存行若以「当时不可计价」的模型落盘,
|
|
3480
3527
|
* 而价目表现在能计价了,该行必须重折——否则显示层按实时价算出的
|
|
@@ -3492,8 +3539,9 @@ function createUsageAggregator(persistence, options = {}) {
|
|
|
3492
3539
|
id,
|
|
3493
3540
|
...cwd === void 0 ? {} : { cwd },
|
|
3494
3541
|
...stamp === null ? {} : { stamp },
|
|
3495
|
-
foldVersion:
|
|
3542
|
+
foldVersion: 23,
|
|
3496
3543
|
fingerprint: foldFingerprint,
|
|
3544
|
+
foldedAt: Date.now(),
|
|
3497
3545
|
fold: serializeFold(fold)
|
|
3498
3546
|
};
|
|
3499
3547
|
const row = ledger.get(id);
|
|
@@ -3557,8 +3605,8 @@ function createUsageAggregator(persistence, options = {}) {
|
|
|
3557
3605
|
const now = Date.now();
|
|
3558
3606
|
await ensureLedgerLoaded();
|
|
3559
3607
|
const registeredProviders = options.resolveRegisteredProviders?.() ?? /* @__PURE__ */ new Set();
|
|
3560
|
-
foldFingerprint = foldFingerprintOf(registeredProviders);
|
|
3561
3608
|
const roundRoutes = routesOf(registeredProviders);
|
|
3609
|
+
foldFingerprint = foldFingerprintOf(registeredProviders, roundRoutes);
|
|
3562
3610
|
let metas;
|
|
3563
3611
|
try {
|
|
3564
3612
|
metas = await persistence.list();
|
|
@@ -3590,7 +3638,7 @@ function createUsageAggregator(persistence, options = {}) {
|
|
|
3590
3638
|
try {
|
|
3591
3639
|
const cwd = meta.cwd;
|
|
3592
3640
|
const ledgerRow = ledger.get(id);
|
|
3593
|
-
if (ledgerRow !== void 0 && ledgerRow.stamp !== void 0 && stamp !== null && ledgerRow.stamp === stamp && (ledgerRow.foldVersion ?? 1) ===
|
|
3641
|
+
if (ledgerRow !== void 0 && ledgerRow.stamp !== void 0 && stamp !== null && ledgerRow.stamp === stamp && (ledgerRow.foldVersion ?? 1) === 23 && ledgerRow.fingerprint === foldFingerprint && !foldNeedsReprice(ledgerRow.fold)) {
|
|
3594
3642
|
const fold = deserializedFoldOf(ledgerRow.fold);
|
|
3595
3643
|
folds.push({
|
|
3596
3644
|
id,
|
|
@@ -3691,7 +3739,7 @@ function createUsageAggregator(persistence, options = {}) {
|
|
|
3691
3739
|
for (const entry of ledger.values()) {
|
|
3692
3740
|
if (included.has(entry.id)) continue;
|
|
3693
3741
|
try {
|
|
3694
|
-
const stale = (entry.foldVersion ?? 1) <
|
|
3742
|
+
const stale = (entry.foldVersion ?? 1) < 23 || entry.fingerprint !== foldFingerprint;
|
|
3695
3743
|
folds.push({
|
|
3696
3744
|
id: entry.id,
|
|
3697
3745
|
...entry.cwd === void 0 ? {} : { cwd: entry.cwd },
|
|
@@ -6933,15 +6981,21 @@ function createFileUsageLedgerStore(ledgerPath) {
|
|
|
6933
6981
|
mode: 448
|
|
6934
6982
|
});
|
|
6935
6983
|
const commit = async () => {
|
|
6984
|
+
let existing;
|
|
6985
|
+
let existingDoc;
|
|
6936
6986
|
try {
|
|
6937
|
-
|
|
6938
|
-
JSON.parse(existing);
|
|
6939
|
-
|
|
6940
|
-
|
|
6941
|
-
|
|
6942
|
-
|
|
6943
|
-
}
|
|
6944
|
-
|
|
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;
|
|
6992
|
+
}
|
|
6993
|
+
if (existing !== void 0) await writeFileAtomic(`${ledgerPath}.bak`, existing, {
|
|
6994
|
+
mode: 384,
|
|
6995
|
+
dirMode: 448
|
|
6996
|
+
});
|
|
6997
|
+
const merged = existingDoc === void 0 ? document : mergeUsageLedgerDocuments(document, existingDoc);
|
|
6998
|
+
await writeFileAtomic(ledgerPath, JSON.stringify(merged), {
|
|
6945
6999
|
mode: 384,
|
|
6946
7000
|
dirMode: 448
|
|
6947
7001
|
});
|
|
@@ -7665,69 +7719,78 @@ function apply(ctx, config = {}) {
|
|
|
7665
7719
|
at: 0,
|
|
7666
7720
|
doc: { balances: [] }
|
|
7667
7721
|
};
|
|
7722
|
+
let balanceInflight;
|
|
7723
|
+
const refreshBalance = async () => {
|
|
7724
|
+
const piProviders = await readPiAiProviders(ctx.settings);
|
|
7725
|
+
const providers = { ...piProviders };
|
|
7726
|
+
const deepseekKey = providers["deepseek"]?.apiKeyEnv;
|
|
7727
|
+
if (deepseekKey === void 0 || deepseekKey === "") providers["deepseek"] = {
|
|
7728
|
+
...providers["deepseek"],
|
|
7729
|
+
apiKeyEnv: config.balanceApiKeyEnv ?? DEFAULT_BALANCE_API_KEY_ENV
|
|
7730
|
+
};
|
|
7731
|
+
const balances = await queryBalances(ctx, providers);
|
|
7732
|
+
const custom = await queryCustomBalances(ctx, config.customBalances ?? []);
|
|
7733
|
+
const declared = await queryDeclaredEndpoints(ctx, piProviders, config.declaredEndpoints ?? []);
|
|
7734
|
+
for (const row of [
|
|
7735
|
+
...balances,
|
|
7736
|
+
...custom,
|
|
7737
|
+
...declared
|
|
7738
|
+
]) if (row.error === "unauthorized") warnAuthOnce("balance", row.provider, row.displayName);
|
|
7739
|
+
const official = balances.find((row) => row.provider === "deepseek");
|
|
7740
|
+
let reconcile;
|
|
7741
|
+
const reconcilable = official !== void 0 && official.totalBalance !== void 0 && (official.currency === void 0 || official.currency.toUpperCase() === "CNY");
|
|
7742
|
+
if (official !== void 0 && reconcilable) {
|
|
7743
|
+
const now = Date.now();
|
|
7744
|
+
const today = dayStamp(now);
|
|
7745
|
+
let todayOfficialCost = 0;
|
|
7746
|
+
try {
|
|
7747
|
+
todayOfficialCost = (await aggregator.aggregate()).byDay[today]?.officialCost ?? 0;
|
|
7748
|
+
} catch {}
|
|
7749
|
+
const result = reconcileBalanceDelta(reconcileRef, official, todayOfficialCost, today, now);
|
|
7750
|
+
const refChanged = result.ref !== null && result.ref !== reconcileRef;
|
|
7751
|
+
reconcileRef = result.ref;
|
|
7752
|
+
if (result.event !== null && result.event.kind !== "flat") reconcile = {
|
|
7753
|
+
...result.event,
|
|
7754
|
+
provider: official.displayName,
|
|
7755
|
+
...official.currency === void 0 ? {} : { currency: official.currency }
|
|
7756
|
+
};
|
|
7757
|
+
if (refChanged) persistReconcileRef();
|
|
7758
|
+
}
|
|
7759
|
+
const doc = {
|
|
7760
|
+
balances: [
|
|
7761
|
+
...balances,
|
|
7762
|
+
...custom,
|
|
7763
|
+
...declared
|
|
7764
|
+
],
|
|
7765
|
+
...reconcile === void 0 ? {} : { reconcile }
|
|
7766
|
+
};
|
|
7767
|
+
balanceCache = {
|
|
7768
|
+
at: Date.now(),
|
|
7769
|
+
doc
|
|
7770
|
+
};
|
|
7771
|
+
};
|
|
7772
|
+
const refreshBalanceOnce = () => {
|
|
7773
|
+
balanceInflight ??= refreshBalance().finally(() => {
|
|
7774
|
+
balanceInflight = void 0;
|
|
7775
|
+
});
|
|
7776
|
+
return balanceInflight;
|
|
7777
|
+
};
|
|
7668
7778
|
ctx.effect(() => ctx.webServer.register({
|
|
7669
7779
|
kind: "exact",
|
|
7670
7780
|
path: "/api/billing/balance",
|
|
7671
7781
|
handler: async (req, res) => {
|
|
7672
7782
|
if (!guardLoopback(req, res, trustedHosts)) return;
|
|
7673
7783
|
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
7674
|
-
const piProviders = await readPiAiProviders(ctx.settings);
|
|
7675
|
-
const providers = { ...piProviders };
|
|
7676
|
-
const deepseekKey = providers["deepseek"]?.apiKeyEnv;
|
|
7677
|
-
if (deepseekKey === void 0 || deepseekKey === "") providers["deepseek"] = {
|
|
7678
|
-
...providers["deepseek"],
|
|
7679
|
-
apiKeyEnv: config.balanceApiKeyEnv ?? DEFAULT_BALANCE_API_KEY_ENV
|
|
7680
|
-
};
|
|
7681
7784
|
if (Date.now() - balanceCache.at < BALANCE_CACHE_MS) {
|
|
7682
7785
|
res.end(JSON.stringify(balanceCache.doc));
|
|
7683
7786
|
return;
|
|
7684
7787
|
}
|
|
7685
7788
|
try {
|
|
7686
|
-
|
|
7687
|
-
const custom = await queryCustomBalances(ctx, config.customBalances ?? []);
|
|
7688
|
-
const declared = await queryDeclaredEndpoints(ctx, piProviders, config.declaredEndpoints ?? []);
|
|
7689
|
-
for (const row of [
|
|
7690
|
-
...balances,
|
|
7691
|
-
...custom,
|
|
7692
|
-
...declared
|
|
7693
|
-
]) if (row.error === "unauthorized") warnAuthOnce("balance", row.provider, row.displayName);
|
|
7694
|
-
const official = balances.find((row) => row.provider === "deepseek");
|
|
7695
|
-
let reconcile;
|
|
7696
|
-
const reconcilable = official !== void 0 && official.totalBalance !== void 0 && (official.currency === void 0 || official.currency.toUpperCase() === "CNY");
|
|
7697
|
-
if (official !== void 0 && reconcilable) {
|
|
7698
|
-
const now = Date.now();
|
|
7699
|
-
const today = dayStamp(now);
|
|
7700
|
-
let todayOfficialCost = 0;
|
|
7701
|
-
try {
|
|
7702
|
-
todayOfficialCost = (await aggregator.aggregate()).byDay[today]?.officialCost ?? 0;
|
|
7703
|
-
} catch {}
|
|
7704
|
-
const result = reconcileBalanceDelta(reconcileRef, official, todayOfficialCost, today, now);
|
|
7705
|
-
const refChanged = result.ref !== null && result.ref !== reconcileRef;
|
|
7706
|
-
reconcileRef = result.ref;
|
|
7707
|
-
if (result.event !== null && result.event.kind !== "flat") reconcile = {
|
|
7708
|
-
...result.event,
|
|
7709
|
-
provider: official.displayName,
|
|
7710
|
-
...official.currency === void 0 ? {} : { currency: official.currency }
|
|
7711
|
-
};
|
|
7712
|
-
if (refChanged) persistReconcileRef();
|
|
7713
|
-
}
|
|
7714
|
-
const doc = {
|
|
7715
|
-
balances: [
|
|
7716
|
-
...balances,
|
|
7717
|
-
...custom,
|
|
7718
|
-
...declared
|
|
7719
|
-
],
|
|
7720
|
-
...reconcile === void 0 ? {} : { reconcile }
|
|
7721
|
-
};
|
|
7722
|
-
balanceCache = {
|
|
7723
|
-
at: Date.now(),
|
|
7724
|
-
doc
|
|
7725
|
-
};
|
|
7726
|
-
res.end(JSON.stringify(doc));
|
|
7789
|
+
await refreshBalanceOnce();
|
|
7727
7790
|
} catch (error) {
|
|
7728
7791
|
console.warn("[usage-billing] refreshBalances failed; serving cached balances:", error);
|
|
7729
|
-
res.end(JSON.stringify(balanceCache.doc));
|
|
7730
7792
|
}
|
|
7793
|
+
res.end(JSON.stringify(balanceCache.doc));
|
|
7731
7794
|
}
|
|
7732
7795
|
}), "usage-billing: balance route");
|
|
7733
7796
|
ctx.effect(() => ctx.webServer.register({
|
|
@@ -7822,6 +7885,13 @@ function apply(ctx, config = {}) {
|
|
|
7822
7885
|
quotas: rows
|
|
7823
7886
|
};
|
|
7824
7887
|
};
|
|
7888
|
+
let quotasInflight;
|
|
7889
|
+
const refreshQuotasOnce = () => {
|
|
7890
|
+
quotasInflight ??= refreshQuotas().finally(() => {
|
|
7891
|
+
quotasInflight = void 0;
|
|
7892
|
+
});
|
|
7893
|
+
return quotasInflight;
|
|
7894
|
+
};
|
|
7825
7895
|
ctx.effect(() => ctx.webServer.register({
|
|
7826
7896
|
kind: "exact",
|
|
7827
7897
|
path: "/api/billing/subscriptions",
|
|
@@ -7829,7 +7899,7 @@ function apply(ctx, config = {}) {
|
|
|
7829
7899
|
if (!guardLoopback(req, res, trustedHosts)) return;
|
|
7830
7900
|
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
7831
7901
|
if (Date.now() - quotaCache.at >= SUBSCRIPTION_CACHE_MS) try {
|
|
7832
|
-
await
|
|
7902
|
+
await refreshQuotasOnce();
|
|
7833
7903
|
} catch (error) {
|
|
7834
7904
|
console.warn("[usage-billing] refreshQuotas failed; serving cached quotas:", error);
|
|
7835
7905
|
}
|
|
@@ -7858,6 +7928,13 @@ function apply(ctx, config = {}) {
|
|
|
7858
7928
|
quotas: await queryRelayQuotas(ctx, routes)
|
|
7859
7929
|
};
|
|
7860
7930
|
};
|
|
7931
|
+
let relayInflight;
|
|
7932
|
+
const refreshRelayOnce = () => {
|
|
7933
|
+
relayInflight ??= refreshRelay().finally(() => {
|
|
7934
|
+
relayInflight = void 0;
|
|
7935
|
+
});
|
|
7936
|
+
return relayInflight;
|
|
7937
|
+
};
|
|
7861
7938
|
ctx.effect(() => ctx.webServer.register({
|
|
7862
7939
|
kind: "exact",
|
|
7863
7940
|
path: "/api/billing/relay-quotas",
|
|
@@ -7865,7 +7942,7 @@ function apply(ctx, config = {}) {
|
|
|
7865
7942
|
if (!guardLoopback(req, res, trustedHosts)) return;
|
|
7866
7943
|
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
7867
7944
|
if (Date.now() - relayCache.at >= SUBSCRIPTION_CACHE_MS) try {
|
|
7868
|
-
await
|
|
7945
|
+
await refreshRelayOnce();
|
|
7869
7946
|
} catch (error) {
|
|
7870
7947
|
console.warn("[usage-billing] refreshRelay failed; serving cached relay quotas:", error);
|
|
7871
7948
|
}
|
|
@@ -7880,6 +7957,7 @@ function apply(ctx, config = {}) {
|
|
|
7880
7957
|
}));
|
|
7881
7958
|
}
|
|
7882
7959
|
}), "usage-billing: relay-quotas route");
|
|
7960
|
+
let statsBodyCache;
|
|
7883
7961
|
ctx.effect(() => ctx.webServer.register({
|
|
7884
7962
|
kind: "exact",
|
|
7885
7963
|
path: "/api/billing/usage-stats",
|
|
@@ -7892,6 +7970,19 @@ function apply(ctx, config = {}) {
|
|
|
7892
7970
|
...config.monthlyBudget === void 0 ? {} : { budget: config.monthlyBudget },
|
|
7893
7971
|
...config.lowBalanceThreshold === void 0 ? {} : { lowBalanceThreshold: config.lowBalanceThreshold }
|
|
7894
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
|
+
};
|
|
7895
7986
|
const aggregating = aggregator.aggregate();
|
|
7896
7987
|
aggregating.then((stats) => {
|
|
7897
7988
|
persistSnapshot(decorate(stats));
|
|
@@ -7907,7 +7998,7 @@ function apply(ctx, config = {}) {
|
|
|
7907
7998
|
console.error("[usage-billing] usage-stats aggregate failed, falling back to snapshot:", error);
|
|
7908
7999
|
}
|
|
7909
8000
|
if (fresh !== void 0) {
|
|
7910
|
-
res.end(
|
|
8001
|
+
res.end(statsBodyOf(fresh));
|
|
7911
8002
|
return;
|
|
7912
8003
|
}
|
|
7913
8004
|
for (const candidate of candidates) {
|
package/lib/types/aggregate.d.ts
CHANGED
|
@@ -575,6 +575,12 @@ export interface UsageLedgerSession {
|
|
|
575
575
|
* 更早写入的行(配置不参与复用判定);与当前指纹不一致的行不复用、全量重折。
|
|
576
576
|
*/
|
|
577
577
|
fingerprint?: string;
|
|
578
|
+
/**
|
|
579
|
+
* 该行最近一次折叠落盘的时刻(epoch 毫秒)。多实例共享同一账本文件时,
|
|
580
|
+
* save 侧按它做行级合并(后折覆盖先折),防止整份覆盖式落盘丢掉另一实例
|
|
581
|
+
* 刚写入的行;缺失 = 合并语义引入前的旧行,视为最旧。
|
|
582
|
+
*/
|
|
583
|
+
foldedAt?: number;
|
|
578
584
|
fold: SerializedSessionFold;
|
|
579
585
|
}
|
|
580
586
|
/** On-disk durable usage ledger. Versioned independently from the dashboard document. */
|
|
@@ -585,6 +591,19 @@ export interface UsageLedgerDocument {
|
|
|
585
591
|
/** 已应用的一次性配置迁移 id 列表(随文档落盘;缺省 = 尚未跑过任何迁移)。 */
|
|
586
592
|
appliedMigrations?: string[];
|
|
587
593
|
}
|
|
594
|
+
/**
|
|
595
|
+
* 多实例账本合并(自审计延期项:save 路径 last-writer-wins 丢写):同一
|
|
596
|
+
* DSH_HOME 下多个宿主实例各自持有内存账本,整份覆盖式落盘会让后写者抹掉
|
|
597
|
+
* 先写者刚保存的行。本进程要落的文档与磁盘现存文档按会话 id 行级合并:
|
|
598
|
+
* 双方都有的行按「谁更新谁胜」,仅磁盘有的行(他实例写入、本进程内存没有)
|
|
599
|
+
* 原样保留——账本行从不按 seen 剪除(见 aggregateOnce 尾部注释),合并不会
|
|
600
|
+
* 复活已删会话。合并只发生在落盘边界:跨实例的即时可见性仍靠重启重载,
|
|
601
|
+
* 此处保证的是磁盘上不丢数据(最终一致)。
|
|
602
|
+
* @param document - 本进程刚聚合出的完整账本文档。
|
|
603
|
+
* @param existing - 磁盘上现存(他实例可能写入过)的账本文档。
|
|
604
|
+
* @returns 合并后的文档(sessions 行序:incoming 在前、existing 独有行按原序追加)。
|
|
605
|
+
*/
|
|
606
|
+
export declare function mergeUsageLedgerDocuments(document: UsageLedgerDocument, existing: UsageLedgerDocument): UsageLedgerDocument;
|
|
588
607
|
/**
|
|
589
608
|
* 折叠算法版本:归账语义变化时递增。v1 = 按 request/header 归账(稀疏 header 把
|
|
590
609
|
* 两次 header 之间的用量串到上一个模型,订阅模型首当其冲,issue #14);v2 =
|
|
@@ -599,7 +618,7 @@ export interface UsageLedgerDocument {
|
|
|
599
618
|
* 会话费用只剩最近一段,issue #29)。
|
|
600
619
|
* 持久账本行据此区分新旧算法:日志已删/不可读而只能沿用旧行时,UI 标注置信度提示。
|
|
601
620
|
*/
|
|
602
|
-
export declare const FOLD_VERSION =
|
|
621
|
+
export declare const FOLD_VERSION = 23;
|
|
603
622
|
/**
|
|
604
623
|
* 一次性账本迁移:id 唯一,apply 在加载边界对原始文档执行,已应用过的跳过。
|
|
605
624
|
* 未来账本/schema 字段变更(重命名、拆桶、语义调整)时,在此追加一条迁移并
|
|
@@ -695,7 +714,7 @@ export interface SessionUsageSummary {
|
|
|
695
714
|
* 订阅豁免),历史账本行立即失效并全量重折,配置变更即时生效(issue #37)。
|
|
696
715
|
* 判定函数无法序列化,统一记为 `fn`(任何函数形态互视为同一指纹)。
|
|
697
716
|
*/
|
|
698
|
-
export declare function configFingerprint(subscriptionMatcher: SubscriptionMatcher, officialProviderIds: ReadonlySet<string> | undefined, routeAliases: Readonly<Record<string, string>>, searchEstimate: number, modelKeyAliases?: Readonly<Record<string, string>>, registeredProviders?: ReadonlySet<string
|
|
717
|
+
export declare function configFingerprint(subscriptionMatcher: SubscriptionMatcher, officialProviderIds: ReadonlySet<string> | undefined, routeAliases: Readonly<Record<string, string>>, searchEstimate: number, modelKeyAliases?: Readonly<Record<string, string>>, registeredProviders?: ReadonlySet<string>, routeOrigins?: Readonly<Record<string, string>>): string;
|
|
699
718
|
/**
|
|
700
719
|
* Create the incremental usage aggregator.
|
|
701
720
|
* @param persistence - the session persistence service.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kenz1117/dsh-ui-usage-billing",
|
|
3
3
|
"description": "Usage billing dashboard for DeepSeek Harness: sidebar cost metrics plus a full dashboard modal, priced from a current multi-provider catalog with real usage aggregated from session logs.",
|
|
4
|
-
"version": "1.5.0
|
|
4
|
+
"version": "1.5.0",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"deepseek-harness",
|
|
7
7
|
"deepseek",
|