@feiyang666/dsh-usage-plugin 1.9.1 → 1.9.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/CHANGELOG.md +19 -0
- package/README.md +15 -5
- package/README.zh.md +15 -5
- package/cordis.patch.yml +23 -22
- package/lib/balance.js +205 -0
- package/lib/client.js +1489 -1247
- package/lib/index.js +1196 -911
- package/package.json +9 -1
- package/scripts/wire.js +109 -108
package/lib/index.js
CHANGED
|
@@ -1,911 +1,1196 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* dsh-usage-plugin — HOST half.
|
|
3
|
-
*
|
|
4
|
-
* Permanent Cordis plugin for a DeepSeek Harness web/desktop profile:
|
|
5
|
-
* - listens to `llm/stream`, records every model call's token usage,
|
|
6
|
-
* cache-hit/miss counts and finish reason;
|
|
7
|
-
* - persists records to
|
|
8
|
-
* - serves a JSON API at `POST /usage/api` for the client half.
|
|
9
|
-
*
|
|
10
|
-
* The apply body is instrumented: every step is appended to a diagnostics
|
|
11
|
-
* buffer and flushed to `dsh-usage-boot.log` (resolved relative to the fs
|
|
12
|
-
* provider cwd) so activation failures are visible without app logs.
|
|
13
|
-
*
|
|
14
|
-
* Cross-platform note: path handling uses node:path (join / dirname) with the
|
|
15
|
-
* host platform's separator, so the plugin works on Windows, macOS and Linux.
|
|
16
|
-
*/
|
|
17
|
-
import path from 'node:path'
|
|
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
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
if (
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
if (
|
|
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
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
const
|
|
204
|
-
if (
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
return
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
const
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
const
|
|
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
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
await
|
|
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
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
if (!
|
|
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
|
-
if (
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
'
|
|
589
|
-
'
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
'
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
const
|
|
635
|
-
if (!
|
|
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
|
-
const
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
}
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
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
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
}
|
|
899
|
-
}
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* dsh-usage-plugin — HOST half.
|
|
3
|
+
*
|
|
4
|
+
* Permanent Cordis plugin for a DeepSeek Harness web/desktop profile:
|
|
5
|
+
* - listens to `llm/stream`, records every model call's token usage,
|
|
6
|
+
* cache-hit/miss counts and finish reason;
|
|
7
|
+
* - persists records to a FIXED dedicated data directory (see resolveDataRoot),
|
|
8
|
+
* - serves a JSON API at `POST /usage/api` for the client half.
|
|
9
|
+
*
|
|
10
|
+
* The apply body is instrumented: every step is appended to a diagnostics
|
|
11
|
+
* buffer and flushed to `dsh-usage-boot.log` (resolved relative to the fs
|
|
12
|
+
* provider cwd) so activation failures are visible without app logs.
|
|
13
|
+
*
|
|
14
|
+
* Cross-platform note: path handling uses node:path (join / dirname) with the
|
|
15
|
+
* host platform's separator, so the plugin works on Windows, macOS and Linux.
|
|
16
|
+
*/
|
|
17
|
+
import path from 'node:path'
|
|
18
|
+
import os from 'node:os'
|
|
19
|
+
import {
|
|
20
|
+
getBalanceProvider,
|
|
21
|
+
matchesModelProvider,
|
|
22
|
+
parseBalanceResponse,
|
|
23
|
+
providerList,
|
|
24
|
+
resolveBalanceEndpoint
|
|
25
|
+
} from './balance.js'
|
|
26
|
+
|
|
27
|
+
export default {
|
|
28
|
+
inject: ['fs', 'webServer', 'subprocess', 'credentials', 'settings', 'sandboxPolicy', 'agents'],
|
|
29
|
+
apply(ctx) {
|
|
30
|
+
const diag = { ok: true, steps: [], error: null }
|
|
31
|
+
const push = (s) => { try { diag.steps.push(String(s)) } catch (e) {} }
|
|
32
|
+
const flushDiag = () => {
|
|
33
|
+
try {
|
|
34
|
+
const fs = ctx.get('fs')
|
|
35
|
+
if (fs && typeof fs.resolve === 'function' && typeof fs.writeText === 'function') {
|
|
36
|
+
fs.resolve('dsh-usage-boot.log')
|
|
37
|
+
.then((target) => fs.writeText(target, JSON.stringify({ time: Date.now(), ...diag }, null, 2)))
|
|
38
|
+
.catch(() => {})
|
|
39
|
+
}
|
|
40
|
+
} catch (e) {}
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
push('apply-start')
|
|
44
|
+
|
|
45
|
+
const records = []
|
|
46
|
+
const MAX_RECORDS = 100000
|
|
47
|
+
|
|
48
|
+
const PRICING = {
|
|
49
|
+
base: {
|
|
50
|
+
'deepseek-v4-flash': { cacheHit: 0.02, cacheMiss: 1.0, output: 2.0 },
|
|
51
|
+
'deepseek-v4-pro': { cacheHit: 0.025, cacheMiss: 3.0, output: 6.0 }
|
|
52
|
+
},
|
|
53
|
+
peakValley: {
|
|
54
|
+
'deepseek-v4-flash': {
|
|
55
|
+
offPeak: { cacheHit: 0.05, cacheMiss: 1.5, output: 4.5 },
|
|
56
|
+
peak: { cacheHit: 0.1, cacheMiss: 3.0, output: 9.0 }
|
|
57
|
+
},
|
|
58
|
+
'deepseek-v4-pro': {
|
|
59
|
+
offPeak: { cacheHit: 0.15, cacheMiss: 4.5, output: 13.5 },
|
|
60
|
+
peak: { cacheHit: 0.3, cacheMiss: 9.0, output: 27.0 }
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const DEFAULT_PRICING = JSON.parse(JSON.stringify(PRICING))
|
|
65
|
+
const PRICE_MODELS = ['deepseek-v4-flash', 'deepseek-v4-pro']
|
|
66
|
+
const SILICONFLOW_PRICING = {
|
|
67
|
+
'deepseek-ai/deepseek-v4-flash': { cacheHit: 0.02, cacheMiss: 1.0, output: 2.0 },
|
|
68
|
+
'deepseek-ai/deepseek-v4-pro': { cacheHit: 1.0, cacheMiss: 12.0, output: 24.0 },
|
|
69
|
+
'deepseek-ai/deepseek-v3.2': { cacheHit: 0.4, cacheMiss: 4.0, output: 6.0 },
|
|
70
|
+
'pro/deepseek-ai/deepseek-v3.2': { cacheHit: 0.4, cacheMiss: 4.0, output: 6.0 },
|
|
71
|
+
'qwen/qwen3.6-27b': { cacheHit: 3.0, cacheMiss: 3.0, output: 18.0 }
|
|
72
|
+
}
|
|
73
|
+
const DIGITALOCEAN_PRICING = {
|
|
74
|
+
flash: { cacheHit: 0.028, cacheMiss: 0.112, output: 0.224 },
|
|
75
|
+
pro: { cacheHit: 0.348, cacheMiss: 1.392, output: 2.784 },
|
|
76
|
+
v32: { cacheHit: 0.15, cacheMiss: 0.425, output: 1.36 }
|
|
77
|
+
}
|
|
78
|
+
let FX = { rate: 0, inverse: 0, date: '', queriedAt: 0, source: 'Frankfurter', stale: false, error: '' }
|
|
79
|
+
// 新价格表(峰谷价)生效时间:北京时间 2026-08-17 00:00。
|
|
80
|
+
// 在此之前的调用按旧价格表(基础价 base)计费;之后按新价格表(峰谷价)计费。
|
|
81
|
+
const EFFECTIVE_AT = Date.parse('2026-08-17T00:00:00+08:00')
|
|
82
|
+
|
|
83
|
+
function modelKey(model) {
|
|
84
|
+
const m = String(model || '').toLowerCase()
|
|
85
|
+
if (m.indexOf('flash') >= 0) return 'deepseek-v4-flash'
|
|
86
|
+
if (m.indexOf('pro') >= 0) return 'deepseek-v4-pro'
|
|
87
|
+
return 'unknown'
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function isPeak(ts) {
|
|
91
|
+
const d = new Date(ts + 8 * 3600 * 1000)
|
|
92
|
+
const t = d.getUTCHours() * 60 + d.getUTCMinutes()
|
|
93
|
+
return (t >= 9 * 60 && t < 12 * 60) || (t >= 14 * 60 && t < 18 * 60)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Third-party providers are priced only when a verified provider/model
|
|
97
|
+
// mapping exists. Unknown mappings deliberately remain zero rather than
|
|
98
|
+
// inheriting DeepSeek prices from a similar model name.
|
|
99
|
+
function costFor(rec, regime) {
|
|
100
|
+
const provider = String(rec.provider || '').trim().toLowerCase()
|
|
101
|
+
const model = String(rec.model || '').trim().toLowerCase()
|
|
102
|
+
const hit = rec.cacheReadTokens || 0
|
|
103
|
+
const miss = rec.inputTokens || 0
|
|
104
|
+
const out = rec.outputTokens || 0
|
|
105
|
+
|
|
106
|
+
if (provider === 'siliconflow') {
|
|
107
|
+
const p = SILICONFLOW_PRICING[model]
|
|
108
|
+
if (!p) return 0
|
|
109
|
+
return (hit * p.cacheHit + miss * p.cacheMiss + out * p.output) / 1e6
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (provider === 'digital-ocean' || provider === 'digitalocean') {
|
|
113
|
+
let p = null
|
|
114
|
+
if (model.indexOf('v3.2') >= 0 || model.indexOf('v3-2') >= 0) p = DIGITALOCEAN_PRICING.v32
|
|
115
|
+
else if (model.indexOf('pro') >= 0) p = DIGITALOCEAN_PRICING.pro
|
|
116
|
+
else if (model.indexOf('flash') >= 0) p = DIGITALOCEAN_PRICING.flash
|
|
117
|
+
if (!p) return 0
|
|
118
|
+
const rate = Number(rec.usdCnyRate || FX.rate || 0)
|
|
119
|
+
if (!(rate > 0)) return 0
|
|
120
|
+
return ((hit * p.cacheHit + miss * p.cacheMiss + out * p.output) / 1e6) * rate
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (provider === 'amd' || provider === 'amd-gpu-cloud' || provider === 'alibaba' || provider === 'aliyun' || provider === 'qwen') return 0
|
|
124
|
+
if (provider !== 'deepseek-official' && provider !== 'deepseek') return 0
|
|
125
|
+
|
|
126
|
+
const mk = modelKey(rec.model)
|
|
127
|
+
if (regime === 'base') {
|
|
128
|
+
const p = PRICING.base[mk]
|
|
129
|
+
if (!p) return 0
|
|
130
|
+
return (hit * p.cacheHit + miss * p.cacheMiss + out * p.output) / 1e6
|
|
131
|
+
}
|
|
132
|
+
if (regime === 'auto') {
|
|
133
|
+
if (rec.time < EFFECTIVE_AT) {
|
|
134
|
+
const p = PRICING.base[mk]
|
|
135
|
+
if (!p) return 0
|
|
136
|
+
return (hit * p.cacheHit + miss * p.cacheMiss + out * p.output) / 1e6
|
|
137
|
+
}
|
|
138
|
+
const pv = PRICING.peakValley[mk]
|
|
139
|
+
if (!pv) return 0
|
|
140
|
+
const p = isPeak(rec.time) ? pv.peak : pv.offPeak
|
|
141
|
+
return (hit * p.cacheHit + miss * p.cacheMiss + out * p.output) / 1e6
|
|
142
|
+
}
|
|
143
|
+
const pv = PRICING.peakValley[mk]
|
|
144
|
+
if (!pv) return 0
|
|
145
|
+
const p = isPeak(rec.time) ? pv.peak : pv.offPeak
|
|
146
|
+
return (hit * p.cacheHit + miss * p.cacheMiss + out * p.output) / 1e6
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const msg = (e) => String((e && e.message) || e)
|
|
150
|
+
const fail = (message) => ({ ok: false, error: message })
|
|
151
|
+
const pad2 = (n) => (n < 10 ? '0' : '') + n
|
|
152
|
+
const fmtInt = (n) => String(Math.round(n || 0)).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
|
|
153
|
+
const fmtTime = (ts) => {
|
|
154
|
+
const d = new Date(ts + 8 * 3600 * 1000)
|
|
155
|
+
return `${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())} ${pad2(d.getUTCHours())}:${pad2(d.getUTCMinutes())}:${pad2(d.getUTCSeconds())}`
|
|
156
|
+
}
|
|
157
|
+
const fmtMoney = (n) => {
|
|
158
|
+
if (!n) return '0.0000'
|
|
159
|
+
if (n < 0.0001) return n.toExponential(2)
|
|
160
|
+
if (n < 1) return n.toFixed(4)
|
|
161
|
+
return n.toFixed(2)
|
|
162
|
+
}
|
|
163
|
+
const IS_WIN = typeof process !== 'undefined' && process.platform === 'win32'
|
|
164
|
+
const IS_MAC = typeof process !== 'undefined' && process.platform === 'darwin'
|
|
165
|
+
const normPath = (p) => {
|
|
166
|
+
const s = String(p == null ? '' : p)
|
|
167
|
+
return IS_WIN ? s.replace(/\//g, '\\') : s
|
|
168
|
+
}
|
|
169
|
+
const joinPath = (...parts) => path.join(...parts.map((p) => String(p == null ? '' : p)))
|
|
170
|
+
const stamp = () => {
|
|
171
|
+
const d = new Date()
|
|
172
|
+
return `${d.getFullYear()}${pad2(d.getMonth() + 1)}${pad2(d.getDate())}-${pad2(d.getHours())}${pad2(d.getMinutes())}${pad2(d.getSeconds())}`
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function bjKey(ts) {
|
|
176
|
+
const d = new Date(Number(ts) + 8 * 3600 * 1000)
|
|
177
|
+
return `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function buildDays() {
|
|
181
|
+
const map = {}
|
|
182
|
+
for (const r of records) {
|
|
183
|
+
const key = bjKey(r.time)
|
|
184
|
+
let d = map[key]
|
|
185
|
+
if (!d) {
|
|
186
|
+
d = {
|
|
187
|
+
day: key, calls: 0, miss: 0, hit: 0, write: 0, out: 0, reason: 0,
|
|
188
|
+
peakCalls: 0, offPeakCalls: 0, baseCost: 0, peakValleyCost: 0, autoCost: 0,
|
|
189
|
+
basePeakCost: 0, baseOffPeakCost: 0,
|
|
190
|
+
pvPeakCost: 0, pvOffPeakCost: 0,
|
|
191
|
+
autoPeakCost: 0, autoOffPeakCost: 0
|
|
192
|
+
}
|
|
193
|
+
map[key] = d
|
|
194
|
+
}
|
|
195
|
+
d.calls++
|
|
196
|
+
d.miss += r.inputTokens || 0
|
|
197
|
+
d.hit += r.cacheReadTokens || 0
|
|
198
|
+
d.write += r.cacheWriteTokens || 0
|
|
199
|
+
d.out += r.outputTokens || 0
|
|
200
|
+
d.reason += r.reasoningTokens || 0
|
|
201
|
+
const cBase = costFor(r, 'base')
|
|
202
|
+
const cPv = costFor(r, 'peakValley')
|
|
203
|
+
const cAuto = costFor(r, 'auto')
|
|
204
|
+
if (isPeak(r.time)) {
|
|
205
|
+
d.peakCalls++
|
|
206
|
+
d.basePeakCost += cBase
|
|
207
|
+
d.pvPeakCost += cPv
|
|
208
|
+
d.autoPeakCost += cAuto
|
|
209
|
+
} else {
|
|
210
|
+
d.offPeakCalls++
|
|
211
|
+
d.baseOffPeakCost += cBase
|
|
212
|
+
d.pvOffPeakCost += cPv
|
|
213
|
+
d.autoOffPeakCost += cAuto
|
|
214
|
+
}
|
|
215
|
+
d.baseCost += cBase
|
|
216
|
+
d.peakValleyCost += cPv
|
|
217
|
+
d.autoCost += cAuto
|
|
218
|
+
}
|
|
219
|
+
const days = []
|
|
220
|
+
for (const k in map) days.push(map[k])
|
|
221
|
+
days.sort((a, b) => (a.day < b.day ? 1 : a.day > b.day ? -1 : 0))
|
|
222
|
+
return days
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const fs = ctx.get('fs')
|
|
226
|
+
push('fs=' + (fs ? 'present' : 'undefined'))
|
|
227
|
+
let root = ''
|
|
228
|
+
let dataPath = ''
|
|
229
|
+
let pricingPath = ''
|
|
230
|
+
let persistOk = false
|
|
231
|
+
let persistError = ''
|
|
232
|
+
let initPromise = null
|
|
233
|
+
let writeChain = Promise.resolve()
|
|
234
|
+
let cachedPolicy = null
|
|
235
|
+
|
|
236
|
+
const dirs = () => ({
|
|
237
|
+
data: joinPath(root, 'dsh-usage'),
|
|
238
|
+
csv: joinPath(root, 'dsh-usage', 'csv'),
|
|
239
|
+
json: joinPath(root, 'dsh-usage', 'json'),
|
|
240
|
+
images: joinPath(root, 'dsh-usage', 'images')
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
function currentAgent() {
|
|
244
|
+
try {
|
|
245
|
+
const agents = ctx.get('agents')
|
|
246
|
+
if (agents && typeof agents.currentInitiator === 'function') return agents.currentInitiator()
|
|
247
|
+
} catch (e) {}
|
|
248
|
+
return undefined
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function sessionPolicy() {
|
|
252
|
+
if (cachedPolicy) return cachedPolicy
|
|
253
|
+
try {
|
|
254
|
+
const agent = currentAgent()
|
|
255
|
+
const sp = ctx.get('sandboxPolicy')
|
|
256
|
+
if (sp && typeof sp.resolve === 'function' && agent && agent.session) {
|
|
257
|
+
const policy = sp.resolve({ session: agent.session })
|
|
258
|
+
if (policy && policy.workspaceRoot) {
|
|
259
|
+
cachedPolicy = policy
|
|
260
|
+
return policy
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
} catch (e) {}
|
|
264
|
+
return undefined
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function persistNow() {
|
|
268
|
+
if (!fs || !dataPath || !persistOk) return Promise.resolve()
|
|
269
|
+
const text = JSON.stringify(records)
|
|
270
|
+
const policy = undefined
|
|
271
|
+
writeChain = writeChain.then(() =>
|
|
272
|
+
fs.resolve(dataPath).then((target) =>
|
|
273
|
+
fs.writeText(target, text, undefined, undefined, policy || undefined)
|
|
274
|
+
)
|
|
275
|
+
).catch(() => {})
|
|
276
|
+
return writeChain
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function persistPricing() {
|
|
280
|
+
if (!fs || !pricingPath || !persistOk) return Promise.resolve()
|
|
281
|
+
const text = JSON.stringify(PRICING)
|
|
282
|
+
const policy = undefined
|
|
283
|
+
return fs.resolve(pricingPath)
|
|
284
|
+
.then((target) => fs.writeText(target, text, undefined, undefined, policy || undefined))
|
|
285
|
+
.catch(() => {})
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function loadPricing(policy) {
|
|
289
|
+
if (!fs || !pricingPath) return
|
|
290
|
+
try {
|
|
291
|
+
const target = await fs.resolve(pricingPath)
|
|
292
|
+
const data = JSON.parse(await fs.readText(target))
|
|
293
|
+
if (!data || typeof data !== 'object') return
|
|
294
|
+
for (const regime of ['base', 'peakValley']) {
|
|
295
|
+
const src = data[regime]
|
|
296
|
+
const dst = PRICING[regime]
|
|
297
|
+
if (!src || typeof src !== 'object' || !dst) continue
|
|
298
|
+
for (const mk of PRICE_MODELS) {
|
|
299
|
+
const row = src[mk]
|
|
300
|
+
if (!row || typeof row !== 'object' || !dst[mk]) continue
|
|
301
|
+
for (const k of ['cacheHit', 'cacheMiss', 'output']) {
|
|
302
|
+
const v = Number(row[k])
|
|
303
|
+
if (Number.isFinite(v) && v >= 0) dst[mk][k] = v
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
} catch (e) {}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function normalizeRecord(raw) {
|
|
311
|
+
if (!raw || typeof raw !== 'object') return null
|
|
312
|
+
const time = Number(raw.time)
|
|
313
|
+
if (!Number.isFinite(time) || time <= 0) return null
|
|
314
|
+
const toNum = (v, d) => { const n = Number(v); return Number.isFinite(n) ? n : (d === undefined ? 0 : d) }
|
|
315
|
+
return {
|
|
316
|
+
time,
|
|
317
|
+
model: String(raw.model || ''),
|
|
318
|
+
provider: String(raw.provider || ''),
|
|
319
|
+
purpose: String(raw.purpose || ''),
|
|
320
|
+
inputTokens: toNum(raw.inputTokens),
|
|
321
|
+
outputTokens: toNum(raw.outputTokens),
|
|
322
|
+
cacheReadTokens: toNum(raw.cacheReadTokens),
|
|
323
|
+
cacheWriteTokens: toNum(raw.cacheWriteTokens),
|
|
324
|
+
reasoningTokens: toNum(raw.reasoningTokens),
|
|
325
|
+
finishReason: String(raw.finishReason || ''),
|
|
326
|
+
usdCnyRate: toNum(raw.usdCnyRate),
|
|
327
|
+
fxDate: String(raw.fxDate || '')
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async function tryInitWithRoot(candidate, policy) {
|
|
332
|
+
const tryPath = joinPath(normPath(candidate), 'dsh-usage', 'usage-records.json')
|
|
333
|
+
try {
|
|
334
|
+
const target = await fs.resolve(tryPath)
|
|
335
|
+
const arr = JSON.parse(await fs.readText(target))
|
|
336
|
+
if (Array.isArray(arr) && arr.length > 0) {
|
|
337
|
+
const existing = {}
|
|
338
|
+
for (let i = 0; i < records.length; i++) existing[records[i].time] = true
|
|
339
|
+
for (let i = 0; i < arr.length; i++) {
|
|
340
|
+
const rec = normalizeRecord(arr[i])
|
|
341
|
+
if (!rec || existing[rec.time]) continue
|
|
342
|
+
existing[rec.time] = true
|
|
343
|
+
records.push(rec)
|
|
344
|
+
}
|
|
345
|
+
if (records.length > MAX_RECORDS) records.splice(0, records.length - MAX_RECORDS)
|
|
346
|
+
records.sort((a, b) => a.time - b.time)
|
|
347
|
+
}
|
|
348
|
+
} catch (e) {}
|
|
349
|
+
try {
|
|
350
|
+
const target = await fs.resolve(tryPath)
|
|
351
|
+
await fs.writeText(target, JSON.stringify(records), undefined, undefined, policy || undefined)
|
|
352
|
+
root = normPath(candidate)
|
|
353
|
+
dataPath = tryPath
|
|
354
|
+
pricingPath = joinPath(path.dirname(dataPath), 'pricing.json')
|
|
355
|
+
await loadPricing(policy)
|
|
356
|
+
persistOk = true
|
|
357
|
+
persistError = ''
|
|
358
|
+
return { ok: true }
|
|
359
|
+
} catch (e) {
|
|
360
|
+
return { ok: false, error: msg(e) }
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
async function migrateLegacy(candidates) {
|
|
365
|
+
if (!fs) return
|
|
366
|
+
const paths = []
|
|
367
|
+
for (const c of candidates) {
|
|
368
|
+
paths.push(joinPath(normPath(c), '.dsh-usage-records.json'))
|
|
369
|
+
paths.push(joinPath(normPath(c), 'dsh-usage', 'usage-records.json'))
|
|
370
|
+
}
|
|
371
|
+
for (const p of paths) {
|
|
372
|
+
try {
|
|
373
|
+
const arr = JSON.parse(await fs.readText(await fs.resolve(p)))
|
|
374
|
+
if (Array.isArray(arr)) {
|
|
375
|
+
const existing = {}
|
|
376
|
+
for (let j = 0; j < records.length; j++) existing[records[j].time] = true
|
|
377
|
+
for (const raw of arr) {
|
|
378
|
+
const rec = normalizeRecord(raw)
|
|
379
|
+
if (!rec || existing[rec.time]) continue
|
|
380
|
+
existing[rec.time] = true
|
|
381
|
+
records.push(rec)
|
|
382
|
+
}
|
|
383
|
+
if (records.length > MAX_RECORDS) records.splice(0, records.length - MAX_RECORDS)
|
|
384
|
+
records.sort((a, b) => a.time - b.time)
|
|
385
|
+
}
|
|
386
|
+
} catch (e) {}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// 固定、专用的数据目录:不随工作区漂移,也不放在 DSH 主目录(~/.dsh)或
|
|
391
|
+
// 桌面端安装目录。优先级:环境变量 DSH_USAGE_DATA_DIR >
|
|
392
|
+
// 系统应用数据目录(AppData/Local 或 ~/Library/Application Support) >
|
|
393
|
+
// 用户主目录下的专用文件夹 dsh-usage-data。
|
|
394
|
+
function resolveDataRoot() {
|
|
395
|
+
const out = []
|
|
396
|
+
const env = ((typeof process !== 'undefined' && process.env && process.env.DSH_USAGE_DATA_DIR) || '').trim()
|
|
397
|
+
if (env) out.push(normPath(env))
|
|
398
|
+
const appData = (typeof process !== 'undefined' && process.env) ? (process.env.LOCALAPPDATA || process.env.APPDATA) : ''
|
|
399
|
+
if (appData) out.push(joinPath(normPath(appData), 'dsh-usage-plugin'))
|
|
400
|
+
out.push(joinPath(normPath(os.homedir()), 'dsh-usage-data'))
|
|
401
|
+
return out
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// 已知可能遗留旧数据的目录(用于一次性合并迁移),不再作为活动根。
|
|
405
|
+
function knownLegacyRoots() {
|
|
406
|
+
const home = normPath(os.homedir())
|
|
407
|
+
const roots = [home, joinPath(home, '.dsh')]
|
|
408
|
+
try {
|
|
409
|
+
const sp = ctx.get('sandboxPolicy')
|
|
410
|
+
if (sp && sp.workspaceRoot) roots.push(normPath(String(sp.workspaceRoot)))
|
|
411
|
+
} catch (e) {}
|
|
412
|
+
const agent = currentAgent()
|
|
413
|
+
if (agent && agent.session && agent.session.header && agent.session.header.cwd) {
|
|
414
|
+
roots.push(normPath(String(agent.session.header.cwd)))
|
|
415
|
+
}
|
|
416
|
+
return roots
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// 会话激活后不再切换根目录(避免路径漂移/历史被拆散);仅把工作区里
|
|
420
|
+
// 可能遗留的旧记录并入固定的数据根。
|
|
421
|
+
async function ensureSessionRoot() {
|
|
422
|
+
if (!fs || !persistOk) return
|
|
423
|
+
await migrateLegacy(knownLegacyRoots())
|
|
424
|
+
persistNow()
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
async function initPersistence() {
|
|
428
|
+
if (!fs) { persistError = '文件服务不可用'; return }
|
|
429
|
+
const candidates = resolveDataRoot()
|
|
430
|
+
let lastError = ''
|
|
431
|
+
for (const c of candidates) {
|
|
432
|
+
const r = await tryInitWithRoot(c, undefined)
|
|
433
|
+
if (r.ok) {
|
|
434
|
+
// 首次初始化时把散落在 主目录/.dsh/各工作区 的历史记录合并进来
|
|
435
|
+
await migrateLegacy(knownLegacyRoots())
|
|
436
|
+
persistNow()
|
|
437
|
+
return
|
|
438
|
+
}
|
|
439
|
+
lastError = r.error || '写入失败'
|
|
440
|
+
}
|
|
441
|
+
persistError = lastError || '未找到可写的持久化目录'
|
|
442
|
+
persistOk = false
|
|
443
|
+
root = ''
|
|
444
|
+
dataPath = ''
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const ensureInit = () => (initPromise ||= initPersistence())
|
|
448
|
+
try { ensureInit() } catch (e) { push('ensureInit-threw: ' + msg(e)) }
|
|
449
|
+
|
|
450
|
+
// ── capture ────────────────────────────────────────────────────────────
|
|
451
|
+
try {
|
|
452
|
+
ctx.on('llm/stream', function (options, next) {
|
|
453
|
+
const source = next()
|
|
454
|
+
const model = (options && options.model) || ''
|
|
455
|
+
const provider = (options && options.provider) || ''
|
|
456
|
+
const purpose = options && options.purpose ? String(options.purpose) : ''
|
|
457
|
+
const startedAt = Date.now()
|
|
458
|
+
let usage = null
|
|
459
|
+
let finishReason = ''
|
|
460
|
+
|
|
461
|
+
async function* observe() {
|
|
462
|
+
try {
|
|
463
|
+
for await (const chunk of source) {
|
|
464
|
+
if (chunk && chunk.type === 'usage' && chunk.usage) {
|
|
465
|
+
usage = chunk.usage
|
|
466
|
+
} else if (chunk && chunk.type === 'finish') {
|
|
467
|
+
const r = chunk.reason
|
|
468
|
+
finishReason = r ? String(r.kind || '') : ''
|
|
469
|
+
}
|
|
470
|
+
yield chunk
|
|
471
|
+
}
|
|
472
|
+
} finally {
|
|
473
|
+
if (usage) {
|
|
474
|
+
if (provider === 'digital-ocean' || provider === 'digitalocean') {
|
|
475
|
+
try { await refreshFxRate(false) } catch (e) {}
|
|
476
|
+
}
|
|
477
|
+
records.push({
|
|
478
|
+
time: startedAt,
|
|
479
|
+
model,
|
|
480
|
+
provider,
|
|
481
|
+
purpose,
|
|
482
|
+
inputTokens: usage.inputTokens || 0,
|
|
483
|
+
outputTokens: usage.outputTokens || 0,
|
|
484
|
+
cacheReadTokens: usage.cacheReadTokens || 0,
|
|
485
|
+
cacheWriteTokens: usage.cacheWriteTokens || 0,
|
|
486
|
+
reasoningTokens: usage.reasoningTokens || 0,
|
|
487
|
+
finishReason,
|
|
488
|
+
usdCnyRate: (provider === 'digital-ocean' || provider === 'digitalocean') ? (FX.rate || 0) : 0,
|
|
489
|
+
fxDate: (provider === 'digital-ocean' || provider === 'digitalocean') ? (FX.date || '') : ''
|
|
490
|
+
})
|
|
491
|
+
if (records.length > MAX_RECORDS) records.splice(0, records.length - MAX_RECORDS)
|
|
492
|
+
try {
|
|
493
|
+
const agent = currentAgent()
|
|
494
|
+
const sp = ctx.get('sandboxPolicy')
|
|
495
|
+
if (sp && typeof sp.resolve === 'function' && agent && agent.session) {
|
|
496
|
+
const policy = sp.resolve({ session: agent.session })
|
|
497
|
+
if (policy && policy.workspaceRoot) cachedPolicy = policy
|
|
498
|
+
}
|
|
499
|
+
} catch (e) {}
|
|
500
|
+
ensureSessionRoot().then(persistNow).catch(() => {})
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
return observe()
|
|
506
|
+
})
|
|
507
|
+
push('llm-stream-listener-ok')
|
|
508
|
+
} catch (e) {
|
|
509
|
+
push('llm-stream-listener-threw: ' + (e && e.stack ? e.stack : msg(e)))
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// ── balance / network helpers ──────────────────────────────────────────
|
|
513
|
+
async function safeCwd() {
|
|
514
|
+
if (root && fs) {
|
|
515
|
+
try {
|
|
516
|
+
const t = await fs.resolve(root)
|
|
517
|
+
const info = await fs.stat(t)
|
|
518
|
+
if (info) return root
|
|
519
|
+
} catch (e) {}
|
|
520
|
+
}
|
|
521
|
+
return (typeof process !== 'undefined' && typeof process.cwd === 'function' && process.cwd()) || '.'
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
async function runCollect(argv, opts) {
|
|
525
|
+
const subprocess = ctx.get('subprocess')
|
|
526
|
+
if (!subprocess) return { ok: false, error: '命令执行服务不可用' }
|
|
527
|
+
let handle
|
|
528
|
+
try {
|
|
529
|
+
handle = subprocess.spawn({
|
|
530
|
+
argv,
|
|
531
|
+
cwd: await safeCwd(),
|
|
532
|
+
stdio: opts && opts.stdinData != null
|
|
533
|
+
? { stdin: { data: opts.stdinData }, stdout: { maxBytes: 65536 }, stderr: { maxBytes: 65536 } }
|
|
534
|
+
: { stdin: 'ignore', stdout: { maxBytes: 65536 }, stderr: { maxBytes: 65536 } },
|
|
535
|
+
graceMs: (opts && opts.graceMs) || 15000,
|
|
536
|
+
...(opts && opts.env ? { env: opts.env } : {})
|
|
537
|
+
})
|
|
538
|
+
} catch (e) { return { ok: false, error: '启动失败:' + msg(e) } }
|
|
539
|
+
let outcome
|
|
540
|
+
try { outcome = await handle.done } catch (e) { return { ok: false, error: '执行失败:' + msg(e) } }
|
|
541
|
+
const outText = handle.collected && handle.collected.stdout ? handle.collected.stdout.readFrom(0).text : ''
|
|
542
|
+
const errText = handle.collected && handle.collected.stderr ? handle.collected.stderr.readFrom(0).text : ''
|
|
543
|
+
return { ok: outcome.exitCode === 0, exitCode: outcome.exitCode, out: outText, err: errText }
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const isElectron = typeof process !== 'undefined' && !!(process.versions && process.versions.electron)
|
|
547
|
+
function nodeCandidates() {
|
|
548
|
+
const list = IS_WIN
|
|
549
|
+
? ['node.exe', 'node', 'C:\\Program Files\\nodejs\\node.exe']
|
|
550
|
+
: ['node']
|
|
551
|
+
if (typeof process !== 'undefined' && process.execPath && !list.includes(process.execPath)) list.push(process.execPath)
|
|
552
|
+
return list
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
async function spawnNode(script, stdinData, env) {
|
|
556
|
+
const subprocess = ctx.get('subprocess')
|
|
557
|
+
if (!subprocess) return { ok: false, error: '命令执行服务不可用' }
|
|
558
|
+
let exe = null
|
|
559
|
+
for (const c of nodeCandidates()) {
|
|
560
|
+
try { exe = await subprocess.resolveExecutable(c); if (exe) break } catch (e) {}
|
|
561
|
+
}
|
|
562
|
+
if (!exe) return { ok: false, error: '未找到 node 可执行文件' }
|
|
563
|
+
const finalEnv = env || {}
|
|
564
|
+
if (isElectron && exe === process.execPath && !('ELECTRON_RUN_AS_NODE' in finalEnv)) {
|
|
565
|
+
finalEnv.ELECTRON_RUN_AS_NODE = '1'
|
|
566
|
+
}
|
|
567
|
+
const r = await runCollect([exe, '-e', script], { stdinData, env: finalEnv })
|
|
568
|
+
if (!r.ok) {
|
|
569
|
+
if (r.exitCode != null) return { ok: false, error: 'node 退出码 ' + r.exitCode + (r.err ? ':' + r.err.trim() : '') }
|
|
570
|
+
return { ok: false, error: r.error || '执行失败' }
|
|
571
|
+
}
|
|
572
|
+
return { ok: true, out: r.out }
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function bjTodayKey() {
|
|
576
|
+
const d = new Date(Date.now() + 8 * 3600 * 1000)
|
|
577
|
+
return d.getUTCFullYear() + '-' + pad2(d.getUTCMonth() + 1) + '-' + pad2(d.getUTCDate())
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
async function refreshFxRate(force) {
|
|
581
|
+
if (!force && FX.rate > 0 && FX.queriedAt && bjKey(FX.queriedAt) === bjTodayKey()) return FX
|
|
582
|
+
const script = [
|
|
583
|
+
'const https=require("https");',
|
|
584
|
+
'const u="https://api.frankfurter.dev/v2/rates?base=USD"es=CNY";',
|
|
585
|
+
'const req=https.get(u,{headers:{Accept:"application/json","User-Agent":"dsh-usage-plugin"}},function(res){',
|
|
586
|
+
'let b="";res.on("data",c=>b+=c);res.on("end",()=>process.stdout.write(JSON.stringify({status:res.statusCode,body:b})));',
|
|
587
|
+
'});',
|
|
588
|
+
'req.on("error",e=>process.stdout.write(JSON.stringify({error:String(e&&e.message||e)})));',
|
|
589
|
+
'req.setTimeout(15000,()=>req.destroy(new Error("timeout")));'
|
|
590
|
+
].join('\n')
|
|
591
|
+
const r = await spawnNode(script)
|
|
592
|
+
if (!r.ok) {
|
|
593
|
+
FX = { ...FX, stale: FX.rate > 0, error: r.error || '汇率请求失败', queriedAt: Date.now() }
|
|
594
|
+
return FX
|
|
595
|
+
}
|
|
596
|
+
try {
|
|
597
|
+
const wrapper = JSON.parse(r.out)
|
|
598
|
+
if (wrapper.error || wrapper.status !== 200) throw new Error(wrapper.error || ('HTTP ' + wrapper.status))
|
|
599
|
+
const arr = JSON.parse(wrapper.body)
|
|
600
|
+
const row = Array.isArray(arr) ? arr.find((x) => x && x.base === 'USD' && x.quote === 'CNY') : null
|
|
601
|
+
const rate = Number(row && row.rate)
|
|
602
|
+
if (!(rate > 0)) throw new Error('响应中缺少 USD/CNY 汇率')
|
|
603
|
+
FX = { rate, inverse: 1 / rate, date: String(row.date || ''), queriedAt: Date.now(), source: 'Frankfurter', stale: false, error: '' }
|
|
604
|
+
} catch (e) {
|
|
605
|
+
FX = { ...FX, stale: FX.rate > 0, error: msg(e), queriedAt: Date.now() }
|
|
606
|
+
}
|
|
607
|
+
return FX
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
async function resolveCredential(credentials, candidates) {
|
|
611
|
+
const seen = new Set()
|
|
612
|
+
for (const candidate of candidates) {
|
|
613
|
+
const name = typeof candidate === 'string' ? candidate : candidate.name
|
|
614
|
+
if (!name || seen.has(name)) continue
|
|
615
|
+
seen.add(name)
|
|
616
|
+
try {
|
|
617
|
+
const hit = await credentials.resolve(name)
|
|
618
|
+
if (hit && hit.value) {
|
|
619
|
+
return {
|
|
620
|
+
name,
|
|
621
|
+
value: hit.value,
|
|
622
|
+
source: String(hit.source || ''),
|
|
623
|
+
route: typeof candidate === 'string' ? '' : String(candidate.route || '')
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
} catch (e) {}
|
|
627
|
+
}
|
|
628
|
+
return null
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
async function configuredModelProvider(provider) {
|
|
632
|
+
if (!provider || provider.queryMode !== 'direct') return null
|
|
633
|
+
try {
|
|
634
|
+
const settings = ctx.get('settings')
|
|
635
|
+
if (!settings || typeof settings.get !== 'function') return null
|
|
636
|
+
const section = await settings.get('llm-pi-ai')
|
|
637
|
+
const profiles = section && section.providers
|
|
638
|
+
if (!profiles || typeof profiles !== 'object') return null
|
|
639
|
+
for (const route of Object.keys(profiles)) {
|
|
640
|
+
const profile = profiles[route]
|
|
641
|
+
if (!profile || typeof profile !== 'object') continue
|
|
642
|
+
if (!matchesModelProvider(provider.id, route, profile.displayName)) continue
|
|
643
|
+
return {
|
|
644
|
+
route,
|
|
645
|
+
apiKeyEnv: typeof profile.apiKeyEnv === 'string' ? profile.apiKeyEnv.trim() : '',
|
|
646
|
+
baseURL: typeof profile.baseURL === 'string' ? profile.baseURL.trim() : ''
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
} catch (e) {}
|
|
650
|
+
return null
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function balanceFailure(provider, error, fields) {
|
|
654
|
+
return {
|
|
655
|
+
ok: false,
|
|
656
|
+
provider: provider.id,
|
|
657
|
+
providerName: provider.name,
|
|
658
|
+
error,
|
|
659
|
+
credentialHelpUrl: provider.credentialHelpUrl || '',
|
|
660
|
+
...(fields || {})
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
const DIGITALOCEAN_CREDENTIAL = 'DIGITALOCEAN_TOKEN'
|
|
665
|
+
|
|
666
|
+
async function credentialDescription(credentials, name) {
|
|
667
|
+
if (credentials && typeof credentials.describe === 'function') {
|
|
668
|
+
try {
|
|
669
|
+
const info = await credentials.describe(name)
|
|
670
|
+
return {
|
|
671
|
+
configured: !!(info && info.configured),
|
|
672
|
+
source: String((info && info.source) || ''),
|
|
673
|
+
writable: !!(info && info.writable)
|
|
674
|
+
}
|
|
675
|
+
} catch (e) {}
|
|
676
|
+
}
|
|
677
|
+
const hit = await resolveCredential(credentials, [{ name, route: '' }])
|
|
678
|
+
return {
|
|
679
|
+
configured: !!hit,
|
|
680
|
+
source: hit ? hit.source : '',
|
|
681
|
+
writable: !!(credentials && typeof credentials.set === 'function')
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
async function balanceCredentialStatus(providerId) {
|
|
686
|
+
const provider = getBalanceProvider(providerId)
|
|
687
|
+
if (!provider) return fail('不支持的余额服务商:' + String(providerId || ''))
|
|
688
|
+
const credentials = ctx.get('credentials')
|
|
689
|
+
if (!credentials) return balanceFailure(provider, '凭据服务不可用', { errorCode: 'credentials-unavailable' })
|
|
690
|
+
if (provider.id === 'siliconflow') {
|
|
691
|
+
const profile = await configuredModelProvider(provider)
|
|
692
|
+
if (!profile) {
|
|
693
|
+
return balanceFailure(provider, '未在“设置 → 模型”中找到 Provider ID 或显示名为 siliconflow 的模型提供商。', { errorCode: 'model-provider-missing' })
|
|
694
|
+
}
|
|
695
|
+
if (!profile.apiKeyEnv) {
|
|
696
|
+
return balanceFailure(provider, '模型提供商 ' + profile.route + ' 没有配置 apiKeyEnv;请编辑该模型提供商并保存 API Key。', { errorCode: 'model-credential-ref-missing', modelProviderRoute: profile.route })
|
|
697
|
+
}
|
|
698
|
+
const info = await credentialDescription(credentials, profile.apiKeyEnv)
|
|
699
|
+
return {
|
|
700
|
+
ok: true,
|
|
701
|
+
provider: provider.id,
|
|
702
|
+
configured: info.configured,
|
|
703
|
+
source: info.source,
|
|
704
|
+
writable: info.writable,
|
|
705
|
+
masked: info.configured ? '••••••••••••' : '',
|
|
706
|
+
credentialName: profile.apiKeyEnv,
|
|
707
|
+
modelProviderRoute: profile.route
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
if (provider.id === 'digitalocean') {
|
|
711
|
+
let credentialName = DIGITALOCEAN_CREDENTIAL
|
|
712
|
+
let info = await credentialDescription(credentials, credentialName)
|
|
713
|
+
if (!info.configured) {
|
|
714
|
+
for (const candidate of provider.credentialNames) {
|
|
715
|
+
if (candidate === DIGITALOCEAN_CREDENTIAL) continue
|
|
716
|
+
const candidateInfo = await credentialDescription(credentials, candidate)
|
|
717
|
+
if (!candidateInfo.configured) continue
|
|
718
|
+
credentialName = candidate
|
|
719
|
+
info = candidateInfo
|
|
720
|
+
break
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
return {
|
|
724
|
+
ok: true,
|
|
725
|
+
provider: provider.id,
|
|
726
|
+
configured: info.configured,
|
|
727
|
+
source: info.source,
|
|
728
|
+
writable: info.writable,
|
|
729
|
+
masked: info.configured ? '••••••••••••' : '',
|
|
730
|
+
credentialName
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
return balanceFailure(provider, '该服务商不支持在余额页管理凭据', { errorCode: 'credential-management-unsupported' })
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
async function saveBalanceCredential(providerId, rawValue) {
|
|
737
|
+
const provider = getBalanceProvider(providerId)
|
|
738
|
+
if (!provider || provider.id !== 'digitalocean') return fail('仅支持在余额页保存 DigitalOcean 账户 Token')
|
|
739
|
+
const value = String(rawValue || '').trim()
|
|
740
|
+
if (!/^dop_v1_[A-Za-z0-9_-]{20,}$/.test(value)) {
|
|
741
|
+
return balanceFailure(provider, 'Token 格式不正确:请输入 DigitalOcean 控制台创建的 dop_v1_ Personal Access Token,不要使用 DO AI 推理 Key。', { errorCode: 'invalid-credential-format' })
|
|
742
|
+
}
|
|
743
|
+
const credentials = ctx.get('credentials')
|
|
744
|
+
if (!credentials || typeof credentials.set !== 'function') {
|
|
745
|
+
return balanceFailure(provider, '当前 Harness 凭据服务不支持安全保存 Token', { errorCode: 'credentials-read-only' })
|
|
746
|
+
}
|
|
747
|
+
const info = await credentialDescription(credentials, DIGITALOCEAN_CREDENTIAL)
|
|
748
|
+
if (info.configured && !info.writable) {
|
|
749
|
+
return balanceFailure(provider, 'DIGITALOCEAN_TOKEN 当前由只读来源 ' + (info.source || '环境变量') + ' 提供,不能在页面覆盖;请修改该来源后重启。', { errorCode: 'credential-read-only', credentialSource: info.source })
|
|
750
|
+
}
|
|
751
|
+
try {
|
|
752
|
+
await credentials.set(DIGITALOCEAN_CREDENTIAL, value)
|
|
753
|
+
} catch (e) {
|
|
754
|
+
return balanceFailure(provider, '保存 Token 失败:' + msg(e), { errorCode: 'credential-save-failed' })
|
|
755
|
+
}
|
|
756
|
+
const saved = await credentialDescription(credentials, DIGITALOCEAN_CREDENTIAL)
|
|
757
|
+
if (!saved.configured) return balanceFailure(provider, 'Token 保存后未能从凭据服务中重新读取', { errorCode: 'credential-save-unverified' })
|
|
758
|
+
return {
|
|
759
|
+
ok: true,
|
|
760
|
+
provider: provider.id,
|
|
761
|
+
configured: true,
|
|
762
|
+
source: saved.source,
|
|
763
|
+
writable: saved.writable,
|
|
764
|
+
masked: '••••••••••••',
|
|
765
|
+
credentialName: DIGITALOCEAN_CREDENTIAL
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
async function queryBalance(providerId) {
|
|
770
|
+
const provider = getBalanceProvider(providerId)
|
|
771
|
+
if (!provider) return fail('不支持的余额服务商:' + String(providerId || ''))
|
|
772
|
+
if (provider.queryMode === 'unsupported') {
|
|
773
|
+
return balanceFailure(provider, 'AMD GPU Cloud 当前未公开可由推理 API Key 调用的余额查询端点;请在 AMD Developer Cloud 控制台查看 credits。', { unsupported: true, errorCode: 'unsupported' })
|
|
774
|
+
}
|
|
775
|
+
const credentials = ctx.get('credentials')
|
|
776
|
+
if (!credentials) return balanceFailure(provider, '凭据服务不可用', { errorCode: 'credentials-unavailable' })
|
|
777
|
+
const modelProfile = await configuredModelProvider(provider)
|
|
778
|
+
const credentialCandidates = []
|
|
779
|
+
if (provider.id === 'siliconflow' && !modelProfile) {
|
|
780
|
+
return balanceFailure(provider, '未在“设置 → 模型”中找到 Provider ID 或显示名为 siliconflow 的模型提供商。请先添加该提供商、填写 API Key 并保存,然后返回此页查询。', { errorCode: 'model-provider-missing' })
|
|
781
|
+
}
|
|
782
|
+
if (provider.id === 'siliconflow' && modelProfile && !modelProfile.apiKeyEnv) {
|
|
783
|
+
return balanceFailure(provider, '模型提供商 ' + modelProfile.route + ' 没有配置 API Key。请在“设置 → 模型”中编辑该提供商并保存 API Key。', { errorCode: 'model-credential-ref-missing', modelProviderRoute: modelProfile.route })
|
|
784
|
+
}
|
|
785
|
+
if (modelProfile && modelProfile.apiKeyEnv) credentialCandidates.push({ name: modelProfile.apiKeyEnv, route: modelProfile.route })
|
|
786
|
+
if (provider.id !== 'siliconflow') {
|
|
787
|
+
for (const name of provider.credentialNames) credentialCandidates.push({ name, route: '' })
|
|
788
|
+
}
|
|
789
|
+
const hit = await resolveCredential(credentials, credentialCandidates)
|
|
790
|
+
if (!hit) {
|
|
791
|
+
const message = provider.id === 'digitalocean'
|
|
792
|
+
? '尚未保存 DigitalOcean 账户 Personal Access Token。请在此页面输入 dop_v1_ Token,保存后查询。'
|
|
793
|
+
: provider.id === 'siliconflow'
|
|
794
|
+
? '模型提供商 ' + modelProfile.route + ' 引用了 ' + modelProfile.apiKeyEnv + ',但该凭据未配置。请在“设置 → 模型”中重新填写 API Key 并保存。'
|
|
795
|
+
: '未找到 ' + provider.credentialHint + ',请配置后重试'
|
|
796
|
+
return balanceFailure(provider, message, { errorCode: 'missing-credential', modelProviderRoute: modelProfile ? modelProfile.route : '' })
|
|
797
|
+
}
|
|
798
|
+
const endpoint = resolveBalanceEndpoint(provider.id, modelProfile && modelProfile.baseURL)
|
|
799
|
+
const script = [
|
|
800
|
+
'const https=require("https");',
|
|
801
|
+
'const key=process.env.BALANCE_API_KEY||"";',
|
|
802
|
+
'const url=process.env.BALANCE_API_URL||"";',
|
|
803
|
+
'const req=https.get(url,{headers:{Authorization:"Bearer "+key,Accept:"application/json","User-Agent":"dsh-usage-plugin"}},function(res){',
|
|
804
|
+
'var body="";',
|
|
805
|
+
'res.on("data",function(c){body+=c});',
|
|
806
|
+
'res.on("end",function(){process.stdout.write(JSON.stringify({statusCode:res.statusCode,contentType:String(res.headers["content-type"]||""),body:body}))});',
|
|
807
|
+
'});',
|
|
808
|
+
'req.on("error",function(e){process.stdout.write(JSON.stringify({error:String(e&&e.message||e)}))});',
|
|
809
|
+
'req.setTimeout(20000,function(){req.destroy(new Error("timeout"))});'
|
|
810
|
+
].join('\n')
|
|
811
|
+
const r = await spawnNode(script, null, { BALANCE_API_KEY: hit.value, BALANCE_API_URL: endpoint })
|
|
812
|
+
if (!r.ok) return balanceFailure(provider, r.error, { errorCode: 'request-failed' })
|
|
813
|
+
let parsed
|
|
814
|
+
try { parsed = JSON.parse(r.out) } catch (e) { return balanceFailure(provider, '无法解析 node 输出', { errorCode: 'invalid-response' }) }
|
|
815
|
+
if (parsed.error) return balanceFailure(provider, parsed.error, { errorCode: 'request-failed' })
|
|
816
|
+
if (parsed.statusCode !== 200) {
|
|
817
|
+
const authHint = parsed.statusCode === 401 || parsed.statusCode === 403 ? ' 请检查凭据是否属于该账户、是否有效及是否具备余额/账单读取权限。' : ''
|
|
818
|
+
return balanceFailure(provider, '接口返回 HTTP ' + parsed.statusCode + ':' + String(parsed.body || '').slice(0, 300) + authHint, {
|
|
819
|
+
errorCode: parsed.statusCode === 401 || parsed.statusCode === 403 ? 'unauthorized' : 'http-error',
|
|
820
|
+
statusCode: parsed.statusCode,
|
|
821
|
+
credentialName: hit.name,
|
|
822
|
+
credentialSource: hit.source,
|
|
823
|
+
modelProviderRoute: hit.route || (modelProfile ? modelProfile.route : '')
|
|
824
|
+
})
|
|
825
|
+
}
|
|
826
|
+
if (!String(parsed.contentType || '').toLowerCase().includes('application/json')) {
|
|
827
|
+
return balanceFailure(provider, '接口返回了非 JSON 内容(Content-Type: ' + String(parsed.contentType || '未知') + '),请求可能被网络代理拦截。', { errorCode: 'invalid-content-type', statusCode: parsed.statusCode })
|
|
828
|
+
}
|
|
829
|
+
const normalized = parseBalanceResponse(provider.id, parsed.body)
|
|
830
|
+
if (normalized.ok) {
|
|
831
|
+
normalized.credentialName = hit.name
|
|
832
|
+
normalized.credentialSource = hit.source
|
|
833
|
+
normalized.modelProviderRoute = hit.route || (modelProfile ? modelProfile.route : '')
|
|
834
|
+
normalized.endpoint = endpoint
|
|
835
|
+
} else {
|
|
836
|
+
normalized.provider = provider.id
|
|
837
|
+
normalized.providerName = provider.name
|
|
838
|
+
normalized.credentialHelpUrl = provider.credentialHelpUrl || ''
|
|
839
|
+
}
|
|
840
|
+
return normalized
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// ── export helpers ─────────────────────────────────────────────────────
|
|
844
|
+
function csvCell(s) {
|
|
845
|
+
s = String(s == null ? '' : s)
|
|
846
|
+
if (/[",\n\r]/.test(s)) return '"' + s.replace(/"/g, '""') + '"'
|
|
847
|
+
return s
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
function buildCsv() {
|
|
851
|
+
const header = ['time', 'model', 'provider', 'inputTokens', 'cacheReadTokens', 'cacheWriteTokens', 'outputTokens', 'reasoningTokens', 'finishReason', 'period', 'baseCost', 'peakValleyCost', 'autoCost']
|
|
852
|
+
const lines = [header.join(',')]
|
|
853
|
+
for (const r of records) {
|
|
854
|
+
lines.push([
|
|
855
|
+
r.time, r.model, r.provider, r.inputTokens, r.cacheReadTokens, r.cacheWriteTokens,
|
|
856
|
+
r.outputTokens, r.reasoningTokens, r.finishReason,
|
|
857
|
+
isPeak(r.time) ? 'peak' : 'offPeak', costFor(r, 'base'), costFor(r, 'peakValley'), costFor(r, 'auto')
|
|
858
|
+
].map(csvCell).join(','))
|
|
859
|
+
}
|
|
860
|
+
return lines.join('\r\n')
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
async function writePngFile(base64, outPath) {
|
|
864
|
+
const script = [
|
|
865
|
+
'const fs=require("fs");',
|
|
866
|
+
'let d="";',
|
|
867
|
+
'process.stdin.on("data",function(c){d+=c});',
|
|
868
|
+
'process.stdin.on("end",function(){',
|
|
869
|
+
' const buf=Buffer.from(d,"base64");',
|
|
870
|
+
' fs.mkdirSync(require("path").dirname(process.env.PNG_PATH),{recursive:true});',
|
|
871
|
+
' fs.writeFileSync(process.env.PNG_PATH,buf);',
|
|
872
|
+
' process.stdout.write(JSON.stringify({ok:true,bytes:buf.length}));',
|
|
873
|
+
'});'
|
|
874
|
+
].join('\n')
|
|
875
|
+
return spawnNode(script, base64, { PNG_PATH: outPath })
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
async function writeTextFileViaNode(content, outPath) {
|
|
879
|
+
const script = [
|
|
880
|
+
'const fs=require("fs");',
|
|
881
|
+
'let d="";',
|
|
882
|
+
'process.stdin.on("data",function(c){d+=c});',
|
|
883
|
+
'process.stdin.on("end",function(){',
|
|
884
|
+
' fs.mkdirSync(require("path").dirname(process.env.OUT_PATH),{recursive:true});',
|
|
885
|
+
' fs.writeFileSync(process.env.OUT_PATH, Buffer.from(d,"utf8"));',
|
|
886
|
+
' process.stdout.write(JSON.stringify({ok:true}));',
|
|
887
|
+
'});'
|
|
888
|
+
].join('\n')
|
|
889
|
+
return spawnNode(script, content, { OUT_PATH: outPath })
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
async function mkdirViaNode(dir) {
|
|
893
|
+
const script = [
|
|
894
|
+
'const fs=require("fs");',
|
|
895
|
+
'fs.mkdirSync(process.env.MKDIR_PATH,{recursive:true});',
|
|
896
|
+
'process.stdout.write(JSON.stringify({ok:true}));'
|
|
897
|
+
].join('\n')
|
|
898
|
+
return spawnNode(script, null, { MKDIR_PATH: dir })
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
async function pickDirectory() {
|
|
902
|
+
const subprocess = ctx.get('subprocess')
|
|
903
|
+
if (!subprocess) return fail('命令执行服务不可用')
|
|
904
|
+
if (IS_MAC) {
|
|
905
|
+
let exe = null
|
|
906
|
+
try { exe = await subprocess.resolveExecutable('osascript') } catch (e) {}
|
|
907
|
+
if (!exe) return fail('未找到 osascript(macOS 需安装命令行工具 Command Line Tools)')
|
|
908
|
+
const r = await runCollect([exe, '-e', 'POSIX path of (choose folder)'], { graceMs: 120000 })
|
|
909
|
+
if (!r.ok && r.error) return fail(r.error)
|
|
910
|
+
const picked = normPath(r.out.trim())
|
|
911
|
+
if (!picked) return { ok: false, cancelled: true }
|
|
912
|
+
return { ok: true, path: picked }
|
|
913
|
+
}
|
|
914
|
+
if (!IS_WIN) {
|
|
915
|
+
for (const c of ['zenity', 'kdialog']) {
|
|
916
|
+
let exe = null
|
|
917
|
+
try { exe = await subprocess.resolveExecutable(c) } catch (e) {}
|
|
918
|
+
if (!exe) continue
|
|
919
|
+
const argv = c === 'zenity'
|
|
920
|
+
? [exe, '--file-selection', '--directory', '--title=选择导出目录']
|
|
921
|
+
: [exe, '--getexistingdirectory', '选择导出目录']
|
|
922
|
+
const r = await runCollect(argv, { graceMs: 120000 })
|
|
923
|
+
if (!r.ok && r.error) return fail(r.error)
|
|
924
|
+
const picked = normPath(r.out.trim())
|
|
925
|
+
if (!picked) return { ok: false, cancelled: true }
|
|
926
|
+
return { ok: true, path: picked }
|
|
927
|
+
}
|
|
928
|
+
return fail('未找到目录选择工具(请安装 zenity 或 kdialog)')
|
|
929
|
+
}
|
|
930
|
+
let exe = null
|
|
931
|
+
for (const c of ['powershell.exe', 'pwsh.exe', 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe']) {
|
|
932
|
+
try { exe = await subprocess.resolveExecutable(c); if (exe) break } catch (e) {}
|
|
933
|
+
}
|
|
934
|
+
if (!exe) return fail('未找到 PowerShell')
|
|
935
|
+
const script = 'Add-Type -AssemblyName System.Windows.Forms; $f = New-Object System.Windows.Forms.FolderBrowserDialog; $f.Description = "选择导出目录"; if ($f.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { [Console]::Out.Write($f.SelectedPath) }'
|
|
936
|
+
const r = await runCollect([exe, '-NoProfile', '-STA', '-NonInteractive', '-Command', script], { graceMs: 120000 })
|
|
937
|
+
if (!r.ok && r.error) return fail(r.error)
|
|
938
|
+
const picked = normPath(r.out.trim())
|
|
939
|
+
if (!picked) return { ok: false, cancelled: true }
|
|
940
|
+
return { ok: true, path: picked }
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
async function revealDir(dirArg) {
|
|
944
|
+
const subprocess = ctx.get('subprocess')
|
|
945
|
+
if (!subprocess) return fail('命令执行服务不可用')
|
|
946
|
+
let target = ''
|
|
947
|
+
const isKey = dirArg === 'csv' || dirArg === 'json' || dirArg === 'images' || dirArg === 'data'
|
|
948
|
+
if (isKey) {
|
|
949
|
+
const d = dirs()
|
|
950
|
+
target = dirArg === 'csv' ? d.csv : dirArg === 'json' ? d.json : dirArg === 'images' ? d.images : d.data
|
|
951
|
+
target = normPath(target)
|
|
952
|
+
const policy = sessionPolicy()
|
|
953
|
+
try {
|
|
954
|
+
const t = await fs.resolve(joinPath(target, '.keep'))
|
|
955
|
+
await fs.writeText(t, '', undefined, undefined, policy || undefined)
|
|
956
|
+
} catch (e) {}
|
|
957
|
+
} else {
|
|
958
|
+
target = normPath(dirArg)
|
|
959
|
+
await mkdirViaNode(target)
|
|
960
|
+
}
|
|
961
|
+
const revealCmd = IS_WIN ? 'explorer.exe' : (IS_MAC ? 'open' : 'xdg-open')
|
|
962
|
+
let exe = null
|
|
963
|
+
try { exe = await subprocess.resolveExecutable(revealCmd) } catch (e) {}
|
|
964
|
+
if (!exe) return fail('未找到 ' + revealCmd)
|
|
965
|
+
try {
|
|
966
|
+
subprocess.spawn({ argv: [exe, target], cwd: await safeCwd(), stdio: { stdin: 'ignore', stdout: { maxBytes: 1024 }, stderr: { maxBytes: 1024 } }, graceMs: 5000 })
|
|
967
|
+
return { ok: true }
|
|
968
|
+
} catch (e) { return fail(msg(e)) }
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
// ── API ────────────────────────────────────────────────────────────────
|
|
972
|
+
async function routeApi(body) {
|
|
973
|
+
const action = body && body.action ? String(body.action) : ''
|
|
974
|
+
try { await ensureInit() } catch (e) {}
|
|
975
|
+
switch (action) {
|
|
976
|
+
case 'list': {
|
|
977
|
+
try { await refreshFxRate(false) } catch (e) {}
|
|
978
|
+
const items = records.map((r) => ({
|
|
979
|
+
time: r.time, model: r.model, provider: r.provider, purpose: r.purpose,
|
|
980
|
+
inputTokens: r.inputTokens, outputTokens: r.outputTokens,
|
|
981
|
+
cacheReadTokens: r.cacheReadTokens, cacheWriteTokens: r.cacheWriteTokens,
|
|
982
|
+
reasoningTokens: r.reasoningTokens, finishReason: r.finishReason,
|
|
983
|
+
usdCnyRate: r.usdCnyRate || 0, fxDate: r.fxDate || '',
|
|
984
|
+
modelKey: modelKey(r.model),
|
|
985
|
+
baseCost: costFor(r, 'base'), peakValleyCost: costFor(r, 'peakValley'), autoCost: costFor(r, 'auto'),
|
|
986
|
+
peak: isPeak(r.time)
|
|
987
|
+
}))
|
|
988
|
+
return { ok: true, records: items, count: items.length, dataPath, persistOk, persistError, pricing: PRICING, effectiveAt: EFFECTIVE_AT, days: buildDays(), fx: FX }
|
|
989
|
+
}
|
|
990
|
+
case 'clear': {
|
|
991
|
+
const n = records.length
|
|
992
|
+
records.length = 0
|
|
993
|
+
persistNow()
|
|
994
|
+
return { ok: true, cleared: n }
|
|
995
|
+
}
|
|
996
|
+
case 'setPrices': {
|
|
997
|
+
const prices = body && body.prices
|
|
998
|
+
if (!prices || typeof prices !== 'object') return fail('缺少价格数据')
|
|
999
|
+
let changed = false
|
|
1000
|
+
for (const regime of ['base', 'peakValley']) {
|
|
1001
|
+
const src = prices[regime]
|
|
1002
|
+
const dst = PRICING[regime]
|
|
1003
|
+
if (!src || typeof src !== 'object' || !dst) continue
|
|
1004
|
+
for (const mk of PRICE_MODELS) {
|
|
1005
|
+
const row = src[mk]
|
|
1006
|
+
if (!row || typeof row !== 'object' || !dst[mk]) continue
|
|
1007
|
+
for (const k of ['cacheHit', 'cacheMiss', 'output']) {
|
|
1008
|
+
const v = Number(row[k])
|
|
1009
|
+
if (Number.isFinite(v) && v >= 0) { dst[mk][k] = v; changed = true }
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
if (!changed) return fail('没有可用的价格更新(价格必须是非负数字)')
|
|
1014
|
+
persistPricing()
|
|
1015
|
+
return { ok: true }
|
|
1016
|
+
}
|
|
1017
|
+
case 'resetPrices': {
|
|
1018
|
+
for (const regime of ['base', 'peakValley']) {
|
|
1019
|
+
const src = DEFAULT_PRICING[regime]
|
|
1020
|
+
const dst = PRICING[regime]
|
|
1021
|
+
if (!src || !dst) continue
|
|
1022
|
+
for (const mk of PRICE_MODELS) {
|
|
1023
|
+
if (!src[mk] || !dst[mk]) continue
|
|
1024
|
+
dst[mk].cacheHit = src[mk].cacheHit
|
|
1025
|
+
dst[mk].cacheMiss = src[mk].cacheMiss
|
|
1026
|
+
dst[mk].output = src[mk].output
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
persistPricing()
|
|
1030
|
+
return { ok: true }
|
|
1031
|
+
}
|
|
1032
|
+
case 'fxRefresh': {
|
|
1033
|
+
const fx = await refreshFxRate(true)
|
|
1034
|
+
return { ok: fx.rate > 0, fx, error: fx.rate > 0 ? '' : (fx.error || '无法获取汇率') }
|
|
1035
|
+
}
|
|
1036
|
+
case 'balance':
|
|
1037
|
+
return queryBalance(body && body.provider)
|
|
1038
|
+
case 'balanceProviders':
|
|
1039
|
+
return { ok: true, providers: providerList() }
|
|
1040
|
+
case 'balanceCredentialStatus':
|
|
1041
|
+
return balanceCredentialStatus(body && body.provider)
|
|
1042
|
+
case 'saveBalanceCredential':
|
|
1043
|
+
return saveBalanceCredential(body && body.provider, body && body.value)
|
|
1044
|
+
case 'pickDir':
|
|
1045
|
+
return pickDirectory()
|
|
1046
|
+
case 'export': {
|
|
1047
|
+
if (!root) return fail('未找到工作区路径')
|
|
1048
|
+
const kind = (body && body.kind) === 'json' ? 'json' : 'csv'
|
|
1049
|
+
const name = 'dsh-usage-' + stamp() + (kind === 'json' ? '.json' : '.csv')
|
|
1050
|
+
const content = kind === 'json'
|
|
1051
|
+
? JSON.stringify({ exportedAt: Date.now(), pricing: PRICING, records }, null, 2)
|
|
1052
|
+
: buildCsv()
|
|
1053
|
+
const dirArg = body && body.dir ? normPath(String(body.dir)) : ''
|
|
1054
|
+
if (dirArg) {
|
|
1055
|
+
const outPath = joinPath(dirArg, name)
|
|
1056
|
+
const r = await writeTextFileViaNode(content, outPath)
|
|
1057
|
+
if (!r.ok) return fail(r.error)
|
|
1058
|
+
return { ok: true, path: outPath, name, dir: dirArg }
|
|
1059
|
+
}
|
|
1060
|
+
const outPath = joinPath(kind === 'json' ? dirs().json : dirs().csv, name)
|
|
1061
|
+
try {
|
|
1062
|
+
const target = await fs.resolve(outPath)
|
|
1063
|
+
await fs.writeText(target, content, undefined, undefined, sessionPolicy() || undefined)
|
|
1064
|
+
return { ok: true, path: normPath(fs.processPath ? fs.processPath(target) : outPath), name, dir: kind === 'json' ? 'json' : 'csv' }
|
|
1065
|
+
} catch (e) { return fail(msg(e)) }
|
|
1066
|
+
}
|
|
1067
|
+
case 'exportPng': {
|
|
1068
|
+
const dataUrl = body && body.dataUrl ? String(body.dataUrl) : ''
|
|
1069
|
+
if (!dataUrl) return fail('缺少图片数据')
|
|
1070
|
+
const idx = dataUrl.indexOf('base64,')
|
|
1071
|
+
const b64 = idx >= 0 ? dataUrl.slice(idx + 7) : dataUrl
|
|
1072
|
+
if (!root) return fail('未找到工作区路径')
|
|
1073
|
+
const name = 'dsh-usage-report-' + stamp() + '.png'
|
|
1074
|
+
const dirArg = body && body.dir ? normPath(String(body.dir)) : ''
|
|
1075
|
+
const outPath = normPath(joinPath(dirArg || dirs().images, name))
|
|
1076
|
+
const r = await writePngFile(b64, outPath)
|
|
1077
|
+
if (!r.ok) return fail(r.error)
|
|
1078
|
+
return { ok: true, path: outPath, name, dir: dirArg || 'images' }
|
|
1079
|
+
}
|
|
1080
|
+
case 'import': {
|
|
1081
|
+
const content = body && body.content != null ? String(body.content) : ''
|
|
1082
|
+
const filename = body && body.filename ? String(body.filename) : ''
|
|
1083
|
+
if (!content) return fail('请选择要导入的文件')
|
|
1084
|
+
let parsed
|
|
1085
|
+
if (String(filename || '').toLowerCase().indexOf('.csv') >= 0) {
|
|
1086
|
+
const lines = String(content).split(/\r?\n/).filter((l) => l.trim().length > 0)
|
|
1087
|
+
const header = lines[0] ? parseCsvLine(lines[0]) : []
|
|
1088
|
+
const idx = {}
|
|
1089
|
+
header.forEach((h, i) => { idx[String(h).trim()] = i })
|
|
1090
|
+
parsed = lines.slice(1).map((line) => {
|
|
1091
|
+
const cells = parseCsvLine(line)
|
|
1092
|
+
const get = (name) => (idx[name] === undefined ? '' : (cells[idx[name]] === undefined ? '' : cells[idx[name]]))
|
|
1093
|
+
return {
|
|
1094
|
+
time: get('time'), model: get('model'), provider: get('provider'),
|
|
1095
|
+
inputTokens: get('inputTokens'), outputTokens: get('outputTokens'),
|
|
1096
|
+
cacheReadTokens: get('cacheReadTokens'), cacheWriteTokens: get('cacheWriteTokens'),
|
|
1097
|
+
reasoningTokens: get('reasoningTokens'), finishReason: get('finishReason')
|
|
1098
|
+
}
|
|
1099
|
+
})
|
|
1100
|
+
} else {
|
|
1101
|
+
try {
|
|
1102
|
+
const data = JSON.parse(content)
|
|
1103
|
+
parsed = Array.isArray(data) ? data : (data && Array.isArray(data.records) ? data.records : null)
|
|
1104
|
+
} catch (e) { parsed = null }
|
|
1105
|
+
}
|
|
1106
|
+
if (!parsed || !Array.isArray(parsed)) return fail('文件内容不是可识别的用量数据(支持 JSON 或 CSV)')
|
|
1107
|
+
let imported = 0, skipped = 0, invalid = 0
|
|
1108
|
+
const existing = {}
|
|
1109
|
+
for (const r of records) existing[r.time] = true
|
|
1110
|
+
for (const raw of parsed) {
|
|
1111
|
+
const rec = normalizeRecord(raw)
|
|
1112
|
+
if (!rec) { invalid++; continue }
|
|
1113
|
+
if (existing[rec.time]) { skipped++; continue }
|
|
1114
|
+
existing[rec.time] = true
|
|
1115
|
+
records.push(rec)
|
|
1116
|
+
imported++
|
|
1117
|
+
}
|
|
1118
|
+
if (records.length > MAX_RECORDS) records.splice(0, records.length - MAX_RECORDS)
|
|
1119
|
+
records.sort((a, b) => a.time - b.time)
|
|
1120
|
+
persistNow()
|
|
1121
|
+
return { ok: true, imported, skipped, invalid, total: records.length }
|
|
1122
|
+
}
|
|
1123
|
+
case 'reveal': {
|
|
1124
|
+
const dirArg = body && body.dir ? String(body.dir) : 'data'
|
|
1125
|
+
return revealDir(dirArg)
|
|
1126
|
+
}
|
|
1127
|
+
default:
|
|
1128
|
+
return fail('未知操作:' + action)
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
function parseCsvLine(line) {
|
|
1133
|
+
const cells = []
|
|
1134
|
+
let cur = ''
|
|
1135
|
+
let inQ = false
|
|
1136
|
+
for (let i = 0; i < line.length; i++) {
|
|
1137
|
+
const ch = line[i]
|
|
1138
|
+
if (inQ) {
|
|
1139
|
+
if (ch === '"') {
|
|
1140
|
+
if (line[i + 1] === '"') { cur += '"'; i++ } else inQ = false
|
|
1141
|
+
} else cur += ch
|
|
1142
|
+
} else if (ch === '"') inQ = true
|
|
1143
|
+
else if (ch === ',') { cells.push(cur); cur = '' }
|
|
1144
|
+
else cur += ch
|
|
1145
|
+
}
|
|
1146
|
+
cells.push(cur)
|
|
1147
|
+
return cells
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
function readBody(req) {
|
|
1151
|
+
return new Promise((resolve) => {
|
|
1152
|
+
let d = ''
|
|
1153
|
+
req.on('data', (c) => { d += c })
|
|
1154
|
+
req.on('end', () => { try { resolve(JSON.parse(d)) } catch (e) { resolve({}) } })
|
|
1155
|
+
req.on('error', () => resolve({}))
|
|
1156
|
+
})
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
function sendJson(res, obj) {
|
|
1160
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' })
|
|
1161
|
+
res.end(JSON.stringify(obj))
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
const webServer = ctx.get('webServer')
|
|
1165
|
+
push('webServer=' + (webServer ? 'present' : 'undefined'))
|
|
1166
|
+
if (webServer && typeof webServer.register === 'function') {
|
|
1167
|
+
try {
|
|
1168
|
+
webServer.register({
|
|
1169
|
+
kind: 'exact',
|
|
1170
|
+
path: '/usage/api',
|
|
1171
|
+
handler: async (req, res) => {
|
|
1172
|
+
try {
|
|
1173
|
+
const body = await readBody(req)
|
|
1174
|
+
sendJson(res, await routeApi(body))
|
|
1175
|
+
} catch (e) {
|
|
1176
|
+
sendJson(res, { ok: false, error: msg(e) })
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
})
|
|
1180
|
+
push('route-registered')
|
|
1181
|
+
} catch (e) {
|
|
1182
|
+
push('route-register-threw: ' + (e && e.stack ? e.stack : msg(e)))
|
|
1183
|
+
}
|
|
1184
|
+
} else {
|
|
1185
|
+
push('route-not-registered (no webServer)')
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
push('apply-end')
|
|
1189
|
+
diag.ok = true
|
|
1190
|
+
} catch (e) {
|
|
1191
|
+
diag.ok = false
|
|
1192
|
+
diag.error = (e && e.stack) ? e.stack : String(e)
|
|
1193
|
+
}
|
|
1194
|
+
flushDiag()
|
|
1195
|
+
}
|
|
1196
|
+
}
|