@mzzsfy/dsh-usage-dash 0.1.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 +49 -0
- package/cordis.patch.yml +9 -0
- package/package.json +46 -0
- package/src/client.js +2416 -0
- package/src/collector.js +327 -0
- package/src/index.js +104 -0
- package/src/pricing.js +119 -0
- package/src/query.js +193 -0
- package/src/routes.js +323 -0
- package/src/store.js +333 -0
- package/test/client.test.mjs +614 -0
- package/test/collector.test.mjs +603 -0
- package/test/pricing-parity.test.mjs +231 -0
- package/test/pricing.test.mjs +285 -0
- package/test/query.test.mjs +427 -0
- package/test/routes.test.mjs +566 -0
- package/test/stats-line.test.mjs +407 -0
- package/test/store.test.mjs +397 -0
- package/test/switch-guard.test.mjs +32 -0
- package/test/turn-tail.test.mjs +120 -0
|
@@ -0,0 +1,603 @@
|
|
|
1
|
+
// collector 行为测试:(session,turn,step) 去重、归因链回退、游标边界与
|
|
2
|
+
// liveness 复查、fork 前缀跳过、采集失败计数、abort 与 reset 协同。
|
|
3
|
+
// session/event 与 sessionPersistence 以内存假件驱动,时间取事件自带 time。
|
|
4
|
+
|
|
5
|
+
import test from 'node:test'
|
|
6
|
+
import assert from 'node:assert/strict'
|
|
7
|
+
|
|
8
|
+
import { UsageCollector } from '../src/collector.js'
|
|
9
|
+
|
|
10
|
+
const local = (y, mo, d, h = 0, mi = 0) => new Date(y, mo - 1, d, h, mi).getTime()
|
|
11
|
+
|
|
12
|
+
const ev = (type, seq, time, data = {}) => ({ type, seq, time, data })
|
|
13
|
+
const usage = (extra = {}) => ({
|
|
14
|
+
inputTokens: 10,
|
|
15
|
+
outputTokens: 20,
|
|
16
|
+
cacheReadTokens: 3,
|
|
17
|
+
cacheWriteTokens: 4,
|
|
18
|
+
...extra,
|
|
19
|
+
})
|
|
20
|
+
const session = (id) => ({ id })
|
|
21
|
+
const tick = () => new Promise((resolve) => setImmediate(resolve))
|
|
22
|
+
|
|
23
|
+
// 采集器依赖的 store 面:record/seenSessions/liveSequences/markSeenSessions/
|
|
24
|
+
// markLiveSequences/reset/readyPromise,内存态 + 可注入失败
|
|
25
|
+
function fakeStore({ failRecord = false, failMarkLive = false } = {}) {
|
|
26
|
+
const samples = []
|
|
27
|
+
const state = { backfilledSessions: new Set(), liveFirstSeq: new Map() }
|
|
28
|
+
const stats = { markLiveCalls: 0, markSeenCalls: 0, resets: 0, lastBoundaries: null }
|
|
29
|
+
return {
|
|
30
|
+
samples,
|
|
31
|
+
state,
|
|
32
|
+
stats,
|
|
33
|
+
async record(sample) {
|
|
34
|
+
if (failRecord) throw new Error('simulated record failure')
|
|
35
|
+
samples.push(sample)
|
|
36
|
+
},
|
|
37
|
+
async seenSessions() {
|
|
38
|
+
return new Set(state.backfilledSessions)
|
|
39
|
+
},
|
|
40
|
+
async liveSequences() {
|
|
41
|
+
return new Map(state.liveFirstSeq)
|
|
42
|
+
},
|
|
43
|
+
async markSeenSessions(ids) {
|
|
44
|
+
stats.markSeenCalls += 1
|
|
45
|
+
for (const id of ids) state.backfilledSessions.add(id)
|
|
46
|
+
},
|
|
47
|
+
async markLiveSequences(entries) {
|
|
48
|
+
stats.markLiveCalls += 1
|
|
49
|
+
if (failMarkLive) throw new Error('simulated mark failure')
|
|
50
|
+
for (const [id, seq] of entries) {
|
|
51
|
+
const prev = state.liveFirstSeq.get(id)
|
|
52
|
+
if (prev === undefined || seq < prev) state.liveFirstSeq.set(id, seq)
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
async reset(boundaries) {
|
|
56
|
+
stats.resets += 1
|
|
57
|
+
stats.lastBoundaries = boundaries
|
|
58
|
+
state.backfilledSessions.clear()
|
|
59
|
+
state.liveFirstSeq = new Map(boundaries)
|
|
60
|
+
samples.length = 0
|
|
61
|
+
},
|
|
62
|
+
async readyPromise() {},
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function fakeSessions(handles = []) {
|
|
67
|
+
const byId = new Map(handles.map((handle) => [handle.id, handle]))
|
|
68
|
+
return {
|
|
69
|
+
list: () => handles,
|
|
70
|
+
get: (id) => byId.get(id),
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// definitions: [{ id, events?, inheritedEventCount?, fail? }]
|
|
75
|
+
function fakePersistence(definitions = []) {
|
|
76
|
+
const byId = new Map(definitions.map((def) => [def.id, def]))
|
|
77
|
+
return {
|
|
78
|
+
async list() {
|
|
79
|
+
return definitions.map((def) => ({ id: def.id }))
|
|
80
|
+
},
|
|
81
|
+
async inspect(id) {
|
|
82
|
+
const def = byId.get(id)
|
|
83
|
+
if (!def) throw new Error(`session "${id}" not found`)
|
|
84
|
+
if (def.fail) throw new Error(def.fail)
|
|
85
|
+
return { meta: { id }, inheritedEventCount: def.inheritedEventCount ?? 0, events: def.events ?? [] }
|
|
86
|
+
},
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function fakeCtx({ sessions = fakeSessions(), persistence = fakePersistence() } = {}) {
|
|
91
|
+
const listeners = new Map()
|
|
92
|
+
return {
|
|
93
|
+
sessions,
|
|
94
|
+
sessionPersistence: persistence,
|
|
95
|
+
on(event, listener) {
|
|
96
|
+
listeners.set(event, listener)
|
|
97
|
+
},
|
|
98
|
+
emit(event, ...args) {
|
|
99
|
+
listeners.get(event)?.(...args)
|
|
100
|
+
},
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const liveCollector = ({ store = fakeStore(), sessions, persistence } = {}) => {
|
|
105
|
+
const ctx = fakeCtx({ sessions, persistence })
|
|
106
|
+
const collector = new UsageCollector(ctx, store)
|
|
107
|
+
collector.start()
|
|
108
|
+
return { ctx, store, collector }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
test('turn/end 产生 turn 标记样本:事件 time 口径,零桶,无去重键', async () => {
|
|
112
|
+
const { ctx, store } = liveCollector()
|
|
113
|
+
const t = local(2026, 8, 2, 14, 37)
|
|
114
|
+
ctx.emit('session/event', session('s1'), ev('turn/end', 7, t))
|
|
115
|
+
await tick()
|
|
116
|
+
assert.equal(store.samples.length, 1)
|
|
117
|
+
assert.deepEqual(store.samples[0], {
|
|
118
|
+
time: t,
|
|
119
|
+
inputTokens: 0,
|
|
120
|
+
outputTokens: 0,
|
|
121
|
+
cacheReadTokens: 0,
|
|
122
|
+
cacheWriteTokens: 0,
|
|
123
|
+
turn: true,
|
|
124
|
+
})
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
test('step/start 与 llm/retry-started 产生 request 标记样本', async () => {
|
|
128
|
+
const { ctx, store } = liveCollector()
|
|
129
|
+
const t = local(2026, 8, 2, 14, 37)
|
|
130
|
+
ctx.emit('session/event', session('s1'), ev('step/start', 1, t))
|
|
131
|
+
ctx.emit('session/event', session('s1'), ev('llm/retry-started', 2, t))
|
|
132
|
+
await tick()
|
|
133
|
+
assert.equal(store.samples.length, 2)
|
|
134
|
+
for (const sample of store.samples) {
|
|
135
|
+
assert.equal(sample.request, true)
|
|
136
|
+
assert.equal(sample.turn, undefined)
|
|
137
|
+
assert.equal(sample.inputTokens, 0)
|
|
138
|
+
assert.equal(sample.time, t)
|
|
139
|
+
}
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
test('(session,turn,step) 去重:同键先到样本生效,重复报告吞掉', async () => {
|
|
143
|
+
const { ctx, store } = liveCollector()
|
|
144
|
+
const t = local(2026, 8, 2, 10, 0)
|
|
145
|
+
ctx.emit('session/event', session('s1'), ev('assistant/chunk', 1, t, {
|
|
146
|
+
turn: 0,
|
|
147
|
+
step: 0,
|
|
148
|
+
chunk: { type: 'usage', usage: usage({ inputTokens: 100 }) },
|
|
149
|
+
}))
|
|
150
|
+
ctx.emit('session/event', session('s1'), ev('assistant/message', 2, t, {
|
|
151
|
+
turn: 0,
|
|
152
|
+
step: 0,
|
|
153
|
+
usage: usage({ inputTokens: 999 }),
|
|
154
|
+
message: {},
|
|
155
|
+
}))
|
|
156
|
+
await tick()
|
|
157
|
+
assert.equal(store.samples.length, 1)
|
|
158
|
+
assert.equal(store.samples[0].inputTokens, 100)
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
test('不同 (turn,step) 各自成立,跨会话同键互不影响', async () => {
|
|
162
|
+
const { ctx, store } = liveCollector()
|
|
163
|
+
const t = local(2026, 8, 2, 10, 0)
|
|
164
|
+
const chunkAt = (seq, turn, step) => ev('assistant/chunk', seq, t, {
|
|
165
|
+
turn,
|
|
166
|
+
step,
|
|
167
|
+
chunk: { type: 'usage', usage: usage({ inputTokens: turn * 10 + step }) },
|
|
168
|
+
})
|
|
169
|
+
ctx.emit('session/event', session('s1'), chunkAt(1, 0, 0))
|
|
170
|
+
ctx.emit('session/event', session('s1'), chunkAt(2, 0, 1))
|
|
171
|
+
ctx.emit('session/event', session('s2'), chunkAt(1, 0, 0))
|
|
172
|
+
await tick()
|
|
173
|
+
assert.deepEqual(store.samples.map((sample) => sample.inputTokens), [0, 1, 0])
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
test('四桶全零 usage 为噪声不产样本', async () => {
|
|
177
|
+
const { ctx, store } = liveCollector()
|
|
178
|
+
const t = local(2026, 8, 2, 10, 0)
|
|
179
|
+
ctx.emit('session/event', session('s1'), ev('assistant/message', 1, t, {
|
|
180
|
+
turn: 0,
|
|
181
|
+
step: 0,
|
|
182
|
+
usage: usage({ inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }),
|
|
183
|
+
}))
|
|
184
|
+
await tick()
|
|
185
|
+
assert.equal(store.samples.length, 0)
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
test('纯缓存调用(仅缓存桶非零)仍产样本', async () => {
|
|
189
|
+
const { ctx, store } = liveCollector()
|
|
190
|
+
const t = local(2026, 8, 2, 10, 0)
|
|
191
|
+
ctx.emit('session/event', session('s1'), ev('assistant/message', 1, t, {
|
|
192
|
+
turn: 0,
|
|
193
|
+
step: 0,
|
|
194
|
+
usage: usage({ inputTokens: 0, outputTokens: 0, cacheReadTokens: 5, cacheWriteTokens: 0 }),
|
|
195
|
+
}))
|
|
196
|
+
await tick()
|
|
197
|
+
assert.equal(store.samples.length, 1)
|
|
198
|
+
assert.equal(store.samples[0].cacheReadTokens, 5)
|
|
199
|
+
assert.equal(store.samples[0].inputTokens, 0)
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
test('非 usage 型 chunk 与缺 usage 的 message 忽略', async () => {
|
|
203
|
+
const { ctx, store } = liveCollector()
|
|
204
|
+
const t = local(2026, 8, 2, 10, 0)
|
|
205
|
+
ctx.emit('session/event', session('s1'), ev('assistant/chunk', 1, t, {
|
|
206
|
+
turn: 0,
|
|
207
|
+
step: 0,
|
|
208
|
+
chunk: { type: 'text-delta', text: 'hi' },
|
|
209
|
+
}))
|
|
210
|
+
ctx.emit('session/event', session('s1'), ev('assistant/message', 2, t, { turn: 0, step: 1, message: {} }))
|
|
211
|
+
await tick()
|
|
212
|
+
assert.equal(store.samples.length, 0)
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
test('归因链:观测路由归因 chunk 样本', async () => {
|
|
216
|
+
const { ctx, store } = liveCollector()
|
|
217
|
+
const t = local(2026, 8, 2, 10, 0)
|
|
218
|
+
ctx.emit('session/event', session('s1'), ev('request/context', 0, t, { provider: 'deepseek', model: 'chat' }))
|
|
219
|
+
ctx.emit('session/event', session('s1'), ev('assistant/chunk', 1, t, {
|
|
220
|
+
turn: 0,
|
|
221
|
+
step: 0,
|
|
222
|
+
chunk: { type: 'usage', usage: usage() },
|
|
223
|
+
}))
|
|
224
|
+
await tick()
|
|
225
|
+
assert.equal(store.samples.length, 1)
|
|
226
|
+
assert.equal(store.samples[0].model, 'deepseek/chat')
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
test('归因链:message.source 权威并刷新会话路由供后续样本沿用', async () => {
|
|
230
|
+
const { ctx, store } = liveCollector()
|
|
231
|
+
const t = local(2026, 8, 2, 10, 0)
|
|
232
|
+
ctx.emit('session/event', session('s1'), ev('request/context', 0, t, { provider: 'deepseek', model: 'chat' }))
|
|
233
|
+
ctx.emit('session/event', session('s1'), ev('assistant/message', 1, t, {
|
|
234
|
+
turn: 0,
|
|
235
|
+
step: 0,
|
|
236
|
+
usage: usage({ inputTokens: 1 }),
|
|
237
|
+
message: { source: { provider: 'other', model: 'pro' } },
|
|
238
|
+
}))
|
|
239
|
+
ctx.emit('session/event', session('s1'), ev('assistant/chunk', 2, t, {
|
|
240
|
+
turn: 0,
|
|
241
|
+
step: 1,
|
|
242
|
+
chunk: { type: 'usage', usage: usage({ inputTokens: 2 }) },
|
|
243
|
+
}))
|
|
244
|
+
await tick()
|
|
245
|
+
assert.deepEqual(store.samples.map((sample) => [sample.inputTokens, sample.model]), [
|
|
246
|
+
[1, 'other/pro'],
|
|
247
|
+
[2, 'other/pro'],
|
|
248
|
+
])
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
test('归因链:无观测路由时从 requestContext() 懒播种', async () => {
|
|
252
|
+
const sessions = fakeSessions([{ id: 's1', requestContext: () => ({ model: 'solo' }) }])
|
|
253
|
+
const { ctx, store } = liveCollector({ sessions })
|
|
254
|
+
const t = local(2026, 8, 2, 10, 0)
|
|
255
|
+
ctx.emit('session/event', session('s1'), ev('assistant/message', 1, t, {
|
|
256
|
+
turn: 0,
|
|
257
|
+
step: 0,
|
|
258
|
+
usage: usage({ inputTokens: 1 }),
|
|
259
|
+
message: {},
|
|
260
|
+
}))
|
|
261
|
+
ctx.emit('session/event', session('s1'), ev('assistant/chunk', 2, t, {
|
|
262
|
+
turn: 0,
|
|
263
|
+
step: 1,
|
|
264
|
+
chunk: { type: 'usage', usage: usage({ inputTokens: 2 }) },
|
|
265
|
+
}))
|
|
266
|
+
await tick()
|
|
267
|
+
assert.deepEqual(store.samples.map((sample) => sample.model), ['solo', 'solo'])
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
test('归因链:全部缺失时样本无 model', async () => {
|
|
271
|
+
const sessions = fakeSessions([{ id: 's1' }])
|
|
272
|
+
const { ctx, store } = liveCollector({ sessions })
|
|
273
|
+
const t = local(2026, 8, 2, 10, 0)
|
|
274
|
+
ctx.emit('session/event', session('s1'), ev('assistant/message', 1, t, {
|
|
275
|
+
turn: 0,
|
|
276
|
+
step: 0,
|
|
277
|
+
usage: usage(),
|
|
278
|
+
message: {},
|
|
279
|
+
}))
|
|
280
|
+
await tick()
|
|
281
|
+
assert.equal(store.samples.length, 1)
|
|
282
|
+
assert.equal(store.samples[0].model, undefined)
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
test('实时游标:首事件标记 firstSeq 并合批,同会话不重复,seq 缺失记哨兵', async () => {
|
|
286
|
+
const { ctx, store } = liveCollector()
|
|
287
|
+
const t = local(2026, 8, 2, 10, 0)
|
|
288
|
+
ctx.emit('session/event', session('s1'), ev('step/start', 3, t))
|
|
289
|
+
ctx.emit('session/event', session('s1'), ev('step/start', 4, t))
|
|
290
|
+
ctx.emit('session/event', session('s2'), ev('turn/end', 0, t))
|
|
291
|
+
await tick()
|
|
292
|
+
assert.deepEqual(store.state.liveFirstSeq, new Map([['s1', 3], ['s2', 0]]))
|
|
293
|
+
assert.equal(store.stats.markLiveCalls, 1)
|
|
294
|
+
ctx.emit('session/event', session('s3'), { type: 'turn/end', time: t })
|
|
295
|
+
ctx.emit('session/event', {}, ev('turn/end', 0, t))
|
|
296
|
+
await tick()
|
|
297
|
+
assert.equal(store.state.liveFirstSeq.get('s3'), -1)
|
|
298
|
+
assert.equal(store.state.liveFirstSeq.size, 3)
|
|
299
|
+
assert.equal(store.stats.markLiveCalls, 2)
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
test('采集侧 store 失败计入 recordFailures 且不抛断采集', async () => {
|
|
303
|
+
const { ctx, store, collector } = liveCollector({ store: fakeStore({ failRecord: true, failMarkLive: true }) })
|
|
304
|
+
const t = local(2026, 8, 2, 10, 0)
|
|
305
|
+
// request/context 只触发游标写(失败),不产样本
|
|
306
|
+
ctx.emit('session/event', session('s1'), ev('request/context', 0, t, { model: 'm' }))
|
|
307
|
+
ctx.emit('session/event', session('s1'), ev('assistant/message', 1, t, {
|
|
308
|
+
turn: 0,
|
|
309
|
+
step: 0,
|
|
310
|
+
usage: usage(),
|
|
311
|
+
message: {},
|
|
312
|
+
}))
|
|
313
|
+
await tick()
|
|
314
|
+
await tick()
|
|
315
|
+
assert.equal(collector.status().recordFailures, 2)
|
|
316
|
+
assert.equal(store.samples.length, 0)
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
test('session/disposed 后该会话去重状态重置', async () => {
|
|
320
|
+
const { ctx, store } = liveCollector()
|
|
321
|
+
const t = local(2026, 8, 2, 10, 0)
|
|
322
|
+
const chunk = (seq) => ev('assistant/chunk', seq, t, {
|
|
323
|
+
turn: 0,
|
|
324
|
+
step: 0,
|
|
325
|
+
chunk: { type: 'usage', usage: usage() },
|
|
326
|
+
})
|
|
327
|
+
ctx.emit('session/event', session('s1'), chunk(1))
|
|
328
|
+
ctx.emit('session/disposed', session('s1'))
|
|
329
|
+
ctx.emit('session/event', session('s1'), chunk(2))
|
|
330
|
+
await tick()
|
|
331
|
+
assert.equal(store.samples.length, 2)
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
test('回扫:无边界会话全量重放,归因与游标写回', async () => {
|
|
335
|
+
const t = local(2026, 8, 2, 14, 0)
|
|
336
|
+
const persistence = fakePersistence([{
|
|
337
|
+
id: 's1',
|
|
338
|
+
events: [
|
|
339
|
+
ev('request/context', 0, t, { provider: 'deepseek', model: 'chat' }),
|
|
340
|
+
ev('step/start', 1, t),
|
|
341
|
+
ev('assistant/message', 2, t, {
|
|
342
|
+
turn: 0,
|
|
343
|
+
step: 0,
|
|
344
|
+
usage: usage(),
|
|
345
|
+
message: { source: { provider: 'deepseek', model: 'chat' } },
|
|
346
|
+
}),
|
|
347
|
+
ev('turn/end', 3, t),
|
|
348
|
+
],
|
|
349
|
+
}])
|
|
350
|
+
const store = fakeStore()
|
|
351
|
+
const collector = new UsageCollector(fakeCtx({ persistence }), store)
|
|
352
|
+
await collector.backfill(persistence, fakeSessions())
|
|
353
|
+
assert.equal(store.samples.length, 3)
|
|
354
|
+
assert.equal(store.samples[0].request, true)
|
|
355
|
+
assert.equal(store.samples[0].model, 'deepseek/chat')
|
|
356
|
+
assert.equal(store.samples[1].inputTokens, 10)
|
|
357
|
+
assert.equal(store.samples[1].model, 'deepseek/chat')
|
|
358
|
+
assert.equal(store.samples[2].turn, true)
|
|
359
|
+
assert.deepEqual([...store.state.backfilledSessions], ['s1'])
|
|
360
|
+
assert.equal(collector.status().total, 1)
|
|
361
|
+
assert.equal(collector.status().done, 1)
|
|
362
|
+
assert.equal(collector.status().scannedSessions, 1)
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
test('回扫:已入游标会话不再重扫', async () => {
|
|
366
|
+
const t = local(2026, 8, 2, 14, 0)
|
|
367
|
+
const persistence = fakePersistence([{ id: 's1', events: [ev('turn/end', 0, t)] }])
|
|
368
|
+
const store = fakeStore()
|
|
369
|
+
store.state.backfilledSessions.add('s1')
|
|
370
|
+
const collector = new UsageCollector(fakeCtx({ persistence }), store)
|
|
371
|
+
await collector.backfill(persistence, fakeSessions())
|
|
372
|
+
assert.equal(store.samples.length, 0)
|
|
373
|
+
assert.equal(collector.status().total, 0)
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
test('回扫:liveFirstSeq 边界只重放前缀,实时已拥事件跳过', async () => {
|
|
377
|
+
const t = local(2026, 8, 2, 14, 0)
|
|
378
|
+
const persistence = fakePersistence([{
|
|
379
|
+
id: 's1',
|
|
380
|
+
events: [
|
|
381
|
+
ev('assistant/message', 0, t, { turn: 0, step: 0, usage: usage({ inputTokens: 1 }), message: {} }),
|
|
382
|
+
ev('turn/end', 1, t),
|
|
383
|
+
ev('assistant/message', 2, t, { turn: 1, step: 0, usage: usage({ inputTokens: 2 }), message: {} }),
|
|
384
|
+
ev('turn/end', 3, t),
|
|
385
|
+
],
|
|
386
|
+
}])
|
|
387
|
+
const store = fakeStore()
|
|
388
|
+
store.state.liveFirstSeq.set('s1', 2)
|
|
389
|
+
const collector = new UsageCollector(fakeCtx({ persistence }), store)
|
|
390
|
+
await collector.backfill(persistence, fakeSessions())
|
|
391
|
+
assert.deepEqual(store.samples.map((sample) => [sample.inputTokens, sample.turn]), [[1, undefined], [0, true]])
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
test('回扫:-1 哨兵会话重放零事件但仍入游标', async () => {
|
|
395
|
+
const t = local(2026, 8, 2, 14, 0)
|
|
396
|
+
const persistence = fakePersistence([{ id: 's1', events: [ev('turn/end', 0, t)] }])
|
|
397
|
+
const store = fakeStore()
|
|
398
|
+
store.state.liveFirstSeq.set('s1', -1)
|
|
399
|
+
const collector = new UsageCollector(fakeCtx({ persistence }), store)
|
|
400
|
+
await collector.backfill(persistence, fakeSessions())
|
|
401
|
+
assert.equal(store.samples.length, 0)
|
|
402
|
+
assert.deepEqual([...store.state.backfilledSessions], ['s1'])
|
|
403
|
+
assert.equal(collector.status().scannedSessions, 1)
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
test('回扫:活跃会话无有效边界时整会话跳过', async () => {
|
|
407
|
+
const t = local(2026, 8, 2, 14, 0)
|
|
408
|
+
const persistence = fakePersistence([{ id: 's1', events: [ev('turn/end', 0, t)] }])
|
|
409
|
+
const sessions = fakeSessions([{ id: 's1' }])
|
|
410
|
+
const store = fakeStore()
|
|
411
|
+
const collector = new UsageCollector(fakeCtx({ persistence }), store)
|
|
412
|
+
await collector.backfill(persistence, sessions)
|
|
413
|
+
assert.equal(store.samples.length, 0)
|
|
414
|
+
assert.equal(store.state.backfilledSessions.size, 0)
|
|
415
|
+
assert.equal(collector.status().done, 1)
|
|
416
|
+
assert.equal(collector.status().scannedSessions, 0)
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
test('回扫:活跃会话有边界时只重放前缀', async () => {
|
|
420
|
+
const t = local(2026, 8, 2, 14, 0)
|
|
421
|
+
const persistence = fakePersistence([{
|
|
422
|
+
id: 's1',
|
|
423
|
+
events: [
|
|
424
|
+
ev('turn/end', 0, t),
|
|
425
|
+
ev('turn/end', 1, t),
|
|
426
|
+
ev('turn/end', 2, t),
|
|
427
|
+
],
|
|
428
|
+
}])
|
|
429
|
+
const sessions = fakeSessions([{ id: 's1', seq: 5 }])
|
|
430
|
+
const store = fakeStore()
|
|
431
|
+
store.state.liveFirstSeq.set('s1', 2)
|
|
432
|
+
const collector = new UsageCollector(fakeCtx({ persistence }), store)
|
|
433
|
+
await collector.backfill(persistence, sessions)
|
|
434
|
+
assert.equal(store.samples.length, 2)
|
|
435
|
+
assert.deepEqual([...store.state.backfilledSessions], ['s1'])
|
|
436
|
+
assert.equal(collector.status().scannedSessions, 1)
|
|
437
|
+
})
|
|
438
|
+
|
|
439
|
+
test('回扫:fork 继承前缀整体跳过且不播种路由', async () => {
|
|
440
|
+
const t = local(2026, 8, 2, 14, 0)
|
|
441
|
+
const persistence = fakePersistence([{
|
|
442
|
+
id: 'child',
|
|
443
|
+
inheritedEventCount: 2,
|
|
444
|
+
events: [
|
|
445
|
+
ev('request/context', 0, t, { model: 'inherited-model' }),
|
|
446
|
+
ev('assistant/message', 1, t, { turn: 0, step: 0, usage: usage({ inputTokens: 500 }), message: {} }),
|
|
447
|
+
ev('assistant/chunk', 2, t, {
|
|
448
|
+
turn: 0,
|
|
449
|
+
step: 1,
|
|
450
|
+
chunk: { type: 'usage', usage: usage({ inputTokens: 7 }) },
|
|
451
|
+
}),
|
|
452
|
+
],
|
|
453
|
+
}])
|
|
454
|
+
const store = fakeStore()
|
|
455
|
+
const collector = new UsageCollector(fakeCtx({ persistence }), store)
|
|
456
|
+
await collector.backfill(persistence, fakeSessions())
|
|
457
|
+
assert.equal(store.samples.length, 1)
|
|
458
|
+
assert.equal(store.samples[0].inputTokens, 7)
|
|
459
|
+
assert.equal(store.samples[0].model, undefined)
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
test('回扫:单会话失败不中断整轮且不进游标', async () => {
|
|
463
|
+
const t = local(2026, 8, 2, 14, 0)
|
|
464
|
+
const persistence = fakePersistence([
|
|
465
|
+
{ id: 's1', events: [ev('turn/end', 0, t)] },
|
|
466
|
+
{ id: 's2', fail: 'corrupted log' },
|
|
467
|
+
])
|
|
468
|
+
const store = fakeStore()
|
|
469
|
+
const collector = new UsageCollector(fakeCtx({ persistence }), store)
|
|
470
|
+
await collector.backfill(persistence, fakeSessions())
|
|
471
|
+
assert.equal(store.samples.length, 1)
|
|
472
|
+
assert.deepEqual([...store.state.backfilledSessions], ['s1'])
|
|
473
|
+
assert.equal(collector.status().error, undefined)
|
|
474
|
+
assert.equal(collector.status().skippedSessions, 1)
|
|
475
|
+
assert.equal(collector.status().done, 2)
|
|
476
|
+
})
|
|
477
|
+
|
|
478
|
+
test('回扫:record 失败使会话失败并保留游标重试机会', async () => {
|
|
479
|
+
const t = local(2026, 8, 2, 14, 0)
|
|
480
|
+
const persistence = fakePersistence([{ id: 's1', events: [ev('turn/end', 0, t)] }])
|
|
481
|
+
const store = fakeStore({ failRecord: true })
|
|
482
|
+
const collector = new UsageCollector(fakeCtx({ persistence }), store)
|
|
483
|
+
await collector.backfill(persistence, fakeSessions())
|
|
484
|
+
assert.equal(store.state.backfilledSessions.size, 0)
|
|
485
|
+
assert.equal(collector.status().skippedSessions, 1)
|
|
486
|
+
assert.equal(collector.status().done, 1)
|
|
487
|
+
})
|
|
488
|
+
|
|
489
|
+
test('abort:已中止 signal 直接返回不进入扫描', async () => {
|
|
490
|
+
const persistence = fakePersistence([{ id: 's1', events: [] }])
|
|
491
|
+
const store = fakeStore()
|
|
492
|
+
const collector = new UsageCollector(fakeCtx({ persistence }), store)
|
|
493
|
+
const controller = new AbortController()
|
|
494
|
+
controller.abort()
|
|
495
|
+
await collector.backfill(persistence, fakeSessions(), controller.signal)
|
|
496
|
+
assert.equal(collector.status().running, false)
|
|
497
|
+
assert.equal(collector.status().total, 0)
|
|
498
|
+
assert.equal(store.samples.length, 0)
|
|
499
|
+
})
|
|
500
|
+
|
|
501
|
+
test('abort:扫描中中断停止处理且不写游标', async () => {
|
|
502
|
+
const controller = new AbortController()
|
|
503
|
+
const t = local(2026, 8, 2, 14, 0)
|
|
504
|
+
const persistence = {
|
|
505
|
+
async list() {
|
|
506
|
+
return [{ id: 's1' }]
|
|
507
|
+
},
|
|
508
|
+
async inspect(id) {
|
|
509
|
+
controller.abort()
|
|
510
|
+
return { meta: { id }, inheritedEventCount: 0, events: [ev('turn/end', 0, t)] }
|
|
511
|
+
},
|
|
512
|
+
}
|
|
513
|
+
const store = fakeStore()
|
|
514
|
+
const collector = new UsageCollector(fakeCtx(), store)
|
|
515
|
+
await collector.backfill(persistence, fakeSessions(), controller.signal)
|
|
516
|
+
assert.equal(store.samples.length, 0)
|
|
517
|
+
assert.equal(store.state.backfilledSessions.size, 0)
|
|
518
|
+
assert.equal(collector.status().running, false)
|
|
519
|
+
assert.equal(collector.status().done, 1)
|
|
520
|
+
})
|
|
521
|
+
|
|
522
|
+
test('回扫并发受默认并发 4 约束', async () => {
|
|
523
|
+
const TARGET_COUNT = 8
|
|
524
|
+
const EXPECTED_CONCURRENCY = 4
|
|
525
|
+
const GATE_TIMEOUT_MS = 250
|
|
526
|
+
let inFlight = 0
|
|
527
|
+
let peak = 0
|
|
528
|
+
let release
|
|
529
|
+
const gate = new Promise((resolve) => { release = resolve })
|
|
530
|
+
const t = local(2026, 8, 2, 14, 0)
|
|
531
|
+
const defs = Array.from({ length: TARGET_COUNT }, (_, i) => ({ id: `s${i}`, events: [ev('turn/end', 0, t)] }))
|
|
532
|
+
const persistence = {
|
|
533
|
+
async list() {
|
|
534
|
+
return defs.map((def) => ({ id: def.id }))
|
|
535
|
+
},
|
|
536
|
+
async inspect(id) {
|
|
537
|
+
inFlight += 1
|
|
538
|
+
peak = Math.max(peak, inFlight)
|
|
539
|
+
if (inFlight >= EXPECTED_CONCURRENCY) release()
|
|
540
|
+
await Promise.race([gate, new Promise((resolve) => setTimeout(resolve, GATE_TIMEOUT_MS))])
|
|
541
|
+
inFlight -= 1
|
|
542
|
+
const def = defs.find((candidate) => candidate.id === id)
|
|
543
|
+
return { meta: { id }, inheritedEventCount: 0, events: def.events }
|
|
544
|
+
},
|
|
545
|
+
}
|
|
546
|
+
const store = fakeStore()
|
|
547
|
+
const collector = new UsageCollector(fakeCtx(), store)
|
|
548
|
+
await collector.backfill(persistence, fakeSessions())
|
|
549
|
+
assert.equal(peak, EXPECTED_CONCURRENCY)
|
|
550
|
+
assert.equal(store.samples.length, TARGET_COUNT)
|
|
551
|
+
})
|
|
552
|
+
|
|
553
|
+
test('resetAndRescan 以 wipe 时刻日志长度为活跃会话边界并重扫', async () => {
|
|
554
|
+
const t = local(2026, 8, 2, 9, 0)
|
|
555
|
+
const events = Array.from({ length: 13 }, (_, seq) => ev('turn/end', seq, t))
|
|
556
|
+
const persistence = fakePersistence([{ id: 'live-a', events }])
|
|
557
|
+
const sessions = fakeSessions([{ id: 'live-a', seq: 10 }, { id: 'live-b' }])
|
|
558
|
+
const store = fakeStore()
|
|
559
|
+
store.state.liveFirstSeq.set('live-a', 3)
|
|
560
|
+
const collector = new UsageCollector(fakeCtx({ sessions, persistence }), store)
|
|
561
|
+
await collector.resetAndRescan()
|
|
562
|
+
assert.equal(store.stats.resets, 1)
|
|
563
|
+
assert.deepEqual([...store.stats.lastBoundaries.keys()].sort(), ['live-a', 'live-b'])
|
|
564
|
+
assert.equal(store.stats.lastBoundaries.get('live-a'), 10)
|
|
565
|
+
assert.equal(store.stats.lastBoundaries.get('live-b'), -1)
|
|
566
|
+
assert.equal(store.samples.length, 10)
|
|
567
|
+
assert.deepEqual([...store.state.backfilledSessions], ['live-a'])
|
|
568
|
+
})
|
|
569
|
+
|
|
570
|
+
test('并发 resetAndRescan 合并为一次重建', async () => {
|
|
571
|
+
const store = fakeStore()
|
|
572
|
+
const collector = new UsageCollector(fakeCtx(), store)
|
|
573
|
+
const first = collector.resetAndRescan()
|
|
574
|
+
const second = collector.resetAndRescan()
|
|
575
|
+
await Promise.all([first, second])
|
|
576
|
+
assert.equal(store.stats.resets, 1)
|
|
577
|
+
})
|
|
578
|
+
|
|
579
|
+
test('status() 返回快照,外部修改不影响内部状态', async () => {
|
|
580
|
+
const t = local(2026, 8, 2, 14, 0)
|
|
581
|
+
const persistence = fakePersistence([{ id: 's1', events: [ev('turn/end', 0, t)] }])
|
|
582
|
+
const store = fakeStore()
|
|
583
|
+
const collector = new UsageCollector(fakeCtx({ persistence }), store)
|
|
584
|
+
assert.deepEqual(collector.status(), {
|
|
585
|
+
running: false,
|
|
586
|
+
total: 0,
|
|
587
|
+
done: 0,
|
|
588
|
+
scannedSessions: 0,
|
|
589
|
+
lastSessionId: undefined,
|
|
590
|
+
error: undefined,
|
|
591
|
+
recordFailures: 0,
|
|
592
|
+
skippedSessions: 0,
|
|
593
|
+
})
|
|
594
|
+
await collector.backfill(persistence, fakeSessions())
|
|
595
|
+
const snapshot = collector.status()
|
|
596
|
+
assert.equal(snapshot.running, false)
|
|
597
|
+
assert.equal(snapshot.total, 1)
|
|
598
|
+
assert.equal(snapshot.done, 1)
|
|
599
|
+
assert.equal(snapshot.scannedSessions, 1)
|
|
600
|
+
assert.equal(snapshot.lastSessionId, 's1')
|
|
601
|
+
snapshot.total = 99
|
|
602
|
+
assert.equal(collector.status().total, 1)
|
|
603
|
+
})
|