@ds-hu/dsh-token-quota 2.4.1 → 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 +107 -2
- package/CONTEXT.md +10 -6
- package/LICENSE +24 -0
- package/README.en.md +357 -0
- package/README.md +69 -89
- package/docs/images/overview.png +0 -0
- package/docs/images/providers.png +0 -0
- package/lib/client.js +205 -38
- package/lib/index.js +192 -14
- package/lib/ledger.js +59 -0
- package/lib/providers.js +16 -4
- package/lib/usage.js +13 -2
- package/package.json +5 -2
- package/scripts/check-package.mjs +6 -1
- package/test/browser/interactions.spec.mjs +9 -3
- package/test/client.mjs +177 -9
- package/test/copy.mjs +8 -2
- package/test/metered.mjs +245 -0
- package/test/mock-dsh.mjs +12 -1
- package/test/runtime.mjs +56 -1
package/test/client.mjs
CHANGED
|
@@ -5,10 +5,11 @@ import vm from "node:vm";
|
|
|
5
5
|
import React from "react";
|
|
6
6
|
import { create, act } from "react-test-renderer";
|
|
7
7
|
|
|
8
|
+
const manifest = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
8
9
|
const source = readFileSync(new URL("../lib/client.js", import.meta.url), "utf8");
|
|
9
10
|
/** 使用实际无构建客户端与真实 React,仅替换宿主槽位、网络、时钟和本地存储。 */
|
|
10
11
|
function client(fetch, { storage = new Map(), search = "", clock = null, mobile = false } = {}) {
|
|
11
|
-
let plugin, dictionary;
|
|
12
|
+
let plugin, dictionary, registeredId;
|
|
12
13
|
const locales = {};
|
|
13
14
|
const slots = {}, timers = [], listeners = {}, cleanups = [];
|
|
14
15
|
const subscribe = (type, fn) => (listeners[type] ||= new Set()).add(fn);
|
|
@@ -21,7 +22,7 @@ function client(fetch, { storage = new Map(), search = "", clock = null, mobile
|
|
|
21
22
|
body: {}, visibilityState: "visible", addEventListener: subscribe, removeEventListener: unsubscribe,
|
|
22
23
|
};
|
|
23
24
|
const window = {
|
|
24
|
-
__ModuleLoader__: { load({ factory }) { plugin = factory((id) => id === "react" ? React : { createPortal: (child) => child }); } },
|
|
25
|
+
__ModuleLoader__: { load({ id, factory }) { registeredId = id; plugin = factory((id) => id === "react" ? React : { createPortal: (child) => child }); } },
|
|
25
26
|
location: { search }, addEventListener: subscribe, removeEventListener: unsubscribe,
|
|
26
27
|
localStorage: { getItem: (key) => storage.get(key) ?? null, setItem: (key, value) => storage.set(key, value) },
|
|
27
28
|
// 视口断点替身:mobile 决定 matchMedia 的结果,宿主浏览器才有真实实现。
|
|
@@ -50,7 +51,7 @@ function client(fetch, { storage = new Map(), search = "", clock = null, mobile
|
|
|
50
51
|
inject(name, fn) { return fn(); }, register(descriptor, component) { slots[descriptor.name ?? descriptor.key] = component; },
|
|
51
52
|
} });
|
|
52
53
|
const t = (key, params = {}) => String(dictionary[key] ?? key).replace(/\{(\w+)\}/g, (_, k) => params[k] ?? `{${k}}`);
|
|
53
|
-
return { Footer: slots["sidebar.footer.action"], Page: slots["settings.section"], t, locales, timers, storage, helpers: plugin.__test, setNow: context.__setNow };
|
|
54
|
+
return { registeredId, Footer: slots["sidebar.footer.action"], Page: slots["settings.section"], t, locales, timers, storage, helpers: plugin.__test, setNow: context.__setNow };
|
|
54
55
|
}
|
|
55
56
|
const textOf = (node) => {
|
|
56
57
|
const walk = (child) => Array.isArray(child) ? child.map(walk).join("") : typeof child === "string" || typeof child === "number" ? String(child) : child?.children ? walk(child.children) : "";
|
|
@@ -82,6 +83,13 @@ const response = (data) => ({ ok: true, status: 200, json: async () => data });
|
|
|
82
83
|
const payload = (name, suppliers = []) => ({ ok: true, traffic: { channelAlive: true }, suppliers, active: { supplierId: suppliers[0]?.id, name, model: "model", at: Date.now() } });
|
|
83
84
|
const supplier = (entries, extra = {}) => ({ id: "opencode", name: "OpenCode", current: true, added: true, enabled: true, state: "ok", todayTokens: 0, entries, ...extra });
|
|
84
85
|
|
|
86
|
+
// 包名与产物里注册的 id 必须逐字符一致:DSH 浏览器半身按包名请求 <pkg>/client,
|
|
87
|
+
// 注册成别的名字时整条 entry 静默不激活,页面只报 loaded without registering。
|
|
88
|
+
test("the bundle registers itself under the package name", async () => {
|
|
89
|
+
const host = client(async () => response(payload("A")));
|
|
90
|
+
assert.equal(host.registeredId, manifest.name);
|
|
91
|
+
});
|
|
92
|
+
|
|
85
93
|
for (const wide of [true, false]) test(`${wide ? "expanded" : "rail"} sidebar opens the single monitor panel`, async () => {
|
|
86
94
|
const host = client(async () => response(payload("A")));
|
|
87
95
|
let tree;
|
|
@@ -118,11 +126,14 @@ test("late state responses cannot overwrite a newer poll", async () => {
|
|
|
118
126
|
} finally { await act(async () => tree?.unmount()); }
|
|
119
127
|
});
|
|
120
128
|
|
|
121
|
-
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 () => {
|
|
122
130
|
const state = payload("OpenCode", [
|
|
123
131
|
supplier([{ name: "5 小时", kind: "win", pct: 5, reset: "约 2 小时后重置" }, { name: "周用量", kind: "win", pct: 2, reset: "下周重置" }], { todayTokens: 64_429_367 }),
|
|
124
132
|
supplier([{ name: "CNY", kind: "bal", remain: "¥41.99" }], { id: "deepseek", name: "DeepSeek", todayTokens: 1_318_275 }),
|
|
133
|
+
// 有用量但不在「当前」集合里(例如已关闭、DSH 仍在调它):token 是真的,就必须进总量
|
|
125
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 }),
|
|
126
137
|
]);
|
|
127
138
|
state.active.model = "deepseek-v4.1-flash-with-a-long-model-name";
|
|
128
139
|
const host = client(async () => response(state));
|
|
@@ -145,8 +156,8 @@ test("expanded sidebar shows the plan name and the weekly bar, and scopes today'
|
|
|
145
156
|
assert.equal(tree.root.findAllByProps({ className: "qm-l3" }).length, 0);
|
|
146
157
|
const entry = tree.root.findByProps({ "data-qm-entry": "" });
|
|
147
158
|
assert.ok(!textOf(entry).includes("tokens"), "卡片正文不再出现今日用量:" + textOf(entry));
|
|
148
|
-
assert.match(entry.props.title, /
|
|
149
|
-
assert.match(entry.props["aria-label"],
|
|
159
|
+
assert.match(entry.props.title, /166M tokens/, "今日总量 = 列表里各行之和(含不在「当前」集合里的行)");
|
|
160
|
+
assert.match(entry.props["aria-label"], /已添加供应商.*今日.*Token.*本地日/);
|
|
150
161
|
assert.ok(entry.props.title.includes("OpenCode"));
|
|
151
162
|
assert.ok(!entry.props.title.includes(state.active.model), "模型名不再进卡片:" + entry.props.title);
|
|
152
163
|
assert.equal(tree.root.findAll((n) => /(^|\s)qm-dot(\s|$)/.test(n.props.className || "")).length, 0, "卡片不再有阈值圆点");
|
|
@@ -358,7 +369,7 @@ test("the card draws one bar per watched limit with its own countdown and no tod
|
|
|
358
369
|
// 今日用量搬进详情弹窗的供应商栏
|
|
359
370
|
await open(tree);
|
|
360
371
|
await expand(tree, "opencode");
|
|
361
|
-
assert.match(textOf(rowOf(tree, "opencode").findByProps({ className: "qm-today-line" })),
|
|
372
|
+
assert.match(textOf(rowOf(tree, "opencode").findByProps({ className: "qm-today-line" })), /1\.24M tokens/);
|
|
362
373
|
} finally { await act(async () => tree?.unmount()); }
|
|
363
374
|
});
|
|
364
375
|
|
|
@@ -395,7 +406,7 @@ test("details show the colored ratio next to today's total, and only when compon
|
|
|
395
406
|
await open(tree);
|
|
396
407
|
await expand(tree, "opencode");
|
|
397
408
|
const line = rowOf(tree, "opencode").findByProps({ className: "qm-today-line" });
|
|
398
|
-
assert.match(textOf(line),
|
|
409
|
+
assert.match(textOf(line), /3\.15M tokens/, "总量仍按供应商口径;区间由选择器表达");
|
|
399
410
|
assert.match(textOf(line), /30\.0 : 1\.00 : 0\.150/, "比例挨着总量:命中 : 未命中 : 输出,未命中 = 1");
|
|
400
411
|
// 三个数字分别着段色,与下方五段条的前三段一一对应
|
|
401
412
|
const ratio = line.findByProps({ className: "qm-ratio" });
|
|
@@ -410,13 +421,83 @@ test("details show the colored ratio next to today's total, and only when compon
|
|
|
410
421
|
await open(tree);
|
|
411
422
|
await expand(tree, "opencode");
|
|
412
423
|
const bare = rowOf(tree, "opencode").findByProps({ className: "qm-today-line" });
|
|
413
|
-
assert.match(textOf(bare),
|
|
424
|
+
assert.match(textOf(bare), /500 tokens/);
|
|
414
425
|
assert.equal(withClass(bare, "qm-split-bar").length, 0);
|
|
415
426
|
assert.equal(withClass(rowOf(tree, "opencode"), "qm-split-bar").length, 0);
|
|
416
427
|
assert.equal(bare.findAllByProps({ className: "qm-ratio" }).length, 0);
|
|
417
428
|
} finally { await act(async () => tree?.unmount()); }
|
|
418
429
|
});
|
|
419
430
|
|
|
431
|
+
test("the today line switches range and derives each window's token estimate", async () => {
|
|
432
|
+
const usageDays = [
|
|
433
|
+
{ day: "2026-10-26", inputTokens: 10, outputTokens: 1, cacheReadTokens: 100, cacheWriteTokens: 0, unsplitTokens: 0, total: 111 },
|
|
434
|
+
{ day: "2026-10-27", inputTokens: 100, outputTokens: 16, cacheReadTokens: 3_000, cacheWriteTokens: 40, unsplitTokens: 0, total: 3_155_000 },
|
|
435
|
+
];
|
|
436
|
+
const hour = 3600_000;
|
|
437
|
+
const at = Date.now();
|
|
438
|
+
const estimate = (tokens, dPct, hours) => ({ from: at - hours * hour, to: at - hour, tokens, dPct });
|
|
439
|
+
const entries = [
|
|
440
|
+
// 每 1pp = 34M ÷ 20 = 1.7M:剩余 64pp ⇒ ≈109M,满 100pp ⇒ ≈170M
|
|
441
|
+
{ name: "5h 滚动", key: "window.5h", short: "5h", kind: "win", pct: 36, estimate: estimate(34_000_000, 20, 5) },
|
|
442
|
+
{ name: "周用量", key: "window.weekly", short: "周", kind: "win", pct: 53, estimate: estimate(2_500_000_000, 30, 72) },
|
|
443
|
+
{ name: "月用量", key: "window.monthly", short: "月", kind: "win", pct: 89, estimate: estimate(3_150_000_000, 30, 96) },
|
|
444
|
+
// 只走了 1pp:整数百分比的舍入误差撑不起斜率,宁可给「—」
|
|
445
|
+
{ name: "新窗口", key: "window.tiny", short: "新", kind: "win", pct: 2, estimate: estimate(9_000_000, 1, 3) },
|
|
446
|
+
];
|
|
447
|
+
const state = payload("OpenCode", [supplier(entries, {
|
|
448
|
+
todayTokens: 382_000_000,
|
|
449
|
+
todayUsage: { inputTokens: 100, outputTokens: 16, cacheReadTokens: 3_000, cacheWriteTokens: 40, unsplitTokens: 0 },
|
|
450
|
+
usageDays,
|
|
451
|
+
})]);
|
|
452
|
+
const host = client(async () => response(state));
|
|
453
|
+
let tree;
|
|
454
|
+
try {
|
|
455
|
+
tree = await mount(host);
|
|
456
|
+
await open(tree);
|
|
457
|
+
await expand(tree, "opencode");
|
|
458
|
+
const today = rowOf(tree, "opencode").findByProps({ className: "qm-today-line" });
|
|
459
|
+
|
|
460
|
+
// 「Today」两个字换成了选择器,右边的文字不再重复区间名
|
|
461
|
+
const range = today.findByType("select");
|
|
462
|
+
assert.deepEqual(range.findAllByType("option").map((node) => node.props.value), ["today", "7d"]);
|
|
463
|
+
assert.equal(range.props.value, "today");
|
|
464
|
+
assert.equal(textOf(today.findByProps({ className: "qm-today-text" })), "382M tokens");
|
|
465
|
+
|
|
466
|
+
// 估算按斜率折算:34M ÷ Δ20pp = 1.7M/pp,剩余 64pp ⇒ ≈109M
|
|
467
|
+
const chips = withClass(today, "qm-est-q");
|
|
468
|
+
assert.deepEqual(chips.map(textOf), ["5h ≈109M", "周 ≈3.92B", "月 ≈1.16B", "新 —"]);
|
|
469
|
+
assert.match(chips[0].props.title, /4h: 34\.0M ÷ Δ20pp/, "title 里给出取样区间与分子分母");
|
|
470
|
+
assert.match(chips[0].props.title, /Δ20pp ±1pp → 104M–115M/);
|
|
471
|
+
assert.equal(chips[3].props.title, "本地样本不足");
|
|
472
|
+
|
|
473
|
+
// 切到套餐总量:同一份斜率,按满 100pp 折算
|
|
474
|
+
const [left, total] = today.findAllByProps({ className: "qm-est-seg" })[0].findAllByType("button");
|
|
475
|
+
assert.equal(left.props["aria-pressed"], true);
|
|
476
|
+
await act(async () => total.props.onClick());
|
|
477
|
+
const totals = withClass(today, "qm-est-q").map(textOf);
|
|
478
|
+
assert.deepEqual(totals, ["5h ≈170M", "周 ≈8.33B", "月 ≈10.5B", "新 —"]);
|
|
479
|
+
|
|
480
|
+
// 切到近 7 天:数量、比例、五分量条一起换,估算仍按窗口(不随区间变)
|
|
481
|
+
await act(async () => range.props.onChange({ target: { value: "7d" } }));
|
|
482
|
+
const line7d = rowOf(tree, "opencode").findByProps({ className: "qm-today-line" });
|
|
483
|
+
assert.equal(textOf(line7d.findByProps({ className: "qm-today-text" })), "3.16M tokens");
|
|
484
|
+
assert.match(textOf(line7d.findByProps({ className: "qm-ratio" })), /28\.2 : 1\.00 : 0\.155/);
|
|
485
|
+
assert.deepEqual(withClass(line7d, "qm-est-q").map(textOf), totals);
|
|
486
|
+
|
|
487
|
+
// 没有本地用量 → 不给估算(不编 0,也不编比例)
|
|
488
|
+
const bare = client(async () => response(payload("OpenCode", [supplier(
|
|
489
|
+
[{ name: "周用量", key: "window.weekly", short: "周", kind: "win", pct: 40 }],
|
|
490
|
+
{ todayTokens: 500 })])));
|
|
491
|
+
let bareTree;
|
|
492
|
+
try {
|
|
493
|
+
bareTree = await mount(bare);
|
|
494
|
+
await open(bareTree);
|
|
495
|
+
await expand(bareTree, "opencode");
|
|
496
|
+
assert.equal(rowOf(bareTree, "opencode").findAllByProps({ className: "qm-est" }).length, 0);
|
|
497
|
+
} finally { await act(async () => bareTree?.unmount()); }
|
|
498
|
+
} finally { await act(async () => tree?.unmount()); }
|
|
499
|
+
});
|
|
500
|
+
|
|
420
501
|
test("expanding a supplier shows today's ratio, the five-part split and one combined chart", async () => {
|
|
421
502
|
const start = (day) => new Date(...day.split("-").map((part, i) => (i === 1 ? Number(part) - 1 : Number(part)))).getTime();
|
|
422
503
|
const usageDays = [
|
|
@@ -876,3 +957,90 @@ test("both locale dictionaries stay complete and agree on placeholders", () => {
|
|
|
876
957
|
assert.deepEqual(placeholders(en[key]), placeholders(zh[key]), `占位符不一致:${key}`);
|
|
877
958
|
}
|
|
878
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
|
@@ -42,6 +42,12 @@ test("removed surfaces and long explanatory paragraphs stay out of the dictionar
|
|
|
42
42
|
});
|
|
43
43
|
|
|
44
44
|
test("locale stays within the copy budget", () => {
|
|
45
|
-
|
|
46
|
-
|
|
45
|
+
// 160 → 163:今日行的区间选择器(rangeLabel、range7d)、估算口径切换(estLeft、estTotal)
|
|
46
|
+
// 与「样本不足」的悬停说明(estNoData)共五条;七天的句子、算式与区间都由数字拼出来,没有另开键。
|
|
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} 键,超出预算`);
|
|
47
53
|
});
|
package/test/metered.mjs
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { apply, Config } from "../lib/index.js";
|
|
7
|
+
import { createCtx } from "./harness.mjs";
|
|
8
|
+
import { ledgerKeyOf, meteredIdOf, meteredNameOf, isMeteredId, routeOfMeteredId } from "../lib/ledger.js";
|
|
9
|
+
import { createUsageRecorder } from "../lib/usage.js";
|
|
10
|
+
import { PROVIDERS } from "../lib/providers.js";
|
|
11
|
+
import { supplierForRoute } from "../lib/detect.js";
|
|
12
|
+
|
|
13
|
+
// ---------- 归属判据(纯函数) ----------
|
|
14
|
+
|
|
15
|
+
test("a route lands on exactly one ledger: registered supplier, explicit alias, or a metered row", () => {
|
|
16
|
+
const owner = supplierForRoute;
|
|
17
|
+
const cases = [
|
|
18
|
+
// 注册表:精确表与前缀表都算「已注册」,账本 key 就是供应商 id
|
|
19
|
+
["deepseek-official", "deepseek"],
|
|
20
|
+
["deepseek", "deepseek"],
|
|
21
|
+
["commandcode-goat", "commandcode"],
|
|
22
|
+
// 显式并表:桌面登录与 API Key 是同一账户的两条路,并成一行
|
|
23
|
+
["deepseek-account", "deepseek"],
|
|
24
|
+
// 不认识的路由:自成一行,key 带 local: 前缀
|
|
25
|
+
["siliconflow", "local:siliconflow"],
|
|
26
|
+
["pi-ai-openai-compatible-2", "local:pi-ai-openai-compatible-2"],
|
|
27
|
+
];
|
|
28
|
+
for (const [route, expected] of cases) {
|
|
29
|
+
assert.equal(ledgerKeyOf(route, owner), expected, route);
|
|
30
|
+
}
|
|
31
|
+
// 扫描期 discovery 的结果优先于静态表
|
|
32
|
+
assert.equal(ledgerKeyOf("my-opencode", (r) => (r === "my-opencode" ? "opencode" : null)), "opencode");
|
|
33
|
+
// 非法路由不落账(事件里 provider 缺失时不制造空行)
|
|
34
|
+
assert.equal(ledgerKeyOf(null, owner), null);
|
|
35
|
+
assert.equal(ledgerKeyOf("", owner), null);
|
|
36
|
+
assert.equal(ledgerKeyOf(42, owner), null);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("metered ids round-trip and never collide with registry ids", () => {
|
|
40
|
+
assert.equal(meteredIdOf("a:b/c"), "local:a:b/c");
|
|
41
|
+
assert.equal(isMeteredId("local:x"), true);
|
|
42
|
+
assert.equal(isMeteredId("deepseek"), false);
|
|
43
|
+
assert.equal(routeOfMeteredId("local:pi-ai/x"), "pi-ai/x");
|
|
44
|
+
assert.equal(routeOfMeteredId("deepseek"), null);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("a metered row is named by the user's rename first, then the DSH display name, then the route", () => {
|
|
48
|
+
assert.equal(meteredNameOf("siliconflow", { siliconflow: " 硅基流动 " }, {}), "硅基流动");
|
|
49
|
+
assert.equal(meteredNameOf("siliconflow", { siliconflow: " " }, { siliconflow: "SiliconFlow" }), "SiliconFlow");
|
|
50
|
+
assert.equal(meteredNameOf("siliconflow", {}, {}), "siliconflow");
|
|
51
|
+
// 名字是显示文本:超长截断,不把整段配置塞进侧栏
|
|
52
|
+
assert.equal(meteredNameOf("x", { x: "长".repeat(200) }, {}).length, 60);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// ---------- 跨 key 替换不双计(I1) ----------
|
|
56
|
+
|
|
57
|
+
test("a sample that changes ledger moves its tokens instead of counting twice", () => {
|
|
58
|
+
const buckets = {};
|
|
59
|
+
const record = createUsageRecorder(buckets);
|
|
60
|
+
const session = { id: "s" };
|
|
61
|
+
const sample = { inputTokens: 100, outputTokens: 20 };
|
|
62
|
+
// 早期样本报在未映射路由上,最终样本报在注册表供应商上(同一 turn/step)
|
|
63
|
+
assert.equal(record(session, "local:siliconflow", sample, 1, 1), true);
|
|
64
|
+
assert.equal(record(session, "deepseek", sample, 1, 1), true);
|
|
65
|
+
assert.equal(JSON.stringify(buckets["local:siliconflow"]), "{}", "旧 key 的桶被扣空");
|
|
66
|
+
assert.equal(Object.keys(buckets["local:siliconflow"]).length, 0);
|
|
67
|
+
const total = Object.values(buckets).flatMap((hours) => Object.values(hours))
|
|
68
|
+
.reduce((sum, bucket) => sum + (bucket.inputTokens || 0) + (bucket.outputTokens || 0), 0);
|
|
69
|
+
assert.equal(total, 120, "同一 (session, turn, step) 只算一次");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("after a ledger move, the replacement subtracts from the new key instead of adding twice", () => {
|
|
73
|
+
const buckets = {};
|
|
74
|
+
const movedTo = {};
|
|
75
|
+
const record = createUsageRecorder(buckets, movedTo);
|
|
76
|
+
const session = { id: "s" };
|
|
77
|
+
const sample = { inputTokens: 600 };
|
|
78
|
+
assert.equal(record(session, "local:r", sample, 1, 1), true);
|
|
79
|
+
// 收编:桶搬进 deepseek,旧 key 删除,并登记搬家
|
|
80
|
+
buckets.deepseek = { ...buckets["local:r"] };
|
|
81
|
+
delete buckets["local:r"];
|
|
82
|
+
movedTo["local:r"] = "deepseek";
|
|
83
|
+
// 同一步骤的下一份样本报到新 key:扣减要落在搬过去的桶里
|
|
84
|
+
assert.equal(record(session, "deepseek", sample, 1, 1), true);
|
|
85
|
+
const total = Object.values(buckets).flatMap((hours) => Object.values(hours))
|
|
86
|
+
.reduce((sum, bucket) => sum + (bucket.inputTokens || 0), 0);
|
|
87
|
+
assert.equal(total, 600, "搬家后的替换仍然是替换,不是第二次累加");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// ---------- 宿主侧集成 ----------
|
|
91
|
+
function mount({ config = {}, foreign = [] } = {}) {
|
|
92
|
+
const home = mkdtempSync(join(tmpdir(), "qm-metered-"));
|
|
93
|
+
const previous = process.env.DSH_HOME;
|
|
94
|
+
process.env.DSH_HOME = home;
|
|
95
|
+
const harness = createCtx({ schema: Config, config, foreign });
|
|
96
|
+
const dispose = apply(harness.ctx, harness.configRef);
|
|
97
|
+
const state = async () => harness.call("/api/dsh-token-quota/state");
|
|
98
|
+
// 同一个会话对象跨事件复用:记录器按对象身份记步骤状态,每次新建对象就测不出替换语义了。
|
|
99
|
+
const session = { id: "page" };
|
|
100
|
+
const emit = (event) => harness.emit("session/event", session, event);
|
|
101
|
+
return {
|
|
102
|
+
harness, emit, state,
|
|
103
|
+
cleanup() {
|
|
104
|
+
dispose();
|
|
105
|
+
if (previous === undefined) delete process.env.DSH_HOME; else process.env.DSH_HOME = previous;
|
|
106
|
+
rmSync(home, { recursive: true, force: true });
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const usageEvent = (provider, usage, turn = 1, step = 1) => ({
|
|
112
|
+
type: "assistant/message",
|
|
113
|
+
data: { turn, step, message: { source: { provider, model: "m" } }, usage },
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("an unknown route's tokens become a read-only metered row, not a dropped event", async () => {
|
|
117
|
+
const host = mount();
|
|
118
|
+
try {
|
|
119
|
+
host.emit(usageEvent("siliconflow", { inputTokens: 300, outputTokens: 60, cacheReadTokens: 1_000 }));
|
|
120
|
+
host.emit(usageEvent("deepseek-official", { inputTokens: 40, outputTokens: 10 }, 2, 1));
|
|
121
|
+
|
|
122
|
+
const payload = await host.state();
|
|
123
|
+
const metered = payload.suppliers.find((s) => s.id === "local:siliconflow");
|
|
124
|
+
assert.ok(metered, "未映射路由应当出现在供应商列表里");
|
|
125
|
+
assert.equal(metered.queryable, false);
|
|
126
|
+
assert.equal(metered.state, "metered");
|
|
127
|
+
assert.equal(metered.current, true, "恒进今日总量");
|
|
128
|
+
assert.equal(metered.added, true);
|
|
129
|
+
assert.equal(metered.route, "siliconflow");
|
|
130
|
+
assert.equal(metered.todayTokens, 1_360);
|
|
131
|
+
const { inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, unsplitTokens } = metered.todayUsage;
|
|
132
|
+
assert.deepEqual({ inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, unsplitTokens }, {
|
|
133
|
+
inputTokens: 300, outputTokens: 60, cacheReadTokens: 1_000, cacheWriteTokens: 0, unsplitTokens: 0,
|
|
134
|
+
});
|
|
135
|
+
assert.deepEqual(metered.entries, [], "没有可查询指标");
|
|
136
|
+
assert.equal(Object.hasOwn(metered, "quotaHistory"), false, "也不取额度历史");
|
|
137
|
+
assert.equal(Object.hasOwn(metered, "lastSuccessAt"), false, "从不查询,没有成功数据时间");
|
|
138
|
+
|
|
139
|
+
// 注册表供应商照旧,且没有被未映射路由污染
|
|
140
|
+
const deepseek = payload.suppliers.find((s) => s.id === "deepseek");
|
|
141
|
+
assert.equal(deepseek.queryable, true);
|
|
142
|
+
assert.equal(deepseek.todayTokens, 50);
|
|
143
|
+
assert.equal(payload.active.supplierId, "deepseek", "当前在用取最近一次调用");
|
|
144
|
+
|
|
145
|
+
// 今日总量 = 列表里每一行之和:有用量就算已添加,谁也藏不住(注册表行与仅计量行同一口径)。
|
|
146
|
+
assert.equal(deepseek.added, true, "有 token 的行必须出现在列表里");
|
|
147
|
+
assert.equal(deepseek.addedReason, "usage");
|
|
148
|
+
assert.equal(deepseek.current, false, "「当前」仍按流量判,不因为有用量就冒充在用");
|
|
149
|
+
const total = payload.suppliers.filter((s) => s.added !== false)
|
|
150
|
+
.reduce((sum, s) => sum + (Number(s.todayTokens) || 0), 0);
|
|
151
|
+
assert.equal(total, 1_410, "未启用的注册表行与仅计量行都计入总量");
|
|
152
|
+
} finally { host.cleanup(); }
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("a desktop-login route with no API key still lands on a row the overview shows and counts", async () => {
|
|
156
|
+
// deepseek-account 并进 deepseek 是没错的(同一账户),错的是「没配 Key 时那一行不算已添加」:
|
|
157
|
+
// 那会把登录态的 token 记进一行既不展示、也不计入今日总量的账本,比整条丢掉更难发现。
|
|
158
|
+
const host = mount();
|
|
159
|
+
try {
|
|
160
|
+
host.emit(usageEvent("deepseek-account", { inputTokens: 1_000 }));
|
|
161
|
+
const payload = await host.state();
|
|
162
|
+
assert.equal(payload.suppliers.some((s) => s.id.startsWith("local:")), false, "并表路由不另开仅计量行");
|
|
163
|
+
const deepseek = payload.suppliers.find((s) => s.id === "deepseek");
|
|
164
|
+
assert.equal(deepseek.todayTokens, 1_000);
|
|
165
|
+
assert.equal(deepseek.added, true, "有用量 ⇒ 概览可见");
|
|
166
|
+
assert.equal(deepseek.queryable, true, "仍是注册表供应商(可查额度),不是仅计量行");
|
|
167
|
+
const total = payload.suppliers.filter((s) => s.added !== false)
|
|
168
|
+
.reduce((sum, s) => sum + (Number(s.todayTokens) || 0), 0);
|
|
169
|
+
assert.equal(total, 1_000, "小组件与概览读到同一个今日总量");
|
|
170
|
+
} finally { host.cleanup(); }
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("refreshing never queries a metered row and never guesses a percentage for it", async (t) => {
|
|
174
|
+
// 真实网络在这里没有意义:只钉「刷新只走注册表供应商」这一件事。
|
|
175
|
+
t.mock.method(PROVIDERS.deepseek, "query", async () => ({
|
|
176
|
+
state: "ok", entries: [{ name: "可用余额", kind: "bal", remain: "¥10", pct: null }],
|
|
177
|
+
headline: { kind: "amt", amt: "¥10" },
|
|
178
|
+
}));
|
|
179
|
+
const host = mount({ config: { suppliers: { deepseek: { enabled: true, apiKey: "saved" } } } });
|
|
180
|
+
try {
|
|
181
|
+
host.emit(usageEvent("siliconflow", { inputTokens: 10 }));
|
|
182
|
+
// 只让 deepseek 真的去查询:刷新后仅计量行仍然是 metered,没有 state/err 之类的查询状态
|
|
183
|
+
const payload = await host.harness.call("/api/dsh-token-quota/refresh", {});
|
|
184
|
+
const metered = payload.suppliers.find((s) => s.id === "local:siliconflow");
|
|
185
|
+
assert.equal(metered.state, "metered");
|
|
186
|
+
assert.equal(metered.headline.kind, "amt");
|
|
187
|
+
assert.equal(metered.headline.amt, "—");
|
|
188
|
+
assert.deepEqual(metered.entries, []);
|
|
189
|
+
assert.equal(payload.suppliers.filter((s) => s.id.startsWith("local:")).length, 1);
|
|
190
|
+
} finally { host.cleanup(); }
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("renaming a metered row writes only meteredNames, and the sanitizer keeps values bounded", async () => {
|
|
194
|
+
const host = mount();
|
|
195
|
+
try {
|
|
196
|
+
host.emit(usageEvent("siliconflow", { inputTokens: 10 }));
|
|
197
|
+
const saved = await host.harness.call("/api/dsh-token-quota/settings", {
|
|
198
|
+
meteredNames: { siliconflow: " 硅基流动 ", "": "ignored", junk: 42 },
|
|
199
|
+
});
|
|
200
|
+
assert.equal(saved.ok, true);
|
|
201
|
+
assert.equal(host.harness.settings.userLayer.meteredNames.siliconflow, "硅基流动");
|
|
202
|
+
assert.equal(host.harness.settings.userLayer.meteredNames.junk, "", "非字符串收敛成空串(回到默认名)");
|
|
203
|
+
assert.ok(!Object.hasOwn(host.harness.settings.userLayer.meteredNames, ""), "空路由键被丢弃");
|
|
204
|
+
|
|
205
|
+
const payload = await host.state();
|
|
206
|
+
assert.equal(payload.suppliers.find((s) => s.id === "local:siliconflow").name, "硅基流动");
|
|
207
|
+
|
|
208
|
+
// 坏形状直接拒绝,不落盘
|
|
209
|
+
const rejected = await host.harness.call("/api/dsh-token-quota/settings", { meteredNames: [] });
|
|
210
|
+
assert.equal(rejected.ok, false);
|
|
211
|
+
assert.match(rejected.error, /meteredNames/);
|
|
212
|
+
} finally { host.cleanup(); }
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("a route later recognised by the registry takes its metered history with it", async () => {
|
|
216
|
+
const host = mount();
|
|
217
|
+
try {
|
|
218
|
+
host.emit(usageEvent("proxied-deepseek", { inputTokens: 500, outputTokens: 100 }));
|
|
219
|
+
const before = await host.state();
|
|
220
|
+
assert.equal(before.suppliers.find((s) => s.id === "local:proxied-deepseek").todayTokens, 600);
|
|
221
|
+
|
|
222
|
+
// DSH 侧给这条路由配了一个指向 DeepSeek 官方主机的 profile:扫描按主机把它归给 deepseek
|
|
223
|
+
host.harness.settings.foreign.push({
|
|
224
|
+
ns: "llm-pi-ai",
|
|
225
|
+
value: { providers: { "proxied-deepseek": { baseURL: "https://api.deepseek.com", apiKeyEnv: "DSH_KEY" } } },
|
|
226
|
+
user: {},
|
|
227
|
+
});
|
|
228
|
+
const rescanned = await host.harness.call("/api/dsh-token-quota/rescan", {});
|
|
229
|
+
assert.equal(rescanned.ok, true, "重扫应成功");
|
|
230
|
+
|
|
231
|
+
const after = await host.harness.call("/api/dsh-token-quota/state");
|
|
232
|
+
assert.equal(after.suppliers.some((s) => s.id === "local:proxied-deepseek"), false, "仅计量行不再存在");
|
|
233
|
+
const deepseek = after.suppliers.find((s) => s.id === "deepseek");
|
|
234
|
+
assert.equal(deepseek.todayTokens, 600, "历史并入正式供应商,不从 0 开始");
|
|
235
|
+
// 收编不能把 token 搬出「计入总量」的范围:今日总量在收编前后必须一样。
|
|
236
|
+
const total = after.suppliers.filter((s) => s.added !== false)
|
|
237
|
+
.reduce((sum, s) => sum + (Number(s.todayTokens) || 0), 0);
|
|
238
|
+
assert.equal(total, 600, "收编前后今日总量不变");
|
|
239
|
+
|
|
240
|
+
// 仍在流式里的同一步骤再报一次相同样本:扣减必须跟着搬家走到新 key,否则整份样本会再加一遍。
|
|
241
|
+
host.emit(usageEvent("proxied-deepseek", { inputTokens: 500, outputTokens: 100 }));
|
|
242
|
+
const settled = await host.harness.call("/api/dsh-token-quota/state");
|
|
243
|
+
assert.equal(settled.suppliers.find((s) => s.id === "deepseek").todayTokens, 600, "替换样本不双计");
|
|
244
|
+
} finally { host.cleanup(); }
|
|
245
|
+
});
|
package/test/mock-dsh.mjs
CHANGED
|
@@ -232,7 +232,18 @@ for (const id of ["deepseek", "opencode", "commandcode", "openrouter", "moonshot
|
|
|
232
232
|
assert.equal(sX.payload.suppliers.find((s) => s.id === id).current, true, id + " 应兜底按启用清单显示");
|
|
233
233
|
}
|
|
234
234
|
console.log("✓ 流量兜底:无近期可用流量时小组件按启用清单显示(trafficStale=true)");
|
|
235
|
-
|
|
235
|
+
// 未映射路由不再被丢掉:它有自己的账本行(local:<route>),token 记在自己名下,
|
|
236
|
+
// 也不进「已启用清单」的流量兜底集合——它不是一个可配置的注册表供应商。
|
|
237
|
+
const meteredX = sX.payload.suppliers.find((s) => s.id === "local:unknown-route-xyz");
|
|
238
|
+
assert.ok(meteredX, "未映射路由应产生一条仅计量行");
|
|
239
|
+
assert.equal(meteredX.queryable, false, "仅计量行标记为不可查询");
|
|
240
|
+
assert.equal(meteredX.state, "metered", "仅计量行有自己的状态,不冒充「尚未查询」");
|
|
241
|
+
assert.deepEqual(meteredX.entries, [], "仅计量行没有任何可查询指标");
|
|
242
|
+
assert.equal(meteredX.todayTokens, 2, "它的 token 记在自己名下");
|
|
243
|
+
assert.equal(meteredX.name, "unknown-route-xyz", "默认名取路由 id(可改名)");
|
|
244
|
+
assert.equal(sX.payload.suppliers.find((s) => s.id === "deepseek").todayTokens ?? 0, 0, "未映射路由的 token 不得记到注册表供应商名下");
|
|
245
|
+
assert.equal(sX.payload.active?.supplierId, "local:unknown-route-xyz", "最近一次调用是未映射路由 → active 指向它的仅计量行");
|
|
246
|
+
console.log("✓ 未映射路由 → 仅计量行:独立记账、不进注册表供应商、可作为当前在用");
|
|
236
247
|
emit({ type: "assistant/message", data: { source: { provider: "deepseek-official", model: "deepseek-v3" }, usage: { uncachedInputTokens: 1000, outputTokens: 500 } } });
|
|
237
248
|
const s2 = await call("/api/dsh-token-quota/state");
|
|
238
249
|
assert.equal(s2.payload.suppliers.find((s) => s.id === "deepseek").todayTokens, 1500);
|
package/test/runtime.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import { apply, Config } from "../lib/index.js";
|
|
|
7
7
|
import { createCtx } from "./harness.mjs";
|
|
8
8
|
import { PROVIDERS } from "../lib/providers.js";
|
|
9
9
|
import * as season from "../lib/season.js";
|
|
10
|
-
import { dayLabelOf } from "../lib/storage.js";
|
|
10
|
+
import { dayLabelOf, hourKeyOf } from "../lib/storage.js";
|
|
11
11
|
|
|
12
12
|
test("plugin instances do not share traffic and dispose releases event handlers", async () => {
|
|
13
13
|
const originalHome = process.env.DSH_HOME;
|
|
@@ -322,6 +322,61 @@ test("a successful query appends a quota sample, and an unchanged value does not
|
|
|
322
322
|
assert.deepEqual(saved.suppliers.opencode["window.rolling"].map(([, pct]) => pct), [12, 40]);
|
|
323
323
|
});
|
|
324
324
|
|
|
325
|
+
test("窗口估算按 Δpp 取样:分子只覆盖本地还留着的桶,左端落在窗口起点", async (t) => {
|
|
326
|
+
const previousHome = process.env.DSH_HOME;
|
|
327
|
+
const previousFetch = globalThis.fetch;
|
|
328
|
+
const home = mkdtempSync(join(tmpdir(), "qm-window-est-"));
|
|
329
|
+
process.env.DSH_HOME = home;
|
|
330
|
+
const now = Date.now();
|
|
331
|
+
const hour = 3600_000;
|
|
332
|
+
mkdirSync(join(home, "dsh-token-quota"), { recursive: true });
|
|
333
|
+
writeFileSync(join(home, "dsh-token-quota", "usage.json"), JSON.stringify({
|
|
334
|
+
version: 2,
|
|
335
|
+
buckets: { opencode: {
|
|
336
|
+
[hourKeyOf(new Date(now - 2 * hour))]: { inputTokens: 100 },
|
|
337
|
+
[hourKeyOf(new Date(now - 20 * hour))]: { inputTokens: 500 },
|
|
338
|
+
[hourKeyOf(new Date(now - 100 * hour))]: { inputTokens: 9_999 },
|
|
339
|
+
} },
|
|
340
|
+
}), "utf8");
|
|
341
|
+
// 额度折线:rolling 只记了本窗口的 30→40;weekly 从 40 小时前的 0 一路涨到 14
|
|
342
|
+
writeFileSync(join(home, "dsh-token-quota", "quota.json"), JSON.stringify({
|
|
343
|
+
version: 1,
|
|
344
|
+
suppliers: { opencode: {
|
|
345
|
+
"window.rolling": [[now - 3 * hour, 30], [now - hour, 40]],
|
|
346
|
+
"window.weekly": [[now - 40 * hour, 0], [now - 3 * hour, 10], [now - hour, 14]],
|
|
347
|
+
} },
|
|
348
|
+
}), "utf8");
|
|
349
|
+
globalThis.fetch = async (url) => {
|
|
350
|
+
if (!String(url).includes("/zen/go/v1/usage")) return { ok: false, status: 401, text: async () => "" };
|
|
351
|
+
return {
|
|
352
|
+
ok: true, status: 200, text: async () => JSON.stringify({ usage: {
|
|
353
|
+
// rolling:重置在 2 小时后、窗口 5h ⇒ 左缘 now−3h,与第一条采样对齐
|
|
354
|
+
rolling: { percent: 40, resetsAt: new Date(now + 2 * hour).toISOString() },
|
|
355
|
+
// weekly:重置在 4 天后、窗口 7d ⇒ 左缘 now−3d,样本从 40 小时前开始算
|
|
356
|
+
weekly: { percent: 14, resetsAt: new Date(now + 4 * 86400_000).toISOString() },
|
|
357
|
+
} }),
|
|
358
|
+
};
|
|
359
|
+
};
|
|
360
|
+
const harness = createCtx({ schema: Config, config: { suppliers: { opencode: { enabled: true, apiKey: "sk-test" } } } });
|
|
361
|
+
const dispose = apply(harness.ctx, harness.configRef);
|
|
362
|
+
t.after(() => {
|
|
363
|
+
dispose();
|
|
364
|
+
globalThis.fetch = previousFetch;
|
|
365
|
+
if (previousHome === undefined) delete process.env.DSH_HOME;
|
|
366
|
+
else process.env.DSH_HOME = previousHome;
|
|
367
|
+
rmSync(home, { recursive: true, force: true });
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
await harness.call("/api/dsh-token-quota/refresh", {});
|
|
371
|
+
const opencode = (await harness.call("/api/dsh-token-quota/state")).suppliers.find((s) => s.id === "opencode");
|
|
372
|
+
const byKey = Object.fromEntries(opencode.entries.map((entry) => [entry.key, entry]));
|
|
373
|
+
assert.equal(byKey["window.rolling"].windowMs, 5 * 3600_000, "窗口长度由供应商声明");
|
|
374
|
+
// 5h:取样 = [now−3h, now−1h],只有 2 小时前那一桶(100)落在里面
|
|
375
|
+
assert.deepEqual(byKey["window.rolling"].estimate, { from: now - 3 * hour, to: now - hour, tokens: 100, dPct: 10 });
|
|
376
|
+
// 周:取样从 40 小时前开始,2 小时前与 20 小时前两桶都算(600),100 小时前那桶在左缘之外
|
|
377
|
+
assert.deepEqual(byKey["window.weekly"].estimate, { from: now - 40 * hour, to: now - hour, tokens: 600, dPct: 14 });
|
|
378
|
+
});
|
|
379
|
+
|
|
325
380
|
test("a retried attempt adds to the same step instead of replacing it", async (t) => {
|
|
326
381
|
const previousHome = process.env.DSH_HOME;
|
|
327
382
|
const home = mkdtempSync(join(tmpdir(), "qm-retry-"));
|