balance-dsh 1.3.0 → 2.0.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 +6 -5
- package/lib/client.js +986 -69
- package/lib/index.js +346 -28
- package/package.json +2 -2
package/lib/index.js
CHANGED
|
@@ -47,7 +47,7 @@ async function inspectPersistedSession(ctx, sessionId) {
|
|
|
47
47
|
return legacySessionInspector(ctx, sessionId);
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
throw new Error("当前
|
|
50
|
+
throw new Error("当前 DeepSeek Harness 未提供兼容的持久化会话读取接口");
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
const DEEPSEEK_BALANCE_URL = "https://api.deepseek.com/user/balance";
|
|
@@ -159,8 +159,10 @@ function isPeakAt(timeMs, config) {
|
|
|
159
159
|
if (kind === "holiday") return false; // 法定节假日:全天按空闲计费
|
|
160
160
|
const hour = d.getUTCHours();
|
|
161
161
|
const weekday = d.getUTCDay(); // 0=周日 … 6=周六
|
|
162
|
-
|
|
163
|
-
|
|
162
|
+
// 官方定价页口径(2026-10 核对):周一至周五为工作日高峰区,其余时段
|
|
163
|
+
// 「包括周末及中国法定节假日全天」均为空闲。所以**调休上班的周末也按周末处理**,
|
|
164
|
+
// 不参与峰谷判定(CN_CALENDAR 里的 workday 条目只保留作数据,不再影响计费)。
|
|
165
|
+
return weekdays.includes(weekday) && windows.some((w) => hour >= w.start && hour < w.end);
|
|
164
166
|
}
|
|
165
167
|
|
|
166
168
|
/** 把模型字符串规整到定价表键;未识别返回 ""(用 Flash 价兜底)。
|
|
@@ -343,7 +345,7 @@ function formatBalance(balance) {
|
|
|
343
345
|
// | { status:"failed" } (网络失败;官方明确“失败绝不变成 0 余额”)
|
|
344
346
|
// client 是 AccountClientMetadata(version / locale / timezoneOffsetSeconds),
|
|
345
347
|
// 缺字段会在平台侧组装请求头时抛错,所以三个字段都要给。
|
|
346
|
-
const PLUGIN_VERSION = "
|
|
348
|
+
const PLUGIN_VERSION = "2.0.0";
|
|
347
349
|
|
|
348
350
|
/**
|
|
349
351
|
* 读取官方账号余额并归一化成与 normalizeBalance 相同的形状。
|
|
@@ -453,12 +455,75 @@ function modelAtTime(switches, time) {
|
|
|
453
455
|
return model;
|
|
454
456
|
}
|
|
455
457
|
|
|
458
|
+
/** 网页搜索 / 抓取工具按次计费(官方 2026-10-17 上线 Search/Fetch API 公告):
|
|
459
|
+
* · Search 与 Fetch 同价:**每千次 ¥5**(= ¥0.005 / 次),结果条数不影响价格;
|
|
460
|
+
* · 只有 Search 返回非空结果、Fetch 返回正文且 HTTP 200 才计费,失败/空结果/超时不计费;
|
|
461
|
+
* · **北京时间 2026-11-01 00:00 起开始收费**,此前的调用免费。
|
|
462
|
+
* 会话事件里 tool/call 带 { callId, name, arguments }(name = web_search / web_fetch),
|
|
463
|
+
* tool/result 带 { toolCallId, message: { isError, content } },按 callId 配对就能算钱。 */
|
|
464
|
+
const WEB_TOOL_BILLING_START = Date.parse("2026-11-01T00:00:00+08:00");
|
|
465
|
+
const WEB_TOOL_PRICE_PER_CALL = 5 / 1000;
|
|
466
|
+
|
|
467
|
+
/** 统计网页工具调用:次数、可计费次数与金额。
|
|
468
|
+
* 只认 tool/call 与 tool/result 配对成功、且 isError !== true 的调用(官方口径:失败不计费)。 */
|
|
469
|
+
function webToolUsage(events) {
|
|
470
|
+
const calls = new Map(); // callId -> { name, time }
|
|
471
|
+
for (const event of events ?? []) {
|
|
472
|
+
if (event.type !== "tool/call") continue;
|
|
473
|
+
const name = event.data?.name;
|
|
474
|
+
if (name !== "web_search" && name !== "web_fetch") continue;
|
|
475
|
+
const time = Number(event.time ?? 0);
|
|
476
|
+
if (!Number.isFinite(time)) continue;
|
|
477
|
+
const id = event.data?.callId;
|
|
478
|
+
if (id === void 0 || id === null) continue;
|
|
479
|
+
calls.set(String(id), { name, time });
|
|
480
|
+
}
|
|
481
|
+
let webSearchCalls = 0;
|
|
482
|
+
let webFetchCalls = 0;
|
|
483
|
+
let webSearchBillable = 0;
|
|
484
|
+
let webFetchBillable = 0;
|
|
485
|
+
let webSearchCost = 0;
|
|
486
|
+
let webFetchCost = 0;
|
|
487
|
+
const done = new Set();
|
|
488
|
+
for (const event of events ?? []) {
|
|
489
|
+
if (event.type !== "tool/result") continue;
|
|
490
|
+
const id = event.data?.toolCallId ?? event.data?.message?.toolCallId;
|
|
491
|
+
const key = id === void 0 || id === null ? "" : String(id);
|
|
492
|
+
const call = calls.get(key);
|
|
493
|
+
if (call === void 0 || done.has(key)) continue;
|
|
494
|
+
done.add(key);
|
|
495
|
+
if (event.data?.message?.isError === true) continue; // 失败不计费
|
|
496
|
+
if (call.name === "web_search") webSearchCalls += 1;
|
|
497
|
+
else webFetchCalls += 1;
|
|
498
|
+
if (call.time >= WEB_TOOL_BILLING_START) {
|
|
499
|
+
if (call.name === "web_search") {
|
|
500
|
+
webSearchBillable += 1;
|
|
501
|
+
webSearchCost += WEB_TOOL_PRICE_PER_CALL;
|
|
502
|
+
} else {
|
|
503
|
+
webFetchBillable += 1;
|
|
504
|
+
webFetchCost += WEB_TOOL_PRICE_PER_CALL;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
return {
|
|
509
|
+
webSearchCalls,
|
|
510
|
+
webFetchCalls,
|
|
511
|
+
webSearchBillable,
|
|
512
|
+
webFetchBillable,
|
|
513
|
+
webSearchCost,
|
|
514
|
+
webFetchCost,
|
|
515
|
+
webToolCost: webSearchCost + webFetchCost,
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
|
|
456
519
|
/** 从事件数组汇总 usage(按模型分段 + 跨时段分桶)。
|
|
457
520
|
* 供 live 会话与持久化(子代理)会话复用。返回按模型拆分的 byModel,及跨模型聚合字段。 */
|
|
458
521
|
function sumEventsUsage(events, config, fallbackModel = "") {
|
|
459
522
|
const switches = collectModelSwitches(events);
|
|
460
523
|
const starts = stepStartTimes(events);
|
|
461
524
|
const fallback = fallbackModel !== "" ? fallbackModel : "deepseek-v4-flash";
|
|
525
|
+
// 网页搜索 / 抓取:次数 + 按次计费金额(2026-11-01 起才收费)
|
|
526
|
+
const webTools = webToolUsage(events);
|
|
462
527
|
const latestByStep = new Map();
|
|
463
528
|
for (const event of events ?? []) {
|
|
464
529
|
if (event.type !== "assistant/message") continue;
|
|
@@ -518,6 +583,7 @@ function sumEventsUsage(events, config, fallbackModel = "") {
|
|
|
518
583
|
peak,
|
|
519
584
|
offpeak,
|
|
520
585
|
byModel: modelList,
|
|
586
|
+
...webTools,
|
|
521
587
|
};
|
|
522
588
|
}
|
|
523
589
|
|
|
@@ -565,6 +631,30 @@ const DEFAULT_SETTINGS = {
|
|
|
565
631
|
showUsagePill: true, // 输入框下方「本会话消耗」胶囊
|
|
566
632
|
showPeriodLabel: true, // 高峰/空闲标签
|
|
567
633
|
showFab: true, // 收起态悬浮圆钮
|
|
634
|
+
// 2.0.0:余额行外观。classic = 早期那版「钱包图标 + 余额 + 金额」纯文字行;
|
|
635
|
+
// timeline = 现在的「meta 行 + 扁刻度条 + 金额」。
|
|
636
|
+
footerStyle: "timeline",
|
|
637
|
+
// 2.0.0:左侧导航栏里的插件设置入口(点一下直接打开插件配置页)
|
|
638
|
+
showSidebarEntry: true,
|
|
639
|
+
// 2.0.0:右键刷新时,余额行从左往右扫过一道泛光亮带
|
|
640
|
+
refreshWave: true,
|
|
641
|
+
// 侧栏「余额与用量」入口是否已被点开过。false = 显示红点。
|
|
642
|
+
// 与版本号无关:只有用户真正点开过这个面板才会被写成 true,之后永不回退。
|
|
643
|
+
entrySeen: false,
|
|
644
|
+
// NEW 徽标的计时起点:记录「当前版本首次启动」的时间戳与版本号。
|
|
645
|
+
// 版本一变(用户升级了)就重置计时;满 3 天后客户端自动不再显示 NEW。
|
|
646
|
+
newTagVersion: "",
|
|
647
|
+
newTagSince: 0,
|
|
648
|
+
// 升级前的版本(用户上一次跑的是哪个版本)。
|
|
649
|
+
// 客户端用它和「某项功能的引入版本」比较:只有比它新的功能才标 NEW。
|
|
650
|
+
// 这样跨几个版本一次升上来的用户能看到中间所有新功能,
|
|
651
|
+
// 已经跑过某个版本的用户不会被重复提示。
|
|
652
|
+
newTagFrom: "",
|
|
653
|
+
// 逐项计时:{ [配置项 key]: { v: 引入版本, t: 首次看到的时间戳 } }。
|
|
654
|
+
// 每个新功能各自走自己的 3 天,互不影响:
|
|
655
|
+
// 旧功能没过完 3 天时又升级,也不会被这次升级重新计时——
|
|
656
|
+
// 因为它记录的版本已经是用户见过的版本了。
|
|
657
|
+
newTagSeen: {},
|
|
568
658
|
// 收起态圆钮外观
|
|
569
659
|
fabSize: 60, // 直径(px)
|
|
570
660
|
fabLeft: 44, // 距窗口左缘(px)
|
|
@@ -591,6 +681,23 @@ function clampNumber(value, min, max, fallback) {
|
|
|
591
681
|
return Math.min(max, Math.max(min, n));
|
|
592
682
|
}
|
|
593
683
|
|
|
684
|
+
/** NEW 逐项计时表:只收 { v: 非空版本串, t: 非负时间戳 } 的条目,
|
|
685
|
+
* 其余一律丢弃(宁可丢一条记录重新计时,也不要带着脏数据跑)。 */
|
|
686
|
+
function normalizeNewTagSeen(raw) {
|
|
687
|
+
if (raw == null || typeof raw !== "object" || Array.isArray(raw)) return {};
|
|
688
|
+
const out = {};
|
|
689
|
+
for (const key of Object.keys(raw)) {
|
|
690
|
+
if (typeof key !== "string" || key === "") continue;
|
|
691
|
+
const rec = raw[key];
|
|
692
|
+
if (rec == null || typeof rec !== "object" || Array.isArray(rec)) continue;
|
|
693
|
+
const v = typeof rec.v === "string" ? rec.v.trim() : "";
|
|
694
|
+
const t = Number(rec.t);
|
|
695
|
+
if (v === "" || !Number.isFinite(t) || t <= 0) continue;
|
|
696
|
+
out[key] = { v, t: Math.floor(t) };
|
|
697
|
+
}
|
|
698
|
+
return out;
|
|
699
|
+
}
|
|
700
|
+
|
|
594
701
|
/** 一组覆盖价:逐个字段校验(有限非负数才收),不填的字段留空表示「用默认价」。
|
|
595
702
|
* 四个字段都没填则整组丢弃(等价于不覆盖)。 */
|
|
596
703
|
function normalizePriceOverride(raw) {
|
|
@@ -619,6 +726,14 @@ function normalizeSettings(raw) {
|
|
|
619
726
|
showUsagePill: bool(src.showUsagePill, d.showUsagePill),
|
|
620
727
|
showPeriodLabel: bool(src.showPeriodLabel, d.showPeriodLabel),
|
|
621
728
|
showFab: bool(src.showFab, d.showFab),
|
|
729
|
+
footerStyle: src.footerStyle === "classic" ? "classic" : d.footerStyle,
|
|
730
|
+
showSidebarEntry: bool(src.showSidebarEntry, d.showSidebarEntry),
|
|
731
|
+
refreshWave: bool(src.refreshWave, d.refreshWave),
|
|
732
|
+
entrySeen: bool(src.entrySeen, d.entrySeen),
|
|
733
|
+
newTagVersion: typeof src.newTagVersion === "string" ? src.newTagVersion : d.newTagVersion,
|
|
734
|
+
newTagFrom: typeof src.newTagFrom === "string" ? src.newTagFrom : d.newTagFrom,
|
|
735
|
+
newTagSeen: normalizeNewTagSeen(src.newTagSeen),
|
|
736
|
+
newTagSince: clampNumber(src.newTagSince, 0, Number.MAX_SAFE_INTEGER, d.newTagSince),
|
|
622
737
|
fabSize: clampNumber(src.fabSize, 32, 120, d.fabSize),
|
|
623
738
|
fabLeft: clampNumber(src.fabLeft, 0, 600, d.fabLeft),
|
|
624
739
|
statsSessionLimit: clampNumber(src.statsSessionLimit, 10, 2000, d.statsSessionLimit),
|
|
@@ -667,21 +782,35 @@ function settingsFilePath(ctx) {
|
|
|
667
782
|
return `${home.replace(/[\\/]+$/, "")}${sep}${SETTINGS_FILENAME}`;
|
|
668
783
|
}
|
|
669
784
|
|
|
670
|
-
/** 读设置文件;不存在 / JSON 损坏 / 无权限一律返回 null(调用方保持默认值)。
|
|
785
|
+
/** 读设置文件;不存在 / JSON 损坏 / 无权限一律返回 null(调用方保持默认值)。
|
|
786
|
+
* 额外做一件事:老版本写下的文件里没有 showSidebarEntry 这个键,
|
|
787
|
+
* 读到这种「升级前」的文件时把侧边栏入口强制打开一次,让用户第一次升级后
|
|
788
|
+
* 能在左侧导航看到新入口(用户要求:第一次更新的用户强制显示在侧边栏)。
|
|
789
|
+
* 之后再关掉就按用户的选择来,不会反复弹回来。 */
|
|
671
790
|
async function readSettingsFile(file) {
|
|
672
791
|
if (file === "") return null;
|
|
673
792
|
try {
|
|
674
793
|
const { readFile } = await import("node:fs/promises");
|
|
675
794
|
const text = await readFile(file, "utf8");
|
|
676
|
-
|
|
677
|
-
|
|
795
|
+
const parsed = JSON.parse(text);
|
|
796
|
+
const settings = normalizeSettings(parsed);
|
|
797
|
+
if (parsed == null || typeof parsed !== "object" || !("showSidebarEntry" in parsed)) {
|
|
798
|
+
settings.showSidebarEntry = true;
|
|
799
|
+
}
|
|
800
|
+
// entrySeen 不按版本强制:文件里没写 true 的旧用户(升级上来的)
|
|
801
|
+
// 读出来就是未读,红点 + NEW 会提示他「这版加了新东西」。
|
|
802
|
+
return settings;
|
|
803
|
+
} catch (error) {
|
|
804
|
+
// 文件不存在 = 全新安装,这里不能当「未读」处理(否则新用户一装就看到一堆 NEW,
|
|
805
|
+
// 对他来说每一项都是新的,标 NEW 毫无信息量)。用字符串标记区分于「读失败」。
|
|
806
|
+
if (error && error.code === "ENOENT") return "missing";
|
|
678
807
|
return null;
|
|
679
808
|
}
|
|
680
809
|
}
|
|
681
810
|
|
|
682
811
|
/** 写设置文件:先写临时文件再改名,避免写一半把配置写坏。 */
|
|
683
812
|
async function writeSettingsFile(file, settings) {
|
|
684
|
-
if (file === "") throw new Error("无法定位
|
|
813
|
+
if (file === "") throw new Error("无法定位 DeepSeek Harness 配置目录");
|
|
685
814
|
const { mkdir, writeFile, rename } = await import("node:fs/promises");
|
|
686
815
|
const { dirname } = await import("node:path");
|
|
687
816
|
await mkdir(dirname(file), { recursive: true });
|
|
@@ -728,6 +857,35 @@ function tokenTotal(usage) {
|
|
|
728
857
|
return usage.inputTokens + usage.outputTokens + usage.cacheReadTokens + usage.cacheWriteTokens;
|
|
729
858
|
}
|
|
730
859
|
|
|
860
|
+
/** 取(或建)某天的统计桶。 */
|
|
861
|
+
function ensureDay(days, key) {
|
|
862
|
+
let day = days.get(key);
|
|
863
|
+
if (day == null) {
|
|
864
|
+
day = {
|
|
865
|
+
date: key,
|
|
866
|
+
...emptyTokenBucket(),
|
|
867
|
+
totalTokens: 0,
|
|
868
|
+
cost: 0,
|
|
869
|
+
sessionIds: new Set(),
|
|
870
|
+
// —— 以下为细分维度,供统计面板展示;不改动上方总额口径 ——
|
|
871
|
+
byModel: new Map(), // modelKey -> { model, tokens..., totalTokens, cost }
|
|
872
|
+
peak: emptyTokenBucket(),
|
|
873
|
+
offpeak: emptyTokenBucket(),
|
|
874
|
+
peakCost: 0,
|
|
875
|
+
offpeakCost: 0,
|
|
876
|
+
// 网页搜索 / 抓取的工具调用次数与按次费用(官方 ¥5/千次,2026-11-01 起计费)
|
|
877
|
+
webSearchCalls: 0,
|
|
878
|
+
webFetchCalls: 0,
|
|
879
|
+
webSearchBillable: 0,
|
|
880
|
+
webFetchBillable: 0,
|
|
881
|
+
webSearchCost: 0,
|
|
882
|
+
webFetchCost: 0,
|
|
883
|
+
};
|
|
884
|
+
days.set(key, day);
|
|
885
|
+
}
|
|
886
|
+
return day;
|
|
887
|
+
}
|
|
888
|
+
|
|
731
889
|
/** 按北京时间把一个会话的最终 assistant/message usage 聚合到每日统计。 */
|
|
732
890
|
function collectDailyUsage(events, config, fallbackModel, cutoffMs, days, sessionId) {
|
|
733
891
|
const switches = collectModelSwitches(events);
|
|
@@ -743,26 +901,52 @@ function collectDailyUsage(events, config, fallbackModel, cutoffMs, days, sessio
|
|
|
743
901
|
// 与 sumEventsUsage 同口径:按"请求发起时刻"归属日期与峰谷,拿不到才回退完成时刻
|
|
744
902
|
latestByStep.set(`${turn}/${step}`, { usage, time: starts.get(`${turn}/${step}`) ?? Number(event.time ?? 0) });
|
|
745
903
|
}
|
|
746
|
-
|
|
904
|
+
// 网页搜索 / 抓取:按调用发生的北京日期归日,金额按官方「每千次 ¥5」、且只在
|
|
905
|
+
// 2026-11-01 00:00(北京)之后计费。tool/call 与 tool/result 配对 + isError 过滤同 webToolUsage。
|
|
906
|
+
const webCalls = new Map(); // callId -> { name, time }
|
|
907
|
+
for (const event of events ?? []) {
|
|
908
|
+
if (event.type !== "tool/call") continue;
|
|
909
|
+
const name = event.data?.name;
|
|
910
|
+
if (name !== "web_search" && name !== "web_fetch") continue;
|
|
911
|
+
const time = Number(event.time ?? 0);
|
|
912
|
+
const id = event.data?.callId;
|
|
747
913
|
if (!Number.isFinite(time) || time < cutoffMs) continue;
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
914
|
+
if (id === void 0 || id === null) continue;
|
|
915
|
+
webCalls.set(String(id), { name, time });
|
|
916
|
+
}
|
|
917
|
+
if (webCalls.size > 0) {
|
|
918
|
+
const seen = new Set();
|
|
919
|
+
for (const event of events ?? []) {
|
|
920
|
+
if (event.type !== "tool/result") continue;
|
|
921
|
+
const id = event.data?.toolCallId ?? event.data?.message?.toolCallId;
|
|
922
|
+
const key = id === void 0 || id === null ? "" : String(id);
|
|
923
|
+
const call = webCalls.get(key);
|
|
924
|
+
if (call === void 0 || seen.has(key)) continue;
|
|
925
|
+
seen.add(key);
|
|
926
|
+
if (event.data?.message?.isError === true) continue;
|
|
927
|
+
const day = ensureDay(days, beijingDayKey(call.time));
|
|
928
|
+
const billable = call.time >= WEB_TOOL_BILLING_START;
|
|
929
|
+
if (call.name === "web_search") {
|
|
930
|
+
day.webSearchCalls += 1;
|
|
931
|
+
if (billable) {
|
|
932
|
+
day.webSearchBillable += 1;
|
|
933
|
+
day.webSearchCost += WEB_TOOL_PRICE_PER_CALL;
|
|
934
|
+
day.cost += WEB_TOOL_PRICE_PER_CALL;
|
|
935
|
+
}
|
|
936
|
+
} else {
|
|
937
|
+
day.webFetchCalls += 1;
|
|
938
|
+
if (billable) {
|
|
939
|
+
day.webFetchBillable += 1;
|
|
940
|
+
day.webFetchCost += WEB_TOOL_PRICE_PER_CALL;
|
|
941
|
+
day.cost += WEB_TOOL_PRICE_PER_CALL;
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
day.sessionIds.add(sessionId);
|
|
765
945
|
}
|
|
946
|
+
}
|
|
947
|
+
for (const { usage, time } of latestByStep.values()) {
|
|
948
|
+
if (!Number.isFinite(time) || time < cutoffMs) continue;
|
|
949
|
+
const day = ensureDay(days, beijingDayKey(time));
|
|
766
950
|
const model = modelAtTime(switches, time) || fallback;
|
|
767
951
|
const isPeak = isPeakAt(time, config);
|
|
768
952
|
// 分时段精确计费:高峰桶按高峰价(= 目录价 ×2)、空闲桶按目录价
|
|
@@ -810,6 +994,76 @@ function recentDayKeys(days, now = Date.now()) {
|
|
|
810
994
|
return result;
|
|
811
995
|
}
|
|
812
996
|
|
|
997
|
+
/** 只算「今天」的消耗总额,供余额行上方的 meta 行用。
|
|
998
|
+
* 与 buildUsageStats 的区别:只扫 updatedAt 落在今天的会话,通常只有几个,
|
|
999
|
+
* 所以可以随余额行刷新(缓存 60s),不必等 5 分钟的完整统计。
|
|
1000
|
+
* @returns {Promise<{ cost:number, totalTokens:number, sessions:number }>} */
|
|
1001
|
+
async function buildTodayCost(ctx, config) {
|
|
1002
|
+
const getService = typeof ctx.get === "function" ? ctx.get.bind(ctx) : null;
|
|
1003
|
+
const controller = getService?.("sessionController");
|
|
1004
|
+
const persistence = getService?.("sessionPersistence");
|
|
1005
|
+
const now = Date.now();
|
|
1006
|
+
// 北京当日 0 点对应的真实时间戳(用 UTC 分量读北京日期,再减回去)
|
|
1007
|
+
const bj = new Date(now + 8 * 60 * 60 * 1000);
|
|
1008
|
+
const dayStart = Date.UTC(bj.getUTCFullYear(), bj.getUTCMonth(), bj.getUTCDate()) - 8 * 60 * 60 * 1000;
|
|
1009
|
+
const todayKey = bj.getUTCFullYear() + "-" + String(bj.getUTCMonth() + 1).padStart(2, "0") + "-" + String(bj.getUTCDate()).padStart(2, "0");
|
|
1010
|
+
// 只挑今天动过的会话
|
|
1011
|
+
let candidates = [];
|
|
1012
|
+
if (typeof controller?.list === "function") {
|
|
1013
|
+
const listed = await controller.list({});
|
|
1014
|
+
const items = Array.isArray(listed) ? listed : (listed?.items ?? []);
|
|
1015
|
+
candidates = items
|
|
1016
|
+
.map((item) => (item && typeof item === "object" ? { id: item.sessionId ?? item.id, at: Number(item.updatedAt ?? item.createdAt ?? 0) } : null))
|
|
1017
|
+
.filter((x) => x && typeof x.id === "string" && x.id !== "");
|
|
1018
|
+
// list() 已按活动降序;万一没有 updatedAt 字段就保守全扫(上限 STATS_MAX_SESSIONS)
|
|
1019
|
+
if (candidates.some((x) => x.at > 0)) {
|
|
1020
|
+
candidates = candidates.filter((x) => x.at >= dayStart);
|
|
1021
|
+
}
|
|
1022
|
+
} else if (typeof persistence?.list === "function") {
|
|
1023
|
+
const headers = await persistence.list();
|
|
1024
|
+
const list = Array.isArray(headers) ? headers : [];
|
|
1025
|
+
candidates = list
|
|
1026
|
+
.map((h) => (h && typeof h.id === "string" ? { id: h.id, at: Number(h.updatedAt ?? h.createdAt ?? 0) } : null))
|
|
1027
|
+
.filter(Boolean);
|
|
1028
|
+
if (candidates.some((x) => x.at > 0)) {
|
|
1029
|
+
candidates = candidates.filter((x) => x.at >= dayStart);
|
|
1030
|
+
}
|
|
1031
|
+
} else {
|
|
1032
|
+
throw new Error("会话持久化服务不可用");
|
|
1033
|
+
}
|
|
1034
|
+
const selected = candidates
|
|
1035
|
+
.slice(0, clampNumber(config?.statsSessionLimit, 10, 2000, STATS_MAX_SESSIONS))
|
|
1036
|
+
.map((x) => x.id);
|
|
1037
|
+
const daily = new Map();
|
|
1038
|
+
let cursor = 0;
|
|
1039
|
+
const worker = async () => {
|
|
1040
|
+
while (cursor < selected.length) {
|
|
1041
|
+
const sessionId = selected[cursor++];
|
|
1042
|
+
try {
|
|
1043
|
+
const inspected = await inspectPersistedSession(ctx, sessionId);
|
|
1044
|
+
collectDailyUsage(
|
|
1045
|
+
inspected?.events ?? [],
|
|
1046
|
+
config,
|
|
1047
|
+
detectModel({ requestHeader: () => void 0, requestContext: () => void 0, events: inspected?.events ?? [] }),
|
|
1048
|
+
dayStart,
|
|
1049
|
+
daily,
|
|
1050
|
+
sessionId,
|
|
1051
|
+
);
|
|
1052
|
+
} catch {
|
|
1053
|
+
// 单个会话读不出来不影响总额
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
};
|
|
1057
|
+
await Promise.all(Array.from({ length: Math.min(3, selected.length) }, () => worker()));
|
|
1058
|
+
const day = daily.get(todayKey);
|
|
1059
|
+
return {
|
|
1060
|
+
date: todayKey,
|
|
1061
|
+
cost: day?.cost ?? 0,
|
|
1062
|
+
totalTokens: day?.totalTokens ?? 0,
|
|
1063
|
+
sessions: day?.sessionIds?.size ?? 0,
|
|
1064
|
+
};
|
|
1065
|
+
}
|
|
1066
|
+
|
|
813
1067
|
async function buildUsageStats(ctx, config) {
|
|
814
1068
|
const getService = typeof ctx.get === "function" ? ctx.get.bind(ctx) : null;
|
|
815
1069
|
const controller = getService?.("sessionController");
|
|
@@ -896,6 +1150,13 @@ async function buildUsageStats(ctx, config) {
|
|
|
896
1150
|
offpeak: day.offpeak,
|
|
897
1151
|
peakCost: day.peakCost,
|
|
898
1152
|
offpeakCost: day.offpeakCost,
|
|
1153
|
+
webSearchCalls: day.webSearchCalls,
|
|
1154
|
+
webFetchCalls: day.webFetchCalls,
|
|
1155
|
+
webSearchBillable: day.webSearchBillable,
|
|
1156
|
+
webFetchBillable: day.webFetchBillable,
|
|
1157
|
+
webSearchCost: day.webSearchCost,
|
|
1158
|
+
webFetchCost: day.webFetchCost,
|
|
1159
|
+
webToolCost: day.webSearchCost + day.webFetchCost,
|
|
899
1160
|
};
|
|
900
1161
|
});
|
|
901
1162
|
const total = rows.reduce((acc, day) => {
|
|
@@ -910,6 +1171,13 @@ async function buildUsageStats(ctx, config) {
|
|
|
910
1171
|
addTokenUsage(acc.offpeak, day.offpeak);
|
|
911
1172
|
acc.peakCost += day.peakCost;
|
|
912
1173
|
acc.offpeakCost += day.offpeakCost;
|
|
1174
|
+
acc.webSearchCalls += day.webSearchCalls;
|
|
1175
|
+
acc.webFetchCalls += day.webFetchCalls;
|
|
1176
|
+
acc.webSearchBillable += day.webSearchBillable;
|
|
1177
|
+
acc.webFetchBillable += day.webFetchBillable;
|
|
1178
|
+
acc.webSearchCost += day.webSearchCost;
|
|
1179
|
+
acc.webFetchCost += day.webFetchCost;
|
|
1180
|
+
acc.webToolCost += day.webToolCost;
|
|
913
1181
|
for (const item of day.byModel ?? []) {
|
|
914
1182
|
let entry = acc.byModel.get(item.model);
|
|
915
1183
|
if (entry == null) {
|
|
@@ -930,6 +1198,13 @@ async function buildUsageStats(ctx, config) {
|
|
|
930
1198
|
offpeak: emptyTokenBucket(),
|
|
931
1199
|
peakCost: 0,
|
|
932
1200
|
offpeakCost: 0,
|
|
1201
|
+
webSearchCalls: 0,
|
|
1202
|
+
webFetchCalls: 0,
|
|
1203
|
+
webSearchBillable: 0,
|
|
1204
|
+
webFetchBillable: 0,
|
|
1205
|
+
webSearchCost: 0,
|
|
1206
|
+
webFetchCost: 0,
|
|
1207
|
+
webToolCost: 0,
|
|
933
1208
|
});
|
|
934
1209
|
return {
|
|
935
1210
|
days: rows,
|
|
@@ -1071,6 +1346,7 @@ function writeJson(res, status, payload) {
|
|
|
1071
1346
|
}
|
|
1072
1347
|
|
|
1073
1348
|
export function apply(ctx, config = {}) {
|
|
1349
|
+
let todayCache = { expiresAt: 0, value: null };
|
|
1074
1350
|
let statsCache = { expiresAt: 0, value: null };
|
|
1075
1351
|
// 用户设置(「插件」页配置卡片读写):先按出厂默认值跑,异步读到文件后热替换。
|
|
1076
1352
|
// 读不到文件不是错误——全新安装就是没有文件,保持默认值即可。
|
|
@@ -1079,9 +1355,35 @@ export function apply(ctx, config = {}) {
|
|
|
1079
1355
|
void (async () => {
|
|
1080
1356
|
settingsState.file = settingsFilePath(ctx);
|
|
1081
1357
|
const loaded = await readSettingsFile(settingsState.file);
|
|
1082
|
-
|
|
1358
|
+
let dirty = false;
|
|
1359
|
+
if (loaded === "missing") {
|
|
1360
|
+
// 全新安装:没有任何历史可回顾,红点不亮,
|
|
1361
|
+
// 并立即落盘固化,保证后续每次启动都走「已读」分支。
|
|
1362
|
+
settingsState.value = { ...DEFAULT_SETTINGS, entrySeen: true };
|
|
1363
|
+
dirty = true;
|
|
1364
|
+
} else if (loaded) {
|
|
1083
1365
|
settingsState.value = loaded;
|
|
1084
|
-
|
|
1366
|
+
}
|
|
1367
|
+
// NEW 徽标的计时:记下「这个版本首次启动」的时间。
|
|
1368
|
+
// 版本号变了(用户升级了)就重置,否则沿用上次记录的起点——
|
|
1369
|
+
// 否则每次启动都会把计时推到“现在”,永远过不了 3 天。
|
|
1370
|
+
const tagVersion = settingsState.value.newTagVersion || "";
|
|
1371
|
+
const tagSince = Number(settingsState.value.newTagSince) || 0;
|
|
1372
|
+
if (tagVersion !== PLUGIN_VERSION || !(tagSince > 0)) {
|
|
1373
|
+
// 升级:把「刚刚跑过的那个版本」存下来。首次安装时 tagVersion 为空串,
|
|
1374
|
+
// 客户端见空串就把所有 NEW_TAGS 都当新功能(新用户确实都没见过)。
|
|
1375
|
+
settingsState.value.newTagFrom = tagVersion || "";
|
|
1376
|
+
settingsState.value.newTagVersion = PLUGIN_VERSION;
|
|
1377
|
+
settingsState.value.newTagSince = Date.now();
|
|
1378
|
+
dirty = true;
|
|
1379
|
+
}
|
|
1380
|
+
effectiveConfig = withSettings(config, settingsState.value);
|
|
1381
|
+
if (dirty) {
|
|
1382
|
+
try {
|
|
1383
|
+
await writeSettingsFile(settingsState.file, settingsState.value);
|
|
1384
|
+
} catch {
|
|
1385
|
+
// 写盘失败(目录只读等)不影响这一次运行:内存里已经是最新值
|
|
1386
|
+
}
|
|
1085
1387
|
}
|
|
1086
1388
|
})();
|
|
1087
1389
|
// DSH 热重载(reload)时会重新 apply 本插件;若旧实例注册的 /balance-api 路由
|
|
@@ -1178,15 +1480,18 @@ export function apply(ctx, config = {}) {
|
|
|
1178
1480
|
let model = "";
|
|
1179
1481
|
let sessionParam = "";
|
|
1180
1482
|
let wantStats = false;
|
|
1483
|
+
let wantToday = false;
|
|
1181
1484
|
try {
|
|
1182
1485
|
const search = new URL(req.url, "http://localhost").searchParams;
|
|
1183
1486
|
model = search.get("model") ?? "";
|
|
1184
1487
|
sessionParam = search.get("session") ?? "";
|
|
1185
1488
|
wantStats = search.get("stats") === "1";
|
|
1489
|
+
wantToday = search.get("today") === "1";
|
|
1186
1490
|
} catch {
|
|
1187
1491
|
model = "";
|
|
1188
1492
|
sessionParam = "";
|
|
1189
1493
|
wantStats = false;
|
|
1494
|
+
wantToday = false;
|
|
1190
1495
|
}
|
|
1191
1496
|
let stats = null;
|
|
1192
1497
|
let statsError = "";
|
|
@@ -1203,6 +1508,18 @@ export function apply(ctx, config = {}) {
|
|
|
1203
1508
|
statsError = error instanceof Error ? error.message : String(error);
|
|
1204
1509
|
}
|
|
1205
1510
|
}
|
|
1511
|
+
// 今日消耗:只扫今天动过的会话,60 秒缓存,供余额行上方的 meta 行随余额一起刷新
|
|
1512
|
+
let today = null;
|
|
1513
|
+
if (wantToday) {
|
|
1514
|
+
try {
|
|
1515
|
+
if (todayCache.value == null || todayCache.expiresAt <= Date.now()) {
|
|
1516
|
+
todayCache = { value: await buildTodayCost(httpCtx, effectiveConfig), expiresAt: Date.now() + 60 * 1000 };
|
|
1517
|
+
}
|
|
1518
|
+
today = todayCache.value;
|
|
1519
|
+
} catch {
|
|
1520
|
+
today = null; // 拿不到就不显示,不阻塞余额
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1206
1523
|
// 按 session id 取会话并分时段汇总 usage(拿不到则返回 null,客户端回退用本地投影)
|
|
1207
1524
|
let usage = null;
|
|
1208
1525
|
// 子代理后代会话的 usage 汇总(与主会话分开)
|
|
@@ -1273,6 +1590,7 @@ export function apply(ctx, config = {}) {
|
|
|
1273
1590
|
subagentUsage,
|
|
1274
1591
|
stats,
|
|
1275
1592
|
statsError,
|
|
1593
|
+
today,
|
|
1276
1594
|
});
|
|
1277
1595
|
},
|
|
1278
1596
|
});
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "balance-dsh",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "在
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "在 DeepSeek Harness 中显示 DeepSeek 账户余额和当前会话 token/费用消耗(含高峰/空闲峰谷计费)。仅支持 DeepSeek 官方模型与账号,暂不支持其他大模型。",
|
|
5
5
|
"author": "Rannist",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "lib/index.js",
|