dsh-all-usage 1.1.17 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +453 -386
- package/LICENSE +21 -21
- package/README.en.md +270 -0
- package/README.md +145 -273
- package/assets/model-icons/ai2-color.svg +1 -0
- package/assets/model-icons/ai21-brand-color.svg +1 -0
- package/assets/model-icons/aionlabs.svg +1 -0
- package/assets/model-icons/alephalpha.svg +1 -0
- package/assets/model-icons/baai.svg +1 -0
- package/assets/model-icons/baichuan-color.svg +1 -0
- package/assets/model-icons/baidu-color.svg +1 -0
- package/assets/model-icons/bytedance-color.svg +1 -0
- package/assets/model-icons/cohere-color.svg +1 -0
- package/assets/model-icons/hunyuan-color.svg +1 -0
- package/assets/model-icons/ibm.svg +1 -0
- package/assets/model-icons/iflytekcloud-color.svg +1 -0
- package/assets/model-icons/internlm-color.svg +1 -0
- package/assets/model-icons/kling-color.svg +1 -0
- package/assets/model-icons/liquid.svg +1 -0
- package/assets/model-icons/longcat-color.svg +1 -0
- package/assets/model-icons/manifest.json +739 -213
- package/assets/model-icons/microsoft-color.svg +1 -0
- package/assets/model-icons/mistral-color.svg +1 -0
- package/assets/model-icons/nousresearch.svg +1 -0
- package/assets/model-icons/nova-color.svg +1 -0
- package/assets/model-icons/nvidia-color.svg +1 -0
- package/assets/model-icons/perplexity-color.svg +1 -0
- package/assets/model-icons/sensenova-color.svg +1 -0
- package/assets/model-icons/skywork-color.svg +1 -0
- package/assets/model-icons/snowflake-color.svg +1 -0
- package/assets/model-icons/stepfun-color.svg +1 -0
- package/assets/model-icons/tencent-color.svg +1 -0
- package/assets/model-icons/tii-color.svg +1 -0
- package/assets/model-icons/upstage-color.svg +1 -0
- package/assets/model-icons/voyage-color.svg +1 -0
- package/assets/model-icons/xiaomimimo.svg +1 -0
- package/assets/model-icons/yandex.svg +1 -0
- package/assets/readme-architecture-en.svg +95 -0
- package/assets/readme-architecture-zh.svg +95 -0
- package/assets/readme-sync-en.svg +81 -0
- package/assets/readme-sync-zh.svg +81 -0
- package/assets/screenshots/en/01-overview.png +0 -0
- package/assets/screenshots/en/02-heatmap.png +0 -0
- package/assets/screenshots/en/03-model-usage.png +0 -0
- package/assets/screenshots/en/04-request-logs.png +0 -0
- package/assets/screenshots/en/05-cost-settings.png +0 -0
- package/assets/screenshots/zh/01-overview.png +0 -0
- package/assets/screenshots/zh/02-heatmap.png +0 -0
- package/assets/screenshots/zh/03-model-usage.png +0 -0
- package/assets/screenshots/zh/04-request-logs.png +0 -0
- package/assets/screenshots/zh/05-cost-settings.png +0 -0
- package/cordis.patch.yml +5 -5
- package/lib/aggregation.js +1842 -1112
- package/lib/client.js +1 -1
- package/lib/http.js +417 -370
- package/lib/icons.js +40 -0
- package/lib/ledger.js +1000 -531
- package/lib/plugin.js +347 -323
- package/lib/pricing-runtime.js +615 -551
- package/lib/pricing.js +1463 -1412
- package/lib/records-cache.js +113 -0
- package/lib/session-sync.js +976 -841
- package/lib/usage-core.js +33 -10
- package/lib/version-check.js +133 -0
- package/package.json +8 -3
- package/screenshots.json +10 -3
- package/assets/screenshot-1.png +0 -0
- package/assets/screenshot-2.png +0 -0
- package/assets/screenshot-3.png +0 -0
package/lib/aggregation.js
CHANGED
|
@@ -1,1112 +1,1842 @@
|
|
|
1
|
-
import { createHash } from 'node:crypto'
|
|
2
|
-
import { RETIRED_WORKSPACE_ID } from './ledger.js'
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
function
|
|
20
|
-
const d = new Date(ms)
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
if (
|
|
39
|
-
keys.
|
|
40
|
-
if (keys
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
return
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
function
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
if (
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
return
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
if (
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
}
|
|
169
|
-
function
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
}
|
|
188
|
-
function
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
}
|
|
202
|
-
function
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
if (
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
const
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
const
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
const
|
|
259
|
-
const
|
|
260
|
-
|
|
261
|
-
const
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
if (
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
const
|
|
308
|
-
if (
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
const
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
if (
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
const
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
state.
|
|
380
|
-
state.
|
|
381
|
-
if (
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
const
|
|
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
|
-
if (
|
|
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
|
-
const
|
|
540
|
-
const
|
|
541
|
-
const
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
return
|
|
555
|
-
}
|
|
556
|
-
function
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
if (
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
const
|
|
586
|
-
return
|
|
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
|
-
const
|
|
709
|
-
const
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
const
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
const
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
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
|
-
const
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
const
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
const
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
const
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
const
|
|
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
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
const
|
|
945
|
-
|
|
946
|
-
const
|
|
947
|
-
const
|
|
948
|
-
const
|
|
949
|
-
const
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
state.
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
}
|
|
993
|
-
function
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
const
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
const
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
const
|
|
1029
|
-
return {
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
identityFromMessage,
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { RETIRED_WORKSPACE_ID } from './ledger.js'
|
|
3
|
+
import { cacheRecordsResult, deleteRecordsResult, getRecordsResult, recordKeyHash, recordPageReferences, recordsCacheStats } from './records-cache.js'
|
|
4
|
+
import { COST_SCHEMA_VERSION, addCostAccumulator, addCostAggregate, calculateCost, createCostAccumulator, decimalSubtract, isCostAccumulator, normalizeCostSnapshot, resolvePricing, serializeCostAggregate, temporalPlanFor } from './pricing.js'
|
|
5
|
+
import { contextTimeIsFresh, extractUsageEvent, normalizeEventSeq, normalizeUsageValues as usageValues, upsertUsageSample as upsertUsageSampleState, usageStepKey, validEventTime as validUsageEventTime } from './usage-core.js'
|
|
6
|
+
|
|
7
|
+
export function createAggregation(host) {
|
|
8
|
+
const { state, markStatsChanged, commitPendingStats, resetSyncState } = host
|
|
9
|
+
// Older hosts (and hand-built test states) predate the per-session digest and the
|
|
10
|
+
// date -> sessions index the /records loader consults.
|
|
11
|
+
if (!(state.perSession instanceof Map)) state.perSession = new Map()
|
|
12
|
+
if (!(state.sessionsByLocalDate instanceof Map)) state.sessionsByLocalDate = new Map()
|
|
13
|
+
if (!(state.sessionsByUtcDate instanceof Map)) state.sessionsByUtcDate = new Map()
|
|
14
|
+
if (!(state.rowTouch instanceof Map)) state.rowTouch = new Map()
|
|
15
|
+
if (!(state.recordsQueryCache instanceof Map)) state.recordsQueryCache = new Map()
|
|
16
|
+
const pricingSnapshot = (...args) => host.pricingSnapshot(...args)
|
|
17
|
+
const syncSnapshot = (...args) => host.syncSnapshot(...args)
|
|
18
|
+
|
|
19
|
+
function dayKey(ms) {
|
|
20
|
+
const d = new Date(ms)
|
|
21
|
+
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0')
|
|
22
|
+
}
|
|
23
|
+
function dayKeyUtc(ms) {
|
|
24
|
+
const d = new Date(ms)
|
|
25
|
+
return d.getUTCFullYear() + '-' + String(d.getUTCMonth() + 1).padStart(2, '0') + '-' + String(d.getUTCDate()).padStart(2, '0')
|
|
26
|
+
}
|
|
27
|
+
function dateKeys(ms) {
|
|
28
|
+
const d = new Date(ms)
|
|
29
|
+
const local = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0')
|
|
30
|
+
const utc = d.getUTCFullYear() + '-' + String(d.getUTCMonth() + 1).padStart(2, '0') + '-' + String(d.getUTCDate()).padStart(2, '0')
|
|
31
|
+
return { local, utc }
|
|
32
|
+
}
|
|
33
|
+
function num(v) {
|
|
34
|
+
return typeof v === 'number' && Number.isFinite(v) ? v : 0
|
|
35
|
+
}
|
|
36
|
+
const validEventTime = validUsageEventTime
|
|
37
|
+
function addDateIndex(index, date, key) {
|
|
38
|
+
if (typeof date !== 'string' || date === '') return
|
|
39
|
+
let keys = index.get(date)
|
|
40
|
+
if (keys === undefined) { keys = new Set(); index.set(date, keys) }
|
|
41
|
+
keys.add(key)
|
|
42
|
+
}
|
|
43
|
+
function removeDateIndex(index, date, key) {
|
|
44
|
+
if (typeof date !== 'string' || date === '') return
|
|
45
|
+
const keys = index.get(date)
|
|
46
|
+
if (keys === undefined) return
|
|
47
|
+
keys.delete(key)
|
|
48
|
+
if (keys.size === 0) index.delete(date)
|
|
49
|
+
}
|
|
50
|
+
// A per-session digest of everything that was folded in: the day/workspace/model
|
|
51
|
+
// cubes can be unwound from it without the session's rows, which is what lets the
|
|
52
|
+
// detail rows be dropped from memory and read back from disk on demand.
|
|
53
|
+
function sessionAggregateFor(sid, create) {
|
|
54
|
+
if (typeof sid !== 'string' || sid === '') return undefined
|
|
55
|
+
let session = state.perSession.get(sid)
|
|
56
|
+
if (session === undefined) {
|
|
57
|
+
if (create !== true) return undefined
|
|
58
|
+
// newestTime is the fence the live feed is checked against: everything older has
|
|
59
|
+
// already been folded from the ledger (or will be), and a replay must not add it
|
|
60
|
+
// a second time under a renumbered key.
|
|
61
|
+
session = { keys: new Map(), buckets: new Map(), newestTime: 0 }
|
|
62
|
+
state.perSession.set(sid, session)
|
|
63
|
+
}
|
|
64
|
+
return session
|
|
65
|
+
}
|
|
66
|
+
function pruneSessionAggregate(sid, session) {
|
|
67
|
+
if (session === undefined) return
|
|
68
|
+
if (session.keys.size === 0 && session.buckets.size === 0) state.perSession.delete(sid)
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* How a row's cost was decided. A row stays resident only when something can still
|
|
72
|
+
* change it: an unpriced row (backfill) or one priced from a request context that
|
|
73
|
+
* had already gone stale (the temporal reconcile rewrites exactly those).
|
|
74
|
+
*/
|
|
75
|
+
function pricingExposureOf(cost, eventTime) {
|
|
76
|
+
const priced = cost !== null && cost !== undefined && cost.status === 'priced'
|
|
77
|
+
let requestContext = 0
|
|
78
|
+
if (cost !== null && cost !== undefined && cost.pricingTimeSource === 'request-context') {
|
|
79
|
+
const at = cost.pricingAt
|
|
80
|
+
requestContext = Number.isFinite(at) && contextTimeIsFresh(at, eventTime) ? 0 : 1
|
|
81
|
+
}
|
|
82
|
+
return { unpriced: priced ? 0 : 1, requestContext }
|
|
83
|
+
}
|
|
84
|
+
/** True when nothing about this session's rows can still change without a rebuild. */
|
|
85
|
+
function sessionIsSettled(session) {
|
|
86
|
+
for (const bucket of session.buckets.values()) if (bucket.unpriced > 0 || bucket.requestContext > 0) return false
|
|
87
|
+
return true
|
|
88
|
+
}
|
|
89
|
+
// date -> session -> how many of that session's rows fall on the date. A
|
|
90
|
+
// reference count, because a session usually has several rows in one day and a
|
|
91
|
+
// plain Set would drop it after the first one is forgotten.
|
|
92
|
+
function addSessionDate(index, date, sid) {
|
|
93
|
+
if (typeof date !== 'string' || date === '' || typeof sid !== 'string' || sid === '') return
|
|
94
|
+
let day = index.get(date)
|
|
95
|
+
if (day === undefined) { day = new Map(); index.set(date, day) }
|
|
96
|
+
day.set(sid, (day.get(sid) || 0) + 1)
|
|
97
|
+
}
|
|
98
|
+
function forgetSessionDate(index, date, sid) {
|
|
99
|
+
const day = index.get(date)
|
|
100
|
+
if (day === undefined) return
|
|
101
|
+
const next = (day.get(sid) || 0) - 1
|
|
102
|
+
if (next > 0) day.set(sid, next)
|
|
103
|
+
else day.delete(sid)
|
|
104
|
+
if (day.size === 0) index.delete(date)
|
|
105
|
+
}
|
|
106
|
+
function sessionBucketKey(entry) {
|
|
107
|
+
// Keep each bucket inside one local/UTC day and hour so aggregate unwinds can
|
|
108
|
+
// remove query-hour contributions without retaining every detail row.
|
|
109
|
+
const identityKey = typeof entry.identityKey === 'string' && entry.identityKey !== '' ? entry.identityKey : coerceIdentity(entry.identity).identityKey
|
|
110
|
+
const time = Number.isFinite(entry.time) ? entry.time : 0
|
|
111
|
+
return JSON.stringify([entry.date, entry.dateUtc, entry.wsId, identityKey, hourStartOf(time, false), hourStartOf(time, true)])
|
|
112
|
+
}
|
|
113
|
+
/** Roll one usage row into its session's digest (both directions). */
|
|
114
|
+
function adjustSessionUsage(sid, direction, entry, count = 1) {
|
|
115
|
+
const session = sessionAggregateFor(sid, direction === 1)
|
|
116
|
+
if (session === undefined) return
|
|
117
|
+
const key = sessionBucketKey(entry)
|
|
118
|
+
let bucket = session.buckets.get(key)
|
|
119
|
+
if (bucket === undefined) {
|
|
120
|
+
if (direction !== 1) return
|
|
121
|
+
bucket = {
|
|
122
|
+
time: entry.time,
|
|
123
|
+
date: entry.date,
|
|
124
|
+
dateUtc: entry.dateUtc,
|
|
125
|
+
wsId: entry.wsId,
|
|
126
|
+
identity: coerceIdentity(entry.identity),
|
|
127
|
+
calls: 0,
|
|
128
|
+
unpriced: 0,
|
|
129
|
+
requestContext: 0,
|
|
130
|
+
turns: new Map(),
|
|
131
|
+
values: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 },
|
|
132
|
+
cost: createCostAccumulator(),
|
|
133
|
+
}
|
|
134
|
+
session.buckets.set(key, bucket)
|
|
135
|
+
}
|
|
136
|
+
bucket.calls += direction * count
|
|
137
|
+
if (direction === 1 && Number.isFinite(entry.time) && entry.time > (session.newestTime || 0)) session.newestTime = entry.time
|
|
138
|
+
const exposure = pricingExposureOf(entry.cost, entry.time)
|
|
139
|
+
bucket.unpriced += direction * exposure.unpriced * count
|
|
140
|
+
bucket.requestContext += direction * exposure.requestContext * count
|
|
141
|
+
adjustValues(bucket.values, entry.values, direction)
|
|
142
|
+
addCostAggregateDirection(bucket.cost, entry.cost, direction)
|
|
143
|
+
if (entry.turn !== null && entry.turn !== undefined) {
|
|
144
|
+
const turnKey = String(sid) + ':turn:' + String(entry.turn)
|
|
145
|
+
const next = (bucket.turns.get(turnKey) || 0) + direction * count
|
|
146
|
+
if (next > 0) bucket.turns.set(turnKey, next)
|
|
147
|
+
else bucket.turns.delete(turnKey)
|
|
148
|
+
}
|
|
149
|
+
if (bucket.calls <= 0 && noValues(bucket.values)) session.buckets.delete(key)
|
|
150
|
+
pruneSessionAggregate(sid, session)
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Only the cost changed (a backfill or re-price): keep the session digest's
|
|
154
|
+
* bucket in step, or a later removal would unwind the stale amount.
|
|
155
|
+
*/
|
|
156
|
+
function adjustSessionCostOnly(item, cost, direction) {
|
|
157
|
+
if (item === null || item === undefined || typeof item.sid !== 'string' || item.sid === '') return
|
|
158
|
+
const session = sessionAggregateFor(item.sid, false)
|
|
159
|
+
if (session === undefined) return
|
|
160
|
+
const identity = coerceIdentity(item.identity || item.modelId)
|
|
161
|
+
const dates = typeof item.date === 'string' && typeof item.dateUtc === 'string' ? { local: item.date, utc: item.dateUtc } : dateKeys(item.time)
|
|
162
|
+
const bucket = session.buckets.get(sessionBucketKey({ date: dates.local, dateUtc: dates.utc, wsId: item.wsId, identityKey: identity.identityKey, time: item.time }))
|
|
163
|
+
if (bucket === undefined) return
|
|
164
|
+
const exposure = pricingExposureOf(cost, item.time)
|
|
165
|
+
bucket.unpriced += direction * exposure.unpriced
|
|
166
|
+
bucket.requestContext += direction * exposure.requestContext
|
|
167
|
+
addCostAggregateDirection(bucket.cost, cost, direction)
|
|
168
|
+
}
|
|
169
|
+
function recordSessionKey(item) {
|
|
170
|
+
const session = sessionAggregateFor(item.sid, true)
|
|
171
|
+
if (session === undefined) return
|
|
172
|
+
// The metadata is the bounded description a cold /records query filters and
|
|
173
|
+
// orders on, so it keeps the row's time/seq and shares its identity object.
|
|
174
|
+
// Values, cost and the row itself stay out: those are read back per page.
|
|
175
|
+
session.keys.set(item.key, {
|
|
176
|
+
date: item.date,
|
|
177
|
+
dateUtc: item.dateUtc,
|
|
178
|
+
wsId: item.wsId,
|
|
179
|
+
time: Number.isFinite(item.time) ? item.time : undefined,
|
|
180
|
+
seq: Number.isFinite(item.seq) ? item.seq : undefined,
|
|
181
|
+
identity: item.identity,
|
|
182
|
+
})
|
|
183
|
+
// which sessions have rows on a date: the disk lookup for /records starts here
|
|
184
|
+
addSessionDate(state.sessionsByLocalDate, item.date, item.sid)
|
|
185
|
+
addSessionDate(state.sessionsByUtcDate, item.dateUtc, item.sid)
|
|
186
|
+
if (state.rowTouch instanceof Map) state.rowTouch.set(item.sid, (state.rowTouch.get(item.sid) || 0) + 1)
|
|
187
|
+
}
|
|
188
|
+
function forgetSessionKey(item) {
|
|
189
|
+
const session = sessionAggregateFor(item.sid, false)
|
|
190
|
+
if (session === undefined) return
|
|
191
|
+
session.keys.delete(item.key)
|
|
192
|
+
if (state.sessionsByLocalDate instanceof Map) forgetSessionDate(state.sessionsByLocalDate, item.date, item.sid)
|
|
193
|
+
if (state.sessionsByUtcDate instanceof Map) forgetSessionDate(state.sessionsByUtcDate, item.dateUtc, item.sid)
|
|
194
|
+
pruneSessionAggregate(item.sid, session)
|
|
195
|
+
}
|
|
196
|
+
/** The resident detail-row budget; the rest lives in the ledger on disk. */
|
|
197
|
+
function residentRowBudget() {
|
|
198
|
+
const configured = Number(process.env.DSH_ALL_USAGE_RESIDENT_ROWS)
|
|
199
|
+
if (Number.isFinite(configured)) return Math.floor(configured)
|
|
200
|
+
return 12000
|
|
201
|
+
}
|
|
202
|
+
function rowIsProtected(sid) {
|
|
203
|
+
if (state.ledgerDirtySessions instanceof Set && state.ledgerDirtySessions.has(sid)) return true
|
|
204
|
+
if (state.ledgerPending instanceof Map && state.ledgerPending.has(sid)) return true
|
|
205
|
+
if (state.ledgerWriting instanceof Set && state.ledgerWriting.has(sid)) return true
|
|
206
|
+
if (state.ledgerReading instanceof Map && (state.ledgerReading.get(sid) || 0) > 0) return true
|
|
207
|
+
if (state.liveResyncPending instanceof Set && state.liveResyncPending.has(sid)) return true
|
|
208
|
+
if (state.ledgerPrefetching instanceof Set && state.ledgerPrefetching.has(sid)) return true
|
|
209
|
+
return false
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Drop the detail rows of sessions whose contribution is already in the cubes and
|
|
213
|
+
* whose pricing is final. The digest keeps such a session removable, and the rows
|
|
214
|
+
* are read back from the ledger when a query or a rebuild asks for them.
|
|
215
|
+
*/
|
|
216
|
+
function evictColdRows(options) {
|
|
217
|
+
bumpCount('evict.runs')
|
|
218
|
+
const requested = options !== null && options !== undefined ? options.rows : undefined
|
|
219
|
+
const budget = Number.isFinite(requested) ? Math.floor(requested) : residentRowBudget()
|
|
220
|
+
const keep = options !== null && options !== undefined && options.keep instanceof Set ? options.keep : null
|
|
221
|
+
if (!(state.usageByStep instanceof Map)) return { sessions: 0, rows: 0 }
|
|
222
|
+
// A negative budget keeps every row resident (the pre-eviction behaviour).
|
|
223
|
+
if (budget < 0) return { sessions: 0, rows: 0 }
|
|
224
|
+
if (state.usageByStep.size <= budget) return { sessions: 0, rows: 0 }
|
|
225
|
+
const candidates = []
|
|
226
|
+
for (const [sid, session] of state.perSession) {
|
|
227
|
+
if (session.keys.size === 0) continue
|
|
228
|
+
if (keep !== null && keep.has(sid)) continue
|
|
229
|
+
if (rowIsProtected(sid)) continue
|
|
230
|
+
candidates.push([sid, state.rowTouch instanceof Map ? (state.rowTouch.get(sid) || 0) : 0])
|
|
231
|
+
}
|
|
232
|
+
// Least recently folded first: the rows of the sessions the scan is working
|
|
233
|
+
// through stay, and only rows whose cost is final are dropped, so a session with
|
|
234
|
+
// one unsettled row keeps just that row.
|
|
235
|
+
candidates.sort((left, right) => left[1] - right[1])
|
|
236
|
+
const startSize = state.usageByStep.size
|
|
237
|
+
let sessions = 0
|
|
238
|
+
let rows = 0
|
|
239
|
+
for (const [sid] of candidates) {
|
|
240
|
+
if (startSize - rows <= budget) break
|
|
241
|
+
const session = state.perSession.get(sid)
|
|
242
|
+
if (session === undefined) continue
|
|
243
|
+
let dropped = 0
|
|
244
|
+
for (const key of session.keys.keys()) {
|
|
245
|
+
const item = state.usageByStep.get(key)
|
|
246
|
+
if (item === undefined) continue
|
|
247
|
+
const exposure = pricingExposureOf(item.cost, item.time)
|
|
248
|
+
if (exposure.unpriced > 0 || exposure.requestContext > 0) continue
|
|
249
|
+
if (state.usageByStep.delete(key)) dropped += 1
|
|
250
|
+
}
|
|
251
|
+
if (dropped > 0) sessions += 1
|
|
252
|
+
rows += dropped
|
|
253
|
+
}
|
|
254
|
+
return { sessions, rows }
|
|
255
|
+
}
|
|
256
|
+
/** The sessions that own at least one row on a date inside the range. */
|
|
257
|
+
function sessionsForRange(utc, start, end) {
|
|
258
|
+
const index = utc === true ? state.sessionsByUtcDate : state.sessionsByLocalDate
|
|
259
|
+
const result = new Set()
|
|
260
|
+
if (!(index instanceof Map)) return result
|
|
261
|
+
for (const [date, day] of index) {
|
|
262
|
+
if (typeof start === 'string' && start !== '' && date < start) continue
|
|
263
|
+
if (typeof end === 'string' && end !== '' && date > end) continue
|
|
264
|
+
// The day is a Map of session id -> row count, so its keys are the sessions.
|
|
265
|
+
for (const sid of day.keys()) result.add(sid)
|
|
266
|
+
}
|
|
267
|
+
return result
|
|
268
|
+
}
|
|
269
|
+
/** One usage row, built the same way whether it is folded or read back. */
|
|
270
|
+
function usageRowOf(record, item, materialization) {
|
|
271
|
+
const identity = item.identity || identityFromLegacy(item.modelId)
|
|
272
|
+
const dates = dateKeys(item.time)
|
|
273
|
+
return {
|
|
274
|
+
key: item.key,
|
|
275
|
+
seq: item.seq,
|
|
276
|
+
wsId: item.workspaceId,
|
|
277
|
+
time: item.time,
|
|
278
|
+
date: dates.local,
|
|
279
|
+
dateUtc: dates.utc,
|
|
280
|
+
values: item.values,
|
|
281
|
+
identity,
|
|
282
|
+
modelId: identity.label,
|
|
283
|
+
cost: item.cost,
|
|
284
|
+
turn: item.turn,
|
|
285
|
+
step: item.step,
|
|
286
|
+
materialization,
|
|
287
|
+
sid: record.sessionId,
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Put a row back for a query without touching the cubes: they already hold its
|
|
292
|
+
* contribution, so it is data for the log view and nothing else.
|
|
293
|
+
*/
|
|
294
|
+
function adoptUsageRow(row) {
|
|
295
|
+
if (row === null || row === undefined || typeof row.key !== 'string' || row.key === '') return false
|
|
296
|
+
if (state.usageByStep.has(row.key)) return false
|
|
297
|
+
state.usageByStep.set(row.key, row)
|
|
298
|
+
bumpCount('row.adopted')
|
|
299
|
+
return true
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Fold one row into the cubes, unless the digest says it is already in them. That
|
|
303
|
+
* is what makes applying a record idempotent once its rows may have been dropped.
|
|
304
|
+
*/
|
|
305
|
+
function foldUsageRow(row, materialization) {
|
|
306
|
+
if (row === null || row === undefined || typeof row.key !== 'string' || row.key === '') return false
|
|
307
|
+
const session = sessionAggregateFor(row.sid, false)
|
|
308
|
+
if (session !== undefined && session.keys.has(row.key)) { bumpCount('fold.skipped'); return false }
|
|
309
|
+
bumpCount('fold.folded')
|
|
310
|
+
const candidate = materialization === undefined || materialization === null || row.materialization === materialization ? row : { ...row, materialization }
|
|
311
|
+
const result = upsertUsageSample(state.usageByStep, candidate)
|
|
312
|
+
return result !== null && result !== undefined && result.accepted === true
|
|
313
|
+
}
|
|
314
|
+
function indexUsage(item) {
|
|
315
|
+
addDateIndex(state.usageByLocalDate, item.date, item.key)
|
|
316
|
+
addDateIndex(state.usageByUtcDate, item.dateUtc, item.key)
|
|
317
|
+
recordSessionKey(item)
|
|
318
|
+
}
|
|
319
|
+
function unindexUsage(item) {
|
|
320
|
+
removeDateIndex(state.usageByLocalDate, item.date, item.key)
|
|
321
|
+
removeDateIndex(state.usageByUtcDate, item.dateUtc, item.key)
|
|
322
|
+
forgetSessionKey(item)
|
|
323
|
+
}
|
|
324
|
+
function indexedEntriesInRange(index, source, start, end) {
|
|
325
|
+
const result = []
|
|
326
|
+
for (const [date, keys] of index) {
|
|
327
|
+
if (date < start || date > end) continue
|
|
328
|
+
for (const key of keys) {
|
|
329
|
+
const item = source.get(key)
|
|
330
|
+
if (item !== undefined) result.push({ item, date })
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return result
|
|
334
|
+
}
|
|
335
|
+
function usageBasisEqual(first, identity, values) {
|
|
336
|
+
if (first === null || first === undefined) return false
|
|
337
|
+
const firstIdentity = first.identity || first.modelId
|
|
338
|
+
const left = coerceIdentity(firstIdentity)
|
|
339
|
+
const right = coerceIdentity(identity)
|
|
340
|
+
if (left.identityKey !== right.identityKey) return false
|
|
341
|
+
return ['input', 'output', 'cacheRead', 'cacheWrite', 'reasoning'].every((key) => num(first.values && first.values[key]) === num(values && values[key]))
|
|
342
|
+
}
|
|
343
|
+
function resolveCurrentPricing(identity) {
|
|
344
|
+
const normalized = coerceIdentity(identity)
|
|
345
|
+
const key = normalized.identityKey
|
|
346
|
+
const cached = state.pricingResolutionCache.get(key)
|
|
347
|
+
if (cached !== undefined) return cached
|
|
348
|
+
const resolved = resolvePricing(normalized, state.pricingState)
|
|
349
|
+
state.pricingResolutionCache.set(key, resolved)
|
|
350
|
+
while (state.pricingResolutionCache.size > 5000) state.pricingResolutionCache.delete(state.pricingResolutionCache.keys().next().value)
|
|
351
|
+
return resolved
|
|
352
|
+
}
|
|
353
|
+
function costForUsage(values, identity, previous, pricingAt, pricingTimeSource) {
|
|
354
|
+
const previousCost = normalizeCostSnapshot(previous && previous.cost)
|
|
355
|
+
if (previousCost !== null && previousCost.pricingMode === 'official-model' && previousCost.pricingTimeSource !== 'legacy-unknown' && usageBasisEqual(previous, identity, values) && costTemporalCompatible(previousCost, identity, pricingAt, pricingTimeSource)) return previousCost
|
|
356
|
+
return calculateCost(values, resolveCurrentPricing(identity), pricingAt, pricingTimeSource)
|
|
357
|
+
}
|
|
358
|
+
function costTemporalCompatible(previousCost, identity, pricingAt, pricingTimeSource) {
|
|
359
|
+
if (previousCost.pricingAt === null || previousCost.pricingTimeSource === null) return false
|
|
360
|
+
const resolved = resolveCurrentPricing(identity)
|
|
361
|
+
const plan = temporalPlanFor(resolved, pricingAt)
|
|
362
|
+
const normalizedAt = Number.isFinite(pricingAt) && pricingAt >= 0 ? Math.trunc(pricingAt) : null
|
|
363
|
+
const normalizedSource = pricingTimeSource === 'request-context' || pricingTimeSource === 'usage-event' ? pricingTimeSource : 'usage-event'
|
|
364
|
+
return previousCost.pricingBand === plan.band && previousCost.pricingPolicyId === plan.policyId && previousCost.pricingPolicyHash === plan.policyHash && previousCost.pricingTimezone === plan.timezone && previousCost.temporalApplicable === (plan.status === 'applied') && previousCost.temporalExemptReason === plan.exemptReason && previousCost.pricingAt === normalizedAt && previousCost.pricingTimeSource === normalizedSource
|
|
365
|
+
}
|
|
366
|
+
function resetAggregationState() {
|
|
367
|
+
state.aggregationGeneration += 1
|
|
368
|
+
state.wsMeta.clear()
|
|
369
|
+
if (state.sessionContextTimes) state.sessionContextTimes.clear()
|
|
370
|
+
state.pathIndex.clear()
|
|
371
|
+
state.memberOf.clear()
|
|
372
|
+
state.byDay.clear()
|
|
373
|
+
state.byDayUtc.clear()
|
|
374
|
+
state.perWorkspace.clear()
|
|
375
|
+
// The deleted bucket is re-derived from the ledger during the rebuild that
|
|
376
|
+
// follows, so a stale flag cannot advertise an empty row.
|
|
377
|
+
state.retiredBucketUsed = false
|
|
378
|
+
state.perModel.clear()
|
|
379
|
+
state.usageByStep.clear()
|
|
380
|
+
if (state.perSession instanceof Map) state.perSession.clear()
|
|
381
|
+
if (state.sessionsByLocalDate instanceof Map) state.sessionsByLocalDate.clear()
|
|
382
|
+
if (state.sessionsByUtcDate instanceof Map) state.sessionsByUtcDate.clear()
|
|
383
|
+
if (state.rowTouch instanceof Map) state.rowTouch.clear()
|
|
384
|
+
state.turnRecords.clear()
|
|
385
|
+
state.usageByLocalDate.clear()
|
|
386
|
+
state.usageByUtcDate.clear()
|
|
387
|
+
for (const timer of state.liveResyncTimers.values()) clearTimeout(timer)
|
|
388
|
+
state.liveResyncTimers.clear()
|
|
389
|
+
state.liveResyncAttempts.clear()
|
|
390
|
+
const triggerSessions = state.liveResyncTriggers instanceof Map ? Array.from(state.liveResyncTriggers.keys()) : []
|
|
391
|
+
state.liveResyncPending.clear()
|
|
392
|
+
for (const sid of triggerSessions) state.liveResyncPending.add(sid)
|
|
393
|
+
state.reconcileHintScheduled = false
|
|
394
|
+
if (state.baselineFallbackTimer !== null) {
|
|
395
|
+
clearTimeout(state.baselineFallbackTimer)
|
|
396
|
+
state.baselineFallbackTimer = null
|
|
397
|
+
}
|
|
398
|
+
state.baselineRetryScheduled = false
|
|
399
|
+
state.queryCache.clear()
|
|
400
|
+
state.recordsQueryCache.clear()
|
|
401
|
+
state.sessionModel.clear()
|
|
402
|
+
state.sessionCount.clear()
|
|
403
|
+
state.sessionSeq.clear()
|
|
404
|
+
state.chains.clear()
|
|
405
|
+
state.knownSessionIds.clear()
|
|
406
|
+
state.totals.turns = 0
|
|
407
|
+
state.totals.input = 0
|
|
408
|
+
state.totals.output = 0
|
|
409
|
+
state.totals.cacheRead = 0
|
|
410
|
+
state.totals.cacheWrite = 0
|
|
411
|
+
state.totals.reasoning = 0
|
|
412
|
+
Object.assign(state.totals.cost, createCostAccumulator())
|
|
413
|
+
state.scan.started = false
|
|
414
|
+
state.scan.done = false
|
|
415
|
+
state.scan.scanned = 0
|
|
416
|
+
state.scan.total = 0
|
|
417
|
+
state.scan.failed = 0
|
|
418
|
+
resetSyncState()
|
|
419
|
+
state.baselineRetryDelay = 1000
|
|
420
|
+
markStatsChanged(['data', 'metadata', 'scan'])
|
|
421
|
+
return state.aggregationGeneration
|
|
422
|
+
}
|
|
423
|
+
function ensureDay(dayMap, date) {
|
|
424
|
+
let day = dayMap.get(date)
|
|
425
|
+
if (day === undefined) {
|
|
426
|
+
day = { turns: 0, tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 }, cost: createCostAccumulator(), perWs: new Map(), byWs: new Map(), byModel: new Map(), sessionIds: new Set(), sessionRefs: new Map(), queryUsage: new Map(), queryTurns: new Map(), queryHours: new Map() }
|
|
427
|
+
dayMap.set(date, day)
|
|
428
|
+
markStatsChanged('metadata')
|
|
429
|
+
}
|
|
430
|
+
return day
|
|
431
|
+
}
|
|
432
|
+
function ensureWs(wsId) {
|
|
433
|
+
let ws = state.perWorkspace.get(wsId)
|
|
434
|
+
if (ws === undefined) {
|
|
435
|
+
ws = { turns: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, cost: createCostAccumulator() }
|
|
436
|
+
state.perWorkspace.set(wsId, ws)
|
|
437
|
+
markStatsChanged('metadata')
|
|
438
|
+
}
|
|
439
|
+
return ws
|
|
440
|
+
}
|
|
441
|
+
function ensureDayWs(day, wsId) {
|
|
442
|
+
let w = day.byWs.get(wsId)
|
|
443
|
+
if (w === undefined) {
|
|
444
|
+
w = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, cost: createCostAccumulator() }
|
|
445
|
+
day.byWs.set(wsId, w)
|
|
446
|
+
}
|
|
447
|
+
return w
|
|
448
|
+
}
|
|
449
|
+
function ensureModel(value) {
|
|
450
|
+
const identity = coerceIdentity(value)
|
|
451
|
+
let item = state.perModel.get(identity.identityKey)
|
|
452
|
+
if (item === undefined) {
|
|
453
|
+
item = { ...identity, model: identity.label, calls: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, cost: createCostAccumulator() }
|
|
454
|
+
state.perModel.set(identity.identityKey, item)
|
|
455
|
+
markStatsChanged('metadata')
|
|
456
|
+
}
|
|
457
|
+
return item
|
|
458
|
+
}
|
|
459
|
+
function ensureDayModel(day, value) {
|
|
460
|
+
const identity = coerceIdentity(value)
|
|
461
|
+
let item = day.byModel.get(identity.identityKey)
|
|
462
|
+
if (item === undefined) {
|
|
463
|
+
item = { ...identity, model: identity.label, calls: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, cost: createCostAccumulator() }
|
|
464
|
+
day.byModel.set(identity.identityKey, item)
|
|
465
|
+
}
|
|
466
|
+
return item
|
|
467
|
+
}
|
|
468
|
+
function adjustValues(target, values, direction) {
|
|
469
|
+
target.input += values.input * direction
|
|
470
|
+
target.output += values.output * direction
|
|
471
|
+
target.cacheRead += values.cacheRead * direction
|
|
472
|
+
target.cacheWrite += values.cacheWrite * direction
|
|
473
|
+
target.reasoning += values.reasoning * direction
|
|
474
|
+
}
|
|
475
|
+
function addQueryMetricTokens(target, values, direction = 1) {
|
|
476
|
+
target.input += values.input * direction
|
|
477
|
+
target.output += values.output * direction
|
|
478
|
+
target.cacheRead += values.cacheRead * direction
|
|
479
|
+
target.cacheWrite += values.cacheWrite * direction
|
|
480
|
+
target.reasoning += values.reasoning * direction
|
|
481
|
+
}
|
|
482
|
+
function noValues(target) {
|
|
483
|
+
return target.input === 0 && target.output === 0 && target.cacheRead === 0 && target.cacheWrite === 0 && target.reasoning === 0
|
|
484
|
+
}
|
|
485
|
+
function adjustDaySession(day, sid, direction) {
|
|
486
|
+
if (sid === undefined || sid === null) return
|
|
487
|
+
const current = day.sessionRefs.get(sid) || 0
|
|
488
|
+
const next = current + direction
|
|
489
|
+
if (next > 0) {
|
|
490
|
+
day.sessionRefs.set(sid, next)
|
|
491
|
+
day.sessionIds.add(sid)
|
|
492
|
+
} else {
|
|
493
|
+
day.sessionRefs.delete(sid)
|
|
494
|
+
day.sessionIds.delete(sid)
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
function adjustDay(dayMap, date, wsId, values, identity, direction, sid, cost, count = 1) {
|
|
498
|
+
const day = ensureDay(dayMap, date)
|
|
499
|
+
adjustDaySession(day, sid, direction * count)
|
|
500
|
+
adjustValues(day.tokens, values, direction)
|
|
501
|
+
addCostAggregateDirection(day.cost, cost, direction)
|
|
502
|
+
const dayWs = ensureDayWs(day, wsId)
|
|
503
|
+
adjustValues(dayWs, values, direction)
|
|
504
|
+
addCostAggregateDirection(dayWs.cost, cost, direction)
|
|
505
|
+
if (noValues(dayWs)) day.byWs.delete(wsId)
|
|
506
|
+
const dayModel = ensureDayModel(day, identity)
|
|
507
|
+
dayModel.calls += direction * count
|
|
508
|
+
adjustValues(dayModel, values, direction)
|
|
509
|
+
addCostAggregateDirection(dayModel.cost, cost, direction)
|
|
510
|
+
if (dayModel.calls === 0 && noValues(dayModel)) day.byModel.delete(dayModel.identityKey)
|
|
511
|
+
}
|
|
512
|
+
function addCostAggregateDirection(target, cost, direction) {
|
|
513
|
+
if (direction === 1) {
|
|
514
|
+
if (isCostAccumulator(target)) addCostAccumulator(target, cost, 1)
|
|
515
|
+
else addCostAggregate(target, cost)
|
|
516
|
+
return
|
|
517
|
+
}
|
|
518
|
+
if (direction !== -1) return
|
|
519
|
+
if (isCostAccumulator(target)) {
|
|
520
|
+
addCostAccumulator(target, cost, -1)
|
|
521
|
+
return
|
|
522
|
+
}
|
|
523
|
+
const status = cost && typeof cost.status === 'string' ? cost.status : 'unpriced'
|
|
524
|
+
if (status === 'priced') {
|
|
525
|
+
target.input = decimalSubtract(target.input, cost.breakdown && cost.breakdown.input)
|
|
526
|
+
target.output = decimalSubtract(target.output, cost.breakdown && cost.breakdown.output)
|
|
527
|
+
target.cacheRead = decimalSubtract(target.cacheRead, cost.breakdown && cost.breakdown.cacheRead)
|
|
528
|
+
target.cacheWrite = decimalSubtract(target.cacheWrite, cost.breakdown && cost.breakdown.cacheWrite)
|
|
529
|
+
target.baseTotal = decimalSubtract(target.baseTotal, cost.baseTotal)
|
|
530
|
+
target.total = decimalSubtract(target.total, cost.total)
|
|
531
|
+
target.pricedCalls -= 1
|
|
532
|
+
} else if (status === 'ambiguous') target.ambiguousCalls -= 1
|
|
533
|
+
else if (status === 'unsupported') target.unsupportedCalls -= 1
|
|
534
|
+
else target.unpricedCalls -= 1
|
|
535
|
+
}
|
|
536
|
+
function adjustQueryRefs(bucket, field, sid, direction) {
|
|
537
|
+
if (sid === undefined || sid === null) return
|
|
538
|
+
const refs = field === 'sessions' ? bucket.sessionRefs : bucket[field + 'Refs']
|
|
539
|
+
const values = bucket[field]
|
|
540
|
+
const current = refs.get(sid) || 0
|
|
541
|
+
const next = current + direction
|
|
542
|
+
if (next > 0) { refs.set(sid, next); values.add(sid) }
|
|
543
|
+
else { refs.delete(sid); values.delete(sid) }
|
|
544
|
+
}
|
|
545
|
+
function queryUsageMetric(identity) {
|
|
546
|
+
return { identity: coerceIdentity(identity), calls: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, cost: createCostAccumulator(), sessions: new Set(), sessionRefs: new Map(), turnKeys: new Set(), turnKeyRefs: new Map() }
|
|
547
|
+
}
|
|
548
|
+
function queryTurnMetric(identity) {
|
|
549
|
+
return { identity: coerceIdentity(identity), turns: 0, sessions: new Set(), sessionRefs: new Map(), records: new Map() }
|
|
550
|
+
}
|
|
551
|
+
function queryWorkspaceMap(root, wsId, create) {
|
|
552
|
+
let value = root.get(wsId)
|
|
553
|
+
if (value === undefined && create) { value = new Map(); root.set(wsId, value) }
|
|
554
|
+
return value
|
|
555
|
+
}
|
|
556
|
+
function adjustQueryUsageMap(root, item, direction, count = 1) {
|
|
557
|
+
bumpCount(direction === 1 ? 'query.add' : 'query.sub')
|
|
558
|
+
const identity = coerceIdentity(item.identity || item.modelId)
|
|
559
|
+
// A subtraction has to land even when the bucket is gone: taking the row back out
|
|
560
|
+
// of the cube is what keeps it honest. The old code returned early when the
|
|
561
|
+
// workspace or bucket was missing, so the amounts it had added stayed in the cube
|
|
562
|
+
// for ever - the current day's query cube drifted several times above the day
|
|
563
|
+
// cube that way. Buckets are also no longer deleted when they empty, so a later
|
|
564
|
+
// subtraction still finds them.
|
|
565
|
+
const workspace = queryWorkspaceMap(root, item.wsId, true)
|
|
566
|
+
if (workspace === undefined) return
|
|
567
|
+
let bucket = workspace.get(identity.identityKey)
|
|
568
|
+
if (bucket === undefined) { bucket = queryUsageMetric(identity); workspace.set(identity.identityKey, bucket) }
|
|
569
|
+
bucket.calls += direction * count
|
|
570
|
+
adjustQueryRefs(bucket, 'sessions', item.sid, direction * count)
|
|
571
|
+
addQueryMetricTokens(bucket, item.values, direction)
|
|
572
|
+
addCostAccumulator(bucket.cost, item.cost, direction)
|
|
573
|
+
if (item.turn !== null && item.turn !== undefined) {
|
|
574
|
+
const turnKey = String(item.sid) + ':turn:' + String(item.turn)
|
|
575
|
+
const current = bucket.turnKeyRefs.get(turnKey) || 0
|
|
576
|
+
const next = current + direction * count
|
|
577
|
+
if (next > 0) { bucket.turnKeyRefs.set(turnKey, next); bucket.turnKeys.add(turnKey) }
|
|
578
|
+
else { bucket.turnKeyRefs.delete(turnKey); bucket.turnKeys.delete(turnKey) }
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
/** The trailing segment of "<sid>:turn:<n>" as a number, or null. */
|
|
582
|
+
function turnNumberOf(turnKey) {
|
|
583
|
+
const at = typeof turnKey === 'string' ? turnKey.lastIndexOf(':') : -1
|
|
584
|
+
if (at < 0) return null
|
|
585
|
+
const value = Number(turnKey.slice(at + 1))
|
|
586
|
+
return Number.isSafeInteger(value) && value >= 0 ? value : null
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* Drop the turn-key reference a usage row left behind in a query bucket, without
|
|
590
|
+
* touching that bucket's calls/tokens/cost (the caller already did that).
|
|
591
|
+
*/
|
|
592
|
+
function adjustQueryUsageTurnRefs(day, item, direction, utc) {
|
|
593
|
+
if (day === undefined) return
|
|
594
|
+
adjustQueryTurnRefsMap(day.queryUsage, item, direction)
|
|
595
|
+
adjustQueryTurnRefsMap(ensureQueryHour(day, hourStartOf(item.time, utc)).usage, item, direction)
|
|
596
|
+
}
|
|
597
|
+
function adjustQueryTurnRefsMap(root, item, direction) {
|
|
598
|
+
const identity = coerceIdentity(item.identity || item.modelId)
|
|
599
|
+
const workspace = queryWorkspaceMap(root, item.wsId, direction === 1)
|
|
600
|
+
if (workspace === undefined) return
|
|
601
|
+
const bucket = workspace.get(identity.identityKey)
|
|
602
|
+
if (bucket === undefined) return
|
|
603
|
+
if (item.turn === null || item.turn === undefined) return
|
|
604
|
+
const turnKey = String(item.sid) + ':turn:' + String(item.turn)
|
|
605
|
+
const current = bucket.turnKeyRefs.get(turnKey) || 0
|
|
606
|
+
const next = current + direction
|
|
607
|
+
if (next > 0) { bucket.turnKeyRefs.set(turnKey, next); bucket.turnKeys.add(turnKey) }
|
|
608
|
+
else { bucket.turnKeyRefs.delete(turnKey); bucket.turnKeys.delete(turnKey) }
|
|
609
|
+
}
|
|
610
|
+
function ensureQueryHour(day, hour) {
|
|
611
|
+
let value = day.queryHours.get(hour)
|
|
612
|
+
if (value === undefined) { value = { usage: new Map(), turns: new Map() }; day.queryHours.set(hour, value) }
|
|
613
|
+
return value
|
|
614
|
+
}
|
|
615
|
+
function adjustQueryUsage(day, item, direction, utc, count = 1) {
|
|
616
|
+
if (day === undefined) return
|
|
617
|
+
// Every add has to come from the wrapper that also feeds the day cube. Anything
|
|
618
|
+
// else would explain two cubes diverging while their call counters agree.
|
|
619
|
+
if (state.diagInWrapper !== true) {
|
|
620
|
+
bumpCount('q.outside.wrapper.' + (direction === 1 ? 'add' : 'sub'))
|
|
621
|
+
bumpSum('q.outside.wrapper.input', direction * ((item.values && item.values.input) || 0))
|
|
622
|
+
}
|
|
623
|
+
// Scoped to the day the add lands on, so the total can be compared with what the
|
|
624
|
+
// root actually holds (and with the day cube the same row also feeds).
|
|
625
|
+
const scopeKey = utc ? item.dateUtc : item.date
|
|
626
|
+
if (direction === 1) bumpCount('q.' + (utc ? 'utc' : 'local') + '.count.' + scopeKey)
|
|
627
|
+
bumpSum('q.' + (utc ? 'utc' : 'local') + '.' + scopeKey, direction * ((item.values && item.values.input) || 0))
|
|
628
|
+
adjustQueryUsageMap(day.queryUsage, item, direction, count)
|
|
629
|
+
const hour = hourStartOf(item.time, utc)
|
|
630
|
+
adjustQueryUsageMap(ensureQueryHour(day, hour).usage, item, direction, count)
|
|
631
|
+
const hourValue = day.queryHours.get(hour)
|
|
632
|
+
if (hourValue !== undefined && hourValue.usage.size === 0 && hourValue.turns.size === 0) day.queryHours.delete(hour)
|
|
633
|
+
}
|
|
634
|
+
function adjustQueryTurnMap(root, turn, direction) {
|
|
635
|
+
const identity = coerceIdentity(turn.identity)
|
|
636
|
+
const workspace = queryWorkspaceMap(root, turn.wsId, direction === 1)
|
|
637
|
+
if (workspace === undefined) return
|
|
638
|
+
let bucket = workspace.get(identity.identityKey)
|
|
639
|
+
if (bucket === undefined && direction === 1) { bucket = queryTurnMetric(identity); workspace.set(identity.identityKey, bucket) }
|
|
640
|
+
if (bucket === undefined) return
|
|
641
|
+
if (direction === 1) {
|
|
642
|
+
if (bucket.records.has(turn.key)) return
|
|
643
|
+
bucket.records.set(turn.key, turn)
|
|
644
|
+
bucket.turns += 1
|
|
645
|
+
adjustQueryRefs(bucket, 'sessions', turn.sid, 1)
|
|
646
|
+
} else {
|
|
647
|
+
const previous = bucket.records.get(turn.key)
|
|
648
|
+
if (previous === undefined) return
|
|
649
|
+
bucket.records.delete(turn.key)
|
|
650
|
+
bucket.turns -= 1
|
|
651
|
+
adjustQueryRefs(bucket, 'sessions', previous.sid, -1)
|
|
652
|
+
}
|
|
653
|
+
if (direction === -1 && bucket.turns <= 0) workspace.delete(identity.identityKey)
|
|
654
|
+
if (workspace.size === 0) root.delete(turn.wsId)
|
|
655
|
+
}
|
|
656
|
+
function adjustQueryTurn(day, turn, direction, utc) {
|
|
657
|
+
if (day === undefined) return
|
|
658
|
+
adjustQueryTurnMap(day.queryTurns, turn, direction)
|
|
659
|
+
const hour = hourStartOf(turn.time, utc)
|
|
660
|
+
const hourValue = ensureQueryHour(day, hour)
|
|
661
|
+
adjustQueryTurnMap(hourValue.turns, turn, direction)
|
|
662
|
+
if (hourValue.usage.size === 0 && hourValue.turns.size === 0) day.queryHours.delete(hour)
|
|
663
|
+
}
|
|
664
|
+
function bumpCount(name, amount = 1) {
|
|
665
|
+
if (!(state.diagCounters instanceof Map)) state.diagCounters = new Map()
|
|
666
|
+
state.diagCounters.set(name, (state.diagCounters.get(name) || 0) + amount)
|
|
667
|
+
}
|
|
668
|
+
function bumpSum(name, amount) {
|
|
669
|
+
if (!Number.isFinite(amount) || amount === 0) return
|
|
670
|
+
if (!(state.diagSums instanceof Map)) state.diagSums = new Map()
|
|
671
|
+
state.diagSums.set(name, (state.diagSums.get(name) || 0) + amount)
|
|
672
|
+
}
|
|
673
|
+
/**
|
|
674
|
+
* What the accounting added versus what it holds. The two must agree; when they do
|
|
675
|
+
* not, the difference names the layer that loses or duplicates a row.
|
|
676
|
+
*/
|
|
677
|
+
function accountingSnapshot() {
|
|
678
|
+
const todayKey = dayKey(Date.now())
|
|
679
|
+
const day = state.byDay.get(todayKey)
|
|
680
|
+
let queryInput = 0
|
|
681
|
+
let queryCalls = 0
|
|
682
|
+
let queryBuckets = 0
|
|
683
|
+
if (day !== undefined && day.queryUsage instanceof Map) {
|
|
684
|
+
const seen = new Set()
|
|
685
|
+
for (const models of day.queryUsage.values()) {
|
|
686
|
+
queryBuckets += models.size
|
|
687
|
+
for (const bucket of models.values()) {
|
|
688
|
+
if (seen.has(bucket)) continue
|
|
689
|
+
seen.add(bucket)
|
|
690
|
+
queryInput += bucket.input
|
|
691
|
+
queryCalls += bucket.calls
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
return {
|
|
696
|
+
date: todayKey,
|
|
697
|
+
added: Object.fromEntries(state.diagSums instanceof Map ? state.diagSums : []),
|
|
698
|
+
dayTokensInput: day === undefined ? null : day.tokens.input,
|
|
699
|
+
dayCalls: day === undefined || !(day.byModel instanceof Map) ? null : Array.from(day.byModel.values()).reduce((acc, row) => acc + (row.calls || 0), 0),
|
|
700
|
+
queryRootInput: queryInput,
|
|
701
|
+
queryRootCalls: queryCalls,
|
|
702
|
+
queryBuckets,
|
|
703
|
+
residentRows: state.usageByStep instanceof Map ? state.usageByStep.size : null,
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
function adjustUsage(wsId, time, values, identity, direction, sid, cachedDates, cost, turn, count = 1) {
|
|
707
|
+
bumpCount(direction === 1 ? 'day.add' : 'day.sub')
|
|
708
|
+
const normalized = coerceIdentity(identity)
|
|
709
|
+
const dates = cachedDates && typeof cachedDates.local === 'string' && typeof cachedDates.utc === 'string' ? cachedDates : dateKeys(time)
|
|
710
|
+
const modelTotals = ensureModel(normalized)
|
|
711
|
+
modelTotals.calls += direction * count
|
|
712
|
+
adjustValues(modelTotals, values, direction)
|
|
713
|
+
addCostAggregateDirection(modelTotals.cost, cost, direction)
|
|
714
|
+
if (modelTotals.calls === 0 && noValues(modelTotals)) {
|
|
715
|
+
state.perModel.delete(normalized.identityKey)
|
|
716
|
+
markStatsChanged('metadata')
|
|
717
|
+
}
|
|
718
|
+
adjustValues(state.totals, values, direction)
|
|
719
|
+
addCostAggregateDirection(state.totals.cost, cost, direction)
|
|
720
|
+
const ws = ensureWs(wsId)
|
|
721
|
+
adjustValues(ws, values, direction)
|
|
722
|
+
addCostAggregateDirection(ws.cost, cost, direction)
|
|
723
|
+
// What the same row hands the day cube's local map: comparing this with what the
|
|
724
|
+
// query root received says whether one call carries one value or two.
|
|
725
|
+
if (direction === 1) bumpCount('d.local.count.' + dates.local)
|
|
726
|
+
bumpSum('d.local.' + dates.local, direction * ((values && values.input) || 0))
|
|
727
|
+
adjustDay(state.byDay, dates.local, wsId, values, normalized, direction, sid, cost, count)
|
|
728
|
+
adjustDay(state.byDayUtc, dates.utc, wsId, values, normalized, direction, sid, cost, count)
|
|
729
|
+
adjustSessionUsage(sid, direction, { time, date: dates.local, dateUtc: dates.utc, wsId, identity: normalized, identityKey: normalized.identityKey, values, cost, turn }, count)
|
|
730
|
+
}
|
|
731
|
+
/**
|
|
732
|
+
* Undo one session's contribution. The per-session digest describes it without
|
|
733
|
+
* the rows, so a session whose detail rows were evicted still unwinds exactly;
|
|
734
|
+
* a session that only ever existed as rows (a hand-built test state) falls back
|
|
735
|
+
* to the row walk.
|
|
736
|
+
*/
|
|
737
|
+
function removeSession(sid) {
|
|
738
|
+
if (typeof sid !== 'string' || sid === '') return 0
|
|
739
|
+
let removed = 0
|
|
740
|
+
const session = sessionAggregateFor(sid, false)
|
|
741
|
+
if (session !== undefined) {
|
|
742
|
+
for (const bucket of session.buckets.values()) {
|
|
743
|
+
const identity = coerceIdentity(bucket.identity)
|
|
744
|
+
const count = bucket.calls
|
|
745
|
+
const values = { ...bucket.values }
|
|
746
|
+
const cost = createCostAccumulator()
|
|
747
|
+
addCostAccumulator(cost, bucket.cost)
|
|
748
|
+
adjustUsage(bucket.wsId, bucket.time, values, identity, -1, sid, { local: bucket.date, utc: bucket.dateUtc }, cost, null, count)
|
|
749
|
+
const item = { sid, wsId: bucket.wsId, time: bucket.time, values, identity, cost, turn: null }
|
|
750
|
+
adjustQueryUsage(state.byDay.get(bucket.date), item, -1, false, count)
|
|
751
|
+
adjustQueryUsage(state.byDayUtc.get(bucket.dateUtc), item, -1, true, count)
|
|
752
|
+
for (const [turnKey, turnCount] of bucket.turns) {
|
|
753
|
+
const turnItem = { sid, wsId: bucket.wsId, time: bucket.time, identity, turn: turnNumberOf(turnKey) }
|
|
754
|
+
const refs = Number.isSafeInteger(turnCount) && turnCount > 0 ? turnCount : 1
|
|
755
|
+
adjustQueryUsageTurnRefs(state.byDay.get(bucket.date), turnItem, -refs, false)
|
|
756
|
+
adjustQueryUsageTurnRefs(state.byDayUtc.get(bucket.dateUtc), turnItem, -refs, true)
|
|
757
|
+
}
|
|
758
|
+
removed += count
|
|
759
|
+
}
|
|
760
|
+
for (const [key, dates] of session.keys) {
|
|
761
|
+
state.usageByStep.delete(key)
|
|
762
|
+
removeDateIndex(state.usageByLocalDate, dates.date, key)
|
|
763
|
+
removeDateIndex(state.usageByUtcDate, dates.dateUtc, key)
|
|
764
|
+
if (state.sessionsByLocalDate instanceof Map) forgetSessionDate(state.sessionsByLocalDate, dates.date, sid)
|
|
765
|
+
if (state.sessionsByUtcDate instanceof Map) forgetSessionDate(state.sessionsByUtcDate, dates.dateUtc, sid)
|
|
766
|
+
}
|
|
767
|
+
if (state.rowTouch instanceof Map) state.rowTouch.delete(sid)
|
|
768
|
+
state.perSession.delete(sid)
|
|
769
|
+
} else {
|
|
770
|
+
const usageKeys = []
|
|
771
|
+
for (const [key, item] of state.usageByStep) if (item.sid === sid) usageKeys.push([key, item])
|
|
772
|
+
for (const [key, item] of usageKeys) {
|
|
773
|
+
unindexUsage(item)
|
|
774
|
+
adjustUsage(item.wsId, item.time, item.values, item.identity || item.modelId, -1, sid, { local: item.date, utc: item.dateUtc }, item.cost, item.turn)
|
|
775
|
+
adjustQueryUsage(state.byDay.get(item.date), item, -1, false)
|
|
776
|
+
adjustQueryUsage(state.byDayUtc.get(item.dateUtc), item, -1, true)
|
|
777
|
+
state.usageByStep.delete(key)
|
|
778
|
+
removed += 1
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
const turnKeys = []
|
|
782
|
+
for (const [key, turn] of state.turnRecords) if (turn.sid === sid) turnKeys.push([key, turn])
|
|
783
|
+
for (const [key, turn] of turnKeys) {
|
|
784
|
+
removeTurnRecord(turn)
|
|
785
|
+
state.turnRecords.delete(key)
|
|
786
|
+
removed += 1
|
|
787
|
+
}
|
|
788
|
+
state.sessionContextTimes.delete(sid)
|
|
789
|
+
state.sessionModel.delete(sid)
|
|
790
|
+
if (removed > 0) markStatsChanged('data')
|
|
791
|
+
return removed
|
|
792
|
+
}
|
|
793
|
+
// Re-key every aggregate entry owned by one session to another workspace id.
|
|
794
|
+
//
|
|
795
|
+
// Retiring a workspace cannot rely on the ledger alone: usage folded from the
|
|
796
|
+
// live feed in the last few milliseconds has no persisted row yet, so it would
|
|
797
|
+
// keep a workspace id the dashboard no longer advertises. Moving the in-memory
|
|
798
|
+
// records keeps the totals, the per-workspace split and the query buckets
|
|
799
|
+
// consistent no matter when the workspace disappears.
|
|
800
|
+
function retargetSession(sid, workspaceId) {
|
|
801
|
+
if (typeof sid !== 'string' || sid === '' || typeof workspaceId !== 'string' || workspaceId === '') return 0
|
|
802
|
+
let moved = 0
|
|
803
|
+
for (const item of state.usageByStep.values()) {
|
|
804
|
+
if (item.sid !== sid || item.wsId === workspaceId) continue
|
|
805
|
+
const previousWs = item.wsId
|
|
806
|
+
const identity = item.identity || item.modelId
|
|
807
|
+
const dates = { local: item.date, utc: item.dateUtc }
|
|
808
|
+
// Decrement under the old workspace, then increment under the new one: the
|
|
809
|
+
// session, model and global totals cancel out exactly.
|
|
810
|
+
adjustUsage(previousWs, item.time, item.values, identity, -1, sid, dates, item.cost, item.turn)
|
|
811
|
+
adjustQueryUsage(state.byDay.get(item.date), item, -1, false)
|
|
812
|
+
adjustQueryUsage(state.byDayUtc.get(item.dateUtc), item, -1, true)
|
|
813
|
+
item.wsId = workspaceId
|
|
814
|
+
const session = sessionAggregateFor(sid, false)
|
|
815
|
+
const keyMeta = session && session.keys.get(item.key)
|
|
816
|
+
if (keyMeta !== undefined) session.keys.set(item.key, { ...keyMeta, wsId: workspaceId })
|
|
817
|
+
adjustUsage(workspaceId, item.time, item.values, identity, 1, sid, dates, item.cost, item.turn)
|
|
818
|
+
adjustQueryUsage(state.byDay.get(item.date), item, 1, false)
|
|
819
|
+
adjustQueryUsage(state.byDayUtc.get(item.dateUtc), item, 1, true)
|
|
820
|
+
moved += 1
|
|
821
|
+
}
|
|
822
|
+
for (const turn of state.turnRecords.values()) {
|
|
823
|
+
if (turn.sid !== sid || turn.wsId === workspaceId) continue
|
|
824
|
+
removeTurnRecord(turn)
|
|
825
|
+
turn.wsId = workspaceId
|
|
826
|
+
ensureWs(workspaceId).turns += 1
|
|
827
|
+
state.totals.turns += 1
|
|
828
|
+
addDayTurn(state.byDay, turn.date, workspaceId, turn.sid, 1)
|
|
829
|
+
addDayTurn(state.byDayUtc, turn.dateUtc, workspaceId, turn.sid, 1)
|
|
830
|
+
adjustQueryTurn(state.byDay.get(turn.date), turn, 1, false)
|
|
831
|
+
adjustQueryTurn(state.byDayUtc.get(turn.dateUtc), turn, 1, true)
|
|
832
|
+
moved += 1
|
|
833
|
+
}
|
|
834
|
+
if (moved > 0) markStatsChanged('data')
|
|
835
|
+
return moved
|
|
836
|
+
}
|
|
837
|
+
function upsertUsageSample(target, sample, options = {}) {
|
|
838
|
+
const previous = target.get(sample.key)
|
|
839
|
+
const candidate = sample.cost === undefined
|
|
840
|
+
? { ...sample, cost: costForUsage(sample.values, sample.identity, options.costPrevious === undefined ? previous : options.costPrevious, sample.pricingAt, sample.pricingTimeSource) }
|
|
841
|
+
: sample
|
|
842
|
+
const digest = target === state.usageByStep && options.materialize !== false && previous === undefined
|
|
843
|
+
? sessionAggregateFor(sample.sid, false)
|
|
844
|
+
: undefined
|
|
845
|
+
if (digest !== undefined && !digest.keys.has(sample.key) && sample.materialization === 'live') {
|
|
846
|
+
const turn = Number.isSafeInteger(sample.turn) && sample.turn >= 0 ? sample.turn : null
|
|
847
|
+
const completedTurnKey = turn === null ? '' : String(sample.sid) + ':turn:' + String(turn)
|
|
848
|
+
if (completedTurnKey !== '' && state.turnRecords instanceof Map && state.turnRecords.has(completedTurnKey)) {
|
|
849
|
+
bumpCount('row.turnCovered')
|
|
850
|
+
return { accepted: false, reason: 'completed-turn-replay', key: sample.key, previous: undefined, next: undefined }
|
|
851
|
+
}
|
|
852
|
+
// The live stream may replay older history under a renumbered key. Keep this
|
|
853
|
+
// heuristic out of scans: persisted ledger events, including late timestamps,
|
|
854
|
+
// are authoritative and must always be folded.
|
|
855
|
+
if (typeof digest.newestTime === 'number' && digest.newestTime > 0 && Number.isFinite(sample.time) && sample.time < digest.newestTime) {
|
|
856
|
+
bumpCount('row.olderThanDigest')
|
|
857
|
+
return { accepted: false, reason: 'older-than-ledger-digest', key: sample.key, previous: undefined, next: undefined }
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
const result = upsertUsageSampleState(target, candidate)
|
|
861
|
+
if (!result.accepted || target !== state.usageByStep || options.materialize === false) return result
|
|
862
|
+
const previousItem = result.previous
|
|
863
|
+
if (previousItem === undefined) {
|
|
864
|
+
// A row the digest already knows was counted before - either by an earlier fold
|
|
865
|
+
// whose detail rows were handed back to the ledger, or by the same request being
|
|
866
|
+
// replayed when the host restarts. Store the row (the log needs it) and leave the
|
|
867
|
+
// cubes alone; without this every restart counted the current day all over again.
|
|
868
|
+
if (digest !== undefined && digest.keys.has(sample.key)) {
|
|
869
|
+
bumpCount('row.replayed')
|
|
870
|
+
return result
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
if (previousItem !== undefined) {
|
|
874
|
+
unindexUsage(previousItem)
|
|
875
|
+
adjustUsage(previousItem.wsId, previousItem.time, previousItem.values, previousItem.identity || previousItem.modelId, -1, previousItem.sid, { local: previousItem.date, utc: previousItem.dateUtc }, previousItem.cost, previousItem.turn)
|
|
876
|
+
adjustQueryUsage(state.byDay.get(previousItem.date), previousItem, -1, false)
|
|
877
|
+
adjustQueryUsage(state.byDayUtc.get(previousItem.dateUtc), previousItem, -1, true)
|
|
878
|
+
}
|
|
879
|
+
const next = result.next
|
|
880
|
+
indexUsage(next)
|
|
881
|
+
// Watch the row's own numbers while the two cubes take them: a shared values object
|
|
882
|
+
// that changes under one of the calls is the one way the same call can hand one sum
|
|
883
|
+
// to the day cube and another to the query root.
|
|
884
|
+
const values = next.values === null || next.values === undefined ? null : next.values
|
|
885
|
+
if (values !== null) {
|
|
886
|
+
if (!(state.diagValuesSeen instanceof WeakSet)) state.diagValuesSeen = new WeakSet()
|
|
887
|
+
if (state.diagValuesSeen.has(values)) bumpCount('values.shared')
|
|
888
|
+
else state.diagValuesSeen.add(values)
|
|
889
|
+
}
|
|
890
|
+
const beforeUsage = values === null ? null : values.input
|
|
891
|
+
adjustUsage(next.wsId, next.time, next.values, next.identity, 1, next.sid, { local: next.date, utc: next.dateUtc }, next.cost, next.turn)
|
|
892
|
+
const afterUsage = values === null ? null : values.input
|
|
893
|
+
if (beforeUsage !== afterUsage) bumpCount('values.mutated.by.usage')
|
|
894
|
+
state.diagInWrapper = true
|
|
895
|
+
// Measure the local bucket's own delta for this one row and compare it with the
|
|
896
|
+
// number handed in: if the query path adds more than it was given, the difference
|
|
897
|
+
// is the bug, named with a real amount instead of a ratio.
|
|
898
|
+
const localDay = state.byDay.get(next.date)
|
|
899
|
+
const bucketBefore = localDay === undefined || !(localDay.queryUsage instanceof Map) ? null : (() => {
|
|
900
|
+
const models = localDay.queryUsage.get(next.wsId)
|
|
901
|
+
const bucket = models === undefined ? undefined : models.get(coerceIdentity(next.identity).identityKey)
|
|
902
|
+
return bucket === undefined ? 0 : bucket.input
|
|
903
|
+
})()
|
|
904
|
+
adjustQueryUsage(localDay, next, 1, false)
|
|
905
|
+
adjustQueryUsage(state.byDayUtc.get(next.dateUtc), next, 1, true)
|
|
906
|
+
state.diagInWrapper = false
|
|
907
|
+
if (bucketBefore !== null) {
|
|
908
|
+
const models = localDay.queryUsage.get(next.wsId)
|
|
909
|
+
const bucket = models === undefined ? undefined : models.get(coerceIdentity(next.identity).identityKey)
|
|
910
|
+
const bucketAfter = bucket === undefined ? 0 : bucket.input
|
|
911
|
+
const expected = next.values === undefined || next.values === null ? 0 : next.values.input
|
|
912
|
+
if (bucketAfter - bucketBefore !== expected) {
|
|
913
|
+
bumpCount('q.bucket.delta.mismatch')
|
|
914
|
+
if (state.diagMismatch === undefined || state.diagMismatch === null) {
|
|
915
|
+
state.diagMismatch = { key: next.key, delta: bucketAfter - bucketBefore, expected, date: next.date, wsId: next.wsId }
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
if (values !== null && afterUsage !== values.input) bumpCount('values.mutated.by.query')
|
|
920
|
+
markStatsChanged('data')
|
|
921
|
+
return result
|
|
922
|
+
}
|
|
923
|
+
function adjustQueryCost(item, cost, direction) {
|
|
924
|
+
if (item === null || item === undefined || typeof item !== 'object') return
|
|
925
|
+
const identity = coerceIdentity(item.identity || item.modelId)
|
|
926
|
+
const dates = typeof item.date === 'string' && typeof item.dateUtc === 'string' ? { local: item.date, utc: item.dateUtc } : dateKeys(item.time)
|
|
927
|
+
const update = (day, utc) => {
|
|
928
|
+
if (day === undefined) return
|
|
929
|
+
const models = day.queryUsage.get(item.wsId)
|
|
930
|
+
const bucket = models && models.get(identity.identityKey)
|
|
931
|
+
if (bucket !== undefined) addCostAccumulator(bucket.cost, cost, direction)
|
|
932
|
+
const hour = hourStartOf(item.time, utc)
|
|
933
|
+
const hourValue = day.queryHours.get(hour)
|
|
934
|
+
const hourModels = hourValue && hourValue.usage.get(item.wsId)
|
|
935
|
+
const hourBucket = hourModels && hourModels.get(identity.identityKey)
|
|
936
|
+
if (hourBucket !== undefined) addCostAccumulator(hourBucket.cost, cost, direction)
|
|
937
|
+
}
|
|
938
|
+
update(state.byDay.get(dates.local), false)
|
|
939
|
+
update(state.byDayUtc.get(dates.utc), true)
|
|
940
|
+
}
|
|
941
|
+
function addUsage(wsId, time, usage, model, sid, data, seq, materialization = 'live', pricingAt, pricingTimeSource) {
|
|
942
|
+
bumpCount('live.addUsage')
|
|
943
|
+
if (!validEventTime(time)) return
|
|
944
|
+
const values = usageValues(usage)
|
|
945
|
+
if (values === null || noValues(values)) return
|
|
946
|
+
const identity = coerceIdentity(model)
|
|
947
|
+
const eventSeq = normalizeEventSeq(seq)
|
|
948
|
+
const dates = dateKeys(time)
|
|
949
|
+
const key = usageStepKey(sid, data, seq)
|
|
950
|
+
// One row per request comes from the ledger with a stable ":step:" key; a live
|
|
951
|
+
// event that cannot name its turn and step lands under a different key, and the
|
|
952
|
+
// ledger will never carry that one. Counting the two shapes says whether the
|
|
953
|
+
// current day is inflated by such rows.
|
|
954
|
+
if (materialization === 'live') {
|
|
955
|
+
const shape = key.includes(':step:') ? 'step' : key.includes(':usage:') ? 'usage' : key.includes(':event:') ? 'event' : key.includes(':time:') ? 'time' : 'other'
|
|
956
|
+
bumpCount('live.key.' + shape)
|
|
957
|
+
}
|
|
958
|
+
upsertUsageSample(state.usageByStep, {
|
|
959
|
+
key,
|
|
960
|
+
seq: eventSeq,
|
|
961
|
+
wsId,
|
|
962
|
+
time,
|
|
963
|
+
date: dates.local,
|
|
964
|
+
dateUtc: dates.utc,
|
|
965
|
+
values,
|
|
966
|
+
identity,
|
|
967
|
+
modelId: identity.label,
|
|
968
|
+
turn: data && Number.isSafeInteger(data.turn) && data.turn >= 0 ? data.turn : null,
|
|
969
|
+
step: data && Number.isSafeInteger(data.step) && data.step >= 0 ? data.step : null,
|
|
970
|
+
materialization,
|
|
971
|
+
sid,
|
|
972
|
+
...(pricingAt === undefined ? {} : { pricingAt }),
|
|
973
|
+
...(pricingTimeSource === undefined ? {} : { pricingTimeSource }),
|
|
974
|
+
})
|
|
975
|
+
}
|
|
976
|
+
function addDayTurn(dayMap, date, wsId, sid, direction = 1) {
|
|
977
|
+
const day = ensureDay(dayMap, date)
|
|
978
|
+
adjustDaySession(day, sid, direction)
|
|
979
|
+
day.turns = Math.max(0, day.turns + direction)
|
|
980
|
+
const next = (day.perWs.get(wsId) || 0) + direction
|
|
981
|
+
if (next > 0) day.perWs.set(wsId, next)
|
|
982
|
+
else day.perWs.delete(wsId)
|
|
983
|
+
}
|
|
984
|
+
function removeTurnRecord(turn) {
|
|
985
|
+
const ws = state.perWorkspace.get(turn.wsId)
|
|
986
|
+
if (ws !== undefined) ws.turns = Math.max(0, ws.turns - 1)
|
|
987
|
+
state.totals.turns = Math.max(0, state.totals.turns - 1)
|
|
988
|
+
addDayTurn(state.byDay, turn.date, turn.wsId, turn.sid, -1)
|
|
989
|
+
addDayTurn(state.byDayUtc, turn.dateUtc, turn.wsId, turn.sid, -1)
|
|
990
|
+
adjustQueryTurn(state.byDay.get(turn.date), turn, -1, false)
|
|
991
|
+
adjustQueryTurn(state.byDayUtc.get(turn.dateUtc), turn, -1, true)
|
|
992
|
+
}
|
|
993
|
+
function turnRecordKey(sid, turn, seq, time) {
|
|
994
|
+
if (Number.isSafeInteger(turn)) return sid + ':turn:' + turn
|
|
995
|
+
if (Number.isSafeInteger(seq) && seq >= 0) return sid + ':event:' + seq
|
|
996
|
+
return sid + ':time:' + String(time)
|
|
997
|
+
}
|
|
998
|
+
function addTurn(wsId, time, sid, turn, identity, materialization = 'live', seq) {
|
|
999
|
+
if (!validEventTime(time)) return
|
|
1000
|
+
const key = turnRecordKey(sid, turn, seq, time)
|
|
1001
|
+
if (state.turnRecords.has(key)) return
|
|
1002
|
+
const normalized = coerceIdentity(identity)
|
|
1003
|
+
const dates = dateKeys(time)
|
|
1004
|
+
const record = { key, sid, wsId, time, date: dates.local, dateUtc: dates.utc, turn: typeof turn === 'number' ? turn : null, identity: normalized, materialization }
|
|
1005
|
+
state.turnRecords.set(key, record)
|
|
1006
|
+
ensureWs(wsId).turns += 1
|
|
1007
|
+
state.totals.turns += 1
|
|
1008
|
+
addDayTurn(state.byDay, dates.local, wsId, sid)
|
|
1009
|
+
addDayTurn(state.byDayUtc, dates.utc, wsId, sid)
|
|
1010
|
+
adjustQueryTurn(state.byDay.get(record.date), record, 1, false)
|
|
1011
|
+
adjustQueryTurn(state.byDayUtc.get(record.dateUtc), record, 1, true)
|
|
1012
|
+
markStatsChanged('data')
|
|
1013
|
+
}
|
|
1014
|
+
const UNKNOWN_MODEL_LABEL = '未知模型(历史记录缺少路由)'
|
|
1015
|
+
function textOrNull(value) {
|
|
1016
|
+
return typeof value === 'string' && value.trim() !== '' ? value.trim() : null
|
|
1017
|
+
}
|
|
1018
|
+
function identityLabel(provider, requestedModel, actualModel, legacyLabel) {
|
|
1019
|
+
const model = actualModel || requestedModel
|
|
1020
|
+
if (model !== null) return provider === null ? model : provider + ' / ' + model
|
|
1021
|
+
return legacyLabel || UNKNOWN_MODEL_LABEL
|
|
1022
|
+
}
|
|
1023
|
+
function makeIdentity(provider, requestedModel, actualModel, legacyLabel) {
|
|
1024
|
+
const normalizedProvider = textOrNull(provider)
|
|
1025
|
+
const normalizedRequested = textOrNull(requestedModel)
|
|
1026
|
+
const normalizedActual = textOrNull(actualModel)
|
|
1027
|
+
const normalizedLegacy = textOrNull(legacyLabel)
|
|
1028
|
+
const key = JSON.stringify([normalizedProvider, normalizedRequested, normalizedActual, normalizedLegacy])
|
|
1029
|
+
return {
|
|
1030
|
+
identityKey: key,
|
|
1031
|
+
provider: normalizedProvider,
|
|
1032
|
+
requestedModel: normalizedRequested,
|
|
1033
|
+
actualModel: normalizedActual,
|
|
1034
|
+
label: identityLabel(normalizedProvider, normalizedRequested, normalizedActual, normalizedLegacy),
|
|
1035
|
+
legacy: normalizedLegacy !== null && normalizedProvider === null && normalizedRequested === null && normalizedActual === null,
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
function identityFromLegacy(label) {
|
|
1039
|
+
return makeIdentity(null, null, null, textOrNull(label) || UNKNOWN_MODEL_LABEL)
|
|
1040
|
+
}
|
|
1041
|
+
function isCanonicalIdentity(value) {
|
|
1042
|
+
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'
|
|
1043
|
+
}
|
|
1044
|
+
function coerceIdentity(value) {
|
|
1045
|
+
if (isCanonicalIdentity(value)) return value
|
|
1046
|
+
if (value !== null && typeof value === 'object') {
|
|
1047
|
+
return makeIdentity(value.provider, value.requestedModel, value.actualModel, value.legacyLabel || (value.legacy === true ? value.label : null))
|
|
1048
|
+
}
|
|
1049
|
+
if (typeof value === 'string' && value !== '') return identityFromLegacy(value)
|
|
1050
|
+
return makeIdentity(null, null, null, UNKNOWN_MODEL_LABEL)
|
|
1051
|
+
}
|
|
1052
|
+
function routeObject(data) {
|
|
1053
|
+
if (data && typeof data === 'object' && (data.provider !== undefined || data.model !== undefined)) return data
|
|
1054
|
+
const config = data && data.header && data.header.config
|
|
1055
|
+
return config && typeof config === 'object' ? config : null
|
|
1056
|
+
}
|
|
1057
|
+
function identityFromRoute(data, fallback) {
|
|
1058
|
+
const base = fallback === undefined ? makeIdentity(null, null, null, null) : coerceIdentity(fallback)
|
|
1059
|
+
const route = routeObject(data)
|
|
1060
|
+
if (route === null) return base
|
|
1061
|
+
return makeIdentity(route.provider === undefined ? base.provider : route.provider, route.model === undefined ? base.requestedModel : route.model, null, base.legacy ? base.label : null)
|
|
1062
|
+
}
|
|
1063
|
+
function identityFromMessage(data, fallback) {
|
|
1064
|
+
const base = fallback === undefined ? makeIdentity(null, null, null, null) : coerceIdentity(fallback)
|
|
1065
|
+
const source = data && data.message && data.message.source
|
|
1066
|
+
if (source === null || typeof source !== 'object') return base
|
|
1067
|
+
return makeIdentity(source.provider === undefined ? base.provider : source.provider, base.requestedModel || source.model, source.model, base.legacy ? base.label : null)
|
|
1068
|
+
}
|
|
1069
|
+
function modelFromRoute(data) {
|
|
1070
|
+
const identity = identityFromRoute(data)
|
|
1071
|
+
return identity.label === UNKNOWN_MODEL_LABEL ? undefined : identity.label
|
|
1072
|
+
}
|
|
1073
|
+
function modelFromMessage(data, fallback) {
|
|
1074
|
+
return identityFromMessage(data, fallback).label
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
// ---------- snapshot for the client ----------
|
|
1078
|
+
function scanSnapshot() {
|
|
1079
|
+
return { started: state.scan.started, done: state.scan.done, scanned: state.scan.scanned, total: state.scan.total, failed: state.scan.failed }
|
|
1080
|
+
}
|
|
1081
|
+
function queryRevision() {
|
|
1082
|
+
return String(state.dataRevision) + ':' + String(state.pricingRevision)
|
|
1083
|
+
}
|
|
1084
|
+
function revisionSnapshot() {
|
|
1085
|
+
return { revision: state.statsRevision, dataRevision: state.dataRevision, metadataRevision: state.metadataRevision, scanRevision: state.scanRevision, pricingRevision: state.pricingRevision, queryRevision: queryRevision() }
|
|
1086
|
+
}
|
|
1087
|
+
function statusSnapshot() {
|
|
1088
|
+
commitPendingStats()
|
|
1089
|
+
return {
|
|
1090
|
+
instanceId: state.instanceId,
|
|
1091
|
+
// The write capability is minted per plugin apply, so a reload rotates it
|
|
1092
|
+
// without moving the process instance id or any revision. The status poll
|
|
1093
|
+
// carries only this derived id (never the capability itself), which is enough
|
|
1094
|
+
// for the page to notice the rotation and re-fetch the full snapshot.
|
|
1095
|
+
capabilityId: state.capabilityId,
|
|
1096
|
+
pluginVersion: state.pluginVersion,
|
|
1097
|
+
...revisionSnapshot(),
|
|
1098
|
+
updatedAt: state.statsUpdatedAt,
|
|
1099
|
+
scan: scanSnapshot(),
|
|
1100
|
+
sync: syncSnapshot(),
|
|
1101
|
+
clientEnv: state.clientEnv,
|
|
1102
|
+
// What the plugin itself is holding, so the residency policy can be checked
|
|
1103
|
+
// from the outside instead of guessed from the host process.
|
|
1104
|
+
counters: Object.fromEntries(state.diagCounters instanceof Map ? state.diagCounters : []),
|
|
1105
|
+
mismatch: state.diagMismatch === undefined ? null : state.diagMismatch,
|
|
1106
|
+
accounting: accountingSnapshot(),
|
|
1107
|
+
memory: {
|
|
1108
|
+
heapUsedMB: Number((process.memoryUsage().heapUsed / 1048576).toFixed(1)),
|
|
1109
|
+
rssMB: Number((process.memoryUsage().rss / 1048576).toFixed(1)),
|
|
1110
|
+
residentRows: state.usageByStep instanceof Map ? state.usageByStep.size : 0,
|
|
1111
|
+
residentRecords: state.ledgerRecords instanceof Map ? state.ledgerRecords.size : 0,
|
|
1112
|
+
recordCacheMB: Number(((state.ledgerRecordBytes || 0) / 1048576).toFixed(1)),
|
|
1113
|
+
recordsCache: recordsCacheStats(state.recordsQueryCache),
|
|
1114
|
+
sessions: state.perSession instanceof Map ? state.perSession.size : 0,
|
|
1115
|
+
},
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
function serializeIdentity(identity) {
|
|
1119
|
+
const value = coerceIdentity(identity)
|
|
1120
|
+
return { identityKey: value.identityKey, provider: value.provider, requestedModel: value.requestedModel, actualModel: value.actualModel, model: value.label, legacy: value.legacy }
|
|
1121
|
+
}
|
|
1122
|
+
function serializeModelAggregate(item) {
|
|
1123
|
+
return { ...serializeIdentity(item), calls: item.calls, input: item.input, output: item.output, cacheRead: item.cacheRead, cacheWrite: item.cacheWrite, reasoning: item.reasoning, cost: serializeCostAggregate(item.cost) }
|
|
1124
|
+
}
|
|
1125
|
+
// Per-day session ids are the only part of a snapshot row that grows with the
|
|
1126
|
+
// number of sessions rather than the number of days, and the panel only needs
|
|
1127
|
+
// them for recent history (the request log for an old day is fetched on
|
|
1128
|
+
// demand). Older rows keep their count and scope session ids, so the payload
|
|
1129
|
+
// stops carrying every session id ever recorded.
|
|
1130
|
+
const SNAPSHOT_SESSION_WINDOW_DAYS = 180
|
|
1131
|
+
function serializeDays(dayMap, sessionWindowStart) {
|
|
1132
|
+
const result = []
|
|
1133
|
+
for (const pair of dayMap) {
|
|
1134
|
+
const date = pair[0]
|
|
1135
|
+
const day = pair[1]
|
|
1136
|
+
result.push({
|
|
1137
|
+
date,
|
|
1138
|
+
turns: day.turns,
|
|
1139
|
+
sessions: day.sessionIds.size,
|
|
1140
|
+
// Time-based so the local and UTC copies window identically.
|
|
1141
|
+
sessionIds: sessionWindowStart !== undefined && calendarStartOf(date, false) < sessionWindowStart ? [] : Array.from(day.sessionIds).sort(),
|
|
1142
|
+
tokens: { input: day.tokens.input, output: day.tokens.output, cacheRead: day.tokens.cacheRead, cacheWrite: day.tokens.cacheWrite, reasoning: day.tokens.reasoning },
|
|
1143
|
+
cost: serializeCostAggregate(day.cost),
|
|
1144
|
+
perWorkspace: Array.from(day.perWs, (p) => ({ workspaceId: p[0], turns: p[1] })),
|
|
1145
|
+
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, cost: serializeCostAggregate(day.byWs.get(p[0]).cost) })),
|
|
1146
|
+
byModel: Array.from(day.byModel, (p) => serializeModelAggregate(p[1])),
|
|
1147
|
+
})
|
|
1148
|
+
}
|
|
1149
|
+
result.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0))
|
|
1150
|
+
return result
|
|
1151
|
+
}
|
|
1152
|
+
function snapshot(utc = null) {
|
|
1153
|
+
commitPendingStats()
|
|
1154
|
+
const generatedAt = Date.now()
|
|
1155
|
+
const cacheKey = utc === null ? 'both' : (utc ? 'utc' : 'local')
|
|
1156
|
+
if (state.snapshotCache !== null && state.snapshotCache.revision === state.statsRevision && state.snapshotCache.key === cacheKey) return Object.assign({}, state.snapshotCache.value, { generatedAt })
|
|
1157
|
+
const windowStart = Date.now() - SNAPSHOT_SESSION_WINDOW_DAYS * 86400000
|
|
1158
|
+
const value = {
|
|
1159
|
+
...statusSnapshot(),
|
|
1160
|
+
generatedAt,
|
|
1161
|
+
usageSchemaVersion: 3,
|
|
1162
|
+
costSchemaVersion: COST_SCHEMA_VERSION,
|
|
1163
|
+
requestToken: state.requestToken,
|
|
1164
|
+
workspaces: [
|
|
1165
|
+
...Array.from(state.wsMeta.values(), (w) => ({ id: w.id, title: w.title, path: w.path })),
|
|
1166
|
+
...(state.retiredBucketUsed ? [{ id: RETIRED_WORKSPACE_ID, title: '', path: '', deleted: true, retiredBucket: true }] : []),
|
|
1167
|
+
],
|
|
1168
|
+
aliases: Object.assign({}, state.aliases),
|
|
1169
|
+
pricing: pricingSnapshot({ detailed: false }),
|
|
1170
|
+
tokenSemantics: {
|
|
1171
|
+
processedTotal: 'input + output + cacheRead + cacheWrite + reasoning',
|
|
1172
|
+
cacheRead: 'reused context tokens; not newly generated output',
|
|
1173
|
+
cacheWrite: 'tokens written into a provider cache',
|
|
1174
|
+
// v1.0.7: structured, machine-readable accounting semantics (cc-switch
|
|
1175
|
+
// input_token_semantics parity). DSH reports input as fresh (cache read /
|
|
1176
|
+
// cache write sit in their own buckets) — verified against real ledger data;
|
|
1177
|
+
// reasoning is bucketed separately from output and assumed non-overlapping.
|
|
1178
|
+
semantics: {
|
|
1179
|
+
input: 'fresh (excludes cache-read and cache-write tokens, bucketed separately)',
|
|
1180
|
+
buckets: ['input', 'output', 'cacheRead', 'cacheWrite', 'reasoning'],
|
|
1181
|
+
inputIncludesCache: false,
|
|
1182
|
+
cacheBucketed: true,
|
|
1183
|
+
reasoningSeparate: true,
|
|
1184
|
+
gate: 'all-zero usage rows are ignored; pure cache-read requests still count',
|
|
1185
|
+
},
|
|
1186
|
+
},
|
|
1187
|
+
costSemantics: {
|
|
1188
|
+
source: 'models.dev',
|
|
1189
|
+
currency: 'USD',
|
|
1190
|
+
buckets: ['input', 'output', 'cacheRead', 'cacheWrite'],
|
|
1191
|
+
input: 'fresh (DSH TokenUsage already excludes cache)',
|
|
1192
|
+
reasoning: 'not added to output again; provider output already carries completion/thoughts where reported',
|
|
1193
|
+
multiplier: 'applies only to final total',
|
|
1194
|
+
providerMatching: 'DSH provider is ignored; only the official model vendor entry is selected',
|
|
1195
|
+
historical: 'positive non-tiered cost snapshots are stable; legacy tiered snapshots migrate to unsupported; only unresolved usage is eligible for backfill',
|
|
1196
|
+
},
|
|
1197
|
+
totals: { turns: state.totals.turns, sessions: state.sessionCount.size, input: state.totals.input, output: state.totals.output, cacheRead: state.totals.cacheRead, cacheWrite: state.totals.cacheWrite, reasoning: state.totals.reasoning, cost: serializeCostAggregate(state.totals.cost) },
|
|
1198
|
+
perWorkspace: Array.from(state.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, cost: serializeCostAggregate(p[1].cost) })),
|
|
1199
|
+
perModel: Array.from(state.perModel.values(), (item) => serializeModelAggregate(item)),
|
|
1200
|
+
byDay: utc === true ? [] : serializeDays(state.byDay, windowStart),
|
|
1201
|
+
byDayUtc: utc === false ? [] : serializeDays(state.byDayUtc, windowStart),
|
|
1202
|
+
}
|
|
1203
|
+
state.snapshotCache = { revision: state.statsRevision, key: cacheKey, value }
|
|
1204
|
+
return Object.assign({}, value, { generatedAt })
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
function validDateText(value) {
|
|
1208
|
+
if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false
|
|
1209
|
+
const year = Number(value.slice(0, 4))
|
|
1210
|
+
const month = Number(value.slice(5, 7))
|
|
1211
|
+
const day = Number(value.slice(8, 10))
|
|
1212
|
+
const date = new Date(Date.UTC(year, month - 1, day))
|
|
1213
|
+
return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day
|
|
1214
|
+
}
|
|
1215
|
+
function shiftDateText(value, days, utc) {
|
|
1216
|
+
const parts = value.split('-').map(Number)
|
|
1217
|
+
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)
|
|
1218
|
+
return utc ? dayKeyUtc(date.getTime()) : dayKey(date.getTime())
|
|
1219
|
+
}
|
|
1220
|
+
function queryScopeFromRequest(req) {
|
|
1221
|
+
let url
|
|
1222
|
+
try { url = new URL(req.url || '/', 'http://all-usage.local') } catch (err) { return { ok: false, message: 'bad-query' } }
|
|
1223
|
+
const rawUtc = url.searchParams.get('utc')
|
|
1224
|
+
if (rawUtc !== null && rawUtc !== '' && rawUtc !== '0' && rawUtc !== '1') return { ok: false, message: 'invalid-timezone' }
|
|
1225
|
+
const utc = rawUtc === '1'
|
|
1226
|
+
const today = utc ? dayKeyUtc(Date.now()) : dayKey(Date.now())
|
|
1227
|
+
const start = url.searchParams.get('start') || today
|
|
1228
|
+
const end = url.searchParams.get('end') || today
|
|
1229
|
+
if (!validDateText(start) || !validDateText(end) || start > end) return { ok: false, message: 'invalid-date-range' }
|
|
1230
|
+
const readParam = (name, max) => {
|
|
1231
|
+
const value = url.searchParams.get(name)
|
|
1232
|
+
if (value === null || value === '') return undefined
|
|
1233
|
+
return value.length <= max ? value : null
|
|
1234
|
+
}
|
|
1235
|
+
const workspaceId = readParam('workspaceId', 256)
|
|
1236
|
+
const provider = readParam('provider', 256)
|
|
1237
|
+
const modelKey = readParam('modelKey', 1024)
|
|
1238
|
+
if (workspaceId === null || provider === null || modelKey === null) return { ok: false, message: 'query-too-long' }
|
|
1239
|
+
return { ok: true, scope: { start, end, utc, workspaceId, provider, modelKey } }
|
|
1240
|
+
}
|
|
1241
|
+
function scopeFingerprint(scope) {
|
|
1242
|
+
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 })
|
|
1243
|
+
}
|
|
1244
|
+
function dateInScope(date, scope) {
|
|
1245
|
+
return date >= scope.start && date <= scope.end
|
|
1246
|
+
}
|
|
1247
|
+
function modelNameOfIdentity(identity) {
|
|
1248
|
+
const normalized = coerceIdentity(identity)
|
|
1249
|
+
const structured = normalized.actualModel || normalized.requestedModel
|
|
1250
|
+
if (structured !== null) return structured
|
|
1251
|
+
if (normalized.legacy && typeof normalized.label === 'string') {
|
|
1252
|
+
const separator = normalized.label.indexOf(' / ')
|
|
1253
|
+
if (separator > 0) return normalized.label.slice(separator + 3)
|
|
1254
|
+
}
|
|
1255
|
+
return normalized.label
|
|
1256
|
+
}
|
|
1257
|
+
/**
|
|
1258
|
+
* The same model is often served through several routes, whose ids differ only by
|
|
1259
|
+
* a vendor namespace or letter case (`deepseek-v4.1-flash` versus
|
|
1260
|
+
* `deepseek/deepseek-v4.1-flash`). A model filter is about the model, so it
|
|
1261
|
+
* compares this key: the last path segment, lowercased. The exact identity key
|
|
1262
|
+
* still matches, which keeps a stored route-level value working.
|
|
1263
|
+
*/
|
|
1264
|
+
function modelNameKeyOf(value) {
|
|
1265
|
+
if (typeof value !== 'string') return ''
|
|
1266
|
+
const text = value.trim()
|
|
1267
|
+
if (text === '') return ''
|
|
1268
|
+
const slash = text.lastIndexOf('/')
|
|
1269
|
+
return (slash >= 0 ? text.slice(slash + 1) : text).trim().toLowerCase()
|
|
1270
|
+
}
|
|
1271
|
+
function identityMatchesScope(identity, scope) {
|
|
1272
|
+
const normalized = coerceIdentity(identity)
|
|
1273
|
+
if (scope.provider !== undefined && scope.provider !== null && normalized.provider !== scope.provider) return false
|
|
1274
|
+
if (scope.modelKey !== undefined && scope.modelKey !== null) {
|
|
1275
|
+
const wanted = String(scope.modelKey)
|
|
1276
|
+
const byIdentity = normalized.identityKey === wanted
|
|
1277
|
+
const byName = modelNameOfIdentity(normalized) === wanted
|
|
1278
|
+
const byModelKey = modelNameKeyOf(modelNameOfIdentity(normalized)) === modelNameKeyOf(wanted) && modelNameKeyOf(wanted) !== ''
|
|
1279
|
+
if (!byIdentity && !byName && !byModelKey) return false
|
|
1280
|
+
}
|
|
1281
|
+
return true
|
|
1282
|
+
}
|
|
1283
|
+
function queryMetric() {
|
|
1284
|
+
return { turns: 0, calls: 0, sessions: new Set(), turnKeys: new Set(), input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, cost: createCostAccumulator() }
|
|
1285
|
+
}
|
|
1286
|
+
function queryAggregate() {
|
|
1287
|
+
return { totals: queryMetric(), days: new Map(), workspaces: new Map(), models: new Map() }
|
|
1288
|
+
}
|
|
1289
|
+
function queryDay(aggregate, date) {
|
|
1290
|
+
let day = aggregate.days.get(date)
|
|
1291
|
+
if (day === undefined) { day = queryMetric(); day.date = date; aggregate.days.set(date, day) }
|
|
1292
|
+
return day
|
|
1293
|
+
}
|
|
1294
|
+
function queryWorkspace(aggregate, workspaceId) {
|
|
1295
|
+
let row = aggregate.workspaces.get(workspaceId)
|
|
1296
|
+
if (row === undefined) { row = queryMetric(); row.workspaceId = workspaceId; aggregate.workspaces.set(workspaceId, row) }
|
|
1297
|
+
return row
|
|
1298
|
+
}
|
|
1299
|
+
function queryModel(aggregate, identity) {
|
|
1300
|
+
const normalized = coerceIdentity(identity)
|
|
1301
|
+
let row = aggregate.models.get(normalized.identityKey)
|
|
1302
|
+
if (row === undefined) { row = queryMetric(); Object.assign(row, serializeIdentity(normalized)); aggregate.models.set(normalized.identityKey, row) }
|
|
1303
|
+
return row
|
|
1304
|
+
}
|
|
1305
|
+
function addQueryTokens(metric, values) {
|
|
1306
|
+
metric.input += values.input
|
|
1307
|
+
metric.output += values.output
|
|
1308
|
+
metric.cacheRead += values.cacheRead
|
|
1309
|
+
metric.cacheWrite += values.cacheWrite
|
|
1310
|
+
metric.reasoning += values.reasoning
|
|
1311
|
+
}
|
|
1312
|
+
function mergeQuerySessions(target, sessions) {
|
|
1313
|
+
for (const sid of sessions) target.sessions.add(sid)
|
|
1314
|
+
}
|
|
1315
|
+
function mergeQueryUsageMetric(target, bucket) {
|
|
1316
|
+
// The source's numbers are copied first, and its cost is only merged when it is
|
|
1317
|
+
// not the same accumulator: a bucket must never be able to aggregate into
|
|
1318
|
+
// itself. That aliasing is what let the current day's query numbers run away -
|
|
1319
|
+
// every fresh read doubled what the previous one had left behind.
|
|
1320
|
+
const calls = bucket.calls
|
|
1321
|
+
const values = { input: bucket.input, output: bucket.output, cacheRead: bucket.cacheRead, cacheWrite: bucket.cacheWrite, reasoning: bucket.reasoning }
|
|
1322
|
+
target.calls += calls
|
|
1323
|
+
mergeQuerySessions(target, bucket.sessions)
|
|
1324
|
+
addQueryTokens(target, values)
|
|
1325
|
+
if (target.cost !== bucket.cost) addCostAccumulator(target.cost, bucket.cost)
|
|
1326
|
+
}
|
|
1327
|
+
function addQueryUsageBucket(aggregate, bucket, date, workspaceId) {
|
|
1328
|
+
const targets = [aggregate.totals, queryDay(aggregate, date), queryWorkspace(aggregate, workspaceId)]
|
|
1329
|
+
targets.push(queryModel(aggregate, bucket.identity))
|
|
1330
|
+
for (const target of targets) mergeQueryUsageMetric(target, bucket)
|
|
1331
|
+
}
|
|
1332
|
+
function addQueryTurnMetric(target, turn) {
|
|
1333
|
+
if (target.turnKeys.has(turn.key)) return
|
|
1334
|
+
target.turnKeys.add(turn.key)
|
|
1335
|
+
target.turns += 1
|
|
1336
|
+
target.sessions.add(turn.sid)
|
|
1337
|
+
}
|
|
1338
|
+
function mergeQueryTurnBucket(target, bucket) {
|
|
1339
|
+
target.turns += bucket.turns
|
|
1340
|
+
mergeQuerySessions(target, bucket.sessions)
|
|
1341
|
+
}
|
|
1342
|
+
function addQueryTurn(aggregate, turn, date) {
|
|
1343
|
+
const targets = [aggregate.totals, queryDay(aggregate, date), queryWorkspace(aggregate, turn.wsId)]
|
|
1344
|
+
targets.push(queryModel(aggregate, turn.identity))
|
|
1345
|
+
for (const target of targets) addQueryTurnMetric(target, turn)
|
|
1346
|
+
}
|
|
1347
|
+
function addQueryTurnBucket(aggregate, bucket, date, workspaceId) {
|
|
1348
|
+
const targets = [aggregate.totals, queryDay(aggregate, date), queryWorkspace(aggregate, workspaceId)]
|
|
1349
|
+
targets.push(queryModel(aggregate, bucket.identity))
|
|
1350
|
+
for (const target of targets) mergeQueryTurnBucket(target, bucket)
|
|
1351
|
+
}
|
|
1352
|
+
function finalizeQueryMetric(metric) {
|
|
1353
|
+
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, cost: serializeCostAggregate(metric.cost) }
|
|
1354
|
+
}
|
|
1355
|
+
function finalizeQueryAggregate(aggregate) {
|
|
1356
|
+
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 } }))
|
|
1357
|
+
const perWorkspace = Array.from(aggregate.workspaces.values()).map((row) => ({ workspaceId: row.workspaceId, ...finalizeQueryMetric(row) }))
|
|
1358
|
+
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) }))
|
|
1359
|
+
return { totals: finalizeQueryMetric(aggregate.totals), daily, perWorkspace, perModel }
|
|
1360
|
+
}
|
|
1361
|
+
function heatmapDay(days, date) {
|
|
1362
|
+
let day = days.get(date)
|
|
1363
|
+
if (day === undefined) { day = { date, turns: 0, tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 }, perWorkspace: new Map() }; days.set(date, day) }
|
|
1364
|
+
return day
|
|
1365
|
+
}
|
|
1366
|
+
function addHeatmapUsage(days, bucket, date) {
|
|
1367
|
+
const day = heatmapDay(days, date)
|
|
1368
|
+
addQueryTokens(day.tokens, bucket)
|
|
1369
|
+
}
|
|
1370
|
+
function addHeatmapTurnBucket(days, bucket, date, workspaceId) {
|
|
1371
|
+
const day = heatmapDay(days, date)
|
|
1372
|
+
day.turns += bucket.turns
|
|
1373
|
+
day.perWorkspace.set(workspaceId, (day.perWorkspace.get(workspaceId) || 0) + bucket.turns)
|
|
1374
|
+
}
|
|
1375
|
+
function addHeatmapTurns(days, records, date) {
|
|
1376
|
+
const day = heatmapDay(days, date)
|
|
1377
|
+
for (const turn of records) {
|
|
1378
|
+
day.turns += 1
|
|
1379
|
+
day.perWorkspace.set(turn.wsId, (day.perWorkspace.get(turn.wsId) || 0) + 1)
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
function serializeHeatmap(days) {
|
|
1383
|
+
return Array.from(days.values()).sort((a, b) => a.date < b.date ? -1 : a.date > b.date ? 1 : 0).map((day) => ({ date: day.date, turns: day.turns, tokens: { input: day.tokens.input, output: day.tokens.output, cacheRead: day.tokens.cacheRead, cacheWrite: day.tokens.cacheWrite, reasoning: day.tokens.reasoning }, perWorkspace: Array.from(day.perWorkspace, (pair) => ({ workspaceId: pair[0], turns: pair[1] })) }))
|
|
1384
|
+
}
|
|
1385
|
+
const HOUR_MS = 60 * 60 * 1000
|
|
1386
|
+
function hourStartOf(time, utc) {
|
|
1387
|
+
const date = new Date(time)
|
|
1388
|
+
if (utc) return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), date.getUTCHours())
|
|
1389
|
+
// Reconstructing a local hour with new Date(y, m, d, h) collapses a DST
|
|
1390
|
+
// fall-back twice-repeated hour (both 01:xx map to the first occurrence).
|
|
1391
|
+
// Instead align the instant to the local hour boundary using the offset
|
|
1392
|
+
// that applied at that instant, keeping repeated local hours distinct.
|
|
1393
|
+
const offset = date.getTimezoneOffset()
|
|
1394
|
+
const shifted = time - offset * 60000
|
|
1395
|
+
return Math.floor(shifted / HOUR_MS) * HOUR_MS + offset * 60000
|
|
1396
|
+
}
|
|
1397
|
+
function calendarStartOf(dateText, utc) {
|
|
1398
|
+
const parts = dateText.split('-').map(Number)
|
|
1399
|
+
return utc ? Date.UTC(parts[0], parts[1] - 1, parts[2]) : new Date(parts[0], parts[1] - 1, parts[2]).getTime()
|
|
1400
|
+
}
|
|
1401
|
+
function nextCalendarStartOf(dateText, utc) {
|
|
1402
|
+
const parts = dateText.split('-').map(Number)
|
|
1403
|
+
return utc ? Date.UTC(parts[0], parts[1] - 1, parts[2] + 1) : new Date(parts[0], parts[1] - 1, parts[2] + 1).getTime()
|
|
1404
|
+
}
|
|
1405
|
+
function hourlyRangeOf(scope, nowMs) {
|
|
1406
|
+
if (scope.start !== scope.end) return null
|
|
1407
|
+
const start = calendarStartOf(scope.start, scope.utc)
|
|
1408
|
+
const today = scope.utc ? dayKeyUtc(nowMs) : dayKey(nowMs)
|
|
1409
|
+
const end = scope.start === today ? nowMs : nextCalendarStartOf(scope.start, scope.utc)
|
|
1410
|
+
return { start, count: Math.max(1, Math.ceil(Math.max(0, end - start) / HOUR_MS)) }
|
|
1411
|
+
}
|
|
1412
|
+
function serializeTrendMetric(time, metric) {
|
|
1413
|
+
const value = finalizeQueryMetric(metric || queryMetric())
|
|
1414
|
+
return { time, date: new Date(time).toISOString(), ...value, tokens: { input: value.input, output: value.output, cacheRead: value.cacheRead, cacheWrite: value.cacheWrite, reasoning: value.reasoning } }
|
|
1415
|
+
}
|
|
1416
|
+
function queryHourlyTrend(day, scope, nowMs, matchingTurnKeys) {
|
|
1417
|
+
const range = hourlyRangeOf(scope, nowMs)
|
|
1418
|
+
if (range === null) return []
|
|
1419
|
+
const buckets = new Map()
|
|
1420
|
+
const indexHours = new Map()
|
|
1421
|
+
const metricFor = (hour) => {
|
|
1422
|
+
// Zone offsets are not always whole hours (Lord Howe +10:30/+11), so a
|
|
1423
|
+
// floor can drop or merge fractional-hour buckets; round and clamp keeps
|
|
1424
|
+
// every event inside the requested range without losing rows.
|
|
1425
|
+
const index = Math.min(range.count - 1, Math.max(0, Math.round((hour - range.start) / HOUR_MS)))
|
|
1426
|
+
if (index < 0 || index >= range.count) return null
|
|
1427
|
+
const previousHour = indexHours.get(index)
|
|
1428
|
+
if (previousHour === undefined || hour < previousHour) indexHours.set(index, hour)
|
|
1429
|
+
let metric = buckets.get(index)
|
|
1430
|
+
if (metric === undefined) { metric = queryMetric(); buckets.set(index, metric) }
|
|
1431
|
+
return metric
|
|
1432
|
+
}
|
|
1433
|
+
const hasIdentityFilter = (scope.provider !== undefined && scope.provider !== null) || (scope.modelKey !== undefined && scope.modelKey !== null)
|
|
1434
|
+
const hours = day && day.queryHours instanceof Map ? day.queryHours : new Map()
|
|
1435
|
+
for (const [hour, value] of hours) {
|
|
1436
|
+
for (const [workspaceId, models] of value.usage) {
|
|
1437
|
+
if (scope.workspaceId !== undefined && scope.workspaceId !== null && workspaceId !== scope.workspaceId) continue
|
|
1438
|
+
for (const bucket of models.values()) {
|
|
1439
|
+
if (!identityMatchesScope(bucket.identity, scope)) continue
|
|
1440
|
+
const metric = metricFor(hour)
|
|
1441
|
+
if (metric === null) continue
|
|
1442
|
+
mergeQueryUsageMetric(metric, bucket)
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
for (const [workspaceId, models] of value.turns) {
|
|
1446
|
+
if (scope.workspaceId !== undefined && scope.workspaceId !== null && workspaceId !== scope.workspaceId) continue
|
|
1447
|
+
for (const bucket of models.values()) {
|
|
1448
|
+
if (!hasIdentityFilter || identityMatchesScope(bucket.identity, scope)) {
|
|
1449
|
+
const metric = metricFor(hour)
|
|
1450
|
+
if (metric !== null) mergeQueryTurnBucket(metric, bucket)
|
|
1451
|
+
} else if (matchingTurnKeys.size > 0) {
|
|
1452
|
+
const metric = metricFor(hour)
|
|
1453
|
+
if (metric === null) continue
|
|
1454
|
+
for (const turn of bucket.records.values()) if (matchingTurnKeys.has(turn.key)) addQueryTurnMetric(metric, turn)
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
// Emit the real bucket boundary when the bucket was rounded into this slot;
|
|
1460
|
+
// the uniform grid only labels empty slots, so fractional-hour zones keep
|
|
1461
|
+
// exact labels instead of drifting 30 minutes off.
|
|
1462
|
+
return Array.from({ length: range.count }, (_, index) => serializeTrendMetric(indexHours.has(index) ? indexHours.get(index) : range.start + index * HOUR_MS, buckets.get(index)))
|
|
1463
|
+
}
|
|
1464
|
+
/**
|
|
1465
|
+
* Rebuild a drifted scoped cube from the session digests. These retain local/UTC
|
|
1466
|
+
* hours and row/turn counts even after detail eviction. The normal update path
|
|
1467
|
+
* preserves both cubes; this remains a recovery guard for already drifted state.
|
|
1468
|
+
*/
|
|
1469
|
+
function reconcileScopedUsage(date, utc) {
|
|
1470
|
+
const dayMap = utc === true ? state.byDayUtc : state.byDay
|
|
1471
|
+
const day = dayMap.get(date)
|
|
1472
|
+
if (day === undefined || !(day.queryUsage instanceof Map)) return 0
|
|
1473
|
+
let scoped = 0
|
|
1474
|
+
for (const models of day.queryUsage.values()) for (const bucket of models.values()) scoped += bucket.input
|
|
1475
|
+
const expected = day.tokens.input
|
|
1476
|
+
if (expected === 0 || Math.abs(scoped - expected) <= Math.max(expected * 0.01, 1000)) return 0
|
|
1477
|
+
const rebuilt = new Map()
|
|
1478
|
+
const rebuiltHours = new Map()
|
|
1479
|
+
let restored = 0
|
|
1480
|
+
for (const [sid, session] of state.perSession) {
|
|
1481
|
+
for (const bucket of session.buckets.values()) {
|
|
1482
|
+
const bucketDate = utc === true ? bucket.dateUtc : bucket.date
|
|
1483
|
+
if (bucketDate !== date) continue
|
|
1484
|
+
const values = bucket.values
|
|
1485
|
+
if (values === null || values === undefined || noValues(values)) continue
|
|
1486
|
+
const scopedDay = { queryUsage: rebuilt, queryHours: rebuiltHours, queryTurns: new Map() }
|
|
1487
|
+
adjustQueryUsage(scopedDay, {
|
|
1488
|
+
sid,
|
|
1489
|
+
wsId: bucket.wsId,
|
|
1490
|
+
time: bucket.time,
|
|
1491
|
+
values,
|
|
1492
|
+
cost: bucket.cost,
|
|
1493
|
+
identity: bucket.identity,
|
|
1494
|
+
turn: null,
|
|
1495
|
+
}, 1, utc === true, bucket.calls)
|
|
1496
|
+
for (const [turnKey, count] of bucket.turns) {
|
|
1497
|
+
const turn = turnNumberOf(turnKey)
|
|
1498
|
+
if (turn === null) continue
|
|
1499
|
+
const refs = Number.isSafeInteger(count) && count > 0 ? count : 1
|
|
1500
|
+
adjustQueryUsageTurnRefs(scopedDay, { sid, wsId: bucket.wsId, time: bucket.time, identity: bucket.identity, turn }, refs, utc === true)
|
|
1501
|
+
}
|
|
1502
|
+
restored += 1
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
// Usage reconciliation must preserve the turn/end layer of each hour.
|
|
1506
|
+
for (const [hour, current] of day.queryHours) {
|
|
1507
|
+
let target = rebuiltHours.get(hour)
|
|
1508
|
+
if (target === undefined) { target = { usage: new Map(), turns: new Map() }; rebuiltHours.set(hour, target) }
|
|
1509
|
+
target.turns = current.turns
|
|
1510
|
+
}
|
|
1511
|
+
day.queryUsage = rebuilt
|
|
1512
|
+
day.queryHours = rebuiltHours
|
|
1513
|
+
bumpCount('q.reconciled.' + date, 1)
|
|
1514
|
+
return restored
|
|
1515
|
+
}
|
|
1516
|
+
function queryUsageScope(scope) {
|
|
1517
|
+
bumpCount('read.queryUsageScope')
|
|
1518
|
+
commitPendingStats()
|
|
1519
|
+
const nowMs = Date.now()
|
|
1520
|
+
const today = scope.utc ? dayKeyUtc(nowMs) : dayKey(nowMs)
|
|
1521
|
+
const hourlyCacheKey = scope.start === scope.end ? ':' + (scope.start === today ? hourStartOf(nowMs, scope.utc) : 'fixed') : ''
|
|
1522
|
+
const currentQueryRevision = queryRevision()
|
|
1523
|
+
const key = currentQueryRevision + ':' + scopeFingerprint(scope) + hourlyCacheKey
|
|
1524
|
+
const cached = state.queryCache.get(key)
|
|
1525
|
+
if (cached !== undefined) {
|
|
1526
|
+
const revisions = revisionSnapshot()
|
|
1527
|
+
return { ...cached, ...revisions, updatedAt: state.statsUpdatedAt, partial: !state.scan.done, completeThrough: { ...revisions, at: state.statsUpdatedAt } }
|
|
1528
|
+
}
|
|
1529
|
+
const now = new Date(nowMs)
|
|
1530
|
+
const weekday = scope.utc ? now.getUTCDay() : now.getDay()
|
|
1531
|
+
const sunday = shiftDateText(today, -weekday, scope.utc)
|
|
1532
|
+
const heatStart = shiftDateText(sunday, -52 * 7, scope.utc)
|
|
1533
|
+
const selected = queryAggregate()
|
|
1534
|
+
const heatmap = new Map()
|
|
1535
|
+
const matchingTurnKeys = new Set()
|
|
1536
|
+
const dayMap = scope.utc ? state.byDayUtc : state.byDay
|
|
1537
|
+
// Repair a drifted day before answering from it (cheap when they agree).
|
|
1538
|
+
for (const [date, day] of dayMap) {
|
|
1539
|
+
if (!dateInScope(date, scope)) continue
|
|
1540
|
+
reconcileScopedUsage(date, scope.utc === true)
|
|
1541
|
+
}
|
|
1542
|
+
const hasIdentityFilter = (scope.provider !== undefined && scope.provider !== null) || (scope.modelKey !== undefined && scope.modelKey !== null)
|
|
1543
|
+
for (const [date, day] of dayMap) {
|
|
1544
|
+
const inSelected = dateInScope(date, scope)
|
|
1545
|
+
const inHeat = date >= heatStart && date <= today
|
|
1546
|
+
if (!inSelected && !inHeat) continue
|
|
1547
|
+
// A bucket is counted once per day, whatever the workspace map it hangs under:
|
|
1548
|
+
// the same object can be reachable from two keys after a session is moved.
|
|
1549
|
+
const countedBuckets = new Set()
|
|
1550
|
+
for (const [workspaceId, models] of day.queryUsage) {
|
|
1551
|
+
if (scope.workspaceId !== undefined && scope.workspaceId !== null && workspaceId !== scope.workspaceId) continue
|
|
1552
|
+
for (const bucket of models.values()) {
|
|
1553
|
+
if (bucket.calls <= 0 && noValues(bucket)) continue
|
|
1554
|
+
if (countedBuckets.has(bucket)) continue
|
|
1555
|
+
countedBuckets.add(bucket)
|
|
1556
|
+
if (!identityMatchesScope(bucket.identity, scope)) continue
|
|
1557
|
+
if (hasIdentityFilter) for (const turnKey of bucket.turnKeys) matchingTurnKeys.add(turnKey)
|
|
1558
|
+
if (inSelected) addQueryUsageBucket(selected, bucket, date, workspaceId)
|
|
1559
|
+
if (inHeat) addHeatmapUsage(heatmap, bucket, date)
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
for (const [date, day] of dayMap) {
|
|
1564
|
+
const inSelected = dateInScope(date, scope)
|
|
1565
|
+
const inHeat = date >= heatStart && date <= today
|
|
1566
|
+
if (!inSelected && !inHeat) continue
|
|
1567
|
+
for (const [workspaceId, models] of day.queryTurns) {
|
|
1568
|
+
if (scope.workspaceId !== undefined && scope.workspaceId !== null && workspaceId !== scope.workspaceId) continue
|
|
1569
|
+
for (const bucket of models.values()) {
|
|
1570
|
+
if (!hasIdentityFilter || identityMatchesScope(bucket.identity, scope)) {
|
|
1571
|
+
if (inSelected) addQueryTurnBucket(selected, bucket, date, workspaceId)
|
|
1572
|
+
if (inHeat) addHeatmapTurnBucket(heatmap, bucket, date, workspaceId)
|
|
1573
|
+
continue
|
|
1574
|
+
}
|
|
1575
|
+
if (matchingTurnKeys.size === 0) continue
|
|
1576
|
+
const matching = []
|
|
1577
|
+
for (const turn of bucket.records.values()) if (matchingTurnKeys.has(turn.key)) matching.push(turn)
|
|
1578
|
+
if (matching.length === 0) continue
|
|
1579
|
+
if (inSelected) for (const turn of matching) addQueryTurn(selected, turn, date)
|
|
1580
|
+
if (inHeat) addHeatmapTurns(heatmap, matching, date)
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
const hourly = queryHourlyTrend(dayMap.get(scope.start), scope, nowMs, matchingTurnKeys)
|
|
1585
|
+
const revisions = revisionSnapshot()
|
|
1586
|
+
const result = { schemaVersion: 1, usageSchemaVersion: 3, costSchemaVersion: COST_SCHEMA_VERSION, instanceId: state.instanceId, ...revisions, updatedAt: state.statsUpdatedAt, scope: JSON.parse(scopeFingerprint(scope)), partial: !state.scan.done, completeThrough: { ...revisions, at: state.statsUpdatedAt }, ...finalizeQueryAggregate(selected), hourly, heatmap: serializeHeatmap(heatmap) }
|
|
1587
|
+
state.queryCache.set(key, result)
|
|
1588
|
+
while (state.queryCache.size > 20) state.queryCache.delete(state.queryCache.keys().next().value)
|
|
1589
|
+
return result
|
|
1590
|
+
}
|
|
1591
|
+
function opaqueRecordId(item) {
|
|
1592
|
+
return createHash('sha256').update(item.sid + '\0' + item.key).digest('hex').slice(0, 20)
|
|
1593
|
+
}
|
|
1594
|
+
function recordOrder(a, b) {
|
|
1595
|
+
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)
|
|
1596
|
+
}
|
|
1597
|
+
/**
|
|
1598
|
+
* Describe a scope's detail rows from the per-session digest alone. The result
|
|
1599
|
+
* carries only bounded metadata; the payloads are read back per page. Returns
|
|
1600
|
+
* undefined when the host cannot page rows or the digest cannot describe the
|
|
1601
|
+
* scope exactly, so the caller falls back to the full record read instead of
|
|
1602
|
+
* silently dropping a row.
|
|
1603
|
+
*/
|
|
1604
|
+
function digestRecordsResult(scope) {
|
|
1605
|
+
if (host.ledger === undefined || typeof host.ledger.readUsageRows !== 'function') return undefined
|
|
1606
|
+
const utc = scope.utc === true
|
|
1607
|
+
const index = utc ? state.usageByUtcDate : state.usageByLocalDate
|
|
1608
|
+
if (!(index instanceof Map)) return undefined
|
|
1609
|
+
const sessions = sessionsForRange(utc, scope.start, scope.end)
|
|
1610
|
+
if (!(sessions instanceof Set)) return undefined
|
|
1611
|
+
const inRange = new Map()
|
|
1612
|
+
for (const sid of sessions) {
|
|
1613
|
+
const session = state.perSession.get(sid)
|
|
1614
|
+
if (session === undefined || !(session.keys instanceof Map)) return undefined
|
|
1615
|
+
for (const [key, meta] of session.keys) {
|
|
1616
|
+
if (meta === null || typeof meta !== 'object') return undefined
|
|
1617
|
+
const date = utc ? meta.dateUtc : meta.date
|
|
1618
|
+
if (typeof date !== 'string' || date === '') return undefined
|
|
1619
|
+
if (date < scope.start || date > scope.end) continue
|
|
1620
|
+
inRange.set(key, { sid, key, date, meta })
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
// Cross-check the date index so a partially populated digest can never
|
|
1624
|
+
// shorten the page: any disagreement uses the full record read.
|
|
1625
|
+
let indexed = 0
|
|
1626
|
+
for (const [date, keys] of index) {
|
|
1627
|
+
if (date < scope.start || date > scope.end) continue
|
|
1628
|
+
for (const key of keys) {
|
|
1629
|
+
indexed += 1
|
|
1630
|
+
if (!inRange.has(key)) return undefined
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
if (indexed !== inRange.size) return undefined
|
|
1634
|
+
const candidates = []
|
|
1635
|
+
for (const entry of inRange.values()) {
|
|
1636
|
+
const { meta } = entry
|
|
1637
|
+
if (scope.workspaceId !== undefined && scope.workspaceId !== null && meta.wsId !== scope.workspaceId) continue
|
|
1638
|
+
if (meta.identity === undefined || meta.identity === null) return undefined
|
|
1639
|
+
if (!identityMatchesScope(meta.identity, scope)) continue
|
|
1640
|
+
if (!Number.isFinite(meta.time) || !Number.isFinite(meta.seq)) return undefined
|
|
1641
|
+
candidates.push({ sid: entry.sid, key: entry.key, date: entry.date, time: meta.time, seq: meta.seq })
|
|
1642
|
+
}
|
|
1643
|
+
candidates.sort(recordOrder)
|
|
1644
|
+
// A small scope whose rows are all still resident keeps the real payloads,
|
|
1645
|
+
// exactly as the full read would have; anything evicted pages by key.
|
|
1646
|
+
const rows = new Array(candidates.length)
|
|
1647
|
+
let resident = true
|
|
1648
|
+
for (let position = 0; position < candidates.length; position += 1) {
|
|
1649
|
+
const candidate = candidates[position]
|
|
1650
|
+
const item = state.usageByStep.get(candidate.key)
|
|
1651
|
+
if (item === undefined || item.sid !== candidate.sid) { resident = false; break }
|
|
1652
|
+
rows[position] = { item, date: candidate.date }
|
|
1653
|
+
}
|
|
1654
|
+
if (resident) return { rows, forceIndex: false }
|
|
1655
|
+
return { rows: candidates.map(candidate => ({ item: { sid: candidate.sid, key: candidate.key }, date: candidate.date })), forceIndex: true }
|
|
1656
|
+
}
|
|
1657
|
+
async function queryRecords(scope, cursor, limit) {
|
|
1658
|
+
bumpCount('read.queryRecords')
|
|
1659
|
+
commitPendingStats()
|
|
1660
|
+
const fingerprint = scopeFingerprint(scope)
|
|
1661
|
+
let currentQueryRevision = queryRevision()
|
|
1662
|
+
let offset = 0
|
|
1663
|
+
const hasCursor = cursor !== undefined && cursor !== ''
|
|
1664
|
+
if (hasCursor) {
|
|
1665
|
+
try {
|
|
1666
|
+
const decoded = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'))
|
|
1667
|
+
if (decoded.queryRevision !== currentQueryRevision || decoded.scope !== fingerprint || !Number.isInteger(decoded.offset) || decoded.offset < 0) return { error: 'stale-cursor' }
|
|
1668
|
+
offset = decoded.offset
|
|
1669
|
+
} catch (err) { return { error: 'bad-cursor' } }
|
|
1670
|
+
}
|
|
1671
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
1672
|
+
const cacheKey = currentQueryRevision + ':' + fingerprint
|
|
1673
|
+
let entry = getRecordsResult(state.recordsQueryCache, cacheKey)
|
|
1674
|
+
if (entry === undefined) {
|
|
1675
|
+
const digest = digestRecordsResult(scope)
|
|
1676
|
+
if (digest !== undefined) {
|
|
1677
|
+
// The digest path never loads the whole range: its compact index pages
|
|
1678
|
+
// through only the sessions each page actually needs.
|
|
1679
|
+
entry = cacheRecordsResult(state.recordsQueryCache, cacheKey, digest.rows, digest.forceIndex)
|
|
1680
|
+
evictColdRows()
|
|
1681
|
+
} else try {
|
|
1682
|
+
if (host.ledger !== undefined && typeof host.ledger.loadRowsForRange === 'function') {
|
|
1683
|
+
await host.ledger.loadRowsForRange(scope.utc === true, scope.start, scope.end)
|
|
1684
|
+
}
|
|
1685
|
+
commitPendingStats()
|
|
1686
|
+
const observed = queryRevision()
|
|
1687
|
+
if (observed !== currentQueryRevision) {
|
|
1688
|
+
if (hasCursor) return { error: 'stale-cursor' }
|
|
1689
|
+
currentQueryRevision = observed
|
|
1690
|
+
continue
|
|
1691
|
+
}
|
|
1692
|
+
entry = getRecordsResult(state.recordsQueryCache, cacheKey)
|
|
1693
|
+
if (entry === undefined) {
|
|
1694
|
+
const index = scope.utc ? state.usageByUtcDate : state.usageByLocalDate
|
|
1695
|
+
const rows = indexedEntriesInRange(index, state.usageByStep, scope.start, scope.end).filter(({ item }) => {
|
|
1696
|
+
if (scope.workspaceId !== undefined && scope.workspaceId !== null && item.wsId !== scope.workspaceId) return false
|
|
1697
|
+
return identityMatchesScope(item.identity || item.modelId, scope)
|
|
1698
|
+
})
|
|
1699
|
+
rows.sort((left, right) => recordOrder(left.item, right.item))
|
|
1700
|
+
entry = cacheRecordsResult(state.recordsQueryCache, cacheKey, rows)
|
|
1701
|
+
}
|
|
1702
|
+
} finally { evictColdRows() }
|
|
1703
|
+
}
|
|
1704
|
+
let page
|
|
1705
|
+
if (entry.kind === 'rows') page = entry.rows.slice(offset, offset + limit)
|
|
1706
|
+
else {
|
|
1707
|
+
const references = recordPageReferences(entry, offset, limit)
|
|
1708
|
+
if (host.ledger !== undefined && typeof host.ledger.readUsageRows === 'function') {
|
|
1709
|
+
try { page = await host.ledger.readUsageRows(references, scope.utc === true) }
|
|
1710
|
+
catch (error) {
|
|
1711
|
+
commitPendingStats()
|
|
1712
|
+
const observed = queryRevision()
|
|
1713
|
+
if (observed === currentQueryRevision) throw error
|
|
1714
|
+
if (hasCursor) return { error: 'stale-cursor' }
|
|
1715
|
+
currentQueryRevision = observed
|
|
1716
|
+
continue
|
|
1717
|
+
}
|
|
1718
|
+
} else {
|
|
1719
|
+
page = references.map(({ sid, key, keyHash }) => {
|
|
1720
|
+
const item = key === undefined ? Array.from(state.usageByStep.values()).find(row => row.sid === sid && recordKeyHash(row.key) === keyHash) : state.usageByStep.get(key)
|
|
1721
|
+
return item !== undefined && item.sid === sid ? { item, date: scope.utc ? item.dateUtc : item.date } : undefined
|
|
1722
|
+
})
|
|
1723
|
+
}
|
|
1724
|
+
// Page reads can race with a reprice/resync even on a cache hit. Never
|
|
1725
|
+
// attach the fresh revision to rows selected from the previous index.
|
|
1726
|
+
commitPendingStats()
|
|
1727
|
+
const observed = queryRevision()
|
|
1728
|
+
if (observed !== currentQueryRevision) {
|
|
1729
|
+
// The index was built under the previous revision; releasing it keeps an
|
|
1730
|
+
// unstable construction from leaving a cached scope behind.
|
|
1731
|
+
if (state.recordsQueryCache.get(cacheKey) === entry) deleteRecordsResult(state.recordsQueryCache, cacheKey)
|
|
1732
|
+
if (hasCursor) return { error: 'stale-cursor' }
|
|
1733
|
+
currentQueryRevision = observed
|
|
1734
|
+
continue
|
|
1735
|
+
}
|
|
1736
|
+
if (page.some(row => row === undefined)) {
|
|
1737
|
+
if (state.recordsQueryCache.get(cacheKey) === entry) deleteRecordsResult(state.recordsQueryCache, cacheKey)
|
|
1738
|
+
// Rebuilding from an incomplete record would hide a missing row while
|
|
1739
|
+
// leaving its aggregate and revision unchanged, even on a first page.
|
|
1740
|
+
return { error: 'stale-cursor' }
|
|
1741
|
+
}
|
|
1742
|
+
}
|
|
1743
|
+
const items = page.map(({ item, date }) => {
|
|
1744
|
+
const identity = coerceIdentity(item.identity || item.modelId)
|
|
1745
|
+
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, cost: item.cost, materialization: item.materialization || 'unknown' }
|
|
1746
|
+
})
|
|
1747
|
+
const length = entry.kind === 'rows' ? entry.rows.length : entry.ids.length
|
|
1748
|
+
const nextOffset = offset + items.length
|
|
1749
|
+
return { schemaVersion: 1, usageSchemaVersion: 3, costSchemaVersion: COST_SCHEMA_VERSION, instanceId: state.instanceId, ...revisionSnapshot(), scope: JSON.parse(fingerprint), items, hasMore: nextOffset < length, nextCursor: nextOffset < length ? Buffer.from(JSON.stringify({ queryRevision: currentQueryRevision, scope: fingerprint, offset: nextOffset })).toString('base64url') : null }
|
|
1750
|
+
}
|
|
1751
|
+
return { error: 'stale-cursor' }
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
return {
|
|
1755
|
+
dayKey,
|
|
1756
|
+
dayKeyUtc,
|
|
1757
|
+
dateKeys,
|
|
1758
|
+
num,
|
|
1759
|
+
validEventTime,
|
|
1760
|
+
addDateIndex,
|
|
1761
|
+
removeDateIndex,
|
|
1762
|
+
indexUsage,
|
|
1763
|
+
unindexUsage,
|
|
1764
|
+
indexedEntriesInRange,
|
|
1765
|
+
usageBasisEqual,
|
|
1766
|
+
resolveCurrentPricing,
|
|
1767
|
+
costForUsage,
|
|
1768
|
+
removeSession,
|
|
1769
|
+
retargetSession,
|
|
1770
|
+
resetAggregationState,
|
|
1771
|
+
ensureDay,
|
|
1772
|
+
ensureWs,
|
|
1773
|
+
ensureDayWs,
|
|
1774
|
+
ensureModel,
|
|
1775
|
+
ensureDayModel,
|
|
1776
|
+
adjustValues,
|
|
1777
|
+
noValues,
|
|
1778
|
+
adjustDaySession,
|
|
1779
|
+
adjustDay,
|
|
1780
|
+
addCostAggregateDirection,
|
|
1781
|
+
adjustSessionCostOnly,
|
|
1782
|
+
adjustUsage,
|
|
1783
|
+
adjustQueryCost,
|
|
1784
|
+
upsertUsageSample,
|
|
1785
|
+
addUsage,
|
|
1786
|
+
addDayTurn,
|
|
1787
|
+
turnRecordKey,
|
|
1788
|
+
addTurn,
|
|
1789
|
+
textOrNull,
|
|
1790
|
+
identityLabel,
|
|
1791
|
+
makeIdentity,
|
|
1792
|
+
identityFromLegacy,
|
|
1793
|
+
isCanonicalIdentity,
|
|
1794
|
+
coerceIdentity,
|
|
1795
|
+
routeObject,
|
|
1796
|
+
identityFromRoute,
|
|
1797
|
+
identityFromMessage,
|
|
1798
|
+
modelFromRoute,
|
|
1799
|
+
modelFromMessage,
|
|
1800
|
+
scanSnapshot,
|
|
1801
|
+
queryRevision,
|
|
1802
|
+
revisionSnapshot,
|
|
1803
|
+
statusSnapshot,
|
|
1804
|
+
serializeIdentity,
|
|
1805
|
+
serializeModelAggregate,
|
|
1806
|
+
serializeDays,
|
|
1807
|
+
snapshot,
|
|
1808
|
+
validDateText,
|
|
1809
|
+
shiftDateText,
|
|
1810
|
+
queryScopeFromRequest,
|
|
1811
|
+
scopeFingerprint,
|
|
1812
|
+
dateInScope,
|
|
1813
|
+
modelNameOfIdentity,
|
|
1814
|
+
identityMatchesScope,
|
|
1815
|
+
queryMetric,
|
|
1816
|
+
queryAggregate,
|
|
1817
|
+
queryDay,
|
|
1818
|
+
queryWorkspace,
|
|
1819
|
+
queryModel,
|
|
1820
|
+
addQueryTokens,
|
|
1821
|
+
addQueryTurn,
|
|
1822
|
+
finalizeQueryMetric,
|
|
1823
|
+
finalizeQueryAggregate,
|
|
1824
|
+
hourStartOf,
|
|
1825
|
+
calendarStartOf,
|
|
1826
|
+
nextCalendarStartOf,
|
|
1827
|
+
hourlyRangeOf,
|
|
1828
|
+
serializeTrendMetric,
|
|
1829
|
+
queryHourlyTrend,
|
|
1830
|
+
queryUsageScope,
|
|
1831
|
+
opaqueRecordId,
|
|
1832
|
+
recordOrder,
|
|
1833
|
+
queryRecords,
|
|
1834
|
+
// Used by the ledger: one row, built the same way whether it is folded or
|
|
1835
|
+
// read back from its document for a query.
|
|
1836
|
+
usageRowOf,
|
|
1837
|
+
foldUsageRow,
|
|
1838
|
+
adoptUsageRow,
|
|
1839
|
+
sessionsForRange,
|
|
1840
|
+
evictColdRows
|
|
1841
|
+
}
|
|
1842
|
+
}
|