dsh-workbuddy-xdpool 0.2.0 → 0.3.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 +35 -0
- package/README.en.md +11 -0
- package/README.md +11 -0
- package/assets/region-tabs.png +0 -0
- package/lib/bin.js +1335 -1294
- package/lib/client.js +604 -219
- package/lib/index.d.ts +716 -629
- package/lib/index.js +2139 -1920
- package/package.json +109 -109
- package/screenshots.json +1 -0
package/lib/index.js
CHANGED
|
@@ -1,1920 +1,2139 @@
|
|
|
1
|
-
import { dirname, join, resolve } from "node:path";
|
|
2
|
-
import z from "@deepseek-ai/schemastery";
|
|
3
|
-
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
4
|
-
import { readFile, readdir } from "node:fs/promises";
|
|
5
|
-
import { homedir } from "node:os";
|
|
6
|
-
import { createProvider } from "@earendil-works/pi-ai";
|
|
7
|
-
import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
|
|
8
|
-
import { resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
|
|
9
|
-
import { PiAiAdapter } from "@deepseek-ai/dsh-llm-pi-ai";
|
|
10
|
-
import { createServer } from "node:http";
|
|
11
|
-
import { Readable } from "node:stream";
|
|
12
|
-
//#region src/
|
|
13
|
-
/**
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
*/
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
if (
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
function
|
|
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
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
}
|
|
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
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
if (
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
const
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
const now = Date
|
|
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
|
-
|
|
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
|
-
return
|
|
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
|
-
const
|
|
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
|
-
if (
|
|
534
|
-
|
|
535
|
-
"
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
const
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
*
|
|
560
|
-
|
|
561
|
-
function
|
|
562
|
-
const
|
|
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
|
-
|
|
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
|
-
id,
|
|
837
|
-
name,
|
|
838
|
-
contextWindow:
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
}
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
if (
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
}
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
};
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
if (
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
}
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
});
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
async function
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
if (
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
}
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
for (const account of
|
|
1443
|
-
const
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
}
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
}
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
}
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
}
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
const
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
}
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
}
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
/**
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
function
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
}
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
}
|
|
1919
|
-
|
|
1920
|
-
|
|
1
|
+
import { dirname, join, resolve } from "node:path";
|
|
2
|
+
import z from "@deepseek-ai/schemastery";
|
|
3
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
4
|
+
import { readFile, readdir } from "node:fs/promises";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { createProvider } from "@earendil-works/pi-ai";
|
|
7
|
+
import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
|
|
8
|
+
import { resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
|
|
9
|
+
import { PiAiAdapter } from "@deepseek-ai/dsh-llm-pi-ai";
|
|
10
|
+
import { createServer } from "node:http";
|
|
11
|
+
import { Readable } from "node:stream";
|
|
12
|
+
//#region src/upstream.ts
|
|
13
|
+
/** CN chat base per dingminhua's on-machine probe (HTTP 200 for chat/models). */
|
|
14
|
+
const CN_CHAT_BASE = "https://copilot.tencent.com";
|
|
15
|
+
/** Billing/console base — `www.codebuddy.cn` is the billing origin. */
|
|
16
|
+
const CN_BILLING_BASE = "https://www.codebuddy.cn";
|
|
17
|
+
/** Global base for `workbuddy.ai` logins. */
|
|
18
|
+
const GLOBAL_BASE = "https://www.workbuddy.ai";
|
|
19
|
+
/** Client UA the desktop CLI uses. */
|
|
20
|
+
const CLIENT_UA = "CLI/2.63.2 CodeBuddy/2.63.2";
|
|
21
|
+
const JSON_TIMEOUT_MS = 3e4;
|
|
22
|
+
const ERROR_BODY_LIMIT = 4096;
|
|
23
|
+
/** Insufficient-credit markers, ASCII lowercase plus the original Chinese. */
|
|
24
|
+
const HARD_CREDIT_MARKERS = [
|
|
25
|
+
"insufficient credit",
|
|
26
|
+
"no credit",
|
|
27
|
+
"credit exhausted",
|
|
28
|
+
"out of credit",
|
|
29
|
+
"quota exceeded",
|
|
30
|
+
"quota exhaust",
|
|
31
|
+
"payment required",
|
|
32
|
+
"credit not enough",
|
|
33
|
+
"not enough credit",
|
|
34
|
+
"积分不足",
|
|
35
|
+
"额度不足",
|
|
36
|
+
"余额不足",
|
|
37
|
+
"积分用完",
|
|
38
|
+
"额度用尽",
|
|
39
|
+
"没有积分"
|
|
40
|
+
];
|
|
41
|
+
/** Session-invalidation markers that mean "sign in again in the WorkBuddy app". */
|
|
42
|
+
const SESSION_DEAD_MARKERS = ["Offline user session not found", "12153"];
|
|
43
|
+
function regionOf(domain) {
|
|
44
|
+
const lowered = domain.trim().toLowerCase();
|
|
45
|
+
if (lowered.endsWith(".workbuddy.ai") || lowered.endsWith(".workbuddy.cc")) return "global";
|
|
46
|
+
if (lowered === "workbuddy.ai" || lowered === "workbuddy.cc") return "global";
|
|
47
|
+
return "cn";
|
|
48
|
+
}
|
|
49
|
+
function chatBase(credential) {
|
|
50
|
+
return regionOf(credential.domain) === "global" ? GLOBAL_BASE : CN_CHAT_BASE;
|
|
51
|
+
}
|
|
52
|
+
function billingBase(credential) {
|
|
53
|
+
return regionOf(credential.domain) === "global" ? GLOBAL_BASE : CN_BILLING_BASE;
|
|
54
|
+
}
|
|
55
|
+
function originReferer(credential) {
|
|
56
|
+
return regionOf(credential.domain) === "global" ? GLOBAL_BASE : CN_BILLING_BASE;
|
|
57
|
+
}
|
|
58
|
+
/** Headers every upstream request shares. */
|
|
59
|
+
function commonHeaders(credential) {
|
|
60
|
+
return {
|
|
61
|
+
"Accept": "application/json, text/plain, */*",
|
|
62
|
+
"X-Requested-With": "XMLHttpRequest",
|
|
63
|
+
"Origin": originReferer(credential),
|
|
64
|
+
"Referer": `${originReferer(credential)}/`,
|
|
65
|
+
"User-Agent": CLIENT_UA
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/** Chat request headers, including the X-No-* conventions the official CLI uses. */
|
|
69
|
+
function chatHeaders(credential) {
|
|
70
|
+
return {
|
|
71
|
+
...commonHeaders(credential),
|
|
72
|
+
"Content-Type": "application/json",
|
|
73
|
+
"Authorization": `Bearer ${credential.accessToken}`,
|
|
74
|
+
...credential.uid === "" || credential.uid === void 0 ? { "X-No-User-Id": "1" } : { "X-User-Id": credential.uid },
|
|
75
|
+
...credential.enterpriseId === void 0 || credential.enterpriseId === "" ? { "X-No-Enterprise-Id": "1" } : { "X-Enterprise-Id": credential.enterpriseId },
|
|
76
|
+
...credential.domain === "" ? { "X-No-Department-Info": "1" } : { "X-Domain": credential.domain },
|
|
77
|
+
"X-Product": "SaaS"
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/** Refresh-endpoint headers; X-Refresh-Token appears here and nowhere else. */
|
|
81
|
+
function refreshHeaders(credential) {
|
|
82
|
+
const headers = {
|
|
83
|
+
...commonHeaders(credential),
|
|
84
|
+
"X-Refresh-Token": credential.refreshToken,
|
|
85
|
+
"X-Auth-Refresh-Source": "workbuddy"
|
|
86
|
+
};
|
|
87
|
+
if (credential.enterpriseId !== void 0 && credential.enterpriseId !== "") headers["X-Enterprise-Id"] = credential.enterpriseId;
|
|
88
|
+
return headers;
|
|
89
|
+
}
|
|
90
|
+
/** Billing request headers. */
|
|
91
|
+
function billingHeaders(credential) {
|
|
92
|
+
const headers = {
|
|
93
|
+
"Authorization": `Bearer ${credential.accessToken}`,
|
|
94
|
+
"Accept": "application/json",
|
|
95
|
+
"Content-Type": "application/json"
|
|
96
|
+
};
|
|
97
|
+
if (credential.uid !== "" && credential.uid !== void 0) headers["X-User-Id"] = credential.uid;
|
|
98
|
+
if (credential.enterpriseId !== void 0 && credential.enterpriseId !== "") {
|
|
99
|
+
headers["X-Enterprise-Id"] = credential.enterpriseId;
|
|
100
|
+
headers["X-Tenant-Id"] = credential.enterpriseId;
|
|
101
|
+
}
|
|
102
|
+
if (credential.domain !== "") headers["X-Domain"] = credential.domain;
|
|
103
|
+
return headers;
|
|
104
|
+
}
|
|
105
|
+
async function readEnvelope(response) {
|
|
106
|
+
const text = await response.text();
|
|
107
|
+
let parsed;
|
|
108
|
+
try {
|
|
109
|
+
parsed = JSON.parse(text);
|
|
110
|
+
} catch {
|
|
111
|
+
throw new Error(`workbuddy upstream returned non-JSON (http ${response.status}): ${text.slice(0, 160)}`);
|
|
112
|
+
}
|
|
113
|
+
if (typeof parsed !== "object" || parsed === null) throw new Error(`workbuddy upstream returned an unexpected document (http ${response.status})`);
|
|
114
|
+
const document = parsed;
|
|
115
|
+
return {
|
|
116
|
+
code: typeof document["code"] === "number" ? document["code"] : 0,
|
|
117
|
+
msg: typeof document["msg"] === "string" ? document["msg"] : "",
|
|
118
|
+
data: "data" in document ? document["data"] : void 0
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
/** Fail an envelope whose business code is non-zero, classified like HTTP errors. */
|
|
122
|
+
function envelopeError(status, envelope) {
|
|
123
|
+
const kind = classifyUpstreamError(status, envelope.msg);
|
|
124
|
+
return /* @__PURE__ */ new Error(`workbuddy upstream ${kind} (http ${status}): ${envelope.msg.slice(0, 160)}`);
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Classify an upstream failure from its HTTP status and body excerpt.
|
|
128
|
+
* Body markers win over status, because the upstream reuses 400/200 for
|
|
129
|
+
* several distinct conditions.
|
|
130
|
+
*/
|
|
131
|
+
function classifyUpstreamError(status, body) {
|
|
132
|
+
if (status === 402) return "hard_credit";
|
|
133
|
+
const lower = body.toLowerCase();
|
|
134
|
+
for (const marker of HARD_CREDIT_MARKERS) if (lower.includes(marker.toLowerCase()) || body.includes(marker)) return "hard_credit";
|
|
135
|
+
for (const marker of SESSION_DEAD_MARKERS) if (body.includes(marker)) return "session_dead";
|
|
136
|
+
if (status === 429) return "soft_rate";
|
|
137
|
+
if (body.includes("soft_rate") || body.includes("\"code\":6004") || body.includes("频率限制")) return "soft_rate";
|
|
138
|
+
if (status === 404) return "not_found";
|
|
139
|
+
if (status >= 500) return "server";
|
|
140
|
+
return "client";
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Parse the reset time the upstream reports for a rate limit, when present.
|
|
144
|
+
* Recognises an epoch-millisecond field and the Chinese-localised sentence
|
|
145
|
+
* form, so the pool can resume exactly when the window reopens.
|
|
146
|
+
*/
|
|
147
|
+
function parseRateLimitReset(body) {
|
|
148
|
+
const epochMs = /"(?:resetAt|reset_at|resetTime|reset_time)"\s*:\s*(\d{13})/.exec(body);
|
|
149
|
+
if (epochMs !== null) return Number(epochMs[1]);
|
|
150
|
+
const localized = /将在\s*([0-9]{4}-[0-9]{2}-[0-9]{2}[ T][0-9]{2}:[0-9]{2}:[0-9]{2})/.exec(body);
|
|
151
|
+
if (localized !== null) {
|
|
152
|
+
const parsed = Date.parse(localized[1].replace(" ", "T"));
|
|
153
|
+
if (!Number.isNaN(parsed)) return parsed;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/** Parse the upstream's `credits` string into a multiplier. */
|
|
157
|
+
function parseCreditMultiplier(value) {
|
|
158
|
+
if (typeof value !== "string") return void 0;
|
|
159
|
+
const match = /x\s*([0-9]*\.?[0-9]+)/iu.exec(value);
|
|
160
|
+
if (match === null) return void 0;
|
|
161
|
+
const parsed = Number(match[1]);
|
|
162
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : void 0;
|
|
163
|
+
}
|
|
164
|
+
/** Parse the upstream's `reasoning` object; unknown shapes degrade to `{}`. */
|
|
165
|
+
function parseReasoning(value) {
|
|
166
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
|
|
167
|
+
const raw = value;
|
|
168
|
+
const supportedEfforts = Array.isArray(raw["supportedEfforts"]) ? raw["supportedEfforts"].filter((effort) => typeof effort === "string") : void 0;
|
|
169
|
+
const defaultEffort = typeof raw["defaultEffort"] === "string" ? raw["defaultEffort"] : void 0;
|
|
170
|
+
const canDisableThinking = typeof raw["canDisableThinking"] === "boolean" ? raw["canDisableThinking"] : void 0;
|
|
171
|
+
if (supportedEfforts === void 0 && defaultEffort === void 0 && canDisableThinking === void 0) return;
|
|
172
|
+
return {
|
|
173
|
+
...supportedEfforts === void 0 || supportedEfforts.length === 0 ? {} : { supportedEfforts },
|
|
174
|
+
...defaultEffort === void 0 ? {} : { defaultEffort },
|
|
175
|
+
...canDisableThinking === void 0 ? {} : { canDisableThinking }
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
/** Parse one catalog entry; entries without usable token limits are dropped. */
|
|
179
|
+
function parseUpstreamModel(value) {
|
|
180
|
+
if (typeof value !== "object" || value === null) return void 0;
|
|
181
|
+
const raw = value;
|
|
182
|
+
const id = typeof raw["id"] === "string" ? raw["id"] : "";
|
|
183
|
+
if (id === "" || raw["disabled"] === true) return void 0;
|
|
184
|
+
const input = typeof raw["maxInputTokens"] === "number" ? raw["maxInputTokens"] : 0;
|
|
185
|
+
const output = typeof raw["maxOutputTokens"] === "number" ? raw["maxOutputTokens"] : 0;
|
|
186
|
+
if (input <= 0 || output <= 0) return void 0;
|
|
187
|
+
const name = typeof raw["name"] === "string" && raw["name"] !== "" ? raw["name"] : id;
|
|
188
|
+
const descriptionZh = typeof raw["descriptionZh"] === "string" && raw["descriptionZh"] !== "" ? raw["descriptionZh"] : void 0;
|
|
189
|
+
const descriptionEn = typeof raw["descriptionEn"] === "string" && raw["descriptionEn"] !== "" ? raw["descriptionEn"] : void 0;
|
|
190
|
+
const creditMultiplier = parseCreditMultiplier(raw["credits"]);
|
|
191
|
+
const reasoning = parseReasoning(raw["reasoning"]);
|
|
192
|
+
const supportsToolCall = typeof raw["supportsToolCall"] === "boolean" ? raw["supportsToolCall"] : void 0;
|
|
193
|
+
return {
|
|
194
|
+
id,
|
|
195
|
+
name,
|
|
196
|
+
contextWindow: input,
|
|
197
|
+
maxTokens: output,
|
|
198
|
+
...creditMultiplier === void 0 ? {} : { creditMultiplier },
|
|
199
|
+
...reasoning === void 0 ? {} : { reasoning },
|
|
200
|
+
...descriptionZh === void 0 ? {} : { descriptionZh },
|
|
201
|
+
...descriptionEn === void 0 ? {} : { descriptionEn },
|
|
202
|
+
...supportsToolCall === void 0 ? {} : { supportsToolCall }
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
var WorkBuddyUpstreamClient = class {
|
|
206
|
+
fetchImpl;
|
|
207
|
+
clientVersion;
|
|
208
|
+
constructor(options = {}) {
|
|
209
|
+
this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
210
|
+
this.clientVersion = options.clientVersion ?? "2.0.4";
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Normalize an OpenAI chat-completions body for the WorkBuddy upstream:
|
|
214
|
+
* force `stream: true` (the upstream rejects non-streaming), convert the
|
|
215
|
+
* DSH `developer` role into `system` (upstream rejects `developer` with
|
|
216
|
+
* business code 11128), and flatten `tool_choice` into its string form.
|
|
217
|
+
*/
|
|
218
|
+
prepareChatBody(raw) {
|
|
219
|
+
let body;
|
|
220
|
+
try {
|
|
221
|
+
body = JSON.parse(raw);
|
|
222
|
+
} catch {
|
|
223
|
+
return raw;
|
|
224
|
+
}
|
|
225
|
+
if (typeof body !== "object" || body === null || Array.isArray(body)) return raw;
|
|
226
|
+
const obj = body;
|
|
227
|
+
obj["stream"] = true;
|
|
228
|
+
delete obj["stream_options"];
|
|
229
|
+
if (Array.isArray(obj["messages"])) for (const value of obj["messages"]) {
|
|
230
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) continue;
|
|
231
|
+
const message = value;
|
|
232
|
+
if (message["role"] === "developer") message["role"] = "system";
|
|
233
|
+
}
|
|
234
|
+
const choice = obj["tool_choice"];
|
|
235
|
+
if (typeof choice === "string") {
|
|
236
|
+
if (choice.trim().toLowerCase() === "none") {
|
|
237
|
+
delete obj["tool_choice"];
|
|
238
|
+
delete obj["tools"];
|
|
239
|
+
delete obj["functions"];
|
|
240
|
+
}
|
|
241
|
+
} else if (typeof choice === "object" && choice !== null && !Array.isArray(choice)) {
|
|
242
|
+
const wrapped = choice;
|
|
243
|
+
const type = typeof wrapped["type"] === "string" ? wrapped["type"].trim().toLowerCase() : "";
|
|
244
|
+
if (type === "none") {
|
|
245
|
+
delete obj["tool_choice"];
|
|
246
|
+
delete obj["tools"];
|
|
247
|
+
delete obj["functions"];
|
|
248
|
+
} else if (type === "auto" || type === "required") obj["tool_choice"] = type;
|
|
249
|
+
else if (type === "function") {
|
|
250
|
+
const fn = typeof wrapped["function"] === "object" && wrapped["function"] !== null ? wrapped["function"] : void 0;
|
|
251
|
+
let name = typeof fn?.["name"] === "string" ? fn["name"] : "";
|
|
252
|
+
if (name === "" && typeof wrapped["name"] === "string") name = wrapped["name"];
|
|
253
|
+
obj["tool_choice"] = name.trim() !== "" ? name.trim() : "auto";
|
|
254
|
+
} else delete obj["tool_choice"];
|
|
255
|
+
}
|
|
256
|
+
return JSON.stringify(obj);
|
|
257
|
+
}
|
|
258
|
+
/** Forward one chat completion. Never throws for upstream failures. */
|
|
259
|
+
async chatStream(credential, prepared, signal) {
|
|
260
|
+
let response;
|
|
261
|
+
try {
|
|
262
|
+
response = await this.fetchImpl(`${chatBase(credential)}/v2/chat/completions`, {
|
|
263
|
+
method: "POST",
|
|
264
|
+
headers: chatHeaders(credential),
|
|
265
|
+
body: prepared,
|
|
266
|
+
...signal === void 0 ? {} : { signal }
|
|
267
|
+
});
|
|
268
|
+
} catch (error) {
|
|
269
|
+
return {
|
|
270
|
+
ok: false,
|
|
271
|
+
kind: "server",
|
|
272
|
+
status: 0,
|
|
273
|
+
message: `transport error: ${String(error)}`
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
if (response.ok) return {
|
|
277
|
+
ok: true,
|
|
278
|
+
response
|
|
279
|
+
};
|
|
280
|
+
const text = (await response.text().catch(() => "")).slice(0, ERROR_BODY_LIMIT);
|
|
281
|
+
return {
|
|
282
|
+
ok: false,
|
|
283
|
+
kind: classifyUpstreamError(response.status, text),
|
|
284
|
+
status: response.status,
|
|
285
|
+
message: text
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
/** POST the token-refresh endpoint; the caller merges the outcome. */
|
|
289
|
+
async refreshToken(credential) {
|
|
290
|
+
const response = await this.fetchImpl(`${chatBase(credential)}/v2/plugin/auth/token/refresh`, {
|
|
291
|
+
method: "POST",
|
|
292
|
+
headers: refreshHeaders(credential),
|
|
293
|
+
signal: AbortSignal.timeout(JSON_TIMEOUT_MS)
|
|
294
|
+
});
|
|
295
|
+
const envelope = await readEnvelope(response);
|
|
296
|
+
if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope);
|
|
297
|
+
const data = typeof envelope.data === "object" && envelope.data !== null ? envelope.data : {};
|
|
298
|
+
const accessToken = typeof data["accessToken"] === "string" ? data["accessToken"] : "";
|
|
299
|
+
if (accessToken === "") throw new Error("workbuddy token refresh returned no accessToken; sign in again in the WorkBuddy app");
|
|
300
|
+
const outcome = { accessToken };
|
|
301
|
+
if (typeof data["refreshToken"] === "string" && data["refreshToken"] !== "") outcome.refreshToken = data["refreshToken"];
|
|
302
|
+
if (typeof data["expiresIn"] === "number" && data["expiresIn"] > 0) outcome.expiresInSec = data["expiresIn"];
|
|
303
|
+
if (typeof data["domain"] === "string" && data["domain"] !== "") outcome.domain = data["domain"];
|
|
304
|
+
return outcome;
|
|
305
|
+
}
|
|
306
|
+
/** GET the personal model catalog, keeping the `cli` agent's models only. */
|
|
307
|
+
async fetchModels(credential, signal) {
|
|
308
|
+
const response = await this.fetchImpl(`${chatBase(credential)}/console/enterprises/personal/models`, {
|
|
309
|
+
headers: {
|
|
310
|
+
"Authorization": `Bearer ${credential.accessToken}`,
|
|
311
|
+
"Accept": "application/json",
|
|
312
|
+
"Origin": originReferer(credential),
|
|
313
|
+
"Referer": `${originReferer(credential)}/`,
|
|
314
|
+
"User-Agent": CLIENT_UA
|
|
315
|
+
},
|
|
316
|
+
...signal === void 0 ? {} : { signal }
|
|
317
|
+
});
|
|
318
|
+
const envelope = await readEnvelope(response);
|
|
319
|
+
if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope);
|
|
320
|
+
const data = typeof envelope.data === "object" && envelope.data !== null ? envelope.data : {};
|
|
321
|
+
const rawModels = Array.isArray(data["models"]) ? data["models"] : [];
|
|
322
|
+
const agents = Array.isArray(data["agents"]) ? data["agents"] : [];
|
|
323
|
+
let cliIds;
|
|
324
|
+
for (const agent of agents) if (typeof agent === "object" && agent !== null) {
|
|
325
|
+
const wrapped = agent;
|
|
326
|
+
if (wrapped["name"] === "cli" && Array.isArray(wrapped["models"])) {
|
|
327
|
+
cliIds = wrapped["models"].filter((id) => typeof id === "string");
|
|
328
|
+
break;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
const byId = /* @__PURE__ */ new Map();
|
|
332
|
+
for (const model of rawModels) {
|
|
333
|
+
const parsed = parseUpstreamModel(model);
|
|
334
|
+
if (parsed !== void 0) byId.set(parsed.id, parsed);
|
|
335
|
+
}
|
|
336
|
+
const models = (cliIds !== void 0 && cliIds.length > 0 ? cliIds : [...byId.keys()]).map((id) => byId.get(id)).filter((model) => model !== void 0);
|
|
337
|
+
if (models.length === 0) throw new Error("workbuddy model catalog resolved to an empty list");
|
|
338
|
+
return models;
|
|
339
|
+
}
|
|
340
|
+
/** Read-only credits query, aggregated by package. Does not consume credits. */
|
|
341
|
+
async fetchCredits(credential) {
|
|
342
|
+
const now = /* @__PURE__ */ new Date();
|
|
343
|
+
const fmt = (date) => {
|
|
344
|
+
const p = (n) => n.toString().padStart(2, "0");
|
|
345
|
+
return `${date.getFullYear()}-${p(date.getMonth() + 1)}-${p(date.getDate())} ${p(date.getHours())}:${p(date.getMinutes())}:${p(date.getSeconds())}`;
|
|
346
|
+
};
|
|
347
|
+
const response = await this.fetchImpl(`${billingBase(credential)}/v2/billing/meter/get-user-resource`, {
|
|
348
|
+
method: "POST",
|
|
349
|
+
headers: billingHeaders(credential),
|
|
350
|
+
body: JSON.stringify({
|
|
351
|
+
PageNumber: 1,
|
|
352
|
+
PageSize: 100,
|
|
353
|
+
ProductCode: "p_tcaca",
|
|
354
|
+
Status: [0, 3],
|
|
355
|
+
PackageEndTimeRangeBegin: fmt(now),
|
|
356
|
+
PackageEndTimeRangeEnd: fmt(new Date(now.getTime() + 3185136e6))
|
|
357
|
+
}),
|
|
358
|
+
signal: AbortSignal.timeout(JSON_TIMEOUT_MS)
|
|
359
|
+
});
|
|
360
|
+
const envelope = await readEnvelope(response);
|
|
361
|
+
if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope);
|
|
362
|
+
const wrapper = typeof envelope.data === "object" && envelope.data !== null ? envelope.data : {};
|
|
363
|
+
const data = typeof wrapper["Response"] === "object" && wrapper["Response"] !== null ? wrapper["Response"] : {};
|
|
364
|
+
const inner = typeof data["Data"] === "object" && data["Data"] !== null ? data["Data"] : {};
|
|
365
|
+
const rawAccounts = Array.isArray(inner["Accounts"]) ? inner["Accounts"] : [];
|
|
366
|
+
let total = 0;
|
|
367
|
+
let nearestExpiryMs;
|
|
368
|
+
let expiringSoon = 0;
|
|
369
|
+
const SOON_MS = 2592e5;
|
|
370
|
+
const parseDate = (raw) => {
|
|
371
|
+
if (typeof raw === "number" && raw > 0xe8d4a51000) return raw;
|
|
372
|
+
if (typeof raw === "string" && raw !== "") {
|
|
373
|
+
const parsed = Date.parse(raw);
|
|
374
|
+
if (!Number.isNaN(parsed)) return parsed;
|
|
375
|
+
}
|
|
376
|
+
};
|
|
377
|
+
const packages = [];
|
|
378
|
+
for (const raw of rawAccounts) {
|
|
379
|
+
if (typeof raw !== "object" || raw === null) continue;
|
|
380
|
+
const account = raw;
|
|
381
|
+
const num = (key) => typeof account[key] === "number" ? account[key] : 0;
|
|
382
|
+
const monthly = num("CapacityType") === 4;
|
|
383
|
+
const size = monthly ? num("CycleCapacitySize") : num("CapacitySize");
|
|
384
|
+
const remain = monthly ? num("CycleCapacityRemain") : num("CapacityRemain");
|
|
385
|
+
const capped = remain < 0 ? 0 : remain;
|
|
386
|
+
const cycleEndMs = parseDate(account["CycleEndTime"]);
|
|
387
|
+
const expiresAtMs = monthly ? void 0 : parseDate(account["ExpiredTime"]) ?? cycleEndMs;
|
|
388
|
+
const refreshAtMs = monthly ? cycleEndMs === void 0 ? void 0 : cycleEndMs + 1e3 : void 0;
|
|
389
|
+
if (!monthly && (capped <= 0 || expiresAtMs !== void 0 && expiresAtMs <= Date.now())) continue;
|
|
390
|
+
total += capped;
|
|
391
|
+
if (expiresAtMs !== void 0) {
|
|
392
|
+
if (nearestExpiryMs === void 0 || expiresAtMs < nearestExpiryMs) nearestExpiryMs = expiresAtMs;
|
|
393
|
+
if (expiresAtMs - Date.now() <= SOON_MS) expiringSoon += capped;
|
|
394
|
+
}
|
|
395
|
+
packages.push({
|
|
396
|
+
packageName: typeof account["PackageName"] === "string" ? account["PackageName"] : "(unnamed)",
|
|
397
|
+
remain: capped,
|
|
398
|
+
size,
|
|
399
|
+
monthly,
|
|
400
|
+
...refreshAtMs === void 0 ? {} : { refreshAtMs },
|
|
401
|
+
...expiresAtMs === void 0 ? {} : { expiresAtMs }
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
return {
|
|
405
|
+
total,
|
|
406
|
+
packages,
|
|
407
|
+
expiringSoon,
|
|
408
|
+
...nearestExpiryMs === void 0 ? {} : { nearestExpiryMs }
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
/** Query today's check-in status without changing account state. */
|
|
412
|
+
async fetchCheckinStatus(credential) {
|
|
413
|
+
const response = await this.fetchImpl(`${billingBase(credential)}/v2/billing/meter/checkin-activity-status`, {
|
|
414
|
+
method: "POST",
|
|
415
|
+
headers: billingHeaders(credential),
|
|
416
|
+
body: "{}",
|
|
417
|
+
signal: AbortSignal.timeout(JSON_TIMEOUT_MS)
|
|
418
|
+
});
|
|
419
|
+
const envelope = await readEnvelope(response);
|
|
420
|
+
if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope);
|
|
421
|
+
const data = typeof envelope.data === "object" && envelope.data !== null ? envelope.data : {};
|
|
422
|
+
const num = (key) => typeof data[key] === "number" ? data[key] : 0;
|
|
423
|
+
return {
|
|
424
|
+
active: data["active"] === true,
|
|
425
|
+
todayCheckedIn: data["today_checked_in"] === true,
|
|
426
|
+
streakDays: num("streak_days"),
|
|
427
|
+
dailyCredit: num("daily_credit"),
|
|
428
|
+
todayCredit: num("today_credit"),
|
|
429
|
+
isStreakDay: data["is_streak_day"] === true,
|
|
430
|
+
nextStreakDay: num("next_streak_day"),
|
|
431
|
+
streakBonusDays: num("streak_bonus_days"),
|
|
432
|
+
streakBonusCredit: num("streak_bonus_credit"),
|
|
433
|
+
...typeof data["claim_button_text"] === "string" && data["claim_button_text"] !== "" ? { claimButtonText: data["claim_button_text"] } : {}
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
/** Claim today's check-in reward. The browser route guards this mutation. */
|
|
437
|
+
async claimDailyCheckin(credential) {
|
|
438
|
+
const response = await this.fetchImpl(`${billingBase(credential)}/v2/billing/meter/daily-checkin`, {
|
|
439
|
+
method: "POST",
|
|
440
|
+
headers: billingHeaders(credential),
|
|
441
|
+
body: "{}",
|
|
442
|
+
signal: AbortSignal.timeout(JSON_TIMEOUT_MS)
|
|
443
|
+
});
|
|
444
|
+
const envelope = await readEnvelope(response);
|
|
445
|
+
if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope);
|
|
446
|
+
const data = typeof envelope.data === "object" && envelope.data !== null ? envelope.data : {};
|
|
447
|
+
const numberField = (key) => typeof data[key] === "number" ? data[key] : 0;
|
|
448
|
+
return {
|
|
449
|
+
credit: numberField("credit"),
|
|
450
|
+
streakDays: numberField("streak_days"),
|
|
451
|
+
isStreakDay: data["is_streak_day"] === true
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
/** Legacy thin wrapper kept for `status`/`doctor`: returns raw envelope data. */
|
|
455
|
+
async credits(credential) {
|
|
456
|
+
try {
|
|
457
|
+
return {
|
|
458
|
+
ok: true,
|
|
459
|
+
data: await this.fetchCredits(credential)
|
|
460
|
+
};
|
|
461
|
+
} catch (error) {
|
|
462
|
+
return {
|
|
463
|
+
ok: false,
|
|
464
|
+
message: String(error)
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
};
|
|
469
|
+
//#endregion
|
|
470
|
+
//#region src/accounts.ts
|
|
471
|
+
/**
|
|
472
|
+
* Account pool: discovers every WorkBuddy credential snapshot the desktop app
|
|
473
|
+
* has left on this machine and hands out one healthy account per request,
|
|
474
|
+
* rotating away from any account the upstream has rate-limited.
|
|
475
|
+
*
|
|
476
|
+
* Discovery is read-only: the desktop app's files are never written. Each
|
|
477
|
+
* account is keyed by its billing identity (`uin`, falling back to `uid`), so
|
|
478
|
+
* re-logging the same account refreshes in place instead of creating a duplicate.
|
|
479
|
+
*
|
|
480
|
+
* @module dsh-workbuddy-xdpool/accounts
|
|
481
|
+
*/
|
|
482
|
+
/** Live auth file name the WorkBuddy desktop app writes. */
|
|
483
|
+
const WORKBUDDY_LIVE_FILENAME = "workbuddy-desktop.info";
|
|
484
|
+
/** Snapshot files left behind by previous logins share this prefix. */
|
|
485
|
+
const SNAPSHOT_PREFIX = "workbuddy-desktop.";
|
|
486
|
+
/** Env override for the auth file or its directory. */
|
|
487
|
+
const WORKBUDDY_AUTH_FILE_ENV = "WORKBUDDY_AUTH_FILE";
|
|
488
|
+
function nonEmptyEnv(value) {
|
|
489
|
+
return typeof value === "string" && value.trim() !== "" ? value.trim() : void 0;
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* Platform-default directories holding the desktop app's auth files.
|
|
493
|
+
* Windows probes Local before Roaming; a redirected profile still resolves
|
|
494
|
+
* through the env location.
|
|
495
|
+
*/
|
|
496
|
+
function defaultDesktopAuthDirs(platform = process.platform, home = homedir(), env = process.env) {
|
|
497
|
+
if (platform === "darwin") return [join(home, "Library", "Application Support", "CodeBuddyExtension", "Data", "Public", "auth")];
|
|
498
|
+
if (platform === "win32") {
|
|
499
|
+
const local = nonEmptyEnv(env["LOCALAPPDATA"]) ?? join(home, "AppData", "Local");
|
|
500
|
+
const roaming = nonEmptyEnv(env["APPDATA"]) ?? join(home, "AppData", "Roaming");
|
|
501
|
+
return [join(local, "CodeBuddyExtension", "Data", "Public", "auth"), join(roaming, "CodeBuddyExtension", "Data", "Public", "auth")];
|
|
502
|
+
}
|
|
503
|
+
if (platform === "linux") {
|
|
504
|
+
const config = nonEmptyEnv(env["XDG_CONFIG_HOME"]) ?? join(home, ".config");
|
|
505
|
+
return [join(config, "CodeBuddyExtension", "Data", "Public", "auth")];
|
|
506
|
+
}
|
|
507
|
+
return [];
|
|
508
|
+
}
|
|
509
|
+
/** Normalize an expiry that may arrive in seconds or milliseconds. */
|
|
510
|
+
function expiryToMs(value) {
|
|
511
|
+
if (value <= 0) return 0;
|
|
512
|
+
return value > 0xe8d4a51000 ? value : value * 1e3;
|
|
513
|
+
}
|
|
514
|
+
function optionalString(value) {
|
|
515
|
+
return typeof value === "string" && value !== "" ? value : void 0;
|
|
516
|
+
}
|
|
517
|
+
/**
|
|
518
|
+
* Parse a WorkBuddy auth document. Accepts the nested desktop shape
|
|
519
|
+
* `{"auth":{...},"account":{...}}` and the flat panel shape; returns undefined
|
|
520
|
+
* when there is no usable access token.
|
|
521
|
+
*/
|
|
522
|
+
function parseWorkBuddyAuth(text, sourcePath) {
|
|
523
|
+
let parsed;
|
|
524
|
+
try {
|
|
525
|
+
parsed = JSON.parse(text);
|
|
526
|
+
} catch {
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
|
|
530
|
+
const document = parsed;
|
|
531
|
+
let auth;
|
|
532
|
+
let identity;
|
|
533
|
+
if (typeof document["auth"] === "object" && document["auth"] !== null) {
|
|
534
|
+
auth = document["auth"];
|
|
535
|
+
identity = typeof document["account"] === "object" && document["account"] !== null ? document["account"] : {};
|
|
536
|
+
} else {
|
|
537
|
+
auth = document;
|
|
538
|
+
identity = document;
|
|
539
|
+
}
|
|
540
|
+
const accessToken = typeof auth["accessToken"] === "string" ? auth["accessToken"] : "";
|
|
541
|
+
if (accessToken === "") return void 0;
|
|
542
|
+
const refreshExpiresAtMs = typeof auth["refreshExpiresAt"] === "number" ? expiryToMs(auth["refreshExpiresAt"]) : void 0;
|
|
543
|
+
if (refreshExpiresAtMs !== void 0 && refreshExpiresAtMs > 0 && refreshExpiresAtMs < Date.now()) return;
|
|
544
|
+
return {
|
|
545
|
+
accessToken,
|
|
546
|
+
refreshToken: typeof auth["refreshToken"] === "string" ? auth["refreshToken"] : "",
|
|
547
|
+
expiresAtMs: typeof auth["expiresAt"] === "number" ? expiryToMs(auth["expiresAt"]) : 0,
|
|
548
|
+
...refreshExpiresAtMs === void 0 ? {} : { refreshExpiresAtMs },
|
|
549
|
+
...optionalString(identity["nickname"]) === void 0 ? {} : { nickname: optionalString(identity["nickname"]) },
|
|
550
|
+
...optionalString(identity["uin"]) === void 0 ? {} : { uin: optionalString(identity["uin"]) },
|
|
551
|
+
...optionalString(identity["uid"]) === void 0 ? {} : { uid: optionalString(identity["uid"]) },
|
|
552
|
+
...optionalString(identity["enterpriseId"]) === void 0 ? {} : { enterpriseId: optionalString(identity["enterpriseId"]) },
|
|
553
|
+
domain: typeof auth["domain"] === "string" ? auth["domain"] : "",
|
|
554
|
+
sourcePath
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* Stable account id. `uin` is the billing identity the upstream keys on and
|
|
559
|
+
* survives re-login; `uid` is the fallback.
|
|
560
|
+
*/
|
|
561
|
+
function workbuddyAccountId(credential) {
|
|
562
|
+
const stable = credential.uin ?? credential.uid ?? credential.nickname ?? "unknown";
|
|
563
|
+
return createHash("sha256").update(`workbuddy\0${stable}`).digest("hex").slice(0, 16);
|
|
564
|
+
}
|
|
565
|
+
/** Human label; distinguishes same-nickname accounts by uid prefix. */
|
|
566
|
+
function accountLabel(credential) {
|
|
567
|
+
const name = credential.nickname ?? "WorkBuddy";
|
|
568
|
+
const discriminator = (credential.uid ?? credential.uin ?? "").slice(0, 8);
|
|
569
|
+
return discriminator === "" ? name : `${name}#${discriminator}`;
|
|
570
|
+
}
|
|
571
|
+
/** List the auth files in one directory: the live file plus every snapshot. */
|
|
572
|
+
async function authFilesIn(dir) {
|
|
573
|
+
let entries;
|
|
574
|
+
try {
|
|
575
|
+
entries = await readdir(dir);
|
|
576
|
+
} catch {
|
|
577
|
+
return [];
|
|
578
|
+
}
|
|
579
|
+
const files = entries.filter((name) => {
|
|
580
|
+
if (name === "workbuddy-desktop.info") return true;
|
|
581
|
+
return name.startsWith(SNAPSHOT_PREFIX) && name.endsWith(".info");
|
|
582
|
+
});
|
|
583
|
+
files.sort((a, b) => a < b ? 1 : a > b ? -1 : 0);
|
|
584
|
+
return files.map((name) => join(dir, name));
|
|
585
|
+
}
|
|
586
|
+
async function readCredential(path) {
|
|
587
|
+
try {
|
|
588
|
+
return parseWorkBuddyAuth(await readFile(path, "utf8"), path);
|
|
589
|
+
} catch {
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
/** Every directory the pool should scan, in probe order. */
|
|
594
|
+
function candidateAuthDirs(env = process.env) {
|
|
595
|
+
const dirs = [];
|
|
596
|
+
const override = nonEmptyEnv(env[WORKBUDDY_AUTH_FILE_ENV]);
|
|
597
|
+
if (override !== void 0) dirs.push(override.toLowerCase().endsWith(".info") ? resolve(override, "..") : override);
|
|
598
|
+
dirs.push(...defaultDesktopAuthDirs(process.env["DSH_TEST_PLATFORM"]));
|
|
599
|
+
return dirs;
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* Read-only pool of every discovered WorkBuddy account, with rate-limit
|
|
603
|
+
* cooldown and round-robin failover.
|
|
604
|
+
*/
|
|
605
|
+
var WorkBuddyAccountPool = class {
|
|
606
|
+
logger;
|
|
607
|
+
authDirs;
|
|
608
|
+
cooldownMs;
|
|
609
|
+
client;
|
|
610
|
+
refreshMarginMs;
|
|
611
|
+
accounts = [];
|
|
612
|
+
cursor = 0;
|
|
613
|
+
lastScanAtMs = 0;
|
|
614
|
+
preferredId;
|
|
615
|
+
refreshInflight = /* @__PURE__ */ new Map();
|
|
616
|
+
constructor(options = {}) {
|
|
617
|
+
this.logger = options.logger;
|
|
618
|
+
this.authDirs = options.authDirs ?? candidateAuthDirs();
|
|
619
|
+
this.cooldownMs = options.cooldownMs ?? 6e4;
|
|
620
|
+
this.client = options.client;
|
|
621
|
+
this.refreshMarginMs = options.refreshMarginMs ?? 3e5;
|
|
622
|
+
}
|
|
623
|
+
/**
|
|
624
|
+
* Re-apply configuration that only affects discovery and cooldown policy,
|
|
625
|
+
* without rebuilding the pool. A later `scan()` uses the new auth dirs and
|
|
626
|
+
* cooldown window; existing accounts keep their in-memory state.
|
|
627
|
+
*/
|
|
628
|
+
applyConfig(options) {
|
|
629
|
+
if (options.authDirs !== void 0 && options.authDirs.length > 0) this.authDirs = options.authDirs;
|
|
630
|
+
if (options.cooldownMs !== void 0 && options.cooldownMs >= 1e3) this.cooldownMs = options.cooldownMs;
|
|
631
|
+
}
|
|
632
|
+
/** Rescan the auth directories and merge newly discovered accounts. */
|
|
633
|
+
async scan() {
|
|
634
|
+
const found = [];
|
|
635
|
+
for (const dir of this.authDirs) for (const file of await authFilesIn(dir)) {
|
|
636
|
+
const credential = await readCredential(file);
|
|
637
|
+
if (credential !== void 0) found.push(credential);
|
|
638
|
+
}
|
|
639
|
+
const byId = /* @__PURE__ */ new Map();
|
|
640
|
+
for (const account of this.accounts) byId.set(account.id, account);
|
|
641
|
+
for (const credential of found) {
|
|
642
|
+
const id = workbuddyAccountId(credential);
|
|
643
|
+
const existing = byId.get(id);
|
|
644
|
+
if (existing === void 0) {
|
|
645
|
+
byId.set(id, {
|
|
646
|
+
id,
|
|
647
|
+
label: accountLabel(credential),
|
|
648
|
+
credential,
|
|
649
|
+
cooldownUntilMs: 0,
|
|
650
|
+
modelCooldowns: {},
|
|
651
|
+
rateLimitHits: 0
|
|
652
|
+
});
|
|
653
|
+
continue;
|
|
654
|
+
}
|
|
655
|
+
if ((credential.expiresAtMs ?? 0) > (existing.credential.expiresAtMs ?? 0)) byId.set(id, {
|
|
656
|
+
...existing,
|
|
657
|
+
credential,
|
|
658
|
+
label: accountLabel(credential)
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
this.accounts = [...byId.values()];
|
|
662
|
+
this.lastScanAtMs = Date.now();
|
|
663
|
+
return this.accounts;
|
|
664
|
+
}
|
|
665
|
+
/** All accounts, cooldown state included. */
|
|
666
|
+
list(region) {
|
|
667
|
+
if (region === void 0) return this.accounts;
|
|
668
|
+
return this.accounts.filter((account) => regionOf(account.credential.domain) === region);
|
|
669
|
+
}
|
|
670
|
+
/**
|
|
671
|
+
* Accounts currently eligible to serve a request.
|
|
672
|
+
*
|
|
673
|
+
* With a `modelId`, an account is eligible when it is not account-wide cooled
|
|
674
|
+
* AND that model is not cooling on it — so a 429 on `hy4-preview` only keeps
|
|
675
|
+
* that model out while `hy3` on the same account stays usable. Without a
|
|
676
|
+
* model id the legacy account-wide check applies (callers that cannot name a
|
|
677
|
+
* model, e.g. CLI diagnostics).
|
|
678
|
+
*/
|
|
679
|
+
available(now, modelId, region) {
|
|
680
|
+
return this.accounts.filter((account) => {
|
|
681
|
+
if (account.cooldownUntilMs > now) return false;
|
|
682
|
+
if (modelId !== void 0 && (account.modelCooldowns[modelId] ?? 0) > now) return false;
|
|
683
|
+
if (region !== void 0 && regionOf(account.credential.domain) !== region) return false;
|
|
684
|
+
return true;
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
/**
|
|
688
|
+
* Pick the next usable account for an optional model. Scans on first use,
|
|
689
|
+
* and rescans when every known account is cooling down — a fresh desktop
|
|
690
|
+
* login is the usual way out of an exhausted pool. A preferred
|
|
691
|
+
* (user-selected) account that is healthy is tried first; otherwise the
|
|
692
|
+
* cursor round-robins so consecutive requests spread across accounts and a
|
|
693
|
+
* still-cooling preferred account is skipped.
|
|
694
|
+
*/
|
|
695
|
+
async acquire(modelId, region) {
|
|
696
|
+
if (this.accounts.length === 0) await this.scan();
|
|
697
|
+
let pool = this.available(Date.now(), modelId, region);
|
|
698
|
+
if (pool.length === 0) {
|
|
699
|
+
await this.scan();
|
|
700
|
+
pool = this.available(Date.now(), modelId, region);
|
|
701
|
+
}
|
|
702
|
+
if (pool.length === 0) return void 0;
|
|
703
|
+
let start = this.cursor % pool.length;
|
|
704
|
+
if (this.preferredId !== void 0) {
|
|
705
|
+
const preferredIndex = pool.findIndex((account) => account.id === this.preferredId);
|
|
706
|
+
if (preferredIndex !== -1) start = preferredIndex;
|
|
707
|
+
}
|
|
708
|
+
for (let step = 0; step < pool.length; step += 1) {
|
|
709
|
+
const account = pool[(start + step) % pool.length];
|
|
710
|
+
if (account === void 0) continue;
|
|
711
|
+
await this.ensureFresh(account);
|
|
712
|
+
this.cursor = (start + step + 1) % pool.length;
|
|
713
|
+
return account;
|
|
714
|
+
}
|
|
715
|
+
return pool[0];
|
|
716
|
+
}
|
|
717
|
+
/** Pin the account the plugin card should prefer; tokens stay out of settings. */
|
|
718
|
+
prefer(accountId) {
|
|
719
|
+
this.preferredId = accountId;
|
|
720
|
+
}
|
|
721
|
+
/** Best-effort refresh of one account after a session-dead upstream answer. */
|
|
722
|
+
async refreshAccount(accountId) {
|
|
723
|
+
const account = this.accounts.find((item) => item.id === accountId);
|
|
724
|
+
if (account === void 0) return;
|
|
725
|
+
await this.ensureFresh(account);
|
|
726
|
+
}
|
|
727
|
+
/**
|
|
728
|
+
* Refresh the account's access token when it is within the margin (or already
|
|
729
|
+
* expired), in-flight de-duped per account. A failed refresh keeps the
|
|
730
|
+
* existing token when it has not yet expired, so an unreachable refresh
|
|
731
|
+
* endpoint never takes down a working session.
|
|
732
|
+
*/
|
|
733
|
+
async ensureFresh(account) {
|
|
734
|
+
if (this.client === void 0) return;
|
|
735
|
+
const credential = account.credential;
|
|
736
|
+
if (!(credential.expiresAtMs <= 0 || credential.expiresAtMs <= Date.now() + this.refreshMarginMs)) return;
|
|
737
|
+
const existing = this.refreshInflight.get(account.id);
|
|
738
|
+
if (existing !== void 0) {
|
|
739
|
+
await existing;
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
const run = (async () => {
|
|
743
|
+
if (credential.refreshToken === "") {
|
|
744
|
+
if (credential.expiresAtMs > Date.now() + 3e4) return;
|
|
745
|
+
this.logger?.warn(`dsh-workbuddy-xdpool: ${account.label} token expired with no refresh token; sign in again`);
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
try {
|
|
749
|
+
const outcome = await this.client.refreshToken(credential);
|
|
750
|
+
account.credential = {
|
|
751
|
+
...credential,
|
|
752
|
+
accessToken: outcome.accessToken,
|
|
753
|
+
...outcome.refreshToken === void 0 ? {} : { refreshToken: outcome.refreshToken },
|
|
754
|
+
expiresAtMs: outcome.expiresInSec !== void 0 ? Date.now() + outcome.expiresInSec * 1e3 : credential.expiresAtMs,
|
|
755
|
+
...outcome.domain === void 0 || outcome.domain === "" ? {} : { domain: outcome.domain }
|
|
756
|
+
};
|
|
757
|
+
this.logger?.info?.(`dsh-workbuddy-xdpool: refreshed token for ${account.label}`);
|
|
758
|
+
} catch (error) {
|
|
759
|
+
if (credential.expiresAtMs > Date.now() + 3e4) this.logger?.warn?.(`dsh-workbuddy-xdpool: token refresh failed but token still valid for ${account.label}`, error);
|
|
760
|
+
else this.logger?.error?.(`dsh-workbuddy-xdpool: token refresh failed and token expired for ${account.label}`, error);
|
|
761
|
+
}
|
|
762
|
+
})();
|
|
763
|
+
this.refreshInflight.set(account.id, run);
|
|
764
|
+
try {
|
|
765
|
+
await run;
|
|
766
|
+
} finally {
|
|
767
|
+
this.refreshInflight.delete(account.id);
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
/**
|
|
771
|
+
* Mark an account (or one of its models) rate-limited.
|
|
772
|
+
*
|
|
773
|
+
* With `modelId`, only that model on the account is cooled — the account's
|
|
774
|
+
* other models stay in rotation, matching the upstream's per-model rate
|
|
775
|
+
* limit ("可切换其他模型继续使用"). Without a model id the whole account is
|
|
776
|
+
* cooled, which callers should reserve for limits that truly span every model.
|
|
777
|
+
*/
|
|
778
|
+
penalize(accountId, resetAtMs, modelId) {
|
|
779
|
+
const account = this.accounts.find((item) => item.id === accountId);
|
|
780
|
+
if (account === void 0) return;
|
|
781
|
+
account.rateLimitHits += 1;
|
|
782
|
+
const until = resetAtMs ?? Date.now() + this.cooldownMs;
|
|
783
|
+
if (modelId !== void 0 && modelId !== "") {
|
|
784
|
+
account.modelCooldowns[modelId] = Math.max(account.modelCooldowns[modelId] ?? 0, until);
|
|
785
|
+
this.logger?.warn(`dsh-workbuddy-xdpool: ${account.label} rate-limited on model ${modelId}; cooling that model until ${new Date(until).toISOString()}`);
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
account.cooldownUntilMs = Math.max(account.cooldownUntilMs, until);
|
|
789
|
+
this.logger?.warn(`dsh-workbuddy-xdpool: account ${account.label} rate-limited; cooling until ${new Date(until).toISOString()}`);
|
|
790
|
+
}
|
|
791
|
+
/** Clear all cooldowns (account-wide and per-model), e.g. from a reset command. */
|
|
792
|
+
resetCooldowns() {
|
|
793
|
+
for (const account of this.accounts) {
|
|
794
|
+
account.cooldownUntilMs = 0;
|
|
795
|
+
account.modelCooldowns = {};
|
|
796
|
+
account.rateLimitHits = 0;
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
/** Diagnostics snapshot. Account-wide cooling count (per-model cooling excluded:
|
|
800
|
+
* the account as a whole stays usable when only one model is limited). */
|
|
801
|
+
status() {
|
|
802
|
+
const now = Date.now();
|
|
803
|
+
return {
|
|
804
|
+
count: this.accounts.length,
|
|
805
|
+
cooling: this.accounts.filter((account) => account.cooldownUntilMs > now).length,
|
|
806
|
+
lastScanAtMs: this.lastScanAtMs
|
|
807
|
+
};
|
|
808
|
+
}
|
|
809
|
+
};
|
|
810
|
+
//#endregion
|
|
811
|
+
//#region src/catalog.ts
|
|
812
|
+
/** Static fallback used before the first live catalog fetch. */
|
|
813
|
+
const FALLBACK_WORKBUDDY_MODELS = [
|
|
814
|
+
{
|
|
815
|
+
id: "glm-5.3",
|
|
816
|
+
name: "GLM-5.3",
|
|
817
|
+
contextWindow: 2e5,
|
|
818
|
+
maxOutputTokens: 128e3,
|
|
819
|
+
supportsImages: true
|
|
820
|
+
},
|
|
821
|
+
{
|
|
822
|
+
id: "glm-5.3-flash",
|
|
823
|
+
name: "GLM-5.3-Flash",
|
|
824
|
+
contextWindow: 2e5,
|
|
825
|
+
maxOutputTokens: 128e3,
|
|
826
|
+
supportsImages: true
|
|
827
|
+
},
|
|
828
|
+
{
|
|
829
|
+
id: "glm-5.2",
|
|
830
|
+
name: "GLM-5.2",
|
|
831
|
+
contextWindow: 2e5,
|
|
832
|
+
maxOutputTokens: 128e3,
|
|
833
|
+
supportsImages: true
|
|
834
|
+
},
|
|
835
|
+
{
|
|
836
|
+
id: "glm-5.1",
|
|
837
|
+
name: "GLM-5.1",
|
|
838
|
+
contextWindow: 2e5,
|
|
839
|
+
maxOutputTokens: 128e3,
|
|
840
|
+
supportsImages: false
|
|
841
|
+
},
|
|
842
|
+
{
|
|
843
|
+
id: "deepseek-v4-pro",
|
|
844
|
+
name: "DeepSeek-V4-Pro",
|
|
845
|
+
contextWindow: 2e5,
|
|
846
|
+
maxOutputTokens: 128e3,
|
|
847
|
+
supportsImages: true
|
|
848
|
+
},
|
|
849
|
+
{
|
|
850
|
+
id: "deepseek-v4-flash",
|
|
851
|
+
name: "DeepSeek-V4-Flash",
|
|
852
|
+
contextWindow: 2e5,
|
|
853
|
+
maxOutputTokens: 128e3,
|
|
854
|
+
supportsImages: true
|
|
855
|
+
},
|
|
856
|
+
{
|
|
857
|
+
id: "kimi-k3",
|
|
858
|
+
name: "Kimi-K3",
|
|
859
|
+
contextWindow: 2e5,
|
|
860
|
+
maxOutputTokens: 128e3,
|
|
861
|
+
supportsImages: true
|
|
862
|
+
},
|
|
863
|
+
{
|
|
864
|
+
id: "minimax-m3",
|
|
865
|
+
name: "MiniMax-M3",
|
|
866
|
+
contextWindow: 2e5,
|
|
867
|
+
maxOutputTokens: 128e3,
|
|
868
|
+
supportsImages: true
|
|
869
|
+
},
|
|
870
|
+
{
|
|
871
|
+
id: "hy3",
|
|
872
|
+
name: "Hy3",
|
|
873
|
+
contextWindow: 2e5,
|
|
874
|
+
maxOutputTokens: 128e3,
|
|
875
|
+
supportsImages: true
|
|
876
|
+
},
|
|
877
|
+
{
|
|
878
|
+
id: "hy4-preview",
|
|
879
|
+
name: "Hy4-Preview",
|
|
880
|
+
contextWindow: 1e6,
|
|
881
|
+
maxOutputTokens: 128e3,
|
|
882
|
+
supportsImages: true
|
|
883
|
+
}
|
|
884
|
+
];
|
|
885
|
+
/** Live catalog with a static fallback behind it. */
|
|
886
|
+
var WorkBuddyCatalog = class {
|
|
887
|
+
models = FALLBACK_WORKBUDDY_MODELS;
|
|
888
|
+
listeners = /* @__PURE__ */ new Set();
|
|
889
|
+
/** User's model selection. Empty object = follow the catalog unfiltered. */
|
|
890
|
+
selection = {};
|
|
891
|
+
current() {
|
|
892
|
+
return this.models;
|
|
893
|
+
}
|
|
894
|
+
/**
|
|
895
|
+
* The models DSH should actually offer, after applying the user's selection:
|
|
896
|
+
* disabled models are dropped, an explicit image list overrides the upstream
|
|
897
|
+
* capability flag, and a per-model budget caps the advertised window.
|
|
898
|
+
*
|
|
899
|
+
* An absent `enabledModelIds` means "everything" — a fresh install with no
|
|
900
|
+
* saved selection must not present an empty picker.
|
|
901
|
+
*/
|
|
902
|
+
visible() {
|
|
903
|
+
const enabled = this.selection.enabledModelIds;
|
|
904
|
+
const allow = enabled === void 0 ? void 0 : new Set(enabled);
|
|
905
|
+
const images = this.selection.imageModelIds;
|
|
906
|
+
const imageSet = images === void 0 ? void 0 : new Set(images);
|
|
907
|
+
const budgets = this.selection.contextBudgets;
|
|
908
|
+
return this.models.filter((model) => allow === void 0 || allow.has(model.id)).map((model) => {
|
|
909
|
+
const next = { ...model };
|
|
910
|
+
if (imageSet !== void 0) next.supportsImages = imageSet.has(model.id);
|
|
911
|
+
const budget = budgets?.[model.id];
|
|
912
|
+
if (budget !== void 0 && budget > 0 && budget < next.contextWindow) next.contextWindow = budget;
|
|
913
|
+
return next;
|
|
914
|
+
});
|
|
915
|
+
}
|
|
916
|
+
/** Replace the catalog and notify the adapter to rebuild its model list. */
|
|
917
|
+
update(models) {
|
|
918
|
+
if (models.length === 0) return;
|
|
919
|
+
this.models = models;
|
|
920
|
+
this.notify();
|
|
921
|
+
}
|
|
922
|
+
/** Restore the static fallback, e.g. when the upstream stops answering. */
|
|
923
|
+
reset() {
|
|
924
|
+
this.models = FALLBACK_WORKBUDDY_MODELS;
|
|
925
|
+
this.notify();
|
|
926
|
+
}
|
|
927
|
+
/** Replace the user's selection; the adapter rebuilds from `visible()`. */
|
|
928
|
+
applySelection(selection) {
|
|
929
|
+
this.selection = selection;
|
|
930
|
+
this.notify();
|
|
931
|
+
}
|
|
932
|
+
/** The selection currently in force, for the card's save round-trip. */
|
|
933
|
+
currentSelection() {
|
|
934
|
+
return this.selection;
|
|
935
|
+
}
|
|
936
|
+
onChange(listener) {
|
|
937
|
+
this.listeners.add(listener);
|
|
938
|
+
return () => this.listeners.delete(listener);
|
|
939
|
+
}
|
|
940
|
+
find(id) {
|
|
941
|
+
return this.models.find((model) => model.id === id);
|
|
942
|
+
}
|
|
943
|
+
/** Replace the catalog from the live upstream list; keeps the fallback if empty. */
|
|
944
|
+
updateFromUpstream(models) {
|
|
945
|
+
this.update(catalogFromUpstream(models));
|
|
946
|
+
}
|
|
947
|
+
notify() {
|
|
948
|
+
for (const listener of this.listeners) listener();
|
|
949
|
+
}
|
|
950
|
+
};
|
|
951
|
+
/** Convert one upstream catalog entry into the plugin's model-info shape. */
|
|
952
|
+
function toModelInfo(model) {
|
|
953
|
+
return {
|
|
954
|
+
id: model.id,
|
|
955
|
+
name: model.name,
|
|
956
|
+
contextWindow: model.contextWindow,
|
|
957
|
+
maxOutputTokens: model.maxTokens,
|
|
958
|
+
supportsImages: model.multimodal ?? false,
|
|
959
|
+
...model.creditMultiplier === void 0 ? {} : { multiplier: model.creditMultiplier },
|
|
960
|
+
...model.reasoning?.supportedEfforts === void 0 ? {} : { supportedEfforts: model.reasoning.supportedEfforts }
|
|
961
|
+
};
|
|
962
|
+
}
|
|
963
|
+
/** Map the live upstream list, falling back to the static list when empty. */
|
|
964
|
+
function catalogFromUpstream(models) {
|
|
965
|
+
if (models.length === 0) return FALLBACK_WORKBUDDY_MODELS;
|
|
966
|
+
return models.map(toModelInfo);
|
|
967
|
+
}
|
|
968
|
+
//#endregion
|
|
969
|
+
//#region src/adapter.ts
|
|
970
|
+
/**
|
|
971
|
+
* The `workbuddy-xdpool` pi-ai provider: one loopback-backed adapter registered
|
|
972
|
+
* into the Harness LLM seam, assembled from public `dsh-llm-pi-ai` extension
|
|
973
|
+
* points. Every model points at the shim; account rotation stays inside it.
|
|
974
|
+
*
|
|
975
|
+
* Assembly (createProvider + openAICompletionsApi + inert auth plane + the
|
|
976
|
+
* shim's in-process secret as apiKey) follows corrinehu/dsh-workbuddy-connect
|
|
977
|
+
* (MIT, Copyright (c) 2026 Corrine Hu) and dingminhua/dsh-connect-workbuddy
|
|
978
|
+
* (MIT), both designed and validated against this host.
|
|
979
|
+
*
|
|
980
|
+
* @module dsh-workbuddy-xdpool/adapter
|
|
981
|
+
*/
|
|
982
|
+
/** Provider route this bundle owns. */
|
|
983
|
+
/** Provider route this bundle owns for the domestic (CN) gateway. */
|
|
984
|
+
const WORKBUDDY_POOL_PROVIDER = "workbuddy-xdpool";
|
|
985
|
+
/** The provider id each region registers as. */
|
|
986
|
+
const POOL_PROVIDER_BY_REGION = {
|
|
987
|
+
cn: WORKBUDDY_POOL_PROVIDER,
|
|
988
|
+
global: "workbuddy-xdpool-global"
|
|
989
|
+
};
|
|
990
|
+
/** Display name each region registers under, so the picker can group them. */
|
|
991
|
+
const POOL_NAME_BY_REGION = {
|
|
992
|
+
cn: "WorkBuddy XD Pool(国内版)",
|
|
993
|
+
global: "WorkBuddy XD Pool(国际版)"
|
|
994
|
+
};
|
|
995
|
+
/** Provider idle ceiling while one stream read is outstanding. */
|
|
996
|
+
const WORKBUDDY_STREAM_IDLE_TIMEOUT_MS = 3e5;
|
|
997
|
+
/** Image-request budgets at the dsh-llm-pi-ai defaults. */
|
|
998
|
+
const REQUEST_IMAGE_BUDGETS = {
|
|
999
|
+
maxRequestImageBytes: 20971520,
|
|
1000
|
+
requestImagePixelBudget: 4194304,
|
|
1001
|
+
requestImageMaxBytes: 1048576
|
|
1002
|
+
};
|
|
1003
|
+
/**
|
|
1004
|
+
* Inert pi-ai auth plane. The route authenticates only through the shim shared
|
|
1005
|
+
* secret resolved per request, so pi-ai's own credential lifecycle must never
|
|
1006
|
+
* manufacture a credential for it.
|
|
1007
|
+
*/
|
|
1008
|
+
const INERT_AUTH = {
|
|
1009
|
+
credentials: {
|
|
1010
|
+
async read() {},
|
|
1011
|
+
async list() {
|
|
1012
|
+
return [];
|
|
1013
|
+
},
|
|
1014
|
+
async modify() {
|
|
1015
|
+
throw new Error("dsh-workbuddy-xdpool: this route has no pi-ai credential lifecycle");
|
|
1016
|
+
},
|
|
1017
|
+
async delete() {}
|
|
1018
|
+
},
|
|
1019
|
+
authContext: {
|
|
1020
|
+
async env() {},
|
|
1021
|
+
async fileExists() {
|
|
1022
|
+
return false;
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
};
|
|
1026
|
+
/** No per-token pricing is knowable for a subscription quota; report zero. */
|
|
1027
|
+
const NO_COST = {
|
|
1028
|
+
input: 0,
|
|
1029
|
+
output: 0,
|
|
1030
|
+
cacheRead: 0,
|
|
1031
|
+
cacheWrite: 0
|
|
1032
|
+
};
|
|
1033
|
+
/** pi-ai input modalities: images only when the catalog advertises them. */
|
|
1034
|
+
function modelInput(info) {
|
|
1035
|
+
return info.supportsImages ? ["text", "image"] : ["text"];
|
|
1036
|
+
}
|
|
1037
|
+
/** Map only levels the catalog advertises; undeclared DSH levels stay off. */
|
|
1038
|
+
function thinkingLevelMap(info) {
|
|
1039
|
+
const efforts = info.supportedEfforts;
|
|
1040
|
+
if (efforts === void 0 || efforts.length === 0) return void 0;
|
|
1041
|
+
const levels = [
|
|
1042
|
+
"minimal",
|
|
1043
|
+
"low",
|
|
1044
|
+
"medium",
|
|
1045
|
+
"high",
|
|
1046
|
+
"xhigh",
|
|
1047
|
+
"max"
|
|
1048
|
+
];
|
|
1049
|
+
const map = {};
|
|
1050
|
+
for (const level of levels) map[level] = efforts.includes(level) ? level : null;
|
|
1051
|
+
map["off"] = null;
|
|
1052
|
+
return map;
|
|
1053
|
+
}
|
|
1054
|
+
/** Middle-dot separator: unambiguous between the model's own hyphens and the
|
|
1055
|
+
* rate/badge suffix. Matches the LaoDing family convention. */
|
|
1056
|
+
const DISPLAY_SEPARATOR = " · ";
|
|
1057
|
+
/** Map an upstream tag code to the localized promo label shown next to the
|
|
1058
|
+
* credit rate in the model picker. Both `free` and `limited-free` collapse to
|
|
1059
|
+
* the same short label so the dropdown row stays scannable. */
|
|
1060
|
+
const TAG_LABEL = {
|
|
1061
|
+
"free": "限时免费",
|
|
1062
|
+
"limited-free": "限时免费",
|
|
1063
|
+
"night-discount": "夜间折扣"
|
|
1064
|
+
};
|
|
1065
|
+
/** Resolve the display suffix (`xN.NN`, promo badges) for one catalog row.
|
|
1066
|
+
* Returns `undefined` when there's nothing to show — the name is left alone
|
|
1067
|
+
* so we don't tack a trailing separator on a plain model. */
|
|
1068
|
+
function displaySuffix(info) {
|
|
1069
|
+
const parts = [];
|
|
1070
|
+
if (typeof info.multiplier === "number" && Number.isFinite(info.multiplier)) parts.push(`x${info.multiplier.toFixed(2)}`);
|
|
1071
|
+
for (const tag of info.tags ?? []) {
|
|
1072
|
+
const label = TAG_LABEL[tag];
|
|
1073
|
+
if (label !== void 0 && !parts.includes(label)) parts.push(label);
|
|
1074
|
+
}
|
|
1075
|
+
return parts.length === 0 ? void 0 : parts.join(DISPLAY_SEPARATOR);
|
|
1076
|
+
}
|
|
1077
|
+
/** Apply the catalog's rate + promo badges to one model's display name.
|
|
1078
|
+
* Display-only: the wire request is built from `model.id`, so renaming here
|
|
1079
|
+
* cannot affect routing, token, or upstream accounting. DSH 0.1.2's composer
|
|
1080
|
+
* (`ModelSelect`) renders `model.name` only, which is why the rate and
|
|
1081
|
+
* badges ride the name rather than a separate description column. */
|
|
1082
|
+
function withCatalogDisplay(name, info) {
|
|
1083
|
+
const suffix = displaySuffix(info);
|
|
1084
|
+
return suffix === void 0 ? name : `${name}${DISPLAY_SEPARATOR}${suffix}`;
|
|
1085
|
+
}
|
|
1086
|
+
/** Build one pi-ai model descriptor pointing at the loopback shim. */
|
|
1087
|
+
function toPiModel(info, baseUrl, providerId) {
|
|
1088
|
+
const map = thinkingLevelMap(info);
|
|
1089
|
+
return {
|
|
1090
|
+
id: info.id,
|
|
1091
|
+
name: withCatalogDisplay(info.name, info),
|
|
1092
|
+
api: "openai-completions",
|
|
1093
|
+
provider: providerId,
|
|
1094
|
+
baseUrl,
|
|
1095
|
+
input: modelInput(info),
|
|
1096
|
+
cost: NO_COST,
|
|
1097
|
+
contextWindow: info.contextWindow,
|
|
1098
|
+
maxTokens: info.maxOutputTokens,
|
|
1099
|
+
reasoning: map !== void 0,
|
|
1100
|
+
...map === void 0 ? {} : { thinkingLevelMap: map },
|
|
1101
|
+
compat: { supportsReasoningEffort: map !== void 0 }
|
|
1102
|
+
};
|
|
1103
|
+
}
|
|
1104
|
+
/**
|
|
1105
|
+
* Assemble the adapter. `getModels` re-reads the live catalog, and every
|
|
1106
|
+
* model's `baseUrl` is re-resolved per read so the shim's ephemeral port
|
|
1107
|
+
* applies from the first snapshot after startup. Call only after `shim.ready`.
|
|
1108
|
+
*/
|
|
1109
|
+
function createWorkBuddyAdapter(options) {
|
|
1110
|
+
const { shim, catalog } = options;
|
|
1111
|
+
const providerId = options.providerId ?? "workbuddy-xdpool";
|
|
1112
|
+
const displayName = options.displayName ?? "WorkBuddy XD Pool";
|
|
1113
|
+
const buildModels = () => {
|
|
1114
|
+
const baseUrl = `${shim.baseUrl()}/v1`;
|
|
1115
|
+
return catalog.visible().map((info) => toPiModel(info, baseUrl, providerId));
|
|
1116
|
+
};
|
|
1117
|
+
const provider = {
|
|
1118
|
+
...createProvider({
|
|
1119
|
+
id: providerId,
|
|
1120
|
+
name: displayName,
|
|
1121
|
+
auth: { apiKey: {
|
|
1122
|
+
name: "WorkBuddy XD Pool loopback secret",
|
|
1123
|
+
async resolve({ credential }) {
|
|
1124
|
+
const apiKey = credential?.key;
|
|
1125
|
+
return apiKey === void 0 || apiKey.length === 0 ? void 0 : {
|
|
1126
|
+
auth: { apiKey },
|
|
1127
|
+
source: "WorkBuddy XD Pool"
|
|
1128
|
+
};
|
|
1129
|
+
}
|
|
1130
|
+
} },
|
|
1131
|
+
models: buildModels(),
|
|
1132
|
+
api: openAICompletionsApi()
|
|
1133
|
+
}),
|
|
1134
|
+
getModels: () => buildModels()
|
|
1135
|
+
};
|
|
1136
|
+
const profile = {
|
|
1137
|
+
provider: providerId,
|
|
1138
|
+
displayName,
|
|
1139
|
+
streamIdleTimeoutMs: WORKBUDDY_STREAM_IDLE_TIMEOUT_MS,
|
|
1140
|
+
retryPolicy: resolveRetryPolicy(void 0, "dsh-workbuddy-xdpool retryPolicy"),
|
|
1141
|
+
configuredMaxTokens: /* @__PURE__ */ new Map(),
|
|
1142
|
+
modelErrors: /* @__PURE__ */ new Map(),
|
|
1143
|
+
...REQUEST_IMAGE_BUDGETS,
|
|
1144
|
+
piProvider: provider
|
|
1145
|
+
};
|
|
1146
|
+
let profiles = /* @__PURE__ */ new Map([[providerId, profile]]);
|
|
1147
|
+
return {
|
|
1148
|
+
providerId,
|
|
1149
|
+
displayName,
|
|
1150
|
+
adapter: new PiAiAdapter({
|
|
1151
|
+
profiles: () => profiles,
|
|
1152
|
+
auth: INERT_AUTH,
|
|
1153
|
+
resolveApiKey: async () => shim.token()
|
|
1154
|
+
}),
|
|
1155
|
+
buildModels,
|
|
1156
|
+
invalidate: () => {
|
|
1157
|
+
profiles = /* @__PURE__ */ new Map([[providerId, profile]]);
|
|
1158
|
+
}
|
|
1159
|
+
};
|
|
1160
|
+
}
|
|
1161
|
+
//#endregion
|
|
1162
|
+
//#region src/shim.ts
|
|
1163
|
+
/**
|
|
1164
|
+
* Loopback OpenAI-compatible endpoint with multi-account failover.
|
|
1165
|
+
*
|
|
1166
|
+
* The pi-ai provider points here. Each chat request acquires an account from
|
|
1167
|
+
* the pool; when the upstream answers with a rate limit, the shim cools that
|
|
1168
|
+
* account down, takes the next one, and retries in the same request — so a
|
|
1169
|
+
* `429 soft_rate` never reaches the user as a turn failure.
|
|
1170
|
+
*
|
|
1171
|
+
* Security model (Host/Origin loopback checks, constant-time bearer compare,
|
|
1172
|
+
* random port, in-process secret, body cap, error→status mapping) follows
|
|
1173
|
+
* corrinehu/dsh-workbuddy-connect (MIT, Copyright (c) 2026 Corrine Hu), which
|
|
1174
|
+
* designed and validated it.
|
|
1175
|
+
*
|
|
1176
|
+
* @module dsh-workbuddy-xdpool/shim
|
|
1177
|
+
*/
|
|
1178
|
+
const REQUEST_BODY_LIMIT = 67108864;
|
|
1179
|
+
const LOOPBACK_HOSTS = /* @__PURE__ */ new Set([
|
|
1180
|
+
"127.0.0.1",
|
|
1181
|
+
"localhost",
|
|
1182
|
+
"[::1]"
|
|
1183
|
+
]);
|
|
1184
|
+
/** HTTP status each upstream failure class surfaces as. */
|
|
1185
|
+
const KIND_STATUS = {
|
|
1186
|
+
hard_credit: 402,
|
|
1187
|
+
soft_rate: 429,
|
|
1188
|
+
session_dead: 401,
|
|
1189
|
+
not_found: 502,
|
|
1190
|
+
server: 502,
|
|
1191
|
+
client: 400
|
|
1192
|
+
};
|
|
1193
|
+
function hostnameOfHost(host) {
|
|
1194
|
+
let hostname = host.trim().toLowerCase();
|
|
1195
|
+
if (hostname.startsWith("[")) {
|
|
1196
|
+
const end = hostname.indexOf("]");
|
|
1197
|
+
return end === -1 ? hostname : hostname.slice(0, end + 1);
|
|
1198
|
+
}
|
|
1199
|
+
const colon = hostname.lastIndexOf(":");
|
|
1200
|
+
if (colon !== -1 && /^\d+$/.test(hostname.slice(colon + 1))) hostname = hostname.slice(0, colon);
|
|
1201
|
+
return hostname;
|
|
1202
|
+
}
|
|
1203
|
+
/** Host must name loopback; drops DNS-rebinding attempts before routing. */
|
|
1204
|
+
function hostIsLoopback(host) {
|
|
1205
|
+
if (host === void 0 || host.trim() === "") return false;
|
|
1206
|
+
return LOOPBACK_HOSTS.has(hostnameOfHost(host));
|
|
1207
|
+
}
|
|
1208
|
+
/** A present Origin must be loopback; non-browser clients send none and pass. */
|
|
1209
|
+
function originIsLoopback(origin) {
|
|
1210
|
+
if (origin === void 0 || origin.trim() === "") return true;
|
|
1211
|
+
try {
|
|
1212
|
+
const { hostname } = new URL(origin);
|
|
1213
|
+
return LOOPBACK_HOSTS.has(hostname) || hostname === "::1";
|
|
1214
|
+
} catch {
|
|
1215
|
+
return false;
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
/** Chat POSTs must carry a JSON body type (blocks simple-request CSRF). */
|
|
1219
|
+
function isJsonContentType(req) {
|
|
1220
|
+
const type = req.headers["content-type"];
|
|
1221
|
+
return typeof type === "string" && type.trim().toLowerCase().startsWith("application/json");
|
|
1222
|
+
}
|
|
1223
|
+
function writeJson(res, status, body) {
|
|
1224
|
+
const payload = JSON.stringify(body);
|
|
1225
|
+
res.writeHead(status, {
|
|
1226
|
+
"Content-Type": "application/json",
|
|
1227
|
+
"Content-Length": Buffer.byteLength(payload)
|
|
1228
|
+
});
|
|
1229
|
+
res.end(payload);
|
|
1230
|
+
}
|
|
1231
|
+
function writeOpenAIError(res, status, kind, message) {
|
|
1232
|
+
writeJson(res, status, { error: {
|
|
1233
|
+
message,
|
|
1234
|
+
type: kind,
|
|
1235
|
+
code: kind
|
|
1236
|
+
} });
|
|
1237
|
+
}
|
|
1238
|
+
/** True when an upstream failure body means the request overran the model's
|
|
1239
|
+
* context window (OpenAI `context_length_exceeded`, WorkBuddy code 11115 /
|
|
1240
|
+
* "input length too long"). Surfaced as a friendly hint, never auto-truncated. */
|
|
1241
|
+
function isContextTooLong(body) {
|
|
1242
|
+
if (body.includes("context_length_exceeded")) return true;
|
|
1243
|
+
if (body.includes("input length too long")) return true;
|
|
1244
|
+
if (body.includes("\"code\":11115")) return true;
|
|
1245
|
+
if (/exceeds?\s+(the\s+)?(model\s+)?context\s+(window|limit)/iu.test(body)) return true;
|
|
1246
|
+
return false;
|
|
1247
|
+
}
|
|
1248
|
+
function readBody(req) {
|
|
1249
|
+
return new Promise((resolve, reject) => {
|
|
1250
|
+
const chunks = [];
|
|
1251
|
+
let size = 0;
|
|
1252
|
+
req.on("data", (chunk) => {
|
|
1253
|
+
size += chunk.length;
|
|
1254
|
+
if (size > REQUEST_BODY_LIMIT) {
|
|
1255
|
+
reject(/* @__PURE__ */ new Error("request body too large"));
|
|
1256
|
+
req.destroy();
|
|
1257
|
+
return;
|
|
1258
|
+
}
|
|
1259
|
+
chunks.push(chunk);
|
|
1260
|
+
});
|
|
1261
|
+
req.on("end", () => resolve(Buffer.concat(chunks)));
|
|
1262
|
+
req.on("error", reject);
|
|
1263
|
+
});
|
|
1264
|
+
}
|
|
1265
|
+
function createWorkBuddyShim(options) {
|
|
1266
|
+
const { pool, client, catalog } = options;
|
|
1267
|
+
const region = options.region;
|
|
1268
|
+
const logger = options.logger;
|
|
1269
|
+
const maxAttempts = options.maxAttempts ?? 8;
|
|
1270
|
+
const SHARED_SECRET = randomBytes(32).toString("base64url");
|
|
1271
|
+
function bearerOk(req) {
|
|
1272
|
+
const header = req.headers.authorization;
|
|
1273
|
+
if (typeof header !== "string") return false;
|
|
1274
|
+
const match = /^Bearer\s+(.+)$/i.exec(header.trim());
|
|
1275
|
+
if (match === null) return false;
|
|
1276
|
+
const a = Buffer.from(match[1]);
|
|
1277
|
+
const b = Buffer.from(SHARED_SECRET);
|
|
1278
|
+
if (a.length !== b.length) return false;
|
|
1279
|
+
return timingSafeEqual(a, b);
|
|
1280
|
+
}
|
|
1281
|
+
const server = createServer((req, res) => {
|
|
1282
|
+
handle(req, res);
|
|
1283
|
+
});
|
|
1284
|
+
const ready = new Promise((resolve, reject) => {
|
|
1285
|
+
server.once("listening", () => resolve());
|
|
1286
|
+
server.once("error", reject);
|
|
1287
|
+
});
|
|
1288
|
+
server.listen(0, "127.0.0.1");
|
|
1289
|
+
const baseUrl = () => {
|
|
1290
|
+
const address = server.address();
|
|
1291
|
+
if (address === null || typeof address === "string") throw new Error("workbuddy shim has no listening address");
|
|
1292
|
+
return `http://127.0.0.1:${address.port}`;
|
|
1293
|
+
};
|
|
1294
|
+
async function handle(req, res) {
|
|
1295
|
+
try {
|
|
1296
|
+
if (!hostIsLoopback(req.headers.host)) {
|
|
1297
|
+
writeOpenAIError(res, 403, "host_not_allowed", "Host header must name the loopback interface");
|
|
1298
|
+
return;
|
|
1299
|
+
}
|
|
1300
|
+
if (!originIsLoopback(req.headers.origin)) {
|
|
1301
|
+
writeOpenAIError(res, 403, "origin_not_allowed", "Origin must be a loopback origin");
|
|
1302
|
+
return;
|
|
1303
|
+
}
|
|
1304
|
+
if (!bearerOk(req)) {
|
|
1305
|
+
writeOpenAIError(res, 401, "unauthorized", "missing or invalid Authorization bearer");
|
|
1306
|
+
return;
|
|
1307
|
+
}
|
|
1308
|
+
const url = req.url ?? "/";
|
|
1309
|
+
if (req.method === "GET" && (url === "/healthz" || url === "/healthz/")) {
|
|
1310
|
+
writeJson(res, 200, {
|
|
1311
|
+
ok: true,
|
|
1312
|
+
pool: pool.status()
|
|
1313
|
+
});
|
|
1314
|
+
return;
|
|
1315
|
+
}
|
|
1316
|
+
if (req.method === "GET" && (url === "/v1/models" || url === "/v1/models/")) {
|
|
1317
|
+
writeJson(res, 200, {
|
|
1318
|
+
object: "list",
|
|
1319
|
+
data: catalog.current().map((model) => ({
|
|
1320
|
+
id: model.id,
|
|
1321
|
+
object: "model",
|
|
1322
|
+
created: 0,
|
|
1323
|
+
owned_by: "workbuddy"
|
|
1324
|
+
}))
|
|
1325
|
+
});
|
|
1326
|
+
return;
|
|
1327
|
+
}
|
|
1328
|
+
if (req.method === "POST" && (url === "/v1/chat/completions" || url === "/v1/chat/completions/")) {
|
|
1329
|
+
await chatCompletions(req, res);
|
|
1330
|
+
return;
|
|
1331
|
+
}
|
|
1332
|
+
writeOpenAIError(res, 404, "not_found", `no such route: ${req.method} ${url}`);
|
|
1333
|
+
} catch (error) {
|
|
1334
|
+
if (!res.headersSent) writeOpenAIError(res, 500, "internal", String(error));
|
|
1335
|
+
else res.end();
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
/**
|
|
1339
|
+
* Serve one chat completion, rotating accounts on rate limits.
|
|
1340
|
+
*
|
|
1341
|
+
* A rate-limited account is cooled for exactly the window the upstream
|
|
1342
|
+
* reports (when parseable) and the next account is tried immediately, so a
|
|
1343
|
+
* pool with any healthy member never surfaces a 429 to the caller.
|
|
1344
|
+
*/
|
|
1345
|
+
async function chatCompletions(req, res) {
|
|
1346
|
+
if (!isJsonContentType(req)) {
|
|
1347
|
+
writeOpenAIError(res, 415, "unsupported_media_type", "Content-Type must be application/json");
|
|
1348
|
+
return;
|
|
1349
|
+
}
|
|
1350
|
+
const raw = (await readBody(req)).toString("utf8");
|
|
1351
|
+
const prepared = client.prepareChatBody(raw);
|
|
1352
|
+
const controller = new AbortController();
|
|
1353
|
+
req.on("close", () => controller.abort());
|
|
1354
|
+
let modelId;
|
|
1355
|
+
try {
|
|
1356
|
+
const parsed = JSON.parse(raw);
|
|
1357
|
+
modelId = typeof parsed.model === "string" && parsed.model !== "" ? parsed.model : void 0;
|
|
1358
|
+
} catch {
|
|
1359
|
+
modelId = void 0;
|
|
1360
|
+
}
|
|
1361
|
+
const tried = [];
|
|
1362
|
+
let last;
|
|
1363
|
+
let exhaustedByRateLimit = false;
|
|
1364
|
+
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
|
1365
|
+
if (controller.signal.aborted) return;
|
|
1366
|
+
const account = await pool.acquire(modelId, region);
|
|
1367
|
+
if (account === void 0) {
|
|
1368
|
+
if (exhaustedByRateLimit && last !== void 0) {
|
|
1369
|
+
const subject = modelId === void 0 ? "every WorkBuddy account is rate-limited" : `every account is rate-limited for model ${modelId}`;
|
|
1370
|
+
writeOpenAIError(res, KIND_STATUS[last.kind], last.kind, `${subject} (tried ${tried.length}: ${tried.join(", ")}); resets at the upstream window — ${last.message.slice(0, 200)}`);
|
|
1371
|
+
return;
|
|
1372
|
+
}
|
|
1373
|
+
writeOpenAIError(res, 401, "not_signed_in", "no WorkBuddy credential found; sign in on the desktop app (or set WORKBUDDY_AUTH_FILE)");
|
|
1374
|
+
return;
|
|
1375
|
+
}
|
|
1376
|
+
tried.push(account.label);
|
|
1377
|
+
const result = await client.chatStream(account.credential, prepared, controller.signal);
|
|
1378
|
+
if (result.ok) {
|
|
1379
|
+
logger?.info?.(`dsh-workbuddy-xdpool: served by ${account.label}`);
|
|
1380
|
+
res.writeHead(200, {
|
|
1381
|
+
"Content-Type": "text/event-stream",
|
|
1382
|
+
"Cache-Control": "no-cache",
|
|
1383
|
+
"Connection": "keep-alive",
|
|
1384
|
+
"X-Accel-Buffering": "no"
|
|
1385
|
+
});
|
|
1386
|
+
let sawDone = false;
|
|
1387
|
+
const body = Readable.fromWeb(result.response.body);
|
|
1388
|
+
body.on("data", (chunk) => {
|
|
1389
|
+
if (chunk.includes("[DONE]")) sawDone = true;
|
|
1390
|
+
});
|
|
1391
|
+
body.on("error", (error) => {
|
|
1392
|
+
logger?.warn("dsh-workbuddy-xdpool: upstream stream failed mid-flight", error);
|
|
1393
|
+
if (!sawDone && res.writable) res.end("data: [DONE]\n\n");
|
|
1394
|
+
});
|
|
1395
|
+
body.pipe(res);
|
|
1396
|
+
return;
|
|
1397
|
+
}
|
|
1398
|
+
last = {
|
|
1399
|
+
kind: result.kind,
|
|
1400
|
+
status: result.status,
|
|
1401
|
+
message: result.message
|
|
1402
|
+
};
|
|
1403
|
+
if (result.kind === "session_dead") {
|
|
1404
|
+
logger?.warn(`dsh-workbuddy-xdpool: ${account.label} session dead; refreshing token and retrying`);
|
|
1405
|
+
await pool.refreshAccount(account.id);
|
|
1406
|
+
continue;
|
|
1407
|
+
}
|
|
1408
|
+
if (result.kind !== "soft_rate") break;
|
|
1409
|
+
exhaustedByRateLimit = true;
|
|
1410
|
+
pool.penalize(account.id, parseRateLimitReset(result.message), modelId);
|
|
1411
|
+
logger?.warn(`dsh-workbuddy-xdpool: ${account.label} rate-limited on ${modelId ?? "(no model)"} (attempt ${attempt + 1}/${maxAttempts}); rotating`);
|
|
1412
|
+
}
|
|
1413
|
+
if (last === void 0) {
|
|
1414
|
+
writeOpenAIError(res, 500, "internal", "chat request exhausted without a result");
|
|
1415
|
+
return;
|
|
1416
|
+
}
|
|
1417
|
+
if (isContextTooLong(last.message)) {
|
|
1418
|
+
writeOpenAIError(res, 400, "context_length_exceeded", `${modelId === void 0 ? "the conversation exceeds this model's context window" : `the conversation exceeds ${modelId}'s context window`}. Shorten the conversation, start a new chat, or pick a model with a larger window (e.g. hy4-preview).`);
|
|
1419
|
+
return;
|
|
1420
|
+
}
|
|
1421
|
+
writeOpenAIError(res, KIND_STATUS[last.kind], last.kind, `workbuddy upstream ${last.kind} (http ${last.status}) after ${tried.length} account(s) [${tried.join(" → ")}]: ${last.message.slice(0, 400)}`);
|
|
1422
|
+
}
|
|
1423
|
+
return {
|
|
1424
|
+
ready,
|
|
1425
|
+
baseUrl,
|
|
1426
|
+
token: () => SHARED_SECRET,
|
|
1427
|
+
close: () => new Promise((resolve, reject) => {
|
|
1428
|
+
server.close(() => resolve());
|
|
1429
|
+
server.closeAllConnections();
|
|
1430
|
+
server.once("error", reject);
|
|
1431
|
+
})
|
|
1432
|
+
};
|
|
1433
|
+
}
|
|
1434
|
+
//#endregion
|
|
1435
|
+
//#region src/status.ts
|
|
1436
|
+
/** Build the status document. Never throws. */
|
|
1437
|
+
async function buildStatus(options) {
|
|
1438
|
+
const { pool, catalog, client } = options;
|
|
1439
|
+
const accounts = pool.list();
|
|
1440
|
+
const now = Date.now();
|
|
1441
|
+
const rows = [];
|
|
1442
|
+
for (const account of accounts) {
|
|
1443
|
+
const modelCooldowns = Object.entries(account.modelCooldowns).filter(([, until]) => until > now).sort((a, b) => a[1] - b[1]).map(([modelId, until]) => ({
|
|
1444
|
+
modelId,
|
|
1445
|
+
until: new Date(until).toISOString()
|
|
1446
|
+
}));
|
|
1447
|
+
const row = {
|
|
1448
|
+
id: account.id,
|
|
1449
|
+
label: account.label,
|
|
1450
|
+
...account.credential.nickname === void 0 ? {} : { nickname: account.credential.nickname },
|
|
1451
|
+
domain: account.credential.domain,
|
|
1452
|
+
...account.credential.expiresAtMs === 0 ? {} : { expiresAt: new Date(account.credential.expiresAtMs).toISOString() },
|
|
1453
|
+
cooling: account.cooldownUntilMs > now,
|
|
1454
|
+
...account.cooldownUntilMs > now ? { cooldownUntil: new Date(account.cooldownUntilMs).toISOString() } : {},
|
|
1455
|
+
...modelCooldowns.length === 0 ? {} : { modelCooldowns },
|
|
1456
|
+
rateLimitHits: account.rateLimitHits,
|
|
1457
|
+
sourcePath: account.credential.sourcePath
|
|
1458
|
+
};
|
|
1459
|
+
if (options.includeCredits === true && !row.cooling) try {
|
|
1460
|
+
Object.assign(row, { credits: await client.fetchCredits(account.credential) });
|
|
1461
|
+
} catch (error) {
|
|
1462
|
+
Object.assign(row, { creditsError: String(error).slice(0, 200) });
|
|
1463
|
+
}
|
|
1464
|
+
rows.push(row);
|
|
1465
|
+
}
|
|
1466
|
+
const cooling = rows.filter((row) => row.cooling).length;
|
|
1467
|
+
const firstUsable = accounts.find((account) => account.cooldownUntilMs <= now);
|
|
1468
|
+
return {
|
|
1469
|
+
ok: accounts.length > 0 && cooling < accounts.length,
|
|
1470
|
+
accounts: rows,
|
|
1471
|
+
...firstUsable === void 0 ? {} : { activeAccountId: firstUsable.id },
|
|
1472
|
+
cooling,
|
|
1473
|
+
models: catalog.current().map((model) => ({
|
|
1474
|
+
id: model.id,
|
|
1475
|
+
name: model.name,
|
|
1476
|
+
...model.multiplier === void 0 ? {} : { multiplier: model.multiplier },
|
|
1477
|
+
...model.tags === void 0 ? {} : { tags: model.tags }
|
|
1478
|
+
})),
|
|
1479
|
+
shim: options.shim ?? { running: false }
|
|
1480
|
+
};
|
|
1481
|
+
}
|
|
1482
|
+
/** Format the status document for a terminal. */
|
|
1483
|
+
function formatStatus(status) {
|
|
1484
|
+
const lines = [];
|
|
1485
|
+
lines.push(`WorkBuddy XD Pool: ${status.accounts.length} account(s), ${status.cooling} cooling`);
|
|
1486
|
+
lines.push(`Shim: ${status.shim.running ? "running" : "stopped"}${status.shim.baseUrl === void 0 ? "" : ` at ${status.shim.baseUrl}`}`);
|
|
1487
|
+
lines.push("");
|
|
1488
|
+
if (status.accounts.length === 0) {
|
|
1489
|
+
lines.push("No WorkBuddy credential found. Sign in on the WorkBuddy desktop app,");
|
|
1490
|
+
lines.push("then run: dsh plugin --profile desktop exec dsh-workbuddy-xdpool import <key>");
|
|
1491
|
+
return lines.join("\n");
|
|
1492
|
+
}
|
|
1493
|
+
for (const account of status.accounts) {
|
|
1494
|
+
const flag = account.cooling ? "⏸ " : "▶ ";
|
|
1495
|
+
const active = account.id === status.activeAccountId ? " (next up)" : "";
|
|
1496
|
+
lines.push(`${flag}${account.label}${active}`);
|
|
1497
|
+
lines.push(` uid/uin : ${account.id} [${account.domain || "cn"}]`);
|
|
1498
|
+
if (account.expiresAt !== void 0) lines.push(` expires : ${account.expiresAt}`);
|
|
1499
|
+
if (account.credits !== void 0) {
|
|
1500
|
+
const { total } = account.credits;
|
|
1501
|
+
const parts = [];
|
|
1502
|
+
if (total !== void 0) parts.push(`total ${total}`);
|
|
1503
|
+
lines.push(` credits : ${parts.join(" | ") || "n/a"}`);
|
|
1504
|
+
}
|
|
1505
|
+
if (account.creditsError !== void 0) lines.push(` credits : query failed — ${account.creditsError}`);
|
|
1506
|
+
if (account.cooling && account.cooldownUntil !== void 0) lines.push(` cooldown : until ${account.cooldownUntil} (hits ${account.rateLimitHits})`);
|
|
1507
|
+
if (account.modelCooldowns !== void 0 && account.modelCooldowns.length > 0) for (const mc of account.modelCooldowns) lines.push(` model-cool: ${mc.modelId} until ${mc.until}`);
|
|
1508
|
+
lines.push(` source : ${account.sourcePath}`);
|
|
1509
|
+
lines.push("");
|
|
1510
|
+
}
|
|
1511
|
+
return lines.join("\n").trimEnd();
|
|
1512
|
+
}
|
|
1513
|
+
/** Format the per-model credit multipliers. */
|
|
1514
|
+
function formatRates(status) {
|
|
1515
|
+
const lines = ["Model credit multipliers:"];
|
|
1516
|
+
for (const model of status.models) {
|
|
1517
|
+
const rate = model.multiplier === void 0 ? "x?" : `x${model.multiplier.toFixed(2)}`;
|
|
1518
|
+
lines.push(` ${model.name.padEnd(20)} ${rate}`);
|
|
1519
|
+
}
|
|
1520
|
+
return lines.join("\n");
|
|
1521
|
+
}
|
|
1522
|
+
//#endregion
|
|
1523
|
+
//#region src/status-paths.ts
|
|
1524
|
+
/**
|
|
1525
|
+
* Node-free constants and types shared by the Host and browser halves of the
|
|
1526
|
+
* WorkBuddy XD Pool settings card.
|
|
1527
|
+
*
|
|
1528
|
+
* Pool's runtime state already lives in `src/status.ts` (`buildStatus` /
|
|
1529
|
+
* `WorkBuddyStatus`); this module only carves the cross-domain (Host→browser)
|
|
1530
|
+
* JSON document into a shape that stays token-free and matches what the
|
|
1531
|
+
* browser card renders. Route paths are plugin-owned and mounted on the Host's
|
|
1532
|
+
* same-origin web server (see `src/web-status.ts`).
|
|
1533
|
+
*
|
|
1534
|
+
* @module dsh-workbuddy-xdpool/status-paths
|
|
1535
|
+
*/
|
|
1536
|
+
/** Plugin-owned read-only pool status endpoint (account rows + models + shim). */
|
|
1537
|
+
const POOL_STATUS_PATH = "/plugins/dsh-workbuddy-xdpool/status";
|
|
1538
|
+
/** Plugin-owned local account rescan endpoint (re-read desktop snapshots). */
|
|
1539
|
+
const POOL_RESCAN_PATH = "/plugins/dsh-workbuddy-xdpool/accounts/rescan";
|
|
1540
|
+
/** Plugin-owned cooldown reset endpoint (clear all 429 cooldowns). */
|
|
1541
|
+
const POOL_RESET_COOLDOWN_PATH = "/plugins/dsh-workbuddy-xdpool/cooldowns/reset";
|
|
1542
|
+
/** Plugin-owned daily check-in action endpoint (claim today's reward). */
|
|
1543
|
+
const POOL_CHECKIN_PATH = "/plugins/dsh-workbuddy-xdpool/checkin";
|
|
1544
|
+
/** Plugin-owned model-selection save endpoint (writes the settings section). */
|
|
1545
|
+
const POOL_MODELS_SAVE_PATH = "/plugins/dsh-workbuddy-xdpool/models/save";
|
|
1546
|
+
//#endregion
|
|
1547
|
+
//#region src/web-status.ts
|
|
1548
|
+
/** Redact token-like content before it crosses to the browser. */
|
|
1549
|
+
function safeMessage(error) {
|
|
1550
|
+
return (error instanceof Error ? error.message : String(error)).replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/gu, "[redacted token]").replace(/(\b(?:code|token|refresh_token|access_token)=)[^&\s]+/giu, "$1[redacted]").slice(0, 500);
|
|
1551
|
+
}
|
|
1552
|
+
function json(res, status, body) {
|
|
1553
|
+
const payload = JSON.stringify(body);
|
|
1554
|
+
res.writeHead(status, {
|
|
1555
|
+
"Content-Type": "application/json",
|
|
1556
|
+
"Content-Length": Buffer.byteLength(payload)
|
|
1557
|
+
});
|
|
1558
|
+
res.end(payload);
|
|
1559
|
+
}
|
|
1560
|
+
/** Loopback browser origins only; other devices are refused. */
|
|
1561
|
+
function loopbackOrigin(req) {
|
|
1562
|
+
const origin = req.headers.origin;
|
|
1563
|
+
if (origin === void 0) return true;
|
|
1564
|
+
try {
|
|
1565
|
+
const { hostname } = new URL(origin);
|
|
1566
|
+
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]" || hostname === "::1";
|
|
1567
|
+
} catch {
|
|
1568
|
+
return false;
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
/** Smallest possible POST body reader, capped so a hung or oversized body
|
|
1572
|
+
* cannot pin memory on the Host. Returns `{}` for an empty body. */
|
|
1573
|
+
function readJsonBody(req) {
|
|
1574
|
+
return new Promise((resolve, reject) => {
|
|
1575
|
+
const chunks = [];
|
|
1576
|
+
let size = 0;
|
|
1577
|
+
const LIMIT = 65536;
|
|
1578
|
+
req.on("data", (chunk) => {
|
|
1579
|
+
size += chunk.length;
|
|
1580
|
+
if (size > LIMIT) {
|
|
1581
|
+
reject(/* @__PURE__ */ new Error("request body too large"));
|
|
1582
|
+
req.destroy();
|
|
1583
|
+
return;
|
|
1584
|
+
}
|
|
1585
|
+
chunks.push(chunk);
|
|
1586
|
+
});
|
|
1587
|
+
req.on("end", () => {
|
|
1588
|
+
const text = Buffer.concat(chunks).toString("utf8").trim();
|
|
1589
|
+
if (text === "") {
|
|
1590
|
+
resolve({});
|
|
1591
|
+
return;
|
|
1592
|
+
}
|
|
1593
|
+
try {
|
|
1594
|
+
const parsed = JSON.parse(text);
|
|
1595
|
+
resolve(typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : {});
|
|
1596
|
+
} catch {
|
|
1597
|
+
reject(/* @__PURE__ */ new Error("invalid JSON body"));
|
|
1598
|
+
}
|
|
1599
|
+
});
|
|
1600
|
+
req.on("error", reject);
|
|
1601
|
+
});
|
|
1602
|
+
}
|
|
1603
|
+
/**
|
|
1604
|
+
* Validate an untrusted selection payload.
|
|
1605
|
+
*
|
|
1606
|
+
* Returns undefined for anything malformed so the route answers 400 instead of
|
|
1607
|
+
* writing a partial selection into the settings file. An empty array is
|
|
1608
|
+
* meaningful (`enabledModelIds: []` disables every model, and the card blocks
|
|
1609
|
+
* saving that state) so it is preserved rather than treated as absent.
|
|
1610
|
+
*/
|
|
1611
|
+
function parseSelection(body) {
|
|
1612
|
+
const out = {};
|
|
1613
|
+
for (const key of ["enabledModelIds", "imageModelIds"]) {
|
|
1614
|
+
const value = body[key];
|
|
1615
|
+
if (value === void 0) continue;
|
|
1616
|
+
if (!Array.isArray(value)) return void 0;
|
|
1617
|
+
const ids = [];
|
|
1618
|
+
for (const entry of value) {
|
|
1619
|
+
if (typeof entry !== "string" || entry === "") return void 0;
|
|
1620
|
+
ids.push(entry);
|
|
1621
|
+
}
|
|
1622
|
+
out[key] = ids;
|
|
1623
|
+
}
|
|
1624
|
+
const budgets = body["contextBudgets"];
|
|
1625
|
+
if (budgets !== void 0) {
|
|
1626
|
+
if (typeof budgets !== "object" || budgets === null || Array.isArray(budgets)) return void 0;
|
|
1627
|
+
const map = {};
|
|
1628
|
+
for (const [id, raw] of Object.entries(budgets)) {
|
|
1629
|
+
if (id === "") return void 0;
|
|
1630
|
+
if (typeof raw !== "number" || !Number.isInteger(raw) || raw < 1e3) return void 0;
|
|
1631
|
+
map[id] = raw;
|
|
1632
|
+
}
|
|
1633
|
+
out.contextBudgets = map;
|
|
1634
|
+
}
|
|
1635
|
+
return out;
|
|
1636
|
+
}
|
|
1637
|
+
function toWebAccount(account) {
|
|
1638
|
+
const now = Date.now();
|
|
1639
|
+
const cooling = account.cooldownUntilMs > now;
|
|
1640
|
+
const modelCooldowns = Object.entries(account.modelCooldowns).filter(([, until]) => until > now).sort((a, b) => a[1] - b[1]).map(([modelId, until]) => ({
|
|
1641
|
+
modelId,
|
|
1642
|
+
until: new Date(until).toISOString()
|
|
1643
|
+
}));
|
|
1644
|
+
return {
|
|
1645
|
+
id: account.id,
|
|
1646
|
+
label: account.label,
|
|
1647
|
+
...account.credential.nickname === void 0 ? {} : { nickname: account.credential.nickname },
|
|
1648
|
+
domain: account.credential.domain,
|
|
1649
|
+
...account.credential.expiresAtMs === 0 ? {} : { expiresAt: new Date(account.credential.expiresAtMs).toISOString() },
|
|
1650
|
+
cooling,
|
|
1651
|
+
...cooling ? { cooldownUntil: new Date(account.cooldownUntilMs).toISOString() } : {},
|
|
1652
|
+
...modelCooldowns.length === 0 ? {} : { modelCooldowns },
|
|
1653
|
+
rateLimitHits: account.rateLimitHits
|
|
1654
|
+
};
|
|
1655
|
+
}
|
|
1656
|
+
function toWebModel(model, selection) {
|
|
1657
|
+
const enabled = selection.enabledModelIds;
|
|
1658
|
+
const budget = selection.contextBudgets?.[model.id];
|
|
1659
|
+
const capped = budget !== void 0 && budget > 0 && budget < model.nativeContextWindow;
|
|
1660
|
+
return {
|
|
1661
|
+
id: model.id,
|
|
1662
|
+
name: model.name,
|
|
1663
|
+
...model.multiplier === void 0 ? {} : { multiplier: model.multiplier },
|
|
1664
|
+
...model.tags === void 0 ? {} : { tags: model.tags },
|
|
1665
|
+
...model.supportedEfforts === void 0 || model.supportedEfforts.length === 0 ? {} : { supportedEfforts: model.supportedEfforts },
|
|
1666
|
+
supportsImages: model.supportsImages,
|
|
1667
|
+
contextWindow: capped ? budget : model.nativeContextWindow,
|
|
1668
|
+
nativeContextWindow: model.nativeContextWindow,
|
|
1669
|
+
maxOutputTokens: model.maxOutputTokens,
|
|
1670
|
+
enabled: enabled === void 0 || enabled.includes(model.id)
|
|
1671
|
+
};
|
|
1672
|
+
}
|
|
1673
|
+
/**
|
|
1674
|
+
* Assemble the card's status document. Per-account credits and check-in state
|
|
1675
|
+
* are queried live; a failing query degrades to `creditsError` / `checkinError`
|
|
1676
|
+
* rather than failing the whole document. Never throws.
|
|
1677
|
+
*/
|
|
1678
|
+
async function poolWebStatus(deps, region = "cn") {
|
|
1679
|
+
const accounts = deps.pool.list(region);
|
|
1680
|
+
const regions = ["cn", "global"];
|
|
1681
|
+
const selection = deps.catalog.currentSelection();
|
|
1682
|
+
const rows = [];
|
|
1683
|
+
const now = Date.now();
|
|
1684
|
+
for (const account of accounts) {
|
|
1685
|
+
const row = toWebAccount(account);
|
|
1686
|
+
if (!row.cooling) {
|
|
1687
|
+
try {
|
|
1688
|
+
const credits = await deps.client.fetchCredits(account.credential);
|
|
1689
|
+
Object.assign(row, { credits: {
|
|
1690
|
+
total: credits.total,
|
|
1691
|
+
packages: credits.packages,
|
|
1692
|
+
...credits.expiringSoon === void 0 ? {} : { expiringSoon: credits.expiringSoon },
|
|
1693
|
+
...credits.nearestExpiryMs === void 0 ? {} : { nearestExpiryMs: credits.nearestExpiryMs }
|
|
1694
|
+
} });
|
|
1695
|
+
} catch (error) {
|
|
1696
|
+
Object.assign(row, { creditsError: safeMessage(error) });
|
|
1697
|
+
}
|
|
1698
|
+
try {
|
|
1699
|
+
const checkin = await deps.client.fetchCheckinStatus(account.credential);
|
|
1700
|
+
const web = {
|
|
1701
|
+
active: checkin.active,
|
|
1702
|
+
todayCheckedIn: checkin.todayCheckedIn,
|
|
1703
|
+
streakDays: checkin.streakDays,
|
|
1704
|
+
dailyCredit: checkin.dailyCredit,
|
|
1705
|
+
todayCredit: checkin.todayCredit,
|
|
1706
|
+
isStreakDay: checkin.isStreakDay,
|
|
1707
|
+
nextStreakDay: checkin.nextStreakDay,
|
|
1708
|
+
streakBonusCredit: checkin.streakBonusCredit
|
|
1709
|
+
};
|
|
1710
|
+
Object.assign(row, { checkin: web });
|
|
1711
|
+
} catch (error) {
|
|
1712
|
+
Object.assign(row, { checkinError: safeMessage(error) });
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
rows.push(row);
|
|
1716
|
+
}
|
|
1717
|
+
const cooling = rows.filter((row) => row.cooling).length;
|
|
1718
|
+
const firstUsable = accounts.find((account) => account.cooldownUntilMs <= now);
|
|
1719
|
+
let shim;
|
|
1720
|
+
if (deps.shim === void 0) shim = { running: false };
|
|
1721
|
+
else try {
|
|
1722
|
+
shim = deps.shim();
|
|
1723
|
+
} catch {
|
|
1724
|
+
shim = { running: false };
|
|
1725
|
+
}
|
|
1726
|
+
return {
|
|
1727
|
+
ok: accounts.length > 0 && cooling < accounts.length,
|
|
1728
|
+
accounts: rows,
|
|
1729
|
+
...firstUsable === void 0 ? {} : { activeAccountId: firstUsable.id },
|
|
1730
|
+
cooling,
|
|
1731
|
+
models: deps.catalog.current().map((model) => toWebModel({
|
|
1732
|
+
id: model.id,
|
|
1733
|
+
name: model.name,
|
|
1734
|
+
contextWindow: model.contextWindow,
|
|
1735
|
+
maxOutputTokens: model.maxOutputTokens,
|
|
1736
|
+
nativeContextWindow: model.contextWindow,
|
|
1737
|
+
...model.multiplier === void 0 ? {} : { multiplier: model.multiplier },
|
|
1738
|
+
...model.tags === void 0 ? {} : { tags: model.tags },
|
|
1739
|
+
...model.supportedEfforts === void 0 ? {} : { supportedEfforts: model.supportedEfforts },
|
|
1740
|
+
supportsImages: model.supportsImages
|
|
1741
|
+
}, selection)),
|
|
1742
|
+
selection,
|
|
1743
|
+
region,
|
|
1744
|
+
regions,
|
|
1745
|
+
shim
|
|
1746
|
+
};
|
|
1747
|
+
}
|
|
1748
|
+
/**
|
|
1749
|
+
* Mount the read-only routes on a context where `webServer` is available. The
|
|
1750
|
+
* caller uses `ctx.inject(['webServer'], ...)` so Desktop startup order cannot
|
|
1751
|
+
* make this registration disappear.
|
|
1752
|
+
*/
|
|
1753
|
+
function registerPoolStatusRoute(ctx, deps) {
|
|
1754
|
+
ctx.effect(() => {
|
|
1755
|
+
const disposeStatus = ctx.webServer.register({
|
|
1756
|
+
kind: "exact",
|
|
1757
|
+
path: POOL_STATUS_PATH,
|
|
1758
|
+
handler: async (req, res) => {
|
|
1759
|
+
if (req.method !== "GET") return json(res, 405, { error: "method not allowed" });
|
|
1760
|
+
if (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
|
|
1761
|
+
try {
|
|
1762
|
+
json(res, 200, await poolWebStatus(deps, new URL(req.url ?? "/", "http://localhost").searchParams.get("region") === "global" ? "global" : "cn"));
|
|
1763
|
+
} catch (error) {
|
|
1764
|
+
json(res, 500, { error: safeMessage(error) });
|
|
1765
|
+
}
|
|
1766
|
+
}
|
|
1767
|
+
});
|
|
1768
|
+
const disposeRescan = ctx.webServer.register({
|
|
1769
|
+
kind: "exact",
|
|
1770
|
+
path: POOL_RESCAN_PATH,
|
|
1771
|
+
handler: async (req, res) => {
|
|
1772
|
+
if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
|
|
1773
|
+
if (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
|
|
1774
|
+
try {
|
|
1775
|
+
json(res, 200, { accounts: (await deps.pool.scan()).length });
|
|
1776
|
+
} catch (error) {
|
|
1777
|
+
json(res, 500, { error: safeMessage(error) });
|
|
1778
|
+
}
|
|
1779
|
+
}
|
|
1780
|
+
});
|
|
1781
|
+
const disposeReset = ctx.webServer.register({
|
|
1782
|
+
kind: "exact",
|
|
1783
|
+
path: POOL_RESET_COOLDOWN_PATH,
|
|
1784
|
+
handler: async (req, res) => {
|
|
1785
|
+
if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
|
|
1786
|
+
if (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
|
|
1787
|
+
deps.pool.resetCooldowns();
|
|
1788
|
+
json(res, 200, { ok: true });
|
|
1789
|
+
}
|
|
1790
|
+
});
|
|
1791
|
+
const disposeCheckin = ctx.webServer.register({
|
|
1792
|
+
kind: "exact",
|
|
1793
|
+
path: POOL_CHECKIN_PATH,
|
|
1794
|
+
handler: async (req, res) => {
|
|
1795
|
+
if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
|
|
1796
|
+
if (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
|
|
1797
|
+
try {
|
|
1798
|
+
const body = await readJsonBody(req);
|
|
1799
|
+
const accountId = typeof body["accountId"] === "string" ? body["accountId"] : "";
|
|
1800
|
+
if (accountId === "") return json(res, 400, { error: "accountId is required" });
|
|
1801
|
+
const account = deps.pool.list().find((item) => item.id === accountId);
|
|
1802
|
+
if (account === void 0) return json(res, 404, { error: "unknown account" });
|
|
1803
|
+
const before = await deps.client.fetchCheckinStatus(account.credential);
|
|
1804
|
+
if (!before.active) return json(res, 409, { error: "check-in activity is not active" });
|
|
1805
|
+
if (before.todayCheckedIn) return json(res, 200, {
|
|
1806
|
+
ok: true,
|
|
1807
|
+
alreadyCheckedIn: true,
|
|
1808
|
+
claim: {
|
|
1809
|
+
credit: 0,
|
|
1810
|
+
streakDays: before.streakDays,
|
|
1811
|
+
isStreakDay: before.isStreakDay
|
|
1812
|
+
}
|
|
1813
|
+
});
|
|
1814
|
+
json(res, 200, {
|
|
1815
|
+
ok: true,
|
|
1816
|
+
claim: await deps.client.claimDailyCheckin(account.credential)
|
|
1817
|
+
});
|
|
1818
|
+
} catch (error) {
|
|
1819
|
+
json(res, 500, { error: safeMessage(error) });
|
|
1820
|
+
}
|
|
1821
|
+
}
|
|
1822
|
+
});
|
|
1823
|
+
const disposeModelsSave = ctx.webServer.register({
|
|
1824
|
+
kind: "exact",
|
|
1825
|
+
path: POOL_MODELS_SAVE_PATH,
|
|
1826
|
+
handler: async (req, res) => {
|
|
1827
|
+
if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
|
|
1828
|
+
if (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
|
|
1829
|
+
if (deps.saveSelection === void 0) return json(res, 503, { error: "settings service unavailable; model selection cannot be saved" });
|
|
1830
|
+
try {
|
|
1831
|
+
const selection = parseSelection(await readJsonBody(req));
|
|
1832
|
+
if (selection === void 0) return json(res, 400, { error: "invalid selection payload" });
|
|
1833
|
+
await deps.saveSelection(selection);
|
|
1834
|
+
json(res, 200, {
|
|
1835
|
+
ok: true,
|
|
1836
|
+
selection
|
|
1837
|
+
});
|
|
1838
|
+
} catch (error) {
|
|
1839
|
+
json(res, 500, { error: safeMessage(error) });
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
});
|
|
1843
|
+
return () => {
|
|
1844
|
+
disposeCheckin();
|
|
1845
|
+
disposeModelsSave();
|
|
1846
|
+
disposeReset();
|
|
1847
|
+
disposeRescan();
|
|
1848
|
+
disposeStatus();
|
|
1849
|
+
};
|
|
1850
|
+
}, "dsh-workbuddy-xdpool: Web status route");
|
|
1851
|
+
}
|
|
1852
|
+
//#endregion
|
|
1853
|
+
//#region src/index.ts
|
|
1854
|
+
/**
|
|
1855
|
+
* Host-side plugin entry. Registers the `workbuddy-xdpool` provider into the
|
|
1856
|
+
* Harness LLM seam once the loopback shim holds its port, plus the HTTP status
|
|
1857
|
+
* routes consumed by the CLI.
|
|
1858
|
+
*
|
|
1859
|
+
* @module dsh-workbuddy-xdpool/index
|
|
1860
|
+
*/
|
|
1861
|
+
/** Stable Cordis plugin name. */
|
|
1862
|
+
const name = "llm-workbuddy-xdpool";
|
|
1863
|
+
/** The model registry required before the provider can register. */
|
|
1864
|
+
const inject = ["llm", "settings"];
|
|
1865
|
+
/**
|
|
1866
|
+
* Settings namespace for the WorkBuddy XD Pool card. Registering a section here
|
|
1867
|
+
* is what makes the provider appear on the Models settings page and causes the
|
|
1868
|
+
* Host to mount the plugin's client card under Plugin configuration — exactly
|
|
1869
|
+
* the mechanism the single-account connector uses.
|
|
1870
|
+
*/
|
|
1871
|
+
const WORKBUDDY_POOL_SETTINGS_NS = "workbuddy-xdpool";
|
|
1872
|
+
/** Upper bound the card offers as the "default" context window, in tokens. */
|
|
1873
|
+
const DEFAULT_CONTEXT_BUDGET = 2e5;
|
|
1874
|
+
/**
|
|
1875
|
+
* Plugin configuration schema.
|
|
1876
|
+
*
|
|
1877
|
+
* Mirrors the shape the settings section stores. Every field carries a default
|
|
1878
|
+
* so a config that never touched the card still folds cleanly: a field whose
|
|
1879
|
+
* schema declares no default is read as absent by the settings fold. That is
|
|
1880
|
+
* also why `contextBudgets` is a real dictionary (`z.dict`) - an open object
|
|
1881
|
+
* schema reads as "an object with no fields" and the fold then throws while
|
|
1882
|
+
* the provider row is rendered.
|
|
1883
|
+
*/
|
|
1884
|
+
const Config = z.object({
|
|
1885
|
+
authFile: z.string().description("WorkBuddy desktop auth file (defaults to the app own location)"),
|
|
1886
|
+
cooldownMs: z.number().step(1).min(1e3).default(6e4).description("Rate-limit cooldown per account, in milliseconds"),
|
|
1887
|
+
enabledModelIds: z.array(z.string()).default([]).description("Model ids enabled in the picker (empty = all)"),
|
|
1888
|
+
imageModelIds: z.array(z.string()).default([]).description("Model ids accepting image input (empty = follow upstream)"),
|
|
1889
|
+
contextBudgets: z.dict(z.number().step(1).min(1)).default({}).description("Per-model context-window override, keyed by model id")
|
|
1890
|
+
});
|
|
1891
|
+
/** Live API, published for the CLI. */
|
|
1892
|
+
let api;
|
|
1893
|
+
/** The live API, or undefined when the plugin has not applied yet. */
|
|
1894
|
+
function currentApi() {
|
|
1895
|
+
return api;
|
|
1896
|
+
}
|
|
1897
|
+
/** Test seam: install an API instance without booting cordis. */
|
|
1898
|
+
function setApi(next) {
|
|
1899
|
+
api = next;
|
|
1900
|
+
}
|
|
1901
|
+
/** Assemble the runtime objects without registering anything. */
|
|
1902
|
+
function createCore(logger) {
|
|
1903
|
+
const client = new WorkBuddyUpstreamClient();
|
|
1904
|
+
return {
|
|
1905
|
+
pool: new WorkBuddyAccountPool({
|
|
1906
|
+
...logger === void 0 ? {} : { logger },
|
|
1907
|
+
client
|
|
1908
|
+
}),
|
|
1909
|
+
catalog: new WorkBuddyCatalog(),
|
|
1910
|
+
client
|
|
1911
|
+
};
|
|
1912
|
+
}
|
|
1913
|
+
/**
|
|
1914
|
+
* Start the loopback endpoint, register the `workbuddy-xdpool` provider, and
|
|
1915
|
+
* discover accounts. The provider registers only after `shim.ready` resolves,
|
|
1916
|
+
* because its models read the shim origin at construction time.
|
|
1917
|
+
*/
|
|
1918
|
+
function apply(ctx, config = {}) {
|
|
1919
|
+
const core = createCore(ctx.logger);
|
|
1920
|
+
/**
|
|
1921
|
+
* Invalidate the provider snapshot so the picker re-reads the catalog.
|
|
1922
|
+
*
|
|
1923
|
+
* Seeded with a no-op and reassigned once the adapters exist. The settings
|
|
1924
|
+
* section calls its `onChange` hook SYNCHRONOUSLY from `installSection`,
|
|
1925
|
+
* before registration has run, so a plain `let builtAdapter` declared later
|
|
1926
|
+
* would be read from its temporal dead zone ("Cannot access `builtAdapter`
|
|
1927
|
+
* before initialization") and abort the whole apply — which in turn leaves the
|
|
1928
|
+
* providers undeclared and the settings page unable to render them.
|
|
1929
|
+
*/
|
|
1930
|
+
/** The domestic adapter, published for the CLI after registration. */
|
|
1931
|
+
let builtAdapter;
|
|
1932
|
+
let invalidateCatalog = () => {};
|
|
1933
|
+
let current = () => config;
|
|
1934
|
+
const sectionHooks = {
|
|
1935
|
+
setSource(source) {
|
|
1936
|
+
current = source;
|
|
1937
|
+
},
|
|
1938
|
+
onChange() {
|
|
1939
|
+
applyConfigFromSource();
|
|
1940
|
+
}
|
|
1941
|
+
};
|
|
1942
|
+
const applyConfigFromSource = () => {
|
|
1943
|
+
const { authFile, cooldownMs, enabledModelIds, imageModelIds, contextBudgets } = current();
|
|
1944
|
+
core.pool.applyConfig({
|
|
1945
|
+
...authFile === void 0 ? {} : { authDirs: [dirname(authFile)] },
|
|
1946
|
+
...cooldownMs === void 0 ? {} : { cooldownMs }
|
|
1947
|
+
});
|
|
1948
|
+
core.catalog.applySelection({
|
|
1949
|
+
...enabledModelIds === void 0 ? {} : { enabledModelIds },
|
|
1950
|
+
...imageModelIds === void 0 ? {} : { imageModelIds },
|
|
1951
|
+
...contextBudgets === void 0 ? {} : { contextBudgets }
|
|
1952
|
+
});
|
|
1953
|
+
invalidateCatalog();
|
|
1954
|
+
};
|
|
1955
|
+
const settingsService = ctx.settings;
|
|
1956
|
+
if (typeof settingsService.installSection === "function") settingsService.installSection(ctx, WORKBUDDY_POOL_SETTINGS_NS, Config, config, sectionHooks);
|
|
1957
|
+
else ctx.logger.warn?.("dsh-workbuddy-xdpool: settings service has no installSection; card will not mount");
|
|
1958
|
+
/**
|
|
1959
|
+
* Write one key of the plugin's own settings section. Only ever called with
|
|
1960
|
+
* the three model-selection keys, so the settings file cannot be steered from
|
|
1961
|
+
* the browser; the catalog re-reads through `onChange` either way.
|
|
1962
|
+
*/
|
|
1963
|
+
const setSetting = (key, value) => {
|
|
1964
|
+
if (value === void 0) return;
|
|
1965
|
+
const write = settingsService?.set;
|
|
1966
|
+
if (write === void 0) return;
|
|
1967
|
+
try {
|
|
1968
|
+
write.call(settingsService, key, value);
|
|
1969
|
+
} catch (error) {
|
|
1970
|
+
ctx.logger.warn?.(`dsh-workbuddy-xdpool: failed to persist ${key}`, error);
|
|
1971
|
+
}
|
|
1972
|
+
};
|
|
1973
|
+
const shims = {
|
|
1974
|
+
cn: createWorkBuddyShim({
|
|
1975
|
+
pool: core.pool,
|
|
1976
|
+
client: core.client,
|
|
1977
|
+
catalog: core.catalog,
|
|
1978
|
+
logger: ctx.logger,
|
|
1979
|
+
region: "cn"
|
|
1980
|
+
}),
|
|
1981
|
+
global: createWorkBuddyShim({
|
|
1982
|
+
pool: core.pool,
|
|
1983
|
+
client: core.client,
|
|
1984
|
+
catalog: core.catalog,
|
|
1985
|
+
logger: ctx.logger,
|
|
1986
|
+
region: "global"
|
|
1987
|
+
})
|
|
1988
|
+
};
|
|
1989
|
+
const shim = shims.cn;
|
|
1990
|
+
/** Resolve a region's loopback origin once it has bound a port. */
|
|
1991
|
+
const shimInfo = (which) => {
|
|
1992
|
+
try {
|
|
1993
|
+
return {
|
|
1994
|
+
running: true,
|
|
1995
|
+
baseUrl: shims[which].baseUrl()
|
|
1996
|
+
};
|
|
1997
|
+
} catch {
|
|
1998
|
+
return { running: false };
|
|
1999
|
+
}
|
|
2000
|
+
};
|
|
2001
|
+
let stopped = false;
|
|
2002
|
+
ctx.effect(() => () => {
|
|
2003
|
+
stopped = true;
|
|
2004
|
+
shims.cn.close();
|
|
2005
|
+
shims.global.close();
|
|
2006
|
+
});
|
|
2007
|
+
ctx.inject(["webServer"], (webCtx) => registerPoolStatusRoute(webCtx, {
|
|
2008
|
+
pool: core.pool,
|
|
2009
|
+
catalog: core.catalog,
|
|
2010
|
+
client: core.client,
|
|
2011
|
+
shim: () => shimInfo("cn"),
|
|
2012
|
+
saveSelection: (selection) => {
|
|
2013
|
+
setSetting("enabledModelIds", selection.enabledModelIds);
|
|
2014
|
+
setSetting("imageModelIds", selection.imageModelIds);
|
|
2015
|
+
setSetting("contextBudgets", selection.contextBudgets);
|
|
2016
|
+
}
|
|
2017
|
+
}));
|
|
2018
|
+
api = {
|
|
2019
|
+
...core,
|
|
2020
|
+
shim,
|
|
2021
|
+
get adapter() {
|
|
2022
|
+
return builtAdapter;
|
|
2023
|
+
},
|
|
2024
|
+
async rescan() {
|
|
2025
|
+
const accounts = await core.pool.scan();
|
|
2026
|
+
ctx.logger.info?.(`dsh-workbuddy-xdpool: discovered ${accounts.length} account(s)`);
|
|
2027
|
+
return accounts.length;
|
|
2028
|
+
},
|
|
2029
|
+
async status(includeCredits = false) {
|
|
2030
|
+
return buildStatus({
|
|
2031
|
+
pool: core.pool,
|
|
2032
|
+
catalog: core.catalog,
|
|
2033
|
+
client: core.client,
|
|
2034
|
+
shim: shimInfo("cn"),
|
|
2035
|
+
includeCredits
|
|
2036
|
+
});
|
|
2037
|
+
},
|
|
2038
|
+
resetCooldowns() {
|
|
2039
|
+
core.pool.resetCooldowns();
|
|
2040
|
+
}
|
|
2041
|
+
};
|
|
2042
|
+
Promise.all([shims.cn.ready, shims.global.ready]).then(async () => {
|
|
2043
|
+
if (stopped) return;
|
|
2044
|
+
try {
|
|
2045
|
+
const adaptersByRegion = {
|
|
2046
|
+
cn: createWorkBuddyAdapter({
|
|
2047
|
+
shim: shims.cn,
|
|
2048
|
+
catalog: core.catalog,
|
|
2049
|
+
providerId: POOL_PROVIDER_BY_REGION.cn,
|
|
2050
|
+
displayName: POOL_NAME_BY_REGION.cn
|
|
2051
|
+
}),
|
|
2052
|
+
global: createWorkBuddyAdapter({
|
|
2053
|
+
shim: shims.global,
|
|
2054
|
+
catalog: core.catalog,
|
|
2055
|
+
providerId: POOL_PROVIDER_BY_REGION.global,
|
|
2056
|
+
displayName: POOL_NAME_BY_REGION.global
|
|
2057
|
+
})
|
|
2058
|
+
};
|
|
2059
|
+
let releaseAdapterCn;
|
|
2060
|
+
let releaseAdapterGlobal;
|
|
2061
|
+
let releaseDirectory;
|
|
2062
|
+
try {
|
|
2063
|
+
releaseAdapterCn = ctx.llm.registerAdapter([POOL_PROVIDER_BY_REGION.cn], adaptersByRegion.cn.adapter);
|
|
2064
|
+
releaseAdapterGlobal = ctx.llm.registerAdapter([POOL_PROVIDER_BY_REGION.global], adaptersByRegion.global.adapter);
|
|
2065
|
+
releaseDirectory = ctx.llm.registerConfigurableProviders([{
|
|
2066
|
+
provider: POOL_PROVIDER_BY_REGION.cn,
|
|
2067
|
+
displayName: POOL_NAME_BY_REGION.cn,
|
|
2068
|
+
settingsNs: WORKBUDDY_POOL_SETTINGS_NS,
|
|
2069
|
+
settingsPath: [],
|
|
2070
|
+
declared: false
|
|
2071
|
+
}, {
|
|
2072
|
+
provider: POOL_PROVIDER_BY_REGION.global,
|
|
2073
|
+
displayName: POOL_NAME_BY_REGION.global,
|
|
2074
|
+
settingsNs: WORKBUDDY_POOL_SETTINGS_NS,
|
|
2075
|
+
settingsPath: [],
|
|
2076
|
+
declared: false
|
|
2077
|
+
}]);
|
|
2078
|
+
} finally {
|
|
2079
|
+
if (releaseAdapterCn === void 0 || releaseAdapterGlobal === void 0 || releaseDirectory === void 0) {
|
|
2080
|
+
releaseAdapterCn?.();
|
|
2081
|
+
releaseAdapterGlobal?.();
|
|
2082
|
+
releaseDirectory?.();
|
|
2083
|
+
}
|
|
2084
|
+
}
|
|
2085
|
+
builtAdapter = adaptersByRegion.cn;
|
|
2086
|
+
invalidateCatalog = () => {
|
|
2087
|
+
adaptersByRegion.cn.invalidate();
|
|
2088
|
+
adaptersByRegion.global.invalidate();
|
|
2089
|
+
};
|
|
2090
|
+
/** Release everything the two providers registered, once. */
|
|
2091
|
+
const releaseProviders = () => {
|
|
2092
|
+
releaseAdapterCn?.();
|
|
2093
|
+
releaseAdapterGlobal?.();
|
|
2094
|
+
releaseDirectory?.();
|
|
2095
|
+
};
|
|
2096
|
+
try {
|
|
2097
|
+
ctx.effect(() => releaseProviders);
|
|
2098
|
+
} catch {
|
|
2099
|
+
releaseProviders();
|
|
2100
|
+
}
|
|
2101
|
+
ctx.llm.registerModelDiscovery(WORKBUDDY_POOL_SETTINGS_NS, async (request) => {
|
|
2102
|
+
if (request.provider !== "workbuddy-xdpool" && request.provider !== "workbuddy-xdpool-global") return [];
|
|
2103
|
+
return core.catalog.visible().map((model) => ({
|
|
2104
|
+
id: model.id,
|
|
2105
|
+
name: model.name,
|
|
2106
|
+
contextWindow: model.contextWindow,
|
|
2107
|
+
maxTokens: model.maxOutputTokens,
|
|
2108
|
+
inputModalities: model.supportsImages ? ["text", "image"] : ["text"]
|
|
2109
|
+
}));
|
|
2110
|
+
});
|
|
2111
|
+
ctx.logger.info?.(`dsh-workbuddy-xdpool: providers registered at cn=${shims.cn.baseUrl()} global=${shims.global.baseUrl()}`);
|
|
2112
|
+
} catch (error) {
|
|
2113
|
+
ctx.logger.error("dsh-workbuddy-xdpool: provider registration failed", error);
|
|
2114
|
+
return;
|
|
2115
|
+
}
|
|
2116
|
+
if (stopped) return;
|
|
2117
|
+
core.pool.scan().then((accounts) => {
|
|
2118
|
+
ctx.logger.info?.(`dsh-workbuddy-xdpool: ${accounts.length} WorkBuddy account(s) in rotation`);
|
|
2119
|
+
}, (error) => {
|
|
2120
|
+
ctx.logger.warn("dsh-workbuddy-xdpool: account discovery failed", error);
|
|
2121
|
+
});
|
|
2122
|
+
(async () => {
|
|
2123
|
+
try {
|
|
2124
|
+
const credential = (await core.pool.scan())[0]?.credential;
|
|
2125
|
+
if (credential === void 0) return;
|
|
2126
|
+
const models = await core.client.fetchModels(credential);
|
|
2127
|
+
core.catalog.updateFromUpstream(models);
|
|
2128
|
+
invalidateCatalog();
|
|
2129
|
+
ctx.logger.info?.(`dsh-workbuddy-xdpool: live catalog seeded with ${models.length} model(s)`);
|
|
2130
|
+
} catch (error) {
|
|
2131
|
+
ctx.logger.warn("dsh-workbuddy-xdpool: live model catalog unavailable; using static fallback", error);
|
|
2132
|
+
}
|
|
2133
|
+
})();
|
|
2134
|
+
}, (error) => {
|
|
2135
|
+
ctx.logger.error("dsh-workbuddy-xdpool: shim failed to listen", error);
|
|
2136
|
+
});
|
|
2137
|
+
}
|
|
2138
|
+
//#endregion
|
|
2139
|
+
export { Config, DEFAULT_CONTEXT_BUDGET, FALLBACK_WORKBUDDY_MODELS, POOL_CHECKIN_PATH, POOL_MODELS_SAVE_PATH, POOL_RESCAN_PATH, POOL_RESET_COOLDOWN_PATH, POOL_STATUS_PATH, WORKBUDDY_AUTH_FILE_ENV, WORKBUDDY_LIVE_FILENAME, WORKBUDDY_POOL_PROVIDER, WORKBUDDY_POOL_SETTINGS_NS, WorkBuddyAccountPool, WorkBuddyCatalog, WorkBuddyUpstreamClient, apply, buildStatus, candidateAuthDirs, classifyUpstreamError, createCore, createWorkBuddyAdapter, createWorkBuddyShim, currentApi, defaultDesktopAuthDirs, formatRates, formatStatus, inject, name, parseRateLimitReset, parseWorkBuddyAuth, setApi, workbuddyAccountId };
|