dsh-model-billing 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +61 -17
- package/README.md +247 -219
- package/client.js +2270 -1524
- package/host.js +1289 -898
- package/locale/en.json +6 -6
- package/locale/zh.json +6 -6
- package/package.json +68 -60
package/host.js
CHANGED
|
@@ -1,898 +1,1289 @@
|
|
|
1
|
-
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
|
2
|
-
import { dirname, join } from 'node:path'
|
|
3
|
-
import { homedir } from 'node:os'
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Model order, token prices and session cost estimation.
|
|
7
|
-
*
|
|
8
|
-
* The browser half is a build-free plain-JavaScript Client module, so it cannot
|
|
9
|
-
* declare a generated `ctx.remote` namespace. It talks to this half over
|
|
10
|
-
* same-origin routes on `ctx.webServer`, the same transport other community
|
|
11
|
-
* plugins use. Prices and the user's order live in a small JSON file under the
|
|
12
|
-
* Harness home; usage is always folded from the durable session log.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
export const inject = ['webServer']
|
|
16
|
-
|
|
17
|
-
const CATALOG_PATH = '/model-billing/catalog'
|
|
18
|
-
const USAGE_PATH = '/model-billing/usage'
|
|
19
|
-
const SAVE_PATH = '/model-billing/save'
|
|
20
|
-
const SELECT_PATH = '/model-billing/select'
|
|
21
|
-
|
|
22
|
-
const STATE_VERSION = 1
|
|
23
|
-
const MAX_MODELS = 500
|
|
24
|
-
const MAX_STORED_PRICES = 2000
|
|
25
|
-
const MAX_BODY_BYTES = 256 * 1024
|
|
26
|
-
const PRICE_FIELDS = ['cacheHit', 'cacheMiss', 'output']
|
|
27
|
-
const KEY_SEPARATOR = '\u0000'
|
|
28
|
-
const
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
/**
|
|
230
|
-
function
|
|
231
|
-
return {
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
function
|
|
277
|
-
return
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
return
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
return
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
/**
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
if (
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
return {
|
|
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
|
-
|
|
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
|
-
const
|
|
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
|
-
const
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
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
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
}
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
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
|
-
return
|
|
800
|
-
}
|
|
801
|
-
|
|
802
|
-
return
|
|
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
|
-
|
|
1
|
+
import { mkdir, readdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { basename, dirname, join } from 'node:path'
|
|
3
|
+
import { homedir } from 'node:os'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Model order, token prices and session cost estimation.
|
|
7
|
+
*
|
|
8
|
+
* The browser half is a build-free plain-JavaScript Client module, so it cannot
|
|
9
|
+
* declare a generated `ctx.remote` namespace. It talks to this half over
|
|
10
|
+
* same-origin routes on `ctx.webServer`, the same transport other community
|
|
11
|
+
* plugins use. Prices and the user's order live in a small JSON file under the
|
|
12
|
+
* Harness home; usage is always folded from the durable session log.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export const inject = ['webServer']
|
|
16
|
+
|
|
17
|
+
const CATALOG_PATH = '/model-billing/catalog'
|
|
18
|
+
const USAGE_PATH = '/model-billing/usage'
|
|
19
|
+
const SAVE_PATH = '/model-billing/save'
|
|
20
|
+
const SELECT_PATH = '/model-billing/select'
|
|
21
|
+
|
|
22
|
+
const STATE_VERSION = 1
|
|
23
|
+
const MAX_MODELS = 500
|
|
24
|
+
const MAX_STORED_PRICES = 2000
|
|
25
|
+
const MAX_BODY_BYTES = 256 * 1024
|
|
26
|
+
const PRICE_FIELDS = ['cacheHit', 'cacheMiss', 'output']
|
|
27
|
+
const KEY_SEPARATOR = '\u0000'
|
|
28
|
+
const DISPLAY_DEFAULTS = Object.freeze({
|
|
29
|
+
showTurnTail: true,
|
|
30
|
+
showDock: true,
|
|
31
|
+
showModels: true,
|
|
32
|
+
showProvider: false,
|
|
33
|
+
showCost: true,
|
|
34
|
+
showTokens: false,
|
|
35
|
+
showCacheHit: false,
|
|
36
|
+
showLabels: true,
|
|
37
|
+
showUnpricedNotice: true,
|
|
38
|
+
takeoverPicker: true,
|
|
39
|
+
fontSize: 'normal',
|
|
40
|
+
density: 'comfortable',
|
|
41
|
+
fontWeight: 'normal',
|
|
42
|
+
})
|
|
43
|
+
const DISPLAY_FLAGS = ['showTurnTail', 'showDock', 'showModels', 'showProvider', 'showCost', 'showTokens', 'showCacheHit', 'showLabels', 'showUnpricedNotice', 'takeoverPicker']
|
|
44
|
+
const DISPLAY_FONT_SIZES = new Set(['small', 'normal', 'large'])
|
|
45
|
+
const DISPLAY_DENSITIES = new Set(['compact', 'comfortable'])
|
|
46
|
+
const DISPLAY_FONT_WEIGHTS = new Set(['normal', 'bold'])
|
|
47
|
+
const DEEPSEEK_PRICING_SCHEDULE = '北京时间周一至周五 9:00–12:00、14:00–18:00(法定节假日除外)'
|
|
48
|
+
const DEEPSEEK_PRICES = {
|
|
49
|
+
'deepseek-flash': {
|
|
50
|
+
peak: { cacheHit: 0.04, cacheMiss: 2, output: 8 },
|
|
51
|
+
offPeak: { cacheHit: 0.02, cacheMiss: 1, output: 4 },
|
|
52
|
+
},
|
|
53
|
+
'deepseek-v4-flash': {
|
|
54
|
+
peak: { cacheHit: 0.04, cacheMiss: 2, output: 8 },
|
|
55
|
+
offPeak: { cacheHit: 0.02, cacheMiss: 1, output: 4 },
|
|
56
|
+
},
|
|
57
|
+
'deepseek-v4-flash-vision-exp': {
|
|
58
|
+
peak: { cacheHit: 0.04, cacheMiss: 2, output: 8 },
|
|
59
|
+
offPeak: { cacheHit: 0.02, cacheMiss: 1, output: 4 },
|
|
60
|
+
},
|
|
61
|
+
'deepseek-v4-pro': {
|
|
62
|
+
peak: { cacheHit: 0.3, cacheMiss: 9, output: 27 },
|
|
63
|
+
offPeak: { cacheHit: 0.15, cacheMiss: 4.5, output: 13.5 },
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Chinese statutory holidays (2026). Weekends are always off-peak; these dates
|
|
68
|
+
// are the extra weekday closures that must not be billed at peak rates.
|
|
69
|
+
const DEEPSEEK_HOLIDAYS = new Set([
|
|
70
|
+
'2026-01-01', '2026-01-02', '2026-01-03',
|
|
71
|
+
'2026-02-15', '2026-02-16', '2026-02-17', '2026-02-18', '2026-02-19',
|
|
72
|
+
'2026-02-20', '2026-02-21', '2026-02-22', '2026-02-23',
|
|
73
|
+
'2026-04-04', '2026-04-05', '2026-04-06',
|
|
74
|
+
'2026-05-01', '2026-05-02', '2026-05-03', '2026-05-04', '2026-05-05',
|
|
75
|
+
'2026-06-19', '2026-06-20', '2026-06-21',
|
|
76
|
+
'2026-09-25', '2026-09-26', '2026-09-27',
|
|
77
|
+
'2026-10-01', '2026-10-02', '2026-10-03', '2026-10-04',
|
|
78
|
+
'2026-10-05', '2026-10-06', '2026-10-07',
|
|
79
|
+
])
|
|
80
|
+
const JSON_HEADERS = {
|
|
81
|
+
'content-type': 'application/json; charset=utf-8',
|
|
82
|
+
'cache-control': 'no-store',
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** A refusal that carries the HTTP status and stable code the Client reports. */
|
|
86
|
+
class BillingRefusal extends Error {
|
|
87
|
+
constructor(status, code, message) {
|
|
88
|
+
super(message)
|
|
89
|
+
this.name = 'BillingRefusal'
|
|
90
|
+
this.status = status
|
|
91
|
+
this.code = code
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The durable state path; `DSH_HOME` wins over the conventional home. */
|
|
96
|
+
function statePath() {
|
|
97
|
+
const home = typeof process.env.DSH_HOME === 'string' && process.env.DSH_HOME.trim() !== ''
|
|
98
|
+
? process.env.DSH_HOME.trim()
|
|
99
|
+
: join(homedir(), '.dsh')
|
|
100
|
+
return join(home, 'model-billing.json')
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** How long a provider's model list stays fresh. */
|
|
104
|
+
const CATALOG_TTL_MS = 60_000
|
|
105
|
+
let providerListCache = { at: 0, value: null }
|
|
106
|
+
const modelListCache = new Map()
|
|
107
|
+
const reasoningCache = new Map()
|
|
108
|
+
|
|
109
|
+
/** Drop every catalog cache so the next lookup hits the adapters again. */
|
|
110
|
+
function invalidateCatalogCache() {
|
|
111
|
+
providerListCache = { at: 0, value: null }
|
|
112
|
+
modelListCache.clear()
|
|
113
|
+
reasoningCache.clear()
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Look up the model catalog and merge the saved order/prices into it. */
|
|
117
|
+
async function buildCatalog(ctx, options = {}) {
|
|
118
|
+
const state = await loadState(ctx)
|
|
119
|
+
const llm = ctx.get('llm')
|
|
120
|
+
if (llm === undefined) {
|
|
121
|
+
return {
|
|
122
|
+
ok: true,
|
|
123
|
+
models: [],
|
|
124
|
+
failures: [{ provider: 'llm', message: 'the llm service is not mounted' }],
|
|
125
|
+
pricingArchive: pricingArchive(state),
|
|
126
|
+
...stateEnvelope(state),
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const refresh = options?.refresh === true
|
|
131
|
+
const now = Date.now()
|
|
132
|
+
|
|
133
|
+
let providers
|
|
134
|
+
if (!refresh && providerListCache.value !== null && now - providerListCache.at < CATALOG_TTL_MS) {
|
|
135
|
+
providers = providerListCache.value
|
|
136
|
+
} else {
|
|
137
|
+
const listed = typeof llm.listProviders === 'function' ? llm.listProviders() : []
|
|
138
|
+
providers = Array.isArray(listed) ? listed : []
|
|
139
|
+
providerListCache = { at: now, value: providers }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const settled = await Promise.allSettled(providers.map(async (provider) => {
|
|
143
|
+
let cached = refresh ? undefined : modelListCache.get(provider.id)
|
|
144
|
+
if (cached === undefined || Date.now() - cached.at >= CATALOG_TTL_MS) {
|
|
145
|
+
const models = await llm.listModels(provider.id)
|
|
146
|
+
if (!Array.isArray(models)) throw new Error('the provider returned no model list')
|
|
147
|
+
cached = { at: Date.now(), models }
|
|
148
|
+
modelListCache.set(provider.id, cached)
|
|
149
|
+
}
|
|
150
|
+
const entries = cached.models.filter((model) => typeof model?.id === 'string' && model.id !== '')
|
|
151
|
+
return Promise.all(entries.map(async (model) => {
|
|
152
|
+
let reasoning
|
|
153
|
+
if (typeof llm.resolveModelInfo === 'function') {
|
|
154
|
+
const cacheKey = `${provider.id}\u0000${model.id}`
|
|
155
|
+
if (!refresh && reasoningCache.has(cacheKey)) {
|
|
156
|
+
reasoning = reasoningCache.get(cacheKey)
|
|
157
|
+
} else {
|
|
158
|
+
try {
|
|
159
|
+
const resolved = await llm.resolveModelInfo(provider.id, model.id)
|
|
160
|
+
reasoning = publicReasoning(resolved?.reasoning)
|
|
161
|
+
} catch {
|
|
162
|
+
// Reasoning metadata is optional; an unavailable adapter must not hide the model.
|
|
163
|
+
reasoning = undefined
|
|
164
|
+
}
|
|
165
|
+
// Memoized until the next save; a resolution failure is cached too, so a
|
|
166
|
+
// flapping adapter is not retried on every single catalog request.
|
|
167
|
+
reasoningCache.set(cacheKey, reasoning)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return {
|
|
171
|
+
provider: provider.id,
|
|
172
|
+
providerName: provider.name || provider.id,
|
|
173
|
+
model: model.id,
|
|
174
|
+
name: model.name || model.id,
|
|
175
|
+
isDeepSeek: isDeepSeekRoute(provider.id, model.id),
|
|
176
|
+
...reasoning === undefined ? {} : { reasoning },
|
|
177
|
+
}
|
|
178
|
+
}))
|
|
179
|
+
}))
|
|
180
|
+
|
|
181
|
+
const rows = []
|
|
182
|
+
const failures = []
|
|
183
|
+
const seen = new Set()
|
|
184
|
+
settled.forEach((result, index) => {
|
|
185
|
+
const provider = providers[index]
|
|
186
|
+
if (result.status === 'rejected') {
|
|
187
|
+
failures.push({
|
|
188
|
+
provider: provider?.id ?? 'unknown',
|
|
189
|
+
providerName: provider?.name ?? provider?.id ?? 'unknown',
|
|
190
|
+
message: messageOf(result.reason),
|
|
191
|
+
})
|
|
192
|
+
return
|
|
193
|
+
}
|
|
194
|
+
for (const row of result.value) {
|
|
195
|
+
const key = modelKey(row.provider, row.model)
|
|
196
|
+
if (seen.has(key)) continue
|
|
197
|
+
seen.add(key)
|
|
198
|
+
rows.push(row)
|
|
199
|
+
}
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
const ranked = applySavedOrder(rows, state)
|
|
203
|
+
return {
|
|
204
|
+
ok: true,
|
|
205
|
+
models: ranked.map((row, index) => ({
|
|
206
|
+
...row,
|
|
207
|
+
order: index,
|
|
208
|
+
pricing: catalogPricing(row.provider, row.model, state.pricing[modelKey(row.provider, row.model)]),
|
|
209
|
+
})),
|
|
210
|
+
failures,
|
|
211
|
+
pricingArchive: pricingArchive(state),
|
|
212
|
+
...stateEnvelope(state),
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Put saved routes first, keeping new routes in provider/catalog order behind them. */
|
|
217
|
+
function applySavedOrder(rows, state) {
|
|
218
|
+
const rank = new Map()
|
|
219
|
+
for (const entry of state.order) {
|
|
220
|
+
const key = modelKey(entry.provider, entry.model)
|
|
221
|
+
if (!rank.has(key)) rank.set(key, rank.size)
|
|
222
|
+
}
|
|
223
|
+
return rows
|
|
224
|
+
.map((row, index) => ({ row, index, rank: rank.get(modelKey(row.provider, row.model)) ?? Number.MAX_SAFE_INTEGER }))
|
|
225
|
+
.sort((left, right) => left.rank - right.rank || left.index - right.index)
|
|
226
|
+
.map((entry) => entry.row)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** The pricing/order metadata every catalog response carries. */
|
|
230
|
+
function stateEnvelope(state) {
|
|
231
|
+
return {
|
|
232
|
+
stateVersion: STATE_VERSION,
|
|
233
|
+
savedAt: state.savedAt,
|
|
234
|
+
order: state.order.map((entry) => ({ provider: entry.provider, model: entry.model })),
|
|
235
|
+
display: { ...state.display },
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Prices for routes that are no longer in the configured model list. They stay
|
|
241
|
+
* in the price book so historical calls remain priced; the settings page edits
|
|
242
|
+
* them here instead of forcing the user to re-add a retired model to the picker.
|
|
243
|
+
*/
|
|
244
|
+
function pricingArchive(state) {
|
|
245
|
+
const current = new Set(state.order.map((entry) => modelKey(entry.provider, entry.model)))
|
|
246
|
+
const rows = []
|
|
247
|
+
for (const [key, value] of Object.entries(state.pricing)) {
|
|
248
|
+
if (current.has(key)) continue
|
|
249
|
+
const at = key.indexOf(KEY_SEPARATOR)
|
|
250
|
+
if (at <= 0) continue
|
|
251
|
+
const provider = key.slice(0, at)
|
|
252
|
+
const model = key.slice(at + KEY_SEPARATOR.length)
|
|
253
|
+
if (!validRoute(provider, model)) continue
|
|
254
|
+
rows.push({ provider, model, pricing: { ...publicPricing(value), source: 'user' } })
|
|
255
|
+
}
|
|
256
|
+
return rows.sort((left, right) =>
|
|
257
|
+
left.provider.localeCompare(right.provider) || left.model.localeCompare(right.model))
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Whether a provider id belongs to DeepSeek itself. Anchored at the start so a
|
|
262
|
+
* relay that merely contains the word (`my-deepseek-relay`) is not captured.
|
|
263
|
+
*/
|
|
264
|
+
const OFFICIAL_DEEPSEEK_PROVIDER = /^deepseek(?:$|[-_.])/
|
|
265
|
+
function isOfficialDeepSeekProvider(provider) {
|
|
266
|
+
return OFFICIAL_DEEPSEEK_PROVIDER.test(String(provider ?? '').trim().toLowerCase())
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** The built-in rate table entry for a model, matched case-insensitively. */
|
|
270
|
+
function builtinRatesFor(model) {
|
|
271
|
+
const id = String(model ?? '').trim().toLowerCase()
|
|
272
|
+
return Object.prototype.hasOwnProperty.call(DEEPSEEK_PRICES, id) ? DEEPSEEK_PRICES[id] : undefined
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Whether a route is an official DeepSeek route with a built-in rate. */
|
|
276
|
+
function isDeepSeekRoute(provider, model) {
|
|
277
|
+
return isOfficialDeepSeekProvider(provider) && builtinRatesFor(model) !== undefined
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Whether one instant falls in DeepSeek's Beijing peak window. */
|
|
281
|
+
function deepseekPeakAt(time) {
|
|
282
|
+
const stamp = Number(time)
|
|
283
|
+
if (!Number.isFinite(stamp)) return false
|
|
284
|
+
const beijing = new Date(stamp + 8 * 60 * 60 * 1000)
|
|
285
|
+
const day = beijing.getUTCDay()
|
|
286
|
+
if (day === 0 || day === 6) return false
|
|
287
|
+
if (DEEPSEEK_HOLIDAYS.has(beijing.toISOString().slice(0, 10))) return false
|
|
288
|
+
const hour = beijing.getUTCHours()
|
|
289
|
+
return (hour >= 9 && hour < 12) || (hour >= 14 && hour < 18)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** The built-in price set for a DeepSeek model at one instant, or undefined. */
|
|
293
|
+
function deepseekPricingAt(model, time) {
|
|
294
|
+
const rates = builtinRatesFor(model)
|
|
295
|
+
if (rates === undefined) return undefined
|
|
296
|
+
return deepseekPeakAt(time) ? rates.peak : rates.offPeak
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Keep only the reasoning metadata the Client needs for its effort menu. */
|
|
300
|
+
function publicReasoning(value) {
|
|
301
|
+
if (value === null || typeof value !== 'object' || !Array.isArray(value.efforts)) return undefined
|
|
302
|
+
const efforts = value.efforts
|
|
303
|
+
.filter((effort) => typeof effort?.id === 'string' && effort.id !== '')
|
|
304
|
+
.map((effort) => ({
|
|
305
|
+
id: effort.id,
|
|
306
|
+
name: typeof effort.name === 'string' && effort.name !== '' ? effort.name : effort.id,
|
|
307
|
+
...(typeof effort.description === 'string' && effort.description !== '' ? { description: effort.description } : {}),
|
|
308
|
+
}))
|
|
309
|
+
if (efforts.length === 0) return undefined
|
|
310
|
+
return {
|
|
311
|
+
efforts,
|
|
312
|
+
...(typeof value.defaultEffort === 'string' && value.defaultEffort !== '' ? { defaultEffort: value.defaultEffort } : {}),
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** A stable map key that cannot collide across provider/model strings. */
|
|
317
|
+
function modelKey(provider, model) {
|
|
318
|
+
return `${provider}${KEY_SEPARATOR}${model}`
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Default prices: unset values stay `null`, zero is a real free price. */
|
|
322
|
+
function defaultPricing() {
|
|
323
|
+
return { cacheHit: null, cacheMiss: null, output: null }
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** The public form of one model's three prices. */
|
|
327
|
+
function publicPricing(value) {
|
|
328
|
+
const source = value?.base ?? value
|
|
329
|
+
const fallback = defaultPricing()
|
|
330
|
+
if (source === null || typeof source !== 'object') return fallback
|
|
331
|
+
const result = {}
|
|
332
|
+
for (const field of PRICE_FIELDS) result[field] = priceOrNull(source[field])
|
|
333
|
+
return result
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Official DeepSeek peak/off-peak prices, built in rather than user-edited. */
|
|
337
|
+
function deepseekBuiltinPricing(provider, model) {
|
|
338
|
+
if (!isOfficialDeepSeekProvider(provider)) return null
|
|
339
|
+
const prices = builtinRatesFor(model)
|
|
340
|
+
if (prices === undefined) return null
|
|
341
|
+
return {
|
|
342
|
+
builtin: true,
|
|
343
|
+
peak: { ...prices.peak },
|
|
344
|
+
offPeak: { ...prices.offPeak },
|
|
345
|
+
schedule: DEEPSEEK_PRICING_SCHEDULE,
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* The single pricing resolver shared by catalog and usage. A user-supplied price
|
|
351
|
+
* set always wins; otherwise a DeepSeek route falls back to the built-in official
|
|
352
|
+
* table, and every other route stays on its saved flat prices.
|
|
353
|
+
* @returns {{ kind: 'builtin'|'custom'|'flat'|'none', prices: object, complete: boolean, source: 'official'|'user' }}
|
|
354
|
+
*/
|
|
355
|
+
function resolveRoutePricing(provider, model, savedPricing, time) {
|
|
356
|
+
const saved = publicPricing(savedPricing)
|
|
357
|
+
if (priceSetComplete(saved)) return { kind: 'custom', prices: saved, complete: true, source: 'user' }
|
|
358
|
+
const rates = isOfficialDeepSeekProvider(provider) ? builtinRatesFor(model) : undefined
|
|
359
|
+
if (rates !== undefined) {
|
|
360
|
+
const prices = deepseekPeakAt(time) ? rates.peak : rates.offPeak
|
|
361
|
+
return { kind: 'builtin', prices: { ...prices }, complete: priceSetComplete(prices), source: 'official' }
|
|
362
|
+
}
|
|
363
|
+
const prices = publicPricing(savedPricing)
|
|
364
|
+
const complete = priceSetComplete(prices)
|
|
365
|
+
return { kind: complete ? 'flat' : 'none', prices, complete, source: 'user' }
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** Pricing as presented to the Client: the built-in official table or the saved flat prices. */
|
|
369
|
+
function catalogPricing(provider, model, value) {
|
|
370
|
+
const resolved = resolveRoutePricing(provider, model, value, Date.now())
|
|
371
|
+
if (resolved.kind === 'builtin') return { ...deepseekBuiltinPricing(provider, model), source: 'official' }
|
|
372
|
+
// Keep the official table alongside a custom DeepSeek price so the settings page
|
|
373
|
+
// can offer "restore official prices" without another round trip.
|
|
374
|
+
const official = isOfficialDeepSeekProvider(provider) ? deepseekBuiltinPricing(provider, model) : null
|
|
375
|
+
return {
|
|
376
|
+
...resolved.prices,
|
|
377
|
+
source: 'user',
|
|
378
|
+
...official === null ? {} : { official },
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** A finite non-negative number, or null for "not set". */
|
|
383
|
+
function priceOrNull(value) {
|
|
384
|
+
if (value === null || value === undefined || value === '') return null
|
|
385
|
+
const number = typeof value === 'number' ? value : Number(value)
|
|
386
|
+
if (!Number.isFinite(number) || number < 0 || number > 1e12) return null
|
|
387
|
+
return Math.round(number * 1e8) / 1e8
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** Whether all three prices of a set are configured. */
|
|
391
|
+
function priceSetComplete(value) {
|
|
392
|
+
return PRICE_FIELDS.every((field) => typeof value?.[field] === 'number' && Number.isFinite(value[field]) && value[field] >= 0)
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function isCount(value) {
|
|
396
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function safeSum(values) {
|
|
400
|
+
let total = 0
|
|
401
|
+
for (const value of values) {
|
|
402
|
+
total += value
|
|
403
|
+
if (!Number.isSafeInteger(total)) return undefined
|
|
404
|
+
}
|
|
405
|
+
return total
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** The last usage chunk of a durable assistant stream. */
|
|
409
|
+
function streamUsage(stream) {
|
|
410
|
+
if (!Array.isArray(stream)) return undefined
|
|
411
|
+
for (let index = stream.length - 1; index >= 0; index -= 1) {
|
|
412
|
+
const record = stream[index]
|
|
413
|
+
if (record?.type === 'chunk' && record.chunk?.type === 'usage') return record.chunk.usage
|
|
414
|
+
}
|
|
415
|
+
return undefined
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Normalize a provider usage sample. `inputTokens` is the uncached input bucket;
|
|
420
|
+
* cache reads and writes sit beside it, matching token-meter's accounting.
|
|
421
|
+
*/
|
|
422
|
+
function normalizeUsage(usage, stream) {
|
|
423
|
+
const sample = usage ?? streamUsage(stream)
|
|
424
|
+
if (sample === null || typeof sample !== 'object') return undefined
|
|
425
|
+
const { inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, reasoningTokens, totalTokens } = sample
|
|
426
|
+
if (!isCount(inputTokens) || !isCount(outputTokens)) return undefined
|
|
427
|
+
if (cacheReadTokens !== undefined && !isCount(cacheReadTokens)) return undefined
|
|
428
|
+
if (cacheWriteTokens !== undefined && !isCount(cacheWriteTokens)) return undefined
|
|
429
|
+
if (reasoningTokens !== undefined && (!isCount(reasoningTokens) || reasoningTokens > outputTokens)) return undefined
|
|
430
|
+
const knownPrompt = safeSum([
|
|
431
|
+
inputTokens,
|
|
432
|
+
...cacheReadTokens === undefined ? [] : [cacheReadTokens],
|
|
433
|
+
...cacheWriteTokens === undefined ? [] : [cacheWriteTokens],
|
|
434
|
+
])
|
|
435
|
+
if (knownPrompt === undefined) return undefined
|
|
436
|
+
// The three buckets are the authoritative source; a totalTokens that disagrees
|
|
437
|
+
// with them only marks the row partial. Dropping the whole row would under-report
|
|
438
|
+
// the session far more than flagging it uncertain.
|
|
439
|
+
let partial = false
|
|
440
|
+
if (totalTokens !== undefined) {
|
|
441
|
+
if (isCount(totalTokens)) {
|
|
442
|
+
const exactPrompt = totalTokens - outputTokens
|
|
443
|
+
if (!isCount(exactPrompt) || exactPrompt < knownPrompt) partial = true
|
|
444
|
+
else if (cacheReadTokens !== undefined && cacheWriteTokens !== undefined && exactPrompt !== knownPrompt) partial = true
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
return {
|
|
448
|
+
uncachedInputTokens: inputTokens,
|
|
449
|
+
outputTokens,
|
|
450
|
+
...cacheReadTokens === undefined ? {} : { cacheReadTokens },
|
|
451
|
+
...cacheWriteTokens === undefined ? {} : { cacheWriteTokens },
|
|
452
|
+
...reasoningTokens === undefined ? {} : { reasoningTokens },
|
|
453
|
+
...partial ? { partial: true } : {},
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function messageRoute(message) {
|
|
458
|
+
const provider = message?.source?.provider
|
|
459
|
+
const model = message?.source?.model
|
|
460
|
+
return typeof provider === 'string' && typeof model === 'string' && provider !== '' && model !== ''
|
|
461
|
+
? { provider, model }
|
|
462
|
+
: undefined
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** A short, whitespace-collapsed preview of the first user text in one turn. */
|
|
466
|
+
function userTextPreview(message) {
|
|
467
|
+
const content = Array.isArray(message?.content) ? message.content : []
|
|
468
|
+
const text = content
|
|
469
|
+
.filter((block) => block?.type === 'text' && typeof block.text === 'string')
|
|
470
|
+
.map((block) => block.text)
|
|
471
|
+
.join(' ')
|
|
472
|
+
.replace(/\s+/g, ' ')
|
|
473
|
+
.trim()
|
|
474
|
+
if (text === '') return '(无文本)'
|
|
475
|
+
const characters = Array.from(text)
|
|
476
|
+
return characters.length > 36 ? `${characters.slice(0, 36).join('')}…` : text
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Fold one Session's durable attempts into per-turn and whole-session cost.
|
|
481
|
+
*
|
|
482
|
+
* Only `assistant/message` and `assistant/attempt` samples are billed. A usage
|
|
483
|
+
* sample without a lifecycle route is retained with an unknown route so the UI
|
|
484
|
+
* can report it as unpriced instead of silently dropping tokens.
|
|
485
|
+
*/
|
|
486
|
+
function computeSessionUsage(events, pricingByKey = new Map(), nameByKey = new Map(), orderKeys = null) {
|
|
487
|
+
const turns = new Map()
|
|
488
|
+
// When the caller knows which routes are still configured, an unpriced call can
|
|
489
|
+
// be split into "needs attention now" and "historical leftovers". Without that
|
|
490
|
+
// set every unpriced call is historical, which keeps older callers unchanged.
|
|
491
|
+
const orderSet = orderKeys instanceof Set ? orderKeys : null
|
|
492
|
+
const unpricedRoutes = new Map()
|
|
493
|
+
let headerRoute
|
|
494
|
+
let headerAt
|
|
495
|
+
let latestTime = 0
|
|
496
|
+
let currentTurn = null
|
|
497
|
+
let pendingPreview = null
|
|
498
|
+
|
|
499
|
+
const ensureTurn = (turn, time) => {
|
|
500
|
+
let entry = turns.get(turn)
|
|
501
|
+
if (entry === undefined) {
|
|
502
|
+
entry = {
|
|
503
|
+
turn,
|
|
504
|
+
startTime: Number(time) || 0,
|
|
505
|
+
endTime: Number(time) || 0,
|
|
506
|
+
preview: '',
|
|
507
|
+
seenUser: false,
|
|
508
|
+
attempts: [],
|
|
509
|
+
models: new Map(),
|
|
510
|
+
}
|
|
511
|
+
turns.set(turn, entry)
|
|
512
|
+
}
|
|
513
|
+
return entry
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const addAttempt = (event, route, usage) => {
|
|
517
|
+
const turn = event?.data?.turn
|
|
518
|
+
if (!Number.isSafeInteger(turn) || usage === undefined) return
|
|
519
|
+
const time = Number(event.time) || latestTime
|
|
520
|
+
latestTime = Math.max(latestTime, time)
|
|
521
|
+
const entry = ensureTurn(turn, time)
|
|
522
|
+
entry.endTime = Math.max(entry.endTime, time)
|
|
523
|
+
const provider = route?.provider
|
|
524
|
+
const model = route?.model
|
|
525
|
+
const key = provider !== undefined && model !== undefined ? modelKey(provider, model) : undefined
|
|
526
|
+
// Peak/off-peak follows the moment the request reached the server, not when
|
|
527
|
+
// the stream finished — a long request straddling noon stays on its entry rate.
|
|
528
|
+
const pricedAt = headerAt ?? time
|
|
529
|
+
const resolved = provider !== undefined && model !== undefined
|
|
530
|
+
? resolveRoutePricing(provider, model, key === undefined ? undefined : pricingByKey.get(key), pricedAt)
|
|
531
|
+
: { kind: 'none', prices: defaultPricing(), complete: false }
|
|
532
|
+
const effective = resolved.prices
|
|
533
|
+
const complete = resolved.complete
|
|
534
|
+
const cost = !complete ? 0 : estimateAttemptCost(usage, effective)
|
|
535
|
+
const deepseek = resolved.kind === 'builtin'
|
|
536
|
+
const attempt = {
|
|
537
|
+
seq: event.seq,
|
|
538
|
+
time,
|
|
539
|
+
provider: provider ?? null,
|
|
540
|
+
model: model ?? null,
|
|
541
|
+
name: key === undefined ? null : nameByKey.get(key) ?? null,
|
|
542
|
+
deepseek,
|
|
543
|
+
priced: complete,
|
|
544
|
+
currentOrder: orderSet === null || key === undefined ? null : orderSet.has(key),
|
|
545
|
+
cost,
|
|
546
|
+
usage,
|
|
547
|
+
}
|
|
548
|
+
entry.attempts.push(attempt)
|
|
549
|
+
if (provider !== undefined && model !== undefined) {
|
|
550
|
+
const modelName = nameByKey.get(key) ?? `${provider} / ${model}`
|
|
551
|
+
const existing = entry.models.get(key)
|
|
552
|
+
if (existing === undefined) {
|
|
553
|
+
entry.models.set(key, { provider, model, name: modelName, cost: 0, priced: complete, isDeepSeek: deepseek })
|
|
554
|
+
}
|
|
555
|
+
const modelEntry = entry.models.get(key)
|
|
556
|
+
modelEntry.cost += cost
|
|
557
|
+
modelEntry.priced = modelEntry.priced && complete
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
for (const event of events) {
|
|
562
|
+
if (event?.type === 'turn/start') {
|
|
563
|
+
const turn = event.data?.turn
|
|
564
|
+
if (Number.isSafeInteger(turn)) {
|
|
565
|
+
currentTurn = turn
|
|
566
|
+
headerRoute = undefined
|
|
567
|
+
headerAt = undefined
|
|
568
|
+
const entry = ensureTurn(turn, event.time)
|
|
569
|
+
if (!entry.seenUser && pendingPreview !== null) {
|
|
570
|
+
entry.preview = pendingPreview
|
|
571
|
+
entry.seenUser = true
|
|
572
|
+
pendingPreview = null
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
continue
|
|
576
|
+
}
|
|
577
|
+
if (event?.type === 'user/message') {
|
|
578
|
+
const preview = userTextPreview(event.data)
|
|
579
|
+
if (currentTurn === null) {
|
|
580
|
+
pendingPreview = preview
|
|
581
|
+
} else {
|
|
582
|
+
const entry = ensureTurn(currentTurn, event.time)
|
|
583
|
+
if (!entry.seenUser) {
|
|
584
|
+
entry.preview = preview
|
|
585
|
+
entry.seenUser = true
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
continue
|
|
589
|
+
}
|
|
590
|
+
if (event?.type === 'turn/end') {
|
|
591
|
+
currentTurn = null
|
|
592
|
+
continue
|
|
593
|
+
}
|
|
594
|
+
if (event?.type === 'request/header') {
|
|
595
|
+
const config = event.data?.header?.config
|
|
596
|
+
headerAt = Number(event.time) || headerAt
|
|
597
|
+
if (typeof config?.provider === 'string' && typeof config?.model === 'string') {
|
|
598
|
+
headerRoute = { provider: config.provider, model: config.model }
|
|
599
|
+
}
|
|
600
|
+
continue
|
|
601
|
+
}
|
|
602
|
+
if (event?.type === 'assistant/message') {
|
|
603
|
+
addAttempt(event, messageRoute(event.data?.message) ?? headerRoute, normalizeUsage(event.data?.usage, event.data?.stream))
|
|
604
|
+
continue
|
|
605
|
+
}
|
|
606
|
+
if (event?.type === 'assistant/attempt') {
|
|
607
|
+
addAttempt(event, headerRoute, normalizeUsage(undefined, event.data?.stream))
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
const turnRows = []
|
|
612
|
+
const byModel = new Map()
|
|
613
|
+
const totals = {
|
|
614
|
+
cost: 0,
|
|
615
|
+
promptTokens: 0,
|
|
616
|
+
uncachedInputTokens: 0,
|
|
617
|
+
cacheReadTokens: 0,
|
|
618
|
+
cacheWriteTokens: 0,
|
|
619
|
+
outputTokens: 0,
|
|
620
|
+
cacheHit: null,
|
|
621
|
+
attempts: 0,
|
|
622
|
+
chargeableAttempts: 0,
|
|
623
|
+
pricedAttempts: 0,
|
|
624
|
+
unpricedAttempts: 0,
|
|
625
|
+
unpricedCurrentAttempts: 0,
|
|
626
|
+
unpricedHistoricalAttempts: 0,
|
|
627
|
+
cacheReported: false,
|
|
628
|
+
hasBillable: false,
|
|
629
|
+
partial: false,
|
|
630
|
+
partialCurrent: false,
|
|
631
|
+
costKnown: false,
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
for (const entry of [...turns.values()].sort((left, right) => left.turn - right.turn)) {
|
|
635
|
+
if (entry.attempts.length === 0) continue
|
|
636
|
+
const row = {
|
|
637
|
+
turn: entry.turn,
|
|
638
|
+
startTime: entry.startTime,
|
|
639
|
+
endTime: entry.endTime,
|
|
640
|
+
preview: entry.preview,
|
|
641
|
+
cost: 0,
|
|
642
|
+
promptTokens: 0,
|
|
643
|
+
uncachedInputTokens: 0,
|
|
644
|
+
cacheReadTokens: 0,
|
|
645
|
+
cacheWriteTokens: 0,
|
|
646
|
+
outputTokens: 0,
|
|
647
|
+
cacheHit: null,
|
|
648
|
+
attempts: entry.attempts.length,
|
|
649
|
+
chargeableAttempts: 0,
|
|
650
|
+
pricedAttempts: 0,
|
|
651
|
+
unpricedAttempts: 0,
|
|
652
|
+
unpricedCurrentAttempts: 0,
|
|
653
|
+
unpricedHistoricalAttempts: 0,
|
|
654
|
+
cacheReported: false,
|
|
655
|
+
hasBillable: false,
|
|
656
|
+
partial: false,
|
|
657
|
+
partialCurrent: false,
|
|
658
|
+
costKnown: false,
|
|
659
|
+
models: [...entry.models.values()].sort((left, right) => left.name.localeCompare(right.name)),
|
|
660
|
+
}
|
|
661
|
+
let usagePartial = false
|
|
662
|
+
for (const attempt of entry.attempts) {
|
|
663
|
+
const usage = attempt.usage
|
|
664
|
+
const prompt = usage.uncachedInputTokens
|
|
665
|
+
+ (usage.cacheReadTokens ?? 0)
|
|
666
|
+
+ (usage.cacheWriteTokens ?? 0)
|
|
667
|
+
row.promptTokens += prompt
|
|
668
|
+
row.uncachedInputTokens += usage.uncachedInputTokens
|
|
669
|
+
row.cacheReadTokens += usage.cacheReadTokens ?? 0
|
|
670
|
+
row.cacheWriteTokens += usage.cacheWriteTokens ?? 0
|
|
671
|
+
row.outputTokens += usage.outputTokens
|
|
672
|
+
row.cost += attempt.cost
|
|
673
|
+
if (usage.partial === true) usagePartial = true
|
|
674
|
+
if (usage.cacheReadTokens !== undefined || usage.cacheWriteTokens !== undefined) row.cacheReported = true
|
|
675
|
+
row.chargeableAttempts += 1
|
|
676
|
+
row.hasBillable = true
|
|
677
|
+
if (attempt.priced) row.pricedAttempts += 1
|
|
678
|
+
else {
|
|
679
|
+
row.unpricedAttempts += 1
|
|
680
|
+
if (attempt.currentOrder === true) row.unpricedCurrentAttempts += 1
|
|
681
|
+
else row.unpricedHistoricalAttempts += 1
|
|
682
|
+
if (attempt.provider !== null && attempt.model !== null) {
|
|
683
|
+
const routeKey = modelKey(attempt.provider, attempt.model)
|
|
684
|
+
let leftover = unpricedRoutes.get(routeKey)
|
|
685
|
+
if (leftover === undefined) {
|
|
686
|
+
leftover = {
|
|
687
|
+
provider: attempt.provider,
|
|
688
|
+
model: attempt.model,
|
|
689
|
+
name: attempt.name ?? `${attempt.provider} / ${attempt.model}`,
|
|
690
|
+
attempts: 0,
|
|
691
|
+
promptTokens: 0,
|
|
692
|
+
outputTokens: 0,
|
|
693
|
+
cacheReadTokens: 0,
|
|
694
|
+
firstTime: attempt.time,
|
|
695
|
+
lastTime: attempt.time,
|
|
696
|
+
currentOrder: attempt.currentOrder === true,
|
|
697
|
+
}
|
|
698
|
+
unpricedRoutes.set(routeKey, leftover)
|
|
699
|
+
}
|
|
700
|
+
leftover.attempts += 1
|
|
701
|
+
leftover.promptTokens += prompt
|
|
702
|
+
leftover.outputTokens += usage.outputTokens
|
|
703
|
+
leftover.cacheReadTokens += usage.cacheReadTokens ?? 0
|
|
704
|
+
leftover.firstTime = Math.min(leftover.firstTime, attempt.time)
|
|
705
|
+
leftover.lastTime = Math.max(leftover.lastTime, attempt.time)
|
|
706
|
+
leftover.currentOrder = leftover.currentOrder || attempt.currentOrder === true
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
const key = attempt.provider !== null && attempt.model !== null
|
|
711
|
+
? modelKey(attempt.provider, attempt.model)
|
|
712
|
+
: undefined
|
|
713
|
+
const aggregateKey = key ?? `unknown${KEY_SEPARATOR}${attempt.name ?? 'unknown'}`
|
|
714
|
+
let aggregate = byModel.get(aggregateKey)
|
|
715
|
+
if (aggregate === undefined) {
|
|
716
|
+
aggregate = {
|
|
717
|
+
provider: attempt.provider,
|
|
718
|
+
model: attempt.model,
|
|
719
|
+
name: attempt.name ?? '未知模型',
|
|
720
|
+
cost: 0,
|
|
721
|
+
promptTokens: 0,
|
|
722
|
+
outputTokens: 0,
|
|
723
|
+
cacheReadTokens: 0,
|
|
724
|
+
cacheReported: false,
|
|
725
|
+
attempts: 0,
|
|
726
|
+
pricedAttempts: 0,
|
|
727
|
+
unpricedAttempts: 0,
|
|
728
|
+
isDeepSeek: attempt.deepseek === true,
|
|
729
|
+
}
|
|
730
|
+
byModel.set(aggregateKey, aggregate)
|
|
731
|
+
}
|
|
732
|
+
aggregate.cost += attempt.cost
|
|
733
|
+
aggregate.promptTokens += prompt
|
|
734
|
+
aggregate.outputTokens += usage.outputTokens
|
|
735
|
+
aggregate.cacheReadTokens += usage.cacheReadTokens ?? 0
|
|
736
|
+
aggregate.cacheReported = aggregate.cacheReported || usage.cacheReadTokens !== undefined || usage.cacheWriteTokens !== undefined
|
|
737
|
+
aggregate.attempts += 1
|
|
738
|
+
if (attempt.priced) aggregate.pricedAttempts += 1
|
|
739
|
+
else aggregate.unpricedAttempts += 1
|
|
740
|
+
}
|
|
741
|
+
row.cacheHit = row.cacheReported && row.promptTokens > 0 ? row.cacheReadTokens / row.promptTokens : null
|
|
742
|
+
row.partial = row.unpricedAttempts > 0 || usagePartial
|
|
743
|
+
row.partialCurrent = row.unpricedCurrentAttempts > 0
|
|
744
|
+
row.costKnown = row.attempts > 0 && row.unpricedAttempts === 0
|
|
745
|
+
turnRows.push(row)
|
|
746
|
+
|
|
747
|
+
totals.cost += row.cost
|
|
748
|
+
totals.promptTokens += row.promptTokens
|
|
749
|
+
totals.uncachedInputTokens += row.uncachedInputTokens
|
|
750
|
+
totals.cacheReadTokens += row.cacheReadTokens
|
|
751
|
+
totals.cacheWriteTokens += row.cacheWriteTokens
|
|
752
|
+
totals.outputTokens += row.outputTokens
|
|
753
|
+
totals.attempts += row.attempts
|
|
754
|
+
totals.chargeableAttempts += row.chargeableAttempts
|
|
755
|
+
totals.pricedAttempts += row.pricedAttempts
|
|
756
|
+
totals.unpricedAttempts += row.unpricedAttempts
|
|
757
|
+
totals.unpricedCurrentAttempts += row.unpricedCurrentAttempts
|
|
758
|
+
totals.unpricedHistoricalAttempts += row.unpricedHistoricalAttempts
|
|
759
|
+
totals.cacheReported = totals.cacheReported || row.cacheReported
|
|
760
|
+
totals.hasBillable = totals.hasBillable || row.hasBillable
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
totals.cacheHit = totals.cacheReported && totals.promptTokens > 0 ? totals.cacheReadTokens / totals.promptTokens : null
|
|
764
|
+
totals.partial = totals.unpricedAttempts > 0 || turnRows.some((row) => row.partial === true)
|
|
765
|
+
totals.partialCurrent = totals.unpricedCurrentAttempts > 0
|
|
766
|
+
totals.costKnown = totals.attempts > 0 && totals.unpricedAttempts === 0
|
|
767
|
+
|
|
768
|
+
return {
|
|
769
|
+
turns: turnRows,
|
|
770
|
+
totals,
|
|
771
|
+
byModel: [...byModel.values()].sort((left, right) => left.cost - right.cost || left.name.localeCompare(right.name)),
|
|
772
|
+
unpricedRoutes: [...unpricedRoutes.values()].sort((left, right) => right.attempts - left.attempts || left.name.localeCompare(right.name)),
|
|
773
|
+
lastTurn: turnRows.at(-1) ?? null,
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/** Price one normalized attempt using one price set. */
|
|
778
|
+
function estimateAttemptCost(usage, prices) {
|
|
779
|
+
const hit = (usage.cacheReadTokens ?? 0) / 1_000_000 * prices.cacheHit
|
|
780
|
+
const miss = ((usage.uncachedInputTokens ?? 0) + (usage.cacheWriteTokens ?? 0)) / 1_000_000 * prices.cacheMiss
|
|
781
|
+
const output = (usage.outputTokens ?? 0) / 1_000_000 * prices.output
|
|
782
|
+
const total = hit + miss + output
|
|
783
|
+
return Number.isFinite(total) ? Math.round(total * 1e10) / 1e10 : 0
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/** Load and normalize the durable state. A corrupt file degrades to defaults. */
|
|
787
|
+
async function loadState(ctx) {
|
|
788
|
+
const path = statePath()
|
|
789
|
+
await removeStaleTemps(dirname(path), path)
|
|
790
|
+
try {
|
|
791
|
+
const text = await readFile(path, 'utf8')
|
|
792
|
+
const raw = JSON.parse(text)
|
|
793
|
+
const state = normalizeState(raw)
|
|
794
|
+
if (isStateTooNew(raw)) {
|
|
795
|
+
// Hand back the readable subset but refuse every write, so an older plugin
|
|
796
|
+
// never clobbers a file it does not understand.
|
|
797
|
+
return { ...state, readOnly: true }
|
|
798
|
+
}
|
|
799
|
+
return state
|
|
800
|
+
} catch (error) {
|
|
801
|
+
if (error?.code !== 'ENOENT') ctx.logger?.warn?.(`[model-billing] ignoring unreadable state: ${messageOf(error)}`)
|
|
802
|
+
return normalizeState(undefined)
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/** True when the document was written by a newer plugin than this one. */
|
|
807
|
+
function isStateTooNew(raw) {
|
|
808
|
+
return typeof raw?.version === 'number' && Number.isFinite(raw.version) && raw.version > STATE_VERSION
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/**
|
|
812
|
+
* Delete temp files left behind by an older plugin version. The current writer
|
|
813
|
+
* uses a fixed `${path}.tmp` name that is always replaced, so anything else in
|
|
814
|
+
* the directory is debris from the old pid+timestamp naming scheme.
|
|
815
|
+
*/
|
|
816
|
+
async function removeStaleTemps(directory, path) {
|
|
817
|
+
let entries
|
|
818
|
+
try {
|
|
819
|
+
entries = await readdir(directory)
|
|
820
|
+
} catch {
|
|
821
|
+
return
|
|
822
|
+
}
|
|
823
|
+
for (const entry of entries) {
|
|
824
|
+
if (!entry.startsWith(`${basename(path)}.tmp-`)) continue
|
|
825
|
+
await unlink(join(directory, entry)).catch(() => {})
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/** Validate persisted turn-tail display settings, keeping unknown keys out. */
|
|
830
|
+
function normalizeDisplay(raw) {
|
|
831
|
+
const source = raw !== null && typeof raw === 'object' && !Array.isArray(raw) ? raw : {}
|
|
832
|
+
const display = { ...DISPLAY_DEFAULTS }
|
|
833
|
+
for (const key of DISPLAY_FLAGS) {
|
|
834
|
+
if (typeof source[key] === 'boolean') display[key] = source[key]
|
|
835
|
+
}
|
|
836
|
+
if (DISPLAY_FONT_SIZES.has(source.fontSize)) display.fontSize = source.fontSize
|
|
837
|
+
if (DISPLAY_DENSITIES.has(source.density)) display.density = source.density
|
|
838
|
+
if (DISPLAY_FONT_WEIGHTS.has(source.fontWeight)) display.fontWeight = source.fontWeight
|
|
839
|
+
return display
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/** Validate a persisted state document into the current shape. */
|
|
843
|
+
function normalizeState(raw) {
|
|
844
|
+
const state = {
|
|
845
|
+
version: STATE_VERSION,
|
|
846
|
+
order: [],
|
|
847
|
+
pricing: {},
|
|
848
|
+
display: normalizeDisplay(raw?.display),
|
|
849
|
+
savedAt: typeof raw?.savedAt === 'number' && Number.isFinite(raw.savedAt) ? raw.savedAt : 0,
|
|
850
|
+
}
|
|
851
|
+
const seen = new Set()
|
|
852
|
+
if (Array.isArray(raw?.order)) {
|
|
853
|
+
for (const entry of raw.order) {
|
|
854
|
+
if (state.order.length >= MAX_MODELS) break
|
|
855
|
+
if (!validRoute(entry?.provider, entry?.model)) continue
|
|
856
|
+
const key = modelKey(entry.provider, entry.model)
|
|
857
|
+
if (seen.has(key)) continue
|
|
858
|
+
seen.add(key)
|
|
859
|
+
state.order.push({ provider: entry.provider, model: entry.model })
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
if (raw?.pricing !== null && typeof raw?.pricing === 'object' && !Array.isArray(raw.pricing)) {
|
|
863
|
+
let count = 0
|
|
864
|
+
for (const [key, value] of Object.entries(raw.pricing)) {
|
|
865
|
+
if (count >= MAX_STORED_PRICES) break
|
|
866
|
+
if (key.includes(KEY_SEPARATOR)) {
|
|
867
|
+
state.pricing[key] = publicPricing(value)
|
|
868
|
+
count += 1
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
return state
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
/** Persist one normalized state document atomically. */
|
|
876
|
+
async function writeState(state) {
|
|
877
|
+
const path = statePath()
|
|
878
|
+
await mkdir(dirname(path), { recursive: true })
|
|
879
|
+
const temporary = `${path}.tmp`
|
|
880
|
+
await writeFile(temporary, `${JSON.stringify(state, null, 2)}\n`, 'utf8')
|
|
881
|
+
try {
|
|
882
|
+
await rename(temporary, path)
|
|
883
|
+
} catch (error) {
|
|
884
|
+
// A failed swap must not leave the fixed temp name behind to block the next write.
|
|
885
|
+
await unlink(temporary).catch(() => {})
|
|
886
|
+
throw error
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
/** Validate a route pair used as a map key. */
|
|
891
|
+
function validRoute(provider, model) {
|
|
892
|
+
return typeof provider === 'string'
|
|
893
|
+
&& typeof model === 'string'
|
|
894
|
+
&& provider.trim() !== ''
|
|
895
|
+
&& model.trim() !== ''
|
|
896
|
+
&& provider.length <= 200
|
|
897
|
+
&& model.length <= 200
|
|
898
|
+
&& !/[\u0000-\u001f\u007f]/.test(provider)
|
|
899
|
+
&& !/[\u0000-\u001f\u007f]/.test(model)
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
/** Merge one save request into the durable state and return the new envelope. */
|
|
903
|
+
async function saveOrderAndPrices(ctx, body) {
|
|
904
|
+
const modelsSupplied = Array.isArray(body?.models)
|
|
905
|
+
const archiveSupplied = Array.isArray(body?.pricingArchive)
|
|
906
|
+
if (!modelsSupplied && !archiveSupplied) {
|
|
907
|
+
throw new BillingRefusal(400, 'bad-request', 'models or pricingArchive must be an array')
|
|
908
|
+
}
|
|
909
|
+
const models = modelsSupplied ? body.models : null
|
|
910
|
+
if (models !== null && models.length > MAX_MODELS) {
|
|
911
|
+
throw new BillingRefusal(400, 'bad-request', `models must be an array of at most ${MAX_MODELS} entries`)
|
|
912
|
+
}
|
|
913
|
+
if (archiveSupplied && body.pricingArchive.length > MAX_STORED_PRICES) {
|
|
914
|
+
throw new BillingRefusal(400, 'bad-request', `pricingArchive must be at most ${MAX_STORED_PRICES} entries`)
|
|
915
|
+
}
|
|
916
|
+
if (body?.display !== undefined && (body.display === null || typeof body.display !== 'object' || Array.isArray(body.display))) {
|
|
917
|
+
throw new BillingRefusal(400, 'bad-request', 'display must be an object')
|
|
918
|
+
}
|
|
919
|
+
// Validate the payload before queueing so a bad request never blocks good ones.
|
|
920
|
+
for (const entry of models ?? []) {
|
|
921
|
+
if (!validRoute(entry?.provider, entry?.model)) {
|
|
922
|
+
throw new BillingRefusal(400, 'bad-request', 'every model needs a non-empty provider and model id')
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
for (const entry of body?.pricingArchive ?? []) {
|
|
926
|
+
if (!validRoute(entry?.provider, entry?.model)) {
|
|
927
|
+
throw new BillingRefusal(400, 'bad-request', 'every archived price needs a non-empty provider and model id')
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
// Read, merge and write as one queued unit. Doing the read outside the queue
|
|
931
|
+
// lets two quick saves both start from the same stale snapshot, so the later
|
|
932
|
+
// one silently drops the earlier one's prices.
|
|
933
|
+
return enqueueWrite(async () => {
|
|
934
|
+
const state = await loadState(ctx)
|
|
935
|
+
if (state.readOnly === true) {
|
|
936
|
+
throw new BillingRefusal(409, 'state-too-new', 'this state file was written by a newer version of the plugin; upgrade before saving')
|
|
937
|
+
}
|
|
938
|
+
const display = body?.display === undefined ? state.display : normalizeDisplay(body.display)
|
|
939
|
+
const order = []
|
|
940
|
+
const seen = new Set()
|
|
941
|
+
const pricing = { ...state.pricing }
|
|
942
|
+
if (models === null) {
|
|
943
|
+
// An archive-only save must not disturb the configured order.
|
|
944
|
+
for (const entry of state.order) {
|
|
945
|
+
order.push({ provider: entry.provider, model: entry.model })
|
|
946
|
+
seen.add(modelKey(entry.provider, entry.model))
|
|
947
|
+
}
|
|
948
|
+
} else {
|
|
949
|
+
for (const entry of models) {
|
|
950
|
+
const provider = entry.provider.trim()
|
|
951
|
+
const model = entry.model.trim()
|
|
952
|
+
const key = modelKey(provider, model)
|
|
953
|
+
if (seen.has(key)) throw new BillingRefusal(400, 'bad-request', `duplicate model: ${provider}/${model}`)
|
|
954
|
+
seen.add(key)
|
|
955
|
+
order.push({ provider, model })
|
|
956
|
+
pricing[key] = publicPricing(entry.pricing)
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
if (archiveSupplied) {
|
|
960
|
+
for (const entry of body.pricingArchive) {
|
|
961
|
+
const provider = entry.provider.trim()
|
|
962
|
+
const model = entry.model.trim()
|
|
963
|
+
const key = modelKey(provider, model)
|
|
964
|
+
if (seen.has(key)) {
|
|
965
|
+
throw new BillingRefusal(400, 'bad-request', `archived price targets a configured model: ${provider}/${model}`)
|
|
966
|
+
}
|
|
967
|
+
const archived = publicPricing(entry.pricing)
|
|
968
|
+
// Three blank fields mean "remove this price-book entry".
|
|
969
|
+
if (PRICE_FIELDS.every((field) => archived[field] === null)) delete pricing[key]
|
|
970
|
+
else pricing[key] = archived
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
const capped = capPricing(pricing, order, ctx)
|
|
974
|
+
const next = {
|
|
975
|
+
version: STATE_VERSION,
|
|
976
|
+
order,
|
|
977
|
+
pricing: capped,
|
|
978
|
+
display,
|
|
979
|
+
savedAt: Date.now(),
|
|
980
|
+
}
|
|
981
|
+
await writeState(next)
|
|
982
|
+
// A save can accompany a newly discovered provider/model, so drop the
|
|
983
|
+
// catalog cache so the next catalog request reflects the fresh state.
|
|
984
|
+
invalidateCatalogCache()
|
|
985
|
+
return stateEnvelope(next)
|
|
986
|
+
})
|
|
987
|
+
}
|
|
988
|
+
/**
|
|
989
|
+
* Trim stored prices to the limit, keeping the routes the user still has in
|
|
990
|
+
* `order` first. Truncating by raw insertion order instead drops exactly the
|
|
991
|
+
* newest prices, which is the opposite of what a user expects.
|
|
992
|
+
*/
|
|
993
|
+
function capPricing(pricing, order, ctx) {
|
|
994
|
+
const keys = Object.keys(pricing)
|
|
995
|
+
if (keys.length <= MAX_STORED_PRICES) return pricing
|
|
996
|
+
const orderedKeys = new Set(order.map((entry) => modelKey(entry.provider, entry.model)))
|
|
997
|
+
const kept = []
|
|
998
|
+
for (const key of keys) {
|
|
999
|
+
if (orderedKeys.has(key)) kept.push(key)
|
|
1000
|
+
}
|
|
1001
|
+
for (const key of keys) {
|
|
1002
|
+
if (kept.length >= MAX_STORED_PRICES) break
|
|
1003
|
+
if (!orderedKeys.has(key)) kept.push(key)
|
|
1004
|
+
}
|
|
1005
|
+
const trimmed = {}
|
|
1006
|
+
for (const key of kept.slice(0, MAX_STORED_PRICES)) trimmed[key] = pricing[key]
|
|
1007
|
+
const dropped = keys.length - Object.keys(trimmed).length
|
|
1008
|
+
if (dropped > 0) {
|
|
1009
|
+
ctx.logger?.warn?.(`[model-billing] dropped ${dropped} price entries to stay under the ${MAX_STORED_PRICES} entry limit`)
|
|
1010
|
+
}
|
|
1011
|
+
return trimmed
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
/**
|
|
1015
|
+
* Serialize state work so two quick saves cannot interleave. The whole
|
|
1016
|
+
* read-merge-write runs inside the chain; the previous chain's failures are
|
|
1017
|
+
* swallowed with `.then(operation, operation)` so one bad save never wedges the
|
|
1018
|
+
* next one.
|
|
1019
|
+
*/
|
|
1020
|
+
let writeChain = Promise.resolve()
|
|
1021
|
+
function enqueueWrite(operation) {
|
|
1022
|
+
const run = writeChain.then(operation, operation)
|
|
1023
|
+
writeChain = run.catch(() => {})
|
|
1024
|
+
return run
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
/**
|
|
1028
|
+
* Register the catalog, usage and save routes.
|
|
1029
|
+
* @param {object} ctx Host Cordis context.
|
|
1030
|
+
*/
|
|
1031
|
+
export function apply(ctx) {
|
|
1032
|
+
const onCatalog = (request, response) => {
|
|
1033
|
+
void serveCatalog(ctx, request, response).catch((error) => fail(ctx, response, error))
|
|
1034
|
+
}
|
|
1035
|
+
const onUsage = (request, response) => {
|
|
1036
|
+
void serveUsage(ctx, request, response).catch((error) => fail(ctx, response, error))
|
|
1037
|
+
}
|
|
1038
|
+
const onSave = (request, response) => {
|
|
1039
|
+
void serveSave(ctx, request, response).catch((error) => fail(ctx, response, error))
|
|
1040
|
+
}
|
|
1041
|
+
const onSelect = (request, response) => {
|
|
1042
|
+
void serveSelect(ctx, request, response).catch((error) => fail(ctx, response, error))
|
|
1043
|
+
}
|
|
1044
|
+
ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: CATALOG_PATH, handler: onCatalog }), 'model-billing: catalog route')
|
|
1045
|
+
ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: USAGE_PATH, handler: onUsage }), 'model-billing: usage route')
|
|
1046
|
+
ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: SAVE_PATH, handler: onSave }), 'model-billing: save route')
|
|
1047
|
+
ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: SELECT_PATH, handler: onSelect }), 'model-billing: select route')
|
|
1048
|
+
ctx.logger?.info?.(`[model-billing] routes mounted at ${CATALOG_PATH}, ${USAGE_PATH}, ${SAVE_PATH}, ${SELECT_PATH}`)
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
async function serveCatalog(ctx, request, response) {
|
|
1052
|
+
if (request.method !== 'GET') return sendJson(response, 405, { ok: false, code: 'method-not-allowed', message: 'only GET is accepted' })
|
|
1053
|
+
if (!sameOrigin(request)) return sendJson(response, 403, { ok: false, code: 'untrusted-origin', message: 'the request did not come from this Harness page' })
|
|
1054
|
+
sendJson(response, 200, await buildCatalog(ctx, { refresh: wantsRefresh(request) }))
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
/** `?refresh=1` forces the catalog to hit the adapters again instead of the cache. */
|
|
1058
|
+
function wantsRefresh(request) {
|
|
1059
|
+
try {
|
|
1060
|
+
return (new URL(request.url ?? '/', 'http://127.0.0.1').searchParams.get('refresh') ?? '') === '1'
|
|
1061
|
+
} catch {
|
|
1062
|
+
return false
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
const USAGE_CACHE_LIMIT = 50
|
|
1067
|
+
const usageCache = new Map()
|
|
1068
|
+
|
|
1069
|
+
/** Cache one session's folded usage until its event tail or pricing changes. */
|
|
1070
|
+
function usageCacheKey(sessionId, lastSeq, savedAt) {
|
|
1071
|
+
return `${sessionId}\u0000${lastSeq ?? 'none'}\u0000${savedAt ?? 'none'}`
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
function cachedUsage(sessionId, lastSeq, savedAt, fold) {
|
|
1075
|
+
const key = usageCacheKey(sessionId, lastSeq, savedAt)
|
|
1076
|
+
const hit = usageCache.get(key)
|
|
1077
|
+
if (hit !== undefined) {
|
|
1078
|
+
usageCache.delete(key)
|
|
1079
|
+
usageCache.set(key, hit)
|
|
1080
|
+
return hit
|
|
1081
|
+
}
|
|
1082
|
+
const value = fold()
|
|
1083
|
+
usageCache.set(key, value)
|
|
1084
|
+
while (usageCache.size > USAGE_CACHE_LIMIT) {
|
|
1085
|
+
usageCache.delete(usageCache.keys().next().value)
|
|
1086
|
+
}
|
|
1087
|
+
return value
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
async function serveUsage(ctx, request, response) {
|
|
1091
|
+
if (request.method !== 'GET') return sendJson(response, 405, { ok: false, code: 'method-not-allowed', message: 'only GET is accepted' })
|
|
1092
|
+
if (!sameOrigin(request)) return sendJson(response, 403, { ok: false, code: 'untrusted-origin', message: 'the request did not come from this Harness page' })
|
|
1093
|
+
let sessionId = ''
|
|
1094
|
+
try {
|
|
1095
|
+
sessionId = (new URL(request.url ?? '/', 'http://127.0.0.1').searchParams.get('sessionId') ?? '').trim()
|
|
1096
|
+
} catch {
|
|
1097
|
+
return sendJson(response, 400, { ok: false, code: 'bad-request', message: 'sessionId is missing or malformed' })
|
|
1098
|
+
}
|
|
1099
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/.test(sessionId)) {
|
|
1100
|
+
return sendJson(response, 400, { ok: false, code: 'bad-request', message: 'sessionId is missing or malformed' })
|
|
1101
|
+
}
|
|
1102
|
+
const sessions = ctx.get('sessions')
|
|
1103
|
+
const session = sessions?.get?.(sessionId)
|
|
1104
|
+
if (session === undefined) {
|
|
1105
|
+
return sendJson(response, 404, { ok: false, code: 'session-not-found', message: 'this Session is not loaded in the Host' })
|
|
1106
|
+
}
|
|
1107
|
+
const state = await loadState(ctx)
|
|
1108
|
+
const events = session.ownEvents()
|
|
1109
|
+
const lastSeq = Array.isArray(events) && events.length > 0 ? events[events.length - 1]?.seq : undefined
|
|
1110
|
+
const usage = cachedUsage(sessionId, lastSeq, state.savedAt, () => {
|
|
1111
|
+
const pricingByKey = new Map()
|
|
1112
|
+
for (const [key, value] of Object.entries(state.pricing)) pricingByKey.set(key, publicPricing(value))
|
|
1113
|
+
const orderKeys = new Set(state.order.map((entry) => modelKey(entry.provider, entry.model)))
|
|
1114
|
+
return computeSessionUsage(events, pricingByKey, new Map(), orderKeys)
|
|
1115
|
+
})
|
|
1116
|
+
sendJson(response, 200, {
|
|
1117
|
+
ok: true,
|
|
1118
|
+
sessionId,
|
|
1119
|
+
savedAt: state.savedAt,
|
|
1120
|
+
stateVersion: STATE_VERSION,
|
|
1121
|
+
...usage,
|
|
1122
|
+
})
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
async function serveSave(ctx, request, response) {
|
|
1126
|
+
if (request.method !== 'POST') return sendJson(response, 405, { ok: false, code: 'method-not-allowed', message: 'only POST is accepted' })
|
|
1127
|
+
if (!sameOrigin(request)) return sendJson(response, 403, { ok: false, code: 'untrusted-origin', message: 'the request did not come from this Harness page' })
|
|
1128
|
+
let body
|
|
1129
|
+
try {
|
|
1130
|
+
body = JSON.parse(await readBody(request))
|
|
1131
|
+
} catch (error) {
|
|
1132
|
+
return sendJson(response, error instanceof BillingRefusal ? error.status : 400, {
|
|
1133
|
+
ok: false,
|
|
1134
|
+
code: error instanceof BillingRefusal ? error.code : 'bad-request',
|
|
1135
|
+
message: messageOf(error),
|
|
1136
|
+
})
|
|
1137
|
+
}
|
|
1138
|
+
const envelope = await saveOrderAndPrices(ctx, body)
|
|
1139
|
+
sendJson(response, 200, { ok: true, ...envelope })
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
/** Select a model for one Session through the official session controller. */
|
|
1143
|
+
async function serveSelect(ctx, request, response) {
|
|
1144
|
+
if (request.method !== 'POST') return sendJson(response, 405, { ok: false, code: 'method-not-allowed', message: 'only POST is accepted' })
|
|
1145
|
+
if (!sameOrigin(request)) return sendJson(response, 403, { ok: false, code: 'untrusted-origin', message: 'the request did not come from this Harness page' })
|
|
1146
|
+
let body
|
|
1147
|
+
try {
|
|
1148
|
+
body = JSON.parse(await readBody(request))
|
|
1149
|
+
} catch (error) {
|
|
1150
|
+
return sendJson(response, error instanceof BillingRefusal ? error.status : 400, {
|
|
1151
|
+
ok: false,
|
|
1152
|
+
code: error instanceof BillingRefusal ? error.code : 'bad-request',
|
|
1153
|
+
message: messageOf(error),
|
|
1154
|
+
})
|
|
1155
|
+
}
|
|
1156
|
+
const sessionId = typeof body?.sessionId === 'string' ? body.sessionId.trim() : ''
|
|
1157
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/.test(sessionId)) {
|
|
1158
|
+
return sendJson(response, 400, { ok: false, code: 'bad-request', message: 'sessionId is missing or malformed' })
|
|
1159
|
+
}
|
|
1160
|
+
if (!validRoute(body?.provider, body?.model)) {
|
|
1161
|
+
return sendJson(response, 400, { ok: false, code: 'bad-request', message: 'provider and model are required' })
|
|
1162
|
+
}
|
|
1163
|
+
const controller = ctx.get('sessionController')
|
|
1164
|
+
if (controller === undefined) {
|
|
1165
|
+
return sendJson(response, 503, { ok: false, code: 'unavailable', message: 'the session controller is not mounted' })
|
|
1166
|
+
}
|
|
1167
|
+
const reasoningEffort = typeof body.reasoningEffort === 'string' && body.reasoningEffort !== ''
|
|
1168
|
+
? body.reasoningEffort
|
|
1169
|
+
: undefined
|
|
1170
|
+
try {
|
|
1171
|
+
const selection = await controller.selectModel({
|
|
1172
|
+
sessionId,
|
|
1173
|
+
provider: body.provider.trim(),
|
|
1174
|
+
model: body.model.trim(),
|
|
1175
|
+
...reasoningEffort === undefined ? {} : { reasoningEffort },
|
|
1176
|
+
})
|
|
1177
|
+
sendJson(response, 200, { ok: true, selection })
|
|
1178
|
+
} catch (error) {
|
|
1179
|
+
sendJson(response, error instanceof BillingRefusal ? error.status : 400, {
|
|
1180
|
+
ok: false,
|
|
1181
|
+
code: error instanceof BillingRefusal ? error.code : 'select-failed',
|
|
1182
|
+
message: messageOf(error),
|
|
1183
|
+
})
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
function fail(ctx, response, error) {
|
|
1188
|
+
const refusal = error instanceof BillingRefusal
|
|
1189
|
+
? error
|
|
1190
|
+
: new BillingRefusal(500, 'internal', messageOf(error))
|
|
1191
|
+
ctx.logger?.warn?.(`[model-billing] ${refusal.code}: ${refusal.message}`)
|
|
1192
|
+
sendJson(response, refusal.status, { ok: false, code: refusal.code, message: refusal.message })
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
function sendJson(response, status, value) {
|
|
1196
|
+
if (response.headersSent) return
|
|
1197
|
+
response.writeHead(status, JSON_HEADERS)
|
|
1198
|
+
response.end(JSON.stringify(value))
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
/** Whether the request came from this Harness page rather than another site. */
|
|
1202
|
+
function sameOrigin(request) {
|
|
1203
|
+
const host = request.headers.host
|
|
1204
|
+
if (host !== undefined && !trustedAuthority(request, host)) return false
|
|
1205
|
+
if (request.headers['sec-fetch-site'] === 'cross-site') return false
|
|
1206
|
+
const origin = request.headers.origin
|
|
1207
|
+
if (origin === undefined) return true
|
|
1208
|
+
try {
|
|
1209
|
+
return new URL(origin).host === host
|
|
1210
|
+
} catch {
|
|
1211
|
+
return false
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
function loopbackAuthority(host) {
|
|
1216
|
+
const lower = host.toLowerCase()
|
|
1217
|
+
const name = lower.startsWith('[') ? lower.slice(0, lower.indexOf(']') + 1) : lower.split(':')[0]
|
|
1218
|
+
return name === '127.0.0.1' || name === 'localhost' || name === '[::1]'
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
/**
|
|
1222
|
+
* Loopback stays the default. A request whose Host header also matches the address
|
|
1223
|
+
* this socket is actually listening on is trusted, so a reverse proxy or a LAN
|
|
1224
|
+
* address works without letting a forged Host header through.
|
|
1225
|
+
*/
|
|
1226
|
+
function trustedAuthority(request, host) {
|
|
1227
|
+
if (loopbackAuthority(host)) return true
|
|
1228
|
+
const socket = request.socket
|
|
1229
|
+
const address = socket?.localAddress
|
|
1230
|
+
const port = socket?.localPort
|
|
1231
|
+
if (typeof address !== 'string' || address.length === 0) return false
|
|
1232
|
+
const local = authorityOf(address, port)
|
|
1233
|
+
return local !== null && host.toLowerCase() === local
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
/** Format a socket address the way an HTTP Host header spells it. */
|
|
1237
|
+
function authorityOf(address, port) {
|
|
1238
|
+
const name = address.includes(':') && !address.startsWith('[') ? `[${address}]` : address.toLowerCase()
|
|
1239
|
+
return typeof port === 'number' && port > 0 ? `${name}:${port}` : name
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
/** Read a size-capped JSON request body. */
|
|
1243
|
+
async function readBody(request) {
|
|
1244
|
+
let size = 0
|
|
1245
|
+
const chunks = []
|
|
1246
|
+
for await (const chunk of request) {
|
|
1247
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
|
1248
|
+
size += buffer.length
|
|
1249
|
+
if (size > MAX_BODY_BYTES) {
|
|
1250
|
+
throw new BillingRefusal(413, 'body-too-large', `the request body exceeds ${MAX_BODY_BYTES} bytes`)
|
|
1251
|
+
}
|
|
1252
|
+
chunks.push(buffer)
|
|
1253
|
+
}
|
|
1254
|
+
return Buffer.concat(chunks).toString('utf8')
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
function messageOf(error) {
|
|
1258
|
+
return error instanceof Error ? error.message : String(error)
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
/** Pure helpers exported for the Host test run. */
|
|
1262
|
+
export const __test = {
|
|
1263
|
+
applySavedOrder,
|
|
1264
|
+
authorityOf,
|
|
1265
|
+
builtinRatesFor,
|
|
1266
|
+
buildCatalog,
|
|
1267
|
+
catalogPricing,
|
|
1268
|
+
computeSessionUsage,
|
|
1269
|
+
deepseekBuiltinPricing,
|
|
1270
|
+
deepseekPeakAt,
|
|
1271
|
+
deepseekPricingAt,
|
|
1272
|
+
defaultPricing,
|
|
1273
|
+
estimateAttemptCost,
|
|
1274
|
+
isDeepSeekRoute,
|
|
1275
|
+
isOfficialDeepSeekProvider,
|
|
1276
|
+
isStateTooNew,
|
|
1277
|
+
loadState,
|
|
1278
|
+
modelKey,
|
|
1279
|
+
normalizeDisplay,
|
|
1280
|
+
normalizeState,
|
|
1281
|
+
normalizeUsage,
|
|
1282
|
+
priceSetComplete,
|
|
1283
|
+
publicPricing,
|
|
1284
|
+
resolveRoutePricing,
|
|
1285
|
+
saveOrderAndPrices,
|
|
1286
|
+
statePath,
|
|
1287
|
+
trustedAuthority,
|
|
1288
|
+
validRoute,
|
|
1289
|
+
}
|