balance-dsh 1.2.3 → 1.3.1
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 +639 -197
- package/lib/index.js +456 -57
- 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.1";
|
|
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 = "";
|
|
@@ -414,11 +453,75 @@ function modelAtTime(switches, time) {
|
|
|
414
453
|
return model;
|
|
415
454
|
}
|
|
416
455
|
|
|
456
|
+
/** 网页搜索 / 抓取工具按次计费(官方 2026-10-17 上线 Search/Fetch API 公告):
|
|
457
|
+
* · Search 与 Fetch 同价:**每千次 ¥5**(= ¥0.005 / 次),结果条数不影响价格;
|
|
458
|
+
* · 只有 Search 返回非空结果、Fetch 返回正文且 HTTP 200 才计费,失败/空结果/超时不计费;
|
|
459
|
+
* · **北京时间 2026-11-01 00:00 起开始收费**,此前的调用免费。
|
|
460
|
+
* 会话事件里 tool/call 带 { callId, name, arguments }(name = web_search / web_fetch),
|
|
461
|
+
* tool/result 带 { toolCallId, message: { isError, content } },按 callId 配对就能算钱。 */
|
|
462
|
+
const WEB_TOOL_BILLING_START = Date.parse("2026-11-01T00:00:00+08:00");
|
|
463
|
+
const WEB_TOOL_PRICE_PER_CALL = 5 / 1000;
|
|
464
|
+
|
|
465
|
+
/** 统计网页工具调用:次数、可计费次数与金额。
|
|
466
|
+
* 只认 tool/call 与 tool/result 配对成功、且 isError !== true 的调用(官方口径:失败不计费)。 */
|
|
467
|
+
function webToolUsage(events) {
|
|
468
|
+
const calls = new Map(); // callId -> { name, time }
|
|
469
|
+
for (const event of events ?? []) {
|
|
470
|
+
if (event.type !== "tool/call") continue;
|
|
471
|
+
const name = event.data?.name;
|
|
472
|
+
if (name !== "web_search" && name !== "web_fetch") continue;
|
|
473
|
+
const time = Number(event.time ?? 0);
|
|
474
|
+
if (!Number.isFinite(time)) continue;
|
|
475
|
+
const id = event.data?.callId;
|
|
476
|
+
if (id === void 0 || id === null) continue;
|
|
477
|
+
calls.set(String(id), { name, time });
|
|
478
|
+
}
|
|
479
|
+
let webSearchCalls = 0;
|
|
480
|
+
let webFetchCalls = 0;
|
|
481
|
+
let webSearchBillable = 0;
|
|
482
|
+
let webFetchBillable = 0;
|
|
483
|
+
let webSearchCost = 0;
|
|
484
|
+
let webFetchCost = 0;
|
|
485
|
+
const done = new Set();
|
|
486
|
+
for (const event of events ?? []) {
|
|
487
|
+
if (event.type !== "tool/result") continue;
|
|
488
|
+
const id = event.data?.toolCallId ?? event.data?.message?.toolCallId;
|
|
489
|
+
const key = id === void 0 || id === null ? "" : String(id);
|
|
490
|
+
const call = calls.get(key);
|
|
491
|
+
if (call === void 0 || done.has(key)) continue;
|
|
492
|
+
done.add(key);
|
|
493
|
+
if (event.data?.message?.isError === true) continue; // 失败不计费
|
|
494
|
+
if (call.name === "web_search") webSearchCalls += 1;
|
|
495
|
+
else webFetchCalls += 1;
|
|
496
|
+
if (call.time >= WEB_TOOL_BILLING_START) {
|
|
497
|
+
if (call.name === "web_search") {
|
|
498
|
+
webSearchBillable += 1;
|
|
499
|
+
webSearchCost += WEB_TOOL_PRICE_PER_CALL;
|
|
500
|
+
} else {
|
|
501
|
+
webFetchBillable += 1;
|
|
502
|
+
webFetchCost += WEB_TOOL_PRICE_PER_CALL;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
return {
|
|
507
|
+
webSearchCalls,
|
|
508
|
+
webFetchCalls,
|
|
509
|
+
webSearchBillable,
|
|
510
|
+
webFetchBillable,
|
|
511
|
+
webSearchCost,
|
|
512
|
+
webFetchCost,
|
|
513
|
+
webToolCost: webSearchCost + webFetchCost,
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
|
|
417
517
|
/** 从事件数组汇总 usage(按模型分段 + 跨时段分桶)。
|
|
418
518
|
* 供 live 会话与持久化(子代理)会话复用。返回按模型拆分的 byModel,及跨模型聚合字段。 */
|
|
419
519
|
function sumEventsUsage(events, config, fallbackModel = "") {
|
|
420
520
|
const switches = collectModelSwitches(events);
|
|
521
|
+
const starts = stepStartTimes(events);
|
|
421
522
|
const fallback = fallbackModel !== "" ? fallbackModel : "deepseek-v4-flash";
|
|
523
|
+
// 网页搜索 / 抓取:次数 + 按次计费金额(2026-11-01 起才收费)
|
|
524
|
+
const webTools = webToolUsage(events);
|
|
422
525
|
const latestByStep = new Map();
|
|
423
526
|
for (const event of events ?? []) {
|
|
424
527
|
if (event.type !== "assistant/message") continue;
|
|
@@ -427,7 +530,9 @@ function sumEventsUsage(events, config, fallbackModel = "") {
|
|
|
427
530
|
const turn = event.data?.turn;
|
|
428
531
|
const step = event.data?.step;
|
|
429
532
|
if (turn === void 0 || step === void 0) continue;
|
|
430
|
-
|
|
533
|
+
// 时刻取"请求发起时刻"(step/start),拿不到时回退 assistant/message 的完成时刻
|
|
534
|
+
const time = starts.get(`${turn}/${step}`) ?? event.time ?? Date.now();
|
|
535
|
+
latestByStep.set(`${turn}/${step}`, { usage, time });
|
|
431
536
|
}
|
|
432
537
|
const add = (bucket, usage) => {
|
|
433
538
|
bucket.inputTokens += usage.inputTokens ?? 0;
|
|
@@ -451,7 +556,7 @@ function sumEventsUsage(events, config, fallbackModel = "") {
|
|
|
451
556
|
const offpeak = empty();
|
|
452
557
|
const modelList = [];
|
|
453
558
|
for (const e of byModel.values()) {
|
|
454
|
-
modelList.push({ model: e.model, era: e.era, peak: e.peak, offpeak: e.offpeak, cost: estimateCost(e,
|
|
559
|
+
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
560
|
peak.inputTokens += e.peak.inputTokens;
|
|
456
561
|
peak.outputTokens += e.peak.outputTokens;
|
|
457
562
|
peak.cacheReadTokens += e.peak.cacheReadTokens;
|
|
@@ -476,6 +581,7 @@ function sumEventsUsage(events, config, fallbackModel = "") {
|
|
|
476
581
|
peak,
|
|
477
582
|
offpeak,
|
|
478
583
|
byModel: modelList,
|
|
584
|
+
...webTools,
|
|
479
585
|
};
|
|
480
586
|
}
|
|
481
587
|
|
|
@@ -485,22 +591,188 @@ function sumSessionUsage(session, config) {
|
|
|
485
591
|
return sumEventsUsage(sessionEvents(session), config, detectModel(session));
|
|
486
592
|
}
|
|
487
593
|
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
594
|
+
/** 精确计费:高峰桶按**目录价 × 倍率**、空闲桶按目录价。
|
|
595
|
+
* ⚠️ `pricing` 必须是 `catalogPricing()` 的**原价**(不含倍率)。历史上这里收的是
|
|
596
|
+
* `resolvePricing()` 的结果(已含倍率),再乘一次 2 导致高峰 ×4、空闲 ×2 —— 金额虚高约 3 倍。 */
|
|
597
|
+
function estimateCost(usage, pricing, multiplier = 2) {
|
|
598
|
+
const costOf = (u, factor) =>
|
|
599
|
+
((u.inputTokens * pricing.inputCnyPerMillion +
|
|
491
600
|
u.cacheReadTokens * pricing.cacheReadCnyPerMillion +
|
|
492
601
|
u.cacheWriteTokens * pricing.cacheWriteCnyPerMillion) /
|
|
493
602
|
1_000_000 +
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
return costOf(usage.peak) * 2 + costOf(usage.offpeak);
|
|
603
|
+
(u.outputTokens * pricing.outputCnyPerMillion) / 1_000_000) *
|
|
604
|
+
factor;
|
|
605
|
+
return costOf(usage.peak, multiplier) + costOf(usage.offpeak, 1);
|
|
498
606
|
}
|
|
499
607
|
|
|
500
608
|
const STATS_DAYS = 90;
|
|
501
609
|
const STATS_MAX_SESSIONS = 200;
|
|
502
610
|
const STATS_CACHE_MS = 5 * 60 * 1000;
|
|
503
611
|
|
|
612
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
613
|
+
// 插件设置:由 DSH「插件」页 → balance-dsh 详情页的配置卡片读写(slot
|
|
614
|
+
// `plugins.bundle.config`,见 lib/client.js),落在 <dshHome>/balance-dsh.json。
|
|
615
|
+
// 刻意不接 DSH 的 settings / storageDomain 服务——那些内部 API 无兼容承诺,
|
|
616
|
+
// 而这个插件已经被 DSH 升级打断过多次;普通 JSON 文件不会因升级失效。
|
|
617
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
618
|
+
|
|
619
|
+
const SETTINGS_FILENAME = "balance-dsh.json";
|
|
620
|
+
|
|
621
|
+
/** 出厂默认值;同时是字段白名单(不在这里的键一律丢弃)。 */
|
|
622
|
+
const DEFAULT_SETTINGS = {
|
|
623
|
+
// 余额
|
|
624
|
+
refreshIntervalSec: 60, // 余额轮询间隔(秒)
|
|
625
|
+
lowBalanceThreshold: 10, // 低于该金额用警示色
|
|
626
|
+
apiKey: "", // 官方账号未登录时的回退凭据
|
|
627
|
+
// 显示
|
|
628
|
+
showBalanceRow: true, // 展开态侧栏底部的余额行
|
|
629
|
+
showUsagePill: true, // 输入框下方「本会话消耗」胶囊
|
|
630
|
+
showPeriodLabel: true, // 高峰/空闲标签
|
|
631
|
+
showFab: true, // 收起态悬浮圆钮
|
|
632
|
+
// 收起态圆钮外观
|
|
633
|
+
fabSize: 60, // 直径(px)
|
|
634
|
+
fabLeft: 44, // 距窗口左缘(px)
|
|
635
|
+
// 统计
|
|
636
|
+
statsSessionLimit: STATS_MAX_SESSIONS, // 统计扫描的最近会话数
|
|
637
|
+
statsCacheSec: 300, // 统计结果缓存秒数
|
|
638
|
+
// 计费
|
|
639
|
+
peakMultiplier: 2, // 高峰时段倍率
|
|
640
|
+
priceFlash: null, // flash 覆盖价(元/百万 token),四项全填才生效
|
|
641
|
+
pricePro: null, // v4-pro 覆盖价
|
|
642
|
+
};
|
|
643
|
+
|
|
644
|
+
const PRICE_FIELDS = [
|
|
645
|
+
"inputCnyPerMillion",
|
|
646
|
+
"cacheReadCnyPerMillion",
|
|
647
|
+
"cacheWriteCnyPerMillion",
|
|
648
|
+
"outputCnyPerMillion",
|
|
649
|
+
];
|
|
650
|
+
|
|
651
|
+
/** 夹到合法区间;非有限数回退默认值。 */
|
|
652
|
+
function clampNumber(value, min, max, fallback) {
|
|
653
|
+
const n = Number(value);
|
|
654
|
+
if (!Number.isFinite(n)) return fallback;
|
|
655
|
+
return Math.min(max, Math.max(min, n));
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/** 一组覆盖价:逐个字段校验(有限非负数才收),不填的字段留空表示「用默认价」。
|
|
659
|
+
* 四个字段都没填则整组丢弃(等价于不覆盖)。 */
|
|
660
|
+
function normalizePriceOverride(raw) {
|
|
661
|
+
if (raw == null || typeof raw !== "object") return null;
|
|
662
|
+
const out = {};
|
|
663
|
+
for (const field of PRICE_FIELDS) {
|
|
664
|
+
const input = raw[field];
|
|
665
|
+
if (input == null || input === "") continue;
|
|
666
|
+
const n = Number(input);
|
|
667
|
+
if (!Number.isFinite(n) || n < 0) continue;
|
|
668
|
+
out[field] = n;
|
|
669
|
+
}
|
|
670
|
+
return Object.keys(out).length > 0 ? out : null;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/** 校验并补全一份设置:未知键丢弃、越界值夹紧、类型不符回退默认。 */
|
|
674
|
+
function normalizeSettings(raw) {
|
|
675
|
+
const src = raw != null && typeof raw === "object" ? raw : {};
|
|
676
|
+
const d = DEFAULT_SETTINGS;
|
|
677
|
+
const bool = (value, fallback) => (typeof value === "boolean" ? value : fallback);
|
|
678
|
+
return {
|
|
679
|
+
refreshIntervalSec: clampNumber(src.refreshIntervalSec, 5, 3600, d.refreshIntervalSec),
|
|
680
|
+
lowBalanceThreshold: clampNumber(src.lowBalanceThreshold, 0, 100000, d.lowBalanceThreshold),
|
|
681
|
+
apiKey: typeof src.apiKey === "string" ? src.apiKey.trim() : d.apiKey,
|
|
682
|
+
showBalanceRow: bool(src.showBalanceRow, d.showBalanceRow),
|
|
683
|
+
showUsagePill: bool(src.showUsagePill, d.showUsagePill),
|
|
684
|
+
showPeriodLabel: bool(src.showPeriodLabel, d.showPeriodLabel),
|
|
685
|
+
showFab: bool(src.showFab, d.showFab),
|
|
686
|
+
fabSize: clampNumber(src.fabSize, 32, 120, d.fabSize),
|
|
687
|
+
fabLeft: clampNumber(src.fabLeft, 0, 600, d.fabLeft),
|
|
688
|
+
statsSessionLimit: clampNumber(src.statsSessionLimit, 10, 2000, d.statsSessionLimit),
|
|
689
|
+
statsCacheSec: clampNumber(src.statsCacheSec, 0, 3600, d.statsCacheSec),
|
|
690
|
+
peakMultiplier: clampNumber(src.peakMultiplier, 1, 10, d.peakMultiplier),
|
|
691
|
+
priceFlash: normalizePriceOverride(src.priceFlash),
|
|
692
|
+
pricePro: normalizePriceOverride(src.pricePro),
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/** 把设置折进 Cordis config,让既有的 resolvePricing / resolveApiKey / 统计逻辑无感读到。 */
|
|
697
|
+
function withSettings(config, settings) {
|
|
698
|
+
const s = settings ?? DEFAULT_SETTINGS;
|
|
699
|
+
const priceByModel = {};
|
|
700
|
+
if (s.priceFlash) priceByModel["deepseek-v4-flash"] = s.priceFlash;
|
|
701
|
+
if (s.pricePro) priceByModel["deepseek-v4-pro"] = s.pricePro;
|
|
702
|
+
return {
|
|
703
|
+
...config,
|
|
704
|
+
apiKey: s.apiKey !== "" ? s.apiKey : config?.apiKey,
|
|
705
|
+
peakMultiplier: s.peakMultiplier,
|
|
706
|
+
priceByModel,
|
|
707
|
+
statsSessionLimit: s.statsSessionLimit,
|
|
708
|
+
statsCacheMs: s.statsCacheSec * 1000,
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/** 设置文件路径:优先 DSH 自己的 home(dshHomePath 服务),再环境变量,最后 ~/.dsh。
|
|
713
|
+
* 返回空串表示定位不到,此时读写一律静默降级为「用默认值、不落盘」。 */
|
|
714
|
+
function settingsFilePath(ctx) {
|
|
715
|
+
let home = "";
|
|
716
|
+
try {
|
|
717
|
+
const svc = typeof ctx?.get === "function" ? ctx.get("dshHomePath") : null;
|
|
718
|
+
if (typeof svc === "string") home = svc;
|
|
719
|
+
else if (svc && typeof svc.path === "string") home = svc.path;
|
|
720
|
+
else if (svc && typeof svc.value === "string") home = svc.value;
|
|
721
|
+
} catch {
|
|
722
|
+
// 服务不存在或形状变了:走下面的兜底
|
|
723
|
+
}
|
|
724
|
+
if (home === "") home = String(process.env.DSH_HOME ?? "");
|
|
725
|
+
if (home === "") {
|
|
726
|
+
const base = process.env.USERPROFILE || process.env.HOME || "";
|
|
727
|
+
if (base !== "") home = `${base}${process.platform === "win32" ? "\\" : "/"}.dsh`;
|
|
728
|
+
}
|
|
729
|
+
if (home === "") return "";
|
|
730
|
+
const sep = home.includes("\\") && !home.includes("/") ? "\\" : "/";
|
|
731
|
+
return `${home.replace(/[\\/]+$/, "")}${sep}${SETTINGS_FILENAME}`;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/** 读设置文件;不存在 / JSON 损坏 / 无权限一律返回 null(调用方保持默认值)。 */
|
|
735
|
+
async function readSettingsFile(file) {
|
|
736
|
+
if (file === "") return null;
|
|
737
|
+
try {
|
|
738
|
+
const { readFile } = await import("node:fs/promises");
|
|
739
|
+
const text = await readFile(file, "utf8");
|
|
740
|
+
return normalizeSettings(JSON.parse(text));
|
|
741
|
+
} catch {
|
|
742
|
+
return null;
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/** 写设置文件:先写临时文件再改名,避免写一半把配置写坏。 */
|
|
747
|
+
async function writeSettingsFile(file, settings) {
|
|
748
|
+
if (file === "") throw new Error("无法定位 DSH 配置目录");
|
|
749
|
+
const { mkdir, writeFile, rename } = await import("node:fs/promises");
|
|
750
|
+
const { dirname } = await import("node:path");
|
|
751
|
+
await mkdir(dirname(file), { recursive: true });
|
|
752
|
+
const tmp = `${file}.tmp`;
|
|
753
|
+
await writeFile(tmp, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
|
|
754
|
+
await rename(tmp, file);
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/** 读 HTTP 请求体(限制大小,避免被超大 body 拖垮)。 */
|
|
758
|
+
function readRequestBody(req, limit = 64 * 1024) {
|
|
759
|
+
return new Promise((resolve, reject) => {
|
|
760
|
+
const chunks = [];
|
|
761
|
+
let size = 0;
|
|
762
|
+
req.on("data", (chunk) => {
|
|
763
|
+
size += chunk.length;
|
|
764
|
+
if (size > limit) {
|
|
765
|
+
reject(new Error("请求体过大"));
|
|
766
|
+
if (typeof req.destroy === "function") req.destroy();
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
chunks.push(chunk);
|
|
770
|
+
});
|
|
771
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
772
|
+
req.on("error", reject);
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
|
|
504
776
|
function beijingDayKey(timeMs) {
|
|
505
777
|
return new Date(Number(timeMs) + 8 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
|
506
778
|
}
|
|
@@ -520,9 +792,39 @@ function tokenTotal(usage) {
|
|
|
520
792
|
return usage.inputTokens + usage.outputTokens + usage.cacheReadTokens + usage.cacheWriteTokens;
|
|
521
793
|
}
|
|
522
794
|
|
|
795
|
+
/** 取(或建)某天的统计桶。 */
|
|
796
|
+
function ensureDay(days, key) {
|
|
797
|
+
let day = days.get(key);
|
|
798
|
+
if (day == null) {
|
|
799
|
+
day = {
|
|
800
|
+
date: key,
|
|
801
|
+
...emptyTokenBucket(),
|
|
802
|
+
totalTokens: 0,
|
|
803
|
+
cost: 0,
|
|
804
|
+
sessionIds: new Set(),
|
|
805
|
+
// —— 以下为细分维度,供统计面板展示;不改动上方总额口径 ——
|
|
806
|
+
byModel: new Map(), // modelKey -> { model, tokens..., totalTokens, cost }
|
|
807
|
+
peak: emptyTokenBucket(),
|
|
808
|
+
offpeak: emptyTokenBucket(),
|
|
809
|
+
peakCost: 0,
|
|
810
|
+
offpeakCost: 0,
|
|
811
|
+
// 网页搜索 / 抓取的工具调用次数与按次费用(官方 ¥5/千次,2026-11-01 起计费)
|
|
812
|
+
webSearchCalls: 0,
|
|
813
|
+
webFetchCalls: 0,
|
|
814
|
+
webSearchBillable: 0,
|
|
815
|
+
webFetchBillable: 0,
|
|
816
|
+
webSearchCost: 0,
|
|
817
|
+
webFetchCost: 0,
|
|
818
|
+
};
|
|
819
|
+
days.set(key, day);
|
|
820
|
+
}
|
|
821
|
+
return day;
|
|
822
|
+
}
|
|
823
|
+
|
|
523
824
|
/** 按北京时间把一个会话的最终 assistant/message usage 聚合到每日统计。 */
|
|
524
825
|
function collectDailyUsage(events, config, fallbackModel, cutoffMs, days, sessionId) {
|
|
525
826
|
const switches = collectModelSwitches(events);
|
|
827
|
+
const starts = stepStartTimes(events);
|
|
526
828
|
const fallback = fallbackModel || "deepseek-v4-flash";
|
|
527
829
|
const latestByStep = new Map();
|
|
528
830
|
for (const event of events ?? []) {
|
|
@@ -531,34 +833,61 @@ function collectDailyUsage(events, config, fallbackModel, cutoffMs, days, sessio
|
|
|
531
833
|
const turn = event.data?.turn;
|
|
532
834
|
const step = event.data?.step;
|
|
533
835
|
if (usage == null || turn == null || step == null) continue;
|
|
534
|
-
|
|
836
|
+
// 与 sumEventsUsage 同口径:按"请求发起时刻"归属日期与峰谷,拿不到才回退完成时刻
|
|
837
|
+
latestByStep.set(`${turn}/${step}`, { usage, time: starts.get(`${turn}/${step}`) ?? Number(event.time ?? 0) });
|
|
535
838
|
}
|
|
536
|
-
|
|
839
|
+
// 网页搜索 / 抓取:按调用发生的北京日期归日,金额按官方「每千次 ¥5」、且只在
|
|
840
|
+
// 2026-11-01 00:00(北京)之后计费。tool/call 与 tool/result 配对 + isError 过滤同 webToolUsage。
|
|
841
|
+
const webCalls = new Map(); // callId -> { name, time }
|
|
842
|
+
for (const event of events ?? []) {
|
|
843
|
+
if (event.type !== "tool/call") continue;
|
|
844
|
+
const name = event.data?.name;
|
|
845
|
+
if (name !== "web_search" && name !== "web_fetch") continue;
|
|
846
|
+
const time = Number(event.time ?? 0);
|
|
847
|
+
const id = event.data?.callId;
|
|
537
848
|
if (!Number.isFinite(time) || time < cutoffMs) continue;
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
849
|
+
if (id === void 0 || id === null) continue;
|
|
850
|
+
webCalls.set(String(id), { name, time });
|
|
851
|
+
}
|
|
852
|
+
if (webCalls.size > 0) {
|
|
853
|
+
const seen = new Set();
|
|
854
|
+
for (const event of events ?? []) {
|
|
855
|
+
if (event.type !== "tool/result") continue;
|
|
856
|
+
const id = event.data?.toolCallId ?? event.data?.message?.toolCallId;
|
|
857
|
+
const key = id === void 0 || id === null ? "" : String(id);
|
|
858
|
+
const call = webCalls.get(key);
|
|
859
|
+
if (call === void 0 || seen.has(key)) continue;
|
|
860
|
+
seen.add(key);
|
|
861
|
+
if (event.data?.message?.isError === true) continue;
|
|
862
|
+
const day = ensureDay(days, beijingDayKey(call.time));
|
|
863
|
+
const billable = call.time >= WEB_TOOL_BILLING_START;
|
|
864
|
+
if (call.name === "web_search") {
|
|
865
|
+
day.webSearchCalls += 1;
|
|
866
|
+
if (billable) {
|
|
867
|
+
day.webSearchBillable += 1;
|
|
868
|
+
day.webSearchCost += WEB_TOOL_PRICE_PER_CALL;
|
|
869
|
+
day.cost += WEB_TOOL_PRICE_PER_CALL;
|
|
870
|
+
}
|
|
871
|
+
} else {
|
|
872
|
+
day.webFetchCalls += 1;
|
|
873
|
+
if (billable) {
|
|
874
|
+
day.webFetchBillable += 1;
|
|
875
|
+
day.webFetchCost += WEB_TOOL_PRICE_PER_CALL;
|
|
876
|
+
day.cost += WEB_TOOL_PRICE_PER_CALL;
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
day.sessionIds.add(sessionId);
|
|
555
880
|
}
|
|
881
|
+
}
|
|
882
|
+
for (const { usage, time } of latestByStep.values()) {
|
|
883
|
+
if (!Number.isFinite(time) || time < cutoffMs) continue;
|
|
884
|
+
const day = ensureDay(days, beijingDayKey(time));
|
|
556
885
|
const model = modelAtTime(switches, time) || fallback;
|
|
557
886
|
const isPeak = isPeakAt(time, config);
|
|
558
887
|
// 分时段精确计费:高峰桶按高峰价(= 目录价 ×2)、空闲桶按目录价
|
|
559
888
|
const bucket = { peak: emptyTokenBucket(), offpeak: emptyTokenBucket() };
|
|
560
889
|
addTokenUsage(isPeak ? bucket.peak : bucket.offpeak, usage);
|
|
561
|
-
const stepCost = estimateCost(bucket,
|
|
890
|
+
const stepCost = estimateCost(bucket, catalogPricing(config, model, pricingEraAt(time)), peakMultiplierOf(config));
|
|
562
891
|
|
|
563
892
|
addTokenUsage(day, usage);
|
|
564
893
|
const tokens = tokenTotal({
|
|
@@ -630,7 +959,7 @@ async function buildUsageStats(ctx, config) {
|
|
|
630
959
|
}
|
|
631
960
|
const selected = sessionIds
|
|
632
961
|
.filter((id) => typeof id === "string" && id !== "")
|
|
633
|
-
.slice(0, STATS_MAX_SESSIONS);
|
|
962
|
+
.slice(0, clampNumber(config?.statsSessionLimit, 10, 2000, STATS_MAX_SESSIONS));
|
|
634
963
|
const daily = new Map();
|
|
635
964
|
let cursor = 0;
|
|
636
965
|
let scannedSessions = 0;
|
|
@@ -686,6 +1015,13 @@ async function buildUsageStats(ctx, config) {
|
|
|
686
1015
|
offpeak: day.offpeak,
|
|
687
1016
|
peakCost: day.peakCost,
|
|
688
1017
|
offpeakCost: day.offpeakCost,
|
|
1018
|
+
webSearchCalls: day.webSearchCalls,
|
|
1019
|
+
webFetchCalls: day.webFetchCalls,
|
|
1020
|
+
webSearchBillable: day.webSearchBillable,
|
|
1021
|
+
webFetchBillable: day.webFetchBillable,
|
|
1022
|
+
webSearchCost: day.webSearchCost,
|
|
1023
|
+
webFetchCost: day.webFetchCost,
|
|
1024
|
+
webToolCost: day.webSearchCost + day.webFetchCost,
|
|
689
1025
|
};
|
|
690
1026
|
});
|
|
691
1027
|
const total = rows.reduce((acc, day) => {
|
|
@@ -700,6 +1036,13 @@ async function buildUsageStats(ctx, config) {
|
|
|
700
1036
|
addTokenUsage(acc.offpeak, day.offpeak);
|
|
701
1037
|
acc.peakCost += day.peakCost;
|
|
702
1038
|
acc.offpeakCost += day.offpeakCost;
|
|
1039
|
+
acc.webSearchCalls += day.webSearchCalls;
|
|
1040
|
+
acc.webFetchCalls += day.webFetchCalls;
|
|
1041
|
+
acc.webSearchBillable += day.webSearchBillable;
|
|
1042
|
+
acc.webFetchBillable += day.webFetchBillable;
|
|
1043
|
+
acc.webSearchCost += day.webSearchCost;
|
|
1044
|
+
acc.webFetchCost += day.webFetchCost;
|
|
1045
|
+
acc.webToolCost += day.webToolCost;
|
|
703
1046
|
for (const item of day.byModel ?? []) {
|
|
704
1047
|
let entry = acc.byModel.get(item.model);
|
|
705
1048
|
if (entry == null) {
|
|
@@ -720,6 +1063,13 @@ async function buildUsageStats(ctx, config) {
|
|
|
720
1063
|
offpeak: emptyTokenBucket(),
|
|
721
1064
|
peakCost: 0,
|
|
722
1065
|
offpeakCost: 0,
|
|
1066
|
+
webSearchCalls: 0,
|
|
1067
|
+
webFetchCalls: 0,
|
|
1068
|
+
webSearchBillable: 0,
|
|
1069
|
+
webFetchBillable: 0,
|
|
1070
|
+
webSearchCost: 0,
|
|
1071
|
+
webFetchCost: 0,
|
|
1072
|
+
webToolCost: 0,
|
|
723
1073
|
});
|
|
724
1074
|
return {
|
|
725
1075
|
days: rows,
|
|
@@ -823,16 +1173,16 @@ async function sumSubagentUsage(ctx, sessionId, config) {
|
|
|
823
1173
|
return { usage };
|
|
824
1174
|
}
|
|
825
1175
|
|
|
826
|
-
function formatUsage(usage, pricing, model = "") {
|
|
1176
|
+
function formatUsage(usage, pricing, multiplier = 2, model = "") {
|
|
827
1177
|
// 优先按 byModel 逐段累加(每段用各自模型 + 价格时代的价格,历史消耗不因调价而变);
|
|
828
|
-
// 无 byModel 时回退单一价格估算(兼容旧数据)。
|
|
1178
|
+
// 无 byModel 时回退单一价格估算(兼容旧数据)。pricing 是**目录价**,倍率单独传。
|
|
829
1179
|
const cost =
|
|
830
1180
|
Array.isArray(usage.byModel) && usage.byModel.length > 0
|
|
831
1181
|
? usage.byModel.reduce(
|
|
832
|
-
(sum, b) => sum + (typeof b.cost === "number" && Number.isFinite(b.cost) ? b.cost : estimateCost(b, pricing)),
|
|
1182
|
+
(sum, b) => sum + (typeof b.cost === "number" && Number.isFinite(b.cost) ? b.cost : estimateCost(b, pricing, multiplier)),
|
|
833
1183
|
0,
|
|
834
1184
|
)
|
|
835
|
-
: estimateCost(usage, pricing);
|
|
1185
|
+
: estimateCost(usage, pricing, multiplier);
|
|
836
1186
|
const newTokens = usage.inputTokens + usage.outputTokens;
|
|
837
1187
|
const peakTokens = usage.peak.inputTokens + usage.peak.cacheReadTokens + usage.peak.cacheWriteTokens + usage.peak.outputTokens;
|
|
838
1188
|
const offpeakTokens = usage.offpeak.inputTokens + usage.offpeak.cacheReadTokens + usage.offpeak.cacheWriteTokens + usage.offpeak.outputTokens;
|
|
@@ -862,6 +1212,18 @@ function writeJson(res, status, payload) {
|
|
|
862
1212
|
|
|
863
1213
|
export function apply(ctx, config = {}) {
|
|
864
1214
|
let statsCache = { expiresAt: 0, value: null };
|
|
1215
|
+
// 用户设置(「插件」页配置卡片读写):先按出厂默认值跑,异步读到文件后热替换。
|
|
1216
|
+
// 读不到文件不是错误——全新安装就是没有文件,保持默认值即可。
|
|
1217
|
+
const settingsState = { value: { ...DEFAULT_SETTINGS }, file: "" };
|
|
1218
|
+
let effectiveConfig = withSettings(config, settingsState.value);
|
|
1219
|
+
void (async () => {
|
|
1220
|
+
settingsState.file = settingsFilePath(ctx);
|
|
1221
|
+
const loaded = await readSettingsFile(settingsState.file);
|
|
1222
|
+
if (loaded) {
|
|
1223
|
+
settingsState.value = loaded;
|
|
1224
|
+
effectiveConfig = withSettings(config, loaded);
|
|
1225
|
+
}
|
|
1226
|
+
})();
|
|
865
1227
|
// DSH 热重载(reload)时会重新 apply 本插件;若旧实例注册的 /balance-api 路由
|
|
866
1228
|
// 未被清理,新实例再次 register 就会抛出 duplicate route,导致整个 apply 失败、
|
|
867
1229
|
// 余额不显示。这里收集每次 register 返回的 disposer,并在插件卸载(ctx.on("dispose"))
|
|
@@ -883,20 +1245,21 @@ export function apply(ctx, config = {}) {
|
|
|
883
1245
|
name: "balance",
|
|
884
1246
|
description: "查询 DeepSeek 余额与当前会话消耗",
|
|
885
1247
|
handler: async ({ agent, signal }) => {
|
|
886
|
-
const usage = sumSessionUsage(agent.session,
|
|
1248
|
+
const usage = sumSessionUsage(agent.session, effectiveConfig);
|
|
887
1249
|
const model = detectModel(agent.session);
|
|
888
|
-
const pricing =
|
|
1250
|
+
const pricing = catalogPricing(effectiveConfig, model);
|
|
1251
|
+
const multiplier = peakMultiplierOf(effectiveConfig);
|
|
889
1252
|
// 余额来源:官方账号优先(官方版内置登录,无需 API Key),回退 API Key。
|
|
890
|
-
const { balance, error } = await resolveBalance(ctx,
|
|
1253
|
+
const { balance, error } = await resolveBalance(ctx, effectiveConfig, signal);
|
|
891
1254
|
if (balance === null) {
|
|
892
1255
|
return {
|
|
893
1256
|
kind: "error",
|
|
894
|
-
text: `${error}\n\n${formatUsage(usage, pricing, model)}`,
|
|
1257
|
+
text: `${error}\n\n${formatUsage(usage, pricing, multiplier, model)}`,
|
|
895
1258
|
};
|
|
896
1259
|
}
|
|
897
1260
|
return {
|
|
898
1261
|
kind: "success",
|
|
899
|
-
text: `${formatBalance(balance)}\n\n${formatUsage(usage, pricing, model)}`,
|
|
1262
|
+
text: `${formatBalance(balance)}\n\n${formatUsage(usage, pricing, multiplier, model)}`,
|
|
900
1263
|
};
|
|
901
1264
|
},
|
|
902
1265
|
});
|
|
@@ -912,6 +1275,40 @@ export function apply(ctx, config = {}) {
|
|
|
912
1275
|
kind: "exact",
|
|
913
1276
|
path: "/balance-api",
|
|
914
1277
|
handler: async (req, res) => {
|
|
1278
|
+
// 配置读写:GET /balance-api?config=1 读设置,POST 同路径写设置。
|
|
1279
|
+
// DSH「插件」页 → balance-dsh → 配置卡片(lib/client.js 注册的
|
|
1280
|
+
// plugins.bundle.config)就走这两个入口。
|
|
1281
|
+
let configParams = null;
|
|
1282
|
+
try {
|
|
1283
|
+
configParams = new URL(req.url, "http://localhost").searchParams;
|
|
1284
|
+
} catch {
|
|
1285
|
+
configParams = null;
|
|
1286
|
+
}
|
|
1287
|
+
if (configParams?.get("config") === "1") {
|
|
1288
|
+
try {
|
|
1289
|
+
if (req.method === "GET") {
|
|
1290
|
+
writeJson(res, 200, { ok: true, settings: settingsState.value, version: PLUGIN_VERSION });
|
|
1291
|
+
return;
|
|
1292
|
+
}
|
|
1293
|
+
if (req.method !== "POST") {
|
|
1294
|
+
writeJson(res, 405, { ok: false, error: "method not allowed" });
|
|
1295
|
+
return;
|
|
1296
|
+
}
|
|
1297
|
+
const body = await readRequestBody(req);
|
|
1298
|
+
const parsed = body.trim() === "" ? {} : JSON.parse(body);
|
|
1299
|
+
const patch = parsed != null && typeof parsed === "object" && parsed.settings != null ? parsed.settings : parsed;
|
|
1300
|
+
const next = normalizeSettings({ ...settingsState.value, ...(patch ?? {}) });
|
|
1301
|
+
await writeSettingsFile(settingsState.file || settingsFilePath(ctx), next);
|
|
1302
|
+
settingsState.value = next;
|
|
1303
|
+
effectiveConfig = withSettings(config, next);
|
|
1304
|
+
// 价格与扫描范围可能变了,作废统计缓存
|
|
1305
|
+
statsCache = { expiresAt: 0, value: null };
|
|
1306
|
+
writeJson(res, 200, { ok: true, settings: next, version: PLUGIN_VERSION });
|
|
1307
|
+
} catch (error) {
|
|
1308
|
+
writeJson(res, 400, { ok: false, error: error instanceof Error ? error.message : String(error) });
|
|
1309
|
+
}
|
|
1310
|
+
return;
|
|
1311
|
+
}
|
|
915
1312
|
if (req.method !== "GET") {
|
|
916
1313
|
writeJson(res, 405, { ok: false, error: "method not allowed" });
|
|
917
1314
|
return;
|
|
@@ -937,8 +1334,8 @@ export function apply(ctx, config = {}) {
|
|
|
937
1334
|
try {
|
|
938
1335
|
if (statsCache.value == null || statsCache.expiresAt <= Date.now()) {
|
|
939
1336
|
statsCache = {
|
|
940
|
-
value: await buildUsageStats(httpCtx,
|
|
941
|
-
expiresAt: Date.now() + STATS_CACHE_MS,
|
|
1337
|
+
value: await buildUsageStats(httpCtx, effectiveConfig),
|
|
1338
|
+
expiresAt: Date.now() + (effectiveConfig.statsCacheMs ?? STATS_CACHE_MS),
|
|
942
1339
|
};
|
|
943
1340
|
}
|
|
944
1341
|
stats = statsCache.value;
|
|
@@ -957,7 +1354,7 @@ export function apply(ctx, config = {}) {
|
|
|
957
1354
|
const sessions = ctx.get("sessions");
|
|
958
1355
|
const session = sessions && typeof sessions.get === "function" ? sessions.get(sessionParam) : void 0;
|
|
959
1356
|
if (session !== void 0) {
|
|
960
|
-
usage = sumSessionUsage(session,
|
|
1357
|
+
usage = sumSessionUsage(session, effectiveConfig);
|
|
961
1358
|
detectedModel = detectModel(session);
|
|
962
1359
|
}
|
|
963
1360
|
// DSH 2.0.4 起 live session(sessions.get)可能拿不到 events,导致 usage 全 0。
|
|
@@ -966,7 +1363,7 @@ export function apply(ctx, config = {}) {
|
|
|
966
1363
|
try {
|
|
967
1364
|
const persisted = await inspectPersistedSession(ctx, sessionParam);
|
|
968
1365
|
if (Array.isArray(persisted?.events) && persisted.events.length > 0) {
|
|
969
|
-
const persistedUsage = sumEventsUsage(persisted.events,
|
|
1366
|
+
const persistedUsage = sumEventsUsage(persisted.events, effectiveConfig, detectedModel);
|
|
970
1367
|
if (persistedUsage.totalTokens > 0) {
|
|
971
1368
|
usage = persistedUsage;
|
|
972
1369
|
if (detectedModel === "") {
|
|
@@ -982,7 +1379,7 @@ export function apply(ctx, config = {}) {
|
|
|
982
1379
|
// 仍无任何 token 就返回 null,让客户端回退本地投影估算(而不是当成 0 消耗显示 ¥0.0000)
|
|
983
1380
|
if (usage != null && usage.totalTokens === 0) usage = null;
|
|
984
1381
|
// 子代理枚举走持久化目录(不依赖 live 会话),即使父会话不在内存也能拿到
|
|
985
|
-
const subRes = await sumSubagentUsage(httpCtx, sessionParam,
|
|
1382
|
+
const subRes = await sumSubagentUsage(httpCtx, sessionParam, effectiveConfig);
|
|
986
1383
|
subagentUsage = subRes.usage;
|
|
987
1384
|
} catch {
|
|
988
1385
|
usage = null;
|
|
@@ -993,13 +1390,13 @@ export function apply(ctx, config = {}) {
|
|
|
993
1390
|
const effectiveModel = model || detectedModel;
|
|
994
1391
|
// 逐模型标注 cost(高峰期×2、空闲期×1),客户端直接按各模型 cost 累加,无需自己定价。
|
|
995
1392
|
if (usage && Array.isArray(usage.byModel)) {
|
|
996
|
-
usage.byModel = usage.byModel.map((b) => ({ ...b, cost: estimateCost(b,
|
|
1393
|
+
usage.byModel = usage.byModel.map((b) => ({ ...b, cost: estimateCost(b, catalogPricing(effectiveConfig, b.model, b.era), peakMultiplierOf(effectiveConfig)) }));
|
|
997
1394
|
}
|
|
998
1395
|
if (subagentUsage && Array.isArray(subagentUsage.byModel)) {
|
|
999
|
-
subagentUsage.byModel = subagentUsage.byModel.map((b) => ({ ...b, cost: estimateCost(b,
|
|
1396
|
+
subagentUsage.byModel = subagentUsage.byModel.map((b) => ({ ...b, cost: estimateCost(b, catalogPricing(effectiveConfig, b.model, b.era), peakMultiplierOf(effectiveConfig)) }));
|
|
1000
1397
|
}
|
|
1001
1398
|
// 余额来源:官方账号优先(官方桌面版内置登录,无需 API Key),回退 DeepSeek API Key。
|
|
1002
|
-
const { balance, error: balanceError } = await resolveBalance(ctx,
|
|
1399
|
+
const { balance, error: balanceError } = await resolveBalance(ctx, effectiveConfig);
|
|
1003
1400
|
if (balance === null && !wantStats) {
|
|
1004
1401
|
writeJson(res, 200, { ok: false, error: balanceError });
|
|
1005
1402
|
return;
|
|
@@ -1008,7 +1405,9 @@ export function apply(ctx, config = {}) {
|
|
|
1008
1405
|
ok: true,
|
|
1009
1406
|
balance,
|
|
1010
1407
|
balanceError: balance === null ? balanceError : null,
|
|
1011
|
-
|
|
1408
|
+
// 传给客户端的是**目录价**(不含峰谷倍率):客户端自己按高峰时段乘倍率,
|
|
1409
|
+
// 若这里给 resolvePricing(已乘倍率)客户端会再乘一次,金额虚高约 3 倍。
|
|
1410
|
+
pricing: catalogPricing(effectiveConfig, effectiveModel),
|
|
1012
1411
|
model: effectiveModel,
|
|
1013
1412
|
usage,
|
|
1014
1413
|
subagentUsage,
|