@mzzsfy/dsh-usage-dash 0.4.0 → 0.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/README.md +4 -4
- package/package.json +1 -1
- package/src/client.js +305 -79
- package/src/collector.js +117 -17
- package/src/query.js +38 -14
- package/src/store.js +16 -2
- package/test/client.test.mjs +201 -25
- package/test/collector.test.mjs +306 -11
- package/test/query.test.mjs +48 -0
- package/test/store.test.mjs +58 -0
- package/test/stream-parity.test.mjs +75 -0
- package/test/turn-tail.test.mjs +66 -0
package/test/collector.test.mjs
CHANGED
|
@@ -17,6 +17,12 @@ const usage = (extra = {}) => ({
|
|
|
17
17
|
cacheWriteTokens: 4,
|
|
18
18
|
...extra,
|
|
19
19
|
})
|
|
20
|
+
// 官方助手流紧凑记录构造(与 dsh-llm AssistantStreamAccumulator.snapshot 同形):
|
|
21
|
+
// packed run 以 time0 + dt 差分还原成员时刻,裸 chunk 携带原始 time
|
|
22
|
+
const textRun = (time0, texts, dt = []) => ({ type: 'text-chunks', time0, index: 0, dt, texts })
|
|
23
|
+
const toolRun = (time0, args, extra = {}) => ({ type: 'tool-call-chunks', time0, index: 0, dt: [], id: 't1', args, ...extra })
|
|
24
|
+
const rawChunk = (time, chunk) => ({ type: 'chunk', time, chunk })
|
|
25
|
+
const attempt = (seq, time, turn, step, stream) => ev('assistant/attempt', seq, time, { turn, step, stream })
|
|
20
26
|
const session = (id) => ({ id })
|
|
21
27
|
const tick = () => new Promise((resolve) => setImmediate(resolve))
|
|
22
28
|
|
|
@@ -168,12 +174,12 @@ test('step/start 与 llm/retry-started 产生 request 标记样本', async () =>
|
|
|
168
174
|
}
|
|
169
175
|
})
|
|
170
176
|
|
|
171
|
-
test('
|
|
177
|
+
test('decode 口径:durationMs 为首 token 到汇报,ttftMs 为起点到首 token', async () => {
|
|
172
178
|
const { ctx, store } = liveCollector()
|
|
173
179
|
const start = local(2026, 8, 2, 10, 0)
|
|
174
|
-
const sampleAt = start + 6 * 1000 + 500
|
|
175
180
|
ctx.emit('session/event', session('s1'), ev('step/start', 1, start, { turn: 0, step: 0 }))
|
|
176
|
-
ctx.emit('session/event', session('s1'),
|
|
181
|
+
ctx.emit('session/event', session('s1'), attempt(2, start + 35 * 1000, 0, 0, [textRun(start + 10 * 1000, ['你好'])]))
|
|
182
|
+
ctx.emit('session/event', session('s1'), ev('assistant/message', 3, start + 40 * 1000, {
|
|
177
183
|
turn: 0,
|
|
178
184
|
step: 0,
|
|
179
185
|
usage: usage(),
|
|
@@ -181,7 +187,98 @@ test('step/start 观测起点:首个 usage 样本附 durationMs(start 到样本
|
|
|
181
187
|
}))
|
|
182
188
|
await tick()
|
|
183
189
|
const usageSample = store.samples.find((sample) => !sample.request)
|
|
184
|
-
assert.equal(usageSample.durationMs,
|
|
190
|
+
assert.equal(usageSample.durationMs, 30 * 1000)
|
|
191
|
+
assert.equal(usageSample.ttftMs, 10 * 1000)
|
|
192
|
+
assert.equal(usageSample.decodeTokens, 20)
|
|
193
|
+
assert.equal(usageSample.outputTokens, 20)
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
test('无 attempt 时回落 message 自带 stream 取首 token', async () => {
|
|
197
|
+
const { ctx, store } = liveCollector()
|
|
198
|
+
const start = local(2026, 8, 2, 10, 0)
|
|
199
|
+
ctx.emit('session/event', session('s1'), ev('step/start', 1, start, { turn: 0, step: 0 }))
|
|
200
|
+
ctx.emit('session/event', session('s1'), ev('assistant/message', 2, start + 40 * 1000, {
|
|
201
|
+
turn: 0,
|
|
202
|
+
step: 0,
|
|
203
|
+
usage: usage(),
|
|
204
|
+
stream: [textRun(start + 8 * 1000, ['好'])],
|
|
205
|
+
message: {},
|
|
206
|
+
}))
|
|
207
|
+
await tick()
|
|
208
|
+
const usageSample = store.samples.find((sample) => !sample.request)
|
|
209
|
+
assert.equal(usageSample.durationMs, 32 * 1000)
|
|
210
|
+
assert.equal(usageSample.ttftMs, 8 * 1000)
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
test('chunk 先发 token 契约保持,message 补发纯 timing 增量样本', async () => {
|
|
214
|
+
const { ctx, store } = liveCollector()
|
|
215
|
+
const start = local(2026, 8, 2, 10, 0)
|
|
216
|
+
ctx.emit('session/event', session('s1'), ev('step/start', 1, start, { turn: 0, step: 0 }))
|
|
217
|
+
ctx.emit('session/event', session('s1'), ev('assistant/chunk', 2, start + 30 * 1000, {
|
|
218
|
+
turn: 0,
|
|
219
|
+
step: 0,
|
|
220
|
+
chunk: { type: 'usage', usage: usage() },
|
|
221
|
+
}))
|
|
222
|
+
ctx.emit('session/event', session('s1'), attempt(3, start + 35 * 1000, 0, 0, [textRun(start + 10 * 1000, ['你好'])]))
|
|
223
|
+
ctx.emit('session/event', session('s1'), ev('assistant/message', 4, start + 40 * 1000, {
|
|
224
|
+
turn: 0,
|
|
225
|
+
step: 0,
|
|
226
|
+
usage: usage({ inputTokens: 999 }),
|
|
227
|
+
message: {},
|
|
228
|
+
}))
|
|
229
|
+
await tick()
|
|
230
|
+
assert.equal(store.samples.length, 3)
|
|
231
|
+
const chunkSample = store.samples[1]
|
|
232
|
+
assert.equal(chunkSample.inputTokens, 10)
|
|
233
|
+
assert.equal(chunkSample.durationMs, undefined)
|
|
234
|
+
assert.equal(chunkSample.ttftMs, undefined)
|
|
235
|
+
assert.equal(chunkSample.decodeTokens, undefined)
|
|
236
|
+
assert.equal(store.samples[0].request, true)
|
|
237
|
+
const timingSample = store.samples[2]
|
|
238
|
+
assert.equal(timingSample.inputTokens, 0)
|
|
239
|
+
assert.equal(timingSample.outputTokens, 0)
|
|
240
|
+
assert.equal(timingSample.decodeTokens, 20)
|
|
241
|
+
assert.equal(timingSample.durationMs, 30 * 1000)
|
|
242
|
+
assert.equal(timingSample.ttftMs, 10 * 1000)
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
test('attempt 首 token 锁定:后续 attempt 不覆盖首个产出 token 的时刻', async () => {
|
|
246
|
+
const { ctx, store } = liveCollector()
|
|
247
|
+
const start = local(2026, 8, 2, 10, 0)
|
|
248
|
+
ctx.emit('session/event', session('s1'), ev('step/start', 1, start, { turn: 0, step: 0 }))
|
|
249
|
+
ctx.emit('session/event', session('s1'), attempt(2, start + 35 * 1000, 0, 0, [textRun(start + 10 * 1000, ['a'])]))
|
|
250
|
+
ctx.emit('session/event', session('s1'), attempt(3, start + 38 * 1000, 0, 0, [textRun(start + 20 * 1000, ['b'])]))
|
|
251
|
+
ctx.emit('session/event', session('s1'), ev('assistant/message', 4, start + 40 * 1000, {
|
|
252
|
+
turn: 0,
|
|
253
|
+
step: 0,
|
|
254
|
+
usage: usage(),
|
|
255
|
+
message: {},
|
|
256
|
+
}))
|
|
257
|
+
await tick()
|
|
258
|
+
const usageSample = store.samples.find((sample) => !sample.request)
|
|
259
|
+
assert.equal(usageSample.ttftMs, 10 * 1000)
|
|
260
|
+
assert.equal(usageSample.durationMs, 30 * 1000)
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
test('首个 attempt 无 token,由后续产出 token 的 attempt 锁定', async () => {
|
|
264
|
+
const { ctx, store } = liveCollector()
|
|
265
|
+
const start = local(2026, 8, 2, 10, 0)
|
|
266
|
+
ctx.emit('session/event', session('s1'), ev('step/start', 1, start, { turn: 0, step: 0 }))
|
|
267
|
+
ctx.emit('session/event', session('s1'), attempt(2, start + 5 * 1000, 0, 0, [
|
|
268
|
+
textRun(start + 5 * 1000, ['']),
|
|
269
|
+
rawChunk(start + 6 * 1000, { type: 'usage' }),
|
|
270
|
+
]))
|
|
271
|
+
ctx.emit('session/event', session('s1'), attempt(3, start + 38 * 1000, 0, 0, [toolRun(start + 15 * 1000, ['{'], { name: 'fn' })]))
|
|
272
|
+
ctx.emit('session/event', session('s1'), ev('assistant/message', 4, start + 40 * 1000, {
|
|
273
|
+
turn: 0,
|
|
274
|
+
step: 0,
|
|
275
|
+
usage: usage(),
|
|
276
|
+
message: {},
|
|
277
|
+
}))
|
|
278
|
+
await tick()
|
|
279
|
+
const usageSample = store.samples.find((sample) => !sample.request)
|
|
280
|
+
assert.equal(usageSample.ttftMs, 15 * 1000)
|
|
281
|
+
assert.equal(usageSample.durationMs, 25 * 1000)
|
|
185
282
|
})
|
|
186
283
|
|
|
187
284
|
test('(session,turn,step) 去重:同键先到样本生效,重复报告吞掉', async () => {
|
|
@@ -217,14 +314,194 @@ test('无 step/start 起点的 usage 样本不带 durationMs', async () => {
|
|
|
217
314
|
assert.equal(store.samples[0].durationMs, undefined)
|
|
218
315
|
})
|
|
219
316
|
|
|
220
|
-
test('retry
|
|
317
|
+
test('retry 不重置起点:TTFT 含失败尝试,decode 段自成功首 token 起', async () => {
|
|
221
318
|
const { ctx, store } = liveCollector()
|
|
222
319
|
const start = local(2026, 8, 2, 10, 0)
|
|
223
320
|
const retryAt = start + 60 * 1000
|
|
224
|
-
const sampleAt = retryAt + 5 * 1000
|
|
225
321
|
ctx.emit('session/event', session('s1'), ev('step/start', 1, start, { turn: 0, step: 0 }))
|
|
226
322
|
ctx.emit('session/event', session('s1'), ev('llm/retry-started', 2, retryAt, { turn: 0, step: 0 }))
|
|
227
|
-
ctx.emit('session/event', session('s1'),
|
|
323
|
+
ctx.emit('session/event', session('s1'), attempt(3, retryAt + 3 * 1000, 0, 0, [textRun(retryAt + 5 * 1000, ['好'])]))
|
|
324
|
+
ctx.emit('session/event', session('s1'), ev('assistant/message', 4, retryAt + 35 * 1000, {
|
|
325
|
+
turn: 0,
|
|
326
|
+
step: 0,
|
|
327
|
+
usage: usage(),
|
|
328
|
+
message: {},
|
|
329
|
+
}))
|
|
330
|
+
await tick()
|
|
331
|
+
const usageSample = store.samples.find((sample) => !sample.request)
|
|
332
|
+
assert.equal(usageSample.ttftMs, 65 * 1000)
|
|
333
|
+
assert.equal(usageSample.durationMs, 30 * 1000)
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
test('零 TTFT(起点即出 token)附 ttftMs 为 0', async () => {
|
|
337
|
+
const { ctx, store } = liveCollector()
|
|
338
|
+
const start = local(2026, 8, 2, 10, 0)
|
|
339
|
+
ctx.emit('session/event', session('s1'), ev('step/start', 1, start, { turn: 0, step: 0 }))
|
|
340
|
+
ctx.emit('session/event', session('s1'), attempt(2, start + 30 * 1000, 0, 0, [textRun(start, ['快'])]))
|
|
341
|
+
ctx.emit('session/event', session('s1'), ev('assistant/message', 3, start + 30 * 1000, {
|
|
342
|
+
turn: 0,
|
|
343
|
+
step: 0,
|
|
344
|
+
usage: usage(),
|
|
345
|
+
message: {},
|
|
346
|
+
}))
|
|
347
|
+
await tick()
|
|
348
|
+
const usageSample = store.samples.find((sample) => !sample.request)
|
|
349
|
+
assert.equal(usageSample.ttftMs, 0)
|
|
350
|
+
assert.equal(usageSample.durationMs, 30 * 1000)
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
test('时刻倒挂防回拨:首 token 晚于汇报,durationMs 钳 0 不附,ttftMs 照常', async () => {
|
|
354
|
+
const { ctx, store } = liveCollector()
|
|
355
|
+
const start = local(2026, 8, 2, 10, 0)
|
|
356
|
+
ctx.emit('session/event', session('s1'), ev('step/start', 1, start, { turn: 0, step: 0 }))
|
|
357
|
+
ctx.emit('session/event', session('s1'), attempt(2, start + 50 * 1000, 0, 0, [textRun(start + 50 * 1000, ['晚'])]))
|
|
358
|
+
ctx.emit('session/event', session('s1'), ev('assistant/message', 3, start + 3 * 1000, {
|
|
359
|
+
turn: 0,
|
|
360
|
+
step: 0,
|
|
361
|
+
usage: usage(),
|
|
362
|
+
message: {},
|
|
363
|
+
}))
|
|
364
|
+
await tick()
|
|
365
|
+
const usageSample = store.samples.find((sample) => !sample.request)
|
|
366
|
+
assert.equal(usageSample.durationMs, undefined)
|
|
367
|
+
assert.equal(usageSample.decodeTokens, undefined)
|
|
368
|
+
assert.equal(usageSample.ttftMs, 50 * 1000)
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
test('时刻倒挂防回拨:首 token 早于起点,ttftMs 钳 0 照常计步,durationMs 照常', async () => {
|
|
372
|
+
const { ctx, store } = liveCollector()
|
|
373
|
+
const start = local(2026, 8, 2, 10, 0)
|
|
374
|
+
ctx.emit('session/event', session('s1'), ev('step/start', 1, start, { turn: 0, step: 0 }))
|
|
375
|
+
ctx.emit('session/event', session('s1'), attempt(2, start + 1 * 1000, 0, 0, [textRun(start - 5 * 1000, ['回拨'])]))
|
|
376
|
+
ctx.emit('session/event', session('s1'), ev('assistant/message', 3, start + 3 * 1000, {
|
|
377
|
+
turn: 0,
|
|
378
|
+
step: 0,
|
|
379
|
+
usage: usage(),
|
|
380
|
+
message: {},
|
|
381
|
+
}))
|
|
382
|
+
await tick()
|
|
383
|
+
const usageSample = store.samples.find((sample) => !sample.request)
|
|
384
|
+
assert.equal(usageSample.ttftMs, 0)
|
|
385
|
+
assert.equal(usageSample.durationMs, 8 * 1000)
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
test('usage 缺 outputTokens:不建 decode 配对(官方守卫),ttft 照常', async () => {
|
|
389
|
+
const { ctx, store } = liveCollector()
|
|
390
|
+
const start = local(2026, 8, 2, 10, 0)
|
|
391
|
+
ctx.emit('session/event', session('s1'), ev('step/start', 1, start, { turn: 0, step: 0 }))
|
|
392
|
+
ctx.emit('session/event', session('s1'), attempt(2, start + 30 * 1000, 0, 0, [textRun(start + 10 * 1000, ['早'])]))
|
|
393
|
+
ctx.emit('session/event', session('s1'), ev('assistant/message', 3, start + 40 * 1000, {
|
|
394
|
+
turn: 0,
|
|
395
|
+
step: 0,
|
|
396
|
+
usage: usage({ outputTokens: undefined }),
|
|
397
|
+
message: {},
|
|
398
|
+
}))
|
|
399
|
+
await tick()
|
|
400
|
+
const usageSample = store.samples.find((sample) => !sample.request)
|
|
401
|
+
assert.equal(usageSample.outputTokens, 0)
|
|
402
|
+
assert.equal(usageSample.durationMs, undefined)
|
|
403
|
+
assert.equal(usageSample.decodeTokens, undefined)
|
|
404
|
+
assert.equal(usageSample.ttftMs, 10 * 1000)
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
test('重复报告不重复补发 timing:chunk → message → 重复 message 恰一条增量', async () => {
|
|
408
|
+
const { ctx, store } = liveCollector()
|
|
409
|
+
const start = local(2026, 8, 2, 10, 0)
|
|
410
|
+
ctx.emit('session/event', session('s1'), ev('step/start', 1, start, { turn: 0, step: 0 }))
|
|
411
|
+
ctx.emit('session/event', session('s1'), ev('assistant/chunk', 2, start + 30 * 1000, {
|
|
412
|
+
turn: 0,
|
|
413
|
+
step: 0,
|
|
414
|
+
chunk: { type: 'usage', usage: usage() },
|
|
415
|
+
}))
|
|
416
|
+
ctx.emit('session/event', session('s1'), attempt(3, start + 35 * 1000, 0, 0, [textRun(start + 10 * 1000, ['好'])]))
|
|
417
|
+
ctx.emit('session/event', session('s1'), ev('assistant/message', 4, start + 40 * 1000, {
|
|
418
|
+
turn: 0,
|
|
419
|
+
step: 0,
|
|
420
|
+
usage: usage({ inputTokens: 999 }),
|
|
421
|
+
message: {},
|
|
422
|
+
}))
|
|
423
|
+
ctx.emit('session/event', session('s1'), ev('assistant/message', 5, start + 41 * 1000, {
|
|
424
|
+
turn: 0,
|
|
425
|
+
step: 0,
|
|
426
|
+
usage: usage(),
|
|
427
|
+
message: {},
|
|
428
|
+
}))
|
|
429
|
+
await tick()
|
|
430
|
+
const timingSamples = store.samples.filter((sample) => !sample.request && sample.decodeTokens !== undefined)
|
|
431
|
+
assert.equal(timingSamples.length, 1)
|
|
432
|
+
assert.equal(timingSamples[0].decodeTokens, 20)
|
|
433
|
+
assert.equal(timingSamples[0].durationMs, 30 * 1000)
|
|
434
|
+
assert.equal(timingSamples[0].ttftMs, 10 * 1000)
|
|
435
|
+
})
|
|
436
|
+
|
|
437
|
+
test('message 首发后到达的 chunk usage 不补 timing', async () => {
|
|
438
|
+
const { ctx, store } = liveCollector()
|
|
439
|
+
const start = local(2026, 8, 2, 10, 0)
|
|
440
|
+
ctx.emit('session/event', session('s1'), ev('step/start', 1, start, { turn: 0, step: 0 }))
|
|
441
|
+
ctx.emit('session/event', session('s1'), attempt(2, start + 35 * 1000, 0, 0, [textRun(start + 10 * 1000, ['好'])]))
|
|
442
|
+
ctx.emit('session/event', session('s1'), ev('assistant/message', 3, start + 40 * 1000, {
|
|
443
|
+
turn: 0,
|
|
444
|
+
step: 0,
|
|
445
|
+
usage: usage(),
|
|
446
|
+
message: {},
|
|
447
|
+
}))
|
|
448
|
+
ctx.emit('session/event', session('s1'), ev('assistant/chunk', 4, start + 41 * 1000, {
|
|
449
|
+
turn: 0,
|
|
450
|
+
step: 0,
|
|
451
|
+
chunk: { type: 'usage', usage: usage() },
|
|
452
|
+
}))
|
|
453
|
+
await tick()
|
|
454
|
+
const usageSamples = store.samples.filter((sample) => !sample.request)
|
|
455
|
+
assert.equal(usageSamples.length, 1)
|
|
456
|
+
assert.equal(usageSamples[0].decodeTokens, 20)
|
|
457
|
+
assert.equal(usageSamples[0].durationMs, 30 * 1000)
|
|
458
|
+
})
|
|
459
|
+
|
|
460
|
+
test('chunk 非零后 message usage 全零仍补发 timing', async () => {
|
|
461
|
+
const { ctx, store } = liveCollector()
|
|
462
|
+
const start = local(2026, 8, 2, 10, 0)
|
|
463
|
+
const zeroUsage = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }
|
|
464
|
+
ctx.emit('session/event', session('s1'), ev('step/start', 1, start, { turn: 0, step: 0 }))
|
|
465
|
+
ctx.emit('session/event', session('s1'), ev('assistant/chunk', 2, start + 30 * 1000, {
|
|
466
|
+
turn: 0,
|
|
467
|
+
step: 0,
|
|
468
|
+
chunk: { type: 'usage', usage: usage() },
|
|
469
|
+
}))
|
|
470
|
+
ctx.emit('session/event', session('s1'), attempt(3, start + 35 * 1000, 0, 0, [textRun(start + 10 * 1000, ['好'])]))
|
|
471
|
+
ctx.emit('session/event', session('s1'), ev('assistant/message', 4, start + 40 * 1000, {
|
|
472
|
+
turn: 0,
|
|
473
|
+
step: 0,
|
|
474
|
+
usage: zeroUsage,
|
|
475
|
+
message: {},
|
|
476
|
+
}))
|
|
477
|
+
await tick()
|
|
478
|
+
const timingSamples = store.samples.filter((sample) => !sample.request && sample.ttftMs !== undefined)
|
|
479
|
+
assert.equal(timingSamples.length, 1)
|
|
480
|
+
assert.equal(timingSamples[0].inputTokens, 0)
|
|
481
|
+
assert.equal(timingSamples[0].ttftMs, 10 * 1000)
|
|
482
|
+
assert.equal(timingSamples[0].decodeTokens, 0)
|
|
483
|
+
assert.equal(timingSamples[0].durationMs, 30 * 1000)
|
|
484
|
+
})
|
|
485
|
+
|
|
486
|
+
test('缺 turn/step 的 message 带 stream:首发全量样本含 timing 且不污染去重集合', async () => {
|
|
487
|
+
const { ctx, store } = liveCollector()
|
|
488
|
+
const t = local(2026, 8, 2, 10, 0)
|
|
489
|
+
ctx.emit('session/event', session('s1'), ev('assistant/message', 1, t + 30 * 1000, {
|
|
490
|
+
usage: usage(),
|
|
491
|
+
stream: [textRun(t, ['早'])],
|
|
492
|
+
message: {},
|
|
493
|
+
}))
|
|
494
|
+
await tick()
|
|
495
|
+
assert.equal(store.samples.length, 1)
|
|
496
|
+
assert.equal(store.samples[0].durationMs, 30 * 1000)
|
|
497
|
+
assert.equal(store.samples[0].ttftMs, undefined)
|
|
498
|
+
})
|
|
499
|
+
|
|
500
|
+
test('有首 token 无起点:durationMs 附而 ttftMs 不附', async () => {
|
|
501
|
+
const { ctx, store } = liveCollector()
|
|
502
|
+
const t = local(2026, 8, 2, 10, 0)
|
|
503
|
+
ctx.emit('session/event', session('s1'), attempt(1, t + 10 * 1000, 0, 0, [textRun(t, ['早'])]))
|
|
504
|
+
ctx.emit('session/event', session('s1'), ev('assistant/message', 2, t + 30 * 1000, {
|
|
228
505
|
turn: 0,
|
|
229
506
|
step: 0,
|
|
230
507
|
usage: usage(),
|
|
@@ -232,7 +509,23 @@ test('retry-started 重置起点:时长只含最终尝试', async () => {
|
|
|
232
509
|
}))
|
|
233
510
|
await tick()
|
|
234
511
|
const usageSample = store.samples.find((sample) => !sample.request)
|
|
235
|
-
assert.equal(usageSample.durationMs,
|
|
512
|
+
assert.equal(usageSample.durationMs, 30 * 1000)
|
|
513
|
+
assert.equal(usageSample.ttftMs, undefined)
|
|
514
|
+
})
|
|
515
|
+
|
|
516
|
+
test('无任何 stream 信息不附时长与首字(存量超旧日志形态)', async () => {
|
|
517
|
+
const { ctx, store } = liveCollector()
|
|
518
|
+
const t = local(2026, 8, 2, 10, 0)
|
|
519
|
+
ctx.emit('session/event', session('s1'), ev('assistant/message', 1, t, {
|
|
520
|
+
turn: 0,
|
|
521
|
+
step: 0,
|
|
522
|
+
usage: usage(),
|
|
523
|
+
message: {},
|
|
524
|
+
}))
|
|
525
|
+
await tick()
|
|
526
|
+
assert.equal(store.samples.length, 1)
|
|
527
|
+
assert.equal(store.samples[0].durationMs, undefined)
|
|
528
|
+
assert.equal(store.samples[0].ttftMs, undefined)
|
|
236
529
|
})
|
|
237
530
|
|
|
238
531
|
test('同时刻零时长不附 durationMs,request 标记样本不带时长', async () => {
|
|
@@ -519,14 +812,15 @@ test('回扫:无边界会话全量重放,归因与游标写回', async () => {
|
|
|
519
812
|
assert.equal(collector.status().scannedSessions, 1)
|
|
520
813
|
})
|
|
521
814
|
|
|
522
|
-
test('
|
|
815
|
+
test('回扫:重放路径同样产出 decode 口径时长与首字', async () => {
|
|
523
816
|
const start = local(2026, 8, 2, 14, 0)
|
|
524
817
|
const persistence = fakePersistence([{
|
|
525
818
|
id: 's1',
|
|
526
819
|
events: [
|
|
527
820
|
ev('request/context', 0, start, { provider: 'deepseek', model: 'chat' }),
|
|
528
821
|
ev('step/start', 1, start, { turn: 0, step: 0 }),
|
|
529
|
-
|
|
822
|
+
attempt(2, start + 5 * 1000, 0, 0, [textRun(start + 2 * 1000, ['回'])]),
|
|
823
|
+
ev('assistant/message', 3, start + 7 * 1000, {
|
|
530
824
|
turn: 0,
|
|
531
825
|
step: 0,
|
|
532
826
|
usage: usage(),
|
|
@@ -538,7 +832,8 @@ test('回扫:重放路径同样附加 durationMs', async () => {
|
|
|
538
832
|
const collector = new UsageCollector(fakeCtx({ persistence }), store)
|
|
539
833
|
await collector.backfill(persistence, fakeSessions())
|
|
540
834
|
const usageSample = store.samples.find((sample) => !sample.request)
|
|
541
|
-
assert.equal(usageSample.durationMs,
|
|
835
|
+
assert.equal(usageSample.durationMs, 5 * 1000)
|
|
836
|
+
assert.equal(usageSample.ttftMs, 2 * 1000)
|
|
542
837
|
})
|
|
543
838
|
|
|
544
839
|
test('回扫:已入游标会话不再重扫', async () => {
|
package/test/query.test.mjs
CHANGED
|
@@ -316,6 +316,54 @@ test('attachCosts 保留槽级 speed 字段供曲线消费', () => {
|
|
|
316
316
|
assert.equal(out.daily[1].speed, 5)
|
|
317
317
|
})
|
|
318
318
|
|
|
319
|
+
test('decode 口径:速度分子取 decodeTokens,timing 样本零桶不稀释 token 总量', () => {
|
|
320
|
+
const rows = [
|
|
321
|
+
// token 样本行:只有 token 桶,无时长
|
|
322
|
+
makeRow({ bucket: '2020-01-01', model: 'm1', provider: 'p1', outputTokens: 20 }),
|
|
323
|
+
// 纯 timing 增量行:零 token 桶 + decode 配对
|
|
324
|
+
makeRow({ bucket: '2020-01-02', model: 'm1', provider: 'p1', outputTokens: 0, decodeTokens: 30, durationMs: 2500 }),
|
|
325
|
+
]
|
|
326
|
+
const out = aggregateRange(rows, 'D', '2020-01-01', '2020-01-02')
|
|
327
|
+
const entry = out.models.find((item) => item.model === 'm1')
|
|
328
|
+
assert.equal(entry.tokens, 20)
|
|
329
|
+
assert.equal(entry.speed, 30 / (2500 / 1000))
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
test('旧格式行 durationMs 无 decodeTokens:分子回落 outputTokens', () => {
|
|
333
|
+
const rows = [
|
|
334
|
+
makeRow({ bucket: '2020-01-01', model: 'm1', provider: 'p1', outputTokens: 20, durationMs: 4000 }),
|
|
335
|
+
]
|
|
336
|
+
const out = aggregateRange(rows, 'D', '2020-01-01', '2020-01-01')
|
|
337
|
+
assert.equal(out.models[0].speed, 5)
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
test('ttft 模型级聚合:加权平均,无 ttft 行不参与,无数据不挂字段', () => {
|
|
341
|
+
const rows = [
|
|
342
|
+
makeRow({ bucket: '2020-01-01', model: 'm1', provider: 'p1', outputTokens: 20, ttftMs: 1000, ttftSteps: 1 }),
|
|
343
|
+
makeRow({ bucket: '2020-01-02', model: 'm1', provider: 'p1', outputTokens: 30, ttftMs: 3000, ttftSteps: 2 }),
|
|
344
|
+
makeRow({ bucket: '2020-01-03', model: 'm1', provider: 'p1', outputTokens: 100 }),
|
|
345
|
+
makeRow({ bucket: '2020-01-01', model: 'm2', provider: 'p2', outputTokens: 5 }),
|
|
346
|
+
]
|
|
347
|
+
const out = aggregateRange(rows, 'D', '2020-01-01', '2020-01-03')
|
|
348
|
+
const m1 = out.models.find((item) => item.model === 'm1')
|
|
349
|
+
assert.equal(m1.ttft, (1000 + 3000) / (1 + 2))
|
|
350
|
+
const m2 = out.models.find((item) => item.model === 'm2')
|
|
351
|
+
assert.equal('ttft' in m2, false)
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
test('槽级 ttft 聚合:同槽配对,无 ttft 槽不挂字段,attachCosts 保留', () => {
|
|
355
|
+
const rows = [
|
|
356
|
+
makeRow({ bucket: '2020-01-01', model: 'm1', provider: 'p1', outputTokens: 20, ttftMs: 2000, ttftSteps: 1 }),
|
|
357
|
+
makeRow({ bucket: '2020-01-01', model: 'm2', provider: 'p2', outputTokens: 30, ttftMs: 4000, ttftSteps: 1 }),
|
|
358
|
+
makeRow({ bucket: '2020-01-02', model: 'm1', provider: 'p1', outputTokens: 100 }),
|
|
359
|
+
]
|
|
360
|
+
const result = aggregateRange(rows, 'D', '2020-01-01', '2020-01-02')
|
|
361
|
+
assert.equal(result.daily[0].ttft, (2000 + 4000) / 2)
|
|
362
|
+
assert.equal('ttft' in result.daily[1], false)
|
|
363
|
+
const out = attachCosts(result, rows, 'D', [ruleOf()])
|
|
364
|
+
assert.equal(out.daily[0].ttft, 3000)
|
|
365
|
+
})
|
|
366
|
+
|
|
319
367
|
test('attachCosts H 槽按桶起点计价并归集 totals 与 models', () => {
|
|
320
368
|
const rows = [
|
|
321
369
|
makeRow({ bucket: '2020-01-01T01', model: 'm1', provider: 'p1', inputTokens: 1000000, outputTokens: 500000 }),
|
package/test/store.test.mjs
CHANGED
|
@@ -219,6 +219,64 @@ test('老行缺 durationMs 字段:增量求和不产 NaN', async () => {
|
|
|
219
219
|
assert.equal(seen.outputTokens, 40)
|
|
220
220
|
})
|
|
221
221
|
|
|
222
|
+
test('decode 配对与首字样本落三粒度累加,timing 样本不重复计 token', async () => {
|
|
223
|
+
const domain = fakeDomain()
|
|
224
|
+
const store = new UsageStore(facilityOf(domain))
|
|
225
|
+
const at = local(2026, 8, 2, 14, 37)
|
|
226
|
+
// chunk 先发 token 样本:只有 token 桶
|
|
227
|
+
await store.record(tokenSample(at, { durationMs: undefined }))
|
|
228
|
+
// message 补发纯 timing 增量:零桶 + decodeTokens + durationMs + ttftMs
|
|
229
|
+
await store.record(tokenSample(at + 1000, {
|
|
230
|
+
inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0,
|
|
231
|
+
decodeTokens: 20, durationMs: 30000, ttftMs: 10000,
|
|
232
|
+
}))
|
|
233
|
+
await store.flushNow()
|
|
234
|
+
const key = 'D|2026-08-02|deepseek|deepseek/deepseek-chat'
|
|
235
|
+
const daily = domain.rows.get(key)
|
|
236
|
+
assert.equal(daily.outputTokens, 20)
|
|
237
|
+
assert.equal(daily.decodeTokens, 20)
|
|
238
|
+
assert.equal(daily.durationMs, 30000)
|
|
239
|
+
assert.equal(daily.ttftMs, 10000)
|
|
240
|
+
assert.equal(daily.ttftSteps, 1)
|
|
241
|
+
const minute = domain.rows.get('M|2026-08-02T14:30|deepseek|deepseek/deepseek-chat')
|
|
242
|
+
assert.equal(minute.decodeTokens, 20)
|
|
243
|
+
assert.equal(minute.ttftMs, 10000)
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
test('timing 缺失样本:decodeTokens 与 ttft 字段按零累计', async () => {
|
|
247
|
+
const domain = fakeDomain()
|
|
248
|
+
const store = new UsageStore(facilityOf(domain))
|
|
249
|
+
const at = local(2026, 8, 2, 14, 37)
|
|
250
|
+
await store.record(tokenSample(at))
|
|
251
|
+
await store.flushNow()
|
|
252
|
+
const seen = domain.rows.get('D|2026-08-02|deepseek|deepseek/deepseek-chat')
|
|
253
|
+
assert.equal(seen.decodeTokens, 0)
|
|
254
|
+
assert.equal(seen.durationMs, 0)
|
|
255
|
+
assert.equal(seen.ttftMs, 0)
|
|
256
|
+
assert.equal(seen.ttftSteps, 0)
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
test('老行缺 decode/ttft 字段:增量求和不产 NaN', async () => {
|
|
260
|
+
const domain = fakeDomain()
|
|
261
|
+
const store = new UsageStore(facilityOf(domain))
|
|
262
|
+
const at = local(2026, 8, 2, 14, 37)
|
|
263
|
+
const key = 'D|2026-08-02|deepseek|deepseek/deepseek-chat'
|
|
264
|
+
await store.record(tokenSample(at))
|
|
265
|
+
await store.flushNow()
|
|
266
|
+
// 模拟存量旧格式行:回写去掉新增字段
|
|
267
|
+
const stored = domain.rows.get(key)
|
|
268
|
+
delete stored.decodeTokens
|
|
269
|
+
delete stored.ttftMs
|
|
270
|
+
delete stored.ttftSteps
|
|
271
|
+
await store.record(tokenSample(at + 1000, { decodeTokens: 20, durationMs: 3000, ttftMs: 500 }))
|
|
272
|
+
await store.flushNow()
|
|
273
|
+
const seen = domain.rows.get(key)
|
|
274
|
+
assert.equal(seen.decodeTokens, 20)
|
|
275
|
+
assert.equal(seen.durationMs, 3000)
|
|
276
|
+
assert.equal(seen.ttftMs, 500)
|
|
277
|
+
assert.equal(seen.ttftSteps, 1)
|
|
278
|
+
})
|
|
279
|
+
|
|
222
280
|
test('missing-record 首写竞态:put 种子后重试写入真值', async () => {
|
|
223
281
|
const domain = fakeDomain()
|
|
224
282
|
const store = new UsageStore(facilityOf(domain))
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// 官方同构 parity:collector 移植的助手流首 token 读取与官方 dsh-llm
|
|
2
|
+
// assistantStreamFirstTokenTime / isTokenDelta / runFirstTokenTime 语义逐向量对表。
|
|
3
|
+
// 期望值按官方源码语义推导(首个 token delta = 非空 text/reasoning 成员、
|
|
4
|
+
// 非空 tool-call arguments 成员或具名 tool-call run 起点)。
|
|
5
|
+
// 对表锚点:对表自 dsh 0.1.2-rc.1 官方源码(dsh-llm/lib/types/assistant-stream.js
|
|
6
|
+
// 与 dsh-session-stats/lib/types/projection.js);dsh 本体升级后须重对表,
|
|
7
|
+
// 此处静态向量不感知官方实现漂移。
|
|
8
|
+
|
|
9
|
+
import test from 'node:test'
|
|
10
|
+
import assert from 'node:assert/strict'
|
|
11
|
+
|
|
12
|
+
import { assistantStreamFirstTokenTime } from '../src/collector.js'
|
|
13
|
+
|
|
14
|
+
const textRun = (time0, texts, dt = []) => ({ type: 'text-chunks', time0, index: 0, dt, texts })
|
|
15
|
+
const reasoningRun = (time0, texts, dt = []) => ({ type: 'reasoning-chunks', time0, index: 0, dt, texts })
|
|
16
|
+
const toolRun = (time0, args, extra = {}) => ({ type: 'tool-call-chunks', time0, index: 0, dt: [], id: 'c1', args, ...extra })
|
|
17
|
+
const rawChunk = (time, chunk) => ({ type: 'chunk', time, chunk })
|
|
18
|
+
|
|
19
|
+
test('text run 首个非空成员时刻按 time0+dt 差分还原', () => {
|
|
20
|
+
assert.equal(assistantStreamFirstTokenTime([textRun(1000, ['', 'ab'], [100])]), 1100)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test('全空 text run 无首 token', () => {
|
|
24
|
+
assert.equal(assistantStreamFirstTokenTime([textRun(1000, ['', ''])]), undefined)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('reasoning run 与 text run 同语义', () => {
|
|
28
|
+
assert.equal(assistantStreamFirstTokenTime([reasoningRun(500, ['', '思考'], [20])]), 520)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('具名 tool call run 整体起于 time0', () => {
|
|
32
|
+
assert.equal(assistantStreamFirstTokenTime([toolRun(700, ['', '{'], { name: 'fn' })]), 700)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test('匿名 tool call run 取首个非空 arguments 成员', () => {
|
|
36
|
+
assert.equal(assistantStreamFirstTokenTime([toolRun(700, ['', '{'], { dt: [30] })]), 730)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test('裸 chunk:text-delta 非空即 token,空文本跳过', () => {
|
|
40
|
+
assert.equal(assistantStreamFirstTokenTime([
|
|
41
|
+
rawChunk(100, { type: 'text-delta', index: 0, text: '' }),
|
|
42
|
+
rawChunk(200, { type: 'text-delta', index: 0, text: 'hi' }),
|
|
43
|
+
]), 200)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('裸 chunk:usage 与 finish 非 token', () => {
|
|
47
|
+
assert.equal(assistantStreamFirstTokenTime([
|
|
48
|
+
rawChunk(100, { type: 'usage' }),
|
|
49
|
+
rawChunk(200, { type: 'finish' }),
|
|
50
|
+
]), undefined)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('首个产出 token 的记录生效,后续记录不覆盖', () => {
|
|
54
|
+
assert.equal(assistantStreamFirstTokenTime([
|
|
55
|
+
rawChunk(100, { type: 'usage' }),
|
|
56
|
+
textRun(900, ['a']),
|
|
57
|
+
textRun(500, ['b']),
|
|
58
|
+
]), 900)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('空流与非数组输入无首 token', () => {
|
|
62
|
+
assert.equal(assistantStreamFirstTokenTime([]), undefined)
|
|
63
|
+
assert.equal(assistantStreamFirstTokenTime(undefined), undefined)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('未知记录形态安全跳过不崩溃', () => {
|
|
67
|
+
assert.equal(assistantStreamFirstTokenTime([
|
|
68
|
+
{ type: 'text-delta', text: 'x' },
|
|
69
|
+
textRun(300, ['晚到']),
|
|
70
|
+
]), 300)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('dt 差分短缺视为损坏,按无首 token 处理', () => {
|
|
74
|
+
assert.equal(assistantStreamFirstTokenTime([textRun(1000, ['', 'ab'], [])]), undefined)
|
|
75
|
+
})
|
package/test/turn-tail.test.mjs
CHANGED
|
@@ -17,6 +17,8 @@ const {
|
|
|
17
17
|
turnCostTitleText,
|
|
18
18
|
turnModelOf,
|
|
19
19
|
turnTokenUsageOfMessage,
|
|
20
|
+
turnUsageSourceOfMessage,
|
|
21
|
+
usageSourceBuckets,
|
|
20
22
|
} = core
|
|
21
23
|
|
|
22
24
|
const zhT = createTranslator(MESSAGES_ZH)
|
|
@@ -85,6 +87,70 @@ test('反查:单节点形状残缺跳过不抛,后续命中不受阻', () => {
|
|
|
85
87
|
assert.equal(turnTokenUsageOfMessage([null, 'x', 42], 'm-1'), null)
|
|
86
88
|
})
|
|
87
89
|
|
|
90
|
+
test('反查:同一容器重复调用命中索引,结果一致', () => {
|
|
91
|
+
const tokenUsage = { uncachedInputTokens: 10, outputTokens: 5, totalTokens: 15 }
|
|
92
|
+
const nodes = [tailNode('m-1', tokenUsage)]
|
|
93
|
+
// Given 同一容器连续两次反查(首次建索引,二次命中缓存)
|
|
94
|
+
const first = turnTokenUsageOfMessage(nodes, 'm-1')
|
|
95
|
+
const second = turnTokenUsageOfMessage(nodes, 'm-1')
|
|
96
|
+
// Then 结果一致且均为 tokenUsage 本体
|
|
97
|
+
assert.equal(first, tokenUsage)
|
|
98
|
+
assert.equal(second, tokenUsage)
|
|
99
|
+
// 未命中的 messageId 二次查询仍为 null(索引缓存不放大误命中)
|
|
100
|
+
assert.equal(turnTokenUsageOfMessage(nodes, 'm-2'), null)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test('反查源:命中返回仓库内原始引用,采样形态不归一(归一属渲染层)', () => {
|
|
104
|
+
const tokenUsage = { uncachedInputTokens: 10, outputTokens: 5, totalTokens: 15 }
|
|
105
|
+
assert.equal(turnUsageSourceOfMessage([tailNode('m-1', tokenUsage)], 'm-1'), tokenUsage)
|
|
106
|
+
// 视图节点直挂 tokenUsage 次选
|
|
107
|
+
const directNode = { kind: 'turn-tail', data: { closing: { finalNode: { seq: 1, messageId: 'm-2' } }, tokenUsage } }
|
|
108
|
+
assert.equal(turnUsageSourceOfMessage([directNode], 'm-2'), tokenUsage)
|
|
109
|
+
// 采样 usage 原样返回(引用稳定,useSyncExternalStore 快照可比对)
|
|
110
|
+
const sampled = { inputTokens: 100, outputTokens: 20, totalTokens: 130, cacheReadTokens: 10 }
|
|
111
|
+
const sampledNode = { kind: 'turn-tail', data: { closing: { finalNode: { seq: 2, messageId: 'm-3' }, usage: sampled } } }
|
|
112
|
+
assert.equal(turnUsageSourceOfMessage([sampledNode], 'm-3'), sampled)
|
|
113
|
+
// 三级全缺返回 null(稳定值)
|
|
114
|
+
assert.equal(turnUsageSourceOfMessage([{ kind: 'turn-tail', data: { closing: { finalNode: { seq: 3, messageId: 'm-4' } } } }], 'm-4'), null)
|
|
115
|
+
assert.equal(turnUsageSourceOfMessage([], 'm-1'), null)
|
|
116
|
+
assert.equal(turnUsageSourceOfMessage(null, 'm-1'), null)
|
|
117
|
+
// 仓库两态
|
|
118
|
+
assert.equal(turnUsageSourceOfMessage(new Map([['k', tailNode('m-1', tokenUsage)]]), 'm-1'), tokenUsage)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('反查源:节点数据更新后同容器引用随之更替', () => {
|
|
122
|
+
// Given 命中节点无用量源 When 位置数据补上 tokenUsage(同一容器对象,仓库可变语义)
|
|
123
|
+
const node = { kind: 'turn-tail', data: { closing: { finalNode: { seq: 1, messageId: 'm-1' } } } }
|
|
124
|
+
const nodes = [node]
|
|
125
|
+
assert.equal(turnUsageSourceOfMessage(nodes, 'm-1'), null)
|
|
126
|
+
const tokenUsage = { uncachedInputTokens: 1, outputTokens: 1, totalTokens: 2 }
|
|
127
|
+
node.data.tokenUsage = tokenUsage
|
|
128
|
+
// Then 反查读到新引用 —— selector 快照由恒等(null)变为新引用,驱动重渲染
|
|
129
|
+
assert.equal(turnUsageSourceOfMessage(nodes, 'm-1'), tokenUsage)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
test('归一:采样形态转聚合桶,聚合形态原样直通', () => {
|
|
133
|
+
assert.deepEqual(
|
|
134
|
+
usageSourceBuckets({ inputTokens: 100, outputTokens: 20, totalTokens: 130, cacheReadTokens: 10 }),
|
|
135
|
+
{ uncachedInputTokens: 100, outputTokens: 20, totalTokens: 130, cacheReadTokens: 10 },
|
|
136
|
+
)
|
|
137
|
+
const aggregated = { uncachedInputTokens: 8, outputTokens: 2, totalTokens: 10, routes: [{ provider: 'p', model: 'm' }] }
|
|
138
|
+
assert.equal(usageSourceBuckets(aggregated), aggregated)
|
|
139
|
+
assert.equal(usageSourceBuckets(null), null)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
test('反查:容器换引用(快照更替)后新节点可见', () => {
|
|
143
|
+
const oldUsage = { uncachedInputTokens: 1, outputTokens: 1, totalTokens: 2 }
|
|
144
|
+
const oldNodes = [tailNode('m-1', oldUsage)]
|
|
145
|
+
assert.equal(turnTokenUsageOfMessage(oldNodes, 'm-1'), oldUsage)
|
|
146
|
+
// Given 节点表内容变化以新容器表达(官方 store 快照语义),新增回合节点
|
|
147
|
+
const newUsage = { uncachedInputTokens: 7, outputTokens: 7, totalTokens: 14 }
|
|
148
|
+
const newNodes = [...oldNodes, tailNode('m-2', newUsage)]
|
|
149
|
+
// Then 新容器首查即可见新节点,旧容器结果不受影响
|
|
150
|
+
assert.equal(turnTokenUsageOfMessage(newNodes, 'm-2'), newUsage)
|
|
151
|
+
assert.equal(turnTokenUsageOfMessage(oldNodes, 'm-1'), oldUsage)
|
|
152
|
+
})
|
|
153
|
+
|
|
88
154
|
test('计价模型键:双全拼两段,仅 model 用裸名,双缺回退全通配键', () => {
|
|
89
155
|
assert.equal(turnModelOf({ routes: [{ provider: 'p', model: 'm' }] }), 'p/m')
|
|
90
156
|
assert.equal(turnModelOf({ routes: [{ model: 'm' }] }), 'm')
|