@mzzsfy/dsh-usage-dash 0.3.0 → 0.5.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 +55 -14
- package/package.json +2 -2
- package/src/archive-reader.js +138 -0
- package/src/client.js +539 -128
- package/src/collector.js +175 -29
- package/src/direct-log-reader.js +126 -0
- package/src/pricing.js +5 -6
- package/src/query.js +38 -14
- package/src/routes.js +14 -1
- package/src/store.js +86 -4
- package/test/archive-reader.test.mjs +365 -0
- package/test/client.test.mjs +210 -12
- package/test/collector.test.mjs +458 -19
- package/test/direct-log-reader.test.mjs +104 -0
- package/test/pricing-parity.test.mjs +38 -4
- package/test/pricing.test.mjs +50 -18
- package/test/query.test.mjs +50 -1
- package/test/routes.test.mjs +9 -2
- package/test/stats-line.test.mjs +6 -6
- package/test/store.test.mjs +116 -1
- package/test/stream-parity.test.mjs +75 -0
- package/test/turn-tail.test.mjs +161 -41
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
|
|
|
@@ -72,17 +78,46 @@ function fakeSessions(handles = []) {
|
|
|
72
78
|
}
|
|
73
79
|
|
|
74
80
|
// definitions: [{ id, events?, inheritedEventCount?, fail? }]
|
|
81
|
+
// 内部统一契约形状(list → [{id}],readLog → {inheritedEventCount, events}),
|
|
82
|
+
// 宿主 persistence 的多版本映射由 archive-reader 测试单独覆盖
|
|
75
83
|
function fakePersistence(definitions = []) {
|
|
76
84
|
const byId = new Map(definitions.map((def) => [def.id, def]))
|
|
77
85
|
return {
|
|
78
86
|
async list() {
|
|
79
87
|
return definitions.map((def) => ({ id: def.id }))
|
|
80
88
|
},
|
|
81
|
-
async
|
|
89
|
+
async readLog(id) {
|
|
82
90
|
const def = byId.get(id)
|
|
83
91
|
if (!def) throw new Error(`session "${id}" not found`)
|
|
84
92
|
if (def.fail) throw new Error(def.fail)
|
|
85
|
-
return {
|
|
93
|
+
return { inheritedEventCount: def.inheritedEventCount ?? 0, events: def.events ?? [] }
|
|
94
|
+
},
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// 宿主 handle 代形状(list 返回 snapshot,open/read/close 读体),供走
|
|
99
|
+
// rescan → 工厂分派 → backfill 的装配路径测试使用
|
|
100
|
+
function fakeHostPersistence(definitions = []) {
|
|
101
|
+
const byId = new Map(definitions.map((def) => [def.id, def]))
|
|
102
|
+
return {
|
|
103
|
+
async list() {
|
|
104
|
+
return definitions.map((def) => ({ header: { id: def.id }, revision: {} }))
|
|
105
|
+
},
|
|
106
|
+
async open(id, access) {
|
|
107
|
+
const def = byId.get(id)
|
|
108
|
+
if (!def) throw new Error(`session "${id}" not found`)
|
|
109
|
+
return {
|
|
110
|
+
header: { id },
|
|
111
|
+
inheritedEventCount: def.inheritedEventCount ?? 0,
|
|
112
|
+
access,
|
|
113
|
+
async read(offset, length) {
|
|
114
|
+
if (def.fail) throw new Error(def.fail)
|
|
115
|
+
const all = def.events ?? []
|
|
116
|
+
const start = typeof offset === 'number' && offset > 0 ? offset : 0
|
|
117
|
+
return { eventState: 'detached', events: all.slice(start, typeof length === 'number' ? start + length : undefined) }
|
|
118
|
+
},
|
|
119
|
+
async close() {},
|
|
120
|
+
}
|
|
86
121
|
},
|
|
87
122
|
}
|
|
88
123
|
}
|
|
@@ -139,12 +174,12 @@ test('step/start 与 llm/retry-started 产生 request 标记样本', async () =>
|
|
|
139
174
|
}
|
|
140
175
|
})
|
|
141
176
|
|
|
142
|
-
test('
|
|
177
|
+
test('decode 口径:durationMs 为首 token 到汇报,ttftMs 为起点到首 token', async () => {
|
|
143
178
|
const { ctx, store } = liveCollector()
|
|
144
179
|
const start = local(2026, 8, 2, 10, 0)
|
|
145
|
-
const sampleAt = start + 6 * 1000 + 500
|
|
146
180
|
ctx.emit('session/event', session('s1'), ev('step/start', 1, start, { turn: 0, step: 0 }))
|
|
147
|
-
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, {
|
|
148
183
|
turn: 0,
|
|
149
184
|
step: 0,
|
|
150
185
|
usage: usage(),
|
|
@@ -152,7 +187,98 @@ test('step/start 观测起点:首个 usage 样本附 durationMs(start 到样本
|
|
|
152
187
|
}))
|
|
153
188
|
await tick()
|
|
154
189
|
const usageSample = store.samples.find((sample) => !sample.request)
|
|
155
|
-
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)
|
|
156
282
|
})
|
|
157
283
|
|
|
158
284
|
test('(session,turn,step) 去重:同键先到样本生效,重复报告吞掉', async () => {
|
|
@@ -188,14 +314,194 @@ test('无 step/start 起点的 usage 样本不带 durationMs', async () => {
|
|
|
188
314
|
assert.equal(store.samples[0].durationMs, undefined)
|
|
189
315
|
})
|
|
190
316
|
|
|
191
|
-
test('retry
|
|
317
|
+
test('retry 不重置起点:TTFT 含失败尝试,decode 段自成功首 token 起', async () => {
|
|
192
318
|
const { ctx, store } = liveCollector()
|
|
193
319
|
const start = local(2026, 8, 2, 10, 0)
|
|
194
320
|
const retryAt = start + 60 * 1000
|
|
195
|
-
const sampleAt = retryAt + 5 * 1000
|
|
196
321
|
ctx.emit('session/event', session('s1'), ev('step/start', 1, start, { turn: 0, step: 0 }))
|
|
197
322
|
ctx.emit('session/event', session('s1'), ev('llm/retry-started', 2, retryAt, { turn: 0, step: 0 }))
|
|
198
|
-
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, {
|
|
199
505
|
turn: 0,
|
|
200
506
|
step: 0,
|
|
201
507
|
usage: usage(),
|
|
@@ -203,7 +509,23 @@ test('retry-started 重置起点:时长只含最终尝试', async () => {
|
|
|
203
509
|
}))
|
|
204
510
|
await tick()
|
|
205
511
|
const usageSample = store.samples.find((sample) => !sample.request)
|
|
206
|
-
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)
|
|
207
529
|
})
|
|
208
530
|
|
|
209
531
|
test('同时刻零时长不附 durationMs,request 标记样本不带时长', async () => {
|
|
@@ -250,6 +572,67 @@ test('四桶全零 usage 为噪声不产样本', async () => {
|
|
|
250
572
|
assert.equal(store.samples.length, 0)
|
|
251
573
|
})
|
|
252
574
|
|
|
575
|
+
test('V3 日志兼容:V3 专有事件与新字段被折叠忽略不炸', async () => {
|
|
576
|
+
const { ctx, store } = liveCollector()
|
|
577
|
+
const t = local(2026, 8, 2, 10, 0)
|
|
578
|
+
// V3 新增事件(SESSION_FORMAT_VERSION 3):统计不消费,折叠必须安全忽略
|
|
579
|
+
ctx.emit('session/event', session('s1'), ev('system/message', 1, t, { turn: 0, step: 0, message: { role: 'system', content: 'sys' } }))
|
|
580
|
+
ctx.emit('session/event', session('s1'), ev('assistant/attempt', 2, t, { turn: 0, step: 0, stream: [] }))
|
|
581
|
+
ctx.emit('session/event', session('s1'), ev('request/header', 3, t, { header: { config: {} }, reason: 'initial' }))
|
|
582
|
+
ctx.emit('session/event', session('s1'), ev('user/message', 4, t, { role: 'user', content: 'hi' }, ))
|
|
583
|
+
ctx.emit('session/event', session('s1'), ev('tool/call', 5, t, { turn: 0, step: 0, callId: 'c1', name: 'n', arguments: '{}' }))
|
|
584
|
+
ctx.emit('session/event', session('s1'), ev('tool/result', 6, t, { turn: 0, step: 0, message: { role: 'toolResult', content: 'r' } }))
|
|
585
|
+
ctx.emit('session/event', session('s1'), ev('session/end-seed', 7, t, {}))
|
|
586
|
+
ctx.emit('session/event', session('s1'), ev('step/end', 8, t, { turn: 0, step: 0 }))
|
|
587
|
+
ctx.emit('session/event', session('s1'), ev('turn/start', 9, t, { turn: 1 }))
|
|
588
|
+
// V3 assistant/message 携带 stream/surfaceOp/interrupted 等新字段:usage 折叠不受影响
|
|
589
|
+
ctx.emit('session/event', session('s1'), ev('assistant/message', 10, t, {
|
|
590
|
+
turn: 1,
|
|
591
|
+
step: 0,
|
|
592
|
+
usage: usage({ inputTokens: 5 }),
|
|
593
|
+
message: { role: 'assistant', source: { provider: 'p', model: 'm' } },
|
|
594
|
+
stream: [{ type: 'text-delta', text: 'x' }],
|
|
595
|
+
surfaceOp: 'append',
|
|
596
|
+
interrupted: true,
|
|
597
|
+
}))
|
|
598
|
+
await tick()
|
|
599
|
+
assert.equal(store.samples.length, 1)
|
|
600
|
+
assert.equal(store.samples[0].inputTokens, 5)
|
|
601
|
+
assert.equal(store.samples[0].model, 'p/m')
|
|
602
|
+
})
|
|
603
|
+
|
|
604
|
+
test('V3 日志兼容:回扫重放 V3 事件流,样本与游标正确', async () => {
|
|
605
|
+
const t = local(2026, 8, 2, 14, 0)
|
|
606
|
+
const persistence = fakePersistence([{
|
|
607
|
+
id: 'v3session',
|
|
608
|
+
events: [
|
|
609
|
+
ev('request/header', 0, t, { header: { config: {} }, reason: 'resume' }),
|
|
610
|
+
ev('system/message', 1, t, { turn: 0, step: 0, message: { role: 'system', content: 'sys' } }),
|
|
611
|
+
ev('user/message', 2, t, { role: 'user', content: 'q' }),
|
|
612
|
+
ev('step/start', 3, t, { turn: 0, step: 0 }),
|
|
613
|
+
ev('assistant/attempt', 4, t, { turn: 0, step: 0, stream: [] }),
|
|
614
|
+
ev('assistant/message', 5, t, {
|
|
615
|
+
turn: 0,
|
|
616
|
+
step: 0,
|
|
617
|
+
usage: usage({ inputTokens: 7 }),
|
|
618
|
+
message: { role: 'assistant', source: { provider: 'p', model: 'm' } },
|
|
619
|
+
stream: [],
|
|
620
|
+
}),
|
|
621
|
+
ev('step/end', 6, t, { turn: 0, step: 0 }),
|
|
622
|
+
ev('turn/end', 7, t, { turn: 0, reason: { kind: 'completed' } }),
|
|
623
|
+
],
|
|
624
|
+
}])
|
|
625
|
+
const store = fakeStore()
|
|
626
|
+
const collector = new UsageCollector(fakeCtx({ persistence }), store)
|
|
627
|
+
await collector.backfill(persistence, fakeSessions())
|
|
628
|
+
assert.deepEqual(store.samples.map((sample) => [sample.request ?? sample.turn ?? false, sample.inputTokens]), [
|
|
629
|
+
[true, 0],
|
|
630
|
+
[false, 7],
|
|
631
|
+
[true, 0],
|
|
632
|
+
])
|
|
633
|
+
assert.deepEqual([...store.state.backfilledSessions], ['v3session'])
|
|
634
|
+
})
|
|
635
|
+
|
|
253
636
|
test('纯缓存调用(仅缓存桶非零)仍产样本', async () => {
|
|
254
637
|
const { ctx, store } = liveCollector()
|
|
255
638
|
const t = local(2026, 8, 2, 10, 0)
|
|
@@ -429,14 +812,15 @@ test('回扫:无边界会话全量重放,归因与游标写回', async () => {
|
|
|
429
812
|
assert.equal(collector.status().scannedSessions, 1)
|
|
430
813
|
})
|
|
431
814
|
|
|
432
|
-
test('
|
|
815
|
+
test('回扫:重放路径同样产出 decode 口径时长与首字', async () => {
|
|
433
816
|
const start = local(2026, 8, 2, 14, 0)
|
|
434
817
|
const persistence = fakePersistence([{
|
|
435
818
|
id: 's1',
|
|
436
819
|
events: [
|
|
437
820
|
ev('request/context', 0, start, { provider: 'deepseek', model: 'chat' }),
|
|
438
821
|
ev('step/start', 1, start, { turn: 0, step: 0 }),
|
|
439
|
-
|
|
822
|
+
attempt(2, start + 5 * 1000, 0, 0, [textRun(start + 2 * 1000, ['回'])]),
|
|
823
|
+
ev('assistant/message', 3, start + 7 * 1000, {
|
|
440
824
|
turn: 0,
|
|
441
825
|
step: 0,
|
|
442
826
|
usage: usage(),
|
|
@@ -448,7 +832,8 @@ test('回扫:重放路径同样附加 durationMs', async () => {
|
|
|
448
832
|
const collector = new UsageCollector(fakeCtx({ persistence }), store)
|
|
449
833
|
await collector.backfill(persistence, fakeSessions())
|
|
450
834
|
const usageSample = store.samples.find((sample) => !sample.request)
|
|
451
|
-
assert.equal(usageSample.durationMs,
|
|
835
|
+
assert.equal(usageSample.durationMs, 5 * 1000)
|
|
836
|
+
assert.equal(usageSample.ttftMs, 2 * 1000)
|
|
452
837
|
})
|
|
453
838
|
|
|
454
839
|
test('回扫:已入游标会话不再重扫', async () => {
|
|
@@ -578,6 +963,56 @@ test('回扫:record 失败使会话失败并保留游标重试机会', async ()
|
|
|
578
963
|
assert.equal(collector.status().done, 1)
|
|
579
964
|
})
|
|
580
965
|
|
|
966
|
+
test('回扫:skipped 计数独立累加,超过日志上限不漂移', async () => {
|
|
967
|
+
const t = local(2026, 8, 2, 14, 0)
|
|
968
|
+
const defs = Array.from({ length: 210 }, (_, i) => ({ id: `bad${i}`, fail: 'uses unsupported descriptor version 2' }))
|
|
969
|
+
const persistence = fakePersistence(defs)
|
|
970
|
+
const store = fakeStore()
|
|
971
|
+
const collector = new UsageCollector(fakeCtx({ persistence }), store)
|
|
972
|
+
await collector.backfill(persistence, fakeSessions())
|
|
973
|
+
assert.equal(collector.status().skippedSessions, 210)
|
|
974
|
+
assert.equal(collector.status().log.length, 200)
|
|
975
|
+
assert.deepEqual(collector.status().skippedBreakdown, { descriptor: 210, corrupt: 0, legacy: 0, other: 0 })
|
|
976
|
+
})
|
|
977
|
+
|
|
978
|
+
test('回扫:skipped 按 detail 归因分类,次轮扫描计数复位', async () => {
|
|
979
|
+
const t = local(2026, 8, 2, 14, 0)
|
|
980
|
+
const persistence = fakePersistence([
|
|
981
|
+
{ id: 'a', fail: 'uses unsupported descriptor version 2; source v0 artifact remains unchanged' },
|
|
982
|
+
{ id: 'b', fail: 'stored log is corrupt: SessionFormatError: seq gap' },
|
|
983
|
+
{ id: 'c', fail: 'format v0 contains unknown member "editor"' },
|
|
984
|
+
{ id: 'd', fail: 'mystery failure' },
|
|
985
|
+
{ id: 'ok', events: [ev('turn/end', 0, t)] },
|
|
986
|
+
])
|
|
987
|
+
const store = fakeStore()
|
|
988
|
+
const collector = new UsageCollector(fakeCtx({ persistence }), store)
|
|
989
|
+
await collector.backfill(persistence, fakeSessions())
|
|
990
|
+
assert.deepEqual(collector.status().skippedBreakdown, { descriptor: 1, corrupt: 1, legacy: 1, other: 1 })
|
|
991
|
+
assert.equal(collector.status().skippedSessions, 4)
|
|
992
|
+
// 次轮:失败档不进游标会重现,但本轮从空游标重扫,计数从头累计且包含上一轮成功档
|
|
993
|
+
await collector.backfill(persistence, fakeSessions())
|
|
994
|
+
assert.equal(collector.status().skippedSessions, 4)
|
|
995
|
+
assert.deepEqual(collector.status().skippedBreakdown, { descriptor: 1, corrupt: 1, legacy: 1, other: 1 })
|
|
996
|
+
})
|
|
997
|
+
|
|
998
|
+
test('回扫:list 失败记入 error 并向上传播,running 复位', async () => {
|
|
999
|
+
const failure = new Error('sessionPersistence API 未识别')
|
|
1000
|
+
const persistence = {
|
|
1001
|
+
async list() {
|
|
1002
|
+
throw failure
|
|
1003
|
+
},
|
|
1004
|
+
async readLog() {
|
|
1005
|
+
throw new Error('not reached')
|
|
1006
|
+
},
|
|
1007
|
+
}
|
|
1008
|
+
const store = fakeStore()
|
|
1009
|
+
const collector = new UsageCollector(fakeCtx({ persistence }), store)
|
|
1010
|
+
await assert.rejects(collector.backfill(persistence, fakeSessions()), (error) => error === failure)
|
|
1011
|
+
assert.equal(collector.status().error, failure.message)
|
|
1012
|
+
assert.equal(collector.status().running, false)
|
|
1013
|
+
assert.equal(store.samples.length, 0)
|
|
1014
|
+
})
|
|
1015
|
+
|
|
581
1016
|
test('abort:已中止 signal 直接返回不进入扫描', async () => {
|
|
582
1017
|
const persistence = fakePersistence([{ id: 's1', events: [] }])
|
|
583
1018
|
const store = fakeStore()
|
|
@@ -597,9 +1032,9 @@ test('abort:扫描中中断停止处理且不写游标', async () => {
|
|
|
597
1032
|
async list() {
|
|
598
1033
|
return [{ id: 's1' }]
|
|
599
1034
|
},
|
|
600
|
-
async
|
|
1035
|
+
async readLog() {
|
|
601
1036
|
controller.abort()
|
|
602
|
-
return {
|
|
1037
|
+
return { inheritedEventCount: 0, events: [ev('turn/end', 0, t)] }
|
|
603
1038
|
},
|
|
604
1039
|
}
|
|
605
1040
|
const store = fakeStore()
|
|
@@ -625,14 +1060,14 @@ test('回扫并发受默认并发 4 约束', async () => {
|
|
|
625
1060
|
async list() {
|
|
626
1061
|
return defs.map((def) => ({ id: def.id }))
|
|
627
1062
|
},
|
|
628
|
-
async
|
|
1063
|
+
async readLog(id) {
|
|
629
1064
|
inFlight += 1
|
|
630
1065
|
peak = Math.max(peak, inFlight)
|
|
631
1066
|
if (inFlight >= EXPECTED_CONCURRENCY) release()
|
|
632
1067
|
await Promise.race([gate, new Promise((resolve) => setTimeout(resolve, GATE_TIMEOUT_MS))])
|
|
633
1068
|
inFlight -= 1
|
|
634
1069
|
const def = defs.find((candidate) => candidate.id === id)
|
|
635
|
-
return {
|
|
1070
|
+
return { inheritedEventCount: 0, events: def.events }
|
|
636
1071
|
},
|
|
637
1072
|
}
|
|
638
1073
|
const store = fakeStore()
|
|
@@ -645,7 +1080,7 @@ test('回扫并发受默认并发 4 约束', async () => {
|
|
|
645
1080
|
test('resetAndRescan 以 wipe 时刻日志长度为活跃会话边界并重扫', async () => {
|
|
646
1081
|
const t = local(2026, 8, 2, 9, 0)
|
|
647
1082
|
const events = Array.from({ length: 13 }, (_, seq) => ev('turn/end', seq, t))
|
|
648
|
-
const persistence =
|
|
1083
|
+
const persistence = fakeHostPersistence([{ id: 'live-a', events }])
|
|
649
1084
|
const sessions = fakeSessions([{ id: 'live-a', seq: 10 }, { id: 'live-b' }])
|
|
650
1085
|
const store = fakeStore()
|
|
651
1086
|
store.state.liveFirstSeq.set('live-a', 3)
|
|
@@ -661,7 +1096,7 @@ test('resetAndRescan 以 wipe 时刻日志长度为活跃会话边界并重扫',
|
|
|
661
1096
|
|
|
662
1097
|
test('并发 resetAndRescan 合并为一次重建', async () => {
|
|
663
1098
|
const store = fakeStore()
|
|
664
|
-
const collector = new UsageCollector(fakeCtx(), store)
|
|
1099
|
+
const collector = new UsageCollector(fakeCtx({ persistence: fakeHostPersistence() }), store)
|
|
665
1100
|
const first = collector.resetAndRescan()
|
|
666
1101
|
const second = collector.resetAndRescan()
|
|
667
1102
|
await Promise.all([first, second])
|
|
@@ -682,8 +1117,12 @@ test('status() 返回快照,外部修改不影响内部状态', async () => {
|
|
|
682
1117
|
error: undefined,
|
|
683
1118
|
recordFailures: 0,
|
|
684
1119
|
skippedSessions: 0,
|
|
1120
|
+
skippedBreakdown: { descriptor: 0, corrupt: 0, legacy: 0, other: 0 },
|
|
685
1121
|
log: [],
|
|
686
1122
|
})
|
|
1123
|
+
const before = collector.status().skippedBreakdown
|
|
1124
|
+
before.descriptor = 99
|
|
1125
|
+
assert.equal(collector.status().skippedBreakdown.descriptor, 0)
|
|
687
1126
|
await collector.backfill(persistence, fakeSessions())
|
|
688
1127
|
const snapshot = collector.status()
|
|
689
1128
|
assert.equal(snapshot.running, false)
|