dsh-plugin-billing 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1474 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 插件自检:不需要浏览器,也不需要运行中的 dsh 服务端。
4
+ *
5
+ * 覆盖三块容易出错、又最该被钉住的东西:
6
+ *
7
+ * 1. **计价数学**:高峰/空闲时段判定(含边界与周末)、三档单价折算、四个计费桶
8
+ * 的归并与金额——金额是给用户看的数字,错一位都是误导。
9
+ * 2. **客户端 bundle**:自注册格式、共享模块请求清单、`apply()` 是否把金额行注册
10
+ * 进 `conversation.composer.dock`、zh/en 文案键是否一一对应。
11
+ * 3. **Host 侧**:是否注册了精确路由、非 GET 是否 405、缺凭据时是否给出可解析的
12
+ * 失败响应(不发任何网络请求)。
13
+ *
14
+ * 另有一个默认关闭的联网用例:`DSH_BILLING_LIVE_TEST=1 node scripts/verify.mjs`
15
+ * 会真的调用上游余额接口并断言 `ok: true`。
16
+ *
17
+ * 用法:node scripts/verify.mjs
18
+ */
19
+ import assert from 'node:assert/strict';
20
+ import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
21
+ import { tmpdir } from 'node:os';
22
+ import { dirname, extname, join } from 'node:path';
23
+ import { fileURLToPath } from 'node:url';
24
+ import vm from 'node:vm';
25
+ import { parsePricingText, withLegacyAliases } from '../lib/prices.js';
26
+
27
+ const here = dirname(fileURLToPath(import.meta.url));
28
+ const root = join(here, '..');
29
+ const PACKAGE_ID = 'dsh-plugin-billing';
30
+
31
+ let failures = 0;
32
+ /** 跑一个用例;抛错时记录并继续,最后统一汇报。 */
33
+ function check(label, fn) {
34
+ try {
35
+ fn();
36
+ console.log(` ✔ ${label}`);
37
+ } catch (error) {
38
+ failures += 1;
39
+ console.error(` ✘ ${label}`);
40
+ console.error(` ${error instanceof Error ? error.message : String(error)}`);
41
+ }
42
+ }
43
+
44
+ async function checkAsync(label, fn) {
45
+ try {
46
+ await fn();
47
+ console.log(` ✔ ${label}`);
48
+ } catch (error) {
49
+ failures += 1;
50
+ console.error(` ✘ ${label}`);
51
+ console.error(` ${error instanceof Error ? error.message : String(error)}`);
52
+ }
53
+ }
54
+
55
+ console.log('余额与金额插件自检\n');
56
+
57
+ // ─────────────────────────────────────────────── 客户端 bundle
58
+ console.log('客户端 bundle');
59
+
60
+ const bundleSource = readFileSync(join(root, 'lib', 'client.js'), 'utf8');
61
+
62
+ /** 抓住 bundle 的自注册行。 */
63
+ let registration;
64
+ vm.runInNewContext(
65
+ bundleSource,
66
+ {
67
+ window: { __ModuleLoader__: { load: (row) => { registration = row; } } },
68
+ console,
69
+ // 组件挂载后会去取余额;这里给一个"永远失败"的替身,测试只关心渲染结果。
70
+ fetch: async () => ({ ok: false, status: 503, json: async () => ({ ok: false, error: { message: 'test' } }) }),
71
+ setInterval: () => 0,
72
+ clearInterval: () => {},
73
+ setTimeout,
74
+ clearTimeout,
75
+ },
76
+ { filename: 'lib/client.js' },
77
+ );
78
+
79
+ /**
80
+ * 外壳播种的共享模块(`staticModules`),取自
81
+ * `@deepseek-ai/dsh-web-frontend/dist/assets/index-*.js` 里的播种表:
82
+ * react、react/jsx-runtime、react-dom、react-dom/client、@deepseek-ai/cordis、
83
+ * @deepseek-ai/dsh-client-ui-slots、@deepseek-ai/dsh-client-ui-primitives。
84
+ *
85
+ * 不在播种表、也不是启动图条目的 specifier 会在 `require()` 处抛错
86
+ * (client-modules 的 bundle 纯度门禁)。因此这里把清单钉住。
87
+ */
88
+ const SHELL_SEED_MODULES = [
89
+ 'react',
90
+ 'react/jsx-runtime',
91
+ 'react-dom',
92
+ 'react-dom/client',
93
+ '@deepseek-ai/cordis',
94
+ '@deepseek-ai/dsh-client-ui-slots',
95
+ '@deepseek-ai/dsh-client-ui-primitives',
96
+ ];
97
+
98
+ /** 统一基座提供的共享模块的最小替身。 */
99
+ const SHARED = {
100
+ react: {
101
+ useState: (initial) => [initial, () => {}],
102
+ useEffect: (fn) => (typeof fn === 'function' ? fn() : undefined),
103
+ useMemo: (fn) => fn(),
104
+ useCallback: (fn) => fn,
105
+ memo: (component) => component,
106
+ },
107
+ // 返回可辨识的标记对象:若返回 null,就分不清「渲染了」和「组件返回 null」。
108
+ 'react/jsx-runtime': {
109
+ jsx: (type, props) => ({ __jsx: true, type, props }),
110
+ jsxs: (type, props) => ({ __jsx: true, type, props }),
111
+ Fragment: 'Fragment',
112
+ },
113
+ '@deepseek-ai/dsh-client-ui-primitives': {
114
+ Tooltip: () => null,
115
+ Button: (props) => ({ __jsx: true, type: 'Button', props }),
116
+ },
117
+ };
118
+
119
+ const requested = [];
120
+ const clientExports = registration.factory((specifier) => {
121
+ requested.push(specifier);
122
+ if (SHARED[specifier] === undefined) throw new Error(`未预料的共享模块请求:${specifier}`);
123
+ return SHARED[specifier];
124
+ });
125
+
126
+ check('bundle 通过 window.__ModuleLoader__.load 自注册,id 为包名', () => {
127
+ assert.ok(registration, 'bundle 没有调用 __ModuleLoader__.load');
128
+ assert.equal(registration.id, PACKAGE_ID);
129
+ assert.equal(typeof registration.factory, 'function');
130
+ });
131
+
132
+ check('只请求外壳播种的共享模块(否则 require 会抛纯度门禁错误)', () => {
133
+ assert.ok(requested.length > 0, 'bundle 没有请求任何共享模块');
134
+ for (const specifier of requested) {
135
+ assert.ok(
136
+ SHELL_SEED_MODULES.includes(specifier),
137
+ `${specifier} 不在外壳播种表里,浏览器 require 时会失败`,
138
+ );
139
+ }
140
+ assert.deepEqual([...requested].sort(), [
141
+ '@deepseek-ai/dsh-client-ui-primitives',
142
+ 'react',
143
+ 'react/jsx-runtime',
144
+ ]);
145
+ });
146
+
147
+ check('导出 inject = ["slots","locale"] 与 apply', () => {
148
+ assert.deepEqual([...clientExports.inject], ['slots', 'locale']);
149
+ assert.equal(typeof clientExports.apply, 'function');
150
+ });
151
+
152
+ // ─────────────────────────────────────────────── 计价数学
153
+ console.log('\n计价数学');
154
+
155
+ const testing = clientExports.__testing;
156
+
157
+ /**
158
+ * bundle 跑在 vm 的独立 realm 里,返回对象的原型与本 realm 不同,
159
+ * `deepStrictEqual` 会因原型不同而失败,因此断言前先序列化回普通对象。
160
+ * @param value - 任意 JSON 可序列化值。
161
+ * @returns 本 realm 的等价副本。
162
+ */
163
+ const plain = (value) => JSON.parse(JSON.stringify(value));
164
+
165
+ /**
166
+ * 构造一个“北京时间某星期几某时刻”的 epoch 毫秒(不依赖本机时区)。
167
+ * @param weekday - 0=周日 … 6=周六。
168
+ * @param hour - 北京时间小时。
169
+ * @param minute - 北京时间分钟。
170
+ * @returns epoch 毫秒。
171
+ */
172
+ function beijingAt(weekday, hour, minute) {
173
+ for (let offset = 0; offset < 14; offset += 1) {
174
+ const ms = Date.UTC(2026, 0, 1 + offset, hour - 8, minute);
175
+ if (new Date(ms + 8 * 3600 * 1000).getUTCDay() === weekday) return ms;
176
+ }
177
+ throw new Error(`找不到星期 ${weekday}`);
178
+ }
179
+
180
+ const MONDAY_PEAK = beijingAt(1, 10, 30);
181
+ const MONDAY_AFTERNOON = beijingAt(1, 15, 0);
182
+ const MONDAY_LUNCH = beijingAt(1, 13, 0);
183
+ const MONDAY_EVENING = beijingAt(1, 19, 0);
184
+ const SATURDAY = beijingAt(6, 10, 30);
185
+
186
+ check('高峰时段:周一上午与下午为高峰,午休/傍晚/周末为空闲', () => {
187
+ assert.equal(testing.isPeak(MONDAY_PEAK), true);
188
+ assert.equal(testing.isPeak(MONDAY_AFTERNOON), true);
189
+ assert.equal(testing.isPeak(MONDAY_LUNCH), false);
190
+ assert.equal(testing.isPeak(MONDAY_EVENING), false);
191
+ assert.equal(testing.isPeak(SATURDAY), false);
192
+ });
193
+
194
+ check('高峰窗口边界:09:00 起、12:00 止、14:00 起、18:00 止', () => {
195
+ assert.equal(testing.isPeak(beijingAt(1, 9, 0)), true);
196
+ assert.equal(testing.isPeak(beijingAt(1, 11, 59)), true);
197
+ assert.equal(testing.isPeak(beijingAt(1, 12, 0)), false);
198
+ assert.equal(testing.isPeak(beijingAt(1, 14, 0)), true);
199
+ assert.equal(testing.isPeak(beijingAt(1, 17, 59)), true);
200
+ assert.equal(testing.isPeak(beijingAt(1, 18, 0)), false);
201
+ });
202
+
203
+ check('deepseek-flash(V4.1-Flash)高峰价为官方三档(0.04 / 2 / 8)', () => {
204
+ const rates = testing.ratesFor('deepseek-flash', MONDAY_PEAK);
205
+ assert.equal(rates.peak, true);
206
+ assert.equal(rates.cacheHit, 0.04);
207
+ assert.equal(rates.cacheMiss, 2);
208
+ assert.equal(rates.output, 8);
209
+ });
210
+
211
+ check('两个旧模型名已下线但按 Flash 价计费,必须与 deepseek-flash 同价', () => {
212
+ // 官方定价页注(1):旧名 deepseek-v4-flash / deepseek-v4-flash-vision-exp 的请求
213
+ // 由 DeepSeek-V4.1-Flash 提供服务,并按 Flash 价格计费。
214
+ const flash = testing.ratesFor('deepseek-flash', MONDAY_PEAK);
215
+ for (const legacy of ['deepseek-v4-flash', 'deepseek-v4-flash-vision-exp']) {
216
+ const rates = testing.ratesFor(legacy, MONDAY_PEAK);
217
+ assert.ok(rates !== undefined, `${legacy} 必须有价(否则界面会显示"未配置单价")`);
218
+ assert.deepEqual(
219
+ { cacheHit: rates.cacheHit, cacheMiss: rates.cacheMiss, output: rates.output },
220
+ { cacheHit: flash.cacheHit, cacheMiss: flash.cacheMiss, output: flash.output },
221
+ );
222
+ }
223
+ });
224
+
225
+ check('deepseek-v4-pro 价格未变(0.3 / 9 / 27)', () => {
226
+ const rates = testing.ratesFor('deepseek-v4-pro', MONDAY_PEAK);
227
+ assert.equal(rates.cacheHit, 0.3);
228
+ assert.equal(rates.cacheMiss, 9);
229
+ assert.equal(rates.output, 27);
230
+ });
231
+
232
+ check('路由下发的价格表优先于 bundle 内联的兜底表(改价无需重建)', () => {
233
+ const custom = { 'my-model': { cacheHit: 1, cacheMiss: 2, output: 3 } };
234
+ const rates = testing.ratesFor('my-model', MONDAY_PEAK, custom);
235
+ assert.ok(rates !== undefined, '自定义表里的模型应可计价');
236
+ assert.equal(rates.output, 3);
237
+ // 自定义表里没有的模型不会被兜底表"救活",避免两表混用的错觉
238
+ assert.equal(testing.ratesFor('deepseek-flash', MONDAY_PEAK, custom), undefined);
239
+ assert.ok(testing.priceTable({ prices: custom }) === custom, 'priceTable 应返回路由下发的表');
240
+ assert.ok(testing.priceTable(null) !== custom, '无下发时退回内联兜底表');
241
+ });
242
+
243
+ check('空闲时段价格为高峰的一半', () => {
244
+ const peak = testing.ratesFor('deepseek-v4-flash', MONDAY_PEAK);
245
+ const off = testing.ratesFor('deepseek-v4-flash', MONDAY_EVENING);
246
+ assert.equal(off.peak, false);
247
+ assert.equal(off.cacheHit, peak.cacheHit / 2);
248
+ assert.equal(off.cacheMiss, peak.cacheMiss / 2);
249
+ assert.equal(off.output, peak.output / 2);
250
+ });
251
+
252
+ check('未登记模型返回 undefined(而不是伪造单价)', () => {
253
+ assert.equal(testing.ratesFor('some-unknown-model', MONDAY_PEAK), undefined);
254
+ });
255
+
256
+ check('TokenUsage → 四个计费桶,缺字段与负数按 0 处理', () => {
257
+ assert.deepEqual(plain(testing.bucketsOf({ inputTokens: 10, outputTokens: 5 })), {
258
+ cacheMiss: 10,
259
+ cacheRead: 0,
260
+ cacheWrite: 0,
261
+ output: 5,
262
+ });
263
+ assert.deepEqual(plain(testing.bucketsOf({ inputTokens: -1, outputTokens: 2, cacheReadTokens: 3 })), {
264
+ cacheMiss: 0,
265
+ cacheRead: 3,
266
+ cacheWrite: 0,
267
+ output: 2,
268
+ });
269
+ assert.deepEqual(plain(testing.bucketsOf(undefined)), {
270
+ cacheMiss: 0,
271
+ cacheRead: 0,
272
+ cacheWrite: 0,
273
+ output: 0,
274
+ });
275
+ });
276
+
277
+ check('推理 token 不重复计入(reasoningTokens 不属于任何桶)', () => {
278
+ const buckets = testing.bucketsOf({ inputTokens: 100, outputTokens: 50, reasoningTokens: 40 });
279
+ assert.equal(buckets.output, 50);
280
+ });
281
+
282
+ check('金额 = 各桶 × 对应单价(百万 tokens)', () => {
283
+ const peak = testing.ratesFor('deepseek-v4-flash', MONDAY_PEAK);
284
+ // 每桶各 100 万 tokens(deepseek-flash 高峰价):2(未命中)+ 0.04(命中)+ 8(输出)= 10.04
285
+ const cost = testing.costOf({ cacheMiss: 1e6, cacheRead: 1e6, cacheWrite: 0, output: 1e6 }, peak);
286
+ assert.equal(Math.round(cost * 1e6) / 1e6, 10.04);
287
+ });
288
+
289
+ check('缓存写入并入“缓存未命中”档计费', () => {
290
+ const peak = testing.ratesFor('deepseek-v4-flash', MONDAY_PEAK);
291
+ const writeOnly = testing.costOf({ cacheMiss: 0, cacheRead: 0, cacheWrite: 1e6, output: 0 }, peak);
292
+ assert.equal(Math.round(writeOnly * 1e6) / 1e6, 2);
293
+ });
294
+
295
+ check('小额金额保留有效位,不显示成 ¥0.00', () => {
296
+ assert.equal(testing.formatMoney(0), '¥0');
297
+ assert.equal(testing.formatMoney(0.0004), '¥0.0004');
298
+ assert.equal(testing.formatMoney(0.032), '¥0.032');
299
+ assert.equal(testing.formatMoney(0.5432), '¥0.543');
300
+ assert.equal(testing.formatMoney(12.345), '¥12.35');
301
+ });
302
+
303
+ // ─────────────────────────────────────────────── 轮次折叠
304
+ console.log('\n轮次折叠');
305
+
306
+ const nodes = [
307
+ {
308
+ kind: 'assistant',
309
+ turn: 1,
310
+ step: 1,
311
+ time: MONDAY_PEAK,
312
+ usage: { inputTokens: 1000, outputTokens: 200 },
313
+ },
314
+ {
315
+ kind: 'assistant',
316
+ turn: 1,
317
+ step: 2,
318
+ time: MONDAY_PEAK,
319
+ usage: { inputTokens: 500, outputTokens: 100, cacheReadTokens: 300 },
320
+ },
321
+ // 非 assistant 节点必须被忽略
322
+ { kind: 'tool-result', turn: 1, time: MONDAY_PEAK, callTime: MONDAY_PEAK - 10 },
323
+ { kind: 'user', turn: 1, time: MONDAY_PEAK },
324
+ // 跨到空闲时段的第二轮
325
+ {
326
+ kind: 'assistant',
327
+ turn: 2,
328
+ step: 1,
329
+ time: MONDAY_EVENING,
330
+ usage: { inputTokens: 2000, outputTokens: 400, cacheWriteTokens: 100 },
331
+ },
332
+ ];
333
+
334
+ const folded = testing.foldTurns(nodes);
335
+
336
+ check('按轮次汇总用量,忽略非 assistant 节点', () => {
337
+ assert.equal(folded.perTurn.size, 2);
338
+ assert.deepEqual(plain(folded.perTurn.get(1).buckets), {
339
+ cacheMiss: 1500,
340
+ cacheRead: 300,
341
+ cacheWrite: 0,
342
+ output: 300,
343
+ });
344
+ assert.deepEqual(plain(folded.perTurn.get(2).buckets), {
345
+ cacheMiss: 2000,
346
+ cacheRead: 0,
347
+ cacheWrite: 100,
348
+ output: 400,
349
+ });
350
+ });
351
+
352
+ check('lastTurn 取最大轮次号', () => {
353
+ assert.equal(folded.lastTurn, 2);
354
+ });
355
+
356
+ check('会话总用量为各轮之和', () => {
357
+ assert.deepEqual(plain(folded.sessionBuckets), {
358
+ cacheMiss: 3500,
359
+ cacheRead: 300,
360
+ cacheWrite: 100,
361
+ output: 700,
362
+ });
363
+ });
364
+
365
+ check('空节点列表不产生轮次', () => {
366
+ const empty = testing.foldTurns([]);
367
+ assert.equal(empty.lastTurn, null);
368
+ assert.equal(empty.perTurn.size, 0);
369
+ });
370
+
371
+ // ─────────────────────────────────────────────── 客户端注册
372
+ console.log('\n客户端注册');
373
+
374
+ const registrations = [];
375
+ const locales = [];
376
+ clientExports.apply({
377
+ effect: (fn) => {
378
+ fn();
379
+ return () => {};
380
+ },
381
+ locale: { register: (ns, dict) => locales.push({ ns, dict }) },
382
+ slots: {
383
+ inject: (name, factory) => factory(),
384
+ register: (entry, component) => registrations.push({ entry, component }),
385
+ },
386
+ });
387
+
388
+ check('把金额行注册进 conversation.composer.dock', () => {
389
+ // apply() 先注册金额行、再注册价格行;下面的断言与渲染用例都依赖这个顺序。
390
+ const rows = registrations.filter((item) => item.entry.name === 'conversation.composer.dock');
391
+ assert.equal(rows.length, 1);
392
+ assert.equal(registrations[0], rows[0], '金额行应当是第一个注册的席位');
393
+ assert.equal(registrations[0].entry.id, 'billing');
394
+ assert.ok(registrations[0].entry.order > 0, 'order 应排在 ui-conversation 的 stats(order 0)之后');
395
+ assert.equal(registrations[0].entry.locale, 'billing');
396
+ assert.equal(typeof registrations[0].component, 'function');
397
+ });
398
+
399
+ check('跨版本节点选择器:0.1.5 的 s.legacy.nodes 与 0.1.1 的 s.chat.legacy.nodes 都认', () => {
400
+ const arr = [{ kind: 'assistant', turn: 1 }];
401
+ assert.equal(testing.selectNodes({ legacy: { nodes: arr } }), arr, '0.1.5 形状');
402
+ assert.equal(testing.selectNodes({ chat: { legacy: { nodes: arr } } }), arr, '0.1.1 形状');
403
+ });
404
+
405
+ check('节点选择器对畸形 store 返回稳定空数组(避免重渲染循环)', () => {
406
+ const a = testing.selectNodes(undefined);
407
+ const b = testing.selectNodes({});
408
+ const c = testing.selectNodes({ legacy: null });
409
+ const d = testing.selectNodes({ chat: {} });
410
+ assert.ok(Array.isArray(a) && a.length === 0);
411
+ assert.equal(a, b, '必须是同一个稳定引用');
412
+ assert.equal(b, c);
413
+ assert.equal(c, d);
414
+ });
415
+
416
+ check('组件优先用 useChat(0.1.5+ 的座位)', () => {
417
+ const nodes = [{ kind: 'assistant', turn: 1, usage: { inputTokens: 100, outputTokens: 20 } }];
418
+ let called = 0;
419
+ const useChat = (selector) => {
420
+ called += 1;
421
+ return selector({ legacy: { nodes } });
422
+ };
423
+ const view = registrations[0].component({ useChat, t: testing.fallbackTranslate });
424
+ assert.ok(called > 0, '没有调用传入的 useChat 座位');
425
+ assert.ok(view && view.__jsx === true, '应当渲染出金额行');
426
+ assert.match(String(view.props.label), /余额/, 'tooltip 应带余额明细');
427
+ });
428
+
429
+ check('组件回退到 useSession(0.1.1 的座位)', () => {
430
+ const nodes = [{ kind: 'assistant', turn: 1, usage: { inputTokens: 100, outputTokens: 20 } }];
431
+ let called = 0;
432
+ const useSession = (selector) => {
433
+ called += 1;
434
+ return selector({ chat: { legacy: { nodes } } });
435
+ };
436
+ const view = registrations[0].component({ useSession, t: testing.fallbackTranslate });
437
+ assert.ok(called > 0, '没有调用传入的 useSession 座位');
438
+ assert.ok(view && view.__jsx === true, '应当渲染出金额行');
439
+ });
440
+
441
+ check('两个座位都没有时静默不渲染(不抛错)', () => {
442
+ const view = registrations[0].component({ t: testing.fallbackTranslate });
443
+ assert.equal(view, null);
444
+ });
445
+
446
+ check('注册时声明 inject 回调,把会话 id 注入组件 props', () => {
447
+ const { inject } = registrations[0].entry;
448
+ assert.equal(typeof inject, 'function', '缺少 inject 回调 → 拿不到 sessionId,只能按默认路由计价');
449
+ // 逐个断言属性:vm 跨 realm 的对象原型不同,且 JSON 往返会丢掉 undefined,
450
+ // 所以这里不比对整体结构。
451
+ assert.equal(inject('sess-123').sessionId, 'sess-123');
452
+ assert.equal(inject(undefined).sessionId, undefined);
453
+ assert.equal(inject(42).sessionId, undefined, '非字符串必须被挡掉');
454
+ assert.equal(inject(null).sessionId, undefined);
455
+ });
456
+
457
+ check('注册 zh/en 文案字典', () => {
458
+ assert.equal(locales.length, 1);
459
+ assert.equal(locales[0].ns, 'billing');
460
+ assert.ok(locales[0].dict.zh, '缺少 zh 字典');
461
+ assert.ok(locales[0].dict.en, '缺少 en 字典');
462
+ });
463
+
464
+ check('zh 与 en 的键一一对应(避免某种语言漏文案)', () => {
465
+ const { zh, en } = locales[0].dict;
466
+ assert.deepEqual(Object.keys(zh).sort(), Object.keys(en).sort());
467
+ });
468
+
469
+ check('文案占位符在两份字典中一致', () => {
470
+ const placeholders = (text) => (String(text).match(/\{(\w+)\}/g) ?? []).sort();
471
+ for (const key of Object.keys(locales[0].dict.zh)) {
472
+ assert.deepEqual(
473
+ placeholders(locales[0].dict.zh[key]),
474
+ placeholders(locales[0].dict.en[key]),
475
+ `键 ${key} 的占位符不一致`,
476
+ );
477
+ }
478
+ });
479
+
480
+ // ─────────────────────────────────────────────── 视图装配
481
+ console.log('\n视图装配');
482
+
483
+ /** 从真实 zh 字典构造翻译函数,顺带验证字典本身可插值。 */
484
+ const zhDictionary = locales[0].dict.zh;
485
+ const fakeT = (key, params) => {
486
+ const template = zhDictionary[key];
487
+ assert.ok(template !== undefined, `字典缺少键 ${key}`);
488
+ if (params === undefined) return template;
489
+ return template.replace(/\{(\w+)\}/g, (_, name) => {
490
+ assert.ok(name in params, `键 ${key} 缺少参数 ${name}`);
491
+ return String(params[name]);
492
+ });
493
+ };
494
+
495
+ /** 一次成功的余额摘要(模型为 deepseek-v4-flash)。 */
496
+ const okSummary = {
497
+ phase: 'ready',
498
+ data: {
499
+ ok: true,
500
+ currency: 'CNY',
501
+ balance: { state: 'ok', total: '45.26', granted: '0.00', toppedUp: '45.26' },
502
+ model: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
503
+ },
504
+ };
505
+
506
+ check('余额组显示账户余额', () => {
507
+ const view = testing.composeView(folded, okSummary, fakeT);
508
+ assert.equal(view.groups[0], '余额 ¥45.26');
509
+ });
510
+
511
+ check('本轮金额按该轮时刻的档位独立算出(与视图内部实现无关地重算一遍)', () => {
512
+ const view = testing.composeView(folded, okSummary, fakeT);
513
+ // 最后轮次是 turn 2,发生在周一傍晚 → 空闲价(高峰价的一半)
514
+ const offPeak = { cacheMiss: 1, cacheHit: 0.02, output: 4 };
515
+ const expected = ((2000 + 100) * offPeak.cacheMiss + 0 * offPeak.cacheHit + 400 * offPeak.output) / 1e6;
516
+ assert.equal(view.groups[1], `本轮 ${testing.formatMoney(expected)}`);
517
+ });
518
+
519
+ check('会话金额为各轮按各自档位之和', () => {
520
+ const view = testing.composeView(folded, okSummary, fakeT);
521
+ const peak = { cacheMiss: 2, cacheHit: 0.04, output: 8 };
522
+ const offPeak = { cacheMiss: 1, cacheHit: 0.02, output: 4 };
523
+ const turn1 = ((1500 + 0) * peak.cacheMiss + 300 * peak.cacheHit + 300 * peak.output) / 1e6;
524
+ const turn2 = ((2000 + 100) * offPeak.cacheMiss + 0 * offPeak.cacheHit + 400 * offPeak.output) / 1e6;
525
+ assert.equal(view.groups[2], `本会话 ${testing.formatMoney(turn1 + turn2)}`);
526
+ });
527
+
528
+ check('hover 明细带上模型、档位、单价与 token 拆分', () => {
529
+ const view = testing.composeView(folded, okSummary, fakeT);
530
+ const detail = view.detail.join(' · ');
531
+ assert.match(detail, /余额 45\.26(赠送 0\.00 · 充值 45\.26)/);
532
+ assert.match(detail, /按 deepseek-v4-flash 计价(部署默认路由,未取到会话路由)/);
533
+ assert.match(detail, /空闲价 CNY\/百万 tokens:命中 0\.02 · 未命中 1 · 输出 4/);
534
+ assert.match(detail, /本轮 tokens 入 2\.1K(命中 0)· 出 400/);
535
+ assert.match(detail, /单价来源:/);
536
+ });
537
+
538
+ check('计价依据是该会话真实路由时,hover 明确标注', () => {
539
+ const summary = {
540
+ phase: 'ready',
541
+ data: {
542
+ ...okSummary.data,
543
+ model: { provider: 'deepseek-official', model: 'deepseek-v4-flash', source: 'session' },
544
+ },
545
+ };
546
+ const view = testing.composeView(folded, summary, fakeT);
547
+ assert.match(view.detail.join(' · '), /按 deepseek-v4-flash 计价(该会话实际路由)/);
548
+ });
549
+
550
+ check('未登记单价的模型只报 token,不编造金额', () => {
551
+ const summary = {
552
+ phase: 'ready',
553
+ data: { ...okSummary.data, model: { provider: 'x', model: 'mystery-model' } },
554
+ };
555
+ const view = testing.composeView(folded, summary, fakeT);
556
+ assert.equal(view.groups[0], '余额 ¥45.26');
557
+ assert.equal(view.groups[1], '本轮 未配置单价');
558
+ assert.equal(view.groups.length, 2, '未配置单价时不应出现会话金额');
559
+ });
560
+
561
+ check('余额接口失败时保留占位并在 hover 里给出原因', () => {
562
+ const summary = {
563
+ phase: 'ready',
564
+ data: {
565
+ ok: false,
566
+ currency: 'CNY',
567
+ model: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
568
+ error: { code: 'missing-credential', message: '未配置凭据 DEEPSEEK_API_KEY' },
569
+ },
570
+ };
571
+ const view = testing.composeView(folded, summary, fakeT);
572
+ assert.equal(view.groups[0], '余额 —');
573
+ assert.match(view.detail.join(' · '), /余额不可用:未配置凭据 DEEPSEEK_API_KEY/);
574
+ });
575
+
576
+ check('只有一轮时不显示会话金额(与 stats 行不重复)', () => {
577
+ const turnTwoNodes = nodes.filter((node) => node.kind === 'assistant' && node.turn === 2);
578
+ assert.equal(turnTwoNodes.length, 1, '样例里应当只有一个第二轮 assistant 节点');
579
+ const single = testing.foldTurns(turnTwoNodes);
580
+ assert.equal(single.perTurn.size, 1);
581
+ const view = testing.composeView(single, okSummary, fakeT);
582
+ assert.equal(view.groups.length, 2, '单轮时不应出现会话金额组');
583
+ assert.match(view.groups[0], /^余额 /);
584
+ assert.match(view.groups[1], /^本轮 /);
585
+ });
586
+
587
+ check('空会话只显示余额占位', () => {
588
+ const view = testing.composeView(testing.foldTurns([]), { phase: 'loading' }, fakeT);
589
+ assert.deepEqual([...view.groups], ['余额 —']);
590
+ });
591
+
592
+ check('缺 t 席位时用 zh 字典兜底,整行仍能渲染', () => {
593
+ const view = testing.composeView(folded, okSummary, testing.fallbackTranslate);
594
+ assert.equal(view.groups[0], '余额 ¥45.26');
595
+ });
596
+
597
+ check('兜底翻译遇到未知键返回键名而不是抛错', () => {
598
+ assert.equal(testing.fallbackTranslate('nope.missing'), 'nope.missing');
599
+ assert.equal(testing.fallbackTranslate('turn', { amount: '¥1' }), '本轮 ¥1');
600
+ });
601
+
602
+ check('英文文案同样可插值(en 字典不缺键)', () => {
603
+ const enDictionary = locales[0].dict.en;
604
+ const enT = (key, params) => {
605
+ const template = enDictionary[key];
606
+ assert.ok(template !== undefined, `en 字典缺少键 ${key}`);
607
+ if (params === undefined) return template;
608
+ return template.replace(/\{(\w+)\}/g, (_, name) => String(params[name]));
609
+ };
610
+ const view = testing.composeView(folded, okSummary, enT);
611
+ assert.match(view.groups[0], /^Balance ¥45\.26$/);
612
+ assert.match(view.groups[1], /^This turn ¥/);
613
+ });
614
+
615
+ check('复刻线上场景:model=deepseek-flash(settings.yaml 里的当前值)→ 出金额,不是"未配置单价"', () => {
616
+ // 这是截图里的真实情况:模型座位显示 DeepSeek-V41-Flash,settings 里写的是 deepseek-flash。
617
+ // 之前单价表只有旧的 deepseek-v4-flash,于是界面显示"未配置单价"。
618
+ const nodes = [
619
+ { kind: 'assistant', turn: 1, time: MONDAY_EVENING, usage: { inputTokens: 2000, outputTokens: 400 } },
620
+ ];
621
+ const summary = {
622
+ phase: 'ready',
623
+ data: {
624
+ ok: true,
625
+ currency: 'CNY',
626
+ balance: { state: 'ok', total: '52.18', granted: '0.00', toppedUp: '52.18' },
627
+ model: { provider: 'deepseek-official', model: 'deepseek-flash', source: 'session' },
628
+ },
629
+ };
630
+ const view = testing.composeView(testing.foldTurns(nodes), summary, fakeT);
631
+ assert.equal(view.groups[0], '余额 ¥52.18');
632
+ assert.match(view.groups[1], /^本轮 ¥\d/, `本轮必须是金额,实际 ${view.groups[1]}`);
633
+ assert.ok(!view.groups[1].includes('未配置单价'), '不应再显示"未配置单价"');
634
+ assert.match(view.detail.join(' · '), /按 deepseek-flash 计价/);
635
+ });
636
+
637
+ check('路由下发的价格表覆盖内联表时,界面用下发的价(改价热生效)', () => {
638
+ const nodes = [
639
+ { kind: 'assistant', turn: 1, time: MONDAY_EVENING, usage: { inputTokens: 1e6, outputTokens: 1e6, cacheReadTokens: 0 } },
640
+ ];
641
+ const base = {
642
+ phase: 'ready',
643
+ data: {
644
+ ok: true,
645
+ currency: 'CNY',
646
+ balance: { state: 'ok', total: '1.00', granted: '0', toppedUp: '1.00' },
647
+ model: { provider: 'p', model: 'deepseek-flash', source: 'session' },
648
+ },
649
+ };
650
+ // 空闲价(周一傍晚)× 1M 未命中输入 + 1M 输出 = 1 + 4 = 5 元
651
+ const builtin = testing.composeView(testing.foldTurns(nodes), base, fakeT);
652
+ assert.equal(builtin.groups[1], '本轮 ¥5.00');
653
+ // 下发一张更贵的表(高峰 10 / 40,空闲减半 → 5 / 20):1M 未命中 + 1M 输出 = 25 元
654
+ const override = {
655
+ phase: 'ready',
656
+ data: {
657
+ ...base.data,
658
+ priceSource: '测试覆盖表',
659
+ prices: { 'deepseek-flash': { cacheHit: 1, cacheMiss: 10, output: 40 } },
660
+ },
661
+ };
662
+ const served = testing.composeView(testing.foldTurns(nodes), override, fakeT);
663
+ assert.equal(served.groups[1], '本轮 ¥25.00', '应用下发价格表(注意覆盖表同样按空闲时段减半)');
664
+ assert.match(served.detail.join(' · '), /单价来源:测试覆盖表/);
665
+ });
666
+
667
+ check('数据链:0.1.5 形状 store → 折叠 → 出「本轮/本会话」金额', () => {
668
+ // 走真实链路:selectNodes 取节点 → foldTurns 折叠 → composeView 出文案。
669
+ // (不穿过组件:测试用的 React 替身不跑 effect/状态更新,测不了 fetch 那条路。)
670
+ const store = {
671
+ legacy: {
672
+ nodes: [
673
+ { kind: 'assistant', turn: 1, time: MONDAY_PEAK, usage: { inputTokens: 1000, outputTokens: 200 } },
674
+ { kind: 'assistant', turn: 2, time: MONDAY_EVENING, usage: { inputTokens: 2000, outputTokens: 400 } },
675
+ ],
676
+ },
677
+ };
678
+ const nodes = testing.selectNodes(store);
679
+ assert.equal(nodes.length, 2, '应能从 0.1.5 的 store 形状取到节点');
680
+ const folded2 = testing.foldTurns(nodes);
681
+ assert.equal(folded2.lastTurn, 2);
682
+ const view = testing.composeView(folded2, okSummary, fakeT);
683
+ assert.match(view.groups[0], /^余额 ¥45\.26$/);
684
+ assert.match(view.groups[1], /^本轮 ¥/, '第二轮应算出本轮金额');
685
+ assert.match(view.groups[2], /^本会话 ¥/, '两轮应有本会话金额');
686
+ assert.match(view.detail.join(' · '), /按 deepseek-v4-flash 计价(部署默认路由,未取到会话路由)/);
687
+ });
688
+
689
+ // ─────────────────────────────────────────────── Host 侧
690
+ console.log('\nHost 侧');
691
+
692
+ const { createPricingReader } = await import('../lib/pricing.js');
693
+ const { apply: hostApply } = await import('../lib/index.js');
694
+
695
+ /** 采集路由注册。 */
696
+ const routes = [];
697
+ const hostCtx = {
698
+ effect: (fn) => {
699
+ fn();
700
+ return () => {};
701
+ },
702
+ inject: (deps, fn) => fn(hostCtx),
703
+ get: () => undefined,
704
+ logger: { info: () => {}, warn: () => {} },
705
+ webServer: {
706
+ register: (route) => {
707
+ routes.push(route);
708
+ return () => {};
709
+ },
710
+ },
711
+ };
712
+
713
+ // 让“缺凭据”这条路径可复现:指向一个必然不存在的环境变量名。
714
+ process.env.DSH_BILLING_API_KEY_ENV = 'DSH_BILLING_ABSENT_KEY_FOR_TEST';
715
+
716
+ hostApply(hostCtx);
717
+
718
+ const summaryRoute = routes.find((route) => route.path === '/plugins/billing/summary');
719
+ const pricesRoute = routes.find((route) => route.path === '/plugins/billing/prices');
720
+
721
+ check('注册了余额摘要路由与价格表路由', () => {
722
+ assert.ok(summaryRoute, '缺 /plugins/billing/summary');
723
+ assert.equal(summaryRoute.kind, 'exact');
724
+ assert.equal(typeof summaryRoute.handler, 'function');
725
+ assert.ok(pricesRoute, '缺 /plugins/billing/prices');
726
+ assert.equal(pricesRoute.kind, 'prefix', '价格表路由要一次挂住 GET 与 POST 两个子路径');
727
+ assert.equal(typeof pricesRoute.handler, 'function');
728
+ });
729
+
730
+ /** 最小 http 响应替身,记录写出的状态码与正文。 */
731
+ function fakeResponse() {
732
+ const captured = { status: null, headers: null, body: undefined };
733
+ return {
734
+ captured,
735
+ headersSent: false,
736
+ writeHead(status, headers) {
737
+ captured.status = status;
738
+ captured.headers = headers;
739
+ this.headersSent = true;
740
+ },
741
+ end(body) {
742
+ captured.body = body;
743
+ },
744
+ };
745
+ }
746
+
747
+ // ── 数据面鉴权(与 /api 对齐)──
748
+ console.log('Host 侧 · 鉴权');
749
+
750
+ /** 带自定义 connection 服务的 ctx(用它模拟新版 dsh 的 cookie 鉴权)。 */
751
+ function ctxWithAuth(reject) {
752
+ const registered = [];
753
+ const c = {
754
+ effect: (fn) => {
755
+ fn();
756
+ return () => {};
757
+ },
758
+ logger: { info: () => {}, warn: () => {} },
759
+ webServer: {
760
+ register: (route) => {
761
+ registered.push(route);
762
+ return () => {};
763
+ },
764
+ },
765
+ get: (name) => (name === 'connection' ? { requestRejection: reject } : undefined),
766
+ };
767
+ c.inject = (deps, fn) => fn(c);
768
+ hostApply(c);
769
+ return registered[0];
770
+ }
771
+
772
+ await checkAsync('未认证时返回 401(与 /api 一致),且不碰余额接口', async () => {
773
+ const route = ctxWithAuth(() => 401);
774
+ const res = fakeResponse();
775
+ await route.handler({ method: 'GET', url: '/plugins/billing/summary' }, res);
776
+ assert.equal(res.captured.status, 401);
777
+ assert.match(String(res.captured.body), /authentication required/);
778
+ });
779
+
780
+ await checkAsync('权威不受信时返回 403(Host/Origin 围栏)', async () => {
781
+ const route = ctxWithAuth(() => 403);
782
+ const res = fakeResponse();
783
+ await route.handler({ method: 'GET', url: '/plugins/billing/summary' }, res);
784
+ assert.equal(res.captured.status, 403);
785
+ assert.match(String(res.captured.body), /forbidden/);
786
+ });
787
+
788
+ await checkAsync('准入通过时正常返回余额摘要', async () => {
789
+ const route = ctxWithAuth((req) => (req.headers?.cookie === 'dsh-auth-x=ok' ? undefined : 401));
790
+ const res = fakeResponse();
791
+ await route.handler(
792
+ { method: 'GET', url: '/plugins/billing/summary', headers: { cookie: 'dsh-auth-x=ok' } },
793
+ res,
794
+ );
795
+ assert.equal(res.captured.status, 200);
796
+ assert.equal(JSON.parse(res.captured.body).ok, false);
797
+ });
798
+
799
+ await checkAsync('鉴权服务抛错时按拒绝处理(数据面宁可 401)', async () => {
800
+ const route = ctxWithAuth(() => {
801
+ throw new Error('boom');
802
+ });
803
+ const res = fakeResponse();
804
+ await route.handler({ method: 'GET', url: '/plugins/billing/summary' }, res);
805
+ assert.equal(res.captured.status, 401);
806
+ });
807
+
808
+ await checkAsync('旧版没有 connection 服务时跳过校验(向后兼容)', async () => {
809
+ const res = fakeResponse();
810
+ await summaryRoute.handler({ method: 'GET', url: '/plugins/billing/summary' }, res);
811
+ assert.equal(res.captured.status, 200, '旧版部署不应因为缺少鉴权服务而 401');
812
+ });
813
+
814
+ await checkAsync('非 GET 请求返回 405 且不发网络请求', async () => {
815
+ const res = fakeResponse();
816
+ await summaryRoute.handler({ method: 'POST' }, res);
817
+ assert.equal(res.captured.status, 405);
818
+ assert.equal(res.captured.headers.allow, 'GET, HEAD');
819
+ });
820
+
821
+ await checkAsync('缺凭据时返回可解析的 JSON 失败响应(而非 500 或挂住)', async () => {
822
+ const res = fakeResponse();
823
+ await summaryRoute.handler({ method: 'GET' }, res);
824
+ assert.equal(res.captured.status, 200);
825
+ assert.equal(res.captured.headers['content-type'], 'application/json; charset=utf-8');
826
+ const body = JSON.parse(res.captured.body);
827
+ assert.equal(body.ok, false);
828
+ assert.equal(body.error.code, 'missing-credential');
829
+ assert.match(body.error.message, /未配置凭据/);
830
+ });
831
+
832
+ await checkAsync('失败响应里绝不出现密钥字样', async () => {
833
+ const res = fakeResponse();
834
+ const previous = process.env.DSH_BILLING_ABSENT_KEY_FOR_TEST;
835
+ process.env.DSH_BILLING_ABSENT_KEY_FOR_TEST = 'sk-super-secret-value';
836
+ try {
837
+ await summaryRoute.handler({ method: 'GET' }, res);
838
+ } finally {
839
+ if (previous === undefined) delete process.env.DSH_BILLING_ABSENT_KEY_FOR_TEST;
840
+ else process.env.DSH_BILLING_ABSENT_KEY_FOR_TEST = previous;
841
+ }
842
+ const body = JSON.parse(res.captured.body);
843
+ // 配了密钥却没有 credentials 服务时,会走环境兜底并尝试联网;
844
+ // 无论成败,正文都不该出现密钥本身。
845
+ assert.ok(!String(res.captured.body).includes('sk-super-secret-value'), '响应正文泄露了密钥');
846
+ assert.ok(body.ok === true || typeof body.error?.code === 'string');
847
+ });
848
+
849
+ // ── 价格读取器(Host 侧,可热更新)──
850
+ console.log('\nHost 侧 · 价格读取器');
851
+
852
+ check('内置表:读到 pricing.json 且含当前在用的 deepseek-flash', () => {
853
+ // 这条只验内置表;读取器要显式指定一个空的 home,免得被真实 ~/.dsh 里的覆盖文件影响。
854
+ const isolated = createPricingReader({ home: mkdtempSync(join(tmpdir(), 'billing-builtin-')), ttlMs: 0 });
855
+ const info = isolated.read();
856
+ assert.ok(Object.keys(info.prices).length >= 4, `至少 4 个模型,实际 ${Object.keys(info.prices).length}`);
857
+ assert.deepEqual(info.prices['deepseek-flash'], { cacheHit: 0.04, cacheMiss: 2, output: 8 });
858
+ assert.equal(info.currency, 'CNY');
859
+ assert.match(String(info.source), /DeepSeek 官方定价页/);
860
+ });
861
+
862
+ check('覆盖文件:新增模型、改价、并采用其 source', () => {
863
+ const dir = mkdtempSync(join(tmpdir(), 'billing-prices-'));
864
+ const overridePath = join(dir, 'billing-prices.json');
865
+ writeFileSync(overridePath, JSON.stringify({
866
+ source: '本机覆盖 · 测试',
867
+ peakPrices: {
868
+ 'my-custom-model': { cacheHit: 1, cacheMiss: 2, output: 3 },
869
+ 'deepseek-flash': { cacheHit: 9, cacheMiss: 9, output: 9 },
870
+ },
871
+ }));
872
+ const info = createPricingReader({ overridePath, ttlMs: 0 }).read();
873
+ assert.deepEqual(info.prices['my-custom-model'], { cacheHit: 1, cacheMiss: 2, output: 3 });
874
+ assert.deepEqual(info.prices['deepseek-flash'], { cacheHit: 9, cacheMiss: 9, output: 9 }, '覆盖应生效');
875
+ assert.equal(info.source, '本机覆盖 · 测试');
876
+ assert.equal(info.overrideUsed, true);
877
+ rmSync(dir, { recursive: true, force: true });
878
+ });
879
+
880
+ check('覆盖文件里的畸形记录被忽略,不影响其它条目', () => {
881
+ const dir = mkdtempSync(join(tmpdir(), 'billing-prices-'));
882
+ const overridePath = join(dir, 'billing-prices.json');
883
+ writeFileSync(overridePath, JSON.stringify({
884
+ peakPrices: {
885
+ 'bad-1': { cacheHit: 'x', cacheMiss: 2, output: 3 },
886
+ 'bad-2': { cacheHit: 1, cacheMiss: -1, output: 3 },
887
+ 'bad-3': null,
888
+ 'good-one': { cacheHit: 0.5, cacheMiss: 1, output: 2 },
889
+ },
890
+ }));
891
+ const info = createPricingReader({ overridePath, ttlMs: 0 }).read();
892
+ for (const bad of ['bad-1', 'bad-2', 'bad-3']) assert.equal(info.prices[bad], undefined, `${bad} 应被忽略`);
893
+ assert.deepEqual(info.prices['good-one'], { cacheHit: 0.5, cacheMiss: 1, output: 2 });
894
+ rmSync(dir, { recursive: true, force: true });
895
+ });
896
+
897
+ check('覆盖文件不存在时不报错,只有内置表', () => {
898
+ const info = createPricingReader({ overridePath: join(tmpdir(), 'definitely-not-here-12345.json'), ttlMs: 0 }).read();
899
+ assert.ok(info.prices['deepseek-flash'] !== undefined);
900
+ assert.equal(info.overrideUsed, false);
901
+ });
902
+
903
+ check('TTL:缓存期内不重读,ttlMs=0 立即生效(改价无需重启的前提)', () => {
904
+ const dir = mkdtempSync(join(tmpdir(), 'billing-prices-'));
905
+ const overridePath = join(dir, 'billing-prices.json');
906
+ writeFileSync(overridePath, JSON.stringify({ peakPrices: { m: { cacheHit: 1, cacheMiss: 1, output: 1 } } }));
907
+ const cached = createPricingReader({ overridePath, ttlMs: 60_000 });
908
+ assert.equal(cached.read().prices.m.output, 1);
909
+ writeFileSync(overridePath, JSON.stringify({ peakPrices: { m: { cacheHit: 2, cacheMiss: 2, output: 2 } } }));
910
+ assert.equal(cached.read().prices.m.output, 1, '缓存期内应返回旧值');
911
+ assert.equal(createPricingReader({ overridePath, ttlMs: 0 }).read().prices.m.output, 2, 'ttlMs=0 应立刻读到新值');
912
+ rmSync(dir, { recursive: true, force: true });
913
+ });
914
+
915
+ await checkAsync('响应里带 prices/priceSource(客户端优先用它 → 改价不用重建 bundle)', async () => {
916
+ const res = fakeResponse();
917
+ await summaryRoute.handler({ method: 'GET', url: '/plugins/billing/summary' }, res);
918
+ const body = JSON.parse(res.captured.body);
919
+ assert.ok(body.prices !== undefined && Object.keys(body.prices).length >= 4, '应下发价格表');
920
+ assert.deepEqual(body.prices['deepseek-flash'], { cacheHit: 0.04, cacheMiss: 2, output: 8 });
921
+ assert.match(String(body.priceSource), /DeepSeek 官方定价页/);
922
+ });
923
+
924
+ // ── 按会话解析计价模型 ──
925
+ // 部署默认路由是 v4-pro,而会话实际跑 v4-flash(单价差 3 倍),
926
+ // 因此这条路径必须能被独立验证:命中 / 失败退回 / 不带参数三种情况。
927
+ console.log('\nHost 侧 · 按会话解析计价模型');
928
+
929
+ /** 假会话日志:最后一条 request/context 才是权威值。 */
930
+ const SESSION_EVENTS = [
931
+ { type: 'turn/start', data: { turn: 1 } },
932
+ { type: 'request/context', data: { provider: 'deepseek-official', model: 'deepseek-v4-pro' } },
933
+ { type: 'assistant/message', data: { turn: 1, step: 1, message: {}, usage: { inputTokens: 10, outputTokens: 5 } } },
934
+ { type: 'request/context', data: { provider: 'deepseek-official', model: 'deepseek-v4-flash' } },
935
+ ];
936
+
937
+ const sessionRoutes = [];
938
+ const sessionCtx = {
939
+ effect: (fn) => {
940
+ fn();
941
+ return () => {};
942
+ },
943
+ logger: { info: () => {}, warn: () => {} },
944
+ webServer: {
945
+ register: (route) => {
946
+ sessionRoutes.push(route);
947
+ return () => {};
948
+ },
949
+ },
950
+ get: (name) => {
951
+ if (name === 'sessionPersistence') {
952
+ return {
953
+ inspect: async (id) => {
954
+ if (id !== 'sess-ok') throw new Error(`unknown session ${id}`);
955
+ return { meta: {}, events: SESSION_EVENTS };
956
+ },
957
+ };
958
+ }
959
+ if (name === 'agentDefaultModel') {
960
+ return { currentSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-v4-pro' }) };
961
+ }
962
+ return undefined;
963
+ },
964
+ };
965
+ sessionCtx.inject = (deps, fn) => fn(sessionCtx);
966
+ hostApply(sessionCtx);
967
+
968
+ await checkAsync('带 session 参数时取该会话日志里最后一条 request/context', async () => {
969
+ const res = fakeResponse();
970
+ await sessionRoutes[0].handler({ method: 'GET', url: '/plugins/billing/summary?session=sess-ok' }, res);
971
+ const body = JSON.parse(res.captured.body);
972
+ assert.equal(body.model.model, 'deepseek-v4-flash', '应当取最后一条而非第一条');
973
+ assert.equal(body.model.provider, 'deepseek-official');
974
+ assert.equal(body.model.source, 'session');
975
+ });
976
+
977
+ await checkAsync('会话读取失败时退回部署默认路由,并标明来源', async () => {
978
+ const res = fakeResponse();
979
+ await sessionRoutes[0].handler({ method: 'GET', url: '/plugins/billing/summary?session=sess-missing' }, res);
980
+ const body = JSON.parse(res.captured.body);
981
+ assert.equal(body.model.model, 'deepseek-v4-pro');
982
+ assert.equal(body.model.source, 'default');
983
+ });
984
+
985
+ await checkAsync('不带 session 参数时用部署默认路由', async () => {
986
+ const res = fakeResponse();
987
+ await sessionRoutes[0].handler({ method: 'GET', url: '/plugins/billing/summary' }, res);
988
+ const body = JSON.parse(res.captured.body);
989
+ assert.equal(body.model.source, 'default');
990
+ });
991
+
992
+ await checkAsync('畸形 url 不会让处理器抛错', async () => {
993
+ const res = fakeResponse();
994
+ await sessionRoutes[0].handler({ method: 'GET', url: '::::not a url::::' }, res);
995
+ assert.equal(res.captured.status, 200);
996
+ const body = JSON.parse(res.captured.body);
997
+ assert.equal(typeof body.ok, 'boolean');
998
+ });
999
+
1000
+ // ── 0.1.5+ 的句柄式 API(inspect 已被移除)──
1001
+ console.log('Host 侧 · 0.1.5 句柄式 API');
1002
+
1003
+ const handleOpened = [];
1004
+ const handleRoutes = [];
1005
+ const handleCtx = {
1006
+ effect: (fn) => {
1007
+ fn();
1008
+ return () => {};
1009
+ },
1010
+ logger: { info: () => {}, warn: () => {} },
1011
+ webServer: {
1012
+ register: (route) => {
1013
+ handleRoutes.push(route);
1014
+ return () => {};
1015
+ },
1016
+ },
1017
+ get: (name) => {
1018
+ if (name === 'sessionPersistence') {
1019
+ return {
1020
+ // 新 API:只有 open(id, mode),没有 inspect
1021
+ open: async (id, mode) => {
1022
+ handleOpened.push({ id, mode });
1023
+ if (id !== 'sess-ok') throw new Error(`unknown session ${id}`);
1024
+ let closed = false;
1025
+ return {
1026
+ read: async () => ({ events: SESSION_EVENTS }),
1027
+ close: async () => {
1028
+ closed = true;
1029
+ },
1030
+ isClosed: () => closed,
1031
+ };
1032
+ },
1033
+ };
1034
+ }
1035
+ if (name === 'agentDefaultModel') {
1036
+ return { currentSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-v4-pro' }) };
1037
+ }
1038
+ return undefined;
1039
+ },
1040
+ };
1041
+ handleCtx.inject = (deps, fn) => fn(handleCtx);
1042
+ hostApply(handleCtx);
1043
+
1044
+ await checkAsync('句柄式 API 下也能取到会话真实模型', async () => {
1045
+ const res = fakeResponse();
1046
+ await handleRoutes[0].handler({ method: 'GET', url: '/plugins/billing/summary?session=sess-ok' }, res);
1047
+ const body = JSON.parse(res.captured.body);
1048
+ assert.equal(body.model.model, 'deepseek-v4-flash');
1049
+ assert.equal(body.model.source, 'session');
1050
+ });
1051
+
1052
+ await checkAsync('以只读模式打开句柄(不抢写所有权)', () => {
1053
+ assert.ok(handleOpened.length > 0, '没有调用 open()');
1054
+ for (const call of handleOpened) {
1055
+ assert.equal(call.mode, 'read', `必须以 read 模式打开,实际是 ${call.mode}`);
1056
+ }
1057
+ });
1058
+
1059
+ await checkAsync('句柄式 API 读取失败时退回默认路由', async () => {
1060
+ const res = fakeResponse();
1061
+ await handleRoutes[0].handler({ method: 'GET', url: '/plugins/billing/summary?session=nope' }, res);
1062
+ const body = JSON.parse(res.captured.body);
1063
+ assert.equal(body.model.source, 'default');
1064
+ });
1065
+
1066
+ await checkAsync('没有 sessionPersistence 服务时不崩,退回默认路由', async () => {
1067
+ const res = fakeResponse();
1068
+ await sessionRoutes[0].handler({ method: 'GET', url: '/plugins/billing/summary?session=whatever' }, res);
1069
+ const body = JSON.parse(res.captured.body);
1070
+ // 这个 ctx 里 inspect 对非 sess-ok 会抛错,因此走的是 default;
1071
+ // 真正要守的是「不抛错、仍有可解析响应」。
1072
+ assert.equal(res.captured.status, 200);
1073
+ assert.equal(body.model.source, 'default');
1074
+ });
1075
+
1076
+ if (process.env.DSH_BILLING_LIVE_TEST === '1') {
1077
+ console.log('\n联网用例(DSH_BILLING_LIVE_TEST=1)');
1078
+ delete process.env.DSH_BILLING_API_KEY_ENV;
1079
+ // 用真实凭据 seam 需要完整的 Host 组合;这里直接读凭据文件所在环境,
1080
+ // 因此仅断言路由在真实凭据下的响应形状。
1081
+ await checkAsync('真实调用上游余额接口', async () => {
1082
+ const { apply: liveApply } = await import('../lib/index.js');
1083
+ const liveRoutes = [];
1084
+ /** 与上面的 hostCtx 同构的替身:inject 必须把完整 ctx 交给回调。 */
1085
+ const liveCtx = {
1086
+ effect: (fn) => {
1087
+ fn();
1088
+ return () => {};
1089
+ },
1090
+ get: () => undefined,
1091
+ logger: { info: () => {}, warn: () => {} },
1092
+ webServer: {
1093
+ register: (route) => {
1094
+ liveRoutes.push(route);
1095
+ return () => {};
1096
+ },
1097
+ },
1098
+ };
1099
+ liveCtx.inject = (deps, fn) => fn(liveCtx);
1100
+ liveApply(liveCtx);
1101
+ // 直接把凭据文件里的密钥交给进程环境,模拟本机已配置。
1102
+ const credentialsPath = join(process.env.HOME ?? '', '.dsh', '.credentials.yaml');
1103
+ const raw = readFileSync(credentialsPath, 'utf8');
1104
+ const match = /DEEPSEEK_API_KEY:\s*(\S+)/.exec(raw);
1105
+ assert.ok(match, '凭据文件里没有 DEEPSEEK_API_KEY');
1106
+ process.env.DEEPSEEK_API_KEY = match[1];
1107
+ const res = fakeResponse();
1108
+ await liveRoutes[0].handler({ method: 'GET' }, res);
1109
+ const body = JSON.parse(res.captured.body);
1110
+ assert.equal(body.ok, true, `上游调用失败:${JSON.stringify(body.error)}`);
1111
+ assert.equal(body.balance.state, 'ok');
1112
+ assert.equal(typeof body.currency, 'string');
1113
+ console.log(` 余额 ${body.balance.total} ${body.currency} · 模型 ${body.model?.model}`);
1114
+ });
1115
+ }
1116
+
1117
+ // ───────────────────────────────────── 排版契约(与原生用量行同一排)
1118
+ //
1119
+ // 这一组是给「看起来不像原生」这类退化兜底的:dock 里的条目要和
1120
+ // dsh-client-ui-chat 的 StatsPills 逐项对齐,否则一眼就能看出是外挂。
1121
+
1122
+ check('金额行排版照抄原生用量行(字号 / 行高 / 胶囊内距)', () => {
1123
+ const css = testing.CSS;
1124
+ // dock 在输入卡片外面,不显式写字号就会继承 body 的 14px,而原生是 secondary-1px(默认 12px)。
1125
+ assert.ok(
1126
+ css.includes('var(--dsh-content-font-size-secondary,13px) - 1px'),
1127
+ '缺原生的 secondary 字号公式',
1128
+ );
1129
+ assert.ok(
1130
+ css.includes('calc(20px + var(--dsh-content-font-delta-secondary,0px))'),
1131
+ '缺原生行高公式(1px 内距 + 20px 行高 = 与原生等高的 22px)',
1132
+ );
1133
+ assert.ok(css.includes('padding:1px 8px'), '胶囊内距应与原生一致');
1134
+ assert.ok(css.includes('border-radius:999px'), '胶囊圆角应与原生一致');
1135
+ assert.ok(css.includes('font-variant-numeric:tabular-nums'), '数字应对齐(等宽数字)');
1136
+ assert.ok(css.includes('var(--dsw-alias-label-tertiary)'), '文字色应取原生三级文字色');
1137
+ });
1138
+
1139
+ check('金额行不抢宽度、不用竖线分隔', () => {
1140
+ const css = testing.CSS;
1141
+ // 外层 .yhfFVG_dock 已经是 flex + 居中 + gap:12px;条目自己抢宽度会把原生两枚胶囊挤开。
1142
+ assert.ok(!css.includes('text-align'), 'flex 子项上 text-align 无效,不应出现');
1143
+ assert.ok(!/(^|[;{])width:100%/.test(css), '不应写成整宽(max-width:100% 可以)');
1144
+ assert.ok(!css.includes('opacity'), '原生这一行没有任何 opacity,层级靠文字色 token');
1145
+ assert.ok(!css.includes('margin:0 auto'), '不应自行居中,容器已经居中');
1146
+ // 分隔符用原生中点,而不是竖线。
1147
+ assert.ok(bundleSource.includes("children: '·'"), '分隔符应是原生中点 ·');
1148
+ assert.ok(!bundleSource.includes("children: '|'"), '不应使用竖线分隔');
1149
+ });
1150
+
1151
+ // ───────────────────────────────── 价格表:设置行按钮 + 宿主刷新路由
1152
+
1153
+ check('把价格行注册进 settings.general.item(list 槽必须给 id)', () => {
1154
+ const row = registrations.find((item) => item.entry.name === 'settings.general.item');
1155
+ assert.ok(row, '没有注册价格设置行');
1156
+ assert.equal(row.entry.id, 'billing-prices');
1157
+ assert.equal(typeof row.entry.order, 'number');
1158
+ assert.ok(row.entry.order > 12, 'order 应排在 外观/字号/护眼 之后');
1159
+ assert.equal(typeof row.component, 'function');
1160
+ });
1161
+
1162
+ /** 在独立的 vm realm 里加载 bundle,并注入 fetch(只有价格行用得到网络)。 */
1163
+ function loadBillingBundle(fetchImpl) {
1164
+ let reg;
1165
+ const sandbox = {
1166
+ window: { __ModuleLoader__: { load: (row) => { reg = row; } } },
1167
+ console,
1168
+ fetch: fetchImpl,
1169
+ setTimeout,
1170
+ };
1171
+ vm.runInNewContext(bundleSource, sandbox, { filename: 'lib/client.js' });
1172
+ return reg.factory((specifier) => {
1173
+ if (SHARED[specifier] === undefined) throw new Error(`未预料的共享模块请求:${specifier}`);
1174
+ return SHARED[specifier];
1175
+ });
1176
+ }
1177
+
1178
+ /** 在假 jsx 树里按谓词收集节点(字符串子节点也算,文案断言要用)。 */
1179
+ function findNodes(node, match, out = []) {
1180
+ if (node === null || node === undefined) return out;
1181
+ if (typeof node !== 'object') {
1182
+ if (match(node)) out.push(node);
1183
+ return out;
1184
+ }
1185
+ if (match(node)) out.push(node);
1186
+ const children = node.props?.children;
1187
+ if (Array.isArray(children)) children.forEach((child) => findNodes(child, match, out));
1188
+ else if (children !== undefined) findNodes(children, match, out);
1189
+ return out;
1190
+ }
1191
+
1192
+ await checkAsync('价格行渲染出当前来源与官方 Button', async () => {
1193
+ const bundle = loadBillingBundle(async () => ({
1194
+ ok: true,
1195
+ json: async () => ({ ok: true, source: 'DeepSeek 官方定价页 · 2026-09-18 抓取', updatedAt: '2026-09-18' }),
1196
+ }));
1197
+ const view = bundle.__testing.PricesRow({ t: testing.fallbackTranslate });
1198
+ const texts = findNodes(view, (node) => typeof node === 'string');
1199
+ assert.ok(JSON.stringify(texts).includes('用量价格表'), '缺标题');
1200
+ const buttons = findNodes(view, (node) => node.type === SHARED['@deepseek-ai/dsh-client-ui-primitives'].Button);
1201
+ assert.equal(buttons.length, 1, '应当渲染一个官方 Button');
1202
+ assert.equal(buttons[0].props.children, '立即更新');
1203
+ assert.equal(buttons[0].props.disabled, false);
1204
+ });
1205
+
1206
+ await checkAsync('点按钮:POST 刷新路由、按结果递增价格版本号(金额行随即重算)', async () => {
1207
+ const calls = [];
1208
+ const bundle = loadBillingBundle(async (url, init) => {
1209
+ calls.push({ url, method: init?.method ?? 'GET' });
1210
+ if ((init?.method ?? 'GET') === 'GET') {
1211
+ return { ok: true, json: async () => ({ ok: true, source: '旧表', updatedAt: '2026-09-18' }) };
1212
+ }
1213
+ return {
1214
+ ok: true,
1215
+ json: async () => ({ ok: true, changed: ['deepseek-flash'], source: '新表', updatedAt: '2026-10-09' }),
1216
+ };
1217
+ });
1218
+ assert.equal(bundle.__testing.pricesEpochOf(), 0);
1219
+ const view = bundle.__testing.PricesRow({ t: testing.fallbackTranslate });
1220
+ const button = findNodes(view, (node) => node.type === SHARED['@deepseek-ai/dsh-client-ui-primitives'].Button)[0];
1221
+ await button.props.onClick();
1222
+ assert.deepEqual(
1223
+ calls.map((call) => call.method + ' ' + call.url),
1224
+ ['GET ' + bundle.__testing.PRICES_URL, 'POST ' + bundle.__testing.PRICES_REFRESH_URL],
1225
+ );
1226
+ assert.equal(bundle.__testing.pricesEpochOf(), 1, '成功更新后应通知金额行重算');
1227
+ });
1228
+
1229
+ await checkAsync('按钮失败路径:刷新接口报错时不递增版本号、把原因交给界面', async () => {
1230
+ const bundle = loadBillingBundle(async (url, init) => {
1231
+ if ((init?.method ?? 'GET') === 'GET') {
1232
+ return { ok: true, json: async () => ({ ok: true, source: '旧表' }) };
1233
+ }
1234
+ return {
1235
+ ok: false,
1236
+ status: 502,
1237
+ json: async () => ({ ok: false, error: 'refresh-failed', message: '不满足「空闲 = 高峰的一半」' }),
1238
+ };
1239
+ });
1240
+ const view = bundle.__testing.PricesRow({ t: testing.fallbackTranslate });
1241
+ await findNodes(view, (node) => node.type === SHARED['@deepseek-ai/dsh-client-ui-primitives'].Button)[0].props.onClick();
1242
+ assert.equal(bundle.__testing.pricesEpochOf(), 0, '失败时不应动价格版本号');
1243
+ });
1244
+
1245
+ /** 官方页的最小夹具:两个模型、时段交替、空闲 = 高峰一半。 */
1246
+ const OFFICIAL_PAGE_HTML = `<html><body><table>
1247
+ <tr><td>模型</td><td>deepseek-flash(1)</td><td>deepseek-v4-pro</td></tr>
1248
+ <tr><td>价格(2)</td></tr>
1249
+ <tr><td>百万tokens输入(缓存命中)</td><td>空闲时段</td><td>0.02元</td><td>0.15元</td></tr>
1250
+ <tr><td>高峰时段</td><td>0.04元</td><td>0.30元</td></tr>
1251
+ <tr><td>百万tokens输入(缓存未命中)</td><td>空闲时段</td><td>1元</td><td>4.5元</td></tr>
1252
+ <tr><td>高峰时段</td><td>2元</td><td>9.0元</td></tr>
1253
+ <tr><td>百万tokens输出</td><td>空闲时段</td><td>4元</td><td>13.5元</td></tr>
1254
+ <tr><td>高峰时段</td><td>8元</td><td>27.0元</td></tr>
1255
+ <tr><td>并发限制(3)</td><td>2500</td><td>500</td></tr>
1256
+ </table></body></html>`;
1257
+
1258
+ await checkAsync('宿主刷新路由:抓官方页 → 写本机覆盖文件 → 报出变化(按钮背后就是这个)', async () => {
1259
+ const dir = mkdtempSync(join(tmpdir(), 'billing-refresh-'));
1260
+ const previousHome = process.env.DSH_HOME;
1261
+ const realFetch = globalThis.fetch;
1262
+ process.env.DSH_HOME = dir;
1263
+ globalThis.fetch = async () => ({ ok: true, status: 200, text: async () => OFFICIAL_PAGE_HTML });
1264
+ try {
1265
+ const registered = [];
1266
+ const ctx = {
1267
+ effect: (fn) => { fn(); return () => {}; },
1268
+ inject: (deps, fn) => fn(ctx),
1269
+ get: () => undefined,
1270
+ logger: { info: () => {}, warn: () => {} },
1271
+ webServer: { register: (route) => { registered.push(route); return () => {}; } },
1272
+ };
1273
+ hostApply(ctx);
1274
+ const route = registered.find((item) => item.path === '/plugins/billing/prices');
1275
+ const res = fakeResponse();
1276
+ await route.handler({ method: 'POST', url: '/plugins/billing/prices/refresh' }, res);
1277
+ const body = JSON.parse(res.captured.body);
1278
+ assert.equal(res.captured.status, 200, res.captured.body);
1279
+ assert.equal(body.ok, true);
1280
+ // 覆盖文件真的落盘了,且 Host 立刻用上了新表(不用等 TTL)
1281
+ const written = JSON.parse(readFileSync(join(dir, 'billing-prices.json'), 'utf8'));
1282
+ assert.deepEqual(written.peakPrices['deepseek-flash'], { cacheHit: 0.04, cacheMiss: 2, output: 8 });
1283
+ assert.equal(body.overrideUsed, true);
1284
+ assert.equal(body.updatedAt, new Date().toISOString().slice(0, 10));
1285
+ } finally {
1286
+ globalThis.fetch = realFetch;
1287
+ if (previousHome === undefined) delete process.env.DSH_HOME;
1288
+ else process.env.DSH_HOME = previousHome;
1289
+ rmSync(dir, { recursive: true, force: true });
1290
+ }
1291
+ });
1292
+
1293
+ await checkAsync('宿主刷新路由:官方页改版时 502 且**一个字节都不写**', async () => {
1294
+ const dir = mkdtempSync(join(tmpdir(), 'billing-refresh-bad-'));
1295
+ const previousHome = process.env.DSH_HOME;
1296
+ const realFetch = globalThis.fetch;
1297
+ process.env.DSH_HOME = dir;
1298
+ // 把「输出 高峰」改成 9 → 不再是空闲(4)的两倍,结构自校验必须拦下
1299
+ globalThis.fetch = async () => ({
1300
+ ok: true,
1301
+ status: 200,
1302
+ text: async () => OFFICIAL_PAGE_HTML.replace('8元', '9元'),
1303
+ });
1304
+ try {
1305
+ const registered = [];
1306
+ const ctx = {
1307
+ effect: (fn) => { fn(); return () => {}; },
1308
+ inject: (deps, fn) => fn(ctx),
1309
+ get: () => undefined,
1310
+ logger: { info: () => {}, warn: () => {} },
1311
+ webServer: { register: (route) => { registered.push(route); return () => {}; } },
1312
+ };
1313
+ hostApply(ctx);
1314
+ const route = registered.find((item) => item.path === '/plugins/billing/prices');
1315
+ const res = fakeResponse();
1316
+ await route.handler({ method: 'POST', url: '/plugins/billing/prices/refresh' }, res);
1317
+ assert.equal(res.captured.status, 502);
1318
+ assert.match(JSON.parse(res.captured.body).message, /空闲 = 高峰的一半/);
1319
+ assert.equal(existsSync(join(dir, 'billing-prices.json')), false, '自校验失败时不应写文件');
1320
+ } finally {
1321
+ globalThis.fetch = realFetch;
1322
+ if (previousHome === undefined) delete process.env.DSH_HOME;
1323
+ else process.env.DSH_HOME = previousHome;
1324
+ rmSync(dir, { recursive: true, force: true });
1325
+ }
1326
+ });
1327
+
1328
+ await checkAsync('宿主价格路由:GET 报当前来源、未认证 401、非 POST 405、未知子路径 404', async () => {
1329
+ const registered = [];
1330
+ const ctx = {
1331
+ effect: (fn) => { fn(); return () => {}; },
1332
+ inject: (deps, fn) => fn(ctx),
1333
+ get: (name) => (name === 'connection' ? { requestRejection: () => 401 } : undefined),
1334
+ logger: { info: () => {}, warn: () => {} },
1335
+ webServer: { register: (route) => { registered.push(route); return () => {}; } },
1336
+ };
1337
+ hostApply(ctx);
1338
+ const route = registered.find((item) => item.path === '/plugins/billing/prices');
1339
+ const denied = fakeResponse();
1340
+ await route.handler({ method: 'GET', url: '/plugins/billing/prices' }, denied);
1341
+ assert.equal(denied.captured.status, 401, '写文件的能力必须和 /api 同级鉴权');
1342
+
1343
+ const open = [];
1344
+ const ctx2 = {
1345
+ effect: (fn) => { fn(); return () => {}; },
1346
+ inject: (deps, fn) => fn(ctx2),
1347
+ get: () => undefined,
1348
+ logger: { info: () => {}, warn: () => {} },
1349
+ webServer: { register: (route) => { open.push(route); return () => {}; } },
1350
+ };
1351
+ hostApply(ctx2);
1352
+ const route2 = open.find((item) => item.path === '/plugins/billing/prices');
1353
+ const read = fakeResponse();
1354
+ await route2.handler({ method: 'GET', url: '/plugins/billing/prices' }, read);
1355
+ assert.equal(read.captured.status, 200);
1356
+ const info = JSON.parse(read.captured.body);
1357
+ assert.equal(info.ok, true);
1358
+ assert.ok(info.modelCount >= 4, '应报出模型个数');
1359
+ assert.ok(typeof info.overridePath === 'string' && info.overridePath.endsWith('billing-prices.json'));
1360
+
1361
+ const wrongMethod = fakeResponse();
1362
+ await route2.handler({ method: 'GET', url: '/plugins/billing/prices/refresh' }, wrongMethod);
1363
+ assert.equal(wrongMethod.captured.status, 405);
1364
+
1365
+ const missing = fakeResponse();
1366
+ await route2.handler({ method: 'GET', url: '/plugins/billing/prices/nope' }, missing);
1367
+ assert.equal(missing.captured.status, 404);
1368
+ });
1369
+
1370
+ // ───────────────────────────────── 官方定价页解析器(离线夹具,防页面改版后默默解析错)
1371
+
1372
+ /** 仿官方中文定价页纯文本的关键片段(两个模型)。 */
1373
+ const PRICING_FIXTURE = [
1374
+ '模型',
1375
+ 'deepseek-flash(1)',
1376
+ 'deepseek-v4-pro',
1377
+ '价格(2)',
1378
+ '百万tokens输入',
1379
+ '(缓存命中)',
1380
+ '空闲时段',
1381
+ '0.02元',
1382
+ '0.15元',
1383
+ '高峰时段',
1384
+ '0.04元',
1385
+ '0.30元',
1386
+ '百万tokens输入',
1387
+ '(缓存未命中)',
1388
+ '空闲时段',
1389
+ '1元',
1390
+ '4.5元',
1391
+ '高峰时段',
1392
+ '2元',
1393
+ '9.0元',
1394
+ '百万tokens输出',
1395
+ '空闲时段',
1396
+ '4元',
1397
+ '13.5元',
1398
+ '高峰时段',
1399
+ '8元',
1400
+ '27.0元',
1401
+ '并发限制(3)',
1402
+ '2500',
1403
+ '500',
1404
+ ].join('\n');
1405
+
1406
+ check('解析官方定价页:按行序取高峰价,并认可旧模型名按 Flash 计价', () => {
1407
+ const parsed = parsePricingText(PRICING_FIXTURE);
1408
+ assert.deepEqual([...parsed.models], ['deepseek-flash', 'deepseek-v4-pro']);
1409
+ assert.deepEqual(parsed.peakPrices['deepseek-flash'], { cacheHit: 0.04, cacheMiss: 2, output: 8 });
1410
+ assert.deepEqual(parsed.peakPrices['deepseek-v4-pro'], { cacheHit: 0.3, cacheMiss: 9, output: 27 });
1411
+ const aliased = withLegacyAliases(parsed.peakPrices);
1412
+ assert.deepEqual(aliased['deepseek-v4-flash'], { cacheHit: 0.04, cacheMiss: 2, output: 8 });
1413
+ assert.deepEqual(aliased['deepseek-v4-flash-vision-exp'], { cacheHit: 0.04, cacheMiss: 2, output: 8 });
1414
+ });
1415
+
1416
+ check('解析器宁可失败也不写错钱:空闲 ≠ 高峰一半、缺行、非人民币都拒绝', () => {
1417
+ // 把「输出 高峰」从 8 改成 9 → 不再是空闲(4)的两倍,说明行序/政策变了
1418
+ const broken = PRICING_FIXTURE.replace('高峰时段\n8元', '高峰时段\n9元');
1419
+ assert.throws(() => parsePricingText(broken), /空闲 = 高峰的一半/);
1420
+ // 少一行时段标签(例如官方去掉空闲价)
1421
+ const missing = PRICING_FIXTURE.replace('空闲时段\n4元\n13.5元\n', '');
1422
+ assert.throws(() => parsePricingText(missing), /时段标签|个数/);
1423
+ // 币种不是人民币
1424
+ const usd = PRICING_FIXTURE.replace(/元/g, '美元');
1425
+ assert.throws(() => parsePricingText(usd), /人民币/);
1426
+ });
1427
+
1428
+ // ───────────────────────────── 插件清单元数据(插件页卡片上的图标 / 名称 / 描述)
1429
+ //
1430
+ // 卡片文案取 <包>/locale/<lang>.json 的 meta.title / meta.description,图标取 package.json
1431
+ // 的 icon(包内相对路径,宿主内联成 data URI)。注意 `./locale/*.json` 必须在 exports 里
1432
+ // 声明:宿主是按包名自引用解析这个子路径的,没声明会被 ERR_PACKAGE_PATH_NOT_EXPORTED 拒掉,
1433
+ // 卡片就**静默**回退成包名 + 包描述(不会有任何报错提示)。
1434
+
1435
+ const manifest = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
1436
+
1437
+ check('清单:声明了 locale 子路径导出(否则读不到本地化名称/描述)', () => {
1438
+ assert.ok(
1439
+ Object.keys(manifest.exports ?? {}).includes('./locale/*.json'),
1440
+ 'package.json 的 exports 里缺 "./locale/*.json"',
1441
+ );
1442
+ });
1443
+
1444
+ check('清单:图标是包内相对图片,且不超过 256 KiB', () => {
1445
+ const icon = manifest.icon;
1446
+ assert.equal(typeof icon, 'string', 'package.json 缺 icon 字段');
1447
+ assert.ok(!icon.startsWith('/') && !/^[a-z][a-z\d+.-]*:/i.test(icon), 'icon 必须是相对路径');
1448
+ assert.ok(
1449
+ ['.svg', '.png', '.jpg', '.jpeg', '.webp'].includes(extname(icon).toLowerCase()),
1450
+ 'icon 必须是 SVG / PNG / JPEG / WebP',
1451
+ );
1452
+ const file = join(root, icon);
1453
+ assert.ok(existsSync(file), `图标文件不存在:${icon}`);
1454
+ assert.ok(statSync(file).size <= 256 * 1024, '图标超过 256 KiB,宿主会拒绝读取');
1455
+ });
1456
+
1457
+ check('清单:zh / en 都有 meta.title 与 meta.description,且中英不同', () => {
1458
+ const zh = JSON.parse(readFileSync(join(root, 'locale', 'zh.json'), 'utf8')).meta;
1459
+ const en = JSON.parse(readFileSync(join(root, 'locale', 'en.json'), 'utf8')).meta;
1460
+ for (const [lang, meta] of [['zh', zh], ['en', en]]) {
1461
+ assert.equal(typeof meta?.title, 'string', `${lang} 缺 meta.title`);
1462
+ assert.equal(typeof meta?.description, 'string', `${lang} 缺 meta.description`);
1463
+ }
1464
+ assert.notEqual(zh.title, en.title, '中英标题相同,可能是漏了翻译');
1465
+ assert.ok(zh.title.length <= 24, `中文标题偏长(${zh.title.length} 字),卡片会被截断`);
1466
+ assert.ok(zh.description.length <= 60, `中文描述偏长(${zh.description.length} 字),卡片会被截断`);
1467
+ });
1468
+
1469
+ console.log('');
1470
+ if (failures > 0) {
1471
+ console.error(`自检失败:${failures} 项未通过 ❌`);
1472
+ process.exit(1);
1473
+ }
1474
+ console.log('全部自检通过 ✅');