pi-freeflow 1.28.0 → 1.29.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 +14 -0
- package/README.md +10 -9
- package/package.json +1 -1
- package/src/commands.ts +29 -16
- package/src/opencode-fingerprint.ts +844 -743
- package/src/probe.ts +153 -153
- package/src/proxy.ts +10 -2
- package/src/relay-state.ts +21 -1
- package/src/stream-pipe.ts +720 -306
- package/src/tool-translation.ts +93 -5
|
@@ -1,743 +1,844 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* OpenCode Zen free-tier client fingerprint enforcement & SSE stream aggregator.
|
|
3
|
-
*
|
|
4
|
-
* Upstream OpenCode Zen (/zen/v1/chat/completions, /zen/v1/responses and
|
|
5
|
-
* /zen/v1/messages) validates requests against the official OpenCode agentic
|
|
6
|
-
* client fingerprint. If any of the following 4 axes are missing, upstream
|
|
7
|
-
* answers HTTP 403 FreeTierError:
|
|
8
|
-
* 1. User-Agent (opencode/1.18.31, >= 1.17.0)
|
|
9
|
-
* 2. x-opencode-session (canonical ses_[0-9a-f]{12}[0-9A-Za-z]{14} format)
|
|
10
|
-
* 3. Tools: must declare the placeholder sextet {bash, glob, grep, read, edit, write}
|
|
11
|
-
* 4. Streaming: must be stream: true
|
|
12
|
-
*
|
|
13
|
-
* This module ensures axes 3 and 4 are enforced on outgoing requests, and handles
|
|
14
|
-
* bidirectional streaming conversion: if the client requested non-streaming
|
|
15
|
-
* (stream: false), upstream is still sent stream: true to pass the gate, and the
|
|
16
|
-
* resulting SSE stream is accumulated and converted back to a single JSON response.
|
|
17
|
-
*
|
|
18
|
-
* freeflow serves ONLY OMP and Pi hosts. Either host may send any of its tools
|
|
19
|
-
* (see src/tool-translation.ts for the canonical inventories) in any wire shape,
|
|
20
|
-
* so enforcement renames caller placeholders to lowercase (Bash -> bash,
|
|
21
|
-
* retargeting tool_choice), translates caller tools to the target path's shape
|
|
22
|
-
* (Pi find -> upstream glob via the translator), and only then injects the
|
|
23
|
-
* missing placeholders. Caller tools (ls, powershell, find, web_search, ...)
|
|
24
|
-
* are never dropped. Injected placeholder calls are cloaked from downstream
|
|
25
|
-
* responses on all three paths; caller casing is restored via a bounded
|
|
26
|
-
* per-request map (never module-global).
|
|
27
|
-
*/
|
|
28
|
-
import {
|
|
29
|
-
COMPAT_TOOL_DESCRIPTION,
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
*
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
(
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
if (
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
const
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
*
|
|
317
|
-
*
|
|
318
|
-
*/
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
findGlob
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
if (
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
const
|
|
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
|
-
findGlob
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
const
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
(
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
caseRestore
|
|
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
|
-
if (
|
|
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
|
-
const
|
|
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
|
-
|
|
1
|
+
/**
|
|
2
|
+
* OpenCode Zen free-tier client fingerprint enforcement & SSE stream aggregator.
|
|
3
|
+
*
|
|
4
|
+
* Upstream OpenCode Zen (/zen/v1/chat/completions, /zen/v1/responses and
|
|
5
|
+
* /zen/v1/messages) validates requests against the official OpenCode agentic
|
|
6
|
+
* client fingerprint. If any of the following 4 axes are missing, upstream
|
|
7
|
+
* answers HTTP 403 FreeTierError:
|
|
8
|
+
* 1. User-Agent (opencode/1.18.31, >= 1.17.0)
|
|
9
|
+
* 2. x-opencode-session (canonical ses_[0-9a-f]{12}[0-9A-Za-z]{14} format)
|
|
10
|
+
* 3. Tools: must declare the placeholder sextet {bash, glob, grep, read, edit, write}
|
|
11
|
+
* 4. Streaming: must be stream: true
|
|
12
|
+
*
|
|
13
|
+
* This module ensures axes 3 and 4 are enforced on outgoing requests, and handles
|
|
14
|
+
* bidirectional streaming conversion: if the client requested non-streaming
|
|
15
|
+
* (stream: false), upstream is still sent stream: true to pass the gate, and the
|
|
16
|
+
* resulting SSE stream is accumulated and converted back to a single JSON response.
|
|
17
|
+
*
|
|
18
|
+
* freeflow serves ONLY OMP and Pi hosts. Either host may send any of its tools
|
|
19
|
+
* (see src/tool-translation.ts for the canonical inventories) in any wire shape,
|
|
20
|
+
* so enforcement renames caller placeholders to lowercase (Bash -> bash,
|
|
21
|
+
* retargeting tool_choice), translates caller tools to the target path's shape
|
|
22
|
+
* (Pi find -> upstream glob via the translator), and only then injects the
|
|
23
|
+
* missing placeholders. Caller tools (ls, powershell, find, web_search, ...)
|
|
24
|
+
* are never dropped. Injected placeholder calls are cloaked from downstream
|
|
25
|
+
* responses on all three paths; caller casing is restored via a bounded
|
|
26
|
+
* per-request map (never module-global).
|
|
27
|
+
*/
|
|
28
|
+
import {
|
|
29
|
+
COMPAT_TOOL_DESCRIPTION,
|
|
30
|
+
OMP_FINGERPRINT_DEFS,
|
|
31
|
+
OPENCODE_FINGERPRINT_TOOLS,
|
|
32
|
+
buildFindGlobRestore,
|
|
33
|
+
isOmpLikeCaller,
|
|
34
|
+
restoreToolNameForCaller,
|
|
35
|
+
retargetToolChoiceForUpstream,
|
|
36
|
+
translateToolsForPath,
|
|
37
|
+
upstreamToolNameFor,
|
|
38
|
+
type FindGlobRestore,
|
|
39
|
+
type FingerprintToolName,
|
|
40
|
+
} from "./tool-translation.ts";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Safely extract the tool name whether formatted in Chat Completions style
|
|
44
|
+
* ({ function: { name } }) or flat Responses style ({ name }).
|
|
45
|
+
*/
|
|
46
|
+
export function toolNameOf(tool: unknown): string {
|
|
47
|
+
if (!tool || typeof tool !== "object" || Array.isArray(tool)) return "";
|
|
48
|
+
const t = tool as Record<string, unknown>;
|
|
49
|
+
const fn =
|
|
50
|
+
t.function && typeof t.function === "object" && !Array.isArray(t.function)
|
|
51
|
+
? (t.function as Record<string, unknown>)
|
|
52
|
+
: null;
|
|
53
|
+
const raw = typeof t.name === "string" ? t.name : typeof fn?.name === "string" ? fn.name : "";
|
|
54
|
+
return raw.trim();
|
|
55
|
+
}
|
|
56
|
+
export { OPENCODE_FINGERPRINT_TOOLS };
|
|
57
|
+
export type { FingerprintToolName, FindGlobRestore } from "./tool-translation.ts";
|
|
58
|
+
|
|
59
|
+
/** Lowercase placeholder names the gate expects (canonical translator set). */
|
|
60
|
+
const PLACEHOLDER_BY_LOWER_NAME: Record<string, true> = {};
|
|
61
|
+
for (const n of OPENCODE_FINGERPRINT_TOOLS) PLACEHOLDER_BY_LOWER_NAME[n.toLowerCase()] = true;
|
|
62
|
+
|
|
63
|
+
/** True for placeholder names (case-insensitive): injected compat tools only. */
|
|
64
|
+
export function isPlaceholderToolName(name: unknown): boolean {
|
|
65
|
+
return typeof name === "string" && PLACEHOLDER_BY_LOWER_NAME[name.toLowerCase()] === true;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Fill for one missing fingerprint slot: the shared OMP real definition when
|
|
69
|
+
* the caller is OMP-like, otherwise the empty COMPAT placeholder Pi keeps.
|
|
70
|
+
* Single lockstep source for the three wire-shape ensure functions below.
|
|
71
|
+
*/
|
|
72
|
+
function fingerprintFill(name: FingerprintToolName, omp: boolean): { description: string; schema: Record<string, unknown> } {
|
|
73
|
+
if (!omp) return { description: COMPAT_TOOL_DESCRIPTION, schema: { type: "object", properties: {} } };
|
|
74
|
+
const def = OMP_FINGERPRINT_DEFS[name];
|
|
75
|
+
return { description: def.description, schema: { ...(def.parameters as Record<string, unknown>) } };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Bounded per-request restore map: lowercase placeholder name -> caller casing.
|
|
80
|
+
* Built fresh by normalizePlaceholderCase for each enforced request and threaded
|
|
81
|
+
* explicitly through the SSE converters; never module-global.
|
|
82
|
+
*/
|
|
83
|
+
export type CaseRestoreMap = Record<string, string>;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Lowercase caller placeholder declarations (Bash -> bash) in place before
|
|
87
|
+
* injection, so the gate sees canonical names and injection stays duplicate-free.
|
|
88
|
+
* Non-placeholder tools (ls, powershell, find, web_search, ...) pass untouched.
|
|
89
|
+
* Returns the bounded restore map for downstream response cloaking.
|
|
90
|
+
*/
|
|
91
|
+
export function normalizePlaceholderCase(body: Record<string, unknown>): CaseRestoreMap {
|
|
92
|
+
const restore: CaseRestoreMap = {};
|
|
93
|
+
if (!Array.isArray(body.tools)) return restore;
|
|
94
|
+
for (const tool of body.tools) {
|
|
95
|
+
if (!tool || typeof tool !== "object" || Array.isArray(tool)) continue;
|
|
96
|
+
const t = tool as Record<string, unknown>;
|
|
97
|
+
const fn =
|
|
98
|
+
t.function && typeof t.function === "object" && !Array.isArray(t.function)
|
|
99
|
+
? (t.function as Record<string, unknown>)
|
|
100
|
+
: null;
|
|
101
|
+
const holder = fn && typeof fn.name === "string" ? fn : typeof t.name === "string" ? t : null;
|
|
102
|
+
if (!holder) continue;
|
|
103
|
+
const original = holder.name as string;
|
|
104
|
+
const lower = original.toLowerCase();
|
|
105
|
+
if (PLACEHOLDER_BY_LOWER_NAME[lower] !== true || original === lower) continue;
|
|
106
|
+
if (restore[lower] === undefined) restore[lower] = original;
|
|
107
|
+
holder.name = lower;
|
|
108
|
+
}
|
|
109
|
+
return restore;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Retarget a caller tool_choice that names a placeholder in original casing
|
|
114
|
+
* ({function:{name}}, {name}) to the lowercased name. String choices
|
|
115
|
+
* ("auto", ...) and non-placeholder names pass untouched. Request-side only.
|
|
116
|
+
*/
|
|
117
|
+
export function retargetToolChoice(body: Record<string, unknown>): void {
|
|
118
|
+
const choice = body.tool_choice;
|
|
119
|
+
if (!choice || typeof choice !== "object" || Array.isArray(choice)) return;
|
|
120
|
+
const c = choice as Record<string, unknown>;
|
|
121
|
+
const fn =
|
|
122
|
+
c.function && typeof c.function === "object" && !Array.isArray(c.function)
|
|
123
|
+
? (c.function as Record<string, unknown>)
|
|
124
|
+
: null;
|
|
125
|
+
if (fn && typeof fn.name === "string" && isPlaceholderToolName(fn.name)) {
|
|
126
|
+
fn.name = (fn.name as string).toLowerCase();
|
|
127
|
+
}
|
|
128
|
+
if (typeof c.name === "string" && isPlaceholderToolName(c.name)) {
|
|
129
|
+
c.name = (c.name as string).toLowerCase();
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Restore caller casing on one downstream tool name (bounded map, else verbatim). */
|
|
134
|
+
function restoreName(name: string, restore?: CaseRestoreMap): string {
|
|
135
|
+
if (restore) {
|
|
136
|
+
const hit = restore[name.toLowerCase()];
|
|
137
|
+
if (hit !== undefined) return hit;
|
|
138
|
+
}
|
|
139
|
+
return name;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Full downstream restore: caller casing first, then the translator's
|
|
144
|
+
* find->glob mapping (upstream glob back to caller find when renamed).
|
|
145
|
+
*/
|
|
146
|
+
function restoreCallerName(name: string, caseRestore?: CaseRestoreMap, findGlob?: FindGlobRestore): string {
|
|
147
|
+
const cased = restoreName(name, caseRestore);
|
|
148
|
+
return findGlob ? restoreToolNameForCaller(cased, findGlob) : cased;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Merge missing fingerprint declarations (canonical translator set) into Chat
|
|
153
|
+
* Completions bodies. Case-insensitive and idempotent: Bash counts as bash.
|
|
154
|
+
* Preserves caller tools verbatim; missing slots become executable real
|
|
155
|
+
* definitions for OMP-like callers, empty no-ops otherwise. Explicit `ompLike`
|
|
156
|
+
* overrides auto-detection (pass pre-translation caller names when find was
|
|
157
|
+
* already renamed to glob).
|
|
158
|
+
*/
|
|
159
|
+
export function ensureChatFingerprintTools(body: Record<string, unknown>, ompLike?: boolean): void {
|
|
160
|
+
if (!body || typeof body !== "object") return;
|
|
161
|
+
const present = new Set<string>();
|
|
162
|
+
if (Array.isArray(body.tools)) {
|
|
163
|
+
for (const tool of body.tools) {
|
|
164
|
+
const name = toolNameOf(tool);
|
|
165
|
+
if (name) present.add(name.toLowerCase());
|
|
166
|
+
}
|
|
167
|
+
} else {
|
|
168
|
+
body.tools = [];
|
|
169
|
+
}
|
|
170
|
+
const omp = ompLike ?? isOmpLikeCaller(present);
|
|
171
|
+
for (const name of OPENCODE_FINGERPRINT_TOOLS) {
|
|
172
|
+
if (present.has(name)) continue;
|
|
173
|
+
const fill = fingerprintFill(name, omp);
|
|
174
|
+
(body.tools as unknown[]).push({
|
|
175
|
+
type: "function",
|
|
176
|
+
function: {
|
|
177
|
+
name,
|
|
178
|
+
description: fill.description,
|
|
179
|
+
parameters: fill.schema,
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
present.add(name);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Merge missing fingerprint declarations (canonical translator set) into
|
|
188
|
+
* Responses API bodies. Case-insensitive and idempotent.
|
|
189
|
+
* Uses the flat Responses tool shape ({ type: "function", name, description, parameters }).
|
|
190
|
+
* Missing slots become executable real definitions for OMP-like callers,
|
|
191
|
+
* empty no-ops otherwise; see ensureChatFingerprintTools for the override.
|
|
192
|
+
*/
|
|
193
|
+
export function ensureResponsesFingerprintTools(body: Record<string, unknown>, ompLike?: boolean): void {
|
|
194
|
+
if (!body || typeof body !== "object") return;
|
|
195
|
+
const present = new Set<string>();
|
|
196
|
+
if (Array.isArray(body.tools)) {
|
|
197
|
+
for (const tool of body.tools) {
|
|
198
|
+
const name = toolNameOf(tool);
|
|
199
|
+
if (name) present.add(name.toLowerCase());
|
|
200
|
+
}
|
|
201
|
+
} else {
|
|
202
|
+
body.tools = [];
|
|
203
|
+
}
|
|
204
|
+
const omp = ompLike ?? isOmpLikeCaller(present);
|
|
205
|
+
for (const name of OPENCODE_FINGERPRINT_TOOLS) {
|
|
206
|
+
if (present.has(name)) continue;
|
|
207
|
+
const fill = fingerprintFill(name, omp);
|
|
208
|
+
(body.tools as unknown[]).push({
|
|
209
|
+
type: "function",
|
|
210
|
+
name,
|
|
211
|
+
description: fill.description,
|
|
212
|
+
parameters: fill.schema,
|
|
213
|
+
});
|
|
214
|
+
present.add(name);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Merge missing fingerprint declarations (canonical translator set) into
|
|
220
|
+
* Anthropic Messages bodies. Case-insensitive and idempotent.
|
|
221
|
+
* Uses the Anthropic tool shape ({ name, description, input_schema }).
|
|
222
|
+
* Missing slots become executable real definitions for OMP-like callers,
|
|
223
|
+
* empty no-ops otherwise; see ensureChatFingerprintTools for the override.
|
|
224
|
+
*/
|
|
225
|
+
export function ensureMessagesFingerprintTools(body: Record<string, unknown>, ompLike?: boolean): void {
|
|
226
|
+
if (!body || typeof body !== "object") return;
|
|
227
|
+
const present = new Set<string>();
|
|
228
|
+
if (Array.isArray(body.tools)) {
|
|
229
|
+
for (const tool of body.tools) {
|
|
230
|
+
const name = toolNameOf(tool);
|
|
231
|
+
if (name) present.add(name.toLowerCase());
|
|
232
|
+
}
|
|
233
|
+
} else {
|
|
234
|
+
body.tools = [];
|
|
235
|
+
}
|
|
236
|
+
const omp = ompLike ?? isOmpLikeCaller(present);
|
|
237
|
+
for (const name of OPENCODE_FINGERPRINT_TOOLS) {
|
|
238
|
+
if (present.has(name)) continue;
|
|
239
|
+
const fill = fingerprintFill(name, omp);
|
|
240
|
+
(body.tools as unknown[]).push({
|
|
241
|
+
name,
|
|
242
|
+
description: fill.description,
|
|
243
|
+
input_schema: fill.schema,
|
|
244
|
+
});
|
|
245
|
+
present.add(name);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Normalize a Zen Responses body to what upstream accepts. OMP/Pi hosts
|
|
251
|
+
* sometimes carry Chat Completions leftovers on the responses path and
|
|
252
|
+
* upstream rejects them with 400:
|
|
253
|
+
* - `messages` becomes `input` (copied when `input` is absent, dropped either
|
|
254
|
+
* way — the responses API has no `messages` field).
|
|
255
|
+
* - `max_tokens` / `max_completion_tokens` become `max_output_tokens` (copied
|
|
256
|
+
* when absent, dropped either way).
|
|
257
|
+
* - `temperature` / `top_p` are dropped (Responses rejects them).
|
|
258
|
+
* - `parallel_tool_calls: false` is dropped; absent-or-true rides verbatim.
|
|
259
|
+
* - `tool_choice` is auto-or-absent: `"none"` (string or `{ type: "none" }`)
|
|
260
|
+
* is dropped, everything else rides (named choices still retarget find->glob).
|
|
261
|
+
* - `store` defaults to false (never persisted server-side).
|
|
262
|
+
* - spark reasoning effort clamps `max` / `ultra` to `xhigh`: the spark
|
|
263
|
+
* thinkingLevelMap declares `max: null` (unoffered) and upstream serves at
|
|
264
|
+
* most `xhigh`, so a stale or hand-built `max`/`ultra` effort would 400.
|
|
265
|
+
* Only spark model ids are clamped; `off: null` stays untouched everywhere.
|
|
266
|
+
* Zen-responses only: chat/messages paths keep their own fields, and Kilo/Cline
|
|
267
|
+
* bodies never reach here (the proxy fingerprints Zen bodies exclusively).
|
|
268
|
+
*/
|
|
269
|
+
export function normalizeResponsesBody(body: Record<string, unknown>): void {
|
|
270
|
+
if (!body || typeof body !== "object") return;
|
|
271
|
+
if (body.input === undefined && Array.isArray(body.messages)) {
|
|
272
|
+
body.input = body.messages;
|
|
273
|
+
}
|
|
274
|
+
delete body.messages;
|
|
275
|
+
if (body.max_output_tokens === undefined) {
|
|
276
|
+
if (typeof body.max_tokens === "number") body.max_output_tokens = body.max_tokens;
|
|
277
|
+
else if (typeof body.max_completion_tokens === "number") body.max_output_tokens = body.max_completion_tokens;
|
|
278
|
+
}
|
|
279
|
+
delete body.max_tokens;
|
|
280
|
+
delete body.max_completion_tokens;
|
|
281
|
+
delete body.temperature;
|
|
282
|
+
delete body.top_p;
|
|
283
|
+
if (body.parallel_tool_calls === false) delete body.parallel_tool_calls;
|
|
284
|
+
const choice = body.tool_choice;
|
|
285
|
+
if (typeof choice === "string" ? choice.toLowerCase() === "none" : (
|
|
286
|
+
choice !== null && typeof choice === "object" && !Array.isArray(choice)
|
|
287
|
+
&& (choice as Record<string, unknown>).type === "none"
|
|
288
|
+
)) delete body.tool_choice;
|
|
289
|
+
if (typeof body.model === "string" && /muse-spark/i.test(body.model)) {
|
|
290
|
+
const reasoning = body.reasoning;
|
|
291
|
+
if (reasoning !== null && typeof reasoning === "object" && !Array.isArray(reasoning)) {
|
|
292
|
+
const effort = (reasoning as Record<string, unknown>).effort;
|
|
293
|
+
if (typeof effort === "string" && (effort.toLowerCase() === "max" || effort.toLowerCase() === "ultra")) {
|
|
294
|
+
(reasoning as Record<string, unknown>).effort = "xhigh";
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
for (const key of ["reasoning_effort", "reasoningEffort"] as const) {
|
|
298
|
+
const flat = body[key];
|
|
299
|
+
if (typeof flat === "string" && (flat.toLowerCase() === "max" || flat.toLowerCase() === "ultra")) body[key] = "xhigh";
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
if (body.store === undefined) body.store = false;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Enforce the full OpenCode free-tier client fingerprint on a parsed request body.
|
|
307
|
+
* Caller placeholders are lowercased (Bash -> bash, tool_choice retargeted),
|
|
308
|
+
* tools translated to the target path's wire shape (Pi find -> upstream glob via
|
|
309
|
+
* src/tool-translation.ts), then the missing slots injected in that shape: real
|
|
310
|
+
* executable definitions for OMP-like callers (ask/task/todo/hub/lsp present,
|
|
311
|
+
* or glob without find, detected on pre-translation caller names), empty
|
|
312
|
+
* no-ops for Pi. Returns the original stream flag, caller-tools flag, whether
|
|
313
|
+
* injection added tools, and the bounded per-request restore records
|
|
314
|
+
* (caseRestore, findGlob, injected, injectedReal) for downstream cloaking.
|
|
315
|
+
* Cloaking strips by `injected` (truly-empty placeholders) only: names in
|
|
316
|
+
* `injectedReal` were served with executable definitions, so model calls to
|
|
317
|
+
* them execute downstream instead of being cloaked.
|
|
318
|
+
*/
|
|
319
|
+
export function enforceOpencodeFingerprint(
|
|
320
|
+
body: Record<string, unknown>,
|
|
321
|
+
pathname: string,
|
|
322
|
+
): {
|
|
323
|
+
clientRequestedStream: boolean;
|
|
324
|
+
callerHadTools: boolean;
|
|
325
|
+
addedTools: boolean;
|
|
326
|
+
caseRestore: CaseRestoreMap;
|
|
327
|
+
findGlob: FindGlobRestore;
|
|
328
|
+
injected: string[];
|
|
329
|
+
injectedReal: string[];
|
|
330
|
+
} {
|
|
331
|
+
const clientRequestedStream = body.stream === true;
|
|
332
|
+
const callerHadTools = Array.isArray(body.tools) && body.tools.length > 0;
|
|
333
|
+
// Zen responses shape conformance first (Chat leftovers, tool_choice none,
|
|
334
|
+
// spark effort clamp, store default): Kilo/Cline bodies never reach here.
|
|
335
|
+
if (pathname.endsWith("/responses")) normalizeResponsesBody(body);
|
|
336
|
+
// Upstream Zen free tier mandates stream: true for all free requests
|
|
337
|
+
body.stream = true;
|
|
338
|
+
|
|
339
|
+
const caseRestore = normalizePlaceholderCase(body);
|
|
340
|
+
retargetToolChoice(body);
|
|
341
|
+
|
|
342
|
+
const findGlob = buildFindGlobRestore(Array.isArray(body.tools) ? body.tools : []);
|
|
343
|
+
// Caller tool_choice naming `find` must ride upstream as `glob` — the rename
|
|
344
|
+
// above collapses the declaration, so an unretargeted choice dangles.
|
|
345
|
+
if (body.tool_choice !== undefined) {
|
|
346
|
+
body.tool_choice = retargetToolChoiceForUpstream(body.tool_choice, findGlob) as Record<string, unknown> | string;
|
|
347
|
+
}
|
|
348
|
+
const callerUpstream = new Set<string>();
|
|
349
|
+
const callerNames = new Set<string>();
|
|
350
|
+
if (Array.isArray(body.tools)) {
|
|
351
|
+
for (const tool of body.tools) {
|
|
352
|
+
const n = toolNameOf(tool);
|
|
353
|
+
if (n) {
|
|
354
|
+
callerNames.add(n.toLowerCase());
|
|
355
|
+
callerUpstream.add(upstreamToolNameFor(n).toLowerCase());
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
// OMP detection runs on pre-translation names: find is still visible here,
|
|
360
|
+
// so Pi find-callers are never mistaken for OMP glob-callers.
|
|
361
|
+
const ompLike = isOmpLikeCaller(callerNames);
|
|
362
|
+
const missing = OPENCODE_FINGERPRINT_TOOLS.filter((n) => !callerUpstream.has(n.toLowerCase()));
|
|
363
|
+
// Cloak list carries only the truly-empty placeholders; real definitions
|
|
364
|
+
// survive downstream so injected OMP calls execute.
|
|
365
|
+
const injected = ompLike ? [] : [...missing];
|
|
366
|
+
const injectedReal = ompLike ? [...missing] : [];
|
|
367
|
+
|
|
368
|
+
if (Array.isArray(body.tools)) {
|
|
369
|
+
body.tools = translateToolsForPath(body.tools, pathname);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const before = Array.isArray(body.tools) ? body.tools.length : 0;
|
|
373
|
+
if (pathname.endsWith("/responses")) {
|
|
374
|
+
ensureResponsesFingerprintTools(body, ompLike);
|
|
375
|
+
} else if (pathname.endsWith("/messages")) {
|
|
376
|
+
ensureMessagesFingerprintTools(body, ompLike);
|
|
377
|
+
} else {
|
|
378
|
+
ensureChatFingerprintTools(body, ompLike);
|
|
379
|
+
}
|
|
380
|
+
const after = Array.isArray(body.tools) ? body.tools.length : 0;
|
|
381
|
+
|
|
382
|
+
// Note: Upstream OpenCode Zen explicitly rejects any tool_choice other than "auto"
|
|
383
|
+
// with HTTP 400 (only "auto" is supported). tool_choice is auto-or-absent here:
|
|
384
|
+
// normalizeResponsesBody drops "none" upstream, and we never impose a choice.
|
|
385
|
+
// Empty-placeholder callers stay silent via the COMPAT description and the
|
|
386
|
+
// output-aggregator; real OMP definitions execute normally.
|
|
387
|
+
|
|
388
|
+
return { clientRequestedStream, callerHadTools, addedTools: after > before, caseRestore, findGlob, injected, injectedReal };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Parse raw SSE stream text into individual event blocks.
|
|
393
|
+
*/
|
|
394
|
+
export function parseSseEvents(text: string): Array<{ event?: string; data: string }> {
|
|
395
|
+
const result: Array<{ event?: string; data: string }> = [];
|
|
396
|
+
const blocks = text.split(/\r?\n\r?\n/);
|
|
397
|
+
for (const block of blocks) {
|
|
398
|
+
const trimmed = block.trim();
|
|
399
|
+
if (!trimmed) continue;
|
|
400
|
+
let eventName: string | undefined;
|
|
401
|
+
const dataLines: string[] = [];
|
|
402
|
+
for (const line of trimmed.split(/\r?\n/)) {
|
|
403
|
+
if (line.startsWith("event:")) {
|
|
404
|
+
eventName = line.slice(6).trim();
|
|
405
|
+
} else if (line.startsWith("data:")) {
|
|
406
|
+
dataLines.push(line.slice(5).trimStart());
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
if (dataLines.length > 0) {
|
|
410
|
+
result.push({ event: eventName, data: dataLines.join("\n") });
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
return result;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Convert an SSE stream from an OpenAI-compatible Chat Completions endpoint
|
|
418
|
+
* into a single standard non-streaming ChatCompletion JSON response.
|
|
419
|
+
*/
|
|
420
|
+
/** Tool-call name from a Chat Completions tool_calls entry (else ""). */
|
|
421
|
+
function chatCallName(tc: unknown): string {
|
|
422
|
+
if (!tc || typeof tc !== "object" || Array.isArray(tc)) return "";
|
|
423
|
+
const fn = (tc as Record<string, unknown>).function;
|
|
424
|
+
if (!fn || typeof fn !== "object" || Array.isArray(fn)) return "";
|
|
425
|
+
const n = (fn as Record<string, unknown>).name;
|
|
426
|
+
return typeof n === "string" ? n : "";
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Strip injected placeholder calls from a Chat Completions message in place.
|
|
431
|
+
* Tool-less callers never see tool_calls (args folded to text when content is
|
|
432
|
+
* empty); callers with tools keep only their own calls, casing restored.
|
|
433
|
+
*/
|
|
434
|
+
/**
|
|
435
|
+
* Only names this request actually injected are ever cloaked downstream. The
|
|
436
|
+
* caller's own bash is indistinguishable by name and must survive; legacy
|
|
437
|
+
* direct calls (no record) keep everything.
|
|
438
|
+
*/
|
|
439
|
+
function cloakChatMessage(
|
|
440
|
+
msg: unknown,
|
|
441
|
+
callerHadTools: boolean,
|
|
442
|
+
caseRestore?: CaseRestoreMap,
|
|
443
|
+
findGlob?: FindGlobRestore,
|
|
444
|
+
injected?: readonly string[],
|
|
445
|
+
): void {
|
|
446
|
+
if (!msg || typeof msg !== "object" || Array.isArray(msg)) return;
|
|
447
|
+
const m = msg as Record<string, unknown>;
|
|
448
|
+
if (!Array.isArray(m.tool_calls)) return;
|
|
449
|
+
const calls = m.tool_calls as unknown[];
|
|
450
|
+
if (!callerHadTools) {
|
|
451
|
+
if ((!m.content || m.content === "") && calls.length > 0) {
|
|
452
|
+
m.content = calls
|
|
453
|
+
.map((tc) => {
|
|
454
|
+
if (!tc || typeof tc !== "object" || Array.isArray(tc)) return "";
|
|
455
|
+
const fn = (tc as Record<string, unknown>).function;
|
|
456
|
+
if (!fn || typeof fn !== "object" || Array.isArray(fn)) return "";
|
|
457
|
+
const f = fn as Record<string, unknown>;
|
|
458
|
+
return (typeof f.arguments === "string" && f.arguments) || (typeof f.name === "string" && f.name) || "";
|
|
459
|
+
})
|
|
460
|
+
.join("\n");
|
|
461
|
+
}
|
|
462
|
+
delete m.tool_calls;
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
const kept: unknown[] = [];
|
|
466
|
+
for (const tc of calls) {
|
|
467
|
+
const callName = chatCallName(tc);
|
|
468
|
+
if (callName && injected !== undefined && injected.includes(callName.toLowerCase())) continue;
|
|
469
|
+
if (tc && typeof tc === "object" && !Array.isArray(tc)) {
|
|
470
|
+
const fn = (tc as Record<string, unknown>).function;
|
|
471
|
+
if (fn && typeof fn === "object" && !Array.isArray(fn) && typeof (fn as Record<string, unknown>).name === "string") {
|
|
472
|
+
(fn as Record<string, unknown>).name = restoreCallerName(
|
|
473
|
+
(fn as Record<string, unknown>).name as string,
|
|
474
|
+
caseRestore,
|
|
475
|
+
findGlob,
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
kept.push(tc);
|
|
480
|
+
}
|
|
481
|
+
if (kept.length > 0) m.tool_calls = kept;
|
|
482
|
+
else delete m.tool_calls;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
export function sseToChatCompletionJson(
|
|
486
|
+
sseText: string,
|
|
487
|
+
callerHadTools = true,
|
|
488
|
+
caseRestore?: CaseRestoreMap,
|
|
489
|
+
findGlob?: FindGlobRestore,
|
|
490
|
+
injected?: readonly string[],
|
|
491
|
+
): Record<string, unknown> {
|
|
492
|
+
const events = parseSseEvents(sseText);
|
|
493
|
+
let id = "chatcmpl-freeflow";
|
|
494
|
+
let model = "";
|
|
495
|
+
let created = Math.floor(Date.now() / 1000);
|
|
496
|
+
let finishReason: string | null = null;
|
|
497
|
+
let content = "";
|
|
498
|
+
let reasoningContent = "";
|
|
499
|
+
let role = "assistant";
|
|
500
|
+
let usage: unknown = undefined;
|
|
501
|
+
const toolCallsMap = new Map<
|
|
502
|
+
number,
|
|
503
|
+
{ id: string; type: string; function: { name: string; arguments: string } }
|
|
504
|
+
>();
|
|
505
|
+
|
|
506
|
+
for (const ev of events) {
|
|
507
|
+
if (ev.data === "[DONE]") continue;
|
|
508
|
+
try {
|
|
509
|
+
const parsed = JSON.parse(ev.data);
|
|
510
|
+
if (parsed.id) id = parsed.id;
|
|
511
|
+
if (parsed.model) model = parsed.model;
|
|
512
|
+
if (parsed.created) created = parsed.created;
|
|
513
|
+
if (parsed.usage) usage = parsed.usage;
|
|
514
|
+
if (Array.isArray(parsed.choices)) {
|
|
515
|
+
for (const c of parsed.choices) {
|
|
516
|
+
if (c.finish_reason) finishReason = c.finish_reason;
|
|
517
|
+
const delta = c.delta;
|
|
518
|
+
if (delta) {
|
|
519
|
+
if (delta.role) role = delta.role;
|
|
520
|
+
if (typeof delta.content === "string") content += delta.content;
|
|
521
|
+
if (typeof delta.reasoning_content === "string") {
|
|
522
|
+
reasoningContent += delta.reasoning_content;
|
|
523
|
+
} else if (typeof delta.reasoning === "string") {
|
|
524
|
+
reasoningContent += delta.reasoning;
|
|
525
|
+
}
|
|
526
|
+
if (Array.isArray(delta.tool_calls)) {
|
|
527
|
+
for (const tc of delta.tool_calls) {
|
|
528
|
+
const idx = tc.index ?? 0;
|
|
529
|
+
const existing = toolCallsMap.get(idx) ?? {
|
|
530
|
+
id: tc.id || "",
|
|
531
|
+
type: tc.type || "function",
|
|
532
|
+
function: { name: "", arguments: "" },
|
|
533
|
+
};
|
|
534
|
+
if (tc.id) existing.id = tc.id;
|
|
535
|
+
if (tc.type) existing.type = tc.type;
|
|
536
|
+
if (tc.function?.name) existing.function.name += tc.function.name;
|
|
537
|
+
if (tc.function?.arguments) existing.function.arguments += tc.function.arguments;
|
|
538
|
+
toolCallsMap.set(idx, existing);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
// Choice contains a pre-assembled message: cloak before returning.
|
|
543
|
+
if (c.message) {
|
|
544
|
+
cloakChatMessage(c.message, callerHadTools, caseRestore, findGlob, injected);
|
|
545
|
+
return parsed as Record<string, unknown>;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
} catch {
|
|
550
|
+
// ignore unparseable data chunks
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const toolCalls = Array.from(toolCallsMap.entries())
|
|
555
|
+
.sort(([a], [b]) => a - b)
|
|
556
|
+
.map(([, tc]) => tc);
|
|
557
|
+
|
|
558
|
+
const message: Record<string, unknown> = {
|
|
559
|
+
role,
|
|
560
|
+
content: content || null,
|
|
561
|
+
};
|
|
562
|
+
if (reasoningContent) {
|
|
563
|
+
message.reasoning_content = reasoningContent;
|
|
564
|
+
}
|
|
565
|
+
if (!callerHadTools) {
|
|
566
|
+
if (!content && toolCalls.length > 0) {
|
|
567
|
+
message.content = toolCalls.map((tc) => tc.function.arguments || tc.function.name).join("\n");
|
|
568
|
+
}
|
|
569
|
+
} else {
|
|
570
|
+
const kept = injected === undefined
|
|
571
|
+
? toolCalls
|
|
572
|
+
: toolCalls.filter((tc) => !injected.includes(tc.function.name.toLowerCase()));
|
|
573
|
+
for (const tc of kept) tc.function.name = restoreCallerName(tc.function.name, caseRestore, findGlob);
|
|
574
|
+
if (kept.length > 0) message.tool_calls = kept;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
return {
|
|
578
|
+
id,
|
|
579
|
+
object: "chat.completion",
|
|
580
|
+
created,
|
|
581
|
+
model,
|
|
582
|
+
choices: [
|
|
583
|
+
{
|
|
584
|
+
index: 0,
|
|
585
|
+
message,
|
|
586
|
+
finish_reason: finishReason ?? "stop",
|
|
587
|
+
},
|
|
588
|
+
],
|
|
589
|
+
...(usage ? { usage } : {}),
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Strip injected placeholder function_call items from a Responses object in
|
|
595
|
+
* place. Only names this request injected are removed, whether or not the
|
|
596
|
+
* caller declared tools; caller calls keep restored names.
|
|
597
|
+
*/
|
|
598
|
+
function cloakResponsesObject(resp: unknown, caseRestore?: CaseRestoreMap, findGlob?: FindGlobRestore, injected?: readonly string[]): void {
|
|
599
|
+
if (!resp || typeof resp !== "object" || Array.isArray(resp)) return;
|
|
600
|
+
const output = (resp as Record<string, unknown>).output;
|
|
601
|
+
if (!Array.isArray(output)) return;
|
|
602
|
+
const kept: unknown[] = [];
|
|
603
|
+
for (const item of output) {
|
|
604
|
+
if (item && typeof item === "object" && !Array.isArray(item)) {
|
|
605
|
+
const rec = item as Record<string, unknown>;
|
|
606
|
+
if (rec.type === "function_call") {
|
|
607
|
+
if (typeof rec.name === "string" && injected !== undefined && injected.includes(rec.name.toLowerCase())) continue;
|
|
608
|
+
if (typeof rec.name === "string") rec.name = restoreCallerName(rec.name, caseRestore, findGlob);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
kept.push(item);
|
|
612
|
+
}
|
|
613
|
+
(resp as Record<string, unknown>).output = kept;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* Convert an SSE stream from an OpenAI Responses API endpoint into a single
|
|
618
|
+
* standard non-streaming response object.
|
|
619
|
+
*/
|
|
620
|
+
export function sseToResponsesJson(
|
|
621
|
+
sseText: string,
|
|
622
|
+
callerHadTools = true,
|
|
623
|
+
caseRestore?: CaseRestoreMap,
|
|
624
|
+
findGlob?: FindGlobRestore,
|
|
625
|
+
injected?: readonly string[],
|
|
626
|
+
): Record<string, unknown> {
|
|
627
|
+
const events = parseSseEvents(sseText);
|
|
628
|
+
// 1. Highest fidelity: response.completed event carries the final full response object
|
|
629
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
630
|
+
const ev = events[i];
|
|
631
|
+
try {
|
|
632
|
+
const parsed = JSON.parse(ev.data);
|
|
633
|
+
if (parsed?.type === "response.completed" && parsed.response && typeof parsed.response === "object") {
|
|
634
|
+
cloakResponsesObject(parsed.response, caseRestore, findGlob, injected);
|
|
635
|
+
return parsed.response as Record<string, unknown>;
|
|
636
|
+
}
|
|
637
|
+
} catch { }
|
|
638
|
+
}
|
|
639
|
+
// 2. Secondary fallback: check any event with a response object
|
|
640
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
641
|
+
const ev = events[i];
|
|
642
|
+
try {
|
|
643
|
+
const parsed = JSON.parse(ev.data);
|
|
644
|
+
if (parsed?.response && typeof parsed.response === "object") {
|
|
645
|
+
cloakResponsesObject(parsed.response, caseRestore, findGlob, injected);
|
|
646
|
+
return parsed.response as Record<string, unknown>;
|
|
647
|
+
}
|
|
648
|
+
} catch { }
|
|
649
|
+
}
|
|
650
|
+
// 3. Fallback: parse entire string directly if upstream already returned JSON
|
|
651
|
+
try {
|
|
652
|
+
const parsed = JSON.parse(sseText);
|
|
653
|
+
if (parsed && typeof parsed === "object") {
|
|
654
|
+
cloakResponsesObject(parsed, caseRestore, findGlob, injected);
|
|
655
|
+
return parsed;
|
|
656
|
+
}
|
|
657
|
+
} catch { }
|
|
658
|
+
|
|
659
|
+
return {
|
|
660
|
+
id: "resp_fallback",
|
|
661
|
+
object: "response",
|
|
662
|
+
status: "completed",
|
|
663
|
+
output: [],
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* Drop placeholder tool_use blocks from a complete Anthropic message in place.
|
|
668
|
+
* Tool-less callers never see tool_use; callers with tools keep only their own
|
|
669
|
+
* blocks, names restored.
|
|
670
|
+
*/
|
|
671
|
+
function cloakMessagesContent(
|
|
672
|
+
msg: unknown,
|
|
673
|
+
callerHadTools: boolean,
|
|
674
|
+
caseRestore?: CaseRestoreMap,
|
|
675
|
+
findGlob?: FindGlobRestore,
|
|
676
|
+
injected?: readonly string[],
|
|
677
|
+
): void {
|
|
678
|
+
if (!msg || typeof msg !== "object" || Array.isArray(msg)) return;
|
|
679
|
+
const content = (msg as Record<string, unknown>).content;
|
|
680
|
+
if (!Array.isArray(content)) return;
|
|
681
|
+
const kept: unknown[] = [];
|
|
682
|
+
for (const block of content) {
|
|
683
|
+
if (
|
|
684
|
+
block &&
|
|
685
|
+
typeof block === "object" &&
|
|
686
|
+
!Array.isArray(block) &&
|
|
687
|
+
(block as Record<string, unknown>).type === "tool_use"
|
|
688
|
+
) {
|
|
689
|
+
const rec = block as Record<string, unknown>;
|
|
690
|
+
if (typeof rec.name === "string" && injected !== undefined && injected.includes(rec.name.toLowerCase())) continue;
|
|
691
|
+
if (callerHadTools) {
|
|
692
|
+
if (typeof rec.name === "string") rec.name = restoreCallerName(rec.name, caseRestore, findGlob);
|
|
693
|
+
kept.push(block);
|
|
694
|
+
}
|
|
695
|
+
continue;
|
|
696
|
+
}
|
|
697
|
+
kept.push(block);
|
|
698
|
+
}
|
|
699
|
+
(msg as Record<string, unknown>).content = kept;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* Convert an SSE stream from an Anthropic Messages endpoint into a single
|
|
704
|
+
* standard non-streaming message object. Aggregates content_block deltas
|
|
705
|
+
* (text + tool_use input_json) into content blocks.
|
|
706
|
+
*/
|
|
707
|
+
export function sseToMessagesJson(
|
|
708
|
+
sseText: string,
|
|
709
|
+
callerHadTools = true,
|
|
710
|
+
caseRestore?: CaseRestoreMap,
|
|
711
|
+
findGlob?: FindGlobRestore,
|
|
712
|
+
injected?: readonly string[],
|
|
713
|
+
): Record<string, unknown> {
|
|
714
|
+
const events = parseSseEvents(sseText);
|
|
715
|
+
let id = "msg_freeflow";
|
|
716
|
+
let model = "";
|
|
717
|
+
let stopReason: string | null = null;
|
|
718
|
+
let usage: unknown = undefined;
|
|
719
|
+
const textByIndex = new Map<number, string>();
|
|
720
|
+
const toolByIndex = new Map<number, { id: string; name: string; inputJson: string }>();
|
|
721
|
+
|
|
722
|
+
for (const ev of events) {
|
|
723
|
+
if (ev.data === "[DONE]") continue;
|
|
724
|
+
let parsed: Record<string, unknown>;
|
|
725
|
+
try {
|
|
726
|
+
parsed = JSON.parse(ev.data) as Record<string, unknown>;
|
|
727
|
+
} catch {
|
|
728
|
+
continue;
|
|
729
|
+
}
|
|
730
|
+
const type = typeof parsed.type === "string" ? parsed.type : "";
|
|
731
|
+
if (type === "message_start") {
|
|
732
|
+
const msg = parsed.message as Record<string, unknown> | undefined;
|
|
733
|
+
if (msg) {
|
|
734
|
+
if (typeof msg.id === "string") id = msg.id;
|
|
735
|
+
if (typeof msg.model === "string") model = msg.model;
|
|
736
|
+
}
|
|
737
|
+
continue;
|
|
738
|
+
}
|
|
739
|
+
if (type === "content_block_start") {
|
|
740
|
+
const index = typeof parsed.index === "number" ? parsed.index : 0;
|
|
741
|
+
const block = parsed.content_block as Record<string, unknown> | undefined;
|
|
742
|
+
const blockType = block && typeof block.type === "string" ? block.type : "";
|
|
743
|
+
if (blockType === "tool_use") {
|
|
744
|
+
toolByIndex.set(index, {
|
|
745
|
+
id: typeof block?.id === "string" ? (block.id as string) : "",
|
|
746
|
+
name: typeof block?.name === "string" ? (block.name as string) : "",
|
|
747
|
+
inputJson: "",
|
|
748
|
+
});
|
|
749
|
+
} else if (!toolByIndex.has(index)) {
|
|
750
|
+
textByIndex.set(index, typeof block?.text === "string" ? (block.text as string) : "");
|
|
751
|
+
}
|
|
752
|
+
continue;
|
|
753
|
+
}
|
|
754
|
+
if (type === "content_block_delta") {
|
|
755
|
+
const index = typeof parsed.index === "number" ? parsed.index : 0;
|
|
756
|
+
const delta = parsed.delta as Record<string, unknown> | undefined;
|
|
757
|
+
const deltaType = delta && typeof delta.type === "string" ? delta.type : "";
|
|
758
|
+
if (deltaType === "text_delta" && typeof delta?.text === "string") {
|
|
759
|
+
textByIndex.set(index, (textByIndex.get(index) ?? "") + (delta.text as string));
|
|
760
|
+
} else if (deltaType === "input_json_delta" && typeof delta?.partial_json === "string") {
|
|
761
|
+
const existing = toolByIndex.get(index) ?? { id: "", name: "", inputJson: "" };
|
|
762
|
+
existing.inputJson += delta.partial_json as string;
|
|
763
|
+
toolByIndex.set(index, existing);
|
|
764
|
+
}
|
|
765
|
+
continue;
|
|
766
|
+
}
|
|
767
|
+
if (type === "message_delta") {
|
|
768
|
+
const delta = parsed.delta as Record<string, unknown> | undefined;
|
|
769
|
+
if (delta && typeof delta.stop_reason === "string") stopReason = delta.stop_reason as string;
|
|
770
|
+
if (parsed.usage !== undefined) usage = parsed.usage;
|
|
771
|
+
continue;
|
|
772
|
+
}
|
|
773
|
+
if (type === "message" && parsed.role !== undefined) {
|
|
774
|
+
cloakMessagesContent(parsed, callerHadTools, caseRestore, findGlob, injected);
|
|
775
|
+
return parsed;
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
const content: Record<string, unknown>[] = [];
|
|
780
|
+
const order = Array.from(new Set([...textByIndex.keys(), ...toolByIndex.keys()])).sort((a, b) => a - b);
|
|
781
|
+
for (const index of order) {
|
|
782
|
+
const tool = toolByIndex.get(index);
|
|
783
|
+
if (tool && (tool.id || tool.name)) {
|
|
784
|
+
if (callerHadTools && (injected === undefined || !injected.includes(tool.name.toLowerCase()))) {
|
|
785
|
+
let input: unknown = {};
|
|
786
|
+
try {
|
|
787
|
+
input = tool.inputJson ? JSON.parse(tool.inputJson) : {};
|
|
788
|
+
} catch {
|
|
789
|
+
input = {};
|
|
790
|
+
}
|
|
791
|
+
content.push({ type: "tool_use", id: tool.id, name: restoreCallerName(tool.name, caseRestore, findGlob), input });
|
|
792
|
+
}
|
|
793
|
+
continue;
|
|
794
|
+
}
|
|
795
|
+
const text = textByIndex.get(index) ?? "";
|
|
796
|
+
if (text) content.push({ type: "text", text });
|
|
797
|
+
}
|
|
798
|
+
if (!callerHadTools && content.length === 0 && toolByIndex.size > 0) {
|
|
799
|
+
const fallback = Array.from(toolByIndex.values())
|
|
800
|
+
.map((t) => t.inputJson || t.name)
|
|
801
|
+
.join("\n");
|
|
802
|
+
if (fallback) content.push({ type: "text", text: fallback });
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
return {
|
|
806
|
+
id,
|
|
807
|
+
type: "message",
|
|
808
|
+
role: "assistant",
|
|
809
|
+
model,
|
|
810
|
+
content,
|
|
811
|
+
stop_reason: stopReason ?? "end_turn",
|
|
812
|
+
...(usage ? { usage } : {}),
|
|
813
|
+
};
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* Universal SSE-to-JSON aggregator. If input is not SSE, returns it untouched.
|
|
818
|
+
*/
|
|
819
|
+
export function convertSseToJson(
|
|
820
|
+
sseText: string,
|
|
821
|
+
pathname: string,
|
|
822
|
+
callerHadTools = true,
|
|
823
|
+
caseRestore?: CaseRestoreMap,
|
|
824
|
+
findGlob?: FindGlobRestore,
|
|
825
|
+
injected?: readonly string[],
|
|
826
|
+
): string {
|
|
827
|
+
if (!sseText || typeof sseText !== "string") return sseText;
|
|
828
|
+
const trimmed = sseText.trim();
|
|
829
|
+
if (!trimmed.includes("data:")) {
|
|
830
|
+
return sseText;
|
|
831
|
+
}
|
|
832
|
+
try {
|
|
833
|
+
if (pathname.endsWith("/responses")) {
|
|
834
|
+
return JSON.stringify(sseToResponsesJson(trimmed, callerHadTools, caseRestore, findGlob, injected));
|
|
835
|
+
}
|
|
836
|
+
if (pathname.endsWith("/messages")) {
|
|
837
|
+
return JSON.stringify(sseToMessagesJson(trimmed, callerHadTools, caseRestore, findGlob, injected));
|
|
838
|
+
}
|
|
839
|
+
return JSON.stringify(sseToChatCompletionJson(trimmed, callerHadTools, caseRestore, findGlob, injected));
|
|
840
|
+
} catch {
|
|
841
|
+
return sseText;
|
|
842
|
+
}
|
|
843
|
+
return sseText;
|
|
844
|
+
}
|