@kenz1117/dsh-ui-usage-billing 1.1.7 → 1.1.9

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
@@ -2312,6 +2312,9 @@ function createUsageAggregator(persistence, options = {}) {
2312
2312
  const ledger = /* @__PURE__ */ new Map();
2313
2313
  let ledgerLoaded = false;
2314
2314
  let ledgerNeedsSave = false;
2315
+ let lastLedgerSaveAt = 0;
2316
+ const unreadable = /* @__PURE__ */ new Map();
2317
+ let inflight;
2315
2318
  let ledgerAppliedMigrations;
2316
2319
  let lastDoc;
2317
2320
  let lastAt = 0;
@@ -2362,9 +2365,26 @@ function createUsageAggregator(persistence, options = {}) {
2362
2365
  return null;
2363
2366
  }
2364
2367
  };
2365
- return { async aggregate() {
2368
+ /** 落盘账本:force 无视节流(flush 用);常规路径首次立即保存,之后按
2369
+ * LEDGER_SAVE_INTERVAL_MS 节流。失败仅告警——内存中的聚合值仍然正确。 */
2370
+ const saveLedger = async (force) => {
2371
+ if (options.ledger === void 0 || !ledgerNeedsSave) return;
2372
+ if (!force && lastLedgerSaveAt !== 0 && Date.now() - lastLedgerSaveAt < 6e4) return;
2373
+ try {
2374
+ await options.ledger.save({
2375
+ version: 1,
2376
+ updatedAt: Date.now(),
2377
+ sessions: [...ledger.values()],
2378
+ ...ledgerAppliedMigrations === void 0 ? {} : { appliedMigrations: ledgerAppliedMigrations }
2379
+ });
2380
+ ledgerNeedsSave = false;
2381
+ lastLedgerSaveAt = Date.now();
2382
+ } catch (error) {
2383
+ console.warn("[usage-billing] failed to persist durable usage ledger:", error);
2384
+ }
2385
+ };
2386
+ const aggregateOnce = async () => {
2366
2387
  const now = Date.now();
2367
- if (lastDoc !== void 0 && now - lastAt < 5e3) return lastDoc;
2368
2388
  await ensureLedgerLoaded();
2369
2389
  let metas;
2370
2390
  try {
@@ -2383,6 +2403,7 @@ function createUsageAggregator(persistence, options = {}) {
2383
2403
  const id = String(meta.id);
2384
2404
  seen.add(id);
2385
2405
  const stamp = await stampOf(meta);
2406
+ if (stamp !== null && unreadable.get(id) === stamp) continue;
2386
2407
  const hit = cache.get(id);
2387
2408
  if (hit !== void 0 && stamp !== null && hit.stamp === stamp) {
2388
2409
  cache.delete(id);
@@ -2400,10 +2421,6 @@ function createUsageAggregator(persistence, options = {}) {
2400
2421
  const ledgerRow = ledger.get(id);
2401
2422
  if (ledgerRow !== void 0 && ledgerRow.stamp !== void 0 && stamp !== null && ledgerRow.stamp === stamp && (ledgerRow.foldVersion ?? 1) === 6) {
2402
2423
  const fold = deserializeFold(ledgerRow.fold);
2403
- cache.set(id, {
2404
- stamp,
2405
- fold
2406
- });
2407
2424
  folds.push({
2408
2425
  id,
2409
2426
  ...cwd === void 0 ? {} : { cwd },
@@ -2470,10 +2487,12 @@ function createUsageAggregator(persistence, options = {}) {
2470
2487
  recordLedger(id, cwd, stamp, fold);
2471
2488
  } catch (error) {
2472
2489
  skipped.push(id);
2490
+ if (stamp !== null) unreadable.set(id, stamp);
2473
2491
  console.warn("[usage-billing] skip unreadable session", id, error);
2474
2492
  }
2475
2493
  }
2476
2494
  for (const key of [...cache.keys()]) if (!seen.has(key)) cache.delete(key);
2495
+ for (const key of [...unreadable.keys()]) if (!seen.has(key)) unreadable.delete(key);
2477
2496
  while (cache.size > maxCacheSessions) {
2478
2497
  const oldest = cache.keys().next();
2479
2498
  if (oldest.done === true) break;
@@ -2496,17 +2515,7 @@ function createUsageAggregator(persistence, options = {}) {
2496
2515
  console.warn("[usage-billing] skip invalid durable ledger session", entry.id, error);
2497
2516
  }
2498
2517
  }
2499
- if (ledgerNeedsSave) try {
2500
- await options.ledger.save({
2501
- version: 1,
2502
- updatedAt: now,
2503
- sessions: [...ledger.values()],
2504
- ...ledgerAppliedMigrations === void 0 ? {} : { appliedMigrations: ledgerAppliedMigrations }
2505
- });
2506
- ledgerNeedsSave = false;
2507
- } catch (error) {
2508
- console.warn("[usage-billing] failed to persist durable usage ledger:", error);
2509
- }
2518
+ await saveLedger(false);
2510
2519
  }
2511
2520
  if (skipped.length > 0) console.warn(`[usage-billing] aggregated ${folds.length} sessions, skipped ${skipped.length} unreadable:`, skipped);
2512
2521
  const total = emptyUsage();
@@ -2667,7 +2676,20 @@ function createUsageAggregator(persistence, options = {}) {
2667
2676
  };
2668
2677
  lastAt = now;
2669
2678
  return lastDoc;
2670
- } };
2679
+ };
2680
+ return {
2681
+ aggregate() {
2682
+ if (lastDoc !== void 0 && Date.now() - lastAt < 5e3) return Promise.resolve(lastDoc);
2683
+ inflight ??= aggregateOnce().finally(() => {
2684
+ inflight = void 0;
2685
+ });
2686
+ return inflight;
2687
+ },
2688
+ async flush() {
2689
+ if (inflight !== void 0) await inflight.catch(() => {});
2690
+ await saveLedger(true);
2691
+ }
2692
+ };
2671
2693
  }
2672
2694
  //#endregion
2673
2695
  //#region lib/types/resilience.js
@@ -4839,11 +4861,14 @@ const ENABLE_USAGE_STATS_TOOL_FIELD = "enableUsageStatsTool";
4839
4861
  *
4840
4862
  * Serves `/api/billing/usage-stats`: real usage aggregated from every
4841
4863
  * persisted session log (see `aggregate.ts`) — the browser dashboard reads it
4842
- * instead of showing an empty snapshot. When `sessionPersistence` is
4843
- * unavailable (or aggregation fails), the configured `statsPath` /
4844
- * `DSH_USAGE_STATS` / conventional JSON file is served as a fallback, and a
4845
- * missing file answers `{ error }` so the dashboard shows zeros, never
4846
- * fabricated samples.
4864
+ * instead of showing an empty snapshot. Aggregation is stale-while-revalidate:
4865
+ * requests get a short wait budget and fall back to the freshest persisted
4866
+ * snapshot while a slow full fold keeps running in the background (a heavy
4867
+ * user's first fold takes minutes and must not starve the single-process
4868
+ * host's RPC). When `sessionPersistence` is unavailable (or aggregation
4869
+ * fails), the configured `statsPath` / `DSH_USAGE_STATS` / conventional JSON
4870
+ * file is served as a fallback, and a missing file answers `{ error }` so the
4871
+ * dashboard shows zeros, never fabricated samples.
4847
4872
  */
4848
4873
  /** Peer 地址是否为回环(本地)。回环防护:本插件的端点只供本机浏览器用,
4849
4874
  * 局域网/远端请求一律拒绝,避免面板数据(含中转站 origin 与余额)外泄。 */
@@ -4917,6 +4942,10 @@ const DEFAULT_BALANCE_API_KEY_ENV = "DEEPSEEK_API_KEY";
4917
4942
  const PACKAGE_VERSION = createRequire(import.meta.url)("../package.json").version ?? "0.0.0";
4918
4943
  /** 统计快照的落盘节流(毫秒):前端 30 秒轮询,快照最多每 30 秒写一次。 */
4919
4944
  const SNAPSHOT_INTERVAL_MS = 3e4;
4945
+ /** usage-stats 响应的等待预算(毫秒):聚合在此预算内完成就回实时数据,
4946
+ * 超预算立即回最近快照(stale-while-revalidate),后台折叠继续跑。
4947
+ * 轻量用户暖缓存下的聚合是毫秒级,预算几乎不会触发。 */
4948
+ const RESPONSE_BUDGET_MS = 1500;
4920
4949
  /** 鉴权失败告警冷却(毫秒):同一 provider 在窗口内只提示一次,避免 30 秒轮询刷屏。 */
4921
4950
  const AUTH_WARN_COOLDOWN_MS = 18e5;
4922
4951
  /**
@@ -5176,6 +5205,9 @@ function apply(ctx, config = {}) {
5176
5205
  ...config.searchCallEstimateCny === void 0 ? {} : { searchCallEstimateCny: config.searchCallEstimateCny },
5177
5206
  ledger: ledgerStore
5178
5207
  });
5208
+ ctx.effect(() => () => {
5209
+ aggregator.flush();
5210
+ }, "usage-billing: ledger flush on dispose");
5179
5211
  const candidates = [
5180
5212
  config.statsPath,
5181
5213
  process.env.DSH_USAGE_STATS,
@@ -5610,30 +5642,34 @@ function apply(ctx, config = {}) {
5610
5642
  handler: async (req, res) => {
5611
5643
  if (!guardLoopback(req, res)) return;
5612
5644
  res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
5645
+ const decorate = (doc) => ({
5646
+ ...doc,
5647
+ pluginVersion: PACKAGE_VERSION,
5648
+ ...config.monthlyBudget === void 0 ? {} : { budget: config.monthlyBudget },
5649
+ ...config.lowBalanceThreshold === void 0 ? {} : { lowBalanceThreshold: config.lowBalanceThreshold }
5650
+ });
5651
+ const aggregating = aggregator.aggregate();
5652
+ aggregating.then((stats) => {
5653
+ persistSnapshot(decorate(stats));
5654
+ }, () => {});
5655
+ let fresh;
5613
5656
  try {
5614
- const stats = await aggregator.aggregate();
5615
- const injected = {
5616
- ...config.monthlyBudget === void 0 ? {} : { budget: config.monthlyBudget },
5617
- ...config.lowBalanceThreshold === void 0 ? {} : { lowBalanceThreshold: config.lowBalanceThreshold }
5618
- };
5619
- const payload = {
5620
- ...stats,
5621
- pluginVersion: PACKAGE_VERSION,
5622
- ...injected
5623
- };
5624
- persistSnapshot(payload);
5625
- res.end(JSON.stringify(payload));
5626
- return;
5657
+ fresh = await Promise.race([aggregating.then((stats) => stats), new Promise((resolve) => {
5658
+ setTimeout(() => {
5659
+ resolve(void 0);
5660
+ }, RESPONSE_BUDGET_MS).unref();
5661
+ })]);
5627
5662
  } catch (error) {
5628
5663
  console.error("[usage-billing] usage-stats aggregate failed, falling back to snapshot:", error);
5629
5664
  }
5665
+ if (fresh !== void 0) {
5666
+ res.end(JSON.stringify(decorate(fresh)));
5667
+ return;
5668
+ }
5630
5669
  for (const candidate of candidates) {
5631
5670
  const doc = await readSnapshot(candidate);
5632
5671
  if (doc === null) continue;
5633
- if (config.monthlyBudget !== void 0) doc["budget"] = config.monthlyBudget;
5634
- if (config.lowBalanceThreshold !== void 0) doc["lowBalanceThreshold"] = config.lowBalanceThreshold;
5635
- doc["pluginVersion"] = PACKAGE_VERSION;
5636
- res.end(JSON.stringify(doc));
5672
+ res.end(JSON.stringify(decorate(doc)));
5637
5673
  return;
5638
5674
  }
5639
5675
  res.end(JSON.stringify({ error: "usage stats unavailable" }));
@@ -318,6 +318,10 @@ export declare const SESSION_ROW_LIMIT = 100;
318
318
  export declare const TURN_ROW_LIMIT = 200;
319
319
  /** 聚合文档的短 TTL(毫秒):合并密集轮询,TTL 内直接复用上次的合并结果。 */
320
320
  export declare const AGGREGATE_TTL_MS = 5000;
321
+ /** 账本落盘节流间隔(毫秒):保存是 O(会话数) 的全量序列化+原子重写,重度用户
322
+ * 上万会话时单次即秒级 CPU,每轮聚合都写会周期性白占宿主事件循环。首次保存
323
+ * 不受节流(冷启动全量折叠的成果要立即落盘),节流窗口内的改动由 flush 兜底。 */
324
+ export declare const LEDGER_SAVE_INTERVAL_MS = 60000;
321
325
  /** TTFT 尖峰阈值(毫秒):超过计为一次尖峰样本,用于定位服务端抖动。 */
322
326
  export declare const PERF_SPIKE_MS = 10000;
323
327
  /** 单步性能样本(foldSession 的折叠产物;跨会话合并时按模型/小时再聚合)。 */
@@ -485,11 +489,17 @@ export declare function foldSession(events: readonly {
485
489
  /**
486
490
  * 增量聚合器:按会话缓存折叠结果,用日志文件的 mtime+size 作失效键——
487
491
  * 日志没动的会话直接复用,只有写过的会话重新折叠;整份文档另有短 TTL
488
- * 合并密集轮询。缓存活在内存里(进程重启后首次全量折叠一次)。
492
+ * 合并密集轮询。带增量状态机的活跃会话由有界 LRU 承载(进程内有效);
493
+ * 无状态机的账本行不占 LRU 名额,直接从 durable 账本反序列化复用,
494
+ * 进程重启后日志未动的会话零读取。
489
495
  */
490
496
  export interface UsageAggregator {
491
- /** Aggregate current usage, reusing cached per-session folds when their logs are untouched. */
497
+ /** Aggregate current usage, reusing cached per-session folds when their logs are untouched.
498
+ * 并发调用共享同一次进行中的折叠(in-flight 去重),不会多倍全量重读。 */
492
499
  aggregate(): Promise<UsageStatsDocument>;
500
+ /** 立刻落盘未保存的账本改动(无视节流),供插件卸载时调用;
501
+ * 进行中的聚合先等完再存,折叠失败仍保存已成功部分。 */
502
+ flush(): Promise<void>;
493
503
  }
494
504
  /**
495
505
  * Create the incremental usage aggregator.
@@ -106,6 +106,16 @@ export declare function lastSevenDays(byDay: Record<string, {
106
106
  date: string;
107
107
  cost: number;
108
108
  }[];
109
+ /**
110
+ * 本自然周(周一起算)到 `today` 的累计值(issue #39):标签「本周」的语义是
111
+ * 自然周,近 7 天滚动窗口会在周一把上周用量卷进来(周一时它与当月几乎相等,
112
+ * 用户误解为统计错误)。周一 = `getDay() === 1`;`pick` 决定日行取哪个维度。
113
+ * @param byDay - 按日聚合表(key = `YYYY-MM-DD` 本地时区)。
114
+ * @param today - 今天的本地日期戳。
115
+ * @param pick - 日行取值(缺省取 cost;token 视角传 input+output 合计)。
116
+ * @returns 本周一到今天的合计;`today` 本身不在表里(无调用)时为 0。
117
+ */
118
+ export declare function sinceMondayOf<T>(byDay: Record<string, T>, today: string, pick?: (row: T) => number): number;
109
119
  /** 会话明细行(与服务端 SessionUsageRow 同形;旧快照可能缺失整个 bySession)。 */
110
120
  interface SessionBillingRow {
111
121
  id: string;
@@ -129,9 +129,15 @@ export declare function isPeakHour(beijingHour: number): boolean;
129
129
  */
130
130
  export declare function tierAt(timeMs: number | null | undefined): PriceTierId;
131
131
  /**
132
- * 当前峰谷档位与距下次切换的时长。导出供测试:纯函数。
132
+ * 当前峰谷档位与距下一切换的时长。导出供测试:纯函数。
133
+ *
134
+ * 下一切换点统一定义为档位真正变化的最近边界:自当前时刻起逐天扫描工作日的
135
+ * 09:00 / 12:00 / 14:00 / 18:00,候选时刻的档位由 {@link tierAt} 判定——
136
+ * 周末(周六/周日)北京全天低谷、没有边界,扫描自然跳过;工作日深夜跨周末
137
+ * 时落到周一 09:00 而非周末伪边界(issue #33)。
138
+ * 最坏情形(周五 18:00 后 → 周一 09:00)约 63h,7 天窗口必然覆盖。
133
139
  * @param nowMs - 当前时刻(epoch 毫秒)。
134
- * @returns 当前档位与到下一个切换边界的毫秒数。
140
+ * @returns 当前档位与到下一切换边界的毫秒数。
135
141
  */
136
142
  export declare function tierCountdown(nowMs: number): {
137
143
  tier: PriceTierId;
@@ -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.1.7",
4
+ "version": "1.1.9",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -109,6 +109,7 @@
109
109
  "@testing-library/react": "^16.1.0",
110
110
  "@types/node": "^22.20.0",
111
111
  "@types/react": "~18.3.1",
112
+ "@types/react-dom": "^19.2.7",
112
113
  "jsdom": "^26.1.0",
113
114
  "lightningcss": "^1.32.0",
114
115
  "react": "^18.2.0",