balance-dsh 1.2.3 → 1.3.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/README.md +29 -6
- package/lib/client.js +616 -195
- package/lib/index.js +298 -39
- package/package.json +2 -2
package/lib/index.js
CHANGED
|
@@ -175,14 +175,15 @@ function modelKey(model) {
|
|
|
175
175
|
return "";
|
|
176
176
|
}
|
|
177
177
|
|
|
178
|
-
/**
|
|
179
|
-
* era = "legacy" 时取调价前的历史价(目前仅 flash 系列与现价不同)。
|
|
180
|
-
|
|
178
|
+
/** 目录价(**不含峰谷倍率**):模型基价 + 覆盖价(`config.priceByModel[模型]` 优先,其次 `config.price`)。
|
|
179
|
+
* era = "legacy" 时取调价前的历史价(目前仅 flash 系列与现价不同)。
|
|
180
|
+
* 注意:用户在配置界面填了覆盖价时,该价对所有计费时代生效(他是主动要改价,不是历史重算)。
|
|
181
|
+
* ⚠️ 精确计费(estimateCost)必须用这个**原价**,否则会与高峰倍率重复相乘(见 estimateCost 注释)。 */
|
|
182
|
+
function catalogPricing(config, model = "", era = "current") {
|
|
181
183
|
const key = modelKey(model);
|
|
182
184
|
const base = (era === "legacy" && LEGACY_MODEL_PRICING[key]) || MODEL_PRICING[key] || FALLBACK_PRICING;
|
|
183
|
-
const price = config?.price ?? {};
|
|
184
|
-
const
|
|
185
|
-
const pick = (name, fallback) => Number(price[name] ?? fallback) * multiplier;
|
|
185
|
+
const price = config?.priceByModel?.[key] ?? config?.price ?? {};
|
|
186
|
+
const pick = (name, fallback) => Number(price[name] ?? fallback);
|
|
186
187
|
return {
|
|
187
188
|
inputCnyPerMillion: pick("inputCnyPerMillion", base.inputCnyPerMillion),
|
|
188
189
|
cacheReadCnyPerMillion: pick("cacheReadCnyPerMillion", base.cacheReadCnyPerMillion),
|
|
@@ -191,6 +192,26 @@ function resolvePricing(config, model = "", era = "current") {
|
|
|
191
192
|
};
|
|
192
193
|
}
|
|
193
194
|
|
|
195
|
+
/** 高峰时段价格倍率:`config.peakMultiplier`(旧的 `config.peak: true` 等价于 ×2)。 */
|
|
196
|
+
function peakMultiplierOf(config) {
|
|
197
|
+
const custom = Number(config?.peakMultiplier);
|
|
198
|
+
if (Number.isFinite(custom) && custom > 0) return custom;
|
|
199
|
+
return config?.peak === true ? 2 : 1;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** 当前时段价 = 目录价 × 高峰倍率。**仅用于展示**(客户端按它估算"此刻"的单价);
|
|
203
|
+
* 精确计费请用 `catalogPricing` + `peakMultiplierOf` 组合,不要用这个函数的结果再乘倍率。 */
|
|
204
|
+
function resolvePricing(config, model = "", era = "current") {
|
|
205
|
+
const base = catalogPricing(config, model, era);
|
|
206
|
+
const multiplier = peakMultiplierOf(config);
|
|
207
|
+
return {
|
|
208
|
+
inputCnyPerMillion: base.inputCnyPerMillion * multiplier,
|
|
209
|
+
cacheReadCnyPerMillion: base.cacheReadCnyPerMillion * multiplier,
|
|
210
|
+
cacheWriteCnyPerMillion: base.cacheWriteCnyPerMillion * multiplier,
|
|
211
|
+
outputCnyPerMillion: base.outputCnyPerMillion * multiplier,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
194
215
|
/** 取会话事件数组。DSH 2.0.4 起 live session **不再有 `.events` 属性**,改用 `snapshotEvents()` 方法
|
|
195
216
|
* (签名 `snapshotEvents(fromSeq = 0, toSeqExclusive = seq)`,可无参调用取全部事件);
|
|
196
217
|
* 旧版仍读 `.events` 属性。两者都拿不到时返回空数组(由调用方降级)。 */
|
|
@@ -322,7 +343,7 @@ function formatBalance(balance) {
|
|
|
322
343
|
// | { status:"failed" } (网络失败;官方明确“失败绝不变成 0 余额”)
|
|
323
344
|
// client 是 AccountClientMetadata(version / locale / timezoneOffsetSeconds),
|
|
324
345
|
// 缺字段会在平台侧组装请求头时抛错,所以三个字段都要给。
|
|
325
|
-
const PLUGIN_VERSION = "1.
|
|
346
|
+
const PLUGIN_VERSION = "1.3.0";
|
|
326
347
|
|
|
327
348
|
/**
|
|
328
349
|
* 读取官方账号余额并归一化成与 normalizeBalance 相同的形状。
|
|
@@ -371,8 +392,7 @@ function normalizeAccountBalance(result) {
|
|
|
371
392
|
}
|
|
372
393
|
|
|
373
394
|
/** 取余额:官方账号优先,回退 DeepSeek API Key。返回 { balance, error },二者必有其一。 */
|
|
374
|
-
async function resolveBalance(ctx, config, signal) {
|
|
375
|
-
const fromAccount = await fetchAccountBalance(ctx);
|
|
395
|
+
async function resolveBalance(ctx, config, signal) { const fromAccount = await fetchAccountBalance(ctx);
|
|
376
396
|
if (fromAccount !== null) return { balance: fromAccount, error: null };
|
|
377
397
|
const apiKey = await resolveApiKey(ctx, config);
|
|
378
398
|
if (apiKey === "") {
|
|
@@ -404,6 +424,25 @@ function collectModelSwitches(events) {
|
|
|
404
424
|
return switches;
|
|
405
425
|
}
|
|
406
426
|
|
|
427
|
+
/** 建立 `turn/step` → **请求发起时刻** 的映射(取自 step/start 事件)。
|
|
428
|
+
* 峰谷判定、模型归属、价格时代都应该看"发起时刻",而不是 assistant/message 的完成时刻:
|
|
429
|
+
* 请求跨越时段边界时(如 11:59 发起、12:01 完成),按发起时刻归属才符合"按请求到达时刻计费"。
|
|
430
|
+
* 实测平均请求耗时 5.6 秒、最长 127 秒,撞上边界的概率极低,但这样更严谨。
|
|
431
|
+
* 拿不到 step/start(旧数据、部分子代理会话)时由调用方回退到完成时刻。 */
|
|
432
|
+
function stepStartTimes(events) {
|
|
433
|
+
const starts = new Map();
|
|
434
|
+
for (const event of events ?? []) {
|
|
435
|
+
if (event.type !== "step/start") continue;
|
|
436
|
+
const turn = event.data?.turn;
|
|
437
|
+
const step = event.data?.step;
|
|
438
|
+
if (turn === void 0 || step === void 0) continue;
|
|
439
|
+
const time = Number(event.time);
|
|
440
|
+
if (!Number.isFinite(time)) continue;
|
|
441
|
+
starts.set(`${turn}/${step}`, time);
|
|
442
|
+
}
|
|
443
|
+
return starts;
|
|
444
|
+
}
|
|
445
|
+
|
|
407
446
|
/** 找到 time 之前最近一次模型切换点的模型;无切换点(或早于第一个)返回 ""(调用方回退到后备模型)。 */
|
|
408
447
|
function modelAtTime(switches, time) {
|
|
409
448
|
let model = "";
|
|
@@ -418,6 +457,7 @@ function modelAtTime(switches, time) {
|
|
|
418
457
|
* 供 live 会话与持久化(子代理)会话复用。返回按模型拆分的 byModel,及跨模型聚合字段。 */
|
|
419
458
|
function sumEventsUsage(events, config, fallbackModel = "") {
|
|
420
459
|
const switches = collectModelSwitches(events);
|
|
460
|
+
const starts = stepStartTimes(events);
|
|
421
461
|
const fallback = fallbackModel !== "" ? fallbackModel : "deepseek-v4-flash";
|
|
422
462
|
const latestByStep = new Map();
|
|
423
463
|
for (const event of events ?? []) {
|
|
@@ -427,7 +467,9 @@ function sumEventsUsage(events, config, fallbackModel = "") {
|
|
|
427
467
|
const turn = event.data?.turn;
|
|
428
468
|
const step = event.data?.step;
|
|
429
469
|
if (turn === void 0 || step === void 0) continue;
|
|
430
|
-
|
|
470
|
+
// 时刻取"请求发起时刻"(step/start),拿不到时回退 assistant/message 的完成时刻
|
|
471
|
+
const time = starts.get(`${turn}/${step}`) ?? event.time ?? Date.now();
|
|
472
|
+
latestByStep.set(`${turn}/${step}`, { usage, time });
|
|
431
473
|
}
|
|
432
474
|
const add = (bucket, usage) => {
|
|
433
475
|
bucket.inputTokens += usage.inputTokens ?? 0;
|
|
@@ -451,7 +493,7 @@ function sumEventsUsage(events, config, fallbackModel = "") {
|
|
|
451
493
|
const offpeak = empty();
|
|
452
494
|
const modelList = [];
|
|
453
495
|
for (const e of byModel.values()) {
|
|
454
|
-
modelList.push({ model: e.model, era: e.era, peak: e.peak, offpeak: e.offpeak, cost: estimateCost(e,
|
|
496
|
+
modelList.push({ model: e.model, era: e.era, peak: e.peak, offpeak: e.offpeak, cost: estimateCost(e, catalogPricing(config, e.model, e.era), peakMultiplierOf(config)) });
|
|
455
497
|
peak.inputTokens += e.peak.inputTokens;
|
|
456
498
|
peak.outputTokens += e.peak.outputTokens;
|
|
457
499
|
peak.cacheReadTokens += e.peak.cacheReadTokens;
|
|
@@ -485,22 +527,188 @@ function sumSessionUsage(session, config) {
|
|
|
485
527
|
return sumEventsUsage(sessionEvents(session), config, detectModel(session));
|
|
486
528
|
}
|
|
487
529
|
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
530
|
+
/** 精确计费:高峰桶按**目录价 × 倍率**、空闲桶按目录价。
|
|
531
|
+
* ⚠️ `pricing` 必须是 `catalogPricing()` 的**原价**(不含倍率)。历史上这里收的是
|
|
532
|
+
* `resolvePricing()` 的结果(已含倍率),再乘一次 2 导致高峰 ×4、空闲 ×2 —— 金额虚高约 3 倍。 */
|
|
533
|
+
function estimateCost(usage, pricing, multiplier = 2) {
|
|
534
|
+
const costOf = (u, factor) =>
|
|
535
|
+
((u.inputTokens * pricing.inputCnyPerMillion +
|
|
491
536
|
u.cacheReadTokens * pricing.cacheReadCnyPerMillion +
|
|
492
537
|
u.cacheWriteTokens * pricing.cacheWriteCnyPerMillion) /
|
|
493
538
|
1_000_000 +
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
return costOf(usage.peak) * 2 + costOf(usage.offpeak);
|
|
539
|
+
(u.outputTokens * pricing.outputCnyPerMillion) / 1_000_000) *
|
|
540
|
+
factor;
|
|
541
|
+
return costOf(usage.peak, multiplier) + costOf(usage.offpeak, 1);
|
|
498
542
|
}
|
|
499
543
|
|
|
500
544
|
const STATS_DAYS = 90;
|
|
501
545
|
const STATS_MAX_SESSIONS = 200;
|
|
502
546
|
const STATS_CACHE_MS = 5 * 60 * 1000;
|
|
503
547
|
|
|
548
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
549
|
+
// 插件设置:由 DSH「插件」页 → balance-dsh 详情页的配置卡片读写(slot
|
|
550
|
+
// `plugins.bundle.config`,见 lib/client.js),落在 <dshHome>/balance-dsh.json。
|
|
551
|
+
// 刻意不接 DSH 的 settings / storageDomain 服务——那些内部 API 无兼容承诺,
|
|
552
|
+
// 而这个插件已经被 DSH 升级打断过多次;普通 JSON 文件不会因升级失效。
|
|
553
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
554
|
+
|
|
555
|
+
const SETTINGS_FILENAME = "balance-dsh.json";
|
|
556
|
+
|
|
557
|
+
/** 出厂默认值;同时是字段白名单(不在这里的键一律丢弃)。 */
|
|
558
|
+
const DEFAULT_SETTINGS = {
|
|
559
|
+
// 余额
|
|
560
|
+
refreshIntervalSec: 60, // 余额轮询间隔(秒)
|
|
561
|
+
lowBalanceThreshold: 10, // 低于该金额用警示色
|
|
562
|
+
apiKey: "", // 官方账号未登录时的回退凭据
|
|
563
|
+
// 显示
|
|
564
|
+
showBalanceRow: true, // 展开态侧栏底部的余额行
|
|
565
|
+
showUsagePill: true, // 输入框下方「本会话消耗」胶囊
|
|
566
|
+
showPeriodLabel: true, // 高峰/空闲标签
|
|
567
|
+
showFab: true, // 收起态悬浮圆钮
|
|
568
|
+
// 收起态圆钮外观
|
|
569
|
+
fabSize: 60, // 直径(px)
|
|
570
|
+
fabLeft: 44, // 距窗口左缘(px)
|
|
571
|
+
// 统计
|
|
572
|
+
statsSessionLimit: STATS_MAX_SESSIONS, // 统计扫描的最近会话数
|
|
573
|
+
statsCacheSec: 300, // 统计结果缓存秒数
|
|
574
|
+
// 计费
|
|
575
|
+
peakMultiplier: 2, // 高峰时段倍率
|
|
576
|
+
priceFlash: null, // flash 覆盖价(元/百万 token),四项全填才生效
|
|
577
|
+
pricePro: null, // v4-pro 覆盖价
|
|
578
|
+
};
|
|
579
|
+
|
|
580
|
+
const PRICE_FIELDS = [
|
|
581
|
+
"inputCnyPerMillion",
|
|
582
|
+
"cacheReadCnyPerMillion",
|
|
583
|
+
"cacheWriteCnyPerMillion",
|
|
584
|
+
"outputCnyPerMillion",
|
|
585
|
+
];
|
|
586
|
+
|
|
587
|
+
/** 夹到合法区间;非有限数回退默认值。 */
|
|
588
|
+
function clampNumber(value, min, max, fallback) {
|
|
589
|
+
const n = Number(value);
|
|
590
|
+
if (!Number.isFinite(n)) return fallback;
|
|
591
|
+
return Math.min(max, Math.max(min, n));
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/** 一组覆盖价:逐个字段校验(有限非负数才收),不填的字段留空表示「用默认价」。
|
|
595
|
+
* 四个字段都没填则整组丢弃(等价于不覆盖)。 */
|
|
596
|
+
function normalizePriceOverride(raw) {
|
|
597
|
+
if (raw == null || typeof raw !== "object") return null;
|
|
598
|
+
const out = {};
|
|
599
|
+
for (const field of PRICE_FIELDS) {
|
|
600
|
+
const input = raw[field];
|
|
601
|
+
if (input == null || input === "") continue;
|
|
602
|
+
const n = Number(input);
|
|
603
|
+
if (!Number.isFinite(n) || n < 0) continue;
|
|
604
|
+
out[field] = n;
|
|
605
|
+
}
|
|
606
|
+
return Object.keys(out).length > 0 ? out : null;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/** 校验并补全一份设置:未知键丢弃、越界值夹紧、类型不符回退默认。 */
|
|
610
|
+
function normalizeSettings(raw) {
|
|
611
|
+
const src = raw != null && typeof raw === "object" ? raw : {};
|
|
612
|
+
const d = DEFAULT_SETTINGS;
|
|
613
|
+
const bool = (value, fallback) => (typeof value === "boolean" ? value : fallback);
|
|
614
|
+
return {
|
|
615
|
+
refreshIntervalSec: clampNumber(src.refreshIntervalSec, 5, 3600, d.refreshIntervalSec),
|
|
616
|
+
lowBalanceThreshold: clampNumber(src.lowBalanceThreshold, 0, 100000, d.lowBalanceThreshold),
|
|
617
|
+
apiKey: typeof src.apiKey === "string" ? src.apiKey.trim() : d.apiKey,
|
|
618
|
+
showBalanceRow: bool(src.showBalanceRow, d.showBalanceRow),
|
|
619
|
+
showUsagePill: bool(src.showUsagePill, d.showUsagePill),
|
|
620
|
+
showPeriodLabel: bool(src.showPeriodLabel, d.showPeriodLabel),
|
|
621
|
+
showFab: bool(src.showFab, d.showFab),
|
|
622
|
+
fabSize: clampNumber(src.fabSize, 32, 120, d.fabSize),
|
|
623
|
+
fabLeft: clampNumber(src.fabLeft, 0, 600, d.fabLeft),
|
|
624
|
+
statsSessionLimit: clampNumber(src.statsSessionLimit, 10, 2000, d.statsSessionLimit),
|
|
625
|
+
statsCacheSec: clampNumber(src.statsCacheSec, 0, 3600, d.statsCacheSec),
|
|
626
|
+
peakMultiplier: clampNumber(src.peakMultiplier, 1, 10, d.peakMultiplier),
|
|
627
|
+
priceFlash: normalizePriceOverride(src.priceFlash),
|
|
628
|
+
pricePro: normalizePriceOverride(src.pricePro),
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/** 把设置折进 Cordis config,让既有的 resolvePricing / resolveApiKey / 统计逻辑无感读到。 */
|
|
633
|
+
function withSettings(config, settings) {
|
|
634
|
+
const s = settings ?? DEFAULT_SETTINGS;
|
|
635
|
+
const priceByModel = {};
|
|
636
|
+
if (s.priceFlash) priceByModel["deepseek-v4-flash"] = s.priceFlash;
|
|
637
|
+
if (s.pricePro) priceByModel["deepseek-v4-pro"] = s.pricePro;
|
|
638
|
+
return {
|
|
639
|
+
...config,
|
|
640
|
+
apiKey: s.apiKey !== "" ? s.apiKey : config?.apiKey,
|
|
641
|
+
peakMultiplier: s.peakMultiplier,
|
|
642
|
+
priceByModel,
|
|
643
|
+
statsSessionLimit: s.statsSessionLimit,
|
|
644
|
+
statsCacheMs: s.statsCacheSec * 1000,
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/** 设置文件路径:优先 DSH 自己的 home(dshHomePath 服务),再环境变量,最后 ~/.dsh。
|
|
649
|
+
* 返回空串表示定位不到,此时读写一律静默降级为「用默认值、不落盘」。 */
|
|
650
|
+
function settingsFilePath(ctx) {
|
|
651
|
+
let home = "";
|
|
652
|
+
try {
|
|
653
|
+
const svc = typeof ctx?.get === "function" ? ctx.get("dshHomePath") : null;
|
|
654
|
+
if (typeof svc === "string") home = svc;
|
|
655
|
+
else if (svc && typeof svc.path === "string") home = svc.path;
|
|
656
|
+
else if (svc && typeof svc.value === "string") home = svc.value;
|
|
657
|
+
} catch {
|
|
658
|
+
// 服务不存在或形状变了:走下面的兜底
|
|
659
|
+
}
|
|
660
|
+
if (home === "") home = String(process.env.DSH_HOME ?? "");
|
|
661
|
+
if (home === "") {
|
|
662
|
+
const base = process.env.USERPROFILE || process.env.HOME || "";
|
|
663
|
+
if (base !== "") home = `${base}${process.platform === "win32" ? "\\" : "/"}.dsh`;
|
|
664
|
+
}
|
|
665
|
+
if (home === "") return "";
|
|
666
|
+
const sep = home.includes("\\") && !home.includes("/") ? "\\" : "/";
|
|
667
|
+
return `${home.replace(/[\\/]+$/, "")}${sep}${SETTINGS_FILENAME}`;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/** 读设置文件;不存在 / JSON 损坏 / 无权限一律返回 null(调用方保持默认值)。 */
|
|
671
|
+
async function readSettingsFile(file) {
|
|
672
|
+
if (file === "") return null;
|
|
673
|
+
try {
|
|
674
|
+
const { readFile } = await import("node:fs/promises");
|
|
675
|
+
const text = await readFile(file, "utf8");
|
|
676
|
+
return normalizeSettings(JSON.parse(text));
|
|
677
|
+
} catch {
|
|
678
|
+
return null;
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/** 写设置文件:先写临时文件再改名,避免写一半把配置写坏。 */
|
|
683
|
+
async function writeSettingsFile(file, settings) {
|
|
684
|
+
if (file === "") throw new Error("无法定位 DSH 配置目录");
|
|
685
|
+
const { mkdir, writeFile, rename } = await import("node:fs/promises");
|
|
686
|
+
const { dirname } = await import("node:path");
|
|
687
|
+
await mkdir(dirname(file), { recursive: true });
|
|
688
|
+
const tmp = `${file}.tmp`;
|
|
689
|
+
await writeFile(tmp, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
|
|
690
|
+
await rename(tmp, file);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/** 读 HTTP 请求体(限制大小,避免被超大 body 拖垮)。 */
|
|
694
|
+
function readRequestBody(req, limit = 64 * 1024) {
|
|
695
|
+
return new Promise((resolve, reject) => {
|
|
696
|
+
const chunks = [];
|
|
697
|
+
let size = 0;
|
|
698
|
+
req.on("data", (chunk) => {
|
|
699
|
+
size += chunk.length;
|
|
700
|
+
if (size > limit) {
|
|
701
|
+
reject(new Error("请求体过大"));
|
|
702
|
+
if (typeof req.destroy === "function") req.destroy();
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
chunks.push(chunk);
|
|
706
|
+
});
|
|
707
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
708
|
+
req.on("error", reject);
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
|
|
504
712
|
function beijingDayKey(timeMs) {
|
|
505
713
|
return new Date(Number(timeMs) + 8 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
|
506
714
|
}
|
|
@@ -523,6 +731,7 @@ function tokenTotal(usage) {
|
|
|
523
731
|
/** 按北京时间把一个会话的最终 assistant/message usage 聚合到每日统计。 */
|
|
524
732
|
function collectDailyUsage(events, config, fallbackModel, cutoffMs, days, sessionId) {
|
|
525
733
|
const switches = collectModelSwitches(events);
|
|
734
|
+
const starts = stepStartTimes(events);
|
|
526
735
|
const fallback = fallbackModel || "deepseek-v4-flash";
|
|
527
736
|
const latestByStep = new Map();
|
|
528
737
|
for (const event of events ?? []) {
|
|
@@ -531,7 +740,8 @@ function collectDailyUsage(events, config, fallbackModel, cutoffMs, days, sessio
|
|
|
531
740
|
const turn = event.data?.turn;
|
|
532
741
|
const step = event.data?.step;
|
|
533
742
|
if (usage == null || turn == null || step == null) continue;
|
|
534
|
-
|
|
743
|
+
// 与 sumEventsUsage 同口径:按"请求发起时刻"归属日期与峰谷,拿不到才回退完成时刻
|
|
744
|
+
latestByStep.set(`${turn}/${step}`, { usage, time: starts.get(`${turn}/${step}`) ?? Number(event.time ?? 0) });
|
|
535
745
|
}
|
|
536
746
|
for (const { usage, time } of latestByStep.values()) {
|
|
537
747
|
if (!Number.isFinite(time) || time < cutoffMs) continue;
|
|
@@ -558,7 +768,7 @@ function collectDailyUsage(events, config, fallbackModel, cutoffMs, days, sessio
|
|
|
558
768
|
// 分时段精确计费:高峰桶按高峰价(= 目录价 ×2)、空闲桶按目录价
|
|
559
769
|
const bucket = { peak: emptyTokenBucket(), offpeak: emptyTokenBucket() };
|
|
560
770
|
addTokenUsage(isPeak ? bucket.peak : bucket.offpeak, usage);
|
|
561
|
-
const stepCost = estimateCost(bucket,
|
|
771
|
+
const stepCost = estimateCost(bucket, catalogPricing(config, model, pricingEraAt(time)), peakMultiplierOf(config));
|
|
562
772
|
|
|
563
773
|
addTokenUsage(day, usage);
|
|
564
774
|
const tokens = tokenTotal({
|
|
@@ -630,7 +840,7 @@ async function buildUsageStats(ctx, config) {
|
|
|
630
840
|
}
|
|
631
841
|
const selected = sessionIds
|
|
632
842
|
.filter((id) => typeof id === "string" && id !== "")
|
|
633
|
-
.slice(0, STATS_MAX_SESSIONS);
|
|
843
|
+
.slice(0, clampNumber(config?.statsSessionLimit, 10, 2000, STATS_MAX_SESSIONS));
|
|
634
844
|
const daily = new Map();
|
|
635
845
|
let cursor = 0;
|
|
636
846
|
let scannedSessions = 0;
|
|
@@ -823,16 +1033,16 @@ async function sumSubagentUsage(ctx, sessionId, config) {
|
|
|
823
1033
|
return { usage };
|
|
824
1034
|
}
|
|
825
1035
|
|
|
826
|
-
function formatUsage(usage, pricing, model = "") {
|
|
1036
|
+
function formatUsage(usage, pricing, multiplier = 2, model = "") {
|
|
827
1037
|
// 优先按 byModel 逐段累加(每段用各自模型 + 价格时代的价格,历史消耗不因调价而变);
|
|
828
|
-
// 无 byModel 时回退单一价格估算(兼容旧数据)。
|
|
1038
|
+
// 无 byModel 时回退单一价格估算(兼容旧数据)。pricing 是**目录价**,倍率单独传。
|
|
829
1039
|
const cost =
|
|
830
1040
|
Array.isArray(usage.byModel) && usage.byModel.length > 0
|
|
831
1041
|
? usage.byModel.reduce(
|
|
832
|
-
(sum, b) => sum + (typeof b.cost === "number" && Number.isFinite(b.cost) ? b.cost : estimateCost(b, pricing)),
|
|
1042
|
+
(sum, b) => sum + (typeof b.cost === "number" && Number.isFinite(b.cost) ? b.cost : estimateCost(b, pricing, multiplier)),
|
|
833
1043
|
0,
|
|
834
1044
|
)
|
|
835
|
-
: estimateCost(usage, pricing);
|
|
1045
|
+
: estimateCost(usage, pricing, multiplier);
|
|
836
1046
|
const newTokens = usage.inputTokens + usage.outputTokens;
|
|
837
1047
|
const peakTokens = usage.peak.inputTokens + usage.peak.cacheReadTokens + usage.peak.cacheWriteTokens + usage.peak.outputTokens;
|
|
838
1048
|
const offpeakTokens = usage.offpeak.inputTokens + usage.offpeak.cacheReadTokens + usage.offpeak.cacheWriteTokens + usage.offpeak.outputTokens;
|
|
@@ -862,6 +1072,18 @@ function writeJson(res, status, payload) {
|
|
|
862
1072
|
|
|
863
1073
|
export function apply(ctx, config = {}) {
|
|
864
1074
|
let statsCache = { expiresAt: 0, value: null };
|
|
1075
|
+
// 用户设置(「插件」页配置卡片读写):先按出厂默认值跑,异步读到文件后热替换。
|
|
1076
|
+
// 读不到文件不是错误——全新安装就是没有文件,保持默认值即可。
|
|
1077
|
+
const settingsState = { value: { ...DEFAULT_SETTINGS }, file: "" };
|
|
1078
|
+
let effectiveConfig = withSettings(config, settingsState.value);
|
|
1079
|
+
void (async () => {
|
|
1080
|
+
settingsState.file = settingsFilePath(ctx);
|
|
1081
|
+
const loaded = await readSettingsFile(settingsState.file);
|
|
1082
|
+
if (loaded) {
|
|
1083
|
+
settingsState.value = loaded;
|
|
1084
|
+
effectiveConfig = withSettings(config, loaded);
|
|
1085
|
+
}
|
|
1086
|
+
})();
|
|
865
1087
|
// DSH 热重载(reload)时会重新 apply 本插件;若旧实例注册的 /balance-api 路由
|
|
866
1088
|
// 未被清理,新实例再次 register 就会抛出 duplicate route,导致整个 apply 失败、
|
|
867
1089
|
// 余额不显示。这里收集每次 register 返回的 disposer,并在插件卸载(ctx.on("dispose"))
|
|
@@ -883,20 +1105,21 @@ export function apply(ctx, config = {}) {
|
|
|
883
1105
|
name: "balance",
|
|
884
1106
|
description: "查询 DeepSeek 余额与当前会话消耗",
|
|
885
1107
|
handler: async ({ agent, signal }) => {
|
|
886
|
-
const usage = sumSessionUsage(agent.session,
|
|
1108
|
+
const usage = sumSessionUsage(agent.session, effectiveConfig);
|
|
887
1109
|
const model = detectModel(agent.session);
|
|
888
|
-
const pricing =
|
|
1110
|
+
const pricing = catalogPricing(effectiveConfig, model);
|
|
1111
|
+
const multiplier = peakMultiplierOf(effectiveConfig);
|
|
889
1112
|
// 余额来源:官方账号优先(官方版内置登录,无需 API Key),回退 API Key。
|
|
890
|
-
const { balance, error } = await resolveBalance(ctx,
|
|
1113
|
+
const { balance, error } = await resolveBalance(ctx, effectiveConfig, signal);
|
|
891
1114
|
if (balance === null) {
|
|
892
1115
|
return {
|
|
893
1116
|
kind: "error",
|
|
894
|
-
text: `${error}\n\n${formatUsage(usage, pricing, model)}`,
|
|
1117
|
+
text: `${error}\n\n${formatUsage(usage, pricing, multiplier, model)}`,
|
|
895
1118
|
};
|
|
896
1119
|
}
|
|
897
1120
|
return {
|
|
898
1121
|
kind: "success",
|
|
899
|
-
text: `${formatBalance(balance)}\n\n${formatUsage(usage, pricing, model)}`,
|
|
1122
|
+
text: `${formatBalance(balance)}\n\n${formatUsage(usage, pricing, multiplier, model)}`,
|
|
900
1123
|
};
|
|
901
1124
|
},
|
|
902
1125
|
});
|
|
@@ -912,6 +1135,40 @@ export function apply(ctx, config = {}) {
|
|
|
912
1135
|
kind: "exact",
|
|
913
1136
|
path: "/balance-api",
|
|
914
1137
|
handler: async (req, res) => {
|
|
1138
|
+
// 配置读写:GET /balance-api?config=1 读设置,POST 同路径写设置。
|
|
1139
|
+
// DSH「插件」页 → balance-dsh → 配置卡片(lib/client.js 注册的
|
|
1140
|
+
// plugins.bundle.config)就走这两个入口。
|
|
1141
|
+
let configParams = null;
|
|
1142
|
+
try {
|
|
1143
|
+
configParams = new URL(req.url, "http://localhost").searchParams;
|
|
1144
|
+
} catch {
|
|
1145
|
+
configParams = null;
|
|
1146
|
+
}
|
|
1147
|
+
if (configParams?.get("config") === "1") {
|
|
1148
|
+
try {
|
|
1149
|
+
if (req.method === "GET") {
|
|
1150
|
+
writeJson(res, 200, { ok: true, settings: settingsState.value, version: PLUGIN_VERSION });
|
|
1151
|
+
return;
|
|
1152
|
+
}
|
|
1153
|
+
if (req.method !== "POST") {
|
|
1154
|
+
writeJson(res, 405, { ok: false, error: "method not allowed" });
|
|
1155
|
+
return;
|
|
1156
|
+
}
|
|
1157
|
+
const body = await readRequestBody(req);
|
|
1158
|
+
const parsed = body.trim() === "" ? {} : JSON.parse(body);
|
|
1159
|
+
const patch = parsed != null && typeof parsed === "object" && parsed.settings != null ? parsed.settings : parsed;
|
|
1160
|
+
const next = normalizeSettings({ ...settingsState.value, ...(patch ?? {}) });
|
|
1161
|
+
await writeSettingsFile(settingsState.file || settingsFilePath(ctx), next);
|
|
1162
|
+
settingsState.value = next;
|
|
1163
|
+
effectiveConfig = withSettings(config, next);
|
|
1164
|
+
// 价格与扫描范围可能变了,作废统计缓存
|
|
1165
|
+
statsCache = { expiresAt: 0, value: null };
|
|
1166
|
+
writeJson(res, 200, { ok: true, settings: next, version: PLUGIN_VERSION });
|
|
1167
|
+
} catch (error) {
|
|
1168
|
+
writeJson(res, 400, { ok: false, error: error instanceof Error ? error.message : String(error) });
|
|
1169
|
+
}
|
|
1170
|
+
return;
|
|
1171
|
+
}
|
|
915
1172
|
if (req.method !== "GET") {
|
|
916
1173
|
writeJson(res, 405, { ok: false, error: "method not allowed" });
|
|
917
1174
|
return;
|
|
@@ -937,8 +1194,8 @@ export function apply(ctx, config = {}) {
|
|
|
937
1194
|
try {
|
|
938
1195
|
if (statsCache.value == null || statsCache.expiresAt <= Date.now()) {
|
|
939
1196
|
statsCache = {
|
|
940
|
-
value: await buildUsageStats(httpCtx,
|
|
941
|
-
expiresAt: Date.now() + STATS_CACHE_MS,
|
|
1197
|
+
value: await buildUsageStats(httpCtx, effectiveConfig),
|
|
1198
|
+
expiresAt: Date.now() + (effectiveConfig.statsCacheMs ?? STATS_CACHE_MS),
|
|
942
1199
|
};
|
|
943
1200
|
}
|
|
944
1201
|
stats = statsCache.value;
|
|
@@ -957,7 +1214,7 @@ export function apply(ctx, config = {}) {
|
|
|
957
1214
|
const sessions = ctx.get("sessions");
|
|
958
1215
|
const session = sessions && typeof sessions.get === "function" ? sessions.get(sessionParam) : void 0;
|
|
959
1216
|
if (session !== void 0) {
|
|
960
|
-
usage = sumSessionUsage(session,
|
|
1217
|
+
usage = sumSessionUsage(session, effectiveConfig);
|
|
961
1218
|
detectedModel = detectModel(session);
|
|
962
1219
|
}
|
|
963
1220
|
// DSH 2.0.4 起 live session(sessions.get)可能拿不到 events,导致 usage 全 0。
|
|
@@ -966,7 +1223,7 @@ export function apply(ctx, config = {}) {
|
|
|
966
1223
|
try {
|
|
967
1224
|
const persisted = await inspectPersistedSession(ctx, sessionParam);
|
|
968
1225
|
if (Array.isArray(persisted?.events) && persisted.events.length > 0) {
|
|
969
|
-
const persistedUsage = sumEventsUsage(persisted.events,
|
|
1226
|
+
const persistedUsage = sumEventsUsage(persisted.events, effectiveConfig, detectedModel);
|
|
970
1227
|
if (persistedUsage.totalTokens > 0) {
|
|
971
1228
|
usage = persistedUsage;
|
|
972
1229
|
if (detectedModel === "") {
|
|
@@ -982,7 +1239,7 @@ export function apply(ctx, config = {}) {
|
|
|
982
1239
|
// 仍无任何 token 就返回 null,让客户端回退本地投影估算(而不是当成 0 消耗显示 ¥0.0000)
|
|
983
1240
|
if (usage != null && usage.totalTokens === 0) usage = null;
|
|
984
1241
|
// 子代理枚举走持久化目录(不依赖 live 会话),即使父会话不在内存也能拿到
|
|
985
|
-
const subRes = await sumSubagentUsage(httpCtx, sessionParam,
|
|
1242
|
+
const subRes = await sumSubagentUsage(httpCtx, sessionParam, effectiveConfig);
|
|
986
1243
|
subagentUsage = subRes.usage;
|
|
987
1244
|
} catch {
|
|
988
1245
|
usage = null;
|
|
@@ -993,13 +1250,13 @@ export function apply(ctx, config = {}) {
|
|
|
993
1250
|
const effectiveModel = model || detectedModel;
|
|
994
1251
|
// 逐模型标注 cost(高峰期×2、空闲期×1),客户端直接按各模型 cost 累加,无需自己定价。
|
|
995
1252
|
if (usage && Array.isArray(usage.byModel)) {
|
|
996
|
-
usage.byModel = usage.byModel.map((b) => ({ ...b, cost: estimateCost(b,
|
|
1253
|
+
usage.byModel = usage.byModel.map((b) => ({ ...b, cost: estimateCost(b, catalogPricing(effectiveConfig, b.model, b.era), peakMultiplierOf(effectiveConfig)) }));
|
|
997
1254
|
}
|
|
998
1255
|
if (subagentUsage && Array.isArray(subagentUsage.byModel)) {
|
|
999
|
-
subagentUsage.byModel = subagentUsage.byModel.map((b) => ({ ...b, cost: estimateCost(b,
|
|
1256
|
+
subagentUsage.byModel = subagentUsage.byModel.map((b) => ({ ...b, cost: estimateCost(b, catalogPricing(effectiveConfig, b.model, b.era), peakMultiplierOf(effectiveConfig)) }));
|
|
1000
1257
|
}
|
|
1001
1258
|
// 余额来源:官方账号优先(官方桌面版内置登录,无需 API Key),回退 DeepSeek API Key。
|
|
1002
|
-
const { balance, error: balanceError } = await resolveBalance(ctx,
|
|
1259
|
+
const { balance, error: balanceError } = await resolveBalance(ctx, effectiveConfig);
|
|
1003
1260
|
if (balance === null && !wantStats) {
|
|
1004
1261
|
writeJson(res, 200, { ok: false, error: balanceError });
|
|
1005
1262
|
return;
|
|
@@ -1008,7 +1265,9 @@ export function apply(ctx, config = {}) {
|
|
|
1008
1265
|
ok: true,
|
|
1009
1266
|
balance,
|
|
1010
1267
|
balanceError: balance === null ? balanceError : null,
|
|
1011
|
-
|
|
1268
|
+
// 传给客户端的是**目录价**(不含峰谷倍率):客户端自己按高峰时段乘倍率,
|
|
1269
|
+
// 若这里给 resolvePricing(已乘倍率)客户端会再乘一次,金额虚高约 3 倍。
|
|
1270
|
+
pricing: catalogPricing(effectiveConfig, effectiveModel),
|
|
1012
1271
|
model: effectiveModel,
|
|
1013
1272
|
usage,
|
|
1014
1273
|
subagentUsage,
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "balance-dsh",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.3.0",
|
|
4
|
+
"description": "在 DSH 中显示 DeepSeek 账户余额和当前会话 token/费用消耗(含高峰/空闲峰谷计费)。仅支持 DeepSeek 官方模型与账号,暂不支持其他大模型。",
|
|
5
5
|
"author": "Rannist",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "lib/index.js",
|