@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.
@@ -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
- assert.equal(sX.payload.active, null, "未映射路由(unknown-route-xyz)不产生 active");
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);