@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.
@@ -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('step/start 观测起点:首个 usage 样本附 durationMs(start 到样本时刻)', async () => {
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'), ev('assistant/message', 2, sampleAt, {
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, 6500)
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-started 重置起点:时长只含最终尝试', async () => {
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'), ev('assistant/message', 3, sampleAt, {
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, 5000)
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('回扫:重放路径同样附加 durationMs', async () => {
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
- ev('assistant/message', 2, start + 7 * 1000, {
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, 7000)
835
+ assert.equal(usageSample.durationMs, 5 * 1000)
836
+ assert.equal(usageSample.ttftMs, 2 * 1000)
542
837
  })
543
838
 
544
839
  test('回扫:已入游标会话不再重扫', async () => {
@@ -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 }),
@@ -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
+ })
@@ -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')