@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/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
- return { async aggregate() {
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
- if (ledgerNeedsSave) try {
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. When `sessionPersistence` is
5294
- * unavailable (or aggregation fails), the configured `statsPath` /
5295
- * `DSH_USAGE_STATS` / conventional JSON file is served as a fallback, and a
5296
- * missing file answers `{ error }` so the dashboard shows zeros, never
5297
- * fabricated samples.
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
- const stats = await aggregator.aggregate();
6110
- const injected = {
6111
- ...config.monthlyBudget === void 0 ? {} : { budget: config.monthlyBudget },
6112
- ...config.lowBalanceThreshold === void 0 ? {} : { lowBalanceThreshold: config.lowBalanceThreshold }
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
- if (config.monthlyBudget !== void 0) doc["budget"] = config.monthlyBudget;
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" }));
@@ -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
  * 聚合配置指纹:影响折叠语义的全部配置(订阅豁免、官方名单、路由别名、搜索估值)
@@ -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. When `sessionPersistence` is
7
- * unavailable (or aggregation fails), the configured `statsPath` /
8
- * `DSH_USAGE_STATS` / conventional JSON file is served as a fallback, and a
9
- * missing file answers `{ error }` so the dashboard shows zeros, never
10
- * fabricated samples.
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.38",
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",