@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/client.js +3 -3
- package/lib/index.js +76 -40
- package/lib/types/aggregate.d.ts +12 -2
- package/lib/types/client/UsageBilling.d.ts +10 -0
- package/lib/types/client/pricing.d.ts +8 -2
- package/lib/types/index.d.ts +8 -5
- package/package.json +2 -1
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
|
-
|
|
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
|
-
|
|
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.
|
|
4843
|
-
*
|
|
4844
|
-
*
|
|
4845
|
-
*
|
|
4846
|
-
*
|
|
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
|
-
|
|
5615
|
-
|
|
5616
|
-
|
|
5617
|
-
|
|
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
|
-
|
|
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" }));
|
package/lib/types/aggregate.d.ts
CHANGED
|
@@ -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;
|
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.1.
|
|
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",
|