dsh-dock 0.11.2 → 0.11.3
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/LICENSE +18 -18
- package/README.md +242 -396
- package/client.js +173 -9
- package/cordis.patch.yml +2 -2
- package/features/animation/host.js +119 -119
- package/features/animation/view.jsx +1662 -1662
- package/features/balance/host.js +494 -494
- package/features/balance/view.js +351 -351
- package/features/games/games/breakout.jsx +227 -227
- package/features/games/games/gomoku.jsx +244 -244
- package/features/games/games/racer.jsx +169 -169
- package/features/games/games/shared.jsx +118 -118
- package/features/games/games/snake.jsx +144 -144
- package/features/games/games/sokoban.jsx +180 -180
- package/features/games/games/tank.jsx +128 -128
- package/features/games/games/tetris.jsx +308 -308
- package/features/games/games/xiangqi.jsx +277 -277
- package/features/games/tank-core.js +152 -152
- package/features/games/view.jsx +639 -639
- package/features/games/xiangqi-core.js +299 -299
- package/features/heartbeat/view.js +35 -35
- package/features/mobile-relay/gateway.js +472 -472
- package/features/mobile-relay/host.js +570 -570
- package/features/mobile-relay/view.jsx +300 -300
- package/features/modelconfig/host.js +406 -406
- package/features/modelconfig/view.js +490 -490
- package/features/notify/host.js +374 -374
- package/features/notify/view.jsx +751 -751
- package/features/theme/view.js +61 -61
- package/features/tokenlog/host.js +882 -883
- package/features/tokenlog/view.jsx +1117 -932
- package/features/visionproxy/host.js +315 -315
- package/index.js +195 -195
- package/package.json +66 -64
- package/src/client.jsx +677 -677
- package/src/host-core.js +207 -207
- package/src/shared.js +192 -192
- package/src/task-track.js +354 -359
|
@@ -1,883 +1,882 @@
|
|
|
1
|
-
// dsh-dock · 功能模块【用量记录】· 宿主半部(v0.4.0,移植自 @wycto/dsh-token-usage lib/index.js)
|
|
2
|
-
//
|
|
3
|
-
// 记录 DSH 会话日志中的每次 LLM 调用(assistant/message 的 usage),
|
|
4
|
-
// 支持历史全量扫描 + 实时 session/event 增量;RPC 经 webServer HTTP 路由
|
|
5
|
-
// POST /dsh-dock/tokenlog/query|export|scan|hello|pricing|setpricing
|
|
6
|
-
// 向浏览器半部提供查询/统计/导出,以及单价的读取与保存。
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
// 用
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
const
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
{ match: 'deepseek-v4-
|
|
78
|
-
peaks: [{ start: 9, end: 14, input:
|
|
79
|
-
{ match: 'deepseek-
|
|
80
|
-
|
|
81
|
-
{ match: 'deepseek-
|
|
82
|
-
{ match: '
|
|
83
|
-
{ match: '
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
let
|
|
88
|
-
|
|
89
|
-
let
|
|
90
|
-
let
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
if (
|
|
126
|
-
if (typeof t.
|
|
127
|
-
if (typeof t.
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
const
|
|
149
|
-
if (
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
//
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
prices.
|
|
197
|
-
prices.
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
num(usage.
|
|
210
|
-
num(usage.
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
const
|
|
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
|
-
|
|
259
|
-
const
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
if (!
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
if (!
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
//
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
if (h
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|| (a ===
|
|
351
|
-
|| a >=
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
-
const
|
|
434
|
-
|
|
435
|
-
const
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
const
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
console.
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
//
|
|
485
|
-
//
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
const
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
const
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
//
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
const
|
|
575
|
-
const
|
|
576
|
-
const
|
|
577
|
-
const
|
|
578
|
-
const
|
|
579
|
-
const
|
|
580
|
-
const
|
|
581
|
-
const
|
|
582
|
-
const
|
|
583
|
-
const
|
|
584
|
-
const
|
|
585
|
-
const
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
sessionId,
|
|
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
|
-
console.
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
const
|
|
668
|
-
const
|
|
669
|
-
const
|
|
670
|
-
const
|
|
671
|
-
const
|
|
672
|
-
const
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
if (
|
|
677
|
-
if (
|
|
678
|
-
if (
|
|
679
|
-
if (
|
|
680
|
-
return
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
e.
|
|
693
|
-
e.
|
|
694
|
-
e.
|
|
695
|
-
e.
|
|
696
|
-
e.
|
|
697
|
-
e.
|
|
698
|
-
e.
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
t.
|
|
723
|
-
t.
|
|
724
|
-
t.
|
|
725
|
-
t.
|
|
726
|
-
t.
|
|
727
|
-
t.
|
|
728
|
-
t.
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
//
|
|
738
|
-
|
|
739
|
-
//
|
|
740
|
-
//
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
//
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
if (
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
const
|
|
761
|
-
const
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
r.
|
|
768
|
-
r.
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
}
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
const
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
const
|
|
797
|
-
const
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
const
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
const
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
}
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
}
|
|
1
|
+
// dsh-dock · 功能模块【用量记录】· 宿主半部(v0.4.0,移植自 @wycto/dsh-token-usage lib/index.js)
|
|
2
|
+
//
|
|
3
|
+
// 记录 DSH 会话日志中的每次 LLM 调用(assistant/message 的 usage),
|
|
4
|
+
// 支持历史全量扫描 + 实时 session/event 增量;RPC 经 webServer HTTP 路由
|
|
5
|
+
// POST /dsh-dock/tokenlog/query|export|scan|hello|pricing|setpricing
|
|
6
|
+
// 向浏览器半部提供查询/统计/导出,以及单价的读取与保存。
|
|
7
|
+
// export 返回结构化明细 { rows, rateUsdCny, count }(不是 CSV 文本):中文表头与取值格式由客户端
|
|
8
|
+
// 按「调用明细」表统一生成,保证导出与页面显示一致;费用在导出时同样按当前单价重算。
|
|
9
|
+
//
|
|
10
|
+
// 数据模型(标量,无 Host 对象引用):
|
|
11
|
+
// {
|
|
12
|
+
// id, time, sessionId, provider, model, apiKey(掩码),
|
|
13
|
+
// inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, reasoningTokens,
|
|
14
|
+
// billedInput, totalTokens, cacheHitPercent, cost, pricingSource, effort, status, llmMs, turn, step
|
|
15
|
+
// }
|
|
16
|
+
//
|
|
17
|
+
// 单价配置保存在 settings 的 dsh-dock 命名空间 tokenlog 段(schema 见 src/host-core.js):
|
|
18
|
+
// dsh-dock:
|
|
19
|
+
// tokenlog:
|
|
20
|
+
// usdCnyRate: 7.2
|
|
21
|
+
// fetchOfficial: true
|
|
22
|
+
// pricing:
|
|
23
|
+
// - { match: deepseek-v4-flash, input: 1.5, output: 4.5, cacheRead: 0.05, cacheWrite: 0,
|
|
24
|
+
// peaks: [{ start: 9, end: 14, input: 3, output: 9, cacheRead: 0.1, cacheWrite: 0 }] }
|
|
25
|
+
// 定价优先级:用户自定义单价 > 官网抓取刊例价 > 内置默认表 > 兜底单价。
|
|
26
|
+
// 分时段价支持多段(start>end 跨零点、start=end 全天、小数小时表示半点);旧单段 peak 自动归一。
|
|
27
|
+
// 费用在查询/导出时按当前单价即时重算,因此改价无需重扫历史。
|
|
28
|
+
import { DOCK_NS, sendJson, readBody } from '../../src/host-core.js'
|
|
29
|
+
|
|
30
|
+
export const feature = {
|
|
31
|
+
id: 'tokenlog',
|
|
32
|
+
name: '用量记录',
|
|
33
|
+
description: '记录全部 LLM API 调用并统计 Token 用量与花费',
|
|
34
|
+
defaultEnabled: false,
|
|
35
|
+
setup(ctx) {
|
|
36
|
+
const disposers = []
|
|
37
|
+
const dispose = () => {
|
|
38
|
+
while (disposers.length > 0) {
|
|
39
|
+
const fn = disposers.pop()
|
|
40
|
+
try { if (typeof fn === 'function') fn() } catch { /* 停用清理失败不阻断 */ }
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 用 Map<id, record> 存记录: id = sessionId:seq 天然唯一。
|
|
45
|
+
// 实时监听与历史扫描存在重叠窗口(活跃会话既被实时 push 又被扫描全量读),
|
|
46
|
+
// 用 set 幂等去重, 避免同一条 LLM 调用被统计两次(此前用数组 push 导致"两倍")。
|
|
47
|
+
const records = new Map()
|
|
48
|
+
let scannedSessions = new Set()
|
|
49
|
+
const headerCache = new Map()
|
|
50
|
+
const turnError = new Map()
|
|
51
|
+
const turnStatus = new Map()
|
|
52
|
+
const stepStart = new Map()
|
|
53
|
+
|
|
54
|
+
function num(v) { return typeof v === 'number' && Number.isFinite(v) ? v : 0 }
|
|
55
|
+
|
|
56
|
+
// ===== 定价与金额估算 =====
|
|
57
|
+
// 定价表以「人民币元 / 百万 tokens」为基准(DeepSeek/智谱官方刊例即人民币), 金额先算 CNY,
|
|
58
|
+
// 对外记录的 cost 保持 USD,供旧版客户端兼容;当前客户端和 CSV 统一展示人民币。
|
|
59
|
+
// 单价来源与优先级(resolvePricing):
|
|
60
|
+
// 1) 用户在面板「单价设置」保存的自定义单价(settings dsh-dock.tokenlog.pricing)——最高优先;
|
|
61
|
+
// 2) 官网抓取刊例价(fetchOfficial 关闭时不参与);
|
|
62
|
+
// 3) 内置默认表 DEFAULT_PRICING;
|
|
63
|
+
// 4) 兜底单价(settings dsh-dock.tokenlog.fallback)。
|
|
64
|
+
// 费用在查询/导出时即时重算(见 withCost), 所以改价立即生效、无需重扫历史。
|
|
65
|
+
const DEFAULT_PRICING_URL = 'https://api-docs.deepseek.com/zh-cn/quick_start/pricing'
|
|
66
|
+
const DEFAULT_FETCH_INTERVAL_HOURS = 24
|
|
67
|
+
const DEFAULT_FALLBACK = { input: 2.16, output: 6.48, cacheRead: 0.43, cacheWrite: 4.32 }
|
|
68
|
+
/** 计价来源标签(随记录下发, 供界面标注)。 */
|
|
69
|
+
const PRICING_SOURCE_LABELS = { custom: '自定义', official: '官网', builtin: '内置', fallback: '兜底' }
|
|
70
|
+
let usdCnyRate = 7.2
|
|
71
|
+
let fallbackPricing = { match: '*', ...DEFAULT_FALLBACK }
|
|
72
|
+
// 内置定价表(人民币元 / 百万 tokens), 按 model 子串匹配; 未匹配走 fallback。
|
|
73
|
+
// 支持分时段定价: 条目可带 peaks=[{start,end,input,output,cacheRead,cacheWrite}, ...],
|
|
74
|
+
// 命中某段(插件运行机器本地时间)用该段价, 其余时段用基准价; 支持多段与跨零点(start>end)。
|
|
75
|
+
// 官方来源: DeepSeek-V4 系列 2026-08-17 生效的分时段刊例(高峰每日 9:00-14:00, 空闲为高峰一半)。
|
|
76
|
+
const DEFAULT_PRICING = [
|
|
77
|
+
{ match: 'deepseek-v4-flash', input: 1.5, output: 4.5, cacheRead: 0.05, cacheWrite: 0,
|
|
78
|
+
peaks: [{ start: 9, end: 14, input: 3.0, output: 9.0, cacheRead: 0.10, cacheWrite: 0 }] },
|
|
79
|
+
{ match: 'deepseek-v4-pro', input: 4.5, output: 13.5, cacheRead: 0.15, cacheWrite: 0,
|
|
80
|
+
peaks: [{ start: 9, end: 14, input: 9.0, output: 27.0, cacheRead: 0.30, cacheWrite: 0 }] },
|
|
81
|
+
{ match: 'deepseek-v3', input: 3.6, output: 7.2, cacheRead: 0.72, cacheWrite: 7.2 },
|
|
82
|
+
{ match: 'deepseek-chat', input: 1.94, output: 7.92, cacheRead: 0.5, cacheWrite: 7.92 },
|
|
83
|
+
{ match: 'deepseek-reasoner', input: 3.96, output: 15.77, cacheRead: 1.01, cacheWrite: 7.92 },
|
|
84
|
+
{ match: 'nemotron', input: 1.08, output: 2.88, cacheRead: 0.22, cacheWrite: 2.16 },
|
|
85
|
+
{ match: 'glm', input: 2.88, output: 8.64, cacheRead: 0.58, cacheWrite: 7.2 },
|
|
86
|
+
]
|
|
87
|
+
let pricingTable = DEFAULT_PRICING
|
|
88
|
+
// 用户自定义单价(最高优先)与官网抓取开关/地址——均由 loadPricingConfig() 从 settings 刷新。
|
|
89
|
+
let userPricing = []
|
|
90
|
+
let fetchOfficialEnabled = true
|
|
91
|
+
let pricingUrl = DEFAULT_PRICING_URL
|
|
92
|
+
let fetchIntervalMs = DEFAULT_FETCH_INTERVAL_HOURS * 3600000
|
|
93
|
+
|
|
94
|
+
function normalizePricing(match, e) {
|
|
95
|
+
const n = (x) => (typeof x === 'number' && Number.isFinite(x) ? x : 0)
|
|
96
|
+
const row = { match, input: n(e.input), output: n(e.output), cacheRead: n(e.cacheRead), cacheWrite: n(e.cacheWrite) }
|
|
97
|
+
// 分时段价支持多段: peaks=[{start,end,input,output,cacheRead,cacheWrite}, ...]。
|
|
98
|
+
// 兼容旧的单段写法 peak={...}(以及旧版本存盘数据), 统一归一到 peaks。
|
|
99
|
+
const src = Array.isArray(e.peaks) && e.peaks.length ? e.peaks : (e.peak && typeof e.peak === 'object' ? [e.peak] : [])
|
|
100
|
+
const peaks = []
|
|
101
|
+
for (const s of src) {
|
|
102
|
+
if (!s || typeof s !== 'object') continue
|
|
103
|
+
peaks.push({
|
|
104
|
+
start: n(s.start), end: n(s.end),
|
|
105
|
+
input: n(s.input), output: n(s.output),
|
|
106
|
+
cacheRead: n(s.cacheRead), cacheWrite: n(s.cacheWrite),
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
if (peaks.length) row.peaks = peaks
|
|
110
|
+
return row
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** 浅拷贝分时段数组(RPC 下发与落盘都用它,避免调用方改到内存态)。 */
|
|
114
|
+
function clonePeaks(peaks) {
|
|
115
|
+
return Array.isArray(peaks) ? peaks.map((s) => Object.assign({}, s)) : []
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// 读 settings 的 dsh-dock.tokenlog 段并刷新模块级定价状态。幂等, 可重复调用。
|
|
119
|
+
// 取值做防御性归一(schema 已给默认, 但 settings 未挂载/异常时仍需兜底)。
|
|
120
|
+
function loadPricingConfig() {
|
|
121
|
+
try {
|
|
122
|
+
const settings = ctx.get('settings')
|
|
123
|
+
const root = settings && typeof settings.get === 'function' ? settings.get(DOCK_NS) : null
|
|
124
|
+
const t = root && typeof root === 'object' && root.tokenlog && typeof root.tokenlog === 'object' ? root.tokenlog : null
|
|
125
|
+
if (!t) return
|
|
126
|
+
if (typeof t.usdCnyRate === 'number' && t.usdCnyRate > 0) usdCnyRate = t.usdCnyRate
|
|
127
|
+
if (typeof t.fetchOfficial === 'boolean') fetchOfficialEnabled = t.fetchOfficial
|
|
128
|
+
if (typeof t.pricingUrl === 'string' && t.pricingUrl) pricingUrl = t.pricingUrl
|
|
129
|
+
if (typeof t.pricingFetchIntervalHours === 'number' && t.pricingFetchIntervalHours > 0) {
|
|
130
|
+
fetchIntervalMs = t.pricingFetchIntervalHours * 3600000
|
|
131
|
+
}
|
|
132
|
+
userPricing = Array.isArray(t.pricing)
|
|
133
|
+
? t.pricing
|
|
134
|
+
.filter((e) => e && typeof e === 'object' && typeof e.match === 'string' && e.match)
|
|
135
|
+
.map((e) => normalizePricing(String(e.match).toLowerCase(), e))
|
|
136
|
+
: []
|
|
137
|
+
if (t.fallback && typeof t.fallback === 'object') fallbackPricing = normalizePricing('*', t.fallback)
|
|
138
|
+
} catch (e) {
|
|
139
|
+
console.error('[dsh-dock] tokenlog read pricing config failed', e && e.message)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// 分时段命中: start<end 为普通区间 [start,end); start>end 表示跨零点(如 23~7 点);
|
|
144
|
+
// start===end 视为全天。多段命中时取第一段(按配置顺序)。
|
|
145
|
+
function pickPeakSegment(peaks, hour) {
|
|
146
|
+
if (!Array.isArray(peaks) || hour < 0) return null
|
|
147
|
+
for (const s of peaks) {
|
|
148
|
+
const a = Number(s.start), b = Number(s.end)
|
|
149
|
+
if (!Number.isFinite(a) || !Number.isFinite(b)) continue
|
|
150
|
+
const hit = a === b ? true : (a < b ? (hour >= a && hour < b) : (hour >= a || hour < b))
|
|
151
|
+
if (hit) return s
|
|
152
|
+
}
|
|
153
|
+
return null
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// 命中定价行: 用户自定义 → 官网抓取(可关闭) → 内置表 → 兜底; 再按调用时刻取分时价。
|
|
157
|
+
// 官网获取的数据若为 USD 则自动折成 CNY(内置表已是 CNY)。返回单价(元/百万 tokens)与来源标签。
|
|
158
|
+
function resolvePricing(model, timeMs) {
|
|
159
|
+
const m = String(model || '').toLowerCase()
|
|
160
|
+
let row = null
|
|
161
|
+
let source = 'fallback'
|
|
162
|
+
// 1) 用户自定义单价: 最高优先(官网抓取价常与实际计费不符, 用户填了就以其为准)
|
|
163
|
+
for (const r of userPricing) {
|
|
164
|
+
if (m.includes(r.match)) { row = r; source = 'custom'; break }
|
|
165
|
+
}
|
|
166
|
+
// 2) 官网自动获取的定价(48h 内有效, 超时回退内置; 可在设置里关闭抓取)
|
|
167
|
+
if (!row && fetchOfficialEnabled) {
|
|
168
|
+
const fetched = fetchedPricing
|
|
169
|
+
if (fetched && fetched.length > 0 && (Date.now() - lastFetchTime < fetchIntervalMs * 2)) {
|
|
170
|
+
for (const r of fetched) {
|
|
171
|
+
if (m.includes(r.match)) { row = r; source = 'official'; break }
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const fromFetch = source === 'official'
|
|
176
|
+
// 3) 内置表
|
|
177
|
+
if (!row) {
|
|
178
|
+
for (const r of pricingTable) {
|
|
179
|
+
if (m.includes(r.match)) { row = r; source = 'builtin'; break }
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
// 4) 兜底
|
|
183
|
+
if (!row) { row = fallbackPricing; source = 'fallback' }
|
|
184
|
+
// 分时段判断(本地时间, 支持多段与跨零点; 用小数小时以便 08:30 这类半点边界); 未命中用基准价
|
|
185
|
+
let h = -1
|
|
186
|
+
if (typeof timeMs === 'number' && Number.isFinite(timeMs)) {
|
|
187
|
+
const d = new Date(timeMs)
|
|
188
|
+
h = d.getHours() + d.getMinutes() / 60
|
|
189
|
+
}
|
|
190
|
+
const seg = pickPeakSegment(row.peaks, h)
|
|
191
|
+
const prices = seg
|
|
192
|
+
? { input: seg.input, output: seg.output, cacheRead: seg.cacheRead, cacheWrite: seg.cacheWrite }
|
|
193
|
+
: { input: row.input, output: row.output, cacheRead: row.cacheRead, cacheWrite: row.cacheWrite }
|
|
194
|
+
// 币种转换: 官网抓取的数据若是 USD 则折成 CNY(内置表已是 CNY, 无需转换)
|
|
195
|
+
if (fromFetch && row.currency === 'usd') {
|
|
196
|
+
prices.input *= usdCnyRate
|
|
197
|
+
prices.output *= usdCnyRate
|
|
198
|
+
prices.cacheRead *= usdCnyRate
|
|
199
|
+
prices.cacheWrite *= usdCnyRate
|
|
200
|
+
}
|
|
201
|
+
return { ...prices, source }
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// 金额估算: 表为人民币价 → 先算 CNY, 再折成 USD(= CNY / usdCnyRate) 供客户端/CSV 使用。
|
|
205
|
+
// 返回 { cost(USD), source(计价来源) }。
|
|
206
|
+
function costOf(usage, model, timeMs) {
|
|
207
|
+
const p = resolvePricing(model, timeMs)
|
|
208
|
+
const cny = (
|
|
209
|
+
num(usage.inputTokens) * p.input +
|
|
210
|
+
num(usage.cacheReadTokens) * p.cacheRead +
|
|
211
|
+
num(usage.cacheWriteTokens) * p.cacheWrite +
|
|
212
|
+
num(usage.outputTokens) * p.output
|
|
213
|
+
) / 1e6
|
|
214
|
+
return { cost: cny / usdCnyRate, source: p.source }
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// 按当前单价重算一条记录的费用(浅拷贝):面板改价后无需重扫历史即可即时生效。
|
|
218
|
+
function withCost(r) {
|
|
219
|
+
const c = costOf(r, r.model, r.time)
|
|
220
|
+
return Object.assign({}, r, { cost: c.cost, pricingSource: c.source })
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// 面板「单价设置」读取:返回当前生效的单价表 + 兜底/汇率/抓取开关 + 已出现过的模型
|
|
224
|
+
// (含已配置 Provider 的模型, 供用户按模型逐条配置单价; 未配置的模型可手输匹配串)。
|
|
225
|
+
function readPricingConfig() {
|
|
226
|
+
const cfg = readConfiguredProviders()
|
|
227
|
+
const seen = new Set()
|
|
228
|
+
for (const r of records.values()) if (r.model) seen.add(r.model)
|
|
229
|
+
for (const ids of Object.values(cfg)) for (const id of ids) if (id) seen.add(id)
|
|
230
|
+
// 每个模型的当前计价来源(供面板提示哪些模型走了兜底价, 提示用户去配单价)
|
|
231
|
+
const sourcesByModel = {}
|
|
232
|
+
for (const m of seen) sourcesByModel[m] = PRICING_SOURCE_LABELS[resolvePricing(m, Date.now()).source] || ''
|
|
233
|
+
return {
|
|
234
|
+
usdCnyRate,
|
|
235
|
+
fetchOfficial: fetchOfficialEnabled,
|
|
236
|
+
pricingUrl,
|
|
237
|
+
pricingFetchIntervalHours: Math.round(fetchIntervalMs / 3600000),
|
|
238
|
+
pricing: userPricing.map((r) => Object.assign({}, r, { peaks: clonePeaks(r.peaks) })),
|
|
239
|
+
fallback: Object.assign({}, fallbackPricing),
|
|
240
|
+
builtin: pricingTable.map((r) => ({ match: r.match, input: r.input, output: r.output, cacheRead: r.cacheRead, cacheWrite: r.cacheWrite, peaks: clonePeaks(r.peaks) })),
|
|
241
|
+
models: [...seen].sort(),
|
|
242
|
+
sourcesByModel,
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// 保存面板提交的单价配置: 校验 → 合并进 dsh-dock.tokenlog(保留 pricingUrl 等未在面板暴露
|
|
247
|
+
// 的字段) → 落盘。保存后由调用方 loadPricingConfig() 刷新内存, 使改价立即影响费用估算。
|
|
248
|
+
async function savePricingConfig(input) {
|
|
249
|
+
const settings = ctx.get('settings')
|
|
250
|
+
if (!settings || typeof settings.get !== 'function' || typeof settings.mutate !== 'function') {
|
|
251
|
+
throw new Error('settings 服务不可用,单价无法持久化')
|
|
252
|
+
}
|
|
253
|
+
const bad = (msg) => { const err = new Error(msg); err.statusCode = 400; return err }
|
|
254
|
+
const nonNeg = (v, label) => {
|
|
255
|
+
const n = Number(v)
|
|
256
|
+
if (!Number.isFinite(n) || n < 0) throw bad(`「${label}」需为不小于 0 的数字`)
|
|
257
|
+
return n
|
|
258
|
+
}
|
|
259
|
+
const root = settings.get(DOCK_NS)
|
|
260
|
+
const current = root && typeof root === 'object' && root.tokenlog && typeof root.tokenlog === 'object' ? root.tokenlog : {}
|
|
261
|
+
const next = Object.assign({}, current)
|
|
262
|
+
if (input.usdCnyRate !== undefined) {
|
|
263
|
+
const r = Number(input.usdCnyRate)
|
|
264
|
+
if (!Number.isFinite(r) || r <= 0 || r > 1000) throw bad('「USD→CNY 汇率」需为 0~1000 的正数')
|
|
265
|
+
next.usdCnyRate = r
|
|
266
|
+
}
|
|
267
|
+
if (input.fetchOfficial !== undefined) next.fetchOfficial = !!input.fetchOfficial
|
|
268
|
+
if (input.pricing !== undefined) {
|
|
269
|
+
if (!Array.isArray(input.pricing)) throw bad('「模型单价」需为数组')
|
|
270
|
+
const rows = []
|
|
271
|
+
for (const raw of input.pricing) {
|
|
272
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw bad('模型单价条目格式不符')
|
|
273
|
+
const match = String(raw.match || '').trim().toLowerCase()
|
|
274
|
+
if (!match) throw bad('模型单价条目的「模型匹配」不能为空')
|
|
275
|
+
if (match === '*') throw bad('模型匹配不能是 *;未匹配模型请填「兜底单价」')
|
|
276
|
+
const row = {
|
|
277
|
+
match,
|
|
278
|
+
input: nonNeg(raw.input, `${match} 输入单价`),
|
|
279
|
+
output: nonNeg(raw.output, `${match} 输出单价`),
|
|
280
|
+
cacheRead: nonNeg(raw.cacheRead, `${match} 缓存命中单价`),
|
|
281
|
+
cacheWrite: nonNeg(raw.cacheWrite, `${match} 缓存写入单价`),
|
|
282
|
+
}
|
|
283
|
+
// 分时段价(可多段): 每段 start/end 为 0~24 的本地小时(支持 8.5 表示 08:30);
|
|
284
|
+
// start>end 表示跨零点, start===end 表示全天。
|
|
285
|
+
const rawPeaks = Array.isArray(raw.peaks) ? raw.peaks : []
|
|
286
|
+
const peaks = []
|
|
287
|
+
for (const seg of rawPeaks) {
|
|
288
|
+
if (!seg || typeof seg !== 'object' || Array.isArray(seg)) throw bad(`「${match}」分时段条目格式不符`)
|
|
289
|
+
const start = Number(seg.start), end = Number(seg.end)
|
|
290
|
+
if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || start > 24 || end < 0 || end > 24) {
|
|
291
|
+
throw bad(`「${match}」分时段时刻需为 0~24 的小时数`)
|
|
292
|
+
}
|
|
293
|
+
peaks.push({
|
|
294
|
+
start, end,
|
|
295
|
+
input: nonNeg(seg.input, `${match} 分时段输入单价`),
|
|
296
|
+
output: nonNeg(seg.output, `${match} 分时段输出单价`),
|
|
297
|
+
cacheRead: nonNeg(seg.cacheRead, `${match} 分时段缓存命中单价`),
|
|
298
|
+
cacheWrite: nonNeg(seg.cacheWrite, `${match} 分时段缓存写入单价`),
|
|
299
|
+
})
|
|
300
|
+
}
|
|
301
|
+
if (peaks.length) row.peaks = peaks
|
|
302
|
+
rows.push(row)
|
|
303
|
+
}
|
|
304
|
+
next.pricing = rows
|
|
305
|
+
}
|
|
306
|
+
if (input.fallback !== undefined) {
|
|
307
|
+
const f = input.fallback
|
|
308
|
+
if (!f || typeof f !== 'object' || Array.isArray(f)) throw bad('「兜底单价」格式不符')
|
|
309
|
+
next.fallback = {
|
|
310
|
+
input: nonNeg(f.input, '兜底输入单价'),
|
|
311
|
+
output: nonNeg(f.output, '兜底输出单价'),
|
|
312
|
+
cacheRead: nonNeg(f.cacheRead, '兜底缓存命中单价'),
|
|
313
|
+
cacheWrite: nonNeg(f.cacheWrite, '兜底缓存写入单价'),
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
try {
|
|
317
|
+
await settings.mutate(DOCK_NS, [{ op: 'set', path: ['tokenlog'], value: next }])
|
|
318
|
+
} catch (e) {
|
|
319
|
+
const err = new Error('保存单价被拒绝:' + ((e && e.message) || String(e)))
|
|
320
|
+
err.statusCode = 400
|
|
321
|
+
throw err
|
|
322
|
+
}
|
|
323
|
+
console.log('[dsh-dock] tokenlog pricing saved: rows=' + (Array.isArray(next.pricing) ? next.pricing.length : 0) +
|
|
324
|
+
', fetchOfficial=' + next.fetchOfficial)
|
|
325
|
+
return next
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ===== 官网定价自动获取 =====
|
|
329
|
+
// 每 N 小时(默认 24)从官网抓取最新定价并解析 HTML 表格; 抓取/解析失败时使用内置默认值。
|
|
330
|
+
// 用户在面板配置的单价优先于抓取价, 因此抓取仅作兜底; 也可在面板关闭抓取。
|
|
331
|
+
let fetchedPricing = null // Array<{ match, input, output, cacheRead, cacheWrite, currency }>
|
|
332
|
+
let lastFetchTime = 0
|
|
333
|
+
|
|
334
|
+
function getPricingFetchConfig() {
|
|
335
|
+
return { url: pricingUrl, intervalMs: fetchIntervalMs }
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// 服务端出站请求前的 URL 校验(SSRF 防护): 仅 http/https, 且拒绝本机/环回/私有/保留地址。
|
|
339
|
+
function isPrivateOrReservedHost(host) {
|
|
340
|
+
const h = String(host || '').toLowerCase().replace(/^\[|\]$/g, '')
|
|
341
|
+
if (!h) return true
|
|
342
|
+
if (h === 'localhost' || h.endsWith('.localhost') || h.endsWith('.local') || h.endsWith('.internal')) return true
|
|
343
|
+
if (h.includes(':')) { // IPv6 字面量
|
|
344
|
+
return h === '::' || h === '::1' || h.startsWith('fe80') || h.startsWith('fc') || h.startsWith('fd')
|
|
345
|
+
}
|
|
346
|
+
const m = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/)
|
|
347
|
+
if (m) {
|
|
348
|
+
const a = Number(m[1]), b = Number(m[2])
|
|
349
|
+
return a === 0 || a === 10 || a === 127
|
|
350
|
+
|| (a === 169 && b === 254) // link-local
|
|
351
|
+
|| (a === 172 && b >= 16 && b <= 31) // 私网
|
|
352
|
+
|| (a === 192 && b === 168) // 私网
|
|
353
|
+
|| a >= 224 // 组播/保留
|
|
354
|
+
}
|
|
355
|
+
return false
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function assertAllowedFetchUrl(raw) {
|
|
359
|
+
let u
|
|
360
|
+
try { u = new URL(String(raw)) } catch { throw new Error('价格页地址不是合法 URL:' + raw) }
|
|
361
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error('价格页地址仅支持 http/https')
|
|
362
|
+
if (isPrivateOrReservedHost(u.hostname)) throw new Error('价格页地址不允许指向本机/内网/保留地址')
|
|
363
|
+
return u.toString()
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// 从 HTML 中提取所有 <table> 的行列数据
|
|
367
|
+
function extractHTMLTables(html) {
|
|
368
|
+
const tables = []
|
|
369
|
+
const tableRe = /<table[^>]*>([\s\S]*?)<\/table>/gi
|
|
370
|
+
let tm
|
|
371
|
+
while ((tm = tableRe.exec(html)) !== null) {
|
|
372
|
+
const rows = []
|
|
373
|
+
const trRe = /<tr[^>]*>([\s\S]*?)<\/tr>/gi
|
|
374
|
+
let tr
|
|
375
|
+
while ((tr = trRe.exec(tm[1])) !== null) {
|
|
376
|
+
const cells = []
|
|
377
|
+
const tdRe = /<t[hd][^>]*>([\s\S]*?)<\/t[hd]>/gi
|
|
378
|
+
let td
|
|
379
|
+
while ((td = tdRe.exec(tr[1])) !== null) {
|
|
380
|
+
cells.push(td[1].replace(/<[^>]+>/g, '').replace(/&[a-z]+;/g, ' ').trim())
|
|
381
|
+
}
|
|
382
|
+
if (cells.length > 0) rows.push(cells)
|
|
383
|
+
}
|
|
384
|
+
if (rows.length >= 2) tables.push(rows)
|
|
385
|
+
}
|
|
386
|
+
return tables
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// 从价格字符串提取数字(支持 "$0.27", "0.27", "¥1.5", "1.5元", "1,500" 等格式)
|
|
390
|
+
function parsePrice(s) {
|
|
391
|
+
if (!s) return null
|
|
392
|
+
const cleaned = String(s).replace(/,/g, '').replace(/¥|¥|元|\/.*$/g, '').trim()
|
|
393
|
+
const m = cleaned.match(/(\d+\.?\d*)/)
|
|
394
|
+
return m ? parseFloat(m[1]) : null
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// 识别定价表列: 通过表头关键词定位各列
|
|
398
|
+
function identifyPricingColumns(headers) {
|
|
399
|
+
const h = headers.map(x => x.toLowerCase())
|
|
400
|
+
const find = (...patterns) => {
|
|
401
|
+
for (const p of patterns) {
|
|
402
|
+
const idx = h.findIndex(x => x.includes(p))
|
|
403
|
+
if (idx >= 0) return idx
|
|
404
|
+
}
|
|
405
|
+
return -1
|
|
406
|
+
}
|
|
407
|
+
return {
|
|
408
|
+
model: find('模型', 'model', '名称', 'name'),
|
|
409
|
+
inputMiss: find('缓存未命中', '未缓存', 'cache miss', 'uncached'),
|
|
410
|
+
inputHit: find('缓存命中', 'cache hit', 'cached'),
|
|
411
|
+
output: find('输出', 'output'),
|
|
412
|
+
peak: find('高峰', 'peak', 'busy', 'on-peak'),
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// 从 HTML 解析 DeepSeek 官网定价
|
|
417
|
+
// 策略: 提取所有 <table>, 找到含"价格/缓存/输入/输出"关键词的表, 逐行提取模型名+价格;
|
|
418
|
+
// 识别 $ 标记区分 USD/CNY 币种; 无法解析时返回空数组(回退内置默认)。
|
|
419
|
+
function parsePricingFromHTML(html) {
|
|
420
|
+
const tables = extractHTMLTables(html)
|
|
421
|
+
const results = []
|
|
422
|
+
for (const rows of tables) {
|
|
423
|
+
const headers = rows[0]
|
|
424
|
+
const cols = identifyPricingColumns(headers)
|
|
425
|
+
// 至少需要模型名 + 一个价格列
|
|
426
|
+
if (cols.model < 0) continue
|
|
427
|
+
if (cols.inputMiss < 0 && cols.output < 0 && cols.inputHit < 0) continue
|
|
428
|
+
// 表头需与定价相关
|
|
429
|
+
const headerText = headers.join(' ')
|
|
430
|
+
if (!/价格|price|缓存|cache|输入|input|输出|output|tokens/i.test(headerText)) continue
|
|
431
|
+
for (let i = 1; i < rows.length; i++) {
|
|
432
|
+
const row = rows[i]
|
|
433
|
+
const name = (row[cols.model] || '').trim()
|
|
434
|
+
if (!name || /^(模型|model|名称|合计|total|---)/i.test(name)) continue
|
|
435
|
+
const input = cols.inputMiss >= 0 ? parsePrice(row[cols.inputMiss]) : null
|
|
436
|
+
const output = cols.output >= 0 ? parsePrice(row[cols.output]) : null
|
|
437
|
+
const cacheHit = cols.inputHit >= 0 ? parsePrice(row[cols.inputHit]) : null
|
|
438
|
+
if (input === null && output === null && cacheHit === null) continue
|
|
439
|
+
// 判断币种
|
|
440
|
+
const allText = row.join(' ')
|
|
441
|
+
const isUSD = /\$|USD|dollar/i.test(allText)
|
|
442
|
+
results.push({
|
|
443
|
+
match: name.toLowerCase(),
|
|
444
|
+
input: input || 0,
|
|
445
|
+
output: output || 0,
|
|
446
|
+
cacheRead: cacheHit || 0,
|
|
447
|
+
cacheWrite: 0, // DeepSeek 官方不收取缓存写入
|
|
448
|
+
currency: isUSD ? 'usd' : 'cny',
|
|
449
|
+
})
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
return results
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// 从官网获取最新定价(异步, 失败静默回退内置默认)
|
|
456
|
+
async function fetchOfficialPricing() {
|
|
457
|
+
try {
|
|
458
|
+
const { url } = getPricingFetchConfig()
|
|
459
|
+
const safeUrl = assertAllowedFetchUrl(url)
|
|
460
|
+
console.log('[dsh-dock] tokenlog fetching official pricing from:', safeUrl)
|
|
461
|
+
const res = await fetch(safeUrl, {
|
|
462
|
+
headers: { 'User-Agent': 'dsh-dock-tokenlog/0.4', 'Accept': 'text/html,*/*' }
|
|
463
|
+
})
|
|
464
|
+
if (!res.ok) throw new Error('HTTP ' + res.status)
|
|
465
|
+
const html = await res.text()
|
|
466
|
+
console.log('[dsh-dock] tokenlog pricing page fetched, size:', html.length)
|
|
467
|
+
if (html.length < 500) {
|
|
468
|
+
console.warn('[dsh-dock] tokenlog page too small, likely SPA shell — using defaults')
|
|
469
|
+
return
|
|
470
|
+
}
|
|
471
|
+
const parsed = parsePricingFromHTML(html)
|
|
472
|
+
if (parsed.length > 0) {
|
|
473
|
+
fetchedPricing = parsed
|
|
474
|
+
lastFetchTime = Date.now()
|
|
475
|
+
console.log('[dsh-dock] tokenlog parsed', parsed.length, 'models from official pricing')
|
|
476
|
+
} else {
|
|
477
|
+
console.warn('[dsh-dock] tokenlog parsed 0 models from pricing page — check page structure, using defaults')
|
|
478
|
+
}
|
|
479
|
+
} catch (e) {
|
|
480
|
+
console.error('[dsh-dock] tokenlog fetch official pricing failed:', e && e.message, '— using built-in defaults')
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// 读取 settings.yaml 的 llm-pi-ai 命名空间, 返回「现有提供商」路由名 -> [模型 id]。
|
|
485
|
+
// 用途: 前端提供商/模型下拉 = 现有配置 ∪ 历史记录, 去重合并 ——
|
|
486
|
+
// - 没有任何记录时仍可选现有提供商;
|
|
487
|
+
// - 提供商/模型已从配置删除时, 因有历史记录仍可选中(兼容两者)。
|
|
488
|
+
function readConfiguredProviders() {
|
|
489
|
+
const out = {}
|
|
490
|
+
try {
|
|
491
|
+
const settings = ctx.get('settings')
|
|
492
|
+
if (!settings || typeof settings.get !== 'function') return out
|
|
493
|
+
const sec = settings.get('llm-pi-ai')
|
|
494
|
+
const providers = sec && typeof sec === 'object' && sec.providers ? sec.providers : {}
|
|
495
|
+
for (const pid of Object.keys(providers || {})) {
|
|
496
|
+
const p = providers[pid]
|
|
497
|
+
if (!p || typeof p !== 'object') { out[pid] = []; continue }
|
|
498
|
+
const models = Array.isArray(p.models) ? p.models : []
|
|
499
|
+
const ids = []
|
|
500
|
+
for (const m of models) {
|
|
501
|
+
const id = m && typeof m === 'object' ? m.id : m
|
|
502
|
+
if (typeof id === 'string' && id) ids.push(id)
|
|
503
|
+
}
|
|
504
|
+
out[pid] = ids
|
|
505
|
+
}
|
|
506
|
+
} catch (e) {
|
|
507
|
+
console.error('[dsh-dock] tokenlog read configured providers failed', e && e.message)
|
|
508
|
+
}
|
|
509
|
+
return out
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// ---------- 事件采集 ----------
|
|
513
|
+
|
|
514
|
+
// 预扫描 turn/end: 先建立 turn -> status/error 映射, 再生成记录
|
|
515
|
+
// (解决顺序处理导致的状态缺失: assistant/message 先于 turn/end 到达)
|
|
516
|
+
function preScanTurnEnds(sessionId, events) {
|
|
517
|
+
for (const e of events) {
|
|
518
|
+
if (e.type !== 'turn/end') continue
|
|
519
|
+
const t = e.data && e.data.turn
|
|
520
|
+
const r = e.data && e.data.reason
|
|
521
|
+
let status = 'completed'
|
|
522
|
+
if (r && typeof r === 'object') status = r.kind || 'completed'
|
|
523
|
+
else if (typeof r === 'string') status = r
|
|
524
|
+
turnStatus.set(sessionId + ':' + t, status)
|
|
525
|
+
let errInfo = null
|
|
526
|
+
if (r && typeof r === 'object' && r.error) {
|
|
527
|
+
const er = r.error
|
|
528
|
+
errInfo = {
|
|
529
|
+
statusCode: typeof er.status === 'number' ? er.status : 0,
|
|
530
|
+
errorCode: er.code || '',
|
|
531
|
+
errorMsg: er.message || '',
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
turnError.set(sessionId + ':' + t, errInfo)
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function ingestEvent(sessionId, event) {
|
|
539
|
+
if (event.type === 'request/header') {
|
|
540
|
+
const h = event.data && event.data.header
|
|
541
|
+
if (h && h.config) {
|
|
542
|
+
headerCache.set(sessionId, {
|
|
543
|
+
provider: h.config.provider || '',
|
|
544
|
+
model: h.config.model || '',
|
|
545
|
+
effort: h.config.reasoningEffort || '',
|
|
546
|
+
})
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
if (event.type === 'turn/end') {
|
|
550
|
+
const t = event.data && event.data.turn
|
|
551
|
+
const r = event.data && event.data.reason
|
|
552
|
+
let status = 'completed'
|
|
553
|
+
if (r && typeof r === 'object') status = r.kind || 'completed'
|
|
554
|
+
else if (typeof r === 'string') status = r
|
|
555
|
+
turnStatus.set(sessionId + ':' + t, status)
|
|
556
|
+
let errInfo = null
|
|
557
|
+
if (r && typeof r === 'object' && r.error) {
|
|
558
|
+
const e = r.error
|
|
559
|
+
errInfo = {
|
|
560
|
+
statusCode: typeof e.status === 'number' ? e.status : 0,
|
|
561
|
+
errorCode: e.code || '',
|
|
562
|
+
errorMsg: e.message || '',
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
turnError.set(sessionId + ':' + t, errInfo)
|
|
566
|
+
}
|
|
567
|
+
if (event.type === 'step/start') {
|
|
568
|
+
stepStart.set(sessionId + ':' + event.data.turn + ':' + event.data.step, event.time)
|
|
569
|
+
}
|
|
570
|
+
if (event.type !== 'assistant/message') return null
|
|
571
|
+
const d = event.data || {}
|
|
572
|
+
let usage = d.usage
|
|
573
|
+
if (!usage || typeof usage !== 'object') usage = { inputTokens: 0, outputTokens: 0 }
|
|
574
|
+
const head = headerCache.get(sessionId) || { provider: '', model: '', effort: '' }
|
|
575
|
+
const turn = num(d.turn)
|
|
576
|
+
const step = num(d.step)
|
|
577
|
+
const startK = sessionId + ':' + turn + ':' + step
|
|
578
|
+
const startMs = stepStart.get(startK)
|
|
579
|
+
const llmMs = typeof startMs === 'number' ? Math.max(0, event.time - startMs) : null
|
|
580
|
+
const status = turnStatus.get(sessionId + ':' + turn) || 'in-progress'
|
|
581
|
+
const ei = turnError.get(sessionId + ':' + turn) || null
|
|
582
|
+
const input = num(usage.inputTokens)
|
|
583
|
+
const output = num(usage.outputTokens)
|
|
584
|
+
const cacheRead = num(usage.cacheReadTokens)
|
|
585
|
+
const cacheWrite = num(usage.cacheWriteTokens)
|
|
586
|
+
const reasoning = num(usage.reasoningTokens)
|
|
587
|
+
const priced = costOf(usage, head.model, event.time)
|
|
588
|
+
stepStart.delete(startK)
|
|
589
|
+
return {
|
|
590
|
+
id: sessionId + ':' + event.seq,
|
|
591
|
+
time: event.time,
|
|
592
|
+
sessionId,
|
|
593
|
+
provider: head.provider || '',
|
|
594
|
+
model: head.model || '',
|
|
595
|
+
apiKey: '',
|
|
596
|
+
inputTokens: input,
|
|
597
|
+
outputTokens: output,
|
|
598
|
+
cacheReadTokens: cacheRead,
|
|
599
|
+
cacheWriteTokens: cacheWrite,
|
|
600
|
+
reasoningTokens: reasoning,
|
|
601
|
+
billedInput: input + cacheRead + cacheWrite,
|
|
602
|
+
totalTokens: input + output + cacheRead + cacheWrite,
|
|
603
|
+
cacheHitPercent: (input + cacheRead) > 0 ? Math.round((cacheRead / (input + cacheRead)) * 100) : 0,
|
|
604
|
+
cost: priced.cost,
|
|
605
|
+
pricingSource: priced.source,
|
|
606
|
+
effort: head.effort || '',
|
|
607
|
+
status,
|
|
608
|
+
statusCode: ei ? ei.statusCode : 0,
|
|
609
|
+
errorCode: ei ? ei.errorCode : '',
|
|
610
|
+
errorMsg: ei ? ei.errorMsg : '',
|
|
611
|
+
llmMs,
|
|
612
|
+
turn,
|
|
613
|
+
step,
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function ingestEvents(sessionId, events) {
|
|
618
|
+
const out = []
|
|
619
|
+
for (const e of events) {
|
|
620
|
+
const r = ingestEvent(sessionId, e)
|
|
621
|
+
if (r) out.push(r)
|
|
622
|
+
}
|
|
623
|
+
return out
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// 历史扫描: 遍历所有会话, 读取完整日志, 重建索引
|
|
627
|
+
// 说明: 读取失败的多是旧版(v0→v1 迁移)、中途损坏或缺 seq 的会话——这类日志无法解码是常态,
|
|
628
|
+
// 不应视为异常。若逐条 console.error 会把终端刷屏, 改为统计失败数并只打印少量样例明细。
|
|
629
|
+
async function scanHistory() {
|
|
630
|
+
if (!sessionQuery) return
|
|
631
|
+
const failures = [] // { sid, msg } 保留前 MAX 条明细供排查
|
|
632
|
+
const MAX_FAIL_SAMPLES = 3
|
|
633
|
+
let failCount = 0
|
|
634
|
+
try {
|
|
635
|
+
const sessions = await sessionQuery.listSessions()
|
|
636
|
+
let added = 0
|
|
637
|
+
for (const s of sessions) {
|
|
638
|
+
const sid = s && s.header ? s.header.id : (s && s.id)
|
|
639
|
+
if (!sid || scannedSessions.has(sid)) continue
|
|
640
|
+
scannedSessions.add(sid)
|
|
641
|
+
try {
|
|
642
|
+
const snap = await sessionQuery.readSession(sid)
|
|
643
|
+
const events = snap && snap.events ? snap.events : []
|
|
644
|
+
// 两遍: 先建 turn/end 映射, 再生成记录
|
|
645
|
+
preScanTurnEnds(sid, events)
|
|
646
|
+
const recs = ingestEvents(sid, events)
|
|
647
|
+
for (const r of recs) records.set(r.id, r)
|
|
648
|
+
added += recs.length
|
|
649
|
+
} catch (e) {
|
|
650
|
+
failCount += 1
|
|
651
|
+
if (failures.length < MAX_FAIL_SAMPLES) failures.push(sid + ': ' + ((e && e.message) || String(e)))
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
// 失败会话统一汇总为一行(仅列前几条样例), 不逐条刷屏。
|
|
655
|
+
if (failCount > 0) {
|
|
656
|
+
console.warn('[dsh-dock] tokenlog 历史扫描: ' + failCount + ' 个会话未能读取' +
|
|
657
|
+
(failCount > MAX_FAIL_SAMPLES ? ', 前 ' + MAX_FAIL_SAMPLES + ' 条如下:' : ':') )
|
|
658
|
+
for (const f of failures) console.warn(' - ' + f)
|
|
659
|
+
}
|
|
660
|
+
console.log('[dsh-dock] tokenlog history scanned: +' + added + ' records, total ' + records.size)
|
|
661
|
+
} catch (e) {
|
|
662
|
+
console.error('[dsh-dock] tokenlog scanHistory failed', e && e.message)
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function buildQuery(args) {
|
|
667
|
+
const q = args || {}
|
|
668
|
+
const from = typeof q.from === 'number' ? q.from : 0
|
|
669
|
+
const to = typeof q.to === 'number' ? q.to : Infinity
|
|
670
|
+
const provider = (q.provider || '').trim()
|
|
671
|
+
const model = (q.model || '').trim()
|
|
672
|
+
const status = (q.status || '').trim()
|
|
673
|
+
const effort = (q.effort || '').trim()
|
|
674
|
+
const sessionId = (q.sessionId || '').trim()
|
|
675
|
+
return [...records.values()].filter((r) => {
|
|
676
|
+
if (r.time < from || r.time > to) return false
|
|
677
|
+
if (provider && r.provider !== provider) return false
|
|
678
|
+
if (model && r.model !== model) return false
|
|
679
|
+
if (status && r.status !== status) return false
|
|
680
|
+
if (effort && r.effort !== effort) return false
|
|
681
|
+
if (sessionId && r.sessionId !== sessionId) return false
|
|
682
|
+
return true
|
|
683
|
+
})
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function summarize(list, dim) {
|
|
687
|
+
const map = new Map()
|
|
688
|
+
for (const r of list) {
|
|
689
|
+
const key = r[dim] || '(none)'
|
|
690
|
+
const e = map.get(key)
|
|
691
|
+
if (e) {
|
|
692
|
+
e.calls += 1
|
|
693
|
+
e.inputTokens += r.inputTokens
|
|
694
|
+
e.outputTokens += r.outputTokens
|
|
695
|
+
e.cacheReadTokens += r.cacheReadTokens
|
|
696
|
+
e.cacheWriteTokens += r.cacheWriteTokens
|
|
697
|
+
e.reasoningTokens += r.reasoningTokens
|
|
698
|
+
e.billedInput += r.billedInput
|
|
699
|
+
e.totalTokens += r.totalTokens
|
|
700
|
+
e.cost += r.cost
|
|
701
|
+
if (typeof r.llmMs === 'number') { e.llmMs += r.llmMs; e.timed += 1 }
|
|
702
|
+
} else {
|
|
703
|
+
map.set(key, {
|
|
704
|
+
key, calls: 1,
|
|
705
|
+
inputTokens: r.inputTokens, outputTokens: r.outputTokens,
|
|
706
|
+
cacheReadTokens: r.cacheReadTokens, cacheWriteTokens: r.cacheWriteTokens,
|
|
707
|
+
reasoningTokens: r.reasoningTokens, billedInput: r.billedInput,
|
|
708
|
+
totalTokens: r.totalTokens, cost: r.cost,
|
|
709
|
+
llmMs: typeof r.llmMs === 'number' ? r.llmMs : 0,
|
|
710
|
+
timed: typeof r.llmMs === 'number' ? 1 : 0,
|
|
711
|
+
})
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
return [...map.values()]
|
|
715
|
+
.map((e) => ({ ...e, cacheHitPct: e.billedInput > 0 ? Math.round((e.cacheReadTokens / e.billedInput) * 100) : 0 }))
|
|
716
|
+
.sort((a, b) => b.calls - a.calls)
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function totals(list) {
|
|
720
|
+
const t = { calls: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, reasoningTokens: 0, billedInput: 0, totalTokens: 0, cost: 0, llmMs: 0, timed: 0 }
|
|
721
|
+
for (const r of list) {
|
|
722
|
+
t.calls += 1
|
|
723
|
+
t.inputTokens += r.inputTokens
|
|
724
|
+
t.outputTokens += r.outputTokens
|
|
725
|
+
t.cacheReadTokens += r.cacheReadTokens
|
|
726
|
+
t.cacheWriteTokens += r.cacheWriteTokens
|
|
727
|
+
t.reasoningTokens += r.reasoningTokens
|
|
728
|
+
t.billedInput += r.billedInput
|
|
729
|
+
t.totalTokens += r.totalTokens
|
|
730
|
+
t.cost += r.cost
|
|
731
|
+
if (typeof r.llmMs === 'number') { t.llmMs += r.llmMs; t.timed += 1 }
|
|
732
|
+
}
|
|
733
|
+
t.cacheHitPct = t.billedInput > 0 ? Math.round((t.cacheReadTokens / t.billedInput) * 100) : 0
|
|
734
|
+
return t
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// ---------- RPC(webServer HTTP 路由,前缀 /dsh-dock/tokenlog/) ----------
|
|
738
|
+
|
|
739
|
+
// 实时监听 + 历史扫描: 必须等 sessionQuery 服务就绪。
|
|
740
|
+
// 注意: 不能在 setup 时用 ctx.get('sessionQuery') 软获取——profile 插件无 inject 声明,
|
|
741
|
+
// 可能早于 sessionQuery 提供方激活, 拿到 undefined 会让 监听+扫描 整条采集链路静默失效。
|
|
742
|
+
// 改用 ctx.inject(['sessionQuery'], ...): 服务已就绪则立即执行, 否则挂起等待。
|
|
743
|
+
let sessionQuery = null
|
|
744
|
+
disposers.push(ctx.inject(['sessionQuery'], (sqCtx) => {
|
|
745
|
+
sessionQuery = sqCtx.sessionQuery
|
|
746
|
+
// 服务就绪后加载 settings 单价配置(汇率 + 自定义单价 + 抓取开关), 供采集/重扫/导出使用
|
|
747
|
+
loadPricingConfig()
|
|
748
|
+
// 从官网获取最新定价(启动时 + 每 N 小时自动刷新; 面板关闭抓取时不发起请求)
|
|
749
|
+
if (fetchOfficialEnabled) fetchOfficialPricing()
|
|
750
|
+
const fetchTimer = setInterval(() => { if (fetchOfficialEnabled) fetchOfficialPricing() }, getPricingFetchConfig().intervalMs)
|
|
751
|
+
sqCtx.effect(() => () => clearInterval(fetchTimer))
|
|
752
|
+
// 实时监听: session/event (root scope 可收到所有 session 事件, 与官方 persistence 同款订阅)
|
|
753
|
+
disposers.push(sqCtx.on('session/event', (session, event) => {
|
|
754
|
+
try {
|
|
755
|
+
const sid = typeof session === 'object' && session ? (session.id || '') : String(session || '')
|
|
756
|
+
const rec = ingestEvent(sid, event)
|
|
757
|
+
if (rec) records.set(rec.id, rec)
|
|
758
|
+
// turn/end 到达后回填该 turn 已有记录的状态/错误(实时增量场景)
|
|
759
|
+
if (event.type === 'turn/end') {
|
|
760
|
+
const t = event.data && event.data.turn
|
|
761
|
+
const key = sid + ':' + t
|
|
762
|
+
const st = turnStatus.get(key)
|
|
763
|
+
const ei = turnError.get(key) || null
|
|
764
|
+
if (st) {
|
|
765
|
+
for (const r of records.values()) {
|
|
766
|
+
if (r.sessionId === sid && String(r.turn) === String(t)) {
|
|
767
|
+
r.status = st
|
|
768
|
+
r.statusCode = ei ? ei.statusCode : 0
|
|
769
|
+
r.errorCode = ei ? ei.errorCode : ''
|
|
770
|
+
r.errorMsg = ei ? ei.errorMsg : ''
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
} catch (err) {
|
|
776
|
+
console.error('[dsh-dock] tokenlog ingest error', err)
|
|
777
|
+
}
|
|
778
|
+
}))
|
|
779
|
+
// 服务就绪后立即做一次历史全量扫描
|
|
780
|
+
scanHistory()
|
|
781
|
+
}))
|
|
782
|
+
|
|
783
|
+
// webServer 就绪后注册 /dsh-dock/tokenlog 前缀路由(ctx.inject 保证就绪后再执行)
|
|
784
|
+
disposers.push(ctx.inject(['webServer'], (wsCtx) => {
|
|
785
|
+
wsCtx.effect(() => wsCtx.webServer.register({
|
|
786
|
+
kind: 'prefix',
|
|
787
|
+
path: '/dsh-dock/tokenlog',
|
|
788
|
+
async handler(req, res) {
|
|
789
|
+
try {
|
|
790
|
+
const url = new URL(req.url || '/', 'http://dsh.internal')
|
|
791
|
+
const method = url.pathname.replace(/^\/dsh-dock\/tokenlog\/?/, '').split('/')[0] || ''
|
|
792
|
+
const payload = await readBody(req)
|
|
793
|
+
if (method === 'query') {
|
|
794
|
+
loadPricingConfig()
|
|
795
|
+
// 费用按当前单价即时重算(withCost): 面板改价后立即反映到统计与明细, 无需重扫历史。
|
|
796
|
+
const list = buildQuery(payload || {}).map(withCost)
|
|
797
|
+
const rows = [...list].sort((a, b) => b.time - a.time)
|
|
798
|
+
const all = [...records.values()]
|
|
799
|
+
const cfg = readConfiguredProviders()
|
|
800
|
+
// 提供商/模型下拉 = 现有配置(llm-pi-ai.providers) ∪ 历史记录, 去重排序。
|
|
801
|
+
// 兼容两种场景: 无记录时仍可选现有提供商; 提供商/模型已删除时因记录仍可选中。
|
|
802
|
+
const providerSet = new Set(all.map((r) => r.provider).filter(Boolean))
|
|
803
|
+
const modelSet = new Set(all.map((r) => r.model).filter(Boolean))
|
|
804
|
+
for (const pid of Object.keys(cfg)) if (pid) providerSet.add(pid)
|
|
805
|
+
for (const ids of Object.values(cfg)) for (const id of ids) modelSet.add(id)
|
|
806
|
+
// 提供商 -> 模型 映射(供前端联动筛选: 选中提供商后模型下拉只显示其下模型)
|
|
807
|
+
const modelsByProvider = {}
|
|
808
|
+
const initMbp = (p) => { if (!modelsByProvider[p]) modelsByProvider[p] = [] }
|
|
809
|
+
for (const p of providerSet) initMbp(p)
|
|
810
|
+
for (const r of all) {
|
|
811
|
+
if (!r.model) continue
|
|
812
|
+
const p = r.provider || '(none)'
|
|
813
|
+
initMbp(p)
|
|
814
|
+
if (!modelsByProvider[p].includes(r.model)) modelsByProvider[p].push(r.model)
|
|
815
|
+
}
|
|
816
|
+
for (const [pid, ids] of Object.entries(cfg)) {
|
|
817
|
+
initMbp(pid)
|
|
818
|
+
for (const id of ids) if (!modelsByProvider[pid].includes(id)) modelsByProvider[pid].push(id)
|
|
819
|
+
}
|
|
820
|
+
for (const p of Object.keys(modelsByProvider)) modelsByProvider[p].sort()
|
|
821
|
+
return sendJson(res, 200, {
|
|
822
|
+
ok: true,
|
|
823
|
+
data: {
|
|
824
|
+
records: rows,
|
|
825
|
+
counts: { total: records.size, matching: rows.length },
|
|
826
|
+
providers: [...providerSet].sort(),
|
|
827
|
+
models: [...modelSet].sort(),
|
|
828
|
+
modelsByProvider,
|
|
829
|
+
statuses: [...new Set(all.map((r) => r.status).filter(Boolean))],
|
|
830
|
+
efforts: [...new Set(all.map((r) => r.effort).filter(Boolean))],
|
|
831
|
+
sessionIds: [...new Set(all.map((r) => r.sessionId).filter(Boolean))].sort(),
|
|
832
|
+
rateUsdCny: usdCnyRate,
|
|
833
|
+
pricingInfo: {
|
|
834
|
+
hasCustom: userPricing.length > 0,
|
|
835
|
+
customCount: userPricing.length,
|
|
836
|
+
fetchOfficial: fetchOfficialEnabled,
|
|
837
|
+
sources: [...new Set(list.map((r) => r.pricingSource).filter(Boolean))].map((s) => PRICING_SOURCE_LABELS[s] || s),
|
|
838
|
+
},
|
|
839
|
+
totals: totals(list),
|
|
840
|
+
summary: payload && payload.dim ? summarize(list, payload.dim) : [],
|
|
841
|
+
}
|
|
842
|
+
})
|
|
843
|
+
}
|
|
844
|
+
if (method === 'pricing') {
|
|
845
|
+
loadPricingConfig()
|
|
846
|
+
return sendJson(res, 200, { ok: true, data: readPricingConfig() })
|
|
847
|
+
}
|
|
848
|
+
if (method === 'setpricing') {
|
|
849
|
+
await savePricingConfig(payload || {})
|
|
850
|
+
loadPricingConfig()
|
|
851
|
+
return sendJson(res, 200, { ok: true, data: readPricingConfig() })
|
|
852
|
+
}
|
|
853
|
+
if (method === 'export') {
|
|
854
|
+
loadPricingConfig()
|
|
855
|
+
const list = buildQuery(payload || {}).map(withCost)
|
|
856
|
+
// export 返回结构化明细(不是 CSV 文本):客户端按「明细表列序 + 中文表头」统一
|
|
857
|
+
// 格式化成 CSV,取值格式与页面显示一致;rateUsdCny 供客户端把 cost(USD) 折人民币。
|
|
858
|
+
return sendJson(res, 200, {
|
|
859
|
+
ok: true,
|
|
860
|
+
data: { rows: list, rateUsdCny: usdCnyRate, count: list.length },
|
|
861
|
+
})
|
|
862
|
+
}
|
|
863
|
+
if (method === 'scan') {
|
|
864
|
+
await scanHistory()
|
|
865
|
+
return sendJson(res, 200, { ok: true, data: { total: records.size } })
|
|
866
|
+
}
|
|
867
|
+
if (method === 'hello') {
|
|
868
|
+
return sendJson(res, 200, { ok: true, data: { ok: true, records: records.size, scanned: scannedSessions.size } })
|
|
869
|
+
}
|
|
870
|
+
return sendJson(res, 404, { ok: false, error: { code: 'method-not-found', message: 'unknown method: ' + method, details: {} } })
|
|
871
|
+
} catch (e) {
|
|
872
|
+
// 参数校验类错误按 4xx 返回(如单价非法), 其余为 500
|
|
873
|
+
const status = e && e.statusCode ? e.statusCode : 500
|
|
874
|
+
return sendJson(res, status, { ok: false, error: { code: status === 500 ? 'internal' : 'bad-request', message: (e && e.message) || String(e), details: {} } })
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
}), 'dsh-dock tokenlog: /dsh-dock/tokenlog HTTP route')
|
|
878
|
+
}))
|
|
879
|
+
|
|
880
|
+
return dispose
|
|
881
|
+
},
|
|
882
|
+
}
|