@kenz1117/dsh-ui-usage-billing 1.0.38 → 1.0.40
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 +5 -5
- package/lib/index.js +76 -40
- package/lib/types/aggregate.d.ts +12 -2
- package/lib/types/index.d.ts +8 -5
- package/package.json +2 -1
package/lib/index.js
CHANGED
|
@@ -2441,6 +2441,9 @@ function createUsageAggregator(persistence, options = {}) {
|
|
|
2441
2441
|
const ledger = /* @__PURE__ */ new Map();
|
|
2442
2442
|
let ledgerLoaded = false;
|
|
2443
2443
|
let ledgerNeedsSave = false;
|
|
2444
|
+
let lastLedgerSaveAt = 0;
|
|
2445
|
+
const unreadable = /* @__PURE__ */ new Map();
|
|
2446
|
+
let inflight;
|
|
2444
2447
|
let ledgerAppliedMigrations;
|
|
2445
2448
|
let lastDoc;
|
|
2446
2449
|
let lastAt = 0;
|
|
@@ -2499,9 +2502,26 @@ function createUsageAggregator(persistence, options = {}) {
|
|
|
2499
2502
|
return null;
|
|
2500
2503
|
}
|
|
2501
2504
|
};
|
|
2502
|
-
|
|
2505
|
+
/** 落盘账本:force 无视节流(flush 用);常规路径首次立即保存,之后按
|
|
2506
|
+
* LEDGER_SAVE_INTERVAL_MS 节流。失败仅告警——内存中的聚合值仍然正确。 */
|
|
2507
|
+
const saveLedger = async (force) => {
|
|
2508
|
+
if (options.ledger === void 0 || !ledgerNeedsSave) return;
|
|
2509
|
+
if (!force && lastLedgerSaveAt !== 0 && Date.now() - lastLedgerSaveAt < 6e4) return;
|
|
2510
|
+
try {
|
|
2511
|
+
await options.ledger.save({
|
|
2512
|
+
version: 1,
|
|
2513
|
+
updatedAt: Date.now(),
|
|
2514
|
+
sessions: [...ledger.values()],
|
|
2515
|
+
...ledgerAppliedMigrations === void 0 ? {} : { appliedMigrations: ledgerAppliedMigrations }
|
|
2516
|
+
});
|
|
2517
|
+
ledgerNeedsSave = false;
|
|
2518
|
+
lastLedgerSaveAt = Date.now();
|
|
2519
|
+
} catch (error) {
|
|
2520
|
+
console.warn("[usage-billing] failed to persist durable usage ledger:", error);
|
|
2521
|
+
}
|
|
2522
|
+
};
|
|
2523
|
+
const aggregateOnce = async () => {
|
|
2503
2524
|
const now = Date.now();
|
|
2504
|
-
if (lastDoc !== void 0 && now - lastAt < 5e3) return lastDoc;
|
|
2505
2525
|
await ensureLedgerLoaded();
|
|
2506
2526
|
let metas;
|
|
2507
2527
|
try {
|
|
@@ -2520,6 +2540,7 @@ function createUsageAggregator(persistence, options = {}) {
|
|
|
2520
2540
|
const id = String(meta.id);
|
|
2521
2541
|
seen.add(id);
|
|
2522
2542
|
const stamp = await stampOf(meta);
|
|
2543
|
+
if (stamp !== null && unreadable.get(id) === stamp) continue;
|
|
2523
2544
|
const hit = cache.get(id);
|
|
2524
2545
|
if (hit !== void 0 && stamp !== null && hit.stamp === stamp) {
|
|
2525
2546
|
cache.delete(id);
|
|
@@ -2537,10 +2558,6 @@ function createUsageAggregator(persistence, options = {}) {
|
|
|
2537
2558
|
const ledgerRow = ledger.get(id);
|
|
2538
2559
|
if (ledgerRow !== void 0 && ledgerRow.stamp !== void 0 && stamp !== null && ledgerRow.stamp === stamp && (ledgerRow.foldVersion ?? 1) === 10 && ledgerRow.fingerprint === foldFingerprint) {
|
|
2539
2560
|
const fold = deserializeFold(ledgerRow.fold);
|
|
2540
|
-
cache.set(id, {
|
|
2541
|
-
stamp,
|
|
2542
|
-
fold
|
|
2543
|
-
});
|
|
2544
2561
|
folds.push({
|
|
2545
2562
|
id,
|
|
2546
2563
|
...cwd === void 0 ? {} : { cwd },
|
|
@@ -2606,10 +2623,12 @@ function createUsageAggregator(persistence, options = {}) {
|
|
|
2606
2623
|
recordLedger(id, cwd, stamp, fold);
|
|
2607
2624
|
} catch (error) {
|
|
2608
2625
|
skipped.push(id);
|
|
2626
|
+
if (stamp !== null) unreadable.set(id, stamp);
|
|
2609
2627
|
console.warn("[usage-billing] skip unreadable session", id, error);
|
|
2610
2628
|
}
|
|
2611
2629
|
}
|
|
2612
2630
|
for (const key of [...cache.keys()]) if (!seen.has(key)) cache.delete(key);
|
|
2631
|
+
for (const key of [...unreadable.keys()]) if (!seen.has(key)) unreadable.delete(key);
|
|
2613
2632
|
while (cache.size > maxCacheSessions) {
|
|
2614
2633
|
const oldest = cache.keys().next();
|
|
2615
2634
|
if (oldest.done === true) break;
|
|
@@ -2632,17 +2651,7 @@ function createUsageAggregator(persistence, options = {}) {
|
|
|
2632
2651
|
console.warn("[usage-billing] skip invalid durable ledger session", entry.id, error);
|
|
2633
2652
|
}
|
|
2634
2653
|
}
|
|
2635
|
-
|
|
2636
|
-
await options.ledger.save({
|
|
2637
|
-
version: 1,
|
|
2638
|
-
updatedAt: now,
|
|
2639
|
-
sessions: [...ledger.values()],
|
|
2640
|
-
...ledgerAppliedMigrations === void 0 ? {} : { appliedMigrations: ledgerAppliedMigrations }
|
|
2641
|
-
});
|
|
2642
|
-
ledgerNeedsSave = false;
|
|
2643
|
-
} catch (error) {
|
|
2644
|
-
console.warn("[usage-billing] failed to persist durable usage ledger:", error);
|
|
2645
|
-
}
|
|
2654
|
+
await saveLedger(false);
|
|
2646
2655
|
}
|
|
2647
2656
|
if (skipped.length > 0) console.warn(`[usage-billing] aggregated ${folds.length} sessions, skipped ${skipped.length} unreadable:`, skipped);
|
|
2648
2657
|
const total = emptyUsage();
|
|
@@ -2808,7 +2817,20 @@ function createUsageAggregator(persistence, options = {}) {
|
|
|
2808
2817
|
};
|
|
2809
2818
|
lastAt = now;
|
|
2810
2819
|
return lastDoc;
|
|
2811
|
-
}
|
|
2820
|
+
};
|
|
2821
|
+
return {
|
|
2822
|
+
aggregate() {
|
|
2823
|
+
if (lastDoc !== void 0 && Date.now() - lastAt < 5e3) return Promise.resolve(lastDoc);
|
|
2824
|
+
inflight ??= aggregateOnce().finally(() => {
|
|
2825
|
+
inflight = void 0;
|
|
2826
|
+
});
|
|
2827
|
+
return inflight;
|
|
2828
|
+
},
|
|
2829
|
+
async flush() {
|
|
2830
|
+
if (inflight !== void 0) await inflight.catch(() => {});
|
|
2831
|
+
await saveLedger(true);
|
|
2832
|
+
}
|
|
2833
|
+
};
|
|
2812
2834
|
}
|
|
2813
2835
|
//#endregion
|
|
2814
2836
|
//#region lib/types/resilience.js
|
|
@@ -5290,11 +5312,14 @@ const ENABLE_USAGE_STATS_TOOL_FIELD = "enableUsageStatsTool";
|
|
|
5290
5312
|
*
|
|
5291
5313
|
* Serves `/api/billing/usage-stats`: real usage aggregated from every
|
|
5292
5314
|
* persisted session log (see `aggregate.ts`) — the browser dashboard reads it
|
|
5293
|
-
* instead of showing an empty snapshot.
|
|
5294
|
-
*
|
|
5295
|
-
*
|
|
5296
|
-
*
|
|
5297
|
-
*
|
|
5315
|
+
* instead of showing an empty snapshot. Aggregation is stale-while-revalidate:
|
|
5316
|
+
* requests get a short wait budget and fall back to the freshest persisted
|
|
5317
|
+
* snapshot while a slow full fold keeps running in the background (a heavy
|
|
5318
|
+
* user's first fold takes minutes and must not starve the single-process
|
|
5319
|
+
* host's RPC). When `sessionPersistence` is unavailable (or aggregation
|
|
5320
|
+
* fails), the configured `statsPath` / `DSH_USAGE_STATS` / conventional JSON
|
|
5321
|
+
* file is served as a fallback, and a missing file answers `{ error }` so the
|
|
5322
|
+
* dashboard shows zeros, never fabricated samples.
|
|
5298
5323
|
*/
|
|
5299
5324
|
/** Peer 地址是否为回环(本地)。回环防护:本插件的端点只供本机浏览器用,
|
|
5300
5325
|
* 局域网/远端请求一律拒绝,避免面板数据(含中转站 origin 与余额)外泄。 */
|
|
@@ -5368,6 +5393,10 @@ const DEFAULT_BALANCE_API_KEY_ENV = "DEEPSEEK_API_KEY";
|
|
|
5368
5393
|
const PACKAGE_VERSION = createRequire(import.meta.url)("../package.json").version ?? "0.0.0";
|
|
5369
5394
|
/** 统计快照的落盘节流(毫秒):前端 30 秒轮询,快照最多每 30 秒写一次。 */
|
|
5370
5395
|
const SNAPSHOT_INTERVAL_MS = 3e4;
|
|
5396
|
+
/** usage-stats 响应的等待预算(毫秒):聚合在此预算内完成就回实时数据,
|
|
5397
|
+
* 超预算立即回最近快照(stale-while-revalidate),后台折叠继续跑。
|
|
5398
|
+
* 轻量用户暖缓存下的聚合是毫秒级,预算几乎不会触发。 */
|
|
5399
|
+
const RESPONSE_BUDGET_MS = 1500;
|
|
5371
5400
|
/** 鉴权失败告警冷却(毫秒):同一 provider 在窗口内只提示一次,避免 30 秒轮询刷屏。 */
|
|
5372
5401
|
const AUTH_WARN_COOLDOWN_MS = 18e5;
|
|
5373
5402
|
/**
|
|
@@ -5671,6 +5700,9 @@ function apply(ctx, config = {}) {
|
|
|
5671
5700
|
...config.searchCallEstimateCny === void 0 ? {} : { searchCallEstimateCny: config.searchCallEstimateCny },
|
|
5672
5701
|
ledger: ledgerStore
|
|
5673
5702
|
});
|
|
5703
|
+
ctx.effect(() => () => {
|
|
5704
|
+
aggregator.flush();
|
|
5705
|
+
}, "usage-billing: ledger flush on dispose");
|
|
5674
5706
|
const candidates = [
|
|
5675
5707
|
config.statsPath,
|
|
5676
5708
|
process.env.DSH_USAGE_STATS,
|
|
@@ -6105,30 +6137,34 @@ function apply(ctx, config = {}) {
|
|
|
6105
6137
|
handler: async (req, res) => {
|
|
6106
6138
|
if (!guardLoopback(req, res)) return;
|
|
6107
6139
|
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
6140
|
+
const decorate = (doc) => ({
|
|
6141
|
+
...doc,
|
|
6142
|
+
pluginVersion: PACKAGE_VERSION,
|
|
6143
|
+
...config.monthlyBudget === void 0 ? {} : { budget: config.monthlyBudget },
|
|
6144
|
+
...config.lowBalanceThreshold === void 0 ? {} : { lowBalanceThreshold: config.lowBalanceThreshold }
|
|
6145
|
+
});
|
|
6146
|
+
const aggregating = aggregator.aggregate();
|
|
6147
|
+
aggregating.then((stats) => {
|
|
6148
|
+
persistSnapshot(decorate(stats));
|
|
6149
|
+
}, () => {});
|
|
6150
|
+
let fresh;
|
|
6108
6151
|
try {
|
|
6109
|
-
|
|
6110
|
-
|
|
6111
|
-
|
|
6112
|
-
|
|
6113
|
-
};
|
|
6114
|
-
const payload = {
|
|
6115
|
-
...stats,
|
|
6116
|
-
pluginVersion: PACKAGE_VERSION,
|
|
6117
|
-
...injected
|
|
6118
|
-
};
|
|
6119
|
-
persistSnapshot(payload);
|
|
6120
|
-
res.end(JSON.stringify(payload));
|
|
6121
|
-
return;
|
|
6152
|
+
fresh = await Promise.race([aggregating.then((stats) => stats), new Promise((resolve) => {
|
|
6153
|
+
setTimeout(() => {
|
|
6154
|
+
resolve(void 0);
|
|
6155
|
+
}, RESPONSE_BUDGET_MS).unref();
|
|
6156
|
+
})]);
|
|
6122
6157
|
} catch (error) {
|
|
6123
6158
|
console.error("[usage-billing] usage-stats aggregate failed, falling back to snapshot:", error);
|
|
6124
6159
|
}
|
|
6160
|
+
if (fresh !== void 0) {
|
|
6161
|
+
res.end(JSON.stringify(decorate(fresh)));
|
|
6162
|
+
return;
|
|
6163
|
+
}
|
|
6125
6164
|
for (const candidate of candidates) {
|
|
6126
6165
|
const doc = await readSnapshot(candidate);
|
|
6127
6166
|
if (doc === null) continue;
|
|
6128
|
-
|
|
6129
|
-
if (config.lowBalanceThreshold !== void 0) doc["lowBalanceThreshold"] = config.lowBalanceThreshold;
|
|
6130
|
-
doc["pluginVersion"] = PACKAGE_VERSION;
|
|
6131
|
-
res.end(JSON.stringify(doc));
|
|
6167
|
+
res.end(JSON.stringify(decorate(doc)));
|
|
6132
6168
|
return;
|
|
6133
6169
|
}
|
|
6134
6170
|
res.end(JSON.stringify({ error: "usage stats unavailable" }));
|
package/lib/types/aggregate.d.ts
CHANGED
|
@@ -358,6 +358,10 @@ export declare const SESSION_ROW_LIMIT = 100;
|
|
|
358
358
|
export declare const TURN_ROW_LIMIT = 200;
|
|
359
359
|
/** 聚合文档的短 TTL(毫秒):合并密集轮询,TTL 内直接复用上次的合并结果。 */
|
|
360
360
|
export declare const AGGREGATE_TTL_MS = 5000;
|
|
361
|
+
/** 账本落盘节流间隔(毫秒):保存是 O(会话数) 的全量序列化+原子重写,重度用户
|
|
362
|
+
* 上万会话时单次即秒级 CPU,每轮聚合都写会周期性白占宿主事件循环。首次保存
|
|
363
|
+
* 不受节流(冷启动全量折叠的成果要立即落盘),节流窗口内的改动由 flush 兜底。 */
|
|
364
|
+
export declare const LEDGER_SAVE_INTERVAL_MS = 60000;
|
|
361
365
|
/** TTFT 尖峰阈值(毫秒):超过计为一次尖峰样本,用于定位服务端抖动。 */
|
|
362
366
|
export declare const PERF_SPIKE_MS = 10000;
|
|
363
367
|
/** 单步性能样本(foldSession 的折叠产物;跨会话合并时按模型/小时再聚合)。 */
|
|
@@ -535,11 +539,17 @@ export declare function foldSession(events: readonly {
|
|
|
535
539
|
/**
|
|
536
540
|
* 增量聚合器:按会话缓存折叠结果,用日志文件的 mtime+size 作失效键——
|
|
537
541
|
* 日志没动的会话直接复用,只有写过的会话重新折叠;整份文档另有短 TTL
|
|
538
|
-
*
|
|
542
|
+
* 合并密集轮询。带增量状态机的活跃会话由有界 LRU 承载(进程内有效);
|
|
543
|
+
* 无状态机的账本行不占 LRU 名额,直接从 durable 账本反序列化复用,
|
|
544
|
+
* 进程重启后日志未动的会话零读取。
|
|
539
545
|
*/
|
|
540
546
|
export interface UsageAggregator {
|
|
541
|
-
/** Aggregate current usage, reusing cached per-session folds when their logs are untouched.
|
|
547
|
+
/** Aggregate current usage, reusing cached per-session folds when their logs are untouched.
|
|
548
|
+
* 并发调用共享同一次进行中的折叠(in-flight 去重),不会多倍全量重读。 */
|
|
542
549
|
aggregate(): Promise<UsageStatsDocument>;
|
|
550
|
+
/** 立刻落盘未保存的账本改动(无视节流),供插件卸载时调用;
|
|
551
|
+
* 进行中的聚合先等完再存,折叠失败仍保存已成功部分。 */
|
|
552
|
+
flush(): Promise<void>;
|
|
543
553
|
}
|
|
544
554
|
/**
|
|
545
555
|
* 聚合配置指纹:影响折叠语义的全部配置(订阅豁免、官方名单、路由别名、搜索估值)
|
package/lib/types/index.d.ts
CHANGED
|
@@ -3,11 +3,14 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Serves `/api/billing/usage-stats`: real usage aggregated from every
|
|
5
5
|
* persisted session log (see `aggregate.ts`) — the browser dashboard reads it
|
|
6
|
-
* instead of showing an empty snapshot.
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
6
|
+
* instead of showing an empty snapshot. Aggregation is stale-while-revalidate:
|
|
7
|
+
* requests get a short wait budget and fall back to the freshest persisted
|
|
8
|
+
* snapshot while a slow full fold keeps running in the background (a heavy
|
|
9
|
+
* user's first fold takes minutes and must not starve the single-process
|
|
10
|
+
* host's RPC). When `sessionPersistence` is unavailable (or aggregation
|
|
11
|
+
* fails), the configured `statsPath` / `DSH_USAGE_STATS` / conventional JSON
|
|
12
|
+
* file is served as a fallback, and a missing file answers `{ error }` so the
|
|
13
|
+
* dashboard shows zeros, never fabricated samples.
|
|
11
14
|
*/
|
|
12
15
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
13
16
|
import type { Context } from '@deepseek-ai/cordis';
|
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.0.
|
|
4
|
+
"version": "1.0.40",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"deepseek-harness",
|
|
7
7
|
"deepseek",
|
|
@@ -129,6 +129,7 @@
|
|
|
129
129
|
"@testing-library/react": "^16.1.0",
|
|
130
130
|
"@types/node": "^22.20.0",
|
|
131
131
|
"@types/react": "~18.3.1",
|
|
132
|
+
"@types/react-dom": "^19.2.7",
|
|
132
133
|
"jsdom": "^26.1.0",
|
|
133
134
|
"lightningcss": "^1.32.0",
|
|
134
135
|
"react": "^18.2.0",
|