@ds-hu/dsh-token-quota 2.5.0 → 2.6.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/CHANGELOG.md +85 -2
- package/CONTEXT.md +10 -6
- package/LICENSE +24 -0
- package/README.en.md +357 -0
- package/README.md +55 -88
- package/docs/images/overview.png +0 -0
- package/docs/images/providers.png +0 -0
- package/lib/client.js +92 -34
- package/lib/index.js +135 -13
- package/lib/ledger.js +59 -0
- package/lib/usage.js +13 -2
- package/package.json +5 -2
- package/scripts/check-package.mjs +6 -1
- package/test/client.mjs +93 -3
- package/test/copy.mjs +6 -2
- package/test/metered.mjs +245 -0
- package/test/mock-dsh.mjs +12 -1
package/lib/index.js
CHANGED
|
@@ -31,11 +31,12 @@ import { readLegacySections } from "./legacy-config.js";
|
|
|
31
31
|
import { createScheduler } from "./scheduler.js";
|
|
32
32
|
import { createScanCoordinator } from "./scan-coordinator.js";
|
|
33
33
|
import { createUsageRecorder } from "./usage.js";
|
|
34
|
+
import { isMeteredId, ledgerKeyOf, meteredNameOf, routeOfMeteredId } from "./ledger.js";
|
|
34
35
|
import { createRouteResolver } from "./routes.js";
|
|
35
36
|
import { autoFillPatch, DSH_ROUTE_MAP, detectHarnessSuppliers, resolveCredentialRef } from "./detect.js";
|
|
36
37
|
import { PROVIDERS, SUPPLIER_IDS } from "./providers.js";
|
|
37
38
|
import { sumDayTokens, splitDayTokens, dayUsageHistory, dshHome, acquireUsageStore, migrateUsageDir,
|
|
38
|
-
quotaFilePath, hourTimeMs, totalOf } from "./storage.js";
|
|
39
|
+
quotaFilePath, hourTimeMs, totalOf, addBreakdown } from "./storage.js";
|
|
39
40
|
import { acquireQuotaStore, clipSamples, downsampleSamples, QUOTA_RETENTION_DAYS, QUOTA_SEND_POINTS } from "./quota-history.js";
|
|
40
41
|
import { describeSeason, normalizeHolidays, validateHolidayOverride, tierOfHour } from "./season.js";
|
|
41
42
|
|
|
@@ -111,6 +112,9 @@ export const Config = markVolatile(z.object({
|
|
|
111
112
|
// 数组整体替换:settings.update 的深合并不会把两次覆盖表拼起来。
|
|
112
113
|
// 之所以可覆盖:内置表只覆盖国务院已公布年份,缺次年会把工作日假期静默当成峰价。
|
|
113
114
|
holidays: z.array(z.string()).default([]),
|
|
115
|
+
// 仅计量供应商的改名表(route → 显示名)。条目本身不在这里:一条路由产生用量就自动成行,
|
|
116
|
+
// 行不是配置出来的,所以没有「添加/删除」,只有改名。空串 = 回到默认名(displayName 或路由 id)。
|
|
117
|
+
meteredNames: z.dict(z.string()).default({}),
|
|
114
118
|
suppliers: z.object(Object.fromEntries(SUPPLIER_IDS.map((id) => [id, supplierSchema(id)]))),
|
|
115
119
|
}));
|
|
116
120
|
|
|
@@ -196,6 +200,8 @@ export function apply(ctx, config) {
|
|
|
196
200
|
routeSeq: 0,
|
|
197
201
|
sessionRouteSeen: {}, // sessionId -> routeId -> { lastSeenAt, lastModel, seq }(「当前显示页」按会话归集)
|
|
198
202
|
usageBuckets: {}, // supplierId -> { "YYYYMMDDHH": breakdown }(本地用量数据,落盘)
|
|
203
|
+
// 账本搬家表:仅计量 key -> 正式供应商 id(收编时登记;不落盘,进程内与桶同寿命)
|
|
204
|
+
ledgerMoves: {},
|
|
199
205
|
usageFile: null, // <DSH_HOME>/dsh-token-quota/usage.json
|
|
200
206
|
quotaFile: null, // <DSH_HOME>/dsh-token-quota/quota.json(额度历史,落盘)
|
|
201
207
|
channelAlive: null, // null=未知(兜底启用清单) true=已观测到事件 false=不可用
|
|
@@ -216,6 +222,49 @@ export function apply(ctx, config) {
|
|
|
216
222
|
}, // 探测诊断(仅非密钥字段)
|
|
217
223
|
};
|
|
218
224
|
|
|
225
|
+
// ---------- 账本归属(lib/ledger.js 的字节说明在那边,这里只做装配) ----------
|
|
226
|
+
// 一条路由要么落进注册表供应商,要么落进 `local:<route>` 的仅计量行;纯函数,只由 route 决定。
|
|
227
|
+
const ledgerKey = (route) => ledgerKeyOf(route, resolveSupplier);
|
|
228
|
+
const meteredIds = () => Object.keys(runtime.usageBuckets).filter(isMeteredId);
|
|
229
|
+
/** 仅计量行的显示名:用户改名 → 扫描到的 displayName → 路由 id。 */
|
|
230
|
+
const meteredName = (route) => meteredNameOf(
|
|
231
|
+
route,
|
|
232
|
+
runtime.config.meteredNames,
|
|
233
|
+
Object.fromEntries(runtime.unmapped.map((u) => [u.route, u.displayName])),
|
|
234
|
+
);
|
|
235
|
+
/** 供应商显示名;仅计量 id 也走同一条路(注册表里没有它)。 */
|
|
236
|
+
const supplierName = (id) => {
|
|
237
|
+
const route = routeOfMeteredId(id);
|
|
238
|
+
return route === null ? (PROVIDERS[id]?.name || id) : meteredName(route);
|
|
239
|
+
};
|
|
240
|
+
/** 供应商元数据:注册表条目,或为仅计量行合成的不可查询元数据。 */
|
|
241
|
+
const supplierMeta = (id) => PROVIDERS[id] || (isMeteredId(id) ? {
|
|
242
|
+
id, name: supplierName(id), queryable: false,
|
|
243
|
+
credentialClass: null, credentialKey: null, credentialLabel: null,
|
|
244
|
+
baseUrlDefault: "", needs: [],
|
|
245
|
+
} : null);
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* 注册表后来收编了某个路由:把 `local:<route>` 的历史桶并入它的正式 key。
|
|
249
|
+
* 不搬的话,新供应商从 0 开始,而旧历史永远挂在一行看不见的账本上(I7)。
|
|
250
|
+
* 搬完登记进 `ledgerMoves`:记录器的扣减要跟着走,否则在途步骤的下一份样本会被重复计入。
|
|
251
|
+
* @returns 是否真的搬过(调用方据此标脏)
|
|
252
|
+
*/
|
|
253
|
+
function adoptMeteredBuckets() {
|
|
254
|
+
let moved = false;
|
|
255
|
+
for (const id of meteredIds()) {
|
|
256
|
+
const owner = resolveSupplier(routeOfMeteredId(id));
|
|
257
|
+
if (!owner) continue;
|
|
258
|
+
const from = runtime.usageBuckets[id];
|
|
259
|
+
const into = runtime.usageBuckets[owner] ??= {};
|
|
260
|
+
for (const [hour, breakdown] of Object.entries(from || {})) addBreakdown(into[hour] ??= {}, breakdown);
|
|
261
|
+
delete runtime.usageBuckets[id];
|
|
262
|
+
runtime.ledgerMoves[id] = owner;
|
|
263
|
+
moved = true;
|
|
264
|
+
}
|
|
265
|
+
return moved;
|
|
266
|
+
}
|
|
267
|
+
|
|
219
268
|
/**
|
|
220
269
|
* 三级取值:配置里的本体(手动接管)→ 配置里的凭据引用(apiKeyEnv,宿主内存解析)→
|
|
221
270
|
* 自动探测(autoSource)命中的 DSH 路由密钥。运行期与 /test 共用,避免两条路径各判一次。
|
|
@@ -409,7 +458,7 @@ export function apply(ctx, config) {
|
|
|
409
458
|
|
|
410
459
|
// ---------- 「当前在用」供应商(小组件紧凑条主显示) ----------
|
|
411
460
|
// 语义(用户选定):最近一次真实 LLM 调用命中的供应商 + 模型名。
|
|
412
|
-
//
|
|
461
|
+
// 认账本 key(含仅计量行),不只是注册表:任何经过 Harness 的调用都可能是在用的那一个。
|
|
413
462
|
// 与 enabled/current 过滤解耦:DSH 真在用的供应商即使本插件未启用也照实显示。
|
|
414
463
|
// 同时维护两张表:全局 routeSeen(Popover「当前供应商/近 24h 无流量」沿用)与
|
|
415
464
|
// 按 sessionId 归集的 sessionRouteSeen(小组件跟随「当前显示页」的最近调用)。
|
|
@@ -418,8 +467,8 @@ export function apply(ctx, config) {
|
|
|
418
467
|
for (const [route, seen] of Object.entries(routes || {})) {
|
|
419
468
|
const seq = Number(seen?.seq) || 0;
|
|
420
469
|
if (!seq || (best && best.seq >= seq)) continue;
|
|
421
|
-
const supplierId =
|
|
422
|
-
if (!supplierId || !
|
|
470
|
+
const supplierId = ledgerKey(route);
|
|
471
|
+
if (!supplierId || !supplierMeta(supplierId)) continue;
|
|
423
472
|
best = {
|
|
424
473
|
seq,
|
|
425
474
|
supplierId,
|
|
@@ -431,7 +480,7 @@ export function apply(ctx, config) {
|
|
|
431
480
|
const fresh = Date.now() - best.at <= (runtime.config.trafficWindowHours || 24) * 3600_000;
|
|
432
481
|
return {
|
|
433
482
|
supplierId: best.supplierId,
|
|
434
|
-
name:
|
|
483
|
+
name: supplierName(best.supplierId),
|
|
435
484
|
model: best.model,
|
|
436
485
|
at: best.at,
|
|
437
486
|
atText: new Date(best.at).toLocaleTimeString(),
|
|
@@ -563,6 +612,51 @@ export function apply(ctx, config) {
|
|
|
563
612
|
};
|
|
564
613
|
// 折线与柱共用同一条时间轴:窗口左缘 = 最近 N 天里的第一天 00:00(与 usageDays 首行对齐)
|
|
565
614
|
const usageWindowStart = new Date(now.getFullYear(), now.getMonth(), now.getDate() - (historyDays - 1)).getTime();
|
|
615
|
+
// 仅计量行:只有 token,没有 entries / lastSuccessAt / quotaHistory —— 它从不查询,
|
|
616
|
+
// 也就不欠一条查询状态。恒 current:今日总量必须涵盖它,否则会比真实调用少一块。
|
|
617
|
+
const meteredRow = (id) => {
|
|
618
|
+
const route = routeOfMeteredId(id);
|
|
619
|
+
const usageDays = daysOf(id);
|
|
620
|
+
const todayUsage = usageDays[usageDays.length - 1] ?? {
|
|
621
|
+
inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, unsplitTokens: 0,
|
|
622
|
+
};
|
|
623
|
+
return {
|
|
624
|
+
id,
|
|
625
|
+
name: meteredName(route),
|
|
626
|
+
route,
|
|
627
|
+
queryable: false,
|
|
628
|
+
enabled: true,
|
|
629
|
+
added: true,
|
|
630
|
+
addedReason: "usage",
|
|
631
|
+
current: true,
|
|
632
|
+
todayTokens: todayOf(id),
|
|
633
|
+
todayUsage,
|
|
634
|
+
usageDays,
|
|
635
|
+
seasonSplit: null,
|
|
636
|
+
keySet: false,
|
|
637
|
+
apiKeyEnv: "",
|
|
638
|
+
keyRefResolved: false,
|
|
639
|
+
keyRefSource: null,
|
|
640
|
+
autoDetected: false,
|
|
641
|
+
autoSource: null,
|
|
642
|
+
autoEnvName: null,
|
|
643
|
+
autoKeySource: null,
|
|
644
|
+
envKeySet: false,
|
|
645
|
+
warnPct: 80,
|
|
646
|
+
critPct: 95,
|
|
647
|
+
displayKeys: [],
|
|
648
|
+
baseUrl: "",
|
|
649
|
+
baseUrlDefault: "",
|
|
650
|
+
meta: {
|
|
651
|
+
credentialClass: null, credentialLabelKey: null, credentialLabel: null,
|
|
652
|
+
region: null, official: null, compat: null, needs: [],
|
|
653
|
+
},
|
|
654
|
+
state: "metered",
|
|
655
|
+
entries: [],
|
|
656
|
+
headline: { kind: "amt", amt: "—" },
|
|
657
|
+
};
|
|
658
|
+
};
|
|
659
|
+
|
|
566
660
|
return {
|
|
567
661
|
ok: true,
|
|
568
662
|
active: session === null ? computeActiveSupplier() : computeActiveSupplierFor(String(session)),
|
|
@@ -617,16 +711,22 @@ export function apply(ctx, config) {
|
|
|
617
711
|
// B3:detectedNow 只表示「当前发现」(最近一次成功扫描的结果),绝不把持久化的
|
|
618
712
|
// autoSource 历史来源算成当前接入;历史来源继续通过 autoSource 字段展示。
|
|
619
713
|
const detectedNow = !!runtime.harness[id] || currentFound.includes(id);
|
|
620
|
-
|
|
621
|
-
|
|
714
|
+
// 有用量就算已添加:账本里已经有这一行的 token,它就必须出现在列表里、并且进今日总量。
|
|
715
|
+
// 少了这一条,「未配置但仍在跑」的注册表路由(例如只登录桌面端时的 deepseek-account)
|
|
716
|
+
// 会把 token 记进一行谁也不展示、谁也不计入的账本,比整条丢掉更难发现。
|
|
717
|
+
const todayTokens = todayOf(id);
|
|
718
|
+
const added = detectedNow || !!c.enabled || anyKeySet || todayTokens > 0;
|
|
719
|
+
const addedReason = detectedNow ? "detected" : c.enabled ? "enabled"
|
|
720
|
+
: anyKeySet ? "keySet" : todayTokens > 0 ? "usage" : null;
|
|
622
721
|
return {
|
|
623
722
|
id,
|
|
624
723
|
name: meta.name,
|
|
724
|
+
queryable: true,
|
|
625
725
|
enabled: !!c.enabled,
|
|
626
726
|
added,
|
|
627
727
|
addedReason,
|
|
628
728
|
current: currentIds.includes(id),
|
|
629
|
-
todayTokens
|
|
729
|
+
todayTokens,
|
|
630
730
|
// 今日 Token 分布:五项互不相交的分量(未缓存输入/输出/缓存命中/缓存写入/未细分)。
|
|
631
731
|
// 与 todayTokens 同源,运行时测试钉住 sum(分量) === todayTokens。
|
|
632
732
|
todayUsage: {
|
|
@@ -675,7 +775,7 @@ export function apply(ctx, config) {
|
|
|
675
775
|
// 成功数据时间独立于 fetchedAt;失败尝试只更新状态,不能冒充新数据。
|
|
676
776
|
...(s.lastSuccessAt != null ? { lastSuccessAt: s.lastSuccessAt } : {}),
|
|
677
777
|
};
|
|
678
|
-
}),
|
|
778
|
+
}).concat(meteredIds().map(meteredRow)),
|
|
679
779
|
// 刷新历史逐条标注当时的计费时段(t 是展示文案,tier 用记录里的原始时刻判定)。
|
|
680
780
|
history: runtime.history.slice(-50).map((row) => ({
|
|
681
781
|
...row,
|
|
@@ -769,6 +869,9 @@ export function apply(ctx, config) {
|
|
|
769
869
|
if (!result.ok) ctx.logger?.warn?.("[quota-monitor] %s", result.error);
|
|
770
870
|
});
|
|
771
871
|
runtime.usageBuckets = usageStore.store.buckets;
|
|
872
|
+
// 搬家表与桶同寿命:store 按文件缓存并跨 apply 复用(同一进程里第二次挂载仍调同一个记录器),
|
|
873
|
+
// 挂在 store 上才不会被新的 runtime 对象换掉。
|
|
874
|
+
runtime.ledgerMoves = usageStore.store.ledgerMoves ??= {};
|
|
772
875
|
if (usageStore.store.error) ctx.logger?.warn?.("[dsh-token-quota] %s", usageStore.store.error);
|
|
773
876
|
if (usageStore.store.loadedVersion === 1) {
|
|
774
877
|
ctx.logger?.info?.("[dsh-token-quota] 用量文件为版本 1,将在下次保存时升级为版本 2;旧数据以「未细分」保留");
|
|
@@ -844,6 +947,11 @@ export function apply(ctx, config) {
|
|
|
844
947
|
|
|
845
948
|
// 1) 提交路由与「当前发现」(detectedNow 只来自最近成功扫描)
|
|
846
949
|
routeResolver.update(detected.routeSuppliers || {});
|
|
950
|
+
// 路由归属变了之后,才可能发现「以前的仅计量行现在有正式供应商了」
|
|
951
|
+
if (adoptMeteredBuckets()) {
|
|
952
|
+
usageStore.markDirty();
|
|
953
|
+
scheduleUsageSave();
|
|
954
|
+
}
|
|
847
955
|
const meta = detected.meta || {};
|
|
848
956
|
runtime.detect = {
|
|
849
957
|
at: stamp(),
|
|
@@ -1007,7 +1115,7 @@ export function apply(ctx, config) {
|
|
|
1007
1115
|
// usage 字段:inputTokens / outputTokens / cacheReadTokens / cacheWriteTokens
|
|
1008
1116
|
// 同一 (turn, step) 的早期 usage 样本与最终 assistant/message usage 是替换关系
|
|
1009
1117
|
// (与 dsh-token-meter 的 addReplacing 语义一致),按会话×step 记账避免重复累加。
|
|
1010
|
-
const recordUsage = usageStore.store.recordUsage ??= createUsageRecorder(runtime.usageBuckets);
|
|
1118
|
+
const recordUsage = usageStore.store.recordUsage ??= createUsageRecorder(runtime.usageBuckets, runtime.ledgerMoves);
|
|
1011
1119
|
const sessionSeen = new WeakMap(); // session -> { provider }
|
|
1012
1120
|
disposers.push(ctx.on("session/event", (session, event) => {
|
|
1013
1121
|
if (disposed) return;
|
|
@@ -1060,11 +1168,13 @@ export function apply(ctx, config) {
|
|
|
1060
1168
|
}
|
|
1061
1169
|
st.provider = route;
|
|
1062
1170
|
}
|
|
1063
|
-
|
|
1064
|
-
|
|
1171
|
+
// 账本归属:注册表供应商,或未被识别路由的仅计量行。以前这里 `if (supplierId && …)`
|
|
1172
|
+
// 直接把未映射路由的 token 丢掉——「今日总量」因此比真实调用少一块。
|
|
1173
|
+
const ledger = ledgerKey(route);
|
|
1174
|
+
if (ledger && usage && typeof usage === "object") {
|
|
1065
1175
|
// 只有真的改变了小时桶才 markDirty / 排程落盘:同一 (session, turn, step) 的相同最终样本
|
|
1066
1176
|
// 在流式期间会被反复报告,不重复写盘。setRetention 引起的 dirty 与此无关,仍然生效。
|
|
1067
|
-
if (recordUsage(session,
|
|
1177
|
+
if (recordUsage(session, ledger, usage, turn, step) === true) {
|
|
1068
1178
|
usageStore.markDirty();
|
|
1069
1179
|
scheduleUsageSave();
|
|
1070
1180
|
}
|
|
@@ -1170,6 +1280,18 @@ export function apply(ctx, config) {
|
|
|
1170
1280
|
if (!checked.ok) throw new Error(checked.error);
|
|
1171
1281
|
patch.holidays = checked.days;
|
|
1172
1282
|
}
|
|
1283
|
+
// 仅计量行的改名表:settings.update 只做 JSON 形状校验,这里自己把关
|
|
1284
|
+
// (键是路由 id,值是显示名;空串 = 回到默认名)。
|
|
1285
|
+
if (patch.meteredNames !== undefined) {
|
|
1286
|
+
const source = patch.meteredNames;
|
|
1287
|
+
if (!source || typeof source !== "object" || Array.isArray(source)) throw new Error("meteredNames 必须是对象");
|
|
1288
|
+
const names = {};
|
|
1289
|
+
for (const [route, value] of Object.entries(source).slice(0, 200)) {
|
|
1290
|
+
if (typeof route !== "string" || !route) continue;
|
|
1291
|
+
names[route] = typeof value === "string" ? value.trim().slice(0, 60) : "";
|
|
1292
|
+
}
|
|
1293
|
+
patch.meteredNames = names;
|
|
1294
|
+
}
|
|
1173
1295
|
for (const id of SUPPLIER_IDS) {
|
|
1174
1296
|
const supplier = patch.suppliers?.[id];
|
|
1175
1297
|
if (!supplier || typeof supplier !== "object") continue;
|
package/lib/ledger.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// lib/ledger.js — 用量账本的归属判据:一条 DSH 路由 → 一个账本 key。
|
|
2
|
+
//
|
|
3
|
+
// 账本 key 只有两种形态:
|
|
4
|
+
// - 注册表供应商 id(deepseek / openai-org / …):可查额度,也可能有百分比
|
|
5
|
+
// - `local:<route>`:仅计量,只有经过 Harness 的 token,没有任何可查询的额度或百分比
|
|
6
|
+
//
|
|
7
|
+
// 归属是纯函数,只由 route 决定。同一 (session, turn, step) 的替换记账因此不会双计:
|
|
8
|
+
// 早期样本落旧 key 时被扣掉,最终样本落到新 key,总量只算一次。
|
|
9
|
+
|
|
10
|
+
/** 仅计量 id 的前缀。注册表 id 里不会出现 `:`,加上它就不会撞车。 */
|
|
11
|
+
export const METERED_PREFIX = "local:";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 显式并表:某条到达路径确实属于某个已注册供应商时,把用量并进那一行。
|
|
15
|
+
*
|
|
16
|
+
* `deepseek-account`(桌面登录)与 `deepseek-official` / `deepseek`(API Key)是同一账户的两条路,
|
|
17
|
+
* 余额只能按 API Key 查。拆成两行会把「今日总量」读成两家,而第二行永远拿不到指标。
|
|
18
|
+
* 加这张表要谨慎:并错了就是把两个账户的账混在一起,运行期没有证据可以自动纠正。
|
|
19
|
+
*/
|
|
20
|
+
export const OWNER_ALIAS = { "deepseek-account": "deepseek" };
|
|
21
|
+
|
|
22
|
+
/** route → 仅计量账本 key。 */
|
|
23
|
+
export const meteredIdOf = (route) => `${METERED_PREFIX}${route}`;
|
|
24
|
+
|
|
25
|
+
export const isMeteredId = (id) => typeof id === "string" && id.startsWith(METERED_PREFIX);
|
|
26
|
+
|
|
27
|
+
/** 仅计量 key → route;不是仅计量 key 时返回 null。 */
|
|
28
|
+
export const routeOfMeteredId = (id) => (isMeteredId(id) ? id.slice(METERED_PREFIX.length) : null);
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 一条路由落到哪个账本。
|
|
32
|
+
* @param route 事件里的 provider 字符串
|
|
33
|
+
* @param resolveOwner (route) => supplierId | null,注册表归属(含扫描期 discovery)
|
|
34
|
+
* @returns 账本 key;route 不是非空字符串时 null
|
|
35
|
+
*/
|
|
36
|
+
export function ledgerKeyOf(route, resolveOwner) {
|
|
37
|
+
if (typeof route !== "string" || !route) return null;
|
|
38
|
+
if (Object.hasOwn(OWNER_ALIAS, route)) return OWNER_ALIAS[route];
|
|
39
|
+
const owner = typeof resolveOwner === "function" ? resolveOwner(route) : null;
|
|
40
|
+
return owner || meteredIdOf(route);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 仅计量行的显示名:用户改名优先,其次扫描拿到的 DSH displayName,最后是路由 id 本身。
|
|
45
|
+
* @param names config.meteredNames(route → 名字)
|
|
46
|
+
* @param displayNames route → DSH 里的 displayName
|
|
47
|
+
*/
|
|
48
|
+
export function meteredNameOf(route, names = {}, displayNames = {}) {
|
|
49
|
+
const pick = (value) => (typeof value === "string" ? value.trim().slice(0, 60) : "");
|
|
50
|
+
if (names && typeof names === "object" && Object.hasOwn(names, route)) {
|
|
51
|
+
const custom = pick(names[route]);
|
|
52
|
+
if (custom) return custom;
|
|
53
|
+
}
|
|
54
|
+
if (displayNames && typeof displayNames === "object" && Object.hasOwn(displayNames, route)) {
|
|
55
|
+
const display = pick(displayNames[route]);
|
|
56
|
+
if (display) return display;
|
|
57
|
+
}
|
|
58
|
+
return String(route).slice(0, 120);
|
|
59
|
+
}
|
package/lib/usage.js
CHANGED
|
@@ -38,12 +38,23 @@ const sameBreakdown = (left, right) =>
|
|
|
38
38
|
|
|
39
39
|
/**
|
|
40
40
|
* @param buckets supplierId -> { "YYYYMMDDHH": breakdown }(宿主内存与落盘共用同一对象)
|
|
41
|
+
* @param movedTo 账本搬家表:旧 key -> 新 key(仅计量行被注册表收编时登记,见 lib/index.js)
|
|
41
42
|
* @returns record(session, supplier, usage, turn, step, date?) => boolean
|
|
42
43
|
* 本次调用是否真的改变了小时桶(true = 有替换/扣减/增加,需要落盘)。
|
|
43
44
|
* record.forget(session, turn, step) 结束该步骤的替换范围(重试时调用)。
|
|
44
45
|
*/
|
|
45
|
-
export function createUsageRecorder(buckets) {
|
|
46
|
+
export function createUsageRecorder(buckets, movedTo = null) {
|
|
46
47
|
const sessions = new WeakMap();
|
|
48
|
+
/**
|
|
49
|
+
* 扣减要跟着搬家走:收编把历史桶从 `local:R` 搬到正式 key 之后,同一步骤的替换样本
|
|
50
|
+
* 若还去扣已删掉的旧桶,扣减就成了空操作,整份样本会再加一遍(静默双计)。
|
|
51
|
+
*/
|
|
52
|
+
const bucketIdOf = (supplier) => {
|
|
53
|
+
if (!movedTo) return supplier;
|
|
54
|
+
let id = supplier;
|
|
55
|
+
for (let hop = 0; hop < 8 && Object.hasOwn(movedTo, id); hop += 1) id = movedTo[id];
|
|
56
|
+
return id;
|
|
57
|
+
};
|
|
47
58
|
const record = (session, supplier, usage, turn, step, date = new Date()) => {
|
|
48
59
|
const breakdown = usageBreakdownOf(usage);
|
|
49
60
|
if (!breakdown) return false;
|
|
@@ -64,7 +75,7 @@ export function createUsageRecorder(buckets) {
|
|
|
64
75
|
}
|
|
65
76
|
const hours = buckets[supplier] ??= {};
|
|
66
77
|
if (previous) {
|
|
67
|
-
const prior = buckets[previous.supplier];
|
|
78
|
+
const prior = buckets[bucketIdOf(previous.supplier)];
|
|
68
79
|
const priorBucket = prior?.[hour];
|
|
69
80
|
if (priorBucket) {
|
|
70
81
|
subtractBreakdown(priorBucket, previous.breakdown);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ds-hu/dsh-token-quota",
|
|
3
|
-
"version": "2.
|
|
4
|
-
"description": "DeepSeek Harness
|
|
3
|
+
"version": "2.6.0",
|
|
4
|
+
"description": "DeepSeek Harness 用量监控:官方侧栏完整/紧凑卡片与单一面板的概览、供应商配置,按当前会话显示周期限额、逐币种余额与报告用量费用;内置供应商之外的路由也有 Token 统计(仅计量),保留凭据类别隔离、自动识别普通 API Key 与本地今日用量统计。",
|
|
5
5
|
"icon": "./icon.svg",
|
|
6
6
|
"author": "shxtmaker <https://github.com/shxtmaker>",
|
|
7
7
|
"license": "MIT",
|
|
@@ -22,8 +22,11 @@
|
|
|
22
22
|
"icon.svg",
|
|
23
23
|
"locale/*.json",
|
|
24
24
|
"README.md",
|
|
25
|
+
"README.en.md",
|
|
26
|
+
"LICENSE",
|
|
25
27
|
"CHANGELOG.md",
|
|
26
28
|
"docs/windows-desktop-adaptation.md",
|
|
29
|
+
"docs/images",
|
|
27
30
|
"CONTEXT.md",
|
|
28
31
|
"ui-prototype.html"
|
|
29
32
|
],
|
|
@@ -30,7 +30,12 @@ for (const required of [pkg.main, pkg.exports["./client"], pkg.dsh.bundle.patch,
|
|
|
30
30
|
"lib/season.js",
|
|
31
31
|
// 额度历史:统计页的剩余额度折线依赖它,漏打包会让 /state?quota=1 直接 500
|
|
32
32
|
"lib/quota-history.js",
|
|
33
|
-
|
|
33
|
+
// 账本归属:用量落账与「仅计量」行的推导都在它里面,漏打包时宿主半身直接起不来
|
|
34
|
+
"lib/ledger.js",
|
|
35
|
+
// 两份 README:中文是主文档,英文版靠 package.json 的 files 一起进产物,漏了就只有中文
|
|
36
|
+
"README.md", "README.en.md", "LICENSE", "CHANGELOG.md",
|
|
37
|
+
// README 里引用的截图:不打包时 npm 页面上的图全裂
|
|
38
|
+
"docs/images/overview.png", "docs/images/providers.png"]) {
|
|
34
39
|
assert.ok(paths.has(required.replace(/^\.\//, "")), `产物缺少 ${required}`);
|
|
35
40
|
}
|
|
36
41
|
// Plugins 面板的标题与图标同样要进产物并能经 ESM 解析器取到:
|
package/test/client.mjs
CHANGED
|
@@ -126,11 +126,14 @@ test("late state responses cannot overwrite a newer poll", async () => {
|
|
|
126
126
|
} finally { await act(async () => tree?.unmount()); }
|
|
127
127
|
});
|
|
128
128
|
|
|
129
|
-
test("expanded sidebar shows the plan name and the weekly bar, and
|
|
129
|
+
test("expanded sidebar shows the plan name and the weekly bar, and counts every listed supplier's today", async () => {
|
|
130
130
|
const state = payload("OpenCode", [
|
|
131
131
|
supplier([{ name: "5 小时", kind: "win", pct: 5, reset: "约 2 小时后重置" }, { name: "周用量", kind: "win", pct: 2, reset: "下周重置" }], { todayTokens: 64_429_367 }),
|
|
132
132
|
supplier([{ name: "CNY", kind: "bal", remain: "¥41.99" }], { id: "deepseek", name: "DeepSeek", todayTokens: 1_318_275 }),
|
|
133
|
+
// 有用量但不在「当前」集合里(例如已关闭、DSH 仍在调它):token 是真的,就必须进总量
|
|
133
134
|
supplier([], { id: "historic", name: "Historical", current: false, todayTokens: 100_000_000 }),
|
|
135
|
+
// 既没用量也不在列表里的行不该被算进去
|
|
136
|
+
supplier([], { id: "dropped", name: "Dropped", current: false, added: false, todayTokens: 999_000 }),
|
|
134
137
|
]);
|
|
135
138
|
state.active.model = "deepseek-v4.1-flash-with-a-long-model-name";
|
|
136
139
|
const host = client(async () => response(state));
|
|
@@ -153,8 +156,8 @@ test("expanded sidebar shows the plan name and the weekly bar, and scopes today'
|
|
|
153
156
|
assert.equal(tree.root.findAllByProps({ className: "qm-l3" }).length, 0);
|
|
154
157
|
const entry = tree.root.findByProps({ "data-qm-entry": "" });
|
|
155
158
|
assert.ok(!textOf(entry).includes("tokens"), "卡片正文不再出现今日用量:" + textOf(entry));
|
|
156
|
-
assert.match(entry.props.title, /
|
|
157
|
-
assert.match(entry.props["aria-label"],
|
|
159
|
+
assert.match(entry.props.title, /166M tokens/, "今日总量 = 列表里各行之和(含不在「当前」集合里的行)");
|
|
160
|
+
assert.match(entry.props["aria-label"], /已添加供应商.*今日.*Token.*本地日/);
|
|
158
161
|
assert.ok(entry.props.title.includes("OpenCode"));
|
|
159
162
|
assert.ok(!entry.props.title.includes(state.active.model), "模型名不再进卡片:" + entry.props.title);
|
|
160
163
|
assert.equal(tree.root.findAll((n) => /(^|\s)qm-dot(\s|$)/.test(n.props.className || "")).length, 0, "卡片不再有阈值圆点");
|
|
@@ -954,3 +957,90 @@ test("both locale dictionaries stay complete and agree on placeholders", () => {
|
|
|
954
957
|
assert.deepEqual(placeholders(en[key]), placeholders(zh[key]), `占位符不一致:${key}`);
|
|
955
958
|
}
|
|
956
959
|
});
|
|
960
|
+
|
|
961
|
+
// ---------- 仅计量供应商(任意供应商的 token 统计,没有剩余 %) ----------
|
|
962
|
+
const meteredSupplier = (extra = {}) => ({
|
|
963
|
+
id: "local:siliconflow", name: "SiliconFlow", route: "siliconflow",
|
|
964
|
+
queryable: false, state: "metered", entries: [], current: true, added: true, enabled: true,
|
|
965
|
+
todayTokens: 1_830_000,
|
|
966
|
+
todayUsage: { inputTokens: 340_000, outputTokens: 123_000, cacheReadTokens: 1_020_000, cacheWriteTokens: 347_000, unsplitTokens: 0 },
|
|
967
|
+
usageDays: [
|
|
968
|
+
{ day: "2026-11-03", inputTokens: 400_000, outputTokens: 140_000, cacheReadTokens: 1_400_000, cacheWriteTokens: 400_000, unsplitTokens: 0, total: 2_340_000 },
|
|
969
|
+
{ day: "2026-11-04", inputTokens: 340_000, outputTokens: 123_000, cacheReadTokens: 1_020_000, cacheWriteTokens: 347_000, unsplitTokens: 0, total: 1_830_000 },
|
|
970
|
+
],
|
|
971
|
+
...extra,
|
|
972
|
+
});
|
|
973
|
+
const meteredState = () => {
|
|
974
|
+
const metered = meteredSupplier();
|
|
975
|
+
return { ...payload("SiliconFlow", [metered]), active: { supplierId: metered.id, name: metered.name, model: "Qwen3", at: Date.now(), fresh: true } };
|
|
976
|
+
};
|
|
977
|
+
|
|
978
|
+
test("a metered supplier has no quota surface: no bar, no metric table, no data time", async () => {
|
|
979
|
+
const host = client(async () => response(meteredState()));
|
|
980
|
+
let tree;
|
|
981
|
+
try {
|
|
982
|
+
tree = await mount(host);
|
|
983
|
+
// 卡片:名字 + 今日 chip 都在,第 2 行是无条回退,填的是「近 7 天」而不是「—」
|
|
984
|
+
assert.equal(line(tree, "qm-plan"), "SiliconFlow");
|
|
985
|
+
assert.equal(textOf(tree.root.findByProps({ className: "qm-today-chip" })), "今日 1.83M");
|
|
986
|
+
assert.equal(barNode(tree), null, "没有百分比就没有进度条");
|
|
987
|
+
assert.match(line(tree, "qm-l2"), /^近 7 天 /, "没有条时第 2 行给这个窗口自己的量");
|
|
988
|
+
|
|
989
|
+
await open(tree);
|
|
990
|
+
await expand(tree, "local:siliconflow");
|
|
991
|
+
const row = rowOf(tree, "local:siliconflow");
|
|
992
|
+
assert.equal(textOf(row.findAll((node) => /^qm-pill/.test(node.props?.className || ""))[0]), "仅计量");
|
|
993
|
+
assert.match(textOf(row.findByProps({ className: "qm-row-metric" })), /^近 7 天 /);
|
|
994
|
+
assert.equal(row.findAllByProps({ className: "qm-metric-table" }).length, 0, "不画指标表");
|
|
995
|
+
assert.ok(!textOf(row).includes("尚未查询"), "空指标表不得被读成一次失败的查询");
|
|
996
|
+
assert.equal(row.findAllByProps({ className: "qm-data-time" }).length, 0, "从不查询,所以没有数据时间");
|
|
997
|
+
assert.equal(row.findAllByProps({ className: "qm-est" }).length, 0, "没有百分比就没有窗口估算");
|
|
998
|
+
assert.equal(textOf(row.findByProps({ className: "qm-today-text" })), "1.83M tokens");
|
|
999
|
+
assert.match(textOf(row.findByProps({ className: "qm-ratio" })), /3\.00 : 1\.00 : 0\.362/);
|
|
1000
|
+
assert.ok(withClass(row, "qm-split-bar").length > 0, "五分量照常画");
|
|
1001
|
+
assert.equal(textOf(row.findByProps({ className: "qm-row-actions" }).findByType("button")), "改名");
|
|
1002
|
+
} finally { await act(async () => tree?.unmount()); }
|
|
1003
|
+
});
|
|
1004
|
+
|
|
1005
|
+
test("the window label follows the retention period instead of always saying 7 days", async () => {
|
|
1006
|
+
// 求和窗口是 min(7, retentionDays)。写死「近 7 天」会在保留期更短时谎报口径。
|
|
1007
|
+
const state = meteredState();
|
|
1008
|
+
state.poll = { usageHistoryDays: 3 };
|
|
1009
|
+
const host = client(async () => response(state));
|
|
1010
|
+
let tree;
|
|
1011
|
+
try {
|
|
1012
|
+
tree = await mount(host);
|
|
1013
|
+
assert.match(line(tree, "qm-l2"), /^近 3 天 /, "卡片按真实窗口写天数");
|
|
1014
|
+
await open(tree);
|
|
1015
|
+
await expand(tree, "local:siliconflow");
|
|
1016
|
+
const row = rowOf(tree, "local:siliconflow");
|
|
1017
|
+
assert.match(textOf(row.findByProps({ className: "qm-row-metric" })), /^近 3 天 /);
|
|
1018
|
+
const options = withClass(row, "qm-range")[0].findAllByType("option").map(textOf);
|
|
1019
|
+
assert.deepEqual(options, ["今日", "近 3 天"], "区间选项与实际求和天数一致");
|
|
1020
|
+
} finally { await act(async () => tree?.unmount()); }
|
|
1021
|
+
});
|
|
1022
|
+
|
|
1023
|
+
test("renaming a metered supplier writes only meteredNames, never a supplier config", async () => {
|
|
1024
|
+
const posts = [];
|
|
1025
|
+
const state = meteredState();
|
|
1026
|
+
const host = client(async (url, opts) => {
|
|
1027
|
+
if (opts?.method !== "POST") return response(state);
|
|
1028
|
+
posts.push(JSON.parse(opts.body));
|
|
1029
|
+
return response({ ok: true });
|
|
1030
|
+
});
|
|
1031
|
+
let tree;
|
|
1032
|
+
try {
|
|
1033
|
+
tree = await mount(host, {}, host.Page);
|
|
1034
|
+
await act(async () => tab(tree, "供应商").props.onClick());
|
|
1035
|
+
// 仅计量行没有启用开关:行不是一个可以关掉的采集器
|
|
1036
|
+
assert.equal(tree.root.findAll((node) => node.props?.className === "qm-page-toggle").length, 0);
|
|
1037
|
+
await act(async () => button(tree, "改名").props.onClick());
|
|
1038
|
+
const input = tree.root.findByType("input");
|
|
1039
|
+
assert.equal(input.props.value, "SiliconFlow");
|
|
1040
|
+
assert.match(textOf(tree.root.findByProps({ className: "qm-empty-line" })), /siliconflow/, "子页点名它挂在哪条路由上");
|
|
1041
|
+
await act(async () => input.props.onChange({ target: { value: "硅基流动" } }));
|
|
1042
|
+
await act(async () => button(tree, "保存").props.onClick());
|
|
1043
|
+
assert.deepEqual(posts[0], { meteredNames: { siliconflow: "硅基流动" } });
|
|
1044
|
+
assert.ok(!Object.hasOwn(posts[0], "suppliers"), "改名不得写进供应商配置");
|
|
1045
|
+
} finally { await act(async () => tree?.unmount()); }
|
|
1046
|
+
});
|
package/test/copy.mjs
CHANGED
|
@@ -44,6 +44,10 @@ test("removed surfaces and long explanatory paragraphs stay out of the dictionar
|
|
|
44
44
|
test("locale stays within the copy budget", () => {
|
|
45
45
|
// 160 → 163:今日行的区间选择器(rangeLabel、range7d)、估算口径切换(estLeft、estTotal)
|
|
46
46
|
// 与「样本不足」的悬停说明(estNoData)共五条;七天的句子、算式与区间都由数字拼出来,没有另开键。
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
// 163 → 168:仅计量供应商是一个新的行类型,五条全是数据标签而非说明文字——
|
|
48
|
+
// 行状态(meteredOnly)、窗口量(windowTokens)、行按钮(rename)、
|
|
49
|
+
// 改名子页的两个字段名(meteredName、meteredRoute)。
|
|
50
|
+
// 168 → 167:窗口天数由保留期决定,区间选项改用既有的 statsWindow,删掉写死的 range7d。
|
|
51
|
+
assert.ok(zh.size <= 167, `中文词典 ${zh.size} 键,超出预算`);
|
|
52
|
+
assert.ok(en.size <= 167, `英文词典 ${en.size} 键,超出预算`);
|
|
49
53
|
});
|