dsh-all-usage 1.0.8 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +46 -0
- package/LICENSE +21 -21
- package/README.md +45 -9
- package/assets/screenshot-1.png +0 -0
- package/assets/screenshot-2.png +0 -0
- package/assets/screenshot-3.png +0 -0
- package/assets/screenshot-4.png +0 -0
- package/assets/screenshot-5.png +0 -0
- package/assets/screenshot-6.png +0 -0
- package/assets/screenshot-7.png +0 -0
- package/cordis.patch.yml +5 -5
- package/lib/client.js +2271 -1200
- package/lib/index.js +1572 -1039
- package/package.json +56 -55
- package/screenshots.json +9 -0
package/lib/index.js
CHANGED
|
@@ -1,1039 +1,1572 @@
|
|
|
1
|
-
// dsh-all-usage 插件 Host 半(永久版)
|
|
2
|
-
// 数据聚合 + 账户余额 + 工作区别名持久化,通过 webServer 路由向客户端提供数据。
|
|
3
|
-
import { randomBytes, timingSafeEqual } from 'node:crypto'
|
|
4
|
-
|
|
5
|
-
const name = 'dsh-all-usage'
|
|
6
|
-
const inject = ['sessionQuery', 'workspaceRegistry', 'timer', 'sessionPersistence', 'storage']
|
|
7
|
-
|
|
8
|
-
// webServer route handlers do not inherit the connection API fence; keep this plugin
|
|
9
|
-
// local and require a browser-originated capability for state-changing reads/writes.
|
|
10
|
-
function requestHeader(req, name) {
|
|
11
|
-
const headers = req && req.headers
|
|
12
|
-
if (headers === null || headers === undefined || typeof headers !== 'object') return undefined
|
|
13
|
-
const value = headers[name.toLowerCase()]
|
|
14
|
-
if (Array.isArray(value)) return typeof value[0] === 'string' ? value[0] : undefined
|
|
15
|
-
return typeof value === 'string' ? value : undefined
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
function isLoopbackHostname(hostname) {
|
|
19
|
-
if (hostname === 'localhost' || hostname === '[::1]') return true
|
|
20
|
-
const parts = hostname.split('.')
|
|
21
|
-
return parts.length === 4 && parts[0] === '127' && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255)
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
function isTrustedLocalApiRequest(req, requireOrigin) {
|
|
25
|
-
const host = requestHeader(req, 'host')
|
|
26
|
-
if (host === undefined) return false
|
|
27
|
-
let hostUrl
|
|
28
|
-
try {
|
|
29
|
-
hostUrl = new URL('http://' + host)
|
|
30
|
-
} catch (err) {
|
|
31
|
-
return false
|
|
32
|
-
}
|
|
33
|
-
if (!isLoopbackHostname(hostUrl.hostname)) return false
|
|
34
|
-
if (requestHeader(req, 'sec-fetch-site') === 'cross-site') return false
|
|
35
|
-
const origin = requestHeader(req, 'origin')
|
|
36
|
-
if (origin === undefined) return requireOrigin !== true
|
|
37
|
-
try {
|
|
38
|
-
const originUrl = new URL(origin)
|
|
39
|
-
return originUrl.protocol === 'http:' && originUrl.host === hostUrl.host
|
|
40
|
-
} catch (err) {
|
|
41
|
-
return false
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function hasWriteToken(req, expected) {
|
|
46
|
-
const actual = requestHeader(req, 'x-all-usage-request-token')
|
|
47
|
-
if (typeof actual !== 'string' || typeof expected !== 'string') return false
|
|
48
|
-
const actualBytes = Buffer.from(actual)
|
|
49
|
-
const expectedBytes = Buffer.from(expected)
|
|
50
|
-
return actualBytes.length === expectedBytes.length && timingSafeEqual(actualBytes, expectedBytes)
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function sendJson(res, code, value) {
|
|
54
|
-
res.statusCode = code
|
|
55
|
-
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
56
|
-
res.setHeader('cache-control', 'no-store')
|
|
57
|
-
res.end(JSON.stringify(value))
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function readBody(req, maxBytes) {
|
|
61
|
-
return new Promise((resolve) => {
|
|
62
|
-
const chunks = []
|
|
63
|
-
let size = 0
|
|
64
|
-
req.on('data', (chunk) => {
|
|
65
|
-
size += chunk.length
|
|
66
|
-
if (size <= maxBytes) chunks.push(chunk)
|
|
67
|
-
})
|
|
68
|
-
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
|
|
69
|
-
req.on('error', () => resolve(''))
|
|
70
|
-
})
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function apply(ctx) {
|
|
74
|
-
const credentials = ctx.get('credentials')
|
|
75
|
-
const settings = ctx.get('settings')
|
|
76
|
-
const storage = ctx.get('storage')
|
|
77
|
-
const webServer = ctx.get('webServer')
|
|
78
|
-
// v1.0.8: optional sessionPersistence exposes listSnapshots() — a cheap per-session
|
|
79
|
-
// revision (header line + stat, no full-log read) that lets the baseline skip
|
|
80
|
-
// re-reading unchanged sessions after a DSH restart.
|
|
81
|
-
const sessionPersistence = ctx.get('sessionPersistence')
|
|
82
|
-
|
|
83
|
-
// ---------- owned aggregation state ----------
|
|
84
|
-
const wsMeta = new Map()
|
|
85
|
-
const pathIndex = new Map()
|
|
86
|
-
const memberOf = new Map()
|
|
87
|
-
const byDay = new Map()
|
|
88
|
-
const byDayUtc = new Map()
|
|
89
|
-
const perWorkspace = new Map()
|
|
90
|
-
const perModel = new Map()
|
|
91
|
-
// One canonical usage contribution per session turn/step. This makes retries and
|
|
92
|
-
// replacement messages update a logical model call instead of double-counting it.
|
|
93
|
-
const usageByStep = new Map()
|
|
94
|
-
const
|
|
95
|
-
const
|
|
96
|
-
const
|
|
97
|
-
const
|
|
98
|
-
const
|
|
99
|
-
const
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
let
|
|
103
|
-
let
|
|
104
|
-
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
let
|
|
109
|
-
|
|
110
|
-
let
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
function
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
}
|
|
198
|
-
function
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
}
|
|
256
|
-
function
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
if (
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
if (
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
return
|
|
297
|
-
}
|
|
298
|
-
function
|
|
299
|
-
return
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
if (
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
const
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
const
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
function
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
return
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
const
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
if (
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
if (
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
}
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
if (
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
const
|
|
775
|
-
const
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
}
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
}
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
}
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
if (
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
}
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
const
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
const
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1
|
+
// dsh-all-usage 插件 Host 半(永久版)
|
|
2
|
+
// 数据聚合 + 账户余额 + 工作区别名持久化,通过 webServer 路由向客户端提供数据。
|
|
3
|
+
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
|
|
4
|
+
|
|
5
|
+
const name = 'dsh-all-usage'
|
|
6
|
+
const inject = ['sessionQuery', 'workspaceRegistry', 'timer', 'sessionPersistence', 'storage']
|
|
7
|
+
|
|
8
|
+
// webServer route handlers do not inherit the connection API fence; keep this plugin
|
|
9
|
+
// local and require a browser-originated capability for state-changing reads/writes.
|
|
10
|
+
function requestHeader(req, name) {
|
|
11
|
+
const headers = req && req.headers
|
|
12
|
+
if (headers === null || headers === undefined || typeof headers !== 'object') return undefined
|
|
13
|
+
const value = headers[name.toLowerCase()]
|
|
14
|
+
if (Array.isArray(value)) return typeof value[0] === 'string' ? value[0] : undefined
|
|
15
|
+
return typeof value === 'string' ? value : undefined
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function isLoopbackHostname(hostname) {
|
|
19
|
+
if (hostname === 'localhost' || hostname === '[::1]') return true
|
|
20
|
+
const parts = hostname.split('.')
|
|
21
|
+
return parts.length === 4 && parts[0] === '127' && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function isTrustedLocalApiRequest(req, requireOrigin) {
|
|
25
|
+
const host = requestHeader(req, 'host')
|
|
26
|
+
if (host === undefined) return false
|
|
27
|
+
let hostUrl
|
|
28
|
+
try {
|
|
29
|
+
hostUrl = new URL('http://' + host)
|
|
30
|
+
} catch (err) {
|
|
31
|
+
return false
|
|
32
|
+
}
|
|
33
|
+
if (!isLoopbackHostname(hostUrl.hostname)) return false
|
|
34
|
+
if (requestHeader(req, 'sec-fetch-site') === 'cross-site') return false
|
|
35
|
+
const origin = requestHeader(req, 'origin')
|
|
36
|
+
if (origin === undefined) return requireOrigin !== true
|
|
37
|
+
try {
|
|
38
|
+
const originUrl = new URL(origin)
|
|
39
|
+
return originUrl.protocol === 'http:' && originUrl.host === hostUrl.host
|
|
40
|
+
} catch (err) {
|
|
41
|
+
return false
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function hasWriteToken(req, expected) {
|
|
46
|
+
const actual = requestHeader(req, 'x-all-usage-request-token')
|
|
47
|
+
if (typeof actual !== 'string' || typeof expected !== 'string') return false
|
|
48
|
+
const actualBytes = Buffer.from(actual)
|
|
49
|
+
const expectedBytes = Buffer.from(expected)
|
|
50
|
+
return actualBytes.length === expectedBytes.length && timingSafeEqual(actualBytes, expectedBytes)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function sendJson(res, code, value) {
|
|
54
|
+
res.statusCode = code
|
|
55
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
56
|
+
res.setHeader('cache-control', 'no-store')
|
|
57
|
+
res.end(JSON.stringify(value))
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function readBody(req, maxBytes) {
|
|
61
|
+
return new Promise((resolve) => {
|
|
62
|
+
const chunks = []
|
|
63
|
+
let size = 0
|
|
64
|
+
req.on('data', (chunk) => {
|
|
65
|
+
size += chunk.length
|
|
66
|
+
if (size <= maxBytes) chunks.push(chunk)
|
|
67
|
+
})
|
|
68
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
|
|
69
|
+
req.on('error', () => resolve(''))
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function apply(ctx) {
|
|
74
|
+
const credentials = ctx.get('credentials')
|
|
75
|
+
const settings = ctx.get('settings')
|
|
76
|
+
const storage = ctx.get('storage')
|
|
77
|
+
const webServer = ctx.get('webServer')
|
|
78
|
+
// v1.0.8: optional sessionPersistence exposes listSnapshots() — a cheap per-session
|
|
79
|
+
// revision (header line + stat, no full-log read) that lets the baseline skip
|
|
80
|
+
// re-reading unchanged sessions after a DSH restart.
|
|
81
|
+
const sessionPersistence = ctx.get('sessionPersistence')
|
|
82
|
+
|
|
83
|
+
// ---------- owned aggregation state ----------
|
|
84
|
+
const wsMeta = new Map()
|
|
85
|
+
const pathIndex = new Map()
|
|
86
|
+
const memberOf = new Map()
|
|
87
|
+
const byDay = new Map()
|
|
88
|
+
const byDayUtc = new Map()
|
|
89
|
+
const perWorkspace = new Map()
|
|
90
|
+
const perModel = new Map()
|
|
91
|
+
// One canonical usage contribution per session turn/step. This makes retries and
|
|
92
|
+
// replacement messages update a logical model call instead of double-counting it.
|
|
93
|
+
const usageByStep = new Map()
|
|
94
|
+
const turnRecords = new Map()
|
|
95
|
+
const sessionModel = new Map()
|
|
96
|
+
const totals = { turns: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 }
|
|
97
|
+
const sessionCount = new Set()
|
|
98
|
+
const sessionSeq = new Map()
|
|
99
|
+
const chains = new Map()
|
|
100
|
+
const scan = { started: false, done: false, scanned: 0, total: 0, failed: 0 }
|
|
101
|
+
const aliases = {}
|
|
102
|
+
let kvUnit = null
|
|
103
|
+
let aliasWriteChain = Promise.resolve()
|
|
104
|
+
let balanceCache = { fetchedAt: 0, payload: null }
|
|
105
|
+
const requestToken = randomBytes(32).toString('base64url')
|
|
106
|
+
// A non-secret identity distinguishes HMR/restart revision resets from a stale page.
|
|
107
|
+
const instanceId = randomBytes(12).toString('base64url')
|
|
108
|
+
let statsRevision = 0
|
|
109
|
+
let statsUpdatedAt = Date.now()
|
|
110
|
+
let statsDirtyScheduled = false
|
|
111
|
+
const sync = {
|
|
112
|
+
lastStartedAt: 0,
|
|
113
|
+
lastCompletedAt: 0,
|
|
114
|
+
lastErrorAt: 0,
|
|
115
|
+
lastErrorCode: null,
|
|
116
|
+
persistenceSnapshotsAvailable: false,
|
|
117
|
+
sessionsTotal: 0,
|
|
118
|
+
sessionsRead: 0,
|
|
119
|
+
sessionsSkippedByRevision: 0,
|
|
120
|
+
sessionsRestoredFromLedger: 0,
|
|
121
|
+
sessionsFailed: 0,
|
|
122
|
+
}
|
|
123
|
+
const ledgerRecords = new Map()
|
|
124
|
+
const queryCache = new Map()
|
|
125
|
+
let recordsIndex = null
|
|
126
|
+
let ledgerUnit = null
|
|
127
|
+
let ledgerReady = Promise.resolve()
|
|
128
|
+
let ledgerWriteChain = Promise.resolve()
|
|
129
|
+
const LEDGER_VERSION = 2
|
|
130
|
+
const LEGACY_LEDGER_VERSION = 1
|
|
131
|
+
let ledgerRevision = Date.now()
|
|
132
|
+
let disposed = false
|
|
133
|
+
let baselineRetryDelay = 1000
|
|
134
|
+
let baselineRetryScheduled = false
|
|
135
|
+
let baselineFallbackTimer = null
|
|
136
|
+
const knownSessionIds = new Set()
|
|
137
|
+
let aggregationGeneration = 0
|
|
138
|
+
let reconcileHintScheduled = false
|
|
139
|
+
let reconcilePending = false
|
|
140
|
+
let reconcileInFlight = false
|
|
141
|
+
let reconcileTimer = null
|
|
142
|
+
const RECONCILE_INTERVAL_MS = 120000
|
|
143
|
+
const RECONCILE_HINT_DELAY_MS = 3000
|
|
144
|
+
|
|
145
|
+
function dayKey(ms) {
|
|
146
|
+
const d = new Date(ms)
|
|
147
|
+
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0')
|
|
148
|
+
}
|
|
149
|
+
function dayKeyUtc(ms) {
|
|
150
|
+
const d = new Date(ms)
|
|
151
|
+
return d.getUTCFullYear() + '-' + String(d.getUTCMonth() + 1).padStart(2, '0') + '-' + String(d.getUTCDate()).padStart(2, '0')
|
|
152
|
+
}
|
|
153
|
+
function dateKeys(ms) {
|
|
154
|
+
const d = new Date(ms)
|
|
155
|
+
const local = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0')
|
|
156
|
+
const utc = d.getUTCFullYear() + '-' + String(d.getUTCMonth() + 1).padStart(2, '0') + '-' + String(d.getUTCDate()).padStart(2, '0')
|
|
157
|
+
return { local, utc }
|
|
158
|
+
}
|
|
159
|
+
function num(v) {
|
|
160
|
+
return typeof v === 'number' && Number.isFinite(v) ? v : 0
|
|
161
|
+
}
|
|
162
|
+
// Coalesce synchronous aggregation writes without adding a timer lifecycle.
|
|
163
|
+
function markStatsChanged() {
|
|
164
|
+
recordsIndex = null
|
|
165
|
+
if (disposed || statsDirtyScheduled) return
|
|
166
|
+
statsDirtyScheduled = true
|
|
167
|
+
const flush = () => {
|
|
168
|
+
statsDirtyScheduled = false
|
|
169
|
+
if (disposed) return
|
|
170
|
+
statsRevision += 1
|
|
171
|
+
statsUpdatedAt = Date.now()
|
|
172
|
+
}
|
|
173
|
+
if (typeof queueMicrotask === 'function') queueMicrotask(flush)
|
|
174
|
+
else Promise.resolve().then(flush)
|
|
175
|
+
}
|
|
176
|
+
function resetSyncState() {
|
|
177
|
+
sync.lastStartedAt = 0
|
|
178
|
+
sync.lastCompletedAt = 0
|
|
179
|
+
sync.lastErrorAt = 0
|
|
180
|
+
sync.lastErrorCode = null
|
|
181
|
+
sync.persistenceSnapshotsAvailable = false
|
|
182
|
+
sync.sessionsTotal = 0
|
|
183
|
+
sync.sessionsRead = 0
|
|
184
|
+
sync.sessionsSkippedByRevision = 0
|
|
185
|
+
sync.sessionsRestoredFromLedger = 0
|
|
186
|
+
sync.sessionsFailed = 0
|
|
187
|
+
}
|
|
188
|
+
function beginSync() {
|
|
189
|
+
resetSyncState()
|
|
190
|
+
sync.lastStartedAt = Date.now()
|
|
191
|
+
markStatsChanged()
|
|
192
|
+
}
|
|
193
|
+
function noteSyncError(code) {
|
|
194
|
+
sync.lastErrorAt = Date.now()
|
|
195
|
+
sync.lastErrorCode = code
|
|
196
|
+
markStatsChanged()
|
|
197
|
+
}
|
|
198
|
+
function syncSnapshot() {
|
|
199
|
+
return {
|
|
200
|
+
lastStartedAt: sync.lastStartedAt,
|
|
201
|
+
lastCompletedAt: sync.lastCompletedAt,
|
|
202
|
+
lastErrorAt: sync.lastErrorAt,
|
|
203
|
+
lastErrorCode: sync.lastErrorCode,
|
|
204
|
+
persistenceSnapshotsAvailable: sync.persistenceSnapshotsAvailable,
|
|
205
|
+
sessionsTotal: sync.sessionsTotal,
|
|
206
|
+
sessionsRead: sync.sessionsRead,
|
|
207
|
+
sessionsSkippedByRevision: sync.sessionsSkippedByRevision,
|
|
208
|
+
sessionsRestoredFromLedger: sync.sessionsRestoredFromLedger,
|
|
209
|
+
sessionsFailed: sync.sessionsFailed,
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
async function safeContextTimeout(ms) {
|
|
213
|
+
if (disposed) return false
|
|
214
|
+
try {
|
|
215
|
+
await ctx.timeout(ms)
|
|
216
|
+
return !disposed
|
|
217
|
+
} catch (err) {
|
|
218
|
+
if (!disposed) console.error('[all-usage] context timer unavailable:', err)
|
|
219
|
+
return false
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
function resetAggregationState() {
|
|
223
|
+
aggregationGeneration += 1
|
|
224
|
+
wsMeta.clear()
|
|
225
|
+
pathIndex.clear()
|
|
226
|
+
memberOf.clear()
|
|
227
|
+
byDay.clear()
|
|
228
|
+
byDayUtc.clear()
|
|
229
|
+
perWorkspace.clear()
|
|
230
|
+
perModel.clear()
|
|
231
|
+
usageByStep.clear()
|
|
232
|
+
turnRecords.clear()
|
|
233
|
+
queryCache.clear()
|
|
234
|
+
recordsIndex = null
|
|
235
|
+
sessionModel.clear()
|
|
236
|
+
sessionCount.clear()
|
|
237
|
+
sessionSeq.clear()
|
|
238
|
+
chains.clear()
|
|
239
|
+
knownSessionIds.clear()
|
|
240
|
+
totals.turns = 0
|
|
241
|
+
totals.input = 0
|
|
242
|
+
totals.output = 0
|
|
243
|
+
totals.cacheRead = 0
|
|
244
|
+
totals.cacheWrite = 0
|
|
245
|
+
totals.reasoning = 0
|
|
246
|
+
scan.started = false
|
|
247
|
+
scan.done = false
|
|
248
|
+
scan.scanned = 0
|
|
249
|
+
scan.total = 0
|
|
250
|
+
scan.failed = 0
|
|
251
|
+
resetSyncState()
|
|
252
|
+
baselineRetryDelay = 1000
|
|
253
|
+
markStatsChanged()
|
|
254
|
+
return aggregationGeneration
|
|
255
|
+
}
|
|
256
|
+
function ensureDay(dayMap, date) {
|
|
257
|
+
let day = dayMap.get(date)
|
|
258
|
+
if (day === undefined) {
|
|
259
|
+
day = { turns: 0, tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 }, perWs: new Map(), byWs: new Map(), byModel: new Map(), sessionIds: new Set() }
|
|
260
|
+
dayMap.set(date, day)
|
|
261
|
+
}
|
|
262
|
+
return day
|
|
263
|
+
}
|
|
264
|
+
function ensureWs(wsId) {
|
|
265
|
+
let ws = perWorkspace.get(wsId)
|
|
266
|
+
if (ws === undefined) {
|
|
267
|
+
ws = { turns: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 }
|
|
268
|
+
perWorkspace.set(wsId, ws)
|
|
269
|
+
}
|
|
270
|
+
return ws
|
|
271
|
+
}
|
|
272
|
+
function ensureDayWs(day, wsId) {
|
|
273
|
+
let w = day.byWs.get(wsId)
|
|
274
|
+
if (w === undefined) {
|
|
275
|
+
w = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 }
|
|
276
|
+
day.byWs.set(wsId, w)
|
|
277
|
+
}
|
|
278
|
+
return w
|
|
279
|
+
}
|
|
280
|
+
function ensureModel(value) {
|
|
281
|
+
const identity = coerceIdentity(value)
|
|
282
|
+
let item = perModel.get(identity.identityKey)
|
|
283
|
+
if (item === undefined) {
|
|
284
|
+
item = { ...identity, model: identity.label, calls: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 }
|
|
285
|
+
perModel.set(identity.identityKey, item)
|
|
286
|
+
}
|
|
287
|
+
return item
|
|
288
|
+
}
|
|
289
|
+
function ensureDayModel(day, value) {
|
|
290
|
+
const identity = coerceIdentity(value)
|
|
291
|
+
let item = day.byModel.get(identity.identityKey)
|
|
292
|
+
if (item === undefined) {
|
|
293
|
+
item = { ...identity, model: identity.label, calls: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 }
|
|
294
|
+
day.byModel.set(identity.identityKey, item)
|
|
295
|
+
}
|
|
296
|
+
return item
|
|
297
|
+
}
|
|
298
|
+
function usageValues(usage) {
|
|
299
|
+
return {
|
|
300
|
+
input: num(usage && usage.inputTokens),
|
|
301
|
+
output: num(usage && usage.outputTokens),
|
|
302
|
+
cacheRead: num(usage && usage.cacheReadTokens),
|
|
303
|
+
cacheWrite: num(usage && usage.cacheWriteTokens),
|
|
304
|
+
reasoning: num(usage && usage.reasoningTokens),
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
function adjustValues(target, values, direction) {
|
|
308
|
+
target.input += values.input * direction
|
|
309
|
+
target.output += values.output * direction
|
|
310
|
+
target.cacheRead += values.cacheRead * direction
|
|
311
|
+
target.cacheWrite += values.cacheWrite * direction
|
|
312
|
+
target.reasoning += values.reasoning * direction
|
|
313
|
+
}
|
|
314
|
+
function noValues(target) {
|
|
315
|
+
return target.input === 0 && target.output === 0 && target.cacheRead === 0 && target.cacheWrite === 0 && target.reasoning === 0
|
|
316
|
+
}
|
|
317
|
+
function adjustDay(dayMap, date, wsId, values, identity, direction, sid) {
|
|
318
|
+
const day = ensureDay(dayMap, date)
|
|
319
|
+
if (sid !== undefined && sid !== null) day.sessionIds.add(sid)
|
|
320
|
+
adjustValues(day.tokens, values, direction)
|
|
321
|
+
const dayWs = ensureDayWs(day, wsId)
|
|
322
|
+
adjustValues(dayWs, values, direction)
|
|
323
|
+
if (noValues(dayWs)) day.byWs.delete(wsId)
|
|
324
|
+
const dayModel = ensureDayModel(day, identity)
|
|
325
|
+
dayModel.calls += direction
|
|
326
|
+
adjustValues(dayModel, values, direction)
|
|
327
|
+
if (dayModel.calls === 0 && noValues(dayModel)) day.byModel.delete(dayModel.identityKey)
|
|
328
|
+
}
|
|
329
|
+
function adjustUsage(wsId, time, values, identity, direction, sid, cachedDates) {
|
|
330
|
+
const normalized = coerceIdentity(identity)
|
|
331
|
+
const dates = cachedDates && typeof cachedDates.local === 'string' && typeof cachedDates.utc === 'string' ? cachedDates : dateKeys(time)
|
|
332
|
+
const modelTotals = ensureModel(normalized)
|
|
333
|
+
modelTotals.calls += direction
|
|
334
|
+
adjustValues(modelTotals, values, direction)
|
|
335
|
+
if (modelTotals.calls === 0 && noValues(modelTotals)) perModel.delete(normalized.identityKey)
|
|
336
|
+
adjustValues(totals, values, direction)
|
|
337
|
+
const ws = ensureWs(wsId)
|
|
338
|
+
adjustValues(ws, values, direction)
|
|
339
|
+
adjustDay(byDay, dates.local, wsId, values, normalized, direction, sid)
|
|
340
|
+
adjustDay(byDayUtc, dates.utc, wsId, values, normalized, direction, sid)
|
|
341
|
+
}
|
|
342
|
+
function usageStepKey(sid, data, seq) {
|
|
343
|
+
const turn = data && typeof data.turn === 'number' ? data.turn : null
|
|
344
|
+
const step = data && typeof data.step === 'number' ? data.step : null
|
|
345
|
+
if (turn !== null && step !== null) return sid + ':step:' + turn + ':' + step
|
|
346
|
+
return sid + ':event:' + (typeof seq === 'number' ? seq : String(Date.now()))
|
|
347
|
+
}
|
|
348
|
+
function addUsage(wsId, time, usage, model, sid, data, seq, materialization = 'live') {
|
|
349
|
+
const values = usageValues(usage)
|
|
350
|
+
const identity = coerceIdentity(model)
|
|
351
|
+
const eventSeq = typeof seq === 'number' ? seq : -1
|
|
352
|
+
// v1.0.7: a usage event carrying no billable token in any bucket must not add a
|
|
353
|
+
// meaningless row nor wipe previously recorded real usage (cc-switch
|
|
354
|
+
// has_billable_tokens parity). Pure cache-read requests are billable and pass.
|
|
355
|
+
if (noValues(values)) return
|
|
356
|
+
const dates = dateKeys(time)
|
|
357
|
+
const key = usageStepKey(sid, data, seq)
|
|
358
|
+
const previous = usageByStep.get(key)
|
|
359
|
+
// A late replay of an older raw event cannot replace the canonical later step.
|
|
360
|
+
if (previous !== undefined && eventSeq >= 0 && previous.seq > eventSeq) return
|
|
361
|
+
if (previous !== undefined) adjustUsage(previous.wsId, previous.time, previous.values, previous.identity || previous.modelId, -1, previous.sid, { local: previous.date, utc: previous.dateUtc })
|
|
362
|
+
const next = { seq: eventSeq, wsId, time, date: dates.local, dateUtc: dates.utc, values, identity, modelId: identity.label, turn: data && typeof data.turn === 'number' ? data.turn : null, step: data && typeof data.step === 'number' ? data.step : null, materialization, sid }
|
|
363
|
+
usageByStep.set(key, next)
|
|
364
|
+
adjustUsage(wsId, time, values, identity, 1, sid, dates)
|
|
365
|
+
markStatsChanged()
|
|
366
|
+
}
|
|
367
|
+
function addDayTurn(dayMap, date, wsId, sid) {
|
|
368
|
+
const day = ensureDay(dayMap, date)
|
|
369
|
+
if (sid !== undefined && sid !== null) day.sessionIds.add(sid)
|
|
370
|
+
day.turns += 1
|
|
371
|
+
day.perWs.set(wsId, (day.perWs.get(wsId) || 0) + 1)
|
|
372
|
+
}
|
|
373
|
+
function turnRecordKey(sid, turn, seq, time) {
|
|
374
|
+
if (typeof turn === 'number' && Number.isFinite(turn)) return sid + ':turn:' + turn
|
|
375
|
+
if (typeof seq === 'number' && Number.isFinite(seq)) return sid + ':event:' + seq
|
|
376
|
+
return sid + ':time:' + String(time)
|
|
377
|
+
}
|
|
378
|
+
function addTurn(wsId, time, sid, turn, identity, materialization = 'live', seq) {
|
|
379
|
+
const key = turnRecordKey(sid, turn, seq, time)
|
|
380
|
+
if (turnRecords.has(key)) return
|
|
381
|
+
const normalized = coerceIdentity(identity)
|
|
382
|
+
const dates = dateKeys(time)
|
|
383
|
+
turnRecords.set(key, { key, sid, wsId, time, date: dates.local, dateUtc: dates.utc, turn: typeof turn === 'number' ? turn : null, identity: normalized, materialization })
|
|
384
|
+
ensureWs(wsId).turns += 1
|
|
385
|
+
totals.turns += 1
|
|
386
|
+
addDayTurn(byDay, dates.local, wsId, sid)
|
|
387
|
+
addDayTurn(byDayUtc, dates.utc, wsId, sid)
|
|
388
|
+
markStatsChanged()
|
|
389
|
+
}
|
|
390
|
+
const UNKNOWN_MODEL_LABEL = '未知模型(历史记录缺少路由)'
|
|
391
|
+
function textOrNull(value) {
|
|
392
|
+
return typeof value === 'string' && value.trim() !== '' ? value.trim() : null
|
|
393
|
+
}
|
|
394
|
+
function identityLabel(provider, requestedModel, actualModel, legacyLabel) {
|
|
395
|
+
const model = actualModel || requestedModel
|
|
396
|
+
if (model !== null) return provider === null ? model : provider + ' / ' + model
|
|
397
|
+
return legacyLabel || UNKNOWN_MODEL_LABEL
|
|
398
|
+
}
|
|
399
|
+
function makeIdentity(provider, requestedModel, actualModel, legacyLabel) {
|
|
400
|
+
const normalizedProvider = textOrNull(provider)
|
|
401
|
+
const normalizedRequested = textOrNull(requestedModel)
|
|
402
|
+
const normalizedActual = textOrNull(actualModel)
|
|
403
|
+
const normalizedLegacy = textOrNull(legacyLabel)
|
|
404
|
+
const key = JSON.stringify([normalizedProvider, normalizedRequested, normalizedActual, normalizedLegacy])
|
|
405
|
+
return {
|
|
406
|
+
identityKey: key,
|
|
407
|
+
provider: normalizedProvider,
|
|
408
|
+
requestedModel: normalizedRequested,
|
|
409
|
+
actualModel: normalizedActual,
|
|
410
|
+
label: identityLabel(normalizedProvider, normalizedRequested, normalizedActual, normalizedLegacy),
|
|
411
|
+
legacy: normalizedLegacy !== null && normalizedProvider === null && normalizedRequested === null && normalizedActual === null,
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
function identityFromLegacy(label) {
|
|
415
|
+
return makeIdentity(null, null, null, textOrNull(label) || UNKNOWN_MODEL_LABEL)
|
|
416
|
+
}
|
|
417
|
+
function isCanonicalIdentity(value) {
|
|
418
|
+
return value !== null && typeof value === 'object' && typeof value.identityKey === 'string' && typeof value.label === 'string' && (value.provider === null || typeof value.provider === 'string') && (value.requestedModel === null || typeof value.requestedModel === 'string') && (value.actualModel === null || typeof value.actualModel === 'string') && typeof value.legacy === 'boolean'
|
|
419
|
+
}
|
|
420
|
+
function coerceIdentity(value) {
|
|
421
|
+
if (isCanonicalIdentity(value)) return value
|
|
422
|
+
if (value !== null && typeof value === 'object') {
|
|
423
|
+
return makeIdentity(value.provider, value.requestedModel, value.actualModel, value.legacyLabel || (value.legacy === true ? value.label : null))
|
|
424
|
+
}
|
|
425
|
+
if (typeof value === 'string' && value !== '') return identityFromLegacy(value)
|
|
426
|
+
return makeIdentity(null, null, null, UNKNOWN_MODEL_LABEL)
|
|
427
|
+
}
|
|
428
|
+
function routeObject(data) {
|
|
429
|
+
if (data && typeof data === 'object' && (data.provider !== undefined || data.model !== undefined)) return data
|
|
430
|
+
const config = data && data.header && data.header.config
|
|
431
|
+
return config && typeof config === 'object' ? config : null
|
|
432
|
+
}
|
|
433
|
+
function identityFromRoute(data, fallback) {
|
|
434
|
+
const base = fallback === undefined ? makeIdentity(null, null, null, null) : coerceIdentity(fallback)
|
|
435
|
+
const route = routeObject(data)
|
|
436
|
+
if (route === null) return base
|
|
437
|
+
return makeIdentity(route.provider === undefined ? base.provider : route.provider, route.model === undefined ? base.requestedModel : route.model, null, base.legacy ? base.label : null)
|
|
438
|
+
}
|
|
439
|
+
function identityFromMessage(data, fallback) {
|
|
440
|
+
const base = fallback === undefined ? makeIdentity(null, null, null, null) : coerceIdentity(fallback)
|
|
441
|
+
const source = data && data.message && data.message.source
|
|
442
|
+
if (source === null || typeof source !== 'object') return base
|
|
443
|
+
return makeIdentity(source.provider === undefined ? base.provider : source.provider, base.requestedModel || source.model, source.model, base.legacy ? base.label : null)
|
|
444
|
+
}
|
|
445
|
+
function modelFromRoute(data) {
|
|
446
|
+
const identity = identityFromRoute(data)
|
|
447
|
+
return identity.label === UNKNOWN_MODEL_LABEL ? undefined : identity.label
|
|
448
|
+
}
|
|
449
|
+
function modelFromMessage(data, fallback) {
|
|
450
|
+
return identityFromMessage(data, fallback).label
|
|
451
|
+
}
|
|
452
|
+
function nextLedgerRevision() {
|
|
453
|
+
ledgerRevision = Math.max(ledgerRevision + 1, Date.now())
|
|
454
|
+
return ledgerRevision
|
|
455
|
+
}
|
|
456
|
+
function ledgerEventKey(event, index) {
|
|
457
|
+
return typeof event.seq === 'number' ? String(event.seq) : 'event:' + index
|
|
458
|
+
}
|
|
459
|
+
function buildLedgerRecord(session, workspaceId, source = 'scan', revision) {
|
|
460
|
+
const sid = session && typeof session.id === 'string' ? session.id : ''
|
|
461
|
+
const events = session && Array.isArray(session.events) ? session.events : []
|
|
462
|
+
if (sid === '' || workspaceId === undefined) return null
|
|
463
|
+
const turns = new Map()
|
|
464
|
+
const usage = new Map()
|
|
465
|
+
let currentIdentity = makeIdentity(null, null, null, null)
|
|
466
|
+
for (let index = 0; index < events.length; index += 1) {
|
|
467
|
+
const event = events[index]
|
|
468
|
+
if (event === null || typeof event !== 'object') continue
|
|
469
|
+
const data = event.data
|
|
470
|
+
if (event.type === 'request/context' || event.type === 'request/header') {
|
|
471
|
+
currentIdentity = identityFromRoute(data, currentIdentity)
|
|
472
|
+
continue
|
|
473
|
+
}
|
|
474
|
+
if (event.type === 'turn/end') {
|
|
475
|
+
const key = ledgerEventKey(event, index)
|
|
476
|
+
const turn = data && typeof data.turn === 'number' ? data.turn : null
|
|
477
|
+
turns.set(key, { key, seq: typeof event.seq === 'number' ? event.seq : -1, time: event.time, workspaceId, turn, identity: currentIdentity })
|
|
478
|
+
continue
|
|
479
|
+
}
|
|
480
|
+
if (event.type !== 'assistant/message' || data === null || typeof data !== 'object' || data.usage === undefined) continue
|
|
481
|
+
const values = usageValues(data.usage)
|
|
482
|
+
// v1.0.7: all-zero usage rows carry no billable tokens and stay out of the
|
|
483
|
+
// durable ledger (cc-switch has_billable_tokens parity).
|
|
484
|
+
if (noValues(values)) continue
|
|
485
|
+
const identity = identityFromMessage(data, currentIdentity)
|
|
486
|
+
const eventSeq = typeof event.seq === 'number' ? event.seq : -1
|
|
487
|
+
const key = usageStepKey(sid, data, event.seq)
|
|
488
|
+
const previous = usage.get(key)
|
|
489
|
+
if (previous !== undefined && eventSeq >= 0 && previous.seq > eventSeq) continue
|
|
490
|
+
usage.set(key, {
|
|
491
|
+
key,
|
|
492
|
+
seq: eventSeq,
|
|
493
|
+
time: event.time,
|
|
494
|
+
workspaceId,
|
|
495
|
+
identity,
|
|
496
|
+
modelId: identity.label,
|
|
497
|
+
turn: data && typeof data.turn === 'number' ? data.turn : null,
|
|
498
|
+
step: data && typeof data.step === 'number' ? data.step : null,
|
|
499
|
+
values,
|
|
500
|
+
})
|
|
501
|
+
currentIdentity = identity
|
|
502
|
+
}
|
|
503
|
+
return { version: LEDGER_VERSION, sessionId: sid, workspaceId, lastSeq: lastSeqOf(events), source, updatedAt: nextLedgerRevision(), lastRevision: typeof revision === 'string' ? revision : undefined, sourceRevision: typeof revision === 'string' ? revision : undefined, lastIdentity: currentIdentity, turns: Array.from(turns.values()), usage: Array.from(usage.values()) }
|
|
504
|
+
}
|
|
505
|
+
function normalizeLedgerRecord(raw, key) {
|
|
506
|
+
if (raw === null || typeof raw !== 'object' || (raw.version !== LEDGER_VERSION && raw.version !== LEGACY_LEDGER_VERSION) || typeof raw.sessionId !== 'string' || raw.sessionId !== key) return null
|
|
507
|
+
if (!Array.isArray(raw.turns) || !Array.isArray(raw.usage)) return null
|
|
508
|
+
const needsUpgrade = raw.version !== LEDGER_VERSION || raw.usage.some((item) => item === null || typeof item !== 'object' || item.identity === undefined)
|
|
509
|
+
const turnMap = new Map()
|
|
510
|
+
for (const turn of raw.turns) {
|
|
511
|
+
if (turn && typeof turn.key === 'string' && turn.workspaceId !== undefined && Number.isFinite(turn.time)) turnMap.set(turn.key, { key: turn.key, seq: typeof turn.seq === 'number' ? turn.seq : -1, time: turn.time, workspaceId: turn.workspaceId, turn: typeof turn.turn === 'number' ? turn.turn : null, identity: coerceIdentity(turn.identity || turn.modelId) })
|
|
512
|
+
}
|
|
513
|
+
const usageMap = new Map()
|
|
514
|
+
for (const item of raw.usage) {
|
|
515
|
+
if (!item || typeof item.key !== 'string' || item.workspaceId === undefined || !Number.isFinite(item.time) || item.values === null || typeof item.values !== 'object') continue
|
|
516
|
+
const identity = coerceIdentity(item.identity || item.modelId)
|
|
517
|
+
const normalized = { key: item.key, seq: typeof item.seq === 'number' ? item.seq : -1, time: item.time, workspaceId: item.workspaceId, identity, modelId: identity.label, turn: typeof item.turn === 'number' ? item.turn : null, step: typeof item.step === 'number' ? item.step : null, values: usageValues({ inputTokens: item.values.input, outputTokens: item.values.output, cacheReadTokens: item.values.cacheRead, cacheWriteTokens: item.values.cacheWrite, reasoningTokens: item.values.reasoning }) }
|
|
518
|
+
const previous = usageMap.get(normalized.key)
|
|
519
|
+
if (previous === undefined || previous.seq <= normalized.seq) usageMap.set(normalized.key, normalized)
|
|
520
|
+
}
|
|
521
|
+
const updatedAt = typeof raw.updatedAt === 'number' ? raw.updatedAt : 0
|
|
522
|
+
ledgerRevision = Math.max(ledgerRevision, updatedAt)
|
|
523
|
+
const lastUsage = Array.from(usageMap.values()).at(-1)
|
|
524
|
+
const lastIdentity = coerceIdentity(raw.lastIdentity || raw.sourceRevisionIdentity || (lastUsage && lastUsage.identity))
|
|
525
|
+
const normalizedRecord = { version: LEDGER_VERSION, sessionId: raw.sessionId, workspaceId: raw.workspaceId, lastSeq: typeof raw.lastSeq === 'number' ? raw.lastSeq : -1, source: raw.source === 'flush' ? 'flush' : 'scan', updatedAt, lastRevision: typeof raw.lastRevision === 'string' ? raw.lastRevision : (typeof raw.sourceRevision === 'string' ? raw.sourceRevision : undefined), sourceRevision: typeof raw.sourceRevision === 'string' ? raw.sourceRevision : (typeof raw.lastRevision === 'string' ? raw.lastRevision : undefined), lastIdentity, turns: Array.from(turnMap.values()), usage: Array.from(usageMap.values()) }
|
|
526
|
+
Object.defineProperty(normalizedRecord, 'needsUpgrade', { value: needsUpgrade, enumerable: false, writable: true })
|
|
527
|
+
return normalizedRecord
|
|
528
|
+
}
|
|
529
|
+
function applyLedgerRecord(record, materialization = 'ledger-reuse') {
|
|
530
|
+
if (record === null || record === undefined) return
|
|
531
|
+
if (record.lastIdentity !== undefined) sessionModel.set(record.sessionId, record.lastIdentity)
|
|
532
|
+
for (const turn of record.turns) addTurn(turn.workspaceId, turn.time, record.sessionId, turn.turn, turn.identity, materialization, turn.seq)
|
|
533
|
+
for (const item of record.usage) {
|
|
534
|
+
const identity = item.identity || identityFromLegacy(item.modelId)
|
|
535
|
+
const dates = dateKeys(item.time)
|
|
536
|
+
adjustUsage(item.workspaceId, item.time, item.values, identity, 1, record.sessionId, dates)
|
|
537
|
+
usageByStep.set(item.key, { seq: item.seq, wsId: item.workspaceId, time: item.time, date: dates.local, dateUtc: dates.utc, values: item.values, identity, modelId: identity.label, turn: item.turn, step: item.step, materialization, sid: record.sessionId })
|
|
538
|
+
}
|
|
539
|
+
if (record.turns.length > 0 || record.usage.length > 0) {
|
|
540
|
+
sessionCount.add(record.sessionId)
|
|
541
|
+
if (record.usage.length > 0) markStatsChanged()
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
function ledgerRank(record) {
|
|
545
|
+
return [typeof record.lastSeq === 'number' ? record.lastSeq : -1, record.source === 'flush' ? 1 : 0, typeof record.updatedAt === 'number' ? record.updatedAt : 0]
|
|
546
|
+
}
|
|
547
|
+
function replaceLedgerRecord(record) {
|
|
548
|
+
ledgerRecords.set(record.sessionId, record)
|
|
549
|
+
return record
|
|
550
|
+
}
|
|
551
|
+
function storeLedgerRecord(record) {
|
|
552
|
+
const current = ledgerRecords.get(record.sessionId)
|
|
553
|
+
if (current !== undefined) {
|
|
554
|
+
const nextRank = ledgerRank(record)
|
|
555
|
+
const currentRank = ledgerRank(current)
|
|
556
|
+
if (nextRank[0] < currentRank[0] || (nextRank[0] === currentRank[0] && (nextRank[1] < currentRank[1] || (nextRank[1] === currentRank[1] && nextRank[2] <= currentRank[2])))) return current
|
|
557
|
+
}
|
|
558
|
+
ledgerRecords.set(record.sessionId, record)
|
|
559
|
+
return record
|
|
560
|
+
}
|
|
561
|
+
function persistLedgerRecord(record) {
|
|
562
|
+
if (ledgerUnit === null || record === null || record === undefined) return ledgerWriteChain
|
|
563
|
+
const write = ledgerWriteChain.then(async () => {
|
|
564
|
+
if (ledgerUnit !== null && ledgerRecords.get(record.sessionId) === record) await ledgerUnit.putRecord('sessions', record.sessionId, record)
|
|
565
|
+
})
|
|
566
|
+
ledgerWriteChain = write.catch((err) => {
|
|
567
|
+
console.error('[all-usage] usage ledger write failed:', err)
|
|
568
|
+
})
|
|
569
|
+
return ledgerWriteChain
|
|
570
|
+
}
|
|
571
|
+
function foldEvent(wsId, time, type, data, sid, seq, materialization = 'live') {
|
|
572
|
+
if (type === 'request/context' || type === 'request/header') {
|
|
573
|
+
sessionModel.set(sid, identityFromRoute(data, sessionModel.get(sid)))
|
|
574
|
+
} else if (type === 'turn/end') {
|
|
575
|
+
addTurn(wsId, time, sid, data && typeof data.turn === 'number' ? data.turn : null, sessionModel.get(sid), materialization, seq)
|
|
576
|
+
} else if (type === 'assistant/message' && data && data.usage) {
|
|
577
|
+
const identity = identityFromMessage(data, sessionModel.get(sid))
|
|
578
|
+
sessionModel.set(sid, identity)
|
|
579
|
+
addUsage(wsId, time, data.usage, identity, sid, data, seq, materialization)
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
function foldEvents(wsId, events, fromSeq, sid, materialization = 'scan') {
|
|
583
|
+
for (const ev of events) {
|
|
584
|
+
if (fromSeq !== undefined) {
|
|
585
|
+
const s = typeof ev.seq === 'number' ? ev.seq : -1
|
|
586
|
+
if (s <= fromSeq) continue
|
|
587
|
+
}
|
|
588
|
+
if (ev.type === 'turn/end' || ev.type === 'assistant/message' || ev.type === 'request/context' || ev.type === 'request/header') foldEvent(wsId, ev.time, ev.type, ev.data, sid, ev.seq, materialization)
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
function lastSeqOf(events) {
|
|
592
|
+
let last = 0
|
|
593
|
+
for (const ev of events) {
|
|
594
|
+
const s = typeof ev.seq === 'number' ? ev.seq : -1
|
|
595
|
+
if (s > last) last = s
|
|
596
|
+
}
|
|
597
|
+
return last
|
|
598
|
+
}
|
|
599
|
+
function sequenceProfile(events) {
|
|
600
|
+
let previous = -1
|
|
601
|
+
let last = 0
|
|
602
|
+
let nonMonotonic = false
|
|
603
|
+
for (const ev of events) {
|
|
604
|
+
const seq = ev && typeof ev.seq === 'number' ? ev.seq : -1
|
|
605
|
+
if (seq < 0) continue
|
|
606
|
+
if (seq <= previous) nonMonotonic = true
|
|
607
|
+
previous = seq
|
|
608
|
+
if (seq > last) last = seq
|
|
609
|
+
}
|
|
610
|
+
return { lastSeq: last, nonMonotonic }
|
|
611
|
+
}
|
|
612
|
+
function enqueue(sid, task) {
|
|
613
|
+
const prev = chains.get(sid) || Promise.resolve()
|
|
614
|
+
const next = prev.then(() => task(), () => task())
|
|
615
|
+
chains.set(sid, next)
|
|
616
|
+
return next
|
|
617
|
+
}
|
|
618
|
+
function wsForLiveSession(session, sid) {
|
|
619
|
+
let wsId = memberOf.get(sid)
|
|
620
|
+
if (wsId !== undefined) return wsId
|
|
621
|
+
const header = session && session.header
|
|
622
|
+
const cwd = header && typeof header.cwd === 'string' ? header.cwd : ''
|
|
623
|
+
if (cwd === '') return undefined
|
|
624
|
+
wsId = pathIndex.get(cwd)
|
|
625
|
+
if (wsId !== undefined) memberOf.set(sid, wsId)
|
|
626
|
+
return wsId
|
|
627
|
+
}
|
|
628
|
+
async function processLiveEvent(sid, wsId, event, generation = aggregationGeneration) {
|
|
629
|
+
if (disposed || generation !== aggregationGeneration) return
|
|
630
|
+
const seq = typeof event.seq === 'number' ? event.seq : -1
|
|
631
|
+
const last = sessionSeq.get(sid)
|
|
632
|
+
if (last === undefined) {
|
|
633
|
+
try {
|
|
634
|
+
const snap = await ctx.sessionQuery.readSession(sid)
|
|
635
|
+
if (disposed || generation !== aggregationGeneration) return
|
|
636
|
+
if (snap && Array.isArray(snap.events)) {
|
|
637
|
+
foldEvents(wsId, snap.events, undefined, sid, 'live')
|
|
638
|
+
sessionSeq.set(sid, lastSeqOf(snap.events))
|
|
639
|
+
sessionCount.add(sid)
|
|
640
|
+
}
|
|
641
|
+
} catch (err) { /* retry on the next event */ }
|
|
642
|
+
return
|
|
643
|
+
}
|
|
644
|
+
if (seq <= last) return
|
|
645
|
+
if (seq > last + 1) {
|
|
646
|
+
try {
|
|
647
|
+
const snap = await ctx.sessionQuery.readSession(sid)
|
|
648
|
+
if (disposed || generation !== aggregationGeneration) return
|
|
649
|
+
if (snap && Array.isArray(snap.events)) {
|
|
650
|
+
foldEvents(wsId, snap.events, last, sid, 'live')
|
|
651
|
+
sessionSeq.set(sid, lastSeqOf(snap.events))
|
|
652
|
+
}
|
|
653
|
+
} catch (err) { /* keep last; retry later */ }
|
|
654
|
+
return
|
|
655
|
+
}
|
|
656
|
+
foldEvent(wsId, event.time, event.type, event.data, sid, event.seq, 'live')
|
|
657
|
+
sessionSeq.set(sid, seq)
|
|
658
|
+
sessionCount.add(sid)
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// ---------- durable usage ledger ----------
|
|
662
|
+
async function loadLedger() {
|
|
663
|
+
if (storage === undefined || storage.backend === undefined || typeof storage.backend.get !== 'function') return
|
|
664
|
+
try {
|
|
665
|
+
const backend = storage.backend.get('json')
|
|
666
|
+
if (backend === undefined || backend === null || backend.kv === undefined) return
|
|
667
|
+
const unit = await backend.kv.open({ name: 'all_usage_ledger', version: 0, tables: ['sessions'], hasGlobal: false })
|
|
668
|
+
if (disposed) { await unit.close().catch(() => {}); return }
|
|
669
|
+
ledgerUnit = unit
|
|
670
|
+
const snapshot = await unit.loadAll()
|
|
671
|
+
const rows = snapshot && snapshot.tables && snapshot.tables.sessions
|
|
672
|
+
if (rows !== null && rows !== undefined && typeof rows === 'object') {
|
|
673
|
+
for (const [key, raw] of Object.entries(rows)) {
|
|
674
|
+
const record = normalizeLedgerRecord(raw, key)
|
|
675
|
+
if (record === null) console.warn('[all-usage] ignoring malformed usage ledger row:', key)
|
|
676
|
+
else ledgerRecords.set(key, record)
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
} catch (err) {
|
|
680
|
+
console.error('[all-usage] usage ledger unavailable:', err)
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// ---------- baseline scan over durable logs ----------
|
|
685
|
+
function scheduleNativeBaselineRetry(generation, delay) {
|
|
686
|
+
if (disposed || baselineFallbackTimer !== null) return
|
|
687
|
+
baselineFallbackTimer = setTimeout(() => {
|
|
688
|
+
baselineFallbackTimer = null
|
|
689
|
+
if (!disposed && generation === aggregationGeneration && !scan.started && !scan.done) void runBaseline(generation)
|
|
690
|
+
}, delay)
|
|
691
|
+
if (baselineFallbackTimer && typeof baselineFallbackTimer.unref === 'function') baselineFallbackTimer.unref()
|
|
692
|
+
}
|
|
693
|
+
function scheduleBaselineRetry(generation = aggregationGeneration) {
|
|
694
|
+
if (disposed || baselineRetryScheduled || scan.done || generation !== aggregationGeneration) return
|
|
695
|
+
baselineRetryScheduled = true
|
|
696
|
+
const delay = baselineRetryDelay
|
|
697
|
+
baselineRetryDelay = Math.min(baselineRetryDelay * 2, 30000)
|
|
698
|
+
void safeContextTimeout(delay).then((ready) => {
|
|
699
|
+
baselineRetryScheduled = false
|
|
700
|
+
if (ready && generation === aggregationGeneration && !scan.started && !scan.done) return runBaseline(generation)
|
|
701
|
+
if (!ready && generation === aggregationGeneration && !disposed) scheduleNativeBaselineRetry(generation, delay)
|
|
702
|
+
return undefined
|
|
703
|
+
})
|
|
704
|
+
}
|
|
705
|
+
async function runBaseline(generation = aggregationGeneration) {
|
|
706
|
+
if (scan.started || disposed || generation !== aggregationGeneration) return
|
|
707
|
+
scan.started = true
|
|
708
|
+
beginSync()
|
|
709
|
+
await ledgerReady
|
|
710
|
+
if (disposed || generation !== aggregationGeneration) return
|
|
711
|
+
let setupFailed = false
|
|
712
|
+
try {
|
|
713
|
+
const workspaces = ctx.workspaceRegistry.list()
|
|
714
|
+
for (const w of workspaces) {
|
|
715
|
+
const id = w && w.id
|
|
716
|
+
const path = w && typeof w.path === 'string' ? w.path : ''
|
|
717
|
+
const title = w && typeof w.title === 'string' ? w.title : ''
|
|
718
|
+
if (id === undefined) continue
|
|
719
|
+
wsMeta.set(id, { id, title, path })
|
|
720
|
+
if (path !== '') pathIndex.set(path, id)
|
|
721
|
+
if (w && Array.isArray(w.sessionIds)) {
|
|
722
|
+
for (const sid of w.sessionIds) memberOf.set(sid, id)
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
} catch (err) {
|
|
726
|
+
console.error('[all-usage] workspace list failed:', err)
|
|
727
|
+
setupFailed = true
|
|
728
|
+
noteSyncError('workspace-list-failed')
|
|
729
|
+
}
|
|
730
|
+
let records = null
|
|
731
|
+
try {
|
|
732
|
+
records = await ctx.sessionQuery.listSessions()
|
|
733
|
+
} catch (err) {
|
|
734
|
+
console.error('[all-usage] session list failed:', err)
|
|
735
|
+
if (disposed || generation !== aggregationGeneration) return
|
|
736
|
+
noteSyncError('session-list-failed')
|
|
737
|
+
}
|
|
738
|
+
if (disposed || generation !== aggregationGeneration) return
|
|
739
|
+
// v1.0.8: cheap per-session change signal (header line + stat, no full-log read)
|
|
740
|
+
let snapshots = null
|
|
741
|
+
if (sessionPersistence !== undefined && typeof sessionPersistence.listSnapshots === 'function') {
|
|
742
|
+
try {
|
|
743
|
+
const rows = await sessionPersistence.listSnapshots()
|
|
744
|
+
if (disposed || generation !== aggregationGeneration) return
|
|
745
|
+
if (Array.isArray(rows)) {
|
|
746
|
+
sync.persistenceSnapshotsAvailable = true
|
|
747
|
+
snapshots = new Map()
|
|
748
|
+
for (const row of rows) {
|
|
749
|
+
const rid = row && row.header && typeof row.header.id === 'string' ? row.header.id : undefined
|
|
750
|
+
if (rid !== undefined && row && typeof row.revision === 'string') snapshots.set(rid, row.revision)
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
} catch (err) {
|
|
754
|
+
console.error('[all-usage] session persistence snapshots unavailable:', err)
|
|
755
|
+
if (disposed || generation !== aggregationGeneration) return
|
|
756
|
+
sync.persistenceSnapshotsAvailable = false
|
|
757
|
+
markStatsChanged()
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
if (disposed || generation !== aggregationGeneration) return
|
|
761
|
+
if (setupFailed || !Array.isArray(records)) {
|
|
762
|
+
// A transient registry failure must not be reported as a completed empty scan.
|
|
763
|
+
scan.started = false
|
|
764
|
+
markStatsChanged()
|
|
765
|
+
scheduleBaselineRetry(generation)
|
|
766
|
+
return
|
|
767
|
+
}
|
|
768
|
+
scan.total = records.length
|
|
769
|
+
sync.sessionsTotal = records.length
|
|
770
|
+
markStatsChanged()
|
|
771
|
+
const listedSessionIds = new Set()
|
|
772
|
+
for (const record of records) {
|
|
773
|
+
if (record === undefined || record === null || record.header === undefined) continue
|
|
774
|
+
const sid = record.header.id
|
|
775
|
+
const cwd = typeof record.header.cwd === 'string' ? record.header.cwd : ''
|
|
776
|
+
const wsId = cwd === '' ? undefined : pathIndex.get(cwd)
|
|
777
|
+
if (sid !== undefined && wsId !== undefined) listedSessionIds.add(sid)
|
|
778
|
+
}
|
|
779
|
+
for (const [sid, record] of ledgerRecords) {
|
|
780
|
+
if (!listedSessionIds.has(sid)) {
|
|
781
|
+
applyLedgerRecord(record, 'ledger-recovery')
|
|
782
|
+
if (record.turns.length > 0 || record.usage.length > 0) sync.sessionsRestoredFromLedger += 1
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
if (sync.sessionsRestoredFromLedger > 0) markStatsChanged()
|
|
786
|
+
for (const record of records) {
|
|
787
|
+
if (disposed || generation !== aggregationGeneration) return
|
|
788
|
+
if (record === undefined || record === null || record.header === undefined) {
|
|
789
|
+
scan.scanned += 1
|
|
790
|
+
markStatsChanged()
|
|
791
|
+
continue
|
|
792
|
+
}
|
|
793
|
+
const sid = record.header.id
|
|
794
|
+
const cwd = typeof record.header.cwd === 'string' ? record.header.cwd : ''
|
|
795
|
+
const wsId = cwd === '' ? undefined : pathIndex.get(cwd)
|
|
796
|
+
if (sid === undefined || wsId === undefined) {
|
|
797
|
+
scan.scanned += 1
|
|
798
|
+
markStatsChanged()
|
|
799
|
+
continue
|
|
800
|
+
}
|
|
801
|
+
listedSessionIds.add(sid)
|
|
802
|
+
await enqueue(sid, async () => {
|
|
803
|
+
if (disposed || generation !== aggregationGeneration) return
|
|
804
|
+
try {
|
|
805
|
+
if (sessionSeq.has(sid)) return
|
|
806
|
+
// v1.0.8: when the persisted log revision is unchanged since the last ledger
|
|
807
|
+
// write, the whole readSession (full event transfer) is skipped — the ledger
|
|
808
|
+
// record is applied directly and the live feed keeps catching new events.
|
|
809
|
+
const previousRecord = ledgerRecords.get(sid)
|
|
810
|
+
const revision = snapshots === null ? undefined : snapshots.get(sid)
|
|
811
|
+
if (previousRecord !== undefined && previousRecord.needsUpgrade !== true && typeof previousRecord.lastRevision === 'string' && typeof revision === 'string' && revision === previousRecord.lastRevision) {
|
|
812
|
+
sync.sessionsSkippedByRevision += 1
|
|
813
|
+
applyLedgerRecord(previousRecord, 'ledger-reuse')
|
|
814
|
+
sessionSeq.set(sid, previousRecord.lastSeq)
|
|
815
|
+
sessionCount.add(sid)
|
|
816
|
+
markStatsChanged()
|
|
817
|
+
return
|
|
818
|
+
}
|
|
819
|
+
sync.sessionsRead += 1
|
|
820
|
+
markStatsChanged()
|
|
821
|
+
const snap = await ctx.sessionQuery.readSession(sid)
|
|
822
|
+
if (disposed || generation !== aggregationGeneration) return
|
|
823
|
+
if (snap && Array.isArray(snap.events)) {
|
|
824
|
+
// v1.0.7: incremental seed — the durable ledger doubles as a per-session
|
|
825
|
+
// cursor (cc-switch session_log_sync mtime+offset parity). An unchanged
|
|
826
|
+
// session applies its canonical record directly and never re-folds;
|
|
827
|
+
// a changed session seeds the previous record once, then folds only the
|
|
828
|
+
// new tail (previously every listed session was re-read and fully rebuilt).
|
|
829
|
+
const sequence = sequenceProfile(snap.events)
|
|
830
|
+
const currentLastSeq = sequence.lastSeq
|
|
831
|
+
const previous = ledgerRecords.get(sid)
|
|
832
|
+
const canFoldTail = previous !== undefined && previous.needsUpgrade !== true && !sequence.nonMonotonic && previous.lastSeq >= 0 && currentLastSeq > previous.lastSeq
|
|
833
|
+
if (canFoldTail) {
|
|
834
|
+
applyLedgerRecord(previous, 'ledger-reuse')
|
|
835
|
+
foldEvents(wsId, snap.events, previous.lastSeq, sid, 'scan')
|
|
836
|
+
} else {
|
|
837
|
+
// A changed revision with no new tail may still contain a replacement;
|
|
838
|
+
// rebuild from the complete read instead of trusting lastSeq alone.
|
|
839
|
+
foldEvents(wsId, snap.events, undefined, sid, 'scan')
|
|
840
|
+
}
|
|
841
|
+
const ledger = buildLedgerRecord({ id: sid, header: record.header, events: snap.events }, wsId, 'scan', revision)
|
|
842
|
+
const canonical = ledger === null ? ledgerRecords.get(sid) : (canFoldTail ? storeLedgerRecord(ledger) : replaceLedgerRecord(ledger))
|
|
843
|
+
if (canonical === ledger) {
|
|
844
|
+
void persistLedgerRecord(ledger)
|
|
845
|
+
}
|
|
846
|
+
sessionSeq.set(sid, currentLastSeq)
|
|
847
|
+
sessionCount.add(sid)
|
|
848
|
+
}
|
|
849
|
+
} catch (err) {
|
|
850
|
+
if (generation !== aggregationGeneration) return
|
|
851
|
+
sync.sessionsFailed += 1
|
|
852
|
+
noteSyncError('session-read-failed')
|
|
853
|
+
const saved = ledgerRecords.get(sid)
|
|
854
|
+
if (saved !== undefined) {
|
|
855
|
+
applyLedgerRecord(saved, 'ledger-recovery')
|
|
856
|
+
if (saved.turns.length > 0 || saved.usage.length > 0) sync.sessionsRestoredFromLedger += 1
|
|
857
|
+
sessionSeq.set(sid, -1)
|
|
858
|
+
sessionCount.add(sid)
|
|
859
|
+
} else {
|
|
860
|
+
sessionSeq.set(sid, -1)
|
|
861
|
+
scan.failed += 1
|
|
862
|
+
}
|
|
863
|
+
} finally {
|
|
864
|
+
if (generation === aggregationGeneration) {
|
|
865
|
+
scan.scanned += 1
|
|
866
|
+
markStatsChanged()
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
})
|
|
870
|
+
if (!(await safeContextTimeout(0))) {
|
|
871
|
+
scan.started = false
|
|
872
|
+
noteSyncError('baseline-yield-unavailable')
|
|
873
|
+
scheduleBaselineRetry(generation)
|
|
874
|
+
return
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
if (disposed || generation !== aggregationGeneration) return
|
|
878
|
+
await ledgerWriteChain
|
|
879
|
+
if (disposed || generation !== aggregationGeneration) return
|
|
880
|
+
knownSessionIds.clear()
|
|
881
|
+
for (const sid of listedSessionIds) knownSessionIds.add(sid)
|
|
882
|
+
scan.done = true
|
|
883
|
+
sync.lastCompletedAt = Date.now()
|
|
884
|
+
if (sync.sessionsFailed === 0) {
|
|
885
|
+
sync.lastErrorAt = 0
|
|
886
|
+
sync.lastErrorCode = null
|
|
887
|
+
}
|
|
888
|
+
markStatsChanged()
|
|
889
|
+
if (reconcilePending) scheduleReconcileHint()
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
function sessionIdsFromRecords(records) {
|
|
893
|
+
const ids = new Set()
|
|
894
|
+
for (const record of records) {
|
|
895
|
+
if (record === undefined || record === null || record.header === undefined) continue
|
|
896
|
+
const sid = record.header.id
|
|
897
|
+
const cwd = typeof record.header.cwd === 'string' ? record.header.cwd : ''
|
|
898
|
+
const wsId = cwd === '' ? undefined : pathIndex.get(cwd)
|
|
899
|
+
if (sid !== undefined && wsId !== undefined) ids.add(sid)
|
|
900
|
+
}
|
|
901
|
+
return ids
|
|
902
|
+
}
|
|
903
|
+
async function reconcileSessions() {
|
|
904
|
+
if (disposed || reconcileInFlight || !scan.done) return
|
|
905
|
+
reconcilePending = false
|
|
906
|
+
reconcileInFlight = true
|
|
907
|
+
try {
|
|
908
|
+
const records = await ctx.sessionQuery.listSessions()
|
|
909
|
+
if (disposed || !Array.isArray(records)) return
|
|
910
|
+
const currentIds = sessionIdsFromRecords(records)
|
|
911
|
+
let removed = false
|
|
912
|
+
for (const sid of knownSessionIds) {
|
|
913
|
+
if (!currentIds.has(sid)) { removed = true; break }
|
|
914
|
+
}
|
|
915
|
+
if (removed && !disposed && scan.done) {
|
|
916
|
+
console.info('[all-usage] session removal detected; rebuilding usage index')
|
|
917
|
+
const generation = resetAggregationState()
|
|
918
|
+
void runBaseline(generation)
|
|
919
|
+
return
|
|
920
|
+
}
|
|
921
|
+
knownSessionIds.clear()
|
|
922
|
+
for (const sid of currentIds) knownSessionIds.add(sid)
|
|
923
|
+
} catch (err) {
|
|
924
|
+
console.error('[all-usage] session reconciliation failed:', err)
|
|
925
|
+
noteSyncError('session-reconcile-failed')
|
|
926
|
+
} finally {
|
|
927
|
+
reconcileInFlight = false
|
|
928
|
+
if (reconcilePending && !disposed) scheduleReconcileHint()
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
function scheduleReconcileHint() {
|
|
932
|
+
if (disposed) return
|
|
933
|
+
reconcilePending = true
|
|
934
|
+
if (reconcileHintScheduled || reconcileInFlight) return
|
|
935
|
+
reconcileHintScheduled = true
|
|
936
|
+
void safeContextTimeout(RECONCILE_HINT_DELAY_MS).then((ready) => {
|
|
937
|
+
reconcileHintScheduled = false
|
|
938
|
+
if (ready && !disposed) void reconcileSessions()
|
|
939
|
+
}, () => {
|
|
940
|
+
reconcileHintScheduled = false
|
|
941
|
+
})
|
|
942
|
+
}
|
|
943
|
+
function scheduleReconcileTimer() {
|
|
944
|
+
if (disposed || reconcileTimer !== null) return
|
|
945
|
+
reconcileTimer = setTimeout(() => {
|
|
946
|
+
reconcileTimer = null
|
|
947
|
+
if (!disposed) {
|
|
948
|
+
void reconcileSessions()
|
|
949
|
+
scheduleReconcileTimer()
|
|
950
|
+
}
|
|
951
|
+
}, RECONCILE_INTERVAL_MS)
|
|
952
|
+
if (reconcileTimer && typeof reconcileTimer.unref === 'function') reconcileTimer.unref()
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
// ---------- live feed ----------
|
|
956
|
+
ctx.on('session/event', (session, event) => {
|
|
957
|
+
if (disposed) return
|
|
958
|
+
if (event === undefined || event === null) return
|
|
959
|
+
const type = event.type
|
|
960
|
+
if (type !== 'turn/end' && type !== 'assistant/message' && type !== 'request/context' && type !== 'request/header') return
|
|
961
|
+
const sid = session && session.id
|
|
962
|
+
if (typeof sid !== 'string') return
|
|
963
|
+
const wsId = wsForLiveSession(session, sid)
|
|
964
|
+
if (wsId === undefined) return
|
|
965
|
+
const generation = aggregationGeneration
|
|
966
|
+
knownSessionIds.add(sid)
|
|
967
|
+
enqueue(sid, () => processLiveEvent(sid, wsId, event, generation))
|
|
968
|
+
})
|
|
969
|
+
ctx.on('session/flush', async (session) => {
|
|
970
|
+
if (disposed || session === null || typeof session !== 'object' || typeof session.id !== 'string') return
|
|
971
|
+
await ledgerReady
|
|
972
|
+
if (disposed) return
|
|
973
|
+
const wsId = wsForLiveSession(session, session.id)
|
|
974
|
+
if (wsId === undefined) return
|
|
975
|
+
const ledger = buildLedgerRecord(session, wsId, 'flush')
|
|
976
|
+
if (ledger === null) return
|
|
977
|
+
const canonical = storeLedgerRecord(ledger)
|
|
978
|
+
if (canonical === ledger) await persistLedgerRecord(ledger)
|
|
979
|
+
})
|
|
980
|
+
ctx.on('session/disposed', () => {
|
|
981
|
+
if (!disposed) scheduleReconcileHint()
|
|
982
|
+
})
|
|
983
|
+
|
|
984
|
+
// ---------- workspace aliases (durable, schema-free KV unit) ----------
|
|
985
|
+
async function loadAliases() {
|
|
986
|
+
if (storage === undefined) return
|
|
987
|
+
try {
|
|
988
|
+
const backend = storage.backend.get('json')
|
|
989
|
+
if (backend === undefined || backend === null || backend.kv === undefined) return
|
|
990
|
+
const unit = await backend.kv.open({ name: 'all_usage_aliases', version: 0, tables: [], hasGlobal: true })
|
|
991
|
+
kvUnit = unit
|
|
992
|
+
const snap = await unit.loadAll()
|
|
993
|
+
const g = snap && snap.global
|
|
994
|
+
if (g !== null && g !== undefined && typeof g === 'object') {
|
|
995
|
+
for (const key of Object.keys(g)) {
|
|
996
|
+
const value = g[key]
|
|
997
|
+
if (typeof value === 'string' && value.trim() !== '') aliases[key] = value
|
|
998
|
+
}
|
|
999
|
+
markStatsChanged()
|
|
1000
|
+
}
|
|
1001
|
+
} catch (err) {
|
|
1002
|
+
console.error('[all-usage] alias storage unavailable:', err)
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
function persistAliases() {
|
|
1006
|
+
const snapshotAliases = {}
|
|
1007
|
+
for (const key of Object.keys(aliases)) snapshotAliases[key] = aliases[key]
|
|
1008
|
+
aliasWriteChain = aliasWriteChain.then(() => {
|
|
1009
|
+
if (kvUnit === null || kvUnit === undefined) return undefined
|
|
1010
|
+
return kvUnit.setGlobal(snapshotAliases).catch((err) => {
|
|
1011
|
+
console.error('[all-usage] alias persist failed:', err)
|
|
1012
|
+
})
|
|
1013
|
+
})
|
|
1014
|
+
}
|
|
1015
|
+
function setAlias(wsId, raw) {
|
|
1016
|
+
const alias = typeof raw === 'string' ? raw.trim().slice(0, 80) : ''
|
|
1017
|
+
if (!wsMeta.has(wsId)) return { ok: false, message: 'unknown-workspace', aliases: Object.assign({}, aliases) }
|
|
1018
|
+
if (alias === '') delete aliases[wsId]
|
|
1019
|
+
else aliases[wsId] = alias
|
|
1020
|
+
persistAliases()
|
|
1021
|
+
markStatsChanged()
|
|
1022
|
+
return { ok: true, aliases: Object.assign({}, aliases) }
|
|
1023
|
+
}
|
|
1024
|
+
ctx.effect(() => () => {
|
|
1025
|
+
disposed = true
|
|
1026
|
+
if (reconcileTimer !== null) {
|
|
1027
|
+
clearTimeout(reconcileTimer)
|
|
1028
|
+
reconcileTimer = null
|
|
1029
|
+
}
|
|
1030
|
+
if (baselineFallbackTimer !== null) {
|
|
1031
|
+
clearTimeout(baselineFallbackTimer)
|
|
1032
|
+
baselineFallbackTimer = null
|
|
1033
|
+
}
|
|
1034
|
+
})
|
|
1035
|
+
ctx.effect(() => () => {
|
|
1036
|
+
const unit = kvUnit
|
|
1037
|
+
kvUnit = null
|
|
1038
|
+
if (unit !== null && unit !== undefined) void unit.close().catch(() => {})
|
|
1039
|
+
})
|
|
1040
|
+
ctx.effect(() => async () => {
|
|
1041
|
+
const unit = ledgerUnit
|
|
1042
|
+
await ledgerWriteChain
|
|
1043
|
+
if (unit !== null && unit !== undefined) await unit.close().catch(() => {})
|
|
1044
|
+
if (ledgerUnit === unit) ledgerUnit = null
|
|
1045
|
+
})
|
|
1046
|
+
|
|
1047
|
+
// ---------- snapshot for the client ----------
|
|
1048
|
+
function scanSnapshot() {
|
|
1049
|
+
return { started: scan.started, done: scan.done, scanned: scan.scanned, total: scan.total, failed: scan.failed }
|
|
1050
|
+
}
|
|
1051
|
+
function statusSnapshot() {
|
|
1052
|
+
return { instanceId, revision: statsRevision, updatedAt: statsUpdatedAt, scan: scanSnapshot(), sync: syncSnapshot() }
|
|
1053
|
+
}
|
|
1054
|
+
function serializeIdentity(identity) {
|
|
1055
|
+
const value = coerceIdentity(identity)
|
|
1056
|
+
return { identityKey: value.identityKey, provider: value.provider, requestedModel: value.requestedModel, actualModel: value.actualModel, model: value.label, legacy: value.legacy }
|
|
1057
|
+
}
|
|
1058
|
+
function serializeModelAggregate(item) {
|
|
1059
|
+
return { ...serializeIdentity(item), calls: item.calls, input: item.input, output: item.output, cacheRead: item.cacheRead, cacheWrite: item.cacheWrite, reasoning: item.reasoning }
|
|
1060
|
+
}
|
|
1061
|
+
function serializeDays(dayMap) {
|
|
1062
|
+
const result = []
|
|
1063
|
+
for (const pair of dayMap) {
|
|
1064
|
+
const date = pair[0]
|
|
1065
|
+
const day = pair[1]
|
|
1066
|
+
result.push({
|
|
1067
|
+
date,
|
|
1068
|
+
turns: day.turns,
|
|
1069
|
+
sessions: day.sessionIds.size,
|
|
1070
|
+
sessionIds: Array.from(day.sessionIds).sort(),
|
|
1071
|
+
tokens: { input: day.tokens.input, output: day.tokens.output, cacheRead: day.tokens.cacheRead, cacheWrite: day.tokens.cacheWrite, reasoning: day.tokens.reasoning },
|
|
1072
|
+
perWorkspace: Array.from(day.perWs, (p) => ({ workspaceId: p[0], turns: p[1] })),
|
|
1073
|
+
byWorkspace: Array.from(day.byWs, (p) => ({ workspaceId: p[0], input: p[1].input, output: p[1].output, cacheRead: p[1].cacheRead, cacheWrite: p[1].cacheWrite, reasoning: p[1].reasoning })),
|
|
1074
|
+
byModel: Array.from(day.byModel, (p) => serializeModelAggregate(p[1])),
|
|
1075
|
+
})
|
|
1076
|
+
}
|
|
1077
|
+
result.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0))
|
|
1078
|
+
return result
|
|
1079
|
+
}
|
|
1080
|
+
function snapshot() {
|
|
1081
|
+
return {
|
|
1082
|
+
...statusSnapshot(),
|
|
1083
|
+
generatedAt: Date.now(),
|
|
1084
|
+
usageSchemaVersion: 2,
|
|
1085
|
+
requestToken,
|
|
1086
|
+
workspaces: Array.from(wsMeta.values(), (w) => ({ id: w.id, title: w.title, path: w.path })),
|
|
1087
|
+
aliases: Object.assign({}, aliases),
|
|
1088
|
+
tokenSemantics: {
|
|
1089
|
+
processedTotal: 'input + output + cacheRead + cacheWrite + reasoning',
|
|
1090
|
+
cacheRead: 'reused context tokens; not newly generated output',
|
|
1091
|
+
cacheWrite: 'tokens written into a provider cache',
|
|
1092
|
+
// v1.0.7: structured, machine-readable accounting semantics (cc-switch
|
|
1093
|
+
// input_token_semantics parity). DSH reports input as fresh (cache read /
|
|
1094
|
+
// cache write sit in their own buckets) — verified against real ledger data;
|
|
1095
|
+
// reasoning is bucketed separately from output and assumed non-overlapping.
|
|
1096
|
+
semantics: {
|
|
1097
|
+
input: 'fresh (excludes cache-read and cache-write tokens, bucketed separately)',
|
|
1098
|
+
buckets: ['input', 'output', 'cacheRead', 'cacheWrite', 'reasoning'],
|
|
1099
|
+
inputIncludesCache: false,
|
|
1100
|
+
cacheBucketed: true,
|
|
1101
|
+
reasoningSeparate: true,
|
|
1102
|
+
gate: 'all-zero usage rows are ignored; pure cache-read requests still count',
|
|
1103
|
+
},
|
|
1104
|
+
},
|
|
1105
|
+
totals: { turns: totals.turns, sessions: sessionCount.size, input: totals.input, output: totals.output, cacheRead: totals.cacheRead, cacheWrite: totals.cacheWrite, reasoning: totals.reasoning },
|
|
1106
|
+
perWorkspace: Array.from(perWorkspace, (p) => ({ workspaceId: p[0], turns: p[1].turns, input: p[1].input, output: p[1].output, cacheRead: p[1].cacheRead, cacheWrite: p[1].cacheWrite, reasoning: p[1].reasoning })),
|
|
1107
|
+
perModel: Array.from(perModel.values(), (item) => serializeModelAggregate(item)),
|
|
1108
|
+
byDay: serializeDays(byDay),
|
|
1109
|
+
byDayUtc: serializeDays(byDayUtc),
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
function validDateText(value) {
|
|
1114
|
+
if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false
|
|
1115
|
+
const year = Number(value.slice(0, 4))
|
|
1116
|
+
const month = Number(value.slice(5, 7))
|
|
1117
|
+
const day = Number(value.slice(8, 10))
|
|
1118
|
+
const date = new Date(Date.UTC(year, month - 1, day))
|
|
1119
|
+
return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day
|
|
1120
|
+
}
|
|
1121
|
+
function shiftDateText(value, days, utc) {
|
|
1122
|
+
const parts = value.split('-').map(Number)
|
|
1123
|
+
const date = utc ? new Date(Date.UTC(parts[0], parts[1] - 1, parts[2] + days)) : new Date(parts[0], parts[1] - 1, parts[2] + days)
|
|
1124
|
+
return utc ? dayKeyUtc(date.getTime()) : dayKey(date.getTime())
|
|
1125
|
+
}
|
|
1126
|
+
function queryScopeFromRequest(req) {
|
|
1127
|
+
let url
|
|
1128
|
+
try { url = new URL(req.url || '/', 'http://all-usage.local') } catch (err) { return { ok: false, message: 'bad-query' } }
|
|
1129
|
+
const rawUtc = url.searchParams.get('utc')
|
|
1130
|
+
if (rawUtc !== null && rawUtc !== '' && rawUtc !== '0' && rawUtc !== '1') return { ok: false, message: 'invalid-timezone' }
|
|
1131
|
+
const utc = rawUtc === '1'
|
|
1132
|
+
const today = utc ? dayKeyUtc(Date.now()) : dayKey(Date.now())
|
|
1133
|
+
const start = url.searchParams.get('start') || today
|
|
1134
|
+
const end = url.searchParams.get('end') || today
|
|
1135
|
+
if (!validDateText(start) || !validDateText(end) || start > end) return { ok: false, message: 'invalid-date-range' }
|
|
1136
|
+
const readParam = (name, max) => {
|
|
1137
|
+
const value = url.searchParams.get(name)
|
|
1138
|
+
if (value === null || value === '') return undefined
|
|
1139
|
+
return value.length <= max ? value : null
|
|
1140
|
+
}
|
|
1141
|
+
const workspaceId = readParam('workspaceId', 256)
|
|
1142
|
+
const provider = readParam('provider', 256)
|
|
1143
|
+
const modelKey = readParam('modelKey', 1024)
|
|
1144
|
+
if (workspaceId === null || provider === null || modelKey === null) return { ok: false, message: 'query-too-long' }
|
|
1145
|
+
return { ok: true, scope: { start, end, utc, workspaceId, provider, modelKey } }
|
|
1146
|
+
}
|
|
1147
|
+
function scopeFingerprint(scope) {
|
|
1148
|
+
return JSON.stringify({ start: scope.start, end: scope.end, utc: scope.utc === true, workspaceId: scope.workspaceId || null, provider: scope.provider || null, modelKey: scope.modelKey || null })
|
|
1149
|
+
}
|
|
1150
|
+
function dateInScope(date, scope) {
|
|
1151
|
+
return date >= scope.start && date <= scope.end
|
|
1152
|
+
}
|
|
1153
|
+
function modelNameOfIdentity(identity) {
|
|
1154
|
+
const normalized = coerceIdentity(identity)
|
|
1155
|
+
const structured = normalized.actualModel || normalized.requestedModel
|
|
1156
|
+
if (structured !== null) return structured
|
|
1157
|
+
if (normalized.legacy && typeof normalized.label === 'string') {
|
|
1158
|
+
const separator = normalized.label.indexOf(' / ')
|
|
1159
|
+
if (separator > 0) return normalized.label.slice(separator + 3)
|
|
1160
|
+
}
|
|
1161
|
+
return normalized.label
|
|
1162
|
+
}
|
|
1163
|
+
function identityMatchesScope(identity, scope) {
|
|
1164
|
+
const normalized = coerceIdentity(identity)
|
|
1165
|
+
if (scope.provider !== undefined && scope.provider !== null && normalized.provider !== scope.provider) return false
|
|
1166
|
+
if (scope.modelKey !== undefined && scope.modelKey !== null && normalized.identityKey !== scope.modelKey && modelNameOfIdentity(normalized) !== scope.modelKey) return false
|
|
1167
|
+
return true
|
|
1168
|
+
}
|
|
1169
|
+
function queryMetric() {
|
|
1170
|
+
return { turns: 0, calls: 0, sessions: new Set(), turnKeys: new Set(), input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 }
|
|
1171
|
+
}
|
|
1172
|
+
function queryAggregate() {
|
|
1173
|
+
return { totals: queryMetric(), days: new Map(), workspaces: new Map(), models: new Map() }
|
|
1174
|
+
}
|
|
1175
|
+
function queryDay(aggregate, date) {
|
|
1176
|
+
let day = aggregate.days.get(date)
|
|
1177
|
+
if (day === undefined) { day = queryMetric(); day.date = date; aggregate.days.set(date, day) }
|
|
1178
|
+
return day
|
|
1179
|
+
}
|
|
1180
|
+
function queryWorkspace(aggregate, workspaceId) {
|
|
1181
|
+
let row = aggregate.workspaces.get(workspaceId)
|
|
1182
|
+
if (row === undefined) { row = queryMetric(); row.workspaceId = workspaceId; aggregate.workspaces.set(workspaceId, row) }
|
|
1183
|
+
return row
|
|
1184
|
+
}
|
|
1185
|
+
function queryModel(aggregate, identity) {
|
|
1186
|
+
const normalized = coerceIdentity(identity)
|
|
1187
|
+
let row = aggregate.models.get(normalized.identityKey)
|
|
1188
|
+
if (row === undefined) { row = queryMetric(); Object.assign(row, serializeIdentity(normalized)); aggregate.models.set(normalized.identityKey, row) }
|
|
1189
|
+
return row
|
|
1190
|
+
}
|
|
1191
|
+
function addQueryTokens(metric, values) {
|
|
1192
|
+
metric.input += values.input
|
|
1193
|
+
metric.output += values.output
|
|
1194
|
+
metric.cacheRead += values.cacheRead
|
|
1195
|
+
metric.cacheWrite += values.cacheWrite
|
|
1196
|
+
metric.reasoning += values.reasoning
|
|
1197
|
+
}
|
|
1198
|
+
function addQueryTurn(aggregate, turn, date) {
|
|
1199
|
+
const targets = [aggregate.totals, queryDay(aggregate, date), queryWorkspace(aggregate, turn.wsId)]
|
|
1200
|
+
const model = queryModel(aggregate, turn.identity)
|
|
1201
|
+
targets.push(model)
|
|
1202
|
+
const key = turn.key
|
|
1203
|
+
for (const target of targets) {
|
|
1204
|
+
if (target.turnKeys.has(key)) continue
|
|
1205
|
+
target.turnKeys.add(key)
|
|
1206
|
+
target.turns += 1
|
|
1207
|
+
target.sessions.add(turn.sid)
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
function addQueryUsage(aggregate, item, date) {
|
|
1211
|
+
const targets = [aggregate.totals, queryDay(aggregate, date), queryWorkspace(aggregate, item.wsId)]
|
|
1212
|
+
const model = queryModel(aggregate, item.identity || item.modelId)
|
|
1213
|
+
targets.push(model)
|
|
1214
|
+
for (const target of targets) {
|
|
1215
|
+
target.calls += 1
|
|
1216
|
+
target.sessions.add(item.sid)
|
|
1217
|
+
addQueryTokens(target, item.values)
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
function finalizeQueryMetric(metric) {
|
|
1221
|
+
return { turns: metric.turns, calls: metric.calls, sessions: metric.sessions.size, input: metric.input, output: metric.output, cacheRead: metric.cacheRead, cacheWrite: metric.cacheWrite, reasoning: metric.reasoning }
|
|
1222
|
+
}
|
|
1223
|
+
function finalizeQueryAggregate(aggregate) {
|
|
1224
|
+
const daily = Array.from(aggregate.days.values()).sort((a, b) => a.date < b.date ? -1 : a.date > b.date ? 1 : 0).map((day) => ({ date: day.date, ...finalizeQueryMetric(day), tokens: { input: day.input, output: day.output, cacheRead: day.cacheRead, cacheWrite: day.cacheWrite, reasoning: day.reasoning } }))
|
|
1225
|
+
const perWorkspace = Array.from(aggregate.workspaces.values()).map((row) => ({ workspaceId: row.workspaceId, ...finalizeQueryMetric(row) }))
|
|
1226
|
+
const perModel = Array.from(aggregate.models.values()).filter((row) => row.calls > 0 || row.input > 0 || row.output > 0 || row.cacheRead > 0 || row.cacheWrite > 0 || row.reasoning > 0).map((row) => ({ identityKey: row.identityKey, provider: row.provider, requestedModel: row.requestedModel, actualModel: row.actualModel, model: row.model, legacy: row.legacy, ...finalizeQueryMetric(row) }))
|
|
1227
|
+
return { totals: finalizeQueryMetric(aggregate.totals), daily, perWorkspace, perModel }
|
|
1228
|
+
}
|
|
1229
|
+
const HOUR_MS = 60 * 60 * 1000
|
|
1230
|
+
function hourStartOf(time, utc) {
|
|
1231
|
+
const date = new Date(time)
|
|
1232
|
+
return utc ? Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), date.getUTCHours()) : new Date(date.getFullYear(), date.getMonth(), date.getDate(), date.getHours()).getTime()
|
|
1233
|
+
}
|
|
1234
|
+
function calendarStartOf(dateText, utc) {
|
|
1235
|
+
const parts = dateText.split('-').map(Number)
|
|
1236
|
+
return utc ? Date.UTC(parts[0], parts[1] - 1, parts[2]) : new Date(parts[0], parts[1] - 1, parts[2]).getTime()
|
|
1237
|
+
}
|
|
1238
|
+
function nextCalendarStartOf(dateText, utc) {
|
|
1239
|
+
const parts = dateText.split('-').map(Number)
|
|
1240
|
+
return utc ? Date.UTC(parts[0], parts[1] - 1, parts[2] + 1) : new Date(parts[0], parts[1] - 1, parts[2] + 1).getTime()
|
|
1241
|
+
}
|
|
1242
|
+
function hourlyRangeOf(scope, nowMs) {
|
|
1243
|
+
if (scope.start !== scope.end) return null
|
|
1244
|
+
const start = calendarStartOf(scope.start, scope.utc)
|
|
1245
|
+
const today = scope.utc ? dayKeyUtc(nowMs) : dayKey(nowMs)
|
|
1246
|
+
const end = scope.start === today ? nowMs : nextCalendarStartOf(scope.start, scope.utc)
|
|
1247
|
+
return { start, count: Math.max(1, Math.ceil(Math.max(0, end - start) / HOUR_MS)) }
|
|
1248
|
+
}
|
|
1249
|
+
function serializeTrendMetric(time, metric) {
|
|
1250
|
+
const value = finalizeQueryMetric(metric || queryMetric())
|
|
1251
|
+
return { time, date: new Date(time).toISOString(), ...value, tokens: { input: value.input, output: value.output, cacheRead: value.cacheRead, cacheWrite: value.cacheWrite, reasoning: value.reasoning } }
|
|
1252
|
+
}
|
|
1253
|
+
function queryHourlyTrend(matchingUsage, matchingTurns, scope, nowMs) {
|
|
1254
|
+
const range = hourlyRangeOf(scope, nowMs)
|
|
1255
|
+
if (range === null) return []
|
|
1256
|
+
const buckets = new Map()
|
|
1257
|
+
const metricFor = (time) => {
|
|
1258
|
+
const index = Math.floor((hourStartOf(time, scope.utc) - range.start) / HOUR_MS)
|
|
1259
|
+
if (index < 0 || index >= range.count) return null
|
|
1260
|
+
let metric = buckets.get(index)
|
|
1261
|
+
if (metric === undefined) { metric = queryMetric(); buckets.set(index, metric) }
|
|
1262
|
+
return metric
|
|
1263
|
+
}
|
|
1264
|
+
for (const entry of matchingUsage) {
|
|
1265
|
+
if (!dateInScope(entry.date, scope)) continue
|
|
1266
|
+
const metric = metricFor(entry.item.time)
|
|
1267
|
+
if (metric === null) continue
|
|
1268
|
+
metric.calls += 1
|
|
1269
|
+
metric.sessions.add(entry.item.sid)
|
|
1270
|
+
addQueryTokens(metric, entry.item.values)
|
|
1271
|
+
}
|
|
1272
|
+
for (const entry of matchingTurns) {
|
|
1273
|
+
if (!dateInScope(entry.date, scope)) continue
|
|
1274
|
+
const metric = metricFor(entry.turn.time)
|
|
1275
|
+
if (metric === null || metric.turnKeys.has(entry.turn.key)) continue
|
|
1276
|
+
metric.turnKeys.add(entry.turn.key)
|
|
1277
|
+
metric.turns += 1
|
|
1278
|
+
metric.sessions.add(entry.turn.sid)
|
|
1279
|
+
}
|
|
1280
|
+
return Array.from({ length: range.count }, (_, index) => serializeTrendMetric(range.start + index * HOUR_MS, buckets.get(index)))
|
|
1281
|
+
}
|
|
1282
|
+
function queryUsageScope(scope) {
|
|
1283
|
+
const nowMs = Date.now()
|
|
1284
|
+
const today = scope.utc ? dayKeyUtc(nowMs) : dayKey(nowMs)
|
|
1285
|
+
const hourlyCacheKey = scope.start === scope.end ? ':' + (scope.start === today ? hourStartOf(nowMs, scope.utc) : 'fixed') : ''
|
|
1286
|
+
const key = statsRevision + ':' + scopeFingerprint(scope) + hourlyCacheKey
|
|
1287
|
+
const cached = queryCache.get(key)
|
|
1288
|
+
if (cached !== undefined) return cached
|
|
1289
|
+
const now = new Date(nowMs)
|
|
1290
|
+
const weekday = scope.utc ? now.getUTCDay() : now.getDay()
|
|
1291
|
+
const sunday = shiftDateText(today, -weekday, scope.utc)
|
|
1292
|
+
const heatStart = shiftDateText(sunday, -52 * 7, scope.utc)
|
|
1293
|
+
const matchingUsage = []
|
|
1294
|
+
const matchingTurnKeys = new Set()
|
|
1295
|
+
for (const item of usageByStep.values()) {
|
|
1296
|
+
const date = scope.utc ? (item.dateUtc || dayKeyUtc(item.time)) : (item.date || dayKey(item.time))
|
|
1297
|
+
if (!dateInScope(date, scope) && (date < heatStart || date > today)) continue
|
|
1298
|
+
if (scope.workspaceId !== undefined && scope.workspaceId !== null && item.wsId !== scope.workspaceId) continue
|
|
1299
|
+
if (!identityMatchesScope(item.identity || item.modelId, scope)) continue
|
|
1300
|
+
matchingUsage.push({ item, date })
|
|
1301
|
+
if (item.turn !== null && item.turn !== undefined) matchingTurnKeys.add(item.sid + ':turn:' + item.turn)
|
|
1302
|
+
}
|
|
1303
|
+
const matchingTurns = []
|
|
1304
|
+
for (const turn of turnRecords.values()) {
|
|
1305
|
+
const date = scope.utc ? (turn.dateUtc || dayKeyUtc(turn.time)) : (turn.date || dayKey(turn.time))
|
|
1306
|
+
if (!dateInScope(date, scope) && (date < heatStart || date > today)) continue
|
|
1307
|
+
if (scope.workspaceId !== undefined && scope.workspaceId !== null && turn.wsId !== scope.workspaceId) continue
|
|
1308
|
+
if ((scope.provider !== undefined && scope.provider !== null) || (scope.modelKey !== undefined && scope.modelKey !== null)) {
|
|
1309
|
+
if (!identityMatchesScope(turn.identity, scope) && !matchingTurnKeys.has(turn.key)) continue
|
|
1310
|
+
}
|
|
1311
|
+
matchingTurns.push({ turn, date })
|
|
1312
|
+
}
|
|
1313
|
+
const selected = queryAggregate()
|
|
1314
|
+
const heatmap = queryAggregate()
|
|
1315
|
+
for (const entry of matchingUsage) {
|
|
1316
|
+
if (dateInScope(entry.date, scope)) addQueryUsage(selected, entry.item, entry.date)
|
|
1317
|
+
if (entry.date >= heatStart && entry.date <= today) addQueryUsage(heatmap, entry.item, entry.date)
|
|
1318
|
+
}
|
|
1319
|
+
for (const entry of matchingTurns) {
|
|
1320
|
+
if (dateInScope(entry.date, scope)) addQueryTurn(selected, entry.turn, entry.date)
|
|
1321
|
+
if (entry.date >= heatStart && entry.date <= today) addQueryTurn(heatmap, entry.turn, entry.date)
|
|
1322
|
+
}
|
|
1323
|
+
const hourly = queryHourlyTrend(matchingUsage, matchingTurns, scope, nowMs)
|
|
1324
|
+
const result = { schemaVersion: 1, usageSchemaVersion: 2, instanceId, revision: statsRevision, updatedAt: statsUpdatedAt, scope: JSON.parse(scopeFingerprint(scope)), partial: !scan.done, completeThrough: { revision: statsRevision, at: statsUpdatedAt }, ...finalizeQueryAggregate(selected), hourly, heatmap: finalizeQueryAggregate(heatmap).daily }
|
|
1325
|
+
queryCache.set(key, result)
|
|
1326
|
+
while (queryCache.size > 20) queryCache.delete(queryCache.keys().next().value)
|
|
1327
|
+
return result
|
|
1328
|
+
}
|
|
1329
|
+
function opaqueRecordId(item) {
|
|
1330
|
+
return createHash('sha256').update(item.sid + '\0' + item.key).digest('hex').slice(0, 20)
|
|
1331
|
+
}
|
|
1332
|
+
function recordOrder(a, b) {
|
|
1333
|
+
return b.time - a.time || b.seq - a.seq || (a.sid < b.sid ? -1 : a.sid > b.sid ? 1 : 0) || (a.key < b.key ? -1 : a.key > b.key ? 1 : 0)
|
|
1334
|
+
}
|
|
1335
|
+
function recordIndexItems() {
|
|
1336
|
+
if (recordsIndex !== null && recordsIndex.revision === statsRevision) return recordsIndex.items
|
|
1337
|
+
const items = Array.from(usageByStep.values())
|
|
1338
|
+
items.sort(recordOrder)
|
|
1339
|
+
recordsIndex = { revision: statsRevision, items }
|
|
1340
|
+
return items
|
|
1341
|
+
}
|
|
1342
|
+
function queryRecords(scope, cursor, limit) {
|
|
1343
|
+
const fingerprint = scopeFingerprint(scope)
|
|
1344
|
+
let offset = 0
|
|
1345
|
+
if (cursor !== undefined && cursor !== '') {
|
|
1346
|
+
try {
|
|
1347
|
+
const decoded = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'))
|
|
1348
|
+
if (decoded.revision !== statsRevision || decoded.scope !== fingerprint || !Number.isInteger(decoded.offset) || decoded.offset < 0) return { error: 'stale-cursor' }
|
|
1349
|
+
offset = decoded.offset
|
|
1350
|
+
} catch (err) { return { error: 'bad-cursor' } }
|
|
1351
|
+
}
|
|
1352
|
+
const rows = []
|
|
1353
|
+
for (const item of recordIndexItems()) {
|
|
1354
|
+
const date = scope.utc ? (item.dateUtc || dayKeyUtc(item.time)) : (item.date || dayKey(item.time))
|
|
1355
|
+
if (!dateInScope(date, scope)) continue
|
|
1356
|
+
if (scope.workspaceId !== undefined && scope.workspaceId !== null && item.wsId !== scope.workspaceId) continue
|
|
1357
|
+
if (!identityMatchesScope(item.identity || item.modelId, scope)) continue
|
|
1358
|
+
rows.push({ item, date })
|
|
1359
|
+
}
|
|
1360
|
+
const page = rows.slice(offset, offset + limit)
|
|
1361
|
+
const items = page.map(({ item, date }) => {
|
|
1362
|
+
const identity = coerceIdentity(item.identity || item.modelId)
|
|
1363
|
+
return { id: opaqueRecordId(item), date, time: item.time, workspaceId: item.wsId, provider: identity.provider, requestedModel: identity.requestedModel, actualModel: identity.actualModel, model: identity.label, identityKey: identity.identityKey, turn: item.turn, step: item.step, seq: item.seq, values: item.values, materialization: item.materialization || 'unknown' }
|
|
1364
|
+
})
|
|
1365
|
+
const nextOffset = offset + items.length
|
|
1366
|
+
return { schemaVersion: 1, usageSchemaVersion: 2, instanceId, revision: statsRevision, scope: JSON.parse(fingerprint), items, hasMore: nextOffset < rows.length, nextCursor: nextOffset < rows.length ? Buffer.from(JSON.stringify({ revision: statsRevision, scope: fingerprint, offset: nextOffset })).toString('base64url') : null }
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
// ---------- account balance (DeepSeek open platform) ----------
|
|
1370
|
+
async function requestBalance(url, key) {
|
|
1371
|
+
let controller = null
|
|
1372
|
+
let timer = null
|
|
1373
|
+
try {
|
|
1374
|
+
if (typeof AbortController === 'function') {
|
|
1375
|
+
controller = new AbortController()
|
|
1376
|
+
timer = setTimeout(() => controller.abort(), 30000)
|
|
1377
|
+
}
|
|
1378
|
+
const response = await fetch(url, {
|
|
1379
|
+
method: 'GET',
|
|
1380
|
+
headers: { accept: 'application/json', authorization: 'Bearer ' + key },
|
|
1381
|
+
...(controller === null ? {} : { signal: controller.signal }),
|
|
1382
|
+
})
|
|
1383
|
+
return { ok: response.ok, status: response.status, text: await response.text() }
|
|
1384
|
+
} catch (err) {
|
|
1385
|
+
return { ok: false, status: 0, text: '', error: 'network request failed' }
|
|
1386
|
+
} finally {
|
|
1387
|
+
if (timer !== null) clearTimeout(timer)
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
function moneyOf(v) {
|
|
1391
|
+
if (typeof v === 'number' && Number.isFinite(v)) return v
|
|
1392
|
+
if (typeof v === 'string' && v.trim() !== '') {
|
|
1393
|
+
const n = parseFloat(v)
|
|
1394
|
+
if (Number.isFinite(n)) return n
|
|
1395
|
+
}
|
|
1396
|
+
return null
|
|
1397
|
+
}
|
|
1398
|
+
function parseBalance(text) {
|
|
1399
|
+
let obj = null
|
|
1400
|
+
try {
|
|
1401
|
+
obj = JSON.parse(String(text).replace(/^\uFEFF/, ''))
|
|
1402
|
+
} catch (err) {
|
|
1403
|
+
return null
|
|
1404
|
+
}
|
|
1405
|
+
if (obj === null || typeof obj !== 'object') return null
|
|
1406
|
+
if (obj.is_available === false) return { unavailable: true, currencies: [] }
|
|
1407
|
+
const infos = (Array.isArray(obj.balance_infos) && obj.balance_infos) || (Array.isArray(obj.balance) && obj.balance) || null
|
|
1408
|
+
if (!infos) return null
|
|
1409
|
+
const out = []
|
|
1410
|
+
for (const info of infos) {
|
|
1411
|
+
if (info === null || typeof info !== 'object') continue
|
|
1412
|
+
if (typeof info.currency !== 'string') continue
|
|
1413
|
+
out.push({
|
|
1414
|
+
currency: info.currency,
|
|
1415
|
+
total: moneyOf(info.total_balance !== undefined ? info.total_balance : info.balance),
|
|
1416
|
+
granted: moneyOf(info.granted_balance),
|
|
1417
|
+
toppedUp: moneyOf(info.topped_up_balance),
|
|
1418
|
+
})
|
|
1419
|
+
}
|
|
1420
|
+
return { unavailable: false, currencies: out }
|
|
1421
|
+
}
|
|
1422
|
+
async function fetchBalance(force) {
|
|
1423
|
+
const now = Date.now()
|
|
1424
|
+
if (force !== true && balanceCache.payload !== null && now - balanceCache.fetchedAt < 300000) return balanceCache.payload
|
|
1425
|
+
let ref = 'DEEPSEEK_API_KEY'
|
|
1426
|
+
if (settings !== undefined) {
|
|
1427
|
+
try {
|
|
1428
|
+
const section = settings.get('llm-deepseek')
|
|
1429
|
+
if (section !== null && typeof section === 'object' && typeof section.apiKeyEnv === 'string' && section.apiKeyEnv.length > 0) ref = section.apiKeyEnv
|
|
1430
|
+
} catch (err) { /* default ref */ }
|
|
1431
|
+
}
|
|
1432
|
+
let key
|
|
1433
|
+
if (credentials !== undefined) {
|
|
1434
|
+
try {
|
|
1435
|
+
const hit = await credentials.resolve(ref)
|
|
1436
|
+
if (hit !== null && hit !== undefined && typeof hit.value === 'string' && hit.value.length > 0) key = hit.value
|
|
1437
|
+
} catch (err) { /* unconfigured */ }
|
|
1438
|
+
}
|
|
1439
|
+
if (key === undefined) {
|
|
1440
|
+
const payload = { status: 'missing-key' }
|
|
1441
|
+
balanceCache = { fetchedAt: now, payload }
|
|
1442
|
+
return payload
|
|
1443
|
+
}
|
|
1444
|
+
if (typeof fetch !== 'function') {
|
|
1445
|
+
const payload = { status: 'error', message: '当前 DSH 运行时不支持余额查询' }
|
|
1446
|
+
balanceCache = { fetchedAt: now, payload }
|
|
1447
|
+
return payload
|
|
1448
|
+
}
|
|
1449
|
+
const result = await requestBalance('https://api.deepseek.com/user/balance', key)
|
|
1450
|
+
const body = (result.text || '').trim()
|
|
1451
|
+
if (result.ok && body.length > 0) {
|
|
1452
|
+
const parsed = parseBalance(body)
|
|
1453
|
+
if (parsed !== null && parsed.unavailable) {
|
|
1454
|
+
const payload = { status: 'unavailable', message: 'DeepSeek 接口返回余额不可用(is_available=false)' }
|
|
1455
|
+
balanceCache = { fetchedAt: now, payload }
|
|
1456
|
+
return payload
|
|
1457
|
+
}
|
|
1458
|
+
if (parsed !== null && parsed.currencies.length > 0) {
|
|
1459
|
+
const payload = { status: 'ok', currencies: parsed.currencies, fetchedAt: now }
|
|
1460
|
+
balanceCache = { fetchedAt: now, payload }
|
|
1461
|
+
return payload
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
const detail = body.length > 0 ? body.slice(0, 300) : (result.status > 0 ? 'HTTP ' + result.status : result.error || 'network request failed')
|
|
1465
|
+
const payload = { status: 'error', message: '余额查询失败', detail }
|
|
1466
|
+
balanceCache = { fetchedAt: now, payload }
|
|
1467
|
+
return payload
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
// ---------- HTTP data routes for the client half ----------
|
|
1471
|
+
if (webServer !== undefined) {
|
|
1472
|
+
const rejectRequest = (res) => sendJson(res, 403, { ok: false, message: 'forbidden' })
|
|
1473
|
+
ctx.effect(() => webServer.register({
|
|
1474
|
+
kind: 'exact',
|
|
1475
|
+
path: '/api/all-usage/query',
|
|
1476
|
+
handler: (req, res) => {
|
|
1477
|
+
if (req.method !== 'GET') { res.statusCode = 405; res.end(); return }
|
|
1478
|
+
if (!isTrustedLocalApiRequest(req, false)) { rejectRequest(res); return }
|
|
1479
|
+
const parsed = queryScopeFromRequest(req)
|
|
1480
|
+
if (!parsed.ok) { sendJson(res, 400, { ok: false, message: parsed.message }); return }
|
|
1481
|
+
sendJson(res, 200, queryUsageScope(parsed.scope))
|
|
1482
|
+
},
|
|
1483
|
+
}))
|
|
1484
|
+
ctx.effect(() => webServer.register({
|
|
1485
|
+
kind: 'exact',
|
|
1486
|
+
path: '/api/all-usage/records',
|
|
1487
|
+
handler: (req, res) => {
|
|
1488
|
+
if (req.method !== 'GET') { res.statusCode = 405; res.end(); return }
|
|
1489
|
+
if (!isTrustedLocalApiRequest(req, false)) { rejectRequest(res); return }
|
|
1490
|
+
const parsed = queryScopeFromRequest(req)
|
|
1491
|
+
if (!parsed.ok) { sendJson(res, 400, { ok: false, message: parsed.message }); return }
|
|
1492
|
+
let limit = 50
|
|
1493
|
+
let cursor
|
|
1494
|
+
try {
|
|
1495
|
+
const url = new URL(req.url || '/', 'http://all-usage.local')
|
|
1496
|
+
const rawLimit = url.searchParams.get('limit')
|
|
1497
|
+
if (rawLimit !== null && rawLimit !== '') limit = Number(rawLimit)
|
|
1498
|
+
cursor = url.searchParams.get('cursor') || undefined
|
|
1499
|
+
} catch (err) { sendJson(res, 400, { ok: false, message: 'bad-query' }); return }
|
|
1500
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 200) { sendJson(res, 400, { ok: false, message: 'invalid-limit' }); return }
|
|
1501
|
+
const result = queryRecords(parsed.scope, cursor, limit)
|
|
1502
|
+
if (result.error !== undefined) { sendJson(res, result.error === 'stale-cursor' ? 409 : 400, { ok: false, message: result.error }); return }
|
|
1503
|
+
sendJson(res, 200, result)
|
|
1504
|
+
},
|
|
1505
|
+
}))
|
|
1506
|
+
ctx.effect(() => webServer.register({
|
|
1507
|
+
kind: 'exact',
|
|
1508
|
+
path: '/api/all-usage/status',
|
|
1509
|
+
handler: (req, res) => {
|
|
1510
|
+
if (req.method !== 'GET') { res.statusCode = 405; res.end(); return }
|
|
1511
|
+
if (!isTrustedLocalApiRequest(req, false)) { rejectRequest(res); return }
|
|
1512
|
+
if (!scan.started) void runBaseline()
|
|
1513
|
+
sendJson(res, 200, statusSnapshot())
|
|
1514
|
+
},
|
|
1515
|
+
}))
|
|
1516
|
+
ctx.effect(() => webServer.register({
|
|
1517
|
+
kind: 'exact',
|
|
1518
|
+
path: '/api/all-usage',
|
|
1519
|
+
handler: (req, res) => {
|
|
1520
|
+
if (req.method !== 'GET') { res.statusCode = 405; res.end(); return }
|
|
1521
|
+
if (!isTrustedLocalApiRequest(req, false)) { rejectRequest(res); return }
|
|
1522
|
+
if (!scan.started) void runBaseline()
|
|
1523
|
+
sendJson(res, 200, snapshot())
|
|
1524
|
+
},
|
|
1525
|
+
}))
|
|
1526
|
+
ctx.effect(() => webServer.register({
|
|
1527
|
+
kind: 'exact',
|
|
1528
|
+
path: '/api/all-usage/balance',
|
|
1529
|
+
handler: async (req, res) => {
|
|
1530
|
+
if (req.method !== 'GET') { res.statusCode = 405; res.end(); return }
|
|
1531
|
+
// Browsers may omit Origin on same-origin GET; the process token remains required.
|
|
1532
|
+
if (!isTrustedLocalApiRequest(req, false) || !hasWriteToken(req, requestToken)) { rejectRequest(res); return }
|
|
1533
|
+
let force = false
|
|
1534
|
+
try {
|
|
1535
|
+
const url = new URL(req.url ?? '/', 'http://x')
|
|
1536
|
+
force = url.searchParams.get('force') === '1'
|
|
1537
|
+
} catch (err) { /* default */ }
|
|
1538
|
+
sendJson(res, 200, await fetchBalance(force))
|
|
1539
|
+
},
|
|
1540
|
+
}))
|
|
1541
|
+
ctx.effect(() => webServer.register({
|
|
1542
|
+
kind: 'exact',
|
|
1543
|
+
path: '/api/all-usage/alias',
|
|
1544
|
+
handler: async (req, res) => {
|
|
1545
|
+
if (req.method !== 'POST') {
|
|
1546
|
+
res.statusCode = 405
|
|
1547
|
+
res.end()
|
|
1548
|
+
return
|
|
1549
|
+
}
|
|
1550
|
+
if (!isTrustedLocalApiRequest(req, true) || !hasWriteToken(req, requestToken)) { rejectRequest(res); return }
|
|
1551
|
+
const body = await readBody(req, 16 * 1024)
|
|
1552
|
+
let args = null
|
|
1553
|
+
try {
|
|
1554
|
+
args = JSON.parse(body)
|
|
1555
|
+
} catch (err) { /* invalid json */ }
|
|
1556
|
+
const result = args !== null && args !== undefined && typeof args.workspaceId === 'string'
|
|
1557
|
+
? setAlias(args.workspaceId, args.alias)
|
|
1558
|
+
: { ok: false, message: 'bad-request', aliases: Object.assign({}, aliases) }
|
|
1559
|
+
sendJson(res, 200, result)
|
|
1560
|
+
},
|
|
1561
|
+
}))
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
// ---------- start the historical backfill immediately ----------
|
|
1565
|
+
ledgerReady = loadLedger()
|
|
1566
|
+
void runBaseline()
|
|
1567
|
+
scheduleReconcileTimer()
|
|
1568
|
+
void loadAliases()
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
export { name, inject, apply }
|
|
1572
|
+
export default { name, inject, apply }
|